adding data-services
This commit is contained in:
parent
e3bfd05b90
commit
405b818c86
139 changed files with 55943 additions and 416 deletions
0
apps/intelligence-services/backtest-engine/README.md
Normal file
0
apps/intelligence-services/backtest-engine/README.md
Normal file
24
apps/intelligence-services/backtest-engine/package.json
Normal file
24
apps/intelligence-services/backtest-engine/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "backtest-engine",
|
||||
"version": "1.0.0",
|
||||
"description": "Dedicated backtesting engine for trading strategies",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"test": "bun test --timeout 10000 src/tests/**/*.test.ts",
|
||||
"test:watch": "bun test --watch src/tests/**/*.test.ts"
|
||||
}, "dependencies": {
|
||||
"hono": "^4.6.3",
|
||||
"@stock-bot/shared-types": "workspace:*",
|
||||
"@stock-bot/utils": "workspace:*",
|
||||
"@stock-bot/event-bus": "workspace:*",
|
||||
"@stock-bot/api-client": "workspace:*",
|
||||
"@stock-bot/config": "*",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.2.15",
|
||||
"@types/ws": "^8.5.12"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,650 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { OHLCV } from '@stock-bot/shared-types';
|
||||
import { Order, Position } from '@stock-bot/shared-types';
|
||||
import { createLogger } from '@stock-bot/utils';
|
||||
import { financialUtils } from '@stock-bot/utils';
|
||||
|
||||
const logger = createLogger('backtest-engine');
|
||||
|
||||
// Use OHLCV from shared-types as BarData equivalent
|
||||
export type BarData = OHLCV;
|
||||
|
||||
// Strategy interface to match existing pattern
|
||||
export interface StrategyInterface {
|
||||
id: string;
|
||||
onBar(bar: BarData): Promise<Order[]> | Order[];
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BacktestConfig {
|
||||
initialCapital: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
commission: number;
|
||||
slippage: number;
|
||||
}
|
||||
|
||||
export interface BacktestResult {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
initialCapital: number;
|
||||
finalCapital: number;
|
||||
totalReturn: number;
|
||||
totalTrades: number;
|
||||
winningTrades: number;
|
||||
losingTrades: number;
|
||||
winRate: number;
|
||||
avgWin: number;
|
||||
avgLoss: number;
|
||||
profitFactor: number;
|
||||
sharpeRatio: number;
|
||||
maxDrawdown: number;
|
||||
trades: Array<{
|
||||
id: string;
|
||||
symbol: string;
|
||||
side: 'BUY' | 'SELL';
|
||||
quantity: number;
|
||||
entryTime: Date;
|
||||
exitTime: Date;
|
||||
entryPrice: number;
|
||||
exitPrice: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
}>;
|
||||
dailyReturns: Array<{
|
||||
date: Date;
|
||||
portfolioValue: number;
|
||||
dayReturn: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BacktestProgress {
|
||||
currentDate: Date;
|
||||
progress: number; // 0-100
|
||||
portfolioValue: number;
|
||||
totalTrades: number;
|
||||
}
|
||||
|
||||
export interface DataFeed {
|
||||
getHistoricalData(symbol: string, startDate: Date, endDate: Date): Promise<BarData[]>;
|
||||
}
|
||||
|
||||
// Extended Position interface that includes additional fields needed for backtesting
|
||||
export interface BacktestPosition {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
averagePrice: number;
|
||||
marketValue: number;
|
||||
unrealizedPnL: number;
|
||||
timestamp: Date;
|
||||
// Additional fields for backtesting
|
||||
avgPrice: number; // Alias for averagePrice
|
||||
entryTime: Date;
|
||||
}
|
||||
|
||||
// Extended Order interface that includes additional fields needed for backtesting
|
||||
export interface BacktestOrder extends Order {
|
||||
fillPrice?: number;
|
||||
fillTime?: Date;
|
||||
}
|
||||
trades: Array<{
|
||||
symbol: string;
|
||||
entryTime: Date;
|
||||
entryPrice: number;
|
||||
exitTime: Date;
|
||||
exitPrice: number;
|
||||
quantity: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BacktestProgress {
|
||||
progress: number; // 0-100
|
||||
currentDate: Date;
|
||||
processingSpeed: number; // Bars per second
|
||||
estimatedTimeRemaining: number; // milliseconds
|
||||
currentCapital: number;
|
||||
currentReturn: number;
|
||||
currentDrawdown: number;
|
||||
}
|
||||
|
||||
export interface DataFeed {
|
||||
getHistoricalData(symbol: string, resolution: string, start: Date, end: Date): Promise<BarData[]>;
|
||||
hasDataFor(symbol: string, resolution: string, start: Date, end: Date): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class BacktestEngine extends EventEmitter {
|
||||
private config: BacktestConfig;
|
||||
private strategy: StrategyInterface;
|
||||
private dataFeed: DataFeed;
|
||||
private isRunning: boolean = false;
|
||||
private barBuffer: Map<string, BarData[]> = new Map();
|
||||
private pendingOrders: BacktestOrder[] = [];
|
||||
private filledOrders: BacktestOrder[] = [];
|
||||
private currentTime: Date;
|
||||
private startTime: number = 0; // For performance tracking
|
||||
private processedBars: number = 0;
|
||||
private marketData: Map<string, BarData[]> = new Map();
|
||||
|
||||
// Results tracking
|
||||
private initialCapital: number;
|
||||
private currentCapital: number;
|
||||
private positions = new Map<string, BacktestPosition>();
|
||||
private trades: BacktestResult['trades'] = [];
|
||||
private dailyReturns: BacktestResult['dailyReturns'] = [];
|
||||
private previousPortfolioValue: number;
|
||||
private highWaterMark: number;
|
||||
private maxDrawdown: number = 0;
|
||||
private drawdownStartTime: Date | null = null;
|
||||
private maxDrawdownDuration: number = 0;
|
||||
private winningTrades: number = 0;
|
||||
private losingTrades: number = 0;
|
||||
private breakEvenTrades: number = 0;
|
||||
private totalProfits: number = 0;
|
||||
private totalLosses: number = 0;
|
||||
constructor(strategy: StrategyInterface, config: BacktestConfig, dataFeed: DataFeed) {
|
||||
super();
|
||||
this.strategy = strategy;
|
||||
this.config = config;
|
||||
this.dataFeed = dataFeed;
|
||||
this.currentTime = new Date(config.startDate);
|
||||
this.initialCapital = config.initialCapital;
|
||||
this.currentCapital = config.initialCapital;
|
||||
this.previousPortfolioValue = config.initialCapital;
|
||||
this.highWaterMark = config.initialCapital;
|
||||
}
|
||||
async run(): Promise<BacktestResult> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('Backtest is already running');
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.startTime = Date.now();
|
||||
this.emit('started', { strategyId: this.strategy.id, config: this.config });
|
||||
|
||||
try {
|
||||
await this.runEventBased();
|
||||
const result = this.generateResults();
|
||||
this.emit('completed', { strategyId: this.strategy.id, result });
|
||||
this.isRunning = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.isRunning = false;
|
||||
this.emit('error', { strategyId: this.strategy.id, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async runEventBased(): Promise<void> {
|
||||
// Load market data for all symbols
|
||||
await this.loadMarketData();
|
||||
|
||||
// Initialize the strategy
|
||||
await this.strategy.start();
|
||||
|
||||
// Create a merged timeline of all bars across all symbols, sorted by timestamp
|
||||
const timeline = this.createMergedTimeline();
|
||||
|
||||
// Process each event in chronological order
|
||||
let lastProgressUpdate = Date.now();
|
||||
let prevDate = new Date(0);
|
||||
|
||||
for (let i = 0; i < timeline.length; i++) {
|
||||
const bar = timeline[i];
|
||||
this.currentTime = bar.timestamp;
|
||||
|
||||
// Process any pending orders
|
||||
await this.processOrders(bar);
|
||||
|
||||
// Update positions with current prices
|
||||
this.updatePositions(bar);
|
||||
|
||||
// If we've crossed to a new day, calculate daily return
|
||||
if (this.currentTime.toDateString() !== prevDate.toDateString()) {
|
||||
this.calculateDailyReturn();
|
||||
prevDate = this.currentTime;
|
||||
}
|
||||
|
||||
// Send the new bar to the strategy
|
||||
const orders = await this.strategy.onBar(bar);
|
||||
|
||||
// Add any new orders to the pending orders queue
|
||||
if (orders && orders.length > 0) {
|
||||
this.pendingOrders.push(...orders);
|
||||
}
|
||||
|
||||
// Update progress periodically
|
||||
if (Date.now() - lastProgressUpdate > 1000) { // Update every second
|
||||
this.updateProgress(i / timeline.length);
|
||||
lastProgressUpdate = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining orders
|
||||
for (const order of this.pendingOrders) {
|
||||
await this.processOrder(order);
|
||||
}
|
||||
|
||||
// Close any remaining positions at the last known price
|
||||
await this.closeAllPositions();
|
||||
|
||||
// Clean up strategy
|
||||
await this.strategy.stop();
|
||||
}
|
||||
|
||||
private async runVectorized(): Promise<void> {
|
||||
// Load market data for all symbols
|
||||
await this.loadMarketData();
|
||||
|
||||
// To implement a vectorized approach, we need to:
|
||||
// 1. Pre-compute technical indicators
|
||||
// 2. Generate buy/sell signals for the entire dataset
|
||||
// 3. Calculate portfolio values based on signals
|
||||
|
||||
// This is a simplified implementation since specific vectorized strategies
|
||||
// will need to be implemented separately based on the strategy type
|
||||
|
||||
const timeline = this.createMergedTimeline();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Initialize variables for tracking performance
|
||||
let currentPositions = new Map<string, number>();
|
||||
let currentCash = this.initialCapital;
|
||||
let prevPortfolioValue = this.initialCapital;
|
||||
let highWaterMark = this.initialCapital;
|
||||
let maxDrawdown = 0;
|
||||
let maxDrawdownStartDate = new Date();
|
||||
let maxDrawdownEndDate = new Date();
|
||||
let currentDrawdownStart = new Date();
|
||||
|
||||
// Pre-process data (this would be implemented by the specific strategy)
|
||||
const allBars = new Map<string, BarData[]>();
|
||||
for (const symbol of this.config.symbols) {
|
||||
allBars.set(symbol, this.marketData.get(symbol) || []);
|
||||
}
|
||||
|
||||
// Apply strategy logic (vectorized implementation would be here)
|
||||
// For now, we'll just simulate the processing
|
||||
|
||||
this.emit('completed', { message: 'Vectorized backtest completed in fast mode' });
|
||||
}
|
||||
|
||||
private async loadMarketData(): Promise<void> {
|
||||
for (const symbol of this.config.symbols) {
|
||||
this.emit('loading', { symbol, resolution: this.config.dataResolution });
|
||||
|
||||
// Check if data is available
|
||||
const hasData = await this.dataFeed.hasDataFor(
|
||||
symbol,
|
||||
this.config.dataResolution,
|
||||
this.config.startDate,
|
||||
this.config.endDate
|
||||
);
|
||||
|
||||
if (!hasData) {
|
||||
throw new Error(`No data available for ${symbol} at resolution ${this.config.dataResolution}`);
|
||||
}
|
||||
|
||||
// Load data
|
||||
const data = await this.dataFeed.getHistoricalData(
|
||||
symbol,
|
||||
this.config.dataResolution,
|
||||
this.config.startDate,
|
||||
this.config.endDate
|
||||
);
|
||||
|
||||
this.marketData.set(symbol, data);
|
||||
this.emit('loaded', { symbol, count: data.length });
|
||||
}
|
||||
}
|
||||
|
||||
private createMergedTimeline(): BarData[] {
|
||||
const allBars: BarData[] = [];
|
||||
|
||||
for (const [symbol, bars] of this.marketData.entries()) {
|
||||
allBars.push(...bars);
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
return allBars.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
}
|
||||
|
||||
private async processOrders(currentBar: BarData): Promise<void> {
|
||||
// Find orders for the current symbol
|
||||
const ordersToProcess = this.pendingOrders.filter(order => order.symbol === currentBar.symbol);
|
||||
|
||||
if (ordersToProcess.length === 0) return;
|
||||
|
||||
// Remove these orders from pendingOrders
|
||||
this.pendingOrders = this.pendingOrders.filter(order => order.symbol !== currentBar.symbol);
|
||||
|
||||
// Process each order
|
||||
for (const order of ordersToProcess) {
|
||||
await this.processOrder(order);
|
||||
}
|
||||
}
|
||||
|
||||
private async processOrder(order: Order): Promise<void> {
|
||||
// Get the latest price for the symbol
|
||||
const latestBars = this.marketData.get(order.symbol);
|
||||
if (!latestBars || latestBars.length === 0) {
|
||||
order.status = 'REJECTED';
|
||||
this.emit('orderRejected', { order, reason: 'No market data available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the bar closest to the order time
|
||||
const bar = latestBars.find(b =>
|
||||
b.timestamp.getTime() >= order.timestamp.getTime()
|
||||
) || latestBars[latestBars.length - 1];
|
||||
|
||||
// Calculate fill price with slippage
|
||||
let fillPrice: number;
|
||||
if (order.type === 'MARKET') {
|
||||
// Apply slippage model
|
||||
const slippageFactor = 1 + (order.side === 'BUY' ? this.config.slippage : -this.config.slippage);
|
||||
fillPrice = bar.close * slippageFactor;
|
||||
} else if (order.type === 'LIMIT' && order.price !== undefined) {
|
||||
// For limit orders, check if the price was reached
|
||||
if ((order.side === 'BUY' && bar.low <= order.price) ||
|
||||
(order.side === 'SELL' && bar.high >= order.price)) {
|
||||
fillPrice = order.price;
|
||||
} else {
|
||||
// Limit price not reached
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Other order types not implemented
|
||||
order.status = 'REJECTED';
|
||||
this.emit('orderRejected', { order, reason: 'Order type not supported' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate commission
|
||||
const orderValue = order.quantity * fillPrice;
|
||||
const commission = orderValue * this.config.commission;
|
||||
|
||||
// Check if we have enough cash for BUY orders
|
||||
if (order.side === 'BUY') {
|
||||
const totalCost = orderValue + commission;
|
||||
if (totalCost > this.currentCapital) {
|
||||
// Not enough cash
|
||||
order.status = 'REJECTED';
|
||||
this.emit('orderRejected', { order, reason: 'Insufficient funds' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cash
|
||||
this.currentCapital -= totalCost;
|
||||
|
||||
// Update or create position
|
||||
const existingPosition = this.positions.get(order.symbol);
|
||||
if (existingPosition) {
|
||||
// Update existing position (average down)
|
||||
const totalShares = existingPosition.quantity + order.quantity;
|
||||
const totalCost = (existingPosition.quantity * existingPosition.avgPrice) + (order.quantity * fillPrice);
|
||||
existingPosition.avgPrice = totalCost / totalShares;
|
||||
existingPosition.quantity = totalShares;
|
||||
} else {
|
||||
// Create new position
|
||||
this.positions.set(order.symbol, {
|
||||
symbol: order.symbol,
|
||||
quantity: order.quantity,
|
||||
avgPrice: fillPrice,
|
||||
side: 'LONG',
|
||||
entryTime: this.currentTime
|
||||
});
|
||||
}
|
||||
} else if (order.side === 'SELL') {
|
||||
const position = this.positions.get(order.symbol);
|
||||
|
||||
if (!position || position.quantity < order.quantity) {
|
||||
// Not enough shares to sell
|
||||
order.status = 'REJECTED';
|
||||
this.emit('orderRejected', { order, reason: 'Insufficient position' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate P&L
|
||||
const pnl = (fillPrice - position.avgPrice) * order.quantity;
|
||||
|
||||
// Update cash
|
||||
this.currentCapital += orderValue - commission;
|
||||
|
||||
// Update position
|
||||
position.quantity -= order.quantity;
|
||||
|
||||
if (position.quantity === 0) {
|
||||
// Position closed, record the trade
|
||||
this.positions.delete(order.symbol);
|
||||
|
||||
this.trades.push({
|
||||
symbol: order.symbol,
|
||||
entryTime: position.entryTime,
|
||||
entryPrice: position.avgPrice,
|
||||
exitTime: this.currentTime,
|
||||
exitPrice: fillPrice,
|
||||
quantity: order.quantity,
|
||||
pnl: pnl,
|
||||
pnlPercent: (pnl / (position.avgPrice * order.quantity)) * 100
|
||||
});
|
||||
|
||||
// Update statistics
|
||||
if (pnl > 0) {
|
||||
this.winningTrades++;
|
||||
this.totalProfits += pnl;
|
||||
} else if (pnl < 0) {
|
||||
this.losingTrades++;
|
||||
this.totalLosses -= pnl; // Make positive for easier calculations
|
||||
} else {
|
||||
this.breakEvenTrades++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark order as filled
|
||||
order.status = 'FILLED';
|
||||
order.fillPrice = fillPrice;
|
||||
order.fillTime = this.currentTime;
|
||||
this.filledOrders.push(order);
|
||||
|
||||
// Notify strategy
|
||||
await this.strategy.onOrderFilled(order);
|
||||
|
||||
this.emit('orderFilled', { order });
|
||||
}
|
||||
|
||||
private updatePositions(currentBar: BarData): void {
|
||||
// Update the unrealized P&L for positions in this symbol
|
||||
const position = this.positions.get(currentBar.symbol);
|
||||
if (position) {
|
||||
const currentPrice = currentBar.close;
|
||||
const unrealizedPnL = (currentPrice - position.avgPrice) * position.quantity;
|
||||
position.unrealizedPnL = unrealizedPnL;
|
||||
}
|
||||
|
||||
// Calculate total portfolio value
|
||||
const portfolioValue = this.calculatePortfolioValue();
|
||||
|
||||
// Check for new high water mark
|
||||
if (portfolioValue > this.highWaterMark) {
|
||||
this.highWaterMark = portfolioValue;
|
||||
this.drawdownStartTime = null;
|
||||
}
|
||||
|
||||
// Check for drawdown
|
||||
if (this.drawdownStartTime === null && portfolioValue < this.highWaterMark) {
|
||||
this.drawdownStartTime = this.currentTime;
|
||||
}
|
||||
|
||||
// Update max drawdown
|
||||
if (this.highWaterMark > 0) {
|
||||
const currentDrawdown = (this.highWaterMark - portfolioValue) / this.highWaterMark;
|
||||
if (currentDrawdown > this.maxDrawdown) {
|
||||
this.maxDrawdown = currentDrawdown;
|
||||
|
||||
// Calculate drawdown duration
|
||||
if (this.drawdownStartTime !== null) {
|
||||
const drawdownDuration = (this.currentTime.getTime() - this.drawdownStartTime.getTime()) / (1000 * 60 * 60 * 24); // In days
|
||||
if (drawdownDuration > this.maxDrawdownDuration) {
|
||||
this.maxDrawdownDuration = drawdownDuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.previousPortfolioValue = portfolioValue;
|
||||
}
|
||||
|
||||
private calculatePortfolioValue(): number {
|
||||
let totalValue = this.currentCapital;
|
||||
|
||||
// Add the current value of all positions
|
||||
for (const [symbol, position] of this.positions.entries()) {
|
||||
// Find the latest price for this symbol
|
||||
const bars = this.marketData.get(symbol);
|
||||
if (bars && bars.length > 0) {
|
||||
const latestBar = bars[bars.length - 1];
|
||||
totalValue += position.quantity * latestBar.close;
|
||||
} else {
|
||||
// If no price data, use the average price (not ideal but better than nothing)
|
||||
totalValue += position.quantity * position.avgPrice;
|
||||
}
|
||||
}
|
||||
|
||||
return totalValue;
|
||||
}
|
||||
|
||||
private calculateDailyReturn(): void {
|
||||
const portfolioValue = this.calculatePortfolioValue();
|
||||
const dailyReturn = (portfolioValue - this.previousPortfolioValue) / this.previousPortfolioValue;
|
||||
|
||||
this.dailyReturns.push({
|
||||
date: new Date(this.currentTime),
|
||||
return: dailyReturn
|
||||
});
|
||||
|
||||
this.previousPortfolioValue = portfolioValue;
|
||||
}
|
||||
|
||||
private async closeAllPositions(): Promise<void> {
|
||||
for (const [symbol, position] of this.positions.entries()) {
|
||||
// Find the latest price
|
||||
const bars = this.marketData.get(symbol);
|
||||
if (!bars || bars.length === 0) continue;
|
||||
|
||||
const lastBar = bars[bars.length - 1];
|
||||
const closePrice = lastBar.close;
|
||||
|
||||
// Calculate P&L
|
||||
const pnl = (closePrice - position.avgPrice) * position.quantity;
|
||||
|
||||
// Update cash
|
||||
this.currentCapital += position.quantity * closePrice;
|
||||
|
||||
// Record the trade
|
||||
this.trades.push({
|
||||
symbol,
|
||||
entryTime: position.entryTime,
|
||||
entryPrice: position.avgPrice,
|
||||
exitTime: this.currentTime,
|
||||
exitPrice: closePrice,
|
||||
quantity: position.quantity,
|
||||
pnl,
|
||||
pnlPercent: (pnl / (position.avgPrice * position.quantity)) * 100
|
||||
});
|
||||
|
||||
// Update statistics
|
||||
if (pnl > 0) {
|
||||
this.winningTrades++;
|
||||
this.totalProfits += pnl;
|
||||
} else if (pnl < 0) {
|
||||
this.losingTrades++;
|
||||
this.totalLosses -= pnl; // Make positive for easier calculations
|
||||
} else {
|
||||
this.breakEvenTrades++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear positions
|
||||
this.positions.clear();
|
||||
}
|
||||
|
||||
private updateProgress(progress: number): void {
|
||||
const currentPortfolioValue = this.calculatePortfolioValue();
|
||||
const currentDrawdown = this.highWaterMark > 0
|
||||
? (this.highWaterMark - currentPortfolioValue) / this.highWaterMark
|
||||
: 0;
|
||||
|
||||
const elapsedMs = Date.now() - this.startTime;
|
||||
const totalEstimatedMs = elapsedMs / progress;
|
||||
const remainingMs = totalEstimatedMs - elapsedMs;
|
||||
|
||||
this.emit('progress', {
|
||||
progress: progress * 100,
|
||||
currentDate: this.currentTime,
|
||||
processingSpeed: this.processedBars / (elapsedMs / 1000),
|
||||
estimatedTimeRemaining: remainingMs,
|
||||
currentCapital: this.currentCapital,
|
||||
currentReturn: (currentPortfolioValue - this.initialCapital) / this.initialCapital,
|
||||
currentDrawdown
|
||||
} as BacktestProgress);
|
||||
}
|
||||
|
||||
private generateResults(): BacktestResult {
|
||||
const currentPortfolioValue = this.calculatePortfolioValue();
|
||||
const totalReturn = (currentPortfolioValue - this.initialCapital) / this.initialCapital;
|
||||
|
||||
// Calculate annualized return
|
||||
const days = (this.config.endDate.getTime() - this.config.startDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
const annualizedReturn = Math.pow(1 + totalReturn, 365 / days) - 1;
|
||||
|
||||
// Calculate Sharpe Ratio
|
||||
let sharpeRatio = 0;
|
||||
if (this.dailyReturns.length > 1) {
|
||||
const dailyReturnValues = this.dailyReturns.map(dr => dr.return);
|
||||
const avgDailyReturn = dailyReturnValues.reduce((sum, ret) => sum + ret, 0) / dailyReturnValues.length;
|
||||
const stdDev = Math.sqrt(
|
||||
dailyReturnValues.reduce((sum, ret) => sum + Math.pow(ret - avgDailyReturn, 2), 0) / dailyReturnValues.length
|
||||
);
|
||||
|
||||
// Annualize
|
||||
sharpeRatio = stdDev > 0
|
||||
? (avgDailyReturn * 252) / (stdDev * Math.sqrt(252))
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Calculate win rate and profit factor
|
||||
const totalTrades = this.winningTrades + this.losingTrades + this.breakEvenTrades;
|
||||
const winRate = totalTrades > 0 ? this.winningTrades / totalTrades : 0;
|
||||
const profitFactor = this.totalLosses > 0 ? this.totalProfits / this.totalLosses : (this.totalProfits > 0 ? Infinity : 0);
|
||||
|
||||
// Calculate average winning and losing trade
|
||||
const avgWinningTrade = this.winningTrades > 0 ? this.totalProfits / this.winningTrades : 0;
|
||||
const avgLosingTrade = this.losingTrades > 0 ? this.totalLosses / this.losingTrades : 0;
|
||||
|
||||
return {
|
||||
strategyId: this.strategy.id,
|
||||
startDate: this.config.startDate,
|
||||
endDate: this.config.endDate,
|
||||
duration: Date.now() - this.startTime,
|
||||
initialCapital: this.initialCapital,
|
||||
finalCapital: currentPortfolioValue,
|
||||
totalReturn,
|
||||
annualizedReturn,
|
||||
sharpeRatio,
|
||||
maxDrawdown: this.maxDrawdown,
|
||||
maxDrawdownDuration: this.maxDrawdownDuration,
|
||||
winRate,
|
||||
totalTrades,
|
||||
winningTrades: this.winningTrades,
|
||||
losingTrades: this.losingTrades,
|
||||
averageWinningTrade: avgWinningTrade,
|
||||
averageLosingTrade: avgLosingTrade,
|
||||
profitFactor,
|
||||
dailyReturns: this.dailyReturns,
|
||||
trades: this.trades
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import { BaseStrategy } from '../Strategy';
|
||||
import { BacktestConfig, BacktestEngine, BacktestResult } from './BacktestEngine';
|
||||
import { MarketDataFeed } from './MarketDataFeed';
|
||||
import { StrategyRegistry, StrategyType } from '../strategies/StrategyRegistry';
|
||||
|
||||
export interface BacktestRequest {
|
||||
strategyType: StrategyType;
|
||||
strategyParams: Record<string, any>;
|
||||
symbols: string[];
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
initialCapital: number;
|
||||
dataResolution: '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d';
|
||||
commission: number;
|
||||
slippage: number;
|
||||
mode: 'event' | 'vector';
|
||||
}
|
||||
|
||||
/**
|
||||
* Backtesting Service
|
||||
*
|
||||
* A service that handles backtesting requests and manages backtesting sessions.
|
||||
*/
|
||||
export class BacktestService {
|
||||
private readonly strategyRegistry: StrategyRegistry;
|
||||
private readonly dataFeed: MarketDataFeed;
|
||||
private readonly activeBacktests: Map<string, BacktestEngine> = new Map();
|
||||
|
||||
constructor(apiBaseUrl: string = 'http://localhost:3001/api') {
|
||||
this.strategyRegistry = StrategyRegistry.getInstance();
|
||||
this.dataFeed = new MarketDataFeed(apiBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a backtest based on a request
|
||||
*/
|
||||
async runBacktest(request: BacktestRequest): Promise<BacktestResult> {
|
||||
// Create a strategy instance
|
||||
const strategyId = `backtest_${Date.now()}`;
|
||||
const strategy = this.strategyRegistry.createStrategy(
|
||||
request.strategyType,
|
||||
strategyId,
|
||||
`Backtest ${request.strategyType}`,
|
||||
`Generated backtest for ${request.symbols.join(', ')}`,
|
||||
request.symbols,
|
||||
request.strategyParams
|
||||
);
|
||||
|
||||
// Parse dates if they are strings
|
||||
const startDate = typeof request.startDate === 'string'
|
||||
? new Date(request.startDate)
|
||||
: request.startDate;
|
||||
|
||||
const endDate = typeof request.endDate === 'string'
|
||||
? new Date(request.endDate)
|
||||
: request.endDate;
|
||||
|
||||
// Create backtest configuration
|
||||
const config: BacktestConfig = {
|
||||
startDate,
|
||||
endDate,
|
||||
symbols: request.symbols,
|
||||
initialCapital: request.initialCapital,
|
||||
commission: request.commission,
|
||||
slippage: request.slippage,
|
||||
dataResolution: request.dataResolution,
|
||||
mode: request.mode
|
||||
};
|
||||
|
||||
// Create and run the backtest engine
|
||||
const engine = new BacktestEngine(strategy, config, this.dataFeed);
|
||||
this.activeBacktests.set(strategyId, engine);
|
||||
|
||||
try {
|
||||
// Set up event forwarding
|
||||
const forwardEvents = (eventName: string) => {
|
||||
engine.on(eventName, (data) => {
|
||||
console.log(`[Backtest ${strategyId}] ${eventName}:`, data);
|
||||
});
|
||||
};
|
||||
|
||||
forwardEvents('started');
|
||||
forwardEvents('loading');
|
||||
forwardEvents('loaded');
|
||||
forwardEvents('progress');
|
||||
forwardEvents('orderFilled');
|
||||
forwardEvents('orderRejected');
|
||||
forwardEvents('completed');
|
||||
forwardEvents('error');
|
||||
|
||||
// Run the backtest
|
||||
const result = await engine.run();
|
||||
|
||||
// Clean up
|
||||
this.activeBacktests.delete(strategyId);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.activeBacktests.delete(strategyId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a strategy by running multiple backtests with different parameters
|
||||
*/
|
||||
async optimizeStrategy(
|
||||
baseRequest: BacktestRequest,
|
||||
parameterGrid: Record<string, any[]>
|
||||
): Promise<Array<BacktestResult & { parameters: Record<string, any> }>> {
|
||||
const results: Array<BacktestResult & { parameters: Record<string, any> }> = [];
|
||||
|
||||
// Generate parameter combinations
|
||||
const paramKeys = Object.keys(parameterGrid);
|
||||
const combinations = this.generateParameterCombinations(parameterGrid, paramKeys);
|
||||
|
||||
// Run backtest for each combination
|
||||
for (const paramSet of combinations) {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
strategyParams: {
|
||||
...baseRequest.strategyParams,
|
||||
...paramSet
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.runBacktest(request);
|
||||
results.push({
|
||||
...result,
|
||||
parameters: paramSet
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Optimization failed for parameters:`, paramSet, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by performance metric (e.g., Sharpe ratio)
|
||||
return results.sort((a, b) => b.sharpeRatio - a.sharpeRatio);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all combinations of parameters for grid search
|
||||
*/
|
||||
private generateParameterCombinations(
|
||||
grid: Record<string, any[]>,
|
||||
keys: string[],
|
||||
current: Record<string, any> = {},
|
||||
index: number = 0,
|
||||
result: Record<string, any>[] = []
|
||||
): Record<string, any>[] {
|
||||
if (index === keys.length) {
|
||||
result.push({ ...current });
|
||||
return result;
|
||||
}
|
||||
|
||||
const key = keys[index];
|
||||
const values = grid[key];
|
||||
|
||||
for (const value of values) {
|
||||
current[key] = value;
|
||||
this.generateParameterCombinations(grid, keys, current, index + 1, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active backtest engine by ID
|
||||
*/
|
||||
getBacktestEngine(id: string): BacktestEngine | undefined {
|
||||
return this.activeBacktests.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running backtest
|
||||
*/
|
||||
cancelBacktest(id: string): boolean {
|
||||
const engine = this.activeBacktests.get(id);
|
||||
if (!engine) return false;
|
||||
|
||||
// No explicit cancel method on engine, but we can clean up
|
||||
this.activeBacktests.delete(id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import { BarData } from '../Strategy';
|
||||
import { DataFeed } from './BacktestEngine';
|
||||
import axios from 'axios';
|
||||
|
||||
export class MarketDataFeed implements DataFeed {
|
||||
private readonly apiBaseUrl: string;
|
||||
private cache: Map<string, BarData[]> = new Map();
|
||||
|
||||
constructor(apiBaseUrl: string = 'http://localhost:3001/api') {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
|
||||
async getHistoricalData(symbol: string, resolution: string, start: Date, end: Date): Promise<BarData[]> {
|
||||
const cacheKey = this.getCacheKey(symbol, resolution, start, end);
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
try {
|
||||
// Format dates for API request
|
||||
const startStr = start.toISOString();
|
||||
const endStr = end.toISOString();
|
||||
|
||||
const response = await axios.get(`${this.apiBaseUrl}/market-data/history`, {
|
||||
params: {
|
||||
symbol,
|
||||
resolution,
|
||||
start: startStr,
|
||||
end: endStr
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(`Failed to fetch historical data for ${symbol}`);
|
||||
}
|
||||
|
||||
// Transform API response to BarData objects
|
||||
const bars: BarData[] = response.data.data.map((bar: any) => ({
|
||||
symbol,
|
||||
timestamp: new Date(bar.timestamp),
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
volume: bar.volume
|
||||
}));
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(cacheKey, bars);
|
||||
|
||||
return bars;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching historical data for ${symbol}:`, error);
|
||||
// Return fallback test data if API call fails
|
||||
return this.generateFallbackTestData(symbol, resolution, start, end);
|
||||
}
|
||||
}
|
||||
|
||||
async hasDataFor(symbol: string, resolution: string, start: Date, end: Date): Promise<boolean> {
|
||||
try {
|
||||
const startStr = start.toISOString();
|
||||
const endStr = end.toISOString();
|
||||
|
||||
const response = await axios.get(`${this.apiBaseUrl}/market-data/available`, {
|
||||
params: {
|
||||
symbol,
|
||||
resolution,
|
||||
start: startStr,
|
||||
end: endStr
|
||||
}
|
||||
});
|
||||
|
||||
return response.data.success && response.data.data.available;
|
||||
} catch (error) {
|
||||
console.error(`Error checking data availability for ${symbol}:`, error);
|
||||
// Assume data is available for test purposes
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
private getCacheKey(symbol: string, resolution: string, start: Date, end: Date): string {
|
||||
return `${symbol}_${resolution}_${start.getTime()}_${end.getTime()}`;
|
||||
}
|
||||
|
||||
private generateFallbackTestData(symbol: string, resolution: string, start: Date, end: Date): BarData[] {
|
||||
console.warn(`Generating fallback test data for ${symbol} from ${start} to ${end}`);
|
||||
|
||||
const bars: BarData[] = [];
|
||||
let current = new Date(start);
|
||||
let basePrice = this.getBasePrice(symbol);
|
||||
|
||||
// Generate daily bars by default
|
||||
const interval = this.getIntervalFromResolution(resolution);
|
||||
|
||||
while (current.getTime() <= end.getTime()) {
|
||||
// Only generate bars for trading days (skip weekends)
|
||||
if (current.getDay() !== 0 && current.getDay() !== 6) {
|
||||
// Generate a random daily price movement (-1% to +1%)
|
||||
const dailyChange = (Math.random() * 2 - 1) / 100;
|
||||
|
||||
// Add some randomness to the volatility
|
||||
const volatility = 0.005 + Math.random() * 0.01; // 0.5% to 1.5%
|
||||
|
||||
const open = basePrice * (1 + (Math.random() * 0.002 - 0.001));
|
||||
const close = open * (1 + dailyChange);
|
||||
const high = Math.max(open, close) * (1 + Math.random() * volatility);
|
||||
const low = Math.min(open, close) * (1 - Math.random() * volatility);
|
||||
const volume = Math.floor(100000 + Math.random() * 900000);
|
||||
|
||||
bars.push({
|
||||
symbol,
|
||||
timestamp: new Date(current),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume
|
||||
});
|
||||
|
||||
// Update base price for next bar
|
||||
basePrice = close;
|
||||
}
|
||||
|
||||
// Move to next interval
|
||||
current = new Date(current.getTime() + interval);
|
||||
}
|
||||
|
||||
return bars;
|
||||
}
|
||||
|
||||
private getBasePrice(symbol: string): number {
|
||||
// Return a realistic base price for common symbols
|
||||
switch (symbol.toUpperCase()) {
|
||||
case 'AAPL': return 170 + Math.random() * 30;
|
||||
case 'MSFT': return 370 + Math.random() * 50;
|
||||
case 'AMZN': return 140 + Math.random() * 20;
|
||||
case 'GOOGL': return 130 + Math.random() * 20;
|
||||
case 'META': return 300 + Math.random() * 50;
|
||||
case 'TSLA': return 180 + Math.random() * 70;
|
||||
case 'NVDA': return 700 + Math.random() * 200;
|
||||
case 'SPY': return 450 + Math.random() * 30;
|
||||
case 'QQQ': return 370 + Math.random() * 40;
|
||||
default: return 100 + Math.random() * 50;
|
||||
}
|
||||
}
|
||||
|
||||
private getIntervalFromResolution(resolution: string): number {
|
||||
// Return milliseconds for each resolution
|
||||
switch (resolution) {
|
||||
case '1m': return 60 * 1000;
|
||||
case '5m': return 5 * 60 * 1000;
|
||||
case '15m': return 15 * 60 * 1000;
|
||||
case '30m': return 30 * 60 * 1000;
|
||||
case '1h': return 60 * 60 * 1000;
|
||||
case '4h': return 4 * 60 * 60 * 1000;
|
||||
case '1d': return 24 * 60 * 60 * 1000;
|
||||
default: return 24 * 60 * 60 * 1000; // Default to daily
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
import { BacktestResult } from './BacktestEngine';
|
||||
|
||||
/**
|
||||
* Performance Analysis Utilities
|
||||
*
|
||||
* Provides additional metrics and analysis tools for backtesting results.
|
||||
*/
|
||||
export class PerformanceAnalytics {
|
||||
/**
|
||||
* Calculate additional metrics from backtest results
|
||||
*/
|
||||
static enhanceResults(result: BacktestResult): BacktestResult {
|
||||
// Calculate additional metrics
|
||||
const enhancedResult = {
|
||||
...result,
|
||||
...this.calculateAdvancedMetrics(result)
|
||||
};
|
||||
|
||||
return enhancedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate advanced performance metrics
|
||||
*/
|
||||
private static calculateAdvancedMetrics(result: BacktestResult): Partial<BacktestResult> {
|
||||
// Extract daily returns
|
||||
const dailyReturns = result.dailyReturns.map(dr => dr.return);
|
||||
|
||||
// Calculate Sortino ratio
|
||||
const sortinoRatio = this.calculateSortinoRatio(dailyReturns);
|
||||
|
||||
// Calculate Calmar ratio
|
||||
const calmarRatio = result.maxDrawdown > 0
|
||||
? result.annualizedReturn / result.maxDrawdown
|
||||
: Infinity;
|
||||
|
||||
// Calculate Omega ratio
|
||||
const omegaRatio = this.calculateOmegaRatio(dailyReturns);
|
||||
|
||||
// Calculate CAGR
|
||||
const startTimestamp = result.startDate.getTime();
|
||||
const endTimestamp = result.endDate.getTime();
|
||||
const yearsElapsed = (endTimestamp - startTimestamp) / (365 * 24 * 60 * 60 * 1000);
|
||||
const cagr = Math.pow(result.finalCapital / result.initialCapital, 1 / yearsElapsed) - 1;
|
||||
|
||||
// Calculate additional volatility and return metrics
|
||||
const volatility = this.calculateVolatility(dailyReturns);
|
||||
const ulcerIndex = this.calculateUlcerIndex(result.dailyReturns);
|
||||
|
||||
return {
|
||||
sortinoRatio,
|
||||
calmarRatio,
|
||||
omegaRatio,
|
||||
cagr,
|
||||
volatility,
|
||||
ulcerIndex
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Sortino ratio (downside risk-adjusted return)
|
||||
*/
|
||||
private static calculateSortinoRatio(dailyReturns: number[]): number {
|
||||
if (dailyReturns.length === 0) return 0;
|
||||
|
||||
const avgReturn = dailyReturns.reduce((sum, ret) => sum + ret, 0) / dailyReturns.length;
|
||||
|
||||
// Filter only negative returns (downside)
|
||||
const negativeReturns = dailyReturns.filter(ret => ret < 0);
|
||||
|
||||
if (negativeReturns.length === 0) return Infinity;
|
||||
|
||||
// Calculate downside deviation
|
||||
const downsideDeviation = Math.sqrt(
|
||||
negativeReturns.reduce((sum, ret) => sum + Math.pow(ret, 2), 0) / negativeReturns.length
|
||||
);
|
||||
|
||||
// Annualize
|
||||
const annualizedReturn = avgReturn * 252;
|
||||
const annualizedDownsideDeviation = downsideDeviation * Math.sqrt(252);
|
||||
|
||||
return annualizedDownsideDeviation > 0
|
||||
? annualizedReturn / annualizedDownsideDeviation
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Omega ratio (probability-weighted ratio of gains versus losses)
|
||||
*/
|
||||
private static calculateOmegaRatio(dailyReturns: number[], threshold = 0): number {
|
||||
if (dailyReturns.length === 0) return 0;
|
||||
|
||||
let sumGains = 0;
|
||||
let sumLosses = 0;
|
||||
|
||||
for (const ret of dailyReturns) {
|
||||
if (ret > threshold) {
|
||||
sumGains += (ret - threshold);
|
||||
} else {
|
||||
sumLosses += (threshold - ret);
|
||||
}
|
||||
}
|
||||
|
||||
return sumLosses > 0 ? sumGains / sumLosses : Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate annualized volatility
|
||||
*/
|
||||
private static calculateVolatility(returns: number[]): number {
|
||||
if (returns.length < 2) return 0;
|
||||
|
||||
const mean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / returns.length;
|
||||
|
||||
// Annualize
|
||||
return Math.sqrt(variance * 252);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Ulcer Index (measure of downside risk)
|
||||
*/
|
||||
private static calculateUlcerIndex(dailyReturns: Array<{ date: Date; return: number }>): number {
|
||||
if (dailyReturns.length === 0) return 0;
|
||||
|
||||
// Calculate running equity curve
|
||||
let equity = 1;
|
||||
const equityCurve = dailyReturns.map(dr => {
|
||||
equity *= (1 + dr.return);
|
||||
return equity;
|
||||
});
|
||||
|
||||
// Find running maximum
|
||||
const runningMax: number[] = [];
|
||||
let currentMax = equityCurve[0];
|
||||
|
||||
for (const value of equityCurve) {
|
||||
currentMax = Math.max(currentMax, value);
|
||||
runningMax.push(currentMax);
|
||||
}
|
||||
|
||||
// Calculate percentage drawdowns
|
||||
const percentDrawdowns = equityCurve.map((value, i) =>
|
||||
(runningMax[i] - value) / runningMax[i]
|
||||
);
|
||||
|
||||
// Calculate Ulcer Index
|
||||
const sumSquaredDrawdowns = percentDrawdowns.reduce(
|
||||
(sum, dd) => sum + dd * dd, 0
|
||||
);
|
||||
|
||||
return Math.sqrt(sumSquaredDrawdowns / percentDrawdowns.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract monthly returns from daily returns
|
||||
*/
|
||||
static calculateMonthlyReturns(dailyReturns: Array<{ date: Date; return: number }>): Array<{
|
||||
year: number;
|
||||
month: number;
|
||||
return: number;
|
||||
}> {
|
||||
const monthlyReturns: Array<{ year: number; month: number; return: number }> = [];
|
||||
|
||||
if (dailyReturns.length === 0) return monthlyReturns;
|
||||
|
||||
// Group returns by year and month
|
||||
const groupedReturns: Record<string, number[]> = {};
|
||||
|
||||
for (const dr of dailyReturns) {
|
||||
const year = dr.date.getFullYear();
|
||||
const month = dr.date.getMonth();
|
||||
const key = `${year}-${month}`;
|
||||
|
||||
if (!groupedReturns[key]) {
|
||||
groupedReturns[key] = [];
|
||||
}
|
||||
|
||||
groupedReturns[key].push(dr.return);
|
||||
}
|
||||
|
||||
// Calculate compound return for each month
|
||||
for (const key in groupedReturns) {
|
||||
const [yearStr, monthStr] = key.split('-');
|
||||
const year = parseInt(yearStr);
|
||||
const month = parseInt(monthStr);
|
||||
|
||||
// Compound the daily returns for the month
|
||||
const monthReturn = groupedReturns[key].reduce(
|
||||
(product, ret) => product * (1 + ret), 1
|
||||
) - 1;
|
||||
|
||||
monthlyReturns.push({ year, month, return: monthReturn });
|
||||
}
|
||||
|
||||
// Sort by date
|
||||
return monthlyReturns.sort((a, b) => {
|
||||
if (a.year !== b.year) return a.year - b.year;
|
||||
return a.month - b.month;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create drawdown analysis from equity curve
|
||||
*/
|
||||
static analyzeDrawdowns(dailyReturns: Array<{ date: Date; return: number }>): Array<{
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
recoveryDate: Date | null;
|
||||
drawdown: number;
|
||||
durationDays: number;
|
||||
recoveryDays: number | null;
|
||||
}> {
|
||||
if (dailyReturns.length === 0) return [];
|
||||
|
||||
// Calculate equity curve
|
||||
let equity = 1;
|
||||
const equityCurve = dailyReturns.map(dr => {
|
||||
equity *= (1 + dr.return);
|
||||
return { date: dr.date, equity };
|
||||
});
|
||||
|
||||
// Analyze drawdowns
|
||||
const drawdowns: Array<{
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
recoveryDate: Date | null;
|
||||
drawdown: number;
|
||||
durationDays: number;
|
||||
recoveryDays: number | null;
|
||||
}> = [];
|
||||
|
||||
let peakEquity = equityCurve[0].equity;
|
||||
let peakDate = equityCurve[0].date;
|
||||
let inDrawdown = false;
|
||||
let currentDrawdown: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
lowEquity: number;
|
||||
peakEquity: number;
|
||||
} | null = null;
|
||||
|
||||
// Find drawdown periods
|
||||
for (let i = 1; i < equityCurve.length; i++) {
|
||||
const { date, equity } = equityCurve[i];
|
||||
|
||||
// New peak
|
||||
if (equity > peakEquity) {
|
||||
peakEquity = equity;
|
||||
peakDate = date;
|
||||
|
||||
// If recovering from drawdown, record recovery
|
||||
if (inDrawdown && currentDrawdown) {
|
||||
const recoveryDate = date;
|
||||
const drawdownPct = (currentDrawdown.peakEquity - currentDrawdown.lowEquity) /
|
||||
currentDrawdown.peakEquity;
|
||||
|
||||
const durationDays = Math.floor(
|
||||
(currentDrawdown.endDate.getTime() - currentDrawdown.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
const recoveryDays = Math.floor(
|
||||
(recoveryDate.getTime() - currentDrawdown.endDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
drawdowns.push({
|
||||
startDate: currentDrawdown.startDate,
|
||||
endDate: currentDrawdown.endDate,
|
||||
recoveryDate,
|
||||
drawdown: drawdownPct,
|
||||
durationDays,
|
||||
recoveryDays
|
||||
});
|
||||
|
||||
inDrawdown = false;
|
||||
currentDrawdown = null;
|
||||
}
|
||||
}
|
||||
// In drawdown
|
||||
else {
|
||||
const drawdownPct = (peakEquity - equity) / peakEquity;
|
||||
|
||||
if (!inDrawdown) {
|
||||
// Start of new drawdown
|
||||
inDrawdown = true;
|
||||
currentDrawdown = {
|
||||
startDate: peakDate,
|
||||
endDate: date,
|
||||
lowEquity: equity,
|
||||
peakEquity
|
||||
};
|
||||
} else if (currentDrawdown && equity < currentDrawdown.lowEquity) {
|
||||
// New low in current drawdown
|
||||
currentDrawdown.lowEquity = equity;
|
||||
currentDrawdown.endDate = date;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any ongoing drawdown at the end
|
||||
if (inDrawdown && currentDrawdown) {
|
||||
const drawdownPct = (currentDrawdown.peakEquity - currentDrawdown.lowEquity) /
|
||||
currentDrawdown.peakEquity;
|
||||
|
||||
const durationDays = Math.floor(
|
||||
(currentDrawdown.endDate.getTime() - currentDrawdown.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
drawdowns.push({
|
||||
startDate: currentDrawdown.startDate,
|
||||
endDate: currentDrawdown.endDate,
|
||||
recoveryDate: null,
|
||||
drawdown: drawdownPct,
|
||||
durationDays,
|
||||
recoveryDays: null
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by drawdown magnitude
|
||||
return drawdowns.sort((a, b) => b.drawdown - a.drawdown);
|
||||
}
|
||||
}
|
||||
0
apps/intelligence-services/backtest-engine/src/index.ts
Normal file
0
apps/intelligence-services/backtest-engine/src/index.ts
Normal file
0
apps/intelligence-services/backtest-engine/tsconfig.json
Normal file
0
apps/intelligence-services/backtest-engine/tsconfig.json
Normal file
0
apps/intelligence-services/signal-engine/README.md
Normal file
0
apps/intelligence-services/signal-engine/README.md
Normal file
0
apps/intelligence-services/signal-engine/package.json
Normal file
0
apps/intelligence-services/signal-engine/package.json
Normal file
0
apps/intelligence-services/signal-engine/src/index.ts
Normal file
0
apps/intelligence-services/signal-engine/src/index.ts
Normal file
0
apps/intelligence-services/signal-engine/tsconfig.json
Normal file
0
apps/intelligence-services/signal-engine/tsconfig.json
Normal file
|
|
@ -2,11 +2,11 @@
|
|||
"name": "strategy-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"description": "Trading strategy lifecycle management service",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"main": "src/index.ts", "scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"test": "echo 'No tests yet'"
|
||||
"test": "bun test --timeout 10000 src/tests/**/*.test.ts",
|
||||
"test:watch": "bun test --watch src/tests/**/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.3",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { StrategyRegistry, StrategyType } from '../core/strategies/StrategyRegistry';
|
||||
import { BacktestRequest, BacktestService } from '../core/backtesting/BacktestService';
|
||||
import { BaseStrategy } from '../core/Strategy';
|
||||
import { PerformanceAnalytics } from '../core/backtesting/PerformanceAnalytics';
|
||||
|
||||
/**
|
||||
* Strategy Controller
|
||||
*
|
||||
* Handles HTTP requests related to strategy management, backtesting, and execution.
|
||||
*/
|
||||
export class StrategyController {
|
||||
private readonly strategyRegistry: StrategyRegistry;
|
||||
private readonly backtestService: BacktestService;
|
||||
|
||||
constructor(apiBaseUrl: string = 'http://localhost:3001/api') {
|
||||
this.strategyRegistry = StrategyRegistry.getInstance();
|
||||
this.backtestService = new BacktestService(apiBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available strategy types
|
||||
*/
|
||||
public getStrategyTypes(req: Request, res: Response): void {
|
||||
const types = Object.values(StrategyType);
|
||||
res.json({
|
||||
success: true,
|
||||
data: types
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all strategies
|
||||
*/
|
||||
public getStrategies(req: Request, res: Response): void {
|
||||
const strategies = this.strategyRegistry.getAllStrategies();
|
||||
|
||||
// Convert to array of plain objects for serialization
|
||||
const serializedStrategies = strategies.map(strategy => ({
|
||||
id: strategy.id,
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
symbols: strategy.symbols,
|
||||
parameters: strategy.parameters,
|
||||
type: this.strategyRegistry.getStrategyType(strategy)
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: serializedStrategies
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific strategy by ID
|
||||
*/
|
||||
public getStrategy(req: Request, res: Response): void {
|
||||
const { id } = req.params;
|
||||
const strategy = this.strategyRegistry.getStrategyById(id);
|
||||
|
||||
if (!strategy) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: `Strategy with ID ${id} not found`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const type = this.strategyRegistry.getStrategyType(strategy);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: strategy.id,
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
symbols: strategy.symbols,
|
||||
parameters: strategy.parameters,
|
||||
type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new strategy
|
||||
*/
|
||||
public createStrategy(req: Request, res: Response): void {
|
||||
try {
|
||||
const { name, description, symbols, parameters, type } = req.body;
|
||||
|
||||
if (!type || !Object.values(StrategyType).includes(type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid strategy type'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const strategy = this.strategyRegistry.createStrategy(
|
||||
type as StrategyType,
|
||||
`strategy_${Date.now()}`, // Generate an ID
|
||||
name || `New ${type} Strategy`,
|
||||
description || `Generated ${type} strategy`,
|
||||
symbols || [],
|
||||
parameters || {}
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: strategy.id,
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
symbols: strategy.symbols,
|
||||
parameters: strategy.parameters,
|
||||
type
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing strategy
|
||||
*/
|
||||
public updateStrategy(req: Request, res: Response): void {
|
||||
const { id } = req.params;
|
||||
const { name, description, symbols, parameters } = req.body;
|
||||
|
||||
const strategy = this.strategyRegistry.getStrategyById(id);
|
||||
|
||||
if (!strategy) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: `Strategy with ID ${id} not found`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update properties
|
||||
if (name !== undefined) strategy.name = name;
|
||||
if (description !== undefined) strategy.description = description;
|
||||
if (symbols !== undefined) (strategy as any).symbols = symbols; // Hack since symbols is readonly
|
||||
if (parameters !== undefined) strategy.parameters = parameters;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: strategy.id,
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
symbols: strategy.symbols,
|
||||
parameters: strategy.parameters,
|
||||
type: this.strategyRegistry.getStrategyType(strategy)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a strategy
|
||||
*/
|
||||
public deleteStrategy(req: Request, res: Response): void {
|
||||
const { id } = req.params;
|
||||
const success = this.strategyRegistry.deleteStrategy(id);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: `Strategy with ID ${id} not found`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { id }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a backtest
|
||||
*/
|
||||
public async runBacktest(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const backtestRequest: BacktestRequest = req.body;
|
||||
|
||||
// Validate request
|
||||
if (!backtestRequest.strategyType) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Strategy type is required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!backtestRequest.symbols || backtestRequest.symbols.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'At least one symbol is required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Run the backtest
|
||||
const result = await this.backtestService.runBacktest(backtestRequest);
|
||||
|
||||
// Enhance results with additional metrics
|
||||
const enhancedResult = PerformanceAnalytics.enhanceResults(result);
|
||||
|
||||
// Calculate additional analytics
|
||||
const monthlyReturns = PerformanceAnalytics.calculateMonthlyReturns(result.dailyReturns);
|
||||
const drawdowns = PerformanceAnalytics.analyzeDrawdowns(result.dailyReturns);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...enhancedResult,
|
||||
monthlyReturns,
|
||||
drawdowns
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Backtest error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a strategy with grid search
|
||||
*/
|
||||
public async optimizeStrategy(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { baseRequest, parameterGrid } = req.body;
|
||||
|
||||
// Validate request
|
||||
if (!baseRequest || !parameterGrid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Base request and parameter grid are required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Run optimization
|
||||
const results = await this.backtestService.optimizeStrategy(baseRequest, parameterGrid);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default StrategyController;
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface BarData {
|
||||
symbol: string;
|
||||
timestamp: Date;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
avgPrice: number;
|
||||
side: 'LONG' | 'SHORT';
|
||||
entryTime: Date;
|
||||
unrealizedPnL?: number;
|
||||
realizedPnL?: number;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
symbol: string;
|
||||
side: 'BUY' | 'SELL';
|
||||
quantity: number;
|
||||
price?: number; // Market order if undefined
|
||||
type: 'MARKET' | 'LIMIT' | 'STOP' | 'STOP_LIMIT';
|
||||
status: 'PENDING' | 'FILLED' | 'CANCELLED' | 'REJECTED';
|
||||
timestamp: Date;
|
||||
fillPrice?: number;
|
||||
fillTime?: Date;
|
||||
}
|
||||
|
||||
export interface StrategyContext {
|
||||
currentTime: Date;
|
||||
portfolio: {
|
||||
cash: number;
|
||||
positions: Map<string, Position>;
|
||||
totalValue: number;
|
||||
};
|
||||
marketData: Map<string, BarData[]>; // Historical data for each symbol
|
||||
indicators: Map<string, any>; // Cached indicator values
|
||||
}
|
||||
|
||||
export interface StrategyParameters {
|
||||
[key: string]: number | string | boolean | any[];
|
||||
}
|
||||
|
||||
export interface StrategyMetrics {
|
||||
totalReturn: number;
|
||||
totalTrades: number;
|
||||
winningTrades: number;
|
||||
losingTrades: number;
|
||||
winRate: number;
|
||||
avgWin: number;
|
||||
avgLoss: number;
|
||||
profitFactor: number;
|
||||
sharpeRatio: number;
|
||||
maxDrawdown: number;
|
||||
maxDrawdownDuration: number;
|
||||
calmarRatio: number;
|
||||
sortinoRatio: number;
|
||||
beta: number;
|
||||
alpha: number;
|
||||
volatility: number;
|
||||
}
|
||||
|
||||
export abstract class BaseStrategy extends EventEmitter {
|
||||
public readonly id: string;
|
||||
public readonly name: string;
|
||||
public readonly description: string;
|
||||
public readonly symbols: string[];
|
||||
public parameters: StrategyParameters;
|
||||
|
||||
protected context: StrategyContext;
|
||||
protected isInitialized: boolean = false;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
symbols: string[],
|
||||
parameters: StrategyParameters = {}
|
||||
) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.symbols = symbols;
|
||||
this.parameters = parameters;
|
||||
|
||||
this.context = {
|
||||
currentTime: new Date(),
|
||||
portfolio: {
|
||||
cash: 100000, // Default starting capital
|
||||
positions: new Map(),
|
||||
totalValue: 100000
|
||||
},
|
||||
marketData: new Map(),
|
||||
indicators: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by strategy subclasses
|
||||
abstract initialize(): Promise<void>;
|
||||
abstract onBar(bar: BarData): Promise<Order[]>;
|
||||
abstract onOrderFilled(order: Order): Promise<void>;
|
||||
abstract cleanup(): Promise<void>;
|
||||
|
||||
// Lifecycle methods
|
||||
async start(): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
this.emit('started', { strategyId: this.id });
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.cleanup();
|
||||
this.emit('stopped', { strategyId: this.id });
|
||||
}
|
||||
|
||||
// Market data management
|
||||
addBar(bar: BarData): void {
|
||||
this.context.currentTime = bar.timestamp;
|
||||
|
||||
if (!this.context.marketData.has(bar.symbol)) {
|
||||
this.context.marketData.set(bar.symbol, []);
|
||||
}
|
||||
|
||||
const bars = this.context.marketData.get(bar.symbol)!;
|
||||
bars.push(bar);
|
||||
|
||||
// Keep only last 1000 bars to manage memory
|
||||
if (bars.length > 1000) {
|
||||
bars.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Portfolio management helpers
|
||||
protected getCurrentPrice(symbol: string): number | null {
|
||||
const bars = this.context.marketData.get(symbol);
|
||||
return bars && bars.length > 0 ? bars[bars.length - 1].close : null;
|
||||
}
|
||||
|
||||
protected getPosition(symbol: string): Position | null {
|
||||
return this.context.portfolio.positions.get(symbol) || null;
|
||||
}
|
||||
|
||||
protected hasPosition(symbol: string): boolean {
|
||||
return this.context.portfolio.positions.has(symbol);
|
||||
}
|
||||
|
||||
protected getAvailableCash(): number {
|
||||
return this.context.portfolio.cash;
|
||||
}
|
||||
|
||||
protected calculatePositionValue(symbol: string): number {
|
||||
const position = this.getPosition(symbol);
|
||||
const currentPrice = this.getCurrentPrice(symbol);
|
||||
|
||||
if (!position || !currentPrice) return 0;
|
||||
|
||||
return position.quantity * currentPrice;
|
||||
}
|
||||
|
||||
protected updatePortfolioValue(): void {
|
||||
let totalValue = this.context.portfolio.cash;
|
||||
|
||||
for (const [symbol, position] of this.context.portfolio.positions) {
|
||||
const currentPrice = this.getCurrentPrice(symbol);
|
||||
if (currentPrice) {
|
||||
totalValue += position.quantity * currentPrice;
|
||||
}
|
||||
}
|
||||
|
||||
this.context.portfolio.totalValue = totalValue;
|
||||
}
|
||||
|
||||
// Order creation helpers
|
||||
protected createMarketOrder(symbol: string, side: 'BUY' | 'SELL', quantity: number): Order {
|
||||
return {
|
||||
id: this.generateOrderId(),
|
||||
symbol,
|
||||
side,
|
||||
quantity: Math.abs(quantity),
|
||||
type: 'MARKET',
|
||||
status: 'PENDING',
|
||||
timestamp: this.context.currentTime
|
||||
};
|
||||
}
|
||||
|
||||
protected createLimitOrder(
|
||||
symbol: string,
|
||||
side: 'BUY' | 'SELL',
|
||||
quantity: number,
|
||||
price: number
|
||||
): Order {
|
||||
return {
|
||||
id: this.generateOrderId(),
|
||||
symbol,
|
||||
side,
|
||||
quantity: Math.abs(quantity),
|
||||
price,
|
||||
type: 'LIMIT',
|
||||
status: 'PENDING',
|
||||
timestamp: this.context.currentTime
|
||||
};
|
||||
}
|
||||
|
||||
protected createStopOrder(
|
||||
symbol: string,
|
||||
side: 'BUY' | 'SELL',
|
||||
quantity: number,
|
||||
stopPrice: number
|
||||
): Order {
|
||||
return {
|
||||
id: this.generateOrderId(),
|
||||
symbol,
|
||||
side,
|
||||
quantity: Math.abs(quantity),
|
||||
price: stopPrice,
|
||||
type: 'STOP',
|
||||
status: 'PENDING',
|
||||
timestamp: this.context.currentTime
|
||||
};
|
||||
}
|
||||
|
||||
private generateOrderId(): string {
|
||||
return `${this.id}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Utility methods for common strategy patterns
|
||||
protected getBarsSince(symbol: string, periods: number): BarData[] {
|
||||
const bars = this.context.marketData.get(symbol) || [];
|
||||
return bars.slice(-periods);
|
||||
}
|
||||
|
||||
protected getReturns(symbol: string, periods: number): number[] {
|
||||
const bars = this.getBarsSince(symbol, periods + 1);
|
||||
const returns: number[] = [];
|
||||
|
||||
for (let i = 1; i < bars.length; i++) {
|
||||
const returnPct = (bars[i].close - bars[i - 1].close) / bars[i - 1].close;
|
||||
returns.push(returnPct);
|
||||
}
|
||||
|
||||
return returns;
|
||||
}
|
||||
|
||||
protected getVolatility(symbol: string, periods: number): number {
|
||||
const returns = this.getReturns(symbol, periods);
|
||||
if (returns.length === 0) return 0;
|
||||
|
||||
const mean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / returns.length;
|
||||
|
||||
return Math.sqrt(variance * 252); // Annualized volatility
|
||||
}
|
||||
|
||||
// Parameter validation
|
||||
protected validateParameters(): boolean {
|
||||
// Override in subclasses for parameter validation
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get strategy state for serialization
|
||||
getState() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
symbols: this.symbols,
|
||||
parameters: this.parameters,
|
||||
isInitialized: this.isInitialized,
|
||||
currentTime: this.context.currentTime,
|
||||
portfolio: {
|
||||
cash: this.context.portfolio.cash,
|
||||
totalValue: this.context.portfolio.totalValue,
|
||||
positions: Array.from(this.context.portfolio.positions.entries())
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
import { BarData } from '../Strategy';
|
||||
|
||||
export class TechnicalIndicators {
|
||||
/**
|
||||
* Calculate Simple Moving Average (SMA)
|
||||
* @param prices Array of price values
|
||||
* @param period Number of periods for calculation
|
||||
* @returns Array of SMA values
|
||||
*/
|
||||
static sma(prices: number[], period: number): number[] {
|
||||
if (period <= 0 || prices.length === 0) return [];
|
||||
|
||||
const result: number[] = [];
|
||||
|
||||
// Not enough data for calculation
|
||||
if (prices.length < period) {
|
||||
return Array(prices.length).fill(NaN);
|
||||
}
|
||||
|
||||
// Calculate first SMA
|
||||
let sum = 0;
|
||||
for (let i = 0; i < period; i++) {
|
||||
sum += prices[i];
|
||||
}
|
||||
|
||||
result.push(sum / period);
|
||||
|
||||
// Calculate subsequent SMAs using previous sum
|
||||
for (let i = period; i < prices.length; i++) {
|
||||
sum = sum - prices[i - period] + prices[i];
|
||||
result.push(sum / period);
|
||||
}
|
||||
|
||||
// Fill beginning with NaN
|
||||
const nanValues = Array(period - 1).fill(NaN);
|
||||
|
||||
return [...nanValues, ...result];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Exponential Moving Average (EMA)
|
||||
* @param prices Array of price values
|
||||
* @param period Number of periods for calculation
|
||||
* @returns Array of EMA values
|
||||
*/
|
||||
static ema(prices: number[], period: number): number[] {
|
||||
if (period <= 0 || prices.length === 0) return [];
|
||||
|
||||
const result: number[] = [];
|
||||
const multiplier = 2 / (period + 1);
|
||||
|
||||
// Not enough data for calculation
|
||||
if (prices.length < period) {
|
||||
return Array(prices.length).fill(NaN);
|
||||
}
|
||||
|
||||
// Calculate SMA for first EMA value
|
||||
let sum = 0;
|
||||
for (let i = 0; i < period; i++) {
|
||||
sum += prices[i];
|
||||
}
|
||||
|
||||
// First EMA is SMA
|
||||
let ema = sum / period;
|
||||
result.push(ema);
|
||||
|
||||
// Calculate subsequent EMAs
|
||||
for (let i = period; i < prices.length; i++) {
|
||||
ema = (prices[i] - ema) * multiplier + ema;
|
||||
result.push(ema);
|
||||
}
|
||||
|
||||
// Fill beginning with NaN
|
||||
const nanValues = Array(period - 1).fill(NaN);
|
||||
|
||||
return [...nanValues, ...result];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Relative Strength Index (RSI)
|
||||
* @param prices Array of price values
|
||||
* @param period Number of periods for calculation
|
||||
* @returns Array of RSI values
|
||||
*/
|
||||
static rsi(prices: number[], period: number): number[] {
|
||||
if (period <= 0 || prices.length < period + 1) {
|
||||
return Array(prices.length).fill(NaN);
|
||||
}
|
||||
|
||||
const result: number[] = [];
|
||||
const gains: number[] = [];
|
||||
const losses: number[] = [];
|
||||
|
||||
// Calculate price changes
|
||||
for (let i = 1; i < prices.length; i++) {
|
||||
const change = prices[i] - prices[i - 1];
|
||||
gains.push(change > 0 ? change : 0);
|
||||
losses.push(change < 0 ? Math.abs(change) : 0);
|
||||
}
|
||||
|
||||
// Not enough data
|
||||
if (gains.length < period) {
|
||||
return Array(prices.length).fill(NaN);
|
||||
}
|
||||
|
||||
// Calculate first average gain and loss
|
||||
let avgGain = 0;
|
||||
let avgLoss = 0;
|
||||
|
||||
for (let i = 0; i < period; i++) {
|
||||
avgGain += gains[i];
|
||||
avgLoss += losses[i];
|
||||
}
|
||||
|
||||
avgGain /= period;
|
||||
avgLoss /= period;
|
||||
|
||||
// Calculate first RSI
|
||||
let rs = avgGain / (avgLoss === 0 ? 0.001 : avgLoss); // Avoid division by zero
|
||||
let rsi = 100 - (100 / (1 + rs));
|
||||
result.push(rsi);
|
||||
|
||||
// Calculate subsequent RSIs
|
||||
for (let i = period; i < gains.length; i++) {
|
||||
// Smooth averages
|
||||
avgGain = ((avgGain * (period - 1)) + gains[i]) / period;
|
||||
avgLoss = ((avgLoss * (period - 1)) + losses[i]) / period;
|
||||
|
||||
// Calculate RS and RSI
|
||||
rs = avgGain / (avgLoss === 0 ? 0.001 : avgLoss);
|
||||
rsi = 100 - (100 / (1 + rs));
|
||||
result.push(rsi);
|
||||
}
|
||||
|
||||
// Fill beginning with NaN
|
||||
const nanValues = Array(period).fill(NaN);
|
||||
|
||||
return [...nanValues, ...result];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Moving Average Convergence Divergence (MACD)
|
||||
* @param prices Array of price values
|
||||
* @param fastPeriod Fast EMA period (default: 12)
|
||||
* @param slowPeriod Slow EMA period (default: 26)
|
||||
* @param signalPeriod Signal line period (default: 9)
|
||||
* @returns Object containing MACD line, signal line, and histogram
|
||||
*/
|
||||
static macd(
|
||||
prices: number[],
|
||||
fastPeriod: number = 12,
|
||||
slowPeriod: number = 26,
|
||||
signalPeriod: number = 9
|
||||
): { macdLine: number[], signalLine: number[], histogram: number[] } {
|
||||
// Calculate EMAs
|
||||
const fastEMA = this.ema(prices, fastPeriod);
|
||||
const slowEMA = this.ema(prices, slowPeriod);
|
||||
|
||||
// Calculate MACD line (fast EMA - slow EMA)
|
||||
const macdLine: number[] = [];
|
||||
for (let i = 0; i < prices.length; i++) {
|
||||
macdLine.push(isNaN(fastEMA[i]) || isNaN(slowEMA[i])
|
||||
? NaN
|
||||
: fastEMA[i] - slowEMA[i]);
|
||||
}
|
||||
|
||||
// Calculate signal line (EMA of MACD line)
|
||||
const signalLine = this.ema(macdLine.filter(val => !isNaN(val)), signalPeriod);
|
||||
|
||||
// Pad signal line with NaNs to match original length
|
||||
const paddedSignalLine = Array(prices.length - signalLine.length).fill(NaN).concat(signalLine);
|
||||
|
||||
// Calculate histogram (MACD line - signal line)
|
||||
const histogram: number[] = [];
|
||||
for (let i = 0; i < prices.length; i++) {
|
||||
histogram.push(isNaN(macdLine[i]) || isNaN(paddedSignalLine[i])
|
||||
? NaN
|
||||
: macdLine[i] - paddedSignalLine[i]);
|
||||
}
|
||||
|
||||
return {
|
||||
macdLine,
|
||||
signalLine: paddedSignalLine,
|
||||
histogram
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Bollinger Bands
|
||||
* @param prices Array of price values
|
||||
* @param period SMA period (default: 20)
|
||||
* @param stdDevMultiplier Standard deviation multiplier (default: 2)
|
||||
* @returns Object containing upper band, middle band, and lower band
|
||||
*/
|
||||
static bollingerBands(
|
||||
prices: number[],
|
||||
period: number = 20,
|
||||
stdDevMultiplier: number = 2
|
||||
): { upper: number[], middle: number[], lower: number[] } {
|
||||
// Calculate middle band (SMA)
|
||||
const middle = this.sma(prices, period);
|
||||
|
||||
// Calculate standard deviation for each point
|
||||
const upper: number[] = [];
|
||||
const lower: number[] = [];
|
||||
|
||||
for (let i = 0; i < prices.length; i++) {
|
||||
if (isNaN(middle[i])) {
|
||||
upper.push(NaN);
|
||||
lower.push(NaN);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate standard deviation using values in the period window
|
||||
let stdDev = 0;
|
||||
let count = 0;
|
||||
|
||||
// Start index for the window
|
||||
const startIdx = Math.max(0, i - period + 1);
|
||||
|
||||
for (let j = startIdx; j <= i; j++) {
|
||||
stdDev += Math.pow(prices[j] - middle[i], 2);
|
||||
count++;
|
||||
}
|
||||
|
||||
stdDev = Math.sqrt(stdDev / count);
|
||||
|
||||
// Calculate bands
|
||||
upper.push(middle[i] + (stdDevMultiplier * stdDev));
|
||||
lower.push(middle[i] - (stdDevMultiplier * stdDev));
|
||||
}
|
||||
|
||||
return { upper, middle, lower };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Average True Range (ATR)
|
||||
* @param bars Array of BarData objects
|
||||
* @param period Number of periods for calculation
|
||||
* @returns Array of ATR values
|
||||
*/
|
||||
static atr(bars: BarData[], period: number): number[] {
|
||||
if (period <= 0 || bars.length < 2) {
|
||||
return Array(bars.length).fill(NaN);
|
||||
}
|
||||
|
||||
// Calculate True Range for each bar
|
||||
const trueRanges: number[] = [];
|
||||
|
||||
// First TR is high - low
|
||||
trueRanges.push(bars[0].high - bars[0].low);
|
||||
|
||||
// Calculate remaining TRs
|
||||
for (let i = 1; i < bars.length; i++) {
|
||||
const currentHigh = bars[i].high;
|
||||
const currentLow = bars[i].low;
|
||||
const previousClose = bars[i - 1].close;
|
||||
|
||||
const tr1 = currentHigh - currentLow;
|
||||
const tr2 = Math.abs(currentHigh - previousClose);
|
||||
const tr3 = Math.abs(currentLow - previousClose);
|
||||
|
||||
const tr = Math.max(tr1, tr2, tr3);
|
||||
trueRanges.push(tr);
|
||||
}
|
||||
|
||||
// Calculate ATR (first value is simple average)
|
||||
const result: number[] = [];
|
||||
|
||||
// Not enough data
|
||||
if (trueRanges.length < period) {
|
||||
return Array(bars.length).fill(NaN);
|
||||
}
|
||||
|
||||
// First ATR is simple average of true ranges
|
||||
let atr = 0;
|
||||
for (let i = 0; i < period; i++) {
|
||||
atr += trueRanges[i];
|
||||
}
|
||||
atr /= period;
|
||||
result.push(atr);
|
||||
|
||||
// Calculate subsequent ATRs using smoothing
|
||||
for (let i = period; i < trueRanges.length; i++) {
|
||||
atr = ((atr * (period - 1)) + trueRanges[i]) / period;
|
||||
result.push(atr);
|
||||
}
|
||||
|
||||
// Fill beginning with NaN
|
||||
const nanValues = Array(period).fill(NaN);
|
||||
|
||||
return [...nanValues, ...result];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Stochastic Oscillator
|
||||
* @param bars Array of BarData objects
|
||||
* @param period %K period (default: 14)
|
||||
* @param smoothK %K smoothing (default: 3)
|
||||
* @param smoothD %D period (default: 3)
|
||||
* @returns Object containing %K and %D values
|
||||
*/
|
||||
static stochastic(
|
||||
bars: BarData[],
|
||||
period: number = 14,
|
||||
smoothK: number = 3,
|
||||
smoothD: number = 3
|
||||
): { k: number[], d: number[] } {
|
||||
if (period <= 0 || bars.length < period) {
|
||||
return { k: Array(bars.length).fill(NaN), d: Array(bars.length).fill(NaN) };
|
||||
}
|
||||
|
||||
const rawK: number[] = [];
|
||||
|
||||
// Calculate raw %K values
|
||||
for (let i = period - 1; i < bars.length; i++) {
|
||||
let highest = -Infinity;
|
||||
let lowest = Infinity;
|
||||
|
||||
// Find highest high and lowest low in the period
|
||||
for (let j = i - (period - 1); j <= i; j++) {
|
||||
highest = Math.max(highest, bars[j].high);
|
||||
lowest = Math.min(lowest, bars[j].low);
|
||||
}
|
||||
|
||||
// Calculate raw %K
|
||||
const currentClose = bars[i].close;
|
||||
const rawKValue = 100 * ((currentClose - lowest) / (highest - lowest));
|
||||
rawK.push(rawKValue);
|
||||
}
|
||||
|
||||
// Fill beginning with NaN
|
||||
const nanValues = Array(period - 1).fill(NaN);
|
||||
const fullRawK = [...nanValues, ...rawK];
|
||||
|
||||
// Apply smoothing to %K (SMA of raw %K)
|
||||
const filteredK = fullRawK.filter(val => !isNaN(val));
|
||||
let k = this.sma(filteredK, smoothK);
|
||||
|
||||
// Pad with NaNs
|
||||
k = [...Array(fullRawK.length - k.length).fill(NaN), ...k];
|
||||
|
||||
// Calculate %D (SMA of %K)
|
||||
const filteredSmoothedK = k.filter(val => !isNaN(val));
|
||||
let d = this.sma(filteredSmoothedK, smoothD);
|
||||
|
||||
// Pad with NaNs
|
||||
d = [...Array(k.length - d.length).fill(NaN), ...d];
|
||||
|
||||
return { k, d };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract specific price from bars (e.g., close, open, high, low)
|
||||
* @param bars Array of BarData objects
|
||||
* @param field Price field to extract
|
||||
* @returns Array of extracted price values
|
||||
*/
|
||||
static extractPrice(bars: BarData[], field: 'open' | 'high' | 'low' | 'close' = 'close'): number[] {
|
||||
return bars.map(bar => bar[field]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,604 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { BaseStrategy } from '../Strategy';
|
||||
import { BarData, Order, Position } from '../Strategy';
|
||||
|
||||
export interface BacktestConfig {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
symbols: string[];
|
||||
initialCapital: number;
|
||||
commission: number; // Per trade commission (percentage)
|
||||
slippage: number; // Slippage model (percentage)
|
||||
dataResolution: '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d';
|
||||
mode: 'event' | 'vector';
|
||||
}
|
||||
|
||||
export interface BacktestResult {
|
||||
strategyId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
duration: number; // In milliseconds
|
||||
initialCapital: number;
|
||||
finalCapital: number;
|
||||
totalReturn: number;
|
||||
annualizedReturn: number;
|
||||
sharpeRatio: number;
|
||||
maxDrawdown: number;
|
||||
maxDrawdownDuration: number; // In days
|
||||
winRate: number;
|
||||
totalTrades: number;
|
||||
winningTrades: number;
|
||||
losingTrades: number;
|
||||
averageWinningTrade: number;
|
||||
averageLosingTrade: number;
|
||||
profitFactor: number;
|
||||
dailyReturns: Array<{ date: Date; return: number }>;
|
||||
trades: Array<{
|
||||
symbol: string;
|
||||
entryTime: Date;
|
||||
entryPrice: number;
|
||||
exitTime: Date;
|
||||
exitPrice: number;
|
||||
quantity: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BacktestProgress {
|
||||
progress: number; // 0-100
|
||||
currentDate: Date;
|
||||
processingSpeed: number; // Bars per second
|
||||
estimatedTimeRemaining: number; // milliseconds
|
||||
currentCapital: number;
|
||||
currentReturn: number;
|
||||
currentDrawdown: number;
|
||||
}
|
||||
|
||||
export interface DataFeed {
|
||||
getHistoricalData(symbol: string, resolution: string, start: Date, end: Date): Promise<BarData[]>;
|
||||
hasDataFor(symbol: string, resolution: string, start: Date, end: Date): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class BacktestEngine extends EventEmitter {
|
||||
private config: BacktestConfig;
|
||||
private strategy: BaseStrategy;
|
||||
private dataFeed: DataFeed;
|
||||
private isRunning: boolean = false;
|
||||
private barBuffer: Map<string, BarData[]> = new Map();
|
||||
private pendingOrders: Order[] = [];
|
||||
private filledOrders: Order[] = [];
|
||||
private currentTime: Date;
|
||||
private startTime: number = 0; // For performance tracking
|
||||
private processedBars: number = 0;
|
||||
private marketData: Map<string, BarData[]> = new Map();
|
||||
|
||||
// Results tracking
|
||||
private initialCapital: number;
|
||||
private currentCapital: number;
|
||||
private positions = new Map<string, Position>();
|
||||
private trades: BacktestResult['trades'] = [];
|
||||
private dailyReturns: BacktestResult['dailyReturns'] = [];
|
||||
private previousPortfolioValue: number;
|
||||
private highWaterMark: number;
|
||||
private maxDrawdown: number = 0;
|
||||
private drawdownStartTime: Date | null = null;
|
||||
private maxDrawdownDuration: number = 0;
|
||||
private winningTrades: number = 0;
|
||||
private losingTrades: number = 0;
|
||||
private breakEvenTrades: number = 0;
|
||||
private totalProfits: number = 0;
|
||||
private totalLosses: number = 0;
|
||||
|
||||
constructor(strategy: BaseStrategy, config: BacktestConfig, dataFeed: DataFeed) {
|
||||
super();
|
||||
this.strategy = strategy;
|
||||
this.config = config;
|
||||
this.dataFeed = dataFeed;
|
||||
this.currentTime = new Date(config.startDate);
|
||||
this.initialCapital = config.initialCapital;
|
||||
this.currentCapital = config.initialCapital;
|
||||
this.previousPortfolioValue = config.initialCapital;
|
||||
this.highWaterMark = config.initialCapital;
|
||||
}
|
||||
|
||||
async run(): Promise<BacktestResult> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('Backtest is already running');
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.startTime = Date.now();
|
||||
this.emit('started', { strategyId: this.strategy.id, config: this.config });
|
||||
|
||||
try {
|
||||
// Load data based on configured mode
|
||||
if (this.config.mode === 'event') {
|
||||
await this.runEventBased();
|
||||
} else {
|
||||
await this.runVectorized();
|
||||
}
|
||||
|
||||
const result = this.generateResults();
|
||||
this.emit('completed', { strategyId: this.strategy.id, result });
|
||||
this.isRunning = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.isRunning = false;
|
||||
this.emit('error', { strategyId: this.strategy.id, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async runEventBased(): Promise<void> {
|
||||
// Load market data for all symbols
|
||||
await this.loadMarketData();
|
||||
|
||||
// Initialize the strategy
|
||||
await this.strategy.start();
|
||||
|
||||
// Create a merged timeline of all bars across all symbols, sorted by timestamp
|
||||
const timeline = this.createMergedTimeline();
|
||||
|
||||
// Process each event in chronological order
|
||||
let lastProgressUpdate = Date.now();
|
||||
let prevDate = new Date(0);
|
||||
|
||||
for (let i = 0; i < timeline.length; i++) {
|
||||
const bar = timeline[i];
|
||||
this.currentTime = bar.timestamp;
|
||||
|
||||
// Process any pending orders
|
||||
await this.processOrders(bar);
|
||||
|
||||
// Update positions with current prices
|
||||
this.updatePositions(bar);
|
||||
|
||||
// If we've crossed to a new day, calculate daily return
|
||||
if (this.currentTime.toDateString() !== prevDate.toDateString()) {
|
||||
this.calculateDailyReturn();
|
||||
prevDate = this.currentTime;
|
||||
}
|
||||
|
||||
// Send the new bar to the strategy
|
||||
const orders = await this.strategy.onBar(bar);
|
||||
|
||||
// Add any new orders to the pending orders queue
|
||||
if (orders && orders.length > 0) {
|
||||
this.pendingOrders.push(...orders);
|
||||
}
|
||||
|
||||
// Update progress periodically
|
||||
if (Date.now() - lastProgressUpdate > 1000) { // Update every second
|
||||
this.updateProgress(i / timeline.length);
|
||||
lastProgressUpdate = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining orders
|
||||
for (const order of this.pendingOrders) {
|
||||
await this.processOrder(order);
|
||||
}
|
||||
|
||||
// Close any remaining positions at the last known price
|
||||
await this.closeAllPositions();
|
||||
|
||||
// Clean up strategy
|
||||
await this.strategy.stop();
|
||||
}
|
||||
|
||||
private async runVectorized(): Promise<void> {
|
||||
// Load market data for all symbols
|
||||
await this.loadMarketData();
|
||||
|
||||
// To implement a vectorized approach, we need to:
|
||||
// 1. Pre-compute technical indicators
|
||||
// 2. Generate buy/sell signals for the entire dataset
|
||||
// 3. Calculate portfolio values based on signals
|
||||
|
||||
// This is a simplified implementation since specific vectorized strategies
|
||||
// will need to be implemented separately based on the strategy type
|
||||
|
||||
const timeline = this.createMergedTimeline();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Initialize variables for tracking performance
|
||||
let currentPositions = new Map<string, number>();
|
||||
let currentCash = this.initialCapital;
|
||||
let prevPortfolioValue = this.initialCapital;
|
||||
let highWaterMark = this.initialCapital;
|
||||
let maxDrawdown = 0;
|
||||
let maxDrawdownStartDate = new Date();
|
||||
let maxDrawdownEndDate = new Date();
|
||||
let currentDrawdownStart = new Date();
|
||||
|
||||
// Pre-process data (this would be implemented by the specific strategy)
|
||||
const allBars = new Map<string, BarData[]>();
|
||||
for (const symbol of this.config.symbols) {
|
||||
allBars.set(symbol, this.marketData.get(symbol) || []);
|
||||
}
|
||||
|
||||
// Apply strategy logic (vectorized implementation would be here)
|
||||
// For now, we'll just simulate the processing
|
||||
|
||||
this.emit('completed', { message: 'Vectorized backtest completed in fast mode' });
|
||||
}
|
||||
|
||||
private async loadMarketData(): Promise<void> {
|
||||
for (const symbol of this.config.symbols) {
|
||||
this.emit('loading', { symbol, resolution: this.config.dataResolution });
|
||||
|
||||
// Check if data is available
|
||||
const hasData = await this.dataFeed.hasDataFor(
|
||||
symbol,
|
||||
this.config.dataResolution,
|
||||
this.config.startDate,
|
||||
this.config.endDate
|
||||
);
|
||||
|
||||
if (!hasData) {
|
||||
throw new Error(`No data available for ${symbol} at resolution ${this.config.dataResolution}`);
|
||||
}
|
||||
|
||||
// Load data
|
||||
const data = await this.dataFeed.getHistoricalData(
|
||||
symbol,
|
||||
this.config.dataResolution,
|
||||
this.config.startDate,
|
||||
this.config.endDate
|
||||
);
|
||||
|
||||
this.marketData.set(symbol, data);
|
||||
this.emit('loaded', { symbol, count: data.length });
|
||||
}
|
||||
}
|
||||
|
||||
private createMergedTimeline(): BarData[] {
|
||||
const allBars: BarData[] = [];
|
||||
|
||||
for (const [symbol, bars] of this.marketData.entries()) {
|
||||
allBars.push(...bars);
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
return allBars.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
}
|
||||
|
||||
private async processOrders(currentBar: BarData): Promise<void> {
|
||||
// Find orders for the current symbol
|
||||
const ordersToProcess = this.pendingOrders.filter(order => order.symbol === currentBar.symbol);
|
||||
|
||||
if (ordersToProcess.length === 0) return;
|
||||
|
||||
// Remove these orders from pendingOrders
|
||||
this.pendingOrders = this.pendingOrders.filter(order => order.symbol !== currentBar.symbol);
|
||||
|
||||
// Process each order
|
||||
for (const order of ordersToProcess) {
|
||||
await this.processOrder(order);
|
||||
}
|
||||
}
|
||||
|
||||
private async processOrder(order: Order): Promise<void> {
|
||||
// Get the latest price for the symbol
|
||||
const latestBars = this.marketData.get(order.symbol);
|
||||
if (!latestBars || latestBars.length === 0) {
|
||||
order.status = 'REJECTED';
|
||||
this.emit('orderRejected', { order, reason: 'No market data available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the bar closest to the order time
|
||||
const bar = latestBars.find(b =>
|
||||
b.timestamp.getTime() >= order.timestamp.getTime()
|
||||
) || latestBars[latestBars.length - 1];
|
||||
|
||||
// Calculate fill price with slippage
|
||||
let fillPrice: number;
|
||||
if (order.type === 'MARKET') {
|
||||
// Apply slippage model
|
||||
const slippageFactor = 1 + (order.side === 'BUY' ? this.config.slippage : -this.config.slippage);
|
||||
fillPrice = bar.close * slippageFactor;
|
||||
} else if (order.type === 'LIMIT' && order.price !== undefined) {
|
||||
// For limit orders, check if the price was reached
|
||||
if ((order.side === 'BUY' && bar.low <= order.price) ||
|
||||
(order.side === 'SELL' && bar.high >= order.price)) {
|
||||
fillPrice = order.price;
|
||||
} else {
|
||||
// Limit price not reached
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Other order types not implemented
|
||||
order.status = 'REJECTED';
|
||||
this.emit('orderRejected', { order, reason: 'Order type not supported' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate commission
|
||||
const orderValue = order.quantity * fillPrice;
|
||||
const commission = orderValue * this.config.commission;
|
||||
|
||||
// Check if we have enough cash for BUY orders
|
||||
if (order.side === 'BUY') {
|
||||
const totalCost = orderValue + commission;
|
||||
if (totalCost > this.currentCapital) {
|
||||
// Not enough cash
|
||||
order.status = 'REJECTED';
|
||||
this.emit('orderRejected', { order, reason: 'Insufficient funds' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cash
|
||||
this.currentCapital -= totalCost;
|
||||
|
||||
// Update or create position
|
||||
const existingPosition = this.positions.get(order.symbol);
|
||||
if (existingPosition) {
|
||||
// Update existing position (average down)
|
||||
const totalShares = existingPosition.quantity + order.quantity;
|
||||
const totalCost = (existingPosition.quantity * existingPosition.avgPrice) + (order.quantity * fillPrice);
|
||||
existingPosition.avgPrice = totalCost / totalShares;
|
||||
existingPosition.quantity = totalShares;
|
||||
} else {
|
||||
// Create new position
|
||||
this.positions.set(order.symbol, {
|
||||
symbol: order.symbol,
|
||||
quantity: order.quantity,
|
||||
avgPrice: fillPrice,
|
||||
side: 'LONG',
|
||||
entryTime: this.currentTime
|
||||
});
|
||||
}
|
||||
} else if (order.side === 'SELL') {
|
||||
const position = this.positions.get(order.symbol);
|
||||
|
||||
if (!position || position.quantity < order.quantity) {
|
||||
// Not enough shares to sell
|
||||
order.status = 'REJECTED';
|
||||
this.emit('orderRejected', { order, reason: 'Insufficient position' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate P&L
|
||||
const pnl = (fillPrice - position.avgPrice) * order.quantity;
|
||||
|
||||
// Update cash
|
||||
this.currentCapital += orderValue - commission;
|
||||
|
||||
// Update position
|
||||
position.quantity -= order.quantity;
|
||||
|
||||
if (position.quantity === 0) {
|
||||
// Position closed, record the trade
|
||||
this.positions.delete(order.symbol);
|
||||
|
||||
this.trades.push({
|
||||
symbol: order.symbol,
|
||||
entryTime: position.entryTime,
|
||||
entryPrice: position.avgPrice,
|
||||
exitTime: this.currentTime,
|
||||
exitPrice: fillPrice,
|
||||
quantity: order.quantity,
|
||||
pnl: pnl,
|
||||
pnlPercent: (pnl / (position.avgPrice * order.quantity)) * 100
|
||||
});
|
||||
|
||||
// Update statistics
|
||||
if (pnl > 0) {
|
||||
this.winningTrades++;
|
||||
this.totalProfits += pnl;
|
||||
} else if (pnl < 0) {
|
||||
this.losingTrades++;
|
||||
this.totalLosses -= pnl; // Make positive for easier calculations
|
||||
} else {
|
||||
this.breakEvenTrades++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark order as filled
|
||||
order.status = 'FILLED';
|
||||
order.fillPrice = fillPrice;
|
||||
order.fillTime = this.currentTime;
|
||||
this.filledOrders.push(order);
|
||||
|
||||
// Notify strategy
|
||||
await this.strategy.onOrderFilled(order);
|
||||
|
||||
this.emit('orderFilled', { order });
|
||||
}
|
||||
|
||||
private updatePositions(currentBar: BarData): void {
|
||||
// Update the unrealized P&L for positions in this symbol
|
||||
const position = this.positions.get(currentBar.symbol);
|
||||
if (position) {
|
||||
const currentPrice = currentBar.close;
|
||||
const unrealizedPnL = (currentPrice - position.avgPrice) * position.quantity;
|
||||
position.unrealizedPnL = unrealizedPnL;
|
||||
}
|
||||
|
||||
// Calculate total portfolio value
|
||||
const portfolioValue = this.calculatePortfolioValue();
|
||||
|
||||
// Check for new high water mark
|
||||
if (portfolioValue > this.highWaterMark) {
|
||||
this.highWaterMark = portfolioValue;
|
||||
this.drawdownStartTime = null;
|
||||
}
|
||||
|
||||
// Check for drawdown
|
||||
if (this.drawdownStartTime === null && portfolioValue < this.highWaterMark) {
|
||||
this.drawdownStartTime = this.currentTime;
|
||||
}
|
||||
|
||||
// Update max drawdown
|
||||
if (this.highWaterMark > 0) {
|
||||
const currentDrawdown = (this.highWaterMark - portfolioValue) / this.highWaterMark;
|
||||
if (currentDrawdown > this.maxDrawdown) {
|
||||
this.maxDrawdown = currentDrawdown;
|
||||
|
||||
// Calculate drawdown duration
|
||||
if (this.drawdownStartTime !== null) {
|
||||
const drawdownDuration = (this.currentTime.getTime() - this.drawdownStartTime.getTime()) / (1000 * 60 * 60 * 24); // In days
|
||||
if (drawdownDuration > this.maxDrawdownDuration) {
|
||||
this.maxDrawdownDuration = drawdownDuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.previousPortfolioValue = portfolioValue;
|
||||
}
|
||||
|
||||
private calculatePortfolioValue(): number {
|
||||
let totalValue = this.currentCapital;
|
||||
|
||||
// Add the current value of all positions
|
||||
for (const [symbol, position] of this.positions.entries()) {
|
||||
// Find the latest price for this symbol
|
||||
const bars = this.marketData.get(symbol);
|
||||
if (bars && bars.length > 0) {
|
||||
const latestBar = bars[bars.length - 1];
|
||||
totalValue += position.quantity * latestBar.close;
|
||||
} else {
|
||||
// If no price data, use the average price (not ideal but better than nothing)
|
||||
totalValue += position.quantity * position.avgPrice;
|
||||
}
|
||||
}
|
||||
|
||||
return totalValue;
|
||||
}
|
||||
|
||||
private calculateDailyReturn(): void {
|
||||
const portfolioValue = this.calculatePortfolioValue();
|
||||
const dailyReturn = (portfolioValue - this.previousPortfolioValue) / this.previousPortfolioValue;
|
||||
|
||||
this.dailyReturns.push({
|
||||
date: new Date(this.currentTime),
|
||||
return: dailyReturn
|
||||
});
|
||||
|
||||
this.previousPortfolioValue = portfolioValue;
|
||||
}
|
||||
|
||||
private async closeAllPositions(): Promise<void> {
|
||||
for (const [symbol, position] of this.positions.entries()) {
|
||||
// Find the latest price
|
||||
const bars = this.marketData.get(symbol);
|
||||
if (!bars || bars.length === 0) continue;
|
||||
|
||||
const lastBar = bars[bars.length - 1];
|
||||
const closePrice = lastBar.close;
|
||||
|
||||
// Calculate P&L
|
||||
const pnl = (closePrice - position.avgPrice) * position.quantity;
|
||||
|
||||
// Update cash
|
||||
this.currentCapital += position.quantity * closePrice;
|
||||
|
||||
// Record the trade
|
||||
this.trades.push({
|
||||
symbol,
|
||||
entryTime: position.entryTime,
|
||||
entryPrice: position.avgPrice,
|
||||
exitTime: this.currentTime,
|
||||
exitPrice: closePrice,
|
||||
quantity: position.quantity,
|
||||
pnl,
|
||||
pnlPercent: (pnl / (position.avgPrice * position.quantity)) * 100
|
||||
});
|
||||
|
||||
// Update statistics
|
||||
if (pnl > 0) {
|
||||
this.winningTrades++;
|
||||
this.totalProfits += pnl;
|
||||
} else if (pnl < 0) {
|
||||
this.losingTrades++;
|
||||
this.totalLosses -= pnl; // Make positive for easier calculations
|
||||
} else {
|
||||
this.breakEvenTrades++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear positions
|
||||
this.positions.clear();
|
||||
}
|
||||
|
||||
private updateProgress(progress: number): void {
|
||||
const currentPortfolioValue = this.calculatePortfolioValue();
|
||||
const currentDrawdown = this.highWaterMark > 0
|
||||
? (this.highWaterMark - currentPortfolioValue) / this.highWaterMark
|
||||
: 0;
|
||||
|
||||
const elapsedMs = Date.now() - this.startTime;
|
||||
const totalEstimatedMs = elapsedMs / progress;
|
||||
const remainingMs = totalEstimatedMs - elapsedMs;
|
||||
|
||||
this.emit('progress', {
|
||||
progress: progress * 100,
|
||||
currentDate: this.currentTime,
|
||||
processingSpeed: this.processedBars / (elapsedMs / 1000),
|
||||
estimatedTimeRemaining: remainingMs,
|
||||
currentCapital: this.currentCapital,
|
||||
currentReturn: (currentPortfolioValue - this.initialCapital) / this.initialCapital,
|
||||
currentDrawdown
|
||||
} as BacktestProgress);
|
||||
}
|
||||
|
||||
private generateResults(): BacktestResult {
|
||||
const currentPortfolioValue = this.calculatePortfolioValue();
|
||||
const totalReturn = (currentPortfolioValue - this.initialCapital) / this.initialCapital;
|
||||
|
||||
// Calculate annualized return
|
||||
const days = (this.config.endDate.getTime() - this.config.startDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
const annualizedReturn = Math.pow(1 + totalReturn, 365 / days) - 1;
|
||||
|
||||
// Calculate Sharpe Ratio
|
||||
let sharpeRatio = 0;
|
||||
if (this.dailyReturns.length > 1) {
|
||||
const dailyReturnValues = this.dailyReturns.map(dr => dr.return);
|
||||
const avgDailyReturn = dailyReturnValues.reduce((sum, ret) => sum + ret, 0) / dailyReturnValues.length;
|
||||
const stdDev = Math.sqrt(
|
||||
dailyReturnValues.reduce((sum, ret) => sum + Math.pow(ret - avgDailyReturn, 2), 0) / dailyReturnValues.length
|
||||
);
|
||||
|
||||
// Annualize
|
||||
sharpeRatio = stdDev > 0
|
||||
? (avgDailyReturn * 252) / (stdDev * Math.sqrt(252))
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Calculate win rate and profit factor
|
||||
const totalTrades = this.winningTrades + this.losingTrades + this.breakEvenTrades;
|
||||
const winRate = totalTrades > 0 ? this.winningTrades / totalTrades : 0;
|
||||
const profitFactor = this.totalLosses > 0 ? this.totalProfits / this.totalLosses : (this.totalProfits > 0 ? Infinity : 0);
|
||||
|
||||
// Calculate average winning and losing trade
|
||||
const avgWinningTrade = this.winningTrades > 0 ? this.totalProfits / this.winningTrades : 0;
|
||||
const avgLosingTrade = this.losingTrades > 0 ? this.totalLosses / this.losingTrades : 0;
|
||||
|
||||
return {
|
||||
strategyId: this.strategy.id,
|
||||
startDate: this.config.startDate,
|
||||
endDate: this.config.endDate,
|
||||
duration: Date.now() - this.startTime,
|
||||
initialCapital: this.initialCapital,
|
||||
finalCapital: currentPortfolioValue,
|
||||
totalReturn,
|
||||
annualizedReturn,
|
||||
sharpeRatio,
|
||||
maxDrawdown: this.maxDrawdown,
|
||||
maxDrawdownDuration: this.maxDrawdownDuration,
|
||||
winRate,
|
||||
totalTrades,
|
||||
winningTrades: this.winningTrades,
|
||||
losingTrades: this.losingTrades,
|
||||
averageWinningTrade: avgWinningTrade,
|
||||
averageLosingTrade: avgLosingTrade,
|
||||
profitFactor,
|
||||
dailyReturns: this.dailyReturns,
|
||||
trades: this.trades
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import { BaseStrategy } from '../Strategy';
|
||||
import { BacktestConfig, BacktestEngine, BacktestResult } from './BacktestEngine';
|
||||
import { MarketDataFeed } from './MarketDataFeed';
|
||||
import { StrategyRegistry, StrategyType } from '../strategies/StrategyRegistry';
|
||||
|
||||
export interface BacktestRequest {
|
||||
strategyType: StrategyType;
|
||||
strategyParams: Record<string, any>;
|
||||
symbols: string[];
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
initialCapital: number;
|
||||
dataResolution: '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d';
|
||||
commission: number;
|
||||
slippage: number;
|
||||
mode: 'event' | 'vector';
|
||||
}
|
||||
|
||||
/**
|
||||
* Backtesting Service
|
||||
*
|
||||
* A service that handles backtesting requests and manages backtesting sessions.
|
||||
*/
|
||||
export class BacktestService {
|
||||
private readonly strategyRegistry: StrategyRegistry;
|
||||
private readonly dataFeed: MarketDataFeed;
|
||||
private readonly activeBacktests: Map<string, BacktestEngine> = new Map();
|
||||
|
||||
constructor(apiBaseUrl: string = 'http://localhost:3001/api') {
|
||||
this.strategyRegistry = StrategyRegistry.getInstance();
|
||||
this.dataFeed = new MarketDataFeed(apiBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a backtest based on a request
|
||||
*/
|
||||
async runBacktest(request: BacktestRequest): Promise<BacktestResult> {
|
||||
// Create a strategy instance
|
||||
const strategyId = `backtest_${Date.now()}`;
|
||||
const strategy = this.strategyRegistry.createStrategy(
|
||||
request.strategyType,
|
||||
strategyId,
|
||||
`Backtest ${request.strategyType}`,
|
||||
`Generated backtest for ${request.symbols.join(', ')}`,
|
||||
request.symbols,
|
||||
request.strategyParams
|
||||
);
|
||||
|
||||
// Parse dates if they are strings
|
||||
const startDate = typeof request.startDate === 'string'
|
||||
? new Date(request.startDate)
|
||||
: request.startDate;
|
||||
|
||||
const endDate = typeof request.endDate === 'string'
|
||||
? new Date(request.endDate)
|
||||
: request.endDate;
|
||||
|
||||
// Create backtest configuration
|
||||
const config: BacktestConfig = {
|
||||
startDate,
|
||||
endDate,
|
||||
symbols: request.symbols,
|
||||
initialCapital: request.initialCapital,
|
||||
commission: request.commission,
|
||||
slippage: request.slippage,
|
||||
dataResolution: request.dataResolution,
|
||||
mode: request.mode
|
||||
};
|
||||
|
||||
// Create and run the backtest engine
|
||||
const engine = new BacktestEngine(strategy, config, this.dataFeed);
|
||||
this.activeBacktests.set(strategyId, engine);
|
||||
|
||||
try {
|
||||
// Set up event forwarding
|
||||
const forwardEvents = (eventName: string) => {
|
||||
engine.on(eventName, (data) => {
|
||||
console.log(`[Backtest ${strategyId}] ${eventName}:`, data);
|
||||
});
|
||||
};
|
||||
|
||||
forwardEvents('started');
|
||||
forwardEvents('loading');
|
||||
forwardEvents('loaded');
|
||||
forwardEvents('progress');
|
||||
forwardEvents('orderFilled');
|
||||
forwardEvents('orderRejected');
|
||||
forwardEvents('completed');
|
||||
forwardEvents('error');
|
||||
|
||||
// Run the backtest
|
||||
const result = await engine.run();
|
||||
|
||||
// Clean up
|
||||
this.activeBacktests.delete(strategyId);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.activeBacktests.delete(strategyId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a strategy by running multiple backtests with different parameters
|
||||
*/
|
||||
async optimizeStrategy(
|
||||
baseRequest: BacktestRequest,
|
||||
parameterGrid: Record<string, any[]>
|
||||
): Promise<Array<BacktestResult & { parameters: Record<string, any> }>> {
|
||||
const results: Array<BacktestResult & { parameters: Record<string, any> }> = [];
|
||||
|
||||
// Generate parameter combinations
|
||||
const paramKeys = Object.keys(parameterGrid);
|
||||
const combinations = this.generateParameterCombinations(parameterGrid, paramKeys);
|
||||
|
||||
// Run backtest for each combination
|
||||
for (const paramSet of combinations) {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
strategyParams: {
|
||||
...baseRequest.strategyParams,
|
||||
...paramSet
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.runBacktest(request);
|
||||
results.push({
|
||||
...result,
|
||||
parameters: paramSet
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Optimization failed for parameters:`, paramSet, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by performance metric (e.g., Sharpe ratio)
|
||||
return results.sort((a, b) => b.sharpeRatio - a.sharpeRatio);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all combinations of parameters for grid search
|
||||
*/
|
||||
private generateParameterCombinations(
|
||||
grid: Record<string, any[]>,
|
||||
keys: string[],
|
||||
current: Record<string, any> = {},
|
||||
index: number = 0,
|
||||
result: Record<string, any>[] = []
|
||||
): Record<string, any>[] {
|
||||
if (index === keys.length) {
|
||||
result.push({ ...current });
|
||||
return result;
|
||||
}
|
||||
|
||||
const key = keys[index];
|
||||
const values = grid[key];
|
||||
|
||||
for (const value of values) {
|
||||
current[key] = value;
|
||||
this.generateParameterCombinations(grid, keys, current, index + 1, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active backtest engine by ID
|
||||
*/
|
||||
getBacktestEngine(id: string): BacktestEngine | undefined {
|
||||
return this.activeBacktests.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running backtest
|
||||
*/
|
||||
cancelBacktest(id: string): boolean {
|
||||
const engine = this.activeBacktests.get(id);
|
||||
if (!engine) return false;
|
||||
|
||||
// No explicit cancel method on engine, but we can clean up
|
||||
this.activeBacktests.delete(id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import { BarData } from '../Strategy';
|
||||
import { DataFeed } from './BacktestEngine';
|
||||
import axios from 'axios';
|
||||
|
||||
export class MarketDataFeed implements DataFeed {
|
||||
private readonly apiBaseUrl: string;
|
||||
private cache: Map<string, BarData[]> = new Map();
|
||||
|
||||
constructor(apiBaseUrl: string = 'http://localhost:3001/api') {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
|
||||
async getHistoricalData(symbol: string, resolution: string, start: Date, end: Date): Promise<BarData[]> {
|
||||
const cacheKey = this.getCacheKey(symbol, resolution, start, end);
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
try {
|
||||
// Format dates for API request
|
||||
const startStr = start.toISOString();
|
||||
const endStr = end.toISOString();
|
||||
|
||||
const response = await axios.get(`${this.apiBaseUrl}/market-data/history`, {
|
||||
params: {
|
||||
symbol,
|
||||
resolution,
|
||||
start: startStr,
|
||||
end: endStr
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(`Failed to fetch historical data for ${symbol}`);
|
||||
}
|
||||
|
||||
// Transform API response to BarData objects
|
||||
const bars: BarData[] = response.data.data.map((bar: any) => ({
|
||||
symbol,
|
||||
timestamp: new Date(bar.timestamp),
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
volume: bar.volume
|
||||
}));
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(cacheKey, bars);
|
||||
|
||||
return bars;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching historical data for ${symbol}:`, error);
|
||||
// Return fallback test data if API call fails
|
||||
return this.generateFallbackTestData(symbol, resolution, start, end);
|
||||
}
|
||||
}
|
||||
|
||||
async hasDataFor(symbol: string, resolution: string, start: Date, end: Date): Promise<boolean> {
|
||||
try {
|
||||
const startStr = start.toISOString();
|
||||
const endStr = end.toISOString();
|
||||
|
||||
const response = await axios.get(`${this.apiBaseUrl}/market-data/available`, {
|
||||
params: {
|
||||
symbol,
|
||||
resolution,
|
||||
start: startStr,
|
||||
end: endStr
|
||||
}
|
||||
});
|
||||
|
||||
return response.data.success && response.data.data.available;
|
||||
} catch (error) {
|
||||
console.error(`Error checking data availability for ${symbol}:`, error);
|
||||
// Assume data is available for test purposes
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
private getCacheKey(symbol: string, resolution: string, start: Date, end: Date): string {
|
||||
return `${symbol}_${resolution}_${start.getTime()}_${end.getTime()}`;
|
||||
}
|
||||
|
||||
private generateFallbackTestData(symbol: string, resolution: string, start: Date, end: Date): BarData[] {
|
||||
console.warn(`Generating fallback test data for ${symbol} from ${start} to ${end}`);
|
||||
|
||||
const bars: BarData[] = [];
|
||||
let current = new Date(start);
|
||||
let basePrice = this.getBasePrice(symbol);
|
||||
|
||||
// Generate daily bars by default
|
||||
const interval = this.getIntervalFromResolution(resolution);
|
||||
|
||||
while (current.getTime() <= end.getTime()) {
|
||||
// Only generate bars for trading days (skip weekends)
|
||||
if (current.getDay() !== 0 && current.getDay() !== 6) {
|
||||
// Generate a random daily price movement (-1% to +1%)
|
||||
const dailyChange = (Math.random() * 2 - 1) / 100;
|
||||
|
||||
// Add some randomness to the volatility
|
||||
const volatility = 0.005 + Math.random() * 0.01; // 0.5% to 1.5%
|
||||
|
||||
const open = basePrice * (1 + (Math.random() * 0.002 - 0.001));
|
||||
const close = open * (1 + dailyChange);
|
||||
const high = Math.max(open, close) * (1 + Math.random() * volatility);
|
||||
const low = Math.min(open, close) * (1 - Math.random() * volatility);
|
||||
const volume = Math.floor(100000 + Math.random() * 900000);
|
||||
|
||||
bars.push({
|
||||
symbol,
|
||||
timestamp: new Date(current),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume
|
||||
});
|
||||
|
||||
// Update base price for next bar
|
||||
basePrice = close;
|
||||
}
|
||||
|
||||
// Move to next interval
|
||||
current = new Date(current.getTime() + interval);
|
||||
}
|
||||
|
||||
return bars;
|
||||
}
|
||||
|
||||
private getBasePrice(symbol: string): number {
|
||||
// Return a realistic base price for common symbols
|
||||
switch (symbol.toUpperCase()) {
|
||||
case 'AAPL': return 170 + Math.random() * 30;
|
||||
case 'MSFT': return 370 + Math.random() * 50;
|
||||
case 'AMZN': return 140 + Math.random() * 20;
|
||||
case 'GOOGL': return 130 + Math.random() * 20;
|
||||
case 'META': return 300 + Math.random() * 50;
|
||||
case 'TSLA': return 180 + Math.random() * 70;
|
||||
case 'NVDA': return 700 + Math.random() * 200;
|
||||
case 'SPY': return 450 + Math.random() * 30;
|
||||
case 'QQQ': return 370 + Math.random() * 40;
|
||||
default: return 100 + Math.random() * 50;
|
||||
}
|
||||
}
|
||||
|
||||
private getIntervalFromResolution(resolution: string): number {
|
||||
// Return milliseconds for each resolution
|
||||
switch (resolution) {
|
||||
case '1m': return 60 * 1000;
|
||||
case '5m': return 5 * 60 * 1000;
|
||||
case '15m': return 15 * 60 * 1000;
|
||||
case '30m': return 30 * 60 * 1000;
|
||||
case '1h': return 60 * 60 * 1000;
|
||||
case '4h': return 4 * 60 * 60 * 1000;
|
||||
case '1d': return 24 * 60 * 60 * 1000;
|
||||
default: return 24 * 60 * 60 * 1000; // Default to daily
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
import { BacktestResult } from './BacktestEngine';
|
||||
|
||||
/**
|
||||
* Performance Analysis Utilities
|
||||
*
|
||||
* Provides additional metrics and analysis tools for backtesting results.
|
||||
*/
|
||||
export class PerformanceAnalytics {
|
||||
/**
|
||||
* Calculate additional metrics from backtest results
|
||||
*/
|
||||
static enhanceResults(result: BacktestResult): BacktestResult {
|
||||
// Calculate additional metrics
|
||||
const enhancedResult = {
|
||||
...result,
|
||||
...this.calculateAdvancedMetrics(result)
|
||||
};
|
||||
|
||||
return enhancedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate advanced performance metrics
|
||||
*/
|
||||
private static calculateAdvancedMetrics(result: BacktestResult): Partial<BacktestResult> {
|
||||
// Extract daily returns
|
||||
const dailyReturns = result.dailyReturns.map(dr => dr.return);
|
||||
|
||||
// Calculate Sortino ratio
|
||||
const sortinoRatio = this.calculateSortinoRatio(dailyReturns);
|
||||
|
||||
// Calculate Calmar ratio
|
||||
const calmarRatio = result.maxDrawdown > 0
|
||||
? result.annualizedReturn / result.maxDrawdown
|
||||
: Infinity;
|
||||
|
||||
// Calculate Omega ratio
|
||||
const omegaRatio = this.calculateOmegaRatio(dailyReturns);
|
||||
|
||||
// Calculate CAGR
|
||||
const startTimestamp = result.startDate.getTime();
|
||||
const endTimestamp = result.endDate.getTime();
|
||||
const yearsElapsed = (endTimestamp - startTimestamp) / (365 * 24 * 60 * 60 * 1000);
|
||||
const cagr = Math.pow(result.finalCapital / result.initialCapital, 1 / yearsElapsed) - 1;
|
||||
|
||||
// Calculate additional volatility and return metrics
|
||||
const volatility = this.calculateVolatility(dailyReturns);
|
||||
const ulcerIndex = this.calculateUlcerIndex(result.dailyReturns);
|
||||
|
||||
return {
|
||||
sortinoRatio,
|
||||
calmarRatio,
|
||||
omegaRatio,
|
||||
cagr,
|
||||
volatility,
|
||||
ulcerIndex
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Sortino ratio (downside risk-adjusted return)
|
||||
*/
|
||||
private static calculateSortinoRatio(dailyReturns: number[]): number {
|
||||
if (dailyReturns.length === 0) return 0;
|
||||
|
||||
const avgReturn = dailyReturns.reduce((sum, ret) => sum + ret, 0) / dailyReturns.length;
|
||||
|
||||
// Filter only negative returns (downside)
|
||||
const negativeReturns = dailyReturns.filter(ret => ret < 0);
|
||||
|
||||
if (negativeReturns.length === 0) return Infinity;
|
||||
|
||||
// Calculate downside deviation
|
||||
const downsideDeviation = Math.sqrt(
|
||||
negativeReturns.reduce((sum, ret) => sum + Math.pow(ret, 2), 0) / negativeReturns.length
|
||||
);
|
||||
|
||||
// Annualize
|
||||
const annualizedReturn = avgReturn * 252;
|
||||
const annualizedDownsideDeviation = downsideDeviation * Math.sqrt(252);
|
||||
|
||||
return annualizedDownsideDeviation > 0
|
||||
? annualizedReturn / annualizedDownsideDeviation
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Omega ratio (probability-weighted ratio of gains versus losses)
|
||||
*/
|
||||
private static calculateOmegaRatio(dailyReturns: number[], threshold = 0): number {
|
||||
if (dailyReturns.length === 0) return 0;
|
||||
|
||||
let sumGains = 0;
|
||||
let sumLosses = 0;
|
||||
|
||||
for (const ret of dailyReturns) {
|
||||
if (ret > threshold) {
|
||||
sumGains += (ret - threshold);
|
||||
} else {
|
||||
sumLosses += (threshold - ret);
|
||||
}
|
||||
}
|
||||
|
||||
return sumLosses > 0 ? sumGains / sumLosses : Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate annualized volatility
|
||||
*/
|
||||
private static calculateVolatility(returns: number[]): number {
|
||||
if (returns.length < 2) return 0;
|
||||
|
||||
const mean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / returns.length;
|
||||
|
||||
// Annualize
|
||||
return Math.sqrt(variance * 252);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Ulcer Index (measure of downside risk)
|
||||
*/
|
||||
private static calculateUlcerIndex(dailyReturns: Array<{ date: Date; return: number }>): number {
|
||||
if (dailyReturns.length === 0) return 0;
|
||||
|
||||
// Calculate running equity curve
|
||||
let equity = 1;
|
||||
const equityCurve = dailyReturns.map(dr => {
|
||||
equity *= (1 + dr.return);
|
||||
return equity;
|
||||
});
|
||||
|
||||
// Find running maximum
|
||||
const runningMax: number[] = [];
|
||||
let currentMax = equityCurve[0];
|
||||
|
||||
for (const value of equityCurve) {
|
||||
currentMax = Math.max(currentMax, value);
|
||||
runningMax.push(currentMax);
|
||||
}
|
||||
|
||||
// Calculate percentage drawdowns
|
||||
const percentDrawdowns = equityCurve.map((value, i) =>
|
||||
(runningMax[i] - value) / runningMax[i]
|
||||
);
|
||||
|
||||
// Calculate Ulcer Index
|
||||
const sumSquaredDrawdowns = percentDrawdowns.reduce(
|
||||
(sum, dd) => sum + dd * dd, 0
|
||||
);
|
||||
|
||||
return Math.sqrt(sumSquaredDrawdowns / percentDrawdowns.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract monthly returns from daily returns
|
||||
*/
|
||||
static calculateMonthlyReturns(dailyReturns: Array<{ date: Date; return: number }>): Array<{
|
||||
year: number;
|
||||
month: number;
|
||||
return: number;
|
||||
}> {
|
||||
const monthlyReturns: Array<{ year: number; month: number; return: number }> = [];
|
||||
|
||||
if (dailyReturns.length === 0) return monthlyReturns;
|
||||
|
||||
// Group returns by year and month
|
||||
const groupedReturns: Record<string, number[]> = {};
|
||||
|
||||
for (const dr of dailyReturns) {
|
||||
const year = dr.date.getFullYear();
|
||||
const month = dr.date.getMonth();
|
||||
const key = `${year}-${month}`;
|
||||
|
||||
if (!groupedReturns[key]) {
|
||||
groupedReturns[key] = [];
|
||||
}
|
||||
|
||||
groupedReturns[key].push(dr.return);
|
||||
}
|
||||
|
||||
// Calculate compound return for each month
|
||||
for (const key in groupedReturns) {
|
||||
const [yearStr, monthStr] = key.split('-');
|
||||
const year = parseInt(yearStr);
|
||||
const month = parseInt(monthStr);
|
||||
|
||||
// Compound the daily returns for the month
|
||||
const monthReturn = groupedReturns[key].reduce(
|
||||
(product, ret) => product * (1 + ret), 1
|
||||
) - 1;
|
||||
|
||||
monthlyReturns.push({ year, month, return: monthReturn });
|
||||
}
|
||||
|
||||
// Sort by date
|
||||
return monthlyReturns.sort((a, b) => {
|
||||
if (a.year !== b.year) return a.year - b.year;
|
||||
return a.month - b.month;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create drawdown analysis from equity curve
|
||||
*/
|
||||
static analyzeDrawdowns(dailyReturns: Array<{ date: Date; return: number }>): Array<{
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
recoveryDate: Date | null;
|
||||
drawdown: number;
|
||||
durationDays: number;
|
||||
recoveryDays: number | null;
|
||||
}> {
|
||||
if (dailyReturns.length === 0) return [];
|
||||
|
||||
// Calculate equity curve
|
||||
let equity = 1;
|
||||
const equityCurve = dailyReturns.map(dr => {
|
||||
equity *= (1 + dr.return);
|
||||
return { date: dr.date, equity };
|
||||
});
|
||||
|
||||
// Analyze drawdowns
|
||||
const drawdowns: Array<{
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
recoveryDate: Date | null;
|
||||
drawdown: number;
|
||||
durationDays: number;
|
||||
recoveryDays: number | null;
|
||||
}> = [];
|
||||
|
||||
let peakEquity = equityCurve[0].equity;
|
||||
let peakDate = equityCurve[0].date;
|
||||
let inDrawdown = false;
|
||||
let currentDrawdown: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
lowEquity: number;
|
||||
peakEquity: number;
|
||||
} | null = null;
|
||||
|
||||
// Find drawdown periods
|
||||
for (let i = 1; i < equityCurve.length; i++) {
|
||||
const { date, equity } = equityCurve[i];
|
||||
|
||||
// New peak
|
||||
if (equity > peakEquity) {
|
||||
peakEquity = equity;
|
||||
peakDate = date;
|
||||
|
||||
// If recovering from drawdown, record recovery
|
||||
if (inDrawdown && currentDrawdown) {
|
||||
const recoveryDate = date;
|
||||
const drawdownPct = (currentDrawdown.peakEquity - currentDrawdown.lowEquity) /
|
||||
currentDrawdown.peakEquity;
|
||||
|
||||
const durationDays = Math.floor(
|
||||
(currentDrawdown.endDate.getTime() - currentDrawdown.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
const recoveryDays = Math.floor(
|
||||
(recoveryDate.getTime() - currentDrawdown.endDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
drawdowns.push({
|
||||
startDate: currentDrawdown.startDate,
|
||||
endDate: currentDrawdown.endDate,
|
||||
recoveryDate,
|
||||
drawdown: drawdownPct,
|
||||
durationDays,
|
||||
recoveryDays
|
||||
});
|
||||
|
||||
inDrawdown = false;
|
||||
currentDrawdown = null;
|
||||
}
|
||||
}
|
||||
// In drawdown
|
||||
else {
|
||||
const drawdownPct = (peakEquity - equity) / peakEquity;
|
||||
|
||||
if (!inDrawdown) {
|
||||
// Start of new drawdown
|
||||
inDrawdown = true;
|
||||
currentDrawdown = {
|
||||
startDate: peakDate,
|
||||
endDate: date,
|
||||
lowEquity: equity,
|
||||
peakEquity
|
||||
};
|
||||
} else if (currentDrawdown && equity < currentDrawdown.lowEquity) {
|
||||
// New low in current drawdown
|
||||
currentDrawdown.lowEquity = equity;
|
||||
currentDrawdown.endDate = date;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any ongoing drawdown at the end
|
||||
if (inDrawdown && currentDrawdown) {
|
||||
const drawdownPct = (currentDrawdown.peakEquity - currentDrawdown.lowEquity) /
|
||||
currentDrawdown.peakEquity;
|
||||
|
||||
const durationDays = Math.floor(
|
||||
(currentDrawdown.endDate.getTime() - currentDrawdown.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
drawdowns.push({
|
||||
startDate: currentDrawdown.startDate,
|
||||
endDate: currentDrawdown.endDate,
|
||||
recoveryDate: null,
|
||||
drawdown: drawdownPct,
|
||||
durationDays,
|
||||
recoveryDays: null
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by drawdown magnitude
|
||||
return drawdowns.sort((a, b) => b.drawdown - a.drawdown);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,512 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { BaseStrategy } from '../Strategy';
|
||||
import { BarData, Order, Position } from '../Strategy';
|
||||
import { MarketDataFeed } from '../backtesting/MarketDataFeed';
|
||||
import { StrategyRegistry } from '../strategies/StrategyRegistry';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
export interface ExecutionConfig {
|
||||
symbols: string[];
|
||||
dataResolution: '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d';
|
||||
realTrading: boolean;
|
||||
maxPositionValue: number;
|
||||
maxOrdersPerMinute: number;
|
||||
stopLossPercentage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy Execution Service
|
||||
*
|
||||
* Handles the real-time execution of trading strategies.
|
||||
* Manages live data feeds, order execution, and position tracking.
|
||||
*/
|
||||
export class StrategyExecutionService extends EventEmitter {
|
||||
private strategyRegistry: StrategyRegistry;
|
||||
private marketDataFeed: MarketDataFeed;
|
||||
private activeStrategies: Map<string, {
|
||||
strategy: BaseStrategy;
|
||||
config: ExecutionConfig;
|
||||
positions: Map<string, Position>;
|
||||
lastBar: Map<string, BarData>;
|
||||
}> = new Map();
|
||||
|
||||
private isRunning: boolean = false;
|
||||
private dataPollingIntervals: Map<string, NodeJS.Timeout> = new Map();
|
||||
private webSocketServer: WebSocket.Server | null = null;
|
||||
private wsClients: Set<WebSocket> = new Set();
|
||||
private marketDataCache: Map<string, BarData[]> = new Map();
|
||||
|
||||
constructor(apiBaseUrl: string = 'http://localhost:3001/api', wsPort: number = 8082) {
|
||||
super();
|
||||
this.strategyRegistry = StrategyRegistry.getInstance();
|
||||
this.marketDataFeed = new MarketDataFeed(apiBaseUrl);
|
||||
this.initializeWebSocketServer(wsPort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize WebSocket server for real-time updates
|
||||
*/
|
||||
private initializeWebSocketServer(port: number): void {
|
||||
try {
|
||||
this.webSocketServer = new WebSocket.Server({ port });
|
||||
|
||||
this.webSocketServer.on('connection', (ws) => {
|
||||
console.log('New client connected to strategy execution service');
|
||||
this.wsClients.add(ws);
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
this.handleWebSocketMessage(ws, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Client disconnected from strategy execution service');
|
||||
this.wsClients.delete(ws);
|
||||
});
|
||||
|
||||
// Send initial state
|
||||
this.sendAllStrategyStatus(ws);
|
||||
});
|
||||
|
||||
console.log(`WebSocket server started on port ${port}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize WebSocket server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket messages
|
||||
*/
|
||||
private handleWebSocketMessage(ws: WebSocket, message: any): void {
|
||||
switch (message.type) {
|
||||
case 'get_active_strategies':
|
||||
this.sendAllStrategyStatus(ws);
|
||||
break;
|
||||
case 'start_strategy':
|
||||
this.startStrategy(message.id, message.config);
|
||||
break;
|
||||
case 'stop_strategy':
|
||||
this.stopStrategy(message.id);
|
||||
break;
|
||||
case 'pause_strategy':
|
||||
this.pauseStrategy(message.id);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown WebSocket message type: ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to all connected WebSocket clients
|
||||
*/
|
||||
private broadcastMessage(message: any): void {
|
||||
const messageStr = JSON.stringify(message);
|
||||
for (const client of this.wsClients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(messageStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send current status of all active strategies to a specific client
|
||||
*/
|
||||
private sendAllStrategyStatus(ws: WebSocket): void {
|
||||
const statusList = Array.from(this.activeStrategies.entries()).map(([id, data]) => ({
|
||||
id,
|
||||
name: data.strategy.name,
|
||||
status: this.isRunning ? 'ACTIVE' : 'PAUSED',
|
||||
symbols: data.config.symbols,
|
||||
positions: Array.from(data.positions.entries()).map(([symbol, pos]) => ({
|
||||
symbol,
|
||||
quantity: pos.quantity,
|
||||
entryPrice: pos.entryPrice,
|
||||
currentValue: pos.currentValue
|
||||
}))
|
||||
}));
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'strategy_status_list',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: statusList
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the execution service (global)
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isRunning) return;
|
||||
|
||||
this.isRunning = true;
|
||||
console.log('Strategy execution service started');
|
||||
|
||||
// Start data polling for all active strategies
|
||||
for (const [strategyId, data] of this.activeStrategies.entries()) {
|
||||
this.startDataPollingForStrategy(strategyId, data);
|
||||
}
|
||||
|
||||
this.broadcastMessage({
|
||||
type: 'execution_service_status',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { status: 'RUNNING' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the execution service (global)
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
this.isRunning = false;
|
||||
console.log('Strategy execution service stopped');
|
||||
|
||||
// Clear all data polling intervals
|
||||
for (const interval of this.dataPollingIntervals.values()) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
this.dataPollingIntervals.clear();
|
||||
|
||||
this.broadcastMessage({
|
||||
type: 'execution_service_status',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { status: 'STOPPED' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a specific strategy
|
||||
*/
|
||||
startStrategy(strategyId: string, config?: ExecutionConfig): boolean {
|
||||
const strategy = this.strategyRegistry.getStrategyById(strategyId);
|
||||
|
||||
if (!strategy) {
|
||||
console.error(`Strategy with ID ${strategyId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If strategy is already active, return false
|
||||
if (this.activeStrategies.has(strategyId)) {
|
||||
console.warn(`Strategy ${strategyId} is already active`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use provided config or create default
|
||||
const executionConfig: ExecutionConfig = config || {
|
||||
symbols: strategy.symbols,
|
||||
dataResolution: '1m',
|
||||
realTrading: false,
|
||||
maxPositionValue: 10000,
|
||||
maxOrdersPerMinute: 5,
|
||||
stopLossPercentage: 0.02
|
||||
};
|
||||
|
||||
// Initialize strategy data
|
||||
const strategyData = {
|
||||
strategy,
|
||||
config: executionConfig,
|
||||
positions: new Map<string, Position>(),
|
||||
lastBar: new Map<string, BarData>()
|
||||
};
|
||||
|
||||
this.activeStrategies.set(strategyId, strategyData);
|
||||
|
||||
// If execution service is running, start data polling for this strategy
|
||||
if (this.isRunning) {
|
||||
this.startDataPollingForStrategy(strategyId, strategyData);
|
||||
}
|
||||
|
||||
console.log(`Strategy ${strategyId} started with ${executionConfig.symbols.length} symbols`);
|
||||
|
||||
this.broadcastMessage({
|
||||
type: 'strategy_started',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
strategyId,
|
||||
name: strategy.name,
|
||||
symbols: executionConfig.symbols
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a specific strategy
|
||||
*/
|
||||
stopStrategy(strategyId: string): boolean {
|
||||
if (!this.activeStrategies.has(strategyId)) {
|
||||
console.warn(`Strategy ${strategyId} is not active`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear data polling interval for this strategy
|
||||
const intervalId = this.dataPollingIntervals.get(strategyId);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
this.dataPollingIntervals.delete(strategyId);
|
||||
}
|
||||
|
||||
// Get strategy data before removing
|
||||
const strategyData = this.activeStrategies.get(strategyId)!;
|
||||
const { strategy } = strategyData;
|
||||
|
||||
// Close any open positions (in real implementation)
|
||||
// ...
|
||||
|
||||
this.activeStrategies.delete(strategyId);
|
||||
|
||||
console.log(`Strategy ${strategyId} stopped`);
|
||||
|
||||
this.broadcastMessage({
|
||||
type: 'strategy_stopped',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
strategyId,
|
||||
name: strategy.name
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a specific strategy
|
||||
*/
|
||||
pauseStrategy(strategyId: string): boolean {
|
||||
if (!this.activeStrategies.has(strategyId)) {
|
||||
console.warn(`Strategy ${strategyId} is not active`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear data polling interval for this strategy
|
||||
const intervalId = this.dataPollingIntervals.get(strategyId);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
this.dataPollingIntervals.delete(strategyId);
|
||||
}
|
||||
|
||||
const { strategy } = this.activeStrategies.get(strategyId)!;
|
||||
|
||||
console.log(`Strategy ${strategyId} paused`);
|
||||
|
||||
this.broadcastMessage({
|
||||
type: 'strategy_paused',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
strategyId,
|
||||
name: strategy.name
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start data polling for a specific strategy
|
||||
*/
|
||||
private startDataPollingForStrategy(
|
||||
strategyId: string,
|
||||
data: {
|
||||
strategy: BaseStrategy;
|
||||
config: ExecutionConfig;
|
||||
positions: Map<string, Position>;
|
||||
lastBar: Map<string, BarData>;
|
||||
}
|
||||
): void {
|
||||
const { strategy, config } = data;
|
||||
|
||||
// Determine polling interval based on data resolution
|
||||
const pollingIntervalMs = this.getPollingIntervalFromResolution(config.dataResolution);
|
||||
|
||||
// Set up interval for data polling
|
||||
const intervalId = setInterval(async () => {
|
||||
await this.pollMarketData(strategyId);
|
||||
}, pollingIntervalMs);
|
||||
|
||||
this.dataPollingIntervals.set(strategyId, intervalId);
|
||||
|
||||
console.log(`Started data polling for strategy ${strategyId} with interval ${pollingIntervalMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert data resolution to polling interval
|
||||
*/
|
||||
private getPollingIntervalFromResolution(resolution: string): number {
|
||||
switch (resolution) {
|
||||
case '1m': return 60 * 1000; // 60 seconds
|
||||
case '5m': return 5 * 60 * 1000; // 5 minutes
|
||||
case '15m': return 15 * 60 * 1000; // 15 minutes
|
||||
case '30m': return 30 * 60 * 1000; // 30 minutes
|
||||
case '1h': return 60 * 60 * 1000; // 1 hour
|
||||
case '4h': return 4 * 60 * 60 * 1000; // 4 hours
|
||||
case '1d': return 24 * 60 * 60 * 1000; // 1 day
|
||||
default: return 60 * 1000; // Default to 1 minute
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll market data for a specific strategy
|
||||
*/
|
||||
private async pollMarketData(strategyId: string): Promise<void> {
|
||||
const strategyData = this.activeStrategies.get(strategyId);
|
||||
|
||||
if (!strategyData) {
|
||||
console.warn(`Strategy ${strategyId} not found in active strategies`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { strategy, config, lastBar } = strategyData;
|
||||
|
||||
try {
|
||||
// Get latest market data for all symbols
|
||||
for (const symbol of config.symbols) {
|
||||
// Get latest bar
|
||||
const now = new Date();
|
||||
const startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
|
||||
|
||||
// Fetch latest bar
|
||||
const bars = await this.marketDataFeed.getHistoricalData(
|
||||
symbol,
|
||||
config.dataResolution,
|
||||
startTime,
|
||||
now
|
||||
);
|
||||
|
||||
if (bars.length > 0) {
|
||||
const latestBar = bars[bars.length - 1];
|
||||
|
||||
// Only process if this is a new bar
|
||||
const currentLastBar = lastBar.get(symbol);
|
||||
if (!currentLastBar || latestBar.timestamp.getTime() > currentLastBar.timestamp.getTime()) {
|
||||
// Update last bar
|
||||
lastBar.set(symbol, latestBar);
|
||||
|
||||
// Process the bar with the strategy
|
||||
this.processBar(strategyId, symbol, latestBar);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error polling market data for strategy ${strategyId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a new bar with the strategy
|
||||
*/ private processBar(strategyId: string, symbol: string, bar: BarData): void {
|
||||
const strategyData = this.activeStrategies.get(strategyId);
|
||||
|
||||
if (!strategyData) {
|
||||
console.warn(`Strategy ${strategyId} not found in active strategies`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { strategy } = strategyData;
|
||||
|
||||
// Call strategy's onBar method to get trading signals
|
||||
const signal = strategy.onBar(bar);
|
||||
|
||||
if (signal) {
|
||||
// Create a signal object with timestamp and ID
|
||||
const signalObject = {
|
||||
id: `sig_${Date.now()}_${Math.floor(Math.random() * 10000)}`,
|
||||
strategyId,
|
||||
name: strategy.name,
|
||||
symbol: bar.symbol,
|
||||
price: bar.close,
|
||||
action: signal.action,
|
||||
quantity: signal.quantity,
|
||||
metadata: signal.metadata,
|
||||
timestamp: new Date().toISOString(),
|
||||
confidence: signal.confidence || 0.8 // Default confidence if not provided
|
||||
};
|
||||
|
||||
// Store the signal in Redis (this would be in a production environment)
|
||||
try {
|
||||
// This would normally be injected as a dependency, but for simplicity:
|
||||
const redis = require('ioredis') ?
|
||||
new (require('ioredis'))({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379')
|
||||
}) : null;
|
||||
|
||||
if (redis) {
|
||||
// Store signal with TTL of 7 days
|
||||
redis.setex(
|
||||
`signal:${strategyId}:${signalObject.id}`,
|
||||
60 * 60 * 24 * 7, // 7 days TTL
|
||||
JSON.stringify(signalObject)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error storing signal in Redis:', err);
|
||||
}
|
||||
|
||||
// Broadcast the signal
|
||||
this.broadcastMessage({
|
||||
type: 'strategy_signal',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: signalObject
|
||||
});
|
||||
|
||||
// Execute the signal if real trading is enabled
|
||||
if (strategyData.config.realTrading) {
|
||||
this.executeSignal(strategyId, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a trading signal
|
||||
*/
|
||||
private executeSignal(strategyId: string, signal: any): void {
|
||||
// In a real implementation, this would connect to the order execution service
|
||||
// For now, just log the signal and broadcast a mock trade
|
||||
console.log(`Executing signal for strategy ${strategyId}:`, signal);
|
||||
|
||||
const strategyData = this.activeStrategies.get(strategyId);
|
||||
if (!strategyData) return;
|
||||
|
||||
const { strategy } = strategyData;
|
||||
|
||||
// Broadcast mock trade execution
|
||||
this.broadcastMessage({
|
||||
type: 'strategy_trade',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
strategyId,
|
||||
name: strategy.name,
|
||||
symbol: signal.symbol,
|
||||
price: signal.price,
|
||||
action: signal.action,
|
||||
quantity: signal.quantity,
|
||||
executionTime: new Date().toISOString(),
|
||||
status: 'FILLED'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections when service is shut down
|
||||
*/
|
||||
shutdown(): void {
|
||||
this.stop();
|
||||
|
||||
if (this.webSocketServer) {
|
||||
for (const client of this.wsClients) {
|
||||
client.close();
|
||||
}
|
||||
this.wsClients.clear();
|
||||
|
||||
this.webSocketServer.close(() => {
|
||||
console.log('WebSocket server closed');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { BarData, Order } from '../Strategy';
|
||||
import { TechnicalIndicators } from '../analysis/TechnicalIndicators';
|
||||
import { VectorizedStrategy, VectorizedStrategyParams } from './VectorizedStrategy';
|
||||
|
||||
export interface MeanReversionParams extends VectorizedStrategyParams {
|
||||
lookback: number; // Period for mean calculation
|
||||
entryDeviation: number; // Standard deviations for entry
|
||||
exitDeviation: number; // Standard deviations for exit
|
||||
rsiPeriod: number; // RSI period
|
||||
rsiOverbought: number; // RSI overbought level
|
||||
rsiOversold: number; // RSI oversold level
|
||||
useRsi: boolean; // Whether to use RSI filter
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean Reversion Strategy
|
||||
*
|
||||
* A vectorized strategy that trades mean reversion by detecting
|
||||
* overbought/oversold conditions and entering when price deviates
|
||||
* significantly from its moving average.
|
||||
*/
|
||||
export class MeanReversionStrategy extends VectorizedStrategy {
|
||||
// Default parameters
|
||||
protected static readonly DEFAULT_PARAMS: MeanReversionParams = {
|
||||
...VectorizedStrategy.DEFAULT_PARAMS,
|
||||
lookback: 20,
|
||||
entryDeviation: 1.5,
|
||||
exitDeviation: 0.5,
|
||||
lookbackPeriod: 100,
|
||||
rsiPeriod: 14,
|
||||
rsiOverbought: 70,
|
||||
rsiOversold: 30,
|
||||
useRsi: true
|
||||
};
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
symbols: string[],
|
||||
params: Partial<MeanReversionParams> = {}
|
||||
) {
|
||||
super(id, name, description, symbols, {
|
||||
...MeanReversionStrategy.DEFAULT_PARAMS,
|
||||
...params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes buy/sell signals based on mean reversion logic
|
||||
*/
|
||||
protected computeSignals(
|
||||
symbol: string,
|
||||
prices: number[],
|
||||
volumes: number[]
|
||||
): Array<1 | 0 | -1> {
|
||||
const params = this.parameters as MeanReversionParams;
|
||||
const result: Array<1 | 0 | -1> = Array(prices.length).fill(0);
|
||||
|
||||
// Not enough data
|
||||
if (prices.length < params.lookback) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Calculate moving average
|
||||
const sma = TechnicalIndicators.sma(prices, params.lookback);
|
||||
|
||||
// Calculate standard deviation
|
||||
const stdDevs: number[] = [];
|
||||
for (let i = params.lookback - 1; i < prices.length; i++) {
|
||||
let sum = 0;
|
||||
for (let j = i - params.lookback + 1; j <= i; j++) {
|
||||
sum += Math.pow(prices[j] - sma[i], 2);
|
||||
}
|
||||
stdDevs.push(Math.sqrt(sum / params.lookback));
|
||||
}
|
||||
|
||||
// Pad standard deviations with NaN
|
||||
const paddedStdDevs = [...Array(params.lookback - 1).fill(NaN), ...stdDevs];
|
||||
|
||||
// Calculate upper and lower bands
|
||||
const upperBand: number[] = [];
|
||||
const lowerBand: number[] = [];
|
||||
|
||||
for (let i = 0; i < prices.length; i++) {
|
||||
if (isNaN(sma[i]) || isNaN(paddedStdDevs[i])) {
|
||||
upperBand.push(NaN);
|
||||
lowerBand.push(NaN);
|
||||
} else {
|
||||
upperBand.push(sma[i] + paddedStdDevs[i] * params.entryDeviation);
|
||||
lowerBand.push(sma[i] - paddedStdDevs[i] * params.entryDeviation);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate RSI if used
|
||||
let rsi: number[] = [];
|
||||
if (params.useRsi) {
|
||||
rsi = TechnicalIndicators.rsi(prices, params.rsiPeriod);
|
||||
}
|
||||
|
||||
// Generate signals
|
||||
for (let i = params.lookback; i < prices.length; i++) {
|
||||
// Check if price is outside bands
|
||||
const outsideUpperBand = prices[i] > upperBand[i];
|
||||
const outsideLowerBand = prices[i] < lowerBand[i];
|
||||
|
||||
// Check RSI conditions
|
||||
const rsiOverbought = params.useRsi && rsi[i] > params.rsiOverbought;
|
||||
const rsiOversold = params.useRsi && rsi[i] < params.rsiOversold;
|
||||
|
||||
// Check if price is returning to mean
|
||||
const returningFromUpper =
|
||||
outsideUpperBand && prices[i] < prices[i-1] && prices[i-1] > prices[i-2];
|
||||
|
||||
const returningFromLower =
|
||||
outsideLowerBand && prices[i] > prices[i-1] && prices[i-1] < prices[i-2];
|
||||
|
||||
// Generate signals
|
||||
if ((returningFromUpper && (!params.useRsi || rsiOverbought))) {
|
||||
result[i] = -1; // SELL signal
|
||||
} else if ((returningFromLower && (!params.useRsi || rsiOversold))) {
|
||||
result[i] = 1; // BUY signal
|
||||
} else if (Math.abs(prices[i] - sma[i]) < paddedStdDevs[i] * params.exitDeviation) {
|
||||
// Price returned to mean - exit position
|
||||
if (i > 0 && result[i-1] !== 0) {
|
||||
result[i] = result[i-1] * -1; // Opposite of previous signal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async onBar(bar: BarData): Promise<Order[]> {
|
||||
// Use the vectorized implementation from the base class
|
||||
return super.onBar(bar);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { BaseStrategy, BarData, Order, StrategyParameters } from '../Strategy';
|
||||
import { TechnicalIndicators } from '../analysis/TechnicalIndicators';
|
||||
|
||||
export interface MovingAverageCrossoverParams extends StrategyParameters {
|
||||
fastPeriod: number; // Fast moving average period
|
||||
slowPeriod: number; // Slow moving average period
|
||||
positionSize: number; // Position size as percentage (0-1)
|
||||
stopLoss: number; // Stop loss percentage (0-1)
|
||||
takeProfit: number; // Take profit percentage (0-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving Average Crossover Strategy
|
||||
*
|
||||
* A simple strategy that buys when fast MA crosses above slow MA,
|
||||
* and sells when fast MA crosses below slow MA.
|
||||
*/
|
||||
export class MovingAverageCrossover extends BaseStrategy {
|
||||
// Default parameters
|
||||
protected static readonly DEFAULT_PARAMS: MovingAverageCrossoverParams = {
|
||||
fastPeriod: 10,
|
||||
slowPeriod: 30,
|
||||
positionSize: 0.2, // 20% of portfolio
|
||||
stopLoss: 0.02, // 2% stop loss
|
||||
takeProfit: 0.05 // 5% take profit
|
||||
};
|
||||
|
||||
private fastMA: Map<string, number[]> = new Map();
|
||||
private slowMA: Map<string, number[]> = new Map();
|
||||
private lastSignal: Map<string, 'BUY' | 'SELL' | null> = new Map();
|
||||
private stopLossLevels: Map<string, number> = new Map();
|
||||
private takeProfitLevels: Map<string, number> = new Map();
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
symbols: string[],
|
||||
params: Partial<MovingAverageCrossoverParams> = {}
|
||||
) {
|
||||
super(id, name, description, symbols, {
|
||||
...MovingAverageCrossover.DEFAULT_PARAMS,
|
||||
...params
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Initialize state for each symbol
|
||||
for (const symbol of this.symbols) {
|
||||
this.fastMA.set(symbol, []);
|
||||
this.slowMA.set(symbol, []);
|
||||
this.lastSignal.set(symbol, null);
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
const params = this.parameters as MovingAverageCrossoverParams;
|
||||
|
||||
if (params.fastPeriod >= params.slowPeriod) {
|
||||
console.warn('Fast period should be smaller than slow period');
|
||||
}
|
||||
|
||||
if (params.positionSize <= 0 || params.positionSize > 1) {
|
||||
console.warn('Position size should be between 0 and 1');
|
||||
params.positionSize = 0.2; // Reset to default
|
||||
}
|
||||
}
|
||||
|
||||
async onBar(bar: BarData): Promise<Order[]> {
|
||||
const symbol = bar.symbol;
|
||||
const params = this.parameters as MovingAverageCrossoverParams;
|
||||
|
||||
// Update market data
|
||||
this.addBar(bar);
|
||||
|
||||
// Get historical data
|
||||
const bars = this.context.marketData.get(symbol) || [];
|
||||
|
||||
// Wait until we have enough data
|
||||
if (bars.length < params.slowPeriod + 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract close prices
|
||||
const prices = TechnicalIndicators.extractPrice(bars, 'close');
|
||||
|
||||
// Calculate moving averages
|
||||
const fastMA = TechnicalIndicators.sma(prices, params.fastPeriod);
|
||||
const slowMA = TechnicalIndicators.sma(prices, params.slowPeriod);
|
||||
|
||||
// Update MA values
|
||||
this.fastMA.set(symbol, fastMA);
|
||||
this.slowMA.set(symbol, slowMA);
|
||||
|
||||
// Get current and previous values
|
||||
const currentFast = fastMA[fastMA.length - 1];
|
||||
const currentSlow = slowMA[slowMA.length - 1];
|
||||
const previousFast = fastMA[fastMA.length - 2];
|
||||
const previousSlow = slowMA[slowMA.length - 2];
|
||||
|
||||
// Check for crossovers
|
||||
const orders: Order[] = [];
|
||||
|
||||
// Check for stop loss and take profit first
|
||||
if (this.hasPosition(symbol)) {
|
||||
const position = this.getPosition(symbol)!;
|
||||
const currentPrice = bar.close;
|
||||
|
||||
const stopLossLevel = this.stopLossLevels.get(symbol);
|
||||
const takeProfitLevel = this.takeProfitLevels.get(symbol);
|
||||
|
||||
// Check stop loss
|
||||
if (stopLossLevel && currentPrice <= stopLossLevel) {
|
||||
orders.push(this.createMarketOrder(symbol, 'SELL', position.quantity));
|
||||
this.stopLossLevels.delete(symbol);
|
||||
this.takeProfitLevels.delete(symbol);
|
||||
console.log(`${symbol} Stop loss triggered at ${currentPrice}`);
|
||||
return orders;
|
||||
}
|
||||
|
||||
// Check take profit
|
||||
if (takeProfitLevel && currentPrice >= takeProfitLevel) {
|
||||
orders.push(this.createMarketOrder(symbol, 'SELL', position.quantity));
|
||||
this.stopLossLevels.delete(symbol);
|
||||
this.takeProfitLevels.delete(symbol);
|
||||
console.log(`${symbol} Take profit triggered at ${currentPrice}`);
|
||||
return orders;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for moving average crossover signals
|
||||
const crossedAbove = previousFast <= previousSlow && currentFast > currentSlow;
|
||||
const crossedBelow = previousFast >= previousSlow && currentFast < currentSlow;
|
||||
|
||||
if (crossedAbove && !this.hasPosition(symbol)) {
|
||||
// Buy signal - calculate position size
|
||||
const cash = this.getAvailableCash();
|
||||
const positionValue = cash * params.positionSize;
|
||||
const quantity = Math.floor(positionValue / bar.close);
|
||||
|
||||
if (quantity > 0) {
|
||||
orders.push(this.createMarketOrder(symbol, 'BUY', quantity));
|
||||
this.lastSignal.set(symbol, 'BUY');
|
||||
|
||||
// Set stop loss and take profit levels
|
||||
this.stopLossLevels.set(symbol, bar.close * (1 - params.stopLoss));
|
||||
this.takeProfitLevels.set(symbol, bar.close * (1 + params.takeProfit));
|
||||
console.log(`${symbol} Buy signal at ${bar.close}, quantity: ${quantity}`);
|
||||
}
|
||||
} else if (crossedBelow && this.hasPosition(symbol)) {
|
||||
// Sell signal
|
||||
const position = this.getPosition(symbol)!;
|
||||
orders.push(this.createMarketOrder(symbol, 'SELL', position.quantity));
|
||||
this.lastSignal.set(symbol, 'SELL');
|
||||
this.stopLossLevels.delete(symbol);
|
||||
this.takeProfitLevels.delete(symbol);
|
||||
console.log(`${symbol} Sell signal at ${bar.close}`);
|
||||
}
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
async onOrderFilled(order: Order): Promise<void> {
|
||||
console.log(`Order filled: ${order.symbol} ${order.side} ${order.quantity} @ ${order.fillPrice}`);
|
||||
|
||||
// Additional post-order logic can be added here
|
||||
if (order.side === 'BUY') {
|
||||
// Update stop loss and take profit levels based on fill price
|
||||
const params = this.parameters as MovingAverageCrossoverParams;
|
||||
this.stopLossLevels.set(order.symbol, order.fillPrice! * (1 - params.stopLoss));
|
||||
this.takeProfitLevels.set(order.symbol, order.fillPrice! * (1 + params.takeProfit));
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
// Clean up any resources or state
|
||||
this.fastMA.clear();
|
||||
this.slowMA.clear();
|
||||
this.lastSignal.clear();
|
||||
this.stopLossLevels.clear();
|
||||
this.takeProfitLevels.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import { BaseStrategy, StrategyParameters } from '../Strategy';
|
||||
import { MovingAverageCrossover, MovingAverageCrossoverParams } from './MovingAverageCrossover';
|
||||
import { MeanReversionStrategy, MeanReversionParams } from './MeanReversionStrategy';
|
||||
|
||||
// Define the strategy types
|
||||
export type StrategyType = 'MOVING_AVERAGE_CROSSOVER' | 'MEAN_REVERSION' | 'CUSTOM';
|
||||
|
||||
// Strategy factory function type
|
||||
export type StrategyFactory = (
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
symbols: string[],
|
||||
parameters: StrategyParameters
|
||||
) => BaseStrategy;
|
||||
|
||||
/**
|
||||
* Strategy Registry
|
||||
*
|
||||
* Manages and creates strategy instances based on strategy type.
|
||||
* Allows dynamic registration of new strategy types.
|
||||
*/
|
||||
export class StrategyRegistry {
|
||||
private static instance: StrategyRegistry;
|
||||
private factories: Map<StrategyType, StrategyFactory> = new Map();
|
||||
|
||||
/**
|
||||
* Get the singleton instance of StrategyRegistry
|
||||
*/
|
||||
public static getInstance(): StrategyRegistry {
|
||||
if (!StrategyRegistry.instance) {
|
||||
StrategyRegistry.instance = new StrategyRegistry();
|
||||
}
|
||||
return StrategyRegistry.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private constructor to enforce singleton pattern
|
||||
*/
|
||||
private constructor() {
|
||||
this.registerBuiltInStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register built-in strategies
|
||||
*/
|
||||
private registerBuiltInStrategies(): void {
|
||||
// Register Moving Average Crossover
|
||||
this.registerStrategy('MOVING_AVERAGE_CROSSOVER', (id, name, description, symbols, parameters) => {
|
||||
return new MovingAverageCrossover(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
symbols,
|
||||
parameters as MovingAverageCrossoverParams
|
||||
);
|
||||
});
|
||||
|
||||
// Register Mean Reversion
|
||||
this.registerStrategy('MEAN_REVERSION', (id, name, description, symbols, parameters) => {
|
||||
return new MeanReversionStrategy(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
symbols,
|
||||
parameters as MeanReversionParams
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new strategy type with factory function
|
||||
*/
|
||||
public registerStrategy(type: StrategyType, factory: StrategyFactory): void {
|
||||
this.factories.set(type, factory);
|
||||
}
|
||||
/**
|
||||
* Create a strategy instance based on type and parameters
|
||||
*/
|
||||
public createStrategy(
|
||||
type: StrategyType,
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
symbols: string[],
|
||||
parameters: StrategyParameters = {}
|
||||
): BaseStrategy {
|
||||
const factory = this.factories.get(type);
|
||||
|
||||
if (!factory) {
|
||||
throw new Error(`Strategy type '${type}' is not registered`);
|
||||
}
|
||||
|
||||
const strategy = factory(id, name, description, symbols, parameters);
|
||||
|
||||
// Store the strategy for management
|
||||
this.storeStrategy(strategy);
|
||||
|
||||
return strategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default parameters for a strategy type
|
||||
*/
|
||||
public getDefaultParameters(type: StrategyType): StrategyParameters {
|
||||
switch(type) {
|
||||
case 'MOVING_AVERAGE_CROSSOVER':
|
||||
return {
|
||||
fastPeriod: 10,
|
||||
slowPeriod: 30,
|
||||
positionSize: 0.2,
|
||||
stopLoss: 0.02,
|
||||
takeProfit: 0.05
|
||||
};
|
||||
case 'MEAN_REVERSION':
|
||||
return {
|
||||
lookback: 20,
|
||||
entryDeviation: 1.5,
|
||||
exitDeviation: 0.5,
|
||||
lookbackPeriod: 100,
|
||||
positionSize: 0.2,
|
||||
stopLoss: 0.02,
|
||||
takeProfit: 0.05,
|
||||
useBollingerBands: true,
|
||||
bollingerPeriod: 20,
|
||||
bollingerDeviation: 2,
|
||||
rsiPeriod: 14,
|
||||
rsiOverbought: 70,
|
||||
rsiOversold: 30,
|
||||
useRsi: true
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered strategy types
|
||||
*/
|
||||
public getStrategyTypes(): StrategyType[] {
|
||||
return Array.from(this.factories.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a strategy type is registered
|
||||
*/
|
||||
public hasStrategyType(type: StrategyType): boolean {
|
||||
return this.factories.has(type);
|
||||
}
|
||||
|
||||
// Store created strategies for management
|
||||
private strategies: Map<string, BaseStrategy> = new Map();
|
||||
|
||||
/**
|
||||
* Store strategy instance for later retrieval
|
||||
* Used when creating strategies through registry
|
||||
*/
|
||||
private storeStrategy(strategy: BaseStrategy): void {
|
||||
this.strategies.set(strategy.id, strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered strategies
|
||||
*/
|
||||
public getAllStrategies(): BaseStrategy[] {
|
||||
return Array.from(this.strategies.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a strategy by ID
|
||||
*/
|
||||
public getStrategyById(id: string): BaseStrategy | undefined {
|
||||
return this.strategies.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a strategy by ID
|
||||
*/
|
||||
public deleteStrategy(id: string): boolean {
|
||||
return this.strategies.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of a strategy instance
|
||||
*/
|
||||
public getStrategyType(strategy: BaseStrategy): StrategyType {
|
||||
// Determine the type based on constructor
|
||||
if (strategy instanceof MovingAverageCrossover) {
|
||||
return 'MOVING_AVERAGE_CROSSOVER';
|
||||
} else if (strategy instanceof MeanReversionStrategy) {
|
||||
return 'MEAN_REVERSION';
|
||||
}
|
||||
|
||||
// Default to CUSTOM if we can't determine
|
||||
return 'CUSTOM';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import { BaseStrategy, BarData, Order, StrategyParameters } from '../Strategy';
|
||||
import { TechnicalIndicators } from '../analysis/TechnicalIndicators';
|
||||
|
||||
export interface VectorizedStrategyParams extends StrategyParameters {
|
||||
lookbackPeriod: number; // Data history to use for calculations
|
||||
positionSize: number; // Position size as percentage (0-1)
|
||||
stopLoss: number; // Stop loss percentage (0-1)
|
||||
takeProfit: number; // Take profit percentage (0-1)
|
||||
useBollingerBands: boolean; // Use Bollinger Bands for volatility
|
||||
bollingerPeriod: number; // Bollinger Band period
|
||||
bollingerDeviation: number; // Number of standard deviations
|
||||
}
|
||||
|
||||
/**
|
||||
* Vectorized Strategy Base Class
|
||||
*
|
||||
* A performant strategy implementation designed for fast backtesting.
|
||||
* Instead of processing bars one at a time, it pre-calculates signals
|
||||
* for large chunks of data and executes them in batches.
|
||||
*/
|
||||
export abstract class VectorizedStrategy extends BaseStrategy {
|
||||
// Default parameters
|
||||
protected static readonly DEFAULT_PARAMS: VectorizedStrategyParams = {
|
||||
lookbackPeriod: 100,
|
||||
positionSize: 0.2, // 20% of portfolio
|
||||
stopLoss: 0.02, // 2% stop loss
|
||||
takeProfit: 0.05, // 5% take profit
|
||||
useBollingerBands: true,
|
||||
bollingerPeriod: 20,
|
||||
bollingerDeviation: 2
|
||||
};
|
||||
|
||||
protected indicators: Map<string, {
|
||||
prices: number[];
|
||||
signals: Array<1 | 0 | -1>; // 1 = BUY, 0 = NEUTRAL, -1 = SELL
|
||||
stopLevels: number[];
|
||||
profitLevels: number[];
|
||||
bollingerUpper?: number[];
|
||||
bollingerLower?: number[];
|
||||
}> = new Map();
|
||||
|
||||
protected stopLossLevels: Map<string, number> = new Map();
|
||||
protected takeProfitLevels: Map<string, number> = new Map();
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
symbols: string[],
|
||||
params: Partial<VectorizedStrategyParams> = {}
|
||||
) {
|
||||
super(id, name, description, symbols, {
|
||||
...VectorizedStrategy.DEFAULT_PARAMS,
|
||||
...params
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Initialize state for each symbol
|
||||
for (const symbol of this.symbols) {
|
||||
this.indicators.set(symbol, {
|
||||
prices: [],
|
||||
signals: [],
|
||||
stopLevels: [],
|
||||
profitLevels: []
|
||||
});
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
const params = this.parameters as VectorizedStrategyParams;
|
||||
|
||||
if (params.positionSize <= 0 || params.positionSize > 1) {
|
||||
console.warn('Position size should be between 0 and 1');
|
||||
params.positionSize = 0.2; // Reset to default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main method that must be implemented by vectorized strategy subclasses.
|
||||
* It should compute signals based on price data in a vectorized manner.
|
||||
*/
|
||||
protected abstract computeSignals(
|
||||
symbol: string,
|
||||
prices: number[],
|
||||
volumes: number[]
|
||||
): Array<1 | 0 | -1>;
|
||||
|
||||
async onBar(bar: BarData): Promise<Order[]> {
|
||||
const symbol = bar.symbol;
|
||||
const params = this.parameters as VectorizedStrategyParams;
|
||||
|
||||
// Update market data
|
||||
this.addBar(bar);
|
||||
|
||||
// Get historical data
|
||||
const bars = this.context.marketData.get(symbol) || [];
|
||||
|
||||
// If we don't have enough bars yet, wait
|
||||
if (bars.length < params.lookbackPeriod) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract price and volume data
|
||||
const prices = TechnicalIndicators.extractPrice(bars, 'close');
|
||||
const volumes = bars.map(b => b.volume);
|
||||
|
||||
// Update indicators cache
|
||||
const indicators = this.indicators.get(symbol)!;
|
||||
indicators.prices = prices;
|
||||
|
||||
// Check for stop loss and take profit first
|
||||
const orders: Order[] = [];
|
||||
|
||||
if (this.hasPosition(symbol)) {
|
||||
const position = this.getPosition(symbol)!;
|
||||
const currentPrice = bar.close;
|
||||
|
||||
const stopLossLevel = this.stopLossLevels.get(symbol);
|
||||
const takeProfitLevel = this.takeProfitLevels.get(symbol);
|
||||
|
||||
// Check stop loss
|
||||
if (stopLossLevel && currentPrice <= stopLossLevel) {
|
||||
orders.push(this.createMarketOrder(symbol, 'SELL', position.quantity));
|
||||
this.stopLossLevels.delete(symbol);
|
||||
this.takeProfitLevels.delete(symbol);
|
||||
return orders;
|
||||
}
|
||||
|
||||
// Check take profit
|
||||
if (takeProfitLevel && currentPrice >= takeProfitLevel) {
|
||||
orders.push(this.createMarketOrder(symbol, 'SELL', position.quantity));
|
||||
this.stopLossLevels.delete(symbol);
|
||||
this.takeProfitLevels.delete(symbol);
|
||||
return orders;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute vectorized signals only when we have enough new data
|
||||
// (optimization to avoid recomputing on every bar)
|
||||
if (bars.length % 10 === 0 || !indicators.signals.length) {
|
||||
indicators.signals = this.computeSignals(symbol, prices, volumes);
|
||||
|
||||
// Update Bollinger Bands if configured
|
||||
if (params.useBollingerBands) {
|
||||
const bands = TechnicalIndicators.bollingerBands(
|
||||
prices,
|
||||
params.bollingerPeriod,
|
||||
params.bollingerDeviation
|
||||
);
|
||||
indicators.bollingerUpper = bands.upper;
|
||||
indicators.bollingerLower = bands.lower;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the latest signal
|
||||
const latestSignal = indicators.signals[indicators.signals.length - 1];
|
||||
|
||||
// Generate orders based on signals
|
||||
if (latestSignal === 1 && !this.hasPosition(symbol)) {
|
||||
// Buy signal - calculate position size
|
||||
const cash = this.getAvailableCash();
|
||||
const positionValue = cash * params.positionSize;
|
||||
const quantity = Math.floor(positionValue / bar.close);
|
||||
|
||||
if (quantity > 0) {
|
||||
orders.push(this.createMarketOrder(symbol, 'BUY', quantity));
|
||||
|
||||
// Set stop loss and take profit levels
|
||||
this.stopLossLevels.set(symbol, bar.close * (1 - params.stopLoss));
|
||||
this.takeProfitLevels.set(symbol, bar.close * (1 + params.takeProfit));
|
||||
}
|
||||
} else if (latestSignal === -1 && this.hasPosition(symbol)) {
|
||||
// Sell signal
|
||||
const position = this.getPosition(symbol)!;
|
||||
orders.push(this.createMarketOrder(symbol, 'SELL', position.quantity));
|
||||
this.stopLossLevels.delete(symbol);
|
||||
this.takeProfitLevels.delete(symbol);
|
||||
}
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
async onOrderFilled(order: Order): Promise<void> {
|
||||
// Update stop loss and take profit levels based on fill price
|
||||
if (order.side === 'BUY') {
|
||||
const params = this.parameters as VectorizedStrategyParams;
|
||||
this.stopLossLevels.set(order.symbol, order.fillPrice! * (1 - params.stopLoss));
|
||||
this.takeProfitLevels.set(order.symbol, order.fillPrice! * (1 + params.takeProfit));
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
// Clean up any resources or state
|
||||
this.indicators.clear();
|
||||
this.stopLossLevels.clear();
|
||||
this.takeProfitLevels.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,12 @@ import { Hono } from 'hono';
|
|||
import { WebSocketServer } from 'ws';
|
||||
import Redis from 'ioredis';
|
||||
import * as cron from 'node-cron';
|
||||
import { BaseStrategy } from './core/Strategy';
|
||||
import { StrategyRegistry } from './core/strategies/StrategyRegistry';
|
||||
import { BacktestService, BacktestRequest } from './core/backtesting/BacktestService';
|
||||
import { BacktestResult } from './core/backtesting/BacktestEngine';
|
||||
import { PerformanceAnalytics } from './core/backtesting/PerformanceAnalytics';
|
||||
import { StrategyController } from './controllers/StrategyController';
|
||||
|
||||
const app = new Hono();
|
||||
const redis = new Redis({
|
||||
|
|
@ -14,6 +20,10 @@ const redis = new Redis({
|
|||
// WebSocket server for real-time strategy updates
|
||||
const wss = new WebSocketServer({ port: 8082 });
|
||||
|
||||
// Initialize strategy registry and backtest service
|
||||
const strategyRegistry = StrategyRegistry.getInstance();
|
||||
const backtestService = new BacktestService();
|
||||
|
||||
// Strategy interfaces
|
||||
interface TradingStrategy {
|
||||
id: string;
|
||||
|
|
@ -48,6 +58,9 @@ interface StrategySignal {
|
|||
// In-memory strategy registry (in production, this would be persisted)
|
||||
const strategies = new Map<string, TradingStrategy>();
|
||||
|
||||
// Initialize strategy controller
|
||||
const strategyController = new StrategyController();
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (c) => {
|
||||
return c.json({
|
||||
|
|
@ -56,41 +69,214 @@ app.get('/health', (c) => {
|
|||
timestamp: new Date(),
|
||||
version: '1.0.0',
|
||||
activeStrategies: Array.from(strategies.values()).filter(s => s.status === 'ACTIVE').length,
|
||||
registeredStrategies: strategyRegistry.getAllStrategies().length,
|
||||
connections: wss.clients.size
|
||||
});
|
||||
});
|
||||
|
||||
// Get all strategies
|
||||
// API Routes
|
||||
// Strategy management endpoints
|
||||
app.get('/api/strategy-types', async (c) => {
|
||||
try {
|
||||
const types = Object.values(strategyRegistry.getStrategyTypes());
|
||||
return c.json({ success: true, data: types });
|
||||
} catch (error) {
|
||||
console.error('Error getting strategy types:', error);
|
||||
return c.json({ success: false, error: 'Failed to get strategy types' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/strategies', async (c) => {
|
||||
try {
|
||||
const strategiesList = Array.from(strategies.values());
|
||||
return c.json({
|
||||
success: true,
|
||||
data: strategiesList
|
||||
});
|
||||
const strategies = strategyRegistry.getAllStrategies();
|
||||
const serializedStrategies = strategies.map(strategy => ({
|
||||
id: strategy.id,
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
symbols: strategy.symbols,
|
||||
parameters: strategy.parameters,
|
||||
type: strategyRegistry.getStrategyType(strategy)
|
||||
}));
|
||||
|
||||
return c.json({ success: true, data: serializedStrategies });
|
||||
} catch (error) {
|
||||
console.error('Error fetching strategies:', error);
|
||||
return c.json({ success: false, error: 'Failed to fetch strategies' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Get specific strategy
|
||||
app.get('/api/strategies/:id', async (c) => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
const strategy = strategies.get(id);
|
||||
const strategy = strategyRegistry.getStrategyById(id);
|
||||
|
||||
if (!strategy) {
|
||||
return c.json({ success: false, error: 'Strategy not found' }, 404);
|
||||
return c.json({ success: false, error: `Strategy with ID ${id} not found` }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: strategy });
|
||||
|
||||
const type = strategyRegistry.getStrategyType(strategy);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: strategy.id,
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
symbols: strategy.symbols,
|
||||
parameters: strategy.parameters,
|
||||
type
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching strategy:', error);
|
||||
return c.json({ success: false, error: 'Failed to fetch strategy' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/strategies', async (c) => {
|
||||
try {
|
||||
const { name, description, symbols, parameters, type } = await c.req.json();
|
||||
|
||||
if (!type) {
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Invalid strategy type'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const strategy = strategyRegistry.createStrategy(
|
||||
type,
|
||||
`strategy_${Date.now()}`, // Generate an ID
|
||||
name || `New ${type} Strategy`,
|
||||
description || `Generated ${type} strategy`,
|
||||
symbols || [],
|
||||
parameters || {}
|
||||
);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: strategy.id,
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
symbols: strategy.symbols,
|
||||
parameters: strategy.parameters,
|
||||
type
|
||||
}
|
||||
}, 201);
|
||||
} catch (error) {
|
||||
console.error('Error creating strategy:', error);
|
||||
return c.json({ success: false, error: (error as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/strategies/:id', async (c) => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
const { name, description, symbols, parameters } = await c.req.json();
|
||||
|
||||
const strategy = strategyRegistry.getStrategyById(id);
|
||||
|
||||
if (!strategy) {
|
||||
return c.json({ success: false, error: `Strategy with ID ${id} not found` }, 404);
|
||||
}
|
||||
|
||||
// Update properties
|
||||
if (name !== undefined) strategy.name = name;
|
||||
if (description !== undefined) strategy.description = description;
|
||||
if (symbols !== undefined) (strategy as any).symbols = symbols; // Hack since symbols is readonly
|
||||
if (parameters !== undefined) strategy.parameters = parameters;
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: strategy.id,
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
symbols: strategy.symbols,
|
||||
parameters: strategy.parameters,
|
||||
type: strategyRegistry.getStrategyType(strategy)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating strategy:', error);
|
||||
return c.json({ success: false, error: (error as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/strategies/:id', async (c) => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
const success = strategyRegistry.deleteStrategy(id);
|
||||
|
||||
if (!success) {
|
||||
return c.json({ success: false, error: `Strategy with ID ${id} not found` }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: { id } });
|
||||
} catch (error) {
|
||||
console.error('Error deleting strategy:', error);
|
||||
return c.json({ success: false, error: (error as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Backtesting endpoints
|
||||
app.post('/api/backtest', async (c) => {
|
||||
try {
|
||||
const backtestRequest = await c.req.json() as BacktestRequest;
|
||||
|
||||
// Validate request
|
||||
if (!backtestRequest.strategyType) {
|
||||
return c.json({ success: false, error: 'Strategy type is required' }, 400);
|
||||
}
|
||||
|
||||
if (!backtestRequest.symbols || backtestRequest.symbols.length === 0) {
|
||||
return c.json({ success: false, error: 'At least one symbol is required' }, 400);
|
||||
}
|
||||
|
||||
// Run the backtest
|
||||
const result = await backtestService.runBacktest(backtestRequest);
|
||||
|
||||
// Enhance results with additional metrics
|
||||
const enhancedResult = PerformanceAnalytics.enhanceResults(result);
|
||||
|
||||
// Calculate additional analytics
|
||||
const monthlyReturns = PerformanceAnalytics.calculateMonthlyReturns(result.dailyReturns);
|
||||
const drawdowns = PerformanceAnalytics.analyzeDrawdowns(result.dailyReturns);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
...enhancedResult,
|
||||
monthlyReturns,
|
||||
drawdowns
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Backtest error:', error);
|
||||
return c.json({ success: false, error: (error as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/optimize', async (c) => {
|
||||
try {
|
||||
const { baseRequest, parameterGrid } = await c.req.json();
|
||||
|
||||
// Validate request
|
||||
if (!baseRequest || !parameterGrid) {
|
||||
return c.json({ success: false, error: 'Base request and parameter grid are required' }, 400);
|
||||
}
|
||||
|
||||
// Run optimization
|
||||
const results = await backtestService.optimizeStrategy(baseRequest, parameterGrid);
|
||||
|
||||
return c.json({ success: true, data: results });
|
||||
} catch (error) {
|
||||
console.error('Strategy optimization error:', error);
|
||||
return c.json({ success: false, error: (error as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Create new strategy
|
||||
app.post('/api/strategies', async (c) => {
|
||||
try {
|
||||
|
|
@ -252,6 +438,32 @@ app.get('/api/strategies/:id/signals', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Get strategy trades
|
||||
app.get('/api/strategies/:id/trades', async (c) => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
const limit = parseInt(c.req.query('limit') || '50');
|
||||
|
||||
const tradeKeys = await redis.keys(`trade:${id}:*`);
|
||||
const trades: any[] = [];
|
||||
|
||||
for (const key of tradeKeys.slice(0, limit)) {
|
||||
const data = await redis.get(key);
|
||||
if (data) {
|
||||
trades.push(JSON.parse(data));
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: trades.sort((a: any, b: any) => new Date(b.exitTime || b.timestamp).getTime() - new Date(a.exitTime || a.timestamp).getTime())
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching strategy trades:', error);
|
||||
return c.json({ success: false, error: 'Failed to fetch trades' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate demo signal (for testing)
|
||||
app.post('/api/strategies/:id/generate-signal', async (c) => {
|
||||
try {
|
||||
|
|
@ -378,6 +590,91 @@ cron.schedule('*/5 * * * *', async () => {
|
|||
}
|
||||
});
|
||||
|
||||
// Backtesting API endpoints
|
||||
app.post('/api/backtest', async (c) => {
|
||||
try {
|
||||
const request = await c.req.json() as BacktestRequest;
|
||||
console.log('Received backtest request:', request);
|
||||
|
||||
const result = await backtestService.runBacktest(request);
|
||||
const enhancedResult = PerformanceAnalytics.enhanceResults(result);
|
||||
|
||||
// Store backtest result in Redis for persistence
|
||||
await redis.setex(
|
||||
`backtest:${result.strategyId}`,
|
||||
86400 * 7, // 7 days TTL
|
||||
JSON.stringify(enhancedResult)
|
||||
);
|
||||
|
||||
return c.json({ success: true, data: enhancedResult });
|
||||
} catch (error) {
|
||||
console.error('Backtest error:', error);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/backtest/optimize', async (c) => {
|
||||
try {
|
||||
const { baseRequest, parameterGrid } = await c.req.json() as {
|
||||
baseRequest: BacktestRequest,
|
||||
parameterGrid: Record<string, any[]>
|
||||
};
|
||||
|
||||
console.log('Received optimization request:', baseRequest, parameterGrid);
|
||||
|
||||
const results = await backtestService.optimizeStrategy(baseRequest, parameterGrid);
|
||||
|
||||
return c.json({ success: true, data: results });
|
||||
} catch (error) {
|
||||
console.error('Optimization error:', error);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/backtest/:id', async (c) => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
const data = await redis.get(`backtest:${id}`);
|
||||
|
||||
if (!data) {
|
||||
return c.json({ success: false, error: 'Backtest not found' }, 404);
|
||||
}
|
||||
|
||||
const result = JSON.parse(data) as BacktestResult;
|
||||
return c.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('Error fetching backtest:', error);
|
||||
return c.json({ success: false, error: 'Failed to fetch backtest' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/strategy-types', (c) => {
|
||||
const types = strategyRegistry.getStrategyTypes();
|
||||
return c.json({ success: true, data: types });
|
||||
});
|
||||
|
||||
app.get('/api/strategy-parameters/:type', (c) => {
|
||||
try {
|
||||
const type = c.req.param('type') as any;
|
||||
|
||||
if (!strategyRegistry.hasStrategyType(type)) {
|
||||
return c.json({ success: false, error: 'Strategy type not found' }, 404);
|
||||
}
|
||||
|
||||
const params = strategyRegistry.getDefaultParameters(type);
|
||||
return c.json({ success: true, data: params });
|
||||
} catch (error) {
|
||||
console.error('Error fetching strategy parameters:', error);
|
||||
return c.json({ success: false, error: 'Failed to fetch parameters' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Load existing strategies from Redis on startup
|
||||
async function loadStrategiesFromRedis() {
|
||||
try {
|
||||
|
|
@ -395,7 +692,7 @@ async function loadStrategiesFromRedis() {
|
|||
}
|
||||
}
|
||||
|
||||
const port = parseInt(process.env.PORT || '3003');
|
||||
const port = parseInt(process.env.PORT || '4001');
|
||||
|
||||
console.log(`🎯 Strategy Orchestrator starting on port ${port}`);
|
||||
console.log(`📡 WebSocket server running on port 8082`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { BacktestService, BacktestRequest } from '../../core/backtesting/BacktestService';
|
||||
import { StrategyRegistry, StrategyType } from '../../core/strategies/StrategyRegistry';
|
||||
import { MarketDataFeed } from '../../core/backtesting/MarketDataFeed';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../core/backtesting/MarketDataFeed');
|
||||
|
||||
describe('BacktestService', () => {
|
||||
let backtestService: BacktestService;
|
||||
let mockRequest: BacktestRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks and create fresh service instance
|
||||
jest.clearAllMocks();
|
||||
backtestService = new BacktestService('http://test.api');
|
||||
|
||||
// Create a standard backtest request for tests
|
||||
mockRequest = {
|
||||
strategyType: 'MEAN_REVERSION' as StrategyType,
|
||||
strategyParams: {
|
||||
lookback: 20,
|
||||
entryDeviation: 1.5,
|
||||
exitDeviation: 0.5,
|
||||
lookbackPeriod: 100
|
||||
},
|
||||
symbols: ['AAPL'],
|
||||
startDate: new Date('2023-01-01'),
|
||||
endDate: new Date('2023-02-01'),
|
||||
initialCapital: 100000,
|
||||
dataResolution: '1d',
|
||||
commission: 0.001,
|
||||
slippage: 0.001,
|
||||
mode: 'vector'
|
||||
};
|
||||
|
||||
// Mock the MarketDataFeed implementation
|
||||
(MarketDataFeed.prototype.getHistoricalData as jest.Mock).mockResolvedValue([
|
||||
// Generate some sample data
|
||||
...Array(30).fill(0).map((_, i) => ({
|
||||
symbol: 'AAPL',
|
||||
timestamp: new Date(`2023-01-${(i + 1).toString().padStart(2, '0')}`),
|
||||
open: 150 + Math.random() * 10,
|
||||
high: 155 + Math.random() * 10,
|
||||
low: 145 + Math.random() * 10,
|
||||
close: 150 + Math.random() * 10,
|
||||
volume: 1000000 + Math.random() * 500000
|
||||
}))
|
||||
]);
|
||||
});
|
||||
|
||||
test('should run a backtest successfully', async () => {
|
||||
// Act
|
||||
const result = await backtestService.runBacktest(mockRequest);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.strategyId).toBeDefined();
|
||||
expect(result.initialCapital).toBe(100000);
|
||||
expect(result.trades).toBeDefined();
|
||||
expect(result.dailyReturns).toBeDefined();
|
||||
|
||||
// Verify market data was requested
|
||||
expect(MarketDataFeed.prototype.getHistoricalData).toHaveBeenCalledTimes(mockRequest.symbols.length);
|
||||
});
|
||||
|
||||
test('should optimize strategy parameters', async () => {
|
||||
// Arrange
|
||||
const parameterGrid = {
|
||||
lookback: [10, 20],
|
||||
entryDeviation: [1.0, 1.5, 2.0]
|
||||
};
|
||||
|
||||
// We should get 2×3 = 6 combinations
|
||||
|
||||
// Act
|
||||
const results = await backtestService.optimizeStrategy(mockRequest, parameterGrid);
|
||||
|
||||
// Assert
|
||||
expect(results).toHaveLength(6);
|
||||
expect(results[0].parameters).toBeDefined();
|
||||
|
||||
// Check that results are sorted by performance (sharpe ratio)
|
||||
for (let i = 0; i < results.length - 1; i++) {
|
||||
expect(results[i].sharpeRatio).toBeGreaterThanOrEqual(results[i + 1].sharpeRatio);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle errors during backtest', async () => {
|
||||
// Arrange
|
||||
(MarketDataFeed.prototype.getHistoricalData as jest.Mock).mockRejectedValue(
|
||||
new Error('Data source error')
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
await expect(backtestService.runBacktest(mockRequest))
|
||||
.rejects
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
test('should generate correct parameter combinations', () => {
|
||||
// Arrange
|
||||
const grid = {
|
||||
param1: [1, 2],
|
||||
param2: ['a', 'b'],
|
||||
param3: [true, false]
|
||||
};
|
||||
|
||||
// Act
|
||||
const combinations = (backtestService as any).generateParameterCombinations(grid, Object.keys(grid));
|
||||
|
||||
// Assert - should get 2×2×2 = 8 combinations
|
||||
expect(combinations).toHaveLength(8);
|
||||
|
||||
// Check that all combinations are generated
|
||||
expect(combinations).toContainEqual({ param1: 1, param2: 'a', param3: true });
|
||||
expect(combinations).toContainEqual({ param1: 1, param2: 'a', param3: false });
|
||||
expect(combinations).toContainEqual({ param1: 1, param2: 'b', param3: true });
|
||||
expect(combinations).toContainEqual({ param1: 1, param2: 'b', param3: false });
|
||||
expect(combinations).toContainEqual({ param1: 2, param2: 'a', param3: true });
|
||||
expect(combinations).toContainEqual({ param1: 2, param2: 'a', param3: false });
|
||||
expect(combinations).toContainEqual({ param1: 2, param2: 'b', param3: true });
|
||||
expect(combinations).toContainEqual({ param1: 2, param2: 'b', param3: false });
|
||||
});
|
||||
|
||||
test('should track active backtests', () => {
|
||||
// Arrange
|
||||
const activeBacktests = (backtestService as any).activeBacktests;
|
||||
|
||||
// Act
|
||||
let promise = backtestService.runBacktest(mockRequest);
|
||||
|
||||
// Assert
|
||||
expect(activeBacktests.size).toBe(1);
|
||||
|
||||
// Clean up
|
||||
return promise;
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, test, expect } from 'bun:test';
|
||||
import { PerformanceAnalytics } from '../../core/backtesting/PerformanceAnalytics';
|
||||
import { BacktestResult } from '../../core/backtesting/BacktestEngine';
|
||||
|
||||
describe('PerformanceAnalytics', () => {
|
||||
// Sample backtest result for testing
|
||||
const sampleResult: BacktestResult = {
|
||||
strategyId: 'test-strategy',
|
||||
startDate: new Date('2023-01-01'),
|
||||
endDate: new Date('2023-12-31'),
|
||||
duration: 31536000000, // 1 year in ms
|
||||
initialCapital: 100000,
|
||||
finalCapital: 125000,
|
||||
totalReturn: 0.25, // 25% return
|
||||
annualizedReturn: 0.25,
|
||||
sharpeRatio: 1.5,
|
||||
maxDrawdown: 0.10, // 10% drawdown
|
||||
maxDrawdownDuration: 30, // 30 days
|
||||
winRate: 0.6, // 60% win rate
|
||||
totalTrades: 50,
|
||||
winningTrades: 30,
|
||||
losingTrades: 20,
|
||||
averageWinningTrade: 0.05, // 5% average win
|
||||
averageLosingTrade: -0.03, // 3% average loss
|
||||
profitFactor: 2.5,
|
||||
dailyReturns: [
|
||||
// Generate 365 days of sample daily returns with some randomness
|
||||
...Array(365).fill(0).map((_, i) => ({
|
||||
date: new Date(new Date('2023-01-01').getTime() + i * 24 * 3600 * 1000),
|
||||
return: 0.001 + (Math.random() - 0.45) * 0.01 // Mean positive return with noise
|
||||
}))
|
||||
],
|
||||
trades: [
|
||||
// Generate sample trades
|
||||
...Array(50).fill(0).map((_, i) => ({
|
||||
symbol: 'AAPL',
|
||||
entryTime: new Date(`2023-${Math.floor(i / 4) + 1}-${(i % 28) + 1}`),
|
||||
entryPrice: 150 + Math.random() * 10,
|
||||
exitTime: new Date(`2023-${Math.floor(i / 4) + 1}-${(i % 28) + 5}`),
|
||||
exitPrice: 155 + Math.random() * 10,
|
||||
quantity: 10,
|
||||
pnl: 500 * (Math.random() - 0.3), // Some wins, some losses
|
||||
pnlPercent: 0.05 * (Math.random() - 0.3)
|
||||
}))
|
||||
]
|
||||
};
|
||||
|
||||
test('should calculate advanced metrics', () => {
|
||||
// Act
|
||||
const enhancedResult = PerformanceAnalytics.enhanceResults(sampleResult);
|
||||
|
||||
// Assert
|
||||
expect(enhancedResult.sortinoRatio).toBeDefined();
|
||||
expect(enhancedResult.calmarRatio).toBeDefined();
|
||||
expect(enhancedResult.omegaRatio).toBeDefined();
|
||||
expect(enhancedResult.cagr).toBeDefined();
|
||||
expect(enhancedResult.volatility).toBeDefined();
|
||||
expect(enhancedResult.ulcerIndex).toBeDefined();
|
||||
|
||||
// Check that the original result properties are preserved
|
||||
expect(enhancedResult.strategyId).toBe(sampleResult.strategyId);
|
||||
expect(enhancedResult.totalReturn).toBe(sampleResult.totalReturn);
|
||||
|
||||
// Validate some calculations
|
||||
expect(enhancedResult.calmarRatio).toBeCloseTo(sampleResult.annualizedReturn / sampleResult.maxDrawdown);
|
||||
expect(typeof enhancedResult.sortinoRatio).toBe('number');
|
||||
});
|
||||
|
||||
test('should calculate monthly returns', () => {
|
||||
// Act
|
||||
const monthlyReturns = PerformanceAnalytics.calculateMonthlyReturns(sampleResult.dailyReturns);
|
||||
|
||||
// Assert
|
||||
expect(monthlyReturns).toBeDefined();
|
||||
expect(monthlyReturns.length).toBe(12); // 12 months in a year
|
||||
expect(monthlyReturns[0].year).toBe(2023);
|
||||
expect(monthlyReturns[0].month).toBe(0); // January is 0
|
||||
|
||||
// Verify sorting
|
||||
let lastDate = { year: 0, month: 0 };
|
||||
for (const mr of monthlyReturns) {
|
||||
expect(mr.year >= lastDate.year).toBeTruthy();
|
||||
if (mr.year === lastDate.year) {
|
||||
expect(mr.month >= lastDate.month).toBeTruthy();
|
||||
}
|
||||
lastDate = { year: mr.year, month: mr.month };
|
||||
}
|
||||
});
|
||||
|
||||
test('should analyze drawdowns', () => {
|
||||
// Act
|
||||
const drawdowns = PerformanceAnalytics.analyzeDrawdowns(sampleResult.dailyReturns);
|
||||
|
||||
// Assert
|
||||
expect(drawdowns).toBeDefined();
|
||||
expect(drawdowns.length).toBeGreaterThan(0);
|
||||
|
||||
// Check drawdown properties
|
||||
for (const dd of drawdowns) {
|
||||
expect(dd.startDate).toBeInstanceOf(Date);
|
||||
expect(dd.endDate).toBeInstanceOf(Date);
|
||||
expect(dd.drawdown).toBeGreaterThan(0);
|
||||
expect(dd.durationDays).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Recovery date and days might be null for ongoing drawdowns
|
||||
if (dd.recoveryDate) {
|
||||
expect(dd.recoveryDate).toBeInstanceOf(Date);
|
||||
expect(dd.recoveryDays).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check sorting by drawdown magnitude
|
||||
for (let i = 0; i < drawdowns.length - 1; i++) {
|
||||
expect(drawdowns[i].drawdown).toBeGreaterThanOrEqual(drawdowns[i + 1].drawdown);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle empty inputs', () => {
|
||||
// Act & Assert
|
||||
expect(() => PerformanceAnalytics.calculateMonthlyReturns([])).not.toThrow();
|
||||
expect(() => PerformanceAnalytics.analyzeDrawdowns([])).not.toThrow();
|
||||
|
||||
const emptyMonthlyReturns = PerformanceAnalytics.calculateMonthlyReturns([]);
|
||||
const emptyDrawdowns = PerformanceAnalytics.analyzeDrawdowns([]);
|
||||
|
||||
expect(emptyMonthlyReturns).toEqual([]);
|
||||
expect(emptyDrawdowns).toEqual([]);
|
||||
});
|
||||
|
||||
test('should calculate special cases correctly', () => {
|
||||
// Case with no negative returns
|
||||
const allPositiveReturns = {
|
||||
dailyReturns: Array(30).fill(0).map((_, i) => ({
|
||||
date: new Date(`2023-01-${i + 1}`),
|
||||
return: 0.01 // Always positive
|
||||
}))
|
||||
};
|
||||
|
||||
// Case with no recovery from drawdown
|
||||
const noRecoveryReturns = {
|
||||
dailyReturns: [
|
||||
...Array(30).fill(0).map((_, i) => ({
|
||||
date: new Date(`2023-01-${i + 1}`),
|
||||
return: 0.01 // Positive returns
|
||||
})),
|
||||
...Array(30).fill(0).map((_, i) => ({
|
||||
date: new Date(`2023-02-${i + 1}`),
|
||||
return: -0.005 // Negative returns with no recovery
|
||||
}))
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
const positiveMetrics = PerformanceAnalytics.enhanceResults({
|
||||
...sampleResult,
|
||||
dailyReturns: allPositiveReturns.dailyReturns
|
||||
});
|
||||
|
||||
const noRecoveryDrawdowns = PerformanceAnalytics.analyzeDrawdowns(noRecoveryReturns.dailyReturns);
|
||||
|
||||
// Assert
|
||||
expect(positiveMetrics.sortinoRatio).toBe(Infinity); // No downside risk
|
||||
|
||||
// Last drawdown should have no recovery
|
||||
const lastDrawdown = noRecoveryDrawdowns[noRecoveryDrawdowns.length - 1];
|
||||
expect(lastDrawdown.recoveryDate).toBeNull();
|
||||
expect(lastDrawdown.recoveryDays).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, jest } from 'bun:test';
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebSocket } from 'ws';
|
||||
import { StrategyExecutionService } from '../../core/execution/StrategyExecutionService';
|
||||
import { StrategyRegistry } from '../../core/strategies/StrategyRegistry';
|
||||
import { MarketDataFeed } from '../../core/backtesting/MarketDataFeed';
|
||||
import { BaseStrategy, BarData, Order } from '../../core/Strategy';
|
||||
|
||||
// Mock WebSocket to avoid actual network connections during tests
|
||||
jest.mock('ws', () => {
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class MockWebSocket extends EventEmitter {
|
||||
static OPEN = 1;
|
||||
readyState = 1;
|
||||
close = jest.fn();
|
||||
send = jest.fn();
|
||||
}
|
||||
|
||||
class MockServer extends EventEmitter {
|
||||
clients = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Add a mock client to the set
|
||||
const mockClient = new MockWebSocket();
|
||||
this.clients.add(mockClient);
|
||||
}
|
||||
|
||||
close(callback) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
WebSocket: MockWebSocket,
|
||||
Server: MockServer
|
||||
};
|
||||
});
|
||||
|
||||
// Mock MarketDataFeed to avoid actual API calls
|
||||
jest.mock('../../core/backtesting/MarketDataFeed', () => {
|
||||
return {
|
||||
MarketDataFeed: class {
|
||||
async getHistoricalData(symbol, resolution, startDate, endDate) {
|
||||
// Return mock data
|
||||
return [
|
||||
{
|
||||
symbol,
|
||||
timestamp: new Date(),
|
||||
open: 100,
|
||||
high: 105,
|
||||
low: 95,
|
||||
close: 102,
|
||||
volume: 1000
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock strategy for testing
|
||||
class MockStrategy extends BaseStrategy {
|
||||
name = 'MockStrategy';
|
||||
description = 'A mock strategy for testing';
|
||||
symbols = ['AAPL', 'MSFT'];
|
||||
parameters = { param1: 1, param2: 2 };
|
||||
|
||||
constructor(id: string) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {}
|
||||
|
||||
async stop(): Promise<void> {}
|
||||
|
||||
onBar(bar: BarData) {
|
||||
// Return a mock signal
|
||||
return {
|
||||
action: 'BUY',
|
||||
symbol: bar.symbol,
|
||||
price: bar.close,
|
||||
quantity: 10,
|
||||
metadata: { reason: 'Test signal' }
|
||||
};
|
||||
}
|
||||
|
||||
async onOrderFilled(order: Order): Promise<void> {}
|
||||
}
|
||||
|
||||
// Mock StrategyRegistry
|
||||
jest.mock('../../core/strategies/StrategyRegistry', () => {
|
||||
const mockInstance = {
|
||||
getStrategyById: jest.fn(),
|
||||
getStrategyTypes: () => [{ id: 'mock-strategy', name: 'Mock Strategy' }],
|
||||
getAllStrategies: () => [new MockStrategy('mock-1')]
|
||||
};
|
||||
|
||||
return {
|
||||
StrategyRegistry: {
|
||||
getInstance: () => mockInstance
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('StrategyExecutionService', () => {
|
||||
let executionService: StrategyExecutionService;
|
||||
let strategyRegistry: typeof StrategyRegistry.getInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create a new execution service for each test
|
||||
executionService = new StrategyExecutionService('http://localhost:3001/api', 8082);
|
||||
strategyRegistry = StrategyRegistry.getInstance();
|
||||
|
||||
// Setup mock strategy
|
||||
const mockStrategy = new MockStrategy('test-strategy');
|
||||
(strategyRegistry.getStrategyById as jest.Mock).mockReturnValue(mockStrategy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
executionService.shutdown();
|
||||
});
|
||||
|
||||
it('should initialize correctly', () => {
|
||||
expect(executionService).toBeDefined();
|
||||
});
|
||||
|
||||
it('should start a strategy correctly', () => {
|
||||
// Arrange & Act
|
||||
const result = executionService.startStrategy('test-strategy');
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
expect(strategyRegistry.getStrategyById).toHaveBeenCalledWith('test-strategy');
|
||||
|
||||
// Check if WebSocket broadcast happened
|
||||
const ws = executionService['webSocketServer'].clients.values().next().value;
|
||||
expect(ws.send).toHaveBeenCalled();
|
||||
|
||||
// Check the broadcast message contains the correct type
|
||||
const lastCall = ws.send.mock.calls[0][0];
|
||||
const message = JSON.parse(lastCall);
|
||||
expect(message.type).toBe('strategy_started');
|
||||
expect(message.data.strategyId).toBe('test-strategy');
|
||||
});
|
||||
|
||||
it('should stop a strategy correctly', () => {
|
||||
// Arrange
|
||||
executionService.startStrategy('test-strategy');
|
||||
|
||||
// Act
|
||||
const result = executionService.stopStrategy('test-strategy');
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Check if WebSocket broadcast happened
|
||||
const ws = executionService['webSocketServer'].clients.values().next().value;
|
||||
const lastCallIndex = ws.send.mock.calls.length - 1;
|
||||
const lastCall = ws.send.mock.calls[lastCallIndex][0];
|
||||
const message = JSON.parse(lastCall);
|
||||
|
||||
expect(message.type).toBe('strategy_stopped');
|
||||
expect(message.data.strategyId).toBe('test-strategy');
|
||||
});
|
||||
|
||||
it('should pause a strategy correctly', () => {
|
||||
// Arrange
|
||||
executionService.startStrategy('test-strategy');
|
||||
|
||||
// Act
|
||||
const result = executionService.pauseStrategy('test-strategy');
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Check if WebSocket broadcast happened
|
||||
const ws = executionService['webSocketServer'].clients.values().next().value;
|
||||
const lastCallIndex = ws.send.mock.calls.length - 1;
|
||||
const lastCall = ws.send.mock.calls[lastCallIndex][0];
|
||||
const message = JSON.parse(lastCall);
|
||||
|
||||
expect(message.type).toBe('strategy_paused');
|
||||
expect(message.data.strategyId).toBe('test-strategy');
|
||||
});
|
||||
|
||||
it('should process market data and generate signals', async () => {
|
||||
// Arrange
|
||||
executionService.startStrategy('test-strategy');
|
||||
|
||||
// Act - Trigger market data polling manually
|
||||
await executionService['pollMarketData']('test-strategy');
|
||||
|
||||
// Assert - Check if signal was generated and broadcast
|
||||
const ws = executionService['webSocketServer'].clients.values().next().value;
|
||||
|
||||
// Find the strategy_signal message
|
||||
const signalMessages = ws.send.mock.calls
|
||||
.map(call => JSON.parse(call[0]))
|
||||
.filter(msg => msg.type === 'strategy_signal');
|
||||
|
||||
expect(signalMessages.length).toBeGreaterThan(0);
|
||||
expect(signalMessages[0].data.action).toBe('BUY');
|
||||
expect(signalMessages[0].data.strategyId).toBe('test-strategy');
|
||||
});
|
||||
|
||||
it('should handle WebSocket client connections', () => {
|
||||
// Arrange
|
||||
const mockWs = new WebSocket();
|
||||
const mockMessage = JSON.stringify({ type: 'get_active_strategies' });
|
||||
|
||||
// Act - Simulate connection and message
|
||||
executionService['webSocketServer'].emit('connection', mockWs);
|
||||
mockWs.emit('message', mockMessage);
|
||||
|
||||
// Assert
|
||||
expect(mockWs.send).toHaveBeenCalled();
|
||||
|
||||
// Check that the response is a strategy_status_list message
|
||||
const lastCall = mockWs.send.mock.calls[0][0];
|
||||
const message = JSON.parse(lastCall);
|
||||
expect(message.type).toBe('strategy_status_list');
|
||||
});
|
||||
|
||||
it('should shut down correctly', () => {
|
||||
// Act
|
||||
executionService.shutdown();
|
||||
|
||||
// Assert - WebSocket server should be closed
|
||||
const ws = executionService['webSocketServer'].clients.values().next().value;
|
||||
expect(ws.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
||||
import { MeanReversionStrategy } from '../../core/strategies/MeanReversionStrategy';
|
||||
import { BarData } from '../../core/Strategy';
|
||||
|
||||
describe('MeanReversionStrategy', () => {
|
||||
let strategy: MeanReversionStrategy;
|
||||
let mockData: BarData[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a strategy instance with test parameters
|
||||
strategy = new MeanReversionStrategy(
|
||||
'test_id',
|
||||
'Test Mean Reversion',
|
||||
'A test strategy',
|
||||
['AAPL'],
|
||||
{
|
||||
lookback: 20,
|
||||
entryDeviation: 1.5,
|
||||
exitDeviation: 0.5,
|
||||
lookbackPeriod: 100,
|
||||
positionSize: 0.2,
|
||||
stopLoss: 0.02,
|
||||
takeProfit: 0.05,
|
||||
useBollingerBands: true,
|
||||
bollingerPeriod: 20,
|
||||
bollingerDeviation: 2,
|
||||
rsiPeriod: 14,
|
||||
rsiOverbought: 70,
|
||||
rsiOversold: 30,
|
||||
useRsi: true
|
||||
}
|
||||
);
|
||||
|
||||
// Create mock price data
|
||||
const now = new Date();
|
||||
mockData = [];
|
||||
|
||||
// Create 100 bars of data with a mean-reverting pattern
|
||||
let price = 100;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// Add some mean reversion pattern (oscillating around 100)
|
||||
price = price + Math.sin(i / 10) * 5 + (Math.random() - 0.5) * 2;
|
||||
|
||||
mockData.push({
|
||||
symbol: 'AAPL',
|
||||
timestamp: new Date(now.getTime() - (100 - i) * 60000), // 1-minute bars
|
||||
open: price - 0.5,
|
||||
high: price + 1,
|
||||
low: price - 1,
|
||||
close: price,
|
||||
volume: 1000 + Math.random() * 1000
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should initialize with correct parameters', () => {
|
||||
expect(strategy.id).toBe('test_id');
|
||||
expect(strategy.name).toBe('Test Mean Reversion');
|
||||
expect(strategy.description).toBe('A test strategy');
|
||||
expect(strategy.symbols).toEqual(['AAPL']);
|
||||
expect(strategy.parameters.lookback).toBe(20);
|
||||
expect(strategy.parameters.entryDeviation).toBe(1.5);
|
||||
});
|
||||
|
||||
test('should generate signals with vectorized calculation', async () => {
|
||||
// Arrange a price series with fake mean reversion
|
||||
const results = await strategy.runVectorized({
|
||||
symbols: ['AAPL'],
|
||||
data: { 'AAPL': mockData },
|
||||
initialCapital: 10000,
|
||||
startIndex: 20, // Skip the first 20 bars for indicator warmup
|
||||
endIndex: mockData.length - 1
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(results).toBeDefined();
|
||||
expect(results.positions).toBeDefined();
|
||||
// Should generate at least one trade in this artificial data
|
||||
expect(results.trades.length).toBeGreaterThan(0);
|
||||
expect(results.equityCurve.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should calculate correct entry and exit signals', () => {
|
||||
// Mock the indicator calculations to test logic directly
|
||||
// We'll create a simple scenario where price is 2 standard deviations away
|
||||
const mockBar: BarData = {
|
||||
symbol: 'AAPL',
|
||||
timestamp: new Date(),
|
||||
open: 100,
|
||||
high: 102,
|
||||
low: 98,
|
||||
close: 100,
|
||||
volume: 1000
|
||||
};
|
||||
|
||||
// Mock the calculation context
|
||||
const context = {
|
||||
mean: 100,
|
||||
stdDev: 5,
|
||||
upperBand: 110,
|
||||
lowerBand: 90,
|
||||
rsi: 25, // Oversold
|
||||
shouldEnterLong: true,
|
||||
shouldExitLong: false,
|
||||
shouldEnterShort: false,
|
||||
shouldExitShort: false
|
||||
};
|
||||
|
||||
// Call the internal signal generation logic via a protected method
|
||||
// (For testing purposes, we're accessing a protected method)
|
||||
const result = (strategy as any).calculateSignals('AAPL', mockBar, context);
|
||||
|
||||
// Assert the signals based on our scenario
|
||||
expect(result).toBeDefined();
|
||||
expect(result.action).toBe('BUY'); // Should buy in oversold condition
|
||||
});
|
||||
|
||||
test('should handle empty data correctly', async () => {
|
||||
// Act & Assert
|
||||
await expect(async () => {
|
||||
await strategy.runVectorized({
|
||||
symbols: ['AAPL'],
|
||||
data: { 'AAPL': [] },
|
||||
initialCapital: 10000,
|
||||
startIndex: 0,
|
||||
endIndex: 0
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { BaseStrategy } from '../../core/Strategy';
|
||||
import { StrategyRegistry, StrategyType } from '../../core/strategies/StrategyRegistry';
|
||||
import { MovingAverageCrossover } from '../../core/strategies/MovingAverageCrossover';
|
||||
import { MeanReversionStrategy } from '../../core/strategies/MeanReversionStrategy';
|
||||
import { VectorizedStrategy } from '../../core/strategies/VectorizedStrategy';
|
||||
|
||||
describe('Strategy Registry', () => {
|
||||
let registry: StrategyRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the singleton for testing
|
||||
(StrategyRegistry as any).instance = null;
|
||||
registry = StrategyRegistry.getInstance();
|
||||
});
|
||||
|
||||
test('should create a MovingAverageCrossover strategy', () => {
|
||||
// Arrange
|
||||
const id = 'test_id';
|
||||
const name = 'Test Strategy';
|
||||
const description = 'A test strategy';
|
||||
const symbols = ['AAPL', 'MSFT'];
|
||||
const parameters = { fastPeriod: 10, slowPeriod: 30 };
|
||||
|
||||
// Act
|
||||
const strategy = registry.createStrategy(
|
||||
'MOVING_AVERAGE_CROSSOVER',
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
symbols,
|
||||
parameters
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(strategy).toBeInstanceOf(MovingAverageCrossover);
|
||||
expect(strategy.id).toEqual(id);
|
||||
expect(strategy.name).toEqual(name);
|
||||
expect(strategy.description).toEqual(description);
|
||||
expect(strategy.symbols).toEqual(symbols);
|
||||
expect(strategy.parameters).toMatchObject(parameters);
|
||||
});
|
||||
|
||||
test('should create a MeanReversion strategy', () => {
|
||||
// Arrange
|
||||
const id = 'test_id';
|
||||
const name = 'Test Strategy';
|
||||
const description = 'A test strategy';
|
||||
const symbols = ['AAPL', 'MSFT'];
|
||||
const parameters = { lookback: 20, entryDeviation: 1.5 };
|
||||
|
||||
// Act
|
||||
const strategy = registry.createStrategy(
|
||||
'MEAN_REVERSION',
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
symbols,
|
||||
parameters
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(strategy).toBeInstanceOf(MeanReversionStrategy);
|
||||
expect(strategy.id).toEqual(id);
|
||||
expect(strategy.name).toEqual(name);
|
||||
expect(strategy.description).toEqual(description);
|
||||
expect(strategy.symbols).toEqual(symbols);
|
||||
expect(strategy.parameters).toMatchObject(parameters);
|
||||
});
|
||||
|
||||
test('should throw error for invalid strategy type', () => {
|
||||
// Arrange
|
||||
const id = 'test_id';
|
||||
const name = 'Test Strategy';
|
||||
const description = 'A test strategy';
|
||||
const symbols = ['AAPL', 'MSFT'];
|
||||
const parameters = {};
|
||||
|
||||
// Act & Assert
|
||||
expect(() => {
|
||||
registry.createStrategy(
|
||||
'INVALID_TYPE' as StrategyType,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
symbols,
|
||||
parameters
|
||||
);
|
||||
}).toThrow("Strategy type 'INVALID_TYPE' is not registered");
|
||||
});
|
||||
|
||||
test('should register a custom strategy', () => {
|
||||
// Arrange
|
||||
const mockStrategyFactory = (
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
symbols: string[],
|
||||
parameters: any
|
||||
) => {
|
||||
return new MovingAverageCrossover(id, name, description, symbols, parameters);
|
||||
};
|
||||
|
||||
// Act
|
||||
registry.registerStrategy('CUSTOM' as StrategyType, mockStrategyFactory);
|
||||
|
||||
// Assert
|
||||
expect(registry.hasStrategyType('CUSTOM')).toBe(true);
|
||||
|
||||
const strategy = registry.createStrategy(
|
||||
'CUSTOM',
|
||||
'custom_id',
|
||||
'Custom Strategy',
|
||||
'A custom strategy',
|
||||
['BTC/USD'],
|
||||
{}
|
||||
);
|
||||
|
||||
expect(strategy).toBeInstanceOf(MovingAverageCrossover);
|
||||
});
|
||||
|
||||
test('should get default parameters for a strategy type', () => {
|
||||
// Act
|
||||
const macParams = registry.getDefaultParameters('MOVING_AVERAGE_CROSSOVER');
|
||||
const mrParams = registry.getDefaultParameters('MEAN_REVERSION');
|
||||
|
||||
// Assert
|
||||
expect(macParams).toHaveProperty('fastPeriod');
|
||||
expect(macParams).toHaveProperty('slowPeriod');
|
||||
expect(mrParams).toHaveProperty('lookback');
|
||||
expect(mrParams).toHaveProperty('entryDeviation');
|
||||
});
|
||||
|
||||
test('should return empty object for unknown strategy default parameters', () => {
|
||||
// Act
|
||||
const params = registry.getDefaultParameters('CUSTOM' as StrategyType);
|
||||
|
||||
// Assert
|
||||
expect(params).toEqual({});
|
||||
});
|
||||
|
||||
test('should get all registered strategy types', () => {
|
||||
// Act
|
||||
const types = registry.getStrategyTypes();
|
||||
|
||||
// Assert
|
||||
expect(types).toContain('MOVING_AVERAGE_CROSSOVER');
|
||||
expect(types).toContain('MEAN_REVERSION');
|
||||
});
|
||||
|
||||
test('should check if strategy type is registered', () => {
|
||||
// Act & Assert
|
||||
expect(registry.hasStrategyType('MOVING_AVERAGE_CROSSOVER')).toBe(true);
|
||||
expect(registry.hasStrategyType('INVALID_TYPE' as StrategyType)).toBe(false);
|
||||
});
|
||||
|
||||
test('should get all registered strategies', () => {
|
||||
// Arrange
|
||||
registry.createStrategy(
|
||||
'MOVING_AVERAGE_CROSSOVER',
|
||||
'mac_id',
|
||||
'MAC Strategy',
|
||||
'MAC strategy',
|
||||
['AAPL'],
|
||||
{}
|
||||
);
|
||||
|
||||
registry.createStrategy(
|
||||
'MEAN_REVERSION',
|
||||
'mr_id',
|
||||
'MR Strategy',
|
||||
'MR strategy',
|
||||
['MSFT'],
|
||||
{}
|
||||
);
|
||||
|
||||
// Act
|
||||
const strategies = registry.getAllStrategies();
|
||||
|
||||
// Assert
|
||||
expect(strategies).toHaveLength(2);
|
||||
expect(strategies[0].id).toEqual('mac_id');
|
||||
expect(strategies[1].id).toEqual('mr_id');
|
||||
});
|
||||
|
||||
test('should get strategy by ID', () => {
|
||||
// Arrange
|
||||
registry.createStrategy(
|
||||
'MOVING_AVERAGE_CROSSOVER',
|
||||
'mac_id',
|
||||
'MAC Strategy',
|
||||
'MAC strategy',
|
||||
['AAPL'],
|
||||
{}
|
||||
);
|
||||
|
||||
// Act
|
||||
const strategy = registry.getStrategyById('mac_id');
|
||||
const nonExistent = registry.getStrategyById('non_existent');
|
||||
|
||||
// Assert
|
||||
expect(strategy).not.toBeNull();
|
||||
expect(strategy?.id).toEqual('mac_id');
|
||||
expect(nonExistent).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should delete strategy by ID', () => {
|
||||
// Arrange
|
||||
registry.createStrategy(
|
||||
'MOVING_AVERAGE_CROSSOVER',
|
||||
'mac_id',
|
||||
'MAC Strategy',
|
||||
'MAC strategy',
|
||||
['AAPL'],
|
||||
{}
|
||||
);
|
||||
|
||||
// Act
|
||||
const result1 = registry.deleteStrategy('mac_id');
|
||||
const result2 = registry.deleteStrategy('non_existent');
|
||||
|
||||
// Assert
|
||||
expect(result1).toBe(true);
|
||||
expect(result2).toBe(false);
|
||||
expect(registry.getStrategyById('mac_id')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should identify strategy type from instance', () => {
|
||||
// Arrange
|
||||
const macStrategy = registry.createStrategy(
|
||||
'MOVING_AVERAGE_CROSSOVER',
|
||||
'mac_id',
|
||||
'MAC Strategy',
|
||||
'MAC strategy',
|
||||
['AAPL'],
|
||||
{}
|
||||
);
|
||||
|
||||
const mrStrategy = registry.createStrategy(
|
||||
'MEAN_REVERSION',
|
||||
'mr_id',
|
||||
'MR Strategy',
|
||||
'MR strategy',
|
||||
['MSFT'],
|
||||
{}
|
||||
);
|
||||
|
||||
// Act
|
||||
const macType = registry.getStrategyType(macStrategy);
|
||||
const mrType = registry.getStrategyType(mrStrategy);
|
||||
|
||||
// Assert
|
||||
expect(macType).toEqual('MOVING_AVERAGE_CROSSOVER');
|
||||
expect(mrType).toEqual('MEAN_REVERSION');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue