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
1 change: 0 additions & 1 deletion core/basket_rebalancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import os
from datetime import datetime, timedelta
from typing import Optional
from zoneinfo import ZoneInfo

import yaml
Expand Down
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
80 changes: 58 additions & 22 deletions monitoring/web_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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, '&quot;').replace(/'/g, '&#39;');
}
const card = (label, value, cls) => `<div class="card"><div class="label">${escHtml(label)}</div><div class="value ${cls || ''}">${value}</div></div>`;
const stat = (k, v, cls) => `<div class="stat"><div class="k">${escHtml(k)}</div><div class="v ${cls || ''}">${v}</div></div>`;

Expand Down Expand Up @@ -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 => `<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 : '');
Expand Down Expand Up @@ -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 `<tr><td>${p.symbol || '-'}</td><td class="num">${p.quantity ?? '-'}</td><td class="num">${fmtNum(p.avg_price)}</td><td class="num">${fmtNum(p.current_price)}</td><td class="num">${fmtNum(p.current_value)}</td><td class="num ${cls}">${fmtPct(p.pnl_rate)}</td></tr>`;
return `<tr><td>${escHtml(p.symbol || '-')}</td><td class="num">${p.quantity ?? '-'}</td><td class="num">${fmtNum(p.avg_price)}</td><td class="num">${fmtNum(p.current_price)}</td><td class="num">${fmtNum(p.current_value)}</td><td class="num ${cls}">${fmtPct(p.pnl_rate)}</td></tr>`;
}).join('');
}

Expand Down Expand Up @@ -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();
Expand All @@ -706,16 +720,24 @@ 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');
if (bkRes.ok) { const bk = await bkRes.json(); ensureChartAccountOptions(bk); renderBasketTracks(bk); }
else { basketTracksEl.innerHTML = '<div class="panel error">바스켓 조회 불가</div>'; }
} catch (e) { basketTracksEl.innerHTML = '<div class="panel error">바스켓 조회 불가</div>'; }
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 */ }
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
130 changes: 126 additions & 4 deletions tests/test_dashboard_baskets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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 입금이 기록되던
Expand Down Expand Up @@ -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:
Expand Down
Loading