diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index 5d57e2bb..e90cc5e1 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -12,7 +12,6 @@ import os from datetime import datetime, timedelta -from typing import Optional from zoneinfo import ZoneInfo import yaml diff --git a/main.py b/main.py index 0ff740b3..f0cb4a07 100644 --- a/main.py +++ b/main.py @@ -645,7 +645,7 @@ def run_deploy_check(args) -> int: print(f" 포트폴리오 대비 회전율: {summary['turnover_pct_of_portfolio']}%") for p in summary["plan"][:10]: print(f" {p['action']:4} {p['symbol']} {p['quantity']}주 @ {p['price']:,.0f} = {p['amount']:,.0f}원") - print(f"\n 다음 절차:") + print("\n 다음 절차:") for i, s in enumerate(summary["next_steps"], 1): print(f" {i}. {s}") print() diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py index e2aac2c2..0ac582bb 100644 --- a/monitoring/web_dashboard.py +++ b/monitoring/web_dashboard.py @@ -32,26 +32,37 @@ def _require_aiohttp_web(): def _serialize_snapshots(df): - """DataFrame 스냅샷을 JSON 직렬화 가능한 리스트로 변환""" + """DataFrame 스냅샷을 JSON 직렬화 가능한 리스트로 변환. + + 날짜형은 컬럼을 특정하지 않고 전부 문자열화한다 — 'date'만 처리하던 시절 + created_at 컬럼 추가(일간 수익률 경계용)로 pd.Timestamp가 그대로 새어나가 + /api/snapshots가 매 폴링 500이 나고 수익률 차트가 조용히 죽었다(빈 DF만 + 쓰는 테스트는 통과해서 못 잡던 회귀). + """ if df.empty: return [] out = [] for _, row in df.iterrows(): d = row.to_dict() - if "date" in d and hasattr(d["date"], "strftime"): - d["date"] = d["date"].strftime("%Y-%m-%d") - # numpy 타입 → Python 네이티브 for k, v in d.items(): - if hasattr(v, "item"): + if hasattr(v, "strftime"): # date/datetime/pd.Timestamp + d[k] = v.strftime("%Y-%m-%d %H:%M:%S") if k != "date" else v.strftime("%Y-%m-%d") + elif hasattr(v, "item"): # numpy 타입 → Python 네이티브 d[k] = v.item() out.append(d) return out +_DASH = None # 폴링(10초)마다 Dashboard/PortfolioManager를 새로 만들면 초기화 INFO가 스팸이 된다 + + def get_portfolio_json(current_prices: Optional[dict] = None) -> dict: """현재 포트폴리오 요약을 JSON 친화적 dict로 반환""" + global _DASH config = Config.get() - dash = Dashboard(config=config) + if _DASH is None: + _DASH = Dashboard(config=config) + dash = _DASH summary = dash.portfolio_manager.get_portfolio_summary(current_prices or {}) return { "timestamp": datetime.now().isoformat(), @@ -86,7 +97,6 @@ def get_baskets_json() -> dict: from database.repositories import ( get_all_positions, get_cash_flow_total, - get_latest_snapshot_summary, ) from database.models import PortfolioSnapshot, get_session @@ -216,15 +226,9 @@ def get_runtime_json() -> dict: logger.debug("get_runtime_json read_state: {}", e) out["signals_today"] = None - if out["kis_stats"] is None: - try: - from api.kis_api import KISApi - - out["kis_stats"] = KISApi().get_rate_limit_stats() - out["kis_stats_source"] = "dashboard_process" - except Exception as e: - logger.debug("get_runtime_json KISApi: {}", e) - + # KIS 통계 폴백(대시보드 프로세스에서 KISApi 신규 생성) 제거 — 레이트리미터 + # 상태가 인스턴스별이라 항상 0(아무것도 측정 안 함)에 폴링마다 초기화 로그만 + # 남겼다. 스케줄러 파일에 없으면 정직하게 '조회 불가'로 둔다. return out @@ -460,7 +464,12 @@ def _html_page() -> str: const fmtNum = (n) => Number(n).toLocaleString('ko-KR'); const fmtPct = (n) => (Number(n) >= 0 ? '+' : '') + Number(n).toFixed(2) + '%'; - function escHtml(t) { const d = document.createElement('div'); d.textContent = t == null ? '' : String(t); return d.innerHTML; } + function escHtml(t) { + const d = document.createElement('div'); + d.textContent = t == null ? '' : String(t); + // textContent→innerHTML은 &<>만 이스케이프 — value="..." 속성에도 쓰이므로 따옴표까지. + return d.innerHTML.replace(/"/g, '"').replace(/'/g, '''); + } const card = (label, value, cls) => `
${escHtml(label)}
${value}
`; const stat = (k, v, cls) => `
${escHtml(k)}
${v}
`; @@ -530,7 +539,10 @@ def _html_page() -> str: const baskets = (data && data.baskets) || []; const wanted = baskets.map(b => ({ v: b.account_key, t: b.display_name })); wanted.push({ v: '', t: '기본 계정' }); - if (chartAccountSel.options.length === wanted.length) return; + // 개수만 비교하면 바스켓 교체/개명 시 스테일 옵션이 남는다 — 값 시그니처로 비교. + const sig = wanted.map(w => w.v + '' + w.t).join(''); + if (chartAccountSel.dataset.sig === sig) return; + chartAccountSel.dataset.sig = sig; const prev = chartAccountSel.value; chartAccountSel.innerHTML = wanted.map(w => ``).join(''); chartAccountSel.value = prev && Array.from(chartAccountSel.options).some(o => o.value === prev) ? prev : (wanted[0] ? wanted[0].v : ''); @@ -660,7 +672,7 @@ def _html_page() -> str: if (!has) return; $('positions').innerHTML = ps.map(p => { const cls = p.pnl_rate >= 0 ? 'positive' : 'negative'; - return `${p.symbol || '-'}${p.quantity ?? '-'}${fmtNum(p.avg_price)}${fmtNum(p.current_price)}${fmtNum(p.current_value)}${fmtPct(p.pnl_rate)}`; + return `${escHtml(p.symbol || '-')}${p.quantity ?? '-'}${fmtNum(p.avg_price)}${fmtNum(p.current_price)}${fmtNum(p.current_value)}${fmtPct(p.pnl_rate)}`; }).join(''); } @@ -689,7 +701,9 @@ def _html_page() -> str: const btn = $('depSubmit'); btn.disabled = true; btn.textContent = '기록 중...'; try { const res = await fetch('/api/deposit', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', + // X-Requested-With: 서버의 CSRF 방어(커스텀 헤더 필수)와 한 쌍 + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'quant-dashboard' }, body: JSON.stringify({ basket, amount, note: $('depNote').value || '' }) }); const data = await res.json(); @@ -706,7 +720,13 @@ def _html_page() -> str: } /* ── 폴링 ── */ + let _polling = false; // 오버랩 가드 — 느린 응답(운영 상태 수십 초)이 폴링을 적체시키지 않게 async function fetchData() { + if (_polling) return; + _polling = true; + try { await _fetchDataInner(); } finally { _polling = false; } + } + async function _fetchDataInner() { let ts = new Date().toLocaleTimeString('ko-KR'); try { const bkRes = await fetch('/api/baskets'); @@ -714,8 +734,10 @@ def _html_page() -> str: else { basketTracksEl.innerHTML = '
바스켓 조회 불가
'; } } catch (e) { basketTracksEl.innerHTML = '
바스켓 조회 불가
'; } try { + // account_key는 빈 값이어도 항상 보낸다 — 파라미터 부재는 '무필터(전 계정 혼합)'라 + // 기본 계정('')과 의미가 다르다. const acct = chartAccountSel.value; - const url = '/api/snapshots?days=30' + (acct ? '&account_key=' + encodeURIComponent(acct) : ''); + const url = '/api/snapshots?days=30&account_key=' + encodeURIComponent(acct); const r = await fetch(url); if (r.ok) updateChart((await r.json()).snapshots || []); } catch (e) { /* skip */ } @@ -781,7 +803,17 @@ async def handle_api_deposit(request: web.Request) -> web.Response: 웹에서 가능한 쓰기는 이것 하나다(기록·조회까지가 웹의 권한 — 매매·설정 변경은 웹에 두지 않는다). occurred_at은 서버 시각 고정이라 소급 조작이 불가능하고, 금액 양수·바스켓 존재·TWR 체인 보호(마지막 스냅샷 이후) 검증은 공유 함수가 한다. + + CSRF 방어: 커스텀 헤더(X-Requested-With) 필수 — 루프백 바인딩이어도 브라우저 + 경유 cross-site 요청은 막지 못한다(악성 페이지가 text/plain fetch로 127.0.0.1에 + POST 가능, aiohttp request.json()은 Content-Type을 보지 않음). 커스텀 헤더는 + CORS preflight를 강제하는데 이 서버는 preflight에 응답하지 않으므로 외부 + 오리진에서는 실을 수 없다. 대시보드 프론트만 이 헤더를 보낸다. """ + if request.headers.get("X-Requested-With") != "quant-dashboard": + return web.json_response( + {"ok": False, "error": "대시보드 외 요청 차단(CSRF 방어)"}, status=403, + ) try: body = await request.json() except Exception: @@ -839,7 +871,11 @@ async def handle_api_runtime(_request: web.Request) -> web.Response: async def handle_api_snapshots(request: web.Request) -> web.Response: try: days = int(request.query.get("days", 30)) - account_key = request.query.get("account_key") or None + # 파라미터 '존재'와 '빈 값'을 구분한다: account_key=(빈)은 기본 계정('')의 + # 시계열을 뜻한다 — `or None`으로 강등하면 전 계정이 무필터로 섞여 + # 10M/30만 스케일이 한 차트에 뒤엉킨 톱니가 나온다. + raw_key = request.query.get("account_key") + account_key = raw_key if raw_key is not None else None data = get_snapshots_json(days=days, account_key=account_key) return web.json_response(data) except Exception as e: diff --git a/tests/test_dashboard_baskets.py b/tests/test_dashboard_baskets.py index 76ab8243..d40cd6a3 100644 --- a/tests/test_dashboard_baskets.py +++ b/tests/test_dashboard_baskets.py @@ -148,6 +148,90 @@ async def run(): asyncio.run(run()) +class TestSnapshotsSerialization: + """HIGH 회귀 고정: created_at(pd.Timestamp) 컬럼 추가 후 /api/snapshots가 + 매 폴링 500이 나고 차트가 조용히 죽던 문제 — 비어 있지 않은 DF로 검증해야 잡힌다.""" + + def test_serializer_handles_all_datetime_columns(self): + import json + import pandas as pd + from monitoring.web_dashboard import _serialize_snapshots + + df = pd.DataFrame([{ + "date": pd.Timestamp("2026-07-07"), + "created_at": pd.Timestamp("2026-07-07 10:07:12"), + "total_value": 300_126.0, + "cumulative_return": 0.04, + }]) + out = _serialize_snapshots(df) + json.dumps(out) # 직렬화 가능해야 한다 (회귀 시 TypeError) + assert out[0]["date"] == "2026-07-07" + assert out[0]["created_at"].startswith("2026-07-07 10:07") + + @pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치") + def test_snapshots_endpoint_200_with_real_rows(self): + # 실제 스냅샷 행(created_at 포함)이 있을 때 200 — 빈 DF 경로만 타던 구멍 방지. + import asyncio + from aiohttp.test_utils import TestClient, TestServer + from monitoring import web_dashboard as wd + + name = "kr_pocket_snap200" + acct = _seed_pocket(name) + + async def run(): + app = wd.create_app() + client = TestClient(TestServer(app)) + await client.start_server() + try: + res = await client.get( + "/api/snapshots?days=30&account_key=" + acct + ) + assert res.status == 200 + data = await res.json() + finally: + await client.close() + assert len(data["snapshots"]) == 1 + assert data["snapshots"][0]["total_value"] == 400_126 + + asyncio.run(run()) + + @pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치") + def test_empty_account_key_filters_default_account_only(self): + # account_key=(빈 값)은 기본 계정('')만 — 무필터(전 계정 혼합)로 강등되면 + # 10M/30만 스케일 시계열이 한 차트에 섞인다. + import asyncio + from datetime import datetime as _dt + from aiohttp.test_utils import TestClient, TestServer + from monitoring import web_dashboard as wd + from database.models import PortfolioSnapshot, get_session + + _seed_pocket("kr_pocket_mix") # 바스켓 계정 행 + session = get_session() + try: + session.add(PortfolioSnapshot( + account_key="", date=_dt(2026, 7, 7), + total_value=10_000_000, cash=10_000_000, invested=0, + )) + session.commit() + finally: + session.close() + + async def run(): + app = wd.create_app() + client = TestClient(TestServer(app)) + await client.start_server() + try: + res = await client.get("/api/snapshots?days=30&account_key=") + assert res.status == 200 + data = await res.json() + finally: + await client.close() + vals = [s["total_value"] for s in data["snapshots"]] + assert vals == [10_000_000] # 기본 계정 행만 — 바스켓 행 미포함 + + asyncio.run(run()) + + def test_html_page_contains_basket_tracks_section(): from monitoring.web_dashboard import _html_page @@ -188,6 +272,7 @@ async def run(): res = await client.post( "/api/deposit", json={"basket": name, "amount": 100000, "note": "웹 테스트"}, + headers={"X-Requested-With": "quant-dashboard"}, ) assert res.status == 200 data = await res.json() @@ -219,9 +304,10 @@ async def run(): client = TestClient(TestServer(app)) await client.start_server() try: - r1 = await client.post("/api/deposit", json={"basket": "kr_pocket_dep2", "amount": 0}) - r2 = await client.post("/api/deposit", json={"basket": "no_such", "amount": 1000}) - r3 = await client.post("/api/deposit", data=b"not-json") + h = {"X-Requested-With": "quant-dashboard"} + r1 = await client.post("/api/deposit", json={"basket": "kr_pocket_dep2", "amount": 0}, headers=h) + r2 = await client.post("/api/deposit", json={"basket": "no_such", "amount": 1000}, headers=h) + r3 = await client.post("/api/deposit", data=b"not-json", headers=h) assert r1.status == 400 and (await r1.json())["ok"] is False assert r2.status == 400 and (await r2.json())["ok"] is False assert r3.status == 400 @@ -230,6 +316,39 @@ async def run(): asyncio.run(run()) + def test_deposit_without_csrf_header_is_403(self): + # CSRF 방어: 커스텀 헤더 없는 POST(브라우저 경유 cross-site 요청 모사)는 + # 검증 전에 차단되고 아무것도 기록되지 않아야 한다. + import asyncio + from aiohttp.test_utils import TestClient, TestServer + from monitoring import web_dashboard as wd + from core.basket_rebalancer import rebalance_live_strategy_id + from database.repositories import get_cash_flow_total + + name = "kr_pocket_csrf" + init_database() + + async def run(): + with patch( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value=_cfg(name), + ): + app = wd.create_app() + client = TestClient(TestServer(app)) + await client.start_server() + try: + res = await client.post( + "/api/deposit", json={"basket": name, "amount": 100000}, + ) + assert res.status == 403 + finally: + await client.close() + + asyncio.run(run()) + assert get_cash_flow_total( + account_key=rebalance_live_strategy_id(name) + ) == 0.0 + def test_deposit_rejects_nonfinite_json_literals(self): # python json.loads는 Infinity/NaN 리터럴을 기본 허용 — float('inf')>0 은 True, # nan<=0 은 False라 기존 양수 검사를 둘 다 통과해 무한대/NaN 입금이 기록되던 @@ -259,7 +378,10 @@ async def run(): ): res = await client.post( "/api/deposit", data=payload, - headers={"Content-Type": "application/json"}, + headers={ + "Content-Type": "application/json", + "X-Requested-With": "quant-dashboard", + }, ) assert res.status == 400, f"payload {payload!r} → {res.status}" finally: