182 lines
6.9 KiB
TypeScript
182 lines
6.9 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|