/** * 업비트 실시간 호가 훅 * * frontend_golden/src/hooks/useUpbitOrderbook.ts 를 현재 프로젝트에 맞게 변환: * - WebSocket: /upbit-ws (nginx 프록시 → wss://api.upbit.com/websocket/v1) * - REST: /upbit-api/v1/orderbook * - console.log 제거, 재연결 로직 강화 */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; // ── 타입 ──────────────────────────────────────────────────────────────────── export interface OrderbookUnit { ask_price: number; bid_price: number; ask_size: number; bid_size: number; } export interface OrderbookDisplayUnit { price: number; size: number; percentage: number; // 최대 잔량 대비 비율 (0–100), 배경 바 넓이 type: 'ask' | 'bid'; } export interface OrderbookState { asks: OrderbookDisplayUnit[]; bids: OrderbookDisplayUnit[]; totalAskSize: number; totalBidSize: number; timestamp: number; connected: boolean; } export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; interface UpbitObWsMessage { type: string; code: string; total_ask_size: number; total_bid_size: number; orderbook_units: OrderbookUnit[]; timestamp: number; stream_type?: string; } // ── 상수 ──────────────────────────────────────────────────────────────────── const RECONNECT_DELAY = 3_000; const INITIAL_STATE: OrderbookState = { asks: [], bids: [], totalAskSize: 0, totalBidSize: 0, timestamp: 0, connected: false, }; // ── 훅 ────────────────────────────────────────────────────────────────────── export function useUpbitOrderbook(market: string) { const [orderbook, setOrderbook] = useState(INITIAL_STATE); const [wsStatus, setWsStatus] = useState('disconnected'); const wsRef = useRef(null); const reconnTimerRef = useRef | null>(null); const mountedRef = useRef(true); const isConnectingRef = useRef(false); // ── 데이터 파싱 → state ───────────────────────────────────────────────── const processData = useCallback((msg: UpbitObWsMessage) => { const units = msg.orderbook_units; if (!units || units.length === 0) return; const maxSize = Math.max( ...units.map(u => u.ask_size), ...units.map(u => u.bid_size), ); // 매도: 높은 가격 → 낮은 가격 (역순) const asks: OrderbookDisplayUnit[] = units .map(u => ({ price: u.ask_price, size: u.ask_size, percentage: maxSize > 0 ? (u.ask_size / maxSize) * 100 : 0, type: 'ask' as const, })) .reverse(); // 매수: 높은 가격 → 낮은 가격 (정순 – 1번 unit이 best bid) const bids: OrderbookDisplayUnit[] = units.map(u => ({ price: u.bid_price, size: u.bid_size, percentage: maxSize > 0 ? (u.bid_size / maxSize) * 100 : 0, type: 'bid' as const, })); setOrderbook({ asks, bids, totalAskSize: msg.total_ask_size, totalBidSize: msg.total_bid_size, timestamp: msg.timestamp, connected: true, }); }, []); // ── REST 초기 스냅샷 ──────────────────────────────────────────────────── const fetchSnapshot = useCallback(async () => { try { const res = await fetch(`/upbit-api/v1/orderbook?markets=${market}`); if (!res.ok) return; const data: Array<{ market: string; timestamp: number; total_ask_size: number; total_bid_size: number; orderbook_units: OrderbookUnit[]; }> = await res.json(); if (data && data[0]) { const d = data[0]; processData({ type: 'orderbook', code: d.market, total_ask_size: d.total_ask_size, total_bid_size: d.total_bid_size, orderbook_units: d.orderbook_units, timestamp: d.timestamp, stream_type: 'SNAPSHOT', }); } } catch { /* ignore */ } }, [market, processData]); // ── WebSocket 연결 ────────────────────────────────────────────────────── const connect = useCallback(() => { if (!mountedRef.current) return; if (isConnectingRef.current) return; if (wsRef.current?.readyState === WebSocket.OPEN) return; isConnectingRef.current = true; setWsStatus('connecting'); const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`); ws.binaryType = 'arraybuffer'; wsRef.current = ws; ws.onopen = () => { if (!mountedRef.current) { ws.close(); return; } isConnectingRef.current = false; setWsStatus('connected'); ws.send(JSON.stringify([ { ticket: `ob-${market}-${Date.now()}` }, { type: 'orderbook', codes: [market] }, ])); }; ws.onmessage = async (e: MessageEvent) => { if (!mountedRef.current) return; try { let text: string; if (typeof e.data === 'string') { text = e.data; } else if (e.data instanceof ArrayBuffer) { text = new TextDecoder('utf-8').decode(e.data); } else if (e.data instanceof Blob) { text = await (e.data as Blob).text(); } else return; const msg: UpbitObWsMessage = JSON.parse(text); if (msg.type === 'orderbook' && msg.code === market) { processData(msg); } } catch { /* ignore */ } }; ws.onerror = () => { isConnectingRef.current = false; if (mountedRef.current) setWsStatus('error'); }; ws.onclose = () => { isConnectingRef.current = false; if (!mountedRef.current) return; setWsStatus('disconnected'); setOrderbook(prev => ({ ...prev, connected: false })); // 자동 재연결 reconnTimerRef.current = setTimeout(connect, RECONNECT_DELAY); }; }, [market, processData]); // ── 연결 해제 ──────────────────────────────────────────────────────────── const disconnect = useCallback(() => { if (reconnTimerRef.current) { clearTimeout(reconnTimerRef.current); reconnTimerRef.current = null; } if (wsRef.current) { const ws = wsRef.current; ws.onopen = null; ws.onmessage = null; ws.onerror = null; ws.onclose = null; if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(); wsRef.current = null; } isConnectingRef.current = false; }, []); // ── 라이프사이클 ───────────────────────────────────────────────────────── useEffect(() => { mountedRef.current = true; setOrderbook(INITIAL_STATE); fetchSnapshot(); connect(); return () => { mountedRef.current = false; disconnect(); }; }, [market]); // eslint-disable-line react-hooks/exhaustive-deps // ── 스프레드 계산 ──────────────────────────────────────────────────────── const spread = useMemo(() => { const { asks, bids } = orderbook; if (!asks.length || !bids.length) return { bestAsk: 0, bestBid: 0, spread: 0, pct: 0 }; const bestAsk = asks[asks.length - 1].price; // asks 배열 마지막 = 가장 낮은 매도가 const bestBid = bids[0].price; // bids 배열 첫번째 = 가장 높은 매수가 const diff = bestAsk - bestBid; return { bestAsk, bestBid, spread: diff, pct: bestBid > 0 ? (diff / bestBid) * 100 : 0 }; }, [orderbook.asks, orderbook.bids]); return { orderbook, wsStatus, spread }; }