use crate::api::rest::models::{ApiTimeFrame, ApiWalkType};
use crate::session::ExpiryRule;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CreateSimulationRequest {
pub symbol: String,
pub steps: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<String>, format = DateTime)]
pub start_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step_interval_seconds: Option<u64>,
pub timezone: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub calendar: Option<String>,
pub expiration_time: String,
#[schema(value_type = Vec<Object>)]
pub schedules: Vec<ExpiryRule>,
pub initial_price: f64,
pub volatility: f64,
pub risk_free_rate: f64,
pub dividend_yield: f64,
pub method: ApiWalkType,
pub time_frame: ApiTimeFrame,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chain_size: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strike_interval: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skew_slope: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smile_curve: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spread: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
}
impl fmt::Display for CreateSimulationRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let json = serde_json::to_string(self).map_err(|_| fmt::Error)?;
write!(f, "{json}")
}
}
#[cfg(test)]
mod tests {
use super::*;
const REFERENCE_REQUEST: &str = r#"{
"symbol": "SPX",
"steps": 500,
"start_at": "2026-01-05T14:30:00Z",
"step_interval_seconds": 86400,
"timezone": "America/New_York",
"calendar": "weekdays_v1",
"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": { "GeometricBrownian": { "dt": 0.004, "drift": 0.05, "volatility": 0.18 } },
"time_frame": "Day",
"chain_size": 15,
"strike_interval": 25.0,
"skew_slope": -0.2,
"smile_curve": 0.4,
"spread": 0.02,
"seed": 42
}"#;
#[test]
fn test_reference_request_deserializes() {
match serde_json::from_str::<CreateSimulationRequest>(REFERENCE_REQUEST) {
Ok(request) => {
assert_eq!(request.symbol, "SPX");
assert_eq!(request.steps, 500);
assert_eq!(request.step_interval_seconds, Some(86_400));
assert_eq!(request.schedules.len(), 3);
assert_eq!(request.seed, Some(42));
}
Err(error) => panic!("the reference request must deserialize: {error}"),
}
}
#[test]
fn test_optional_fields_may_be_omitted() {
let json = r#"{
"symbol": "SPX",
"steps": 10,
"timezone": "America/New_York",
"expiration_time": "17:00",
"schedules": [ { "rule_id": "zero_dte", "kind": "daily", "target_count": 1 } ],
"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"
}"#;
match serde_json::from_str::<CreateSimulationRequest>(json) {
Ok(request) => {
assert!(request.start_at.is_none());
assert!(request.step_interval_seconds.is_none());
assert!(request.calendar.is_none());
assert!(request.seed.is_none());
}
Err(error) => panic!("must deserialize without optional fields: {error}"),
}
}
#[test]
fn test_unknown_field_is_rejected() {
let json = r#"{
"symbol": "SPX",
"steps": 10,
"timezone": "America/New_York",
"expiration_time": "17:00",
"schedules": [ { "rule_id": "zero_dte", "kind": "daily", "target_count": 1 } ],
"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",
"days_to_expiration": 30.0
}"#;
let error = match serde_json::from_str::<CreateSimulationRequest>(json) {
Ok(_) => panic!("an unknown field must be rejected"),
Err(error) => error.to_string(),
};
assert!(
error.contains("days_to_expiration"),
"the error must name the offending field, got {error}"
);
}
#[test]
fn test_unknown_field_inside_a_rule_is_rejected() {
let json = r#"{
"symbol": "SPX",
"steps": 10,
"timezone": "America/New_York",
"expiration_time": "17:00",
"schedules": [
{ "rule_id": "zero_dte", "kind": "daily", "target_count": 1, "typo": 1 }
],
"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"
}"#;
assert!(serde_json::from_str::<CreateSimulationRequest>(json).is_err());
}
#[test]
fn test_field_not_valid_for_the_rule_kind_is_rejected() {
let json = r#"{
"symbol": "SPX",
"steps": 10,
"timezone": "America/New_York",
"expiration_time": "17:00",
"schedules": [
{ "rule_id": "zero_dte", "kind": "daily", "target_count": 1,
"weekdays": ["Mon"] }
],
"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"
}"#;
let error = match serde_json::from_str::<CreateSimulationRequest>(json) {
Ok(_) => panic!("weekdays on a daily rule must be rejected"),
Err(error) => error.to_string(),
};
assert!(
error.contains("weekdays"),
"the error must name the offending field, got {error}"
);
}
#[test]
fn test_request_round_trips_through_json() {
let request = match serde_json::from_str::<CreateSimulationRequest>(REFERENCE_REQUEST) {
Ok(request) => request,
Err(error) => panic!("must deserialize: {error}"),
};
let json = request.to_string();
match serde_json::from_str::<CreateSimulationRequest>(&json) {
Ok(round_tripped) => {
assert_eq!(round_tripped.symbol, request.symbol);
assert_eq!(round_tripped.schedules, request.schedules);
assert_eq!(round_tripped.start_at, request.start_at);
}
Err(error) => panic!("must round-trip: {error}"),
}
}
}