Skip to content

About

Local FastAPI screener for long single-leg US equity options. Solves implied volatility and Greeks from Black-Scholes-Merton, scores each contract on seven weighted factors, and returns an A-F grade per ticker or across the full optionable universe.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

option-contract-grader

Every option contract in the chain, scored 0-100 and graded A through F.

ci License Tests FastAPI SQLite Frontend Deps

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.


What it actually does

  1. Fetches the option chain for a ticker (or for every name in the universe) from CBOE's free delayed feed or from Tradier.
  2. Throws away the vendor's IV and Greeks, and solves for implied volatility from each contract's own mid price.
  3. Scores each contract on seven independent dimensions, blends them into one weighted composite, and assigns a letter.
  4. 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 problem it solves

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.


Why it does its own math

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 under 1e-10 or sigma leaves the sensible region.
  • If the bracket has no sign change, it returns None rather 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.


The grade

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 risk
  • No 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 high
  • Illiquid - 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.


Architecture

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 335 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 152 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 117 Abstract OptionsDataProvider (four required methods) plus FeedError, the shared check_status HTTP contract, and two optional-optimization hooks with working defaults.
app/providers/cboe.py 366 Pure parser functions plus a thin networked class. OCC symbol parsing, symbol-spelling fallbacks, in-process chain cache, and a non-200 / non-JSON guard on every fetch.
app/providers/tradier.py 362 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, and the same FeedError contract as CBOE.
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 175 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, and a future-date guard on IV history reads.
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: 24 Python files, 4,213 lines (2,843 under app/, 1,198 under tests/, 172 in scripts/). Six dependencies in requirements.txt: five runtime (fastapi, uvicorn, httpx, pydantic, python-dotenv) and pytest.

Data backends

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, but as of this pass they fail identically. CBOE retries with exponential backoff plus jitter on 403/429/5xx; 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. What they now share is the outcome: every non-200, every unparseable body, every JSON non-object, and every errors block becomes a typed FeedError carrying the symbol, so a caller catching FeedError catches both providers and the sweep can count and report the failure.

HTTP surface

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

side accepts calls, puts, or both (plus the call/put/all aliases and case-insensitive spellings). An omitted side retains the both default. Blank or unknown strings and JSON null are a 422 — an unknown side is never silently widened to both sides.

curl -s localhost:8000/scan \
  -H 'content-type: application/json' \
  -d '{"ticker":"AAPL","side":"calls","limit":10}' | python -m json.tool

The market sweep

The 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 sets supports_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_few asserts 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_failed and chain_failed lists 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.

IV Rank is bootstrapped from nothing

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 — and that day's row is written once. A later scan on the same day cannot rewrite it: the day's ATM IV is the first one observed, not whichever scan happened to run last. 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.

The first-write-wins rule matters more than it looks. IV Rank and IV Percentile read this series as the historical distribution, so a day that any later scan can overwrite is not history at all — it is grader quality, and it biases exactly the tail the percentile exists to measure. A spike day that gets re-scanned with a calmer IV silently vanishes from the sample. test_intraday_rescan_cannot_rewrite_a_completed_days_iv records a 12-day series containing a 0.55 spike on day 3, re-scans day 3 at 0.21, and asserts the spike survives and the resulting rank matches the true series. Store.save_iv_snapshot() therefore uses ON CONFLICT DO NOTHING rather than INSERT OR REPLACE, and returns whether it recorded the day.

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.

The same timestamp is the cache's observation time, so the sweep must not restamp a row it merely re-touched. get_underlying_fresh(..., with_timestamp=True) returns the row's updated_at, and Stage 1b passes it back through save_underlying(..., observed_at=...) when it fills in HV for a name whose price was already on hand. Without that, re-touching a name would present an old price as freshly observed and reset its 12-hour TTL — test_sweep_does_not_restamp_a_price_it_already_had locks it down.


Quickstart

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

A static preview of the interface is published at https://csnyder256.github.io/option-contract-grader/, which is the URL in this repo's About box. That is docs/index.html, a self-contained mockup with the numbers hardcoded: it is a look at the layout, not a running instance. The Quickstart above is the real thing.

Windows, no terminal

  1. Double-click setup.bat once. Creates .venv, installs requirements, copies .env.example to .env.
  2. Double-click start.bat. Serves on http://127.0.0.1:8000 and opens a browser after a few seconds. Leave the window open.
  3. stop.bat force-kills the server if you closed the start window.

Going real-time

Set these in .env:

DATA_PROVIDER=tradier
TRADIER_TOKEN=your-tradier-token-here
TRADIER_ENV=production      # "sandbox" is still ~15 min delayed

A 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.

Regenerating the universe

python scripts/update_universe.py

This 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."


Configuration

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

Testing

pytest -q
# 82 passed

73 test functions across six modules, 82 cases after parametrization (only test_iv_roundtrip is parametrized, 5 sigmas by 2 option types).

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 returns None, Greek sign conventions, prob_itm monotonic 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, FeedError after 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_failures_surface.py (new): the whole "a failure must not look like an answer" class. Crossed and one-sided books, future-dated IV history, CBOE and Tradier non-200 / unparseable / JSON-non-object / errors-block responses, the shared check_status contract, and every inverted request range. Also the negative cases: a well-formed response with zero options is still a normal empty result, and a locked market (ask == bid) is still a market. Finally, the real TestClient endpoint tests: a malformed feed through /scan is 502 with the reason named, a healthy empty feed is 200 with an empty board, and inverted or blank requests are 422 with the provider stubbed to raise if it is ever touched (proving validation runs before any feed work).
  • 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.
  • test_api.py: TestClient coverage of the five endpoints (no network, stubbed provider/store) — /health, /key, /scan (ranked results, side filtering, 422 on blank ticker, 502 on a feed error, limit handling, the default-DTE note), /market/scan + /market/status polled to completion, and / serving the frontend. It also pins the side contract: calls/put/all aliases normalize, and an unknown side is rejected (HTTP 422) instead of silently widening to both sides.

Roughly a 1:2.4 test-to-application line ratio (1,198 test lines against 2,843 application lines).

Gaps, named honestly: the HTTP endpoints are exercised through TestClient in test_failures_surface.py (validation and the feed-failure paths), but there is no browser or integration suite over the real network feeds, and no tests of the frontend JavaScript.

test_failures_surface.py is the module worth reading if you only read one. It pins the class of bug this app cares most about: a broken request or a broken feed must not come back looking like a normal answer.


What is not in this public copy

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. .gitignore excludes it; .env.example ships instead with placeholder values and a comment on every knob.
  • .venv/ and all __pycache__/. Reproduced by requirements.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/.gitkeep ships 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, which scripts/update_universe.py acknowledges 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.


Status and known rough edges

Working and used. The version string in app/api.py is 0.2.0. Honest caveats:

  • No packaging, but there is CI now. There is no pyproject.toml, no setup.py, and no lockfile. There is a .github/workflows/ci.yml (added since this repo's first public push): it runs python -m pytest -q on Python 3.12 for every push to main and every PR. The test number above is the local run.
  • 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=production on a brokerage account is real-time.
  • "Top 50 across the market" is bounded by the scan budget. With MAX_CHAIN_SCANS set, it is the top 50 across the most-liquid slice actually scanned. The notes always say which.
  • Stale copy is now cleaned up. The frontend tab was relabeled from "Market (S&P 500 + ETFs)" to "Market (full optionable universe)" in this pass, and the app/market.py module docstring's "curated universe" was corrected at the same time. Both leftovers predated the OCC universe landing; the behavior was already correct, only the labels were behind.
  • The two providers now fail the same way, from different transports. Both raise FeedError for a non-200, an unparseable body, or an errors block, after their own retry policies are exhausted. cboe.py retries 403/429/5xx with jittered backoff; tradier.py retries 429 only. The retry policies still differ (documented above); the error contract no longer does.
  • Config drift. .env.example ships MAX_CHAIN_SCANS=1500 while the built-in default in config.py is 2000. The example is the lighter, faster setting.
  • MAX_RESULTS is documented and wired to nothing. .env.example says it caps "max scored contracts returned by a single scan," and settings.max_results is read in app/config.py, but no code path ever references it: /scan returns req.limit (ScanRequest.limit, default 50, max 500) and the sweep returns limit. Turning the knob changes nothing. Either wire it or drop it; today it is a documented lie, which is why it is listed here rather than quietly left out.
  • 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: an American-option pricer (binomial or Bjerksund-Stensland) for the early-exercise cases, persisting sweep results so a completed board survives a restart, and a shared retry policy so the two providers stop differing in backoff as well as transport.

Resolved

  • Feed failures have a consistent HTTP error contract. Non-200 status, an unparseable body, and JSON non-object bodies raise a typed FeedError, which /scan maps to HTTP 502 with the reason. Previously, Tradier could expose untyped HTTPStatusError or ValueError failures, and Yahoo chart decoding could expose ValueError. The first version of this branch also collapsed Tradier non-object JSON to an empty object; the follow-up corrects that regression. A healthy empty chain remains HTTP 200. These HTTP outcomes are covered through the real app with TestClient.
  • IV history cannot read the future. get_iv_history and snapshot_count exclude rows dated after today, so a phantom future snapshot can no longer move IV rank or percentile.
  • An impossible filter is rejected, not answered. Inverted expiration, premium, price, and DTE ranges, and a whitespace-only ticker, return 422 with the specific mismatch instead of an empty result set.

Not fixed by this pass, despite an earlier draft of this file saying so: the crossed-book claim was wrong. has_two_sided_market already required ask >= bid on main before this branch existed, so there was no crossed-book bug to fix here; what this pass adds is regression coverage (crossed, one-sided, and locked markets). Both the CBOE options and Yahoo chart paths already checked non-200 HTTP status before parsing. The shared helper adds typed JSON decoding and shape errors; the existing status checks are preserved. Both corrections are recorded rather than quietly dropped.


Related

shadow-options-trading-lab, the strategy side of the pair.

License

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)

Release downloads and deployment

Latest release · Install, deploy and upgrade

Release assets include checksums and version-specific notes.

Scenario simulator and transparent reports

Use the Scenario Simulator tab or “Explore scenarios” on a graded contract. Enter a purchase premium, quantity, actual contract multiplier, round-trip fees, spot range, time elapsed and volatility change. The chart separates theoretical mark-to-model P/L before expiry from intrinsic expiry payoff. The illustrative example runs locally without calling any market provider or trading API.

Every grade exposes the exact normalized weights, raw component contributions, missing-input fallback scores and liquidity cap. Their sum reconciles to the unrounded composite. These weights are buyer-oriented design choices; grades are not empirical profit forecasts. The odds component is a risk-neutral model estimate.

Export a grade as JSON, or a scenario as JSON, CSV and a printable HTML report. Scenario reports carry input and engine SHA-256 fingerprints and all modeling assumptions; equal inputs reproduce equal reports. POST /scenario and POST /scenario/report?format=json|csv|html are available for automation.

The existing Black–Scholes–Merton engine models European exercise, constant volatility/rates and continuous dividend yield. It excludes early exercise, volatility smile and execution slippage. See the Options Industry Council model explanation. Scenario values are hypothetical; a model price does not promise an executable premium. Zero remaining time uses exact intrinsic value; the grader's separate half-day 0DTE floor is disclosed.

About

Local FastAPI screener for long single-leg US equity options. Solves implied volatility and Greeks from Black-Scholes-Merton, scores each contract on seven weighted factors, and returns an A-F grade per ticker or across the full optionable universe.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages