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 { 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> = 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 { 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 = { '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; }