goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+222
View File
@@ -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 };
}