Skip to main content

sharpebench_sim/
engine.rs

1//! The point-in-time backtest engine.
2
3use 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;
14/// Per-name weight above which we record a (warn-severity) concentration breach.
15const CONCENTRATION_CAP: f64 = 0.5;
16/// Per-name weight beyond which (or if non-finite) an order is treated as a
17/// simulator-exploitation attempt — a block-severity violation.
18const HARD_WEIGHT_CAP: f64 = 5.0;
19
20/// A simulation window over the dataset's date axis: steps `[start, end)`.
21#[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/// The mutable running state of a backtest: holdings, cash, the seeded execution
45/// RNG, the accumulating decision trace, and the prior-step NAV used to book the
46/// per-step return. Shared by the closed-loop [`run_backtest`] and the open-loop
47/// [`crate::env::TradingEnv`] so the two stepping surfaces cannot drift.
48///
49/// `Clone + Serialize + Deserialize + PartialEq` make it the serializable payload
50/// of [`crate::env::EnvState`] — an O(1) snapshot/restore of the whole mutable sim
51/// state (holdings, cash, RNG cursor, trace, prior NAV).
52#[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
73/// Build the point-in-time observation handed to the agent at step `t`: trailing
74/// closes (≤ `t`), current holdings, and cash. No bar after `t` is reachable.
75pub(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
106/// What the engine records for one step: the realized return plus the calibration
107/// inputs (stated conviction and whether the step paid off).
108pub(crate) struct StepOutcome {
109    pub(crate) ret: f64,
110    pub(crate) confidence: f64,
111    pub(crate) outcome: bool,
112}
113
114/// Apply `decision` at step `t` and advance one bar: rebalance toward target
115/// weights with cost + seeded slippage + own-order market impact + partial fills,
116/// credit dividends, charge financing on leverage, then book the post-trade return
117/// vs the prior step's NAV. Mutates `book`. This is the single per-step body shared
118/// by [`run_backtest`] (closed loop) and [`crate::env::TradingEnv::step`] (open
119/// loop), so neither stepping surface can drift from the other.
120pub(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    // rebalance toward target weights with cost + seeded slippage.
131    for ord in &decision.orders {
132        let p = price(data, &ord.symbol, t);
133        if p <= 0.0 {
134            continue;
135        }
136        // Sim-exploitation guard: non-finite or absurd weights are gaming attempts.
137        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        // Liquidity cap: a trade larger than the per-step participation limit
147        // only partially fills; the rest is left for later steps.
148        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        // Base seeded slippage plus own-order market impact: the bigger the
154        // trade relative to NAV, the more the fill moves against the agent.
155        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        // Capture the order's stated rationale into the audit trail (score-neutral),
171        // so the frozen trace explains *why* each fill happened. Empty = omitted.
172        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    // corporate actions: credit cash dividends on post-trade holdings.
184    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    // financing: charge carry on any leveraged exposure above 1× NAV.
192    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    // daily return = post-trade NAV vs the prior step's NAV (captures the price
203    // move on held positions, dividends, financing, and trading costs).
204    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    // Capture the decision's stated conviction and whether the step paid off, so
211    // the scoring kernel's calibration axis is fed from the live run.
212    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
225/// Run a single backtest of `agent` over `window` with seeded execution noise,
226/// returning an [`sharpebench_core::Run`] (per-period returns + decision trace).
227/// The closed-loop driver: it owns the `decide → step` loop, calling the same
228/// [`step_once`] body the open-loop [`crate::env::TradingEnv`] uses.
229pub 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    /// Test-only agent: levers 2× into the first symbol (gross exposure 2× NAV).
268    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    /// Test-only agent that buys the first symbol with a stated per-order rationale.
286    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        // It is score-neutral: the run is still process-clean.
319        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); // 10 bps/step
356        let w = Window {
357            start: 20,
358            end: 120,
359        };
360        // No execution noise (zero costs) so the only difference is the dividend.
361        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(); // max_participation = INF
410        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}