work on backtest engine
This commit is contained in:
parent
3a7557c8f4
commit
b8cefdb8cd
11 changed files with 1525 additions and 318 deletions
|
|
@ -86,6 +86,11 @@ export class RustBacktestAdapter extends EventEmitter {
|
|||
tradesLength: rustResult.trades?.length,
|
||||
finalPositions: rustResult.final_positions
|
||||
});
|
||||
|
||||
// Log first trade structure to understand format
|
||||
if (rustResult.trades?.length > 0) {
|
||||
this.container.logger.info('First trade structure:', rustResult.trades[0]);
|
||||
}
|
||||
|
||||
// Store OHLC data for each symbol
|
||||
const ohlcData: Record<string, any[]> = {};
|
||||
|
|
@ -106,44 +111,9 @@ export class RustBacktestAdapter extends EventEmitter {
|
|||
}));
|
||||
}
|
||||
|
||||
// Convert Rust result to orchestrator format
|
||||
// Rust result is already in the correct format, just add OHLC data
|
||||
const result: BacktestResult = {
|
||||
backtestId: `rust-${Date.now()}`,
|
||||
status: 'completed',
|
||||
completedAt: new Date().toISOString(),
|
||||
config: {
|
||||
name: config.name || 'Backtest',
|
||||
strategy: config.strategy,
|
||||
symbols: config.symbols,
|
||||
startDate: config.startDate,
|
||||
endDate: config.endDate,
|
||||
initialCapital: config.initialCapital,
|
||||
commission: config.commission || 0.001,
|
||||
slippage: config.slippage || 0.0001,
|
||||
dataFrequency: config.dataFrequency || '1d',
|
||||
},
|
||||
metrics: {
|
||||
totalReturn: rustResult.metrics.total_return,
|
||||
sharpeRatio: rustResult.metrics.sharpe_ratio,
|
||||
maxDrawdown: rustResult.metrics.max_drawdown,
|
||||
winRate: rustResult.metrics.win_rate,
|
||||
totalTrades: rustResult.metrics.total_trades,
|
||||
profitFactor: rustResult.metrics.profit_factor,
|
||||
profitableTrades: rustResult.metrics.profitable_trades,
|
||||
avgWin: rustResult.metrics.avg_win,
|
||||
avgLoss: rustResult.metrics.avg_loss,
|
||||
expectancy: this.calculateExpectancy(rustResult.metrics),
|
||||
calmarRatio: rustResult.metrics.total_return / (rustResult.metrics.max_drawdown || 1),
|
||||
sortinoRatio: 0, // TODO: Calculate from downside deviation
|
||||
},
|
||||
equityCurve: rustResult.equity_curve.map((point: any) => ({
|
||||
timestamp: new Date(point[0]).getTime(),
|
||||
value: point[1],
|
||||
})),
|
||||
trades: this.transformCompletedTradesToFills(rustResult.trades || []),
|
||||
dailyReturns: this.calculateDailyReturns(rustResult.equity_curve),
|
||||
finalPositions: rustResult.final_positions || {},
|
||||
executionTime: Date.now() - startTime,
|
||||
...rustResult,
|
||||
ohlcData,
|
||||
};
|
||||
|
||||
|
|
@ -315,208 +285,4 @@ export class RustBacktestAdapter extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private calculateExpectancy(metrics: any): number {
|
||||
if (metrics.total_trades === 0) return 0;
|
||||
|
||||
const winProb = metrics.win_rate / 100;
|
||||
const lossProb = 1 - winProb;
|
||||
|
||||
return (winProb * metrics.avg_win) - (lossProb * Math.abs(metrics.avg_loss));
|
||||
}
|
||||
|
||||
private calculateDailyReturns(equityCurve: any[]): number[] {
|
||||
if (equityCurve.length < 2) return [];
|
||||
|
||||
const returns: number[] = [];
|
||||
for (let i = 1; i < equityCurve.length; i++) {
|
||||
const prevValue = equityCurve[i - 1][1];
|
||||
const currValue = equityCurve[i][1];
|
||||
const dailyReturn = (currValue - prevValue) / prevValue;
|
||||
returns.push(dailyReturn);
|
||||
}
|
||||
|
||||
return returns;
|
||||
}
|
||||
|
||||
private getEmptyMetrics() {
|
||||
return {
|
||||
totalReturn: 0,
|
||||
sharpeRatio: 0,
|
||||
maxDrawdown: 0,
|
||||
winRate: 0,
|
||||
totalTrades: 0,
|
||||
profitFactor: 0,
|
||||
profitableTrades: 0,
|
||||
avgWin: 0,
|
||||
avgLoss: 0,
|
||||
expectancy: 0,
|
||||
calmarRatio: 0,
|
||||
sortinoRatio: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private transformCompletedTradesToFills(completedTrades: any[]): any[] {
|
||||
// Convert completed trades (paired entry/exit) back to individual fills for the UI
|
||||
const fills: any[] = [];
|
||||
let fillId = 0;
|
||||
|
||||
completedTrades.forEach(trade => {
|
||||
// Create entry fill
|
||||
fills.push({
|
||||
id: `fill-${fillId++}`,
|
||||
timestamp: trade.entry_time || trade.entryDate,
|
||||
symbol: trade.symbol,
|
||||
side: trade.side === 'Buy' || trade.side === 'long' ? 'buy' : 'sell',
|
||||
quantity: trade.quantity,
|
||||
price: trade.entry_price || trade.entryPrice,
|
||||
commission: trade.commission / 2, // Split commission between entry and exit
|
||||
});
|
||||
|
||||
// Create exit fill (opposite side)
|
||||
const exitSide = (trade.side === 'Buy' || trade.side === 'long') ? 'sell' : 'buy';
|
||||
fills.push({
|
||||
id: `fill-${fillId++}`,
|
||||
timestamp: trade.exit_time || trade.exitDate,
|
||||
symbol: trade.symbol,
|
||||
side: exitSide,
|
||||
quantity: trade.quantity,
|
||||
price: trade.exit_price || trade.exitPrice,
|
||||
commission: trade.commission / 2,
|
||||
pnl: trade.pnl,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by timestamp
|
||||
fills.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
|
||||
return fills;
|
||||
}
|
||||
|
||||
private transformFillsToTrades(completedTrades: any[]): any[] {
|
||||
// Group fills by symbol to match entries with exits
|
||||
const fillsBySymbol: { [symbol: string]: any[] } = {};
|
||||
|
||||
completedTrades.forEach(trade => {
|
||||
if (!fillsBySymbol[trade.symbol]) {
|
||||
fillsBySymbol[trade.symbol] = [];
|
||||
}
|
||||
fillsBySymbol[trade.symbol].push(trade);
|
||||
});
|
||||
|
||||
const pairedTrades: any[] = [];
|
||||
const openPositions: { [symbol: string]: any[] } = {};
|
||||
|
||||
// Process each symbol's fills chronologically
|
||||
Object.entries(fillsBySymbol).forEach(([symbol, fills]) => {
|
||||
// Sort by timestamp
|
||||
fills.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
|
||||
fills.forEach((fill, idx) => {
|
||||
const isBuy = fill.side === 'Buy';
|
||||
const timestamp = new Date(fill.timestamp);
|
||||
|
||||
if (!openPositions[symbol]) {
|
||||
openPositions[symbol] = [];
|
||||
}
|
||||
|
||||
const openPos = openPositions[symbol];
|
||||
|
||||
// For buy fills, add to open positions
|
||||
if (isBuy) {
|
||||
openPos.push(fill);
|
||||
} else {
|
||||
// For sell fills, match with open buy positions (FIFO)
|
||||
if (openPos.length > 0 && openPos[0].side === 'Buy') {
|
||||
const entry = openPos.shift();
|
||||
const entryDate = new Date(entry.timestamp);
|
||||
const duration = (timestamp.getTime() - entryDate.getTime()) / 1000; // seconds
|
||||
const pnl = (fill.price - entry.price) * fill.quantity - entry.commission - fill.commission;
|
||||
const pnlPercent = ((fill.price - entry.price) / entry.price) * 100;
|
||||
|
||||
pairedTrades.push({
|
||||
id: `trade-${pairedTrades.length}`,
|
||||
symbol,
|
||||
entryDate: entryDate.toISOString(),
|
||||
exitDate: timestamp.toISOString(),
|
||||
entryPrice: entry.price,
|
||||
exitPrice: fill.price,
|
||||
quantity: fill.quantity,
|
||||
side: 'long',
|
||||
pnl,
|
||||
pnlPercent,
|
||||
commission: entry.commission + fill.commission,
|
||||
duration,
|
||||
});
|
||||
} else {
|
||||
// This is a short entry
|
||||
openPos.push(fill);
|
||||
}
|
||||
}
|
||||
|
||||
// For short positions (sell first, then buy to cover)
|
||||
if (!isBuy && openPos.length > 0) {
|
||||
const lastPos = openPos[openPos.length - 1];
|
||||
if (lastPos.side === 'Sell' && idx < fills.length - 1) {
|
||||
const nextFill = fills[idx + 1];
|
||||
if (nextFill && nextFill.side === 'Buy') {
|
||||
// We'll handle this when we process the buy fill
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle buy fills that close short positions
|
||||
if (isBuy && openPos.length > 1) {
|
||||
const shortPos = openPos.find(p => p.side === 'Sell');
|
||||
if (shortPos) {
|
||||
const shortIdx = openPos.indexOf(shortPos);
|
||||
openPos.splice(shortIdx, 1);
|
||||
|
||||
const entryDate = new Date(shortPos.timestamp);
|
||||
const duration = (timestamp.getTime() - entryDate.getTime()) / 1000;
|
||||
const pnl = (shortPos.price - fill.price) * fill.quantity - shortPos.commission - fill.commission;
|
||||
const pnlPercent = ((shortPos.price - fill.price) / shortPos.price) * 100;
|
||||
|
||||
pairedTrades.push({
|
||||
id: `trade-${pairedTrades.length}`,
|
||||
symbol,
|
||||
entryDate: entryDate.toISOString(),
|
||||
exitDate: timestamp.toISOString(),
|
||||
entryPrice: shortPos.price,
|
||||
exitPrice: fill.price,
|
||||
quantity: fill.quantity,
|
||||
side: 'short',
|
||||
pnl,
|
||||
pnlPercent,
|
||||
commission: shortPos.commission + fill.commission,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add any remaining open positions as incomplete trades
|
||||
const remainingOpenPositions = openPositions[symbol] || [];
|
||||
remainingOpenPositions.forEach(pos => {
|
||||
const timestamp = new Date(pos.timestamp);
|
||||
const side = pos.side === 'Buy' ? 'buy' : 'sell';
|
||||
|
||||
pairedTrades.push({
|
||||
id: `trade-${pairedTrades.length}`,
|
||||
symbol,
|
||||
entryDate: timestamp.toISOString(),
|
||||
exitDate: timestamp.toISOString(), // Same as entry for open positions
|
||||
entryPrice: pos.price,
|
||||
exitPrice: pos.price,
|
||||
quantity: pos.quantity,
|
||||
side,
|
||||
pnl: 0, // No PnL for open positions
|
||||
pnlPercent: 0,
|
||||
commission: pos.commission,
|
||||
duration: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return pairedTrades;
|
||||
}
|
||||
}
|
||||
|
|
@ -150,20 +150,27 @@ async function testMeanReversionBacktest() {
|
|||
// Show first few trades
|
||||
console.log(` - First 3 trades:`);
|
||||
trades.slice(0, 3).forEach((trade, idx) => {
|
||||
console.log(` ${idx + 1}. ${trade.side} - Price: $${trade.price.toFixed(2)}, Quantity: ${trade.quantity}${trade.pnl ? `, PnL: $${trade.pnl.toFixed(2)}` : ''}`);
|
||||
console.log(` ${idx + 1}. Trade:`, JSON.stringify(trade, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
// Check position distribution
|
||||
const allDurations = result.trades.map(t => t.duration / 86400); // Convert to days
|
||||
const avgDuration = allDurations.reduce((a, b) => a + b, 0) / allDurations.length;
|
||||
const minDuration = Math.min(...allDurations);
|
||||
const maxDuration = Math.max(...allDurations);
|
||||
// Analyze trade pairing
|
||||
console.log('\n=== Trade Pairing Analysis ===');
|
||||
console.log(`Total fills: ${result.trades.length}`);
|
||||
console.log(`Expected pairs: ${result.trades.length / 2}`);
|
||||
|
||||
console.log('\n=== Duration Analysis ===');
|
||||
console.log(`Average trade duration: ${avgDuration.toFixed(1)} days`);
|
||||
console.log(`Min duration: ${minDuration.toFixed(1)} days`);
|
||||
console.log(`Max duration: ${maxDuration.toFixed(1)} days`);
|
||||
// Look for patterns that show instant buy/sell
|
||||
let instantPairs = 0;
|
||||
for (let i = 1; i < result.trades.length; i++) {
|
||||
const prev = result.trades[i-1];
|
||||
const curr = result.trades[i];
|
||||
if (prev.symbol === curr.symbol &&
|
||||
prev.side === 'buy' && curr.side === 'sell' &&
|
||||
new Date(curr.timestamp).getTime() - new Date(prev.timestamp).getTime() < 86400000) {
|
||||
instantPairs++;
|
||||
}
|
||||
}
|
||||
console.log(`Instant buy/sell pairs (< 1 day): ${instantPairs}`);
|
||||
|
||||
// Final positions
|
||||
console.log('\n=== Final Positions ===');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue