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

223 lines
8.5 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.
/**
* 업비트 실시간 호가 훅
*
* 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<OrderbookState>(INITIAL_STATE);
const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected');
const wsRef = useRef<WebSocket | null>(null);
const reconnTimerRef = useRef<ReturnType<typeof setTimeout> | 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 };
}