use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::config::BacktestConfig;
use crate::error::BacktestError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(u8)]
pub enum ScenarioType {
MonteCarlo = 0,
StressTest = 1,
Historical = 2,
Custom = 3,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigOverride {
pub seed: Option<u64>,
#[serde(default)]
pub walk_volatility_factor: Option<Decimal>,
#[serde(default)]
pub walk_drift_delta: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioParams {
pub kind: ScenarioType,
pub base_seed: u64,
pub count: u32,
#[serde(default)]
pub sweep: Vec<ConfigOverride>,
}
const ENGINE_SEED_TAG: &[u8] = b"ironcondor.scenario.child_seed.v1\0";
const DATA_SEED_TAG: &[u8] = b"ironcondor.scenario.child_data_seed.v1\0";
#[must_use]
fn seed_from(tag: &[u8], base_seed: u64, index: u32) -> u64 {
let mut hasher = Sha256::new();
hasher.update(tag);
hasher.update(base_seed.to_le_bytes());
hasher.update(index.to_le_bytes());
let digest = hasher.finalize();
let mut bytes = [0_u8; 8];
for (dst, src) in bytes.iter_mut().zip(digest.iter()) {
*dst = *src;
}
u64::from_le_bytes(bytes)
}
#[must_use]
pub fn child_seed(base_seed: u64, index: u32) -> u64 {
seed_from(ENGINE_SEED_TAG, base_seed, index)
}
#[must_use]
pub fn child_data_seed(base_seed: u64, index: u32) -> u64 {
seed_from(DATA_SEED_TAG, base_seed, index)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WalkPreset {
Brownian {
dt: Decimal,
drift: Decimal,
volatility: Decimal,
},
GeometricBrownian {
dt: Decimal,
drift: Decimal,
volatility: Decimal,
},
MeanReverting {
dt: Decimal,
volatility: Decimal,
speed: Decimal,
mean: Decimal,
},
JumpDiffusion {
dt: Decimal,
drift: Decimal,
volatility: Decimal,
intensity: Decimal,
jump_mean: Decimal,
jump_volatility: Decimal,
},
Garch {
dt: Decimal,
drift: Decimal,
volatility: Decimal,
alpha: Decimal,
beta: Decimal,
},
Heston {
dt: Decimal,
drift: Decimal,
volatility: Decimal,
kappa: Decimal,
theta: Decimal,
xi: Decimal,
rho: Decimal,
},
}
impl WalkPreset {
#[must_use]
pub const fn variant_name(&self) -> &'static str {
match self {
Self::Brownian { .. } => "Brownian",
Self::GeometricBrownian { .. } => "GeometricBrownian",
Self::MeanReverting { .. } => "MeanReverting",
Self::JumpDiffusion { .. } => "JumpDiffusion",
Self::Garch { .. } => "Garch",
Self::Heston { .. } => "Heston",
}
}
#[must_use = "the built walk method must be used"]
pub fn to_method_json(&self) -> Result<serde_json::Value, BacktestError> {
let params = match self {
Self::Brownian {
dt,
drift,
volatility,
}
| Self::GeometricBrownian {
dt,
drift,
volatility,
} => serde_json::json!({
"dt": num(*dt)?,
"drift": num(*drift)?,
"volatility": num(*volatility)?,
}),
Self::MeanReverting {
dt,
volatility,
speed,
mean,
} => serde_json::json!({
"dt": num(*dt)?,
"volatility": num(*volatility)?,
"speed": num(*speed)?,
"mean": num(*mean)?,
}),
Self::JumpDiffusion {
dt,
drift,
volatility,
intensity,
jump_mean,
jump_volatility,
} => serde_json::json!({
"dt": num(*dt)?,
"drift": num(*drift)?,
"volatility": num(*volatility)?,
"intensity": num(*intensity)?,
"jump_mean": num(*jump_mean)?,
"jump_volatility": num(*jump_volatility)?,
}),
Self::Garch {
dt,
drift,
volatility,
alpha,
beta,
} => serde_json::json!({
"dt": num(*dt)?,
"drift": num(*drift)?,
"volatility": num(*volatility)?,
"alpha": num(*alpha)?,
"beta": num(*beta)?,
}),
Self::Heston {
dt,
drift,
volatility,
kappa,
theta,
xi,
rho,
} => serde_json::json!({
"dt": num(*dt)?,
"drift": num(*drift)?,
"volatility": num(*volatility)?,
"kappa": num(*kappa)?,
"theta": num(*theta)?,
"xi": num(*xi)?,
"rho": num(*rho)?,
}),
};
let mut method = serde_json::Map::new();
method.insert(self.variant_name().to_string(), params);
Ok(serde_json::Value::Object(method))
}
}
fn num(value: Decimal) -> Result<serde_json::Value, BacktestError> {
let as_f64 = value.to_f64().filter(|v| v.is_finite()).ok_or_else(|| {
BacktestError::Conversion(format!("walk parameter {value} is not a finite f64"))
})?;
let number = serde_json::Number::from_f64(as_f64).ok_or_else(|| {
BacktestError::Conversion(format!("walk parameter {value} is not a finite f64 number"))
})?;
Ok(serde_json::Value::Number(number))
}
const MAX_RUNS: usize = 1_000_000;
#[must_use = "the expanded configs must be used"]
pub fn expand(
params: &ScenarioParams,
base: &BacktestConfig,
) -> Result<Vec<BacktestConfig>, BacktestError> {
let default_override = ConfigOverride::default();
let overrides: &[ConfigOverride] = if params.sweep.is_empty() {
std::slice::from_ref(&default_override)
} else {
¶ms.sweep
};
if params.kind != ScenarioType::StressTest
&& overrides
.iter()
.any(|over| over.walk_volatility_factor.is_some() || over.walk_drift_delta.is_some())
{
return Err(BacktestError::Config(format!(
"walk_volatility_factor / walk_drift_delta are StressTest-only shocks, but the \
scenario kind is {:?}",
params.kind
)));
}
if params.count > 1 && overrides.iter().any(|over| over.seed.is_some()) {
return Err(BacktestError::Config(
"an explicit ConfigOverride.seed with count > 1 makes every replicate share a \
run_id (a concurrent bundle-directory race); use base_seed derivation \
(seed: None) for replicated runs, or set count = 1"
.to_string(),
));
}
let count = usize::try_from(params.count).map_err(|_| BacktestError::ArithmeticOverflow)?;
let total = overrides
.len()
.checked_mul(count)
.ok_or(BacktestError::ArithmeticOverflow)?;
if total > MAX_RUNS {
return Err(BacktestError::Config(format!(
"scenario batch expands to {total} runs, exceeding the hard cap of {MAX_RUNS}"
)));
}
let mut out = Vec::with_capacity(total);
let mut index: u32 = 0;
for over in overrides {
for _replicate in 0..count {
out.push(derive_config(base, params.base_seed, index, over)?);
index = index
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
}
Ok(out)
}
fn derive_config(
base: &BacktestConfig,
base_seed: u64,
index: u32,
over: &ConfigOverride,
) -> Result<BacktestConfig, BacktestError> {
let mut config = base.clone();
config.seed = over.seed.unwrap_or_else(|| child_seed(base_seed, index));
#[cfg(feature = "simulator")]
if let crate::data::DataSourceSpec::Simulator(spec) = &mut config.data_source {
let data_seed = child_data_seed(base_seed, index);
spec.data_seed = data_seed;
spec.session.seed = Some(data_seed);
spec.tape_sha256 = String::new();
apply_walk_shocks(&mut spec.session, over)?;
}
#[cfg(not(feature = "simulator"))]
let _ = over;
Ok(config)
}
#[cfg(feature = "simulator")]
fn apply_walk_shocks(
session: &mut crate::data::simulator::CreateSessionRequest,
over: &ConfigOverride,
) -> Result<(), BacktestError> {
if let Some(factor) = over.walk_volatility_factor {
let factor = shock_scalar(factor)?;
if factor < 0.0 {
return Err(BacktestError::Conversion(format!(
"walk_volatility_factor must be >= 0, got {factor}"
)));
}
session.volatility = finite(session.volatility * factor, "session volatility")?;
shock_method_field(&mut session.method, "volatility", |v| v * factor)?;
}
if let Some(delta) = over.walk_drift_delta {
let delta = shock_scalar(delta)?;
shock_method_field(&mut session.method, "drift", |v| v + delta)?;
}
Ok(())
}
#[cfg(feature = "simulator")]
fn shock_scalar(value: Decimal) -> Result<f64, BacktestError> {
value.to_f64().filter(|v| v.is_finite()).ok_or_else(|| {
BacktestError::Conversion(format!("stress shock {value} is not a finite f64"))
})
}
#[cfg(feature = "simulator")]
fn finite(value: f64, what: &str) -> Result<f64, BacktestError> {
if value.is_finite() {
Ok(value)
} else {
Err(BacktestError::Conversion(format!(
"shocked {what} is not finite ({value})"
)))
}
}
#[cfg(feature = "simulator")]
fn shock_method_field(
method: &mut serde_json::Value,
field: &str,
f: impl Fn(f64) -> f64,
) -> Result<(), BacktestError> {
let Some(object) = method.as_object_mut() else {
return Ok(());
};
if object.len() != 1 {
return Ok(());
}
let Some(variant_key) = object.keys().next().cloned() else {
return Ok(());
};
let Some(params) = object.get_mut(&variant_key).and_then(|v| v.as_object_mut()) else {
return Ok(());
};
if let Some(slot) = params.get_mut(field)
&& let Some(current) = slot.as_f64()
{
let shocked = finite(f(current), field)?;
let number = serde_json::Number::from_f64(shocked).ok_or_else(|| {
BacktestError::Conversion(format!("shocked walk {field} is not a finite f64 number"))
})?;
*slot = serde_json::Value::Number(number);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
ConfigOverride, ScenarioParams, ScenarioType, WalkPreset, child_data_seed, child_seed,
expand,
};
use crate::config::{BacktestConfig, FeeSchedule, ResourceLimits, SlippageModel};
use crate::data::DataSourceSpec;
use crate::domain::ExecutionMode;
use crate::error::BacktestError;
use rust_decimal::Decimal;
fn base_config() -> BacktestConfig {
BacktestConfig {
data_source: DataSourceSpec::Parquet {
path: "chains/spx.parquet".to_string(),
sha256: String::new(),
},
mode: ExecutionMode::Naive,
seed: 999,
initial_capital: 10_000_000,
fees: FeeSchedule {
per_contract_cents: 65,
per_order_cents: 100,
},
slippage: SlippageModel::None,
marketable_cap_ticks: 10,
liquidity_profile: crate::config::LiquidityProfile::default(),
limits: ResourceLimits::default(),
output_dir: "runs/out".into(),
overwrite: false,
}
}
#[test]
fn test_scenario_params_serde_round_trip_preserves_fields() {
let params = ScenarioParams {
kind: ScenarioType::MonteCarlo,
base_seed: 42,
count: 128,
sweep: vec![
ConfigOverride {
seed: Some(7),
..ConfigOverride::default()
},
ConfigOverride::default(),
],
};
let json = serde_json::to_string(¶ms).unwrap_or_default();
let back: Result<ScenarioParams, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(ref p) if *p == params));
}
#[test]
fn test_scenario_type_variants_round_trip() {
for kind in [
ScenarioType::MonteCarlo,
ScenarioType::StressTest,
ScenarioType::Historical,
ScenarioType::Custom,
] {
let json = serde_json::to_string(&kind).unwrap_or_default();
let back: Result<ScenarioType, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(k) if k == kind));
}
}
#[test]
fn test_scenario_params_default_sweep_is_empty() {
let json = r#"{"kind":"MonteCarlo","base_seed":1,"count":4}"#;
let parsed: Result<ScenarioParams, _> = serde_json::from_str(json);
assert!(matches!(parsed, Ok(ref p) if p.sweep.is_empty()));
}
#[test]
fn test_config_override_backward_compatible_seed_only_json() {
let parsed: Result<ConfigOverride, _> = serde_json::from_str(r#"{"seed":7}"#);
assert!(matches!(
parsed,
Ok(ref o) if o.seed == Some(7)
&& o.walk_volatility_factor.is_none()
&& o.walk_drift_delta.is_none()
));
}
#[test]
fn test_child_seed_is_pinned_and_stable() {
assert_eq!(child_seed(0, 0), 0x0b6b_944a_18e1_6e29);
assert_eq!(child_seed(42, 0), 0x5317_e52e_2b4f_cb1a);
assert_eq!(child_seed(42, 1), 0xfb9f_52a6_4559_c464);
assert_eq!(child_data_seed(42, 0), 0x7d1c_7410_be51_cdb9);
assert_ne!(child_seed(42, 0), child_data_seed(42, 0));
}
#[test]
fn test_child_seed_is_a_pure_function_of_inputs() {
for base in [0_u64, 1, 42, u64::MAX] {
for index in 0_u32..8 {
assert_eq!(child_seed(base, index), child_seed(base, index));
}
let seeds: Vec<u64> = (0..8).map(|i| child_seed(base, i)).collect();
let mut unique = seeds.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(
unique.len(),
seeds.len(),
"no index collisions for base {base}"
);
}
}
#[test]
fn test_expand_empty_sweep_yields_count_replicas_with_derived_seeds() {
let params = ScenarioParams {
kind: ScenarioType::MonteCarlo,
base_seed: 42,
count: 4,
sweep: Vec::new(),
};
let configs = match expand(¶ms, &base_config()) {
Ok(c) => c,
Err(e) => panic!("expand must succeed: {e}"),
};
assert_eq!(configs.len(), 4, "empty sweep => count replicas");
for (index, config) in configs.iter().enumerate() {
let i = u32::try_from(index).unwrap_or(u32::MAX);
assert_eq!(config.seed, child_seed(42, i));
}
}
#[test]
fn test_expand_cardinality_is_sweep_times_count() {
let params = ScenarioParams {
kind: ScenarioType::StressTest,
base_seed: 7,
count: 3,
sweep: vec![
ConfigOverride::default(),
ConfigOverride::default(),
ConfigOverride::default(),
ConfigOverride::default(),
],
};
let configs = match expand(¶ms, &base_config()) {
Ok(c) => c,
Err(e) => panic!("expand must succeed: {e}"),
};
assert_eq!(configs.len(), 4 * 3, "sweep.len() x count");
for (index, config) in configs.iter().enumerate() {
let i = u32::try_from(index).unwrap_or(u32::MAX);
assert_eq!(config.seed, child_seed(7, i));
}
}
#[test]
fn test_expand_rejects_run_count_over_max() {
let params = ScenarioParams {
kind: ScenarioType::MonteCarlo,
base_seed: 1,
count: u32::MAX,
sweep: Vec::new(),
};
assert!(matches!(
expand(¶ms, &base_config()),
Err(BacktestError::Config(_))
));
}
#[test]
fn test_expand_rejects_explicit_seed_with_count_gt_one() {
let bad = ScenarioParams {
kind: ScenarioType::MonteCarlo,
base_seed: 1,
count: 2,
sweep: vec![ConfigOverride {
seed: Some(7),
..ConfigOverride::default()
}],
};
assert!(matches!(
expand(&bad, &base_config()),
Err(BacktestError::Config(_))
));
let ok = ScenarioParams {
kind: ScenarioType::MonteCarlo,
base_seed: 1,
count: 1,
sweep: vec![ConfigOverride {
seed: Some(7),
..ConfigOverride::default()
}],
};
assert!(expand(&ok, &base_config()).is_ok());
}
#[test]
fn test_expand_rejects_walk_shocks_outside_stress_test() {
for kind in [
ScenarioType::MonteCarlo,
ScenarioType::Historical,
ScenarioType::Custom,
] {
let params = ScenarioParams {
kind,
base_seed: 1,
count: 1,
sweep: vec![ConfigOverride {
seed: None,
walk_volatility_factor: Some(Decimal::new(15, 1)),
walk_drift_delta: None,
}],
};
assert!(
matches!(
expand(¶ms, &base_config()),
Err(BacktestError::Config(_))
),
"kind {kind:?} must reject a walk shock"
);
}
let stress = ScenarioParams {
kind: ScenarioType::StressTest,
base_seed: 1,
count: 1,
sweep: vec![ConfigOverride {
seed: None,
walk_volatility_factor: Some(Decimal::new(15, 1)),
walk_drift_delta: None,
}],
};
assert!(expand(&stress, &base_config()).is_ok());
}
#[test]
fn test_expand_is_scheduling_independent_pure_function() {
let params = ScenarioParams {
kind: ScenarioType::MonteCarlo,
base_seed: 12_345,
count: 1,
sweep: vec![
ConfigOverride {
seed: Some(111),
..ConfigOverride::default()
},
ConfigOverride::default(),
],
};
let base = base_config();
let first = expand(¶ms, &base);
let second = expand(¶ms, &base);
match (first, second) {
(Ok(a), Ok(b)) => assert_eq!(a, b),
other => panic!("expand must be deterministic: {other:?}"),
}
}
#[test]
fn test_expand_explicit_seed_override_pins_engine_seed() {
let params = ScenarioParams {
kind: ScenarioType::Custom,
base_seed: 1,
count: 1,
sweep: vec![
ConfigOverride {
seed: Some(4242),
..ConfigOverride::default()
},
ConfigOverride::default(),
],
};
let configs = match expand(¶ms, &base_config()) {
Ok(c) => c,
Err(e) => panic!("expand must succeed: {e}"),
};
assert_eq!(configs.len(), 2);
assert_eq!(configs[0].seed, 4242);
assert_eq!(configs[1].seed, child_seed(1, 1));
}
#[test]
fn test_walk_preset_geometric_brownian_maps_to_tagged_method_json() {
let preset = WalkPreset::GeometricBrownian {
dt: Decimal::new(4, 3), drift: Decimal::new(5, 2), volatility: Decimal::new(25, 2), };
let method = match preset.to_method_json() {
Ok(m) => m,
Err(e) => panic!("preset must build a method: {e}"),
};
let expected = serde_json::json!({
"GeometricBrownian": {"dt": 0.004, "drift": 0.05, "volatility": 0.25}
});
assert_eq!(method, expected);
}
#[cfg(feature = "simulator")]
#[test]
fn test_expand_simulator_derives_data_seed_and_applies_stress_shocks() {
use crate::data::SimulatorSourceSpec;
use crate::data::simulator::CreateSessionRequest;
let mut base = base_config();
base.data_source = DataSourceSpec::Simulator(SimulatorSourceSpec {
session: CreateSessionRequest {
symbol: "SPX".to_string(),
steps: 10,
initial_price: 4300.0,
days_to_expiration: 30.0,
volatility: 0.2,
risk_free_rate: 0.03,
dividend_yield: 0.0,
method: serde_json::json!({
"GeometricBrownian": {"dt": 0.004, "drift": 0.05, "volatility": 0.2}
}),
time_frame: "Day".to_string(),
chain_size: Some(15),
strike_interval: Some(5.0),
skew_slope: None,
smile_curve: None,
spread: Some(0.02),
seed: None,
},
base_url: "http://localhost:7070".to_string(),
data_seed: 0,
tape_sha256: "stale".to_string(),
simulator_version: None,
});
let params = ScenarioParams {
kind: ScenarioType::StressTest,
base_seed: 7,
count: 1,
sweep: vec![
ConfigOverride::default(),
ConfigOverride {
seed: None,
walk_volatility_factor: Some(Decimal::new(15, 1)),
walk_drift_delta: Some(Decimal::new(1, 2)),
},
],
};
let configs = match expand(¶ms, &base) {
Ok(c) => c,
Err(e) => panic!("simulator expand must succeed: {e}"),
};
assert_eq!(configs.len(), 2);
let DataSourceSpec::Simulator(spec0) = &configs[0].data_source else {
panic!("run 0 keeps a simulator source");
};
assert_eq!(spec0.data_seed, child_data_seed(7, 0));
assert_eq!(spec0.session.seed, Some(child_data_seed(7, 0)));
assert!(
spec0.tape_sha256.is_empty(),
"the stale tape sha is cleared"
);
assert_eq!(spec0.session.volatility, 0.2);
let DataSourceSpec::Simulator(spec1) = &configs[1].data_source else {
panic!("run 1 keeps a simulator source");
};
assert_eq!(spec1.data_seed, child_data_seed(7, 1));
assert!((spec1.session.volatility - 0.3).abs() < 1e-9);
let method = &spec1.session.method;
let vol = method
.get("GeometricBrownian")
.and_then(|m| m.get("volatility"))
.and_then(serde_json::Value::as_f64);
let drift = method
.get("GeometricBrownian")
.and_then(|m| m.get("drift"))
.and_then(serde_json::Value::as_f64);
assert!(
matches!(vol, Some(v) if (v - 0.3).abs() < 1e-9),
"walk vol shocked: {vol:?}"
);
assert!(
matches!(drift, Some(d) if (d - 0.06).abs() < 1e-9),
"walk drift shifted: {drift:?}"
);
}
#[test]
fn test_walk_preset_heston_maps_to_all_upstream_fields() {
let preset = WalkPreset::Heston {
dt: Decimal::new(4, 3),
drift: Decimal::new(5, 2),
volatility: Decimal::new(2, 1),
kappa: Decimal::new(15, 1),
theta: Decimal::new(4, 2),
xi: Decimal::new(3, 1),
rho: Decimal::new(-5, 1),
};
let method = match preset.to_method_json() {
Ok(m) => m,
Err(e) => panic!("preset must build a method: {e}"),
};
let expected = serde_json::json!({
"Heston": {
"dt": 0.004, "drift": 0.05, "volatility": 0.2,
"kappa": 1.5, "theta": 0.04, "xi": 0.3, "rho": -0.5
}
});
assert_eq!(method, expected);
}
}