use crate::portfolio::{CostModel, Metrics, Portfolio};
use crate::types::Symbol;
pub trait Strategy {
fn compute_weights(
&self,
bar_index: usize,
prices: &[(Symbol, i64)],
portfolio: &Portfolio,
) -> Vec<(Symbol, f64)>;
}
#[derive(Clone, Debug)]
pub struct BacktestResult {
pub portfolio: Portfolio,
pub metrics: Option<Metrics>,
}
pub fn run_backtest<S: Strategy>(
strategy: &S,
price_series: &[Vec<(Symbol, i64)>],
initial_cash: i64,
cost_model: CostModel,
periods_per_year: f64,
risk_free: f64,
) -> BacktestResult {
let mut portfolio = Portfolio::new(initial_cash, cost_model);
for (i, prices) in price_series.iter().enumerate() {
let weights = strategy.compute_weights(i, prices, &portfolio);
portfolio.rebalance_simple(&weights, prices);
portfolio.record_return(prices);
}
let metrics =
crate::portfolio::compute_metrics(portfolio.returns(), periods_per_year, risk_free);
BacktestResult { portfolio, metrics }
}
pub struct EqualWeight;
impl Strategy for EqualWeight {
fn compute_weights(
&self,
_bar_index: usize,
prices: &[(Symbol, i64)],
_portfolio: &Portfolio,
) -> Vec<(Symbol, f64)> {
if prices.is_empty() {
return Vec::new();
}
let n = prices.len() as f64;
prices.iter().map(|&(sym, _)| (sym, 1.0 / n)).collect()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::inconsistent_digit_grouping)]
use super::*;
fn sym(s: &str) -> Symbol {
Symbol::new(s)
}
#[test]
fn equal_weight_single_stock() {
let prices = vec![
vec![(sym("AAPL"), 150_00)],
vec![(sym("AAPL"), 155_00)],
vec![(sym("AAPL"), 160_00)],
];
let result = run_backtest(
&EqualWeight,
&prices,
1_000_000_00,
CostModel::zero(),
12.0,
0.0,
);
assert!(result.portfolio.returns().len() == 3);
assert!(result.metrics.is_some());
let m = result.metrics.unwrap();
assert!(m.total_return > 0.0); }
#[test]
fn equal_weight_two_stocks() {
let prices = vec![
vec![(sym("AAPL"), 150_00), (sym("MSFT"), 300_00)],
vec![(sym("AAPL"), 155_00), (sym("MSFT"), 310_00)],
vec![(sym("AAPL"), 145_00), (sym("MSFT"), 320_00)],
];
let result = run_backtest(
&EqualWeight,
&prices,
1_000_000_00,
CostModel::zero(),
12.0,
0.0,
);
assert_eq!(result.portfolio.returns().len(), 3);
assert!(result.metrics.is_some());
}
#[test]
fn empty_price_series() {
let prices: Vec<Vec<(Symbol, i64)>> = vec![];
let result = run_backtest(
&EqualWeight,
&prices,
1_000_000_00,
CostModel::zero(),
12.0,
0.0,
);
assert!(result.portfolio.returns().is_empty());
assert!(result.metrics.is_none());
}
#[test]
fn custom_strategy() {
struct DelayedBuy;
impl Strategy for DelayedBuy {
fn compute_weights(
&self,
bar_index: usize,
prices: &[(Symbol, i64)],
_portfolio: &Portfolio,
) -> Vec<(Symbol, f64)> {
if bar_index == 0 {
Vec::new() } else {
prices.iter().map(|&(sym, _)| (sym, 1.0)).collect()
}
}
}
let prices = vec![
vec![(sym("AAPL"), 100_00)],
vec![(sym("AAPL"), 110_00)],
vec![(sym("AAPL"), 120_00)],
];
let result = run_backtest(
&DelayedBuy,
&prices,
100_000_00,
CostModel::zero(),
12.0,
0.0,
);
assert_eq!(result.portfolio.returns().len(), 3);
}
#[test]
fn backtest_with_costs() {
let cost_model = CostModel {
commission_bps: 10,
slippage_bps: 5,
min_trade_fee: 0,
};
let prices = vec![
vec![(sym("AAPL"), 150_00)],
vec![(sym("AAPL"), 150_00)], vec![(sym("AAPL"), 150_00)],
];
let result = run_backtest(&EqualWeight, &prices, 1_000_000_00, cost_model, 12.0, 0.0);
let m = result.metrics.unwrap();
assert!(m.total_return < 0.0);
}
#[test]
fn equal_weight_empty_bar() {
let strat = EqualWeight;
let weights = strat.compute_weights(0, &[], &Portfolio::new(100_00, CostModel::zero()));
assert!(weights.is_empty());
}
}