Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
+476
View File
@@ -0,0 +1,476 @@
//! Backtesting engine for trading strategies.
//!
//! Tests strategies against generated scenarios.
use crate::MarketSimError;
use marketsim_shared::{
AggregateBacktestStats, BacktestRequest, BacktestResult, ScenarioBacktestResult,
ScenarioResult, Strategy, StrategyType,
};
/// Backtest engine.
#[derive(Debug)]
pub struct BacktestEngine {
/// Risk-free rate for Sharpe calculation.
risk_free_rate: f64,
}
impl Default for BacktestEngine {
fn default() -> Self {
Self::new()
}
}
impl BacktestEngine {
/// Create a new backtest engine.
#[must_use]
pub fn new() -> Self {
Self {
risk_free_rate: 0.04,
}
}
/// Run backtest.
pub fn run(&self, request: &BacktestRequest) -> Result<BacktestResult, MarketSimError> {
let mut scenario_results = Vec::with_capacity(request.scenarios.len());
for scenario in &request.scenarios {
let result = self.backtest_scenario(&request.strategy, scenario, request)?;
scenario_results.push(result);
}
// Calculate aggregate statistics
let aggregate_stats = self.calculate_aggregate_stats(&scenario_results);
Ok(BacktestResult {
strategy: request.strategy.clone(),
scenario_results,
aggregate_stats,
})
}
/// Backtest a single scenario.
fn backtest_scenario(
&self,
strategy: &Strategy,
scenario: &ScenarioResult,
request: &BacktestRequest,
) -> Result<ScenarioBacktestResult, MarketSimError> {
if scenario.price_paths.is_empty() {
return Err(MarketSimError::BacktestFailed("No price paths".to_string()));
}
let num_paths = scenario.price_paths[0].paths.len();
let _num_assets = scenario.price_paths.len();
let _horizon = scenario.price_paths[0].paths[0].len() - 1;
let mut final_values = Vec::with_capacity(num_paths);
let mut total_returns = Vec::with_capacity(num_paths);
let mut sharpe_ratios = Vec::with_capacity(num_paths);
let mut max_drawdowns = Vec::with_capacity(num_paths);
for path_idx in 0..num_paths {
// Get prices for this path
let prices: Vec<Vec<f64>> = scenario
.price_paths
.iter()
.map(|ap| ap.paths[path_idx].clone())
.collect();
// Run strategy
let (portfolio_values, returns) = self.run_strategy(
strategy,
&prices,
request.initial_capital,
request.transaction_cost_bps,
);
let final_value = *portfolio_values.last().unwrap();
let total_return = (final_value / request.initial_capital) - 1.0;
// Calculate Sharpe ratio
let mean_return: f64 = returns.iter().sum::<f64>() / returns.len() as f64;
let variance: f64 = returns
.iter()
.map(|r| (r - mean_return).powi(2))
.sum::<f64>()
/ returns.len() as f64;
let std_dev = variance.sqrt();
let annualized_return = mean_return * 252.0;
let annualized_std = std_dev * (252.0_f64).sqrt();
let sharpe = if annualized_std > 0.0 {
(annualized_return - self.risk_free_rate) / annualized_std
} else {
0.0
};
// Calculate max drawdown
let max_dd = self.calculate_max_drawdown(&portfolio_values);
final_values.push(final_value);
total_returns.push(total_return);
sharpe_ratios.push(sharpe);
max_drawdowns.push(max_dd);
}
let mean_final_value = final_values.iter().sum::<f64>() / num_paths as f64;
let mean_total_return = total_returns.iter().sum::<f64>() / num_paths as f64;
let mean_sharpe = sharpe_ratios.iter().sum::<f64>() / num_paths as f64;
let mean_max_dd = max_drawdowns.iter().sum::<f64>() / num_paths as f64;
let prob_loss =
total_returns.iter().filter(|&&r| r < 0.0).count() as f64 / num_paths as f64;
Ok(ScenarioBacktestResult {
scenario_name: scenario.scenario.name.clone(),
mean_final_value,
mean_total_return,
mean_sharpe_ratio: mean_sharpe,
mean_max_drawdown: mean_max_dd,
probability_of_loss: prob_loss,
})
}
/// Run strategy on price paths.
fn run_strategy(
&self,
strategy: &Strategy,
prices: &[Vec<f64>],
initial_capital: f64,
transaction_cost_bps: f64,
) -> (Vec<f64>, Vec<f64>) {
let num_assets = prices.len();
let horizon = prices[0].len();
// Initialize equal weights
let mut weights: Vec<f64> = vec![1.0 / num_assets as f64; num_assets];
let mut portfolio_value: f64 = initial_capital;
let mut portfolio_values = Vec::with_capacity(horizon);
let mut returns = Vec::with_capacity(horizon - 1);
portfolio_values.push(portfolio_value);
for t in 1..horizon {
// Calculate asset returns
let asset_returns: Vec<f64> = (0..num_assets)
.map(|i| (prices[i][t] / prices[i][t - 1]) - 1.0)
.collect();
// Portfolio return
let portfolio_return: f64 = weights
.iter()
.zip(asset_returns.iter())
.map(|(w, r)| w * r)
.sum();
// Update portfolio value
portfolio_value *= 1.0 + portfolio_return;
// Rebalance if needed
if t % strategy.parameters.rebalance_frequency == 0 {
let new_weights = self.calculate_weights(strategy, prices, t);
// Transaction costs
let turnover: f64 = weights
.iter()
.zip(new_weights.iter())
.map(|(old, new)| (new - old).abs())
.sum::<f64>()
/ 2.0;
let cost = turnover * transaction_cost_bps / 10000.0;
portfolio_value *= 1.0 - cost;
weights = new_weights;
}
returns.push(portfolio_return);
portfolio_values.push(portfolio_value);
}
(portfolio_values, returns)
}
/// Calculate strategy weights.
fn calculate_weights(&self, strategy: &Strategy, prices: &[Vec<f64>], t: usize) -> Vec<f64> {
let num_assets = prices.len();
let lookback = strategy.parameters.lookback.min(t);
match strategy.strategy_type {
StrategyType::BuyAndHold => {
// Equal weight
vec![1.0 / num_assets as f64; num_assets]
}
StrategyType::Momentum => {
// Weight by past returns
let mut momentum: Vec<f64> = (0..num_assets)
.map(|i| {
if t > lookback && prices[i][t - lookback] > 0.0 {
(prices[i][t] / prices[i][t - lookback]) - 1.0
} else {
0.0
}
})
.collect();
// Normalize to positive weights
let min_mom = momentum.iter().copied().fold(f64::INFINITY, f64::min);
for m in &mut momentum {
*m -= min_mom - 0.001;
}
let sum: f64 = momentum.iter().sum();
if sum > 0.0 {
momentum.iter().map(|m| m / sum).collect()
} else {
vec![1.0 / num_assets as f64; num_assets]
}
}
StrategyType::MeanReversion => {
// Weight inversely by past returns
let mut z_scores: Vec<f64> = (0..num_assets)
.map(|i| {
if t > lookback {
let returns: Vec<f64> = (t - lookback..t)
.map(|j| (prices[i][j + 1] / prices[i][j]) - 1.0)
.collect();
let mean: f64 = returns.iter().sum::<f64>() / returns.len() as f64;
-mean // Negative = mean reversion
} else {
0.0
}
})
.collect();
// Normalize
let min_z = z_scores.iter().copied().fold(f64::INFINITY, f64::min);
for z in &mut z_scores {
*z -= min_z - 0.001;
}
let sum: f64 = z_scores.iter().sum();
if sum > 0.0 {
z_scores.iter().map(|z| z / sum).collect()
} else {
vec![1.0 / num_assets as f64; num_assets]
}
}
StrategyType::RiskParity => {
// Weight inversely by volatility
let inv_vol: Vec<f64> = (0..num_assets)
.map(|i| {
if t > lookback {
let returns: Vec<f64> = (t - lookback..t)
.map(|j| (prices[i][j + 1] / prices[i][j]) - 1.0)
.collect();
let mean: f64 = returns.iter().sum::<f64>() / returns.len() as f64;
let variance: f64 =
returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
/ returns.len() as f64;
let vol = variance.sqrt();
if vol > 0.001 { 1.0 / vol } else { 1.0 }
} else {
1.0
}
})
.collect();
let sum: f64 = inv_vol.iter().sum();
inv_vol.iter().map(|v| v / sum).collect()
}
StrategyType::TrendFollowing => {
// Binary: 100% if above MA, 0% if below
let short_lookback = lookback / 2;
let weights: Vec<f64> = (0..num_assets)
.map(|i| {
if t > lookback {
let long_ma: f64 = (t - lookback..t).map(|j| prices[i][j]).sum::<f64>()
/ lookback as f64;
let short_ma: f64 = if t > short_lookback {
(t - short_lookback..t).map(|j| prices[i][j]).sum::<f64>()
/ short_lookback as f64
} else {
prices[i][t]
};
if short_ma > long_ma {
1.0
} else {
0.1 // Minimum weight
}
} else {
1.0
}
})
.collect();
let sum: f64 = weights.iter().sum();
weights.iter().map(|w| w / sum).collect()
}
StrategyType::Custom => {
// Default to equal weight
vec![1.0 / num_assets as f64; num_assets]
}
}
}
/// Calculate max drawdown.
fn calculate_max_drawdown(&self, values: &[f64]) -> f64 {
let mut peak: f64 = values[0];
let mut max_dd: f64 = 0.0;
for &value in values.iter().skip(1) {
peak = peak.max(value);
let dd = (peak - value) / peak;
max_dd = max_dd.max(dd);
}
max_dd
}
/// Calculate aggregate statistics.
fn calculate_aggregate_stats(
&self,
results: &[ScenarioBacktestResult],
) -> AggregateBacktestStats {
let n = results.len() as f64;
let overall_mean_return = results.iter().map(|r| r.mean_total_return).sum::<f64>() / n;
let overall_sharpe = results.iter().map(|r| r.mean_sharpe_ratio).sum::<f64>() / n;
let worst_case = results
.iter()
.map(|r| r.mean_total_return)
.fold(f64::INFINITY, f64::min);
let best_case = results
.iter()
.map(|r| r.mean_total_return)
.fold(f64::NEG_INFINITY, f64::max);
let win_rate = results.iter().filter(|r| r.mean_total_return > 0.0).count() as f64 / n;
AggregateBacktestStats {
overall_mean_return,
overall_sharpe_ratio: overall_sharpe,
worst_case_return: worst_case,
best_case_return: best_case,
win_rate,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use marketsim_shared::{
AssetPricePaths, PathStatistics, ScenarioDescription, ScenarioType, SimulationMetadata,
Strategy, StrategyParameters,
};
fn create_test_scenario() -> ScenarioResult {
// Create simple price paths: 10 paths, 20 steps
let paths: Vec<Vec<f64>> = (0..10)
.map(|i| {
let mut path = vec![100.0];
for t in 1..21 {
// Simple random walk
let change = 1.0 + 0.01 * (i as f64 - 5.0) * 0.1;
path.push(path.last().unwrap() * change);
}
path
})
.collect();
let return_paths: Vec<Vec<f64>> = paths
.iter()
.map(|p| p.windows(2).map(|w| (w[1] / w[0]).ln()).collect())
.collect();
ScenarioResult {
scenario: ScenarioDescription {
name: "Test".to_string(),
scenario_type: ScenarioType::Crisis,
description: "Test".to_string(),
severity: 0.5,
duration_days: 20,
},
price_paths: vec![AssetPricePaths {
symbol: "SPY".to_string(),
paths,
return_paths,
}],
statistics: PathStatistics {
asset_stats: vec![],
final_correlation: vec![],
},
metadata: SimulationMetadata {
computation_time_ms: 1,
model_version: "1.0".to_string(),
seed_used: 42,
},
}
}
#[test]
fn test_backtest_engine_creation() {
let engine = BacktestEngine::new();
assert!(engine.risk_free_rate > 0.0);
}
#[test]
fn test_run_backtest() {
let engine = BacktestEngine::new();
let scenario = create_test_scenario();
let request = BacktestRequest {
strategy: Strategy {
name: "Momentum".to_string(),
strategy_type: StrategyType::Momentum,
parameters: StrategyParameters::default(),
},
scenarios: vec![scenario],
initial_capital: 100000.0,
transaction_cost_bps: 10.0,
};
let result = engine.run(&request);
assert!(result.is_ok());
let backtest = result.unwrap();
assert_eq!(backtest.scenario_results.len(), 1);
}
#[test]
fn test_strategy_types() {
let engine = BacktestEngine::new();
let scenario = create_test_scenario();
for strategy_type in [
StrategyType::BuyAndHold,
StrategyType::Momentum,
StrategyType::MeanReversion,
StrategyType::RiskParity,
] {
let request = BacktestRequest {
strategy: Strategy {
name: "Test".to_string(),
strategy_type,
parameters: StrategyParameters::default(),
},
scenarios: vec![scenario.clone()],
initial_capital: 100000.0,
transaction_cost_bps: 10.0,
};
let result = engine.run(&request);
assert!(result.is_ok());
}
}
#[test]
fn test_max_drawdown() {
let engine = BacktestEngine::new();
let values = vec![100.0, 110.0, 105.0, 95.0, 100.0];
let dd = engine.calculate_max_drawdown(&values);
// Max drawdown from 110 to 95 = (110-95)/110 ≈ 0.136
assert!((dd - 0.136).abs() < 0.01);
}
}