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 |
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.
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.
| 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.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 — 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.
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:8000A 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.
- 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
# 82 passed73 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 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_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 sharedcheck_statuscontract, 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 realTestClientendpoint tests: a malformed feed through/scanis502with the reason named, a healthy empty feed is200with an empty board, and inverted or blank requests are422with 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:TestClientcoverage 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/statuspolled to completion, and/serving the frontend. It also pins thesidecontract:calls/put/allaliases 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.
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, but there is CI now. There is no
pyproject.toml, nosetup.py, and no lockfile. There is a.github/workflows/ci.yml(added since this repo's first public push): it runspython -m pytest -qon Python 3.12 for every push tomainand 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=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 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.pymodule 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
FeedErrorfor a non-200, an unparseable body, or anerrorsblock, after their own retry policies are exhausted.cboe.pyretries 403/429/5xx with jittered backoff;tradier.pyretries 429 only. The retry policies still differ (documented above); the error contract no longer does. - Config drift.
.env.exampleshipsMAX_CHAIN_SCANS=1500while the built-in default inconfig.pyis2000. The example is the lighter, faster setting. MAX_RESULTSis documented and wired to nothing..env.examplesays it caps "max scored contracts returned by a single scan," andsettings.max_resultsis read inapp/config.py, but no code path ever references it:/scanreturnsreq.limit(ScanRequest.limit, default 50, max 500) and the sweep returnslimit. 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.
- Feed failures have a consistent HTTP error contract. Non-200 status, an unparseable body, and JSON non-object bodies raise a typed
FeedError, which/scanmaps to HTTP 502 with the reason. Previously, Tradier could expose untypedHTTPStatusErrororValueErrorfailures, and Yahoo chart decoding could exposeValueError. 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 withTestClient. - IV history cannot read the future.
get_iv_historyandsnapshot_countexclude 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
422with 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.
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)
Latest release · Install, deploy and upgrade
Release assets include checksums and version-specific notes.
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.