<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: David Han</title>
    <description>The latest articles on DEV Community by David Han (@davidhanz).</description>
    <link>https://dev.to/davidhanz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4176150%2F1bacd9f0-10ca-45d8-bd9a-29987cc136a1.jpg</url>
      <title>DEV Community: David Han</title>
      <link>https://dev.to/davidhanz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/davidhanz"/>
    <language>en</language>
    <item>
      <title>How I built a free congressional stock-trade tracker from public filings</title>
      <dc:creator>David Han</dc:creator>
      <pubDate>Sun, 11 Oct 2026 01:15:21 +0000</pubDate>
      <link>https://dev.to/davidhanz/how-i-built-a-free-congressional-stock-trade-tracker-from-public-filings-1n53</link>
      <guid>https://dev.to/davidhanz/how-i-built-a-free-congressional-stock-trade-tracker-from-public-filings-1n53</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So I built a free tracker for it: &lt;a href="https://aistockwire.com/capitol" rel="noopener noreferrer"&gt;AIStockWire's Congress stock tracker&lt;/a&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the data comes from
&lt;/h2&gt;

&lt;p&gt;The primary sources are the &lt;a href="https://disclosures-clerk.house.gov/" rel="noopener noreferrer"&gt;House Clerk's financial disclosure site&lt;/a&gt; and the &lt;a href="https://efdsearch.senate.gov/" rel="noopener noreferrer"&gt;Senate's eFD search&lt;/a&gt;. I don't parse those PDFs myself. Two pipelines feed the tracker:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The live feed.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The history backfill.&lt;/strong&gt; The open-source &lt;a href="https://github.com/kadoa-org/congress-trading-monitor" rel="noopener noreferrer"&gt;kadoa-org/congress-trading-monitor&lt;/a&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keyOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transactionDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The bug that wiped the history
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix was to make the loader say which case it hit, and to refuse to save when the read failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadStore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STORE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;trades&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trades&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Only a missing file is a real "empty". Anything else is an error.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;trades&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENOENT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the disclosure date right
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For House filings, the PDF is a stable public URL, so the true posting date is in the file's &lt;code&gt;Last-Modified&lt;/code&gt; header. A single HEAD request gets it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HEAD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;last-modified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Format in New York time, since that's the date the public saw it.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing tickers the feed gets wrong
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring performance without fooling yourself
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Window-length bias.&lt;/strong&gt; A 2013 purchase was compared against ten years of S&amp;amp;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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Survivorship bias.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://aistockwire.com/capitol/methodology" rel="noopener noreferrer"&gt;methodology page&lt;/a&gt; spells that out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open data
&lt;/h2&gt;

&lt;p&gt;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: &lt;a href="https://doi.org/10.5281/zenodo.23290073" rel="noopener noreferrer"&gt;10.5281/zenodo.23290073&lt;/a&gt;. It's licensed CC BY 4.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone building on public filings
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never trust a single feed's metadata.&lt;/strong&gt; Check dates and tickers against the primary document at least once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate "empty" from "failed"&lt;/strong&gt; anywhere a read feeds a write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a measurement you can defend&lt;/strong&gt; before you publish a leaderboard. The honest version is less dramatic, and that's the point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you find an error in the data, I'd like to hear about it.&lt;/p&gt;

&lt;p&gt;Written with AI assistance, based on AIStockWire’s own code and data.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>javascript</category>
      <category>opendata</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
