backtest work
This commit is contained in:
parent
143e2e1678
commit
55b4ca78c9
6 changed files with 427 additions and 129 deletions
|
|
@ -232,7 +232,9 @@ export class BacktestEngine extends EventEmitter {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Chart data (frontend-ready format)
|
// Chart data (frontend-ready format)
|
||||||
equity: this.equityCurve.map(point => ({
|
equity: this.equityCurve
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
.map(point => ({
|
||||||
date: new Date(point.timestamp).toISOString(),
|
date: new Date(point.timestamp).toISOString(),
|
||||||
value: point.value
|
value: point.value
|
||||||
})),
|
})),
|
||||||
|
|
@ -375,19 +377,34 @@ export class BacktestEngine extends EventEmitter {
|
||||||
|
|
||||||
let price = 100; // Base price
|
let price = 100; // Base price
|
||||||
let currentTime = startTime;
|
let currentTime = startTime;
|
||||||
|
let trend = 0; // Current trend direction
|
||||||
|
let trendStrength = 0;
|
||||||
|
let trendDuration = 0;
|
||||||
|
|
||||||
while (currentTime <= endTime) {
|
while (currentTime <= endTime) {
|
||||||
// Generate random price movement
|
// Every 20-50 days, change trend
|
||||||
const changePercent = (Math.random() - 0.5) * 0.04; // +/- 2% daily
|
if (trendDuration <= 0) {
|
||||||
|
trend = Math.random() > 0.5 ? 1 : -1;
|
||||||
|
trendStrength = 0.002 + Math.random() * 0.003; // 0.2% to 0.5% daily trend
|
||||||
|
trendDuration = Math.floor(20 + Math.random() * 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate price movement with trend and noise
|
||||||
|
const trendComponent = trend * trendStrength;
|
||||||
|
const noiseComponent = (Math.random() - 0.5) * 0.03; // +/- 1.5% noise
|
||||||
|
const changePercent = trendComponent + noiseComponent;
|
||||||
price = price * (1 + changePercent);
|
price = price * (1 + changePercent);
|
||||||
|
|
||||||
// Generate OHLC
|
// Generate OHLC with realistic intraday movement
|
||||||
const open = price;
|
const open = price * (1 + (Math.random() - 0.5) * 0.005);
|
||||||
const high = price * (1 + Math.random() * 0.02);
|
const dayRange = 0.01 + Math.random() * 0.02; // 1-3% daily range
|
||||||
const low = price * (1 - Math.random() * 0.02);
|
const high = Math.max(open, price) * (1 + Math.random() * dayRange);
|
||||||
const close = price * (1 + (Math.random() - 0.5) * 0.01);
|
const low = Math.min(open, price) * (1 - Math.random() * dayRange);
|
||||||
|
const close = low + Math.random() * (high - low);
|
||||||
const volume = Math.random() * 1000000 + 500000;
|
const volume = Math.random() * 1000000 + 500000;
|
||||||
|
|
||||||
|
trendDuration--;
|
||||||
|
|
||||||
data.push({
|
data.push({
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -572,7 +589,9 @@ export class BacktestEngine extends EventEmitter {
|
||||||
this.container.logger.debug('Fill processed:', fillResult);
|
this.container.logger.debug('Fill processed:', fillResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record trade
|
// Handle trade recording based on side
|
||||||
|
if (fill.side === 'buy') {
|
||||||
|
// Create new trade entry for buy orders
|
||||||
const trade = {
|
const trade = {
|
||||||
symbol: fill.symbol,
|
symbol: fill.symbol,
|
||||||
side: fill.side,
|
side: fill.side,
|
||||||
|
|
@ -590,10 +609,9 @@ export class BacktestEngine extends EventEmitter {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.trades.push(trade);
|
this.trades.push(trade);
|
||||||
this.container.logger.info(`💵 Trade recorded: ${fill.side} ${fill.quantity} ${fill.symbol} @ ${fill.price}`);
|
this.container.logger.info(`💵 Buy trade opened: ${fill.quantity} ${fill.symbol} @ ${fill.price}`);
|
||||||
|
} else if (fill.side === 'sell') {
|
||||||
// Update existing trades if this is a closing trade
|
// Update existing trades for sell orders
|
||||||
if (fill.side === 'sell') {
|
|
||||||
this.updateClosedTrades(fill);
|
this.updateClosedTrades(fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -994,7 +1012,8 @@ export class BacktestEngine extends EventEmitter {
|
||||||
low: d.data.low,
|
low: d.data.low,
|
||||||
close: d.data.close,
|
close: d.data.close,
|
||||||
volume: d.data.volume
|
volume: d.data.volume
|
||||||
}));
|
}))
|
||||||
|
.sort((a, b) => a.time - b.time); // Ensure data is sorted by time
|
||||||
|
|
||||||
ohlcData[symbol] = symbolData;
|
ohlcData[symbol] = symbolData;
|
||||||
});
|
});
|
||||||
|
|
@ -1066,11 +1085,34 @@ export class BacktestEngine extends EventEmitter {
|
||||||
const tradeToClose = openTrades[0];
|
const tradeToClose = openTrades[0];
|
||||||
tradeToClose.exitPrice = fill.price;
|
tradeToClose.exitPrice = fill.price;
|
||||||
tradeToClose.exitTime = fill.timestamp || this.currentTime;
|
tradeToClose.exitTime = fill.timestamp || this.currentTime;
|
||||||
tradeToClose.pnl = (tradeToClose.exitPrice - tradeToClose.entryPrice) * tradeToClose.quantity - tradeToClose.commission - fill.commission;
|
tradeToClose.pnl = (tradeToClose.exitPrice - tradeToClose.entryPrice) * tradeToClose.quantity - tradeToClose.commission - (fill.commission || 0);
|
||||||
tradeToClose.returnPct = ((tradeToClose.exitPrice - tradeToClose.entryPrice) / tradeToClose.entryPrice) * 100;
|
tradeToClose.returnPct = ((tradeToClose.exitPrice - tradeToClose.entryPrice) / tradeToClose.entryPrice) * 100;
|
||||||
tradeToClose.holdingPeriod = tradeToClose.exitTime - tradeToClose.entryTime;
|
tradeToClose.holdingPeriod = tradeToClose.exitTime - tradeToClose.entryTime;
|
||||||
|
|
||||||
this.container.logger.info(`💰 Trade closed: P&L ${tradeToClose.pnl.toFixed(2)}, Return ${tradeToClose.returnPct.toFixed(2)}%`);
|
this.container.logger.info(`💰 Trade closed: P&L ${tradeToClose.pnl.toFixed(2)}, Return ${tradeToClose.returnPct.toFixed(2)}%`);
|
||||||
|
} else {
|
||||||
|
// Log if we're trying to sell without an open position
|
||||||
|
this.container.logger.warn(`⚠️ Sell order for ${fill.symbol} but no open trades found`);
|
||||||
|
|
||||||
|
// Still record it as a trade for tracking purposes (short position)
|
||||||
|
const trade = {
|
||||||
|
symbol: fill.symbol,
|
||||||
|
side: 'sell',
|
||||||
|
quantity: fill.quantity,
|
||||||
|
entryPrice: fill.price,
|
||||||
|
entryTime: fill.timestamp || this.currentTime,
|
||||||
|
exitPrice: null,
|
||||||
|
exitTime: null,
|
||||||
|
pnl: 0,
|
||||||
|
returnPct: 0,
|
||||||
|
commission: fill.commission || 0,
|
||||||
|
currentPrice: fill.price,
|
||||||
|
holdingPeriod: 0,
|
||||||
|
backtestTime: this.currentTime
|
||||||
|
};
|
||||||
|
|
||||||
|
this.trades.push(trade);
|
||||||
|
this.container.logger.info(`💵 Short trade opened: ${fill.quantity} ${fill.symbol} @ ${fill.price}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +121,8 @@ export abstract class BaseStrategy extends EventEmitter {
|
||||||
? currentPos + fill.quantity
|
? currentPos + fill.quantity
|
||||||
: currentPos - fill.quantity;
|
: currentPos - fill.quantity;
|
||||||
|
|
||||||
|
logger.info(`[BaseStrategy] Position update for ${update.symbol}: ${currentPos} -> ${newPos} (${update.side} ${fill.quantity})`);
|
||||||
|
|
||||||
if (Math.abs(newPos) < 0.0001) {
|
if (Math.abs(newPos) < 0.0001) {
|
||||||
this.positions.delete(update.symbol);
|
this.positions.delete(update.symbol);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,15 @@ const logger = getLogger('SimpleMovingAverageCrossover');
|
||||||
|
|
||||||
export class SimpleMovingAverageCrossover extends BaseStrategy {
|
export class SimpleMovingAverageCrossover extends BaseStrategy {
|
||||||
private priceHistory = new Map<string, number[]>();
|
private priceHistory = new Map<string, number[]>();
|
||||||
private positions = new Map<string, number>();
|
private lastTradeTime = new Map<string, number>();
|
||||||
private lastSignalTime = new Map<string, number>();
|
|
||||||
private totalSignals = 0;
|
private totalSignals = 0;
|
||||||
|
|
||||||
// Strategy parameters
|
// Strategy parameters
|
||||||
private readonly FAST_PERIOD = 10;
|
private readonly FAST_PERIOD = 10;
|
||||||
private readonly SLOW_PERIOD = 20;
|
private readonly SLOW_PERIOD = 20;
|
||||||
private readonly POSITION_SIZE = 0.1; // 10% of capital per position
|
private readonly POSITION_SIZE = 0.1; // 10% of capital per position
|
||||||
private readonly MIN_SIGNAL_INTERVAL = 24 * 60 * 60 * 1000; // 1 day minimum between signals
|
private readonly MIN_HOLDING_BARS = 1; // Minimum bars to hold position
|
||||||
|
private readonly DEBUG_INTERVAL = 20; // Log every N bars for debugging
|
||||||
|
|
||||||
constructor(config: any, modeManager?: any, executionService?: any) {
|
constructor(config: any, modeManager?: any, executionService?: any) {
|
||||||
super(config, modeManager, executionService);
|
super(config, modeManager, executionService);
|
||||||
|
|
@ -69,30 +69,80 @@ export class SimpleMovingAverageCrossover extends BaseStrategy {
|
||||||
const prevFastMA = this.calculateSMA(prevHistory, this.FAST_PERIOD);
|
const prevFastMA = this.calculateSMA(prevHistory, this.FAST_PERIOD);
|
||||||
const prevSlowMA = this.calculateSMA(prevHistory, this.SLOW_PERIOD);
|
const prevSlowMA = this.calculateSMA(prevHistory, this.SLOW_PERIOD);
|
||||||
|
|
||||||
const currentPosition = this.positions.get(symbol) || 0;
|
const currentPosition = this.getPosition(symbol);
|
||||||
const currentPrice = data.data.close;
|
const currentPrice = data.data.close;
|
||||||
const timestamp = new Date(data.data.timestamp).toISOString().split('T')[0];
|
const timestamp = new Date(data.data.timestamp).toISOString().split('T')[0];
|
||||||
|
|
||||||
// Log every 50 bars to track MA values and crossover conditions
|
// Check minimum holding period
|
||||||
if (history.length % 50 === 0) {
|
const lastTradeBar = this.lastTradeTime.get(symbol) || 0;
|
||||||
logger.info(`${symbol} @ ${timestamp} - Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)}, Slow MA: ${slowMA.toFixed(2)}, Position: ${currentPosition}`);
|
const barsSinceLastTrade = lastTradeBar > 0 ? history.length - lastTradeBar : Number.MAX_SAFE_INTEGER;
|
||||||
logger.debug(`${symbol} - Prev Fast MA: ${prevFastMA.toFixed(2)}, Prev Slow MA: ${prevSlowMA.toFixed(2)}`);
|
|
||||||
logger.debug(`${symbol} - Fast > Slow: ${fastMA > slowMA}, Prev Fast <= Prev Slow: ${prevFastMA <= prevSlowMA}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect crossovers with detailed logging
|
// Detect crossovers FIRST
|
||||||
const goldenCross = prevFastMA <= prevSlowMA && fastMA > slowMA;
|
const goldenCross = prevFastMA <= prevSlowMA && fastMA > slowMA;
|
||||||
const deathCross = prevFastMA >= prevSlowMA && fastMA < slowMA;
|
const deathCross = prevFastMA >= prevSlowMA && fastMA < slowMA;
|
||||||
|
|
||||||
if (goldenCross && currentPosition === 0) {
|
// Enhanced debugging - log more frequently and when MAs are close
|
||||||
// Golden cross - buy signal
|
const maDiff = fastMA - slowMA;
|
||||||
|
const maDiffPct = (maDiff / slowMA) * 100;
|
||||||
|
const masAreClose = Math.abs(maDiffPct) < 1.0; // Within 1%
|
||||||
|
|
||||||
|
if (history.length % this.DEBUG_INTERVAL === 0 || masAreClose || goldenCross || deathCross) {
|
||||||
|
logger.info(`${symbol} @ ${timestamp} [Bar ${history.length}]:`);
|
||||||
|
logger.info(` Price: $${currentPrice.toFixed(2)}`);
|
||||||
|
logger.info(` Fast MA (${this.FAST_PERIOD}): $${fastMA.toFixed(2)}`);
|
||||||
|
logger.info(` Slow MA (${this.SLOW_PERIOD}): $${slowMA.toFixed(2)}`);
|
||||||
|
logger.info(` MA Diff: ${maDiff.toFixed(2)} (${maDiffPct.toFixed(2)}%)`);
|
||||||
|
logger.info(` Position: ${currentPosition} shares`);
|
||||||
|
logger.info(` Bars since last trade: ${barsSinceLastTrade}`);
|
||||||
|
|
||||||
|
if (goldenCross) {
|
||||||
|
logger.info(` 🟢 GOLDEN CROSS DETECTED!`);
|
||||||
|
}
|
||||||
|
if (deathCross) {
|
||||||
|
logger.info(` 🔴 DEATH CROSS DETECTED!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (barsSinceLastTrade < this.MIN_HOLDING_BARS && lastTradeBar > 0) {
|
||||||
|
return null; // Too soon to trade again
|
||||||
|
}
|
||||||
|
|
||||||
|
if (goldenCross) {
|
||||||
logger.info(`🟢 Golden cross detected for ${symbol} @ ${timestamp}`);
|
logger.info(`🟢 Golden cross detected for ${symbol} @ ${timestamp}`);
|
||||||
|
logger.info(` Current position: ${currentPosition} shares`);
|
||||||
|
|
||||||
|
// For golden cross, we want to be long
|
||||||
|
// If we're short, we need to close the short first
|
||||||
|
if (currentPosition < 0) {
|
||||||
|
logger.info(` Closing short position of ${Math.abs(currentPosition)} shares`);
|
||||||
|
const signal: Signal = {
|
||||||
|
type: 'buy',
|
||||||
|
symbol,
|
||||||
|
strength: 0.8,
|
||||||
|
reason: 'Golden cross - Closing short position',
|
||||||
|
metadata: {
|
||||||
|
fastMA,
|
||||||
|
slowMA,
|
||||||
|
prevFastMA,
|
||||||
|
prevSlowMA,
|
||||||
|
crossoverType: 'golden',
|
||||||
|
price: currentPrice,
|
||||||
|
quantity: Math.abs(currentPosition) // Buy to close short
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastTradeTime.set(symbol, history.length);
|
||||||
|
this.totalSignals++;
|
||||||
|
logger.info(`👉 Total signals generated: ${this.totalSignals}`);
|
||||||
|
return signal;
|
||||||
|
} else if (currentPosition === 0) {
|
||||||
|
// No position, open long
|
||||||
logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} > Slow MA: ${slowMA.toFixed(2)}`);
|
logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} > Slow MA: ${slowMA.toFixed(2)}`);
|
||||||
logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} <= Prev Slow MA: ${prevSlowMA.toFixed(2)}`);
|
logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} <= Prev Slow MA: ${prevSlowMA.toFixed(2)}`);
|
||||||
|
|
||||||
// Calculate position size
|
// Calculate position size
|
||||||
const positionSize = this.calculatePositionSize(currentPrice);
|
const positionSize = this.calculatePositionSize(currentPrice);
|
||||||
logger.info(` Position size: ${positionSize} shares`);
|
logger.info(` Opening long position: ${positionSize} shares`);
|
||||||
|
|
||||||
const signal: Signal = {
|
const signal: Signal = {
|
||||||
type: 'buy',
|
type: 'buy',
|
||||||
|
|
@ -110,18 +160,23 @@ export class SimpleMovingAverageCrossover extends BaseStrategy {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track signal time
|
this.lastTradeTime.set(symbol, history.length);
|
||||||
this.lastSignalTime.set(symbol, Date.now());
|
|
||||||
this.totalSignals++;
|
this.totalSignals++;
|
||||||
logger.info(`👉 Total signals generated: ${this.totalSignals}`);
|
logger.info(`👉 Total signals generated: ${this.totalSignals}`);
|
||||||
|
|
||||||
return signal;
|
return signal;
|
||||||
} else if (deathCross && currentPosition > 0) {
|
} else {
|
||||||
// Death cross - sell signal
|
logger.info(` ⚠️ Already long, skipping buy signal`);
|
||||||
|
}
|
||||||
|
} else if (deathCross) {
|
||||||
logger.info(`🔴 Death cross detected for ${symbol} @ ${timestamp}`);
|
logger.info(`🔴 Death cross detected for ${symbol} @ ${timestamp}`);
|
||||||
|
logger.info(` Current position: ${currentPosition} shares`);
|
||||||
|
|
||||||
|
// For death cross, we want to be flat or short
|
||||||
|
if (currentPosition > 0) {
|
||||||
|
// Close long position
|
||||||
|
logger.info(` Closing long position of ${currentPosition} shares`);
|
||||||
logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} < Slow MA: ${slowMA.toFixed(2)}`);
|
logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} < Slow MA: ${slowMA.toFixed(2)}`);
|
||||||
logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} >= Prev Slow MA: ${prevSlowMA.toFixed(2)}`);
|
logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} >= Prev Slow MA: ${prevSlowMA.toFixed(2)}`);
|
||||||
logger.info(` Current position: ${currentPosition} shares`);
|
|
||||||
|
|
||||||
const signal: Signal = {
|
const signal: Signal = {
|
||||||
type: 'sell',
|
type: 'sell',
|
||||||
|
|
@ -139,12 +194,42 @@ export class SimpleMovingAverageCrossover extends BaseStrategy {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track signal time
|
this.lastTradeTime.set(symbol, history.length);
|
||||||
this.lastSignalTime.set(symbol, Date.now());
|
|
||||||
this.totalSignals++;
|
this.totalSignals++;
|
||||||
logger.info(`👉 Total signals generated: ${this.totalSignals}`);
|
logger.info(`👉 Total signals generated: ${this.totalSignals}`);
|
||||||
|
|
||||||
return signal;
|
return signal;
|
||||||
|
} else if (currentPosition === 0) {
|
||||||
|
// Optional: Open short position (comment out if long-only)
|
||||||
|
logger.info(` No position, staying flat (long-only strategy)`);
|
||||||
|
// Uncomment below for long/short strategy:
|
||||||
|
/*
|
||||||
|
const positionSize = this.calculatePositionSize(currentPrice);
|
||||||
|
logger.info(` Opening short position: ${positionSize} shares`);
|
||||||
|
|
||||||
|
const signal: Signal = {
|
||||||
|
type: 'sell',
|
||||||
|
symbol,
|
||||||
|
strength: 0.8,
|
||||||
|
reason: 'Death cross - Opening short position',
|
||||||
|
metadata: {
|
||||||
|
fastMA,
|
||||||
|
slowMA,
|
||||||
|
prevFastMA,
|
||||||
|
prevSlowMA,
|
||||||
|
crossoverType: 'death',
|
||||||
|
price: currentPrice,
|
||||||
|
quantity: positionSize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastTradeTime.set(symbol, history.length);
|
||||||
|
this.totalSignals++;
|
||||||
|
logger.info(`👉 Total signals generated: ${this.totalSignals}`);
|
||||||
|
return signal;
|
||||||
|
*/
|
||||||
|
} else {
|
||||||
|
logger.info(` ⚠️ Already short, skipping sell signal`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log near-crossover conditions
|
// Log near-crossover conditions
|
||||||
|
|
@ -207,24 +292,21 @@ export class SimpleMovingAverageCrossover extends BaseStrategy {
|
||||||
protected onOrderFilled(fill: any): void {
|
protected onOrderFilled(fill: any): void {
|
||||||
const { symbol, side, quantity, price } = fill;
|
const { symbol, side, quantity, price } = fill;
|
||||||
|
|
||||||
const currentPosition = this.positions.get(symbol) || 0;
|
logger.info(`✅ ${side.toUpperCase()} filled: ${symbol} - ${quantity} shares @ ${price}`);
|
||||||
|
|
||||||
if (side === 'buy') {
|
// Position tracking is handled by BaseStrategy.onOrderUpdate
|
||||||
this.positions.set(symbol, currentPosition + quantity);
|
// Just log the current position
|
||||||
logger.info(`✅ BUY filled: ${symbol} - ${quantity} shares @ ${price}`);
|
const currentPosition = this.getPosition(symbol);
|
||||||
} else {
|
logger.info(`Position for ${symbol}: ${currentPosition} shares`);
|
||||||
this.positions.set(symbol, Math.max(0, currentPosition - quantity));
|
|
||||||
logger.info(`✅ SELL filled: ${symbol} - ${quantity} shares @ ${price}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Position updated for ${symbol}: ${this.positions.get(symbol)} shares`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override to provide custom order generation
|
// Override to provide custom order generation
|
||||||
protected async signalToOrder(signal: Signal): Promise<OrderRequest | null> {
|
protected async signalToOrder(signal: Signal): Promise<OrderRequest | null> {
|
||||||
logger.info(`🔄 Converting signal to order:`, signal);
|
logger.info(`🔄 Converting signal to order:`, signal);
|
||||||
|
|
||||||
// Get position sizing from metadata or calculate
|
const currentPosition = this.getPosition(signal.symbol);
|
||||||
|
|
||||||
|
// Get position sizing from metadata
|
||||||
const quantity = signal.metadata?.quantity || 100;
|
const quantity = signal.metadata?.quantity || 100;
|
||||||
|
|
||||||
if (signal.type === 'buy') {
|
if (signal.type === 'buy') {
|
||||||
|
|
|
||||||
99
apps/stock/orchestrator/test-backtest.ts
Normal file
99
apps/stock/orchestrator/test-backtest.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { createContainer } from './src/simple-container';
|
||||||
|
import { BacktestEngine } from './src/backtest/BacktestEngine';
|
||||||
|
import { StrategyManager } from './src/strategies/StrategyManager';
|
||||||
|
import { SimpleMovingAverageCrossover } from './src/strategies/examples/SimpleMovingAverageCrossover';
|
||||||
|
|
||||||
|
async function runBacktest() {
|
||||||
|
console.log('Starting backtest test...');
|
||||||
|
|
||||||
|
// Create container with minimal config
|
||||||
|
const config = {
|
||||||
|
port: 2004,
|
||||||
|
mode: 'paper',
|
||||||
|
enableWebSocket: false,
|
||||||
|
database: {
|
||||||
|
mongodb: { enabled: false },
|
||||||
|
postgres: { enabled: false },
|
||||||
|
questdb: { enabled: false }
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
url: 'redis://localhost:6379'
|
||||||
|
},
|
||||||
|
backtesting: {
|
||||||
|
maxConcurrent: 1,
|
||||||
|
defaultSpeed: 'max',
|
||||||
|
dataResolutions: ['1d']
|
||||||
|
},
|
||||||
|
strategies: {
|
||||||
|
maxActive: 10,
|
||||||
|
defaultTimeout: 30000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const container = await createContainer(config);
|
||||||
|
|
||||||
|
// Initialize strategy manager
|
||||||
|
const strategyManager = new StrategyManager(container.executionService, container.modeManager);
|
||||||
|
|
||||||
|
// Create and add strategy
|
||||||
|
const strategyConfig = {
|
||||||
|
id: 'sma-test',
|
||||||
|
name: 'SMA Test',
|
||||||
|
type: 'sma-crossover',
|
||||||
|
symbols: ['AA'],
|
||||||
|
active: true,
|
||||||
|
allocation: 1.0,
|
||||||
|
riskLimit: 0.02,
|
||||||
|
maxPositions: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const strategy = new SimpleMovingAverageCrossover(strategyConfig, container.modeManager, container.executionService);
|
||||||
|
await strategyManager.addStrategy(strategy);
|
||||||
|
|
||||||
|
// Create backtest engine
|
||||||
|
const backtestEngine = new BacktestEngine(container, strategyManager);
|
||||||
|
|
||||||
|
// Run backtest
|
||||||
|
const backtestConfig = {
|
||||||
|
symbols: ['AA'],
|
||||||
|
strategy: 'sma-crossover',
|
||||||
|
startDate: '2024-01-01',
|
||||||
|
endDate: '2024-12-31',
|
||||||
|
initialCapital: 100000,
|
||||||
|
commission: 0.001,
|
||||||
|
slippage: 0.0005,
|
||||||
|
dataFrequency: '1d'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Running backtest...');
|
||||||
|
const result = await backtestEngine.runBacktest(backtestConfig);
|
||||||
|
|
||||||
|
console.log('\n=== Backtest Results ===');
|
||||||
|
console.log(`Total trades: ${result.metrics.totalTrades}`);
|
||||||
|
console.log(`Win rate: ${result.metrics.winRate.toFixed(2)}%`);
|
||||||
|
console.log(`Total return: ${result.metrics.totalReturn.toFixed(2)}%`);
|
||||||
|
console.log(`Sharpe ratio: ${result.metrics.sharpeRatio.toFixed(2)}`);
|
||||||
|
console.log(`Max drawdown: ${result.metrics.maxDrawdown.toFixed(2)}%`);
|
||||||
|
|
||||||
|
console.log('\n=== Trade History ===');
|
||||||
|
result.trades.forEach((trade, i) => {
|
||||||
|
console.log(`Trade ${i + 1}: ${trade.side} ${trade.quantity} ${trade.symbol} @ ${trade.entryPrice.toFixed(2)}`);
|
||||||
|
if (trade.exitDate) {
|
||||||
|
console.log(` Exit: ${trade.exitPrice.toFixed(2)}, P&L: ${trade.pnl.toFixed(2)} (${trade.pnlPercent.toFixed(2)}%)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nTotal trades in result: ${result.trades.length}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backtest failed:', error);
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
await container.shutdownManager.shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runBacktest().catch(console.error);
|
||||||
|
|
@ -11,6 +11,16 @@ export interface ChartData {
|
||||||
volume?: number;
|
volume?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TradeMarker {
|
||||||
|
time: number;
|
||||||
|
position: 'aboveBar' | 'belowBar';
|
||||||
|
color: string;
|
||||||
|
shape: 'arrowUp' | 'arrowDown';
|
||||||
|
text: string;
|
||||||
|
id?: string;
|
||||||
|
price?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChartProps {
|
export interface ChartProps {
|
||||||
data: ChartData[];
|
data: ChartData[];
|
||||||
height?: number;
|
height?: number;
|
||||||
|
|
@ -23,6 +33,7 @@ export interface ChartProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
lineWidth?: number;
|
lineWidth?: number;
|
||||||
}>;
|
}>;
|
||||||
|
tradeMarkers?: TradeMarker[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,6 +44,7 @@ export function Chart({
|
||||||
showVolume = true,
|
showVolume = true,
|
||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
overlayData = [],
|
overlayData = [],
|
||||||
|
tradeMarkers = [],
|
||||||
className = '',
|
className = '',
|
||||||
}: ChartProps) {
|
}: ChartProps) {
|
||||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -94,16 +106,18 @@ export function Chart({
|
||||||
|
|
||||||
chartRef.current = chart;
|
chartRef.current = chart;
|
||||||
|
|
||||||
// Filter and validate data
|
// Filter, validate and sort data
|
||||||
const validateAndFilterData = (rawData: any[]) => {
|
const validateAndFilterData = (rawData: any[]) => {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
return rawData.filter((item, index) => {
|
return rawData
|
||||||
|
.filter((item, index) => {
|
||||||
if (seen.has(item.time)) {
|
if (seen.has(item.time)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
seen.add(item.time);
|
seen.add(item.time);
|
||||||
return true;
|
return true;
|
||||||
});
|
})
|
||||||
|
.sort((a, b) => a.time - b.time); // Ensure ascending time order
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create main series
|
// Create main series
|
||||||
|
|
@ -193,7 +207,8 @@ export function Chart({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out duplicate timestamps and ensure ascending order
|
// Filter out duplicate timestamps and ensure ascending order
|
||||||
const uniqueData = overlay.data.reduce((acc: any[], curr) => {
|
const sortedData = [...overlay.data].sort((a, b) => a.time - b.time);
|
||||||
|
const uniqueData = sortedData.reduce((acc: any[], curr) => {
|
||||||
if (!acc.length || curr.time > acc[acc.length - 1].time) {
|
if (!acc.length || curr.time > acc[acc.length - 1].time) {
|
||||||
acc.push(curr);
|
acc.push(curr);
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +218,22 @@ export function Chart({
|
||||||
overlaySeriesRef.current.set(overlay.name, series);
|
overlaySeriesRef.current.set(overlay.name, series);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add trade markers
|
||||||
|
if (tradeMarkers.length > 0 && mainSeriesRef.current) {
|
||||||
|
// Sort markers by time to ensure they're in ascending order
|
||||||
|
const sortedMarkers = [...tradeMarkers].sort((a, b) => a.time - b.time);
|
||||||
|
const markers: LightweightCharts.SeriesMarker<LightweightCharts.Time>[] = sortedMarkers.map(marker => ({
|
||||||
|
time: marker.time as LightweightCharts.Time,
|
||||||
|
position: marker.position,
|
||||||
|
color: marker.color,
|
||||||
|
shape: marker.shape as LightweightCharts.SeriesMarkerShape,
|
||||||
|
text: marker.text,
|
||||||
|
id: marker.id,
|
||||||
|
size: 1
|
||||||
|
}));
|
||||||
|
mainSeriesRef.current.setMarkers(markers);
|
||||||
|
}
|
||||||
|
|
||||||
// Fit content with a slight delay to ensure all series are loaded
|
// Fit content with a slight delay to ensure all series are loaded
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
chart.timeScale().fitContent();
|
chart.timeScale().fitContent();
|
||||||
|
|
@ -251,7 +282,7 @@ export function Chart({
|
||||||
chart.remove();
|
chart.remove();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [data, height, type, showVolume, theme, overlayData]);
|
}, [data, height, type, showVolume, theme, overlayData, tradeMarkers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`}>
|
<div className={`relative ${className}`}>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ interface BacktestResultsProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BacktestResults({ status, results, currentTime }: BacktestResultsProps) {
|
export function BacktestResults({ status, results, currentTime }: BacktestResultsProps) {
|
||||||
|
const [selectedSymbol, setSelectedSymbol] = useState<string>('');
|
||||||
|
|
||||||
if (status === 'idle') {
|
if (status === 'idle') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
|
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
|
||||||
|
|
@ -112,16 +114,55 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
|
||||||
|
|
||||||
{/* Performance Chart */}
|
{/* Performance Chart */}
|
||||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
|
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
|
||||||
<h3 className="text-base font-medium text-text-primary mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-base font-medium text-text-primary">
|
||||||
Portfolio Performance
|
Portfolio Performance
|
||||||
</h3>
|
</h3>
|
||||||
|
{results.ohlcData && Object.keys(results.ohlcData).length > 1 && (
|
||||||
|
<select
|
||||||
|
value={selectedSymbol || Object.keys(results.ohlcData)[0]}
|
||||||
|
onChange={(e) => setSelectedSymbol(e.target.value)}
|
||||||
|
className="px-3 py-1 text-sm bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
{Object.keys(results.ohlcData).map(symbol => (
|
||||||
|
<option key={symbol} value={symbol}>{symbol}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const hasOhlcData = results.ohlcData && Object.keys(results.ohlcData).length > 0;
|
const hasOhlcData = results.ohlcData && Object.keys(results.ohlcData).length > 0;
|
||||||
const hasEquityData = results.equity && results.equity.length > 0;
|
const hasEquityData = results.equity && results.equity.length > 0;
|
||||||
|
|
||||||
if (hasOhlcData) {
|
if (hasOhlcData) {
|
||||||
const firstSymbol = Object.keys(results.ohlcData)[0];
|
const activeSymbol = selectedSymbol || Object.keys(results.ohlcData)[0];
|
||||||
const ohlcData = results.ohlcData[firstSymbol];
|
const ohlcData = results.ohlcData[activeSymbol];
|
||||||
|
|
||||||
|
// Create trade markers for the selected symbol
|
||||||
|
const tradeMarkers = results.trades
|
||||||
|
.filter(trade => trade.symbol === activeSymbol)
|
||||||
|
.map(trade => ({
|
||||||
|
time: Math.floor(new Date(trade.entryDate).getTime() / 1000),
|
||||||
|
position: 'belowBar' as const,
|
||||||
|
color: '#10b981',
|
||||||
|
shape: 'arrowUp' as const,
|
||||||
|
text: `Buy ${trade.quantity}@${trade.entryPrice.toFixed(2)}`,
|
||||||
|
id: `${trade.id}-entry`,
|
||||||
|
price: trade.entryPrice
|
||||||
|
}))
|
||||||
|
.concat(
|
||||||
|
results.trades
|
||||||
|
.filter(trade => trade.symbol === activeSymbol && trade.exitDate)
|
||||||
|
.map(trade => ({
|
||||||
|
time: Math.floor(new Date(trade.exitDate!).getTime() / 1000),
|
||||||
|
position: 'aboveBar' as const,
|
||||||
|
color: '#ef4444',
|
||||||
|
shape: 'arrowDown' as const,
|
||||||
|
text: `Sell ${trade.quantity}@${trade.exitPrice.toFixed(2)} (${trade.pnl >= 0 ? '+' : ''}${trade.pnl.toFixed(2)})`,
|
||||||
|
id: `${trade.id}-exit`,
|
||||||
|
price: trade.exitPrice
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Chart
|
<Chart
|
||||||
|
|
@ -141,6 +182,7 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
|
||||||
lineWidth: 3
|
lineWidth: 3
|
||||||
}
|
}
|
||||||
] : []}
|
] : []}
|
||||||
|
tradeMarkers={tradeMarkers}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue