<?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[ Nikhil Adithyan - 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[ Nikhil Adithyan - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 10 Jul 2026 22:35:37 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/NikhilAdithyan/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Find Stock-Specific Moves in the S&P 500 with Python ]]>
                </title>
                <description>
                    <![CDATA[ On June 12, 2026, SPY closed up 0.54%. EchoStar (SATS) dropped 11%. Lennar (LEN) dropped 4.9%. Most of the other 500 stocks in the index barely moved beyond what SPY’s own gain would predict. That gap ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-find-stock-specific-moves-in-the-s-p-500-with-python/</link>
                <guid isPermaLink="false">6a3c0c12b0a5ff1b08f51660</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ finance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stockmarket ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Wed, 24 Jun 2026 16:55:46 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4588a4f3-fb34-40af-8a43-ab746e1152b0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>On June 12, 2026, SPY closed up 0.54%. EchoStar (SATS) dropped 11%. Lennar (LEN) dropped 4.9%. Most of the other 500 stocks in the index barely moved beyond what SPY’s own gain would predict.</p>
<p>That gap is the entire premise of this article. Every stock has a normal relationship to the market: how much it tends to rise when SPY rises, how much it tends to fall when SPY falls.</p>
<p>Once you know that relationship, you can calculate what a stock should have done on any given day and compare it to what it actually did. Most days, for most stocks, there’s almost nothing left over. Some days, for a handful of stocks, there’s a lot left over, and that’s where the real story is.</p>
<p>This article builds a Python scanner that runs that comparison across the entire S&amp;P 500 every day, flags the stocks with the largest gap between expected and actual return, and checks whether news, volume, or sector activity explains what happened.</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-setting-up-importing-packages">Setting Up: Importing Packages</a></p>
</li>
<li><p><a href="#heading-building-the-sp-500-universe">Building the S&amp;P 500&nbsp;Universe</a></p>
</li>
<li><p><a href="#heading-fetching-prices-volume-and-daily-returns">Fetching Prices, Volume, and Daily&nbsp;Returns</a></p>
<ul>
<li><a href="#heading-calculating-daily-returns">Calculating Daily&nbsp;Returns</a></li>
</ul>
</li>
<li><p><a href="#heading-estimating-rolling-beta-and-alpha">Estimating Rolling Beta and&nbsp;Alpha</a></p>
</li>
<li><p><a href="#heading-computing-the-residual-return">Computing the Residual&nbsp;Return</a></p>
</li>
<li><p><a href="#heading-scoring-the-residual-with-a-drift-corrected-z-score">Scoring the Residual With a Drift-Corrected Z-Score</a></p>
</li>
<li><p><a href="#heading-adding-multi-day-confirmation">Adding Multi-Day Confirmation</a></p>
</li>
<li><p><a href="#heading-confirming-with-volume">Confirming With&nbsp;Volume</a></p>
</li>
<li><p><a href="#heading-building-the-alpha-investigation-queue">Building the Alpha Investigation Queue</a></p>
</li>
<li><p><a href="#heading-checking-the-story-against-the-news">Checking the Story Against the&nbsp;News</a></p>
</li>
<li><p><a href="#heading-visualizing-the-abnormal-movers">Visualizing the Abnormal&nbsp;Movers</a></p>
<ul>
<li><p><a href="#heading-actual-vs-expected-return">Actual vs Expected&nbsp;Return</a></p>
</li>
<li><p><a href="#heading-top-30-abnormal-movers-by-z-score">Top 30 Abnormal Movers by&nbsp;Z-Score</a></p>
</li>
<li><p><a href="#heading-trailing-abnormal-returns">Trailing Abnormal&nbsp;Returns</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusions-and-ideas-for-next-steps">Conclusions and Ideas for Next&nbsp;Steps</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be comfortable with basic Python, pandas DataFrames, loops, functions, 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 EODHD API key</p>
</li>
<li><p>The following Python libraries: <code>requests</code>, <code>pandas</code>, <code>numpy</code>, <code>matplotlib</code>, and <code>statsmodels</code></p>
</li>
<li><p>Basic familiarity with daily returns, beta, alpha, volume, z-scores, and stock tickers</p>
</li>
</ul>
<p>You don't need advanced quantitative finance knowledge. The goal is to build a practical scanner that separates market-driven moves from stock-specific moves, then checks whether volume and news help explain the abnormal return.</p>
<h2 id="heading-setting-up-importing-packages">Setting Up: Importing Packages</h2>
<pre><code class="language-python">import requests
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import statsmodels.api as sm
from statsmodels.regression.rolling import RollingOLS

plt.style.use('ggplot')
</code></pre>
<p><code>requests</code> and <code>pandas</code> handle the API calls and all the data wrangling. <code>RollingOLS</code> from <code>statsmodels</code> runs the rolling regression that estimates each stock's beta and alpha against SPY, which is the core of the scanner. <code>ggplot</code> gives the charts a cleaner look than matplotlib's default.</p>
<h2 id="heading-building-the-sampp-500-universe"><strong>Building the S&amp;P 500&nbsp;Universe</strong></h2>
<p>The scanner needs a current list of S&amp;P 500 tickers and their sectors. <a href="https://eodhd.com/lp/fundamental-data-api">EODHD’s fundamentals endpoint</a> for the index returns this directly.</p>
<pre><code class="language-python">api_key = 'eodhd api key'

url = f'https://eodhd.com/api/fundamentals/GSPC.INDX?api_token={api_key}&amp;fmt=json&amp;filter=Components'
r = requests.get(url)
components = r.json()

universe = pd.DataFrame(components).T[['Code', 'Sector']].rename(columns={
    'Code': 'ticker',
    'Sector': 'sector'
}).reset_index(drop=True)

tickers = universe['ticker'].tolist()

print(f'universe size: {len(universe)}')
print(universe['sector'].value_counts())
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">universe size: 503
sector
Technology                83
Industrials               75
Financial Services        70
Healthcare                59
Consumer Cyclical         54
Consumer Defensive        35
Utilities                 31
Real Estate               31
Communication Services    24
Energy                    21
Basic Materials           20
Name: count, dtype: int64
</code></pre>
<p>503 tickers, because the S&amp;P 500 includes a handful of dual-class share structures. Technology and Industrials make up nearly a third of the index between them, which matters later when a cluster of moves shows up concentrated in one sector.</p>
<p>SPY is fetched separately in the next step and never enters this list. It’s the benchmark, not a candidate.</p>
<h2 id="heading-fetching-prices-volume-and-daily-returns"><strong>Fetching Prices, Volume, and Daily&nbsp;Returns</strong></h2>
<p>The regression needs a full year of price and volume history for every ticker in the universe, plus SPY as the benchmark. This historical data can be fetched using <a href="https://eodhd.com/lp/historical-eod-api">EODHD's historical EOD endpoint</a>.</p>
<pre><code class="language-python">end_date = pd.Timestamp.today().strftime('%Y-%m-%d')
start_date = (pd.Timestamp.today() - pd.Timedelta(days=365)).strftime('%Y-%m-%d')

def fetch_ohlcv(ticker, start, end):
    url = f'https://eodhd.com/api/eod/{ticker}.US?from={start}&amp;to={end}&amp;api_token={api_key}&amp;fmt=json'
    r = requests.get(url)
    data = r.json()
    df = pd.DataFrame(data)[['date', 'adjusted_close', 'volume']]
    df['date'] = pd.to_datetime(df['date'])
    df = df.set_index('date')
    df.columns = [ticker, f'{ticker}_vol']
    return df

all_prices = {}
all_volumes = {}

for ticker in tickers + ['SPY']:
    try:
        result = fetch_ohlcv(ticker, start_date, end_date)
        all_prices[ticker] = result[ticker]
        all_volumes[ticker] = result[f'{ticker}_vol']
        print(f'{ticker} DONE')
    except:
        print(f'{ticker} ERROR')

prices = pd.DataFrame(all_prices)
volumes = pd.DataFrame(all_volumes)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/72926ade-bc8a-43ee-9f12-5d1386f68f6a.png" alt="Historical prices data" style="display:block;margin:0 auto" width="1317" height="726" loading="lazy">

<p>Two wide dataframes come out of the loop, one for price and one for volume, both indexed by date with a column per ticker. 251 trading days across exactly one year, 504 columns because the 503 S&amp;P 500 tickers plus SPY all came back successfully.</p>
<h3 id="heading-calculating-daily-returns">Calculating Daily&nbsp;Returns</h3>
<p>Adjusted close converts directly into daily percentage returns, which is what the regression actually runs on, not raw price.</p>
<pre><code class="language-python">prices = prices.sort_index()
volumes = volumes.sort_index()

prices = prices.apply(pd.to_numeric, errors='coerce')
volumes = volumes.apply(pd.to_numeric, errors='coerce')

prices = prices.ffill(limit=3)

returns = prices.pct_change(fill_method=None)
returns = returns.iloc[1:]

missing_pct = returns.isna().mean()

valid_tickers = missing_pct[missing_pct &lt;= 0.10].index.tolist()

if 'SPY' not in valid_tickers:
    valid_tickers.append('SPY')

returns = returns[valid_tickers]
volumes = volumes[valid_tickers]

spy_returns = returns['SPY']
stock_returns = returns.drop(columns=['SPY'])

stock_returns.head()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/9bb4bd2f-7935-4b3e-abab-b48e9692ffb8.png" alt="Historical returns data" style="display:block;margin:0 auto" width="1547" height="500" loading="lazy">

<p>A couple of tickers, including Q, came back with NaN prices on certain days. This is the kind of one-off gap a 503-ticker pull is bound to hit.</p>
<p>Forward-filling that gap on the price itself, capped at three trading days, is what <code>ffill(limit=3)</code> does before the percentage change is taken. So the return calculated from it reflects an actual assumption: no new price, no change, instead of a fabricated number from filling the return directly.</p>
<p>Anything with a gap longer than three days still shows up as NaN in <code>returns</code> and gets dropped by the 10% missing threshold rather than patched.</p>
<p><code>fill_method=None</code> on <code>pct_change</code> matters too, since pandas would otherwise forward-fill before differencing on its own, which is the exact shortcut this fix avoids. Two tickers came out as 501 instead of 503 after the filter, both falling above the missing threshold.</p>
<h2 id="heading-estimating-rolling-beta-and-alpha"><strong>Estimating Rolling Beta and&nbsp;Alpha</strong></h2>
<p>Every stock has a normal sensitivity to SPY: how much it tends to move when the market moves. Beta captures that sensitivity, and a 60-day rolling window gives a stable estimate without overreacting to any single day. <code>RollingOLS</code> runs that regression for every ticker in one pass.</p>
<pre><code class="language-python">window = 60

rolling_beta = pd.DataFrame(np.nan, index=stock_returns.index, columns=stock_returns.columns)
rolling_alpha = pd.DataFrame(np.nan, index=stock_returns.index, columns=stock_returns.columns)

spy_with_const = sm.add_constant(spy_returns)

for ticker in stock_returns.columns:
    model = RollingOLS(stock_returns[ticker], spy_with_const, window=window).fit()
    rolling_beta[ticker] = model.params['SPY']
    rolling_alpha[ticker] = model.params['const']

print(f'beta estimated for: {rolling_beta.notna().any().sum()} tickers')
print(f'date range with estimates: {rolling_beta.dropna(how="all").index[0].date()} to {rolling_beta.dropna(how="all").index[-1].date()}')
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/f27dd33e-d398-4b96-ae28-6cd959f041ec.png" alt="Rolling Beta" style="display:block;margin:0 auto" width="1660" height="616" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/09663bdc-aa76-4d0a-b56d-26750d81ee65.png" alt="Rolling Alpha" style="display:block;margin:0 auto" width="1741" height="638" loading="lazy">

<p><code>sm.add_constant</code> adds the intercept term to SPY's return series so the regression solves for both alpha and beta together. <code>model.params['SPY']</code> is the beta, <code>model.params['const']</code> is the alpha, pulled straight out of the fitted model for every ticker in the loop.</p>
<p>PGR's beta sitting around -0.42 to -0.53 in early June stands out immediately, an insurance name moving consistently opposite to the market over that stretch, while CSX holds steady near 0.43 to 0.49, a much more textbook beta for an industrial name.</p>
<h2 id="heading-computing-the-residual-return"><strong>Computing the Residual&nbsp;Return</strong></h2>
<p>Beta and alpha describe what a stock should have done given how SPY moved. Subtracting that expected return from what the stock actually did leaves the residual, the part of the move that has nothing to do with the market.</p>
<p>Using today’s beta to judge today’s move would let the move influence the very benchmark it’s being measured against, so both get shifted back a day first.</p>
<pre><code class="language-python">beta_shifted = rolling_beta.shift(1)
alpha_shifted = rolling_alpha.shift(1)

spy_aligned = spy_returns.reindex(stock_returns.index)

expected_returns = alpha_shifted.add(beta_shifted.multiply(spy_aligned, axis=0))
residuals = stock_returns - expected_returns
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/af067b9b-ea59-4d9f-9195-b9ab3c9d483f.png" alt="Expected returns" style="display:block;margin:0 auto" width="1689" height="623" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/baaf8818-2cd9-4e29-9342-6a0eeb3a8504.png" alt="Residuals" style="display:block;margin:0 auto" width="1684" height="628" loading="lazy">

<p><code>expected_returns</code> is yesterday's alpha plus yesterday's beta times today's SPY return, the prediction a stock's normal market relationship would have made. <code>residuals</code> is the actual return minus that prediction.</p>
<p>Most of these numbers sit in a narrow band, a few tenths of a percent either way. This is exactly what a market-driven move looks like once the market's own contribution has been removed.</p>
<h2 id="heading-scoring-the-residual-with-a-drift-corrected-z-score"><strong>Scoring the Residual With a Drift-Corrected Z-Score</strong></h2>
<p>A residual of 0.03 means nothing on its own. Some stocks routinely have noisier idiosyncratic moves than others, so the same residual needs to be judged against that stock’s own recent history, not a fixed threshold applied across all the names.</p>
<pre><code class="language-python">window_z = 20

resid_mean = residuals.shift(1).rolling(window_z).mean()
resid_std = residuals.shift(1).rolling(window_z).std()

zscore = (residuals - resid_mean) / resid_std

zscore.tail()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/13a240ae-8bff-4ca8-8a3a-026061cf065e.png" alt="Residual Z-Score" style="display:block;margin:0 auto" width="1217" height="389" loading="lazy">

<p>The rolling mean is in there deliberately, not just the rolling standard deviation. Some stocks carry a small persistent drift in their residuals, a slight tendency to run a touch above or below zero over any given stretch, and scoring against that drift rather than against zero keeps the z-score honest about what’s actually unusual for that specific stock.</p>
<p>Both the mean and the standard deviation get shifted by a day for the same reason beta and alpha did: today’s score can’t be built from a distribution that includes today’s own value.</p>
<p>AFL’s -2.31 on June 8 and SOLV’s -2.31 the same day already clear the threshold worth paying attention to (two standard deviations below their own recent norm), while SOLV swings to +2.40 the very next day.</p>
<h2 id="heading-adding-multi-day-confirmation"><strong>Adding Multi-Day Confirmation</strong></h2>
<p>A single day’s z-score can be noise, one stray print that happens to land outside the normal range. Compounding the residual over the trailing 3 and 5 days checks whether the move actually held.</p>
<pre><code class="language-python">residuals_3d = (1 + residuals).rolling(3).apply(np.prod, raw=True) - 1
residuals_5d = (1 + residuals).rolling(5).apply(np.prod, raw=True) - 1

print('3-day compounded residuals (last 5 rows, first 5 tickers):')
print(residuals_3d.iloc[-5:, :5].round(4))
print('\n5-day compounded residuals (last 5 rows, first 5 tickers):')
print(residuals_5d.iloc[-5:, :5].round(4))
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/b09c82a2-ff33-4ac9-bd59-ccbb44c0a50f.png" alt="3-day, 5-day compounded residuals" style="display:block;margin:0 auto" width="973" height="612" loading="lazy">

<p>Compounding rather than summing matters here because residual returns multiply through time the same way regular returns do.</p>
<p>AIZ's residual climbs from 2.4% over 3 days to a near-flat 0.6% over 5, which means most of that move was concentrated in the most recent stretch and the earlier days were closer to neutral. MNST shows the opposite shape: a steady build from 2% to 4.4% to 5.8% across the three windows in the days leading into June 11, a sustained drift rather than a single spike.</p>
<h2 id="heading-confirming-with-volume"><strong>Confirming With&nbsp;Volume</strong></h2>
<p>A large residual return on ordinary trading volume is easier to dismiss than the same move with twice the usual number of shares changing hands. Volume is the check on whether the move had real participation behind it.</p>
<pre><code class="language-python">vol_mean = volumes.shift(1).rolling(20).mean()
volume_ratio = volumes / vol_mean

volume_ratio = volume_ratio.drop(columns=['SPY'], errors='ignore')

print('volume ratios (last 5 rows, first 5 tickers):')
print(volume_ratio.iloc[-5:, :5].round(2))
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/b7eb8cbf-ded4-46c9-b185-6a7f5ef4c64b.png" alt="Volume ratios" style="display:block;margin:0 auto" width="933" height="364" loading="lazy">

<p>The 20-day average volume used as the denominator is shifted by a day for the same reason every other rolling statistic in this scanner is: today's elevated volume shouldn't be allowed to inflate the baseline it's being measured against.</p>
<p>None of these five tickers cross 1.5x on these particular days, which is the threshold that turns a volume reading into a meaningful confirmation rather than ordinary day-to-day variation. A ratio above 1.5 paired with a z-score outside 2 standard deviations is a stronger candidate than either signal showing up alone.</p>
<h2 id="heading-building-the-alpha-investigation-queue"><strong>Building the Alpha Investigation Queue</strong></h2>
<p>Every piece built so far points at the same trading day. Pulling the most recent row out of each one and joining them by ticker turns five separate dataframes into the single table the whole scanner exists to produce.</p>
<pre><code class="language-python">scan_date = stock_returns.index[-1]

queue = pd.DataFrame({
    'sector': universe.set_index('ticker')['sector'],
    'actual_return': stock_returns.loc[scan_date],
    'spy_return': spy_returns.loc[scan_date],
    'beta': beta_shifted.loc[scan_date],
    'expected_return': expected_returns.loc[scan_date],
    'residual': residuals.loc[scan_date],
    'zscore': zscore.loc[scan_date],
    'residual_3d': residuals_3d.loc[scan_date],
    'residual_5d': residuals_5d.loc[scan_date],
    'volume_ratio': volume_ratio.loc[scan_date]
})

queue = queue.dropna()
queue = queue.reindex(queue['zscore'].abs().sort_values(ascending=False).index)
queue['high_confidence'] = (queue['zscore'].abs() &gt; 2.0) &amp; (queue['volume_ratio'] &gt; 1.5)

queue.head(10)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/dca16809-83ab-4ba2-bd77-a7acd5135d2a.png" alt="dca16809-83ab-4ba2-bd77-a7acd5135d2a" style="display:block;margin:0 auto" width="1682" height="656" loading="lazy">

<p>A few names stand out for different reasons.</p>
<p><strong>SATS, the volume outlier:</strong> Down almost 11% while SPY was up half a percent. A beta of 1.55 would have called for a small gain, not a double-digit drop, and the residual lands near -12%. Volume ran more than six times its 20-day average, the highest ratio in the table.</p>
<p><strong>LEN, the extreme score:</strong> A z-score of -3.9, the single most negative number anywhere in the queue. Beta of 1.45 predicted a modest gain on a day SPY was up. The stock fell almost 5% instead.</p>
<p><strong>MOS and ALB, a possible shared story:</strong> Both Basic Materials, both positive, both backed by elevated volume, sitting back to back in the ranking. Worth checking for a common catalyst before treating either one as an independent idiosyncratic move.</p>
<p><strong>TKO, a flag with a catch:</strong> Clears the high-confidence bar on the numbers alone, but the ticker maps to two different companies depending on the source, TKO Group Holdings and Tikehau Capital. That collision turns into a real problem once the news search runs.</p>
<h2 id="heading-checking-the-story-against-the-news"><strong>Checking the Story Against the&nbsp;News</strong></h2>
<p>A z-score only says a move was unusual, not why it happened. Pulling recent headlines for the flagged names is the only way to find out whether there’s an actual story behind the number.&nbsp;We’ll fetch the news data using <a href="https://eodhd.com/financial-apis/stock-market-financial-news-api">EODHD’s financial news endpoint</a>.</p>
<pre><code class="language-python">def fetch_news(ticker, start, end):
    url = f'https://eodhd.com/api/news?s={ticker}.US&amp;from={start}&amp;to={end}&amp;limit=3&amp;api_token={api_key}&amp;fmt=json'
    r = requests.get(url)
    data = r.json()
    return [item['title'] for item in data[:3]]

news_start = (scan_date - pd.Timedelta(days=3)).strftime('%Y-%m-%d')
news_end = scan_date.strftime('%Y-%m-%d')

high_conf = queue[queue['high_confidence']].head(10)
remaining = queue[~queue['high_confidence']].head(max(0, 10 - len(high_conf)))
news_candidates = pd.concat([high_conf, remaining])

news_results = {}
for ticker in news_candidates.index:
    headlines = fetch_news(ticker, news_start, news_end)
    news_results[ticker] = headlines
    print(f'\n{ticker}:')
    if headlines:
        for h in headlines:
            print(f'  - {h}')
    else:
        print('  no news found')
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">LEN:
  - Lennar Corp (LEN) Q2 2026 Earnings Call Highlights: Strong Margins and Strategic Adjustments ...
  - Why Lennar (LEN) Stock Is Down Today
  - Update: Equities Rise as SpaceX Soars; Wall Street Logs Weekly Gain Amid Iran Deal Optimism

SATS:
  - Stocks Rally on Hopes for a Near-term US-Iran Interim Peace Agreement
  - Stock Market Today, June 12: EchoStar Falls as SpaceX-Linked Rally Meets DISH DBS Payment Risk
  - Why EchoStar (SATS) Stock Is Falling Today

MOS:
  - S&amp;amp;P 500 Movers: KLAC, MOS
  - Top 10 most oversold S&amp;amp;P 500 stocks
  - Mosaic (MOS) Down 5% Since Last Earnings Report: Can It Rebound?

ALB:
  - DuPont Achieves Renewable Power Milestone in US Healthcare Sites
  - S&amp;amp;P 500 Movers: KLAC, MOS
  - ATI and BWX Technologies Extend Strategic Partnership Through 2030

TKO:
  - Tikehau Capital: Disclosure of Shares Repurchases from 05 June 2026 to 11 June 2026
  - Here's Why We're Wary Of Buying TKO Group Holdings' (NYSE:TKO) For Its Upcoming Dividend
  - Tikehau Capital: Extension of the Share Repurchase Mandate

FOX:
  - Fox Could Unlock 800+ World Cup Ad Spots
  - World Cup Economics: How Much Boost Could The US Get?
  - Why Is Fox (FOXA) Up 3.3% Since Last Earnings Report?

DPZ:
  - Is Domino's (DPZ) Valuation Reset Revealing a Deeper Shift in Its Competitive Moat?
  - Domino's Pizza (DPZ) Stock Valuation Check After Mixed Recent Performance
  - Is Domino's Pizza, Inc. (DPZ) A Good Stock To Buy Now?

CTAS:
  - UniFirst Shareholders Approve Transaction with Cintas
  - Cintas Stock Bears Are Overlooking This Profit Engine

CTVA:
  - Corteva sees higher restructuring charges, plans to cease production at Spanish site
  - Zacks Industry Outlook Highlights Corteva, Archer Daniels Midland, The Scotts, Miracle-Gro, Adecoagro and Mission Produce
  - 5 Agriculture Operations Stocks to Benefit From Innovation-Driven Growth

TSN:
  - Tyson Foods Installs New COO As Beef Woes And Valuation Discount Persist
  - Why JBS Is Closing Plants Even as Beef Prices Hit Records
  - Tyson Foods, Inc. (TSN) is Attracting Investor Attention: Here is What You Should Know
</code></pre>
<p>The high-confidence stocks get pulled first, with the remaining slots filled by the next highest z-scores if fewer than 10 clear that bar. This is why DPZ, CTAS, CTVA, and TSN show up here despite not carrying the flag.</p>
<p><strong>SATS holds up.</strong> A direct headline ties the drop to DISH DBS payment risk, surfacing on the exact day the residual shows up and lining up with the volume spike.</p>
<p><strong>LEN holds up, too.</strong> "Why Lennar (LEN) Stock Is Down Today" is about as direct a confirmation as a headline gets, backed by a Q2 earnings call reference that explains why the market would be repricing the stock specifically.</p>
<p><strong>TKO breaks.</strong> Every headline returned is about Tikehau Capital, a French asset manager that happens to share the same ticker as TKO Group Holdings on a different exchange. The high-confidence flag fired correctly. The news search picked the wrong company entirely.</p>
<p><strong>MOS and ALB stay unexplained.</strong> ALB's headlines are about DuPont and a defense partnership, neither relevant. MOS gets a real mention in passing, a "down 5% since last earnings" line, but nothing that explains a same-day move. The shared-catalyst theory from the queue doesn't get resolved here either way.</p>
<h2 id="heading-visualizing-the-abnormal-movers">Visualizing the Abnormal&nbsp;Movers</h2>
<h3 id="heading-actual-vs-expected-return">Actual vs Expected&nbsp;Return</h3>
<p>A stock’s actual return only earns attention here if it breaks away from what beta alone would have predicted. A scatter against the expected return is the fastest way to see which ones did.</p>
<pre><code class="language-python">fig, ax = plt.subplots(figsize=(9, 7))

sector_list = queue['sector'].unique()
colors = plt.cm.tab20(np.linspace(0, 1, len(sector_list)))
sector_colors = dict(zip(sector_list, colors))

for sector in sector_list:
    subset = queue[queue['sector'] == sector]
    ax.scatter(
        subset['expected_return'], subset['actual_return'],
        s=subset['volume_ratio'] * 40,
        color=sector_colors[sector],
        label=sector, alpha=0.7, edgecolors='black', linewidth=0.5
    )

lims = [queue[['expected_return', 'actual_return']].min().min(),
        queue[['expected_return', 'actual_return']].max().max()]
ax.plot(lims, lims, color='black', linestyle='--', linewidth=1)

ax.set_xlabel('expected return (beta-adjusted)')
ax.set_ylabel('actual return')
ax.set_title(f'Actual vs Expected Return - {scan_date.date()}')
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=8)
plt.tight_layout()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/ef383587-3a4d-4e80-9024-58bfc22e0db4.png" alt="Actual vs Expected Return" style="display:block;margin:0 auto" width="883" height="690" loading="lazy">

<p>Most of the 487 points crowd close to the dashed line, sitting in the narrow band near zero where actual and expected return roughly agree. This is what the bulk of any given day’s trading actually looks like once beta is accounted for.</p>
<p>SATS sits far below the line on the right side of the chart, the largest bubble in the entire plot, its size scaled directly to the 6.28x volume ratio that confirmed the move.</p>
<p>The big grey Technology-colored point near the bottom is LEN, also well clear of the line and large enough to stand out against the Consumer Cyclical points clustered tighter to the diagonal.</p>
<p>A handful of other points drift noticeably off the line in both directions without being flagged as high-confidence, a reminder that distance from the line alone doesn’t guarantee a real story, which is exactly what the volume and news checks exist to settle.</p>
<h3 id="heading-top-30-abnormal-movers-by-z-score">Top 30 Abnormal Movers by&nbsp;Z-Score</h3>
<p>A z-score ranking alone tells you which moves were statistically unusual. Pairing each bar with its volume ratio shows which of those moves also had real trading activity behind them, since the two together matter more than either alone.</p>
<pre><code class="language-python">top30 = queue.head(30).sort_values('zscore')

fig, ax = plt.subplots(figsize=(8, 6))
bar_colors = ['#2ca02c' if z &gt; 0 else '#d62728' for z in top30['zscore']]
ax.barh(top30.index, top30['zscore'], color=bar_colors)

for i, (ticker, row) in enumerate(top30.iterrows()):
    ax.text(row['zscore'] + (0.1 if row['zscore'] &gt; 0 else -0.1),
             i, f'vol={row["volume_ratio"]:.1f}x',
             va='center', ha='left' if row['zscore'] &gt; 0 else 'right', fontsize=7)

ax.axvline(2.0, color='black', linestyle='--', linewidth=1)
ax.axvline(-2.0, color='black', linestyle='--', linewidth=1)
ax.set_xlabel('z-score')
ax.set_title(f'Top 30 Abnormal Movers by Z-Score - {scan_date.date()}')
plt.tight_layout()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/76ebb3d3-e1fe-4969-a1c0-ed9858563742.png" alt="Top 30 Abnormal Movers by Z-Score" style="display:block;margin:0 auto" width="787" height="590" loading="lazy">

<p>LEN’s bar runs past -3.9, well clear of the -2.0 reference line, with a 2.4x volume label sitting right at the tip.</p>
<p>SATS follows close behind at roughly -3.0. But the number that actually stands out next to it is the volume label, 6.3x, the highest ratio anywhere on the chart and a much stronger confirmation than LEN’s.</p>
<p>On the positive side, MOS and ALB sit at the top of the green bars within a fraction of each other, both backed by volume north of 1.5x. This is consistent with the queue’s earlier suggestion that the two might share a catalyst.</p>
<p>ADBE is the one worth lingering on. Its bar barely crosses -1.6, short of the -2.0 threshold that would have earned it a high-confidence flag. But its volume ratio of 4.2x is among the highest in the entire chart. That combination, a moderate z-score paired with unusually heavy volume, is exactly the kind of case a fixed threshold misses and a chart like this one catches instead.</p>
<h3 id="heading-trailing-abnormal-returns">Trailing Abnormal&nbsp;Returns</h3>
<p>A single day’s residual can’t say whether a move is building or already over. Lining up the 1, 3, and 5-day windows for the same set of stocks separates the two.</p>
<pre><code class="language-python">top15 = queue.head(15)
heatmap_data = top15[['residual', 'residual_3d', 'residual_5d']]
heatmap_data.columns = ['1-day', '3-day', '5-day']

fig, ax = plt.subplots(figsize=(7, 5))
im = ax.imshow(heatmap_data.values, cmap='RdYlGn', aspect='auto', vmin=-0.1, vmax=0.1)

ax.set_xticks(range(len(heatmap_data.columns)))
ax.set_xticklabels(heatmap_data.columns)
ax.set_yticks(range(len(heatmap_data.index)))
ax.set_yticklabels(heatmap_data.index)
ax.grid(False)

for i in range(heatmap_data.shape[0]):
    for j in range(heatmap_data.shape[1]):
        ax.text(j, i, f'{heatmap_data.values[i, j]:.1%}', ha='center', va='center', fontsize=8)

ax.set_title(f'Trailing Abnormal Returns - {scan_date.date()}')
plt.colorbar(im, ax=ax, label='abnormal return')
plt.tight_layout()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/13bb39fb-d4b5-442b-be75-216f869a4b9b.png" alt="Trailing Abnormal Returns" style="display:block;margin:0 auto" width="683" height="490" loading="lazy">

<p>ALB is the clearest example of a move that built rather than spiked, going from 7.0% on day one to 11.8% over three days and settling at 10.5% over five, each window deepening the color rather than reversing it.</p>
<p>SATS tells the opposite story. The 1-day column shows -11.8% (by far the darkest red cell in the entire heatmap), but the 3-day and 5-day columns fade to -2.9% and -2.3%. This means that most of the damage was already priced in within the first session and the days that followed barely added to it.</p>
<p>CVNA shows a third pattern entirely, a move that got worse before it got better: -6.4% on day one widens to -8.9% over three days, the single deepest red cell outside of SATS’s first column, before narrowing back to -4.4% by day five.</p>
<p>Three names, three different shapes, and none of that distinction would be visible from the single-day z-score table alone.</p>
<h2 id="heading-conclusions-and-ideas-for-next-steps"><strong>Conclusions and Ideas for Next&nbsp;Steps</strong></h2>
<p>A few things stood out from today’s scan:</p>
<ul>
<li><p>31 out of 487 stocks cleared the high-confidence bar, roughly 6%, which is a reasonable hit rate for a daily flag.</p>
</li>
<li><p>SATS and LEN both had real news behind the move, the best-case outcome for this kind of scanner.</p>
</li>
<li><p>TKO is a reminder that a ticker can mean two different companies depending on the data source.</p>
</li>
<li><p>MOS and ALB moving together with no news confirmation is worth a closer look, not just a glance at the table.</p>
</li>
</ul>
<p>A few ways to take this further:</p>
<ul>
<li><p>Match news by company name instead of ticker. That alone would've caught the TKO collision.</p>
</li>
<li><p>Pull more than 3 headlines per stock. ALB and MOS both got thin results.</p>
</li>
<li><p>Run this daily and keep a log, a single day’s queue can’t tell you if a move held or reversed.</p>
</li>
<li><p>Add a sector check. Two stocks from the same sector flagging together is worth a second look before calling either one idiosyncratic.</p>
</li>
</ul>
<p>Beta explains most of what a stock does on most days. The exceptions are rare, and even then, it still takes a real check before you know if one means anything.</p>
<p>With that being said, you’ve reached the end of the article. Hope you learned something new and useful. Thank you very much for your time.</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[ Geopolitical Risk Isn't One Thing. I Built a Python Framework to Prove It ]]>
                </title>
                <description>
                    <![CDATA[ On April 3, 2025, the US announced sweeping tariffs on Chinese imports. SPY dropped 4.8% that day. The next day, it dropped another 6%. Financial news ran the usual headline: markets rattled by geopol ]]>
                </description>
                <link>https://www.freecodecamp.org/news/geopolitical-risk-isn-t-one-thing-i-built-a-python-framework-to-prove-it/</link>
                <guid isPermaLink="false">6a2cfaa312f3716e29ed15f7</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stockmarket ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dataanalysis ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Geopolitics ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Sat, 13 Jun 2026 06:37:23 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/58157af6-2b00-4d0c-a5a0-c8e08892c568.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>On April 3, 2025, the US announced sweeping tariffs on Chinese imports. SPY dropped 4.8% that day. The next day, it dropped another 6%. Financial news ran the usual headline: markets rattled by geopolitical uncertainty.</p>
<p>Three months earlier, on August 5, 2024, the yen carry trade unwound. SPY dropped 3% in a single session. VIXY hit 65. Same headline: geopolitical uncertainty roils markets.</p>
<p>Both events got the same label. But if you actually pull the data and look at what moved, the two events have almost nothing in common. Gold surged in the tariff shock. In the yen unwind, it fell. Bonds rallied in the yen unwind. In the tariff shock, they sold off alongside equities.</p>
<p>Same label. Completely different markets.</p>
<p>To understand why, in this analysis we'll forensically pull apart three geopolitical events using Python and EODHD’s market data APIs. We'll track what moved, in what order, what the options market was pricing before spot prices moved, and what news sentiment was saying through all of it. The data tells a more specific story than the headlines did.</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-setup-the-asset-basket-and-data-source">Setup: The Asset Basket and Data Source</a></p>
</li>
<li><p><a href="#heading-the-repricing-sequence-engine">The Repricing Sequence Engine</a></p>
</li>
<li><p><a href="#heading-options-data-and-iv-skew">Options Data and IV Skew</a></p>
</li>
<li><p><a href="#heading-composite-stress-score">Composite Stress Score</a></p>
</li>
<li><p><a href="#heading-news-sentiment">News Sentiment</a></p>
</li>
<li><p><a href="#heading-event-1-hamas-attack-on-israel-oct-7-2023">Event 1: Hamas Attack on Israel, Oct 7 2023</a></p>
</li>
<li><p><a href="#heading-event-2-yen-carry-unwind-aug-5-2024">Event 2: Yen Carry Unwind, Aug 5 2024</a></p>
</li>
<li><p><a href="#heading-event-3-us-china-tariff-shock-apr-2025">Event 3: US-China Tariff Shock, Apr&nbsp;2025</a></p>
</li>
<li><p><a href="#heading-putting-it-all-together-the-heatmap">Putting It All Together: The&nbsp;Heatmap</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you should be comfortable with basic Python and pandas. This article assumes you can read DataFrames, work with dictionaries, write simple functions, and understand basic return calculations.</p>
<p>You’ll also need:</p>
<ul>
<li><p>Python 3.9 or later</p>
</li>
<li><p>An EODHD API key</p>
</li>
<li><p>The following Python libraries: <code>requests</code>, <code>pandas</code>, <code>numpy</code>, and <code>plotly</code></p>
</li>
<li><p>Basic familiarity with ETFs like SPY, QQQ, GLD, TLT, and VIXY</p>
</li>
<li><p>Some understanding of returns, volatility, implied volatility, options skew, correlation, and market sentiment</p>
</li>
</ul>
<p>You don't need to be an options expert to follow the article. The options section uses one simple idea: if out-of-the-money puts become more expensive relative to at-the-money calls, the market is paying more for downside protection. We’ll use that as a rough fear signal, not as a full options pricing model.</p>
<p>The goal isn't to build a perfect geopolitical risk model. The goal is to show how different market data layers can help separate one type of shock from another.</p>
<h2 id="heading-setup-the-asset-basket-and-data-source">Setup: The Asset Basket and Data Source</h2>
<p>The asset basket is built around one question: which instruments reveal the most about how a shock is being interpreted by the market?</p>
<p>Broad equities (SPY, QQQ, IWM) show the scale of the selloff and which market cap segments are hit hardest. Sector ETFs (XLE, XLF, ITA, XLK) show where the economic consequence is being priced. Energy, financials, defense, and tech each respond differently depending on the nature of the shock. Safe havens (GLD, TLT, UUP) are the most diagnostic: how gold, bonds, and the dollar move relative to equities tells you what kind of fear the market is expressing. VIXY tracks implied volatility directly.</p>
<p>Together, these 11 assets produce a fingerprint for each event.</p>
<p>We've pulled data from <a href="https://eodhd.com/lp/historical-eod-api">EODHD’s historical EOD API</a>. Each event gets a 30-day window on either side of the event date.</p>
<pre><code class="language-python">import requests
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

api_key = 'your_eodhd_api_key'

events = {
    'oct7_attack': {
        'date': '2023-10-07',
        'label': 'Hamas Attack on Israel (Oct 2023)',
        'shock_type': 'confidence',
        'shock_label': 'Type 1 - Confidence Shock'
    },
    'yen_carry_unwind': {
        'date': '2024-08-05',
        'label': 'Yen Carry Unwind + Middle East Escalation (Aug 2024)',
        'shock_type': 'liquidity',
        'shock_label': 'Type 2 - Liquidity Shock'
    },
    'tariff_shock': {
        'date': '2025-04-03',
        'label': 'US-China Tariff Shock (Apr 2025)',
        'shock_type': 'structural',
        'shock_label': 'Type 3 - Structural Shock'
    }
}

assets = {
    'spy': 'SPY.US', 'qqq': 'QQQ.US', 'iwm': 'IWM.US',
    'xle': 'XLE.US', 'xlf': 'XLF.US', 'ita': 'ITA.US',
    'xlk': 'XLK.US', 'gld': 'GLD.US', 'tlt': 'TLT.US',
    'uup': 'UUP.US', 'vixy': 'VIXY.US'
}

def fetch_prices(ticker, start, end):
    url = f'https://eodhd.com/api/eod/{ticker}'
    params = {
        'from': start,
        'to': end,
        'api_token': api_key,
        'fmt': 'json'
    }
    r = requests.get(url, params=params)
    df = pd.DataFrame(r.json())
    df['date'] = pd.to_datetime(df['date'])
    df = df.set_index('date')[['adjusted_close']]
    df.columns = [ticker.split('.')[0].lower()]
    return df

def fetch_event_prices(event_date, lookback=30, lookahead=30):
    start = (pd.Timestamp(event_date) - pd.Timedelta(days=lookback)).strftime('%Y-%m-%d')
    end = (pd.Timestamp(event_date) + pd.Timedelta(days=lookahead)).strftime('%Y-%m-%d')
    frames = [fetch_prices(ticker, start, end) for ticker in assets.values()]
    return pd.concat(frames, axis=1)

event_prices = {name: fetch_event_prices(e['date']) for name, e in events.items()}

event_prices.keys()
</code></pre>
<p>This gives us three dataframes: one per event, each with 11 columns and roughly 60 rows covering the full window.</p>
<pre><code class="language-plaintext">dict_keys(['oct7_attack', 'yen_carry_unwind', 'tariff_shock'])
</code></pre>
<p>All prices are adjusted close, which handles any splits or dividend distortions cleanly.</p>
<h2 id="heading-the-repricing-sequence-engine">The Repricing Sequence Engine</h2>
<p>Before looking at each event individually, we need a consistent way to measure what happened across all of them. The repricing sequence engine does three things: normalizes all asset prices to 100 at the event date so cross-asset comparison is clean, slices a tight window around the event, and ranks assets by the size of their T+1 move to identify what repriced fastest.</p>
<pre><code class="language-python">def normalize_to_event(df, event_date):
    event_date = pd.Timestamp(event_date)
    valid_dates = df.index[df.index &gt;= event_date]
    anchor = valid_dates[0]
    normalized = df.div(df.loc[anchor]) * 100
    return normalized, anchor

def get_event_window(df, anchor, t_minus=5, t_plus=10):
    start_idx = df.index.get_loc(anchor) - t_minus
    end_idx = df.index.get_loc(anchor) + t_plus
    start_idx = max(start_idx, 0)
    return df.iloc[start_idx:end_idx + 1]

def repricing_leaderboard(window_df, anchor):
    anchor_idx = window_df.index.get_loc(anchor)
    post_event = window_df.iloc[anchor_idx:]
    cumulative_returns = (post_event / post_event.iloc[0] - 1) * 100
    t1_moves = cumulative_returns.iloc[1].abs().sort_values(ascending=False)
    return cumulative_returns, t1_moves

event_windows = {}
leaderboards = {}

for name, meta in events.items():
    df = event_prices[name]
    normalized, anchor = normalize_to_event(df, meta['date'])
    window = get_event_window(normalized, anchor)
    cumret, t1_rank = repricing_leaderboard(window, anchor)
    event_windows[name] = {'window': window, 'anchor': anchor, 'cumret': cumret}
    leaderboards[name] = t1_rank
    print(f"\n{meta['label']}")
    print(f'anchor date: {anchor.date()}')
    print('T+1 move ranking:')
    print(t1_rank.round(2))
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">Hamas Attack on Israel (Oct 2023)
anchor date: 2023-10-09
T+1 move ranking:
vixy    3.35
iwm     1.13
xlf     0.73
ita     0.72
qqq     0.55
spy     0.52
uup     0.24
gld     0.17
xlk     0.15
tlt     0.14
xle     0.12
Name: 2023-10-10 00:00:00, dtype: float64

Yen Carry Unwind + Middle East Escalation (Aug 2024)
anchor date: 2024-08-05
T+1 move ranking:
vixy    20.52
tlt      2.24
xlf      1.62
xlk      1.36
iwm      1.09
qqq      0.96
spy      0.92
gld      0.80
xle      0.61
ita      0.57
uup      0.32
Name: 2024-08-06 00:00:00, dtype: float64

US-China Tariff Shock (Apr 2025)
anchor date: 2025-04-03
T+1 move ranking:
vixy    19.97
xle      9.20
ita      8.44
xlf      7.32
xlk      6.59
qqq      6.21
spy      5.85
iwm      4.46
gld      2.34
uup      1.11
tlt      1.09
Name: 2025-04-04 00:00:00, dtype: float64
</code></pre>
<p>VIXY leads all three events at T+1, which makes sense. Volatility reprices faster than anything else. But look past VIXY and the rankings diverge completely.</p>
<p>In the Hamas attack, moves were small across the board. The largest non-VIXY move was IWM at 1.13%. In the yen carry unwind, TLT was the second biggest mover at 2.24%, bonds bid hard as a safe haven. In the tariff shock, every equity sector moved 4% to 9% while TLT moved just 1.09%, and gold came in at 2.34%.</p>
<p>Three events with three completely different repricing hierarchies. The T+1 leaderboard alone tells you something meaningful about what each market was actually pricing.</p>
<p>Note on the Oct 7 anchor: the attack happened on a Saturday. The first trading day was Monday, October 9, which is why the anchor is Oct 9 rather than Oct 7. This matters for the skew analysis later.</p>
<h2 id="heading-options-data-and-iv-skew">Options Data and IV Skew</h2>
<p>Price data tells you what happened. Options data tells you what the market was willing to pay to protect against it.</p>
<p>The skew metric we compute here is straightforward: the difference between the average implied volatility of OTM puts (strikes at 90% to 97% of spot) and ATM calls (97% to 103% of spot). When this number rises, the market is paying a premium for downside protection relative to upside exposure. That is fear, quantified.</p>
<p>We pull SPY options data from <a href="https://eodhd.com/lp/us-stock-options-api">EODHD's options EOD endpoint</a>, paginating through the full dataset for each event window.</p>
<pre><code class="language-python">def fetch_options_all(ticker, start, end, exp_cap):
    url = 'https://eodhd.com/api/mp/unicornbay/options/eod'
    all_records = []
    offset = 0
    limit = 1000
    cols = None

    while True:
        params = {
            'filter[underlying_symbol]': ticker,
            'filter[tradetime_from]': start,
            'filter[tradetime_to]': end,
            'filter[exp_date_to]': exp_cap,
            'fields[options-eod]': 'type,exp_date,strike,volatility,tradetime',
            'page[limit]': limit,
            'page[offset]': offset,
            'api_token': api_key,
            'compact': 1
        }
        r = requests.get(url, params=params)
        payload = r.json()

        if 'meta' not in payload:
            print(f'unexpected response at offset {offset}: {list(payload.keys())}')
            break

        if cols is None:
            cols = [f.strip() for f in payload['meta']['fields']]

        batch = payload['data']
        all_records.extend(batch)

        total = payload['meta']['total']
        offset += limit
        if offset &gt;= total or not batch:
            break

    df = pd.DataFrame(all_records, columns=cols)
    df['tradetime'] = pd.to_datetime(df['tradetime'])
    df['exp_date'] = pd.to_datetime(df['exp_date'])
    df['strike'] = pd.to_numeric(df['strike'], errors='coerce')
    df['volatility'] = pd.to_numeric(df['volatility'], errors='coerce')
    return df.dropna(subset=['volatility', 'strike']).query('volatility &gt; 0')

def compute_skew(df, spot):
    df = df.copy()
    df['moneyness'] = df['strike'] / spot

    for expiry in sorted(df['exp_date'].unique()):
        sub = df[df['exp_date'] == expiry]
        otm_puts = sub[(sub['type'] == 'put') &amp; (sub['moneyness'].between(0.90, 0.97))]
        atm_calls = sub[(sub['type'] == 'call') &amp; (sub['moneyness'].between(0.97, 1.03))]
        if otm_puts.empty or atm_calls.empty:
            continue

        daily_skew = []
        for date, puts in otm_puts.groupby('tradetime'):
            calls = atm_calls[atm_calls['tradetime'] == date]
            if calls.empty:
                continue
            skew = puts['volatility'].mean() - calls['volatility'].mean()
            daily_skew.append({'date': date, 'skew': skew})

        if daily_skew:
            print(f'  using expiry: {expiry.date()}, {len(daily_skew)} days')
            return pd.DataFrame(daily_skew).set_index('date').sort_index()

    return pd.DataFrame()

spy_skew = {}

for name, meta in events.items():
    anchor = event_windows[name]['anchor']
    spot = event_prices[name].loc[anchor, 'spy']
    start = (anchor - pd.Timedelta(days=20)).strftime('%Y-%m-%d')
    end = (anchor + pd.Timedelta(days=5)).strftime('%Y-%m-%d')
    exp_cap = (pd.Timestamp(end) + pd.Timedelta(days=90)).strftime('%Y-%m-%d')
    raw = fetch_options_all('SPY', start, end, exp_cap)
    print(f'\n{meta["label"]} | total rows: {len(raw)}')
    skew_df = compute_skew(raw, spot)
    spy_skew[name] = skew_df
    print(skew_df)
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">Hamas Attack on Israel (Oct 2023) | total rows: 10435
  using expiry: 2023-11-17, 3 days
                skew
date                
2023-10-11  0.014164
2023-10-12  0.034279
2023-10-13  0.054055
unexpected response at offset 11000: ['errors']

Yen Carry Unwind + Middle East Escalation (Aug 2024) | total rows: 10660
  using expiry: 2024-10-18, 11 days
                skew
date                
2024-07-26  0.040748
2024-07-29  0.041219
2024-07-30  0.087402
2024-07-31  0.029824
2024-08-01  0.065074
2024-08-02  0.053369
2024-08-05  0.049848
2024-08-06  0.055957
2024-08-07  0.050664
2024-08-08  0.050283
2024-08-09  0.055462
unexpected response at offset 11000: ['errors']

US-China Tariff Shock (Apr 2025) | total rows: 10698
  using expiry: 2025-06-20, 18 days
                skew
date                
2025-03-14  0.042500
2025-03-17  0.029671
2025-03-18  0.027886
2025-03-19  0.029360
2025-03-20  0.026691
2025-03-21  0.008500
2025-03-24  0.013388
2025-03-25  0.022157
2025-03-26  0.012829
2025-03-27  0.009171
2025-03-28  0.026971
2025-03-31  0.036586
2025-04-01  0.022857
2025-04-02 -0.023000
2025-04-03  0.019729
2025-04-04  0.036729
2025-04-07  0.005257
2025-04-08  0.041543
</code></pre>
<p>A few observations worth noting before the event analysis. The Oct 7 dataset has only three data points, all post-event, due to limited options coverage for that period. The tariff shock dataset has the richest pre-event coverage, going back to March 14, nearly three weeks before the event. It also includes a negative skew reading on April 2, the day before the crash. We'll look at what each of these means in context when we get to the individual events.</p>
<h2 id="heading-composite-stress-score">Composite Stress Score</h2>
<p>The skew signal alone has a weakness: it can spike for reasons unrelated to geopolitical stress. To make it more robust, we combine it with a second signal: the rolling 10-day correlation between SPY and GLD.</p>
<p>Under normal conditions, equities and gold are weakly correlated or negatively correlated. When stress builds, that relationship breaks down. Tracking the breakdown gives us a second, independent measure of market stress that doesn't depend on options pricing.</p>
<p>Both signals are z-scored before combining, so neither dominates due to scale differences. The correlation signal is inverted since falling correlation means rising stress. The composite is the average of the two.</p>
<pre><code class="language-python">def build_composite(event_name, skew_df, event_prices_df, anchor):
    prices = event_prices_df[['spy', 'gld']].copy()
    prices['corr'] = prices['spy'].rolling(10).corr(prices['gld'])

    def zscore(s):
        return (s - s.mean()) / s.std()

    skew_z = zscore(skew_df['skew'])
    corr_z = zscore(prices['corr'].dropna())

    corr_z = corr_z * -1

    combined = pd.concat([skew_z.rename('skew_z'), corr_z.rename('corr_z')], axis=1).dropna()
    combined['composite'] = combined.mean(axis=1)

    combined['stress_flag'] = combined['composite'] &gt; 1.0

    return combined

composites = {}

for name, meta in events.items():
    anchor = event_windows[name]['anchor']
    skew_df = spy_skew[name]
    prices_df = event_prices[name]
    comp = build_composite(name, skew_df, prices_df, anchor)
    composites[name] = comp
    print(f"\n{meta['label']}")
    print(comp.round(3))
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">Hamas Attack on Israel (Oct 2023)
            skew_z  corr_z  composite  stress_flag
date                                              
2023-10-11  -1.003  -1.186     -1.094        False
2023-10-12   0.006  -1.316     -0.655        False
2023-10-13   0.997  -0.971      0.013        False

Yen Carry Unwind + Middle East Escalation (Aug 2024)
            skew_z  corr_z  composite  stress_flag
date                                              
2024-07-26  -0.808  -0.863     -0.835        False
2024-07-29  -0.776  -1.074     -0.925        False
2024-07-30   2.343  -0.559      0.892        False
2024-07-31  -1.546  -0.082     -0.814        False
2024-08-01   0.835   0.933      0.884        False
2024-08-02   0.044   2.117      1.081         True
2024-08-05  -0.194   1.977      0.892        False
2024-08-06   0.219   1.525      0.872        False
2024-08-07  -0.138   1.170      0.516        False
2024-08-08  -0.164   0.881      0.358        False
2024-08-09   0.186   0.371      0.278        False

US-China Tariff Shock (Apr 2025)
            skew_z  corr_z  composite  stress_flag
date                                              
2025-03-17   0.511   0.516      0.513        False
2025-03-18   0.398   0.493      0.445        False
2025-03-19   0.491   0.154      0.323        False
2025-03-20   0.322  -0.209      0.057        False
2025-03-21  -0.830  -1.023     -0.926        False
2025-03-24  -0.520  -0.999     -0.759        False
2025-03-25   0.035  -0.777     -0.371        False
2025-03-26  -0.556  -0.566     -0.561        False
2025-03-27  -0.787   0.096     -0.346        False
2025-03-28   0.340   1.093      0.716        False
2025-03-31   0.949   1.179      1.064         True
2025-04-01   0.080   1.309      0.694        False
2025-04-02  -2.824   1.190     -0.817        False
2025-04-03  -0.119   1.047      0.464        False
2025-04-04   0.958   0.119      0.539        False
2025-04-07  -1.035  -0.794     -0.915        False
2025-04-08   1.263  -1.274     -0.006        False
</code></pre>
<p>The stress flag threshold is set at 1.0. Two days get flagged across all three events: August 2, 2024, for the yen carry unwind, and March 31, 2025, for the tariff shock. Both are pre-event. The Oct 7 dataset is too sparse to produce a meaningful composite reading.</p>
<p>The Apr 2 row in the tariff shock is worth noting: <code>skew_z</code> of -2.824, the most negative skew reading in the entire dataset, pulling the composite negative despite the correlation signal remaining elevated. The options market was actively pricing more upside than downside on the day before the largest single-day SPY drop of 2025. That isn't a signal failure to brush past. We'll come back to it.</p>
<h2 id="heading-news-sentiment">News Sentiment</h2>
<p>The final data layer is news sentiment. <a href="https://eodhd.com/financial-apis/stock-market-financial-news-api">EODHD's sentiment API</a> generates a daily normalized score for each ticker derived from financial news coverage, ranging from -1 (strongly negative) to +1 (strongly positive). We pull SPY sentiment as a broad market proxy for the same windows used in the options analysis.</p>
<pre><code class="language-python">def fetch_sentiment(ticker, start, end):
    url = 'https://eodhd.com/api/sentiments'
    params = {
        's': ticker,
        'from': start,
        'to': end,
        'api_token': api_key,
        'fmt': 'json'
    }
    r = requests.get(url, params=params)
    data = r.json()
    key = ticker if ticker in data else ticker + '.US'
    if key not in data:
        return pd.DataFrame()
    df = pd.DataFrame(data[key])
    df['date'] = pd.to_datetime(df['date'])
    df = df.set_index('date')[['normalized']].rename(columns={'normalized': 'sentiment'})
    return df.sort_index()

event_sentiment = {}

for name, meta in events.items():
    anchor = event_windows[name]['anchor']
    start = (anchor - pd.Timedelta(days=20)).strftime('%Y-%m-%d')
    end = (anchor + pd.Timedelta(days=10)).strftime('%Y-%m-%d')
    sent_df = fetch_sentiment('SPY', start, end)
    event_sentiment[name] = sent_df
    print(f"\n{meta['label']}")
    print(sent_df)
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">Hamas Attack on Israel (Oct 2023)
            sentiment
date                 
2023-09-25      0.997
2023-09-26      0.986

Yen Carry Unwind + Middle East Escalation (Aug 2024)
            sentiment
date                 
2024-07-17     0.9340
2024-07-22     0.9460
2024-07-23     0.9550
2024-07-25     0.9925
2024-07-26     0.9860
2024-07-29     0.9850
2024-07-30     0.9630
2024-07-31     0.9950
2024-08-02     0.3350
2024-08-05     0.9780
2024-08-06     0.3603
2024-08-15     0.9980

US-China Tariff Shock (Apr 2025)
            sentiment
date                 
2025-03-14    -0.9890
2025-03-15     0.9930
2025-03-17    -0.7010
2025-03-18     0.9990
2025-03-20    -0.8900
2025-03-22     0.9950
2025-03-24     0.9600
2025-03-27     0.9830
2025-03-28     0.9917
2025-04-03     0.9365
2025-04-05     0.0130
2025-04-06     0.9990
2025-04-07     0.9870
2025-04-09     0.5460
2025-04-10     0.8079
2025-04-11     0.0929
2025-04-12    -0.9920
2025-04-13     0.0130
</code></pre>
<p>Two things stand out immediately. For the yen carry unwind, sentiment ranged between 0.934 and 0.995 from July 17 through July 31 while skew was already spiking on July 30 and the composite was building. Sentiment did not register the stress the options market was pricing. For the tariff shock, sentiment on April 3, the day SPY dropped 4.8%, was +0.9365. Strongly positive. The news cycle had no idea what was coming.</p>
<p>The October 7 sentiment data has only two data points from late September, both near +1.0. This predates the event by nearly two weeks and tells us nothing about market sentiment around the attack itself. Coverage is too thin for this event to contribute to the sentiment analysis.</p>
<h2 id="heading-event-1-hamas-attack-on-israel-oct-7-2023">Event 1: Hamas Attack on Israel, Oct 7 2023</h2>
<p>The Hamas attack on October 7, 2023, was a major geopolitical shock. The market's response was not.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/9c627253-1f58-40a1-a382-8f6fa44a1cab.png" alt="Event 1 repricing sequence chart (Image by Author)" style="display:block;margin:0 auto" width="1394" height="706" loading="lazy">

<p>SPY closed up 0.64% on October 9 relative to the October 6 close. The anchor is Monday, October 9, because the attack happened on a Saturday. GLD and TLT both rallied. VIXY spiked to a T+1 move of 3.35%, modest compared to the 20% readings in the other two events. Within two weeks, most assets had drifted back toward pre-event levels.</p>
<p>The market's interpretation was specific: this was a regional conflict with limited direct economic transmission. Israel is not a major oil supplier, not a critical trade partner, and not deeply embedded in global supply chains in a way that would reprice earnings expectations. The uncertainty was real. The economic consequence was not.</p>
<p>That distinction shows up clearly in the safe haven behavior. GLD and TLT both up, UUP flat, equities essentially unchanged. When gold and bonds rally together while equities hold, the market is expressing classic flight-to-safety. Money moved into defensive assets as insurance against uncertainty, not as a response to any fundamental repricing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/be567f7b-fefd-4310-b9f8-c742feecddac.png" alt="Event 1 IV skew chart" style="display:block;margin:0 auto" width="1000" height="427" loading="lazy">

<p>The skew data for this event is limited to three post-event days: October 11, 12, and 13. Skew climbed steadily from 0.014 to 0.054 over those three days, consistent with the market pricing of ongoing uncertainty in the days following the attack.</p>
<p>But because the attack happened on a weekend and EODHD's options coverage for this period is thin, there is no pre-event skew data. We can't say whether the options market anticipated this event.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/72d154a3-76d5-4d25-9eb9-b21c90611449.png" alt="Event 1 composite stress score chart" style="display:block;margin:0 auto" width="1549" height="690" loading="lazy">

<p>The composite is similarly sparse. Three data points, none flagged. There isn't enough data here to draw conclusions about early warning signals.</p>
<p>This is the weakest case study analytically. It stays in the analysis because the repricing fingerprint is informative and the contrast with the other two events is stark. The small moves, the clean flight-to-safety pattern, and the rapid recovery point to a specific kind of event: one where the market prices fear without pricing economic damage. That's a meaningful category even if the options data can't say more about it.</p>
<h2 id="heading-event-2-yen-carry-unwind-aug-5-2024">Event 2: Yen Carry Unwind, Aug 5 2024</h2>
<p>The August 2024 event is the most analytically rich of the three. It's also the one where the data most clearly supports the idea that structured market signals were pricing stress before the crash arrived.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/8f35d73b-963c-483c-ac2b-5353885f7c25.png" alt="Event 2 repricing sequence chart" style="display:block;margin:0 auto" width="1406" height="713" loading="lazy">

<p>The repricing sequence tells an immediate story. VIXY exploded to a T+1 move of 20.52%. TLT was the second biggest mover at 2.24%, bid hard as a safe haven. Equities sold off across the board.</p>
<p>This is what a liquidity shock looks like. The Bank of Japan raised rates unexpectedly on July 31, triggering a massive unwind of yen carry trades.</p>
<p>The selling wasn't driven by a change in economic fundamentals. It was driven by positioning. Traders who had borrowed cheaply in yen to buy higher-yielding assets were forced to sell those assets simultaneously to cover their positions. The correlation between assets broke down because everything was being sold for the same mechanical reason.</p>
<p>Now look at what the skew data was doing before any of this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/11ac0cd1-3a91-4a39-b879-d32c4498cce1.png" alt="Event 2 IV skew chart" style="display:block;margin:0 auto" width="1621" height="665" loading="lazy">

<p>On July 30, six days before the crash, skew spiked to 0.087. The highest reading in the entire pre-event window by a significant margin. It then compressed on July 31 before rising again on August 1 and 2. The crash hit on August 5.</p>
<p>That July 30 spike is the most important data point in this analysis. The BOJ rate decision that triggered the unwind came on July 31. The options market was pricing elevated downside risk the day before the trigger event, not after it. Someone, or more likely many someones, was paying up for SPY put protection before the news was public.</p>
<p>Now look at what sentiment was doing over the same period:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/2e437fa7-929e-4434-bd9f-ebd038e81f28.png" alt="Event 2 sentiment vs skew chart" style="display:block;margin:0 auto" width="1500" height="674" loading="lazy">

<p>From July 17 through July 31, sentiment held between 0.934 and 0.995. Near maximum bullishness, every single day. On July 30, the same day skew spiked to 0.087, sentiment was 0.963. The news cycle was not concerned. The options market was.</p>
<p>Sentiment finally dropped to 0.335 on August 2, three days after the skew spike and three days before the crash. By that point, the options market had already been signaling stress for nearly a week.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/b544bc56-9b85-4291-8bb7-f7007479a4a2.png" alt="Event 2 composite stress score chart" style="display:block;margin:0 auto" width="1000" height="456" loading="lazy">

<p>The composite flagged August 2 as a stress day, driven primarily by the correlation breakdown signal. The SPY/GLD rolling correlation had been deteriorating since late July as gold started decoupling from equities. The composite didn't catch the July 30 skew spike cleanly because the skew signal compressed the day after, pulling the z-score back down. But the combination of a spiking skew on July 30 and a flagged composite on August 2 gave a two-stage warning before the August 5 crash.</p>
<p>The yen carry unwind is the clearest case in this analysis for the thesis that structured market signals carry information that news sentiment does not. The options market wasn't prescient. But it was pricing something that the headlines weren't.</p>
<h2 id="heading-event-3-us-china-tariff-shock-apr-2025"><strong>Event 3: US-China Tariff Shock, Apr&nbsp;2025</strong></h2>
<p>The April 2025 tariff shock is the most interesting event in this analysis, not because the signals worked, but because of where they failed.</p>
<img src="https://cdn-images-1.medium.com/max/1500/1*SrxSkLqrxJoq4Hzhr2AS6g.png" alt="Event 3 repricing sequence chart" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The numbers are severe. SPY dropped 5.85% at T+1 and continued falling through T+3. Every equity sector moved between 4% and 9%. XLE led at 9.20%, reflecting the direct exposure of energy and trade-dependent sectors to tariff policy. ITA followed at 8.44%. Tech dropped 6.59%.</p>
<p>These aren't volatility moves. They're repricing moves, the market adjusting its estimate of what these companies are actually worth under a structurally different trade regime.</p>
<p>The safe haven behavior is the most diagnostic part of this chart. GLD rose 2.34% at T+1 and kept climbing in the days that followed. TLT moved only 1.09% at T+1 and then sold off. Bonds and equities fell together. There was no flight to bonds. The only clean safe haven was gold.</p>
<p>This is what distinguishes a structural shock from the other two event types. In a confidence shock, both gold and bonds rally. In a liquidity shock, bonds rally hard. In a structural shock, bonds offer no protection because the shock itself calls into question the fiscal and monetary outlook. Gold becomes the only asset without a counterparty.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/8c7eed1c-d470-4ee8-9654-d29c22686c59.png" alt="Event 3 IV skew chart" style="display:block;margin:0 auto" width="1000" height="430" loading="lazy">

<p>This is where the analysis gets genuinely uncomfortable.</p>
<p>On April 2, 2025, the day before the crash, skew was -0.023. Negative. ATM calls were more expensive than OTM puts. The options market wasn't pricing downside risk. It was pricing upside.</p>
<p>Skew had been elevated through mid-March, ranging from 0.025 to 0.042, then compressed steadily through late March. By the time the tariff announcement hit, the options market had actively de-risked its fear positioning.</p>
<p>There are two plausible explanations. The first is that the market had been pricing tariff risk as a negotiating tactic throughout March, then concluded by early April that a deal was likely. The negative skew on April 2 reflects collective confidence that the announced tariffs wouldn't materialize at full scale.</p>
<p>The second is that the options market simply didn't have the information. The tariff announcement on April 2 was more severe and more immediate than most participants expected.</p>
<p>Either way, the options market failed as an early warning signal here. This isn't a flaw in the methodology. It's a finding. Skew measures what market participants are willing to pay for protection. If participants have collectively decided a risk isn't worth pricing, skew won't warn you. That decision can be wrong.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/bcedafad-4f85-4833-ab28-7b92e496748e.png" alt="Event 3 composite stress score chart" style="display:block;margin:0 auto" width="1000" height="455" loading="lazy">

<p>The composite flagged March 31 as a stress day, three days before the crash. The signal came entirely from the correlation breakdown component, not the skew component. The SPY/GLD rolling correlation had been deteriorating through late March as gold climbed while equities softened. The composite picked up that decoupling even while skew was compressing.</p>
<p>On April 2, the composite dropped sharply to -0.817. The skew component had turned strongly negative, overwhelming the still-elevated correlation signal and flipping the composite well below zero. The composite effectively said no stress, just before the largest single-day SPY drop of 2025.</p>
<p>The tariff shock exposes a real limitation of any signal built on options pricing. When the market has collectively mispriced a risk, the signal will reflect that mispricing. The correlation breakdown component performed better here, but one signal out of two isn't a reliable composite.</p>
<h2 id="heading-putting-it-all-together-the-heatmap">Putting It All Together: The&nbsp;Heatmap</h2>
<p>The individual event analyses show three different stories. The heatmap puts them side by side so the differences are visible in one place.</p>
<pre><code class="language-python">fig = make_subplots(rows=1, cols=3,
    subplot_titles=[e['label'] for e in events.values()],
    horizontal_spacing=0.08)

for i, (name, meta) in enumerate(events.items()):
    window = event_windows[name]['window']
    anchor = event_windows[name]['anchor']
    anchor_idx = window.index.get_loc(anchor)

    start_i = max(anchor_idx - 3, 0)
    end_i = min(anchor_idx + 8, len(window))
    slice_df = window.iloc[start_i:end_i].copy()
    slice_df.columns = [c.upper() for c in slice_df.columns]

    anchor_pos = anchor_idx - start_i
    anchor_vals = slice_df.iloc[anchor_pos]
    pct_df = ((slice_df - anchor_vals) / anchor_vals * 100).round(2)

    n_days = len(pct_df)
    t_labels = [f'T{d:+d}' for d in range(-anchor_pos, -anchor_pos + n_days)]

    fig.add_trace(go.Heatmap(
        z=pct_df.values.T,
        x=t_labels,
        y=list(pct_df.columns),
        colorscale='RdYlGn',
        zmid=0,
        zmin=-15,
        zmax=15,
        showscale=(i == 2),
        colorbar=dict(title='% return from T0')
    ), row=1, col=i+1)

fig.update_layout(
    title='Asset Return Heatmap - T-3 to T+7 across Events',
    template='plotly_dark',
    height=500
)

for annotation in fig['layout']['annotations']:
    annotation['font'] = dict(size=11)
    annotation['y'] = 1.02
    
fig.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/2b37a32b-6c70-4d30-a9b8-3fbdddd80a19.png" alt="Asset return heatmap" style="display:block;margin:0 auto" width="1545" height="759" loading="lazy">

<p>Three panels, one per event, each showing percentage returns relative to the event date from T-3 to T+7. Green means the asset gained relative to T0. Red means it lost. The color scale is capped at plus or minus 15%, so the tariff shock’s extreme moves don't wash out the smaller Oct 7 moves.</p>
<p>The VIXY row tells different stories depending on the event. In the Hamas attack and tariff shock, it spikes green post-event as volatility surged above its T0 level. In the yen carry unwind, the row is deep red throughout, not because volatility didn't spike but because VIXY was already at its highest point on August 5, the anchor date, making everything relative to T0 look flat or negative.</p>
<p>Look at the GLD row. In the Hamas attack, it stays near neutral, a minimal safe haven response. In the yen carry unwind, it turns green post-event as forced selling cleared and gold recovered. In the tariff shock, it turns deeply green and stays there, the strongest and most sustained move of any asset across the three events.</p>
<p>The TLT row shows the starkest contrast. Near neutral in the Hamas attack, clearly green in the yen carry unwind as bonds rallied hard, and near neutral to slightly negative in the tariff shock. Bonds were a reliable safe haven in one event and offered almost nothing in the other two.</p>
<p>The equity rows tell the scale story. In the Hamas attack, the colors are pale, with small moves in both directions. In the yen carry unwind, they're moderately red before recovering to green. In the tariff shock, they are deep red across every sector from T0 through T+3, the kind of uniform selloff that happens when the market is repricing fundamentals, not just pricing fear.</p>
<p>This is what the taxonomy looks like in data form. Three events, three fingerprints, and three different markets responding to three different things that all got filed under the same label.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>The three events in this analysis all got the same label. But the data gave them three different ones.</p>
<p>A confidence shock prices fear without pricing economic damage. Gold and bonds rally, equities hold, recovery is faster than it feels.</p>
<p>A liquidity shock is mechanical: everything sells off because positioning unwinds, not because fundamentals changed.</p>
<p>A structural shock reprices what companies are actually worth under a different economic regime. Bonds offer no protection. Gold is the only clean hedge. Recovery timeline is unknown.</p>
<p>The IV skew and correlation composite built here using EODHD’s historical and options data worked cleanly for one event, partially for another, and failed for the third. That's not a reason to dismiss the signals. It's a reason to understand what they measure. Skew reflects what participants are paying for downside protection. When the market has collectively decided a risk isn't worth pricing, skew goes quiet. That silence isn't safety.</p>
<p>The most useful output of this framework isn't a signal. It's a question: what kind of shock is this? The answer changes everything that follows.</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>
        
            <item>
                <title>
                    <![CDATA[ Beyond NVIDIA: Where the AI Infra Trade Actually Shows Up ]]>
                </title>
                <description>
                    <![CDATA[ The AI capex trade is usually discussed like one clean idea. Capex simply means capital expenditure, or the money companies spend on long-term assets like data centers, chips, servers, power systems,  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/beyond-nvidia-where-the-ai-infra-trade-actually-shows-up/</link>
                <guid isPermaLink="false">6a1a129da369e7c9ad1b3aa9</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ infrastructure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stocks ]]>
                    </category>
                
                    <category>
                        <![CDATA[ NVIDIA ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Fri, 29 May 2026 22:26:37 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/31d12d22-a89b-44c1-8786-ef568be6e6b8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The AI capex trade is usually discussed like one clean idea. Capex simply means capital expenditure, or the money companies spend on long-term assets like data centers, chips, servers, power systems, and other infrastructure.</p>
<p>NVIDIA. Hyperscalers. Data centers. Power demand. Everything gets pushed into the same bucket and called "AI infrastructure."</p>
<p>But I don't think this is very useful anymore.</p>
<p>Capex doesn't move through the market as a headline. It moves through a chain. A cloud company decides to spend more on AI infrastructure, but that spending has to pass through chips, semiconductor equipment, servers, networking, data centers, power systems, cooling, and construction before it becomes usable compute.</p>
<p>That's where the story gets more interesting.</p>
<p>The obvious AI names still matter, but they're not the whole map. If AI capex is becoming one of the biggest investment cycles in the market, then the better question isn't just:</p>
<blockquote>
<p><em>"Which companies are AI stocks?"</em></p>
</blockquote>
<p>It's actually:</p>
<blockquote>
<p><em>"Where does the money actually travel?"</em></p>
</blockquote>
<p>In this article, we'll use Python and <a href="https://eodhd.com/">EODHD</a> data to build a simple AI capex map. The goal isn't to create a buy list. The goal is to separate the theme into layers, compare fundamentals with market recognition, and see where the AI infrastructure trade is already showing up in the data.</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-what-were-investigating">What We're Investigating</a></p>
</li>
<li><p><a href="#heading-import-the-required-packages">Import the Required Packages</a></p>
</li>
<li><p><a href="#heading-building-the-ai-capex-universe">Building the AI Capex Universe</a></p>
</li>
<li><p><a href="#heading-pulling-the-financial-data-behind-the-story">Pulling the Financial Data Behind the Story</a></p>
<ul>
<li><p><a href="#heading-fundamentals-data">Fundamentals Data</a></p>
</li>
<li><p><a href="#heading-historical-prices-data">Historical Prices Data</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-separating-business-strength-from-market-recognition">Separating Business Strength from Market Recognition</a></p>
<ul>
<li><p><a href="#heading-fundamental-signal">Fundamental Signal</a></p>
</li>
<li><p><a href="#heading-market-recognition-signal">Market Recognition Signal</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-ai-capex-matrix-where-the-trade-actually-shows-up">The AI Capex Matrix: Where the Trade Actually Shows Up</a></p>
</li>
<li><p><a href="#which-ai-infrastructure-layers-has-the-market-rewarded-most">Which AI Infrastructure Layers Has the Market Rewarded Most?</a></p>
</li>
<li><p><a href="#heading-the-physical-infrastructure-layer-is-no-longer-hidden">The Physical Infrastructure Layer Is No Longer Hidden</a></p>
</li>
<li><p><a href="#heading-what-the-market-has-already-noticed">What the Market Has Already Noticed</a></p>
</li>
<li><p><a href="#heading-what-this-study-shows">What This Study Shows</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before following along, you should be comfortable with basic Python, especially working with dictionaries, lists, functions, and pandas DataFrames.</p>
<p>You’ll also need:</p>
<ul>
<li><p>Python 3.9 or later</p>
</li>
<li><p>An EODHD 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 financial metrics like revenue growth, profit margin, P/E ratio, stock returns, volatility, and drawdown</p>
</li>
</ul>
<p>You don’t need advanced finance knowledge for this article. The goal is to show how data visualization can help map a market theme, not to build a complete valuation model or stock recommendation engine.</p>
<h2 id="heading-what-were-investigating">What We're Investigating</h2>
<p>The lazy version of this article would be a list of AI stocks.</p>
<p>That's not what I want to do here.</p>
<p>The more useful approach is to treat AI capex as a spending chain and ask where each part of that chain appears in the market.</p>
<p>A company selling GPUs is exposed to the theme in one way. A company building electrical systems for data centers is exposed in a completely different way. Both can benefit from the same capex cycle, but the economics, margins, valuation, and market behavior may look very different.</p>
<p>So the investigation has three parts.</p>
<p>First, we'll create a working AI infrastructure universe across layers like chips, semiconductor equipment, servers, networking, data centers, power, cooling, and construction.</p>
<p>Second, we'll pull fundamentals and price data from EODHD to measure two things:</p>
<ul>
<li><p><strong>Fundamental signal:</strong> Is the business showing growth and profitability?</p>
</li>
<li><p><strong>Market recognition signal:</strong> Has the stock already been rewarded by the market?</p>
</li>
</ul>
<p>Third, we'll map the companies into a matrix and look for patterns.</p>
<p>The main output isn't a ranking of the "best AI infrastructure stocks." It's a clearer view of where the AI capex trade has already shown up, where it looks concentrated, and where the physical infrastructure layer starts becoming hard to ignore.</p>
<h2 id="heading-import-the-required-packages">Import the Required Packages</h2>
<p>We'll keep the setup light. This is an analysis notebook, not a production system.</p>
<pre><code class="language-python">import requests
import pandas as pd
import numpy as np
from datetime import date, timedelta
import matplotlib.pyplot as plt
</code></pre>
<p>These packages cover everything we need here.</p>
<p><code>requests</code> will call the EODHD API, <code>pandas</code> will handle the tables, and <code>numpy</code> will help with basic calculations. We'll use <code>date</code> and <code>timedelta</code> for the one-year price window, and <code>matplotlib</code> for the charts.</p>
<h2 id="heading-building-the-ai-capex-universe">Building the AI Capex Universe</h2>
<p>There's one issue with analyzing AI infrastructure stocks: AI capex exposure isn't a clean financial field.</p>
<p>No API directly tells us that a company is "30% exposed to AI data center spending" or "highly tied to GPU infrastructure." So we need a research universe first.</p>
<p>For this article, I used an LLM as a research assistant to draft the first version of the AI capex chain, then manually reviewed the companies before pulling fundamentals and price data from EODHD.</p>
<p>The universe is split into layers:</p>
<ul>
<li><p>Demand-side hyperscalers</p>
</li>
<li><p>AI compute and chips</p>
</li>
<li><p>Semiconductor equipment</p>
</li>
<li><p>Servers and storage</p>
</li>
<li><p>Networking</p>
</li>
<li><p>Data centers</p>
</li>
<li><p>Power and electrification</p>
</li>
<li><p>Cooling and industrial systems</p>
</li>
<li><p>Construction and engineering</p>
</li>
</ul>
<pre><code class="language-python">ai_capex_universe = [
    {'ticker': 'MSFT.US', 'company': 'Microsoft', 'capex_layer': 'Demand-side hyperscalers', 'exposure_level': 'High', 'reason': 'Major cloud and AI infrastructure spender through Azure'},
    {'ticker': 'AMZN.US', 'company': 'Amazon', 'capex_layer': 'Demand-side hyperscalers', 'exposure_level': 'High', 'reason': 'Large AI and cloud infrastructure spender through AWS'},
    {'ticker': 'GOOGL.US', 'company': 'Alphabet', 'capex_layer': 'Demand-side hyperscalers', 'exposure_level': 'High', 'reason': 'Major AI infrastructure spender across Google Cloud and internal AI systems'},
    {'ticker': 'META.US', 'company': 'Meta Platforms', 'capex_layer': 'Demand-side hyperscalers', 'exposure_level': 'High', 'reason': 'Large AI compute and data center spending program'},

    {'ticker': 'NVDA.US', 'company': 'NVIDIA', 'capex_layer': 'AI compute and chips', 'exposure_level': 'Very High', 'reason': 'Core GPU and accelerator supplier for AI training and inference'},
    {'ticker': 'AMD.US', 'company': 'Advanced Micro Devices', 'capex_layer': 'AI compute and chips', 'exposure_level': 'High', 'reason': 'AI accelerator and data center CPU exposure'},
    {'ticker': 'AVGO.US', 'company': 'Broadcom', 'capex_layer': 'AI compute and chips', 'exposure_level': 'High', 'reason': 'Custom silicon and networking exposure for AI infrastructure'},
    {'ticker': 'MRVL.US', 'company': 'Marvell Technology', 'capex_layer': 'AI compute and chips', 'exposure_level': 'High', 'reason': 'Custom silicon, networking, and data infrastructure exposure'},

    {'ticker': 'AMAT.US', 'company': 'Applied Materials', 'capex_layer': 'Semiconductor equipment', 'exposure_level': 'High', 'reason': 'Supplies equipment used in advanced chip manufacturing'},
    {'ticker': 'LRCX.US', 'company': 'Lam Research', 'capex_layer': 'Semiconductor equipment', 'exposure_level': 'High', 'reason': 'Semiconductor manufacturing equipment supplier'},
    {'ticker': 'KLAC.US', 'company': 'KLA', 'capex_layer': 'Semiconductor equipment', 'exposure_level': 'High', 'reason': 'Process control and inspection tools for chip manufacturing'},
    {'ticker': 'ASML.US', 'company': 'ASML', 'capex_layer': 'Semiconductor equipment', 'exposure_level': 'Very High', 'reason': 'Critical lithography equipment supplier for advanced chips'},

    {'ticker': 'DELL.US', 'company': 'Dell Technologies', 'capex_layer': 'Servers and storage', 'exposure_level': 'High', 'reason': 'AI server and enterprise hardware exposure'},
    {'ticker': 'HPE.US', 'company': 'Hewlett Packard Enterprise', 'capex_layer': 'Servers and storage', 'exposure_level': 'Medium', 'reason': 'Server, storage, and enterprise infrastructure exposure'},
    {'ticker': 'SMCI.US', 'company': 'Super Micro Computer', 'capex_layer': 'Servers and storage', 'exposure_level': 'High', 'reason': 'AI server systems and data center hardware exposure'},

    {'ticker': 'ANET.US', 'company': 'Arista Networks', 'capex_layer': 'Networking', 'exposure_level': 'High', 'reason': 'Data center networking supplier tied to AI cluster buildouts'},
    {'ticker': 'CSCO.US', 'company': 'Cisco', 'capex_layer': 'Networking', 'exposure_level': 'Medium', 'reason': 'Networking and enterprise infrastructure exposure'},

    {'ticker': 'EQIX.US', 'company': 'Equinix', 'capex_layer': 'Data centers', 'exposure_level': 'Medium', 'reason': 'Global data center and interconnection infrastructure'},
    {'ticker': 'DLR.US', 'company': 'Digital Realty', 'capex_layer': 'Data centers', 'exposure_level': 'Medium', 'reason': 'Data center real estate exposure'},

    {'ticker': 'VRT.US', 'company': 'Vertiv', 'capex_layer': 'Power and electrification', 'exposure_level': 'High', 'reason': 'Power and thermal infrastructure for data centers'},
    {'ticker': 'ETN.US', 'company': 'Eaton', 'capex_layer': 'Power and electrification', 'exposure_level': 'Medium', 'reason': 'Electrical systems and power management exposure'},
    {'ticker': 'PWR.US', 'company': 'Quanta Services', 'capex_layer': 'Power and electrification', 'exposure_level': 'Medium', 'reason': 'Grid, power, and infrastructure construction exposure'},
    {'ticker': 'CEG.US', 'company': 'Constellation Energy', 'capex_layer': 'Power and electrification', 'exposure_level': 'Medium', 'reason': 'Power demand beneficiary from data center expansion'},

    {'ticker': 'TT.US', 'company': 'Trane Technologies', 'capex_layer': 'Cooling and industrial systems', 'exposure_level': 'Medium', 'reason': 'Cooling and climate systems exposure for buildings and infrastructure'},
    {'ticker': 'CARR.US', 'company': 'Carrier Global', 'capex_layer': 'Cooling and industrial systems', 'exposure_level': 'Medium', 'reason': 'Cooling, HVAC, and infrastructure systems exposure'},
    {'ticker': 'JCI.US', 'company': 'Johnson Controls', 'capex_layer': 'Cooling and industrial systems', 'exposure_level': 'Medium', 'reason': 'Building systems, controls, and cooling infrastructure exposure'},

    {'ticker': 'EME.US', 'company': 'EMCOR Group', 'capex_layer': 'Construction and engineering', 'exposure_level': 'Medium', 'reason': 'Electrical and mechanical construction exposure'},
    {'ticker': 'FIX.US', 'company': 'Comfort Systems USA', 'capex_layer': 'Construction and engineering', 'exposure_level': 'Medium', 'reason': 'Mechanical and electrical services for commercial infrastructure'}
]

universe = pd.DataFrame(ai_capex_universe)

universe.head()
</code></pre>
<p>This gives us the research universe.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/3c82c07a-f9fa-4d23-aba7-c17d62158589.png" alt="AI capex stock universe (Image by Author)" style="display:block;margin:0 auto" width="1500" height="325" loading="lazy">

<p>The important thing is that this table doesn't prove anything by itself. It only defines the map. The actual comparison comes from the fundamentals and historical price data we pull next.</p>
<h2 id="heading-pulling-the-financial-data-behind-the-story">Pulling the Financial Data Behind the Story</h2>
<p>The universe gives us the map, but the map is not the analysis.</p>
<p>Now we need actual data behind each company. For that, we'll use EODHD fundamentals and historical prices.</p>
<p>The fundamentals help us check business strength. The price data helps us see whether the market has already recognized the company as part of the AI capex trade.</p>
<h3 id="heading-fundamentals-data">Fundamentals Data</h3>
<p>First, we'll pull fundamentals using <a href="https://eodhd.com/lp/fundamental-data-api">EODHD's fundamentals endpoint</a>.</p>
<pre><code class="language-python">api_key = 'YOUR EODHD API KEY'

def get_fundamentals(ticker):
    url = f'https://eodhd.com/api/fundamentals/{ticker}?api_token={api_key}&amp;fmt=json'
    data = requests.get(url).json()
    return data
</code></pre>
<p><strong>Note:</strong> Replace <code>YOUR EODHD API KEY</code> with your actual EODHD API key.</p>
<p>This function calls the fundamentals endpoint for one ticker and returns the full JSON response.</p>
<p>We don't need the entire response for this analysis, so we'll extract only the fields we care about.</p>
<pre><code class="language-python">def extract_fundamental_fields(ticker, data):
    general = data.get('General', {})
    highlights = data.get('Highlights', {})
    valuation = data.get('Valuation', {})
    technicals = data.get('Technicals', {})

    return {
        'ticker': ticker,
        'sector': general.get('Sector'),
        'industry': general.get('Industry'),
        'market_cap': highlights.get('MarketCapitalization'),
        'revenue_growth_yoy': highlights.get('QuarterlyRevenueGrowthYOY'),
        'profit_margin': highlights.get('ProfitMargin'),
        'operating_margin': highlights.get('OperatingMarginTTM'),
        'return_on_equity': highlights.get('ReturnOnEquityTTM'),
        'pe_ratio': highlights.get('PERatio'),
        'forward_pe': valuation.get('ForwardPE'),
        'beta': technicals.get('Beta')
    }
</code></pre>
<p>These fields give us a compact view of growth, profitability, valuation, and company context.</p>
<p>Now we can run this across the full universe.</p>
<pre><code class="language-python">fundamental_rows = []

for ticker in universe['ticker']:
    try:
        data = get_fundamentals(ticker)
        row = extract_fundamental_fields(ticker, data)
        fundamental_rows.append(row)
        print(f'{ticker} DONE')

    except Exception as e:
        fundamental_rows.append({
            'ticker': ticker,
            'sector': np.nan,
            'industry': np.nan,
            'market_cap': np.nan,
            'revenue_growth_yoy': np.nan,
            'profit_margin': np.nan,
            'operating_margin': np.nan,
            'return_on_equity': np.nan,
            'pe_ratio': np.nan,
            'forward_pe': np.nan,
            'beta': np.nan
        })
        print(f'{ticker} ERROR')

fundamentals = pd.DataFrame(fundamental_rows)

fundamentals.head()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/c3430710-8978-4739-9217-b8b729d17c68.png" alt="Fundamentals Data (Image by Author)" style="display:block;margin:0 auto" width="1500" height="353" loading="lazy">

<p>The try block keeps the scan moving if one ticker fails. That matters because this universe mixes different types of companies, and one missing response should not break the whole analysis.</p>
<h3 id="heading-historical-prices-data">Historical Prices Data</h3>
<p>Next, we'll pull one year of historical prices using <a href="https://eodhd.com/lp/historical-eod-api">EODHD's historical end-of-day prices endpoint</a>.</p>
<pre><code class="language-python">price_start = date.today() - timedelta(days=365)
price_end = date.today()

def get_price_history(ticker):
    url = f'https://eodhd.com/api/eod/{ticker}?api_token={api_key}&amp;fmt=json&amp;from={price_start.isoformat()}&amp;to={price_end.isoformat()}&amp;period=d'
    data = requests.get(url).json()
    prices = pd.DataFrame(data)

    if prices.empty:
        return pd.DataFrame()

    prices['date'] = pd.to_datetime(prices['date'], errors='coerce')
    prices['adjusted_close'] = pd.to_numeric(prices['adjusted_close'], errors='coerce')

    prices = prices.dropna(subset=['date', 'adjusted_close'])
    prices = prices.sort_values('date').reset_index(drop=True)

    return prices[['date', 'adjusted_close']]
</code></pre>
<p>We use adjusted close because it's cleaner for return calculations after splits and dividends.</p>
<p>Now we'll convert the price history into a few market signals.</p>
<pre><code class="language-python">def calculate_market_signals(prices):
    if prices.empty or len(prices) &lt; 60:
        return {
            'return_1y': np.nan,
            'return_6m': np.nan,
            'return_3m': np.nan,
            'volatility_1y': np.nan,
            'max_drawdown_1y': np.nan
        }

    prices = prices.copy()
    prices['daily_return'] = prices['adjusted_close'].pct_change()

    latest_close = prices['adjusted_close'].iloc[-1]

    return_1y = (latest_close / prices['adjusted_close'].iloc[0]) - 1
    return_6m = (latest_close / prices['adjusted_close'].iloc[-126]) - 1 if len(prices) &gt;= 126 else np.nan
    return_3m = (latest_close / prices['adjusted_close'].iloc[-63]) - 1 if len(prices) &gt;= 63 else np.nan

    volatility_1y = prices['daily_return'].std() * np.sqrt(252)

    running_high = prices['adjusted_close'].cummax()
    drawdown = (prices['adjusted_close'] / running_high) - 1
    max_drawdown_1y = drawdown.min()

    return {
        'return_1y': return_1y,
        'return_6m': return_6m,
        'return_3m': return_3m,
        'volatility_1y': volatility_1y,
        'max_drawdown_1y': max_drawdown_1y
    }
</code></pre>
<p>These signals tell us how strongly the market has already responded to each company.</p>
<p>Now we run the same logic for every ticker.</p>
<pre><code class="language-python">market_rows = []

for ticker in universe['ticker']:
    try:
        prices = get_price_history(ticker)
        signals = calculate_market_signals(prices)
        signals['ticker'] = ticker
        market_rows.append(signals)
        print(f'{ticker} DONE')

    except Exception:
        market_rows.append({
            'ticker': ticker,
            'return_1y': np.nan,
            'return_6m': np.nan,
            'return_3m': np.nan,
            'volatility_1y': np.nan,
            'max_drawdown_1y': np.nan
        })
        print(f'{ticker} ERROR')

market_signals = pd.DataFrame(market_rows)

market_signals.head()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/3f270de4-2a76-4419-99e6-f6ab322b66f6.png" alt="Market Signals (Image by Author)" style="display:block;margin:0 auto" width="1000" height="311" loading="lazy">

<p>Finally, we merge the universe, fundamentals, and market signals into one dataset.</p>
<pre><code class="language-python">capex_data = universe.merge(fundamentals, on='ticker', how='left')
capex_data = capex_data.merge(market_signals, on='ticker', how='left')

print(capex_data.columns)
capex_data.head()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/eee12f1d-846a-4d08-a968-e2cec4b538a9.png" alt="Capex data columns (Image by Author)" style="display:block;margin:0 auto" width="1000" height="173" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5ba2a827-f946-4347-a914-f5c30c84cba0.png" alt="Capex data (Image by Author)" style="display:block;margin:0 auto" width="1500" height="643" loading="lazy">

<h2 id="heading-separating-business-strength-from-market-recognition">Separating Business Strength from Market Recognition</h2>
<p>Now comes the part that makes the analysis useful.</p>
<p>If we only look at stock returns, we end up chasing what already moved. If we only look at fundamentals, we miss how the market is actually treating the theme.</p>
<p>So I split the analysis into two simple signals:</p>
<ul>
<li><p><strong>Fundamental Signal:</strong> is the business showing growth and profitability?</p>
</li>
<li><p><strong>Market Recognition Signal:</strong> has the market already rewarded the stock?</p>
</li>
</ul>
<p>First, we need a helper function to normalize each metric.</p>
<pre><code class="language-python">def min_max_score(series):
    series = pd.to_numeric(series, errors='coerce')

    if series.isna().all():
        return pd.Series(0, index=series.index)

    min_val = series.min()
    max_val = series.max()

    if min_val == max_val:
        return pd.Series(0.5, index=series.index)

    return (series - min_val) / (max_val - min_val)
</code></pre>
<p>This brings every metric into a 0 to 1 range, so growth, margins, returns, and drawdowns can be compared without mixing raw scales.</p>
<h3 id="heading-fundamental-signal">Fundamental Signal</h3>
<p>Now we build the fundamental signal.</p>
<pre><code class="language-python">capex_data['revenue_growth_score'] = min_max_score(capex_data['revenue_growth_yoy'])
capex_data['profit_margin_score'] = min_max_score(capex_data['profit_margin'])
capex_data['operating_margin_score'] = min_max_score(capex_data['operating_margin'])
capex_data['roe_score'] = min_max_score(capex_data['return_on_equity'])

capex_data['fundamental_signal'] = (
    capex_data['revenue_growth_score'] * 0.35 +
    capex_data['operating_margin_score'] * 0.30 +
    capex_data['profit_margin_score'] * 0.20 +
    capex_data['roe_score'] * 0.15
) * 100

capex_data['fundamental_signal'] = capex_data['fundamental_signal'].round(2)
capex_data[['ticker', 'company', 'capex_layer', 'revenue_growth_yoy', 'operating_margin', 'profit_margin', 'return_on_equity', 'fundamental_signal']].sort_values('fundamental_signal', ascending=False).head(10)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/fcbea8d5-dd09-422d-910d-db0d54df9b80.png" alt="Fundamental signal (Image by Author)" style="display:block;margin:0 auto" width="1500" height="452" loading="lazy">

<p>This signal isn't trying to crown the best company. It's just checking whether the business data supports the AI capex story.</p>
<p>In my run, NVIDIA clearly stood out because its revenue growth and margins were on a different level. But the interesting part was not only NVIDIA. Names like KLA, Arista, Broadcom, Microsoft, Meta, Lam Research, Alphabet, and Super Micro also appeared near the top for different reasons.</p>
<p>That already tells us something important: the AI capex chain has different types of winners. Some are high-margin platform businesses. Some are semiconductor equipment names. Some are high-growth hardware names with thinner margins.</p>
<h3 id="heading-market-recognition-signal">Market Recognition Signal</h3>
<p>Now we build the market recognition signal.</p>
<pre><code class="language-python">capex_data['return_1y_score'] = min_max_score(capex_data['return_1y'])
capex_data['return_6m_score'] = min_max_score(capex_data['return_6m'])
capex_data['return_3m_score'] = min_max_score(capex_data['return_3m'])
capex_data['drawdown_score'] = min_max_score(capex_data['max_drawdown_1y'])

capex_data['market_recognition_signal'] = (
    capex_data['return_1y_score'] * 0.40 +
    capex_data['return_6m_score'] * 0.30 +
    capex_data['return_3m_score'] * 0.20 +
    capex_data['drawdown_score'] * 0.10
) * 100

capex_data['market_recognition_signal'] = capex_data['market_recognition_signal'].round(2)
capex_data[['ticker','company','capex_layer','return_1y','return_6m','return_3m','max_drawdown_1y','market_recognition_signal']].sort_values('market_recognition_signal', ascending=False).head(10)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/03f91e5a-e63f-49db-95ec-4f718c0c25f0.png" alt="Market recognition signal (Image by Author)" style="display:block;margin:0 auto" width="1500" height="479" loading="lazy">

<p>This is where the story gets more interesting.</p>
<p>The market recognition list wasn't just filled with hyperscalers or chip names. Comfort Systems, Vertiv, Quanta Services, Dell, Applied Materials, and Lam Research showed up strongly. That is the first clear sign that the AI capex trade is spreading into the physical infrastructure layer, not staying locked inside the usual mega-cap AI basket.</p>
<h2 id="heading-the-ai-capex-matrix-where-the-trade-actually-shows-up">The AI Capex Matrix: Where the Trade Actually Shows Up</h2>
<p>At this point, we have two separate lenses.</p>
<ul>
<li><p>The fundamental signal tells us whether the business looks strong.</p>
</li>
<li><p>The market recognition signal tells us whether the stock has already been rewarded.</p>
</li>
</ul>
<p>Now we can put both on the same chart.</p>
<pre><code class="language-python">plt.figure(figsize=(12, 8))

plot_data = capex_data.dropna(
    subset=['market_recognition_signal', 'fundamental_signal', 'market_cap']
).copy()

plot_data['bubble_size'] = np.sqrt(plot_data['market_cap']) / 5000

for layer in plot_data['capex_layer'].unique():
    layer_data = plot_data[plot_data['capex_layer'] == layer]

    plt.scatter(
        layer_data['market_recognition_signal'],
        layer_data['fundamental_signal'],
        s=layer_data['bubble_size'],
        alpha=0.6,
        label=layer
    )

for _, row in plot_data.iterrows():
    if row['market_recognition_signal'] &gt; 55 or row['fundamental_signal'] &gt; 45:
        plt.text(row['market_recognition_signal'] + 0.8, row['fundamental_signal'] + 0.8, row['ticker'].replace('.US', ''), fontsize=10)

plt.axvline(plot_data['market_recognition_signal'].median(), linestyle='--', linewidth=1)
plt.axhline(plot_data['fundamental_signal'].median(), linestyle='--', linewidth=1)

plt.text(median_market + 2, median_fundamental + 55, 'Strong fundamentals,\nmore recognized',fontsize=10)
plt.text(4, median_fundamental + 55,'Strong fundamentals,\nless recognized',fontsize=10)
plt.text(median_market + 2, 4, 'High market recognition,\nweaker fundamentals',fontsize=10)
plt.text(4, 4, 'Less clear in this framework', fontsize=10)

plt.title('AI Capex Matrix: Fundamentals vs Market Recognition')
plt.xlabel('Market Recognition Signal')
plt.ylabel('Fundamental Signal')
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
plt.tight_layout()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/ece6d0b1-a270-4e68-9220-d2b50064e691.png" alt="AI Capex Matrix: Fundamentals vs Market Recognition (Image by Author)" style="display:block;margin:0 auto" width="1298" height="866" loading="lazy">

<p>This is the most useful chart in the study.</p>
<p>It makes one thing clear: AI capex doesn't show up in one clean cluster.</p>
<p>NVIDIA is the obvious fundamental outlier. That makes sense. Its growth and margins are difficult to compare with almost anything else in the universe.</p>
<p>But the right side of the chart is where the broader story starts. AMD, Marvell, Vertiv, Comfort Systems, Dell, Lam Research, Applied Materials, and Quanta Services show stronger market recognition. That is a very different mix of companies. Some are chip-related. Some are equipment-related. Some are physical infrastructure names.</p>
<p>That matters because it shows the market isn't only rewarding the most obvious AI companies. It's also rewarding the companies that help turn AI capex into actual infrastructure.</p>
<p>This is the main shift in the article: the AI capex trade starts looking less like a tech basket and more like a buildout chain.</p>
<h2 id="heading-which-ai-infrastructure-layers-has-the-market-rewarded-most">Which AI Infrastructure Layers Has the Market Rewarded Most?</h2>
<p>The matrix is useful at the company level. But the AI capex trade also needs to be viewed by layer.</p>
<p>So next, I grouped the companies by <code>capex_layer</code> and calculated median returns and median signal scores.</p>
<pre><code class="language-python">layer_performance = capex_data.groupby('capex_layer').agg(
    company_count=('ticker', 'count'),
    median_return_1y=('return_1y', 'median'),
    median_return_6m=('return_6m', 'median'),
    median_fundamental_signal=('fundamental_signal', 'median'),
    median_market_recognition=('market_recognition_signal', 'median')
).reset_index()

layer_performance = layer_performance.sort_values('median_return_1y', ascending=False)

layer_performance
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/bfd9fa16-a5e8-4a30-ada3-d707522cdd81.png" alt="Layer performance summary (Image by Author)" style="display:block;margin:0 auto" width="1500" height="433" loading="lazy">

<p>Then I plotted the median one-year return by infrastructure layer.</p>
<pre><code class="language-python">plt.figure(figsize=(11, 6))

plt.barh(layer_performance['capex_layer'], layer_performance['median_return_1y'] * 100)

plt.gca().invert_yaxis()

plt.title('Median 1Y Return by AI Infrastructure Layer', fontsize=14, pad=12)
plt.xlabel('Median 1Y Return (%)')
plt.ylabel('')

plt.grid(axis='x', alpha=0.25)

plt.tight_layout()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/ed630cda-e8ff-43e3-aa10-a95a9b210a6a.png" alt="Median 1Y Return by AI Infrastructure Layer (Image by Author)" style="display:block;margin:0 auto" width="1410" height="742" loading="lazy">

<p>This chart is where the story becomes much less obvious.</p>
<p>Construction and engineering ranked at the top by median one-year return, followed by semiconductor equipment, AI compute and chips, and servers and storage. That's not the usual way people talk about the AI trade.</p>
<p>The takeaway is not that construction and engineering is automatically the best AI capex layer. The sample size is small, so the result should be read as directional. But it still tells us something useful: the market has been rewarding the physical buildout side of AI infrastructure, not just the companies selling chips or cloud services.</p>
<p>That's the larger point. Once AI capex becomes real-world infrastructure, the trade starts showing up in companies tied to equipment, servers, electrical work, and construction.</p>
<h2 id="heading-the-physical-infrastructure-layer-is-no-longer-hidden">The Physical Infrastructure Layer Is No Longer Hidden</h2>
<p>This is the part of the AI capex trade that I find most useful.</p>
<p>The obvious AI story starts with chips and hyperscalers. But once the spending becomes real infrastructure, the list gets wider. AI data centers need servers, networking equipment, power systems, cooling, grid work, electrical construction, and physical capacity.</p>
<p>So I filtered the dataset to focus on the non-obvious infrastructure layers.</p>
<pre><code class="language-python">physical_layers = ['Power and electrification', 'Cooling and industrial systems', 'Construction and engineering',
                   'Data centers', 'Servers and storage', 'Networking']

physical_infra = capex_data[capex_data['capex_layer'].isin(physical_layers)].copy()
physical_infra = physical_infra.sort_values(['market_recognition_signal', 'fundamental_signal'], ascending=False)
physical_watchlist = physical_infra[['ticker', 'company', 'capex_layer', 'revenue_growth_yoy', 'operating_margin',
                                     'return_1y', 'return_6m', 'fundamental_signal', 'market_recognition_signal']].head(12)

physical_watchlist.head(10)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5187a7b4-99de-4f69-8e23-df3a2625ef11.png" alt="Physical infrastructure watchlist (Image by Author)" style="display:block;margin:0 auto" width="1500" height="579" loading="lazy">

<p>Comfort Systems, Vertiv, Dell, Quanta Services, Cisco, HPE, EMCOR, Equinix, Johnson Controls, and Digital Realty all sit in different parts of the physical buildout. Some are tied to servers. Some are tied to power and electrification. Some are tied to data centers, cooling, or construction.</p>
<p>The key point is simple: the market is already treating parts of the physical infrastructure layer as part of the AI capex story.</p>
<p>That doesn't mean every name here has the same quality or the same upside. The fundamental signals vary a lot. But the table shows why looking only at "AI software" or "AI chip" names misses a large part of the spending chain.</p>
<h2 id="heading-what-the-market-has-already-noticed">What the Market Has Already Noticed</h2>
<p>This section is important because not every AI capex name is early.</p>
<p>Some companies in the chain have already moved aggressively. That doesn't make them weak companies, but it changes the question. At that point, the question is no longer just whether the company is exposed to AI infrastructure. The better question is whether the market has already priced in a large part of that exposure.</p>
<p>To check that, I sorted the universe by the market recognition signal.</p>
<pre><code class="language-python">market_already_noticed = capex_data.sort_values('market_recognition_signal', ascending=False).head(10).copy()

market_already_noticed['return_1y'] = (market_already_noticed['return_1y'] * 100).round(2)
market_already_noticed['return_6m'] = (market_already_noticed['return_6m'] * 100).round(2)
market_already_noticed['return_3m'] = (market_already_noticed['return_3m'] * 100).round(2)
market_already_noticed['max_drawdown_1y'] = (market_already_noticed['max_drawdown_1y'] * 100).round(2)

market_already_noticed = market_already_noticed[['ticker', 'company', 'capex_layer', 'return_1y', 'return_6m', 'return_3m', 
                                                 'max_drawdown_1y', 'market_recognition_signal', 'fundamental_signal']]

market_already_noticed
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/2ac645ab-3050-453e-a4cd-a78e0a966030.png" alt="Market already noticed list (Image by Author)" style="display:block;margin:0 auto" width="1500" height="578" loading="lazy">

<p>This list is a useful reality check.</p>
<p>Comfort Systems, AMD, Marvell, Vertiv, Lam Research, Dell, Applied Materials, Quanta Services, Cisco, and Alphabet all show up with strong market recognition. The mix is the important part. It includes chips, semiconductor equipment, servers, networking, power, construction, and a hyperscaler.</p>
<p>That tells us the AI capex trade has already broadened in price action. It's not waiting quietly in the background.</p>
<p>But this also means we need to be careful with the "hidden beneficiary" framing. Some infrastructure names have already delivered very large one-year returns. So the smarter follow-up question is not:</p>
<blockquote>
<p>"Which companies are exposed?"</p>
</blockquote>
<p>It's:</p>
<blockquote>
<p>"How much of that exposure has the market already recognized?"</p>
</blockquote>
<h2 id="heading-what-this-study-shows">What This Study Shows</h2>
<p>The AI capex trade is easier to understand when we stop treating it as one group of "AI stocks."</p>
<p>The data shows three things clearly.</p>
<p>First, the obvious names still matter. NVIDIA remains the cleanest fundamental outlier in this universe, and chip-related names continue to sit close to the center of the AI infrastructure story.</p>
<p>Second, the trade has already moved beyond chips. Semiconductor equipment, servers, networking, power, and construction names all show up in the market recognition data. That makes sense. AI infrastructure isn't just model training. It needs physical capacity, electrical systems, cooling, data centers, and buildout work.</p>
<p>Third, market recognition and business strength don't always move together. Some companies have strong fundamentals but quieter price action. Others have already moved aggressively, even if their fundamental signal isn't as strong. That's why a simple "AI beneficiary" label isn't enough.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>AI capex isn't just a mega-cap tech story. It's a spending chain.</p>
<p>Once we trace that chain, the theme becomes broader and more interesting. It moves from chips to semiconductor equipment, from servers to networking, from data centers to power, cooling, and construction.</p>
<p>The goal of this study wasn't to find the best AI infrastructure stock. It was to build a clearer map of where the trade is already showing up.</p>
<p>That map matters because the next phase of the AI story may not be about who mentions AI the most. It may be about who sits closest to the infrastructure that makes AI possible.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Live Options Database in Python – A Complete Guide ]]>
                </title>
                <description>
                    <![CDATA[ Live options analytics change constantly. Implied volatility shifts, Greeks drift, and the shape of the surface can look different even a few minutes later. But a lot of teams still treat these number ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-live-options-database-in-python-a-complete-guide/</link>
                <guid isPermaLink="false">69fd19789f93a850a43041c9</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stockmarket ]]>
                    </category>
                
                    <category>
                        <![CDATA[ trading,  ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Thu, 07 May 2026 23:00:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4ecffa99-c492-4959-9899-885021d11ee4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Live options analytics change constantly. Implied volatility shifts, Greeks drift, and the shape of the surface can look different even a few minutes later.</p>
<p>But a lot of teams still treat these numbers like something you glance at once. A screenshot in a deck. A one-off notebook cell. A quick check in a UI before a meeting.</p>
<p>That works until you need to answer basic questions that show up in real workflows:</p>
<p>What did TSLA's surface look like at 10:32? When did skew start steepening? Did the change come from the wings moving or the ATM shifting?</p>
<p>If you don't store the data as it arrives, you can't replay it, compare it, or audit it. You're stuck with whatever you happened to look at in the moment.</p>
<p>In this walkthrough, we'll build something small but practical: an internal database that continuously captures SpiderRock MLink's LiveImpliedQuote analytics for TSLA, stores each snapshot as queryable history, and also maintains a "latest view" table so you can pull the current surface state without scanning the full history.</p>
<p><strong>The goal is not to build a trading system. It's to build a reliable internal dataset that you can monitor and query.</strong></p>
<p>Note: SpiderRock MLink's LiveImpliedQuote analytics is a product offered for a fee, which includes exchange charges for the underlying market data used in its creation.</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-what-data-were-using">What Data We're Using</a></p>
</li>
<li><p><a href="#heading-setup-importing-packages">Setup: Importing Packages</a></p>
</li>
<li><p><a href="#heading-database-design">Database Design</a></p>
</li>
<li><p><a href="#heading-pulling-liveimpliedquote">Pulling LiveImpliedQuote</a></p>
</li>
<li><p><a href="#heading-normalizing-the-response-into-rows">Normalizing the Response Into Rows</a></p>
</li>
<li><p><a href="#heading-writing-to-the-database">Writing To The Database</a></p>
</li>
<li><p><a href="#heading-running-a-short-polling-capture">Running a Short Polling Capture</a></p>
</li>
<li><p><a href="#heading-analysis-smile-reconstruction-from-the-database">Analysis: Smile Reconstruction From the Database</a></p>
<ul>
<li><p><a href="#heading-pick-an-expiry-with-good-coverage">Pick an Expiry with Good Coverage</a></p>
</li>
<li><p><a href="#heading-rebuild-the-smile-across-snapshots">Rebuild the Smile Across Snapshots</a></p>
</li>
<li><p><a href="#heading-zoom-in-around-spot">Zoom-In Around Spot</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-analysis-atm-iv-and-skew-over-time">Analysis: ATM IV and Skew Over Time</a></p>
</li>
<li><p><a href="#heading-alert-style-thresholds">Alert-Style Thresholds</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before running any of the code in this walkthrough, there are a few things you need to have in place.</p>
<p>On the API side, you need a SpiderRock MLink account with access to the LiveImpliedQuote feed. The examples use the REST interface, so no websocket setup is required, but you do need a valid API key. If you don't have one yet, you can reach out to SpiderRock directly to get access.</p>
<p>On the Python side, the environment is minimal. You need Python 3.10 or later for the tuple type hint syntax used in one of the function signatures. The external packages are requests, pandas, numpy, and matplotlib. Everything else – sqlite3, time, datetime – is part of the standard library. You can install the external dependencies with:</p>
<pre><code class="language-plaintext">pip install requests pandas numpy matplotlib
</code></pre>
<p>No database setup is required beyond a writable local path. SQLite creates the file automatically on first run, so there's nothing to install or configure separately.</p>
<p>Finally, the walkthrough uses TSLA as the target symbol because it has a liquid and active options chain. If you want to swap in a different underlying, the only thing you need to change is the symbol variable in the config block.</p>
<h2 id="heading-what-data-were-using">What Data We're Using</h2>
<p>This build is driven by one OptAnalytics message type from SpiderRock MLink: <a href="https://docs.spiderrockconnect.com/docs/next/MessageSchemas/Schema/Topics/analytics/LiveImpliedQuote/"><strong>LiveImpliedQuote</strong></a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7150e733-6238-410b-afe7-abc781d67e7a.png" alt="LiveImpliedQuote docs page" style="display:block;margin:0 auto" width="1000" height="451" loading="lazy">

<p>Each message represents an option contract and comes with the analytics you actually need for monitoring:</p>
<ul>
<li><p>the option identifier (symbol, expiry, strike, call or put)</p>
</li>
<li><p>surface IV (sVol) and related surface fields</p>
</li>
<li><p>Greeks (delta, gamma, theta, vega)</p>
</li>
<li><p>context fields like underlying price (uPrc), time to expiry (years), and rate (rate)</p>
</li>
<li><p>timestamps and calc source markers, which matter when you're turning a live feed into a database</p>
</li>
</ul>
<p>We'll treat sVol as the main volatility field for the article and refer to it as surface IV. That keeps the workflow consistent when we rebuild smiles or compute skew proxies from stored history.</p>
<p>The demo uses TSLA because it has a rich and active options chain, which makes the database and queries more interesting even in a short capture window. The same pipeline works for any other underlying&nbsp;– the only thing you change is the symbol filter.</p>
<h2 id="heading-setup-importing-packages">Setup: Importing Packages</h2>
<p>Before touching the database or the API, we set up a small, repeatable environment. This section is intentionally minimal. We only import what we need for three things: making REST calls, storing data in SQLite, and doing basic analysis and plots.</p>
<pre><code class="language-python">import requests
import sqlite3
import pandas as pd
import numpy as np
import time
from datetime import datetime, timezone
import matplotlib.pyplot as plt
plt.style.use('ggplot')
</code></pre>
<ul>
<li><p><code>requests</code> is used for calling MLink REST endpoints.</p>
</li>
<li><p><code>sqlite3</code> gives us a lightweight database we can write to locally without extra setup.</p>
</li>
<li><p><code>pandas</code> and <code>numpy</code> are only for shaping and filtering the data once it comes back.</p>
</li>
<li><p><code>time</code> and <code>datetime</code> help us run a polling loop and timestamp each snapshot so the database becomes a real-time series.</p>
</li>
</ul>
<h2 id="heading-database-design">Database Design</h2>
<p>If the goal is to make live analytics queryable, the database design has to support two different needs.</p>
<p>First, you want an audit trail. Every snapshot should be preserved so you can reconstruct what the surface looked like at a specific time.</p>
<p>Second, you also want a fast way to answer "what does it look like right now" without scanning everything you've ever stored.</p>
<p>So we use two tables:</p>
<ul>
<li><p><code>implied_quote_history</code>: Append-only. Every poll inserts a full snapshot.</p>
</li>
<li><p><code>implied_quote_latest</code>: One row per option contract. Each poll upserts into this table so it always reflects the most recent snapshot.</p>
</li>
</ul>
<p>The core of both tables is a stable option identifier. In the feed, the option key is nested, so we normalize it into a single <code>option_key</code> string that includes symbol, expiry, strike, call or put, and venue fields. This becomes the primary key for the latest table and the main join key for queries.</p>
<pre><code class="language-python">#config
api_key = "YOUR SPIDERROCK API KEY"
mlink_url = "https://mlink-live.nms.saturn.spiderrockconnect.com/rest/json"

msg_type = "LiveImpliedQuote"

symbol = "TSLA"
poll_interval_s = 10
poll_duration_s = 120
limit = 2000

#create db connection
db_path = "/mnt/data/optanalytics_iv_greeks.db"

def get_conn(path: str = db_path):
    conn = sqlite3.connect(path)
    conn.execute("PRAGMA journal_mode=WAL;")
    conn.execute("PRAGMA synchronous=NORMAL;")
    return conn

#create db schema
def setup_db(path: str = db_path):
    conn = get_conn(path)
    cur = conn.cursor()

    cur.execute("""
    create table if not exists implied_quote_history (
        id integer primary key autoincrement,
        asof_ts text not null,

        option_key text not null,
        symbol text not null,
        expiry text not null,
        strike real not null,
        cp text not null,

        calc_source text,
        u_prc real,
        years real,
        rate real,

        s_vol real,
        atm_vol real,
        s_mark real,

        o_bid real,
        o_ask real,
        o_bid_iv real,
        o_ask_iv real,

        delta real,
        gamma real,
        theta real,
        vega real,

        src_ts text
    );
    """)

    cur.execute("""
    create index if not exists idx_hist_symbol_expiry_asof
    on implied_quote_history(symbol, expiry, asof_ts);
    """)

    cur.execute("""
    create index if not exists idx_hist_option_asof
    on implied_quote_history(option_key, asof_ts);
    """)

    cur.execute("""
    create table if not exists implied_quote_latest (
        option_key text primary key,

        last_asof_ts text not null,
        symbol text not null,
        expiry text not null,
        strike real not null,
        cp text not null,

        calc_source text,
        u_prc real,
        years real,
        rate real,

        s_vol real,
        atm_vol real,
        s_mark real,

        o_bid real,
        o_ask real,
        o_bid_iv real,
        o_ask_iv real,

        delta real,
        gamma real,
        theta real,
        vega real,

        src_ts text
    );
    """)

    cur.execute("""
    create index if not exists idx_latest_symbol_expiry
    on implied_quote_latest(symbol, expiry);
    """)

    conn.commit()
    conn.close()

setup_db()
</code></pre>
<p>This creates the SQLite database file and both tables. The history table is append-only and indexed for the two queries we'll run later: pulling snapshots by expiry and time, and pulling a specific option's timeline by <code>option_key</code>. The latest table is keyed by <code>option_key</code>, which lets us upsert and maintain a consistent "current view."</p>
<p>The columns we store are intentionally opinionated. We keep surface IV (s_vol), surface mark (s_mark), Greeks, and a few context fields. We also store timestamps so later we can reason about when a value was produced.</p>
<h2 id="heading-pulling-liveimpliedquote">Pulling LiveImpliedQuote</h2>
<p>Now we do the first live pull. The goal here is not to build a perfect filter. It's to confirm that we can retrieve a meaningful slice of TSLA option analytics and that the response structure is what we expect.</p>
<p>We request LiveImpliedQuote and filter by symbol using the where clause. The response is a list where most rows are actual LiveImpliedQuote messages, and one row at the end is a QueryResult summary.</p>
<pre><code class="language-python">def fetch_live_implied_quote(symbol: str, limit: int = 2000):
    where = f"okey.tk:eq:{symbol}"

    params = {
        "apiKey": api_key,
        "cmd": "getmsgs",
        "msgType": msg_type,
        "where": where,
        "limit": limit
    }

    r = requests.get(mlink_url, params=params)
    r.raise_for_status()
    return r.json()

raw = fetch_live_implied_quote(symbol, limit=limit)
print("raw messages:", len(raw))
print("first type:", raw[0].get("header", {}).get("mTyp") if raw else None)
</code></pre>
<p>This is a straight REST <code>getmsgs</code> call. We pass the API key, message type, and a simple symbol filter. The <code>limit</code> is important. It caps how many messages we get back in one poll, so for active underlyings, the returned set of strikes and expiries can vary between polls. That's fine for this tutorial, because the goal is to show the database pattern and the types of monitoring queries it enables.</p>
<p>This is the output you should see:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/606259cd-e6ed-4f6f-b24f-48fafe9c561b.png" alt="LiveImpliedQuote sample pull" style="display:block;margin:0 auto" width="988" height="170" loading="lazy">

<h2 id="heading-normalizing-the-response-into-rows">Normalizing the Response Into Rows</h2>
<p>Right now, raw is a list of nested message objects. That format is fine for transport, but it's not something you can store or query directly. So now, we turn each LiveImpliedQuote message into one flat row with a consistent schema.</p>
<pre><code class="language-python">def make_option_key(okey: dict) -&gt; str:
    return "|".join([
        str(okey.get("tk")),
        str(okey.get("dt")),
        str(okey.get("xx")),
        str(okey.get("cp")),
        str(okey.get("at")),
        str(okey.get("ts")),
    ])

def normalize_liq(raw: list, asof_ts: str, keep_calc_source: str = "Loop") -&gt; pd.DataFrame:
    rows = []

    for row in raw:
        if row.get("header", {}).get("mTyp") != "LiveImpliedQuote":
            continue

        m = row.get("message", {})
        if keep_calc_source and m.get("calcSource") != keep_calc_source:
            continue

        pkey = m.get("pkey", {})
        okey = pkey.get("okey", {})
        if not okey:
            continue

        s_vol = m.get("sVol")
        if s_vol is None or s_vol == 0:
            continue

        o_bid = m.get("oBid", 0) or 0
        o_ask = m.get("oAsk", 0) or 0

        quote_ok = int(not (o_bid == 0 and o_ask == 0))

        rows.append({
            "asof_ts": asof_ts,
            "option_key": make_option_key(okey),

            "symbol": okey.get("tk"),
            "expiry": okey.get("dt"),
            "strike": okey.get("xx"),
            "cp": okey.get("cp"),

            "calc_source": m.get("calcSource"),
            "u_prc": m.get("uPrc"),
            "years": m.get("years"),
            "rate": m.get("rate"),

            "s_vol": s_vol,
            "atm_vol": m.get("atmVol"),
            "s_mark": m.get("sMark"),

            "o_bid": o_bid,
            "o_ask": o_ask,
            "o_bid_iv": m.get("oBidIv"),
            "o_ask_iv": m.get("oAskIv"),
            "quote_ok": quote_ok,

            "delta": m.get("de"),
            "gamma": m.get("ga"),
            "theta": m.get("th"),
            "vega": m.get("ve"),

            "src_ts": m.get("timestamp"),
        })

    df = pd.DataFrame(rows)
    if df.empty:
        return df

    df = (
        df.sort_values("src_ts")
          .drop_duplicates(subset=["option_key"], keep="last")
          .reset_index(drop=True)
    )
    return df

asof_ts = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
snapshot_df = normalize_liq(raw, asof_ts)

print("snapshot rows:", len(snapshot_df))
print("quote_ok distribution:", snapshot_df["quote_ok"].value_counts().to_dict() if not snapshot_df.empty else {})
snapshot_df.head()
</code></pre>
<p>There are three practical decisions baked into this normalization step:</p>
<ul>
<li><p>First, we build a stable <code>option_key</code> from the option identifier so we have a consistent primary key for the latest table.</p>
</li>
<li><p>Second, we keep only <code>calcSource="Loop"</code>. LiveImpliedQuote can include both Tick and Loop records. Loop records tend to be more consistent for snapshot-style analysis because the underlying reference price is stable across the surface.</p>
</li>
<li><p>Third, we avoid aggressive filtering. In this dataset, the top-of-book bid and ask fields can be zero even when the analytics fields are populated. So instead of dropping those rows, we store a <code>quote_ok</code> flag and keep the record. That keeps the pipeline usable while still making it obvious later which rows had live quotes.</p>
</li>
</ul>
<p>This is the output:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7d04a9e8-d3ec-4737-a0a7-64cb3888380c.png" alt="LiveImpliedQuote snapshot" style="display:block;margin:0 auto" width="1500" height="496" loading="lazy">

<p>At this point, one row represents one option contract snapshot. The fact that <code>quote_ok</code> is 0 across the board simply means bid and ask are not populated in this slice, even though surface IV, Greeks, and other analytics fields are present. That's still useful for building a monitoring database, because the core idea here is tracking the evolution of analytics over time, not reconstructing executable markets.</p>
<h2 id="heading-writing-to-the-database">Writing to the Database</h2>
<p>Now that we have a clean snapshot DataFrame, the job is to persist it in two places.</p>
<p>History table: Append everything. This is the audit log. Latest table: Upsert by <code>option_key</code>. This is the fast "current view."</p>
<p>This separation is what makes the database useful. History lets you reconstruct any past snapshot. Latest lets you answer "what does the surface look like right now" without scanning time series.</p>
<pre><code class="language-python">def safe_add_column(table: str, col: str, col_type: str, path: str = db_path):
    conn = get_conn(path)
    cur = conn.cursor()
    existing = [r[1] for r in cur.execute(f"PRAGMA table_info({table});").fetchall()]
    if col not in existing:
        cur.execute(f"ALTER TABLE {table} ADD COLUMN {col} {col_type};")
    conn.commit()
    conn.close()

safe_add_column("implied_quote_history", "quote_ok", "INTEGER")
safe_add_column("implied_quote_latest", "quote_ok", "INTEGER")

def write_snapshot_to_db(df: pd.DataFrame, path: str = db_path) -&gt; tuple[int, int]:
    if df.empty:
        return 0, 0

    conn = get_conn(path)
    cur = conn.cursor()

    cols = [
        "asof_ts",
        "option_key","symbol","expiry","strike","cp",
        "calc_source","u_prc","years","rate",
        "s_vol","atm_vol","s_mark",
        "o_bid","o_ask","o_bid_iv","o_ask_iv",
        "delta","gamma","theta","vega",
        "quote_ok","src_ts"
    ]

    for c in cols:
        if c not in df.columns:
            df[c] = None

    insert_df = df[cols].copy()

    cur.executemany(
        """
        insert into implied_quote_history (
            asof_ts,
            option_key, symbol, expiry, strike, cp,
            calc_source, u_prc, years, rate,
            s_vol, atm_vol, s_mark,
            o_bid, o_ask, o_bid_iv, o_ask_iv,
            delta, gamma, theta, vega,
            quote_ok, src_ts
        ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        insert_df.itertuples(index=False, name=None)
    )
    history_inserted = cur.rowcount

    cur.executemany(
        """
        insert into implied_quote_latest (
            option_key,
            last_asof_ts, symbol, expiry, strike, cp,
            calc_source, u_prc, years, rate,
            s_vol, atm_vol, s_mark,
            o_bid, o_ask, o_bid_iv, o_ask_iv,
            delta, gamma, theta, vega,
            quote_ok, src_ts
        ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        on conflict(option_key) do update set
            last_asof_ts=excluded.last_asof_ts,
            symbol=excluded.symbol,
            expiry=excluded.expiry,
            strike=excluded.strike,
            cp=excluded.cp,
            calc_source=excluded.calc_source,
            u_prc=excluded.u_prc,
            years=excluded.years,
            rate=excluded.rate,
            s_vol=excluded.s_vol,
            atm_vol=excluded.atm_vol,
            s_mark=excluded.s_mark,
            o_bid=excluded.o_bid,
            o_ask=excluded.o_ask,
            o_bid_iv=excluded.o_bid_iv,
            o_ask_iv=excluded.o_ask_iv,
            delta=excluded.delta,
            gamma=excluded.gamma,
            theta=excluded.theta,
            vega=excluded.vega,
            quote_ok=excluded.quote_ok,
            src_ts=excluded.src_ts
        """,
        insert_df[[
            "option_key","asof_ts","symbol","expiry","strike","cp",
            "calc_source","u_prc","years","rate",
            "s_vol","atm_vol","s_mark",
            "o_bid","o_ask","o_bid_iv","o_ask_iv",
            "delta","gamma","theta","vega",
            "quote_ok","src_ts"
        ]].itertuples(index=False, name=None)
    )
    latest_upserted = cur.rowcount

    conn.commit()
    conn.close()
    return history_inserted, latest_upserted

hist_n, latest_n = write_snapshot_to_db(snapshot_df)
print("history inserted:", hist_n)
print("latest upserted:", latest_n)
</code></pre>
<p>We batch write using <code>executemany</code> so inserts are fast even with thousands of option rows. The history insert is straightforward. The latest write uses a SQLite upsert keyed on <code>option_key</code>, which means if the contract already exists in the latest table, its fields are overwritten with the newest snapshot.</p>
<p>You should see:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/8fdbdeb1-a4f2-434d-a3c7-99f44e51ec5d.png" alt="History inserted: 1852, latest upserted: 1852" style="display:block;margin:0 auto" width="608" height="137" loading="lazy">

<p>After the first write, both tables have the same number of rows. That's expected, because there is only one snapshot in history so far. Once we start polling multiple snapshots, the history table will grow every cycle, while the latest table will stay roughly flat and continue updating in place.</p>
<h2 id="heading-running-a-short-polling-capture">Running a Short Polling Capture</h2>
<p>At this point, the pipeline works end-to-end for a single snapshot. The whole point of the database, though, is to turn live analytics into a time series. So we run a short capture window and store multiple snapshots back-to-back.</p>
<p>This isn't meant to be a production scheduler. It's just a simple loop that runs for a couple of minutes, polls every few seconds, timestamps the snapshot, and writes it to both tables.</p>
<pre><code class="language-python">def poll_and_write(symbol: str, duration_s: int = poll_duration_s, interval_s: int = poll_interval_s):
    start = time.time()
    polls = 0
    total_hist = 0

    while time.time() - start &lt; duration_s:
        asof_ts = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

        raw = fetch_live_implied_quote(symbol, limit=limit)
        df = normalize_liq(raw, asof_ts)

        hist_n, latest_n = write_snapshot_to_db(df)
        polls += 1
        total_hist += hist_n

        print(f"[{polls}] {asof_ts} snapshot_rows={len(df)} history+={hist_n} latest_upsert={latest_n}")
        time.sleep(interval_s)

    print(f"done. polls={polls}, total_history_added={total_hist}")

poll_and_write(symbol, duration_s=120, interval_s=10)
</code></pre>
<p>Each loop iteration represents one snapshot. We generate a UTC timestamp (asof_ts), pull the latest batch from LiveImpliedQuote, normalize it into rows, then write it into the database. The history table accumulates every snapshot. The latest table overwrites by <code>option_key</code>, so it always represents the most recent view.</p>
<p>One practical detail is worth calling out. The API call is capped by limit, so you're not guaranteed to receive an identical set of strikes and expiries every poll. That's why <code>snapshot_rows</code> can vary between iterations.</p>
<p>In production, you usually stabilize the slice by pinning specific expiries and a strike band or by interpolating IV to fixed moneyness points. For this tutorial, we're keeping ingestion simple and focusing on the database pattern and the monitoring queries it enables.</p>
<p>You should see per-poll telemetry like this:</p>
<pre><code class="language-plaintext">[1] 2026-04-14T18:09:29Z snapshot_rows=1454 history+=1454 latest_upsert=1454
...
done. polls=9, total_history_added=12806
</code></pre>
<p>This confirms the database is building a time series. Over nine polls, you stored 12,806 option rows in history. The latest table is updated each time, but it doesn't grow in the same way as history because it overwrites per contract key.</p>
<p>From the next section, we'll stop writing and start querying.</p>
<h2 id="heading-analysis-smile-reconstruction-from-the-database">Analysis: Smile Reconstruction From the Database</h2>
<p>Once the data is in <code>implied_quote_history</code>, the workflow flips. We stop thinking in terms of "API responses" and start thinking in terms of "queries." This section does two things. First, it picks an expiry that has enough rows to be representative. Then it reconstructs the call-side volatility smile for that expiry across a few timestamps.</p>
<h3 id="heading-pick-an-expiry-with-good-coverage">Pick an Expiry with Good Coverage</h3>
<p>If you pick an expiry that only appears sporadically in the captured snapshots, the smile plot will be misleading. So we start by looking at which expiries have the most rows in the history table.</p>
<pre><code class="language-python">conn = get_conn()

expiry_counts = pd.read_sql_query(
    """
    select expiry, count(*) as n
    from implied_quote_history
    where symbol = ?
    group by expiry
    order by n desc
    limit 10
    """,
    conn,
    params=(symbol,)
)

conn.close()
expiry_counts
</code></pre>
<p>This query scans only the history table, filters to TSLA, and counts how many option rows exist per expiry across the capture window. We keep the top 10 and pick the first one as the expiry we'll reconstruct.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/2f7b897f-0a4f-4b1a-826e-0fee6b19f2bd.png" alt="Expiry-wise coverage" style="display:block;margin:0 auto" width="373" height="724" loading="lazy">

<p>The expiry date <code>2026-11-20</code> has the highest count.</p>
<p>Here, the count doesn't mean this expiry is "best" in any trading sense. It just means it showed up most consistently in the captured data. That makes it a practical choice for a clean smile comparison.</p>
<h3 id="heading-rebuild-the-smile-across-snapshots">Rebuild the Smile Across Snapshots</h3>
<p>Now we query the stored history for one expiry, keep only calls, and plot surface IV (s_vol) against strike for multiple snapshot timestamps.</p>
<pre><code class="language-python">chosen_expiry = "2026-11-20" 

conn = get_conn()
smile = pd.read_sql_query(
    """
    select asof_ts, strike, cp, s_vol, u_prc
    from implied_quote_history
    where symbol = ? and expiry = ?
    """,
    conn,
    params=(symbol, chosen_expiry)
)
conn.close()

smile_calls = smile[smile["cp"] == "Call"].copy()

ts_list = sorted(smile_calls["asof_ts"].unique())
pick = [ts_list[0], ts_list[len(ts_list)//2], ts_list[-1]]

plt.figure(figsize=(9,5))
for ts in pick:
    g = smile_calls[smile_calls["asof_ts"] == ts].sort_values("strike")
    plt.plot(g["strike"], g["s_vol"], label=ts)

plt.title(f"{symbol} Vol Smile (Calls) | Expiry {chosen_expiry} | 3 snapshots")
plt.xlabel("Strike")
plt.ylabel("Implied Vol (s_vol)")
plt.grid(True)
plt.legend()
plt.show()
</code></pre>
<p>We pull all rows for the chosen expiry from history, then filter to calls so we don't mix put and call shapes. To keep the plot readable, we only plot three snapshots. First, middle, and last.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/84416f80-9253-4f18-8da4-ea814e174987.png" alt="TSLA vol smile (calls)" style="display:block;margin:0 auto" width="778" height="475" loading="lazy">

<p>Over a short capture window, the smiles often overlap heavily. That doesn't mean the system isn't working. It usually means the surface didn't move much in those two minutes. The important part is that we can reconstruct and compare it purely from stored history.</p>
<h3 id="heading-zoom-in-around-spot">Zoom-In Around Spot</h3>
<p>The full-range plot is useful for shape, but it can hide small shifts near the region people actually care about. So we zoom to a band around the underlying price.</p>
<pre><code class="language-python">s0 = float(smile_calls["u_prc"].dropna().median())
low, high = s0 * 0.6, s0 * 1.4

for ts in pick:
    g = smile_calls[smile_calls["asof_ts"] == ts].sort_values("strike")
    g = g[(g["strike"] &gt;= low) &amp; (g["strike"] &lt;= high)]
    plt.plot(g["strike"], g["s_vol"], label=ts)

plt.title(f"{symbol} Vol Smile (Calls) | Expiry {chosen_expiry} | zoomed")
plt.xlabel("Strike")
plt.ylabel("Implied Vol (s_vol)")
plt.grid(True)
plt.legend(fontsize=8)
plt.show()
</code></pre>
<p>We take a robust spot proxy from the stored <code>u_prc</code> values and then keep strikes within a range around it. The goal is not precision. It's to make the chart readable and show whether the near-ATM region is drifting.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/107de4b4-7b40-4e79-a38b-fac96cb11b26.png" alt="TSLA vol smile (calls)  -  zoomed-in" style="display:block;margin:0 auto" width="781" height="475" loading="lazy">

<p>Here, even small changes become visible. This is also why storing history matters. If you only looked at one snapshot in isolation, these shifts would be easy to miss or dismiss.</p>
<h2 id="heading-analysis-atm-iv-and-skew-over-time">Analysis: ATM IV and Skew Over Time</h2>
<p>A full smile plot is useful, but it's not always the fastest way to monitor a surface. In practice, teams usually track a few summary numbers per expiry so they can spot changes quickly, then drill down only when something looks off.</p>
<p>Here we reduce each stored snapshot into two metrics for a single expiry.</p>
<ul>
<li><p>ATM IV: Surface IV at the strike closest to spot.</p>
</li>
<li><p>Skew proxy: Surface IV at 0.9 times spot minus surface IV at 1.1 times spot, using the closest available strikes.</p>
</li>
</ul>
<pre><code class="language-python">chosen_expiry = "2026-11-20"

conn = get_conn()
df = pd.read_sql_query(
    """
    select asof_ts, strike, s_vol, u_prc
    from implied_quote_history
    where symbol = ? and expiry = ? and cp = 'Call'
    """,
    conn,
    params=(symbol, chosen_expiry)
)
conn.close()

df["strike"] = df["strike"].astype(float)
df["s_vol"] = df["s_vol"].astype(float)

def closest_iv(grp: pd.DataFrame, target_strike: float):
    g = grp.iloc[(grp["strike"] - target_strike).abs().argsort()[:1]]
    return float(g["s_vol"].iloc[0]), float(g["strike"].iloc[0])

rows = []
for ts, grp in df.groupby("asof_ts"):
    spot = float(grp["u_prc"].dropna().median())
    atm_target = spot
    down_target = spot * 0.9
    up_target = spot * 1.1

    atm_iv, atm_k = closest_iv(grp, atm_target)
    down_iv, down_k = closest_iv(grp, down_target)
    up_iv, up_k = closest_iv(grp, up_target)

    rows.append({
        "asof_ts": ts,
        "spot": spot,
        "atm_strike": atm_k,
        "atm_iv": atm_iv,
        "k90": down_k,
        "iv_90": down_iv,
        "k110": up_k,
        "iv_110": up_iv,
        "skew_90_110": down_iv - up_iv
    })

metrics = pd.DataFrame(rows).sort_values("asof_ts").reset_index(drop=True)
metrics
</code></pre>
<p>We query the history table for one expiry and keep only calls, then group by snapshot timestamp. For each snapshot, we use the median <code>u_prc</code> as a spot proxy and pick the closest available strike to spot. That gives ATM IV. We repeat the same approach for 0.9 times spot and 1.1 times spot and compute a skew proxy as the difference.</p>
<p>The table also stores the actual strikes used (atm_strike, k90, k110). Options strikes are discrete, so the nearest strike can change between snapshots. Keeping the chosen strikes visible makes the metric explainable when it moves.</p>
<p>The output is a table with one row per snapshot timestamp and the computed metrics.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5590b162-5fe7-4713-8f56-edc4c6171ab2.png" alt="ATM IV, skew proxy metrics" style="display:block;margin:0 auto" width="1000" height="441" loading="lazy">

<p>Now that we have a clean time series table, we can visualize the two metrics. First, ATM IV. Then, the skew proxy.</p>
<pre><code class="language-python">plt.plot(metrics["asof_ts"], metrics["atm_iv"])
plt.title(f"{symbol} ATM IV over time | Expiry {chosen_expiry}")
plt.xticks(rotation=30, ha="right")
plt.ylabel("ATM IV (s_vol)")
plt.grid(True)
plt.show()

plt.plot(metrics["asof_ts"], metrics["skew_90_110"])
plt.title(f"{symbol} Skew proxy (IV@0.9S - IV@1.1S) | Expiry {chosen_expiry}")
plt.xticks(rotation=30, ha="right")
plt.ylabel("Skew proxy")
plt.grid(True)
plt.show()
</code></pre>
<p>Here is the first chart, ATM IV over time.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/0df9b0ff-e02f-4c6b-b4ec-175ddc46522c.png" alt="TSLA ATM IV over time" style="display:block;margin:0 auto" width="831" height="453" loading="lazy">

<p>ATM IV tends to move slowly over short windows unless there is a sharp repricing event. In this run, it stays fairly stable, which is a realistic outcome for a short capture. The value here is that the database turns "fairly stable" into something you can quantify and compare later, rather than a vague impression.</p>
<p>Here is the second chart, Skew proxy over time.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/f90243ee-6039-4d7e-94ed-d248eaaf9722.png" alt="TSLA skew proxy" style="display:block;margin:0 auto" width="831" height="453" loading="lazy">

<p>The skew proxy is more sensitive because it's based on wing points. If it changes, it usually means the downside is being repriced differently from the upside for that expiry. One nuance is that the nearest available strike can change between snapshots, which can create step-like moves even when the surface isn't moving dramatically. That's why we keep k90 and k110 in the metrics table. It keeps the skew plot explainable.</p>
<h2 id="heading-alert-style-thresholds">Alert-Style Thresholds</h2>
<p>Once you have a metrics table per snapshot, adding a monitoring layer is straightforward. The idea isn't to generate trades. It's to flag when the surface moves enough that someone should look closer.</p>
<p>Here we do two checks:</p>
<ul>
<li><p>ATM IV change alert: Flag if ATM IV changes more than a small threshold between snapshots.</p>
</li>
<li><p>Skew change alert: Flag if the skew proxy changes more than a threshold between snapshots.</p>
</li>
</ul>
<pre><code class="language-python">alerts = metrics.copy()

alerts["atm_iv_change"] = alerts["atm_iv"].diff()
alerts["skew_change"] = alerts["skew_90_110"].diff()

atm_thresh = 0.002    
skew_thresh = 0.003   

alerts["atm_alert"] = alerts["atm_iv_change"].abs() &gt;= atm_thresh
alerts["skew_alert"] = alerts["skew_change"].abs() &gt;= skew_thresh

alerts[[
    "asof_ts",
    "atm_iv", "atm_iv_change", "atm_alert",
    "skew_90_110", "skew_change", "skew_alert",
    "atm_strike", "k90", "k110"
]]
</code></pre>
<p>We take the per-snapshot metrics table and compute first differences. Then we compare those changes to thresholds and store boolean flags. The output table keeps both the metrics and the strikes used for the calculations, so any alert is explainable rather than a black box.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/b6805adc-90f6-4c57-8dee-aa6e0ec4d724.png" alt="Alerts dataframe" style="display:block;margin:0 auto" width="1500" height="546" loading="lazy">

<p>In this run, the ATM IV alerts are all false, while the skew alert triggers once.</p>
<p>The skew alert fires because the skew proxy jumps by more than the threshold between two snapshots. This is explainable. If you see the table, you can see the strikes used for the proxy changed around the same time (k90 shifts from 340 to 315). Because strikes are discrete, nearest-strike metrics can step even when the surface is not moving dramatically.</p>
<p>To make this easier to read, we also plot the two series and mark alert points.</p>
<pre><code class="language-python">plt.plot(alerts["asof_ts"], alerts["atm_iv"])
for i, r in alerts[alerts["atm_alert"]].iterrows():
    plt.scatter(r["asof_ts"], r["atm_iv"],  s=30, edgecolors="r", alpha=0.6, linewidth=2)
plt.title(f"{symbol} ATM IV with alerts | Expiry {chosen_expiry}")
plt.xticks(rotation=30, ha="right")
plt.grid(True)
plt.show()

plt.plot(alerts["asof_ts"], alerts["skew_90_110"])
for i, r in alerts[alerts["skew_alert"]].iterrows():
    plt.scatter(r["asof_ts"], r["skew_90_110"], s=30, edgecolors="r", alpha=0.6, linewidth=2)
plt.title(f"{symbol} Skew proxy with alerts | Expiry {chosen_expiry}")
plt.xticks(rotation=30, ha="right")
plt.grid(True)
plt.show()
</code></pre>
<p>Both plots use the same pattern. Plot the metric as a line, then overlay a marker on any timestamp where the corresponding alert flag is true. This makes it obvious when something crossed the threshold.</p>
<p>This chart represents skew proxy with alerts.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/eff87263-68f0-4132-935d-bdf148e73c82.png" alt="TSLA skew proxy with alerts" style="display:block;margin:0 auto" width="831" height="453" loading="lazy">

<p>This chart shows one alert marker, which matches what we saw in the table.</p>
<p>The ATM IV plot isn't featured since there are no alert points.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>In this walkthrough, we used SpiderRock MLink's LiveImpliedQuote feed for TSLA and turned it into a small internal database you can query. We stored every snapshot in an append-only history table, maintained a latest view keyed by a stable option identifier, then used that stored data to rebuild a smile, track ATM surface IV and a simple skew proxy, and add a basic alert rule on top.</p>
<p>This fits well in B2B workflows because it turns live analytics into something operational: a dataset you can audit, replay, and monitor. The same pattern works whether you're building an internal dashboard, running routine surface checks for a desk, or doing a quick post-event review without relying on screenshots and one-off notebook runs.</p>
<p>If you want to extend it, the most practical next steps are longer capture windows, tracking multiple symbols, and moving from SQLite to Postgres once the data volume grows. If metric stability becomes important, you can also standardize the slice you track per poll or interpolate IV to fixed moneyness points so skew measures don't step when nearest strikes change.</p>
<p>With that being said, you've reached the end of the article. Hope you learned something new and useful.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Market Research Copilot with MCP and Python [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ Most financial AI tools are good at one thing: summarizing a stock. You ask about Apple, NVIDIA, or Tesla, and they give you a clean overview of price action, a few ratios, and maybe some company cont ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-market-research-copilot-with-mcp-and-python-handbook/</link>
                <guid isPermaLink="false">69fb845950ecad45335e0fe2</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stockmarket ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Wed, 06 May 2026 18:11:37 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/97192f8e-e5c5-4339-8974-90d823d93a86.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most financial AI tools are good at one thing: summarizing a stock. You ask about Apple, NVIDIA, or Tesla, and they give you a clean overview of price action, a few ratios, and maybe some company context. That can be useful, but it falls short the moment the task becomes more like real research.</p>
<p>Real research usually starts with a view. Not a ticker. A trader, analyst, or product team is more likely to ask something like, “Apple looks attractive because downside has been controlled and business quality remains high. Does the data actually support that?” That's a different problem. A summary can't answer it properly because the system needs to test the claim itself, not just describe the company around it.</p>
<p>In this tutorial, we're going to build a financial research copilot that does exactly that. It takes a natural-language thesis, pulls historical prices and fundamentals through EODHD’s MCP server, turns those inputs into structured evidence, and returns a short research memo with a verdict.</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-what-this-copilot-actually-produces">What This Copilot Actually Produces</a></p>
</li>
<li><p><a href="#heading-what-makes-this-different-from-a-normal-stock-assistant">What Makes This Different from a Normal Stock Assistant</a></p>
</li>
<li><p><a href="#heading-the-workflow">The Workflow</a></p>
</li>
<li><p><a href="#heading-building-the-mcp-client">Building the MCP Client</a></p>
</li>
<li><p><a href="#heading-setting-up-corepyhttpcorepy">Setting Up core.py</a></p>
</li>
<li><p><a href="#heading-parsing-a-research-prompt-into-a-structured-request">Parsing a Research Prompt into a Structured Request</a></p>
</li>
<li><p><a href="#heading-fetching-the-two-data-sources-historical-amp-fundamental-data">Fetching the Two Data Sources: Historical &amp; Fundamental Data</a></p>
</li>
<li><p><a href="#heading-building-the-first-evidence-layer-from-price-data">Building the First Evidence Layer from Price Data</a></p>
</li>
<li><p><a href="#heading-building-the-second-evidence-layer-from-fundamentals">Building the Second Evidence Layer from Fundamentals</a></p>
</li>
<li><p><a href="#heading-what-do-we-have-so-far">What do we have so far?</a></p>
</li>
<li><p><a href="#heading-classifying-the-thesis">Classifying the Thesis</a></p>
</li>
<li><p><a href="#heading-turning-signals-into-support-contradiction-and-missing-evidence">Turning Signals into Support, Contradiction, and Missing Evidence</a></p>
<ul>
<li><a href="#heading-sanity-check-jupyter-notebook">Sanity Check (Jupyter Notebook)</a></li>
</ul>
</li>
<li><p><a href="#heading-assigning-a-verdict">Assigning a Verdict</a></p>
</li>
<li><p><a href="#heading-building-the-facts-object">Building the Facts Object</a></p>
<ul>
<li><p><a href="#heading-1-company-context">1. Company Context</a></p>
</li>
<li><p><a href="#heading-2-single-stock-facts-builder">2. Single-Stock Facts Builder</a></p>
</li>
<li><p><a href="#heading-3-watchlist-facts-builder">3. Watchlist Facts Builder</a></p>
</li>
<li><p><a href="#heading-sanity-check-jupyter-notebook-1">Sanity Check (Jupyter Notebook)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-writing-the-final-memo">Writing the Final Memo</a></p>
<ul>
<li><a href="#heading-sanity-check-jupyter-notebook-2">Sanity Check (Jupyter Notebook)</a></li>
</ul>
</li>
<li><p><a href="#heading-stitching-everything-together">Stitching Everything Together</a></p>
</li>
<li><p><a href="#heading-demo-time-jupyter-notebook">Demo Time! (Jupyter Notebook)</a></p>
<ul>
<li><p><a href="#heading-demo-1-testing-whether-a-premium-is-actually-justified">Demo 1. Testing Whether a Premium Is Actually Justified</a></p>
</li>
<li><p><a href="#heading-demo-2-testing-whether-volatility-is-too-high-for-the-underlying-business">Demo 2. Testing Whether Volatility Is Too High for the Underlying Business</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before starting, make sure you have the following in place.</p>
<p>You will need Python 3.9 or later, along with these libraries: <code>mcp</code>, <code>openai</code>, <code>numpy</code>, and <code>pandas</code>. Install them with pip before running any code.</p>
<p>You will also need two API keys. One from EODHD for historical prices and fundamentals data, and one from OpenAI for parsing and memo generation. If you don't have an EODHD key, you can get one by registering for a developer account at <a href="http://eodhd.com">eodhd.com</a>.</p>
<p>The tutorial assumes basic familiarity with Python and async programming. You don't need a background in finance, but it helps to understand what a P/E ratio and drawdown mean before reading the evidence-building sections.</p>
<p>A Jupyter notebook environment is recommended for running the sanity checks, though any Python environment that supports <code>await</code> will work.</p>
<h2 id="heading-what-this-copilot-actually-produces">What This Copilot Actually&nbsp;Produces</h2>
<p>Before getting into the pipeline, it helps to see the kind of output we're building toward. The easiest way to understand this project is to look at one real example.</p>
<p>Suppose the user gives the system this prompt:</p>
<blockquote>
<p>I think Apple looks attractive because downside has been controlled and business quality remains high. Can you test that for AAPL over the last 180&nbsp;days?</p>
</blockquote>
<p>The copilot doesn't respond with a loose summary of Apple. It turns that into a structured research memo:</p>
<pre><code class="language-plaintext">1. Thesis under review  

Apple appears attractive due to controlled downside and sustained high business 
quality.

2. Supporting evidence  

Over the past 180 days, maximum drawdown was limited to -13.82%, suggesting relatively contained downside.Profitability metrics are strong, with a 35.37% operating margin and 27.04% profit margin. Returns on capital are high, with ROA at 24.38% and ROE at 152.02%, indicating efficient asset use and strong  capital efficiency. Growth metrics support ongoing business strength, with quarterly revenue growth of 15.70% and earnings growth of 18.30% year-over-year. Forward estimates also remain positive, with expected earnings growth of 9.68% and 
revenue growth of 6.87%.

3. Evidence that weakens the thesis  

Net EPS revisions over the past 30 days are negative (-3), indicating some deterioration in analyst sentiment.

4. Missing evidence  

No material gaps in the provided dataset.

5. Verdict  

partially_supported - There is more supporting evidence than contradicting evidence, but the thesis is not fully confirmed.

6. Bottom-line assessment  

Apple demonstrates strong and consistent business quality supported by high margins, returns, and continued growth. Downside has been relatively contained over the observed period, though not negligible. However, negative earnings 
revisions introduce some caution, leaving the thesis supported but not conclusively established.
</code></pre>
<p>This example makes the goal of the project much clearer. We're not building a system that simply tells us what happened to Apple. We're building one that takes a claim, checks it against market and fundamentals data, and returns a structured judgment.</p>
<p>That distinction matters because the memo is only the final surface. Underneath it, the system first parses the thesis, pulls prices and fundamentals through <a href="https://eodhd.com/financial-apis/mcp-server-for-financial-data-by-eodhd"><strong>EODHD’s MCP server</strong></a>, computes the relevant signals, builds support and contradiction, assigns a verdict, and only then writes the final note. That's what gives the output its structure.</p>
<p>In this first part, we’ll build everything up to the evidence layers that power this kind of output.</p>
<h2 id="heading-what-makes-this-different-from-a-normal-stock-assistant">What Makes This Different from a Normal Stock Assistant</h2>
<img src="https://cdn-images-1.medium.com/max/1000/1*rJirKoA1xWiuZjyENZypGg.png" alt="Stock assistant vs Thesis copilot workflow comparison" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>A normal stock assistant starts with a ticker and tries to explain what happened. It may summarize price action, mention a few ratios, and add some company context. That is useful when the question is broad, but it's not enough when the input is a specific investment view.</p>
<p>This project starts from the opposite direction. The input is not “tell me about Apple.” The input is a claim, like Apple looks attractive because downside has been controlled and business quality remains high. That changes the job of the system. It now has to test each part of that claim, decide what supports it, decide what weakens it, and be clear about what's still missing.</p>
<p>That one shift is what shapes the whole workflow. Instead of ending at retrieval and summarization, the pipeline has to parse the thesis, map the data to the right kind of evidence, and return a verdict. That's what makes this feel like a research copilot rather than a better stock summary tool.</p>
<h2 id="heading-the-workflow">The Workflow</h2>
<p>At a high level, the copilot follows a simple sequence:</p>
<ul>
<li><p>parse the user’s thesis into a structured request</p>
</li>
<li><p>fetch historical prices and fundamentals through MCP</p>
</li>
<li><p>turn those inputs into market and business signals</p>
</li>
<li><p>map those signals into support, contradiction, and missing evidence</p>
</li>
<li><p>assign a verdict</p>
</li>
<li><p>write the final memo</p>
</li>
</ul>
<p>That's the full loop. The output may look like a short research note, but it sits on top of a more controlled pipeline in <code>core.py</code>.</p>
<h4 id="heading-project-structure">Project structure:</h4>
<pre><code class="language-plaintext">project/
├── client.py
├── core.py
└── test.ipynb
</code></pre>
<p><code>client.py</code> is the MCP access layer. It connects to EODHD, lists tools, calls them with retries and timeouts, and returns metadata for each request. <code>core.py</code> contains the actual thesis-testing logic, including parsing, data fetching, signal computation, evidence building, verdict assignment, and memo generation. <code>test.ipynb</code> is where the quality checks and end-to-end demos are run.</p>
<p>This split is useful because it keeps the tutorial easy to follow. When we move into code, each block has a clear place. MCP access stays in <code>client.py</code>, while the research workflow stays in <code>core.py</code>.</p>
<h2 id="heading-building-the-mcp-client">Building the MCP&nbsp;Client</h2>
<p>We’ll start with the thinnest part of the project, which is the MCP access layer.</p>
<p>This file only does one job. It connects to EODHD’s MCP server, lists available tools, calls a tool with retries and a timeout, and returns a small metadata object alongside the response. The actual thesis logic doesn't belong here. Keeping this layer small makes the rest of the project much easier to reason about later.</p>
<p>Create a file called <code>client.py</code> and add this:</p>
<pre><code class="language-python">import time
import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

class EODHDMCP:
    def __init__(self, apikey, base_url=None):
        self.apikey = apikey
        self.base_url = base_url or "https://mcp.eodhd.dev/mcp"
        self._tools = None

    def _url(self):
        return f"{self.base_url}?apikey={self.apikey}"

    def _open(self):
        return streamablehttp_client(self._url())

    async def list_tools(self):
        if self._tools is not None:
            return self._tools

        async with self._open() as (read, write, _):
            async with ClientSession(read, write) as s:
                await s.initialize()
                resp = await s.list_tools()
                self._tools = [t.name for t in resp.tools]
                return self._tools

    async def call_tool(self, name, args, trace_id, timeout_s=25, retries=2):
        last = None

        for attempt in range(retries + 1):
            t0 = time.time()
            try:
                async with self._open() as (read, write, _):
                    async with ClientSession(read, write) as s:
                        await s.initialize()
                        out = await asyncio.wait_for(s.call_tool(name, args), timeout=timeout_s)
                        dt = time.time() - t0
                        meta = {
                            "trace_id": trace_id,
                            "tool": name,
                            "args": args,
                            "latency_s": round(dt, 3),
                        }
                        return out, meta
            except Exception as e:
                last = e
                if attempt &lt; retries:
                    await asyncio.sleep(0.5 * (attempt + 1))

        raise last
</code></pre>
<p>There are only two methods that really matter here. <code>list_tools()</code> is just a quick way to inspect and cache the tools exposed by the MCP server. <code>call_tool()</code> is the method the rest of the project will actually use. It makes the request, applies timeout and retry handling, and returns both the raw output and a small metadata object.</p>
<p>That metadata becomes useful later because the workflow stays traceable. When the copilot returns a memo, we still know which tool was called, with what arguments, and how long it took. So even though this file is small, it gives the rest of the system a clean and inspectable access layer.</p>
<h2 id="heading-setting-up-corepy">Setting Up&nbsp;<code>core.py</code></h2>
<p>Now that the MCP client is ready, we can start building the main workflow in <code>core.py</code>.</p>
<p>This file will hold the actual thesis-testing logic, so the first step is to set up the imports, API clients, a few limits, and some small helper functions that the rest of the pipeline will reuse.</p>
<p>Create a file called <code>core.py</code> and start with this:</p>
<pre><code class="language-python">import json
import re
import time
import uuid
import asyncio
from datetime import date, timedelta

import numpy as np
import pandas as pd
from openai import OpenAI

from client import EODHDMCP

eodhd_api_key = "your eodhd api key"
mcp_base_url = "https://mcp.eodhd.dev/mcp"

openai_api_key = "your openai api key"
model_name = "gpt-5.3-chat-latest"

max_lookback_days = 365
max_tool_calls = 10
max_tickers = 5

mcp = EODHDMCP(eodhd_api_key, base_url=mcp_base_url)
oa = OpenAI(api_key=openai_api_key)

def log_event(event, trace_id, **extra):
    payload = {
        "event": event,
        "trace_id": trace_id,
        "ts": round(time.time(), 3),
    }
    payload.update(extra)
    print(json.dumps(payload, default=str))

def get_dates_from_lookback(days):
    end = date.today()
    start = end - timedelta(days=int(days))
    return start.isoformat(), end.isoformat()

def make_state():
    return {
        "tool_calls": 0,
        "tool_trace": [],
    }

def bump_tool_call(state, meta):
    state["tool_calls"] += 1
    state["tool_trace"].append(meta)

    if state["tool_calls"] &gt; max_tool_calls:
        raise RuntimeError("tool call budget exceeded")

def to_text(out):
    if isinstance(out, str):
        return out.strip()

    if hasattr(out, "content"):
        try:
            parts = []
            for item in out.content:
                if hasattr(item, "text") and item.text is not None:
                    parts.append(item.text)
                else:
                    parts.append(str(item))
            return "\n".join(parts).strip()
        except Exception:
            pass

    return str(out).strip()
</code></pre>
<p>Note: 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 EODHD developer account.</p>
<p>This block does three things:</p>
<ul>
<li><p>First, it sets up the two clients we need. <code>mcp</code> is the EODHD MCP client from <code>client.py</code>, and <code>oa</code> is the OpenAI client that will be used for parsing and memo generation later.</p>
</li>
<li><p>Second, it defines a few small limits for the workflow. These help keep the system controlled by capping the lookback window, the number of tickers, and the number of tool calls in a single run.</p>
</li>
<li><p>Third, it adds helper functions that the rest of the file depends on. <code>log_event()</code> gives us lightweight tracing, <code>get_dates_from_lookback()</code> converts a lookback window into start and end dates, <code>make_state()</code> and <code>bump_tool_call()</code> help track MCP usage, and <code>to_text()</code> safely converts tool output into plain text before we parse it.</p>
</li>
</ul>
<h2 id="heading-parsing-a-research-prompt-into-a-structured-request">Parsing a Research Prompt into a Structured Request</h2>
<p>The first thing this copilot needs to do is clean up the input. A user isn't going to send a perfectly formatted request every time. They're more likely to write a research thought in plain English and mix the thesis, ticker, and timeframe into one prompt.</p>
<p>That is why the system starts by turning the raw prompt into four fields:</p>
<ul>
<li><p>ticker</p>
</li>
<li><p>lookback window</p>
</li>
<li><p>thesis</p>
</li>
<li><p>mode</p>
</li>
</ul>
<p>This logic goes into <code>core.py</code>.</p>
<pre><code class="language-python">def parse_request(text):
    prompt = f"""
You are extracting fields for a financial thesis-testing copilot.

Return only valid JSON with this exact shape:
{{
  "tickers": ["AAPL"],
  "lookback_days": 180,
  "thesis": "the actual thesis statement",
  "mode": "single"
}}

Rules:
- Extract only tickers explicitly mentioned or strongly implied.
- Do not invent tickers.
- If there are multiple tickers, mode must be "watchlist".
- If there is one ticker, mode must be "single".
- If no timeframe is mentioned, use 180.
- Convert months to days using 30 days per month.
- Convert years to days using 365 days per year.
- Keep the thesis concise but faithful to the user's intent.
- Return JSON only. No markdown. No explanation.

User request:
{text}
""".strip()

    r = oa.responses.create(
        model=model_name,
        input=[{"role": "user", "content": prompt}],
    )

    raw = r.output_text.strip()

    try:
        parsed = json.loads(raw)
    except Exception:
        raise RuntimeError(f"parser returned non-json text: {raw[:500]}")

    return parsed
</code></pre>
<p>This function gives the model one very narrow job. It's not asking for an opinion or analysis. It's only asking for structured extraction. That matters because we want flexibility at the input layer, but we don't want the whole workflow to become fuzzy.</p>
<p>Once the model returns that JSON, Python takes over and tightens it up.</p>
<pre><code class="language-python">def enforce_limits(parsed):
    tickers = parsed.get("tickers", [])
    if not isinstance(tickers, list):
        tickers = []

    tickers = [str(x).upper().strip() for x in tickers if str(x).strip()]
    tickers = tickers[:max_tickers]

    lookback_days = parsed.get("lookback_days", 180)
    try:
        lookback_days = int(lookback_days)
    except Exception:
        lookback_days = 180

    if lookback_days &lt; 1:
        lookback_days = 1
    if lookback_days &gt; max_lookback_days:
        lookback_days = max_lookback_days

    thesis = str(parsed.get("thesis", "")).strip()
    if not thesis:
        thesis = "No thesis provided."

    mode = parsed.get("mode", "single")
    if len(tickers) &gt; 1:
        mode = "watchlist"
    else:
        mode = "single"

    return {
        "tickers": tickers,
        "lookback_days": lookback_days,
        "thesis": thesis,
        "mode": mode,
    }
</code></pre>
<p>This second function is what keeps the workflow controlled. It cleans the tickers, caps how many we allow in one request, clamps the time window, and makes sure the mode matches the number of tickers. So the model gives us flexibility, while the code gives us boundaries. That combination is important for a build like this.</p>
<h2 id="heading-fetching-the-two-data-sources-historical-amp-fundamental-data">Fetching the Two Data Sources: Historical &amp; Fundamental Data</h2>
<p>Once the request is parsed, the next step is to pull the data that will feed the rest of the workflow. For this version, we only use two sources from EODHD: historical prices and fundamentals. That's enough to test a surprising number of thesis types without making the build unnecessarily wide.</p>
<p>Add these two functions to <code>core.py</code>:</p>
<pre><code class="language-python">async def fetch_prices(ticker, start_date, end_date, trace_id, state):
    args = {
        "ticker": ticker,
        "start_date": start_date,
        "end_date": end_date,
        "period": "d",
        "order": "a",
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_historical_stock_prices", args, trace_id)
    text = to_text(out)

    bump_tool_call(state, meta)

    if not text:
        raise RuntimeError("empty response from get_historical_stock_prices")

    try:
        data = json.loads(text)
    except Exception:
        raise RuntimeError(f"price tool returned non-json text: {text[:300]}")

    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    df = pd.DataFrame(data)
    if df.empty:
        return df

    keep = [c for c in ["date", "close"] if c in df.columns]
    df = df[keep].copy()
    df["ticker"] = ticker

    return df

async def fetch_fundamentals(ticker, trace_id, state):
    args = {
        "ticker": ticker,
        "include_financials": False,
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_fundamentals_data", args, trace_id)
    text = to_text(out)

    bump_tool_call(state, meta)

    if not text:
        raise RuntimeError("empty response from get_fundamentals_data")

    try:
        data = json.loads(text)
    except Exception:
        raise RuntimeError(f"fundamentals tool returned non-json text: {text[:300]}")

    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    return data
</code></pre>
<ul>
<li><p><code>fetch_prices()</code> pulls daily historical data for the requested window and reduces it to the fields we actually need right now: <code>date</code>, <code>close</code>, and the ticker itself. That trimmed DataFrame is what we'll later use for return, drawdown, volatility, trend, and other market signals.</p>
</li>
<li><p><code>fetch_fundamentals()</code> keeps the fundamentals payload as JSON because we'll extract different categories from it in the next sections, including margins, growth, valuation, revisions, and beta.</p>
</li>
</ul>
<p>A couple of details matter here. Both functions run through the same MCP wrapper, so they automatically inherit the timeout, retry, and metadata handling we already built in <code>client.py</code>. Both also call <code>bump_tool_call()</code>, which lets us track how many external calls were made during a single run. That becomes useful later when we want the workflow to stay inspectable rather than feel like a black box.</p>
<h2 id="heading-building-the-first-evidence-layer-from-price-data">Building the First Evidence Layer from Price&nbsp;Data</h2>
<p>Once the price data is in, the next step is to turn that raw series into something we can actually reason with. For this copilot, price history isn't the final answer, but it is still the first evidence layer. It helps us test claims around downside control, risk, momentum, and the quality of returns.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def compute_price_signals(prices_df):
    if prices_df is None or prices_df.empty:
        return {}

    df = prices_df.copy()
    df["date"] = pd.to_datetime(df["date"], errors="coerce")
    df["close"] = pd.to_numeric(df["close"], errors="coerce")

    df = df.dropna(subset=["date", "close"]).sort_values("date")
    if df.empty:
        return {}

    close = df["close"]
    rets = close.pct_change().dropna()

    out = {
        "n_points": int(len(close)),
        "start_price": float(close.iloc[0]),
        "end_price": float(close.iloc[-1]),
    }

    if len(close) &gt;= 2:
        out["ret_total"] = float(close.iloc[-1] / close.iloc[0] - 1)

    if not rets.empty:
        vol_daily = float(rets.std())
        vol_annualized = float(vol_daily * np.sqrt(252))

        out["vol_daily"] = vol_daily
        out["vol_annualized"] = vol_annualized

        if vol_annualized &gt; 0 and "ret_total" in out:
            out["ret_to_vol"] = float(out["ret_total"] / vol_annualized)

    peak = close.cummax()
    drawdown = close / peak - 1
    out["max_drawdown"] = float(drawdown.min())

    logp = np.log(close.values)
    x = np.arange(len(logp))
    if len(logp) &gt;= 3:
        out["trend_slope"] = float(np.polyfit(x, logp, 1)[0])
    else:
        out["trend_slope"] = 0.0

    return out
</code></pre>
<p>This function gives us a compact set of market signals from a plain close-price series. <code>ret_total</code> tells us how the stock moved over the full window. <code>vol_annualized</code> tells us how noisy that move was. <code>max_drawdown</code> is useful when the thesis talks about downside control. <code>trend_slope</code> gives us a simple directional measure, and <code>ret_to_vol</code> helps us judge return quality instead of looking at raw return alone.</p>
<p>The important point here is that we aren't asking the model to infer all of this from raw prices. We compute it first in Python, so the later reasoning step starts from explicit signals rather than vague interpretation. That makes the whole workflow much more stable.</p>
<h2 id="heading-building-the-second-evidence-layer-from-fundamentals">Building the Second Evidence Layer from Fundamentals</h2>
<p>Price data gives us one side of the thesis. The second side comes from fundamentals. This is the part that makes the project stop sounding generic. Once the copilot starts treating fundamentals as actual evidence, instead of just company profile data, the outputs become much more useful.</p>
<p>Add this helper first in <code>core.py</code>:</p>
<pre><code class="language-python">def _to_float(x):
    if x in (None, "", "NA"):
        return None
    try:
        return float(x)
    except Exception:
        return None
</code></pre>
<p>This small function just cleans values before we use them. Fundamentals payloads often contain strings, nulls, or <code>"NA"</code>, so it helps to normalize everything early.</p>
<p>Now add the main function:</p>
<pre><code class="language-python">def compute_fundamental_signals(fundamentals):
    if not isinstance(fundamentals, dict):
        return {}

    general = fundamentals.get("General", {}) or {}
    highlights = fundamentals.get("Highlights", {}) or {}
    valuation = fundamentals.get("Valuation", {}) or {}
    technicals = fundamentals.get("Technicals", {}) or {}

    earnings = fundamentals.get("Earnings", {}) or {}
    trend = earnings.get("Trend", {}) or {}

    latest_trend = None
    if isinstance(trend, dict) and trend:
        latest_key = sorted(trend.keys())[-1]
        latest_trend = trend.get(latest_key, {}) or {}
    else:
        latest_trend = {}

    out = {
        "sector": general.get("Sector"),
        "industry": general.get("Industry"),
        "employees": _to_float(general.get("FullTimeEmployees")),

        "market_cap": _to_float(highlights.get("MarketCapitalization")),
        "pe_ratio": _to_float(highlights.get("PERatio")),
        "peg_ratio": _to_float(highlights.get("PEGRatio")),
        "profit_margin": _to_float(highlights.get("ProfitMargin")),
        "operating_margin": _to_float(highlights.get("OperatingMarginTTM")),
        "roa": _to_float(highlights.get("ReturnOnAssetsTTM")),
        "roe": _to_float(highlights.get("ReturnOnEquityTTM")),
        "revenue_ttm": _to_float(highlights.get("RevenueTTM")),
        "revenue_growth_yoy": _to_float(highlights.get("QuarterlyRevenueGrowthYOY")),
        "earnings_growth_yoy": _to_float(highlights.get("QuarterlyEarningsGrowthYOY")),
        "dividend_yield": _to_float(highlights.get("DividendYield")),

        "trailing_pe": _to_float(valuation.get("TrailingPE")),
        "forward_pe": _to_float(valuation.get("ForwardPE")),
        "price_sales": _to_float(valuation.get("PriceSalesTTM")),
        "price_book": _to_float(valuation.get("PriceBookMRQ")),
        "ev_revenue": _to_float(valuation.get("EnterpriseValueRevenue")),
        "ev_ebitda": _to_float(valuation.get("EnterpriseValueEbitda")),

        "beta": _to_float(technicals.get("Beta")),

        "earnings_estimate_growth": _to_float(latest_trend.get("earningsEstimateGrowth")),
        "revenue_estimate_growth": _to_float(latest_trend.get("revenueEstimateGrowth")),
        "eps_revisions_up_30d": _to_float(latest_trend.get("epsRevisionsUpLast30days")),
        "eps_revisions_down_30d": _to_float(latest_trend.get("epsRevisionsDownLast30days")),
    }

    if out["trailing_pe"] is not None and out["forward_pe"] is not None:
        out["forward_vs_trailing_pe_change"] = out["forward_pe"] - out["trailing_pe"]

    if out["eps_revisions_up_30d"] is not None and out["eps_revisions_down_30d"] is not None:
        out["net_eps_revisions_30d"] = out["eps_revisions_up_30d"] - out["eps_revisions_down_30d"]

    return out
</code></pre>
<p>This function pulls together the parts of the fundamentals payload that matter most for thesis testing.</p>
<ul>
<li><p>From <code>Highlights</code>, we get profitability, returns on capital, growth, and market cap. From <code>Valuation</code>, we get multiples like trailing P/E, forward P/E, price-to-sales, and EV-based ratios.</p>
</li>
<li><p>From <code>Technicals</code>, we take beta.</p>
</li>
<li><p>From <code>Earnings.Trend</code>, we pick up forward estimate growth and revision data.</p>
</li>
</ul>
<p>These are the fields that let us test claims around business quality, premium justification, valuation, and forward expectations in a much more concrete way.</p>
<p>The last two derived fields are also useful. The gap between forward P/E and trailing P/E gives us a quick way to see whether valuation is easing or staying stretched. Net EPS revisions over the last 30 days tell us whether analyst expectations are improving or deteriorating.</p>
<h2 id="heading-what-do-we-have-so-far">What Do We Have So Far?</h2>
<p>At this point, the copilot can parse a thesis, fetch prices and fundamentals, and convert both into two reusable signal layers:</p>
<ul>
<li><p>Price signals cover return, volatility, drawdown, trend, and return quality</p>
</li>
<li><p>Fundamentals signals cover margins, returns on capital, growth, valuation, revisions, and beta.</p>
</li>
</ul>
<p>Next, we’ll turn those signals into what a real research workflow needs: supporting evidence, weakening evidence, what’s missing, a verdict, and the final memo.</p>
<h2 id="heading-classifying-the-thesis">Classifying the&nbsp;Thesis</h2>
<p>Before the copilot can judge a thesis, it first needs to understand what kind of claim is being made.</p>
<p>This matters because not every thesis should be tested the same way. A claim about controlled downside should care more about drawdown and volatility. A claim about business quality should lean more on margins, returns on capital, and growth. A claim about premium justification may need both business quality and valuation context.</p>
<p>So instead of jumping straight from signals to a verdict, we'll add a small classification step. This gives the system a short list of claim types to work with and a cleaner summary of the thesis.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def classify_thesis(thesis):
    prompt = f"""
You are classifying a stock thesis into a few broad claim types.

Return only valid JSON like this:
{{
  "claim_types": ["controlled_downside", "business_quality"],
  "summary": "short restatement of the thesis"
}}

Allowed claim types:
- controlled_downside
- momentum_strength
- low_risk
- high_risk
- valuation_attractive
- valuation_expensive
- business_quality
- weak_business_quality
- premium_justified
- premium_not_justified

Rules:
- pick only the claim types that are clearly relevant
- do not invent extra labels
- if nothing fits strongly, return an empty list
- summary should be short and faithful

Thesis:
{thesis}
""".strip()

    r = oa.responses.create(
        model=model_name,
        input=[{"role": "user", "content": prompt}],
    )

    raw = r.output_text.strip()

    try:
        out = json.loads(raw)
    except Exception:
        raise RuntimeError(f"thesis classifier returned non-json text: {raw[:500]}")

    claim_types = out.get("claim_types", [])
    if not isinstance(claim_types, list):
        claim_types = []

    clean = []
    allowed = {
        "controlled_downside",
        "momentum_strength",
        "low_risk",
        "high_risk",
        "valuation_attractive",
        "valuation_expensive",
        "business_quality",
        "weak_business_quality",
        "premium_justified",
        "premium_not_justified",
    }

    for x in claim_types:
        x = str(x).strip()
        if x in allowed and x not in clean:
            clean.append(x)

    return {
        "claim_types": clean,
        "summary": str(out.get("summary", "")).strip(),
    }
</code></pre>
<p>This function keeps the model’s job narrow. It's not being asked to decide whether the thesis is right or wrong. It's only being asked to identify the kind of thesis it's dealing with. That makes the next step much cleaner, because the evidence engine no longer has to treat every prompt the same way.</p>
<p>The validation at the bottom is important too. Even though the model returns the labels, Python still filters them through an allowed set and removes anything unexpected. That keeps this step flexible, but still controlled.</p>
<h2 id="heading-turning-signals-into-support-contradiction-and-missing-evidence">Turning Signals into Support, Contradiction, and Missing&nbsp;Evidence</h2>
<p>This is the step where the copilot actually starts reasoning.</p>
<p>Up to this point, we have three things in hand. We have the thesis, we have the claim types, and we have the signal layers built from price data and fundamentals. But none of that is useful on its own unless the system can turn it into a clear argument.</p>
<p>That means it needs to answer three questions for every thesis:</p>
<ul>
<li><p>What in the data supports this claim?</p>
</li>
<li><p>What in the data weakens it?</p>
</li>
<li><p>What is still missing before we can judge it properly?</p>
</li>
</ul>
<p>That's exactly what <code>build_evidence_blocks()</code> does. It takes the classified thesis, checks the relevant price and fundamentals signals, and sorts them into three buckets: support, contradiction, and missing evidence.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def build_evidence_blocks(thesis, thesis_tags, price_signals, fundamental_signals):
    evidence_for = []
    evidence_against = []
    missing_evidence = []

    ret_total = price_signals.get("ret_total")
    vol = price_signals.get("vol_annualized")
    dd = price_signals.get("max_drawdown")
    trend = price_signals.get("trend_slope")
    ret_to_vol = price_signals.get("ret_to_vol")

    pe = fundamental_signals.get("pe_ratio") or fundamental_signals.get("trailing_pe")
    forward_pe = fundamental_signals.get("forward_pe")
    beta = fundamental_signals.get("beta")

    profit_margin = fundamental_signals.get("profit_margin")
    operating_margin = fundamental_signals.get("operating_margin")
    roa = fundamental_signals.get("roa")
    roe = fundamental_signals.get("roe")
    revenue_growth = fundamental_signals.get("revenue_growth_yoy")
    earnings_growth = fundamental_signals.get("earnings_growth_yoy")
    earnings_estimate_growth = fundamental_signals.get("earnings_estimate_growth")
    revenue_estimate_growth = fundamental_signals.get("revenue_estimate_growth")
    net_eps_revisions = fundamental_signals.get("net_eps_revisions_30d")

    claim_types = thesis_tags.get("claim_types", [])

    if "controlled_downside" in claim_types:
        if dd is not None:
            if dd &gt; -0.15:
                evidence_for.append(f"Maximum drawdown was relatively contained at {dd:.2%}.")
            else:
                evidence_against.append(f"Maximum drawdown reached {dd:.2%}, which weakens the controlled-downside claim.")
        else:
            missing_evidence.append("No drawdown signal available to test downside control.")

    if "momentum_strength" in claim_types:
        if trend is not None and ret_total is not None:
            if trend &gt; 0 and ret_total &gt; 0:
                evidence_for.append(f"Trend was positive and total return over the window was {ret_total:.2%}.")
            else:
                evidence_against.append("Trend and total return do not strongly support a momentum-strength view.")
        else:
            missing_evidence.append("No usable trend or return signal available to test momentum.")

    if "low_risk" in claim_types:
        if vol is not None:
            if vol &lt; 0.30:
                evidence_for.append(f"Annualized volatility was {vol:.2%}, which supports a lower-risk view.")
            else:
                evidence_against.append(f"Annualized volatility was {vol:.2%}, which weakens a low-risk thesis.")
        else:
            missing_evidence.append("No volatility signal available to test risk.")

    if "high_risk" in claim_types:
        if vol is not None:
            if vol &gt;= 0.30:
                evidence_for.append(f"Annualized volatility was {vol:.2%}, which supports a higher-risk view.")
            else:
                evidence_against.append(f"Annualized volatility was only {vol:.2%}, which does not strongly support a high-risk thesis.")
        else:
            missing_evidence.append("No volatility signal available to test risk.")

    if "valuation_attractive" in claim_types:
        if pe is not None:
            if pe &lt; 20:
                evidence_for.append(f"P/E is {pe:.2f}, which supports a more attractive valuation view.")
            elif pe &gt; 30:
                evidence_against.append(f"P/E is {pe:.2f}, which weakens the attractive-valuation claim.")
        else:
            missing_evidence.append("No P/E metric available to test valuation attractiveness.")

        if forward_pe is not None and pe is not None:
            if forward_pe &lt; pe:
                evidence_for.append(f"Forward P/E ({forward_pe:.2f}) is below trailing P/E ({pe:.2f}), which can support an improving earnings setup.")

    if "valuation_expensive" in claim_types or "premium_not_justified" in claim_types:
        if pe is not None:
            if pe &gt; 30:
                evidence_for.append(f"P/E is {pe:.2f}, which supports an expensive-valuation view.")
            else:
                evidence_against.append(f"P/E is {pe:.2f}, which does not strongly support an expensive-valuation claim.")
        else:
            missing_evidence.append("No P/E metric available to test whether valuation looks expensive.")

    if "business_quality" in claim_types or "premium_justified" in claim_types:
        quality_hits = 0

        if operating_margin is not None:
            if operating_margin &gt;= 0.25:
                evidence_for.append(f"Operating margin is {operating_margin:.2%}, which supports strong business quality.")
                quality_hits += 1
            else:
                evidence_against.append(f"Operating margin is {operating_margin:.2%}, which is not especially strong for a quality claim.")

        if profit_margin is not None:
            if profit_margin &gt;= 0.20:
                evidence_for.append(f"Profit margin is {profit_margin:.2%}, which supports business quality.")
                quality_hits += 1
            else:
                evidence_against.append(f"Profit margin is {profit_margin:.2%}, which weakens a strong-quality thesis.")

        if roa is not None:
            if roa &gt;= 0.10:
                evidence_for.append(f"ROA is {roa:.2%}, which supports efficient asset use.")
                quality_hits += 1
            else:
                evidence_against.append(f"ROA is {roa:.2%}, which does not strongly support a quality claim.")

        if roe is not None:
            if roe &gt;= 0.20:
                evidence_for.append(f"ROE is {roe:.2%}, which supports strong capital efficiency.")
                quality_hits += 1
            else:
                evidence_against.append(f"ROE is {roe:.2%}, which is weaker than expected for a strong-quality thesis.")

        if revenue_growth is not None:
            if revenue_growth &gt; 0:
                evidence_for.append(f"Quarterly revenue growth was {revenue_growth:.2%} YoY, which supports business momentum.")
                quality_hits += 1
            else:
                evidence_against.append(f"Quarterly revenue growth was {revenue_growth:.2%} YoY, which weakens the quality claim.")

        if earnings_growth is not None:
            if earnings_growth &gt; 0:
                evidence_for.append(f"Quarterly earnings growth was {earnings_growth:.2%} YoY, which supports operating strength.")
                quality_hits += 1
            else:
                evidence_against.append(f"Quarterly earnings growth was {earnings_growth:.2%} YoY, which weakens the quality claim.")

        if earnings_estimate_growth is not None:
            if earnings_estimate_growth &gt; 0:
                evidence_for.append(f"Forward earnings estimate growth is {earnings_estimate_growth:.2%}, which supports a healthier forward outlook.")
            else:
                evidence_against.append(f"Forward earnings estimate growth is {earnings_estimate_growth:.2%}, which weakens the quality argument.")

        if revenue_estimate_growth is not None:
            if revenue_estimate_growth &gt; 0:
                evidence_for.append(f"Forward revenue estimate growth is {revenue_estimate_growth:.2%}, which supports ongoing business strength.")
            else:
                evidence_against.append(f"Forward revenue estimate growth is {revenue_estimate_growth:.2%}, which weakens the quality argument.")

        if net_eps_revisions is not None:
            if net_eps_revisions &gt; 0:
                evidence_for.append(f"Net EPS revisions over the last 30 days are positive ({net_eps_revisions:.0f}), which supports improving expectations.")
            elif net_eps_revisions &lt; 0:
                evidence_against.append(f"Net EPS revisions over the last 30 days are negative ({net_eps_revisions:.0f}), which weakens the thesis.")

        if quality_hits == 0:
            missing_evidence.append("This version could not extract enough direct business-quality metrics to test the quality claim.")

    if "weak_business_quality" in claim_types:
        if operating_margin is not None and operating_margin &lt; 0.15:
            evidence_for.append(f"Operating margin is only {operating_margin:.2%}, which supports a weaker-quality view.")
        if profit_margin is not None and profit_margin &lt; 0.10:
            evidence_for.append(f"Profit margin is only {profit_margin:.2%}, which supports a weaker-quality view.")
        if revenue_growth is not None and revenue_growth &lt;= 0:
            evidence_for.append(f"Revenue growth is {revenue_growth:.2%} YoY, which supports a weaker-quality view.")
        if earnings_growth is not None and earnings_growth &lt;= 0:
            evidence_for.append(f"Earnings growth is {earnings_growth:.2%} YoY, which supports a weaker-quality view.")

    if beta is not None:
        if beta &gt; 1.2:
            evidence_against.append(f"Beta is {beta:.2f}, which suggests above-market sensitivity.")
        elif beta &lt; 0.9:
            evidence_for.append(f"Beta is {beta:.2f}, which suggests below-market sensitivity.")
    else:
        missing_evidence.append("No beta value available.")

    if ret_to_vol is None:
        missing_evidence.append("No return-to-volatility signal available.")

    if not evidence_for and not evidence_against:
        missing_evidence.append("The current data is not enough to strongly support or reject the thesis.")

    return {
        "thesis": thesis,
        "thesis_summary": thesis_tags.get("summary", ""),
        "claim_types": claim_types,
        "evidence_for": evidence_for,
        "evidence_against": evidence_against,
        "missing_evidence": list(dict.fromkeys(missing_evidence)),
    }
</code></pre>
<p>The function looks long, but the logic is simple once you break it down.</p>
<p>It starts by pulling the signals it needs from the two evidence layers that we built earlier. Then it checks the thesis tags one by one. If the thesis is about controlled downside, it looks at drawdown. If it's about risk, it looks at volatility and beta. If't is about business quality, it leans on margins, returns on capital, growth, and revisions. If it's about valuation, it checks multiples like P/E and the relationship between forward and trailing valuation.</p>
<p>That's the key shift in this project. The copilot is no longer just collecting data. It's deciding which parts of the EODHD-backed signal set actually matter for the thesis in front of it.</p>
<p>The three output buckets are what make this useful.</p>
<ul>
<li><p><code>evidence_for</code> holds the points that support the claim.</p>
</li>
<li><p><code>evidence_against</code> holds the points that weaken it.</p>
</li>
<li><p><code>missing_evidence</code> makes the gaps explicit instead of letting the system sound more confident than it should.</p>
</li>
</ul>
<p>That's what makes this feel like a thesis-testing workflow rather than a polished stock summary.</p>
<h3 id="heading-sanity-check-jupyter-notebook">Sanity Check (Jupyter Notebook)</h3>
<p>Run this code inside <code>test.ipynb</code> for a quick sanity check:</p>
<pre><code class="language-python">import uuid
from core import (
    fetch_prices,
    fetch_fundamentals,
    compute_price_signals,
    classify_thesis,
    build_evidence_blocks,
    make_state
)
import json

trace_id = uuid.uuid4().hex[:10]
state = make_state()

thesis = "Apple looks attractive because downside has been controlled and business quality remains high."

prices = await fetch_prices("AAPL.US", "2026-01-01", "2026-04-01", trace_id, state)
funds = await fetch_fundamentals("AAPL.US", trace_id, state)

signals = compute_price_signals(prices)
tags = classify_thesis(thesis)
evidence = build_evidence_blocks(thesis, tags, signals, funds)

print(tags)
print(json.dumps(evidence, indent=2))
</code></pre>
<p><strong>Expected Output:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/38ec0e04-b237-4ebb-8b26-61e2f82f36b0.png" alt="Sanity check expected output" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<h2 id="heading-assigning-a-verdict">Assigning a&nbsp;Verdict</h2>
<p>Once the evidence is structured, the copilot still needs one more layer before it can write a memo. It needs a controlled way to label the thesis.</p>
<p>That's the job of <code>decide_verdict()</code>. It looks at how much evidence supports the thesis, how much weakens it, and whether the claim still depends on missing business-quality or valuation evidence. The goal here isn't to create a perfect scoring model. It's to make sure the system doesn't jump from a few evidence strings straight into a confident conclusion.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def decide_verdict(evidence, claim_types=None):
    claim_types = claim_types or []

    evidence_for = evidence.get("evidence_for", [])
    evidence_against = evidence.get("evidence_against", [])
    missing = evidence.get("missing_evidence", [])

    n_for = len(evidence_for)
    n_against = len(evidence_against)
    n_missing = len(missing)

    quality_claim = any(x in claim_types for x in ["business_quality", "weak_business_quality", "premium_justified", "premium_not_justified"])
    valuation_claim = any(x in claim_types for x in ["valuation_attractive", "valuation_expensive", "premium_justified", "premium_not_justified"])

    if n_for == 0 and n_against == 0:
        return {
            "verdict": "unresolved_due_to_missing_evidence",
            "reason": "There is not enough usable evidence to test the thesis.",
        }

    if quality_claim and n_missing &gt;= 1:
        if n_against &gt; 0:
            return {
                "verdict": "weakly_supported",
                "reason": "Some evidence supports the thesis, but direct business-quality evidence is missing and contradictory signals remain.",
            }
        return {
            "verdict": "partially_supported",
            "reason": "Part of the thesis is supported, but direct business-quality evidence is missing.",
        }

    if valuation_claim and n_missing &gt;= 1:
        return {
            "verdict": "unresolved_due_to_missing_evidence",
            "reason": "The thesis depends on valuation evidence that is not available in this version.",
        }

    if n_for &gt; 0 and n_against == 0:
        if n_missing &gt;= 2:
            return {
                "verdict": "partially_supported",
                "reason": "The available evidence supports the thesis, but important evidence is still missing.",
            }
        return {
            "verdict": "supported",
            "reason": "The available evidence mainly supports the thesis.",
        }

    if n_against &gt; 0 and n_for == 0:
        return {
            "verdict": "not_supported",
            "reason": "The available evidence mainly weakens the thesis.",
        }

    if n_for &gt; n_against:
        return {
            "verdict": "partially_supported",
            "reason": "There is more supporting evidence than contradicting evidence, but the thesis is not fully confirmed.",
        }

    if n_against &gt;= n_for:
        return {
            "verdict": "weakly_supported",
            "reason": "Contradicting evidence is meaningful enough that the thesis is only weakly supported.",
        }

    return {
        "verdict": "unresolved_due_to_missing_evidence",
        "reason": "The evidence is mixed and does not clearly resolve the thesis.",
    }
</code></pre>
<p>The logic here is intentionally simple. It doesn't try to do fine-grained scoring. Instead, it uses the shape of the evidence to decide whether the thesis is supported, partially supported, weakly supported, not supported, or still unresolved.</p>
<p>A couple of checks matter more than the rest. If the thesis depends on business-quality or valuation evidence and that evidence is still missing, the verdict gets capped early instead of sounding stronger than it should. That is important because a thesis can look convincing on price behavior alone, but still be incomplete if the claim depends on fundamentals that aren't actually present.</p>
<p>The other useful thing about this function is that it returns both a short label and a reason. That makes the final output easier to understand later, and it also gives the memo-writing step something cleaner to work from than a bare category.</p>
<h2 id="heading-building-the-facts-object">Building the Facts&nbsp;Object</h2>
<p>Before the memo gets written, the system first puts everything into one structured object. That object becomes the single source of truth for the final output. Instead of handing the model a mix of scattered variables, we'll give it one clean package containing the thesis, signals, company context, evidence, and verdict.</p>
<h3 id="heading-1-company-context">1. Company&nbsp;Context</h3>
<p>We’ll start with a small helper that pulls the basic company context from the fundamentals payload.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def extract_company_context(fundamentals):
    if not isinstance(fundamentals, dict):
        return {}

    gen = fundamentals.get("General", {}) or {}

    out = {
        "name": gen.get("Name"),
        "code": gen.get("Code"),
        "exchange": gen.get("Exchange"),
        "sector": gen.get("Sector"),
        "industry": gen.get("Industry"),
        "country": gen.get("CountryName"),
        "market_cap": gen.get("MarketCapitalization"),
        "pe_ratio": gen.get("PERatio"),
        "beta": gen.get("Beta"),
        "dividend_yield": gen.get("DividendYield"),
        "description": gen.get("Description"),
    }

    clean = {}
    for k, v in out.items():
        if v not in (None, "", "NA"):
            clean[k] = v

    return clean
</code></pre>
<p>This function is just a cleanup step. It gives us a compact company context block that can later sit alongside the price and fundamentals signals without dragging the full fundamentals payload into the memo layer.</p>
<h3 id="heading-2-single-stock-facts-builder">2. Single-Stock Facts&nbsp;Builder</h3>
<p>Now add the single-stock facts builder:</p>
<pre><code class="language-python">def build_thesis_facts(parsed, ticker, signals, fundamentals, thesis_tags, evidence):
    company = extract_company_context(fundamentals)

    facts = {
        "type": "single_name_thesis_test",
        "ticker": ticker,
        "lookback_days": parsed["lookback_days"],
        "thesis": parsed["thesis"],
        "thesis_summary": thesis_tags.get("summary", ""),
        "claim_types": thesis_tags.get("claim_types", []),
        "market_signals": {
            "ret_total": signals.get("ret_total"),
            "vol_annualized": signals.get("vol_annualized"),
            "max_drawdown": signals.get("max_drawdown"),
            "trend_slope": signals.get("trend_slope"),
            "ret_to_vol": signals.get("ret_to_vol"),
            "start_price": signals.get("start_price"),
            "end_price": signals.get("end_price"),
            "n_points": signals.get("n_points"),
        },
        "company_context": {
            "name": company.get("name"),
            "exchange": company.get("exchange"),
            "sector": company.get("sector"),
            "industry": company.get("industry"),
            "country": company.get("country"),
            "market_cap": company.get("market_cap"),
            "pe_ratio": company.get("pe_ratio"),
            "beta": company.get("beta"),
            "dividend_yield": company.get("dividend_yield"),
        },
        "description": company.get("description"),
        "evidence_for": evidence.get("evidence_for", []),
        "evidence_against": evidence.get("evidence_against", []),
        "missing_evidence": evidence.get("missing_evidence", []),
    }

    facts["verdict"] = decide_verdict(evidence, thesis_tags.get("claim_types", []))
    return facts
</code></pre>
<p>This is the main facts object for a single-stock thesis. It pulls together the parsed thesis, the market signals, the basic company context, the evidence buckets, and the verdict. At this point, the copilot has already done the reasoning work. The memo isn't deciding anything new. It's just writing from this object.</p>
<h3 id="heading-3-watchlist-facts-builder">3. Watchlist Facts&nbsp;Builder</h3>
<p>Now add the watchlist version:</p>
<pre><code class="language-python">def build_watchlist_facts(parsed, tickers, signals_by_ticker, fundamentals_by_ticker, thesis_tags, evidence_by_ticker):
    per_ticker = {}

    for t in tickers:
        company = extract_company_context(fundamentals_by_ticker.get(t, {}))
        signals = signals_by_ticker.get(t, {})
        evidence = evidence_by_ticker.get(t, {})

        per_ticker[t] = {
            "company_context": {
                "name": company.get("name"),
                "sector": company.get("sector"),
                "industry": company.get("industry"),
                "market_cap": company.get("market_cap"),
                "pe_ratio": company.get("pe_ratio"),
                "beta": company.get("beta"),
            },
            "market_signals": {
                "ret_total": signals.get("ret_total"),
                "vol_annualized": signals.get("vol_annualized"),
                "max_drawdown": signals.get("max_drawdown"),
                "trend_slope": signals.get("trend_slope"),
                "ret_to_vol": signals.get("ret_to_vol"),
            },
            "evidence_for": evidence.get("evidence_for", []),
            "evidence_against": evidence.get("evidence_against", []),
            "missing_evidence": evidence.get("missing_evidence", []),
            "verdict": decide_verdict(evidence, thesis_tags.get("claim_types", []))
        }

    facts = {
        "type": "watchlist_thesis_test",
        "tickers": tickers,
        "lookback_days": parsed["lookback_days"],
        "thesis": parsed["thesis"],
        "thesis_summary": thesis_tags.get("summary", ""),
        "claim_types": thesis_tags.get("claim_types", []),
        "per_ticker": per_ticker,
    }

    return facts
</code></pre>
<p>This version does the same thing, but across multiple tickers. Instead of one top-level evidence block, it stores a per-ticker structure so the memo layer can later compare names without needing to reconstruct anything.</p>
<p>That is the main reason this section matters. By the time we reach the memo step, we no longer want to pass loose values around. We want one structured object that already contains:</p>
<ul>
<li><p>the thesis</p>
</li>
<li><p>the relevant signals</p>
</li>
<li><p>the company context</p>
</li>
<li><p>the evidence buckets</p>
</li>
<li><p>the verdict</p>
</li>
</ul>
<p>That keeps the final writing step much cleaner and makes the whole workflow easier to debug.</p>
<h3 id="heading-sanity-check-jupyter-notebook">Sanity Check (Jupyter Notebook)</h3>
<p>Run this code inside <code>test.ipynb</code> for a quick sanity check:</p>
<pre><code class="language-python">from core import build_thesis_facts, extract_company_context

facts = build_thesis_facts(
    parsed={
        "tickers": ["AAPL"],
        "lookback_days": 180,
        "thesis": "Apple looks attractive because downside has been controlled and business quality remains high.",
        "mode": "single"
    },
    ticker="AAPL.US",
    signals=signals,
    fundamentals=funds,
    thesis_tags=tags,
    evidence=evidence
)

print(json.dumps(facts, indent=2))
</code></pre>
<p><strong>Expected Output:</strong></p>
<pre><code class="language-json">{
  "type": "single_name_thesis_test",
  "ticker": "AAPL.US",
  "lookback_days": 180,
  "thesis": "Apple looks attractive because downside has been controlled and business quality remains high.",
  "thesis_summary": "Apple is attractive due to controlled downside and strong business quality",
  "claim_types": [
    "controlled_downside",
    "business_quality"
  ],
  "market_signals": {
    "ret_total": -0.05675067340688533,
    "vol_annualized": 0.2504818805125429,
    "max_drawdown": -0.11322450740687473,
    "trend_slope": -0.0005437843809243782,
    "ret_to_vol": -0.22656598270006817,
    "start_price": 271.01,
    "end_price": 255.63,
    "n_points": 62
  },
  "company_context": {
    "name": "Apple Inc",
    "exchange": "NASDAQ",
    "sector": "Technology",
    "industry": "Consumer Electronics",
    "country": "USA",
    "market_cap": null,
    "pe_ratio": null,
    "beta": null,
    "dividend_yield": null
  },
  "description": "Apple Inc. designs, manufactures, and markets smartphones, personal computers, tablets, wearables, and accessories worldwide. The company offers iPhone, a line of smartphones; Mac, a line of personal computers; iPad, a line of multi-purpose tablets; and wearables, home, and accessories comprising AirPods, Apple Vision Pro, Apple TV, Apple Watch, Beats products, and HomePod, as well as Apple branded and third-party accessories. It also provides AppleCare support and cloud services; and operates various platforms, including the App Store that allow customers to discover and download applications and digital content, such as books, music, video, games, and podcasts, as well as advertising services include third-party licensing arrangements and its own advertising platforms. In addition, the company offers various subscription-based services, such as Apple Arcade, a game subscription service; Apple Fitness+, a personalized fitness service; Apple Music, which offers users a curated listening experience with on-demand radio stations; Apple News+, a subscription news and magazine service; Apple TV, which offers exclusive original content and live sports; Apple Card, a co-branded credit card; and Apple Pay, a cashless payment service, as well as licenses its intellectual property. The company serves consumers, and small and mid-sized businesses; and the education, enterprise, and government markets. It distributes third-party applications for its products through the App Store. The company also sells its products through its retail and online stores, and direct sales force; and third-party cellular network carriers and resellers. The company was formerly known as Apple Computer, Inc. and changed its name to Apple Inc. in January 2007. Apple Inc. was founded in 1976 and is headquartered in Cupertino, California.",
  "evidence_for": [
    "Maximum drawdown was relatively contained at -11.32%."
  ],
  "evidence_against": [],
  "missing_evidence": [
    "This version does not include direct business-quality metrics such as margins, growth, cash flow, or return on capital.",
    "Only basic company context is available, which is not enough on its own to confirm business quality.",
    "No beta value available."
  ],
  "verdict": {
    "verdict": "partially_supported",
    "reason": "Part of the thesis is supported, but direct business-quality evidence is missing."
  }
}
</code></pre>
<h2 id="heading-writing-the-final-memo">Writing the Final&nbsp;Memo</h2>
<p>At this point, the hard part is already done.</p>
<p>By the time we reach the memo step, the copilot already has a structured facts object with the thesis, claim types, market signals, company context, evidence buckets, and verdict. So this final function isn't where the reasoning happens. It's just the presentation layer that turns that structured judgment into something readable.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def write_thesis_memo(facts):
    prompt = f"""
You are writing a short financial research memo.

Write using only the facts provided below.
Do not invent numbers, events, comparisons, or opinions beyond the supplied evidence.
If evidence is missing, say so clearly.

Use this exact structure:

1. Thesis under review
2. Supporting evidence
3. Evidence that weakens the thesis
4. Missing evidence
5. Verdict
6. Bottom-line assessment

Style rules:
- Keep it concise
- Keep it analytical and professional
- No bullet points unless necessary
- No hype
- No generic investment disclaimer language
- The bottom-line assessment should be balanced and evidence-based
- The verdict section must explicitly use the supplied verdict

Facts:
{json.dumps(facts, indent=2, default=str)}
""".strip()

    r = oa.responses.create(
        model=model_name,
        input=[{"role": "user", "content": prompt}],
    )

    return r.output_text.strip()
</code></pre>
<p>This function keeps the model boxed into one narrow task. It's not being asked to look at raw price history, raw fundamentals, or scattered variables. It's being asked to write from one clean facts object that already contains the judgment.</p>
<p>That separation matters because it keeps the final memo grounded. The model isn't deciding what it thinks about the stock at the last second. It's simply turning the structured output of the earlier steps into a short research note.</p>
<p>The prompt is also deliberately strict. It fixes the memo structure, tells the model not to invent anything, and makes the verdict explicit instead of leaving it implied. That helps the final output stay consistent even when the underlying thesis changes.</p>
<h3 id="heading-sanity-check-jupyter-notebook">Sanity Check (Jupyter Notebook)</h3>
<p>You can test it with a facts object from the previous section:</p>
<pre><code class="language-python">from core import write_thesis_memo

memo = write_thesis_memo(facts)
print(memo)
</code></pre>
<p><strong>Expected Output:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/b5f44144-8da4-4c9a-8a59-c5ac6915a6b0.png" alt="Sanity check expected output" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<h2 id="heading-stitching-everything-together">Stitching Everything Together</h2>
<p>At this point, all the individual pieces are ready. We have the parser, the data fetchers, the signal builders, the thesis classifier, the evidence engine, the verdict layer, and the memo writer. The only thing left is to connect them into one end-to-end function.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">async def run_thesis_copilot(user_text):
    trace_id = uuid.uuid4().hex[:10]
    log_event("request_started", trace_id, text=user_text)

    parsed = enforce_limits(parse_request(user_text))
    tickers = parsed["tickers"]

    if not tickers:
        return {
            "memo": "No valid ticker was found in the request.",
            "facts": {},
            "data_used": {},
            "tool_trace_id": trace_id,
        }

    log_event(
        "parsed",
        trace_id,
        tickers=tickers,
        lookback_days=parsed["lookback_days"],
        mode=parsed["mode"],
        thesis=parsed["thesis"],
    )

    start_date, end_date = get_dates_from_lookback(parsed["lookback_days"])
    state = make_state()

    try:
        thesis_tags = classify_thesis(parsed["thesis"])

        if parsed["mode"] == "single":
            ticker = tickers[0]
            ticker_full = ticker if "." in ticker else f"{ticker}.US"

            log_event(
                "tool_phase",
                trace_id,
                mode="single",
                ticker=ticker_full,
                start_date=start_date,
                end_date=end_date,
            )

            prices = await fetch_prices(ticker_full, start_date, end_date, trace_id, state)
            funds = await fetch_fundamentals(ticker_full, trace_id, state)

            price_signals = compute_price_signals(prices)
            fundamental_signals = compute_fundamental_signals(funds)

            evidence = build_evidence_blocks(
                parsed["thesis"],
                thesis_tags,
                price_signals,
                fundamental_signals
            )

            facts = build_thesis_facts(
                parsed,
                ticker_full,
                price_signals,
                funds,
                thesis_tags,
                evidence
            )

            facts["fundamental_signals"] = fundamental_signals

            memo = write_thesis_memo(facts)

            out = {
                "memo": memo,
                "facts": facts,
                "data_used": {
                    "tickers": [ticker_full],
                    "date_range": [start_date, end_date],
                    "tools_called": [x.get("tool") for x in state["tool_trace"]],
                    "tool_calls": state["tool_calls"],
                },
                "tool_trace_id": trace_id,
            }

            log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
            return out

        ticker_full = [x if "." in x else f"{x}.US" for x in tickers]

        log_event(
            "tool_phase",
            trace_id,
            mode="watchlist",
            tickers=ticker_full,
            start_date=start_date,
            end_date=end_date,
        )

        signals_by_ticker = {}
        funds_by_ticker = {}
        evidence_by_ticker = {}

        for t in ticker_full:
            prices = await fetch_prices(t, start_date, end_date, trace_id, state)
            funds = await fetch_fundamentals(t, trace_id, state)

            price_signals = compute_price_signals(prices)
            fundamental_signals = compute_fundamental_signals(funds)

            evidence = build_evidence_blocks(
                parsed["thesis"],
                thesis_tags,
                price_signals,
                fundamental_signals
            )

            signals_by_ticker[t] = {
                **price_signals,
                "fundamental_signals": fundamental_signals
            }
            funds_by_ticker[t] = funds
            evidence_by_ticker[t] = evidence

        facts = build_watchlist_facts(
            parsed,
            ticker_full,
            signals_by_ticker,
            funds_by_ticker,
            thesis_tags,
            evidence_by_ticker,
        )

        memo = write_thesis_memo(facts)

        out = {
            "memo": memo,
            "facts": facts,
            "data_used": {
                "tickers": ticker_full,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }

        log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
        return out

    except Exception as e:
        detail = repr(e)
        if hasattr(e, "exceptions"):
            detail = detail + " | " + " ; ".join([repr(x) for x in e.exceptions])

        log_event("request_failed", trace_id, err=detail)

        return {
            "memo": f"failed: {e}",
            "facts": {},
            "data_used": {
                "tickers": tickers,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }
</code></pre>
<p>This function is just the full workflow in one place. It parses the request, fetches the data, computes the two signal layers, builds the evidence, assembles the facts object, writes the memo, and returns everything in a clean output.</p>
<p>The useful part is that it returns more than just the memo. It also returns the structured facts object, the tools that were used, the date range, and the trace ID. That keeps the final result inspectable instead of turning the copilot into a black box.</p>
<h2 id="heading-demo-time-jupyter-notebook">Demo Time! (Jupyter Notebook)</h2>
<h3 id="heading-demo-1-testing-whether-a-premium-is-actually-justified">Demo 1: Testing Whether a Premium Is Actually Justified</h3>
<p>This is a good first demo because it pushes the copilot beyond a basic single-stock check. The prompt isn't asking whether NVIDIA is a good company in general. It's asking whether NVIDIA’s premium over AMD can actually be defended using market behavior and business quality.</p>
<p>Here's the prompt:</p>
<pre><code class="language-python">from core import run_thesis_copilot

q = """
Between NVDA and AMD, I think NVDA's premium is still justified by stronger market behavior and business quality.
Check that over the last 6 months.
""".strip()

result = await run_thesis_copilot(q)

print(result["memo"])
print(result["data_used"])
</code></pre>
<p>And here's the output:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/e4a9e881-243a-47bb-b36b-1e273deb8e04.png" alt="Demo 1 output" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>What makes this output useful is that it doesn't flatten the result into a simple yes or no. NVIDIA clearly looks stronger on business quality, but market behavior isn't as convincing, and the lack of direct valuation data stops the copilot from overclaiming.</p>
<p>This is the kind of behavior we want. The system isn't just comparing two companies. It's testing whether the specific claim about a premium actually holds up.</p>
<h3 id="heading-demo-2-testing-whether-volatility-is-too-high-for-the-underlying-business">Demo 2: Testing Whether Volatility Is Too High for the Underlying Business</h3>
<p>The second demo shifts back to a single-stock thesis, but the claim is different. This time, the question isn't whether the company looks attractive. It's whether the stock is more volatile than the underlying business quality would justify.</p>
<p>Here's the prompt:</p>
<pre><code class="language-python">q = """
TSLA feels too volatile for the underlying business quality.
Test that thesis over the last year.
""".strip()

result = await run_thesis_copilot(q)

print(result["memo"])
print(result["data_used"])
</code></pre>
<p>And here's the output:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/a9767ee9-d227-4478-a2aa-9ee62c46488c.png" alt="Demo 2 output" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This result is useful because it shows a more conflicted thesis. Tesla’s recent returns and forward growth expectations offer some support, but the current profitability, recent operating trends, revisions, and volatility profile all push back against the idea that the business quality is strong enough to fully justify that risk.</p>
<p>So the final verdict lands where it should: not as a clean confirmation, but as a weakly supported thesis.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>At this point, the copilot already does the most important part well. It can take a natural-language thesis, pull the right market and fundamentals data through EODHD’s MCP layer, turn those inputs into structured evidence, and return a research memo that's much more disciplined than a normal stock summary.</p>
<p>At the same time, this version still has clear limits. It doesn't yet go deeper into statement-level accounting logic, it doesn't use news or catalyst context, and its handling of relative valuation can still be stronger for more demanding comparison cases.</p>
<p>But even with those limits, the shift here is already meaningful. The real change wasn't just connecting a model to financial data. It was moving from summarizing stocks to testing claims.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Positioning-Based Crude Oil Strategy in Python [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ Commitment of Traders (COT) data gets referenced a lot in commodity trading, especially when people talk about crowded positioning, speculative sentiment, or reversal risk. But most of that discussion ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-positioning-based-crude-oil-strategy-in-python/</link>
                <guid isPermaLink="false">69d91ddfc8e5007ddbc0e7ca</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stockmarket ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Fri, 10 Apr 2026 15:57:19 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c18002cf-6519-4b76-b068-3b443cb0f347.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Commitment of Traders (COT) data gets referenced a lot in commodity trading, especially when people talk about crowded positioning, speculative sentiment, or reversal risk. But most of that discussion stays at the idea level. It rarely becomes a rule that can actually be tested.</p>
<p>That was the starting point for this project.</p>
<p>I wanted to see whether crude oil positioning data could be turned into something more useful than a vague market read. Not a polished macro narrative. An actual strategy framework that could be coded, tested, and challenged.</p>
<p>The goal here was not to begin with a finished strategy. It was to start with a reasonable hypothesis, build the signal step by step, and see what survived once the data was involved.</p>
<p>For this, I used FinancialModelingPrep’s Commitment of Traders data along with historical West Texas Intermediate (WTI) crude oil prices. The first idea was simple: if speculative positioning becomes extreme, maybe that tells us something about what crude oil might do next. But as the build progressed, that idea had to be narrowed, filtered, and reworked before it became usable.</p>
<p>So this article is not a clean showcase of a strategy that worked on the first try. It's the full process of getting there.</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-initial-idea-use-positioning-extremes-to-define-market-regimes">The Initial Idea: Use Positioning Extremes to Define Market Regimes</a></p>
</li>
<li><p><a href="#heading-importing-packages">Importing Packages</a></p>
</li>
<li><p><a href="#heading-pulling-the-data-cot--wti-crude-prices-using-fmp-apis">Pulling the Data: COT + WTI Crude Prices using FMP APIs</a></p>
</li>
<li><p><a href="#heading-turning-raw-cot-data-into-usable-features">Turning Raw COT Data Into Usable Features</a></p>
</li>
<li><p><a href="#heading-building-the-first-version-of-the-regime-model">Building the First Version of the Regime Model</a></p>
</li>
<li><p><a href="#heading-first-test-what-happens-after-each-regime">First Test: What Happens After Each Regime?</a></p>
</li>
<li><p><a href="#heading-looking-at-the-regimes-more-closely">Looking at the Regimes More Closely</a></p>
</li>
<li><p><a href="#heading-narrowing-the-focus-keeping-two-extra-variants-for-comparison">Narrowing the Focus: Keeping Two Extra Variants for Comparison</a></p>
</li>
<li><p><a href="#heading-building-the-first-trade-rules">Building the First Trade Rules</a></p>
</li>
<li><p><a href="#heading-comparing-bullish-unwind-against-buy-and-hold">Comparing Bullish Unwind Against Buy-and-Hold</a></p>
</li>
<li><p><a href="#heading-adding-a-trend-filter">Adding a Trend Filter</a></p>
</li>
<li><p><a href="#heading-stress-testing-the-setup">Stress-Testing the Setup</a></p>
</li>
<li><p><a href="#heading-the-final-strategy">The Final Strategy</a></p>
</li>
<li><p><a href="#heading-further-improvements">Further Improvements</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>To follow along with this article, you'll need a basic familiarity with Python and the pandas library, as we'll do most of the data manipulation and analysis using DataFrames. The following packages should be installed in your environment: <code>requests</code>, <code>numpy</code>, <code>pandas</code>, and <code>matplotlib</code>.</p>
<p>You'll also need a FinancialModelingPrep API key required to pull both the COT and WTI crude oil price data. If you don't have one, you can register for a free account on the FinancialModelingPrep website.</p>
<p>Finally, a general understanding of what the Commitment of Traders report is and what non-commercial positioning represents will help you follow the reasoning behind the signal construction, though it's not strictly necessary to get value from the code itself.</p>
<p>This article also assumes some baseline familiarity with financial markets and trading concepts. If terms like long and short positioning, open interest, or speculative sentiment are unfamiliar, it may be worth spending a little time with those before diving in.</p>
<h2 id="heading-the-initial-idea-use-positioning-extremes-to-define-market-regimes">The Initial Idea: Use Positioning Extremes to Define Market Regimes</h2>
<p>The first version of the idea was not a trading rule. It was a framework.</p>
<p>If speculative positioning in crude oil becomes extreme, that probably means different things depending on what happens next. A market that is heavily long and still getting more crowded is not the same as a market that is heavily long but starting to unwind. The same logic applies on the bearish side too.</p>
<p>So instead of forcing one blunt signal like “extreme long means short” or “extreme short means buy,” I started by splitting the market into regimes.</p>
<p>The two variables I used were simple. First, how extreme positioning is relative to recent history. Second, whether that positioning is still building or starting to reverse.</p>
<p>That gave me four possible states:</p>
<ul>
<li><p>bullish buildup</p>
</li>
<li><p>bullish unwind</p>
</li>
<li><p>bearish buildup</p>
</li>
<li><p>bearish unwind</p>
</li>
</ul>
<p>This felt like a better starting point than jumping straight into a strategy. It let me treat COT data as a way to describe market state first, then test whether any of those states actually led to useful price behavior.</p>
<p>At this stage, I still didn't know whether any of these regimes would hold up. The point was just to create a structure that could be tested properly.</p>
<h2 id="heading-importing-packages">Importing Packages</h2>
<p>We’ll keep the packages import minimal and simple.</p>
<pre><code class="language-python">import requests
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams["figure.figsize"] = (14,6)
plt.style.use("ggplot")

api_key = "YOUR FMP API KEY"
base_url = "https://financialmodelingprep.com/stable" 
</code></pre>
<p>Nothing fancy here. Make sure to replace YOUR FMP API KEY with your actual FMP API key. If you don’t have one, you can obtain it by opening a FMP developer account.</p>
<h2 id="heading-pulling-the-data-cot-wti-crude-prices-using-fmp-apis">Pulling the Data: COT + WTI Crude Prices using FMP APIs</h2>
<p>To build this strategy, I needed two datasets. First, I needed COT data for crude oil. Second, I needed historical WTI crude oil prices.</p>
<p>I started with the COT market list to identify the correct crude oil contract.</p>
<pre><code class="language-python">url = f"{base_url}/commitment-of-traders-list?apikey={api_key}"
r = requests.get(url)
cot_list = pd.DataFrame(r.json())

crude_candidates = cot_list[
    cot_list.astype(str)
    .apply(lambda col: col.str.contains("crude", case=False, na=False))
    .any(axis=1)
]

crude_candidates
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/f6de5da0-9876-4928-8b36-59730cab64e2.png" alt="COT market list" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This gives a filtered list of crude-related contracts from the COT universe. In this case, the key contract I used was CL.</p>
<pre><code class="language-python">cot_symbol = "CL"
start_date = "2010-01-01"
end_date = "2026-03-20"

url = f"{base_url}/commitment-of-traders-report?symbol={cot_symbol}&amp;from={start_date}&amp;to={end_date}&amp;apikey={api_key}"
r = requests.get(url)

cot_df = pd.DataFrame(r.json())
cot_df["date"] = pd.to_datetime(cot_df["date"])
cot_df = cot_df.sort_values("date").drop_duplicates(subset="date").reset_index(drop=True)
cot_df = cot_df.rename(columns={"date": "cot_date"})

cot_df.head()
</code></pre>
<p>This returns the weekly COT records for crude oil:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7ac107b3-dda6-4568-b535-9ab5533448e1.png" alt="Weekly COT crude oil data" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The main fields I needed later were:</p>
<ul>
<li><p><code>date</code></p>
</li>
<li><p><code>openInterestAll</code></p>
</li>
<li><p><code>noncommPositionsLongAll</code></p>
</li>
<li><p><code>noncommPositionsShortAll</code></p>
</li>
</ul>
<p>Next, I pulled the WTI crude oil price data using FMP’s commodity price endpoint.</p>
<pre><code class="language-python">price_symbol = "CLUSD"
start_date = "2010-01-01"
end_date = "2026-03-20"

url = f"{base_url}/historical-price-eod/full?symbol={price_symbol}&amp;from={start_date}&amp;to={end_date}&amp;apikey={api_key}"
r = requests.get(url)

price_df = pd.DataFrame(r.json())
price_df["date"] = pd.to_datetime(price_df["date"])
price_df = price_df.sort_values("date").drop_duplicates(subset="date").reset_index(drop=True)

price_df
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/6bbd3f99-618f-4e80-a2e4-04157f108b9c.png" alt="WTI crude oil price data" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Since the COT dataset is weekly, I converted the price series into weekly bars using the Friday close.</p>
<pre><code class="language-python">price_df["date"] = pd.to_datetime(price_df["date"])
price_df = price_df.sort_values("date").drop_duplicates(subset="date").reset_index(drop=True)

weekly_price = price_df.set_index("date").resample("W-FRI").agg({
    "symbol": "last",
    "open": "first",
    "high": "max",
    "low": "min",
    "close": "last",
    "volume": "sum",
    "vwap": "mean"
}).dropna().reset_index()

weekly_price["weekly_return"] = weekly_price["close"].pct_change()
weekly_price = weekly_price.rename(columns={"date": "price_date"})

weekly_price
</code></pre>
<p>This step matters because the two datasets need to live on the same time scale. If I kept prices daily while COT stayed weekly, the signal alignment would become messy very quickly.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/cba82494-e180-4278-ac41-a5f3490346f5.png" alt="WTI crude oil price data weekly" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Finally, I aligned each COT observation with the next weekly WTI price bar.</p>
<pre><code class="language-python">merged_df = pd.merge_asof(
    cot_df.sort_values("cot_date"),
    weekly_price.sort_values("price_date"),
    left_on="cot_date",
    right_on="price_date",
    direction="forward"
)

merged_df[["cot_date", "price_date", "close", "weekly_return", "openInterestAll", "noncommPositionsLongAll", "noncommPositionsShortAll"]]
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/65b8ed6d-d4ef-43f5-99a2-1b4a5fd80459.png" alt="COT &amp; Price Data merged" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The output is one clean working table with:</p>
<ul>
<li><p>the COT report date</p>
</li>
<li><p>the matched WTI weekly price date</p>
</li>
<li><p>weekly crude price data</p>
</li>
<li><p>the main positioning fields needed for feature engineering</p>
</li>
</ul>
<p>That is the full base dataset for the strategy. With this in place, the next step is to turn the raw positioning data into something more useful.</p>
<h2 id="heading-turning-raw-cot-data-into-usable-features">Turning Raw COT Data Into Usable Features</h2>
<p>At this point, the raw data was ready, but it still wasn't useful as a signal. The COT report gives positioning numbers, but those numbers by themselves don't say much unless they're turned into something comparable over time.</p>
<p>So the next step was to build a few features that could describe positioning in a more meaningful way.</p>
<p>I started with the net non-commercial position. This is just the difference between non-commercial longs and non-commercial shorts.</p>
<pre><code class="language-python">merged_df["net_position"] = merged_df["noncommPositionsLongAll"] - merged_df["noncommPositionsShortAll"]
</code></pre>
<p>This gives the raw speculative bias. A positive value means non-commercial traders are net long. A negative value means they're net short.</p>
<p>But raw net positioning has a problem. The size of the market changes over time, so a value that looked extreme in one period may not mean the same thing in another. To fix that, I normalized it by open interest.</p>
<pre><code class="language-python">merged_df["net_position_ratio"] = merged_df["net_position"] / merged_df["openInterestAll"]
</code></pre>
<p>This made the signal much more useful. Instead of looking at absolute positioning, I was now looking at positioning as a share of the total market.</p>
<p>Next, I needed to know whether that positioning was still building or starting to unwind. For that, I calculated the week-over-week change in the ratio.</p>
<pre><code class="language-python">merged_df["net_position_ratio_change"] = merged_df["net_position_ratio"].diff()
</code></pre>
<p>This was important because the direction of change adds context. An extreme long position that's still increasing isn't the same as an extreme long position that has started to fall.</p>
<p>The last feature was the most important one: a rolling percentile of the positioning ratio. I used a 104-week window.</p>
<pre><code class="language-python">def rolling_percentile(x):
    return pd.Series(x).rank(pct=True).iloc[-1]

merged_df["position_percentile_104"] = merged_df["net_position_ratio"].rolling(104).apply(rolling_percentile)
</code></pre>
<p>This tells us how extreme the current positioning is relative to the last two years. A value above 0.80 means the market is in the top 20% of bullish positioning relative to that recent history. A value below 0.20 means the market is in the bottom 20%.</p>
<p>After adding all four features, I checked the output.</p>
<pre><code class="language-python">merged_df[["cot_date","price_date","net_position","net_position_ratio","net_position_ratio_change","position_percentile_104"]]
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/a94f7dee-fdc6-4495-829a-eee72d95a43d.png" alt="final merged_df" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The first few rows of <code>net_position_ratio_change</code> were <code>NaN</code>, which is expected since the first row has no prior week to compare with. The first 103 rows of <code>position_percentile_104</code> were also <code>NaN</code> because the rolling window needs 104 weeks of history before it can calculate the percentile.</p>
<p>That was fine. What mattered was that the dataset now had four usable pieces:</p>
<ul>
<li><p>raw speculative positioning</p>
</li>
<li><p>normalized positioning</p>
</li>
<li><p>weekly change in positioning</p>
</li>
<li><p>a rolling measure of how extreme that positioning is</p>
</li>
</ul>
<p>This was the point where the COT data stopped being just a table of trader positions and started becoming something that could be turned into a regime model.</p>
<h2 id="heading-building-the-first-version-of-the-regime-model">Building the First Version of the Regime Model</h2>
<p>Once the features were ready, the next step was to turn them into actual market states.</p>
<p>The main idea was simple: positioning extremes on their own aren't enough. A market can stay heavily long or heavily short for a long time. What matters more is what happens while positioning is extreme. Is it still building, or has it started to reverse?</p>
<p>That's why I used two dimensions:</p>
<ul>
<li><p>the 104-week positioning percentile</p>
</li>
<li><p>the weekly change in the positioning ratio</p>
</li>
</ul>
<p>With those two variables, I defined four regimes.</p>
<pre><code class="language-python">merged_df["regime"] = "neutral"

merged_df.loc[(merged_df["position_percentile_104"] &gt; 0.8) &amp; (merged_df["net_position_ratio_change"] &gt; 0), "regime"] = "bullish_buildup"
merged_df.loc[(merged_df["position_percentile_104"] &gt; 0.8) &amp; (merged_df["net_position_ratio_change"] &lt; 0), "regime"] = "bullish_unwind"
merged_df.loc[(merged_df["position_percentile_104"] &lt; 0.2) &amp; (merged_df["net_position_ratio_change"] &lt; 0), "regime"] = "bearish_buildup"
merged_df.loc[(merged_df["position_percentile_104"] &lt; 0.2) &amp; (merged_df["net_position_ratio_change"] &gt; 0), "regime"] = "bearish_unwind"
</code></pre>
<p>Here's what each one means:</p>
<ul>
<li><p><strong>bullish buildup</strong>: positioning is already very bullish, and it's still getting more bullish</p>
</li>
<li><p><strong>bullish unwind</strong>: positioning is very bullish, but that bullishness has started to fade</p>
</li>
<li><p><strong>bearish buildup</strong>: positioning is already very bearish, and it's still getting more bearish</p>
</li>
<li><p><strong>bearish unwind</strong>: positioning is very bearish, but that bearishness has started to ease</p>
</li>
</ul>
<p>Anything that didn't meet one of those extreme conditions stayed in the <code>neutral</code> bucket.</p>
<p>After assigning the regimes, I checked how many observations fell into each one.</p>
<pre><code class="language-python">print(merged_df["regime"].value_counts())
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5133085c-281c-46fc-8ab6-fa414aa1d682.png" alt="regime count" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This output matters because it tells us whether the framework is usable or too sparse. In this case, neutral was still the largest group, which is expected. Most weeks shouldn't be extreme. The four regime buckets were smaller, but still had enough observations to test properly.</p>
<p>I also looked at a sample of the classified rows.</p>
<pre><code class="language-python">merged_df[["cot_date","price_date","net_position_ratio","net_position_ratio_change","position_percentile_104","regime"]].tail(10)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/9dd1352c-932f-4fd9-bb84-071b61433121.png" alt="merged_df + regime" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>At this point, the raw COT data had been turned into a regime model. The next question was whether any of these regimes actually led to useful price behavior.</p>
<h2 id="heading-first-test-what-happens-after-each-regime">First Test: What Happens After Each Regime?</h2>
<p>At this point, I had a regime framework, but not a strategy. Before turning any of these states into trades, I wanted to know what crude oil actually did after each one.</p>
<p>So the next step was to measure forward returns after every regime over four holding windows:</p>
<ul>
<li><p>1 week</p>
</li>
<li><p>2 weeks</p>
</li>
<li><p>4 weeks</p>
</li>
<li><p>8 weeks</p>
</li>
</ul>
<p>I started by creating the forward return columns from the weekly close series.</p>
<pre><code class="language-python">merged_df["fwd_return_1w"] = merged_df["close"].shift(-1) / merged_df["close"] - 1
merged_df["fwd_return_2w"] = merged_df["close"].shift(-2) / merged_df["close"] - 1
merged_df["fwd_return_4w"] = merged_df["close"].shift(-4) / merged_df["close"] - 1
merged_df["fwd_return_8w"] = merged_df["close"].shift(-8) / merged_df["close"] - 1

merged_df[["cot_date","price_date","close","regime","fwd_return_1w","fwd_return_2w","fwd_return_4w","fwd_return_8w"]].tail(12)
</code></pre>
<p>Each of these columns answers a simple question. If crude oil is in a given regime this week, what happens over the next 1, 2, 4, or 8 weeks?</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/cde3faca-cb6d-43b6-81d4-15f6ec660205.png" alt="forward return columns from the weekly close series" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The last few rows had NaN values, which is normal. There is no future price data available beyond the end of the dataset, so the longest horizons drop off first.</p>
<p>Next, I grouped the data by regime and calculated a few summary statistics:</p>
<ul>
<li><p>count</p>
</li>
<li><p>average forward return</p>
</li>
<li><p>median forward return</p>
</li>
<li><p>hit rate</p>
</li>
</ul>
<pre><code class="language-python">regime_summary = merged_df.groupby("regime").agg(
    count=("regime", "size"),
    avg_1w=("fwd_return_1w", "mean"),
    median_1w=("fwd_return_1w", "median"),
    hit_rate_1w=("fwd_return_1w", lambda x: (x &gt; 0).mean()),
    avg_2w=("fwd_return_2w", "mean"),
    median_2w=("fwd_return_2w", "median"),
    hit_rate_2w=("fwd_return_2w", lambda x: (x &gt; 0).mean()),
    avg_4w=("fwd_return_4w", "mean"),
    median_4w=("fwd_return_4w", "median"),
    hit_rate_4w=("fwd_return_4w", lambda x: (x &gt; 0).mean()),
    avg_8w=("fwd_return_8w", "mean"),
    median_8w=("fwd_return_8w", "median"),
    hit_rate_8w=("fwd_return_8w", lambda x: (x &gt; 0).mean())
).reset_index()

regime_summary
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5e522449-c64a-4a7c-a4b6-43723b3241bd.png" alt="grouped data by regime" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This table was the first real test of the framework, and it immediately ruled out some of the original ideas.</p>
<p>The results weren't great for the raw regime model. In fact, they were weaker than I expected.</p>
<p>A few things stood out:</p>
<ul>
<li><p><code>neutral</code> often outperformed the regime buckets</p>
</li>
<li><p><code>bullish_buildup</code> looked consistently weak</p>
</li>
<li><p><code>bearish_buildup</code> also looked weak</p>
</li>
<li><p><code>bearish_unwind</code> looked stronger at first glance, but some of that came from a few large upside outliers</p>
</li>
<li><p><code>bullish_unwind</code> was the only regime that looked somewhat stable across multiple horizons</p>
</li>
</ul>
<p>That changed the direction of the project.</p>
<p>Up to this point, the plan was to build a full four-regime framework and maybe convert multiple states into trade rules. After looking at the forward returns, that no longer made sense. Most of the regimes were not adding much value.</p>
<p>So instead of carrying all four forward, I started focusing on the one regime that still looked promising: <strong>bullish unwind.</strong></p>
<p>Before making that decision, I wanted to look at the distributions visually and see whether the averages were hiding anything important.</p>
<h2 id="heading-looking-at-the-regimes-more-closely">Looking at the Regimes More Closely</h2>
<p>The summary table already told me that most of the raw regime framework was weak, but I still wanted to look at the behavior visually before dropping anything.</p>
<p>I started with a simple chart that places WTI crude oil next to the speculative net positioning ratio.</p>
<pre><code class="language-python">plt.plot(merged_df["price_date"], merged_df["close"], label="wti close")
plt.plot(merged_df["price_date"], merged_df["net_position_ratio"] * 100, label="net position ratio x 100")
plt.title("WTI crude oil price vs speculative net positioning")
plt.xlabel("date")
plt.ylabel("value")
plt.legend()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/e1655a05-0c3a-4d4f-8f5d-51dc20e8b305.png" alt="WTI crude oil price vs speculative net positioning" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This chart isn't meant to compare the two series on the same scale. It's just a quick way to see whether large moves in crude oil tend to happen when speculative positioning is becoming stretched.</p>
<p>Next, I plotted the 104-week positioning percentile itself.</p>
<pre><code class="language-python">plt.plot(merged_df["price_date"], merged_df["position_percentile_104"])
plt.axhline(0.8, linestyle="--", color="b")
plt.axhline(0.2, linestyle="--", color="b")
plt.title("104-week positioning percentile")
plt.xlabel("date")
plt.ylabel("percentile")
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5547d52a-001f-4f30-9479-4414e7b74498.png" alt="104-week positioning percentile" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This made the regime logic easier to understand. Any time the percentile moved above 0.80, the market entered the bullish extreme zone. Any time it dropped below 0.20, the market entered the bearish extreme zone.</p>
<p>Then I looked at how many observations actually fell into each regime.</p>
<pre><code class="language-python">regime_counts = merged_df["regime"].value_counts()

plt.bar(regime_counts.index, regime_counts.values)
plt.title("Regime counts")
plt.xlabel("regime")
plt.ylabel("count")
plt.xticks(rotation=30)
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/6eee2a9a-2876-41c9-9204-8d1e0b0b13f4.png" alt="Regime counts" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The regime counts looked reasonable. Neutral was still the largest bucket, and the four signal regimes had enough observations to test without being too sparse.</p>
<p>After that, I plotted the average 4-week forward return by regime.</p>
<pre><code class="language-python">avg_4w = regime_summary.set_index("regime")["avg_4w"].sort_values()

plt.bar(avg_4w.index, avg_4w.values)
plt.title("Average 4-week forward return by regime")
plt.xlabel("regime")
plt.ylabel("average return")
plt.xticks(rotation=30)
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/00ba5ce0-89df-4a9d-8559-1a96c113447b.png" alt="Average 4-week forward return by regime" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This was the first strong sign that the original framework was too broad. Both buildup regimes looked weak. <code>bullish_unwind</code> was slightly positive, but not by much. <code>bearish_unwind</code> looked strongest on average, which was interesting, but I still didn't trust that result without checking the distribution.</p>
<p>So I looked at the 4-week hit rate next.</p>
<pre><code class="language-python">hit_4w = regime_summary.set_index("regime")["hit_rate_4w"].sort_values()

plt.bar(hit_4w.index, hit_4w.values)
plt.title("4-week hit rate by regime")
plt.xlabel("regime")
plt.ylabel("hit rate")
plt.xticks(rotation=30)
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/93a8bf60-3c69-4c6d-a198-85cda789d3dc.png" alt="4-week hit rate by regime" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The hit rates told a similar story. <code>bullish_unwind</code> was one of the better regimes, but still not strong enough to justify calling it a strategy. <code>neutral</code> was still doing too well, which meant the regime filter wasn't creating a very clean edge yet.</p>
<p>At that point, I wanted to check whether the averages were being distorted by a few large moves. So I plotted the 4-week return distribution for each regime.</p>
<pre><code class="language-python">plot_df = merged_df[["regime", "fwd_return_4w"]].dropna()

plot_df.boxplot(column="fwd_return_4w", by="regime", grid=False)
plt.title("4-week forward return distribution by regime")
plt.suptitle("")
plt.xlabel("regime")
plt.ylabel("4-week forward return")
plt.xticks(rotation=30)
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/849b0d06-0699-4482-84d3-fef2b35f3475.png" alt="4-week forward return distribution by regime" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This chart made the problem much clearer.</p>
<p><code>bearish_unwind</code> looked strong on average, but that strength came from a few very large upside outliers. That made it less convincing as a base strategy.</p>
<p><code>bullish_buildup</code> and <code>bearish_buildup</code> were weak both in the summary table and in the distribution.</p>
<p><code>bullish_unwind</code> was the only regime that looked somewhat stable without depending too much on a handful of extreme observations.</p>
<p>That changed the direction of the build.</p>
<p>Up to this point, the idea was to test a full regime framework and maybe keep multiple paths. After these charts, that no longer made sense. Most of the framework had already done its job by showing what not to use.</p>
<p>So instead of carrying all four regimes forward, I narrowed the focus to just one: bullish unwind.</p>
<h2 id="heading-narrowing-the-focus-keeping-two-extra-variants-for-comparison">Narrowing the Focus: Keeping Two Extra Variants for Comparison</h2>
<p>At this point, <code>bullish_unwind</code> was already the main regime worth paying attention to. The buildup regimes were weak, and <code>bearish_unwind</code> was less convincing because a big part of its strength came from a few outsized moves.</p>
<p>So the focus was already shifting toward <code>bullish_unwind</code>.</p>
<p>Still, before fully committing to it, I kept two additional unwind-based variants in the next step just for comparison:</p>
<ul>
<li><p>a long signal based on <code>bearish_unwind</code></p>
</li>
<li><p>a combined long signal that fires on either unwind regime</p>
</li>
</ul>
<p>That way, the first round of backtests could show whether <code>bullish_unwind</code> was actually better in practice, or whether the broader unwind logic worked better as a whole.</p>
<pre><code class="language-python">merged_df["long_bullish_unwind"] = (merged_df["regime"] == "bullish_unwind").astype(int)
merged_df["long_bearish_unwind"] = (merged_df["regime"] == "bearish_unwind").astype(int)
merged_df["long_any_unwind"] = merged_df["regime"].isin(["bullish_unwind", "bearish_unwind"]).astype(int)

print("number of trades:\n", merged_df[["long_bullish_unwind", "long_bearish_unwind", "long_any_unwind"]].sum())
merged_df[["cot_date","price_date","regime","long_bullish_unwind","long_bearish_unwind","long_any_unwind"]].tail()
</code></pre>
<p>This creates three simple binary signals:</p>
<ul>
<li><p><code>long_bullish_unwind</code> is 1 only when the regime is bullish_unwind</p>
</li>
<li><p><code>long_bearish_unwind</code> is 1 only when the regime is bearish_unwind</p>
</li>
<li><p><code>long_any_unwind</code> is 1 when either unwind regime appears</p>
</li>
</ul>
<p>The output also gives the number of signal occurrences for each one, which matters because the next step is a proper backtest. A signal can look interesting conceptually, but if it barely appears, there isn't much to test.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/0975eaf6-a8a9-408b-a490-f71559fc0f7b.png" alt="number of signal occurrences" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>So going into the strategy layer, bullish_unwind was already the main path. The other two were still kept around, but mainly to compare how much weaker or stronger they looked once the trades were actually executed.</p>
<h2 id="heading-building-the-first-trade-rules">Building the First Trade Rules</h2>
<p>Once the three unwind-based signals were ready, the next step was to turn them into actual trades.</p>
<p>I kept the backtest simple on purpose:</p>
<ul>
<li><p>long-only</p>
</li>
<li><p>4-week holding period</p>
</li>
<li><p>non-overlapping trades</p>
</li>
</ul>
<p>The non-overlapping part matters. If a new signal appeared while a current trade was still active, I skipped it. That kept the trade list cleaner and avoided inflating the strategy by stacking overlapping positions on top of each other.</p>
<p>Here is the backtest function I used.</p>
<pre><code class="language-python">def run_fixed_hold_backtest(df, signal_col, hold_weeks=4):
    trades = []
    i = 0

    while i &lt; len(df) - hold_weeks:
        if df.iloc[i][signal_col] == 1:
            entry_date = df.iloc[i]["price_date"]
            exit_date = df.iloc[i + hold_weeks]["price_date"]
            entry_price = df.iloc[i]["close"]
            exit_price = df.iloc[i + hold_weeks]["close"]
            trade_return = exit_price / entry_price - 1

            trades.append({
                "signal": signal_col,
                "entry_index": i,
                "exit_index": i + hold_weeks,
                "entry_date": entry_date,
                "exit_date": exit_date,
                "entry_price": entry_price,
                "exit_price": exit_price,
                "trade_return": trade_return
            })

            i += hold_weeks
        else:
            i += 1

    return pd.DataFrame(trades)
</code></pre>
<p>This function scans through the dataset, checks whether a signal is active, enters at the current weekly bar, exits four weeks later, and records the trade result.</p>
<p>Then I ran it for all three unwind-based signals.</p>
<pre><code class="language-python">bullish_unwind_trades = run_fixed_hold_backtest(merged_df, "long_bullish_unwind", hold_weeks=4)
bearish_unwind_trades = run_fixed_hold_backtest(merged_df, "long_bearish_unwind", hold_weeks=4)
any_unwind_trades = run_fixed_hold_backtest(merged_df, "long_any_unwind", hold_weeks=4)
</code></pre>
<p>After that, I checked how many trades were actually executed.</p>
<pre><code class="language-python">print("executed bullish_unwind trades:", len(bullish_unwind_trades))
print("executed bearish_unwind trades:", len(bearish_unwind_trades))
print("executed any_unwind trades:", len(any_unwind_trades))
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/e6e87883-fe88-4b04-9c55-8dd71aaf92b3.png" alt="executed trades" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This output was lower than the raw signal counts from the previous section, which is expected because overlapping signals were skipped.</p>
<p>Next, I built a small helper function to summarize the trade results and applied it to all three strategies.</p>
<pre><code class="language-python">def summarize_trades(trades):
    return pd.Series({
        "trades": len(trades),
        "win_rate": (trades["trade_return"] &gt; 0).mean(),
        "avg_trade_return": trades["trade_return"].mean(),
        "median_trade_return": trades["trade_return"].median(),
        "cumulative_return": (1 + trades["trade_return"]).prod() - 1
    })

trade_summary = pd.DataFrame({
    "bullish_unwind": summarize_trades(bullish_unwind_trades),
    "bearish_unwind": summarize_trades(bearish_unwind_trades),
    "any_unwind": summarize_trades(any_unwind_trades)
}).T

trade_summary
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/da0d8d65-74a4-4ec9-9af5-24a0a0e14b77.png" alt="backtest results" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This was the first full strategy result, and it cleared up the hierarchy very quickly.</p>
<p><code>bullish_unwind</code> was still the best of the three. It wasn't strong yet, but it was clearly better than the other two.</p>
<p>A few things stood out:</p>
<ul>
<li><p><code>bullish_unwind</code> had the best win rate</p>
</li>
<li><p><code>bullish_unwind</code> had the best average and median trade return</p>
</li>
<li><p><code>bearish_unwind</code> and <code>any_unwind</code> both performed badly on a cumulative basis</p>
</li>
<li><p>Combining the two unwind regimes didn't help, just diluted the stronger one</p>
</li>
</ul>
<p>I also wanted to see how these strategies behaved over time, not just in a summary table. So I added simple equity curves for each one.</p>
<pre><code class="language-python">
bullish_unwind_trades["equity_curve"] = (1 + bullish_unwind_trades["trade_return"]).cumprod()
bearish_unwind_trades["equity_curve"] = (1 + bearish_unwind_trades["trade_return"]).cumprod()
any_unwind_trades["equity_curve"] = (1 + any_unwind_trades["trade_return"]).cumprod()

plt.plot(bullish_unwind_trades["exit_date"], bullish_unwind_trades["equity_curve"], label="bullish unwind")
plt.plot(bearish_unwind_trades["exit_date"], bearish_unwind_trades["equity_curve"], label="bearish unwind")
plt.plot(any_unwind_trades["exit_date"], any_unwind_trades["equity_curve"], label="any unwind")
plt.title("Equity curves for 4-week unwind strategies")
plt.xlabel("date")
plt.ylabel("equity multiple")
plt.legend()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/52a0865f-9054-497c-b3de-7e0ec13c28fc.png" alt="Equity curves for 4-week unwind strategies" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This chart made the same point more clearly. <code>bullish_unwind</code> was still weak in absolute terms, but it held up much better than the other two. <code>bearish_unwind</code> didn't survive the conversion from regime idea to actual strategy, and <code>any_unwind</code> was even worse because it inherited the weakness of both.</p>
<p>So by the end of this step, the picture was much clearer.</p>
<p>The broader unwind idea didn't work well as a whole. <code>bearish_unwind</code> wasn't holding up in a clean backtest. <code>any_unwind</code> was even worse. That left only one regime worth carrying further: <code>bullish unwind</code>.</p>
<p>Still, even that result wasn't strong enough yet. The strategy was better than the alternatives, but not good enough to stop here. In fact, we haven’t even made a profit yet.</p>
<p>The next step was to compare it against buy-and-hold and see whether it actually added anything useful.</p>
<h2 id="heading-comparing-bullish-unwind-against-buy-and-hold">Comparing Bullish Unwind Against Buy-and-Hold</h2>
<p>By this point, <code>bullish_unwind</code> had already beaten the other regime-based variants. But that still did not mean much on its own.</p>
<p>A strategy can look decent relative to weaker alternatives and still fail the most basic test: does it do anything better than just holding crude oil?</p>
<p>So the next step was to compare the raw <code>bullish_unwind</code> strategy against a simple buy-and-hold benchmark.</p>
<p>I started by building the buy-and-hold curve from the weekly WTI price series.</p>
<pre><code class="language-python">buy_hold_df = weekly_price.copy()
buy_hold_df = buy_hold_df.sort_values("price_date").reset_index(drop=True)
buy_hold_df["buy_hold_curve"] = buy_hold_df["close"] / buy_hold_df["close"].iloc[0]

buy_hold_df[["price_date", "close", "buy_hold_curve"]].tail()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/c0a025b3-364e-46a0-b136-d24336010c52.png" alt="buy/hold data" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Then I plotted buy-and-hold against the raw <code>bullish_unwind</code> strategy.</p>
<pre><code class="language-python">plt.plot(buy_hold_df["price_date"], buy_hold_df["buy_hold_curve"], label="buy and hold wti", linewidth=2, alpha=0.5)
plt.plot(bullish_unwind_trades["exit_date"], bullish_unwind_trades["equity_curve"], label="bullish unwind strategy", color="b")
plt.title("Bullish unwind strategy vs buy and hold crude oil")
plt.xlabel("date")
plt.ylabel("equity multiple")
plt.legend()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7de51477-a1b3-4ab4-b5c3-b82589f907b9.png" alt="Bullish unwind strategy vs buy and hold crude oil" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The chart was useful because it showed the exact problem with the raw signal. <code>bullish_unwind</code> was more selective than buy-and-hold, but that selectivity was not creating a real edge. The strategy had some decent stretches, but it still lagged the simpler benchmark overall.</p>
<p>To make that comparison more explicit, I calculated the full buy-and-hold return over the sample, then I put both results into one small summary table.</p>
<pre><code class="language-python">buy_hold_return = buy_hold_df["buy_hold_curve"].iloc[-1] - 1

comparison_summary = pd.DataFrame({
    "strategy": ["bullish_unwind", "buy_and_hold"],
    "trades": [len(bullish_unwind_trades), np.nan],
    "win_rate": [(bullish_unwind_trades["trade_return"] &gt; 0).mean(), np.nan],
    "avg_trade_return": [bullish_unwind_trades["trade_return"].mean(), np.nan],
    "cumulative_return": [
        (1 + bullish_unwind_trades["trade_return"]).prod() - 1,
        buy_hold_return
    ]
})

comparison_summary
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/fe0f4949-ac97-4918-a388-43092f3215c5.png" alt="strategy vs b/h returns comparison" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This was the real turning point in the article.</p>
<p>Even though <code>bullish_unwind</code> was the best regime-based candidate so far, it still underperformed buy-and-hold. That made the conclusion very clear: the raw signal wasn't strong enough yet.</p>
<p>So this was no longer a question of choosing between regimes. That part was already settled. The real question now was whether the bullish_unwind setup could be improved without turning the strategy into something over-engineered.</p>
<p>That's what led to the next step: adding a simple trend filter.</p>
<h2 id="heading-adding-a-trend-filter">Adding a Trend Filter</h2>
<p>At this point, the core signal had been narrowed to <code>bullish_unwind</code>, but the raw version still wasn't good enough. It underperformed buy-and-hold, which meant the signal needed more context.</p>
<p>The next idea was simple: not every bullish unwind should be treated the same way. If speculative positioning is starting to unwind while crude oil is already in a weak broader trend, that long signal may not be worth taking. So I added one basic filter: only take the <code>bullish_unwind</code> trade when WTI is above its 26-week moving average.</p>
<p>First, I created the moving average and a binary trend flag. Then I combined that filter with the existing <code>bullish_unwind</code> regime.</p>
<pre><code class="language-python">merged_df["ma_26"] = merged_df["close"].rolling(26).mean()
merged_df["above_ma_26"] = (merged_df["close"] &gt; merged_df["ma_26"]).astype(int)
merged_df["long_bullish_unwind_tf"] = ((merged_df["regime"] == "bullish_unwind") &amp; (merged_df["above_ma_26"] == 1)).astype(int)
</code></pre>
<p>This creates a filtered version of the original signal. The output also shows how many trade opportunities remain after applying the trend filter. As expected, the number drops. That isn't a problem if the remaining trades are better.</p>
<p>Next, I ran the same 4-week non-overlapping backtest on the filtered signal.</p>
<pre><code class="language-python">bullish_unwind_tf_trades = run_fixed_hold_backtest(
    merged_df,
    "long_bullish_unwind_tf",
    hold_weeks=4
)

filtered_summary = pd.DataFrame({
    "bullish_unwind": summarize_trades(bullish_unwind_trades),
    "bullish_unwind_tf": summarize_trades(bullish_unwind_tf_trades)
}).T

filtered_summary
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7ab5d6b1-6ebc-4d6a-870a-a9b4048b5386.png" alt="original vs optimized strategy performance" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This was the first major improvement in the process.</p>
<p>The filtered version didn't just look slightly better. It changed the profile of the strategy in a meaningful way:</p>
<ul>
<li><p>fewer trades</p>
</li>
<li><p>higher win rate</p>
</li>
<li><p>higher average trade return</p>
</li>
<li><p>much stronger cumulative return</p>
</li>
</ul>
<p>That was exactly what I wanted from a filter. It made the signal more selective, but it also made it much cleaner.</p>
<p>To visualize the difference, I added equity curves for the raw strategy, the filtered version, and buy-and-hold.</p>
<pre><code class="language-python">bullish_unwind_tf_trades["equity_curve"] = (1 + bullish_unwind_tf_trades["trade_return"]).cumprod()

plt.plot(bullish_unwind_trades["exit_date"], bullish_unwind_trades["equity_curve"], label="bullish unwind")
plt.plot(bullish_unwind_tf_trades["exit_date"], bullish_unwind_tf_trades["equity_curve"], label="bullish unwind + trend filter")
plt.plot(buy_hold_df["price_date"], buy_hold_df["buy_hold_curve"], label="buy and hold wti")
plt.title("Bullish unwind strategy with and without trend filter")
plt.xlabel("date")
plt.ylabel("equity multiple")
plt.legend()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/b1bda6f8-5018-4747-941f-144dc8f8960b.png" alt="Bullish unwind strategy with and without trend filter" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This chart made the change easy to see. The raw strategy was drifting, while the filtered version was much more stable and clearly stronger over the full sample.</p>
<p>So this was the point where the strategy started becoming usable. The signal was no longer just “extreme bullish positioning is starting to unwind.” It was: <strong>extreme bullish positioning is starting to unwind, while crude oil is still in a broader uptrend</strong></p>
<p>That was much more specific, and much more effective.</p>
<p>The next question was whether this improved version was actually stable, or whether it only worked because of one lucky parameter choice.</p>
<h2 id="heading-stress-testing-the-setup">Stress-Testing the Setup</h2>
<p>Once the trend filter improved the strategy, I still didn't want to treat that version as final without checking how fragile it was.</p>
<p>A setup can look strong simply because one exact combination of parameters happened to work. So the next step was to test nearby variations and see whether the result still held up.</p>
<p>I kept the core idea the same:</p>
<ul>
<li><p>bullish unwind</p>
</li>
<li><p>long-only</p>
</li>
<li><p>trend filter stays on</p>
</li>
</ul>
<p>Then I varied three things:</p>
<ul>
<li><p>the percentile window</p>
</li>
<li><p>the threshold that defines an extreme</p>
</li>
<li><p>the holding period</p>
</li>
</ul>
<p>First, I created a helper function to build bullish unwind signals using different percentile columns and threshold levels, and then, a second percentile series using a shorter 52-week window.</p>
<pre><code class="language-python">def add_bullish_unwind_signal(df, percentile_col, high_threshold, signal_name):
    df[signal_name] = (
        (df[percentile_col] &gt; high_threshold) &amp;
        (df["net_position_ratio_change"] &lt; 0) &amp;
        (df["above_ma_26"] == 1)
    ).astype(int)
    
def rolling_percentile(x):
    return pd.Series(x).rank(pct=True).iloc[-1]

merged_df["position_percentile_52"] = merged_df["net_position_ratio"].rolling(52).apply(rolling_percentile)
</code></pre>
<p>With that in place, I built four signal variants:</p>
<ul>
<li><p>104-week percentile with an 80th percentile threshold</p>
</li>
<li><p>104-week percentile with an 85th percentile threshold</p>
</li>
<li><p>52-week percentile with an 80th percentile threshold</p>
</li>
<li><p>52-week percentile with an 85th percentile threshold</p>
</li>
</ul>
<pre><code class="language-python">add_bullish_unwind_signal(merged_df, "position_percentile_104", 0.80, "sig_104_80")
add_bullish_unwind_signal(merged_df, "position_percentile_104", 0.85, "sig_104_85")
add_bullish_unwind_signal(merged_df, "position_percentile_52", 0.80, "sig_52_80")
add_bullish_unwind_signal(merged_df, "position_percentile_52", 0.85, "sig_52_85")
</code></pre>
<p>After that, I ran the same backtest across three holding periods:</p>
<ul>
<li><p>2 weeks</p>
</li>
<li><p>4 weeks</p>
</li>
<li><p>8 weeks</p>
</li>
</ul>
<pre><code class="language-python">results = []

for signal_col in ["sig_104_80", "sig_104_85", "sig_52_80", "sig_52_85"]:
    for hold_weeks in [2, 4, 8]:
        trades = run_fixed_hold_backtest(merged_df, signal_col, hold_weeks=hold_weeks)

        if len(trades) == 0:
            continue

        results.append({
            "signal": signal_col,
            "hold_weeks": hold_weeks,
            "trades": len(trades),
            "win_rate": (trades["trade_return"] &gt; 0).mean(),
            "avg_trade_return": trades["trade_return"].mean(),
            "median_trade_return": trades["trade_return"].median(),
            "cumulative_return": (1 + trades["trade_return"]).prod() - 1
        })

stress_test = pd.DataFrame(results)
stress_test
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/ee70c28c-86a6-4ede-821f-cde23b36cad9.png" alt="backtest across three holding periods" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This output was one of the most important parts of the entire article. It showed whether the improved strategy was actually stable, or whether it only worked in one narrow version.</p>
<p>A few things stood out immediately.</p>
<p>The <strong>104-week / 80th percentile</strong> version was clearly the strongest family. It held up across all three holding periods:</p>
<ul>
<li><p>2-week hold: cumulative return <strong>38.16%</strong></p>
</li>
<li><p>4-week hold: cumulative return <strong>45.95%</strong></p>
</li>
<li><p>8-week hold: cumulative return <strong>19.02%</strong></p>
</li>
</ul>
<p>That consistency mattered. It meant the signal wasn't collapsing the moment the hold period changed.</p>
<p>The <strong>4-week hold</strong> stood out as the best overall choice. It had:</p>
<ul>
<li><p><strong>26 trades</strong></p>
</li>
<li><p><strong>65.38% win rate</strong></p>
</li>
<li><p><strong>1.84% average trade return</strong></p>
</li>
<li><p><strong>3.69% median trade return</strong></p>
</li>
<li><p><strong>45.95% cumulative return</strong></p>
</li>
</ul>
<p>The <strong>8-week hold</strong> had a slightly higher average trade return in some cases, but it came with fewer trades. That made it thinner and harder to treat as the main version.</p>
<p>The <strong>104-week / 85th percentile</strong> setup was too restrictive for the shorter holds. Its 2-week and 4-week versions turned negative, even though the 8-week hold still worked reasonably well.</p>
<p>The <strong>52-week variants</strong> were much less convincing overall. A few of them were positive, but they were not nearly as stable as the 104-week / 80th percentile version.</p>
<p>So by the end of this step, the final structure wasn't just the version that happened to look good once. It was the version that kept holding up even after nearby variations were tested.</p>
<p>That gave me a clear final setup:</p>
<ul>
<li><p><strong>104-week percentile</strong></p>
</li>
<li><p><strong>80th percentile threshold</strong></p>
</li>
<li><p><strong>bullish unwind</strong></p>
</li>
<li><p><strong>26-week moving average filter</strong></p>
</li>
<li><p><strong>4-week hold</strong></p>
</li>
</ul>
<h2 id="heading-the-final-strategy">The Final Strategy</h2>
<p>By this stage, the process had already done most of the filtering.</p>
<p>The raw four-regime framework didn't work well as a strategy. The broader unwind idea didn't work either. The raw <code>bullish_unwind</code> signal was better than the alternatives, but still weaker than buy-and-hold.</p>
<p>The only version that held up after all of that was this one:</p>
<ul>
<li><p>bullish unwind</p>
</li>
<li><p>104-week positioning percentile</p>
</li>
<li><p>80th percentile threshold</p>
</li>
<li><p>26-week moving average filter</p>
</li>
<li><p>4-week hold</p>
</li>
<li><p>non-overlapping trades</p>
</li>
</ul>
<p>So now it made sense to stop iterating and show the final result clearly. I first locked the final signal and reran the backtest using the chosen setup.</p>
<pre><code class="language-python">final_signal = "sig_104_80"
final_hold = 4
final_trades = run_fixed_hold_backtest(merged_df, final_signal, hold_weeks=final_hold)
final_trades["equity_curve"] = (1 + final_trades["trade_return"]).cumprod()

final_summary = pd.DataFrame({
    "metric": [
        "trades",
        "win_rate",
        "avg_trade_return",
        "median_trade_return",
        "cumulative_return"
    ],
    "value": [
        len(final_trades),
        (final_trades["trade_return"] &gt; 0).mean(),
        final_trades["trade_return"].mean(),
        final_trades["trade_return"].median(),
        (1 + final_trades["trade_return"]).prod() - 1
    ]
})

final_summary
</code></pre>
<p>That output gives the final performance profile:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/f7f5219d-233d-4fe7-8ac9-2cee2026feeb.png" alt="final performance profile" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Those numbers were already a big improvement over the earlier raw versions, but I still wanted the comparison in one place. So I built a final table against the two reference points:</p>
<ul>
<li><p>buy-and-hold</p>
</li>
<li><p>raw bullish unwind</p>
</li>
</ul>
<pre><code class="language-python">final_comparison = pd.DataFrame({
    "strategy": ["buy_and_hold", "bullish_unwind_raw", "bullish_unwind_filtered"],
    "trades": [
        np.nan,
        len(bullish_unwind_trades),
        len(final_trades)
    ],
    "win_rate": [
        np.nan,
        (bullish_unwind_trades["trade_return"] &gt; 0).mean(),
        (final_trades["trade_return"] &gt; 0).mean()
    ],
    "avg_trade_return": [
        np.nan,
        bullish_unwind_trades["trade_return"].mean(),
        final_trades["trade_return"].mean()
    ],
    "cumulative_return": [
        buy_hold_return,
        (1 + bullish_unwind_trades["trade_return"]).prod() - 1,
        (1 + final_trades["trade_return"]).prod() - 1
    ]
})

final_comparison
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/2b7a3779-1701-4221-9bd2-df0a4ac22de7.png" alt="final performance comparison table" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This was the full payoff of the build:</p>
<ul>
<li><p>buy-and-hold: 13.67%</p>
</li>
<li><p>raw bullish unwind: -2.13%</p>
</li>
<li><p>filtered bullish unwind: 45.95%</p>
</li>
</ul>
<p>The trend filter didn't just smooth the strategy a bit. It changed the result completely.</p>
<p>To make that visible, I plotted the three curves together.</p>
<pre><code class="language-python">plt.plot(buy_hold_df["price_date"], buy_hold_df["buy_hold_curve"], label="buy and hold wti", linewidth=2, alpha=0.5)
plt.plot(bullish_unwind_trades["exit_date"], bullish_unwind_trades["equity_curve"], label="raw bullish unwind", color="indigo")
plt.plot(final_trades["exit_date"], final_trades["equity_curve"], label="filtered bullish unwind", color="b")
plt.title("Crude oil strategy comparison")
plt.xlabel("date")
plt.ylabel("equity multiple")
plt.legend()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/f4e50969-c1b3-441e-bc7c-5e90327ef9f0.png" alt="Crude oil strategy comparison" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This chart says the same thing as the table, but more directly. The raw signal drifts. Buy-and-hold is positive over the full sample, but much noisier. The filtered version is the only one that compounds in a cleaner way.</p>
<p>I also wanted to show where these filtered trades actually appear on the WTI chart.</p>
<pre><code class="language-python">plt.plot(merged_df["price_date"], merged_df["close"], label="wti close", linewidth=2, alpha=0.5)
plt.scatter(merged_df.loc[merged_df[final_signal] == 1, "price_date"], merged_df.loc[merged_df[final_signal] == 1, "close"],
            s=25, label="filtered bullish unwind signal", color="b")
plt.title("Filtered bullish unwind signals on WTI crude oil")
plt.xlabel("date")
plt.ylabel("price")
plt.legend()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/c688c947-2819-47af-a825-13c0bac7b530.png" alt="Filtered bullish unwind signals on WTI crude oil" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This is useful because it shows the strategy is selective. It doesn't fire all the time. It only activates when positioning stays in an extreme bullish zone, starts to unwind, and the broader price trend is still intact.</p>
<p>I did the same on the positioning side.</p>
<pre><code class="language-python">plt.plot(merged_df["price_date"], merged_df["position_percentile_104"], label="104-week percentile", linewidth=2, alpha=0.5)
plt.axhline(0.8, linestyle="--", label="80th percentile")
plt.scatter(merged_df.loc[merged_df[final_signal] == 1, "price_date"], merged_df.loc[merged_df[final_signal] == 1, "position_percentile_104"],
            s=25, label="trade signals", color="indigo")
plt.title("Bullish unwind signals from COT positioning extremes")
plt.xlabel("date")
plt.ylabel("percentile")
plt.legend()
plt.show()
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/85f8ae62-60ca-4de5-8074-213eb5296f92.png" alt="Bullish unwind signals from COT positioning extremes" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This final chart ties everything together. The trades only appear when the percentile is already in the extreme zone, which means the signal is still doing what it was originally designed to do. It's just doing it in a much more disciplined way than the raw regime framework.</p>
<h2 id="heading-further-improvements">Further Improvements</h2>
<p>There are still a few places where this can be pushed further.</p>
<p>The first is execution realism. Right now the strategy uses a clean weekly entry and exit rule, but it doesn't include slippage, spreads, or any contract-level execution constraints. Adding those would make the result stricter.</p>
<p>The second is signal depth. This version only uses non-commercial positioning, a trend filter, and a fixed hold period. It would be worth testing whether commercial positioning, volatility filters, or dynamic exits can improve the setup without overcomplicating it.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>This started as a broad COT idea, not a finished strategy. The first regime framework looked reasonable, but most of it didn't hold up once the data was tested. That part was important, because it made the final signal much narrower and much cleaner.</p>
<p>What survived was a very specific setup: extreme bullish positioning that starts to unwind, while WTI is still above its 26-week moving average. That version ended up outperforming both the raw signal and buy-and-hold over the tested sample.</p>
<p>The nice part is that the whole thing can be built from scratch with FinancialModelingPrep’s COT and commodity price data APIs, without needing to patch together multiple data sources. That made it much easier to go from idea to actual testing.</p>
<p>With that being said, you’ve reached the end of the article. Hope you learned something new and useful. Thank you for your time.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Market Pulse App in Python: Real-Time & Multi-Asset ]]>
                </title>
                <description>
                    <![CDATA[ A “market pulse” screen is basically the tab you keep open when you don’t want to stare at charts all day. It tells you what’s moving right now, what’s unusually volatile, and which names are starting ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-market-pulse-app-in-python-real-time-multi-asset/</link>
                <guid isPermaLink="false">69d3c38540c9cabf4435ed16</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stockmarket ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Real Time ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Mon, 06 Apr 2026 14:30:29 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8fd6bb83-0418-41e4-9b93-a3c81325033a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A “market pulse” screen is basically the tab you keep open when you don’t want to stare at charts all day. It tells you what’s moving right now, what’s unusually volatile, and which names are starting to move together.</p>
<p>Not in a research-paper way. In a product way. The kind of feed you could drop into a media platform or investing app and have it feel instantly useful.</p>
<p>In this tutorial, we’ll build a minimal version of that in Python using Streamlit. The dashboard has three parts:</p>
<ul>
<li><p>a Pulse table that ranks the biggest movers across your watchlist</p>
</li>
<li><p>a Stress feed that emits event-style alerts instead of raw tick spam</p>
</li>
<li><p>a small Correlation card that updates based on the current volatility regime</p>
</li>
</ul>
<p>The data for the dashboard will be powered by EODHD’s real-time WebSocket feeds.</p>
<p>Quick expectation setting: this isn’t TradingView, and it’s not a backtester. It’s a lightweight real-time system that streams prices, maintains rolling buffers, computes a few live metrics, and turns them into UI-ready widgets.</p>
<p>The goal here is to build something you can actually ship as a “market pulse” feature, not a one-off notebook demo.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-app-were-building">The App We’re Building</a></p>
<ul>
<li><p><a href="#heading-pulse-table">Pulse Table</a></p>
</li>
<li><p><a href="#heading-stress-feed">Stress Feed</a></p>
</li>
<li><p><a href="#heading-correlation-card">Correlation Card</a></p>
</li>
<li><p><a href="#heading-control-panel">Control Panel</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-app-architecture">The App Architecture</a></p>
<ul>
<li><a href="#heading-code-file-structure">Code File Structure</a></li>
</ul>
</li>
<li><p><a href="#heading-streaming-layer-one-queue-many-feeds">Streaming Layer: One Queue, Many Feeds</a></p>
<ul>
<li><p><a href="#heading-feedspy"><code>feeds.py</code></a></p>
</li>
<li><p><a href="#heading-why-the-watchlist-is-curated">Why the Watchlist is Curated</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-rolling-state-buffers-returns-volatility-trend">Rolling State: Buffers, Returns, Volatility, Trend</a></p>
<ul>
<li><a href="#heading-pulse-storepy"><code>pulse_store.py</code></a></li>
</ul>
</li>
<li><p><a href="#heading-turning-live-stats-into-events-stress-feed">Turning Live Stats Into Events (Stress Feed)</a></p>
<ul>
<li><a href="#heading-eventspy"><code>events.py</code></a></li>
</ul>
</li>
<li><p><a href="#heading-regime-tagging-small-but-important">Regime Tagging (Small but Important)</a></p>
<ul>
<li><p><a href="#heading-add-this-to-pulse-storepy">Add This to <code>pulse_store.py</code></a></p>
</li>
<li><p><a href="#heading-attach-regime-inside-snapshot-in-pulse-storepy">Attach Regime Inside <code>snapshot()</code> in <code>pulse_store.py</code></a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-correlation-card-stocks-only-regime-aware-window">Correlation Card (Stocks Only, Regime-aware Window)</a></p>
<ul>
<li><a href="#heading-correlationpy"><code>correlation.py</code></a></li>
</ul>
</li>
<li><p><a href="#heading-building-the-streamlit-app">Building the Streamlit App</a></p>
</li>
<li><p><a href="#heading-final-output">Final Output</a></p>
</li>
<li><p><a href="#heading-what-id-improve-next">What I’d Improve Next</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before we get into the build, make sure you have a few basics ready.</p>
<p>You should be comfortable running Python scripts, installing packages with <code>pip</code>, and working with a small multi-file project.</p>
<p>This tutorial isn't notebook-based. We’ll be building a lightweight real-time app with separate files for streaming, state, events, correlation logic, and the Streamlit UI.</p>
<p>You’ll need Python 3.10+ and these packages installed:</p>
<pre><code class="language-shell">pip install streamlit pandas websockets
</code></pre>
<p>You’ll also need an <a href="https://eodhd.com/">EODHD API key</a> with access to their real-time WebSocket feeds, since the dashboard depends on live stock, forex, and crypto data.</p>
<p>To follow along smoothly, create these files in your project folder before starting:</p>
<pre><code class="language-plaintext">feeds.py
pulse_store.py
events.py
correlation.py
app.py
</code></pre>
<p>One quick note before we begin: Since this app runs on live market data, what you see will depend on when you open it. During weekends or off-market hours, crypto will usually dominate the dashboard while stocks and most forex pairs stay relatively quiet. That is expected.</p>
<h2 id="heading-the-app-were-building">The App We’re&nbsp;Building</h2>
<p>Before we touch any code, here’s what the finished dashboard looks like:</p>
<p><a href="https://gumlet.tv/watch/69b99df9554f0fb510c28ce6/">https://gumlet.tv/watch/69b99df9554f0fb510c28ce6/</a></p>
<p>Let's go over its main features:</p>
<h3 id="heading-pulse-table">Pulse Table</h3>
<p>This is the main screen. It’s your ranked list of movers across the watchlist. Each row is one symbol, and the columns are the small set of signals we compute live: last price, 1-minute return, 5-minute return when available, 15-minute volatility, and a simple regime label.</p>
<p>If you open the app and only want one thing, it’s this table. You can glance at it and immediately know what deserves attention.</p>
<h3 id="heading-stress-feed">Stress Feed</h3>
<p>This is where the app stops feeling like a live ticker and starts feeling like a product feature. Instead of printing every update, we only emit events when something crosses a threshold, like a sharp 1-minute move or a volatility spike. Those events become “cards” in a feed. The point is to reduce noise, not create more of it.</p>
<h3 id="heading-correlation-card">Correlation Card</h3>
<p>This is intentionally small and conservative. Correlation in real time gets messy fast because different symbols tick at different frequencies and you need alignment. For this build, we keep it to stocks only and compute correlation off time buckets.</p>
<p>It’s not meant to be a full correlation matrix. It’s just a quick “what’s moving with my base symbol right now” view, and it adapts its lookback window depending on whether the base symbol is in a normal or high-vol regime.</p>
<h3 id="heading-control-panel">Control Panel</h3>
<p>At the top, you have a few controls that make the demo feel interactive without turning it into a settings page. Top movers lets you pick how many rows you want in the Pulse table. Correlation base switches which stock you’re anchoring correlation around. Correlation bucket changes the time bucket size used for alignment, which is useful when the feed is sparse and you want correlation to stabilize.</p>
<h2 id="heading-the-app-architecture">The App Architecture</h2>
<p>If you’ve ever tried to build a live Streamlit app, you’ve probably hit the same wall. Streamlit reruns your script constantly. Any time a widget changes, any time you call <code>st.rerun()</code>, the whole file executes again from the top.</p>
<p>That’s great for normal dashboards, but it’s a terrible place to run an infinite WebSocket loop. If you do that in the main thread, the UI either freezes or you end up reconnecting to feeds on every rerun.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/f6431fe7-fa92-448a-8116-132af071c490.png" alt="Multi-Asset Market Pulse App Architecture" style="display:block;margin:0 auto" width="1360" height="1390" loading="lazy">

<p>So the architecture here is intentionally split into two roles.</p>
<p>One background worker owns the real-time work. It connects to the WebSocket feeds, ingests ticks, updates rolling buffers, computes metrics, and emits stress events. That worker runs continuously, and it keeps the latest state in memory. That’s the engine of the app.</p>
<p>Streamlit itself stays dumb on purpose. On every rerun, it only reads whatever state the worker has produced and renders tables and a small correlation card. There's no data fetching in the UI loop. No heavy computation. Just display. That separation is the reason the app stays stable even when you keep refreshing the page or tweaking controls.</p>
<p>In practice, the simplest way to do this in Python is a background thread that runs an async loop. Streamlit starts that thread once using <code>st.session_state</code> as a guard, and then the UI code just keeps rerendering from the shared state.</p>
<p>It’s not fancy. But it’s the difference between a “works for 30 seconds” demo and something that can sit open like a real market pulse screen.</p>
<h3 id="heading-code-file-structure">Code File Structure</h3>
<p>To keep this build readable, I split the app into five small files. Each file has one job, and the Streamlit UI doesn’t touch the WebSocket logic directly.</p>
<ul>
<li><p><code>feeds.py</code> handles WebSocket connections and normalizes every incoming message into the same tick format.</p>
</li>
<li><p><code>pulse_store.py</code> keeps rolling buffers per symbol and computes pulse metrics (returns, vol, trend, regime). This is the core state.</p>
</li>
<li><p><code>events.py</code> turns the live metrics into a stress feed with cooldowns and asset-aware thresholds.</p>
</li>
<li><p><code>correlation.py</code> builds the correlation card by bucketing and aligning returns, then changing the lookback window based on regime.</p>
</li>
<li><p><code>app.py</code> is the Streamlit dashboard. It starts the background worker once, then keeps rerendering from shared state.</p>
</li>
</ul>
<p>That split is what makes the app stable. The background worker can run forever. Streamlit can rerun as often as it wants without reconnecting to feeds or recomputing everything from scratch.</p>
<h2 id="heading-streaming-layer-one-queue-many-feeds">Streaming Layer: One Queue, Many&nbsp;Feeds</h2>
<p>The first step is getting real-time ticks into the system. We connect to EODHD’s WebSocket feeds for stocks, forex, and crypto, subscribe to a small watchlist, then normalize every message into one tick schema: <code>{symbol, asset, ts, price}</code>.</p>
<p>Once we have that, everything downstream becomes predictable.</p>
<h3 id="heading-feedspy"><code>feeds.py:</code></h3>
<pre><code class="language-python">import asyncio
import json
import time
import websockets

API_KEY = "YOUR EODHD API KEY"

WS = {
    "stocks": "wss://ws.eodhistoricaldata.com/ws/us?api_token=",
    "forex":  "wss://ws.eodhistoricaldata.com/ws/forex?api_token=",
    "crypto": "wss://ws.eodhistoricaldata.com/ws/crypto?api_token=",
}

def _tick(symbol, asset, price):
    return {"symbol": symbol, "asset": asset, "ts": time.time(), "price": float(price)}

def _parse(asset, msg):
    s = msg.get("s")
    p = msg.get("p")
    if s is None or p is None:
        return None
    return _tick(s, asset, p)

async def _stream(asset, symbols, q):
    url = WS[asset] + API_KEY

    while True:
        try:
            async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
                sub = {"action": "subscribe", "symbols": ",".join(symbols)}
                await ws.send(json.dumps(sub))

                async for raw in ws:
                    try:
                        msg = json.loads(raw)
                    except Exception:
                        continue

                    t = _parse(asset, msg)
                    if t:
                        await q.put(t)

        except Exception:
            await asyncio.sleep(1.0)

async def start_streams(q):
    tasks = []
    tasks.append(asyncio.create_task(_stream("stocks", ["AAPL","TSLA","NVDA","AMZN","MSFT","META","GOOGL"], q)))
    tasks.append(asyncio.create_task(_stream("forex", ["EURUSD","USDINR","USDJPY","GBPUSD","AUDUSD"], q)))
    tasks.append(asyncio.create_task(_stream("crypto", ["BTC-USD","ETH-USD","BTC-USDT","ETH-USDT","SOL-USDT"], q)))
    return tasks
</code></pre>
<p><strong>Note:</strong> Replace YOUR EODHD API KEY with your actual EODHD API key. If you don’t have one, you can obtain it by opening an EODHD developer account.</p>
<p>What this code is doing is simple. Each feed runs in its own async task, pushes normalized ticks into a single shared queue, and reconnects if the socket drops. We don’t try to do anything smart here. This layer is just plumbing.</p>
<h3 id="heading-why-the-watchlist-is-curated">Why the Watchlist is&nbsp;Curated</h3>
<p>A bigger watchlist makes the demo look impressive, but it also makes debugging and alignment harder. For the article, you want a list that’s small enough to reason about, but diverse enough to show multi-asset behavior.</p>
<p>One thing that will skew what you see is weekends. Stocks and most forex won’t meaningfully tick when markets are closed, while crypto runs 24/7. So if you run the app on a Sunday, crypto will naturally dominate the pulse table. That’s not a bug. It’s just what happens when only one asset class is actually moving.</p>
<p>In a real product, you’d solve this by ranking movers per asset class or rendering separate sections. For this build, we'll keep it simple and accept that the output depends on when you run it.</p>
<h2 id="heading-rolling-state-buffers-returns-volatility-trend">Rolling State: Buffers, Returns, Volatility, Trend</h2>
<p>This is the core of the app. We keep a rolling buffer per symbol, compute a few live signals from it, and expose everything as a compact snapshot that the UI and the event system can consume.</p>
<h3 id="heading-pulsestorepy"><code>pulse_store.py:</code></h3>
<pre><code class="language-python">import time
import math
import threading
from collections import deque

class PulseStore:
    def __init__(self, window_sec=3600):
        self.window_sec = window_sec
        self.buffers = {}
        self.latest = {}
        self.asset = {}
        self.vol_hist = {}
        self.lock = threading.Lock()

    def _buf(self, symbol):
        if symbol not in self.buffers:
            self.buffers[symbol] = deque()
        return self.buffers[symbol]

    def update(self, tick):
        symbol = tick["symbol"]
        ts = tick["ts"]
        px = tick["price"]

        with self.lock:
            b = self._buf(symbol)
            b.append((ts, px))
            self.latest[symbol] = px
            self.asset[symbol] = tick.get("asset")

            cutoff = ts - self.window_sec
            while b and b[0][0] &lt; cutoff:
                b.popleft()

        return len(b)

    def _price_at_or_before(self, b, target_ts):
        with self.lock:
            data = list(b)

        for i in range(len(data) - 1, -1, -1):
            if data[i][0] &lt;= target_ts:
                return data[i][1]
        return None

    def ret(self, symbol, window_sec):
        b = self.buffers.get(symbol)
        if not b:
            return None

        with self.lock:
            if len(b) &lt; 2:
                return None
            now_ts, now_px = b[-1]

        px0 = self._price_at_or_before(b, now_ts - window_sec)
        if px0 is None:
            return None

        return (now_px / px0) - 1.0

    def ret_1m(self, symbol):
        return self.ret(symbol, 60)

    def ret_5m(self, symbol):
        return self.ret(symbol, 300)

    def ret_15m(self, symbol):
        return self.ret(symbol, 900)

    def _recent_prices(self, b, window_sec):
        with self.lock:
            data = list(b)

        if not data:
            return []

        cutoff = data[-1][0] - window_sec
        out = []
        for ts, px in data:
            if ts &gt;= cutoff:
                out.append(px)
        return out

    def vol_15m(self, symbol):
        b = self.buffers.get(symbol)
        if not b:
            return None

        prices = self._recent_prices(b, 900)
        if len(prices) &lt; 6:
            return None

        rets = []
        for i in range(1, len(prices)):
            rets.append(prices[i] / prices[i-1] - 1.0)

        if len(rets) &lt; 3:
            return None

        m = sum(rets) / len(rets)
        var = sum((x - m) ** 2 for x in rets) / len(rets)
        return var ** 0.5

    def trend_15m(self, symbol):
        b = self.buffers.get(symbol)
        if not b:
            return None

        prices = self._recent_prices(b, 900)
        if len(prices) &lt; 8:
            return None

        lp = []
        for p in prices:
            if p &gt; 0:
                lp.append(math.log(p))

        if len(lp) &lt; 8:
            return None

        n = len(lp)
        xs = list(range(n))

        xbar = sum(xs) / n
        ybar = sum(lp) / n

        num = 0.0
        den = 0.0
        for i in range(n):
            dx = xs[i] - xbar
            dy = lp[i] - ybar
            num += dx * dy
            den += dx * dx

        if den == 0:
            return None

        return num / den

    def _vh(self, symbol):
        if symbol not in self.vol_hist:
            self.vol_hist[symbol] = deque(maxlen=200)
        return self.vol_hist[symbol]

    def update_vol_history(self, symbol):
        v = self.vol_15m(symbol)
        if v is None:
            return None
        self._vh(symbol).append(v)
        return v

    def regime(self, symbol):
        h = self.vol_hist.get(symbol)
        if not h or len(h) &lt; 30:
            return "unknown"

        cur = h[-1]
        hs = sorted(h)
        p80 = hs[int(0.8 * (len(hs) - 1))]

        if cur &gt;= p80:
            return "high_vol"
        return "normal"

    def snapshot(self, symbol):
        last = self.latest.get(symbol)
        if last is None:
            return None

        out = {"symbol": symbol, "asset": self.asset.get(symbol), "last": last}

        r1 = self.ret_1m(symbol)
        r5 = self.ret_5m(symbol)
        r15 = self.ret_15m(symbol)
        v15 = self.vol_15m(symbol)
        tr = self.trend_15m(symbol)

        if r1 is not None:
            out["ret_1m"] = r1
        if r5 is not None:
            out["ret_5m"] = r5
        if r15 is not None:
            out["ret_15m"] = r15
        if v15 is not None:
            out["vol_15m"] = v15
        if tr is not None:
            out["trend_15m"] = tr

        v = self.update_vol_history(symbol)
        if v is not None:
            out["regime"] = self.regime(symbol)

        return out

    def snapshots(self):
        with self.lock:
            syms = list(self.buffers.keys())

        out = []
        for s in syms:
            snap = self.snapshot(s)
            if snap:
                out.append(snap)
        return out
</code></pre>
<p><code>update()</code> is the entry point. Every incoming tick gets appended to that symbol’s deque, and old points get pruned so the buffer never grows unbounded.</p>
<p>Returns are computed using a small trick: we don’t assume we have a price exactly 60 seconds ago or 300 seconds ago. We scan backwards and grab the most recent price at or before the target timestamp. That keeps returns stable even when ticks come in unevenly.</p>
<p>Volatility is computed from short returns inside the last 15 minutes of prices. It’s not annualized. It’s just a live noise meter. Trend is a tiny slope on log prices over that same window, which gives a directional hint without doing anything heavy.</p>
<p>The <code>vol_hist</code> deque is used to label regimes. We store a rolling history of recent volatility values per symbol, then call the current state <code>high_vol</code> if it’s above the 80th percentile of that recent history. It’s intentionally simple, but it’s good enough to drive the correlation window logic later.</p>
<p>The concurrency issue is the reason the lock exists. The background thread is writing to deques while Streamlit is reading them. If you iterate a deque while it’s being mutated, Python will throw an error. So every place where we iterate, we first take a snapshot copy of the deque under the lock and iterate that list instead. That keeps reads safe without making the writer slow.</p>
<h2 id="heading-turning-live-stats-into-events-stress-feed">Turning Live Stats Into Events (Stress&nbsp;Feed)</h2>
<p>Once you have live metrics, the next question is what you do with them. If you stream raw ticks into a UI, you’ll drown the user in noise. What we want instead is an event feed. Small cards that only show up when something crosses a threshold.</p>
<p>That’s what the stress feed does. It watches the snapshot coming out of PulseStore and emits one of three event types.</p>
<ul>
<li><p><code>move_1m</code> when the 1-minute move is large enough</p>
</li>
<li><p><code>move_5m</code> when the 5-minute move is large enough</p>
</li>
<li><p><code>vol_spike</code> when 15-minute volatility crosses a threshold</p>
</li>
</ul>
<p>Two practical features make this usable in a real dashboard. First, cooldowns. If TSLA crosses the 1-minute threshold, we don’t want 50 duplicate events on every tick. Second, asset-aware thresholds. Crypto naturally moves more than equities, so if you use one global threshold, BTC will dominate your stress feed all day.</p>
<h3 id="heading-eventspy"><code>events.py</code></h3>
<pre><code class="language-python">import time
from collections import deque

class EventStore:
    def __init__(self, max_events=25):
        self.max_events = max_events
        self.events = deque(maxlen=max_events)
        
    def add(self, e):
        self.events.appendleft(e)

    def latest(self):
        return list(self.events)


class StressDetector:
    def __init__(self, move_thr_1m=0.0015, move_thr_5m=0.004, vol_thr=0.00025):
        self.move_thr_1m = move_thr_1m
        self.move_thr_5m = move_thr_5m
        self.vol_thr = vol_thr
        self.cooldown_sec = 30
        self.last_emit = {}
        self.thr = {
            "stocks": {"move_1m": 0.0012, "move_5m": 0.0040, "vol": 0.00006},
            "crypto": {"move_1m": 0.0025, "move_5m": 0.0080, "vol": 0.00045},
            "forex":  {"move_1m": 0.0006, "move_5m": 0.0018, "vol": 0.00015},
        }

    def _can_emit(self, symbol, etype, now):
        k = (symbol, etype)
        prev = self.last_emit.get(k)
        if prev is None:
            self.last_emit[k] = now
            return True
        if now - prev &gt;= self.cooldown_sec:
            self.last_emit[k] = now
            return True
        return False

    def check(self, snap):
        if not snap:
            return None

        sym = snap.get("symbol")
        asset = snap.get("asset", None)
        thr = self.thr.get(asset, {"move_1m": self.move_thr_1m, "move_5m": self.move_thr_5m, "vol": self.vol_thr})
        move_thr_1m = thr["move_1m"]
        move_thr_5m = thr["move_5m"]
        vol_thr = thr["vol"]
        now = time.time()

        r5 = snap.get("ret_5m")
        r1 = snap.get("ret_1m")
        v15 = snap.get("vol_15m")

        if r5 is not None and abs(r5) &gt;= move_thr_5m:
            if self._can_emit(sym, "move_5m", now):
                return {"ts": now, "type": "move_5m", "symbol": sym, "asset": asset, "value": float(r5)}
            return None

        if r1 is not None and abs(r1) &gt;= move_thr_1m:
            if self._can_emit(sym, "move_1m", now):
                return {"ts": now, "type": "move_1m", "symbol": sym, "asset": asset, "value": float(r1)}
            return None

        if v15 is not None and v15 &gt;= vol_thr:
            if self._can_emit(sym, "vol_spike", now):
                return {"ts": now, "type": "vol_spike", "symbol": sym, "asset": asset, "value": float(v15)}
            return None

        return None
</code></pre>
<p><code>EventStore</code> is just a rolling feed. It keeps the last N events so Streamlit can render them as a table.</p>
<p><code>StressDetector.check()</code> is the filter. It looks at the latest snapshot and decides whether it’s worth creating an event. The cooldown logic is what stops spam. Once a symbol emits a <code>move_1m</code> event, it won’t emit another <code>move_1m</code> for 30 seconds.</p>
<p>The thresholds are intentionally different per asset class. Crypto needs wider bands for both moves and volatility. Otherwise, even a quiet BTC session will look like constant stress relative to equities. This one change makes the feed feel balanced and product-like.</p>
<h2 id="heading-regime-tagging-small-but-important">Regime Tagging (Small but Important)</h2>
<p>Regime is just a lightweight context label. We keep a short history of <code>vol_15m</code> per symbol and classify the current state as <code>high_vol</code> if it’s above the recent 80th percentile, otherwise normal. This gives us a stable switch we can use later. Most importantly, we use it to change the correlation lookback window depending on conditions.</p>
<h3 id="heading-add-this-to-pulsestorepy">Add this to <code>pulse_store.py</code></h3>
<p>You already have <code>PulseStore</code> in <code>pulse_store.py</code>. Insert the following methods inside the <code>PulseStore class</code>, right after <code>vol_15m()</code> and <code>trend_15m()</code> (placement isn’t critical. it just keeps the file readable).</p>
<pre><code class="language-python">    def _vh(self, symbol):
        if symbol not in self.vol_hist:
            self.vol_hist[symbol] = deque(maxlen=200)
        return self.vol_hist[symbol]

    def update_vol_history(self, symbol):
        v = self.vol_15m(symbol)
        if v is None:
            return None
        self._vh(symbol).append(v)
        return v

    def regime(self, symbol):
        h = self.vol_hist.get(symbol)
        if not h or len(h) &lt; 30:
            return "unknown"

        cur = h[-1]
        hs = sorted(h)
        p80 = hs[int(0.8 * (len(hs) - 1))]

        if cur &gt;= p80:
            return "high_vol"
        return "normal"
</code></pre>
<h3 id="heading-attach-regime-inside-snapshot-in-pulsestorepy">Attach regime inside <code>snapshot()</code> in <code>pulse_store.py</code></h3>
<p>In the same file, inside snapshot(self, symbol), add this block near the end of the function, right before return out:</p>
<pre><code class="language-python">    v = self.update_vol_history(symbol)
    if v is not None:
        out["regime"] = self.regime(symbol)
</code></pre>
<p>That’s it for regime tagging.</p>
<p><strong>Why this matters later:</strong></p>
<p>Once <code>snapshot()</code> includes regime, the rest of the app can use it without recomputing anything. In the next section, the correlation card reads <code>store.regime(base_symbol)</code> and uses that to decide whether it should look back 60 minutes (normal) or just 15 minutes (high volatility). This is what stops correlation from feeling stale during spikes and overly jumpy during calm periods.</p>
<h2 id="heading-correlation-card-stocks-only-regime-aware-window">Correlation Card (Stocks Only, Regime-aware Window)</h2>
<p>Correlation sounds simple until you try to do it live. In real-time feeds, different symbols tick at different moments. If you just correlate raw tick-to-tick returns, you’re basically correlating noise and timing gaps.</p>
<p>So we do two things to make it usable.</p>
<p>First, we align prices by time. We bucket ticks into fixed time bins (like 10s, 20s, 30s) and treat the last price inside each bin as the price for that bin. That gives every symbol a comparable timeline.</p>
<p>Second, we make the correlation window regime-aware. If the base symbol is in <code>high_vol</code>, we compute correlation on a shorter recent slice so the card reacts faster. If the regime is normal, we use a longer lookback so it doesn’t flip wildly every refresh.</p>
<p>We also keep this card stocks-only in the app. Multi-asset correlation is doable, but alignment becomes much harder when tick frequency differs massively across assets. This article is about building something shippable. A stable stocks card beats a flaky multi-asset one.</p>
<h3 id="heading-correlationpy"><code>correlation.py</code></h3>
<pre><code class="language-python">import math

def _bucket(ts, bin_sec):
    return int(ts // bin_sec) * bin_sec

def build_price_table(store, symbols, window_sec=1800, bin_sec=10):
    table = {}
    now = None

    for s in symbols:
        b = store.buffers.get(s)
        if not b:
            continue
        if now is None:
            now = b[-1][0]
        else:
            now = max(now, b[-1][0])

    if now is None:
        return {}

    cutoff = now - window_sec

    for s in symbols:
        b = store.buffers.get(s)
        if not b:
            continue

        for ts, px in b:
            if ts &lt; cutoff:
                continue
            k = _bucket(ts, bin_sec)
            row = table.get(k)
            if row is None:
                row = {}
                table[k] = row
            row[s] = px

    return table

def to_return_matrix(price_table, symbols):
    buckets = sorted(price_table.keys())
    if len(buckets) &lt; 3:
        return []

    last_prices = None
    rows = []

    for bt in buckets:
        rowp = price_table[bt]
        if any(s not in rowp for s in symbols):
            continue

        prices = [float(rowp[s]) for s in symbols]

        if last_prices is None:
            last_prices = prices
            continue

        rets = []
        ok = True
        for i in range(len(symbols)):
            p0 = last_prices[i]
            p1 = prices[i]
            if p0 &lt;= 0 or p1 &lt;= 0:
                ok = False
                break
            rets.append(p1 / p0 - 1.0)

        last_prices = prices
        if ok:
            rows.append(rets)

    return rows

def corr(a, b):
    n = len(a)
    if n &lt; 5:
        return None
    am = sum(a) / n
    bm = sum(b) / n
    num = 0.0
    da = 0.0
    db = 0.0
    for i in range(n):
        x = a[i] - am
        y = b[i] - bm
        num += x * y
        da += x * x
        db += y * y
    if da == 0 or db == 0:
        return None
    return num / math.sqrt(da * db)

def corr_card(store, symbols, base_symbol, bin_sec=10):
    reg = store.regime(base_symbol)
    win = 900 if reg == "high_vol" else 3600

    pt = build_price_table(store, symbols, window_sec=win, bin_sec=bin_sec)
    mat = to_return_matrix(pt, symbols)
    if not mat:
        return {"base": base_symbol, "regime": reg, "window_sec": win, "top": []}

    cols = list(zip(*mat))
    if base_symbol not in symbols:
        return {"base": base_symbol, "regime": reg, "window_sec": win, "top": []}

    bi = symbols.index(base_symbol)
    base = list(cols[bi])

    scores = []
    for i, s in enumerate(symbols):
        if s == base_symbol:
            continue
        c = corr(base, list(cols[i]))
        if c is None:
            continue
        scores.append((s, c))

    scores.sort(key=lambda x: abs(x[1]), reverse=True)
    top = [{"symbol": s, "corr": float(v)} for s, v in scores[:3]]

    return {"base": base_symbol, "regime": reg, "window_sec": win, "top": top}
</code></pre>
<p><code>build_price_table()</code> creates the aligned timeline. It scans each symbol’s rolling buffer, buckets timestamps into fixed bins, and stores the last price per bucket.</p>
<p><code>to_return_matrix()</code> converts those bucketed prices into returns, but only when every symbol has a price in the same bucket. That’s the alignment step that keeps correlation meaningful.</p>
<p><code>corr_card()</code> is the actual widget output. It checks the base symbol’s regime, chooses a lookback window (15m for high-vol, 60m for normal), then computes correlations against the base symbol and returns the top matches.</p>
<p>Next, we’ll wire all of this into Streamlit and render the dashboard. That’s where the build starts to feel like a real app.</p>
<h2 id="heading-building-the-streamlit-app">Building the Streamlit App</h2>
<p>At this point, we have all the moving parts. A streaming layer that produces ticks, a state engine that produces snapshots, a stress detector that emits events, and a correlation function that can generate a small card. Now we just need to wrap it in a Streamlit app without breaking everything.</p>
<p>The key trick is to start the real-time worker once and keep it running in the background. Streamlit reruns the script constantly, so the UI code should never reconnect to WebSockets or spin up new loops. It should only read shared state and render tables.</p>
<pre><code class="language-python">import asyncio
import threading
import time

import pandas as pd
import streamlit as st

from feeds import start_streams
from pulse_store import PulseStore
from events import StressDetector, EventStore
from correlation import corr_card

st.set_page_config(page_title="Market Pulse", layout="wide")

st.markdown("""
&lt;style&gt;
html, body, [class*="css"]  { background-color: #0b0f14; color: #e6edf3; }
.stApp { background-color: #0b0f14; }
div[data-testid="stMetricValue"] { color: #e6edf3; }
div[data-testid="stMetricLabel"] { color: #9aa4af; }
[data-testid="stDataFrame"] { background-color: #0b0f14; }
&lt;/style&gt;
""", unsafe_allow_html=True)

def _runner(state):
    async def _main():
        q = asyncio.Queue()
        await start_streams(q)

        store = PulseStore(window_sec=3600)
        detector = StressDetector()
        ev = EventStore(max_events=50)

        state["store"] = store
        state["events"] = ev
        state["detector"] = detector
        state["started_at"] = time.time()

        while True:
            t = await q.get()
            store.update(t)
            snap = store.snapshot(t["symbol"])
            e = detector.check(snap)
            if e:
                ev.add(e)

    asyncio.run(_main())

if "bg_started" not in st.session_state:
    st.session_state.bg_started = True
    st.session_state.state = {}
    th = threading.Thread(target=_runner, args=(st.session_state.state,), daemon=True)
    th.start()

state = st.session_state.state

st.title("Market Pulse")

col1, col2, col3 = st.columns([2, 2, 1])
with col1:
    st.caption("Real-time multi-asset pulse. Moves, stress events, and a simple correlation card.")
with col3:
    up = 0
    if "started_at" in state:
        up = int(time.time() - state["started_at"])
    st.metric("Uptime (s)", up)

if "store" not in state:
    st.info("Connecting to feeds and warming up buffers...")
    st.stop()

store = state["store"]
ev = state["events"]

c1, c2, c3 = st.columns(3)
with c1:
    top_k = st.slider("Top movers", 3, 10, 5)
with c2:
    base = st.selectbox("Correlation base (stocks)", ["TSLA", "AAPL"], index=0)
with c3:
    bin_sec = st.selectbox("Correlation bucket (sec)", [10, 20, 30], index=2)

snaps = store.snapshots()

def score(x):
    r1 = x.get("ret_1m")
    r5 = x.get("ret_5m")
    if r1 is not None:
        return abs(r1)
    if r5 is not None:
        return abs(r5)
    return 0.0

snaps.sort(key=score, reverse=True)
top = snaps[:top_k]

pulse_df = pd.DataFrame(top)
keep_cols = ["symbol", "asset", "last", "ret_1m", "ret_5m", "vol_15m", "regime"]
pulse_df = pulse_df[[c for c in keep_cols if c in pulse_df.columns]]

st.subheader("Pulse")
st.dataframe(pulse_df, use_container_width=True, height=260)

st.subheader("Stress feed")
events = ev.latest()[:15]
if events:
    ev_df = pd.DataFrame(events)
    ev_df["time"] = pd.to_datetime(ev_df["ts"], unit="s").dt.strftime("%H:%M:%S")
    ev_df = ev_df[["time", "type", "symbol", "asset", "value"]]
    st.dataframe(ev_df, use_container_width=True, height=260)
else:
    st.caption("No events yet.")

st.subheader("Correlation card (stocks)")
corr_symbols = ["AAPL", "TSLA"]
card = corr_card(store, corr_symbols, base_symbol=base, bin_sec=bin_sec)

st.write(card)

time.sleep(2.0)
st.rerun()
</code></pre>
<p>The background worker starts exactly once, inside a daemon thread. It owns the async WebSocket loop and keeps updating store and events in memory. Streamlit never touches the sockets.</p>
<p>The Pulse table comes straight from <code>store.snapshots()</code>. We sort by absolute 1-minute return when available, and fall back to 5-minute return when it exists.</p>
<p>The stress feed is rendered as a simple table, but we convert the raw epoch timestamp into a readable time string so it looks like a real UI.</p>
<p>The correlation card is a small JSON-ish object. It includes the base symbol, current regime, the window used, and the top correlations.</p>
<p>Finally, the refresh loop is intentionally basic. Sleep for two seconds, rerun, render the latest state. The heavy work continues in the worker thread.</p>
<h2 id="heading-final-output">Final Output</h2>
<p>The final app: <a href="https://gumlet.tv/watch/69b99df9554f0fb510c28ce6/">https://gumlet.tv/watch/69b99df9554f0fb510c28ce6/</a></p>
<h2 id="heading-what-id-improve-next">What I’d Improve&nbsp;Next</h2>
<p>If you want to take this beyond a demo, I’d start with a few practical upgrades.</p>
<p>First, split the Pulse table by asset class. A single global ranking is fine, but crypto will often dominate simply because it trades all the time and moves more. Separate tables for stocks, forex, and crypto makes the dashboard feel more balanced and closer to how a real product would present it.</p>
<p>Second, add light persistence. Even a tiny SQLite file or parquet dump every few minutes is enough to replay the last hour and debug issues without leaving the app running all day.</p>
<p>Third, route stress events somewhere useful. A webhook, a queue, or a small database table. Once events leave the UI and become part of a system, you can power alerts, newsletters, and internal monitoring.</p>
<p>Finally, if you want correlation to truly be multi-asset, you’ll need a stronger alignment approach. Bucketing works well for liquid equities, but for mixed tick rates you’ll want resampling logic, missing-data handling, and probably different bucket sizes per asset class.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>That’s the full build: a live market pulse screen that streams multi-asset prices, maintains rolling state in memory, converts noisy ticks into usable signals, and surfaces everything through a simple Streamlit dashboard.</p>
<p>The main takeaway is the pattern. Keep streaming, state, and UI separated. Compute a small set of metrics that update smoothly. Then turn those metrics into event cards and widgets that a product team can actually use.</p>
<p>If you already use a multi-asset feed like EODHD for pricing and coverage, this kind of dashboard becomes a straightforward extension. Not a giant engineering project, just a clean way to ship real-time market context.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use the Model Context Protocol to Build a Personal Financial Assistant ]]>
                </title>
                <description>
                    <![CDATA[ LLMs are great at writing market commentary. The problem is they can sound confident even when they haven't looked at any data. That’s fine for casual chat, but it’s not fine if you’re building a feat ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-mcp-to-build-a-personal-financial-assistant/</link>
                <guid isPermaLink="false">69c4104010e664c5dac37aed</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Wed, 25 Mar 2026 16:41:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d0911eef-bfd9-49f7-92ce-8890d8222efd.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>LLMs are great at writing market commentary. The problem is they can sound confident even when they haven't looked at any data. That’s fine for casual chat, but it’s not fine if you’re building a feature for a product, an internal tool, or anything a user might rely on.</p>
<p>In this guide, we’ll build a small financial assistant that fetches real data by calling tools exposed via the MCP protocol (Model Context Protocol), then computes the numbers in Python. The LLM’s job is only to narrate the computed facts. It doesn't invent metrics, and it doesn't do the math.</p>
<p>By the end, you’ll have two outputs you can actually plug into a product flow: a single-ticker market brief, and a watchlist snapshot that compares multiple tickers on volatility and drawdown, with the tool calls traced so you can see exactly what data was used.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-is-mcp-and-how-does-it-change-the-integration-story">What is MCP, and How Does it Change the Integration Story?</a></p>
</li>
<li><p><a href="#heading-architecture-the-narrator-pattern">Architecture: The “Narrator” Pattern</a></p>
</li>
<li><p><a href="#heading-step-1-mcp-client-wrapper-clientpy">Step 1: MCP Client Wrapper (<code>client.py</code>)</a></p>
</li>
<li><p><a href="#heading-step-2-the-assistant-core-corepy">Step 2: The Assistant Core (<code>core.py</code>)</a></p>
<ul>
<li><p><a href="#heading-1-budgets-and-trace-logging">1. Budgets and Trace Logging</a></p>
</li>
<li><p><a href="#heading-2-parsing-the-request">2. Parsing the Request</a></p>
</li>
<li><p><a href="#heading-3-tool-wrappers-prices-and-fundamentals">3. Tool Wrappers: Prices and Fundamentals</a></p>
</li>
<li><p><a href="#heading-4-deterministic-metrics">4. Deterministic Metrics</a></p>
</li>
<li><p><a href="#heading-5-watchlist-utilities">5. Watchlist Utilities</a></p>
</li>
<li><p><a href="#heading-6-facts-object-and-narration">6. Facts Object and Narration</a></p>
</li>
<li><p><a href="#heading-7-the-orchestration-function-run_assistant">7. The Orchestration Function (<code>run_assistant()</code>)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-demo-1-market-brief-for-one-ticker">Demo 1: Market Brief for One Ticker</a></p>
</li>
<li><p><a href="#heading-demo-2-watchlist-snapshot">Demo 2: Watchlist Snapshot</a></p>
</li>
<li><p><a href="#heading-what-makes-this-shippable-and-what-can-be-improved">What Makes this Shippable, and What Can Be Improved?</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This is a code-first guide. I won’t explain every line of Python, so you should be comfortable reading pandas code, basic async/await patterns, and calling APIs from Python.</p>
<p>Before you start, you’ll need:</p>
<ul>
<li><p>Python 3.10+</p>
</li>
<li><p>An EODHD API key (to access the EODHD MCP server)</p>
</li>
<li><p>An OpenAI API key (for the narration step)</p>
</li>
<li><p>The MCP Python client installed, plus the usual data stack: numpy and pandas</p>
</li>
<li><p>A local environment where you can run async Python code (Jupyter or a normal script both work)</p>
</li>
</ul>
<p>If you’ve never worked with async code before, you can still follow along. Just treat the async functions as "network calls" and focus on how the data flows from tool calls, to deterministic metrics, to narration.</p>
<h2 id="heading-what-is-mcp-and-how-does-it-change-the-integration-story">What is MCP, and How Does it Change the Integration Story?</h2>
<img src="https://cdn-images-1.medium.com/max/1000/0*zHMQKv6lzgY5-X7p" alt="source - https://www.civo.com/blog/what-is-mcp" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>MCP (Model Context Protocol) is a protocol for how an LLM application can discover and call external tools exposed by an MCP server. Instead of hardcoding a bunch of function schemas or building custom connectors per framework, you plug into an MCP server and the tools become “available” in a consistent format.</p>
<p>For product teams, this matters because it reduces integration churn. Tool discovery is predictable, you’re not rewriting wrappers every time your stack changes, and you get a clean separation between the model and the data layer.</p>
<p>In our case, that data layer is EOD Historical Data (EODHD), a market data provider. We’ll use <a href="https://eodhd.com/financial-apis/mcp-server-for-financial-data-by-eodhd">EODHD’s MCP server</a>, which exposes market data tools the assistant can call whenever it needs prices or fundamentals.</p>
<p>One important clarification for this tutorial: we’re using an MCP server purely as the data access layer. The model doesn’t decide which MCP tools to call or what parameters to pass. We'll do that deterministically in Python, then hand the model a facts object and let it write the narrative. This keeps the output grounded and makes the system much easier to trust and debug.</p>
<h2 id="heading-architecture-the-narrator-pattern">Architecture: The “Narrator” Pattern</h2>
<p>Here’s the architecture we’re using in this guide:</p>
<img src="https://cdn-images-1.medium.com/max/1500/1*Ljxbr06gEJSs2QdQnPcQSw.png" alt="Architecture diagram showing request parsing, MCP tool calls to EODHD, deterministic Python metrics, and LLM narration" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The idea is simple: we'll separate “getting facts” from “writing words”. The model only does the second part.</p>
<p>First, the user asks a question like “Give me a 30-day brief for AAPL” or “Compare TSLA, NVDA, AMZN over the last 60 days”. That raw text goes into a tiny parser. The parser is intentionally boring. It only extracts what the system needs to operate: a list of tickers and a lookback window.</p>
<p>Once we have tickers and dates, we fetch data by calling MCP tools on the EODHD MCP server. In this case, our MCP client connects to the EODHD MCP server. So instead of the assistant guessing prices or fundamentals, it calls tools like “get historical prices” and “get fundamentals”. At this point we have raw data. Nothing has been computed yet, and the model has not written a single sentence.</p>
<p>Then Python takes over. This is where we compute everything deterministically: returns, volatility, max drawdown, trend slope, and a simple volatility regime label. For watchlists, we align returns and compute correlation. These numbers are the backbone of the output. If you rerun the same query with the same window, you should get the same metrics.</p>
<p>Only after that do we involve the LLM. We pass it a compact facts object. It contains the metrics we computed, plus a few clean fundamentals fields. The prompt is strict. Use only these facts – no extra numbers and no guessing. The model’s job is to turn the facts into a clean note that feels like something a product would show.</p>
<p>Finally, the assistant returns a structured response object. Not just text. You get:</p>
<ul>
<li><p><code>answer</code> (the narrative)</p>
</li>
<li><p><code>metrics</code> (the exact computed numbers)</p>
</li>
<li><p><code>data_used</code> (tickers, date range, and which tools were called)</p>
</li>
<li><p><code>tool_trace_id</code> (a trace id you can log, debug, or attach to monitoring)</p>
</li>
</ul>
<p>This pattern is B2B-friendly for a very practical reason. It reduces hallucinations because the model isn’t doing analysis. It makes numbers repeatable because Python computes them. And it’s easy to audit because you can always show what data was fetched, what window was used, and which tool calls happened.</p>
<h2 id="heading-step-1-mcp-client-wrapper-clientpy">Step 1: MCP Client Wrapper (<code>client.py</code>)</h2>
<p>Before we touch any “assistant logic”, we need one thing: a tiny MCP client wrapper that opens MCP sessions to the EODHD MCP server and calls tools reliably. That’s it.</p>
<p>This file does three jobs:</p>
<ul>
<li><p>opens a streamable HTTP MCP session</p>
</li>
<li><p>calls a tool with a timeout and a small retry loop</p>
</li>
<li><p>returns the tool output plus a small metadata object we can later attach to logs and traces</p>
</li>
</ul>
<p>Here’s the complete <code>client.py</code>:</p>
<pre><code class="language-python">import time
import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

class EODHDMCP:
    def __init__(self, apikey, base_url=None):
        self.apikey = apikey
        self.base_url = base_url or "https://mcp.eodhd.dev/mcp"
        self._tools = None

    def _url(self):
        return f"{self.base_url}?apikey={self.apikey}"

    def _open(self):
        return streamable_http_client(self._url())

    async def list_tools(self):
        if self._tools is not None:
            return self._tools

        async with self._open() as (read, write, _):
            async with ClientSession(read, write) as s:
                await s.initialize()
                resp = await s.list_tools()
                self._tools = [t.name for t in resp.tools]
                return self._tools

    async def call_tool(self, name, args, trace_id, timeout_s=25, retries=1):
        last = None

        for attempt in range(retries + 1):
            t0 = time.time()
            try:
                async with self._open() as (read, write, _):
                    async with ClientSession(read, write) as s:
                        await s.initialize()
                        out = await asyncio.wait_for(s.call_tool(name, args), timeout=timeout_s)
                        dt = time.time() - t0
                        meta = {"trace_id": trace_id, "tool": name, "args": args, "latency_s": round(dt, 3)}
                        return out, meta
            except Exception as e:
                last = e
                if attempt &lt; retries:
                    await asyncio.sleep(0.25)

        raise last
</code></pre>
<p>How this works:</p>
<ul>
<li><p><code>streamablehttp_client(self._url())</code> opens an MCP session over streamable HTTP. The URL includes your API key as a query param, so the server can authenticate.</p>
</li>
<li><p><code>list_tools()</code> is just a convenience. It asks the server which tools exist and caches the names in memory so you don’t fetch them repeatedly.</p>
</li>
<li><p><code>call_tool()</code> is the workhorse. It opens a session, initializes it, calls a tool with <code>call_tool(name, args)</code>, and wraps the result with a <code>meta</code> object.</p>
</li>
<li><p>That <code>meta</code> object is important later. It lets you trace which tool was called, with which params, how long it took, and which request it belonged to (<code>trace_id</code>).</p>
</li>
</ul>
<p>Next, we’ll build the core runner in <code>core.py</code>. This is where we parse the user’s request, fetch prices and fundamentals via MCP, compute metrics in Python, and then hand the facts to the LLM for narration.</p>
<h2 id="heading-step-2-the-assistant-core-corepy">Step 2: The Assistant Core (<code>core.py</code>)</h2>
<p>This is where the assistant actually becomes “real”. <code>client.py</code> was just a connector. Here we decide what data to fetch, how much to fetch, how to compute the numbers, and what we hand to the model for narration.</p>
<h3 id="heading-1-budgets-and-trace-logging">1. Budgets and Trace&nbsp;Logging</h3>
<p>When you build anything that calls real tools, you want limits. Not because you don’t trust your code, but because without limits, one messy prompt can easily turn into an expensive, slow request.</p>
<p>In our case, we cap:</p>
<ul>
<li><p>how far back we’ll fetch data (<code>MAX_LOOKBACK_DAYS</code>)</p>
</li>
<li><p>how many tool calls we allow per request (<code>MAX_TOOL_CALLS</code>)</p>
</li>
<li><p>how many tickers we’ll accept in one query (<code>MAX_TICKERS</code>)</p>
</li>
</ul>
<p>And we log a few events so we can always debug what happened later.</p>
<p>Here’s the top part of <code>core.py</code> for that:</p>
<pre><code class="language-python">import json
import re
import time
import uuid
from datetime import date, timedelta
from openai import OpenAI
import numpy as np
import pandas as pd
import asyncio
from client import EODHDMCP

EODHD_API_KEY = "YOUR EODHD API KEY"
MCP_BASE_URL = "https://mcp.eodhd.dev/mcp"

MAX_LOOKBACK_DAYS = 365
MAX_TOOL_CALLS = 6
MAX_TICKERS = 5

mcp = EODHDMCP(EODHD_API_KEY, base_url=MCP_BASE_URL)
oa = OpenAI(api_key = "OPENAI API KEY")
NARRATION_MODEL = "gpt-5.3-chat-latest"

def log_event(event, trace_id, **k):
    payload = {"event": event, "trace_id": trace_id, "ts": round(time.time(), 3)}
    payload.update(k)
    print(json.dumps(payload, default=str))
</code></pre>
<p>What’s going on here:</p>
<ul>
<li><p><code>MAX_LOOKBACK_DAYS</code>, <code>MAX_TOOL_CALLS</code>, <code>MAX_TICKERS</code> are basically your safety rails. We’ll enforce them later, right after parsing the user query.</p>
</li>
<li><p><code>trace_id</code> is a small id we generate per request. Every log line includes it, so when something breaks, you can reconstruct the exact flow for that request.</p>
</li>
<li><p><code>log_event()</code> prints one JSON line. Nothing fancy –&nbsp;but it’s enough for debugging and it also looks very similar to how real systems emit traces.</p>
</li>
</ul>
<p>Note: Make sure to replace <code>YOUR EODHD API KEY</code> with your actual EODHD API key. If you don’t have one, you can obtain it by creating an EODHD developer account.</p>
<h3 id="heading-2-parsing-the-request">2. Parsing the&nbsp;Request</h3>
<p>This part is intentionally not “smart”. We’re not doing NLP. We’re not letting the model interpret the query. We just want to extract two things in a predictable way:</p>
<ul>
<li><p>tickers</p>
</li>
<li><p>lookback window</p>
</li>
</ul>
<p>That’s it.</p>
<p>The benefit of keeping it dumb is that the behavior is stable. If the query is messy, we still do something consistent, and the rest of the pipeline remains controllable.</p>
<p>Here are the two functions:</p>
<pre><code class="language-python">def parse_request(text):
    t = (text or "").upper()

    raw = re.findall(r"\b[A-Z]{1,5}\b", t)

    bad = {
        "I","A","AN","THE","AND","OR","TO","FOR","OF","IN","ON","BY","WITH","ME","WE","US",
        "GIVE","DAY","DAYS","BRIEF","COMPARE","RANK","OVER","LAST","TREND","VOL","VOLATILITY",
        "DRAWDOWN","FLAG","RISKS","RISK","PLUS","MAX","MIN","LOOKBACK"
    }

    tickers = []
    for x in raw:
        if x in bad:
            continue
        if len(x) &lt; 2:
            continue
        if x not in tickers:
            tickers.append(x)

    days = 30

    if "LAST" in t:
        after = t.split("LAST", 1)[1]
        m = re.search(r"\d{1,4}", after)
        if m:
            days = int(m.group(0))
    
    return tickers, days

def enforce_budgets(tickers, lookback_days):
    if lookback_days &lt; 1:
        lookback_days = 1
    if lookback_days &gt; MAX_LOOKBACK_DAYS:
        lookback_days = MAX_LOOKBACK_DAYS

    tickers = tickers[:MAX_TICKERS]

    return tickers, lookback_days
</code></pre>
<p>How to read this:</p>
<ul>
<li><p><code>re.findall(r"\b[A-Z]{1,5}\b", t)</code> pulls out every short uppercase token. That’s our crude “ticker candidate” list.</p>
</li>
<li><p>The <code>bad</code> set is just a blacklist of common words that show up in prompts but are obviously not tickers.</p>
</li>
<li><p>We keep unique tickers in order, because the first ticker becomes the “base” for correlation in the watchlist demo.</p>
</li>
<li><p>Lookback is simple: the default is 30 days. If the query contains “last&nbsp;…”, we grab the first number after “LAST”. That avoids regex edge cases with punctuation.</p>
</li>
</ul>
<p>Then <code>enforce_budgets()</code> clamps everything so one request can’t ask for 500 tickers or a 10-year window.</p>
<p>Next, we’ll wire these parsed values into a request state and start making actual MCP calls for prices and fundamentals.</p>
<h3 id="heading-3-tool-wrappers-prices-and-fundamentals">3. Tool Wrappers: Prices and Fundamentals</h3>
<p>Now we’re at the point where the assistant actually touches data.</p>
<p>These two functions do the same job in different ways:</p>
<ul>
<li><p><code>fetch_prices()</code> calls the historical prices tool on the EODHD MCP server, then normalizes the output into a tiny DataFrame with just <code>date</code> and <code>price</code>.</p>
</li>
<li><p><code>fetch_fundamentals()</code> calls the fundamentals tool on the EODHD MCP server.</p>
</li>
</ul>
<p>We also keep a small <code>state</code> object per request. It tracks tool calls and keeps a trace of what was called. That’s how we later produce the <code>data_used</code> block in the final response.</p>
<p>Here’s the code:</p>
<pre><code class="language-python">def new_state():
    return {"tool_calls": 0, "tool_trace": [], "rows": {}}

def _bump(state, meta):
    state["tool_calls"] += 1
    state["tool_trace"].append(meta)
    if state["tool_calls"] &gt; MAX_TOOL_CALLS:
        raise RuntimeError("tool call budget exceeded")

def _as_json_text(out):
    if isinstance(out, str):
        return out
    if hasattr(out, "content"):
        try:
            return out.content[0].text
        except Exception:
            pass
    return str(out)

async def fetch_prices(ticker, start_date, end_date, trace_id, state):
    args = {
        "ticker": ticker,
        "start_date": start_date,
        "end_date": end_date,
        "period": "d",
        "order": "a",
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_historical_stock_prices", args, trace_id)
    txt = _as_json_text(out)

    _bump(state, meta)

    data = json.loads(txt)
    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    df = pd.DataFrame(data)
    if df.empty:
        return df

    cols = [c for c in ["date", "adjusted_close", "close"] if c in                   df.columns]
    df = df[cols].copy()

    if "adjusted_close" in df.columns:
        df = df.rename(columns={"adjusted_close": "price"})
    elif "close" in df.columns:
        df = df.rename(columns={"close": "price"})
    else:
        return pd.DataFrame()

    df["ticker"] = ticker

    state["rows"][f"{meta['tool']}:{ticker}"] = len(df)
    return df

async def fetch_fundamentals(ticker, trace_id, state):
    args = {
        "ticker": ticker,
        "include_financials": False,
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_fundamentals_data", args, trace_id)
    txt = _as_json_text(out)

    _bump(state, meta)

    data = json.loads(txt)
    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    return data
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p><code>_bump()</code> is the budget guard. Every time we make a tool call, we increment the counter and store the tool metadata. If we cross the budget, we fail fast.</p>
</li>
<li><p><code>meta</code> comes from <code>client.py</code>. It contains <code>tool</code>, <code>args</code>, and latency. That’s enough to trace “what did we call and how long did it take”.</p>
</li>
<li><p><code>_as_json_text()</code> is there because the tool results returned by the MCP server are not always plain strings. Sometimes it’s an object with&nbsp;<code>.content</code>. This helper just tries to extract the text cleanly.</p>
</li>
<li><p>In <code>fetch_prices()</code>, we intentionally keep only <code>date</code> and <code>price</code>. That’s not because OHLC is useless. It’s because this tutorial’s metrics only need adjusted closes. Fewer columns means simpler code, smaller payloads, and fewer chances to break.</p>
</li>
</ul>
<p>Next, we’ll compute the actual metrics. This is where the assistant stops being “an API caller” and starts producing something useful.</p>
<h3 id="heading-4-deterministic-metrics">4. Deterministic Metrics</h3>
<p>This is the most important design choice in the whole build. The model never computes numbers. Python does.</p>
<p>So for every ticker, we compute a small set of metrics that are easy to explain and are actually useful in a “market brief” style output:</p>
<ul>
<li><p>total return over the window</p>
</li>
<li><p>realized volatility (daily and annualized)</p>
</li>
<li><p>max drawdown (worst peak-to-trough fall)</p>
</li>
<li><p>a simple trend slope (so we can say “mild uptrend” or “downtrend” without vibes)</p>
</li>
<li><p>a lightweight regime label (low, mid, high volatility)</p>
</li>
</ul>
<p>Here’s the code:</p>
<pre><code class="language-python">def compute_metrics(prices_df):
    if prices_df is None or prices_df.empty:
        return {}

    df = prices_df.copy()
    df["date"] = pd.to_datetime(df["date"], errors="coerce")
    df = df.dropna(subset=["date"]).sort_values("date")

    close = pd.to_numeric(df["price"], errors="coerce").dropna()
    if close.empty:
        return {}

    rets = close.pct_change().dropna()

    out = {}

    # realized vol (daily), annualize with sqrt(252)
    if not rets.empty:
        out["vol_daily"] = float(rets.std())
        out["vol_annualized"] = float(rets.std() * np.sqrt(252))
        out["ret_total"] = float((close.iloc[-1] / close.iloc[0]) - 1.0)

    # max drawdown
    peak = close.cummax()
    dd = (close / peak) - 1.0
    out["max_drawdown"] = float(dd.min())

    # simple trend score
    logp = np.log(close.values)
    x = np.arange(len(logp))
    if len(logp) &gt;= 3:
        slope = np.polyfit(x, logp, 1)[0]
        out["trend_slope"] = float(slope)
    else:
        out["trend_slope"] = 0.0

    # basic helpers
    out["n_points"] = int(len(close))
    out["start_close"] = float(close.iloc[0])
    out["end_close"] = float(close.iloc[-1])

    return out

def compute_regime(prices_df, window=20):
    # cheap regime label, based on rolling vol percentile
    if prices_df is None or prices_df.empty:
        return {"regime": "unknown"}

    df = prices_df.copy()
    df["date"] = pd.to_datetime(df["date"], errors="coerce")
    df = df.dropna(subset=["date"]).sort_values("date")

    close = pd.to_numeric(df["price"], errors="coerce").dropna()
    if close.empty:
        return {"regime": "unknown"}

    rets = close.pct_change()
    rv = rets.rolling(window).std()

    last = rv.dropna()
    if last.empty:
        return {"regime": "unknown"}

    cur = float(last.iloc[-1])
    p80 = float(last.quantile(0.8))
    p50 = float(last.quantile(0.5))

    if cur &gt;= p80:
        reg = "high_vol"
    elif cur &gt;= p50:
        reg = "mid_vol"
    else:
        reg = "low_vol"

    return {"regime": reg, "rolling_vol": cur, "window": int(window)}
</code></pre>
<p>How to think about these calculations:</p>
<ul>
<li><p><strong>Total return</strong> is just <code>end / start - 1</code>. It’s the simplest “did it go up or down” number.</p>
</li>
<li><p><strong>Volatility</strong> here is realized volatility of daily returns. That’s just the standard deviation of daily % changes. We annualize it using <code>sqrt(252)</code> because markets have roughly 252 trading days.</p>
</li>
<li><p><strong>Max drawdown</strong> tells you how bad the worst dip was during the window. It’s often more meaningful than return when you’re writing a quick risk note.</p>
</li>
<li><p><strong>Trend slope</strong> is intentionally simple. We fit a straight line to log prices. If the slope is positive, it’s generally drifting up. If it’s negative, it’s drifting down.</p>
</li>
<li><p><strong>Regime label</strong> is not a fancy model. It just says “compared to its own recent rolling volatility, are we currently in a high, medium, or low vol phase”.</p>
</li>
</ul>
<p>The main point is this: these numbers are deterministic. If the assistant says “max drawdown was -13%”, you can trace it back to the exact adjusted close series that produced it.</p>
<p>Next, we’ll handle the watchlist side. That means aligning returns across tickers, computing correlation, and generating a ranked snapshot.</p>
<h3 id="heading-5-watchlist-utilities">5. Watchlist Utilities</h3>
<p>Once you have more than one ticker, you want two extra things:</p>
<ul>
<li><p>a quick ranking so you can say “this is the riskiest name in the basket”</p>
</li>
<li><p>a correlation snapshot so you can see what’s moving together</p>
</li>
</ul>
<p>The only “gotcha” with correlation is dates. If TSLA has 41 price points and NVDA has 39 because of missing days, you can’t just correlate blindly. You need the returns lined up on the same dates first. That’s what <code>align_returns()</code> does.</p>
<p>Here’s the code:</p>
<pre><code class="language-python">def align_returns(price_frames):
    if not price_frames:
        return pd.DataFrame()

    parts = []
    for df in price_frames:
        if df is None or df.empty:
            continue
        x = df.copy()
        x["date"] = pd.to_datetime(x["date"], errors="coerce")
        x = x.dropna(subset=["date"])
        x["price"] = pd.to_numeric(x["price"], errors="coerce")
        x = x.dropna(subset=["price"])
        x = x.sort_values("date")
        x["ret"] = x["price"].pct_change()
        x = x.dropna(subset=["ret"])
        parts.append(x[["date", "ticker", "ret"]])

    if not parts:
        return pd.DataFrame()

    allr = pd.concat(parts, ignore_index=True)
    wide = allr.pivot(index="date", columns="ticker", values="ret").dropna(how="any")
    return wide


def corr_summary(ret_wide, base_ticker, top_n=3):
    if ret_wide is None or ret_wide.empty:
        return []

    if base_ticker not in ret_wide.columns:
        return []

    c = ret_wide.corr()[base_ticker].dropna()
    c = c.drop(labels=[base_ticker], errors="ignore")
    if c.empty:
        return []

    out = []
    for k, v in c.sort_values(ascending=False).head(top_n).items():
        out.append({"ticker": k, "corr": float(v)})

    return out


def rank_watchlist(metrics_by_ticker):
    rows = []
    for t, m in metrics_by_ticker.items():
        if not m:
            continue
        rows.append({
            "ticker": t,
            "vol_annualized": m.get("vol_annualized"),
            "max_drawdown": m.get("max_drawdown"),
            "ret_total": m.get("ret_total"),
            "trend_slope": m.get("trend_slope"),
        })

    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    df = df.sort_values(["vol_annualized", "max_drawdown"], ascending=[False, True])
    return df.reset_index(drop=True)
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p><code>align_returns()</code> takes a list of price DataFrames, computes daily returns for each, then pivots them into a wide table like: <code>date -&gt; TSLA.US, NVDA.US, AMZN.US</code>.</p>
</li>
<li><p>We drop rows where any ticker is missing, because correlation only makes sense when the returns are aligned on the same dates.</p>
</li>
<li><p><code>corr_summary()</code> is a compact “who moves with whom” helper. We pick one base ticker, compute correlations against everything else, then grab the top few. For a watchlist widget, that’s usually enough.</p>
</li>
<li><p><code>rank_watchlist()</code> is the ranking logic for the snapshot. We sort primarily by annualized volatility, and use drawdown as a secondary risk indicator. You could choose different ranking logic. The point is to keep it deterministic and explainable.</p>
</li>
</ul>
<p>Next, we’ll build the facts objects and narration layer. That’s where we enforce the “model is just a narrator” contract.</p>
<h3 id="heading-6-facts-object-and-narration">6. Facts Object and Narration</h3>
<p>This is where the “narrator pattern” becomes real.</p>
<p>Up to this point, we’ve done everything with MCP and Python. We fetched prices and fundamentals from EODHD, we computed metrics, and we aligned returns. Now we need one clean object that represents “the truth” for this request.</p>
<p>That’s what the <code>facts</code> object is.</p>
<p>The rule is simple.</p>
<ul>
<li><p><code>facts</code> contains only things we actually fetched or computed.</p>
</li>
<li><p>The model never sees raw market data. It sees the cleaned facts.</p>
</li>
<li><p>The model is told to write using only those facts, and not to invent any numbers.</p>
</li>
</ul>
<p>Here are the functions that build those facts objects for the two demos, plus the narration function.</p>
<pre><code class="language-python">def build_facts_single(ticker, lookback_days, metrics, regime, fundamentals):
    # keep this compact. LLM will narrate from this later
    out = {
        "type": "single_ticker_brief",
        "ticker": ticker,
        "lookback_days": int(lookback_days),
        "metrics": metrics,
        "regime": regime,
    }

    if isinstance(fundamentals, dict):
        gen = fundamentals.get("General", {}) or {}
        hi = fundamentals.get("Highlights", {}) or {}
        val = fundamentals.get("Valuation", {}) or {}
        tech = fundamentals.get("Technicals", {}) or {}

        base = {
            "name": gen.get("Name"),
            "exchange": gen.get("Exchange"),
            "sector": gen.get("Sector"),
            "industry": gen.get("Industry"),
        }

        metrics = {
            "market_cap": hi.get("MarketCapitalization"),
            "pe": hi.get("PERatio") or val.get("TrailingPE") or val.get("PERatio"),
            "beta": tech.get("Beta"),
            "div_yield": hi.get("DividendYield"),
        }

        out["fundamentals"] = {k: v for k, v in {**base, **metrics}.items() if v is not None}

    return out


def build_facts_watchlist(tickers, lookback_days, rank_df, corr_bits, metrics_by_ticker):
    out = {
        "type": "watchlist_snapshot",
        "tickers": tickers,
        "lookback_days": int(lookback_days),
        "ranking": rank_df.to_dict(orient="records") if isinstance(rank_df, pd.DataFrame) else [],
        "correlation": corr_bits,
        "metrics_by_ticker": metrics_by_ticker,
    }
    return out


def narrate(facts):
    prompt = (
        "Write a short, product-ready market note using ONLY the facts below.\n"
        "No guessing. No extra numbers. If something is missing, say it's missing.\n"
        "Keep it tight and readable.\n\n"
        f"FACTS:\n{json.dumps(facts, indent=2, default=str)}"
    )

    r = oa.responses.create(
        model=NARRATION_MODEL,
        input=[{"role": "user", "content": prompt}],
    )

    try:
        return r.output_text
    except Exception:
        return str(r)
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p><code>build_facts_single()</code> takes the ticker, window, computed metrics, the vol regime label, and the fundamentals payload. But it doesn’t dump the entire fundamentals JSON. It picks a handful of fields from the <code>General</code> section and only keeps what exists. That keeps the prompt tight and the output predictable.</p>
</li>
<li><p><code>build_facts_watchlist()</code> is the same idea but for multiple tickers. It passes the ranking table, correlation notes, and per-ticker metrics.</p>
</li>
<li><p><code>narrate()</code> is basically “convert this facts object into human-friendly text”. The prompt is strict on purpose. If the model can only see these facts, it cannot hallucinate numbers outside them.</p>
</li>
</ul>
<p>One small implementation detail: <code>narrate()</code> is a normal blocking function, while everything else is async. That’s why later, inside <code>run_assistant()</code>, we call it with <code>await asyncio.to_thread(...)</code> so it doesn’t block the async flow.</p>
<h3 id="heading-7-the-orchestration-function-runassistant">7. The Orchestration Function (<code>run_assistant()</code>)</h3>
<p>This is the piece that ties everything together. It does four things in order:</p>
<ol>
<li><p>create a trace id and log the request</p>
</li>
<li><p>parse tickers and lookback, then clamp them to budgets</p>
</li>
<li><p>fetch EODHD data via MCP and compute metrics in Python</p>
</li>
<li><p>call the model to narrate the facts, then return a structured response</p>
</li>
</ol>
<p>Here’s the function:</p>
<pre><code class="language-python">def _dates_from_lookback(lookback_days):
    end = date.today()
    start = end - timedelta(days=int(lookback_days))
    return start.isoformat(), end.isoformat()

async def run_assistant(user_text, mode="auto"):
    trace_id = uuid.uuid4().hex[:10]
    log_event("request_started", trace_id, text=user_text, mode=mode)

    tickers, lookback = parse_request(user_text)
    tickers, lookback = enforce_budgets(tickers, lookback)

    if not tickers:
        return {
            "answer": "no tickers found in request",
            "metrics": {},
            "data_used": {},
            "tool_trace_id": trace_id,
        }

    log_event("parsed", trace_id, tickers=tickers, lookback_days=lookback)
    
    start_date, end_date = _dates_from_lookback(lookback)
    state = new_state()
        
    if mode == "auto":
        mode = "watchlist" if len(tickers) &gt; 1 else "single"

    try:
        if mode == "single":
            t = tickers[0]
            t_full = t if "." in t else f"{t}.US"

            log_event("tool_phase", trace_id, mode="single", ticker=t_full, start_date=start_date, end_date=end_date)

            prices = await fetch_prices(t_full, start_date, end_date, trace_id, state)
            metrics = compute_metrics(prices)
            regime = compute_regime(prices)

            fundamentals = await fetch_fundamentals(t_full, trace_id, state)

            facts = build_facts_single(t_full, lookback, metrics, regime, fundamentals)
            answer = await asyncio.to_thread(narrate, facts)

            resp = {
                "answer": answer,
                "metrics": metrics,
                "data_used": {
                    "tickers": [t_full],
                    "date_range": [start_date, end_date],
                    "tools_called": [x.get("tool") for x in state["tool_trace"]],
                    "tool_calls": state["tool_calls"],
                },
                "tool_trace_id": trace_id,
            }

            log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
            return resp

        # watchlist
        full = [x if "." in x else f"{x}.US" for x in tickers]

        log_event("tool_phase", trace_id, mode="watchlist", tickers=full, start_date=start_date, end_date=end_date)

        frames = []
        metrics_by = {}

        for t in full:
            prices = await fetch_prices(t, start_date, end_date, trace_id, state)
            frames.append(prices)
            metrics_by[t] = compute_metrics(prices)

        ret_wide = align_returns(frames)

        base = full[0]
        corr_bits = []
        top = corr_summary(ret_wide, base, top_n=3)
        if top:
            corr_bits.append({"base": base, "top": top})

        rank_df = rank_watchlist(metrics_by)
        facts = build_facts_watchlist(full, lookback, rank_df, corr_bits, metrics_by)
        answer = await asyncio.to_thread(narrate, facts)

        resp = {
            "answer": answer,
            "metrics": {"by_ticker": metrics_by},
            "data_used": {
                "tickers": full,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }

        log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
        return resp

    except Exception as e:
        detail = repr(e)
        if hasattr(e, "exceptions"):
            detail = detail + " | " + " ; ".join([repr(x) for x in e.exceptions])

        log_event("request_failed", trace_id, err=detail)
        
        return {
            "answer": f"failed: {e}",
            "metrics": {},
            "data_used": {
                "tickers": tickers,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }
</code></pre>
<p>This function is the glue. It creates a <code>trace_id</code>, logs the request, extracts tickers and a lookback window, then clamps both to your budgets so the assistant can’t over-fetch or spam tool calls.</p>
<p>After that, it turns the lookback into a <code>start_date</code> and <code>end_date</code>, initializes a fresh <code>state</code>, and picks a mode. In <code>single</code> mode, it fetches prices and fundamentals for one ticker via EODHD’s MCP tools, computes the metrics in Python, packs everything into a facts object, and asks the LLM to only narrate those facts. In <code>watchlist</code> mode it does the same across multiple tickers, then aligns returns so correlation is computed on matching dates, and builds a ranked snapshot.</p>
<p>The response is always structured the same way. You get the narrative <code>answer</code>, the raw computed <code>metrics</code>, a <code>data_used</code> block that shows tickers, date range, and tools called, plus a <code>tool_trace_id</code> so you can trace any output back to logs.</p>
<p>That structure is the difference between “a chat response” and “a shippable assistant output”. You can plug the same response into a UI card, a Slack alert, or a dashboard without changing anything.</p>
<h2 id="heading-demo-1-market-brief-for-one-ticker">Demo 1: Market Brief for One&nbsp;Ticker</h2>
<p>Let’s start with the simplest flow. One ticker, one lookback window, and a market brief that looks like something you could show inside a product.</p>
<p><strong>Prompt used:</strong></p>
<blockquote>
<p><em>“Give me a 30-day brief for AAPL. trend, volatility, max drawdown, plus 3 fundamental highlights.”</em></p>
</blockquote>
<p><strong>Code (Jupyter Notebook):</strong></p>
<pre><code class="language-python">import asyncio
import json
from core import run_assistant

q1 = "Give me a 30-day brief for AAPL. trend, volatility, max drawdown, plus 3 fundamental highlights."

r1 = await run_assistant(q1, mode="single")
print(json.dumps(r1, indent=2, ensure_ascii=False))
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">{"event": "request_started", "trace_id": "2af550173f", "ts": 1772735388.777, "text": "Give me a 30-day brief for AAPL. trend, volatility, max drawdown, plus 3 fundamental highlights.", "mode": "single"}
{"event": "parsed", "trace_id": "2af550173f", "ts": 1772735388.778, "tickers": ["AAPL"], "lookback_days": 30}
{"event": "tool_phase", "trace_id": "2af550173f", "ts": 1772735388.778, "mode": "single", "ticker": "AAPL.US", "start_date": "2026-02-03", "end_date": "2026-03-05"}
{"event": "request_finished", "trace_id": "2af550173f", "ts": 1772735404.392, "tool_calls": 2}
{
  "answer": "Apple Inc (AAPL.US) | NASDAQ | Technology — Consumer Electronics\n
\nOver the past 30 days, Apple shares declined 2.58%, falling from 269.48 to 
262.52 across 21 trading observations. The trend slope over the period was 
negative (-0.00175), indicating a modest downward drift.\n\nRealized daily 
volatility was 1.93%, equivalent to about 30.65% annualized. The stock is currently 
classified in a high‑volatility regime based on a 20‑day rolling volatility measure.
\n\nMaximum drawdown during the period reached -8.03%.\n\nAdditional fundamentals 
or valuation metrics were not provided.",
  "metrics": {
    "vol_daily": 0.01930981768788001,
    "vol_annualized": 0.3065338527847606,
    "ret_total": -0.02582751966750796,
    "max_drawdown": -0.08032503955127279,
    "trend_slope": -0.0017498633497641184,
    "n_points": 21,
    "start_close": 269.48,
    "end_close": 262.52
  },
  "data_used": {
    "tickers": [
      "AAPL.US"
    ],
    "date_range": [
      "2026-02-03",
      "2026-03-05"
    ],
    "tools_called": [
      "get_historical_stock_prices",
      "get_fundamentals_data"
    ],
    "tool_calls": 2
  },
  "tool_trace_id": "2af550173f"
}
</code></pre>
<p>First, you’ll see the log events. They’re not part of the final response. They’re just the trace trail.</p>
<ul>
<li><p><code>request_started</code> shows the raw prompt and that we forced <code>mode="single"</code>.</p>
</li>
<li><p><code>parsed</code> confirms the parser extracted <code>AAPL</code> and a 30-day lookback.</p>
</li>
<li><p><code>tool_phase</code> shows what we actually fetched: <code>AAPL.US</code> from <code>2026-02-03</code> to <code>2026-03-05</code>.</p>
</li>
<li><p><code>request_finished</code> confirms we made exactly <strong>2 tool calls.</strong></p>
</li>
</ul>
<p>Now the actual response JSON:</p>
<p><code>answer</code> is the narrative. In this run it summarizes:</p>
<ul>
<li><p>return of -2.58% (269.48 to 262.52)</p>
</li>
<li><p>21 price observations in that window</p>
</li>
<li><p>negative trend slope (-0.00175) meaning mild downward drift</p>
</li>
<li><p>daily vol 1.93% and annualized vol 30.65%</p>
</li>
<li><p>max drawdown -8.03%</p>
</li>
<li><p>and it labels the regime as high volatility using the rolling vol logic.</p>
</li>
</ul>
<p><code>metrics</code> is where those numbers come from. This is the deterministic part. <code>ret_total</code>, <code>vol_daily</code>, <code>vol_annualized</code>, <code>max_drawdown</code>, and <code>trend_slope</code> were computed directly from the fetched closes. <code>start_close</code>, <code>end_close</code>, and <code>n_points</code> explain the exact series used.</p>
<p><code>data_used</code> is the audit block for this specific output. It shows:</p>
<ul>
<li><p>ticker normalized to <code>AAPL.US</code></p>
</li>
<li><p>the exact date range pulled</p>
</li>
<li><p>the exact tools called on the MCP server: <code>get_historical_stock_prices</code> and <code>get_fundamentals_data</code></p>
</li>
<li><p>and again, <code>tool_calls: 2</code> so you can quickly spot runaway calls.</p>
</li>
</ul>
<p><code>tool_trace_id</code> (<code>2af550173f</code>) is your handle for debugging. Every log line above carries the same id, so you can trace this brief back to the exact tool calls and parameters.</p>
<h2 id="heading-demo-2-watchlist-snapshot">Demo 2: Watchlist Snapshot</h2>
<p>Now let’s switch to the watchlist flow. Same assistant core. The only difference is we pass multiple tickers and a longer window, so the output becomes a comparative risk snapshot.</p>
<p><strong>Prompt used:</strong></p>
<blockquote>
<p><em>“Compare TSLA, NVDA, AMZN over the last 60 days. rank by volatility and drawdown, and flag valuation risks.”</em></p>
</blockquote>
<p><strong>Code:</strong></p>
<pre><code class="language-python">q2 = "Compare TSLA, NVDA, AMZN over the last 60 days. rank by volatility and drawdown, and flag risk outliers."

r2 = await run_assistant(q2, mode="watchlist")
print(json.dumps(r2, indent=2, ensure_ascii=False))
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">{"event": "request_started", "trace_id": "1b67bb47d6", "ts": 1772735404.394, "text": "Compare TSLA, NVDA, AMZN over the last 60 days. rank by volatility and drawdown, and flag valuation risks.", "mode": "watchlist"}
{"event": "parsed", "trace_id": "1b67bb47d6", "ts": 1772735404.394, "tickers": ["TSLA", "NVDA", "AMZN"], "lookback_days": 60}
{"event": "tool_phase", "trace_id": "1b67bb47d6", "ts": 1772735404.394, "mode": "watchlist", "tickers": ["TSLA.US", "NVDA.US", "AMZN.US"], "start_date": "2026-01-05", "end_date": "2026-03-06"}
{"event": "request_finished", "trace_id": "1b67bb47d6", "ts": 1772735423.004, "tool_calls": 3}
{
  "answer": "Market Watchlist Snapshot (last 60 days)\n\nAll three names show 
negative total returns and downward trend slopes over the period.\n\nNVDA.US 
ranks highest in the group despite a small decline. Total return is -0.027. 
Price moved from 188.12 to 183.04 across 41 observations. Annualized volatility is 
0.3808 and maximum drawdown is -0.107.\n\nTSLA.US shows the second‑highest volatility 
profile with annualized volatility of 0.3561. Total return is -0.101, with price 
falling from 451.67 to 405.94. Maximum drawdown reached -0.131. Trend slope is negative.
\n\nAMZN.US has the lowest volatility in the set (annualized 0.3196) but the deepest 
drawdown at -0.196. Total return is -0.0697, with price moving from 233.06 to 
216.82. Trend slope is also negative.\n\nCorrelation: TSLA shows a stronger 
relationship with NVDA (0.533) than with AMZN (0.177).\n\nMissing from the 
data: trading volume, catalysts, sector context, and forward-looking indicators.",
  "metrics": {
    "by_ticker": {
      "TSLA.US": {
        "vol_daily": 0.02243518393199404,
        "vol_annualized": 0.3561475038122908,
        "ret_total": -0.10124648526579139,
        "max_drawdown": -0.13115770363318358,
        "trend_slope": -0.0026452119688441023,
        "n_points": 41,
        "start_close": 451.67,
        "end_close": 405.94
      },
      "NVDA.US": {
        "vol_daily": 0.023987861378298222,
        "vol_annualized": 0.3807954941476091,
        "ret_total": -0.027004039974484417,
        "max_drawdown": -0.10716326424601319,
        "trend_slope": -4.3573704505466623e-05,
        "n_points": 41,
        "start_close": 188.12,
        "end_close": 183.04
      },
      "AMZN.US": {
        "vol_daily": 0.020129905817481322,
        "vol_annualized": 0.31955234824924766,
        "ret_total": -0.06968162704882863,
        "max_drawdown": -0.1964184655186353,
        "trend_slope": -0.00520436173926906,
        "n_points": 41,
        "start_close": 233.06,
        "end_close": 216.82
      }
    }
  },
  "data_used": {
    "tickers": [
      "TSLA.US",
      "NVDA.US",
      "AMZN.US"
    ],
    "date_range": [
      "2026-01-05",
      "2026-03-06"
    ],
    "tools_called": [
      "get_historical_stock_prices",
      "get_historical_stock_prices",
      "get_historical_stock_prices"
    ],
    "tool_calls": 3
  },
  "tool_trace_id": "1b67bb47d6"
}
</code></pre>
<p>The logs show the assistant correctly extracted <code>TSLA</code>, <code>NVDA</code>, <code>AMZN</code> and a <strong>60-day</strong> lookback, then fetched <code>TSLA.US</code>, <code>NVDA.US</code>, and <code>AMZN.US</code> from <code>2026-01-05</code> to <code>2026-03-06</code>. Since this is a watchlist request, it made exactly <strong>3</strong> tool calls. One <code>get_historical_stock_prices</code> call per ticker.</p>
<p>Inside <code>answer</code>, the model is basically summarizing what Python computed. In this run, all three names had negative returns and negative trend slopes.</p>
<ul>
<li><p>NVDA had the highest annualized volatility at 0.3808 with a relatively small decline of -2.7%.</p>
</li>
<li><p>TSLA was next in volatility (0.3561) with a larger decline (-10.1%) and drawdown of about -13.1%.</p>
</li>
<li><p>AMZN had the lowest volatility (0.3196) but the deepest drawdown at around -19.6%. It also includes a correlation note derived from the aligned returns table.</p>
</li>
<li><p>TSLA’s return series correlated more with NVDA (0.533) than with AMZN (0.177) in this window.</p>
</li>
</ul>
<p><code>metrics.by_ticker</code> is where the snapshot really lives. It contains the full computed metric set per ticker, including observation count (<code>n_points=41</code>) and the start and end closes used for the return calculation. <code>data_used</code> shows exactly what we fetched, including the tickers, the date range, and the three price tool calls. And <code>tool_trace_id</code> is the id that links this output back to the full trace logs.</p>
<p>So how would a product team use this? Well, this output is already shaped like a widget backend. You can render the ranking as a watchlist “risk card”, show the top volatility and drawdown names, and drop the narrative into a compact summary box. Since you also get deterministic <code>metrics</code>, you can build UI elements without parsing text, and still keep the narration as a layer on top.</p>
<h2 id="heading-what-makes-this-shippable-and-what-can-be-improved">What Makes this Shippable, and What Can Be&nbsp;Improved?</h2>
<p>The core reason this works in a real product setting is that the numbers are deterministic. Prices and fundamentals come from EODHD via MCP, metrics are computed in Python, and the model only writes narrative from a facts object.</p>
<p>On top of that, every run is traceable. You get tool logs, <code>data_used</code>, and a <code>tool_trace_id</code>, plus hard limits on lookback, tickers, and tool calls so the system can’t spiral.</p>
<p>At the same time, this is still an MVP. The parsing is a simple heuristic, the metric set is intentionally small, and fundamentals are only lightly extracted.</p>
<p>If you want to take this further, the next upgrades are straightforward: you can add volume and a couple more data tools like earnings calendar and news, introduce caching for repeated requests, build a tiny evaluation harness with fixed prompts and expected outputs, then wrap <code>run_assistant()</code> behind a small API so it can power an actual UI or internal service.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The main takeaway is simple. If you want a financial assistant to be usable beyond casual chat, you need to separate facts from narrative. The MCP protocol gives you a clean way to connect to tool providers via an MCP server. Python gives you deterministic metrics, and the model becomes the last-mile layer that turns those facts into readable output.</p>
<p>This is still a small build, but it’s already shaped like something you can ship. The response format is structured, traceable, and easy to plug into a UI. If you extend it with a few more tools and add basic caching, it can quickly move from a Jupyter notebook demo to a real feature.</p>
<p>If you want to try the same approach with a full market data tool layer out of the box, EODHD’s MCP server is a solid starting point.</p>
<p>With that being said, you’ve reached the end of the article. Hope you learned something new and useful today. Thank you very much for your time.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ A Comprehensive Guide to Financial Storytelling using Data Visualization ]]>
                </title>
                <description>
                    <![CDATA[ In any analysis project, raw tables of numbers often don’t tell the full story. Visualisations simplify complexity by transforming data into shapes that our brains can quickly understand, emphasising  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/financial-storytelling-using-data-visualization/</link>
                <guid isPermaLink="false">69b1ced06c896b0519c207be</guid>
                
                    <category>
                        <![CDATA[ data visualization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ finance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Data Science ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Wed, 11 Mar 2026 20:21:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/64ab3674-959f-44b5-8be2-4ca00a798621.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In any analysis project, raw tables of numbers often don’t tell the full story. Visualisations simplify complexity by transforming data into shapes that our brains can quickly understand, emphasising trends, outliers, and regime shifts that might be overlooked in raw data.</p>
<p>This is especially vital in finance and trading, where clear visuals can uncover risks, opportunities, and patterns, directly affecting decisions on position sizing, timing, and confidence.</p>
<p>Today, we'll use FMP APIs to interpret earnings data: extracting announcements, surprises, and price reactions across almost 1,000 stocks to identify actionable patterns in post‑earnings movements.</p>
<p>Here’s exactly what we’ll build:</p>
<ul>
<li><p><strong>Sector heatmap</strong>: Maps strongest 3/10-day post-earnings reactions by sector/market-cap buckets.</p>
</li>
<li><p><strong>EPS scatter</strong>: Tests if earnings beats drive returns (sector-colored, with regression).</p>
</li>
<li><p><strong>Return violins</strong>: Shows 3-day post-earnings volatility/skew by sector and market-cap.</p>
</li>
<li><p><strong>Mega-tech time series</strong>: Tracks AAPL/MSFT/NVDA post-earnings patterns over time.</p>
</li>
<li><p><strong>Monthly seasonality</strong>: Reveals calendar edges in post-earnings returns/surprises.</p>
</li>
<li><p><strong>Regime cross-section</strong>: Tests sector robustness across bull/bear/sideways markets.</p>
</li>
</ul>
<h3 id="heading-what-well-cover">What we'll cover:</h3>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-data-extraction">Data Extraction</a></p>
</li>
<li><p><a href="#heading-storytelling-with-charts-and-visuals">Storytelling with Charts and Visuals</a></p>
<ul>
<li><p><a href="#heading-sector-heatmap">Sector Heatmap</a></p>
</li>
<li><p><a href="#heading-megacap-tech-time-series">Mega‑Cap Tech Time Series</a></p>
</li>
<li><p><a href="#heading-eps-surprise-scatter-plot">EPS Surprise Scatter Plot</a></p>
</li>
<li><p><a href="#heading-return-distribution-violins">Return Distribution Violins</a></p>
</li>
<li><p><a href="#heading-monthly-seasonality">Monthly Seasonality</a></p>
</li>
<li><p><a href="#heading-regime-crosssection">Regime Cross-Section</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-did-we-get-out-of-all-this-storyline">What Did We Get Out of All This Storyline?</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be comfortable with Python and basic data manipulation in pandas.</p>
<p>This is a code-first guide. I’ll focus on the workflow and the story the charts reveal, and I won’t explain every line of Python. You should be comfortable reading pandas code, loops, and basic plotting logic so you can follow along without needing a step-by-step breakdown of each block.</p>
<p>You’ll need:</p>
<ul>
<li><p>Python 3.10+</p>
</li>
<li><p>A Financial Modeling Prep (FMP) API key</p>
</li>
<li><p>pandas, numpy, matplotlib, seaborn, scipy installed</p>
</li>
<li><p>Enough local compute and patience to run API loops across a large stock universe</p>
</li>
</ul>
<h2 id="heading-data-extraction">Data Extraction</h2>
<p>In the first part of this article, we need to collect all the data required for our visualisation exercise. Using FMP’s Stock Screener API, we will retrieve NASDAQ stocks. The first API call will return 1,000 stocks.</p>
<pre><code class="language-python">import requests
import pandas as pd
import numpy as np
import json
from datetime import datetime, timedelta
import seaborn as sns
import matplotlib.pyplot as plt
from scipy import stats

token = 'YOUR FMP TOKEN'

url = f'https://financialmodelingprep.com/stable/company-screener'
querystring = {"apikey":token,"country":"US", "exchange": "NASDAQ", "isActiveTrading": True, "isEtf": False, "isFund": False}
resp = requests.get(url, querystring).json()

df_universe = pd.DataFrame(resp)
df_universe = df_universe[df_universe['exchangeShortName'] == 'NASDAQ']
df_universe
</code></pre>
<p>This will give us 1,000 stocks! Next, we'll bin the market capitalisation to gain a better understanding of the results later on, and we will keep only four columns that are necessary: the symbol, name, market cap, and sector.</p>
<pre><code class="language-python">bins = [0,
        250_000_000,    # 250M
        2_000_000_000,  # 2B
        10_000_000_000, # 10B
        200_000_000_000,# 200B
        float("inf")]

labels = ["Micro", "Small", "Mid", "Large", "Mega"]

df_universe["marketCap"] = pd.cut(df_universe["marketCap"], bins=bins, labels=labels, right=False)
df_universe = df_universe[['symbol', 'companyName', 'marketCap', 'sector']]
df_universe
</code></pre>
<img src="https://cdn-images-1.medium.com/max/1000/0*rAiF7Q5TqSNlRG4h.png" alt="0*rAiF7Q5TqSNlRG4h" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Now it is time to retrieve the earnings using FMP’s Earnings Report API. We'll loop through each symbol and collect all the earnings the endpoint provides to us.</p>
<pre><code class="language-python">symbols = df_universe['symbol'].to_list()

all_dfs = []

for symbol in symbols:
    url = f"https://financialmodelingprep.com/stable/earnings?symbol={symbol}"
    params = {"apikey": token}
    resp = requests.get(url, params=params)

    if resp.status_code != 200:
        print(f"Error for {symbol}: {resp.status_code} - {resp.text}")
        continue

    data = resp.json()
    if not data:
        print(f"No data for {symbol}")
        continue

    df_symbol = pd.DataFrame(data)
    df_symbol["symbol"] = symbol
    all_dfs.append(df_symbol)

# Single DataFrame with all earnings
df_earnings = pd.concat(all_dfs, ignore_index=True)
df_earnings = df_earnings.dropna(subset=['epsActual', 'epsEstimated', 'revenueActual','revenueEstimated'])
df_earnings
</code></pre>
<p>Now we'll calculate the surprise, both for earnings and revenue in percentage terms, so we can later compare apples with apples! We'll keep everything from 2010 onwards.</p>
<pre><code class="language-python">df_earnings["eps_surprise"] = ((df_earnings["epsActual"] - df_earnings["epsEstimated"]) /
                               abs(df_earnings["epsEstimated"]) * 100).round(2)

df_earnings["revenue_surprise"] = ((df_earnings["revenueActual"] - df_earnings["revenueEstimated"]) /
                                   abs(df_earnings["revenueEstimated"]) * 100).round(2)

df_earnings = df_earnings[['symbol', 'date', 'eps_surprise', 'revenue_surprise']]

df_earnings["date"] = pd.to_datetime(df_earnings["date"])
df_earnings = df_earnings[df_earnings["date"] &gt; "2009-12-31"]
</code></pre>
<p>Lastly, as a final step in gathering the data needed for visualization, using FMP’s Historical Index Full Chart API, we'll loop through the stocks in our dataframe, retrieve the historical daily prices, and calculate the return of the stock 3 and 10 trading days before and after the earnings announcement.</p>
<pre><code class="language-python">unique_symbols = df_earnings["symbol"].unique()

price_results = []

print(f"Processing {len(unique_symbols)} symbols...")

for symbol in unique_symbols:
    # Fetch full historical prices
    url = f"https://financialmodelingprep.com/stable/historical-price-eod/full"
    params = {"apikey":token, "symbol":symbol, "from":'2009-10-01'}
    resp = requests.get(url, params=params)

    if resp.status_code != 200:
        print(f"Error for {symbol}: {resp.status_code}")
        continue

    data = resp.json()

    hist_df = pd.DataFrame(data)
    hist_df["date"] = pd.to_datetime(hist_df["date"])
    hist_df = hist_df.sort_values("date").reset_index(drop=True)

    # Get matching earnings rows
    earnings_symbol = df_earnings[df_earnings["symbol"] == symbol].copy()

    for _, row in earnings_symbol.iterrows():
        earn_date = pd.to_datetime(row["date"]).date()

        # === 3-DAY WINDOWS ===
        pre3_mask = (hist_df["date"].dt.date &lt; earn_date) &amp; \
                    (hist_df["date"].dt.date &gt;= earn_date - timedelta(days=10))
        pre3 = hist_df[pre3_mask].tail(3)

        post3_mask = (hist_df["date"].dt.date &gt; earn_date) &amp; \
                     (hist_df["date"].dt.date &lt;= earn_date + timedelta(days=10))
        post3 = hist_df[post3_mask].head(3)

        pre3_start = pre3["close"].iloc[0] if len(pre3) &gt;= 3 else None
        pre3_end = pre3["close"].iloc[-1] if len(pre3) &gt;= 1 else None
        post3_end = post3["close"].iloc[-1] if len(post3) &gt;= 3 else None

        pct_pre_3d = ((pre3_end - pre3_start) / pre3_start * 100) if pre3_start and pre3_end else None
        pct_post_3d = ((post3_end - pre3_end) / pre3_end * 100) if pre3_end and post3_end else None

        # === 10-DAY WINDOWS ===
        pre10_mask = (hist_df["date"].dt.date &lt; earn_date) &amp; \
                     (hist_df["date"].dt.date &gt;= earn_date - timedelta(days=20))
        pre10 = hist_df[pre10_mask].tail(10)

        post10_mask = (hist_df["date"].dt.date &gt; earn_date) &amp; \
                      (hist_df["date"].dt.date &lt;= earn_date + timedelta(days=20))
        post10 = hist_df[post10_mask].head(10)

        pre10_start = pre10["close"].iloc[0] if len(pre10) &gt;= 10 else None
        pre10_end = pre10["close"].iloc[-1] if len(pre10) &gt;= 1 else None
        post10_end = post10["close"].iloc[-1] if len(post10) &gt;= 10 else None

        pct_pre_10d = ((pre10_end - pre10_start) / pre10_start * 100) if pre10_start and pre10_end else None
        pct_post_10d = ((post10_end - pre10_end) / pre10_end * 100) if pre10_end and post10_end else None

        price_results.append({
            "symbol": symbol,
            "earn_date": earn_date,
            "month": earn_date.month,
            "pct_pre_3d": round(pct_pre_3d, 2) if pct_pre_3d else None,
            "pct_post_3d": round(pct_post_3d, 2) if pct_post_3d else None,
            "pct_pre_10d": round(pct_pre_10d, 2) if pct_pre_10d else None,
            "pct_post_10d": round(pct_post_10d, 2) if pct_post_10d else None,
            "eps_surprise": row["eps_surprise"],
            "revenue_surprise": row["revenue_surprise"]
        })



df_earnings = pd.DataFrame(price_results)
df_earnings.dropna(inplace=True)
df_earnings = df_universe.merge(df_earnings, on="symbol")
df_earnings
</code></pre>
<p>As you can see, at the end of the code, we have also merged the initial dataset, so all the information, such as name, marketCap, and sector, is now in a single dataset.</p>
<h2 id="heading-storytelling-with-charts-and-visuals">Storytelling with Charts and Visuals</h2>
<h3 id="heading-sector-heatmap">Sector Heatmap</h3>
<p>First, we'll present the Sector Heatmap of average 3-day post-earnings returns segmented by sector and market-cap category. This basic visualisation highlights areas with the most significant reactions, enabling traders to swiftly identify high-alpha sectors and market caps for earnings strategies.</p>
<pre><code class="language-python"># Aggregate: average post-earnings returns and EPS surprise
agg = (
    df_earnings
    .dropna(subset=['pct_post_3d', 'pct_post_10d', 'eps_surprise', 'marketCap', 'sector'])
    .groupby(['sector', 'marketCap'])
    .agg(
        avg_post3d=('pct_post_3d', 'mean'),
        avg_post10d=('pct_post_10d', 'mean'),
        avg_eps_surprise=('eps_surprise', 'mean')
    )
    .reset_index()
)

# Heatmap: average 3-day post-earnings return
heatmap_3d = agg.pivot(index='sector', columns='marketCap', values='avg_post3d')

plt.figure(figsize=(12, 8))
sns.heatmap(
    heatmap_3d,
    annot=True,
    fmt='.2f',
    cmap='RdYlGn',
    center=0,
    linewidths=0.5,
    linecolor='grey'
)
plt.title('Average 3-Day Post-Earnings Return by Sector and Market-Cap Bucket')
plt.xlabel('Market-cap bucket')
plt.ylabel('Sector')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
</code></pre>
<img src="https://cdn-images-1.medium.com/max/1000/0*u0AOCzVCWJ4NQMIS.png" alt="Heatmap of average 3-day post-earnings returns by sector and market-cap bucket for NASDAQ stocks" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Consumer Cyclical and Materials are performing really well, with small and mid caps seeing positive reactions over 1.1%. Real Estate is also doing great, jumping up to +4.0% in mid caps. Energy and Financials are holding steady, staying close to zero. Technology, on the other hand, is showing more muted gains, under 1.1%, indicating there might be limited immediate upside from the big tech earnings.</p>
<p>Building on the 3‑day heatmap, we'll now look at the Sector Heatmap for average <em>10‑day</em> post‑earnings returns by sector and market‑cap category. This extends the timeframe to capture momentum persistence, revealing which sectors maintain or reverse short‑term reactions.</p>
<pre><code class="language-python"># Heatmap: average 10-day post-earnings return
heatmap_10d = agg.pivot(index='sector', columns='marketCap', values='avg_post10d')

plt.figure(figsize=(12, 8))
sns.heatmap(
    heatmap_10d,
    annot=True,
    fmt='.2f',
    cmap='RdYlGn',
    center=0,
    linewidths=0.5,
    linecolor='grey'
)
plt.title('Average 10-Day Post-Earnings Return by Sector and Market-Cap Bucket')
plt.xlabel('Market-cap bucket')
plt.ylabel('Sector')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
</code></pre>
<img src="https://cdn-images-1.medium.com/max/1000/0*DB7p_HYR-6jWYaaP.png" alt="Heatmap of average 10-day post-earnings returns by sector and market-cap bucket for NASDAQ stocks" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Consumer Cyclical stands out with peaks at 3.2% (mega caps), and Industrials and Health Care show consistent gains in mid and large caps around 1.1%. Real Estate has eased after its 3-day surge. Technology has seen a small boost in mega caps (+1.8%) but remains less active overall compared to cyclicals.</p>
<h3 id="heading-megacap-tech-time-series"><strong>Mega‑Cap Tech Time&nbsp;Series</strong></h3>
<p>Extending the heatmaps, we’ll now look at a Mega-Cap Tech time series. It tracks 10-day post-earnings returns over time for AAPL, MSFT, NVDA, and a few other mega-cap tech names.</p>
<p>A bubble chart works well here because it encodes more than one thing at once. The x-axis is the earnings date, the y-axis is the 10-day post-earnings return, the bubble size scales with the absolute EPS surprise magnitude, and the color shows whether the surprise was a beat or a miss. This makes it easy to spot outlier quarters and see whether big surprises consistently lead to bigger post-earnings moves.</p>
<pre><code class="language-python"># Define mega-cap tech tickers (top ones from data: AAPL, MSFT, NVDA, AMZN, GOOG/GOOGL, META)
tech_tickers = ['AAPL', 'MSFT', 'NVDA', 'AMZN', 'GOOG', 'GOOGL', 'META']

# Filter data for mega-cap tech
df_tech = (
    df_earnings[df_earnings['symbol'].isin(tech_tickers)]
    .dropna(subset=['earn_date', 'pct_post_10d', 'eps_surprise'])
    .sort_values('earn_date')
    .assign(
        earn_date=lambda x: pd.to_datetime(x['earn_date'])
    )
)

# Create time-series plot: pct_post_10d vs earn_date, sized/color by eps_surprise
plt.figure(figsize=(14, 8))

# Scatter plot
scatter = plt.scatter(
    df_tech['earn_date'],
    df_tech['pct_post_10d'],
    s=np.abs(df_tech['eps_surprise']) * 50 + 20,  # Size by abs(eps_surprise)
    c=df_tech['eps_surprise'],
    cmap='RdYlBu_r',
    alpha=0.7,
    edgecolors='black',
    linewidth=0.5
)

plt.colorbar(scatter, label='EPS Surprise (%)')
plt.xlabel('Earnings Date')
plt.ylabel('10-Day Post-Earnings Return (%)')
plt.title('Mega-Cap Tech: 10-Day Post-Earnings Returns vs Time\n(Point size/color by EPS Surprise)')
plt.grid(True, alpha=0.3)

# Add trend line
z = np.polyfit(pd.to_numeric(df_tech['earn_date']), df_tech['pct_post_10d'], 1)
p = np.poly1d(z)
plt.plot(df_tech['earn_date'], p(pd.to_numeric(df_tech['earn_date'])), "r--", alpha=0.8, linewidth=2, label=f'Trend: {z[0]:.3f}x + {z[1]:.1f}')

plt.legend()
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
</code></pre>
<img src="https://cdn-images-1.medium.com/max/1500/1*vFJ_bKUzT1WGiJaiF53tEg.png" alt="Bubble chart of 10-day post-earnings returns over time for AAPL, MSFT, NVDA, AMZN, GOOG, GOOGL, META. Bubble size reflects EPS surprise magnitude. Color reflects beat or miss" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>That large red bubble around 2018 is almost certainly <strong>AAPL’s Q4 2018 earnings miss</strong> (Jan 2019 announcement, but fiscal Q4 2018 data) and it stands out because:</p>
<ul>
<li><p><strong>Large size</strong> = massive EPS surprise magnitude (Apple cut guidance dramatically, ~10% miss)</p>
</li>
<li><p><strong>Red colour</strong> = negative surprise</p>
</li>
<li><p><strong>Low Y position</strong> = poor 10‑day return (~-10% range visible)</p>
</li>
</ul>
<p>This was Apple’s infamous “iPhone demand warning” that triggered the January 2019 market panic. Perfect example of how one outlier event can anchor the whole trend line downward in your visualisation.</p>
<h3 id="heading-eps-surprise-scatter-plot">EPS Surprise Scatter&nbsp;Plot</h3>
<p>After identifying major tech trends, let's now look at the <strong>EPS Surprise Scatter</strong> plots. This plot checks a simple hypothesis. Do earnings beats lead to positive returns, and do misses lead to negative returns? We plot EPS surprise on the x-axis and post-earnings returns on the y-axis, then add a regression line to show the average relationship.</p>
<pre><code class="language-python"># Prepare data: drop NaNs and convert earn_date if needed (not used here)
df_plot = (
    df_earnings
    .dropna(subset=['eps_surprise', 'pct_post_3d', 'pct_post_10d', 'sector'])
    .copy()
)

# 1. Scatter: EPS Surprise vs 3-Day Post-Return, colored by sector
plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
sns.scatterplot(
    data=df_plot,
    x='eps_surprise',
    y='pct_post_3d',
    hue='sector',
    alpha=0.6,
    s=40
)

# Regression line (overall)
slope, intercept, r_value, p_value, std_err = stats.linregress(df_plot['eps_surprise'], df_plot['pct_post_3d'])
line = slope * df_plot['eps_surprise'] + intercept
plt.plot(df_plot['eps_surprise'], line, 'red', linestyle='--', linewidth=2,
         label=f'y = {slope:.3f}x + {intercept:.2f}\nR²={r_value**2:.3f}')
plt.xlabel('EPS Surprise (%)')
plt.ylabel('3-Day Post-Earnings Return (%)')
plt.title('EPS Surprise vs 3-Day Post-Return by Sector')
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
plt.grid(True, alpha=0.3)

# 2. Scatter: EPS Surprise vs 10-Day Post-Return, colored by sector
plt.subplot(1, 2, 2)
sns.scatterplot(
    data=df_plot,
    x='eps_surprise',
    y='pct_post_10d',
    hue='sector',
    alpha=0.6,
    s=40
)

# Regression line (overall)
slope10, intercept10, r_value10, p_value10, std_err10 = stats.linregress(df_plot['eps_surprise'], df_plot['pct_post_10d'])
line10 = slope10 * df_plot['eps_surprise'] + intercept10
plt.plot(df_plot['eps_surprise'], line10, 'red', linestyle='--', linewidth=2,
         label=f'y = {slope10:.3f}x + {intercept10:.2f}\nR²={r_value10**2:.3f}')
plt.xlabel('EPS Surprise (%)')
plt.ylabel('10-Day Post-Earnings Return (%)')
plt.title('EPS Surprise vs 10-Day Post-Return by Sector')
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Optional: Summary table of correlations by sector
corr_3d = df_plot.groupby('sector')[['eps_surprise', 'pct_post_3d']].corr().unstack().xs('pct_post_3d', level=1, axis=1)['eps_surprise']
corr_10d = df_plot.groupby('sector')[['eps_surprise', 'pct_post_10d']].corr().unstack().xs('pct_post_10d', level=1, axis=1)['eps_surprise']

corr_df = pd.DataFrame({
    'Corr_EPS_3Day': corr_3d.round(3),
    'Corr_EPS_10Day': corr_10d.round(3)
}).sort_values('Corr_EPS_10Day', ascending=False)
</code></pre>
<img src="https://cdn-images-1.medium.com/max/1500/1*rEAHbGRiyJs-NT9VPRudDQ.png" alt="Scatter plot of EPS surprise versus post-earnings returns with sector colors and overall regression line" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The red dashed trend line illustrates the <em>typical</em> relationship: for every 1% EPS beat, stocks tend to gain about 0.05–0.1% over 3 to 10 days. The gentle slope suggests that while surprises can give a little boost, <strong>they don’t guarantee large moves</strong>.</p>
<p>You’ll notice that Consumer Cyclical dots mainly cluster in the upper right (beats leading to gains), and Real Estate shows a steeper increase. The wide spread around the line indicates that other factors often influence stock movements beyond surprises.</p>
<h3 id="heading-return-distribution-violins">Return Distribution Violins</h3>
<p>Heatmaps show averages, but averages can hide risk. Violin plots show the full distribution of returns, including how wide the outcomes are and whether the tails are heavy. Here we plot 3-day post-earnings return distributions by sector and by market-cap bucket.</p>
<pre><code class="language-python"># Prepare data
df_plot = (
    df_earnings
    .dropna(subset=['pct_post_3d', 'sector', 'marketCap'])
    .copy()
)

# 1. Violin plot: 3-day post-returns by sector
plt.figure(figsize=(15, 6))

plt.subplot(1, 2, 1)
sns.violinplot(
    data=df_plot,
    x='sector',
    y='pct_post_3d',
    inner='quartile',
    palette='Set2'
)
plt.title('Distribution of 3-Day Post-Earnings Returns by Sector (Violin)')
plt.xlabel('Sector')
plt.ylabel('3-Day Post-Earnings Return (%)')
plt.xticks(rotation=45, ha='right')
plt.grid(True, alpha=0.3)

# 2. Violin plot: 3-day post-returns by market-cap group
plt.subplot(1, 2, 2)
sns.violinplot(
    data=df_plot,
    x='marketCap',
    y='pct_post_3d',
    inner='quartile',
    palette='Set3'
)
plt.title('Distribution of 3-Day Post-Earnings Returns by Market-Cap (Violin)')
plt.xlabel('Market-cap bucket')
plt.ylabel('3-Day Post-Earnings Return (%)')
plt.xticks(rotation=45, ha='right')
plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()


plt.show()

# Summary statistics table
summary = df_plot.groupby(['sector', 'marketCap'])['pct_post_3d'].agg(['mean', 'median', 'std', 'count']).round(2)
print("Summary Statistics: Mean/Median/Std/Count of 3-Day Returns by Sector &amp; Market-Cap")
print(summary)
</code></pre>
<img src="https://cdn-images-1.medium.com/max/1500/1*JLOvSp-2jwD5_ZNqeqdBEw.png" alt="Violin plots showing distribution of 3-day post-earnings returns by sector and by market-cap bucket" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>All violins concentrate near zero with modest variations (±5%), indicating that post-earnings reactions are <em>generally noisy and lack a clear direction.</em> Markets efficiently incorporate expectations, resulting in little predictable advantage. Consumer Cyclical and Materials sectors display slightly more frequent upside surprises, while small caps exhibit the greatest variability, reflecting higher risk and occasional gains. Not every visualization reveals alpha; this one honestly illustrates the difficulty involved.</p>
<h3 id="heading-monthly-seasonality">Monthly Seasonality</h3>
<p>After observing narrow return distributions near zero, let's now look at Monthly Seasonality in four panels: average 3/10‑day post‑returns, EPS surprises, and event counts by month. This reveals calendar effects,  systematic seasonal biases ,  that can influence timing of entries despite noisy individual responses.</p>
<pre><code class="language-python"># 1. Ensure earn_date is datetime
df_month = (
    df_earnings
    .dropna(subset=['earn_date', 'pct_post_3d', 'pct_post_10d', 'eps_surprise'])
    .copy()
)

df_month['earn_date'] = pd.to_datetime(df_month['earn_date'])

# 2. Derive month number and name
df_month['month_num'] = df_month['earn_date'].dt.month
df_month['month_name'] = df_month['earn_date'].dt.strftime('%b')

# 3. Aggregate averages by month
monthly_agg = (
    df_month
    .groupby('month_num')
    .agg(
        pct_post_3d_mean=('pct_post_3d', 'mean'),
        pct_post_10d_mean=('pct_post_10d', 'mean'),
        eps_surprise_mean=('eps_surprise', 'mean'),
        n_obs=('earn_date', 'count')
    )
    .reset_index()
    .sort_values('month_num')
)

# Keep a stable month order and names
month_order = monthly_agg['month_num'].tolist()
month_labels = df_month.drop_duplicates('month_num').set_index('month_num')['month_name'].reindex(month_order)

monthly_agg['month_name'] = month_labels.values

# 4. Plot bar charts
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle('Monthly Seasonality of Post-Earnings Returns and EPS Surprise', fontsize=16)

# Avg 3-day return
axes[0, 0].bar(monthly_agg['month_name'], monthly_agg['pct_post_3d_mean'], color='skyblue')
axes[0, 0].set_title('Avg 3-Day Post-Earnings Return by Month')
axes[0, 0].set_ylabel('Return (%)')
axes[0, 0].grid(alpha=0.3)

# Avg 10-day return
axes[0, 1].bar(monthly_agg['month_name'], monthly_agg['pct_post_10d_mean'], color='lightgreen')
axes[0, 1].set_title('Avg 10-Day Post-Earnings Return by Month')
axes[0, 1].set_ylabel('Return (%)')
axes[0, 1].grid(alpha=0.3)

# Avg EPS surprise
axes[1, 0].bar(monthly_agg['month_name'], monthly_agg['eps_surprise_mean'], color='salmon')
axes[1, 0].set_title('Avg EPS Surprise by Month')
axes[1, 0].set_ylabel('EPS Surprise')
axes[1, 0].grid(alpha=0.3)

# Number of observations
axes[1, 1].bar(monthly_agg['month_name'], monthly_agg['n_obs'], color='gold')
axes[1, 1].set_title('Number of Earnings Events by Month')
axes[1, 1].set_ylabel('Count')
axes[1, 1].grid(alpha=0.3)

for ax in axes.ravel():
    ax.set_xlabel('Month')
    ax.tick_params(axis='x', rotation=0)

plt.tight_layout()
plt.show()
</code></pre>
<img src="https://cdn-images-1.medium.com/max/1500/1*HjdZDaUhudYQZPNvOqy-_Q.png" alt="Four-panel bar charts showing monthly averages of 3-day returns, 10-day returns, EPS surprise, and event counts" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Jan/Oct tend to have the best 3‑day returns, about 0.8%, while May/Jul usually see weaker results. The 10‑day trends show a similar but gentler pattern, with February and August reaching peaks. EPS surprises are slightly negative in January and May, possibly due to tough comparisons, and there are fewer events in July, August, and December because of holidays. While there’s a hint of seasonality, its impact is quite small, around 0.5%.</p>
<h3 id="heading-regime-cross-section">Regime Cross-Section</h3>
<p>Finally, after subtle monthly patterns, we'll look at the Regime Cross‑Section: sector 10‑day post‑earnings returns by market regime (heatmap at the top, bars below). This stress‑tests earlier findings  ( do patterns persist across bull, bear, and COVID eras), revealing rotation opportunities and regime dependence.</p>
<pre><code class="language-python"># Prepare data with year extraction
df_regimes = (
    df_earnings
    .dropna(subset=['earn_date', 'pct_post_10d', 'sector'])
    .copy()
)

df_regimes['earn_date'] = pd.to_datetime(df_regimes['earn_date'])
df_regimes['year'] = df_regimes['earn_date'].dt.year

# Define market regimes (adjust years based on your data/market history)
# Example: Bull (2023-2025), Bear/Transition (2022), COVID (2020-2021), etc.
def assign_regime(year):
    if year &gt;= 2023:
        return 'Bull (2023+)'
    elif year == 2022:
        return 'Bear (2022)'
    elif 2020 &lt;= year &lt;= 2021:
        return 'COVID Recovery'
    elif 2018 &lt;= year &lt;= 2019:
        return 'Pre-COVID'
    else:
        return 'Earlier'

df_regimes['market_regime'] = df_regimes['year'].apply(assign_regime)

# 1. Aggregate: average 10-day returns by sector and regime/year
agg_data = (
    df_regimes
    .groupby(['sector', 'market_regime'])['pct_post_10d']
    .agg(['mean', 'count'])
    .reset_index()
    .query('count &gt;= 5')  # Filter low-sample regimes
)

# 2. Visualization: Heatmap first (quick overview)
plt.figure(figsize=(12, 8))

plt.subplot(2, 1, 1)
pivot_heatmap = agg_data.pivot(index='sector', columns='market_regime', values='mean')
sns.heatmap(pivot_heatmap, annot=True, fmt='.2f', cmap='RdYlGn', center=0, linewidths=0.5)
plt.title('Average 10-Day Post-Earnings Returns: Sector x Market Regime Heatmap')

# 3. Bar charts: By regime (stacked by sector)
plt.subplot(2, 1, 2)
regime_order = agg_data.groupby('market_regime')['mean'].mean().sort_values(ascending=False).index
sns.barplot(data=agg_data, x='market_regime', y='mean', hue='sector',
            palette='Set2', order=regime_order)
plt.title('Average 10-Day Returns by Market Regime (Colored by Sector)')
plt.ylabel('10-Day Post-Return (%)')
plt.xlabel('Market Regime')
plt.xticks(rotation=45, ha='right')
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
plt.grid(axis='y', alpha=0.3)

plt.tight_layout()
plt.show()

# 5. Summary tables
print("Average Returns by Sector x Market Regime (min 5 obs):")
print(agg_data.pivot(index='sector', columns='market_regime', values='mean').round(2))

# 6. Ranking: Best/worst performing sectors by regime
print("\nTop/Bottom Sectors by Regime:")
for regime in regime_order:
    regime_data = agg_data[agg_data['market_regime'] == regime].sort_values('mean', ascending=False)
    print(f"\n{regime}:")
    print(regime_data[['sector', 'mean', 'count']].round(2).head(3))
</code></pre>
<img src="https://cdn-images-1.medium.com/max/1000/0*Pn2sH97R2DCpRl7u.png" alt="Heatmap and bar chart showing average 10-day post-earnings returns by sector across market regimes" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Consumer Cyclical does well during Bull (2023+) and COVID Recovery (<del>1.5–2%), but it’s less favorable in Bear 2022. Utilities turned negative before COVID. The bottom bars show the COVID era led overall gains (</del>1%), with Basic Materials and Industrials being the strongest. The recent Bull remains positive but less so. Sector leadership shifts depending on the market regime , there are no consistent winners.</p>
<h2 id="heading-what-did-we-get-out-of-all-this-storyline">What Did We Get Out of All This Storyline?</h2>
<p>Guiding you through six interconnected visualizations, we’ve turned 15 years of earnings data into a clear and engaging story.</p>
<p>Each chart responds to a specific question, yet together, they paint a bigger picture: earnings surprises influence markets, but not in the same way everywhere. Some sectors, periods, and regimes often provide consistent advantages, while others don’t.</p>
<p>Here’s what the data shows us:</p>
<ul>
<li><p><strong>No definitive alpha here, but specific opportunities are present</strong>: Markets are mostly efficient,  returns hover near zero with weak surprise correlations ,  yet Consumer Cyclicals and Materials consistently show upside potential across different timeframes and market sizes. Timing your sector choice is important.</p>
</li>
<li><p><strong>Timing windows alter the story</strong>: 3-day reactions benefit Real Estate mid-caps (+4%), while 10-day reactions shift leadership to Consumer Cyclical mega-caps (+3.2%). Don’t assume all earnings reactions occur at the same pace.</p>
</li>
<li><p><strong>Mega-tech hype isn’t eternal</strong>: The bubble chart shows AAPL/MSFT/NVDA delivered strong returns from 2020–2022, but the falling trend since then indicates waning market enthusiasm. Don’t chase yesterday’s overhyped stocks.</p>
</li>
<li><p><strong>Calendar patterns reward patience</strong>: January and October deliver slightly stronger post-earnings returns (~0.8%), while July and August tend to have lower liquidity. Combine seasonal timing with sector choices for additional gains.</p>
</li>
<li><p><strong>Market regimes change winners</strong>: Cyclicals underperformed during COVID recovery and the bull run (2023+), while Industrials peaked during the recovery. There are no universal “best performers,” only the best performers <em>for now</em>. Adjust to the regime.</p>
</li>
<li><p><strong>The actionable setup</strong>: Small to mid-cap cyclical longs in January during bull markets combine all these signals for maximum conviction ,  where sector timing, seasonality, and regime alignment converge.</p>
</li>
</ul>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>This exercise shows why visualization is important in finance: raw tables of returns and surprises wouldn’t reveal these patterns.</p>
<ul>
<li><p>Heatmaps instantly highlighted sector winners.</p>
</li>
<li><p>Scatter plots demonstrated the weak surprise‑return connection. Bubble charts narrated the mega‑tech story over time.</p>
</li>
<li><p>Violins unveiled the harsh truth  that markets are noisy. Cross‑sectional regime analysis reminded us that yesterday’s approach doesn’t ensure tomorrow’s returns.</p>
</li>
</ul>
<p>The effort to interpret this data pays off: you shift from passive observation to active pattern recognition. You see not just what occurred, but where and when it happened. In trading and analysis, understanding the shape of complexity often surpasses having a perfect formula.</p>
<p>Visual storytelling turns data into intuition . And intuition, based on evidence, outperforms guesswork every time.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an LLM Market Copilot MVP with LangChain, APIs, and Streamlit ]]>
                </title>
                <description>
                    <![CDATA[ In fintech or wealthtech products, people constantly need quick market context. They need to know why a particular stock moved, what changed recently, or what they should watch next. This usually beco ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-an-llm-market-copilot-with-langchain/</link>
                <guid isPermaLink="false">699f5b70c9015c37f6bbd3e8</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Tue, 24 Feb 2026 20:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/24b80a39-6649-4987-961c-ca4efb964b28.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In fintech or wealthtech products, people constantly need quick market context. They need to know why a particular stock moved, what changed recently, or what they should watch next.</p>
<p>This usually becomes manual work. Someone pulls recent returns, checks a couple fundamentals, scans headlines, then writes a short Slack or Notion note. It works, but it doesn't scale, and everyone formats it differently.</p>
<p>Dashboards help when the question is predefined. Pure LLM answers are flexible, but they're not something you can trust unless the numbers are tool-backed.</p>
<p>In this handbook, we’ll build a market copilot MVP. Think of it as a lightweight “market note generator” for a single stock.</p>
<p>A stock question could be anything like: “What happened to AAPL over the last 60 days?”, “Is this move unusually risky?”, or “What changed in the news this week?” A market brief is the short, structured write-up you’d paste into Slack. It includes a snapshot, a few key metrics, and a compact interpretation backed by real data.</p>
<p>We'll keep the product logic separate from the UI. The engine lives in <code>copilot.py</code>. It fetches facts through EODHD-backed tools, which are just Python functions that call EODHD endpoints and return small, predictable outputs. The Streamlit app in <code>app.py</code> is only a shell that calls the engine, then renders the brief and the tool-backed metrics side by side.</p>
<p><strong>One quick clarification:</strong> When I say “copilot” in this handbook, I’m not referring to GitHub Copilot. This will serve as an in-product assistant that helps generate repeatable market context by calling data tools and writing a brief from those tool outputs.</p>
<h2 id="heading-prerequisites-and-tools"><strong>Prerequisites and Tools</strong></h2>
<p>You’ll get the most out of this handbook if you’re comfortable with Python basics and have built at least one small script that calls an API.</p>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>Python 3.10+ installed</p>
</li>
<li><p>An EODHD API key</p>
</li>
<li><p>An OpenAI API key</p>
</li>
<li><p>A working local environment (venv or conda is fine)</p>
</li>
</ul>
<p>Tools used in this build:</p>
<ul>
<li><p>EODHD. You'll use it as the data layer for end-of-day prices, fundamentals, and news.</p>
</li>
<li><p>OpenAI. You'll use a chat model to write the brief, but only after tools return the underlying facts.</p>
</li>
<li><p>LangChain + LangGraph. You'll use these tools and a ReAct-style agent so the model can decide which data functions to call, then compose a short brief.</p>
</li>
<li><p>Streamlit. You'll use it only as the quickest way to demo the copilot as a clickable product surface.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites-and-tools">Prerequisites and Tools</a></p>
</li>
<li><p><a href="#heading-what-the-mvp-does">What the MVP Does</a></p>
<ul>
<li><a href="#heading-non-negotiables">Non-negotiables</a></li>
</ul>
</li>
<li><p><a href="#heading-architecture">Architecture</a></p>
<ul>
<li><p><a href="http://copilot.py">copilot.py</a> <a href="#heading-copilotpy-the-engine">– the engine</a></p>
</li>
<li><p><a href="http://app.py">app.py</a> <a href="#heading-apppy-the-mvp-shell">– the MVP shell</a></p>
</li>
<li><p><a href="#heading-why-this-split-matters">Why this split matters</a></p>
</li>
</ul>
</li>
<li><p><a href="http://copilot.py">copilot.py</a><a href="#heading-copilotpy-build-the-engine">: Build the Engine</a></p>
<ul>
<li><p><a href="#heading-1-import-packages">1. Import packages</a></p>
</li>
<li><p><a href="#heading-2-helper-functions">2. Helper Functions</a></p>
</li>
<li><p><a href="#heading-3-data-tools">3. Data tools</a></p>
</li>
<li><p><a href="#heading-4-testing-the-data-tools-outside-copilotpy">4. Testing the Data Tools (outside</a> <a href="http://copilot.py">copilot.py</a><a href="#heading-4-testing-the-data-tools-outside-copilotpy">)</a></p>
</li>
<li><p><a href="#heading-5-creating-the-agent">5. Creating the agent</a></p>
</li>
<li><p><a href="#heading-6-turning-the-agent-into-a-callable-backend">6. Turning the agent into a callable backend</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-demo-runs-outside-copilotpy">Demo Runs (Outside</a> <a href="http://copilot.py">copilot.py</a><a href="#heading-demo-runs-outside-copilotpy">)</a></p>
<ul>
<li><p><a href="#heading-demo-1-baseline-brief-return-fundamentals-headlines">Demo 1: Baseline brief (return + fundamentals + headlines)</a></p>
</li>
<li><p><a href="#heading-demo-2-risk-first-brief-volatility-drawdown-on-the-same-window">Demo 2: Risk-first brief (volatility + drawdown on the same window)</a></p>
</li>
<li><p><a href="#heading-demo-3-news-only-what-changed-no-metrics-unless-required">Demo 3: News-only “what changed” (no metrics unless required)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-build-the-streamlit-mvp">Build the Streamlit MVP</a></p>
<ul>
<li><p><a href="#heading-ui-design-query-first-with-optional-parameters">UI Design: Query First, with Optional Parameters</a></p>
</li>
<li><p><a href="#heading-two-pane-layout-brief-on-the-left-numbers-on-the-right">Two-Pane Layout: Brief on the Left, Numbers on the Right</a></p>
</li>
<li><p><a href="#heading-i-app-skeleton">i. App skeleton</a></p>
</li>
<li><p><a href="#heading-ii-inputs-panel">ii. Inputs panel</a></p>
</li>
<li><p><a href="#heading-iii-metrics-rendering">iii. Metrics rendering</a></p>
</li>
<li><p><a href="#heading-iv-wiring-the-ui-to-the-engine">iv. Wiring the UI to the engine</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-app-demo">App Demo</a></p>
<ul>
<li><p><a href="#heading-demo-1-baseline-brief-return-valuation-headlines">Demo 1. Baseline brief (return + valuation + headlines)</a></p>
</li>
<li><p><a href="#heading-demo-2-risk-first-workflow-volatility-drawdown-no-news">Demo 2. Risk-first workflow (volatility + drawdown, no news)</a></p>
</li>
<li><p><a href="#heading-demo-3-news-only-context-panel-themes-no-extra-metrics">Demo 3. News-only context panel (themes, no extra metrics)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-practical-notes">Practical Notes</a></p>
<ul>
<li><p><a href="#heading-things-that-will-break-in-real-usage">Things that will break in real usage</a></p>
</li>
<li><p><a href="#heading-small-extensions-that-fit-this-mvp">Small extensions that fit this MVP</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-the-mvp-does">What the MVP&nbsp;Does</h2>
<p>At a high level, this MVP has one job: to turn a stock question into a short, repeatable market brief.</p>
<p>You give it:</p>
<ul>
<li><p>A ticker (like AAPL.US)</p>
</li>
<li><p>A recent window in trading days (like 60 or 120)</p>
</li>
<li><p>A free-form query (what you actually want to know)</p>
</li>
<li><p>Optional parameters that force certain parts to be included, like fundamentals, risk, or headlines</p>
</li>
</ul>
<p>In practice, the query drives the brief. The optional parameters are there for consistency when a team wants a standard format.</p>
<p>Then it returns two things:</p>
<ol>
<li><p>A short brief in Markdown with a consistent structure you can read quickly.</p>
</li>
<li><p>A set of tool-backed artifacts, basically the raw metrics the UI can render without re-calling the APIs.</p>
</li>
</ol>
<p>That second output is important. It keeps the app fast and makes the “numbers” auditable.</p>
<h3 id="heading-non-negotiables">Non-negotiables</h3>
<p>This MVP is designed like a product feature, not a chat demo.</p>
<ul>
<li><p>Metrics are tool-first. The model doesn’t guess.</p>
</li>
<li><p>If data is missing, it says so.</p>
</li>
<li><p>No raw price dumps, no giant news lists.</p>
</li>
<li><p>It computes only what the query asked for.</p>
</li>
<li><p>The output reads like an internal note you’d paste into Slack or a weekly memo.</p>
</li>
</ul>
<p>Once you have this pattern working, a few useful things happen.</p>
<p>First, you get consistent briefs that PMs, research, and sales can all reuse. You can also generate weekly market notes faster. And demos become simple. Type a query, get a brief, show the metrics next to it.</p>
<h2 id="heading-architecture">Architecture</h2>
<p>We’ll keep this simple: two files with two clear responsibilities.</p>
<h3 id="heading-copilotpy-the-engine">copilot.py – the&nbsp;engine</h3>
<p>This file holds everything that actually makes the copilot work:</p>
<ul>
<li><p>The <strong>EODHD</strong>&nbsp;data tools (prices, fundamentals, news, risk)</p>
</li>
<li><p>The agent setup and prompt rules</p>
</li>
<li><p>A single run_brief()&nbsp;function that takes inputs and returns:</p>
</li>
<li><p>the markdown brief</p>
</li>
<li><p>the structured artifacts for the UI</p>
</li>
</ul>
<p>If you want to reuse this copilot anywhere else later, this is the file you keep.</p>
<h3 id="heading-apppy-the-mvp-shell">app.py&nbsp;– the MVP&nbsp;shell</h3>
<p>This is just the Streamlit layer:</p>
<ul>
<li><p>Sidebar inputs (ticker, window, query, optional parameters)</p>
</li>
<li><p>A two-pane layout: left side shows the brief, right side shows tool-backed metrics and headlines</p>
</li>
</ul>
<p>No data logic lives here. It only calls run_brief()&nbsp;and renders what comes back.</p>
<h3 id="heading-why-this-split-matters">Why this split&nbsp;matters</h3>
<p>If everything is mixed into one Streamlit script, you’re stuck with Streamlit forever.</p>
<p>With this split, you can replace Streamlit with FastAPI later without rewriting the core logic. You also keep “product logic” in one place, which makes testing and iteration much easier. And you avoid the notebook trap where UI code and data code become impossible to maintain.</p>
<h2 id="heading-copilotpy-build-the-engine">copilot.py: Build the&nbsp;Engine</h2>
<p>This section is where we build the backend engine. By the end of it, you’ll have a single callable function that takes a query, pulls the required facts using EODHD tools, and returns two things: a Markdown brief for humans, and a structured artifacts dictionary for the UI.</p>
<h3 id="heading-1-import-packages">1. Import&nbsp;packages</h3>
<p>We’re keeping the stack minimal. The goal is not to show off tooling – it’s to ship something that works and is easy to maintain.</p>
<pre><code class="language-python">
import json
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
import requests

from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent

eodhd_api_key = 'YOUR EODHD API KEY'
openai_api_key = 'YOUR OPENAI API KEY'
</code></pre>
<p>Apart from importing the packages, we also define <code>eodhd_api_key</code>&nbsp;and <code>openai_api_key</code>&nbsp;at the top so the file can run as-is. In a real deployment, you’d move these to environment variables.</p>
<h3 id="heading-2-helper-functions">2. Helper Functions</h3>
<p>Before we touch tools or the agent, we add three small helpers. None of them are “AI-related”, but they’re the difference between a demo that works once and a feature that keeps working.</p>
<pre><code class="language-python">
def normalize_ticker(t: str) -&gt; str:
    t = (t or "").strip().upper()
    if not t:
        return t
    if "." in t:
        return t
    return f"{t}.US"

def _safe_json_loads(x: Any) -&gt; Optional[Any]:
    if x is None:
        return None
    if isinstance(x, (dict, list)):
        return x
    if not isinstance(x, str):
        return None
    try:
        return json.loads(x)
    except Exception:
        return None
    
def get_eod_prices_raw(ticker: str, start: str, end: str) -&gt; pd.DataFrame:
    url = f"https://eodhd.com/api/eod/{ticker}"
    params = {"from": start, "to": end, "api_token": eodhd_api_key, "fmt": "json"}
    r = requests.get(url, params=params)
    data = r.json()

    if not isinstance(data, list) or not data:
        return pd.DataFrame(columns=["date", "open", "high", "low", "close", "volume", "ticker"])

    df = pd.DataFrame(data)
    keep = [c for c in ["date", "open", "high", "low", "close", "volume"] if c in df.columns]
    df = df[keep].copy()
    df["ticker"] = ticker
    df["date"] = pd.to_datetime(df["date"], errors="coerce")
    df = df.dropna(subset=["date", "close"]).sort_values("date").reset_index(drop=True)
    return df
</code></pre>
<p>Here’s a brief explanation of the three helper functions in the code:</p>
<ul>
<li><p><code>normalize_ticker()</code>&nbsp;fixes user input. People will type aapl, AAPL, <a href="http://AAPL.US">AAPL.US</a> with spaces sometimes. EODHD expects a consistent symbol format. This function forces that consistency before any API call.</p>
</li>
<li><p><code>_safe_json_loads()</code>&nbsp;is there because when we read tool outputs from the agent messages, the payload might already be a Python dict/list, or it might be a JSON string. This helper lets us handle both without throwing errors.</p>
</li>
<li><p><code>get_eod_prices_raw()</code>&nbsp;is the base price fetcher. Every tool that needs OHLCV uses this instead of re-writing request + cleaning logic each time. It returns a cleaned DataFrame extracted using <a href="https://eodhd.com/financial-apis/api-for-historical-data-and-volumes">EODHD’s end-of-day historical data API</a>, sorted by date, with missing values handled, so the rest of the tools can assume they’re working with sane data.</p>
</li>
</ul>
<p>That’s it. Nothing fancy. It just keeps the rest of the code predictable.</p>
<h3 id="heading-3-data-tools">3. Data&nbsp;tools</h3>
<p>Before the agent, we need a reliable data layer.</p>
<p>If you’re building this as a product, the tools are your “internal API”. They decide what the copilot can and cannot say. The agent is just calling them and turning their outputs into a brief.</p>
<p>In this MVP, each tool has a narrow job and returns compact outputs. That’s intentional. You want predictable shapes for the UI. You also want to avoid dumping raw data into the model unless you genuinely need it.</p>
<pre><code class="language-python">
@tool
def last_n_days_prices(ticker: str, n: int = 60) -&gt; Dict[str, Any]:
    """
    Quick return window over last N trading days.
    Returns a compact summary. No raw rows.
    """
    ticker = normalize_ticker(ticker)

    end = datetime.utcnow().date().isoformat()
    start = (datetime.utcnow().date() - timedelta(days=240)).isoformat()

    df = get_eod_prices_raw(ticker, start, end)
    if df.empty:
        return {"ticker": ticker, "error": "no_price_data"}

    df = df.tail(int(n)).reset_index(drop=True)
    if df.empty:
        return {"ticker": ticker, "error": "no_price_data"}

    first_close = float(df.loc[0, "close"])
    last_close = float(df.loc[len(df) - 1, "close"])
    total_return = float((last_close / first_close) - 1.0)

    return {
        "ticker": ticker,
        "n": int(n),
        "start_date": str(df.loc[0, "date"].date()),
        "end_date": str(df.loc[len(df) - 1, "date"].date()),
        "first_close": first_close,
        "last_close": last_close,
        "total_return": total_return,
    }

@tool
def fundamentals_snapshot(ticker: str) -&gt; Dict[str, Any]:
    """
    Lightweight fundamentals snapshot.
    Returns a flat dict.
    """
    ticker = normalize_ticker(ticker)

    url = f"https://eodhd.com/api/fundamentals/{ticker}"
    params = {"api_token": eodhd_api_key, "fmt": "json"}
    r = requests.get(url, params=params)
    data = r.json()

    if not isinstance(data, dict) or not data:
        return {"ticker": ticker, "error": "no_data"}

    highlights = data.get("Highlights", {}) or {}
    general = data.get("General", {}) or {}
    valuation = data.get("Valuation", {}) or {}
    technicals = data.get("Technicals", {}) or {}

    return {
        "ticker": ticker,
        "name": general.get("Name"),
        "sector": general.get("Sector"),
        "industry": general.get("Industry"),
        "market_cap": highlights.get("MarketCapitalization"),
        "pe": valuation.get("TrailingPE"),
        "pb": valuation.get("PriceBookMRQ"),
        "profit_margin": highlights.get("ProfitMargin"),
        "dividend_yield": highlights.get("DividendYield"),
        "beta": technicals.get("Beta"),
    }

@tool
def latest_news(ticker: str, limit: int = 5) -&gt; List[Dict[str, Any]]:
    """
    Latest headlines for a ticker.
    Returns a compact list of dicts.
    """
    ticker = normalize_ticker(ticker)

    url = f"https://eodhd.com/api/news"
    params = {"s": ticker, "limit": int(limit), "offset": 0, "api_token": eodhd_api_key, "fmt": "json"}
    r = requests.get(url, params=params)
    data = r.json()

    if not isinstance(data, list) or not data:
        return []

    df = pd.DataFrame(data)
    keep = [c for c in ["date", "title", "link", "source"] if c in df.columns]
    df = df[keep].copy()

    if "date" in df.columns:
        df["date"] = pd.to_datetime(df["date"], errors="coerce")
        df = df.sort_values("date", ascending=False)

    out = df.head(int(limit)).reset_index(drop=True).to_dict(orient="records")
    for row in out:
        dt = row.get("date")
        if isinstance(dt, (pd.Timestamp, datetime)):
            row["date"] = dt.isoformat()
    return out

@tool
def risk_metrics(ticker: str, start: str, end: str) -&gt; Dict[str, Any]:
    """
    Risk metrics from daily close prices over a window.
    volatility_ann: annualized vol from daily returns
    max_drawdown: max drawdown over the window
    """
    ticker = normalize_ticker(ticker)

    df = get_eod_prices_raw(ticker, start, end)
    if df.empty:
        return {"ticker": ticker, "error": "no_price_data"}

    df = df.sort_values("date").reset_index(drop=True)
    df["ret"] = df["close"].pct_change().fillna(0.0)

    vol_ann = float(df["ret"].std(ddof=0) * np.sqrt(252))

    cummax = df["close"].cummax()
    dd = (df["close"] / cummax) - 1.0
    max_dd = float(dd.min())

    first_close = float(df.loc[0, "close"])
    last_close = float(df.loc[len(df) - 1, "close"])
    total_return = float((last_close / first_close) - 1.0)

    return {
        "ticker": ticker,
        "start_date": str(df.loc[0, "date"].date()),
        "end_date": str(df.loc[len(df) - 1, "date"].date()),
        "n": int(len(df)),
        "total_return": total_return,
        "volatility_ann": vol_ann,
        "max_drawdown": max_dd,
    }

@tool
def eod_prices(ticker: str, start: str, end: str) -&gt; List[Dict[str, Any]]:
    """
    Raw OHLCV rows. Use only for custom calculations that cannot be done with other tools.
    """
    ticker = normalize_ticker(ticker)
    df = get_eod_prices_raw(ticker, start, end)
    return json.loads(df.to_json(orient="records"))
</code></pre>
<p>Let’s go through the key parts of this code.</p>
<p><strong>1.</strong> <code>last_n_days_prices</code>  <strong>–  Price window</strong></p>
<p>Most real requests start with something like: “what happened recently?”</p>
<p>So this tool does one thing: it pulls enough daily bars to safely cover the last N trading days (using a buffer window), then returns a small summary:</p>
<ul>
<li><p>start and end dates for the window</p>
</li>
<li><p>first and last close</p>
</li>
<li><p>total return</p>
</li>
<li><p>number of trading days used</p>
</li>
</ul>
<p>It doesn’t return raw rows. That keeps the agent from flooding output, and it keeps the UI fast.</p>
<p><strong>2.</strong> <code>fundamentals_snapshot</code>  –  <strong>Fundamentals snapshot</strong></p>
<p>This tool is for quick context. You usually want a rough valuation anchor in the brief, but you don’t want to turn the MVP into a full fundamentals pipeline.</p>
<p>So we’ll keep it simple. It fetches the EODHD fundamentals data API&nbsp;once and extracts a handful of fields that are commonly useful in a brief:</p>
<ul>
<li><p>PE, PB</p>
</li>
<li><p>market cap</p>
</li>
<li><p>sector and beta</p>
</li>
<li><p>a couple of optional extras like dividend yield and profit margin</p>
</li>
</ul>
<p>If a field is missing, it just returns None&nbsp;for that field. No guessing.</p>
<p><strong>3.</strong> <code>latest_news</code>  <strong>–  Headlines</strong></p>
<p>Price moves without context aren’t helpful.</p>
<p>This tool pulls the latest headlines for a ticker via EODHD Financial News API, sorts them by date when available, and returns a compact list with only what we actually need in the app:</p>
<ul>
<li><p>date</p>
</li>
<li><p>title</p>
</li>
<li><p>link</p>
</li>
<li><p>source</p>
</li>
</ul>
<p>We’re not doing sentiment here. The point is simply to ground the brief in real narrative context.</p>
<p><strong>4.</strong> <code>risk_metrics</code>  <strong>–  Risk metrics</strong></p>
<p>Sometimes the question isn’t “what happened?”. It’s “how extreme was this move?”</p>
<p>That’s where volatility and drawdown are useful. This tool takes a start and end date, pulls daily closes, then calculates:</p>
<ul>
<li><p>annualized volatility from daily returns</p>
</li>
<li><p>max drawdown over the window</p>
</li>
<li><p>and it also returns total return again for the same window, so everything stays consistent</p>
</li>
</ul>
<p>In the product, this tool should only run when the user asks for risk. It’s extra compute and extra API calls.</p>
<p><strong>5.</strong> <code>eod_prices</code>  <strong>–  Escape Hatch</strong></p>
<p>This is the tool you keep around for later extensions.</p>
<p>Most of the time, the MVP doesn’t need raw OHLCV rows. But as soon as you want custom metrics (rolling indicators, ATR, custom signals, pattern detection), you’ll need raw bars.</p>
<p>So eod_prices&nbsp;returns the full daily rows as a list of dicts.</p>
<p>The rule is simple: don’t call it unless you have to. It’s heavier, and it’s the easiest way to accidentally blow up token usage or slow down the app.</p>
<h3 id="heading-4-testing-the-data-tools-outside-copilotpy">4. Testing the Data Tools (outside copilot.py)</h3>
<p>Before the agent writes anything, you’ll want to know that the data layer is behaving. This isn’t “testing for fun”. It’s a quick sanity check that answers three questions:</p>
<ol>
<li><p>Can we fetch data for a normal ticker without errors?</p>
</li>
<li><p>Are the fields we depend on actually present?</p>
</li>
<li><p>Do the outputs look roughly reasonable, so the brief won’t be garbage?</p>
</li>
</ol>
<p>Here’s the exact test block I ran. One call per tool. I printed the key parts and kept the code block small.</p>
<pre><code class="language-python">
print("\n--- last_n_days_prices ---")
out_price = last_n_days_prices.invoke({"ticker": "AAPL.US", "n": 60})
print(out_price)

print("\n--- fundamentals_snapshot ---")
out_fund = fundamentals_snapshot.invoke({"ticker": "AAPL.US"})
print(out_fund)

print("\n--- latest_news ---")
out_news = latest_news.invoke({"ticker": "AAPL.US", "limit": 5})
print(f"news rows: {len(out_news)}")
print(out_news[:2])

print("\n--- risk_metrics ---")
end = datetime.utcnow().date()
start = (end - timedelta(days=180)).isoformat()
end = end.isoformat()
out_risk = risk_metrics.invoke({"ticker": "AAPL.US", "start": start, "end": end})
print(out_risk)

print("\n--- eod_prices (raw rows, small window) ---")
raw_rows = eod_prices.invoke({"ticker": "AAPL.US", "start": "2025-12-01", "end": "2026-01-15"})
print(f"rows: {len(raw_rows)}")
print(raw_rows[:2])
</code></pre>
<img src="https://static.wixstatic.com/media/867939_14570ee67a03444eb7e76a5fa0590413~mv2.png/v1/fill/w_1175,h_493,al_c,q_90,usm_0.66_1.00_0.01,enc_avif,quality_auto/867939_14570ee67a03444eb7e76a5fa0590413~mv2.png" alt="Output" width="600" height="400" loading="lazy">

<p>This output is basically confirming that the data layer works.</p>
<p><code>last_n_days_prices</code>&nbsp;gave you a clean 60 trading day window (2025-10-28 to 2026-01-23) with first close 269.0, last close 248.04, and total return around -7.79%. <code>fundamentals_snapshot</code>&nbsp;also returned the key fields you want for a brief. PE 33.2048, PB 49.4443, market cap ~3.665T, beta 1.093, plus sector and industry.</p>
<p><code>latest_news</code>&nbsp;returned 5 items in a consistent shape (date, title, link). <code>risk_metrics</code>&nbsp;worked too, but it used a different window (last 180 calendar days became 123 trading days), so its total return (+18.65%) won’t match the 60 day tool, which is why we later force risk metrics to use the same start and end dates as the return window.</p>
<p><code>eod_prices</code>&nbsp;returned 32 raw rows as expected. The date field shows up as an epoch-style number here, which is fine since this tool is meant for internal calculations, not direct display.</p>
<h3 id="heading-5-creating-the-agent">5. Creating the&nbsp;agent</h3>
<p>This is where the whole thing becomes a copilot instead of a bunch of loose functions. We define how the agent should behave, give it the only tools it’s allowed to use, then set up a clean way to capture tool outputs for the UI.</p>
<pre><code class="language-python">
system_prompt = (
    "You are a market brief copilot embedded in a product.\n"
    "Rules:\n"
    "1) Use tools for facts. Never invent numbers.\n"
    "2) Do not dump raw price rows or long news lists.\n"
    "3) If the user didn't ask for something, don't compute it.\n"
    "4) Output in clean Markdown with sections.\n"
    "5) Keep it short and useful, like an internal dashboard note.\n"
    "Tool guidance:\n"
    "- Use last_n_days_prices for return windows.\n"
    "- Use fundamentals_snapshot for PE/PB/market cap/sector/beta.\n"
    "- Use latest_news for headlines.\n"
    "- Use risk_metrics only if asked for vol/drawdown.\n"
    "- Use eod_prices only if absolutely required for custom calcs.\n"
)

def _build_agent() -&gt; Any:
    llm = ChatOpenAI(
        model='gpt-5-nano',
        temperature=0,
        api_key=openai_api_key,
    )
    tools = [last_n_days_prices, fundamentals_snapshot, latest_news, risk_metrics, eod_prices]
    return create_react_agent(model=llm, tools=tools)

AGENT = _build_agent()

def _extract_artifacts(messages: List[Any]) -&gt; Dict[str, Any]:
    """
    Pull tool outputs from the LangGraph message list.
    This avoids calling the endpoints twice in Streamlit.
    """
    out: Dict[str, Any] = {}
    for m in messages:
        name = getattr(m, "name", None)
        content = getattr(m, "content", None)

        if not name:
            continue

        payload = _safe_json_loads(content)
        if payload is None:
            continue

        if name.endswith("last_n_days_prices"):
            out["price"] = payload
        elif name.endswith("fundamentals_snapshot"):
            out["valuation"] = payload
        elif name.endswith("risk_metrics"):
            out["risk"] = payload
        elif name.endswith("latest_news"):
            out["headlines"] = payload

    return out
</code></pre>
<p>The system prompt is basically a contract. If you don’t spell this out, the agent will eventually drift. It will start guessing numbers, dumping long outputs, or doing work you didn’t ask for. This prompt keeps it in the “internal brief writer” lane, and the tool guidance reduces tool misuse.</p>
<p><code>_build_agent()</code>&nbsp;is just wiring. One model, a fixed toolset, and a ReAct agent that can decide when to call what. The other important piece here is <code>_extract_artifacts()</code>. We’re not building this just to print a nice paragraph. We also want structured outputs that the UI can render. So instead of calling the endpoints again inside Streamlit, we reuse the tool results that already happened during the agent run.</p>
<h3 id="heading-6-turning-the-agent-into-a-callable-backend">6. Turning the agent into a callable&nbsp;backend</h3>
<p>Up to now, we’ve built tools and an agent. This is the piece that turns it into something your app can call like a regular backend function. One input in, one brief out, plus the structured data you need to render the UI.</p>
<pre><code class="language-python">
def run_brief(
    ticker: str,
    n_days: int = 60,
    include_fundamentals: bool = True,
    include_risk: bool = False,
    include_news: bool = True,
    news_limit: int = 5,
) -&gt; Tuple[str, Dict[str, Any]]:
    """
    Returns:
      - markdown brief (string)
      - artifacts dict with keys like price/valuation/risk/headlines when tools were used
    """
    t = normalize_ticker(ticker)

    request_parts = [
        f"Ticker: {t}.",
        f"Compute total return over the last {int(n_days)} trading days.",
    ]
    if include_fundamentals:
        request_parts.append("Fetch fundamentals and report PE, PB, market cap, sector, beta.")
    if include_risk:
        request_parts.append("Compute annualized volatility and max drawdown over the same window.")
        request_parts.append("Use the same start_date and end_date as the return window.")
    if include_news:
        request_parts.append(f"Pull {int(news_limit)} latest headlines and reference them briefly.")
    request_parts.append(
        "Write a short market brief with sections: Snapshot, Metrics, What it might mean, Caveats."
    )
    request_parts.append("Keep it concise. Do not paste raw rows.")

    user_prompt = " ".join(request_parts)

    response = AGENT.invoke(
        {"messages": [("system", system_prompt), ("user", user_prompt)]}
    )

    messages = response.get("messages", [])
    final_msg = messages[-1]
    brief_md = getattr(final_msg, "content", "") or ""

    artifacts = _extract_artifacts(messages)
    return brief_md, artifacts
</code></pre>
<p>The <code>run_brief</code>&nbsp;function is doing two jobs. First, it translates “what the user wants” into a very specific instruction set that keeps the agent on rails. That’s why it builds <code>request_parts</code>&nbsp;instead of passing the user a blank prompt and hoping for the best.</p>
<p>Second, it returns two outputs. <code>brief_md</code>&nbsp;is what you show on the left side of the app. <code>artifacts</code>&nbsp;is what you render on the right side. Those artifacts come from <code>_extract_artifacts(messages)</code>, which is just a clean way to reuse the tool outputs that already happened during the run, instead of re-calling EODHD again just to populate the UI.</p>
<h2 id="heading-demo-runs-outside-copilotpy">Demo Runs (Outside copilot.py)</h2>
<p>Below are three runs that map to how a PM, founder, or analyst would actually use this in a product. Each demo has a short setup line, the exact code you run, the output, then a tight interpretation tied to what the output actually says.</p>
<h3 id="heading-demo-1-baseline-brief-return-fundamentals-headlines">Demo 1: Baseline brief (return + fundamentals + headlines)</h3>
<p>This is the default “give me the situation” request. In the output, you want to see one window, one return, key valuation fields, and a short headline-backed story.</p>
<pre><code class="language-python">
def run_agent(query: str):
    resp = AGENT.invoke({"messages": [("system", system_prompt), ("user", query)]})
    msgs = resp.get("messages", [])
    final = msgs[-1].content if msgs else ""
    print("\n" + "=" * 80)
    print("QUERY:")
    print(query)
    print("\nANSWER:")
    print(final)
    return resp

resp = run_agent(
    "Ticker: AAPL.US. Compute total return over the last 60 trading days. "
    "Fetch fundamentals and report PE, PB, market cap, sector, beta. "
    "Pull 5 latest headlines and reference them briefly. "
    "Write a short market brief with sections: Snapshot, Metrics, What it might mean, Caveats. "
    "Keep it concise. Do not paste raw rows."
)
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">
================================================================================
QUERY:
Ticker: AAPL.US. Compute total return over the last 60 trading days. Fetch fundamentals and report PE, PB, market cap, sector, beta. Pull 5 latest headlines and reference them briefly. Write a short market brief with sections: Snapshot, Metrics, What it might mean, Caveats. Keep it concise. Do not paste raw rows.

ANSWER:
### Snapshot
- Window: last 60 trading days (2025-10-28 to 2026-01-23)
- Price path: 269.00 → 248.04
- Total return: -7.79%

### Metrics
- Sector: Technology
- Market cap: $3.665T (3,665,126,490,112)
- P/E: 33.20
- P/B: 49.44
- Beta: 1.09

### What it might mean
- The 60-day horizon shows a ~7.8% decline alongside a tech-focused mega-cap backdrop. Elevated P/B suggests high balance-sheet or growth expectations reflected in asset valuation. Macro headlines point to ongoing risk factors (Fed policy, geopolitical/economic headlines) shaping near-term moves.

Headlines reference (brief)
- IWO vs. MGK: How Small-Cap Diversification Compares to Mega-Cap Growth - context on growth tilts and diversification
- Stock Futures Are Falling Ahead of Fed Meeting as Shutdown Fears Rise - macro risk backdrop
- This founder cracked firefighting - now he's creating an AI gold mine - AI/tech narrative
- Dow Jones Futures Fall; Trump Tariffs, Government Shutdown, Big Earnings In Focus - earnings/macroe attention
- SPDR's SPTM Offers Broad Market Reach, While Vanguard's VTV Targets Value Stocks. Which Is the Better Buy? - market breadth/value debate

### Caveats
- Data reflect the latest available snapshot; updates can shift returns, multiples, and fundamentals.
- PB is unusually high here; interpret as market perception of value/assets rather than a strict earnings-based metric.
- This is not investment advice; consider broader context and your risk tolerance.
</code></pre>
<p>The core signal is the -7.79% move across a clearly stated window (2025–10–28 to 2026–01–23). The “Metrics” section does what we want for an internal note: it gives sector and a compact valuation context (P/E, P/B, beta, market cap) without turning into a fundamentals report.</p>
<p>The headlines are intentionally brief and more macro-tilted, which is useful if you just need “what’s in the air” rather than a deep Apple-only news breakdown.</p>
<h3 id="heading-demo-2-risk-first-brief-volatility-drawdown-on-the-same-window">Demo 2: Risk-first brief (volatility + drawdown on the same&nbsp;window)</h3>
<p>This is the “tell me how ugly it got” workflow. The output should show a return, volatility, and max drawdown all computed on the same dates.</p>
<pre><code class="language-python">
resp = run_agent(
    "Ticker: MSFT.US. Compute total return over the last 90 trading days. "
    "Compute annualized volatility and max drawdown over the same window. "
    "Use the same start_date and end_date as the return window. "
    "Write a short market brief with sections: Snapshot, Metrics, What it might mean, Caveats."
)
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">
================================================================================
QUERY:
Ticker: MSFT.US. Compute total return over the last 90 trading days. Compute annualized volatility and max drawdown over the same window. Use the same start_date and end_date as the return window. Write a short market brief with sections: Snapshot, Metrics, What it might mean, Caveats.

ANSWER:
## Market Brief - MSFT.US

Snapshot
- Window: 2025-09-16 to 2026-01-23 (90 trading days)
- Start price: 509.04
- End price: 465.95
- Total return (price only): -8.46% over the window (dividends not included)

Metrics
- Annualized volatility: 19.30%
- Max drawdown: -18.07% (peak-to-trough during the window)

What it might mean
- The period shows meaningful downside with elevated volatility, ending the 90-day window in a weaker price regime.
- A roughly 18% drawdown alongside ~19% annualized volatility suggests higher-than-average price swings for this horizon.
- If you're evaluating risk, this window points to downside risk exposure that could matter for near-term positioning.

Caveats
- This total return reflects price movement only; dividends are not included.
- Results depend on the exact window; future performance can differ materially.
- Data as of 2026-01-23; markets and fundamentals can change quickly. If you want, I can add a quick fundamentals snapshot or news context.
</code></pre>
<p>The return (-8.46%) tells you direction. The drawdown (-18.07%) tells you the worst pain inside the window, which is usually what stakeholders actually care about when they say “risk”. Volatility (19.30% annualized) gives you a scale for how noisy the ride was. Also notice the window is explicitly pinned (2025–09–16 to 2026–01–23), which is what makes these metrics comparable and repeatable.</p>
<h3 id="heading-demo-3-news-only-what-changed-no-metrics-unless-required">Demo 3: News-only “what changed” (no metrics unless required)</h3>
<p>This is the “give me context fast” workflow. The output should stay narrative and not sneak in extra metrics since the query explicitly says not to.</p>
<pre><code class="language-python">
resp = run_agent(
    "Ticker: AAPL.US. Pull 7 latest headlines. "
    "Give a short 'what changed' summary in 6-8 lines, referencing themes, not every headline. "
    "Do not compute returns unless needed."
)
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">
================================================================================
QUERY:
Ticker: AAPL.US. Pull 7 latest headlines. Give a short 'what changed' summary in 6-8 lines, referencing themes, not every headline. Do not compute returns unless needed.

ANSWER:
## AAPL.US – 7 latest headlines: thematic quick read

What changed (themes)
- Macro risk is back in focus: futures drift lower ahead of the Fed meeting, with shutdown fears weighing sentiment.
- Policy risk remains a thread: tariffs and government shutdown headlines keep policy uncertainty front and center.
- Allocation debates persist: small-cap vs mega-cap discussions (IWO vs MGK) drive diversification talk.
- Growth vs value framing broadens: SPTM vs VTV and VOOG vs IWO highlight different exposure bets.
- Earnings season adds collateral volatility alongside macro noise.
- AI narrative gains traction: a founder profile signals growing interest in AI-enabled investment theme
</code></pre>
<p>This is doing the right kind of compression. It doesn’t list seven headlines and call it a day. It clusters them into themes (macro, policy, allocation, style drift, earnings, AI narrative). Also important, it respected the constraint. No return or risk metrics were pulled “just because”, which is exactly what you want if this is meant to be a quick context panel inside a product.</p>
<h2 id="heading-build-the-streamlit-mvp">Build the Streamlit MVP</h2>
<p>At this stage, the goal is not a perfect UI. It's a working product surface you can show to someone on your team.</p>
<p>A notebook is fine when you're the only user. The moment you want feedback from a PM, a founder, or anyone non-technical, you need something they can click through. Streamlit is the fastest way to wrap your copilot function into that kind of experience, without building a frontend stack.</p>
<h3 id="heading-ui-design-query-first-with-optional-parameters">UI Design: Query First, with Optional Parameters</h3>
<p>The biggest change in the UI is making the query the primary input. That’s how people actually think. They don’t start with “60 trading days + fundamentals + headlines”. They start with a question.</p>
<p>So the sidebar should lead with a <code>Query</code> box where someone can type something like:</p>
<p>“Give me a 60-day brief on AAPL. Include fundamentals and 5 headlines.”</p>
<p>Then we keep the other controls as optional parameters. These aren’t the “main input”. They’re enforcement knobs. If your team wants every brief to always include fundamentals, you can force that. If you’re doing a risk-focused workflow, you can keep risk always on. If headlines are too noisy for your use case, you can switch them off.</p>
<h3 id="heading-two-pane-layout-brief-on-the-left-numbers-on-the-right">Two-Pane Layout: Brief on the Left, Numbers on the&nbsp;Right</h3>
<p>Once you hit “Generate”, you want the output to feel like a product screen, not a chat window.</p>
<img src="https://cdn-images-1.medium.com/max/1500/1*ERqFXO59ZgbP5jMzRGgOfg.png" alt="1*ERqFXO59ZgbP5jMzRGgOfg" width="600" height="400" loading="lazy">

<p>Left side is the brief. It’s the thing you’d copy into Slack or drop into a weekly memo. It’s narrative and compressed.</p>
<p>Right side is the tool-backed artifacts. That’s where the trust comes from. You can scan the return window, the key fundamentals, the risk metrics, and the headline list without hunting through paragraphs. It also makes it obvious what the model actually pulled from tools versus what it wrote as interpretation.</p>
<h3 id="heading-i-app-skeleton">i. App&nbsp;Skeleton</h3>
<p>We’re not building logic here. We’re just defining the outer shell so the app feels like a small product surface instead of a notebook cell.</p>
<pre><code class="language-python">import streamlit as st
import pandas as pd
from copilot import run_query

st.set_page_config(page_title="Market Brief Copilot", layout="wide")

st.title("Market Brief Copilot")
st.caption("LangChain + EODHD. Minimal internal-style brief, with tool-backed metrics.")
</code></pre>
<p>The important line here is <code>from copilot import run_query</code>. This keeps the boundary clean. Streamlit stays a UI layer, and the copilot logic stays in <code>copilot.py</code>. That separation is what makes this reusable later if you decide to wrap the same backend inside FastAPI or a different internal UI.</p>
<p><code>st.set_page_config(..., layout="wide")</code> is mostly a UX decision. Since we’re going to render a brief on the left and tool-backed metrics on the right, you want the wide layout so the output doesn’t feel cramped.</p>
<h3 id="heading-ii-inputs-panel">ii. Inputs&nbsp;Panel</h3>
<p>This is the most important part of the UI, because it defines how the copilot is used.</p>
<p>The whole point of moving to a query-first design is that this matches how people actually ask for market context. They don’t think in terms of “checkboxes first”. They think in terms of “here’s my question”. The ticker and window still exist, but only as defaults. They’re there as guardrails when the query doesn’t specify them.</p>
<p>Then we add “Optional parameters” as a forcing layer. This is not for normal usage. This is for teams that want consistency. For example, you might want fundamentals always included in every brief, even if the query forgot to ask. Same for risk, or headlines.</p>
<pre><code class="language-python">with st.sidebar:
    st.header("Inputs")

    query = st.text_area("Query", value="For AAPL.US, compute total return over the last 60 trading days. Fetch PE and PB. Pull 5 latest headlines. Brief interpretation.")
    default_ticker = st.text_input("Default ticker (used only if query doesn't mention one)", value="AAPL.US")
    default_n_days = st.slider("Default trading days window (used only if query doesn't mention one)", min_value=20, max_value=180, value=60, step=5)

    st.divider()
    
    with st.sidebar.expander("Optional parameters (force include)"):
        include_fund = st.checkbox("Fundamentals (PE, PB, etc.)", value=False)
        include_risk = st.checkbox("Risk metrics (volatility, drawdown)", value=False)
        include_news = st.checkbox("Headlines", value=False)
        news_limit = st.slider("Headline count", min_value=3, max_value=10, value=5, step=1, disabled=not include_news)

    run_btn = st.button("Generate brief", type="primary")
</code></pre>
<p>The <code>query</code> text area is the primary input. In the demo, you can literally paste the same kind of prompts you used in the agent test runs. That’s intentional. It keeps the product surface aligned with the real workflows this tool is meant for.</p>
<p>The <code>default_ticker</code> and <code>default_n_days</code> are secondary. They only matter when the query is vague. In a product setting, this matters more than it sounds. People will type “give me a 60-day brief” and forget to mention the ticker because they assume the context is already set. Defaults prevent the whole run from failing.</p>
<p>The expander is where the “team enforcement” idea lives. By keeping it collapsed by default, you’re not cluttering the UI for normal users. But the controls are still there when you want to run a consistent template, like “always include fundamentals and headlines for every brief”.</p>
<h3 id="heading-iii-metrics-rendering">iii. Metrics Rendering</h3>
<p>The brief is useful, but in a product you also need the numbers to be scannable and reusable.</p>
<p>So we treat the output as two layers:</p>
<ol>
<li><p><strong>Narrative</strong> (the markdown brief).</p>
</li>
<li><p><strong>Structured artifacts</strong> (price window, fundamentals, risk, headlines).</p>
</li>
</ol>
<p>The key is. We don’t want Streamlit to call EODHD again just to show metrics. The agent already called the tools once. So we extract those tool outputs from the agent messages and pass them straight to the UI.</p>
<h4 id="heading-extracting-tool-outputs-inside-copilotpy">Extracting tool outputs inside <code>copilot.py</code></h4>
<p>This helper walks through the LangGraph message list and pulls out anything that came from our tools. It gives us a single <code>artifacts</code> dict with consistent keys that the UI can render.</p>
<pre><code class="language-python">def _extract_artifacts(messages: List[Any]) -&gt; Dict[str, Any]:
    out: Dict[str, Any] = {}
    for m in messages:
        name = getattr(m, "name", None)
        content = getattr(m, "content", None)

        if not name:
            continue

        payload = _safe_json_loads(content)
        if payload is None:
            continue

        if name.endswith("last_n_days_prices"):
            out["price"] = payload
        elif name.endswith("fundamentals_snapshot"):
            out["valuation"] = payload
        elif name.endswith("risk_metrics"):
            out["risk"] = payload
        elif name.endswith("latest_news"):
            out["headlines"] = payload

    return out
</code></pre>
<p>This is the bridge between “agent world” and “UI world”. <code>run_query()</code> just calls this at the end and returns both <code>brief_md</code> and <code>artifacts</code>.</p>
<h4 id="heading-rendering-artifacts-in-apppy">Rendering artifacts in <code>app.py</code></h4>
<p>On the Streamlit side, we keep rendering logic in one place. <code>_render_metrics()</code> takes the <code>artifacts</code> dict and turns it into a clean right-hand panel.</p>
<pre><code class="language-python">def _render_metrics(artifacts: dict):
    cols = st.columns(3)

    price = artifacts.get("price")
    valuation = artifacts.get("valuation")
    risk = artifacts.get("risk")
    headlines = artifacts.get("headlines")

    with cols[0]:
        st.subheader("Price window")
        if isinstance(price, dict) and "error" not in price:
            st.metric("Total return", f"{price.get('total_return', 0.0) * 100:.2f}%")
            st.caption(f"{price.get('start_date')} to {price.get('end_date')} . N={price.get('n')}")
            st.write(
                pd.DataFrame([price]).rename(
                    columns={
                        "first_close": "first_close",
                        "last_close": "last_close",
                        "total_return": "total_return (decimal)",
                    }
                ).T
            )
        elif isinstance(price, dict) and "error" in price:
            st.warning(price["error"])
        else:
            st.info("No price tool output (not requested or tool not used).")

    with cols[1]:
        st.subheader("Fundamentals")
        if isinstance(valuation, dict) and "error" not in valuation:
            df = pd.DataFrame([valuation])
            keep = ["ticker", "name", "sector", "market_cap", "pe", "pb", "beta", "dividend_yield", "profit_margin"]
            keep = [c for c in keep if c in df.columns]
            st.write(df[keep].T)
        elif isinstance(valuation, dict) and "error" in valuation:
            st.warning(valuation["error"])
        else:
            st.info("No fundamentals tool output (not requested or tool not used).")

    with cols[2]:
        st.subheader("Risk")
        if isinstance(risk, dict) and "error" not in risk:
            st.metric("Volatility (ann.)", f"{risk.get('volatility_ann', 0.0) * 100:.2f}%")
            st.metric("Max drawdown", f"{risk.get('max_drawdown', 0.0) * 100:.2f}%")
            st.caption(f"{risk.get('start_date')} to {risk.get('end_date')} . N={risk.get('n')}")
            st.write(pd.DataFrame([risk]).T)
        elif isinstance(risk, dict) and "error" in risk:
            st.warning(risk["error"])
        else:
            st.info("No risk tool output (not requested or tool not used).")

    st.subheader("Headlines")
    if isinstance(headlines, list) and len(headlines) &gt; 0:
        for h in headlines:
            title = h.get("title", "Untitled")
            link = h.get("link")
            src = h.get("source")
            dt = h.get("date")
            line = f"- {title}"
            if src:
                line += f" ({src})"
            if dt:
                line += f" . {dt}"
            if link:
                st.markdown(f"{line}  \n  {link}")
            else:
                st.markdown(line)
    else:
        st.info("No headlines tool output (not requested or tool not used).")
</code></pre>
<p>This is why the whole app feels “product-ish”. The model can write a brief, but the UI can still show hard numbers in a predictable layout. Also, we’re not re-fetching anything. We’re only rendering what the tools already returned during the agent run.</p>
<h3 id="heading-iv-wiring-the-ui-to-the-engine">iv. Wiring the UI to the&nbsp;Engine</h3>
<p>At this point, the Streamlit app shouldn’t “think”. It should just collect inputs, call one function, and render whatever comes back.</p>
<p>Originally, <code>copilot.py</code> exposed <code>run_brief(ticker, n_days,&nbsp;…)</code>. Once we moved to a query-first UI, that shape stopped making sense. So we updated the backend function to <code>run_query(query, default_ticker, default_n_days, force_...,&nbsp;…)</code>. The app stays simple, but the engine becomes flexible enough to handle real product-style prompts.</p>
<p>This is the updated <code>run_query</code> function on copilot.py:</p>
<pre><code class="language-python">def run_query(
    query: str,
    default_ticker: str = "AAPL.US",
    default_n_days: int = 60,
    force_fundamentals: bool = True,
    force_risk: bool = False,
    force_news: bool = True,
    news_limit: int = 5,
) -&gt; Tuple[str, Dict[str, Any]]:
    
    q = (query or "").strip()

    if not q:
        q = f"For {default_ticker}, compute total return over the last {int(default_n_days)} trading days."

    constraints = [
        "Constraints:",
        "1) Use tools for facts. Never invent numbers.",
        "2) Do not dump raw price rows or long news lists.",
        "3) Output in clean Markdown with sections: Snapshot, Metrics, What it might mean, Caveats.",
        "4) Keep it short and useful.",
        f"5) If the query does not specify a window, assume last {int(default_n_days)} trading days.",
        f"6) If the query does not specify a ticker, assume {normalize_ticker(default_ticker)}.",
    ]

    if force_fundamentals:
        constraints.append("7) You must include fundamentals (PE, PB, market cap, sector, beta). Use fundamentals_snapshot.")
    if force_risk:
        constraints.append("8) You must include risk metrics (annualized volatility and max drawdown). Use risk_metrics.")
        constraints.append("   Use the same start_date and end_date as the return window.")
    if force_news:
        constraints.append(f"9) You must include headlines. Pull exactly {int(news_limit)}. Use latest_news.")

    user_prompt = "User query:\n" + q + "\n\n" + "\n".join(constraints)

    response = AGENT.invoke(
        {"messages": [("system", system_prompt), ("user", user_prompt)]}
    )

    messages = response.get("messages", [])
    final_msg = messages[-1] if messages else None
    brief_md = getattr(final_msg, "content", "") or ""

    artifacts = _extract_artifacts(messages)
    return brief_md, artifacts
</code></pre>
<p>Here’s the core wiring inside <code>app.py</code>. It only runs when the user clicks the button.</p>
<pre><code class="language-python">if run_btn:
    with st.spinner("Running tools and generating brief..."):
        brief_md, artifacts = run_query(
            query=query,
            default_ticker=default_ticker,
            default_n_days=default_n_days,
            force_fundamentals=include_fund,
            force_risk=include_risk,
            force_news=include_news,
            news_limit=news_limit,
        )

    left, right = st.columns([1.2, 1])

    with left:
        st.subheader("Market brief")
        st.markdown(brief_md)

    with right:
        st.subheader("Tool-backed metrics")
        _render_metrics(artifacts)

else:
    st.info("Set inputs on the left and click **Generate brief**.")
</code></pre>
<p>The call returns two things, same idea as before. <code>brief_md</code> is the markdown brief you show on the left. <code>artifacts</code> are the tool outputs you render on the right without making extra API calls.</p>
<p>The important change is what the engine now expects. Instead of the UI building a “request_parts” prompt itself, the UI just passes the raw query and the enforcement flags. The enforcement logic lives inside <code>run_query()</code>, not inside Streamlit. That’s a cleaner separation. Your UI can evolve, but the product behavior stays consistent in one place.</p>
<h2 id="heading-app-demo">App Demo</h2>
<p>This section shows a demo of the Streamlit MVP. These are example queries you can paste into the app to validate that the UI, tool calls, and brief output behave the way you expect.</p>
<h3 id="heading-demo-1-baseline-brief-return-valuation-headlines">Demo 1. Baseline brief (return + valuation + headlines)</h3>
<p>This is the default “tell me what’s going on” query. It forces the copilot to combine price movement, a small fundamentals snapshot, and a few headlines to add context.</p>
<p><strong>Query:</strong></p>
<blockquote>
<p>For AAPL.US, compute total return over the last 60 trading days. Fetch PE and PB. Pull 5 latest headlines. Brief interpretation.</p>
</blockquote>
<p>%[<a href="https://gumlet.tv/watch/6986e2cb4db88a967f4169a0/%5C%5D">https://gumlet.tv/watch/6986e2cb4db88a967f4169a0/\]</a></p>
<h3 id="heading-demo-2-risk-first-workflow-volatility-drawdown-no-news">Demo 2. Risk-first workflow (volatility + drawdown, no&nbsp;news)</h3>
<p>This is the “how ugly did it get?” workflow. It’s useful when someone is checking risk exposure or explaining why a position feels painful even if the end-to-end return is not extreme.</p>
<p><strong>Query:</strong></p>
<blockquote>
<p>For MSFT.US, last 90 trading days. Compute annualized volatility and max drawdown. Keep it short. No headlines.</p>
</blockquote>
<p>%[<a href="https://gumlet.tv/watch/6986e4b54db88a967f4190e4/%5C%5D">https://gumlet.tv/watch/6986e4b54db88a967f4190e4/\]</a></p>
<h3 id="heading-demo-3-news-only-context-panel-themes-no-extra-metrics">Demo 3. News-only context panel (themes, no extra&nbsp;metrics)</h3>
<p>This is the fastest “what changed?” workflow. The point is narrative compression. It should not sneak in returns or risk metrics unless the query genuinely requires it.</p>
<p><strong>Query:</strong></p>
<blockquote>
<p>For NVDA.US, pull 7 latest headlines. Summarize what changed in 6–8 lines. Reference themes, not every headline. Don’t compute returns unless needed.</p>
</blockquote>
<p>%[<a href="https://gumlet.tv/watch/6986e794924a60df4b1298c9/%5C%5D">https://gumlet.tv/watch/6986e794924a60df4b1298c9/\]</a></p>
<h2 id="heading-practical-notes">Practical Notes</h2>
<h3 id="heading-things-that-will-break-in-real-usage">Things that will break in real&nbsp;usage</h3>
<p>People will type messy symbols. Some will type <code>aapl</code>, some will type <code>AAPL</code>, some will paste <code>AAPL.US</code>&nbsp;. If you don’t normalize that upfront, you’ll spend time debugging “random” API failures. That’s why <code>normalize_ticker()</code> exists.</p>
<p>You’ll also hit missing data. Some tickers won’t have news. Some fundamentals fields will be null. Sometimes the price API returns nothing for the window. The tools already return small error objects. The Streamlit UI should surface that as warnings instead of crashing or silently showing blanks.</p>
<p>The biggest silent killer is tool cost. <code>eod_prices</code> is useful, but it’s the easiest way to slow down the app and bloat what the model sees. Keep it as an escape hatch. Default to compact tools like the 60-day summary, fundamentals snapshot, and headline list.</p>
<p>Finally, output drift is real. If you let the agent freestyle, it will start doing extra work and the format will slowly degrade. The fix is boring but effective. Keep the prompt strict, keep the toolset small, and keep the output format consistent.</p>
<h3 id="heading-small-extensions-that-fit-this-mvp">Small extensions that fit this&nbsp;MVP</h3>
<p>A simple next step is multi-ticker compare. Same query pattern, but for two or three tickers, then return a short side-by-side summary.</p>
<p>You can also schedule briefs. Run a daily or weekly query for a watchlist and push the output to Slack or email. The core pattern stays the same.</p>
<p>Caching is another quick win. Cache tool results by <code>(ticker, window)</code> so repeated demos don’t keep hitting the APIs and the UI stays snappy.</p>
<p>If you want this to live inside a real product, wrap <code>run_query()</code> behind a FastAPI endpoint. Streamlit can stay as the demo shell, and your app can call the backend like any other internal service.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>At this point, you have a working Market Copilot MVP. It takes a natural-language query, pulls the relevant facts through tools, and returns a short brief plus the underlying metrics that the UI can display. The main win is not the model response – it’s the repeatable workflow and the clean split between the engine and the app.</p>
<p>If you’re building a fintech product, this pattern maps well to a common need. Teams often already have the raw ingredients (like EODHD’s prices, fundamentals, news), but they sit across endpoints and dashboards. A small copilot layer can turn that into a consistent “market note” output that a PM, analyst, or sales team can reuse. It’s also a practical internal demo artifact because the numbers are visible and traceable, not buried behind a chat response.</p>
<p>From here, the best next step is to run it with real internal questions for a week and see what people keep asking for. That will tell you whether to add caching, multi-ticker comparisons, scheduled briefs, or an API wrapper. The MVP is already enough to test that loop without overbuilding.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
