use crate::align::align;
use crate::panel::Panel;
use ndarray::Array2;
use std::collections::HashMap;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Trade {
pub symbol: String,
pub entry_date: i32,
pub exit_date: Option<i32>,
pub ret: f64,
pub period: u32,
pub mae: Option<f64>,
pub mfe: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct BacktestConfig {
pub fee_ratio: f64,
pub tax_ratio: f64,
pub position_limit: f64,
pub slippage_ratio: f64,
pub initial_capital: f64,
pub max_participation: f64,
pub impact_coef: f64,
pub delist_after: usize,
pub delist_haircut: f64,
pub benchmark_key: Option<String>,
pub bootstrap_samples: usize,
pub bootstrap_block: usize,
pub live_performance_start: Option<i32>,
}
impl Default for BacktestConfig {
fn default() -> Self {
BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
position_limit: 0.0,
slippage_ratio: 0.0,
initial_capital: 0.0,
max_participation: 0.0,
impact_coef: 0.0,
delist_after: 0,
delist_haircut: 0.0,
benchmark_key: None,
bootstrap_samples: 0,
bootstrap_block: 0,
live_performance_start: None,
}
}
}
pub(crate) fn normalize_weights_row(row: &mut [f64]) {
let total = row.iter().map(|w| w.abs()).sum::<f64>().max(1.0);
for w in row.iter_mut() {
*w /= total;
}
}
pub(crate) fn cap_weights_row(row: &mut [f64], limit: f64) {
if limit <= 0.0 {
return;
}
for w in row.iter_mut() {
*w = w.clamp(-limit, limit);
}
}
pub(crate) fn cap_weights_by_liquidity(
row: &mut [f64],
dollar_vol: &[f64],
max_participation: f64,
initial_capital: f64,
) {
if max_participation <= 0.0 || initial_capital <= 0.0 {
return;
}
for (w, dv) in row.iter_mut().zip(dollar_vol) {
if dv.is_nan() {
continue;
}
let cap = max_participation * dv / initial_capital;
*w = w.clamp(-cap, cap);
}
}
fn scan_delistings(px: &Panel, delist_after: usize) -> Option<(Array2<bool>, Array2<bool>)> {
if delist_after == 0 {
return None;
}
let (nrows, n) = (px.nrows(), px.ncols());
let mut dead = Array2::from_elem((nrows, n), false);
let mut confirm = Array2::from_elem((nrows, n), false);
for c in 0..n {
let mut nan_run = 0usize;
for r in 0..nrows {
if px.data[[r, c]].is_nan() {
nan_run += 1;
if nan_run == delist_after {
confirm[[r, c]] = true;
}
if nan_run >= delist_after {
dead[[r, c]] = true;
}
} else {
nan_run = 0;
}
}
}
Some((dead, confirm))
}
fn conform_to(grid: &Panel, other: Option<&Panel>) -> Option<Array2<f64>> {
let other = other?;
let row_of: HashMap<i32, usize> = other
.dates
.iter()
.copied()
.enumerate()
.map(|(i, d)| (d, i))
.collect();
let col_of: HashMap<&str, usize> = other
.symbols
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
let mut out = Array2::from_elem(grid.data.raw_dim(), f64::NAN);
for (r, day) in grid.dates.iter().enumerate() {
let Some(&or) = row_of.get(day) else { continue };
for (c, sym) in grid.symbols.iter().enumerate() {
if let Some(&oc) = col_of.get(sym.as_str()) {
out[[r, c]] = other.data[[or, oc]];
}
}
}
Some(out)
}
fn excursion(
hi: &Option<Array2<f64>>,
lo: &Option<Array2<f64>>,
er: usize,
exit: usize,
c: usize,
ep: f64,
dir: f64,
) -> (Option<f64>, Option<f64>) {
let (Some(hi), Some(lo)) = (hi, lo) else {
return (None, None);
};
if ep == 0.0 || ep.is_nan() {
return (None, None);
}
let (mut mae, mut mfe): (Option<f64>, Option<f64>) = (None, None);
for r in er..=exit {
let (h, l) = (hi[[r, c]], lo[[r, c]]);
if h.is_nan() || l.is_nan() {
continue;
}
let favorable = if dir >= 0.0 { h } else { l };
let adverse = if dir >= 0.0 { l } else { h };
let fav = dir * (favorable / ep - 1.0);
let adv = dir * (adverse / ep - 1.0);
mfe = Some(mfe.map_or(fav, |m| m.max(fav)));
mae = Some(mae.map_or(adv, |m| m.min(adv)));
}
(mae, mfe)
}
pub struct BacktestRun {
pub dates: Vec<i32>,
pub equity: Vec<f64>,
pub trades: Vec<Trade>,
pub exposure: Vec<f64>,
}
pub fn run(
positions: &Panel,
prices: &Panel,
high: Option<&Panel>,
low: Option<&Panel>,
volume: Option<&Panel>,
cfg: &BacktestConfig,
) -> BacktestRun {
let (pos, px) = align(positions, prices);
let hi = conform_to(&px, high);
let lo = conform_to(&px, low);
let liquidity_on = cfg.max_participation > 0.0 && cfg.initial_capital > 0.0 && volume.is_some();
let impact_on = cfg.impact_coef > 0.0 && cfg.initial_capital > 0.0 && volume.is_some();
let dollar_vol: Option<Array2<f64>> = if liquidity_on || impact_on {
conform_to(&px, volume).map(|mut v| {
v.zip_mut_with(&px.data, |dv, p| *dv *= p);
v
})
} else {
None
};
let n = px.ncols();
let nrows = px.nrows();
let dates = px.dates.clone();
let delist = scan_delistings(&px, cfg.delist_after);
let mut target = Array2::zeros(px.data.raw_dim());
let mut rebalance = vec![false; nrows];
let mut exposure = vec![0.0_f64; nrows];
{
let mut last = vec![0.0_f64; n];
let mut prev_raw: Option<Vec<f64>> = None;
for r in 0..nrows {
for c in 0..n {
let v = pos.data[[r, c]];
if !v.is_nan() {
last[c] = v;
}
if let Some((dead, _)) = &delist {
if dead[[r, c]] {
last[c] = 0.0;
}
}
}
rebalance[r] = prev_raw.as_deref() != Some(last.as_slice());
prev_raw = Some(last.clone());
let mut row = last.clone();
normalize_weights_row(&mut row);
cap_weights_row(&mut row, cfg.position_limit);
if liquidity_on {
if let Some(dv) = &dollar_vol {
let dv_row: Vec<f64> = (0..n).map(|c| dv[[r, c]]).collect();
cap_weights_by_liquidity(
&mut row,
&dv_row,
cfg.max_participation,
cfg.initial_capital,
);
}
}
exposure[r] = row.iter().map(|w| w.abs()).sum();
for c in 0..n {
target[[r, c]] = row[c];
}
}
}
let mut equity = vec![1.0_f64; nrows];
let mut value = 1.0_f64;
let mut w_prev = vec![0.0_f64; n];
for c in 0..n {
w_prev[c] = target[[0, c]];
}
let dv_row = |r: usize| -> Option<Vec<f64>> {
if !impact_on {
return None;
}
dollar_vol
.as_ref()
.map(|dv| (0..n).map(|c| dv[[r, c]]).collect())
};
value *= 1.0 - rebalance_cost(&vec![0.0; n], &w_prev, dv_row(0).as_deref(), cfg);
equity[0] = value;
for r in 1..nrows {
let mut g = 0.0;
let mut drift = vec![0.0_f64; n];
for c in 0..n {
let p0 = px.data[[r - 1, c]];
let p1 = px.data[[r, c]];
let ret = if p0.is_nan() || p1.is_nan() || p0 == 0.0 {
0.0
} else {
p1 / p0 - 1.0
};
g += w_prev[c] * ret;
drift[c] = w_prev[c] * (1.0 + ret);
}
value *= 1.0 + g;
let factor = 1.0 + g;
if factor != 0.0 {
for c in 0..n {
drift[c] /= factor;
}
}
if let Some((_, confirm)) = &delist {
let mut loss = 0.0;
for c in 0..n {
if confirm[[r, c]] && drift[c] != 0.0 {
loss += drift[c] * cfg.delist_haircut;
drift[c] = 0.0;
}
}
if loss != 0.0 {
value *= 1.0 - loss;
let f = 1.0 - loss;
if f != 0.0 {
for w in drift.iter_mut() {
*w /= f;
}
}
}
}
if rebalance[r] {
let tgt: Vec<f64> = (0..n).map(|c| target[[r, c]]).collect();
value *= 1.0 - rebalance_cost(&drift, &tgt, dv_row(r).as_deref(), cfg);
w_prev = tgt;
} else {
w_prev = drift;
}
equity[r] = value;
}
let mut trades: Vec<Trade> = Vec::new();
for c in 0..n {
let mut open: Option<(usize, f64)> = None; let mut last_valid_px = f64::NAN;
for r in 0..nrows {
if !px.data[[r, c]].is_nan() {
last_valid_px = px.data[[r, c]];
}
let held = target[[r, c]] != 0.0;
let entry_now = held && open.is_none();
let exit_now = !held && open.is_some();
if entry_now {
open = Some((r, px.data[[r, c]]));
} else if exit_now {
let (er, ep) = open.take().unwrap();
let delisted = delist
.as_ref()
.map(|(_, confirm)| confirm[[r, c]])
.unwrap_or(false);
let xp = if delisted {
last_valid_px * (1.0 - cfg.delist_haircut)
} else {
px.data[[r, c]]
};
let gross = if ep == 0.0 || ep.is_nan() || xp.is_nan() {
1.0
} else {
xp / ep
};
let exit_leg = if delisted {
1.0
} else {
1.0 - cfg.fee_ratio - cfg.tax_ratio - cfg.slippage_ratio
};
let net = (1.0 - cfg.fee_ratio - cfg.slippage_ratio) * gross * exit_leg;
let dir = target[[er, c]].signum();
let (mae, mfe) = excursion(&hi, &lo, er, r, c, ep, dir);
trades.push(Trade {
symbol: px.symbols[c].clone(),
entry_date: px.dates[er],
exit_date: Some(px.dates[r]),
ret: net - 1.0,
period: (r - er) as u32,
mae,
mfe,
});
}
}
if let Some((er, ep)) = open {
let xp = px.data[[nrows - 1, c]];
let gross = if ep == 0.0 || ep.is_nan() || xp.is_nan() {
1.0
} else {
xp / ep
};
let dir = target[[er, c]].signum();
let (mae, mfe) = excursion(&hi, &lo, er, nrows - 1, c, ep, dir);
trades.push(Trade {
symbol: px.symbols[c].clone(),
entry_date: px.dates[er],
exit_date: None,
ret: gross - 1.0, period: (nrows - 1 - er) as u32,
mae,
mfe,
});
}
}
BacktestRun {
dates,
equity,
trades,
exposure,
}
}
fn rebalance_cost(
drift: &[f64],
target: &[f64],
dollar_vol: Option<&[f64]>,
cfg: &BacktestConfig,
) -> f64 {
let turnover: f64 = drift.iter().zip(target).map(|(d, t)| (t - d).abs()).sum();
let sells: f64 = drift
.iter()
.zip(target)
.map(|(d, t)| (d - t).max(0.0))
.sum();
let mut cost = (cfg.fee_ratio + cfg.slippage_ratio) * turnover + cfg.tax_ratio * sells;
if cfg.impact_coef > 0.0 && cfg.initial_capital > 0.0 {
if let Some(dv) = dollar_vol {
for ((d, t), &v) in drift.iter().zip(target).zip(dv) {
let dw = (t - d).abs();
if dw == 0.0 || v.is_nan() || v <= 0.0 {
continue;
}
let participation = (dw * cfg.initial_capital / v).min(1.0);
cost += dw * cfg.impact_coef * participation.sqrt();
}
}
}
cost
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cap_weights_clamps_each_to_limit_leaving_cash() {
let mut a = [0.5, 0.5];
cap_weights_row(&mut a, 0.3);
assert_eq!(a, [0.3, 0.3]); let mut b = [0.2, 0.2];
cap_weights_row(&mut b, 0.3);
assert_eq!(b, [0.2, 0.2]); let mut c = [0.5];
cap_weights_row(&mut c, 0.0);
assert_eq!(c, [0.5]); }
#[test]
fn normalize_caps_at_one_but_leaves_small_books() {
let mut a = [0.5, 0.5, 0.5]; normalize_weights_row(&mut a);
assert!((a[0] - 1.0 / 3.0).abs() < 1e-12);
let mut b = [0.2, 0.3]; normalize_weights_row(&mut b);
assert_eq!(b, [0.2, 0.3]);
}
#[test]
fn single_asset_full_weight_tracks_price() {
use crate::panel::Panel;
let pos = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![1.0], vec![1.0], vec![1.0]],
)
.unwrap();
let px = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![10.0], vec![11.0], vec![12.0]],
)
.unwrap();
let run = run(&pos, &px, None, None, None, &BacktestConfig::default());
assert_eq!(run.equity.len(), 3);
assert!((run.equity[0] - 1.0).abs() < 1e-12);
assert!((run.equity[1] - 1.1).abs() < 1e-12); assert!((run.equity[2] - 1.2).abs() < 1e-12); }
#[test]
fn slippage_charges_turnover_like_a_fee() {
use crate::panel::Panel;
let pos = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![1.0], vec![1.0], vec![0.0]],
)
.unwrap();
let px = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![10.0], vec![10.0], vec![10.0]],
)
.unwrap();
let slip = BacktestConfig {
slippage_ratio: 0.001,
..Default::default()
};
let r = run(&pos, &px, None, None, None, &slip);
let want = (1.0 - 0.001) * (1.0 - 0.001);
assert!(
(r.equity[2] - want).abs() < 1e-12,
"equity {} want {want}",
r.equity[2]
);
let t = &r.trades[0];
let want_ret = (1.0 - 0.001) * 1.0 * (1.0 - 0.001) - 1.0;
assert!((t.ret - want_ret).abs() < 1e-12, "trade ret {}", t.ret);
let fee = BacktestConfig {
fee_ratio: 0.001,
..Default::default()
};
let r2 = run(&pos, &px, None, None, None, &fee);
assert_eq!(r.equity, r2.equity);
}
#[test]
fn impact_cost_criteria() {
use crate::panel::Panel;
let dates = vec![20240102, 20240103];
let syms = vec!["LIQ".to_string(), "ILQ".to_string()];
let pos = Panel::from_rows(dates.clone(), syms.clone(), vec![vec![1.0, 1.0]; 2]).unwrap();
let px = Panel::from_rows(dates.clone(), syms.clone(), vec![vec![10.0, 10.0]; 2]).unwrap();
let vol = Panel::from_rows(dates.clone(), syms.clone(), vec![vec![1e9, 100.0]; 2]).unwrap();
let cfg = |coef: f64| BacktestConfig {
impact_coef: coef,
initial_capital: 1_000_000.0,
..Default::default()
};
let coef = 0.01;
let r = run(&pos, &px, None, None, Some(&vol), &cfg(coef));
let liq_impact = 0.5 * coef * (5e-5_f64).sqrt();
let ilq_impact = 0.5 * coef * 1.0_f64; let want = 1.0 - (liq_impact + ilq_impact);
assert!(
(r.equity[0] - want).abs() < 1e-15,
"equity {} want {want}",
r.equity[0]
);
assert!(ilq_impact > liq_impact);
let off = run(&pos, &px, None, None, Some(&vol), &cfg(0.0));
let legacy = run(
&pos,
&px,
None,
None,
Some(&vol),
&BacktestConfig::default(),
);
assert_eq!(off.equity, legacy.equity);
let r2 = run(&pos, &px, None, None, Some(&vol), &cfg(2.0 * coef));
assert!(((1.0 - r2.equity[0]) - 2.0 * (1.0 - r.equity[0])).abs() < 1e-15);
for bad in [0.0, f64::NAN] {
let vol_bad =
Panel::from_rows(dates.clone(), syms.clone(), vec![vec![1e9, bad]; 2]).unwrap();
let rb = run(&pos, &px, None, None, Some(&vol_bad), &cfg(coef));
let want_liq_only = 1.0 - liq_impact;
assert!(
(rb.equity[0] - want_liq_only).abs() < 1e-15,
"bad dv {bad}: equity {}",
rb.equity[0]
);
assert!(rb.equity.iter().all(|e| e.is_finite()));
}
let rn = run(&pos, &px, None, None, None, &cfg(coef));
assert_eq!(rn.equity, legacy.equity);
}
#[test]
fn impact_cost_is_sign_symmetric() {
use crate::panel::Panel;
let dates = vec![20240102, 20240103, 20240104];
let syms = vec!["A".to_string()];
let pos = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![vec![1.0], vec![1.0], vec![0.0]],
)
.unwrap();
let px = Panel::from_rows(dates.clone(), syms.clone(), vec![vec![10.0]; 3]).unwrap();
let vol = Panel::from_rows(dates.clone(), syms.clone(), vec![vec![1e6]; 3]).unwrap();
let cfg = BacktestConfig {
impact_coef: 0.01,
initial_capital: 1_000_000.0,
..Default::default()
};
let r = run(&pos, &px, None, None, Some(&vol), &cfg);
let entry_cost = 1.0 - r.equity[0];
let exit_cost = 1.0 - r.equity[2] / r.equity[1];
assert!(entry_cost > 0.0);
assert!(
(entry_cost - exit_cost).abs() < 1e-15,
"entry {entry_cost} vs exit {exit_cost}"
);
}
#[test]
fn liquidity_cap_limits_weight_to_volume_participation() {
use crate::panel::Panel;
let dates = vec![20240102, 20240103, 20240104];
let syms = vec!["A".to_string()];
let pos = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![vec![1.0], vec![1.0], vec![1.0]],
)
.unwrap();
let px = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![vec![10.0], vec![10.0], vec![10.0]],
)
.unwrap();
let vol = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![vec![1000.0], vec![1000.0], vec![1000.0]],
)
.unwrap();
let cfg = BacktestConfig {
initial_capital: 1_000_000.0,
max_participation: 0.05,
..Default::default()
};
let r = run(&pos, &px, None, None, Some(&vol), &cfg);
assert!((r.exposure[0] - 0.0005).abs() < 1e-12, "capped weight");
let r2 = run(
&pos,
&px,
None,
None,
Some(&vol),
&BacktestConfig::default(),
);
assert!((r2.exposure[0] - 1.0).abs() < 1e-12);
let r3 = run(&pos, &px, None, None, None, &cfg);
assert!((r3.exposure[0] - 1.0).abs() < 1e-12);
let vol_nan = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![vec![f64::NAN], vec![1000.0], vec![1000.0]],
)
.unwrap();
let r4 = run(&pos, &px, None, None, Some(&vol_nan), &cfg);
assert!((r4.exposure[0] - 1.0).abs() < 1e-12, "NaN dv -> no cap");
}
#[test]
fn delisting_forces_exit_with_haircut() {
use crate::panel::Panel;
let dates = vec![20240102, 20240103, 20240104, 20240105, 20240108];
let syms = vec!["A".to_string(), "B".to_string()];
let pos = Panel::from_rows(dates.clone(), syms.clone(), vec![vec![1.0, 1.0]; 5]).unwrap();
let px = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![
vec![10.0, 10.0],
vec![10.0, 10.0],
vec![10.0, f64::NAN],
vec![10.0, f64::NAN],
vec![10.0, f64::NAN],
],
)
.unwrap();
let r0 = run(&pos, &px, None, None, None, &BacktestConfig::default());
assert!((r0.equity[4] - 1.0).abs() < 1e-12, "legacy freezes");
assert!(r0
.trades
.iter()
.all(|t| t.symbol != "B" || t.exit_date.is_none()));
let cfg = BacktestConfig {
delist_after: 2,
delist_haircut: 1.0,
..Default::default()
};
let r = run(&pos, &px, None, None, None, &cfg);
assert!((r.equity[2] - 1.0).abs() < 1e-12, "before confirmation");
assert!((r.equity[3] - 0.5).abs() < 1e-12, "haircut hits equity");
assert!((r.equity[4] - 0.5).abs() < 1e-12);
let b = r
.trades
.iter()
.find(|t| t.symbol == "B" && t.exit_date.is_some())
.unwrap();
assert_eq!(b.exit_date, Some(20240105));
assert!((b.ret - (-1.0)).abs() < 1e-12, "total loss, ret {}", b.ret);
assert!((r.exposure[3] - 1.0).abs() < 1e-12);
let cfg0 = BacktestConfig {
delist_after: 2,
delist_haircut: 0.0,
..Default::default()
};
let r2 = run(&pos, &px, None, None, None, &cfg0);
assert!((r2.equity[4] - 1.0).abs() < 1e-12);
let b2 = r2
.trades
.iter()
.find(|t| t.symbol == "B" && t.exit_date.is_some())
.unwrap();
assert!(b2.ret.abs() < 1e-12, "flat exit, ret {}", b2.ret);
}
#[test]
fn run_reports_per_day_gross_exposure() {
use crate::panel::Panel;
let pos = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![1.0], vec![1.0], vec![1.0]],
)
.unwrap();
let px = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![10.0], vec![11.0], vec![12.0]],
)
.unwrap();
let run = run(&pos, &px, None, None, None, &BacktestConfig::default());
assert_eq!(run.exposure.len(), 3);
for e in &run.exposure {
assert!((e - 1.0).abs() < 1e-12);
}
}
#[test]
fn computes_direction_aware_mae_mfe() {
use crate::panel::Panel;
let dates = vec![20240102, 20240103, 20240104, 20240105];
let syms = vec!["LONG".to_string(), "SHORT".to_string()];
let pos = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![
vec![1.0, -1.0],
vec![1.0, -1.0],
vec![1.0, -1.0],
vec![0.0, -1.0],
],
)
.unwrap();
let close = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![
vec![10.0, 10.0],
vec![11.0, 9.0],
vec![12.0, 8.0],
vec![11.0, 9.0],
],
)
.unwrap();
let high = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![
vec![10.0, 10.0],
vec![13.0, 11.0],
vec![12.0, 12.0],
vec![11.0, 9.0],
],
)
.unwrap();
let low = Panel::from_rows(
dates.clone(),
syms.clone(),
vec![
vec![9.0, 10.0],
vec![11.0, 8.0],
vec![12.0, 7.0],
vec![10.0, 9.0],
],
)
.unwrap();
let r = run(
&pos,
&close,
Some(&high),
Some(&low),
None,
&BacktestConfig::default(),
);
let long = r.trades.iter().find(|t| t.symbol == "LONG").unwrap();
let short = r.trades.iter().find(|t| t.symbol == "SHORT").unwrap();
assert!((long.mfe.unwrap() - 0.3).abs() < 1e-9, "long mfe");
assert!((long.mae.unwrap() - (-0.1)).abs() < 1e-9, "long mae");
assert!((short.mfe.unwrap() - 0.3).abs() < 1e-9, "short mfe");
assert!((short.mae.unwrap() - (-0.2)).abs() < 1e-9, "short mae");
let r2 = run(&pos, &close, None, None, None, &BacktestConfig::default());
assert!(r2.trades.iter().all(|t| t.mae.is_none() && t.mfe.is_none()));
}
}