optionchain_simulator 0.2.0

OptionChain-Simulator is a lightweight REST API service that simulates an evolving option chain with every request. It is designed for developers building or testing trading systems, backtesters, and visual tools that depend on option data streams but want to avoid relying on live data feeds.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
//! Operational configuration for v2 rolling simulations.
//!
//! Everything here is about **real** time and **real** memory: how long an idle
//! simulation is kept, how often expired ones are reaped, and how much of the
//! expensive derived state stays resident. None of it touches the *simulated*
//! clock, which may span years regardless.
//!
//! That separation is the point of the module. A simulation whose simulated
//! horizon is three years is still walked one request at a time, and losing it
//! to a thirty-minute idle timeout would make the horizon unusable — so v2 gets
//! its own retention window rather than inheriting v1's.
//!
//! # Why this validates instead of falling back
//!
//! `api::rest::limits` reads its caps with a warn-and-default, because a bad
//! `OCS_MAX_STEPS` degrades one request. These knobs are different: a retention
//! window silently reset to a default would expire simulations a client is
//! still walking, and a cache capacity silently reset would change the
//! service's memory profile without anyone noticing. So an invalid value fails
//! startup with a message naming the variable, which is what
//! `rules/global_rules.md` asks of a configuration knob.

use crate::utils::ChainError;
use std::env;
use std::sync::OnceLock;
use std::time::Duration;
use tracing::info;

/// Default idle retention for a v2 simulation, in seconds.
///
/// An hour rather than v1's thirty minutes: a v2 simulation is walked one
/// request at a time over a long simulated horizon, and a client pausing
/// between steps must not lose it.
pub const DEFAULT_RETENTION_SECS: u64 = 3_600;

/// Default interval between cleanup passes, in seconds.
///
/// Frequent enough that an expired simulation's caches are reclaimed promptly,
/// rare enough that the pass itself is not a load.
pub const DEFAULT_CLEANUP_INTERVAL_SECS: u64 = 60;

/// Default number of factor tapes held resident.
///
/// A tape is `O(steps)` four-field rows, so this is generous relative to the
/// snapshot bound.
pub const DEFAULT_MAX_CACHED_TAPES: usize = 64;

/// Default number of snapshots held resident.
///
/// A snapshot holds every strike of every live expiration, so the bound is
/// deliberately small. It bounds the *map*, not the memory — see
/// [`DEFAULT_MAX_CACHED_SNAPSHOT_CONTRACTS`], which does.
pub const DEFAULT_MAX_CACHED_SNAPSHOTS: usize = 256;

/// Default cap on the contracts one snapshot may price.
///
/// A snapshot prices every strike of every live expiration, so its cost is the
/// product of two caps that each look reasonable alone: a chain size of 500 is
/// 1 001 strikes, and a schedule may keep 512 expirations alive, for half a
/// million contracts in one request. The default admits any realistic
/// configuration — ADR 0001's reference schedule prices about 500 contracts a
/// snapshot — while refusing the shapes that exist only to exhaust the service.
pub const DEFAULT_MAX_SNAPSHOT_CONTRACTS: usize = 200_000;

/// Default number of contracts held resident across every cached snapshot.
///
/// The honest unit for a memory bound: one entry is a few hundred contracts in
/// ADR 0001's reference configuration and up to the per-snapshot cap in a large
/// one, so an entry count says nothing about how much is resident. Four million
/// contracts is roughly a gigabyte of `OptionData`, and it lets the reference
/// configuration keep its entry bound's worth of snapshots without ever
/// reaching this one.
pub const DEFAULT_MAX_CACHED_SNAPSHOT_CONTRACTS: usize = 4_000_000;

/// The longest retention window that can be configured, in seconds — thirty
/// days.
///
/// Not a technical limit but an operational one: a window longer than this is
/// almost certainly a typo (seconds entered as milliseconds, say), and the
/// consequence would be simulations pinning memory for weeks.
const MAX_RETENTION_SECS: u64 = 30 * 24 * 3_600;

/// The longest cleanup interval that can be configured, in seconds — one hour.
const MAX_CLEANUP_INTERVAL_SECS: u64 = 3_600;

/// The largest cache bound that can be configured.
const MAX_CACHE_CAPACITY: usize = 1_000_000;

/// The largest per-snapshot contract cap that can be configured.
///
/// Above the product of the two caps it bounds, so it can never be the binding
/// constraint by accident — a value this high is a typo.
const MAX_SNAPSHOT_CONTRACTS_CEILING: usize = 10_000_000;

/// The largest contract budget that can be configured.
///
/// A hundred million `OptionData` is far past any machine this runs on, so a
/// value above it is a typo rather than an intent.
const MAX_CACHE_CONTRACTS: usize = 100_000_000;

/// Default cap on the steps one export request may cover.
///
/// Bounds the *work*, not the memory: streaming already keeps the response
/// bounded, but an `option_chains` export is `steps × expirations × strikes`
/// priced contracts, so an unbounded range is minutes of CPU on a blocking
/// thread. A client wanting more pages the range.
pub const DEFAULT_MAX_EXPORT_ROWS: usize = 100_000;

/// The largest export range that can be configured.
const MAX_EXPORT_ROWS_CEILING: usize = 10_000_000;

/// Operational limits for the v2 rolling-simulation surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SimulationV2Config {
    /// How long an idle simulation is kept before it is reaped.
    ///
    /// Measured from the last **write**, not the last read: a peek persists
    /// nothing (ADR 0001 §6), so a client that only peeks does not refresh the
    /// window. Both stores behave the same way.
    pub retention: Duration,
    /// How often the cleanup pass runs.
    pub cleanup_interval: Duration,
    /// How many factor tapes stay resident.
    pub max_cached_tapes: usize,
    /// How many snapshots stay resident.
    pub max_cached_snapshots: usize,
    /// How many contracts one snapshot may price.
    pub max_snapshot_contracts: usize,
    /// How many contracts stay resident across every cached snapshot.
    pub max_cached_snapshot_contracts: usize,
    /// How many steps one export request may cover.
    pub max_export_rows: usize,
}

impl Default for SimulationV2Config {
    fn default() -> Self {
        Self {
            retention: Duration::from_secs(DEFAULT_RETENTION_SECS),
            cleanup_interval: Duration::from_secs(DEFAULT_CLEANUP_INTERVAL_SECS),
            max_cached_tapes: DEFAULT_MAX_CACHED_TAPES,
            max_cached_snapshots: DEFAULT_MAX_CACHED_SNAPSHOTS,
            max_snapshot_contracts: DEFAULT_MAX_SNAPSHOT_CONTRACTS,
            max_cached_snapshot_contracts: DEFAULT_MAX_CACHED_SNAPSHOT_CONTRACTS,
            max_export_rows: DEFAULT_MAX_EXPORT_ROWS,
        }
    }
}

impl SimulationV2Config {
    /// Reads the configuration from the environment.
    ///
    /// An unset variable takes its documented default. A set-but-invalid one
    /// fails, rather than falling back — see the module docs for why.
    ///
    /// # Errors
    ///
    /// Returns [`ChainError::Validation`] naming the environment variable when
    /// its value does not parse, is zero, or exceeds its documented bound.
    pub fn from_env() -> Result<Self, ChainError> {
        let config = Self {
            retention: Duration::from_secs(parse_secs(
                "OCS_V2_RETENTION_SECS",
                read("OCS_V2_RETENTION_SECS").as_deref(),
                DEFAULT_RETENTION_SECS,
                MAX_RETENTION_SECS,
            )?),
            cleanup_interval: Duration::from_secs(parse_secs(
                "OCS_V2_CLEANUP_INTERVAL_SECS",
                read("OCS_V2_CLEANUP_INTERVAL_SECS").as_deref(),
                DEFAULT_CLEANUP_INTERVAL_SECS,
                MAX_CLEANUP_INTERVAL_SECS,
            )?),
            max_cached_tapes: parse_capacity(
                "OCS_MAX_CACHED_TAPES",
                read("OCS_MAX_CACHED_TAPES").as_deref(),
                DEFAULT_MAX_CACHED_TAPES,
            )?,
            max_cached_snapshots: parse_capacity(
                "OCS_MAX_CACHED_SNAPSHOTS",
                read("OCS_MAX_CACHED_SNAPSHOTS").as_deref(),
                DEFAULT_MAX_CACHED_SNAPSHOTS,
            )?,
            max_snapshot_contracts: parse_bounded(
                "OCS_MAX_SNAPSHOT_CONTRACTS",
                read("OCS_MAX_SNAPSHOT_CONTRACTS").as_deref(),
                DEFAULT_MAX_SNAPSHOT_CONTRACTS,
                MAX_SNAPSHOT_CONTRACTS_CEILING,
            )?,
            max_cached_snapshot_contracts: parse_bounded(
                "OCS_MAX_CACHED_SNAPSHOT_CONTRACTS",
                read("OCS_MAX_CACHED_SNAPSHOT_CONTRACTS").as_deref(),
                DEFAULT_MAX_CACHED_SNAPSHOT_CONTRACTS,
                MAX_CACHE_CONTRACTS,
            )?,
            max_export_rows: parse_bounded(
                "OCS_MAX_EXPORT_ROWS",
                read("OCS_MAX_EXPORT_ROWS").as_deref(),
                DEFAULT_MAX_EXPORT_ROWS,
                MAX_EXPORT_ROWS_CEILING,
            )?,
        };

        info!(
            retention_secs = config.retention.as_secs(),
            cleanup_interval_secs = config.cleanup_interval.as_secs(),
            max_cached_tapes = config.max_cached_tapes,
            max_cached_snapshots = config.max_cached_snapshots,
            max_snapshot_contracts = config.max_snapshot_contracts,
            max_cached_snapshot_contracts = config.max_cached_snapshot_contracts,
            max_export_rows = config.max_export_rows,
            "Loaded the v2 simulation configuration"
        );
        // Publish the parsed cap for the validator, which has no config handle.
        // A second call is a no-op: the first configuration a process loads is
        // the one it runs with.
        let _ = SNAPSHOT_CONTRACT_CAP.set(config.max_snapshot_contracts);

        Ok(config)
    }

    /// The retention window in seconds, for the stores that take one.
    #[must_use]
    pub fn retention_secs(&self) -> u64 {
        self.retention.as_secs()
    }
}

/// Parses a duration knob, in seconds.
///
/// Takes the raw value rather than reading it, so the parsing and the bounds
/// can be tested without mutating the process environment — which would need
/// `unsafe` in this edition and would race every other test in the binary.
fn parse_secs(
    variable: &str,
    raw: Option<&str>,
    default: u64,
    max: u64,
) -> Result<u64, ChainError> {
    let Some(raw) = raw else {
        return Ok(default);
    };

    let seconds = raw.parse::<u64>().map_err(|_| invalid(variable, raw))?;
    if seconds == 0 {
        return Err(ChainError::Validation {
            field: variable.to_string(),
            reason: "must be at least 1 second".to_string(),
        });
    }
    if seconds > max {
        return Err(ChainError::Validation {
            field: variable.to_string(),
            reason: format!("must not exceed {max} seconds, got {seconds}"),
        });
    }
    Ok(seconds)
}

/// Parses a cache-capacity knob.
///
/// Takes the raw value for the same reason as [`parse_secs`].
fn parse_capacity(variable: &str, raw: Option<&str>, default: usize) -> Result<usize, ChainError> {
    parse_bounded(variable, raw, default, MAX_CACHE_CAPACITY)
}

/// Parses a positive count with an explicit ceiling.
///
/// Takes the raw value for the same reason as [`parse_secs`].
fn parse_bounded(
    variable: &str,
    raw: Option<&str>,
    default: usize,
    max: usize,
) -> Result<usize, ChainError> {
    let Some(raw) = raw else {
        return Ok(default);
    };

    let value = raw.parse::<usize>().map_err(|_| invalid(variable, raw))?;
    if value == 0 {
        return Err(ChainError::Validation {
            field: variable.to_string(),
            reason: "must be at least 1".to_string(),
        });
    }
    if value > max {
        return Err(ChainError::Validation {
            field: variable.to_string(),
            reason: format!("must not exceed {max}, got {value}"),
        });
    }
    Ok(value)
}

/// Reads a variable, treating an empty or whitespace-only value as unset.
///
/// A blank value in a `.env` file is how a knob gets "commented out" in
/// practice; treating it as unset is friendlier than failing startup over it,
/// and unambiguous either way.
fn read(variable: &str) -> Option<String> {
    let raw = env::var(variable).ok()?;
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

/// The error for a value that does not parse.
#[cold]
fn invalid(variable: &str, raw: &str) -> ChainError {
    ChainError::Validation {
        field: variable.to_string(),
        reason: format!("must be a whole number, got {raw:?}"),
    }
}

/// The per-snapshot contract cap the running service applies.
///
/// [`crate::session::SimulationParametersV2::validate`] is a method on a value, not a service
/// with a config handle, so the parsed cap reaches it through here.
/// [`SimulationV2Config::from_env`] publishes it once at startup, which keeps
/// the parsing — and the failure that names the variable — in exactly one
/// place: a malformed `OCS_MAX_SNAPSHOT_CONTRACTS` fails the boot rather than
/// quietly deploying a different bound than the operator asked for.
///
/// Before startup publishes it, and in tests and library use where no service
/// exists, the documented default applies.
static SNAPSHOT_CONTRACT_CAP: OnceLock<usize> = OnceLock::new();

/// The cap to validate against.
#[must_use]
pub fn max_snapshot_contracts() -> usize {
    *SNAPSHOT_CONTRACT_CAP
        .get()
        .unwrap_or(&DEFAULT_MAX_SNAPSHOT_CONTRACTS)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The defaults are the documented ones.
    #[test]
    fn test_the_defaults_are_the_documented_values() {
        let config = SimulationV2Config::default();

        assert_eq!(config.retention.as_secs(), DEFAULT_RETENTION_SECS);
        assert_eq!(
            config.cleanup_interval.as_secs(),
            DEFAULT_CLEANUP_INTERVAL_SECS
        );
        assert_eq!(config.max_cached_tapes, DEFAULT_MAX_CACHED_TAPES);
        assert_eq!(config.max_cached_snapshots, DEFAULT_MAX_CACHED_SNAPSHOTS);
        assert_eq!(config.retention_secs(), DEFAULT_RETENTION_SECS);
    }

    /// The v2 retention outlasts v1's thirty minutes, which is the whole reason
    /// it is a separate knob.
    ///
    /// v1's window is not a constant this crate can reference — it is the
    /// literal `1800` default inside `InRedisSessionStore::new` — so the
    /// comparison is written against that number directly.
    #[test]
    fn test_the_v2_retention_outlasts_the_v1_default() {
        let v1_default_secs: u64 = 1_800;

        assert!(
            SimulationV2Config::default().retention_secs() > v1_default_secs,
            "a v2 simulation is walked one request at a time over a long horizon"
        );
    }

    /// An unset knob takes its default.
    #[test]
    fn test_an_unset_duration_takes_its_default() {
        match parse_secs("OCS_V2_RETENTION_SECS", None, 900, 3_600) {
            Ok(seconds) => assert_eq!(seconds, 900),
            Err(error) => panic!("an unset knob must take its default: {error}"),
        }
    }

    /// A valid duration is accepted verbatim.
    #[test]
    fn test_a_valid_duration_is_accepted() {
        match parse_secs("OCS_V2_RETENTION_SECS", Some("120"), 900, 3_600) {
            Ok(seconds) => assert_eq!(seconds, 120),
            Err(error) => panic!("a valid duration must be accepted: {error}"),
        }
    }

    /// A duration that does not parse fails, naming the variable — rather than
    /// falling back and silently changing how long simulations live.
    #[test]
    fn test_an_unparseable_duration_fails_by_name() {
        match parse_secs("OCS_V2_RETENTION_SECS", Some("an hour"), 900, 3_600) {
            Err(ChainError::Validation { field, reason }) => {
                assert_eq!(field, "OCS_V2_RETENTION_SECS");
                assert!(reason.contains("whole number"), "{reason}");
            }
            other => panic!("expected a validation error, got {other:?}"),
        }
    }

    /// A zero retention would expire every simulation instantly.
    #[test]
    fn test_a_zero_duration_is_rejected() {
        match parse_secs("OCS_V2_RETENTION_SECS", Some("0"), 900, 3_600) {
            Err(ChainError::Validation { reason, .. }) => {
                assert!(reason.contains("at least 1 second"), "{reason}");
            }
            other => panic!("expected a validation error, got {other:?}"),
        }
    }

    /// A retention beyond the operational ceiling is almost certainly a typo,
    /// and would pin memory for weeks.
    #[test]
    fn test_a_duration_beyond_its_ceiling_is_rejected() {
        match parse_secs("OCS_V2_RETENTION_SECS", Some("999999999"), 900, 3_600) {
            Err(ChainError::Validation { reason, .. }) => {
                assert!(reason.contains("must not exceed"), "{reason}");
            }
            other => panic!("expected a validation error, got {other:?}"),
        }
    }

    /// An unset capacity takes its default; a valid one is accepted.
    #[test]
    fn test_capacity_parsing_accepts_valid_values() {
        match parse_capacity("OCS_MAX_CACHED_TAPES", None, 64) {
            Ok(capacity) => assert_eq!(capacity, 64),
            Err(error) => panic!("an unset knob must take its default: {error}"),
        }
        match parse_capacity("OCS_MAX_CACHED_TAPES", Some("8"), 64) {
            Ok(capacity) => assert_eq!(capacity, 8),
            Err(error) => panic!("a valid capacity must be accepted: {error}"),
        }
    }

    /// A zero capacity would make the cache inert, so it fails rather than
    /// silently reverting to the default.
    #[test]
    fn test_a_zero_capacity_is_rejected() {
        match parse_capacity("OCS_MAX_CACHED_SNAPSHOTS", Some("0"), 256) {
            Err(ChainError::Validation { field, reason }) => {
                assert_eq!(field, "OCS_MAX_CACHED_SNAPSHOTS");
                assert!(reason.contains("at least 1"), "{reason}");
            }
            other => panic!("expected a validation error, got {other:?}"),
        }
    }

    /// A capacity beyond the ceiling is rejected.
    #[test]
    fn test_a_capacity_beyond_its_ceiling_is_rejected() {
        match parse_capacity("OCS_MAX_CACHED_SNAPSHOTS", Some("99999999"), 256) {
            Err(ChainError::Validation { reason, .. }) => {
                assert!(reason.contains("must not exceed"), "{reason}");
            }
            other => panic!("expected a validation error, got {other:?}"),
        }
    }

    /// A capacity that does not parse fails by name.
    #[test]
    fn test_an_unparseable_capacity_fails_by_name() {
        match parse_capacity("OCS_MAX_CACHED_TAPES", Some("lots"), 64) {
            Err(ChainError::Validation { field, .. }) => {
                assert_eq!(field, "OCS_MAX_CACHED_TAPES");
            }
            other => panic!("expected a validation error, got {other:?}"),
        }
    }

    /// Both backends apply the same default retention.
    ///
    /// ADR 0001 §9.1 requires the in-memory and Redis stores to agree on
    /// expiry, and two independently-named defaults are exactly how that
    /// agreement drifts. There is now one number, and this asserts every path
    /// still reaches it.
    #[test]
    fn test_both_stores_default_to_the_configured_retention() {
        use crate::session::{DEFAULT_V2_RETENTION_SECS, InMemorySimulationStore};

        assert_eq!(DEFAULT_V2_RETENTION_SECS, DEFAULT_RETENTION_SECS);
        assert_eq!(
            InMemorySimulationStore::new().idle_retention(),
            SimulationV2Config::default().retention,
            "the in-memory store must apply the configured window"
        );
    }

    /// The configuration loads from the ambient environment.
    ///
    /// The service is deployed with these unset far more often than not, so the
    /// path that has to work is the one that takes every default.
    #[test]
    fn test_it_loads_from_the_environment() {
        match SimulationV2Config::from_env() {
            Ok(config) => {
                assert!(config.retention.as_secs() >= 1);
                assert!(config.max_cached_tapes >= 1);
                assert!(config.max_cached_snapshots >= 1);
            }
            Err(error) => panic!("the ambient environment must load: {error}"),
        }
    }
}