top of page

The Stock Fell 20%, Then The CEO Bought It. I Tested What Happened Next

  • Writer: Nikhil Adithyan
    Nikhil Adithyan
  • 4 hours ago
  • 17 min read

A Python workflow for combining Form 4 insider transactions, historical prices, drawdowns, and matched control events



When a CEO buys stock after a sharp decline, it looks like one of the cleaner insider signals. The market has marked the company down, and the person running the business is choosing to put their own money into the stock.


But that intuition has a problem. Stocks that fall 20% can rebound even when no insider buys. So the useful question is not whether CEO-buying stocks went up afterward, but whether they did better than similar drawdowns without CEO buying.


I built a Python workflow to test that. It combines Form 4 insider transactions, historical prices, trailing drawdowns, purchase episodes, and matched control events. The result was not a simple “CEO bought, stock outperformed” story, which made the analysis more useful.


Did CEO Purchases Beat Similar Drawdowns Without CEO Buying?

The easy version of this test would be to find CEO purchases and calculate what happened next. That would give an answer, but not a very useful one.


A stock that is down 20% already has a return profile. Some keep falling. Some rebound sharply. Some move sideways for months. If a CEO buys during that drawdown and the stock later rises, the purchase may have mattered. It may also be ordinary mean reversion after a large decline.

So I framed the test around a stricter question:


When a stock was at least 20% below its trailing one-year high, did CEO purchases lead to better forward returns than similar drawdowns without nearby CEO buying?

That framing changes the workflow. The CEO purchase is no longer the only event. The drawdown becomes the baseline, and the CEO purchase becomes the thing being tested against that baseline.


The Workflow

I split the analysis into two datasets: one for CEO purchase events and one for price behavior around those events. The purchase dataset came from Form 4 filings. The price dataset came from historical adjusted closes.


The full workflow looked like this:


  1. Stock universe

  2. Form 4 filings

  3. CEO purchase filter

  4. Daily purchase events

  5. Historical prices

  6. Trailing 252-day drawdowns

  7. Purchase episodes

  8. Forward returns

  9. Matched no-purchase controls 

  10. CEO vs. control comparison


The order matters. If I calculated returns directly from raw Form 4 rows, repeated purchases would dominate the sample. If I skipped the control group, I would only know whether CEO-buying stocks went up, not whether they did better than similar drawdowns without CEO buying.


Build The CEO Purchase Dataset

The first dataset we need is the CEO-purchase dataset. It starts with a stock universe, then pulls Form 4 filings for each ticker, filters those filings down to CEO purchase transactions, and keeps only filings from 2022 through 2025.


At this stage, we are not calculating returns yet. We are only building the raw insider-purchase dataset that the rest of the analysis will use.


Stock Universe

I started by creating a screener-based stock universe across four market-cap buckets:


  • micro_cap  -  $50 million to $300 million

  • small_cap - $300 million to $2 billion

  • mid_cap - $2 billion to $10 billion

  • large_cap - $10 billion and above



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

For each bucket, I fetched 500 screener results sorted by market cap, then randomly sampled 250 tickers using a fixed random seed. That produced a 1,000-stock universe with 250 tickers per bucket.


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.


Stock Universe (Image by Author)

This gives us a working universe with only two columns: the ticker and the cap bucket. It is a screener sample, not the entire market. One detail worth stating clearly: the micro-cap bucket used the broader us exchange filter, while the other buckets used NYSE, so I treat this as a cap-bucketed screener universe rather than a fully representative US-market sample.


Fetch CEO Purchases And Apply The Date Filter

With the universe in place, the next step is to fetch Form 4 filings and keep only the rows that look like CEO purchases.


The filter is strict enough to remove most irrelevant insider activity:


  • use only non-derivative transactions

  • require the reporting owner to be an officer

  • require the officer title to match CEO-related titles

  • require transaction code P

  • require acquired shares

  • require positive shares and positive price

  • require the reported security to be common stock


The filing date is the signal date. The transaction date tells us when the CEO bought, but outside investors usually observe the purchase only after the Form 4 is filed.



def fetch_ceo_purchases(ticker):
    try:
        api_key = 'YOUR EODHD API KEY'

        all_form4 = []

        for i in range(0,1000,100):
            form4_url = f'https://eodhd.com/api/sec-filings/{ticker}/form4?api_token={api_key}&page[limit]=100&page[offset]={i}'
            resp = requests.get(form4_url).json()['data']
            all_form4.append(resp)

        all_form4 = [item for sublist in all_form4 for item in sublist]

        all_purchases = []

        ceo_pattern = re.compile(
            r'\bceo\b|chief executive officer|co-chief executive officer|co-ceo|chief exec officer',
            re.IGNORECASE
        )

        for filing in all_form4:
            footnote_map = {
                footnote['footnote_id']: footnote['text']
                for footnote in filing.get('footnotes', [])
            }

            for transaction in filing.get('non_derivative', []):
                officer_title = transaction.get('officer_title') or ''
                security_title = transaction.get('security_title') or ''
                shares_amount = transaction.get('shares_amount')
                price_per_share = transaction.get('price_per_share')

                is_ceo = bool(ceo_pattern.search(officer_title))

                is_purchase = (
                    transaction.get('is_officer') is True
                    and is_ceo
                    and transaction.get('transaction_code') == 'P'
                    and transaction.get('acquired_or_disposed') == 'A'
                    and shares_amount is not None
                    and shares_amount > 0
                    and price_per_share is not None
                    and price_per_share > 0
                    and 'common stock' in security_title.lower()
                )

                if not is_purchase:
                    continue

                linked_footnotes = ' '.join(
                    footnote_map.get(footnote_id, '')
                    for footnote_id in transaction.get('footnote_ids', [])
                )

                all_purchases.append({
                    'ticker': ticker,
                    'accession_number': filing['accession_number'],
                    'filed_at': filing['filed_at'],
                    'transaction_date': transaction['transaction_date'],
                    'reporting_owner_cik': transaction['reporting_owner_cik'],
                    'reporting_owner_name': transaction['reporting_owner_name'],
                    'officer_title': officer_title,
                    'security_title': security_title,
                    'shares_amount': shares_amount,
                    'price_per_share': price_per_share,
                    'total_value': transaction.get('total_value'),
                    'shares_owned_after': transaction.get('shares_owned_after'),
                    'footnotes': linked_footnotes
                })

        return all_purchases
    except:
        return None

all_ceo_purchases = []

for ticker in stocks_1000.ticker[:500]:
    ticker = ticker + '.US'
    ceo_purchases = fetch_ceo_purchases(ticker)
    if ceo_purchases:
        all_ceo_purchases.extend(ceo_purchases)
        print(f'{len(ceo_purchases)} ceo purchases found in {ticker}')
    else:
        print(f'no transaction found in {ticker}')
        
cp_df = pd.DataFrame(all_ceo_purchases)
cp_df.to_csv('ceo_purchases.csv')

The code loops through each filing, checks the non-derivative transaction rows, applies the CEO-purchase filter, and stores only the fields needed later. The footnotes are retained because a P transaction is not always a plain open-market purchase. Some records can involve plans, offerings, or other context that only appears in the filing text.


For this article, I ran the Form 4 extraction on the first 500 tickers because the insider-filings endpoint can consume API credits quickly. The same workflow can be run on the full 1,000-stock sample, and that would be the better choice for a larger version of the test.


After collecting the rows, I restrict the dataset to filings from 2022 through 2025:



cp_df = cp_df[cp_df["filed_at"].between("2022-01-01","2025-12-31")]
cp_df.tail()

CEO Purchases (Image by Author)

These rows are not final signals yet. A single CEO purchase can still appear as multiple rows if the trade was reported in several price blocks.


Turn Form 4 Rows Into Daily Purchase Events

A single CEO purchase can be split across several rows when the shares were bought in different price blocks on the same day. If we leave those rows untouched, one purchase can count as several signals just because the filing reported multiple prices.


The fix is to aggregate rows that belong to the same CEO, ticker, filing, and transaction date. We sum the shares, sum the purchase value, and calculate a weighted-average purchase price.



cp_df['purchase_value'] = (
    cp_df['shares_amount'] * cp_df['price_per_share']
)

group_columns = [
    'ticker',
    'reporting_owner_cik',
    'reporting_owner_name',
    'officer_title',
    'accession_number',
    'filed_at',
    'transaction_date'
]

daily_events = (
    cp_df.groupby(
        group_columns,
        as_index=False,
        dropna=False
    )
    .agg(
        shares_purchased=('shares_amount', 'sum'),
        total_purchase_value=('purchase_value', 'sum'),
        transaction_rows=('shares_amount', 'size'),
        shares_owned_after=('shares_owned_after', 'max')
    )
)

daily_events['weighted_average_price'] = (
    daily_events['total_purchase_value']
    / daily_events['shares_purchased']
)

daily_events.filed_at = pd.to_datetime(daily_events.filed_at)
daily_events.to_csv('ceo_purchases_grouped.csv')
daily_events.tail()

This keeps the economic purchase intact while removing the row-level noise created by the filing format.


transaction_rows is useful because it tells us how many raw Form 4 rows were collapsed into each daily purchase event. Most events are single-row purchases. Some are not. In one case, a single daily purchase was split across 17 rows.


Grouped CEO Purchase Dataset

The dataset now has one row per CEO purchase day instead of one row per Form 4 transaction line. That is the right level for the next step, where each purchase event gets matched with the stock’s drawdown at the time the filing became public.


Add Historical Prices And Drawdowns

The CEO-purchase dataset tells us when a CEO bought. It does not tell us whether the stock was actually under pressure at that point.


To measure that, we need a second dataset: historical daily prices for every ticker that appears in the CEO-purchase events. I pulled prices from 2021 through 2025 because the test uses a trailing 252-trading-day high. The extra 2021 data is needed for purchases that happened early in 2022.


The drawdown calculation uses adjusted close:


rolling_high_252 = highest adjusted close over the previous 252 trading days
drawdown = adjusted_close / rolling_high_252 - 1

For the merge, I matched each CEO purchase filing to the latest completed trading day before the filing date. I did not use the transaction date, and I did not use the filing date’s same-day close. If a filing happened during the trading day, the same-day close would not have been known yet.



tickers = list(cp_df.ticker.unique())
historical_eod_entries = []

def fetch_historical_eod(ticker):
    api_key = 'YOUR EODHD API KEY'
    historical_url = f'https://eodhd.com/api/eod/{ticker}?from=2021-01-01&to=2025-12-31&period=d&api_token={api_key}&fmt=json'
    historical_resp = requests.get(historical_url).json()
    historical_filtered = []
    for item in historical_resp:
        item['ticker'] = ticker
        keys = ['ticker', 'date', 'adjusted_close']
        item = {key: item.get(key) for key in keys}
        historical_filtered.append(item)
    return historical_filtered

for ticker in tickers:
    try:
        historical_eod = fetch_historical_eod(ticker)
        historical_eod_entries.extend(historical_eod)
        print(f'{ticker} done')
    except:
        print(f'{ticker} error')

This gives us one price row per ticker per trading day. Next, we calculate the rolling high and drawdown.



historical_df = pd.DataFrame(historical_eod_entries)
historical_df['date'] = pd.to_datetime(historical_df.date)

historical_df['rolling_high_252'] = (historical_df.groupby('ticker')['adjusted_close'].transform
                                     (lambda prices: prices.rolling(window=252, min_periods=200).max()))

historical_df['drawdown'] = (historical_df['adjusted_close']/ historical_df['rolling_high_252']- 1)
historical_df['drawdown_pct'] = (historical_df['drawdown'] * 100)

The min_periods=200 condition prevents early price rows from getting weak drawdown estimates when there is not enough trailing history. That means some early observations will have missing drawdowns, which is better than forcing a noisy value.


Now we merge those drawdowns back into the CEO-purchase events.



cp_df.filed_at = pd.to_datetime(cp_df.filed_at)

price_columns = ['ticker', 'date', 'adjusted_close', 'rolling_high_252', 'drawdown', 'drawdown_pct']

analysis_df = pd.merge_asof(
    cp_df.sort_values(['filed_at', 'ticker']),
    historical_df[price_columns].sort_values(['date', 'ticker']),
    by='ticker',
    left_on='filed_at',
    right_on='date',
    direction='backward',
    allow_exact_matches=False
)

analysis_df = analysis_df.rename(columns={'date': 'price_date'})
analysis_df.tail(5)

The merge adds five price-related columns to each CEO purchase event:


  • price_date latest completed trading day before the Form 4 filing

  • adjusted_close adjusted close on that trading day

  • rolling_high_252 trailing 252-trading-day high

  • drawdown decimal drawdown from that high

  • drawdown_pct same drawdown expressed as a percentage


The merged dataframe now looks like this:


Merged Dataset (Image by Author)

At this point, the dataset is no longer just an insider-transaction table. Each purchase event now has the market context attached to it. For example, a drawdown of -0.1457 means the stock was 14.57% below its trailing 252-trading-day high on the last completed trading day before the filing.


Convert Purchase Events Into Episodes

The merged dataset still has one row per CEO purchase day. That is too granular for the return analysis because the same CEO may buy repeatedly over several days or weeks.


Suppose a CEO makes five purchases within three weeks. Those trades reflect one sustained buying decision, not five independent signals. Counting each row separately would give frequent buyers too much influence over the results.


I use the following episode rule:


  • Group by ticker and reporting owner.

  • Sort purchases by filing date.

  • Start a new episode when more than 28 calendar days have passed since the previous filing.

  • Combine the shares and purchase value across all events in the episode.

  • Use the first filing date and its drawdown as the episode’s starting point.

  • Apply the 20% drawdown threshold only after the episodes have been constructed.


Applying the drawdown filter afterward matters. An episode that begins at an 18% drawdown and later reaches 22% should not be classified as an episode that started after a 20% decline.



purchase_events = analysis_df.dropna(subset=["drawdown"])
purchase_events.filed_at = pd.to_datetime(purchase_events.filed_at)
purchase_events = purchase_events.sort_values(["ticker", "reporting_owner_cik", "filed_at"])
purchase_events["days_since_previous"] = purchase_events.groupby(["ticker", "reporting_owner_cik"])["filed_at"].diff().dt.days
purchase_events["new_episode"] = purchase_events["days_since_previous"].isna() | (purchase_events["days_since_previous"] > 28)
purchase_events["episode_id"] = purchase_events.groupby(["ticker", "reporting_owner_cik"])["new_episode"].cumsum()

days_since_previous measures the calendar-day gap between consecutive filings by the same CEO in the same stock. The first purchase for each ticker-CEO pair automatically starts a new episode. Every later purchase starts another episode only when the gap exceeds 28 days.


The episode identifier is then used to aggregate the underlying daily events.



episodes = purchase_events.groupby(["ticker", "reporting_owner_cik", "reporting_owner_name", "episode_id"], 
                               as_index=False).agg(first_filing_date=("filed_at", "min"), 
                                                   last_filing_date=("filed_at", "max"),
                                                   first_transaction_date=("transaction_date", "min"),
                                                   total_shares=("shares_purchased", "sum"),
                                                   total_purchase_value=("total_purchase_value", "sum"),
                                                   purchase_days=("filed_at", "nunique"),
                                                   transaction_events=("filed_at", "size"),
                                                   initial_drawdown=("drawdown", "first"), 
                                                   initial_drawdown_pct=("drawdown_pct", "first"))

purchase_days counts the number of distinct filing dates in the episode, while transaction_events counts the daily purchase events that were combined. The total shares and purchase value cover the full episode, but the drawdown comes from the first filing date.


We can now keep only the episodes that began at least 20% below the trailing 252-day high.



episodes_20 = episodes[episodes["initial_drawdown"] <= -0.20].copy()

print("All purchase episodes:", len(episodes))
print("20% drawdown episodes:", len(episodes_20))
print("Tickers represented:", episodes_20["ticker"].nunique())

episodes_20

Purchase Episodes
Purchase Episodes

The resulting dataframe contains the first and last filing dates, total shares, total purchase value, number of purchase days, and the drawdown at the start of each episode. These 137 episodes form the CEO-purchase sample used for the forward-return analysis.


What Happened After the CEO Purchases?

The 137 qualifying episodes now have a clear starting point: the first Form 4 filing in the episode. To measure what happened next, I use the first trading day after that filing as the entry date.


That avoids entering before the purchase became public. It also handles filings submitted on weekends or market holidays without forcing an exact calendar-date match.


The return horizons are fixed in trading days:


  • 1 Month: 21 trading days

  • 3 Months: 63 trading days

  • 6 Months: 126 trading days

  • 12 Months: 252 trading days


The first step is to organize the historical prices by ticker so each episode can be matched to its own price series.



episodes_20 = episodes_20.reset_index(drop = True)
episodes_20['first_filing_date'] = pd.to_datetime(episodes_20['first_filing_date'])
historical_df['date'] = pd.to_datetime(historical_df['date'])

prices = historical_df[['ticker', 'date', 'adjusted_close']].dropna().sort_values(['ticker', 'date'])
price_map = {ticker: group.reset_index(drop=True) for ticker, group in prices.groupby('ticker')}

price_map stores one sorted dataframe per ticker. That makes it easier to find the entry date and move forward by a fixed number of trading sessions.


The return function searches for the first available trading date strictly after the filing date, then calculates returns at each horizon.



def calculate_forward_returns(row):
    ticker_prices = price_map.get(row['ticker'])

    if ticker_prices is None:
        return pd.Series(dtype='object')

    dates = ticker_prices['date'].to_numpy(dtype='datetime64[ns]')
    entry_index = np.searchsorted(dates, np.datetime64(row['first_filing_date']), side='right')

    if entry_index >= len(ticker_prices):
        return pd.Series(dtype='object')

    entry_date = ticker_prices.loc[entry_index, 'date']
    entry_price = ticker_prices.loc[entry_index, 'adjusted_close']

    result = {'entry_date': entry_date, 'entry_price': entry_price}
    horizons = {'1m': 21, '3m': 63, '6m': 126, '12m': 252}

    for label, days in horizons.items():
        target_index = entry_index + days

        if target_index < len(ticker_prices):
            target_price = ticker_prices.loc[target_index, 'adjusted_close']
            result[f'date_{label}'] = ticker_prices.loc[target_index, 'date']
            result[f'return_{label}'] = target_price / entry_price - 1
        else:
            result[f'date_{label}'] = pd.NaT
            result[f'return_{label}'] = np.nan

    return pd.Series(result)

forward_returns = episodes_20.apply(calculate_forward_returns, axis=1)
episode_returns = pd.concat([episodes_20.reset_index(drop=True), forward_returns], axis=1)

episode_returns

The resulting dataframe contains the episode details, entry date, entry price, and forward returns at each horizon:


CEO Purchase Forward Returns

To summarize the full sample, we’ll calculate the mean return, median return, and percentage of positive outcomes at each horizon.



summary = []

for horizon in ['1m', '3m', '6m', '12m']:
    returns = episode_returns[f'return_{horizon}'].dropna()

    summary.append({
        'horizon': horizon,
        'observations': len(returns),
        'mean_return': returns.mean(),
        'median_return': returns.median(),
        'positive_rate': (returns > 0).mean()
    })

summary_df = pd.DataFrame(summary)
summary_df

Forward Returns Summary

The raw numbers look encouraging, especially over three (+60%) and twelve months (+64%). But they answer only one question: what happened after CEOs bought?


They do not show whether the CEO purchase added information beyond the fact that the stock was already down at least 20%.


Build The No-Purchase Control Group

The raw return table shows what happened after CEOs bought, but it still lacks a baseline. A stock that is already down 20% may rebound without any insider activity.


The control group uses drawdown dates from the same stocks. A valid control must:


  • belong to the same ticker as the CEO-purchase episode

  • fall in the same calendar year

  • have a drawdown within five percentage points

  • occur within 180 calendar days of the purchase episode

  • have no CEO purchase within 28 days before or after it

  • be used only once


The matching also uses each CEO-purchase episode only once. Future returns are not involved in selecting controls.


Create The Control Candidates

A stock can remain below its 20% drawdown threshold for several months. Treating every trading day as a separate control would produce hundreds of nearly identical candidates from the same decline.


To reduce that repetition, we’ll divide each continuous below-20% period into 28-day blocks and keep one date from each block.



hist = historical_df[['ticker', 'date', 'adjusted_close', 'rolling_high_252', 'drawdown', 'drawdown_pct']].dropna(subset=['drawdown'])

hist['date'] = pd.to_datetime(hist['date'])
hist = hist[hist['date'].between('2022-01-01', '2025-12-31')].sort_values(['ticker', 'date'])

hist['below_20'] = hist['drawdown'] <= -0.20
hist['previous_below_20'] = hist.groupby('ticker')['below_20'].shift().fillna(False)
hist['new_state'] = hist['below_20'].ne(hist['previous_below_20'])
hist['drawdown_segment'] = hist.groupby('ticker')['new_state'].cumsum()

control_candidates = hist[hist['below_20']].copy()

control_candidates['segment_start'] = control_candidates.groupby(['ticker', 'drawdown_segment'])['date'].transform('min')
control_candidates['anchor_block'] = ((control_candidates['date'] - control_candidates['segment_start']).dt.days // 28)
control_candidates = control_candidates.sort_values(['ticker', 'date']).drop_duplicates(['ticker', 'drawdown_segment', 'anchor_block'])

control_candidates = control_candidates.reset_index(drop = True)
control_candidates

below_20 identifies dates where the stock is at least 20% below its trailing high.


drawdown_segment separates one continuous drawdown period from the next. anchor_block divides a long drawdown into 28-day windows, preventing every trading day from entering the control pool.


control candidates

The resulting candidates still need to be screened for nearby CEO purchases.


Remove Dates Near CEO Purchases

A control should represent a drawdown without a nearby CEO purchase. I use every CEO purchase filing date in the event dataset, not only the 137 episodes that passed the final drawdown filter.



purchase_dates = analysis_df[['ticker', 'filed_at']].dropna().drop_duplicates()
purchase_dates['filed_at'] = pd.to_datetime(purchase_dates['filed_at'])
purchase_dates = purchase_dates.rename(columns={'filed_at': 'purchase_date'})
purchase_dates

Purchase Dates

For each control candidate, the next two merges find the closest CEO purchase before and after that date.



control_candidates = pd.merge_asof(
    control_candidates.sort_values('date'),
    purchase_dates.sort_values('purchase_date'),
    by='ticker',
    left_on='date',
    right_on='purchase_date',
    direction='backward'
)

control_candidates = control_candidates.rename(columns={'purchase_date': 'previous_purchase_date'})

control_candidates = pd.merge_asof(
    control_candidates.sort_values('date'),
    purchase_dates.sort_values('purchase_date'),
    by='ticker',
    left_on='date',
    right_on='purchase_date',
    direction='forward'
)

control_candidates = control_candidates.rename(columns={'purchase_date': 'next_purchase_date'})

Now, we’ll remove candidates within 28 calendar days of either purchase.



days_from_previous = (control_candidates['date'] - control_candidates['previous_purchase_date']).dt.days
days_to_next = (control_candidates['next_purchase_date'] - control_candidates['date']).dt.days

far_from_previous = control_candidates['previous_purchase_date'].isna() | (days_from_previous > 28)
far_from_next = control_candidates['next_purchase_date'].isna() | (days_to_next > 28)

control_candidates = control_candidates[far_from_previous & far_from_next]
control_candidates

A missing previous or next purchase is acceptable. It means there was no purchase on that side of the candidate within the available period.


Filtered Candidates

At this point, each row represents a possible no-purchase control date with its ticker, date, adjusted close, and drawdown.


Match Purchase Episodes With Controls

Both datasets need stable identifiers before matching.



purchase_pool = episodes_20.reset_index(drop = True)

purchase_pool['first_filing_date'] = pd.to_datetime(purchase_pool['first_filing_date'])
purchase_pool['year'] = purchase_pool['first_filing_date'].dt.year
purchase_pool['purchase_id'] = np.arange(len(purchase_pool))

control_candidates['year'] = control_candidates['date'].dt.year
control_candidates['control_id'] = np.arange(len(control_candidates))

The matching runs separately for each ticker and year. Within that group, a valid pair must have:


  • no more than five percentage points of drawdown difference

  • no more than 180 calendar days between the two dates


The assignment algorithm then selects a one-to-one set of controls while prioritizing the smallest drawdown difference.



from scipy.optimize import linear_sum_assignment

matches = []
max_drawdown_gap = 0.05
max_calendar_gap = 180

for (ticker, year), purchases in purchase_pool.groupby(['ticker', 'year']):
    controls = control_candidates[(control_candidates['ticker'] == ticker) & (control_candidates['year'] == year)].copy()

    if controls.empty:
        continue

    purchase_drawdowns = purchases['initial_drawdown'].to_numpy()[:, None]
    control_drawdowns = controls['drawdown'].to_numpy()[None, :]
    drawdown_cost = np.abs(purchase_drawdowns - control_drawdowns)

    purchase_dates = purchases['first_filing_date'].to_numpy(dtype='datetime64[D]')
    control_dates = controls['date'].to_numpy(dtype='datetime64[D]')
    calendar_gap = np.abs((purchase_dates[:, None] - control_dates[None, :]).astype('timedelta64[D]').astype(int))

    valid = (drawdown_cost <= max_drawdown_gap) & (calendar_gap <= max_calendar_gap)

    if not valid.any():
        continue

    cost = drawdown_cost + calendar_gap / 1000000
    cost[~valid] = 1000000

    row_indices, column_indices = linear_sum_assignment(cost)
    keep = cost[row_indices, column_indices] < 1000000

    selected = pd.DataFrame({
        'purchase_id': purchases.iloc[row_indices[keep]]['purchase_id'].to_numpy(),
        'control_id': controls.iloc[column_indices[keep]]['control_id'].to_numpy(),
        'drawdown_gap': drawdown_cost[row_indices[keep], column_indices[keep]],
        'calendar_gap_days': calendar_gap[row_indices[keep], column_indices[keep]]
    })

    matches.append(selected)

matched_pairs = pd.concat(matches, ignore_index=True)

drawdown_cost measures how different the two drawdowns are. A purchase episode at -32% and a candidate at -34% have a gap of two percentage points.


The small calendar-gap term breaks ties between controls with similar drawdowns. It does not override the drawdown requirement.

Because linear_sum_assignment() creates a one-to-one assignment, the same control date cannot be matched to several CEO-purchase episodes.


Finally, we'll merge the purchase and control details into one dataframe.



selected_controls = control_candidates[['control_id', 'ticker', 'date', 'adjusted_close', 'drawdown', 'drawdown_pct']].rename(columns={'ticker': 'control_ticker',
                                                                                                                                       'date': 'control_date',
                                                                                                                                       'adjusted_close': 'control_signal_price',
                                                                                                                                       'drawdown': 'control_drawdown',
                                                                                                                                       'drawdown_pct': 'control_drawdown_pct'})

matched_sample = matched_pairs.merge(purchase_pool,on='purchase_id',how='left').merge(selected_controls,on='control_id',how='left')
matched_sample

Each row now contains one CEO-purchase episode and its matched no-purchase drawdown date:


Final Control Group

The Final Comparison: CEO Purchases Against Similar No-Purchase Drawdowns

The matched sample gives each CEO-purchase episode a comparable no-purchase date from the same stock. From here, both sides must be treated identically.


The CEO side starts on the first trading day after the Form 4 filing. The control side starts on the first trading day after its matched drawdown date. Using the same entry rule and return horizons prevents the comparison from quietly favoring either group.



matched_sample['first_filing_date'] = pd.to_datetime(matched_sample['first_filing_date'])
matched_sample['control_date'] = pd.to_datetime(matched_sample['control_date'])
historical_df['date'] = pd.to_datetime(historical_df['date'])

prices = historical_df[['ticker', 'date', 'adjusted_close']].dropna().sort_values(['ticker', 'date'])
price_map = {ticker: group.reset_index(drop=True) for ticker, group in prices.groupby('ticker')}

price_map stores one chronologically ordered dataframe per ticker. The purchase and control calculations will both read from these same price histories.


Calculate Forward Returns

Next, we’ll create one reusable function that takes a ticker and signal date. It enters on the first trading day after that date and calculates returns after 21, 63, 126, and 252 trading sessions.



def get_forward_returns(ticker, signal_date):
    result = {
        'entry_date': pd.NaT,
        'entry_price': np.nan,
        'return_1m': np.nan,
        'return_3m': np.nan,
        'return_6m': np.nan,
        'return_12m': np.nan
    }

    ticker_prices = price_map.get(ticker)

    if ticker_prices is None or pd.isna(signal_date):
        return pd.Series(result)

    dates = ticker_prices['date'].to_numpy(dtype='datetime64[ns]')
    entry_index = np.searchsorted(dates, np.datetime64(signal_date), side='right')

    if entry_index >= len(ticker_prices):
        return pd.Series(result)

    entry_price = ticker_prices.loc[entry_index, 'adjusted_close']
    result['entry_date'] = ticker_prices.loc[entry_index, 'date']
    result['entry_price'] = entry_price

    for label, days in {'1m': 21, '3m': 63, '6m': 126, '12m': 252}.items():
        target_index = entry_index + days

        if target_index < len(ticker_prices):
            target_price = ticker_prices.loc[target_index, 'adjusted_close']
            result[f'return_{label}'] = target_price / entry_price - 1

    return pd.Series(result)

Using side='right' forces the entry to occur after the signal date. The purchase group cannot enter on the filing date, and the control group cannot enter on the matched drawdown date.


Returns remain missing when the historical dataset does not extend far enough beyond the signal. This mostly affects late-2025 observations at the longer horizons.


Apply The Same Function To Purchases And Controls

We can now run the function twice for every matched row. The first pass uses the CEO filing date, while the second uses the control date.



purchase_returns = matched_sample.apply(lambda row: get_forward_returns(row['ticker'], row['first_filing_date']), axis=1).add_prefix('purchase_')
control_returns = matched_sample.apply(lambda row: get_forward_returns(row['control_ticker'], row['control_date']), axis=1).add_prefix('control_')
matched_returns = pd.concat([
    matched_sample.reset_index(drop=True),
    purchase_returns.reset_index(drop=True),
    control_returns.reset_index(drop=True)], axis=1)

The output places the purchase and control returns side by side for every pair. A pair enters a particular horizon only when both sides have a valid return.


Comparison

Now the final step is to summarize the matched returns for comparison. Means show the overall return level, but they can be pulled upward by a few extreme outcomes. The paired median difference and win rate show whether the CEO-purchase side won consistently across individual matches.



comparison = []

for horizon in ['1m', '3m', '6m', '12m']:
    purchase_col = f'purchase_return_{horizon}'
    control_col = f'control_return_{horizon}'
    valid = matched_returns[[purchase_col, control_col]].dropna()
    differences = valid[purchase_col] - valid[control_col]

    comparison.append({
        'horizon': horizon,
        'matched_pairs': len(valid),
        'purchase_mean': valid[purchase_col].mean(),
        'control_mean': valid[control_col].mean(),
        'mean_difference': differences.mean(),
        'purchase_median': valid[purchase_col].median(),
        'control_median': valid[control_col].median(),
        'median_difference': differences.median(),
        'purchase_positive_rate': (valid[purchase_col] > 0).mean(),
        'control_positive_rate': (valid[control_col] > 0).mean(),
        'purchase_win_rate': (valid[purchase_col] > valid[control_col]).mean()
    })

comparison_df = pd.DataFrame(comparison)
comparison_df

median_difference is calculated from the return gap inside each matched pair. It is not simply the CEO median minus the control median.


Final Comparison

The one-month result was weak across every measure. CEO-purchase episodes had a lower mean, a negative median pair gap, and beat their controls in only 43.6% of matches.


Three months produced the cleanest advantage. The CEO group returned 6.2 percentage points more on average, had a positive median paired difference of 3.9 points, and won 59.6% of the comparisons.


The longer horizons were less convincing than their averages suggest. At six months, the CEO group had the higher mean but lost more individual comparisons than it won. At twelve months, the average advantage reached 9.9 points, yet the median pair gap was negative and the CEO group won only 37.9% of matches.


A smaller number of large winners lifted the six- and twelve-month averages. Three months was the only horizon where the average, paired median difference, positive-return rate, and win rate all supported the CEO-purchase group.


What The Results Actually Say

The raw returns made CEO buying look stronger than it really was. A 35.4% average return after twelve months sounds compelling, but the matched controls showed that beaten-down stocks were already capable of large rebounds without any nearby CEO purchase.


The three-month window was different. It was the only horizon where the CEO-purchase group had a higher average return, a positive median pair gap, a higher positive-return rate, and a win rate above 50%. That is the closest this dataset came to a consistent edge.


The six and twelve-month averages were easy to misread. Both were lifted by a smaller number of large winners, while the typical matched CEO-purchase episode did not outperform its control. At twelve months, the CEO group won only 37.9% of pairs despite posting the higher average return.


CEO buying was not useless. Its value was narrower than the headline numbers suggested, concentrated around the three-month horizon rather than showing up as a dependable long-term signal.


What This Test Can And Cannot Say

This was a 500-security, screener-based sample, not the full market. The universe may carry survivorship bias, some code-P purchases may not have been fully discretionary, and matching improves the comparison without proving that CEO buying caused the returns. The results are exploratory, not a trading rule.


The useful part of the workflow was separating insider buying from what beaten-down stocks already do on their own. Without controls, the CEO-purchase sample looked broadly bullish. Once similar no-purchase drawdowns were added, most of that story narrowed to one possible three-month edge, with no clean long-term guarantee.


Honestly, that smaller answer is more useful than a bigger but misleading one.


Bring information-rich articles and research works straight to your inbox (it's not that hard). 

Thanks for subscribing!

© 2023 by InsightBig. Powered and secured by Wix

bottom of page