Chan-Compass / rotation.py
ranranrunforit's picture
Upload 17 files
3d1576e verified
Raw
History Blame Contribute Delete
4.73 kB
"""
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")])