229 lines
9.2 KiB
TypeScript
229 lines
9.2 KiB
TypeScript
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;
|
||
}
|