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

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 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 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 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" } 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) 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) 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 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 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 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% 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 eve

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.