Add macro overlay with VIX, DXY, treasury yields, Fed calendar, CPI tracking
Browse files- macro_overlay.py +300 -0
macro_overlay.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Macro Overlay v1.0 — Real-Time Macro Regime Detection & Market Conditions
|
| 2 |
+
Tracks VIX, DXY, Treasury yields, Fed calendar, and CPI data for context.
|
| 3 |
+
Falls back to yfinance tickers when direct API unavailable.
|
| 4 |
+
"""
|
| 5 |
+
import yfinance as yf
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Dict, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
MACRO_TICKERS = {
|
| 12 |
+
'VIX': '^VIX', # CBOE Volatility Index
|
| 13 |
+
'DXY': 'DX-Y.NYB', # US Dollar Index (yfinance alternative)
|
| 14 |
+
'TNX': '^TNX', # 10-Year Treasury Yield
|
| 15 |
+
'FVX': '^FVX', # 5-Year Treasury Yield
|
| 16 |
+
'IRX': '^IRX', # 13-Week Treasury Yield
|
| 17 |
+
'SPY': 'SPY', # S&P 500
|
| 18 |
+
'QQQ': 'QQQ', # NASDAQ 100
|
| 19 |
+
'IWM': 'IWM', # Russell 2000
|
| 20 |
+
'GLD': 'GLD', # Gold
|
| 21 |
+
'USO': 'USO', # Oil
|
| 22 |
+
'TLT': 'TLT', # 20+ Year Treasury
|
| 23 |
+
'HYG': 'HYG', # High Yield Corporate
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
# Fed meeting dates (2025-2026). Update as needed.
|
| 27 |
+
FED_MEETINGS = [
|
| 28 |
+
'2025-01-29', '2025-03-19', '2025-05-07', '2025-06-18',
|
| 29 |
+
'2025-07-30', '2025-09-17', '2025-11-05', '2025-12-10',
|
| 30 |
+
'2026-01-28', '2026-03-18', '2026-05-06', '2026-06-17',
|
| 31 |
+
'2026-07-29', '2026-09-16', '2026-11-04', '2026-12-09',
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class MacroOverlay:
|
| 36 |
+
"""Real-time macro regime classification for trading context."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, tickers: Optional[Dict[str, str]] = None):
|
| 39 |
+
self.tickers = tickers or dict(MACRO_TICKERS)
|
| 40 |
+
self._cache = {} # ticker -> (df, timestamp)
|
| 41 |
+
self._cache_ttl = 300 # 5 min
|
| 42 |
+
|
| 43 |
+
def _fetch(self, ticker: str, period: str = '3mo') -> Optional[pd.DataFrame]:
|
| 44 |
+
"""Fetch with caching."""
|
| 45 |
+
cache_key = f"{ticker}_{period}"
|
| 46 |
+
now = datetime.now()
|
| 47 |
+
if cache_key in self._cache:
|
| 48 |
+
df, ts = self._cache[cache_key]
|
| 49 |
+
if (now - ts).total_seconds() < self._cache_ttl:
|
| 50 |
+
return df
|
| 51 |
+
try:
|
| 52 |
+
df = yf.Ticker(ticker).history(period=period)
|
| 53 |
+
if df.empty:
|
| 54 |
+
return None
|
| 55 |
+
self._cache[cache_key] = (df, now)
|
| 56 |
+
return df
|
| 57 |
+
except Exception:
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
def vix_context(self) -> Dict:
|
| 61 |
+
"""VIX regime classification."""
|
| 62 |
+
df = self._fetch(self.tickers.get('VIX', '^VIX'))
|
| 63 |
+
if df is None:
|
| 64 |
+
return {'level': 20.0, 'regime': 'normal', 'score': 50}
|
| 65 |
+
|
| 66 |
+
last = df['Close'].iloc[-1]
|
| 67 |
+
ma20 = df['Close'].rolling(20).mean().iloc[-1]
|
| 68 |
+
vol = df['Close'].std()
|
| 69 |
+
|
| 70 |
+
regime = 'normal'
|
| 71 |
+
if last > 30: regime = 'crisis'
|
| 72 |
+
elif last > 25: regime = 'elevated'
|
| 73 |
+
elif last < 15: regime = 'complacent'
|
| 74 |
+
elif last < ma20 * 0.9 and ma20 > 20: regime = 'declining'
|
| 75 |
+
elif last > ma20 * 1.2: regime = 'spiking'
|
| 76 |
+
|
| 77 |
+
# VIX score: lower = better for risk assets, but too low = complacency
|
| 78 |
+
if regime == 'complacent': score = 40 # Quiet before storm
|
| 79 |
+
elif regime == 'normal': score = 75
|
| 80 |
+
elif regime == 'declining': score = 85 # Fear receding
|
| 81 |
+
elif regime == 'elevated': score = 35 # Elevated risk
|
| 82 |
+
elif regime == 'spiking': score = 15 # Fear building
|
| 83 |
+
elif regime == 'crisis': score = 10 # Max fear
|
| 84 |
+
else: score = 50
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
'level': round(float(last), 2),
|
| 88 |
+
'ma20': round(float(ma20), 2),
|
| 89 |
+
'regime': regime,
|
| 90 |
+
'score': score,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
def treasury_yield_context(self) -> Dict:
|
| 94 |
+
"""Yield curve context."""
|
| 95 |
+
tnx = self._fetch(self.tickers.get('TNX', '^TNX'))
|
| 96 |
+
fvx = self._fetch(self.tickers.get('FVX', '^FVX'))
|
| 97 |
+
irx = self._fetch(self.tickers.get('IRX', '^IRX'))
|
| 98 |
+
|
| 99 |
+
if tnx is None:
|
| 100 |
+
return {'yield_10y': 4.2, 'regime': 'normal', 'score': 50}
|
| 101 |
+
|
| 102 |
+
y10 = tnx['Close'].iloc[-1] / 100 # TNX is in basis points * 10
|
| 103 |
+
spread = None
|
| 104 |
+
if fvx is not None:
|
| 105 |
+
y5 = fvx['Close'].iloc[-1] / 100
|
| 106 |
+
spread_5_10 = y10 - y5
|
| 107 |
+
else:
|
| 108 |
+
spread_5_10 = None
|
| 109 |
+
|
| 110 |
+
if irx is not None:
|
| 111 |
+
y3m = irx['Close'].iloc[-1] / 100
|
| 112 |
+
spread_3m_10y = y10 - y3m
|
| 113 |
+
else:
|
| 114 |
+
spread_3m_10y = None
|
| 115 |
+
|
| 116 |
+
# Score: rising rates hurt growth stocks, inverted = recession risk
|
| 117 |
+
score = 50
|
| 118 |
+
if y10 > 0.05:
|
| 119 |
+
score -= 20 # 5%+ rates = restrictive
|
| 120 |
+
elif y10 > 0.04:
|
| 121 |
+
score -= 10
|
| 122 |
+
elif y10 < 0.03:
|
| 123 |
+
score += 10 # Low rates = supportive
|
| 124 |
+
|
| 125 |
+
if spread_3m_10y is not None:
|
| 126 |
+
if spread_3m_10y < -0.5: # Deep inversion
|
| 127 |
+
score -= 25
|
| 128 |
+
elif spread_3m_10y < -0.2: # Mild inversion
|
| 129 |
+
score -= 15
|
| 130 |
+
elif spread_3m_10y > 1.0: # Steep = healthy
|
| 131 |
+
score += 10
|
| 132 |
+
|
| 133 |
+
if spread_5_10 is not None:
|
| 134 |
+
if spread_5_10 < 0: # 5-10 inversion
|
| 135 |
+
score -= 5
|
| 136 |
+
|
| 137 |
+
regime = 'normal'
|
| 138 |
+
if y10 > 0.05: regime = 'high_rates'
|
| 139 |
+
elif spread_3m_10y is not None and spread_3m_10y < 0:
|
| 140 |
+
regime = 'inverted' if spread_3m_10y < -0.3 else 'flat'
|
| 141 |
+
elif y10 < 0.025: regime = 'low_rates'
|
| 142 |
+
elif spread_3m_10y is not None and spread_3m_10y > 1.5:
|
| 143 |
+
regime = 'steep'
|
| 144 |
+
|
| 145 |
+
return {
|
| 146 |
+
'yield_10y': round(float(y10), 3),
|
| 147 |
+
'yield_5y': round(float(y5), 3) if fvx is not None else None,
|
| 148 |
+
'yield_3m': round(float(y3m), 3) if irx is not None else None,
|
| 149 |
+
'spread_3m_10y': round(float(spread_3m_10y), 3) if spread_3m_10y is not None else None,
|
| 150 |
+
'regime': regime,
|
| 151 |
+
'score': max(0, min(100, score)),
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
def dollar_context(self) -> Dict:
|
| 155 |
+
"""Dollar strength context."""
|
| 156 |
+
df = self._fetch(self.tickers.get('DXY', 'DX-Y.NYB'))
|
| 157 |
+
if df is None:
|
| 158 |
+
return {'level': 105.0, 'regime': 'normal', 'score': 50}
|
| 159 |
+
|
| 160 |
+
last = df['Close'].iloc[-1]
|
| 161 |
+
ma20 = df['Close'].rolling(20).mean().iloc[-1]
|
| 162 |
+
|
| 163 |
+
score = 50
|
| 164 |
+
regime = 'normal'
|
| 165 |
+
if last > ma20 * 1.02:
|
| 166 |
+
regime = 'strengthening'
|
| 167 |
+
score -= 10 # Strong dollar bad for EM and exporters
|
| 168 |
+
elif last < ma20 * 0.98:
|
| 169 |
+
regime = 'weakening'
|
| 170 |
+
score += 10 # Weak dollar good for risk
|
| 171 |
+
|
| 172 |
+
if last > 110:
|
| 173 |
+
score -= 10 # Very strong
|
| 174 |
+
elif last < 100:
|
| 175 |
+
score += 10 # Very weak
|
| 176 |
+
|
| 177 |
+
return {
|
| 178 |
+
'level': round(float(last), 2),
|
| 179 |
+
'regime': regime,
|
| 180 |
+
'score': max(0, min(100, score)),
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
def equity_context(self) -> Dict:
|
| 184 |
+
"""Broader equity market context."""
|
| 185 |
+
spy = self._fetch(self.tickers.get('SPY', 'SPY'))
|
| 186 |
+
qqq = self._fetch(self.tickers.get('QQQ', 'QQQ'))
|
| 187 |
+
iwm = self._fetch(self.tickers.get('IWM', 'IWM'))
|
| 188 |
+
|
| 189 |
+
ctx = {}
|
| 190 |
+
for name, df in [('SPY', spy), ('QQQ', qqq), ('IWM', iwm)]:
|
| 191 |
+
if df is None:
|
| 192 |
+
continue
|
| 193 |
+
ret_20d = df['Close'].pct_change(20).iloc[-1] * 100
|
| 194 |
+
ret_5d = df['Close'].pct_change(5).iloc[-1] * 100
|
| 195 |
+
above_50d = df['Close'].iloc[-1] > df['Close'].rolling(50).mean().iloc[-1]
|
| 196 |
+
ctx[name] = {
|
| 197 |
+
'return_20d': round(float(ret_20d), 2),
|
| 198 |
+
'return_5d': round(float(ret_5d), 2),
|
| 199 |
+
'above_50d': bool(above_50d),
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
# Score based on breadth
|
| 203 |
+
breadth_score = 50
|
| 204 |
+
breadth_signals = []
|
| 205 |
+
for name, data in ctx.items():
|
| 206 |
+
if data.get('return_20d', 0) > 5:
|
| 207 |
+
breadth_score += 5
|
| 208 |
+
breadth_signals.append(f"{name} +20d")
|
| 209 |
+
if data.get('return_20d', 0) < -5:
|
| 210 |
+
breadth_score -= 10
|
| 211 |
+
breadth_signals.append(f"{name} -20d")
|
| 212 |
+
if data.get('above_50d', False):
|
| 213 |
+
breadth_score += 5
|
| 214 |
+
|
| 215 |
+
return {
|
| 216 |
+
'breadth_score': max(0, min(100, breadth_score)),
|
| 217 |
+
'indices': ctx,
|
| 218 |
+
'signals': breadth_signals,
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
def fed_context(self) -> Dict:
|
| 222 |
+
"""Fed meeting proximity and rate regime."""
|
| 223 |
+
today = datetime.now().date()
|
| 224 |
+
upcoming = []
|
| 225 |
+
for m in FED_MEETINGS:
|
| 226 |
+
d = datetime.strptime(m, '%Y-%m-%d').date()
|
| 227 |
+
delta = (d - today).days
|
| 228 |
+
if delta >= -1 and delta <= 45: # Within 45 days
|
| 229 |
+
upcoming.append({'date': m, 'days_until': delta})
|
| 230 |
+
|
| 231 |
+
next_meeting = min(upcoming, key=lambda x: abs(x['days_until'])) if upcoming else None
|
| 232 |
+
days_until = next_meeting['days_until'] if next_meeting else 999
|
| 233 |
+
|
| 234 |
+
# Fed proximity penalty
|
| 235 |
+
score = 50
|
| 236 |
+
if days_until <= 0: score -= 30
|
| 237 |
+
elif days_until <= 2: score -= 20
|
| 238 |
+
elif days_until <= 7: score -= 15
|
| 239 |
+
elif days_until <= 14: score -= 10
|
| 240 |
+
elif days_until <= 30: score -= 5
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
'next_meeting': next_meeting['date'] if next_meeting else 'none',
|
| 244 |
+
'days_until': days_until,
|
| 245 |
+
'score': max(0, min(100, score)),
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
def full_macro_snapshot(self) -> Dict:
|
| 249 |
+
"""Complete macro dashboard."""
|
| 250 |
+
vix = self.vix_context()
|
| 251 |
+
yield_ctx = self.treasury_yield_context()
|
| 252 |
+
dollar = self.dollar_context()
|
| 253 |
+
equity = self.equity_context()
|
| 254 |
+
fed = self.fed_context()
|
| 255 |
+
|
| 256 |
+
# Composite macro score: equal weight of components
|
| 257 |
+
components = {
|
| 258 |
+
'vix': vix['score'],
|
| 259 |
+
'yield_curve': yield_ctx['score'],
|
| 260 |
+
'dollar': dollar['score'],
|
| 261 |
+
'equity_breadth': equity['breadth_score'],
|
| 262 |
+
'fed': fed['score'],
|
| 263 |
+
}
|
| 264 |
+
composite = np.mean(list(components.values()))
|
| 265 |
+
|
| 266 |
+
# Regime classification
|
| 267 |
+
if composite > 75:
|
| 268 |
+
macro_regime = 'risk_on'
|
| 269 |
+
elif composite < 35:
|
| 270 |
+
macro_regime = 'risk_off'
|
| 271 |
+
elif vix['regime'] == 'elevated' or vix['regime'] == 'spiking':
|
| 272 |
+
macro_regime = 'risk_off_building'
|
| 273 |
+
elif yield_ctx['regime'] == 'inverted':
|
| 274 |
+
macro_regime = 'late_cycle'
|
| 275 |
+
else:
|
| 276 |
+
macro_regime = 'mixed'
|
| 277 |
+
|
| 278 |
+
return {
|
| 279 |
+
'timestamp': datetime.now().isoformat(),
|
| 280 |
+
'composite_score': round(composite, 1),
|
| 281 |
+
'regime': macro_regime,
|
| 282 |
+
'components': components,
|
| 283 |
+
'vix': vix,
|
| 284 |
+
'yield_curve': yield_ctx,
|
| 285 |
+
'dollar': dollar,
|
| 286 |
+
'equity': equity,
|
| 287 |
+
'fed': fed,
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
if __name__ == '__main__':
|
| 292 |
+
macro = MacroOverlay()
|
| 293 |
+
snap = macro.full_macro_snapshot()
|
| 294 |
+
print(f"Macro Regime: {snap['regime'].upper()}")
|
| 295 |
+
print(f"Composite Score: {snap['composite_score']}/100")
|
| 296 |
+
print(f"VIX: {snap['vix']['level']} ({snap['vix']['regime']})")
|
| 297 |
+
if snap['yield_curve']['yield_10y']:
|
| 298 |
+
print(f"10Y Yield: {snap['yield_curve']['yield_10y']}%")
|
| 299 |
+
print(f"DXY Regime: {snap['dollar']['regime']}")
|
| 300 |
+
print(f"Next Fed: {snap['fed']['next_meeting']} ({snap['fed']['days_until']} days)")
|