Buys on insider cluster purchases (2+ insiders, open-market, C-suite weighted 2x, weighted score ≥ 5, $250k+ total), filtered by trend (price > 50DMA) and volatility regime (VIX < 30). Paper trades against a self-simulated $100k account (cash + positions in D1, priced via Yahoo Finance — no brokerage account needed). Daily digest + weekly recap by email.
Thresholds come from the 2021–25 backtest sweep (backtest/sweep.py): score ≥ 5 was the only slice with positive alpha both in-sample and out-of-sample. The edge is small and within noise — the paper run is the real test.
Sell rules: hard stop −8% · trailing stop 15% off peak (armed at +10%) · time stop 90 days.
Free & open-source — if it's useful, a coffee keeps the hosted feed running.
This is the full source — backtest, Cloudflare Worker, and public site — released under AGPLv3. Two ways to use it:
- Self-host it. Clone, set a handful of secrets,
wrangler deploy, and you have your own copy of everything below on your own Cloudflare account. Setup is in Parts 1–3. - Skip the setup — use the hosted instance. Follow any House member or corporate insider and it emails you when they disclose a new trade. No account wiring, no cron, no API keys. Free.
The code is public because the whole premise is honest, reproducible analysis — every number traces to a public filing and a script in this repo (see the Analysis blog). AGPLv3 keeps that true downstream: modify it freely, but if you host your version for others, publish your changes too.
Identity is config, not code. This tree carries no personal details. The SEC/House contact
USER_AGENT, the CAN-SPAMCONTACT_ADDRESS, and the operatorEMAIL_TOare allwrangler secrets (withSIGNALBOT_UA/SIGNALBOT_EMAIL_TOenv vars for the local Python scripts). Set your own before running — unset, they fall back to placeholders that SEC will rightly throttle. This is also what lets the public repo be a byte-for-byte mirror of the deployed one.
SignalBot ships a Model Context Protocol connector — the same kind of thing the paid trackers charge a subscription for, except it's free and runs over open public data. Add one URL to Claude and just ask:
- "What did Nancy Pelosi disclose this month?"
- "Any insider clusters worth a look this week?"
- "Show me all the Congress and insider activity on NVDA."
- "Is Trump buying or selling his own stock (DJT)?"
- "Which House members trade the most, and what's crowded right now?"
Setup — 30 seconds, no login: in Claude → Settings → Connectors → Add custom connector → paste:
https://signalbot.cannappy.org/mcp
It's read-only and rate-limited; every answer traces back to a House STOCK Act filing or an SEC Form 4. Tools exposed:
| Tool | What it answers |
|---|---|
search_people |
find a member or insider by name |
get_person_trades |
one person's disclosed trades (PTR or Form 4) |
get_ticker_activity |
Congress + insider buys/sells + cluster signal for a ticker |
recent_congress_trades / recent_insider_buys |
the latest disclosures |
insider_clusters |
tickers where multiple insiders bought together |
most_active_members / most_traded_tickers |
leaderboards |
get_ticker_analysis |
the cached AI read on a ticker |
Self-hosting? The connector is served straight off your own Worker at /mcp — deploy (Parts 1–3) and yours lives at <your-domain>/mcp.
cd backtest
pip install -r requirements.txt
python fetch_data.py --start 2021Q1 --end 2025Q4 # ~10 min, pulls SEC + Yahoo data
python backtest.py # prints stats, writes data/trades.csvTunables live at the top of strategy.py. Things worth sweeping:
MIN_CLUSTER_VALUE(50k / 100k / 250k)TIME_STOP_DAYS(60 / 90 / 180)CSUITE_WEIGHTandMIN_SCORE- Trail % / arm threshold
One account needed: Resend (resend.com, free tier) → API key + verified sending domain.
cd worker
npm i -D wrangler typescript @cloudflare/workers-types
npx wrangler d1 create signalbot # paste ID into wrangler.toml
npx wrangler d1 execute signalbot --file=schema.sql --remote
npx wrangler deploy
npx wrangler secret put RESEND_KEYSet EMAIL_TO (a wrangler secret, so the operator address stays out of the
committed config) and the from: address in src/email.ts. Also set
USER_AGENT (wrangler secret put USER_AGENT) to your SEC contact string —
e.g. Org signalbot you@example.com. The EDGAR poller sends it on every
request; the placeholder default (config.ts) gets throttled.
| Cron | What happens |
|---|---|
| Every 10 min, 6am–10pm ET | Poll EDGAR latest Form 4 feed (3 pages, new filings only) → store open-market buys in D1 |
| ~3:45pm ET daily (before close) | Check exits (stops/trails/time) → detect clusters → apply filters → paper buy $1k/position (max 10), reconcile actual fills → daily email |
| Friday ~6pm ET | Weekly recap — bot vs SPY, closed trades |
| ~7am ET daily | Ingest new House PTRs → email subscribers the trades their followed people disclosed |
Runs before the close on purpose: market orders placed after hours queue to the next open, so recorded entry/exit prices would drift from real fills. Job failures trigger an error email.
Insider track records — automatic. When a position closes, its alpha vs SPY is attributed to every insider in the triggering cluster (insiders table). Once someone has 3+ closed trades, their avg alpha shows up in the daily email when they buy again. Query anytime:
SELECT owner, trades_closed, wins, ROUND(sum_alpha/trades_closed*100,1) AS avg_alpha_pct
FROM insiders WHERE trades_closed >= 3 ORDER BY avg_alpha_pct DESC;Watchlist — add anyone (insider name as filed, politician, or ticker) and every buy they make hits the daily email regardless of cluster rules:
npx wrangler d1 execute signalbot --remote --command \
"INSERT INTO watchlist (name, kind, note) VALUES
('COOK TIMOTHY D', 'insider', 'AAPL CEO'),
('Pelosi', 'politician', ''),
('NVDA', 'ticker', 'any insider buy');"A free public feed of House STOCK Act disclosures, with email alerts on the people you follow. No API key or data vendor: the House Clerk publishes a ZIP index of every disclosure, refreshed daily, and the trades are parsed straight out of the filing PDFs (congress.ts, via unpdf).
| Route | What it is |
|---|---|
/politicians |
Public feed — latest trades, most active members, most crowded tickers |
/p/<slug> |
One person's trade history, holdings + net worth, Follow button (politicians and insiders both) |
/insiders |
Form 4 track records, ranked by realized alpha vs SPY |
/subscribe |
Magic-link sign-up — no passwords |
/run/congress-backfill?limit=25 |
Manual chunked backfill (chunked on purpose; see below) |
/run/fd-backfill?year=2025&limit=10 |
Same, for annual reports (holdings + net worth) |
/run/ocr?limit=20 |
Read paper-filed PTRs with a vision model (chunked by page; see below) |
Live at https://signalbot.cannappy.org. The workers.dev host now redirects here
(sessions and Turnstile only work on the canonical domain); workers_dev = true is
still load-bearing — declaring a route silently disables that host otherwise, and the
redirect itself needs it reachable.
Setup on a fresh DB: the schema.sql from Part 2 is the full current schema
— every congress, net-worth and OCR table included — so a fresh D1 needs no
migrations. The numbered files in migrations/ are the incremental upgrade
path for a DB deployed before a feature landed: apply the ones newer than your
deployment, in order. They're additive and safe against live data, but the
ADD COLUMN ones (004, 006b, 008, 009) can't be re-run, so never re-apply a
migration whose columns your schema already has.
# UPGRADING an existing DB only — a fresh one is already complete from schema.sql.
# The full ordered set; skip any already applied to your deployment.
for m in 002_congress 003_insider_people_and_search 004_congress_networth \
005_rate_hits 006a_congress_tickerless 006b_congress_ocr \
007_fd_skipped 008_opportunistic_shadow 009_filings_avg_price \
010_insider_sells 011_normalize_congress_filing_dates; do
npx wrangler d1 execute signalbot --file=migrations/$m.sql --remote
done
npx wrangler secret put SESSION_SECRET # any long random string
npx wrangler secret put RUN_KEY # gates /run/*; unset = they 404 for everyone
npx wrangler secret put OPENROUTER_KEY # /analyze, and OCR of paper filings (src/ocr.ts)
npx wrangler secret put TURNSTILE_SECRET # pairs with TURNSTILE_SITEKEY in wrangler.toml
npx wrangler secret put EMAIL_TO # operator address for digests + error alerts
npx wrangler secret put USER_AGENT # SEC/House contact UA: "Org app you@example.com" (required — placeholder gets throttled)
npx wrangler secret put CONTACT_ADDRESS # physical mailing address for the alert-email CAN-SPAM footer
npx wrangler deploy
# backfill, 25-50 at a time until remaining=0. /run/* is POST + needs the key.
# `wrangler secret put RUN_KEY` sets it in the Worker; the curls below read it
# from YOUR shell, so export the same value locally first (it isn't set for you):
export RUN_KEY='the-same-long-random-string-you-gave-wrangler'
curl -XPOST -H "X-Run-Key: $RUN_KEY" "https://signalbot.cannappy.org/run/congress-backfill?limit=50"
curl -XPOST -H "X-Run-Key: $RUN_KEY" "https://signalbot.cannappy.org/run/insider-people"/run/* mutates the paper account and hammers SEC/House from our IP, so it is
gated by RUN_KEY (404, not 401 — a prober learns nothing). It was briefly
reachable by anyone on 2026-07-17; don't reintroduce that.
Signup is Turnstile-protected, which means you cannot test it with curl or a headless browser — both get rejected, by design. Use a real browser.
Measured across all 303 of 2026's PTRs — the numbers to expect, not guesses:
- 191 filings parse → 2,107 trades across 622 tickers.
- 81 parse to nothing, correctly — Treasury bills, munis, private funds, annuities. No ticker, nothing to show.
- 31 are paper scans with no text layer (~10%), 305 pages in total.
TXN_RXcan never see these; they are read by OCR instead (below). Until the OCR job has read every page of one it staysstatus='scanned'and is surfaced in the UI rather than hidden, so a paper filer doesn't look like a non-trader.
Two things that bite when touching TXN_RX in congress.ts:
- Never blocklist tickers.
GS,ST, andETare Goldman Sachs, Sensata, and Energy Transfer — and also look like asset-type codes. The regex is safe because the ticker group is bounded by parens and a separate bracketed asset-type group. - Never dedupe on the visible fields. A member selling one ticker out of three accounts on one day files three rows differing only by account.
row_idx(position in the filing) is what makes a row unique.
The parser is validated offline before deploy — see parsePTR's doc comment for the expected split. The daily cron emails a warning if a batch of filings suddenly parses to zero trades, which is how a Clerk template change would show up.
The 31 scanned PTRs are one CCITT Group 4 fax image per page, no text layer. pdf.js decodes that to a 1-bit bitmap in pure JS, which ocr.ts re-wraps as PNG (fflate for the zlib stream) and sends to a vision model. That combination is the whole trick, and it is why Tesseract is not here: OCR needs pixels, rasterizing a PDF needs a canvas, and workerd has none. Measured in workerd: ~40ms CPU and ~30KB of PNG per page, no nodejs_compat needed. The forms are also part-handwritten, which a vision model reads and Tesseract does not.
Model choice is not free-tier. grade-ocr.mjs scores a model against pages transcribed by hand. The amount column is a grid of narrow checkboxes; reading it means counting which column an X sits in, and only top-tier models do that reliably. Graded: gemini-2.5-pro and claude-sonnet-5 both hit 100% on amounts; gemini-2.5-flash, gpt-5-mini, and the free gemma/nemotron/nova tier landed 0–56%, systematically off by a column. OCR_MODEL defaults to gemini-2.5-pro. There is deliberately no cheap fallback — a wrong amount in a public feed is worse than leaving the filing scanned. Re-grade before changing the model. Cost is negligible: 305 pages once, then a trickle.
curl -XPOST -H "X-Run-Key: $RUN_KEY" "https://signalbot.cannappy.org/run/ocr?limit=20" # until remaining=0Chunked by page, not by filing: the biggest filing is 56 pages, a vision call takes seconds, and Cloudflare cuts a request off at 100s. Each page is banked in congress_ocr_pages as it's read, so a run only pays for what isn't done, a failure resumes, and re-assembling after a prompt change is free. A filing's trades are written only once every page is in hand — a partial one would publish a silently short trade count.
What comes out is not the same as parsed data, and nothing pretends otherwise:
- Rows are stored
source='ocr'and taggedOCRon every surface that renders them. - They are tickerless by construction — the paper form says "provide full name, not ticker symbol" — so they can never reach a ticker surface (search,
/analyze, clusters). - Amounts are lettered checkboxes (A–J), mapped to brackets in code rather than read as text. Column K is a flag, not an amount ("Transaction in a Spouse or Dependent Child Asset over $1,000,000"); returning it as one would invent a $50M+ trade.
- The blank form carries a pre-printed example row ("Example: Mega Corp. Common Stock", dated 8/14/12 or 02/05/24). It is not a trade. The prompt forbids it and
rowsToTxnsdrops it again — it sits on every form and would otherwise ingest as a real disclosure. - An unreadable amount is stored
NULLand renders as "—". Never guess a bracket the filing didn't give.
The same yearly ZIP also indexes annual reports — the only place holdings exist. PTRs are flow (what changed); annual reports are the stock (what's held). A member who has owned NVDA since 2019 files no PTR for it and appears only here. Schedule A gives assets, Schedule D liabilities, both as brackets. 372 members filed for 2024, and 20 of 20 sampled had a text layer — no paper-scan problem, unlike PTRs.
Three traps, all of which bite silently:
- Filing type
Ais Amendment, not Annual. The annual report isO. Filtering onAbecause it looks right gets you 103 amendments and none of the 372 reports. - Section headers extract as NUL bytes. The form sets "SCHEDULE A: ASSETS" in a small-caps font with no Unicode mapping, so
unpdfreturnsS\0\0\0\0\0\0\0 A: A\0\0\0.\sdoesn't match NUL, so matching raw text finds zero schedules and silently yields zero assets for every member. Run text throughdeNulfirst. (pdftotextrenders those as spaces, so a prototype built against it works and the Worker doesn't — validate againstunpdf.) - The Value column isn't always money. It can read
NoneorUndetermined(dormant LLCs, private interests, holdings valued at the account level). If the regex only accepts a dollar bracket, those rows don't match, the scan runs on to the next row's bracket, and it both drops the asset and glues its text onto the following asset's name. 92 of 1,571 sampled asset rows are these.
/p/<slug> shows the range and calls the midpoint what it is; don't render the point estimate alone, and don't clamp negatives to zero to make the page look tidier.
efdsearch.senate.gov sits behind bot protection (Akamai; blocks non-browser TLS fingerprints, so a Worker fetch can't reach it either). Its front door is a checkbox affirming 5 U.S.C. app. § 105(c) — which makes it unlawful to use these reports "for any commercial purpose, other than by news and communications media for dissemination to the general public." Automating past that to feed a paid tier is the thing the checkbox says you won't do. If you want the Senate, license it from a vendor who has made their own call on § 105(c).
That statute governs the House reports too. The public feed is free on purpose: "dissemination to the general public" is the exception every competitor relies on, and it's what keeps this on the right side of the line. Charge for what you make — the AI analysis, the Form 4 alpha records, the alert delivery — not for the disclosure data itself.
PTRs are filed up to 45 days after the trade. This is a transparency and audience product, not a trading edge — congressional trades never touch signals or positions, and /politicians says so on the page.
There's no brokerage attached — going live would mean adding a broker API (that's a feature, not a config flip). Don't even think about it until the paper results have earned it.
- SEC/House require a real User-Agent with contact info. It is not hardcoded: set
USER_AGENT(Worker secret) andSIGNALBOT_UA(local Python env) to your ownOrg app you@example.com.config.tsfalls back to an unusable placeholder if unset, so don't forget it in prod. - Prices come from Yahoo Finance's public chart API (keyless; ~30 requests/day at the close). Yahoo blocks fully-spoofed browser UA strings — the plain
Mozilla/5.0UA inmarket.tsis deliberate. - Regime filter is the real ^VIX < 30, same rule the backtest uses.
- Simulated fills happen at the 3:45pm ET quote with no slippage or spread — treat results as an upper bound.
- The insider edge is a months-scale screening signal. Expect boring weeks. That's normal.
- This is paper trading. Run it 60–90 days before even thinking about real money.
Released under the GNU Affero General Public License v3.0 (LICENSE). You may
run, study, modify, and redistribute it — but if you run a modified version as a
network service for others, the AGPL requires you to offer those users your
modified source. Not financial advice; analysis of public filings, no warranty.