goldenChat base source add
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 백엔드 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 } from './useUpbitData';
|
||||
import type { WsStatus } from './useUpbitData';
|
||||
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;
|
||||
}
|
||||
|
||||
interface Return {
|
||||
bars: OHLCVBar[];
|
||||
latestBar: OHLCVBar | null;
|
||||
wsStatus: WsStatus;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
async function fetchInitialBars(market: string, candleType: string, count: number): Promise<OHLCVBar[]> {
|
||||
const params = new URLSearchParams({
|
||||
market,
|
||||
type: candleType,
|
||||
count: String(count),
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/candles/history?${params}`);
|
||||
if (!res.ok) throw new Error(`history ${res.status}`);
|
||||
const data = (await res.json()) as StompCandlePayload[];
|
||||
return data.map(b => ({
|
||||
time: b.time,
|
||||
open: b.open,
|
||||
high: b.high,
|
||||
low: b.low,
|
||||
close: b.close,
|
||||
volume: b.volume,
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchInitialBarsWithRetry(
|
||||
market: string,
|
||||
candleType: string,
|
||||
count: number,
|
||||
attempts = 3,
|
||||
): Promise<OHLCVBar[]> {
|
||||
let lastErr: Error | null = null;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await fetchInitialBars(market, candleType, count);
|
||||
} catch (e) {
|
||||
lastErr = e as Error;
|
||||
if (i < attempts - 1) {
|
||||
await new Promise(r => setTimeout(r, 400 * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error('history fetch failed');
|
||||
}
|
||||
|
||||
export function useStompChartData(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
opts: Options,
|
||||
): Return {
|
||||
const candleType = timeframeToCandleType(timeframe);
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (opts.enabled === false) return;
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setBars([]);
|
||||
barsRef.current = [];
|
||||
last1mVolRef.current = 0;
|
||||
last1mTimeRef.current = 0;
|
||||
lastTimeRef.current = 0;
|
||||
|
||||
const count = DISPLAY_COUNT + WARMUP_COUNT;
|
||||
fetchInitialBarsWithRetry(market, candleType, count)
|
||||
.then(newBars => {
|
||||
if (cancelled) return;
|
||||
barsRef.current = newBars;
|
||||
setBars(newBars);
|
||||
setLatestBar(newBars[newBars.length - 1] ?? null);
|
||||
lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0;
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
setError(`STOMP 초기 로딩 실패: ${(err as Error).message}`);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market, candleType, opts.enabled]);
|
||||
|
||||
const applyCandle = useCallback((data: StompCandlePayload, displayTf: Timeframe) => {
|
||||
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;
|
||||
if (current.length === 0) {
|
||||
barsRef.current = [bar];
|
||||
setLatestBar(bar);
|
||||
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);
|
||||
optsRef.current.onTickUpdate(bar);
|
||||
} else if (bar.time > last.time) {
|
||||
barsRef.current = [...current, bar];
|
||||
setLatestBar(bar);
|
||||
optsRef.current.onNewCandle(bar);
|
||||
} else {
|
||||
const patched = { ...bar, time: last.time };
|
||||
barsRef.current[current.length - 1] = patched;
|
||||
setLatestBar(patched);
|
||||
optsRef.current.onTickUpdate(patched);
|
||||
}
|
||||
lastTimeRef.current = bar.time;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
|
||||
const topic = `/sub/charts/${market}/${STOMP_TICK_CANDLE_TYPE}`;
|
||||
const unsub = subscribeStompTopic(topic, msg => {
|
||||
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 () => {
|
||||
window.clearTimeout(t);
|
||||
unsub();
|
||||
setWsStatus('disconnected');
|
||||
};
|
||||
}, [market, timeframe, opts.enabled, applyCandle]);
|
||||
|
||||
return { bars, latestBar, wsStatus, isLoading, error };
|
||||
}
|
||||
Reference in New Issue
Block a user