diff --git a/apps/stock/core/index.node b/apps/stock/core/index.node index 883f541..8f7447e 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 index a9cf5ae..414c0ac 100644 --- a/apps/stock/core/src/api/backtest.rs +++ b/apps/stock/core/src/api/backtest.rs @@ -231,9 +231,11 @@ fn parse_backtest_config(obj: napi::JsObject) -> Result { 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")?; + let strategy: Option = obj.get_named_property("strategy").ok(); Ok(BacktestConfig { name, + strategy, symbols, start_time: DateTime::parse_from_rfc3339(&start_date) .map_err(|e| Error::from_reason(e.to_string()))? @@ -419,12 +421,15 @@ impl Strategy for SimpleSMAStrategy { } fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) { - eprintln!("Fill received: {} {} @ {} - {}", quantity, symbol, price, side); + eprintln!("🔸 SMA Strategy - Fill received: {} {} @ ${:.2} - {}", 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 }; + eprintln!(" Position change: {} -> {}", current_pos, new_pos); + if new_pos.abs() < 0.0001 { self.positions.remove(symbol); + eprintln!(" Position closed"); } else { self.positions.insert(symbol.to_string(), new_pos); } diff --git a/apps/stock/core/src/backtest/engine.rs b/apps/stock/core/src/backtest/engine.rs index 82e9f69..88659b6 100644 --- a/apps/stock/core/src/backtest/engine.rs +++ b/apps/stock/core/src/backtest/engine.rs @@ -1,508 +1,715 @@ -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, TradeTracker, -}; - -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>, - pub market_data_source: Arc>>, - execution_handler: Arc>>, - - // Metrics - total_trades: usize, - profitable_trades: usize, - total_pnl: f64, - - // Price tracking - last_prices: HashMap, - - // Trade tracking - trade_tracker: TradeTracker, -} - -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, - last_prices: HashMap::new(), - trade_tracker: TradeTracker::new(), - } - } - - pub fn add_strategy(&mut self, strategy: Box) { - self.strategies.write().push(strategy); - } - - pub async fn run(&mut self) -> Result { - eprintln!("=== BacktestEngine::run() START ==="); - eprintln!("Config: start={}, end={}, symbols={:?}", - self.config.start_time, self.config.end_time, self.config.symbols); - eprintln!("Number of strategies loaded: {}", self.strategies.read().len()); - - // Initialize start time - if let Some(simulated_time) = self.time_provider.as_any() - .downcast_ref::() - { - simulated_time.advance_to(self.config.start_time); - eprintln!("Time initialized to: {}", self.config.start_time); - } - - // Load market data - eprintln!("Loading market data from data source..."); - self.load_market_data().await?; - - let queue_len = self.event_queue.read().len(); - eprintln!("Event queue length after loading: {}", queue_len); - - if queue_len == 0 { - eprintln!("WARNING: No events loaded! Check data source."); - } - - // Main event loop - let mut iteration = 0; - while !self.event_queue.read().is_empty() { - iteration += 1; - if iteration <= 5 || iteration % 100 == 0 { - eprintln!("Processing iteration {} at time {}", iteration, self.time_provider.now()); - } - - // Get the next event's timestamp - let next_event_time = self.event_queue.read() - .peek_next() - .map(|e| e.timestamp); - - if let Some(event_time) = next_event_time { - // Advance time to the next event - self.advance_time(event_time); - - // Get all events at this timestamp - 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(); - } else { - // No more events - break; - } - } - - eprintln!("Backtest complete. Total trades: {}", self.total_trades); - - // Generate results - Ok(self.generate_results()) - } - - async fn load_market_data(&mut self) -> Result<(), String> { - eprintln!("=== load_market_data START ==="); - let mut data_source = self.market_data_source.write(); - - // Check if it's a HistoricalDataSource - if let Some(historical) = data_source.as_any() - .downcast_ref::() { - eprintln!("Data source is HistoricalDataSource"); - eprintln!("Historical data points available: {}", historical.data_len()); - } else { - eprintln!("WARNING: Data source is NOT HistoricalDataSource!"); - } - - eprintln!("Seeking to start time: {}", self.config.start_time); - data_source.seek_to_time(self.config.start_time)?; - - let mut count = 0; - let mut first_few = 0; - - // Load all data into event queue - while let Some(update) = data_source.get_next_update().await { - if update.timestamp > self.config.end_time { - eprintln!("Reached end time at {} data points", count); - break; - } - - count += 1; - - // Log first few data points - if first_few < 3 { - eprintln!("Data point {}: symbol={}, time={}, type={:?}", - count, update.symbol, update.timestamp, - match &update.data { - MarketDataType::Bar(b) => format!("Bar(close={})", b.close), - MarketDataType::Quote(q) => format!("Quote(bid={}, ask={})", q.bid, q.ask), - MarketDataType::Trade(t) => format!("Trade(price={})", t.price), - } - ); - first_few += 1; - } - - if count % 100 == 0 { - eprintln!("Loaded {} data points so far...", count); - } - - let event = BacktestEvent::market_data(update.timestamp, update); - self.event_queue.write().push(event); - } - - eprintln!("=== load_market_data COMPLETE ==="); - eprintln!("Total data points loaded: {}", count); - 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 - // Note: We now record fills in process_fill with symbol info - } - 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> { - static mut MARKET_DATA_COUNT: usize = 0; - unsafe { - MARKET_DATA_COUNT += 1; - if MARKET_DATA_COUNT <= 3 || MARKET_DATA_COUNT % 100 == 0 { - eprintln!("process_market_data #{}: symbol={}, time={}", - MARKET_DATA_COUNT, data.symbol, data.timestamp); - } - } - - // Update price tracking - match &data.data { - MarketDataType::Bar(bar) => { - self.last_prices.insert(data.symbol.clone(), bar.close); - } - MarketDataType::Quote(quote) => { - // Use mid price for quotes - let mid_price = (quote.bid + quote.ask) / 2.0; - self.last_prices.insert(data.symbol.clone(), mid_price); - } - MarketDataType::Trade(trade) => { - self.last_prices.insert(data.symbol.clone(), trade.price); - } - } - - // 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 (i, strategy) in strategies.iter_mut().enumerate() { - let signals = strategy.on_market_data(&market_data); - if !signals.is_empty() { - eprintln!("Strategy {} generated {} signals!", i, signals.len()); - } - all_signals.extend(signals); - } - } - - // Process signals - for signal in all_signals { - eprintln!("Processing signal: {:?}", signal); - 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 - let base_price = self.last_prices.get(&order.symbol) - .copied() - .ok_or_else(|| format!("No price available for symbol: {}", order.symbol))?; - - // 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 with symbol and side information - self.state.write().record_fill(order.symbol.clone(), order.side, fill.clone()); - - // Track trades - self.trade_tracker.process_fill(&order.symbol, order.side, &fill); - - // 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)); - } - } - - eprintln!("Fill processed: {} {} @ {} (side: {:?})", - fill.quantity, order.symbol, fill.price, order.side); - eprintln!("Current position after fill: {}", - self.position_tracker.get_position(&order.symbol) - .map(|p| p.quantity) - .unwrap_or(0.0)); - - // 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 { - // Use last known price for the symbol - let price = self.last_prices.get(&position.symbol).copied().unwrap_or(position.average_price); - let market_value = position.quantity * 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 = self.last_prices.get(symbol).copied().unwrap_or(100.0); - - (position_value / price).floor() - } - - fn get_next_event_time(&self) -> Option> { - // Get the timestamp of the next event in the queue - self.event_queue.read() - .peek_next() - .map(|event| event.timestamp) - } - - fn generate_results(&self) -> BacktestResult { - let state = self.state.read(); - let start_time = self.config.start_time; - - // Get final positions - let final_positions = self.position_tracker.get_all_positions() - .into_iter() - .map(|p| (p.symbol.clone(), p)) - .collect(); - - // Get completed trades from tracker - let completed_trades = self.trade_tracker.get_completed_trades(); - - // Use simple results builder with proper trade data - BacktestResult::from_engine_data_with_trades( - self.config.clone(), - state.equity_curve.clone(), - state.completed_trades.clone(), - completed_trades, - final_positions, - start_time, - &self.last_prices, - ) - } -} - -// Add uuid dependency +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, TradeTracker, +}; + +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>, + pub market_data_source: Arc>>, + execution_handler: Arc>>, + + // Metrics + total_trades: usize, + profitable_trades: usize, + total_pnl: f64, + + // Price tracking - single source of truth + // Maps symbol -> (timestamp, price) + last_prices: HashMap, f64)>, + + // Trade tracking + trade_tracker: TradeTracker, +} + +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, + last_prices: HashMap::new(), + trade_tracker: TradeTracker::new(), + } + } + + pub fn add_strategy(&mut self, strategy: Box) { + self.strategies.write().push(strategy); + } + + pub async fn run(&mut self) -> Result { + eprintln!("=== BacktestEngine::run() START ==="); + eprintln!("Config: start={}, end={}, symbols={:?}", + self.config.start_time, self.config.end_time, self.config.symbols); + eprintln!("Number of strategies loaded: {}", self.strategies.read().len()); + + // Initialize start time + if let Some(simulated_time) = self.time_provider.as_any() + .downcast_ref::() + { + simulated_time.advance_to(self.config.start_time); + eprintln!("Time initialized to: {}", self.config.start_time); + } + + // Load market data + eprintln!("Loading market data from data source..."); + self.load_market_data().await?; + + let queue_len = self.event_queue.read().len(); + eprintln!("Event queue length after loading: {}", queue_len); + + if queue_len == 0 { + eprintln!("WARNING: No events loaded! Check data source."); + } + + // Main event loop - process events grouped by timestamp + let mut iteration = 0; + let mut last_update_time = self.config.start_time; + + while !self.event_queue.read().is_empty() { + iteration += 1; + + // Get the next event's timestamp + let next_event_time = self.event_queue.read() + .peek_next() + .map(|e| e.timestamp); + + if let Some(event_time) = next_event_time { + // Advance time to the next event + self.advance_time(event_time); + + // Get all events at this timestamp + let current_time = self.time_provider.now(); + let events = self.event_queue.write().pop_until(current_time); + + if iteration <= 5 || iteration % 100 == 0 { + eprintln!("Processing iteration {} at time {} with {} events", + iteration, current_time, events.len()); + } + + // Process all events at this timestamp + for event in events { + self.process_event(event).await?; + } + + // Only update portfolio value if time has actually advanced + // This ensures we have prices for all symbols at this timestamp + if current_time > last_update_time { + self.update_portfolio_value(); + last_update_time = current_time; + } + } else { + // No more events + break; + } + } + + eprintln!("Backtest complete. Total trades: {}", self.total_trades); + + // Close all open positions at market prices + self.close_all_positions().await?; + + // Generate results + Ok(self.generate_results()) + } + + async fn load_market_data(&mut self) -> Result<(), String> { + eprintln!("=== load_market_data START ==="); + let mut data_source = self.market_data_source.write(); + + // Check if it's a HistoricalDataSource + if let Some(historical) = data_source.as_any() + .downcast_ref::() { + eprintln!("Data source is HistoricalDataSource"); + eprintln!("Historical data points available: {}", historical.data_len()); + } else { + eprintln!("WARNING: Data source is NOT HistoricalDataSource!"); + } + + eprintln!("Seeking to start time: {}", self.config.start_time); + data_source.seek_to_time(self.config.start_time)?; + + let mut count = 0; + let mut first_few = 0; + + // Load all data into event queue + while let Some(update) = data_source.get_next_update().await { + if update.timestamp > self.config.end_time { + eprintln!("Reached end time at {} data points", count); + break; + } + + count += 1; + + // Log first few data points + if first_few < 3 { + eprintln!("Data point {}: symbol={}, time={}, type={:?}", + count, update.symbol, update.timestamp, + match &update.data { + MarketDataType::Bar(b) => format!("Bar(close={})", b.close), + MarketDataType::Quote(q) => format!("Quote(bid={}, ask={})", q.bid, q.ask), + MarketDataType::Trade(t) => format!("Trade(price={})", t.price), + } + ); + first_few += 1; + } + + if count % 100 == 0 { + eprintln!("Loaded {} data points so far...", count); + } + + let event = BacktestEvent::market_data(update.timestamp, update); + self.event_queue.write().push(event); + } + + eprintln!("=== load_market_data COMPLETE ==="); + eprintln!("Total data points loaded: {}", count); + 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 + // Note: We now record fills in process_fill with symbol info + } + 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> { + static mut MARKET_DATA_COUNT: usize = 0; + unsafe { + MARKET_DATA_COUNT += 1; + if MARKET_DATA_COUNT <= 3 || MARKET_DATA_COUNT % 100 == 0 { + eprintln!("process_market_data #{}: symbol={}, time={}", + MARKET_DATA_COUNT, data.symbol, data.timestamp); + } + } + + // Update price tracking - single source of truth + let price = match &data.data { + MarketDataType::Bar(bar) => { + let old_entry = self.last_prices.get(&data.symbol); + let old_price = old_entry.map(|(_, p)| *p); + eprintln!("📊 PRICE UPDATE: {} @ {} - close: ${:.2} (was: ${:?})", + data.symbol, data.timestamp.format("%Y-%m-%d"), bar.close, old_price); + bar.close + } + MarketDataType::Quote(quote) => { + // Use mid price for quotes + (quote.bid + quote.ask) / 2.0 + } + MarketDataType::Trade(trade) => { + trade.price + } + }; + + // Store price with timestamp - this is our source of truth + self.last_prices.insert(data.symbol.clone(), (data.timestamp, price)); + + // 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 (i, strategy) in strategies.iter_mut().enumerate() { + let signals = strategy.on_market_data(&market_data); + if !signals.is_empty() { + eprintln!("Strategy {} generated {} signals!", i, signals.len()); + } + all_signals.extend(signals); + } + } + + // Process signals + for signal in all_signals { + eprintln!("Processing signal: {:?}", signal); + self.process_signal(signal).await?; + } + + // Check pending orders for fills + self.check_pending_orders(&data).await?; + + // Don't update portfolio value here - wait until all events at this timestamp are processed + + 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> { + let current_time = self.time_provider.now(); + eprintln!("📡 SIGNAL at {}: {:?} {} (strength: {}, reason: {:?})", + current_time.format("%Y-%m-%d"), + signal.signal_type, + signal.symbol, + signal.strength, + signal.reason); + + // Only process strong signals + if signal.strength.abs() < 0.7 { + eprintln!(" Signal ignored (strength < 0.7)"); + return Ok(()); + } + + // Check current price before creating order + if let Some((price_time, price)) = self.last_prices.get(&signal.symbol) { + eprintln!(" Current price for {}: ${:.2} (from {})", + signal.symbol, price, price_time.format("%Y-%m-%d")); + } + + // Convert signal to order + let order = self.signal_to_order(signal)?; + + eprintln!(" Creating {:?} order for {} shares", order.side, order.quantity); + + // 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> { + let current_time = self.time_provider.now(); + + // Get current market price - only use if it's from the current time + let (price_time, base_price) = self.last_prices.get(&order.symbol) + .copied() + .ok_or_else(|| format!("No price available for symbol: {}", order.symbol))?; + + // CRITICAL: Verify the price is from the current time + if price_time != current_time { + eprintln!("⚠️ WARNING: Price timestamp mismatch! Current: {}, Price from: {}", + current_time.format("%Y-%m-%d %H:%M:%S"), + price_time.format("%Y-%m-%d %H:%M:%S")); + // In a real system, we would reject this fill or fetch current price + // For now, log the issue + } + + eprintln!("🔍 CHECK_ORDER_FILL: {:?} {} @ time {} - price: ${:.2} (from {})", + order.side, order.symbol, current_time.format("%Y-%m-%d"), + base_price, price_time.format("%Y-%m-%d")); + + // DEBUG: Check what's in last_prices for this symbol + eprintln!(" DEBUG: All prices for {}: {:?}", + order.symbol, + self.last_prices.get(&order.symbol)); + + // 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), + }; + + eprintln!(" Fill price after slippage ({}): ${:.2}", self.config.slippage, fill_price); + + // Create fill + let fill = crate::Fill { + timestamp: current_time, + 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); + + // Get position before the fill + let position_before = self.position_tracker + .get_position(&order.symbol) + .map(|p| p.quantity) + .unwrap_or(0.0); + + // Update positions + let update = self.position_tracker.process_fill( + &order.symbol, + &fill, + order.side, + ); + + // Calculate P&L if position was reduced/closed + let pnl = if update.resulting_position.realized_pnl != 0.0 { + Some(update.resulting_position.realized_pnl) + } else { + None + }; + + // Get position after this fill + let position_after = update.resulting_position.quantity; + + // Record the fill with position and P&L information + self.state.write().record_fill( + order.symbol.clone(), + order.side, + fill.clone(), + position_after, + position_before, + pnl + ); + + // Track trades + self.trade_tracker.process_fill(&order.symbol, order.side, &fill); + + // 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() { + let side_str = match order.side { + crate::Side::Buy => "buy", + crate::Side::Sell => "sell", + }; + strategy.on_fill(&order.symbol, fill.quantity, fill.price, side_str); + } + } + + let fill_date = fill.timestamp.format("%Y-%m-%d").to_string(); + let is_feb_mar_2024 = fill_date >= "2024-02-28".to_string() && fill_date <= "2024-03-05".to_string(); + + if is_feb_mar_2024 { + eprintln!(" +🔴 CRITICAL FILL on {}: {} {} @ {} (side: {:?})", + fill_date, fill.quantity, order.symbol, fill.price, order.side); + eprintln!("Cash before: ${:.2}, Cash after: ${:.2}, Cash change: ${:.2}", + self.state.read().cash - cash_change, self.state.read().cash, cash_change); + } + + eprintln!("Fill processed: {} {} @ {} (side: {:?})", + fill.quantity, order.symbol, fill.price, order.side); + eprintln!("Current position after fill: {}", + self.position_tracker.get_position(&order.symbol) + .map(|p| p.quantity) + .unwrap_or(0.0)); + + // 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 cash = self.state.read().cash; + let mut portfolio_value = cash; + let current_time = self.time_provider.now(); + + // Debug logging for first few updates + static mut UPDATE_COUNT: usize = 0; + unsafe { + UPDATE_COUNT += 1; + if UPDATE_COUNT <= 5 || UPDATE_COUNT % 100 == 0 || + // Log around Feb 28 - Mar 5, 2024 + (current_time.format("%Y-%m-%d").to_string() >= "2024-02-28".to_string() && + current_time.format("%Y-%m-%d").to_string() <= "2024-03-05".to_string()) { + eprintln!("=== Portfolio Update #{} at {} ===", UPDATE_COUNT, current_time); + eprintln!("Cash: ${:.2}", cash); + } + } + + for position in &positions { + // Use last known price for the symbol + let price = self.last_prices.get(&position.symbol) + .map(|(_, p)| *p) + .unwrap_or(position.average_price); + + // Calculate market value correctly for long and short positions + let market_value = if position.quantity > 0.0 { + // Long position: value = quantity * current_price + position.quantity * price + } else { + // Short position: + // We have a liability to buy back shares at current market price + // This is a negative value that reduces portfolio value + // Value = quantity * price (quantity is already negative) + position.quantity * price + }; + + portfolio_value += market_value; + + unsafe { + if UPDATE_COUNT <= 5 || UPDATE_COUNT % 100 == 0 || + // Log around Feb 28 - Mar 5, 2024 + (current_time.format("%Y-%m-%d").to_string() >= "2024-02-28".to_string() && + current_time.format("%Y-%m-%d").to_string() <= "2024-03-05".to_string()) { + let pnl = if position.quantity > 0.0 { + (price - position.average_price) * position.quantity + } else { + (position.average_price - price) * position.quantity.abs() + }; + let position_type = if position.quantity > 0.0 { "LONG" } else { "SHORT" }; + eprintln!(" {} {} position: {} shares @ avg ${:.2}, current ${:.2} = ${:.2} (P&L: ${:.2})", + position_type, position.symbol, position.quantity, position.average_price, price, market_value, pnl); + } + } + } + + unsafe { + if UPDATE_COUNT <= 5 || UPDATE_COUNT % 100 == 0 || + // Log around Feb 28 - Mar 5, 2024 + (current_time.format("%Y-%m-%d").to_string() >= "2024-02-28".to_string() && + current_time.format("%Y-%m-%d").to_string() <= "2024-03-05".to_string()) { + eprintln!("Total Portfolio Value: ${:.2}", portfolio_value); + eprintln!("==================================="); + } + } + + self.state.write().update_portfolio_value(portfolio_value); + } + + fn calculate_position_size(&self, symbol: &str, signal_strength: f64) -> f64 { + let state = self.state.read(); + let portfolio_value = state.portfolio_value; + let cash = state.cash; + + // Use available cash, not total portfolio value for position sizing + let allocation = 0.2; // 20% of available cash per position + let position_value = cash.min(portfolio_value * allocation) * signal_strength.abs(); + + let price = self.last_prices.get(symbol) + .map(|(_, p)| *p) + .unwrap_or(100.0); + let shares = (position_value / price).floor(); + + eprintln!("Position sizing for {}: portfolio=${:.2}, cash=${:.2}, price=${:.2}, shares={}", + symbol, portfolio_value, cash, price, shares); + + shares + } + + fn get_next_event_time(&self) -> Option> { + // Get the timestamp of the next event in the queue + self.event_queue.read() + .peek_next() + .map(|event| event.timestamp) + } + + async fn close_all_positions(&mut self) -> Result<(), String> { + eprintln!("=== Closing all open positions at end of backtest ==="); + eprintln!("Current time: {}", self.time_provider.now()); + eprintln!("Last prices:"); + for (symbol, (time, price)) in &self.last_prices { + eprintln!(" {}: ${:.2} (from {})", symbol, price, time.format("%Y-%m-%d %H:%M:%S")); + } + + let positions = self.position_tracker.get_all_positions(); + for position in positions { + if position.quantity.abs() > 0.001 { + let last_price = self.last_prices.get(&position.symbol).map(|(_, p)| *p); + eprintln!("Closing position: {} {} shares of {} at last price: {:?}", + if position.quantity > 0.0 { "Selling" } else { "Buying" }, + position.quantity.abs(), + position.symbol, + last_price + ); + + // Create market order to close position + let order = crate::Order { + id: format!("close_{}", uuid::Uuid::new_v4()), + symbol: position.symbol.clone(), + side: if position.quantity > 0.0 { Side::Sell } else { Side::Buy }, + quantity: position.quantity.abs(), + order_type: crate::OrderType::Market, + time_in_force: crate::TimeInForce::Day, + }; + + // Process the closing order + self.check_order_fill(&order).await?; + } + } + + eprintln!("All positions closed. Final cash: {}", self.state.read().cash); + Ok(()) + } + + fn generate_results(&self) -> BacktestResult { + let state = self.state.read(); + let start_time = self.config.start_time; + + // Get final positions + let final_positions = self.position_tracker.get_all_positions() + .into_iter() + .map(|p| (p.symbol.clone(), p)) + .collect(); + + // Get completed trades from tracker + let completed_trades = self.trade_tracker.get_completed_trades(); + + // Convert last_prices to simple HashMap for results + let simple_last_prices: HashMap = self.last_prices + .iter() + .map(|(symbol, (_, price))| (symbol.clone(), *price)) + .collect(); + + // Use simple results builder with proper trade data + BacktestResult::from_engine_data_with_trades( + self.config.clone(), + state.equity_curve.clone(), + state.completed_trades.clone(), + completed_trades, + final_positions, + start_time, + &simple_last_prices, + ) + } +} + +// Add uuid dependency use uuid::Uuid; \ No newline at end of file diff --git a/apps/stock/core/src/backtest/mod.rs b/apps/stock/core/src/backtest/mod.rs index 4e2d404..240b7e2 100644 --- a/apps/stock/core/src/backtest/mod.rs +++ b/apps/stock/core/src/backtest/mod.rs @@ -28,13 +28,20 @@ pub struct CompletedTrade { pub price: f64, pub quantity: f64, pub commission: f64, + pub position_after: f64, // Position size after this trade + pub pnl: Option, // P&L if position was reduced/closed } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct BacktestConfig { pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub strategy: Option, pub symbols: Vec, + #[serde(rename = "startDate")] pub start_time: DateTime, + #[serde(rename = "endDate")] pub end_time: DateTime, pub initial_capital: f64, pub commission: f64, @@ -66,7 +73,18 @@ impl BacktestState { pub fn update_portfolio_value(&mut self, value: f64) { self.portfolio_value = value; - self.equity_curve.push((self.current_time, value)); + + // Only add a new equity curve point if the timestamp has changed + // or if it's the first point + if self.equity_curve.is_empty() || + self.equity_curve.last().map(|(t, _)| *t != self.current_time).unwrap_or(true) { + self.equity_curve.push((self.current_time, value)); + } else { + // Update the last point with the new value + if let Some(last) = self.equity_curve.last_mut() { + last.1 = value; + } + } } pub fn add_pending_order(&mut self, order: Order) { @@ -77,7 +95,7 @@ impl BacktestState { self.pending_orders.remove(order_id) } - pub fn record_fill(&mut self, symbol: String, side: Side, fill: Fill) { + pub fn record_fill(&mut self, symbol: String, side: Side, fill: Fill, position_after: f64, position_before: f64, pnl: Option) { self.completed_trades.push(CompletedTrade { symbol, side, @@ -85,6 +103,8 @@ impl BacktestState { price: fill.price, quantity: fill.quantity, commission: fill.commission, + position_after, + pnl, }); } } diff --git a/apps/stock/core/src/backtest/simple_results.rs b/apps/stock/core/src/backtest/simple_results.rs index 3059ecf..3b23285 100644 --- a/apps/stock/core/src/backtest/simple_results.rs +++ b/apps/stock/core/src/backtest/simple_results.rs @@ -23,10 +23,18 @@ pub struct BacktestMetrics { pub expectancy: f64, pub calmar_ratio: f64, pub sortino_ratio: f64, + // Missing fields required by web app + pub final_value: f64, + pub winning_trades: usize, + pub losing_trades: usize, + pub largest_win: f64, + pub largest_loss: f64, + pub annual_return: f64, } // Individual trade (fill) structure for UI #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Trade { pub id: String, pub timestamp: String, @@ -37,6 +45,7 @@ pub struct Trade { pub commission: f64, #[serde(skip_serializing_if = "Option::is_none")] pub pnl: Option, + pub position_after: f64, // Position size after this trade } // Analytics data structure @@ -79,7 +88,7 @@ pub struct BacktestResult { pub config: BacktestConfig, pub metrics: BacktestMetrics, pub equity: Vec, - pub trades: Vec, + pub trades: Vec, // Now shows all individual fills pub positions: Vec, pub analytics: Analytics, pub execution_time: u64, @@ -95,23 +104,6 @@ pub struct EquityPoint { pub value: f64, } -// Trade structure that web app expects (with entry/exit info) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompletedTradeInfo { - pub id: String, - pub symbol: String, - pub entry_date: String, - pub exit_date: Option, - pub entry_price: f64, - pub exit_price: f64, - pub quantity: f64, - pub side: String, - pub pnl: f64, - pub pnl_percent: f64, - pub commission: f64, - pub duration: i64, // milliseconds -} impl BacktestResult { pub fn from_engine_data( @@ -138,7 +130,8 @@ impl BacktestResult { quantity: fill.quantity, price: fill.price, commission: fill.commission, - pnl: None, + pnl: fill.pnl, + position_after: fill.position_after, }) .collect(); @@ -239,6 +232,12 @@ impl BacktestResult { expectancy: 0.0, calmar_ratio, sortino_ratio: 0.0, // TODO: Calculate + final_value, + winning_trades: profitable_trades, + losing_trades: 0, + largest_win: 0.0, + largest_loss: 0.0, + annual_return: annualized_return * 100.0, }; // Create analytics @@ -306,24 +305,22 @@ impl BacktestResult { let initial_capital = config.initial_capital; let final_value = equity_curve.last().map(|(_, v)| *v).unwrap_or(initial_capital); - // Convert completed trades to web app format - let trades: Vec = completed_trades.iter() - .map(|trade| CompletedTradeInfo { - id: trade.id.clone(), - symbol: trade.symbol.clone(), - entry_date: trade.entry_time.to_rfc3339(), - exit_date: Some(trade.exit_time.to_rfc3339()), - entry_price: trade.entry_price, - exit_price: trade.exit_price, - quantity: trade.quantity, - side: match trade.side { + // Convert fills to web app format (all individual trades) + let trades: Vec = fills.iter() + .enumerate() + .map(|(i, fill)| Trade { + id: format!("trade-{}", i + 1), + timestamp: fill.timestamp.to_rfc3339(), + symbol: fill.symbol.clone(), + side: match fill.side { crate::Side::Buy => "buy".to_string(), crate::Side::Sell => "sell".to_string(), }, - pnl: trade.pnl, - pnl_percent: trade.pnl_percent, - commission: trade.commission, - duration: trade.duration_seconds * 1000, // Convert to milliseconds + quantity: fill.quantity, + price: fill.price, + commission: fill.commission, + pnl: fill.pnl, + position_after: fill.position_after, }) .collect(); @@ -480,10 +477,21 @@ impl BacktestResult { quantity: fill.quantity, price: fill.price, commission: fill.commission, - pnl: None, + pnl: fill.pnl, + position_after: fill.position_after, }) .collect(); + // Find largest win/loss + let largest_win = winning_trades.iter() + .map(|t| t.pnl) + .max_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(0.0); + let largest_loss = losing_trades.iter() + .map(|t| t.pnl) + .min_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(0.0); + let metrics = BacktestMetrics { total_return, sharpe_ratio, @@ -498,6 +506,12 @@ impl BacktestResult { expectancy, calmar_ratio, sortino_ratio, + final_value, + winning_trades: profitable_trades, + losing_trades: losing_trades.len(), + largest_win, + largest_loss, + annual_return: annualized_return * 100.0, // Convert to percentage }; // Calculate monthly returns diff --git a/apps/stock/core/src/backtest/trade_tracker.rs b/apps/stock/core/src/backtest/trade_tracker.rs index 654c281..ce98cf3 100644 --- a/apps/stock/core/src/backtest/trade_tracker.rs +++ b/apps/stock/core/src/backtest/trade_tracker.rs @@ -93,6 +93,16 @@ impl TradeTracker { duration_seconds: (fill.timestamp - open_pos.entry_time).num_seconds(), }; + eprintln!("📈 TRADE CLOSED: {} {} @ entry: ${:.2} ({}), exit: ${:.2} ({}) = P&L: ${:.2}", + symbol, + match open_pos.side { Side::Buy => "LONG", Side::Sell => "SHORT" }, + open_pos.entry_price, + open_pos.entry_time.format("%Y-%m-%d"), + fill.price, + fill.timestamp.format("%Y-%m-%d"), + pnl + ); + self.completed_trades.push(completed_trade); // Update open position diff --git a/apps/stock/core/src/strategies/mean_reversion_fixed.rs b/apps/stock/core/src/strategies/mean_reversion_fixed.rs index 5553c7c..7cf0cca 100644 --- a/apps/stock/core/src/strategies/mean_reversion_fixed.rs +++ b/apps/stock/core/src/strategies/mean_reversion_fixed.rs @@ -88,18 +88,24 @@ impl Strategy for MeanReversionFixedStrategy { // Get actual position from our tracking let current_position = self.current_positions.get(symbol).copied().unwrap_or(0.0); - // Entry signals - only when flat - if current_position.abs() < 0.001 { - if price < lower_band { - // Price is oversold, buy - eprintln!("Mean reversion: {} oversold at ${:.2}, buying (lower band: ${:.2}, mean: ${:.2})", - symbol, price, lower_band, mean); + // Entry signals - allow pyramiding up to 3x base position + let max_long_position = self.position_size * 3.0; + let max_short_position = -self.position_size * 3.0; + + if price < lower_band && current_position < max_long_position { + // Price is oversold, buy (or add to long position) + let remaining_capacity = max_long_position - current_position; + let trade_size = self.position_size.min(remaining_capacity); + + if trade_size > 0.0 { + eprintln!("Mean reversion: {} oversold at ${:.2}, buying {} shares (current: {}, lower band: ${:.2}, mean: ${:.2})", + symbol, price, trade_size, current_position, lower_band, mean); signals.push(Signal { symbol: symbol.clone(), signal_type: SignalType::Buy, strength: 1.0, - quantity: Some(self.position_size), + quantity: Some(trade_size), reason: Some(format!( "Mean reversion buy: price ${:.2} < lower band ${:.2} (mean: ${:.2}, std: ${:.2})", price, lower_band, mean, std_dev @@ -112,16 +118,21 @@ impl Strategy for MeanReversionFixedStrategy { "price": price, })), }); - } else if price > upper_band { - // Price is overbought, sell short - eprintln!("Mean reversion: {} overbought at ${:.2}, selling short (upper band: ${:.2}, mean: ${:.2})", - symbol, price, upper_band, mean); + } + } else if price > upper_band && current_position > max_short_position { + // Price is overbought, sell short (or add to short position) + let remaining_capacity = current_position - max_short_position; + let trade_size = self.position_size.min(remaining_capacity); + + if trade_size > 0.0 { + eprintln!("Mean reversion: {} overbought at ${:.2}, selling {} shares short (current: {}, upper band: ${:.2}, mean: ${:.2})", + symbol, price, trade_size, current_position, upper_band, mean); signals.push(Signal { symbol: symbol.clone(), signal_type: SignalType::Sell, strength: 1.0, - quantity: Some(self.position_size), + quantity: Some(trade_size), reason: Some(format!( "Mean reversion sell: price ${:.2} > upper band ${:.2} (mean: ${:.2}, std: ${:.2})", price, upper_band, mean, std_dev @@ -136,16 +147,19 @@ impl Strategy for MeanReversionFixedStrategy { }); } } - // Exit signals - only when we have a position - else if current_position > 0.0 { + + // Exit signals based on current position + if current_position > 0.0 { // We're long - check exit conditions let entry_price = self.entry_prices.get(symbol).copied().unwrap_or(price); let target_price = entry_price + (mean - entry_price) * self.exit_threshold; let stop_loss = lower_band - std_dev; // Stop loss below lower band - if price >= target_price { - eprintln!("Mean reversion: {} reached target ${:.2} (entry: ${:.2}, mean: ${:.2}), closing long", - symbol, target_price, entry_price, mean); + // Exit if price reaches target or stop loss + if price >= target_price || price <= stop_loss { + let exit_reason = if price >= target_price { "target" } else { "stop_loss" }; + eprintln!("Mean reversion: {} exit long at ${:.2} ({}), closing {} shares", + symbol, price, exit_reason, current_position); signals.push(Signal { symbol: symbol.clone(), @@ -161,26 +175,8 @@ impl Strategy for MeanReversionFixedStrategy { "price": price, "entry_price": entry_price, "target_price": target_price, - "exit_type": "target", - })), - }); - } else if price <= stop_loss { - eprintln!("Mean reversion: {} hit stop loss ${:.2}, closing long", - symbol, stop_loss); - - signals.push(Signal { - symbol: symbol.clone(), - signal_type: SignalType::Sell, - strength: 1.0, - quantity: Some(current_position), - reason: Some(format!( - "Mean reversion stop loss: price ${:.2} <= stop ${:.2}", - price, stop_loss - )), - metadata: Some(json!({ "stop_loss": stop_loss, - "price": price, - "exit_type": "stop_loss", + "exit_type": exit_reason, })), }); } @@ -190,9 +186,11 @@ impl Strategy for MeanReversionFixedStrategy { let target_price = entry_price - (entry_price - mean) * self.exit_threshold; let stop_loss = upper_band + std_dev; // Stop loss above upper band - if price <= target_price { - eprintln!("Mean reversion: {} reached target ${:.2} (entry: ${:.2}, mean: ${:.2}), closing short", - symbol, target_price, entry_price, mean); + // Exit if price reaches target or stop loss + if price <= target_price || price >= stop_loss { + let exit_reason = if price <= target_price { "target" } else { "stop_loss" }; + eprintln!("Mean reversion: {} exit short at ${:.2} ({}), covering {} shares", + symbol, price, exit_reason, current_position.abs()); signals.push(Signal { symbol: symbol.clone(), @@ -200,34 +198,16 @@ impl Strategy for MeanReversionFixedStrategy { strength: 1.0, quantity: Some(current_position.abs()), reason: Some(format!( - "Mean reversion exit short: price ${:.2} reached target ${:.2} (entry: ${:.2})", - price, target_price, entry_price + "Mean reversion exit short: price ${:.2} {} (entry: ${:.2}, target: ${:.2}, stop: ${:.2})", + price, exit_reason, entry_price, target_price, stop_loss )), metadata: Some(json!({ "mean": mean, "price": price, "entry_price": entry_price, "target_price": target_price, - "exit_type": "target", - })), - }); - } else if price >= stop_loss { - eprintln!("Mean reversion: {} hit stop loss ${:.2}, closing short", - symbol, stop_loss); - - signals.push(Signal { - symbol: symbol.clone(), - signal_type: SignalType::Buy, - strength: 1.0, - quantity: Some(current_position.abs()), - reason: Some(format!( - "Mean reversion stop loss: price ${:.2} >= stop ${:.2}", - price, stop_loss - )), - metadata: Some(json!({ "stop_loss": stop_loss, - "price": price, - "exit_type": "stop_loss", + "exit_type": exit_reason, })), }); } @@ -241,7 +221,7 @@ impl Strategy for MeanReversionFixedStrategy { fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) { // Update our position tracking based on actual fills let current = self.current_positions.get(symbol).copied().unwrap_or(0.0); - let new_position = if side.contains("Buy") { + let new_position = if side == "buy" { current + quantity } else { current - quantity @@ -255,11 +235,20 @@ impl Strategy for MeanReversionFixedStrategy { // Position closed self.current_positions.remove(symbol); self.entry_prices.remove(symbol); + eprintln!("Position closed for {}", symbol); } else { self.current_positions.insert(symbol.to_string(), new_position); - // Track entry price for new positions + + // Track average entry price if current.abs() < 0.001 { + // New position - set initial entry price self.entry_prices.insert(symbol.to_string(), price); + } else if (current > 0.0 && new_position > current) || (current < 0.0 && new_position < current) { + // Adding to existing position - update average entry price + let old_price = self.entry_prices.get(symbol).copied().unwrap_or(price); + let avg_price = (old_price * current.abs() + price * quantity) / new_position.abs(); + self.entry_prices.insert(symbol.to_string(), avg_price); + eprintln!("Updated avg entry price for {}: ${:.2}", symbol, avg_price); } } } diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx index d06076a..dbcb77e 100644 --- a/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx +++ b/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx @@ -2,6 +2,7 @@ import type { BacktestStatus } from '../types'; import type { BacktestResult } from '../services/backtestApi'; import { MetricsCard } from './MetricsCard'; import { PositionsTable } from './PositionsTable'; +import { TradeLog } from './TradeLog'; import { Chart } from '../../../components/charts'; import { useState, useMemo } from 'react'; @@ -138,31 +139,25 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult const activeSymbol = selectedSymbol || Object.keys(results.ohlcData)[0]; const ohlcData = results.ohlcData[activeSymbol]; - // Create trade markers for the selected symbol + // Create trade markers for the selected symbol (individual fills) const tradeMarkers = results.trades .filter(trade => trade.symbol === activeSymbol) - .map(trade => ({ - time: Math.floor(new Date(trade.entryDate).getTime() / 1000), - position: 'belowBar' as const, - color: '#10b981', - shape: 'arrowUp' as const, - text: `Buy ${trade.quantity}@${trade.entryPrice.toFixed(2)}`, - id: `${trade.id}-entry`, - price: trade.entryPrice - })) - .concat( - results.trades - .filter(trade => trade.symbol === activeSymbol && trade.exitDate) - .map(trade => ({ - time: Math.floor(new Date(trade.exitDate!).getTime() / 1000), - position: 'aboveBar' as const, - color: '#ef4444', - shape: 'arrowDown' as const, - text: `Sell ${trade.quantity}@${trade.exitPrice.toFixed(2)} (${trade.pnl >= 0 ? '+' : ''}${trade.pnl.toFixed(2)})`, - id: `${trade.id}-exit`, - price: trade.exitPrice - })) - ); + .map(trade => { + // Buy = green up arrow, Sell = red down arrow + const isBuy = trade.side === 'buy'; + const pnlText = trade.pnl !== undefined ? ` (${trade.pnl >= 0 ? '+' : ''}${trade.pnl.toFixed(2)})` : ''; + const positionText = ` → ${trade.positionAfter > 0 ? '+' : ''}${trade.positionAfter}`; + + return { + time: Math.floor(new Date(trade.timestamp).getTime() / 1000), + position: isBuy ? 'belowBar' as const : 'aboveBar' as const, + color: isBuy ? '#10b981' : '#ef4444', + shape: isBuy ? 'arrowUp' as const : 'arrowDown' as const, + text: `${trade.side.toUpperCase()} ${trade.quantity}@${trade.price.toFixed(2)}${positionText}${pnlText}`, + id: trade.id, + price: trade.price + }; + }); // Convert OHLC data timestamps const chartData = ohlcData.map((bar: any) => ({ @@ -218,107 +213,13 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult })()} - {/* Trade History Table */} + {/* Trade Log */} {results.trades && results.trades.length > 0 && (

- Trade History ({results.trades.length} trades) + Trade Log ({results.trades.length} fills)

-
- - - - - - - - - - - - - - - - {results.trades.slice().reverse().map((trade) => { - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: '2-digit' - }); - }; - - const formatDuration = (ms: number) => { - const days = Math.floor(ms / (1000 * 60 * 60 * 24)); - if (days > 0) return `${days}d`; - const hours = Math.floor(ms / (1000 * 60 * 60)); - if (hours > 0) return `${hours}h`; - return '<1h'; - }; - - return ( - - - - - - - - - - - - ); - })} - - - - - - - - - -
DateSymbolSideQtyEntryExitP&LReturnDuration
- {formatDate(trade.entryDate)} - - {trade.symbol} - - - {trade.side.toUpperCase()} - - - {trade.quantity} - - ${trade.entryPrice.toFixed(2)} - - ${trade.exitPrice.toFixed(2)} - = 0 ? 'text-success' : 'text-error' - }`}> - {trade.pnl >= 0 ? '+' : ''}${trade.pnl.toFixed(2)} - = 0 ? 'text-success' : 'text-error' - }`}> - {trade.pnlPercent >= 0 ? '+' : ''}{trade.pnlPercent.toFixed(2)}% - - {formatDuration(trade.duration)} -
- Total - sum + t.pnl, 0) >= 0 ? 'text-success' : 'text-error' - }`}> - ${results.trades.reduce((sum, t) => sum + t.pnl, 0).toFixed(2)} - - Avg: {(results.trades.reduce((sum, t) => sum + t.pnlPercent, 0) / results.trades.length).toFixed(2)}% -
-
+
)} diff --git a/apps/stock/web-app/src/features/backtest/components/TradeLog.tsx b/apps/stock/web-app/src/features/backtest/components/TradeLog.tsx index 2925df8..eb7c682 100644 --- a/apps/stock/web-app/src/features/backtest/components/TradeLog.tsx +++ b/apps/stock/web-app/src/features/backtest/components/TradeLog.tsx @@ -25,59 +25,108 @@ export function TradeLog({ trades }: TradeLogProps) { // Show latest trades first const sortedTrades = [...trades].reverse(); + + // Check if any trades have P&L + const showPnLColumn = trades.some(t => t.pnl !== undefined); + + // Determine the action type based on side and position change + const getActionType = (trade: Trade): string => { + const positionBefore = trade.positionAfter + (trade.side === 'buy' ? -trade.quantity : trade.quantity); + + if (trade.side === 'buy') { + // If we had a negative position (short) and buying reduces it, it's a COVER + if (positionBefore < 0 && trade.positionAfter > positionBefore) { + return 'COVER'; + } + // Otherwise it's a BUY (opening or adding to long) + return 'BUY'; + } else { + // If we had a positive position (long) and selling reduces it, it's a SELL + if (positionBefore > 0 && trade.positionAfter < positionBefore) { + return 'SELL'; + } + // Otherwise it's a SHORT (opening or adding to short) + return 'SHORT'; + } + }; + + // Get color for action type + const getActionColor = (action: string): string => { + switch (action) { + case 'BUY': + return 'bg-success/10 text-success'; + case 'SELL': + return 'bg-error/10 text-error'; + case 'SHORT': + return 'bg-warning/10 text-warning'; + case 'COVER': + return 'bg-primary/10 text-primary'; + default: + return 'bg-surface-tertiary text-text-secondary'; + } + }; return ( -
+
- + - - - - - - - - {trades.some(t => t.pnl !== undefined) && ( - + + + + + + + + + {showPnLColumn && ( + )} {sortedTrades.map((trade) => { const tradeValue = trade.quantity * trade.price; + const actionType = getActionType(trade); return ( - - - + - - - - + - {trade.pnl !== undefined && ( - )} diff --git a/apps/stock/web-app/src/features/backtest/types/backtest.types.ts b/apps/stock/web-app/src/features/backtest/types/backtest.types.ts index bf815da..16c4253 100644 --- a/apps/stock/web-app/src/features/backtest/types/backtest.types.ts +++ b/apps/stock/web-app/src/features/backtest/types/backtest.types.ts @@ -47,6 +47,7 @@ export interface Trade { price: number; commission: number; pnl?: number; + positionAfter: number; // Position size after this trade } export interface PerformanceDataPoint {
TimeSymbolSideQuantityPriceValueComm.P&LTimeSymbolActionQtyPriceValuePositionComm.P&L
+ {formatTime(trade.timestamp)} {trade.symbol} - - {trade.side.toUpperCase()} + {trade.symbol} + + {actionType} + {trade.quantity.toLocaleString()} + {formatCurrency(trade.price)} + {formatCurrency(tradeValue)} + 0 ? 'text-success' : + trade.positionAfter < 0 ? 'text-error' : + 'text-text-muted' + }`}> + {trade.positionAfter > 0 ? '+' : ''}{trade.positionAfter.toLocaleString()} + {formatCurrency(trade.commission)} = 0 ? 'text-success' : 'text-error' + {showPnLColumn && ( + = 0 ? 'text-success' : 'text-error') : 'text-text-muted' }`}> - {trade.pnl >= 0 ? '+' : ''}{formatCurrency(trade.pnl)} + {trade.pnl !== undefined ? ( + <>{trade.pnl >= 0 ? '+' : ''}{formatCurrency(trade.pnl)} + ) : ( + '-' + )}