use crate::api::rest::error::map_error;
use crate::domain::factors::FactorTape;
use crate::domain::series::{SeriesBuilder, SeriesSnapshot};
use crate::infrastructure::{
CURRENT_SNAPSHOT_GENERATION, QuoteRow, SimulationSnapshotRepository, SnapshotRecord,
};
use crate::session::{SimulationManager, SimulationParametersV2};
use crate::utils::ChainError;
use actix_web::{HttpRequest, HttpResponse, Responder, web};
use chrono::{DateTime, SecondsFormat, Utc};
use futures::stream::Stream;
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::runtime::Handle;
use tokio::sync::mpsc;
use tracing::{debug, info, instrument, warn};
use utoipa::ToSchema;
use uuid::Uuid;
const CHANNEL_CAPACITY: usize = 16;
const SNAPSHOT_WINDOW_STEPS: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Dataset {
Underlying,
Volatility,
OptionChains,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Format {
Json,
Csv,
}
impl Format {
#[must_use]
fn content_type(self) -> &'static str {
match self {
Format::Json => "application/json",
Format::Csv => "text/csv; charset=utf-8",
}
}
#[must_use]
fn extension(self) -> &'static str {
match self {
Format::Json => "json",
Format::Csv => "csv",
}
}
}
impl Dataset {
#[must_use]
fn as_str(self) -> &'static str {
match self {
Dataset::Underlying => "underlying",
Dataset::Volatility => "volatility",
Dataset::OptionChains => "option_chains",
}
}
#[must_use]
fn header(self) -> &'static [&'static str] {
match self {
Dataset::Underlying => &["step", "simulated_at", "symbol", "price"],
Dataset::Volatility => &["step", "simulated_at", "symbol", "base_volatility"],
Dataset::OptionChains => &[
"step",
"simulated_at",
"symbol",
"expires_at",
"labels",
"days_to_expiration",
"strike",
"implied_volatility",
"call_bid",
"call_ask",
"call_mid",
"call_delta",
"put_bid",
"put_ask",
"put_mid",
"put_delta",
"gamma",
],
}
}
#[must_use]
fn needs_chains(self) -> bool {
matches!(self, Dataset::OptionChains)
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub(crate) struct ExportQuery {
pub(crate) dataset: Dataset,
pub(crate) format: Format,
#[serde(default)]
pub(crate) from_step: Option<usize>,
#[serde(default)]
pub(crate) to_step: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StepRange {
from: usize,
to: usize,
}
impl StepRange {
fn resolve(query: &ExportQuery, steps: usize, max_rows: usize) -> Result<Self, ChainError> {
let last = steps.checked_sub(1).ok_or_else(|| {
ChainError::Internal(
"a simulation with no steps cannot exist; `steps >= 1` is validated at creation"
.to_string(),
)
})?;
let from = query.from_step.unwrap_or(0);
let to = query.to_step.unwrap_or(last);
if from > last {
return Err(ChainError::Validation {
field: "from_step".to_string(),
reason: format!("must not exceed the last step ({last}), got {from}"),
});
}
if to > last {
return Err(ChainError::Validation {
field: "to_step".to_string(),
reason: format!("must not exceed the last step ({last}), got {to}"),
});
}
if from > to {
return Err(ChainError::Validation {
field: "from_step".to_string(),
reason: format!("must not exceed to_step ({to}), got {from}"),
});
}
let span = to
.checked_sub(from)
.and_then(|span| span.checked_add(1))
.ok_or_else(|| ChainError::Validation {
field: "to_step".to_string(),
reason: "the requested range overflows".to_string(),
})?;
if span > max_rows {
return Err(ChainError::Validation {
field: "to_step".to_string(),
reason: format!(
"the requested range covers {span} steps, above the {max_rows} the service will export in one request"
),
});
}
Ok(Self { from, to })
}
fn steps(self) -> impl Iterator<Item = usize> {
self.from..=self.to
}
}
#[must_use]
#[inline]
fn render_instant(instant: DateTime<Utc>) -> String {
instant.to_rfc3339_opts(SecondsFormat::Secs, true)
}
#[must_use]
#[inline]
fn render_optional(value: Option<f64>) -> String {
value.map(|value| value.to_string()).unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct QuoteView {
strike: f64,
implied_volatility: f64,
call_bid: Option<f64>,
call_ask: Option<f64>,
call_mid: Option<f64>,
call_delta: Option<f64>,
put_bid: Option<f64>,
put_ask: Option<f64>,
put_mid: Option<f64>,
put_delta: Option<f64>,
gamma: Option<f64>,
}
impl QuoteView {
#[must_use]
fn replayed(data: &OptionData) -> Self {
Self {
strike: data.strike_price.to_f64(),
implied_volatility: data.implied_volatility.to_f64(),
call_bid: data.call_bid.map(|value| value.to_f64()),
call_ask: data.call_ask.map(|value| value.to_f64()),
call_mid: data.call_middle.map(|value| value.to_f64()),
call_delta: data.delta_call.and_then(decimal_to_f64),
put_bid: data.put_bid.map(|value| value.to_f64()),
put_ask: data.put_ask.map(|value| value.to_f64()),
put_mid: data.put_middle.map(|value| value.to_f64()),
put_delta: data.delta_put.and_then(decimal_to_f64),
gamma: data.gamma.and_then(decimal_to_f64),
}
}
#[must_use]
fn stored(row: &QuoteRow) -> Self {
Self {
strike: row.strike.to_f64(),
implied_volatility: row.implied_volatility.to_f64(),
call_bid: row.call_bid.map(|value| value.to_f64()),
call_ask: row.call_ask.map(|value| value.to_f64()),
call_mid: row.call_mid.map(|value| value.to_f64()),
call_delta: row.delta_call.and_then(decimal_to_f64),
put_bid: row.put_bid.map(|value| value.to_f64()),
put_ask: row.put_ask.map(|value| value.to_f64()),
put_mid: row.put_mid.map(|value| value.to_f64()),
put_delta: row.delta_put.and_then(decimal_to_f64),
gamma: row.gamma.and_then(decimal_to_f64),
}
}
}
#[derive(Debug, Clone, Copy)]
enum QuoteSource<'a> {
Replayed(&'a OptionChain),
Stored(&'a [QuoteRow]),
}
impl<'a> QuoteSource<'a> {
fn quotes(self) -> impl Iterator<Item = QuoteView> + 'a {
let replayed = match self {
QuoteSource::Replayed(chain) => Some(chain.iter()),
QuoteSource::Stored(_) => None,
};
let stored = match self {
QuoteSource::Replayed(_) => None,
QuoteSource::Stored(quotes) => Some(quotes.iter()),
};
replayed
.into_iter()
.flatten()
.map(QuoteView::replayed)
.chain(stored.into_iter().flatten().map(QuoteView::stored))
}
}
#[derive(Debug, Clone, Copy)]
struct ExpirationView<'a> {
expires_at: DateTime<Utc>,
days_to_expiration: f64,
labels: &'a [String],
quotes: QuoteSource<'a>,
}
#[derive(Debug, Clone, Copy)]
enum StepChains<'a> {
Replayed(&'a SeriesSnapshot),
Stored(&'a SnapshotRecord),
}
impl<'a> StepChains<'a> {
fn expirations(self) -> impl Iterator<Item = ExpirationView<'a>> {
let replayed = match self {
StepChains::Replayed(snapshot) => Some(snapshot.chains.iter()),
StepChains::Stored(_) => None,
};
let stored = match self {
StepChains::Replayed(_) => None,
StepChains::Stored(record) => Some(record.expirations.iter()),
};
replayed
.into_iter()
.flatten()
.map(|chain| ExpirationView {
expires_at: chain.expires_at,
days_to_expiration: chain.days_to_expiration.to_f64(),
labels: &chain.labels,
quotes: QuoteSource::Replayed(&chain.chain),
})
.chain(
stored
.into_iter()
.flatten()
.map(|expiration| ExpirationView {
expires_at: expiration.expires_at,
days_to_expiration: expiration.days_to_expiration.to_f64(),
labels: &expiration.labels,
quotes: QuoteSource::Stored(&expiration.quotes),
}),
)
}
}
#[must_use]
#[inline]
fn window_end(from: usize, window: usize, last: usize) -> usize {
match window
.checked_sub(1)
.and_then(|span| from.checked_add(span))
{
Some(end) => end.min(last),
None => last,
}
}
struct StoredSteps {
repository: Arc<dyn SimulationSnapshotRepository>,
runtime: Handle,
simulation: Uuid,
loaded: VecDeque<SnapshotRecord>,
window_end: Option<usize>,
window: usize,
degraded: bool,
}
impl StoredSteps {
#[must_use]
fn new(
repository: Arc<dyn SimulationSnapshotRepository>,
simulation: Uuid,
runtime: Handle,
) -> Self {
Self {
repository,
runtime,
simulation,
loaded: VecDeque::new(),
window_end: None,
window: SNAPSHOT_WINDOW_STEPS,
degraded: false,
}
}
fn take(&mut self, step: usize, last: usize) -> Option<SnapshotRecord> {
if self.degraded {
return None;
}
if self.window_end.is_none_or(|end| step > end) {
self.load(step, last);
}
while self.loaded.front().is_some_and(|record| record.step < step) {
self.loaded.pop_front();
}
match self.loaded.front() {
Some(record) if record.step == step => self.loaded.pop_front(),
_ => None,
}
}
fn load(&mut self, from: usize, last: usize) {
self.loaded.clear();
loop {
let to = window_end(from, self.window, last);
match self.read(from, to) {
Ok(records) => {
debug!(
simulation_id = %self.simulation,
from_step = from,
to_step = to,
found = records.len(),
"Prefetched persisted snapshots for an export window"
);
self.loaded = VecDeque::from(records);
self.window_end = Some(to);
return;
}
Err(ChainError::Validation { field, reason }) if self.window > 1 => {
self.window /= 2;
debug!(
simulation_id = %self.simulation,
window = self.window,
field = %field,
reason = %reason,
"Narrowed the snapshot read window and retried"
);
}
Err(error) => {
debug!(
simulation_id = %self.simulation,
from_step = from,
error = %error,
"Could not read persisted snapshots; the export replays instead"
);
self.degraded = true;
return;
}
}
}
}
fn read(&self, from: usize, to: usize) -> Result<Vec<SnapshotRecord>, ChainError> {
let repository = Arc::clone(&self.repository);
let simulation = self.simulation;
self.runtime.block_on(async move {
repository
.read_range(simulation, CURRENT_SNAPSHOT_GENERATION, from, to)
.await
})
}
}
struct RowStream {
receiver: mpsc::Receiver<Result<Vec<u8>, ChainError>>,
}
impl Stream for RowStream {
type Item = Result<web::Bytes, actix_web::Error>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match self.receiver.poll_recv(cx) {
Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(web::Bytes::from(chunk)))),
Poll::Ready(Some(Err(error))) => {
warn!(%error, "a v2 export failed after the response had started");
Poll::Ready(Some(Err(actix_web::error::ErrorInternalServerError(
error.to_string(),
))))
}
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
#[utoipa::path(
get,
path = "/api/v2/simulations/{id}/export",
description = "Export a simulation's complete tape, or a step range of it, as JSON or CSV. \
Read-only: it replays from an immutable snapshot of the effective parameters and never \
advances the cursor, changes the state or version, or alters what the next peek returns. \
A simulation that has not been walked at all exports its whole tape. Where snapshot \
persistence is enabled, an option_chains export serves the steps the warehouse holds from \
it and replays the rest; the rows are identical either way. JSON is a single array of row \
objects; CSV is RFC 4180 with a header row and CRLF line endings. Repeating the same \
export yields byte-identical output.",
params(
("id" = String, Path, description = "The simulation's identifier"),
("dataset" = String, Query, description = "underlying | volatility | option_chains"),
("format" = String, Query, description = "json | csv"),
("from_step" = Option<usize>, Query, description = "First step, inclusive; defaults to 0"),
("to_step" = Option<usize>, Query, description = "Last step, inclusive; defaults to the final step")
),
responses(
(status = 200, description = "The exported rows, streamed", body = String),
(status = 400, description = "Unknown dataset or format, or an invalid range; body carries `error` and `field`"),
(status = 404, description = "Simulation not found"),
(status = 500, description = "Internal server error")
)
)]
#[instrument(skip(manager, snapshots, query), level = "debug")]
pub(crate) async fn export_simulation(
req: HttpRequest,
manager: web::Data<Arc<SimulationManager>>,
snapshots: Option<web::Data<Arc<dyn SimulationSnapshotRepository>>>,
path: web::Path<super::handlers_v2::SimulationPath>,
query: web::Query<ExportQuery>,
) -> impl Responder {
info!("{} {}", req.method(), req.path());
let id = match Uuid::parse_str(&path.id) {
Ok(id) => id,
Err(_) => {
return map_error(ChainError::Validation {
field: "id".to_string(),
reason: format!("must be a UUID, got {:?}", path.id),
});
}
};
let simulation = match manager.get(id).await {
Ok(simulation) => simulation,
Err(error) => return map_error(error),
};
let parameters = simulation.parameters.clone();
let range = match StepRange::resolve(&query, parameters.steps, manager.config().max_export_rows)
{
Ok(range) => range,
Err(error) => return map_error(error),
};
let dataset = query.dataset;
let format = query.format;
let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
let stored = snapshots
.filter(|_| dataset.needs_chains())
.map(|repository| {
StoredSteps::new(Arc::clone(repository.get_ref()), id, Handle::current())
});
tokio::task::spawn_blocking(move || {
if let Err(error) = produce(¶meters, dataset, format, range, stored, &sender) {
let _ = sender.blocking_send(Err(error));
}
});
let filename = format!(
"{}-{}-{}.{}",
simulation.id,
dataset.as_str(),
range.from,
format.extension()
);
HttpResponse::Ok()
.content_type(format.content_type())
.insert_header((
actix_web::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
))
.streaming(RowStream { receiver })
}
fn produce(
parameters: &SimulationParametersV2,
dataset: Dataset,
format: Format,
range: StepRange,
mut stored: Option<StoredSteps>,
sender: &mpsc::Sender<Result<Vec<u8>, ChainError>>,
) -> Result<(), ChainError> {
let tape = FactorTape::build(parameters, ¶meters.method)?;
let builder = if dataset.needs_chains() {
Some(SeriesBuilder::new(parameters, &tape)?)
} else {
None
};
let mut writer = Writer::new(format, dataset)?;
if let Some(chunk) = writer.prologue()?
&& sender.blocking_send(Ok(chunk)).is_err()
{
return Ok(());
}
let mut served_from_storage: usize = 0;
for step in range.steps() {
let row = tape
.row(step)
.ok_or_else(|| ChainError::Internal(format!("the tape has no row at step {step}")))?;
let record = match &mut stored {
Some(stored) => stored.take(step, range.to),
None => None,
};
let replayed = match (&record, &builder) {
(None, Some(builder)) => Some(builder.snapshot(step)?),
_ => None,
};
let chains = match (&record, &replayed) {
(Some(record), _) => Some(StepChains::Stored(record)),
(None, Some(snapshot)) => Some(StepChains::Replayed(snapshot)),
(None, None) => None,
};
if record.is_some() {
served_from_storage = served_from_storage
.checked_add(1)
.ok_or_else(|| ChainError::Internal("the step counter overflowed".to_string()))?;
}
let chunk = writer.rows(parameters, row.step, row, chains)?;
if !chunk.is_empty() && sender.blocking_send(Ok(chunk)).is_err() {
return Ok(());
}
}
if let Some(chunk) = writer.epilogue()?
&& sender.blocking_send(Ok(chunk)).is_err()
{
return Ok(());
}
if stored.is_some() {
debug!(
from_step = range.from,
to_step = range.to,
served_from_storage,
"Finished a v2 export"
);
}
Ok(())
}
enum Writer {
Json { dataset: Dataset, first: bool },
Csv { dataset: Dataset },
}
impl Writer {
fn new(format: Format, dataset: Dataset) -> Result<Self, ChainError> {
Ok(match format {
Format::Json => Writer::Json {
dataset,
first: true,
},
Format::Csv => Writer::Csv { dataset },
})
}
fn prologue(&mut self) -> Result<Option<Vec<u8>>, ChainError> {
match self {
Writer::Json { .. } => Ok(Some(b"[".to_vec())),
Writer::Csv { dataset } => {
let header: Vec<String> =
dataset.header().iter().map(ToString::to_string).collect();
Ok(Some(encode_csv(&[header])?))
}
}
}
fn epilogue(&mut self) -> Result<Option<Vec<u8>>, ChainError> {
match self {
Writer::Json { .. } => Ok(Some(b"]".to_vec())),
Writer::Csv { .. } => Ok(None),
}
}
fn rows(
&mut self,
parameters: &SimulationParametersV2,
step: usize,
row: &crate::domain::factors::FactorRow,
chains: Option<StepChains<'_>>,
) -> Result<Vec<u8>, ChainError> {
let simulated_at = render_instant(row.simulated_at);
let symbol = parameters.symbol.as_str();
match self {
Writer::Json { dataset, first } => {
let values = json_rows(*dataset, step, &simulated_at, symbol, row, chains);
let mut chunk = Vec::new();
for value in values {
if !*first {
chunk.push(b',');
}
*first = false;
let encoded = serde_json::to_vec(&value).map_err(|e| {
ChainError::Internal(format!("failed to encode an export row: {e}"))
})?;
chunk.extend_from_slice(&encoded);
}
Ok(chunk)
}
Writer::Csv { dataset } => {
let records = csv_rows(*dataset, step, &simulated_at, symbol, row, chains);
encode_csv(&records)
}
}
}
}
fn encode_csv(records: &[Vec<String>]) -> Result<Vec<u8>, ChainError> {
let mut writer = csv::WriterBuilder::new()
.terminator(csv::Terminator::CRLF)
.from_writer(Vec::new());
for record in records {
writer.write_record(record).map_err(csv_error)?;
}
writer
.into_inner()
.map_err(|e| ChainError::Internal(format!("failed to flush the export buffer: {e}")))
}
#[cold]
fn csv_error(error: csv::Error) -> ChainError {
ChainError::Internal(format!("failed to encode an export row: {error}"))
}
fn json_rows(
dataset: Dataset,
step: usize,
simulated_at: &str,
symbol: &str,
row: &crate::domain::factors::FactorRow,
chains: Option<StepChains<'_>>,
) -> Vec<serde_json::Value> {
match dataset {
Dataset::Underlying => vec![serde_json::json!({
"step": step,
"simulated_at": simulated_at,
"symbol": symbol,
"price": row.spot.to_f64(),
})],
Dataset::Volatility => vec![serde_json::json!({
"step": step,
"simulated_at": simulated_at,
"symbol": symbol,
"base_volatility": row.base_volatility.to_f64(),
})],
Dataset::OptionChains => {
let Some(chains) = chains else {
return Vec::new();
};
let mut rows = Vec::new();
for expiration in chains.expirations() {
let expires_at = render_instant(expiration.expires_at);
let labels = expiration.labels.join("|");
for quote in expiration.quotes.quotes() {
rows.push(serde_json::json!({
"step": step,
"simulated_at": simulated_at,
"symbol": symbol,
"expires_at": expires_at,
"labels": labels,
"days_to_expiration": expiration.days_to_expiration,
"strike": quote.strike,
"implied_volatility": quote.implied_volatility,
"call_bid": quote.call_bid,
"call_ask": quote.call_ask,
"call_mid": quote.call_mid,
"call_delta": quote.call_delta,
"put_bid": quote.put_bid,
"put_ask": quote.put_ask,
"put_mid": quote.put_mid,
"put_delta": quote.put_delta,
"gamma": quote.gamma,
}));
}
}
rows
}
}
}
fn csv_rows(
dataset: Dataset,
step: usize,
simulated_at: &str,
symbol: &str,
row: &crate::domain::factors::FactorRow,
chains: Option<StepChains<'_>>,
) -> Vec<Vec<String>> {
match dataset {
Dataset::Underlying => vec![vec![
step.to_string(),
simulated_at.to_string(),
symbol.to_string(),
row.spot.to_f64().to_string(),
]],
Dataset::Volatility => vec![vec![
step.to_string(),
simulated_at.to_string(),
symbol.to_string(),
row.base_volatility.to_f64().to_string(),
]],
Dataset::OptionChains => {
let Some(chains) = chains else {
return Vec::new();
};
let mut records = Vec::new();
for expiration in chains.expirations() {
let expires_at = render_instant(expiration.expires_at);
let labels = expiration.labels.join("|");
for quote in expiration.quotes.quotes() {
records.push(vec![
step.to_string(),
simulated_at.to_string(),
symbol.to_string(),
expires_at.clone(),
labels.clone(),
expiration.days_to_expiration.to_string(),
quote.strike.to_string(),
quote.implied_volatility.to_string(),
render_optional(quote.call_bid),
render_optional(quote.call_ask),
render_optional(quote.call_mid),
render_optional(quote.call_delta),
render_optional(quote.put_bid),
render_optional(quote.put_ask),
render_optional(quote.put_mid),
render_optional(quote.put_delta),
render_optional(quote.gamma),
]);
}
}
records
}
}
}
#[must_use]
#[inline]
fn decimal_to_f64(value: rust_decimal::Decimal) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
value.to_f64()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::rest::routes::configure_v2_routes;
use crate::infrastructure::SimulationV2Config;
use crate::session::InMemorySimulationStore;
use actix_web::App;
use actix_web::http::StatusCode;
use actix_web::test as actix_test;
use serde_json::{Value, json};
fn reference_body() -> Value {
json!({
"symbol": "SPX",
"steps": 3,
"start_at": "2026-01-05T14:30:00Z",
"step_interval_seconds": 86400,
"timezone": "America/New_York",
"expiration_time": "17:00",
"schedules": [
{ "rule_id": "zero_dte", "kind": "daily", "target_count": 1 },
{ "rule_id": "weeklies", "kind": "weekly", "target_count": 3,
"weekdays": ["Mon", "Wed", "Fri"] }
],
"initial_price": 5000.0,
"volatility": 0.18,
"risk_free_rate": 0.04,
"dividend_yield": 0.0,
"method": { "Brownian": { "dt": 0.004, "drift": 0.0, "volatility": 0.18 } },
"time_frame": "Day",
"chain_size": 3,
"strike_interval": 25.0,
"spread": 0.02,
"seed": 42
})
}
macro_rules! v2_service {
() => {
v2_service!(None)
};
($snapshots:expr) => {{
let manager = Arc::new(crate::session::SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
SimulationV2Config::default(),
));
let snapshots: Option<Arc<dyn SimulationSnapshotRepository>> = $snapshots;
actix_test::init_service(
App::new()
.configure(|cfg| configure_v2_routes(cfg, manager.clone(), snapshots.clone())),
)
.await
}};
}
macro_rules! create {
($app:expr) => {{
let request = actix_test::TestRequest::post()
.uri("/api/v2/simulations")
.set_json(reference_body())
.to_request();
let response = actix_test::call_service(&$app, request).await;
assert_eq!(response.status(), StatusCode::CREATED);
let body: Value = actix_test::read_body_json(response).await;
match body.get("id").and_then(Value::as_str) {
Some(id) => id.to_string(),
None => panic!("the response must carry an id: {body}"),
}
}};
}
macro_rules! export {
($app:expr, $id:expr, $query:expr) => {{
let uri = format!("/api/v2/simulations/{}/export?{}", $id, $query);
let response = actix_test::call_service(
&$app,
actix_test::TestRequest::get().uri(&uri).to_request(),
)
.await;
let status = response.status();
let body = actix_test::read_body(response).await;
(status, String::from_utf8_lossy(&body).to_string())
}};
}
fn csv_rows_of(body: &str) -> usize {
body.split("\r\n").filter(|line| !line.is_empty()).count() - 1
}
fn json_rows_of(body: &str) -> Vec<Value> {
match serde_json::from_str::<Value>(body) {
Ok(Value::Array(rows)) => rows,
other => panic!("a JSON export must be an array, got {other:?}"),
}
}
#[actix_web::test]
async fn test_every_dataset_exports_in_both_formats_with_equal_row_counts() {
let app = v2_service!();
let id = create!(app);
for dataset in ["underlying", "volatility", "option_chains"] {
let (json_status, json_body) =
export!(app, id, format!("dataset={dataset}&format=json"));
let (csv_status, csv_body) = export!(app, id, format!("dataset={dataset}&format=csv"));
assert_eq!(json_status, StatusCode::OK, "{dataset} json");
assert_eq!(csv_status, StatusCode::OK, "{dataset} csv");
assert_eq!(
json_rows_of(&json_body).len(),
csv_rows_of(&csv_body),
"{dataset}: the two encodings must carry the same rows"
);
}
}
#[actix_web::test]
async fn test_the_per_step_datasets_carry_one_row_per_step() {
let app = v2_service!();
let id = create!(app);
for dataset in ["underlying", "volatility"] {
let (_, body) = export!(app, id, format!("dataset={dataset}&format=json"));
let rows = json_rows_of(&body);
assert_eq!(rows.len(), 3, "{dataset}");
for (index, row) in rows.iter().enumerate() {
assert_eq!(row.get("step"), Some(&json!(index)));
assert_eq!(row.get("symbol"), Some(&json!("SPX")));
}
}
}
#[actix_web::test]
async fn test_the_option_chains_dataset_carries_every_documented_column() {
let app = v2_service!();
let id = create!(app);
let (_, body) = export!(app, id, "dataset=option_chains&format=json");
let rows = json_rows_of(&body);
assert!(!rows.is_empty());
let first = match rows.first() {
Some(first) => first,
None => panic!("the export must carry rows"),
};
for column in Dataset::OptionChains.header() {
assert!(
first.get(*column).is_some(),
"the export must carry {column}: {first}"
);
}
}
#[actix_web::test]
async fn test_rows_are_ordered_by_step_then_expiration_then_strike() {
let app = v2_service!();
let id = create!(app);
let (_, body) = export!(app, id, "dataset=option_chains&format=json");
let rows = json_rows_of(&body);
let key = |row: &Value| {
(
row.get("step").and_then(Value::as_u64).unwrap_or_default(),
row.get("expires_at")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
row.get("strike")
.and_then(Value::as_f64)
.unwrap_or_default()
.to_string(),
)
};
let keys: Vec<_> = rows.iter().map(key).collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(keys, sorted, "the export must be in its documented order");
}
#[actix_web::test]
async fn test_overlapping_labels_stay_one_column() {
let app = v2_service!();
let id = create!(app);
let (_, body) = export!(app, id, "dataset=option_chains&format=csv");
assert!(
body.contains("weeklies|zero_dte"),
"a shared expiration must carry both labels in one column"
);
assert!(
!body.contains("\"weeklies"),
"the pipe join means the labels never need quoting"
);
}
#[actix_web::test]
async fn test_the_csv_is_rfc_4180() {
let app = v2_service!();
let id = create!(app);
let (_, body) = export!(app, id, "dataset=underlying&format=csv");
assert!(body.starts_with("step,simulated_at,symbol,price\r\n"));
assert!(body.ends_with("\r\n"));
}
#[actix_web::test]
async fn test_the_json_is_a_single_valid_array() {
let app = v2_service!();
let id = create!(app);
let (_, body) = export!(app, id, "dataset=option_chains&format=json");
assert!(body.starts_with('['));
assert!(body.ends_with(']'));
assert!(!json_rows_of(&body).is_empty());
}
#[actix_web::test]
async fn test_a_repeated_export_is_byte_identical() {
let app = v2_service!();
let id = create!(app);
for query in [
"dataset=underlying&format=csv",
"dataset=option_chains&format=json",
] {
let (_, first) = export!(app, id, query);
let (_, second) = export!(app, id, query);
assert_eq!(first, second, "{query} must be byte-identical on a repeat");
}
}
#[actix_web::test]
async fn test_the_same_seed_exports_an_identical_tape() {
let app = v2_service!();
let first = create!(app);
let second = create!(app);
assert_ne!(first, second);
let (_, left) = export!(app, first, "dataset=option_chains&format=csv");
let (_, right) = export!(app, second, "dataset=option_chains&format=csv");
assert_eq!(left, right);
}
#[actix_web::test]
async fn test_a_different_seed_exports_a_different_tape() {
let app = v2_service!();
let baseline = create!(app);
let mut body = reference_body();
body["seed"] = json!(43);
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/v2/simulations")
.set_json(body)
.to_request(),
)
.await;
let created: Value = actix_test::read_body_json(response).await;
let other = match created.get("id").and_then(Value::as_str) {
Some(id) => id.to_string(),
None => panic!("the response must carry an id"),
};
let (_, left) = export!(app, baseline, "dataset=underlying&format=json");
let (_, right) = export!(app, other, "dataset=underlying&format=json");
assert_ne!(left, right, "a different seed must move the market path");
let expiries = |body: &str| -> Vec<String> {
json_rows_of(body)
.iter()
.filter_map(|row| row.get("expires_at").and_then(Value::as_str))
.map(ToString::to_string)
.collect()
};
let (_, left_chains) = export!(app, baseline, "dataset=option_chains&format=json");
let (_, right_chains) = export!(app, other, "dataset=option_chains&format=json");
assert_eq!(
expiries(&left_chains),
expiries(&right_chains),
"expirations come from the schedule, not the seed"
);
}
#[actix_web::test]
async fn test_an_export_changes_nothing() {
let app = v2_service!();
let id = create!(app);
let before: Value = {
let response = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri(&format!("/api/v2/simulations/{id}/snapshot"))
.to_request(),
)
.await;
actix_test::read_body_json(response).await
};
let (status, _) = export!(app, id, "dataset=option_chains&format=json");
assert_eq!(status, StatusCode::OK);
let after: Value = {
let response = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri(&format!("/api/v2/simulations/{id}/snapshot"))
.to_request(),
)
.await;
actix_test::read_body_json(response).await
};
assert_eq!(before, after, "an export must not disturb the simulation");
}
#[actix_web::test]
async fn test_a_completed_simulation_still_exports_its_whole_tape() {
let app = v2_service!();
let id = create!(app);
for _ in 0..3 {
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri(&format!("/api/v2/simulations/{id}/step"))
.to_request(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
let peeked = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri(&format!("/api/v2/simulations/{id}/snapshot"))
.to_request(),
)
.await;
assert_eq!(peeked.status(), StatusCode::GONE);
let (status, body) = export!(app, id, "dataset=underlying&format=json");
assert_eq!(status, StatusCode::OK);
assert_eq!(json_rows_of(&body).len(), 3);
}
#[actix_web::test]
async fn test_an_unwalked_simulation_exports_from_step_zero() {
let app = v2_service!();
let id = create!(app);
let (_, body) = export!(app, id, "dataset=underlying&format=json");
let rows = json_rows_of(&body);
assert_eq!(
rows.first().and_then(|row| row.get("step")),
Some(&json!(0))
);
assert_eq!(rows.len(), 3);
}
#[actix_web::test]
async fn test_a_range_is_inclusive() {
let app = v2_service!();
let id = create!(app);
let (_, body) = export!(
app,
id,
"dataset=underlying&format=json&from_step=1&to_step=2"
);
let rows = json_rows_of(&body);
assert_eq!(rows.len(), 2);
assert_eq!(
rows.first().and_then(|row| row.get("step")),
Some(&json!(1))
);
assert_eq!(rows.last().and_then(|row| row.get("step")), Some(&json!(2)));
}
#[actix_web::test]
async fn test_a_single_step_range_is_one_row() {
let app = v2_service!();
let id = create!(app);
let (status, body) = export!(
app,
id,
"dataset=underlying&format=json&from_step=1&to_step=1"
);
assert_eq!(status, StatusCode::OK);
assert_eq!(json_rows_of(&body).len(), 1);
}
#[actix_web::test]
async fn test_a_reversed_range_is_rejected() {
let app = v2_service!();
let id = create!(app);
let (status, body) = export!(
app,
id,
"dataset=underlying&format=json&from_step=2&to_step=1"
);
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("from_step"), "{body}");
}
#[actix_web::test]
async fn test_a_bound_past_the_tape_is_rejected() {
let app = v2_service!();
let id = create!(app);
let (status, body) = export!(app, id, "dataset=underlying&format=json&to_step=99");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("to_step"), "{body}");
let (status, body) = export!(app, id, "dataset=underlying&format=json&from_step=99");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("from_step"), "{body}");
}
#[actix_web::test]
async fn test_an_unknown_dataset_or_format_is_rejected() {
let app = v2_service!();
let id = create!(app);
let (status, _) = export!(app, id, "dataset=greeks&format=json");
assert_eq!(status, StatusCode::BAD_REQUEST);
let (status, _) = export!(app, id, "dataset=underlying&format=parquet");
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[actix_web::test]
async fn test_an_unknown_simulation_is_not_found() {
let app = v2_service!();
let (status, _) = export!(app, Uuid::new_v4(), "dataset=underlying&format=json");
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[actix_web::test]
async fn test_a_malformed_id_is_rejected() {
let app = v2_service!();
let (status, body) = export!(app, "not-a-uuid", "dataset=underlying&format=json");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("\"field\":\"id\""), "{body}");
}
#[actix_web::test]
async fn test_the_response_carries_its_content_type_and_filename() {
let app = v2_service!();
let id = create!(app);
let response = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri(&format!(
"/api/v2/simulations/{id}/export?dataset=option_chains&format=csv"
))
.to_request(),
)
.await;
let headers = response.headers();
assert_eq!(
headers
.get(actix_web::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("text/csv; charset=utf-8")
);
let disposition = headers
.get(actix_web::http::header::CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(disposition.contains("attachment"), "{disposition}");
assert!(disposition.contains("option_chains"), "{disposition}");
assert!(disposition.ends_with(".csv\""), "{disposition}");
}
#[actix_web::test]
async fn test_a_symbol_cannot_carry_a_csv_separator() {
let app = v2_service!();
for symbol in ["SP,X", "SP\"X", "SP|X", "SP\nX"] {
let mut body = reference_body();
body["symbol"] = json!(symbol);
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/v2/simulations")
.set_json(body)
.to_request(),
)
.await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{symbol:?} must be rejected at the boundary"
);
}
}
#[derive(Default)]
struct FakeWarehouse {
stored: std::sync::Mutex<std::collections::BTreeMap<usize, SnapshotRecord>>,
failing: bool,
reads: std::sync::atomic::AtomicUsize,
}
impl FakeWarehouse {
fn failing() -> Self {
Self {
failing: true,
..Self::default()
}
}
fn reads(&self) -> usize {
self.reads.load(std::sync::atomic::Ordering::SeqCst)
}
fn fill(&self, records: Vec<SnapshotRecord>) {
let mut stored = match self.stored.lock() {
Ok(stored) => stored,
Err(poisoned) => poisoned.into_inner(),
};
for record in records {
stored.insert(record.step, record);
}
}
fn range(&self, from: usize, to: usize) -> Vec<SnapshotRecord> {
match self.stored.lock() {
Ok(stored) => stored
.range(from..=to)
.map(|(_, record)| record.clone())
.collect(),
Err(poisoned) => poisoned
.into_inner()
.range(from..=to)
.map(|(_, record)| record.clone())
.collect(),
}
}
}
#[async_trait::async_trait]
impl SimulationSnapshotRepository for FakeWarehouse {
async fn persist(&self, record: SnapshotRecord) -> Result<(), ChainError> {
match self.stored.lock() {
Ok(mut stored) => {
stored.insert(record.step, record);
}
Err(poisoned) => {
poisoned.into_inner().insert(record.step, record);
}
}
Ok(())
}
async fn get(
&self,
_simulation: Uuid,
_generation: u64,
step: usize,
) -> Result<Option<SnapshotRecord>, ChainError> {
Ok(self.range(step, step).into_iter().next())
}
async fn read_range(
&self,
_simulation: Uuid,
_generation: u64,
from_step: usize,
to_step: usize,
) -> Result<Vec<SnapshotRecord>, ChainError> {
self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if self.failing {
return Err(ChainError::ClickHouseError(
"the warehouse is unreachable".to_string(),
));
}
Ok(self.range(from_step, to_step))
}
async fn contract_series(
&self,
_query: crate::infrastructure::ContractSeriesQuery,
) -> Result<Vec<crate::infrastructure::ContractQuote>, ChainError> {
Ok(Vec::new())
}
}
fn reference_parameters() -> SimulationParametersV2 {
let request: crate::api::rest::requests_v2::CreateSimulationRequest =
match serde_json::from_value(reference_body()) {
Ok(request) => request,
Err(error) => panic!("the reference body must deserialize: {error}"),
};
match SimulationParametersV2::try_from(request) {
Ok(parameters) => parameters,
Err(error) => panic!("the reference body must convert: {error}"),
}
}
fn stored_record(simulation: Uuid, step: usize) -> SnapshotRecord {
let parameters = reference_parameters();
let tape = match FactorTape::build(¶meters, ¶meters.method) {
Ok(tape) => tape,
Err(error) => panic!("the tape must build: {error}"),
};
let builder = match SeriesBuilder::new(¶meters, &tape) {
Ok(builder) => builder,
Err(error) => panic!("the builder must accept the parameters: {error}"),
};
let snapshot = match builder.snapshot(step) {
Ok(snapshot) => snapshot,
Err(error) => panic!("the snapshot must build: {error}"),
};
SnapshotRecord::new(
simulation,
CURRENT_SNAPSHOT_GENERATION,
snapshot.step,
snapshot.simulated_at,
parameters.symbol.clone(),
snapshot.spot,
snapshot.base_volatility,
snapshot
.chains
.iter()
.map(|chain| {
crate::infrastructure::ExpirationRecord::new(
chain.expires_at,
chain.days_to_expiration,
chain.labels.clone(),
chain
.chain
.iter()
.map(|data| QuoteRow {
strike: data.strike_price,
implied_volatility: data.implied_volatility,
call_bid: data.call_bid,
call_ask: data.call_ask,
call_mid: data.call_middle,
put_bid: data.put_bid,
put_ask: data.put_ask,
put_mid: data.put_middle,
delta_call: data.delta_call,
delta_put: data.delta_put,
gamma: data.gamma,
})
.collect(),
)
})
.collect(),
)
}
fn stored_tape(simulation: Uuid) -> Vec<SnapshotRecord> {
(0..3).map(|step| stored_record(simulation, step)).collect()
}
fn parse_id(id: &str) -> Uuid {
match Uuid::parse_str(id) {
Ok(id) => id,
Err(error) => panic!("the created id must be a UUID: {error}"),
}
}
#[actix_web::test]
async fn test_a_persisted_step_renders_exactly_like_a_replayed_one() {
let warehouse = Arc::new(FakeWarehouse::default());
let app = v2_service!(Some(
Arc::clone(&warehouse) as Arc<dyn SimulationSnapshotRepository>
));
let id = create!(app);
let mut replayed = Vec::new();
for format in ["json", "csv"] {
let (status, body) = export!(app, id, format!("dataset=option_chains&format={format}"));
assert_eq!(status, StatusCode::OK, "{format}");
replayed.push(body);
}
warehouse.fill(stored_tape(parse_id(&id)));
for (format, replayed) in ["json", "csv"].iter().zip(replayed) {
let (status, stored) =
export!(app, id, format!("dataset=option_chains&format={format}"));
assert_eq!(status, StatusCode::OK, "{format}");
assert_eq!(
replayed, stored,
"{format}: a persisted step must render identically to a replayed one"
);
}
assert!(
warehouse.reads() >= 4,
"every chains export must have consulted the warehouse"
);
}
#[actix_web::test]
async fn test_the_export_prefers_the_persisted_snapshot() {
let warehouse = Arc::new(FakeWarehouse::default());
let app = v2_service!(Some(
Arc::clone(&warehouse) as Arc<dyn SimulationSnapshotRepository>
));
let id = create!(app);
let (_, replayed) = export!(app, id, "dataset=option_chains&format=json");
assert!(
!replayed.contains("1234.5"),
"the marker must be impossible to reach by replay"
);
let mut records = stored_tape(parse_id(&id));
for record in &mut records {
for expiration in &mut record.expirations {
for quote in &mut expiration.quotes {
quote.call_bid = Some(positive::pos_or_panic!(1_234.5));
}
}
}
warehouse.fill(records);
let (status, stored) = export!(app, id, "dataset=option_chains&format=json");
assert_eq!(status, StatusCode::OK);
let rows = json_rows_of(&stored);
assert!(!rows.is_empty());
for row in &rows {
assert_eq!(
row.get("call_bid"),
Some(&json!(1234.5)),
"every chains row must come from the warehouse: {row}"
);
}
}
#[actix_web::test]
async fn test_a_missing_step_falls_back_to_replay() {
let warehouse = Arc::new(FakeWarehouse::default());
let app = v2_service!(Some(
Arc::clone(&warehouse) as Arc<dyn SimulationSnapshotRepository>
));
let id = create!(app);
let mut replayed = Vec::new();
for format in ["json", "csv"] {
let (_, body) = export!(app, id, format!("dataset=option_chains&format={format}"));
replayed.push(body);
}
warehouse.fill(vec![stored_record(parse_id(&id), 1)]);
for (format, replayed) in ["json", "csv"].iter().zip(replayed) {
let (status, mixed) =
export!(app, id, format!("dataset=option_chains&format={format}"));
assert_eq!(status, StatusCode::OK, "{format}");
assert_eq!(
replayed, mixed,
"{format}: a partially persisted tape must export the whole range"
);
}
}
#[actix_web::test]
async fn test_a_failing_warehouse_does_not_fail_the_export() {
let replaying = v2_service!();
let id = create!(replaying);
let warehouse = Arc::new(FakeWarehouse::failing());
let storing = v2_service!(Some(
Arc::clone(&warehouse) as Arc<dyn SimulationSnapshotRepository>
));
let stored_id = create!(storing);
let (_, replayed) = export!(replaying, id, "dataset=option_chains&format=csv");
let (status, degraded) = export!(storing, stored_id, "dataset=option_chains&format=csv");
assert_eq!(status, StatusCode::OK);
assert_eq!(replayed, degraded, "a failed read must fall back to replay");
assert_eq!(
warehouse.reads(),
1,
"a warehouse that failed once must not be asked again for this export"
);
}
#[actix_web::test]
async fn test_only_the_chains_dataset_reads_the_warehouse() {
let warehouse = Arc::new(FakeWarehouse::default());
let app = v2_service!(Some(
Arc::clone(&warehouse) as Arc<dyn SimulationSnapshotRepository>
));
let id = create!(app);
for dataset in ["underlying", "volatility"] {
let (status, _) = export!(app, id, format!("dataset={dataset}&format=json"));
assert_eq!(status, StatusCode::OK);
}
assert_eq!(
warehouse.reads(),
0,
"the tape-only datasets must not pay for a warehouse lookup"
);
let (status, _) = export!(app, id, "dataset=option_chains&format=json");
assert_eq!(status, StatusCode::OK);
assert_eq!(
warehouse.reads(),
1,
"three steps fit in one window, so one read serves them all"
);
}
#[test]
fn test_a_window_is_bounded_by_its_width_and_by_the_range() {
assert_eq!(window_end(0, SNAPSHOT_WINDOW_STEPS, 1_000), 63);
assert_eq!(window_end(64, SNAPSHOT_WINDOW_STEPS, 1_000), 127);
assert_eq!(
window_end(0, SNAPSHOT_WINDOW_STEPS, 10),
10,
"a short range must not be read past its end"
);
assert_eq!(window_end(7, 1, 1_000), 7, "a narrowed window is one step");
assert_eq!(
window_end(usize::MAX, SNAPSHOT_WINDOW_STEPS, usize::MAX),
usize::MAX,
"the arithmetic must not wrap into a reversed range"
);
}
fn query(from: Option<usize>, to: Option<usize>) -> ExportQuery {
ExportQuery {
dataset: Dataset::Underlying,
format: Format::Json,
from_step: from,
to_step: to,
}
}
#[test]
fn test_omitted_bounds_cover_the_whole_tape() {
match StepRange::resolve(&query(None, None), 10, 1_000) {
Ok(range) => {
assert_eq!(range.from, 0);
assert_eq!(range.to, 9);
assert_eq!(range.steps().count(), 10);
}
Err(error) => panic!("the default range must resolve: {error}"),
}
}
#[test]
fn test_a_range_beyond_the_cap_is_refused() {
match StepRange::resolve(&query(None, None), 10_000, 100) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "to_step");
assert!(reason.contains("the service will export"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_range_exactly_at_the_cap_is_allowed() {
assert!(StepRange::resolve(&query(None, None), 100, 100).is_ok());
}
#[test]
fn test_an_absent_optional_renders_empty() {
assert_eq!(render_optional(None), "");
assert_eq!(render_optional(Some(0.0)), "0");
assert_eq!(render_optional(Some(1.5)), "1.5");
}
#[test]
fn test_every_header_matches_its_row_width() {
for (dataset, width) in [
(Dataset::Underlying, 4),
(Dataset::Volatility, 4),
(Dataset::OptionChains, 17),
] {
assert_eq!(dataset.header().len(), width, "{dataset:?}");
}
}
#[test]
fn test_only_the_chains_dataset_prices_anything() {
assert!(!Dataset::Underlying.needs_chains());
assert!(!Dataset::Volatility.needs_chains());
assert!(Dataset::OptionChains.needs_chains());
}
}