""" macro_data.py — Macro Rotation Data Provider Quantum Terminal Analytical Terminal Fetches sector ETF and country ETF performance data from yfinance. Computes 1-week and 4-week percentage changes. Results cached with configurable TTL to avoid API hammering. Endpoints served: GET /api/macro/sectors → sector ETF performance GET /api/macro/countries → country ETF performance """ import logging import time from typing import Dict, List, Optional log = logging.getLogger("mk.macro_data") # ════════════════════════════════════════════════════════ # ETF UNIVERSE — no hardcoded assets, but ETFs are # benchmark instruments (not part of trading universe) # ════════════════════════════════════════════════════════ SECTOR_ETFS = { "XLE": {"name": "Energy", "color": "#e67e22"}, "XLF": {"name": "Financials", "color": "#3498db"}, "XLK": {"name": "Technology", "color": "#9b59b6"}, "XLRE": {"name": "Real Estate", "color": "#1abc9c"}, "XLU": {"name": "Utilities", "color": "#f39c12"}, "XLV": {"name": "Healthcare", "color": "#2ecc71"}, "XLB": {"name": "Materials", "color": "#e74c3c"}, "XLI": {"name": "Industrials", "color": "#34495e"}, "XLC": {"name": "Communication", "color": "#e84393"}, "XLY": {"name": "Consumer Discretionary", "color": "#00cec9"}, "XLP": {"name": "Consumer Staples", "color": "#fdcb6e"}, } COUNTRY_ETFS = { "SPY": {"name": "United States", "code": "US"}, "EWJ": {"name": "Japan", "code": "JP"}, "FXI": {"name": "China", "code": "CN"}, "EWG": {"name": "Germany", "code": "DE"}, "EWU": {"name": "United Kingdom", "code": "GB"}, "EWZ": {"name": "Brazil", "code": "BR"}, "EWA": {"name": "Australia", "code": "AU"}, "EWC": {"name": "Canada", "code": "CA"}, "EWY": {"name": "South Korea", "code": "KR"}, "EWT": {"name": "Taiwan", "code": "TW"}, "INDA": {"name": "India", "code": "IN"}, "EWQ": {"name": "France", "code": "FR"}, "EWI": {"name": "Italy", "code": "IT"}, "EWP": {"name": "Spain", "code": "ES"}, "EWW": {"name": "Mexico", "code": "MX"}, "EZA": {"name": "South Africa", "code": "ZA"}, "TUR": {"name": "Turkey", "code": "TR"}, "KSA": {"name": "Saudi Arabia", "code": "SA"}, "EWS": {"name": "Singapore", "code": "SG"}, "EWH": {"name": "Hong Kong", "code": "HK"}, "EWM": {"name": "Malaysia", "code": "MY"}, "EWN": {"name": "Netherlands", "code": "NL"}, "EWD": {"name": "Sweden", "code": "SE"}, "EWL": {"name": "Switzerland", "code": "CH"}, "THD": {"name": "Thailand", "code": "TH"}, } # ════════════════════════════════════════════════════════ # CACHE # ════════════════════════════════════════════════════════ _cache: Dict[str, dict] = {} CACHE_TTL_SECONDS = 3600 # 1 hour def _is_cache_valid(key: str) -> bool: if key not in _cache: return False return (time.time() - _cache[key]["ts"]) < CACHE_TTL_SECONDS # ════════════════════════════════════════════════════════ # DATA FETCH # ════════════════════════════════════════════════════════ def _fetch_etf_changes(tickers: List[str], period: str = "2mo") -> Dict[str, dict]: """ Fetch OHLCV for a list of ETF tickers and compute 1-week and 4-week percentage changes from latest close. Returns dict keyed by ticker: {ticker: {"close": float, "chg_1w": float, "chg_4w": float}} """ try: import yfinance as yf except ImportError: log.warning("yfinance not installed — macro data unavailable") return {} results = {} try: # Batch download — single API call for all tickers data = yf.download(tickers, period=period, interval="1d", auto_adjust=True, progress=False, threads=True) if data.empty: log.warning("yfinance returned empty data for macro ETFs") return {} close = data["Close"] if "Close" in data.columns else data.get("close") if close is None or close.empty: log.warning("No Close column in yfinance macro data") return {} # Handle single ticker case (returns Series, not DataFrame) if isinstance(close, type(data)): pass # already DataFrame else: # Single ticker returns Series — unlikely but handle close = close.to_frame(name=tickers[0]) for ticker in tickers: if ticker not in close.columns: continue series = close[ticker].dropna() if len(series) < 2: continue latest = float(series.iloc[-1]) # 1-week change (5 trading days back) idx_1w = min(5, len(series) - 1) close_1w = float(series.iloc[-1 - idx_1w]) chg_1w = ((latest - close_1w) / close_1w) * 100 if close_1w != 0 else 0.0 # 4-week change (20 trading days back) idx_4w = min(20, len(series) - 1) close_4w = float(series.iloc[-1 - idx_4w]) chg_4w = ((latest - close_4w) / close_4w) * 100 if close_4w != 0 else 0.0 results[ticker] = { "close": round(latest, 2), "chg_1w": round(chg_1w, 2), "chg_4w": round(chg_4w, 2), } except Exception as e: log.warning(f"yfinance macro fetch failed: {e}") return results # ════════════════════════════════════════════════════════ # PUBLIC API # ════════════════════════════════════════════════════════ def get_sector_data(force_refresh: bool = False) -> dict: """ Returns sector performance data. Response shape: { "sectors": [ {"ticker": "XLE", "name": "Energy", "color": "#e67e22", "close": 88.5, "chg_1w": 2.3, "chg_4w": -1.1}, ... ], "top5_1w": [...], "bottom5_1w": [...], "top5_4w": [...], "bottom5_4w": [...], "updated_at": 1700000000.0 } """ cache_key = "sectors" if not force_refresh and _is_cache_valid(cache_key): return _cache[cache_key]["data"] tickers = list(SECTOR_ETFS.keys()) raw = _fetch_etf_changes(tickers) sectors = [] for ticker, meta in SECTOR_ETFS.items(): perf = raw.get(ticker, {}) sectors.append({ "ticker": ticker, "name": meta["name"], "color": meta["color"], "close": perf.get("close", 0), "chg_1w": perf.get("chg_1w", 0), "chg_4w": perf.get("chg_4w", 0), }) # Sort helpers sorted_1w = sorted(sectors, key=lambda s: s["chg_1w"], reverse=True) sorted_4w = sorted(sectors, key=lambda s: s["chg_4w"], reverse=True) result = { "sectors": sectors, "top5_1w": sorted_1w[:5], "bottom5_1w": sorted_1w[-5:], "top5_4w": sorted_4w[:5], "bottom5_4w": sorted_4w[-5:], "updated_at": time.time(), } _cache[cache_key] = {"data": result, "ts": time.time()} log.info(f"Sector data refreshed — {len(sectors)} sectors loaded") return result def get_country_data(force_refresh: bool = False) -> dict: """ Returns country ETF performance data. Response shape: { "countries": [ {"ticker": "SPY", "name": "United States", "code": "US", "close": 440.5, "chg_1w": 1.2, "chg_4w": 3.5}, ... ], "top5_1w": [...], "bottom5_1w": [...], "top5_4w": [...], "bottom5_4w": [...], "updated_at": 1700000000.0 } """ cache_key = "countries" if not force_refresh and _is_cache_valid(cache_key): return _cache[cache_key]["data"] tickers = list(COUNTRY_ETFS.keys()) raw = _fetch_etf_changes(tickers) countries = [] for ticker, meta in COUNTRY_ETFS.items(): perf = raw.get(ticker, {}) countries.append({ "ticker": ticker, "name": meta["name"], "code": meta["code"], "close": perf.get("close", 0), "chg_1w": perf.get("chg_1w", 0), "chg_4w": perf.get("chg_4w", 0), }) sorted_1w = sorted(countries, key=lambda c: c["chg_1w"], reverse=True) sorted_4w = sorted(countries, key=lambda c: c["chg_4w"], reverse=True) result = { "countries": countries, "top5_1w": sorted_1w[:5], "bottom5_1w": sorted_1w[-5:], "top5_4w": sorted_4w[:5], "bottom5_4w": sorted_4w[-5:], "updated_at": time.time(), } _cache[cache_key] = {"data": result, "ts": time.time()} log.info(f"Country data refreshed — {len(countries)} countries loaded") return result # ════════════════════════════════════════════════════════ # CLI DIAGNOSTIC # ════════════════════════════════════════════════════════ if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") print("=" * 60) print("MACRO DATA — SECTOR ETFS") print("=" * 60) sd = get_sector_data(force_refresh=True) for s in sd["sectors"]: arrow_1w = "▲" if s["chg_1w"] >= 0 else "▼" arrow_4w = "▲" if s["chg_4w"] >= 0 else "▼" print(f" {s['ticker']:5s} {s['name']:28s} " f"1W: {arrow_1w} {s['chg_1w']:+6.2f}% " f"4W: {arrow_4w} {s['chg_4w']:+6.2f}%") print(f"\n TOP 5 (1W): {', '.join(s['name'] for s in sd['top5_1w'])}") print(f" BOTTOM 5 (1W): {', '.join(s['name'] for s in sd['bottom5_1w'])}") print("\n" + "=" * 60) print("MACRO DATA — COUNTRY ETFS") print("=" * 60) cd = get_country_data(force_refresh=True) for c in cd["countries"]: arrow_1w = "▲" if c["chg_1w"] >= 0 else "▼" arrow_4w = "▲" if c["chg_4w"] >= 0 else "▼" print(f" {c['ticker']:5s} {c['code']:3s} {c['name']:20s} " f"1W: {arrow_1w} {c['chg_1w']:+6.2f}% " f"4W: {arrow_4w} {c['chg_4w']:+6.2f}%") print(f"\n TOP 5 (1W): {', '.join(c['name'] for c in cd['top5_1w'])}") print(f" BOTTOM 5 (1W): {', '.join(c['name'] for c in cd['bottom5_1w'])}") print("\nDONE")