300 lines
9.5 KiB
TypeScript
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 };
|
|
}
|