A web app that scans a configurable watchlist on an interval (default 5 min), computes options metrics per ticker, stores snapshots in SQLite, and flags unusual activity in a live signal feed.
Requires uv.
./run.shThat syncs the environment from pyproject.toml/uv.lock, picks the first
free port from 8000 upward (so it won't collide with anything else you're
running), starts the server — which hosts both the API and the dashboard —
and opens the browser. PORT=9000 ./run.sh forces a port; NO_OPEN=1
skips the browser; extra args pass through to uvicorn (e.g. ./run.sh --reload).
Add tickers and you're done. The first scan runs at boot and whenever you add a symbol; afterwards on the configured interval.
By default the app uses free, ~15-min-delayed Yahoo data. With a Schwab brokerage account and an approved app on developer.schwab.com you get real-time quotes and chains with broker-computed IV/greeks and live bid/ask:
cp .env.example .envand fill inSCHWAB_APP_KEY,SCHWAB_APP_SECRET, and your registeredSCHWAB_CALLBACK_URL.uv run python -m app.schwab_auth— browser login; the token lands inschwab_token.json(gitignored). Schwab refresh tokens expire every 7 days (their human-in-the-loop security model — this can't be automated away). You won't have to remember it: a header pill counts down (amber ≤2d, red when expired) and clicking it re-runs the flow in your browser; a daily 16:50 ET check also fires a native macOS notification when expiry is near, even if the dashboard isn't open.- Set
"provider": "schwab"inconfig.jsonand restart.
Earnings dates and short interest still come from yfinance (Schwab's API doesn't serve them); if the token is missing the app falls back to yfinance so it never stops scanning.
- Index gamma tape — a strip atop the Board tab with SPY/QQQ/IWM
dealer-gamma levels on its own 60-second lane (front expirations incl.
0DTE): put wall / call wall on a rail with spot marked (the largest
put/call open-interest strikes below/above spot, on the 1–5d chain where OI
is real standing positioning), plus two gamma magnets split by horizon — a
hollow diamond for the standing pin (1–5d) and a solid diamond for today's
0DTE pin. Their gap is a read: aligned = the book agrees, diverging =
same-day flow leaning off the standing pin. A FADE/MOMO regime badge comes
from the net GEX sign (pooled across horizons). An inline, phase-aware key
under the header decodes every glyph (the close-ring and o/n-drift marks
show only pre/post-market, when they're actually on the rail). Runs
the options session (to 4:15 ET) live; in pre/post-market it freezes the
levels (OI doesn't change overnight) and moves only the spot — a ghost dot
marks the close, the solid dot the extended-hours price, so overnight drift
toward a wall is the morning-prep read. Configurable via
index_gamma_*keys. - Watchlist table — symbol, last scanned price with day change vs the
previous close (green ▲ / red ▼), IV/HV (highlighted when elevated),
put/call ratio, and a 24h signal-count badge (🔥 marks a confluence in
the last 24h). IV/HV carries a small
Ndhorizon tag — the days to the nearest expiry the IV is read from — so different listing cadences (daily ETFs vs monthly small caps) are visible rather than silently conflated. An IV rank column shows where each name's ATM IV sits in its own trailing range (0–100, amber = rich / green = cheap) — comparable across names in a way IV/HV isn't. A Val column shows where the P/S multiple sits in its own band (IV-rank idea applied to valuation; accretes daily, valid between earnings — the fixed denominator makes the multiple band a price band). Click a row to expand full metrics (ATM IV, HV, IV rank + percentile + sparkline, valuation band with P/S/P/E/P/CF and a fair-value price envelope, volumes, peak gamma strike, skew, net GEX) and filter the feed to that name. Negative numbers render finance-style in parentheses. - Watchlist board — a Board tab (Dashboard / Board / Performance in
the header) applies the index tape's gamma rail to every watchlist name:
put wall / pin / call wall on a rail with the spot dot, plus that name's
active alert badges (colored by severity) and its IV rank + signal count.
A spatial "where's price relative to its walls, and what's firing" read to
complement the sortable table — same watchlist, same scan cadence (no
60-second lane). Because single names aren't index ETFs, a chain too thin
near spot to anchor trustworthy OI walls renders them muted grey
(low-confidence, not firm); the
board_wall_min_oithreshold sets the bar. Click a card to open that name filtered in the feed. - Signal feed — split into Today and Older sections, with a
type filter (dropdown) that composes with the click-a-row symbol filter;
a purple daily wrap card lands at 4:15 PM ET summarizing every name's
day (
POST /api/wrapregenerates it manually); entries younger than ~2 scan intervals get a brighter card and anewpill so the latest information stands out. Click a signal's kind label (e.g. GAMMA FLIP) to jump straight to its explanation in the help glossary. - Help glossary — the
?button explains every metric and signal in plain English, including how to read skew, net GEX, and pinning. - Settings — collapsible panel for the scan interval and the most-used thresholds; the header shows market open/closed and last/next scan times.
- Event chart — from any expanded row, "⇱ Event chart" opens a full-width overlay: the price line (5-min snapshots, overnight gaps compressed at session dividers) with curated signal markers overlaid (warning+ severity and setup-class kinds), a 5d/15d/30d range selector, and a synced list showing each signal's forward return — the per-instance answer to "did the signal precede the move?"
- Performance view — a Dashboard / Performance toggle in the header opens signal outcome tracking: By signal grades each detector by its forward move vs a baseline (trust / promising / weak / noise / collecting), scored by flavor — direction, magnitude, or stillness; By name shows which tickers produce predictive signals plus a name × signal edge heatmap. Each fired signal is stamped with its +1d / +5d return by a daily job; outcomes live in their own table (longer retention than the 5-day feed) so +5d can mature. Early verdicts lean on +1d and upgrade to +5d as data accrues.
| Signal | What it detects | Why it matters |
|---|---|---|
iv_premium |
ATM IV ≥ 1.25× 20-day HV | Options pricing in a bigger move than the stock has realized — possible event/catalyst, or rich premium to sell |
iv_spike |
ATM IV rising ≥10% vs the average of recent scans | Someone is bidding up vol right now — the message says whether the stock was flat (often precedes news), falling (reactive hedging), or rallying (upside chase) alongside |
unusual_volume |
Contract volume on pace for ≥ 2× open interest (min 500 lots at day pace) | New positioning today, not closing of old positions |
put_call_ratio |
P/C volume ratio > 2.0 or < 0.4 | One-sided directional flow |
gamma_build |
Net GEX up ≥25% scan-over-scan (same sign, ≥ $5M floor) | Dealer hedging pressure building — message says whether it stabilizes or destabilizes the tape, and names the driver strikes |
gamma_flip |
Net GEX changed sign since last scan (≥ $5M floor) | Regime change: hedging switches between dampening moves (positive) and amplifying them (negative) |
gamma_pin |
Peak gamma strike within 2% of spot | Pin/magnet risk into expiry |
skew_shift |
OTM put IV minus OTM call IV moved ≥4 vol pts vs baseline | Crash protection getting bid, or upside being chased |
setup_read |
IV rank at an extreme (≤30 cheap / ≥70 rich) + a directional flow lean | Synthesizes vol level and flow into one structural read (cheap vol + bullish flow → well-priced upside optionality, etc.) |
cc_write |
IV rank ≥70 AND IV/HV ≥1.25 AND earnings ≥21d out AND short float <10% AND net GEX >0 | The covered-call checklist as one signal — fires only on clean premium-selling setups, with the peak-γ strike as a strike-selection hint |
strike_concentration |
≥40% of the day's option premium piled into one contract (with a $ floor) | A single strike drawing that much flow = position-building; the most-active strike is also always in the detail panel + daily wrap |
confluence |
≥3 distinct signal kinds on one symbol within 4h | Independent detectors agreeing — the strongest pattern here; flagged 🔥 in the watchlist |
squeeze_setup |
≥10% short float + ≥3 of: call-heavy flow, fresh OTM call buying, inverted skew, price+IV rising | Short-cover and dealer-hedging feedback loops aligned; warns when the naive GEX sign is likely inverted |
vol_compression |
10d HV ≤ 0.6× 20d HV with IV not pricing expansion | Coiled spring — compressed ranges resolve violently, direction unknown |
daily_wrap |
Generated 4:15 PM ET each market day | One card per day: every name's move, IV change, top signal, and what stayed elevated at the close |
Gamma signals also carry OpEx context: in the days before monthly expiration they note that OI is concentrated (effects intensify); on the first session after, gamma build/flip are downgraded to info — the change is mostly Friday's expired OI mechanically rolling off, not positioning.
Signals carry catalyst context: earnings within 7 days appends "event premium likely", while IV signals with a known earnings date more than 14 days out get flagged "vol bid without an obvious catalyst" — the interesting case. Unknown dates get no tag (absence of data isn't absence of a catalyst). Windows configurable; earnings dates are fetched once per symbol per day and shown in the detail panel.
All thresholds are editable in the UI (the most-used ones) or via
PUT /api/config / config.json (all of them).
The live roadmap is the issue tracker
— features are enhancement issues grouped into
milestones, bugs carry root
cause and resolution, and commits close them with Fixes/Closes #N. The
themes below feed that backlog.
Flow-quality signals (need tick/trade-level data — Polygon, CBOE DataShop, or UW):
- Sweeps vs blocks — multi-exchange sweeps at the ask are aggressive, informed-looking buying; single-exchange blocks at mid are often institutional hedges.
- Premium-weighted flow — $5M of OTM calls in a quiet mid-cap matters more than $5M in SPY. Rank by premium / average daily premium.
- Repeated hits — the same strike/expiry getting bought across multiple scans is far more meaningful than one print.
Vol-surface signals (need a fuller chain history):
- IV rank / IV percentile — where today's IV sits in its own 52-week range; 90th percentile IV means rich premium, 10th means cheap optionality. (The snapshots table already accumulates the history you need for this.)
- Term-structure inversion — front-month IV above back-month = event premium; quantify the kink around a specific expiry to infer the event date the market expects.
- Implied earnings move vs history — straddle price around earnings vs the stock's average realized earnings move.
Positioning signals:
- OI delta day-over-day — volume tells you today's trading; change in OI tells you what stuck. Big new OI at a strike is a footprint.
- Vanna/charm exposure — beyond gamma: how dealer deltas shift as IV falls or time passes (drives post-event drift and OpEx flows).
- Max pain drift — strike minimizing option-holder value, and whether spot gravitates toward it into expiry.
Cross-signals:
- IV up + price flat — vol bid without a move is one of the cleanest "someone knows something" patterns.
- Catalyst overlay — join signals against an earnings/FDA/macro calendar; an IV spike with no scheduled catalyst is the interesting one.
- Sector-relative IV — a name's IV jumping while its sector ETF's IV is flat isolates idiosyncratic anticipation.
app/
main.py FastAPI routes + APScheduler (interval rescheduled live)
scanner.py fetch → compute snapshot → persist → run detectors
market_clock.py ET session math: market hours, day-pace projection
config.py config.json load/save with defaults
db.py SQLite: watchlist, snapshots, signals
analytics/
signals.py one detector per signal kind
black_scholes.py greeks (Yahoo supplies IV, no solver needed)
historical_vol.py annualized close-to-close HV
providers/
base.py OptionsDataProvider interface
yfinance_provider.py free, ~15-min delayed data
static/ vanilla-JS dashboard (no build step)
- With
provider: schwab, quotes/chains are real-time with broker-computed IV and live bid/ask (the delayed-data caveats below apply to the default yfinance provider only). - Yahoo data is ~15 min delayed and per-contract IV can be stale on illiquid
strikes. For real edge, swap in Polygon/Tradier/ORATS via
providers/base.py— the rest of the app doesn't change. - Scheduled scans only run 9:30–16:00 ET Mon–Fri (
market_hours_onlyin config). After hours, the manual Scan now button always works, and adding a symbol triggers an immediate scan of just that symbol so its row fills in right away. Exchange holidays are not modeled — holiday scans see stale data, and the signal cooldown absorbs the repeats. - Volume-based signals are projected to full-day pace, and comparison signals (IV spike, gamma build, skew shift) baseline only against the current session, so they stay quiet for the first couple of scans each morning.
- Snapshots are kept 30 days (they feed future detectors like IV rank) and signals 5 days (alerts get stale fast), both configurable; purged after each sweep, which also drops snapshots for symbols no longer on the watchlist. The DB runs in WAL mode for concurrent scanner/API access.
- The DB is backed up daily (16:45 ET and on boot) to
backups/, keeping the newest 14 (backup_keep) — the snapshots/outcomes corpus is the app's research memory, so it never has just one copy. - Only one process scans at a time: instances share a lease in the DB, and the newest start (or a manual Scan now) takes ownership. If a second instance is left running against the same DB it goes passive (its UI shows a ⚠ passive badge) rather than writing conflicting data.
- Each scan pulls every expiration in the
dte_min–dte_maxwindow (default 2–21 days, capped atmax_expirations), so all names are measured over a comparable forward horizon rather than a fixed count that meant ~2 days for daily-listing ETFs but months for monthly names. The sweep runs symbols in parallel (scan_workers), so it stays fast as the watchlist and the window grow; sweep duration shows next to "last scan". Widening the window or the watchlist still raises Yahoo rate-limit pressure (~1 + expirations-in-window requests per symbol), so keep both sensible. - This flags anomalies, not trades. Everything here is a starting point for research, not financial advice.