Skip to content

Commit 57fa414

Browse files
committed
fix: 스냅샷 직렬화 500 수복 + 입금 CSRF 방어 + 대시보드 다듬기
3차 점검(통합 스크립트·린트·프론트/스레딩 적대 리뷰) 결과 일괄 적용. HIGH — /api/snapshots 매 폴링 500(수익률 차트 사망): 어제 일간 수익률 경계용으로 추가한 created_at 컬럼을 직렬화가 처리하지 못해 pd.Timestamp가 JSON 예외를 냈다. 프론트는 비정상 응답을 조용히 스킵해 차트만 빈 채였고, 빈 DF만 타는 테스트는 못 잡았다. 모든 날짜형 컬럼 처리 + 비어 있지 않은 DF·실행 회귀 고정. 실서버 200/2rows 확인. MED — POST /api/deposit CSRF: 루프백 바인딩도 브라우저 경유 cross-site POST는 못 막고 aiohttp request.json()은 Content-Type을 안 본다(위조 입금 → 트랙 원금 오염 가능). X-Requested-With 커스텀 헤더 필수(preflight 강제, 이 서버는 preflight 미응답) + 프론트 한 쌍 + 무헤더 403·기록 0건 테스트. MED — 기본 계정 차트가 전 계정 혼합: 빈 account_key를 무필터로 강등하던 핸들러 수정(존재/빈 값 구분) + 프론트 상시 파라미터 전송 + 격리 테스트. LOW — 폴링 오버랩 가드, 계정 옵션 값 시그니처 비교, escHtml 따옴표·포지션 심볼 이스케이프, Dashboard 싱글턴화(10초마다 초기화 INFO 스팸 제거), 항상 0이던 KIS 통계 폴백 삭제(무의미 측정 + 폴링 로그 스팸). 린트(pyflakes): 미사용 import 2건 + 플레이스홀더 없는 f-string 1건. 통합 스크립트 test_integration.py 38/38(5주 만 재실행), 전체 스위트 1650.
1 parent 2b3ec35 commit 57fa414

4 files changed

Lines changed: 185 additions & 28 deletions

File tree

core/basket_rebalancer.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
import os
1414
from datetime import datetime, timedelta
15-
from typing import Optional
1615
from zoneinfo import ZoneInfo
1716

1817
import yaml

main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ def run_deploy_check(args) -> int:
645645
print(f" 포트폴리오 대비 회전율: {summary['turnover_pct_of_portfolio']}%")
646646
for p in summary["plan"][:10]:
647647
print(f" {p['action']:4} {p['symbol']} {p['quantity']}주 @ {p['price']:,.0f} = {p['amount']:,.0f}원")
648-
print(f"\n 다음 절차:")
648+
print("\n 다음 절차:")
649649
for i, s in enumerate(summary["next_steps"], 1):
650650
print(f" {i}. {s}")
651651
print()

monitoring/web_dashboard.py

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,26 +32,37 @@ def _require_aiohttp_web():
3232

3333

3434
def _serialize_snapshots(df):
35-
"""DataFrame 스냅샷을 JSON 직렬화 가능한 리스트로 변환"""
35+
"""DataFrame 스냅샷을 JSON 직렬화 가능한 리스트로 변환.
36+
37+
날짜형은 컬럼을 특정하지 않고 전부 문자열화한다 — 'date'만 처리하던 시절
38+
created_at 컬럼 추가(일간 수익률 경계용)로 pd.Timestamp가 그대로 새어나가
39+
/api/snapshots가 매 폴링 500이 나고 수익률 차트가 조용히 죽었다(빈 DF만
40+
쓰는 테스트는 통과해서 못 잡던 회귀).
41+
"""
3642
if df.empty:
3743
return []
3844
out = []
3945
for _, row in df.iterrows():
4046
d = row.to_dict()
41-
if "date" in d and hasattr(d["date"], "strftime"):
42-
d["date"] = d["date"].strftime("%Y-%m-%d")
43-
# numpy 타입 → Python 네이티브
4447
for k, v in d.items():
45-
if hasattr(v, "item"):
48+
if hasattr(v, "strftime"): # date/datetime/pd.Timestamp
49+
d[k] = v.strftime("%Y-%m-%d %H:%M:%S") if k != "date" else v.strftime("%Y-%m-%d")
50+
elif hasattr(v, "item"): # numpy 타입 → Python 네이티브
4651
d[k] = v.item()
4752
out.append(d)
4853
return out
4954

5055

56+
_DASH = None # 폴링(10초)마다 Dashboard/PortfolioManager를 새로 만들면 초기화 INFO가 스팸이 된다
57+
58+
5159
def get_portfolio_json(current_prices: Optional[dict] = None) -> dict:
5260
"""현재 포트폴리오 요약을 JSON 친화적 dict로 반환"""
61+
global _DASH
5362
config = Config.get()
54-
dash = Dashboard(config=config)
63+
if _DASH is None:
64+
_DASH = Dashboard(config=config)
65+
dash = _DASH
5566
summary = dash.portfolio_manager.get_portfolio_summary(current_prices or {})
5667
return {
5768
"timestamp": datetime.now().isoformat(),
@@ -86,7 +97,6 @@ def get_baskets_json() -> dict:
8697
from database.repositories import (
8798
get_all_positions,
8899
get_cash_flow_total,
89-
get_latest_snapshot_summary,
90100
)
91101
from database.models import PortfolioSnapshot, get_session
92102

@@ -216,15 +226,9 @@ def get_runtime_json() -> dict:
216226
logger.debug("get_runtime_json read_state: {}", e)
217227
out["signals_today"] = None
218228

219-
if out["kis_stats"] is None:
220-
try:
221-
from api.kis_api import KISApi
222-
223-
out["kis_stats"] = KISApi().get_rate_limit_stats()
224-
out["kis_stats_source"] = "dashboard_process"
225-
except Exception as e:
226-
logger.debug("get_runtime_json KISApi: {}", e)
227-
229+
# KIS 통계 폴백(대시보드 프로세스에서 KISApi 신규 생성) 제거 — 레이트리미터
230+
# 상태가 인스턴스별이라 항상 0(아무것도 측정 안 함)에 폴링마다 초기화 로그만
231+
# 남겼다. 스케줄러 파일에 없으면 정직하게 '조회 불가'로 둔다.
228232
return out
229233

230234

@@ -460,7 +464,12 @@ def _html_page() -> str:
460464
461465
const fmtNum = (n) => Number(n).toLocaleString('ko-KR');
462466
const fmtPct = (n) => (Number(n) >= 0 ? '+' : '') + Number(n).toFixed(2) + '%';
463-
function escHtml(t) { const d = document.createElement('div'); d.textContent = t == null ? '' : String(t); return d.innerHTML; }
467+
function escHtml(t) {
468+
const d = document.createElement('div');
469+
d.textContent = t == null ? '' : String(t);
470+
// textContent→innerHTML은 &<>만 이스케이프 — value="..." 속성에도 쓰이므로 따옴표까지.
471+
return d.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
472+
}
464473
const card = (label, value, cls) => `<div class="card"><div class="label">${escHtml(label)}</div><div class="value ${cls || ''}">${value}</div></div>`;
465474
const stat = (k, v, cls) => `<div class="stat"><div class="k">${escHtml(k)}</div><div class="v ${cls || ''}">${v}</div></div>`;
466475
@@ -530,7 +539,10 @@ def _html_page() -> str:
530539
const baskets = (data && data.baskets) || [];
531540
const wanted = baskets.map(b => ({ v: b.account_key, t: b.display_name }));
532541
wanted.push({ v: '', t: '기본 계정' });
533-
if (chartAccountSel.options.length === wanted.length) return;
542+
// 개수만 비교하면 바스켓 교체/개명 시 스테일 옵션이 남는다 — 값 시그니처로 비교.
543+
const sig = wanted.map(w => w.v + '' + w.t).join('');
544+
if (chartAccountSel.dataset.sig === sig) return;
545+
chartAccountSel.dataset.sig = sig;
534546
const prev = chartAccountSel.value;
535547
chartAccountSel.innerHTML = wanted.map(w => `<option value="${escHtml(w.v)}">${escHtml(w.t)}</option>`).join('');
536548
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:
660672
if (!has) return;
661673
$('positions').innerHTML = ps.map(p => {
662674
const cls = p.pnl_rate >= 0 ? 'positive' : 'negative';
663-
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>`;
675+
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>`;
664676
}).join('');
665677
}
666678
@@ -689,7 +701,9 @@ def _html_page() -> str:
689701
const btn = $('depSubmit'); btn.disabled = true; btn.textContent = '기록 중...';
690702
try {
691703
const res = await fetch('/api/deposit', {
692-
method: 'POST', headers: { 'Content-Type': 'application/json' },
704+
method: 'POST',
705+
// X-Requested-With: 서버의 CSRF 방어(커스텀 헤더 필수)와 한 쌍
706+
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'quant-dashboard' },
693707
body: JSON.stringify({ basket, amount, note: $('depNote').value || '' })
694708
});
695709
const data = await res.json();
@@ -706,16 +720,24 @@ def _html_page() -> str:
706720
}
707721
708722
/* ── 폴링 ── */
723+
let _polling = false; // 오버랩 가드 — 느린 응답(운영 상태 수십 초)이 폴링을 적체시키지 않게
709724
async function fetchData() {
725+
if (_polling) return;
726+
_polling = true;
727+
try { await _fetchDataInner(); } finally { _polling = false; }
728+
}
729+
async function _fetchDataInner() {
710730
let ts = new Date().toLocaleTimeString('ko-KR');
711731
try {
712732
const bkRes = await fetch('/api/baskets');
713733
if (bkRes.ok) { const bk = await bkRes.json(); ensureChartAccountOptions(bk); renderBasketTracks(bk); }
714734
else { basketTracksEl.innerHTML = '<div class="panel error">바스켓 조회 불가</div>'; }
715735
} catch (e) { basketTracksEl.innerHTML = '<div class="panel error">바스켓 조회 불가</div>'; }
716736
try {
737+
// account_key는 빈 값이어도 항상 보낸다 — 파라미터 부재는 '무필터(전 계정 혼합)'라
738+
// 기본 계정('')과 의미가 다르다.
717739
const acct = chartAccountSel.value;
718-
const url = '/api/snapshots?days=30' + (acct ? '&account_key=' + encodeURIComponent(acct) : '');
740+
const url = '/api/snapshots?days=30&account_key=' + encodeURIComponent(acct);
719741
const r = await fetch(url);
720742
if (r.ok) updateChart((await r.json()).snapshots || []);
721743
} catch (e) { /* skip */ }
@@ -781,7 +803,17 @@ async def handle_api_deposit(request: web.Request) -> web.Response:
781803
웹에서 가능한 쓰기는 이것 하나다(기록·조회까지가 웹의 권한 — 매매·설정 변경은
782804
웹에 두지 않는다). occurred_at은 서버 시각 고정이라 소급 조작이 불가능하고,
783805
금액 양수·바스켓 존재·TWR 체인 보호(마지막 스냅샷 이후) 검증은 공유 함수가 한다.
806+
807+
CSRF 방어: 커스텀 헤더(X-Requested-With) 필수 — 루프백 바인딩이어도 브라우저
808+
경유 cross-site 요청은 막지 못한다(악성 페이지가 text/plain fetch로 127.0.0.1에
809+
POST 가능, aiohttp request.json()은 Content-Type을 보지 않음). 커스텀 헤더는
810+
CORS preflight를 강제하는데 이 서버는 preflight에 응답하지 않으므로 외부
811+
오리진에서는 실을 수 없다. 대시보드 프론트만 이 헤더를 보낸다.
784812
"""
813+
if request.headers.get("X-Requested-With") != "quant-dashboard":
814+
return web.json_response(
815+
{"ok": False, "error": "대시보드 외 요청 차단(CSRF 방어)"}, status=403,
816+
)
785817
try:
786818
body = await request.json()
787819
except Exception:
@@ -839,7 +871,11 @@ async def handle_api_runtime(_request: web.Request) -> web.Response:
839871
async def handle_api_snapshots(request: web.Request) -> web.Response:
840872
try:
841873
days = int(request.query.get("days", 30))
842-
account_key = request.query.get("account_key") or None
874+
# 파라미터 '존재'와 '빈 값'을 구분한다: account_key=(빈)은 기본 계정('')의
875+
# 시계열을 뜻한다 — `or None`으로 강등하면 전 계정이 무필터로 섞여
876+
# 10M/30만 스케일이 한 차트에 뒤엉킨 톱니가 나온다.
877+
raw_key = request.query.get("account_key")
878+
account_key = raw_key if raw_key is not None else None
843879
data = get_snapshots_json(days=days, account_key=account_key)
844880
return web.json_response(data)
845881
except Exception as e:

tests/test_dashboard_baskets.py

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,90 @@ async def run():
148148
asyncio.run(run())
149149

150150

151+
class TestSnapshotsSerialization:
152+
"""HIGH 회귀 고정: created_at(pd.Timestamp) 컬럼 추가 후 /api/snapshots가
153+
매 폴링 500이 나고 차트가 조용히 죽던 문제 — 비어 있지 않은 DF로 검증해야 잡힌다."""
154+
155+
def test_serializer_handles_all_datetime_columns(self):
156+
import json
157+
import pandas as pd
158+
from monitoring.web_dashboard import _serialize_snapshots
159+
160+
df = pd.DataFrame([{
161+
"date": pd.Timestamp("2026-07-07"),
162+
"created_at": pd.Timestamp("2026-07-07 10:07:12"),
163+
"total_value": 300_126.0,
164+
"cumulative_return": 0.04,
165+
}])
166+
out = _serialize_snapshots(df)
167+
json.dumps(out) # 직렬화 가능해야 한다 (회귀 시 TypeError)
168+
assert out[0]["date"] == "2026-07-07"
169+
assert out[0]["created_at"].startswith("2026-07-07 10:07")
170+
171+
@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
172+
def test_snapshots_endpoint_200_with_real_rows(self):
173+
# 실제 스냅샷 행(created_at 포함)이 있을 때 200 — 빈 DF 경로만 타던 구멍 방지.
174+
import asyncio
175+
from aiohttp.test_utils import TestClient, TestServer
176+
from monitoring import web_dashboard as wd
177+
178+
name = "kr_pocket_snap200"
179+
acct = _seed_pocket(name)
180+
181+
async def run():
182+
app = wd.create_app()
183+
client = TestClient(TestServer(app))
184+
await client.start_server()
185+
try:
186+
res = await client.get(
187+
"/api/snapshots?days=30&account_key=" + acct
188+
)
189+
assert res.status == 200
190+
data = await res.json()
191+
finally:
192+
await client.close()
193+
assert len(data["snapshots"]) == 1
194+
assert data["snapshots"][0]["total_value"] == 400_126
195+
196+
asyncio.run(run())
197+
198+
@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
199+
def test_empty_account_key_filters_default_account_only(self):
200+
# account_key=(빈 값)은 기본 계정('')만 — 무필터(전 계정 혼합)로 강등되면
201+
# 10M/30만 스케일 시계열이 한 차트에 섞인다.
202+
import asyncio
203+
from datetime import datetime as _dt
204+
from aiohttp.test_utils import TestClient, TestServer
205+
from monitoring import web_dashboard as wd
206+
from database.models import PortfolioSnapshot, get_session
207+
208+
_seed_pocket("kr_pocket_mix") # 바스켓 계정 행
209+
session = get_session()
210+
try:
211+
session.add(PortfolioSnapshot(
212+
account_key="", date=_dt(2026, 7, 7),
213+
total_value=10_000_000, cash=10_000_000, invested=0,
214+
))
215+
session.commit()
216+
finally:
217+
session.close()
218+
219+
async def run():
220+
app = wd.create_app()
221+
client = TestClient(TestServer(app))
222+
await client.start_server()
223+
try:
224+
res = await client.get("/api/snapshots?days=30&account_key=")
225+
assert res.status == 200
226+
data = await res.json()
227+
finally:
228+
await client.close()
229+
vals = [s["total_value"] for s in data["snapshots"]]
230+
assert vals == [10_000_000] # 기본 계정 행만 — 바스켓 행 미포함
231+
232+
asyncio.run(run())
233+
234+
151235
def test_html_page_contains_basket_tracks_section():
152236
from monitoring.web_dashboard import _html_page
153237

@@ -188,6 +272,7 @@ async def run():
188272
res = await client.post(
189273
"/api/deposit",
190274
json={"basket": name, "amount": 100000, "note": "웹 테스트"},
275+
headers={"X-Requested-With": "quant-dashboard"},
191276
)
192277
assert res.status == 200
193278
data = await res.json()
@@ -219,9 +304,10 @@ async def run():
219304
client = TestClient(TestServer(app))
220305
await client.start_server()
221306
try:
222-
r1 = await client.post("/api/deposit", json={"basket": "kr_pocket_dep2", "amount": 0})
223-
r2 = await client.post("/api/deposit", json={"basket": "no_such", "amount": 1000})
224-
r3 = await client.post("/api/deposit", data=b"not-json")
307+
h = {"X-Requested-With": "quant-dashboard"}
308+
r1 = await client.post("/api/deposit", json={"basket": "kr_pocket_dep2", "amount": 0}, headers=h)
309+
r2 = await client.post("/api/deposit", json={"basket": "no_such", "amount": 1000}, headers=h)
310+
r3 = await client.post("/api/deposit", data=b"not-json", headers=h)
225311
assert r1.status == 400 and (await r1.json())["ok"] is False
226312
assert r2.status == 400 and (await r2.json())["ok"] is False
227313
assert r3.status == 400
@@ -230,6 +316,39 @@ async def run():
230316

231317
asyncio.run(run())
232318

319+
def test_deposit_without_csrf_header_is_403(self):
320+
# CSRF 방어: 커스텀 헤더 없는 POST(브라우저 경유 cross-site 요청 모사)는
321+
# 검증 전에 차단되고 아무것도 기록되지 않아야 한다.
322+
import asyncio
323+
from aiohttp.test_utils import TestClient, TestServer
324+
from monitoring import web_dashboard as wd
325+
from core.basket_rebalancer import rebalance_live_strategy_id
326+
from database.repositories import get_cash_flow_total
327+
328+
name = "kr_pocket_csrf"
329+
init_database()
330+
331+
async def run():
332+
with patch(
333+
"core.basket_rebalancer.BasketRebalancer._load_baskets_config",
334+
return_value=_cfg(name),
335+
):
336+
app = wd.create_app()
337+
client = TestClient(TestServer(app))
338+
await client.start_server()
339+
try:
340+
res = await client.post(
341+
"/api/deposit", json={"basket": name, "amount": 100000},
342+
)
343+
assert res.status == 403
344+
finally:
345+
await client.close()
346+
347+
asyncio.run(run())
348+
assert get_cash_flow_total(
349+
account_key=rebalance_live_strategy_id(name)
350+
) == 0.0
351+
233352
def test_deposit_rejects_nonfinite_json_literals(self):
234353
# python json.loads는 Infinity/NaN 리터럴을 기본 허용 — float('inf')>0 은 True,
235354
# nan<=0 은 False라 기존 양수 검사를 둘 다 통과해 무한대/NaN 입금이 기록되던
@@ -259,7 +378,10 @@ async def run():
259378
):
260379
res = await client.post(
261380
"/api/deposit", data=payload,
262-
headers={"Content-Type": "application/json"},
381+
headers={
382+
"Content-Type": "application/json",
383+
"X-Requested-With": "quant-dashboard",
384+
},
263385
)
264386
assert res.status == 400, f"payload {payload!r}{res.status}"
265387
finally:

0 commit comments

Comments
 (0)