Deterministic options screener

option-contract-grader

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.

What it does

01 / FETCH

Real chains

Option chains from CBOE's free delayed feed by default, or Tradier for real-time. Both sit behind one four-method provider interface.

02 / SOLVE

Its own IV

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.

03 / SCORE

Seven dimensions

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.

Why it does its own math

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:

  • No-arbitrage bounds are checked first. Outside them there is no valid sigma, and it returns None.
  • It seeds with the Brenner-Subrahmanyam ATM approximation, not a blind 0.20.
  • Newton-Raphson on Vega, falling back to a 200-iteration bisection over [1e-4, 5.0] the moment Vega drops under 1e-10.
  • No sign change in the bracket means 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.

The composite

Sub-scoreWeightWhat it measures
Value (Price Edge)30BSM fair value at realized vol vs. the market mid
Odds of Profit20N(d2) at the break-even price
Liquidity200.6 spread + 0.25 log-scaled OI + 0.15 log-scaled volume
Volatility Value12IV/HV ratio, blended 60/40 with inverted IV rank when it exists
Move Needed10Break-even move over the 1-standard-deviation move
Time-Decay Risk4Theta as a fraction of premium per day, halved under 7 DTE
Leverage & Risk4Gaussian 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.

The market sweep

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

Nothing fails silently

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.

IV rank bootstrapped from nothing

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.

Quickstart

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.

Honest caveats

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.