Rotation Map v2: framework, evidence and full code

Version 2.0 · built 28 Sep 2026 (Bangkok) · data through the 2026-09-27 UTC daily close · live dashboard http://159.223.88.168/rotation.html · live data rotmap.json

Read this first

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:

Hindsight is used only here, to grade. No trading rule reads it.

StretchBest placeWon byBTCAltsDays late (v2)Time held (v2)Caught
2020-06-01 to 2020-07-31ALTS+20%+16%+39%297%yes
2020-09-03 to 2020-12-07BTC+81%+81%-6%1074%yes
2020-12-08 to 2021-04-12ALTS+72%+247%+497%0100%yes
2021-10-20 to 2022-06-10CASH+132%-57%-77%4573%yes
2022-07-27 to 2022-11-19CASH+41%-29%-39%096%yes
2023-08-31 to 2024-05-11ALTS+26%+137%+200%6830%MISSED
2025-09-01 to 2026-03-02CASH+60%-37%-62%088%yes

2 · The framework (v2)

Data

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

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

Iteration 2 · Earlier ways in and out of alts

Iteration 3 · Robustness

Iteration 4 · Liquidity, trenches, and other add-ons

Iteration 5 · Rebuild, deploy, verify

4 · Everything that was rejected

IdeaResult
Alts/BTC, ETH/BTC and per basket relative strength trends16 versions, none beat the book in both periods
Breadth gate or thrust entry7 versions, none beat the book in both periods
Market wide alt top as exit2020-24 +1807% vs +4278%
Stablecoin supply growthhelps 2020-24 drops, costs return since 2024, no forecast power
Funding crowding as a warningcrowded did better, not worse
A basket bouncing off its own lowbeat BTC 19 to 33% of the time
Basket momentum and "extension" as forecasts (v1 study)no forecast power
BTC dominancenot used: listings move it without money moving

5 · Operations

6 · Known limits

7 · How to reproduce

  1. Put rotmap.py in a folder, run python3 rotmap.py --full --dry (about 20 minutes: OKX candles for 65 coins since 2019, Hyperliquid funding since 2024). It writes store/px.json, store/fund.json and rotmap.json (set PUB_JSON to choose the path). --dry prints alerts instead of sending.
  2. Put the lab files in a sibling lab/ folder. lab.py expects rotmap.py and its store in ../deploy/; adjust the two paths at its top.
  3. Run it1.py 1 50 (v1) and it1.py 1 100 (v2), then it2.py 50, it2.py 100, it2b.py, it3.py, it3b.py, it4.py (needs stables.json from https://stablecoins.llama.fi/stablecoincharts/all) and it4b.py. Their outputs from 28 Sep are in section 10.

8 · Questions for the reviewer

  1. Is there any lookahead in Model, book_rule or backtest? (Fill is on the next close. The clocks use trailing windows only.)
  2. 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?
  3. 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?
  4. How would you fix survivorship without delisted price history?
  5. 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?
  6. 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 &middot; Risk switch</h2>
  <div class="big" id="rbig">&mdash;</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 &middot; Alt clock</h2>
  <div class="big" id="cbig">&mdash;</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)">&#9646;</span> 90 day &nbsp; <span style="color:var(--accent)">&#9646;</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 &middot; The rotation strip</h2><div class="strip" id="strip"></div>
 <div class="note" style="margin-top:12px"><b>Red</b> top confirmed &middot; <b>light red</b> extended, stop adding &middot;
 <b>green</b> bottom confirmed, part of the next rotation &middot; <b>light green</b> washed out, waiting for the turn &middot;
 grey nothing crossed. Orange % = weight in the book from the next close.</div></div>

<div class="card"><h2>4 &middot; 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 &middot; 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 &middot; 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 &middot; 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 &middot; 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">&mdash;</span> Bangkok &middot; closed daily candles only &middot; alerts on Telegram when a colour or the call changes &middot;
<a href="/rotmap.json">raw numbers</a> &middot; <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> &nbsp;→&nbsp; 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

Version 2.0 · 28 Sep 2026 · latest version always at http://159.223.88.168/hx-c9ois/rotation/handover.html