281 lines
9.1 KiB
TypeScript
281 lines
9.1 KiB
TypeScript
/**
|
|
* 업비트 마켓 목록 + 실시간 시세 훅
|
|
*
|
|
* 1) /v1/market/all — 마켓 목록
|
|
* 2) /v1/ticker — 초기 시세 (lite: 우선 종목만, full: 전 종목)
|
|
* 3) upbitWsBroker ticker 구독
|
|
* 4) REST 보조 폴링 30초
|
|
*/
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
import { setMarketNames } from '../utils/marketNameCache';
|
|
import { UPBIT_API } from '../utils/upbitApi';
|
|
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;
|
|
/** lite 시 즉시 ticker REST·WS 할 마켓 (차트·관심 등) */
|
|
priorityMarkets?: string[];
|
|
/** 렌더마다 최신 우선 종목 (watchlist 등 — 훅 순서 유지용) */
|
|
getPriorityMarkets?: () => string[];
|
|
/** true면 전 종목 ticker 로드 (마켓 패널 열림 등) */
|
|
loadFull?: boolean;
|
|
}
|
|
|
|
const REST_POLL_INTERVAL = 30_000;
|
|
const WS_BATCH_MS = 400;
|
|
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;
|
|
}
|
|
|
|
interface UpbitTickerRaw {
|
|
market?: string;
|
|
code?: string;
|
|
trade_price: number;
|
|
signed_change_rate: number;
|
|
signed_change_price: number;
|
|
acc_trade_price_24h: number;
|
|
acc_trade_volume_24h?: number;
|
|
opening_price: number;
|
|
high_price: number;
|
|
low_price: number;
|
|
change?: 'RISE' | 'FALL' | 'EVEN';
|
|
}
|
|
|
|
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,
|
|
}));
|
|
}
|
|
|
|
async function fetchAllTickers(markets: string[]): Promise<UpbitTickerRaw[]> {
|
|
const results: UpbitTickerRaw[] = [];
|
|
for (let i = 0; i < markets.length; i += 100) {
|
|
const chunk = markets.slice(i, i + 100);
|
|
try {
|
|
const res = await fetch(`${UPBIT_API}/ticker?markets=${chunk.join(',')}`);
|
|
if (res.ok) results.push(...(await res.json()));
|
|
} catch { /* 부분 실패 무시 */ }
|
|
}
|
|
return results;
|
|
}
|
|
|
|
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 subscribeTickerWs(
|
|
markets: string[],
|
|
onBatch: (rawList: UpbitTickerRaw[]) => void,
|
|
): () => void {
|
|
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);
|
|
});
|
|
}
|
|
|
|
function mergePriorityMarkets(priorityMarkets?: string[]): string[] {
|
|
const set = new Set(LITE_BASE_MARKETS);
|
|
priorityMarkets?.forEach(m => { if (m?.startsWith('KRW-')) set.add(m); });
|
|
return [...set];
|
|
}
|
|
|
|
export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
|
const lite = opts.lite ?? false;
|
|
const loadFull = opts.loadFull ?? !lite;
|
|
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 wsMarketsRef = useRef<string[]>([]);
|
|
const fullLoadedRef = useRef(false);
|
|
|
|
const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => {
|
|
if (!mountedRef.current) 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 startPoll = useCallback((markets: string[]) => {
|
|
clearPoll();
|
|
pollTimerRef.current = setInterval(async () => {
|
|
if (!mountedRef.current) return;
|
|
try {
|
|
const rawList = await fetchAllTickers(markets);
|
|
applyRawList(rawList);
|
|
} catch { /* ignore */ }
|
|
}, REST_POLL_INTERVAL);
|
|
}, [applyRawList, clearPoll]);
|
|
|
|
const attachWs = useCallback((markets: string[]) => {
|
|
destroyWsRef.current?.();
|
|
wsMarketsRef.current = markets;
|
|
destroyWsRef.current = subscribeTickerWs(markets, applyRawList);
|
|
}, [applyRawList]);
|
|
|
|
const loadTickersFor = useCallback(async (markets: string[]) => {
|
|
if (markets.length === 0) return;
|
|
try {
|
|
const rawList = await fetchAllTickers(markets);
|
|
if (mountedRef.current) applyRawList(rawList);
|
|
} catch { /* ignore */ }
|
|
}, [applyRawList]);
|
|
|
|
// 마켓 목록 + 초기 ticker (lite / full)
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
fullLoadedRef.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;
|
|
|
|
const initialMarkets = loadFull ? allMarkets : priorityList;
|
|
await loadTickersFor(initialMarkets);
|
|
if (!mountedRef.current) return;
|
|
|
|
setLoading(false);
|
|
attachWs(initialMarkets);
|
|
startPoll(initialMarkets);
|
|
|
|
if (loadFull) fullLoadedRef.current = true;
|
|
})();
|
|
|
|
return () => {
|
|
mountedRef.current = false;
|
|
destroyWsRef.current?.();
|
|
destroyWsRef.current = null;
|
|
clearPoll();
|
|
};
|
|
}, [loadFull, priorityKey, loadTickersFor, attachWs, startPoll, clearPoll, priorityList]);
|
|
|
|
// lite → full 확장 (마켓 패널 열림 등)
|
|
useEffect(() => {
|
|
if (!loadFull || fullLoadedRef.current) return;
|
|
if (marketListRef.current.length === 0) return;
|
|
|
|
let cancelled = false;
|
|
(async () => {
|
|
const all = marketListRef.current;
|
|
await loadTickersFor(all);
|
|
if (cancelled || !mountedRef.current) return;
|
|
fullLoadedRef.current = true;
|
|
attachWs(all);
|
|
startPoll(all);
|
|
})();
|
|
|
|
return () => { cancelled = true; };
|
|
}, [loadFull, loadTickersFor, attachWs, startPoll]);
|
|
|
|
return { tickers, marketInfos, loading, usdRate };
|
|
}
|