goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
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;
}