feat: 자산웹 실시간 시세·평가손익 (키움 WebSocket)
- realtime_hub.py: 키움 WS 허브(0B 체결가·1h VI), behive_web 데몬 스레드 + 메모리 상태 - /api/realtime/quotes 엔드포인트 (허브 메모리만, 키움 호출 0) - 보유행 현재가·평가손익·평가금액·등락률(NXT종가 기준)·비중 실시간 - KPI 총평가금액·총평가손익·순자산·당일평가손익 실시간 (owner 탭 합계→자산정보 탭 카드 연동) - fill_watcher 체결 시 fill_signal 기록 → 자산웹 패널 자동 새로고침(수량·예수금·당일정산) - 감시종목 탭 자동갱신 카운트다운 활성화 - 안전: kill-switch BEHIVE_REALTIME=0, 허브 콜드/실패 시 REST 폴백, 매매 경로 무관 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1845,9 +1845,9 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
if c.get('_show_day_change') and isinstance(day_pct, (int, float)):
|
||||
d_sign = '+' if day_pct >= 0 else ''
|
||||
d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral')
|
||||
price_cell = f'{mark_html}{price:,}<span class="{d_cls}">{d_sign}{day_pct:.2f}%</span>'
|
||||
price_cell = f'{mark_html}<span class="rt-px">{price:,}</span><span class="{d_cls}">{d_sign}{day_pct:.2f}%</span>'
|
||||
else:
|
||||
price_cell = f'{mark_html}{price:,}'
|
||||
price_cell = f'{mark_html}<span class="rt-px">{price:,}</span>'
|
||||
if mode == 'watching' and isinstance(ref_price, (int, float)) and ref_price > 0:
|
||||
diff_cell = f'<span class="muted small">매수가</span> {ref_price:,.0f}'
|
||||
else:
|
||||
@@ -2157,9 +2157,12 @@ def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool
|
||||
fee_html = f'<span class="muted small"> · 수수료·세금 {realized_fees:,}원</span>' if realized_fees else ''
|
||||
realized_html = f'<span class="{rcls}">{rsign}{realized_pl:,}원</span>{fee_html}'
|
||||
kpis.append(('당일 실현손익', realized_html))
|
||||
rows = ''.join(
|
||||
f'<tr><th>{html.escape(k)}</th><td>{v}</td></tr>' for k, v in kpis
|
||||
)
|
||||
_rtk = {'총 평가금액': 'value', '총 평가손익': 'profit', '순자산': 'net', '당일 평가손익': 'daypl'}
|
||||
_row_list = []
|
||||
for k, v in kpis:
|
||||
attr = f' data-rt-kpi="{_rtk[k]}"' if k in _rtk else ''
|
||||
_row_list.append(f'<tr><th>{html.escape(k)}</th><td{attr}>{v}</td></tr>')
|
||||
rows = ''.join(_row_list)
|
||||
trade_btn_html = ''
|
||||
if not compact:
|
||||
owner_attr = html.escape(owner, quote=True)
|
||||
@@ -2169,7 +2172,7 @@ def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool
|
||||
f'data-trade-owner="{owner_attr}" data-trade-stock="{label_attr}">'
|
||||
f'📋 전체 거래내역</button>'
|
||||
)
|
||||
return f'''<section class="owner-card">
|
||||
return f'''<section class="owner-card" data-rt-owner="{html.escape(owner, quote=True)}">
|
||||
<div class="owner-title-row">
|
||||
<div class="owner-title">{html.escape(owner_label_text)}</div>
|
||||
{trade_btn_html}
|
||||
@@ -2569,7 +2572,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
)
|
||||
|
||||
pl_pairs: list[str] = [
|
||||
f'<dt>평가손익</dt><dd class="num"><span class="{pcls}">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span></dd>',
|
||||
f'<dt>평가손익</dt><dd class="num"><span class="{pcls} rt-profit-detail">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span></dd>',
|
||||
]
|
||||
if day_change:
|
||||
dcls = _profit_class(day_change)
|
||||
@@ -2577,10 +2580,10 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
day_value = day_change * qty
|
||||
dvsign = '+' if day_value >= 0 else ''
|
||||
pl_pairs.append(
|
||||
f'<dt>평가금액 변동</dt><dd class="num"><span class="{dcls}">{dvsign}{day_value:,}원</span></dd>'
|
||||
f'<dt>평가금액 변동</dt><dd class="num"><span class="{dcls} rt-dayvalue">{dvsign}{day_value:,}원</span></dd>'
|
||||
)
|
||||
pl_pairs.append(
|
||||
f'<dt>당일 등락</dt><dd class="num"><span class="{dcls}">{dsign}{day_change:,}원 '
|
||||
f'<dt>당일 등락</dt><dd class="num"><span class="{dcls} rt-daychange">{dsign}{day_change:,}원 '
|
||||
f'({dsign}{day_change_pct:.2f}%)</span></dd>'
|
||||
)
|
||||
# 가격 출처 마크 — cur_price 가 어느 시장 응답 가격과 일치하는지로 판정.
|
||||
@@ -2626,9 +2629,9 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
if show_day_change and isinstance(d_pct_val, (int, float)) and d_pct_val != 0:
|
||||
d_sign = '+' if d_pct_val >= 0 else ''
|
||||
d_cls = 'day-pct up' if d_pct_val > 0 else 'day-pct down'
|
||||
price_html = f'{mark_html}{price:,}<span class="{d_cls}">{d_sign}{d_pct_val:.2f}%</span>'
|
||||
price_html = f'{mark_html}<span class="rt-px">{price:,}</span><span class="{d_cls} rt-daypct">{d_sign}{d_pct_val:.2f}%</span>'
|
||||
else:
|
||||
price_html = f'{mark_html}{price:,}'
|
||||
price_html = f'{mark_html}<span class="rt-px">{price:,}</span>'
|
||||
|
||||
chart_block = ''
|
||||
if code:
|
||||
@@ -2644,7 +2647,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
right_pairs = [
|
||||
f'<dt>보유수량</dt><dd class="num">{qty:,}주</dd>',
|
||||
f'<dt>매입금액</dt><dd class="num">{buy_amount:,}원</dd>',
|
||||
f'<dt>평가금액</dt><dd class="num">{eval_value:,}원</dd>',
|
||||
f'<dt>평가금액</dt><dd class="num"><span class="rt-eval">{eval_value:,}원</span></dd>',
|
||||
]
|
||||
dl_inner = _interleave_kv_pairs(left_pairs, right_pairs)
|
||||
pl_pairs.extend(journal_lines)
|
||||
@@ -2661,17 +2664,17 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
f'</div>'
|
||||
) if code else ''
|
||||
tag_chip = _tag_chip_html(r.get('code') or '', r.get('stock') or '', interactive=True)
|
||||
return f'''<details class="row {pcls} mode-held" data-row-key="{row_key}">
|
||||
return f'''<details class="row {pcls} mode-held" data-row-key="{row_key}" data-rt-qty="{qty}" data-rt-cost="{eval_value - profit}" data-rt-px="{price}" data-rt-prevclose="{price - day_change}">
|
||||
<summary>
|
||||
<div class="left">
|
||||
<div class="line1">{_buy_rank_badge(buy_amount)}<span class="stock">{stock}</span>{tag_chip}{star}<span class="code">{accounts}</span></div>
|
||||
<div class="line2"><span class="badge held">보유 {qty:,}주</span>{_pending_badges_html(r)}<span class="muted small">비중 {weight:.2f}%</span></div>
|
||||
<div class="line2"><span class="badge held">보유 {qty:,}주</span>{_pending_badges_html(r)}<span class="muted small">비중 <span class="rt-weight">{weight:.2f}</span>%</span></div>
|
||||
{summary_journal_html}
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
|
||||
<div class="ref">평단 {avg:,}</div>
|
||||
<div class="diff">{sign}{profit:,}<span class="pct">{sign}{profit_rate:.2f}%</span></div>
|
||||
<div class="diff"><span class="rt-profit">{sign}{profit:,}</span><span class="pct rt-profit-pct">{sign}{profit_rate:.2f}%</span></div>
|
||||
</div>
|
||||
{candle_summary}
|
||||
</summary>
|
||||
@@ -3802,7 +3805,18 @@ def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: s
|
||||
if not held and not phantoms:
|
||||
parts.append('<div class="empty">보유 종목 데이터가 없습니다.</div>')
|
||||
|
||||
return '\n'.join(parts)
|
||||
# rt-scope: 이 owner의 KPI 카드 + 보유행을 한 컨테이너로 묶음. 실시간 JS가 consolidated pane 행을
|
||||
# 합산해 같은 scope 안의 data-rt-kpi(총평가금액·총평가손익·순자산·당일평가손익) td를 갱신.
|
||||
# deposit은 순자산 계산용.
|
||||
# 당일평가손익 = total_net − prev_net − net_cash_flow 에서 실시간 변동분은 평가액(total_value)뿐 →
|
||||
# 당일평가손익 = 실시간평가액 + C, C = day_pl_total − total_value (세션 중 상수).
|
||||
# prev_net은 % 계산용. day_pl_total None(전날 스냅 없음·휴장)이면 속성 미부착 → JS가 스킵.
|
||||
deposit = d.get('deposit', 0) or 0
|
||||
_daypl = d.get('day_pl_total')
|
||||
_attrs = f'data-rt-owner="{html.escape(owner, quote=True)}" data-rt-deposit="{deposit}"'
|
||||
if _daypl is not None:
|
||||
_attrs += f' data-rt-daypl-c="{_daypl - d.get("total_value", 0)}" data-rt-prevnet="{d.get("prev_net", 0) or 0}"'
|
||||
return f'<div class="rt-scope" {_attrs}>' + '\n'.join(parts) + '</div>'
|
||||
|
||||
|
||||
_CSS = '''
|
||||
@@ -6194,8 +6208,9 @@ def render_html() -> str:
|
||||
)
|
||||
|
||||
valid_tids_js = ','.join(f"'{tid}'" for tid in valid_tids)
|
||||
# 자동 갱신 동작 탭 — 자산정보·본인·가희(owner 부분 fetch)·관심종목(전체 fetch). 감시종목은 시세 변화 추적 대상 아니라 제외.
|
||||
auto_refresh_tids_js = ','.join(f"'{tid}'" for tid in valid_tids if tid != 'tab-wl')
|
||||
# 자동 갱신 동작 탭 — 전체 탭(자산정보·본인·가희·관심·감시). 감시종목도 카운트다운 동작(2026-06-08 관리자 요청).
|
||||
# 시세는 이미 실시간(rt-px)이라 풀패널 자동갱신은 미체결·시세외 데이터 재동기화 목적.
|
||||
auto_refresh_tids_js = ','.join(f"'{tid}'" for tid in valid_tids)
|
||||
|
||||
# 탭 활성 동기화 — URL hash → html[data-tab]. CSS-only 전환이라 클릭은 즉시.
|
||||
# 자산정보 탭으로 진입할 때는 전체 fetch 트리거 — 모든 owner를 한 화면에 모아 보는 요약 탭이라 stale 최소화.
|
||||
@@ -8475,6 +8490,13 @@ function updateMarketPhaseDisplay(){
|
||||
cls = 'closed';
|
||||
canTrade = false;
|
||||
}
|
||||
// 실시간 VI 발동 종목이면 모달 상태줄에 표시 (window.__rtState 는 realtime_script 가 채움)
|
||||
try {
|
||||
var _rt = window.__rtState;
|
||||
if(_rt && _rt.vi && state.code && _rt.vi[state.code] && _rt.vi[state.code].active){
|
||||
label = '⚡VI 발동 · ' + label;
|
||||
}
|
||||
} catch(e){}
|
||||
var el = $('[data-market-phase]');
|
||||
if(el){
|
||||
el.textContent = label + ' ' + (phase.time || '');
|
||||
@@ -8905,6 +8927,131 @@ window.closeOrderModal = closeModal;
|
||||
window.openPinModal = openPinModal;
|
||||
})();</script>'''
|
||||
|
||||
# 실시간 시세 갱신 — /api/realtime/quotes(허브 메모리) 1초 폴링으로 행 가격·VI 배지를 라이브 갱신.
|
||||
# 허브 콜드/끊김이면 응답이 비어 아무것도 안 함(REST로 그려진 값 유지). visibility 가드.
|
||||
realtime_script = r'''<script>(function(){
|
||||
function codesOnPage(){
|
||||
var s={};
|
||||
document.querySelectorAll('details.row[data-row-key]').forEach(function(r){
|
||||
var k=r.getAttribute('data-row-key')||'';
|
||||
if(/^\d{6}$/.test(k)) s[k]=1;
|
||||
});
|
||||
return Object.keys(s);
|
||||
}
|
||||
function paintPrice(code,q){
|
||||
// 현재가 숫자 span(.rt-px)만 갱신 — 등락률·매매표시·당일정산(실현손익) 셀은 .rt-px가 없어 자동 제외.
|
||||
// 값이 바뀔 때만 써서 깜빡임 방지.
|
||||
var t=q.price.toLocaleString();
|
||||
document.querySelectorAll('details.row[data-row-key="'+code+'"] .rt-px').forEach(function(el){
|
||||
if(el.textContent!==t) el.textContent=t;
|
||||
});
|
||||
}
|
||||
function paintVI(code,active){
|
||||
document.querySelectorAll('details.row[data-row-key="'+code+'"] .line1').forEach(function(l1){
|
||||
var b=l1.querySelector('.rt-vi-badge');
|
||||
if(active && !b){ b=document.createElement('span'); b.className='rt-vi-badge'; b.textContent='⚡VI'; l1.appendChild(b); }
|
||||
else if(!active && b){ b.remove(); }
|
||||
});
|
||||
}
|
||||
function sgn(n){ return (n>=0?'+':'')+Math.round(n).toLocaleString(); } // 음수는 toLocaleString이 '-' 포함
|
||||
function setUD(el,v){ el.classList.remove('up','down','neutral'); el.classList.add(v>0?'up':(v<0?'down':'neutral')); }
|
||||
function rowCode(r){ return (r.getAttribute('data-row-key')||'').replace(/:.*$/,''); }
|
||||
// 보유행 평가손익/평가금액 = 현재가×수량 − 매입금액(정밀). 서버 손익 공식과 동일.
|
||||
// data-rt-qty 있는 모든 사본(요약·당일매매·계좌별)에 적용.
|
||||
function paintProfit(code,price){
|
||||
document.querySelectorAll('details.row[data-rt-qty][data-row-key="'+code+'"]').forEach(function(r){
|
||||
var qty=parseFloat(r.getAttribute('data-rt-qty')||'0');
|
||||
var cost=parseFloat(r.getAttribute('data-rt-cost')||'0');
|
||||
if(!qty||!cost) return;
|
||||
var ev=price*qty, profit=ev-cost, rate=profit/cost*100;
|
||||
var pf=r.querySelector('.rt-profit'); if(pf) pf.textContent=sgn(profit);
|
||||
var pp=r.querySelector('.rt-profit-pct'); if(pp) pp.textContent=(rate>=0?'+':'')+rate.toFixed(2)+'%';
|
||||
var pd=r.querySelector('.rt-profit-detail'); if(pd){ pd.textContent=sgn(profit)+'원 ('+(rate>=0?'+':'')+rate.toFixed(2)+'%)'; setUD(pd,profit); }
|
||||
var ee=r.querySelector('.rt-eval'); if(ee) ee.textContent=Math.round(ev).toLocaleString()+'원';
|
||||
setUD(r,profit); // .row.up/.down → 현재가·손익 색 일괄
|
||||
});
|
||||
}
|
||||
// 행 등락률·당일등락·평가금액변동 = 현재가 vs NXT 종가(data-rt-prevclose). 화면 표시와 동일 기준(0B의 KRX 등락율 아님).
|
||||
function paintDay(code,price){
|
||||
document.querySelectorAll('details.row[data-rt-prevclose][data-row-key="'+code+'"]').forEach(function(r){
|
||||
var pc=parseFloat(r.getAttribute('data-rt-prevclose')||'0');
|
||||
var qty=parseFloat(r.getAttribute('data-rt-qty')||'0');
|
||||
if(pc<=0) return;
|
||||
var dc=price-pc, pct=(price/pc-1)*100, dv=dc*qty;
|
||||
var sp=r.querySelector('.rt-daypct'); if(sp){ sp.textContent=(pct>=0?'+':'')+pct.toFixed(2)+'%'; setUD(sp,dc); }
|
||||
var sv=r.querySelector('.rt-dayvalue'); if(sv){ sv.textContent=sgn(dv)+'원'; setUD(sv,dc); }
|
||||
var sd=r.querySelector('.rt-daychange'); if(sd){ sd.textContent=sgn(dc)+'원 ('+(pct>=0?'+':'')+pct.toFixed(2)+'%)'; setUD(sd,dc); }
|
||||
});
|
||||
}
|
||||
function udcls(v){ return v>0?'up':(v<0?'down':'neutral'); }
|
||||
// owner의 KPI를 해당 owner의 모든 카드(owner 탭 + 자산정보 탭)에 적용. data-rt-owner로 연결.
|
||||
function setOwnerKpis(owner,T){
|
||||
document.querySelectorAll('[data-rt-owner="'+owner+'"]').forEach(function(card){
|
||||
var tv=card.querySelector('[data-rt-kpi="value"]'); if(tv) tv.textContent=Math.round(T.value).toLocaleString()+'원';
|
||||
var tp=card.querySelector('[data-rt-kpi="profit"]'); if(tp) tp.innerHTML='<span class="'+udcls(T.profit)+'">'+sgn(T.profit)+'원 ('+(T.rate>=0?'+':'')+T.rate.toFixed(2)+'%)</span>';
|
||||
var tn=card.querySelector('[data-rt-kpi="net"]'); if(tn) tn.innerHTML='<span class="net">'+Math.round(T.net).toLocaleString()+'원</span>';
|
||||
if(T.daypl!==null){
|
||||
var td=card.querySelector('[data-rt-kpi="daypl"]');
|
||||
if(td) td.innerHTML='<span class="'+udcls(T.daypl)+'">'+sgn(T.daypl)+'원'+(T.dpct!==null?' ('+(T.dpct>=0?'+':'')+T.dpct.toFixed(2)+'%)':'')+'</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
// KPI 총합 — owner 탭 scope의 consolidated pane 행만 합산(중복합산 방지). 시세 미수신 종목은 data-rt-px(서버값) fallback.
|
||||
function updateScopes(px){
|
||||
document.querySelectorAll('.rt-scope[data-rt-owner]').forEach(function(scope){
|
||||
var rows=scope.querySelectorAll('[data-account-view-pane="consolidated"] details.row[data-rt-qty]');
|
||||
if(!rows.length) return;
|
||||
var value=0, cost=0, rv=[];
|
||||
rows.forEach(function(r){
|
||||
var qty=parseFloat(r.getAttribute('data-rt-qty')||'0');
|
||||
var c=parseFloat(r.getAttribute('data-rt-cost')||'0');
|
||||
var p=px[rowCode(r)]; if(p==null) p=parseFloat(r.getAttribute('data-rt-px')||'0');
|
||||
var v=p*qty; value+=v; cost+=c; rv.push([r,v]);
|
||||
});
|
||||
if(!cost) return;
|
||||
// 비중 = 행 평가액 / 총평가액. consolidated 행에만 (계좌별 행은 계좌단위 비중이라 code 일괄갱신 X).
|
||||
if(value) rv.forEach(function(pair){
|
||||
var w=pair[0].querySelector('.rt-weight'); if(w) w.textContent=(pair[1]/value*100).toFixed(2);
|
||||
});
|
||||
var T={value:value, profit:value-cost, rate:(value/cost-1)*100,
|
||||
net:value+parseFloat(scope.getAttribute('data-rt-deposit')||'0'),
|
||||
daypl:null, dpct:null};
|
||||
var dc=scope.getAttribute('data-rt-daypl-c'); // 당일평가손익 = 실시간평가액 + C
|
||||
if(dc!==null){
|
||||
T.daypl=value+parseFloat(dc);
|
||||
var pn=parseFloat(scope.getAttribute('data-rt-prevnet')||'0');
|
||||
T.dpct=pn?(T.daypl/pn*100):null;
|
||||
}
|
||||
setOwnerKpis(scope.getAttribute('data-rt-owner'), T);
|
||||
});
|
||||
}
|
||||
function tick(){
|
||||
if(document.hidden) return;
|
||||
var codes=codesOnPage(); if(!codes.length){ return; }
|
||||
fetch('/api/realtime/quotes?codes='+codes.join(','))
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if(!d || !d.connected) return;
|
||||
window.__rtState = d;
|
||||
var qs=d.quotes||{}, vis=d.vi||{}, px={};
|
||||
Object.keys(qs).forEach(function(c){ px[c]=qs[c].price; paintPrice(c,qs[c]); paintProfit(c,qs[c].price); paintDay(c,qs[c].price); });
|
||||
codes.forEach(function(c){ paintVI(c, !!(vis[c] && vis[c].active)); });
|
||||
window.__rtPx=px;
|
||||
updateScopes(px);
|
||||
// 체결 신호 변화 → 패널 새로고침(수량·예수금·당일정산 등 사건 기반 값). 첫 관측은 기준값만 저장(공회전 방지).
|
||||
if(d.fill_seq){
|
||||
if(window.__rtFillSeq===undefined){ window.__rtFillSeq=d.fill_seq; }
|
||||
else if(window.__rtFillSeq!==d.fill_seq){
|
||||
window.__rtFillSeq=d.fill_seq;
|
||||
if(window.__behive_load){ window.__behive_load(window.__behive_active_owner?window.__behive_active_owner():null, true); }
|
||||
}
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
setInterval(tick,1000); tick();
|
||||
})();</script>
|
||||
<style>.rt-vi-badge{display:inline-block;margin-left:6px;padding:0 5px;border-radius:4px;font-size:10px;font-weight:700;color:#1a1205;background:#ffd166;vertical-align:middle}</style>'''
|
||||
|
||||
return f'''<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
@@ -8964,6 +9111,7 @@ window.openPinModal = openPinModal;
|
||||
{modal_script}
|
||||
{order_modal_script}
|
||||
{stock_name_tip_script}
|
||||
{realtime_script}
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
@@ -9144,6 +9292,34 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == '/api/realtime/quotes':
|
||||
# 실시간 허브 메모리의 최신 현재가/VI (키움 호출 0). 허브 콜드/끊김이면 빈 응답 → JS가 무시.
|
||||
qs = parse_qs(parsed.query)
|
||||
raw = (qs.get('codes') or [''])[0]
|
||||
codes = [c for c in raw.split(',') if c.strip()]
|
||||
quotes, vi, sub_count, connected = {}, {}, 0, False
|
||||
hub = _RT_HUB
|
||||
if hub is not None:
|
||||
import kiwoom_client as kc
|
||||
connected = hub.is_connected()
|
||||
sub_count = len(hub._subs)
|
||||
if not codes:
|
||||
codes = sorted(hub._subs)
|
||||
quotes = hub.get_quotes(codes)
|
||||
for c in codes:
|
||||
v = hub.get_vi(c)
|
||||
if v:
|
||||
vi[kc._clean_code(c)] = v
|
||||
# 체결 신호 — fill_watcher가 체결 시 갱신. 브라우저가 값 변하면 패널 자동 새로고침.
|
||||
fill_seq = ''
|
||||
try:
|
||||
_fp = WORKSPACE / 'state' / 'fill_signal'
|
||||
if _fp.exists():
|
||||
fill_seq = _fp.read_text().strip()
|
||||
except Exception:
|
||||
pass
|
||||
self._send_json(200, {'connected': connected, 'sub_count': sub_count, 'quotes': quotes, 'vi': vi, 'fill_seq': fill_seq})
|
||||
return
|
||||
if parsed.path == '/api/trades':
|
||||
qs = parse_qs(parsed.query)
|
||||
code = (qs.get('code') or [''])[0].strip()
|
||||
@@ -10180,7 +10356,90 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_error(404)
|
||||
|
||||
|
||||
# ---------------- 실시간 시세 허브 (realtime_hub 연동) ----------------
|
||||
# 키움 WebSocket 실시간 현재가(0B)·VI(1h)를 메모리에 들고 화면에 라이브로 표시.
|
||||
# kill-switch: 환경변수 BEHIVE_REALTIME=0 이면 비활성(REST 폴백만). 기본 활성.
|
||||
# hub 시작·연결 실패가 페이지 서빙을 막지 않도록 전부 fail-safe.
|
||||
_RT_HUB = None
|
||||
_RT_MAX_CODES = 200 # 폭주 방지 상한. 키움은 59종목(=118등록) 수락 확인. 초과분은 우선순위 컷+로깅.
|
||||
|
||||
|
||||
def _rt_enabled() -> bool:
|
||||
import os
|
||||
return os.environ.get('BEHIVE_REALTIME', '1') not in ('0', 'false', 'no')
|
||||
|
||||
|
||||
def _rt_gather_codes() -> list:
|
||||
"""구독 대상: 보유(4계좌) > 관심 > 감시 우선순위. 상한 초과분은 잘라내고 로깅."""
|
||||
import kiwoom_client as kc
|
||||
seen: set = set()
|
||||
codes: list = []
|
||||
|
||||
def _add(code):
|
||||
if not code:
|
||||
return
|
||||
cc = kc._clean_code(code)
|
||||
if cc and cc not in seen:
|
||||
seen.add(cc)
|
||||
codes.append(cc)
|
||||
|
||||
try:
|
||||
for _label, positions in (kc.get_positions_all() or {}).items():
|
||||
for p in positions:
|
||||
_add(p.get('code'))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
try:
|
||||
for c in _load_interests():
|
||||
_add(c.get('code'))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for c in _load_watchlist():
|
||||
_add(c.get('code'))
|
||||
except Exception:
|
||||
pass
|
||||
if len(codes) > _RT_MAX_CODES:
|
||||
dropped = codes[_RT_MAX_CODES:]
|
||||
sys.stderr.write(f'[realtime] 구독 상한 {_RT_MAX_CODES} 초과 → {len(dropped)}종목 제외: {dropped}\n')
|
||||
codes = codes[:_RT_MAX_CODES]
|
||||
return codes
|
||||
|
||||
|
||||
def _rt_resubscribe_loop():
|
||||
while True:
|
||||
try:
|
||||
if _RT_HUB is not None:
|
||||
codes = _rt_gather_codes()
|
||||
if codes:
|
||||
_RT_HUB.set_subscriptions(codes)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
def _rt_start():
|
||||
"""실시간 허브 기동 (fail-safe). 실패해도 서버는 정상 서빙(REST 폴백)."""
|
||||
global _RT_HUB
|
||||
if not _rt_enabled():
|
||||
print('[realtime] BEHIVE_REALTIME=0 → 실시간 허브 비활성 (REST 폴백)', flush=True)
|
||||
return
|
||||
try:
|
||||
import realtime_hub as rh
|
||||
_RT_HUB = rh.RealtimeHub()
|
||||
codes = _rt_gather_codes()
|
||||
_RT_HUB.set_subscriptions(codes)
|
||||
_RT_HUB.start()
|
||||
threading.Thread(target=_rt_resubscribe_loop, name='realtime-resub', daemon=True).start()
|
||||
print(f'[realtime] 허브 기동 — {len(codes)}종목 구독', flush=True)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
_RT_HUB = None
|
||||
print('[realtime] 허브 기동 실패 → REST 폴백으로 계속', flush=True)
|
||||
|
||||
|
||||
def cmd_serve() -> int:
|
||||
_rt_start()
|
||||
server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler)
|
||||
print(f'serving on http://{BIND_HOST}:{BIND_PORT}', flush=True)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user