Initial backtesting engine
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
//! Basket/collective strategy backtest implementation.
|
||||
//!
|
||||
//! Supports multiple instruments with synchronized signals.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, OhlcvData, Trade,
|
||||
};
|
||||
use crate::execution::FeeModel;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
use crate::portfolio::allocation::{AllocationStrategy, CapitalAllocator};
|
||||
use crate::signals::processor::SignalProcessor;
|
||||
use crate::signals::synchronizer::{SignalSynchronizer, SyncMode};
|
||||
|
||||
/// Basket backtest configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BasketConfig {
|
||||
/// Base backtest config.
|
||||
pub base: BacktestConfig,
|
||||
/// Signal synchronization mode.
|
||||
pub sync_mode: SyncMode,
|
||||
/// Capital allocation strategy.
|
||||
pub allocation: AllocationStrategy,
|
||||
/// Whether to rebalance on each signal.
|
||||
pub rebalance_on_signal: bool,
|
||||
}
|
||||
|
||||
impl Default for BasketConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: BacktestConfig::default(),
|
||||
sync_mode: SyncMode::All,
|
||||
allocation: AllocationStrategy::EqualWeight,
|
||||
rebalance_on_signal: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Basket/collective strategy backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct BasketBacktest {
|
||||
/// Configuration.
|
||||
config: BasketConfig,
|
||||
/// Signal synchronizer.
|
||||
synchronizer: SignalSynchronizer,
|
||||
/// Capital allocator.
|
||||
#[allow(dead_code)]
|
||||
allocator: CapitalAllocator,
|
||||
/// Signal processor.
|
||||
signal_processor: SignalProcessor,
|
||||
/// Fee model.
|
||||
fee_model: FeeModel,
|
||||
}
|
||||
|
||||
impl BasketBacktest {
|
||||
/// Create a new basket backtest.
|
||||
pub fn new(config: BasketConfig) -> Self {
|
||||
let allocator = CapitalAllocator::new(config.base.initial_capital)
|
||||
.with_strategy(config.allocation.clone());
|
||||
|
||||
Self {
|
||||
synchronizer: SignalSynchronizer::new(config.sync_mode),
|
||||
allocator,
|
||||
signal_processor: SignalProcessor::new(),
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run basket backtest with multiple instruments.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Combined backtest result
|
||||
pub fn run(&self, instruments: &[(OhlcvData, CompiledSignals)]) -> BacktestResult {
|
||||
if instruments.is_empty() {
|
||||
return self.empty_result();
|
||||
}
|
||||
|
||||
let n_instruments = instruments.len();
|
||||
let n_bars = instruments[0].0.len();
|
||||
|
||||
// Verify all instruments have same length
|
||||
for (ohlcv, signals) in instruments {
|
||||
assert_eq!(
|
||||
ohlcv.len(),
|
||||
n_bars,
|
||||
"All instruments must have same number of bars"
|
||||
);
|
||||
assert_eq!(signals.len(), n_bars, "Signals must match OHLCV length");
|
||||
}
|
||||
|
||||
// Synchronize signals
|
||||
let entry_signals: Vec<&[bool]> = instruments
|
||||
.iter()
|
||||
.map(|(_, s)| s.entries.as_slice())
|
||||
.collect();
|
||||
let exit_signals: Vec<&[bool]> = instruments
|
||||
.iter()
|
||||
.map(|(_, s)| s.exits.as_slice())
|
||||
.collect();
|
||||
|
||||
let synced_entries = self.synchronizer.sync_entries(&entry_signals);
|
||||
let synced_exits = self.synchronizer.sync_exits(&exit_signals);
|
||||
|
||||
// Clean signals
|
||||
let (clean_entries, clean_exits) = self
|
||||
.signal_processor
|
||||
.clean_signals(&synced_entries, &synced_exits);
|
||||
|
||||
// Initialize state
|
||||
let mut cash = self.config.base.initial_capital;
|
||||
let mut positions: Vec<Option<PositionState>> = vec![None; n_instruments];
|
||||
let mut equity_curve = vec![cash; n_bars];
|
||||
let mut drawdown_curve = vec![0.0; n_bars];
|
||||
let mut returns = vec![0.0; n_bars];
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
let mut peak_equity = cash;
|
||||
let mut trade_counter = 0u64;
|
||||
|
||||
// Main simulation loop
|
||||
for i in 0..n_bars {
|
||||
// Calculate current position values
|
||||
let mut _total_position_value = 0.0;
|
||||
for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() {
|
||||
if let Some(ref pos) = positions[inst_idx] {
|
||||
_total_position_value += pos.size * ohlcv.close[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for exit
|
||||
if clean_exits[i] {
|
||||
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
||||
if let Some(pos) = positions[inst_idx].take() {
|
||||
let exit_price = ohlcv.close[i];
|
||||
let fees =
|
||||
self.fee_model
|
||||
.calculate(exit_price, pos.size, signals.direction);
|
||||
|
||||
let pnl = (exit_price - pos.entry_price)
|
||||
* pos.size
|
||||
* signals.direction.multiplier()
|
||||
- fees;
|
||||
|
||||
let cost_basis = pos.entry_price * pos.size;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
cash += exit_price * pos.size - fees;
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: i,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.size,
|
||||
direction: signals.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: ohlcv.timestamps[i],
|
||||
fees,
|
||||
exit_reason: ExitReason::Signal,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entry
|
||||
if clean_entries[i] && positions.iter().all(|p| p.is_none()) {
|
||||
// Calculate position sizes
|
||||
let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).collect();
|
||||
let weights: Vec<f64> = instruments.iter().map(|(_, s)| s.weight).collect();
|
||||
let sizes = self.calculate_sizes(&prices, &weights, cash);
|
||||
|
||||
// Enter positions
|
||||
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
||||
let size = sizes[inst_idx];
|
||||
if size > 0.0 {
|
||||
let entry_price = ohlcv.close[i];
|
||||
let fees = self
|
||||
.fee_model
|
||||
.calculate(entry_price, size, signals.direction);
|
||||
cash -= entry_price * size + fees;
|
||||
|
||||
positions[inst_idx] = Some(PositionState {
|
||||
entry_idx: i,
|
||||
entry_price,
|
||||
size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update equity
|
||||
let mut position_value = 0.0;
|
||||
for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() {
|
||||
if let Some(ref pos) = positions[inst_idx] {
|
||||
position_value += pos.size * ohlcv.close[i];
|
||||
}
|
||||
}
|
||||
let equity = cash + position_value;
|
||||
equity_curve[i] = equity;
|
||||
|
||||
// Update drawdown
|
||||
if equity > peak_equity {
|
||||
peak_equity = equity;
|
||||
}
|
||||
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
|
||||
|
||||
// Calculate return
|
||||
if i > 0 {
|
||||
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining positions
|
||||
let last_idx = n_bars - 1;
|
||||
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
||||
if let Some(pos) = positions[inst_idx].take() {
|
||||
let exit_price = ohlcv.close[last_idx];
|
||||
let fees = self
|
||||
.fee_model
|
||||
.calculate(exit_price, pos.size, signals.direction);
|
||||
|
||||
let pnl =
|
||||
(exit_price - pos.entry_price) * pos.size * signals.direction.multiplier()
|
||||
- fees;
|
||||
|
||||
let cost_basis = pos.entry_price * pos.size;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: last_idx,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.size,
|
||||
direction: signals.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: ohlcv.timestamps[last_idx],
|
||||
fees,
|
||||
exit_reason: ExitReason::EndOfData,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
|
||||
|
||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||
}
|
||||
|
||||
/// Calculate position sizes for each instrument.
|
||||
fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec<f64> {
|
||||
let n = prices.len();
|
||||
let total_weight: f64 = weights.iter().sum();
|
||||
|
||||
if total_weight == 0.0 {
|
||||
return vec![0.0; n];
|
||||
}
|
||||
|
||||
prices
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(&price, &weight)| {
|
||||
if price <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let allocation = available_capital * (weight / total_weight);
|
||||
allocation / price
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Calculate metrics for the backtest.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
trades: &[Trade],
|
||||
streaming: &StreamingMetrics,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = self.config.base.initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
let total_trades = trades.len();
|
||||
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
|
||||
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
|
||||
|
||||
let win_rate_pct = if total_trades > 0 {
|
||||
winning_trades as f64 / total_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||
let gross_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let sharpe_ratio = streaming.sharpe_ratio(252.0);
|
||||
let sortino_ratio = streaming.sortino_ratio(252.0);
|
||||
let calmar_ratio = if max_drawdown_pct > 0.0 {
|
||||
total_return_pct / max_drawdown_pct
|
||||
} else if total_return_pct > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio,
|
||||
sortino_ratio,
|
||||
calmar_ratio,
|
||||
max_drawdown_pct,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
total_trades,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create empty result.
|
||||
fn empty_result(&self) -> BacktestResult {
|
||||
BacktestResult::new(
|
||||
BacktestMetrics {
|
||||
start_value: self.config.base.initial_capital,
|
||||
end_value: self.config.base.initial_capital,
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal position state.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PositionState {
|
||||
entry_idx: usize,
|
||||
entry_price: f64,
|
||||
size: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_instruments() -> Vec<(OhlcvData, CompiledSignals)> {
|
||||
let n = 20;
|
||||
|
||||
let ohlcv1 = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (100..100 + n).map(|x| x as f64).collect(),
|
||||
high: (101..101 + n).map(|x| x as f64).collect(),
|
||||
low: (99..99 + n).map(|x| x as f64).collect(),
|
||||
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
|
||||
volume: vec![1000.0; n],
|
||||
};
|
||||
|
||||
let ohlcv2 = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (50..50 + n).map(|x| x as f64).collect(),
|
||||
high: (51..51 + n).map(|x| x as f64).collect(),
|
||||
low: (49..49 + n).map(|x| x as f64).collect(),
|
||||
close: (50..50 + n).map(|x| x as f64 + 0.25).collect(),
|
||||
volume: vec![2000.0; n],
|
||||
};
|
||||
|
||||
let mut entries1 = vec![false; n];
|
||||
let mut exits1 = vec![false; n];
|
||||
entries1[2] = true;
|
||||
exits1[8] = true;
|
||||
|
||||
let mut entries2 = vec![false; n];
|
||||
let mut exits2 = vec![false; n];
|
||||
entries2[2] = true;
|
||||
exits2[8] = true;
|
||||
|
||||
let signals1 = CompiledSignals {
|
||||
symbol: "INST1".to_string(),
|
||||
entries: entries1,
|
||||
exits: exits1,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let signals2 = CompiledSignals {
|
||||
symbol: "INST2".to_string(),
|
||||
entries: entries2,
|
||||
exits: exits2,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
vec![(ohlcv1, signals1), (ohlcv2, signals2)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basket_backtest() {
|
||||
let config = BasketConfig::default();
|
||||
let backtest = BasketBacktest::new(config);
|
||||
let instruments = sample_instruments();
|
||||
|
||||
let result = backtest.run(&instruments);
|
||||
|
||||
// Should have trades for both instruments
|
||||
assert!(result.trades.len() >= 2);
|
||||
assert_eq!(result.equity_curve.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_mode_all() {
|
||||
let config = BasketConfig {
|
||||
sync_mode: SyncMode::All,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = BasketBacktest::new(config);
|
||||
let instruments = sample_instruments();
|
||||
|
||||
let result = backtest.run(&instruments);
|
||||
|
||||
// With All mode, both instruments should enter at same time
|
||||
assert!(result.trades.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_instruments() {
|
||||
let config = BasketConfig::default();
|
||||
let backtest = BasketBacktest::new(config);
|
||||
|
||||
let result = backtest.run(&[]);
|
||||
|
||||
assert_eq!(result.trades.len(), 0);
|
||||
assert!(result.equity_curve.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Strategy implementations for different backtest types.
|
||||
|
||||
pub mod basket;
|
||||
pub mod multi;
|
||||
pub mod options;
|
||||
pub mod pairs;
|
||||
pub mod single;
|
||||
|
||||
pub use basket::BasketBacktest;
|
||||
pub use multi::MultiStrategyBacktest;
|
||||
pub use options::OptionsBacktest;
|
||||
pub use pairs::PairsBacktest;
|
||||
pub use single::SingleBacktest;
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Multi-strategy backtest implementation.
|
||||
//!
|
||||
//! Supports running multiple strategies on the same instrument.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, OhlcvData, Trade,
|
||||
};
|
||||
use crate::execution::FeeModel;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
|
||||
/// Strategy combination mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CombineMode {
|
||||
/// Enter when any strategy signals.
|
||||
Any,
|
||||
/// Enter when all strategies signal.
|
||||
All,
|
||||
/// Enter when majority of strategies signal.
|
||||
Majority,
|
||||
/// Run strategies independently with separate capital.
|
||||
Independent,
|
||||
/// Vote-weighted combination.
|
||||
Weighted,
|
||||
}
|
||||
|
||||
impl Default for CombineMode {
|
||||
fn default() -> Self {
|
||||
CombineMode::Any
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-strategy configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MultiStrategyConfig {
|
||||
/// Base backtest config.
|
||||
pub base: BacktestConfig,
|
||||
/// Strategy combination mode.
|
||||
pub combine_mode: CombineMode,
|
||||
/// Capital allocation per strategy (for independent mode).
|
||||
pub capital_per_strategy: Option<f64>,
|
||||
/// Strategy weights (for weighted mode).
|
||||
pub strategy_weights: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Default for MultiStrategyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: BacktestConfig::default(),
|
||||
combine_mode: CombineMode::Any,
|
||||
capital_per_strategy: None,
|
||||
strategy_weights: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-strategy backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct MultiStrategyBacktest {
|
||||
/// Configuration.
|
||||
config: MultiStrategyConfig,
|
||||
/// Fee model.
|
||||
#[allow(dead_code)]
|
||||
fee_model: FeeModel,
|
||||
}
|
||||
|
||||
impl MultiStrategyBacktest {
|
||||
/// Create a new multi-strategy backtest.
|
||||
pub fn new(config: MultiStrategyConfig) -> Self {
|
||||
Self {
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run multi-strategy backtest.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ohlcv` - OHLCV data for the instrument
|
||||
/// * `strategies` - Vector of compiled signals from each strategy
|
||||
///
|
||||
/// # Returns
|
||||
/// Combined backtest result
|
||||
pub fn run(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
|
||||
if strategies.is_empty() {
|
||||
return self.empty_result();
|
||||
}
|
||||
|
||||
let n = ohlcv.len();
|
||||
for signals in strategies {
|
||||
assert_eq!(
|
||||
signals.len(),
|
||||
n,
|
||||
"All strategies must have same length as OHLCV"
|
||||
);
|
||||
}
|
||||
|
||||
match self.config.combine_mode {
|
||||
CombineMode::Independent => self.run_independent(ohlcv, strategies),
|
||||
_ => self.run_combined(ohlcv, strategies),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run strategies independently with separate capital.
|
||||
fn run_independent(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
|
||||
let n_strategies = strategies.len();
|
||||
let capital_per = self
|
||||
.config
|
||||
.capital_per_strategy
|
||||
.unwrap_or(self.config.base.initial_capital / n_strategies as f64);
|
||||
|
||||
// Run each strategy independently
|
||||
let mut all_trades: Vec<Trade> = Vec::new();
|
||||
let mut strategy_equities: Vec<Vec<f64>> = Vec::new();
|
||||
|
||||
for (strat_idx, signals) in strategies.iter().enumerate() {
|
||||
let single_config = BacktestConfig {
|
||||
initial_capital: capital_per,
|
||||
..self.config.base.clone()
|
||||
};
|
||||
let single = crate::strategies::single::SingleBacktest::new(single_config);
|
||||
let result = single.run(ohlcv, signals);
|
||||
|
||||
// Tag trades with strategy index
|
||||
for mut trade in result.trades {
|
||||
trade.symbol = format!("{}_{}", trade.symbol, strat_idx);
|
||||
all_trades.push(trade);
|
||||
}
|
||||
|
||||
strategy_equities.push(result.equity_curve);
|
||||
}
|
||||
|
||||
// Combine equity curves
|
||||
let n = ohlcv.len();
|
||||
let mut combined_equity = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
for equity in &strategy_equities {
|
||||
combined_equity[i] += equity[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate drawdown
|
||||
let mut peak = combined_equity[0];
|
||||
let mut drawdown_curve = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
if combined_equity[i] > peak {
|
||||
peak = combined_equity[i];
|
||||
}
|
||||
drawdown_curve[i] = (peak - combined_equity[i]) / peak * 100.0;
|
||||
}
|
||||
|
||||
// Calculate returns
|
||||
let mut returns = vec![0.0; n];
|
||||
for i in 1..n {
|
||||
returns[i] = (combined_equity[i] - combined_equity[i - 1]) / combined_equity[i - 1];
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
for trade in &all_trades {
|
||||
streaming.update(trade.return_pct / 100.0);
|
||||
}
|
||||
|
||||
let metrics = self.calculate_metrics(
|
||||
&combined_equity,
|
||||
&drawdown_curve,
|
||||
&all_trades,
|
||||
&streaming,
|
||||
self.config.base.initial_capital,
|
||||
);
|
||||
|
||||
BacktestResult::new(
|
||||
metrics,
|
||||
combined_equity,
|
||||
drawdown_curve,
|
||||
all_trades,
|
||||
returns,
|
||||
)
|
||||
}
|
||||
|
||||
/// Run strategies with combined signals.
|
||||
fn run_combined(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
|
||||
let n = ohlcv.len();
|
||||
let n_strategies = strategies.len();
|
||||
|
||||
// Combine entry signals
|
||||
let mut combined_entries = vec![false; n];
|
||||
let mut combined_exits = vec![false; n];
|
||||
|
||||
for i in 0..n {
|
||||
let entry_count = strategies.iter().filter(|s| s.entries[i]).count();
|
||||
let exit_count = strategies.iter().filter(|s| s.exits[i]).count();
|
||||
|
||||
combined_entries[i] = match self.config.combine_mode {
|
||||
CombineMode::Any => entry_count > 0,
|
||||
CombineMode::All => entry_count == n_strategies,
|
||||
CombineMode::Majority => entry_count > n_strategies / 2,
|
||||
CombineMode::Weighted => {
|
||||
let weighted_sum: f64 = strategies
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.entries[i])
|
||||
.map(|(idx, _)| {
|
||||
self.config
|
||||
.strategy_weights
|
||||
.get(idx)
|
||||
.copied()
|
||||
.unwrap_or(1.0)
|
||||
})
|
||||
.sum();
|
||||
let total_weight: f64 = self
|
||||
.config
|
||||
.strategy_weights
|
||||
.iter()
|
||||
.sum::<f64>()
|
||||
.max(n_strategies as f64);
|
||||
weighted_sum / total_weight > 0.5
|
||||
}
|
||||
CombineMode::Independent => unreachable!(),
|
||||
};
|
||||
|
||||
// Exit when any strategy wants to exit (conservative)
|
||||
combined_exits[i] = exit_count > 0;
|
||||
}
|
||||
|
||||
// Use first strategy's direction and symbol
|
||||
let direction = strategies[0].direction;
|
||||
let symbol = strategies[0].symbol.clone();
|
||||
|
||||
let combined_signals = CompiledSignals {
|
||||
symbol,
|
||||
entries: combined_entries,
|
||||
exits: combined_exits,
|
||||
position_sizes: None,
|
||||
direction,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
// Run single backtest with combined signals
|
||||
let single = crate::strategies::single::SingleBacktest::new(self.config.base.clone());
|
||||
single.run(ohlcv, &combined_signals)
|
||||
}
|
||||
|
||||
/// Calculate metrics.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
trades: &[Trade],
|
||||
streaming: &StreamingMetrics,
|
||||
initial_capital: f64,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
let total_trades = trades.len();
|
||||
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
|
||||
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
|
||||
|
||||
let win_rate_pct = if total_trades > 0 {
|
||||
winning_trades as f64 / total_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||
let gross_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio: streaming.sharpe_ratio(252.0),
|
||||
sortino_ratio: streaming.sortino_ratio(252.0),
|
||||
calmar_ratio: if max_drawdown_pct > 0.0 {
|
||||
total_return_pct / max_drawdown_pct
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
max_drawdown_pct,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
total_trades,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create empty result.
|
||||
fn empty_result(&self) -> BacktestResult {
|
||||
BacktestResult::new(
|
||||
BacktestMetrics {
|
||||
start_value: self.config.base.initial_capital,
|
||||
end_value: self.config.base.initial_capital,
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_strategies() -> (OhlcvData, Vec<CompiledSignals>) {
|
||||
let n = 20;
|
||||
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (100..100 + n).map(|x| x as f64).collect(),
|
||||
high: (101..101 + n).map(|x| x as f64).collect(),
|
||||
low: (99..99 + n).map(|x| x as f64).collect(),
|
||||
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
|
||||
volume: vec![1000.0; n],
|
||||
};
|
||||
|
||||
// Strategy 1: Early entry
|
||||
let mut entries1 = vec![false; n];
|
||||
let mut exits1 = vec![false; n];
|
||||
entries1[2] = true;
|
||||
exits1[8] = true;
|
||||
|
||||
// Strategy 2: Later entry
|
||||
let mut entries2 = vec![false; n];
|
||||
let mut exits2 = vec![false; n];
|
||||
entries2[4] = true;
|
||||
exits2[10] = true;
|
||||
|
||||
let signals1 = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: entries1,
|
||||
exits: exits1,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let signals2 = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: entries2,
|
||||
exits: exits2,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
(ohlcv, vec![signals1, signals2])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_any_mode() {
|
||||
let config = MultiStrategyConfig {
|
||||
combine_mode: CombineMode::Any,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = MultiStrategyBacktest::new(config);
|
||||
let (ohlcv, strategies) = sample_strategies();
|
||||
|
||||
let result = backtest.run(&ohlcv, &strategies);
|
||||
|
||||
// With Any mode, should enter at index 2 (first strategy)
|
||||
assert!(!result.trades.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_all_mode() {
|
||||
let config = MultiStrategyConfig {
|
||||
combine_mode: CombineMode::All,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = MultiStrategyBacktest::new(config);
|
||||
let (ohlcv, strategies) = sample_strategies();
|
||||
|
||||
let result = backtest.run(&ohlcv, &strategies);
|
||||
|
||||
// With All mode, should not enter (strategies don't signal at same time)
|
||||
assert!(result.trades.is_empty() || result.trades.len() < 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_independent_mode() {
|
||||
let config = MultiStrategyConfig {
|
||||
combine_mode: CombineMode::Independent,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = MultiStrategyBacktest::new(config);
|
||||
let (ohlcv, strategies) = sample_strategies();
|
||||
|
||||
let result = backtest.run(&ohlcv, &strategies);
|
||||
|
||||
// With Independent mode, should have trades from both strategies
|
||||
assert!(result.trades.len() >= 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Options strategy backtest implementation.
|
||||
//!
|
||||
//! Supports dynamic strike selection and options-specific position sizing.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, OhlcvData, Trade,
|
||||
};
|
||||
use crate::execution::FeeModel;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
|
||||
/// Options position type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OptionType {
|
||||
Call,
|
||||
Put,
|
||||
}
|
||||
|
||||
/// Strike selection mode.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum StrikeSelection {
|
||||
/// At-the-money (closest to spot).
|
||||
Atm,
|
||||
/// In-the-money by N strikes.
|
||||
Itm(usize),
|
||||
/// Out-of-the-money by N strikes.
|
||||
Otm(usize),
|
||||
/// Fixed strike offset from ATM in percentage.
|
||||
PercentOffset(f64),
|
||||
/// Delta-based selection.
|
||||
Delta(f64),
|
||||
}
|
||||
|
||||
impl Default for StrikeSelection {
|
||||
fn default() -> Self {
|
||||
StrikeSelection::Atm
|
||||
}
|
||||
}
|
||||
|
||||
/// Position size type for options.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum SizeType {
|
||||
/// Fixed number of contracts.
|
||||
Contracts(usize),
|
||||
/// Percentage of capital.
|
||||
Percent(f64),
|
||||
/// Fixed notional value.
|
||||
Notional(f64),
|
||||
/// Risk-based (percentage of capital at risk).
|
||||
RiskPercent(f64),
|
||||
}
|
||||
|
||||
impl Default for SizeType {
|
||||
fn default() -> Self {
|
||||
SizeType::Percent(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Options backtest configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OptionsConfig {
|
||||
/// Base backtest config.
|
||||
pub base: BacktestConfig,
|
||||
/// Option type (call/put).
|
||||
pub option_type: OptionType,
|
||||
/// Strike selection mode.
|
||||
pub strike_selection: StrikeSelection,
|
||||
/// Position size type.
|
||||
pub size_type: SizeType,
|
||||
/// Lot size (contracts per lot).
|
||||
pub lot_size: usize,
|
||||
/// Strike interval.
|
||||
pub strike_interval: f64,
|
||||
/// Days to expiry preference.
|
||||
pub target_dte: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for OptionsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: BacktestConfig::default(),
|
||||
option_type: OptionType::Call,
|
||||
strike_selection: StrikeSelection::Atm,
|
||||
size_type: SizeType::Percent(1.0),
|
||||
lot_size: 1,
|
||||
strike_interval: 50.0,
|
||||
target_dte: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct OptionsBacktest {
|
||||
/// Configuration.
|
||||
config: OptionsConfig,
|
||||
/// Fee model.
|
||||
fee_model: FeeModel,
|
||||
}
|
||||
|
||||
impl OptionsBacktest {
|
||||
/// Create a new options backtest.
|
||||
pub fn new(config: OptionsConfig) -> Self {
|
||||
Self {
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run options backtest.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `spot_ohlcv` - Spot/underlying OHLCV data
|
||||
/// * `option_prices` - Option premium prices (parallel array)
|
||||
/// * `signals` - Trading signals
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run(
|
||||
&self,
|
||||
spot_ohlcv: &OhlcvData,
|
||||
option_prices: &[f64],
|
||||
signals: &CompiledSignals,
|
||||
) -> BacktestResult {
|
||||
let n = spot_ohlcv.len();
|
||||
assert_eq!(n, option_prices.len());
|
||||
assert_eq!(n, signals.len());
|
||||
|
||||
// Clean signals
|
||||
let processor = crate::signals::processor::SignalProcessor::new();
|
||||
let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits);
|
||||
|
||||
// Initialize state
|
||||
let mut cash = self.config.base.initial_capital;
|
||||
let mut position: Option<OptionsPosition> = None;
|
||||
let mut equity_curve = vec![cash; n];
|
||||
let mut drawdown_curve = vec![0.0; n];
|
||||
let mut returns = vec![0.0; n];
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
let mut peak_equity = cash;
|
||||
let mut trade_counter = 0u64;
|
||||
|
||||
// Main simulation loop
|
||||
for i in 0..n {
|
||||
let spot_price = spot_ohlcv.close[i];
|
||||
let option_price = option_prices[i];
|
||||
|
||||
// Check for exit
|
||||
if exits[i] {
|
||||
if let Some(pos) = position.take() {
|
||||
let exit_price = option_price;
|
||||
let fees = self.fee_model.calculate(
|
||||
exit_price,
|
||||
pos.contracts as f64,
|
||||
signals.direction,
|
||||
);
|
||||
|
||||
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
|
||||
let cost_basis =
|
||||
pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
cash += exit_price * pos.contracts as f64 * self.config.lot_size as f64 - fees;
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: i,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.contracts as f64,
|
||||
direction: signals.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: spot_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: spot_ohlcv.timestamps[i],
|
||||
fees,
|
||||
exit_reason: ExitReason::Signal,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entry
|
||||
if entries[i] && position.is_none() {
|
||||
let strike = self.select_strike(spot_price);
|
||||
let contracts = self.calculate_contracts(option_price, cash);
|
||||
|
||||
if contracts > 0 {
|
||||
let entry_cost = option_price * contracts as f64 * self.config.lot_size as f64;
|
||||
let fees =
|
||||
self.fee_model
|
||||
.calculate(option_price, contracts as f64, signals.direction);
|
||||
|
||||
cash -= entry_cost + fees;
|
||||
|
||||
position = Some(OptionsPosition {
|
||||
entry_idx: i,
|
||||
entry_price: option_price,
|
||||
strike,
|
||||
contracts,
|
||||
option_type: self.config.option_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update equity
|
||||
let position_value = if let Some(ref pos) = position {
|
||||
option_price * pos.contracts as f64 * self.config.lot_size as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let equity = cash + position_value;
|
||||
equity_curve[i] = equity;
|
||||
|
||||
// Update drawdown
|
||||
if equity > peak_equity {
|
||||
peak_equity = equity;
|
||||
}
|
||||
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
|
||||
|
||||
// Calculate return
|
||||
if i > 0 {
|
||||
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining position
|
||||
if let Some(pos) = position.take() {
|
||||
let last_idx = n - 1;
|
||||
let exit_price = option_prices[last_idx];
|
||||
let fees =
|
||||
self.fee_model
|
||||
.calculate(exit_price, pos.contracts as f64, signals.direction);
|
||||
|
||||
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
|
||||
let cost_basis = pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: last_idx,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.contracts as f64,
|
||||
direction: signals.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: spot_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: spot_ohlcv.timestamps[last_idx],
|
||||
fees,
|
||||
exit_reason: ExitReason::EndOfData,
|
||||
});
|
||||
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
|
||||
|
||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||
}
|
||||
|
||||
/// Select strike price based on configuration.
|
||||
fn select_strike(&self, spot_price: f64) -> f64 {
|
||||
let interval = self.config.strike_interval;
|
||||
let atm_strike = (spot_price / interval).round() * interval;
|
||||
|
||||
match self.config.strike_selection {
|
||||
StrikeSelection::Atm => atm_strike,
|
||||
StrikeSelection::Itm(n) => match self.config.option_type {
|
||||
OptionType::Call => atm_strike - (n as f64 * interval),
|
||||
OptionType::Put => atm_strike + (n as f64 * interval),
|
||||
},
|
||||
StrikeSelection::Otm(n) => match self.config.option_type {
|
||||
OptionType::Call => atm_strike + (n as f64 * interval),
|
||||
OptionType::Put => atm_strike - (n as f64 * interval),
|
||||
},
|
||||
StrikeSelection::PercentOffset(pct) => {
|
||||
let offset = spot_price * pct;
|
||||
match self.config.option_type {
|
||||
OptionType::Call => atm_strike + offset,
|
||||
OptionType::Put => atm_strike - offset,
|
||||
}
|
||||
}
|
||||
StrikeSelection::Delta(_) => atm_strike, // Simplified - would need options chain
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate number of contracts based on size type.
|
||||
fn calculate_contracts(&self, option_price: f64, available_capital: f64) -> usize {
|
||||
if option_price <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let contract_cost = option_price * self.config.lot_size as f64;
|
||||
|
||||
match self.config.size_type {
|
||||
SizeType::Contracts(n) => n,
|
||||
SizeType::Percent(pct) => {
|
||||
let allocation = available_capital * pct;
|
||||
(allocation / contract_cost) as usize
|
||||
}
|
||||
SizeType::Notional(value) => (value / contract_cost) as usize,
|
||||
SizeType::RiskPercent(pct) => {
|
||||
// Max loss is the premium paid
|
||||
let risk_amount = available_capital * pct;
|
||||
(risk_amount / contract_cost) as usize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate P&L for a position.
|
||||
fn calculate_pnl(&self, position: &OptionsPosition, current_price: f64) -> f64 {
|
||||
let multiplier = self.config.lot_size as f64;
|
||||
(current_price - position.entry_price) * position.contracts as f64 * multiplier
|
||||
}
|
||||
|
||||
/// Calculate metrics.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
trades: &[Trade],
|
||||
streaming: &StreamingMetrics,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = self.config.base.initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
let total_trades = trades.len();
|
||||
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
|
||||
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
|
||||
|
||||
let win_rate_pct = if total_trades > 0 {
|
||||
winning_trades as f64 / total_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||
let gross_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio: streaming.sharpe_ratio(252.0),
|
||||
sortino_ratio: streaming.sortino_ratio(252.0),
|
||||
calmar_ratio: if max_drawdown_pct > 0.0 {
|
||||
total_return_pct / max_drawdown_pct
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
max_drawdown_pct,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
total_trades,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal options position state.
|
||||
#[derive(Debug, Clone)]
|
||||
struct OptionsPosition {
|
||||
entry_idx: usize,
|
||||
entry_price: f64,
|
||||
#[allow(dead_code)]
|
||||
strike: f64,
|
||||
contracts: usize,
|
||||
#[allow(dead_code)]
|
||||
option_type: OptionType,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_strike_selection_atm() {
|
||||
let config = OptionsConfig {
|
||||
strike_interval: 50.0,
|
||||
strike_selection: StrikeSelection::Atm,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = OptionsBacktest::new(config);
|
||||
|
||||
// Spot at 17834, ATM should be 17850
|
||||
let strike = backtest.select_strike(17834.0);
|
||||
assert!((strike - 17850.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strike_selection_otm() {
|
||||
let config = OptionsConfig {
|
||||
strike_interval: 50.0,
|
||||
strike_selection: StrikeSelection::Otm(2),
|
||||
option_type: OptionType::Call,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = OptionsBacktest::new(config);
|
||||
|
||||
// Spot at 17834, ATM=17850, OTM 2 strikes = 17950
|
||||
let strike = backtest.select_strike(17834.0);
|
||||
assert!((strike - 17950.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_sizing_percent() {
|
||||
let config = OptionsConfig {
|
||||
size_type: SizeType::Percent(0.5),
|
||||
lot_size: 50,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = OptionsBacktest::new(config);
|
||||
|
||||
// 50% of 100000 = 50000, option at 100 * lot 50 = 5000 per contract
|
||||
let contracts = backtest.calculate_contracts(100.0, 100_000.0);
|
||||
assert_eq!(contracts, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! Pairs trading strategy backtest implementation.
|
||||
//!
|
||||
//! Supports long/short legs with hedge ratios.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
|
||||
OhlcvData, Trade,
|
||||
};
|
||||
use crate::execution::FeeModel;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
|
||||
/// Pairs trading configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PairsConfig {
|
||||
/// Base backtest config.
|
||||
pub base: BacktestConfig,
|
||||
/// Hedge ratio (units of leg2 per unit of leg1).
|
||||
pub hedge_ratio: f64,
|
||||
/// Whether to dynamically update hedge ratio.
|
||||
pub dynamic_hedge: bool,
|
||||
/// Lookback period for dynamic hedge calculation.
|
||||
pub hedge_lookback: usize,
|
||||
/// Maximum spread for entry.
|
||||
pub max_spread: Option<f64>,
|
||||
/// Entry z-score threshold.
|
||||
pub entry_zscore: f64,
|
||||
/// Exit z-score threshold.
|
||||
pub exit_zscore: f64,
|
||||
}
|
||||
|
||||
impl Default for PairsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: BacktestConfig::default(),
|
||||
hedge_ratio: 1.0,
|
||||
dynamic_hedge: false,
|
||||
hedge_lookback: 20,
|
||||
max_spread: None,
|
||||
entry_zscore: 2.0,
|
||||
exit_zscore: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairs trading backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct PairsBacktest {
|
||||
/// Configuration.
|
||||
config: PairsConfig,
|
||||
/// Fee model.
|
||||
fee_model: FeeModel,
|
||||
}
|
||||
|
||||
impl PairsBacktest {
|
||||
/// Create a new pairs backtest.
|
||||
pub fn new(config: PairsConfig) -> Self {
|
||||
Self {
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run pairs trading backtest.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `leg1_ohlcv` - OHLCV data for leg 1 (long leg when spread widens)
|
||||
/// * `leg2_ohlcv` - OHLCV data for leg 2 (short leg when spread widens)
|
||||
/// * `signals` - Entry/exit signals based on spread
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run(
|
||||
&self,
|
||||
leg1_ohlcv: &OhlcvData,
|
||||
leg2_ohlcv: &OhlcvData,
|
||||
signals: &CompiledSignals,
|
||||
) -> BacktestResult {
|
||||
let n = leg1_ohlcv.len();
|
||||
assert_eq!(n, leg2_ohlcv.len());
|
||||
assert_eq!(n, signals.len());
|
||||
|
||||
// Clean signals
|
||||
let processor = crate::signals::processor::SignalProcessor::new();
|
||||
let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits);
|
||||
|
||||
// Initialize state
|
||||
let mut cash = self.config.base.initial_capital;
|
||||
let mut position: Option<PairsPosition> = None;
|
||||
let mut equity_curve = vec![cash; n];
|
||||
let mut drawdown_curve = vec![0.0; n];
|
||||
let mut returns = vec![0.0; n];
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
let mut peak_equity = cash;
|
||||
let mut trade_counter = 0u64;
|
||||
|
||||
// Main simulation loop
|
||||
for i in 0..n {
|
||||
let leg1_price = leg1_ohlcv.close[i];
|
||||
let leg2_price = leg2_ohlcv.close[i];
|
||||
|
||||
// Calculate current hedge ratio
|
||||
let hedge_ratio = if self.config.dynamic_hedge && i >= self.config.hedge_lookback {
|
||||
self.calculate_hedge_ratio(
|
||||
&leg1_ohlcv.close[i - self.config.hedge_lookback..=i],
|
||||
&leg2_ohlcv.close[i - self.config.hedge_lookback..=i],
|
||||
)
|
||||
} else {
|
||||
self.config.hedge_ratio
|
||||
};
|
||||
|
||||
// Check for exit
|
||||
if exits[i] {
|
||||
if let Some(pos) = position.take() {
|
||||
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
|
||||
let cost_basis = pos.leg1_cost + pos.leg2_cost;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Return capital
|
||||
cash += pos.leg1_size * leg1_price + pos.leg2_size * leg2_price - fees;
|
||||
|
||||
// Record trades for both legs
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: format!("{}_LEG1", signals.symbol),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: i,
|
||||
entry_price: pos.leg1_entry_price,
|
||||
exit_price: leg1_price,
|
||||
size: pos.leg1_size,
|
||||
direction: pos.leg1_direction,
|
||||
pnl: pnl / 2.0, // Split P&L attribution
|
||||
return_pct: return_pct / 2.0,
|
||||
entry_time: leg1_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: leg1_ohlcv.timestamps[i],
|
||||
fees: fees / 2.0,
|
||||
exit_reason: ExitReason::Signal,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: format!("{}_LEG2", signals.symbol),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: i,
|
||||
entry_price: pos.leg2_entry_price,
|
||||
exit_price: leg2_price,
|
||||
size: pos.leg2_size,
|
||||
direction: pos.leg2_direction,
|
||||
pnl: pnl / 2.0,
|
||||
return_pct: return_pct / 2.0,
|
||||
entry_time: leg2_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: leg2_ohlcv.timestamps[i],
|
||||
fees: fees / 2.0,
|
||||
exit_reason: ExitReason::Signal,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entry
|
||||
if entries[i] && position.is_none() {
|
||||
// Determine direction from signal direction
|
||||
let (leg1_dir, leg2_dir) = match signals.direction {
|
||||
Direction::Long => (Direction::Long, Direction::Short),
|
||||
Direction::Short => (Direction::Short, Direction::Long),
|
||||
};
|
||||
|
||||
// Calculate position sizes
|
||||
let allocation = cash * 0.5; // Use 50% per leg
|
||||
let leg1_size = allocation / leg1_price;
|
||||
let leg2_size = (allocation * hedge_ratio) / leg2_price;
|
||||
|
||||
let leg1_cost = leg1_size * leg1_price;
|
||||
let leg2_cost = leg2_size * leg2_price;
|
||||
let entry_fees = self.fee_model.calculate(leg1_price, leg1_size, leg1_dir)
|
||||
+ self.fee_model.calculate(leg2_price, leg2_size, leg2_dir);
|
||||
|
||||
cash -= leg1_cost + leg2_cost + entry_fees;
|
||||
|
||||
position = Some(PairsPosition {
|
||||
entry_idx: i,
|
||||
leg1_entry_price: leg1_price,
|
||||
leg2_entry_price: leg2_price,
|
||||
leg1_size,
|
||||
leg2_size,
|
||||
leg1_direction: leg1_dir,
|
||||
leg2_direction: leg2_dir,
|
||||
leg1_cost,
|
||||
leg2_cost,
|
||||
hedge_ratio,
|
||||
});
|
||||
}
|
||||
|
||||
// Update equity
|
||||
let position_value = if let Some(ref pos) = position {
|
||||
let _leg1_value = pos.leg1_size * leg1_price;
|
||||
let _leg2_value = pos.leg2_size * leg2_price;
|
||||
|
||||
// For pairs, value is long leg - short leg + cash equivalent
|
||||
let leg1_pnl = (leg1_price - pos.leg1_entry_price)
|
||||
* pos.leg1_size
|
||||
* pos.leg1_direction.multiplier();
|
||||
let leg2_pnl = (leg2_price - pos.leg2_entry_price)
|
||||
* pos.leg2_size
|
||||
* pos.leg2_direction.multiplier();
|
||||
|
||||
pos.leg1_cost + pos.leg2_cost + leg1_pnl + leg2_pnl
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let equity = cash + position_value;
|
||||
equity_curve[i] = equity;
|
||||
|
||||
// Update drawdown
|
||||
if equity > peak_equity {
|
||||
peak_equity = equity;
|
||||
}
|
||||
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
|
||||
|
||||
// Calculate return
|
||||
if i > 0 {
|
||||
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining position
|
||||
if let Some(pos) = position.take() {
|
||||
let last_idx = n - 1;
|
||||
let leg1_price = leg1_ohlcv.close[last_idx];
|
||||
let leg2_price = leg2_ohlcv.close[last_idx];
|
||||
|
||||
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
|
||||
let cost_basis = pos.leg1_cost + pos.leg2_cost;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: last_idx,
|
||||
entry_price: pos.leg1_entry_price,
|
||||
exit_price: leg1_price,
|
||||
size: pos.leg1_size + pos.leg2_size,
|
||||
direction: pos.leg1_direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: leg1_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: leg1_ohlcv.timestamps[last_idx],
|
||||
fees,
|
||||
exit_reason: ExitReason::EndOfData,
|
||||
});
|
||||
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
|
||||
|
||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||
}
|
||||
|
||||
/// Calculate hedge ratio using OLS regression.
|
||||
fn calculate_hedge_ratio(&self, leg1_prices: &[f64], leg2_prices: &[f64]) -> f64 {
|
||||
let n = leg1_prices.len() as f64;
|
||||
if n < 2.0 {
|
||||
return self.config.hedge_ratio;
|
||||
}
|
||||
|
||||
let sum_x: f64 = leg2_prices.iter().sum();
|
||||
let sum_y: f64 = leg1_prices.iter().sum();
|
||||
let sum_xy: f64 = leg1_prices
|
||||
.iter()
|
||||
.zip(leg2_prices.iter())
|
||||
.map(|(y, x)| x * y)
|
||||
.sum();
|
||||
let sum_x2: f64 = leg2_prices.iter().map(|x| x * x).sum();
|
||||
|
||||
let denominator = n * sum_x2 - sum_x * sum_x;
|
||||
if denominator.abs() < 1e-10 {
|
||||
return self.config.hedge_ratio;
|
||||
}
|
||||
|
||||
let beta = (n * sum_xy - sum_x * sum_y) / denominator;
|
||||
beta.max(0.1).min(10.0) // Constrain to reasonable range
|
||||
}
|
||||
|
||||
/// Close position and calculate P&L.
|
||||
fn close_position(
|
||||
&self,
|
||||
position: &PairsPosition,
|
||||
leg1_price: f64,
|
||||
leg2_price: f64,
|
||||
) -> (f64, f64) {
|
||||
let leg1_pnl = (leg1_price - position.leg1_entry_price)
|
||||
* position.leg1_size
|
||||
* position.leg1_direction.multiplier();
|
||||
|
||||
let leg2_pnl = (leg2_price - position.leg2_entry_price)
|
||||
* position.leg2_size
|
||||
* position.leg2_direction.multiplier();
|
||||
|
||||
let exit_fees =
|
||||
self.fee_model
|
||||
.calculate(leg1_price, position.leg1_size, position.leg1_direction)
|
||||
+ self
|
||||
.fee_model
|
||||
.calculate(leg2_price, position.leg2_size, position.leg2_direction);
|
||||
|
||||
let total_pnl = leg1_pnl + leg2_pnl - exit_fees;
|
||||
|
||||
(total_pnl, exit_fees)
|
||||
}
|
||||
|
||||
/// Calculate metrics.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
trades: &[Trade],
|
||||
streaming: &StreamingMetrics,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = self.config.base.initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
// For pairs, count trade pairs (every 2 trades = 1 round trip)
|
||||
let total_trades = trades.len() / 2;
|
||||
let winning_trades = trades
|
||||
.chunks(2)
|
||||
.filter(|chunk| chunk.iter().map(|t| t.pnl).sum::<f64>() > 0.0)
|
||||
.count();
|
||||
let losing_trades = total_trades.saturating_sub(winning_trades);
|
||||
|
||||
let win_rate_pct = if total_trades > 0 {
|
||||
winning_trades as f64 / total_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||
let gross_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio: streaming.sharpe_ratio(252.0),
|
||||
sortino_ratio: streaming.sortino_ratio(252.0),
|
||||
calmar_ratio: if max_drawdown_pct > 0.0 {
|
||||
total_return_pct / max_drawdown_pct
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
max_drawdown_pct,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
total_trades,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal pairs position state.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PairsPosition {
|
||||
entry_idx: usize,
|
||||
leg1_entry_price: f64,
|
||||
leg2_entry_price: f64,
|
||||
leg1_size: f64,
|
||||
leg2_size: f64,
|
||||
leg1_direction: Direction,
|
||||
leg2_direction: Direction,
|
||||
leg1_cost: f64,
|
||||
leg2_cost: f64,
|
||||
#[allow(dead_code)]
|
||||
hedge_ratio: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_pairs_data() -> (OhlcvData, OhlcvData, CompiledSignals) {
|
||||
let n = 20;
|
||||
|
||||
// Leg 1: Trending up
|
||||
let leg1 = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (100..100 + n).map(|x| x as f64).collect(),
|
||||
high: (101..101 + n).map(|x| x as f64).collect(),
|
||||
low: (99..99 + n).map(|x| x as f64).collect(),
|
||||
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
|
||||
volume: vec![1000.0; n],
|
||||
};
|
||||
|
||||
// Leg 2: Correlated but with different magnitude
|
||||
let leg2 = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (50..50 + n).map(|x| x as f64).collect(),
|
||||
high: (51..51 + n).map(|x| x as f64).collect(),
|
||||
low: (49..49 + n).map(|x| x as f64).collect(),
|
||||
close: (50..50 + n).map(|x| x as f64 + 0.2).collect(),
|
||||
volume: vec![2000.0; n],
|
||||
};
|
||||
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
entries[2] = true;
|
||||
exits[10] = true;
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "PAIR".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long, // Long leg1, short leg2
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
(leg1, leg2, signals)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pairs_backtest() {
|
||||
let config = PairsConfig::default();
|
||||
let backtest = PairsBacktest::new(config);
|
||||
let (leg1, leg2, signals) = sample_pairs_data();
|
||||
|
||||
let result = backtest.run(&leg1, &leg2, &signals);
|
||||
|
||||
// Should have trades for both legs
|
||||
assert!(result.trades.len() >= 2);
|
||||
assert_eq!(result.equity_curve.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hedge_ratio_calculation() {
|
||||
let config = PairsConfig {
|
||||
dynamic_hedge: true,
|
||||
hedge_lookback: 5,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = PairsBacktest::new(config);
|
||||
|
||||
let leg1 = vec![100.0, 102.0, 104.0, 106.0, 108.0];
|
||||
let leg2 = vec![50.0, 51.0, 52.0, 53.0, 54.0];
|
||||
|
||||
let ratio = backtest.calculate_hedge_ratio(&leg1, &leg2);
|
||||
|
||||
// Ratio should be approximately 2 (leg1 moves 2x leg2)
|
||||
assert!(ratio > 1.5 && ratio < 2.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Single instrument backtest implementation.
|
||||
|
||||
use crate::core::types::{BacktestConfig, BacktestResult, CompiledSignals, OhlcvData};
|
||||
use crate::portfolio::engine::PortfolioEngine;
|
||||
|
||||
/// Single instrument backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct SingleBacktest {
|
||||
/// Portfolio engine.
|
||||
engine: PortfolioEngine,
|
||||
}
|
||||
|
||||
impl SingleBacktest {
|
||||
/// Create a new single instrument backtest.
|
||||
pub fn new(config: BacktestConfig) -> Self {
|
||||
Self {
|
||||
engine: PortfolioEngine::new(config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the backtest.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ohlcv` - OHLCV price data
|
||||
/// * `signals` - Compiled trading signals
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result with metrics, trades, and equity curve
|
||||
pub fn run(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
|
||||
self.engine.run_single(ohlcv, signals)
|
||||
}
|
||||
|
||||
/// Run backtest from raw arrays.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `timestamps` - Timestamp array
|
||||
/// * `open` - Open prices
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `volume` - Volume
|
||||
/// * `entries` - Entry signals
|
||||
/// * `exits` - Exit signals
|
||||
/// * `direction` - Trade direction (1 = long, -1 = short)
|
||||
/// * `symbol` - Symbol name
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run_from_arrays(
|
||||
&self,
|
||||
timestamps: &[i64],
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
entries: &[bool],
|
||||
exits: &[bool],
|
||||
direction: i32,
|
||||
symbol: &str,
|
||||
) -> BacktestResult {
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: timestamps.to_vec(),
|
||||
open: open.to_vec(),
|
||||
high: high.to_vec(),
|
||||
low: low.to_vec(),
|
||||
close: close.to_vec(),
|
||||
volume: volume.to_vec(),
|
||||
};
|
||||
|
||||
let dir = crate::core::types::Direction::from_int(direction)
|
||||
.unwrap_or(crate::core::types::Direction::Long);
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: symbol.to_string(),
|
||||
entries: entries.to_vec(),
|
||||
exits: exits.to_vec(),
|
||||
position_sizes: None,
|
||||
direction: dir,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
self.run(&ohlcv, &signals)
|
||||
}
|
||||
|
||||
/// Run backtest with position sizing.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ohlcv` - OHLCV price data
|
||||
/// * `signals` - Compiled trading signals
|
||||
/// * `position_sizes` - Position size for each bar (fraction of capital)
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run_with_sizing(
|
||||
&self,
|
||||
ohlcv: &OhlcvData,
|
||||
signals: &CompiledSignals,
|
||||
position_sizes: Vec<f64>,
|
||||
) -> BacktestResult {
|
||||
let mut signals_with_sizing = signals.clone();
|
||||
signals_with_sizing.position_sizes = Some(position_sizes);
|
||||
self.engine.run_single(ohlcv, &signals_with_sizing)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::types::{Direction, StopConfig, TargetConfig};
|
||||
|
||||
fn sample_data() -> (OhlcvData, CompiledSignals) {
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: (0..20).map(|i| i as i64).collect(),
|
||||
open: vec![
|
||||
100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0,
|
||||
102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0,
|
||||
],
|
||||
high: vec![
|
||||
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0,
|
||||
103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
|
||||
],
|
||||
low: vec![
|
||||
99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0,
|
||||
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0,
|
||||
],
|
||||
close: vec![
|
||||
100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5,
|
||||
102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5,
|
||||
],
|
||||
volume: vec![1000.0; 20],
|
||||
};
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: vec![
|
||||
false, true, false, false, false, false, false, false, false, false, false, true,
|
||||
false, false, false, false, false, false, false, false,
|
||||
],
|
||||
exits: vec![
|
||||
false, false, false, false, false, true, false, false, false, false, false, false,
|
||||
false, false, false, true, false, false, false, false,
|
||||
],
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
(ohlcv, signals)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_backtest() {
|
||||
let config = BacktestConfig {
|
||||
initial_capital: 100_000.0,
|
||||
fees: 0.0,
|
||||
slippage: 0.0,
|
||||
stop: StopConfig::None,
|
||||
target: TargetConfig::None,
|
||||
upon_bar_close: true,
|
||||
};
|
||||
|
||||
let backtest = SingleBacktest::new(config);
|
||||
let (ohlcv, signals) = sample_data();
|
||||
|
||||
let result = backtest.run(&ohlcv, &signals);
|
||||
|
||||
assert_eq!(result.trades.len(), 2);
|
||||
assert!(result.metrics.total_return_pct > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_arrays() {
|
||||
let config = BacktestConfig::default();
|
||||
let backtest = SingleBacktest::new(config);
|
||||
|
||||
let timestamps: Vec<i64> = (0..10).collect();
|
||||
let close: Vec<f64> = (100..110).map(|x| x as f64).collect();
|
||||
let open = close.clone();
|
||||
let high: Vec<f64> = close.iter().map(|x| x + 1.0).collect();
|
||||
let low: Vec<f64> = close.iter().map(|x| x - 1.0).collect();
|
||||
let volume = vec![1000.0; 10];
|
||||
|
||||
let entries = vec![
|
||||
false, true, false, false, false, false, false, false, false, false,
|
||||
];
|
||||
let exits = vec![
|
||||
false, false, false, false, false, true, false, false, false, false,
|
||||
];
|
||||
|
||||
let result = backtest.run_from_arrays(
|
||||
×tamps,
|
||||
&open,
|
||||
&high,
|
||||
&low,
|
||||
&close,
|
||||
&volume,
|
||||
&entries,
|
||||
&exits,
|
||||
1,
|
||||
"TEST",
|
||||
);
|
||||
|
||||
assert_eq!(result.trades.len(), 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user