Members of Congress have to disclose their stock trades within 45 days under the STOCK Act. The filings are public, but they are not easy to use. House reports are PDFs posted by the Clerk of the House, Senate reports sit behind a search tool, and the most useful questions, like "did this member's purchases beat the market?", require stitching both together with price data.
So I built a free tracker for it: AIStockWire's Congress stock tracker. As of October 2026, it holds 1,620 disclosed trades by 86 members of Congress. Here is how it works, including the parts I got wrong the first time.
Where the data comes from
The primary sources are the House Clerk's financial disclosure site and the Senate's eFD search. I don't parse those PDFs myself. Two pipelines feed the tracker:
- The live feed. Financial Modeling Prep's free "latest" endpoints return recent House and Senate filings as JSON. The catch: they only return the most recent ~100 filings per chamber, so a single fetch is a sliding window, not a history.
- The history backfill. The open-source kadoa-org/congress-trading-monitor project (MIT license) publishes the same House and Senate filings already parsed to JSON, one file per filer, rebuilt daily. A nightly job pulls the files for the members I track.
Because the free feed only shows a window, the tracker has to accumulate. Every six hours it fetches the latest filings, keeps the tracked members, and merges new trades into a stored history. Each trade gets an identity key so a re-fetched filing doesn't become a duplicate:
const keyOf = (t) =>
[t.last, t.symbol, t.transactionDate, t.type, t.amount, t.owner].join("|");
The bug that wiped the history
The first version had a quiet failure mode. Loading the stored history returned an empty list whether the file genuinely didn't exist yet or the read had failed. On July 27, a burst of API errors led to a failed read, the empty result was treated as "no history," and the merge saved that empty base straight back over the real data.
The fix was to make the loader say which case it hit, and to refuse to save when the read failed:
async function loadStore() {
try {
const j = JSON.parse(await readFile(STORE, "utf8"));
return { trades: j.trades ?? [], ok: true };
} catch (e) {
// Only a missing file is a real "empty". Anything else is an error.
return { trades: [], ok: e?.code === "ENOENT" };
}
}
Writes also go to a temp file first and are renamed into place, so a crash mid-write can't leave a half-written store. The tracker runs as two Node workers, which made that atomic write more important.
Getting the disclosure date right
The free feed has a "disclosureDate" field, but comparing it against the House Clerk's own files showed it lags the actual public posting by about a business day, and by a full weekend for Friday filings.
For House filings, the PDF is a stable public URL, so the true posting date is in the file's Last-Modified header. A single HEAD request gets it:
const r = await fetch(link, { method: "HEAD" });
const lm = r.headers.get("last-modified");
// Format in New York time, since that's the date the public saw it.
Senate filings sit behind a session-gated viewer with no public direct-file URL, so those still use the feed's date. The methodology page says so.
Fixing tickers the feed gets wrong
Multi-class shares are a common trap. The feed tagged one member's sale of "Alphabet Inc. - Class C Capital Stock" as GOOGL, but the PDF says Class C, which is GOOG. Correcting the stored row didn't stick, because the next refresh re-supplied the wrong tag and the trade came back as a "new" record. The fix is a small override table applied before a record is ever keyed, so the corrected trade always gets the same identity.
Measuring performance without fooling yourself
The question everyone asks is whether Congress beats the market. My first attempt measured each purchase from its trade date to the latest close and compared it with SPY. It produced dramatic numbers, and they were wrong in two ways:
- Window-length bias. A 2013 purchase was compared against ten years of S&P 500 compounding, while a 2024 purchase got one year. Most individual stocks trail the index over long periods, so nearly every member looked terrible: 53 of 62 had a negative median. That was a property of the measurement, not of the traders.
- Survivorship bias. Delisted tickers were dropped. Delistings skew toward failures, so dropping them flattered the survivors. One member's median excess return looked like +172%, with 191 of their 442 purchases silently missing.
The fix is a fixed horizon. Every disclosed purchase is measured over the same twelve months from its trade date, against SPY over the same twelve months. A stock that stops trading inside the window is carried at its last close, so a wipeout counts as a loss instead of disappearing.
It's also important to say what the number isn't. Filings report a dollar range, never a share count, so you can't weight positions or compute a member's real return. Every purchase counts once, equally, and the methodology page spells that out.
Open data
The full trade table is a free CSV download on the tracker, and a snapshot is archived on Zenodo with a DOI so researchers can cite it: 10.5281/zenodo.23290073. It's licensed CC BY 4.0.
What I'd tell anyone building on public filings
- Never trust a single feed's metadata. Check dates and tickers against the primary document at least once.
- Separate "empty" from "failed" anywhere a read feeds a write.
- Pick a measurement you can defend before you publish a leaderboard. The honest version is less dramatic, and that's the point.
If you find an error in the data, I'd like to hear about it.
Written with AI assistance, based on AIStockWire’s own code and data.
Top comments (1)
Separating a missing store from a failed read closes one nasty overwrite path. With two workers, I'd also test a successful but stale read: both load the same history, A adds trade X and writes, then B adds trade Y and renames its file. Atomic rename prevents a torn file, but that fixture checks whether X survives the second writer. The identity key is another boundary worth exercising with two distinct filings that share the displayed trade fields, and an amendment to one filing. Keeping source filing ID and row provenance would let the merge distinguish a re-fetch from a genuinely separate transaction without relying only on surname and trade details.