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 scored 0-100 with a letter grade. It runs for a single ticker, or it sweeps the whole optionable universe (roughly 6,000 names) and returns a ranked board of the best contracts it found.
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.
- Fetches the option chain for a ticker (or for every name in the universe) from CBOE's free delayed feed or from Tradier.
- Throws away the vendor's IV and Greeks, and solves for implied volatility from each contract's own mid price.
- Scores each contract on seven independent dimensions, blends them into one weighted composite, and assigns a letter.
- Sorts, applies a per-underlying board cap, and returns the top contracts with a plain-English explanation attached to every sub-score.
Scope: long single-leg positions only, buying calls and puts. No spreads, no selling premium. The config file describes itself as buyer-tuned.
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, and CBOE's are a delayed snapshot. Both drift against the mid price you would actually pay. And nobody sells you historical implied volatility for free, so IV rank, the single most useful "is this expensive right now" signal, is not in either feed. This app computes the first from the quote and bootstraps the second from its own daily snapshots.
The solver in app/engine/blackscholes.py is not a naive Newton loop:
- It checks the no-arbitrage bounds first. Below discounted intrinsic, or above discounted spot (calls) / discounted strike (puts), there is no valid sigma and it returns
None. - It seeds with the Brenner-Subrahmanyam ATM approximation instead of a blind 0.20.
- It runs Newton-Raphson on Vega, and bails out to a 200-iteration bisection over
[1e-4, 5.0]the moment Vega drops under1e-10or sigma leaves the sensible region. - If the bracket has no sign change, it returns
Nonerather than a spurious root. A garbage IV would poison four of the seven sub-scores, so no answer beats a wrong one.
When the solve fails the contract is not silently dropped and it is not silently faked. It falls back to the vendor IV and carries the flag Using vendor IV (couldn't solve from price). If there is no clean IV at all, the flag reads No clean IV (illiquid / one-sided market). That fallback path is why "does not trust vendor Greeks" is a description of behavior rather than a slogan.
Two more things worth pointing at:
Greeks come back in chain-quote conventions, not textbook units. Theta is divided by 365 (per calendar day), vega and rho by 100 (per one percentage point). The conventions are documented in the module docstring and pinned by test_greek_signs and test_put_call_parity, because a factor-of-365 mismatch is the kind of bug that silently produces plausible-looking nonsense.
Realized volatility is Yang-Zhang, not close-to-close. It combines the overnight jump, the open-to-close move, and a Rogers-Satchell drift-independent term, with an automatic fallback to close-to-close when the estimator yields a non-positive variance. This matters because the highest-weighted sub-score (Value, weight 30) prices the contract at realized vol and compares that to the market mid. A bad HV estimate moves the top of the board.
Seven independent 0-100 sub-scores blend into one weighted composite. Weights live in app/config.py as DEFAULT_WEIGHTS and sum to 100.
| Sub-score | Weight | What it measures |
|---|---|---|
| Value (Price Edge) | 30 | BSM fair value computed at realized vol vs. the actual market mid |
| Odds of Profit | 20 | N(d2) evaluated 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 an inverted IV rank when rank exists |
| Move Needed | 10 | Required break-even move divided by 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, exp(-((abs(delta) - 0.45) / 0.30)^2) |
Grade bands come from GRADE_BANDS in app/config.py. The wording below paraphrases the app's own copy, which is blunter than this table.
| Grade | Score | Meaning |
|---|---|---|
| A | 90-100 | Odds, price, liquidity and math all line up |
| B | 75-89 | Strong setup, a couple of things are not perfect |
| C | 60-74 | Playable but mediocre. No real edge either way |
| D | 40-59 | Weak. The price or the odds are working against you |
| F | 0-39 | Overpriced, illiquid, or the odds are ugly |
The liquidity gate is a hard cap, not a penalty. If the bid/ask spread exceeds 15% of mid, or open interest is under 50, the composite is clamped to 39.0 (grade F) no matter how good the other six look. The reasoning is in the code: if the quote is that wide, the mid is fiction, so the IV solved from it is fiction, so every downstream number is fiction.
Six human-readable flags can be attached to a contract, all in app/engine/scoring.py:
Expires today (0DTE) - extreme gamma riskNo price (no bid/ask/last)Using vendor IV (couldn't solve from price)No clean IV (illiquid / one-sided market)Under 7 DTE - decay & gamma risk are highIlliquid - overall capped; other scores unreliable
Every sub-score also carries a plain-English explanation string and a raw metric string, both rendered in the UI under a "Why this grade?" disclosure. You read one letter instead of interpreting delta against theta against open interest.
Scoring is deterministic. Same chain in, same grades out. Nothing is sampled, fitted, or trained.
Dependency flow is one-way: frontend to api.py, api.py to scanner.py and market.py, both of those down into engine/ and providers/, with store.py as the only stateful layer.
app/scanner.py holds the per-symbol core used by both the single-ticker endpoint and the market sweep: expiration selection, optional ATM strike pruning, the ATM IV solve and snapshot, the premium pre-filter, scoring, sort. It is deliberately free of FastAPI types so market.py can call it without a circular import.
| File | LOC | Role |
|---|---|---|
app/api.py |
262 | FastAPI surface. Providers and the store are lazily-created singletons, so importing the app never requires a token. StaticFiles is mounted last so API routes win. |
app/config.py |
153 | One dataclass, exactly 25 env-var knobs: weights, grade bands, liquidity thresholds, sweep tuning. No threshold is hardcoded in logic. |
app/models.py |
142 | Dataclasses. Contract derives mid / has_two_sided_market / spread_pct, falling back to last trade on a one-sided market. |
app/engine/blackscholes.py |
215 | BSM with continuous dividend yield q: bs_price, greeks, implied_vol, prob_itm. |
app/engine/volatility.py |
150 | Close-to-close and Yang-Zhang realized vol, IV/HV ratio, iv_rank, iv_percentile. |
app/engine/scoring.py |
270 | The seven sub-scores, the composite, the liquidity gate, the English strings. |
app/engine/grading.py |
41 | Score to letter plus meaning, and the grade_key() the UI legend reads. |
app/providers/base.py |
92 | Abstract OptionsDataProvider (four required methods) plus FeedError and two optional-optimization hooks with working defaults. |
app/providers/cboe.py |
332 | Pure parser functions plus a thin networked class. OCC symbol parsing, symbol-spelling fallbacks, in-process chain cache. |
app/providers/tradier.py |
304 | Same pure-parser / client split, but a different retry policy (429 only). Batched quotes, scalar-vs-array normalization, client-side rate limiting at 118 requests/minute against a 120 cap. |
app/scanner.py |
131 | The shared per-symbol core described above. |
app/market.py |
373 | Universe loading, the locked background state machine, and run_market_sweep as a testable function taking a progress callback and injectable providers. |
app/store.py |
164 | SQLite: iv_snapshots and underlying_cache, an in-place ALTER TABLE migration, check_same_thread=False plus an RLock so sweep workers share one connection. |
scripts/update_universe.py |
172 | Regenerates the optionable universe from the OCC directory, with fallbacks. |
frontend/ |
558 | Two tabs, live progress bar, client-side re-sort by any sub-score. Zero JS dependencies. |
Totals: 23 Python files, 3,465 lines (2,632 under app/, 661 under tests/, 172 in scripts/). Six dependencies in requirements.txt: five runtime (fastapi, uvicorn, httpx, pydantic, python-dotenv) and pytest.
Both sit behind the same four-method interface, so a third one is a drop-in. app/providers/base.py names Robinhood and ThetaData as the intended next backends.
| CBOE (default) | Tradier | |
|---|---|---|
| Credentials | none | brokerage API token |
| Freshness | roughly 15 minutes delayed | real-time on production |
| Chain fetch | one bulk JSON payload per name | one REST call per expiration |
| History | Yahoo public chart endpoint | Tradier history endpoint |
| Batch quotes | falls back to a per-symbol loop | POST /markets/quotes, 100 symbols per request |
Robinhood was evaluated and set aside for now. robin_stocks issues one request per contract with no published rate limit, so refreshing a single chain means hundreds of requests per second, which is a documented way to get an account blocked. Tradier serves a whole expiration in one call under a 120 requests/minute cap.
The two live providers do not retry identically. CBOE retries with exponential backoff plus jitter on 403/429/5xx and raises a typed FeedError carrying the symbol, so failures get counted and reported instead of silently vanishing. Tradier retries on HTTP 429 only, honoring the server's Retry-After header when present and falling back to exponential backoff (no jitter) when it is not; every other error status goes straight to raise_for_status() and surfaces as httpx.HTTPStatusError. Bringing Tradier up to the CBOE contract is listed under "Status and known rough edges" below.
| Method | Path | Purpose |
|---|---|---|
| POST | /scan |
One ticker plus filters, returns a graded, ranked list |
| POST | /market/scan |
Starts a background sweep, returns immediately |
| GET | /market/status |
Polls progress, phase, notes, and Top-N results |
| GET | /health |
Config sanity, provider in use, universe size |
| GET | /key |
Grade legend and sub-score labels |
| GET | / |
The static frontend |
curl -s localhost:8000/scan \
-H 'content-type: application/json' \
-d '{"ticker":"AAPL","side":"calls","limit":10}' | python -m json.toolThe sweep is the most engineered part of the repo. It is a cost-reduction funnel, not a loop over 6,000 tickers.
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
| (one call yields price AND history together)
v
[2] prune to the requested stock-price band
|
v
[3] fetch + score chains for survivors only
| <= 300 in-band names and a token present -> real-time Tradier
| otherwise -> CBOE bulk delayed
| bounded by MAX_CHAIN_SCANS
v
[4] global rank, then a per-underlying board cap (default 2)
v
Top N + notes explaining exactly what was covered
Details that matter:
- The CBOE provider overrides
get_price_and_history()so one small Yahoo chart call yields both the price and the OHLC bars. Without that override, stage 1 would download a roughly 1.5 MB option chain just to read a stock price. - Provider capability hooks have working defaults.
get_quotes_batch()defaults to a per-symbol loop for every provider; Tradier overrides it and setssupports_batch_quotes = True. No provider is required to implement it, but the one that can lets the sweep prune 6,000 names in a few dozen requests. - The per-underlying board cap stops one hot name from filling the Top 50 with its own strike ladder.
test_board_has_many_distinct_names_not_a_fewasserts at least 15 distinct names across a 20-name universe. The test docstring names the bug it locks down. - Nothing fails silently. The sweep accumulates
price_failedandchain_failedlists and emits notes stating names priced vs. attempted, budget truncation (Scanned the top N of M in-band names by liquidity), chain-fetch failures with example symbols, the routing mode actually used, and a data-as-of timestamp. Two tests assert those notes contain what they claim.
Neither CBOE nor Tradier serves historical implied volatility, so the app builds its own. Each scan solves the ATM IV for the symbol and snapshots it into SQLite, one row per symbol per day. iv_rank() returns None below 10 observations, at which point the Volatility sub-score falls back to pure IV-vs-HV and the API emits a note: IV Rank is warming up (N day(s) of history; needs ~10). Rank gets better the longer you run it, and it is honest about not having it yet.
One bug fix worth preserving lives in Store.get_underlying_fresh(): cache freshness is keyed on an updated_at UTC timestamp, not on a calendar snap_date. A TTL spanning midnight therefore still finds yesterday's row. test_underlying_cache_ttl_respects_age_and_ignores_snap_date inserts a row stamped with yesterday's date but a one-hour-old timestamp and asserts it is still a cache hit. The docstring calls it the cross-midnight fix for the intermittent empty-board bug.
No API key is needed. DATA_PROVIDER=cboe is the default and runs on the public delayed feed with zero credentials. Install into a virtualenv: the test suite needs httpx and collection fails without it.
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
cp .env.example .env # optional, the defaults work as-is
uvicorn app.api:app --reload
# open http://localhost:8000- Double-click
setup.batonce. Creates.venv, installs requirements, copies.env.exampleto.env. - Double-click
start.bat. Serves onhttp://127.0.0.1:8000and opens a browser after a few seconds. Leave the window open. stop.batforce-kills the server if you closed the start window.
Set these in .env:
DATA_PROVIDER=tradier
TRADIER_TOKEN=your-tradier-token-here
TRADIER_ENV=production # "sandbox" is still ~15 min delayedA free Tradier brokerage account is required for real-time option data. Bring your own token and comply with each vendor's data terms. This repo ships no credentials and no vendor data.
python scripts/update_universe.pyThis writes app/universe/optionable.txt from the OCC Directory of Listed Products, falling back to the Cboe symbol reference and then to the curated list. It refuses to overwrite if it assembles fewer than 565 symbols or fewer than 400 S&P names, so a half-failed download cannot silently shrink the screener's coverage. Cash-settled index roots are dropped, symbols are deduped on a separator-stripped key, and the output is ordered ETFs, then S&P 500, then the alphabetical long tail. That ordering is what makes the sweep's scan budget mean "the most liquid slice."
All 25 knobs are environment variables read in app/config.py and documented in .env.example. The ones you are most likely to touch:
| Variable | Default | What it does |
|---|---|---|
DATA_PROVIDER |
cboe |
cboe (free, delayed, no account) or tradier |
RISK_FREE_RATE |
0.04 |
The r fed to Black-Scholes-Merton |
HV_WINDOW |
30 |
Trading days of history in the Yang-Zhang estimate |
DEFAULT_MIN_DTE |
14 |
Lower end of the default expiration window |
DEFAULT_MAX_DTE |
60 |
Upper end of it |
MAX_SPREAD_PCT |
0.15 |
Spread above this trips the hard liquidity cap |
MIN_OPEN_INTEREST |
50 |
Open interest below this trips the same cap |
MAX_CHAIN_SCANS |
2000 |
Cap on in-band names a sweep fetches chains for (0 = no cap) |
REALTIME_CHAIN_THRESHOLD |
300 |
At or below this many in-band names, route chains to Tradier |
PER_NAME_BOARD_CAP |
2 |
Contracts allowed per underlying on the final board |
SWEEP_MAX_WORKERS |
4 |
Thread-pool width for the sweep's price pre-pass |
CACHE_TTL_HOURS |
12.0 |
How stale a cached price/HV row may be and still count |
pytest -q
# 49 passed40 test functions across five modules, 49 cases after parametrization (only test_iv_roundtrip is parametrized, 5 sigmas by 2 option types). Verified in a clean virtualenv on Python 3.14 while writing this: 49 passed in 2.14s.
Everything runs offline. No network access, no mocking library, no recorded-cassette dependency. Every network parser is a pure module-level function that takes a dict, so the provider tests feed it literal payloads. The two retry-path tests monkeypatch a fake transport.
test_blackscholes.py: BSM against textbook values, put-call parity, IV round-trip across several sigmas and both sides, IV below intrinsic returnsNone, Greek sign conventions,prob_itmmonotonic in strike.test_scoring.py: all sub-scores bounded to[0,100], Value rises with realized vol, the liquidity gate actually caps the composite, tighter spread scores more liquid, a no-price contract bottoms out at F.test_cboe.py: OCC symbol parsing, quote/expiration/chain parsing with side and expiration filters, Yahoo history dropping nulls and sorting ascending, retry-then-succeed,FeedErrorafter exhausting retries, multi-spelling symbol fallback.test_tradier.py: scalar-vs-array collapse normalization, all four parsers, empty payloads are safe, batch-quote keying and chunking, the combined price+history path.test_market.py: universe parsing, store round-trip, price-band pruning, descending sort, the distinct-names board cap regression, chain-failure counting surfaced in notes, budget truncation wording, order-independence (no alphabet bias), the cross-midnight TTL fix, and an end-to-end background sweep polled to completion through the real state machine.
Roughly a 1:4 test-to-application line ratio.
Gaps, named honestly: there are no HTTP-level tests of the five FastAPI endpoints (no TestClient), and no tests of the frontend JavaScript.
This repo is a sanitized copy of a working local install. The exclusions are enforced by .gitignore, not by hand: it has labeled sections for secrets, Python build output, runtime state, and the generated universe.
.env, which held a live brokerage API token..gitignoreexcludes it;.env.exampleships instead with placeholder values and a comment on every knob..venv/and all__pycache__/. Reproduced byrequirements.txt.data/options.db, the accumulated SQLite state (IV snapshots plus the price/HV cache). Only public market data, no account or position information, but it is runtime output.data/.gitkeepships so the path exists; the schema is created on first run.app/universe/optionable.txt, the roughly 6,000-name generated list. The OCC and Cboe source files carry redistribution restrictions, whichscripts/update_universe.pyacknowledges in its own docstring, so the generator ships and the generated artifact does not.
That last one changes behavior on a fresh clone. app/market.py prefers optionable.txt and falls back to app/universe/sp500_etfs.txt, a curated 565-symbol list (62 ETFs plus 503 S&P names). So out of the box the market sweep covers 565 names. Run python scripts/update_universe.py once to get the full optionable universe. GET /health reports universe_size so you can tell which list is loaded.
Working and used. The version string in app/api.py is 0.2.0. Honest caveats:
- No packaging and no CI. There is no
pyproject.toml, nosetup.py, no lockfile, and no.github/directory. The test number above is one run locally in a clean venv, not one a service verified. - BSM is European; US equity options are American. Inverting a European model against an American premium is an approximation. It is a good one for the non-dividend, non-deep-ITM majority of the board, and it is wrong at the edges. Early exercise is not modeled.
- The free feeds are unofficial. The CBOE delayed-quote CDN and the Yahoo chart endpoint are public but undocumented, and they can change or start rate-limiting without notice. That is why the retry, the backoff, and the failure counting exist.
- Tradier sandbox is still delayed and serves no Greeks. Only
TRADIER_ENV=productionon a brokerage account is real-time. - "Top 50 across the market" is bounded by the scan budget. With
MAX_CHAIN_SCANSset, it is the top 50 across the most-liquid slice actually scanned. The notes always say which. - Stale copy in two places. The frontend tab still reads "Market (S&P 500 + ETFs)" (
frontend/index.html:18) and theapp/market.pymodule docstring still says "curated universe," both left over from before the OCC universe landed. The behavior is correct; the labels are behind. - The two providers do not fail the same way.
cboe.pyretries 403/429/5xx with jittered backoff and raisesFeedError;tradier.pyretries 429 only and lets everything else surface ashttpx.HTTPStatusError. Callers that catchFeedErrorwill not catch a Tradier 5xx. Unifying the two is on the roadmap. - Config drift.
.env.exampleshipsMAX_CHAIN_SCANS=1500while the built-in default inconfig.pyis2000. The example is the lighter, faster setting. - This is a calculator, not a broker. It places no orders and connects to no execution venue. A letter grade summarizes seven measurable properties of a contract at a point in time; it is not advice and none of the output should be read as a recommendation.
Roadmap, in rough priority order: TestClient coverage of the five endpoints, an American-option pricer (binomial or Bjerksund-Stensland) for the early-exercise cases, and persisting sweep results so a completed board survives a restart.
shadow-options-trading-lab, the strategy side of the pair.
MIT. See LICENSE. Market data belongs to CBOE, Yahoo, Tradier, and the OCC respectively, and complying with their terms is on you. The app/universe/sp500_etfs.txt fallback is a small static set of ticker symbols compiled from public S&P 500 constituent listings.
Built by Cade (https://github.com/csnyder256)