use super::*;
use crate::panel::Panel;
use std::collections::HashMap;
#[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()));
assert_eq!(long.side, TradeSide::Long);
assert!((long.entry_price - 10.0).abs() < 1e-12); assert!((long.exit_price.unwrap() - 11.0).abs() < 1e-12); assert_eq!(short.side, TradeSide::Short);
assert!((short.entry_price - 10.0).abs() < 1e-12);
assert!(short.exit_price.is_none()); }
#[test]
fn delisting_exit_price_is_haircut_last_valid() {
use crate::panel::Panel;
let dates = vec![20240102, 20240103, 20240104, 20240105];
let pos = Panel::from_rows(
dates.clone(),
vec!["A".into()],
vec![vec![1.0], vec![1.0], vec![1.0], vec![1.0]],
)
.unwrap();
let px = Panel::from_rows(
dates.clone(),
vec!["A".into()],
vec![vec![10.0], vec![20.0], vec![f64::NAN], vec![f64::NAN]],
)
.unwrap();
let cfg = BacktestConfig {
delist_after: 1,
delist_haircut: 0.1,
..Default::default()
};
let r = run(&pos, &px, None, None, None, &cfg);
let t = &r.trades[0];
assert_eq!(t.side, TradeSide::Long);
assert!((t.entry_price - 10.0).abs() < 1e-12);
assert!(t.exit_date.is_some(), "delisting force-closes the trade");
assert!(
(t.exit_price.unwrap() - 18.0).abs() < 1e-12,
"exit fills at 20 * (1 - 0.1) = 18, got {:?}",
t.exit_price
);
}
#[test]
fn terminal_weights_report_the_final_book() {
use crate::panel::Panel;
let dates = vec![20240102, 20240103];
let pos =
Panel::from_rows(dates.clone(), vec!["A".into()], vec![vec![1.0], vec![1.0]]).unwrap();
let px = Panel::from_rows(dates, vec!["A".into()], vec![vec![10.0], vec![11.0]]).unwrap();
let r = run(&pos, &px, None, None, None, &BacktestConfig::default());
assert_eq!(r.terminal_weights.len(), 1);
assert!((r.terminal_weights["A"] - 1.0).abs() < 1e-12);
}
#[test]
fn initial_weights_pay_seam_turnover_only_on_the_difference() {
use crate::panel::Panel;
let dates = vec![20240102, 20240103, 20240104];
let pos = Panel::from_rows(
dates.clone(),
vec!["A".into()],
vec![vec![1.0], vec![1.0], vec![1.0]],
)
.unwrap();
let px = Panel::from_rows(
dates,
vec!["A".into()],
vec![vec![10.0], vec![10.0], vec![10.0]],
)
.unwrap();
let cfg = BacktestConfig {
fee_ratio: 0.01,
..Default::default()
};
let flat = run(&pos, &px, None, None, None, &cfg);
assert!((flat.equity[0] - 0.99).abs() < 1e-12);
assert!((flat.terminal_weights["A"] - 1.0).abs() < 1e-12);
let carried = HashMap::from([("A".to_string(), 1.0)]);
let warm = run_with_initial(&pos, &px, None, None, None, None, &cfg, Some(&carried));
assert!((warm.equity[0] - 1.0).abs() < 1e-12, "no seam cost");
assert!((warm.equity[2] - 1.0).abs() < 1e-12);
let half = HashMap::from([("A".to_string(), 0.5)]);
let partial = run_with_initial(&pos, &px, None, None, None, None, &cfg, Some(&half));
assert!((partial.equity[0] - (1.0 - 0.01 * 0.5)).abs() < 1e-12);
let posb = Panel::from_rows(
vec![20240102, 20240103],
vec!["A".into(), "B".into()],
vec![vec![0.0, 1.0], vec![0.0, 1.0]],
)
.unwrap();
let pxb = Panel::from_rows(
vec![20240102, 20240103],
vec!["A".into(), "B".into()],
vec![vec![10.0, 10.0], vec![10.0, 10.0]],
)
.unwrap();
let cross = run_with_initial(
&posb,
&pxb,
None,
None,
None,
None,
&cfg,
Some(&HashMap::from([("A".to_string(), 1.0)])),
);
assert!((cross.equity[0] - (1.0 - 0.01 * 2.0)).abs() < 1e-12);
}
fn stop_fixture(
ohlc: &[(f64, f64, f64, f64)], ) -> (Panel, Panel, Panel, Panel, Panel) {
use crate::panel::Panel;
let dates: Vec<i32> = (0..ohlc.len() as i32).map(|i| 20240102 + i).collect();
let col = |f: fn(&(f64, f64, f64, f64)) -> f64| {
Panel::from_rows(
dates.clone(),
vec!["A".into()],
ohlc.iter().map(|x| vec![f(x)]).collect(),
)
.unwrap()
};
let pos = Panel::from_rows(
dates.clone(),
vec!["A".into()],
ohlc.iter().map(|_| vec![1.0]).collect(),
)
.unwrap();
(pos, col(|x| x.3), col(|x| x.0), col(|x| x.1), col(|x| x.2))
}
#[test]
fn touched_stop_loss_fills_at_the_level_not_the_close() {
let (pos, close, open, high, low) =
stop_fixture(&[(100.0, 100.0, 100.0, 100.0), (98.0, 99.0, 90.0, 95.0)]);
let cfg = BacktestConfig {
stops: StopConfig {
stop_loss: 0.08,
..Default::default()
},
..Default::default()
};
let r = run_with_initial(
&pos,
&close,
Some(&open),
Some(&high),
Some(&low),
None,
&cfg,
None,
);
assert!(
(r.equity[1] - 0.92).abs() < 1e-12,
"fill at 92, got {}",
r.equity[1]
);
let t = &r.trades[0];
assert!((t.exit_price.unwrap() - 92.0).abs() < 1e-12);
}
#[test]
fn gapped_stop_fills_at_the_open() {
let (pos, close, open, high, low) =
stop_fixture(&[(100.0, 100.0, 100.0, 100.0), (88.0, 89.0, 87.0, 88.0)]);
let cfg = BacktestConfig {
stops: StopConfig {
stop_loss: 0.08,
..Default::default()
},
..Default::default()
};
let r = run_with_initial(
&pos,
&close,
Some(&open),
Some(&high),
Some(&low),
None,
&cfg,
None,
);
assert!(
(r.equity[1] - 0.88).abs() < 1e-12,
"gap fill at open 88, got {}",
r.equity[1]
);
}
#[test]
fn close_fill_mode_triggers_and_fills_on_the_close() {
let (pos, close, open, high, low) =
stop_fixture(&[(100.0, 100.0, 100.0, 100.0), (98.0, 99.0, 90.0, 91.0)]);
let cfg = BacktestConfig {
stops: StopConfig {
stop_loss: 0.08,
fill: StopFill::Close,
..Default::default()
},
..Default::default()
};
let r = run_with_initial(
&pos,
&close,
Some(&open),
Some(&high),
Some(&low),
None,
&cfg,
None,
);
assert!(
(r.equity[1] - 0.91).abs() < 1e-12,
"close fill at 91, got {}",
r.equity[1]
);
}
#[test]
fn take_profit_fills_at_the_level() {
let (pos, close, open, high, low) =
stop_fixture(&[(100.0, 100.0, 100.0, 100.0), (108.0, 115.0, 107.0, 112.0)]);
let cfg = BacktestConfig {
stops: StopConfig {
take_profit: 0.10,
..Default::default()
},
..Default::default()
};
let r = run_with_initial(
&pos,
&close,
Some(&open),
Some(&high),
Some(&low),
None,
&cfg,
None,
);
assert!(
(r.equity[1] - 1.10).abs() < 1e-12,
"take-profit at 110, got {}",
r.equity[1]
);
}
#[test]
fn stopped_name_stays_flat_until_the_signal_resets() {
use crate::panel::Panel;
let dates: Vec<i32> = (0..5).map(|i| 20240102 + i).collect();
let pos = Panel::from_rows(
dates.clone(),
vec!["A".into()],
vec![vec![1.0], vec![1.0], vec![1.0], vec![0.0], vec![1.0]],
)
.unwrap();
let mk = |v: Vec<f64>| {
Panel::from_rows(
dates.clone(),
vec!["A".into()],
v.into_iter().map(|x| vec![x]).collect(),
)
.unwrap()
};
let close = mk(vec![100.0, 95.0, 200.0, 100.0, 100.0]);
let open = mk(vec![100.0, 98.0, 190.0, 100.0, 100.0]);
let high = mk(vec![100.0, 99.0, 205.0, 100.0, 100.0]);
let low = mk(vec![100.0, 90.0, 190.0, 100.0, 100.0]);
let cfg = BacktestConfig {
stops: StopConfig {
stop_loss: 0.08,
..Default::default()
},
..Default::default()
};
let r = run_with_initial(
&pos,
&close,
Some(&open),
Some(&high),
Some(&low),
None,
&cfg,
None,
);
assert!((r.equity[1] - 0.92).abs() < 1e-12);
assert!(
(r.equity[2] - 0.92).abs() < 1e-12,
"must be flat day2, got {}",
r.equity[2]
);
assert!((r.equity[4] - 0.92).abs() < 1e-12);
assert_eq!(r.trades.len(), 2, "expected a stopped exit + a re-entry");
let reentry = r.trades.iter().find(|t| t.entry_date == 20240106).unwrap();
assert!(reentry.exit_date.is_none(), "day4 re-entry is still open");
}
#[test]
fn trailing_stop_arms_and_ratchets() {
let (pos, close, open, high, low) = stop_fixture(&[
(100.0, 100.0, 100.0, 100.0),
(105.0, 120.0, 104.0, 118.0), (118.0, 118.0, 108.0, 109.0), ]);
let cfg = BacktestConfig {
stops: StopConfig {
trail_stop: 0.10,
trail_stop_activation: 0.05,
..Default::default()
},
..Default::default()
};
let r = run_with_initial(
&pos,
&close,
Some(&open),
Some(&high),
Some(&low),
None,
&cfg,
None,
);
assert!(
(r.equity[2] - 1.10).abs() < 1e-9,
"trail exit at 110, got {}",
r.equity[2]
);
}
#[test]
fn short_position_stop_loss_triggers_on_a_rise() {
use crate::panel::Panel;
let dates: Vec<i32> = (0..2).map(|i| 20240102 + i).collect();
let pos = Panel::from_rows(
dates.clone(),
vec!["A".into()],
vec![vec![-1.0], vec![-1.0]],
)
.unwrap();
let mk = |v: Vec<f64>| {
Panel::from_rows(
dates.clone(),
vec!["A".into()],
v.into_iter().map(|x| vec![x]).collect(),
)
.unwrap()
};
let close = mk(vec![100.0, 106.0]);
let open = mk(vec![100.0, 102.0]);
let high = mk(vec![100.0, 110.0]);
let low = mk(vec![100.0, 101.0]);
let cfg = BacktestConfig {
stops: StopConfig {
stop_loss: 0.08,
..Default::default()
},
..Default::default()
};
let r = run_with_initial(
&pos,
&close,
Some(&open),
Some(&high),
Some(&low),
None,
&cfg,
None,
);
assert!(
(r.equity[1] - 0.92).abs() < 1e-12,
"short stop at 108, got {}",
r.equity[1]
);
}
#[test]
fn stops_off_by_default_leaves_the_curve_unchanged() {
let (pos, close, open, high, low) =
stop_fixture(&[(100.0, 100.0, 100.0, 100.0), (98.0, 99.0, 90.0, 95.0)]);
let r = run_with_initial(
&pos,
&close,
Some(&open),
Some(&high),
Some(&low),
None,
&BacktestConfig::default(),
None,
);
assert!(
(r.equity[1] - 0.95).abs() < 1e-12,
"no stop -> close 95, got {}",
r.equity[1]
);
}