/** * 업비트 마켓 목록 + 실시간 시세 훅 * * 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 { 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 = {}; 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(); let batchTimer: ReturnType | 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>(new Map()); const [marketInfos, setMarketInfos] = useState([]); const [loading, setLoading] = useState(true); const [usdRate, setUsdRate] = useState(1_300); const mountedRef = useRef(true); const marketListRef = useRef([]); const marketInfoMapRef = useRef>(new Map()); const destroyWsRef = useRef<(() => void) | null>(null); const pollTimerRef = useRef | 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 }; }