Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 165 additions & 2 deletions monitoring/web_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 파일).
Expand Down Expand Up @@ -184,6 +272,11 @@ def _html_page() -> str:
<p class="meta" id="runtimeMeta"></p>
</section>

<section>
<h2>바스켓 트랙 — 내 돈 화면</h2>
<div id="basketTracks"><p class="muted">불러오는 중...</p></div>
</section>

<section>
<h2>바스켓 paper 승격 진행률</h2>
<div class="grid" id="basketEval"></div>
Expand Down Expand Up @@ -231,7 +324,7 @@ def _html_page() -> str:
</section>

<section>
<h2>수익률 추이 (최근 30일)</h2>
<h2>수익률 추이 (최근 30일) · 계정: <select id="chartAccount" style="background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;"></select></h2>
<div class="chart-wrap"><canvas id="chartEquity"></canvas></div>
</section>

Expand Down Expand Up @@ -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 = '<p class="muted">enabled 바스켓 없음</p>';
return;
}
basketTracksEl.innerHTML = baskets.map(function(b) {
const s = b.snapshot;
const na = '<span class="muted">스냅샷 없음</span>';
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 '<h3 style="margin:12px 0 8px 0;font-size:0.95rem;">' + escHtml(b.display_name) + ' <span class="muted">(' + escHtml(b.basket) + ')</span></h3><div class="grid">' + cards + '</div>';
}).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 '<option value="' + escHtml(w.v) + '">' + escHtml(w.t) + '</option>';
}).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);
Expand Down Expand Up @@ -441,7 +582,19 @@ def _html_page() -> str:
summaryEl.innerHTML = '<p class="error">포트폴리오 조회 불가</p>';
}
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 = '<p class="error">바스켓 조회 불가</p>';
}
} catch (e) { basketTracksEl.innerHTML = '<p class="error">바스켓 조회 불가</p>'; }
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 || []);
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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

Expand Down
157 changes: 157 additions & 0 deletions tests/test_dashboard_baskets.py
Original file line number Diff line number Diff line change
@@ -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 # 차트 계정 선택기
Loading