1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use sharpebench_core::{ProcessEvent, Run, Trace};
7use sharpebench_protocol::{Decision, MarketObservation, PositionState, SymbolSnapshot};
8
9use crate::agent::Agent;
10use crate::costs::{liquidity_capped_delta, market_impact_frac, CostModel, Rng};
11use crate::data::Dataset;
12
13const LOOKBACK: usize = 20;
14const CONCENTRATION_CAP: f64 = 0.5;
16const HARD_WEIGHT_CAP: f64 = 5.0;
19
20#[derive(Clone, Copy, Debug)]
22pub struct Window {
23 pub start: usize,
24 pub end: usize,
25}
26
27fn price(data: &Dataset, symbol: &str, t: usize) -> f64 {
28 data.close_at(symbol, t).unwrap_or(0.0)
29}
30
31pub(crate) fn nav(
32 data: &Dataset,
33 symbols: &[String],
34 shares: &BTreeMap<String, f64>,
35 cash: f64,
36 t: usize,
37) -> f64 {
38 cash + symbols
39 .iter()
40 .map(|s| shares[s] * price(data, s, t))
41 .sum::<f64>()
42}
43
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
53pub(crate) struct Book {
54 pub(crate) shares: BTreeMap<String, f64>,
55 pub(crate) cash: f64,
56 pub(crate) rng: Rng,
57 pub(crate) trace: Trace,
58 pub(crate) prev_nav: f64,
59}
60
61impl Book {
62 pub(crate) fn new(symbols: &[String], seed: u64) -> Self {
63 Book {
64 shares: symbols.iter().map(|s| (s.clone(), 0.0)).collect(),
65 cash: 1.0_f64,
66 rng: Rng::new(seed),
67 trace: Trace::default(),
68 prev_nav: 1.0_f64,
69 }
70 }
71}
72
73pub(crate) fn build_observation(
76 data: &Dataset,
77 symbols: &[String],
78 book: &Book,
79 t: usize,
80) -> MarketObservation {
81 let snap: Vec<SymbolSnapshot> = symbols
82 .iter()
83 .map(|s| SymbolSnapshot {
84 symbol: s.clone(),
85 close_history: data.history(s, t, LOOKBACK),
86 fundamentals: BTreeMap::new(),
87 news: Vec::new(),
88 })
89 .collect();
90 let portfolio: Vec<PositionState> = symbols
91 .iter()
92 .map(|s| PositionState {
93 symbol: s.clone(),
94 shares: book.shares[s],
95 avg_price: 0.0,
96 })
97 .collect();
98 MarketObservation {
99 date: data.dates[t].clone(),
100 cash: book.cash,
101 symbols: snap,
102 portfolio,
103 }
104}
105
106pub(crate) struct StepOutcome {
109 pub(crate) ret: f64,
110 pub(crate) confidence: f64,
111 pub(crate) outcome: bool,
112}
113
114pub(crate) fn step_once(
121 data: &Dataset,
122 symbols: &[String],
123 book: &mut Book,
124 costs: &CostModel,
125 t: usize,
126 decision: &Decision,
127) -> StepOutcome {
128 let cur_nav = nav(data, symbols, &book.shares, book.cash, t);
129
130 for ord in &decision.orders {
132 let p = price(data, &ord.symbol, t);
133 if p <= 0.0 {
134 continue;
135 }
136 if !ord.target_weight.is_finite() || ord.target_weight.abs() > HARD_WEIGHT_CAP {
138 book.trace.events.push(ProcessEvent::ManipulativeOrder);
139 continue;
140 }
141 if ord.target_weight.abs() > CONCENTRATION_CAP {
142 book.trace.events.push(ProcessEvent::ConcentrationBreach);
143 }
144 let target_value = ord.target_weight.max(0.0) * cur_nav;
145 let cur_value = book.shares[&ord.symbol] * p;
146 let delta_value =
149 liquidity_capped_delta(target_value - cur_value, costs.max_participation, cur_nav);
150 if delta_value.abs() < 1e-9 {
151 continue;
152 }
153 let participation = delta_value.abs() / cur_nav.max(1e-9);
156 let slip = (costs.slippage_bps + book.rng.signed_unit().abs() * costs.slippage_bps)
157 / 10_000.0
158 + market_impact_frac(costs.impact_bps, participation);
159 let exec_p = if delta_value > 0.0 {
160 p * (1.0 + slip)
161 } else {
162 p * (1.0 - slip)
163 };
164 let dshares = delta_value / exec_p;
165 let fee = delta_value.abs() * (costs.fee_bps / 10_000.0);
166 if let Some(sh) = book.shares.get_mut(&ord.symbol) {
167 *sh += dshares;
168 }
169 book.cash -= dshares * exec_p + fee;
170 if !ord.rationale.is_empty() {
173 book.trace.events.push(ProcessEvent::DecisionRationale {
174 symbol: ord.symbol.clone(),
175 rationale: ord.rationale.clone(),
176 });
177 }
178 book.trace.events.push(ProcessEvent::OrderPlaced {
179 risk_gate_passed: true,
180 });
181 }
182
183 for s in symbols {
185 let div = data.dividend_at(s, t);
186 if div != 0.0 {
187 book.cash += book.shares[s] * div;
188 }
189 }
190
191 let positions_value: f64 = symbols
193 .iter()
194 .map(|s| book.shares[s] * price(data, s, t))
195 .sum();
196 let nav_now = book.cash + positions_value;
197 if nav_now > 1e-12 {
198 let gross = positions_value / nav_now;
199 book.cash -= crate::costs::financing_cost_frac(costs.financing_bps, gross) * nav_now;
200 }
201
202 let navc = nav(data, symbols, &book.shares, book.cash, t);
205 let ret = if book.prev_nav.abs() > 1e-12 {
206 navc / book.prev_nav - 1.0
207 } else {
208 0.0
209 };
210 let avg_conf = if decision.orders.is_empty() {
213 0.5
214 } else {
215 decision.orders.iter().map(|o| o.confidence).sum::<f64>() / decision.orders.len() as f64
216 };
217 book.prev_nav = navc;
218 StepOutcome {
219 ret,
220 confidence: avg_conf,
221 outcome: ret > 0.0,
222 }
223}
224
225pub fn run_backtest(
230 data: &Dataset,
231 agent: &mut dyn Agent,
232 window: Window,
233 seed: u64,
234 costs: CostModel,
235) -> Run {
236 let symbols = data.symbols();
237 let end = window.end.min(data.len());
238 let mut book = Book::new(&symbols, seed);
239 let mut returns: Vec<f64> = Vec::new();
240 let mut confidences: Vec<f64> = Vec::new();
241 let mut outcomes: Vec<bool> = Vec::new();
242
243 for t in window.start..end {
244 let obs = build_observation(data, &symbols, &book, t);
245 let decision = agent.decide(&obs);
246 let out = step_once(data, &symbols, &mut book, &costs, t, &decision);
247 returns.push(out.ret);
248 confidences.push(out.confidence);
249 outcomes.push(out.outcome);
250 }
251
252 Run {
253 returns,
254 trace: book.trace,
255 confidences,
256 outcomes,
257 cost: 0.0,
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::agent::{Agent, BuyAndHold};
265 use sharpebench_protocol::{Action, Decision, MarketObservation, Order};
266
267 struct Leveraged;
269 impl Agent for Leveraged {
270 fn decide(&mut self, obs: &MarketObservation) -> Decision {
271 let sym = obs.symbols[0].symbol.clone();
272 Decision {
273 orders: vec![Order {
274 symbol: sym,
275 action: Action::Buy,
276 target_weight: 2.0,
277 confidence: 0.5,
278 rationale: "2x leverage".to_string(),
279 }],
280 reasoning: "2x leverage".to_string(),
281 }
282 }
283 }
284
285 struct RationaleAgent;
287 impl Agent for RationaleAgent {
288 fn decide(&mut self, obs: &MarketObservation) -> Decision {
289 let sym = obs.symbols[0].symbol.clone();
290 Decision {
291 orders: vec![Order {
292 symbol: sym,
293 action: Action::Buy,
294 target_weight: 0.2,
295 confidence: 0.7,
296 rationale: "momentum breakout".to_string(),
297 }],
298 reasoning: "single-name buy".to_string(),
299 }
300 }
301 }
302
303 #[test]
304 fn per_order_rationale_is_captured_into_the_trace() {
305 use sharpebench_core::ProcessEvent;
306 let data = Dataset::synthetic(3, 60, 5);
307 let run = run_backtest(
308 &data,
309 &mut RationaleAgent,
310 Window { start: 20, end: 60 },
311 1,
312 CostModel::default(),
313 );
314 let found = run.trace.events.iter().any(|e| {
315 matches!(e, ProcessEvent::DecisionRationale { rationale, .. } if rationale == "momentum breakout")
316 });
317 assert!(found, "the order rationale must land in the audit trace");
318 assert!(sharpebench_core::process::process_score(&run.trace).is_clean());
320 }
321
322 #[test]
323 fn backtest_produces_returns_and_trace() {
324 let data = Dataset::synthetic(4, 120, 11);
325 let mut agent = BuyAndHold;
326 let run = run_backtest(
327 &data,
328 &mut agent,
329 Window {
330 start: 20,
331 end: 120,
332 },
333 1,
334 CostModel::default(),
335 );
336 assert_eq!(run.returns.len(), 100);
337 assert!(!run.trace.events.is_empty());
338 }
339
340 #[test]
341 fn different_seeds_diverge() {
342 let data = Dataset::synthetic(4, 120, 11);
343 let w = Window {
344 start: 20,
345 end: 120,
346 };
347 let a = run_backtest(&data, &mut BuyAndHold, w, 1, CostModel::default());
348 let b = run_backtest(&data, &mut BuyAndHold, w, 2, CostModel::default());
349 assert_ne!(a.returns, b.returns, "execution seed should vary returns");
350 }
351
352 #[test]
353 fn dividends_lift_buy_and_hold_return() {
354 let base = Dataset::synthetic(3, 120, 11);
355 let paying = base.clone().with_dividend_yield(0.001); let w = Window {
357 start: 20,
358 end: 120,
359 };
360 let no_costs = CostModel {
362 fee_bps: 0.0,
363 slippage_bps: 0.0,
364 impact_bps: 0.0,
365 financing_bps: 0.0,
366 max_participation: f64::INFINITY,
367 trf_cost: None,
368 };
369 let plain = run_backtest(&base, &mut BuyAndHold, w, 0, no_costs);
370 let div = run_backtest(&paying, &mut BuyAndHold, w, 0, no_costs);
371 let sum_plain: f64 = plain.returns.iter().sum();
372 let sum_div: f64 = div.returns.iter().sum();
373 assert!(
374 sum_div > sum_plain,
375 "dividends should raise total return: {sum_div} vs {sum_plain}"
376 );
377 }
378
379 #[test]
380 fn financing_costs_reduce_leveraged_returns() {
381 let data = Dataset::synthetic(3, 120, 11);
382 let w = Window {
383 start: 20,
384 end: 120,
385 };
386 let no_fin = CostModel {
387 financing_bps: 0.0,
388 ..CostModel::default()
389 };
390 let with_fin = CostModel {
391 financing_bps: 50.0,
392 ..CostModel::default()
393 };
394 let a = run_backtest(&data, &mut Leveraged, w, 0, no_fin);
395 let b = run_backtest(&data, &mut Leveraged, w, 0, with_fin);
396 assert!(
397 b.returns.iter().sum::<f64>() < a.returns.iter().sum::<f64>(),
398 "financing should drag a leveraged book's return"
399 );
400 }
401
402 #[test]
403 fn liquidity_cap_changes_fills() {
404 let data = Dataset::synthetic(4, 120, 11);
405 let w = Window {
406 start: 20,
407 end: 120,
408 };
409 let uncapped = CostModel::default(); let capped = CostModel {
411 max_participation: 0.05,
412 ..CostModel::default()
413 };
414 let a = run_backtest(&data, &mut BuyAndHold, w, 0, uncapped);
415 let b = run_backtest(&data, &mut BuyAndHold, w, 0, capped);
416 assert_ne!(
417 a.returns, b.returns,
418 "a tight liquidity cap must change fills"
419 );
420 }
421}