108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
import type { OHLCVBar, Timeframe } from '../types';
|
|
import { API_BASE } from './backendApi';
|
|
import { timeframeToCandleType } from './chartCandleType';
|
|
import { fetchUpbitCandlesBeforeCached } from './requestCache';
|
|
import { normalizeEpochSec } from './backtestUiUtils';
|
|
|
|
/** 백엔드·업비트 history API용 ISO (Z 접미사 없음 — 서버에서 추가) */
|
|
export function toHistoryApiIso(epochSec: number): string {
|
|
return new Date(epochSec * 1000).toISOString().replace(/\.\d{3}Z$/, '');
|
|
}
|
|
|
|
/** 백테스팅·실매매 분석용 과거 캔들 로드 */
|
|
export async function loadAnalysisCandles(
|
|
market: string,
|
|
timeframe: Timeframe,
|
|
toTimeSec: number,
|
|
count: number,
|
|
): Promise<OHLCVBar[]> {
|
|
const type = timeframeToCandleType(timeframe);
|
|
const toSec = normalizeEpochSec(toTimeSec);
|
|
const to = toHistoryApiIso(toSec);
|
|
const safeCount = Math.max(50, Math.min(count, 500));
|
|
const params = new URLSearchParams({
|
|
market,
|
|
type,
|
|
to,
|
|
count: String(safeCount),
|
|
});
|
|
|
|
let data: OHLCVBar[] = [];
|
|
try {
|
|
const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`);
|
|
if (res.ok) {
|
|
const raw: Array<Record<string, number>> = await res.json();
|
|
data = raw.map(d => ({
|
|
time: d.time,
|
|
open: d.open,
|
|
high: d.high,
|
|
low: d.low,
|
|
close: d.close,
|
|
volume: d.volume,
|
|
}));
|
|
}
|
|
} catch { /* backend fallback below */ }
|
|
|
|
if (data.length === 0) {
|
|
try {
|
|
data = await fetchUpbitCandlesBeforeCached(market, timeframe, safeCount, to);
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
return data.sort((a, b) => a.time - b.time);
|
|
}
|
|
|
|
/** 시그널 발생 봉 기준 좌우 N개 캔들 (기본 ±5) */
|
|
export const SIGNAL_SNAPSHOT_CONTEXT_BARS = 5;
|
|
|
|
/** 화면에 표시할 봉 수 (±context) */
|
|
export const SIGNAL_SNAPSHOT_VISIBLE_BARS = SIGNAL_SNAPSHOT_CONTEXT_BARS * 2 + 1;
|
|
|
|
/** CCI·RSI 등 보조지표 계산용 선행 워밍업 봉 (표시 범위와 별도) */
|
|
const SIGNAL_SNAPSHOT_WARMUP_BARS = 50;
|
|
|
|
export function findNearestBarIndex(bars: OHLCVBar[], candleTimeSec: number): number {
|
|
if (bars.length === 0) return -1;
|
|
let bestIdx = 0;
|
|
let bestDist = Math.abs(bars[0].time - candleTimeSec);
|
|
for (let i = 1; i < bars.length; i++) {
|
|
const dist = Math.abs(bars[i].time - candleTimeSec);
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
bestIdx = i;
|
|
}
|
|
}
|
|
return bestIdx;
|
|
}
|
|
|
|
export async function loadSignalSnapshotCandles(
|
|
market: string,
|
|
timeframe: Timeframe,
|
|
candleTimeSec: number,
|
|
contextBars = SIGNAL_SNAPSHOT_CONTEXT_BARS,
|
|
): Promise<OHLCVBar[]> {
|
|
const barSec = timeframeToBarSeconds(timeframe);
|
|
const normalizedCandle = normalizeEpochSec(candleTimeSec);
|
|
const wantVisible = contextBars * 2 + 1;
|
|
const toTimeSec = normalizedCandle + barSec * (contextBars + 2);
|
|
const fetchCount = Math.max(wantVisible + SIGNAL_SNAPSHOT_WARMUP_BARS + 10, 60);
|
|
const raw = await loadAnalysisCandles(market, timeframe, toTimeSec, fetchCount);
|
|
if (raw.length === 0) return raw;
|
|
|
|
let idx = raw.findIndex(b => b.time === normalizedCandle);
|
|
if (idx < 0) idx = findNearestBarIndex(raw, normalizedCandle);
|
|
if (idx < 0) return raw.slice(-wantVisible);
|
|
|
|
const start = Math.max(0, idx - contextBars - SIGNAL_SNAPSHOT_WARMUP_BARS);
|
|
const end = Math.min(raw.length, idx + contextBars + 1);
|
|
return raw.slice(start, end);
|
|
}
|
|
|
|
function timeframeToBarSeconds(timeframe: string): number {
|
|
const map: Record<string, number> = {
|
|
'1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800,
|
|
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
|
|
};
|
|
return map[timeframe] ?? 180;
|
|
}
|