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 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 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);
}
}
}