214 lines
6.7 KiB
TypeScript
214 lines
6.7 KiB
TypeScript
/**
|
|
* 백테스팅 워밍업 — 차트(표시 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;
|
|
|
|
const before = byTime.size;
|
|
for (const b of raw) byTime.set(b.time, b);
|
|
if (byTime.size === before) break;
|
|
|
|
remaining -= raw.length;
|
|
|
|
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,
|
|
};
|
|
}
|