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
+181
View File
@@ -0,0 +1,181 @@
/**
* useHistoryLoader
*
* 차트를 왼쪽으로 스크롤할 때 과거 캔들 데이터를 추가로 불러오는 훅.
*
* 데이터 소스 우선순위:
* 1. 백엔드 GET /api/candles/history (Ta4jStorage + 업비트 REST 프록시 + RSI 포함)
* 2. 백엔드 호출 실패 시 → 업비트 REST 직접 폴백
*
* 중복 방지 락(Lock):
* loadingRef = true 인 동안에는 동일 타임스탬프 중복 요청을 원천 차단.
*
* 사용 방법:
* 1. 훅에서 반환한 { isLoadingMore, loadMoreRef } 를 받는다.
* 2. ChartManager.subscribeVisibleLogicalRange 콜백 안에서
* `r.from < LOAD_MORE_TRIGGER` 조건 시 `loadMoreRef.current()` 를 호출한다.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import type { RefObject, MutableRefObject } from 'react';
import type { ChartManager } from '../utils/ChartManager';
import { fetchUpbitCandlesBeforeCached } from '../utils/requestCache';
import type { OHLCVBar, Timeframe } from '../types';
/** 이 값(바 인덱스)보다 왼쪽에 가시 범위의 시작이 오면 추가 로드 트리거 */
export const LOAD_MORE_TRIGGER = 30;
/** 한 번에 불러올 추가 캔들 수 */
const LOAD_BATCH = 200;
/** 연속 스크롤 중 불필요한 요청을 막는 디바운스 (ms) */
const DEBOUNCE_MS = 300;
/** 백엔드 API 기본 URL */
import { API_BASE } from '../utils/backendApi';
interface UseHistoryLoaderOptions {
symbol: string;
timeframe: Timeframe;
isUpbit: boolean;
managerRef: RefObject<ChartManager | null>;
}
interface UseHistoryLoaderReturn {
isLoadingMore: boolean;
/** onManagerReady 의 subscribeVisibleLogicalRange 콜백에서 직접 호출 */
loadMoreRef: MutableRefObject<() => void>;
}
// ── 백엔드 /api/candles/history 호출 ──────────────────────────────────────────
/**
* 백엔드에서 과거 캔들을 가져온다.
* 응답에 rsi 필드가 포함되어 있으며, 시간 오름차순으로 정렬된다.
*
* @param market 업비트 마켓 코드 (e.g. "KRW-BTC")
* @param type 캔들 타입 (e.g. "1m", "1d")
* @param to 기준 시간 이전 ISO-8601 (e.g. "2026-05-20T10:00:00")
* @param count 요청 캔들 수
*/
async function fetchHistoryFromBackend(
market: string,
type: string,
to: string,
count: number,
): Promise<OHLCVBar[]> {
const params = new URLSearchParams({
market,
type,
to,
count: String(count),
});
const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any[] = await res.json();
// 백엔드 CandleBarDto → OHLCVBar 변환 (rsi 는 extra 필드로 보존)
return data.map(d => ({
time: d.time as number,
open: d.open as number,
high: d.high as number,
low: d.low as number,
close: d.close as number,
volume: d.volume as number,
}));
}
// ── 훅 본체 ───────────────────────────────────────────────────────────────────
export function useHistoryLoader({
symbol,
timeframe,
isUpbit,
managerRef,
}: UseHistoryLoaderOptions): UseHistoryLoaderReturn {
const [isLoadingMore, setIsLoadingMore] = useState(false);
/**
* 중복 요청 방지 락(Lock):
* ref 를 사용하여 re-render 없이 동기적으로 상태를 업데이트한다.
* true 인 동안에는 스크롤 이벤트가 아무리 발생해도 API 를 재호출하지 않는다.
*/
const loadingRef = useRef(false);
/** false 가 되면 더 이상 API 를 호출하지 않음 */
const hasMoreRef = useRef(true);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 심볼·타임프레임이 바뀌면 상태 초기화
useEffect(() => {
hasMoreRef.current = true;
loadingRef.current = false;
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
}, [symbol, timeframe]);
const doLoadMore = useCallback(async () => {
if (!isUpbit) return; // 시뮬레이션 모드는 건너뜀
if (loadingRef.current) return; // ★ 중복 방지 락: 이미 로딩 중이면 즉시 리턴
if (!hasMoreRef.current) return; // 더 이상 데이터 없음
const mgr = managerRef.current;
if (!mgr || mgr.getRawBarsLength() === 0) return;
const oldestTime = mgr.getOldestBarTime();
if (oldestTime === null) return;
// Unix 타임스탬프(초) → ISO 8601 UTC ('Z' 없이 전달, 백엔드에서 처리)
const beforeIso = new Date(oldestTime * 1000)
.toISOString()
.replace('.000Z', ''); // '2026-05-20T10:00:00'
// ★ 락 설정: 이 시점부터 동일 타임스탬프 중복 요청 차단
loadingRef.current = true;
setIsLoadingMore(true);
try {
let olderBars: OHLCVBar[];
// ── 1. 백엔드 /api/candles/history 우선 시도 ──────────────────────────
try {
olderBars = await fetchHistoryFromBackend(symbol, timeframe, beforeIso, LOAD_BATCH);
console.debug(`[HistoryLoader] 백엔드에서 ${olderBars.length}개 수신`);
} catch (backendErr) {
// ── 2. 백엔드 실패 시 업비트 REST 직접 폴백 ───────────────────────
console.warn('[HistoryLoader] 백엔드 실패, 업비트 직접 폴백:', backendErr);
olderBars = await fetchUpbitCandlesBeforeCached(
symbol, timeframe, LOAD_BATCH, beforeIso,
);
}
if (olderBars.length === 0) {
hasMoreRef.current = false; // 더 이상 데이터 없음
} else {
// ── 3. 차트 앞부분에 부드럽게 병합 (Prepend) ──────────────────────
await mgr.prependBars(olderBars);
}
} catch (e) {
console.warn('[HistoryLoader] 과거 데이터 로드 실패:', e);
} finally {
// ★ 락 해제: 다음 스크롤 이벤트부터 재요청 허용
loadingRef.current = false;
setIsLoadingMore(false);
}
}, [symbol, timeframe, isUpbit, managerRef]);
/**
* 디바운스 래퍼 — subscribeVisibleLogicalRange 는 스크롤 중 매 프레임 호출되므로
* 사용자가 잠시 멈췄을 때 한 번만 실제 API 요청이 나가도록 한다.
*/
const loadMoreRef = useRef<() => void>(() => {});
loadMoreRef.current = useCallback(() => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
debounceTimer.current = null;
doLoadMore();
}, DEBOUNCE_MS);
}, [doLoadMore]);
return { isLoadingMore, loadMoreRef };
}