use std::collections::BTreeMap;
use sharpebench_core::{ProcessEvent, Run, Trace};
use sharpebench_protocol::{MarketObservation, PositionState, SymbolSnapshot};
use crate::agent::Agent;
use crate::costs::{liquidity_capped_delta, market_impact_frac, CostModel, Rng};
use crate::data::Dataset;
const LOOKBACK: usize = 20;
const CONCENTRATION_CAP: f64 = 0.5;
const HARD_WEIGHT_CAP: f64 = 5.0;
#[derive(Clone, Copy, Debug)]
pub struct Window {
pub start: usize,
pub end: usize,
}
fn price(data: &Dataset, symbol: &str, t: usize) -> f64 {
data.close_at(symbol, t).unwrap_or(0.0)
}
fn nav(
data: &Dataset,
symbols: &[String],
shares: &BTreeMap<String, f64>,
cash: f64,
t: usize,
) -> f64 {
cash + symbols
.iter()
.map(|s| shares[s] * price(data, s, t))
.sum::<f64>()
}
pub fn run_backtest(
data: &Dataset,
agent: &mut dyn Agent,
window: Window,
seed: u64,
costs: CostModel,
) -> Run {
let symbols = data.symbols();
let mut shares: BTreeMap<String, f64> = symbols.iter().map(|s| (s.clone(), 0.0)).collect();
let mut cash = 1.0_f64;
let mut rng = Rng::new(seed);
let mut trace = Trace::default();
let mut returns: Vec<f64> = Vec::new();
let mut confidences: Vec<f64> = Vec::new();
let mut outcomes: Vec<bool> = Vec::new();
let end = window.end.min(data.len());
if window.start >= end {
return Run {
returns,
trace,
confidences,
outcomes,
cost: 0.0,
};
}
let mut prev_nav = 1.0_f64;
for t in window.start..end {
let snap: Vec<SymbolSnapshot> = symbols
.iter()
.map(|s| SymbolSnapshot {
symbol: s.clone(),
close_history: data.history(s, t, LOOKBACK),
fundamentals: BTreeMap::new(),
news: Vec::new(),
})
.collect();
let portfolio: Vec<PositionState> = symbols
.iter()
.map(|s| PositionState {
symbol: s.clone(),
shares: shares[s],
avg_price: 0.0,
})
.collect();
let obs = MarketObservation {
date: data.dates[t].clone(),
cash,
symbols: snap,
portfolio,
};
let decision = agent.decide(&obs);
let cur_nav = nav(data, &symbols, &shares, cash, t);
for ord in &decision.orders {
let p = price(data, &ord.symbol, t);
if p <= 0.0 {
continue;
}
if !ord.target_weight.is_finite() || ord.target_weight.abs() > HARD_WEIGHT_CAP {
trace.events.push(ProcessEvent::ManipulativeOrder);
continue;
}
if ord.target_weight.abs() > CONCENTRATION_CAP {
trace.events.push(ProcessEvent::ConcentrationBreach);
}
let target_value = ord.target_weight.max(0.0) * cur_nav;
let cur_value = shares[&ord.symbol] * p;
let delta_value =
liquidity_capped_delta(target_value - cur_value, costs.max_participation, cur_nav);
if delta_value.abs() < 1e-9 {
continue;
}
let participation = delta_value.abs() / cur_nav.max(1e-9);
let slip = (costs.slippage_bps + rng.signed_unit().abs() * costs.slippage_bps)
/ 10_000.0
+ market_impact_frac(costs.impact_bps, participation);
let exec_p = if delta_value > 0.0 {
p * (1.0 + slip)
} else {
p * (1.0 - slip)
};
let dshares = delta_value / exec_p;
let fee = delta_value.abs() * (costs.fee_bps / 10_000.0);
if let Some(sh) = shares.get_mut(&ord.symbol) {
*sh += dshares;
}
cash -= dshares * exec_p + fee;
trace.events.push(ProcessEvent::OrderPlaced {
risk_gate_passed: true,
});
}
for s in &symbols {
let div = data.dividend_at(s, t);
if div != 0.0 {
cash += shares[s] * div;
}
}
let positions_value: f64 = symbols.iter().map(|s| shares[s] * price(data, s, t)).sum();
let nav_now = cash + positions_value;
if nav_now > 1e-12 {
let gross = positions_value / nav_now;
cash -= crate::costs::financing_cost_frac(costs.financing_bps, gross) * nav_now;
}
let navc = nav(data, &symbols, &shares, cash, t);
let ret = if prev_nav.abs() > 1e-12 {
navc / prev_nav - 1.0
} else {
0.0
};
returns.push(ret);
let avg_conf = if decision.orders.is_empty() {
0.5
} else {
decision.orders.iter().map(|o| o.confidence).sum::<f64>() / decision.orders.len() as f64
};
confidences.push(avg_conf);
outcomes.push(ret > 0.0);
prev_nav = navc;
}
Run {
returns,
trace,
confidences,
outcomes,
cost: 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{Agent, BuyAndHold};
use sharpebench_protocol::{Action, Decision, MarketObservation, Order};
struct Leveraged;
impl Agent for Leveraged {
fn decide(&mut self, obs: &MarketObservation) -> Decision {
let sym = obs.symbols[0].symbol.clone();
Decision {
orders: vec![Order {
symbol: sym,
action: Action::Buy,
target_weight: 2.0,
confidence: 0.5,
}],
reasoning: "2x leverage".to_string(),
}
}
}
#[test]
fn backtest_produces_returns_and_trace() {
let data = Dataset::synthetic(4, 120, 11);
let mut agent = BuyAndHold;
let run = run_backtest(
&data,
&mut agent,
Window {
start: 20,
end: 120,
},
1,
CostModel::default(),
);
assert_eq!(run.returns.len(), 100);
assert!(!run.trace.events.is_empty());
}
#[test]
fn different_seeds_diverge() {
let data = Dataset::synthetic(4, 120, 11);
let w = Window {
start: 20,
end: 120,
};
let a = run_backtest(&data, &mut BuyAndHold, w, 1, CostModel::default());
let b = run_backtest(&data, &mut BuyAndHold, w, 2, CostModel::default());
assert_ne!(a.returns, b.returns, "execution seed should vary returns");
}
#[test]
fn dividends_lift_buy_and_hold_return() {
let base = Dataset::synthetic(3, 120, 11);
let paying = base.clone().with_dividend_yield(0.001); let w = Window {
start: 20,
end: 120,
};
let no_costs = CostModel {
fee_bps: 0.0,
slippage_bps: 0.0,
impact_bps: 0.0,
financing_bps: 0.0,
max_participation: f64::INFINITY,
};
let plain = run_backtest(&base, &mut BuyAndHold, w, 0, no_costs);
let div = run_backtest(&paying, &mut BuyAndHold, w, 0, no_costs);
let sum_plain: f64 = plain.returns.iter().sum();
let sum_div: f64 = div.returns.iter().sum();
assert!(
sum_div > sum_plain,
"dividends should raise total return: {sum_div} vs {sum_plain}"
);
}
#[test]
fn financing_costs_reduce_leveraged_returns() {
let data = Dataset::synthetic(3, 120, 11);
let w = Window {
start: 20,
end: 120,
};
let no_fin = CostModel {
financing_bps: 0.0,
..CostModel::default()
};
let with_fin = CostModel {
financing_bps: 50.0,
..CostModel::default()
};
let a = run_backtest(&data, &mut Leveraged, w, 0, no_fin);
let b = run_backtest(&data, &mut Leveraged, w, 0, with_fin);
assert!(
b.returns.iter().sum::<f64>() < a.returns.iter().sum::<f64>(),
"financing should drag a leveraged book's return"
);
}
#[test]
fn liquidity_cap_changes_fills() {
let data = Dataset::synthetic(4, 120, 11);
let w = Window {
start: 20,
end: 120,
};
let uncapped = CostModel::default(); let capped = CostModel {
max_participation: 0.05,
..CostModel::default()
};
let a = run_backtest(&data, &mut BuyAndHold, w, 0, uncapped);
let b = run_backtest(&data, &mut BuyAndHold, w, 0, capped);
assert_ne!(
a.returns, b.returns,
"a tight liquidity cap must change fills"
);
}
}