Skip to content

joaquinbejar/IronCondor

Repository files navigation

Dual License Crates.io Downloads Stars Issues PRs

Build Status Dependencies 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

Contribution and Contact

We welcome contributions to this project! If you would like to contribute, please follow these steps:

  1. Fork the repository.
  2. Create a new branch for your feature or bug fix.
  3. Make your changes and ensure that the project still builds and all tests pass.
  4. Commit your changes and push your branch to your forked repository.
  5. Submit a pull request to the main repository.

If you have any questions, issues, or would like to provide feedback, please feel free to contact the project maintainer:

Contact Information

We appreciate your interest and look forward to your contributions!

License: MIT

About

High-performance backtesting engine for options strategies with order-book-level fill simulation

Topics

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages