ironcondor 0.5.0

High-performance backtesting engine for options trading strategies with order-book-level fill simulation. Built on OptionStratLib.
Documentation

IronCondor

IronCondor is a high-performance backtester for options-trading strategies with order-book-level fill simulation, written in Rust.

It is the deterministic replay engine around an upstream options stack, not a re-implementation of it. Pricing, Greeks, multi-leg strategies, and exit policies come from optionstratlib; order matching comes from option-chain-orderbook and orderbook-rs; synthetic option chains come from OptionChain-Simulator. This crate contributes the replay loop, the dual fill models, P&L attribution by Greek, the result bundle, and the Python bindings.

Why order-book-level fills

Most backtesters fill option orders at mid or bid/ask with a fixed slippage assumption. IronCondor adds a realistic mode that routes every order through a real options matching engine — queue position, partial fills, multi-level book walks, and a resting GTC lifecycle — so fill risk is an emergent property of the book rather than a guess. A fast naive mode (mid/spread plus configurable slippage) stays available for quick iteration, and both modes emit the identical fill-report shape, so downstream analytics is mode-agnostic.

Key properties

  • Deterministic replay. For a fixed (seed, config, data, crate version, Rust toolchain, lockfile) the four Parquet tables are byte-identical and the manifest is identical minus its one wall-clock provenance field. Across environments the guarantee is logical equivalence under a documented normalization (canonical row ordering, canonical JSON). No wall clock, no unseeded RNG, and no map-iteration order reaches a result.
  • Money as integer cents. Every execution and result boundary carries money as integer cents; order-book prices are u128 ticks. f64 is confined to the upstream pricing/Greeks kernel and the documented derived-analytics columns.
  • Dual execution modes. naive (mid/spread plus slippage) and realistic (a real order book: queue position, partial fills, multi-level walks, resting GTC orders), selected once from config with no per-step dynamic dispatch.
  • P&L attribution by Greek. Each step's mark-to-market change is decomposed into theta, delta, vega, and spread capture minus fees, closed by an exact integer-cents residual: for every step, theta + delta + vega + spread − fees + residual equals the equity change by construction. A large residual is an advisory model-quality signal, never a run failure.
  • A frozen result bundle. Every run publishes an ironcondor.bundle.v1 directory (a manifest plus four Parquet tables) consumed by ChainView.
  • Hardened against untrusted input. The engine parses untrusted files and runs inside CI, so every external input pairs a validation with a resource ceiling and a typed error — no panic, hang, or OOM on malformed input. Parsers are fuzzed, the crate is #![forbid(unsafe_code)], errors are the typed BacktestError, and none cross the Python boundary as a panic.
  • Performance as an acceptance criterion. The replay loop holds zero steady-state allocation, and the hot paths (loop, fill models, conversion, bundle writer, PyO3 boundary) carry CI-gated budgets measured with criterion and hdrhistogram. As a recorded baseline (see BENCH.md), a full run_backtest over a 2048-step, four-leg iron-condor Parquet chain runs at a p50 of about 2.35 µs/step (about 425k steps/sec/core) in naive mode on an Apple M4 Max — a measurement, not a guarantee.

Feature flags

Feature Default What it adds
(none) yes Replay engine, naive execution, Parquet/CSV historical feeds, and the result bundle.
orderbook Realistic, liquidity-aware fills routed through option-chain-orderbook.
simulator Synthetic chain sessions from OptionChain-Simulator over HTTP.
python PyO3 bindings, built as a wheel with maturin (PyPI publication planned).

Quick start (Rust)

Drive one backtest end to end — a Parquet chain in, an equity curve out:

use ironcondor::{
    BacktestConfig, DataSourceSpec, ExecutionMode, FeeSchedule, IronCondorSpec,
    LiquidityProfile, PriceCents, Quantity, ResourceLimits, SlippageModel,
    StrategySpec, Underlying, run_backtest,
};
use optionstratlib::ExpirationDate;
use optionstratlib::simulation::ExitPolicy;
use rust_decimal::Decimal;

fn main() -> Result<(), ironcondor::BacktestError> {
    let config = BacktestConfig {
        data_source: DataSourceSpec::Parquet {
            path: "chains/spx.parquet".to_string(),
            sha256: String::new(),
        },
        mode: ExecutionMode::Naive,
        seed: 42,
        initial_capital: 10_000_000, // $100,000, in cents
        fees: FeeSchedule { per_contract_cents: 65, per_order_cents: 100 },
        slippage: SlippageModel::None,
        marketable_cap_ticks: 10,
        liquidity_profile: LiquidityProfile::default(),
        limits: ResourceLimits::default(),
        output_dir: "runs".into(),
        overwrite: false,
    };

    let strategy = StrategySpec::IronCondor(IronCondorSpec {
        underlying: Underlying::new("SPX")?,
        underlying_price: PriceCents::new(500_000),
        short_call_strike: PriceCents::new(510_000),
        short_put_strike: PriceCents::new(490_000),
        long_call_strike: PriceCents::new(520_000),
        long_put_strike: PriceCents::new(480_000),
        expiration: ExpirationDate::DateTime(
            chrono::DateTime::from_timestamp_nanos(1_750_291_200_000_000_000),
        ),
        implied_volatility: Decimal::new(20, 2), // 0.20
        risk_free_rate: Decimal::new(5, 2),      // 0.05
        dividend_yield: Decimal::ZERO,
        quantity: Quantity::new(1)?,
        premium_short_call: PriceCents::new(2_000),
        premium_short_put: PriceCents::new(1_800),
        premium_long_call: PriceCents::new(800),
        premium_long_put: PriceCents::new(700),
        open_fee: PriceCents::new(65),
        close_fee: PriceCents::new(65),
    });

    // A non-triggering exit so the run marks every step and closes at the end.
    let run = run_backtest(&config, &strategy, ExitPolicy::TimeSteps(1_000_000))?;
    println!(
        "{}: {} equity points",
        run.result.strategy_name,
        run.equity_curve.len(),
    );
    Ok(())
}

Quick start (Python)

The python feature builds a PyO3 extension module. Wheels are not yet on PyPI; build one locally with maturin:

maturin develop --release --features python,orderbook,simulator
import ironcondor as ic

config = (
    ic.BacktestConfig(seed=42, capital_cents=10_000_000)
    .data_parquet("chains/spx.parquet")
    .strategy_iron_condor(
        underlying="SPX",
        underlying_price_cents=500_000,
        short_call_strike_cents=510_000,
        short_put_strike_cents=490_000,
        long_call_strike_cents=520_000,
        long_put_strike_cents=480_000,
        expiration_ns=1_750_291_200_000_000_000,
        quantity=1,
        premium_short_call_cents=2_000,
        premium_short_put_cents=1_800,
        premium_long_call_cents=800,
        premium_long_put_cents=700,
        implied_volatility=0.20,
        risk_free_rate=0.05,
        dividend_yield=0.0,
        open_fee_cents=65,
        close_fee_cents=65,
    )
    .execution_naive()
    .fees(per_contract_cents=65, per_order_cents=100)
    .exit_time_steps(1_000_000)
    .output_dir("runs")
)

bundle = ic.run(config)          # publishes an ironcondor.bundle.v1 directory
print(bundle.metrics())          # summary metrics as a dict
equity = bundle.equity_curve()   # a pandas DataFrame with integer-cents columns

The result bundle

Every run publishes an ironcondor.bundle.v1 directory: a manifest.json (run metadata, strategy, config, data source, code version) plus four Parquet tables — fills.parquet, equity_curve.parquet, positions.parquet, and greeks_attribution.parquet. Writes are atomic (temp file plus rename). The schema tag is frozen and its lineage is coordinated with ChainView, which consumes the bundle in replay mode — so a schema change is a deliberate SemVer event, not an accident.

Status and versioning

0.5.0 completes the v0.1–v0.5 roadmap — the engine, both fill models, the full analytics and result bundle, and the Python bindings — with the v1.0 stability gates wired: the Rust public surface, the configuration surface, and the bundle schema are each pinned by a committed snapshot that fails CI on drift. Under SemVer 0.x, breaking changes may still land in minor releases; the 1.0 cut follows the documented one-quarter stability window. Documentation states present-tense claims only for behaviour that exists, and no benchmark number is written before it is measured.

Ecosystem

Part of a family of Rust crates for options-trading infrastructure: OptionStratLib · Option-Chain-OrderBook · OrderBook-rs · OptionChain-Simulator · ChainView

Contact

Joaquin Bejar — jb@taunais.com

License

MIT — see LICENSE.