182 lines
7.6 KiB
TypeScript
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');
|