import type { OHLCVBar, Timeframe } from '../types'; // ─── Upbit REST API ──────────────────────────────────────────────────────── // 개발: Vite proxy (/upbit-api) → https://api.upbit.com // 프로덕션: nginx proxy (/upbit-api) → https://api.upbit.com // 항상 동일한 상대 경로를 사용하므로 CORS 없이 동일 오리진으로 요청됨 const UPBIT_API = '/upbit-api/v1'; export const UPBIT_MARKETS = [ 'KRW-BTC', 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-ADA', 'KRW-DOT', 'KRW-AVAX', 'KRW-LINK', 'KRW-MATIC', 'KRW-SHIB', 'KRW-UNI', 'KRW-ATOM', 'KRW-LTC', 'KRW-ETC', ]; export function isUpbitMarket(symbol: string): boolean { return /^(KRW|BTC|USDT)-\w+$/.test(symbol.toUpperCase()); } // ─── Upbit candle → OHLCVBar ─────────────────────────────────────────────── export interface UpbitCandle { market: string; candle_date_time_utc: string; opening_price: number; high_price: number; low_price: number; trade_price: number; timestamp: number; candle_acc_trade_price: number; candle_acc_trade_volume: number; unit?: number; } export function candleToBar(c: UpbitCandle): OHLCVBar { const time = Math.floor(new Date(c.candle_date_time_utc + 'Z').getTime() / 1000); return { time, open: c.opening_price, high: c.high_price, low: c.low_price, close: c.trade_price, volume: c.candle_acc_trade_volume, }; } /** Upbit 캔들 API 1회 요청 최대 개수 */ const UPBIT_MAX_PER_REQ = 200; /** * 지표 워밍업 바 수 분석 * ───────────────────────────────────────────────────── * 지표 | 최대 계산 기간 | 워밍업 바 * BB(20) | 20 | 20 * EMA(20/50) | 50 | 50 * RSI(14) | 14 | 14 * MACD(12,26,9) | 26 + 9 | 35 * DMI/ADX(14) | 14 × 2 | 28 * 일목균형표 Span B | 52 | 52 ← 최대 * ───────────────────────────────────────────────────── * → 최소 52바, 안전 마진 포함 100바 워밍업 필요 * → 표시 200 + 워밍업 100 = 300바 = 요청 2회 (200 + 100) */ export const INDICATOR_WARMUP = 100; // 안전 마진 포함 워밍업 바 수 /** OBV 누적 정확도용 초기 캔들 수 (표시 200 + 워밍업 600) */ export const OBV_DEEP_HISTORY = 800; function candleEndpoint( timeframe: Timeframe, market: string, count: number, to?: string, // ISO 8601 (exclusive) - 이 시각 이전 캔들을 반환 ): string { const base = `${UPBIT_API}/candles`; const toParam = to ? `&to=${encodeURIComponent(to + 'Z')}` : ''; switch (timeframe) { case '1m': return `${base}/minutes/1?market=${market}&count=${count}${toParam}`; case '3m': return `${base}/minutes/3?market=${market}&count=${count}${toParam}`; case '5m': return `${base}/minutes/5?market=${market}&count=${count}${toParam}`; case '15m': return `${base}/minutes/15?market=${market}&count=${count}${toParam}`; case '30m': return `${base}/minutes/30?market=${market}&count=${count}${toParam}`; case '1h': return `${base}/minutes/60?market=${market}&count=${count}${toParam}`; case '4h': return `${base}/minutes/240?market=${market}&count=${count}${toParam}`; case '1D': return `${base}/days?market=${market}&count=${count}${toParam}`; case '1W': return `${base}/weeks?market=${market}&count=${count}${toParam}`; case '1M': return `${base}/months?market=${market}&count=${count}${toParam}`; default: return `${base}/minutes/1?market=${market}&count=${count}${toParam}`; } } /** * 업비트 캔들 페치 (count > 200 이면 자동 분할 요청) * * Upbit API 는 1회 최대 200개 반환. * count=300 이면: * - 1차: 최신 200개 * - 2차: 그 이전 100개 (to = 1차 결과 중 가장 오래된 시각) * 결과는 시간 오름차순(오래된→최신)으로 반환. * * @param before - ISO 8601 UTC (exclusive) — 이 시각 **이전** 캔들을 반환. * 과거 데이터 추가 로드 시 현재 가장 오래된 캔들의 시각을 전달한다. */ export async function fetchUpbitCandles( market: string, timeframe: Timeframe, count = 200, before?: string, // ISO 8601 UTC exclusive (예: '2024-01-01T00:00:00') ): Promise { const mkt = market.toUpperCase(); let remaining = count; let to: string | undefined = before; // before 가 있으면 그 시각 이전부터 시작 const batches: UpbitCandle[][] = []; while (remaining > 0) { const batchCount = Math.min(remaining, UPBIT_MAX_PER_REQ); const url = candleEndpoint(timeframe, mkt, batchCount, to); const res = await fetch(url, { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error(`Upbit API 오류: ${res.status} ${res.statusText}`); const data: UpbitCandle[] = await res.json(); if (data.length === 0) break; batches.push(data); // 각 배치: Upbit 반환순 (최신→오래된) remaining -= data.length; if (data.length < batchCount) break; // 서버에 데이터 더 없음 // 다음 배치 기준: 현재 배치에서 가장 오래된 캔들 시각 (exclusive) to = data[data.length - 1].candle_date_time_utc; // requestCache 의 deduplication 으로 동시 요청이 1건으로 줄어들었으므로 // 배치 간 딜레이는 100ms 로 유지 (2번째 배치가 동시에 날아가지 않도록) if (remaining > 0) await new Promise(r => setTimeout(r, 100)); } // 배치 역순 합산 후 시간 오름차순 정렬 → 중복 제거 const allCandles = batches.reverse().flat(); const seen = new Set(); const unique = allCandles.filter(c => { if (seen.has(c.candle_date_time_utc)) return false; seen.add(c.candle_date_time_utc); return true; }); unique.sort((a, b) => a.candle_date_time_utc.localeCompare(b.candle_date_time_utc)); return unique.map(candleToBar); } // ─── Upbit WebSocket ─────────────────────────────────────────────────────── export interface UpbitTrade { type: 'trade'; code: string; trade_price: number; trade_volume: number; ask_bid: 'ASK' | 'BID'; trade_timestamp: number; trade_date: string; trade_time: string; sequential_id: number; stream_type: 'REALTIME' | 'SNAPSHOT'; } export interface UpbitTickerSimple { type: 'ticker'; code: string; trade_price: number; trade_volume: number; opening_price: number; high_price: number; low_price: number; acc_trade_volume: number; trade_timestamp: number; stream_type: string; } export type UpbitMessage = UpbitTrade | UpbitTickerSimple; export interface UpbitWsOptions { market: string; onMessage: (msg: UpbitMessage) => void; onConnect: () => void; onDisconnect: () => void; onError: (err: string) => void; } // 개발: Vite proxy (/upbit-ws) → wss://api.upbit.com/websocket/v1 // 프로덕션: nginx proxy (/upbit-ws) → wss://api.upbit.com/websocket/v1 // 항상 동일 오리진 상대 경로를 사용 → CORS / Origin 차단 모두 우회 function getWsUrl(): string { const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; return `${proto}://${window.location.host}/upbit-ws`; } const INITIAL_DELAY = 80; // React StrictMode 이중마운트 방지 (cleanup < 80ms면 연결 안 함) const RECONNECT_BASE = 2000; // 최초 재연결 대기 const RECONNECT_MAX = 30000; // 최대 재연결 대기 const PING_INTERVAL = 20000; // 20초마다 PING (서버가 60초 내 응답 없으면 끊음) export class UpbitWebSocketClient { private ws: WebSocket | null = null; private opts: UpbitWsOptions; private destroyed = false; private retryCount = 0; private reconnectTimer: ReturnType | null = null; private pingTimer: ReturnType | null = null; constructor(opts: UpbitWsOptions) { this.opts = opts; // 80ms 지연으로 React StrictMode 이중 마운트 방지 // (cleanup 이 80ms 안에 호출되면 connect 가 취소됨) this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(); }, INITIAL_DELAY); } private connect() { if (this.destroyed) return; try { const ws = new WebSocket(getWsUrl()); this.ws = ws; ws.binaryType = 'arraybuffer'; ws.onopen = () => { this.retryCount = 0; // ── 구독 메시지 ────────────────────────────────────────────── // format 생략 시 DEFAULT (긴 키명), SIMPLE이면 단축 키명 ws.send(JSON.stringify([ { ticket: `react_chart_${Date.now()}` }, { type: 'trade', codes: [this.opts.market.toUpperCase()] }, { format: 'DEFAULT' }, ])); // ── PING keepalive ───────────────────────────────────────── this.clearPing(); this.pingTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send('PING'); } }, PING_INTERVAL); this.opts.onConnect(); }; ws.onmessage = (event) => { // ① 텍스트 메시지: 서버 PING → PONG 응답, 상태 메시지 → 무시 if (typeof event.data === 'string') { if (event.data === 'PING') ws.send('PONG'); return; } // ② 바이너리 메시지: 체결/시세 데이터 let msg: UpbitMessage | null = null; try { const text = new TextDecoder('utf-8').decode(event.data as ArrayBuffer); if (text === 'PING') { ws.send('PONG'); return; } const parsed = JSON.parse(text) as UpbitMessage; if (!('type' in parsed)) return; msg = parsed; } catch { // JSON 파싱 실패 → 무시 return; } // JSON 파싱과 onMessage 를 분리: 차트 업데이트 오류가 WS 메시지 처리를 중단시키지 않도록 try { this.opts.onMessage(msg); } catch (err) { console.error('[UpbitWS] onMessage 처리 중 오류:', err); } }; ws.onclose = (ev) => { this.clearPing(); if (!this.destroyed) { // 정상 종료(1000/1001)가 아닐 때만 오류 표시 if (ev.code !== 1000 && ev.code !== 1001) { this.opts.onError(`연결 끊김 (code ${ev.code})`); } this.opts.onDisconnect(); this.scheduleReconnect(); } }; ws.onerror = () => { // onerror 직후 onclose가 반드시 발생하므로 여기서는 로그만 // (onclose에서 재연결 처리) console.warn('[UpbitWS] onerror 발생 → onclose 대기'); }; } catch (err) { this.opts.onError(`WebSocket 초기화 실패: ${String(err)}`); this.scheduleReconnect(); } } private clearPing() { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } } private scheduleReconnect() { if (this.destroyed) return; const delay = Math.min(RECONNECT_BASE * 2 ** this.retryCount, RECONNECT_MAX); this.retryCount++; console.info(`[UpbitWS] ${delay}ms 후 재연결 시도 (${this.retryCount}회차)`); this.reconnectTimer = setTimeout(() => this.connect(), delay); } destroy() { this.destroyed = true; this.clearPing(); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { this.ws.onclose = null; this.ws.onerror = null; this.ws.close(1000, 'destroy'); this.ws = null; } } } // ─── 타임프레임 초 단위 ─────────────────────────────────────────────────── export const TF_SECONDS: Record = { '1m': 60, '3m': 180, '5m': 300, '15m': 900, '30m': 1800, '1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000, }; export function getCandleStart(tradeTimestampMs: number, timeframe: Timeframe): number { const sec = Math.floor(tradeTimestampMs / 1000); const tfSec = TF_SECONDS[timeframe] ?? 60; return Math.floor(sec / tfSec) * tfSec; }