Skip to main content

Crate ironcondor

Crate ironcondor 

Source
Expand description

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

FeatureDefaultWhat it adds
(none)yesReplay engine, naive execution, Parquet/CSV historical feeds, and the result bundle.
orderbookRealistic, liquidity-aware fills routed through option-chain-orderbook.
simulatorSynthetic chain sessions from OptionChain-Simulator over HTTP.
pythonPyO3 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

Re-exports§

pub use analytics::Metrics;
pub use batch::BATCH_INDEX_SCHEMA;
pub use batch::BatchIndex;
pub use batch::BatchRunEntry;
pub use batch::BatchRunOutcome;
pub use batch::SimulatorMaterialisation;
pub use batch::run_scenario_batch;
pub use bundle::BUNDLE_SCHEMA;
pub use bundle::EQUITY_CURVE_SORT_COLUMNS;
pub use bundle::FILLS_SORT_COLUMNS;
pub use bundle::GREEKS_ATTRIBUTION_SORT_COLUMNS;
pub use bundle::Manifest;
pub use bundle::POSITIONS_SORT_COLUMNS;
pub use bundle::RowCounts;
pub use bundle::RunId;
pub use bundle::ValidatedBundle;
pub use bundle::ValidatedManifest;
pub use bundle::equity_sort_key;
pub use bundle::fill_sort_key;
pub use bundle::greeks_sort_key;
pub use bundle::position_sort_key;
pub use bundle::read_bundle;
pub use bundle::write_bundle;
pub use config::BacktestConfig;
pub use config::FeeSchedule;
pub use config::LiquidityProfile;
pub use config::ResourceLimits;
pub use config::SlippageModel;
pub use config::TouchSize;
pub use data::CsvFeed;
pub use data::DataFeed;
pub use data::DataSourceSpec;
pub use data::FeedKind;
pub use data::ParquetFeed;
pub use data::RawQuote;
pub use data::SnapshotMeta;
pub use data::TapeMeta;
pub use data::feed_catalogue;
pub use data::raw_quotes_to_snapshot;
pub use data::snapshot_to_option_chain;
pub use domain::Cents;
pub use domain::ChainSnapshot;
pub use domain::ContractKey;
pub use domain::EquityPoint;
pub use domain::ExecutionMode;
pub use domain::Fill;
pub use domain::FillRow;
pub use domain::GreeksAttributionRow;
pub use domain::InstrumentSpec;
pub use domain::IronCondorSpec;
pub use domain::OpenPosition;
pub use domain::OrderCommand;
pub use domain::OrderId;
pub use domain::OrderIntent;
pub use domain::PendingOrder;
pub use domain::PositionAction;
pub use domain::PositionId;
pub use domain::PositionRow;
pub use domain::PriceCents;
pub use domain::Quantity;
pub use domain::QuoteView;
pub use domain::ShortStrangleSpec;
pub use domain::SimTime;
pub use domain::StepIndex;
pub use domain::StrategySpec;
pub use domain::Ticks;
pub use domain::TimeInForce;
pub use domain::TradeId;
pub use domain::Underlying;
pub use engine::AttributionSubstrate;
pub use engine::BacktestEngine;
pub use engine::BacktestRun;
pub use engine::ChainContext;
pub use engine::ConfigOverride;
pub use engine::Event;
pub use engine::FillRecord;
pub use engine::Ledger;
pub use engine::LegAttributionSample;
pub use engine::OptStratAdapter;
pub use engine::PositionMark;
pub use engine::PositionSnapshot;
pub use engine::PositionableStrategy;
pub use engine::ScenarioParams;
pub use engine::ScenarioType;
pub use engine::SimClock;
pub use engine::StepAttributionScalars;
pub use engine::Strategy;
pub use engine::UnitGreeks;
pub use engine::WalkPreset;
pub use engine::child_data_seed;
pub use engine::child_seed;
pub use engine::expand;
pub use error::BacktestError;
pub use execution::ExecutionModel;
pub use execution::FillGroup;
pub use execution::NaiveFill;
pub use run::run_backtest;

Modules§

analytics
Post-run analytics.
batch
Scenario batch runner — the multi-run composition root.
bundle
The result bundle.
config
Backtest configuration.
data
Chain data feeds.
domain
Canonical domain types.
engine
The deterministic replay engine.
error
The typed error boundary of the crate.
execution
Fill simulation: the ExecutionModel seam and the shared Fill assembly both fill models emit through.
run
The end-to-end entry: Parquet in, equity curve out.