From 7f1c52a5806e28d88789e4ad4ce6977e193110cb Mon Sep 17 00:00:00 2001 From: hyowons Date: Tue, 9 Jun 2026 12:26:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=90=EC=82=B0=EC=9B=B9=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84(WS)=20NXT=20=ED=86=B5=ED=95=A9=C2=B7?= =?UTF-8?q?=EC=8B=9C=EC=84=B8=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=C2=B7=EC=97=B0=EA=B2=B0=EC=83=81=ED=83=9C=20=EC=A0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - realtime_hub: 0B/1h 구독을 _AL(통합/SOR)로 전환 → 정규장 KRX + NXT 시간대 NXT 체결을 한 구독으로 수신(수신 시 거래소 접미사 제거 후 bare 저장). NXT 당일평가손익 실시간 갱신 미동작 근본 수정. 0B 시/고/저(16/17/18) 캡처 - behive_web: WS 연결 시 보유 가격보정 ka10095 2콜 skip(완전 중복), 관심·감시 시세도 허브 메모리 우선 충당(OHLC·마크는 서버 렌더 유지, mark는 exchange 필드로 판정) → 시세성 키움 호출 중복 제거 - VI 배지: 자동갱신 swap 직후 마지막 WS 상태 즉시 재적용(깜빡임 제거), codesOnPage가 보유행(:held)·영문 6자리 코드도 수집 → 보유종목까지 VI 표시 - 상단바 WS 연결 상태 점(초록/빨강/회색) 추가 + 탭 시 상태 토스트 Co-Authored-By: Claude Opus 4.8 --- agents/stock/workspace/scripts/behive_web.py | 87 ++++++++++++++++--- .../stock/workspace/scripts/realtime_hub.py | 19 +++- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/agents/stock/workspace/scripts/behive_web.py b/agents/stock/workspace/scripts/behive_web.py index 7af8453..d39a767 100644 --- a/agents/stock/workspace/scripts/behive_web.py +++ b/agents/stock/workspace/scripts/behive_web.py @@ -661,6 +661,35 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]: if not miss_codes: return cards + # WS 허브 연결 시 — 미스 종목 시세를 허브 메모리(0B 실시간, KRX+NXT 통합)에서 우선 충당. + # 서버가 동일 렌더러로 OHLC·마크를 그대로 그린다 (마크는 허브 exchange 필드로 판정). + # 허브에 없는(아직 미체결) 종목만 ka10095 폴백으로 남긴다. 캐시엔 안 넣음 — 매 페치 라이브 허브 사용. + hub = _RT_HUB + if hub is not None and hub.is_connected(): + hub_q = hub.get_quotes(miss_codes) + rest_codes: list[str] = [] + rest_indices: list[int] = [] + for idx, code in zip(miss_indices, miss_codes): + q = hub_q.get(kc._clean_code(code)) + price = (q or {}).get('price') or 0 + if not price: + rest_codes.append(code) + rest_indices.append(idx) + continue + out = cards[idx] + out['price'] = price + out['day_change'] = q.get('change') + out['day_change_pct'] = q.get('pct') + out['open'] = q.get('open') or None + out['high'] = q.get('high') or None + out['low'] = q.get('low') or None + is_nxt = (q.get('exchange') or '').upper() == 'NXT' + out['_krx_price'] = 0 if is_nxt else price + out['_nxt_price'] = price if is_nxt else 0 + miss_codes, miss_indices = rest_codes, rest_indices + if not miss_codes: + return cards + try: batch = kc.get_watchlist_quotes(miss_codes) except Exception as ex: @@ -1090,7 +1119,10 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict: held_codes_set.add(_p['code']) ohlc_map_krx: dict[str, dict] = {} ohlc_map_nxt: dict[str, dict] = {} - if held_codes_set: + # WS 허브 연결 시 보유종목 현재가는 1초 실시간 스트림(KRX+NXT 통합)이 덮어쓰므로 + # 여기 ka10095 보정 2콜은 중복 → skip. 허브 콜드/끊김일 때만 REST로 보정(초기 렌더 정확도). + _rt_live = _RT_HUB is not None and _RT_HUB.is_connected() + if held_codes_set and not _rt_live: try: ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX') except Exception as e: @@ -3953,6 +3985,11 @@ header.top { header.top .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; } header.top h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: #f0f0f0; animation: refresh-flash 0.8s ease-out; } header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; animation: refresh-flash-meta 0.8s ease-out; } +/* 실시간(WebSocket) 연결 상태 점 — 24px 탭영역 안에 10px 컬러 점. 초록=연결 / 빨강=끊김 / 회색=확인중 */ +.ws-dot { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; flex: none; padding: 0; margin: 0; background: transparent; border: 0; cursor: pointer; } +.ws-dot::before { content: ''; width: 10px; height: 10px; border-radius: 50%; background: #5a5f6c; transition: background 0.3s, box-shadow 0.3s; } +.ws-dot.on::before { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.7); } +.ws-dot.off::before { background: #ff4d5e; box-shadow: 0 0 6px rgba(255,77,94,0.65); } /* 페이지 로드(첫 진입·새로고침) 시 1회 발동. 탭 클릭(hashchange)은 reload 아니므로 발동 안 함. */ @keyframes refresh-flash { 0%, 20% { color: #ff4d5e; } @@ -8186,6 +8223,7 @@ function showToast(text, kind, durationMs){ if(__toastTimer) clearTimeout(__toastTimer); __toastTimer = setTimeout(function(){ if(__toastEl) __toastEl.classList.remove('show'); }, durationMs || 3500); } +window.showToast = showToast; // 다른 스크립트(실시간 상태 점 등)에서 재사용 function setMsg(text, kind){ // 모달 내 메시지 영역은 hide, 알림은 토스트로 var el = $('[data-order-msg]'); if(el) el.classList.add('hidden'); @@ -8968,8 +9006,10 @@ window.openPinModal = openPinModal; 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; + // ':held'·':held:계좌'·':pending-*' 접미사 제거 → 보유행 코드도 실시간/VI 대상에 포함. + var k=(r.getAttribute('data-row-key')||'').split(':')[0]; + // 숫자+영문 6자리 — 0193W0·0154F0 등 영문 섞인 ETF/ETN 코드까지 포함. 종목명(한글) 키는 자동 제외. + if(/^[0-9A-Z]{6}$/.test(k)) s[k]=1; }); return Object.keys(s); } @@ -9079,19 +9119,42 @@ window.openPinModal = openPinModal; setOwnerKpis(scope.getAttribute('data-rt-owner'), T); }); } + // 수신 상태를 화면에 페인팅 — 1초 틱과 패널 swap 직후 재적용이 공유. + function applyState(d){ + if(!d || !d.connected) return; + var codes=codesOnPage(); + 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); + } + // 상단 WS 연결 상태 점 — 초록(on)/빨강(off)/회색(미정). tick 성공·실패마다 갱신. + function setWsStatus(ok){ + window.__wsConnected = !!ok; + var el=document.getElementById('ws-status'); if(!el) return; + el.classList.toggle('on', !!ok); + el.classList.toggle('off', !ok); + } + // 점 탭 → 현재 연결 상태 토스트. 헤더는 패널 swap 대상이 아니라 한 번만 바인딩. + (function(){ + var dot=document.getElementById('ws-status'); if(!dot) return; + dot.addEventListener('click',function(){ + var ok=window.__wsConnected; + var msg=ok?'실시간 시세 연결됨 ✓ (WebSocket)':'실시간 시세 끊김 — REST 폴백 중'; + if(window.showToast) window.showToast(msg, ok?'success':'info'); + }); + })(); function tick(){ if(document.hidden) return; - var codes=codesOnPage(); if(!codes.length){ return; } + var codes=codesOnPage(); fetch('/api/realtime/quotes?codes='+codes.join(',')) .then(function(r){ return r.json(); }) .then(function(d){ + setWsStatus(d && d.connected); 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(codes.length) applyState(d); // 체결 신호 변화 → 패널 새로고침(수량·예수금·당일정산 등 사건 기반 값). 첫 관측은 기준값만 저장(공회전 방지). // 빈 문자열('')도 유효 기준으로 취급 — 신호파일이 비어있다 첫 체결로 채워질 때도 reload 되게. var fs=d.fill_seq||''; @@ -9100,8 +9163,11 @@ window.openPinModal = openPinModal; window.__rtFillSeq=fs; if(window.__behive_load){ window.__behive_load(window.__behive_active_owner?window.__behive_active_owner():null, true); } } - }).catch(function(){}); + }).catch(function(){ setWsStatus(false); }); } + // 자동갱신 swap 직후 서버 HTML엔 VI 배지·실시간 가격이 빠져있다 → 다음 1초 틱 전까지 깜빡임. + // 마지막 수신 상태를 즉시 재적용해 배지가 사라졌다 나타나는 현상 제거. + document.addEventListener('behive:panels-loaded',function(){ if(window.__rtState) applyState(window.__rtState); }); setInterval(tick,1000); tick(); })(); ''' @@ -9130,6 +9196,7 @@ window.openPinModal = openPinModal; 갱신 {now}
+ diff --git a/agents/stock/workspace/scripts/realtime_hub.py b/agents/stock/workspace/scripts/realtime_hub.py index 7c3db2a..501718a 100644 --- a/agents/stock/workspace/scripts/realtime_hub.py +++ b/agents/stock/workspace/scripts/realtime_hub.py @@ -68,7 +68,7 @@ class RealtimeHub: 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._quotes: dict[str, dict] = {} # code -> {price, change, pct, open, high, low, ts, exchange} self._vi: dict[str, dict] = {} # code -> {active, kind, trigger_price, ...} self._subs: set[str] = set() # 현재 구독 종목코드(정규화) self._ws: websocket.WebSocket | None = None @@ -261,17 +261,23 @@ class RealtimeHub: return self._ws.send(json.dumps(payload)) + @staticmethod + def _al(codes: list[str]) -> list[str]: + # 통합(SOR) 거래소코드 `_AL` 접미사 — 정규장 KRX 체결 + NXT 시간대 NXT 체결을 + # 한 구독으로 수신. bare 코드는 KRX 단독이라 NXT(15:30~20:00)에 시세가 끊긴다. (PDF 0B/1h 스펙) + return [f'{c}_AL' for c in codes] + 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']}], + 'data': [{'item': self._al(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']}], + 'data': [{'item': self._al(codes), 'type': ['0B', '1h']}], }) # ---- 수신 처리 ---- @@ -302,7 +308,9 @@ class RealtimeHub: def _handle_real(self, blk: dict) -> None: typ = blk.get('type') - code = kc._clean_code(blk.get('item') or '') + # 통합(_AL)·NXT(_NX) 구독은 item이 `039490_AL` 형태로 echo됨 → 거래소 접미사를 떼고 + # bare 코드로 저장해야 get_quotes(bare) 매칭이 유지된다. 키움 종목코드엔 '_'가 없다. + code = kc._clean_code((blk.get('item') or '').split('_')[0]) vals = blk.get('values') or {} if not code: return @@ -312,6 +320,9 @@ class RealtimeHub: 'price': _abs_int(vals.get('10')), 'change': kc._to_int(vals.get('11')), 'pct': kc._to_float(vals.get('12')), + 'open': _abs_int(vals.get('16')), # 시가 + 'high': _abs_int(vals.get('17')), # 고가 + 'low': _abs_int(vals.get('18')), # 저가 'exchange': (vals.get('9081') or '').strip(), 'ts': datetime.now(KST).isoformat(), }