use crate::api::rest::error::map_error;
use crate::api::rest::requests_v2::CreateSimulationRequest;
use crate::api::rest::responses_v2::{SimulationResponse, SnapshotResponse, snapshot_response};
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)]
pub(crate) struct AdvanceQuery {
#[serde(default)]
pub(crate) expected_step: Option<usize>,
}
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.",
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")),
responses(
(status = 200, description = "The snapshot at the current cursor", body = SnapshotResponse),
(status = 400, description = "Malformed id, or the simulation is in a terminal error state"),
(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>,
) -> 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.peek(id).await {
Ok((simulation, snapshot)) => {
HttpResponse::Ok().json(snapshot_response(&simulation, &snapshot))
}
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")
),
responses(
(status = 200, description = "Served the snapshot and advanced once", body = SnapshotResponse),
(status = 400, description = "Malformed id, or the simulation is in a terminal error state"),
(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),
};
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)) => {
HttpResponse::Ok().json(snapshot_response(&simulation, &snapshot))
}
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()
}
#[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}"),
}
}
#[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_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);
}
#[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"), "");
}
}