백테스팅 워밍업데이터 적용
This commit is contained in:
@@ -1317,6 +1317,8 @@ export interface BacktestRequest {
|
||||
symbol?: string;
|
||||
strategyName?: string;
|
||||
deviceId?: string;
|
||||
/** bars 마지막 N봉만 시그널 평가 (앞쪽은 지표 워밍업) */
|
||||
evaluationBarCount?: number;
|
||||
}
|
||||
|
||||
/** 백테스팅 실행 */
|
||||
|
||||
@@ -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: '바',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user