learning_ai_invt_trdg/backend/testBacktestIsolation.ts

182 lines
7.6 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from '../src/config/index.js';
import { runBacktest } from '../src/backtest/index.js';
import type { BacktestRequest } from '../src/backtest/types.js';
import logger from '../src/utils/logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const makeCandle = (timestamp: number, close: number) => ({
timestamp,
open: close * 0.999,
high: close * 1.002,
low: close * 0.998,
close,
volume: 100
});
const build15mSeries = (startTs: number, count: number): Array<ReturnType<typeof makeCandle>> => {
const out: Array<ReturnType<typeof makeCandle>> = [];
let price = 100;
for (let i = 0; i < count; i++) {
if (i < Math.floor(count * 0.6)) {
price += 0.5;
} else {
price -= 0.65;
}
out.push(makeCandle(startTs + i * 15 * 60 * 1000, Number(price.toFixed(6))));
}
return out;
};
const buildRequest = (): BacktestRequest => {
const candles = build15mSeries(Date.UTC(2025, 0, 1, 0, 0, 0), 700);
const from = new Date(candles[220].timestamp).toISOString();
const to = new Date(candles[candles.length - 1].timestamp).toISOString();
return {
mode: 'backtest',
symbols: ['BTC/USDT'],
timeframe: '15m',
dateRange: { from, to },
dataSource: {
type: 'json',
payload: {
candles: {
'BTC/USDT': {
'15m': candles
}
}
}
},
strategyConfig: {
rules: [
{ ruleId: 'TrendBiasRule', enabled: true, ruleType: 'voting', params: { fastPeriod: 10, slowPeriod: 20 } },
{ ruleId: 'SessionRule', enabled: true, ruleType: 'mandatory', params: { sessions: '24/7' } },
{ ruleId: 'RiskManagementRule', enabled: true, ruleType: 'mandatory', params: { atrPeriod: 14, maxRisk: 10 } }
],
riskLimits: {
maxDailyLossUsd: 5000,
dailyProfitTargetUsd: 5000,
maxOpenTrades: 3,
maxConsecutiveLosses: 10
},
execution: {
orderType: 'market',
cooldownMinutes: 0,
minRulePassRatio: 1,
entryMode: 'both'
}
},
execution: {
initialCapitalUsd: 10_000,
orderType: 'market',
slippageBps: 5,
partialFillPct: 1,
enforceWarmup: true,
allowNegativeCash: false,
forceCloseAtWindowEnd: false
}
};
};
const verifySourceIsolation = (): void => {
const backtestRoot = path.join(repoRoot, 'src', 'backtest');
const files = fs.readdirSync(backtestRoot, { recursive: true })
.filter((entry) => String(entry).endsWith('.ts'))
.map((entry) => path.join(backtestRoot, String(entry)));
assert(files.length > 0, 'Expected backtest source files to exist.');
const forbiddenPatterns = [
/from ['"]\.\.\/services\/TradeExecutor/i,
/from ['"]\.\.\/services\/AutoTrader/i,
/from ['"]\.\.\/services\/CapitalLedger/i,
/from ['"]\.\.\/connectors\/alpaca/i,
/from ['"]\.\.\/connectors\/ccxt/i,
/\.from\(['"](orders|trade_history|capital_ledgers)['"]\)/
];
for (const filePath of files) {
const source = fs.readFileSync(filePath, 'utf8');
for (const pattern of forbiddenPatterns) {
assert(!pattern.test(source), `Isolation violation in ${path.relative(repoRoot, filePath)} (${pattern})`);
}
}
};
const verifyBacktestBehavior = async (): Promise<void> => {
const originalFlag = config.ENABLE_BACKTEST;
const originalInfo = (logger as any).info?.bind(logger);
const originalWarn = (logger as any).warn?.bind(logger);
const originalError = (logger as any).error?.bind(logger);
config.ENABLE_BACKTEST = true;
(logger as any).info = () => undefined;
(logger as any).warn = () => undefined;
(logger as any).error = () => undefined;
try {
const request = buildRequest();
const first = await runBacktest(request, {
profileSettings: {
allocated_capital: 10_000,
risk_per_trade_percent: 1,
strategy_config: request.strategyConfig
}
});
const second = await runBacktest(request, {
profileSettings: {
allocated_capital: 10_000,
risk_per_trade_percent: 1,
strategy_config: request.strategyConfig
}
});
assert.deepEqual(first, second, 'Backtest must be deterministic for identical inputs.');
const fromTs = Date.parse(request.dateRange.from);
const toTs = Date.parse(request.dateRange.to);
assert.equal(first.window.endOfWindowPolicy, 'OPEN_AT_END', 'Default end-of-window behavior must be OPEN_AT_END.');
assert.equal(first.window.fromTimestamp, fromTs, 'Window.fromTimestamp must match request.from.');
assert.equal(first.window.toTimestamp, toTs, 'Window.toTimestamp must match request.to.');
assert(first.window.includesWarmupCandles, 'Warm-up candles should be loaded before replay start.');
assert(first.warmup.endTimestamp >= first.warmup.startTimestamp, 'Warm-up report must be valid.');
assert(first.trades.every((trade) => trade.entryTimestamp >= fromTs), 'No trades allowed before replay window start.');
assert(first.trades.every((trade) => trade.entryTimestamp <= toTs), 'No trades allowed after replay window end.');
assert(first.trades.every((trade) => trade.exitTimestamp <= toTs), 'No exits allowed after replay window end.');
assert(first.trades.every((trade) => trade.entryTimestamp >= first.warmup.endTimestamp), 'No trades allowed before warm-up completion.');
assert(first.timeline.every((point) => point.cashUsd >= -1e-6), 'Cash must never be negative with allowNegativeCash=false.');
assert(first.timeline.every((point) => point.reservedUsd <= point.cashUsd + 1e-6), 'Reserved capital cannot exceed cash.');
assert.equal(first.diagnostics.connectorCalls.placeOrder, 0, 'Backtest must never call placeOrder.');
const forcedWindowCloseRequest: BacktestRequest = {
...request,
execution: {
...(request.execution || {}),
forceCloseAtWindowEnd: true
}
};
const forcedCloseResult = await runBacktest(forcedWindowCloseRequest, {
profileSettings: {
allocated_capital: 10_000,
risk_per_trade_percent: 1,
strategy_config: request.strategyConfig
}
});
assert.equal(forcedCloseResult.window.endOfWindowPolicy, 'FORCE_CLOSE', 'Force-close mode must be reported in window metadata.');
assert.equal(forcedCloseResult.openPositionsAtEnd.length, 0, 'Force-close mode should not leave OPEN_AT_END positions.');
assert.equal(forcedCloseResult.diagnostics.connectorCalls.placeOrder, 0, 'Force-close mode must never call placeOrder.');
} finally {
config.ENABLE_BACKTEST = originalFlag;
(logger as any).info = originalInfo;
(logger as any).warn = originalWarn;
(logger as any).error = originalError;
}
};
verifySourceIsolation();
await verifyBacktestBehavior();
console.log('[backtest-isolation] OK: warm-up, capital, determinism, and isolation guards validated');