1use 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
17const WARMUP: usize = 20;
19
20pub struct StepInfo {
23 pub nav: f64,
24 pub events: Vec<ProcessEvent>,
25}
26
27pub struct StepResult {
31 pub observation: MarketObservation,
32 pub reward: f64,
33 pub done: bool,
34 pub info: StepInfo,
35}
36
37pub 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 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 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 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 fn obs_index(&self) -> usize {
118 self.cursor.min(self.end.saturating_sub(1))
119 }
120
121 pub fn clone_state(&self) -> EnvState {
128 EnvState {
129 cursor: self.cursor,
130 book: self.book.clone(),
131 }
132 }
133
134 pub fn restore_state(&mut self, state: EnvState) {
139 self.cursor = state.cursor;
140 self.book = state.book;
141 }
142}
143
144#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
149pub struct EnvState {
150 cursor: usize,
151 book: Book,
152}
153
154pub struct Scenario {
158 pub name: String,
159 pub data: Dataset,
160 pub windows: Vec<Window>,
161 pub costs: CostModel,
162}
163
164impl Scenario {
165 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 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 #[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 #[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 #[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 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 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 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 #[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 #[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}