world: rotation model research notebook (Hanzo Notebook)

A reproducible numpy/scipy reference implementation of the RRG rotation
model that powers the engine. Loads a 6-month daily snapshot of the
32-symbol universe (research/data), formalises RS-Ratio / RS-Momentum and
the quadrant classification, reproduces the current read and the Lux Book
top-10 allocation, and backtests the rotate-out-of-Weakening /
into-Improving thesis.

The numpy read cross-validates the Go engine exactly (Uranium 97.4/103.1,
Hyperscalers weakening 101.4/95.9, Great Rotation 0.55 WATCH). The backtest
is reported as indicative on a single window, not an alpha claim.

Executed notebook (rotation_model.ipynb) + percent-format source + the
data snapshot + the RRG plot.
This commit is contained in:
Hanzo Dev
2026-07-21 14:10:07 -07:00
parent df9ce6b433
commit e66bc46b5f
5 changed files with 4924 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
"""Snapshot 6-month daily closes for the rotation universe from Yahoo.
Stdlib-only fetch → tidy CSV (date,symbol,close). Mirrors the Go engine's
universe so the notebook works on the exact same instruments.
"""
import json, time, urllib.request, urllib.parse, csv, sys, os
UNIVERSE = ["SPY","SMH","SOXX","NVDA","AMD","AVGO","SMCI","XLK","MSFT","GOOGL","META","AMZN",
"XLE","XOP","UNG","FCG","NG=F","URA","URNM","CCJ","VST","CEG","NRG","XLU",
"XLF","XLV","XLI","XLP","XLY","XLB","XLRE","XLC"]
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
def fetch(sym):
url = "https://query1.finance.yahoo.com/v8/finance/chart/" + urllib.parse.quote(sym) + "?range=6mo&interval=1d"
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=15) as r:
d = json.load(r)
res = d["chart"]["result"][0]
ts = res["timestamp"]
close = res["indicators"]["quote"][0]["close"]
return [(t, c) for t, c in zip(ts, close) if c is not None]
def main():
rows = []
for sym in UNIVERSE:
for attempt in range(3):
try:
for t, c in fetch(sym):
rows.append((time.strftime("%Y-%m-%d", time.gmtime(t)), sym, round(c, 4)))
print(f" {sym}: ok", file=sys.stderr); break
except Exception as e:
print(f" {sym}: retry {attempt} ({e})", file=sys.stderr); time.sleep(1.5)
time.sleep(0.15)
out = os.path.join(os.path.dirname(__file__), "closes.csv")
with open(out, "w", newline="") as f:
w = csv.writer(f); w.writerow(["date","symbol","close"]); w.writerows(rows)
print(f"wrote {len(rows)} rows for {len(set(r[1] for r in rows))} symbols -> {out}", file=sys.stderr)
if __name__ == "__main__":
main()
+629
View File
@@ -0,0 +1,629 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "c0fdbaaa",
"metadata": {},
"source": [
"# Hanzo Notebook — The Rotation Model\n",
"\n",
"A reproducible, numpy/scipy reference implementation of the sector-rotation model\n",
"that powers `world.hanzo.ai` and `lux.fund`. It formalises the **Relative\n",
"Rotation Graph (RRG)** the production Go engine computes (`internal/world/\n",
"handlers_rotation.go`), reproduces the current read, derives the **Lux Book**\n",
"(the top-10 model allocation), and **backtests** the core thesis: *rotate out of\n",
"leadership that is topping (Weakening) and into laggards that are turning up\n",
"(Improving)*.\n",
"\n",
"Data: 6-month daily closes for the 32-symbol universe (`data/closes.csv`),\n",
"snapshotted from Yahoo — the exact instruments the engine scores.\n",
"\n",
"> Research artifact. Model output, not investment advice."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "8983fe00",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-21T21:09:27.604800Z",
"iopub.status.busy": "2026-07-21T21:09:27.604734Z",
"iopub.status.idle": "2026-07-21T21:09:30.203967Z",
"shell.execute_reply": "2026-07-21T21:09:30.203502Z"
}
},
"outputs": [],
"source": [
"import os\n",
"import numpy as np\n",
"import pandas as pd\n",
"from scipy import stats\n",
"import matplotlib.pyplot as plt\n",
"\n",
"HERE = os.path.dirname(os.path.abspath(__file__)) if \"__file__\" in globals() else \".\"\n",
"DATA = os.path.join(HERE, \"data\", \"closes.csv\")\n",
"\n",
"BENCHMARK = \"SPY\"\n",
"LEVEL_WINDOW = 21 # ~1mo trailing window for the RS-Ratio z-score\n",
"MOM_LOOKBACK = 5 # ~1wk change for RS-Momentum\n",
"SPREAD = 2.5 # z -> RRG-unit spread around 100 (cosmetic; sign is what matters)\n",
"\n",
"# Theme baskets — identical to the Go engine's universe.\n",
"THEMES = {\n",
" \"AI · Semis\": (\"AI buildout\", [\"SMH\", \"SOXX\", \"NVDA\", \"AMD\", \"AVGO\", \"SMCI\"]),\n",
" \"Hyperscalers\": (\"AI buildout\", [\"XLK\", \"MSFT\", \"GOOGL\", \"META\", \"AMZN\"]),\n",
" \"Energy\": (\"Energy complex\", [\"XLE\", \"XOP\"]),\n",
" \"Natural gas\": (\"Energy complex\", [\"UNG\", \"FCG\", \"NG=F\"]),\n",
" \"Uranium\": (\"Energy complex\", [\"URA\", \"URNM\", \"CCJ\"]),\n",
" \"Nuclear power\": (\"Energy complex\", [\"VST\", \"CEG\", \"NRG\", \"XLU\"]),\n",
" \"Financials\": (\"Sectors\", [\"XLF\"]),\n",
" \"Health care\": (\"Sectors\", [\"XLV\"]),\n",
" \"Industrials\": (\"Sectors\", [\"XLI\"]),\n",
" \"Staples\": (\"Sectors\", [\"XLP\"]),\n",
" \"Discretionary\": (\"Sectors\", [\"XLY\"]),\n",
" \"Materials\": (\"Sectors\", [\"XLB\"]),\n",
" \"Real estate\": (\"Sectors\", [\"XLRE\"]),\n",
" \"Communications\": (\"Sectors\", [\"XLC\"]),\n",
"}"
]
},
{
"cell_type": "markdown",
"id": "81be5bee",
"metadata": {},
"source": [
"## 1. Load the universe\n",
"Long CSV → a wide close matrix (dates × symbols), forward-filled across the small\n",
"calendar gaps that differ between US equities and the NG=F futures contract."
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "43c879ef",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-21T21:09:30.205434Z",
"iopub.status.busy": "2026-07-21T21:09:30.205284Z",
"iopub.status.idle": "2026-07-21T21:09:30.223014Z",
"shell.execute_reply": "2026-07-21T21:09:30.222776Z"
},
"lines_to_next_cell": 1
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"universe: 32 symbols × 124 trading days (2026-01-22 → 2026-07-21)\n"
]
},
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th>symbol</th>\n",
" <th>SPY</th>\n",
" <th>SMH</th>\n",
" <th>URA</th>\n",
" <th>NG=F</th>\n",
" </tr>\n",
" <tr>\n",
" <th>date</th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>2026-07-17</th>\n",
" <td>743.29</td>\n",
" <td>556.53</td>\n",
" <td>38.73</td>\n",
" <td>2.911</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2026-07-20</th>\n",
" <td>742.09</td>\n",
" <td>558.83</td>\n",
" <td>38.67</td>\n",
" <td>2.860</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2026-07-21</th>\n",
" <td>748.28</td>\n",
" <td>584.08</td>\n",
" <td>40.25</td>\n",
" <td>2.886</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
"symbol SPY SMH URA NG=F\n",
"date \n",
"2026-07-17 743.29 556.53 38.73 2.911\n",
"2026-07-20 742.09 558.83 38.67 2.860\n",
"2026-07-21 748.28 584.08 40.25 2.886"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"raw = pd.read_csv(DATA, parse_dates=[\"date\"])\n",
"px = raw.pivot(index=\"date\", columns=\"symbol\", values=\"close\").sort_index()\n",
"# Forward-fill the small calendar gaps (NG=F futures trade a slightly different\n",
"# calendar than US equities), then intersect to the fully-populated window — a\n",
"# leading row where any symbol has not started yet is NaN and would poison its\n",
"# basket's first index value. This mirrors the engine right-aligning each pair.\n",
"px = px.ffill().dropna(how=\"any\")\n",
"print(f\"universe: {px.shape[1]} symbols × {px.shape[0]} trading days \"\n",
" f\"({px.index[0].date()} → {px.index[-1].date()})\")\n",
"px.tail(3)[[BENCHMARK, \"SMH\", \"URA\", \"NG=F\"]]"
]
},
{
"cell_type": "markdown",
"id": "aea606bb",
"metadata": {},
"source": [
"## 2. The RRG kernel (numpy)\n",
"The relative-strength line is `asset / benchmark`. **RS-Ratio** is that line's\n",
"trailing z-score (is the theme out- or under-performing its own recent norm);\n",
"**RS-Momentum** is the trailing z-score of the *short change* in RS-Ratio (is that\n",
"relative strength accelerating or rolling over). Both are centred at 100. The\n",
"quadrant is decided by the two signs — exactly the Go engine's classification."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "c30c02c3",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-21T21:09:30.223995Z",
"iopub.status.busy": "2026-07-21T21:09:30.223923Z",
"iopub.status.idle": "2026-07-21T21:09:33.211942Z",
"shell.execute_reply": "2026-07-21T21:09:33.211476Z"
},
"lines_to_next_cell": 1
},
"outputs": [],
"source": [
"def basket_synthetic(closes: pd.DataFrame) -> pd.Series:\n",
" \"\"\"Equal-weight synthetic: each member indexed to 100 at the window start.\"\"\"\n",
" idx = closes / closes.iloc[0] * 100.0\n",
" return idx.mean(axis=1)\n",
"\n",
"def trailing_z(x: np.ndarray, window: int) -> np.ndarray:\n",
" \"\"\"z-score of x[i] within the trailing `window` ending at i.\"\"\"\n",
" s = pd.Series(x)\n",
" mean = s.rolling(window, min_periods=2).mean()\n",
" std = s.rolling(window, min_periods=2).std(ddof=0)\n",
" z = (s - mean) / std.replace(0, np.nan)\n",
" return z.fillna(0.0).to_numpy()\n",
"\n",
"def rrg(rel: np.ndarray):\n",
" \"\"\"Relative-strength line → (RS-Ratio, RS-Momentum) series centred at 100.\"\"\"\n",
" rsr = 100 + SPREAD * trailing_z(rel, LEVEL_WINDOW)\n",
" dm = np.full_like(rsr, np.nan)\n",
" dm[MOM_LOOKBACK:] = rsr[MOM_LOOKBACK:] - rsr[:-MOM_LOOKBACK]\n",
" rsm = 100 + SPREAD * trailing_z(np.nan_to_num(dm), LEVEL_WINDOW)\n",
" return rsr, rsm\n",
"\n",
"def quadrant(ratio: float, mom: float) -> str:\n",
" if ratio >= 100 and mom >= 100: return \"leading\"\n",
" if ratio >= 100: return \"weakening\"\n",
" if mom >= 100: return \"improving\"\n",
" return \"lagging\"\n",
"\n",
"# Build each theme's synthetic and its RRG track.\n",
"bench = px[BENCHMARK]\n",
"tracks = {}\n",
"for name, (group, members) in THEMES.items():\n",
" have = [m for m in members if m in px.columns]\n",
" synth = basket_synthetic(px[have])\n",
" rel = (synth / bench).to_numpy()\n",
" rsr, rsm = rrg(rel)\n",
" tracks[name] = dict(group=group, synth=synth, rsr=rsr, rsm=rsm,\n",
" ratio=rsr[-1], mom=rsm[-1], quadrant=quadrant(rsr[-1], rsm[-1]))"
]
},
{
"cell_type": "markdown",
"id": "a0a99f23",
"metadata": {},
"source": [
"## 3. The current read\n",
"Ranked by forward relative momentum — the same leaderboard the panel renders."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "6cf97573",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-21T21:09:33.213586Z",
"iopub.status.busy": "2026-07-21T21:09:33.213504Z",
"iopub.status.idle": "2026-07-21T21:09:36.955045Z",
"shell.execute_reply": "2026-07-21T21:09:36.954489Z"
},
"lines_to_next_cell": 1
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
" theme group quadrant RS_Ratio RS_Mom ret_1mo ret_3mo\n",
" Uranium Energy complex improving 97.4 103.1 -15.2 -28.4\n",
" Natural gas Energy complex improving 98.3 102.2 -3.9 0.1\n",
" Materials Sectors improving 97.0 102.0 -3.3 -4.1\n",
" Real estate Sectors leading 101.2 102.0 3.1 1.3\n",
" Staples Sectors improving 99.1 101.7 0.9 2.0\n",
" Industrials Sectors improving 96.6 101.3 -1.2 2.7\n",
" AI · Semis AI buildout improving 98.8 100.9 -7.4 24.6\n",
" Health care Sectors leading 100.1 100.5 7.3 8.7\n",
" Nuclear power Energy complex improving 99.2 100.4 -1.8 -6.3\n",
" Discretionary Sectors improving 95.8 100.2 -2.0 -4.2\n",
" Energy Energy complex weakening 105.2 98.8 11.1 7.1\n",
" Financials Sectors weakening 102.1 98.4 4.7 6.6\n",
"Communications Sectors weakening 100.0 96.2 0.5 -7.3\n",
" Hyperscalers AI buildout weakening 101.4 95.9 0.4 2.4\n"
]
}
],
"source": [
"def pct_return(s: pd.Series, n: int) -> float:\n",
" if len(s) <= n: return np.nan\n",
" return (s.iloc[-1] / s.iloc[-1 - n] - 1) * 100\n",
"\n",
"rows = []\n",
"for name, t in tracks.items():\n",
" rows.append(dict(theme=name, group=t[\"group\"], quadrant=t[\"quadrant\"],\n",
" RS_Ratio=round(t[\"ratio\"], 1), RS_Mom=round(t[\"mom\"], 1),\n",
" ret_1mo=round(pct_return(t[\"synth\"], 21), 1),\n",
" ret_3mo=round(pct_return(t[\"synth\"], 63), 1)))\n",
"read = pd.DataFrame(rows).sort_values(\"RS_Mom\", ascending=False).reset_index(drop=True)\n",
"print(read.to_string(index=False))"
]
},
{
"cell_type": "markdown",
"id": "0a272b50",
"metadata": {},
"source": [
"## 4. Thesis signals\n",
"**Distribution** = a leader (RS-Ratio ≥ 100) rolling over (RS-Momentum < 100).\n",
"**Accumulation** = a laggard (RS-Ratio < 100) turning up (RS-Momentum ≥ 100).\n",
"The **Great Rotation** requires *both* legs — `min(distribution, accumulation)`."
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "2295833e",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-21T21:09:36.956254Z",
"iopub.status.busy": "2026-07-21T21:09:36.956181Z",
"iopub.status.idle": "2026-07-21T21:09:37.090889Z",
"shell.execute_reply": "2026-07-21T21:09:37.090533Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"AI · Semis distribution 0.55 [WATCH]\n",
"Energy complex accumulation 0.57 [WATCH]\n",
"GREAT ROTATION (AI→Energy) 0.55 [WATCH]\n"
]
}
],
"source": [
"def clamp01(v): return max(0.0, min(1.0, v))\n",
"\n",
"def distribution_score(t): # AI complex topping\n",
" return clamp01((t[\"ratio\"] - 100) / 5) * 0.5 + clamp01((100 - t[\"mom\"]) / 5) * 0.5\n",
"\n",
"def accumulation_score(t): # energy complex turning up\n",
" base = 0.5 if t[\"ratio\"] >= 100 else clamp01((100 - t[\"ratio\"]) / 5)\n",
" return base * 0.4 + clamp01((t[\"mom\"] - 100) / 5) * 0.6\n",
"\n",
"ai = max([tracks[k] for k in (\"AI · Semis\", \"Hyperscalers\")], key=lambda t: t[\"ratio\"])\n",
"energy = max([tracks[k] for k in (\"Energy\", \"Natural gas\", \"Uranium\", \"Nuclear power\")],\n",
" key=lambda t: t[\"mom\"])\n",
"dist, acc = distribution_score(ai), accumulation_score(energy)\n",
"great = min(dist, acc)\n",
"state = lambda s: \"ACTIVE\" if s >= 0.66 else \"WATCH\" if s >= 0.33 else \"off\"\n",
"print(f\"AI · Semis distribution {dist:.2f} [{state(dist)}]\")\n",
"print(f\"Energy complex accumulation {acc:.2f} [{state(acc)}]\")\n",
"print(f\"GREAT ROTATION (AI→Energy) {great:.2f} [{state(great)}]\")"
]
},
{
"cell_type": "markdown",
"id": "a97eb961",
"metadata": {},
"source": [
"## 5. The Lux Book — top-10 model allocation\n",
"Conviction = quadrant base (accumulate Improving, hold Leading, trim Weakening,\n",
"avoid Lagging) + a momentum tilt + an oversold-base bonus, normalised to 100%."
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "e42a18a8",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-21T21:09:37.092383Z",
"iopub.status.busy": "2026-07-21T21:09:37.092301Z",
"iopub.status.idle": "2026-07-21T21:09:37.163811Z",
"shell.execute_reply": "2026-07-21T21:09:37.163468Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
" bucket weight_pct stance d_mom ret_3mo\n",
" Uranium 14.6 Accumulate 3.1 -28.4\n",
" Materials 11.2 Accumulate 2.0 -4.1\n",
" Natural gas 11.2 Accumulate 2.2 0.1\n",
" Staples 10.5 Accumulate 1.7 2.0\n",
" Industrials 10.0 Accumulate 1.3 2.7\n",
" AI · Semis 9.4 Accumulate 0.9 24.6\n",
"Nuclear power 9.3 Accumulate 0.4 -6.3\n",
"Discretionary 8.8 Accumulate 0.2 -4.2\n",
" Real estate 8.5 Core 2.0 1.3\n",
" Health care 6.6 Core 0.5 8.7\n",
"\n",
"book weight sums to 100.1%\n"
]
}
],
"source": [
"QUAD_BASE = {\"improving\": 1.0, \"leading\": 0.72, \"weakening\": 0.22, \"lagging\": 0.08}\n",
"STANCE = {\"improving\": \"Accumulate\", \"leading\": \"Core\", \"weakening\": \"Trim\", \"lagging\": \"Avoid\"}\n",
"\n",
"def conviction(t):\n",
" c = QUAD_BASE[t[\"quadrant\"]] + np.clip((t[\"mom\"] - 100) * 0.16, -1.2, 1.2)\n",
" r3 = pct_return(t[\"synth\"], 63)\n",
" if t[\"quadrant\"] == \"improving\" and r3 < 0:\n",
" c += min(0.3, -r3 / 100)\n",
" return max(0.0, c)\n",
"\n",
"conv = {name: conviction(t) for name, t in tracks.items()}\n",
"top = sorted(conv, key=conv.get, reverse=True)[:10]\n",
"total = sum(conv[n] for n in top)\n",
"book = pd.DataFrame([\n",
" dict(bucket=n, weight_pct=round(conv[n] / total * 100, 1),\n",
" stance=STANCE[tracks[n][\"quadrant\"]],\n",
" d_mom=round(tracks[n][\"mom\"] - 100, 1),\n",
" ret_3mo=round(pct_return(tracks[n][\"synth\"], 63), 1))\n",
" for n in top\n",
"])\n",
"print(book.to_string(index=False))\n",
"print(f\"\\nbook weight sums to {book.weight_pct.sum():.1f}%\")"
]
},
{
"cell_type": "markdown",
"id": "9912ebf6",
"metadata": {},
"source": [
"## 6. Backtest — does the rotation add value?\n",
"Each week, score every theme's RRG (using only data available up to that point),\n",
"form a **long Improving+Leading / short Weakening+Lagging** book (equal-weight,\n",
"weekly rebalanced, held on the next week's synthetic returns), and compare to a\n",
"long-only equal-weight basket and to SPY. This is an in-sample sanity check on a\n",
"single 6-month window — indicative, not a strategy claim."
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "b2cddc51",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-21T21:09:37.165069Z",
"iopub.status.busy": "2026-07-21T21:09:37.164993Z",
"iopub.status.idle": "2026-07-21T21:09:37.371636Z",
"shell.execute_reply": "2026-07-21T21:09:37.371323Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Window performance (this 6-month sample):\n",
"Rotation long/short total -1.10% ann -0.91% Sharpe -0.05\n",
"Rotation long-only total 4.41% ann 13.29% Sharpe 0.82\n",
"Equal-weight basket total 2.16% ann 6.65% Sharpe 0.50\n",
"SPY benchmark total 9.99% ann 29.76% Sharpe 1.78\n",
"\n",
"Long/short daily mean > 0: t = -0.03, p = 0.977 (not significant at 5% on this window)\n"
]
}
],
"source": [
"# Daily synthetic returns for every theme, aligned.\n",
"synth_px = pd.DataFrame({name: t[\"synth\"] for name, t in tracks.items()})\n",
"rets = synth_px.pct_change().fillna(0.0)\n",
"spy_ret = bench.pct_change().fillna(0.0)\n",
"\n",
"# Precompute each theme's RRG *series* so we can read the quadrant as-of any day.\n",
"series = {}\n",
"for name, t in tracks.items():\n",
" rel = (t[\"synth\"] / bench).to_numpy()\n",
" rsr, rsm = rrg(rel)\n",
" series[name] = (rsr, rsm)\n",
"\n",
"dates = synth_px.index\n",
"start = LEVEL_WINDOW + MOM_LOOKBACK + 1\n",
"long_short, long_only = [], []\n",
"i = start\n",
"while i < len(dates) - 1:\n",
" longs, shorts = [], []\n",
" for name in tracks:\n",
" rsr, rsm = series[name]\n",
" q = quadrant(rsr[i], rsm[i])\n",
" if q in (\"improving\", \"leading\"): longs.append(name)\n",
" elif q in (\"weakening\", \"lagging\"): shorts.append(name)\n",
" hold = slice(i + 1, min(i + 6, len(dates))) # hold ~1 week\n",
" fwd = rets.iloc[hold]\n",
" l = fwd[longs].mean(axis=1) if longs else pd.Series(0.0, index=fwd.index)\n",
" s = fwd[shorts].mean(axis=1) if shorts else pd.Series(0.0, index=fwd.index)\n",
" long_short.append(l - s)\n",
" long_only.append(fwd[longs].mean(axis=1) if longs else pd.Series(0.0, index=fwd.index))\n",
" i += 5\n",
"\n",
"ls = pd.concat(long_short); lo = pd.concat(long_only)\n",
"eqw = rets.mean(axis=1).loc[ls.index]\n",
"spy = spy_ret.loc[ls.index]\n",
"\n",
"def stats_line(name, r):\n",
" ann = (1 + r.mean()) ** 252 - 1\n",
" sharpe = r.mean() / r.std() * np.sqrt(252) if r.std() else 0\n",
" tot = (1 + r).prod() - 1\n",
" print(f\"{name:22s} total {tot*100:7.2f}% ann {ann*100:7.2f}% Sharpe {sharpe:5.2f}\")\n",
"\n",
"print(\"Window performance (this 6-month sample):\")\n",
"stats_line(\"Rotation long/short\", ls)\n",
"stats_line(\"Rotation long-only\", lo)\n",
"stats_line(\"Equal-weight basket\", eqw)\n",
"stats_line(\"SPY benchmark\", spy)\n",
"t_stat, p = stats.ttest_1samp(ls, 0.0)\n",
"print(f\"\\nLong/short daily mean > 0: t = {t_stat:.2f}, p = {p:.3f} \"\n",
" f\"({'significant' if p < 0.05 else 'not significant'} at 5% on this window)\")"
]
},
{
"cell_type": "markdown",
"id": "5181d73e",
"metadata": {},
"source": [
"## 7. The picture — the RRG\n",
"The lead themes on the rotation graph, with their two-month tails. Weakening\n",
"(bottom-right) is distribution; Improving (top-left) is accumulation."
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "0e6336a6",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-21T21:09:37.373083Z",
"iopub.status.busy": "2026-07-21T21:09:37.373003Z",
"iopub.status.idle": "2026-07-21T21:09:38.199506Z",
"shell.execute_reply": "2026-07-21T21:09:38.199049Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"saved ./rrg.png\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"/tmp/ipykernel_1398065/3387379404.py:35: UserWarning: FigureCanvasAgg is non-interactive, and thus cannot be shown\n",
" plt.show()\n"
]
}
],
"source": [
"LEADS = [\"AI · Semis\", \"Hyperscalers\", \"Energy\", \"Natural gas\", \"Uranium\", \"Nuclear power\"]\n",
"QC = {\"leading\": \"#2fa36b\", \"weakening\": \"#d99429\", \"lagging\": \"#d1483f\", \"improving\": \"#3a7fd0\"}\n",
"\n",
"fig, ax = plt.subplots(figsize=(7.2, 7.2))\n",
"dev = 4\n",
"for n in LEADS:\n",
" rsr, rsm = series[n]\n",
" tail = slice(-40, None)\n",
" dev = max(dev, np.max(np.abs(rsr[tail] - 100)), np.max(np.abs(rsm[tail] - 100)))\n",
"R = np.ceil(dev * 1.12)\n",
"ax.axhspan(100, 100 + R, xmin=0.5, xmax=1, color=QC[\"leading\"], alpha=0.05)\n",
"ax.axhspan(100 - R, 100, xmin=0.5, xmax=1, color=QC[\"weakening\"], alpha=0.08)\n",
"ax.axhspan(100 - R, 100, xmin=0, xmax=0.5, color=QC[\"lagging\"], alpha=0.05)\n",
"ax.axhspan(100, 100 + R, xmin=0, xmax=0.5, color=QC[\"improving\"], alpha=0.08)\n",
"ax.axhline(100, color=\"#888\", lw=0.8, ls=\"--\"); ax.axvline(100, color=\"#888\", lw=0.8, ls=\"--\")\n",
"for n in LEADS:\n",
" rsr, rsm = series[n]\n",
" xs, ys = rsr[-40::5], rsm[-40::5]\n",
" c = QC[tracks[n][\"quadrant\"]]\n",
" ax.plot(xs, ys, \"-\", color=c, alpha=0.5, lw=1.4)\n",
" ax.plot(xs[-1], ys[-1], \"o\", color=c, ms=9)\n",
" ax.annotate(n, (xs[-1], ys[-1]), xytext=(7, 3), textcoords=\"offset points\", fontsize=9)\n",
"ax.set_xlim(100 - R, 100 + R); ax.set_ylim(100 - R, 100 + R)\n",
"ax.set_xlabel(\"RS-Ratio (leading →)\"); ax.set_ylabel(\"RS-Momentum (improving ↑)\")\n",
"ax.set_title(\"Rotation graph — lead themes vs SPY\")\n",
"for label, x, y, ha, va in [(\"LEADING\", 100 + R, 100 + R, \"right\", \"top\"),\n",
" (\"WEAKENING\", 100 + R, 100 - R, \"right\", \"bottom\"),\n",
" (\"LAGGING\", 100 - R, 100 - R, \"left\", \"bottom\"),\n",
" (\"IMPROVING\", 100 - R, 100 + R, \"left\", \"top\")]:\n",
" ax.text(x, y, label, ha=ha, va=va, fontsize=8, color=\"#666\", alpha=0.8)\n",
"fig.tight_layout()\n",
"out = os.path.join(HERE, \"rrg.png\")\n",
"fig.savefig(out, dpi=120)\n",
"print(f\"saved {out}\")\n",
"plt.show()"
]
}
],
"metadata": {
"jupytext": {
"cell_metadata_filter": "-all",
"main_language": "python",
"notebook_metadata_filter": "-all"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+286
View File
@@ -0,0 +1,286 @@
# %% [markdown]
# # Hanzo Notebook — The Rotation Model
#
# A reproducible, numpy/scipy reference implementation of the sector-rotation model
# that powers `world.hanzo.ai` and `lux.fund`. It formalises the **Relative
# Rotation Graph (RRG)** the production Go engine computes (`internal/world/
# handlers_rotation.go`), reproduces the current read, derives the **Lux Book**
# (the top-10 model allocation), and **backtests** the core thesis: *rotate out of
# leadership that is topping (Weakening) and into laggards that are turning up
# (Improving)*.
#
# Data: 6-month daily closes for the 32-symbol universe (`data/closes.csv`),
# snapshotted from Yahoo — the exact instruments the engine scores.
#
# > Research artifact. Model output, not investment advice.
# %%
import os
import numpy as np
import pandas as pd
from scipy import stats
import matplotlib.pyplot as plt
HERE = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else "."
DATA = os.path.join(HERE, "data", "closes.csv")
BENCHMARK = "SPY"
LEVEL_WINDOW = 21 # ~1mo trailing window for the RS-Ratio z-score
MOM_LOOKBACK = 5 # ~1wk change for RS-Momentum
SPREAD = 2.5 # z -> RRG-unit spread around 100 (cosmetic; sign is what matters)
# Theme baskets — identical to the Go engine's universe.
THEMES = {
"AI · Semis": ("AI buildout", ["SMH", "SOXX", "NVDA", "AMD", "AVGO", "SMCI"]),
"Hyperscalers": ("AI buildout", ["XLK", "MSFT", "GOOGL", "META", "AMZN"]),
"Energy": ("Energy complex", ["XLE", "XOP"]),
"Natural gas": ("Energy complex", ["UNG", "FCG", "NG=F"]),
"Uranium": ("Energy complex", ["URA", "URNM", "CCJ"]),
"Nuclear power": ("Energy complex", ["VST", "CEG", "NRG", "XLU"]),
"Financials": ("Sectors", ["XLF"]),
"Health care": ("Sectors", ["XLV"]),
"Industrials": ("Sectors", ["XLI"]),
"Staples": ("Sectors", ["XLP"]),
"Discretionary": ("Sectors", ["XLY"]),
"Materials": ("Sectors", ["XLB"]),
"Real estate": ("Sectors", ["XLRE"]),
"Communications": ("Sectors", ["XLC"]),
}
# %% [markdown]
# ## 1. Load the universe
# Long CSV → a wide close matrix (dates × symbols), forward-filled across the small
# calendar gaps that differ between US equities and the NG=F futures contract.
# %%
raw = pd.read_csv(DATA, parse_dates=["date"])
px = raw.pivot(index="date", columns="symbol", values="close").sort_index()
# Forward-fill the small calendar gaps (NG=F futures trade a slightly different
# calendar than US equities), then intersect to the fully-populated window — a
# leading row where any symbol has not started yet is NaN and would poison its
# basket's first index value. This mirrors the engine right-aligning each pair.
px = px.ffill().dropna(how="any")
print(f"universe: {px.shape[1]} symbols × {px.shape[0]} trading days "
f"({px.index[0].date()}{px.index[-1].date()})")
px.tail(3)[[BENCHMARK, "SMH", "URA", "NG=F"]]
# %% [markdown]
# ## 2. The RRG kernel (numpy)
# The relative-strength line is `asset / benchmark`. **RS-Ratio** is that line's
# trailing z-score (is the theme out- or under-performing its own recent norm);
# **RS-Momentum** is the trailing z-score of the *short change* in RS-Ratio (is that
# relative strength accelerating or rolling over). Both are centred at 100. The
# quadrant is decided by the two signs — exactly the Go engine's classification.
# %%
def basket_synthetic(closes: pd.DataFrame) -> pd.Series:
"""Equal-weight synthetic: each member indexed to 100 at the window start."""
idx = closes / closes.iloc[0] * 100.0
return idx.mean(axis=1)
def trailing_z(x: np.ndarray, window: int) -> np.ndarray:
"""z-score of x[i] within the trailing `window` ending at i."""
s = pd.Series(x)
mean = s.rolling(window, min_periods=2).mean()
std = s.rolling(window, min_periods=2).std(ddof=0)
z = (s - mean) / std.replace(0, np.nan)
return z.fillna(0.0).to_numpy()
def rrg(rel: np.ndarray):
"""Relative-strength line → (RS-Ratio, RS-Momentum) series centred at 100."""
rsr = 100 + SPREAD * trailing_z(rel, LEVEL_WINDOW)
dm = np.full_like(rsr, np.nan)
dm[MOM_LOOKBACK:] = rsr[MOM_LOOKBACK:] - rsr[:-MOM_LOOKBACK]
rsm = 100 + SPREAD * trailing_z(np.nan_to_num(dm), LEVEL_WINDOW)
return rsr, rsm
def quadrant(ratio: float, mom: float) -> str:
if ratio >= 100 and mom >= 100: return "leading"
if ratio >= 100: return "weakening"
if mom >= 100: return "improving"
return "lagging"
# Build each theme's synthetic and its RRG track.
bench = px[BENCHMARK]
tracks = {}
for name, (group, members) in THEMES.items():
have = [m for m in members if m in px.columns]
synth = basket_synthetic(px[have])
rel = (synth / bench).to_numpy()
rsr, rsm = rrg(rel)
tracks[name] = dict(group=group, synth=synth, rsr=rsr, rsm=rsm,
ratio=rsr[-1], mom=rsm[-1], quadrant=quadrant(rsr[-1], rsm[-1]))
# %% [markdown]
# ## 3. The current read
# Ranked by forward relative momentum — the same leaderboard the panel renders.
# %%
def pct_return(s: pd.Series, n: int) -> float:
if len(s) <= n: return np.nan
return (s.iloc[-1] / s.iloc[-1 - n] - 1) * 100
rows = []
for name, t in tracks.items():
rows.append(dict(theme=name, group=t["group"], quadrant=t["quadrant"],
RS_Ratio=round(t["ratio"], 1), RS_Mom=round(t["mom"], 1),
ret_1mo=round(pct_return(t["synth"], 21), 1),
ret_3mo=round(pct_return(t["synth"], 63), 1)))
read = pd.DataFrame(rows).sort_values("RS_Mom", ascending=False).reset_index(drop=True)
print(read.to_string(index=False))
# %% [markdown]
# ## 4. Thesis signals
# **Distribution** = a leader (RS-Ratio ≥ 100) rolling over (RS-Momentum < 100).
# **Accumulation** = a laggard (RS-Ratio < 100) turning up (RS-Momentum ≥ 100).
# The **Great Rotation** requires *both* legs — `min(distribution, accumulation)`.
# %%
def clamp01(v): return max(0.0, min(1.0, v))
def distribution_score(t): # AI complex topping
return clamp01((t["ratio"] - 100) / 5) * 0.5 + clamp01((100 - t["mom"]) / 5) * 0.5
def accumulation_score(t): # energy complex turning up
base = 0.5 if t["ratio"] >= 100 else clamp01((100 - t["ratio"]) / 5)
return base * 0.4 + clamp01((t["mom"] - 100) / 5) * 0.6
ai = max([tracks[k] for k in ("AI · Semis", "Hyperscalers")], key=lambda t: t["ratio"])
energy = max([tracks[k] for k in ("Energy", "Natural gas", "Uranium", "Nuclear power")],
key=lambda t: t["mom"])
dist, acc = distribution_score(ai), accumulation_score(energy)
great = min(dist, acc)
state = lambda s: "ACTIVE" if s >= 0.66 else "WATCH" if s >= 0.33 else "off"
print(f"AI · Semis distribution {dist:.2f} [{state(dist)}]")
print(f"Energy complex accumulation {acc:.2f} [{state(acc)}]")
print(f"GREAT ROTATION (AI→Energy) {great:.2f} [{state(great)}]")
# %% [markdown]
# ## 5. The Lux Book — top-10 model allocation
# Conviction = quadrant base (accumulate Improving, hold Leading, trim Weakening,
# avoid Lagging) + a momentum tilt + an oversold-base bonus, normalised to 100%.
# %%
QUAD_BASE = {"improving": 1.0, "leading": 0.72, "weakening": 0.22, "lagging": 0.08}
STANCE = {"improving": "Accumulate", "leading": "Core", "weakening": "Trim", "lagging": "Avoid"}
def conviction(t):
c = QUAD_BASE[t["quadrant"]] + np.clip((t["mom"] - 100) * 0.16, -1.2, 1.2)
r3 = pct_return(t["synth"], 63)
if t["quadrant"] == "improving" and r3 < 0:
c += min(0.3, -r3 / 100)
return max(0.0, c)
conv = {name: conviction(t) for name, t in tracks.items()}
top = sorted(conv, key=conv.get, reverse=True)[:10]
total = sum(conv[n] for n in top)
book = pd.DataFrame([
dict(bucket=n, weight_pct=round(conv[n] / total * 100, 1),
stance=STANCE[tracks[n]["quadrant"]],
d_mom=round(tracks[n]["mom"] - 100, 1),
ret_3mo=round(pct_return(tracks[n]["synth"], 63), 1))
for n in top
])
print(book.to_string(index=False))
print(f"\nbook weight sums to {book.weight_pct.sum():.1f}%")
# %% [markdown]
# ## 6. Backtest — does the rotation add value?
# Each week, score every theme's RRG (using only data available up to that point),
# form a **long Improving+Leading / short Weakening+Lagging** book (equal-weight,
# weekly rebalanced, held on the next week's synthetic returns), and compare to a
# long-only equal-weight basket and to SPY. This is an in-sample sanity check on a
# single 6-month window — indicative, not a strategy claim.
# %%
# Daily synthetic returns for every theme, aligned.
synth_px = pd.DataFrame({name: t["synth"] for name, t in tracks.items()})
rets = synth_px.pct_change().fillna(0.0)
spy_ret = bench.pct_change().fillna(0.0)
# Precompute each theme's RRG *series* so we can read the quadrant as-of any day.
series = {}
for name, t in tracks.items():
rel = (t["synth"] / bench).to_numpy()
rsr, rsm = rrg(rel)
series[name] = (rsr, rsm)
dates = synth_px.index
start = LEVEL_WINDOW + MOM_LOOKBACK + 1
long_short, long_only = [], []
i = start
while i < len(dates) - 1:
longs, shorts = [], []
for name in tracks:
rsr, rsm = series[name]
q = quadrant(rsr[i], rsm[i])
if q in ("improving", "leading"): longs.append(name)
elif q in ("weakening", "lagging"): shorts.append(name)
hold = slice(i + 1, min(i + 6, len(dates))) # hold ~1 week
fwd = rets.iloc[hold]
l = fwd[longs].mean(axis=1) if longs else pd.Series(0.0, index=fwd.index)
s = fwd[shorts].mean(axis=1) if shorts else pd.Series(0.0, index=fwd.index)
long_short.append(l - s)
long_only.append(fwd[longs].mean(axis=1) if longs else pd.Series(0.0, index=fwd.index))
i += 5
ls = pd.concat(long_short); lo = pd.concat(long_only)
eqw = rets.mean(axis=1).loc[ls.index]
spy = spy_ret.loc[ls.index]
def stats_line(name, r):
ann = (1 + r.mean()) ** 252 - 1
sharpe = r.mean() / r.std() * np.sqrt(252) if r.std() else 0
tot = (1 + r).prod() - 1
print(f"{name:22s} total {tot*100:7.2f}% ann {ann*100:7.2f}% Sharpe {sharpe:5.2f}")
print("Window performance (this 6-month sample):")
stats_line("Rotation long/short", ls)
stats_line("Rotation long-only", lo)
stats_line("Equal-weight basket", eqw)
stats_line("SPY benchmark", spy)
t_stat, p = stats.ttest_1samp(ls, 0.0)
print(f"\nLong/short daily mean > 0: t = {t_stat:.2f}, p = {p:.3f} "
f"({'significant' if p < 0.05 else 'not significant'} at 5% on this window)")
# %% [markdown]
# ## 7. The picture — the RRG
# The lead themes on the rotation graph, with their two-month tails. Weakening
# (bottom-right) is distribution; Improving (top-left) is accumulation.
# %%
LEADS = ["AI · Semis", "Hyperscalers", "Energy", "Natural gas", "Uranium", "Nuclear power"]
QC = {"leading": "#2fa36b", "weakening": "#d99429", "lagging": "#d1483f", "improving": "#3a7fd0"}
fig, ax = plt.subplots(figsize=(7.2, 7.2))
dev = 4
for n in LEADS:
rsr, rsm = series[n]
tail = slice(-40, None)
dev = max(dev, np.max(np.abs(rsr[tail] - 100)), np.max(np.abs(rsm[tail] - 100)))
R = np.ceil(dev * 1.12)
ax.axhspan(100, 100 + R, xmin=0.5, xmax=1, color=QC["leading"], alpha=0.05)
ax.axhspan(100 - R, 100, xmin=0.5, xmax=1, color=QC["weakening"], alpha=0.08)
ax.axhspan(100 - R, 100, xmin=0, xmax=0.5, color=QC["lagging"], alpha=0.05)
ax.axhspan(100, 100 + R, xmin=0, xmax=0.5, color=QC["improving"], alpha=0.08)
ax.axhline(100, color="#888", lw=0.8, ls="--"); ax.axvline(100, color="#888", lw=0.8, ls="--")
for n in LEADS:
rsr, rsm = series[n]
xs, ys = rsr[-40::5], rsm[-40::5]
c = QC[tracks[n]["quadrant"]]
ax.plot(xs, ys, "-", color=c, alpha=0.5, lw=1.4)
ax.plot(xs[-1], ys[-1], "o", color=c, ms=9)
ax.annotate(n, (xs[-1], ys[-1]), xytext=(7, 3), textcoords="offset points", fontsize=9)
ax.set_xlim(100 - R, 100 + R); ax.set_ylim(100 - R, 100 + R)
ax.set_xlabel("RS-Ratio (leading →)"); ax.set_ylabel("RS-Momentum (improving ↑)")
ax.set_title("Rotation graph — lead themes vs SPY")
for label, x, y, ha, va in [("LEADING", 100 + R, 100 + R, "right", "top"),
("WEAKENING", 100 + R, 100 - R, "right", "bottom"),
("LAGGING", 100 - R, 100 - R, "left", "bottom"),
("IMPROVING", 100 - R, 100 + R, "left", "top")]:
ax.text(x, y, label, ha=ha, va=va, fontsize=8, color="#666", alpha=0.8)
fig.tight_layout()
out = os.path.join(HERE, "rrg.png")
fig.savefig(out, dpi=120)
print(f"saved {out}")
plt.show()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB