From b4e958b477a25e8ac29db5d38ec9f526bdec8657 Mon Sep 17 00:00:00 2001 From: shuim Date: Thu, 1 Oct 2026 11:41:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(market):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=EC=B2=B4=EC=8B=9C=EC=9E=A5=20=EC=A7=80?= =?UTF-8?q?=EC=88=98=20=EB=B0=94=20(KOSPI=C2=B7KOSDAQ=C2=B7KOSPI200=C2=B7S?= =?UTF-8?q?&P500=C2=B7NASDAQ)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/market/indices — 네이버 JSON API(비-KIS), 45초 캐시 - 부분 실패 시 직전값 병합, 전부 실패 시 12초 쿨다운 + 직전 캐시 반환 - 방향 code 해석 불가 시 원 부호 폴백, NaN/inf 거부 - MarketIndexBar: 60초 갱신, 상승 빨강/하락 파랑, 장 마감 배지 - 테스트 12건 (전체 877 passed) Co-Authored-By: Claude Opus 5.5 --- backend/app/api/__init__.py | 6 +- backend/app/api/endpoints/market_indices.py | 22 +++ backend/app/services/market_indices.py | 129 +++++++++++++++ backend/tests/test_market_indices.py | 174 ++++++++++++++++++++ frontend/src/components/MarketIndexBar.tsx | 60 +++++++ frontend/src/pages/Dashboard.tsx | 2 + 6 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/endpoints/market_indices.py create mode 100644 backend/app/services/market_indices.py create mode 100644 backend/tests/test_market_indices.py create mode 100644 frontend/src/components/MarketIndexBar.tsx diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index f1aa616..893b07b 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -14,7 +14,7 @@ from app.api.endpoints import ( monitoring, tensorflow_predictions, debug, risk_management, ml_ensemble, journals, day_trading, strategy_config, backtest, analysis, swing_portfolio, daytrading_v2, us_trading, kr_backtest, - market_intelligence, market_playbook, news, + market_intelligence, market_playbook, news, market_indices, adaptive_strategy, manual_buy, auth, kis_credentials, ) @@ -85,6 +85,10 @@ api_router.include_router( market_data.router, prefix="/market-data", tags=["market_data"], dependencies=[Depends(optional_api_key)], ) +api_router.include_router( + market_indices.router, prefix="/market", tags=["market_indices"], + dependencies=[Depends(optional_api_key)], +) api_router.include_router( analysis.router, prefix="/analysis", tags=["analysis"], dependencies=[Depends(optional_api_key)], diff --git a/backend/app/api/endpoints/market_indices.py b/backend/app/api/endpoints/market_indices.py new file mode 100644 index 0000000..dcada5a --- /dev/null +++ b/backend/app/api/endpoints/market_indices.py @@ -0,0 +1,22 @@ +"""전체시장 지수 API (읽기 전용) + +- GET /api/market/indices : KOSPI/KOSDAQ/KOSPI200 + S&P500/NASDAQ +""" +from datetime import datetime + +from fastapi import APIRouter +from fastapi.responses import Response + +from app.core.json_helpers import _dumps +from app.services.market_indices import get_market_indices_cached + +router = APIRouter() + + +@router.get("/indices") +async def get_indices(): + items = await get_market_indices_cached() + return Response( + content=_dumps({"indices": items, "count": len(items), "fetched_at": datetime.now().isoformat()}), + media_type="application/json", + ) diff --git a/backend/app/services/market_indices.py b/backend/app/services/market_indices.py new file mode 100644 index 0000000..b1425bc --- /dev/null +++ b/backend/app/services/market_indices.py @@ -0,0 +1,129 @@ +""" +전체시장 지수 조회 (읽기 전용, 비-KIS) + +- 국내: https://m.stock.naver.com/api/index/{KOSPI|KOSDAQ|KPI200}/basic +- 미국: https://api.stock.naver.com/index/{.INX|.IXIC}/basic +- 인메모리 캐시 (TTL 45초). 실패한 지수는 결과에서 빠지고 warning 로그. +- DB 쓰기·KIS 호출 없음. +""" +import asyncio +import logging +import math +import time +from typing import Any, Dict, List, Optional + +import httpx + +logger = logging.getLogger(__name__) + +_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"} +CACHE_TTL_SEC = 45.0 +FAIL_COOLDOWN_SEC = 12.0 # 전부 실패 시 재조회 억제 + +# (key, 표시명, 시장, URL) +INDEX_SOURCES = [ + ("KOSPI", "코스피", "KR", "https://m.stock.naver.com/api/index/KOSPI/basic"), + ("KOSDAQ", "코스닥", "KR", "https://m.stock.naver.com/api/index/KOSDAQ/basic"), + ("KOSPI200", "코스피200", "KR", "https://m.stock.naver.com/api/index/KPI200/basic"), + ("SP500", "S&P 500", "US", "https://api.stock.naver.com/index/.INX/basic"), + ("NASDAQ", "나스닥", "US", "https://api.stock.naver.com/index/.IXIC/basic"), +] + +_cache: Dict[str, Any] = {"ts": 0.0, "data": None, "by_key": {}, "fail_ts": 0.0} +_lock = asyncio.Lock() + + +def _num(v: Any) -> Optional[float]: + if v is None: + return None + try: + f = float(str(v).replace(",", "").replace("+", "").strip()) + except ValueError: + return None + return f if math.isfinite(f) else None + + +def parse_index_payload(key: str, name: str, market: str, data: Dict) -> Optional[Dict]: + """네이버 지수 basic JSON → 표준 dict. 필수 값 없으면 None. + + KR 응답은 변동값이 부호 없이 오고 방향은 compareToPreviousPrice.code 로 표현된다 + (1 상한/2 상승/3 보합/4 하한/5 하락). US 는 부호가 포함된다 → 절대값 + code 로 통일. + """ + if not isinstance(data, dict): + return None + price = _num(data.get("closePrice")) + if price is None: + return None + raw_change = _num(data.get("compareToPreviousClosePrice")) or 0.0 + raw_pct = _num(data.get("fluctuationsRatio")) or 0.0 + cmp_ = data.get("compareToPreviousPrice") + code = str(cmp_.get("code", "")) if isinstance(cmp_, dict) else "" + if code in ("1", "2"): + change, pct = abs(raw_change), abs(raw_pct) + elif code in ("4", "5"): + change, pct = -abs(raw_change), -abs(raw_pct) + elif code == "3": + change, pct = 0.0, 0.0 + else: # 방향 코드 해석 불가 → 원 값의 부호 유지 + change, pct = raw_change, raw_pct + return { + "code": key, + "name": name, + "market": market, + "price": price, + "change": round(change, 2), + "change_pct": round(pct, 2), + "market_status": data.get("marketStatus"), + "updated_at": data.get("localTradedAt"), + } + + +async def _fetch_one(client: httpx.AsyncClient, key: str, name: str, market: str, url: str) -> Optional[Dict]: + try: + resp = await client.get(url, headers=_HEADERS, timeout=5.0) + resp.raise_for_status() + parsed = parse_index_payload(key, name, market, resp.json()) + if parsed is None: + logger.warning("지수 %s 응답 파싱 불가", key) + return parsed + except Exception as e: + logger.warning("지수 %s 조회 실패: %s", key, e) + return None + + +async def get_market_indices_cached() -> List[Dict]: + """지수 목록. 45초 캐시. + + - 부분 실패: 실패한 key 만 직전 캐시 값으로 병합. + - 전부 실패: 직전 캐시(없으면 [])를 반환하고 FAIL_COOLDOWN_SEC 동안 재조회하지 않는다. + """ + now = time.monotonic() + if _cache["data"] is not None and now - _cache["ts"] < CACHE_TTL_SEC: + return _cache["data"] + if _cache["fail_ts"] and now - _cache["fail_ts"] < FAIL_COOLDOWN_SEC: + return _cache["data"] or [] + async with _lock: + now = time.monotonic() + if _cache["data"] is not None and now - _cache["ts"] < CACHE_TTL_SEC: + return _cache["data"] + if _cache["fail_ts"] and now - _cache["fail_ts"] < FAIL_COOLDOWN_SEC: + return _cache["data"] or [] + async with httpx.AsyncClient(verify=True) as client: + results = await asyncio.gather( + *[_fetch_one(client, k, n, m, u) for k, n, m, u in INDEX_SOURCES] + ) + if not any(results): + logger.warning("지수 전부 조회 실패 — 직전 캐시 반환(%s초 쿨다운)", FAIL_COOLDOWN_SEC) + _cache["fail_ts"] = now + return _cache["data"] or [] + by_key = _cache["by_key"] + merged: List[Dict] = [] + for (k, _n, _m, _u), r in zip(INDEX_SOURCES, results): + if r: + by_key[k] = r + merged.append(r) + elif k in by_key: + logger.warning("지수 %s 실패 — 직전 값 유지", k) + merged.append(by_key[k]) + _cache.update(ts=now, data=merged, fail_ts=0.0) + return merged diff --git a/backend/tests/test_market_indices.py b/backend/tests/test_market_indices.py new file mode 100644 index 0000000..f33e031 --- /dev/null +++ b/backend/tests/test_market_indices.py @@ -0,0 +1,174 @@ +"""전체시장 지수 파서/캐시 테스트 — 외부 HTTP 는 예외 발생형 목.""" +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import json +import pytest +from unittest.mock import patch + +from app.services import market_indices as mi + + +def _kr(close, diff, ratio, code): + return {"closePrice": close, "compareToPreviousClosePrice": diff, "fluctuationsRatio": ratio, + "compareToPreviousPrice": {"code": code}, "marketStatus": "OPEN", "localTradedAt": "t"} + + +def test_parse_kr_rising_unsigned(): + r = mi.parse_index_payload("KOSPI", "코스피", "KR", _kr("6,869.42", "31.38", "0.46", "2")) + assert r["price"] == 6869.42 and r["change"] == 31.38 and r["change_pct"] == 0.46 + + +def test_parse_kr_falling_unsigned_becomes_negative(): + r = mi.parse_index_payload("KOSDAQ", "코스닥", "KR", _kr("879.26", "23.35", "2.73", "5")) + assert r["change"] == -23.35 and r["change_pct"] == -2.73 + + +def test_parse_us_signed_falling(): + r = mi.parse_index_payload("SP500", "S&P 500", "US", _kr("7,651.54", "-19.30", "-0.25", "5")) + assert r["change"] == -19.30 and r["change_pct"] == -0.25 + + +def test_parse_flat_zeroes_change(): + r = mi.parse_index_payload("KOSPI", "코스피", "KR", _kr("100", "0", "0", "3")) + assert r["change"] == 0 and r["change_pct"] == 0 + + +def test_parse_invalid_returns_none(): + assert mi.parse_index_payload("X", "x", "KR", {}) is None + assert mi.parse_index_payload("X", "x", "KR", None) is None + assert mi.parse_index_payload("X", "x", "KR", {"closePrice": "abc"}) is None + + +@pytest.fixture(autouse=True) +def _reset_cache(): + mi._cache.update(ts=0.0, data=None, by_key={}, fail_ts=0.0) + yield + mi._cache.update(ts=0.0, data=None, by_key={}, fail_ts=0.0) + + +class _Resp: + def __init__(self, payload): self._p = payload + def raise_for_status(self): pass + def json(self): return self._p + + +@pytest.mark.asyncio +async def test_partial_failure_and_cache(): + calls = [] + + async def fake_get(self, url, **kw): + calls.append(url) + if "KOSDAQ" in url: + raise RuntimeError("boom") + return _Resp(_kr("100", "1", "1.0", "2")) + + with patch("httpx.AsyncClient.get", fake_get): + items = await mi.get_market_indices_cached() + assert {i["code"] for i in items} == {"KOSPI", "KOSPI200", "SP500", "NASDAQ"} + n = len(calls) + await mi.get_market_indices_cached() + assert len(calls) == n # 캐시 적중 + + +@pytest.mark.asyncio +async def test_endpoint_returns_json(): + from fastapi import FastAPI + import httpx + from app.api.endpoints import market_indices as ep + + async def fake_cached(): + return [{"code": "KOSPI", "name": "코스피", "market": "KR", "price": 1.0, + "change": 0.1, "change_pct": 0.1}] + + app = FastAPI() + app.include_router(ep.router, prefix="/api/market") + with patch.object(ep, "get_market_indices_cached", fake_cached): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as c: + r = await c.get("/api/market/indices") + body = json.loads(r.text) + assert r.status_code == 200 and body["count"] == 1 and body["indices"][0]["code"] == "KOSPI" + + +def test_parse_nan_rejected(): + assert mi.parse_index_payload("X", "x", "KR", _kr("nan", "1", "1", "2")) is None + assert mi.parse_index_payload("X", "x", "KR", _kr("inf", "1", "1", "2")) is None + r = mi.parse_index_payload("X", "x", "KR", _kr("100", "nan", "inf", "2")) + assert r["change"] == 0 and r["change_pct"] == 0 + + +def test_parse_missing_code_keeps_raw_sign(): + d = {"closePrice": "100", "compareToPreviousClosePrice": "-2.5", "fluctuationsRatio": "-1.2"} + r = mi.parse_index_payload("X", "x", "US", d) + assert r["change"] == -2.5 and r["change_pct"] == -1.2 + d["compareToPreviousPrice"] = {"code": "9"} + assert mi.parse_index_payload("X", "x", "US", d)["change"] == -2.5 + + +def _clock(monkeypatch, t): + monkeypatch.setattr(mi.time, "monotonic", lambda: t[0]) + + +@pytest.mark.asyncio +async def test_partial_failure_merges_previous(monkeypatch): + t = [1000.0] + _clock(monkeypatch, t) + fail = {"on": False} + + async def fake_get(self, url, **kw): + if fail["on"] and "KOSDAQ" in url: + raise RuntimeError("boom") + return _Resp(_kr("100", "1", "1.0", "2")) + + with patch("httpx.AsyncClient.get", fake_get): + assert len(await mi.get_market_indices_cached()) == 5 + t[0] += 50 + fail["on"] = True + items = await mi.get_market_indices_cached() + assert "KOSDAQ" in {i["code"] for i in items} and len(items) == 5 + + +@pytest.mark.asyncio +async def test_all_fail_returns_stale_and_cooldown(monkeypatch): + t = [1000.0] + _clock(monkeypatch, t) + calls = [] + fail = {"on": False} + + async def fake_get(self, url, **kw): + calls.append(url) + if fail["on"]: + raise RuntimeError("down") + return _Resp(_kr("100", "1", "1.0", "2")) + + with patch("httpx.AsyncClient.get", fake_get): + await mi.get_market_indices_cached() + t[0] += 50 + fail["on"] = True + n = len(calls) + items = await mi.get_market_indices_cached() + assert len(items) == 5 and len(calls) == n + 5 + t[0] += 5 # 쿨다운 내 + assert len(await mi.get_market_indices_cached()) == 5 + assert len(calls) == n + 5 + t[0] += 20 # 쿨다운 경과 -> 재조회 + await mi.get_market_indices_cached() + assert len(calls) == n + 10 + + +@pytest.mark.asyncio +async def test_cold_start_all_fail_not_cached_45s(monkeypatch): + t = [1000.0] + _clock(monkeypatch, t) + state = {"fail": True} + + async def fake_get(self, url, **kw): + if state["fail"]: + raise RuntimeError("down") + return _Resp(_kr("100", "1", "1.0", "2")) + + with patch("httpx.AsyncClient.get", fake_get): + assert await mi.get_market_indices_cached() == [] + state["fail"] = False + t[0] += mi.FAIL_COOLDOWN_SEC + 1 + assert len(await mi.get_market_indices_cached()) == 5 diff --git a/frontend/src/components/MarketIndexBar.tsx b/frontend/src/components/MarketIndexBar.tsx new file mode 100644 index 0000000..b1b19a8 --- /dev/null +++ b/frontend/src/components/MarketIndexBar.tsx @@ -0,0 +1,60 @@ +/** + * MarketIndexBar.tsx — 전체시장 지수 바 (KOSPI/KOSDAQ/KOSPI200/S&P500/NASDAQ) + * 데이터: GET /market/indices (네이버 지수, 서버 45초 캐시) / 폴링 60초 + * 한국 관례: 상승 빨강, 하락 파랑 + */ +import React from 'react' +import { useQuery } from '@tanstack/react-query' +import api from '../lib/api' + +interface IndexItem { + code: string + name: string + market: 'KR' | 'US' + price: number + change: number + change_pct: number + market_status?: string | null +} + +interface IndicesResponse { + indices: IndexItem[] +} + +const MarketIndexBar = React.memo(() => { + const { data } = useQuery({ + queryKey: ['market-indices'], + queryFn: async () => (await api.get('/market/indices')).data, + refetchInterval: 60000, + staleTime: 30000, + }) + + const items = data?.indices ?? [] + if (items.length === 0) return null + + return ( +
+ {items.map((it) => { + const color = it.change > 0 ? 'text-[#ef4444]' : it.change < 0 ? 'text-[#3b82f6]' : 'text-[#8faabf]' + const arrow = it.change > 0 ? '▲' : it.change < 0 ? '▼' : '-' + const sign = it.change > 0 ? '+' : '' + return ( +
+ {it.name} + {it.market_status && it.market_status !== 'OPEN' && ( + 마감 + )} + + {it.price.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + + {arrow} {Math.abs(it.change).toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ({sign}{it.change_pct.toFixed(2)}%) + +
+ ) + })} +
+ ) +}) + +export default MarketIndexBar diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ab3e424..0050f47 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -36,6 +36,7 @@ import OrderbookPanel from '../components/OrderbookPanel' import RecommendationPanel from '../components/RecommendationPanel' import JournalPanel from '../components/JournalPanel' import AccountSetupNotice from '../components/AccountSetupNotice' +import MarketIndexBar from '../components/MarketIndexBar' import { CandleData, Position, TradeHistory } from '../types/trading' import { isUsStock } from '../utils/stockUtils' import './Dashboard.css' @@ -884,6 +885,7 @@ const Dashboard = () => {
+
{([['ALL', '전체'], ['KR', '국내'], ['US', '미국']] as const).map(([value, label]) => (