optionchain_simulator 0.2.15

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.
//! Configurable upper bounds for client-supplied simulation parameters.
//!
//! These caps protect the service from pathological requests (an enormous number of
//! steps, an oversized option chain, or a huge historical price series) that would
//! otherwise blow up memory or CPU. Each limit is read once from an environment
//! variable via [`std::sync::LazyLock`]; an unset or invalid value falls back to the
//! documented default and emits a `tracing::warn!`.
//!
//! | Env var                    | Default   | Meaning                                  |
//! |----------------------------|-----------|------------------------------------------|
//! | `OCS_MAX_STEPS`            | `10_000`  | Max simulation steps per session         |
//! | `OCS_MAX_CHAIN_SIZE`       | `500`     | Max option-chain size per request        |
//! | `OCS_MAX_HISTORICAL_PRICES`| `100_000` | Max historical prices in a walk request  |
//! | `OCS_EXPORT_BLOCK_ROWS`    | `4_096`   | Rows per block in a binary export        |

use std::sync::LazyLock;
use tracing::warn;

/// Default cap on the number of simulation steps per session.
pub(crate) const DEFAULT_MAX_STEPS: usize = 10_000;
/// Default cap on the option-chain size per request.
pub(crate) const DEFAULT_MAX_CHAIN_SIZE: usize = 500;
/// Default cap on the number of historical prices in a `Historical` walk request.
pub(crate) const DEFAULT_MAX_HISTORICAL_PRICES: usize = 100_000;
/// Default number of rows in one binary export block.
///
/// The binary encodings are columnar, so a column cannot be written until its
/// last row is known: rows are therefore buffered a block at a time, and this
/// is what the export's memory is a function of instead of the number of steps.
/// Four thousand rows of the widest dataset is a few megabytes, small enough to
/// stream and wide enough that the per-block overhead disappears.
pub(crate) const DEFAULT_EXPORT_BLOCK_ROWS: usize = 4_096;

/// Maximum number of simulation steps a session may request (`OCS_MAX_STEPS`).
pub(crate) static MAX_STEPS: LazyLock<usize> = LazyLock::new(|| {
    parse_limit(
        crate::utils::env::read_var("OCS_MAX_STEPS"),
        DEFAULT_MAX_STEPS,
    )
});

/// Maximum option-chain size a request may ask for (`OCS_MAX_CHAIN_SIZE`).
pub(crate) static MAX_CHAIN_SIZE: LazyLock<usize> = LazyLock::new(|| {
    parse_limit(
        crate::utils::env::read_var("OCS_MAX_CHAIN_SIZE"),
        DEFAULT_MAX_CHAIN_SIZE,
    )
});

/// The widest a binary export block may be configured.
///
/// This knob sets the export's memory floor directly — a block is buffered
/// whole before it is written — so it is the one limit whose own upper bound
/// matters. A quarter of a million rows of the widest dataset is already
/// hundreds of megabytes.
pub(crate) const MAX_EXPORT_BLOCK_ROWS: usize = 262_144;

/// Rows per block in the binary export encodings (`OCS_EXPORT_BLOCK_ROWS`).
pub(crate) static EXPORT_BLOCK_ROWS: LazyLock<usize> = LazyLock::new(|| {
    let configured = parse_limit(
        crate::utils::env::read_var("OCS_EXPORT_BLOCK_ROWS"),
        DEFAULT_EXPORT_BLOCK_ROWS,
    );
    if configured > MAX_EXPORT_BLOCK_ROWS {
        warn!(
            configured,
            maximum = MAX_EXPORT_BLOCK_ROWS,
            "OCS_EXPORT_BLOCK_ROWS above the maximum; using the maximum"
        );
        return MAX_EXPORT_BLOCK_ROWS;
    }
    configured
});

/// Maximum number of historical prices a `Historical` walk may carry
/// (`OCS_MAX_HISTORICAL_PRICES`).
pub(crate) static MAX_HISTORICAL_PRICES: LazyLock<usize> = LazyLock::new(|| {
    parse_limit(
        crate::utils::env::read_var("OCS_MAX_HISTORICAL_PRICES"),
        DEFAULT_MAX_HISTORICAL_PRICES,
    )
});

/// The number of strikes a chain of `chain_size` carries, or `None` when the
/// count does not fit.
///
/// `chain_size` is upstream's per-side half-width — it counts strikes above
/// *and* below the money — so the ladder is `2n + 1` wide. Checked rather than
/// saturating: a saturating width would silently answer a different question
/// than the caller asked, and the caller is a validator whose whole job is to
/// reject what it cannot serve.
#[must_use]
pub(crate) fn strikes_per_chain(chain_size: usize) -> Option<usize> {
    chain_size.checked_mul(2)?.checked_add(1)
}

/// Parses a raw environment value into a positive `usize` limit.
///
/// Returns `default` when `raw` is `None` — unset, or blank, which
/// [`crate::utils::env::read_var`] reads as unset — or when it does not parse
/// into an integer `>= 1`. Invalid values are logged at `WARN` and never abort
/// startup, keeping the service resilient to misconfiguration. A blank value
/// reaches this as `None`, so it falls back SILENTLY: it is a knob someone
/// commented out, not a mistake worth warning about.
///
/// # Examples
///
/// ```ignore
/// assert_eq!(parse_limit(None, 10), 10);
/// assert_eq!(parse_limit(Some("42".to_string()), 10), 42);
/// assert_eq!(parse_limit(Some("nope".to_string()), 10), 10);
/// ```
#[must_use]
pub(crate) fn parse_limit(raw: Option<String>, default: usize) -> usize {
    match raw {
        None => default,
        Some(value) => match value.trim().parse::<usize>() {
            Ok(parsed) if parsed >= 1 => parsed,
            _ => {
                warn!(
                    raw = %value,
                    default,
                    "invalid limit value; falling back to default"
                );
                default
            }
        },
    }
}

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

    #[test]
    fn test_parse_limit_unset_uses_default() {
        assert_eq!(parse_limit(None, 10), 10);
    }

    #[test]
    fn test_parse_limit_valid_value_is_used() {
        assert_eq!(parse_limit(Some("42".to_string()), 10), 42);
    }

    #[test]
    fn test_parse_limit_trims_whitespace() {
        assert_eq!(parse_limit(Some("  25  ".to_string()), 10), 25);
    }

    #[test]
    fn test_parse_limit_non_numeric_falls_back() {
        assert_eq!(parse_limit(Some("not-a-number".to_string()), 10), 10);
    }

    #[test]
    fn test_parse_limit_zero_falls_back() {
        assert_eq!(parse_limit(Some("0".to_string()), 10), 10);
    }

    #[test]
    fn test_parse_limit_negative_falls_back() {
        assert_eq!(parse_limit(Some("-5".to_string()), 10), 10);
    }

    #[test]
    fn test_default_limits_match_documentation() {
        // Without env overrides the parsed limits equal the documented defaults.
        assert_eq!(*MAX_STEPS, DEFAULT_MAX_STEPS);
        assert_eq!(*MAX_STEPS, 10_000);
        assert_eq!(*MAX_CHAIN_SIZE, DEFAULT_MAX_CHAIN_SIZE);
        assert_eq!(*MAX_CHAIN_SIZE, 500);
        assert_eq!(*MAX_HISTORICAL_PRICES, DEFAULT_MAX_HISTORICAL_PRICES);
        assert_eq!(*MAX_HISTORICAL_PRICES, 100_000);
        assert_eq!(*EXPORT_BLOCK_ROWS, DEFAULT_EXPORT_BLOCK_ROWS);
        assert_eq!(*EXPORT_BLOCK_ROWS, 4_096);
        assert_eq!(MAX_EXPORT_BLOCK_ROWS, 262_144);
    }
}