Realistic limit-order fill simulator for options credit/debit spreads.
Engine-agnostic. Data-source-agnostic. Zero runtime dependencies.
Most options-credit-spread backtests fill at mid (or at bid/ask without queueing). Both lie. This library models what actually happens when you post a limit at MM-edge against a 1-min option chain (or any tick stream): you sit on the book until someone else's order crosses your price, with stale-quote guards, deterministic tiebreaking, and a patient-then-cross exit. It's the substrate, not a strategy.
from datetime import date, datetime
from fillsim import simulate_fill, Spread, Leg, Config
# A vertical credit spread you've decided to post
spread = Spread(
short=Leg(strike=440, bid=1.30, ask=1.30),
long=Leg(strike=435, bid=0.86, ask=0.88),
limit_credit=0.40,
width=5.0,
expiry=date(2026, 5, 15),
)
# The chain at the bar you're checking
chain_at_bar = {
(date(2026, 5, 15), 440.0): (1.30, 1.30),
(date(2026, 5, 15), 435.0): (0.86, 0.88),
}
bar = simulate_fill(
bar_ts=datetime(2026, 4, 15, 10, 5),
chain=chain_at_bar,
candidates=[spread],
)
if bar.fill is not None:
print(f"filled at {bar.fill.fill_price:.2f}, edge_captured={bar.fill.edge_captured:+.2f}")
else:
print(f"no fill, near_misses={bar.near_misses}")Pick any "this strategy returned 5,000% in backtest" credit-spread post and check the fill model. It's almost always implicit mid-fills. Returns drop dramatically the moment you model:
- Post-and-wait limits (you don't fill until someone crosses your price)
- Stale-quote crosses (a one-tick blip in
biddoesn't mean you'd really get filled) - Random tiebreak when multiple candidates cross the same bar (any EV-aware tiebreak is a forward-looking oracle)
- Exit limits that don't walk down (your stop-loss has to actually fill at a real ask)
This library models all of those. None of the magic numbers are tuned to make a specific strategy look good — they were calibrated against the edge_captured distribution of an early permissive run, then frozen.
The headline API is a per-bar primitive — one stateless function that takes a bar's quotes and a list of open limit candidates, returns whether any fill happened on that bar:
def simulate_fill(
bar_ts: datetime,
chain: dict[tuple[date, float], tuple[float, float]], # (expiry, strike) → (bid, ask)
candidates: list[Spread],
config: Config = Config(),
) -> BarResult: ...This makes the simulator embed in:
- QuantConnect — call it from your
OnDatahandler - Backtrader — call it from
next() - Live trading bots — call it on each market-data update
- Custom backtesters — drop-in replacement for naive
if combo_mid <= limit:fill logic - EOD strategies — works the same way; the simulator doesn't assume any specific bar resolution
For offline backtests with all the data up-front, loop-driving convenience wrappers are also shipped. right defaults to "PUT" and can be set to "CALL" for call-spread chains:
from fillsim import InMemoryChainProvider, simulate_fills
provider = InMemoryChainProvider(quotes=[...])
result = simulate_fills(posted_ts, candidates, provider, right="PUT")
if result.filled:
print(f"filled in {result.bars_waited} bars; saw {result.near_misses} near-misses")CSVChainProvider is available for tidy CSV exports with ts, expiry, strike, right, bid, and ask columns.
pip install flashalpha-fill-simulatorZero runtime dependencies. Python 3.10+.
| feature | configurable via |
|---|---|
| post-and-wait limit fills | Config.fill_max_wait_bars |
| stale-quote guard at fill | Config.min_edge_floor |
| epsilon over limit required to count as a fill | Config.fill_epsilon |
| relative-spread quote-quality filter | Config.fill_max_rel_spread |
| same-bar tiebreak (deterministic, EV-blind) | seeded by bar timestamp |
| multi-expiry candidate pools | per-candidate expiry field |
| patient exit (limit-then-market-out) | Config.exit_mode = "patient" |
| simpler exit modes (mid / ask) | Config.exit_mode = "mid" | "ask" |
| exit wait window | Config.exit_max_wait_bars |
| at-expiry intrinsic settlement | expiry_settlement_pnl(...) |
These are intentional simplifications. See docs/SPEC.md §7 for the full list.
- Queue position / size impact (works for retail/prop scale, breaks down at institutional size)
- Commissions / fees (caller subtracts them)
- Borrow/financing on cash collateral
- Early assignment risk
- Pin risk at expiry (linear interpolation only)
- Hard exchange halts
- docs/SPEC.md — full behavioural contract. Read this before relying on any number the simulator produces.
- docs/examples/ — runnable examples, no broker/data feed required.
- CHANGELOG.md — version history.
pip install -e ".[test]"
pytest60+ tests, <2s wall time. CI enforces ruff, formatting, coverage, and type checks. The mandatory regression tests cover:
- EV-oracle: same-bar tiebreak never reverts to EV/rank ordering
- Stale-quote: invalid wide/crossed quotes cannot create fills
- Exit realism: patient exit does not walk the limit down
- Boundary: every threshold (
fill_epsilon,min_edge_floor,exit_max_wait_bars) has a test asserting the correct boundary semantics
Beyond the synthetic-chain unit tests, the suite includes 11 integration scenarios driven by real SPY put-chain data (tests/fixtures/real_data/spy_2024_06_03.json). The fixture is checked in so the suite runs offline, but it was pulled minute-by-minute from the FlashAlpha Historical Options API — the same data product the simulator was originally tuned against:
FA_API_KEY=... python scripts/fetch_real_data.pyIf you want to run the simulator against your own quotes, historical.flashalpha.com covers SPY at 1-min resolution since 2018 plus 6,000+ US equities/ETFs, with greeks, IV surfaces, and dealer exposure pre-computed. Free for evaluation; paid plans for production. The fetch script is self-contained — adapt it to any chain provider you prefer.
PRs welcome. See CONTRIBUTING.md. For behavioral changes, update docs/SPEC.md and add a synthetic-chain regression test.
Particularly wanted:
- Additional
ChainProvideradapters (Polygon, Tradier, IBKR, dxFeed, ...) - Property-based tests via Hypothesis
- A
quantconnect-fillsimcompanion package showing how to wire it into a QC algorithm
MIT. See LICENSE.
Extracted from FlashAlpha's internal SPY VRP-harvest backtester. The simulator was built specifically because every off-the-shelf options backtest framework we evaluated assumed mid-fills, and our strategy returns flipped from "+5,400%" to "ambiguous" the moment we modeled execution honestly. Open-sourcing the substrate so others don't have to relearn that lesson the hard way.