/** * 가상투자 — 종목별 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; /** useStompChartData 와 동일 — STOMP 수립 후 틱 없어도 실시간 파이프라인 준비 완료로 간주 */ const CONN_GRACE_MS = 800; 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}`; } function isCandlePayload(data: { time?: unknown; close?: unknown } | null): boolean { if (!data) return false; return typeof data.time === 'number' || typeof data.close === 'number'; } /** 목록·카드에서 liveStatusByMarket 미등록 시 fallback */ export function resolveVirtualLiveStatusFallback( market: string, strategyId: number | null, statusByMarket: Record, running: boolean, ): VirtualLiveStatus { const known = statusByMarket[market]; if (known) return known; if (!strategyId) return 'idle'; return running ? 'connecting' : 'idle'; } export interface VirtualTargetLiveState { statusByMarket: Record; /** STOMP 틱 수신 시각 (ms) — 카드 형광 플래시 트리거 */ lastTickAtByMarket: Record; } export function useVirtualTargetLiveStatus( targets: TargetRef[], running: boolean, /** 전략 지정만 된 상태에서도 STOMP·pin으로 업비트 틱 수신 (지표 warm-up 보조) */ eagerConnect = true, ): VirtualTargetLiveState { const [byMarket, setByMarket] = useState>({}); const [lastTickAtByMarket, setLastTickAtByMarket] = useState>({}); const stateRef = useRef>({}); const stompConnectedRef = useRef(isStompChartConnected()); const stompConnectedAtRef = useRef( isStompChartConnected() ? Date.now() : null, ); 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) 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) { const connectedAt = stompConnectedAtRef.current; if (connectedAt != null && now - connectedAt >= CONN_GRACE_MS) { patchMarket(market, { status: 'live' }); return; } patchMarket(market, { status: 'connecting' }); } else { patchMarket(market, { status: 'disconnected' }); } }; const targetsKey = targets .filter(t => t.strategyId != null) .map(t => `${t.market}:${t.strategyId}`) .join('|'); useEffect(() => { const activeMarkets = targets .filter(t => t.strategyId != null) .map(t => t.market); const active = running || (eagerConnect && activeMarkets.length > 0); if (!active) { stateRef.current = {}; setByMarket({}); setLastTickAtByMarket({}); return; } 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; close?: number }>(msg); if (!isCandlePayload(data)) return; patchMarket(market, { status: 'live', lastTickAt: Date.now() }); }); }); let graceTimer: ReturnType | null = null; const scheduleGraceRecompute = () => { if (graceTimer) clearTimeout(graceTimer); if (!stompConnectedRef.current) return; graceTimer = setTimeout(() => { for (const market of activeMarkets) recomputeStatus(market); }, CONN_GRACE_MS); }; const offConn = subscribeStompConnection(next => { stompConnectedRef.current = next; stompConnectedAtRef.current = next ? Date.now() : null; for (const market of activeMarkets) recomputeStatus(market); if (next) scheduleGraceRecompute(); }); if (stompConnectedRef.current) scheduleGraceRecompute(); const staleId = window.setInterval(() => { for (const market of activeMarkets) recomputeStatus(market); }, 3000); return () => { unsubs.forEach(u => u()); offConn(); clearInterval(staleId); if (graceTimer) clearTimeout(graceTimer); }; }, [targetsKey, running, eagerConnect]); return { statusByMarket: byMarket, lastTickAtByMarket }; }