use std::collections::{HashMap, HashSet, hash_map::Entry};
use crate::portfolio::metrics::{
DrawdownEvent, Metrics, compute_metrics, drawdown_series, rolling_sharpe,
};
use crate::portfolio::{CostModel, Portfolio};
use crate::types::Symbol;
#[derive(Clone, Debug, Default)]
pub struct BacktestStopConfig {
pub fixed_stop_pct: Option<f64>,
pub trailing_stop_pct: Option<f64>,
pub atr_multiple: Option<f64>,
pub atr_period: usize,
}
impl BacktestStopConfig {
fn sanitized(&self) -> Option<Self> {
let fixed = sanitize_pct(self.fixed_stop_pct);
let trailing = sanitize_pct(self.trailing_stop_pct);
let atr_multiple = sanitize_positive(self.atr_multiple);
let atr_period = self.atr_period.max(1);
if fixed.is_none() && trailing.is_none() && atr_multiple.is_none() {
return None;
}
Some(Self {
fixed_stop_pct: fixed,
trailing_stop_pct: trailing,
atr_multiple,
atr_period,
})
}
}
#[derive(Clone, Debug, Default)]
pub struct BacktestBridgeOptions {
pub stop_cfg: Option<BacktestStopConfig>,
}
#[derive(Clone, Debug, Copy)]
pub struct BarPrices {
pub open: i64,
pub high: i64,
pub low: i64,
pub close: i64,
}
#[derive(Clone, Debug, Copy, PartialEq)]
pub enum FillPolicy {
SignalBarClose,
NextBarOpen,
NextBarTypical,
}
#[derive(Clone, Debug)]
pub struct BacktestStopEvent {
pub period_index: usize,
pub symbol: Symbol,
pub trigger_price: i64,
pub exit_price: i64,
pub reason: &'static str,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AttributionTrade {
pub symbol: Symbol,
pub entry_index: usize,
pub exit_index: Option<usize>,
pub entry_weight: f64,
pub exit_weight: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AttributionResult {
pub contributions: Vec<Vec<(Symbol, f64)>>,
pub cumulative_contributions: Vec<Vec<(Symbol, f64)>>,
pub trades: Vec<AttributionTrade>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TradeAnalytics {
pub trade_count: usize,
pub open_trade_count: usize,
pub closed_trade_count: usize,
}
#[derive(Debug, Clone)]
pub struct TearSheet {
pub monthly_returns: Vec<Vec<f64>>,
pub rolling_sharpe: Vec<f64>,
pub drawdown_events: Vec<DrawdownEvent>,
pub trade_analytics: TradeAnalytics,
}
pub struct BacktestBridgeResult {
pub returns: Vec<f64>,
pub equity_curve: Vec<i64>,
pub final_cash: i64,
pub metrics: Option<Metrics>,
pub holdings: Vec<Vec<(Symbol, f64)>>,
pub symbol_returns: Vec<Vec<(Symbol, f64)>>,
pub stop_events: Vec<BacktestStopEvent>,
pub skipped_rebalances: Vec<usize>,
}
pub fn backtest_weights(
weight_schedule: &[Vec<(Symbol, f64)>],
price_schedule: &[Vec<(Symbol, BarPrices)>],
initial_cash_cents: i64,
cost_model: CostModel,
fill_policy: FillPolicy,
periods_per_year: f64,
risk_free: f64,
) -> BacktestBridgeResult {
backtest_weights_with_options(
weight_schedule,
price_schedule,
initial_cash_cents,
cost_model,
fill_policy,
periods_per_year,
risk_free,
BacktestBridgeOptions::default(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn backtest_weights_with_options(
weight_schedule: &[Vec<(Symbol, f64)>],
price_schedule: &[Vec<(Symbol, BarPrices)>],
initial_cash_cents: i64,
cost_model: CostModel,
fill_policy: FillPolicy,
periods_per_year: f64,
risk_free: f64,
options: BacktestBridgeOptions,
) -> BacktestBridgeResult {
if !valid_inputs(weight_schedule, price_schedule, initial_cash_cents) {
return empty_result(initial_cash_cents);
}
let stop_cfg = options
.stop_cfg
.as_ref()
.and_then(BacktestStopConfig::sanitized);
let mut portfolio = Portfolio::new(initial_cash_cents, cost_model);
let mut equity_curve = Vec::with_capacity(weight_schedule.len() + 1);
equity_curve.push(initial_cash_cents);
let mut holdings = Vec::with_capacity(weight_schedule.len());
let mut symbol_returns = Vec::with_capacity(weight_schedule.len());
let mut stop_events = Vec::new();
let mut skipped_rebalances = Vec::new();
let mut prev_prices: HashMap<Symbol, i64> = HashMap::new();
let mut stop_trackers: HashMap<Symbol, StopTracker> = HashMap::new();
for (period_index, (weights, bars)) in weight_schedule
.iter()
.zip(price_schedule.iter())
.enumerate()
{
let close_prices: Vec<(Symbol, i64)> = bars
.iter()
.map(|&(symbol, bar_prices)| (symbol, bar_prices.close))
.collect();
let price_map: HashMap<Symbol, i64> = close_prices.iter().copied().collect();
let mut period_symbol_returns = Vec::with_capacity(bars.len());
for &(sym, bp) in bars {
let px = bp.close;
let ret = prev_prices
.get(&sym)
.copied()
.and_then(|p0| {
if p0 > 0 && px > 0 {
Some((px - p0) as f64 / p0 as f64)
} else {
None
}
})
.unwrap_or(f64::NAN);
period_symbol_returns.push((sym, ret));
}
period_symbol_returns.sort_by_key(|(sym, _)| *sym);
symbol_returns.push(period_symbol_returns);
if let Some(fill_prices) = fill_prices_for_period(price_schedule, period_index, fill_policy)
{
portfolio.rebalance_simple(weights, &fill_prices);
} else {
skipped_rebalances.push(period_index);
}
if let Some(cfg) = stop_cfg.as_ref() {
apply_stop_cfg(
&mut portfolio,
&price_map,
period_index,
cfg,
&mut stop_trackers,
&mut stop_events,
);
}
portfolio.record_return(&close_prices);
let mut period_holdings = portfolio.current_weights(&close_prices);
period_holdings.sort_by_key(|(sym, _)| *sym);
holdings.push(period_holdings);
let equity = portfolio.total_equity(&close_prices);
equity_curve.push(equity);
prev_prices = price_map;
}
let returns = portfolio.returns().to_vec();
let metrics = compute_metrics(&returns, periods_per_year, risk_free);
BacktestBridgeResult {
returns,
equity_curve,
final_cash: portfolio.cash(),
metrics,
holdings,
symbol_returns,
stop_events,
skipped_rebalances,
}
}
fn fill_prices_for_period(
price_schedule: &[Vec<(Symbol, BarPrices)>],
period_index: usize,
fill_policy: FillPolicy,
) -> Option<Vec<(Symbol, i64)>> {
match fill_policy {
FillPolicy::SignalBarClose => Some(
price_schedule[period_index]
.iter()
.map(|&(symbol, bar_prices)| (symbol, bar_prices.close))
.collect(),
),
FillPolicy::NextBarOpen => {
let next = price_schedule.get(period_index + 1)?;
Some(
next.iter()
.map(|&(symbol, bar_prices)| (symbol, bar_prices.open))
.collect(),
)
}
FillPolicy::NextBarTypical => {
let next = price_schedule.get(period_index + 1)?;
Some(
next.iter()
.map(|&(symbol, bar_prices)| {
(
symbol,
(bar_prices.high + bar_prices.low + bar_prices.close) / 3,
)
})
.collect(),
)
}
}
}
pub fn tear_sheet(
result: &BacktestBridgeResult,
rolling_window: usize,
periods_per_year: usize,
) -> TearSheet {
let attribution = decompose_backtest(&result.holdings, &result.symbol_returns);
let equity: Vec<f64> = result
.equity_curve
.iter()
.map(|value| *value as f64)
.collect();
TearSheet {
monthly_returns: monthly_return_matrix(&result.returns, 21),
rolling_sharpe: rolling_sharpe(&result.returns, rolling_window, periods_per_year),
drawdown_events: drawdown_series(&equity),
trade_analytics: TradeAnalytics {
trade_count: attribution.trades.len(),
open_trade_count: attribution
.trades
.iter()
.filter(|trade| trade.exit_index.is_none())
.count(),
closed_trade_count: attribution
.trades
.iter()
.filter(|trade| trade.exit_index.is_some())
.count(),
},
}
}
fn monthly_return_matrix(returns: &[f64], periods_per_month: usize) -> Vec<Vec<f64>> {
if periods_per_month == 0 {
return Vec::new();
}
returns
.chunks(periods_per_month)
.map(|chunk| chunk.iter().fold(1.0, |acc, value| acc * (1.0 + value)) - 1.0)
.collect::<Vec<_>>()
.chunks(12)
.map(|year| year.to_vec())
.collect()
}
pub fn decompose_backtest(
weight_schedule: &[Vec<(Symbol, f64)>],
return_schedule: &[Vec<(Symbol, f64)>],
) -> AttributionResult {
if weight_schedule.len() != return_schedule.len() {
return AttributionResult {
contributions: Vec::new(),
cumulative_contributions: Vec::new(),
trades: Vec::new(),
};
}
let mut cumulative: HashMap<Symbol, f64> = HashMap::new();
let mut previous_weights: HashMap<Symbol, f64> = HashMap::new();
let mut open_trades: HashMap<Symbol, (usize, f64)> = HashMap::new();
let mut contributions = Vec::with_capacity(weight_schedule.len());
let mut cumulative_contributions = Vec::with_capacity(weight_schedule.len());
let mut trades = Vec::new();
for (period_index, (weights, returns)) in
weight_schedule.iter().zip(return_schedule).enumerate()
{
let weight_map: HashMap<Symbol, f64> = weights.iter().copied().collect();
let return_map: HashMap<Symbol, f64> = returns.iter().copied().collect();
let mut symbols: Vec<Symbol> = weight_map
.keys()
.chain(return_map.keys())
.chain(previous_weights.keys())
.copied()
.collect();
symbols.sort_unstable();
symbols.dedup();
let mut period_contrib = Vec::new();
let mut period_cumulative = Vec::new();
for symbol in symbols {
let weight = weight_map
.get(&symbol)
.copied()
.filter(|value| value.is_finite())
.unwrap_or(0.0);
let previous = previous_weights
.get(&symbol)
.copied()
.filter(|value| value.is_finite())
.unwrap_or(0.0);
if previous == 0.0 && weight != 0.0 {
open_trades.insert(symbol, (period_index, weight));
} else if previous != 0.0 && weight == 0.0 {
if let Some((entry_index, entry_weight)) = open_trades.remove(&symbol) {
trades.push(AttributionTrade {
symbol,
entry_index,
exit_index: Some(period_index),
entry_weight,
exit_weight: previous,
});
}
}
let period_return = return_map
.get(&symbol)
.copied()
.filter(|value| value.is_finite())
.unwrap_or(0.0);
let contribution = weight * period_return;
if contribution != 0.0 || weight != 0.0 || previous != 0.0 {
let running = cumulative.entry(symbol).or_insert(0.0);
*running += contribution;
period_contrib.push((symbol, contribution));
period_cumulative.push((symbol, *running));
}
}
period_contrib.sort_by_key(|(symbol, _)| *symbol);
period_cumulative.sort_by_key(|(symbol, _)| *symbol);
contributions.push(period_contrib);
cumulative_contributions.push(period_cumulative);
previous_weights = weight_map;
}
for (symbol, (entry_index, entry_weight)) in open_trades {
let exit_weight = previous_weights
.get(&symbol)
.copied()
.unwrap_or(entry_weight);
trades.push(AttributionTrade {
symbol,
entry_index,
exit_index: None,
entry_weight,
exit_weight,
});
}
trades.sort_by_key(|trade| (trade.entry_index, trade.symbol));
AttributionResult {
contributions,
cumulative_contributions,
trades,
}
}
fn valid_inputs(
weight_schedule: &[Vec<(Symbol, f64)>],
price_schedule: &[Vec<(Symbol, BarPrices)>],
initial_cash_cents: i64,
) -> bool {
if weight_schedule.len() != price_schedule.len() {
return false;
}
if initial_cash_cents <= 0 {
return false;
}
for (weights, prices) in weight_schedule.iter().zip(price_schedule.iter()) {
for &(_, w) in weights {
if !w.is_finite() {
return false;
}
}
for &(_, bp) in prices {
if bp.open < 0 || bp.high < 0 || bp.low < 0 || bp.close < 0 {
return false;
}
if bp.high < bp.low {
return false;
}
}
}
true
}
fn empty_result(initial_cash_cents: i64) -> BacktestBridgeResult {
BacktestBridgeResult {
returns: Vec::new(),
equity_curve: vec![initial_cash_cents],
final_cash: initial_cash_cents,
metrics: None,
holdings: Vec::new(),
symbol_returns: Vec::new(),
stop_events: Vec::new(),
skipped_rebalances: Vec::new(),
}
}
#[derive(Clone, Debug)]
struct StopTracker {
side: i8, entry_price: i64,
reference_price: i64,
last_price: i64,
abs_changes: Vec<i64>,
}
impl StopTracker {
fn new(entry_price: i64, side: i8) -> Self {
Self {
side,
entry_price,
reference_price: entry_price,
last_price: entry_price,
abs_changes: Vec::new(),
}
}
fn update(&mut self, price: i64, atr_period: usize) {
if price <= 0 {
return;
}
let delta = (price - self.last_price).abs();
self.abs_changes.push(delta);
let keep = atr_period.max(1) * 6;
if self.abs_changes.len() > keep {
let drop_n = self.abs_changes.len() - keep;
self.abs_changes.drain(..drop_n);
}
self.last_price = price;
if self.side > 0 {
self.reference_price = self.reference_price.max(price);
} else {
self.reference_price = self.reference_price.min(price);
}
}
fn atr(&self, atr_period: usize) -> Option<f64> {
if self.abs_changes.is_empty() {
return None;
}
let k = atr_period.max(1).min(self.abs_changes.len());
let tail = &self.abs_changes[self.abs_changes.len() - k..];
let mean = tail.iter().map(|x| *x as f64).sum::<f64>() / k as f64;
Some(mean)
}
}
fn apply_stop_cfg(
portfolio: &mut Portfolio,
price_map: &HashMap<Symbol, i64>,
period_index: usize,
cfg: &BacktestStopConfig,
trackers: &mut HashMap<Symbol, StopTracker>,
stop_events: &mut Vec<BacktestStopEvent>,
) {
let open_positions: Vec<(Symbol, i64, i64)> = portfolio
.positions()
.filter_map(|(sym, pos)| {
if pos.is_flat() {
return None;
}
let px = price_map.get(sym).copied()?;
if px <= 0 {
return None;
}
Some((*sym, pos.quantity, px))
})
.collect();
let open_symbols: HashSet<Symbol> = open_positions.iter().map(|(s, _, _)| *s).collect();
trackers.retain(|sym, _| open_symbols.contains(sym));
for (sym, qty, price) in open_positions {
let side = if qty >= 0 { 1 } else { -1 };
let tracker = match trackers.entry(sym) {
Entry::Occupied(mut entry) => {
if entry.get().side != side {
entry.insert(StopTracker::new(price, side));
} else {
entry.get_mut().update(price, cfg.atr_period);
}
entry.into_mut()
}
Entry::Vacant(entry) => entry.insert(StopTracker::new(price, side)),
};
let Some((stop_level, reason)) = effective_stop_level(cfg, tracker) else {
continue;
};
let breached = if side > 0 {
price <= stop_level
} else {
price >= stop_level
};
if breached {
let closed = portfolio.close_position_at(sym, price);
if closed {
stop_events.push(BacktestStopEvent {
period_index,
symbol: sym,
trigger_price: stop_level,
exit_price: price,
reason,
});
trackers.remove(&sym);
}
}
}
}
fn effective_stop_level(
cfg: &BacktestStopConfig,
tracker: &StopTracker,
) -> Option<(i64, &'static str)> {
let mut candidates = Vec::new();
if let Some(p) = cfg.fixed_stop_pct {
let level = if tracker.side > 0 {
(tracker.entry_price as f64 * (1.0 - p)).round() as i64
} else {
(tracker.entry_price as f64 * (1.0 + p)).round() as i64
}
.max(1);
candidates.push((level, "fixed"));
}
if let Some(p) = cfg.trailing_stop_pct {
let level = if tracker.side > 0 {
(tracker.reference_price as f64 * (1.0 - p)).round() as i64
} else {
(tracker.reference_price as f64 * (1.0 + p)).round() as i64
}
.max(1);
candidates.push((level, "trailing"));
}
if let Some(mult) = cfg.atr_multiple {
if let Some(atr) = tracker.atr(cfg.atr_period) {
let level = if tracker.side > 0 {
(tracker.reference_price as f64 - mult * atr).round() as i64
} else {
(tracker.reference_price as f64 + mult * atr).round() as i64
}
.max(1);
candidates.push((level, "atr"));
}
}
if candidates.is_empty() {
return None;
}
if tracker.side > 0 {
candidates.into_iter().max_by_key(|(level, _)| *level)
} else {
candidates.into_iter().min_by_key(|(level, _)| *level)
}
}
fn sanitize_pct(v: Option<f64>) -> Option<f64> {
v.filter(|x| x.is_finite() && *x > 0.0 && *x < 1.0)
}
fn sanitize_positive(v: Option<f64>) -> Option<f64> {
v.filter(|x| x.is_finite() && *x > 0.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn aapl() -> Symbol {
Symbol::new("AAPL")
}
fn msft() -> Symbol {
Symbol::new("MSFT")
}
fn bar(p: i64) -> BarPrices {
BarPrices {
open: p,
high: p,
low: p,
close: p,
}
}
#[test]
fn signal_bar_close_parity_with_degenerate_ohlc() {
let weights = vec![vec![(aapl(), 1.0)]; 3];
let close_prices = [100_00i64, 110_00, 99_00];
let prices: Vec<Vec<(Symbol, BarPrices)>> = close_prices
.iter()
.map(|&p| vec![(aapl(), bar(p))])
.collect();
let result = backtest_weights(
&weights,
&prices,
1_000_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
);
assert_eq!(result.equity_curve.len(), 4);
assert!(result.skipped_rebalances.is_empty());
assert!(result.equity_curve[2] > result.equity_curve[0]);
}
#[test]
fn next_bar_open_fills_at_open_t_plus_1() {
let n = 4usize;
let closes = [100_00i64, 101_00, 102_00, 103_00];
let opens = [99_00i64, 100_00 + 100, 101_00 + 100, 102_00 + 100];
let prices: Vec<Vec<(Symbol, BarPrices)>> = (0..n)
.map(|i| {
vec![(
aapl(),
BarPrices {
open: opens[i],
high: opens[i].max(closes[i]),
low: opens[i].min(closes[i]),
close: closes[i],
},
)]
})
.collect();
let weights = vec![vec![(aapl(), 1.0)]; n];
let result = backtest_weights(
&weights,
&prices,
1_000_000_00,
CostModel::zero(),
FillPolicy::NextBarOpen,
252.0,
0.0,
);
assert!(result.skipped_rebalances.contains(&(n - 1)));
assert_eq!(result.equity_curve.len(), n + 1);
}
#[test]
fn next_bar_typical_fill_price_is_hlc3() {
let base = 100_00i64;
let h1 = base * 102 / 100;
let l1 = base * 99 / 100;
let c1 = base;
let prices = vec![
vec![(aapl(), bar(base))],
vec![(
aapl(),
BarPrices {
open: c1,
high: h1,
low: l1,
close: c1,
},
)],
];
let weights = vec![vec![(aapl(), 1.0)]; 2];
let result = backtest_weights(
&weights,
&prices,
1_000_000_00,
CostModel::zero(),
FillPolicy::NextBarTypical,
252.0,
0.0,
);
assert!(result.skipped_rebalances.contains(&1));
assert_eq!(result.equity_curve.len(), 3);
}
#[test]
fn last_bar_skip_with_next_bar_open() {
let n = 3usize;
let p = 100_00i64;
let prices: Vec<Vec<(Symbol, BarPrices)>> =
(0..n).map(|_| vec![(aapl(), bar(p))]).collect();
let weights: Vec<Vec<(Symbol, f64)>> = (0..n).map(|_| vec![(aapl(), 1.0)]).collect();
let result = backtest_weights(
&weights,
&prices,
1_000_000_00,
CostModel::zero(),
FillPolicy::NextBarOpen,
252.0,
0.0,
);
assert!(result.skipped_rebalances.contains(&(n - 1)));
assert_eq!(result.equity_curve.len(), n + 1);
assert_eq!(result.returns.len(), n);
}
#[test]
fn tear_sheet_contains_reporting_payload() {
let weights = vec![vec![(aapl(), 1.0)]; 24];
let prices: Vec<Vec<(Symbol, BarPrices)>> = (0..24)
.map(|i| vec![(aapl(), bar(100_00 + i as i64 * 100))])
.collect();
let result = backtest_weights(
&weights,
&prices,
1_000_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
);
let sheet = tear_sheet(&result, 5, 252);
assert_eq!(sheet.monthly_returns.len(), 1);
assert_eq!(sheet.monthly_returns[0].len(), 2);
assert_eq!(sheet.rolling_sharpe.len(), result.returns.len());
assert!(sheet.trade_analytics.trade_count >= 1);
}
#[test]
fn monthly_return_matrix_compounds_chunks() {
let matrix = monthly_return_matrix(&[0.1, 0.1, -0.1], 2);
assert_eq!(matrix.len(), 1);
assert!((matrix[0][0] - 0.21).abs() < 1e-12);
assert!((matrix[0][1] + 0.1).abs() < 1e-12);
}
#[test]
fn decompose_backtest_computes_contribution_and_cumulative_sum() {
let weights = vec![
vec![(aapl(), 0.6), (msft(), 0.4)],
vec![(aapl(), 0.5), (msft(), 0.5)],
];
let returns = vec![
vec![(aapl(), 0.10), (msft(), -0.05)],
vec![(aapl(), 0.02), (msft(), 0.04)],
];
let result = decompose_backtest(&weights, &returns);
assert_eq!(
result.contributions[0],
vec![(aapl(), 0.06), (msft(), -0.020000000000000004)]
);
assert_eq!(
result.contributions[1],
vec![(aapl(), 0.01), (msft(), 0.02)]
);
assert!((result.cumulative_contributions[1][0].1 - 0.07).abs() < 1e-12);
assert!((result.cumulative_contributions[1][1].1 - 0.0).abs() < 1e-12);
}
#[test]
fn decompose_backtest_detects_entries_exits_and_open_trades() {
let weights = vec![
vec![(aapl(), 1.0)],
vec![(aapl(), 0.5), (msft(), 0.5)],
vec![(msft(), 1.0)],
];
let returns = vec![
vec![(aapl(), 0.01)],
vec![(aapl(), 0.01), (msft(), 0.02)],
vec![(msft(), 0.03)],
];
let result = decompose_backtest(&weights, &returns);
assert!(result.trades.iter().any(|trade| {
trade.symbol == aapl()
&& trade.entry_index == 0
&& trade.exit_index == Some(2)
&& trade.entry_weight == 1.0
&& trade.exit_weight == 0.5
}));
assert!(result.trades.iter().any(|trade| {
trade.symbol == msft() && trade.entry_index == 1 && trade.exit_index.is_none()
}));
}
#[test]
fn decompose_backtest_rejects_mismatched_lengths_with_empty_result() {
let result = decompose_backtest(&[vec![(aapl(), 1.0)]], &[]);
assert!(result.contributions.is_empty());
assert!(result.cumulative_contributions.is_empty());
assert!(result.trades.is_empty());
}
#[test]
fn decompose_backtest_integrates_with_backtest_weights_outputs() {
let weights = vec![
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
];
let prices = vec![
vec![(aapl(), bar(100_00))],
vec![(aapl(), bar(110_00))],
vec![(aapl(), bar(99_00))],
];
let backtest = backtest_weights(
&weights,
&prices,
1_000_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
);
let attribution = decompose_backtest(&backtest.holdings, &backtest.symbol_returns);
for (period, contributions) in attribution.contributions.iter().enumerate() {
let summed: f64 = contributions.iter().map(|(_, value)| value).sum();
assert!((summed - backtest.returns[period]).abs() < 1e-12);
}
}
#[test]
fn decompose_backtest_treats_non_finite_returns_as_zero() {
let weights = vec![vec![(aapl(), 1.0)]];
let returns = vec![vec![(aapl(), f64::NAN)]];
let result = decompose_backtest(&weights, &returns);
assert_eq!(result.contributions, vec![vec![(aapl(), 0.0)]]);
assert_eq!(result.cumulative_contributions, vec![vec![(aapl(), 0.0)]]);
}
#[test]
fn basic_two_period_backtest() {
let weights = vec![
vec![(aapl(), 0.5), (msft(), 0.5)],
vec![(aapl(), 0.3), (msft(), 0.7)],
];
let prices = vec![
vec![(aapl(), bar(150_00)), (msft(), bar(300_00))],
vec![(aapl(), bar(155_00)), (msft(), bar(310_00))],
];
let result = backtest_weights(
&weights,
&prices,
1_000_000_00,
CostModel {
commission_bps: 10.0,
slippage_bps: 0.0,
min_commission: 0,
},
FillPolicy::SignalBarClose,
252.0,
0.0,
);
assert_eq!(result.returns.len(), 2);
assert_eq!(result.equity_curve.len(), 3); assert!(result.metrics.is_some());
assert_eq!(result.holdings.len(), 2);
assert_eq!(result.symbol_returns.len(), 2);
}
#[test]
fn zero_cost_preserves_equity() {
let weights = vec![vec![(aapl(), 0.5)]];
let prices = vec![vec![(aapl(), bar(100_00))]];
let result = backtest_weights(
&weights,
&prices,
1_000_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
);
let final_eq = *result
.equity_curve
.last()
.expect("equity curve has one point");
assert!((final_eq - 1_000_000_00).abs() < 200_00); }
#[test]
fn empty_schedule() {
let result = backtest_weights(
&[],
&[],
1_000_000_00,
CostModel {
commission_bps: 10.0,
slippage_bps: 0.0,
min_commission: 0,
},
FillPolicy::SignalBarClose,
252.0,
0.0,
);
assert!(result.returns.is_empty());
assert!(result.metrics.is_none());
assert_eq!(result.equity_curve.len(), 1);
assert!(result.holdings.is_empty());
assert!(result.symbol_returns.is_empty());
}
#[test]
fn fixed_stop_triggers_exit() {
let weights = vec![vec![(aapl(), 1.0)], vec![(aapl(), 1.0)]];
let prices = vec![vec![(aapl(), bar(100_00))], vec![(aapl(), bar(85_00))]];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: Some(0.10),
trailing_stop_pct: None,
atr_multiple: None,
atr_period: 14,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert_eq!(result.stop_events.len(), 1);
assert_eq!(result.stop_events[0].reason, "fixed");
assert_eq!(result.stop_events[0].period_index, 1);
assert_eq!(result.stop_events[0].trigger_price, 90_00);
assert_eq!(result.stop_events[0].exit_price, 85_00);
assert!(result.holdings[1].is_empty());
}
#[test]
fn trailing_stop_emits_event() {
let weights = vec![
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
];
let prices = vec![
vec![(aapl(), bar(100_00))],
vec![(aapl(), bar(110_00))],
vec![(aapl(), bar(95_00))],
];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: None,
trailing_stop_pct: Some(0.10),
atr_multiple: None,
atr_period: 14,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert!(!result.stop_events.is_empty());
assert_eq!(result.stop_events[0].reason, "trailing");
}
#[test]
fn first_breach_triggers_once_per_position_lifecycle() {
let weights = vec![
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
];
let prices = vec![
vec![(aapl(), bar(100_00))],
vec![(aapl(), bar(90_00))], vec![(aapl(), bar(89_00))], ];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: Some(0.10),
trailing_stop_pct: None,
atr_multiple: None,
atr_period: 14,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert_eq!(result.stop_events.len(), 1);
assert_eq!(result.stop_events[0].period_index, 1);
assert_eq!(result.stop_events[0].reason, "fixed");
}
#[test]
fn tighter_stop_reason_is_reported_when_multiple_rules_enabled() {
let weights = vec![
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
];
let prices = vec![
vec![(aapl(), bar(100_00))],
vec![(aapl(), bar(110_00))], vec![(aapl(), bar(103_00))], ];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: Some(0.10),
trailing_stop_pct: Some(0.05),
atr_multiple: None,
atr_period: 14,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert_eq!(result.stop_events.len(), 1);
assert_eq!(result.stop_events[0].reason, "trailing");
assert_eq!(result.stop_events[0].trigger_price, 104_50);
}
#[test]
fn atr_stop_triggers_on_high_volatility() {
let weights = vec![
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
];
let prices = vec![
vec![(aapl(), bar(100_00))],
vec![(aapl(), bar(110_00))],
vec![(aapl(), bar(95_00))],
vec![(aapl(), bar(85_00))],
];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: None,
trailing_stop_pct: None,
atr_multiple: Some(2.0), atr_period: 3,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert!(!result.stop_events.is_empty());
assert_eq!(result.stop_events[0].reason, "atr");
}
#[test]
fn short_position_fixed_stop_triggers_on_rise() {
let weights = vec![vec![(aapl(), -1.0)], vec![(aapl(), -1.0)]];
let prices = vec![vec![(aapl(), bar(100_00))], vec![(aapl(), bar(115_00))]];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: Some(0.10), trailing_stop_pct: None,
atr_multiple: None,
atr_period: 14,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert_eq!(result.stop_events.len(), 1);
assert_eq!(result.stop_events[0].reason, "fixed");
assert_eq!(result.stop_events[0].trigger_price, 110_00); assert_eq!(result.stop_events[0].exit_price, 115_00);
}
#[test]
fn short_position_trailing_stop_adjusts_downward() {
let weights = vec![
vec![(aapl(), -1.0)],
vec![(aapl(), -1.0)],
vec![(aapl(), -1.0)],
];
let prices = vec![
vec![(aapl(), bar(100_00))],
vec![(aapl(), bar(90_00))], vec![(aapl(), bar(98_00))], ];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: None,
trailing_stop_pct: Some(0.05),
atr_multiple: None,
atr_period: 14,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert_eq!(result.stop_events.len(), 1);
assert_eq!(result.stop_events[0].reason, "trailing");
assert_eq!(result.stop_events[0].trigger_price, 94_50);
assert_eq!(result.stop_events[0].exit_price, 98_00);
}
#[test]
fn multiple_symbols_independent_stops() {
let weights = vec![
vec![(aapl(), 0.5), (msft(), 0.5)],
vec![(aapl(), 0.5), (msft(), 0.5)],
];
let prices = vec![
vec![(aapl(), bar(100_00)), (msft(), bar(100_00))],
vec![(aapl(), bar(85_00)), (msft(), bar(95_00))],
];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: Some(0.10),
trailing_stop_pct: None,
atr_multiple: None,
atr_period: 14,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert_eq!(result.stop_events.len(), 1);
assert_eq!(result.stop_events[0].symbol, aapl());
assert!(result.holdings[1].iter().all(|(sym, _)| *sym != aapl()));
}
#[test]
fn position_flip_resets_stop_tracker() {
let weights = vec![
vec![(aapl(), 1.0)], vec![(aapl(), -1.0)], vec![(aapl(), -1.0)],
];
let prices = vec![
vec![(aapl(), bar(100_00))],
vec![(aapl(), bar(95_00))],
vec![(aapl(), bar(110_00))], ];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: Some(0.10),
trailing_stop_pct: None,
atr_multiple: None,
atr_period: 14,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert_eq!(result.stop_events.len(), 1);
assert_eq!(result.stop_events[0].period_index, 2);
}
#[test]
fn stop_loss_with_low_volatility_no_atr_trigger() {
let weights = vec![
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
vec![(aapl(), 1.0)],
];
let prices = vec![
vec![(aapl(), bar(100_00))],
vec![(aapl(), bar(101_00))],
vec![(aapl(), bar(102_00))],
vec![(aapl(), bar(101_50))],
];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: None,
trailing_stop_pct: None,
atr_multiple: Some(3.0), atr_period: 3,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert!(result.stop_events.is_empty());
}
#[test]
fn stop_loss_with_rebalance_keeps_tracking() {
let weights = vec![
vec![(aapl(), 0.8), (msft(), 0.2)],
vec![(aapl(), 0.6), (msft(), 0.4)], vec![(aapl(), 0.6), (msft(), 0.4)],
];
let prices = vec![
vec![(aapl(), bar(100_00)), (msft(), bar(100_00))],
vec![(aapl(), bar(95_00)), (msft(), bar(95_00))], vec![(aapl(), bar(85_00)), (msft(), bar(95_00))], ];
let options = BacktestBridgeOptions {
stop_cfg: Some(BacktestStopConfig {
fixed_stop_pct: Some(0.10),
trailing_stop_pct: None,
atr_multiple: None,
atr_period: 14,
}),
};
let result = backtest_weights_with_options(
&weights,
&prices,
100_000_00,
CostModel::zero(),
FillPolicy::SignalBarClose,
252.0,
0.0,
options,
);
assert_eq!(result.stop_events.len(), 1);
assert_eq!(result.stop_events[0].symbol, aapl());
assert!(result.holdings[2].iter().any(|(sym, _)| *sym == msft()));
}
}