Files
goldenChart/frontend/src/hooks/useMarketTicker.ts
T
2026-05-31 23:00:49 +09:00

274 lines
9.0 KiB
TypeScript

/**
* 업비트 마켓 목록 + 실시간 시세 훅
*
* 1) /v1/market/all — 마켓 목록
* 2) /v1/ticker — 우선 종목 즉시, 전 종목은 패널 열림·지연 후 (429 방지)
* 3) upbitWsBroker ticker (우선 종목 위주)
* 4) REST 보조 폴링 30초
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { setMarketNames } from '../utils/marketNameCache';
import { UPBIT_API } from '../utils/upbitApi';
import { fetchTickersThrottled, type UpbitTickerRaw } from '../utils/upbitTickerFetch';
import { subscribeUpbitWs } from '../utils/upbitWsBroker';
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
export interface TickerData {
market: string;
koreanName: string;
tradePrice: number | null;
changeRate: number | null;
changePrice: number | null;
accTradePrice24: number;
accTradeVolume24: number | null;
openingPrice: number | null;
highPrice: number | null;
lowPrice: number | null;
change: ChangeDir;
}
export interface MarketInfo {
market: string;
koreanName: string;
englishName: string;
}
export interface UseMarketTickerOptions {
/** 우선 종목만 먼저 로드 (기본: 마켓 패널 닫힘) */
lite?: boolean;
priorityMarkets?: string[];
getPriorityMarkets?: () => string[];
/** true일 때만 전 종목 REST·폴링 (마켓 패널 열림) */
loadFull?: boolean;
}
const REST_POLL_INTERVAL = 30_000;
const WS_BATCH_MS = 400;
const FULL_LOAD_DEFER_MS = 2_000;
const WS_TICKER_CAP = 80;
const LITE_BASE_MARKETS = ['KRW-BTC', 'KRW-USDT'];
function toChangeDir(raw: UpbitTickerRaw): ChangeDir {
if (raw.change === 'RISE' || raw.change === 'FALL' || raw.change === 'EVEN') return raw.change;
const r = raw.signed_change_rate ?? 0;
return r > 0 ? 'RISE' : r < 0 ? 'FALL' : 'EVEN';
}
interface UpbitMarketAll {
market: string;
korean_name: string;
english_name: string;
}
function getMarketCode(raw: UpbitTickerRaw): string {
return (raw.code ?? raw.market ?? '') as string;
}
async function fetchAllMarkets(): Promise<MarketInfo[]> {
const res = await fetch(`${UPBIT_API}/market/all?isDetails=true`);
if (!res.ok) throw new Error(`market/all ${res.status}`);
const data: UpbitMarketAll[] = await res.json();
const krw = data.filter(m => m.market.startsWith('KRW-'));
const nameMap: Record<string, string> = {};
krw.forEach(m => { nameMap[m.market] = m.korean_name; });
setMarketNames(nameMap);
return krw.map(m => ({
market: m.market,
koreanName: m.korean_name,
englishName: m.english_name,
}));
}
function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData {
return {
market: getMarketCode(raw),
koreanName,
tradePrice: raw.trade_price ?? null,
changeRate: raw.signed_change_rate ?? null,
changePrice: raw.signed_change_price ?? null,
accTradePrice24: raw.acc_trade_price_24h ?? 0,
accTradeVolume24: raw.acc_trade_volume_24h ?? null,
openingPrice: raw.opening_price ?? null,
highPrice: raw.high_price ?? null,
lowPrice: raw.low_price ?? null,
change: toChangeDir(raw),
};
}
function mergePriorityMarkets(priorityMarkets?: string[]): string[] {
const set = new Set(LITE_BASE_MARKETS);
priorityMarkets?.forEach(m => { if (m?.startsWith('KRW-')) set.add(m); });
return [...set];
}
function wsTickerMarkets(all: string[], priority: string[]): string[] {
const set = new Set(priority);
for (const m of all) {
if (set.size >= WS_TICKER_CAP) break;
set.add(m);
}
return [...set];
}
function subscribeTickerWs(
markets: string[],
onBatch: (rawList: UpbitTickerRaw[]) => void,
): () => void {
if (markets.length === 0) return () => {};
const batch = new Map<string, UpbitTickerRaw>();
let batchTimer: ReturnType<typeof setTimeout> | null = null;
const flushBatch = () => {
batchTimer = null;
if (batch.size === 0) return;
onBatch(Array.from(batch.values()));
batch.clear();
};
return subscribeUpbitWs('ticker', markets, raw => {
if (raw.type !== 'ticker' && !raw.code) return;
const msg = raw as unknown as UpbitTickerRaw;
const code = getMarketCode(msg);
if (!code) return;
batch.set(code, msg);
if (!batchTimer) batchTimer = setTimeout(flushBatch, WS_BATCH_MS);
});
}
export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
const loadFull = opts.loadFull ?? false;
const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets);
const priorityKey = priorityList.join(',');
const [tickers, setTickers] = useState<Map<string, TickerData>>(new Map());
const [marketInfos, setMarketInfos] = useState<MarketInfo[]>([]);
const [loading, setLoading] = useState(true);
const [usdRate, setUsdRate] = useState(1_300);
const mountedRef = useRef(true);
const marketListRef = useRef<string[]>([]);
const marketInfoMapRef = useRef<Map<string, MarketInfo>>(new Map());
const destroyWsRef = useRef<(() => void) | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fullLoadedRef = useRef(false);
const initDoneRef = useRef(false);
const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => {
if (!mountedRef.current || rawList.length === 0) return;
setTickers(prev => {
const next = new Map(prev);
rawList.forEach(raw => {
const code = getMarketCode(raw);
if (!code) return;
const info = marketInfoMapRef.current.get(code);
const name = info?.koreanName ?? next.get(code)?.koreanName ?? code.replace(/^KRW-/, '');
next.set(code, rawToTicker(raw, name));
});
return next;
});
const usdt = rawList.find(t => getMarketCode(t) === 'KRW-USDT');
if (usdt?.trade_price) setUsdRate(usdt.trade_price);
}, []);
const clearPoll = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
const attachWs = useCallback((markets: string[]) => {
destroyWsRef.current?.();
destroyWsRef.current = subscribeTickerWs(markets, applyRawList);
}, [applyRawList]);
const startPoll = useCallback((markets: string[]) => {
clearPoll();
if (markets.length === 0) return;
pollTimerRef.current = setInterval(() => {
if (!mountedRef.current) return;
void fetchTickersThrottled(markets).then(raw => {
if (raw.length > 0) applyRawList(raw);
});
}, REST_POLL_INTERVAL);
}, [applyRawList, clearPoll]);
const loadTickersFor = useCallback(async (markets: string[]) => {
const rawList = await fetchTickersThrottled(markets);
if (mountedRef.current && rawList.length > 0) applyRawList(rawList);
return rawList;
}, [applyRawList]);
const runFullTickerLoad = useCallback(async () => {
const all = marketListRef.current;
if (all.length === 0 || fullLoadedRef.current) return;
await loadTickersFor(all);
if (!mountedRef.current) return;
fullLoadedRef.current = true;
attachWs(wsTickerMarkets(all, priorityList));
startPoll(all);
}, [loadTickersFor, attachWs, startPoll, priorityList]);
// 1) 마켓 목록 + 우선 종목 ticker (마운트 1회)
useEffect(() => {
mountedRef.current = true;
fullLoadedRef.current = false;
initDoneRef.current = false;
(async () => {
let infos: MarketInfo[] = [];
try { infos = await fetchAllMarkets(); }
catch { /* ignore */ }
if (!mountedRef.current) return;
infos.forEach(m => marketInfoMapRef.current.set(m.market, m));
setMarketInfos(infos);
const allMarkets = infos.map(m => m.market);
if (!allMarkets.includes('KRW-USDT')) allMarkets.push('KRW-USDT');
marketListRef.current = allMarkets;
await loadTickersFor(priorityList);
if (!mountedRef.current) return;
setLoading(false);
initDoneRef.current = true;
attachWs(wsTickerMarkets(allMarkets, priorityList));
if (loadFull) {
setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS);
}
})();
return () => {
mountedRef.current = false;
destroyWsRef.current?.();
destroyWsRef.current = null;
clearPoll();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- init only
}, []);
// 2) 마켓 패널 열림 → 전 종목 (지연·스로틀)
useEffect(() => {
if (!loadFull || !initDoneRef.current) return;
if (fullLoadedRef.current) return;
const t = setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS);
return () => clearTimeout(t);
}, [loadFull, runFullTickerLoad]);
// 3) 우선 종목 변경 (lite) — 전체 재시작 없이 보강
useEffect(() => {
if (!initDoneRef.current || fullLoadedRef.current) return;
void loadTickersFor(priorityList);
const all = marketListRef.current;
if (all.length > 0) attachWs(wsTickerMarkets(all, priorityList));
}, [priorityKey, loadTickersFor, attachWs, priorityList]);
return { tickers, marketInfos, loading, usdRate };
}