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:
- Short window (20 periods / ~5 hours): Captures intraday sentiment shifts and short-term divergence signals
- 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:
- Sign up at tickdb.ai (free, no credit card required)
- Generate an API key in the dashboard
- Set the
TICKDB_API_KEYenvironment 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.