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:
hyowons
2026-06-08 14:30:19 +09:00
parent 020ac27bd0
commit 925014dfd8
3 changed files with 608 additions and 18 deletions
+277 -18
View File
@@ -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:
@@ -61,6 +61,17 @@ _STATE_DIR = _WORKSPACE_ROOT / 'state'
QUEUE_FILE = _STATE_DIR / 'fill_pending.jsonl'
QUEUE_LOCK = _STATE_DIR / 'fill_pending.jsonl.lock'
PID_FILE = _STATE_DIR / 'fill_watcher.pid'
# 체결 발생 신호 파일 — behive_web(별도 프로세스)이 /api/realtime/quotes 응답에 값을 실어주고,
# 브라우저가 값 변화 감지 시 자산웹 패널을 자동 새로고침(수량·예수금·당일정산 반영). file 기반 IPC.
FILL_SIGNAL_FILE = _STATE_DIR / 'fill_signal'
def _bump_fill_signal() -> None:
"""체결(전량/부분) 시 신호파일 갱신 → behive_web 패널 자동 새로고침 트리거. 실패 무시."""
try:
FILL_SIGNAL_FILE.write_text(str(time.time()), encoding='utf-8')
except Exception:
pass
# ---------- Tracked entry ----------
@@ -330,6 +341,7 @@ class _FillWatcher:
cntr_qty, cntr_uv, t.ord_no))
# 전량 체결 → 자산웹 거래내역 즉시 갱신 (별도 스레드, 추적 블로킹 X)
_spawn_journal_collect()
_bump_fill_signal() # 자산웹 패널 자동 새로고침(수량·예수금·당일정산)
else:
ledger.append('partial', {'card_id': t.card_id, 'ord_no': t.ord_no,
'account': t.account, 'symbol': t.symbol,
@@ -337,6 +349,7 @@ class _FillWatcher:
'price': cntr_uv, 'new_fill': new_fill})
self._send(card.format_partial(t.card_id, t.side, t.symbol_name,
cntr_qty, t.order_qty, cntr_uv, t.ord_no))
_bump_fill_signal() # 부분체결도 수량·예수금 변동 → 패널 새로고침
def _handle_timeout(self, t: Tracked) -> None:
with self._lock:
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""키움 실시간 시세 허브 (WebSocket) — A단계 단독 모듈.
조회 전용 REST(kiwoom_client) 별개로, 키움 실시간 시세 WebSocket을 상시 청취해
최신 현재가(0B 주식체결)·VI 발동/해제(1h) 메모리에 들고 있다가 읽어주는 컴포넌트.
behive_web 프로세스 안의 데몬 스레드로 띄울 예정이지만(B단계), 파일 자체는
behive_web을 전혀 import/수정하지 않는다. 단독 실행으로 실접속·파싱을 먼저 검증한다.
python3 realtime_hub.py test 005930 000660 # 지정 종목 실시간 수신을 콘솔에 출력
설계:
- 연결 1(시세는 계좌 무관 토큰 아무 계좌나 재사용)
- 흐름: connect LOGIN REG(구독) 수신 루프(PING 에코·REAL 파싱) 끊기면 백오프 재연결+재구독
- 메모리 상태 저장소(lock): quotes{code:{...}}, vi{code:{...}}
- hub가 콜드/끊김이어도 호출측은 REST로 폴백 가능하도록, 읽기 메서드는 없으면 None 반환
키움 WS 프로토콜:
- 엔드포인트: wss://api.kiwoom.com:10000/api/dostk/websocket (운영)
- LOGIN/PING 핸드셰이크는 공식 PDF에 표가 없어 키움 공식 샘플 규약을 따름:
LOGIN 송신: {"trnm":"LOGIN","token":"<access_token>"} (Bearer 접두어 없이 raw 토큰)
서버가 {"trnm":"PING"} 주기 송신 같은 메시지 그대로 에코
- REG/REAL 스펙·필드코드는 PDF(국내주식>실시간시세) 기준:
0B 주식체결: 10=현재가 11=전일대비 12=등락율 20=체결시간 16/17/18=시고저 9081=거래소
1h VI발동/해제: 9068=발동구분 1221=발동가격 1224=해제시각 1225=적용구분 1489=등락율 9069=방향
"""
from __future__ import annotations
import json
import sys
import threading
import time
from datetime import datetime, timezone, timedelta
import websocket # websocket-client (동기식). 기존 threading 서버와 궁합.
import kiwoom_client as kc
KST = timezone(timedelta(hours=9))
# 재연결 백오프 (초) — 점증, 상한 30s
_BACKOFF_START = 2.0
_BACKOFF_MAX = 30.0
# 토큰 만료 전 선제 재연결 (초). 키움 토큰 ~30분 → 25분마다 새 연결로 갈아탐.
_MAX_CONN_AGE = 25 * 60
# recv 타임아웃 — 이 시간 내 아무것도 안 오면 루프 돌며 stop/age 체크
_RECV_TIMEOUT = 5.0
def _ws_url() -> str:
"""REST base_url에서 WS 엔드포인트 도출. https→wss, :10000, /api/dostk/websocket."""
base = kc.base_url() # 예: https://api.kiwoom.com 또는 https://mockapi.kiwoom.com
host = base.split('://', 1)[-1].rstrip('/')
return f'wss://{host}:10000/api/dostk/websocket'
def _abs_int(s) -> int:
"""부호 포함 문자열 → 절대값 int (현재가·호가용)."""
return abs(kc._to_int(s))
class RealtimeHub:
"""키움 실시간 시세 허브. start()로 데몬 스레드 기동, get_quote/get_vi로 읽기."""
def __init__(self, account_label: str | None = None):
self._label = account_label or kc._default_account_label()
self._lock = threading.Lock()
self._quotes: dict[str, dict] = {} # code -> {price, change, pct, ts, exchange}
self._vi: dict[str, dict] = {} # code -> {active, kind, trigger_price, ...}
self._subs: set[str] = set() # 현재 구독 종목코드(정규화)
self._ws: websocket.WebSocket | None = None
self._ws_lock = threading.Lock() # send 직렬화
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._connected = threading.Event()
self.last_error: str | None = None
self.connected_at: float = 0.0
# ---- 공개 읽기 API (없으면 None → 호출측 REST 폴백) ----
def get_quote(self, code: str) -> dict | None:
with self._lock:
q = self._quotes.get(kc._clean_code(code))
return dict(q) if q else None
def get_quotes(self, codes: list[str]) -> dict[str, dict]:
with self._lock:
out = {}
for c in codes:
cc = kc._clean_code(c)
if cc in self._quotes:
out[cc] = dict(self._quotes[cc])
return out
def get_vi(self, code: str) -> dict | None:
with self._lock:
v = self._vi.get(kc._clean_code(code))
return dict(v) if v else None
def active_vis(self) -> dict[str, dict]:
with self._lock:
return {c: dict(v) for c, v in self._vi.items() if v.get('active')}
def is_connected(self) -> bool:
return self._connected.is_set()
# ---- 구독 관리 ----
def set_subscriptions(self, codes: list[str]) -> None:
"""구독 종목 목록을 통째로 교체. 연결돼 있으면 즉시 REG/REMOVE 반영."""
new = {kc._clean_code(c) for c in codes if c}
with self._lock:
add = new - self._subs
rem = self._subs - new
self._subs = new
if self._connected.is_set():
if add:
self._send_reg(sorted(add))
if rem:
self._send_remove(sorted(rem))
# ---- 생명주기 ----
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, name='realtime-hub', daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
with self._ws_lock:
if self._ws:
try:
self._ws.close()
except Exception:
pass
# ---- 내부: 연결 루프 ----
def _run(self) -> None:
backoff = _BACKOFF_START
while not self._stop.is_set():
try:
self._connect_once()
backoff = _BACKOFF_START # 정상 종료(선제 재연결 등) → 백오프 리셋
except Exception as e:
self.last_error = f'{type(e).__name__}: {e}'
self._connected.clear()
sys.stderr.write(f'[realtime-hub] 연결 끊김/실패: {self.last_error}\n')
if self._stop.is_set():
break
time.sleep(backoff)
backoff = min(backoff * 2, _BACKOFF_MAX)
def _connect_once(self) -> None:
url = _ws_url()
token = kc.issue_token(self._label) # 매 연결마다 fresh (캐시·만료는 issue_token이 관리)
ws = websocket.create_connection(url, timeout=10)
ws.settimeout(_RECV_TIMEOUT)
with self._ws_lock:
self._ws = ws
try:
# 1) LOGIN
ws.send(json.dumps({'trnm': 'LOGIN', 'token': token}))
login = json.loads(ws.recv())
if login.get('trnm') == 'LOGIN' and login.get('return_code') not in (0, '0', None):
raise RuntimeError(f'LOGIN 실패: {login.get("return_msg")} ({login})')
self._connected.set()
self.connected_at = time.time()
self.last_error = None
sys.stderr.write(f'[realtime-hub] LOGIN 성공 @ {url}\n')
# 2) 현재 구독 종목 REG
with self._lock:
codes = sorted(self._subs)
if codes:
self._send_reg(codes)
# 3) 수신 루프
while not self._stop.is_set():
if time.time() - self.connected_at > _MAX_CONN_AGE:
sys.stderr.write('[realtime-hub] 토큰 만료 전 선제 재연결\n')
return # 정상 종료 → _run이 즉시 재연결
try:
raw = ws.recv()
except websocket.WebSocketTimeoutException:
continue
if not raw:
continue
self._handle(raw)
finally:
self._connected.clear()
with self._ws_lock:
self._ws = None
try:
ws.close()
except Exception:
pass
# ---- 송신 ----
def _send(self, payload: dict) -> None:
with self._ws_lock:
if self._ws is None:
return
self._ws.send(json.dumps(payload))
def _send_reg(self, codes: list[str]) -> None:
# 0B(체결) + 1h(VI) 동시 등록. refresh=1 → 기존 구독 유지하며 추가.
self._send({
'trnm': 'REG', 'grp_no': '1', 'refresh': '1',
'data': [{'item': codes, 'type': ['0B', '1h']}],
})
def _send_remove(self, codes: list[str]) -> None:
self._send({
'trnm': 'REMOVE', 'grp_no': '1',
'data': [{'item': codes, 'type': ['0B', '1h']}],
})
# ---- 수신 처리 ----
def _handle(self, raw: str) -> None:
try:
msg = json.loads(raw)
except (ValueError, TypeError):
return
trnm = msg.get('trnm')
if trnm == 'PING':
# PING은 받은 그대로 에코 (키움 keepalive 규약)
with self._ws_lock:
if self._ws is not None:
self._ws.send(raw)
return
if trnm == 'REAL':
for blk in msg.get('data') or []:
self._handle_real(blk)
return
if trnm in ('REG', 'REMOVE'):
rc = msg.get('return_code')
if rc not in (0, '0', None):
sys.stderr.write(f'[realtime-hub] {trnm} 거부: rc={rc} msg={msg.get("return_msg")}\n')
else:
sys.stderr.write(f'[realtime-hub] {trnm} ok (rc={rc})\n')
return
# LOGIN 응답 등 기타는 무시
def _handle_real(self, blk: dict) -> None:
typ = blk.get('type')
code = kc._clean_code(blk.get('item') or '')
vals = blk.get('values') or {}
if not code:
return
if typ == '0B': # 주식체결 → 현재가
with self._lock:
self._quotes[code] = {
'price': _abs_int(vals.get('10')),
'change': kc._to_int(vals.get('11')),
'pct': kc._to_float(vals.get('12')),
'exchange': (vals.get('9081') or '').strip(),
'ts': datetime.now(KST).isoformat(),
}
elif typ == '1h': # VI 발동/해제
# active 판정: 9068 VI발동구분. 해제시각(1224) 유무와 함께 실증 확인 예정(A단계).
kind_raw = (vals.get('9068') or '').strip()
with self._lock:
self._vi[code] = {
'active': kind_raw not in ('', '0', '2'), # ⚠️ 매핑 실증 확인 필요
'vi_gubun': kind_raw,
'apply_kind': (vals.get('1225') or '').strip(), # 정적/동적/동적+정적
'trigger_price': _abs_int(vals.get('1221')),
'trigger_pct': kc._to_float(vals.get('1489')),
'release_time': (vals.get('1224') or '').strip(),
'direction': (vals.get('9069') or '').strip(),
'ts': datetime.now(KST).isoformat(),
}
# ---------------- CLI (A단계 단독 검증) ----------------
def _cmd_test(codes: list[str]) -> int:
if not codes:
codes = ['005930', '000660'] # 삼성전자, SK하이닉스
print(f'[test] WS URL = {_ws_url()}')
print(f'[test] 구독 종목 = {codes} (Ctrl-C 종료)')
hub = RealtimeHub()
hub.set_subscriptions(codes)
hub.start()
last_print: dict[str, str] = {}
try:
while True:
time.sleep(1)
if not hub.is_connected():
continue
for c in codes:
cc = kc._clean_code(c)
q = hub.get_quote(cc)
if q:
line = f"{q['price']:,} ({q['pct']:+.2f}%) {q['exchange']}"
if last_print.get(cc) != line:
print(f' [{cc}] {line} @ {q["ts"][11:19]}')
last_print[cc] = line
v = hub.get_vi(cc)
if v and v.get('active'):
vk = f"VI:{cc} {v['apply_kind']} 발동가 {v['trigger_price']:,} ({v['trigger_pct']:+.2f}%)"
if last_print.get(f'vi_{cc}') != vk:
print(f'{vk}')
last_print[f'vi_{cc}'] = vk
except KeyboardInterrupt:
print('\n[test] 종료')
hub.stop()
return 0
def main(argv: list[str]) -> int:
if not argv or argv[0] != 'test':
print('usage: python3 realtime_hub.py test [code ...]', file=sys.stderr)
return 2
return _cmd_test(argv[1:])
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))