Skip to main content

sharpebench_sim/
env.rs

1//! The Gym-style open-loop environment: the caller drives `reset()` / `step()`.
2//!
3//! [`TradingEnv`] is the open-loop face of the same engine [`crate::run_backtest`]
4//! runs closed — both call the one shared [`crate::engine::step_once`] body, so a
5//! trajectory the env produces is byte-identical to the equivalent `run_backtest`
6//! (proven by `env_step_matches_run_backtest`). Look-ahead is impossible: the env
7//! owns the time cursor and only ever builds a point-in-time observation.
8
9use serde::{Deserialize, Serialize};
10use sharpebench_core::ProcessEvent;
11use sharpebench_protocol::{Decision, MarketObservation};
12
13use crate::costs::{CostModel, CostProfile};
14use crate::data::Dataset;
15use crate::engine::{build_observation, nav, step_once, Book, Window};
16
17/// Bars of trailing history to burn in before a scenario's first decision.
18const WARMUP: usize = 20;
19
20/// Per-step side channel: the post-step NAV and the process events generated
21/// during the step (fills, sim-exploitation guards, captured rationale).
22pub struct StepInfo {
23    pub nav: f64,
24    pub events: Vec<ProcessEvent>,
25}
26
27/// The result of one environment step: the next point-in-time observation, this
28/// step's portfolio return (the reward), whether the window is exhausted, and the
29/// per-step side channel.
30pub struct StepResult {
31    pub observation: MarketObservation,
32    pub reward: f64,
33    pub done: bool,
34    pub info: StepInfo,
35}
36
37/// A steppable, leak-free trading environment over a frozen dataset. The caller
38/// drives it: [`reset`](Self::reset) returns the first observation, then each
39/// [`step`](Self::step) applies the supplied decision and advances one bar.
40pub struct TradingEnv {
41    data: Dataset,
42    symbols: Vec<String>,
43    costs: CostModel,
44    window: Window,
45    end: usize,
46    seed: u64,
47    cursor: usize,
48    book: Book,
49}
50
51impl TradingEnv {
52    /// Build an environment that steps `window` over `data` with seeded execution
53    /// noise and the given cost model.
54    pub fn new(data: Dataset, window: Window, costs: CostModel, seed: u64) -> Self {
55        let symbols = data.symbols();
56        let end = window.end.min(data.len());
57        let book = Book::new(&symbols, seed);
58        TradingEnv {
59            data,
60            symbols,
61            costs,
62            window,
63            end,
64            seed,
65            cursor: window.start,
66            book,
67        }
68    }
69
70    /// Reset to the start of the window and return the first point-in-time
71    /// observation. Re-seeds the book, so it is safe to call repeatedly.
72    pub fn reset(&mut self) -> MarketObservation {
73        self.book = Book::new(&self.symbols, self.seed);
74        self.cursor = self.window.start;
75        build_observation(&self.data, &self.symbols, &self.book, self.obs_index())
76    }
77
78    /// Apply `decision` at the current bar and advance one step. The returned
79    /// `reward` is the bar's portfolio return; `done` is set once the window is
80    /// exhausted (further calls re-apply the final bar harmlessly).
81    pub fn step(&mut self, decision: Decision) -> StepResult {
82        let t = self.obs_index();
83        let events_before = self.book.trace.events.len();
84        let out = step_once(
85            &self.data,
86            &self.symbols,
87            &mut self.book,
88            &self.costs,
89            t,
90            &decision,
91        );
92        let events = self.book.trace.events[events_before..].to_vec();
93        let nav_after = nav(
94            &self.data,
95            &self.symbols,
96            &self.book.shares,
97            self.book.cash,
98            t,
99        );
100        self.cursor += 1;
101        let done = self.cursor >= self.end;
102        let observation =
103            build_observation(&self.data, &self.symbols, &self.book, self.obs_index());
104        StepResult {
105            observation,
106            reward: out.ret,
107            done,
108            info: StepInfo {
109                nav: nav_after,
110                events,
111            },
112        }
113    }
114
115    /// The bar index used to build an observation — the cursor, clamped to the last
116    /// in-window bar so a terminal observation never leaks a post-window row.
117    fn obs_index(&self) -> usize {
118        self.cursor.min(self.end.saturating_sub(1))
119    }
120
121    /// Snapshot the mutable sim state (time cursor + book) in O(1) — no replay.
122    /// The returned [`EnvState`] is a value: clone it, stash it, restore it later
123    /// with [`restore_state`](Self::restore_state) to fork the env from this exact
124    /// point (e.g. tree search / what-if rollouts over the same frozen data). The
125    /// immutable config (data, window, costs, seed) is not snapshotted — it never
126    /// changes — so a snapshot is cheap and a restore is exact.
127    pub fn clone_state(&self) -> EnvState {
128        EnvState {
129            cursor: self.cursor,
130            book: self.book.clone(),
131        }
132    }
133
134    /// Restore a snapshot taken by [`clone_state`](Self::clone_state): the env
135    /// resumes producing the exact byte-identical trajectory it would have from the
136    /// snapshot point, including seeded execution noise (the RNG cursor is part of
137    /// the snapshot).
138    pub fn restore_state(&mut self, state: EnvState) {
139        self.cursor = state.cursor;
140        self.book = state.book;
141    }
142}
143
144/// An O(1), serializable snapshot of a [`TradingEnv`]'s mutable state — the time
145/// cursor plus the full [`Book`] (holdings, cash, RNG cursor, decision trace, prior
146/// NAV). Everything an env needs to resume an exact trajectory; the immutable
147/// config (frozen data, window, costs, seed) lives in the env and is not copied.
148#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
149pub struct EnvState {
150    cursor: usize,
151    book: Book,
152}
153
154/// A named bundle of a dataset, the windows to evaluate over it, and the cost
155/// model — so "run my agent through the crisis suite under worst-case execution"
156/// is one object rather than three loose arguments.
157pub struct Scenario {
158    pub name: String,
159    pub data: Dataset,
160    pub windows: Vec<Window>,
161    pub costs: CostModel,
162}
163
164impl Scenario {
165    /// A single full window over `data` (after a [`WARMUP`]-bar burn-in) under `costs`.
166    pub fn full(name: impl Into<String>, data: Dataset, costs: CostModel) -> Self {
167        let end = data.len();
168        let start = WARMUP.min(end.saturating_sub(1));
169        Scenario {
170            name: name.into(),
171            data,
172            windows: vec![Window { start, end }],
173            costs,
174        }
175    }
176
177    /// The built-in crisis suite (flash crash + whipsaw) under the given execution
178    /// profile — each scenario tests *survival*, not calm-market return.
179    pub fn crisis_suite(seed: u64, profile: CostProfile) -> Vec<Scenario> {
180        let costs = profile.resolve().costs;
181        Dataset::stress_suite(seed)
182            .into_iter()
183            .map(|(name, data)| Scenario::full(name, data, costs))
184            .collect()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::agent::{Agent, BuyAndHold};
192    use crate::engine::run_backtest;
193
194    /// The load-bearing guarantee: driving the env with the same (deterministic)
195    /// agent reproduces `run_backtest`'s returns AND trace byte-for-byte — proof
196    /// that inverting the loop into `reset`/`step` did not change the math.
197    #[test]
198    fn env_step_matches_run_backtest() {
199        let data = Dataset::synthetic(4, 120, 11);
200        let window = Window {
201            start: 20,
202            end: 120,
203        };
204        let costs = CostModel::default();
205        let seed = 7;
206
207        let reference = run_backtest(&data, &mut BuyAndHold, window, seed, costs);
208
209        let mut env = TradingEnv::new(data.clone(), window, costs, seed);
210        let mut agent = BuyAndHold;
211        let mut obs = env.reset();
212        let mut rewards: Vec<f64> = Vec::new();
213        let mut events: Vec<ProcessEvent> = Vec::new();
214        loop {
215            let decision = agent.decide(&obs);
216            let res = env.step(decision);
217            rewards.push(res.reward);
218            events.extend(res.info.events);
219            obs = res.observation;
220            if res.done {
221                break;
222            }
223        }
224
225        assert_eq!(
226            rewards, reference.returns,
227            "env rewards must match run_backtest returns byte-for-byte"
228        );
229        assert_eq!(
230            events, reference.trace.events,
231            "env per-step events must reassemble run_backtest's trace exactly"
232        );
233    }
234
235    /// No-lookahead at the observation boundary: every symbol's trailing history
236    /// ends exactly at `close_at(t)` — the env can never hand out a future bar.
237    #[test]
238    fn env_observation_is_point_in_time() {
239        let data = Dataset::synthetic(4, 120, 3);
240        let window = Window {
241            start: 20,
242            end: 120,
243        };
244        let mut env = TradingEnv::new(data.clone(), window, CostModel::default(), 5);
245        let mut agent = BuyAndHold;
246
247        let mut obs = env.reset();
248        let mut t = window.start;
249        loop {
250            for snap in &obs.symbols {
251                let last = *snap
252                    .close_history
253                    .last()
254                    .expect("a point-in-time history is never empty within the window");
255                assert_eq!(
256                    last,
257                    data.close_at(&snap.symbol, t).unwrap(),
258                    "history for {} must end at close_at(t={t})",
259                    snap.symbol
260                );
261            }
262            let decision = agent.decide(&obs);
263            let res = env.step(decision);
264            obs = res.observation;
265            t += 1;
266            if res.done {
267                break;
268            }
269        }
270    }
271
272    /// O(1) snapshot/restore reproduces an exact forward trajectory: step N, snap,
273    /// step on, then restore and re-step — the observations after the snapshot point
274    /// are byte-identical to the first continuation (RNG cursor included). This is
275    /// the replay-free fork the closed loop cannot offer.
276    #[test]
277    fn clone_restore_state_reproduces_trajectory() {
278        let data = Dataset::synthetic(4, 120, 11);
279        let window = Window {
280            start: 20,
281            end: 120,
282        };
283        let mut env = TradingEnv::new(data, window, CostModel::default(), 7);
284        let mut agent = BuyAndHold;
285
286        let mut obs = env.reset();
287        for _ in 0..30 {
288            obs = env.step(agent.decide(&obs)).observation;
289        }
290
291        // Snapshot here, then record the next 20 observations as the baseline.
292        let snap = env.clone_state();
293        let resume = obs.clone();
294        let mut baseline: Vec<String> = Vec::new();
295        let mut o = obs;
296        for _ in 0..20 {
297            o = env.step(agent.decide(&o)).observation;
298            baseline.push(serde_json::to_string(&o).unwrap());
299        }
300
301        // Restore and replay from the snapshot point — must match byte-for-byte.
302        env.restore_state(snap.clone());
303        let mut o2 = resume;
304        let mut replayed: Vec<String> = Vec::new();
305        for _ in 0..20 {
306            o2 = env.step(agent.decide(&o2)).observation;
307            replayed.push(serde_json::to_string(&o2).unwrap());
308        }
309
310        assert_eq!(baseline, replayed, "restore must reproduce the trajectory");
311        // Two snapshots of the same state are PartialEq-equal (Clone is exact).
312        env.restore_state(snap.clone());
313        assert_eq!(
314            snap,
315            env.clone_state(),
316            "an unstepped re-snapshot must match"
317        );
318    }
319
320    /// `EnvState` is serializable so a fork point can be persisted. A clean snapshot
321    /// (taken at reset — all exact values) survives a JSON round-trip unchanged.
322    #[test]
323    fn env_state_serializes_round_trip() {
324        let data = Dataset::synthetic(3, 60, 4);
325        let window = Window { start: 20, end: 60 };
326        let mut env = TradingEnv::new(data, window, CostModel::default(), 1);
327        env.reset();
328        let snap = env.clone_state();
329        let json = serde_json::to_string(&snap).unwrap();
330        let back: EnvState = serde_json::from_str(&json).unwrap();
331        assert_eq!(
332            snap, back,
333            "a clean EnvState must survive a serde round-trip"
334        );
335    }
336
337    /// The crisis suite bundles flash-crash + whipsaw datasets, each with a
338    /// non-empty window that a baseline agent can be run through end-to-end.
339    #[test]
340    fn crisis_suite_scenarios_run() {
341        let scenarios = Scenario::crisis_suite(11, CostProfile::WorstCase);
342        assert_eq!(scenarios.len(), 2, "flash crash + whipsaw");
343        for sc in &scenarios {
344            let window = sc.windows[0];
345            assert!(
346                window.start < window.end,
347                "{} has a non-empty window",
348                sc.name
349            );
350            let run = run_backtest(&sc.data, &mut BuyAndHold, window, 1, sc.costs);
351            assert_eq!(run.returns.len(), window.end - window.start);
352        }
353    }
354}