Files
goldenChart/frontend/src/utils/upbitApi.ts
T
2026-05-31 22:26:54 +09:00

229 lines
9.2 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';
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<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;
}
/** @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<Timeframe, number> = {
'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;
}