Files
goldenChart/frontend/src/utils/upbitApi.ts
T
2026-05-23 15:11:48 +09:00

329 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}