DEV Community

Mustapha
Mustapha

Posted on AI-assisted

How to track insider buying with Python (SEC Form 4, free EDGAR data)

Disclosure: I built the Apify actor used in the second half of this post. It's a paid tool (pay per trade). The first half is free and uses only SEC EDGAR. Nothing here is investment advice.

When a CEO buys $500k of their own company's stock on the open market, the trade has to be public within two business days. It shows up on SEC Form 4, and anyone can read it on EDGAR for free.

Getting that data into code is harder than it should be:

  • OpenInsider is a great screener, but it has no API. People end up scraping HTML or using IMPORTXML in Google Sheets.
  • Paid insider-trading APIs typically cost $20–200 a month.
  • EDGAR itself is free, but each Form 4 is a separate XML file, the filing index links to a rendered HTML version, and SEC blocks scripts that don't identify themselves.

This post covers the free route first, then a hosted option for when you want the whole market every day with alerts.

What a Form 4 row tells you

Each Form 4 has one line per transaction. The field that matters most is the transaction code:

Code Meaning Signal?
P Open-market or private purchase The one people watch
S Open-market or private sale Often planned or for diversification
A Grant or award Compensation, not a decision
M Option exercise Mechanical
F Shares withheld for tax Mechanical
G Gift Usually estate planning

Since April 2023, Form 4 also has a Rule 10b5-1 checkbox for trades under a pre-arranged plan. Planned sales say much less about the insider's view than discretionary ones.

The free way: SEC EDGAR in ~35 lines

SEC's fair-access rules ask automated tools to declare a User-Agent with your name and e-mail and to stay under 10 requests per second. Without a declared User-Agent you get a 403 "Undeclared Automated Tool" page. Watch the classic bug: requests.get(url, headers) passes your dict as query parameters. It has to be headers=headers.

import time
import requests
import xml.etree.ElementTree as ET

HEADERS = {"User-Agent": "Jane Doe jane.doe@example.com"}  # your name + e-mail

def get(url):
    r = requests.get(url, headers=HEADERS, timeout=30)
    r.raise_for_status()
    time.sleep(0.2)  # well under SEC's 10 requests/second
    return r

def val(node, path):
    el = node.find(path)
    return el.text.strip() if el is not None and el.text else None

TICKER = "NVDA"
tickers = get("https://www.sec.gov/files/company_tickers.json").json()
cik = next(v["cik_str"] for v in tickers.values() if v["ticker"] == TICKER)

recent = get(f"https://data.sec.gov/submissions/CIK{cik:010d}.json").json()["filings"]["recent"]
form4s = [(acc, doc, filed) for acc, doc, filed, form in zip(
    recent["accessionNumber"], recent["primaryDocument"], recent["filingDate"], recent["form"])
    if form == "4"][:20]

rows = []
for acc, doc, filed in form4s:
    # primaryDocument points at the rendered HTML ("xslF345X05/..."): drop the folder for raw XML
    url = f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc.replace('-', '')}/{doc.split('/')[-1]}"
    root = ET.fromstring(get(url).content)
    if int(val(root, "issuer/issuerCik")) != cik:
        continue  # the company itself was the insider at another issuer
    owner = val(root, "reportingOwner/reportingOwnerId/rptOwnerName")
    for t in root.findall("nonDerivativeTable/nonDerivativeTransaction"):
        shares = float(val(t, "transactionAmounts/transactionShares/value") or 0)
        price = float(val(t, "transactionAmounts/transactionPricePerShare/value") or 0)
        rows.append({"filed": filed, "date": val(t, "transactionDate/value"), "insider": owner,
                     "code": val(t, "transactionCoding/transactionCode"),
                     "shares": shares, "price": price, "value": round(shares * price)})

for r in rows:
    if r["code"] in ("P", "S"):
        print(r)
Enter fullscreen mode Exit fullscreen mode

I ran this against NVIDIA's recent filings. It printed open-market sales by named executives, tax-withholding (F) lines and gifts (G), which is exactly why you filter on the code.

Things you'll hit next if you keep going:

  • Whole market: https://www.sec.gov/Archives/edgar/daily-index/2026/QTR4/form.20261027.idx lists every filing of the day by form type. Each Form 4 appears twice, once under the company and once under the insider, so dedupe on the accession number.
  • Derivatives: options, RSUs and warrants live in derivativeTable, with their own fields.
  • Footnotes: prices are often weighted averages explained in a footnote, and 10b5-1 plans are usually mentioned there too.
  • Amendments: a Form 4/A corrects an earlier filing. It doesn't replace it in the index.
  • State: for alerts you have to remember which filings you've already seen.

None of this is hard. It just adds up to a small project.

The hosted way: one call for the whole market

Insider Trading Tracker is the Apify actor I built that does the above at scale. It reads the same public Form 4 filings, returns one row per transaction (common stock and derivatives, with footnote text), and adds the 10b5-1 flag, holdings before and after, and cluster signals. It has three modes: latest (the whole market), tickers and insiders (by CIK or name).

With a free Apify account and your API token, this returns yesterday's discretionary open-market buys of $100k or more, market-wide:

import requests

TOKEN = "YOUR_APIFY_TOKEN"
url = ("https://api.apify.com/v2/acts/groupoject~sec-insider-trading-tracker"
       f"/run-sync-get-dataset-items?token={TOKEN}")

trades = requests.post(url, json={
    "mode": "latest",
    "lastNDays": 1,
    "transactionCodes": ["P"],
    "minTransactionValueUsd": 100000,
    "excludePlannedTrades": True,
    "maxResults": 500,
}, timeout=320).json()

for t in trades:
    print(t["issuerTicker"], t["insiderName"], t["insiderRole"],
          t["transactionDate"], t["shares"], t["pricePerShare"], t["valueUsd"])
Enter fullscreen mode Exit fullscreen mode

For longer runs, such as a year of trades for a watchlist, use the client library (pip install apify-client):

from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("groupoject/sec-insider-trading-tracker").call(run_input={
    "mode": "tickers",
    "tickers": ["AAPL", "NVDA", "JPM"],
    "lastNDays": 365,
    "openMarketOnly": True,
    "includeDerivative": False,
})

for t in client.dataset(run["defaultDatasetId"]).iterate_items():
    plan = "10b5-1 plan" if t["is10b5_1Plan"] else "discretionary"
    print(t["transactionDate"], t["issuerTicker"], t["transactionCode"], t["valueUsd"], plan)
Enter fullscreen mode Exit fullscreen mode

Each row also has transactionLabel (the code in plain English), sharesOwnedAfter, ownershipChangePct, isNewPosition, footnotes, isAmendment, acceptedAt (EDGAR acceptance time) and filingUrl.

A daily insider-buying alert in Slack

  1. Save this input as a task in Apify Console:
{
  "mode": "latest",
  "lastNDays": 3,
  "transactionCodes": ["P"],
  "minTransactionValueUsd": 250000,
  "roles": ["officer", "director"],
  "excludePlannedTrades": true,
  "onlyNew": true,
  "stateKey": "daily-insider-buys"
}
Enter fullscreen mode Exit fullscreen mode
  1. Schedules → Create: run the task once a day in the evening (US Eastern). Most Form 4s are filed late in the afternoon.
  2. On the task's Integrations tab, add Slack and post a message when a run succeeds.

onlyNew + stateKey make each run remember what earlier runs returned. A 3-day window catches late filers, but you only get, and only pay for, trades you haven't seen. A run with nothing new saves no rows. Use a different stateKey for each watchlist.

Cluster buys

One director buying can mean anything. Several insiders buying the same stock within days is the pattern people look for. Each row carries clusterBuy and clusterInsiderCount. By default that means 3 or more distinct insiders with open-market purchases within 10 days (clusterMinInsiders, clusterWindowDays). Use a date range at least as long as the window:

run = client.actor("groupoject/sec-insider-trading-tracker").call(run_input={
    "mode": "latest", "lastNDays": 14, "transactionCodes": ["P"],
    "clusterMinInsiders": 3, "clusterWindowDays": 10,
})

clusters = {}
for t in client.dataset(run["defaultDatasetId"]).iterate_items():
    if t["clusterBuy"]:
        c = clusters.setdefault(t["issuerTicker"], {"insiders": set(), "usd": 0})
        c["insiders"].add(t["insiderName"])
        c["usd"] += t["valueUsd"] or 0

for ticker, c in sorted(clusters.items(), key=lambda kv: -kv[1]["usd"]):
    print(ticker, len(c["insiders"]), "insiders", round(c["usd"]), "USD")
Enter fullscreen mode Exit fullscreen mode

The run's SUMMARY record (free) also lists cluster buys by company and the top buys and sells by value.

What it costs

$2.50 per 1,000 trades on Apify's free plan, and lower on paid plans. Errors, non-matching filings and the SUMMARY record aren't charged. A typical trading day has a few hundred open-market buy and sell rows, so a full daily scan costs well under $1. A filtered buy alert usually costs cents.

Honest limits

  • Filing delay. Insiders have up to two business days to file. transactionDate can be a day or two before filingDate, and late filings do happen.
  • Amendments. A Form 4/A comes back as its own rows with isAmendment: true. It doesn't delete the original rows, so compare them yourself.
  • Derivatives. Option exercises, RSU vesting and conversions are included by default. Set includeDerivative: false or openMarketOnly: true to see only stock bought or sold with the insider's own money.
  • Counts differ from OpenInsider. The actor returns every transaction line. OpenInsider merges a filing into one line with an average price.
  • No price data. It reports what was filed. It doesn't tell you what the stock did afterwards.
  • Not investment advice. Insiders buy and sell for many reasons, including taxes, diversification, planned sales and option expiries. A cluster buy is a reason to read the filing, not a reason to trade.

If you only follow a few tickers, the free script above is all you need. If you want the whole market every evening, with derivatives, 10b5-1 flags and clusters already worked out, try the actor: apify.com/groupoject/sec-insider-trading-tracker. If something parses wrong, open an issue with the accession number. I read them daily.

Top comments (0)