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
IMPORTXMLin 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)
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.idxlists 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"])
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)
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
- 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"
}
- Schedules → Create: run the task once a day in the evening (US Eastern). Most Form 4s are filed late in the afternoon.
- 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")
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.
transactionDatecan be a day or two beforefilingDate, 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: falseoropenMarketOnly: trueto 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)