Initial backtesting engine

This commit is contained in:
porcelaincode
2026-01-28 06:30:03 +05:30
commit f6c60d7b8b
53 changed files with 13632 additions and 0 deletions
+480
View File
@@ -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());
}
}
+13
View File
@@ -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;
+412
View File
@@ -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);
}
}
+450
View File
@@ -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);
}
}
+481
View File
@@ -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);
}
}
+206
View File
@@ -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(
&timestamps,
&open,
&high,
&low,
&close,
&volume,
&entries,
&exits,
1,
"TEST",
);
assert_eq!(result.trades.len(), 1);
}
}