Files
goldenChart/frontend/src/hooks/useMarketTicker.ts
T
2026-05-23 15:11:48 +09:00

262 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 업비트 마켓 목록 + 실시간 시세 훅
*
* 동작:
* 1) 앱 마운트 시 /v1/market/all 로 전체 마켓 목록 + 한글명 로드
* 2) /v1/ticker?markets=... 로 초기 시세 로드 (100개 단위 분할)
* 3) WebSocket ticker 구독으로 실시간 시세 수신 (배치 업데이트 500ms)
* 4) REST 폴링 30초마다 보조 갱신 (WebSocket 누락 방지)
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { setMarketNames } from '../utils/marketNameCache';
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
export interface TickerData {
market: string;
koreanName: string;
tradePrice: number | null;
changeRate: number | null; // 전일대비 등락률 (0.01 = +1%)
changePrice: number | null; // 전일대비 등락금액
accTradePrice24: number; // 24h 누적 거래대금 (KRW) 정렬용 (0 fallback)
accTradeVolume24: number | null; // 24h 누적 거래량 (코인)
openingPrice: number | null;
highPrice: number | null;
lowPrice: number | null;
change: ChangeDir; // RISE / FALL / EVEN
}
export interface MarketInfo {
market: string;
koreanName: string;
englishName: string;
}
const REST_POLL_INTERVAL = 30_000; // 30초 보조 폴링
const WS_BATCH_MS = 400; // WebSocket 업데이트 배치 간격
// ── 내부 API 타입 ─────────────────────────────────────────────────────────
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; // REST
code?: string; // WebSocket
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/v1/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/v1/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),
};
}
// ── WebSocket 연결 관리 ───────────────────────────────────────────────────
function createTickerWs(
markets: string[],
onBatch: (rawList: UpbitTickerRaw[]) => void,
): () => void {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
let ws: WebSocket | null = null;
let destroyed = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
// 배치 버퍼: 너무 빈번한 state 업데이트 방지 (500ms 단위로 모아서 전달)
const batch: Map<string, UpbitTickerRaw> = new Map();
let batchTimer: ReturnType<typeof setTimeout> | null = null;
function flushBatch() {
batchTimer = null;
if (batch.size === 0) return;
onBatch(Array.from(batch.values()));
batch.clear();
}
function connect() {
if (destroyed) return;
ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
ws?.send(JSON.stringify([
{ ticket: 'market-panel-ticker' },
{ type: 'ticker', codes: markets },
]));
};
ws.onmessage = (e: MessageEvent) => {
try {
const decoder = new TextDecoder('utf-8');
const text = typeof e.data === 'string'
? e.data
: decoder.decode(e.data as ArrayBuffer);
const msg: UpbitTickerRaw & { type?: string } = JSON.parse(text);
if (msg.type !== 'ticker' && !msg.code) return;
const code = getMarketCode(msg);
if (!code) return;
batch.set(code, msg);
if (!batchTimer) {
batchTimer = setTimeout(flushBatch, WS_BATCH_MS);
}
} catch { /* ignore */ }
};
ws.onerror = () => { /* ignore onclose will handle */ };
ws.onclose = () => {
if (!destroyed) {
reconnectTimer = setTimeout(connect, 5_000);
}
};
}
connect();
return () => {
destroyed = true;
if (batchTimer) { clearTimeout(batchTimer); flushBatch(); }
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
};
}
// ── 훅 ──────────────────────────────────────────────────────────────────
export function useMarketTicker() {
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 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);
}, []);
useEffect(() => {
mountedRef.current = true;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let destroyWs: (() => void) | null = null;
(async () => {
// 1) 마켓 목록
let infos: MarketInfo[] = [];
try { infos = await fetchAllMarkets(); }
catch { /* 실패 시 빈 목록 */ }
if (!mountedRef.current) return;
infos.forEach(m => marketInfoMapRef.current.set(m.market, m));
setMarketInfos(infos);
const markets = infos.map(m => m.market);
if (!markets.includes('KRW-USDT')) markets.push('KRW-USDT');
marketListRef.current = markets;
// 2) 초기 시세 (REST)
try {
const rawList = await fetchAllTickers(markets);
if (mountedRef.current) applyRawList(rawList);
} catch { /* ignore */ }
if (!mountedRef.current) return;
setLoading(false);
// 3) WebSocket 실시간 구독
destroyWs = createTickerWs(markets, applyRawList);
// 4) REST 보조 폴링 (30초 WebSocket 누락 보완)
pollTimer = setInterval(async () => {
if (!mountedRef.current) return;
try {
const rawList = await fetchAllTickers(marketListRef.current);
applyRawList(rawList);
} catch { /* ignore */ }
}, REST_POLL_INTERVAL);
})();
return () => {
mountedRef.current = false;
destroyWs?.();
if (pollTimer) clearInterval(pollTimer);
};
}, [applyRawList]);
return { tickers, marketInfos, loading, usdRate };
}