New market indicator — the Radon pattern
An indicator is a vertical slice with seven parts. All seven ship together; the reference implementations are the FINRA margin-debt tab (external HTTP source, daily timer) and the NYSE breadth tab (IB + StockCharts, 5-min timer). Copy margin-debt for scheduled external-source indicators; copy breadth for IB-fed intraday collectors.
0. File checklist
Create:
| File | Modeled on |
|---|---|
scripts/clients/<source>_client.py (only if the source needs retry/conditional-GET logic) |
scripts/clients/finra_client.py |
scripts/fetch_<name>.py or scripts/<name>_scan.py |
scripts/fetch_margin_debt.py / scripts/breadth_scan.py |
scripts/db/migrations/00NN_<name>.sql (next free number, 4-digit, lex-ordered) |
0027_margin_debt_history.sql, 0029_rv_ratio.sql |
scripts/tests/test_<name>.py |
scripts/tests/test_margin_debt.py |
web/app/api/<name>/route.ts |
web/app/api/margin-debt/route.ts |
web/lib/<name>.ts (pure helpers + types) |
web/lib/marginDebt.ts |
web/lib/use<Name>.ts |
web/lib/useMarginDebt.ts |
web/components/<Name>Panel.tsx |
web/components/MarginDebtPanel.tsx |
web/app/regime/<slug>/page.tsx (time-series regime only; name-ranking scanners use /scanner?mode=<slug>) |
web/app/regime/margin/page.tsx / ScannerModeTabs |
web/tests/<name>-api.test.ts |
web/tests/margin-debt-api.test.ts |
web/tests/<name>-panel.test.tsx |
web/tests/margin-debt-panel.test.tsx |
web/e2e/<name>-tab.spec.ts |
web/e2e/margin-debt-tab.spec.ts |
cloud/services/radon-<name>.service + .timer |
radon-margin-debt.{service,timer}, radon-bpi.timer |
Modify (lockstep pins — miss one and a test fails, which is the point):
scripts/db/writer.py—upsert_<name>_rows(...)(+ reuseupsert_scan_snapshot/record_service_health)- IA: time-series market-state charts (CRI, GEX, breadth, margin) go on
/regime/<slug>. Name-ranking scanners (LEAP, GARCH, cheap-wing vol cone) go on/scanner?mode=<slug>next to Flow / Discover. Do not park a name scanner on Regime. - Regime only:
web/lib/regimeRail.tsfirst (theRegimeTabunion, theREGIME_RAIL_GROUPSentry that places the tab in its rail group, andREGIME_TAB_LABEL), thenweb/components/RegimePanel.tsx(MOBILE_TAB_LABELif the label needs a short form, thetabFromPathnameregex, the mobile chip bar's inlineRegimeTab[]array, plus theif (activeTab === "<slug>")dispatch branch) - Regime only:
web/tests/regime-tab-routes.test.tsx— add["<slug>", "app/regime/<slug>/page.tsx"]to thedescribe.eachtable + a render/navigation case - Scanner:
ScannerModeTabs+WorkspaceSectionsScannerMode+?mode=parse + panel branch +scanner-mode-tabstests web/lib/serviceHealthWindows.ts— staleness window entry (kebab-case service name)web/tests/service-health-windows.test.ts— theexpectedset is exhaustive; add the new serviceweb/lib/refreshSchedule.ts—export const <NAME>_REFRESH: UtcSchedule = { cadence, hourUtc, minuteUtc[, weekdayUtc] }mirroring the timer'sOnCalendarline (this is what the panel's countdown reads)web/tests/refresh-schedule.test.ts— add anit("<name> mirrors radon-<name>.timer")case; the test parses the unit file, so a timer edit without the constant fails CIscripts/watchdog/services.py— same window for the Python watchdog + the daily-bucket check listcloud/scripts/setup-vps.sh— append both units to theSERVICE_FILESarray (line ~34-97), AND add both unit hashes tocloud/config/installed-units.sha256in the same commit: since PR #73 the deploy'sradon-deploy-root install-unitsverb installs every manifest-pinned unit andenable --nows new timers, so no root SSH is owed. A unit missing from the manifest is never installed.cloud/tests/test_systemd_services.py— add both units to the canonical set
1. Ingestion job (scripts/)
- Python 3.13, stdlib-preferred parsing (margin-debt parses xlsx with
zipfile+ElementTree). Composed-method style: small pure functions (parse_*,merge_*,compute_*,build_output) so pytest covers them without network. - Honest User-Agent (
radon/2.0). Never impersonate a browser; FINRA's Cloudflare blocks browser UAs and serves plain clients. - Conditional GET fast path: persist the source
Last-Modifiedin the JSON payload; sendIf-Modified-Since; on 304 skip parse + row upserts and only refresh the snapshot + heartbeat with a newscan_time("source unchanged (304); refreshing snapshot only"). - Empty-payload guard: never cache or mirror an empty result (
persist_resultrefuses; protects last-good cache — cf. feedback_dont_cache_empty_results). - Timestamps: tz-aware UTC ISO
scan_time;ZoneInfo("America/New_York")for session logic, never hardcoded offsets. - Writes, in order, every cycle:
writer.ensure_no_replica_for_writers()writer.upsert_<name>_rows(series, recorded_at=scan_time)— only when rows changedwriter.upsert_scan_snapshot("<name>", scan_time, payload)— every cyclewriter.record_service_health("<name>", "ok", finished_at=scan_time)— every cycle, or error rows latch (feedback_service_health_heartbeat)- JSON fallback
data/<name>.json(atomic write) — fallback only; Turso is the source of truth (Data Persistence rule: host-local files are ephemeral on the VPS)
- Service name is kebab-case everywhere (
margin-debt, notmargin_debt— the underscore variant once wrote a strayservice_healthrow that had to be deleted by hand on the VPS). - CLI:
--jsonprints the payload to stdout; progress/summary to stderr (subprocess contract). - If IB is involved: bounded awaits (
asyncio.wait_for), range-based client IDs registered inscripts/CLAUDE.md,snapshot=Truemust pass""genericTickList.
2. Storage (Turso-first)
- Migration
scripts/db/migrations/00NN_<name>.sql:CREATE TABLE IF NOT EXISTS <name>_history (...)with a natural PK (dateor(symbol, date)), arecorded_at TEXT NOT NULL, aDESCindex, and the trailingINSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (NN, datetime('now')); - Applied automatically:
scripts/db/migrate.pyruns asradon-apiExecStartPreon deploy (no per-migration registration). Local:bun run db:migrate. - Writer: idempotent per-key
INSERT ... ON CONFLICT(date) DO UPDATE. Latest-snapshot reads go throughscan_snapshots (service, scan_time, payload)unless the payload is per-symbol (then a dedicated*_snapshotstable like rv-ratio/bpi). - Verify the row lands in Turso in production before calling the task done.
data/*.jsonis a stale fallback, never verification evidence.
3. API route (web/app/api/<name>/route.ts)
export const dynamic = "force-dynamic"; export const runtime = "nodejs";— GET only unless there is a real on-demand trigger (breadth's POST proxies FastAPI with cooldown + single-flight).export const radonCapability = "read";(GET market data). On-demand POST scans use a method map withread.spawn. Unclassified routes failweb/tests/assistant-catalog-pin.test.ts.- Chat
list_apis/call_apiderive from these pins plusscripts/api/assistant_catalog.py. Do not edit a handwritten OPERATIONS seed. A new FastAPI pin is enough for the assistant to call it; a Next-only route appears automatically and failsweb/tests/assistant-catalog-freshness.test.tsuntilweb/lib/assistant/nextLoaders.tsgains a static import for that route id. - Read through
dbFirstRead(web/lib/dbFirstRead.ts):fromDbselects the latestscan_snapshotsrow (WHERE service = '<name>' ORDER BY scan_time DESC LIMIT 1),fromDiskreadsdata/<name>.json, and the helper serves whichever content timestamp is fresher. MAX_AGE_MSderives from the real cadence with slack (5-min timer → 30min), commented with the reasoning: "older than X means the writer is down." A session-gated writer (Mon-Fri timer, RTH-only scanner) must NOT get a private budget: takegetFreshnessWindowMs("<name>", "closed")from the shared catalog (web/lib/serviceHealthWindows.ts, mirrored inscripts/watchdog/services.py) so the route and the watchdog agree. A daily Mon-Fri timer with a private 48h budget showed Friday's healthy snapshot as an outage every Sunday (vol-cone,e7323e4e; dispersion R-450 before it).- Missing contract: absent data is
HTTP 200+ a frozen{ missing: true, scan_time: null, series: [], ... }object — never a 4xx (feedback_http_status_for_real_errors). setCacheResponseHeaders(response, { maxAgeSeconds, staleWhileRevalidateSeconds, requestId, cacheState: "HIT", tags: ["<name>"] })scaled to cadence (daily → 300/3600).- Hook
web/lib/use<Name>.ts:useSyncHook({ endpoint, interval, hasPost, extractTimestamp: d => d.scan_time }). Poll interval matches cadence (hourly for a daily series);0pauses.
4. Chart tab (web/components/<Name>Panel.tsx)
- Gate order:
SpectralLoader(label="Loading <source> series") while(loading || syncing) && !data→SectionEmptyStateonmissing:true→ content. - History chart:
CriHistoryChartwith up to two series; SPX overlay = left axis,chartSeriesColor("primary"),scaleType: "log"for multi-decade price; the indicator on the right axis. TitleUPPERCASE,xTickFormatfor non-session x domains. - Range:
HistoryRangeChips+web/lib/historyRange.tspresets andBrushMinimap(values,range,onRangeChange,onCustom,testIdPrefix="<name>-brush"). Long monthly series default toAll; session charts default perdefaultPresetForLength. - Strip:
RegimeStrip/RegimeStripCelldesktop,useViewport()→MetricCellgrid mobile. - Brand tokens only —
var(--token)+color-mix(...), never raw hex/rgba; 4px max radius;InfoTooltipexplains the signal and thresholds; no em dashes in copy. - Freshness copy is derived, never asserted: header clock renders
lastSync(= payloadscan_time); aSOURCE UPDATEDcell renders the upstreamLast-Modified/data date. Never write "Refreshes daily/5m" unless it names the actual timer cadence — and grep the repo for existing instances before shipping cadence copy (UI Copy rule). Empty-state copy may reference "the refresh timer" only if that timer exists. - Next-update countdown is mandatory. Every indicator panel mounts
<FreshnessRail schedule={<NAME>_REFRESH} asOf={data.data_date ?? current.date} testId="<name>-freshness-rail" asOfTestId="<name>-strip-asof" />(web/components/FreshnessRail.tsx) directly under the strip, exactly asIvRankPanel.tsxdoes. It rendersAs of <date>plus a liveNext samplecountdown, and it derives both fromweb/lib/refreshSchedule.ts(nextRefreshUtc) andweb/lib/freshnessRail.ts(computeFreshnessRail,formatCountdown,WRITER_GRACE_MS) — never from a cadence string in the panel.behind/overduestates come from the ET session calendar, so a Friday-evening reading is "current" over the weekend and only turns amber once the timer slot for the missing session has passed plus the writer grace. An indicator without a timer (on-demand scans) has no schedule to countdown and skips the rail; say so in the spec. - Chart-system spec: panels inherit
surface.paddingPx: 16/radiusPx: 4fromweb/lib/chart-system-spec.json(pinned by og-chart tests).
5. Tests (red/green TDD — write these first)
- pytest (
scripts/tests/test_<name>.py): checked-in real fixture artifacts (a captured upstream file) parsed at import; expected values derived by inspecting fixtures, never mental arithmetic; window-relative dates for anything freshness-related (hardcoded dates rot in CI); migration executed into in-memory sqlite to pin schema + version + upsert idempotency; conditional-GET stub client asserting the 304 path heartbeats without row upserts; monkeypatch_write_db_cache/writer —db.client.get_db()andhrana_httprefuse real connections underPYTEST_CURRENT_TESTby design. - vitest API (
@vitest-environment node): mock@/lib/dbwith a real in-memory@libsql/clientseeded with the actual table so SQL executes; assert Turso-beats-older-disk, disk fallback, exactmissing:trueobject at 200, no cross-service snapshot leak,route.dynamic === "force-dynamic". - vitest panel (
@vitest-environment jsdom): stubResizeObserver;vi.mockthe hook; factory fixtures (buildSeries(n),hookState()); assert loader label, empty state, strip values, chart title, chips/toggles, a NaN guard (no <path d> contains "NaN"), and the freshness rail:[data-testid="<name>-freshness-rail"]renders,<name>-strip-asofshows the payload date, and theNext samplecell shows a countdown (usevi.useFakeTimers()+vi.setSystemTime()pinned one hour before the slot and assert the formattedformatCountdownvalue, asweb/tests/freshness-rail-render.test.tsxdoes). - Playwright (
web/e2e/<name>-tab.spec.ts):page.routemocks for**/api/<name>+ the ambient routes (portfolio,orders,ib-status), abort**/api/prices; assert active tab, rendered paths, brush visible, missing-state copy. Localhost dev server needs no login (RADON_AUTHLESS_TEST=1+ dev-mode localhost bypass inweb/middleware.ts). - Run from repo root (cwd drift produces bogus failures): full vitest is
bunx vitest run --config vitest.config.ts; pytest ispython -m pytest scripts/tests scripts/api/tests scripts/trade_blotter+python -m pytest cloud/tests -q.
6. Scheduling and deploy
- Units in
cloud/services/:radon-<name>.service(Type=oneshot,User=radon,WorkingDirectory=/home/radon/radon,EnvironmentFile=/etc/radon/env,Environment=RADON_DB_NO_REPLICA=1,ExecStart=/home/radon/radon/.venv/bin/python /home/radon/radon/scripts/fetch_<name>.py,TimeoutStartSecsized to the job, journald out/err) +radon-<name>.timer(OnCalendar=... UTC,Persistent=true,RandomizedDelaySec,WantedBy=timers.target). Comment the OnCalendar choice. - Register in
setup-vps.shSERVICE_FILES,cloud/tests/test_systemd_services.py, andcloud/config/installed-units.sha256(sha256 of each unit file). The deploy'sinstall-unitsverb then installs the pair root-owned and enables the timer on the next green deploy; verify on the host withsystemctl list-timers 'radon-<name>*'. - Prod env note: units call the venv python directly — the
run_*.shwrapper fallback ladder resolves the wrong python on the VPS (feedback_scan_wrapper_fallback_picks_system_python).
7. CI gates and shipping
CI (.github/workflows/ci.yml) gates every push to main, then auto-deploys on green:
- gitleaks secret scan
bunx vitest run --config vitest.config.ts --coverage— full config, coverage ratchet lines 75 / functions 78 / branches 65 (web/app/api/**is inside coverageinclude— an untested route drops the ratchet)python -m pytest scripts/tests scripts/api/tests scripts/trade_blotter --cov=scripts --cov=api --cov-branch --cov-fail-under=64+python -m pytest cloud/tests -q(validates your systemd units)- Perimeter smoke (next build + curl auth asserts)
- Deploy to Hetzner (no manual approval; never push while a deploy is in flight — cancelled builds have corrupted production)
Playwright is not in CI — run it locally and attach the evidence.
Ship checklist: one focused commit (stage files explicitly, never git add -A), push once, gh run watch to green, then verify production: latest scan_snapshots/history rows in Turso, curl the prod API route (authenticated perimeter: anon 401/404 is the perimeter working, not an outage), and a browser screenshot of the live tab.
8. Gotchas that cost real debugging time
- Breadth's
serviceHealthWindowscategory/copy drifted from reality twice (says "on-demand" + "IB gateway" though a 5-min timer + StockCharts now drive it). When the data source or cadence changes, sweep every copy string that mentions it. - The
regime-tab-routesandservice-health-windowstests are deliberate lockstep pins — update them with the feature, in the same commit. - In-memory libsql test schemas can drift from the real migration (breadth's fixture omits a PK column). Seed test tables from the migration file when practical.
- Divergence/threshold semantics: use strict inequalities and pin the boundary in a test.
- A brand-new timer's first
service_healthrow may be absent until first fire — no-row-ever = dormant, don't page (feedback_watchdog_dormant_no_row).