use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::config::ResourceLimits;
use crate::data::convert::chain_response_to_snapshot;
use crate::data::feed::{DataFeed, TapeMeta};
use crate::data::historical::{push_checked, to_hex};
use crate::data::{DataSourceSpec, SimulatorSourceSpec};
use crate::domain::{ChainSnapshot, InstrumentSpec, Quantity, SimTime, StepIndex};
use crate::error::BacktestError;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateSessionRequest {
pub symbol: String,
pub steps: usize,
pub initial_price: f64,
pub days_to_expiration: f64,
pub volatility: f64,
pub risk_free_rate: f64,
pub dividend_yield: f64,
pub method: Value,
pub time_frame: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub chain_size: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strike_interval: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skew_slope: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smile_curve: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spread: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct UpdateSessionRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub steps: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_price: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub days_to_expiration: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub volatility: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub risk_free_rate: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dividend_yield: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_frame: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chain_size: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strike_interval: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skew_slope: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smile_curve: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spread: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionResponse {
pub id: String,
pub created_at: String,
pub updated_at: String,
pub parameters: SessionParametersResponse,
pub current_step: usize,
pub total_steps: usize,
pub state: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SessionParametersResponse {
pub symbol: String,
pub initial_price: f64,
pub volatility: f64,
pub risk_free_rate: f64,
pub method: Value,
pub time_frame: String,
pub dividend_yield: f64,
pub skew_slope: Option<f64>,
pub smile_curve: Option<f64>,
pub spread: Option<f64>,
pub seed: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ChainResponse {
pub underlying: String,
pub timestamp: String,
pub price: f64,
pub contracts: Vec<OptionContractResponse>,
pub session_info: SessionInfoResponse,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct OptionContractResponse {
pub strike: f64,
pub expiration: String,
pub call: OptionPriceResponse,
pub put: OptionPriceResponse,
pub implied_volatility: Option<f64>,
pub gamma: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct OptionPriceResponse {
pub bid: Option<f64>,
pub ask: Option<f64>,
pub mid: Option<f64>,
pub delta: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SessionInfoResponse {
pub id: String,
pub current_step: usize,
pub total_steps: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ErrorResponse {
pub error: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum SessionState {
Initialized = 0,
InProgress = 1,
Modified = 2,
Reinitialized = 3,
Completed = 4,
Error = 5,
Unknown = 6,
}
impl SessionState {
#[must_use]
pub fn from_wire(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().replace(' ', "").as_str() {
"initialized" => Self::Initialized,
"inprogress" => Self::InProgress,
"modified" => Self::Modified,
"reinitialized" => Self::Reinitialized,
"completed" => Self::Completed,
"error" => Self::Error,
_ => Self::Unknown,
}
}
#[must_use]
pub const fn from_progress(current_step: usize, total_steps: usize) -> Self {
if current_step >= total_steps {
Self::Completed
} else {
Self::InProgress
}
}
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Error)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MarketState {
pub current_step: usize,
pub total_steps: usize,
pub state: SessionState,
pub chain: ChainResponse,
}
#[derive(Debug, Clone)]
pub struct ApiClient {
client: reqwest::Client,
base_url: String,
max_response_bytes: u64,
}
impl ApiClient {
pub const DEFAULT_BASE_URL: &'static str = "http://localhost:7070";
pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 4 * (1 << 30);
#[must_use]
pub fn base_url_from_env() -> String {
Self::base_url_from(std::env::var("API_URL").ok())
}
#[must_use]
fn base_url_from(api_url: Option<String>) -> String {
match api_url {
Some(url) if !url.trim().is_empty() => url,
_ => Self::DEFAULT_BASE_URL.to_string(),
}
}
pub fn new(base_url: &str, timeout: Duration) -> Result<Self, BacktestError> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| BacktestError::Session(format!("http client build failed: {e}")))?;
Ok(Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
max_response_bytes: Self::DEFAULT_MAX_RESPONSE_BYTES,
})
}
#[must_use = "the reconfigured client must be used"]
pub fn with_max_response_bytes(mut self, max_response_bytes: u64) -> Self {
self.max_response_bytes = max_response_bytes;
self
}
async fn read_capped_json<T: serde::de::DeserializeOwned>(
&self,
response: reqwest::Response,
what: &str,
) -> Result<T, BacktestError> {
let bytes = self.read_capped_body(response, what).await?;
serde_json::from_slice(&bytes)
.map_err(|e| BacktestError::Session(format!("{what} failed: {e}")))
}
async fn read_capped_body(
&self,
mut response: reqwest::Response,
what: &str,
) -> Result<Vec<u8>, BacktestError> {
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| BacktestError::Session(format!("{what}: read body failed: {e}")))?
{
let next_len = body
.len()
.checked_add(chunk.len())
.ok_or(BacktestError::ArithmeticOverflow)?;
let next_len =
u64::try_from(next_len).map_err(|_| BacktestError::ArithmeticOverflow)?;
if next_len > self.max_response_bytes {
return Err(BacktestError::Session(format!(
"{what}: response body exceeds the {}-byte cap",
self.max_response_bytes
)));
}
body.extend_from_slice(chunk.as_ref());
}
Ok(body)
}
pub async fn create_session(
&self,
params: CreateSessionRequest,
) -> Result<SessionResponse, BacktestError> {
let url = format!("{}/api/v1/chain", self.base_url);
let response = self
.client
.post(&url)
.json(¶ms)
.send()
.await
.map_err(|e| BacktestError::Session(format!("create_session request failed: {e}")))?;
if !response.status().is_success() {
return Err(self.error_from_response(response).await);
}
self.read_capped_json::<SessionResponse>(response, "parse session response")
.await
}
pub async fn get_next_step(&self, session_id: &str) -> Result<ChainResponse, BacktestError> {
self.get_next_step_expecting(session_id, None).await
}
pub async fn get_next_step_expecting(
&self,
session_id: &str,
expected_step: Option<usize>,
) -> Result<ChainResponse, BacktestError> {
let url = match expected_step {
Some(expected) => format!(
"{}/api/v1/chain/step?sessionid={session_id}&expected_step={expected}",
self.base_url
),
None => format!("{}/api/v1/chain/step?sessionid={session_id}", self.base_url),
};
let response =
self.client.post(&url).send().await.map_err(|e| {
BacktestError::Session(format!("get_next_step request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(self.error_from_response(response).await);
}
self.read_capped_json::<ChainResponse>(response, "parse chain response")
.await
}
pub async fn replace_session(
&self,
session_id: &str,
params: CreateSessionRequest,
) -> Result<SessionResponse, BacktestError> {
let url = format!("{}/api/v1/chain?sessionid={session_id}", self.base_url);
let response = self
.client
.put(&url)
.json(¶ms)
.send()
.await
.map_err(|e| BacktestError::Session(format!("replace_session request failed: {e}")))?;
if !response.status().is_success() {
return Err(self.error_from_response(response).await);
}
self.read_capped_json::<SessionResponse>(response, "parse session response")
.await
}
pub async fn update_session(
&self,
session_id: &str,
params: UpdateSessionRequest,
) -> Result<SessionResponse, BacktestError> {
let url = format!("{}/api/v1/chain?sessionid={session_id}", self.base_url);
let response = self
.client
.patch(&url)
.json(¶ms)
.send()
.await
.map_err(|e| BacktestError::Session(format!("update_session request failed: {e}")))?;
if !response.status().is_success() {
return Err(self.error_from_response(response).await);
}
self.read_capped_json::<SessionResponse>(response, "parse session response")
.await
}
pub async fn delete_session(&self, session_id: &str) -> Result<(), BacktestError> {
let url = format!("{}/api/v1/chain?sessionid={session_id}", self.base_url);
let response =
self.client.delete(&url).send().await.map_err(|e| {
BacktestError::Session(format!("delete_session request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(self.error_from_response(response).await);
}
Ok(())
}
#[cold]
async fn error_from_response(&self, response: reqwest::Response) -> BacktestError {
let status = response.status().as_u16();
match self.read_capped_body(response, "error body").await {
Ok(bytes) => match serde_json::from_slice::<ErrorResponse>(&bytes) {
Ok(body) => BacktestError::Session(format!("http {status}: {}", body.error)),
Err(_) => BacktestError::Session(format!("http {status}: unparseable error body")),
},
Err(_) => BacktestError::Session(format!(
"http {status}: error body exceeded the response cap"
)),
}
}
}
#[derive(Debug, Clone)]
pub struct MarketSimulator {
api_client: Arc<ApiClient>,
session_id: Option<String>,
current_state: Option<MarketState>,
}
impl MarketSimulator {
#[must_use]
pub fn new(api_client: Arc<ApiClient>) -> Self {
Self {
api_client,
session_id: None,
current_state: None,
}
}
#[must_use]
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
fn record_session_response(&mut self, resp: &SessionResponse) {
self.session_id = Some(resp.id.clone());
self.current_state = Some(MarketState {
current_step: resp.current_step,
total_steps: resp.total_steps,
state: SessionState::from_wire(&resp.state),
chain: ChainResponse::default(),
});
}
fn record_chain_response(&mut self, resp: &ChainResponse) {
let info = &resp.session_info;
self.current_state = Some(MarketState {
current_step: info.current_step,
total_steps: info.total_steps,
state: SessionState::from_progress(info.current_step, info.total_steps),
chain: resp.clone(),
});
}
pub async fn create_simulation(
&mut self,
request: CreateSessionRequest,
) -> Result<(), BacktestError> {
let response = self.api_client.create_session(request).await?;
self.record_session_response(&response);
Ok(())
}
pub async fn next_step(&mut self) -> Result<MarketState, BacktestError> {
let session_id = self
.session_id
.clone()
.ok_or_else(|| BacktestError::Session("no active simulation session".to_string()))?;
let response = self.api_client.get_next_step(&session_id).await?;
self.record_chain_response(&response);
self.get_current_state()
}
pub fn get_current_state(&self) -> Result<MarketState, BacktestError> {
self.current_state
.clone()
.ok_or_else(|| BacktestError::Session("no current market state".to_string()))
}
#[must_use]
pub fn is_terminated(&self) -> bool {
match &self.current_state {
Some(state) => state.state.is_terminal() || state.current_step >= state.total_steps,
None => true,
}
}
pub async fn reset(&mut self) -> Result<(), BacktestError> {
if let Some(session_id) = &self.session_id {
self.api_client.delete_session(session_id).await?;
}
self.session_id = None;
self.current_state = None;
Ok(())
}
}
const MATERIALISE_ATTEMPTS: u32 = 3;
const TAPE_HASH_TAG: &[u8] = b"ironcondor.tape.v1\0";
async fn with_retry<T, F, Fut>(what: &'static str, mut call: F) -> Result<T, BacktestError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, BacktestError>>,
{
let mut last: Option<BacktestError> = None;
for attempt in 1..=MATERIALISE_ATTEMPTS {
match call().await {
Ok(value) => return Ok(value),
Err(error) => {
tracing::warn!(
request = what,
attempt,
max_attempts = MATERIALISE_ATTEMPTS,
error = %error,
"materialisation request failed"
);
last = Some(error);
}
}
}
Err(last.unwrap_or_else(|| {
BacktestError::Session(format!("{what}: retry bound exhausted without an error"))
}))
}
fn response_ts(resp: &ChainResponse) -> Result<SimTime, BacktestError> {
let ts_dt = chrono::DateTime::parse_from_rfc3339(&resp.timestamp).map_err(|e| {
BacktestError::Conversion(format!(
"unparseable snapshot timestamp {:?}: {e}",
resp.timestamp
))
})?;
let ts_ns = ts_dt.timestamp_nanos_opt().ok_or_else(|| {
BacktestError::Conversion(format!(
"snapshot timestamp {:?} is outside the nanosecond range",
resp.timestamp
))
})?;
Ok(SimTime::new(ts_ns))
}
fn session_ended(state: SessionState, current_step: usize, total_steps: usize) -> bool {
state.is_terminal() || SessionState::from_progress(current_step, total_steps).is_terminal()
}
#[must_use = "the tape identity must be used"]
fn tape_sha256(tape: &[ChainSnapshot]) -> Result<String, BacktestError> {
let mut hasher = Sha256::new();
hasher.update(TAPE_HASH_TAG);
for snapshot in tape {
hasher.update(snapshot.ts.value().to_le_bytes());
hasher.update(snapshot.step.value().to_le_bytes());
update_bytes(&mut hasher, snapshot.underlying.as_str().as_bytes())?;
hasher.update(snapshot.underlying_price.value().to_le_bytes());
hasher.update(snapshot.spec.tick_size_cents.value().to_le_bytes());
hasher.update(snapshot.spec.contract_multiplier.to_le_bytes());
let quote_count =
u64::try_from(snapshot.quotes.len()).map_err(|_| BacktestError::ArithmeticOverflow)?;
hasher.update(quote_count.to_le_bytes());
for (key, view) in &snapshot.quotes {
update_bytes(&mut hasher, key.to_contract_id()?.as_bytes())?;
hasher.update(view.bid.value().to_le_bytes());
hasher.update(view.ask.value().to_le_bytes());
hasher.update(view.mid.value().to_le_bytes());
hasher.update(view.bid_size.value().to_le_bytes());
hasher.update(view.ask_size.value().to_le_bytes());
hasher.update(view.implied_volatility.serialize());
hasher.update(view.delta.serialize());
hasher.update(view.gamma.serialize());
hasher.update(view.theta.serialize());
hasher.update(view.vega.serialize());
}
}
Ok(to_hex(&hasher.finalize()))
}
fn update_bytes(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), BacktestError> {
let len = u64::try_from(bytes.len()).map_err(|_| BacktestError::ArithmeticOverflow)?;
hasher.update(len.to_le_bytes());
hasher.update(bytes);
Ok(())
}
#[derive(Debug)]
#[must_use = "a SimulatorFeed does nothing unless its snapshots are consumed via DataFeed::next"]
pub struct SimulatorFeed {
tape: Vec<ChainSnapshot>,
cursor: usize,
meta: TapeMeta,
source: SimulatorSourceSpec,
}
impl SimulatorFeed {
pub fn open(
spec: &SimulatorSourceSpec,
instrument: InstrumentSpec,
quote_size: Quantity,
timeout: Duration,
limits: &ResourceLimits,
) -> Result<Self, BacktestError> {
if tokio::runtime::Handle::try_current().is_ok() {
return Err(BacktestError::Session(
"SimulatorFeed::open must not be called from inside an async runtime; \
it blocks on its own current-thread runtime"
.to_string(),
));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| BacktestError::Session(format!("tokio runtime build failed: {e}")))?;
runtime.block_on(Self::open_async(
spec, instrument, quote_size, timeout, limits,
))
}
async fn open_async(
spec: &SimulatorSourceSpec,
instrument: InstrumentSpec,
quote_size: Quantity,
timeout: Duration,
limits: &ResourceLimits,
) -> Result<Self, BacktestError> {
let client =
ApiClient::new(&spec.base_url, timeout)?.with_max_response_bytes(limits.max_file_bytes);
let mut request = spec.session.clone();
request.seed = Some(spec.data_seed);
let session =
with_retry("create_session", || client.create_session(request.clone())).await?;
let session_id = session.id.clone();
let drained = Self::drain(&client, &session, instrument, quote_size, limits).await;
let deleted = with_retry("delete_session", || client.delete_session(&session_id)).await;
if let Err(delete_error) = &deleted {
tracing::warn!(
session_id = %session_id,
error = %delete_error,
materialisation_failed = drained.is_err(),
"delete_session failed; the server session may be leaked"
);
}
let tape = drained?;
if tape.is_empty() {
return Err(BacktestError::Conversion(format!(
"simulator session {session_id} produced an empty tape; \
an empty tape fails to construct"
)));
}
let sha256 = tape_sha256(&tape)?;
let meta = TapeMeta::from_tape(sha256.clone(), &tape)?;
let mut source = spec.clone();
source.session.seed = Some(spec.data_seed);
match session.parameters.seed {
Some(effective) => {
if effective != spec.data_seed {
tracing::warn!(
configured = spec.data_seed,
effective,
"simulator echoed a different effective walk seed; recording the effective value"
);
}
source.data_seed = effective;
source.session.seed = Some(effective);
}
None => {
tracing::warn!(
configured = spec.data_seed,
"simulator echoed no effective walk seed (pre-v0.1.0 server?); the \
configured data_seed cannot be claimed to have driven the walk"
);
}
}
source.tape_sha256 = sha256;
Ok(Self {
tape,
cursor: 0,
meta,
source,
})
}
async fn drain(
client: &ApiClient,
session: &SessionResponse,
instrument: InstrumentSpec,
quote_size: Quantity,
limits: &ResourceLimits,
) -> Result<Vec<ChainSnapshot>, BacktestError> {
let mut tape: Vec<ChainSnapshot> = Vec::new();
let mut total_bytes: u64 = 0;
let mut anchor_ts: Option<SimTime> = None;
let mut prev_ts: Option<SimTime> = None;
let mut ended = session_ended(
SessionState::from_wire(&session.state),
session.current_step,
session.total_steps,
);
let mut expected_step = session.current_step;
while !ended {
let resp = with_retry("get_next_step", || {
client.get_next_step_expecting(&session.id, Some(expected_step))
})
.await?;
let quote_count = u64::try_from(resp.contracts.len())
.map_err(|_| BacktestError::ArithmeticOverflow)?
.checked_mul(2)
.ok_or(BacktestError::ArithmeticOverflow)?;
let contracts_cap = u64::from(limits.max_contracts_per_snapshot);
if quote_count > contracts_cap {
return Err(BacktestError::TapeTooLarge {
limit: "max_contracts_per_snapshot",
value: quote_count,
cap: contracts_cap,
});
}
let step = StepIndex::new(
u32::try_from(tape.len()).map_err(|_| BacktestError::ArithmeticOverflow)?,
);
let anchor = match anchor_ts {
Some(existing) => existing,
None => {
let first = response_ts(&resp)?;
anchor_ts = Some(first);
first
}
};
let snapshot = chain_response_to_snapshot(&resp, step, instrument, anchor, quote_size)?;
if let Some(prev) = prev_ts
&& snapshot.ts <= prev
{
return Err(BacktestError::DataOutOfOrder {
step: step.value(),
ts: snapshot.ts.value(),
prev: prev.value(),
});
}
prev_ts = Some(snapshot.ts);
push_checked(&mut tape, snapshot, &mut total_bytes, limits)?;
let info = &resp.session_info;
ended = SessionState::from_progress(info.current_step, info.total_steps).is_terminal();
expected_step = info.current_step;
}
Ok(tape)
}
}
impl DataFeed for SimulatorFeed {
fn next(&mut self) -> Result<Option<ChainSnapshot>, BacktestError> {
match self.tape.get(self.cursor) {
Some(snapshot) => {
self.cursor += 1;
Ok(Some(snapshot.clone()))
}
None => Ok(None),
}
}
fn meta(&self) -> DataSourceSpec {
DataSourceSpec::Simulator(self.source.clone())
}
fn tape_meta(&self) -> &TapeMeta {
&self.meta
}
}
#[cfg(test)]
mod tests {
use super::{
ApiClient, ChainResponse, CreateSessionRequest, MarketSimulator, OptionContractResponse,
OptionPriceResponse, SessionInfoResponse, SessionParametersResponse, SessionResponse,
SessionState,
};
use crate::error::BacktestError;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
fn stub_sim() -> MarketSimulator {
match ApiClient::new(ApiClient::DEFAULT_BASE_URL, Duration::from_secs(5)) {
Ok(client) => MarketSimulator::new(Arc::new(client)),
Err(e) => panic!("reqwest client must build offline: {e}"),
}
}
fn created(state: &str, current: usize, total: usize) -> SessionResponse {
SessionResponse {
id: "11111111-1111-1111-1111-111111111111".to_string(),
created_at: "2026-07-15T00:00:00Z".to_string(),
updated_at: "2026-07-15T00:00:00Z".to_string(),
parameters: SessionParametersResponse {
symbol: "SPX".to_string(),
seed: Some(42),
..SessionParametersResponse::default()
},
current_step: current,
total_steps: total,
state: state.to_string(),
}
}
fn advanced(current: usize, total: usize) -> ChainResponse {
ChainResponse {
underlying: "SPX".to_string(),
timestamp: "2026-07-15T00:00:01Z".to_string(),
price: 100.0,
contracts: Vec::new(),
session_info: SessionInfoResponse {
id: "11111111-1111-1111-1111-111111111111".to_string(),
current_step: current,
total_steps: total,
},
}
}
#[test]
fn test_is_terminated_true_with_no_session() {
let sim = stub_sim();
assert!(
sim.is_terminated(),
"no session must report terminated so termination derives from real feed state"
);
}
#[test]
fn test_step_reads_real_session_state() {
let mut sim = stub_sim();
sim.record_session_response(&created("Initialized", 0, 2));
let s0 = sim.get_current_state();
assert!(matches!(s0, Ok(ref s) if s.state == SessionState::Initialized));
assert!(!sim.is_terminated(), "a fresh session is not terminated");
sim.record_chain_response(&advanced(1, 2));
let s1 = sim.get_current_state();
assert!(matches!(s1, Ok(ref s) if s.state == SessionState::InProgress));
assert!(!sim.is_terminated());
sim.record_chain_response(&advanced(2, 2));
let s2 = sim.get_current_state();
assert!(matches!(s2, Ok(ref s) if s.state == SessionState::Completed));
assert!(sim.is_terminated(), "reaching the final step terminates");
}
#[tokio::test]
async fn test_next_step_without_session_is_session_error() {
let mut sim = stub_sim();
let result = sim.next_step().await;
assert!(matches!(result, Err(BacktestError::Session(_))));
}
#[tokio::test]
async fn test_response_body_cap_rejects_oversized_body() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = match TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(e) => panic!("bind failed: {e}"),
};
let addr = match listener.local_addr() {
Ok(addr) => addr,
Err(e) => panic!("local_addr failed: {e}"),
};
let server = tokio::spawn(async move {
if let Ok((mut socket, _)) = listener.accept().await {
let mut scratch = [0u8; 1024];
let _ = socket.read(&mut scratch).await;
let body = vec![b'x'; 4096]; let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = socket.write_all(header.as_bytes()).await;
let _ = socket.write_all(&body).await;
let _ = socket.flush().await;
}
});
let base_url = format!("http://{addr}");
let client = match ApiClient::new(&base_url, Duration::from_secs(5)) {
Ok(client) => client.with_max_response_bytes(64),
Err(e) => panic!("client build failed: {e}"),
};
let request = CreateSessionRequest {
symbol: "SPX".to_string(),
steps: 1,
initial_price: 100.0,
days_to_expiration: 30.0,
volatility: 0.2,
risk_free_rate: 0.03,
dividend_yield: 0.0,
method: json!({"Brownian": {"dt": 0.004, "drift": 0.0, "volatility": 0.2}}),
time_frame: "Day".to_string(),
chain_size: None,
strike_interval: None,
skew_slope: None,
smile_curve: None,
spread: None,
seed: None,
};
let result = client.create_session(request).await;
assert!(
matches!(result, Err(BacktestError::Session(_))),
"an oversized response body must be a typed Session error, got {result:?}"
);
let _ = server.await;
}
#[test]
fn test_session_state_from_wire_parses_known_spellings() {
assert_eq!(
SessionState::from_wire("Initialized"),
SessionState::Initialized
);
assert_eq!(
SessionState::from_wire("In Progress"),
SessionState::InProgress
);
assert_eq!(
SessionState::from_wire("InProgress"),
SessionState::InProgress
);
assert_eq!(
SessionState::from_wire("Completed"),
SessionState::Completed
);
assert_eq!(SessionState::from_wire("Error"), SessionState::Error);
assert_eq!(
SessionState::from_wire("weird-new-state"),
SessionState::Unknown
);
}
#[test]
fn test_session_state_from_progress_and_terminality() {
assert_eq!(SessionState::from_progress(1, 3), SessionState::InProgress);
assert_eq!(SessionState::from_progress(3, 3), SessionState::Completed);
assert!(SessionState::Completed.is_terminal());
assert!(SessionState::Error.is_terminal());
assert!(!SessionState::InProgress.is_terminal());
assert!(!SessionState::Unknown.is_terminal());
}
#[test]
fn test_create_session_request_serde_round_trip() {
let req = CreateSessionRequest {
symbol: "SPX".to_string(),
steps: 20,
initial_price: 100.0,
days_to_expiration: 30.0,
volatility: 0.2,
risk_free_rate: 0.03,
dividend_yield: 0.0,
method: json!({"GeometricBrownian": {"dt": 0.004, "drift": 0.05, "volatility": 0.25}}),
time_frame: "Day".to_string(),
chain_size: Some(15),
strike_interval: Some(5.0),
skew_slope: None,
smile_curve: None,
spread: Some(0.02),
seed: None,
};
let text = serde_json::to_string(&req).unwrap_or_default();
assert!(text.contains("\"GeometricBrownian\""));
assert!(
!text.contains("skew_slope"),
"None optional fields are omitted"
);
assert!(
!text.contains("seed"),
"an unset seed is omitted on the wire, keeping the request \
compatible with older seedless servers"
);
let back: Result<CreateSessionRequest, _> = serde_json::from_str(&text);
assert!(matches!(back, Ok(ref r) if *r == req));
}
#[test]
fn test_create_session_request_seed_serialised_when_set() {
let req = CreateSessionRequest {
symbol: "SPX".to_string(),
steps: 3,
initial_price: 100.0,
days_to_expiration: 30.0,
volatility: 0.2,
risk_free_rate: 0.03,
dividend_yield: 0.0,
method: json!({"Brownian": {"dt": 0.004, "drift": 0.0, "volatility": 0.2}}),
time_frame: "Day".to_string(),
chain_size: None,
strike_interval: None,
skew_slope: None,
smile_curve: None,
spread: None,
seed: Some(1_234_567),
};
let text = serde_json::to_string(&req).unwrap_or_default();
assert!(
text.contains("\"seed\":1234567"),
"a set data seed travels on the wire: {text}"
);
let back: Result<CreateSessionRequest, _> = serde_json::from_str(&text);
assert!(matches!(back, Ok(ref r) if r.seed == Some(1_234_567)));
}
#[test]
fn test_base_url_from_resolution_rule() {
assert_eq!(
ApiClient::base_url_from(None),
ApiClient::DEFAULT_BASE_URL,
"unset API_URL falls back to the conventional default"
);
assert_eq!(
ApiClient::base_url_from(Some(String::new())),
ApiClient::DEFAULT_BASE_URL,
"an empty API_URL falls back to the conventional default"
);
assert_eq!(
ApiClient::base_url_from(Some("http://sim.internal:9090".to_string())),
"http://sim.internal:9090"
);
}
#[test]
fn test_chain_response_serde_round_trip() {
let resp = ChainResponse {
underlying: "SPX".to_string(),
timestamp: "2026-07-15T00:00:00Z".to_string(),
price: 4321.5,
contracts: vec![OptionContractResponse {
strike: 4300.0,
expiration: "2026-08-15T00:00:00Z".to_string(),
call: OptionPriceResponse {
bid: Some(12.0),
ask: Some(12.5),
mid: Some(12.25),
delta: Some(0.55),
},
put: OptionPriceResponse::default(),
implied_volatility: Some(0.19),
gamma: Some(0.001),
}],
session_info: SessionInfoResponse {
id: "s".to_string(),
current_step: 1,
total_steps: 5,
},
};
let text = serde_json::to_string(&resp).unwrap_or_default();
let back: Result<ChainResponse, _> = serde_json::from_str(&text);
assert!(matches!(back, Ok(ref r) if *r == resp));
}
#[test]
fn test_session_response_deserialises_raw_state_string() {
let text = r#"{
"id": "abc",
"created_at": "t0",
"updated_at": "t0",
"parameters": {
"symbol": "SPX",
"initial_price": 100.0,
"volatility": 0.2,
"risk_free_rate": 0.03,
"method": {"Brownian": {"dt": 0.004, "drift": 0.0, "volatility": 0.2}},
"time_frame": "Day",
"dividend_yield": 0.0,
"skew_slope": null,
"smile_curve": null,
"spread": 0.02,
"seed": 42
},
"current_step": 0,
"total_steps": 10,
"state": "In Progress"
}"#;
let parsed: Result<SessionResponse, _> = serde_json::from_str(text);
assert!(matches!(parsed, Ok(ref r) if r.state == "In Progress"));
if let Ok(r) = parsed {
assert_eq!(SessionState::from_wire(&r.state), SessionState::InProgress);
assert_eq!(
r.parameters.seed,
Some(42),
"the effective walk seed is read back from the echoed parameters"
);
}
}
#[test]
fn test_session_parameters_lenient_to_unknown_and_missing_optionals() {
let text = r#"{
"symbol": "SPX",
"initial_price": 100.0,
"volatility": 0.2,
"risk_free_rate": 0.03,
"method": "Brownian",
"time_frame": "Day",
"dividend_yield": 0.0,
"some_future_field": true
}"#;
let parsed: Result<SessionParametersResponse, _> = serde_json::from_str(text);
match parsed {
Ok(p) => {
assert_eq!(p.symbol, "SPX");
assert_eq!(p.seed, None, "a seedless echo parses to None");
assert_eq!(p.spread, None);
}
Err(e) => panic!("a response with an unknown field must parse: {e}"),
}
}
}
#[cfg(test)]
mod tape_tests {
use optionstratlib::{ExpirationDate, OptionStyle};
use super::{SessionState, session_ended, tape_sha256};
use crate::data::convert::{RawQuote, SnapshotMeta, raw_quotes_to_snapshot};
use crate::domain::{ChainSnapshot, PriceCents, Quantity, SimTime, StepIndex, Underlying};
const TS0: i64 = 1_784_073_601_000_000_000;
fn snapshot(step: u32, ts_offset_ns: i64, bid: u64) -> ChainSnapshot {
let underlying = match Underlying::new("SPX") {
Ok(u) => u,
Err(e) => panic!("SPX must be a valid underlying: {e}"),
};
let bid_size = match Quantity::new(10) {
Ok(q) => q,
Err(e) => panic!("10 must be a valid quantity: {e}"),
};
let quote = RawQuote {
expiration: ExpirationDate::DateTime(chrono::DateTime::from_timestamp_nanos(
TS0 + 30 * 86_400_000_000_000,
)),
strike: PriceCents::new(430_000),
style: OptionStyle::Call,
bid: PriceCents::new(bid),
ask: PriceCents::new(1_250),
bid_size,
ask_size: bid_size,
implied_volatility: 0.19,
delta: 0.55,
gamma: 0.001,
theta: 0.0,
vega: 0.0,
};
let meta = SnapshotMeta {
ts: SimTime::new(TS0 + ts_offset_ns),
step: StepIndex::new(step),
anchor_ts: SimTime::new(TS0),
underlying,
underlying_price: PriceCents::new(431_025),
tick_size_cents: PriceCents::new(5),
contract_multiplier: 100,
};
match raw_quotes_to_snapshot(&meta, &[quote]) {
Ok(s) => s,
Err(e) => panic!("conversion must succeed: {e}"),
}
}
#[test]
fn test_tape_sha256_stable_for_identical_tapes() {
let tape_a = vec![snapshot(0, 0, 1_200), snapshot(1, 1_000_000_000, 1_205)];
let tape_b = vec![snapshot(0, 0, 1_200), snapshot(1, 1_000_000_000, 1_205)];
let sha_a = match tape_sha256(&tape_a) {
Ok(s) => s,
Err(e) => panic!("hashing must succeed: {e}"),
};
let sha_b = match tape_sha256(&tape_b) {
Ok(s) => s,
Err(e) => panic!("hashing must succeed: {e}"),
};
assert_eq!(
sha_a, sha_b,
"the identical ordered tape hashes identically"
);
assert_eq!(sha_a.len(), 64, "sha256 is 64 lowercase hex chars");
assert!(sha_a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_tape_sha256_sensitive_to_any_converted_value() {
let base = vec![snapshot(0, 0, 1_200), snapshot(1, 1_000_000_000, 1_205)];
let bid_changed = vec![snapshot(0, 0, 1_195), snapshot(1, 1_000_000_000, 1_205)];
let ts_changed = vec![snapshot(0, 0, 1_200), snapshot(1, 2_000_000_000, 1_205)];
let sha = |tape: &[ChainSnapshot]| match tape_sha256(tape) {
Ok(s) => s,
Err(e) => panic!("hashing must succeed: {e}"),
};
assert_ne!(
sha(&base),
sha(&bid_changed),
"a differing quoted value must yield a distinct identity"
);
assert_ne!(
sha(&base),
sha(&ts_changed),
"a differing timestamp must yield a distinct identity"
);
}
#[test]
fn test_session_ended_derives_from_wire_state_and_counters() {
assert!(!session_ended(SessionState::Initialized, 0, 3));
assert!(session_ended(SessionState::Initialized, 0, 0));
assert!(session_ended(SessionState::InProgress, 3, 3));
assert!(session_ended(SessionState::Completed, 0, 3));
assert!(session_ended(SessionState::Error, 0, 3));
assert!(!session_ended(SessionState::Unknown, 1, 3));
}
}