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

343 lines
11 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 { API_BASE } from '../utils/backendApi';
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 {
/** false — REST·WS 중지(전략편집기 등), 캐시된 tickers 유지 */
enabled?: boolean;
/** 우선 종목만 먼저 로드 (기본: 마켓 패널 닫힘) */
lite?: boolean;
priorityMarkets?: string[];
getPriorityMarkets?: () => string[];
/** true일 때만 전 종목 REST·폴링 (마켓 패널 열림) */
loadFull?: boolean;
}
const REST_POLL_INTERVAL = 30_000;
const REST_POLL_LITE_MS = 60_000;
const WS_BATCH_MS = 400;
const FULL_LOAD_DEFER_MS = 2_000;
const PRIORITY_DEBOUNCE_MS = 600;
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[]> {
let data: UpbitMarketAll[] | null = null;
try {
const res = await fetch(`${API_BASE}/markets/all?isDetails=true`);
if (res.ok) data = await res.json();
} catch { /* backend fallback */ }
if (!data) {
const res = await fetch(`${UPBIT_API}/market/all?isDetails=true`);
if (!res.ok) throw new Error(`market/all ${res.status}`);
data = 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 enabled = opts.enabled ?? true;
const loadFull = opts.loadFull ?? false;
const isMobileLite = opts.lite ?? 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 || isMobileLite) return;
const interval = isMobileLite ? REST_POLL_LITE_MS : REST_POLL_INTERVAL;
pollTimerRef.current = setInterval(() => {
if (!mountedRef.current) return;
void fetchTickersThrottled(markets).then(raw => {
if (raw.length > 0) applyRawList(raw);
});
}, interval);
}, [applyRawList, clearPoll, isMobileLite]);
const loadTickersFor = useCallback(async (markets: string[]) => {
if (!enabled) return [];
const rawList = await fetchTickersThrottled(markets);
if (mountedRef.current && rawList.length > 0) applyRawList(rawList);
return rawList;
}, [applyRawList, enabled]);
const runFullTickerLoad = useCallback(async () => {
if (!enabled) return;
const all = marketListRef.current;
if (all.length === 0 || fullLoadedRef.current) return;
if (isMobileLite) {
await loadTickersFor([]);
if (!mountedRef.current) return;
fullLoadedRef.current = true;
attachWs(wsTickerMarkets(all, priorityList));
return;
}
await loadTickersFor(all);
if (!mountedRef.current) return;
fullLoadedRef.current = true;
attachWs(wsTickerMarkets(all, priorityList));
startPoll(all);
}, [enabled, isMobileLite, loadTickersFor, attachWs, startPoll, priorityList]);
const pauseFeed = useCallback(() => {
destroyWsRef.current?.();
destroyWsRef.current = null;
clearPoll();
}, [clearPoll]);
const resumeFeed = useCallback(() => {
const all = marketListRef.current;
if (all.length === 0) return;
attachWs(wsTickerMarkets(all, priorityList));
if (loadFull && fullLoadedRef.current && !isMobileLite) {
startPoll(all);
}
}, [attachWs, priorityList, loadFull, isMobileLite, startPoll]);
// 마운트 생명주기
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
pauseFeed();
};
}, [pauseFeed]);
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const priorityListRef = useRef(priorityList);
priorityListRef.current = priorityList;
const loadFullRef = useRef(loadFull);
loadFullRef.current = loadFull;
// enabled: 비차트 화면(전략편집기 등)에서 REST·WS 중지
useEffect(() => {
if (!enabled) {
pauseFeed();
if (initDoneRef.current) setLoading(false);
return;
}
if (initDoneRef.current) {
resumeFeed();
return;
}
let cancelled = false;
(async () => {
let infos: MarketInfo[] = [];
try { infos = await fetchAllMarkets(); }
catch { /* ignore */ }
if (cancelled || !mountedRef.current || !enabledRef.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;
const pri = mergePriorityMarkets(
opts.getPriorityMarkets?.() ?? opts.priorityMarkets,
);
if (!enabledRef.current) return;
const rawList = await fetchTickersThrottled(pri);
if (cancelled || !mountedRef.current || !enabledRef.current) return;
if (rawList.length > 0) applyRawList(rawList);
setLoading(false);
initDoneRef.current = true;
attachWs(wsTickerMarkets(allMarkets, pri));
if (loadFullRef.current && !isMobileLite) {
setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabled 토글·최초 1회만
}, [enabled]);
// 마켓 패널 열림 → 전 종목
useEffect(() => {
if (!enabled || !loadFull || !initDoneRef.current) return;
if (fullLoadedRef.current) return;
const delay = isMobileLite ? 300 : FULL_LOAD_DEFER_MS;
const t = setTimeout(() => { void runFullTickerLoad(); }, delay);
return () => clearTimeout(t);
}, [enabled, isMobileLite, loadFull, runFullTickerLoad]);
// 우선 종목 변경
useEffect(() => {
if (!enabled || !initDoneRef.current) return;
const t = setTimeout(() => {
if (!mountedRef.current) return;
if (!fullLoadedRef.current) void loadTickersFor(priorityList);
const all = marketListRef.current;
if (all.length > 0) attachWs(wsTickerMarkets(all, priorityList));
}, PRIORITY_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [enabled, priorityKey, loadTickersFor, attachWs, priorityList]);
return { tickers, marketInfos, loading, usdRate };
}