added initial py analytics / rust core / ts orchestrator services
This commit is contained in:
parent
680b5fd2ae
commit
c862ed496b
62 changed files with 13459 additions and 0 deletions
634
apps/stock/orchestrator/src/backtest/BacktestEngine.ts
Normal file
634
apps/stock/orchestrator/src/backtest/BacktestEngine.ts
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
import { logger } from '@stock-bot/logger';
|
||||
import { EventEmitter } from 'events';
|
||||
import { MarketData, BacktestConfigSchema, PerformanceMetrics, MarketMicrostructure } from '../types';
|
||||
import { StorageService } from '../services/StorageService';
|
||||
import { StrategyManager } from '../strategies/StrategyManager';
|
||||
import { TradingEngine } from '../../core';
|
||||
import { DataManager } from '../data/DataManager';
|
||||
import { MarketSimulator } from './MarketSimulator';
|
||||
import { PerformanceAnalyzer } from '../analytics/PerformanceAnalyzer';
|
||||
|
||||
interface BacktestEvent {
|
||||
timestamp: number;
|
||||
type: 'market_data' | 'strategy_signal' | 'order_fill';
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface BacktestResult {
|
||||
id: string;
|
||||
config: any;
|
||||
performance: PerformanceMetrics;
|
||||
trades: any[];
|
||||
equityCurve: { timestamp: number; value: number }[];
|
||||
drawdown: { timestamp: number; value: number }[];
|
||||
dailyReturns: number[];
|
||||
finalPositions: any[];
|
||||
}
|
||||
|
||||
export class BacktestEngine extends EventEmitter {
|
||||
private eventQueue: BacktestEvent[] = [];
|
||||
private currentTime: number = 0;
|
||||
private equityCurve: { timestamp: number; value: number }[] = [];
|
||||
private trades: any[] = [];
|
||||
private isRunning = false;
|
||||
private dataManager: DataManager;
|
||||
private marketSimulator: MarketSimulator;
|
||||
private performanceAnalyzer: PerformanceAnalyzer;
|
||||
private microstructures: Map<string, MarketMicrostructure> = new Map();
|
||||
|
||||
constructor(
|
||||
private storageService: StorageService,
|
||||
private strategyManager: StrategyManager
|
||||
) {
|
||||
super();
|
||||
this.dataManager = new DataManager(storageService);
|
||||
this.marketSimulator = new MarketSimulator({
|
||||
useHistoricalSpreads: true,
|
||||
modelHiddenLiquidity: true,
|
||||
includeDarkPools: true,
|
||||
latencyMs: 1
|
||||
});
|
||||
this.performanceAnalyzer = new PerformanceAnalyzer();
|
||||
}
|
||||
|
||||
async runBacktest(config: any): Promise<BacktestResult> {
|
||||
// Validate config
|
||||
const validatedConfig = BacktestConfigSchema.parse(config);
|
||||
|
||||
logger.info(`Starting backtest from ${validatedConfig.startDate} to ${validatedConfig.endDate}`);
|
||||
|
||||
// Reset state
|
||||
this.reset();
|
||||
this.isRunning = true;
|
||||
|
||||
// Generate backtest ID
|
||||
const backtestId = `backtest_${Date.now()}`;
|
||||
|
||||
try {
|
||||
// Load historical data with multi-resolution support
|
||||
const dataMap = await this.dataManager.loadHistoricalData(
|
||||
validatedConfig.symbols,
|
||||
new Date(validatedConfig.startDate),
|
||||
new Date(validatedConfig.endDate),
|
||||
validatedConfig.dataFrequency,
|
||||
true // Include extended hours
|
||||
);
|
||||
|
||||
// Load market microstructure for each symbol
|
||||
await this.loadMarketMicrostructure(validatedConfig.symbols);
|
||||
|
||||
// Convert to flat array and sort by time
|
||||
const marketData: MarketData[] = [];
|
||||
dataMap.forEach((data, symbol) => {
|
||||
marketData.push(...data);
|
||||
});
|
||||
marketData.sort((a, b) => a.data.timestamp - b.data.timestamp);
|
||||
|
||||
logger.info(`Loaded ${marketData.length} market data points`);
|
||||
|
||||
// Initialize strategies
|
||||
await this.strategyManager.initializeStrategies(validatedConfig.strategies || []);
|
||||
|
||||
// Convert market data to events
|
||||
this.populateEventQueue(marketData);
|
||||
|
||||
// Main backtest loop
|
||||
await this.processEvents();
|
||||
|
||||
// Calculate final metrics
|
||||
const performance = this.calculatePerformance();
|
||||
|
||||
// Get final positions
|
||||
const finalPositions = await this.getFinalPositions();
|
||||
|
||||
// Store results
|
||||
const result: BacktestResult = {
|
||||
id: backtestId,
|
||||
config: validatedConfig,
|
||||
performance,
|
||||
trades: this.trades,
|
||||
equityCurve: this.equityCurve,
|
||||
drawdown: this.calculateDrawdown(),
|
||||
dailyReturns: this.calculateDailyReturns(),
|
||||
finalPositions
|
||||
};
|
||||
|
||||
await this.storeResults(result);
|
||||
|
||||
logger.info(`Backtest completed: ${performance.totalTrades} trades, ${performance.totalReturn}% return`);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Backtest failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadHistoricalData(config: any): Promise<MarketData[]> {
|
||||
const data: MarketData[] = [];
|
||||
const startDate = new Date(config.startDate);
|
||||
const endDate = new Date(config.endDate);
|
||||
|
||||
for (const symbol of config.symbols) {
|
||||
const bars = await this.storageService.getHistoricalBars(
|
||||
symbol,
|
||||
startDate,
|
||||
endDate,
|
||||
config.dataFrequency
|
||||
);
|
||||
|
||||
// Convert to MarketData format
|
||||
bars.forEach(bar => {
|
||||
data.push({
|
||||
type: 'bar',
|
||||
data: {
|
||||
symbol,
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
volume: bar.volume,
|
||||
vwap: bar.vwap,
|
||||
timestamp: new Date(bar.timestamp).getTime()
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
data.sort((a, b) => {
|
||||
const timeA = a.data.timestamp;
|
||||
const timeB = b.data.timestamp;
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private populateEventQueue(marketData: MarketData[]): void {
|
||||
// Convert market data to events
|
||||
marketData.forEach(data => {
|
||||
this.eventQueue.push({
|
||||
timestamp: data.data.timestamp,
|
||||
type: 'market_data',
|
||||
data
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by timestamp (should already be sorted)
|
||||
this.eventQueue.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
|
||||
private async processEvents(): Promise<void> {
|
||||
const tradingEngine = this.strategyManager.getTradingEngine();
|
||||
let lastEquityUpdate = 0;
|
||||
const equityUpdateInterval = 60000; // Update equity every minute
|
||||
|
||||
while (this.eventQueue.length > 0 && this.isRunning) {
|
||||
const event = this.eventQueue.shift()!;
|
||||
|
||||
// Advance time
|
||||
this.currentTime = event.timestamp;
|
||||
if (tradingEngine) {
|
||||
await tradingEngine.advanceTime(this.currentTime);
|
||||
}
|
||||
|
||||
// Process event based on type
|
||||
switch (event.type) {
|
||||
case 'market_data':
|
||||
await this.processMarketData(event.data);
|
||||
break;
|
||||
|
||||
case 'strategy_signal':
|
||||
await this.processStrategySignal(event.data);
|
||||
break;
|
||||
|
||||
case 'order_fill':
|
||||
await this.processFill(event.data);
|
||||
break;
|
||||
}
|
||||
|
||||
// Update equity curve periodically
|
||||
if (this.currentTime - lastEquityUpdate > equityUpdateInterval) {
|
||||
await this.updateEquityCurve();
|
||||
lastEquityUpdate = this.currentTime;
|
||||
}
|
||||
|
||||
// Emit progress
|
||||
if (this.eventQueue.length % 1000 === 0) {
|
||||
this.emit('progress', {
|
||||
processed: this.trades.length,
|
||||
remaining: this.eventQueue.length,
|
||||
currentTime: new Date(this.currentTime)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Final equity update
|
||||
await this.updateEquityCurve();
|
||||
}
|
||||
|
||||
private async processMarketData(data: MarketData): Promise<void> {
|
||||
const tradingEngine = this.strategyManager.getTradingEngine();
|
||||
if (!tradingEngine) return;
|
||||
|
||||
// Process through market simulator for realistic orderbook
|
||||
const orderbook = this.marketSimulator.processMarketData(data);
|
||||
|
||||
if (orderbook) {
|
||||
// Update trading engine with simulated orderbook
|
||||
if (orderbook.bids.length > 0 && orderbook.asks.length > 0) {
|
||||
tradingEngine.updateQuote(
|
||||
orderbook.symbol,
|
||||
orderbook.bids[0].price,
|
||||
orderbook.asks[0].price,
|
||||
orderbook.bids[0].size,
|
||||
orderbook.asks[0].size
|
||||
);
|
||||
}
|
||||
|
||||
// Set microstructure in trading core for realistic fills
|
||||
const microstructure = this.microstructures.get(orderbook.symbol);
|
||||
if (microstructure && tradingEngine.setMicrostructure) {
|
||||
tradingEngine.setMicrostructure(orderbook.symbol, microstructure);
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple processing
|
||||
switch (data.type) {
|
||||
case 'quote':
|
||||
tradingEngine.updateQuote(
|
||||
data.data.symbol,
|
||||
data.data.bid,
|
||||
data.data.ask,
|
||||
data.data.bidSize,
|
||||
data.data.askSize
|
||||
);
|
||||
break;
|
||||
|
||||
case 'trade':
|
||||
tradingEngine.updateTrade(
|
||||
data.data.symbol,
|
||||
data.data.price,
|
||||
data.data.size,
|
||||
data.data.side
|
||||
);
|
||||
break;
|
||||
|
||||
case 'bar':
|
||||
const spread = data.data.high - data.data.low;
|
||||
const spreadBps = (spread / data.data.close) * 10000;
|
||||
const halfSpread = data.data.close * Math.min(spreadBps, 10) / 20000;
|
||||
|
||||
tradingEngine.updateQuote(
|
||||
data.data.symbol,
|
||||
data.data.close - halfSpread,
|
||||
data.data.close + halfSpread,
|
||||
data.data.volume / 100,
|
||||
data.data.volume / 100
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Let strategies process the data
|
||||
await this.strategyManager.onMarketData(data);
|
||||
|
||||
// Track performance
|
||||
this.performanceAnalyzer.addEquityPoint(
|
||||
new Date(this.currentTime),
|
||||
this.getPortfolioValue()
|
||||
);
|
||||
}
|
||||
|
||||
private async processStrategySignal(signal: any): Promise<void> {
|
||||
// Strategy signals are handled by strategy manager
|
||||
// This is here for future extensions
|
||||
}
|
||||
|
||||
private async processFill(fill: any): Promise<void> {
|
||||
// Record trade
|
||||
this.trades.push({
|
||||
...fill,
|
||||
backtestTime: this.currentTime
|
||||
});
|
||||
|
||||
// Store in database
|
||||
await this.storageService.storeFill(fill);
|
||||
}
|
||||
|
||||
private async updateEquityCurve(): Promise<void> {
|
||||
const tradingEngine = this.strategyManager.getTradingEngine();
|
||||
if (!tradingEngine) return;
|
||||
|
||||
// Get current P&L
|
||||
const [realized, unrealized] = tradingEngine.getTotalPnl();
|
||||
const totalEquity = 100000 + realized + unrealized; // Assuming 100k starting capital
|
||||
|
||||
this.equityCurve.push({
|
||||
timestamp: this.currentTime,
|
||||
value: totalEquity
|
||||
});
|
||||
}
|
||||
|
||||
private calculatePerformance(): PerformanceMetrics {
|
||||
// Use sophisticated performance analyzer
|
||||
this.trades.forEach(trade => {
|
||||
this.performanceAnalyzer.addTrade({
|
||||
entryTime: new Date(trade.entryTime),
|
||||
exitTime: new Date(trade.exitTime || this.currentTime),
|
||||
symbol: trade.symbol,
|
||||
side: trade.side,
|
||||
entryPrice: trade.entryPrice,
|
||||
exitPrice: trade.exitPrice || trade.currentPrice,
|
||||
quantity: trade.quantity,
|
||||
commission: trade.commission || 0,
|
||||
pnl: trade.pnl || 0,
|
||||
returnPct: trade.returnPct || 0,
|
||||
holdingPeriod: trade.holdingPeriod || 0,
|
||||
mae: trade.mae || 0,
|
||||
mfe: trade.mfe || 0
|
||||
});
|
||||
});
|
||||
|
||||
const metrics = this.performanceAnalyzer.analyze();
|
||||
|
||||
// Add drawdown analysis
|
||||
const drawdownAnalysis = this.performanceAnalyzer.analyzeDrawdowns();
|
||||
|
||||
return {
|
||||
...metrics,
|
||||
maxDrawdown: drawdownAnalysis.maxDrawdown,
|
||||
maxDrawdownDuration: drawdownAnalysis.maxDrawdownDuration
|
||||
};
|
||||
}
|
||||
|
||||
const initialEquity = this.equityCurve[0].value;
|
||||
const finalEquity = this.equityCurve[this.equityCurve.length - 1].value;
|
||||
const totalReturn = ((finalEquity - initialEquity) / initialEquity) * 100;
|
||||
|
||||
// Calculate daily returns
|
||||
const dailyReturns = this.calculateDailyReturns();
|
||||
|
||||
// Sharpe ratio (assuming 0% risk-free rate)
|
||||
const avgReturn = dailyReturns.reduce((a, b) => a + b, 0) / dailyReturns.length;
|
||||
const stdDev = Math.sqrt(
|
||||
dailyReturns.reduce((sum, r) => sum + Math.pow(r - avgReturn, 2), 0) / dailyReturns.length
|
||||
);
|
||||
const sharpeRatio = stdDev > 0 ? (avgReturn / stdDev) * Math.sqrt(252) : 0; // Annualized
|
||||
|
||||
// Win rate and profit factor
|
||||
const winningTrades = this.trades.filter(t => t.pnl > 0);
|
||||
const losingTrades = this.trades.filter(t => t.pnl < 0);
|
||||
const winRate = this.trades.length > 0 ? (winningTrades.length / this.trades.length) * 100 : 0;
|
||||
|
||||
const totalWins = winningTrades.reduce((sum, t) => sum + t.pnl, 0);
|
||||
const totalLosses = Math.abs(losingTrades.reduce((sum, t) => sum + t.pnl, 0));
|
||||
const profitFactor = totalLosses > 0 ? totalWins / totalLosses : totalWins > 0 ? Infinity : 0;
|
||||
|
||||
const avgWin = winningTrades.length > 0 ? totalWins / winningTrades.length : 0;
|
||||
const avgLoss = losingTrades.length > 0 ? totalLosses / losingTrades.length : 0;
|
||||
|
||||
// Max drawdown
|
||||
const drawdowns = this.calculateDrawdown();
|
||||
const maxDrawdown = Math.min(...drawdowns.map(d => d.value));
|
||||
|
||||
return {
|
||||
totalReturn,
|
||||
sharpeRatio,
|
||||
sortinoRatio: sharpeRatio * 0.8, // Simplified for now
|
||||
maxDrawdown: Math.abs(maxDrawdown),
|
||||
winRate,
|
||||
profitFactor,
|
||||
avgWin,
|
||||
avgLoss,
|
||||
totalTrades: this.trades.length
|
||||
};
|
||||
}
|
||||
|
||||
private calculateDrawdown(): { timestamp: number; value: number }[] {
|
||||
const drawdowns: { timestamp: number; value: number }[] = [];
|
||||
let peak = this.equityCurve[0]?.value || 0;
|
||||
|
||||
for (const point of this.equityCurve) {
|
||||
if (point.value > peak) {
|
||||
peak = point.value;
|
||||
}
|
||||
const drawdown = ((point.value - peak) / peak) * 100;
|
||||
drawdowns.push({
|
||||
timestamp: point.timestamp,
|
||||
value: drawdown
|
||||
});
|
||||
}
|
||||
|
||||
return drawdowns;
|
||||
}
|
||||
|
||||
private calculateDailyReturns(): number[] {
|
||||
const dailyReturns: number[] = [];
|
||||
const dailyEquity = new Map<string, number>();
|
||||
|
||||
// Group equity by day
|
||||
for (const point of this.equityCurve) {
|
||||
const date = new Date(point.timestamp).toDateString();
|
||||
dailyEquity.set(date, point.value);
|
||||
}
|
||||
|
||||
// Calculate returns
|
||||
const dates = Array.from(dailyEquity.keys()).sort();
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
const prevValue = dailyEquity.get(dates[i - 1])!;
|
||||
const currValue = dailyEquity.get(dates[i])!;
|
||||
const dailyReturn = ((currValue - prevValue) / prevValue) * 100;
|
||||
dailyReturns.push(dailyReturn);
|
||||
}
|
||||
|
||||
return dailyReturns;
|
||||
}
|
||||
|
||||
private async getFinalPositions(): Promise<any[]> {
|
||||
const tradingEngine = this.strategyManager.getTradingEngine();
|
||||
if (!tradingEngine) return [];
|
||||
|
||||
const positions = JSON.parse(tradingEngine.getOpenPositions());
|
||||
return positions;
|
||||
}
|
||||
|
||||
private async storeResults(result: BacktestResult): Promise<void> {
|
||||
// Store performance metrics
|
||||
await this.storageService.storeStrategyPerformance(
|
||||
result.id,
|
||||
result.performance
|
||||
);
|
||||
|
||||
// Could also store detailed results in a separate table or file
|
||||
logger.debug(`Backtest results stored with ID: ${result.id}`);
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.eventQueue = [];
|
||||
this.currentTime = 0;
|
||||
this.equityCurve = [];
|
||||
this.trades = [];
|
||||
this.marketSimulator.reset();
|
||||
}
|
||||
|
||||
private async loadMarketMicrostructure(symbols: string[]): Promise<void> {
|
||||
// In real implementation, would load from database
|
||||
// For now, create reasonable defaults based on symbol characteristics
|
||||
for (const symbol of symbols) {
|
||||
const microstructure: MarketMicrostructure = {
|
||||
symbol,
|
||||
avgSpreadBps: 2 + Math.random() * 3, // 2-5 bps
|
||||
dailyVolume: 10_000_000 * (1 + Math.random() * 9), // 10-100M shares
|
||||
avgTradeSize: 100 + Math.random() * 400, // 100-500 shares
|
||||
volatility: 0.15 + Math.random() * 0.25, // 15-40% annual vol
|
||||
tickSize: 0.01,
|
||||
lotSize: 1,
|
||||
intradayVolumeProfile: this.generateIntradayProfile()
|
||||
};
|
||||
|
||||
this.microstructures.set(symbol, microstructure);
|
||||
this.marketSimulator.setMicrostructure(symbol, microstructure);
|
||||
}
|
||||
}
|
||||
|
||||
private generateIntradayProfile(): number[] {
|
||||
// U-shaped intraday volume pattern
|
||||
const profile = new Array(24).fill(0);
|
||||
const tradingHours = [9, 10, 11, 12, 13, 14, 15, 16]; // 9:30 AM to 4:00 PM
|
||||
|
||||
tradingHours.forEach((hour, idx) => {
|
||||
if (idx === 0 || idx === tradingHours.length - 1) {
|
||||
// High volume at open and close
|
||||
profile[hour] = 0.2;
|
||||
} else if (idx === 1 || idx === tradingHours.length - 2) {
|
||||
// Moderate volume
|
||||
profile[hour] = 0.15;
|
||||
} else {
|
||||
// Lower midday volume
|
||||
profile[hour] = 0.1;
|
||||
}
|
||||
});
|
||||
|
||||
// Normalize
|
||||
const sum = profile.reduce((a, b) => a + b, 0);
|
||||
return profile.map(v => v / sum);
|
||||
}
|
||||
|
||||
private getPortfolioValue(): number {
|
||||
const tradingEngine = this.strategyManager.getTradingEngine();
|
||||
if (!tradingEngine) return 100000; // Default initial capital
|
||||
|
||||
const [realized, unrealized] = tradingEngine.getTotalPnl();
|
||||
return 100000 + realized + unrealized;
|
||||
}
|
||||
|
||||
async stopBacktest(): Promise<void> {
|
||||
this.isRunning = false;
|
||||
logger.info('Backtest stop requested');
|
||||
}
|
||||
|
||||
async exportResults(format: 'json' | 'csv' | 'html' = 'json'): Promise<string> {
|
||||
const result = {
|
||||
summary: this.calculatePerformance(),
|
||||
trades: this.trades,
|
||||
equityCurve: this.equityCurve,
|
||||
drawdowns: this.calculateDrawdown(),
|
||||
dataQuality: this.dataManager.getDataQualityReport(),
|
||||
performanceReport: this.performanceAnalyzer.exportReport()
|
||||
};
|
||||
|
||||
switch (format) {
|
||||
case 'json':
|
||||
return JSON.stringify(result, null, 2);
|
||||
case 'csv':
|
||||
// Convert to CSV format
|
||||
return this.convertToCSV(result);
|
||||
case 'html':
|
||||
// Generate HTML report
|
||||
return this.generateHTMLReport(result);
|
||||
default:
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
}
|
||||
|
||||
private convertToCSV(result: any): string {
|
||||
// Simple CSV conversion for trades
|
||||
const headers = ['Date', 'Symbol', 'Side', 'Entry', 'Exit', 'Quantity', 'PnL', 'Return%'];
|
||||
const rows = result.trades.map(t => [
|
||||
new Date(t.entryTime).toISOString(),
|
||||
t.symbol,
|
||||
t.side,
|
||||
t.entryPrice,
|
||||
t.exitPrice,
|
||||
t.quantity,
|
||||
t.pnl,
|
||||
t.returnPct
|
||||
]);
|
||||
|
||||
return [headers, ...rows].map(row => row.join(',')).join('\n');
|
||||
}
|
||||
|
||||
private generateHTMLReport(result: any): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Backtest Report</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
.metric { margin: 10px 0; }
|
||||
.positive { color: green; }
|
||||
.negative { color: red; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Backtest Performance Report</h1>
|
||||
|
||||
<h2>Summary Statistics</h2>
|
||||
<div class="metric">Total Return: <span class="${result.summary.totalReturn >= 0 ? 'positive' : 'negative'}">${result.summary.totalReturn.toFixed(2)}%</span></div>
|
||||
<div class="metric">Sharpe Ratio: ${result.summary.sharpeRatio.toFixed(2)}</div>
|
||||
<div class="metric">Max Drawdown: <span class="negative">${result.summary.maxDrawdown.toFixed(2)}%</span></div>
|
||||
<div class="metric">Win Rate: ${result.summary.winRate.toFixed(1)}%</div>
|
||||
<div class="metric">Total Trades: ${result.summary.totalTrades}</div>
|
||||
|
||||
<h2>Detailed Performance Metrics</h2>
|
||||
<pre>${result.performanceReport}</pre>
|
||||
|
||||
<h2>Trade History</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Symbol</th>
|
||||
<th>Side</th>
|
||||
<th>Entry Price</th>
|
||||
<th>Exit Price</th>
|
||||
<th>Quantity</th>
|
||||
<th>P&L</th>
|
||||
<th>Return %</th>
|
||||
</tr>
|
||||
${result.trades.map(t => `
|
||||
<tr>
|
||||
<td>${new Date(t.entryTime).toLocaleDateString()}</td>
|
||||
<td>${t.symbol}</td>
|
||||
<td>${t.side}</td>
|
||||
<td>$${t.entryPrice.toFixed(2)}</td>
|
||||
<td>$${t.exitPrice.toFixed(2)}</td>
|
||||
<td>${t.quantity}</td>
|
||||
<td class="${t.pnl >= 0 ? 'positive' : 'negative'}">$${t.pnl.toFixed(2)}</td>
|
||||
<td class="${t.returnPct >= 0 ? 'positive' : 'negative'}">${t.returnPct.toFixed(2)}%</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
385
apps/stock/orchestrator/src/backtest/MarketSimulator.ts
Normal file
385
apps/stock/orchestrator/src/backtest/MarketSimulator.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import { logger } from '@stock-bot/logger';
|
||||
import { MarketData, Quote, Trade, Bar, OrderBookSnapshot, PriceLevel } from '../types';
|
||||
import { MarketMicrostructure } from '../types/MarketMicrostructure';
|
||||
|
||||
export interface SimulationConfig {
|
||||
useHistoricalSpreads: boolean;
|
||||
modelHiddenLiquidity: boolean;
|
||||
includeDarkPools: boolean;
|
||||
latencyMs: number;
|
||||
rebateRate: number;
|
||||
takeFeeRate: number;
|
||||
}
|
||||
|
||||
export interface LiquidityProfile {
|
||||
visibleLiquidity: number;
|
||||
hiddenLiquidity: number;
|
||||
darkPoolLiquidity: number;
|
||||
totalLiquidity: number;
|
||||
}
|
||||
|
||||
export class MarketSimulator {
|
||||
private orderBooks: Map<string, OrderBookSnapshot> = new Map();
|
||||
private microstructures: Map<string, MarketMicrostructure> = new Map();
|
||||
private liquidityProfiles: Map<string, LiquidityProfile> = new Map();
|
||||
private lastTrades: Map<string, Trade> = new Map();
|
||||
private config: SimulationConfig;
|
||||
|
||||
constructor(config: Partial<SimulationConfig> = {}) {
|
||||
this.config = {
|
||||
useHistoricalSpreads: true,
|
||||
modelHiddenLiquidity: true,
|
||||
includeDarkPools: true,
|
||||
latencyMs: 1,
|
||||
rebateRate: -0.0002, // 2 bps rebate for providing liquidity
|
||||
takeFeeRate: 0.0003, // 3 bps fee for taking liquidity
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
setMicrostructure(symbol: string, microstructure: MarketMicrostructure): void {
|
||||
this.microstructures.set(symbol, microstructure);
|
||||
this.updateLiquidityProfile(symbol);
|
||||
}
|
||||
|
||||
processMarketData(data: MarketData): OrderBookSnapshot | null {
|
||||
const { symbol } = this.getSymbolFromData(data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'quote':
|
||||
return this.updateFromQuote(symbol, data.data);
|
||||
case 'trade':
|
||||
return this.updateFromTrade(symbol, data.data);
|
||||
case 'bar':
|
||||
return this.reconstructFromBar(symbol, data.data);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private updateFromQuote(symbol: string, quote: Quote): OrderBookSnapshot {
|
||||
let orderbook = this.orderBooks.get(symbol);
|
||||
const microstructure = this.microstructures.get(symbol);
|
||||
|
||||
if (!orderbook || !microstructure) {
|
||||
// Create new orderbook
|
||||
orderbook = this.createOrderBook(symbol, quote, microstructure);
|
||||
} else {
|
||||
// Update existing orderbook
|
||||
orderbook = this.updateOrderBook(orderbook, quote, microstructure);
|
||||
}
|
||||
|
||||
this.orderBooks.set(symbol, orderbook);
|
||||
return orderbook;
|
||||
}
|
||||
|
||||
private updateFromTrade(symbol: string, trade: Trade): OrderBookSnapshot | null {
|
||||
const orderbook = this.orderBooks.get(symbol);
|
||||
if (!orderbook) return null;
|
||||
|
||||
// Update last trade
|
||||
this.lastTrades.set(symbol, trade);
|
||||
|
||||
// Adjust orderbook based on trade
|
||||
// Large trades likely consumed liquidity
|
||||
const impactFactor = Math.min(trade.size / 1000, 0.1); // Max 10% impact
|
||||
|
||||
if (trade.side === 'buy') {
|
||||
// Buy trade consumed ask liquidity
|
||||
orderbook.asks = orderbook.asks.map((level, i) => ({
|
||||
...level,
|
||||
size: level.size * (1 - impactFactor * Math.exp(-i * 0.5))
|
||||
}));
|
||||
} else {
|
||||
// Sell trade consumed bid liquidity
|
||||
orderbook.bids = orderbook.bids.map((level, i) => ({
|
||||
...level,
|
||||
size: level.size * (1 - impactFactor * Math.exp(-i * 0.5))
|
||||
}));
|
||||
}
|
||||
|
||||
return orderbook;
|
||||
}
|
||||
|
||||
private reconstructFromBar(symbol: string, bar: Bar): OrderBookSnapshot {
|
||||
const microstructure = this.microstructures.get(symbol) || this.createDefaultMicrostructure(symbol);
|
||||
|
||||
// Estimate spread from high-low range
|
||||
const hlSpread = (bar.high - bar.low) / bar.close;
|
||||
const estimatedSpreadBps = Math.max(
|
||||
microstructure.avgSpreadBps,
|
||||
hlSpread * 10000 * 0.1 // 10% of HL range as spread estimate
|
||||
);
|
||||
|
||||
// Create synthetic quote
|
||||
const midPrice = bar.vwap || (bar.high + bar.low + bar.close) / 3;
|
||||
const halfSpread = midPrice * estimatedSpreadBps / 20000;
|
||||
|
||||
const quote: Quote = {
|
||||
bid: midPrice - halfSpread,
|
||||
ask: midPrice + halfSpread,
|
||||
bidSize: bar.volume / 100, // Rough estimate
|
||||
askSize: bar.volume / 100
|
||||
};
|
||||
|
||||
return this.createOrderBook(symbol, quote, microstructure);
|
||||
}
|
||||
|
||||
private createOrderBook(
|
||||
symbol: string,
|
||||
topQuote: Quote,
|
||||
microstructure?: MarketMicrostructure
|
||||
): OrderBookSnapshot {
|
||||
const micro = microstructure || this.createDefaultMicrostructure(symbol);
|
||||
const levels = 10;
|
||||
|
||||
const bids: PriceLevel[] = [];
|
||||
const asks: PriceLevel[] = [];
|
||||
|
||||
// Model order book depth
|
||||
for (let i = 0; i < levels; i++) {
|
||||
const depthFactor = Math.exp(-i * 0.3); // Exponential decay
|
||||
const spreadMultiplier = 1 + i * 0.1; // Wider spread at deeper levels
|
||||
|
||||
// Hidden liquidity modeling
|
||||
const hiddenRatio = this.config.modelHiddenLiquidity ?
|
||||
1 + Math.random() * 2 : // 1-3x visible size hidden
|
||||
1;
|
||||
|
||||
// Bid levels
|
||||
const bidPrice = topQuote.bid - (i * micro.tickSize);
|
||||
const bidSize = topQuote.bidSize * depthFactor * (0.8 + Math.random() * 0.4);
|
||||
bids.push({
|
||||
price: bidPrice,
|
||||
size: Math.round(bidSize / micro.lotSize) * micro.lotSize,
|
||||
orderCount: Math.max(1, Math.floor(bidSize / 100)),
|
||||
hiddenSize: this.config.modelHiddenLiquidity ? bidSize * (hiddenRatio - 1) : undefined
|
||||
});
|
||||
|
||||
// Ask levels
|
||||
const askPrice = topQuote.ask + (i * micro.tickSize);
|
||||
const askSize = topQuote.askSize * depthFactor * (0.8 + Math.random() * 0.4);
|
||||
asks.push({
|
||||
price: askPrice,
|
||||
size: Math.round(askSize / micro.lotSize) * micro.lotSize,
|
||||
orderCount: Math.max(1, Math.floor(askSize / 100)),
|
||||
hiddenSize: this.config.modelHiddenLiquidity ? askSize * (hiddenRatio - 1) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
symbol,
|
||||
timestamp: new Date(),
|
||||
bids,
|
||||
asks,
|
||||
lastTrade: this.lastTrades.get(symbol)
|
||||
};
|
||||
}
|
||||
|
||||
private updateOrderBook(
|
||||
current: OrderBookSnapshot,
|
||||
quote: Quote,
|
||||
microstructure?: MarketMicrostructure
|
||||
): OrderBookSnapshot {
|
||||
const micro = microstructure || this.createDefaultMicrostructure(current.symbol);
|
||||
|
||||
// Update top of book
|
||||
if (current.bids.length > 0) {
|
||||
current.bids[0].price = quote.bid;
|
||||
current.bids[0].size = quote.bidSize;
|
||||
}
|
||||
|
||||
if (current.asks.length > 0) {
|
||||
current.asks[0].price = quote.ask;
|
||||
current.asks[0].size = quote.askSize;
|
||||
}
|
||||
|
||||
// Adjust deeper levels based on spread changes
|
||||
const oldSpread = current.asks[0].price - current.bids[0].price;
|
||||
const newSpread = quote.ask - quote.bid;
|
||||
const spreadRatio = newSpread / oldSpread;
|
||||
|
||||
// Update deeper levels
|
||||
for (let i = 1; i < current.bids.length; i++) {
|
||||
// Adjust sizes based on top of book changes
|
||||
const sizeRatio = quote.bidSize / (current.bids[0].size || quote.bidSize);
|
||||
current.bids[i].size *= sizeRatio * (0.9 + Math.random() * 0.2);
|
||||
|
||||
// Adjust prices to maintain relative spacing
|
||||
const spacing = (current.bids[i-1].price - current.bids[i].price) * spreadRatio;
|
||||
current.bids[i].price = current.bids[i-1].price - spacing;
|
||||
}
|
||||
|
||||
for (let i = 1; i < current.asks.length; i++) {
|
||||
const sizeRatio = quote.askSize / (current.asks[0].size || quote.askSize);
|
||||
current.asks[i].size *= sizeRatio * (0.9 + Math.random() * 0.2);
|
||||
|
||||
const spacing = (current.asks[i].price - current.asks[i-1].price) * spreadRatio;
|
||||
current.asks[i].price = current.asks[i-1].price + spacing;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
simulateMarketImpact(
|
||||
symbol: string,
|
||||
side: 'buy' | 'sell',
|
||||
orderSize: number,
|
||||
orderType: 'market' | 'limit',
|
||||
limitPrice?: number
|
||||
): {
|
||||
fills: Array<{ price: number; size: number; venue: string }>;
|
||||
totalCost: number;
|
||||
avgPrice: number;
|
||||
marketImpact: number;
|
||||
fees: number;
|
||||
} {
|
||||
const orderbook = this.orderBooks.get(symbol);
|
||||
const microstructure = this.microstructures.get(symbol);
|
||||
const liquidityProfile = this.liquidityProfiles.get(symbol);
|
||||
|
||||
if (!orderbook) {
|
||||
throw new Error(`No orderbook available for ${symbol}`);
|
||||
}
|
||||
|
||||
const fills: Array<{ price: number; size: number; venue: string }> = [];
|
||||
let remainingSize = orderSize;
|
||||
let totalCost = 0;
|
||||
let fees = 0;
|
||||
|
||||
// Get relevant price levels
|
||||
const levels = side === 'buy' ? orderbook.asks : orderbook.bids;
|
||||
const multiplier = side === 'buy' ? 1 : -1;
|
||||
|
||||
// Simulate walking the book
|
||||
for (const level of levels) {
|
||||
if (remainingSize <= 0) break;
|
||||
|
||||
// Check limit price constraint
|
||||
if (limitPrice !== undefined) {
|
||||
if (side === 'buy' && level.price > limitPrice) break;
|
||||
if (side === 'sell' && level.price < limitPrice) break;
|
||||
}
|
||||
|
||||
// Calculate available liquidity including hidden
|
||||
let availableSize = level.size;
|
||||
if (level.hiddenSize && this.config.modelHiddenLiquidity) {
|
||||
availableSize += level.hiddenSize * Math.random(); // Hidden liquidity probabilistic
|
||||
}
|
||||
|
||||
const fillSize = Math.min(remainingSize, availableSize);
|
||||
|
||||
// Simulate latency - price might move
|
||||
if (this.config.latencyMs > 0 && orderType === 'market') {
|
||||
const priceMovement = microstructure ?
|
||||
(Math.random() - 0.5) * microstructure.avgSpreadBps / 10000 * level.price :
|
||||
0;
|
||||
level.price += priceMovement * multiplier;
|
||||
}
|
||||
|
||||
fills.push({
|
||||
price: level.price,
|
||||
size: fillSize,
|
||||
venue: 'primary'
|
||||
});
|
||||
|
||||
totalCost += fillSize * level.price;
|
||||
remainingSize -= fillSize;
|
||||
|
||||
// Calculate fees
|
||||
if (orderType === 'market') {
|
||||
fees += fillSize * level.price * this.config.takeFeeRate;
|
||||
} else {
|
||||
// Limit orders that provide liquidity get rebate
|
||||
fees += fillSize * level.price * this.config.rebateRate;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark pool execution for remaining size
|
||||
if (remainingSize > 0 && this.config.includeDarkPools && liquidityProfile) {
|
||||
const darkPoolPct = liquidityProfile.darkPoolLiquidity / liquidityProfile.totalLiquidity;
|
||||
const darkPoolSize = remainingSize * darkPoolPct * Math.random();
|
||||
|
||||
if (darkPoolSize > 0) {
|
||||
const midPrice = (orderbook.bids[0].price + orderbook.asks[0].price) / 2;
|
||||
fills.push({
|
||||
price: midPrice,
|
||||
size: darkPoolSize,
|
||||
venue: 'dark'
|
||||
});
|
||||
|
||||
totalCost += darkPoolSize * midPrice;
|
||||
remainingSize -= darkPoolSize;
|
||||
// Dark pools typically have lower fees
|
||||
fees += darkPoolSize * midPrice * 0.0001;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate results
|
||||
const filledSize = orderSize - remainingSize;
|
||||
const avgPrice = filledSize > 0 ? totalCost / filledSize : 0;
|
||||
|
||||
// Calculate market impact
|
||||
const initialMid = (orderbook.bids[0].price + orderbook.asks[0].price) / 2;
|
||||
const marketImpact = filledSize > 0 ?
|
||||
Math.abs(avgPrice - initialMid) / initialMid * 10000 : // in bps
|
||||
0;
|
||||
|
||||
return {
|
||||
fills,
|
||||
totalCost,
|
||||
avgPrice,
|
||||
marketImpact,
|
||||
fees
|
||||
};
|
||||
}
|
||||
|
||||
private updateLiquidityProfile(symbol: string): void {
|
||||
const microstructure = this.microstructures.get(symbol);
|
||||
if (!microstructure) return;
|
||||
|
||||
// Estimate liquidity distribution
|
||||
const visiblePct = 0.3; // 30% visible
|
||||
const hiddenPct = 0.5; // 50% hidden
|
||||
const darkPct = 0.2; // 20% dark
|
||||
|
||||
const totalDailyLiquidity = microstructure.dailyVolume;
|
||||
|
||||
this.liquidityProfiles.set(symbol, {
|
||||
visibleLiquidity: totalDailyLiquidity * visiblePct,
|
||||
hiddenLiquidity: totalDailyLiquidity * hiddenPct,
|
||||
darkPoolLiquidity: totalDailyLiquidity * darkPct,
|
||||
totalLiquidity: totalDailyLiquidity
|
||||
});
|
||||
}
|
||||
|
||||
private createDefaultMicrostructure(symbol: string): MarketMicrostructure {
|
||||
return {
|
||||
symbol,
|
||||
avgSpreadBps: 5,
|
||||
dailyVolume: 1000000,
|
||||
avgTradeSize: 100,
|
||||
volatility: 0.02,
|
||||
tickSize: 0.01,
|
||||
lotSize: 1,
|
||||
intradayVolumeProfile: new Array(24).fill(1/24)
|
||||
};
|
||||
}
|
||||
|
||||
private getSymbolFromData(data: MarketData): { symbol: string } {
|
||||
return { symbol: data.data.symbol };
|
||||
}
|
||||
|
||||
getOrderBook(symbol: string): OrderBookSnapshot | undefined {
|
||||
return this.orderBooks.get(symbol);
|
||||
}
|
||||
|
||||
getLiquidityProfile(symbol: string): LiquidityProfile | undefined {
|
||||
return this.liquidityProfiles.get(symbol);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.orderBooks.clear();
|
||||
this.lastTrades.clear();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue