Files
goldenChart/frontend/src/hooks/useStompChartData.ts
T

300 lines
9.5 KiB
TypeScript

/**
* 백엔드 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<void> {
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<OHLCVBar[]> {
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<OHLCVBar[]>([]);
const [barsMarket, setBarsMarket] = useState<string | null>(null);
const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null);
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const barsRef = useRef<OHLCVBar[]>([]);
const optsRef = useRef(opts);
optsRef.current = opts;
const lastTimeRef = useRef<number>(0);
/** 1m 봉 volume 스냅샷 — 상위 분봉 volume 델타 계산용 */
const last1mVolRef = useRef<number>(0);
const last1mTimeRef = useRef<number>(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<StompCandlePayload>(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 };
}