Deterministic options screener
Every option contract in the chain, scored 0-100 and graded A through F.
A local FastAPI app that pulls real US equity option chains, re-derives implied volatility and the Greeks itself with Black-Scholes-Merton, and hands back every contract with a score and a letter. Run it on one ticker, or sweep the whole optionable universe (roughly 6,000 names) for a ranked board.
This is the calculating companion to shadow-options-trading-lab. The lab runs strategies. This repo does the option math and the scoring. They are separate programs by design: no shared code, no shared database.
Option chains from CBOE's free delayed feed by default, or Tradier for real-time. Both sit behind one four-method provider interface.
The vendor IV and Greeks are discarded. Sigma is inverted out of each contract's own mid price, and all five Greeks come from that sigma.
Value, odds, liquidity, volatility value, move needed, decay, leverage. One weighted composite, one letter, one plain-English reason per sub-score.
Scope is deliberately narrow: long single-leg positions, buying calls and puts. No spreads, no selling premium. Scoring is deterministic, so the same chain in gives the same grades out. Nothing is sampled, fitted, or trained.
The vendor's numbers are not the numbers you are trading against. Tradier's IV and Greeks come from a third-party model refreshed roughly hourly; CBOE's are a delayed snapshot. Both drift against the mid you would actually pay.
So the solver in app/engine/blackscholes.py re-derives sigma per contract, and it is not a naive Newton loop:
None.[1e-4, 5.0] the moment Vega drops under 1e-10.None, not a spurious root.When the solve fails, the contract is neither dropped nor faked. It falls back to the vendor IV and carries a visible flag saying so.
Greeks use chain-quote conventions. Theta per calendar day (divided by 365), vega and rho per one percentage point (divided by 100). Both are pinned by tests, because a factor-of-365 mismatch produces plausible-looking nonsense.
Realized volatility is Yang-Zhang, combining the overnight jump, the open-to-close move, and a Rogers-Satchell drift-independent term, with a close-to-close fallback when the estimator returns a non-positive variance. The highest-weighted sub-score prices the contract at realized vol, so a bad HV estimate moves the top of the board.
The liquidity gate is a hard cap, not a penalty. Spread over 15% of mid, or open interest under 50, clamps the composite to 39.0 and grade F no matter how good the other six look. If the quote is that wide, the mid is fiction, so everything downstream is fiction.
| Sub-score | Weight | What it measures |
|---|---|---|
| Value (Price Edge) | 30 | BSM fair value at realized vol vs. the market mid |
| Odds of Profit | 20 | N(d2) at the break-even price |
| Liquidity | 20 | 0.6 spread + 0.25 log-scaled OI + 0.15 log-scaled volume |
| Volatility Value | 12 | IV/HV ratio, blended 60/40 with inverted IV rank when it exists |
| Move Needed | 10 | Break-even move over the 1-standard-deviation move |
| Time-Decay Risk | 4 | Theta as a fraction of premium per day, halved under 7 DTE |
| Leverage & Risk | 4 | Gaussian bump centered on absolute delta 0.45 |
Weights and grade bands live in app/config.py, alongside 25 environment-variable knobs. No threshold is hardcoded in logic.
Sweeping 6,000 names is a cost-reduction funnel, not a loop. A cached price pre-pass prunes the universe before a single option chain is downloaded.
universe (liquidity-ordered, ~6,000 names)
|
[1] price + HV pre-pass
| SQLite TTL cache first (default 12h)
| then batched quotes (Tradier, 100/request)
| or a bounded thread pool of Yahoo chart calls
v
[2] prune to the requested stock-price band
v
[3] fetch + score chains for survivors only
| <= 300 in-band names + a token -> real-time Tradier
| otherwise -> CBOE bulk delayed
v
[4] global rank, then a per-underlying board cap (default 2)
v
Top N + notes explaining exactly what was covered
The sweep tracks price and chain failures separately and emits notes: names priced versus attempted, budget truncation, chain-fetch failures with example symbols, the routing mode actually used, and a data-as-of timestamp. Two tests assert those notes say what they claim.
Neither feed serves historical implied volatility, so each scan snapshots the ATM IV into SQLite, one row per symbol per day. Below 10 observations, rank returns nothing and the API says so instead of guessing.
No API key. The default CBOE provider runs on the public delayed feed with zero credentials. Install into a virtualenv.
git clone https://github.com/csnyder256/option-contract-grader
cd option-contract-grader
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.api:app --reload
# open http://localhost:8000
Six dependencies total: five runtime plus pytest. The frontend is vanilla JavaScript with no build step and no packages. On Windows, setup.bat and start.bat do the same thing without a terminal.
Tests: 40 test functions across five modules, 49 cases, all fully offline with no network and no mocking library. Verified in a clean virtualenv on Python 3.14: 49 passed in 2.14s.
The full list, including two known stale UI labels and a config drift between .env.example and the built-in default, is in the repository README.