<?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: jaredmansu</title>
    <description>The latest articles on DEV Community by jaredmansu (@jaredmansu).</description>
    <link>https://dev.to/jaredmansu</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%2F4105420%2F389f3d0b-1cfd-4edc-bc25-50f44033971b.png</url>
      <title>DEV Community: jaredmansu</title>
      <link>https://dev.to/jaredmansu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jaredmansu"/>
    <language>en</language>
    <item>
      <title>Only 11.8% of insider trades are actual purchases: lessons from polling SEC EDGAR</title>
      <dc:creator>jaredmansu</dc:creator>
      <pubDate>Wed, 02 Sep 2026 04:59:36 +0000</pubDate>
      <link>https://dev.to/jaredmansu/only-118-of-insider-trades-are-actual-purchases-lessons-from-polling-sec-edgar-4ki6</link>
      <guid>https://dev.to/jaredmansu/only-118-of-insider-trades-are-actual-purchases-lessons-from-polling-sec-edgar-4ki6</guid>
      <description>&lt;p&gt;I wanted to know when corporate insiders buy their own company's stock, so I wrote a poller that reads SEC Form 4 filings as they land and posts the interesting ones to Discord. It runs on a Raspberry Pi.&lt;/p&gt;

&lt;p&gt;The filtering turned out to be the easy part. Here are the things that actually cost me time.&lt;/p&gt;

&lt;h2&gt;
  
  
  EDGAR's feed is a sliding window, and that changes your error handling
&lt;/h2&gt;

&lt;p&gt;The SEC publishes a &lt;code&gt;getcurrent&lt;/code&gt; Atom feed of recent filings. It holds roughly the last 100. There is no cursor, no &lt;code&gt;since&lt;/code&gt; parameter, and nothing that lets you ask for what you missed.&lt;/p&gt;

&lt;p&gt;So if a filing falls off the end before you've processed it, it is gone as far as your poller is concerned. That inverts the usual instinct about parse failures. The naive loop looks like this:&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;for &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;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &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;alreadySeen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&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;filing&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;parseForm4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// throws sometimes&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markSeen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filing&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;The bug is that a transient failure — a slow response, a malformed document, a 503 from EDGAR — leaves that filing unprocessed, and the next tick has no reason to retry it if you marked it seen. I ended up doing the opposite: &lt;strong&gt;a filing is only marked seen once it has been parsed and stored successfully.&lt;/strong&gt; A failure means it gets retried on every subsequent tick until it either works or scrolls out of the window on its own.&lt;/p&gt;

&lt;p&gt;That means a genuinely broken filing gets retried ~25 times over 100 minutes before disappearing. That is fine. Retrying a handful of documents is much cheaper than silently losing a real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ordering of two queries is load-bearing
&lt;/h2&gt;

&lt;p&gt;Two of my four filters depend on history:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;first-time buyer&lt;/strong&gt; — has this insider ever bought this company before?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cluster buy&lt;/strong&gt; — how many &lt;em&gt;other&lt;/em&gt; insiders bought this ticker in the last seven days?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are questions about the state of the database &lt;em&gt;before&lt;/em&gt; the current transaction exists in it. Which means this is correct:&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;hasPrior&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;hasPriorPurchase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;insiderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ticker&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;otherCount&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;countOtherRecentPurchasers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;insiderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&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;insertTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// now it exists&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hasPrior&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;otherCount&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and this is silently, subtly wrong:&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;await&lt;/span&gt; &lt;span class="nf"&gt;insertTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&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;hasPrior&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;hasPriorPurchase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;insiderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// finds itself&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;otherCount&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;countOtherRecentPurchasers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;insiderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Insert first and every insider's &lt;em&gt;first&lt;/em&gt; purchase looks like a repeat, because the query finds the row you just wrote. The cluster count is off by one in the same way, for the same reason.&lt;/p&gt;

&lt;p&gt;What makes this the kind of bug I want to write down is that it doesn't crash, doesn't throw, doesn't log anything, and doesn't fail a test unless you wrote a test specifically for the ordering. It just quietly produces plausible-looking wrong output forever. It's now the loudest comment in the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  node:sqlite has one sharp edge
&lt;/h2&gt;

&lt;p&gt;I used Node's built-in &lt;code&gt;node:sqlite&lt;/code&gt; rather than &lt;code&gt;better-sqlite3&lt;/code&gt;, because a native dependency on an ARM board is a compile step I'd rather not own. It was still flagged experimental. It's been fine, with one gotcha worth knowing before you hit it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A prepared statement throws if the bound parameter object contains any key the SQL doesn't reference.&lt;/strong&gt;&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;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO transactions (ticker, shares) VALUES (:ticker, :shares)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACME&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;               &lt;span class="c1"&gt;// fine&lt;/span&gt;
&lt;span class="nx"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACME&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;12.5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// throws:&lt;/span&gt;
                                                         &lt;span class="c1"&gt;// Unknown named parameter 'price'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most drivers ignore extra keys. This one doesn't. The practical consequence is that you can never spread a caller-supplied object into &lt;code&gt;.run()&lt;/code&gt; — every write helper has to build its parameter object explicitly, field by field. Which is arguably better practice anyway, but it's a strange way to find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that surprised me
&lt;/h2&gt;

&lt;p&gt;Everything gets stored, whether or not it's worth alerting on — about 1,700 filings and 3,300 transactions so far. That's the part that makes the filtering auditable instead of a black box, and it also means I can just query the feed itself.&lt;/p&gt;

&lt;p&gt;Breaking those transactions down by SEC transaction code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S&lt;/td&gt;
&lt;td&gt;Sale&lt;/td&gt;
&lt;td&gt;44.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A + M + F&lt;/td&gt;
&lt;td&gt;Grants, option exercises, tax withholding&lt;/td&gt;
&lt;td&gt;~33%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Open-market purchase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11.8%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only &lt;strong&gt;11.8%&lt;/strong&gt; of Form 4 transactions are someone actually buying stock on the open market with their own money. A third of the entire feed is pure compensation plumbing — vesting schedules and shares withheld to cover taxes. And sales outnumber purchases roughly &lt;strong&gt;4:1&lt;/strong&gt;, which is worth remembering the next time you see a headline about insiders selling. Without that baseline, "insiders sold $X million last quarter" doesn't mean anything at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The filter I'm least happy with
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;first-time-buyer&lt;/code&gt; fires on nearly every purchase early on. Two reasons: the database starts empty, so &lt;em&gt;everyone&lt;/em&gt; is a first-time buyer at first, and there's no time window on the check at all — insiders often buy only a few times a year, so "has never bought before" stays true for a long while legitimately.&lt;/p&gt;

&lt;p&gt;It's the weakest of the four rules and I know it. Weighting it below the large-buy and cluster-buy signals is the workaround; a proper fix is a lookback window and a warm-up period before the rule is allowed to fire.&lt;/p&gt;




&lt;p&gt;The result is a Discord server that posts open-market buys over $100k, two-or-more-insider cluster buys inside a seven-day window, first-time buyers, and CEO/CFO sales over $1M — NYSE and NASDAQ only, since insider "buying" in an OTC shell is not a signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure: I built this and I run it.&lt;/strong&gt; The free channel carries every alert on a 45-minute delay with no signup and no email — &lt;a href="https://jaredmansu.github.io/insider-alerts/" rel="noopener noreferrer"&gt;insider-alerts&lt;/a&gt; has the details. There's a $5/month tier that removes the delay, which is the only difference between them.&lt;/p&gt;

&lt;p&gt;Not investment advice. It's public SEC data, parsed and filtered.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>sqlite</category>
    </item>
  </channel>
</rss>
