@@ -32,26 +32,37 @@ def _require_aiohttp_web():
3232
3333
3434def _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+
5159def 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, '"').replace(/'/g, ''');
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:
839871async 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 :
0 commit comments