Realistic limit-order fill simulator for options credit/debit spreads. Engine-agnostic, data-source-agnostic.
Project description
flashalpha-fill-simulator
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}")
Why this exists
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.
Use it from anywhere
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.
Install
pip install flashalpha-fill-simulator
Zero runtime dependencies. Python 3.10+.
What's modeled
| 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(...) |
What's NOT modeled
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
Documentation
- 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.
Tests
pip install -e ".[test]"
pytest
60+ 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
Real-data integration tests
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.py
If 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.
Contributing
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
License
MIT. See LICENSE.
Provenance
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file flashalpha_fill_simulator-0.2.1.tar.gz.
File metadata
- Download URL: flashalpha_fill_simulator-0.2.1.tar.gz
- Upload date:
- Size: 48.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
779fd082daeabe2e04b1c70b1190ef1105037e967fb2e3f0d62b6d32736a1bf5
|
|
| MD5 |
9c257cf77f299c87588d17be128b5fd6
|
|
| BLAKE2b-256 |
388f0e95fb4c6488966e4b2cd7c9d946c7028b9b743feb6b160c14502c5ea89f
|
File details
Details for the file flashalpha_fill_simulator-0.2.1-py3-none-any.whl.
File metadata
- Download URL: flashalpha_fill_simulator-0.2.1-py3-none-any.whl
- Upload date:
- Size: 22.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
950177cc32faa3140a96166a2d4693d7d72c4403a44a109b726e3e089b3d6e11
|
|
| MD5 |
8c466cec3ba2d0979cacbc7ce443678d
|
|
| BLAKE2b-256 |
76edcf538a34a5d3a916b0964bc0a0b9459dddf56769fbf2b48dd082a8d6a426
|