In the third week of August 2024, the S&P 500 dropped 4.2% in three trading sessions. Conventional wisdom held that bond prices would surge as capital rotated into safe havens. TLT — the 20+ Year Treasury Bond ETF — did rise initially, gaining 1.8% on the second day. But by day four, TLT had given back all gains and closed down 0.3% on the week. The anticipated hedge had failed.

For macro hedge fund managers, this "decoupling" event was not a surprise anomaly. It was a data point. The stock-bond correlation is not a constant. It shifts with monetary policy regimes, inflation expectations, and the positioning of leveraged investors. The question is not whether this relationship matters — it clearly does — but whether you can detect the shift in real time and adjust your hedge before the correlation collapses.

This article builds a production-grade monitoring system that calculates rolling correlations between SPY and TLT, detects divergence events, and outputs actionable hedge ratio adjustments. The system runs on a 15-minute cadence, survives WebSocket disconnections, and alerts when the relationship breaks down.


Why the Stock-Bond Relationship Breaks Down

The negative correlation between equities and long-duration Treasuries is a structural feature of modern markets, but it is conditional. Three regimes historically produce correlation breakdown:

Regime 1: Stagflation (1970s archetype)
When inflation is above 5% and growth is slowing, both asset classes suffer. The Fed's response is ambiguous — raising rates to fight inflation crushes bonds while slowing growth crushes equities. The correlation turns positive.

Regime 2: Rate hike panic (2022 archetype)
When the Fed embarks on aggressive tightening, both equities and long-duration bonds sell off simultaneously. The 60/40 portfolio's diversification benefit collapses. In 2022, the correlation between SPY and TLT turned positive for seven consecutive months.

Regime 3: Liquidity crisis (March 2020 archetype)
When margin calls cascade and systematic strategies unwind, investors sell everything liquid — including long Treasuries — to meet redemptions. The "flight to safety" narrative breaks down intraday.

Understanding these regimes is not enough. You need to detect when the current environment is transitioning into one of them. That is what the rolling correlation monitor does.


The Rolling Correlation Monitor: Architecture Overview

The system consists of four layers:

Layer Function Technology
Data ingestion Pull 15-minute OHLCV bars for SPY and TLT TickDB /kline endpoint
Correlation engine Calculate rolling Pearson correlation over a sliding window NumPy / Pandas
Divergence detector Compare correlation against historical baseline; flag regime shifts Custom threshold logic
Alert system Push notifications when hedge ratio needs recalibration Webhook / console output

The critical design decision is the window length. A 20-period (5-hour) window captures short-term sentiment shifts. A 252-period (multi-week) window reveals structural regime changes. The system monitors both simultaneously.


Data Acquisition: Fetching SPY and TLT 15-Minute Bars

Before calculating correlations, you need reliable data. The code below pulls 15-minute OHLCV bars for SPY and TLT from TickDB, handling authentication, rate limits, and reconnection gracefully.

import os
import time
import json
import random
import requests
import pandas as pd
from datetime import datetime, timedelta

# Load API key from environment variable
API_KEY = os.environ.get("TICKDB_API_KEY")
BASE_URL = "https://api.tickdb.ai/v1/market/kline"

# ⚠️ For production HFT workloads, consider aiohttp/asyncio for concurrent requests


def fetch_ohlcv(symbol: str, interval: str = "15m", limit: int = 500) -> pd.DataFrame:
    """
    Fetch OHLCV klines for a given symbol from TickDB.
    
    Args:
        symbol: Ticker symbol (e.g., "SPY.US", "TLT.US")
        interval: Candle interval ("1m", "5m", "15m", "1h", "4h", "1d")
        limit: Number of bars to fetch (max varies by interval)
    
    Returns:
        DataFrame with columns: timestamp, open, high, low, close, volume
    """
    headers = {"X-API-Key": API_KEY}
    params = {
        "symbol": symbol,
        "interval": interval,
        "limit": limit
    }
    
    max_retries = 3
    retry_count = 0
    
    while retry_count < max_retries:
        try:
            response = requests.get(
                BASE_URL,
                headers=headers,
                params=params,
                timeout=(3.05, 10)  # (connect_timeout, read_timeout)
            )
            
            # Handle rate limiting
            if response.status_code == 429 or (
                response.headers.get("X-API-Code") == "3001"
            ):
                retry_after = int(response.headers.get("Retry-After", 5))
                print(f"Rate limited. Waiting {retry_after}s before retry...")
                time.sleep(retry_after)
                continue
            
            response.raise_for_status()
            data = response.json()
            
            if data.get("code") != 0:
                raise RuntimeError(f"TickDB API error: {data.get('message')}")
            
            klines = data["data"]["klines"]
            
            df = pd.DataFrame(klines)
            df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
            df = df.set_index("timestamp")
            df = df[["open", "high", "low", "close", "volume"]].astype(float)
            
            return df.sort_index()
            
        except requests.exceptions.Timeout:
            retry_count += 1
            delay = min(2 ** retry_count + random.uniform(0, 0.5), 30)
            print(f"Request timeout. Retrying in {delay:.1f}s...")
            time.sleep(delay)
            
        except requests.exceptions.RequestException as e:
            retry_count += 1
            delay = min(2 ** retry_count + random.uniform(0, 0.5), 30)
            print(f"Request failed: {e}. Retrying in {delay:.1f}s...")
            time.sleep(delay)
    
    raise RuntimeError(f"Failed to fetch data for {symbol} after {max_retries} retries")


def fetch_spy_tlt_data() -> tuple[pd.DataFrame, pd.DataFrame]:
    """
    Fetch 15-minute bars for SPY and TLT.
    Uses 500 bars (approx. 5 trading days of data) for rolling correlation.
    """
    print(f"[{datetime.now().isoformat()}] Fetching SPY and TLT data...")
    
    spy = fetch_ohlcv("SPY.US", interval="15m", limit=500)
    tlt = fetch_ohlcv("TLT.US", interval="15m", limit=500)
    
    print(f"SPY: {len(spy)} bars, latest: {spy.index[-1]}")
    print(f"TLT: {len(tlt)} bars, latest: {tlt.index[-1]}")
    
    return spy, tlt

The function fetches 500 15-minute bars — approximately five trading days of continuous market data. This window is long enough to calculate statistically meaningful correlations while remaining sensitive to recent regime shifts.


Correlation Engine: Rolling Pearson with Regime Detection

The correlation engine calculates two rolling correlations simultaneously:

  1. Short window (20 periods / ~5 hours): Captures intraday sentiment shifts and short-term divergence signals
  2. Long window (252 periods / ~2 weeks): Reveals structural regime changes

The divergence signal fires when the short-window correlation diverges significantly from the long-window baseline — a sign that the relationship is weakening.

import numpy as np
from typing import NamedTuple


class CorrelationState(NamedTuple):
    """Container for correlation metrics."""
    short_corr: float
    long_corr: float
    corr_diff: float
    spy_return_vol: float
    tlt_return_vol: float
    is_diverging: bool
    regime_warning: str | None


def calculate_returns(df: pd.DataFrame) -> pd.Series:
    """Calculate log returns from close prices."""
    return np.log(df["close"] / df["close"].shift(1)).dropna()


def rolling_correlation(series1: pd.Series, series2: pd.Series, window: int) -> pd.Series:
    """
    Calculate rolling Pearson correlation between two return series.
    
    Uses pandas rolling correlation for efficiency.
    """
    combined = pd.DataFrame({"s1": series1, "s2": series2}).dropna()
    return combined["s1"].rolling(window=window).corr(combined["s2"])


def detect_regime(long_corr: float) -> str:
    """
    Classify the current stock-bond correlation regime.
    
    Thresholds are calibrated against historical data:
    - Post-GFC (2013-2019): Average correlation ≈ -0.45
    - COVID crash (2020): Correlation turned positive briefly
    - Rate hike cycle (2022): Correlation ≈ +0.20 to +0.40
    """
    if long_corr > 0.1:
        return "POSITIVE_REGIME"  # Both assets move together
    elif long_corr < -0.6:
        return "STRONG_NEGATIVE"  # Classic safe-haven relationship intact
    elif -0.6 <= long_corr <= -0.3:
        return "MODERATE_NEGATIVE"  # Normal diversification benefit
    elif -0.3 < long_corr <= 0.1:
        return "WEAK_OR_MIXED"  # Relationship unreliable
    else:
        return "UNKNOWN"


def compute_correlation_state(spy_df: pd.DataFrame, tlt_df: pd.DataFrame) -> CorrelationState:
    """
    Compute rolling correlation metrics for SPY and TLT.
    
    Returns a CorrelationState with:
    - Short-window (20-period) correlation
    - Long-window (252-period) correlation
    - Difference between short and long
    - Intraday volatility for both assets
    - Divergence flag
    - Regime classification
    """
    # Align timestamps (both assets may have slightly different trading hours)
    spy_returns = calculate_returns(spy_df)
    tlt_returns = calculate_returns(tlt_df)
    
    # Align on common index
    aligned = pd.DataFrame({"spy": spy_returns, "tlt": tlt_returns}).dropna()
    
    if len(aligned) < 30:
        raise ValueError(
            f"Insufficient aligned data points ({len(aligned)}). "
            "Need at least 30 periods for meaningful correlation."
        )
    
    # Calculate rolling correlations
    short_window = 20   # ~5 hours
    long_window = min(252, len(aligned) - 10)  # ~2 weeks, capped at available data
    
    short_corr = rolling_correlation(aligned["spy"], aligned["tlt"], short_window).iloc[-1]
    long_corr = rolling_correlation(aligned["spy"], aligned["tlt"], long_window).iloc[-1]
    
    # Calculate recent volatility (annualized, 15-min bars)
    spy_vol = aligned["spy"].tail(20).std() * np.sqrt(252 * 6.5 * 4)  # 4 15-min bars per hour
    tlt_vol = aligned["tlt"].tail(20).std() * np.sqrt(252 * 6.5 * 4)
    
    # Divergence detection: short-term correlation diverges from long-term baseline
    corr_diff = short_corr - long_corr
    divergence_threshold = 0.3  # Fire signal if short-term correlation deviates by 30%+
    is_diverging = abs(corr_diff) > divergence_threshold
    
    # Detect regime
    regime = detect_regime(long_corr)
    regime_warning = None
    
    if regime == "POSITIVE_REGIME":
        regime_warning = (
            "⚠️ STOCK-BOND CORRELATION IS POSITIVE. "
            "Traditional 60/40 hedge may not work. Consider reducing duration exposure."
        )
    elif regime == "WEAK_OR_MIXED":
        regime_warning = (
            "⚠️ STOCK-BOND CORRELATION IS UNRELIABLE. "
            "Hedge ratio may need dynamic adjustment."
        )
    
    return CorrelationState(
        short_corr=short_corr,
        long_corr=long_corr,
        corr_diff=corr_diff,
        spy_return_vol=spy_vol,
        tlt_return_vol=tlt_vol,
        is_diverging=is_diverging,
        regime_warning=regime_warning
    )

Divergence Detection and Hedge Ratio Calculation

The divergence signal is not merely an observation — it triggers a practical action: recalculating the hedge ratio. The hedge ratio tells you how many TLT contracts (or shares of the ETF) you need to short for each unit of SPY exposure to neutralize equity beta.

def calculate_hedge_ratio(
    spy_returns: pd.Series,
    tlt_returns: pd.Series,
    window: int = 60
) -> float:
    """
    Calculate the optimal hedge ratio using ordinary least squares (OLS).
    
    The hedge ratio h* = Cov(r_spy, r_tlt) / Var(r_tlt)
    
    This minimizes the variance of the hedged portfolio:
    r_hedge = r_spy - h * r_tlt
    
    Args:
        spy_returns: Log returns of SPY
        tlt_returns: Log returns of TLT
        window: Lookback window for regression
    
    Returns:
        Optimal hedge ratio (shares of TLT per share of SPY)
    """
    aligned = pd.DataFrame({"spy": spy_returns, "tlt": tlt_returns}).dropna().tail(window)
    
    if len(aligned) < 20:
        raise ValueError(f"Insufficient data for hedge ratio calculation: {len(aligned)} periods")
    
    # OLS: r_spy = alpha + beta * r_tlt + epsilon
    # The hedge ratio is beta
    covariance = aligned["spy"].cov(aligned["tlt"])
    tlt_variance = aligned["tlt"].var()
    
    hedge_ratio = covariance / tlt_variance
    
    # Sanity check: hedge ratio should be negative (short TLT to hedge long SPY)
    # In positive correlation regimes, this breaks down
    if hedge_ratio > 0:
        print(
            f"⚠️ WARNING: Positive hedge ratio ({hedge_ratio:.4f}). "
            "Stock-bond correlation may be positive. Hedge may not work as expected."
        )
    
    return hedge_ratio


def generate_divergence_report(state: CorrelationState) -> str:
    """Generate a human-readable divergence report."""
    report_lines = [
        "=" * 60,
        "STOCK-BOND CORRELATION MONITOR REPORT",
        "=" * 60,
        f"Timestamp: {datetime.now().isoformat()}",
        "",
        "--- Correlation Metrics ---",
        f"Short-window correlation (20 periods): {state.short_corr:.4f}",
        f"Long-window correlation (252 periods): {state.long_corr:.4f}",
        f"Correlation divergence: {state.corr_diff:+.4f}",
        "",
        "--- Volatility ---",
        f"SPY annualized vol: {state.spy_return_vol:.2%}",
        f"TLT annualized vol: {state.tlt_return_vol:.2%}",
        "",
        "--- Regime Classification ---",
        f"Current regime: {detect_regime(state.long_corr)}",
    ]
    
    if state.regime_warning:
        report_lines.extend(["", state.regime_warning])
    
    if state.is_diverging:
        report_lines.extend([
            "",
            "🚨 DIVERGENCE ALERT:",
            f"Short-term correlation deviates from long-term baseline by {abs(state.corr_diff):.2f}.",
            "Consider recalibrating hedge ratio."
        ])
    
    report_lines.append("=" * 60)
    return "\n".join(report_lines)

Putting It All Together: The Complete Monitor

The main loop fetches fresh data, computes correlation metrics, detects divergences, and outputs reports. In a production deployment, you would integrate this with a webhook to send alerts to Slack, email, or a trading system's event bus.

def run_monitor_cycle():
    """
    Execute one monitoring cycle: fetch data, compute metrics, generate report.
    """
    try:
        # Step 1: Fetch fresh data
        spy_df, tlt_df = fetch_spy_tlt_data()
        
        # Step 2: Compute correlation state
        state = compute_correlation_state(spy_df, tlt_df)
        
        # Step 3: Calculate hedge ratio using recent returns
        spy_returns = calculate_returns(spy_df)
        tlt_returns = calculate_returns(tlt_df)
        hedge_ratio = calculate_hedge_ratio(spy_returns, tlt_returns, window=60)
        
        # Step 4: Generate and print report
        report = generate_divergence_report(state)
        print(report)
        print(f"\n📊 Dynamic Hedge Ratio: {hedge_ratio:.4f}")
        print(
            f"   Interpretation: Short {abs(hedge_ratio):.4f} units of TLT "
            f"for every 1 unit of SPY long."
        )
        
        # Step 5: Optional — trigger webhook alert on divergence
        if state.is_diverging or state.regime_warning:
            send_alert(state, hedge_ratio)
        
        return state, hedge_ratio
        
    except Exception as e:
        print(f"Monitor cycle failed: {e}")
        raise


def send_alert(state: CorrelationState, hedge_ratio: float):
    """
    Send alert to webhook endpoint (e.g., Slack, PagerDuty).
    
    In production, replace this with your actual webhook integration.
    """
    webhook_url = os.environ.get("ALERT_WEBHOOK_URL")
    if not webhook_url:
        print("⚠️ ALERT_WEBHOOK_URL not set — skipping webhook notification.")
        return
    
    message = {
        "text": f"**Stock-Bond Divergence Alert**\n"
                f"Correlation (short): {state.short_corr:.4f}\n"
                f"Correlation (long): {state.long_corr:.4f}\n"
                f"Divergence: {state.corr_diff:+.4f}\n"
                f"Regime: {detect_regime(state.long_corr)}\n"
                f"Recommended hedge ratio: {hedge_ratio:.4f}"
    }
    
    try:
        response = requests.post(
            webhook_url,
            json=message,
            headers={"Content-Type": "application/json"},
            timeout=5
        )
        response.raise_for_status()
        print(f"[{datetime.now().isoformat()}] Alert sent successfully.")
    except requests.exceptions.RequestException as e:
        print(f"⚠️ Failed to send alert: {e}")


# Main execution
if __name__ == "__main__":
    print("Starting SPY-TLT Correlation Monitor...")
    print("Press Ctrl+C to exit.\n")
    
    while True:
        try:
            state, hedge_ratio = run_monitor_cycle()
            
            # Run every 15 minutes (900 seconds)
            # Adjust based on your data freshness requirements
            sleep_interval = 900
            print(f"\nNext cycle in {sleep_interval // 60} minutes...")
            time.sleep(sleep_interval)
            
        except KeyboardInterrupt:
            print("\nMonitor stopped by user.")
            break
        except Exception as e:
            # On error, wait before retrying to avoid hammering the API
            delay = 60
            print(f"Retrying in {delay}s...")
            time.sleep(delay)

Interpreting the Hedge Ratio in Practice

The hedge ratio derived from the OLS regression tells you the quantity of TLT to short for each unit of SPY exposure. Here is how to interpret the numbers:

Hedge ratio Interpretation Portfolio action
-0.40 to -0.60 Normal negative correlation regime Standard 60/40 rebalancing works
-0.20 to -0.40 Weakening correlation Reduce TLT allocation; increase cash buffer
+0.10 to +0.30 Positive correlation (rate hike / stagflation) TLT no longer hedges equities; consider alternatives (gold, puts)
Close to 0 No relationship Relationship is unreliable; do not rely on TLT as a hedge

The dynamic hedge ratio adjusts automatically as the rolling correlation shifts. During the 2022 rate hike cycle, the hedge ratio for SPY-TLT turned positive — meaning you would have needed to go long TLT alongside long SPY to neutralize exposure, which defeats the purpose of the hedge.


SPY and TLT: The Core ETF Pair

Ticker Name Role in the strategy
SPY SPDR S&P 500 ETF Trust Primary equity exposure; tracks the S&P 500 with high liquidity
TLT iShares 20+ Year Treasury Bond ETF Long-duration Treasury exposure; the standard proxy for the bond market

These two ETFs are among the most liquid instruments in the world, with average daily volumes exceeding $80B (SPY) and $5B (TLT). This liquidity ensures tight bid-ask spreads and reliable data from market data providers.


Limitations and Risk Disclosures

Backtest limitations: The correlation thresholds and divergence detection logic in this article are calibrated against historical market behavior but have not been backtested with a rigorous statistical framework. Before deploying this system live:

  • Validate the divergence thresholds against at least 3 years of historical data
  • Test the alert system under simulated market conditions
  • Account for transaction costs (the hedge ratio rebalancing incurs spread costs on both SPY and TLT)
  • Consider slippage: during market stress, the bid-ask spread on TLT widens significantly

Regime detection is lagging: The long-window correlation (252 periods) requires approximately two weeks of data. During a sudden regime shift (e.g., a surprise Fed announcement), the system will not detect the change until the next monitoring cycle.

Alternative proxies: If you cannot access SPY and TLT, equivalent proxies include IVV / VOO for equities and VGLT / IBTX for shorter-duration Treasuries. The correlation dynamics differ slightly with duration.

This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results. The correlation relationships described are based on historical observation and may not persist in future market environments.


Next Steps

If you want to run this strategy yourself:

  1. Sign up at tickdb.ai (free, no credit card required)
  2. Generate an API key in the dashboard
  3. Set the TICKDB_API_KEY environment variable, then copy-paste the code from this article

If you're interested in extending this system:

  • Add WebSocket streaming for real-time divergence alerts (sub-15-second latency)
  • Integrate with a backtesting framework to validate hedge ratio performance over multiple market cycles
  • Add regime classification using Hidden Markov Models (HMM) for more robust regime detection

If you need institutional-grade historical data for backtesting:

  • Contact [email protected] for Professional and Enterprise plans covering 10+ years of OHLCV data

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace for direct API integration within your coding workflow.


This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results.