/** * 백엔드 STOMP /sub/charts/{market}/{candleType} 실시간 캔들 (Blueprint 중앙 집중 경로) */ import { useState, useEffect, useRef, useCallback } from 'react'; import type { OHLCVBar, Timeframe } from '../types'; import { parseStompJson } from '../utils/stompMessage'; import { subscribeStompTopic } from '../utils/stompChartBroker'; import { timeframeToCandleType } from '../utils/chartCandleType'; import { getCandleStart, TF_SECONDS } from '../utils/upbitApi'; import { DISPLAY_COUNT, WARMUP_COUNT, OBV_DEEP_HISTORY } from './useUpbitData'; import type { WsStatus } from './useUpbitData'; import { fetchBacktestHistoryCandles } from '../utils/backtestWarmup'; import { API_BASE } from '../utils/backendApi'; /** 백엔드 BarBuilder 는 틱마다 1m 만 STOMP 발행 — 차트 분봉은 클라이언트에서 집계 */ const STOMP_TICK_CANDLE_TYPE = '1m'; /** 표시 분봉 버킷 — 히스토리 마지막 봉과 STOMP time 불일치 시 마지막 봉 갱신용 */ function resolveChartBarTime( bucketTime: number, displayTf: Timeframe, lastChartTime: number | undefined, ): number { if (lastChartTime == null || bucketTime >= lastChartTime) return bucketTime; const tfSec = TF_SECONDS[displayTf] ?? 60; if (lastChartTime - bucketTime < tfSec) return lastChartTime; return bucketTime; } async function pinChartWatch(market: string, candleType: string): Promise { try { await fetch(`${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`, { method: 'POST', }); } catch { /* watch 실패해도 STOMP 구독은 계속 */ } } interface StompCandlePayload { time: number; open: number; high: number; low: number; close: number; volume: number; } interface Options { onTickUpdate: (bar: OHLCVBar) => void; onNewCandle: (bar: OHLCVBar) => void; enabled?: boolean; /** OBV 누적 정확도 — 더 많은 초기 봉 로드 */ deepObvHistory?: boolean; } interface Return { bars: OHLCVBar[]; /** bars 가 어느 종목 기준인지 (로딩 중·전환 직후 null) */ barsMarket: string | null; latestBar: OHLCVBar | null; wsStatus: WsStatus; isLoading: boolean; error: string | null; } /** * 백엔드 /api/candles/history — 200봉 초과 시 페이지네이션 (워밍업·OBV 누적용) */ async function loadInitialBars( market: string, timeframe: Timeframe, count: number, ): Promise { try { return await fetchBacktestHistoryCandles(market, timeframe, count); } catch { return []; } } export function useStompChartData( market: string, timeframe: Timeframe, opts: Options, ): Return { const candleType = timeframeToCandleType(timeframe); const [bars, setBars] = useState([]); const [barsMarket, setBarsMarket] = useState(null); const [latestBar, setLatestBar] = useState(null); const [wsStatus, setWsStatus] = useState('connecting'); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const barsRef = useRef([]); const optsRef = useRef(opts); optsRef.current = opts; const lastTimeRef = useRef(0); /** 1m 봉 volume 스냅샷 — 상위 분봉 volume 델타 계산용 */ const last1mVolRef = useRef(0); const last1mTimeRef = useRef(0); /** 초기 히스토리 로드 완료 전에는 차트 직접 갱신 콜백을 호출하지 않음 */ const historyReadyRef = useRef(false); /** 백엔드 history 비어 있을 때 STOMP 누적 → React bars 동기화 */ const bootstrapFromStompRef = useRef(false); useEffect(() => { if (opts.enabled === false) { setIsLoading(false); return; } let cancelled = false; historyReadyRef.current = false; bootstrapFromStompRef.current = false; setIsLoading(true); setError(null); setBars([]); setBarsMarket(null); barsRef.current = []; last1mVolRef.current = 0; last1mTimeRef.current = 0; lastTimeRef.current = 0; const count = opts.deepObvHistory ? OBV_DEEP_HISTORY : DISPLAY_COUNT + WARMUP_COUNT; loadInitialBars(market, timeframe, count) .then(newBars => { if (cancelled) return; barsRef.current = newBars; historyReadyRef.current = true; setBarsMarket(market); if (newBars.length >= 2) { bootstrapFromStompRef.current = false; setBars(newBars); setLatestBar(newBars[newBars.length - 1] ?? null); lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0; } else { bootstrapFromStompRef.current = true; setBars([]); setLatestBar(null); } setIsLoading(false); }) .catch(err => { if (cancelled) return; setError(`STOMP 초기 로딩 실패: ${(err as Error).message}`); setIsLoading(false); }); return () => { cancelled = true; }; }, [market, candleType, timeframe, opts.enabled, opts.deepObvHistory]); const syncBarsToReactIfNeeded = useCallback((marketId: string) => { if (!bootstrapFromStompRef.current) return; if (barsRef.current.length < 2) return; bootstrapFromStompRef.current = false; setBarsMarket(marketId); setBars([...barsRef.current]); }, []); const applyCandle = useCallback((data: StompCandlePayload, displayTf: Timeframe) => { if (!historyReadyRef.current) return; const oneMin: OHLCVBar = { time: data.time, open: data.open, high: data.high, low: data.low, close: data.close, volume: data.volume, }; const pushBar = (bar: OHLCVBar) => { const current = barsRef.current; const notifyChart = historyReadyRef.current; if (current.length === 0) { barsRef.current = [bar]; setLatestBar(bar); if (notifyChart) optsRef.current.onNewCandle(bar); lastTimeRef.current = bar.time; return; } const last = current[current.length - 1]; if (bar.time === last.time) { barsRef.current[current.length - 1] = bar; setLatestBar(bar); if (notifyChart) optsRef.current.onTickUpdate(bar); } else if (bar.time > last.time) { barsRef.current = [...current, bar]; setLatestBar(bar); if (notifyChart) optsRef.current.onNewCandle(bar); } else { const patched = { ...bar, time: last.time }; barsRef.current[current.length - 1] = patched; setLatestBar(patched); if (notifyChart) optsRef.current.onTickUpdate(patched); } lastTimeRef.current = bar.time; syncBarsToReactIfNeeded(market); }; const lastChart = barsRef.current[barsRef.current.length - 1]; // 1m 차트: STOMP 페이로드를 그대로 사용 if (displayTf === '1m') { const t = resolveChartBarTime(oneMin.time, '1m', lastChart?.time); pushBar({ ...oneMin, time: t }); return; } // 상위 분봉: 1m 틱을 useUpbitData 와 동일하게 집계 const candleStart = resolveChartBarTime( getCandleStart(oneMin.time * 1000, displayTf), displayTf, lastChart?.time, ); const current = barsRef.current; if (current.length === 0) { pushBar({ time: candleStart, open: oneMin.open, high: oneMin.high, low: oneMin.low, close: oneMin.close, volume: oneMin.volume, }); last1mTimeRef.current = oneMin.time; last1mVolRef.current = oneMin.volume; return; } let volDelta = oneMin.volume; if (oneMin.time === last1mTimeRef.current) { volDelta = oneMin.volume - last1mVolRef.current; } else { last1mTimeRef.current = oneMin.time; } last1mVolRef.current = oneMin.volume; const last = current[current.length - 1]; if (last.time === candleStart) { const updated: OHLCVBar = { time: candleStart, open: last.open, high: Math.max(last.high, oneMin.high), low: Math.min(last.low, oneMin.low), close: oneMin.close, volume: Math.max(0, last.volume + volDelta), }; pushBar(updated); } else if (candleStart > last.time) { pushBar({ time: candleStart, open: oneMin.open, high: oneMin.high, low: oneMin.low, close: oneMin.close, volume: oneMin.volume, }); last1mTimeRef.current = oneMin.time; last1mVolRef.current = oneMin.volume; } }, [market, syncBarsToReactIfNeeded]); useEffect(() => { if (opts.enabled === false) { setWsStatus('disconnected'); return; } setWsStatus('connecting'); setError(null); last1mVolRef.current = 0; last1mTimeRef.current = 0; void pinChartWatch(market, STOMP_TICK_CANDLE_TYPE); if (candleType !== STOMP_TICK_CANDLE_TYPE) { void pinChartWatch(market, candleType); } const topic = `/sub/charts/${market}/${STOMP_TICK_CANDLE_TYPE}`; let alive = true; const unsub = subscribeStompTopic(topic, msg => { if (!alive) return; setWsStatus('connected'); const data = parseStompJson(msg); if (data?.time == null || typeof data.time !== 'number') return; applyCandle(data, timeframe); }); const t = window.setTimeout(() => setWsStatus('connected'), 800); return () => { alive = false; window.clearTimeout(t); unsub(); setWsStatus('disconnected'); }; }, [market, timeframe, opts.enabled, applyCandle]); return { bars, barsMarket, latestBar, wsStatus, isLoading, error }; }