262 lines
8.9 KiB
TypeScript
262 lines
8.9 KiB
TypeScript
/**
|
||
* 업비트 마켓 목록 + 실시간 시세 훅
|
||
*
|
||
* 동작:
|
||
* 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 };
|
||
}
|