/** * 가상투자 — 종목별 STOMP 실시간 데이터 수신 상태 */ import { useEffect, useRef, useState } from 'react'; import { pinCandleWatch } from '../utils/backendApi'; import { parseStompJson } from '../utils/stompMessage'; import { isStompChartConnected, subscribeStompConnection, subscribeStompTopic, } from '../utils/stompChartBroker'; /** idle=미시작, connecting=연결 중, live=틱 수신 중, disconnected=끊김 */ export type VirtualLiveStatus = 'idle' | 'connecting' | 'live' | 'disconnected'; const STALE_MS = 20_000; const TICK_CANDLE = '1m'; interface TargetRef { market: string; strategyId: number | null; } interface MarketLiveState { status: VirtualLiveStatus; lastTickAt: number | null; } function topicFor(market: string): string { return `/sub/charts/${market}/${TICK_CANDLE}`; } export interface VirtualTargetLiveState { statusByMarket: Record; /** STOMP 틱 수신 시각 (ms) — 카드 형광 플래시 트리거 */ lastTickAtByMarket: Record; } export function useVirtualTargetLiveStatus( targets: TargetRef[], running: boolean, ): VirtualTargetLiveState { const [byMarket, setByMarket] = useState>({}); const [lastTickAtByMarket, setLastTickAtByMarket] = useState>({}); const stateRef = useRef>({}); const stompConnectedRef = useRef(isStompChartConnected()); const patchMarket = (market: string, patch: Partial) => { const prev = stateRef.current[market] ?? { status: 'idle' as VirtualLiveStatus, lastTickAt: null }; const next: MarketLiveState = { ...prev, ...patch }; stateRef.current[market] = next; setByMarket(cur => ({ ...cur, [market]: next.status })); if (patch.lastTickAt != null) { setLastTickAtByMarket(cur => ({ ...cur, [market]: patch.lastTickAt! })); } }; const recomputeStatus = (market: string) => { const s = stateRef.current[market]; if (!s || !running) return; const now = Date.now(); if (s.lastTickAt != null && now - s.lastTickAt <= STALE_MS) { if (s.status !== 'live') patchMarket(market, { status: 'live' }); return; } if (stompConnectedRef.current) { patchMarket(market, { status: 'connecting' }); } else { patchMarket(market, { status: 'disconnected' }); } }; useEffect(() => { if (!running) { stateRef.current = {}; setByMarket({}); setLastTickAtByMarket({}); return; } const activeMarkets = targets .filter(t => t.strategyId != null) .map(t => t.market); for (const market of activeMarkets) { if (!stateRef.current[market]) { stateRef.current[market] = { status: 'connecting', lastTickAt: null }; setByMarket(cur => ({ ...cur, [market]: 'connecting' })); } void pinCandleWatch(market, TICK_CANDLE); } for (const key of Object.keys(stateRef.current)) { if (!activeMarkets.includes(key)) { delete stateRef.current[key]; setByMarket(cur => { const next = { ...cur }; delete next[key]; return next; }); setLastTickAtByMarket(cur => { const next = { ...cur }; delete next[key]; return next; }); } } const unsubs = activeMarkets.map(market => { const topic = topicFor(market); return subscribeStompTopic(topic, msg => { const data = parseStompJson<{ time?: number }>(msg); if (data?.time == null) return; patchMarket(market, { status: 'live', lastTickAt: Date.now() }); }); }); const offConn = subscribeStompConnection(next => { stompConnectedRef.current = next; for (const market of activeMarkets) recomputeStatus(market); }); const staleId = window.setInterval(() => { for (const market of activeMarkets) recomputeStatus(market); }, 3000); return () => { unsubs.forEach(u => u()); offConn(); clearInterval(staleId); }; }, [targets, running]); return { statusByMarket: byMarket, lastTickAtByMarket }; }