feat(stock): NXT 시간외 거래가를 정규장 마감 후 자동 연결

네이버 모바일 주식 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 19:32:10 +09:00
parent 134e628e5e
commit a826e00399
4 changed files with 473 additions and 26 deletions

240
stock-lab/API_SPEC.md Normal file
View File

@@ -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:0015:30) 실시간 가격
> - `NXT_PRE` — NXT 프리마켓(08:0008:50) 거래가
> - `NXT_AFTER` — NXT 애프터마켓(15:3020: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
```

View File

@@ -22,7 +22,7 @@ from .db import (
add_sell_history, get_sell_history, update_sell_history, delete_sell_history, add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
) )
from .scraper import fetch_market_news, fetch_major_indices 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 from .ai_summarizer import summarize_news, OllamaError
app = FastAPI() app = FastAPI()
@@ -319,7 +319,7 @@ def get_portfolio():
} }
tickers = list({item["ticker"] for item in items}) tickers = list({item["ticker"] for item in items})
prices = get_current_prices(tickers) details = get_current_prices_detail(tickers)
holdings = [] holdings = []
total_buy = 0 # 요약 표시용 (purchase_price 기반) total_buy = 0 # 요약 표시용 (purchase_price 기반)
@@ -327,7 +327,10 @@ def get_portfolio():
total_eval = 0 total_eval = 0
for item in items: 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: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준 # avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준
# purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백) # purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백)
purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["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"], "avg_price": item["avg_price"],
"purchase_price": purchase_price, "purchase_price": purchase_price,
"current_price": current_price, "current_price": current_price,
"price_session": price_session,
"price_as_of": price_as_of,
"eval_amount": eval_amount, "eval_amount": eval_amount,
"profit_amount": profit_amount, "profit_amount": profit_amount,
"profit_rate": profit_rate, "profit_rate": profit_rate,

View File

@@ -3,7 +3,8 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from typing import Optional 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분 _CACHE_TTL = 180 # 3분
_HEADERS = { _HEADERS = {
@@ -15,22 +16,74 @@ _HEADERS = {
} }
def _fetch_from_mobile_api(ticker: str) -> Optional[int]: def _parse_price_str(value) -> Optional[int]:
"""네이버 모바일 주식 API로 현재가 조회""" 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" url = f"https://m.stock.naver.com/api/stock/{ticker}/basic"
try: try:
resp = requests.get(url, headers=_HEADERS, timeout=5) resp = requests.get(url, headers=_HEADERS, timeout=5)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() return 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
except Exception: except Exception:
return None return None
def _fetch_from_html_fallback(ticker: str) -> Optional[int]: def _fetch_close_price_from_html(ticker: str) -> Optional[int]:
"""네이버 금융 HTML 폴백 (.no_today .blind 파싱)""" """네이버 금융 HTML 폴백 (정규장 종가만 가능, NXT 정보 없음)."""
url = f"https://finance.naver.com/item/main.naver?code={ticker}" url = f"https://finance.naver.com/item/main.naver?code={ticker}"
try: try:
resp = requests.get(url, headers=_HEADERS, timeout=5) 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") soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949")
tag = soup.select_one(".no_today .blind") tag = soup.select_one(".no_today .blind")
if tag: if tag:
price_str = tag.get_text(strip=True).replace(",", "") return _parse_price_str(tag.get_text(strip=True))
return int(price_str) if price_str.isdigit() else None
return None return None
except Exception: except Exception:
return None return None
def get_current_price(ticker: str) -> Optional[int]: def get_current_price_info(ticker: str) -> Optional[dict]:
"""단건 현재가 조회 (3분 캐시)""" """단건 상세 가격 정보 조회 (3분 캐시).
반환: {"price": int | None, "session": str, "as_of": str | None} | None
"""
now = time.time() now = time.time()
cached = _cache.get(ticker) cached = _cache.get(ticker)
if cached and (now - cached[1]) < _CACHE_TTL: if cached and (now - cached[1]) < _CACHE_TTL:
return cached[0] return cached[0]
price = _fetch_from_mobile_api(ticker) detail: Optional[dict] = None
if price is None: payload = _fetch_mobile_api_payload(ticker)
price = _fetch_from_html_fallback(ticker) if isinstance(payload, dict):
detail = _select_price_from_response(payload)
if detail.get("price") is None:
detail = None # 폴백 시도
_cache[ticker] = (price, now) if detail is None:
return price 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]]: def get_current_prices(tickers: list[str]) -> dict[str, Optional[int]]:
"""배치 현재가 조회 (캐시 미스 종목만 실제 호출)""" """배치 현재가 조회 — 호환용."""
result: dict[str, Optional[int]] = {} return {ticker: get_current_price(ticker) for ticker in tickers}
for ticker in tickers:
result[ticker] = get_current_price(ticker)
return result

View File

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