use serde::{Deserialize, Serialize};
use sharpebench_core::ProcessEvent;
use sharpebench_protocol::{Decision, MarketObservation};
use crate::costs::{CostModel, CostProfile};
use crate::data::Dataset;
use crate::engine::{build_observation, nav, step_once, Book, Window};
const WARMUP: usize = 20;
pub struct StepInfo {
pub nav: f64,
pub events: Vec<ProcessEvent>,
}
pub struct StepResult {
pub observation: MarketObservation,
pub reward: f64,
pub done: bool,
pub info: StepInfo,
}
pub struct TradingEnv {
data: Dataset,
symbols: Vec<String>,
costs: CostModel,
window: Window,
end: usize,
seed: u64,
cursor: usize,
book: Book,
}
impl TradingEnv {
pub fn new(data: Dataset, window: Window, costs: CostModel, seed: u64) -> Self {
let symbols = data.symbols();
let end = window.end.min(data.len());
let book = Book::new(&symbols, seed);
TradingEnv {
data,
symbols,
costs,
window,
end,
seed,
cursor: window.start,
book,
}
}
pub fn reset(&mut self) -> MarketObservation {
self.book = Book::new(&self.symbols, self.seed);
self.cursor = self.window.start;
build_observation(&self.data, &self.symbols, &self.book, self.obs_index())
}
pub fn step(&mut self, decision: Decision) -> StepResult {
let t = self.obs_index();
let events_before = self.book.trace.events.len();
let out = step_once(
&self.data,
&self.symbols,
&mut self.book,
&self.costs,
t,
&decision,
);
let events = self.book.trace.events[events_before..].to_vec();
let nav_after = nav(
&self.data,
&self.symbols,
&self.book.shares,
self.book.cash,
t,
);
self.cursor += 1;
let done = self.cursor >= self.end;
let observation =
build_observation(&self.data, &self.symbols, &self.book, self.obs_index());
StepResult {
observation,
reward: out.ret,
done,
info: StepInfo {
nav: nav_after,
events,
},
}
}
fn obs_index(&self) -> usize {
self.cursor.min(self.end.saturating_sub(1))
}
pub fn clone_state(&self) -> EnvState {
EnvState {
cursor: self.cursor,
book: self.book.clone(),
}
}
pub fn restore_state(&mut self, state: EnvState) {
self.cursor = state.cursor;
self.book = state.book;
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EnvState {
cursor: usize,
book: Book,
}
pub struct Scenario {
pub name: String,
pub data: Dataset,
pub windows: Vec<Window>,
pub costs: CostModel,
}
impl Scenario {
pub fn full(name: impl Into<String>, data: Dataset, costs: CostModel) -> Self {
let end = data.len();
let start = WARMUP.min(end.saturating_sub(1));
Scenario {
name: name.into(),
data,
windows: vec![Window { start, end }],
costs,
}
}
pub fn crisis_suite(seed: u64, profile: CostProfile) -> Vec<Scenario> {
let costs = profile.resolve().costs;
Dataset::stress_suite(seed)
.into_iter()
.map(|(name, data)| Scenario::full(name, data, costs))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{Agent, BuyAndHold};
use crate::engine::run_backtest;
#[test]
fn env_step_matches_run_backtest() {
let data = Dataset::synthetic(4, 120, 11);
let window = Window {
start: 20,
end: 120,
};
let costs = CostModel::default();
let seed = 7;
let reference = run_backtest(&data, &mut BuyAndHold, window, seed, costs);
let mut env = TradingEnv::new(data.clone(), window, costs, seed);
let mut agent = BuyAndHold;
let mut obs = env.reset();
let mut rewards: Vec<f64> = Vec::new();
let mut events: Vec<ProcessEvent> = Vec::new();
loop {
let decision = agent.decide(&obs);
let res = env.step(decision);
rewards.push(res.reward);
events.extend(res.info.events);
obs = res.observation;
if res.done {
break;
}
}
assert_eq!(
rewards, reference.returns,
"env rewards must match run_backtest returns byte-for-byte"
);
assert_eq!(
events, reference.trace.events,
"env per-step events must reassemble run_backtest's trace exactly"
);
}
#[test]
fn env_observation_is_point_in_time() {
let data = Dataset::synthetic(4, 120, 3);
let window = Window {
start: 20,
end: 120,
};
let mut env = TradingEnv::new(data.clone(), window, CostModel::default(), 5);
let mut agent = BuyAndHold;
let mut obs = env.reset();
let mut t = window.start;
loop {
for snap in &obs.symbols {
let last = *snap
.close_history
.last()
.expect("a point-in-time history is never empty within the window");
assert_eq!(
last,
data.close_at(&snap.symbol, t).unwrap(),
"history for {} must end at close_at(t={t})",
snap.symbol
);
}
let decision = agent.decide(&obs);
let res = env.step(decision);
obs = res.observation;
t += 1;
if res.done {
break;
}
}
}
#[test]
fn clone_restore_state_reproduces_trajectory() {
let data = Dataset::synthetic(4, 120, 11);
let window = Window {
start: 20,
end: 120,
};
let mut env = TradingEnv::new(data, window, CostModel::default(), 7);
let mut agent = BuyAndHold;
let mut obs = env.reset();
for _ in 0..30 {
obs = env.step(agent.decide(&obs)).observation;
}
let snap = env.clone_state();
let resume = obs.clone();
let mut baseline: Vec<String> = Vec::new();
let mut o = obs;
for _ in 0..20 {
o = env.step(agent.decide(&o)).observation;
baseline.push(serde_json::to_string(&o).unwrap());
}
env.restore_state(snap.clone());
let mut o2 = resume;
let mut replayed: Vec<String> = Vec::new();
for _ in 0..20 {
o2 = env.step(agent.decide(&o2)).observation;
replayed.push(serde_json::to_string(&o2).unwrap());
}
assert_eq!(baseline, replayed, "restore must reproduce the trajectory");
env.restore_state(snap.clone());
assert_eq!(
snap,
env.clone_state(),
"an unstepped re-snapshot must match"
);
}
#[test]
fn env_state_serializes_round_trip() {
let data = Dataset::synthetic(3, 60, 4);
let window = Window { start: 20, end: 60 };
let mut env = TradingEnv::new(data, window, CostModel::default(), 1);
env.reset();
let snap = env.clone_state();
let json = serde_json::to_string(&snap).unwrap();
let back: EnvState = serde_json::from_str(&json).unwrap();
assert_eq!(
snap, back,
"a clean EnvState must survive a serde round-trip"
);
}
#[test]
fn crisis_suite_scenarios_run() {
let scenarios = Scenario::crisis_suite(11, CostProfile::WorstCase);
assert_eq!(scenarios.len(), 2, "flash crash + whipsaw");
for sc in &scenarios {
let window = sc.windows[0];
assert!(
window.start < window.end,
"{} has a non-empty window",
sc.name
);
let run = run_backtest(&sc.data, &mut BuyAndHold, window, 1, sc.costs);
assert_eq!(run.returns.len(), window.end - window.start);
}
}
}