goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+280
View File
@@ -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 };
}