ec9bf0410f
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2124 lines
68 KiB
Rust
2124 lines
68 KiB
Rust
//! Pure Rust backtest engine — no PyO3, no numpy dependency.
|
||
//!
|
||
//! This module contains all backtest logic as pure functions operating on
|
||
//! `&[f64]` slices. The PyO3 binding crate provides thin wrappers that
|
||
//! convert NumPy arrays to slices and call into this module.
|
||
|
||
use crate::commission::CommissionModel;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Utility helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Replace NaN → 0, +Inf → f64::MAX, −Inf → −f64::MAX (mirrors numpy nan_to_num defaults).
|
||
#[inline]
|
||
pub fn nan_to_num(v: f64) -> f64 {
|
||
if v.is_nan() {
|
||
0.0
|
||
} else if v.is_infinite() {
|
||
if v.is_sign_positive() {
|
||
f64::MAX
|
||
} else {
|
||
-f64::MAX
|
||
}
|
||
} else {
|
||
v
|
||
}
|
||
}
|
||
|
||
/// Kelly criterion formula: f = win_rate − (1 − win_rate) × (|avg_loss| / avg_win), clamped to [0, 1].
|
||
#[inline]
|
||
pub fn kelly_formula(win_rate: f64, avg_win: f64, avg_loss: f64) -> f64 {
|
||
if avg_win <= 0.0 {
|
||
return 0.0;
|
||
}
|
||
let f = win_rate - (1.0 - win_rate) * (avg_loss.abs() / avg_win);
|
||
f.clamp(0.0, 1.0)
|
||
}
|
||
|
||
/// Deterministic LCG (Knuth MMIX).
|
||
#[inline]
|
||
pub fn lcg_next(state: &mut u64) -> u64 {
|
||
*state = state
|
||
.wrapping_mul(6_364_136_223_846_793_005_u64)
|
||
.wrapping_add(1_442_695_040_888_963_407_u64);
|
||
*state
|
||
}
|
||
|
||
#[inline]
|
||
pub fn lcg_index(state: &mut u64, n: usize) -> usize {
|
||
((lcg_next(state) >> 11) as usize) % n
|
||
}
|
||
|
||
/// Compute commission cost as a fraction of `initial_capital` for a single execution.
|
||
#[inline]
|
||
pub fn commission_fraction(
|
||
cm: &CommissionModel,
|
||
fill_price: f64,
|
||
position_size: f64,
|
||
is_buy: bool,
|
||
initial_capital: f64,
|
||
) -> f64 {
|
||
if fill_price <= 0.0 || position_size <= 0.0 || initial_capital <= 0.0 {
|
||
return 0.0;
|
||
}
|
||
let trade_value = position_size * fill_price * initial_capital;
|
||
let num_lots = if cm.lot_size > 0.0 {
|
||
(position_size * initial_capital / (cm.lot_size * fill_price)).ceil()
|
||
} else {
|
||
1.0
|
||
};
|
||
cm.cost_fraction(trade_value, num_lots, is_buy, initial_capital)
|
||
}
|
||
|
||
/// Resolve a CommissionModel from an optional reference or backward-compat scalar.
|
||
pub fn resolve_commission_model(
|
||
commission: Option<&CommissionModel>,
|
||
commission_per_trade: f64,
|
||
) -> CommissionModel {
|
||
match commission {
|
||
Some(c) => c.clone(),
|
||
None if commission_per_trade > 0.0 => CommissionModel {
|
||
flat_per_order: commission_per_trade,
|
||
..Default::default()
|
||
},
|
||
None => CommissionModel::default(),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Structs
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Configuration for the OHLCV-aware backtester.
|
||
#[derive(Clone, Debug)]
|
||
pub struct BacktestConfig {
|
||
pub fill_mode: String,
|
||
pub stop_loss_pct: f64,
|
||
pub take_profit_pct: f64,
|
||
pub trailing_stop_pct: f64,
|
||
pub slippage_bps: f64,
|
||
pub initial_capital: f64,
|
||
pub commission_per_trade: f64,
|
||
pub max_hold_bars: usize,
|
||
pub slippage_pct_range: f64,
|
||
pub breakeven_pct: f64,
|
||
pub periods_per_year: f64,
|
||
pub margin_ratio: f64,
|
||
pub margin_call_pct: f64,
|
||
pub daily_loss_limit: f64,
|
||
pub total_loss_limit: f64,
|
||
pub commission: Option<CommissionModel>,
|
||
}
|
||
|
||
impl Default for BacktestConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
fill_mode: "market_open".to_string(),
|
||
stop_loss_pct: 0.0,
|
||
take_profit_pct: 0.0,
|
||
trailing_stop_pct: 0.0,
|
||
slippage_bps: 0.0,
|
||
initial_capital: 100_000.0,
|
||
commission_per_trade: 0.0,
|
||
max_hold_bars: 0,
|
||
slippage_pct_range: 0.0,
|
||
breakeven_pct: 0.0,
|
||
periods_per_year: 252.0,
|
||
margin_ratio: 0.0,
|
||
margin_call_pct: 0.5,
|
||
daily_loss_limit: 0.0,
|
||
total_loss_limit: 0.0,
|
||
commission: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Result of the OHLCV backtest engine.
|
||
#[derive(Clone, Debug)]
|
||
pub struct OhlcvBacktestResult {
|
||
pub positions: Vec<f64>,
|
||
pub fill_prices: Vec<f64>,
|
||
pub bar_returns: Vec<f64>,
|
||
pub strategy_returns: Vec<f64>,
|
||
pub equity: Vec<f64>,
|
||
}
|
||
|
||
/// A single completed trade record.
|
||
#[derive(Clone, Debug)]
|
||
pub struct TradeRecord {
|
||
pub entry_bar: i64,
|
||
pub exit_bar: i64,
|
||
pub direction: f64,
|
||
pub entry_price: f64,
|
||
pub exit_price: f64,
|
||
pub pnl_pct: f64,
|
||
pub duration_bars: i64,
|
||
pub mae: f64,
|
||
pub mfe: f64,
|
||
}
|
||
|
||
/// Comprehensive performance metrics.
|
||
#[derive(Clone, Debug)]
|
||
pub struct BacktestMetrics {
|
||
pub total_return: f64,
|
||
pub cagr: f64,
|
||
pub annualized_vol: f64,
|
||
pub sharpe: f64,
|
||
pub sortino: f64,
|
||
pub calmar: f64,
|
||
pub max_drawdown: f64,
|
||
pub avg_drawdown: f64,
|
||
pub max_drawdown_duration_bars: usize,
|
||
pub avg_drawdown_duration_bars: f64,
|
||
pub ulcer_index: f64,
|
||
pub omega_ratio: f64,
|
||
pub win_rate: f64,
|
||
pub profit_factor: f64,
|
||
pub r_expectancy: f64,
|
||
pub avg_win: f64,
|
||
pub avg_loss: f64,
|
||
pub tail_ratio: f64,
|
||
pub skewness: f64,
|
||
pub kurtosis: f64,
|
||
pub best_bar: f64,
|
||
pub worst_bar: f64,
|
||
pub n_trades: usize,
|
||
pub n_position_changes: usize,
|
||
// Optional benchmark metrics
|
||
pub benchmark_total_return: Option<f64>,
|
||
pub benchmark_cagr: Option<f64>,
|
||
pub benchmark_annualized_vol: Option<f64>,
|
||
pub benchmark_sharpe: Option<f64>,
|
||
pub alpha: Option<f64>,
|
||
pub beta: Option<f64>,
|
||
pub tracking_error: Option<f64>,
|
||
pub information_ratio: Option<f64>,
|
||
}
|
||
|
||
/// Mutable state bundle for the OHLCV backtest loop.
|
||
pub struct OhlcvState {
|
||
pub current_pos: f64,
|
||
pub entry_price: f64,
|
||
pub trail_high: f64,
|
||
pub trail_low: f64,
|
||
pub breakeven_activated: bool,
|
||
pub breakeven_stop: f64,
|
||
pub bars_in_trade: usize,
|
||
pub margin_entry_price: f64,
|
||
pub initial_margin_required: f64,
|
||
}
|
||
|
||
impl OhlcvState {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
current_pos: 0.0,
|
||
entry_price: f64::NAN,
|
||
trail_high: f64::NAN,
|
||
trail_low: f64::NAN,
|
||
breakeven_activated: false,
|
||
breakeven_stop: f64::NAN,
|
||
bars_in_trade: 0,
|
||
margin_entry_price: f64::NAN,
|
||
initial_margin_required: 0.0,
|
||
}
|
||
}
|
||
|
||
#[inline]
|
||
pub fn close_position(&mut self) {
|
||
self.current_pos = 0.0;
|
||
self.entry_price = f64::NAN;
|
||
self.trail_high = f64::NAN;
|
||
self.trail_low = f64::NAN;
|
||
self.breakeven_activated = false;
|
||
self.breakeven_stop = f64::NAN;
|
||
self.bars_in_trade = 0;
|
||
self.margin_entry_price = f64::NAN;
|
||
self.initial_margin_required = 0.0;
|
||
}
|
||
}
|
||
|
||
impl Default for OhlcvState {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
/// Stateful streaming backtester — feed one bar at a time.
|
||
#[derive(Clone, Debug)]
|
||
pub struct StreamingBacktest {
|
||
pub commission_per_trade: f64,
|
||
pub slippage_bps: f64,
|
||
pub position: f64,
|
||
pub entry_price: f64,
|
||
pub equity: f64,
|
||
pub prev_close: f64,
|
||
pub total_commission: f64,
|
||
pub n_trades: usize,
|
||
pub sum_wins: f64,
|
||
pub n_wins: usize,
|
||
pub sum_losses: f64,
|
||
pub n_losses: usize,
|
||
}
|
||
|
||
/// Result of a single `StreamingBacktest::on_bar` call.
|
||
#[derive(Clone, Debug)]
|
||
pub struct StreamingBarResult {
|
||
pub position: f64,
|
||
pub bar_return: f64,
|
||
pub equity: f64,
|
||
pub n_trades: usize,
|
||
}
|
||
|
||
/// Summary statistics from StreamingBacktest.
|
||
#[derive(Clone, Debug)]
|
||
pub struct StreamingSummary {
|
||
pub equity: f64,
|
||
pub n_trades: usize,
|
||
pub total_commission: f64,
|
||
pub win_rate: f64,
|
||
pub avg_win: f64,
|
||
pub avg_loss: f64,
|
||
pub kelly_fraction: f64,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Signal generators
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// RSI threshold strategy: +1 when RSI <= oversold, -1 when RSI >= overbought, 0 otherwise.
|
||
pub fn rsi_threshold_signals(
|
||
close: &[f64],
|
||
timeperiod: usize,
|
||
oversold: f64,
|
||
overbought: f64,
|
||
) -> Vec<f64> {
|
||
let rsi = crate::momentum::rsi(close, timeperiod);
|
||
rsi.iter()
|
||
.map(|&v| {
|
||
if v.is_nan() {
|
||
f64::NAN
|
||
} else if v <= oversold {
|
||
1.0
|
||
} else if v >= overbought {
|
||
-1.0
|
||
} else {
|
||
0.0
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// SMA crossover strategy: +1 when fast SMA > slow SMA, -1 otherwise. Warm-up bars are NaN.
|
||
///
|
||
/// Returns `Err` if `fast >= slow`.
|
||
pub fn sma_crossover_signals(close: &[f64], fast: usize, slow: usize) -> Result<Vec<f64>, String> {
|
||
if fast >= slow {
|
||
return Err(format!("fast ({fast}) must be less than slow ({slow})"));
|
||
}
|
||
let sma_fast = crate::overlap::sma(close, fast);
|
||
let sma_slow = crate::overlap::sma(close, slow);
|
||
Ok(sma_fast
|
||
.iter()
|
||
.zip(sma_slow.iter())
|
||
.map(|(&f, &s)| {
|
||
if f.is_nan() || s.is_nan() {
|
||
f64::NAN
|
||
} else if f > s {
|
||
1.0
|
||
} else {
|
||
-1.0
|
||
}
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// MACD crossover strategy: +1 when MACD line > signal line, -1 otherwise.
|
||
///
|
||
/// Returns `Err` if `fastperiod >= slowperiod`.
|
||
pub fn macd_crossover_signals(
|
||
close: &[f64],
|
||
fastperiod: usize,
|
||
slowperiod: usize,
|
||
signalperiod: usize,
|
||
) -> Result<Vec<f64>, String> {
|
||
if fastperiod >= slowperiod {
|
||
return Err(format!(
|
||
"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})"
|
||
));
|
||
}
|
||
let (macd_line, signal_line, _) =
|
||
crate::overlap::macd(close, fastperiod, slowperiod, signalperiod);
|
||
Ok(macd_line
|
||
.iter()
|
||
.zip(signal_line.iter())
|
||
.map(|(&m, &s)| {
|
||
if m.is_nan() || s.is_nan() {
|
||
f64::NAN
|
||
} else if m > s {
|
||
1.0
|
||
} else {
|
||
-1.0
|
||
}
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Core backtest (close-only)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Backtest result from the simple close-only engine.
|
||
#[derive(Clone, Debug)]
|
||
pub struct BacktestCoreResult {
|
||
pub positions: Vec<f64>,
|
||
pub bar_returns: Vec<f64>,
|
||
pub strategy_returns: Vec<f64>,
|
||
pub equity: Vec<f64>,
|
||
}
|
||
|
||
/// Backtest core loop over close prices and strategy signals.
|
||
///
|
||
/// Uses the full `CommissionModel` if provided, otherwise falls back to
|
||
/// `commission_per_trade` as a flat per-order fee.
|
||
pub fn backtest_core(
|
||
close: &[f64],
|
||
signals: &[f64],
|
||
commission: Option<&CommissionModel>,
|
||
slippage_bps: f64,
|
||
initial_capital: f64,
|
||
commission_per_trade: f64,
|
||
) -> Result<BacktestCoreResult, String> {
|
||
let n = close.len();
|
||
if n != signals.len() {
|
||
return Err(format!(
|
||
"close length ({}) != signals length ({})",
|
||
n,
|
||
signals.len()
|
||
));
|
||
}
|
||
|
||
let mut positions = vec![0.0_f64; n];
|
||
if n > 1 {
|
||
for i in 1..n {
|
||
positions[i] = nan_to_num(signals[i - 1]);
|
||
}
|
||
}
|
||
|
||
let mut bar_returns = vec![0.0_f64; n];
|
||
for i in 1..n {
|
||
bar_returns[i] = (close[i] - close[i - 1]) / close[i - 1];
|
||
}
|
||
|
||
let mut strategy_returns = vec![0.0_f64; n];
|
||
for i in 0..n {
|
||
strategy_returns[i] = positions[i] * bar_returns[i];
|
||
}
|
||
|
||
let mut position_changed = vec![false; n];
|
||
for i in 1..n {
|
||
position_changed[i] = (positions[i] - positions[i - 1]).abs() > 1e-12;
|
||
}
|
||
|
||
if slippage_bps > 0.0 {
|
||
let slip = slippage_bps / 10_000.0;
|
||
for i in 0..n {
|
||
if position_changed[i] {
|
||
strategy_returns[i] -= slip;
|
||
}
|
||
}
|
||
}
|
||
|
||
let cm = resolve_commission_model(commission, commission_per_trade);
|
||
|
||
let mut equity = vec![1.0_f64; n];
|
||
let mut cum = 1.0_f64;
|
||
for i in 0..n {
|
||
cum *= 1.0 + strategy_returns[i];
|
||
if position_changed[i] {
|
||
let prev_pos = if i > 0 { positions[i - 1] } else { 0.0 };
|
||
let cost = commission_fraction(
|
||
&cm,
|
||
if close[i] != 0.0 { close[i] } else { 1.0 },
|
||
(positions[i] - prev_pos).abs(),
|
||
positions[i] > prev_pos,
|
||
initial_capital,
|
||
);
|
||
cum -= cost;
|
||
}
|
||
equity[i] = cum;
|
||
}
|
||
|
||
Ok(BacktestCoreResult {
|
||
positions,
|
||
bar_returns,
|
||
strategy_returns,
|
||
equity,
|
||
})
|
||
}
|
||
|
||
/// Core single-asset loop reused by multi-asset backtest.
|
||
pub fn single_asset_backtest(
|
||
close: &[f64],
|
||
signals: &[f64],
|
||
commission_per_trade: f64,
|
||
slippage_bps: f64,
|
||
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||
let n = close.len();
|
||
let slip = slippage_bps / 10_000.0;
|
||
|
||
let mut positions = vec![0.0_f64; n];
|
||
for i in 1..n {
|
||
positions[i] = nan_to_num(signals[i - 1]);
|
||
}
|
||
|
||
let mut bar_returns = vec![0.0_f64; n];
|
||
for i in 1..n {
|
||
if close[i - 1] != 0.0 {
|
||
bar_returns[i] = (close[i] - close[i - 1]) / close[i - 1];
|
||
}
|
||
}
|
||
|
||
let mut strategy_returns = vec![0.0_f64; n];
|
||
for i in 0..n {
|
||
strategy_returns[i] = positions[i] * bar_returns[i];
|
||
}
|
||
|
||
let mut position_changed = vec![false; n];
|
||
for i in 1..n {
|
||
position_changed[i] = (positions[i] - positions[i - 1]).abs() > 1e-12;
|
||
}
|
||
|
||
if slip > 0.0 {
|
||
for i in 0..n {
|
||
if position_changed[i] {
|
||
strategy_returns[i] -= slip;
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut equity = vec![1.0_f64; n];
|
||
if commission_per_trade <= 0.0 {
|
||
let mut g = 1.0_f64;
|
||
for i in 0..n {
|
||
g *= 1.0 + strategy_returns[i];
|
||
equity[i] = g;
|
||
}
|
||
} else {
|
||
let mut gross = vec![1.0_f64; n];
|
||
let mut g = 1.0_f64;
|
||
for i in 0..n {
|
||
g *= 1.0 + strategy_returns[i];
|
||
gross[i] = g;
|
||
}
|
||
let has_zero = gross.contains(&0.0);
|
||
if has_zero {
|
||
equity[0] = 1.0;
|
||
for i in 1..n {
|
||
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]);
|
||
if position_changed[i] {
|
||
equity[i] -= commission_per_trade;
|
||
}
|
||
}
|
||
} else {
|
||
let mut disc = 0.0_f64;
|
||
for i in 0..n {
|
||
if position_changed[i] {
|
||
disc += commission_per_trade / gross[i];
|
||
}
|
||
equity[i] = gross[i] * (1.0 - disc);
|
||
}
|
||
}
|
||
}
|
||
|
||
(positions, strategy_returns, equity)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// OHLCV-aware backtest engine
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Full OHLCV backtest with stop loss, take profit, trailing stops, breakeven,
|
||
/// margin calls, circuit breakers, limit orders, and short borrow costs.
|
||
pub fn backtest_ohlcv_core(
|
||
open: &[f64],
|
||
high: &[f64],
|
||
low: &[f64],
|
||
close: &[f64],
|
||
signals: &[f64],
|
||
config: &BacktestConfig,
|
||
limit_prices: Option<&[f64]>,
|
||
) -> Result<OhlcvBacktestResult, String> {
|
||
let n = close.len();
|
||
if n < 2 {
|
||
return Err("arrays must have at least 2 elements".to_string());
|
||
}
|
||
if open.len() != n || high.len() != n || low.len() != n || signals.len() != n {
|
||
return Err(format!(
|
||
"all arrays must have equal length (close={}), got open={}, high={}, low={}, signals={}",
|
||
n,
|
||
open.len(),
|
||
high.len(),
|
||
low.len(),
|
||
signals.len()
|
||
));
|
||
}
|
||
|
||
let use_open_fill = config.fill_mode != "market_close";
|
||
let cm = resolve_commission_model(config.commission.as_ref(), config.commission_per_trade);
|
||
|
||
let stop_loss_pct = config.stop_loss_pct;
|
||
let take_profit_pct = config.take_profit_pct;
|
||
let trailing_stop_pct = config.trailing_stop_pct;
|
||
let slippage_bps = config.slippage_bps;
|
||
let initial_capital = config.initial_capital;
|
||
let max_hold_bars = config.max_hold_bars;
|
||
let slippage_pct_range = config.slippage_pct_range;
|
||
let breakeven_pct = config.breakeven_pct;
|
||
let periods_per_year = config.periods_per_year;
|
||
let margin_ratio = config.margin_ratio;
|
||
let margin_call_pct = config.margin_call_pct;
|
||
let daily_loss_limit = config.daily_loss_limit;
|
||
let total_loss_limit = config.total_loss_limit;
|
||
|
||
let mut positions = vec![0.0_f64; n];
|
||
let mut fill_prices = vec![f64::NAN; n];
|
||
let mut bar_returns = vec![0.0_f64; n];
|
||
let mut strategy_returns = vec![0.0_f64; n];
|
||
|
||
let mut st = OhlcvState::new();
|
||
let default_slip = slippage_bps / 10_000.0;
|
||
|
||
let mut circuit_broken: bool = false;
|
||
let mut running_equity: f64 = 1.0;
|
||
|
||
for i in 1..n {
|
||
let pos_start = st.current_pos;
|
||
let desired_pos = nan_to_num(signals[i - 1]);
|
||
|
||
// --- Margin call check (at bar open) ---
|
||
if margin_ratio > 0.0
|
||
&& st.current_pos != 0.0
|
||
&& !st.margin_entry_price.is_nan()
|
||
&& st.initial_margin_required > 0.0
|
||
{
|
||
let position_pnl =
|
||
st.current_pos * (open[i] - st.margin_entry_price) / st.margin_entry_price;
|
||
let margin_equity = st.initial_margin_required + position_pnl;
|
||
if margin_equity <= margin_call_pct * st.initial_margin_required {
|
||
let mc_fill = open[i];
|
||
let mc_ret = if close[i - 1] != 0.0 {
|
||
st.current_pos * (mc_fill - close[i - 1]) / close[i - 1]
|
||
} else {
|
||
0.0
|
||
};
|
||
let comm = commission_fraction(
|
||
&cm,
|
||
mc_fill,
|
||
st.current_pos.abs(),
|
||
st.current_pos < 0.0,
|
||
initial_capital,
|
||
);
|
||
strategy_returns[i] = mc_ret - comm;
|
||
fill_prices[i] = mc_fill;
|
||
st.close_position();
|
||
positions[i] = 0.0;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
let slip: f64 = if slippage_pct_range > 0.0 && close[i] > 0.0 {
|
||
slippage_pct_range * (high[i] - low[i]) / close[i]
|
||
} else {
|
||
default_slip
|
||
};
|
||
|
||
if trailing_stop_pct > 0.0 {
|
||
if st.current_pos > 0.0 && !st.trail_high.is_nan() {
|
||
st.trail_high = st.trail_high.max(high[i]);
|
||
}
|
||
if st.current_pos < 0.0 && !st.trail_low.is_nan() {
|
||
st.trail_low = st.trail_low.min(low[i]);
|
||
}
|
||
}
|
||
|
||
let close_ret = if close[i - 1] != 0.0 {
|
||
(close[i] - close[i - 1]) / close[i - 1]
|
||
} else {
|
||
0.0
|
||
};
|
||
bar_returns[i] = close_ret;
|
||
|
||
let mut forced_close = false;
|
||
|
||
// --- Circuit breaker check ---
|
||
if i > 1 {
|
||
running_equity *= 1.0 + strategy_returns[i - 1];
|
||
}
|
||
if !circuit_broken {
|
||
if daily_loss_limit > 0.0 && i > 1 && strategy_returns[i - 1] < -daily_loss_limit {
|
||
circuit_broken = true;
|
||
}
|
||
if total_loss_limit > 0.0 && running_equity < 1.0 - total_loss_limit {
|
||
circuit_broken = true;
|
||
}
|
||
}
|
||
if circuit_broken && st.current_pos != 0.0 {
|
||
let base_fill = if use_open_fill { open[i] } else { close[i] };
|
||
let is_buy = st.current_pos < 0.0;
|
||
let close_r = if close[i - 1] != 0.0 {
|
||
st.current_pos * (base_fill - close[i - 1]) / close[i - 1]
|
||
} else {
|
||
0.0
|
||
};
|
||
let comm = commission_fraction(
|
||
&cm,
|
||
base_fill,
|
||
st.current_pos.abs(),
|
||
is_buy,
|
||
initial_capital,
|
||
);
|
||
strategy_returns[i] = close_r - comm;
|
||
fill_prices[i] = base_fill;
|
||
st.close_position();
|
||
positions[i] = 0.0;
|
||
forced_close = true;
|
||
}
|
||
if circuit_broken {
|
||
positions[i] = 0.0;
|
||
continue;
|
||
}
|
||
|
||
// ---- Intrabar trailing stop check ----
|
||
if trailing_stop_pct > 0.0 && st.current_pos != 0.0 && !st.entry_price.is_nan() {
|
||
if st.current_pos > 0.0 && !st.trail_high.is_nan() {
|
||
let trail_stop = st.trail_high * (1.0 - trailing_stop_pct);
|
||
if low[i] <= trail_stop {
|
||
let stop_ret = if close[i - 1] != 0.0 {
|
||
(trail_stop - close[i - 1]) / close[i - 1]
|
||
} else {
|
||
-trailing_stop_pct
|
||
};
|
||
let comm = commission_fraction(
|
||
&cm,
|
||
trail_stop,
|
||
st.current_pos.abs(),
|
||
false,
|
||
initial_capital,
|
||
);
|
||
strategy_returns[i] = st.current_pos * stop_ret - slip - comm;
|
||
fill_prices[i] = trail_stop;
|
||
st.close_position();
|
||
positions[i] = 0.0;
|
||
forced_close = true;
|
||
}
|
||
} else if st.current_pos < 0.0 && !st.trail_low.is_nan() {
|
||
let trail_stop = st.trail_low * (1.0 + trailing_stop_pct);
|
||
if high[i] >= trail_stop {
|
||
let stop_ret = if close[i - 1] != 0.0 {
|
||
(trail_stop - close[i - 1]) / close[i - 1]
|
||
} else {
|
||
trailing_stop_pct
|
||
};
|
||
let comm = commission_fraction(
|
||
&cm,
|
||
trail_stop,
|
||
st.current_pos.abs(),
|
||
true,
|
||
initial_capital,
|
||
);
|
||
strategy_returns[i] = st.current_pos * stop_ret - slip - comm;
|
||
fill_prices[i] = trail_stop;
|
||
st.close_position();
|
||
positions[i] = 0.0;
|
||
forced_close = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Breakeven stop activation ----
|
||
if breakeven_pct > 0.0
|
||
&& st.current_pos != 0.0
|
||
&& !st.entry_price.is_nan()
|
||
&& !st.breakeven_activated
|
||
{
|
||
let condition_met = if st.current_pos > 0.0 {
|
||
high[i] >= st.entry_price * (1.0 + breakeven_pct)
|
||
} else {
|
||
low[i] <= st.entry_price * (1.0 - breakeven_pct)
|
||
};
|
||
if condition_met {
|
||
st.breakeven_activated = true;
|
||
st.breakeven_stop = st.entry_price;
|
||
}
|
||
}
|
||
|
||
// ---- Intrabar SL/TP combined bracket check ----
|
||
{
|
||
let has_stop = st.breakeven_activated || stop_loss_pct > 0.0;
|
||
let stop_long = if st.breakeven_activated {
|
||
st.breakeven_stop
|
||
} else {
|
||
st.entry_price * (1.0 - stop_loss_pct)
|
||
};
|
||
let stop_short = if st.breakeven_activated {
|
||
st.breakeven_stop
|
||
} else {
|
||
st.entry_price * (1.0 + stop_loss_pct)
|
||
};
|
||
let has_tp = take_profit_pct > 0.0;
|
||
let tp_long = st.entry_price * (1.0 + take_profit_pct);
|
||
let tp_short = st.entry_price * (1.0 - take_profit_pct);
|
||
|
||
if !forced_close && st.current_pos != 0.0 && !st.entry_price.is_nan() {
|
||
let (exit_price, did_exit) = if st.current_pos > 0.0 {
|
||
let sl_hit = has_stop && low[i] <= stop_long;
|
||
let tp_hit = has_tp && high[i] >= tp_long;
|
||
match (sl_hit, tp_hit) {
|
||
(true, true) => {
|
||
if (open[i] - stop_long).abs() < (tp_long - open[i]).abs() {
|
||
(stop_long, true)
|
||
} else {
|
||
(tp_long, true)
|
||
}
|
||
}
|
||
(true, false) => (stop_long, true),
|
||
(false, true) => (tp_long, true),
|
||
_ => (0.0, false),
|
||
}
|
||
} else {
|
||
let sl_hit = has_stop && high[i] >= stop_short;
|
||
let tp_hit = has_tp && low[i] <= tp_short;
|
||
match (sl_hit, tp_hit) {
|
||
(true, true) => {
|
||
if (stop_short - open[i]).abs() < (open[i] - tp_short).abs() {
|
||
(stop_short, true)
|
||
} else {
|
||
(tp_short, true)
|
||
}
|
||
}
|
||
(true, false) => (stop_short, true),
|
||
(false, true) => (tp_short, true),
|
||
_ => (0.0, false),
|
||
}
|
||
};
|
||
|
||
if did_exit {
|
||
let exit_ret = if close[i - 1] != 0.0 {
|
||
(exit_price - close[i - 1]) / close[i - 1]
|
||
} else {
|
||
0.0
|
||
};
|
||
let is_buy = st.current_pos < 0.0;
|
||
let comm = commission_fraction(
|
||
&cm,
|
||
exit_price,
|
||
st.current_pos.abs(),
|
||
is_buy,
|
||
initial_capital,
|
||
);
|
||
strategy_returns[i] = st.current_pos * exit_ret - slip - comm;
|
||
fill_prices[i] = exit_price;
|
||
st.close_position();
|
||
positions[i] = 0.0;
|
||
forced_close = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Time-based exit check ----
|
||
if !forced_close
|
||
&& max_hold_bars > 0
|
||
&& st.current_pos != 0.0
|
||
&& st.bars_in_trade >= max_hold_bars
|
||
{
|
||
let base_fill = if use_open_fill { open[i] } else { close[i] };
|
||
let is_buy = st.current_pos < 0.0;
|
||
let actual_fill = if is_buy {
|
||
base_fill * (1.0 + slip)
|
||
} else {
|
||
base_fill * (1.0 - slip)
|
||
};
|
||
let exit_ret = if close[i - 1] != 0.0 {
|
||
st.current_pos * (actual_fill - close[i - 1]) / close[i - 1]
|
||
} else {
|
||
0.0
|
||
};
|
||
let comm = commission_fraction(
|
||
&cm,
|
||
actual_fill,
|
||
st.current_pos.abs(),
|
||
is_buy,
|
||
initial_capital,
|
||
);
|
||
strategy_returns[i] = exit_ret - comm;
|
||
fill_prices[i] = actual_fill;
|
||
st.close_position();
|
||
positions[i] = 0.0;
|
||
forced_close = true;
|
||
}
|
||
|
||
if !forced_close {
|
||
// ---- Limit order check ----
|
||
let raw_change = (desired_pos - st.current_pos).abs() > 1e-12;
|
||
let (effective_desired_pos, limit_override_price): (f64, Option<f64>) = if raw_change {
|
||
match limit_prices {
|
||
Some(lp) => {
|
||
let lp_val = lp[i - 1];
|
||
if lp_val.is_nan() {
|
||
(desired_pos, None)
|
||
} else {
|
||
let is_buy = desired_pos > st.current_pos;
|
||
if (is_buy && low[i] <= lp_val) || (!is_buy && high[i] >= lp_val) {
|
||
(desired_pos, Some(lp_val))
|
||
} else {
|
||
(st.current_pos, None)
|
||
}
|
||
}
|
||
}
|
||
None => (desired_pos, None),
|
||
}
|
||
} else {
|
||
(desired_pos, None)
|
||
};
|
||
|
||
let pos_changed = (effective_desired_pos - st.current_pos).abs() > 1e-12;
|
||
let base_fill_raw = if use_open_fill { open[i] } else { close[i] };
|
||
let base_fill = limit_override_price.unwrap_or(base_fill_raw);
|
||
|
||
let actual_fill = if effective_desired_pos > st.current_pos {
|
||
base_fill * (1.0 + slip)
|
||
} else if effective_desired_pos < st.current_pos {
|
||
base_fill * (1.0 - slip)
|
||
} else {
|
||
base_fill
|
||
};
|
||
|
||
if pos_changed {
|
||
fill_prices[i] = actual_fill;
|
||
if effective_desired_pos != 0.0 {
|
||
st.entry_price = actual_fill;
|
||
if trailing_stop_pct > 0.0 {
|
||
if effective_desired_pos > 0.0 {
|
||
st.trail_high = actual_fill;
|
||
st.trail_low = f64::NAN;
|
||
} else {
|
||
st.trail_low = actual_fill;
|
||
st.trail_high = f64::NAN;
|
||
}
|
||
}
|
||
} else {
|
||
st.entry_price = f64::NAN;
|
||
st.trail_high = f64::NAN;
|
||
st.trail_low = f64::NAN;
|
||
st.breakeven_activated = false;
|
||
st.breakeven_stop = f64::NAN;
|
||
}
|
||
if effective_desired_pos != 0.0
|
||
&& st.current_pos != 0.0
|
||
&& (effective_desired_pos.signum() != st.current_pos.signum())
|
||
{
|
||
st.breakeven_activated = false;
|
||
st.breakeven_stop = f64::NAN;
|
||
}
|
||
}
|
||
|
||
strategy_returns[i] = if pos_changed && use_open_fill && actual_fill != 0.0 {
|
||
if effective_desired_pos != 0.0 && st.current_pos == 0.0 {
|
||
let r = effective_desired_pos * (close[i] - actual_fill) / actual_fill;
|
||
let comm = commission_fraction(
|
||
&cm,
|
||
actual_fill,
|
||
effective_desired_pos.abs(),
|
||
effective_desired_pos > 0.0,
|
||
initial_capital,
|
||
);
|
||
r - comm
|
||
} else if effective_desired_pos == 0.0 {
|
||
let r = if close[i - 1] != 0.0 {
|
||
st.current_pos * (actual_fill - close[i - 1]) / close[i - 1]
|
||
} else {
|
||
0.0
|
||
};
|
||
let comm = commission_fraction(
|
||
&cm,
|
||
actual_fill,
|
||
st.current_pos.abs(),
|
||
st.current_pos < 0.0,
|
||
initial_capital,
|
||
);
|
||
r - comm
|
||
} else {
|
||
let exit_r = if close[i - 1] != 0.0 {
|
||
st.current_pos * (actual_fill - close[i - 1]) / close[i - 1]
|
||
} else {
|
||
0.0
|
||
};
|
||
let entry_r = effective_desired_pos * (close[i] - actual_fill) / actual_fill;
|
||
let exit_comm = commission_fraction(
|
||
&cm,
|
||
actual_fill,
|
||
st.current_pos.abs(),
|
||
st.current_pos < 0.0,
|
||
initial_capital,
|
||
);
|
||
let entry_comm = commission_fraction(
|
||
&cm,
|
||
actual_fill,
|
||
effective_desired_pos.abs(),
|
||
effective_desired_pos > 0.0,
|
||
initial_capital,
|
||
);
|
||
exit_r + entry_r - exit_comm - entry_comm
|
||
}
|
||
} else {
|
||
let r = st.current_pos * close_ret;
|
||
if pos_changed {
|
||
let comm = commission_fraction(
|
||
&cm,
|
||
if close[i] != 0.0 { close[i] } else { 1.0 },
|
||
(effective_desired_pos - st.current_pos).abs(),
|
||
effective_desired_pos > st.current_pos,
|
||
initial_capital,
|
||
);
|
||
r - comm
|
||
} else {
|
||
r
|
||
}
|
||
};
|
||
|
||
if pos_changed && margin_ratio > 0.0 {
|
||
if effective_desired_pos != 0.0 {
|
||
if st.current_pos == 0.0
|
||
|| (st.current_pos.signum() != effective_desired_pos.signum())
|
||
{
|
||
st.initial_margin_required = effective_desired_pos.abs() * margin_ratio;
|
||
st.margin_entry_price = actual_fill;
|
||
}
|
||
} else {
|
||
st.initial_margin_required = 0.0;
|
||
st.margin_entry_price = f64::NAN;
|
||
}
|
||
}
|
||
|
||
st.current_pos = effective_desired_pos;
|
||
positions[i] = st.current_pos;
|
||
}
|
||
|
||
// --- Short borrow cost accrual ---
|
||
if st.current_pos < 0.0 && cm.short_borrow_rate_annual > 0.0 {
|
||
let fill_price_for_borrow = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 {
|
||
fill_prices[i]
|
||
} else {
|
||
close[i]
|
||
};
|
||
let trade_value = st.current_pos.abs() * fill_price_for_borrow * initial_capital;
|
||
let borrow_cost_fraction =
|
||
cm.short_borrow_cost(trade_value, periods_per_year) / initial_capital;
|
||
strategy_returns[i] -= borrow_cost_fraction;
|
||
}
|
||
|
||
// Update bars_in_trade counter
|
||
if st.current_pos == 0.0 {
|
||
st.bars_in_trade = 0;
|
||
} else if pos_start == 0.0 || (pos_start.signum() != st.current_pos.signum()) {
|
||
st.bars_in_trade = 1;
|
||
} else {
|
||
st.bars_in_trade += 1;
|
||
}
|
||
}
|
||
|
||
// Build equity curve
|
||
let mut equity = vec![1.0_f64; n];
|
||
let mut cum = 1.0_f64;
|
||
for i in 0..n {
|
||
cum *= 1.0 + strategy_returns[i];
|
||
equity[i] = cum;
|
||
}
|
||
|
||
Ok(OhlcvBacktestResult {
|
||
positions,
|
||
fill_prices,
|
||
bar_returns,
|
||
strategy_returns,
|
||
equity,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Performance metrics
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Compute all industry-standard performance metrics from strategy returns and equity.
|
||
pub fn compute_performance_metrics(
|
||
strategy_returns: &[f64],
|
||
equity: &[f64],
|
||
periods_per_year: f64,
|
||
risk_free_rate: f64,
|
||
benchmark_returns: Option<&[f64]>,
|
||
) -> Result<BacktestMetrics, String> {
|
||
let r = strategy_returns;
|
||
let eq = equity;
|
||
let n = r.len();
|
||
|
||
if n < 2 {
|
||
return Err("strategy_returns must have at least 2 elements".to_string());
|
||
}
|
||
if eq.len() != n {
|
||
return Err("equity and strategy_returns must have equal length".to_string());
|
||
}
|
||
|
||
// --- Pass 1: drawdown / equity stats ---
|
||
let mut peak = eq[0];
|
||
let mut max_dd = 0.0_f64;
|
||
let mut dd_sum = 0.0_f64;
|
||
let mut dd_count = 0_usize;
|
||
let mut ulcer_sum = 0.0_f64;
|
||
let mut current_dd_len = 0_usize;
|
||
let mut max_dd_len = 0_usize;
|
||
let mut dd_len_sum = 0_usize;
|
||
let mut dd_len_count = 0_usize;
|
||
|
||
for &eq_val in eq.iter().take(n) {
|
||
if eq_val > peak {
|
||
if current_dd_len > 0 {
|
||
dd_len_sum += current_dd_len;
|
||
dd_len_count += 1;
|
||
current_dd_len = 0;
|
||
}
|
||
peak = eq_val;
|
||
}
|
||
let dd = if peak != 0.0 {
|
||
(eq_val - peak) / peak
|
||
} else {
|
||
0.0
|
||
};
|
||
if dd < 0.0 {
|
||
dd_sum += dd;
|
||
dd_count += 1;
|
||
ulcer_sum += dd * dd;
|
||
current_dd_len += 1;
|
||
if dd < max_dd {
|
||
max_dd = dd;
|
||
}
|
||
if current_dd_len > max_dd_len {
|
||
max_dd_len = current_dd_len;
|
||
}
|
||
}
|
||
}
|
||
if current_dd_len > 0 {
|
||
dd_len_sum += current_dd_len;
|
||
dd_len_count += 1;
|
||
}
|
||
|
||
let avg_dd = if dd_count > 0 {
|
||
dd_sum / dd_count as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
let ulcer_index = (ulcer_sum / n as f64).sqrt();
|
||
let avg_dd_duration = if dd_len_count > 0 {
|
||
dd_len_sum as f64 / dd_len_count as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
// --- Pass 2: statistical moments ---
|
||
let rf_per_bar = risk_free_rate / periods_per_year;
|
||
|
||
let valid_r: Vec<f64> = r.iter().copied().filter(|v| v.is_finite()).collect();
|
||
let n_valid = valid_r.len();
|
||
if n_valid == 0 {
|
||
return Err("No finite values in strategy_returns".to_string());
|
||
}
|
||
|
||
let mean_r: f64 = valid_r.iter().sum::<f64>() / n_valid as f64;
|
||
let variance: f64 = valid_r.iter().map(|&v| (v - mean_r).powi(2)).sum::<f64>() / n_valid as f64;
|
||
let std_r = variance.sqrt();
|
||
|
||
let downside_sq_sum: f64 = valid_r
|
||
.iter()
|
||
.filter(|&&v| v < rf_per_bar)
|
||
.map(|&v| (v - rf_per_bar).powi(2))
|
||
.sum();
|
||
let downside_std = (downside_sq_sum / n_valid as f64).sqrt();
|
||
|
||
let skewness = if std_r > 0.0 {
|
||
valid_r
|
||
.iter()
|
||
.map(|&v| ((v - mean_r) / std_r).powi(3))
|
||
.sum::<f64>()
|
||
/ n_valid as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
let kurtosis = if std_r > 0.0 {
|
||
valid_r
|
||
.iter()
|
||
.map(|&v| ((v - mean_r) / std_r).powi(4))
|
||
.sum::<f64>()
|
||
/ n_valid as f64
|
||
- 3.0
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
let total_return = if eq[0] != 0.0 {
|
||
eq[n - 1] / eq[0] - 1.0
|
||
} else {
|
||
0.0
|
||
};
|
||
let cagr = if eq[0] != 0.0 && eq[n - 1] > 0.0 {
|
||
(eq[n - 1] / eq[0]).powf(periods_per_year / n as f64) - 1.0
|
||
} else {
|
||
0.0
|
||
};
|
||
let annual_vol = std_r * periods_per_year.sqrt();
|
||
let sharpe = if annual_vol > 0.0 {
|
||
(cagr - risk_free_rate) / annual_vol
|
||
} else {
|
||
0.0
|
||
};
|
||
let sortino = if downside_std > 0.0 {
|
||
(cagr - risk_free_rate) / (downside_std * periods_per_year.sqrt())
|
||
} else {
|
||
0.0
|
||
};
|
||
let calmar = if max_dd < 0.0 {
|
||
cagr / max_dd.abs()
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
// Win / loss analysis — single pass with running counters
|
||
let mut n_active = 0_usize;
|
||
let mut n_wins = 0_usize;
|
||
let mut n_losses = 0_usize;
|
||
let mut win_sum = 0.0_f64;
|
||
let mut loss_sum = 0.0_f64;
|
||
for &v in &valid_r {
|
||
if v != 0.0 {
|
||
n_active += 1;
|
||
if v > 0.0 {
|
||
n_wins += 1;
|
||
win_sum += v;
|
||
} else {
|
||
n_losses += 1;
|
||
loss_sum += v.abs();
|
||
}
|
||
}
|
||
}
|
||
let win_rate = if n_active > 0 {
|
||
n_wins as f64 / n_active as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
let avg_win = if n_wins > 0 {
|
||
win_sum / n_wins as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
let avg_loss = if n_losses > 0 {
|
||
-(loss_sum / n_losses as f64)
|
||
} else {
|
||
0.0
|
||
};
|
||
let profit_factor = if loss_sum > 0.0 {
|
||
win_sum / loss_sum
|
||
} else {
|
||
f64::INFINITY
|
||
};
|
||
let loss_rate = 1.0 - win_rate;
|
||
let r_expectancy = win_rate * avg_win - loss_rate * avg_loss.abs();
|
||
|
||
// Omega ratio
|
||
let omega_numer: f64 = valid_r
|
||
.iter()
|
||
.filter(|&&v| v > rf_per_bar)
|
||
.map(|&v| v - rf_per_bar)
|
||
.sum();
|
||
let omega_denom: f64 = valid_r
|
||
.iter()
|
||
.filter(|&&v| v <= rf_per_bar)
|
||
.map(|&v| rf_per_bar - v)
|
||
.sum();
|
||
let omega_ratio = if omega_denom > 0.0 {
|
||
omega_numer / omega_denom
|
||
} else {
|
||
f64::INFINITY
|
||
};
|
||
|
||
// Tail ratio — use select_nth_unstable for O(n) percentile lookup
|
||
let mut pct_r = valid_r.clone();
|
||
let idx_5 = ((n_valid as f64 * 0.05) as usize).min(n_valid.saturating_sub(1));
|
||
let idx_95 = ((n_valid as f64 * 0.95) as usize).min(n_valid.saturating_sub(1));
|
||
// Find 5th percentile (also partitions so all elements below idx_5 are <=)
|
||
pct_r.select_nth_unstable_by(idx_5, |a, b| {
|
||
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
let p5 = pct_r[idx_5];
|
||
let worst_bar = pct_r[..=idx_5]
|
||
.iter()
|
||
.copied()
|
||
.fold(f64::INFINITY, f64::min);
|
||
// Find 95th percentile in the remaining upper partition
|
||
pct_r[idx_5..].select_nth_unstable_by(idx_95 - idx_5, |a, b| {
|
||
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
let p95 = pct_r[idx_95];
|
||
let best_bar = pct_r[idx_95..]
|
||
.iter()
|
||
.copied()
|
||
.fold(f64::NEG_INFINITY, f64::max);
|
||
let tail_ratio = if p5.abs() > 0.0 {
|
||
p95.abs() / p5.abs()
|
||
} else {
|
||
f64::INFINITY
|
||
};
|
||
|
||
// Position changes
|
||
let mut n_pos_changes = 0_usize;
|
||
for i in 1..n {
|
||
let prev_active = r[i - 1].is_finite() && r[i - 1] != 0.0;
|
||
let cur_active = r[i].is_finite() && r[i] != 0.0;
|
||
if prev_active != cur_active {
|
||
n_pos_changes += 1;
|
||
}
|
||
}
|
||
|
||
// Benchmark metrics
|
||
let (
|
||
benchmark_total_return,
|
||
benchmark_cagr,
|
||
benchmark_annualized_vol,
|
||
benchmark_sharpe,
|
||
alpha,
|
||
beta,
|
||
tracking_error,
|
||
information_ratio,
|
||
) = if let Some(br) = benchmark_returns {
|
||
if br.len() == n {
|
||
let mut s_sum = 0.0_f64;
|
||
let mut b_sum = 0.0_f64;
|
||
let mut nb = 0_usize;
|
||
for i in 0..n {
|
||
if r[i].is_finite() && br[i].is_finite() {
|
||
s_sum += r[i];
|
||
b_sum += br[i];
|
||
nb += 1;
|
||
}
|
||
}
|
||
if nb > 1 {
|
||
let s_mean = s_sum / nb as f64;
|
||
let b_mean = b_sum / nb as f64;
|
||
|
||
let mut b_var_sum = 0.0_f64;
|
||
let mut cov_sum = 0.0_f64;
|
||
let mut ex_sum = 0.0_f64;
|
||
let mut ex_sq_sum = 0.0_f64;
|
||
for i in 0..n {
|
||
if r[i].is_finite() && br[i].is_finite() {
|
||
let sd = r[i] - s_mean;
|
||
let bd = br[i] - b_mean;
|
||
b_var_sum += bd * bd;
|
||
cov_sum += sd * bd;
|
||
let ex = r[i] - br[i];
|
||
ex_sum += ex;
|
||
ex_sq_sum += ex * ex;
|
||
}
|
||
}
|
||
let b_var = b_var_sum / nb as f64;
|
||
let b_std = b_var.sqrt();
|
||
|
||
let mut b_eq = 1.0_f64;
|
||
for &ret in br {
|
||
b_eq *= 1.0 + if ret.is_finite() { ret } else { 0.0 };
|
||
}
|
||
let bench_total_return = b_eq - 1.0;
|
||
let bench_cagr = if b_eq > 0.0 {
|
||
b_eq.powf(periods_per_year / n as f64) - 1.0
|
||
} else {
|
||
0.0
|
||
};
|
||
let bench_ann_vol = b_std * periods_per_year.sqrt();
|
||
let bench_sharpe = if bench_ann_vol > 0.0 {
|
||
(bench_cagr - risk_free_rate) / bench_ann_vol
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
let cov_val = cov_sum / nb as f64;
|
||
let beta_val = if b_var > 0.0 { cov_val / b_var } else { 0.0 };
|
||
let alpha_val = cagr - bench_cagr;
|
||
|
||
let ex_mean = ex_sum / nb as f64;
|
||
let ex_var = ex_sq_sum / nb as f64 - ex_mean * ex_mean;
|
||
let te = ex_var.max(0.0).sqrt() * periods_per_year.sqrt();
|
||
let ir = if te > 0.0 { alpha_val / te } else { 0.0 };
|
||
|
||
(
|
||
Some(bench_total_return),
|
||
Some(bench_cagr),
|
||
Some(bench_ann_vol),
|
||
Some(bench_sharpe),
|
||
Some(alpha_val),
|
||
Some(beta_val),
|
||
Some(te),
|
||
Some(ir),
|
||
)
|
||
} else {
|
||
(None, None, None, None, None, None, None, None)
|
||
}
|
||
} else {
|
||
(None, None, None, None, None, None, None, None)
|
||
}
|
||
} else {
|
||
(None, None, None, None, None, None, None, None)
|
||
};
|
||
|
||
Ok(BacktestMetrics {
|
||
total_return,
|
||
cagr,
|
||
annualized_vol: annual_vol,
|
||
sharpe,
|
||
sortino,
|
||
calmar,
|
||
max_drawdown: max_dd,
|
||
avg_drawdown: avg_dd,
|
||
max_drawdown_duration_bars: max_dd_len,
|
||
avg_drawdown_duration_bars: avg_dd_duration,
|
||
ulcer_index,
|
||
omega_ratio,
|
||
win_rate,
|
||
profit_factor,
|
||
r_expectancy,
|
||
avg_win,
|
||
avg_loss,
|
||
tail_ratio,
|
||
skewness,
|
||
kurtosis,
|
||
best_bar,
|
||
worst_bar,
|
||
n_trades: n_active,
|
||
n_position_changes: n_pos_changes,
|
||
benchmark_total_return,
|
||
benchmark_cagr,
|
||
benchmark_annualized_vol,
|
||
benchmark_sharpe,
|
||
alpha,
|
||
beta,
|
||
tracking_error,
|
||
information_ratio,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Trade extraction
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Extract trade records from positions and price arrays.
|
||
pub fn extract_trades_ohlcv(
|
||
positions: &[f64],
|
||
fill_prices: &[f64],
|
||
high: &[f64],
|
||
low: &[f64],
|
||
) -> Result<Vec<TradeRecord>, String> {
|
||
let n = positions.len();
|
||
if fill_prices.len() != n || high.len() != n || low.len() != n {
|
||
return Err(format!(
|
||
"all arrays must have equal length (positions={}), got fill_prices={}, high={}, low={}",
|
||
n,
|
||
fill_prices.len(),
|
||
high.len(),
|
||
low.len()
|
||
));
|
||
}
|
||
|
||
let mut trades: Vec<TradeRecord> = Vec::new();
|
||
|
||
let mut in_trade = false;
|
||
let mut trade_entry_bar = 0_i64;
|
||
let mut trade_dir = 0.0_f64;
|
||
let mut trade_entry_price = 0.0_f64;
|
||
let mut trade_mae = 0.0_f64;
|
||
let mut trade_mfe = 0.0_f64;
|
||
|
||
for i in 0..n {
|
||
let cur_pos = positions[i];
|
||
|
||
if !in_trade {
|
||
if cur_pos != 0.0 {
|
||
in_trade = true;
|
||
trade_entry_bar = i as i64;
|
||
trade_dir = cur_pos.signum();
|
||
trade_entry_price = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 {
|
||
fill_prices[i]
|
||
} else {
|
||
high[i]
|
||
};
|
||
trade_mae = 0.0;
|
||
trade_mfe = 0.0;
|
||
}
|
||
} else {
|
||
if trade_entry_price > 0.0 {
|
||
let unreal_high = trade_dir * (high[i] - trade_entry_price) / trade_entry_price;
|
||
let unreal_low = trade_dir * (low[i] - trade_entry_price) / trade_entry_price;
|
||
let bar_best = unreal_high.max(unreal_low);
|
||
let bar_worst = unreal_high.min(unreal_low);
|
||
if bar_best > trade_mfe {
|
||
trade_mfe = bar_best;
|
||
}
|
||
if bar_worst < trade_mae {
|
||
trade_mae = bar_worst;
|
||
}
|
||
}
|
||
|
||
let pos_closed = cur_pos == 0.0 || cur_pos.signum() != trade_dir;
|
||
|
||
if pos_closed {
|
||
let exit_price = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 {
|
||
fill_prices[i]
|
||
} else {
|
||
low[i]
|
||
};
|
||
let pnl = if trade_entry_price > 0.0 {
|
||
trade_dir * (exit_price - trade_entry_price) / trade_entry_price
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
trades.push(TradeRecord {
|
||
entry_bar: trade_entry_bar,
|
||
exit_bar: i as i64,
|
||
direction: trade_dir,
|
||
entry_price: trade_entry_price,
|
||
exit_price,
|
||
pnl_pct: pnl,
|
||
duration_bars: i as i64 - trade_entry_bar,
|
||
mae: trade_mae,
|
||
mfe: trade_mfe,
|
||
});
|
||
|
||
if cur_pos != 0.0 {
|
||
in_trade = true;
|
||
trade_entry_bar = i as i64;
|
||
trade_dir = cur_pos.signum();
|
||
trade_entry_price = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 {
|
||
fill_prices[i]
|
||
} else {
|
||
high[i]
|
||
};
|
||
trade_mae = 0.0;
|
||
trade_mfe = 0.0;
|
||
} else {
|
||
in_trade = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Close any open trade at last bar
|
||
if in_trade {
|
||
let last = n - 1;
|
||
let exit_price = if fill_prices[last].is_finite() && fill_prices[last] > 0.0 {
|
||
fill_prices[last]
|
||
} else {
|
||
high[last]
|
||
};
|
||
let pnl = if trade_entry_price > 0.0 {
|
||
trade_dir * (exit_price - trade_entry_price) / trade_entry_price
|
||
} else {
|
||
0.0
|
||
};
|
||
trades.push(TradeRecord {
|
||
entry_bar: trade_entry_bar,
|
||
exit_bar: last as i64,
|
||
direction: trade_dir,
|
||
entry_price: trade_entry_price,
|
||
exit_price,
|
||
pnl_pct: pnl,
|
||
duration_bars: last as i64 - trade_entry_bar,
|
||
mae: trade_mae,
|
||
mfe: trade_mfe,
|
||
});
|
||
}
|
||
|
||
Ok(trades)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Multi-asset backtest
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Multi-asset result: per-asset strategy returns (n_bars x n_assets), portfolio returns, portfolio equity.
|
||
#[derive(Clone, Debug)]
|
||
pub struct MultiAssetBacktestResult {
|
||
/// Shape: (n_assets, n_bars) — row-major per asset.
|
||
pub asset_returns: Vec<Vec<f64>>,
|
||
pub portfolio_returns: Vec<f64>,
|
||
pub portfolio_equity: Vec<f64>,
|
||
}
|
||
|
||
/// Backtest N assets, then combine into a portfolio.
|
||
///
|
||
/// `close_2d`: row-major (n_assets, n_bars)
|
||
/// `weights_2d`: row-major (n_assets, n_bars)
|
||
///
|
||
/// Callers must transpose from (n_bars, n_assets) if needed.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn backtest_multi_asset_core(
|
||
close_2d: &[Vec<f64>],
|
||
weights_2d: &[Vec<f64>],
|
||
n_bars: usize,
|
||
n_assets: usize,
|
||
commission_per_trade: f64,
|
||
slippage_bps: f64,
|
||
max_asset_weight: f64,
|
||
max_gross_exposure: f64,
|
||
max_net_exposure: f64,
|
||
) -> Result<MultiAssetBacktestResult, String> {
|
||
if n_bars < 2 {
|
||
return Err("n_bars must be at least 2".to_string());
|
||
}
|
||
if close_2d.len() != n_assets || weights_2d.len() != n_assets {
|
||
return Err("close_2d and weights_2d must have n_assets rows".to_string());
|
||
}
|
||
|
||
// Apply portfolio constraints per bar
|
||
let mut constrained: Vec<Vec<f64>> = weights_2d.to_vec();
|
||
#[allow(clippy::needless_range_loop)]
|
||
if max_asset_weight != 1.0 || max_gross_exposure > 0.0 || max_net_exposure > 0.0 {
|
||
for i in 0..n_bars {
|
||
// 1. Clamp per-asset weight
|
||
if max_asset_weight < f64::INFINITY && max_asset_weight > 0.0 {
|
||
for j in 0..n_assets {
|
||
let w = constrained[j][i];
|
||
if w.abs() > max_asset_weight {
|
||
constrained[j][i] = w.signum() * max_asset_weight;
|
||
}
|
||
}
|
||
}
|
||
// 2. Normalize so sum(abs) <= max_gross_exposure
|
||
if max_gross_exposure > 0.0 {
|
||
let gross: f64 = (0..n_assets).map(|j| constrained[j][i].abs()).sum();
|
||
if gross > max_gross_exposure {
|
||
let scale = max_gross_exposure / gross;
|
||
for j in 0..n_assets {
|
||
constrained[j][i] *= scale;
|
||
}
|
||
}
|
||
}
|
||
// 3. Clamp net exposure
|
||
if max_net_exposure > 0.0 {
|
||
let net: f64 = (0..n_assets).map(|j| constrained[j][i]).sum();
|
||
if net.abs() > max_net_exposure {
|
||
let excess = net - net.signum() * max_net_exposure;
|
||
let adj_per_asset = excess / n_assets as f64;
|
||
for j in 0..n_assets {
|
||
constrained[j][i] -= adj_per_asset;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Per-asset backtests
|
||
let asset_strategy_returns: Vec<Vec<f64>> = (0..n_assets)
|
||
.map(|j| {
|
||
let (_, strat_rets, _) = single_asset_backtest(
|
||
&close_2d[j],
|
||
&constrained[j],
|
||
commission_per_trade,
|
||
slippage_bps,
|
||
);
|
||
strat_rets
|
||
})
|
||
.collect();
|
||
|
||
// Portfolio return = sum of per-asset strategy returns
|
||
let mut portfolio_returns = vec![0.0_f64; n_bars];
|
||
#[allow(clippy::needless_range_loop)]
|
||
for i in 0..n_bars {
|
||
let mut s = 0.0_f64;
|
||
for j in 0..n_assets {
|
||
s += asset_strategy_returns[j][i];
|
||
}
|
||
portfolio_returns[i] = s;
|
||
}
|
||
|
||
// Portfolio equity
|
||
let mut portfolio_equity = vec![1.0_f64; n_bars];
|
||
let mut cum = 1.0_f64;
|
||
for i in 0..n_bars {
|
||
cum *= 1.0 + portfolio_returns[i];
|
||
portfolio_equity[i] = cum;
|
||
}
|
||
|
||
Ok(MultiAssetBacktestResult {
|
||
asset_returns: asset_strategy_returns,
|
||
portfolio_returns,
|
||
portfolio_equity,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Monte Carlo bootstrap
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Bootstrap Monte Carlo simulation over strategy returns.
|
||
///
|
||
/// Returns `n_sims` equity curves, each of length `n_bars`.
|
||
pub fn monte_carlo_bootstrap(
|
||
strategy_returns: &[f64],
|
||
n_sims: usize,
|
||
seed: u64,
|
||
block_size: usize,
|
||
) -> Result<Vec<Vec<f64>>, String> {
|
||
let n = strategy_returns.len();
|
||
if n < 2 {
|
||
return Err("strategy_returns must have at least 2 elements".to_string());
|
||
}
|
||
if n_sims == 0 {
|
||
return Err("n_sims must be >= 1".to_string());
|
||
}
|
||
let bsize = block_size.max(1).min(n);
|
||
|
||
let mut result: Vec<Vec<f64>> = Vec::with_capacity(n_sims);
|
||
|
||
for sim_idx in 0..n_sims {
|
||
let mut state = seed
|
||
.wrapping_mul(6_364_136_223_846_793_005_u64)
|
||
.wrapping_add((sim_idx as u64).wrapping_mul(2_862_933_555_777_941_757_u64));
|
||
lcg_next(&mut state);
|
||
lcg_next(&mut state);
|
||
|
||
let mut row = vec![0.0_f64; n];
|
||
|
||
if bsize == 1 {
|
||
for dst in row.iter_mut() {
|
||
*dst = strategy_returns[lcg_index(&mut state, n)];
|
||
}
|
||
} else {
|
||
let mut filled = 0_usize;
|
||
while filled < n {
|
||
let start = lcg_index(&mut state, n);
|
||
let take = bsize.min(n - filled);
|
||
for k in 0..take {
|
||
row[filled + k] = strategy_returns[(start + k) % n];
|
||
}
|
||
filled += take;
|
||
}
|
||
}
|
||
|
||
// Convert to equity curve in-place
|
||
let mut cum = 1.0_f64;
|
||
for elem in row.iter_mut() {
|
||
cum *= 1.0 + *elem;
|
||
*elem = cum;
|
||
}
|
||
|
||
result.push(row);
|
||
}
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Walk-forward indices
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Generate train/test fold index boundaries for walk-forward analysis.
|
||
///
|
||
/// Returns a vector of (train_start, train_end, test_start, test_end) tuples.
|
||
pub fn walk_forward_indices(
|
||
n_bars: usize,
|
||
train_bars: usize,
|
||
test_bars: usize,
|
||
anchored: bool,
|
||
step_bars: usize,
|
||
) -> Result<Vec<[i64; 4]>, String> {
|
||
if train_bars == 0 {
|
||
return Err("train_bars must be >= 1".to_string());
|
||
}
|
||
if test_bars == 0 {
|
||
return Err("test_bars must be >= 1".to_string());
|
||
}
|
||
if train_bars + test_bars > n_bars {
|
||
return Err("train_bars + test_bars must be <= n_bars".to_string());
|
||
}
|
||
|
||
let step = if step_bars == 0 { test_bars } else { step_bars };
|
||
let mut folds: Vec<[i64; 4]> = Vec::new();
|
||
|
||
let mut offset = 0_usize;
|
||
loop {
|
||
let train_start = if anchored { 0 } else { offset };
|
||
let train_end = offset + train_bars;
|
||
let test_start = train_end;
|
||
let test_end = test_start + test_bars;
|
||
|
||
if test_end > n_bars {
|
||
break;
|
||
}
|
||
|
||
folds.push([
|
||
train_start as i64,
|
||
train_end as i64,
|
||
test_start as i64,
|
||
test_end as i64,
|
||
]);
|
||
|
||
offset += step;
|
||
}
|
||
|
||
if folds.is_empty() {
|
||
return Err(
|
||
"No complete folds fit within n_bars with the given train/test sizes".to_string(),
|
||
);
|
||
}
|
||
|
||
Ok(folds)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Kelly criterion
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Compute the Kelly fraction: f = win_rate - (1 - win_rate) * (|avg_loss| / avg_win), clamped to [0, 1].
|
||
pub fn kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> Result<f64, String> {
|
||
if !(0.0..=1.0).contains(&win_rate) {
|
||
return Err("win_rate must be in [0, 1]".to_string());
|
||
}
|
||
if avg_win <= 0.0 {
|
||
return Err("avg_win must be > 0".to_string());
|
||
}
|
||
Ok(kelly_formula(win_rate, avg_win, avg_loss))
|
||
}
|
||
|
||
/// Half-Kelly fraction (conservative position sizing).
|
||
pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> Result<f64, String> {
|
||
Ok(kelly_fraction(win_rate, avg_win, avg_loss)? / 2.0)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// StreamingBacktest
|
||
// ---------------------------------------------------------------------------
|
||
|
||
impl StreamingBacktest {
|
||
pub fn new(commission_per_trade: f64, slippage_bps: f64) -> Self {
|
||
StreamingBacktest {
|
||
commission_per_trade,
|
||
slippage_bps,
|
||
position: 0.0,
|
||
entry_price: f64::NAN,
|
||
equity: 1.0,
|
||
prev_close: f64::NAN,
|
||
total_commission: 0.0,
|
||
n_trades: 0,
|
||
sum_wins: 0.0,
|
||
n_wins: 0,
|
||
sum_losses: 0.0,
|
||
n_losses: 0,
|
||
}
|
||
}
|
||
|
||
/// Process one bar. Returns position, bar_return, equity, n_trades.
|
||
pub fn on_bar(&mut self, close: f64, signal: f64) -> StreamingBarResult {
|
||
let slip = self.slippage_bps / 10_000.0;
|
||
let mut bar_return = 0.0_f64;
|
||
|
||
if self.position != 0.0 && !self.prev_close.is_nan() {
|
||
let price_ret = (close - self.prev_close) / self.prev_close;
|
||
bar_return = self.position * price_ret;
|
||
self.equity *= 1.0 + bar_return;
|
||
}
|
||
|
||
let new_pos = if signal.is_nan() { 0.0 } else { signal };
|
||
if (new_pos - self.position).abs() > 1e-12 {
|
||
let direction = if new_pos > self.position { 1.0 } else { -1.0 };
|
||
let slippage_cost = direction * slip;
|
||
self.equity *= 1.0 - slippage_cost.abs();
|
||
self.equity -= self.commission_per_trade;
|
||
self.total_commission += self.commission_per_trade;
|
||
|
||
if self.position != 0.0 && !self.entry_price.is_nan() {
|
||
let trade_ret = self.position * (close - self.entry_price) / self.entry_price;
|
||
if trade_ret >= 0.0 {
|
||
self.sum_wins += trade_ret;
|
||
self.n_wins += 1;
|
||
} else {
|
||
self.sum_losses += trade_ret.abs();
|
||
self.n_losses += 1;
|
||
}
|
||
self.n_trades += 1;
|
||
}
|
||
|
||
self.position = new_pos;
|
||
self.entry_price = if new_pos != 0.0 { close } else { f64::NAN };
|
||
}
|
||
|
||
self.prev_close = close;
|
||
|
||
StreamingBarResult {
|
||
position: self.position,
|
||
bar_return,
|
||
equity: self.equity,
|
||
n_trades: self.n_trades,
|
||
}
|
||
}
|
||
|
||
/// Summary statistics.
|
||
pub fn summary(&self) -> StreamingSummary {
|
||
let win_rate = if self.n_trades > 0 {
|
||
self.n_wins as f64 / self.n_trades as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
let avg_win = if self.n_wins > 0 {
|
||
self.sum_wins / self.n_wins as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
let avg_loss = if self.n_losses > 0 {
|
||
self.sum_losses / self.n_losses as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
let kf = kelly_formula(win_rate, avg_win, avg_loss);
|
||
|
||
StreamingSummary {
|
||
equity: self.equity,
|
||
n_trades: self.n_trades,
|
||
total_commission: self.total_commission,
|
||
win_rate,
|
||
avg_win,
|
||
avg_loss,
|
||
kelly_fraction: kf,
|
||
}
|
||
}
|
||
|
||
/// Reset all state.
|
||
pub fn reset(&mut self) {
|
||
self.position = 0.0;
|
||
self.entry_price = f64::NAN;
|
||
self.equity = 1.0;
|
||
self.prev_close = f64::NAN;
|
||
self.total_commission = 0.0;
|
||
self.n_trades = 0;
|
||
self.sum_wins = 0.0;
|
||
self.n_wins = 0;
|
||
self.sum_losses = 0.0;
|
||
self.n_losses = 0;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_nan_to_num() {
|
||
assert_eq!(nan_to_num(f64::NAN), 0.0);
|
||
assert_eq!(nan_to_num(f64::INFINITY), f64::MAX);
|
||
assert_eq!(nan_to_num(f64::NEG_INFINITY), -f64::MAX);
|
||
assert_eq!(nan_to_num(42.0), 42.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_kelly_formula_basic() {
|
||
let f = kelly_formula(0.6, 1.0, 0.5);
|
||
assert!((f - 0.4).abs() < 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_kelly_fraction_validation() {
|
||
assert!(kelly_fraction(1.5, 1.0, 0.5).is_err());
|
||
assert!(kelly_fraction(0.5, -1.0, 0.5).is_err());
|
||
assert!(kelly_fraction(0.6, 1.0, 0.5).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_half_kelly() {
|
||
let full = kelly_fraction(0.6, 1.0, 0.5).unwrap();
|
||
let half = half_kelly_fraction(0.6, 1.0, 0.5).unwrap();
|
||
assert!((half - full / 2.0).abs() < 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn test_backtest_core_flat_signal() {
|
||
let close = vec![100.0, 101.0, 102.0, 103.0, 104.0];
|
||
let signals = vec![0.0, 0.0, 0.0, 0.0, 0.0];
|
||
let result = backtest_core(&close, &signals, None, 0.0, 100_000.0, 0.0).unwrap();
|
||
// With zero signals, equity should remain at 1.0
|
||
for &e in &result.equity {
|
||
assert!((e - 1.0).abs() < 1e-10);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_backtest_core_long_signal() {
|
||
let close = vec![100.0, 110.0, 120.0];
|
||
let signals = vec![1.0, 1.0, 1.0];
|
||
let result = backtest_core(&close, &signals, None, 0.0, 100_000.0, 0.0).unwrap();
|
||
// Position is lagged: pos[0]=0, pos[1]=1, pos[2]=1
|
||
// bar_returns[1] = 0.1, bar_returns[2] ≈ 0.0909
|
||
// strategy_returns[1] = 1*0.1 = 0.1, strategy_returns[2] = 1*0.0909
|
||
assert!((result.equity[2] - 1.1 * (1.0 + 10.0 / 110.0)).abs() < 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_walk_forward_indices_basic() {
|
||
let folds = walk_forward_indices(100, 50, 25, false, 0).unwrap();
|
||
assert_eq!(folds.len(), 2);
|
||
assert_eq!(folds[0], [0, 50, 50, 75]);
|
||
assert_eq!(folds[1], [25, 75, 75, 100]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_walk_forward_anchored() {
|
||
let folds = walk_forward_indices(100, 50, 25, true, 0).unwrap();
|
||
assert!(folds.len() >= 2);
|
||
// Anchored: train always starts at 0
|
||
for fold in &folds {
|
||
assert_eq!(fold[0], 0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_monte_carlo_basic() {
|
||
let returns = vec![0.01, -0.005, 0.02, -0.01, 0.015];
|
||
let result = monte_carlo_bootstrap(&returns, 10, 42, 1).unwrap();
|
||
assert_eq!(result.len(), 10);
|
||
for curve in &result {
|
||
assert_eq!(curve.len(), 5);
|
||
// Equity curves should be positive
|
||
assert!(curve.last().unwrap() > &0.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_trades_empty() {
|
||
let positions = vec![0.0, 0.0, 0.0];
|
||
let fill_prices = vec![f64::NAN, f64::NAN, f64::NAN];
|
||
let high = vec![100.0, 101.0, 102.0];
|
||
let low = vec![99.0, 100.0, 101.0];
|
||
let trades = extract_trades_ohlcv(&positions, &fill_prices, &high, &low).unwrap();
|
||
assert!(trades.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_trades_single_roundtrip() {
|
||
let positions = vec![0.0, 1.0, 1.0, 0.0];
|
||
let fill_prices = vec![f64::NAN, 100.0, f64::NAN, 110.0];
|
||
let high = vec![100.0, 105.0, 115.0, 112.0];
|
||
let low = vec![98.0, 99.0, 100.0, 108.0];
|
||
let trades = extract_trades_ohlcv(&positions, &fill_prices, &high, &low).unwrap();
|
||
assert_eq!(trades.len(), 1);
|
||
assert_eq!(trades[0].entry_bar, 1);
|
||
assert_eq!(trades[0].exit_bar, 3);
|
||
assert!((trades[0].entry_price - 100.0).abs() < 1e-10);
|
||
assert!((trades[0].exit_price - 110.0).abs() < 1e-10);
|
||
assert!(trades[0].pnl_pct > 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_ohlcv_backtest_basic() {
|
||
let n = 10;
|
||
let open: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
|
||
let high: Vec<f64> = open.iter().map(|&v| v + 2.0).collect();
|
||
let low: Vec<f64> = open.iter().map(|&v| v - 2.0).collect();
|
||
let close: Vec<f64> = open.iter().map(|&v| v + 1.0).collect();
|
||
let signals: Vec<f64> = vec![0.0, 1.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 0.0];
|
||
|
||
let config = BacktestConfig::default();
|
||
let result =
|
||
backtest_ohlcv_core(&open, &high, &low, &close, &signals, &config, None).unwrap();
|
||
assert_eq!(result.equity.len(), n);
|
||
// Equity should be positive
|
||
assert!(*result.equity.last().unwrap() > 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_streaming_backtest() {
|
||
let mut engine = StreamingBacktest::new(0.0, 0.0);
|
||
let closes = vec![100.0, 105.0, 103.0, 110.0];
|
||
let signals = vec![1.0, 1.0, -1.0, 0.0];
|
||
|
||
for (&c, &s) in closes.iter().zip(signals.iter()) {
|
||
let _r = engine.on_bar(c, s);
|
||
}
|
||
assert!(engine.equity > 0.0);
|
||
let summary = engine.summary();
|
||
assert!(summary.n_trades > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_compute_performance_metrics_basic() {
|
||
let returns = vec![0.01, -0.005, 0.02, -0.01, 0.015, 0.005, -0.003, 0.008];
|
||
let mut equity = vec![1.0_f64; returns.len()];
|
||
let mut cum = 1.0;
|
||
for (i, &r) in returns.iter().enumerate() {
|
||
cum *= 1.0 + r;
|
||
equity[i] = cum;
|
||
}
|
||
let metrics = compute_performance_metrics(&returns, &equity, 252.0, 0.0, None).unwrap();
|
||
assert!(metrics.total_return > 0.0);
|
||
assert!(metrics.sharpe != 0.0);
|
||
assert!(metrics.n_trades > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_multi_asset_basic() {
|
||
let n_bars = 5;
|
||
let close1 = vec![100.0, 101.0, 102.0, 103.0, 104.0];
|
||
let close2 = vec![200.0, 198.0, 201.0, 203.0, 205.0];
|
||
let weights1 = vec![0.0, 0.5, 0.5, 0.5, 0.0];
|
||
let weights2 = vec![0.0, 0.5, 0.5, 0.5, 0.0];
|
||
|
||
let result = backtest_multi_asset_core(
|
||
&[close1, close2],
|
||
&[weights1, weights2],
|
||
n_bars,
|
||
2,
|
||
0.0,
|
||
0.0,
|
||
1.0,
|
||
0.0,
|
||
0.0,
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(result.portfolio_returns.len(), n_bars);
|
||
assert_eq!(result.portfolio_equity.len(), n_bars);
|
||
assert_eq!(result.asset_returns.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_sma_crossover_signals() {
|
||
let close: Vec<f64> = (1..=40).map(|i| i as f64).collect();
|
||
let signals = sma_crossover_signals(&close, 5, 10).unwrap();
|
||
assert_eq!(signals.len(), close.len());
|
||
// First 9 bars should be NaN (slow SMA warm-up)
|
||
for i in 0..9 {
|
||
assert!(signals[i].is_nan(), "bar {} should be NaN", i);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_sma_crossover_invalid() {
|
||
let close = vec![1.0; 20];
|
||
assert!(sma_crossover_signals(&close, 10, 5).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_rsi_threshold_signals() {
|
||
let close: Vec<f64> = (1..=30).map(|i| 100.0 + (i as f64).sin() * 10.0).collect();
|
||
let signals = rsi_threshold_signals(&close, 14, 30.0, 70.0);
|
||
assert_eq!(signals.len(), close.len());
|
||
}
|
||
}
|