Compare commits
2 Commits
7e492924c0
...
97f77d568c
| Author | SHA1 | Date | |
|---|---|---|---|
| 97f77d568c | |||
| b4e958b477 |
@@ -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)],
|
||||
|
||||
22
backend/app/api/endpoints/market_indices.py
Normal file
22
backend/app/api/endpoints/market_indices.py
Normal file
@@ -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",
|
||||
)
|
||||
129
backend/app/services/market_indices.py
Normal file
129
backend/app/services/market_indices.py
Normal file
@@ -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
|
||||
174
backend/tests/test_market_indices.py
Normal file
174
backend/tests/test_market_indices.py
Normal 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
|
||||
60
frontend/src/components/MarketIndexBar.tsx
Normal file
60
frontend/src/components/MarketIndexBar.tsx
Normal file
@@ -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<IndicesResponse>({
|
||||
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 (
|
||||
<div className="flex items-center gap-0 mb-2 bg-[#0c1522] border border-[#152035] rounded-sm overflow-x-auto">
|
||||
{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 (
|
||||
<div key={it.code} className="flex items-baseline gap-2 px-3 py-1.5 border-r border-[#152035] last:border-r-0 whitespace-nowrap">
|
||||
<span className="text-[10px] text-[#4a6580]">{it.name}</span>
|
||||
{it.market_status && it.market_status !== 'OPEN' && (
|
||||
<span className="text-[8px] px-1 border border-[#152035] rounded-sm text-[#4a6580]">마감</span>
|
||||
)}
|
||||
<span className={`text-[12px] font-mono font-semibold ${color}`}>
|
||||
{it.price.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className={`text-[10px] font-mono ${color}`}>
|
||||
{arrow} {Math.abs(it.change).toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ({sign}{it.change_pct.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default MarketIndexBar
|
||||
@@ -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 = () => {
|
||||
|
||||
<main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-3 overflow-hidden">
|
||||
<div className="flex-shrink-0">
|
||||
<MarketIndexBar />
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-0 bg-[#0c1522] border border-[#152035] rounded-sm overflow-hidden">
|
||||
{([['ALL', '전체'], ['KR', '국내'], ['US', '미국']] as const).map(([value, label]) => (
|
||||
|
||||
Reference in New Issue
Block a user