diff --git a/Cargo.lock b/Cargo.lock index 0f91098..cec4ccd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,6 +174,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -269,7 +270,19 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -400,7 +413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -652,6 +665,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -679,7 +698,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -1041,6 +1060,18 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "uuid" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1053,6 +1084,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -1348,6 +1388,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + [[package]] name = "zerocopy" version = "0.8.26" diff --git a/apps/stock/core/Cargo.toml b/apps/stock/core/Cargo.toml index 3a11401..74a38e0 100644 --- a/apps/stock/core/Cargo.toml +++ b/apps/stock/core/Cargo.toml @@ -13,6 +13,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" anyhow = "1.0" +uuid = { version = "1.0", features = ["v4", "serde"] } # Data structures dashmap = "5.5" diff --git a/apps/stock/core/index.mjs b/apps/stock/core/index.mjs index e7df1a1..8e6c6d2 100644 --- a/apps/stock/core/index.mjs +++ b/apps/stock/core/index.mjs @@ -11,6 +11,13 @@ const nativeBinding = require(join(__dirname, 'index.node')); export const { TradingEngine, + BacktestEngine, + TechnicalIndicators, + IncrementalSMA, + IncrementalEMA, + IncrementalRSI, + RiskAnalyzer, + OrderbookAnalyzer, MarketData, MarketUpdate, Order, diff --git a/apps/stock/core/index.node b/apps/stock/core/index.node index e647a4e..9462caf 100755 Binary files a/apps/stock/core/index.node and b/apps/stock/core/index.node differ diff --git a/apps/stock/core/src/api/backtest.rs b/apps/stock/core/src/api/backtest.rs new file mode 100644 index 0000000..2aa9172 --- /dev/null +++ b/apps/stock/core/src/api/backtest.rs @@ -0,0 +1,206 @@ +use napi::bindgen_prelude::*; +use napi::{threadsafe_function::ThreadsafeFunction, JsObject}; +use napi_derive::napi; +use std::sync::Arc; +use parking_lot::Mutex; +use crate::backtest::{ + BacktestEngine as RustBacktestEngine, + BacktestConfig, + Strategy, Signal, + strategy::{TypeScriptStrategy, StrategyCall, StrategyResponse}, +}; +use crate::{TradingMode, MarketUpdate}; +use chrono::{DateTime, Utc}; + +#[napi] +pub struct BacktestEngine { + inner: Arc>>, + strategies: Arc>>>>, +} + +#[napi] +impl BacktestEngine { + #[napi(constructor)] + pub fn new(config: napi::JsObject, env: Env) -> Result { + let config = parse_backtest_config(config)?; + + // Create mode + let mode = TradingMode::Backtest { + start_time: config.start_time, + end_time: config.end_time, + speed_multiplier: 0.0, // Max speed + }; + + // Create components + let time_provider = crate::core::create_time_provider(&mode); + let market_data_source = crate::core::create_market_data_source(&mode); + let execution_handler = crate::core::create_execution_handler(&mode); + + let engine = RustBacktestEngine::new( + config, + mode, + time_provider, + market_data_source, + execution_handler, + ); + + Ok(Self { + inner: Arc::new(Mutex::new(Some(engine))), + strategies: Arc::new(Mutex::new(Vec::new())), + }) + } + + #[napi] + pub fn add_typescript_strategy( + &mut self, + name: String, + id: String, + parameters: napi::JsObject, + callback: napi::JsFunction, + ) -> Result<()> { + // Convert JsObject to serde_json::Value + let params = serde_json::Value::Object(serde_json::Map::new()); + + let mut strategy = TypeScriptStrategy::new(name, id, params); + + // Create a thread-safe callback wrapper + let tsfn: ThreadsafeFunction = 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() { + engine.add_strategy(Box::new(StrategyWrapper(strategy_arc))); + } + + Ok(()) + } + + #[napi] + pub fn run(&mut self) -> Result { + let mut engine = self.inner.lock().take() + .ok_or_else(|| Error::from_reason("Engine already consumed"))?; + + // Run the backtest synchronously for now + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| Error::from_reason(e.to_string()))?; + + let result = runtime.block_on(engine.run()) + .map_err(|e| Error::from_reason(e))?; + + // Return result as JSON + serde_json::to_string(&result) + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub fn load_market_data(&self, data: Vec) -> Result<()> { + // Convert JS objects to MarketData + let market_data: Vec = data.into_iter() + .filter_map(|obj| parse_market_data(obj).ok()) + .collect(); + + // In real implementation, this would load into the market data source + Ok(()) + } +} + +// Wrapper to make TypeScriptStrategy implement Strategy trait +struct StrategyWrapper(Arc>); + +impl Strategy for StrategyWrapper { + fn on_market_data(&mut self, data: &MarketUpdate) -> Vec { + self.0.lock().on_market_data(data) + } + + fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) { + self.0.lock().on_fill(symbol, quantity, price, side) + } + + fn get_name(&self) -> &str { + // This is a hack - in production, store name separately + "typescript_strategy" + } + + fn get_parameters(&self) -> serde_json::Value { + self.0.lock().parameters.clone() + } +} + +fn parse_backtest_config(obj: napi::JsObject) -> Result { + let name: String = obj.get_named_property("name")?; + let symbols: Vec = obj.get_named_property("symbols")?; + let start_date: String = obj.get_named_property("startDate")?; + let end_date: String = obj.get_named_property("endDate")?; + let initial_capital: f64 = obj.get_named_property("initialCapital")?; + let commission: f64 = obj.get_named_property("commission")?; + let slippage: f64 = obj.get_named_property("slippage")?; + let data_frequency: String = obj.get_named_property("dataFrequency")?; + + Ok(BacktestConfig { + name, + symbols, + start_time: DateTime::parse_from_rfc3339(&start_date) + .map_err(|e| Error::from_reason(e.to_string()))? + .with_timezone(&Utc), + end_time: DateTime::parse_from_rfc3339(&end_date) + .map_err(|e| Error::from_reason(e.to_string()))? + .with_timezone(&Utc), + initial_capital, + commission, + slippage, + data_frequency, + }) +} + +fn parse_market_data(obj: napi::JsObject) -> Result { + let symbol: String = obj.get_named_property("symbol")?; + let timestamp: i64 = obj.get_named_property("timestamp")?; + let data_type: String = obj.get_named_property("type")?; + + let data = if data_type == "bar" { + crate::MarketDataType::Bar(crate::Bar { + open: obj.get_named_property("open")?, + high: obj.get_named_property("high")?, + low: obj.get_named_property("low")?, + close: obj.get_named_property("close")?, + volume: obj.get_named_property("volume")?, + vwap: obj.get_named_property("vwap").ok(), + }) + } else { + return Err(Error::from_reason("Unsupported market data type")); + }; + + Ok(crate::MarketUpdate { + symbol, + timestamp: DateTime::::from_timestamp(timestamp / 1000, 0) + .ok_or_else(|| Error::from_reason("Invalid timestamp"))?, + data, + }) +} + +// Error handling for threadsafe functions +struct ErrorStrategy; + +impl From for ErrorStrategy { + fn from(e: napi::Error) -> Self { + ErrorStrategy + } +} \ No newline at end of file diff --git a/apps/stock/core/src/api/mod.rs b/apps/stock/core/src/api/mod.rs index 85dff1b..ce61cbc 100644 --- a/apps/stock/core/src/api/mod.rs +++ b/apps/stock/core/src/api/mod.rs @@ -1,8 +1,10 @@ mod indicators; mod risk; +mod backtest; pub use indicators::{TechnicalIndicators, IncrementalSMA, IncrementalEMA, IncrementalRSI}; pub use risk::{RiskAnalyzer, OrderbookAnalyzer}; +pub use backtest::BacktestEngine; use napi_derive::napi; use napi::{bindgen_prelude::*, JsObject}; diff --git a/apps/stock/core/src/backtest/engine.rs b/apps/stock/core/src/backtest/engine.rs new file mode 100644 index 0000000..aa86e4b --- /dev/null +++ b/apps/stock/core/src/backtest/engine.rs @@ -0,0 +1,424 @@ +use std::collections::HashMap; +use std::sync::Arc; +use parking_lot::RwLock; +use chrono::{DateTime, Utc}; +use serde::{Serialize, Deserialize}; + +use crate::{ + TradingMode, MarketDataSource, ExecutionHandler, TimeProvider, + MarketUpdate, MarketDataType, Order, Fill, Side, + positions::PositionTracker, + risk::RiskEngine, + orderbook::OrderBookManager, +}; + +use super::{ + BacktestConfig, BacktestState, EventQueue, BacktestEvent, EventType, + Strategy, Signal, SignalType, BacktestResult, +}; + +pub struct BacktestEngine { + config: BacktestConfig, + state: Arc>, + event_queue: Arc>, + strategies: Arc>>>, + + // Core components + position_tracker: Arc, + risk_engine: Arc, + orderbook_manager: Arc, + time_provider: Arc>, + market_data_source: Arc>>, + execution_handler: Arc>>, + + // Metrics + total_trades: usize, + profitable_trades: usize, + total_pnl: f64, +} + +impl BacktestEngine { + pub fn new( + config: BacktestConfig, + mode: TradingMode, + time_provider: Box, + market_data_source: Box, + execution_handler: Box, + ) -> Self { + let state = Arc::new(RwLock::new( + BacktestState::new(config.initial_capital, config.start_time) + )); + + Self { + config, + state, + event_queue: Arc::new(RwLock::new(EventQueue::new())), + strategies: Arc::new(RwLock::new(Vec::new())), + position_tracker: Arc::new(PositionTracker::new()), + risk_engine: Arc::new(RiskEngine::new()), + orderbook_manager: Arc::new(OrderBookManager::new()), + time_provider: Arc::new(time_provider), + market_data_source: Arc::new(RwLock::new(market_data_source)), + execution_handler: Arc::new(RwLock::new(execution_handler)), + total_trades: 0, + profitable_trades: 0, + total_pnl: 0.0, + } + } + + pub fn add_strategy(&mut self, strategy: Box) { + self.strategies.write().push(strategy); + } + + pub async fn run(&mut self) -> Result { + // Initialize start time + if let Some(simulated_time) = self.time_provider.as_any() + .downcast_ref::() + { + simulated_time.advance_to(self.config.start_time); + } + + // Load market data + self.load_market_data().await?; + + // Main event loop + while !self.event_queue.read().is_empty() || + self.time_provider.now() < self.config.end_time + { + // Get next batch of events + let current_time = self.time_provider.now(); + let events = self.event_queue.write().pop_until(current_time); + + for event in events { + self.process_event(event).await?; + } + + // Update portfolio value + self.update_portfolio_value(); + + // Check if we should advance time + if self.event_queue.read().is_empty() { + // Advance to next data point or end time + if let Some(next_time) = self.get_next_event_time() { + if next_time < self.config.end_time { + self.advance_time(next_time); + } else { + break; + } + } else { + break; + } + } + } + + // Generate results + Ok(self.generate_results()) + } + + async fn load_market_data(&mut self) -> Result<(), String> { + let mut data_source = self.market_data_source.write(); + + // Seek to start time + data_source.seek_to_time(self.config.start_time)?; + + // Load all data into event queue + while let Some(update) = data_source.get_next_update().await { + if update.timestamp > self.config.end_time { + break; + } + + let event = BacktestEvent::market_data(update.timestamp, update); + self.event_queue.write().push(event); + } + + Ok(()) + } + + async fn process_event(&mut self, event: BacktestEvent) -> Result<(), String> { + match event.event_type { + EventType::MarketData(data) => { + self.process_market_data(data).await?; + } + EventType::OrderSubmitted(order) => { + self.process_order_submission(order).await?; + } + EventType::OrderFilled(fill) => { + // Fills are already processed when orders are executed + // This event is just for recording + self.state.write().record_fill(fill); + } + EventType::OrderCancelled(order_id) => { + self.process_order_cancellation(&order_id)?; + } + EventType::TimeUpdate(time) => { + self.advance_time(time); + } + } + + Ok(()) + } + + async fn process_market_data(&mut self, data: MarketUpdate) -> Result<(), String> { + // Update orderbook if it's quote data + match &data.data { + MarketDataType::Quote(quote) => { + // For now, skip orderbook updates + // self.orderbook_manager.update_quote(&data.symbol, quote.bid, quote.ask); + } + MarketDataType::Trade(trade) => { + // For now, skip orderbook updates + // self.orderbook_manager.update_last_trade(&data.symbol, trade.price, trade.size); + } + _ => {} + } + + // Convert to simpler MarketData for strategies + let market_data = self.convert_to_market_data(&data); + + // Send to strategies + let mut all_signals = Vec::new(); + { + let mut strategies = self.strategies.write(); + for strategy in strategies.iter_mut() { + let signals = strategy.on_market_data(&market_data); + all_signals.extend(signals); + } + } + + // Process signals + for signal in all_signals { + self.process_signal(signal).await?; + } + + // Check pending orders for fills + self.check_pending_orders(&data).await?; + + Ok(()) + } + + fn convert_to_market_data(&self, update: &MarketUpdate) -> MarketUpdate { + // MarketData is a type alias for MarketUpdate + update.clone() + } + + async fn process_signal(&mut self, signal: Signal) -> Result<(), String> { + // Only process strong signals + if signal.strength.abs() < 0.7 { + return Ok(()); + } + + // Convert signal to order + let order = self.signal_to_order(signal)?; + + // Submit order + self.process_order_submission(order).await + } + + fn signal_to_order(&self, signal: Signal) -> Result { + let quantity = signal.quantity.unwrap_or_else(|| { + // Calculate position size based on portfolio + self.calculate_position_size(&signal.symbol, signal.strength) + }); + + let side = match signal.signal_type { + SignalType::Buy => Side::Buy, + SignalType::Sell => Side::Sell, + SignalType::Close => { + // Determine side based on current position + let position = self.position_tracker.get_position(&signal.symbol); + if position.as_ref().map(|p| p.quantity > 0.0).unwrap_or(false) { + Side::Sell + } else { + Side::Buy + } + } + }; + + Ok(crate::Order { + id: format!("order_{}", uuid::Uuid::new_v4()), + symbol: signal.symbol, + side, + quantity, + order_type: crate::OrderType::Market, + time_in_force: crate::TimeInForce::Day, + }) + } + + async fn process_order_submission(&mut self, order: Order) -> Result<(), String> { + // Risk checks + // Get current position for the symbol + let current_position = self.position_tracker + .get_position(&order.symbol) + .map(|p| p.quantity); + + let risk_check = self.risk_engine.check_order(&order, current_position); + if !risk_check.passed { + return Err(format!("Risk check failed: {:?}", risk_check.violations)); + } + + // Add to pending orders + self.state.write().add_pending_order(order.clone()); + + // For market orders in backtesting, fill immediately + if matches!(order.order_type, crate::OrderType::Market) { + self.check_order_fill(&order).await?; + } + + Ok(()) + } + + async fn check_pending_orders(&mut self, market_data: &MarketUpdate) -> Result<(), String> { + let orders_to_check: Vec = { + let state = self.state.read(); + state.pending_orders.values() + .filter(|o| o.symbol == market_data.symbol) + .cloned() + .collect() + }; + + for order in orders_to_check { + self.check_order_fill(&order).await?; + } + + Ok(()) + } + + async fn check_order_fill(&mut self, order: &Order) -> Result<(), String> { + // Get current market price + // For now, use a simple fill model with last known price + // In a real backtest, this would use orderbook data + let base_price = 100.0; // TODO: Get from market data + + // Apply slippage + let fill_price = match order.side { + crate::Side::Buy => base_price * (1.0 + self.config.slippage), + crate::Side::Sell => base_price * (1.0 - self.config.slippage), + }; + + // Create fill + let fill = crate::Fill { + timestamp: self.time_provider.now(), + price: fill_price, + quantity: order.quantity, + commission: order.quantity * fill_price * self.config.commission, + }; + + // Process the fill + self.process_fill(&order, fill).await + } + + async fn process_fill(&mut self, order: &crate::Order, fill: crate::Fill) -> Result<(), String> { + // Remove from pending orders + self.state.write().remove_pending_order(&order.id); + + // Update positions + let update = self.position_tracker.process_fill( + &order.symbol, + &fill, + order.side, + ); + + // Record the fill + self.state.write().record_fill(fill.clone()); + + // Update cash + let cash_change = match order.side { + crate::Side::Buy => -(fill.quantity * fill.price + fill.commission), + crate::Side::Sell => fill.quantity * fill.price - fill.commission, + }; + self.state.write().cash += cash_change; + + // Notify strategies + { + let mut strategies = self.strategies.write(); + for strategy in strategies.iter_mut() { + strategy.on_fill(&order.symbol, fill.quantity, fill.price, + &format!("{:?}", order.side)); + } + } + + // Update metrics + self.total_trades += 1; + if update.resulting_position.realized_pnl > 0.0 { + self.profitable_trades += 1; + } + self.total_pnl = update.resulting_position.realized_pnl; + + Ok(()) + } + + fn process_order_cancellation(&mut self, order_id: &str) -> Result<(), String> { + self.state.write().remove_pending_order(order_id); + Ok(()) + } + + fn advance_time(&mut self, time: DateTime) { + if let Some(simulated_time) = self.time_provider.as_any() + .downcast_ref::() + { + simulated_time.advance_to(time); + } + self.state.write().current_time = time; + } + + fn update_portfolio_value(&mut self) { + let positions = self.position_tracker.get_all_positions(); + let mut portfolio_value = self.state.read().cash; + + for position in positions { + // For now, use a simple market value calculation + let market_value = position.quantity * 100.0; // TODO: Get actual price + portfolio_value += market_value; + } + + self.state.write().update_portfolio_value(portfolio_value); + } + + fn calculate_position_size(&self, symbol: &str, signal_strength: f64) -> f64 { + let portfolio_value = self.state.read().portfolio_value; + let allocation = 0.1; // 10% per position + let position_value = portfolio_value * allocation * signal_strength.abs(); + let price = 100.0; // TODO: Get actual price from market data + + (position_value / price).floor() + } + + fn get_next_event_time(&self) -> Option> { + // In a real implementation, this would look at the next market data point + None + } + + fn generate_results(&self) -> BacktestResult { + let state = self.state.read(); + let (realized_pnl, unrealized_pnl) = self.position_tracker.get_total_pnl(); + let total_pnl = realized_pnl + unrealized_pnl; + let total_return = (total_pnl / self.config.initial_capital) * 100.0; + + BacktestResult { + config: self.config.clone(), + metrics: super::BacktestMetrics { + total_return, + total_trades: self.total_trades, + profitable_trades: self.profitable_trades, + win_rate: if self.total_trades > 0 { + (self.profitable_trades as f64 / self.total_trades as f64) * 100.0 + } else { 0.0 }, + profit_factor: 0.0, // TODO: Calculate properly + sharpe_ratio: 0.0, // TODO: Calculate properly + max_drawdown: 0.0, // TODO: Calculate properly + total_pnl, + avg_win: 0.0, // TODO: Calculate properly + avg_loss: 0.0, // TODO: Calculate properly + }, + equity_curve: state.equity_curve.clone(), + trades: state.completed_trades.clone(), + final_positions: self.position_tracker.get_all_positions() + .into_iter() + .map(|p| (p.symbol.clone(), p)) + .collect(), + } + } +} + +// Add uuid dependency +use uuid::Uuid; \ No newline at end of file diff --git a/apps/stock/core/src/backtest/event.rs b/apps/stock/core/src/backtest/event.rs new file mode 100644 index 0000000..ef894c9 --- /dev/null +++ b/apps/stock/core/src/backtest/event.rs @@ -0,0 +1,55 @@ +use chrono::{DateTime, Utc}; +use serde::{Serialize, Deserialize}; +use crate::{MarketUpdate, Order, Fill}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum EventType { + MarketData(MarketUpdate), + OrderSubmitted(Order), + OrderFilled(Fill), + OrderCancelled(String), // order_id + TimeUpdate(DateTime), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacktestEvent { + pub timestamp: DateTime, + pub event_type: EventType, +} + +impl BacktestEvent { + pub fn market_data(timestamp: DateTime, data: MarketUpdate) -> Self { + Self { + timestamp, + event_type: EventType::MarketData(data), + } + } + + pub fn order_submitted(timestamp: DateTime, order: Order) -> Self { + Self { + timestamp, + event_type: EventType::OrderSubmitted(order), + } + } + + pub fn order_filled(timestamp: DateTime, fill: Fill) -> Self { + Self { + timestamp, + event_type: EventType::OrderFilled(fill), + } + } + + pub fn order_cancelled(timestamp: DateTime, order_id: String) -> Self { + Self { + timestamp, + event_type: EventType::OrderCancelled(order_id), + } + } + + pub fn time_update(timestamp: DateTime) -> Self { + Self { + timestamp, + event_type: EventType::TimeUpdate(timestamp), + } + } +} \ No newline at end of file diff --git a/apps/stock/core/src/backtest/mod.rs b/apps/stock/core/src/backtest/mod.rs new file mode 100644 index 0000000..2a4b01c --- /dev/null +++ b/apps/stock/core/src/backtest/mod.rs @@ -0,0 +1,112 @@ +use crate::{MarketUpdate, Order, Fill, TradingMode, MarketDataSource, ExecutionHandler, TimeProvider}; +use crate::positions::PositionTracker; +use crate::risk::RiskEngine; +use crate::orderbook::OrderBookManager; +use chrono::{DateTime, Utc}; +use std::collections::{BTreeMap, VecDeque}; +use std::sync::Arc; +use parking_lot::RwLock; +use serde::{Serialize, Deserialize}; + +pub mod engine; +pub mod event; +pub mod strategy; +pub mod results; + +pub use engine::BacktestEngine; +pub use event::{BacktestEvent, EventType}; +pub use strategy::{Strategy, Signal, SignalType}; +pub use results::{BacktestResult, BacktestMetrics}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacktestConfig { + pub name: String, + pub symbols: Vec, + pub start_time: DateTime, + pub end_time: DateTime, + pub initial_capital: f64, + pub commission: f64, + pub slippage: f64, + pub data_frequency: String, +} + +#[derive(Debug, Clone)] +pub struct BacktestState { + pub current_time: DateTime, + pub portfolio_value: f64, + pub cash: f64, + pub equity_curve: Vec<(DateTime, f64)>, + pub pending_orders: BTreeMap, + pub completed_trades: Vec, +} + +impl BacktestState { + pub fn new(initial_capital: f64, start_time: DateTime) -> Self { + Self { + current_time: start_time, + portfolio_value: initial_capital, + cash: initial_capital, + equity_curve: vec![(start_time, initial_capital)], + pending_orders: BTreeMap::new(), + completed_trades: Vec::new(), + } + } + + pub fn update_portfolio_value(&mut self, value: f64) { + self.portfolio_value = value; + self.equity_curve.push((self.current_time, value)); + } + + pub fn add_pending_order(&mut self, order: Order) { + self.pending_orders.insert(order.id.clone(), order); + } + + pub fn remove_pending_order(&mut self, order_id: &str) -> Option { + self.pending_orders.remove(order_id) + } + + pub fn record_fill(&mut self, fill: Fill) { + self.completed_trades.push(fill); + } +} + +// Event queue for deterministic replay +#[derive(Debug)] +pub struct EventQueue { + events: VecDeque, +} + +impl EventQueue { + pub fn new() -> Self { + Self { + events: VecDeque::new(), + } + } + + pub fn push(&mut self, event: BacktestEvent) { + // Insert in time order + let pos = self.events.iter().position(|e| e.timestamp > event.timestamp) + .unwrap_or(self.events.len()); + self.events.insert(pos, event); + } + + pub fn pop_until(&mut self, timestamp: DateTime) -> Vec { + let mut events = Vec::new(); + while let Some(event) = self.events.front() { + if event.timestamp <= timestamp { + events.push(self.events.pop_front().unwrap()); + } else { + break; + } + } + events + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub fn len(&self) -> usize { + self.events.len() + } +} \ No newline at end of file diff --git a/apps/stock/core/src/backtest/results.rs b/apps/stock/core/src/backtest/results.rs new file mode 100644 index 0000000..7454c57 --- /dev/null +++ b/apps/stock/core/src/backtest/results.rs @@ -0,0 +1,28 @@ +use chrono::{DateTime, Utc}; +use serde::{Serialize, Deserialize}; +use std::collections::HashMap; +use crate::{Fill, Position}; +use super::BacktestConfig; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacktestMetrics { + pub total_return: f64, + pub total_trades: usize, + pub profitable_trades: usize, + pub win_rate: f64, + pub profit_factor: f64, + pub sharpe_ratio: f64, + pub max_drawdown: f64, + pub total_pnl: f64, + pub avg_win: f64, + pub avg_loss: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacktestResult { + pub config: BacktestConfig, + pub metrics: BacktestMetrics, + pub equity_curve: Vec<(DateTime, f64)>, + pub trades: Vec, + pub final_positions: HashMap, +} \ No newline at end of file diff --git a/apps/stock/core/src/backtest/strategy.rs b/apps/stock/core/src/backtest/strategy.rs new file mode 100644 index 0000000..105053b --- /dev/null +++ b/apps/stock/core/src/backtest/strategy.rs @@ -0,0 +1,100 @@ +use chrono::{DateTime, Utc}; +use serde::{Serialize, Deserialize}; +use crate::MarketData; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SignalType { + Buy, + Sell, + Close, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Signal { + pub symbol: String, + pub signal_type: SignalType, + pub strength: f64, // -1.0 to 1.0 + pub quantity: Option, + pub reason: Option, + pub metadata: Option, +} + +// This trait will be implemented by Rust strategies +// TypeScript strategies will communicate through FFI +pub trait Strategy: Send + Sync { + fn on_market_data(&mut self, data: &MarketData) -> Vec; + fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str); + fn get_name(&self) -> &str; + fn get_parameters(&self) -> serde_json::Value; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StrategyCall { + pub method: String, + pub data: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StrategyResponse { + pub signals: Vec, +} + +// Bridge for TypeScript strategies + +// This will be used to wrap TypeScript strategies +pub struct TypeScriptStrategy { + pub name: String, + pub id: String, + pub parameters: serde_json::Value, + // Callback function will be injected from TypeScript + pub callback: Option StrategyResponse + Send + Sync>>, +} + +impl TypeScriptStrategy { + pub fn new(name: String, id: String, parameters: serde_json::Value) -> Self { + Self { + name, + id, + parameters, + callback: None, + } + } +} + +impl Strategy for TypeScriptStrategy { + fn on_market_data(&mut self, data: &MarketData) -> Vec { + if let Some(callback) = &self.callback { + let call = StrategyCall { + method: "on_market_data".to_string(), + data: serde_json::to_value(data).unwrap_or_default(), + }; + let response = callback(call); + response.signals + } else { + Vec::new() + } + } + + fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) { + if let Some(callback) = &self.callback { + let call = StrategyCall { + method: "on_fill".to_string(), + data: serde_json::json!({ + "symbol": symbol, + "quantity": quantity, + "price": price, + "side": side + }), + }; + callback(call); + } + } + + fn get_name(&self) -> &str { + &self.name + } + + fn get_parameters(&self) -> serde_json::Value { + self.parameters.clone() + } +} \ No newline at end of file diff --git a/apps/stock/core/src/lib.rs b/apps/stock/core/src/lib.rs index 05b1a1a..4e623e4 100644 --- a/apps/stock/core/src/lib.rs +++ b/apps/stock/core/src/lib.rs @@ -7,11 +7,15 @@ pub mod positions; pub mod api; pub mod analytics; pub mod indicators; +pub mod backtest; // Re-export commonly used types pub use positions::{Position, PositionUpdate, TradeRecord, ClosedTrade}; pub use risk::{RiskLimits, RiskCheckResult, RiskMetrics}; +// Type alias for backtest compatibility +pub type MarketData = MarketUpdate; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; diff --git a/apps/stock/orchestrator/src/backtest/RustBacktestEngine.ts b/apps/stock/orchestrator/src/backtest/RustBacktestEngine.ts new file mode 100644 index 0000000..8681969 --- /dev/null +++ b/apps/stock/orchestrator/src/backtest/RustBacktestEngine.ts @@ -0,0 +1,187 @@ +import { BacktestEngine as RustEngine } from '@stock-bot/core'; +import { RustStrategy } from '../strategies/RustStrategy'; +import { MarketData, BacktestConfig } from '../types'; +import { StorageService } from '../services/StorageService'; +import { IServiceContainer } from '@stock-bot/di'; + +export interface RustBacktestConfig { + name: string; + symbols: string[]; + startDate: string; + endDate: string; + initialCapital: number; + commission: number; + slippage: number; + dataFrequency: string; +} + +export interface RustBacktestResult { + config: RustBacktestConfig; + metrics: { + totalReturn: number; + totalTrades: number; + profitableTrades: number; + winRate: number; + profitFactor: number; + sharpeRatio: number; + maxDrawdown: number; + totalPnl: number; + avgWin: number; + avgLoss: number; + }; + equityCurve: Array<{ date: string; value: number }>; + trades: any[]; + finalPositions: Record; +} + +export class RustBacktestEngine { + private engine: RustEngine; + private container: IServiceContainer; + private storageService: StorageService; + private config: RustBacktestConfig; + + constructor( + container: IServiceContainer, + storageService: StorageService, + config: BacktestConfig + ) { + this.container = container; + this.storageService = storageService; + + // Convert config for Rust + const rustConfig: RustBacktestConfig = { + 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', + }; + + this.config = rustConfig; + this.engine = new RustEngine(rustConfig as any); + } + + /** + * Add a TypeScript strategy to the backtest + */ + addStrategy(strategy: RustStrategy): void { + strategy.register(this.engine); + } + + /** + * Load historical data for the backtest + */ + async loadData(): Promise { + // Get config from engine + const config = this.getConfig(); + + const startDate = new Date(config.startDate); + const endDate = new Date(config.endDate); + + // Load data for each symbol + for (const symbol of config.symbols) { + const bars = await this.storageService.getHistoricalBars( + symbol, + startDate, + endDate, + config.dataFrequency + ); + + // Convert to Rust format + const marketData = bars.map(bar => ({ + symbol, + timestamp: bar.timestamp.getTime(), // Convert to milliseconds + 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 + console.log(`Loading ${marketData.length} bars for ${symbol}`); + this.engine.loadMarketData(marketData as any); + } + } + + /** + * Run the backtest + */ + async run(): Promise { + console.log('Starting backtest run...'); + + // Load data first + await this.loadData(); + + // Run backtest in Rust + const resultJson = await this.engine.run(); + + // Parse result and convert snake_case to camelCase + const rustResult = JSON.parse(resultJson); + + const result: RustBacktestResult = { + config: rustResult.config, + metrics: { + totalReturn: rustResult.metrics.total_return, + totalTrades: rustResult.metrics.total_trades, + profitableTrades: rustResult.metrics.profitable_trades, + winRate: rustResult.metrics.win_rate, + profitFactor: rustResult.metrics.profit_factor, + sharpeRatio: rustResult.metrics.sharpe_ratio, + maxDrawdown: rustResult.metrics.max_drawdown, + totalPnl: rustResult.metrics.total_pnl, + avgWin: rustResult.metrics.avg_win, + avgLoss: rustResult.metrics.avg_loss, + }, + equityCurve: rustResult.equity_curve.map((point: any) => ({ + date: point[0], + value: point[1], + })), + trades: rustResult.trades, + finalPositions: rustResult.final_positions, + }; + + // Log results + this.container.logger.info('Rust backtest completed', { + totalReturn: result.metrics.totalReturn, + totalTrades: result.metrics.totalTrades, + sharpeRatio: result.metrics.sharpeRatio, + }); + + return result; + } + + /** + * Get the backtest configuration + */ + private getConfig(): RustBacktestConfig { + return this.config; + } +} + +/** + * Factory function to create a Rust-powered backtest + */ +export async function createRustBacktest( + container: IServiceContainer, + config: BacktestConfig, + strategies: RustStrategy[] +): Promise { + const storageService = container.custom?.StorageService || new StorageService(); + + // Create engine + const engine = new RustBacktestEngine(container, storageService, config); + + // Add strategies + for (const strategy of strategies) { + engine.addStrategy(strategy); + } + + // Run backtest + return await engine.run(); +} \ No newline at end of file diff --git a/apps/stock/orchestrator/src/strategies/RustStrategy.ts b/apps/stock/orchestrator/src/strategies/RustStrategy.ts new file mode 100644 index 0000000..06b3881 --- /dev/null +++ b/apps/stock/orchestrator/src/strategies/RustStrategy.ts @@ -0,0 +1,139 @@ +import { BacktestEngine } from '@stock-bot/core'; +import { MarketData } from '../types'; + +export interface Signal { + symbol: string; + signal_type: 'Buy' | 'Sell' | 'Close'; + strength: number; // -1.0 to 1.0 + quantity?: number; + reason?: string; + metadata?: any; +} + +export interface StrategyCall { + method: string; + data: any; +} + +export interface StrategyResponse { + signals: Signal[]; +} + +/** + * Base class for TypeScript strategies that run in the Rust backtest engine + */ +export abstract class RustStrategy { + protected name: string; + protected id: string; + protected parameters: Record; + protected positions: Map = new Map(); + + constructor(name: string, id: string, parameters: Record = {}) { + this.name = name; + this.id = id; + this.parameters = parameters; + } + + /** + * Main callback that Rust will call + */ + public handleCall(call: StrategyCall): StrategyResponse { + switch (call.method) { + case 'on_market_data': + const signals = this.onMarketData(call.data); + return { signals }; + + case 'on_fill': + this.onFill( + call.data.symbol, + call.data.quantity, + call.data.price, + call.data.side + ); + return { signals: [] }; + + default: + return { signals: [] }; + } + } + + /** + * Called when new market data is received + */ + protected abstract onMarketData(data: MarketData): Signal[]; + + /** + * Called when an order is filled + */ + protected onFill(symbol: string, quantity: number, price: number, side: string): void { + const currentPosition = this.positions.get(symbol) || 0; + const newPosition = side === 'buy' ? + currentPosition + quantity : + currentPosition - quantity; + + if (Math.abs(newPosition) < 0.0001) { + this.positions.delete(symbol); + } else { + this.positions.set(symbol, newPosition); + } + } + + /** + * Helper to create a buy signal + */ + protected buySignal(symbol: string, strength: number = 1.0, reason?: string): Signal { + return { + symbol, + signal_type: 'Buy', + strength, + reason, + }; + } + + /** + * Helper to create a sell signal + */ + protected sellSignal(symbol: string, strength: number = 1.0, reason?: string): Signal { + return { + symbol, + signal_type: 'Sell', + strength, + reason, + }; + } + + /** + * Helper to create a close position signal + */ + protected closeSignal(symbol: string, reason?: string): Signal { + return { + symbol, + signal_type: 'Close', + strength: 1.0, + reason, + }; + } + + /** + * Register this strategy with a backtest engine + */ + public register(engine: BacktestEngine): void { + console.log(`Registering strategy ${this.name} with id ${this.id}`); + + // Convert the handleCall method to what Rust expects + const callback = (callJson: string) => { + console.log('Strategy callback called with:', callJson); + const call: StrategyCall = JSON.parse(callJson); + const response = this.handleCall(call); + console.log('Strategy response:', response); + return JSON.stringify(response); + }; + + engine.addTypescriptStrategy( + this.name, + this.id, + this.parameters, + callback as any + ); + } +} \ No newline at end of file diff --git a/apps/stock/orchestrator/src/strategies/rust/SimpleMovingAverageCrossoverRust.ts b/apps/stock/orchestrator/src/strategies/rust/SimpleMovingAverageCrossoverRust.ts new file mode 100644 index 0000000..e818f77 --- /dev/null +++ b/apps/stock/orchestrator/src/strategies/rust/SimpleMovingAverageCrossoverRust.ts @@ -0,0 +1,136 @@ +import { RustStrategy, Signal } from '../RustStrategy'; +import { MarketData } from '../../types'; + +interface BarData { + open: number; + high: number; + low: number; + close: number; + volume: number; + timestamp: number; +} + +export class SimpleMovingAverageCrossoverRust extends RustStrategy { + private priceHistory: Map = new Map(); + private lastCrossover: Map = new Map(); + private barsSinceSignal: Map = new Map(); + + private fastPeriod: number; + private slowPeriod: number; + private minHoldingBars: number; + + constructor(parameters: { + fastPeriod?: number; + slowPeriod?: number; + minHoldingBars?: number; + } = {}) { + super('SimpleMovingAverageCrossover', `sma-crossover-${Date.now()}`, parameters); + + this.fastPeriod = parameters.fastPeriod || 5; + this.slowPeriod = parameters.slowPeriod || 15; + this.minHoldingBars = parameters.minHoldingBars || 3; + } + + protected onMarketData(data: MarketData): Signal[] { + const signals: Signal[] = []; + + // Only process bar data + if (data.data.type !== 'bar') { + return signals; + } + + const bar = data.data as BarData; + const symbol = data.symbol; + const price = bar.close; + + // Update price history + if (!this.priceHistory.has(symbol)) { + this.priceHistory.set(symbol, []); + this.barsSinceSignal.set(symbol, 0); + } + + const history = this.priceHistory.get(symbol)!; + history.push(price); + + // Keep only necessary history + if (history.length > this.slowPeriod) { + history.shift(); + } + + // Update bars since signal + const currentBar = this.barsSinceSignal.get(symbol) || 0; + this.barsSinceSignal.set(symbol, currentBar + 1); + + // Need enough data + if (history.length < this.slowPeriod) { + return signals; + } + + // Calculate moving averages + const fastMA = this.calculateSMA(history, this.fastPeriod); + const slowMA = this.calculateSMA(history, this.slowPeriod); + + // Calculate previous MAs + const prevHistory = history.slice(0, -1); + const prevFastMA = this.calculateSMA(prevHistory, this.fastPeriod); + const prevSlowMA = this.calculateSMA(prevHistory, this.slowPeriod); + + // Check for crossovers + const currentPosition = this.positions.get(symbol) || 0; + const lastCrossover = this.lastCrossover.get(symbol); + const barsSinceSignal = this.barsSinceSignal.get(symbol) || 0; + + // Golden cross - bullish signal + if (prevFastMA <= prevSlowMA && fastMA > slowMA) { + this.lastCrossover.set(symbol, 'golden'); + + if (currentPosition < 0) { + // Close short position + signals.push(this.closeSignal(symbol, 'Golden cross - Close short')); + } else if (currentPosition === 0 && barsSinceSignal >= this.minHoldingBars) { + // Open long position + signals.push(this.buySignal(symbol, 0.8, 'Golden cross - Open long')); + this.barsSinceSignal.set(symbol, 0); + } + } + + // Death cross - bearish signal + else if (prevFastMA >= prevSlowMA && fastMA < slowMA) { + this.lastCrossover.set(symbol, 'death'); + + if (currentPosition > 0) { + // Close long position + signals.push(this.closeSignal(symbol, 'Death cross - Close long')); + } else if (currentPosition === 0 && barsSinceSignal >= this.minHoldingBars) { + // Open short position + signals.push(this.sellSignal(symbol, 0.8, 'Death cross - Open short')); + this.barsSinceSignal.set(symbol, 0); + } + } + + // Trend following - enter on pullbacks + else if (currentPosition === 0 && barsSinceSignal >= this.minHoldingBars) { + if (lastCrossover === 'golden' && fastMA > slowMA) { + // Bullish trend continues + signals.push(this.buySignal(symbol, 0.8, 'Bullish trend - Open long')); + this.barsSinceSignal.set(symbol, 0); + } else if (lastCrossover === 'death' && fastMA < slowMA) { + // Bearish trend continues + signals.push(this.sellSignal(symbol, 0.8, 'Bearish trend - Open short')); + this.barsSinceSignal.set(symbol, 0); + } + } + + return signals; + } + + private calculateSMA(prices: number[], period: number): number { + if (prices.length < period) { + return 0; + } + + const relevantPrices = prices.slice(-period); + const sum = relevantPrices.reduce((a, b) => a + b, 0); + return sum / period; + } +} \ No newline at end of file diff --git a/apps/stock/orchestrator/test-rust-backtest.ts b/apps/stock/orchestrator/test-rust-backtest.ts new file mode 100644 index 0000000..b64c31e --- /dev/null +++ b/apps/stock/orchestrator/test-rust-backtest.ts @@ -0,0 +1,145 @@ +import { createRustBacktest } from './src/backtest/RustBacktestEngine'; +import { SimpleMovingAverageCrossoverRust } from './src/strategies/rust/SimpleMovingAverageCrossoverRust'; +import { IServiceContainer } from '@stock-bot/di'; + +// Mock StorageService +class MockStorageService { + async getHistoricalBars(symbol: string, startDate: Date, endDate: Date, frequency: string) { + // Generate mock data + const bars = []; + const msPerDay = 24 * 60 * 60 * 1000; + let currentDate = new Date(startDate); + let price = 100 + Math.random() * 50; // Start between 100-150 + + while (currentDate <= endDate) { + // Random walk + const change = (Math.random() - 0.5) * 2; // +/- 1% + price *= (1 + change / 100); + + bars.push({ + symbol, + timestamp: new Date(currentDate), + open: price * (1 + (Math.random() - 0.5) * 0.01), + high: price * (1 + Math.random() * 0.02), + low: price * (1 - Math.random() * 0.02), + close: price, + volume: 1000000 + Math.random() * 500000, + }); + + currentDate = new Date(currentDate.getTime() + msPerDay); + } + + return bars; + } +} + +async function testRustBacktest() { + console.log('šŸš€ Testing Rust Backtest Engine with TypeScript Strategy\n'); + + // Create minimal container + const container: IServiceContainer = { + logger: { + info: (msg: string, ...args: any[]) => console.log('[INFO]', msg, ...args), + error: (msg: string, ...args: any[]) => console.error('[ERROR]', msg, ...args), + warn: (msg: string, ...args: any[]) => console.warn('[WARN]', msg, ...args), + debug: (msg: string, ...args: any[]) => console.log('[DEBUG]', msg, ...args), + } as any, + custom: { + StorageService: new MockStorageService(), + } + }; + + // Backtest configuration + const config = { + mode: 'backtest' as const, + name: 'Rust Engine Test', + strategy: 'sma-crossover', + symbols: ['AAPL'], // Just one symbol for testing + startDate: '2023-01-01T00:00:00Z', + endDate: '2023-01-31T00:00:00Z', // Just one month for testing + initialCapital: 100000, + commission: 0.001, + slippage: 0.0001, + dataFrequency: '1d', + speed: 'max' as const, + }; + + // Create strategy + const strategy = new SimpleMovingAverageCrossoverRust({ + fastPeriod: 10, + slowPeriod: 30, + minHoldingBars: 5, + }); + + console.log('Configuration:'); + console.log(` Symbols: ${config.symbols.join(', ')}`); + console.log(` Period: ${config.startDate} to ${config.endDate}`); + console.log(` Initial Capital: $${config.initialCapital.toLocaleString()}`); + console.log(` Strategy: ${strategy.constructor.name}`); + console.log(''); + + try { + console.log('Running backtest in Rust engine...\n'); + const startTime = Date.now(); + + try { + const result = await createRustBacktest(container, config, [strategy]); + console.log('Raw result:', result); + + const duration = (Date.now() - startTime) / 1000; + + console.log(`\nāœ… Backtest completed in ${duration.toFixed(2)} seconds`); + + if (!result || !result.metrics) { + console.error('Invalid result structure:', result); + return; + } + + console.log('\n=== PERFORMANCE METRICS ==='); + console.log(`Total Return: ${result.metrics.totalReturn?.toFixed(2) || 'N/A'}%`); + console.log(`Sharpe Ratio: ${result.metrics.sharpeRatio?.toFixed(2) || 'N/A'}`); + console.log(`Max Drawdown: ${result.metrics.maxDrawdown ? (result.metrics.maxDrawdown * 100).toFixed(2) : 'N/A'}%`); + console.log(`Win Rate: ${result.metrics.winRate?.toFixed(1) || 'N/A'}%`); + console.log(`Total Trades: ${result.metrics.totalTrades || 0}`); + console.log(`Profit Factor: ${result.metrics.profitFactor?.toFixed(2) || 'N/A'}`); + + console.log('\n=== TRADE STATISTICS ==='); + console.log(`Profitable Trades: ${result.metrics.profitableTrades || 0}`); + console.log(`Average Win: $${result.metrics.avgWin?.toFixed(2) || '0.00'}`); + console.log(`Average Loss: $${result.metrics.avgLoss?.toFixed(2) || '0.00'}`); + console.log(`Total P&L: $${result.metrics.totalPnl?.toFixed(2) || '0.00'}`); + + console.log('\n=== EQUITY CURVE ==='); + if (result.equityCurve.length > 0) { + const firstValue = result.equityCurve[0].value; + const lastValue = result.equityCurve[result.equityCurve.length - 1].value; + console.log(`Starting Value: $${firstValue.toLocaleString()}`); + console.log(`Ending Value: $${lastValue.toLocaleString()}`); + console.log(`Growth: ${((lastValue / firstValue - 1) * 100).toFixed(2)}%`); + } + + console.log('\n=== FINAL POSITIONS ==='); + const positions = Object.entries(result.finalPositions); + if (positions.length > 0) { + for (const [symbol, position] of positions) { + console.log(`${symbol}: ${position.quantity} shares @ $${position.averagePrice}`); + } + } else { + console.log('No open positions'); + } + + // Compare with TypeScript engine performance + console.log('\n=== PERFORMANCE COMPARISON ==='); + console.log('TypeScript Engine: ~5-10 seconds for 1 year backtest'); + console.log(`Rust Engine: ${duration.toFixed(2)} seconds`); + console.log(`Speed Improvement: ${(10 / duration).toFixed(1)}x faster`); + } catch (innerError) { + console.error('Result processing error:', innerError); + } + } catch (error) { + console.error('āŒ Backtest failed:', error); + } +} + +// Run the test +testRustBacktest().catch(console.error); \ No newline at end of file