The complete design of a personal trading-research system: one graded strategy (12-1 momentum, top‑3, weekly), a walk-forward harness that makes backtests hard to fake, a history database, and a four-tab operator cockpit — built small on purpose, with every technology choice explained against its alternatives.
One paragraph for the person who just walked in.
Spectrum is a single-user trading research system that runs one strategy: every Friday it ranks ~80 large, liquid US stocks by how far they climbed over the past 12 months (ignoring the most recent month), applies two safety filters, and "buys" the top 3 with a simulated $40,000. It never touches a brokerage — every ticket is paper. Around that one strategy sit four layers of machinery: a data layer that fetches and caches prices, news and world signals; a harness that grades the strategy honestly over ten years of history; a history database that stores every fetched value and serves the UI; and a cockpit — four web tabs (Home, Orders, Strategy, Architecture) plus WhatsApp-ready reports.
What it is not: it is not a trading bot (no orders leave the machine), not a signal service, not advice, and not a machine-learning system — the strategy has no fitted parameters at all, which is a deliberate defense against overfitting (§5).
Two lanes that meet at the operator layer, and a database the UI actually reads.
The architecture is two pipelines, not one. The research lane (solid arrows) is slow,
cached and reproducible: prices and filing dates flow into a factor panel, through the harness, and
out as graded results. The live lane (dashed arrows) is fetched fresh at every cockpit build:
news sentiment, world indices, headlines. They meet only in ops.py, which assembles the
cockpit payload, persists everything to SQLite, and lets the API serve the newest snapshot from
the database.
Each layer: what it does, why it exists, and why this tool instead of the obvious alternatives.
Polygon.io supplies ten years of split/dividend-adjusted daily bars for ~133 symbols — including 18 companies that later delisted (bank failures, buyouts). That last detail is the whole reason for the choice: a momentum backtest without the corpses flatters itself (§5, lie #2).
SEC quarterly filings (via Polygon's financials endpoint) act as the earnings-date proxy for the earnings-avoid rule. The dedicated earnings-calendar endpoint isn't in the current plan tier, so filing dates stand in — they lag the actual earnings release by 0–4 days, an approximation the system discloses rather than hides.
Adanos scores stock news sentiment (per-ticker and market-wide). Yahoo's chart API provides keyless global index/commodity/crypto quotes, and BBC/CNBC/DW RSS feeds provide world business headlines — both chosen because they require no credentials and degrade gracefully.
Every Polygon response is cached to a parquet file (data/bars/SYMBOL__d3800.parquet).
Re-runs read from disk in milliseconds and cost zero API quota. Prices are the one dataset kept
outside SQLite, on purpose.
factors.pyThe panel is one long table: a row per (date, symbol) holding the close price, the 12-1 momentum value, and 20-day trailing dollar volume. Two properties matter more than the features themselves:
Point-in-time by construction. The momentum formula
close.shift(21) / close.shift(252) − 1 uses only non-negative shifts — the value on any
date is computable from prices up to that date, mechanically.
Per-symbol computation. Shifts run on each symbol's own trading calendar, not a global date grid. This is a scar, not a nicety: an earlier matrix-shift implementation quietly misaligned symbols with gaps and moved the headline result by ~3 points/yr. The per-symbol version is canonical.
momentum.py, the refereeThe harness is the single choke-point where returns are computed. It walks forward week by week for 451 weeks: build that Friday's 80-name universe from that Friday's liquidity, rank by 12-1 momentum, drop names reporting earnings within 10 days, drop names with negative own-momentum (their slot stays in cash), hold the top 3 for a week, charge 5 bps × turnover on whatever changed. Nothing is fitted; the loop's output — a weekly net return stream — is the raw material for every statistic in this document.
Its importance is structural: the cockpit, the orders, the tabs all just read its outputs. One referee means numbers are comparable across every idea ever tried, and the live Friday picks are literally the last line of the same computation that produced the 10-year record — "backtest" and "live" cannot drift apart.
No — Temporal is not used anywhere in Spectrum. The word "harness" invites the confusion, so let's separate the two things properly:
| Spectrum's harness | Temporal (temporal.io) | |
|---|---|---|
| What it is | A backtesting referee: a deterministic, single-process Python loop that replays history and grades a strategy. | A durable workflow orchestrator: a server + workers that execute long-running, distributed workflows with retries, timers, signals and exactly-once semantics. |
| Problem it solves | "Is this number honest?" — look-ahead, survivorship, costs, overfitting. | "Does this multi-step process survive crashes, waits and partial failures?" |
| Runtime shape | Seconds of CPU over cached local files; rerunning from scratch is the recovery story. | Days-long sagas across services; replayable event history is the recovery story. |
Spectrum's workloads are two short batch jobs (spectrum momentum,
spectrum ops) that each finish in seconds-to-minutes on one machine, touch local caches,
and are safely re-runnable because every write is idempotent (parquet cache keyed by symbol, SQLite
INSERT OR REPLACE keyed by timestamp). Durability comes from the artifacts — the cache,
the database, the JSON exports — not from resumable execution state. Standing up a Temporal server,
workers, and workflow/activity code to wrap two idempotent CLI commands would add operational
surface (a cluster to run, versioned workflow code, a new failure domain) while removing nothing.
data/versions.jsonEvery idea ever evaluated carries a trial number (#1–17 so far) recorded with its result. The locked baseline, the promoted live configuration, and every killed variant live here. The ledger is what makes "we tried ten things and kept the winner" impossible to hide — the count is public (§5).
ops.pyThis is where research becomes a morning routine. It reads the harness's current picks, sizes
three equal-dollar paper tickets from $40,000, fetches the live lane (sentiment, quotes, headlines),
computes the world strip and "what this means for your book" lines, writes the morning/evening
reports, and builds wa.me click-to-send links. It also owns the resilience rules: every
fetched value is persisted, and if a live feed fails, the last stored value is served, labeled
cached.
wa.me/?text=… URL pre-fills WhatsApp with the report and needs zero credentials, zero
webhooks, and keeps a human's thumb on the send button. Twilio auto-send exists behind four
environment variables for the day hands-off delivery is wanted. The system never fabricates a
"sent!" it didn't perform.store.py + SQLiteEvery build writes two kinds of rows: typed history (market sentiment, per-ticker sentiment, news items, global quotes, picks, orders, account marks, momentum report cards — eight tables, keyed by timestamp, deduplicated) and a full snapshot of the build payload (newest 60 kept per kind). The API serves the newest snapshot; the typed tables feed trends (sentiment sparkline, account history) and the cached-fallback path. Flow: fetch → store → read from the store.
Two data endpoints (/api/ops, /api/momentum) and five page routes. Each
data response is stamped served_from: "db" | "file" so provenance is always one glance
away.
Home (picks first, world context, track record — deliberately no dollar figures), Orders (account,
tickets, reports, 322-trade history, account history), Strategy (rules + report card), Architecture
(the live version of this document's diagrams). Each page is one HTML file: CSS design tokens
(light/dark via prefers-color-scheme plus a manual toggle), vanilla JavaScript fetching
the API, hand-drawn inline SVG for sparklines and diagrams, an identical fixed-metric header so
switching tabs never shifts the chrome.
node_modules, no build
pipeline to rot, view-source is the source, and a beginner can read any page top to bottom.
The trade-off (some duplicated header/CSS across files) was accepted consciously; at 4 pages it is
still cheaper than a framework. If the UI grew stateful — live re-ranking, editable orders — that
calculus flips.All keys live in .env files loaded once by config.py; nothing secret is
hardcoded or committed. Costs, paths and the capital default are plain constants in one place.
Logging goes through Python's logging with warnings for every degraded path (a failed
feed, a fallback served).
Cross-sectional momentum — Jegadeesh & Titman (1993), Carhart's 12-1 convention — concentrated to three names.
The idea, in kid terms: line up the ~80 biggest, easiest-to-trade companies each Friday and measure how far each climbed over the last year — ignoring the most recent month, because last month's fireworks usually fizzle. The three highest climbers get the money, split equally. Anyone with earnings due within 10 days sits out; anyone actually down over its own year sits out (that slot stays in cash). Next Friday, re-run the race.
close[t−21] / close[t−252] − 1. Both
shifts point backwards, so the ranking cannot contain the future; the skipped month dodges
short-term reversal. A pre-registered sweep of 3-, 6- and 9-month windows (trials #15–17) lost to
12 months on every metric — quarterly momentum collapsed to 14.5%/yr with a −70% drawdown.| Metric | Strategy (top-3, 12-1) | SPY | QQQ |
|---|---|---|---|
| Annual return | +47.2% | +13.4% | +19.4% |
| Annual volatility | 41.9% | 16.1% | 20.4% |
| Sharpe ratio | 1.13 | 0.83 | 0.95 |
| Max drawdown | −40.9% | −27.8% | −35.5% |
| Best / worst week | +29.2% / −20.7% | — | — |
| Winning weeks | 57% | 60% | 59% |
| Total return, compounded (451 weeks) | +3,093% (×31.9) | +209% | +390% |
| Trade-level (322 episodes, 3 open) | Value | Meaning |
|---|---|---|
| Win rate | 55.3% | share of holding episodes that made money |
| Average win / loss | +13.1% / −6.3% | the asymmetry that does the work |
| Payoff ratio | 2.06 | average win ÷ average loss |
| Profit factor | 2.55 | gross wins ÷ gross losses |
| Best / worst trade | +163.9% / −46.9% | momentum's shape: rare huge winners |
| Average holding | 4.2 weeks | winners are re-elected weekly; losers rotate out |
A backtest is a machine for lying to yourself. The harness makes the four classic lies structurally impossible.
| The lie | The structural fix | Proof from this project |
|---|---|---|
| #1 Peeking at the future (look-ahead) | Signal formula uses only backward shifts; the week's return is what happened next. | Nov-2021: the ranking put Signature Bank (SBNY) on top; the engine bought it and later ate the collapse — an engine that could peek would have skipped it. Deliberate "cheat detector" runs (a signal allowed to see one week ahead) produce absurd numbers, calibrating what peeking looks like. |
| #2 Forgetting the dead (survivorship) | Universe rebuilt as-of each Friday from trailing dollar volume, over a list that keeps 18 delisted names in history until they actually died. | Momentum loves stocks that later blow up; deleting the corpses visibly inflates a top-3 book's record. |
| #3 Trading for free (costs) | 5 bps × turnover charged on every change, computed as the symmetric difference of consecutive books. | A published VWAP strategy graded +21%/yr at zero spread and −1.3%/yr at a 1-cent half-spread. Thirteen intraday strategies were graded here; at realistic costs, zero beat buy-and-hold. |
| #4 Keeping the flattering try (overfitting) | Hypotheses pre-registered from literature; every attempt gets a public trial number; killed ideas stay killed; the full 10-year window is always shown. | The "obvious" 200-day-SMA cash gate was falsified twice (below-trend weeks were the book's best, 66% win). The 5-year Sharpe of 1.77 deflated to ~1.1 over ten years — era-inflation shown, not hidden. |
The bars every idea must clear, net of costs, over the full window: beat SPY and QQQ buy-and-hold (otherwise just buy the index), beat the incumbent it wants to replace, and hold up in the year-by-year table (one lucky era is not a strategy). Seventeen trials have walked the ladder; the current live configuration (trial #14) is the only promotion.
The system end to end, three ways a beginner can trace it.
spectrum ops1) Read the harness's current picks from the newest momentum output. 2) Fetch live: per-pick
sentiment, market gauge, global quotes, world headlines. 3) Size three equal-dollar paper tickets
from $40,000 at last close (whole shares; the remainder is the cash buffer). 4) Compose the world
strip, impact lines, marquee, morning/evening reports and wa.me links. 5) Persist: typed
history rows + the full snapshot into SQLite (and JSON exports). 6) The API's next
SELECT serves the new snapshot; the cockpit refreshes.
133 names → 80 most liquid that day → ranked by 12-1: MU +610%, INTC +306%, AMAT +233% on top → nobody reports earnings within 10 days, all momenta positive → tickets: BUY 29 AMAT ≈ $13,187 · BUY 139 INTC ≈ $13,316 · BUY 13 MU ≈ $13,216 → cash buffer $282 → snapshot stored, cockpit and reports carry the same three tickets.
Adanos times out during a build. The market-gauge fetch raises; the operator layer logs a warning,
reads the last stored gauge from market_sentiment, labels it "(cached — live feed
unavailable)", and the cockpit renders complete. Same pattern for global quotes and both news feeds.
A flaky vendor degrades freshness, never availability — that is the practical payoff of
fetch-→-store-→-serve.
Every tool defends its seat; every absence is a decision, not an omission.
| Chosen | Role | Why it won | Alternatives considered |
|---|---|---|---|
| Python 3 + pandas/numpy | everything computational | the lingua franca of quant research; vectorized panel math; the whole system is readable by one person | R (weaker app/serving story) · Rust/C++ (speed this workload doesn't need) |
| Polygon.io | 10-yr adjusted daily bars incl. delisted | survivorship-safe history, flat pricing, plain REST | yfinance · Alpaca (IEX-only) · Bloomberg |
| parquet (pyarrow) | price cache | columnar, compressed, pandas-native, zero server | CSV · SQLite-for-bars · TimescaleDB |
| SQLite (stdlib) | history DB + snapshot store the API serves | zero dependencies, transactional, one copyable file, fits single-writer reality | PostgreSQL · DuckDB · "just JSON files" |
| FastAPI + uvicorn | API + page serving | typed, tiny, self-documenting, grows without rearchitecting | Flask · static file server |
| Vanilla HTML/CSS/JS | the four tabs | no build step, view-source debugging, design tokens for theming | React/Vue + bundler |
| httpx | all HTTP | timeouts and pagination handled cleanly; modern API | requests · urllib |
| wa.me links (Twilio optional) | notifications | zero credentials, human confirms every send | Twilio-only · email · Slack |
| bespoke walk-forward loop | the harness | auditability is the product; reproduces the locked record to the decimal after every refactor | backtrader · zipline · vectorbt |
| Not used | What it's for | Why it doesn't earn a seat here |
|---|---|---|
| Temporal / Airflow / Dagster | durable, distributed workflow orchestration | the workloads are two idempotent, seconds-long local batch jobs; recovery = re-run. Orchestration adds a server, workers and a failure domain while removing nothing (§3.4.1). Scheduling, when wanted, is one cron/launchd line. |
| Backtesting frameworks | event-driven simulation engines | opaque cost/corporate-action models undermine the referee's whole purpose: line-by-line auditability. |
| Machine learning / fitted parameters | prediction | this project's own intraday-ML arm was graded and killed (edge < spread); the live strategy is deliberately parameter-free so there is nothing to overfit. |
| LLM scoring of historical news | sentiment backtests | the model already knows how the stories ended — look-ahead via training data. Sentiment is live-display only. |
| PostgreSQL / cloud DB | multi-user, concurrent state | one user, one writer, one machine; SQLite is the correct size and the upgrade path is standard. |
| React + bundler | stateful frontends | four read-mostly pages; a toolchain would outweigh the UI it builds. |
| Docker / Kubernetes | deployment isolation and scale | a local venv on one machine; containers would containerize nothing that moves. |
| Real brokerage APIs | order placement | out of scope by principle — Spectrum is research; every ticket is paper, forever, until its operator decides otherwise. |
spectrum/
├── pyproject.toml # deps: numpy pandas pyarrow httpx dotenv fastapi uvicorn
├── data/
│ ├── bars/*__d3800.parquet # price cache (10-yr daily, per symbol)
│ ├── spectrum.db # SQLite: 8 history tables + snapshots
│ ├── momentum.json # export of the latest harness run
│ ├── ops.json # export of the latest cockpit build
│ ├── versions.json # the trial registry / grading ledger
│ ├── earnings_filings.json # SEC filing dates per symbol
│ └── reports/ # morning/evening report text files
└── spectrum/
├── config.py # env, keys, paths — the only place secrets are read
├── factors.py # fetch/cache bars; point-in-time factor panel
├── momentum.py # THE HARNESS + trade episodes + report card
├── ops.py # operator layer: picks→tickets, world, reports, persist
├── store.py # SQLite schema + writes + history reads + snapshots
├── api/server.py # FastAPI: pages + DB-served /api endpoints
└── web/ # ops.html · orders.html · strategy.html ·
# architecture.html · whitepaper.html (this document)
spectrum momentum # rebuild the graded live book (writes DB + exports)
spectrum ops # build the cockpit: fetch → store → serve (opt. --send)
spectrum serve # run the dashboard (--port 8060 --reload)
GET /api/ops newest cockpit snapshot (served_from: db | file)
GET /api/momentum newest harness snapshot (served_from: db | file)
GET / /orders /strategy /architecture /whitepaper — the pages
| Table | Grain | Holds |
|---|---|---|
market_sentiment | per build | score, bullish/bearish %, trend, mentions |
ticker_sentiment | build × symbol | per-pick sentiment score, mentions, trend |
news | unique headline | holding + world headlines (deduplicated) |
global_quotes | build × symbol | Nikkei/HSI/DAX/FTSE/10Y/gold/oil/BTC marks |
picks | per rank date | the top-3 and the full ranked race |
orders | rank date × symbol | paper tickets: side, qty, ref price, cost |
account | per build | capital, cash, deployed, book value, P&L |
momentum_stats | per harness run | the report card (CAGR, Sharpe, win %, PF…) |
snapshots | kind × build | the full payload the API serves (last 60/kind) |