goldenChat base source add
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 업비트 실시간 호가 훅
|
||||
*
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user