Rotation Map v2: framework, evidence and full code
Read this first
- What it is. A daily system that says where money should sit: CASH, BTC, or ALTS (six baskets: ETH, SOL, large caps, memes, AI, DeFi), and which alt basket is topping. It posts a Telegram alert when the call changes, a basket turns red or green, or the trench window opens.
- Goal. Catch the major rotations between cash, BTC and alts as early as possible, with as few wrong switches as possible.
- Current call (2026-09-27 close): HOLD BTC. BTC risk is on. Alts have not washed out and turned, so they are not the trade yet. BTC is 21.1% above its 100 day average. Alt clock: 90 day 58%, 30 day 89%.
- Scoreboard. v2 catches 6 of 7 major rotations since June 2020 (median 2 days late). v1 caught 4 of 7 on the same scoreboard.
- Track record (next day fills, 0.3% per trade, cash earns 0). 2020-06 to 2024-06: book +4278% (max drop -53%), BTC risk switch only +719% (max drop -52%), hold BTC +558% (max drop -77%). 2024-07 to now: book +68% (max drop -30%), switch only +42% (max drop -30%), hold BTC +36% (max drop -53%).
- Honesty flags for the reviewer. (1) v1 rules were designed on 2024-07 to 2026-09 and tested on 2020-06 to 2024-06. v2 changed ONE knob (risk average 50 to 100 days) after seeing both periods, so 2020-24 is no longer clean out of sample. (2) Alt baskets are coins that still trade today: survivorship flatters 2020-21. (3) Seven major rotations is a small sample. (4) All data is daily closes from OKX spot, Hyperliquid funding, DefiLlama.
- What I want reviewed. Look for lookahead, overfitting, and bugs. Specific questions are in section 8.
1 · What counts as a major rotation (the target)
A rotation is money moving between three places: cash (stablecoins), BTC, and alts. It is measured by relative returns, never by BTC dominance: a new listing can move dominance when no money has moved.
To grade any rule, major_rotations() labels history with hindsight:
- Each day since 2020-06-01 gets the best place to be over the next 45 days (CASH = 0%, BTC, or ALTS = equal weight of the six alt baskets).
- Stretches shorter than 21 days are merged into the longer neighbour.
- A stretch is a major rotation when its winner beat the next best place by 20% or more over the stretch.
- A rule caught it when the book held that place for at least half of the stretch. Lag = days from the start of the stretch to the first day held.
Hindsight is used only here, to grade. No trading rule reads it.
| Stretch | Best place | Won by | BTC | Alts | Days late (v2) | Time held (v2) | Caught |
|---|---|---|---|---|---|---|---|
| 2020-06-01 to 2020-07-31 | ALTS | +20% | +16% | +39% | 2 | 97% | yes |
| 2020-09-03 to 2020-12-07 | BTC | +81% | +81% | -6% | 10 | 74% | yes |
| 2020-12-08 to 2021-04-12 | ALTS | +72% | +247% | +497% | 0 | 100% | yes |
| 2021-10-20 to 2022-06-10 | CASH | +132% | -57% | -77% | 45 | 73% | yes |
| 2022-07-27 to 2022-11-19 | CASH | +41% | -29% | -39% | 0 | 96% | yes |
| 2023-08-31 to 2024-05-11 | ALTS | +26% | +137% | +200% | 68 | 30% | MISSED |
| 2025-09-01 to 2026-03-02 | CASH | +60% | -37% | -62% | 0 | 88% | yes |
2 · The framework (v2)
Data
- OKX spot, UTC daily candles (
bar=1Dutc), closed candles only (confirm flag = 1). Plain1Dis Hong Kong aligned and mislabels every day by one. - 65 coins in 7 baskets (lists in
BASKETSin the code). Equal weight, daily rebalanced. A coin joins 30 days after its first candle, so a listing pump is never the basket rising. Single day log moves capped at ±50%. - Hyperliquid hourly funding from 2024 (display only). DefiLlama launchpad and DEX volume via the older trench collector (
rot.db).
Layer 1 · Risk switch
BTC above its 100 day average = risk on. It turns on at +1% above and off at -1% below (hysteresis, so a wobble cannot flip it). Risk off = everything to cash.
Layer 2 · Alt clock and the alt bottom
- Alt clock = share of the 64 alts whose return beats BTC's, over 90 days (slow) and 30 days (fast).
- Alt bottom confirmed (green) = the 90 day clock was at 5% or less inside the last 60 days, the 30 day clock is back to 40%+, and risk is on. Only then does the book move into alts (equal weight across the six alt baskets).
- The book leaves alts when risk turns off.
Layer 3 · Basket tops
For a basket with a clock (MAJORS, MEMES, AI, DEFI): extended (light red) = 70%+ of its members beat BTC over 90 days at some point in the last 30 days. Top confirmed (red) = extended AND under 50% of members now beat BTC over 30 days. A red basket is dropped from the book and its weight moves to BTC.
The call (one action word)
GO TO CASH / STAY IN CASH (risk off) · ROTATE INTO ALTS (alt bottom confirmed) · HOLD ALTS, TRIM X (inside an alt rotation, X red) · BUY BTC / HOLD BTC (risk on, no alt bottom).
Trench window (activity, not a trade)
BTC 7 day return crosses above +10% → 14 day window. Launchpad volume was higher 14 days later in 11 of 14 cases since 2024. As a meme price signal it is weak (section 4).
Backtest rules
A decision made on a day's close is filled at the next day's close. 0.3% cost on the fraction of the book that moves. Cash earns 0. Periods: 2020-06-01 to 2024-06-30 and 2024-07-01 to now.
3 · The five iterations of 28 Sep 2026
Iteration 1 · Build the target and grade v1
- Built the hindsight scoreboard above. v1 (50 day switch) caught 4 of 7. Misses: the Jun-Jul 2020 alt run (held 34%), the Sep-Dec 2020 BTC only run (48%), the 2023-24 alt run (27%, 68 days late).
- Found a data bug: a basket needed 3 live coins before its index started, so MEMES began in 2023 and the 2021 DOGE rotation was invisible. Fixed: a basket starts with its first live coin.
- The core problem it exposed: "own alts whenever BTC trends" made +4914% in 2020-24 but lost 16% since 2024 (max drop -68%). The hard part is telling an alt season from an alt bleed in advance.
Iteration 2 · Earlier ways in and out of alts
- Tested 16 relative strength gates (alts/BTC ratio above its 30, 50, 100 day trend with 0, 2, 5% bands; ETH/BTC; each basket vs BTC; book plus relative strength) and 4 breadth gates. Each was run on both the old and the new risk switch.
- Result: all strong in 2020-24. None beat the book in both periods, and most lost money since 2024. Rejected.
- Tested the risk switch itself: 20, 30, 50, 100, 200 day averages × 0, 1, 3% bands, plus three multi-average ensembles, each on its own and under the book.
- Result: under the book, 100 days with a 0, 1 or 3% band, and 150 days, beat 50 days on return and max drop in both periods. 125 days had smaller drops but made less since 2024 (+29% vs +36%); 75 days was worse in 2020-24 (+1724% vs +3269%). 100 days, 1% band: 2020-24 +4278% vs +3268%, max drop -53% vs -59%; since 2024 +67% vs +35%, max drop -30% vs -48%. Adopted.
- Why it works: a slower switch stops the book getting shaken out of alts during the dips inside an alt season. On BTC alone, 100 days is NOT better than 50. Its value comes only through the alt layer.
Iteration 3 · Robustness
- Calendar years, each started fresh: worst year -34% for 100 days vs -52% for 50 days (hold BTC -65%). The 75, 150 day and ensemble versions sit in the same range.
- Thresholds: the top level (60, 70, 80%) and wash level (3, 5, 10%) all stay between +3378% and +4763% in 2020-24 and +44% to +68% since.
- Costs: 1% per trade still +3035% / +48%. 2% per trade still +1835% / +24%.
- Late fills: 2 days +4921% / +46%, 4 days +5458% / +33%.
- Survivorship stress: removing the best 1, 2 or 3 coins of 2020-21 from every basket still leaves +2271% to +3292% in 2020-24.
Iteration 4 · Liquidity, trenches, and other add-ons
- Stablecoin supply growth as an extra risk condition: smaller drops in 2020-24 (-38% vs -53%) but less return since 2024 (+55% vs +68%). As a predictor of alts vs BTC it has none (terciles flat or inverted). Rejected as a trigger.
- Trench window on meme prices: memes beat BTC over 14 days in 19 of 36 cases in 2020-24 (base rate 39%) and 5 of 11 since. Weak. Kept only as an activity signal (launchpad volume).
- Breadth thrust as an extra way into alts (30 day clock 60, 70, 80%): none beat the book in both periods. Rejected.
- Market wide alt top as an automatic exit: 2020-24 falls from +4278% to +1807%. Rejected, shown as a warning.
- Correction: funding crowding was used as a warning colour (ETH and SOL "crowded"). Rechecked: baskets in their top fifth of funding did better over the next 30 days (-1.3% vs -2.4%; top twentieth +29%, n=6). Removed as a colour, kept as a number.
Iteration 5 · Rebuild, deploy, verify
rotmap.pyv2: 100 day switch, first-coin baskets, UTC candles, crowding colour removed, scoreboard computed on every run and published inrotmap.json(rotations,score,settings).- Dashboard: new section "Did it catch the big rotations?", every evidence line updated, rejected signals listed with their numbers, the reuse of 2020-24 disclosed.
- Verified: the service output on the droplet matches the lab to the decimal; the page renders at 390 and 1280 px with no console errors.
4 · Everything that was rejected
| Idea | Result |
|---|---|
| Alts/BTC, ETH/BTC and per basket relative strength trends | 16 versions, none beat the book in both periods |
| Breadth gate or thrust entry | 7 versions, none beat the book in both periods |
| Market wide alt top as exit | 2020-24 +1807% vs +4278% |
| Stablecoin supply growth | helps 2020-24 drops, costs return since 2024, no forecast power |
| Funding crowding as a warning | crowded did better, not worse |
| A basket bouncing off its own low | beat BTC 19 to 33% of the time |
| Basket momentum and "extension" as forecasts (v1 study) | no forecast power |
| BTC dominance | not used: listings move it without money moving |
5 · Operations
- Droplet
/opt/rotation/rotmap.py, cron25 */4 * * *UTC (07:25 Bangkok is the first read after each UTC close). Writes/opt/launchwatch/pub/rotmap.json; the page is/opt/launchwatch/pub/rotation.html. - Alerts: Telegram, one message per change (call word, a basket turning red or green, trench window opening). State in
rotmap_state.jsonso a restart never replays. fresh.py(cron every 30 min) sends one message ifrotmap.jsonis older than 5.5 hours, one when it recovers.- The older trench collector
rotation.py(hourly) keeps fillingrot.db; its own alerts are muted.
6 · Known limits
- Survivorship: baskets are today's coins. The stress test in iteration 3 bounds it but does not remove it.
- Small sample: 7 major rotations in six years. A single change can move the score by 14 points.
- The 2023-24 alt run is still missed (held 30%, 68 days late) because the book waits for a full washout first. Every earlier-entry idea that fixed it broke 2024-26.
- Late at the 2021 top (45 days): the 100 day average reacts slowly. That is the price of not being shaken out of alt seasons.
- No trench price index exists before 2024, so the trench leg cannot be traded or backtested as a bucket.
7 · How to reproduce
- Put
rotmap.pyin a folder, runpython3 rotmap.py --full --dry(about 20 minutes: OKX candles for 65 coins since 2019, Hyperliquid funding since 2024). It writesstore/px.json,store/fund.jsonandrotmap.json(setPUB_JSONto choose the path).--dryprints alerts instead of sending. - Put the lab files in a sibling
lab/folder.lab.pyexpectsrotmap.pyand its store in../deploy/; adjust the two paths at its top. - Run
it1.py 1 50(v1) andit1.py 1 100(v2), thenit2.py 50,it2.py 100,it2b.py,it3.py,it3b.py,it4.py(needsstables.jsonfromhttps://stablecoins.llama.fi/stablecoincharts/all) andit4b.py. Their outputs from 28 Sep are in section 10.
8 · Questions for the reviewer
- Is there any lookahead in
Model,book_ruleorbacktest? (Fill is on the next close. The clocks use trailing windows only.) - Is the 50 to 100 day switch overfitting, given that about 35 risk variants were looked at on both periods? Is the neighbourhood (section 3, iteration 2, and the it3c sweep) and worst-year argument enough?
- Is "held at least half the stretch" a fair definition of caught? Would a different horizon (30 or 60 days) or edge (15 or 30%) change the ranking of v1 vs v2?
- How would you fix survivorship without delisted price history?
- Can you find an ex-ante signal that separates the 2023-24 alt run from the 2024-26 alt bleed that the tests here missed?
- Any bug in the basket index (30 day join delay, ±50% cap, first-coin start) that could flatter 2020-21?
9 · Full code
Service: rotmap.py · rotation.html. Lab: lab.py · rules.py · it1 · it2 · it2b · it3 · it3b · it4 · it4b. Raw text copies of each file sit next to this page in code/.
rotmap.py
#!/usr/bin/env python3
"""ROTATION MAP v2 (28 Sep 2026). Where the money should sit: cash, BTC or alts,
and which alt basket is topping. Runs every 4 hours on closed UTC daily candles.
THE BOOK (three layers, nothing else drives a trade)
1 RISK BTC above its 100 day average (1% band) = risk on, else CASH.
2 ALT CLOCK share of 64 alts beating BTC. Alts are bought only after a
CONFIRMED BOTTOM: 90d share 5% or less inside 60 days, 30d share
back to 40%+, risk on. They are sold when risk turns off.
3 BASKET TOP a basket whose own members mostly beat BTC (70%+ over 90d) and
have now stopped (under 50% over 30d) is dropped; its weight
moves to BTC.
HOW IT IS GRADED. major_rotations() labels, with hindsight, every stretch since
June 2020 where cash, BTC or alts beat the next best place by 20%+ (best place
over the next 45 days, stretches under 21 days merged). Hindsight only grades;
no rule reads it. v1 caught 4 of 7, v2 catches 6 of 7 (median 2 days late).
WHAT CHANGED IN v2, and why
* risk average 50 -> 100 days. About 35 variants tried. 100 days with a
0, 1 or 3% band, and 150 days, beat 50 on return AND max drop in 2020-24
and 2024-26. 125 days had smaller drops but made less since 2024; 75 days
was worse in 2020-24. Worst calendar year -34% vs -52%. Survives 1% fees, 4 day late fills, and removing the best
2020-21 coins from every basket. Chosen after seeing both periods, so
2020-24 is no longer fully unseen: the dashboard says so.
* baskets start with their first live coin (was: three), so MEMES covers
the 2021 DOGE rotation instead of starting in 2023.
* candles are UTC days (plain OKX 1D is Hong Kong aligned).
* funding crowding removed as a warning colour: crowded baskets did BETTER
over the next 30 days, not worse.
REJECTED, shown as information or not at all: alts/BTC, ETH/BTC and per basket
relative strength trends (18 versions, all lost since 2024); breadth thrust
entries; the market wide alt top as an exit; stablecoin supply growth; a basket
bouncing off its own low. The trench window predicts launchpad VOLUME (11 of
14), not meme prices.
KNOWN LIMITS. Alt baskets are today's surviving coins (flatters 2020-21).
Seven major rotations is a small sample. The 2023-24 alt run was caught late
(68 days) because the book waits for a washout.
"""
import json, math, os, sys, time, statistics as st, urllib.request, datetime as dt
HERE = os.path.dirname(os.path.abspath(__file__))
STORE = os.path.join(HERE, "store")
PUB_JSON = os.environ.get("PUB_JSON", "/opt/launchwatch/pub/rotmap.json")
TRENCH_DB = os.path.join(HERE, "rot.db")
CFG = os.path.join(HERE, "config.json")
UA = {"User-Agent": "Mozilla/5.0 (rotation-map)"}
BKK = 7 * 3600
FEE = 0.003
BASKETS = {
"BTC": ["BTC"], "ETH": ["ETH"], "SOL": ["SOL"],
"MAJORS": ["BNB", "XRP", "ADA", "AVAX", "DOT", "LINK", "TRX", "LTC", "BCH", "ATOM", "NEAR", "APT",
"SUI", "ICP", "FIL", "HBAR", "XLM", "ETC", "XTZ", "ALGO"],
"MEMES": ["DOGE", "SHIB", "PEPE", "WIF", "BONK", "FLOKI", "MEW", "TRUMP", "BOME", "TURBO", "NEIRO",
"PNUT", "GOAT", "ACT", "PENGU"],
"AI": ["FET", "RENDER", "WLD", "GRT", "ARKM", "VIRTUAL", "AIXBT", "NMR", "PHA", "ATH"],
"DEFI": ["UNI", "AAVE", "CRV", "LDO", "PENDLE", "SNX", "COMP", "SUSHI", "1INCH", "DYDX", "JUP", "RAY",
"ENA", "ETHFI", "EIGEN", "YFI", "UMA"],
}
PRICE_BASKETS = ["BTC", "ETH", "SOL", "MAJORS", "MEMES", "AI", "DEFI"]
ALT_BASKETS = ["ETH", "SOL", "MAJORS", "MEMES", "AI", "DEFI"]
FUND = {"BTC": ["BTC"], "ETH": ["ETH"], "SOL": ["SOL"], "MAJORS": ["XRP", "ADA", "AVAX", "LINK", "SUI"],
"MEMES": ["DOGE", "kPEPE", "WIF", "kBONK", "kSHIB"], "AI": ["FET", "RENDER", "WLD", "TAO"],
"DEFI": ["UNI", "AAVE", "ENA", "PENDLE", "LDO"]}
LABEL = {"BTC": "Bitcoin", "ETH": "Ether", "SOL": "Solana", "MAJORS": "Large caps",
"MEMES": "Memes", "AI": "AI", "DEFI": "DeFi"}
CLOCK_TOP, CLOCK_WASH = 0.70, 0.05
RISK_BAND = 0.01
RISK_MA = 100 # v2: was 50. 100d (any band 0-3%) and 150d beat 50d in both periods; worst year -34% vs -52%
INDEX_NEED = 1 # v2: was 3, which started MEMES only in 2023 and hid the 2021 DOGE rotation
# ------------------------------------------------------------------ fetch --
def get(u, body=None, tries=5):
for i in range(tries):
try:
if body is None:
r = urllib.request.Request(u, headers=UA)
else:
r = urllib.request.Request(u, json.dumps(body).encode(),
dict(UA, **{"content-type": "application/json"}))
return json.load(urllib.request.urlopen(r, timeout=45))
except Exception:
if i == tries - 1:
return None
time.sleep(2 + 3 * i)
def okx_closed(inst, want):
"""Closed UTC daily candles only (plain 1D is Hong Kong aligned and would
mislabel every day). OKX marks the live candle confirm=0; a signal
computed on a half-finished day flips back and forth all day."""
out, after = {}, ""
while len(out) < want:
u = ("https://www.okx.com/api/v5/market/history-candles?instId=%s&bar=1Dutc&limit=100"
% inst) + (("&after=" + after) if after else "")
rows = ((get(u) or {}).get("data")) or []
if not rows:
break
for r in rows:
if r[-1] == "1":
out[dt.datetime.utcfromtimestamp(int(r[0]) / 1000).strftime("%Y-%m-%d")] = float(r[4])
after = rows[-1][0]
time.sleep(0.12)
return out
def hl_funding(coin, start_ms):
t, daily = start_ms, {}
while True:
rows = get("https://api.hyperliquid.xyz/info", {"type": "fundingHistory", "coin": coin, "startTime": t})
if not rows:
break
for r in rows:
d = dt.datetime.utcfromtimestamp(r["time"] / 1000).strftime("%Y-%m-%d")
daily.setdefault(d, []).append(float(r["fundingRate"]))
t = rows[-1]["time"] + 1
if len(rows) < 500:
break
time.sleep(0.6)
return {d: 100 * 24 * 365 * sum(v) / len(v) for d, v in daily.items()}
def refresh(full=False):
os.makedirs(STORE, exist_ok=True)
pxp, fdp = os.path.join(STORE, "px.json"), os.path.join(STORE, "fund.json")
px = json.load(open(pxp)) if os.path.exists(pxp) else {}
fd = json.load(open(fdp)) if os.path.exists(fdp) else {}
dead = []
for b, syms in BASKETS.items():
for s in syms:
new = okx_closed(s + "-USDT", 2500 if (full or s not in px) else 60)
if new:
px.setdefault(s, {}).update(new)
elif s in ("BTC", "ETH", "SOL"):
dead.append("price " + s)
json.dump(px, open(pxp + ".tmp", "w"))
os.replace(pxp + ".tmp", pxp)
for coins in FUND.values():
for c in coins:
if full or c not in fd:
start = int(dt.datetime(2024, 1, 1).timestamp()) * 1000
else:
start = int(time.time() - 12 * 86400) * 1000
new = hl_funding(c, start)
if new:
fd.setdefault(c, {}).update(new)
elif c == "BTC":
dead.append("funding BTC")
json.dump(fd, open(fdp + ".tmp", "w"))
os.replace(fdp + ".tmp", fdp)
return px, fd, dead
# ------------------------------------------------------------------ model --
def _add(day, n):
return (dt.date.fromisoformat(day) + dt.timedelta(days=n)).isoformat()
class Model:
def __init__(self, px, fd):
self.px, self.fd = px, fd
self.mem = {b: [s for s in syms if s in px and len(px[s]) >= 120] for b, syms in BASKETS.items()}
days = sorted(set().union(*[set(v) for v in px.values()]))
last = max(px["BTC"]) # never run ahead of BTC's last closed day
self.days = [d for d in days if "2019-12-01" <= d <= last]
self.N = len(self.days)
self.pos = {d: i for i, d in enumerate(self.days)}
self.first = {s: min(v) for s, v in px.items()}
self.idx = {b: self._index(self.mem[b]) for b in PRICE_BASKETS}
self.alts = sorted({s for b in ALT_BASKETS for s in self.mem[b]})
self.c90 = [self._share(i, 90, self.alts) for i in range(self.N)]
self.c30 = [self._share(i, 30, self.alts) for i in range(self.N)]
self.b90 = {b: [self._share(i, 90, self.mem[b]) for i in range(self.N)] for b in ALT_BASKETS if len(self.mem[b]) > 1}
self.b30 = {b: [self._share(i, 30, self.mem[b]) for i in range(self.N)] for b in ALT_BASKETS if len(self.mem[b]) > 1}
# THE RISK SWITCH HAS A 1% BAND. Without it BTC crossed its average
# average six times in Jul-Aug 2026 and would have sent six buy/sell
# alerts in a month. Four bands were tested (0, 1, 2, 3%); 1% was best
# in BOTH test periods and halved the switching.
self._risk, s = [], False
for i in range(self.N):
m, x = self.ma("BTC", i, RISK_MA), self.idx["BTC"][i]
if m and x:
if x > m * (1 + RISK_BAND):
s = True
elif x < m * (1 - RISK_BAND):
s = False
self._risk.append(s)
def p(self, s, i):
return self.px[s].get(self.days[i]) if 0 <= i < self.N else None
def _index(self, members):
out, lvl = [None] * self.N, None
need = 1 if len(members) == 1 else INDEX_NEED
for i in range(1, self.N):
d, q = self.days[i], self.days[i - 1]
r = []
for s in members:
a, b = self.px[s].get(q), self.px[s].get(d)
if a and b and d >= _add(self.first[s], 30):
r.append(max(-0.5, min(0.5, math.log(b / a))))
if len(r) >= need:
lvl = (lvl or 100.0) * math.exp(sum(r) / len(r))
out[i] = lvl
return out
def _share(self, i, n, members):
if i < n:
return None
b0, b1 = self.p("BTC", i - n), self.p("BTC", i)
if not b0 or not b1:
return None
rb, v = b1 / b0 - 1, []
for s in members:
a0, a1 = self.p(s, i - n), self.p(s, i)
if a0 and a1 and self.days[i - n] >= _add(self.first[s], 30):
v.append(a1 / a0 - 1 > rb)
return sum(v) / len(v) if len(v) >= (10 if len(members) > 15 else 3) else None
def ret(self, b, i, n):
x = self.idx[b]
return None if i - n < 0 or i >= self.N or not x[i] or not x[i - n] else x[i] / x[i - n] - 1
def fwd(self, b, i, n):
return self.ret(b, i + n, n) if i + n < self.N else None
def ma(self, b, i, n=50):
x = self.idx[b]
w = [v for v in x[max(0, i - n + 1):i + 1] if v]
return sum(w) / len(w) if len(w) >= int(0.8 * n) else None
def risk_on(self, i):
return self._risk[i]
# --- the validated signals ---------------------------------------------
def washed_recent(self, i, look=60):
return any(c is not None and c <= CLOCK_WASH for c in self.c90[max(0, i - look):i + 1])
def alt_bottom(self, i):
c = self.c30[i]
return self.washed_recent(i) and c is not None and c >= 0.40 and self.risk_on(i)
def alt_top_warning(self, i):
c = self.c30[i]
return any(x is not None and x >= CLOCK_TOP for x in self.c90[max(0, i - 30):i + 1]) and c is not None and c < 0.50
def basket_extended(self, b, i):
return b in self.b90 and any(x is not None and x >= CLOCK_TOP for x in self.b90[b][max(0, i - 30):i + 1])
def basket_top(self, b, i):
return self.basket_extended(b, i) and self.b30[b][i] is not None and self.b30[b][i] < 0.50
def funding(self, b, i, win=7):
v = []
for c in FUND[b]:
w = [self.fd.get(c, {}).get(self.days[k]) for k in range(max(0, i - win + 1), i + 1)]
w = [x for x in w if x is not None]
if w:
v.append(sum(w) / len(w))
return sum(v) / len(v) if v else None
def funding_pct(self, b, i):
"""Where today's crowding sits in this basket's own history since 2024."""
now = self.funding(b, i)
if now is None:
return None, None
hist = [self.funding(b, k) for k in range(max(0, i - 540), i, 3)]
hist = [h for h in hist if h is not None]
if len(hist) < 30:
return now, None
return now, sum(h < now for h in hist) / len(hist)
# --- the book --------------------------------------------------------------
def book_rule(self):
s = {"on": False, "dropped": set()}
def rule(i):
up = self.risk_on(i)
if s["on"] and not up:
s["on"] = False
elif not s["on"] and self.alt_bottom(i):
s["on"], s["dropped"] = True, set()
if not s["on"]:
return {"BTC": 1.0} if up else {}
for b in ALT_BASKETS:
if self.basket_top(b, i):
s["dropped"].add(b)
have = [b for b in ALT_BASKETS if self.idx[b][i]]
live = [b for b in have if b not in s["dropped"]]
w = {b: round(1 / len(have), 6) for b in live}
if len(live) < len(have):
w["BTC"] = round(1 - sum(w.values()), 6)
return w
return rule, s
def backtest(self, rule, lo, hi):
eq, w, pend, curve, log = 1.0, {}, [], [], []
for i in range(lo, hi + 1):
if w:
g = sum(wt * ((self.idx[b][i] / self.idx[b][i - 1]) if self.idx[b][i] and self.idx[b][i - 1] else 1.0)
for b, wt in w.items())
eq *= g + 1.0 - sum(w.values())
while pend and pend[0][0] <= i:
_, new = pend.pop(0)
moved = sum(abs(new.get(b, 0) - w.get(b, 0)) for b in set(new) | set(w))
if moved > 1e-9:
eq *= 1 - FEE * moved
log.append((self.days[i], new))
w = new
new = rule(i)
if new != (pend[-1][1] if pend else w):
pend.append((i + 1, new))
curve.append((self.days[i], eq, dict(w)))
return curve, log
def stats(curve):
e = [c[1] for c in curve]
if len(e) < 30:
return {}
yrs = len(e) / 365
peak, mdd = e[0], 0.0
for v in e:
peak = max(peak, v)
mdd = min(mdd, v / peak - 1)
return {"total": round(100 * (e[-1] / e[0] - 1), 1),
"cagr": round(100 * ((e[-1] / e[0]) ** (1 / yrs) - 1), 1),
"maxdd": round(100 * mdd, 1)}
# ------------------------------------------------------------ scoreboard --
def alts_index(M):
"""Equal weight of the six alt baskets, daily rebalanced (two or more live)."""
out, lvl = [None] * M.N, None
for i in range(1, M.N):
rs = [math.log(M.idx[b][i] / M.idx[b][i - 1]) for b in ALT_BASKETS if M.idx[b][i] and M.idx[b][i - 1]]
if len(rs) >= 2:
lvl = (lvl or 100.0) * math.exp(sum(rs) / len(rs))
out[i] = lvl
return out
def major_rotations(M, lo, H=45, min_run=21, major=0.20):
"""HINDSIGHT LABELS, used only to grade the system, never inside a rule.
Each day gets the best of CASH / BTC / ALTS over the next H days; runs
shorter than min_run are merged into the longer neighbour. A run is a
MAJOR rotation when its bucket beat the next best by 20%+ over the run."""
A = alts_index(M)
idx = {"BTC": M.idx["BTC"], "ALTS": A}
def lr(k, a, b):
return 0.0 if k == "CASH" else (math.log(idx[k][b] / idx[k][a]) if idx[k][a] and idx[k][b] else None)
lab = [None] * M.N
for i in range(lo, M.N - H):
c = {k: lr(k, i, i + H) for k in ("CASH", "BTC", "ALTS")}
c = {k: v for k, v in c.items() if v is not None}
lab[i] = max(c, key=c.get)
runs, s0 = [], lo
for i in range(lo + 1, M.N - H + 1):
if i == M.N - H or lab[i] != lab[s0]:
runs.append([s0, i - 1, lab[s0]])
s0 = i
changed = True
while changed and len(runs) > 1:
changed = False
for k, r in enumerate(runs):
if r[1] - r[0] + 1 < min_run:
if 0 < k < len(runs) - 1:
j = k - 1 if runs[k - 1][1] - runs[k - 1][0] >= runs[k + 1][1] - runs[k + 1][0] else k + 1
else:
j = k - 1 if k else k + 1
a, b = sorted((k, j))
runs[a] = [runs[a][0], runs[b][1], runs[j][2]]
del runs[b]
changed = True
break
k = 0
while k + 1 < len(runs):
if runs[k][2] == runs[k + 1][2]:
runs[k][1] = runs[k + 1][1]
del runs[k + 1]
changed = True
else:
k += 1
out = []
for a, b, l in runs:
z = min(b + 1, M.N - 1)
r = {k: lr(k, a, z) for k in ("CASH", "BTC", "ALTS")}
if None in r.values():
continue
edge = r[l] - max(v for k, v in r.items() if k != l)
out.append({"a": a, "b": b, "bucket": l, "edge": math.exp(edge) - 1, "r": r})
return [x for x in out if x["edge"] >= major]
# ------------------------------------------------------------- the reading --
def holding_word(w):
if not w:
return "CASH"
alts = [b for b in w if b != "BTC"]
if not alts:
return "BTC"
return "ALTS"
def read(M):
i = M.N - 1
today = M.days[i]
btc, m50 = M.idx["BTC"][i], M.ma("BTC", i, RISK_MA)
up = M.risk_on(i)
cross = next((k for k in range(i, 0, -1) if M.risk_on(k) != M.risk_on(k - 1)), 0)
rule, st_ = M.book_rule()
lo = M.pos.get("2020-06-01", 200)
curve, log = M.backtest(rule, lo, i)
held = curve[-1][2]
want = log[-1][1] if log else {}
# what the rule wants as of today's close (it is traded at tomorrow's close)
rule2, _ = M.book_rule()
for k in range(lo, i + 1):
want = rule2(k)
# ---- baskets
cards = []
for b in PRICE_BASKETS:
c = {"key": b, "name": LABEL[b], "members": M.mem[b]}
r30, rb30 = M.ret(b, i, 30), M.ret("BTC", i, 30)
c["vs_btc_30d"] = round(100 * (r30 - rb30), 1) if (r30 is not None and rb30 is not None and b != "BTC") else None
c["ret_30d"] = round(100 * r30, 1) if r30 is not None else None
m = M.ma(b, i)
c["trend"] = "up" if (m and M.idx[b][i] > m) else "down"
fnow, fpct = M.funding_pct(b, i)
c["funding"] = round(fnow, 1) if fnow is not None else None
c["funding_pct"] = round(100 * fpct) if fpct is not None else None
c["clock90"] = round(100 * M.b90[b][i]) if b in M.b90 and M.b90[b][i] is not None else None
c["clock30"] = round(100 * M.b30[b][i]) if b in M.b30 and M.b30[b][i] is not None else None
c["weight"] = round(100 * want.get(b, 0))
checks = []
if b == "BTC":
dist = 100 * (btc / m50 - 1) if m50 else None
c["shade"] = "green" if up else "red"
c["word"] = "RISK ON" if up else "RISK OFF"
c["why"] = ("BTC is %.1f%% %s its 100 day average. Risk switched %s on %s. It flips on at +1%% "
"and off at -1%%, so a wobble on the line cannot flip it." %
(abs(dist or 0), "above" if (dist or 0) >= 0 else "below", "on" if up else "off", M.days[cross]))
checks = [["BTC over its 100 day average, with the 1% band", up]]
elif b in M.b90:
ext, top = M.basket_extended(b, i), M.basket_top(b, i)
if top:
c["shade"], c["word"] = "red", "TOP CONFIRMED"
c["why"] = ("Most of the %s have beaten BTC for three months and have now stopped. "
"This lagged BTC over the next 60 days {EV_TOP}." % LABEL[b].lower())
elif ext:
c["shade"], c["word"] = "lightred", "EXTENDED"
c["why"] = "Most members have beaten BTC for three months. Not rolled over yet, so no exit, just no new money."
elif M.alt_bottom(i):
c["shade"], c["word"] = "green", "BOTTOM CONFIRMED"
c["why"] = "The whole alt market washed out and has turned, and BTC risk is on. This basket is part of the next rotation."
elif M.washed_recent(i):
c["shade"], c["word"] = "lightgreen", "WASHED OUT"
c["why"] = "The alt market washed out. Waiting for alts to start beating BTC again before this turns green."
else:
c["shade"], c["word"] = "grey", ("LEADING BTC" if (c["vs_btc_30d"] or 0) > 0 else "LAGGING BTC")
c["why"] = "Nothing has crossed. Shown for context, not a signal."
checks = [["extended: 70%+ of members beat BTC over 90 days", ext],
["rolled over: under 50% beat BTC over the last 30 days", bool(c["clock30"] is not None and c["clock30"] < 50)]]
else:
# ETH and SOL are single coins, so there is no basket clock to read.
if M.alt_bottom(i):
c["shade"], c["word"] = "green", "BOTTOM CONFIRMED"
c["why"] = "Part of the alt rotation the market clock just confirmed."
elif M.washed_recent(i):
c["shade"], c["word"] = "lightgreen", "WASHED OUT"
c["why"] = "The alt market washed out. Waiting for the turn."
else:
c["shade"], c["word"] = "grey", ("LEADING BTC" if (c["vs_btc_30d"] or 0) > 0 else "LAGGING BTC")
c["why"] = "Single coin, no basket clock. Shown for context."
checks = [["the alt market washed out in the last 60 days", M.washed_recent(i)],
["alts are beating BTC again (30 day share 40%+)", bool(M.c30[i] is not None and M.c30[i] >= 0.40)]]
c["checks"] = checks
x = M.idx[b]
c["spark"] = [round(x[k] / x[max(0, i - 180)], 4) if x[k] and x[max(0, i - 180)] else None
for k in range(max(0, i - 180), i + 1, 3)]
cards.append(c)
# ---- the one call
now_word, want_word = holding_word(held), holding_word(want)
dropped = sorted(st_["dropped"]) if st_["on"] else []
if not up:
call = ("GO TO CASH" if now_word != "CASH" else "STAY IN CASH",
"BTC is below its 100 day average. Risk is off, so nothing is held. {EV_RISK}")
elif want_word == "ALTS" and now_word != "ALTS":
call = ("ROTATE INTO ALTS",
"The alt market washed out and has turned, with BTC risk on. Split across the alt baskets that are not red.")
elif want_word == "ALTS":
red = [c["name"] for c in cards if c["shade"] == "red" and c["key"] != "BTC"]
call = ("HOLD ALTS" + (", TRIM " + " AND ".join(n.upper() for n in red) if red else ""),
"Inside an alt rotation." + (" Red baskets move to BTC." if red else " No basket has topped yet."))
else:
call = ("HOLD BTC" if now_word == "BTC" else "BUY BTC",
"BTC risk is on. Alts have not washed out and turned, so they are not the trade yet.")
# ---- trench card (from Rotation Watch's collector)
trench = None
try:
import sqlite3
c = sqlite3.connect(TRENCH_DB)
rows = list(c.execute("select date, btc, dex_all, pad from d where btc is not null order by date"))
if len(rows) > 40:
dd = [r[0] for r in rows]
b = [r[1] for r in rows]
sh = [(100 * r[3] / r[2]) if r[2] and r[3] else None for r in rows]
n = len(rows) - 1
def sm(x, k, w):
v = [y for y in x[max(0, k - w + 1):k + 1] if y is not None]
return sum(v) / len(v) if v else None
r7 = [(b[k] / b[k - 7] - 1) if k >= 7 and b[k] and b[k - 7] else None for k in range(len(b))]
imp = [k for k in range(8, len(b)) if r7[k] is not None and r7[k - 1] is not None and r7[k] >= 0.10 > r7[k - 1]]
last_cross = dd[imp[-1]] if imp else None
open_ = bool(imp and n - imp[-1] <= 14)
trench = {"share_7d": round(sm(sh, n - 1, 7) or 0, 2), "share_90d_avg": round(sm(sh, n - 1, 90) or 0, 2),
"btc_7d": round(100 * (r7[n] or 0), 1), "window_open": open_, "last_impulse": last_cross,
"window_closes": _add(last_cross, 14) if open_ else None}
except Exception as e:
trench = {"error": str(e)[:80]}
# ---- evidence, recomputed every run
def events(pred, lo_, hi_, gap=45):
out = []
for k in range(lo_ + 1, hi_):
if pred(k) and not pred(k - 1) and (not out or k - out[-1] > gap):
out.append(k)
return out
ev = {}
for per, a, z in (("holdout", "2020-06-01", "2024-06-30"), ("recent", "2024-07-01", today)):
pa, pz = M.pos.get(a, lo), M.pos.get(z, i)
n_ok = n_all = 0
for b in M.b90:
for k in events(lambda k: M.basket_top(b, k), pa, min(pz, i - 60)):
f, g = M.fwd(b, k, 60), M.fwd("BTC", k, 60)
if f is not None and g is not None:
n_all += 1
n_ok += f < g
ev["basket_top_" + per] = [n_ok, n_all]
books = {}
for per, a, z in (("holdout", "2020-06-01", "2024-06-30"), ("recent", "2024-07-01", today), ("full", "2020-06-01", today)):
pa, pz = M.pos.get(a, lo), M.pos.get(z, i)
r1, _ = M.book_rule()
books[per] = {"book": stats(M.backtest(r1, pa, pz)[0]),
"btc_trend": stats(M.backtest(lambda k: {"BTC": 1.0} if M.risk_on(k) else {}, pa, pz)[0]),
"hold_btc": stats(M.backtest(lambda k: {"BTC": 1.0}, pa, pz)[0])}
# ---- the rotation timeline, every leg the book took
legs, prev = [], None
eqd = {d: e for d, e, _ in curve}
for d, w in log:
wd = holding_word(w)
if prev and prev[1] != wd:
legs.append({"from": prev[0], "to": d, "held": prev[1],
"ret": round(100 * (eqd[d] / eqd[prev[0]] - 1), 1)})
prev = (d, wd)
elif not prev:
prev = (d, wd)
if prev:
legs.append({"from": prev[0], "to": None, "held": prev[1], "ret": round(100 * (curve[-1][1] / eqd[prev[0]] - 1), 1)})
# ---- scoreboard: every major rotation since 2020 and whether the book was in it
def bucket(w): # half or more of the book in alts counts as holding ALTS
return "CASH" if not w else ("ALTS" if sum(v for k, v in w.items() if k != "BTC") >= 0.5 else "BTC")
heldw = {M.pos[d]: bucket(w) for d, _, w in curve}
rots = []
for r in major_rotations(M, lo):
span = range(r["a"], r["b"] + 1)
inb = [k for k in span if heldw.get(k) == r["bucket"]]
lag = (inb[0] - r["a"]) if inb else None
share = len(inb) / len(span)
rots.append({"from": M.days[r["a"]], "to": M.days[r["b"]], "bucket": r["bucket"],
"edge": round(100 * r["edge"]), "btc": round(100 * (math.exp(r["r"]["BTC"]) - 1)),
"alts": round(100 * (math.exp(r["r"]["ALTS"]) - 1)),
"lag": lag, "held": round(100 * share), "caught": share >= 0.5})
lags = sorted(x["lag"] for x in rots if x["lag"] is not None)
score = {"major": len(rots), "caught": sum(x["caught"] for x in rots),
"median_lag": lags[len(lags) // 2] if lags else None}
# ---- fill the evidence into the words
t = ev.get("basket_top_holdout", [0, 0])
u = ev.get("basket_top_recent", [0, 0])
ev_top = "%d of %d times in 2020 to 2024 and %d of %d since" % (t[0], t[1], u[0], u[1])
hb, bt = books["holdout"]["hold_btc"], books["holdout"]["btc_trend"]
ev_risk = "On 2020 to 2024 this rule alone cut the worst drop of holding BTC from %d%% to %d%%." % (hb["maxdd"], bt["maxdd"])
for c in cards:
c["why"] = c["why"].replace("{EV_TOP}", ev_top)
call = (call[0], call[1].replace("{EV_RISK}", ev_risk))
# ---- weekly series for the charts
wk = list(range(lo, i + 1, 7)) + ([i] if (i - lo) % 7 else [])
btc_hold = M.idx["BTC"]
series = {"d": [M.days[k] for k in wk],
"clock90": [round(100 * M.c90[k]) if M.c90[k] is not None else None for k in wk],
"clock30": [round(100 * M.c30[k]) if M.c30[k] is not None else None for k in wk],
"risk": [1 if M.risk_on(k) else 0 for k in wk],
"book": [round(curve[k - lo][1], 4) for k in wk],
"btc": [round(btc_hold[k] / btc_hold[lo], 4) for k in wk],
"held": [holding_word(curve[k - lo][2]) for k in wk]}
return {"as_of": today, "call": {"word": call[0], "line": call[1]},
"held_now": {b: round(100 * v) for b, v in held.items()},
"want": {b: round(100 * v) for b, v in want.items()},
"dropped": dropped,
"risk": {"on": up, "btc": round(btc), "ma": round(m50) if m50 else None, "ma_days": RISK_MA,
"dist": round(100 * (btc / m50 - 1), 1) if m50 else None, "since": M.days[cross]},
"clock": {"c90": round(100 * M.c90[i]) if M.c90[i] is not None else None,
"c30": round(100 * M.c30[i]) if M.c30[i] is not None else None,
"washed_recent": M.washed_recent(i), "bottom": M.alt_bottom(i),
"top_warning": M.alt_top_warning(i), "n_alts": len(M.alts)},
"baskets": cards, "trench": trench, "evidence": ev, "books": books,
"legs": legs[-40:], "series": series, "rotations": rots, "score": score,
"settings": {"risk_ma": RISK_MA, "risk_band": RISK_BAND, "clock_top": CLOCK_TOP, "clock_wash": CLOCK_WASH}}
# ----------------------------------------------------------------- alerts --
def tg(msg):
try:
cfg = json.load(open(CFG))["telegram"]
body = json.dumps({"chat_id": cfg["chat_id"], "text": msg, "parse_mode": "HTML",
"disable_web_page_preview": True}).encode()
urllib.request.urlopen(urllib.request.Request(
"https://api.telegram.org/bot%s/sendMessage" % cfg["bot_token"], body,
{"content-type": "application/json"}), timeout=25).read()
return True
except Exception as e:
print("telegram failed:", str(e)[:80])
return False
def alerts(R, dry=False):
"""One message per real change: the call word, a basket's colour, the risk
switch, the trench window. State lives in a file so a restart cannot
replay old alerts."""
sp = os.path.join(HERE, "rotmap_state.json")
old = json.load(open(sp)) if os.path.exists(sp) else None
new = {"call": R["call"]["word"], "risk": R["risk"]["on"],
"shade": {c["key"]: c["shade"] for c in R["baskets"]},
"trench": bool((R.get("trench") or {}).get("window_open"))}
msgs = []
if old is not None:
if new["call"] != old.get("call"):
icon = {"ROTATE INTO ALTS": "\U0001F7E2", "GO TO CASH": "\U0001F534"}.get(new["call"], "\U0001F504")
msgs.append("%s <b>%s</b>\n%s" % (icon, new["call"], R["call"]["line"]))
for c in R["baskets"]:
a, b = old.get("shade", {}).get(c["key"]), c["shade"]
if a and a != b and b in ("red", "green"):
msgs.append("%s <b>%s: %s</b>\n%s" % ("\U0001F7E5" if b == "red" else "\U0001F7E9",
c["name"], c["word"], c["why"]))
if new["trench"] and not old.get("trench"):
t = R["trench"]
msgs.append("\U0001F525 <b>TRENCH WINDOW OPEN</b>\nBTC just did +10%% in a week. The last 14 times, launchpad volume "
"was higher two weeks later in 11. Window closes %s." % t.get("window_closes"))
if msgs:
body = "\n\n".join(msgs) + "\n\n" + R["as_of"] + " close · http://159.223.88.168/rotation.html"
if dry:
print("DRY ALERT:\n" + body)
elif tg(body):
print("alert sent")
if not dry:
json.dump(new, open(sp, "w"))
# ------------------------------------------------------------------- main --
def main():
full = "--full" in sys.argv
dry = "--dry" in sys.argv
if "--offline" in sys.argv:
px = json.load(open(os.path.join(STORE, "px.json")))
fd = json.load(open(os.path.join(STORE, "fund.json")))
dead = []
else:
px, fd, dead = refresh(full)
M = Model(px, fd)
R = read(M)
R["dead"] = dead
R["generated"] = int(time.time())
R["generated_bkk"] = dt.datetime.utcfromtimestamp(R["generated"] + BKK).strftime("%Y-%m-%d %H:%M")
tmp = PUB_JSON + ".tmp"
json.dump(R, open(tmp, "w"), separators=(",", ":"))
os.replace(tmp, PUB_JSON)
print("%s %s risk %s clock90 %s clock30 %s %s" % (
R["generated_bkk"], R["call"]["word"], "on" if R["risk"]["on"] else "off",
R["clock"]["c90"], R["clock"]["c30"],
" ".join("%s:%s" % (c["key"], c["shade"]) for c in R["baskets"])))
if dead:
print("DEAD SOURCES:", dead)
alerts(R, dry)
if __name__ == "__main__":
main()
rotation.html
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Rotation Map</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='none' stroke='%23f0a83c' stroke-width='3'/%3E%3Cpath d='M16 6 L22 16 L16 26 L10 16 Z' fill='%233ecf8e'/%3E%3C/svg%3E">
<style>
:root{--bg:#0e1116;--panel:#151a21;--panel2:#1b212a;--rule:#252c37;--ink:#e7eaf0;--dim:#9aa4b2;--faint:#6b7483;
--up:#3ecf8e;--dn:#f2555a;--accent:#f0a83c;--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased}
.wrap{max-width:1120px;margin:0 auto;padding:0 16px 40px}
header{background:var(--panel);border-bottom:1px solid var(--rule);padding:calc(12px + env(safe-area-inset-top,0px)) 16px 12px;margin-bottom:16px}
header .in{max-width:1120px;margin:0 auto;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
.brand{font-weight:700;font-size:16px}.brand span{color:var(--faint);font-weight:400;font-size:12.5px}
.sp{flex:1}.stamp{font-family:var(--mono);font-size:11.5px;color:var(--faint)}
.card{background:var(--panel);border:1px solid var(--rule);border-radius:12px;padding:18px 20px;margin-bottom:14px}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim);margin:0 0 12px;font-weight:600}
.word{font-size:36px;font-weight:800;letter-spacing:-.02em;line-height:1.1}
.line{margin-top:8px;color:var(--dim);max-width:70ch}
.hold{margin-top:12px;font-family:var(--mono);font-size:12.5px;color:var(--faint)}
.hold b{color:var(--ink)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.gauge .big{font-family:var(--mono);font-size:28px;font-weight:700}
.gauge .sub{color:var(--faint);font-size:12.5px;margin-top:2px}
.bar{position:relative;height:14px;border-radius:7px;margin:14px 0 6px;
background:linear-gradient(90deg,rgba(62,207,142,.45) 0 5%,#232a35 5% 70%,rgba(242,85,90,.45) 70% 100%)}
.bar i{position:absolute;top:-5px;width:3px;height:24px;border-radius:2px;background:var(--ink)}
.bar i.b{background:var(--accent);opacity:.9}
.scale{display:flex;justify-content:space-between;font-family:var(--mono);font-size:10.5px;color:var(--faint)}
.risk{height:14px;border-radius:7px;margin:14px 0 6px;background:#232a35;position:relative}
.strip{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
.bk{border:1px solid var(--rule);border-radius:10px;padding:12px 13px;background:var(--panel2);position:relative;overflow:hidden}
.bk.red{background:rgba(242,85,90,.20);border-color:rgba(242,85,90,.75)}
.bk.lightred{background:rgba(242,85,90,.08);border-color:rgba(242,85,90,.40)}
.bk.green{background:rgba(62,207,142,.20);border-color:rgba(62,207,142,.75)}
.bk.lightgreen{background:rgba(62,207,142,.08);border-color:rgba(62,207,142,.40)}
.bk .t{display:flex;justify-content:space-between;align-items:baseline;gap:6px}
.bk .n{font-weight:700}.bk .w{font-size:11px;font-weight:700;letter-spacing:.04em}
.red .w{color:#ff8a8e}.lightred .w{color:#f7a3a6}.green .w{color:#6ee7b0}.lightgreen .w{color:#9eeac6}.grey .w{color:var(--faint)}
.bk .why{font-size:12px;color:var(--dim);margin:6px 0 8px;min-height:3.4em}
.bk ul{list-style:none;margin:0;padding:0;font-size:11.5px;color:var(--faint)}
.bk li{padding-left:18px;position:relative;margin:2px 0}
.bk li:before{content:"\25CB";position:absolute;left:2px}
.bk li.y{color:var(--ink)}.bk li.y:before{content:"\25CF";color:var(--accent)}
.bk .nums{display:flex;flex-wrap:wrap;gap:4px 12px;font-family:var(--mono);font-size:11px;color:var(--faint);margin-top:8px}
.bk .nums b{color:var(--ink);font-weight:600}
.bk canvas{width:100%;height:34px;display:block;margin-top:8px}
.wt{position:absolute;top:10px;right:12px;font-family:var(--mono);font-size:10.5px;color:var(--accent)}
canvas.big{width:100%;display:block}
.leg{display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--faint);margin-top:8px}
.leg i{display:inline-block;width:12px;height:8px;border-radius:2px;margin-right:5px;vertical-align:middle}
table{width:100%;border-collapse:collapse;font-size:13.5px}
th,td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--rule)}
th{color:var(--faint);font-weight:600;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em}
td.n,th.n{text-align:right;font-family:var(--mono)}
.pos{color:var(--up)}.neg{color:var(--dn)}
.note{font-size:13px;color:var(--faint);line-height:1.6}.note b{color:var(--dim)}
.grade{font-family:var(--mono);font-size:10.5px;padding:2px 6px;border-radius:4px;white-space:nowrap}
.g1{background:rgba(62,207,142,.18);color:#8ef0c0}.g2{background:rgba(240,168,60,.18);color:#f5c47a}.g3{background:#252c37;color:var(--dim)}
.dead{background:#3a1f22;border:1px solid var(--dn);color:#ffd9dc;border-radius:9px;padding:10px 13px;margin-bottom:14px;font-size:13.5px}
footer{color:var(--faint);font-size:11.5px;font-family:var(--mono)}footer a{color:var(--accent);text-decoration:none}
@media(max-width:860px){.strip{grid-template-columns:1fr 1fr}.two{grid-template-columns:1fr}}
@media(max-width:520px){.strip{grid-template-columns:1fr}.word{font-size:28px}.bk .why{min-height:0}}
</style></head><body>
<header><div class="in"><div class="brand">Rotation Map <span>where the money should sit</span></div><div class="sp"></div>
<div class="stamp" id="stamp">loading</div></div></header>
<div class="wrap">
<div id="dead"></div>
<div class="card"><h2>Today's call</h2>
<div class="word" id="word">LOADING</div><div class="line" id="line"></div>
<div class="hold" id="hold"></div></div>
<div class="two">
<div class="card gauge"><h2>1 · Risk switch</h2>
<div class="big" id="rbig">—</div><div class="sub" id="rsub"></div>
<div class="risk"><i id="rneedle" style="position:absolute;top:-5px;width:3px;height:24px;border-radius:2px;background:var(--ink)"></i>
<span style="position:absolute;left:50%;top:0;bottom:0;width:1px;background:var(--faint)"></span></div>
<div class="scale"><span>-20%</span><span id="rma">100 day average</span><span>+20%</span></div>
<div class="note" style="margin-top:10px">On above the average by 1%, off below it by 1%. Off means cash.</div></div>
<div class="card gauge"><h2>2 · Alt clock</h2>
<div class="big" id="cbig">—</div><div class="sub" id="csub"></div>
<div class="bar"><i id="c90"></i><i id="c30" class="b"></i></div>
<div class="scale"><span>0 washed</span><span>share of alts beating BTC</span><span>top zone 100</span></div>
<div class="note" style="margin-top:10px"><span style="color:var(--ink)">▮</span> 90 day <span style="color:var(--accent)">▮</span> 30 day.
Green end: alts washed out, the next rotation's bottom zone. Red end: the alt rotation is topping.</div></div>
</div>
<div class="card"><h2>3 · The rotation strip</h2><div class="strip" id="strip"></div>
<div class="note" style="margin-top:12px"><b>Red</b> top confirmed · <b>light red</b> extended, stop adding ·
<b>green</b> bottom confirmed, part of the next rotation · <b>light green</b> washed out, waiting for the turn ·
grey nothing crossed. Orange % = weight in the book from the next close.</div></div>
<div class="card"><h2>4 · Every rotation since 2020</h2>
<canvas class="big" id="map" style="height:300px"></canvas>
<div class="leg"><span><i style="background:#e7eaf0"></i>alt clock, 90 day</span><span><i style="background:rgba(242,85,90,.35)"></i>top zone 70%+</span>
<span><i style="background:rgba(62,207,142,.35)"></i>washed 5% or less</span><span><i style="background:#3ecf8e"></i>book in alts</span>
<span><i style="background:#f0a83c"></i>book in BTC</span><span><i style="background:#3a414d"></i>book in cash</span></div>
<canvas class="big" id="eq" style="height:230px;margin-top:14px"></canvas>
<div class="leg"><span><i style="background:#3ecf8e"></i>the book</span><span><i style="background:#f0a83c"></i>holding BTC</span><span>log scale, from Jun 2020</span></div></div>
<div class="card"><h2>5 · Did it catch the big rotations?</h2>
<div class="line" id="scoreline" style="margin-bottom:10px"></div>
<div style="overflow-x:auto"><table><thead><tr><th>Rotation</th><th>Best place</th><th class="n">Won by</th><th class="n">Days late</th><th class="n">Time in it</th><th>Caught</th></tr></thead><tbody id="rots"></tbody></table></div>
<div class="note" style="margin-top:10px">A major rotation is a stretch where cash, BTC or alts beat the next best place by 20% or more, labelled with hindsight (best place over the next 45 days, stretches under 21 days merged). Hindsight is only used to grade the system, never inside it. Caught means the book held that place for half the stretch or more.</div></div>
<div class="card"><h2>6 · Track record, next-day execution, 0.3% per trade</h2>
<table><thead><tr><th></th><th class="n">2020-06 to 2024-06</th><th class="n">max drop</th><th class="n">Recent 2024-07 to now</th><th class="n">max drop</th></tr></thead>
<tbody id="rec"></tbody></table>
<div class="note" style="margin-top:10px" id="recnote"></div></div>
<div class="card"><h2>7 · What each colour is built on</h2>
<table><thead><tr><th>Signal</th><th>Rule</th><th>Evidence</th><th>Grade</th></tr></thead><tbody id="ev"></tbody></table></div>
<div class="card"><h2>8 · The last rotations the book took</h2>
<table><thead><tr><th>From</th><th>To</th><th>Held</th><th class="n">Leg</th></tr></thead><tbody id="legs"></tbody></table></div>
<footer>updated <span id="ft">—</span> Bangkok · closed daily candles only · alerts on Telegram when a colour or the call changes ·
<a href="/rotmap.json">raw numbers</a> · <a href="/rotation_trench.html">trench detail</a></footer>
</div>
<script>
var C={red:'#f2555a',green:'#3ecf8e',accent:'#f0a83c',ink:'#e7eaf0',faint:'#6b7483',rule:'#20262f'};
function pct(v,d){return v==null?'—':(v>0?'+':'')+v.toFixed(d==null?0:d)+'%';}
function cls(v){return v==null?'':(v>=0?'pos':'neg');}
function el(id){return document.getElementById(id);}
function ord(n){var q=['th','st','nd','rd'],v=n%100;return n+(q[(v-20)%10]||q[v]||q[0]);}
function setup(cv,h){var d=window.devicePixelRatio||1,w=cv.clientWidth;cv.width=w*d;cv.height=h*d;var x=cv.getContext('2d');x.scale(d,d);return [x,w];}
fetch('/rotmap.json?t='+Date.now()).then(function(r){return r.json();}).then(function(R){
el('stamp').textContent='close of '+R.as_of+' · updated '+R.generated_bkk+' Bangkok';
el('ft').textContent=R.generated_bkk;
if(R.dead&&R.dead.length) el('dead').innerHTML='<div class="dead"><b>A source failed:</b> '+R.dead.join(', ')+'. The numbers below may be a day old.</div>';
var w=el('word'); w.textContent=R.call.word;
w.style.color=/CASH/.test(R.call.word)?C.red:(/ALTS/.test(R.call.word)?C.green:C.accent);
el('line').textContent=R.call.line;
function hold(o){var k=Object.keys(o);return k.length?k.map(function(b){return b+' '+o[b]+'%';}).join(' · '):'cash';}
el('hold').innerHTML='holding now <b>'+hold(R.held_now)+'</b> → from the next close <b>'+hold(R.want)+'</b>';
// risk
var rk=R.risk; el('rbig').textContent=(rk.on?'ON':'OFF')+' '+pct(rk.dist,1); el('rbig').style.color=rk.on?C.green:C.red;
el('rsub').textContent='BTC vs its '+(rk.ma_days||100)+' day average · switched '+(rk.on?'on':'off')+' '+rk.since; el('rma').textContent=(rk.ma_days||100)+' day average';
var p=Math.max(0,Math.min(100,50+(rk.dist||0)*2.5)); el('rneedle').style.left='calc('+p+'% - 1px)';
// clock
var ck=R.clock; el('cbig').textContent=(ck.c90==null?'—':ck.c90+'%')+' · 30d '+(ck.c30==null?'—':ck.c30+'%');
el('csub').textContent=ck.bottom?'BOTTOM CONFIRMED: alts washed out and turned':ck.washed_recent?'Washed out in the last 60 days, waiting for the turn':
ck.top_warning?'Top warning: was 70%+ and the fast clock has rolled':(ck.c90>=70?'In the top zone':'Normal range. '+ck.n_alts+' alts counted');
el('c90').style.left='calc('+(ck.c90||0)+'% - 1px)'; el('c30').style.left='calc('+(ck.c30||0)+'% - 1px)';
// strip
var order=['BTC','ETH','SOL','MAJORS','DEFI','AI','MEMES'], by={}; R.baskets.forEach(function(b){by[b.key]=b;});
var html=order.map(function(k){var b=by[k];if(!b)return '';
var li=b.checks.map(function(c){return '<li class="'+(c[1]?'y':'')+'">'+c[0]+'</li>';}).join('');
var nums=[]; if(b.vs_btc_30d!=null)nums.push('vs BTC 30d <b class="'+cls(b.vs_btc_30d)+'">'+pct(b.vs_btc_30d,1)+'</b>');
else if(b.ret_30d!=null)nums.push('30d <b class="'+cls(b.ret_30d)+'">'+pct(b.ret_30d,1)+'</b>');
if(b.clock90!=null)nums.push('clock <b>'+b.clock90+'/'+b.clock30+'</b>');
if(b.funding_pct!=null)nums.push('crowding <b>'+ord(b.funding_pct)+'</b> pct');
nums.push('trend <b>'+b.trend+'</b>');
return '<div class="bk '+b.shade+'">'+(b.weight?'<span class="wt">'+b.weight+'%</span>':'')+'<div class="t"><span class="n">'+b.name+'</span></div><div class="w">'+b.word+'</div>'+
'<div class="why">'+b.why+'</div><ul>'+li+'</ul><div class="nums">'+nums.map(function(t){return '<span>'+t+'</span>';}).join('')+'</div><canvas data-k="'+k+'"></canvas></div>';}).join('');
var t=R.trench||{}; var tsh=t.window_open?'green':'grey';
html+='<div class="bk '+tsh+'"><div class="t"><span class="n">Trenches</span></div><div class="w">'+(t.window_open?'WINDOW OPEN':'NO WINDOW')+'</div>'+
'<div class="why">'+(t.window_open?'BTC did +10% in a week on '+t.last_impulse+'. Launchpad volume was higher two weeks later 11 of the last 14 times. Closes '+t.window_closes+'.':
'Opens when BTC does +10% in a week. Last impulse '+(t.last_impulse||'none')+'.')+'</div>'+
'<div class="nums"><span>launchpad share <b>'+(t.share_7d!=null?t.share_7d+'%':'\u2014')+'</b></span><span>90d avg <b>'+(t.share_90d_avg!=null?t.share_90d_avg+'%':'\u2014')+'</b></span><span>BTC 7d <b class="'+cls(t.btc_7d)+'">'+pct(t.btc_7d,1)+'</b></span></div></div>';
el('strip').innerHTML=html;
[].forEach.call(document.querySelectorAll('.bk canvas'),function(cv){var b=by[cv.dataset.k];var s=(b.spark||[]).filter(function(v){return v!=null;});if(s.length<2)return;
var a=setup(cv,34),x=a[0],W=a[1],mn=Math.min.apply(null,s),mx=Math.max.apply(null,s);x.beginPath();
s.forEach(function(v,i){var X=W*i/(s.length-1),Y=31-28*(v-mn)/((mx-mn)||1);i?x.lineTo(X,Y):x.moveTo(X,Y);});
x.strokeStyle=s[s.length-1]>=s[0]?C.green:C.red;x.lineWidth=1.4;x.stroke();});
drawMap(R.series); drawEq(R.series);
// scoreboard
var sc=R.score||{}; el('scoreline').innerHTML=(sc.major!=null?'<b>'+sc.caught+' of '+sc.major+'</b> major rotations caught since June 2020 · median '+sc.median_lag+' days late':'');
var nm={CASH:'cash (bear)',BTC:'BTC only',ALTS:'alts'};
el('rots').innerHTML=(R.rotations||[]).map(function(r){return '<tr><td>'+r.from+' to '+r.to+'</td><td>'+nm[r.bucket]+'</td><td class="n">+'+r.edge+'%</td><td class="n">'+(r.lag==null?'never':r.lag)+'</td><td class="n">'+r.held+'%</td><td class="'+(r.caught?'pos':'neg')+'">'+(r.caught?'yes':'MISSED')+'</td></tr>';}).join('');
// record
var B=R.books, rows=[['The book (all three layers)','book'],['BTC with the risk switch only','btc_trend'],['Just hold BTC','hold_btc']];
el('rec').innerHTML=rows.map(function(r){var h=B.holdout[r[1]],c=B.recent[r[1]];
return '<tr><td>'+r[0]+'</td><td class="n '+cls(h.total)+'">'+pct(h.total)+'</td><td class="n neg">'+pct(h.maxdd)+'</td><td class="n '+cls(c.total)+'">'+pct(c.total)+'</td><td class="n neg">'+pct(c.maxdd)+'</td></tr>';}).join('');
el('recnote').innerHTML='<b>Read this honestly.</b> Version 1 rules were set on 2024-07 onward and never fitted to 2020 to 2024. '+
'Version 2 (28 Sep 2026) changed one knob after looking at both periods: the risk switch moved from the 50 to the 100 day average. So 2020 to 2024 is no longer fully unseen. What protects it: 100 days with any band from 0 to 3%, and 150 days, all beat 50 in both periods, 75, 100 and 150 days all had a better worst calendar year, and the result holds with 1% fees, 4 day late fills, and the best 2020 to 2021 coins removed from every basket. '+
'The alt baskets are today’s surviving coins, which flatters 2020 to 2021.';
var e=R.evidence, g=function(a){return a[0]+' of '+a[1];};
el('ev').innerHTML=[
['Risk switch','BTC over its 100 day average, 1% band','Cut the worst drop of holding BTC from '+pct(B.holdout.hold_btc.maxdd)+' to '+pct(B.holdout.btc_trend.maxdd)+' (2020 to 2024) and '+pct(B.recent.hold_btc.maxdd)+' to '+pct(B.recent.btc_trend.maxdd)+' (since). 100 days (any band 0 to 3%) and 150 days beat 50 on return and max drop in both periods; 75 and 125 did not on one of them. Worst calendar year -34% vs -52%','<span class="grade g1">validated</span>'],
['Basket top, red','70%+ of members beat BTC over 90d, then under 50% over 30d','Lagged BTC over the next 60 days '+g(e.basket_top_holdout)+' in 2020 to 2024, '+g(e.basket_top_recent)+' since','<span class="grade g1">validated</span>'],
['Alt bottom, green','alt clock 5% or less inside 60 days, then 30d clock 40%+, risk on','A safety filter. In 2020 to 2024 plain \u201cown alts whenever BTC trends\u201d made about the same (+4414% vs '+pct(B.holdout.book.total)+'). Since 2024 that lost 14% (worst drop -59%) while the book made '+pct(B.recent.book.total),'<span class="grade g2">filter</span>'],
['Alt top warning','alt clock 70%+ inside 30 days, fast clock rolled','As an automatic exit it cut 2020 to 2024 from +4278% to +1807%. A warning, never a trade','<span class="grade g3">context</span>'],
['Crowding as a warning','funding in its own top fifth','Rechecked 28 Sep: crowded baskets did better, not worse, over the next 30 days (-1.3% vs -2.4%; top twentieth +29%). Shown as a number only, never a colour. Data only from 2024','<span class="grade g3">rejected</span>'],
['Trench window','BTC +10% in a week','Launchpad volume up 14 days later 11 of 14 (2024 on). On meme prices it is weak: memes beat BTC 19 of 36 times in 2020 to 2024 (39% in any window), 5 of 11 since','<span class="grade g2">activity only</span>'],
['Stablecoin supply growing','total stablecoins up over 60 days','As an extra condition: smaller drops in 2020 to 2024 (-38% vs -53%) but less return since 2024 (+55% vs +68%). Does not predict alts vs BTC','<span class="grade g3">rejected</span>'],
['Alts vs BTC trend','alt basket, ETH or each basket above its own 30 to 100 day trend vs BTC','16 versions tried, each on the old and new risk switch. None beat the book in both periods; most lost money since 2024','<span class="grade g3">rejected</span>'],
['Breadth thrust entry','30d alt clock 60 to 80% as an extra way into alts','7 versions (as a gate or an extra entry). None beat the book in both periods','<span class="grade g3">rejected</span>'],
['Basket washout on its own','a basket bouncing off its own low','Beat BTC only 19 to 33% of the time','<span class="grade g3">rejected</span>']
].map(function(r){return '<tr><td>'+r[0]+'</td><td class="note">'+r[1]+'</td><td class="note">'+r[2]+'</td><td>'+r[3]+'</td></tr>';}).join('');
el('legs').innerHTML=R.legs.slice(-14).reverse().map(function(l){return '<tr><td>'+l.from+'</td><td>'+(l.to||'open')+'</td><td>'+l.held+'</td><td class="n '+cls(l.ret)+'">'+pct(l.ret,1)+'</td></tr>';}).join('');
});
function drawMap(S){var cv=el('map'),H=300,a=setup(cv,H),x=a[0],W=a[1],L=34,R_=8,T=8,Bb=46,w=W-L-R_,h=H-T-Bb,n=S.d.length;
var X=function(i){return L+w*i/(n-1);},Y=function(v){return T+h-h*v/100;};
x.fillStyle='rgba(242,85,90,.14)';x.fillRect(L,Y(100),w,Y(70)-Y(100));x.fillStyle='rgba(62,207,142,.16)';x.fillRect(L,Y(5),w,Y(0)-Y(5));
x.strokeStyle=C.rule;x.lineWidth=1;[0,25,50,75,100].forEach(function(v){x.beginPath();x.moveTo(L,Y(v));x.lineTo(L+w,Y(v));x.stroke();});
x.beginPath();var st=false;S.clock90.forEach(function(v,i){if(v==null){st=false;return;}st?x.lineTo(X(i),Y(v)):x.moveTo(X(i),Y(v));st=true;});
x.strokeStyle=C.ink;x.lineWidth=1.5;x.stroke();
var col={ALTS:'#3ecf8e',BTC:'#f0a83c',CASH:'#3a414d'};S.held.forEach(function(k,i){x.fillStyle=col[k];x.fillRect(X(i),H-Bb+10,Math.max(1,w/(n-1))+.5,12);});
x.fillStyle=C.faint;x.font='10px ui-monospace,Menlo,monospace';x.textAlign='right';[0,50,100].forEach(function(v){x.fillText(v+'%',L-5,Y(v)+3);});
x.textAlign='center';var last='';S.d.forEach(function(d,i){var yr=d.slice(0,4);if(yr!==last&&d.slice(5,7)<='01'){x.fillText(yr,X(i),H-6);last=yr;}});}
function drawEq(S){var cv=el('eq'),H=230,a=setup(cv,H),x=a[0],W=a[1],L=44,R_=8,T=8,Bb=22,w=W-L-R_,h=H-T-Bb,n=S.d.length;
var all=S.book.concat(S.btc).filter(function(v){return v>0;}),mn=Math.log(Math.min.apply(null,all)),mx=Math.log(Math.max.apply(null,all));
var X=function(i){return L+w*i/(n-1);},Y=function(v){return T+h-h*(Math.log(v)-mn)/((mx-mn)||1);};
x.strokeStyle=C.rule;x.fillStyle=C.faint;x.font='10px ui-monospace,Menlo,monospace';x.textAlign='right';
[1,2,5,10,20,50].forEach(function(m){if(Math.log(m)<mn||Math.log(m)>mx)return;x.beginPath();x.moveTo(L,Y(m));x.lineTo(L+w,Y(m));x.stroke();x.fillText(m+'x',L-5,Y(m)+3);});
[['btc',C.accent],['book',C.green]].forEach(function(p){x.beginPath();S[p[0]].forEach(function(v,i){i?x.lineTo(X(i),Y(v)):x.moveTo(X(i),Y(v));});x.strokeStyle=p[1];x.lineWidth=1.7;x.stroke();});
x.textAlign='center';var last='';S.d.forEach(function(d,i){var yr=d.slice(0,4);if(yr!==last&&d.slice(5,7)<='01'){x.fillText(yr,X(i),H-6);last=yr;}});}
</script></body></html>
lab/lab.py
"""Rotation lab: ground truth for 'major rotations' and a scoreboard any rule
can be graded on. Hindsight is used ONLY to label, never inside a rule."""
import json, math, os, sys
sys.path.insert(0, "/home/claude/rotmap/deploy")
import rotmap as R
ST = "/home/claude/rotmap/deploy/store"
M = None
def load():
global M
if M is None:
M = R.Model(json.load(open(ST + "/px.json")), json.load(open(ST + "/fund.json")))
return M
def lr(M, b, i, n):
a, z = M.idx[b][i], (M.idx[b][i + n] if i + n < M.N else None)
return math.log(z / a) if a and z else None
def alts_idx(M):
"""Equal weight of the six alt baskets, daily rebalanced."""
out, lvl = [None] * M.N, None
for i in range(1, M.N):
rs = [math.log(M.idx[b][i] / M.idx[b][i - 1]) for b in R.ALT_BASKETS if M.idx[b][i] and M.idx[b][i - 1]]
if len(rs) >= 2:
lvl = (lvl or 100.0) * math.exp(sum(rs) / len(rs))
out[i] = lvl
return out
def oracle(M, H=45, min_run=21, lo="2020-06-01"):
"""Best of CASH / BTC / ALTS over the next H days, runs shorter than
min_run merged into their neighbour. Returns list of (label) per day and
the run list [(start_i, end_i, label, edge_log)]."""
A = alts_idx(M); M.idx["ALTS"] = A
i0 = M.pos[lo]; lab = [None] * M.N
for i in range(i0, M.N - H):
c = {"CASH": 0.0, "BTC": lr(M, "BTC", i, H), "ALTS": lr(M, "ALTS", i, H)}
c = {k: v for k, v in c.items() if v is not None}
lab[i] = max(c, key=c.get)
# runs
runs, s = [], i0
for i in range(i0 + 1, M.N - H + 1):
if i == M.N - H or lab[i] != lab[s]:
runs.append([s, i - 1, lab[s]]); s = i
changed = True
while changed:
changed = False
for k, r in enumerate(runs):
if r[1] - r[0] + 1 < min_run and len(runs) > 1:
j = k - 1 if k > 0 else k + 1
if k > 0 and k + 1 < len(runs): # merge into the longer neighbour
j = k - 1 if (runs[k-1][1]-runs[k-1][0]) >= (runs[k+1][1]-runs[k+1][0]) else k + 1
a, b = sorted((k, j))
runs[a] = [runs[a][0], runs[b][1], runs[j][2]]; del runs[b]
changed = True; break
# join equal neighbours
k = 0
while k + 1 < len(runs):
if runs[k][2] == runs[k + 1][2]:
runs[k][1] = runs[k + 1][1]; del runs[k + 1]; changed = True
else:
k += 1
lab2 = [None] * M.N
out = []
for s, e, l in runs:
for i in range(s, e + 1): lab2[i] = l
def gr(b):
if b == "CASH": return 0.0
a, z = M.idx[b][s], M.idx[b][min(e + 1, M.N - 1)]
return math.log(z / a)
rets = {k: gr(k) for k in ("CASH", "BTC", "ALTS")}
edge = rets[l] - max(v for k, v in rets.items() if k != l)
out.append((s, e, l, edge, rets))
return lab2, out
def bucket(w):
if not w: return "CASH"
alt = sum(v for k, v in w.items() if k not in ("BTC",))
return "ALTS" if alt >= 0.5 else "BTC"
def score(M, rule, lo="2020-06-01", hi=None, H=45, label=None, runs=None, verbose=False):
hi = hi or M.days[M.N - H - 1]
i0, i1 = M.pos[lo], M.pos[hi]
if label is None: label, runs = oracle(M, H)
curve, log = M.backtest(rule, i0, i1)
held = {M.pos[d]: bucket(w) for d, _, w in curve}
days = [i for i in range(i0, i1 + 1) if label[i]]
acc = sum(held[i] == label[i] for i in days) / len(days)
# capture on daily returns: system vs oracle vs worst
def dret(b, i):
if b == "CASH": return 0.0
a, z = M.idx[b][i], M.idx[b][i + 1]
return math.log(z / a) if a and z else 0.0
got = best = base = 0.0
for i in days[:-1]:
opts = {k: dret(k, i) for k in ("CASH", "BTC", "ALTS")}
got += opts[held[i]]; best += opts[label[i]]; base += sum(opts.values()) / 3
# event table for major runs
ev = []
for s, e, l, edge, rets in runs:
if s < i0 or e > i1 or edge < math.log(1.20): continue
lag = next((k - s for k in range(s, e + 1) if held.get(k) == l), None)
share = sum(held.get(k) == l for k in range(s, e + 1)) / (e - s + 1)
ev.append((M.days[s], M.days[e], l, round(100 * (math.exp(edge) - 1)), lag, round(100 * share)))
caught = sum(1 for x in ev if x[5] >= 50)
eq = curve[-1][1] / curve[0][1]
return {"acc": round(100 * acc), "capture": round(100 * (got - base) / (best - base)), "major": len(ev),
"caught": caught, "med_lag": sorted([x[4] for x in ev if x[4] is not None])[len([x for x in ev if x[4] is not None]) // 2] if any(x[4] is not None for x in ev) else None,
"switches": len(log), "ret": round(100 * (eq - 1)), "events": ev}
lab/rules.py
"""Candidate rules. Each returns rule(i) -> weights, using only data up to i."""
import math, rotmap as R, lab
def ratio(M, a, b="BTC"):
return [(M.idx[a][i] / M.idx[b][i]) if M.idx[a][i] and M.idx[b][i] else None for i in range(M.N)]
def ma(x, i, n):
v = [y for y in x[max(0, i - n + 1):i + 1] if y is not None]
return sum(v) / len(v) if len(v) >= n * 0.8 else None
def trend_state(x, n, band):
out, s = [], False
for i in range(len(x)):
m = ma(x, i, n)
if m and x[i]:
if x[i] > m * (1 + band): s = True
elif x[i] < m * (1 - band): s = False
out.append(s)
return out
def alts_w(M, i, keep=None):
bs = [b for b in R.ALT_BASKETS if M.idx[b][i] and (keep is None or b in keep)]
return {b: 1 / len(bs) for b in bs} if bs else {"BTC": 1.0}
def rs_gate(M, n=50, band=0.02, src="ALTS"):
"""Risk on + alts/BTC ratio in an uptrend -> alts. Risk on only -> BTC."""
if "ALTS" not in M.idx: M.idx["ALTS"] = lab.alts_idx(M)
st = trend_state(ratio(M, src), n, band)
return lambda i: ({} if not M.risk_on(i) else (alts_w(M, i) if st[i] else {"BTC": 1.0}))
def breadth_gate(M, on=0.55, off=0.40):
s = {"on": False}
def rule(i):
c = M.c30[i]
if c is not None:
if c >= on: s["on"] = True
elif c < off: s["on"] = False
if not M.risk_on(i): return {}
return alts_w(M, i) if s["on"] else {"BTC": 1.0}
return rule
def book_plus_rs(M, n=50, band=0.02):
"""Book G, plus: risk on and alts/BTC in uptrend also opens alts; the
alts/BTC downtrend closes them."""
if "ALTS" not in M.idx: M.idx["ALTS"] = lab.alts_idx(M)
st = trend_state(ratio(M, "ALTS"), n, band)
g = {"on": False, "dropped": set()}
def rule(i):
up = M.risk_on(i)
if not up: g["on"] = False
elif not g["on"] and (M.alt_bottom(i) or st[i]): g["on"], g["dropped"] = True, set()
elif g["on"] and not st[i] and not M.alt_bottom(i): g["on"] = False
if not g["on"]: return {"BTC": 1.0} if up else {}
for b in R.ALT_BASKETS:
if M.basket_top(b, i): g["dropped"].add(b)
have = [b for b in R.ALT_BASKETS if M.idx[b][i]]
live = [b for b in have if b not in g["dropped"]]
w = {b: 1 / len(have) for b in live}
if len(live) < len(have): w["BTC"] = 1 - sum(w.values())
return w
return rule
def basket_rs(M, n=50, band=0.02, top=None):
"""Risk on: hold every alt basket whose own ratio to BTC is in uptrend; the
rest in BTC. Optionally only the top-k by 30d relative strength."""
st = {b: trend_state(ratio(M, b), n, band) for b in R.ALT_BASKETS}
def rule(i):
if not M.risk_on(i): return {}
on = [b for b in R.ALT_BASKETS if M.idx[b][i] and st[b][i]]
if top and len(on) > top:
on = sorted(on, key=lambda b: M.ret(b, i, 30) or -9)[-top:]
if not on: return {"BTC": 1.0}
k = max(len(on), 3) # never more than a third per basket
w = {b: 1 / k for b in on}
if sum(w.values()) < 1: w["BTC"] = 1 - sum(w.values())
return w
return rule
lab/it1.py
import lab, math, sys, rotmap as R
R.INDEX_NEED = int(sys.argv[1]) if len(sys.argv) > 1 else 1
R.RISK_MA = int(sys.argv[2]) if len(sys.argv) > 2 else 50
print('RISK_MA', R.RISK_MA)
M = lab.load()
label, runs = lab.oracle(M)
print("INDEX_NEED", R.INDEX_NEED, "| ORACLE RUNS (45d best of CASH/BTC/ALTS, min run 21d), MAJOR = edge >= 20%")
for s, e, l, edge, r in runs:
tag = "MAJOR" if edge >= math.log(1.2) else ""
print(" %s .. %s %-4s %4dd edge %+5.0f%% BTC %+5.0f%% ALTS %+5.0f%% %s" % (M.days[s], M.days[e], l, e - s + 1,
100 * (math.exp(edge) - 1), 100 * (math.exp(r["BTC"]) - 1), 100 * (math.exp(r["ALTS"]) - 1), tag))
rules = {"hold BTC": lambda M: (lambda i: {"BTC": 1.0}),
"BTC risk switch": lambda M: (lambda i: {"BTC": 1.0} if M.risk_on(i) else {}),
"book G (live)": lambda M: M.book_rule()[0],
"alts when risk on": lambda M: (lambda i: {b: 1/6 for b in R.ALT_BASKETS if M.idx[b][i]} if M.risk_on(i) else {})}
for per in [("2020-06-01", "2024-06-30"), ("2024-07-01", None)]:
print("\nPERIOD", per)
for n, f in rules.items():
s = lab.score(M, f(M), per[0], per[1], label=label, runs=runs)
print(" %-18s acc %3d%% capture %3d%% majors caught %d/%d median lag %s d switches %d ret %+d%%" % (
n, s["acc"], s["capture"], s["caught"], s["major"], s["med_lag"], s["switches"], s["ret"]))
s = lab.score(M, M.book_rule()[0], "2020-06-01", None, label=label, runs=runs)
print("\nBOOK G, every major rotation (start, end, bucket, edge%, lag to hold it, % of run held):")
for x in s["events"]: print(" ", x)
lab/it2.py
import lab, rules as Q, rotmap as R, math
import sys
R.INDEX_NEED = 1
R.RISK_MA = int(sys.argv[1]) if len(sys.argv) > 1 else 50
print('RISK_MA', R.RISK_MA)
M = lab.load(); label, runs = lab.oracle(M)
C = {"risk switch": lambda: (lambda i: {"BTC": 1.0} if M.risk_on(i) else {}),
"book G": lambda: M.book_rule()[0],
"alts when risk on": lambda: (lambda i: Q.alts_w(M, i) if M.risk_on(i) else {})}
for n in (30, 50, 100):
for band in (0.0, 0.02, 0.05):
C["RS gate ALTS/BTC %dd b%.0f%%" % (n, band*100)] = (lambda n=n, band=band: Q.rs_gate(M, n, band))
C["RS gate ETH/BTC 50d b2%"] = lambda: Q.rs_gate(M, 50, 0.02, "ETH")
for on, off in ((0.5, 0.3), (0.55, 0.4), (0.6, 0.45), (0.7, 0.5)):
C["breadth gate %.0f/%.0f" % (on*100, off*100)] = (lambda on=on, off=off: Q.breadth_gate(M, on, off))
for n in (50, 100):
C["book G + RS %dd" % n] = (lambda n=n: Q.book_plus_rs(M, n, 0.02))
C["basket RS %dd all" % n] = (lambda n=n: Q.basket_rs(M, n, 0.02))
C["basket RS %dd top2" % n] = (lambda n=n: Q.basket_rs(M, n, 0.02, 2))
P = [("HOLD", "2020-06-01", "2024-06-30"), ("RECENT", "2024-07-01", None)]
print("%-30s" % "" + "".join("%-46s" % p[0] for p in P))
for name, mk in C.items():
cells = []
for _, a, z in P:
s = lab.score(M, mk(), a, z, label=label, runs=runs)
c, dd = M.backtest(mk(), M.pos[a], M.pos[z] if z else M.N - 1)[0], None
st = R.stats(c)
cells.append("cap %3d%% maj %d/%d ret %+6d%% dd %4.0f%% sw %3d" % (s["capture"], s["caught"], s["major"], st["total"], st["maxdd"], s["switches"]))
print("%-30s" % name + " ".join(cells))
lab/it2b.py
import lab, rules as Q, rotmap as R, math
R.INDEX_NEED = 1
R.RISK_MA = 50 # baseline; the script sets its own risk series where it matters
M = lab.load(); label, runs = lab.oracle(M)
base_risk = list(M._risk)
btc = M.idx["BTC"]
def risk_series(n, band):
return Q.trend_state(btc, n, band)
def ens(ns=(20, 50, 100, 200), band=0.01):
ss = [risk_series(n, band) for n in ns]
return [sum(s[i] for s in ss) / len(ss) for i in range(M.N)]
def wrap(rulef, frac):
r = rulef()
return lambda i: {k: v * frac[i] for k, v in r(i).items()}
P = [("HOLD", "2020-06-01", "2024-06-30"), ("RECENT", "2024-07-01", None)]
def show(name, mk):
cells = []
for _, a, z in P:
s = lab.score(M, mk(), a, z, label=label, runs=runs)
st = R.stats(M.backtest(mk(), M.pos[a], M.pos[z] if z else M.N - 1)[0])
cells.append("cap %3d%% maj %d/%d ret %+6d%% dd %4.0f%% sw %3d" % (s["capture"], s["caught"], s["major"], st["total"], st["maxdd"], s["switches"]))
print("%-34s" % name + " ".join(cells))
for n in (20, 30, 50, 100, 200):
for band in (0.0, 0.01, 0.03):
M._risk = risk_series(n, band)
show("BTC>%dd b%d%% | trend" % (n, band*100), lambda: (lambda i: {"BTC": 1.0} if M._risk[i] else {}))
show("BTC>%dd b%d%% | book G" % (n, band*100), lambda: M.book_rule()[0])
for ns in ((20, 50, 100, 200), (50, 100, 200), (20, 50, 100)):
f = ens(ns)
M._risk = [x >= 0.5 for x in f]
show("ENS %s | trend" % (ns,), lambda: (lambda i: {"BTC": f[i]} if f[i] else {}))
show("ENS %s | book G" % (ns,), lambda: wrap(lambda: M.book_rule()[0], f))
M._risk = base_risk
lab/it3.py
import lab, rules as Q, rotmap as R, math
R.INDEX_NEED = 1
R.RISK_MA = 50 # baseline; the script sets its own risk series where it matters
M = lab.load()
btc = M.idx["BTC"]
def rs(n, b): return Q.trend_state(btc, n, b)
def ens(ns=(50, 100, 200), band=0.01):
ss = [rs(n, band) for n in ns]; return [sum(s[i] for s in ss) / len(ss) for i in range(M.N)]
V = {}
for n, b in ((50, .01), (100, .0), (100, .01), (100, .03), (75, .01), (150, .01)):
V["G on %dd b%d%%" % (n, b*100)] = ("bin", rs(n, b))
V["G on ENS 50/100/200"] = ("frac", ens())
V["hold BTC"] = ("hold", None)
yrs = [("2020-06-01", "2020-12-31")] + [("%d-01-01" % y, "%d-12-31" % y) for y in range(2021, 2026)] + [("2026-01-01", M.days[-1])]
print("%-22s" % "" + "".join("%-9s" % a[:4] for a, _ in yrs) + " worst-yr beats-hold")
for name, (kind, series) in V.items():
row, worst, beats = [], 9e9, 0
for a, z in yrs:
def mk():
if kind == "hold": return lambda i: {"BTC": 1.0}
M._risk = [x >= 0.5 for x in series] if kind == "frac" else series
g = M.book_rule()[0]
if kind == "frac": return lambda i: {k: v * series[i] for k, v in g(i).items()}
return g
r = mk(); c = M.backtest(r, M.pos[a], M.pos[z])[0]
t = 100 * (c[-1][1] / c[0][1] - 1)
h = M.idx["BTC"][M.pos[z]] / M.idx["BTC"][M.pos[a]] * 100 - 100
row.append("%+7.0f%%" % t); worst = min(worst, t); beats += t > h
print("%-22s" % name + " ".join(row) + " %+6.0f%% %d/7" % (worst, beats))
lab/it3b.py
import lab, rules as Q, rotmap as R, math, json, copy
R.INDEX_NEED = 1
R.RISK_MA = 50 # baseline; the script sets its own risk series where it matters
px = json.load(open(lab.ST + "/px.json")); fd = json.load(open(lab.ST + "/fund.json"))
def build(px, n=100, band=0.01, top=0.70, wash=0.05):
R.CLOCK_TOP, R.CLOCK_WASH = top, wash
M = R.Model(px, fd); M._risk = Q.trend_state(M.idx["BTC"], n, band); return M
def run(M, a="2020-06-01", z="2024-06-30", fee=None, lag=1):
if fee is not None: R.FEE = fee
g = M.book_rule()[0]
if lag > 1:
g0 = g; g = lambda i: g0(i - (lag - 1)) if i - (lag - 1) >= 0 else {}
s = R.stats(M.backtest(g, M.pos[a], M.pos[z] if z else M.N - 1)[0]); R.FEE = 0.003
return "%+6.0f%% dd %4.0f%%" % (s["total"], s["maxdd"])
P = [("2020-06-01", "2024-06-30"), ("2024-07-01", None)]
def both(M, **k): return " | ".join(run(M, a, z, **k) for a, z in P)
print("PARAMS (100d b1% risk)")
for top in (0.6, 0.7, 0.8):
for wash in (0.03, 0.05, 0.10):
print(" top %.2f wash %.2f %s" % (top, wash, both(build(px, top=top, wash=wash))))
M = build(px)
print("COSTS fee 1%%: %s" % both(M, fee=0.01))
print(" fee 2%%: %s" % both(M, fee=0.02))
print("LAG 2 days: %s" % both(M, lag=2))
print(" 4 days: %s" % both(M, lag=4))
# survivorship stress: drop each basket's best performers over 2020-06..2021-12
print("SURVIVORSHIP STRESS: remove the top performers of 2020-21 from every alt basket")
for k in (1, 2, 3):
p2 = copy.deepcopy(px)
for b in R.ALT_BASKETS:
mem = [s for s in R.BASKETS[b] if s in px and len(R.BASKETS[b]) > 1]
def perf(s):
d = sorted(d for d in px[s] if "2020-06-01" <= d <= "2021-12-31")
return px[s][d[-1]] / px[s][d[0]] if len(d) > 60 else 0
for s in sorted(mem, key=perf)[-k:]:
if perf(s) > 0: p2.pop(s, None)
M2 = build(p2)
hold_alts = R.stats(M2.backtest(lambda i: Q.alts_w(M2, i), M2.pos["2020-06-01"], M2.pos["2024-06-30"])[0])
print(" drop top %d each: book %s (alts buy-and-hold holdout %+.0f%%)" % (k, both(M2), hold_alts["total"]))
lab/it4.py
import lab, rules as Q, rotmap as R, math, json, datetime as dt, statistics as st
R.INDEX_NEED = 1
R.RISK_MA = 50 # baseline; the script sets its own risk series where it matters
M = lab.load(); M._risk = Q.trend_state(M.idx["BTC"], 100, 0.01)
S = {dt.datetime.utcfromtimestamp(int(r["date"])).strftime("%Y-%m-%d"): r["totalCirculatingUSD"].get("peggedUSD") for r in json.load(open("stables.json"))}
sc = [S.get(d) for d in M.days]
for i in range(1, M.N):
if sc[i] is None: sc[i] = sc[i - 1]
def g(i, n): return (sc[i] / sc[i - n] - 1) if i >= n and sc[i] and sc[i - n] else None
P = [("2020-06-01", "2024-06-30"), ("2024-07-01", None)]
def both(rule):
out = []
for a, z in P:
s = R.stats(M.backtest(rule(), M.pos[a], M.pos[z] if z else M.N - 1)[0]); out.append("%+6.0f%% dd %4.0f%%" % (s["total"], s["maxdd"]))
return " | ".join(out)
print("LIQUIDITY LAYER (stablecoin supply growth) as an extra condition")
print(" G on 100d (base) ", both(lambda: M.book_rule()[0]))
for n in (30, 60, 90):
base = list(M._risk)
M._risk = [base[i] and (g(i, n) or 0) > 0 for i in range(M.N)]
print(" G, risk also needs stables %2dd > 0" % n, both(lambda: M.book_rule()[0]))
M._risk = base
# how often is stables growth negative while BTC>100d? informative
neg = sum(1 for i in range(M.pos["2020-06-01"], M.N) if M._risk[i] and (g(i, 60) or 0) <= 0)
print(" days risk-on with stables 60d shrinking:", neg)
# forward alt vs BTC return by stablecoin growth tercile
print("\nSTABLES 60d growth vs next 60d ALTS minus BTC (alts basket)")
A = lab.alts_idx(M)
rows = []
for i in range(M.pos["2020-06-01"], M.N - 60, 5):
x = g(i, 60)
if x is None or not A[i] or not A[i + 60]: continue
rows.append((x, math.log(A[i + 60] / A[i]) - math.log(M.idx["BTC"][i + 60] / M.idx["BTC"][i]), M.days[i]))
rows.sort()
k = len(rows) // 3
for lab_, part in (("low", rows[:k]), ("mid", rows[k:2*k]), ("high", rows[2*k:])):
for per, f in (("hold", lambda d: d < "2024-07-01"), ("recent", lambda d: d >= "2024-07-01")):
v = [r[1] for r in part if f(r[2])]
if v: print(" %-4s tercile %-6s n=%3d median alts-vs-BTC %+5.1f%% win %2.0f%%" % (lab_, per, len(v), 100 * (math.exp(st.median(v)) - 1), 100 * sum(x > 0 for x in v) / len(v)))
# TRENCH window back-test on the MEMES basket (tradeable proxy, 2020+)
print("\nTRENCH WINDOW PROXY: BTC 7d crosses +10%, then MEMES vs BTC over 14d")
b = M.idx["BTC"]; mm = M.idx["MEMES"]
r7 = [(b[i] / b[i - 7] - 1) if i >= 7 and b[i] and b[i - 7] else None for i in range(M.N)]
ev = [i for i in range(M.pos["2020-06-01"], M.N - 14) if r7[i] and r7[i - 1] is not None and r7[i] >= 0.10 > r7[i - 1]]
ev2 = []
for i in ev:
if not ev2 or i - ev2[-1] > 14: ev2.append(i)
res = []
for i in ev2:
if mm[i] and mm[i + 14]:
rel = (mm[i + 14] / mm[i]) / (b[i + 14] / b[i]) - 1
res.append((M.days[i], rel, mm[i + 14] / mm[i] - 1))
for per, f in (("hold", lambda d: d < "2024-07-01"), ("recent", lambda d: d >= "2024-07-01")):
v = [r for r in res if f(r[0])]
print(" %-6s n=%2d memes beat BTC %d/%d median rel %+.1f%% memes up %d/%d" % (per, len(v), sum(r[1] > 0 for r in v), len(v), 100 * st.median([r[1] for r in v]), sum(r[2] > 0 for r in v), len(v)))
# base rate: any random 14d window
base = [(mm[i + 14] / mm[i]) / (b[i + 14] / b[i]) - 1 for i in range(M.pos["2020-06-01"], M.N - 14) if mm[i] and mm[i + 14]]
print(" base rate: memes beat BTC in any 14d window %.0f%%, median %+.1f%%" % (100 * sum(x > 0 for x in base) / len(base), 100 * st.median(base)))
lab/it4b.py
import lab, rules as Q, rotmap as R, math
R.INDEX_NEED = 1; R.RISK_MA = 100
M = lab.load(); label, runs = lab.oracle(M)
def g_var(thrust=None, top_exit=False):
g = {"on": False, "dropped": set()}
def rule(i):
up = M.risk_on(i)
if g["on"] and (not up or (top_exit and M.alt_top_warning(i))): g["on"] = False
elif not g["on"] and up and (M.alt_bottom(i) or (thrust and (M.c30[i] or 0) >= thrust and (M.c90[i] or 0) < 0.5)):
g["on"], g["dropped"] = True, set()
if not g["on"]: return {"BTC": 1.0} if up else {}
for b in R.ALT_BASKETS:
if M.basket_top(b, i): g["dropped"].add(b)
have = [b for b in R.ALT_BASKETS if M.idx[b][i]]
live = [b for b in have if b not in g["dropped"]]
w = {b: 1 / len(have) for b in live}
if len(live) < len(have): w["BTC"] = 1 - sum(w.values())
return w
return rule
P = [("2020-06-01", "2024-06-30"), ("2024-07-01", None)]
def show(n, mk):
cells = []
for a, z in P:
s = lab.score(M, mk(), a, z, label=label, runs=runs); st = R.stats(M.backtest(mk(), M.pos[a], M.pos[z] if z else M.N - 1)[0])
cells.append("cap %3d%% maj %d/%d ret %+6d%% dd %4.0f%% sw %3d" % (s["capture"], s["caught"], s["major"], st["total"], st["maxdd"], s["switches"]))
print("%-32s" % n + " | ".join(cells))
show("G 100d (v2 base)", lambda: g_var())
for t in (0.6, 0.7, 0.8):
show("G + thrust c30>=%.0f%% (c90<50)" % (t * 100), lambda t=t: g_var(t))
show("G + market top exit", lambda: g_var(None, True))
10 · Raw outputs of every test (28 Sep 2026)
out/it1_v1.txt
RISK_MA 50
INDEX_NEED 1 | ORACLE RUNS (45d best of CASH/BTC/ALTS, min run 21d), MAJOR = edge >= 20%
2020-06-01 .. 2020-07-31 ALTS 61d edge +20% BTC +16% ALTS +39% MAJOR
2020-08-01 .. 2020-09-02 CASH 33d edge +5% BTC -14% ALTS -4%
2020-09-03 .. 2020-12-07 BTC 96d edge +81% BTC +81% ALTS -6% MAJOR
2020-12-08 .. 2021-04-12 ALTS 126d edge +72% BTC +247% ALTS +497% MAJOR
2021-04-13 .. 2021-07-07 CASH 86d edge +19% BTC -48% ALTS -16%
2021-07-08 .. 2021-10-19 ALTS 104d edge +0% BTC +101% ALTS +101%
2021-10-20 .. 2022-06-10 CASH 234d edge +132% BTC -57% ALTS -77% MAJOR
2022-06-11 .. 2022-07-26 ALTS 46d edge +13% BTC -19% ALTS +13%
2022-07-27 .. 2022-11-19 CASH 116d edge +41% BTC -29% ALTS -39% MAJOR
2022-11-20 .. 2022-12-12 BTC 23d edge -1% BTC +9% ALTS +10%
2022-12-13 .. 2023-01-26 ALTS 45d edge +3% BTC +30% ALTS +34%
2023-01-27 .. 2023-03-25 BTC 58d edge +19% BTC +21% ALTS +2%
2023-03-26 .. 2023-05-06 CASH 42d edge -2% BTC +2% ALTS -3%
2023-05-07 .. 2023-06-09 BTC 34d edge -9% BTC -9% ALTS -24%
2023-06-10 .. 2023-06-30 ALTS 21d edge +1% BTC +18% ALTS +19%
2023-07-01 .. 2023-08-30 CASH 61d edge +18% BTC -15% ALTS -18%
2023-08-31 .. 2024-05-11 ALTS 255d edge +26% BTC +137% ALTS +200% MAJOR
2024-05-12 .. 2024-10-12 CASH 154d edge -2% BTC +2% ALTS -15%
2024-10-13 .. 2025-01-09 ALTS 89d edge -11% BTC +51% ALTS +34%
2025-01-10 .. 2025-03-25 CASH 75d edge +9% BTC -8% ALTS -45%
2025-03-26 .. 2025-08-31 ALTS 159d edge -1% BTC +26% ALTS +24%
2025-09-01 .. 2026-03-02 CASH 183d edge +60% BTC -37% ALTS -62% MAJOR
2026-03-03 .. 2026-04-13 BTC 42d edge +9% BTC +9% ALTS -0%
2026-04-14 .. 2026-07-21 CASH 99d edge +12% BTC -11% ALTS -12%
2026-07-22 .. 2026-08-13 ALTS 23d edge -6% BTC -5% ALTS -6%
PERIOD ('2020-06-01', '2024-06-30')
hold BTC acc 14% capture 17% majors caught 1/6 median lag 0 d switches 1 ret +558%
BTC risk switch acc 31% capture 30% majors caught 3/6 median lag 0 d switches 46 ret +895%
book G (live) acc 48% capture 53% majors caught 3/6 median lag 2 d switches 56 ret +3269%
alts when risk on acc 54% capture 55% majors caught 5/6 median lag 1 d switches 49 ret +4879%
PERIOD ('2024-07-01', None)
hold BTC acc 6% capture 48% majors caught 0/1 median lag None d switches 1 ret +2%
BTC risk switch acc 43% capture 75% majors caught 1/1 median lag 0 d switches 29 ret +22%
book G (live) acc 50% capture 54% majors caught 1/1 median lag 0 d switches 32 ret +3%
alts when risk on acc 70% capture -18% majors caught 1/1 median lag 0 d switches 29 ret -47%
BOOK G, every major rotation (start, end, bucket, edge%, lag to hold it, % of run held):
('2020-06-01', '2020-07-31', 'ALTS', 20, 2, 34)
('2020-09-03', '2020-12-07', 'BTC', 81, 0, 48)
('2020-12-08', '2021-04-12', 'ALTS', 72, 0, 100)
('2021-10-20', '2022-06-10', 'CASH', 132, 30, 70)
('2022-07-27', '2022-11-19', 'CASH', 41, 0, 67)
('2023-08-31', '2024-05-11', 'ALTS', 26, 68, 27)
('2025-09-01', '2026-03-02', 'CASH', 60, 0, 80)
out/it1_v2.txt
RISK_MA 100
INDEX_NEED 1 | ORACLE RUNS (45d best of CASH/BTC/ALTS, min run 21d), MAJOR = edge >= 20%
2020-06-01 .. 2020-07-31 ALTS 61d edge +20% BTC +16% ALTS +39% MAJOR
2020-08-01 .. 2020-09-02 CASH 33d edge +5% BTC -14% ALTS -4%
2020-09-03 .. 2020-12-07 BTC 96d edge +81% BTC +81% ALTS -6% MAJOR
2020-12-08 .. 2021-04-12 ALTS 126d edge +72% BTC +247% ALTS +497% MAJOR
2021-04-13 .. 2021-07-07 CASH 86d edge +19% BTC -48% ALTS -16%
2021-07-08 .. 2021-10-19 ALTS 104d edge +0% BTC +101% ALTS +101%
2021-10-20 .. 2022-06-10 CASH 234d edge +132% BTC -57% ALTS -77% MAJOR
2022-06-11 .. 2022-07-26 ALTS 46d edge +13% BTC -19% ALTS +13%
2022-07-27 .. 2022-11-19 CASH 116d edge +41% BTC -29% ALTS -39% MAJOR
2022-11-20 .. 2022-12-12 BTC 23d edge -1% BTC +9% ALTS +10%
2022-12-13 .. 2023-01-26 ALTS 45d edge +3% BTC +30% ALTS +34%
2023-01-27 .. 2023-03-25 BTC 58d edge +19% BTC +21% ALTS +2%
2023-03-26 .. 2023-05-06 CASH 42d edge -2% BTC +2% ALTS -3%
2023-05-07 .. 2023-06-09 BTC 34d edge -9% BTC -9% ALTS -24%
2023-06-10 .. 2023-06-30 ALTS 21d edge +1% BTC +18% ALTS +19%
2023-07-01 .. 2023-08-30 CASH 61d edge +18% BTC -15% ALTS -18%
2023-08-31 .. 2024-05-11 ALTS 255d edge +26% BTC +137% ALTS +200% MAJOR
2024-05-12 .. 2024-10-12 CASH 154d edge -2% BTC +2% ALTS -15%
2024-10-13 .. 2025-01-09 ALTS 89d edge -11% BTC +51% ALTS +34%
2025-01-10 .. 2025-03-25 CASH 75d edge +9% BTC -8% ALTS -45%
2025-03-26 .. 2025-08-31 ALTS 159d edge -1% BTC +26% ALTS +24%
2025-09-01 .. 2026-03-02 CASH 183d edge +60% BTC -37% ALTS -62% MAJOR
2026-03-03 .. 2026-04-13 BTC 42d edge +9% BTC +9% ALTS -0%
2026-04-14 .. 2026-07-21 CASH 99d edge +12% BTC -11% ALTS -12%
2026-07-22 .. 2026-08-13 ALTS 23d edge -6% BTC -5% ALTS -6%
PERIOD ('2020-06-01', '2024-06-30')
hold BTC acc 14% capture 17% majors caught 1/6 median lag 0 d switches 1 ret +558%
BTC risk switch acc 36% capture 24% majors caught 3/6 median lag 0 d switches 30 ret +719%
book G (live) acc 57% capture 54% majors caught 5/6 median lag 10 d switches 46 ret +4279%
alts when risk on acc 58% capture 50% majors caught 5/6 median lag 1 d switches 33 ret +4213%
PERIOD ('2024-07-01', None)
hold BTC acc 6% capture 48% majors caught 0/1 median lag None d switches 1 ret +2%
BTC risk switch acc 41% capture 71% majors caught 1/1 median lag 0 d switches 14 ret +23%
book G (live) acc 56% capture 87% majors caught 1/1 median lag 0 d switches 17 ret +45%
alts when risk on acc 69% capture -5% majors caught 1/1 median lag 0 d switches 14 ret -38%
BOOK G, every major rotation (start, end, bucket, edge%, lag to hold it, % of run held):
('2020-06-01', '2020-07-31', 'ALTS', 20, 2, 97)
('2020-09-03', '2020-12-07', 'BTC', 81, 10, 74)
('2020-12-08', '2021-04-12', 'ALTS', 72, 0, 100)
('2021-10-20', '2022-06-10', 'CASH', 132, 45, 73)
('2022-07-27', '2022-11-19', 'CASH', 41, 0, 96)
('2023-08-31', '2024-05-11', 'ALTS', 26, 68, 30)
('2025-09-01', '2026-03-02', 'CASH', 60, 0, 88)
out/it2_risk50.txt
RISK_MA 50
HOLD RECENT
risk switch cap 30% maj 3/6 ret +894% dd -61% sw 46 cap 75% maj 1/1 ret +60% dd -31% sw 29
book G cap 53% maj 3/6 ret +3268% dd -59% sw 56 cap 54% maj 1/1 ret +35% dd -48% sw 32
alts when risk on cap 55% maj 5/6 ret +4914% dd -57% sw 49 cap -18% maj 1/1 ret -16% dd -68% sw 29
RS gate ALTS/BTC 30d b0% cap 60% maj 3/6 ret +3520% dd -63% sw 101 cap 2% maj 1/1 ret -39% dd -64% sw 73
RS gate ALTS/BTC 30d b2% cap 61% maj 3/6 ret +4611% dd -64% sw 69 cap 15% maj 1/1 ret -7% dd -60% sw 53
RS gate ALTS/BTC 30d b5% cap 55% maj 4/6 ret +3538% dd -64% sw 67 cap 54% maj 1/1 ret +54% dd -47% sw 38
RS gate ALTS/BTC 50d b0% cap 50% maj 4/6 ret +2750% dd -66% sw 77 cap 15% maj 1/1 ret -24% dd -59% sw 66
RS gate ALTS/BTC 50d b2% cap 55% maj 5/6 ret +3668% dd -65% sw 64 cap 15% maj 1/1 ret -10% dd -61% sw 46
RS gate ALTS/BTC 50d b5% cap 47% maj 4/6 ret +2609% dd -67% sw 65 cap 21% maj 1/1 ret +2% dd -53% sw 37
RS gate ALTS/BTC 100d b0% cap 53% maj 5/6 ret +3191% dd -64% sw 72 cap -4% maj 1/1 ret -38% dd -62% sw 62
RS gate ALTS/BTC 100d b2% cap 53% maj 5/6 ret +3446% dd -65% sw 60 cap 16% maj 1/1 ret -6% dd -56% sw 45
RS gate ALTS/BTC 100d b5% cap 50% maj 5/6 ret +3061% dd -61% sw 58 cap 13% maj 1/1 ret -5% dd -61% sw 34
RS gate ETH/BTC 50d b2% cap 47% maj 4/6 ret +2371% dd -58% sw 71 cap 6% maj 1/1 ret -5% dd -61% sw 39
breadth gate 50/30 cap 47% maj 4/6 ret +2420% dd -64% sw 71 cap 40% maj 1/1 ret +18% dd -50% sw 46
breadth gate 55/40 cap 47% maj 3/6 ret +2225% dd -60% sw 76 cap 42% maj 1/1 ret +11% dd -49% sw 48
breadth gate 60/45 cap 43% maj 3/6 ret +1881% dd -60% sw 71 cap 29% maj 1/1 ret +2% dd -54% sw 48
breadth gate 70/50 cap 43% maj 3/6 ret +1798% dd -61% sw 71 cap 47% maj 1/1 ret +23% dd -46% sw 43
book G + RS 50d cap 49% maj 5/6 ret +3255% dd -63% sw 80 cap 32% maj 1/1 ret +22% dd -48% sw 51
basket RS 50d all cap 48% maj 5/6 ret +4064% dd -64% sw 197 cap -3% maj 1/1 ret -9% dd -60% sw 105
basket RS 50d top2 cap 48% maj 5/6 ret +4276% dd -60% sw 165 cap -3% maj 1/1 ret +14% dd -50% sw 106
book G + RS 100d cap 52% maj 5/6 ret +3480% dd -64% sw 77 cap 27% maj 1/1 ret +9% dd -51% sw 58
basket RS 100d all cap 62% maj 5/6 ret +5012% dd -61% sw 151 cap -33% maj 1/1 ret -20% dd -65% sw 91
basket RS 100d top2 cap 62% maj 5/6 ret +5160% dd -60% sw 139 cap -33% maj 1/1 ret +3% dd -55% sw 90
out/it2_risk100.txt
RISK_MA 100
HOLD RECENT
risk switch cap 24% maj 3/6 ret +718% dd -52% sw 30 cap 71% maj 1/1 ret +42% dd -30% sw 14
book G cap 54% maj 5/6 ret +4278% dd -53% sw 46 cap 87% maj 1/1 ret +67% dd -30% sw 17
alts when risk on cap 50% maj 5/6 ret +4414% dd -58% sw 33 cap -5% maj 1/1 ret -13% dd -59% sw 14
RS gate ALTS/BTC 30d b0% cap 57% maj 5/6 ret +3153% dd -60% sw 99 cap 29% maj 1/1 ret -27% dd -56% sw 57
RS gate ALTS/BTC 30d b2% cap 63% maj 5/6 ret +5470% dd -58% sw 57 cap 37% maj 1/1 ret +7% dd -50% sw 35
RS gate ALTS/BTC 30d b5% cap 54% maj 5/6 ret +3849% dd -60% sw 54 cap 70% maj 1/1 ret +65% dd -35% sw 22
RS gate ALTS/BTC 50d b0% cap 50% maj 5/6 ret +2880% dd -62% sw 70 cap 25% maj 1/1 ret -21% dd -53% sw 47
RS gate ALTS/BTC 50d b2% cap 53% maj 6/6 ret +3638% dd -60% sw 51 cap 31% maj 1/1 ret -2% dd -54% sw 28
RS gate ALTS/BTC 50d b5% cap 41% maj 5/6 ret +2240% dd -60% sw 50 cap 26% maj 1/1 ret +0% dd -47% sw 21
RS gate ALTS/BTC 100d b0% cap 46% maj 6/6 ret +2530% dd -61% sw 61 cap 0% maj 1/1 ret -41% dd -62% sw 48
RS gate ALTS/BTC 100d b2% cap 46% maj 6/6 ret +2801% dd -60% sw 47 cap 20% maj 1/1 ret -10% dd -51% sw 30
RS gate ALTS/BTC 100d b5% cap 44% maj 5/6 ret +2554% dd -53% sw 43 cap 16% maj 1/1 ret -10% dd -56% sw 19
RS gate ETH/BTC 50d b2% cap 43% maj 5/6 ret +2203% dd -62% sw 59 cap 21% maj 1/1 ret +0% dd -51% sw 25
breadth gate 50/30 cap 42% maj 5/6 ret +2101% dd -59% sw 58 cap 42% maj 1/1 ret +12% dd -45% sw 28
breadth gate 55/40 cap 43% maj 4/6 ret +1942% dd -54% sw 67 cap 44% maj 1/1 ret +7% dd -44% sw 28
breadth gate 60/45 cap 41% maj 4/6 ret +1753% dd -52% sw 64 cap 37% maj 1/1 ret +4% dd -47% sw 29
breadth gate 70/50 cap 42% maj 4/6 ret +1850% dd -52% sw 59 cap 47% maj 1/1 ret +14% dd -40% sw 26
book G + RS 50d cap 46% maj 5/6 ret +3231% dd -59% sw 75 cap 47% maj 1/1 ret +29% dd -40% sw 35
basket RS 50d all cap 47% maj 6/6 ret +3887% dd -62% sw 193 cap 23% maj 1/1 ret +7% dd -42% sw 85
basket RS 50d top2 cap 47% maj 6/6 ret +3378% dd -59% sw 170 cap 23% maj 1/1 ret +29% dd -35% sw 75
book G + RS 100d cap 46% maj 5/6 ret +3031% dd -58% sw 72 cap 37% maj 1/1 ret +12% dd -40% sw 40
basket RS 100d all cap 57% maj 5/6 ret +5477% dd -61% sw 138 cap -23% maj 1/1 ret -21% dd -57% sw 71
basket RS 100d top2 cap 57% maj 5/6 ret +5139% dd -59% sw 128 cap -23% maj 1/1 ret -2% dd -47% sw 68
out/it2b.txt
BTC>20d b0% | trend cap 8% maj 3/6 ret +170% dd -72% sw 184 cap 71% maj 1/1 ret +21% dd -38% sw 82 BTC>20d b0% | book G cap 17% maj 4/6 ret +350% dd -68% sw 190 cap 55% maj 1/1 ret +2% dd -52% sw 86 BTC>20d b1% | trend cap 0% maj 3/6 ret +147% dd -70% sw 108 cap 38% maj 1/1 ret -1% dd -43% sw 48 BTC>20d b1% | book G cap 8% maj 5/6 ret +280% dd -73% sw 119 cap 16% maj 1/1 ret -21% dd -54% sw 51 BTC>20d b3% | trend cap 16% maj 2/6 ret +443% dd -60% sw 62 cap 53% maj 1/1 ret +19% dd -42% sw 35 BTC>20d b3% | book G cap 24% maj 3/6 ret +776% dd -57% sw 71 cap 15% maj 1/1 ret -17% dd -60% sw 39 BTC>30d b0% | trend cap 16% maj 3/6 ret +314% dd -63% sw 142 cap 54% maj 1/1 ret +6% dd -38% sw 70 BTC>30d b0% | book G cap 22% maj 4/6 ret +511% dd -61% sw 152 cap 37% maj 1/1 ret -10% dd -52% sw 74 BTC>30d b1% | trend cap 24% maj 3/6 ret +606% dd -55% sw 76 cap 66% maj 1/1 ret +15% dd -35% sw 42 BTC>30d b1% | book G cap 30% maj 4/6 ret +986% dd -53% sw 86 cap 50% maj 1/1 ret -1% dd -49% sw 46 BTC>30d b3% | trend cap 21% maj 2/6 ret +572% dd -62% sw 48 cap 46% maj 1/1 ret +22% dd -36% sw 27 BTC>30d b3% | book G cap 26% maj 2/6 ret +900% dd -60% sw 58 cap 34% maj 1/1 ret +11% dd -49% sw 29 BTC>50d b0% | trend cap 28% maj 3/6 ret +739% dd -62% sw 74 cap 65% maj 1/1 ret +33% dd -35% sw 53 BTC>50d b0% | book G cap 51% maj 3/6 ret +2722% dd -61% sw 84 cap 45% maj 1/1 ret +12% dd -51% sw 56 BTC>50d b1% | trend cap 30% maj 3/6 ret +894% dd -61% sw 46 cap 75% maj 1/1 ret +60% dd -31% sw 29 BTC>50d b1% | book G cap 53% maj 3/6 ret +3268% dd -59% sw 56 cap 54% maj 1/1 ret +35% dd -48% sw 32 BTC>50d b3% | trend cap 18% maj 3/6 ret +521% dd -57% sw 36 cap 51% maj 1/1 ret +31% dd -27% sw 23 BTC>50d b3% | book G cap 41% maj 3/6 ret +2036% dd -56% sw 46 cap 31% maj 1/1 ret +10% dd -44% sw 25 BTC>100d b0% | trend cap 27% maj 3/6 ret +777% dd -48% sw 52 cap 64% maj 1/1 ret +24% dd -27% sw 34 BTC>100d b0% | book G cap 57% maj 5/6 ret +4106% dd -48% sw 66 cap 79% maj 1/1 ret +46% dd -30% sw 37 BTC>100d b1% | trend cap 24% maj 3/6 ret +718% dd -52% sw 30 cap 71% maj 1/1 ret +42% dd -30% sw 14 BTC>100d b1% | book G cap 54% maj 5/6 ret +4278% dd -53% sw 46 cap 87% maj 1/1 ret +67% dd -30% sw 17 BTC>100d b3% | trend cap 34% maj 3/6 ret +1190% dd -35% sw 14 cap 58% maj 1/1 ret +25% dd -34% sw 12 BTC>100d b3% | book G cap 58% maj 4/6 ret +6056% dd -39% sw 30 cap 70% maj 1/1 ret +45% dd -34% sw 16 BTC>200d b0% | trend cap 23% maj 3/6 ret +680% dd -56% sw 25 cap 36% maj 1/1 ret -3% dd -30% sw 28 BTC>200d b0% | book G cap 45% maj 4/6 ret +3265% dd -39% sw 36 cap 42% maj 1/1 ret +4% dd -36% sw 34 BTC>200d b1% | trend cap 23% maj 3/6 ret +717% dd -57% sw 19 cap 29% maj 1/1 ret -12% dd -33% sw 16 BTC>200d b1% | book G cap 46% maj 4/6 ret +3481% dd -39% sw 30 cap 35% maj 1/1 ret -9% dd -36% sw 22 BTC>200d b3% | trend cap 26% maj 3/6 ret +842% dd -48% sw 9 cap 39% maj 1/1 ret -2% dd -30% sw 10 BTC>200d b3% | book G cap 49% maj 4/6 ret +4085% dd -39% sw 20 cap 52% maj 1/1 ret +10% dd -30% sw 15 ENS (20, 50, 100, 200) | trend cap 20% maj 2/6 ret +575% dd -54% sw 166 cap 48% maj 0/1 ret +20% dd -26% sw 84 ENS (20, 50, 100, 200) | book G cap 43% maj 5/6 ret +2435% dd -51% sw 144 cap 60% maj 1/1 ret +31% dd -31% sw 67 ENS (50, 100, 200) | trend cap 24% maj 3/6 ret +811% dd -49% sw 83 cap 33% maj 1/1 ret +27% dd -25% sw 53 ENS (50, 100, 200) | book G cap 48% maj 5/6 ret +3443% dd -41% sw 75 cap 95% maj 1/1 ret +67% dd -29% sw 35 ENS (20, 50, 100) | trend cap 21% maj 3/6 ret +515% dd -60% sw 153 cap 66% maj 1/1 ret +32% dd -29% sw 76 ENS (20, 50, 100) | book G cap 45% maj 5/6 ret +1835% dd -53% sw 121 cap 51% maj 1/1 ret +17% dd -45% sw 52
out/it3.txt
2020 2021 2022 2023 2024 2025 2026 worst-yr beats-hold G on 50d b1% +61% +633% -52% +145% +87% -4% +7% -52% 4/7 G on 100d b0% +155% +439% -24% +78% +47% +30% -2% -24% 4/7 G on 100d b1% +147% +572% -34% +76% +74% +22% +1% -34% 4/7 G on 100d b3% +89% +829% -11% +78% +88% +9% -3% -11% 4/7 G on 75d b1% +83% +401% -44% +63% +56% +25% +13% -44% 4/7 G on 150d b1% +89% +545% -14% +61% +75% +5% +1% -14% 4/7 G on ENS 50/100/200 +124% +395% -23% +81% +66% +18% +13% -23% 4/7 hold BTC +203% +43% -65% +153% +108% -10% -6% -65% 2/7
out/it3c_ma_sweep.txt
50 +3269% dd -59% | +36% dd -48% 75 +1724% dd -65% | +79% dd -33% 100 +4278% dd -53% | +68% dd -30% 125 +5733% dd -45% | +29% dd -34% 150 +3563% dd -48% | +42% dd -30%
out/it3b.txt
PARAMS (100d b1% risk)
top 0.60 wash 0.03 +3378% dd -53% | +68% dd -30%
top 0.60 wash 0.05 +4169% dd -53% | +68% dd -30%
top 0.60 wash 0.10 +3631% dd -59% | +49% dd -30%
top 0.70 wash 0.03 +3404% dd -53% | +68% dd -30%
top 0.70 wash 0.05 +4278% dd -53% | +68% dd -30%
top 0.70 wash 0.10 +3727% dd -59% | +44% dd -30%
top 0.80 wash 0.03 +3441% dd -53% | +67% dd -32%
top 0.80 wash 0.05 +4763% dd -53% | +67% dd -32%
top 0.80 wash 0.10 +4151% dd -59% | +47% dd -32%
COSTS fee 1%: +3035% dd -59% | +48% dd -33%
fee 2%: +1835% dd -66% | +24% dd -38%
LAG 2 days: +4921% dd -52% | +46% dd -33%
4 days: +5458% dd -46% | +33% dd -40%
SURVIVORSHIP STRESS: remove the top performers of 2020-21 from every alt basket
drop top 1 each: book +3292% dd -52% | +67% dd -30% (alts buy-and-hold holdout +1116%)
drop top 2 each: book +2271% dd -52% | +70% dd -29% (alts buy-and-hold holdout +1124%)
drop top 3 each: book +3108% dd -54% | +73% dd -29% (alts buy-and-hold holdout +1482%)
out/it4.txt
LIQUIDITY LAYER (stablecoin supply growth) as an extra condition G on 100d (base) +4278% dd -53% | +68% dd -30% G, risk also needs stables 30d > 0 +5059% dd -41% | +58% dd -30% G, risk also needs stables 60d > 0 +5779% dd -38% | +55% dd -32% G, risk also needs stables 90d > 0 +4761% dd -38% | +46% dd -30% days risk-on with stables 60d shrinking: 230 STABLES 60d growth vs next 60d ALTS minus BTC (alts basket) low tercile hold n=115 median alts-vs-BTC -6.0% win 39% low tercile recent n= 35 median alts-vs-BTC -6.2% win 11% mid tercile hold n= 62 median alts-vs-BTC -2.6% win 45% mid tercile recent n= 88 median alts-vs-BTC -2.2% win 41% high tercile hold n=122 median alts-vs-BTC -1.7% win 46% high tercile recent n= 28 median alts-vs-BTC -30.7% win 0% TRENCH WINDOW PROXY: BTC 7d crosses +10%, then MEMES vs BTC over 14d hold n=36 memes beat BTC 19/36 median rel +0.1% memes up 21/36 recent n=11 memes beat BTC 5/11 median rel -2.4% memes up 6/11 base rate: memes beat BTC in any 14d window 39%, median -2.8%
out/it4b.txt
G 100d (v2 base) cap 54% maj 5/6 ret +4278% dd -53% sw 46 | cap 87% maj 1/1 ret +67% dd -30% sw 17 G + thrust c30>=60% (c90<50) cap 48% maj 5/6 ret +3823% dd -59% sw 49 | cap 71% maj 1/1 ret +52% dd -33% sw 25 G + thrust c30>=70% (c90<50) cap 48% maj 5/6 ret +3649% dd -60% sw 48 | cap 83% maj 1/1 ret +73% dd -30% sw 18 G + thrust c30>=80% (c90<50) cap 53% maj 5/6 ret +4179% dd -54% sw 46 | cap 87% maj 1/1 ret +67% dd -30% sw 17 G + market top exit cap 41% maj 5/6 ret +1807% dd -53% sw 55 | cap 87% maj 1/1 ret +67% dd -30% sw 17