<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ #Stock market - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ #Stock market - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 28 Jul 2026 11:52:21 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/stock-market/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Analyze Insider Transactions with Python: A CEO Buying Case Study ]]>
                </title>
                <description>
                    <![CDATA[ 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 ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-analyze-insider-transactions-with-python-a-ceo-buying-case-study/</link>
                <guid isPermaLink="false">6a5660b53fc8b1266f8c07a5</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #Stock market ]]>
                    </category>
                
                    <category>
                        <![CDATA[ trading,  ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 16:15:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/6ec83342-4382-4daf-99b7-2afef1cdf3f2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.”</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-import-the-required-packages">Import The Required Packages</a></p>
</li>
<li><p><a href="#heading-build-the-stock-universe">Build The Stock Universe</a></p>
</li>
<li><p><a href="#heading-fetch-ceo-purchases-and-apply-the-date-filter">Fetch CEO Purchases And Apply The Date Filter</a></p>
</li>
<li><p><a href="#heading-turn-form-4-rows-into-daily-purchase-events">Turn Form 4 Rows Into Daily Purchase Events</a></p>
</li>
<li><p><a href="#heading-add-historical-prices-and-drawdowns">Add Historical Prices And Drawdowns</a></p>
<ul>
<li><p><a href="#heading-calculate-the-trailing-high-and-drawdown">Calculate The Trailing High And Drawdown</a></p>
</li>
<li><p><a href="#heading-match-each-purchase-with-the-latest-available-price">Match Each Purchase With The Latest Available Price</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-convert-purchase-events-into-episodes">Convert Purchase Events Into Episodes</a></p>
</li>
<li><p><a href="#heading-calculate-returns-after-ceo-purchases">Calculate Returns After CEO Purchases</a></p>
<ul>
<li><p><a href="#heading-organize-the-price-history-by-ticker">Organize The Price History By Ticker</a></p>
</li>
<li><p><a href="#heading-find-the-entry-date-and-calculate-forward-returns">Find The Entry Date And Calculate Forward Returns</a></p>
</li>
<li><p><a href="#heading-summarize-the-raw-returns">Summarize The Raw Returns</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-build-the-no-purchase-control-group">Build The No-Purchase Control Group</a></p>
<ul>
<li><p><a href="#heading-create-the-control-candidates">Create The Control Candidates</a></p>
</li>
<li><p><a href="#heading-remove-dates-near-ceo-purchases">Remove Dates Near CEO Purchases</a></p>
</li>
<li><p><a href="#heading-match-purchase-episodes-with-controls">Match Purchase Episodes With Controls</a></p>
</li>
<li><p><a href="#heading-build-the-final-matched-dataset">Build The Final Matched Dataset</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-compare-ceo-purchases-against-similar-no-purchase-drawdowns">Compare CEO Purchases Against Similar No-Purchase Drawdowns</a></p>
<ul>
<li><p><a href="#heading-calculate-forward-returns-from-any-signal-date">Calculate Forward Returns From Any Signal Date</a></p>
</li>
<li><p><a href="#heading-apply-the-same-return-logic-to-both-groups">Apply The Same Return Logic To Both Groups</a></p>
</li>
<li><p><a href="#heading-build-the-final-comparison">Build The Final Comparison</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-the-case-study-found">What The Case Study Found</a></p>
</li>
<li><p><a href="#heading-what-this-test-can-and-cant-say">What This Test Can And Can't Say</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You don't need an advanced finance or quantitative background to follow this tutorial. A basic understanding of Python and <code>pandas</code> should be enough.</p>
<p>Before starting, make sure you have:</p>
<ul>
<li><p>Python installed locally, or access to a notebook environment such as Jupyter Notebook or Google Colab</p>
</li>
<li><p>Basic familiarity with dataframes, functions, loops, and API requests</p>
</li>
<li><p>An EODHD API key with access to the screener, Form 4 filings, and historical EOD endpoints</p>
</li>
<li><p>Enough API credits to process the number of stocks you choose to analyze</p>
</li>
</ul>
<p>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.</p>
<p>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.</p>
<h2 id="heading-import-the-required-packages">Import The Required Packages</h2>
<p>We only need a small set of packages for the full workflow. <code>requests</code> handles the API calls, <code>pandas</code> and <code>NumPy</code> do most of the data work, and SciPy gives us the one-to-one matching algorithm used later for the control group.</p>
<pre><code class="language-python">import json
import re
import numpy as np
import pandas as pd
import requests
from scipy.optimize import linear_sum_assignment
</code></pre>
<p>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 <code>pip</code> before importing them to your environment.</p>
<h2 id="heading-build-the-stock-universe">Build The Stock Universe</h2>
<p>Before pulling insider filings, we need a list of companies to search.</p>
<p>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.</p>
<p>The market-cap buckets are:</p>
<ul>
<li><p><code>micro_cap</code>: $50 million to $300 million</p>
</li>
<li><p><code>small_cap</code>: $300 million to $2 billion</p>
</li>
<li><p><code>mid_cap</code>: $2 billion to $10 billion</p>
</li>
<li><p><code>large_cap</code>: $10 billion and above</p>
</li>
</ul>
<p>For each bucket, we'll fetch 500 screener results, randomly select 250, and combine them into a 1,000-stock universe.</p>
<pre><code class="language-python">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", "&gt;=", 50_000_000],
    ["market_capitalization", "&lt;", 300_000_000]
]

small_filters = [
    ["exchange", "=", "NYSE"],
    ["market_capitalization", "&gt;=", 300_000_000],
    ["market_capitalization", "&lt;", 2_000_000_000]
]

mid_filters = [
    ["exchange", "=", "NYSE"],
    ["market_capitalization", "&gt;=", 2_000_000_000],
    ["market_capitalization", "&lt;", 10_000_000_000]
]

large_filters = [
    ["exchange", "=", "NYSE"],
    ["market_capitalization", "&gt;=", 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
</code></pre>
<p><strong>Note</strong>: Replace <code>YOUR EODHD API KEY</code> with your actual EODHD API key. If you don’t have one, you can obtain it by opening an <a href="https://eodhd.com/">EODHD developer account</a>.</p>
<p>The screener returns at most 100 rows per request, so the loop moves through the first 500 results in five batches.</p>
<p>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.</p>
<p>The final dataframe contains 1,000 tickers, with 250 from each bucket.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/cf6cc7ee-0796-42cc-815c-0f655c1aa434.png" alt="stocks universe" style="display:block;margin:0 auto" width="367" height="690" loading="lazy">

<p>One caveat is worth stating now. The micro-cap filter uses the broader <code>us</code> exchange setting, while the other groups use <code>NYSE</code>. 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.</p>
<h2 id="heading-fetch-ceo-purchases-and-apply-the-date-filter">Fetch CEO Purchases And Apply The Date Filter</h2>
<p>With the stock universe ready, we can start searching the Form 4 filings for CEO purchases using <a href="https://eodhd.com/financial-apis/insider-transactions-api">EODHD’s Insider Transactions (SEC Form 4) API</a>.</p>
<p>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.</p>
<p>For this analysis, a transaction must satisfy all of these conditions:</p>
<ul>
<li><p>appear under non-derivative transactions</p>
</li>
<li><p>be reported by an officer</p>
</li>
<li><p>have an officer title that identifies the person as a CEO</p>
</li>
<li><p>use transaction code <code>P</code></p>
</li>
<li><p>represent acquired shares</p>
</li>
<li><p>contain positive values for both shares and price</p>
</li>
<li><p>refer to common stock</p>
</li>
</ul>
<p>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 could observe the purchase. Later in the analysis, the filing date will become the signal date.</p>
<p>The following block handles the complete extraction. It defines the filtering function, runs it across the first 500 stocks, and combines all qualifying rows into one dataframe.</p>
<pre><code class="language-python">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}&amp;page[limit]=100&amp;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 &gt; 0
                    and price_per_share is not None
                    and price_per_share &gt; 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')
</code></pre>
<p>The function requests the filings in batches of 100 and flattens the returned pages into one list. It then checks every non-derivative transaction against the CEO-purchase rules.</p>
<p>The CEO-title check uses a regular expression because filings don't use one perfectly consistent title. A CEO might appear as <code>CEO</code>, <code>Chief Executive Officer</code>, or <code>Co-CEO</code>, so matching only one exact string would miss valid records.</p>
<p>We also preserve the linked footnotes. Transaction code <code>P</code> is useful, but it doesn't tell the complete story by itself. A footnote may reveal that the purchase involved a trading plan, an offering, or another arrangement that deserves closer inspection.</p>
<p>I ran this step on the first 500 securities in the universe because the Form 4 endpoint can consume API credits quickly. The same loop can be extended to all 1,000 stocks for a larger sample.</p>
<p>Once the rows are collected, we restrict the dataset to filings submitted between the beginning of 2022 and the end of 2025.</p>
<pre><code class="language-python">cp_df = cp_df[cp_df["filed_at"].between("2022-01-01","2025-12-31")]

cp_df.tail()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7ee92269-bbdd-4a15-b16b-81b567eede69.png" alt="CEO Purchases" style="display:block;margin:0 auto" width="1958" height="183" loading="lazy">

<p>These are still raw filing rows rather than independent CEO-buying signals. One purchase can be split across several rows when different blocks of shares were acquired at different prices. The next step is to collapse those fragments into daily purchase events.</p>
<h2 id="heading-turn-form-4-rows-into-daily-purchase-events">Turn Form 4 Rows Into Daily Purchase Events</h2>
<p>A CEO might buy shares at several prices on the same day. The filing can record each price block as a separate row, even though those rows belong to one broader purchase. If we treated every row as an independent signal, one busy purchase day could receive far more weight than another simply because it was split into more price levels.</p>
<p>So the next step is to group rows that share the same <strong>ticker</strong>, <strong>CEO</strong>, <strong>filing</strong>, and <strong>transaction</strong> date.</p>
<p>For each group, we'll add up the shares and total purchase value. We'll then calculate a weighted-average price:</p>
<p><em><strong>weighted average price = total purchase value / total shares purchased</strong></em></p>
<p>The following block performs the full aggregation and produces one row per daily CEO-purchase event.</p>
<pre><code class="language-python">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()
</code></pre>
<p>The <code>purchase_value</code> column gives every raw row a dollar value before aggregation. Once the rows are grouped, those values can be summed without losing the effect of the different purchase prices.</p>
<p>The <code>transaction_rows</code> column is useful for checking how much collapsing happened. A value of <code>1</code> means the daily event already appeared as one row. A value of <code>5</code> means five separate filing rows were combined into one purchase event.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/fcf772a6-ca1b-46e8-b886-bef8ce3f52e2.png" alt="Grouped CEO Purchase Dataset" style="display:block;margin:0 auto" width="1760" height="183" loading="lazy">

<p>The aggregation reduced the dataset from 625 raw transaction rows to 535 daily purchase events.</p>
<p>That difference isn't just housekeeping. It changes the unit of analysis from “one reported price block” to “one CEO purchase day,” which is much closer to the economic event we're trying to study.</p>
<p>We're still not ready to calculate returns, though. A purchase event tells us that the CEO bought, but not whether the stock was near its high, down slightly, or already deep in a drawdown. Next, we'll attach the price context that was available when each filing became public.</p>
<h2 id="heading-add-historical-prices-and-drawdowns">Add Historical Prices And Drawdowns</h2>
<p>A CEO purchase only becomes interesting in this test when we know where the stock was trading at the time.</p>
<p>A purchase made 5% below the yearly high is very different from one made after the stock has fallen 40%. So we now need to bring historical price data into the workflow and measure how far each stock was below its recent high when the filing became public.</p>
<p>We'll use adjusted close rather than the raw closing price because adjusted prices account for events such as stock splits and dividends. That gives us a more consistent series for comparing prices across time.</p>
<p>We pull prices from 2021 through 2025, even though the purchase analysis begins in 2022. The extra year is needed because the first 2022 observations still require enough earlier data to calculate a trailing one-year high.</p>
<p>The following block fetches the daily historical prices <a href="https://eodhd.com/lp/historical-eod-api">EODHD’s historical EOD endpoint</a> for every ticker represented in the CEO-purchase dataset and combines them into one dataframe.</p>
<pre><code class="language-python">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&amp;to=2025-12-31&amp;period=d&amp;api_token={api_key}&amp;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')
</code></pre>
<p>This code fetches the historical data and gives us one price row per ticker per trading day. Next, we calculate the rolling high and drawdown.</p>
<h3 id="heading-calculate-the-trailing-high-and-drawdown">Calculate The Trailing High And Drawdown</h3>
<p>For every trading day, we want to know the highest adjusted close reached during the previous 252 trading sessions. That's roughly one trading year.</p>
<p>The drawdown is then calculated as:</p>
<p><em><strong>drawdown = adjusted close / trailing 252-day high - 1</strong></em></p>
<p>A value of <code>-0.20</code> means the stock is 20% below its trailing high. A value of <code>-0.35</code> means it's 35% below that high.</p>
<p>The next block sorts each stock’s price history, calculates the rolling high, and converts the result into both decimal and percentage drawdown columns.</p>
<pre><code class="language-python">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)
</code></pre>
<p>The <code>min_periods=200</code> argument deserves a quick explanation.</p>
<p>A full rolling window contains 252 trading days, but requiring exactly 252 observations would remove many early rows. Allowing the calculation after 200 sessions gives us some flexibility while still requiring a substantial amount of historical data.</p>
<p>Rows without enough history remain missing rather than receiving a weak drawdown estimate.</p>
<h3 id="heading-match-each-purchase-with-the-latest-available-price">Match Each Purchase With The Latest Available Price</h3>
<p>Now we need to attach the price context to each CEO-purchase event.</p>
<p>The filing date is the signal date, but we shouldn't use the closing price from that same date. A Form 4 can be submitted before, during, or after the trading session, so the same-day close may not have been known when the filing appeared.</p>
<p>Instead, we use the latest completed trading day strictly before the filing date.</p>
<p>The next block performs that match with <code>merge_asof()</code>. Unlike a normal merge, it can match each filing with the nearest earlier price date rather than requiring both dates to be identical.</p>
<pre><code class="language-python">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'})
</code></pre>
<p>The key setting is <code>allow_exact_matches=False</code>. It prevents a filing dated March 10 from using the March 10 closing price. The merge will instead use the latest available trading day before March 10.</p>
<p>The merged dataframe now looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/c0ba4514-0e32-4748-bec0-88421854df9c.png" alt="Merged Dataset" style="display:block;margin:0 auto" width="2266" height="183" loading="lazy">

<p>The merged dataframe now contains the purchase information alongside:</p>
<ul>
<li><p><code>price_date</code>: the trading day used for the match</p>
</li>
<li><p><code>adjusted_close</code>: the stock price on that day</p>
</li>
<li><p><code>rolling_high_252</code>: the trailing one-year high</p>
</li>
<li><p><code>drawdown</code>: the decline expressed as a decimal</p>
</li>
<li><p><code>drawdown_pct</code>: the same decline expressed as a percentage</p>
</li>
</ul>
<p>For example, a <code>drawdown_pct</code> value of <code>-32.4</code> means the stock was 32.4% below its trailing 252-day high on the last completed trading day before the filing.</p>
<p>We now know both that the CEO bought and how beaten down the stock was when the purchase became public.</p>
<p>The next problem is repeated buying. A CEO who buys several times over a few weeks shouldn't automatically create several independent signals.</p>
<h2 id="heading-convert-purchase-events-into-episodes">Convert Purchase Events Into Episodes</h2>
<p>At this point, the dataset has one row per CEO purchase day. That's better than working with raw Form 4 rows, but it can still overcount the same underlying decision.</p>
<p>Imagine a CEO buys shares on Monday, again the following week, and once more two weeks later. Technically, those are three purchase events. Economically, they may be one sustained buying campaign.</p>
<p>Treating all three as independent signals would give frequent buyers more weight than CEOs who completed the same idea in one trade. So before calculating returns, we'll group nearby purchases into <strong>buying episodes</strong>.</p>
<p>The rule is simple: purchases by the same CEO in the same stock belong to one episode when consecutive filing dates are no more than 28 calendar days apart.</p>
<p>We'll first remove purchase events without a usable drawdown, sort the remaining rows by ticker, CEO, and filing date, and calculate the number of days since the previous purchase.</p>
<pre><code class="language-python">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"] &gt; 28)
purchase_events["episode_id"] = purchase_events.groupby(["ticker", "reporting_owner_cik"])["new_episode"].cumsum()
</code></pre>
<p>The first purchase for every ticker-CEO combination automatically starts a new episode because there's no earlier filing to compare it with.</p>
<p>After that, a new episode begins only when the gap from the previous filing exceeds 28 days. Purchases separated by 28 days or less stay inside the same episode.</p>
<p>The cumulative sum of <code>new_episode</code> gives every buying sequence its own identifier.</p>
<p>Now that each purchase event belongs to an episode, we can collapse the events into one row per buying sequence.</p>
<p>For each episode, we keep the first and last filing dates, add up the shares and purchase value, count the purchase activity, and preserve the drawdown from the first filing date.</p>
<pre><code class="language-python">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"))
</code></pre>
<p>There are two activity counts here:</p>
<ul>
<li><p><code>purchase_days</code> counts the number of distinct filing dates in the episode.</p>
</li>
<li><p><code>transaction_events</code> counts the daily purchase events that were grouped together.</p>
</li>
</ul>
<p>The total shares and purchase value cover the entire episode. But the drawdown comes only from the first filing date because that's when the signal begins.</p>
<p>That detail matters for the 20% filter.</p>
<p>Suppose an episode starts when the stock is 18% below its high, then the CEO buys again after the drawdown reaches 24%. It would be misleading to call that an episode that began after a 20% decline.</p>
<p>So we group first, then apply the threshold using <code>initial_drawdown</code>.</p>
<pre><code class="language-python">episodes_20 = episodes[episodes["initial_drawdown"] &lt;= -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
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/c725daf8-16a3-461d-8269-3279310abe87.png" alt="Purchase Episodes" style="display:block;margin:0 auto" width="562" height="135" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/8474797e-b23f-4db3-9f08-946fa4bfc783.png" alt="Purchase Episodes" style="display:block;margin:0 auto" width="1742" height="333" loading="lazy">

<p>We have now reduced repeated purchases into 137 distinct CEO-buying episodes that started while the stock was at least 20% below its trailing high.</p>
<p>These episodes are the actual signals we'll follow through time. Next, we'll enter on the first trading day after the initial filing and measure what happened over one, three, six, and twelve months.</p>
<h2 id="heading-calculate-returns-after-ceo-purchases">Calculate Returns After CEO Purchases</h2>
<p>We now have 137 CEO-buying episodes that began while the stock was at least 20% below its trailing 252-day high.</p>
<p>The next question is straightforward: what happened after those filings became public?</p>
<p>We'll use the first trading day after the episode’s initial filing as the entry date. That keeps the test realistic. An outside investor couldn't act before the Form 4 appeared, and using the next trading session also handles filings submitted on weekends or market holidays.</p>
<p>The return horizons are:</p>
<ul>
<li><p>1 Month: 21 trading days</p>
</li>
<li><p>3 Months: 63 trading days</p>
</li>
<li><p>6 Months: 126 trading days</p>
</li>
<li><p>12 Months: 252 trading days</p>
</li>
</ul>
<h3 id="heading-organize-the-price-history-by-ticker">Organize The Price History By Ticker</h3>
<p>Before calculating returns, we'll create a separate, chronologically ordered price series for each stock.</p>
<p>This lets the return function find the correct entry date and then move forward by a fixed number of trading sessions without repeatedly filtering the full historical dataframe.</p>
<pre><code class="language-python">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')}
</code></pre>
<p><code>price_map</code> is a dictionary where each ticker points to its own dataframe of dates and adjusted closing prices.</p>
<p>For example, <code>price_map['AAT.US']</code> contains only the historical prices for <code>AAT.US</code>, already sorted from oldest to newest.</p>
<h3 id="heading-find-the-entry-date-and-calculate-forward-returns">Find The Entry Date And Calculate Forward Returns</h3>
<p>Now we can write a function that handles one purchase episode at a time.</p>
<p>The function will:</p>
<ol>
<li><p>locate the stock’s price history</p>
</li>
<li><p>find the first trading day strictly after the filing date</p>
</li>
<li><p>save that day’s adjusted close as the entry price</p>
</li>
<li><p>move forward by 21, 63, 126, and 252 trading sessions</p>
</li>
<li><p>calculate the return at each horizon</p>
</li>
</ol>
<pre><code class="language-python">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 &gt;= 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 &lt; 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
</code></pre>
<p>The resulting dataframe contains the episode details, entry date, entry price, and forward returns at each horizon:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/75500507-177b-4bd7-bf42-8710eaefee45.png" alt="CEO Purchase Forward Returns" style="display:block;margin:0 auto" width="2598" height="333" loading="lazy">

<h3 id="heading-summarize-the-raw-returns">Summarize The Raw Returns</h3>
<p>Looking at individual episodes is useful, but we also need a compact view of the full sample.</p>
<p>For each horizon, we'll calculate:</p>
<ul>
<li><p>the number of available observations</p>
</li>
<li><p>the mean return</p>
</li>
<li><p>the median return</p>
</li>
<li><p>the percentage of returns above zero</p>
</li>
</ul>
<pre><code class="language-python">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 &gt; 0).mean()
    })

summary_df = pd.DataFrame(summary)
summary_df
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/3a527a68-a59b-4f66-bb59-ce0ac0ac4318.png" alt="Forward Returns Summary" style="display:block;margin:0 auto" width="960" height="306" loading="lazy">

<p>At first glance, the results look promising. The average return reached 11.9% after three months and 35.4% after twelve months. Most twelve-month observations were also positive.</p>
<p>But this is exactly where it's easy to jump to the wrong conclusion.</p>
<p>These numbers tell us what happened after CEOs bought. They don't tell us how much of that performance came from the CEO purchase itself.</p>
<p>The stocks were already down at least 20%. Some of them may have rebounded simply because beaten-down stocks sometimes recover.</p>
<p>To separate those two effects, we need a comparison group made from similar drawdown dates where no CEO purchase occurred nearby.</p>
<h2 id="heading-build-the-no-purchase-control-group">Build The No-Purchase Control Group</h2>
<p>The raw return table looked encouraging, but it still gave CEO buying all the credit.</p>
<p>That's not a fair test. Every stock in the sample was already down at least 20%, and beaten-down stocks can rebound without any insider activity. We need to compare each CEO-purchase episode with another date where the same stock was under similar pressure but no CEO purchase happened nearby.</p>
<p>A valid control must satisfy six rules:</p>
<ul>
<li><p>same ticker</p>
</li>
<li><p>same calendar year</p>
</li>
<li><p>drawdown within five percentage points</p>
</li>
<li><p>no more than 180 calendar days away</p>
</li>
<li><p>no CEO purchase within 28 days before or after</p>
</li>
<li><p>used only once</p>
</li>
</ul>
<p>Each CEO-purchase episode is also matched only once.</p>
<h3 id="heading-create-the-control-candidates">Create The Control Candidates</h3>
<p>We'll start by finding every trading day between 2022 and 2025 when a stock was at least 20% below its trailing high.</p>
<p>There's one problem, though. A stock can stay below that threshold for months. If we kept every trading day, one long decline could create hundreds of nearly identical control candidates.</p>
<p>To avoid that, we'll split each continuous drawdown period into 28-day blocks and keep one candidate from each block.</p>
<pre><code class="language-python">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'] &lt;= -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
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7fe986c2-f3d3-4dd8-884d-b98772b56834.png" alt="Control Candidates" style="display:block;margin:0 auto" width="1294" height="333" loading="lazy">

<p><code>below_20</code> marks the dates that pass the drawdown threshold.</p>
<p><code>drawdown_segment</code> then separates one continuous decline from another. If the stock recovers above the threshold and falls below it again later, that becomes a new segment.</p>
<p>Inside each segment, <code>anchor_block</code> counts 28-day windows from the day the drawdown began. Keeping one row per block gives us a manageable set of dates without treating every session in the same decline as a fresh event.</p>
<p>These dates are only potential controls. We still need to remove any that occurred close to CEO buying.</p>
<h3 id="heading-remove-dates-near-ceo-purchases">Remove Dates Near CEO Purchases</h3>
<p>A no-purchase control should be genuinely separate from the insider signal.</p>
<p>For example, a drawdown date three days before a CEO filing would be a poor control. The transaction may already have happened, and the filing may simply not have appeared yet.</p>
<p>We therefore collect every CEO purchase filing date in the event dataset, not only the 137 episodes that passed the 20% threshold.</p>
<pre><code class="language-python">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
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/384fe15f-d647-4152-9836-8ef8ac6fd427.png" alt="purchase dates" style="display:block;margin:0 auto" width="281" height="406" loading="lazy">

<p>For each candidate, we now need to find the closest purchase filing before it and the closest purchase filing after it.</p>
<p>Two <code>merge_asof()</code> operations handle that. The first searches backward, while the second searches forward.</p>
<pre><code class="language-python">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'})
</code></pre>
<p>Each candidate now knows the nearest CEO purchase on either side.</p>
<p>We can calculate the distance from those filings and keep only dates that are more than 28 calendar days away from both.</p>
<pre><code class="language-python">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 &gt; 28)
far_from_next = control_candidates['next_purchase_date'].isna() | (days_to_next &gt; 28)

control_candidates = control_candidates[far_from_previous &amp; far_from_next]
control_candidates
</code></pre>
<p>A missing previous or next filing is fine. It simply means there was no CEO purchase on that side of the candidate within the available dataset.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/be9c7d99-1f8a-4f5d-9631-c01185c194f8.png" alt="filtered candidates" style="display:block;margin:0 auto" width="1640" height="333" loading="lazy">

<p>At this point, every remaining row represents a date when:</p>
<ul>
<li><p>the stock was down at least 20%</p>
</li>
<li><p>the date wasn't part of the immediate neighborhood of a CEO purchase</p>
</li>
<li><p>the stock had enough historical price data for the drawdown calculation</p>
</li>
</ul>
<h3 id="heading-match-purchase-episodes-with-controls">Match Purchase Episodes With Controls</h3>
<p>Now comes the actual matching.</p>
<p>We first give every CEO-purchase episode and every control candidate a unique identifier. We also extract the calendar year because matches must come from the same stock and year.</p>
<pre><code class="language-python">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))
</code></pre>
<p>The matching happens separately inside each ticker-year group.</p>
<p>Suppose a CEO bought when a stock was down 32%. We search for a no-purchase date in the same stock and year where the drawdown was close to 32%, while also keeping the dates no more than 180 calendar days apart.</p>
<p>A pair is valid only when:</p>
<ul>
<li><p><strong>drawdown difference &lt;= 0.05</strong></p>
</li>
<li><p><strong>calendar distance &lt;= 180 days</strong></p>
</li>
</ul>
<p>The next block builds the possible pairings and uses <code>linear_sum_assignment()</code> to select a one-to-one set of matches.</p>
<pre><code class="language-python">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) &amp; (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 &lt;= max_drawdown_gap) &amp; (calendar_gap &lt;= 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] &lt; 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)
</code></pre>
<p>The central idea is easier than the code first makes it look.</p>
<p><code>drawdown_cost</code> measures how far apart the two drawdowns are. A purchase at <code>-0.32</code> and a control at <code>-0.34</code> have a difference of <code>0.02</code>, or two percentage points.</p>
<p>The calendar distance is added as a very small tie-breaker. Drawdown similarity remains the main priority, but when two controls are almost equally close, the nearer date is preferred.</p>
<p><code>linear_sum_assignment()</code> prevents the same control from being handed to several purchase episodes. It looks for a set of one-to-one matches that minimizes the combined cost across the group.</p>
<h3 id="heading-build-the-final-matched-dataset">Build The Final Matched Dataset</h3>
<p>The matching result currently contains only the purchase IDs, control IDs, and distance measures.</p>
<p>The final step is to merge the original purchase and control details back into those pairs so we can calculate returns for both sides.</p>
<pre><code class="language-python">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
</code></pre>
<p>Each row now contains one CEO-purchase episode and its matched no-purchase drawdown date:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/ea1737c8-4c08-40ab-bb6f-7e5cc8635557.png" alt="final control group" style="display:block;margin:0 auto" width="2876" height="333" loading="lazy">

<p>We now have the two groups we actually wanted from the beginning: CEO purchases after large drawdowns and similar drawdowns in the same stocks without nearby CEO buying.</p>
<h2 id="heading-compare-ceo-purchases-against-similar-no-purchase-drawdowns">Compare CEO Purchases Against Similar No-Purchase Drawdowns</h2>
<p>This is where the workflow finally earns its keep.</p>
<p>Each row in <code>matched_sample</code> contains two dates from the same stock:</p>
<ul>
<li><p>the first filing date of a CEO-buying episode</p>
</li>
<li><p>a similar drawdown date with no nearby CEO purchase</p>
</li>
</ul>
<p>From this point onward, both sides must be treated exactly the same. The CEO side enters on the first trading day after the filing date. The control side enters on the first trading day after the matched drawdown date. Both use the same adjusted prices and the same return horizons.</p>
<p>We'll first prepare the two signal-date columns and rebuild the ticker-level price map used earlier.</p>
<pre><code class="language-python">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')}
</code></pre>
<p>The price map gives every ticker its own ordered history. That lets us use one return function for both the purchase and control dates instead of writing separate logic for each group.</p>
<h3 id="heading-calculate-forward-returns-from-any-signal-date">Calculate Forward Returns From Any Signal Date</h3>
<p>The next function takes only two inputs: a ticker and a signal date.</p>
<p>It finds the first trading session after that date, uses the adjusted close as the entry price, and calculates returns after 21, 63, 126, and 252 trading days.</p>
<pre><code class="language-python">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 &gt;= 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 &lt; 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)
</code></pre>
<p>The important detail is <code>side='right'</code>.</p>
<p>It prevents either group from entering on its signal date. The CEO-purchase return starts after the filing, and the control return starts after the matched drawdown date.</p>
<p>The function begins with missing values for every output. If a ticker is unavailable or there's not enough future price history for a horizon, that return simply stays as <code>NaN</code>.</p>
<h3 id="heading-apply-the-same-return-logic-to-both-groups">Apply The Same Return Logic To Both Groups</h3>
<p>Now we run the function twice for every matched pair.</p>
<p>The first pass uses the CEO-purchase ticker and filing date. The second uses the control ticker and control date. The returned columns are prefixed so the two sets remain easy to distinguish.</p>
<pre><code class="language-python">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)
</code></pre>
<p>The resulting dataframe now places both outcomes side by side:</p>
<ul>
<li><p>CEO-purchase entry date and price</p>
</li>
<li><p>control entry date and price</p>
</li>
<li><p>CEO-purchase returns</p>
</li>
<li><p>control returns</p>
</li>
</ul>
<p>Not every pair survives at every horizon. A pair is usable only when both sides have enough future price history. That's why the number of observations falls as we move toward twelve months.</p>
<h3 id="heading-build-the-final-comparison">Build The Final Comparison</h3>
<p>The last step is to compare the two return series at each horizon.</p>
<p>We 'll calculate:</p>
<ul>
<li><p>mean return for each group</p>
</li>
<li><p>median return for each group</p>
</li>
<li><p>mean return difference within the matched pairs</p>
</li>
<li><p>median return difference within the matched pairs</p>
</li>
<li><p>positive-return rate</p>
</li>
<li><p>percentage of pairs where the CEO-purchase side beat the control</p>
</li>
</ul>
<pre><code class="language-python">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] &gt; 0).mean(),
        'control_positive_rate': (valid[control_col] &gt; 0).mean(),
        'purchase_win_rate': (valid[purchase_col] &gt; valid[control_col]).mean()
    })

comparison_df = pd.DataFrame(comparison)
comparison_df
</code></pre>
<p>The paired statistics matter here.</p>
<p><code>median_difference</code> is the median of:</p>
<p><em><strong>CEO-purchase return - matched control return</strong></em></p>
<p>for every pair. It's not simply the CEO median minus the control median.</p>
<p>The win rate asks an even more direct question: in what percentage of matched pairs did the CEO-purchase episode actually perform better?</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/37fec1de-fe7f-477b-86a8-5036eaf30a33.png" alt="final comparison" style="display:block;margin:0 auto" width="1408" height="153" loading="lazy">

<p>The one-month result was weak. CEO-purchase episodes trailed the controls on the mean, median pair gap, positive-return rate, and win rate.</p>
<p>Three months was the one window where the result stayed consistent across the table. The CEO-purchase group returned 6.2 percentage points more on average, had a positive median paired advantage of 3.9 points, and won 59.6% of the matches.</p>
<p>The six- and twelve-month averages looked stronger than the typical pair. At twelve months, the CEO group returned nearly 10 percentage points more on average, yet it beat the control in only 37.9% of the comparisons.</p>
<p>That combination usually means a smaller number of large winners are pulling the average upward.</p>
<p>So the final answer is not that CEO buying always worked, or that it never mattered. The apparent edge depended heavily on the horizon, and three months was the only period where the different measures pointed in the same direction.</p>
<h2 id="heading-what-the-case-study-found">What The Case Study Found</h2>
<p>The raw numbers made CEO buying look broadly bullish. After twelve months, the average return was 35.4%, and nearly two-thirds of the available observations were positive.</p>
<p>But once we added matched no-purchase drawdowns, the story became much narrower.</p>
<ul>
<li><p><strong>One month showed no edge.</strong> CEO-purchase episodes underperformed their controls on the mean, median pair gap, positive-return rate, and win rate.</p>
</li>
<li><p><strong>Three months was the strongest window.</strong> The CEO group returned 6.2 percentage points more on average and beat its matched control in 59.6% of the pairs. This was the only horizon where the major measures pointed in the same direction.</p>
</li>
<li><p><strong>Six and twelve months were harder to trust.</strong> The averages were higher for the CEO-purchase group, but the median pair gaps were negative and most individual episodes lost to their controls.</p>
</li>
<li><p><strong>The drawdown itself explained a lot.</strong> Beaten-down stocks often rebounded even without CEO buying, so the raw post-purchase returns overstated the signal.</p>
</li>
</ul>
<p>The most defensible conclusion is not that CEO buying predicts a long-term recovery. In this sample, it looked more like a possible three-month reversal signal, and even that result should be treated as exploratory rather than a trading rule.</p>
<h2 id="heading-what-this-test-can-and-cant-say">What This Test Can And Can't Say</h2>
<p>This was a 500-stock, screener-based sample, not the full market. The universe may carry survivorship bias, some code-<code>P</code> purchases may not have been fully discretionary, and matching similar drawdowns doesn't prove that CEO buying caused the returns. This is an exploratory case study, not a trading strategy.</p>
<p>The most useful part of the workflow was separating the insider signal from what beaten-down stocks already do on their own. Before adding controls, CEO purchases looked broadly bullish across several horizons. After adding them, the result became much narrower: a possible three-month edge, but no clean long-term guarantee.</p>
<p>That may feel less exciting than proving that CEO buying predicts a recovery. But it's a better answer. The workflow forced us to test the story we wanted to believe against a baseline, and the baseline changed the conclusion.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Analyze Analyst Estimate Ranges with Python ]]>
                </title>
                <description>
                    <![CDATA[ Most financial models use analyst consensus as a single forward-looking input: revenue estimate, EPS estimate, EBITDA estimate, or some version of a forward margin assumption. That works, but it flatt ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-analyze-analyst-estimate-ranges-with-python/</link>
                <guid isPermaLink="false">6a34139bd09354fbef127810</guid>
                
                    <category>
                        <![CDATA[ Dataanalysis ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #Stock market ]]>
                    </category>
                
                    <category>
                        <![CDATA[ FinancialAnalysis ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Thu, 18 Jun 2026 15:49:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bb6bebad-1360-43c7-b367-ab984bd8f8b9.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most financial models use analyst consensus as a single forward-looking input: revenue estimate, EPS estimate, EBITDA estimate, or some version of a forward margin assumption.</p>
<p>That works, but it flattens the data.</p>
<p>The average estimate is only the center of the range. Behind it, there is usually a low estimate, a high estimate, and the number of analysts contributing to the view. Two companies can have the same average estimate but very different levels of agreement behind it.</p>
<p>So I wanted to test a simple idea: what happens if we stop treating consensus as one number and start looking at its shape?</p>
<p>Not to predict stock returns or build a trading signal. Just to see whether the range around estimates tells us where analysts actually disagree.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-data-i-needed-to-test-this">The Data I Needed To Test&nbsp;This</a></p>
</li>
<li><p><a href="#heading-pulling-analyst-estimates-across-a-mixed-universe">Pulling Analyst Estimates Across A Mixed&nbsp;Universe</a></p>
</li>
<li><p><a href="#heading-turning-estimate-ranges-into-spread-metrics">Turning Estimate Ranges Into Spread Metrics</a></p>
</li>
<li><p><a href="#heading-first-view-analyst-coverage-does-not-guarantee-agreement">First View: Analyst Coverage Does Not Guarantee Agreement</a></p>
</li>
<li><p><a href="#heading-a-few-names-made-the-pattern-obvious">A Few Names Made The Pattern Obvious</a></p>
</li>
<li><p><a href="#heading-what-this-changes-in-a-forecasting-workflow">What This Changes In A Forecasting Workflow</a></p>
</li>
<li><p><a href="#heading-what-i-would-not-overclaim">What I Would Not Overclaim</a></p>
</li>
<li><p><a href="#heading-final-takeaway-consensus-has-structure">Final Takeaway: Consensus Has Structure</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be comfortable with basic Python, pandas DataFrames, dictionaries, loops, and simple plotting with matplotlib.</p>
<p>You’ll also need:</p>
<ul>
<li><p>Python 3.9 or later</p>
</li>
<li><p>An FMP API key</p>
</li>
<li><p>The following Python libraries: <code>requests</code>, <code>pandas</code>, <code>numpy</code>, and <code>matplotlib</code></p>
</li>
<li><p>Basic familiarity with analyst estimates, revenue, EPS, P/E-style forecasting inputs, and analyst coverage</p>
</li>
</ul>
<p>You don't need advanced financial modeling knowledge. The goal is to show how low, average, high estimates, and analyst counts can reveal the shape of consensus instead of treating analyst estimates as one flat number.</p>
<h2 id="heading-the-data-i-needed-to-test-this">The Data I Needed to Test&nbsp;This</h2>
<p>To test this properly, the average estimate wasn't enough. I needed the full estimate range.</p>
<p>For each company, I wanted:</p>
<ul>
<li><p>revenue low, average, and high</p>
</li>
<li><p>EPS low, average, and high</p>
</li>
<li><p>number of analysts behind the revenue estimate</p>
</li>
<li><p>number of analysts behind the EPS estimate</p>
</li>
</ul>
<p>That gives two useful views. The average shows the center of expectations. The low and high estimates show how wide the expectation range is. The analyst count gives a rough sense of how deep the consensus is.</p>
<p>I also wanted a mixed universe. If the sample only includes mega-cap tech names, the result can easily become too clean because most of those companies are heavily covered. So I used a mix of mega-cap tech, semiconductors, energy, financials, healthcare, consumer names, and higher-uncertainty growth companies.</p>
<p>For the data source, I used <a href="https://site.financialmodelingprep.com/datasets/analyst-estimates-targets">FMP’s analyst estimates data</a> because it provides the low, high, average, and analyst count fields needed for this experiment.</p>
<h2 id="heading-pulling-analyst-estimates-across-a-mixed-universe">Pulling Analyst Estimates Across A Mixed&nbsp;Universe</h2>
<p>I started by importing the basic packages and defining the stock universe.</p>
<pre><code class="language-plaintext">import requests
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime
from time import sleep

api_key = 'YOUR FMP API KEY'
base_url = 'https://financialmodelingprep.com/stable'

tickers = [
    'AAPL', 'MSFT', 'NVDA', 'AMZN', 'META', 'GOOGL',
    'TSLA', 'PLTR', 'COIN', 'RBLX', 'SNOW', 'UBER',
    'AMD', 'INTC', 'MU', 'AVGO', 'QCOM',
    'CAT', 'DE', 'BA', 'GE', 'XOM', 'CVX',
    'WMT', 'COST', 'NKE', 'SBUX', 'MCD', 'TGT',
    'JPM', 'BAC', 'GS', 'MS', 'V', 'MA',
    'UNH', 'PFE', 'LLY', 'MRK', 'ABBV',
    'ROKU', 'SHOP', 'SQ', 'PYPL', 'ZM'
]
</code></pre>
<p>The next step was to pull annual analyst estimates for every ticker. I used the nearest usable future estimate period for each company, because estimate endpoints can return multiple periods and some far-out periods may not be fully populated.</p>
<pre><code class="language-plaintext">all_rows = []

today = pd.Timestamp.today().normalize()

for ticker in tickers:
    url = f'{base_url}/analyst-estimates'

    params = {
        'symbol': ticker,
        'period': 'annual',
        'limit': 10,
        'apikey': api_key
    }

    response = requests.get(url, params=params)
    data = response.json()

    df = pd.DataFrame(data)

    if len(df) == 0:
        print(f'{ticker}: no data')
        continue

    df['date'] = pd.to_datetime(df['date'])
    df = df.sort_values('date')

    df = df[
        (df['date'] &gt; today) &amp;
        (df['revenueAvg'].notna()) &amp;
        (df['revenueLow'].notna()) &amp;
        (df['revenueHigh'].notna()) &amp;
        (df['epsAvg'].notna()) &amp;
        (df['epsLow'].notna()) &amp;
        (df['epsHigh'].notna())
    ].copy()

    if len(df) == 0:
        print(f'{ticker}: no usable future estimates')
        continue

    row = df.iloc[0].copy()
    all_rows.append(row)
    print(f'{ticker} done')
    
    sleep(0.2)

estimates = pd.DataFrame(all_rows)
estimates.head()
</code></pre>
<p>The output gave one usable forward estimate row per company.</p>
<img src="https://cdn-images-1.medium.com/max/1500/1*HKbMHIjAclvzjvTtYawPYw.png" alt="Analyst estimates dataframe" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This table is already more useful than a normal average estimate pull. It gives the center of the estimate, the range around it, and the analyst count behind it. That's enough to start measuring the shape of consensus instead of only storing the average.</p>
<h2 id="heading-turning-estimate-ranges-into-spread-metrics">Turning Estimate Ranges Into Spread Metrics</h2>
<p>Once the estimate data was in place, I needed a way to compare estimate ranges across companies.</p>
<p>Raw ranges aren't enough. A \(10 billion revenue range means something very different for a company expected to generate \)50 billion in revenue versus one expected to generate $500 billion. So I normalized the range by the average estimate.</p>
<pre><code class="language-python">estimates['revenue_spread'] = ((estimates['revenueHigh'] - estimates['revenueLow']) / estimates['revenueAvg'])
estimates['eps_spread'] = ((estimates['epsHigh'] - estimates['epsLow']) / estimates['epsAvg'].abs())
shape_df = estimates[['symbol','date','revenueLow','revenueAvg','revenueHigh','revenue_spread','numAnalystsRevenue',
                      'epsLow','epsAvg','epsHigh','eps_spread','numAnalystsEps']].copy()

shape_df.head()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/d585621a-2a77-49d0-91de-e0ec5ce3530c.png" alt="Spread Metrics (Image by Author)" style="display:block;margin:0 auto" width="1777" height="436" loading="lazy">

<p>The logic is simple. <code>revenue_spread</code> tells us how wide the revenue estimate range is relative to the average revenue estimate. <code>eps_spread</code> does the same for EPS.</p>
<p>But EPS needs one extra check. If average EPS is close to zero, even a normal estimate range can create a huge spread. That doesn't always mean analysts are wildly uncertain. Sometimes it just means the denominator is too small.</p>
<p>So I kept the original EPS spread, but created a cleaner version for plotting.</p>
<pre><code class="language-python">shape_df['eps_spread_clean'] = shape_df['eps_spread']

shape_df.loc[shape_df['epsAvg'].abs() &lt; 1, 'eps_spread_clean'] = np.nan
shape_df.loc[shape_df['eps_spread_clean'] &gt; 3, 'eps_spread_clean'] = np.nan
</code></pre>
<p>After that, I checked the widest and tightest ranges.</p>
<pre><code class="language-python">shape_df.sort_values('revenue_spread', ascending=False)[
    [
        'symbol',
        'revenueLow',
        'revenueAvg',
        'revenueHigh',
        'revenue_spread',
        'numAnalystsRevenue'
    ]
].head(10)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/29eecaa5-e1f1-48e5-adf9-badbda0a4653.png" alt="Revenue spread (Image by Author)" style="display:block;margin:0 auto" width="1372" height="662" loading="lazy">

<p>This was the first sign that the idea might be useful. Some names had wide revenue estimate ranges despite meaningful analyst coverage. TSLA had 35 analysts behind revenue estimates, NVDA had 39, and INTC had 31, but their revenue ranges were still relatively wide.</p>
<p>Then I checked the cleaned EPS spread.</p>
<pre><code class="language-python">shape_df.sort_values('eps_spread_clean', ascending=False)[
    [
        'symbol',
        'epsLow',
        'epsAvg',
        'epsHigh',
        'eps_spread_clean',
        'numAnalystsEps'
    ]
].head(10)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/4a35155d-f324-4d00-ae6c-726444155e3b.png" alt="EPS spread (Image by Author)" style="display:block;margin:0 auto" width="1242" height="731" loading="lazy">

<p>This made the analysis more interesting. Revenue and EPS weren't behaving the same way. TSLA had wide ranges on both. SQ had a very high EPS spread, even though its revenue spread was much tighter. That started to suggest something useful: consensus disagreement can sit in different parts of the model.</p>
<h2 id="heading-first-view-analyst-coverage-does-not-guarantee-agreement">First View: Analyst Coverage Does Not Guarantee Agreement</h2>
<p>The first thing I wanted to check was whether deeper analyst coverage automatically meant tighter consensus.</p>
<p>So I used two simple dimensions:</p>
<ul>
<li><p>number of analysts covering revenue</p>
</li>
<li><p>revenue estimate spread</p>
</li>
</ul>
<p>Then I split the data using median thresholds. This isn't meant to be a formal model. It's just a quick way to separate different consensus shapes.</p>
<pre><code class="language-python">analyst_threshold = shape_df['numAnalystsRevenue'].median()
spread_threshold = shape_df['revenue_spread'].median()

analyst_threshold, spread_threshold
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/f723cb56-108d-47c0-9825-7276483885b0.png" alt="analyst_threshold, spread_threshold" style="display:block;margin:0 auto" width="672" height="89" loading="lazy">

<p>Then I created coverage and spread buckets:</p>
<pre><code class="language-python">shape_df['coverage_bucket'] = np.where(
    shape_df['numAnalystsRevenue'] &gt;= analyst_threshold,
    'high coverage',
    'low coverage'
)

shape_df['spread_bucket'] = np.where(
    shape_df['revenue_spread'] &lt;= spread_threshold,
    'low spread',
    'high spread'
)
</code></pre>
<p>From there, each company falls into one of four simple categories:</p>
<pre><code class="language-python">conditions = [
    (shape_df['coverage_bucket'] == 'high coverage') &amp; (shape_df['spread_bucket'] == 'low spread'),
    (shape_df['coverage_bucket'] == 'high coverage') &amp; (shape_df['spread_bucket'] == 'high spread'),
    (shape_df['coverage_bucket'] == 'low coverage') &amp; (shape_df['spread_bucket'] == 'low spread'),
    (shape_df['coverage_bucket'] == 'low coverage') &amp; (shape_df['spread_bucket'] == 'high spread')
]

labels = [
    'tight consensus',
    'watched but uncertain',
    'thin but stable',
    'weak consensus'
]

shape_df['revenue_consensus_shape'] = np.select(conditions, labels)
</code></pre>
<p>The split came out more balanced than I expected:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7bb26688-395a-4ba1-a750-72616f397e47.png" alt="Category distribution" style="display:block;margin:0 auto" width="682" height="328" loading="lazy">

<p>That was useful because the labels weren't collapsing into one obvious bucket. The universe actually had different consensus shapes.</p>
<p>Then I plotted coverage against revenue spread.</p>
<pre><code class="language-python">plt.figure(figsize=(12, 7))

for label in shape_df['revenue_consensus_shape'].unique():
    temp = shape_df[shape_df['revenue_consensus_shape'] == label]

    plt.scatter(
        temp['numAnalystsRevenue'],
        temp['revenue_spread'],
        s=80,
        label=label,
        alpha=0.8
    )

plt.axvline(analyst_threshold, linestyle='--', linewidth=1)
plt.axhline(spread_threshold, linestyle='--', linewidth=1)

for i, row in shape_df.iterrows():
    if row['revenue_spread'] &gt; spread_threshold or row['numAnalystsRevenue'] &gt; analyst_threshold:
        plt.text(
            row['numAnalystsRevenue'] + 0.3,
            row['revenue_spread'],
            row['symbol'],
            fontsize=9
        )

plt.title('Analyst Coverage vs Revenue Estimate Spread')
plt.xlabel('Number of Analysts Covering Revenue')
plt.ylabel('Revenue Estimate Spread')

plt.legend()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/cb3a674a-e8a6-4518-bbe7-31cec52c4fba.png" alt="Analyst Coverage vs Revenue Estimate Spread" style="display:block;margin:0 auto" width="1234" height="730" loading="lazy">

<p>The chart made one thing clear: more analyst coverage doesn't always mean tighter agreement.</p>
<p>MSFT, AAPL, MA, WMT, and META sat closer to the tight consensus area. They had higher coverage and relatively narrow revenue ranges.</p>
<p>But TSLA, AVGO, NVDA, INTC, AMD, MU, and GOOGL were also heavily covered, yet their revenue estimate spreads were wider. These are the “watched but uncertain” names. The market isn't ignoring them. Analysts are looking at them closely, but the forecast range is still wide.</p>
<p>The weaker consensus area was also useful. CVX, XOM, and COIN had wide revenue ranges with lower coverage compared to the mega-cap names. That's a different kind of uncertainty. It's not just disagreement. It's disagreement with less analyst depth behind it.</p>
<p>This first view was helpful, but it still only looked at revenue. The next question was more interesting: does the uncertainty sit in revenue, EPS, or both?</p>
<pre><code class="language-python">plot_df = shape_df.dropna(subset=['revenue_spread', 'eps_spread_clean']).copy()

plt.figure(figsize=(12, 7))

plt.scatter(
    plot_df['revenue_spread'],
    plot_df['eps_spread_clean'],
    s=plot_df['numAnalystsRevenue'] * 3,
    alpha=0.75
)

for i, row in plot_df.iterrows():
    plt.text(
        row['revenue_spread'] + 0.002,
        row['eps_spread_clean'],
        row['symbol'],
        fontsize=9
    )

plt.title('Revenue Estimate Spread vs EPS Estimate Spread')
plt.xlabel('Revenue Estimate Spread')
plt.ylabel('EPS Estimate Spread')

plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/b982b5a1-0293-4f22-a3a2-180b131dd57e.png" alt="Revenue Estimate Spread vs EPS Estimate Spread" style="display:block;margin:0 auto" width="1336" height="807" loading="lazy">

<p>This was the more useful view.</p>
<p>The chart showed that consensus uncertainty doesn't sit in the same place for every company. Some names had both revenue and EPS clustered tightly. Some had wide ranges across both. And a few had a much more specific kind of disagreement.</p>
<p>SQ was the clearest example. Its revenue spread was low, but its EPS spread was high. That suggests analysts were much closer on the revenue side than on the earnings side.</p>
<p>TSLA showed the opposite kind of extreme. Both revenue and EPS spreads were wide, so the average estimate was hiding disagreement across more than one part of the model.</p>
<p>At this point, I wanted to turn this into a simple classification. Again, this isn't a formal risk model. I used median thresholds only to separate the shapes clearly.</p>
<pre><code class="language-python">revenue_spread_threshold = plot_df['revenue_spread'].median()
eps_spread_threshold = plot_df['eps_spread_clean'].median()

plot_df['revenue_uncertainty'] = np.where(
    plot_df['revenue_spread'] &lt;= revenue_spread_threshold,
    'low revenue uncertainty',
    'high revenue uncertainty'
)

plot_df['eps_uncertainty'] = np.where(
    plot_df['eps_spread_clean'] &lt;= eps_spread_threshold,
    'low EPS uncertainty',
    'high EPS uncertainty'
)
</code></pre>
<p>Then I combined the two buckets into four forecast shapes.</p>
<pre><code class="language-python">conditions = [
    (plot_df['revenue_uncertainty'] == 'low revenue uncertainty') &amp; (plot_df['eps_uncertainty'] == 'low EPS uncertainty'),
    (plot_df['revenue_uncertainty'] == 'low revenue uncertainty') &amp; (plot_df['eps_uncertainty'] == 'high EPS uncertainty'),
    (plot_df['revenue_uncertainty'] == 'high revenue uncertainty') &amp; (plot_df['eps_uncertainty'] == 'low EPS uncertainty'),
    (plot_df['revenue_uncertainty'] == 'high revenue uncertainty') &amp; (plot_df['eps_uncertainty'] == 'high EPS uncertainty')
]

labels = [
    'stable forecast shape',
    'profitability uncertainty',
    'top-line uncertainty',
    'broad forecast uncertainty'
]

plot_df['forecast_shape'] = np.select(conditions, labels)
</code></pre>
<p>The distribution looked like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/920d8d25-b707-46e6-9bbf-379397d8c39e.png" alt="Forecast bucket distribution" style="display:block;margin:0 auto" width="712" height="276" loading="lazy">

<p>That split was more useful than the first one because it showed where the disagreement was located.</p>
<p>A stable forecast shape means both revenue and EPS ranges are relatively tight. Profitability uncertainty means revenue estimates are tighter, but EPS estimates are wider. Top-line uncertainty means the revenue range is wider while EPS is relatively tighter. Broad forecast uncertainty means both sides are wide.</p>
<p>Then I plotted the same chart again with these labels:</p>
<pre><code class="language-python">plt.figure(figsize=(12, 7))

for label in plot_df['forecast_shape'].unique():
    temp = plot_df[plot_df['forecast_shape'] == label]

    plt.scatter(
        temp['revenue_spread'],
        temp['eps_spread_clean'],
        s=temp['numAnalystsRevenue'] * 3,
        label=label,
        alpha=0.75
    )

plt.axvline(revenue_spread_threshold, linestyle='--', linewidth=1)
plt.axhline(eps_spread_threshold, linestyle='--', linewidth=1)

for i, row in plot_df.iterrows():
    if (
        row['revenue_spread'] &gt; revenue_spread_threshold or
        row['eps_spread_clean'] &gt; eps_spread_threshold
    ):
        plt.text(
            row['revenue_spread'] + 0.002,
            row['eps_spread_clean'],
            row['symbol'],
            fontsize=9
        )

plt.title('Revenue Uncertainty vs EPS Uncertainty')
plt.xlabel('Revenue Estimate Spread')
plt.ylabel('EPS Estimate Spread')

plt.legend()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/da19e04a-939b-4115-941c-cc385c139f7d.png" alt="Revenue Uncertainty vs EPS Uncertainty" style="display:block;margin:0 auto" width="1228" height="746" loading="lazy">

<p>This became the main chart for the analysis.</p>
<p>The average estimate hides the center of expectations, but this chart shows the structure around it. For a forecasting workflow, that matters. A model shouldn't treat a tight consensus estimate and a wide consensus estimate as if they carry the same level of agreement.</p>
<h2 id="heading-a-few-names-made-the-pattern-obvious">A Few Names Made The Pattern Obvious</h2>
<p>Once the companies were grouped by forecast shape, the pattern became easier to read.</p>
<pre><code class="language-python">plot_df[
    [
        'symbol',
        'revenue_spread',
        'eps_spread_clean',
        'numAnalystsRevenue',
        'numAnalystsEps',
        'forecast_shape'
    ]
].sort_values(['forecast_shape', 'eps_spread_clean'], ascending=[True, False])
</code></pre>
<p>The full table was useful, but for the article, the more important part is the examples from each bucket.</p>
<pre><code class="language-python">broad_uncertainty = final_view[
    final_view['forecast_shape'] == 'broad forecast uncertainty'
].sort_values('eps_spread_pct', ascending=False)

broad_uncertainty.head(10)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/12652727-f509-4bcd-b28c-a935163d1f18.png" alt="Broad forecast uncertainty" style="display:block;margin:0 auto" width="1500" height="466" loading="lazy">

<p>TSLA was the obvious outlier. The revenue estimate spread was around 21.8%, and the EPS spread was over 104%. That's not just a wide range around one line item. It's disagreement across both the top line and bottom line.</p>
<p>CVX and XOM were also interesting, but for a different reason. Their revenue spreads were very wide, and analyst coverage was lower than many tech names in the sample. That makes their consensus shape different from a name like TSLA, where coverage is deeper but disagreement still remains.</p>
<p>Then I looked at the profitability uncertainty bucket.</p>
<pre><code class="language-python">profitability_uncertainty = final_view[
    final_view['forecast_shape'] == 'profitability uncertainty'
].sort_values('eps_spread_pct', ascending=False)

profitability_uncertainty
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/c2ce5dc8-561e-4e8b-8912-c3ed5c7fccdb.png" alt="Profitability uncertainty" style="display:block;margin:0 auto" width="1500" height="264" loading="lazy">

<p>This was the most useful bucket conceptually.</p>
<p>SQ had only about 1.1% revenue spread, but nearly 73.8% EPS spread. That's a very different shape from TSLA. Here, analysts were much closer on revenue, but far apart on earnings.</p>
<p>That matters for a model. If I only store the average revenue estimate and average EPS estimate, I lose that distinction. The model can't see that the revenue estimate is relatively tight while the EPS estimate carries much more disagreement.</p>
<p>SNOW and PLTR showed a similar pattern, though not as extreme. Revenue expectations were relatively close together, but EPS expectations had a wider range. That points to uncertainty around profitability, margins, or earnings conversion rather than pure revenue growth.</p>
<p>The stable bucket gave the contrast.</p>
<pre><code class="language-python">stable_shape = final_view[
    final_view['forecast_shape'] == 'stable forecast shape'
].sort_values(['revenue_spread_pct', 'eps_spread_pct'])

stable_shape.head(10)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/54a744cc-5b48-47c5-bd36-dfd933064216.png" alt="Stable forecast" style="display:block;margin:0 auto" width="1855" height="584" loading="lazy">

<p>MSFT was the cleanest example here. Its revenue spread was around 0.4%, and its EPS spread was around 3.0%. MA, BAC, ABBV, and TGT also stayed in the stable zone, with relatively tight ranges across both revenue and EPS.</p>
<p>That doesn't mean these estimates will be right. It only means analysts are clustered more tightly around the forward numbers.</p>
<p>Finally, the top-line uncertainty bucket was smaller.</p>
<pre><code class="language-python">topline_uncertainty = final_view[
    final_view['forecast_shape'] == 'top-line uncertainty'
].sort_values('revenue_spread_pct', ascending=False)

topline_uncertainty
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/e8a1c7ab-7ad6-4d0c-bf67-b11d3dc62f5a.png" alt="Top-line uncertainty" style="display:block;margin:0 auto" width="1834" height="324" loading="lazy">

<p>This group was smaller, but it completed the picture. These were cases where revenue uncertainty was more visible than EPS uncertainty.</p>
<p>The broader point is simple: consensus doesn't have one shape. Averages hide that. The range around the average shows whether disagreement sits around revenue, EPS, or both.</p>
<h2 id="heading-what-this-changes-in-a-forecasting-workflow">What This Changes In A Forecasting Workflow</h2>
<p>The practical takeaway isn't that every model needs a new complicated uncertainty system. It's simpler than that.</p>
<p>If a model already stores analyst estimates, it should probably store the range around those estimates too.</p>
<p>Instead of keeping only this:</p>
<pre><code class="language-plaintext">symbol | estimated_revenue | estimated_eps
</code></pre>
<p>I would rather keep this:</p>
<pre><code class="language-plaintext">symbol | estimated_revenue | estimated_eps | revenue_spread | eps_spread | analyst_count | forecast_shape
</code></pre>
<p>That gives the model more context about the forecast input it's already using.</p>
<p>To make this usable, I created a final table with the estimate period, revenue spread, EPS spread, analyst coverage, revenue consensus shape, and overall forecast shape.</p>
<pre><code class="language-python">final_df = plot_df[
    [
        'symbol',
        'date',
        'revenueAvg',
        'revenueLow',
        'revenueHigh',
        'revenue_spread',
        'epsAvg',
        'epsLow',
        'epsHigh',
        'eps_spread_clean',
        'numAnalystsRevenue',
        'numAnalystsEps',
        'revenue_consensus_shape',
        'forecast_shape'
    ]
].copy()

final_df = final_df.rename(
    columns={
        'date': 'estimate_period',
        'revenueAvg': 'revenue_avg',
        'revenueLow': 'revenue_low',
        'revenueHigh': 'revenue_high',
        'epsAvg': 'eps_avg',
        'epsLow': 'eps_low',
        'epsHigh': 'eps_high',
        'eps_spread_clean': 'eps_spread',
        'numAnalystsRevenue': 'revenue_analysts',
        'numAnalystsEps': 'eps_analysts'
    }
)

final_df['revenue_spread_pct'] = final_df['revenue_spread'] * 100
final_df['eps_spread_pct'] = final_df['eps_spread'] * 100

final_view = final_df[
    [
        'symbol',
        'estimate_period',
        'revenue_spread_pct',
        'eps_spread_pct',
        'revenue_analysts',
        'eps_analysts',
        'revenue_consensus_shape',
        'forecast_shape'
    ]
].copy()

final_view = final_view.sort_values('eps_spread_pct', ascending=False)

final_view.head(15)
</code></pre>
<p>The output looked like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/f0423b11-74ed-4082-83a1-b424a1d3f946.png" alt="Final view" style="display:block;margin:0 auto" width="1711" height="760" loading="lazy">

<p>This table is mainly useful for spotting where the average estimate hides the most disagreement.</p>
<p>TSLA is the clearest broad uncertainty case. Both revenue and EPS spreads are wide, so storing only the average estimate would flatten too much of the forecast structure.</p>
<p>SQ is different. Its revenue spread is only about 1.1%, but its EPS spread is about 73.8%. That suggests the disagreement is much less about revenue and much more about profitability or earnings conversion.</p>
<p>SNOW and PLTR show a similar pattern, though less extreme. Their revenue spreads are relatively tight, while EPS spreads are much wider. That's a useful distinction for any model using estimates as inputs.</p>
<p>The point isn't to decide which estimate is right. The point is to avoid treating every consensus average as if it carries the same level of agreement. The average gives the center. The spread shows how much disagreement sits around that center.</p>
<h2 id="heading-what-i-would-not-overclaim">What I Would Not Overclaim</h2>
<p>I wouldn't treat these labels as a final model.</p>
<p>The stock universe here is handpicked, not the full market. The cutoffs are also simple median thresholds, not a statistical confidence model. They're useful for separating the data into readable groups, but they shouldn't be treated as exact boundaries.</p>
<p>EPS spread also needs care. If average EPS is close to zero, the spread can become distorted, which is why I cleaned extreme EPS cases before plotting.</p>
<p>Most importantly, this doesn't tell us which estimate is right. A wide range doesn't automatically mean the company is bad, and a tight range does not mean the forecast will be accurate.</p>
<p>The useful part is more basic: the model stops pretending that every average estimate carries the same level of agreement.</p>
<h2 id="heading-final-takeaway-consensus-has-structure">Final Takeaway: Consensus Has Structure</h2>
<p>The average estimate is still useful. I wouldn't remove it from a forecasting model.</p>
<p>But after looking at the low, high, average, and analyst count together, using only the average feels incomplete.</p>
<p>Consensus has structure. Some estimates are tight. Some are wide. Sometimes disagreement sits around revenue. Sometimes it sits around EPS. Sometimes it shows up across both.</p>
<p>A better forecasting workflow should preserve that structure instead of flattening it away. It doesn't need to become complicated. Even a few extra fields, like revenue spread, EPS spread, analyst count, and forecast shape, can make the estimate layer more honest.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Choose the Best Stock Market API for FinTech Projects and AI Agents  ]]>
                </title>
                <description>
                    <![CDATA[ Choosing a stock API looks simple until the project becomes real. At first, you only need a few prices. You send a request, get JSON back, load it into pandas, and move on. But the moment that API sta ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-choose-the-best-stock-market-api-for-fintech-projects-and-ai-agents/</link>
                <guid isPermaLink="false">6a24b9c567572e709df513c8</guid>
                
                    <category>
                        <![CDATA[ fintech ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #Stock market ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Sun, 07 Jun 2026 00:22:29 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e1f20d3c-eaf8-49e9-be53-4cc99eb971ec.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Choosing a stock API looks simple until the project becomes real.</p>
<p>At first, you only need a few prices. You send a request, get JSON back, load it into pandas, and move on. But the moment that API starts powering a backtester, dashboard, screener, valuation tool, or AI assistant, the decision becomes much more serious.</p>
<p>A backtester needs adjusted historical prices, splits, dividends, and stable time series. A dashboard needs fresh quotes, clean fields, and reliable responses. A stock screener needs fundamentals, ratios, and company metadata. An AI agent needs structured data that it can retrieve and use without guessing.</p>
<p>That's why I wouldn't start by comparing endpoint counts or pricing pages. Those matter, but they're not the first question.</p>
<p>The first question is: <strong>what are you building?</strong></p>
<p>In this article, we’ll walk through how to choose a stock market API based on the workflow it needs to support. Then we’ll build a practical stock research workflow in Python using Alpha Vantage to see how prices, fundamentals, technical indicators, and AI-ready access can fit together in one project.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-stock-api-choice-depends-on-the-workflow">Why Stock API Choice Depends On The Workflow</a></p>
<ul>
<li><p><a href="#heading-1-if-you-are-building-a-backtester">1. If You Are Building A Backtester</a></p>
</li>
<li><p><a href="#heading-2-if-you-are-building-a-dashboard">2. If You Are Building A Dashboard</a></p>
</li>
<li><p><a href="#heading-3-if-you-are-building-a-stock-screener">3. If You Are Building A Stock Screener</a></p>
</li>
<li><p><a href="#heading-4-if-you-are-building-a-valuation-or-research-tool">4. If You Are Building A Valuation Or Research Tool</a></p>
</li>
<li><p><a href="#heading-5-if-you-are-building-an-ai-assistant-or-agent">5. If You Are Building An AI Assistant Or Agent</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-a-modern-stock-market-data-workflow-actually-requires">What A Modern Stock Market Data Workflow Actually Requires</a></p>
</li>
<li><p><a href="#heading-building-a-practical-stock-research-workflow-with-alpha-vantage">Building A Practical Stock Research Workflow With Alpha Vantage</a></p>
<ul>
<li><p><a href="#heading-step-1-fetch-adjusted-historical-prices">Step 1: Fetch Adjusted Historical Prices</a></p>
</li>
<li><p><a href="#heading-step-2-add-company-or-fundamental-data">Step 2: Add Company Or Fundamental Data</a></p>
</li>
<li><p><a href="#heading-step-3-add-technical-indicators">Step 3: Add Technical Indicators</a></p>
</li>
<li><p><a href="#heading-step-4-combine-everything-into-a-research-ready-table">Step 4: Combine Everything Into A Research-Ready Table</a></p>
</li>
<li><p><a href="#heading-step-5-connect-the-workflow-to-ai-agents-with-mcp">Step 5: Connect The Workflow To AI Agents With MCP</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-where-each-provider-fits-in-the-stock-api-workflow">Where Each Provider Fits In The Stock API Workflow</a></p>
</li>
<li><p><a href="#provider-breakdown-through-a-workflow-lens">Provider Breakdown Through A Workflow Lens</a></p>
<ul>
<li><p><a href="#heading-1-when-the-project-needs-several-data-layers-alpha-vantage">1. When The Project Needs Several Data Layers: Alpha Vantage</a></p>
</li>
<li><p><a href="#heading-2-when-the-workflow-is-institutional-bloomberg-api">2. When The Workflow Is Institutional: Bloomberg API</a></p>
</li>
<li><p><a href="#heading-3-when-the-product-needs-investor-relations-widgets-quotemedia">3. When The Product Needs Investor Relations Widgets: QuoteMedia</a></p>
</li>
<li><p><a href="#heading-4-when-the-workflow-is-global-historical-research-eodhd">4. When The Workflow Is Global Historical Research: EODHD</a></p>
</li>
<li><p><a href="#5-when-the-workflow-needs-us-fundamentals-intrinio">5. When The Workflow Needs US Fundamentals: Intrinio</a></p>
</li>
<li><p><a href="#heading-6-when-the-workflow-needs-enterprise-data-delivery-xignite">6. When The Workflow Needs Enterprise Data Delivery: Xignite</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-final-checklist-before-choosing-a-stock-api">Final Checklist Before Choosing A Stock API</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-stock-api-choice-depends-on-the-workflow"><strong>Why Stock API Choice Depends On The Workflow</strong></h2>
<p>A stock API should be judged by the workflow it supports, not by how long its feature list looks. The same provider can be a good fit for one project and a weak fit for another.</p>
<p>A clean historical dataset matters more for a backtester than a live quote endpoint. A dashboard has different problems. It needs fresh responses, predictable fields, and rate limits that don't collapse once users start refreshing the page.</p>
<p>Here is how I would think about it.</p>
<h3 id="heading-1-if-you-are-building-a-backtester">1. If You Are Building A Backtester</h3>
<h4 id="heading-start-with-historical-data-quality">Start with historical data quality.</h4>
<p>A backtest needs adjusted prices, splits, dividends, long history, and stable time series. If those pieces are wrong, the backtest can still run, but the results may be misleading.</p>
<p>For this workflow, real-time data is usually secondary. Clean historical data matters more than fast quotes.</p>
<h3 id="heading-2-if-you-are-building-a-dashboard">2. If You Are Building A Dashboard</h3>
<h4 id="heading-start-with-freshness-and-reliability">Start with freshness and reliability.</h4>
<p>A dashboard needs quote data that updates consistently, fields that don't change unexpectedly, and rate limits that can handle repeated requests. A failed request in a notebook is annoying. A failed request in a user-facing dashboard is a product problem.</p>
<p>You also need to check whether the data can be displayed to users. Licensing becomes part of the workflow once the dashboard is public.</p>
<h3 id="heading-3-if-you-are-building-a-stock-screener">3. If You Are Building A Stock Screener</h3>
<h4 id="heading-start-with-fundamentals-and-structured-fields">Start with fundamentals and structured fields.</h4>
<p>A screener needs more than prices. It may need ratios, company profiles, sector data, market cap, earnings, and symbol coverage across many companies.</p>
<p>The hard part is comparison. If fields are inconsistent across tickers, the screener becomes a cleanup project before it becomes a useful tool.</p>
<h3 id="heading-4-if-you-are-building-a-valuation-or-research-tool">4. If You Are Building A Valuation Or Research Tool</h3>
<h4 id="heading-start-with-financial-statements">Start with financial statements.</h4>
<p>A valuation workflow usually needs income statements, balance sheets, cash flow statements, earnings history, and historical fundamentals. Price data gives market context, but the business data does the heavier work.</p>
<p>This is where depth matters. The latest numbers are useful, but trends across multiple periods are often more important.</p>
<h3 id="heading-5-if-you-are-building-an-ai-assistant-or-agent">5. If You Are Building An AI Assistant Or Agent</h3>
<h4 id="heading-start-with-structure">Start with structure.</h4>
<p>An AI agent shouldn't guess financial data from memory. It needs predictable API responses, clear schemas, and tool access it can use reliably.</p>
<p>This is where MCP-style workflows matter. If an agent can call a tool, retrieve a quote, pull fundamentals, or fetch a time series cleanly, the API becomes part of the agent’s reasoning loop.</p>
<p>The practical point is simple: choose the API around the system you're building. Once the workflow is clear, the rest of the decision becomes much easier.</p>
<h2 id="heading-what-a-modern-stock-market-data-workflow-actually-requires"><strong>What A Modern Stock Market Data Workflow Actually Requires</strong></h2>
<p>A modern stock data workflow is rarely just one API call.</p>
<p>You might start with market data, but most useful projects eventually need more layers. A research dashboard may need fundamentals. A screener may need technical indicators. An AI assistant may need structured responses that it can retrieve through a tool.</p>
<p>A simple way to think about the workflow is:</p>
<p><code>Market Data -&gt; Fundamentals -&gt; Indicators -&gt; Structured Responses -&gt; Programmatic Workflow -&gt; AI/Agent Access</code></p>
<p>Each layer solves a different problem.</p>
<ul>
<li><p><strong>Market data</strong> gives you prices, volume, returns, and historical movement.</p>
</li>
<li><p><strong>Fundamentals</strong> add business context through revenue, margins, cash flow, earnings, and company details.</p>
</li>
<li><p><strong>Indicators</strong> help convert raw prices into features that can support screening, research, or signal testing.</p>
</li>
<li><p><strong>Structured responses</strong> make the data easier to parse, join, and reuse.</p>
</li>
<li><p><strong>Programmatic workflows</strong> turn the raw API response into tables, charts, models, dashboards, or research outputs.</p>
</li>
<li><p><strong>AI or agent access</strong> lets an assistant call tools, retrieve current data, and work with structured financial context instead of relying only on static knowledge.</p>
</li>
</ul>
<p>This is why stock API choice matters beyond the first request. The API is not only there to return data but to support the way the project grows after the prototype.</p>
<h2 id="heading-building-a-practical-stock-research-workflow-with-alpha-vantage"><strong>Building A Practical Stock Research Workflow With Alpha Vantage</strong></h2>
<p>Now let’s turn the framework into something practical.</p>
<p>For this section, we’ll use Alpha Vantage as the implementation API because it gives us the main layers we need for this workflow: adjusted historical prices, company data, technical indicators, and MCP-style access for AI agents.</p>
<p>The goal isn't to test every endpoint. The goal is to build a small research workflow that shows what a useful stock API should help us do.</p>
<p>We’ll build this in five steps:</p>
<ol>
<li><p>Fetch adjusted historical prices.</p>
</li>
<li><p>Add company or fundamental data.</p>
</li>
<li><p>Add a technical indicator.</p>
</li>
<li><p>Combine everything into a research-ready table.</p>
</li>
<li><p>Connect the workflow to an AI-agent setup using MCP.</p>
</li>
</ol>
<p>By the end, we should have a simple but practical stock research table that can support a screener, dashboard, research notebook, or AI assistant.</p>
<h3 id="heading-step-1-fetch-adjusted-historical-prices">Step 1: Fetch Adjusted Historical Prices</h3>
<p>Adjusted prices are the first thing I would check for any research or backtesting workflow. Raw prices can break around stock splits or dividends, while adjusted prices keep the series more useful for return calculations.</p>
<p>Let’s fetch daily adjusted price data for Apple.</p>
<pre><code class="language-python">import requests
import pandas as pd

api_key = 'YOUR ALPHA VANTAGE API KEY'

symbol = 'AAPL'

url = f'https://www.alphavantage.co/query?function=TIME_SERIES_DAILY_ADJUSTED&amp;symbol={symbol}&amp;outputsize=compact&amp;apikey={api_key}'

response = requests.get(url)
data = response.json()

prices = pd.DataFrame(data['Time Series (Daily)']).T

prices.index = pd.to_datetime(prices.index)
prices = prices.sort_index()

prices = prices.rename(columns={
    '1. open': 'open',
    '2. high': 'high',
    '3. low': 'low',
    '4. close': 'close',
    '5. adjusted close': 'adjusted_close',
    '6. volume': 'volume',
    '7. dividend amount': 'dividend',
    '8. split coefficient': 'split'
})

price_cols = ['open', 'high', 'low', 'close', 'adjusted_close', 'volume', 'dividend', 'split']
prices[price_cols] = prices[price_cols].astype(float)

prices.tail()
</code></pre>
<p>The output gives us a clean daily price table as you can see in the image below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/903925ac-462b-4684-9b51-98b6f6173f74.png" alt="903925ac-462b-4684-9b51-98b6f6173f74" style="display:block;margin:0 auto" width="1878" height="556" loading="lazy">

<p>For a chart, you may only need <code>close</code>. For research or backtesting, I would usually work with <code>adjusted_close</code> because it handles corporate actions more safely. Next, we can convert the time series into a few basic price features.</p>
<pre><code class="language-python">latest_price = prices['adjusted_close'].iloc[-1] 
return_30d = prices['adjusted_close'].pct_change(30).iloc[-1] 
volatility_30d = prices['adjusted_close'].pct_change().tail(30).std() 

price_features = {'symbol': symbol, 'latest_price': latest_price, 'return_30d': return_30d, 'volatility_30d': volatility_30d}
price_features
</code></pre>
<p>This returns:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL',
 'latest_price': 312.06,
 'return_30d': 0.18583097277442007,
 'volatility_30d': 0.012845143800989936}
</code></pre>
<p>This is already more useful than a raw API response. We now have a small set of price features that can feed a dashboard, screener, research table, or AI-assisted stock analysis workflow.</p>
<h3 id="heading-step-2-add-company-or-fundamental-data">Step 2: Add Company Or Fundamental Data</h3>
<p>Price data tells us how the stock moved, but it doesn't tell us much about the company behind the ticker. For a screener, valuation tool, or research workflow, we need some business context too.</p>
<p>Alpha Vantage’s OVERVIEW endpoint gives company-level fields like sector, industry, market cap, PE ratio, EPS, profit margin, and other summary metrics. Let’s pull those fields and keep only the ones we need for this workflow.</p>
<pre><code class="language-python">overview_url = f'https://www.alphavantage.co/query?function=OVERVIEW&amp;symbol={symbol}&amp;apikey={api_key}'

response = requests.get(overview_url)
overview = response.json()

fundamental_features = {
    'symbol': symbol,
    'name': overview.get('Name'),
    'sector': overview.get('Sector'),
    'industry': overview.get('Industry'),
    'market_cap': overview.get('MarketCapitalization'),
    'pe_ratio': overview.get('PERatio'),
    'eps': overview.get('EPS'),
    'profit_margin': overview.get('ProfitMargin'),
    'beta': overview.get('Beta')
}

fundamental_features
</code></pre>
<p>This returns:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL',
 'name': 'Apple Inc',
 'sector': 'TECHNOLOGY',
 'industry': 'CONSUMER ELECTRONICS',
 'market_cap': 4583336182000.0,
 'pe_ratio': 37.73,
 'eps': 8.27,
 'profit_margin': 0.272,
 'beta': 1.065}
</code></pre>
<p>Now we have two layers: price behavior from the time series data and business context from the company overview. The next step is to add a technical indicator so the table includes a market-derived signal as well.</p>
<h3 id="heading-step-3-add-technical-indicators">Step 3: Add Technical Indicators</h3>
<p>Fundamentals give us business context, but many research workflows also need market-derived signals. A simple example is the relative strength index, or RSI, which is often used to measure recent momentum.</p>
<p>Alpha Vantage has a RSI endpoint, so we can pull the indicator directly instead of calculating it from scratch.</p>
<pre><code class="language-python">rsi_url = f'https://www.alphavantage.co/query?function=RSI&amp;symbol={symbol}&amp;interval=daily&amp;time_period=14&amp;series_type=close&amp;apikey={api_key}'

response = requests.get(rsi_url)
rsi_data = response.json()

rsi = pd.DataFrame(rsi_data['Technical Analysis: RSI']).T

rsi.index = pd.to_datetime(rsi.index)
rsi = rsi.sort_index()
rsi['RSI'] = rsi['RSI'].astype(float)

latest_rsi = rsi['RSI'].iloc[-1]

indicator_features = {
    'symbol': symbol,
    'rsi_14': latest_rsi
}

indicator_features
</code></pre>
<p>This returns:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL', 'rsi_14': 79.0043}
</code></pre>
<p>Now the workflow has three layers:</p>
<ul>
<li><p>price behavior from adjusted historical data</p>
</li>
<li><p>business context from company fundamentals</p>
</li>
<li><p>momentum context from a technical indicator</p>
</li>
</ul>
<p>None of these is enough on its own. Together, they start to look like a usable research workflow instead of a raw API test.</p>
<h3 id="heading-step-4-combine-everything-into-a-research-ready-table">Step 4: Combine Everything Into A Research-Ready Table</h3>
<p>Now we can combine the price, fundamentals, and indicator layers into one table.</p>
<p>This is the part that matters for most real projects. A dashboard, screener, notebook, or AI assistant usually needs a clean object it can reuse, not three separate raw API responses.</p>
<pre><code class="language-python">research_row = {
    **price_features,
    **fundamental_features,
    **indicator_features
}

research_table = pd.DataFrame([research_row])

research_table
</code></pre>
<p>This gives us a single-row research table:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5d659e28-19e3-4455-a1d8-e9bbd02e3ace.png" alt="research table" style="display:block;margin:0 auto" width="1864" height="126" loading="lazy">

<p>This table is simple, but it already supports several use cases.</p>
<p>A screener can filter on <code>pe_ratio</code>, <code>profit_margin</code>, or <code>rsi_14</code>. A dashboard can show price, returns, sector, and market cap. A research notebook can add more tickers and compare them. An AI assistant can receive this as a compact context object instead of parsing multiple API responses on its own.</p>
<p>That's the real benefit of building the workflow this way. The API calls are only the beginning. The useful output is the structured table you create from them.</p>
<h3 id="heading-step-5-connect-the-workflow-to-ai-agents-with-mcp">Step 5: Connect The Workflow To AI Agents With MCP</h3>
<p>The table we created is useful because it has a predictable structure, which is exactly what AI workflows need.</p>
<p>If an agent needs stock context, it shouldn't guess from memory or parse several raw API responses every time. It should call a tool, retrieve the data, and receive something clean enough to use.</p>
<p>A simplified MCP workflow looks like this:</p>
<p><code>User question -&gt; AI agent -&gt; MCP tool call -&gt; Stock API data -&gt; Structured response -&gt; Final answer</code></p>
<p>For example, a user might ask:</p>
<p><em>Is Apple looking expensive compared with its recent momentum?</em></p>
<p>An agent could retrieve price data, fundamentals, and an indicator such as RSI before answering. The important part is not that the model already “knows” the answer. It's that the model can call the right tool and work with current data.</p>
<p>That is where our research table helps:</p>
<pre><code class="language-python">research_table.to_dict(orient='records')[0]
</code></pre>
<p>This returns a compact dictionary:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL',
 'latest_price': 312.06,
 'return_30d': 0.18583097277442007,
 'volatility_30d': 0.012845143800989936,
 'name': 'Apple Inc',
 'sector': 'TECHNOLOGY',
 'industry': 'CONSUMER ELECTRONICS',
 'market_cap': 4583336182000.0,
 'pe_ratio': 37.73,
 'eps': 8.27,
 'profit_margin': 0.272,
 'beta': 1.065,
 'rsi_14': 79.0043}
</code></pre>
<p>This doesn't replace proper analysis, and it shouldn't be treated as investment advice. But it gives an AI assistant a cleaner starting point than raw JSON, stale model knowledge, or a vague prompt with no data attached.</p>
<p>AI readiness isn't just about saying an API supports agents. The API has to return data that can be retrieved, structured, checked, and passed into a workflow without fragile glue code at every step.</p>
<h2 id="heading-where-each-provider-fits-in-the-stock-api-workflow"><strong>Where Each Provider Fits In The Stock API Workflow</strong></h2>
<p>The workflow we built above is one version of a modern stock data project: prices, fundamentals, indicators, programmatic analysis, and AI-agent access working together.</p>
<p>Other projects may need a narrower or more specialized provider. Here's a practical way to compare the fit:</p>
<table style="min-width:653px"><colgroup><col style="min-width:25px"><col style="width:84px"><col style="width:75px"><col style="width:87px"><col style="width:90px"><col style="width:88px"><col style="width:83px"><col style="width:121px"></colgroup><tbody><tr><td><p><strong>Provider</strong></p></td><td><p><strong>Market Data</strong></p></td><td><p><strong>Fundamentals</strong></p></td><td><p><strong>Technical Indicators</strong></p></td><td><p><strong>Developer Workflow</strong></p></td><td><p><strong>AI / Agent Readiness</strong></p></td><td><p><strong>Workflow Completeness</strong></p></td><td><p><strong>Best Fit</strong></p></td></tr><tr><td><p>Alpha Vantage</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>High</p></td><td><p>Broad technical projects, research tools, screeners, dashboards, and AI-agent workflows</p></td></tr><tr><td><p>Bloomberg API</p></td><td><p>Very strong</p></td><td><p>Strong</p></td><td><p>Moderate</p></td><td><p>Enterprise-focused</p></td><td><p>Enterprise-dependent</p></td><td><p>High</p></td><td><p>Institutions already using Bloomberg internally</p></td></tr><tr><td><p>QuoteMedia</p></td><td><p>Strong</p></td><td><p>Moderate</p></td><td><p>Limited / Moderate</p></td><td><p>Moderate</p></td><td><p>Limited</p></td><td><p>Medium</p></td><td><p>Investor relations websites and embedded market data widgets</p></td></tr><tr><td><p>EODHD</p></td><td><p>Strong</p></td><td><p>Good</p></td><td><p>Good</p></td><td><p>Good</p></td><td><p>Strong</p></td><td><p>High</p></td><td><p>Global EOD history, backtesting, and historical research</p></td></tr><tr><td><p>Intrinio</p></td><td><p>Good</p></td><td><p>Strong</p></td><td><p>Limited / Moderate</p></td><td><p>Good</p></td><td><p>Limited / Moderate</p></td><td><p>Medium / High</p></td><td><p>US fundamentals, valuation tools, and professional datasets</p></td></tr><tr><td><p>Xignite</p></td><td><p>Strong</p></td><td><p>Good</p></td><td><p>Limited / Moderate</p></td><td><p>Enterprise-focused</p></td><td><p>Limited / Moderate</p></td><td><p>Medium / High</p></td><td><p>Enterprise financial applications needing vendor support</p></td></tr></tbody></table>

<p>No provider fits every workflow equally well. The point of this table is to show where the fit is strongest.</p>
<p>Alpha Vantage works well when a project needs several layers together, especially market data, fundamentals, indicators, developer usability, and AI-agent access. EODHD is stronger when the workflow is centered on global historical research. Intrinio fits better when standardized US fundamentals are the main requirement. Bloomberg API and Xignite are more natural for institutional or enterprise environments, while QuoteMedia is more specialized around investor relations and embedded market data widgets.</p>
<p>This is the right way to think about stock APIs: not as one universal winner, but as different tools for different workflow shapes.</p>
<h2 id="heading-provider-breakdown-through-a-workflow-lens"><strong>Provider Breakdown Through A Workflow Lens</strong></h2>
<p>The table gives a quick comparison. This section explains what that means in practice.</p>
<p>Instead of asking which provider is “best” in general, it is better to ask: what kind of workflow is this provider naturally built for?</p>
<h3 id="heading-1-when-the-project-needs-several-data-layers-alpha-vantage">1. When The Project Needs Several Data Layers: Alpha Vantage</h3>
<p>Alpha Vantage fits well when the project needs more than one type of market data in the same workflow.</p>
<p>In the workflow we built earlier, we used:</p>
<ul>
<li><p>adjusted historical prices</p>
</li>
<li><p>company data</p>
</li>
<li><p>technical indicators</p>
</li>
<li><p>structured output for programmatic analysis</p>
</li>
<li><p>a format that can also support AI-agent workflows</p>
</li>
</ul>
<p>That makes Alpha Vantage a flexible fit for stock research notebooks, screeners, dashboards, backtesting workflows, and AI assistants that need market data through tools or MCP-style access.</p>
<p>The main caveat is specialization. If your project needs direct exchange infrastructure, co-location, or a highly specialized institutional setup, you may need a more specialized provider. But for most research, fintech apps, and AI workflows, Alpha Vantage gives enough breadth without forcing you to combine several APIs too early.</p>
<h3 id="heading-2-when-the-workflow-is-institutional-bloomberg-api">2. When The Workflow Is Institutional: Bloomberg API</h3>
<p>Bloomberg API makes sense when the organization already uses Bloomberg internally.</p>
<p>It's best suited for firms that want to connect Bloomberg data with internal tools, reports, models, and risk systems.</p>
<p>This isn't usually the right fit for solo developers or small teams. The cost, licensing, and ecosystem dependency make it more suitable for institutions.</p>
<h3 id="heading-3-when-the-product-needs-investor-relations-widgets-quotemedia">3. When The Product Needs Investor Relations Widgets: QuoteMedia</h3>
<p>QuoteMedia fits products where the main need is public-facing market data display.</p>
<p>That can include:</p>
<ul>
<li><p>investor relations pages</p>
</li>
<li><p>quote widgets</p>
</li>
<li><p>embedded charts</p>
</li>
<li><p>company stock pages</p>
</li>
<li><p>market data modules for public websites</p>
</li>
</ul>
<p>This is different from building a programmatic research workflow. QuoteMedia makes more sense when presentation and embedded financial data are the core product requirement.</p>
<h3 id="heading-4-when-the-workflow-is-global-historical-research-eodhd">4. When The Workflow Is Global Historical Research: EODHD</h3>
<p>EODHD fits well when the project needs broad historical data across global markets.</p>
<p>It's useful for long-horizon backtesting, global screeners, and research workflows that depend on end-of-day data from many exchanges.</p>
<p>The tradeoff is cleanup. Global data often brings differences in symbols, exchange calendars, currencies, and local market conventions. That's manageable, but it should be expected.</p>
<h3 id="heading-5-when-the-workflow-needs-us-fundamentals-intrinio">5. When The Workflow Needs US Fundamentals: Intrinio</h3>
<p>Intrinio fits well when standardized US fundamentals are the center of the product.</p>
<p>It's useful for:</p>
<ul>
<li><p>valuation tools</p>
</li>
<li><p>earnings dashboards</p>
</li>
<li><p>fundamentals-based screeners</p>
</li>
<li><p>professional US equity research workflows</p>
</li>
</ul>
<p>The main thing to check is dataset fit. Before building around Intrinio, I would look closely at the specific datasets, access terms, and coverage levels the product needs.</p>
<h3 id="heading-6-when-the-workflow-needs-enterprise-data-delivery-xignite">6. When The Workflow Needs Enterprise Data Delivery: Xignite</h3>
<p>Xignite fits larger financial applications that need formal vendor support.</p>
<p>This can include banks, brokerages, wealth platforms, and enterprise fintech products where support, contracts, reliability, and data relationships matter as much as the endpoint itself.</p>
<p>For smaller developer projects, it may feel heavier than necessary. For enterprise products, that structure can be exactly the point.</p>
<h2 id="heading-final-checklist-before-choosing-a-stock-api"><strong>Final Checklist Before Choosing A Stock API</strong></h2>
<p>Before choosing a provider, I would run through this checklist.</p>
<table style="min-width:428px"><colgroup><col style="min-width:25px"><col style="width:403px"></colgroup><tbody><tr><td><p><strong>Question</strong></p></td><td><p><strong>Why It Matters</strong></p></td></tr><tr><td><p>What am I building?</p></td><td><p>A backtester, dashboard, screener, valuation tool, and AI assistant all need different things.</p></td></tr><tr><td><p>Do I need real-time, delayed, or historical data?</p></td><td><p>Real-time access matters only if the workflow actually needs it.</p></td></tr><tr><td><p>Do I need adjusted prices?</p></td><td><p>For backtesting and research, adjusted prices are usually non-negotiable.</p></td></tr><tr><td><p>Do I need fundamentals?</p></td><td><p>Screeners, valuation tools, and research dashboards usually need company data, not just prices.</p></td></tr><tr><td><p>Do I need technical indicators?</p></td><td><p>Signal testing, filters, and momentum-style analysis may need indicators directly from the API or calculated separately.</p></td></tr><tr><td><p>How many symbols will I query?</p></td><td><p>One ticker in a notebook is easy. Hundreds of tickers can expose rate-limit and performance issues quickly.</p></td></tr><tr><td><p>Will users see the data?</p></td><td><p>If yes, licensing, display rights, storage rules, and redistribution terms matter before the product goes live.</p></td></tr><tr><td><p>Is the response easy to parse in Python or other programming languages?</p></td><td><p>Clean JSON can save a lot of cleanup work once the project grows.</p></td></tr><tr><td><p>Can it support AI or agent workflows?</p></td><td><p>AI assistants need structured responses, tool compatibility, or MCP-style access.</p></td></tr><tr><td><p>Will this API still work after the prototype stage?</p></td><td><p>A provider can be easy to try and still be hard to build around.</p></td></tr></tbody></table>

<h2 id="heading-final-thoughts"><strong>Final Thoughts</strong></h2>
<p>A good stock API should reduce project risk, not just return data.</p>
<p>If you're building a small chart, almost any clean price endpoint can work. But once the same API starts supporting a backtester, screener, dashboard, valuation tool, or AI assistant, the decision becomes more important. The provider affects your data quality, parsing logic, refresh jobs, licensing choices, and future product direction.</p>
<p>This is why workflow fit matters more than endpoint count. For projects that need several layers together, such as real-time and historical market data, fundamentals, indicators, developer-friendly access, spreadsheet support, and MCP-style AI workflows, Alpha Vantage fits well. For narrower workflow needs, another provider may make more sense.</p>
<p>Choose the API as part of your project’s data infrastructure, not just as a list of endpoints.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
