use crate::api::rest::error::map_error;
use crate::api::rest::greeks::{GreekLevel, render_body};
use crate::api::rest::requests_v2::CreateSimulationRequest;
use crate::api::rest::responses_v2::{SimulationResponse, SnapshotResponse, snapshot_response};
use crate::domain::series::SeriesSnapshot;
use crate::session::{SessionV2, SimulationManager, SimulationParametersV2};
use crate::utils::ChainError;
use actix_web::{HttpRequest, HttpResponse, Responder, web};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::info;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub(crate) struct SimulationPath {
pub(crate) id: String,
}
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct AdvanceQuery {
#[serde(default)]
pub(crate) expected_step: Option<usize>,
#[serde(default)]
pub(crate) greeks: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SnapshotQuery {
#[serde(default)]
pub(crate) greeks: Option<String>,
}
async fn render_snapshot(
simulation: SessionV2,
snapshot: SeriesSnapshot,
level: GreekLevel,
) -> Result<Vec<u8>, ChainError> {
render_body(level, move || {
snapshot_response(&simulation, &snapshot, level)
})
.await
}
#[must_use]
fn json_body(bytes: Vec<u8>) -> HttpResponse {
HttpResponse::Ok()
.content_type("application/json")
.body(bytes)
}
fn parse_id(raw: &str) -> Result<Uuid, ChainError> {
Uuid::parse_str(raw).map_err(|_| ChainError::Validation {
field: "id".to_string(),
reason: format!("must be a UUID, got {raw:?}"),
})
}
#[utoipa::path(
post,
path = "/api/v2/simulations",
description = "Create a deterministic rolling multi-expiration simulation. Resolves the \
effective seed, simulated start and step interval once, and returns them with the \
normalised schedules — together they are everything needed to replay the run. The \
configuration is immutable: changing any of it means creating a new simulation. \
`strike_ladder` decides which strikes the simulation quotes and is the one choice \
worth making deliberately: `rolling`, the default, rebuilds the ladder around the \
underlying at every step, so the quoted strikes stay near the money and a contract \
can leave the chain as the spot moves; `pinned` fixes the ladder at creation from \
`initial_price`, `chain_size` and `strike_interval`, so a contract quoted once is \
quoted for the simulation's whole life, which is what a client holding a position \
across steps needs. A pinned simulation must supply `strike_interval`, because \
without one the interval is derived per expiration and there is no fixed grid to \
pin, and a pinned ladder does not follow a large move: if the spot leaves its range \
every quoted strike ends up on one side of the money rather than the simulation \
inventing new ones, and a spot that drifts further than the service will widen for \
is a 400 naming strike_ladder.",
request_body = CreateSimulationRequest,
responses(
(status = 201, description = "Simulation created", body = SimulationResponse),
(status = 400, description = "Invalid request; body carries `error` and the offending `field`"),
(status = 409, description = "A simulation with the generated id already exists"),
(status = 500, description = "Internal server error")
)
)]
pub(crate) async fn create_simulation(
req: HttpRequest,
manager: web::Data<Arc<SimulationManager>>,
body: web::Json<CreateSimulationRequest>,
) -> impl Responder {
info!("{} {}", req.method(), req.path());
let parameters = match SimulationParametersV2::try_from(body.into_inner()) {
Ok(parameters) => parameters,
Err(error) => return map_error(error),
};
match manager.create(parameters).await {
Ok(simulation) => HttpResponse::Created().json(SimulationResponse::from(&simulation)),
Err(error) => map_error(error),
}
}
#[utoipa::path(
get,
path = "/api/v2/simulations/{id}",
description = "Read a simulation's metadata and effective parameters. Does not build a \
snapshot and does not move the cursor.",
params(("id" = String, Path, description = "The simulation's identifier")),
responses(
(status = 200, description = "The simulation", body = SimulationResponse),
(status = 400, description = "Malformed id"),
(status = 404, description = "Simulation not found"),
(status = 500, description = "Internal server error")
)
)]
pub(crate) async fn get_simulation(
req: HttpRequest,
manager: web::Data<Arc<SimulationManager>>,
path: web::Path<SimulationPath>,
) -> impl Responder {
info!("{} {}", req.method(), req.path());
let id = match parse_id(&path.id) {
Ok(id) => id,
Err(error) => return map_error(error),
};
match manager.get(id).await {
Ok(simulation) => HttpResponse::Ok().json(SimulationResponse::from(&simulation)),
Err(error) => map_error(error),
}
}
#[utoipa::path(
get,
path = "/api/v2/simulations/{id}/snapshot",
description = "Peek the snapshot at the current cursor. Safe and repeatable: it never \
advances the cursor and never persists anything, so calling it twice returns the \
same market. To advance, use POST /api/v2/simulations/{id}/step.",
params(
("id" = String, Path, description = "The simulation's identifier"),
("greeks" = Option<GreekLevel>, Query, description = "How much of the greek set to carry: `none` (default), `first` (adds theta, vega, rho, rho_d) or `all` (the full twelve-value snapshot per style). Every value is per ONE LONG CONTRACT: the client applies position sign and size. The one exception is `alpha`, the ratio gamma/theta, which a short position leaves unchanged and which must NOT be scaled or sign-flipped. An unknown value is a 400")
),
responses(
(status = 200, description = "The snapshot at the current cursor", body = SnapshotResponse),
(status = 400, description = "Malformed id, an unknown `greeks` level, or a simulation in a terminal error state. The malformed-id and unknown-level bodies are the typed `{error, field}` of ValidationErrorResponse, with `field` = `id` or `greeks`; a terminal state carries `error` alone."),
(status = 404, description = "Simulation not found"),
(status = 410, description = "Simulation completed; there is no current step"),
(status = 500, description = "Internal server error")
)
)]
pub(crate) async fn peek_snapshot(
req: HttpRequest,
manager: web::Data<Arc<SimulationManager>>,
path: web::Path<SimulationPath>,
query: web::Query<SnapshotQuery>,
) -> impl Responder {
info!("{} {}", req.method(), req.path());
let id = match parse_id(&path.id) {
Ok(id) => id,
Err(error) => return map_error(error),
};
let level = match GreekLevel::parse(query.greeks.as_deref()) {
Ok(level) => level,
Err(error) => return map_error(error),
};
match manager.peek(id).await {
Ok((simulation, snapshot)) => match render_snapshot(simulation, snapshot, level).await {
Ok(bytes) => json_body(bytes),
Err(error) => map_error(error),
},
Err(error) => map_error(error),
}
}
#[utoipa::path(
post,
path = "/api/v2/simulations/{id}/step",
description = "Serve the snapshot at the current cursor, then advance exactly once. A \
simulation with steps = N serves indices 0..N-1 over N calls; the advance that \
serves the last snapshot marks it completed, and any further call returns 410. \
Pass `expected_step` to make a retry safe: if a previous attempt already consumed \
the step, the call returns 412 with the actual cursor instead of consuming another.",
params(
("id" = String, Path, description = "The simulation's identifier"),
("expected_step" = Option<usize>, Query, description = "Expected current cursor; a mismatch returns 412 without advancing"),
("greeks" = Option<GreekLevel>, Query, description = "How much of the greek set to carry: `none` (default), `first` (adds theta, vega, rho, rho_d) or `all` (the full twelve-value snapshot per style). Every value is per ONE LONG CONTRACT: the client applies position sign and size. The one exception is `alpha`, the ratio gamma/theta, which a short position leaves unchanged and which must NOT be scaled or sign-flipped. An unknown value is a 400")
),
responses(
(status = 200, description = "Served the snapshot and advanced once", body = SnapshotResponse),
(status = 400, description = "Malformed id, an unknown `greeks` level, or a simulation in a terminal error state. The malformed-id and unknown-level bodies are the typed `{error, field}` of ValidationErrorResponse, with `field` = `id` or `greeks`; a terminal state carries `error` alone."),
(status = 404, description = "Simulation not found"),
(status = 409, description = "Another request advanced the simulation first; re-read and retry"),
(status = 410, description = "Simulation completed; no further steps"),
(status = 412, description = "expected_step does not match the cursor; body carries `error` and `current_step`"),
(status = 500, description = "Internal server error")
)
)]
pub(crate) async fn advance_simulation(
req: HttpRequest,
manager: web::Data<Arc<SimulationManager>>,
path: web::Path<SimulationPath>,
query: web::Query<AdvanceQuery>,
) -> impl Responder {
info!("{} {}", req.method(), req.path());
let id = match parse_id(&path.id) {
Ok(id) => id,
Err(error) => return map_error(error),
};
let level = match GreekLevel::parse(query.greeks.as_deref()) {
Ok(level) => level,
Err(error) => return map_error(error),
};
if let Some(expected) = query.expected_step {
match manager.get(id).await {
Ok(simulation) if simulation.current_step != expected => {
return precondition_failed(&simulation);
}
Ok(_) => {}
Err(error) => return map_error(error),
}
}
match manager.advance(id).await {
Ok((simulation, snapshot)) => match render_snapshot(simulation, snapshot, level).await {
Ok(bytes) => json_body(bytes),
Err(error) => map_error(error),
},
Err(error) => map_error(error),
}
}
fn precondition_failed(simulation: &SessionV2) -> HttpResponse {
HttpResponse::PreconditionFailed().json(serde_json::json!({
"error": "expected_step does not match the simulation's current cursor",
"current_step": simulation.current_step,
}))
}
#[utoipa::path(
delete,
path = "/api/v2/simulations/{id}",
description = "Delete a simulation and evict everything cached for it.",
params(("id" = String, Path, description = "The simulation's identifier")),
responses(
(status = 200, description = "Deleted", body = Object),
(status = 400, description = "Malformed id"),
(status = 404, description = "Simulation not found"),
(status = 500, description = "Internal server error")
)
)]
pub(crate) async fn delete_simulation(
req: HttpRequest,
manager: web::Data<Arc<SimulationManager>>,
path: web::Path<SimulationPath>,
) -> impl Responder {
info!("{} {}", req.method(), req.path());
let id = match parse_id(&path.id) {
Ok(id) => id,
Err(error) => return map_error(error),
};
match manager.delete(id).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({
"message": format!("Simulation deleted successfully: {id}"),
"simulation_id": id.to_string(),
})),
Ok(false) => map_error(ChainError::NotFound(format!(
"Simulation with id {id} not found"
))),
Err(error) => map_error(error),
}
}
pub(crate) fn json_error_handler(
error: actix_web::error::JsonPayloadError,
_req: &HttpRequest,
) -> actix_web::Error {
let message = error.to_string();
let response =
HttpResponse::BadRequest().json(crate::api::rest::responses::ValidationErrorResponse {
error: message.clone(),
field: field_from_serde_message(&message),
});
actix_web::error::InternalError::from_response(error, response).into()
}
pub(crate) fn query_error_handler(
error: actix_web::error::QueryPayloadError,
_req: &HttpRequest,
) -> actix_web::Error {
let message = error.to_string();
let response =
HttpResponse::BadRequest().json(crate::api::rest::responses::ValidationErrorResponse {
error: message.clone(),
field: field_from_serde_message(&message),
});
actix_web::error::InternalError::from_response(error, response).into()
}
#[must_use]
fn field_from_serde_message(message: &str) -> String {
if let Some(rest) = message.split("unknown field `").nth(1)
&& let Some(field) = rest.split('`').next()
{
return field.to_string();
}
if let Some(rest) = message.split("Validation Error: ").nth(1)
&& let Some(field) = rest.split(':').next()
{
return field.trim().to_string();
}
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::rest::routes::configure_v2_routes;
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": 4,
"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"] },
{ "rule_id": "monthlies", "kind": "monthly", "target_count": 12,
"weekday": "Fri" }
],
"initial_price": 5000.0,
"volatility": 0.18,
"risk_free_rate": 0.04,
"dividend_yield": 0.012,
"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 {
() => {{
let manager = Arc::new(crate::session::SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
crate::infrastructure::SimulationV2Config::default(),
));
actix_test::init_service(
App::new().configure(|cfg| configure_v2_routes(cfg, manager.clone(), None)),
)
.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;
body
}};
}
fn id_of(body: &Value) -> String {
match body.get("id").and_then(Value::as_str) {
Some(id) => id.to_string(),
None => panic!("the response must carry an id: {body}"),
}
}
fn quotes(body: &Value) -> Vec<&Value> {
let chains = match body.get("chains").and_then(Value::as_array) {
Some(chains) => chains,
None => panic!("the snapshot must carry chains: {body}"),
};
let mut quotes = Vec::new();
for chain in chains {
let contracts = match chain.get("contracts").and_then(Value::as_array) {
Some(contracts) => contracts,
None => panic!("every chain must carry contracts: {chain}"),
};
for contract in contracts {
for side in ["call", "put"] {
match contract.get(side) {
Some(quote) => quotes.push(quote),
None => panic!("every contract must carry a {side}: {contract}"),
}
}
}
}
assert!(!quotes.is_empty(), "the snapshot must quote something");
quotes
}
macro_rules! snapshot {
($app:expr, $id:expr, $query:expr) => {{
let uri = format!("/api/v2/simulations/{}/snapshot{}", $id, $query);
let request = actix_test::TestRequest::get().uri(&uri).to_request();
let response = actix_test::call_service(&$app, request).await;
assert_eq!(response.status(), StatusCode::OK);
let body: Value = actix_test::read_body_json(response).await;
body
}};
}
#[actix_web::test]
async fn test_the_default_snapshot_is_identical_to_greeks_none() {
let app = v2_service!();
let id = id_of(&create!(app));
let default = snapshot!(app, id, "");
let explicit = snapshot!(app, id, "?greeks=none");
assert_eq!(default, explicit);
for quote in quotes(&default) {
assert!(
quote.get("greeks").is_none(),
"the default quote must carry no greeks key: {quote}"
);
}
}
#[actix_web::test]
async fn test_greeks_all_carries_the_twelve_values_per_style() {
const EXPECTED: [&str; 12] = [
"delta", "gamma", "theta", "vega", "rho", "rho_d", "alpha", "vanna", "vomma", "veta",
"charm", "color",
];
let app = v2_service!();
let id = id_of(&create!(app));
let body = snapshot!(app, id, "?greeks=all");
for quote in quotes(&body) {
let greeks = match quote.get("greeks").and_then(Value::as_object) {
Some(greeks) => greeks,
None => panic!("greeks=all must carry a greeks object: {quote}"),
};
for key in EXPECTED {
assert!(
greeks.contains_key(key),
"greeks=all must carry {key}: {quote}"
);
}
assert_eq!(
greeks.len(),
EXPECTED.len(),
"greeks=all must carry exactly the twelve values: {quote}"
);
}
}
#[actix_web::test]
async fn test_greeks_first_carries_only_the_remaining_first_order_set() {
let app = v2_service!();
let id = id_of(&create!(app));
let body = snapshot!(app, id, "?greeks=first");
for quote in quotes(&body) {
let greeks = match quote.get("greeks").and_then(Value::as_object) {
Some(greeks) => greeks,
None => panic!("greeks=first must carry a greeks object: {quote}"),
};
let mut keys: Vec<&str> = greeks.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, vec!["rho", "rho_d", "theta", "vega"], "{quote}");
assert!(
quote.get("delta").is_some(),
"delta stays on the quote itself: {quote}"
);
}
}
#[actix_web::test]
async fn test_the_call_and_put_greeks_are_genuinely_different() {
let app = v2_service!();
let id = id_of(&create!(app));
let body = snapshot!(app, id, "?greeks=all");
let chains = match body.get("chains").and_then(Value::as_array) {
Some(chains) => chains,
None => panic!("the snapshot must carry chains: {body}"),
};
let contracts = match chains
.first()
.and_then(|chain| chain.get("contracts"))
.and_then(Value::as_array)
{
Some(contracts) => contracts,
None => panic!("the first chain must carry contracts: {body}"),
};
let greek = |contract: &Value, side: &str, name: &str| -> f64 {
let raw = contract
.get(side)
.and_then(|quote| quote.get("greeks"))
.and_then(|greeks| greeks.get(name))
.and_then(Value::as_f64);
match raw {
Some(value) => value,
None => panic!("{side}.{name} must be present: {contract}"),
}
};
for contract in contracts {
assert_ne!(
greek(contract, "call", "charm"),
greek(contract, "put", "charm"),
"charm must differ between the styles: {contract}"
);
let call_rho = greek(contract, "call", "rho");
let put_rho = greek(contract, "put", "rho");
assert!(
call_rho * put_rho < 0.0,
"rho must carry opposite signs, got {call_rho} and {put_rho}"
);
}
}
#[actix_web::test]
async fn test_an_unknown_greek_level_is_a_typed_400() {
let app = v2_service!();
let id = id_of(&create!(app));
for uri in [
format!("/api/v2/simulations/{id}/snapshot?greeks=second"),
format!("/api/v2/simulations/{id}/snapshot?greeks=ALL"),
] {
let request = actix_test::TestRequest::get().uri(&uri).to_request();
let response = actix_test::call_service(&app, request).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "for {uri}");
let body: Value = actix_test::read_body_json(response).await;
assert_eq!(
body.get("field").and_then(Value::as_str),
Some("greeks"),
"the 400 must name the field: {body}"
);
}
}
#[actix_web::test]
async fn test_a_misspelled_query_key_is_a_typed_400() {
let app = v2_service!();
let id = id_of(&create!(app));
for key in ["greek", "Greeks", "greeks_level"] {
let uri = format!("/api/v2/simulations/{id}/snapshot?{key}=all");
let request = actix_test::TestRequest::get().uri(&uri).to_request();
let response = actix_test::call_service(&app, request).await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"?{key}=all must be rejected, not ignored"
);
let body: Value = actix_test::read_body_json(response).await;
assert_eq!(
body.get("field").and_then(Value::as_str),
Some(key),
"the 400 must name the offending key: {body}"
);
}
}
#[actix_web::test]
async fn test_an_unknown_greek_level_on_step_does_not_advance() {
let app = v2_service!();
let id = id_of(&create!(app));
let request = actix_test::TestRequest::post()
.uri(&format!("/api/v2/simulations/{id}/step?greeks=second"))
.to_request();
let response = actix_test::call_service(&app, request).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let request = actix_test::TestRequest::get()
.uri(&format!("/api/v2/simulations/{id}"))
.to_request();
let response = actix_test::call_service(&app, request).await;
assert_eq!(response.status(), StatusCode::OK);
let body: Value = actix_test::read_body_json(response).await;
assert_eq!(
body.get("cursor")
.and_then(|cursor| cursor.get("current_step"))
.and_then(Value::as_u64),
Some(0),
"a rejected level must not consume a step: {body}"
);
}
#[actix_web::test]
async fn test_the_step_endpoint_serves_the_requested_greeks() {
let app = v2_service!();
let id = id_of(&create!(app));
let request = actix_test::TestRequest::post()
.uri(&format!("/api/v2/simulations/{id}/step?greeks=all"))
.to_request();
let response = actix_test::call_service(&app, request).await;
assert_eq!(response.status(), StatusCode::OK);
let body: Value = actix_test::read_body_json(response).await;
for quote in quotes(&body) {
assert!(
quote
.get("greeks")
.and_then(|greeks| greeks.get("vomma"))
.is_some(),
"a stepped snapshot must carry the full set: {quote}"
);
}
}
#[actix_web::test]
async fn test_the_greek_level_does_not_move_the_quoted_market() {
let app = v2_service!();
let id = id_of(&create!(app));
let strip = |body: &Value| -> Value {
let mut stripped = body.clone();
if let Some(chains) = stripped.get_mut("chains").and_then(Value::as_array_mut) {
for chain in chains {
if let Some(contracts) =
chain.get_mut("contracts").and_then(Value::as_array_mut)
{
for contract in contracts {
for side in ["call", "put"] {
if let Some(quote) =
contract.get_mut(side).and_then(Value::as_object_mut)
{
quote.remove("greeks");
}
}
}
}
}
}
stripped
};
let none = snapshot!(app, id, "?greeks=none");
let first = snapshot!(app, id, "?greeks=first");
let all = snapshot!(app, id, "?greeks=all");
assert_eq!(strip(&first), none);
assert_eq!(strip(&all), none);
}
#[derive(Default)]
struct AcceptingWarehouse;
#[async_trait::async_trait]
impl crate::infrastructure::SimulationSnapshotRepository for AcceptingWarehouse {
async fn ping(&self) -> Result<(), ChainError> {
Ok(())
}
async fn persist(
&self,
_record: crate::infrastructure::SnapshotRecord,
) -> Result<(), ChainError> {
Ok(())
}
async fn get(
&self,
_simulation: Uuid,
_generation: u64,
_step: usize,
) -> Result<Option<crate::infrastructure::SnapshotRecord>, ChainError> {
Ok(None)
}
async fn read_range(
&self,
_simulation: Uuid,
_generation: u64,
_from_step: usize,
_to_step: usize,
) -> Result<Vec<crate::infrastructure::SnapshotRecord>, ChainError> {
Ok(Vec::new())
}
async fn contract_series(
&self,
_query: crate::infrastructure::ContractSeriesQuery,
) -> Result<Vec<crate::infrastructure::ContractQuote>, ChainError> {
Ok(Vec::new())
}
}
#[actix_web::test]
async fn test_the_greeks_are_the_same_whether_read_or_priced() {
let read_service = {
let manager = Arc::new(
crate::session::SimulationManager::new(
Arc::new(InMemorySimulationStore::new()),
crate::infrastructure::SimulationV2Config::default(),
)
.with_warehouse(Arc::new(AcceptingWarehouse)
as Arc<dyn crate::infrastructure::SimulationSnapshotRepository>),
);
actix_test::init_service(
App::new().configure(|cfg| configure_v2_routes(cfg, manager.clone(), None)),
)
.await
};
let priced_service = v2_service!();
let read_id = id_of(&create!(read_service));
let priced_id = id_of(&create!(priced_service));
let mut read = snapshot!(read_service, read_id, "?greeks=all");
let mut priced = snapshot!(priced_service, priced_id, "?greeks=all");
for body in [&mut read, &mut priced] {
if let Some(object) = body.as_object_mut() {
object.remove("id");
}
}
assert_eq!(read, priced);
assert!(
quotes(&read)
.iter()
.all(|quote| quote.get("greeks").is_some()),
"both bodies must actually carry greeks"
);
}
#[actix_web::test]
async fn test_create_returns_the_replay_inputs() {
let app = v2_service!();
let body = create!(app);
let parameters = match body.get("parameters") {
Some(parameters) => parameters,
None => panic!("the response must echo its parameters: {body}"),
};
for field in [
"seed",
"effective_start",
"step_interval_seconds",
"time_frame",
"timezone",
"calendar",
"tzdb_version",
"expiration_time",
"schedules",
] {
assert!(
parameters.get(field).is_some(),
"the echo must carry {field}: {parameters}"
);
}
assert_eq!(parameters.get("seed"), Some(&json!(42)));
assert_eq!(body.get("state"), Some(&json!("initialized")));
assert_eq!(
body.get("cursor").and_then(|c| c.get("current_step")),
Some(&json!(0))
);
}
#[actix_web::test]
async fn test_the_spread_model_is_accepted_and_echoed() {
let app = v2_service!();
let mut request_body = reference_body();
match request_body.as_object_mut() {
Some(map) => {
map.insert("spread_proportional".to_string(), json!(0.02));
map.insert("spread_moneyness_widening".to_string(), json!(0.5));
map.insert("spread_tenor_widening".to_string(), json!(0.1));
map.insert("spread_tick".to_string(), json!(0.05));
}
None => panic!("the reference body must be an object"),
}
let request = actix_test::TestRequest::post()
.uri("/api/v2/simulations")
.set_json(request_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;
let parameters = match body.get("parameters") {
Some(parameters) => parameters,
None => panic!("the response must echo its parameters: {body}"),
};
for (field, expected) in [
("spread", json!(0.02)),
("spread_proportional", json!(0.02)),
("spread_moneyness_widening", json!(0.5)),
("spread_tenor_widening", json!(0.1)),
("spread_tick", json!(0.05)),
] {
assert_eq!(
parameters.get(field),
Some(&expected),
"the echo must carry {field}: {parameters}"
);
}
}
#[actix_web::test]
async fn test_the_strike_ladder_is_accepted_and_echoed() {
let app = v2_service!();
let mut request_body = reference_body();
match request_body.as_object_mut() {
Some(map) => {
map.insert("strike_ladder".to_string(), json!("pinned"));
map.insert("strike_interval".to_string(), json!(25.0));
map.insert("chain_size".to_string(), json!(3));
}
None => panic!("the reference body must be an object"),
}
let request = actix_test::TestRequest::post()
.uri("/api/v2/simulations")
.set_json(request_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;
assert_eq!(
body.get("parameters").and_then(|p| p.get("strike_ladder")),
Some(&json!("pinned")),
"the echo must carry the ladder: {body}"
);
}
#[actix_web::test]
async fn test_the_strike_ladder_defaults_to_rolling() {
let app = v2_service!();
let body = create!(app);
assert_eq!(
body.get("parameters").and_then(|p| p.get("strike_ladder")),
Some(&json!("rolling")),
"an untouched request must read as rolling: {body}"
);
}
#[actix_web::test]
async fn test_a_pinned_ladder_without_an_interval_is_a_typed_400() {
let app = v2_service!();
let mut request_body = reference_body();
match request_body.as_object_mut() {
Some(map) => {
map.insert("strike_ladder".to_string(), json!("pinned"));
map.remove("strike_interval");
}
None => panic!("the reference body must be an object"),
}
let request = actix_test::TestRequest::post()
.uri("/api/v2/simulations")
.set_json(request_body)
.to_request();
let response = actix_test::call_service(&app, request).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: Value = actix_test::read_body_json(response).await;
assert_eq!(
body.get("field"),
Some(&json!("strike_interval")),
"the rejection must name the field: {body}"
);
}
#[actix_web::test]
async fn test_a_pinned_ladder_wider_than_the_spot_is_refused() {
let app = v2_service!();
let mut request_body = reference_body();
match request_body.as_object_mut() {
Some(map) => {
map.insert("strike_ladder".to_string(), json!("pinned"));
map.insert("initial_price".to_string(), json!(100.0));
map.insert("strike_interval".to_string(), json!(5.0));
map.insert("chain_size".to_string(), json!(25));
}
None => panic!("the reference body must be an object"),
}
let request = actix_test::TestRequest::post()
.uri("/api/v2/simulations")
.set_json(request_body)
.to_request();
let response = actix_test::call_service(&app, request).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: Value = actix_test::read_body_json(response).await;
assert_eq!(
body.get("field"),
Some(&json!("chain_size")),
"the rejection must name what to lower: {body}"
);
}
#[actix_web::test]
async fn test_an_out_of_range_spread_coefficient_is_a_typed_400() {
let app = v2_service!();
let mut request_body = reference_body();
match request_body.as_object_mut() {
Some(map) => map.insert("spread_proportional".to_string(), json!(-0.01)),
None => panic!("the reference body must be an object"),
};
let request = actix_test::TestRequest::post()
.uri("/api/v2/simulations")
.set_json(request_body)
.to_request();
let response = actix_test::call_service(&app, request).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: Value = actix_test::read_body_json(response).await;
assert_eq!(
body.get("field"),
Some(&json!("spread_proportional")),
"the rejection must name the field: {body}"
);
}
#[actix_web::test]
async fn test_the_echoed_schedules_are_normalised() {
let app = v2_service!();
let body = create!(app);
let ids: Vec<String> = match body
.get("parameters")
.and_then(|p| p.get("schedules"))
.and_then(Value::as_array)
{
Some(rules) => rules
.iter()
.filter_map(|rule| rule.get("rule_id").and_then(Value::as_str))
.map(ToString::to_string)
.collect(),
None => panic!("the echo must carry schedules: {body}"),
};
assert_eq!(ids, vec!["monthlies", "weeklies", "zero_dte"]);
}
#[actix_web::test]
async fn test_a_peek_is_byte_stable_and_does_not_advance() {
let app = v2_service!();
let id = id_of(&create!(app));
let uri = format!("/api/v2/simulations/{id}/snapshot");
let first: Value = {
let response = actix_test::call_service(
&app,
actix_test::TestRequest::get().uri(&uri).to_request(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
actix_test::read_body_json(response).await
};
let second: Value = {
let response = actix_test::call_service(
&app,
actix_test::TestRequest::get().uri(&uri).to_request(),
)
.await;
actix_test::read_body_json(response).await
};
assert_eq!(first, second, "a peek must be repeatable");
assert_eq!(
first.get("cursor").and_then(|c| c.get("current_step")),
Some(&json!(0)),
"a peek must not advance"
);
}
#[actix_web::test]
async fn test_the_snapshot_carries_the_documented_shape() {
let app = v2_service!();
let id = id_of(&create!(app));
let response = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri(&format!("/api/v2/simulations/{id}/snapshot"))
.to_request(),
)
.await;
let body: Value = actix_test::read_body_json(response).await;
assert_eq!(
body.get("simulated_at"),
Some(&json!("2026-01-05T14:30:00Z"))
);
let underlying = match body.get("underlying") {
Some(underlying) => underlying,
None => panic!("the snapshot must carry the underlying: {body}"),
};
assert_eq!(underlying.get("symbol"), Some(&json!("SPX")));
assert!(underlying.get("price").is_some());
assert!(underlying.get("base_volatility").is_some());
let chains = match body.get("chains").and_then(Value::as_array) {
Some(chains) => chains,
None => panic!("the snapshot must carry chains: {body}"),
};
assert_eq!(chains.len(), 15);
let first = match chains.first() {
Some(first) => first,
None => panic!("the snapshot must carry chains"),
};
assert_eq!(
first.get("expires_at"),
Some(&json!("2026-01-05T22:00:00Z"))
);
assert_eq!(
first.get("labels"),
Some(&json!(["weeklies", "zero_dte"])),
"a coincident expiration carries every matching label"
);
let contracts = match first.get("contracts").and_then(Value::as_array) {
Some(contracts) => contracts,
None => panic!("a chain must carry contracts: {first}"),
};
let contract = match contracts.first() {
Some(contract) => contract,
None => panic!("a chain must carry contracts"),
};
for field in ["strike", "implied_volatility", "call", "put"] {
assert!(
contract.get(field).is_some(),
"a contract must carry {field}: {contract}"
);
}
}
#[actix_web::test]
async fn test_an_advance_serves_then_advances() {
let app = v2_service!();
let id = id_of(&create!(app));
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 body: Value = actix_test::read_body_json(response).await;
assert_eq!(
body.get("cursor").and_then(|c| c.get("current_step")),
Some(&json!(1)),
"the response reports the cursor after the advance"
);
assert_eq!(
body.get("simulated_at"),
Some(&json!("2026-01-05T14:30:00Z")),
"the snapshot served is the one at the pre-advance cursor"
);
}
#[actix_web::test]
async fn test_the_expected_step_precondition_protects_a_retry() {
let app = v2_service!();
let id = id_of(&create!(app));
let step_uri =
|expected: usize| format!("/api/v2/simulations/{id}/step?expected_step={expected}");
let ok = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri(&step_uri(0))
.to_request(),
)
.await;
assert_eq!(ok.status(), StatusCode::OK);
let stale = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri(&step_uri(0))
.to_request(),
)
.await;
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
let body: Value = actix_test::read_body_json(stale).await;
assert_eq!(body.get("current_step"), Some(&json!(1)));
assert!(body.get("error").is_some());
let response = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri(&format!("/api/v2/simulations/{id}"))
.to_request(),
)
.await;
let simulation: Value = actix_test::read_body_json(response).await;
assert_eq!(
simulation.get("cursor").and_then(|c| c.get("current_step")),
Some(&json!(1))
);
}
#[actix_web::test]
async fn test_an_exhausted_simulation_is_gone() {
let app = v2_service!();
let id = id_of(&create!(app));
let uri = format!("/api/v2/simulations/{id}/step");
for _ in 0..4 {
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post().uri(&uri).to_request(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
let exhausted =
actix_test::call_service(&app, actix_test::TestRequest::post().uri(&uri).to_request())
.await;
assert_eq!(exhausted.status(), StatusCode::GONE);
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);
}
#[actix_web::test]
async fn test_an_unknown_id_is_not_found() {
let app = v2_service!();
let missing = Uuid::new_v4();
for (method, uri) in [
("GET", format!("/api/v2/simulations/{missing}")),
("GET", format!("/api/v2/simulations/{missing}/snapshot")),
("DELETE", format!("/api/v2/simulations/{missing}")),
] {
let request = match method {
"GET" => actix_test::TestRequest::get().uri(&uri),
_ => actix_test::TestRequest::delete().uri(&uri),
};
let response = actix_test::call_service(&app, request.to_request()).await;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"{method} {uri} must be 404"
);
}
}
#[actix_web::test]
async fn test_a_malformed_id_is_a_bad_request() {
let app = v2_service!();
let response = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri("/api/v2/simulations/not-a-uuid")
.to_request(),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: Value = actix_test::read_body_json(response).await;
assert_eq!(body.get("field"), Some(&json!("id")));
}
#[actix_web::test]
async fn test_an_invalid_field_is_reported_by_name() {
let app = v2_service!();
let mut body = reference_body();
body["timezone"] = json!("Mars/Olympus");
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);
let error: Value = actix_test::read_body_json(response).await;
assert_eq!(error.get("field"), Some(&json!("timezone")));
}
#[actix_web::test]
async fn test_an_unknown_field_is_reported_in_the_documented_shape() {
let app = v2_service!();
let mut body = reference_body();
body["days_to_expiration"] = json!(30.0);
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);
let error: Value = actix_test::read_body_json(response).await;
assert_eq!(error.get("field"), Some(&json!("days_to_expiration")));
}
#[actix_web::test]
async fn test_a_rule_level_failure_keeps_its_field() {
let app = v2_service!();
let mut body = reference_body();
body["schedules"] = json!([
{ "rule_id": "zero_dte", "kind": "daily", "target_count": 1, "weekday": "Fri" }
]);
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);
let error: Value = actix_test::read_body_json(response).await;
let field = match error.get("field").and_then(Value::as_str) {
Some(field) => field,
None => panic!("the error must name a field: {error}"),
};
assert!(field.contains("weekday"), "got {field}");
}
#[actix_web::test]
async fn test_delete_then_get_is_not_found() {
let app = v2_service!();
let id = id_of(&create!(app));
let uri = format!("/api/v2/simulations/{id}");
let deleted = actix_test::call_service(
&app,
actix_test::TestRequest::delete().uri(&uri).to_request(),
)
.await;
assert_eq!(deleted.status(), StatusCode::OK);
let body: Value = actix_test::read_body_json(deleted).await;
assert_eq!(body.get("simulation_id"), Some(&json!(id)));
let gone =
actix_test::call_service(&app, actix_test::TestRequest::get().uri(&uri).to_request())
.await;
assert_eq!(gone.status(), StatusCode::NOT_FOUND);
}
#[actix_web::test]
async fn test_the_same_seed_serves_the_same_snapshot() {
let app = v2_service!();
let first = id_of(&create!(app));
let second = id_of(&create!(app));
assert_ne!(first, second, "two creations must be distinct simulations");
let snapshot_of = async |id: &str| -> Value {
let response = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri(&format!("/api/v2/simulations/{id}/snapshot"))
.to_request(),
)
.await;
let mut body: Value = actix_test::read_body_json(response).await;
if let Some(object) = body.as_object_mut() {
object.remove("id");
}
body
};
assert_eq!(snapshot_of(&first).await, snapshot_of(&second).await);
}
#[actix_web::test]
async fn test_the_same_seed_serves_the_same_greeks() {
let app = v2_service!();
let first = id_of(&create!(app));
let second = id_of(&create!(app));
assert_ne!(first, second, "two creations must be distinct simulations");
let mut left = snapshot!(app, first, "?greeks=all");
let mut right = snapshot!(app, second, "?greeks=all");
for body in [&mut left, &mut right] {
if let Some(object) = body.as_object_mut() {
object.remove("id");
}
}
assert_eq!(left, right);
}
#[actix_web::test]
async fn test_the_style_independent_greeks_agree_across_the_two_sides() {
const SHARED: [&str; 6] = ["gamma", "vega", "vanna", "vomma", "veta", "color"];
const PER_STYLE: [&str; 4] = ["delta", "theta", "rho", "rho_d"];
let app = v2_service!();
let id = id_of(&create!(app));
let body = snapshot!(app, id, "?greeks=all");
let chains = match body.get("chains").and_then(Value::as_array) {
Some(chains) => chains,
None => panic!("the snapshot must carry chains: {body}"),
};
for chain in chains {
let contracts = match chain.get("contracts").and_then(Value::as_array) {
Some(contracts) => contracts,
None => panic!("every chain must carry contracts: {chain}"),
};
for contract in contracts {
let side = |name: &str, greek: &str| -> Value {
contract
.get(name)
.and_then(|quote| quote.get("greeks"))
.and_then(|greeks| greeks.get(greek))
.cloned()
.unwrap_or(Value::Null)
};
for greek in SHARED {
assert_eq!(
side("call", greek),
side("put", greek),
"{greek} does not depend on the style: {contract}"
);
}
for greek in PER_STYLE {
assert_ne!(
side("call", greek),
side("put", greek),
"{greek} depends on the style: {contract}"
);
}
}
}
}
#[actix_web::test]
async fn test_the_snapshot_agrees_with_the_mirrors_it_duplicates() {
const TOLERANCE: f64 = 1e-12;
let app = v2_service!();
let id = id_of(&create!(app));
let body = snapshot!(app, id, "?greeks=all");
let parse = |value: Option<&Value>, what: &str| -> f64 {
match value.and_then(Value::as_f64) {
Some(number) => number,
None => panic!("{what} must be present"),
}
};
let chains = match body.get("chains").and_then(Value::as_array) {
Some(chains) => chains,
None => panic!("the snapshot must carry chains: {body}"),
};
for chain in chains {
let contracts = match chain.get("contracts").and_then(Value::as_array) {
Some(contracts) => contracts,
None => panic!("every chain must carry contracts: {chain}"),
};
for contract in contracts {
let mirror_gamma = match contract.get("gamma").and_then(Value::as_f64) {
Some(gamma) => gamma,
None => continue,
};
for side in ["call", "put"] {
let quote = match contract.get(side) {
Some(quote) => quote,
None => panic!("every contract must carry a {side}: {contract}"),
};
let greeks = match quote.get("greeks") {
Some(greeks) => greeks,
None => panic!("greeks=all must carry a greeks object: {quote}"),
};
let snapshot_gamma = parse(greeks.get("gamma"), "the snapshot gamma");
assert!(
(snapshot_gamma - mirror_gamma).abs() < TOLERANCE,
"gamma disagrees with its mirror: {snapshot_gamma} vs {mirror_gamma}"
);
if let Some(mirror_delta) = quote.get("delta").and_then(Value::as_f64) {
let snapshot_delta = parse(greeks.get("delta"), "the snapshot delta");
assert!(
(snapshot_delta - mirror_delta).abs() < TOLERANCE,
"{side} delta disagrees with its mirror: \
{snapshot_delta} vs {mirror_delta}"
);
}
}
}
}
}
#[test]
fn test_a_malformed_id_names_the_field() {
match parse_id("not-a-uuid") {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "id");
assert!(reason.contains("UUID"));
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_well_formed_id_parses() {
assert!(parse_id("6af613b6-569c-5c22-9c37-2ed93f31d3af").is_ok());
}
#[test]
fn test_an_unknown_field_is_recovered_from_the_serde_message() {
let message = "unknown field `days_to_expiration`, expected one of `symbol`, `steps`";
assert_eq!(field_from_serde_message(message), "days_to_expiration");
}
#[test]
fn test_a_rule_validation_failure_is_recovered_from_the_serde_message() {
let message = "Validation Error: schedules.zero_dte.weekdays: does not belong to this rule kind at line 3 column 5";
assert_eq!(
field_from_serde_message(message),
"schedules.zero_dte.weekdays"
);
}
#[test]
fn test_an_unrecognised_message_reports_no_field() {
assert_eq!(field_from_serde_message("EOF while parsing a value"), "");
}
}