/** * useLiveStrategyMarkers * * STOMP WebSocket 으로 /sub/charts/{market}/{candleType} 구독: * - signal 필드(BUY/SELL) 수신 시 마커 누적 (종목별) * - 동일 종목·타임스탬프·시그널 중복 방지 * - activeMarket 차트에만 마커 렌더링, 알림 팝업은 모든 모니터링 종목에 대해 발생 */ import { useEffect, useRef, useCallback, useState } from 'react'; import { parseStompJson } from '../utils/stompMessage'; import { subscribeStompTopic } from '../utils/stompChartBroker'; export interface LiveMarker { time: number; signal: 'BUY' | 'SELL'; price: number; market: string; } export interface MarketCandleSub { market: string; candleType: string; } interface UseLiveStrategyMarkersOptions { /** 실시간 체크 ON 인 종목 목록 */ markets: string[]; /** 종목별 STOMP 분봉 (없으면 candleType 기본값 사용) */ subscriptions?: MarketCandleSub[]; activeMarket: string; candleType?: string; enabled: boolean; onMarkersChange: (markers: LiveMarker[]) => void; onSignal?: (marker: LiveMarker) => void; } export function useLiveStrategyMarkers({ markets, subscriptions, activeMarket, candleType = '1m', enabled, onMarkersChange, onSignal, }: UseLiveStrategyMarkersOptions) { const [liveMarkers, setLiveMarkers] = useState([]); const markersByMarketRef = useRef>({}); const seenRef = useRef>(new Set()); const onMarkersChangeRef = useRef(onMarkersChange); onMarkersChangeRef.current = onMarkersChange; const onSignalRef = useRef(onSignal); onSignalRef.current = onSignal; const pushMarker = useCallback((market: string, data: { time: number; close: number; signal: string; }) => { if (data.signal !== 'BUY' && data.signal !== 'SELL') return; const key = `${market}:${data.time}:${data.signal}`; if (seenRef.current.has(key)) return; seenRef.current.add(key); const newMarker: LiveMarker = { time: data.time, signal: data.signal as 'BUY' | 'SELL', price: data.close, market, }; const prev = markersByMarketRef.current[market] ?? []; markersByMarketRef.current[market] = [...prev, newMarker]; if (market === activeMarket) { const active = markersByMarketRef.current[activeMarket] ?? []; setLiveMarkers(active); onMarkersChangeRef.current(active); } onSignalRef.current?.(newMarker); }, [activeMarket]); const clearMarkers = useCallback(() => { seenRef.current.clear(); markersByMarketRef.current = {}; setLiveMarkers([]); onMarkersChangeRef.current([]); }, []); /** 활성 차트 종목 변경 시 해당 종목 마커만 차트에 반영 */ useEffect(() => { if (!enabled) return; const active = markersByMarketRef.current[activeMarket] ?? []; setLiveMarkers(active); onMarkersChangeRef.current(active); }, [activeMarket, enabled]); const marketsKey = markets.join(','); const subsKey = subscriptions?.map(s => `${s.market}:${s.candleType}`).join(',') ?? ''; useEffect(() => { if (!enabled || markets.length === 0) { clearMarkers(); return; } const subs: MarketCandleSub[] = subscriptions?.length ? subscriptions : markets.map(m => ({ market: m, candleType })); const unsubs = subs.map(({ market, candleType: ct }) => { const topic = `/sub/charts/${market}/${ct}`; return subscribeStompTopic(topic, msg => { const data = parseStompJson<{ time: number; close: number; signal?: string; }>(msg); if (!data?.signal || data.signal === 'NONE') return; pushMarker(market, { time: data.time, close: data.close, signal: data.signal }); }); }); return () => { unsubs.forEach(u => u()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [marketsKey, subsKey, candleType, enabled]); return { liveMarkers, clearMarkers }; }