From 59fc34c88878cbfadaccdc2f41cc3af7e2225fb6 Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Mon, 6 Jul 2026 13:28:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B0=94=EC=8A=A4=EC=BC=93=20=ED=8A=B8=EB=9E=99=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=E2=80=94=20=EB=82=B4=20=EB=8F=88=EC=9D=B4=20?= =?UTF-8?q?=EC=9B=B9=EC=97=90=20=EB=B3=B4=EC=9D=B4=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대시보드는 있었지만 정작 바스켓 트랙의 돈이 화면에 없었다 — /api/portfolio는 기본 계정만 보여줘서 kr_pocket·kr_diversified_hold의 원금/평가/수익률이 어디에도 안 떴다. 추가: - /api/baskets (DB 전용 — 10초 폴링에 네트워크 조회 없음): 바스켓별 최신 스냅샷(평가금·현금·TWR 누적수익률·MDD — 일일 사이클 저장값 그대로, 재계산 없음) + 누적 원금(초기+입금) + 원금 대비 손익(원) + 주식 배치율(실제/설계) + 보유 목록 - 바스켓 트랙(내 돈) 섹션: 바스켓별 카드 7종 - 수익률 추이 차트 계정 선택기(기본 = 첫 바스켓, 기본 계정 전환 가능) 실서버 검증(운영 DB): 두 트랙 숫자 정상 표시, HTML 섹션 렌더 확인. 전체 스위트 1635 통과. --- monitoring/web_dashboard.py | 167 +++++++++++++++++++++++++++++++- tests/test_dashboard_baskets.py | 157 ++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 tests/test_dashboard_baskets.py diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py index 94490bd0..5879bbf2 100644 --- a/monitoring/web_dashboard.py +++ b/monitoring/web_dashboard.py @@ -75,6 +75,94 @@ def get_snapshots_json(days: int = 30, account_key: Optional[str] = None) -> dic return {"snapshots": _serialize_snapshots(df), "days": days} +def get_baskets_json() -> dict: + """enabled 바스켓별 '내 돈' 요약 — 최신 스냅샷·원금(입금 포함)·배치율·보유 (DB 전용). + + 대시보드는 10초 폴링이므로 네트워크 조회를 섞지 않는다 — 평가금·수익률은 + 일일 사이클이 저장한 최신 스냅샷 값(TWR 반영), 보유는 DB 포지션(평균단가 기준). + 적립식 계정(kr_pocket)의 핵심 질문 "내가 넣은 돈 대비 얼마"에 답하는 화면 데이터다. + """ + from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id + from database.repositories import ( + get_all_positions, + get_cash_flow_total, + get_latest_snapshot_summary, + ) + from database.models import PortfolioSnapshot, get_session + + config = Config.get() + baskets_cfg = BasketRebalancer._load_baskets_config() + min_cash = (config.risk_params.get("diversification") or {}).get("min_cash_ratio", 0.20) + global_capital = (config.risk_params.get("position_sizing") or {}).get( + "initial_capital", 10_000_000 + ) + + out = [] + for name in BasketRebalancer.get_enabled_baskets(): + cfg = baskets_cfg.get(name) or {} + key = rebalance_live_strategy_id(name) + initial = float(cfg.get("initial_capital") or global_capital) + deposits = float(get_cash_flow_total(account_key=key) or 0) + principal = initial + deposits + + # 최신 스냅샷 (mdd 포함해 직접 조회 — get_latest_snapshot_summary는 TWR용 최소 필드) + session = get_session() + try: + snap = ( + session.query(PortfolioSnapshot) + .filter(PortfolioSnapshot.account_key == key) + .order_by(PortfolioSnapshot.date.desc()) + .first() + ) + snapshot = None + deployment_ratio = None + if snap is not None: + total = float(snap.total_value or 0) + cash = float(snap.cash or 0) + deployment_ratio = ((total - cash) / total) if total > 0 else None + snapshot = { + "date": str(snap.date)[:10], + "total_value": total, + "cash": cash, + "cumulative_return": float(snap.cumulative_return or 0), + "mdd": float(snap.mdd or 0), + } + finally: + session.close() + + max_stock = 1.0 - float(min_cash) + tsw = cfg.get("target_stock_weight") + design_fraction = max_stock if tsw is None else max(0.0, min(float(tsw), max_stock)) + + positions = [ + { + "symbol": p.symbol, + "quantity": int(p.quantity or 0), + "avg_price": float(p.avg_price or 0), + "invested": float((p.quantity or 0) * (p.avg_price or 0)), + } + for p in (get_all_positions(account_key=key) or []) + if (p.quantity or 0) > 0 + ] + + out.append({ + "basket": name, + "account_key": key, + "display_name": cfg.get("name") or name, + "initial_capital": initial, + "deposits_total": deposits, + "principal": principal, + "snapshot": snapshot, + "profit_vs_principal": ( + (snapshot["total_value"] - principal) if snapshot else None + ), + "deployment_ratio": deployment_ratio, + "design_fraction": design_fraction, + "positions": positions, + }) + return {"baskets": out, "timestamp": datetime.now().isoformat()} + + def get_runtime_json() -> dict: """ 시장 국면(실시간 조회) + 스케줄러가 기록한 신호·루프·블랙스완·KIS 통계(JSON 파일). @@ -184,6 +272,11 @@ def _html_page() -> str:

+
+

바스켓 트랙 — 내 돈 화면

+

불러오는 중...

+
+

바스켓 paper 승격 진행률

@@ -231,7 +324,7 @@ def _html_page() -> str:
-

수익률 추이 (최근 30일)

+

수익률 추이 (최근 30일) · 계정:

@@ -390,6 +483,54 @@ def _html_page() -> str: }).join(''); } + const basketTracksEl = document.getElementById('basketTracks'); + const chartAccountSel = document.getElementById('chartAccount'); + + function renderBasketTracks(data) { + const baskets = (data && data.baskets) || []; + if (!baskets.length) { + basketTracksEl.innerHTML = '

enabled 바스켓 없음

'; + return; + } + basketTracksEl.innerHTML = baskets.map(function(b) { + const s = b.snapshot; + const na = '스냅샷 없음'; + const ret = s ? Number(s.cumulative_return) : null; + const pvp = b.profit_vs_principal; + const dep = b.deployment_ratio != null ? Math.round(b.deployment_ratio * 100) + '% / 설계 ' + Math.round(b.design_fraction * 100) + '%' : '-'; + const pos = (b.positions || []).map(function(p) { return escHtml(p.symbol) + ' ' + p.quantity + '주'; }).join(', ') || '없음'; + const depositLine = b.deposits_total > 0 ? ' (입금 ' + fmtNum(b.deposits_total) + '원 포함)' : ''; + const cards = + card('평가금' + (s ? ' (' + escHtml(s.date) + ')' : ''), s ? fmtNum(s.total_value) + '원' : na, '') + + card('누적 원금', fmtNum(b.principal) + '원' + depositLine, '') + + card('원금 대비', pvp != null ? ((pvp >= 0 ? '+' : '') + fmtNum(pvp) + '원') : '-', pvp != null ? (pvp >= 0 ? 'positive' : 'negative') : '') + + card('수익률(TWR)', ret != null ? fmtPct(ret) : '-', ret != null ? (ret >= 0 ? 'positive' : 'negative') : '') + + card('MDD', s ? fmtPct(-Math.abs(s.mdd)) : '-', 'negative') + + card('주식 배치율', escHtml(dep), '') + + card('보유', escHtml(pos), ''); + return '

' + escHtml(b.display_name) + ' (' + escHtml(b.basket) + ')

' + cards + '
'; + }).join(''); + } + + function ensureChartAccountOptions(data) { + const baskets = (data && data.baskets) || []; + const wanted = baskets.map(function(b) { return { v: b.account_key, t: b.display_name }; }); + wanted.push({ v: '', t: '기본 계정' }); + if (chartAccountSel.options.length === wanted.length) return; // 선택 유지 + const prev = chartAccountSel.value; + chartAccountSel.innerHTML = wanted.map(function(w) { + return ''; + }).join(''); + // 기본 선택: 이전 선택 유지, 없으면 첫 바스켓(내 돈부터 보이게) + chartAccountSel.value = prev && Array.from(chartAccountSel.options).some(o => o.value === prev) + ? prev : (wanted[0] ? wanted[0].v : ''); + } + + chartAccountSel.addEventListener('change', function() { + if (chartEquity) { chartEquity.destroy(); chartEquity = null; } + fetchData(); + }); + function updateChart(snapshots) { if (!snapshots || snapshots.length === 0) return; const labels = snapshots.map(s => s.date); @@ -441,7 +582,19 @@ def _html_page() -> str: summaryEl.innerHTML = '

포트폴리오 조회 불가

'; } try { - const snapRes = await fetch('/api/snapshots?days=30'); + const bkRes = await fetch('/api/baskets'); + if (bkRes.ok) { + const bkData = await bkRes.json(); + renderBasketTracks(bkData); + ensureChartAccountOptions(bkData); + } else { + basketTracksEl.innerHTML = '

바스켓 조회 불가

'; + } + } catch (e) { basketTracksEl.innerHTML = '

바스켓 조회 불가

'; } + try { + const acct = chartAccountSel.value; + const url = '/api/snapshots?days=30' + (acct ? ('&account_key=' + encodeURIComponent(acct)) : ''); + const snapRes = await fetch(url); if (snapRes.ok) { const snapData = await snapRes.json(); updateChart(snapData.snapshots || []); @@ -501,6 +654,15 @@ async def handle_api_portfolio(_request: web.Request) -> web.Response: return web.json_response({"error": str(e)}, status=500) +async def handle_api_baskets(_request: web.Request) -> web.Response: + """바스켓 트랙 '내 돈' 요약 — DB 전용(네트워크 조회 없음), 10초 폴링 안전.""" + try: + return web.json_response(get_baskets_json()) + except Exception as e: + logger.exception("API /api/baskets 오류: {}", e) + return web.json_response({"error": str(e)}, status=500) + + async def handle_api_runtime(_request: web.Request) -> web.Response: try: return web.json_response(get_runtime_json()) @@ -576,6 +738,7 @@ def create_app() -> web.Application: app.router.add_get("/api/portfolio", handle_api_portfolio) app.router.add_get("/api/runtime", handle_api_runtime) app.router.add_get("/api/snapshots", handle_api_snapshots) + app.router.add_get("/api/baskets", handle_api_baskets) app.router.add_get("/api/basket_evaluation", handle_api_basket_evaluation) return app diff --git a/tests/test_dashboard_baskets.py b/tests/test_dashboard_baskets.py new file mode 100644 index 00000000..3077bc30 --- /dev/null +++ b/tests/test_dashboard_baskets.py @@ -0,0 +1,157 @@ +"""대시보드 /api/baskets — 바스켓 트랙 '내 돈' 화면 데이터 테스트. + +계약: DB 전용(네트워크 조회 없음), 최신 스냅샷(TWR 반영값) + 원금(초기+입금) + +배치율 + 보유를 바스켓별로 반환한다. 적립식 계정의 "내가 넣은 돈 대비 얼마"에 답한다. +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from datetime import datetime +from unittest.mock import patch + +import pytest + +from database.models import init_database + + +def _seed_pocket(basket_name): + """격리 DB에 kr_pocket풍 데이터: 스냅샷 + 입금 + 포지션. + + 세션 공유 격리 DB이므로 테스트마다 고유 바스켓 이름을 써서 서로 오염되지 않게 한다. + """ + from core.basket_rebalancer import rebalance_live_strategy_id + from database.models import PortfolioSnapshot, Position, get_session + from database.repositories import record_cash_flow + + acct = rebalance_live_strategy_id(basket_name) + init_database() + session = get_session() + try: + session.add(PortfolioSnapshot( + account_key=acct, date=datetime(2026, 7, 6), + total_value=400_126, cash=171_846, invested=228_280, + cumulative_return=0.04, mdd=0.0, peak_value=400_126, + )) + session.add(Position( + account_key=acct, symbol="069500", avg_price=128_135, + quantity=1, total_invested=128_135, + )) + session.commit() + finally: + session.close() + record_cash_flow(100_000, account_key=acct, occurred_at=datetime(2026, 7, 6, 9, 0)) + return acct + + +def _cfg(basket_name): + return { + basket_name: { + "name": "소액 적립 (KODEX200 50/50)", + "enabled": True, + "initial_capital": 300_000, + "target_stock_weight": 0.5, + "holdings": {"069500": 1.0}, + } + } + + +class TestGetBasketsJson: + def test_principal_snapshot_deployment_positions(self): + name = "kr_pocket_t1" + acct = _seed_pocket(name) + from monitoring import web_dashboard as wd + + with patch( + "core.basket_rebalancer.BasketRebalancer.get_enabled_baskets", + return_value=[name], + ), patch( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value=_cfg(name), + ): + data = wd.get_baskets_json() + + b = data["baskets"][0] + assert b["basket"] == name + assert b["account_key"] == acct + # 원금 = 초기 30만 + 입금 10만 + assert b["principal"] == pytest.approx(400_000) + assert b["deposits_total"] == pytest.approx(100_000) + # 스냅샷 값 그대로(TWR 반영치) — 재계산하지 않는다 + assert b["snapshot"]["total_value"] == pytest.approx(400_126) + assert b["snapshot"]["cumulative_return"] == pytest.approx(0.04) + # 원금 대비 손익 = 평가금 - 원금 + assert b["profit_vs_principal"] == pytest.approx(126) + # 배치율 = (총-현금)/총, 설계 = target_stock_weight + assert b["deployment_ratio"] == pytest.approx((400_126 - 171_846) / 400_126) + assert b["design_fraction"] == pytest.approx(0.5) + # 보유 + assert b["positions"] == [{ + "symbol": "069500", "quantity": 1, + "avg_price": 128_135.0, "invested": 128_135.0, + }] + + def test_no_snapshot_yet_is_null_not_crash(self): + name = "kr_pocket_empty" # 시드 없음 — 운영 전 상태 + init_database() + from monitoring import web_dashboard as wd + + with patch( + "core.basket_rebalancer.BasketRebalancer.get_enabled_baskets", + return_value=[name], + ), patch( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value=_cfg(name), + ): + data = wd.get_baskets_json() + b = data["baskets"][0] + assert b["snapshot"] is None + assert b["profit_vs_principal"] is None + assert b["deployment_ratio"] is None + assert b["principal"] == pytest.approx(300_000) # 입금 없으면 초기자본 + + +try: + import aiohttp # noqa: F401 + _has_aiohttp = True +except ImportError: + _has_aiohttp = False + + +@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치") +def test_baskets_endpoint_serves_json(): + import asyncio + from aiohttp.test_utils import TestClient, TestServer + from monitoring import web_dashboard as wd + + name = "kr_pocket_http" + _seed_pocket(name) + + async def run(): + with patch( + "core.basket_rebalancer.BasketRebalancer.get_enabled_baskets", + return_value=[name], + ), 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.get("/api/baskets") + assert res.status == 200 + data = await res.json() + finally: + await client.close() + assert data["baskets"][0]["basket"] == name + + asyncio.run(run()) + + +def test_html_page_contains_basket_tracks_section(): + from monitoring.web_dashboard import _html_page + + html = _html_page() + assert "basketTracks" in html # 섹션 + assert "/api/baskets" in html # 폴링 대상 + assert "chartAccount" in html # 차트 계정 선택기 \ No newline at end of file