use crate::strategy_lib::strategy::{Signal, Strategy, StrategyError};
use polars::prelude::*;
#[derive(Debug, Clone)]
pub struct BacktestConfig {
pub initial_capital: f64,
pub commission: f64,
pub slippage: f64,
pub position_size: f64,
}
impl Default for BacktestConfig {
fn default() -> Self {
Self {
initial_capital: 10000.0,
commission: 0.001, slippage: 0.0005, position_size: 0.1, }
}
}
#[derive(Debug)]
pub struct BacktestResults {
pub equity_curve: Series,
pub trades: DataFrame,
pub metrics: BacktestMetrics,
}
#[derive(Debug)]
pub struct BacktestMetrics {
pub total_return: f64,
pub annualized_return: f64,
pub max_drawdown: f64,
pub sharpe_ratio: f64,
pub win_rate: f64,
pub profit_factor: f64,
}
pub fn run_backtest<S: Strategy>(
strategy: &S,
data: &DataFrame,
config: BacktestConfig,
) -> Result<BacktestResults, StrategyError> {
for &col in strategy.required_columns().iter() {
if data.column(col).is_err() {
return Err(StrategyError::MissingData(format!(
"Required column '{}' not found in data",
col
)));
}
}
let signals = strategy.generate_signals(data)?;
let prices = data
.column("close")
.map_err(|_| StrategyError::MissingData("Close price column not found".to_string()))?
.f64()
.map_err(|_| StrategyError::InvalidParameter("Unable to parse close prices".to_string()))?;
let mut cash = config.initial_capital;
let mut position = 0.0;
let mut equity_values = Vec::new();
let mut trade_records = Vec::new();
let mut trade_id = 0;
let mut returns = Vec::new();
let mut drawdown_series = Vec::new();
let mut peak_equity = config.initial_capital;
let signal_values = signals
.i32()
.map_err(|_| StrategyError::InvalidParameter("Unable to parse signals".to_string()))?;
for i in 0..signal_values.len() {
let signal_val = signal_values.get(i).unwrap_or(Signal::Hold as i32);
let price = prices.get(i).unwrap_or(0.0);
if price <= 0.0 {
continue; }
let signal = match signal_val {
0 => Signal::Hold,
1 => Signal::Buy,
2 => Signal::Sell,
_ => Signal::Hold,
};
match signal {
Signal::Buy if position <= 0.0 => {
if position < 0.0 {
let close_value = -position * price * (1.0 + config.slippage);
let commission_cost = close_value * config.commission;
cash -= close_value + commission_cost;
trade_records.push(TradeRecord {
id: trade_id,
trade_type: "short_close".to_string(),
price,
quantity: -position,
value: close_value,
commission: commission_cost,
});
trade_id += 1;
}
let position_value = cash * config.position_size;
let shares = position_value / (price * (1.0 + config.slippage));
let commission_cost = position_value * config.commission;
if shares > 0.0 {
position = shares;
cash -= position_value + commission_cost;
trade_records.push(TradeRecord {
id: trade_id,
trade_type: "long_open".to_string(),
price,
quantity: shares,
value: position_value,
commission: commission_cost,
});
trade_id += 1;
}
}
Signal::Sell if position >= 0.0 => {
if position > 0.0 {
let close_value = position * price * (1.0 - config.slippage);
let commission_cost = close_value * config.commission;
cash += close_value - commission_cost;
trade_records.push(TradeRecord {
id: trade_id,
trade_type: "long_close".to_string(),
price,
quantity: position,
value: close_value,
commission: commission_cost,
});
trade_id += 1;
}
let position_value = cash * config.position_size;
let shares = position_value / (price * (1.0 - config.slippage));
let commission_cost = position_value * config.commission;
if shares > 0.0 {
position = -shares;
cash += position_value - commission_cost;
trade_records.push(TradeRecord {
id: trade_id,
trade_type: "short_open".to_string(),
price,
quantity: -shares,
value: position_value,
commission: commission_cost,
});
trade_id += 1;
}
}
Signal::Hold => {
}
Signal::Buy | Signal::Sell => {
}
}
let position_value = if position != 0.0 {
position * price
} else {
0.0
};
let current_equity = cash + position_value;
equity_values.push(current_equity);
if i > 0 {
let prev_equity = equity_values[i - 1]; if prev_equity > 0.0 {
let daily_return = (current_equity - prev_equity) / prev_equity;
returns.push(daily_return);
}
} else {
if config.initial_capital > 0.0 {
let daily_return =
(current_equity - config.initial_capital) / config.initial_capital;
returns.push(daily_return);
}
}
if current_equity > peak_equity {
peak_equity = current_equity;
}
let drawdown = (peak_equity - current_equity) / peak_equity;
drawdown_series.push(drawdown);
}
let metrics = calculate_metrics(
&equity_values,
&returns,
&drawdown_series,
&trade_records,
config.initial_capital,
);
let equity_curve = Series::new("equity".into(), equity_values);
let trades = if trade_records.is_empty() {
let empty_trade_type = Series::new("type".into(), Vec::<String>::new());
let empty_trade_price = Series::new("price".into(), Vec::<f64>::new());
let empty_trade_quantity = Series::new("quantity".into(), Vec::<f64>::new());
let empty_trade_value = Series::new("value".into(), Vec::<f64>::new());
DataFrame::new(vec![
empty_trade_type.into(),
empty_trade_price.into(),
empty_trade_quantity.into(),
empty_trade_value.into(),
])
.unwrap()
} else {
let trade_types: Vec<String> = trade_records.iter().map(|t| t.trade_type.clone()).collect();
let trade_prices: Vec<f64> = trade_records.iter().map(|t| t.price).collect();
let trade_quantities: Vec<f64> = trade_records.iter().map(|t| t.quantity).collect();
let trade_values: Vec<f64> = trade_records.iter().map(|t| t.value).collect();
DataFrame::new(vec![
Series::new("type".into(), trade_types).into(),
Series::new("price".into(), trade_prices).into(),
Series::new("quantity".into(), trade_quantities).into(),
Series::new("value".into(), trade_values).into(),
])
.unwrap()
};
Ok(BacktestResults {
equity_curve,
trades,
metrics,
})
}
#[derive(Debug, Clone)]
struct TradeRecord {
id: u32,
trade_type: String,
price: f64,
quantity: f64,
value: f64,
commission: f64,
}
fn calculate_metrics(
equity_values: &[f64],
returns: &[f64],
drawdown_series: &[f64],
trade_records: &[TradeRecord],
initial_capital: f64,
) -> BacktestMetrics {
let final_equity = equity_values.last().copied().unwrap_or(initial_capital);
let total_return = (final_equity - initial_capital) / initial_capital * 100.0;
let periods = equity_values.len() as f64;
let annualized_return = if periods > 0.0 {
((final_equity / initial_capital).powf(252.0 / periods) - 1.0) * 100.0
} else {
0.0
};
let max_drawdown = drawdown_series.iter().copied().fold(0.0f64, f64::max) * 100.0;
let mean_return = if !returns.is_empty() {
returns.iter().sum::<f64>() / returns.len() as f64
} else {
0.0
};
let return_variance = if returns.len() > 1 {
let sum_sq_diff: f64 = returns.iter().map(|r| (r - mean_return).powi(2)).sum();
sum_sq_diff / (returns.len() - 1) as f64
} else {
0.0
};
let sharpe_ratio = if return_variance > 0.0 {
mean_return / return_variance.sqrt() * (252.0f64).sqrt() } else {
0.0
};
let (win_rate, profit_factor) = calculate_trade_metrics(trade_records);
BacktestMetrics {
total_return,
annualized_return,
max_drawdown,
sharpe_ratio,
win_rate,
profit_factor,
}
}
fn calculate_trade_metrics(trade_records: &[TradeRecord]) -> (f64, f64) {
if trade_records.is_empty() {
return (0.0, 1.0);
}
let mut trade_pairs = Vec::new();
let mut open_trades: std::collections::HashMap<String, &TradeRecord> =
std::collections::HashMap::new();
for trade in trade_records {
match trade.trade_type.as_str() {
"long_open" | "short_open" => {
open_trades.insert(trade.trade_type.clone(), trade);
}
"long_close" => {
if let Some(open_trade) = open_trades.remove("long_open") {
let pnl =
trade.value - open_trade.value - trade.commission - open_trade.commission;
trade_pairs.push(pnl);
}
}
"short_close" => {
if let Some(open_trade) = open_trades.remove("short_open") {
let pnl =
open_trade.value - trade.value - trade.commission - open_trade.commission;
trade_pairs.push(pnl);
}
}
_ => {}
}
}
if trade_pairs.is_empty() {
return (0.0, 1.0);
}
let winning_trades = trade_pairs.iter().filter(|&&pnl| pnl > 0.0).count();
let win_rate = (winning_trades as f64 / trade_pairs.len() as f64) * 100.0;
let gross_profit: f64 = trade_pairs.iter().filter(|&&pnl| pnl > 0.0).sum();
let gross_loss: f64 = trade_pairs
.iter()
.filter(|&&pnl| pnl < 0.0)
.map(|pnl| -pnl)
.sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
1.0
};
(win_rate, profit_factor)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::strategy_lib::strategy::StrategyConfig;
struct MockStrategy {
name: String,
description: String,
required_cols: Vec<String>,
signals: Series,
}
impl Strategy for MockStrategy {
fn new(_config: StrategyConfig) -> Self {
Self {
name: "Mock Strategy".to_string(),
description: "A mock strategy for testing".to_string(),
required_cols: vec!["close".to_string()],
signals: Series::new(
"signal".into(),
vec![Signal::Hold as i32, Signal::Buy as i32, Signal::Sell as i32],
),
}
}
fn generate_signals(&self, _data: &DataFrame) -> Result<Series, StrategyError> {
Ok(self.signals.clone())
}
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn required_columns(&self) -> Vec<&str> {
self.required_cols.iter().map(|s| s.as_str()).collect()
}
}
fn create_test_data() -> DataFrame {
let close = Series::new("close".into(), &[100.0, 101.0, 99.0]);
DataFrame::new(vec![close.into()]).unwrap()
}
#[test]
fn test_backtest_config_default() {
let config = BacktestConfig::default();
assert_eq!(config.initial_capital, 10000.0);
assert_eq!(config.commission, 0.001);
assert_eq!(config.slippage, 0.0005);
assert_eq!(config.position_size, 0.1);
}
#[test]
fn test_run_backtest_with_mock_strategy() {
let data = create_test_data();
let mock_strategy = MockStrategy {
name: "Mock Strategy".to_string(),
description: "A mock strategy for testing".to_string(),
required_cols: vec!["close".to_string()],
signals: Series::new(
"signal".into(),
vec![Signal::Hold as i32, Signal::Buy as i32, Signal::Sell as i32],
),
};
let config = BacktestConfig::default();
let results = run_backtest(&mock_strategy, &data, config).unwrap();
assert_eq!(results.equity_curve.len(), 3);
println!("Actual trades count: {}", results.trades.height());
assert!(
results.trades.height() > 0,
"Should have at least one trade"
);
assert!(
results.trades.height() <= 10,
"Should not have excessive trades"
);
assert!(results.metrics.total_return.is_finite());
assert!(results.metrics.annualized_return.is_finite());
assert!(results.metrics.max_drawdown.is_finite());
assert!(results.metrics.sharpe_ratio.is_finite());
assert!(results.metrics.win_rate.is_finite());
assert!(
results.metrics.profit_factor.is_finite()
|| results.metrics.profit_factor.is_infinite()
);
}
#[test]
fn test_run_backtest_missing_column() {
let wrong_col = Series::new("wrong_col".into(), &[1.0, 2.0, 3.0]);
let data = DataFrame::new(vec![wrong_col.into()]).unwrap();
let mock_strategy = MockStrategy {
name: "Mock Strategy".to_string(),
description: "A mock strategy for testing".to_string(),
required_cols: vec!["close".to_string()],
signals: Series::new(
"signal".into(),
vec![Signal::Hold as i32, Signal::Buy as i32, Signal::Sell as i32],
),
};
let config = BacktestConfig::default();
let result = run_backtest(&mock_strategy, &data, config);
assert!(result.is_err());
match result.unwrap_err() {
StrategyError::MissingData(msg) => {
assert!(msg.contains("close"));
}
_ => panic!("Expected MissingData error"),
}
}
}