329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
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<OHLCVBar[]> {
|
||
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<string>();
|
||
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<typeof setTimeout> | null = null;
|
||
private pingTimer: ReturnType<typeof setInterval> | 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<Timeframe, number> = {
|
||
'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;
|
||
}
|