use serde::{Deserialize, Serialize};
use crate::Dataset;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DistributionMode {
#[default]
Calm,
Hard,
Extreme,
#[serde(rename = "cointegrated_pairs")]
CointegratedPairs,
#[serde(rename = "regime_shift")]
RegimeShift,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScenarioSpec {
pub start_level: u64,
pub num_levels: u64,
pub n_symbols: usize,
pub n_days: usize,
pub distribution_mode: DistributionMode,
}
impl Default for ScenarioSpec {
fn default() -> Self {
Self {
start_level: 0,
num_levels: 0,
n_symbols: 4,
n_days: 120,
distribution_mode: DistributionMode::Calm,
}
}
}
struct SplitMix64(u64);
impl SplitMix64 {
fn new(seed: u64) -> Self {
SplitMix64(seed ^ 0x1234_5678_9ABC_DEF0)
}
fn next_unit(&mut self) -> f64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z >> 11) as f64 / (1u64 << 53) as f64
}
}
struct AmplifyParams {
mode_salt: u64,
vol_mult: f64,
jump_prob: f64,
jump_size: f64,
}
fn amplify(mut base: Dataset, seed: u64, p: AmplifyParams) -> Dataset {
let mut rng = SplitMix64::new(seed ^ p.mode_salt);
for series in base.closes.values_mut() {
if series.len() < 2 {
continue;
}
let rets: Vec<f64> = (1..series.len())
.map(|t| series[t] / series[t - 1] - 1.0)
.collect();
let mean = rets.iter().sum::<f64>() / rets.len() as f64;
let mut price = series[0];
for (i, r) in rets.iter().enumerate() {
let jump = if rng.next_unit() < p.jump_prob {
if rng.next_unit() < 0.5 {
p.jump_size
} else {
-p.jump_size
}
} else {
0.0
};
let adjusted = (mean + p.vol_mult * (r - mean) + jump).max(-0.95);
price *= 1.0 + adjusted;
series[i + 1] = price;
}
}
base
}
const MIN_PRICE: f64 = 0.01;
fn signed(rng: &mut SplitMix64, amp: f64) -> f64 {
(rng.next_unit() - 0.5) * 2.0 * amp
}
#[allow(clippy::needless_range_loop)] fn cointegrated_pairs(mut base: Dataset, seed: u64) -> Dataset {
let mut rng = SplitMix64::new(seed ^ 0x436F_5061_6972_735F);
let mut series: Vec<&mut Vec<f64>> = base.closes.values_mut().collect();
let n = series.len();
let mut i = 0;
while i + 1 < n {
let len = series[i].len().min(series[i + 1].len());
let a_x = 0.8 + 0.4 * rng.next_unit();
let a_y = 0.8 + 0.4 * rng.next_unit();
let mut f = 100.0_f64;
let mut spread = 0.0_f64;
for t in 0..len {
if t > 0 {
f = (f + signed(&mut rng, 1.5)).max(MIN_PRICE);
}
let idio_x = signed(&mut rng, 0.6);
let idio_y = signed(&mut rng, 0.6);
spread = 0.85 * spread + signed(&mut rng, 1.0);
series[i][t] = (a_x * f + idio_x).max(MIN_PRICE);
series[i + 1][t] = (a_y * f + idio_y + spread).max(MIN_PRICE);
}
i += 2;
}
if i < n {
let len = series[i].len();
let mut f = 100.0_f64;
for t in 0..len {
if t > 0 {
f = (f + signed(&mut rng, 1.5)).max(MIN_PRICE);
}
series[i][t] = f;
}
}
base
}
#[allow(clippy::needless_range_loop)] fn regime_shift(mut base: Dataset, seed: u64) -> Dataset {
let mut rng = SplitMix64::new(seed ^ 0x5265_676D_5368_6674);
for s in base.closes.values_mut() {
let len = s.len();
if len < 2 {
continue;
}
let lo = len / 3;
let span = ((2 * len) / 3 - lo).max(1);
let cp = lo + (rng.next_unit() * span as f64) as usize;
let mut price = 100.0_f64;
s[0] = price;
let mut prev_r = 0.0_f64;
for t in 1..len {
let r = if t < cp {
0.004 + 0.7 * prev_r + signed(&mut rng, 0.01)
} else {
-0.6 * prev_r + signed(&mut rng, 0.03)
};
let r = r.max(-0.95);
price *= 1.0 + r;
s[t] = price;
prev_r = r;
}
}
base
}
pub fn generate_scenario(spec: &ScenarioSpec, seed: u64) -> Dataset {
let base = Dataset::synthetic(spec.n_symbols, spec.n_days, seed);
match spec.distribution_mode {
DistributionMode::Calm => base,
DistributionMode::Hard => amplify(
base,
seed,
AmplifyParams {
mode_salt: 0x4861_7264_5f5f_5f5f,
vol_mult: 1.8,
jump_prob: 0.02,
jump_size: 0.06,
},
),
DistributionMode::Extreme => amplify(
base,
seed,
AmplifyParams {
mode_salt: 0x4578_7472_5f5f_5f5f,
vol_mult: 3.0,
jump_prob: 0.06,
jump_size: 0.13,
},
),
DistributionMode::CointegratedPairs => cointegrated_pairs(base, seed),
DistributionMode::RegimeShift => regime_shift(base, seed),
}
}
pub fn level_seed(spec: &ScenarioSpec, index: u64) -> u64 {
let span = if spec.num_levels == 0 {
u64::MAX - spec.start_level
} else {
spec.num_levels
};
spec.start_level + (index % span)
}
pub fn train_test_split(
train: ScenarioSpec,
n_test: u64,
gap: u64,
) -> (ScenarioSpec, ScenarioSpec) {
debug_assert!(
train.num_levels > 0,
"an unbounded train interval admits no disjoint test split"
);
let test_start = train.start_level + train.num_levels + gap;
let test = ScenarioSpec {
start_level: test_start,
num_levels: n_test,
..train.clone()
};
debug_assert!(
test.start_level >= train.start_level + train.num_levels,
"test interval [{}, …) overlaps train [{}, {})",
test.start_level,
train.start_level,
train.start_level + train.num_levels
);
(train, test)
}
#[cfg(test)]
mod tests {
use super::*;
fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
const GOLDEN_CALM_4X120_SEED7_FNV1A: u64 = 0xb7cf_976c_7121_9c52;
const GOLDEN_HARD_4X120_SEED7_FNV1A: u64 = 0x2ef5_aff1_a716_05e6;
const GOLDEN_EXTREME_4X120_SEED7_FNV1A: u64 = 0xb082_0c4d_2c73_7f88;
const GOLDEN_COINTEGRATED_4X120_SEED7_FNV1A: u64 = 0xa3d2_2742_4ef0_5868;
const GOLDEN_REGIME_4X120_SEED7_FNV1A: u64 = 0x8b82_2cf3_c9d3_038f;
fn realized_vol(d: &Dataset) -> f64 {
let mut acc = 0.0;
for series in d.closes.values() {
let rets: Vec<f64> = (1..series.len())
.map(|t| series[t] / series[t - 1] - 1.0)
.collect();
let mean = rets.iter().sum::<f64>() / rets.len() as f64;
let var = rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / rets.len() as f64;
acc += var.sqrt();
}
acc / d.closes.len() as f64
}
fn golden_spec() -> ScenarioSpec {
ScenarioSpec {
distribution_mode: DistributionMode::Calm,
n_symbols: 4,
n_days: 120,
..ScenarioSpec::default()
}
}
#[test]
fn generate_is_deterministic() {
let spec = ScenarioSpec {
distribution_mode: DistributionMode::Hard,
..ScenarioSpec::default()
};
let a = serde_json::to_string(&generate_scenario(&spec, 42)).unwrap();
let b = serde_json::to_string(&generate_scenario(&spec, 42)).unwrap();
assert_eq!(a, b);
}
#[test]
fn distribution_modes_diverge() {
let calm = ScenarioSpec::default();
let hard = ScenarioSpec {
distribution_mode: DistributionMode::Hard,
..ScenarioSpec::default()
};
let extreme = ScenarioSpec {
distribution_mode: DistributionMode::Extreme,
..ScenarioSpec::default()
};
let cj = serde_json::to_string(&generate_scenario(&calm, 1)).unwrap();
let hj = serde_json::to_string(&generate_scenario(&hard, 1)).unwrap();
let ej = serde_json::to_string(&generate_scenario(&extreme, 1)).unwrap();
assert_ne!(cj, hj);
assert_ne!(hj, ej);
}
#[test]
fn distribution_mode_serializes_lowercase() {
assert_eq!(
serde_json::to_string(&DistributionMode::Extreme).unwrap(),
"\"extreme\""
);
}
#[test]
fn level_seed_bounded_wraps_within_interval() {
let spec = ScenarioSpec {
start_level: 100,
num_levels: 8,
..ScenarioSpec::default()
};
for index in 0..32 {
let s = level_seed(&spec, index);
assert!((100..108).contains(&s));
}
assert_eq!(level_seed(&spec, 0), 100);
assert_eq!(level_seed(&spec, 8), 100);
assert_eq!(level_seed(&spec, 9), 101);
}
#[test]
fn level_seed_unbounded_is_offset() {
let spec = ScenarioSpec {
start_level: 5,
num_levels: 0,
..ScenarioSpec::default()
};
assert_eq!(level_seed(&spec, 0), 5);
assert_eq!(level_seed(&spec, 17), 22);
}
#[test]
fn train_test_split_is_disjoint() {
let train = ScenarioSpec {
start_level: 0,
num_levels: 1000,
..ScenarioSpec::default()
};
let (train, test) = train_test_split(train, 200, 50);
let train_end = train.start_level + train.num_levels;
assert!(test.start_level >= train_end);
for ti in [0u64, 1, 999] {
let train_seed = level_seed(&train, ti);
for xi in [0u64, 1, 199] {
assert_ne!(train_seed, level_seed(&test, xi));
}
}
assert_eq!(test.start_level, 1050);
assert_eq!(test.num_levels, 200);
}
#[test]
fn golden_hash_is_stable() {
let json = serde_json::to_string(&generate_scenario(&golden_spec(), 7)).unwrap();
assert_eq!(fnv1a(json.as_bytes()), GOLDEN_CALM_4X120_SEED7_FNV1A);
}
#[test]
fn golden_hash_hard_extreme_stable() {
let hard = ScenarioSpec {
distribution_mode: DistributionMode::Hard,
..golden_spec()
};
let extreme = ScenarioSpec {
distribution_mode: DistributionMode::Extreme,
..golden_spec()
};
let hj = serde_json::to_string(&generate_scenario(&hard, 7)).unwrap();
let ej = serde_json::to_string(&generate_scenario(&extreme, 7)).unwrap();
assert_eq!(fnv1a(hj.as_bytes()), GOLDEN_HARD_4X120_SEED7_FNV1A);
assert_eq!(fnv1a(ej.as_bytes()), GOLDEN_EXTREME_4X120_SEED7_FNV1A);
}
fn coint_spec() -> ScenarioSpec {
ScenarioSpec {
distribution_mode: DistributionMode::CointegratedPairs,
..golden_spec()
}
}
fn regime_spec() -> ScenarioSpec {
ScenarioSpec {
distribution_mode: DistributionMode::RegimeShift,
..golden_spec()
}
}
#[test]
fn structured_modes_serialize_snake_case() {
assert_eq!(
serde_json::to_string(&DistributionMode::CointegratedPairs).unwrap(),
"\"cointegrated_pairs\""
);
assert_eq!(
serde_json::to_string(&DistributionMode::RegimeShift).unwrap(),
"\"regime_shift\""
);
}
#[test]
fn structured_modes_are_deterministic() {
for spec in [coint_spec(), regime_spec()] {
let a = serde_json::to_string(&generate_scenario(&spec, 7)).unwrap();
let b = serde_json::to_string(&generate_scenario(&spec, 7)).unwrap();
assert_eq!(a, b);
}
}
#[test]
fn structured_modes_diverge_from_calm() {
let cj = serde_json::to_string(&generate_scenario(&golden_spec(), 7)).unwrap();
let pj = serde_json::to_string(&generate_scenario(&coint_spec(), 7)).unwrap();
let rj = serde_json::to_string(&generate_scenario(®ime_spec(), 7)).unwrap();
assert_ne!(cj, pj);
assert_ne!(cj, rj);
assert_ne!(pj, rj);
}
#[test]
fn golden_hash_structured_modes_stable() {
let pj = serde_json::to_string(&generate_scenario(&coint_spec(), 7)).unwrap();
let rj = serde_json::to_string(&generate_scenario(®ime_spec(), 7)).unwrap();
assert_eq!(fnv1a(pj.as_bytes()), GOLDEN_COINTEGRATED_4X120_SEED7_FNV1A);
assert_eq!(fnv1a(rj.as_bytes()), GOLDEN_REGIME_4X120_SEED7_FNV1A);
}
fn ols_beta(x: &[f64], y: &[f64]) -> f64 {
let n = x.len() as f64;
let mx = x.iter().sum::<f64>() / n;
let my = y.iter().sum::<f64>() / n;
let mut cov = 0.0;
let mut var = 0.0;
for (xi, yi) in x.iter().zip(y) {
cov += (xi - mx) * (yi - my);
var += (xi - mx) * (xi - mx);
}
cov / var
}
fn variance_ratio(s: &[f64], q: usize) -> f64 {
let diff_var = |k: usize| {
let d: Vec<f64> = (k..s.len()).map(|t| s[t] - s[t - k]).collect();
let m = d.iter().sum::<f64>() / d.len() as f64;
d.iter().map(|v| (v - m) * (v - m)).sum::<f64>() / d.len() as f64
};
diff_var(q) / (q as f64 * diff_var(1))
}
#[test]
fn cointegrated_spread_is_mean_reverting() {
let d = generate_scenario(&coint_spec(), 7);
let cols: Vec<&Vec<f64>> = d.closes.values().collect();
let mut pairs = 0;
let mut i = 0;
while i + 1 < cols.len() {
let x = cols[i];
let y = cols[i + 1];
let beta = ols_beta(x, y);
let spread: Vec<f64> = x.iter().zip(y).map(|(xi, yi)| yi - beta * xi).collect();
let vr = variance_ratio(&spread, 8);
assert!(
vr < 1.0,
"pair {i} spread VR {vr} should be < 1 (mean-reverting)"
);
let leg_vr = variance_ratio(x, 8);
assert!(
leg_vr > vr,
"integrated leg VR {leg_vr} should exceed spread VR {vr}"
);
pairs += 1;
i += 2;
}
assert_eq!(pairs, 2);
}
#[test]
fn regime_shift_halves_differ() {
let d = generate_scenario(®ime_spec(), 7);
let seg_stats = |series: &[f64], a: usize, b: usize| {
let rets: Vec<f64> = (a + 1..b)
.map(|t| series[t] / series[t - 1] - 1.0)
.collect();
let mean = rets.iter().sum::<f64>() / rets.len() as f64;
let var = rets.iter().map(|r| (r - mean) * (r - mean)).sum::<f64>() / rets.len() as f64;
(mean, var.sqrt())
};
let mut trend_drift = 0.0;
let mut whip_drift = 0.0;
let mut trend_vol = 0.0;
let mut whip_vol = 0.0;
let n = d.closes.len() as f64;
for series in d.closes.values() {
let len = series.len();
let (td, tv) = seg_stats(series, 0, len / 3);
let (wd, wv) = seg_stats(series, (2 * len) / 3, len);
trend_drift += td;
whip_drift += wd;
trend_vol += tv;
whip_vol += wv;
}
trend_drift /= n;
whip_drift /= n;
trend_vol /= n;
whip_vol /= n;
assert!(
trend_drift > whip_drift,
"trending drift {trend_drift} should exceed whipsaw drift {whip_drift}"
);
assert!(
whip_vol > trend_vol,
"whipsaw vol {whip_vol} should exceed trending vol {trend_vol}"
);
}
#[test]
fn structured_mode_prices_are_positive_and_finite() {
for spec in [coint_spec(), regime_spec()] {
let d = generate_scenario(&spec, 3);
for series in d.closes.values() {
for &p in series {
assert!(p.is_finite() && p > 0.0, "price {p} must be finite and > 0");
}
}
}
}
#[test]
fn realized_vol_increases_with_difficulty() {
let spec = |m| ScenarioSpec {
distribution_mode: m,
..ScenarioSpec::default()
};
let calm = realized_vol(&generate_scenario(&spec(DistributionMode::Calm), 7));
let hard = realized_vol(&generate_scenario(&spec(DistributionMode::Hard), 7));
let extreme = realized_vol(&generate_scenario(&spec(DistributionMode::Extreme), 7));
assert!(calm < hard, "calm {calm} should be < hard {hard}");
assert!(hard < extreme, "hard {hard} should be < extreme {extreme}");
}
}