Files
goldenChart/frontend/src/utils/analysisChartData.ts
T
2026-06-12 14:39:17 +09:00

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;
}