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) => ``;
@@ -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: