work on core
This commit is contained in:
parent
b8cefdb8cd
commit
44476da13f
10 changed files with 951 additions and 755 deletions
Binary file not shown.
|
|
@ -231,9 +231,11 @@ fn parse_backtest_config(obj: napi::JsObject) -> Result<BacktestConfig> {
|
||||||
let commission: f64 = obj.get_named_property("commission")?;
|
let commission: f64 = obj.get_named_property("commission")?;
|
||||||
let slippage: f64 = obj.get_named_property("slippage")?;
|
let slippage: f64 = obj.get_named_property("slippage")?;
|
||||||
let data_frequency: String = obj.get_named_property("dataFrequency")?;
|
let data_frequency: String = obj.get_named_property("dataFrequency")?;
|
||||||
|
let strategy: Option<String> = obj.get_named_property("strategy").ok();
|
||||||
|
|
||||||
Ok(BacktestConfig {
|
Ok(BacktestConfig {
|
||||||
name,
|
name,
|
||||||
|
strategy,
|
||||||
symbols,
|
symbols,
|
||||||
start_time: DateTime::parse_from_rfc3339(&start_date)
|
start_time: DateTime::parse_from_rfc3339(&start_date)
|
||||||
.map_err(|e| Error::from_reason(e.to_string()))?
|
.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) {
|
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 current_pos = self.positions.get(symbol).copied().unwrap_or(0.0);
|
||||||
let new_pos = if side == "buy" { current_pos + quantity } else { current_pos - quantity };
|
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 {
|
if new_pos.abs() < 0.0001 {
|
||||||
self.positions.remove(symbol);
|
self.positions.remove(symbol);
|
||||||
|
eprintln!(" Position closed");
|
||||||
} else {
|
} else {
|
||||||
self.positions.insert(symbol.to_string(), new_pos);
|
self.positions.insert(symbol.to_string(), new_pos);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,9 @@ pub struct BacktestEngine {
|
||||||
profitable_trades: usize,
|
profitable_trades: usize,
|
||||||
total_pnl: f64,
|
total_pnl: f64,
|
||||||
|
|
||||||
// Price tracking
|
// Price tracking - single source of truth
|
||||||
last_prices: HashMap<String, f64>,
|
// Maps symbol -> (timestamp, price)
|
||||||
|
last_prices: HashMap<String, (DateTime<Utc>, f64)>,
|
||||||
|
|
||||||
// Trade tracking
|
// Trade tracking
|
||||||
trade_tracker: TradeTracker,
|
trade_tracker: TradeTracker,
|
||||||
|
|
@ -103,13 +104,12 @@ impl BacktestEngine {
|
||||||
eprintln!("WARNING: No events loaded! Check data source.");
|
eprintln!("WARNING: No events loaded! Check data source.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main event loop
|
// Main event loop - process events grouped by timestamp
|
||||||
let mut iteration = 0;
|
let mut iteration = 0;
|
||||||
|
let mut last_update_time = self.config.start_time;
|
||||||
|
|
||||||
while !self.event_queue.read().is_empty() {
|
while !self.event_queue.read().is_empty() {
|
||||||
iteration += 1;
|
iteration += 1;
|
||||||
if iteration <= 5 || iteration % 100 == 0 {
|
|
||||||
eprintln!("Processing iteration {} at time {}", iteration, self.time_provider.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the next event's timestamp
|
// Get the next event's timestamp
|
||||||
let next_event_time = self.event_queue.read()
|
let next_event_time = self.event_queue.read()
|
||||||
|
|
@ -124,12 +124,22 @@ impl BacktestEngine {
|
||||||
let current_time = self.time_provider.now();
|
let current_time = self.time_provider.now();
|
||||||
let events = self.event_queue.write().pop_until(current_time);
|
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 {
|
for event in events {
|
||||||
self.process_event(event).await?;
|
self.process_event(event).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update portfolio value
|
// Only update portfolio value if time has actually advanced
|
||||||
self.update_portfolio_value();
|
// 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 {
|
} else {
|
||||||
// No more events
|
// No more events
|
||||||
break;
|
break;
|
||||||
|
|
@ -138,6 +148,9 @@ impl BacktestEngine {
|
||||||
|
|
||||||
eprintln!("Backtest complete. Total trades: {}", self.total_trades);
|
eprintln!("Backtest complete. Total trades: {}", self.total_trades);
|
||||||
|
|
||||||
|
// Close all open positions at market prices
|
||||||
|
self.close_all_positions().await?;
|
||||||
|
|
||||||
// Generate results
|
// Generate results
|
||||||
Ok(self.generate_results())
|
Ok(self.generate_results())
|
||||||
}
|
}
|
||||||
|
|
@ -230,20 +243,26 @@ impl BacktestEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update price tracking
|
// Update price tracking - single source of truth
|
||||||
match &data.data {
|
let price = match &data.data {
|
||||||
MarketDataType::Bar(bar) => {
|
MarketDataType::Bar(bar) => {
|
||||||
self.last_prices.insert(data.symbol.clone(), bar.close);
|
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) => {
|
MarketDataType::Quote(quote) => {
|
||||||
// Use mid price for quotes
|
// Use mid price for quotes
|
||||||
let mid_price = (quote.bid + quote.ask) / 2.0;
|
(quote.bid + quote.ask) / 2.0
|
||||||
self.last_prices.insert(data.symbol.clone(), mid_price);
|
|
||||||
}
|
}
|
||||||
MarketDataType::Trade(trade) => {
|
MarketDataType::Trade(trade) => {
|
||||||
self.last_prices.insert(data.symbol.clone(), trade.price);
|
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
|
// Convert to simpler MarketData for strategies
|
||||||
let market_data = self.convert_to_market_data(&data);
|
let market_data = self.convert_to_market_data(&data);
|
||||||
|
|
@ -270,6 +289,8 @@ impl BacktestEngine {
|
||||||
// Check pending orders for fills
|
// Check pending orders for fills
|
||||||
self.check_pending_orders(&data).await?;
|
self.check_pending_orders(&data).await?;
|
||||||
|
|
||||||
|
// Don't update portfolio value here - wait until all events at this timestamp are processed
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,14 +300,31 @@ impl BacktestEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_signal(&mut self, signal: Signal) -> Result<(), String> {
|
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
|
// Only process strong signals
|
||||||
if signal.strength.abs() < 0.7 {
|
if signal.strength.abs() < 0.7 {
|
||||||
|
eprintln!(" Signal ignored (strength < 0.7)");
|
||||||
return Ok(());
|
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
|
// Convert signal to order
|
||||||
let order = self.signal_to_order(signal)?;
|
let order = self.signal_to_order(signal)?;
|
||||||
|
|
||||||
|
eprintln!(" Creating {:?} order for {} shares", order.side, order.quantity);
|
||||||
|
|
||||||
// Submit order
|
// Submit order
|
||||||
self.process_order_submission(order).await
|
self.process_order_submission(order).await
|
||||||
}
|
}
|
||||||
|
|
@ -361,20 +399,42 @@ impl BacktestEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_order_fill(&mut self, order: &Order) -> Result<(), String> {
|
async fn check_order_fill(&mut self, order: &Order) -> Result<(), String> {
|
||||||
// Get current market price
|
let current_time = self.time_provider.now();
|
||||||
let base_price = self.last_prices.get(&order.symbol)
|
|
||||||
|
// 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()
|
.copied()
|
||||||
.ok_or_else(|| format!("No price available for symbol: {}", order.symbol))?;
|
.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
|
// Apply slippage
|
||||||
let fill_price = match order.side {
|
let fill_price = match order.side {
|
||||||
crate::Side::Buy => base_price * (1.0 + self.config.slippage),
|
crate::Side::Buy => base_price * (1.0 + self.config.slippage),
|
||||||
crate::Side::Sell => 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
|
// Create fill
|
||||||
let fill = crate::Fill {
|
let fill = crate::Fill {
|
||||||
timestamp: self.time_provider.now(),
|
timestamp: current_time,
|
||||||
price: fill_price,
|
price: fill_price,
|
||||||
quantity: order.quantity,
|
quantity: order.quantity,
|
||||||
commission: order.quantity * fill_price * self.config.commission,
|
commission: order.quantity * fill_price * self.config.commission,
|
||||||
|
|
@ -388,6 +448,12 @@ impl BacktestEngine {
|
||||||
// Remove from pending orders
|
// Remove from pending orders
|
||||||
self.state.write().remove_pending_order(&order.id);
|
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
|
// Update positions
|
||||||
let update = self.position_tracker.process_fill(
|
let update = self.position_tracker.process_fill(
|
||||||
&order.symbol,
|
&order.symbol,
|
||||||
|
|
@ -395,8 +461,25 @@ impl BacktestEngine {
|
||||||
order.side,
|
order.side,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Record the fill with symbol and side information
|
// Calculate P&L if position was reduced/closed
|
||||||
self.state.write().record_fill(order.symbol.clone(), order.side, fill.clone());
|
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
|
// Track trades
|
||||||
self.trade_tracker.process_fill(&order.symbol, order.side, &fill);
|
self.trade_tracker.process_fill(&order.symbol, order.side, &fill);
|
||||||
|
|
@ -412,11 +495,25 @@ impl BacktestEngine {
|
||||||
{
|
{
|
||||||
let mut strategies = self.strategies.write();
|
let mut strategies = self.strategies.write();
|
||||||
for strategy in strategies.iter_mut() {
|
for strategy in strategies.iter_mut() {
|
||||||
strategy.on_fill(&order.symbol, fill.quantity, fill.price,
|
let side_str = match order.side {
|
||||||
&format!("{:?}", 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: {:?})",
|
eprintln!("Fill processed: {} {} @ {} (side: {:?})",
|
||||||
fill.quantity, order.symbol, fill.price, order.side);
|
fill.quantity, order.symbol, fill.price, order.side);
|
||||||
eprintln!("Current position after fill: {}",
|
eprintln!("Current position after fill: {}",
|
||||||
|
|
@ -450,25 +547,91 @@ impl BacktestEngine {
|
||||||
|
|
||||||
fn update_portfolio_value(&mut self) {
|
fn update_portfolio_value(&mut self) {
|
||||||
let positions = self.position_tracker.get_all_positions();
|
let positions = self.position_tracker.get_all_positions();
|
||||||
let mut portfolio_value = self.state.read().cash;
|
let cash = self.state.read().cash;
|
||||||
|
let mut portfolio_value = cash;
|
||||||
|
let current_time = self.time_provider.now();
|
||||||
|
|
||||||
for position in positions {
|
// 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
|
// Use last known price for the symbol
|
||||||
let price = self.last_prices.get(&position.symbol).copied().unwrap_or(position.average_price);
|
let price = self.last_prices.get(&position.symbol)
|
||||||
let market_value = position.quantity * price;
|
.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;
|
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);
|
self.state.write().update_portfolio_value(portfolio_value);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_position_size(&self, symbol: &str, signal_strength: f64) -> f64 {
|
fn calculate_position_size(&self, symbol: &str, signal_strength: f64) -> f64 {
|
||||||
let portfolio_value = self.state.read().portfolio_value;
|
let state = self.state.read();
|
||||||
let allocation = 0.1; // 10% per position
|
let portfolio_value = state.portfolio_value;
|
||||||
let position_value = portfolio_value * allocation * signal_strength.abs();
|
let cash = state.cash;
|
||||||
let price = self.last_prices.get(symbol).copied().unwrap_or(100.0);
|
|
||||||
|
|
||||||
(position_value / price).floor()
|
// 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<DateTime<Utc>> {
|
fn get_next_event_time(&self) -> Option<DateTime<Utc>> {
|
||||||
|
|
@ -478,6 +641,44 @@ impl BacktestEngine {
|
||||||
.map(|event| event.timestamp)
|
.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 {
|
fn generate_results(&self) -> BacktestResult {
|
||||||
let state = self.state.read();
|
let state = self.state.read();
|
||||||
let start_time = self.config.start_time;
|
let start_time = self.config.start_time;
|
||||||
|
|
@ -491,6 +692,12 @@ impl BacktestEngine {
|
||||||
// Get completed trades from tracker
|
// Get completed trades from tracker
|
||||||
let completed_trades = self.trade_tracker.get_completed_trades();
|
let completed_trades = self.trade_tracker.get_completed_trades();
|
||||||
|
|
||||||
|
// Convert last_prices to simple HashMap for results
|
||||||
|
let simple_last_prices: HashMap<String, f64> = self.last_prices
|
||||||
|
.iter()
|
||||||
|
.map(|(symbol, (_, price))| (symbol.clone(), *price))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Use simple results builder with proper trade data
|
// Use simple results builder with proper trade data
|
||||||
BacktestResult::from_engine_data_with_trades(
|
BacktestResult::from_engine_data_with_trades(
|
||||||
self.config.clone(),
|
self.config.clone(),
|
||||||
|
|
@ -499,7 +706,7 @@ impl BacktestEngine {
|
||||||
completed_trades,
|
completed_trades,
|
||||||
final_positions,
|
final_positions,
|
||||||
start_time,
|
start_time,
|
||||||
&self.last_prices,
|
&simple_last_prices,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,20 @@ pub struct CompletedTrade {
|
||||||
pub price: f64,
|
pub price: f64,
|
||||||
pub quantity: f64,
|
pub quantity: f64,
|
||||||
pub commission: f64,
|
pub commission: f64,
|
||||||
|
pub position_after: f64, // Position size after this trade
|
||||||
|
pub pnl: Option<f64>, // P&L if position was reduced/closed
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BacktestConfig {
|
pub struct BacktestConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub strategy: Option<String>,
|
||||||
pub symbols: Vec<String>,
|
pub symbols: Vec<String>,
|
||||||
|
#[serde(rename = "startDate")]
|
||||||
pub start_time: DateTime<Utc>,
|
pub start_time: DateTime<Utc>,
|
||||||
|
#[serde(rename = "endDate")]
|
||||||
pub end_time: DateTime<Utc>,
|
pub end_time: DateTime<Utc>,
|
||||||
pub initial_capital: f64,
|
pub initial_capital: f64,
|
||||||
pub commission: f64,
|
pub commission: f64,
|
||||||
|
|
@ -66,7 +73,18 @@ impl BacktestState {
|
||||||
|
|
||||||
pub fn update_portfolio_value(&mut self, value: f64) {
|
pub fn update_portfolio_value(&mut self, value: f64) {
|
||||||
self.portfolio_value = value;
|
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) {
|
pub fn add_pending_order(&mut self, order: Order) {
|
||||||
|
|
@ -77,7 +95,7 @@ impl BacktestState {
|
||||||
self.pending_orders.remove(order_id)
|
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<f64>) {
|
||||||
self.completed_trades.push(CompletedTrade {
|
self.completed_trades.push(CompletedTrade {
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
|
|
@ -85,6 +103,8 @@ impl BacktestState {
|
||||||
price: fill.price,
|
price: fill.price,
|
||||||
quantity: fill.quantity,
|
quantity: fill.quantity,
|
||||||
commission: fill.commission,
|
commission: fill.commission,
|
||||||
|
position_after,
|
||||||
|
pnl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,18 @@ pub struct BacktestMetrics {
|
||||||
pub expectancy: f64,
|
pub expectancy: f64,
|
||||||
pub calmar_ratio: f64,
|
pub calmar_ratio: f64,
|
||||||
pub sortino_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
|
// Individual trade (fill) structure for UI
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Trade {
|
pub struct Trade {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub timestamp: String,
|
pub timestamp: String,
|
||||||
|
|
@ -37,6 +45,7 @@ pub struct Trade {
|
||||||
pub commission: f64,
|
pub commission: f64,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub pnl: Option<f64>,
|
pub pnl: Option<f64>,
|
||||||
|
pub position_after: f64, // Position size after this trade
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analytics data structure
|
// Analytics data structure
|
||||||
|
|
@ -79,7 +88,7 @@ pub struct BacktestResult {
|
||||||
pub config: BacktestConfig,
|
pub config: BacktestConfig,
|
||||||
pub metrics: BacktestMetrics,
|
pub metrics: BacktestMetrics,
|
||||||
pub equity: Vec<EquityDataPoint>,
|
pub equity: Vec<EquityDataPoint>,
|
||||||
pub trades: Vec<CompletedTradeInfo>,
|
pub trades: Vec<Trade>, // Now shows all individual fills
|
||||||
pub positions: Vec<PositionInfo>,
|
pub positions: Vec<PositionInfo>,
|
||||||
pub analytics: Analytics,
|
pub analytics: Analytics,
|
||||||
pub execution_time: u64,
|
pub execution_time: u64,
|
||||||
|
|
@ -95,23 +104,6 @@ pub struct EquityPoint {
|
||||||
pub value: f64,
|
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<String>,
|
|
||||||
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 {
|
impl BacktestResult {
|
||||||
pub fn from_engine_data(
|
pub fn from_engine_data(
|
||||||
|
|
@ -138,7 +130,8 @@ impl BacktestResult {
|
||||||
quantity: fill.quantity,
|
quantity: fill.quantity,
|
||||||
price: fill.price,
|
price: fill.price,
|
||||||
commission: fill.commission,
|
commission: fill.commission,
|
||||||
pnl: None,
|
pnl: fill.pnl,
|
||||||
|
position_after: fill.position_after,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -239,6 +232,12 @@ impl BacktestResult {
|
||||||
expectancy: 0.0,
|
expectancy: 0.0,
|
||||||
calmar_ratio,
|
calmar_ratio,
|
||||||
sortino_ratio: 0.0, // TODO: Calculate
|
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
|
// Create analytics
|
||||||
|
|
@ -306,24 +305,22 @@ impl BacktestResult {
|
||||||
let initial_capital = config.initial_capital;
|
let initial_capital = config.initial_capital;
|
||||||
let final_value = equity_curve.last().map(|(_, v)| *v).unwrap_or(initial_capital);
|
let final_value = equity_curve.last().map(|(_, v)| *v).unwrap_or(initial_capital);
|
||||||
|
|
||||||
// Convert completed trades to web app format
|
// Convert fills to web app format (all individual trades)
|
||||||
let trades: Vec<CompletedTradeInfo> = completed_trades.iter()
|
let trades: Vec<Trade> = fills.iter()
|
||||||
.map(|trade| CompletedTradeInfo {
|
.enumerate()
|
||||||
id: trade.id.clone(),
|
.map(|(i, fill)| Trade {
|
||||||
symbol: trade.symbol.clone(),
|
id: format!("trade-{}", i + 1),
|
||||||
entry_date: trade.entry_time.to_rfc3339(),
|
timestamp: fill.timestamp.to_rfc3339(),
|
||||||
exit_date: Some(trade.exit_time.to_rfc3339()),
|
symbol: fill.symbol.clone(),
|
||||||
entry_price: trade.entry_price,
|
side: match fill.side {
|
||||||
exit_price: trade.exit_price,
|
|
||||||
quantity: trade.quantity,
|
|
||||||
side: match trade.side {
|
|
||||||
crate::Side::Buy => "buy".to_string(),
|
crate::Side::Buy => "buy".to_string(),
|
||||||
crate::Side::Sell => "sell".to_string(),
|
crate::Side::Sell => "sell".to_string(),
|
||||||
},
|
},
|
||||||
pnl: trade.pnl,
|
quantity: fill.quantity,
|
||||||
pnl_percent: trade.pnl_percent,
|
price: fill.price,
|
||||||
commission: trade.commission,
|
commission: fill.commission,
|
||||||
duration: trade.duration_seconds * 1000, // Convert to milliseconds
|
pnl: fill.pnl,
|
||||||
|
position_after: fill.position_after,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -480,10 +477,21 @@ impl BacktestResult {
|
||||||
quantity: fill.quantity,
|
quantity: fill.quantity,
|
||||||
price: fill.price,
|
price: fill.price,
|
||||||
commission: fill.commission,
|
commission: fill.commission,
|
||||||
pnl: None,
|
pnl: fill.pnl,
|
||||||
|
position_after: fill.position_after,
|
||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
let metrics = BacktestMetrics {
|
||||||
total_return,
|
total_return,
|
||||||
sharpe_ratio,
|
sharpe_ratio,
|
||||||
|
|
@ -498,6 +506,12 @@ impl BacktestResult {
|
||||||
expectancy,
|
expectancy,
|
||||||
calmar_ratio,
|
calmar_ratio,
|
||||||
sortino_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
|
// Calculate monthly returns
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,16 @@ impl TradeTracker {
|
||||||
duration_seconds: (fill.timestamp - open_pos.entry_time).num_seconds(),
|
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);
|
self.completed_trades.push(completed_trade);
|
||||||
|
|
||||||
// Update open position
|
// Update open position
|
||||||
|
|
|
||||||
|
|
@ -88,18 +88,24 @@ impl Strategy for MeanReversionFixedStrategy {
|
||||||
// Get actual position from our tracking
|
// Get actual position from our tracking
|
||||||
let current_position = self.current_positions.get(symbol).copied().unwrap_or(0.0);
|
let current_position = self.current_positions.get(symbol).copied().unwrap_or(0.0);
|
||||||
|
|
||||||
// Entry signals - only when flat
|
// Entry signals - allow pyramiding up to 3x base position
|
||||||
if current_position.abs() < 0.001 {
|
let max_long_position = self.position_size * 3.0;
|
||||||
if price < lower_band {
|
let max_short_position = -self.position_size * 3.0;
|
||||||
// Price is oversold, buy
|
|
||||||
eprintln!("Mean reversion: {} oversold at ${:.2}, buying (lower band: ${:.2}, mean: ${:.2})",
|
if price < lower_band && current_position < max_long_position {
|
||||||
symbol, price, lower_band, mean);
|
// 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 {
|
signals.push(Signal {
|
||||||
symbol: symbol.clone(),
|
symbol: symbol.clone(),
|
||||||
signal_type: SignalType::Buy,
|
signal_type: SignalType::Buy,
|
||||||
strength: 1.0,
|
strength: 1.0,
|
||||||
quantity: Some(self.position_size),
|
quantity: Some(trade_size),
|
||||||
reason: Some(format!(
|
reason: Some(format!(
|
||||||
"Mean reversion buy: price ${:.2} < lower band ${:.2} (mean: ${:.2}, std: ${:.2})",
|
"Mean reversion buy: price ${:.2} < lower band ${:.2} (mean: ${:.2}, std: ${:.2})",
|
||||||
price, lower_band, mean, std_dev
|
price, lower_band, mean, std_dev
|
||||||
|
|
@ -112,16 +118,21 @@ impl Strategy for MeanReversionFixedStrategy {
|
||||||
"price": price,
|
"price": price,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} else if price > upper_band {
|
}
|
||||||
// Price is overbought, sell short
|
} else if price > upper_band && current_position > max_short_position {
|
||||||
eprintln!("Mean reversion: {} overbought at ${:.2}, selling short (upper band: ${:.2}, mean: ${:.2})",
|
// Price is overbought, sell short (or add to short position)
|
||||||
symbol, price, upper_band, mean);
|
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 {
|
signals.push(Signal {
|
||||||
symbol: symbol.clone(),
|
symbol: symbol.clone(),
|
||||||
signal_type: SignalType::Sell,
|
signal_type: SignalType::Sell,
|
||||||
strength: 1.0,
|
strength: 1.0,
|
||||||
quantity: Some(self.position_size),
|
quantity: Some(trade_size),
|
||||||
reason: Some(format!(
|
reason: Some(format!(
|
||||||
"Mean reversion sell: price ${:.2} > upper band ${:.2} (mean: ${:.2}, std: ${:.2})",
|
"Mean reversion sell: price ${:.2} > upper band ${:.2} (mean: ${:.2}, std: ${:.2})",
|
||||||
price, upper_band, mean, std_dev
|
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
|
// We're long - check exit conditions
|
||||||
let entry_price = self.entry_prices.get(symbol).copied().unwrap_or(price);
|
let entry_price = self.entry_prices.get(symbol).copied().unwrap_or(price);
|
||||||
let target_price = entry_price + (mean - entry_price) * self.exit_threshold;
|
let target_price = entry_price + (mean - entry_price) * self.exit_threshold;
|
||||||
let stop_loss = lower_band - std_dev; // Stop loss below lower band
|
let stop_loss = lower_band - std_dev; // Stop loss below lower band
|
||||||
|
|
||||||
if price >= target_price {
|
// Exit if price reaches target or stop loss
|
||||||
eprintln!("Mean reversion: {} reached target ${:.2} (entry: ${:.2}, mean: ${:.2}), closing long",
|
if price >= target_price || price <= stop_loss {
|
||||||
symbol, target_price, entry_price, mean);
|
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 {
|
signals.push(Signal {
|
||||||
symbol: symbol.clone(),
|
symbol: symbol.clone(),
|
||||||
|
|
@ -161,26 +175,8 @@ impl Strategy for MeanReversionFixedStrategy {
|
||||||
"price": price,
|
"price": price,
|
||||||
"entry_price": entry_price,
|
"entry_price": entry_price,
|
||||||
"target_price": target_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,
|
"stop_loss": stop_loss,
|
||||||
"price": price,
|
"exit_type": exit_reason,
|
||||||
"exit_type": "stop_loss",
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -190,9 +186,11 @@ impl Strategy for MeanReversionFixedStrategy {
|
||||||
let target_price = entry_price - (entry_price - mean) * self.exit_threshold;
|
let target_price = entry_price - (entry_price - mean) * self.exit_threshold;
|
||||||
let stop_loss = upper_band + std_dev; // Stop loss above upper band
|
let stop_loss = upper_band + std_dev; // Stop loss above upper band
|
||||||
|
|
||||||
if price <= target_price {
|
// Exit if price reaches target or stop loss
|
||||||
eprintln!("Mean reversion: {} reached target ${:.2} (entry: ${:.2}, mean: ${:.2}), closing short",
|
if price <= target_price || price >= stop_loss {
|
||||||
symbol, target_price, entry_price, mean);
|
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 {
|
signals.push(Signal {
|
||||||
symbol: symbol.clone(),
|
symbol: symbol.clone(),
|
||||||
|
|
@ -200,34 +198,16 @@ impl Strategy for MeanReversionFixedStrategy {
|
||||||
strength: 1.0,
|
strength: 1.0,
|
||||||
quantity: Some(current_position.abs()),
|
quantity: Some(current_position.abs()),
|
||||||
reason: Some(format!(
|
reason: Some(format!(
|
||||||
"Mean reversion exit short: price ${:.2} reached target ${:.2} (entry: ${:.2})",
|
"Mean reversion exit short: price ${:.2} {} (entry: ${:.2}, target: ${:.2}, stop: ${:.2})",
|
||||||
price, target_price, entry_price
|
price, exit_reason, entry_price, target_price, stop_loss
|
||||||
)),
|
)),
|
||||||
metadata: Some(json!({
|
metadata: Some(json!({
|
||||||
"mean": mean,
|
"mean": mean,
|
||||||
"price": price,
|
"price": price,
|
||||||
"entry_price": entry_price,
|
"entry_price": entry_price,
|
||||||
"target_price": target_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,
|
"stop_loss": stop_loss,
|
||||||
"price": price,
|
"exit_type": exit_reason,
|
||||||
"exit_type": "stop_loss",
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +221,7 @@ impl Strategy for MeanReversionFixedStrategy {
|
||||||
fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) {
|
fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) {
|
||||||
// Update our position tracking based on actual fills
|
// Update our position tracking based on actual fills
|
||||||
let current = self.current_positions.get(symbol).copied().unwrap_or(0.0);
|
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
|
current + quantity
|
||||||
} else {
|
} else {
|
||||||
current - quantity
|
current - quantity
|
||||||
|
|
@ -255,11 +235,20 @@ impl Strategy for MeanReversionFixedStrategy {
|
||||||
// Position closed
|
// Position closed
|
||||||
self.current_positions.remove(symbol);
|
self.current_positions.remove(symbol);
|
||||||
self.entry_prices.remove(symbol);
|
self.entry_prices.remove(symbol);
|
||||||
|
eprintln!("Position closed for {}", symbol);
|
||||||
} else {
|
} else {
|
||||||
self.current_positions.insert(symbol.to_string(), new_position);
|
self.current_positions.insert(symbol.to_string(), new_position);
|
||||||
// Track entry price for new positions
|
|
||||||
|
// Track average entry price
|
||||||
if current.abs() < 0.001 {
|
if current.abs() < 0.001 {
|
||||||
|
// New position - set initial entry price
|
||||||
self.entry_prices.insert(symbol.to_string(), 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { BacktestStatus } from '../types';
|
||||||
import type { BacktestResult } from '../services/backtestApi';
|
import type { BacktestResult } from '../services/backtestApi';
|
||||||
import { MetricsCard } from './MetricsCard';
|
import { MetricsCard } from './MetricsCard';
|
||||||
import { PositionsTable } from './PositionsTable';
|
import { PositionsTable } from './PositionsTable';
|
||||||
|
import { TradeLog } from './TradeLog';
|
||||||
import { Chart } from '../../../components/charts';
|
import { Chart } from '../../../components/charts';
|
||||||
import { useState, useMemo } from 'react';
|
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 activeSymbol = selectedSymbol || Object.keys(results.ohlcData)[0];
|
||||||
const ohlcData = results.ohlcData[activeSymbol];
|
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
|
const tradeMarkers = results.trades
|
||||||
.filter(trade => trade.symbol === activeSymbol)
|
.filter(trade => trade.symbol === activeSymbol)
|
||||||
.map(trade => ({
|
.map(trade => {
|
||||||
time: Math.floor(new Date(trade.entryDate).getTime() / 1000),
|
// Buy = green up arrow, Sell = red down arrow
|
||||||
position: 'belowBar' as const,
|
const isBuy = trade.side === 'buy';
|
||||||
color: '#10b981',
|
const pnlText = trade.pnl !== undefined ? ` (${trade.pnl >= 0 ? '+' : ''}${trade.pnl.toFixed(2)})` : '';
|
||||||
shape: 'arrowUp' as const,
|
const positionText = ` → ${trade.positionAfter > 0 ? '+' : ''}${trade.positionAfter}`;
|
||||||
text: `Buy ${trade.quantity}@${trade.entryPrice.toFixed(2)}`,
|
|
||||||
id: `${trade.id}-entry`,
|
return {
|
||||||
price: trade.entryPrice
|
time: Math.floor(new Date(trade.timestamp).getTime() / 1000),
|
||||||
}))
|
position: isBuy ? 'belowBar' as const : 'aboveBar' as const,
|
||||||
.concat(
|
color: isBuy ? '#10b981' : '#ef4444',
|
||||||
results.trades
|
shape: isBuy ? 'arrowUp' as const : 'arrowDown' as const,
|
||||||
.filter(trade => trade.symbol === activeSymbol && trade.exitDate)
|
text: `${trade.side.toUpperCase()} ${trade.quantity}@${trade.price.toFixed(2)}${positionText}${pnlText}`,
|
||||||
.map(trade => ({
|
id: trade.id,
|
||||||
time: Math.floor(new Date(trade.exitDate!).getTime() / 1000),
|
price: trade.price
|
||||||
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
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert OHLC data timestamps
|
// Convert OHLC data timestamps
|
||||||
const chartData = ohlcData.map((bar: any) => ({
|
const chartData = ohlcData.map((bar: any) => ({
|
||||||
|
|
@ -218,107 +213,13 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trade History Table */}
|
{/* Trade Log */}
|
||||||
{results.trades && results.trades.length > 0 && (
|
{results.trades && results.trades.length > 0 && (
|
||||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
|
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
|
||||||
<h3 className="text-base font-medium text-text-primary mb-4">
|
<h3 className="text-base font-medium text-text-primary mb-4">
|
||||||
Trade History ({results.trades.length} trades)
|
Trade Log ({results.trades.length} fills)
|
||||||
</h3>
|
</h3>
|
||||||
<div className="overflow-x-auto">
|
<TradeLog trades={results.trades} />
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border">
|
|
||||||
<th className="text-left py-2 px-3 font-medium text-text-secondary">Date</th>
|
|
||||||
<th className="text-left py-2 px-3 font-medium text-text-secondary">Symbol</th>
|
|
||||||
<th className="text-center py-2 px-3 font-medium text-text-secondary">Side</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-text-secondary">Qty</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-text-secondary">Entry</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-text-secondary">Exit</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-text-secondary">P&L</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-text-secondary">Return</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-text-secondary">Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{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 (
|
|
||||||
<tr key={trade.id} className="border-b border-border hover:bg-surface-tertiary">
|
|
||||||
<td className="py-2 px-3 text-text-muted whitespace-nowrap">
|
|
||||||
{formatDate(trade.entryDate)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3 font-medium text-text-primary">
|
|
||||||
{trade.symbol}
|
|
||||||
</td>
|
|
||||||
<td className="text-center py-2 px-3">
|
|
||||||
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
|
|
||||||
trade.side === 'buy'
|
|
||||||
? 'bg-success/10 text-success'
|
|
||||||
: 'bg-error/10 text-error'
|
|
||||||
}`}>
|
|
||||||
{trade.side.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="text-right py-2 px-3 text-text-primary">
|
|
||||||
{trade.quantity}
|
|
||||||
</td>
|
|
||||||
<td className="text-right py-2 px-3 text-text-primary">
|
|
||||||
${trade.entryPrice.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right py-2 px-3 text-text-primary">
|
|
||||||
${trade.exitPrice.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right py-2 px-3 font-medium ${
|
|
||||||
trade.pnl >= 0 ? 'text-success' : 'text-error'
|
|
||||||
}`}>
|
|
||||||
{trade.pnl >= 0 ? '+' : ''}${trade.pnl.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right py-2 px-3 font-medium ${
|
|
||||||
trade.pnlPercent >= 0 ? 'text-success' : 'text-error'
|
|
||||||
}`}>
|
|
||||||
{trade.pnlPercent >= 0 ? '+' : ''}{trade.pnlPercent.toFixed(2)}%
|
|
||||||
</td>
|
|
||||||
<td className="text-right py-2 px-3 text-text-muted">
|
|
||||||
{formatDuration(trade.duration)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
<tfoot className="border-t-2 border-border">
|
|
||||||
<tr className="font-medium">
|
|
||||||
<td colSpan={6} className="py-2 px-3 text-text-primary">
|
|
||||||
Total
|
|
||||||
</td>
|
|
||||||
<td className={`text-right py-2 px-3 ${
|
|
||||||
results.trades.reduce((sum, t) => sum + t.pnl, 0) >= 0 ? 'text-success' : 'text-error'
|
|
||||||
}`}>
|
|
||||||
${results.trades.reduce((sum, t) => sum + t.pnl, 0).toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right py-2 px-3 text-text-secondary">
|
|
||||||
Avg: {(results.trades.reduce((sum, t) => sum + t.pnlPercent, 0) / results.trades.length).toFixed(2)}%
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,58 +26,107 @@ export function TradeLog({ trades }: TradeLogProps) {
|
||||||
// Show latest trades first
|
// Show latest trades first
|
||||||
const sortedTrades = [...trades].reverse();
|
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 (
|
return (
|
||||||
<div className="overflow-x-auto max-h-96">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-surface-secondary">
|
<thead>
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
<th className="text-left py-2 px-2 font-medium text-text-secondary">Time</th>
|
<th className="text-left py-2 px-3 font-medium text-text-secondary">Time</th>
|
||||||
<th className="text-left py-2 px-2 font-medium text-text-secondary">Symbol</th>
|
<th className="text-left py-2 px-3 font-medium text-text-secondary">Symbol</th>
|
||||||
<th className="text-center py-2 px-2 font-medium text-text-secondary">Side</th>
|
<th className="text-center py-2 px-3 font-medium text-text-secondary">Action</th>
|
||||||
<th className="text-right py-2 px-2 font-medium text-text-secondary">Quantity</th>
|
<th className="text-right py-2 px-3 font-medium text-text-secondary">Qty</th>
|
||||||
<th className="text-right py-2 px-2 font-medium text-text-secondary">Price</th>
|
<th className="text-right py-2 px-3 font-medium text-text-secondary">Price</th>
|
||||||
<th className="text-right py-2 px-2 font-medium text-text-secondary">Value</th>
|
<th className="text-right py-2 px-3 font-medium text-text-secondary">Value</th>
|
||||||
<th className="text-right py-2 px-2 font-medium text-text-secondary">Comm.</th>
|
<th className="text-right py-2 px-3 font-medium text-text-secondary">Position</th>
|
||||||
{trades.some(t => t.pnl !== undefined) && (
|
<th className="text-right py-2 px-3 font-medium text-text-secondary">Comm.</th>
|
||||||
<th className="text-right py-2 px-2 font-medium text-text-secondary">P&L</th>
|
{showPnLColumn && (
|
||||||
|
<th className="text-right py-2 px-3 font-medium text-text-secondary">P&L</th>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedTrades.map((trade) => {
|
{sortedTrades.map((trade) => {
|
||||||
const tradeValue = trade.quantity * trade.price;
|
const tradeValue = trade.quantity * trade.price;
|
||||||
|
const actionType = getActionType(trade);
|
||||||
return (
|
return (
|
||||||
<tr key={trade.id} className="border-b border-border hover:bg-surface-tertiary">
|
<tr key={trade.id} className="border-b border-border hover:bg-surface-tertiary">
|
||||||
<td className="py-2 px-2 text-text-muted text-xs">
|
<td className="py-2 px-3 text-text-muted whitespace-nowrap">
|
||||||
{formatTime(trade.timestamp)}
|
{formatTime(trade.timestamp)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-2 font-medium text-text-primary">{trade.symbol}</td>
|
<td className="py-2 px-3 font-medium text-text-primary">{trade.symbol}</td>
|
||||||
<td className="text-center py-2 px-2">
|
<td className="text-center py-2 px-3">
|
||||||
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
|
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${getActionColor(actionType)}`}>
|
||||||
trade.side === 'buy'
|
{actionType}
|
||||||
? 'bg-success/10 text-success'
|
|
||||||
: 'bg-error/10 text-error'
|
|
||||||
}`}>
|
|
||||||
{trade.side.toUpperCase()}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right py-2 px-2 text-text-primary">
|
<td className="text-right py-2 px-3 text-text-primary">
|
||||||
{trade.quantity.toLocaleString()}
|
{trade.quantity.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right py-2 px-2 text-text-primary">
|
<td className="text-right py-2 px-3 text-text-primary">
|
||||||
{formatCurrency(trade.price)}
|
{formatCurrency(trade.price)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right py-2 px-2 text-text-primary">
|
<td className="text-right py-2 px-3 text-text-primary">
|
||||||
{formatCurrency(tradeValue)}
|
{formatCurrency(tradeValue)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right py-2 px-2 text-text-muted">
|
<td className={`text-right py-2 px-3 font-medium ${
|
||||||
|
trade.positionAfter > 0 ? 'text-success' :
|
||||||
|
trade.positionAfter < 0 ? 'text-error' :
|
||||||
|
'text-text-muted'
|
||||||
|
}`}>
|
||||||
|
{trade.positionAfter > 0 ? '+' : ''}{trade.positionAfter.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 px-3 text-text-muted">
|
||||||
{formatCurrency(trade.commission)}
|
{formatCurrency(trade.commission)}
|
||||||
</td>
|
</td>
|
||||||
{trade.pnl !== undefined && (
|
{showPnLColumn && (
|
||||||
<td className={`text-right py-2 px-2 font-medium ${
|
<td className={`text-right py-2 px-3 font-medium ${
|
||||||
trade.pnl >= 0 ? 'text-success' : 'text-error'
|
trade.pnl !== undefined ? (trade.pnl >= 0 ? 'text-success' : 'text-error') : 'text-text-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{trade.pnl >= 0 ? '+' : ''}{formatCurrency(trade.pnl)}
|
{trade.pnl !== undefined ? (
|
||||||
|
<>{trade.pnl >= 0 ? '+' : ''}{formatCurrency(trade.pnl)}</>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export interface Trade {
|
||||||
price: number;
|
price: number;
|
||||||
commission: number;
|
commission: number;
|
||||||
pnl?: number;
|
pnl?: number;
|
||||||
|
positionAfter: number; // Position size after this trade
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PerformanceDataPoint {
|
export interface PerformanceDataPoint {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue