Every trade on WheelRank is sourced from a published rule and recorded in an append-only journal. The scanner proposes, /plan sizes, /sandbox tracks the open exposure, the trade journal closes the loop, and a Telegram digest brings the day back to you. Read top-down: the loop, the stack, then the data-resilience path that keeps it honest.
Each step has one job. None of them decide alone; the chain of decisions is what makes the trade discipline-first.
Ranks every ticker on the watchlist by Keltner Channel zone, RSI, IV rank, premium thickness, and a 0–100 composite score. The output is a ranked list of STRONG signals, grounded in a backtested hit-rate. Refreshed by the daily-scanner cron at 0 9 * * 1-5 with a daily-scanner-retry catch-up at 0 13 * * 1-5 (polsia.toml:4-12).
The trade-to-plan calculator (routes/plan.js) takes a scanner pick and sizes it: account size, max position %, CSP delta target, DTE. When OPT_FEED=massive is live, the latest Massive.com CSP pick prefills strike / premium / DTE for all 17 watchlist tickers — else a delta-derived heuristic stands in.
The open-positions dashboard (routes/sandbox.js) is the only place a "live" trade lives. /sandbox/positions/open is idempotent on (ticker, source, source_ref); /sandbox/positions/close records the exit. The discipline summary card surfaces capital at risk and a 30-day win rate from computeWinRateForWindow.
db/journal.js · migrations/1785200000000_trade_journal.jsAppend-only closure trail. Closing a sandbox position writes one row that the win-rate aggregator reads; nothing edits history. The journal is the audit log the loop is measured against.
jobs/morning-digest.jsThe digest-morning cron at 0 8 * * 1-5 (polsia.toml:15-17) fires before the 9am scan. The digest reports yesterday's closed trades, today's STRONG count, open count, and capital at risk — so a trader sees the day's exposure before the bell.
Five dependencies, one each for web, state, options, prices, and notifications. The page is server-rendered; the cron schedule lives in polsia.toml; the schema lives in migrations/.
Web layer: server.js wires middleware and mounts, routes/*.js serve pages and JSON, views/*.ejs render all surfaces including this one.
Pool via db/index.js; migrations under migrations/. users is Polsia-managed for subscription state; trade tables (scanner_results, options_scan_results, positions, journal) live alongside.
Live CSP / CC picks for the watchlist. Wired in services/optionsFetcher, ranked in services/scanner/optionsScanner. Gated by OPT_FEED=massive + MASSIVE_API_KEY.
Daily OHLCV via services/scanner/dataFetcher, live /quote via services/priceFetcher. Gated by PRICE_FEED=finnhub + FINNHUB_API_KEY.
Weekday 08:00 UTC morning digest via jobs/morning-digest.js + services/telegram. The cron logs skipped (reason) when the Telegram path is unavailable so the digest is never lost silently.
The loop has to keep producing verdicts even when an upstream feed is rate-limited, paid-only, or down. Each fallback below is a real code path, not a marketing line.
PRICE_FEED=yahoo (default, no key) → polygon via POLYGON_API_KEY → finnhub via FINNHUB_API_KEY → synthetic 60-day OHLCV fixture for TQQQ / SOXL / SQQQ when USE_SYNTHETIC_DATA=true. Read by services/priceFetcher and services/scanner/dataFetcher.
backtest-keltner.js writes reports/backtest-keltner.json + .md; /research reads those files so the watchlist keeps a verdict even when the live feed is offline.
When Massive is off, services/optionsScanner falls back to a heuristic strike + premium estimate (routes/plan.js:120-135) so the calculator still returns a sizing answer.
services/telegram.notifyDigest (called from jobs/morning-digest.js:168) returns { sent, skipped }. The cron logs skipped (reason) rather than swallowing the failure.
Start-up block in server.js:38-52 re-runs the scan (and refreshes price_latest) when SCAN_ON_STARTUP=true (default). The first visitor after a cold start sees fresh data without waiting for the next 9am cron.