백테스팅 워밍업데이터 적용

This commit is contained in:
Macbook
2026-06-12 01:41:34 +09:00
parent 0d338fffe4
commit 74b0ea4ab6
12 changed files with 335 additions and 52 deletions
+2
View File
@@ -1317,6 +1317,8 @@ export interface BacktestRequest {
symbol?: string;
strategyName?: string;
deviceId?: string;
/** bars 마지막 N봉만 시그널 평가 (앞쪽은 지표 워밍업) */
evaluationBarCount?: number;
}
/** 백테스팅 실행 */
+16
View File
@@ -1,6 +1,7 @@
import type { ChartType, Timeframe } from '../types';
import type { BacktestResultRecord } from './backendApi';
import { formatUpbitKrwPrice } from './safeFormat';
import { INDICATOR_WARMUP } from './upbitApi';
export function toUpbitMarket(symbol: string): string {
return symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`;
@@ -40,6 +41,21 @@ export function resolveBacktestRecordExecTimeframe(record: BacktestResultRecord)
return '1m';
}
/** 백테스트 결과 차트 로드 봉 수 — 평가 구간 + 지표 워밍업 (차트 SMA와 백테스트 엔진 정렬) */
export function resolveBacktestChartBarCount(record: BacktestResultRecord): number {
const evalCount = record.barCount > 0 ? record.barCount : 200;
let warmup = INDICATOR_WARMUP;
if (record.executionSnapshotJson) {
try {
const snap = JSON.parse(record.executionSnapshotJson) as { warmupBarCount?: number };
if (typeof snap.warmupBarCount === 'number' && snap.warmupBarCount > 0) {
warmup = snap.warmupBarCount;
}
} catch { /* ignore */ }
}
return evalCount + warmup;
}
const CHART_TYPE_KO: Record<ChartType, string> = {
candlestick: '캔들스틱',
bar: '바',
+212
View File
@@ -0,0 +1,212 @@
/**
* 백테스팅 워밍업 — 차트(표시 200 + 워밍업)와 동일하게 지표 수렴 후 평가 구간만 시그널 생성
*/
import type { OHLCVBar, Timeframe } from '../types';
import { API_BASE } from './backendApi';
import { timeframeToCandleType } from './chartCandleType';
import { toHistoryApiIso } from './analysisChartData';
import { INDICATOR_WARMUP } from './upbitApi';
/** 차트 초기 표시·백테스트 평가 구간 기본 봉 수 (워밍업 제외) */
export const BACKTEST_DISPLAY_BAR_COUNT = 200;
const HISTORY_PAGE_SIZE = 200;
const MAX_BACKTEST_FETCH = 800;
const MA_FIELD_RE = /^MA(\d+)$/;
const EMA_FIELD_RE = /^EMA(\d+)$/;
function collectPeriodFromField(field: unknown, out: Set<number>): void {
if (typeof field !== 'string') return;
const ma = MA_FIELD_RE.exec(field);
if (ma) {
out.add(parseInt(ma[1], 10));
return;
}
const ema = EMA_FIELD_RE.exec(field);
if (ema) out.add(parseInt(ema[1], 10));
}
/** 전략 DSL 트리에서 MA/EMA 기간 추출 */
function walkStrategyDsl(node: unknown, out: Set<number>): void {
if (!node || typeof node !== 'object') return;
const n = node as Record<string, unknown>;
const cond = n.condition;
if (cond && typeof cond === 'object') {
const c = cond as Record<string, unknown>;
collectPeriodFromField(c.leftField, out);
collectPeriodFromField(c.rightField, out);
const p = Number(c.period ?? c.leftPeriod ?? c.rightPeriod);
if (Number.isFinite(p) && p > 0) out.add(Math.floor(p));
}
for (const ch of (n.children as unknown[]) ?? []) walkStrategyDsl(ch, out);
}
function maxFromIndicatorParams(
params: Record<string, Record<string, unknown>> | undefined,
): number {
if (!params) return 0;
let max = 0;
const sma = params.SMA ?? params.sma;
if (sma) {
for (let i = 1; i <= 11; i++) {
const v = Number(sma[`period${i}`]);
if (Number.isFinite(v) && v > 0) max = Math.max(max, Math.floor(v));
}
}
const ichimoku = params.IchimokuCloud ?? params.ICHIMOKU;
if (ichimoku) {
for (const key of ['basePeriods', 'laggingSpan2Periods', 'conversionPeriods', 'displacement']) {
const v = Number(ichimoku[key]);
if (Number.isFinite(v) && v > 0) max = Math.max(max, Math.floor(v));
}
}
const macd = params.MACD ?? params.macd;
if (macd) {
for (const key of ['fastLength', 'slowLength', 'signalLength']) {
const v = Number(macd[key]);
if (Number.isFinite(v) && v > 0) max = Math.max(max, Math.floor(v));
}
const slow = Number(macd.slowLength ?? 26);
const sig = Number(macd.signalLength ?? 9);
if (Number.isFinite(slow) && Number.isFinite(sig)) {
max = Math.max(max, Math.floor(slow + sig));
}
}
for (const key of ['RSI', 'CCI', 'ADX', 'Stochastic']) {
const block = params[key];
if (!block) continue;
const len = Number(block.length ?? block.period);
if (Number.isFinite(len) && len > 0) max = Math.max(max, Math.floor(len));
}
return max;
}
/**
* 전략·지표 설정 기준 워밍업 봉 수.
* 차트 INDICATOR_WARMUP(100)과 동일한 안전 마진을 포함한다.
*/
export function computeBacktestWarmupBars(
buyDsl?: unknown,
sellDsl?: unknown,
indicatorParams?: Record<string, Record<string, unknown>>,
): number {
const periods = new Set<number>();
walkStrategyDsl(buyDsl, periods);
walkStrategyDsl(sellDsl, periods);
let maxPeriod = periods.size > 0 ? Math.max(...periods) : 0;
maxPeriod = Math.max(maxPeriod, maxFromIndicatorParams(indicatorParams));
// Ta4j SMA unstable + cross 1봉 여유
const strategyNeed = maxPeriod > 0 ? maxPeriod + 5 : 0;
return Math.max(INDICATOR_WARMUP, strategyNeed);
}
/** 백엔드 history API — count>200 페이지네이션 */
export async function fetchBacktestHistoryCandles(
market: string,
timeframe: Timeframe,
totalCount: number,
toTimeSec?: number,
): Promise<OHLCVBar[]> {
const type = timeframeToCandleType(timeframe);
const want = Math.min(Math.max(totalCount, 1), MAX_BACKTEST_FETCH);
let remaining = want;
let toIso: string | undefined = toTimeSec && toTimeSec > 0
? toHistoryApiIso(toTimeSec)
: undefined;
const byTime = new Map<number, OHLCVBar>();
while (remaining > 0) {
const batch = Math.min(remaining, HISTORY_PAGE_SIZE);
const params = new URLSearchParams({
market,
type,
count: String(batch),
});
if (toIso) params.set('to', toIso);
const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`);
if (!res.ok) throw new Error(`캔들 로딩 실패 (${res.status})`);
const raw = (await res.json()) as OHLCVBar[];
if (raw.length === 0) break;
for (const b of raw) byTime.set(b.time, b);
remaining -= raw.length;
if (raw.length < batch) break;
const oldest = raw[0]?.time;
if (!oldest || oldest <= 0) break;
toIso = toHistoryApiIso(oldest);
if (remaining > 0) {
await new Promise(r => setTimeout(r, 110));
}
}
const sorted = [...byTime.values()].sort((a, b) => a.time - b.time);
if (sorted.length <= want) return sorted;
return sorted.slice(sorted.length - want);
}
export interface BacktestCandleBundle {
bars: OHLCVBar[];
evaluationBarCount: number;
warmupBarCount: number;
}
/**
* 표시 구간(displayCount) + 워밍업을 포함한 캔들 묶음.
* 백엔드 evaluationBarCount 로 마지막 displayCount 봉만 시그널 평가.
*/
export async function fetchBacktestCandleBundle(opts: {
market: string;
timeframe: Timeframe;
displayCount?: number;
buyDsl?: unknown;
sellDsl?: unknown;
indicatorParams?: Record<string, Record<string, unknown>>;
toTimeSec?: number;
}): Promise<BacktestCandleBundle> {
const displayCount = opts.displayCount ?? BACKTEST_DISPLAY_BAR_COUNT;
const warmupBarCount = computeBacktestWarmupBars(
opts.buyDsl,
opts.sellDsl,
opts.indicatorParams,
);
const total = displayCount + warmupBarCount;
const bars = await fetchBacktestHistoryCandles(
opts.market,
opts.timeframe,
total,
opts.toTimeSec,
);
const evaluationBarCount = Math.min(displayCount, bars.length);
return { bars, evaluationBarCount, warmupBarCount };
}
/** 이미 로드된 캔들(차트 rawBars)에 대한 평가·워밍업 구간 계산 */
export function resolveEvaluationFromLoadedBars(
barCount: number,
buyDsl?: unknown,
sellDsl?: unknown,
indicatorParams?: Record<string, Record<string, unknown>>,
): { evaluationBarCount: number; warmupBarCount: number } {
const warmupBarCount = computeBacktestWarmupBars(buyDsl, sellDsl, indicatorParams);
if (barCount <= warmupBarCount) {
return { evaluationBarCount: Math.max(1, barCount), warmupBarCount: 0 };
}
return {
evaluationBarCount: barCount - warmupBarCount,
warmupBarCount,
};
}