import type { OHLCVBar, Timeframe } from '../types'; import { subscribeUpbitTrade, subscribeUpbitWsConnection } from './upbitWsBroker'; // ─── Upbit REST API ──────────────────────────────────────────────────────── // 개발: Vite proxy (/upbit-api) → https://api.upbit.com // 프로덕션: nginx proxy (/upbit-api) → https://api.upbit.com // 항상 동일한 상대 경로를 사용하므로 CORS 없이 동일 오리진으로 요청됨 /** dev: Vite proxy, prod: nginx proxy — 항상 동일 오리진 상대 경로 */ export 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 '10m': return `${base}/minutes/10?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; } /** @deprecated 직접 WebSocket 대신 upbitWsBroker 사용. 하위 호환 래퍼. */ export class UpbitWebSocketClient { private destroyed = false; private unsubTrade: (() => void) | null = null; private unsubConn: (() => void) | null = null; constructor(private opts: UpbitWsOptions) { this.unsubConn = subscribeUpbitWsConnection(connected => { if (this.destroyed) return; if (connected) { this.opts.onConnect(); } else { this.opts.onDisconnect(); } }); this.unsubTrade = subscribeUpbitTrade(opts.market, msg => { if (this.destroyed) return; try { this.opts.onMessage(msg); } catch (err) { console.error('[UpbitWS] onMessage 처리 중 오류:', err); } }); } destroy() { this.destroyed = true; this.unsubTrade?.(); this.unsubTrade = null; this.unsubConn?.(); this.unsubConn = null; } } // ─── 타임프레임 초 단위 ─────────────────────────────────────────────────── export const TF_SECONDS: Record = { '1m': 60, '3m': 180, '5m': 300, '10m': 600, '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; }