feat: 자산웹 실시간(WS) NXT 통합·시세 중복 제거·연결상태 점

- 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 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-09 12:26:45 +09:00
parent 69ef9c09e8
commit 7f1c52a580
2 changed files with 92 additions and 14 deletions
+77 -10
View File
@@ -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();
})();</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>'''
@@ -9130,6 +9196,7 @@ window.openPinModal = openPinModal;
<span class="meta">갱신 {now}</span>
</div>
<div class="top-actions">
<button type="button" id="ws-status" class="ws-dot" title="실시간 연결 상태 (탭하면 표시)" aria-label="실시간 연결 상태"></button>
<button type="button" class="account-view-toggle-btn" data-account-view-state="consolidated" aria-label="계좌 보기 방식 (합산 / 각계좌별도) 토글" title="합산 / 각계좌별도 토글"></button>
<button id="auto-toggle" class="auto-toggle" type="button" aria-pressed="false" title="자동 갱신 사이클: off → 10s → 3s (자산정보·관리자·가희·관심종목 탭에서 동작, 페이지를 보고 있을 때만)"><span class="auto-dot"></span><span>자동</span></button>
<a class="refresh" href="#" onclick="event.preventDefault();window.__behive_manual_refresh&&window.__behive_manual_refresh();" aria-label="새로고침" title="새로고침"></a>
+15 -4
View File
@@ -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(),
}