| """ |
| rotation.py — US sector capital-rotation monitor. |
| |
| True money-flow data for US equities (e.g. tick-rule buy/sell imbalance per |
| sector) is a paid dataset. The standard free proxy — used here — is the family |
| of 11 SPDR sector ETFs, which together cover the entire S&P 500: |
| |
| flow_proxy($) = price_change% × dollar volume (close × volume) |
| |
| A sector whose ETF rises on heavy dollar volume is absorbing capital; one that |
| falls on heavy dollar volume is shedding it. We also compute relative strength |
| vs SPY so "everything up / everything down" days don't mask rotation. |
| |
| Windows: 1-day (today's rotation), 5-day (week trend), 20-day (month trend). |
| """ |
| from __future__ import annotations |
|
|
| import pandas as pd |
|
|
| import data_us |
|
|
| SECTOR_ETFS = { |
| "XLK": "Technology", |
| "XLF": "Financials", |
| "XLE": "Energy", |
| "XLV": "Health Care", |
| "XLY": "Consumer Discretionary", |
| "XLP": "Consumer Staples", |
| "XLI": "Industrials", |
| "XLB": "Materials", |
| "XLU": "Utilities", |
| "XLRE": "Real Estate", |
| "XLC": "Communication Services", |
| } |
| BENCH = "SPY" |
|
|
|
|
| def _window_stats(df: pd.DataFrame, n: int): |
| """Return (pct_change, avg dollar volume, flow proxy $) over last n bars.""" |
| if df is None or len(df) < n + 1: |
| return None |
| closes = df["close"].iloc[-(n + 1):] |
| pct = float(closes.iloc[-1] / closes.iloc[0] - 1.0) |
| dvol = float((df["close"].iloc[-n:] * df["volume"].iloc[-n:]).mean()) |
| return pct, dvol, pct * dvol |
|
|
|
|
| def build_rotation(force: bool = False): |
| """Compute the rotation table. Returns (df_1d, df_5d, df_20d, asof_str).""" |
| frames = {} |
| for tk in list(SECTOR_ETFS) + [BENCH]: |
| frames[tk] = data_us.load_level(tk, "d", force=force) |
|
|
| spy = frames[BENCH] |
| asof = "—" |
| if spy is not None and len(spy): |
| asof = pd.Timestamp(spy["date"].iloc[-1]).strftime("%Y-%m-%d") |
|
|
| out = {} |
| for n, label in ((1, "1D"), (5, "5D"), (20, "20D")): |
| rows = [] |
| spy_stats = _window_stats(spy, n) |
| spy_pct = spy_stats[0] if spy_stats else 0.0 |
| for tk, name in SECTOR_ETFS.items(): |
| st = _window_stats(frames[tk], n) |
| if st is None: |
| continue |
| pct, dvol, flow = st |
| rows.append({ |
| "Sector": name, |
| "ETF": tk, |
| "Change": pct, |
| "Avg $ Volume": dvol, |
| "Flow proxy": flow, |
| "RS vs SPY": pct - spy_pct, |
| }) |
| if not rows: |
| out[label] = pd.DataFrame() |
| continue |
| df = pd.DataFrame(rows).sort_values("Flow proxy", ascending=False).reset_index(drop=True) |
| out[label] = df |
| return out.get("1D"), out.get("5D"), out.get("20D"), asof |
|
|
|
|
| def fmt_table(df: pd.DataFrame) -> pd.DataFrame: |
| """Human-readable formatting for the UI.""" |
| if df is None or df.empty: |
| return pd.DataFrame(columns=["Sector", "ETF", "Change", "$ Volume (avg)", |
| "Flow proxy", "RS vs SPY", "Read"]) |
| d = df.copy() |
|
|
| def _money(x): |
| ax = abs(x) |
| if ax >= 1e9: |
| return f"${x/1e9:,.2f}B" |
| if ax >= 1e6: |
| return f"${x/1e6:,.1f}M" |
| return f"${x:,.0f}" |
|
|
| def _read(row): |
| if row["Flow proxy"] > 0 and row["RS vs SPY"] > 0: |
| return "🟢 Inflow + leading" |
| if row["Flow proxy"] > 0: |
| return "🟩 Inflow" |
| if row["Flow proxy"] < 0 and row["RS vs SPY"] < 0: |
| return "🔴 Outflow + lagging" |
| if row["Flow proxy"] < 0: |
| return "🟥 Outflow" |
| return "—" |
|
|
| d["Read"] = d.apply(_read, axis=1) |
| d["$ Volume (avg)"] = d["Avg $ Volume"].map(_money) |
| d["Flow proxy"] = d["Flow proxy"].map(_money) |
| d["Change"] = d["Change"].map(lambda x: f"{x:+.2%}") |
| d["RS vs SPY"] = d["RS vs SPY"].map(lambda x: f"{x:+.2%}") |
| return d[["Sector", "ETF", "Change", "$ Volume (avg)", "Flow proxy", "RS vs SPY", "Read"]] |
|
|
|
|
| def rotation_brief(df_1d, df_5d, df_20d) -> str: |
| """Plain-text summary fed to the local LLM (and shown as fallback).""" |
| def top_bottom(df, label): |
| if df is None or df.empty: |
| return f"{label}: no data." |
| top = df.head(3) |
| bot = df.tail(3) |
| t = ", ".join(f"{r.Sector} ({r.Change:+.2%}, RS {r['RS vs SPY']:+.2%})" |
| for _, r in top.iterrows()) |
| b = ", ".join(f"{r.Sector} ({r.Change:+.2%}, RS {r['RS vs SPY']:+.2%})" |
| for _, r in bot.iterrows()) |
| return f"{label} — capital flowing INTO: {t}. Flowing OUT OF: {b}." |
| return "\n".join([top_bottom(df_1d, "1-day"), |
| top_bottom(df_5d, "5-day"), |
| top_bottom(df_20d, "20-day")]) |
|
|