feat(market): 대시보드 전체시장 지수 바 (KOSPI·KOSDAQ·KOSPI200·S&P500·NASDAQ)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 11:41:37 +09:00
parent 9de0715165
commit b4e958b477
6 changed files with 392 additions and 1 deletions

View File

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