DEV Community

Cover image for Build an insider-buying screener with Python and SEC Form 4 data
Mikhail Makeev
Mikhail Makeev

Posted on Fully Autonomous

Build an insider-buying screener with Python and SEC Form 4 data

A director bought $101,760 of Tenax Therapeutics. Another bought $500,000 of Inhibrx Biosciences. Sorted by dollars, the second purchase comes first. Measured against the holding reported with each transaction, the increases were 2330.7% and 1.2%.

Both numbers came back in the same API field. They answer a different question from the dollar amount, and neither tells you what the stock will do next.

This tutorial builds a Python screener that keeps that context next to each purchase. Give it a watchlist and a dollar threshold. It writes a Markdown table and a CSV you can sort yourself, plus the API responses used to make them. The repository includes the script and saved runs, so you can reproduce the tables without a key.

I run AlphAI, the API used here. Live requests need a free API key. The script runs on Python 3.10 or newer with no package dependencies. The runs below were captured on September 18, 2026.

To reproduce the fourteen-row screen before making any live requests:

git clone https://github.com/makeev/alphai-insider-buying.git
cd alphai-insider-buying
python3 insider_buying.py \
  --replay samples/2026-09-18/examples/snapshot.json.gz \
  --out out/saved-example
Enter fullscreen mode Exit fullscreen mode

Open out/saved-example/report.md to see the result. The rest of the article explains which records made it into that table and how to screen your own watchlist.

The first watchlist returned no purchases

The initial list had 25 familiar names:

NVDA AMD INTC ORCL CRM ADBE NKE LULU SBUX DIS PYPL SOFI
HOOD JPM BAC C SCHW O CCI STAG AVGO AMZN GOOGL META TSLA
Enter fullscreen mode Exit fullscreen mode

For transaction dates from August 20 through September 18, inclusive, the API returned 50 recorded events. None had transaction code P. The $100,000 threshold wasn't responsible for the empty result. There were no purchase events to apply it to.

That's a result for these tickers and this recorded window. It doesn't establish that nobody bought anywhere in the market, or that every filing was captured. The API's transaction history covers its supported non-derivative events, not every action that can appear on a Form 4.

To get concrete examples for the rest of the tutorial, I then read three pages of the recent insider feed, with marked 10b5-1 events excluded. The 60 returned rows contained eleven purchase events across seven tickers: TENX, XBP, DLPN, ALP, INBX, RVSB and MNR. Those seven became a second input list. They were chosen because the feed already showed purchases, so their hit rate says nothing about a random watchlist.

Fetch the history once

The endpoint is:

GET /api/symbols/{ticker}/insider-trades/?page_size=1
Enter fullscreen mode Exit fullscreen mode

Its first response contains both a paginated events list and a separate chart_events array with the recorded events from the trailing twelve months. page_size=1 keeps the paginated list short. It doesn't shrink chart_events.

The script reads chart_events, then applies its 30-day transaction-date window locally. It ignores the overlapping events list. Concatenating the two arrays would count some purchases twice. Likewise, side=buy filters the paginated list, not chart_events, so the code checks the transaction code itself.

This is an excerpt of the TENX event in the saved response:

{
  "transaction_code": "P",
  "ownership_form": "D",
  "shares": "53000",
  "total_value_usd": "101760",
  "stake_change_pct": "2330.7",
  "is_director": true,
  "transaction_date": "2026-09-16",
  "filed_at": "2026-09-18T01:30:04Z",
  "news_uid": "84e12a4ec11d2122"
}
Enter fullscreen mode Exit fullscreen mode

Amounts arrive as decimal strings. Unknown values arrive as JSON null, which Python reads as None.

One request per ticker made the first run 25 calls. All returned 200. The Free tier currently allows 20 requests per minute and 100 per day. A 3.2-second pause between requests keeps this serial script below the minute cap when it runs alone. Other work using the same account still shares the allowance.

On an HTTP error, the script stops and prints the status and Retry-After header. It preserves the successful responses as an incomplete snapshot and doesn't write a finished screen. An unavailable ticker should not silently turn into a ticker with no purchases.

Make the filter readable

Here is the selection function from the complete script:

def select(events, min_usd, exclude_plans=False, leadership_only=False):
    selected = []
    for event in events:
        if event["transaction_code"] != "P":
            continue
        amount = money(event["total_value_usd"])
        if amount is None or amount < min_usd:
            continue
        if exclude_plans and event["is_10b5_1"]:
            continue
        if leadership_only and not (event["is_officer"] or event["is_director"]):
            continue
        selected.append(event)
    return sorted(selected, key=lambda e: money(e["total_value_usd"]), reverse=True)
Enter fullscreen mode Exit fullscreen mode

money() preserves unknown values and converts decimal strings without passing through a float:

from decimal import Decimal

def money(value):
    return None if value is None else Decimal(value)
Enter fullscreen mode Exit fullscreen mode

The SEC defines code P as a purchase on an exchange or from another person. It includes private transactions. Calling every P row an open-market buy would be wrong. Officers and directors are also only part of the reporting population: large beneficial owners file too. SEC investor bulletin

The optional leadership filter keeps officers and directors, including people who also have the 10% owner flag. It removes rows reported only as large owners. The plan filter removes events marked is_10b5_1=true. A false value isn't evidence of why somebody bought.

Notice that the function never filters on stake_change_pct. An unknown percentage survives if the purchase otherwise qualifies. An unknown dollar amount can't pass a dollar threshold, so those events are counted separately in the report. The dollar value itself sums priced tranches and can be a lower bound when some tranches have no price.

Run it, then change the filters offline

For a live run, set ALPHAI_API_KEY in your environment. The script reads that variable directly, so putting it in a .env file alone won't load it. This command uses the original 25-name sample:

python3 insider_buying.py --out out/watchlist
Enter fullscreen mode Exit fullscreen mode

To use the seven example tickers:

python3 insider_buying.py TENX XBP DLPN ALP INBX RVSB MNR \
  --days 30 --min-usd 100000 --out out/examples
Enter fullscreen mode Exit fullscreen mode

The output directory must be new. Read report.md or open candidates.csv in a spreadsheet. The accompanying snapshot.json preserves the public response bodies with retrieval timestamps. It also records the remaining-quota header, without the API key.

In the captured seven-ticker run, the window contained 36 events, of which 34 were purchases. Fourteen met the $100,000 threshold. Two of those fourteen had an unknown holding-change percentage. A live run uses today's UTC date and will produce a different window from the saved September example.

Replay the snapshot to keep only officers and directors and exclude marked plan events:

python3 insider_buying.py --replay out/examples/snapshot.json \
  --leadership-only --exclude-plans --out out/leadership
Enter fullscreen mode Exit fullscreen mode

That left nine candidates in this capture. Both unknown percentages remained. Replaying uses the saved tickers and dates and makes no API calls, so changing the filter doesn't also change the underlying sample.

Three rows worth opening

These are selected rows from that output, not the three largest purchases:

Ticker Reporting owner Reported purchase value Holding change Ownership Trade date
TENX Declan Doogan $101,760.00 +2330.7% Direct Sep 16
INBX Jon Faiz Kayyem $500,000.00 +1.2% Indirect Sep 16
XBP Par Chadha $579,997.18 unknown Indirect Sep 15

The TENX filing reports 53,000 shares purchased at $1.92 and 55,274 held afterward. Subtracting the purchase reconstructs a previous holding of 2,274 shares:

purchase value = 53,000 × $1.92 = $101,760
holding increase = 53,000 / (55,274 - 53,000) × 100 = 2330.7%
Enter fullscreen mode Exit fullscreen mode

The INBX filing reports 5,000 shares at $100 and 429,360 afterward. The same calculation gives approximately 1.2%. Its footnote identifies the holding as a family trust.

These percentages describe the holding associated with the transaction. They aren't percentages of the person's wealth, and they don't necessarily cover all their holdings in the issuer. The CSV keeps ownership_form and security_title beside the numbers so those distinctions remain available.

The XBP filing explains the missing percentage. The transaction row reports 204,946 shares purchased and 204,946 afterward. The reconstructed starting balance for that row is zero. A percentage increase from zero has no finite value. Writing 0% would say the position didn't change. Writing 100% would invent a denominator.

There are other holdings elsewhere in the same filing. This row doesn't establish that Chadha had never owned XBP before. Its footnote also says the purchase was part of a PIPE transaction under a securities purchase agreement. That is why the table says "purchase" without adding "on the open market."

Five transaction rows don't mean five separate purchases to rank

Fiorenzo Villani's ALP filing contains five purchase rows. The API folds them into two events:

Holding form Transaction rows Shares Reported value
Direct 3 16,647 $67,856.30
Indirect 2 9,900 $41,131.07

The grouping is per filing, transaction code and direct/indirect ownership form. tranche_count records how many rows were folded. A row in the filing can itself report a weighted average across executions, so this isn't an exchange-fill count.

Neither ALP event passes the $100,000 filter, although their combined value exceeds it. The threshold in this script is per API event. A threshold on the whole filing would answer a different question and would need an explicit aggregation step. The two ownership groups have separate identities. Deduplicating by the filing URL would discard one of them.

The script deduplicates by news_uid instead. That also prevents an event appearing twice if a watchlist contains two share classes whose endpoints expose the same issuer events. It doesn't attempt to reconcile amendments or different filings by related owners into one economic purchase.

Keep the dates attached

The TENX purchase happened on September 16. EDGAR accepted the filing at 2026-09-18T01:30:04Z. Filtering on transaction_date places it on the 16th, but it wasn't public through that filing at the time of the trade.

This screen asks which recorded transactions fall in the chosen date window as of retrieval. A group spanning several dates is dated by its last transaction row. It doesn't ask what an investor could have known on each day of that window. That would require disclosure timing and historical availability data, including when the collector made each record available. A current snapshot can't supply those by moving a date filter backward.

Keep filed_at in the CSV and keep the snapshot. Missing records and later corrections can change what a future run sees.

The repository also includes the four SEC XML documents used here, with their original URLs in manifest.json. The examples can be checked without relying on the API's interpretation of the filing.

If you're assembling a weekly research folder, the earlier earnings-week script supplies the reporting calendar and filing reads. The Polymarket brief covers the macro side.

For this screen, the unresolved choice is the dollar threshold. In the saved sample it drops the XBP CFO's $24,997.39 purchase and the CEO's $89,145 purchase. Set --min-usd 0 on a replay to see all 34 priced purchases before deciding whether $100,000 is a useful cutoff for your watchlist.

Top comments (0)