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.
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:
- Universe — read a local store of whole-market fundamentals (downloaded once) and keep common stocks priced $20–200 on NASDAQ/NYSE.
- 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.
- Rank the survivors — a cross-sectional score across valuation / efficiency / sustainability.
- Pull option chains — for the survivors, fetch live put chains from Schwab (concurrent, rate-limited, cached, with retry on transient hiccups).
- 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).
- 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) oropra(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).
- 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.
# 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.
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 keysIt 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.
uv run wheel-screener candidates --top-n 250 --output candidates.csvRuns 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.
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| 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 ….
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.
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.
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.
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.
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 atyield_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. wis 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.
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.
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
-vfor per-stage progress (universe → fundamentals funnel → chains → candidates), or-vvfor 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 viaLOG__DIR,LOG__FILE_LEVEL,LOG__MAX_BYTES,LOG__BACKUP_COUNT, orLOG__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 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.whlNote 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.
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
uv run ruff check . # lint
uv run pytest # testsCI (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.
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.