When a CEO buys shares after their company’s stock has fallen hard, it’s tempting to read the purchase as a vote of confidence. The person running the business knows more than the average investor, so the trade feels like a signal worth following.
But there’s an obvious problem. Stocks that fall 20% or more often rebound even when no insider buys anything. If we only measure what happened after CEO purchases, we may end up crediting the insider signal for a recovery that was already common among beaten-down stocks.
In this tutorial, we’ll build a Python workflow to test that properly. We’ll pull Form 4 transactions, isolate CEO purchases, collapse repeated filing rows into usable events, attach historical prices, calculate drawdowns and forward returns, and then compare the purchase episodes with similar no-purchase dates from the same stocks.
The interesting part isn’t just the final return table. It’s everything required to turn messy regulatory filings into a dataset that can support a fair comparison. Along the way, we’ll deal with duplicate transaction rows, repeated purchases by the same CEO, trading-day alignment, incomplete price histories, and one-to-one control matching.
By the end, we’ll have a full event-study workflow and a more useful answer than “the stock went up after the CEO bought.”
Table of Contents
- Prerequisites
- Import The Required Packages
- Build The Stock Universe
- Fetch CEO Purchases And Apply The Date Filter
- Turn Form 4 Rows Into Daily Purchase Events
- Add Historical Prices And Drawdowns
- Convert Purchase Events Into Episodes
- Calculate Returns After CEO Purchases
- Build The No-Purchase Control Group
- Compare CEO Purchases Against Similar No-Purchase Drawdowns
- What The Case Study Found
- What This Test Can And Can’t Say
Prerequisites
You don’t need an advanced finance or quantitative background to follow this tutorial. A basic understanding of Python and pandas should be enough.
Before starting, make sure you have:
- Python installed locally, or access to a notebook environment such as Jupyter Notebook or Google Colab
- Basic familiarity with dataframes, functions, loops, and API requests
- An EODHD API key with access to the screener, Form 4 filings, and historical EOD endpoints
- Enough API credits to process the number of stocks you choose to analyze
The full case study uses Form 4 data from 500 securities. You can run the workflow on a smaller sample first if you want to understand the code without using as many API calls.
No prior knowledge of event studies or control matching is required. We’ll build those parts step by step as they appear in the analysis.
Import The Required Packages
We only need a small set of packages for the full workflow. requests handles the API calls, pandas and NumPy do most of the data work, and SciPy gives us the one-to-one matching algorithm used later for the control group.
import json
import re
import numpy as np
import pandas as pd
import requests
from scipy.optimize import linear_sum_assignment
That’s the full setup. We’re only importing what the analysis actually needs, without adding extra libraries or unnecessary tooling. Make sure to install these packages using pip before importing them to your environment.
Build The Stock Universe
Before pulling insider filings, we need a list of companies to search.
Rather than starting with one market-cap segment, we’ll build a mixed universe across micro-, small-, mid-, and large-cap stocks. This gives the analysis some variation instead of letting one part of the market dominate the sample.
The market-cap buckets are:
micro_cap: $50 million to $300 millionsmall_cap: $300 million to $2 billionmid_cap: $2 billion to $10 billionlarge_cap: $10 billion and above
For each bucket, we’ll fetch 500 screener results, randomly select 250, and combine them into a 1,000-stock universe.
def fetch_stocks(filters, cap):
api_key = 'YOUR EODHD API KEY'
base_url = 'https://eodhd.com/api/screener'
all_stocks = []
for i in range(0,500,100):
params = {
"api_token": api_key,
"filters": json.dumps(filters),
"sort": "market_capitalization.desc",
"limit": 100,
"offset": i}
resp = requests.get(base_url, params = params).json()
stocks = list(pd.DataFrame(resp['data'])['code'])
all_stocks.append(stocks)
all_stocks = [item for sublist in all_stocks for item in sublist]
df = pd.DataFrame(columns = ['ticker', f'cap'])
df.ticker, df.cap = all_stocks, cap
df = df.sample(n = 250, random_state = 42)
return df
micro_filters = [
["exchange", "=", "us"],
["market_capitalization", ">=", 50_000_000],
["market_capitalization", "<", 300_000_000]
]
small_filters = [
["exchange", "=", "NYSE"],
["market_capitalization", ">=", 300_000_000],
["market_capitalization", "<", 2_000_000_000]
]
mid_filters = [
["exchange", "=", "NYSE"],
["market_capitalization", ">=", 2_000_000_000],
["market_capitalization", "<", 10_000_000_000]
]
large_filters = [
["exchange", "=", "NYSE"],
["market_capitalization", ">=", 10_000_000_000]
]
micro_stocks = fetch_stocks(micro_filters, 'micro_cap')
small_stocks = fetch_stocks(small_filters, 'small_cap')
mid_stocks = fetch_stocks(mid_filters, 'mid_cap')
large_stocks = fetch_stocks(large_filters, 'large_cap')
frames = [micro_stocks, small_stocks, mid_stocks, large_stocks]
stocks_1000 = pd.concat(frames, ignore_index = True)
stocks_1000 = stocks_1000.sample(frac = 1, random_state = 42).reset_index(drop = True)
stocks_1000
Note: Replace YOUR EODHD API KEY with your actual EODHD API key. If you don’t have one, you can obtain it by opening an EODHD developer account.
The screener returns at most 100 rows per request, so the loop moves through the first 500 results in five batches. We then sample 250 tickers from those candidates. The fixed random seed makes the selection repeatable, so rerunning the cell produces the same sample. After that, we define the four market-cap filters and run the function for each one.
The final dataframe contains 1,000 tickers, with 250 from each bucket.

One caveat is worth stating now. The micro-cap filter uses the broader us exchange setting, while the other groups use NYSE. This is the screener sample used for the case study, but it shouldn’t be treated as a perfectly representative sample of the entire US stock market.
Fetch CEO Purchases And Apply The Date Filter
With the stock universe ready, we can start searching the Form 4 filings for CEO purchases using EODHD’s Insider Transactions (SEC Form 4) API.
Form 4 data contains much more than straightforward insider buying. A filing can include sales, awards, option exercises, derivative transactions, and several rows belonging to the same trade. So we can’t simply download every filing and treat every record as a buying signal.
For this analysis, a transaction must satisfy all of these conditions:
- Appear under non-derivative transactions
- Be reported by an officer
- Have an officer title that identifies the person as a CEO
- Use transaction code
P - Represent acquired shares
- Contain positive values for both shares and price
- Refer to common stock
We also retain both the transaction date and filing date. The transaction date tells us when the CEO bought the shares, while the filing date tells us when outside investors first had access to that information — which matters when deciding which date to use as the actual signal.
Turn Form 4 Rows Into Daily Purchase Events
Raw Form 4 data often contains multiple rows for a single day of buying. A CEO might execute several trades at different prices on the same day, and each one generates its own filing row. Treating each row as a separate event would inflate the purchase count and distort any return calculation.
To fix this, we collapse all transactions for the same CEO, ticker, and date into a single event. The aggregated event uses the weighted average purchase price, the total shares acquired, and the total dollar value of the purchase.
This step produces one row per CEO-ticker-date combination, which is the unit we’ll carry through the rest of the analysis.
Add Historical Prices And Drawdowns
With purchase events in hand, we need to attach price history to each ticker so we can measure how far the stock had fallen before the CEO bought.
Calculate The Trailing High And Drawdown
For each trading day in the price history, we calculate the trailing 252-day high — roughly one year of trading days. The drawdown on any given day is how far the closing price sits below that trailing high, expressed as a percentage.
A stock trading at $80 when its 252-day high was $100 has a drawdown of -20%. This is the metric we’ll use to filter purchase events and to find comparable no-purchase dates for the control group.
Match Each Purchase With The Latest Available Price
Purchase dates don’t always fall on trading days. When a transaction date lands on a weekend or holiday, we match it to the most recent prior trading day. This avoids forward-looking bias and keeps the price data aligned with what was actually observable at the time of the purchase.
Convert Purchase Events Into Episodes
A CEO might buy shares on several consecutive or closely spaced days. Treating each of those days as an independent signal would overcount the same underlying decision and create overlapping return windows that interfere with each other.
To address this, we group nearby purchases into episodes. If a CEO buys on multiple days within a short window, those purchases are collapsed into a single episode anchored to the first purchase date. This gives us one clean entry point per buying campaign rather than one entry point per transaction day.
Calculate Returns After CEO Purchases
With episodes defined, we can calculate how the stock performed after each one.
Organize The Price History By Ticker
We index the full price history by ticker so that forward-return lookups are fast. Each episode needs returns at several horizons — typically 30, 60, 90, and 180 calendar days — so the lookup runs once per episode per horizon.
Find The Entry Date And Calculate Forward Returns
The entry date is the first available trading day after the episode start. From that date, we find the closing price at each forward horizon and calculate the percentage return relative to the entry price.
If a price isn’t available at a given horizon — because the stock was delisted or had a data gap — the return for that horizon is left as missing rather than filled with an assumption.
Summarize The Raw Returns
Once every episode has forward returns attached, we summarize the distribution. The summary includes the median return, the mean return, the percentage of episodes with a positive return at each horizon, and the number of episodes that had valid price data.
This gives us the raw CEO-purchase return profile before any comparison to a control group.
Build The No-Purchase Control Group
Raw returns from CEO purchases aren’t useful on their own. We need to know whether those returns are better, worse, or about the same as what the stock would have done anyway after a similar drawdown.
The control group answers that question. For each purchase episode, we find dates from the same stock where the drawdown was comparable but no CEO purchase occurred nearby.
Create The Control Candidates
For every ticker with at least one purchase episode, we take all trading days in the price history and attach the drawdown value for each day. Any day within a short window of an actual CEO purchase is excluded from the candidate pool.
Remove Dates Near CEO Purchases
We remove candidate dates that fall within a buffer window around any purchase episode. This prevents a control date from accidentally overlapping with a real purchase signal, which would contaminate the comparison.
Match Purchase Episodes With Controls
Each purchase episode is matched to one control date from the same ticker using the drawdown values as the matching criterion. We use a one-to-one assignment that minimizes the total difference in drawdowns across all matched pairs.
The linear_sum_assignment function from SciPy solves this matching problem efficiently. It finds the assignment that minimizes the sum of absolute drawdown differences, so each purchase episode gets the most comparable no-purchase date available.
Build The Final Matched Dataset
After matching, we combine the purchase episodes and their controls into a single dataframe. Each row in the purchase group has a corresponding row in the control group from the same ticker at a similar drawdown level. This structure makes the eventual return comparison straightforward and fair.
Compare CEO Purchases Against Similar No-Purchase Drawdowns
With matched pairs in hand, we calculate forward returns for both groups using identical logic.
Calculate Forward Returns From Any Signal Date
We write a single return function that works for any signal date — whether it came from a CEO purchase or a matched control date. This ensures both groups are measured the same way, using the same horizons and the same handling of missing data.
Apply The Same Return Logic To Both Groups
We run the return function over the purchase episodes and then over the control dates. Both groups end up with the same set of forward-return columns, which makes the comparison a straightforward side-by-side summary.
Build The Final Comparison
The final table shows median returns for the CEO-purchase group and the matched control group at each horizon. It also shows the difference between the two medians, which is the part of the return that’s actually attributable to the CEO purchase signal rather than to the general tendency of beaten-down stocks to recover.
What The Case Study Found
Across the 500-security sample, CEO purchases did show positive forward returns at every horizon. But the matched control group — same stocks, similar drawdowns, no CEO purchase — also showed positive returns over the same periods.
The difference between the two groups was smaller than the raw CEO-purchase returns suggested. Some of what looked like a CEO signal was simply the recovery pattern common to stocks that have already fallen significantly. The purchase episodes did outperform their matched controls at longer horizons, but the gap was narrower than a naive before-and-after comparison would imply.
What This Test Can And Can’t Say
This workflow is a structured way to move past “the stock went up after the CEO bought.” By matching purchase episodes to similar no-purchase drawdowns from the same stocks, we isolate more of what the insider signal actually contributes versus what the market was likely to do anyway.
That said, the analysis has real limits. The sample covers one time period and one screener-based universe. It doesn’t account for sector rotation, macro conditions during the measurement window, or the possibility that CEOs systematically buy at the start of recoveries rather than randomly during drawdowns. A single case study can’t resolve those questions, but it can at least frame them more precisely.
The workflow itself is the reusable part. The same event-study structure — fetch filings, define episodes, match controls, compare returns — applies to any insider signal you want to test, with whatever filters and horizons fit your question.