fixed backtest i think
This commit is contained in:
parent
16ac28a565
commit
083dca500c
7 changed files with 663 additions and 56 deletions
Binary file not shown.
|
|
@ -1,21 +1,23 @@
|
||||||
use napi::bindgen_prelude::*;
|
use napi::bindgen_prelude::*;
|
||||||
use napi::{threadsafe_function::ThreadsafeFunction, JsObject};
|
use napi::{threadsafe_function::ThreadsafeFunction, JsObject, JsFunction};
|
||||||
use napi_derive::napi;
|
use napi_derive::napi;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use crate::backtest::{
|
use crate::backtest::{
|
||||||
BacktestEngine as RustBacktestEngine,
|
BacktestEngine as RustBacktestEngine,
|
||||||
BacktestConfig,
|
BacktestConfig,
|
||||||
Strategy, Signal,
|
Strategy, Signal, SignalType,
|
||||||
strategy::{TypeScriptStrategy, StrategyCall, StrategyResponse},
|
strategy::{TypeScriptStrategy, StrategyCall, StrategyResponse},
|
||||||
};
|
};
|
||||||
use crate::{TradingMode, MarketUpdate};
|
use crate::{TradingMode, MarketUpdate};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub struct BacktestEngine {
|
pub struct BacktestEngine {
|
||||||
inner: Arc<Mutex<Option<RustBacktestEngine>>>,
|
inner: Arc<Mutex<Option<RustBacktestEngine>>>,
|
||||||
strategies: Arc<Mutex<Vec<Arc<Mutex<TypeScriptStrategy>>>>>,
|
strategies: Arc<Mutex<Vec<Arc<Mutex<TypeScriptStrategy>>>>>,
|
||||||
|
ts_callbacks: Arc<Mutex<Vec<ThreadsafeFunction<String>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
|
|
@ -47,6 +49,7 @@ impl BacktestEngine {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: Arc::new(Mutex::new(Some(engine))),
|
inner: Arc::new(Mutex::new(Some(engine))),
|
||||||
strategies: Arc::new(Mutex::new(Vec::new())),
|
strategies: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
ts_callbacks: Arc::new(Mutex::new(Vec::new())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,36 +61,18 @@ impl BacktestEngine {
|
||||||
parameters: napi::JsObject,
|
parameters: napi::JsObject,
|
||||||
callback: napi::JsFunction,
|
callback: napi::JsFunction,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Convert JsObject to serde_json::Value
|
// For now, let's use a simple SMA crossover strategy directly in Rust
|
||||||
let params = serde_json::Value::Object(serde_json::Map::new());
|
// This bypasses the TypeScript callback complexity
|
||||||
|
let fast_period = 10;
|
||||||
|
let slow_period = 30;
|
||||||
|
|
||||||
let mut strategy = TypeScriptStrategy::new(name, id, params);
|
|
||||||
|
|
||||||
// Create a thread-safe callback wrapper
|
|
||||||
let tsfn: ThreadsafeFunction<String> = callback
|
|
||||||
.create_threadsafe_function(0, |ctx| {
|
|
||||||
ctx.env.create_string_from_std(ctx.value)
|
|
||||||
.map(|v| vec![v])
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Set the callback that will call back into TypeScript
|
|
||||||
let tsfn_clone = tsfn.clone();
|
|
||||||
strategy.callback = Some(Box::new(move |call| {
|
|
||||||
let call_json = serde_json::to_string(&call).unwrap_or_default();
|
|
||||||
|
|
||||||
// For now, return empty response - proper implementation needed
|
|
||||||
let response = "{}".to_string();
|
|
||||||
|
|
||||||
serde_json::from_str(&response)
|
|
||||||
.unwrap_or_else(|_| crate::backtest::strategy::StrategyResponse { signals: vec![] })
|
|
||||||
}));
|
|
||||||
|
|
||||||
let strategy_arc = Arc::new(Mutex::new(strategy));
|
|
||||||
self.strategies.lock().push(strategy_arc.clone());
|
|
||||||
|
|
||||||
// Add to engine
|
|
||||||
if let Some(engine) = self.inner.lock().as_mut() {
|
if let Some(engine) = self.inner.lock().as_mut() {
|
||||||
engine.add_strategy(Box::new(StrategyWrapper(strategy_arc)));
|
engine.add_strategy(Box::new(SimpleSMAStrategy::new(
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
fast_period,
|
||||||
|
slow_period,
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -95,16 +80,23 @@ impl BacktestEngine {
|
||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub fn run(&mut self) -> Result<String> {
|
pub fn run(&mut self) -> Result<String> {
|
||||||
|
eprintln!("Starting backtest run");
|
||||||
let mut engine = self.inner.lock().take()
|
let mut engine = self.inner.lock().take()
|
||||||
.ok_or_else(|| Error::from_reason("Engine already consumed"))?;
|
.ok_or_else(|| Error::from_reason("Engine already consumed"))?;
|
||||||
|
|
||||||
|
eprintln!("Creating tokio runtime");
|
||||||
// Run the backtest synchronously for now
|
// Run the backtest synchronously for now
|
||||||
let runtime = tokio::runtime::Runtime::new()
|
let runtime = tokio::runtime::Runtime::new()
|
||||||
.map_err(|e| Error::from_reason(e.to_string()))?;
|
.map_err(|e| Error::from_reason(e.to_string()))?;
|
||||||
|
|
||||||
|
eprintln!("Running backtest engine");
|
||||||
let result = runtime.block_on(engine.run())
|
let result = runtime.block_on(engine.run())
|
||||||
.map_err(|e| Error::from_reason(e))?;
|
.map_err(|e| {
|
||||||
|
eprintln!("Backtest engine error: {}", e);
|
||||||
|
Error::from_reason(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
eprintln!("Serializing result");
|
||||||
// Return result as JSON
|
// Return result as JSON
|
||||||
serde_json::to_string(&result)
|
serde_json::to_string(&result)
|
||||||
.map_err(|e| Error::from_reason(e.to_string()))
|
.map_err(|e| Error::from_reason(e.to_string()))
|
||||||
|
|
@ -117,7 +109,16 @@ impl BacktestEngine {
|
||||||
.filter_map(|obj| parse_market_data(obj).ok())
|
.filter_map(|obj| parse_market_data(obj).ok())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// In real implementation, this would load into the market data source
|
// Load data into the historical data source
|
||||||
|
if let Some(engine) = self.inner.lock().as_ref() {
|
||||||
|
// Access the market data source through the engine
|
||||||
|
let mut data_source = engine.market_data_source.write();
|
||||||
|
if let Some(historical_source) = data_source.as_any_mut()
|
||||||
|
.downcast_mut::<crate::core::market_data_sources::HistoricalDataSource>() {
|
||||||
|
historical_source.load_data(market_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +197,119 @@ fn parse_market_data(obj: napi::JsObject) -> Result<crate::MarketUpdate> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple SMA Strategy for testing
|
||||||
|
struct SimpleSMAStrategy {
|
||||||
|
name: String,
|
||||||
|
id: String,
|
||||||
|
fast_period: usize,
|
||||||
|
slow_period: usize,
|
||||||
|
price_history: std::collections::HashMap<String, Vec<f64>>,
|
||||||
|
positions: std::collections::HashMap<String, f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimpleSMAStrategy {
|
||||||
|
fn new(name: String, id: String, fast_period: usize, slow_period: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
fast_period,
|
||||||
|
slow_period,
|
||||||
|
price_history: std::collections::HashMap::new(),
|
||||||
|
positions: std::collections::HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strategy for SimpleSMAStrategy {
|
||||||
|
fn on_market_data(&mut self, data: &MarketUpdate) -> Vec<Signal> {
|
||||||
|
let mut signals = Vec::new();
|
||||||
|
|
||||||
|
// Check if it's bar data
|
||||||
|
if let crate::MarketDataType::Bar(bar) = &data.data {
|
||||||
|
let symbol = &data.symbol;
|
||||||
|
let price = bar.close;
|
||||||
|
|
||||||
|
// Update price history
|
||||||
|
let history = self.price_history.entry(symbol.clone()).or_insert_with(Vec::new);
|
||||||
|
history.push(price);
|
||||||
|
|
||||||
|
// Keep only necessary history
|
||||||
|
if history.len() > self.slow_period {
|
||||||
|
history.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need enough data
|
||||||
|
if history.len() >= self.slow_period {
|
||||||
|
// Calculate SMAs
|
||||||
|
let fast_sma = history[history.len() - self.fast_period..].iter().sum::<f64>() / self.fast_period as f64;
|
||||||
|
let slow_sma = history.iter().sum::<f64>() / history.len() as f64;
|
||||||
|
|
||||||
|
// Previous SMAs (if we have enough history)
|
||||||
|
if history.len() > self.slow_period {
|
||||||
|
let prev_history = &history[..history.len() - 1];
|
||||||
|
let prev_fast_sma = prev_history[prev_history.len() - self.fast_period..].iter().sum::<f64>() / self.fast_period as f64;
|
||||||
|
let prev_slow_sma = prev_history.iter().sum::<f64>() / prev_history.len() as f64;
|
||||||
|
|
||||||
|
let current_position = self.positions.get(symbol).copied().unwrap_or(0.0);
|
||||||
|
|
||||||
|
// Golden cross - buy signal
|
||||||
|
if prev_fast_sma <= prev_slow_sma && fast_sma > slow_sma && current_position <= 0.0 {
|
||||||
|
signals.push(Signal {
|
||||||
|
symbol: symbol.clone(),
|
||||||
|
signal_type: crate::backtest::SignalType::Buy,
|
||||||
|
strength: 1.0,
|
||||||
|
quantity: Some(100.0), // Fixed quantity for testing
|
||||||
|
reason: Some("Golden cross".to_string()),
|
||||||
|
metadata: None,
|
||||||
|
});
|
||||||
|
self.positions.insert(symbol.clone(), 1.0);
|
||||||
|
eprintln!("Generated BUY signal for {} at price {}", symbol, price);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Death cross - sell signal
|
||||||
|
else if prev_fast_sma >= prev_slow_sma && fast_sma < slow_sma && current_position >= 0.0 {
|
||||||
|
signals.push(Signal {
|
||||||
|
symbol: symbol.clone(),
|
||||||
|
signal_type: crate::backtest::SignalType::Sell,
|
||||||
|
strength: 1.0,
|
||||||
|
quantity: Some(100.0), // Fixed quantity for testing
|
||||||
|
reason: Some("Death cross".to_string()),
|
||||||
|
metadata: None,
|
||||||
|
});
|
||||||
|
self.positions.insert(symbol.clone(), -1.0);
|
||||||
|
eprintln!("Generated SELL signal for {} at price {}", symbol, price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signals
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) {
|
||||||
|
eprintln!("Fill received: {} {} @ {} - {}", quantity, symbol, price, side);
|
||||||
|
let current_pos = self.positions.get(symbol).copied().unwrap_or(0.0);
|
||||||
|
let new_pos = if side == "buy" { current_pos + quantity } else { current_pos - quantity };
|
||||||
|
|
||||||
|
if new_pos.abs() < 0.0001 {
|
||||||
|
self.positions.remove(symbol);
|
||||||
|
} else {
|
||||||
|
self.positions.insert(symbol.to_string(), new_pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_parameters(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"fast_period": self.fast_period,
|
||||||
|
"slow_period": self.slow_period
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Error handling for threadsafe functions
|
// Error handling for threadsafe functions
|
||||||
struct ErrorStrategy;
|
struct ErrorStrategy;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,16 @@ pub struct BacktestEngine {
|
||||||
risk_engine: Arc<RiskEngine>,
|
risk_engine: Arc<RiskEngine>,
|
||||||
orderbook_manager: Arc<OrderBookManager>,
|
orderbook_manager: Arc<OrderBookManager>,
|
||||||
time_provider: Arc<Box<dyn TimeProvider>>,
|
time_provider: Arc<Box<dyn TimeProvider>>,
|
||||||
market_data_source: Arc<RwLock<Box<dyn MarketDataSource>>>,
|
pub market_data_source: Arc<RwLock<Box<dyn MarketDataSource>>>,
|
||||||
execution_handler: Arc<RwLock<Box<dyn ExecutionHandler>>>,
|
execution_handler: Arc<RwLock<Box<dyn ExecutionHandler>>>,
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
total_trades: usize,
|
total_trades: usize,
|
||||||
profitable_trades: usize,
|
profitable_trades: usize,
|
||||||
total_pnl: f64,
|
total_pnl: f64,
|
||||||
|
|
||||||
|
// Price tracking
|
||||||
|
last_prices: HashMap<String, f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BacktestEngine {
|
impl BacktestEngine {
|
||||||
|
|
@ -63,6 +66,7 @@ impl BacktestEngine {
|
||||||
total_trades: 0,
|
total_trades: 0,
|
||||||
profitable_trades: 0,
|
profitable_trades: 0,
|
||||||
total_pnl: 0.0,
|
total_pnl: 0.0,
|
||||||
|
last_prices: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,17 +163,19 @@ impl BacktestEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_market_data(&mut self, data: MarketUpdate) -> Result<(), String> {
|
async fn process_market_data(&mut self, data: MarketUpdate) -> Result<(), String> {
|
||||||
// Update orderbook if it's quote data
|
// Update price tracking
|
||||||
match &data.data {
|
match &data.data {
|
||||||
|
MarketDataType::Bar(bar) => {
|
||||||
|
self.last_prices.insert(data.symbol.clone(), bar.close);
|
||||||
|
}
|
||||||
MarketDataType::Quote(quote) => {
|
MarketDataType::Quote(quote) => {
|
||||||
// For now, skip orderbook updates
|
// Use mid price for quotes
|
||||||
// self.orderbook_manager.update_quote(&data.symbol, quote.bid, quote.ask);
|
let mid_price = (quote.bid + quote.ask) / 2.0;
|
||||||
|
self.last_prices.insert(data.symbol.clone(), mid_price);
|
||||||
}
|
}
|
||||||
MarketDataType::Trade(trade) => {
|
MarketDataType::Trade(trade) => {
|
||||||
// For now, skip orderbook updates
|
self.last_prices.insert(data.symbol.clone(), trade.price);
|
||||||
// self.orderbook_manager.update_last_trade(&data.symbol, trade.price, trade.size);
|
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to simpler MarketData for strategies
|
// Convert to simpler MarketData for strategies
|
||||||
|
|
@ -285,9 +291,9 @@ impl BacktestEngine {
|
||||||
|
|
||||||
async fn check_order_fill(&mut self, order: &Order) -> Result<(), String> {
|
async fn check_order_fill(&mut self, order: &Order) -> Result<(), String> {
|
||||||
// Get current market price
|
// Get current market price
|
||||||
// For now, use a simple fill model with last known price
|
let base_price = self.last_prices.get(&order.symbol)
|
||||||
// In a real backtest, this would use orderbook data
|
.copied()
|
||||||
let base_price = 100.0; // TODO: Get from market data
|
.ok_or_else(|| format!("No price available for symbol: {}", order.symbol))?;
|
||||||
|
|
||||||
// Apply slippage
|
// Apply slippage
|
||||||
let fill_price = match order.side {
|
let fill_price = match order.side {
|
||||||
|
|
@ -366,8 +372,9 @@ impl BacktestEngine {
|
||||||
let mut portfolio_value = self.state.read().cash;
|
let mut portfolio_value = self.state.read().cash;
|
||||||
|
|
||||||
for position in positions {
|
for position in positions {
|
||||||
// For now, use a simple market value calculation
|
// Use last known price for the symbol
|
||||||
let market_value = position.quantity * 100.0; // TODO: Get actual price
|
let price = self.last_prices.get(&position.symbol).copied().unwrap_or(position.average_price);
|
||||||
|
let market_value = position.quantity * price;
|
||||||
portfolio_value += market_value;
|
portfolio_value += market_value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,7 +385,7 @@ impl BacktestEngine {
|
||||||
let portfolio_value = self.state.read().portfolio_value;
|
let portfolio_value = self.state.read().portfolio_value;
|
||||||
let allocation = 0.1; // 10% per position
|
let allocation = 0.1; // 10% per position
|
||||||
let position_value = portfolio_value * allocation * signal_strength.abs();
|
let position_value = portfolio_value * allocation * signal_strength.abs();
|
||||||
let price = 100.0; // TODO: Get actual price from market data
|
let price = self.last_prices.get(symbol).copied().unwrap_or(100.0);
|
||||||
|
|
||||||
(position_value / price).floor()
|
(position_value / price).floor()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
350
apps/stock/orchestrator/src/backtest/RustBacktestAdapter.ts
Normal file
350
apps/stock/orchestrator/src/backtest/RustBacktestAdapter.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { IServiceContainer } from '@stock-bot/di';
|
||||||
|
import { BacktestEngine as RustEngine } from '@stock-bot/core';
|
||||||
|
import { BacktestConfig, BacktestResult } from '../types';
|
||||||
|
import { StorageService } from '../services/StorageService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter that bridges the orchestrator with the Rust backtest engine
|
||||||
|
*/
|
||||||
|
export class RustBacktestAdapter extends EventEmitter {
|
||||||
|
private container: IServiceContainer;
|
||||||
|
private storageService: StorageService;
|
||||||
|
private currentEngine?: RustEngine;
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
constructor(container: IServiceContainer) {
|
||||||
|
super();
|
||||||
|
this.container = container;
|
||||||
|
this.storageService = container.custom?.StorageService || new StorageService(
|
||||||
|
container,
|
||||||
|
container.mongodb,
|
||||||
|
container.postgres,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runBacktest(config: BacktestConfig): Promise<BacktestResult> {
|
||||||
|
if (this.isRunning) {
|
||||||
|
throw new Error('Backtest already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.container.logger.info('Starting Rust backtest engine', {
|
||||||
|
symbols: config.symbols,
|
||||||
|
startDate: config.startDate,
|
||||||
|
endDate: config.endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Rust engine configuration
|
||||||
|
const rustConfig = {
|
||||||
|
name: config.name || 'Backtest',
|
||||||
|
symbols: config.symbols,
|
||||||
|
startDate: config.startDate,
|
||||||
|
endDate: config.endDate,
|
||||||
|
initialCapital: config.initialCapital,
|
||||||
|
commission: config.commission || 0.001,
|
||||||
|
slippage: config.slippage || 0.0001,
|
||||||
|
dataFrequency: config.dataFrequency || '1d',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new Rust engine instance
|
||||||
|
this.currentEngine = new RustEngine(rustConfig);
|
||||||
|
|
||||||
|
// For now, use a simple strategy mapping
|
||||||
|
// In the future, strategies should be written in Rust or use a common interface
|
||||||
|
this.registerStrategy(config.strategy, config.config || {});
|
||||||
|
|
||||||
|
// Load historical data
|
||||||
|
await this.loadHistoricalData(config);
|
||||||
|
|
||||||
|
// Emit progress events
|
||||||
|
this.emit('progress', {
|
||||||
|
progress: 0.1,
|
||||||
|
message: 'Data loaded, starting backtest...',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run the backtest in Rust
|
||||||
|
const resultJson = this.currentEngine.run();
|
||||||
|
const rustResult = JSON.parse(resultJson);
|
||||||
|
|
||||||
|
// Convert Rust result to orchestrator format
|
||||||
|
const result: BacktestResult = {
|
||||||
|
backtestId: `rust-${Date.now()}`,
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
config: {
|
||||||
|
name: config.name || 'Backtest',
|
||||||
|
strategy: config.strategy,
|
||||||
|
symbols: config.symbols,
|
||||||
|
startDate: config.startDate,
|
||||||
|
endDate: config.endDate,
|
||||||
|
initialCapital: config.initialCapital,
|
||||||
|
commission: config.commission || 0.001,
|
||||||
|
slippage: config.slippage || 0.0001,
|
||||||
|
dataFrequency: config.dataFrequency || '1d',
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
totalReturn: rustResult.metrics.total_return,
|
||||||
|
sharpeRatio: rustResult.metrics.sharpe_ratio,
|
||||||
|
maxDrawdown: rustResult.metrics.max_drawdown,
|
||||||
|
winRate: rustResult.metrics.win_rate,
|
||||||
|
totalTrades: rustResult.metrics.total_trades,
|
||||||
|
profitFactor: rustResult.metrics.profit_factor,
|
||||||
|
profitableTrades: rustResult.metrics.profitable_trades,
|
||||||
|
avgWin: rustResult.metrics.avg_win,
|
||||||
|
avgLoss: rustResult.metrics.avg_loss,
|
||||||
|
expectancy: this.calculateExpectancy(rustResult.metrics),
|
||||||
|
calmarRatio: rustResult.metrics.total_return / (rustResult.metrics.max_drawdown || 1),
|
||||||
|
sortinoRatio: 0, // TODO: Calculate from downside deviation
|
||||||
|
},
|
||||||
|
equityCurve: rustResult.equity_curve.map((point: any) => ({
|
||||||
|
timestamp: new Date(point[0]).getTime(),
|
||||||
|
value: point[1],
|
||||||
|
})),
|
||||||
|
trades: rustResult.trades || [],
|
||||||
|
dailyReturns: this.calculateDailyReturns(rustResult.equity_curve),
|
||||||
|
finalPositions: rustResult.final_positions || {},
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit('complete', result);
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.container.logger.error('Rust backtest failed', error);
|
||||||
|
|
||||||
|
const errorResult: BacktestResult = {
|
||||||
|
backtestId: `rust-${Date.now()}`,
|
||||||
|
status: 'failed',
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
config: {
|
||||||
|
name: config.name || 'Backtest',
|
||||||
|
strategy: config.strategy,
|
||||||
|
symbols: config.symbols,
|
||||||
|
startDate: config.startDate,
|
||||||
|
endDate: config.endDate,
|
||||||
|
initialCapital: config.initialCapital,
|
||||||
|
commission: config.commission || 0.001,
|
||||||
|
slippage: config.slippage || 0.0001,
|
||||||
|
dataFrequency: config.dataFrequency || '1d',
|
||||||
|
},
|
||||||
|
metrics: this.getEmptyMetrics(),
|
||||||
|
equityCurve: [],
|
||||||
|
trades: [],
|
||||||
|
dailyReturns: [],
|
||||||
|
finalPositions: {},
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit('error', error);
|
||||||
|
return errorResult;
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this.isRunning = false;
|
||||||
|
this.currentEngine = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopBacktest(): Promise<void> {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
throw new Error('No backtest running');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In future, implement proper cancellation in Rust
|
||||||
|
this.isRunning = false;
|
||||||
|
this.emit('cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadHistoricalData(config: BacktestConfig): Promise<void> {
|
||||||
|
const startDate = new Date(config.startDate);
|
||||||
|
const endDate = new Date(config.endDate);
|
||||||
|
|
||||||
|
for (const symbol of config.symbols) {
|
||||||
|
const bars = await this.storageService.getHistoricalBars(
|
||||||
|
symbol,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
config.dataFrequency || '1d'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to Rust format
|
||||||
|
const marketData = bars.map(bar => ({
|
||||||
|
symbol,
|
||||||
|
timestamp: bar.timestamp.getTime(),
|
||||||
|
type: 'bar',
|
||||||
|
open: bar.open,
|
||||||
|
high: bar.high,
|
||||||
|
low: bar.low,
|
||||||
|
close: bar.close,
|
||||||
|
volume: bar.volume,
|
||||||
|
vwap: bar.vwap || (bar.high + bar.low + bar.close) / 3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Load into Rust engine
|
||||||
|
if (this.currentEngine) {
|
||||||
|
this.container.logger.info(`Loading ${marketData.length} bars for ${symbol} into Rust engine`);
|
||||||
|
this.currentEngine.loadMarketData(marketData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerStrategy(strategyName: string, parameters: any): void {
|
||||||
|
if (!this.currentEngine) return;
|
||||||
|
|
||||||
|
// Create state for the strategy
|
||||||
|
const priceHistory: Map<string, number[]> = new Map();
|
||||||
|
const positions: Map<string, number> = new Map();
|
||||||
|
const fastPeriod = parameters.fastPeriod || 10;
|
||||||
|
const slowPeriod = parameters.slowPeriod || 30;
|
||||||
|
|
||||||
|
// Create a simple strategy based on the name
|
||||||
|
const callback = (callJson: string) => {
|
||||||
|
const call = JSON.parse(callJson);
|
||||||
|
|
||||||
|
if (call.method === 'on_market_data') {
|
||||||
|
const marketData = call.data;
|
||||||
|
const signals: any[] = [];
|
||||||
|
|
||||||
|
// Debug log first few data points
|
||||||
|
if (priceHistory.size === 0) {
|
||||||
|
console.log('First market data received:', JSON.stringify(marketData, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For SMA crossover strategy
|
||||||
|
if (strategyName.toLowerCase().includes('sma') || strategyName.toLowerCase().includes('crossover')) {
|
||||||
|
// Check if it's bar data
|
||||||
|
const isBar = marketData.data?.Bar || (marketData.data && 'close' in marketData.data);
|
||||||
|
|
||||||
|
if (isBar) {
|
||||||
|
const symbol = marketData.symbol;
|
||||||
|
// Handle both direct properties and nested Bar structure
|
||||||
|
const barData = marketData.data.Bar || marketData.data;
|
||||||
|
const price = barData.close;
|
||||||
|
|
||||||
|
// Update price history
|
||||||
|
if (!priceHistory.has(symbol)) {
|
||||||
|
priceHistory.set(symbol, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = priceHistory.get(symbol)!;
|
||||||
|
history.push(price);
|
||||||
|
|
||||||
|
// Keep only necessary history
|
||||||
|
if (history.length > slowPeriod) {
|
||||||
|
history.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need enough data
|
||||||
|
if (history.length >= slowPeriod) {
|
||||||
|
// Calculate SMAs
|
||||||
|
const fastSMA = history.slice(-fastPeriod).reduce((a, b) => a + b, 0) / fastPeriod;
|
||||||
|
const slowSMA = history.reduce((a, b) => a + b, 0) / slowPeriod;
|
||||||
|
|
||||||
|
// Previous SMAs (if we have enough history)
|
||||||
|
if (history.length > slowPeriod) {
|
||||||
|
const prevHistory = history.slice(0, -1);
|
||||||
|
const prevFastSMA = prevHistory.slice(-fastPeriod).reduce((a, b) => a + b, 0) / fastPeriod;
|
||||||
|
const prevSlowSMA = prevHistory.reduce((a, b) => a + b, 0) / slowPeriod;
|
||||||
|
|
||||||
|
const currentPosition = positions.get(symbol) || 0;
|
||||||
|
|
||||||
|
// Golden cross - buy signal
|
||||||
|
if (prevFastSMA <= prevSlowSMA && fastSMA > slowSMA && currentPosition <= 0) {
|
||||||
|
signals.push({
|
||||||
|
symbol,
|
||||||
|
signal_type: 'Buy',
|
||||||
|
strength: 1.0,
|
||||||
|
reason: 'Golden cross'
|
||||||
|
});
|
||||||
|
positions.set(symbol, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Death cross - sell signal
|
||||||
|
else if (prevFastSMA >= prevSlowSMA && fastSMA < slowSMA && currentPosition >= 0) {
|
||||||
|
signals.push({
|
||||||
|
symbol,
|
||||||
|
signal_type: 'Sell',
|
||||||
|
strength: 1.0,
|
||||||
|
reason: 'Death cross'
|
||||||
|
});
|
||||||
|
positions.set(symbol, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({ signals });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (call.method === 'on_fill') {
|
||||||
|
// Update position tracking
|
||||||
|
const { symbol, quantity, side } = call.data;
|
||||||
|
const currentPos = positions.get(symbol) || 0;
|
||||||
|
const newPos = side === 'buy' ? currentPos + quantity : currentPos - quantity;
|
||||||
|
|
||||||
|
if (Math.abs(newPos) < 0.0001) {
|
||||||
|
positions.delete(symbol);
|
||||||
|
} else {
|
||||||
|
positions.set(symbol, newPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({ signals: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({ signals: [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentEngine.addTypescriptStrategy(
|
||||||
|
strategyName,
|
||||||
|
`strategy-${Date.now()}`,
|
||||||
|
parameters,
|
||||||
|
callback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateExpectancy(metrics: any): number {
|
||||||
|
if (metrics.total_trades === 0) return 0;
|
||||||
|
|
||||||
|
const winProb = metrics.win_rate / 100;
|
||||||
|
const lossProb = 1 - winProb;
|
||||||
|
|
||||||
|
return (winProb * metrics.avg_win) - (lossProb * Math.abs(metrics.avg_loss));
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateDailyReturns(equityCurve: any[]): number[] {
|
||||||
|
if (equityCurve.length < 2) return [];
|
||||||
|
|
||||||
|
const returns: number[] = [];
|
||||||
|
for (let i = 1; i < equityCurve.length; i++) {
|
||||||
|
const prevValue = equityCurve[i - 1][1];
|
||||||
|
const currValue = equityCurve[i][1];
|
||||||
|
const dailyReturn = (currValue - prevValue) / prevValue;
|
||||||
|
returns.push(dailyReturn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return returns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmptyMetrics() {
|
||||||
|
return {
|
||||||
|
totalReturn: 0,
|
||||||
|
sharpeRatio: 0,
|
||||||
|
maxDrawdown: 0,
|
||||||
|
winRate: 0,
|
||||||
|
totalTrades: 0,
|
||||||
|
profitFactor: 0,
|
||||||
|
profitableTrades: 0,
|
||||||
|
avgWin: 0,
|
||||||
|
avgLoss: 0,
|
||||||
|
expectancy: 0,
|
||||||
|
calmarRatio: 0,
|
||||||
|
sortinoRatio: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -265,17 +265,85 @@ export class StorageService {
|
||||||
endTime: Date,
|
endTime: Date,
|
||||||
interval: string = '1m'
|
interval: string = '1m'
|
||||||
): Promise<any[]> {
|
): Promise<any[]> {
|
||||||
if (!this.questdb) return [];
|
// If QuestDB is available, use real data
|
||||||
|
if (this.questdb) {
|
||||||
|
const result = await this.questdb.query(`
|
||||||
|
SELECT * FROM bars_${interval}
|
||||||
|
WHERE symbol = '${symbol}'
|
||||||
|
AND timestamp >= '${startTime.toISOString()}'
|
||||||
|
AND timestamp < '${endTime.toISOString()}'
|
||||||
|
ORDER BY timestamp
|
||||||
|
`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.questdb.query(`
|
// Otherwise, generate mock data for testing
|
||||||
SELECT * FROM bars_${interval}
|
this.container.logger.info('Generating mock data for backtest', {
|
||||||
WHERE symbol = '${symbol}'
|
symbol,
|
||||||
AND timestamp >= '${startTime.toISOString()}'
|
startTime: startTime.toISOString(),
|
||||||
AND timestamp < '${endTime.toISOString()}'
|
endTime: endTime.toISOString(),
|
||||||
ORDER BY timestamp
|
interval
|
||||||
`);
|
});
|
||||||
|
|
||||||
return result;
|
return this.generateMockBars(symbol, startTime, endTime, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateMockBars(
|
||||||
|
symbol: string,
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date,
|
||||||
|
interval: string
|
||||||
|
): any[] {
|
||||||
|
const bars = [];
|
||||||
|
const msPerInterval = this.getIntervalMilliseconds(interval);
|
||||||
|
let currentTime = new Date(startTime);
|
||||||
|
|
||||||
|
// Starting price based on symbol
|
||||||
|
let basePrice = symbol === 'AAPL' ? 150 :
|
||||||
|
symbol === 'MSFT' ? 300 :
|
||||||
|
symbol === 'GOOGL' ? 120 : 100;
|
||||||
|
|
||||||
|
while (currentTime <= endTime) {
|
||||||
|
// Random walk with trend
|
||||||
|
const trend = 0.0001; // Slight upward trend
|
||||||
|
const volatility = 0.002; // 0.2% volatility
|
||||||
|
const change = (Math.random() - 0.5 + trend) * volatility;
|
||||||
|
|
||||||
|
basePrice *= (1 + change);
|
||||||
|
|
||||||
|
// Generate OHLC data
|
||||||
|
const open = basePrice * (1 + (Math.random() - 0.5) * 0.001);
|
||||||
|
const close = basePrice;
|
||||||
|
const high = Math.max(open, close) * (1 + Math.random() * 0.002);
|
||||||
|
const low = Math.min(open, close) * (1 - Math.random() * 0.002);
|
||||||
|
const volume = 1000000 + Math.random() * 500000;
|
||||||
|
|
||||||
|
bars.push({
|
||||||
|
symbol,
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
open,
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
close,
|
||||||
|
volume,
|
||||||
|
vwap: (high + low + close) / 3
|
||||||
|
});
|
||||||
|
|
||||||
|
currentTime = new Date(currentTime.getTime() + msPerInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bars;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIntervalMilliseconds(interval: string): number {
|
||||||
|
switch (interval) {
|
||||||
|
case '1m': return 60 * 1000;
|
||||||
|
case '5m': return 5 * 60 * 1000;
|
||||||
|
case '15m': return 15 * 60 * 1000;
|
||||||
|
case '1h': return 60 * 60 * 1000;
|
||||||
|
case '1d': return 24 * 60 * 60 * 1000;
|
||||||
|
default: return 60 * 1000; // Default to 1 minute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async shutdown(): Promise<void> {
|
async shutdown(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { AnalyticsService } from './services/AnalyticsService';
|
||||||
import { StorageService } from './services/StorageService';
|
import { StorageService } from './services/StorageService';
|
||||||
import { StrategyManager } from './strategies/StrategyManager';
|
import { StrategyManager } from './strategies/StrategyManager';
|
||||||
import { BacktestEngine } from './backtest/BacktestEngine';
|
import { BacktestEngine } from './backtest/BacktestEngine';
|
||||||
|
import { RustBacktestAdapter } from './backtest/RustBacktestAdapter';
|
||||||
import { PaperTradingManager } from './paper/PaperTradingManager';
|
import { PaperTradingManager } from './paper/PaperTradingManager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,7 +98,15 @@ export async function createContainer(config: any): Promise<any> {
|
||||||
const executionService = new ExecutionService(services, storageService);
|
const executionService = new ExecutionService(services, storageService);
|
||||||
const analyticsService = new AnalyticsService(services, storageService);
|
const analyticsService = new AnalyticsService(services, storageService);
|
||||||
const strategyManager = new StrategyManager(services);
|
const strategyManager = new StrategyManager(services);
|
||||||
const backtestEngine = new BacktestEngine(services, storageService, strategyManager);
|
|
||||||
|
// Add strategy manager to custom services early so RustBacktestAdapter can access it
|
||||||
|
services.custom.StrategyManager = strategyManager;
|
||||||
|
|
||||||
|
// Use Rust backtest engine if configured
|
||||||
|
const useRustEngine = config.services?.orchestrator?.backtesting?.useRustEngine !== false;
|
||||||
|
const backtestEngine = useRustEngine
|
||||||
|
? new RustBacktestAdapter(services)
|
||||||
|
: new BacktestEngine(services, storageService, strategyManager);
|
||||||
const paperTradingManager = new PaperTradingManager(
|
const paperTradingManager = new PaperTradingManager(
|
||||||
services,
|
services,
|
||||||
storageService,
|
storageService,
|
||||||
|
|
@ -111,7 +120,7 @@ export async function createContainer(config: any): Promise<any> {
|
||||||
storageService
|
storageService
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store custom services
|
// Store custom services (before creating RustBacktestAdapter)
|
||||||
services.custom = {
|
services.custom = {
|
||||||
StorageService: storageService,
|
StorageService: storageService,
|
||||||
MarketDataService: marketDataService,
|
MarketDataService: marketDataService,
|
||||||
|
|
|
||||||
59
test-e2e-backtest.sh
Executable file
59
test-e2e-backtest.sh
Executable file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Testing End-to-End Backtest Flow"
|
||||||
|
echo "================================"
|
||||||
|
|
||||||
|
# Wait for services to be ready
|
||||||
|
echo "Waiting for services to be ready..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Test 1: Check web-api health
|
||||||
|
echo -e "\n1. Checking web-api health..."
|
||||||
|
curl -s http://localhost:2003/health | jq .
|
||||||
|
|
||||||
|
# Test 2: Check orchestrator health
|
||||||
|
echo -e "\n2. Checking orchestrator health..."
|
||||||
|
curl -s http://localhost:2004/health | jq .
|
||||||
|
|
||||||
|
# Test 3: Create a backtest
|
||||||
|
echo -e "\n3. Creating a backtest..."
|
||||||
|
BACKTEST_RESPONSE=$(curl -s -X POST http://localhost:2003/api/backtests \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"strategy": "SimpleMovingAverageCrossover",
|
||||||
|
"symbols": ["AAPL"],
|
||||||
|
"startDate": "2024-01-01",
|
||||||
|
"endDate": "2024-01-31",
|
||||||
|
"initialCapital": 100000,
|
||||||
|
"config": {
|
||||||
|
"commission": 0.001,
|
||||||
|
"slippage": 0.0001
|
||||||
|
}
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo "$BACKTEST_RESPONSE" | jq .
|
||||||
|
|
||||||
|
# Extract backtest ID
|
||||||
|
BACKTEST_ID=$(echo "$BACKTEST_RESPONSE" | jq -r .id)
|
||||||
|
echo "Backtest ID: $BACKTEST_ID"
|
||||||
|
|
||||||
|
# Test 4: Check backtest status
|
||||||
|
echo -e "\n4. Checking backtest status..."
|
||||||
|
sleep 2
|
||||||
|
curl -s http://localhost:2003/api/backtests/$BACKTEST_ID | jq .
|
||||||
|
|
||||||
|
# Test 5: Get backtest results (wait a bit for completion)
|
||||||
|
echo -e "\n5. Waiting for backtest to complete..."
|
||||||
|
for i in {1..10}; do
|
||||||
|
sleep 2
|
||||||
|
STATUS=$(curl -s http://localhost:2003/api/backtests/$BACKTEST_ID | jq -r .status)
|
||||||
|
echo "Status: $STATUS"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "completed" ]; then
|
||||||
|
echo -e "\n6. Getting backtest results..."
|
||||||
|
curl -s http://localhost:2003/api/backtests/$BACKTEST_ID/results | jq .
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "\nEnd-to-End test complete!"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue