From a826e003993ed24753117c285dcb23d5efcf963d Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 11 May 2026 19:32:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20NXT=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=99=B8=20=EA=B1=B0=EB=9E=98=EA=B0=80=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=EC=9E=A5=20=EB=A7=88=EA=B0=90=20=ED=9B=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 네이버 모바일 주식 API의 overMarketPriceInfo를 인식해 NXT 프리/애프터마켓 운영 중이면 overPrice를 current_price로 자동 전환. 포트폴리오 응답에 price_session(REGULAR/NXT_PRE/NXT_AFTER/CLOSED)과 price_as_of 메타 동봉. 이전엔 closePrice만 사용해 15:30 이후 NXT 거래가 진행 중이어도 평가금액이 동결됐음. 이제 가격이 자연스럽게 이어짐. _select_price_from_response는 순수 함수로 분리, unittest 8케이스로 회귀 방지. Co-Authored-By: Claude Opus 4.7 (1M context) --- stock-lab/API_SPEC.md | 240 ++++++++++++++++++++++++++++ stock-lab/app/main.py | 11 +- stock-lab/app/price_fetcher.py | 117 +++++++++++--- stock-lab/app/test_price_fetcher.py | 131 +++++++++++++++ 4 files changed, 473 insertions(+), 26 deletions(-) create mode 100644 stock-lab/API_SPEC.md create mode 100644 stock-lab/app/test_price_fetcher.py diff --git a/stock-lab/API_SPEC.md b/stock-lab/API_SPEC.md new file mode 100644 index 0000000..44b1a70 --- /dev/null +++ b/stock-lab/API_SPEC.md @@ -0,0 +1,240 @@ +# 📈 Stock Lab API Specification +프론트엔드 연동을 위한 주식 서비스 API 명세서입니다. + +**Base URL**: `/api` + +--- + +## 1. 💰 계좌 잔고 조회 +현재 연결된 한국투자증권 계좌의 잔고와 보유 종목을 조회합니다. + +- **URL**: `/trade/balance` +- **Method**: `GET` +- **Description**: Windows AI Server를 통해 실시간 잔고를 가져옵니다. + +### Response Example +```json +{ + "holdings": [ + { + "code": "005930", + "name": "삼성전자", + "qty": 10, + "buy_price": 72000.0, + "current_price": 74500.0, + "profit_rate": 3.47 + } + ], + "summary": { + "total_eval": 15400000, + "deposit": 5000000, + "note": "정상 조회됨" + } +} +``` + +--- + +## 2. 🤖 AI 자동 매매 (분석/주문) +AI에게 현재 잔고와 뉴스를 기반으로 매매 판단을 요청합니다. + +- **URL**: `/trade/auto` +- **Method**: `POST` +- **Description**: 분석에는 수 초~수십 초가 소요될 수 있습니다. (타임아웃 주의) + +### Response Example (성공 - JSON 파싱 완료) +```json +{ + "status": "success", + "decision": { + "action": "BUY", + "ticker": "000660", + "quantity": 10, + "reason": "반도체 업황 개선 뉴스 다수 포착 및 현금 비중 과다" + }, + "trade_result": { + "success": true, + "order_no": "1234567" + } +} +``` + +### Response Example (실패 - AI가 JSON을 안 줬을 때) +AI가 말로 설명하느라 JSON 포맷을 어긴 경우입니다. `raw_response`를 화면에 그대로 보여주는 것을 권장합니다. +```json +{ + "status": "failed_parse", + "raw_response": "잔고 현황을 분석해 보겠습니다...\n결정:\n```\n{\n ... \n}\n```" +} +``` +**Frontend 처리 권장사항**: `status`가 `failed_parse`라면 `raw_response` 텍스트를 `pre` 태그 등으로 그대로 노출하거나, 정규식으로 JSON 부분만 추출하여 보여주세요. + +--- + +## 3. 📰 뉴스 조회 +DB에 저장된 최신 뉴스를 조회합니다. + +- **URL**: `/stock/news` +- **Method**: `GET` +- **Params**: + - `limit`: 개수 (기본 20) + - `category`: `domestic` (국내) | `overseas` (해외) + +### Response Example +```json +[ + { + "id": 105, + "title": "삼성전자, 3분기 영업익 2.4조... 전년비 77% 감소", + "link": "https://n.news.naver.com/...", + "published_at": "2024-09-25T09:00:00", + "sentiment": "negative" + } +] +``` + +--- + +## 4. 📊 지수 조회 +KOSPI, KOSDAQ 등 주요 지수를 조회합니다. + +- **URL**: `/stock/indices` +- **Method**: `GET` + +### Response Example +```json +{ + "KOSPI": { + "value": "2450.55", + "change": "-10.23", + "percent": "-0.42%" + }, + "USD/KRW": { + "value": "1340.50", + "change": "5.00", + "percent": "0.37%" + } +} +``` + +--- + +## 5. 📂 포트폴리오 (수동 입력) + +KB증권·삼성증권 등 Open API 미제공 증권사용. +보유 종목을 수동 등록하면 **현재가는 네이버 금융에서 자동 조회** (3분 캐시)하여 손익을 계산해 반환합니다. + +--- + +### 5-1. 전체 조회 + +- **URL**: `GET /portfolio` +- **Description**: 등록된 모든 종목의 현재가·평가금액·손익을 포함하여 반환합니다. + +#### Response +```json +{ + "holdings": [ + { + "id": 1, + "broker": "KB증권", + "ticker": "005930", + "name": "삼성전자", + "quantity": 100, + "avg_price": 72000, + "current_price": 74500, + "price_session": "NXT_AFTER", + "price_as_of": "2026-05-11T19:21:40+09:00", + "eval_amount": 7450000, + "profit_amount": 250000, + "profit_rate": 3.47 + } + ], + "summary": { + "total_buy": 7200000, + "total_eval": 7450000, + "total_profit": 250000, + "total_profit_rate": 3.47 + } +} +``` + +> **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다. +> 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요. + +> **현재가 출처(`price_session`)**: 정규장 마감 후 NXT 시간외 거래가 진행 중이면 NXT 가격으로 자동 전환됩니다. +> - `REGULAR` — KRX 정규장 진행중(09:00–15:30) 실시간 가격 +> - `NXT_PRE` — NXT 프리마켓(08:00–08:50) 거래가 +> - `NXT_AFTER` — NXT 애프터마켓(15:30–20:00) 거래가 +> - `CLOSED` — 모든 세션 마감, 정규장 종가 노출 +> +> `price_as_of`는 가격이 마지막으로 형성된 시각(ISO 8601, KST). HTML 폴백 경로에서는 `null`일 수 있음. + +--- + +### 5-2. 종목 추가 + +- **URL**: `POST /portfolio` +- **Status**: `201 Created` + +#### Request Body +```json +{ + "broker": "KB증권", + "ticker": "005930", + "name": "삼성전자", + "quantity": 100, + "avg_price": 72000 +} +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| `broker` | string | 증권사명 (자유 입력) | +| `ticker` | string | 종목 코드 6자리 | +| `name` | string | 종목명 | +| `quantity` | integer | 보유 수량 | +| `avg_price` | integer | 평균 매입가 (원) | + +#### Response +```json +{ "id": 1, "ok": true } +``` + +--- + +### 5-3. 종목 수정 + +- **URL**: `PUT /portfolio/{id}` +- **Description**: 변경할 필드만 포함하면 됩니다 (부분 수정). + +#### Request Body (모든 필드 Optional) +```json +{ "quantity": 150 } +``` + +#### Response +```json +{ "ok": true } +``` + +#### Error (존재하지 않는 id) +```json +{ "error": "Item not found" } // HTTP 404 +``` + +--- + +### 5-4. 종목 삭제 + +- **URL**: `DELETE /portfolio/{id}` + +#### Response +```json +{ "ok": true } +``` + +#### Error (존재하지 않는 id) +```json +{ "error": "Item not found" } // HTTP 404 +``` diff --git a/stock-lab/app/main.py b/stock-lab/app/main.py index 8b61cf8..76cedcc 100644 --- a/stock-lab/app/main.py +++ b/stock-lab/app/main.py @@ -22,7 +22,7 @@ from .db import ( add_sell_history, get_sell_history, update_sell_history, delete_sell_history, ) from .scraper import fetch_market_news, fetch_major_indices -from .price_fetcher import get_current_prices +from .price_fetcher import get_current_prices, get_current_prices_detail from .ai_summarizer import summarize_news, OllamaError app = FastAPI() @@ -319,7 +319,7 @@ def get_portfolio(): } tickers = list({item["ticker"] for item in items}) - prices = get_current_prices(tickers) + details = get_current_prices_detail(tickers) holdings = [] total_buy = 0 # 요약 표시용 (purchase_price 기반) @@ -327,7 +327,10 @@ def get_portfolio(): total_eval = 0 for item in items: - current_price = prices.get(item["ticker"]) + detail = details.get(item["ticker"]) + current_price = detail["price"] if detail else None + price_session = detail["session"] if detail else None + price_as_of = detail["as_of"] if detail else None # avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준 # purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백) purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"] @@ -347,6 +350,8 @@ def get_portfolio(): "avg_price": item["avg_price"], "purchase_price": purchase_price, "current_price": current_price, + "price_session": price_session, + "price_as_of": price_as_of, "eval_amount": eval_amount, "profit_amount": profit_amount, "profit_rate": profit_rate, diff --git a/stock-lab/app/price_fetcher.py b/stock-lab/app/price_fetcher.py index 7d87b8b..eb800f3 100644 --- a/stock-lab/app/price_fetcher.py +++ b/stock-lab/app/price_fetcher.py @@ -3,7 +3,8 @@ import requests from bs4 import BeautifulSoup from typing import Optional -_cache: dict[str, tuple[Optional[int], float]] = {} # ticker -> (price, timestamp) +# 캐시는 detail 단위(가격+세션+as_of)로 보관. 호환용 단순 가격은 여기서 추출. +_cache: dict[str, tuple[Optional[dict], float]] = {} # ticker -> (detail | None, timestamp) _CACHE_TTL = 180 # 3분 _HEADERS = { @@ -15,22 +16,74 @@ _HEADERS = { } -def _fetch_from_mobile_api(ticker: str) -> Optional[int]: - """네이버 모바일 주식 API로 현재가 조회""" +def _parse_price_str(value) -> Optional[int]: + if value is None: + return None + s = str(value).replace(",", "").strip() + if not s: + return None + # 음수/소수점도 일단 정수 라운드(국내 주식은 정수) + try: + return int(float(s)) + except ValueError: + return None + + +def _select_price_from_response(payload: dict) -> dict: + """네이버 모바일 주식 API 응답 dict에서 (price, session, as_of)를 결정. + + 세션 분류: + - "REGULAR" : 정규장(KRX) 운영중 — closePrice가 실시간 + - "NXT_PRE" : 정규장 마감 + NXT 프리마켓 운영중 → overPrice 사용 + - "NXT_AFTER" : 정규장 마감 + NXT 애프터마켓 운영중 → overPrice 사용 + - "CLOSED" : 정규장 마감 + NXT 비운영/거래중지 → closePrice 사용 + + 반환 dict: {"price": int | None, "session": str, "as_of": str | None} + """ + close_price = _parse_price_str(payload.get("closePrice") or payload.get("stockEndPrice")) + top_as_of = payload.get("localTradedAt") + + market_status = (payload.get("marketStatus") or "").upper() + if market_status == "OPEN": + return {"price": close_price, "session": "REGULAR", "as_of": top_as_of} + + over = payload.get("overMarketPriceInfo") + if isinstance(over, dict): + over_status = (over.get("overMarketStatus") or "").upper() + trade_stop_name = ((over.get("tradeStopType") or {}).get("name") or "").upper() + if over_status == "OPEN" and trade_stop_name == "TRADING": + over_price = _parse_price_str(over.get("overPrice")) + if over_price is not None: + session_type = (over.get("tradingSessionType") or "").upper() + if session_type == "PRE_MARKET": + session = "NXT_PRE" + elif session_type == "AFTER_MARKET": + session = "NXT_AFTER" + else: + # 알 수 없는 NXT 세션은 보수적으로 AFTER 취급 + session = "NXT_AFTER" + return { + "price": over_price, + "session": session, + "as_of": over.get("localTradedAt") or top_as_of, + } + + return {"price": close_price, "session": "CLOSED", "as_of": top_as_of} + + +def _fetch_mobile_api_payload(ticker: str) -> Optional[dict]: + """네이버 모바일 주식 API 응답 dict 반환.""" url = f"https://m.stock.naver.com/api/stock/{ticker}/basic" try: resp = requests.get(url, headers=_HEADERS, timeout=5) resp.raise_for_status() - data = resp.json() - price_str = data.get("closePrice") or data.get("stockEndPrice") or "" - price_str = str(price_str).replace(",", "").strip() - return int(price_str) if price_str.isdigit() else None + return resp.json() except Exception: return None -def _fetch_from_html_fallback(ticker: str) -> Optional[int]: - """네이버 금융 HTML 폴백 (.no_today .blind 파싱)""" +def _fetch_close_price_from_html(ticker: str) -> Optional[int]: + """네이버 금융 HTML 폴백 (정규장 종가만 가능, NXT 정보 없음).""" url = f"https://finance.naver.com/item/main.naver?code={ticker}" try: resp = requests.get(url, headers=_HEADERS, timeout=5) @@ -38,31 +91,49 @@ def _fetch_from_html_fallback(ticker: str) -> Optional[int]: soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949") tag = soup.select_one(".no_today .blind") if tag: - price_str = tag.get_text(strip=True).replace(",", "") - return int(price_str) if price_str.isdigit() else None + return _parse_price_str(tag.get_text(strip=True)) return None except Exception: return None -def get_current_price(ticker: str) -> Optional[int]: - """단건 현재가 조회 (3분 캐시)""" +def get_current_price_info(ticker: str) -> Optional[dict]: + """단건 상세 가격 정보 조회 (3분 캐시). + + 반환: {"price": int | None, "session": str, "as_of": str | None} | None + """ now = time.time() cached = _cache.get(ticker) if cached and (now - cached[1]) < _CACHE_TTL: return cached[0] - price = _fetch_from_mobile_api(ticker) - if price is None: - price = _fetch_from_html_fallback(ticker) + detail: Optional[dict] = None + payload = _fetch_mobile_api_payload(ticker) + if isinstance(payload, dict): + detail = _select_price_from_response(payload) + if detail.get("price") is None: + detail = None # 폴백 시도 - _cache[ticker] = (price, now) - return price + if detail is None: + fallback_price = _fetch_close_price_from_html(ticker) + if fallback_price is not None: + detail = {"price": fallback_price, "session": "CLOSED", "as_of": None} + + _cache[ticker] = (detail, now) + return detail + + +def get_current_prices_detail(tickers: list[str]) -> dict[str, Optional[dict]]: + """배치 상세 가격 조회 (캐시 미스 종목만 실제 호출).""" + return {ticker: get_current_price_info(ticker) for ticker in tickers} + + +def get_current_price(ticker: str) -> Optional[int]: + """단건 현재가 조회 — 호환용. detail에서 price만 추출.""" + detail = get_current_price_info(ticker) + return detail["price"] if detail else None def get_current_prices(tickers: list[str]) -> dict[str, Optional[int]]: - """배치 현재가 조회 (캐시 미스 종목만 실제 호출)""" - result: dict[str, Optional[int]] = {} - for ticker in tickers: - result[ticker] = get_current_price(ticker) - return result + """배치 현재가 조회 — 호환용.""" + return {ticker: get_current_price(ticker) for ticker in tickers} diff --git a/stock-lab/app/test_price_fetcher.py b/stock-lab/app/test_price_fetcher.py new file mode 100644 index 0000000..febc040 --- /dev/null +++ b/stock-lab/app/test_price_fetcher.py @@ -0,0 +1,131 @@ +"""price_fetcher._select_price_from_response 단위 테스트. + +실행: + cd web-backend/stock-lab + python -m unittest app.test_price_fetcher -v +""" +import os +import sys +import unittest + +# app 패키지를 직접 실행 가능하도록 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.price_fetcher import _select_price_from_response + + +class SelectPriceFromResponseTest(unittest.TestCase): + def test_regular_session_uses_close_price(self): + """정규장 운영 중이면 closePrice를 REGULAR 세션으로 반환.""" + payload = { + "closePrice": "70,500", + "marketStatus": "OPEN", + "localTradedAt": "2026-05-11T11:23:45+09:00", + "overMarketPriceInfo": None, + } + result = _select_price_from_response(payload) + self.assertEqual(result["price"], 70500) + self.assertEqual(result["session"], "REGULAR") + self.assertEqual(result["as_of"], "2026-05-11T11:23:45+09:00") + + def test_nxt_after_market_open_uses_over_price(self): + """정규장 마감 + NXT 애프터마켓 운영중이면 overPrice를 NXT_AFTER 세션으로 반환.""" + payload = { + "closePrice": "285,500", + "marketStatus": "CLOSE", + "localTradedAt": "2026-05-11T15:30:00+09:00", + "overMarketPriceInfo": { + "tradingSessionType": "AFTER_MARKET", + "overMarketStatus": "OPEN", + "overPrice": "285,000", + "localTradedAt": "2026-05-11T19:21:40+09:00", + "tradeStopType": {"name": "TRADING"}, + }, + } + result = _select_price_from_response(payload) + self.assertEqual(result["price"], 285000) + self.assertEqual(result["session"], "NXT_AFTER") + self.assertEqual(result["as_of"], "2026-05-11T19:21:40+09:00") + + def test_nxt_pre_market_open_uses_over_price(self): + """NXT 프리마켓 운영중이면 NXT_PRE 세션 + overPrice.""" + payload = { + "closePrice": "70,500", + "marketStatus": "CLOSE", + "localTradedAt": "2026-05-10T15:30:00+09:00", + "overMarketPriceInfo": { + "tradingSessionType": "PRE_MARKET", + "overMarketStatus": "OPEN", + "overPrice": "70,800", + "localTradedAt": "2026-05-11T08:30:00+09:00", + "tradeStopType": {"name": "TRADING"}, + }, + } + result = _select_price_from_response(payload) + self.assertEqual(result["price"], 70800) + self.assertEqual(result["session"], "NXT_PRE") + self.assertEqual(result["as_of"], "2026-05-11T08:30:00+09:00") + + def test_nxt_closed_falls_back_to_close_price(self): + """NXT가 CLOSE 상태이면 closePrice 사용, 세션은 CLOSED.""" + payload = { + "closePrice": "285,500", + "marketStatus": "CLOSE", + "localTradedAt": "2026-05-11T15:30:00+09:00", + "overMarketPriceInfo": { + "tradingSessionType": "AFTER_MARKET", + "overMarketStatus": "CLOSE", + "overPrice": "285,000", + "tradeStopType": {"name": "TRADING"}, + }, + } + result = _select_price_from_response(payload) + self.assertEqual(result["price"], 285500) + self.assertEqual(result["session"], "CLOSED") + + def test_nxt_trading_halted_falls_back_to_close_price(self): + """NXT OPEN이지만 tradeStopType이 TRADING이 아니면 closePrice 사용.""" + payload = { + "closePrice": "285,500", + "marketStatus": "CLOSE", + "overMarketPriceInfo": { + "tradingSessionType": "AFTER_MARKET", + "overMarketStatus": "OPEN", + "overPrice": "285,000", + "tradeStopType": {"name": "STOP"}, + }, + } + result = _select_price_from_response(payload) + self.assertEqual(result["price"], 285500) + self.assertEqual(result["session"], "CLOSED") + + def test_no_over_market_info_returns_close_price(self): + """overMarketPriceInfo 자체가 없는 경우(해외 종목 등) closePrice 그대로.""" + payload = { + "closePrice": "150,000", + "marketStatus": "CLOSE", + "localTradedAt": "2026-05-11T15:30:00+09:00", + } + result = _select_price_from_response(payload) + self.assertEqual(result["price"], 150000) + self.assertEqual(result["session"], "CLOSED") + + def test_missing_close_price_returns_none(self): + """closePrice가 없거나 비숫자면 price는 None.""" + payload = {"closePrice": "", "marketStatus": "CLOSE"} + result = _select_price_from_response(payload) + self.assertIsNone(result["price"]) + + def test_alternate_stock_end_price_field(self): + """일부 응답은 stockEndPrice 필드를 사용 — 폴백 인식.""" + payload = { + "stockEndPrice": "12,345", + "marketStatus": "OPEN", + } + result = _select_price_from_response(payload) + self.assertEqual(result["price"], 12345) + self.assertEqual(result["session"], "REGULAR") + + +if __name__ == "__main__": + unittest.main()