About

Discipline-first. Rules-only. Trade the wheel.

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.

The discipline loop

Scanner → /plan → /sandbox → trade_journal → Telegram.

Each step has one job. None of them decide alone; the chain of decisions is what makes the trade discipline-first.

01
Scanner — /api/scanner · /api/prices

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).

02
/plan — /plan

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.

03
/sandbox — /sandbox

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.

04
trade_journal — db/journal.js · migrations/1785200000000_trade_journal.js

Append-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.

05
Telegram digest — jobs/morning-digest.js

The 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.

The stack

Express · Postgres · Massive.com · Finnhub · Telegram.

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
Express.js + EJS

Web layer: server.js wires middleware and mounts, routes/*.js serve pages and JSON, views/*.ejs render all surfaces including this one.

state
PostgreSQL (Neon)

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.

options
Massive.com options chain

Live CSP / CC picks for the watchlist. Wired in services/optionsFetcher, ranked in services/scanner/optionsScanner. Gated by OPT_FEED=massive + MASSIVE_API_KEY.

prices
Finnhub live prices

Daily OHLCV via services/scanner/dataFetcher, live /quote via services/priceFetcher. Gated by PRICE_FEED=finnhub + FINNHUB_API_KEY.

notify
Telegram cron

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.

Data-resilience path

What survives when a feed goes dark.

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.

p1
Price feed failover

PRICE_FEED=yahoo (default, no key) → polygon via POLYGON_API_KEYfinnhub 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.

p2
Backup target

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.

p3
Options fetch fallback

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.

p4
Bot dispatch

services/telegram.notifyDigest (called from jobs/morning-digest.js:168) returns { sent, skipped }. The cron logs skipped (reason) rather than swallowing the failure.

p5
Cron self-heal on boot

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.