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.
The filtering turned out to be the easy part. Here are the things that actually cost me time.
EDGAR's feed is a sliding window, and that changes your error handling
The SEC publishes a getcurrent Atom feed of recent filings. It holds roughly the last 100. There is no cursor, no since parameter, and nothing that lets you ask for what you missed.
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:
for (const entry of entries) {
if (await alreadySeen(entry.id)) continue;
const filing = await parseForm4(entry); // throws sometimes
await markSeen(entry.id);
await store(filing);
}
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: a filing is only marked seen once it has been parsed and stored successfully. A failure means it gets retried on every subsequent tick until it either works or scrolls out of the window on its own.
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.
The ordering of two queries is load-bearing
Two of my four filters depend on history:
- first-time buyer — has this insider ever bought this company before?
- cluster buy — how many other insiders bought this ticker in the last seven days?
Both are questions about the state of the database before the current transaction exists in it. Which means this is correct:
const hasPrior = await hasPriorPurchase(insiderId, ticker);
const otherCount = await countOtherRecentPurchasers(ticker, insiderId, 7);
await insertTransaction(tx); // now it exists
const tags = evaluate(tx, { hasPrior, otherCount });
and this is silently, subtly wrong:
await insertTransaction(tx);
const hasPrior = await hasPriorPurchase(insiderId, ticker); // finds itself
const otherCount = await countOtherRecentPurchasers(ticker, insiderId, 7);
Insert first and every insider's first 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.
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.
node:sqlite has one sharp edge
I used Node's built-in node:sqlite rather than better-sqlite3, 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:
A prepared statement throws if the bound parameter object contains any key the SQL doesn't reference.
const stmt = db.prepare(
'INSERT INTO transactions (ticker, shares) VALUES (:ticker, :shares)'
);
stmt.run({ ticker: 'ACME', shares: 100 }); // fine
stmt.run({ ticker: 'ACME', shares: 100, price: 12.5 }); // throws:
// Unknown named parameter 'price'
Most drivers ignore extra keys. This one doesn't. The practical consequence is that you can never spread a caller-supplied object into .run() — 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.
The number that surprised me
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.
Breaking those transactions down by SEC transaction code:
| Code | Meaning | Share |
|---|---|---|
| S | Sale | 44.7% |
| A + M + F | Grants, option exercises, tax withholding | ~33% |
| P | Open-market purchase | 11.8% |
Only 11.8% 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 4:1, 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.
The filter I'm least happy with
first-time-buyer fires on nearly every purchase early on. Two reasons: the database starts empty, so everyone 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.
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.
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.
Disclosure: I built this and I run it. The free channel carries every alert on a 45-minute delay with no signup and no email — insider-alerts has the details. There's a $5/month tier that removes the delay, which is the only difference between them.
Not investment advice. It's public SEC data, parsed and filtered.
Top comments (0)