Realistic limit-order fill simulator for options credit/debit spreads.
Realistic fills are where most options backtests lie to you. This simulator is calibrated against the FlashAlpha Historical API (Alpha tier) so your backtested fills match what actually traded. Background: Fill model is the edge
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.
Free and entry tiers cover live exposure analytics. The Alpha tier ($1,499/mo) adds the data you cannot get anywhere else:
- Aggregate vanna and charm exposure. FlashAlpha is the only public source for these dealer-positioning aggregates.
- Point-in-time replay since 2018. Backtest and trade the same code, with no look-ahead and no training-serving skew.
- SVI vol surfaces, VRP analytics, higher-order Greeks, uncached and unlimited.
Built for quants, prop desks, and vol funds. See the full picture and get a key: flashalpha.com/for-quant-teams