sepy97/OptionsScreener

Tool to select best stocks for option trading strategies

★ 0Forks 0PythonGitHub ↗Compare

README

wheel-screener

A command-line cash-secured-put / wheel options screener. It finds financially-sound common stocks you'd be happy to own, then surfaces the cash-secured puts on them actually worth selling — liquid, ~−0.20 delta, ~30–45 DTE, ranked by a blend of fundamental quality and annualized yield, with earnings-risk names filtered out. The output is a shortlist to verify, not trade signals.

Full design, data-source facts, and roadmap: docs/PLAN.md.

How it works (in plain terms)

It's a manual command-line tool — you run it when you want a screen; nothing runs in the background. One command, candidates, does the whole thing and writes a CSV.

Each run is a pipeline:

  1. Universe — read a local store of whole-market fundamentals (downloaded once) and keep common stocks priced $20–200 on NASDAQ/NYSE.
  2. Filter out the bad — drop names that are insolvent, unprofitable, over-leveraged, or cash-flow-negative (hard gates), plus anything reporting earnings inside the trade window.
  3. Rank the survivors — a cross-sectional score across valuation / efficiency / sustainability.
  4. Pull option chains — for the survivors, fetch live put chains from Schwab (concurrent, rate-limited, cached, with retry on transient hiccups).
  5. Pick the put — the ~−0.20Δ put nearest 30–45 DTE that's genuinely liquid (open interest, a premium worth collecting, a real two-sided market).
  6. Rank the shortlist — by a configurable blend of fundamental quality and (conservative, bid-based) annualized yield → CSV.

Two data sources:

  • FMP (Financial Modeling Prep) — fundamentals + the earnings calendar. The whole market is downloaded once into a local store, so screens are instant and quota-free; small refresh jobs keep it current on a cheap key.
  • Schwab — live option chains with greeks/IV (OAuth; the only piece that needs a weekly re-login). The default chain source.
  • Alpaca (optional alternative) — option chains with greeks/IV at ~1000 req/min (vs Schwab's ~120) and simple key/secret auth (no weekly OAuth). Opt in with CHAIN_SOURCE=alpaca + ALPACA__API_KEY / ALPACA__API_SECRET; ALPACA__FEED=indicative (free) or opra (paid, real-time). See Using Alpaca.

Nothing is scheduled for you. You run the screen on demand and run the small data-refresh commands periodically — by hand, or wire them into your own cron (examples below).

What you need

  • uv and Python 3.11+ (uv fetches Python for you).
  • An FMP API key (Starter tier covers the refresh jobs + earnings) and, for the one-time whole-market download, a separate bulk key (Ultimate tier — only needed briefly).
  • A Schwab "Market Data Production" app (OAuth) — register at https://developer.schwab.com early; approval takes a few days.

One-time setup

# 1. install (creates the venv + installs everything; uv fetches Python 3.11)
uv sync

# 2. configure secrets
cp .env.example .env          # then fill in the keys (see below)

# 3. load the local fundamentals store (one-time, ~2 GB; uses the bulk FMP key)
FMP_BULK_API_KEY=your-bulk-key python3 tools/fmp_bulk_import.py \
    --out data/fundamentals --years 2015-2025

# 4. build the local earnings calendar (for the earnings blackout)
uv run wheel-screener refresh-earnings

# 5. log in to Schwab (opens a browser; the token lasts 7 days)
uv run wheel-screener auth-login

.env keys: FMP__API_KEY (fundamentals + earnings) and SCHWAB__CLIENT_ID / SCHWAB__CLIENT_SECRET (chains). FMP_BULK_API_KEY is used only by the importer (env var, --api-key, or .env). All of .env, .secrets/, and data/ are gitignored.

Using Alpaca for chains

Alpaca is an optional drop-in alternative to Schwab for the option-chain stage — higher rate limit (~1000 vs ~120 req/min) and key/secret auth (no weekly OAuth login). It implements the same ChainProvider port, so nothing else changes; the composition root picks it from config:

CHAIN_SOURCE=alpaca
ALPACA__API_KEY=your-key
ALPACA__API_SECRET=your-secret
ALPACA__FEED=indicative        # free (delayed/modified quotes); use "opra" for paid real-time
# ALPACA__TRADING_BASE_URL=https://paper-api.alpaca.markets   # ONLY if using paper-account keys

It merges two Alpaca endpoints per underlying — the data-API snapshot (quotes/greeks/IV) and the trading-API contracts reference (open interest). The contracts host defaults to the live API (api.alpaca.markets); if your keys are paper keys, override ALPACA__TRADING_BASE_URL to https://paper-api.alpaca.markets (it must match the environment of your key/secret). The default remains CHAIN_SOURCE=schwab.

Running a screen

uv run wheel-screener candidates --top-n 250 --output candidates.csv

Runs the full pipeline live and writes a ranked CSV. Handy flags:

  • --top-n N — how many fundamental survivors to pull chains for (more = broader, slower).
  • --fundamental-weight 0..1 — final-rank blend (1 = pure fundamentals, 0 = pure yield; default 0.5).
  • --min-yield, --min-market-cap — optional floors.
  • --timeout SECONDS — wall-clock budget for the chain pull (returns partial results if exceeded).

Output columns:

Column Meaning
rank position in the shortlist (by blended score)
symbol strike expiration dte the put to sell
delta ~−0.20 target
iv per-contract implied volatility
bid credited premium — the conservative, fillable price
mid (bid+ask)/2, reference only (not credited)
open_interest option liquidity
annualized_yield (bid/strike) × (365/dte) — computed off the bid
collateral strike × 100 (the cash you set aside)
fundamental_score 0–1 cross-sectional fundamental composite
score 0–1 blended score: weighted geometric mean of strength and yield-rating (absolute — comparable across runs)
ex_dividend dividend dividend_estimated the first ex-dividend date the contract lives through, the per-share total of all of them, and whether any is estimated from the payer's schedule rather than announced (blank = none)

Ex-dividend dates are flagged, never filtered. The ex-date drop is known in size and date, so the option market prices it into the premium, and a short put doesn't lose value when the stock opens lower. That makes it different from an earnings gap, which is why earnings are filtered and dividends aren't. The warning explains the two things a dividend does change:

  • Puts: the cushion. The strike is closer to the post-dividend price than to today's price.
  • Calls: early assignment. An in-the-money call whose time value is less than the dividend tends to be exercised the day before the ex-date.

Dates come from FMP's per-symbol dividend history (needs FMP__API_KEY). Companies announce only a few weeks ahead, so a regular payer's next date is estimated from its schedule and marked ~.

screen (fundamentals-only, no chains, no Schwab needed) ranks the universe by fundamentals alone — a quick, free check.

Keeping the data fresh (you run these)

The local store goes stale as companies report. Nothing refreshes it automatically — run these periodically:

Cadence Command Why
Weekly uv run wheel-screener auth-login Schwab's refresh token expires every 7 days
Daily / weekly uv run wheel-screener refresh-earnings refresh the earnings-blackout calendar (cheap)
Daily / weekly uv run wheel-screener refresh-fundamentals re-fetch fundamentals for names that just reported
Occasionally python3 tools/fmp_bulk_import.py --out data/fundamentals --years 2015-2025 full rebuild if the store drifts too far

Want it hands-off? Add the refresh jobs to your own cron (the screen stays manual; auth-login can't be automated — it needs a browser):

# example — adjust paths
0  6 * * *  cd ~/dev/OptionsScreener && ~/.local/bin/uv run wheel-screener refresh-earnings
30 6 * * *  cd ~/dev/OptionsScreener && ~/.local/bin/uv run wheel-screener refresh-fundamentals

Commands

Command What it does
candidates full screen → ranked candidate CSV (fundamentals + live chains)
screen fundamentals-only ranking → CSV (no Schwab needed)
auth-login Schwab OAuth browser login (run weekly)
refresh-earnings rebuild the local earnings calendar from FMP
refresh-fundamentals incremental fundamentals refresh for recent reporters
doctor check every data connection and name the one that's broken
balances balances of the linked brokerage account (Schwab)

Global flags go before the command: -v / -vv for progress / per-symbol logging, and --debug for a full traceback on an unexpected error — e.g. wheel-screener -v candidates ….

Company context

Clicking a row in the screener, searching a ticker, or opening a fundamentals report shows the company's name, sector/industry and a short description of what it actually does — because a bare ticker doesn't tell you whether you want to own the thing.

It comes from the local store, so it costs no API call and no extra credential. The description column is read lazily for one symbol at a time and memoised: across ~90,000 profile rows it is far too much prose to keep resident, and excluding it is what makes the store's memory footprint reasonable in the first place. A deployment whose source can't supply profiles simply shows the ticker, and nothing else changes.

Portfolio

A fourth tab showing a linked brokerage account: total value, cash, invested and buying power. Signing in happens at the broker — the site never sees those credentials and only ever reads.

Access is the sign-in itself: completing the broker's OAuth issues a session, so a visitor without one sees a Connect page rather than an account. Everything the feature owns lives under /portfolio, and that prefix denies by default — only the three pre-session entry points are open. Schwab authorisations last 7 days, so reconnecting is a weekly click that doubles as the login.

Early-assignment watch. Every held short option is checked for early assignment. The holder of an option exercises it early only when that gains more than the option's remaining time value, which selling it would have kept. There are three causes:

Cause Applies to Early exercise pays the holder when…
Dividend calls the call is in the money and its time value is below the next dividend. Exercise comes the day before the ex-date, and you lose the shares and the dividend.
Interest on the strike puts the put is in the money and its time value is below the interest the strike cash would earn by expiry. A dividend ahead delays this until the ex-date has passed.
No time value left either the option trades at parity, so the holder gives up nothing by exercising.

The row shows a likely / possible badge plus an ex-div marker, estimated from the broker's mark. Click the row for the full explanation, judged against the live bid. The interest rate defaults to 4% (PORTFOLIO__CARRY_RATE). Tender offers, mergers and hard-to-borrow stocks can also trigger early exercise; they aren't modelled because a quote can't show them.

"Close?" — the put swap rule

An open put gets used up: the stock runs away from the strike, the put is nearly worthless, and the cash behind it earns almost nothing for the rest of its life. The Close? column says whether to buy it back and put that cash into a fresh put — Yes or No, and either one opens the reasoning.

The verdict is two limits, both measured against a fresh_yield: the one put the entry rules would open on the same ticker today, priced at the bid, versus the open put priced at its ask.

Test Default What it stops
The open put must pay less than USED_UP_YIELD 10%/yr Swapping a put that is still earning. At a common expiry a 2x yield gap is roughly a 2-3x delta gap, so without this the rule recommends more risk rather than less idle cash. deliberately below the screen's 15% yield_satisfactory bar: "idle cash" is a lower bar than "a decent yield"
A fresh put must pay MIN_RATIOx the open one 2.0 Swapping for a marginal gain. Measured forward against the market, this is the equivalent of the widely-used "close at 50% of max profit" convention
The extra premium over the days left, after cost, must clear MIN_EXTRA $100 Swaps on small positions, and on puts whose cash frees itself soon anyway

The comparison is made at the open put's own tenor, not at the best-paying expiry in the entry window. Premium grows with the square root of time, so at an identical delta a shorter put always shows a higher annual rate — MRVL on 2026-09-21 paid 44%/yr at 18 days against 28%/yr at 39. Comparing across tenors measures the calendar rather than the position, and flagged a put sold days earlier. The higher short-dated rate is also not free money: Cboe's weekly PutWrite index collected 39.3%/yr in premium against the monthly index's 24.1% and compounded 5.6% against 6.6% (Bondarenko, 2006-2015). Shorter expiries still appear among the suggestions; they just do not decide the verdict.

Same ticker on purpose: same company, same risk, so a yield gap can only mean the open put is used up — a jumpy stock elsewhere on the list cannot drag a good position out from under you. When that ticker has no valid pick today (off the screen, reporting before the new expiry, nothing liquid enough), the median of the screen's picks stands in — never the best of them, which would fire a swap constantly. Puts the stock has fallen below are out of scope: that is the assignment question, and the ways-out panel answers it.

Covered calls are asked a different question. Closing a call frees no capital — the capital is the shares — so there is nothing to redeploy and the only replacement is another call on the same stock. A closer strike would pay more by capping your upside and raising the odds the shares are sold, which is a view on the stock rather than arithmetic. So a call whose premium has decayed below the same floor is simply marked idle — these shares are earning almost nothing — with no strike recommended; the ways-out panel prices the roll-downs if you want one. Short calls also show in/out of the money, worded for the call side: in the money means the shares go at your strike.

Verdicts are priced live when the tab loads — one chain pull per open put, cached ~10 minutes — and Refresh prices re-prices them on demand. The suggestions come from the most recent screen, which cron refreshes just after the open and at 15:35 ET so they are current before the close; the panel shows that run's age.

Limits are settings (SWAP__USED_UP_YIELD, SWAP__MIN_RATIO, SWAP__MIN_EXTRA, SWAP__SWAP_COST, SWAP__TOP_N) because the rule is a draft: see docs/PUT_SWAP_RULE.md. It has not been backtested against simply holding to expiry, so it is a prompt to look, not a signal.

The funnel

universe        every common stock in the price + dollar-volume band
  Rated         deep-fetched and rated — bounded by top_n (the cap gates FIRST, so slots
                aren't spent on names that are about to fail)
  Fundamentals  passed the hard gate, minus names reporting before the earliest expiry
  Chains        returned an option chain. Names with nothing in the window are identified
                up front in a few batched calls and never requested individually
  Candidates    had a contract clearing every sellability gate

The Rated step is the one that decides how much of the market you actually see. It ranks on fundamentals, and yield is only measured afterwards — so capping it tightly hides high-yield names before their yield is ever computed. It costs ~200 chains/min, so the default of 400 takes about two minutes; the dashboard serves the last scheduled run instantly regardless.

How the score works

score = strength^w × yield_rating^(1−w)

Both halves are absolute 0–1 ratings, so the same contract scores the same in every run — which is what makes the number comparable over time and usable as a filter (--min-score, or Minimum score in the web form).

  • strength is the company's absolute financial-strength rating, unchanged by its peers.
  • yield_rating grades the annualized yield against fixed bars: 1.0 at yield_good (25%), 0.5 at yield_satisfactory (15%), straight-line between and below. These are the same anchors the Yield column is coloured by, so the number and the colour agree.
  • w is the Rank by preference dial — which half leads, not a claim about their relative worth. The presets tilt (0.25 / 0.5 / 0.75) rather than reaching the ends, where the score would collapse into either a pure yield ordering or a copy of the Strength column.

It is a geometric mean on purpose: a weighted sum lets a poor company buy its way up the list on premium alone, while a geometric mean drags the score down whenever either half is weak. A name whose fundamentals were never established is judged on its yield alone rather than being scored 0 — under a geometric mean that would delete it from the list, which is a much stronger claim than "no data" supports.

Earlier versions scored yield as a within-run percentile. That made the score a rank position in a measurement's clothing: incomparable between runs, blind to the distance between candidates, capped at (n−0.5)/n, and useless as a threshold since the best of any list always sat near the top of it.

When a data connection breaks

Every provider failure names its provider and what to do about it, so Alpaca rejected our credentials (HTTP 401) — check ALPACA__API_KEY … tells you which of the three credentials expired without guessing.

To check them all at once — locally, or on the droplet with docker compose exec -T app wheel-screener doctor:

$ wheel-screener doctor
Data connections

  XX option chains            alpaca    Alpaca rejected our credentials (HTTP 401) — check …
  ok fundamentals & earnings  fmp       reachable, credentials accepted

GET /health reports the same thing as JSON, under providers. Readiness comes from an actual authenticated call, not from a key being present — a revoked key is still present, and that gap once let the app report "status": "ok" while every chain request returned 401. Probes are cached for 60s, and the HTTP status stays 200 whatever they say: the app is alive and its other tabs work, so an expired credential neither restarts the container nor rolls back a deploy — a redeploy would not fix it anyway.

Logging & troubleshooting

Results and the table print to stdout; everything diagnostic goes to stderr, so candidates -v > out.csv keeps the CSV clean while you watch progress.

  • Console verbosity — quiet by default (only warnings + errors). Add -v for per-stage progress (universe → fundamentals funnel → chains → candidates), or -vv for per-symbol detail:
    uv run wheel-screener -v candidates --top-n 250
  • Always-on log file — every run also writes to a rotating file at logs/wheel-screener.log (INFO and up, regardless of console verbosity; ~1 MB × 5 files). This is what makes a cron'd refresh recoverable — the history is on disk even with no console attached. Tune via LOG__DIR, LOG__FILE_LEVEL, LOG__MAX_BYTES, LOG__BACKUP_COUNT, or LOG__ENABLE_FILE=false.
  • Errors — data-provider problems (expired Schwab login, rate limit, outage) print a clear, actionable message and exit non-zero — never a silently empty result. For a full traceback on an unexpected failure, re-run with --debug.

The Fundamentals tab (optional)

The web UI's third tab shows one company's financial history graded metric by metric — valuation, efficiency, growth, liquidity and risk, across up to fifteen periods, green/amber/red. It answers "is this a business I want to own", separately from the screen's "which contract should I sell".

It is powered by fundcore, an analysis engine that ships as its own package and is deliberately not a dependency of this project — it is absent from pyproject.toml and the lockfile, so this repository stays installable and testable by anyone with no credentials. The adapter imports it lazily, and when it isn't installed the tab explains itself and nothing else in the app is affected. Everything else — the screen, the ticker search — works exactly the same.

In production the engine is fetched from a private release into vendor/ at deploy time (see docs/DEPLOY.md); the version deployed is pinned in deploy/fundcore.version.

If you have access to the engine, install it into the venv to enable the tab locally:

uv pip install /path/to/stockanalysis-<version>-py3-none-any.whl

Note that uv sync prunes anything not in the lockfile, so re-run that install after a sync. The engine reuses this project's FMP__API_KEY — there is one key and one quota — and reports are disk-cached for a day (FUNDCORE__CACHE_TTL_SECONDS), because a report only changes when the company files.

How it's built

Hexagonal ports & adapters: a framework-free core/ (models, pipeline, ranking, ScreenerService) wrapped by thin delivery layers (cli/ today, api/ FastAPI later — both call the same service). Concrete providers live in adapters/ behind core/ports.py Protocols and are wired in composition.py; swapping a provider is a one-line change.

src/wheel_screener/
  core/      models · ports · fundamentals · ranking · errors · service
             pipeline/ (universe · rate_fundamentals · pull_chains · select_strike · rank)
  adapters/  fmp/ · schwab/ · alpaca/ · local/ · fundcore/ · http.py · cache.py · errors.py
  cli/  api/   config.py   composition.py   logging_config.py
tools/   fmp_bulk_import.py     # one-time whole-market bulk loader
docs/    PLAN.md · TODO.md

Development

uv run ruff check .     # lint
uv run pytest           # tests

CI (GitHub Actions) runs ruff + pytest on Python 3.11 and 3.12 for every push and pull request. main is protected — changes land via a PR with green CI. Deferred / optional work is tracked in docs/TODO.md.

Status

Deployed. Live in production at steadybull.net — Dockerized (app + Caddy auto-TLS) on a DigitalOcean droplet, with Alpaca as the chain source (key/secret, no OAuth). The Portfolio tab is private: people sign in with a passkey from a one-time invite (wheel-screener invite), and only the person who linked the broker ever sees its account. People link their own brokerages through SnapTrade (read-only; off until its keys are configured). A fail-closed HTTP Basic-Auth gate is also built in and scoped — AUTH__SCOPE=portfolio leaves the screener public (site covers every path). The pipeline: local fundamentals → Alpaca (or Schwab) chains → ranked CSP shortlist, with the earnings blackout, conservative bid-based yields, an absolute financial-strength rating (0–100, shown beside a peer percentile), and a strength×yield ranking. A server-rendered web UI (FastAPI + HTMX) runs, cancels, and displays screens with live progress, sortable results, per-candidate detail, an instant precomputed dashboard, single-ticker search, and CSV export. Hardened for real-world failures — typed provider errors, retry/backoff, run timeout + cancellation + partial results, no raw tracebacks, and verbosity-controlled logging. See docs/DEPLOY.md for the deploy runbook. The UI is intentionally preliminary (see docs/UI_STATUS.md); a native/Swift front-end is a possible next layer.

Contributors

sepy97

Issues