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()