Rotation Map v2.1: response to the 28 Sep 2026 reviews (r4, dashboard update 30 Sep)
Update 1 Oct 2026 (r6.2): the bigger test did not confirm the chain basket rule
- Same rule, nothing re-tuned, on data back to Sep 2022 and meme baskets of 9 more chains. Where it was found (Solana and Base memes): 33 trades, +7.8% average, $2.91 won per $1 lost. Everywhere else: 58 trades, -2.0% average, 36% wins, $0.75 per $1 lost. All together 91 trades, +1.6%; per week about break even (46 weeks, -0.2%).
- Reading: the edge may be luck or specific to Solana and Base. The dashboard now says "Proven? Not yet" on the scorecard and treats BUY as a small test bet. Cards now say when a trade was bought, its latest sell date (14 days), and how far each earlier sell trigger is.
- Full tables: dashboard-handover.html, section 4b.
Chain baskets rule rebuilt (r6), 30 Sep 2026
- Full handover for another AI (everything about the dashboard, 13 changes, evidence, 30 possible changes, code): dashboard-handover.html.
- Why. Ben judged the first chain basket signal too weak (+2.9% median over 14 days, 56% wins). Retested one trade at a time, the old rule made +1.1% a trade.
- New rule (chain-2). BUY only for the four meme baskets (Pump.fun, Bonk.fun, Solana older memes, Base memes) on a close above the 10 day high, after a 90 day low in the last 60 days, above a rising 20 day trend, BTC risk switch on. SELL on a close below the 20 day trend, -12% from entry, running hot, or 14 days.
- Result, dead coins included, after 1% costs: 34 trades, +7.1% average, +7.4% median, 59% wins, $2.55 won per $1 lost. First half $3.80, second half $2.04. Non meme baskets under the same rule: $1.03. Buying one day late: +3.3% average. All 27 parameter combinations profitable ($2.41 to $5.51). Walk forward (choices made on data before Jul 2025 only): memes beat non memes in both halves; the best first half combination made $2.90 per $1 lost afterwards.
- 13 changes live: (1) meme only BUY with SELL rule, (2) live scorecard with a frozen ledger, (3) 4 hour shadow baskets incl. the Robinhood meme indices, (4) all numbers after costs with late entry and 2% cost stress, (5) robustness grid and walk forward, (6) Telegram alert on every BUY and SELL, (7) closest to a buy with a live preview, (8) past record per card and past trades shaded on charts, (9) Robinhood point in time meme basket, (10) fair breadth reading for Rotation v2 (display only), (11) v2 forward record since the freeze, (12) risk switch history inside the chain engine, (13) overlap flags.
- Rotation v2 accuracy. The fair reading (top 100 coins as they were, dead ones count) shows the 64 coin survivor set flatters the 30 day breadth by about 19 points today. v2 itself stays frozen for the lockbox; a v3 shadow using the fair reading is proposed, not built.
- Added on request (change 14): Ink, Kraken's chain. Two 4 hour baskets: Ink top coins (10 deepest non-stable coins, mostly memes) and Ink stock tokens (wNVDAx, wTSLAx style). Correction: the r5 note said Ink has no meme market; it has a small one (pools of $20k to $135k). Context only, untested.
- Timing fix. CoinMarketCap's own daily file can arrive up to a day late, and buying one day late cut the test average to about +3%. The engine now takes live prices about 35 minutes after the 07:00 Bangkok close as a provisional close, and swaps in the official file when it arrives.
- Tested and not adopted: stale price filter (marginal), $100k volume floor (worse), buying the 3 strongest coins (worse typical trade), rising volume filter (worse), chain coin trend gate (no help), earlier entry at the turn (lost money).
Chain baskets update (r5), 30 Sep 2026: earlier
- What Ben asked. (1) Every chart must work on phone and desktop, on every dashboard. (2) Real detail on the risk switch and the 90 day and 30 day readings: one marker per reading, one per threshold, a clear green zone where it passes. (3) Stop asking "should any money be in crypto"; the question is alts, mainly the Solana and Robinhood indices. (4) Build similar baskets for Solana, Base, Telegram (TON) and "the Kraken chain called Arc", adapted to each chain's own launchpads, marked when they have bottomed and turned up.
- Chain check. Kraken's chain is Ink, not Arc. Arc is Circle's chain (public mainnet 16 Sep 2026). Ink has no meme or launchpad market (its pools are stablecoins, ETH and tokenized stocks), so it gets no basket. Arc has a live launchpad scene since 24 Sep (Argus, Faze, Long, Foci, Peach and others), so Arc gets two baskets.
- New section under "What to do now": Chain baskets. 20 baskets, one word each (BUY, WATCH: CLOSE, WATCH, HOLD, AVOID, TAKE SOME PROFIT), grouped Solana, Robinhood, Base, Arc, TON. Solana: Pump.fun coins, Bonk.fun coins, older memes, AI, apps (DeFi). Base: memes, AI incl. Virtuals, apps (DeFi), Bankr coins, o1 launchpad coins. TON: one basket (only about 10 liquid TON coins exist). Arc: top coins, launchpad tokens. Robinhood: the seven official indices. Each card: the four checks, 1d/7d/30d, distance to its 90 bar high and from its low, a chart with its 20 bar trend, its break level and its low, and every member with its full contract address.
- The rule. BUY = made a 90 bar low in the last 60 bars, back above a rising 20 bar trend, closed above its 30 bar high, and the BTC risk switch is on. WATCH: CLOSE = all of that except the break.
- Tested, and what it found. The nine daily baskets (Solana, Base, TON) are built point in time from 1,003 daily CoinMarketCap snapshots (Jan 2024 to Sep 2026, every coin listed that day, up to 10,000): members picked each Monday from coins that existed that day, so coins that later died still count. Every basket fell on average (median -7.3% a month). BUY with the switch on: median +2.9% over the next 14 days, 56% wins, 82 signals, against -5.6% for any day with the switch on. By 30 days the edge is gone. With the switch off the same signal lost (median -5.7%, 30% wins). WATCH: CLOSE alone has no edge. By year: 2024 -3.8% (16 signals), 2025 +5.8% (42), 2026 +1.6% (24). Memes baskets did best; Solana apps and TON did worst. So the page calls it a two week trade, not a hold.
- Not tested: the 4 hour baskets (Robinhood, Arc, Bankr, o1). Same rule on 4 hour bars; weeks of data. For Arc, Bankr and o1 the history before today uses today's coins, which flatters the past (Bankr shows +326% over 30 days for that reason); the cards say so.
- Gauges rebuilt. Risk switch: "is it safe to buy alts?", red OFF zone, grey hold strip, green ON zone, BTC marker with its value. Breadth: two bars, 90 day (white marker, green washout zone at 5% or below, red top zone at 70% or above) and 30 day (orange marker, green turn zone at 40% or above), plus a tick list of the three conditions the book needs, with the date of the last washout (15 Dec 2025).
- Charts on phone. Every chart redraws when the screen size changes, uses bigger labels on phones, and has a tap or hover readout for any date. The history chart now also shows the 30 day reading and labels its 5%, 40% and 70% lines. The Robinhood chart lets you tap a name to show only that line. Checked at 390 px and 1280 px wide: no errors, no sideways scroll.
- Unchanged. rotmap.py (frozen v2) and the Codex Robinhood Indices site. The chain baskets are context and never move the book.
- Not built, and why. Zora (its own top list is spam: coins with 3 holders showing $100M+), Clanker (no clean public list with prices; Bankr coins, built on Clanker, are covered), Solana Bags and Heaven and new bonk.fun launches (bonding curves under $10k).
Dashboard update, 30 Sep 2026: earlier
- What changed on the live dashboard (display only). Frozen v2 decisions, rotmap.py and the alert rules are unchanged (rotmap.py md5 dc805b4db985d5a8c906a375061b1f14). Only rotation.html and a new hourly script rh_drill.py changed.
- Wording from the dashboard review applied. Today's call now separates the rule state from the market (30 day breadth high, 90 day washout missing, so no alt entry). The alt clock is titled as breadth of the named 64 coin set, lists its coins, and states the three entry conditions. Basket cards list members and say they are context that only moves weight while the book holds alts. The trenches card is labelled an activity watch, not a buy signal. Charts are labelled as a historical simulation.
- New section 9, Robinhood Chain tokens (live panel, not part of the book). Robinhood Index vs BTC, ETH, SOL on the same UTC daily closes; seven baskets from the Robinhood Indices feed (AI Products and AI Company Pairs merged hour by hour into one AI line); every line is official index history, never today's members' older prices; full contract addresses per basket; a stretch gauge (last close against its own 20 close band, 2 standard deviations, daily or 4 hour when history is short) labelled untested.
- New money flow proxies, experimental. Chain level from DefiLlama: stablecoins on Robinhood Chain (1, 7, 30 day change), value locked, DEX volume versus the prior week. Basket level from DexScreener pools: buy share of trades (count, not dollars), pool liquidity and 24 hour volume, and a liquidity change that builds its own hourly history from 30 Sep. A basket reads MONEY COMING IN only when every available check passes (beating the index, most coins rising, buy share above 52%, pool liquidity growing), MONEY LEAVING when none pass. Not tested, never changes the book, no net dollar claim.
- Later the same day (second push). (1) A bug found and fixed: when the Robinhood panel loaded before the main data, a chart loop tripped over the new cards and stopped the page, so sections 4 to 8 (history charts, report card, past results, colour table, last moves) rendered empty. The loop now only touches the strip's own charts, tested with both load orders forced. (2) All seven Robinhood Chain indices now also sit at the end of the rotation strip as context cards. (3) Section 4 has a third chart with every Robinhood index since it began (September 2026), each starting at 1x. (4) Plain names: "What to do now", "the system" instead of "the book", "$1 following the system" against "$1 just holding BTC", report card, past results, the system's last moves, plus a how-to-read line at the top.
- Known limits. Stock Memes and Long.xyz share 8 of 10 coins (flagged on the cards). Bridge flows and signed dollar swap pressure are not included yet. The panel has weeks of history only.
Fourth review (r4): read this first
Two of my r3 numbers were wrong, both caught by the r3 audit's requests. (1) The execution claim "the recent alt edge is gone at 1% plus one day" compared a stressed v2 book with an unstressed BTC switch. Run matched, the v2 book beats the switch since Jul 2024 in every one of 12 cost and delay cells, by +17 to +32 points. (2) The weekly BTC switch baseline printed as +757% in the r3 table counted BTC returns on days v2 was still filling out of BTC. The clean rule (switch read at each snapshot) is +642% (-50%) for 2020-06..2024-06; since Jul 2024 it is unchanged at +25%. The r3 text is left in place with both corrections marked.
- Point 1 · Both clock cohorts, published side by side, nothing chosen. Lookback-start cohort = coins ranked at the start of the 30 or 90 day window (r3). Signal-date cohort = coins ranked on the signal date that were already ranked at the window start. On the signal-date cohort the 90 day clock never reaches 5% on the top 100 (minimum 8%), so the entry never fires there; top 50 fires once (2023-06-04); size-matched fires twice (2023-06-04, 2023-07-09). Weekly, no fees, 2020-06..2024-06, lag 0 / lag 1 week:
Since Jul 2024 every one of the 12 point-in-time variants stays out of alts (+25% at lag 0, +37% at lag 1, which is the switch). Reading: before Jul 2024 only 2 of the 6 point-in-time cells beat the clean switch (lookback-start top 50 and size-matched); 3 are below it and 1 equals it because it never enters alts. That is the fragility finding, now under both definitions.clock universe lookback-start cohort signal-date cohort top 100 +490 / +462 (entries 2023 only) +642 / +510 (never enters alts) top 50 +2,410 / +1,998 (4 entries) +531 / +503 (1 entry) size matched to v2's survivor count +1,531 / +1,568 (3 entries) +537 / +449 (2 entries) BTC switch, clean rule +642 (-50) - Point 2 · Matched execution stress, done. v2 book, BTC switch and hold BTC all through the same daily coin engine, same cost per side, same fill delay (full grid below). Since Jul 2024 the book minus the switch is +32 points at 0.3%/0 days, +25 at 1%/1 day, +17 at 2%/2 days. The switch itself beats hold BTC since Jul 2024 only at 0.3%/0 days (+42 vs +34); with one day of delay it trails hold BTC in every cost cell, while cutting the worst drop from -53% to between -30% and -49%. So the switch is drawdown control, not proven return edge. The book's recent margin comes from its one alt entry since Jul 2024 (2025-05-11), which no point-in-time clock reproduces: one episode, not evidence.
- Point 3 · Agreed, not done in r4. P5-lite stays a warning. The daily exchange reconstruction (data.binance.vision daily klines including delisted pairs, real delisting dates and terminal prices, membership and sector known at each signal date) is the next build. Falling out of the CMC top 1000 is not delisting, and I say so.
- Point 4 · Wording fixed. The lockbox first reading is no earlier than 28 Sep 2027, and later if fewer than three labelled rotations have completed by then. My chat line "first fair score in September 2027" was too strong.
- Point 5 · Bitcoin anchors now confirmed.
ots upgradecompleted for all four proofs. E1 bundle, v2 freeze hash and frozen v2 source anchor in Bitcoin block 969009 (block time 21:49:56 Bangkok, 28 Sep 2026); the lockbox objective in block 969015 (22:13:13 Bangkok). The merkle roots computed by the proofs match the block merkle roots served by blockstream.info (no local Bitcoin node here, so this is an explorer check). What this proves: the files existed by those block times. It does not prove E1 was specified before its one historical pass; the page already says so. - Point 6 · Acknowledged. 9 numbered tests, 10 PASS lines.
- Recommendation, unchanged in substance, sharper in wording. Keep live frozen v2. Alt entries and basket tops are information, not trade signals. Do not install v2.1 decision changes. The BTC switch is framed as drawdown control. Next build: the daily exchange universe with delistings, then rerun v2 unchanged under both cohort definitions, matched costs, and report all cells.
total %% (worst drop %%); every system: same coin engine, same cost per side, same fill delay
cost/side delay | 2020-06..2024-06: v2 book / BTC switch / hold BTC | 2024-07..now: v2 book / BTC switch / hold BTC | since 2024-07 book minus switch
0.3% 0 day | 8206.2 (-55.7) / 718.8 (-51.5) / 558.9 (-76.6) | 74.5 (-29.7) / 42.1 (-29.7) / 34.3 (-53.0) | +32.4 pts
0.3% 1 day | 8735.1 (-55.0) / 793.0 (-50.5) / 548.9 (-76.6) | 52.5 (-32.4) / 21.4 (-34.7) / 34.3 (-53.0) | +31.1 pts
0.3% 2 day | 9649.3 (-53.1) / 810.6 (-48.8) / 540.8 (-76.6) | 37.2 (-31.5) / 8.1 (-34.4) / 34.3 (-53.0) | +29.1 pts
0.3% 3 day | 9178.4 (-46.5) / 859.9 (-44.5) / 551.9 (-76.6) | 38.9 (-39.9) / 7.7 (-41.9) / 34.3 (-53.0) | +31.2 pts
1.0% 0 day | 5790.0 (-61.4) / 562.8 (-54.1) / 554.3 (-76.6) | 53.8 (-32.4) / 27.9 (-33.6) / 34.3 (-53.0) | +25.9 pts
1.0% 1 day | 6166.3 (-60.4) / 622.9 (-52.6) / 544.4 (-76.6) | 34.3 (-36.3) / 9.2 (-38.3) / 34.3 (-53.0) | +25.1 pts
1.0% 2 day | 6815.6 (-58.7) / 637.1 (-51.0) / 536.3 (-76.6) | 20.9 (-35.5) / -2.7 (-38.0) / 34.3 (-53.0) | +23.6 pts
1.0% 3 day | 6482.3 (-52.0) / 677.0 (-47.6) / 547.4 (-76.6) | 22.4 (-43.4) / -3.1 (-45.0) / 34.3 (-53.0) | +25.5 pts
2.0% 0 day | 3485.3 (-68.5) / 388.8 (-58.3) / 547.7 (-76.6) | 28.1 (-38.0) / 9.8 (-38.7) / 34.3 (-53.0) | +18.3 pts
2.0% 1 day | 3715.4 (-67.4) / 433.1 (-55.6) / 537.8 (-76.6) | 11.9 (-41.5) / -6.2 (-43.1) / 34.3 (-53.0) | +18.1 pts
2.0% 2 day | 4111.7 (-66.0) / 443.6 (-55.0) / 529.9 (-76.6) | 0.7 (-40.8) / -16.5 (-42.9) / 34.3 (-53.0) | +17.2 pts
2.0% 3 day | 3909.4 (-60.9) / 473.0 (-52.5) / 540.8 (-76.6) | 2.0 (-48.0) / -16.8 (-49.3) / 34.3 (-53.0) | +18.8 pts
lag 0
clock start cohort, universe ALT100 | weeks in alts 6 | alt entries ['2023-06-04', '2023-07-16']
2020-06..2024-06 PIT-driven v2 book (490.1, -57.5)
2024-07..now PIT-driven v2 book (25.3, -30.5)
lag 1
clock start cohort, universe ALT100 | weeks in alts 6 | alt entries ['2023-06-04', '2023-07-16']
2020-06..2024-06 PIT-driven v2 book (462.0, -50.5)
2024-07..now PIT-driven v2 book (36.6, -24.4)
lag 0
clock start cohort, universe ALT50 | weeks in alts 36 | alt entries ['2021-01-17', '2021-08-15', '2023-06-04', '2023-07-16']
2020-06..2024-06 PIT-driven v2 book (2410.0, -58.1)
2024-07..now PIT-driven v2 book (25.3, -30.5)
lag 1
clock start cohort, universe ALT50 | weeks in alts 36 | alt entries ['2021-01-17', '2021-08-15', '2023-06-04', '2023-07-16']
2020-06..2024-06 PIT-driven v2 book (1997.8, -43.6)
2024-07..now PIT-driven v2 book (36.6, -24.4)
lag 0
clock start cohort, universe MATCH | weeks in alts 52 | alt entries ['2020-06-07', '2023-06-04', '2023-07-16']
2020-06..2024-06 PIT-driven v2 book (1530.6, -57.5)
2024-07..now PIT-driven v2 book (25.3, -30.5)
lag 1
clock start cohort, universe MATCH | weeks in alts 52 | alt entries ['2020-06-07', '2023-06-04', '2023-07-16']
2020-06..2024-06 PIT-driven v2 book (1567.9, -47.4)
2024-07..now PIT-driven v2 book (36.6, -24.4)
lag 0
clock signal cohort, universe ALT100 | weeks in alts 0 | alt entries []
2020-06..2024-06 PIT-driven v2 book (641.5, -50.0)
2024-07..now PIT-driven v2 book (25.3, -30.5)
lag 1
clock signal cohort, universe ALT100 | weeks in alts 0 | alt entries []
2020-06..2024-06 PIT-driven v2 book (509.5, -47.9)
2024-07..now PIT-driven v2 book (36.6, -24.4)
lag 0
clock signal cohort, universe ALT50 | weeks in alts 1 | alt entries ['2023-06-04']
2020-06..2024-06 PIT-driven v2 book (531.3, -54.6)
2024-07..now PIT-driven v2 book (25.3, -30.5)
lag 1
clock signal cohort, universe ALT50 | weeks in alts 1 | alt entries ['2023-06-04']
2020-06..2024-06 PIT-driven v2 book (503.0, -47.9)
2024-07..now PIT-driven v2 book (36.6, -24.4)
lag 0
clock signal cohort, universe MATCH | weeks in alts 7 | alt entries ['2023-06-04', '2023-07-09']
2020-06..2024-06 PIT-driven v2 book (537.0, -54.2)
2024-07..now PIT-driven v2 book (25.3, -30.5)
lag 1
clock signal cohort, universe MATCH | weeks in alts 7 | alt entries ['2023-06-04', '2023-07-09']
2020-06..2024-06 PIT-driven v2 book (448.6, -51.7)
2024-07..now PIT-driven v2 book (36.6, -24.4)
===== lookback-start cohort
agreement of weekly clocks (PIT top100 vs survivor list):
90d clock: corr 0.91, mean PIT 29% vs survivor 33%, weeks 335
30d clock: corr 0.90, mean PIT 36% vs survivor 37%, weeks 344
washed-out weeks (90d clock <=5%): PIT 5, survivor 21
PIT 90d clock minimum 0.04
alt-bottom fire weeks PIT: ['2023-06-04', '2023-07-16']
alt-bottom fire weeks survivor: ['2020-06-07', '2020-07-12', '2020-11-29', '2021-01-17', '2021-08-15', '2023-07-16', '2025-05-11']
PIT weeks with 90d clock <= 5%: ['2023-05-07', '2023-05-14', '2023-05-28', '2023-06-04', '2023-06-11']
PIT weeks with 90d clock <= 10%: ['2020-11-08', '2020-11-15', '2020-11-22', '2020-11-29', '2020-12-06', '2020-12-13', '2020-12-20', '2020-12-27', '2021-01-03', '2021-08-15', '2022-05-22', '2022-05-29', '2022-06-12', '2023-04-30', '2023-05-07', '2023-05-14', '2023-05-21', '2023-05-28', '2023-06-04', '2023-06-11', '2023-06-18', '2023-06-25', '2024-09-01', '2025-03-09', '2025-04-06', '2025-04-13', '2025-04-27', '2025-06-22', '2025-12-14', '2025-12-21', '2025-12-28']
PIT weeks with 90d clock <= 15%: ['2020-11-08', '2020-11-15', '2020-11-22', '2020-11-29', '2020-12-06', '2020-12-13', '2020-12-20', '2020-12-27', '2021-01-03', '2021-01-10', '2021-01-17', '2021-08-01', '2021-08-08', '2021-08-15', '2022-03-06', '2022-03-27', '2022-04-03', '2022-04-17', '2022-05-15', '2022-05-22', '2022-05-29', '2022-06-05', '2022-06-12', '2022-06-19', '2022-06-26', '2022-07-03', '2022-07-10', '2022-07-17', '2022-07-24', '2023-01-01', '2023-04-09', '2023-04-23', '2023-04-30', '2023-05-07', '2023-05-14', '2023-05-21', '2023-05-28', '2023-06-04', '2023-06-11', '2023-06-18']
===== signal-date cohort
agreement of weekly clocks (PIT top100 vs survivor list):
90d clock: corr 0.90, mean PIT 40% vs survivor 33%, weeks 335
30d clock: corr 0.89, mean PIT 41% vs survivor 37%, weeks 344
washed-out weeks (90d clock <=5%): PIT 0, survivor 21
PIT 90d clock minimum 0.08333333333333333
alt-bottom fire weeks PIT: []
alt-bottom fire weeks survivor: ['2020-06-07', '2020-07-12', '2020-11-29', '2021-01-17', '2021-08-15', '2023-07-16', '2025-05-11']
PIT weeks with 90d clock <= 5%: []
PIT weeks with 90d clock <= 10%: ['2023-05-14', '2023-06-11']
PIT weeks with 90d clock <= 15%: ['2020-11-22', '2020-12-06', '2021-08-15', '2022-06-12', '2023-05-07', '2023-05-14', '2023-05-21', '2023-05-28', '2023-06-04', '2023-06-11', '2023-06-25', '2025-03-09', '2025-04-06', '2025-04-13']
Weekly-sampled v2 book, same decisions, no fees. total %, worst drop %
2020-06..2024-06 survivor lists (9493.3, -56.4) PIT sectors (4786.8, -57.1) PIT broad top50 (1551.9, -53.0)
2024-07..now survivor lists (59.3, -27.2) PIT sectors (53.0, -26.9) PIT broad top50 (53.3, -27.4)
Basket bias in the weeks v2 held it (annualised, PIT minus survivor, weekly rebalance)
MAJORS weeks 69 PIT minus survivor -65.2% per year
MEMES weeks 55 PIT minus survivor -80.2% per year
AI weeks 56 PIT minus survivor +96.7% per year
DEFI weeks 71 PIT minus survivor +24.6% per year
Top-ranked coins that left the top 1000 inside a week (booked at -90%): 15 cases
examples: ['ACA', 'BAN', 'BNSOL', 'BXC', 'CIX100', 'DREP', 'EOS', 'HEX', 'MKR', 'PEPECASH', 'WEOS', 'WOW', 'YAM']
2020-06..2024-06 hold BTC (542.3, -75.1) BTC switch read at snapshot (641.5, -50.0) r3 printed (timing error) (756.9, -50.0)
2024-07..now hold BTC (34.7, -51.8) BTC switch read at snapshot (25.3, -30.5) r3 printed (timing error) (25.5, -30.5)
== E1_REGISTRATION_BUNDLE.txt
Assuming target filename is 'E1_REGISTRATION_BUNDLE.txt'
Could not connect to Bitcoin node: Cookie file unusable ([Errno 2] No such file or directory: '/root/.bitcoin/.cookie') and rpcpassword not specified in the configuration file: '/root/.bitcoin/bitcoin.conf'
verify PendingAttestation('https://alice.btc.calendar.opentimestamps.org')
verify BitcoinBlockHeaderAttestation(969009)
# Bitcoin block merkle root f626cb38f009c25b3cc0d14aa201276f22a0d2798f03c5a9bf184064d91677f9
== V2_FREEZE.sha256
Assuming target filename is 'V2_FREEZE.sha256'
Could not connect to Bitcoin node: Cookie file unusable ([Errno 2] No such file or directory: '/root/.bitcoin/.cookie') and rpcpassword not specified in the configuration file: '/root/.bitcoin/bitcoin.conf'
verify PendingAttestation('https://btc.calendar.catallaxy.com')
verify PendingAttestation('https://alice.btc.calendar.opentimestamps.org')
verify BitcoinBlockHeaderAttestation(969009)
== rotmap_v2_frozen.py
Assuming target filename is 'rotmap_v2_frozen.py'
Could not connect to Bitcoin node: Cookie file unusable ([Errno 2] No such file or directory: '/root/.bitcoin/.cookie') and rpcpassword not specified in the configuration file: '/root/.bitcoin/bitcoin.conf'
verify PendingAttestation('https://btc.calendar.catallaxy.com')
verify PendingAttestation('https://alice.btc.calendar.opentimestamps.org')
verify BitcoinBlockHeaderAttestation(969009)
== OBJECTIVE_LOCKBOX.txt
Assuming target filename is 'OBJECTIVE_LOCKBOX.txt'
Could not connect to Bitcoin node: Cookie file unusable ([Errno 2] No such file or directory: '/root/.bitcoin/.cookie') and rpcpassword not specified in the configuration file: '/root/.bitcoin/bitcoin.conf'
verify PendingAttestation('https://btc.calendar.catallaxy.com')
verify PendingAttestation('https://alice.btc.calendar.opentimestamps.org')
verify BitcoinBlockHeaderAttestation(969015)
Independent check of the Bitcoin anchors (block data from blockstream.info, no local node):
block 969009 hash 000000000000000000010edade8fffaeb270cde4792a717980a82d1af73269e6
merkle root f626cb38f009c25b3cc0d14aa201276f22a0d2798f03c5a9bf184064d91677f9 = root computed by the proofs of
E1_REGISTRATION_BUNDLE.txt, V2_FREEZE.sha256, rotmap_v2_frozen.py; block time 2026-09-28 21:49:56 Bangkok (14:49:56 UTC)
block 969015 hash 00000000000000000001b2e2e9e0d3404592b5cc2ae999e54bfc007a4efa2c07
merkle root fe2d4a4db3d11d9abc666a220a0a0196f0e95f8415ecb21af8faccb9d4b9b3a9 = root computed by the proof of
OBJECTIVE_LOCKBOX.txt; block time 2026-09-28 22:13:13 Bangkok (15:13:13 UTC)
Third review (r3)
Headline. I built a first point-in-time universe (P5-lite) from weekly CoinMarketCap snapshots, top 1000 by market cap on every Sunday 2019-06-02..2026-09-27 (383 snapshots, coins that later died included, e.g. LUNA at $0.0002 on 2022-05-15). Two findings. (a) Holding v2's decisions fixed, survivor lists inflate the pre-Jul-2024 alt return 2x to 6x. (b) More important: v2's alt entry trigger does not transfer. On the point-in-time top 100 the 90 day clock reached 5% in only 5 weeks (all 2023), so the same rule with the same parameters enters alts only in 2023; on the top 50 it enters four times; on no point-in-time universe does it enter after Jul 2024. The BTC risk switch is the part that survives. The alt leg is unproven, not validated.
| Weekly grid, no fees, total % (worst drop %) | 2020-06..2024-06 | 2024-07..now |
|---|---|---|
| v2 decisions, survivor baskets (what the page reported) | +9,493 (-56) | +59 (-27) |
| v2 decisions, point-in-time sector baskets (CMC tags) | +4,787 (-57) | +53 (-27) |
| v2 decisions, point-in-time broad top 50 alts | +1,552 (-53) | +53 (-27) |
| v2 rule driven by point-in-time clocks, top 100 universe | +490 (-58) | +25 (-31) |
| same, top 50 universe | +2,410 (-58) | +25 (-31) |
| same, universe size matched to v2's survivor count on each date | +1,531 (-58) | +25 (-31) |
| BTC with the risk switch only (no survivorship possible) | +757 (-50) [r4: timing error, correct +642 (-50)] | +26 (-31) |
| Hold BTC | +542 (-75) | +35 (-52) |
- Why the trigger breaks. In Jun 2020 the survivor alt universe eligible to v2 was only 14 coins (28 in Jan 2021, 41 in Jun 2023, 58 in Jan 2025). A share of 14 coins hits 5% far more easily than a share of 100. Clocks correlate well week to week (90 day corr 0.91, 30 day corr 0.90) but the tail differs: 21 washed-out weeks on survivors vs 5 on the top 100 point-in-time universe, all in 2023. Weekly fire dates, survivor: 2020-06-07, 2020-07-12, 2020-11-29, 2021-01-17, 2021-08-15, 2023-07-16, 2025-05-11. Point-in-time top 100: 2023-06-04, 2023-07-16. The outcome swings from below the BTC switch to about 3x it depending on a universe choice nobody would have fixed in advance. That is fragility, not an edge. No threshold was changed to get these numbers and none will be tuned on them.
- Limits of P5-lite. Weekly sampling and weekly rebalance on both sides. CMC prices, not exchange fills. Sector tags are CMC's current tags (so NEAR, ICP, FIL count as AI). Tags only exist from late 2020, so earlier sector baskets fall back to the top 50. 15 top-ranked coins left the top 1000 inside a week: EOS and MKR are token swaps (booked 0%), coins that reappear within 12 weeks use the next sighting, the rest are booked at -90%; -100% or 0% moves the pre-2024 sector book between +4,736% and +5,265% and changes nothing else. Delisting losses at exchange level and fills on dead venues are not modelled.
- Point 4, execution stress, done. Same frozen decisions on the daily coin engine with fills 0 to 3 days later and 0.3%, 1% or 2% per side (fee plus spread plus impact). Since Jul 2024: +74.5% at 0.3%/0 days, +34.3% at 1%/1 day, +0.7% at 2%/2 days, against +42.1% for the BTC switch alone. [r4: this sentence was wrong. It compared a stressed book with an unstressed switch. Matched, the book beats the switch in every cell; see r4.] Pre-2024 stays large but is survivor-inflated (above). A volume-tiered cost model was not built; the stress grid is the upper-bound label.
- Point 3, objective predeclared and stamped. OBJECTIVE_LOCKBOX.txt: one primary number (net equity vs the BTC switch at 1% per side and one extra day, bootstrap lower bound above 0.95), five reported-never-optimised measures (drawdown, missed return on point-in-time labels, delay, wrong-way exposure, turnover), first reading 2027-09-28 or after three labelled rotations, whichever is later. sha256
90d733bb452b46b5a428737f81c128a585faad6356f0ebcacf3b6c939b4925ed, OpenTimestamps submitted 28 Sep 2026 22:05 Bangkok (15:05 UTC). Earlier stamps still await Bitcoin confirmation (ots upgrade: not complete). - Point 5, dashboard uses the corrected index. Checked. In the candidate
read(), every basket card's 30 day return, trend and sparkline readarith_indices.Model._indexis still used only by the frozen decision path (existence checks and BTC, where a one coin index equals the price) and by one record row that is labelled "Same book on the old synthetic index". - Point 6, test count. My error, corrected. I told Ben "12 tests pass". Wrong.
test_v21.pyhas 9 numbered tests printing 10 PASS lines (test 2 checks continuity and the in-leg cold start separately). All 10 passed on the droplet copy on 28 Sep. I miscounted; nothing else depended on the number. - Points 1 and 2 agreed. P5-lite above is the first cut, not the full reconstruction (daily exchange klines incl. delisted pairs from data.binance.vision, delisting outcomes). No clean out-of-sample result exists; the CSCV 73% is a split statistic, not a forecast. The lockbox is the first clean test.
- What I now recommend. Treat the BTC risk switch as the only validated layer. Treat alt entries and basket tops as unproven context, not trade signals, until the lockbox says otherwise. Do not install v2.1 as a decision change. If anything is installed it is the plumbing only (outbox lock, health gate, journal). Any earlier-entry idea goes in as a separately registered sleeve with a fixed risk budget, judged only on lockbox data.
Second review (r2): what changed in this version
- 1 · Edge captured had one extra day. Agreed, fixed.
capture()now uses equity from close a to close z, which is the same interval as the benchmark. Numbers moved by tenths: the 2023-24 alt run is 63.6% for v2 and 63.3% with E1; the median across the ten major runs is 83.0% before Jul 2024 and 87.0% since. A test proves a book that holds exactly the winner scores 100.0. - 2 · "Point-in-time" was too strong. Agreed, relabelled. It is point-in-time eligibility inside today's survivor list: a coin counts from 30 days after its first candle if it trades on that day. It is not historical membership. Every return here stays an upper bound.
- 3 · The corrected index could silently renormalise over missing members. Agreed, guarded. Each basket now records its daily member coverage. A day under 80% is marked unknown and never averaged over the survivors. Measured on the stored history 2020-06..now: every basket had 0 days below 100% (out_coverage.txt), so no published number changes. The guard only matters when data goes missing in the future.
- 4 · PBO 0.31 is not "a 31% chance it was luck". Agreed, reworded. It is a selection-instability check. I also reran it on coin-level returns. On that basis the in-sample winner lands below the out-of-sample median in 50% of splits, a coin flip: nothing supports the exact setting over its neighbours. The in-sample winner still beat holding BTC out of sample in 73% of splits (81% on the old index). My read: the family (risk switch plus alt bottom plus basket tops) earns its place against BTC, but the choice of 100d/1% inside it does not. That is one more reason never to tune it. The splits are combinatorial, not chronological.
- 5 · Fee labels. Agreed, relabelled and matched. Turnover (10.5x a year) is gross and two-sided (buys plus sells). 20% and 67% are summed fee loads. The matched runs: at 0.3% fees ending equity is 18.2% below the same book at zero fees (x145.0 against x177.1); at 1% it is 48.9% below (x90.6).
- 6 · The outbox raced. Agreed, fixed, and the race is real. Temp files are now unique per writer. One exclusive lock covers every outbox read-modify-write in both the 4-hourly run and the flusher. Without the lock, 4 overlapping workers kept only 23 of 80 events and sent 7 duplicates. With it: 80 of 80, 80 sends, 0 duplicates. A crash after Telegram accepts but before the state is saved resends that message once, which is the known at-least-once limit (test 9).
- 7 · The hash proves content, not timing. Agreed. The E1 bundle (spec plus rule source, sha256
43f0ab5e…) and the frozen v2 file were submitted to three independent OpenTimestamps calendars (pool.opentimestamps.org, eternitywall, catallaxy) at 2026-09-28 21:33 Bangkok (14:33 UTC). That proves existence from that moment, not before the one descriptive pass, which remains disclosed. The .ots proofs sit next to the files and needots upgradeonce Bitcoin confirms. From here forward the droplet journal is append-only. - The one wording fix I owe: "x4.53" is a daily-rebalanced equal-weight MAJORS portfolio (
sanity_invest.py, mode daily). The coin-level book is a different path: weights drift between book changes. They are labelled separately below. - Still true: nothing here is deployed; v2 still decides; E1 is shadow only.
Read this first (r1, corrected in place)
- Agreed decision. v2 is frozen as the comparison baseline (sha256 of the file below). No signal threshold was added or changed. v2.1 is a candidate that changes how the book is measured, when a decision is published, and how alerts are delivered. A test proves v2.1 makes the identical decision as v2 on all 2,310 closes since 2020-06-01.
- All five audit fixes are implemented and tested (continuity, coin-level costs, freshness gate, alert outbox, contamination accounting), plus the decision journal, the dashboard changes and a pre-registered shadow early-entry candidate.
- New finding, bigger than the audit expected. v2's basket index averaged log returns (a geometric mean, not a portfolio) and clipped days at ±50%. Over 2020-06..now the synthetic MAJORS index shows x1.25 while a daily-rebalanced equal-weight portfolio of the same coins shows x4.53 (the coin-level book itself lets weights drift between book changes, a separate path). The synthetic index therefore understated alt baskets. It never drove a decision (signals only test whether an index exists), but it drove the published returns, the hindsight labels and the basket-top evidence.
- Headline, corrected and still in-sample. Coin-level book, next-close fills, 0.3% on every coin trade: 2020-06..2024-06 +8,206% (worst drop -55.7%), 2024-07..now +74.5% (-29.7%). At 1% per trade: +5,790% and +53.8%. Survivorship is not removed, so treat these as upper bounds.
- Score, restated. On corrected indices the reference definition (45/21/20) gives 8/10 by the half-duration rule · 7/9 through Jun 2024 · 1/1 since · worst delay 68 days (it was 6/7 = 5/6 + 1/1 on the flawed index). The labels changed; the book did not. Across the 27-cell grid the through-Jun-2024 count ranges 4/5 to 9/12 and the worst delay is 40 to 68 days in every cell.
- Contamination, measured. Selection-instability check (CSCV, 16 blocks) over the 69 variants actually looked at: the in-sample winner falls below the out-of-sample median in 31% of splits on the old index and 50% on coin-level returns; it beats holding BTC out of sample in 81% and 73% (see r2 point 4). The 1% band is not the best on its own terms (100d/3% had the higher Sharpe); it was kept because it was v1's band. Basket tops: 10 of 13 events since Jul 2024, but only 2 of 5 distinct episodes.
- The key failure stays open. The 2023-08..2024-05 alt run: 68 days late, 30% held, 64% of the edge captured. Shadow E1 is registered (hash below) and will be judged only on data from the 2026-09-28 close. Its one descriptive pass on history did not fix 2023-24, so its prior is low.
- Nothing deploys without your explicit request. The patch plan is section 9.
1 · Continuity (audit fix 1)
- Every period is now a slice of one continuous run.
invest()takeswarm_from; the test asserts the warm-started 2024-07 run equals the slice of the full run exactly. - For the published 2024-07-01 boundary the bug changed nothing: on 2024-06-30 the book was in cash, and the dropped-basket memory resets on every alt entry. Inside an alt leg it matters: a cold start on 2021-01-15 gives +1,553.7% against +1,645.7% for the continuous strategy.
continuous-run state on 2024-06-30: {'on': False, 'dropped': ['AI', 'DEFI', 'MAJORS', 'MEMES']} holding {}
recent COLD (published): {'total': 67.8, 'cagr': 25.9, 'maxdd': -29.5}
recent WARM (sliced) : {'total': 67.8, 'cagr': 25.9, 'maxdd': -29.5}
holdout cold: {'total': 4278.5, 'cagr': 152.2, 'maxdd': -52.8}
holdout warm: {'total': 4278.5, 'cagr': 152.2, 'maxdd': -52.8}
Boundary inside an alt leg (demonstrates the bug class):
from 2021-01-15 cold +1553.7% (dd -52.8) warm +1645.7% (dd -52.8) holding at boundary {'ETH': 0.17, 'SOL': 0.17, 'MAJORS': 0.17, 'MEMES': 0.17, 'AI': 0.17, 'DEFI': 0.17}
from 2021-02-15 cold +648.5% (dd -52.8) warm +634.4% (dd -52.8) holding at boundary {'ETH': 0.17, 'SOL': 0.17, 'MAJORS': 0.17, 'MEMES': 0.17, 'AI': 0.17, 'DEFI': 0.17}
from 2023-12-15 cold +150.0% (dd -30.0) warm +154.5% (dd -30.0) holding at boundary {'ETH': 0.17, 'SOL': 0.17, 'MAJORS': 0.17, 'MEMES': 0.17, 'BTC': 0.33}
2 · Coin-level book and its costs (audit fix 2)
- The book now holds coins, not an index. Each basket weight is split equally across members of today's basket lists that are eligible on the fill day (a close exists, 30+ days since the first candle). That is point-in-time eligibility inside a survivor list, not historical membership. Weights drift between fills. Every coin trade pays the fee. Real closes, no cap. Sanity tests: hold BTC equals the price ratio minus one fee.
- Gross two-sided turnover 10.5x a year. Summed fee load 20% at 0.3% and 67% at 1%; matched against the same book at zero fees, ending equity is 18.2% and 48.9% lower. False switches (round trips reversed within 14 days): v2 15, v1 32.
- Not modelled: slippage beyond the flat fee, depth limits on thin coins, delisting losses (the universe has no delisted coins, see section 5).
Frozen v2 book. Periods measured inside ONE continuous run (warm start).
2020-06 .. 2024-06 2024-07 .. now turnover / fees (full)
synthetic index (published method) +4278.5% dd -52.8% +67.8% dd -29.5%
investable drift fee 0.3% +8206.2% dd -55.7% +74.5% dd -29.7% 10.5x/yr, fees 20.0% of equity
investable drift fee 1.0% +5790.0% dd -61.4% +53.8% dd -32.4% 10.5x/yr, fees 66.7% of equity
investable weekly fee 0.3% +8650.9% dd -52.5% +74.5% dd -29.2% 11.6x/yr, fees 22.1% of equity
investable weekly fee 1.0% +5830.1% dd -58.7% +53.2% dd -32.3% 11.6x/yr, fees 73.7% of equity
investable daily fee 0.3% +8325.2% dd -52.2% +73.5% dd -29.0% 13.3x/yr, fees 25.3% of equity
investable daily fee 1.0% +5241.1% dd -58.7% +51.1% dd -32.3% 13.3x/yr, fees 84.4% of equity
continuity: slice of full {'total': 74.5, 'cagr': 28.2, 'maxdd': -29.7} vs warm-started run {'total': 74.5, 'cagr': 28.2, 'maxdd': -29.7}
continuity assert passed
hold BTC engine 8.8492 expected 8.8492
hold ETH engine 11.3112 expected 11.3112
MAJORS daily-rebalanced engine x4.53 synthetic index x1.25
daily moves beyond the +-50% log cap since 2020-06: 40 [('2022-11-09', 'SOL', -0.42), ('2020-12-23', 'XRP', -0.42), ('2023-07-13', 'XRP', 0.73), ('2025-03-02', 'ADA', 0.72), ('2021-02-10', 'AVAX', 0.72), ('2024-12-03', 'TRX', 0.96), ('2021-05-19', 'ATOM', -0.4), ('2025-11-07', 'FIL', 0.78), ('2024-04-23', 'HBAR', 0.73), ('2021-01-06', 'XLM', 0.77), ('2021-01-02', 'DOGE', 0.85), ('2021-01-28', 'DOGE', 3.92)]
fee 0.3%: summed fee load 20.0%, ending equity 18.2% below zero-fee run; zero-fee x177.1 vs x145.0
fee 1.0%: summed fee load 66.7%, ending equity 48.9% below zero-fee run; zero-fee x177.1 vs x90.6
Construction sensitivity (review question 6)
The cap only affects the synthetic index; the investable book is uncapped by construction. Listing delay: 30 days sits between 0 and 90, both of which score lower. Not tuned.
synthetic 20-24 synthetic 24-now investable 20-24 investable 24-now v2 as published (join 30, cap 50%) +4278.5% dd -52.8% +67.8% dd -29.5% +8206.2% dd -55.7% +74.5% dd -29.7% cap 30% +3070.8% dd -52.8% +65.7% dd -29.6% +8206.2% dd -55.7% +74.5% dd -29.7% uncapped +6972.4% dd -52.8% +68.7% dd -29.5% +8206.2% dd -55.7% +74.5% dd -29.7% new listings from day 1 (join 0) +3643.8% dd -53.6% +67.8% dd -29.5% +6953.8% dd -56.0% +74.5% dd -29.7% join 90 days +3600.4% dd -52.1% +67.8% dd -29.5% +7234.9% dd -55.0% +74.5% dd -29.7%
3 · Freshness and coverage gate (audit fix 3)
refresh()records every coin's fetch result.health()publishes the expected close, BTC's last close, per-basket member coverage and missing coins, alt-clock coverage, funding and trench last days.- STALE (BTC not at the expected close, or alt-clock coverage under 90%): no new decision, no alert. The page shows the last valid call with its decision time, struck through, under a red data line.
- DEGRADED (a basket under 80% coverage, or any fetch failure): that basket shows NO READ and cannot trigger a colour alert. A stale trench database blanks the trench card.
- The 90% and 80% coverage floors are operational thresholds, not signal thresholds. They are flagged for the reviewer.
4 · Alert outbox (audit fix 4)
- r2: one exclusive file lock around every outbox read-modify-write, unique temp files per writer. Race evidence:
WITHOUT the lock: 23 of 80 events survive in the outbox; 83 sends, 76 unique ids (7 duplicates) WITH the lock (test_v21.py test 8): 80 of 80 kept, 80 sends, 0 duplicates
- A transition is written to
alert_outbox.jsonbefore the state file is saved. Delivery is attempted from the outbox and marked delivered only on Telegram'sok:true. Anything pending is retried byrotmap.py --flush(proposed cron every 10 minutes). After 48 hours it becomes dead-lettered and shows on the page. - Each event has a stable id:
sha1(close|kind|key|value). A rerun of the same close never enqueues it twice. - Limit: Telegram has no idempotency key. A timeout after Telegram accepted a message can still produce one duplicate. This is at-least-once delivery, not exactly-once; the message carries its ref id so a duplicate is recognisable.
- An append-only
journal.jsonlrecords every run: input cutoff, status, coverage, signal state, intended weights, fill close, enqueued alert ids, then delivery and dead-letter events.
5 · Contamination (audit fix 5)
- Freeze. v2 file sha256
32a5fcfc0faa47dca6fd10d69da3d38dd15fbb64f30f53dbfc4b689b231a5b10. Lockbox = UTC closes from 2026-09-28. Any change to v2 or E1 after looking at lockbox data spends the lockbox. - Selection. CSCV over 69 variants (the risk averages and bands, ensembles, relative-strength gates, breadth gates, basket relative strength, alts-when-risk-on). It is a selection-instability diagnostic, not the probability that v2 is luck. Old index: the in-sample winner lands below the out-of-sample median in 31% of splits and beats holding BTC in 81%. Coin-level returns: 50% and 73%. The v2 book ranks 3rd (old index) and 5th (coin-level) of 69 by full-period Sharpe.
- No clean out-of-sample rotation exists today. 2020-24 was used to pick 100 days; 2024-26 was used to design v1. Both are labelled in-sample on the page.
- Survivorship, probed. OKX returns nothing for delisted pairs (MATIC, FTT, SRM, LUNC, WAVES, XMR, EOS and others all give error 51001), so OKX alone cannot build a point-in-time universe. Two sources can:
data.binance.visionserves daily klines for delisted pairs (FTTUSDT 2022-10 and LUNAUSDT 2022-04 both return 200 from this machine), and CoinMarketCap historical snapshots give dated rankings (the 2021-01-03 page returns 200; its first 20 rows are server-rendered, the rest need its data endpoint). Plan item P5 builds that universe.
variants K=69, days T=2309, blocks S=16, combinations 12870
PBO = 0.31 (share of splits where the in-sample winner lands below the out-of-sample median)
share of splits where the in-sample winner beats holding BTC out of sample: 0.81
median logit 0.71; OOS Sharpe of IS winner minus OOS median: median 0.0062
top 8 by full-period daily Sharpe: [('G risk 100d b3', 1.42), ('G risk 125d b0', 1.4), ('G risk 100d b1', 1.37), ('G risk 125d b1', 1.37), ('G risk 150d b3', 1.36), ('G risk 100d b0', 1.33), ('G risk 50d b1', 1.31), ('G risk 125d b3', 1.28)]
v2 book rank by Sharpe: 3 of 69
--- coin-level returns ---
variants K=69, days T=2309, blocks S=16, combinations 12870
PBO = 0.50 (share of splits where the in-sample winner lands below the out-of-sample median)
share of splits where the in-sample winner beats holding BTC out of sample: 0.73
median logit -0.06; OOS Sharpe of IS winner minus OOS median: median -0.0002
top 8 by full-period daily Sharpe: [('G risk 100d b3', 1.42), ('G risk 125d b0', 1.4), ('G risk 125d b1', 1.37), ('G risk 150d b3', 1.37), ('G risk 100d b1', 1.34), ('G risk 125d b3', 1.31), ('G risk 100d b0', 1.3), ('G risk 50d b1', 1.29)]
v2 book rank by Sharpe: 5 of 69
Historical member coverage of the corrected index
BTC days under 100%: 0 under 80%: 0 worst 1.00 on None first gaps [] ETH days under 100%: 0 under 80%: 0 worst 1.00 on None first gaps [] SOL days under 100%: 0 under 80%: 0 worst 1.00 on None first gaps [] MAJORS days under 100%: 0 under 80%: 0 worst 1.00 on None first gaps [] MEMES days under 100%: 0 under 80%: 0 worst 1.00 on None first gaps [] AI days under 100%: 0 under 80%: 0 worst 1.00 on None first gaps [] DEFI days under 100%: 0 under 80%: 0 worst 1.00 on None first gaps []
Basket-top evidence as independent episodes
2020-06..2024-06: basket events lagged BTC 15 of 22; distinct episodes 10, majority-lagged 8 of 10 (95% CI 49-94%) episode from 2020-08-11: MAJORS lagged (-13% vs BTC) episode from 2021-03-23: MAJORS beat (+42% vs BTC) episode from 2021-05-23: MAJORS lagged (-12% vs BTC) episode from 2021-09-25: AI lagged (-29% vs BTC), MAJORS lagged (-21% vs BTC) episode from 2022-01-21: MAJORS lagged (-14% vs BTC) episode from 2022-08-20: DEFI beat (+1% vs BTC), AI beat (+26% vs BTC), MAJORS lagged (-5% vs BTC), DEFI lagged (-16% vs BTC), MAJORS lagged (-5% vs BTC) episode from 2023-03-07: AI lagged (-41% vs BTC) episode from 2023-12-05: AI beat (+9% vs BTC), DEFI beat (+18% vs BTC), MEMES lagged (-32% vs BTC), AI beat (+148% vs BTC), DEFI lagged (-15% vs BTC), MAJORS lagged (-9% vs BTC) episode from 2024-03-31: MAJORS lagged (-20% vs BTC), MEMES beat (+82% vs BTC), AI lagged (-21% vs BTC) episode from 2024-06-14: MEMES lagged (-6% vs BTC) 2024-07..now: basket events lagged BTC 10 of 13; distinct episodes 5, majority-lagged 2 of 5 (95% CI 12-77%) episode from 2024-11-07: MEMES lagged (-16% vs BTC), AI lagged (-32% vs BTC), MAJORS lagged (-25% vs BTC), DEFI lagged (-36% vs BTC) episode from 2025-05-30: MEMES beat (+10% vs BTC) episode from 2025-08-01: MEMES lagged (-14% vs BTC), DEFI lagged (-17% vs BTC), MAJORS lagged (-18% vs BTC), MEMES lagged (-28% vs BTC), AI lagged (-27% vs BTC) episode from 2026-04-03: AI beat (+12% vs BTC) episode from 2026-06-24: AI lagged (-28% vs BTC), DEFI beat (+16% vs BTC)
6 · The rotation grade, rebuilt
- Labels use corrected, investable basket indices; the book is the coin-level one.
- Per rotation the page now shows: days late, time in the winning place, edge captured (the book's own equity between the winner and the worst place, which credits partial positions), return missed, and wrong-way days (time in the place that did worst).
- The 27-cell grid (30/45/60-day horizon, 14/21/30-day merge, 15/20/30% edge) is shown split at July 2024. It is descriptive only.
- The new miss at 45/21/20 is 2022-06-11..07-27: a bear-market alt rally the book never held because risk was off. Missing it is arguably correct, but the definition counts it.
SYSTEM SUMMARY (investable drift, 0.3% per coin trade, continuous run)
v2 frozen book pre +8206.2% dd -55.7 | post +74.5% dd -29.7 | turnover 10.5x/yr fees 20.0% | false switches 15
v1 book pre +3241.8% dd -61.2 | post +38.6% dd -48.2 | turnover 15.2x/yr fees 28.8% | false switches 32
BTC switch 100d pre +718.8% dd -51.5 | post +42.1% dd -29.7 | turnover 7.1x/yr fees 13.5% | false switches 18
hold BTC pre +557.6% dd -76.6 | post +34.3% dd -53.0 | turnover 0.2x/yr fees 0.3% | false switches 0
GRID: caught/major pre-Jul-2024 | post (v2 frozen, v1)
H30 merge14 edge15 v2 8/11 | 4/5 delay med 2 worst 45 missed 30% wrong 11% v1 6/11 | 4/5
H30 merge14 edge20 v2 7/10 | 4/4 delay med 6.0 worst 45 missed 32% wrong 11% v1 5/10 | 3/4
H30 merge14 edge30 v2 5/7 | 0/0 delay med 10 worst 40 missed 32% wrong 14% v1 4/7 | 0/0
H30 merge21 edge15 v2 9/11 | 2/2 delay med 2 worst 45 missed 30% wrong 10% v1 6/11 | 2/2
H30 merge21 edge20 v2 9/11 | 2/2 delay med 2 worst 45 missed 30% wrong 10% v1 6/11 | 2/2
H30 merge21 edge30 v2 5/6 | 1/1 delay med 20.0 worst 40 missed 35% wrong 11% v1 3/6 | 1/1
H30 merge30 edge15 v2 8/10 | 2/2 delay med 6.0 worst 45 missed 28% wrong 9% v1 5/10 | 2/2
H30 merge30 edge20 v2 8/10 | 2/2 delay med 6.0 worst 45 missed 28% wrong 9% v1 5/10 | 2/2
H30 merge30 edge30 v2 5/6 | 1/1 delay med 20.0 worst 40 missed 31% wrong 10% v1 2/6 | 1/1
H45 merge14 edge15 v2 9/12 | 1/2 delay med 6.0 worst 68 missed 35% wrong 12% v1 7/12 | 1/2
H45 merge14 edge20 v2 7/9 | 1/2 delay med 2 worst 68 missed 34% wrong 8% v1 6/9 | 1/2
H45 merge14 edge30 v2 5/6 | 1/1 delay med 5.0 worst 68 missed 33% wrong 8% v1 5/6 | 1/1
H45 merge21 edge15 v2 9/12 | 1/1 delay med 6.0 worst 68 missed 35% wrong 12% v1 7/12 | 1/1
H45 merge21 edge20 v2 7/9 | 1/1 delay med 2 worst 68 missed 33% wrong 8% v1 6/9 | 1/1
H45 merge21 edge30 v2 5/6 | 1/1 delay med 5.0 worst 68 missed 32% wrong 8% v1 5/6 | 1/1
H45 merge30 edge15 v2 7/9 | 1/1 delay med 2 worst 68 missed 35% wrong 12% v1 6/9 | 1/1
H45 merge30 edge20 v2 6/8 | 1/1 delay med 6.0 worst 68 missed 37% wrong 13% v1 5/8 | 1/1
H45 merge30 edge30 v2 6/7 | 1/1 delay med 2 worst 68 missed 34% wrong 13% v1 5/7 | 1/1
H60 merge14 edge15 v2 5/6 | 1/1 delay med 15.5 worst 68 missed 33% wrong 15% v1 4/6 | 1/1
H60 merge14 edge20 v2 5/6 | 1/1 delay med 15.5 worst 68 missed 33% wrong 15% v1 4/6 | 1/1
H60 merge14 edge30 v2 4/5 | 1/1 delay med 23 worst 68 missed 35% wrong 16% v1 4/5 | 1/1
H60 merge21 edge15 v2 5/6 | 2/3 delay med 5.0 worst 55 missed 32% wrong 16% v1 4/6 | 2/3
H60 merge21 edge20 v2 5/6 | 1/2 delay med 5.0 worst 55 missed 32% wrong 16% v1 4/6 | 2/2
H60 merge21 edge30 v2 4/5 | 1/1 delay med 8 worst 55 missed 34% wrong 17% v1 4/5 | 1/1
H60 merge30 edge15 v2 5/6 | 2/3 delay med 5.0 worst 55 missed 47% wrong 19% v1 4/6 | 2/3
H60 merge30 edge20 v2 5/6 | 1/2 delay med 5.0 worst 55 missed 47% wrong 19% v1 4/6 | 2/2
H60 merge30 edge30 v2 4/5 | 1/1 delay med 8 worst 55 missed 49% wrong 19% v1 4/5 | 1/1
v2 frozen, reference cell 45/21/20, every major rotation:
pre {'from': '2020-06-01', 'to': '2020-07-31', 'bucket': 'ALTS', 'edge': 25, 'lag': 2, 'held': 97, 'caught': True, 'missed_ret': -3, 'wrong_days': 1, 'days': 61}
pre {'from': '2020-09-03', 'to': '2020-12-02', 'bucket': 'BTC', 'edge': 64, 'lag': 10, 'held': 78, 'caught': True, 'missed_ret': 8, 'wrong_days': 5, 'days': 91}
pre {'from': '2020-12-03', 'to': '2021-04-25', 'bucket': 'ALTS', 'edge': 447, 'lag': 0, 'held': 100, 'caught': True, 'missed_ret': 0, 'wrong_days': 0, 'days': 144}
pre {'from': '2021-04-26', 'to': '2021-06-30', 'bucket': 'CASH', 'edge': 28, 'lag': 0, 'held': 76, 'caught': True, 'missed_ret': 11, 'wrong_days': 16, 'days': 66}
pre {'from': '2021-10-20', 'to': '2022-02-12', 'bucket': 'CASH', 'edge': 57, 'lag': 45, 'held': 61, 'caught': True, 'missed_ret': 9, 'wrong_days': 0, 'days': 116}
pre {'from': '2022-03-16', 'to': '2022-06-10', 'bucket': 'CASH', 'edge': 45, 'lag': 0, 'held': 78, 'caught': True, 'missed_ret': 10, 'wrong_days': 5, 'days': 87}
pre {'from': '2022-06-11', 'to': '2022-07-27', 'bucket': 'ALTS', 'edge': 26, 'lag': None, 'held': 0, 'caught': False, 'missed_ret': 26, 'wrong_days': 0, 'days': 47}
pre {'from': '2022-07-28', 'to': '2022-12-10', 'bucket': 'CASH', 'edge': 37, 'lag': 0, 'held': 96, 'caught': True, 'missed_ret': 33, 'wrong_days': 5, 'days': 136}
pre {'from': '2023-08-31', 'to': '2024-05-11', 'bucket': 'ALTS', 'edge': 66, 'lag': 68, 'held': 30, 'caught': False, 'missed_ret': 54, 'wrong_days': 49, 'days': 255}
post {'from': '2025-09-01', 'to': '2026-03-02', 'bucket': 'CASH', 'edge': 60, 'lag': 0, 'held': 88, 'caught': True, 'missed_ret': 11, 'wrong_days': 0, 'days': 183}
7 · Shadow early-entry E1 (registered, not traded)
SHADOW E1, registered 2026-09-28, never traded, never alerted.
Question: can a small, early alt position catch a rotation like 2023-08..2024-05
that the book entered 68 days late, without the 2024-26 bleed?
Rule, evaluated at each close, only while the v2 book is NOT already in alts:
ON when all hold: risk on (v2 switch); the 90d alt clock was 20% or lower at
some close in the last 120 days (a depressed base, looser than the 5%
washout); the 30d alt clock is 50% or higher (breadth turning); the 90d
alt clock is 50% or lower (still early).
SIZE one third of the book in alts, split equally over alt baskets that are
not red; the rest stays where v2 has it. When the v2 bottom confirms,
v2 takes over completely.
OFF when risk turns off, or the 30d alt clock falls under 40%.
Numbers are round values fixed before any test (20% = four times the washout
level, 120 days = twice the washout look-back, one third so a false start costs
at most a third of an alt drawdown). They may not be changed.
Lockbox: closes from 2026-09-28. Judged no earlier than 2028-09-30 and only
after 3 major rotations (45/21/20 reference) have completed inside the lockbox.
Promote only if, on the lockbox alone: investable net return >= v2, worst drop
no more than 5 points deeper, missed time in major alt runs lower, wrong-way
time not higher. Any change to E1 or v2 after looking spends the lockbox.
Registration hash (spec text + rule source): 43f0ab5edb0866dfb5e165dbe6cfe66dfb5a580bc7559b2d8d5ad717f1432b83. Order of events, stated plainly: the spec and rule were written before any test; one descriptive pass was run on history; the hash was taken after that pass with the spec unchanged. The pass cannot promote E1 because it uses the same data. r2: an independent OpenTimestamps stamp now dates the bundle from 2026-09-28 21:33 Bangkok (14:33 UTC):
File sha256 hash: 43f0ab5edb0866dfb5e165dbe6cfe66dfb5a580bc7559b2d8d5ad717f1432b83
Timestamp:
append e42e3f6dc602259ff2736408a5ab980b
sha256
-> append 27df0266c00d3bb3
sha256
append a16fc7b0304dd6ce6a50eaaf8a0c994a
sha256
prepend 8cdd0946121fa25db8dd933a88a920105dd75909ecf025e129eb7fcef3a7b902
sha256
append 8718d7c1e3de217eeb8253d3a75f5d4202a489e8ae61ce8b4b5b2a58f73a9db8
sha256
prepend 6aba7ac1
append 6a884e606e08a864
verify PendingAttestation('https://alice.btc.calendar.opentimestamps.org')
-> append 28687a02fd322bd6f13fb6ad4f08c94a
sha256
append 176cc8aa2cfc35a382a2830493184930afc042824923823bc68b12be2d3321df
sha256
append 3ccccdc9d9e7aef4231043c48bf074acd6248f2323e32bd0c6eedaf6c3c1e352
sha256
prepend 6aba7ac1
append 3b2b2dfde5dd5631
verify PendingAttestation('https://btc.calendar.catallaxy.com')
-> append a554b20c726fb8de
sha256
prepend 12d6d8f5b87b0cdfa10cb031491a0dc711010da7eb644897b4f8a5b5c2012ac5
sha256
prepend 70ec8fcd96e9d3815bccca25c4c3fd804fb2abab85e1a1855a354bfb40d7b026
sha256
append 1deb17ddd0a6a657edab2428dac7abf4c13d741801f42881ada1d42afff00b40
sha256
append 881d6ad0f8d0f07d9012a37c98c8a05a
sha256
prepend a63e63bb984359dd7ff49bb296738909f2032b7baceb2083bf8344691588d5c3
sha256
append 7472e6f60720f829019c287a809b9aacdccfaf99bde090eb73c9367bb096a034
sha256
prepend 6aba7ac1
append 9c6e8ee954c2192f
verify PendingAttestation('https://bob.btc.calendar.opentimestamps.org')
-> append bee306e179b203e25ef7cd6e9949ae94
sha256
append 623bc5bd9c0f0feed84bab52468ae6a86f0dbd63e1947214db7ef652c97ee5dc
sha256
prepend ee446dbe9e293062f413724a062dff016948793bfe2ef87d723cb11f69c87f1f
sha256
prepend 99a039204688fc511386339fd03efd5fd1c0402f5d8b72bb8d3c0c3c62bfb464
sha256
append 05cb797db2fe37c7dced4a03940bc23413eac241d9bd05ddc023da77714534c2
sha256
prepend 6aba7ac1
append 2f8c3ca64a9e7984
verify PendingAttestation('https://finney.calendar.eternitywall.com')
File sha256 hash: cf5bf09aa1303c8fd12ec93007a8d1a3ffb8a60ece63458f64846b8e358a046c
Timestamp:
append 2277ebd478b3930ad0a7972dbfd2db40
sha256
prepend cf5fa0051a93a421a30d955dc8e14c3badc65d14f88de5d0d59fdf8381bd0e9f
sha256
-> append 14673b1b40e24f5bd72b0794d4d53bbf
sha256
prepend 8ab4a589b92860efab12c995713a96cd5c5fd18d7c1281e8e84ae08dff1e592f
sha256
append 285f4667b528b12d83f28469a4a95526023af689a3be63eee8b6430040ba46c6
sha256
append 94f9e1f1fd5e617b960ffe63aefcb61a2ecd78cbeeb74031b46bcc2e47ed4ecc
sha256
prepend 6aba7acd
append 037ac58c0f43a8ee
verify PendingAttestation('https://btc.calendar.catallaxy.com')
-> append 2a6ef9dcc396df2a
sha256
append 9feede2b9beec3e5d8ae7ae0b6a376d7
sha256
prepend 921971927e1ed4901b5329ba998fef306e04ed9e7a1e6bac649acd1f95df129f
sha256
prepend 55cd84353116269058cb19a80199dec449dddced581e4137355256ea2052949b
sha256
append ff4f4049608af11fea6e7b21ef41e1b78b5eee70faad7dc342ba56a691009b80
sha256
prepend 6aba7acd
append 9f32b3df5ba425c6
verify PendingAttestation('https://alice.btc.calendar.opentimestamps.org')
-> append 87c0bb0562a2db7e
sha256
append b228dcd25f714208ff22567ea1fefb88ed5e33cab633dcec90f2f83a48103992
sha256
append 033c01d18fb164a4d6c1385d7242e610
sha256
append 3c370df9b1549b91471b3e4b3b4ca2ba765845cedd0a329d96893efae052bcd8
sha256
append 10b9690b26a74098077738b16a4a2c73b45c5d83b447fd4af1cf658da65968a0
sha256
append b391bbd589340049afeabedc6a80492207209ecd47fad71e979e1e5b2e97fdeb
sha256
prepend 6aba7acd
append 33342d906f6fac11
verify PendingAttestation('https://bob.btc.calendar.opentimestamps.org')
-> append c67504dfd0546eae70d9b914a1189209
sha256
prepend 4a25616091eb3f3c6be796ddb6d11a4ac71e91208f88a1354cf7feec24f53577
sha256
prepend 69e101908135ba42847ca55351e9576c6785d966f8393f7dff96f7df00bc53c6
sha256
append c4a9223dcc93832d7828d45d66287c8175846a8899a8fb03e868c00ac6b92765
sha256
append a21645c8dd058aca3cffa239d5ccdded2bb8401a01ecabddd84628b2c5cdb47f
sha256
prepend 6aba7acd
append 78cb4394e7f2c53f
verify PendingAttestation('https://finney.calendar.eternitywall.com')
Result of that pass. Inside the 2023-08..2024-05 run E1 first switched on only on 2024-02-07 (its earlier activations were in 2022 and January 2023), so the run is still 68 days late (edge captured 63.3% against 63.6%). 2020-24 return was about equal; since 2024 it was 5.5 points lower. It stays in shadow because it costs nothing to log. I did not iterate it: any E2 designed after seeing this pass would be just as contaminated.
E1 switched on 14 times: ['2022-03-23', '2022-03-26', '2022-03-29', '2022-11-04', '2022-11-07', '2023-01-15', '2024-02-07', '2024-07-21', '2024-09-25', '2024-11-24', '2026-05-08', '2026-05-21', '2026-09-06', '2026-09-17']
fold v2 book v2 + E1
2020 +151.4% +151.4%
2021 +1726.4% +1726.4%
2022 -33.6% -36.7%
2023 +83.1% +92.9%
2024 +71.6% +64.5%
2025 +30.9% +30.9%
2026 +1.1% +1.7%
2020-06..2024-06 v2 +8206.2% dd -55.7 | v2+E1 +8216.7% dd -57.0
2024-07..now v2 +74.5% dd -29.7 | v2+E1 +69.0% dd -29.7
v2 all: {'n': 10, 'caught': 8, 'median_delay': 2, 'worst_delay': 68, 'missed_time_pct': 29.8, 'wrong_way_pct': 6.8, 'median_capture_pct': 84.2} | alt runs: [('2020-06-01', 2, 96.7), ('2020-12-03', 0, 100.0), ('2022-06-11', 47, 0.0), ('2023-08-31', 68, 30.2)]
v2+E1 all: {'n': 10, 'caught': 8, 'median_delay': 2, 'worst_delay': 68, 'missed_time_pct': 29.7, 'wrong_way_pct': 6.8, 'median_capture_pct': 84.2} | alt runs: [('2020-06-01', 2, 96.7), ('2020-12-03', 0, 100.0), ('2022-06-11', 47, 0.0), ('2023-08-31', 68, 31.0)]
v2 2020-06-01 delay 2 held 96.7 captured 96.1 missed -2.8
v2 2020-12-03 delay 0 held 100.0 captured 89.9 missed 0.0
v2 2022-06-11 delay 47 held 0.0 captured 43.0 missed 26.2
v2 2023-08-31 delay 68 held 30.2 captured 63.6 missed 54.0
v2+E1 2020-06-01 delay 2 held 96.7 captured 96.1 missed -2.8
v2+E1 2020-12-03 delay 0 held 100.0 captured 89.9 missed 0.0
v2+E1 2022-06-11 delay 47 held 0.0 captured 43.0 missed 26.2
v2+E1 2023-08-31 delay 68 held 31.0 captured 63.3 missed 54.7
8 · Where I disagree with the audit, or go further
- Direction of the synthetic-index bias. The audit expected the friction-light index to overstate returns. It understated them (geometric mean plus clipping). Coin-level costs are real, 20 to 67% of equity summed over the trades, but the investable book still beats the synthetic one. What does overstate history is survivorship and the missing slippage on thin coins, not the index.
- The requested "5/6 + 1/1" split sits on the flawed index. On corrected indices the same definition gives 7/9 + 1/1. I report the corrected figure and explain the change rather than keep a number I know is built on a bug.
- Warm start. A real bug, but it did not change the published split, because the book was in cash on 2024-06-30. It does change results for any boundary inside an alt leg.
- Exactly-once alerts are not possible on Telegram. The outbox gives at-least-once delivery with a visible ref id.
- Basket tops deserve a harder downgrade than the audit asked for. Since July 2024 only 2 of 5 distinct episodes lagged BTC. Relabelled in-sample; keep watching before relying on it.
- The half-duration rule cannot credit a staged entry. A one-third position never counts as "holding alts". Edge captured was added so E1 (or any staged entry) can be judged fairly.
- Agreed without reservation: freeze v2, forward lockbox, grid publication, episode counting, freshness gating, journal, the labels, and contrast and missing-data rendering.
9 · Patch plan (nothing is applied until you ask)
| # | Change | Files and commands on the droplet | Check before and after |
|---|---|---|---|
| P0 | Freeze v2 | copy /opt/rotation/rotmap.py to rotmap_v2_frozen.py; store the sha256 | hash equals 32a5fcfc0faa47dc… |
| P1 | Service v2.1 | install rotmap.py (sha256 b8e7e2415f698ad0…); run test_v21.py on the droplet store; one --offline --dry run | all tests pass; call and weights equal v2's; health OK |
| P2 | Alert delivery | cron */10 * * * * cd /opt/rotation && python3 -W ignore rotmap.py --flush; keep the 4-hourly run | inject a failing send in dry mode; the outbox shows pending then delivered |
| P3 | Monitoring | extend fresh.py: alert on health STALE for more than 6h, outbox pending for more than 1h, or any dead letter | fake-age tests as before |
| P4 | Dashboard v2.1 | install rotation.html (sha256 c0d3c57adfac4788…) | 390px and 1280px render, no console errors, stale state renders struck through |
| P5 | Point-in-time universe (research) | monthly CMC snapshots for membership, Binance vision klines for delisted prices; rerun the grade and books | report the survivorship gap as a number; no rule changes |
| P6 | Lockbox review | no earlier than 2028-09-30 and after 3 completed major rotations | pre-registered E1 criteria, v2 unchanged |
| — | Rollback | copy rotmap_v2_frozen.py back to rotmap.py, restore the saved v2 rotation.html, remove the flush cron | dashboard renders the v2 page |
10 · Code
Service candidate and diff from frozen v2: rotmap.py · diff · rotation.html · diff · tests. Audit scripts: common · invest · indices · score · grid · episodes · pbo · construction · E1 pass · warm. The frozen v2 file itself is in the v2 handover.
rotmap.py (v2.1 candidate)
#!/usr/bin/env python3
"""ROTATION MAP v2.1 (28 Sep 2026): v2 rules, frozen, plus the review fixes at the
bottom of this file (measurement, freshness gate, alert outbox, journal, shadow E1).
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, fetch = [], {}
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)
fetch[s] = bool(new)
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, fetch
# ------------------------------------------------------------------ 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]
# ===================================================================== v2.1 ==
# Everything below was added on 28 Sep 2026 after the external review. None of
# it changes a trading rule: Model, book_rule and every threshold are exactly
# v2 (sha256 of the frozen file is in V2_FREEZE.sha256). It changes how the
# book is MEASURED, when a decision is PUBLISHED, and how alerts are DELIVERED.
LOCKBOX_START = "2026-09-28" # first UTC close never seen while building v2
SPLIT = "2024-07-01"
OUTBOX = os.path.join(HERE, "alert_outbox.json")
JOURNAL = os.path.join(HERE, "journal.jsonl")
LAST_VALID = os.path.join(HERE, "last_valid.json")
SHADOW_LOG = os.path.join(HERE, "shadow_e1.jsonl")
MIN_ALT_COVERAGE = 0.90 # share of eligible alts with today's candle
MIN_BASKET_COVERAGE = 0.80 # per basket
DEAD_AFTER_H = 48 # an alert not delivered in 48h goes to dead letter
def atomic_json(path, obj):
"""r2: unique temp name per writer, so two processes never share one temp file."""
import tempfile
fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".", dir=os.path.dirname(os.path.abspath(path)))
with os.fdopen(fd, "w") as f:
json.dump(obj, f, separators=(",", ":"))
os.replace(tmp, path)
class outbox_lock:
"""r2: one exclusive lock around every outbox read-modify-write, shared by the
4-hourly run and the 10-minute flusher."""
def __enter__(self):
import fcntl
self.f = open(OUTBOX + ".lock", "a")
fcntl.flock(self.f, fcntl.LOCK_EX)
return self
def __exit__(self, *a):
import fcntl
fcntl.flock(self.f, fcntl.LOCK_UN)
self.f.close()
def rnd1(x):
"""The ONE rounding rule: Python rounds to 1 decimal, the page prints it as is."""
return None if x is None else round(float(x), 1)
# ------------------------------------------------ corrected basket indices --
MIN_INDEX_COVERAGE = 0.80 # r2: a benchmark day with fewer member closes is unknown
def arith_index(M, members, join=30, coverage=None):
"""What an equal-weight basket rebalanced at each close actually earns: the
ARITHMETIC mean of member returns, uncapped. v2's Model._index averages log
returns (a geometric mean, not a portfolio) and caps days at +-50%, which
understated MAJORS 2020-06..now as x1.25 against x4.53 for the portfolio.
Signals never read index levels, so this only changes grading and display."""
out, lvl = [None] * M.N, None
for i in range(1, M.N):
d, q = M.days[i], M.days[i - 1]
elig = [s for s in members if d >= _add(M.first[s], join)]
r = [M.px[s][d] / M.px[s][q] - 1 for s in elig if M.px[s].get(q) and M.px[s].get(d)]
cov = len(r) / len(elig) if elig else None
if coverage is not None:
coverage.append(cov)
if r and cov >= MIN_INDEX_COVERAGE:
lvl = (lvl or 100.0) * (1 + sum(r) / len(r))
out[i] = lvl
elif r:
out[i] = None # r2: too few member closes: the day is unknown, never silently renormalised
else:
out[i] = lvl
return out
def arith_indices(M, cov_out=None):
I = {}
for b in PRICE_BASKETS:
cv = []
I[b] = arith_index(M, M.mem[b], coverage=cv)
if cov_out is not None:
known = [c for c in cv if c is not None]
cov_out[b] = {"days_below_100": sum(1 for c in known if c < 1), "days_unknown": sum(1 for c in known if c < MIN_INDEX_COVERAGE),
"worst": rnd1(100 * min(known)) if known else None}
out, lvl = [None] * M.N, None
for i in range(1, M.N):
rs = [I[b][i] / I[b][i - 1] - 1 for b in ALT_BASKETS if I[b][i] and I[b][i - 1]]
if len(rs) >= 2:
lvl = (lvl or 100.0) * (1 + sum(rs) / len(rs))
out[i] = lvl
I["ALTS"] = out
return I
# -------------------------------------------- investable, point-in-time book --
def coin_target(M, bw, i):
w = {}
for b, W in bw.items():
live = [s for s in M.mem[b] if M.p(s, i) and M.days[i] >= _add(M.first[s], 30)]
for s in live:
w[s] = w.get(s, 0) + W / len(live)
return w
def invest(M, rule, lo, hi, fee=FEE, warm_from=None):
"""Coin-level book. Decision at close i, filled at close i+1. Each basket
weight is split over members live on the fill day. Weights drift between
fills (no hidden daily rebalance). Every coin trade pays `fee`. Real closes,
no cap. Returns the curve [(day, equity, basket weights)] and cost stats."""
val, cash, pend, last_bw, lastpx = {}, 1.0, [], None, {}
curve, turn, fees = [], 0.0, 0.0
for i in range(lo if warm_from is None else warm_from, hi + 1):
for s in list(val):
p0, p1 = lastpx.get(s), M.p(s, i)
if p0 and p1:
val[s] *= p1 / p0
if p1:
lastpx[s] = p1
tot = cash + sum(val.values())
bw = None
while pend and pend[0][0] <= i:
_, bw = pend.pop(0)
if bw is not None:
new = {s: tot * w for s, w in coin_target(M, bw, i).items()}
moved = sum(abs(new.get(s, 0) - val.get(s, 0)) for s in set(new) | set(val))
if moved > 1e-9 * tot:
cost = fee * moved
if i >= lo:
turn += moved / tot
fees += cost / tot
k = (tot - cost) / tot
val = {s: v * k for s, v in new.items() if v > 0}
for s in val:
lastpx[s] = M.p(s, i) or lastpx.get(s)
cash = (tot - cost) - sum(val.values())
last_bw = bw
d = rule(i)
if d != (pend[-1][1] if pend else last_bw):
pend.append((i + 1, d))
if i >= lo:
curve.append((M.days[i], cash + sum(val.values()), dict(last_bw or {})))
yrs = len(curve) / 365
return curve, {"turnover_per_year": rnd1(turn / yrs), "fee_drag_pct": rnd1(100 * fees)}
def slice_stats(curve, a, z=None):
"""Stats for [a, z] cut out of ONE continuous run (the warm-start rule)."""
c = [x for x in curve if x[0] >= a and (z is None or x[0] <= z)]
s = stats(c)
return {k: rnd1(v) for k, v in s.items()}
# ------------------------------------------------------------ grading v2.1 --
def label_runs(M, I, lo, H=45, merge=21, edge=0.20):
def lr(k, a, b):
if k == "CASH":
return 0.0
x = I[k]
return math.log(x[b] / x[a]) if x[a] and x[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 < merge:
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
e = math.exp(r[l] - max(v for k, v in r.items() if k != l)) - 1
out.append({"a": a, "b": b, "bucket": l, "edge": e, "r": r, "worst": min(r, key=r.get), "major": e >= edge})
return out
def bucket_of(w):
return "CASH" if not w else ("ALTS" if sum(v for k, v in w.items() if k != "BTC") >= 0.5 else "BTC")
def capture(r, eq):
"""Share of the winner's edge over the worst place that the book's own equity
captured across the run (credits partial exposure; 100 = matched the winner)."""
a, z = r["a"], r["b"] + 1
if a not in eq or z not in eq:
return None
got = math.log(eq[z] / eq[a]) # r2: close a to close z, the same interval as r["r"]
best, worst = r["r"][r["bucket"]], min(r["r"].values())
return rnd1(100 * (got - worst) / (best - worst)) if best > worst else None
def grade_runs(M, I, curve, runs):
held = {M.pos[d]: bucket_of(w) for d, _, w in curve}
eq = {M.pos[d]: e for d, e, _ in curve}
def dr(k, i):
if k == "CASH":
return 0.0
x = I[k]
return math.log(x[i + 1] / x[i]) if i + 1 < M.N and x[i] and x[i + 1] else 0.0
rows = []
for r in runs:
if not r["major"]:
continue
span = [k for k in range(r["a"], r["b"] + 1) if k in held]
if not span:
continue
inb = [k for k in span if held[k] == r["bucket"]]
rows.append({"from": M.days[r["a"]], "to": M.days[r["b"]], "bucket": r["bucket"], "edge": rnd1(100 * r["edge"]),
"delay": (inb[0] - r["a"]) if inb else len(span), "held": rnd1(100 * len(inb) / len(span)),
"caught": len(inb) / len(span) >= 0.5, "days": len(span),
"missed_return": rnd1(100 * (math.exp(sum(dr(r["bucket"], k) - dr(held[k], k) for k in span)) - 1)),
"wrong_way_days": sum(1 for k in span if held[k] == r["worst"]),
"captured_pct": capture(r, eq),
"period": "pre" if M.days[r["a"]] < SPLIT else "post"})
return rows
def grade_summary(rows):
if not rows:
return {"n": 0, "caught": 0}
d = sum(x["days"] for x in rows)
dl = sorted(x["delay"] for x in rows)
return {"n": len(rows), "caught": sum(x["caught"] for x in rows), "median_delay": dl[len(dl) // 2],
"worst_delay": dl[-1], "missed_time_pct": rnd1(100 * sum(x["days"] * (1 - x["held"] / 100) for x in rows) / d),
"wrong_way_pct": rnd1(100 * sum(x["wrong_way_days"] for x in rows) / d),
"median_capture_pct": sorted(x["captured_pct"] for x in rows if x["captured_pct"] is not None)[
len([x for x in rows if x["captured_pct"] is not None]) // 2] if any(x["captured_pct"] is not None for x in rows) else None}
def false_switches(log, days=14):
n = 0
for (d1, w1), (d2, w2), (d3, w3) in zip(log, log[1:], log[2:]):
if bucket_of(w1) == bucket_of(w3) != bucket_of(w2) and \
(dt.date.fromisoformat(d3) - dt.date.fromisoformat(d2)).days <= days:
n += 1
return n
def basket_top_episodes(M, I, lo):
"""Basket-top events, and the same events grouped into distinct market
episodes (events within 60 days of each other are one episode)."""
ev = []
for b in M.b90:
last = -999
for k in range(lo + 1, M.N - 60):
if M.basket_top(b, k) and not M.basket_top(b, k - 1) and k - last > 45:
x, y = I[b], I["BTC"]
if x[k] and x[k + 60] and y[k] and y[k + 60]:
ev.append((k, b, x[k + 60] / x[k] < y[k + 60] / y[k]))
last = k
ev.sort()
eps, cur = [], []
for e in ev:
if cur and e[0] - cur[-1][0] > 60:
eps.append(cur)
cur = []
cur.append(e)
if cur:
eps.append(cur)
out = {}
for per, f in (("pre", lambda d: d < SPLIT), ("post", lambda d: d >= SPLIT)):
E = [e for e in ev if f(M.days[e[0]])]
P = [p for p in eps if f(M.days[p[0][0]])]
out[per] = {"events": len(E), "events_lagged": sum(e[2] for e in E), "episodes": len(P),
"episodes_majority_lagged": sum(1 for p in P if 2 * sum(e[2] for e in p) > len(p))}
return out
# -------------------------------------------------- freshness and coverage --
def expected_close(now=None):
now = now or dt.datetime.utcnow()
return (now.date() - dt.timedelta(days=1)).isoformat()
def health(M, px, fd, fetch=None, trench_last=None, now=None):
exp = expected_close(now)
btc_last = max(px["BTC"]) if px.get("BTC") else None
asof = M.days[-1]
per_basket = {}
for b in PRICE_BASKETS:
elig = [s for s in M.mem[b] if _add(M.first[s], 30) <= asof]
have = [s for s in elig if asof in px.get(s, {})]
per_basket[b] = {"eligible": len(elig), "with_close": len(have),
"coverage": rnd1(100 * len(have) / len(elig)) if elig else None,
"missing": sorted(set(elig) - set(have))}
elig = [s for s in M.alts if M.days[max(0, M.N - 91)] >= _add(M.first[s], 30)]
have = [s for s in elig if asof in px.get(s, {}) and M.days[M.N - 91] in px.get(s, {})]
alt_cov = len(have) / len(elig) if elig else 0
fund_last = max((max(v) for v in fd.values() if v), default=None)
problems = []
if btc_last != exp:
problems.append("BTC last close %s, expected %s" % (btc_last, exp))
if alt_cov < MIN_ALT_COVERAGE:
problems.append("alt clock coverage %.0f%% (need %.0f%%)" % (100 * alt_cov, 100 * MIN_ALT_COVERAGE))
weak = [b for b, v in per_basket.items() if v["coverage"] is not None and v["coverage"] < 100 * MIN_BASKET_COVERAGE]
failed = sorted(s for s, v in (fetch or {}).items() if not v)
status = "STALE" if problems else ("DEGRADED" if weak or failed else "OK")
return {"status": status, "expected_close": exp, "btc_last_close": btc_last, "as_of": asof,
"alt_clock_coverage": rnd1(100 * alt_cov), "baskets": per_basket, "weak_baskets": weak,
"fetch_failed": failed, "funding_last_day": fund_last, "trench_last_day": trench_last,
"problems": problems}
# ----------------------------------------------------------------- outbox --
def _outbox():
return json.load(open(OUTBOX)) if os.path.exists(OUTBOX) else []
def enqueue(events, as_of):
"""events: [(kind, key, value, text)]. Stable id per transition, so a retry
or a rerun of the same close never makes a second message."""
import hashlib
with outbox_lock():
return _enqueue(events, as_of, hashlib)
def _enqueue(events, as_of, hashlib):
box = _outbox()
have = {m["id"] for m in box}
added = []
for kind, key, value, text in events:
mid = hashlib.sha1(("%s|%s|%s|%s" % (as_of, kind, key, value)).encode()).hexdigest()[:12]
if mid in have:
continue
box.append({"id": mid, "as_of": as_of, "created": int(time.time()), "text": text,
"status": "pending", "attempts": 0, "last_error": None})
added.append(mid)
atomic_json(OUTBOX, box)
return added
def flush(send, dry=False):
with outbox_lock():
return _flush(send, dry)
def _flush(send, dry=False):
box = _outbox()
for m in box:
if m["status"] != "pending":
continue
if time.time() - m["created"] > DEAD_AFTER_H * 3600:
m["status"] = "dead"
journal({"event": "alert_dead", "id": m["id"]})
continue
if dry:
print("DRY ALERT %s:\n%s" % (m["id"], m["text"]))
continue
ok, err = send(m["text"] + "\n<i>ref %s</i>" % m["id"])
m["attempts"] += 1
if ok:
m["status"], m["delivered"] = "delivered", int(time.time())
journal({"event": "alert_delivered", "id": m["id"]})
else:
m["last_error"] = err
atomic_json(OUTBOX, box) # after every message, so a crash loses nothing
keep = [m for m in box if m["status"] == "pending" or time.time() - m["created"] < 30 * 86400]
atomic_json(OUTBOX, keep)
return {"pending": sum(m["status"] == "pending" for m in keep), "dead": sum(m["status"] == "dead" for m in keep),
"last_delivered": max([m.get("delivered", 0) for m in keep] or [0]) or None}
def tg_send(msg):
"""(ok, error). ok only on Telegram's own ok:true."""
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()
r = json.load(urllib.request.urlopen(urllib.request.Request(
"https://api.telegram.org/bot%s/sendMessage" % cfg["bot_token"], body,
{"content-type": "application/json"}), timeout=25))
return bool(r.get("ok")), None if r.get("ok") else str(r.get("description"))[:80]
except Exception as e:
return False, type(e).__name__ + ": " + str(e)[:60]
def journal(rec):
rec = dict(rec, ts=int(time.time()))
with open(JOURNAL, "a") as f:
f.write(json.dumps(rec, separators=(",", ":")) + "\n")
# -------------------------------------------------- shadow experiment E1 --
E1_SPEC = """SHADOW E1, registered 2026-09-28, never traded, never alerted.
Question: can a small, early alt position catch a rotation like 2023-08..2024-05
that the book entered 68 days late, without the 2024-26 bleed?
Rule, evaluated at each close, only while the v2 book is NOT already in alts:
ON when all hold: risk on (v2 switch); the 90d alt clock was 20% or lower at
some close in the last 120 days (a depressed base, looser than the 5%
washout); the 30d alt clock is 50% or higher (breadth turning); the 90d
alt clock is 50% or lower (still early).
SIZE one third of the book in alts, split equally over alt baskets that are
not red; the rest stays where v2 has it. When the v2 bottom confirms,
v2 takes over completely.
OFF when risk turns off, or the 30d alt clock falls under 40%.
Numbers are round values fixed before any test (20% = four times the washout
level, 120 days = twice the washout look-back, one third so a false start costs
at most a third of an alt drawdown). They may not be changed.
Lockbox: closes from 2026-09-28. Judged no earlier than 2028-09-30 and only
after 3 major rotations (45/21/20 reference) have completed inside the lockbox.
Promote only if, on the lockbox alone: investable net return >= v2, worst drop
no more than 5 points deeper, missed time in major alt runs lower, wrong-way
time not higher. Any change to E1 or v2 after looking spends the lockbox."""
def e1_rule(M):
g, gs = M.book_rule()
s = {"on": False}
def rule(i):
base = g(i)
in_alts = bucket_of(base) == "ALTS"
c30, c90 = M.c30[i], M.c90[i]
depressed = any(x is not None and x <= 0.20 for x in M.c90[max(0, i - 120):i + 1])
if s["on"] and (not M.risk_on(i) or (c30 is not None and c30 < 0.40)):
s["on"] = False
elif not s["on"] and not in_alts and M.risk_on(i) and depressed and \
c30 is not None and c30 >= 0.50 and c90 is not None and c90 <= 0.50:
s["on"] = True
if in_alts or not s["on"]:
if in_alts:
s["on"] = False
return base
live = [b for b in ALT_BASKETS if M.idx[b][i] and not M.basket_top(b, i)]
w = {b: (1 / 3) / len(live) for b in live} if live else {}
rest = 1 - sum(w.values())
for k, v in base.items():
w[k] = w.get(k, 0) + v * rest
return w
return rule, s
# ------------------------------------------------------------- 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) # synthetic index run: only for the decision path
held = curve[-1][2]
covs = {}
I = arith_indices(M, covs) # v2.1: what a basket really earns
def iret(b, n):
x = I[b]
return (x[i] / x[i - n] - 1) if i - n >= 0 and x[i] and x[i - n] else None
def ima(b, n=50):
w = [v for v in I[b][max(0, i - n + 1):i + 1] if v]
return sum(w) / len(w) if len(w) >= int(0.8 * n) else None
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 = iret(b, 30), iret("BTC", 30)
c["vs_btc_30d"] = rnd1(100 * (r30 - rb30)) if (r30 is not None and rb30 is not None and b != "BTC") else None
c["ret_30d"] = rnd1(100 * r30) if r30 is not None else None
m = ima(b)
c["trend"] = None if not (m and I[b][i]) else ("up" if I[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 = I[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 (v2.1): basket tops as events AND as distinct market episodes
ev = {"basket_top": basket_top_episodes(M, I, lo), "index_coverage": covs}
# ---- books (v2.1): every period is a slice of ONE continuous run, and the
# headline book is the investable coin-level one with fees on every coin trade
inv_curve, inv_cost = invest(M, M.book_rule()[0], lo, i)
sw_curve, sw_cost = invest(M, lambda k: {"BTC": 1.0} if M.risk_on(k) else {}, lo, i)
hb_curve, _ = invest(M, lambda k: {"BTC": 1.0}, lo, i)
books = {}
for per, a_, z_ in (("holdout", "2020-06-01", "2024-06-30"), ("recent", SPLIT, None), ("full", "2020-06-01", None)):
books[per] = {"book": slice_stats(inv_curve, a_, z_), "btc_trend": slice_stats(sw_curve, a_, z_),
"hold_btc": slice_stats(hb_curve, a_, z_), "book_synthetic": slice_stats(curve, a_, z_)}
nofee, _ = invest(M, M.book_rule()[0], lo, i, fee=0.0)
inv_cost["ending_equity_lost_to_fees_pct"] = rnd1(100 * (1 - inv_curve[-1][1] / nofee[-1][1]))
books["costs"] = {"book": inv_cost, "btc_trend": sw_cost, "false_switches_14d": false_switches(log),
"notes": "turnover is gross two-sided (buys plus sells) per year; fee_drag_pct sums each fee as a share of equity at that trade; ending_equity_lost_to_fees_pct compares final equity with the same run at zero fees"}
# ---- the rotation timeline, every leg the book took
legs, prev = [], None
eqd = {d: e for d, e, _ in inv_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 * (inv_curve[-1][1] / eqd[prev[0]] - 1), 1)})
# ---- scoreboard (v2.1): graded on corrected indices and the investable book.
# Reference definition 45/21/20, plus the full 27-cell sensitivity grid.
ref = grade_runs(M, I, inv_curve, label_runs(M, I, lo, 45, 21, 0.20))
rots = ref
score = {"pre": grade_summary([x for x in ref if x["period"] == "pre"]),
"post": grade_summary([x for x in ref if x["period"] == "post"]),
"all": grade_summary(ref), "definition": "best of cash/BTC/alts over 45d, runs under 21d merged, 20%+ edge, caught = held half the run"}
grid = []
for H in (30, 45, 60):
for mg in (14, 21, 30):
for ed in (0.15, 0.20, 0.30):
g = grade_runs(M, I, inv_curve, label_runs(M, I, lo, H, mg, ed))
grid.append({"H": H, "merge": mg, "edge": int(round(ed * 100)),
"pre": grade_summary([x for x in g if x["period"] == "pre"]),
"post": grade_summary([x for x in g if x["period"] == "post"])})
score["grid"] = grid
t = ev["basket_top"]
ev_top = ("%d of %d basket events lagged BTC in 2020 to 2024 (%d of %d distinct episodes), %d of %d since "
"(%d of %d episodes)" % (t["pre"]["events_lagged"], t["pre"]["events"], t["pre"]["episodes_majority_lagged"],
t["pre"]["episodes"], t["post"]["events_lagged"], t["post"]["events"],
t["post"]["episodes_majority_lagged"], t["post"]["episodes"]))
hb, bt = books["holdout"]["hold_btc"], books["holdout"]["btc_trend"]
ev_risk = "In-sample, 2020 to 2024, this rule alone cut the worst drop of holding BTC from %s%% to %s%%." % (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(inv_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(inv_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,
"decision": {"weights_after_next_close": {b: rnd1(100 * v) for b, v in want.items()}, "fill_close": _add(today, 1)},
"settings": {"version": "2.1 (v2 rules frozen)", "lockbox_start": LOCKBOX_START, "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):
"""v2.1: transitions go to the OUTBOX first; state is saved only after the
outbox write, and delivery happens (and is retried) from the outbox."""
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"))}
events, tail = [], "\n\n" + R["as_of"] + " close \u00b7 http://159.223.88.168/rotation.html"
if old is not None:
if new["call"] != old.get("call"):
icon = {"ROTATE INTO ALTS": "\U0001F7E2", "GO TO CASH": "\U0001F534"}.get(new["call"], "\U0001F504")
events.append(("call", "book", new["call"], "%s <b>%s</b>\n%s%s" % (icon, new["call"], R["call"]["line"], tail)))
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"):
events.append(("shade", c["key"], b_, "%s <b>%s: %s</b>\n%s%s" % (
"\U0001F7E5" if b_ == "red" else "\U0001F7E9", c["name"], c["word"], c["why"], tail)))
if new["trench"] and not old.get("trench"):
t = R["trench"]
events.append(("trench", "window", t.get("last_impulse"), "\U0001F525 <b>TRENCH WINDOW OPEN</b>\nBTC just did +10%% in a week. "
"Launchpad volume was higher two weeks later in 11 of the last 14 cases (in-sample). Window closes %s.%s"
% (t.get("window_closes"), tail)))
ids = enqueue(events, R["as_of"]) if (events and not dry) else []
if dry:
for e in events:
print("DRY EVENT", e[:3])
if not dry:
atomic_json(sp, new)
return ids
# ------------------------------------------------------------------- main --
def trench_last_day():
try:
import sqlite3
return sqlite3.connect(TRENCH_DB).execute("select max(date) from d where btc is not null").fetchone()[0]
except Exception:
return None
def main():
full, dry = "--full" in sys.argv, "--dry" in sys.argv
if "--flush" in sys.argv: # cron every 10 min: deliver what is pending, nothing else
print(flush(tg_send, dry))
return
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, fetch = [], {}
else:
px, fd, dead, fetch = refresh(full)
M = Model(px, fd)
H = health(M, px, fd, fetch, trench_last_day(), now=None if "--offline" not in sys.argv else
dt.datetime.fromisoformat(max(px["BTC"])) + dt.timedelta(days=1, hours=1))
now = int(time.time())
if H["status"] == "STALE":
# never publish a fresh-looking call on stale inputs: re-show the last valid one
R = json.load(open(LAST_VALID)) if os.path.exists(LAST_VALID) else {"call": {"word": "NO CALL", "line": ""}}
R["stale"] = True
ids = []
else:
R = read(M)
if H["status"] == "DEGRADED":
for c in R["baskets"]:
if c["key"] in H["weak_baskets"]:
c["shade"], c["word"], c["why"] = "grey", "NO READ", "Too few member closes today to read this basket."
tl = H.get("trench_last_day")
if R.get("trench") and (not tl or tl < _add(H["expected_close"], -2)):
R["trench"] = {"error": "trench data stale (last day %s)" % tl}
R["stale"] = False
R["generated"] = now
R["generated_bkk"] = dt.datetime.utcfromtimestamp(now + BKK).strftime("%Y-%m-%d %H:%M")
ids = alerts(R, dry)
if not dry:
atomic_json(LAST_VALID, R)
# shadow E1: logged only, never traded, never alerted
er, es = e1_rule(M)
for k in range(M.pos.get("2020-06-01", 200), M.N):
ew = er(k)
with open(SHADOW_LOG, "a") as f:
f.write(json.dumps({"as_of": R["as_of"], "e1_on": es["on"], "weights": {k: rnd1(100 * v) for k, v in ew.items()},
"v2_weights": R["decision"]["weights_after_next_close"], "ts": now}) + "\n")
R["shadow_e1"] = {"on": es["on"], "weights": {k: rnd1(100 * v) for k, v in ew.items()}}
R["dead"] = dead
R["health"] = H
R["alerts"] = flush(tg_send, dry) if not dry else {"pending": None}
R["checked"] = now
R["checked_bkk"] = dt.datetime.utcfromtimestamp(now + BKK).strftime("%Y-%m-%d %H:%M")
if not dry:
journal({"event": "run", "as_of": R.get("as_of"), "input_cutoff": H["btc_last_close"], "status": H["status"],
"coverage": H["alt_clock_coverage"], "problems": H["problems"], "call": R["call"]["word"],
"published_new_decision": not R["stale"], "risk_on": R.get("risk", {}).get("on"),
"clock": R.get("clock"), "dropped": R.get("dropped"), "decision": R.get("decision"), "alerts_enqueued": ids})
atomic_json(PUB_JSON, R)
print("%s %s status %s risk %s clock90 %s clock30 %s" % (
R["checked_bkk"], R["call"]["word"], H["status"], R.get("risk", {}).get("on"),
(R.get("clock") or {}).get("c90"), (R.get("clock") or {}).get("c30")))
if H["problems"] or dead:
print("DATA PROBLEMS:", H["problems"], dead)
if __name__ == "__main__":
main()
rotmap_v2_to_v21.diff
--- /opt/rotation/rotmap.py 2026-09-28 12:13:18.766840716 +0000
+++ rotmap.py 2026-09-28 14:40:23.598631648 +0000
@@ -1,5 +1,8 @@
#!/usr/bin/env python3
-"""ROTATION MAP v2 (28 Sep 2026). Where the money should sit: cash, BTC or alts,
+"""ROTATION MAP v2.1 (28 Sep 2026): v2 rules, frozen, plus the review fixes at the
+bottom of this file (measurement, freshness gate, alert outbox, journal, shadow E1).
+
+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)
@@ -129,10 +132,11 @@
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 = []
+ dead, fetch = [], {}
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)
+ fetch[s] = bool(new)
if new:
px.setdefault(s, {}).update(new)
elif s in ("BTC", "ETH", "SOL"):
@@ -152,7 +156,7 @@
dead.append("funding BTC")
json.dump(fd, open(fdp + ".tmp", "w"))
os.replace(fdp + ".tmp", fdp)
- return px, fd, dead
+ return px, fd, dead, fetch
# ------------------------------------------------------------------ model --
@@ -397,6 +401,464 @@
return [x for x in out if x["edge"] >= major]
+# ===================================================================== v2.1 ==
+# Everything below was added on 28 Sep 2026 after the external review. None of
+# it changes a trading rule: Model, book_rule and every threshold are exactly
+# v2 (sha256 of the frozen file is in V2_FREEZE.sha256). It changes how the
+# book is MEASURED, when a decision is PUBLISHED, and how alerts are DELIVERED.
+
+LOCKBOX_START = "2026-09-28" # first UTC close never seen while building v2
+SPLIT = "2024-07-01"
+OUTBOX = os.path.join(HERE, "alert_outbox.json")
+JOURNAL = os.path.join(HERE, "journal.jsonl")
+LAST_VALID = os.path.join(HERE, "last_valid.json")
+SHADOW_LOG = os.path.join(HERE, "shadow_e1.jsonl")
+MIN_ALT_COVERAGE = 0.90 # share of eligible alts with today's candle
+MIN_BASKET_COVERAGE = 0.80 # per basket
+DEAD_AFTER_H = 48 # an alert not delivered in 48h goes to dead letter
+
+
+def atomic_json(path, obj):
+ """r2: unique temp name per writer, so two processes never share one temp file."""
+ import tempfile
+ fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".", dir=os.path.dirname(os.path.abspath(path)))
+ with os.fdopen(fd, "w") as f:
+ json.dump(obj, f, separators=(",", ":"))
+ os.replace(tmp, path)
+
+
+class outbox_lock:
+ """r2: one exclusive lock around every outbox read-modify-write, shared by the
+ 4-hourly run and the 10-minute flusher."""
+ def __enter__(self):
+ import fcntl
+ self.f = open(OUTBOX + ".lock", "a")
+ fcntl.flock(self.f, fcntl.LOCK_EX)
+ return self
+
+ def __exit__(self, *a):
+ import fcntl
+ fcntl.flock(self.f, fcntl.LOCK_UN)
+ self.f.close()
+
+
+def rnd1(x):
+ """The ONE rounding rule: Python rounds to 1 decimal, the page prints it as is."""
+ return None if x is None else round(float(x), 1)
+
+
+# ------------------------------------------------ corrected basket indices --
+MIN_INDEX_COVERAGE = 0.80 # r2: a benchmark day with fewer member closes is unknown
+
+
+def arith_index(M, members, join=30, coverage=None):
+ """What an equal-weight basket rebalanced at each close actually earns: the
+ ARITHMETIC mean of member returns, uncapped. v2's Model._index averages log
+ returns (a geometric mean, not a portfolio) and caps days at +-50%, which
+ understated MAJORS 2020-06..now as x1.25 against x4.53 for the portfolio.
+ Signals never read index levels, so this only changes grading and display."""
+ out, lvl = [None] * M.N, None
+ for i in range(1, M.N):
+ d, q = M.days[i], M.days[i - 1]
+ elig = [s for s in members if d >= _add(M.first[s], join)]
+ r = [M.px[s][d] / M.px[s][q] - 1 for s in elig if M.px[s].get(q) and M.px[s].get(d)]
+ cov = len(r) / len(elig) if elig else None
+ if coverage is not None:
+ coverage.append(cov)
+ if r and cov >= MIN_INDEX_COVERAGE:
+ lvl = (lvl or 100.0) * (1 + sum(r) / len(r))
+ out[i] = lvl
+ elif r:
+ out[i] = None # r2: too few member closes: the day is unknown, never silently renormalised
+ else:
+ out[i] = lvl
+ return out
+
+
+def arith_indices(M, cov_out=None):
+ I = {}
+ for b in PRICE_BASKETS:
+ cv = []
+ I[b] = arith_index(M, M.mem[b], coverage=cv)
+ if cov_out is not None:
+ known = [c for c in cv if c is not None]
+ cov_out[b] = {"days_below_100": sum(1 for c in known if c < 1), "days_unknown": sum(1 for c in known if c < MIN_INDEX_COVERAGE),
+ "worst": rnd1(100 * min(known)) if known else None}
+ out, lvl = [None] * M.N, None
+ for i in range(1, M.N):
+ rs = [I[b][i] / I[b][i - 1] - 1 for b in ALT_BASKETS if I[b][i] and I[b][i - 1]]
+ if len(rs) >= 2:
+ lvl = (lvl or 100.0) * (1 + sum(rs) / len(rs))
+ out[i] = lvl
+ I["ALTS"] = out
+ return I
+
+
+# -------------------------------------------- investable, point-in-time book --
+def coin_target(M, bw, i):
+ w = {}
+ for b, W in bw.items():
+ live = [s for s in M.mem[b] if M.p(s, i) and M.days[i] >= _add(M.first[s], 30)]
+ for s in live:
+ w[s] = w.get(s, 0) + W / len(live)
+ return w
+
+
+def invest(M, rule, lo, hi, fee=FEE, warm_from=None):
+ """Coin-level book. Decision at close i, filled at close i+1. Each basket
+ weight is split over members live on the fill day. Weights drift between
+ fills (no hidden daily rebalance). Every coin trade pays `fee`. Real closes,
+ no cap. Returns the curve [(day, equity, basket weights)] and cost stats."""
+ val, cash, pend, last_bw, lastpx = {}, 1.0, [], None, {}
+ curve, turn, fees = [], 0.0, 0.0
+ for i in range(lo if warm_from is None else warm_from, hi + 1):
+ for s in list(val):
+ p0, p1 = lastpx.get(s), M.p(s, i)
+ if p0 and p1:
+ val[s] *= p1 / p0
+ if p1:
+ lastpx[s] = p1
+ tot = cash + sum(val.values())
+ bw = None
+ while pend and pend[0][0] <= i:
+ _, bw = pend.pop(0)
+ if bw is not None:
+ new = {s: tot * w for s, w in coin_target(M, bw, i).items()}
+ moved = sum(abs(new.get(s, 0) - val.get(s, 0)) for s in set(new) | set(val))
+ if moved > 1e-9 * tot:
+ cost = fee * moved
+ if i >= lo:
+ turn += moved / tot
+ fees += cost / tot
+ k = (tot - cost) / tot
+ val = {s: v * k for s, v in new.items() if v > 0}
+ for s in val:
+ lastpx[s] = M.p(s, i) or lastpx.get(s)
+ cash = (tot - cost) - sum(val.values())
+ last_bw = bw
+ d = rule(i)
+ if d != (pend[-1][1] if pend else last_bw):
+ pend.append((i + 1, d))
+ if i >= lo:
+ curve.append((M.days[i], cash + sum(val.values()), dict(last_bw or {})))
+ yrs = len(curve) / 365
+ return curve, {"turnover_per_year": rnd1(turn / yrs), "fee_drag_pct": rnd1(100 * fees)}
+
+
+def slice_stats(curve, a, z=None):
+ """Stats for [a, z] cut out of ONE continuous run (the warm-start rule)."""
+ c = [x for x in curve if x[0] >= a and (z is None or x[0] <= z)]
+ s = stats(c)
+ return {k: rnd1(v) for k, v in s.items()}
+
+
+# ------------------------------------------------------------ grading v2.1 --
+def label_runs(M, I, lo, H=45, merge=21, edge=0.20):
+ def lr(k, a, b):
+ if k == "CASH":
+ return 0.0
+ x = I[k]
+ return math.log(x[b] / x[a]) if x[a] and x[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 < merge:
+ 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
+ e = math.exp(r[l] - max(v for k, v in r.items() if k != l)) - 1
+ out.append({"a": a, "b": b, "bucket": l, "edge": e, "r": r, "worst": min(r, key=r.get), "major": e >= edge})
+ return out
+
+
+def bucket_of(w):
+ return "CASH" if not w else ("ALTS" if sum(v for k, v in w.items() if k != "BTC") >= 0.5 else "BTC")
+
+
+def capture(r, eq):
+ """Share of the winner's edge over the worst place that the book's own equity
+ captured across the run (credits partial exposure; 100 = matched the winner)."""
+ a, z = r["a"], r["b"] + 1
+ if a not in eq or z not in eq:
+ return None
+ got = math.log(eq[z] / eq[a]) # r2: close a to close z, the same interval as r["r"]
+ best, worst = r["r"][r["bucket"]], min(r["r"].values())
+ return rnd1(100 * (got - worst) / (best - worst)) if best > worst else None
+
+
+def grade_runs(M, I, curve, runs):
+ held = {M.pos[d]: bucket_of(w) for d, _, w in curve}
+ eq = {M.pos[d]: e for d, e, _ in curve}
+
+ def dr(k, i):
+ if k == "CASH":
+ return 0.0
+ x = I[k]
+ return math.log(x[i + 1] / x[i]) if i + 1 < M.N and x[i] and x[i + 1] else 0.0
+ rows = []
+ for r in runs:
+ if not r["major"]:
+ continue
+ span = [k for k in range(r["a"], r["b"] + 1) if k in held]
+ if not span:
+ continue
+ inb = [k for k in span if held[k] == r["bucket"]]
+ rows.append({"from": M.days[r["a"]], "to": M.days[r["b"]], "bucket": r["bucket"], "edge": rnd1(100 * r["edge"]),
+ "delay": (inb[0] - r["a"]) if inb else len(span), "held": rnd1(100 * len(inb) / len(span)),
+ "caught": len(inb) / len(span) >= 0.5, "days": len(span),
+ "missed_return": rnd1(100 * (math.exp(sum(dr(r["bucket"], k) - dr(held[k], k) for k in span)) - 1)),
+ "wrong_way_days": sum(1 for k in span if held[k] == r["worst"]),
+ "captured_pct": capture(r, eq),
+ "period": "pre" if M.days[r["a"]] < SPLIT else "post"})
+ return rows
+
+
+def grade_summary(rows):
+ if not rows:
+ return {"n": 0, "caught": 0}
+ d = sum(x["days"] for x in rows)
+ dl = sorted(x["delay"] for x in rows)
+ return {"n": len(rows), "caught": sum(x["caught"] for x in rows), "median_delay": dl[len(dl) // 2],
+ "worst_delay": dl[-1], "missed_time_pct": rnd1(100 * sum(x["days"] * (1 - x["held"] / 100) for x in rows) / d),
+ "wrong_way_pct": rnd1(100 * sum(x["wrong_way_days"] for x in rows) / d),
+ "median_capture_pct": sorted(x["captured_pct"] for x in rows if x["captured_pct"] is not None)[
+ len([x for x in rows if x["captured_pct"] is not None]) // 2] if any(x["captured_pct"] is not None for x in rows) else None}
+
+
+def false_switches(log, days=14):
+ n = 0
+ for (d1, w1), (d2, w2), (d3, w3) in zip(log, log[1:], log[2:]):
+ if bucket_of(w1) == bucket_of(w3) != bucket_of(w2) and \
+ (dt.date.fromisoformat(d3) - dt.date.fromisoformat(d2)).days <= days:
+ n += 1
+ return n
+
+
+def basket_top_episodes(M, I, lo):
+ """Basket-top events, and the same events grouped into distinct market
+ episodes (events within 60 days of each other are one episode)."""
+ ev = []
+ for b in M.b90:
+ last = -999
+ for k in range(lo + 1, M.N - 60):
+ if M.basket_top(b, k) and not M.basket_top(b, k - 1) and k - last > 45:
+ x, y = I[b], I["BTC"]
+ if x[k] and x[k + 60] and y[k] and y[k + 60]:
+ ev.append((k, b, x[k + 60] / x[k] < y[k + 60] / y[k]))
+ last = k
+ ev.sort()
+ eps, cur = [], []
+ for e in ev:
+ if cur and e[0] - cur[-1][0] > 60:
+ eps.append(cur)
+ cur = []
+ cur.append(e)
+ if cur:
+ eps.append(cur)
+ out = {}
+ for per, f in (("pre", lambda d: d < SPLIT), ("post", lambda d: d >= SPLIT)):
+ E = [e for e in ev if f(M.days[e[0]])]
+ P = [p for p in eps if f(M.days[p[0][0]])]
+ out[per] = {"events": len(E), "events_lagged": sum(e[2] for e in E), "episodes": len(P),
+ "episodes_majority_lagged": sum(1 for p in P if 2 * sum(e[2] for e in p) > len(p))}
+ return out
+
+
+# -------------------------------------------------- freshness and coverage --
+def expected_close(now=None):
+ now = now or dt.datetime.utcnow()
+ return (now.date() - dt.timedelta(days=1)).isoformat()
+
+
+def health(M, px, fd, fetch=None, trench_last=None, now=None):
+ exp = expected_close(now)
+ btc_last = max(px["BTC"]) if px.get("BTC") else None
+ asof = M.days[-1]
+ per_basket = {}
+ for b in PRICE_BASKETS:
+ elig = [s for s in M.mem[b] if _add(M.first[s], 30) <= asof]
+ have = [s for s in elig if asof in px.get(s, {})]
+ per_basket[b] = {"eligible": len(elig), "with_close": len(have),
+ "coverage": rnd1(100 * len(have) / len(elig)) if elig else None,
+ "missing": sorted(set(elig) - set(have))}
+ elig = [s for s in M.alts if M.days[max(0, M.N - 91)] >= _add(M.first[s], 30)]
+ have = [s for s in elig if asof in px.get(s, {}) and M.days[M.N - 91] in px.get(s, {})]
+ alt_cov = len(have) / len(elig) if elig else 0
+ fund_last = max((max(v) for v in fd.values() if v), default=None)
+ problems = []
+ if btc_last != exp:
+ problems.append("BTC last close %s, expected %s" % (btc_last, exp))
+ if alt_cov < MIN_ALT_COVERAGE:
+ problems.append("alt clock coverage %.0f%% (need %.0f%%)" % (100 * alt_cov, 100 * MIN_ALT_COVERAGE))
+ weak = [b for b, v in per_basket.items() if v["coverage"] is not None and v["coverage"] < 100 * MIN_BASKET_COVERAGE]
+ failed = sorted(s for s, v in (fetch or {}).items() if not v)
+ status = "STALE" if problems else ("DEGRADED" if weak or failed else "OK")
+ return {"status": status, "expected_close": exp, "btc_last_close": btc_last, "as_of": asof,
+ "alt_clock_coverage": rnd1(100 * alt_cov), "baskets": per_basket, "weak_baskets": weak,
+ "fetch_failed": failed, "funding_last_day": fund_last, "trench_last_day": trench_last,
+ "problems": problems}
+
+
+# ----------------------------------------------------------------- outbox --
+def _outbox():
+ return json.load(open(OUTBOX)) if os.path.exists(OUTBOX) else []
+
+
+def enqueue(events, as_of):
+ """events: [(kind, key, value, text)]. Stable id per transition, so a retry
+ or a rerun of the same close never makes a second message."""
+ import hashlib
+ with outbox_lock():
+ return _enqueue(events, as_of, hashlib)
+
+
+def _enqueue(events, as_of, hashlib):
+ box = _outbox()
+ have = {m["id"] for m in box}
+ added = []
+ for kind, key, value, text in events:
+ mid = hashlib.sha1(("%s|%s|%s|%s" % (as_of, kind, key, value)).encode()).hexdigest()[:12]
+ if mid in have:
+ continue
+ box.append({"id": mid, "as_of": as_of, "created": int(time.time()), "text": text,
+ "status": "pending", "attempts": 0, "last_error": None})
+ added.append(mid)
+ atomic_json(OUTBOX, box)
+ return added
+
+
+def flush(send, dry=False):
+ with outbox_lock():
+ return _flush(send, dry)
+
+
+def _flush(send, dry=False):
+ box = _outbox()
+ for m in box:
+ if m["status"] != "pending":
+ continue
+ if time.time() - m["created"] > DEAD_AFTER_H * 3600:
+ m["status"] = "dead"
+ journal({"event": "alert_dead", "id": m["id"]})
+ continue
+ if dry:
+ print("DRY ALERT %s:\n%s" % (m["id"], m["text"]))
+ continue
+ ok, err = send(m["text"] + "\n<i>ref %s</i>" % m["id"])
+ m["attempts"] += 1
+ if ok:
+ m["status"], m["delivered"] = "delivered", int(time.time())
+ journal({"event": "alert_delivered", "id": m["id"]})
+ else:
+ m["last_error"] = err
+ atomic_json(OUTBOX, box) # after every message, so a crash loses nothing
+ keep = [m for m in box if m["status"] == "pending" or time.time() - m["created"] < 30 * 86400]
+ atomic_json(OUTBOX, keep)
+ return {"pending": sum(m["status"] == "pending" for m in keep), "dead": sum(m["status"] == "dead" for m in keep),
+ "last_delivered": max([m.get("delivered", 0) for m in keep] or [0]) or None}
+
+
+def tg_send(msg):
+ """(ok, error). ok only on Telegram's own ok:true."""
+ 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()
+ r = json.load(urllib.request.urlopen(urllib.request.Request(
+ "https://api.telegram.org/bot%s/sendMessage" % cfg["bot_token"], body,
+ {"content-type": "application/json"}), timeout=25))
+ return bool(r.get("ok")), None if r.get("ok") else str(r.get("description"))[:80]
+ except Exception as e:
+ return False, type(e).__name__ + ": " + str(e)[:60]
+
+
+def journal(rec):
+ rec = dict(rec, ts=int(time.time()))
+ with open(JOURNAL, "a") as f:
+ f.write(json.dumps(rec, separators=(",", ":")) + "\n")
+
+
+# -------------------------------------------------- shadow experiment E1 --
+E1_SPEC = """SHADOW E1, registered 2026-09-28, never traded, never alerted.
+Question: can a small, early alt position catch a rotation like 2023-08..2024-05
+that the book entered 68 days late, without the 2024-26 bleed?
+Rule, evaluated at each close, only while the v2 book is NOT already in alts:
+ ON when all hold: risk on (v2 switch); the 90d alt clock was 20% or lower at
+ some close in the last 120 days (a depressed base, looser than the 5%
+ washout); the 30d alt clock is 50% or higher (breadth turning); the 90d
+ alt clock is 50% or lower (still early).
+ SIZE one third of the book in alts, split equally over alt baskets that are
+ not red; the rest stays where v2 has it. When the v2 bottom confirms,
+ v2 takes over completely.
+ OFF when risk turns off, or the 30d alt clock falls under 40%.
+Numbers are round values fixed before any test (20% = four times the washout
+level, 120 days = twice the washout look-back, one third so a false start costs
+at most a third of an alt drawdown). They may not be changed.
+Lockbox: closes from 2026-09-28. Judged no earlier than 2028-09-30 and only
+after 3 major rotations (45/21/20 reference) have completed inside the lockbox.
+Promote only if, on the lockbox alone: investable net return >= v2, worst drop
+no more than 5 points deeper, missed time in major alt runs lower, wrong-way
+time not higher. Any change to E1 or v2 after looking spends the lockbox."""
+
+
+def e1_rule(M):
+ g, gs = M.book_rule()
+ s = {"on": False}
+
+ def rule(i):
+ base = g(i)
+ in_alts = bucket_of(base) == "ALTS"
+ c30, c90 = M.c30[i], M.c90[i]
+ depressed = any(x is not None and x <= 0.20 for x in M.c90[max(0, i - 120):i + 1])
+ if s["on"] and (not M.risk_on(i) or (c30 is not None and c30 < 0.40)):
+ s["on"] = False
+ elif not s["on"] and not in_alts and M.risk_on(i) and depressed and \
+ c30 is not None and c30 >= 0.50 and c90 is not None and c90 <= 0.50:
+ s["on"] = True
+ if in_alts or not s["on"]:
+ if in_alts:
+ s["on"] = False
+ return base
+ live = [b for b in ALT_BASKETS if M.idx[b][i] and not M.basket_top(b, i)]
+ w = {b: (1 / 3) / len(live) for b in live} if live else {}
+ rest = 1 - sum(w.values())
+ for k, v in base.items():
+ w[k] = w.get(k, 0) + v * rest
+ return w
+ return rule, s
+
+
# ------------------------------------------------------------- the reading --
def holding_word(w):
if not w:
@@ -416,8 +878,18 @@
rule, st_ = M.book_rule()
lo = M.pos.get("2020-06-01", 200)
- curve, log = M.backtest(rule, lo, i)
+ curve, log = M.backtest(rule, lo, i) # synthetic index run: only for the decision path
held = curve[-1][2]
+ covs = {}
+ I = arith_indices(M, covs) # v2.1: what a basket really earns
+
+ def iret(b, n):
+ x = I[b]
+ return (x[i] / x[i - n] - 1) if i - n >= 0 and x[i] and x[i - n] else None
+
+ def ima(b, n=50):
+ w = [v for v in I[b][max(0, i - n + 1):i + 1] if v]
+ return sum(w) / len(w) if len(w) >= int(0.8 * n) else None
want = log[-1][1] if log else {}
# what the rule wants as of today's close (it is traded at tomorrow's close)
@@ -429,11 +901,11 @@
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"
+ r30, rb30 = iret(b, 30), iret("BTC", 30)
+ c["vs_btc_30d"] = rnd1(100 * (r30 - rb30)) if (r30 is not None and rb30 is not None and b != "BTC") else None
+ c["ret_30d"] = rnd1(100 * r30) if r30 is not None else None
+ m = ima(b)
+ c["trend"] = None if not (m and I[b][i]) else ("up" if I[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
@@ -483,7 +955,7 @@
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]
+ x = I[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)
@@ -530,35 +1002,26 @@
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]
+ # ---- evidence (v2.1): basket tops as events AND as distinct market episodes
+ ev = {"basket_top": basket_top_episodes(M, I, lo), "index_coverage": covs}
+
+ # ---- books (v2.1): every period is a slice of ONE continuous run, and the
+ # headline book is the investable coin-level one with fees on every coin trade
+ inv_curve, inv_cost = invest(M, M.book_rule()[0], lo, i)
+ sw_curve, sw_cost = invest(M, lambda k: {"BTC": 1.0} if M.risk_on(k) else {}, lo, i)
+ hb_curve, _ = invest(M, lambda k: {"BTC": 1.0}, lo, i)
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])}
+ for per, a_, z_ in (("holdout", "2020-06-01", "2024-06-30"), ("recent", SPLIT, None), ("full", "2020-06-01", None)):
+ books[per] = {"book": slice_stats(inv_curve, a_, z_), "btc_trend": slice_stats(sw_curve, a_, z_),
+ "hold_btc": slice_stats(hb_curve, a_, z_), "book_synthetic": slice_stats(curve, a_, z_)}
+ nofee, _ = invest(M, M.book_rule()[0], lo, i, fee=0.0)
+ inv_cost["ending_equity_lost_to_fees_pct"] = rnd1(100 * (1 - inv_curve[-1][1] / nofee[-1][1]))
+ books["costs"] = {"book": inv_cost, "btc_trend": sw_cost, "false_switches_14d": false_switches(log),
+ "notes": "turnover is gross two-sided (buys plus sells) per year; fee_drag_pct sums each fee as a share of equity at that trade; ending_equity_lost_to_fees_pct compares final equity with the same run at zero fees"}
# ---- the rotation timeline, every leg the book took
legs, prev = [], None
- eqd = {d: e for d, e, _ in curve}
+ eqd = {d: e for d, e, _ in inv_curve}
for d, w in log:
wd = holding_word(w)
if prev and prev[1] != wd:
@@ -568,32 +1031,32 @@
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)})
+ legs.append({"from": prev[0], "to": None, "held": prev[1], "ret": round(100 * (inv_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])
+ # ---- scoreboard (v2.1): graded on corrected indices and the investable book.
+ # Reference definition 45/21/20, plus the full 27-cell sensitivity grid.
+ ref = grade_runs(M, I, inv_curve, label_runs(M, I, lo, 45, 21, 0.20))
+ rots = ref
+ score = {"pre": grade_summary([x for x in ref if x["period"] == "pre"]),
+ "post": grade_summary([x for x in ref if x["period"] == "post"]),
+ "all": grade_summary(ref), "definition": "best of cash/BTC/alts over 45d, runs under 21d merged, 20%+ edge, caught = held half the run"}
+ grid = []
+ for H in (30, 45, 60):
+ for mg in (14, 21, 30):
+ for ed in (0.15, 0.20, 0.30):
+ g = grade_runs(M, I, inv_curve, label_runs(M, I, lo, H, mg, ed))
+ grid.append({"H": H, "merge": mg, "edge": int(round(ed * 100)),
+ "pre": grade_summary([x for x in g if x["period"] == "pre"]),
+ "post": grade_summary([x for x in g if x["period"] == "post"])})
+ score["grid"] = grid
+
+ t = ev["basket_top"]
+ ev_top = ("%d of %d basket events lagged BTC in 2020 to 2024 (%d of %d distinct episodes), %d of %d since "
+ "(%d of %d episodes)" % (t["pre"]["events_lagged"], t["pre"]["events"], t["pre"]["episodes_majority_lagged"],
+ t["pre"]["episodes"], t["post"]["events_lagged"], t["post"]["events"],
+ t["post"]["episodes_majority_lagged"], t["post"]["episodes"]))
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"])
+ ev_risk = "In-sample, 2020 to 2024, this rule alone cut the worst drop of holding BTC from %s%% to %s%%." % (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))
@@ -605,9 +1068,9 @@
"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],
+ "book": [round(inv_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]}
+ "held": [holding_word(inv_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()},
@@ -621,7 +1084,8 @@
"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}}
+ "decision": {"weights_after_next_close": {b: rnd1(100 * v) for b, v in want.items()}, "fill_close": _add(today, 1)},
+ "settings": {"version": "2.1 (v2 rules frozen)", "lockbox_start": LOCKBOX_START, "risk_ma": RISK_MA, "risk_band": RISK_BAND, "clock_top": CLOCK_TOP, "clock_wash": CLOCK_WASH}}
# ----------------------------------------------------------------- alerts --
@@ -640,63 +1104,105 @@
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."""
+ """v2.1: transitions go to the OUTBOX first; state is saved only after the
+ outbox write, and delivery happens (and is retried) from the outbox."""
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 = []
+ events, tail = [], "\n\n" + R["as_of"] + " close \u00b7 http://159.223.88.168/rotation.html"
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"]))
+ events.append(("call", "book", new["call"], "%s <b>%s</b>\n%s%s" % (icon, new["call"], R["call"]["line"], tail)))
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"]))
+ a_, b_ = old.get("shade", {}).get(c["key"]), c["shade"]
+ if a_ and a_ != b_ and b_ in ("red", "green"):
+ events.append(("shade", c["key"], b_, "%s <b>%s: %s</b>\n%s%s" % (
+ "\U0001F7E5" if b_ == "red" else "\U0001F7E9", c["name"], c["word"], c["why"], tail)))
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")
+ events.append(("trench", "window", t.get("last_impulse"), "\U0001F525 <b>TRENCH WINDOW OPEN</b>\nBTC just did +10%% in a week. "
+ "Launchpad volume was higher two weeks later in 11 of the last 14 cases (in-sample). Window closes %s.%s"
+ % (t.get("window_closes"), tail)))
+ ids = enqueue(events, R["as_of"]) if (events and not dry) else []
+ if dry:
+ for e in events:
+ print("DRY EVENT", e[:3])
if not dry:
- json.dump(new, open(sp, "w"))
+ atomic_json(sp, new)
+ return ids
# ------------------------------------------------------------------- main --
+def trench_last_day():
+ try:
+ import sqlite3
+ return sqlite3.connect(TRENCH_DB).execute("select max(date) from d where btc is not null").fetchone()[0]
+ except Exception:
+ return None
+
+
def main():
- full = "--full" in sys.argv
- dry = "--dry" in sys.argv
+ full, dry = "--full" in sys.argv, "--dry" in sys.argv
+ if "--flush" in sys.argv: # cron every 10 min: deliver what is pending, nothing else
+ print(flush(tg_send, dry))
+ return
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 = []
+ dead, fetch = [], {}
else:
- px, fd, dead = refresh(full)
+ px, fd, dead, fetch = refresh(full)
M = Model(px, fd)
- R = read(M)
+ H = health(M, px, fd, fetch, trench_last_day(), now=None if "--offline" not in sys.argv else
+ dt.datetime.fromisoformat(max(px["BTC"])) + dt.timedelta(days=1, hours=1))
+ now = int(time.time())
+ if H["status"] == "STALE":
+ # never publish a fresh-looking call on stale inputs: re-show the last valid one
+ R = json.load(open(LAST_VALID)) if os.path.exists(LAST_VALID) else {"call": {"word": "NO CALL", "line": ""}}
+ R["stale"] = True
+ ids = []
+ else:
+ R = read(M)
+ if H["status"] == "DEGRADED":
+ for c in R["baskets"]:
+ if c["key"] in H["weak_baskets"]:
+ c["shade"], c["word"], c["why"] = "grey", "NO READ", "Too few member closes today to read this basket."
+ tl = H.get("trench_last_day")
+ if R.get("trench") and (not tl or tl < _add(H["expected_close"], -2)):
+ R["trench"] = {"error": "trench data stale (last day %s)" % tl}
+ R["stale"] = False
+ R["generated"] = now
+ R["generated_bkk"] = dt.datetime.utcfromtimestamp(now + BKK).strftime("%Y-%m-%d %H:%M")
+ ids = alerts(R, dry)
+ if not dry:
+ atomic_json(LAST_VALID, R)
+ # shadow E1: logged only, never traded, never alerted
+ er, es = e1_rule(M)
+ for k in range(M.pos.get("2020-06-01", 200), M.N):
+ ew = er(k)
+ with open(SHADOW_LOG, "a") as f:
+ f.write(json.dumps({"as_of": R["as_of"], "e1_on": es["on"], "weights": {k: rnd1(100 * v) for k, v in ew.items()},
+ "v2_weights": R["decision"]["weights_after_next_close"], "ts": now}) + "\n")
+ R["shadow_e1"] = {"on": es["on"], "weights": {k: rnd1(100 * v) for k, v in ew.items()}}
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)
+ R["health"] = H
+ R["alerts"] = flush(tg_send, dry) if not dry else {"pending": None}
+ R["checked"] = now
+ R["checked_bkk"] = dt.datetime.utcfromtimestamp(now + BKK).strftime("%Y-%m-%d %H:%M")
+ if not dry:
+ journal({"event": "run", "as_of": R.get("as_of"), "input_cutoff": H["btc_last_close"], "status": H["status"],
+ "coverage": H["alt_clock_coverage"], "problems": H["problems"], "call": R["call"]["word"],
+ "published_new_decision": not R["stale"], "risk_on": R.get("risk", {}).get("on"),
+ "clock": R.get("clock"), "dropped": R.get("dropped"), "decision": R.get("decision"), "alerts_enqueued": ids})
+ atomic_json(PUB_JSON, R)
+ print("%s %s status %s risk %s clock90 %s clock30 %s" % (
+ R["checked_bkk"], R["call"]["word"], H["status"], R.get("risk", {}).get("on"),
+ (R.get("clock") or {}).get("c90"), (R.get("clock") or {}).get("c30")))
+ if H["problems"] or dead:
+ print("DATA PROBLEMS:", H["problems"], dead)
if __name__ == "__main__":
rotation.html (v2.1 candidate)
<!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:#c0c7d2;--faint:#a3acb9;
--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:13px;color:var(--dim);margin:6px 0 8px;min-height:3.4em}
.bk ul{list-style:none;margin:0;padding:0;font-size:12.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:14px;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}.g0{background:rgba(108,182,255,.16);color:#a9d1ff}.unk{color:var(--faint)}
.dq{font-family:var(--mono);font-size:12.5px;color:var(--dim);background:var(--panel2);border:1px solid var(--rule);border-radius:9px;padding:9px 12px;margin-bottom:14px}
.dq.bad{background:#3a1f22;border-color:var(--dn);color:#ffd9dc}.dq.warn{background:#3a2f1a;border-color:var(--accent);color:#ffe7c2}
.stalecall .word{color:var(--faint)!important;text-decoration:line-through}
details summary{cursor:pointer;color:var(--dim);margin-top:10px}.sr{font-size:13px;color:var(--dim);margin-top:6px}.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){table{display:block;overflow-x:auto}.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="dq" id="dq" role="status">checking data</div>
<div class="card" id="callcard"><h2>Today's call</h2>
<div class="word" id="word">LOADING</div><div class="line" id="line"></div>
<div class="hold" id="hold"></div></div>
<div class="two">
<div class="card gauge"><h2>1 · Risk switch</h2>
<div class="big" id="rbig">—</div><div class="sub" id="rsub"></div>
<div class="risk"><i id="rneedle" style="position:absolute;top:-5px;width:3px;height:24px;border-radius:2px;background:var(--ink)"></i>
<span style="position:absolute;left:50%;top:0;bottom:0;width:1px;background:var(--faint)"></span></div>
<div class="scale"><span>-20%</span><span id="rma">100 day average</span><span>+20%</span></div>
<div class="note" style="margin-top:10px">On above the average by 1%, off below it by 1%. Off means cash.</div></div>
<div class="card gauge"><h2>2 · Alt clock</h2>
<div class="big" id="cbig">—</div><div class="sub" id="csub"></div>
<div class="bar"><i id="c90"></i><i id="c30" class="b"></i></div>
<div class="scale"><span>0 washed</span><span>share of alts beating BTC</span><span>top zone 100</span></div>
<div class="note" style="margin-top:10px"><span style="color:var(--ink)">▮</span> 90 day <span style="color:var(--accent)">▮</span> 30 day.
Green end: alts washed out, the next rotation's bottom zone. Red end: the alt rotation is topping.</div></div>
</div>
<div class="card"><h2>3 · The rotation strip</h2><div class="strip" id="strip"></div>
<div class="note" style="margin-top:12px"><b>Red</b> top confirmed · <b>light red</b> extended, stop adding ·
<b>green</b> bottom confirmed, part of the next rotation · <b>light green</b> washed out, waiting for the turn ·
grey nothing crossed. Orange % = weight in the book from the next close.</div></div>
<div class="card"><h2>4 · Every rotation since 2020</h2>
<canvas class="big" id="map" style="height:300px" role="img" aria-label="Alt clock since June 2020 with the book's holding underneath"></canvas>
<p class="sr" id="mapsum"></p>
<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" role="img" aria-label="Book equity against holding BTC, log scale"></canvas>
<p class="sr" id="eqsum"></p>
<div class="leg"><span><i style="background:#3ecf8e"></i>the book</span><span><i style="background:#f0a83c"></i>holding BTC</span><span>log scale, from Jun 2020</span></div></div>
<div class="card"><h2>5 · Did it catch the big rotations?</h2>
<div class="line" id="scoreline" style="margin-bottom:6px"></div>
<div class="note" id="scorenote" 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 class="n">Edge captured</th><th class="n">Return missed</th><th class="n">Wrong-way days</th><th>Half-duration rule</th></tr></thead><tbody id="rots"></tbody></table></div>
<details><summary>Sensitivity: 27 definitions of a major rotation (descriptive, never used to tune)</summary>
<div style="overflow-x:auto"><table><thead><tr><th>Horizon</th><th>Merge</th><th>Edge</th><th class="n">Through Jun 2024</th><th class="n">Since Jul 2024</th><th class="n">Median late</th><th class="n">Worst late</th><th class="n">Missed time</th><th class="n">Wrong way</th></tr></thead><tbody id="grid"></tbody></table></div></details>
<div class="note" style="margin-top:10px">Labels use hindsight: each day gets the best of cash, BTC or alts over the next 45 days, stretches under 21 days are merged, and a stretch is major when the winner beat the next best by 20% or more. Hindsight grades the system; no rule reads it. The half-duration rule is one descriptive score. Edge captured credits partial positions: 100 means the book matched the winner, 0 means it did as badly as the worst place.</div></div>
<div class="card"><h2>6 · Track record: coin-level book, next-close fills, 0.3% on every coin trade</h2>
<table><thead><tr><th></th><th class="n">2020-06 to 2024-06 (in-sample)</th><th class="n">max drop</th><th class="n">2024-07 to now (in-sample)</th><th class="n">max drop</th></tr></thead>
<tbody id="rec"></tbody></table>
<div class="note" style="margin-top:10px" id="recnote"></div></div>
<div class="card"><h2>7 · What each colour is built on</h2>
<table><thead><tr><th>Signal</th><th>Rule</th><th>Evidence</th><th>Grade</th></tr></thead><tbody id="ev"></tbody></table></div>
<div class="card"><h2>8 · The last rotations the book took</h2>
<table><thead><tr><th>From</th><th>To</th><th>Held</th><th class="n">Leg</th></tr></thead><tbody id="legs"></tbody></table></div>
<footer>updated <span id="ft">—</span> Bangkok · alerts <span id="al">—</span> · shadow E1 <span id="e1">—</span> · closed daily candles only · alerts on Telegram when a colour or the call changes ·
<a href="/rotmap.json">raw numbers</a> · <a href="/rotation_trench.html">trench detail</a></footer>
</div>
<script>
var C={red:'#f2555a',green:'#3ecf8e',accent:'#f0a83c',ink:'#e7eaf0',faint:'#6b7483',rule:'#20262f'};
function pct(v,d){return v==null?'—':(v>0?'+':'')+Number(v).toFixed(1)+'%';} // one rule: 1 decimal, Python rounds first
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+' · decided '+(R.generated_bkk||'?')+' · checked '+(R.checked_bkk||'?')+' Bangkok';
var Hh=R.health||{},dq=el('dq'),old=[Hh.btc_last_close,Hh.funding_last_day,Hh.trench_last_day].filter(Boolean).sort()[0];
dq.innerHTML='data <b>'+(Hh.status||'unknown')+'</b> · last BTC close '+(Hh.btc_last_close||'?')+' (expected '+(Hh.expected_close||'?')+') · oldest source '+(old||'?')+
' · alt clock coverage '+(Hh.alt_clock_coverage!=null?Hh.alt_clock_coverage+'%':'?')+(Hh.weak_baskets&&Hh.weak_baskets.length?' · thin: '+Hh.weak_baskets.join(', '):'')+
(Hh.fetch_failed&&Hh.fetch_failed.length?' · fetch failed: '+Hh.fetch_failed.join(', '):'')+(Hh.problems&&Hh.problems.length?' · '+Hh.problems.join('; '):'');
dq.className='dq'+(Hh.status==='STALE'?' bad':(Hh.status==='DEGRADED'?' warn':''));
if(R.stale){el('callcard').className='card stalecall';}
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.stale?'NO NEW CALL: inputs are stale. This is the last valid call, decided '+R.generated_bkk+' Bangkok. ':'')+R.call.line;
function hold(o){var k=Object.keys(o);return k.length?k.map(function(b){return b+' '+o[b]+'%';}).join(' · '):'cash';}
el('hold').innerHTML='holding now <b>'+hold(R.held_now)+'</b> → from the next close <b>'+hold(R.want)+'</b>';
// risk
var rk=R.risk; el('rbig').textContent=(rk.on?'ON':'OFF')+' '+pct(rk.dist,1); el('rbig').style.color=rk.on?C.green:C.red;
el('rsub').textContent='BTC vs its '+(rk.ma_days||100)+' day average · switched '+(rk.on?'on':'off')+' '+rk.since; el('rma').textContent=(rk.ma_days||100)+' day average';
var p=Math.max(0,Math.min(100,50+(rk.dist||0)*2.5)); el('rneedle').style.left='calc('+p+'% - 1px)';
// clock
var ck=R.clock; el('cbig').textContent=(ck.c90==null?'—':ck.c90+'%')+' · 30d '+(ck.c30==null?'—':ck.c30+'%');
el('csub').textContent=ck.bottom?'BOTTOM CONFIRMED: alts washed out and turned':ck.washed_recent?'Washed out in the last 60 days, waiting for the turn':
ck.top_warning?'Top warning: was 70%+ and the fast clock has rolled':(ck.c90>=70?'In the top zone':'Normal range. '+ck.n_alts+' alts counted');
if(ck.c90==null){el('c90').style.display='none';}else{el('c90').style.left='calc('+ck.c90+'% - 1px)';} if(ck.c30==null){el('c30').style.display='none';}else{el('c30').style.left='calc('+ck.c30+'% - 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(b.trend?'trend <b>'+b.trend+'</b>':'trend <b class="unk">unknown</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);
var S_=R.series,nb=S_.book.length;el('mapsum').textContent='Latest 90 day alt clock '+(R.clock.c90==null?'unknown':R.clock.c90+'%')+'. The book held alts in '+S_.held.filter(function(h){return h==='ALTS';}).length+' of '+nb+' weeks, BTC in '+S_.held.filter(function(h){return h==='BTC';}).length+', cash in '+S_.held.filter(function(h){return h==='CASH';}).length+'.';
el('eqsum').textContent='Since June 2020 the book grew '+S_.book[nb-1].toFixed(1)+'x against '+S_.btc[nb-1].toFixed(1)+'x for holding BTC (in-sample, coin-level, after fees).';
// scoreboard
var sc=R.score||{},pre=sc.pre||{},post=sc.post||{},all=sc.all||{};
var wd=Math.max(pre.worst_delay||0,post.worst_delay||0);
el('scoreline').innerHTML='<b>'+all.caught+'/'+all.n+'</b> by the half-duration rule · <b>'+pre.caught+'/'+pre.n+'</b> through Jun 2024 · <b>'+post.caught+'/'+post.n+'</b> since · worst delay <b>'+wd+'d</b>';
el('scorenote').innerHTML='Median edge captured '+(all.median_capture_pct!=null?all.median_capture_pct+'%':'?')+' · time outside the winning place '+all.missed_time_pct+'% · time in the worst place '+all.wrong_way_pct+'%. Both periods are in-sample for v2. Nothing here is out-of-sample until the lockbox that starts '+((R.settings||{}).lockbox_start||'')+'.';
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">'+pct(r.edge)+'</td><td class="n">'+r.delay+'</td><td class="n">'+r.held.toFixed(1)+'%</td><td class="n">'+(r.captured_pct==null?'—':r.captured_pct.toFixed(1)+'%')+'</td><td class="n">'+pct(r.missed_return)+'</td><td class="n">'+r.wrong_way_days+'</td><td class="'+(r.caught?'pos':'neg')+'">'+(r.caught?'held half or more':'MISSED')+'</td></tr>';}).join('');
el('grid').innerHTML=(sc.grid||[]).map(function(g){var f=function(x){return x.n?x.caught+'/'+x.n:'0/0';};return '<tr><td>'+g.H+'d</td><td>'+g.merge+'d</td><td>'+g.edge+'%</td><td class="n">'+f(g.pre)+'</td><td class="n">'+f(g.post)+'</td><td class="n">'+(g.pre.median_delay!=null?g.pre.median_delay:'—')+'</td><td class="n">'+Math.max(g.pre.worst_delay||0,g.post.worst_delay||0)+'</td><td class="n">'+(g.pre.missed_time_pct!=null?g.pre.missed_time_pct+'%':'—')+'</td><td class="n">'+(g.pre.wrong_way_pct!=null?g.pre.wrong_way_pct+'%':'—')+'</td></tr>';}).join('');
var AL=R.alerts||{};el('al').textContent=(AL.pending==null?'n/a':AL.pending+' pending, '+AL.dead+' dead');
var E1=R.shadow_e1;el('e1').textContent=E1?(E1.on?'would hold a third in alts (not traded)':'off (not traded)'):'n/a';
// record
var B=R.books, rows=[['The book (all three layers)','book'],['Same book on the old synthetic index','book_synthetic'],['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('');
var Cc=B.costs||{};
el('recnote').innerHTML='<b>Read this honestly.</b> Every number is in-sample: v1 was designed on 2024-07 onward, and v2 changed the risk average after looking at both periods. The coin-level book buys every member of today’s basket lists that is trading on the fill day (point-in-time eligibility inside a survivor list, not historical membership) at the next close, lets weights drift, and pays 0.3% on every coin trade: '+
((Cc.book||{}).turnover_per_year)+'x gross two-sided turnover a year (buys plus sells), a summed fee load of '+((Cc.book||{}).fee_drag_pct)+'% (each fee as a share of equity at that trade), ending equity '+((Cc.book||{}).ending_equity_lost_to_fees_pct)+'% below the same book at zero fees, '+Cc.false_switches_14d+' round trips reversed within 14 days. At 1% per trade the 2020 to 2024 figure falls to about +5790%. '+
'It earns more than the old synthetic index because that index averaged log returns (a geometric mean, not a portfolio) and clipped days at 50%. Baskets are today’s surviving coins, which flatters 2020 to 2021; that bias is not removed.';
var e=R.evidence, g=function(a){return a[0]+' of '+a[1];};
var t=(e.basket_top||{}),tp=t.pre||{},tq=t.post||{};
el('ev').innerHTML=[
['Risk switch','BTC over its 100 day average, 1% band','Worst drop of holding BTC '+pct(B.holdout.hold_btc.maxdd)+' vs '+pct(B.holdout.btc_trend.maxdd)+' with the switch (2020 to 2024), '+pct(B.recent.hold_btc.maxdd)+' vs '+pct(B.recent.btc_trend.maxdd)+' since. 100 days was picked from about 35 variants after seeing both periods; the 1% band was kept from v1, not chosen as best (3% scored higher on return)','<span class="grade g2">in-sample rule evidence</span>'],
['Risk switch neighbourhood','100 days with 0/1/3% bands, 75 to 150 days','100 (any band) and 150 beat 50 on return and drop in both periods; 75 and 125 did not in one. Selection-instability check (CSCV) over the 69 variants looked at: the in-sample winner fell below the out-of-sample median in 31% of splits on the old index and 50% on coin-level returns, so the exact setting is not supported; the in-sample winner still beat holding BTC in 73 to 81% of splits','<span class="grade g0">robustness check</span>'],
['Basket top, red','70%+ of members beat BTC over 90d, then under 50% over 30d','Lagged BTC over 60 days: '+tp.events_lagged+' of '+tp.events+' events, '+tp.episodes_majority_lagged+' of '+tp.episodes+' distinct episodes (2020 to 2024); '+tq.events_lagged+' of '+tq.events+' events, '+tq.episodes_majority_lagged+' of '+tq.episodes+' episodes since','<span class="grade g2">in-sample rule evidence</span>'],
['Alt bottom, green','alt clock 5% or less inside 60 days, then 30d clock 40%+, risk on','Acts as a safety filter: plain alts-whenever-BTC-trends made about the same in 2020 to 2024 and lost money since 2024. Entered the 2023 to 2024 alt run 68 days late','<span class="grade g2">in-sample rule evidence</span>'],
['Alt top warning','alt clock 70%+ inside 30 days, fast clock rolled','As an automatic exit it cut 2020 to 2024 roughly in half. Shown only','<span class="grade g3">context only</span>'],
['Crowding','funding in its own top fifth','Crowded baskets did better, not worse, over the next 30 days. A number only','<span class="grade g3">context only</span>'],
['Trench window','BTC +10% in a week','Launchpad volume up 14 days later 11 of 14 (2024 on). Weak on meme prices','<span class="grade g3">context only</span>'],
['Shadow E1','small early alt entry on a turning breadth','Registered 2026-09-28 with a hash, never traded or alerted. One descriptive pass: it would not have fixed 2023 to 2024. Judged only on the lockbox','<span class="grade g3">not tested live</span>'],
['Alts vs BTC trend, breadth gates, stablecoins, basket washout','various','None beat the book in both periods','<span class="grade g3">rejected (in-sample)</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,100,200,500].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>
rotation_v2_to_v21.diff
--- /opt/launchwatch/pub/rotation.html 2026-09-28 12:13:18.767840794 +0000
+++ rotation.html 2026-09-28 14:40:23.598631648 +0000
@@ -3,7 +3,7 @@
<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;
+:root{--bg:#0e1116;--panel:#151a21;--panel2:#1b212a;--rule:#252c37;--ink:#e7eaf0;--dim:#c0c7d2;--faint:#a3acb9;
--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}
@@ -37,8 +37,8 @@
.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 .why{font-size:13px;color:var(--dim);margin:6px 0 8px;min-height:3.4em}
+.bk ul{list-style:none;margin:0;padding:0;font-size:12.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)}
@@ -54,20 +54,25 @@
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)}
+.note{font-size:14px;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)}
+.g1{background:rgba(62,207,142,.18);color:#8ef0c0}.g0{background:rgba(108,182,255,.16);color:#a9d1ff}.unk{color:var(--faint)}
+.dq{font-family:var(--mono);font-size:12.5px;color:var(--dim);background:var(--panel2);border:1px solid var(--rule);border-radius:9px;padding:9px 12px;margin-bottom:14px}
+.dq.bad{background:#3a1f22;border-color:var(--dn);color:#ffd9dc}.dq.warn{background:#3a2f1a;border-color:var(--accent);color:#ffe7c2}
+.stalecall .word{color:var(--faint)!important;text-decoration:line-through}
+details summary{cursor:pointer;color:var(--dim);margin-top:10px}.sr{font-size:13px;color:var(--dim);margin-top:6px}.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}}
+@media(max-width:520px){table{display:block;overflow-x:auto}.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="dq" id="dq" role="status">checking data</div>
-<div class="card"><h2>Today's call</h2>
+<div class="card" id="callcard"><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>
@@ -92,20 +97,25 @@
grey nothing crossed. Orange % = weight in the book from the next close.</div></div>
<div class="card"><h2>4 · Every rotation since 2020</h2>
- <canvas class="big" id="map" style="height:300px"></canvas>
+ <canvas class="big" id="map" style="height:300px" role="img" aria-label="Alt clock since June 2020 with the book's holding underneath"></canvas>
+ <p class="sr" id="mapsum"></p>
<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>
+ <canvas class="big" id="eq" style="height:230px;margin-top:14px" role="img" aria-label="Book equity against holding BTC, log scale"></canvas>
+ <p class="sr" id="eqsum"></p>
<div class="leg"><span><i style="background:#3ecf8e"></i>the book</span><span><i style="background:#f0a83c"></i>holding BTC</span><span>log scale, from Jun 2020</span></div></div>
<div class="card"><h2>5 · Did it catch the big rotations?</h2>
- <div class="line" id="scoreline" style="margin-bottom:10px"></div>
- <div style="overflow-x:auto"><table><thead><tr><th>Rotation</th><th>Best place</th><th class="n">Won by</th><th class="n">Days late</th><th class="n">Time in it</th><th>Caught</th></tr></thead><tbody id="rots"></tbody></table></div>
- <div class="note" style="margin-top:10px">A major rotation is a stretch where cash, BTC or alts beat the next best place by 20% or more, labelled with hindsight (best place over the next 45 days, stretches under 21 days merged). Hindsight is only used to grade the system, never inside it. Caught means the book held that place for half the stretch or more.</div></div>
+ <div class="line" id="scoreline" style="margin-bottom:6px"></div>
+ <div class="note" id="scorenote" 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 class="n">Edge captured</th><th class="n">Return missed</th><th class="n">Wrong-way days</th><th>Half-duration rule</th></tr></thead><tbody id="rots"></tbody></table></div>
+ <details><summary>Sensitivity: 27 definitions of a major rotation (descriptive, never used to tune)</summary>
+ <div style="overflow-x:auto"><table><thead><tr><th>Horizon</th><th>Merge</th><th>Edge</th><th class="n">Through Jun 2024</th><th class="n">Since Jul 2024</th><th class="n">Median late</th><th class="n">Worst late</th><th class="n">Missed time</th><th class="n">Wrong way</th></tr></thead><tbody id="grid"></tbody></table></div></details>
+ <div class="note" style="margin-top:10px">Labels use hindsight: each day gets the best of cash, BTC or alts over the next 45 days, stretches under 21 days are merged, and a stretch is major when the winner beat the next best by 20% or more. Hindsight grades the system; no rule reads it. The half-duration rule is one descriptive score. Edge captured credits partial positions: 100 means the book matched the winner, 0 means it did as badly as the worst place.</div></div>
-<div class="card"><h2>6 · Track record, next-day execution, 0.3% per trade</h2>
- <table><thead><tr><th></th><th class="n">2020-06 to 2024-06</th><th class="n">max drop</th><th class="n">Recent 2024-07 to now</th><th class="n">max drop</th></tr></thead>
+<div class="card"><h2>6 · Track record: coin-level book, next-close fills, 0.3% on every coin trade</h2>
+ <table><thead><tr><th></th><th class="n">2020-06 to 2024-06 (in-sample)</th><th class="n">max drop</th><th class="n">2024-07 to now (in-sample)</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>
@@ -115,23 +125,29 @@
<div class="card"><h2>8 · The last rotations the book took</h2>
<table><thead><tr><th>From</th><th>To</th><th>Held</th><th class="n">Leg</th></tr></thead><tbody id="legs"></tbody></table></div>
-<footer>updated <span id="ft">—</span> Bangkok · closed daily candles only · alerts on Telegram when a colour or the call changes ·
+<footer>updated <span id="ft">—</span> Bangkok · alerts <span id="al">—</span> · shadow E1 <span id="e1">—</span> · closed daily candles only · alerts on Telegram when a colour or the call changes ·
<a href="/rotmap.json">raw numbers</a> · <a href="/rotation_trench.html">trench detail</a></footer>
</div>
<script>
var C={red:'#f2555a',green:'#3ecf8e',accent:'#f0a83c',ink:'#e7eaf0',faint:'#6b7483',rule:'#20262f'};
-function pct(v,d){return v==null?'—':(v>0?'+':'')+v.toFixed(d==null?0:d)+'%';}
+function pct(v,d){return v==null?'—':(v>0?'+':'')+Number(v).toFixed(1)+'%';} // one rule: 1 decimal, Python rounds first
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('stamp').textContent='close of '+R.as_of+' · decided '+(R.generated_bkk||'?')+' · checked '+(R.checked_bkk||'?')+' Bangkok';
+ var Hh=R.health||{},dq=el('dq'),old=[Hh.btc_last_close,Hh.funding_last_day,Hh.trench_last_day].filter(Boolean).sort()[0];
+ dq.innerHTML='data <b>'+(Hh.status||'unknown')+'</b> · last BTC close '+(Hh.btc_last_close||'?')+' (expected '+(Hh.expected_close||'?')+') · oldest source '+(old||'?')+
+ ' · alt clock coverage '+(Hh.alt_clock_coverage!=null?Hh.alt_clock_coverage+'%':'?')+(Hh.weak_baskets&&Hh.weak_baskets.length?' · thin: '+Hh.weak_baskets.join(', '):'')+
+ (Hh.fetch_failed&&Hh.fetch_failed.length?' · fetch failed: '+Hh.fetch_failed.join(', '):'')+(Hh.problems&&Hh.problems.length?' · '+Hh.problems.join('; '):'');
+ dq.className='dq'+(Hh.status==='STALE'?' bad':(Hh.status==='DEGRADED'?' warn':''));
+ if(R.stale){el('callcard').className='card stalecall';}
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;
+ el('line').textContent=(R.stale?'NO NEW CALL: inputs are stale. This is the last valid call, decided '+R.generated_bkk+' Bangkok. ':'')+R.call.line;
function hold(o){var k=Object.keys(o);return k.length?k.map(function(b){return b+' '+o[b]+'%';}).join(' · '):'cash';}
el('hold').innerHTML='holding now <b>'+hold(R.held_now)+'</b> → from the next close <b>'+hold(R.want)+'</b>';
// risk
@@ -142,7 +158,7 @@
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)';
+ if(ck.c90==null){el('c90').style.display='none';}else{el('c90').style.left='calc('+ck.c90+'% - 1px)';} if(ck.c30==null){el('c30').style.display='none';}else{el('c30').style.left='calc('+ck.c30+'% - 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 '';
@@ -151,7 +167,7 @@
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>');
+ nums.push(b.trend?'trend <b>'+b.trend+'</b>':'trend <b class="unk">unknown</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';
@@ -165,29 +181,38 @@
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);
+ var S_=R.series,nb=S_.book.length;el('mapsum').textContent='Latest 90 day alt clock '+(R.clock.c90==null?'unknown':R.clock.c90+'%')+'. The book held alts in '+S_.held.filter(function(h){return h==='ALTS';}).length+' of '+nb+' weeks, BTC in '+S_.held.filter(function(h){return h==='BTC';}).length+', cash in '+S_.held.filter(function(h){return h==='CASH';}).length+'.';
+ el('eqsum').textContent='Since June 2020 the book grew '+S_.book[nb-1].toFixed(1)+'x against '+S_.btc[nb-1].toFixed(1)+'x for holding BTC (in-sample, coin-level, after fees).';
// 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 sc=R.score||{},pre=sc.pre||{},post=sc.post||{},all=sc.all||{};
+ var wd=Math.max(pre.worst_delay||0,post.worst_delay||0);
+ el('scoreline').innerHTML='<b>'+all.caught+'/'+all.n+'</b> by the half-duration rule · <b>'+pre.caught+'/'+pre.n+'</b> through Jun 2024 · <b>'+post.caught+'/'+post.n+'</b> since · worst delay <b>'+wd+'d</b>';
+ el('scorenote').innerHTML='Median edge captured '+(all.median_capture_pct!=null?all.median_capture_pct+'%':'?')+' · time outside the winning place '+all.missed_time_pct+'% · time in the worst place '+all.wrong_way_pct+'%. Both periods are in-sample for v2. Nothing here is out-of-sample until the lockbox that starts '+((R.settings||{}).lockbox_start||'')+'.';
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('');
+ 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">'+pct(r.edge)+'</td><td class="n">'+r.delay+'</td><td class="n">'+r.held.toFixed(1)+'%</td><td class="n">'+(r.captured_pct==null?'—':r.captured_pct.toFixed(1)+'%')+'</td><td class="n">'+pct(r.missed_return)+'</td><td class="n">'+r.wrong_way_days+'</td><td class="'+(r.caught?'pos':'neg')+'">'+(r.caught?'held half or more':'MISSED')+'</td></tr>';}).join('');
+ el('grid').innerHTML=(sc.grid||[]).map(function(g){var f=function(x){return x.n?x.caught+'/'+x.n:'0/0';};return '<tr><td>'+g.H+'d</td><td>'+g.merge+'d</td><td>'+g.edge+'%</td><td class="n">'+f(g.pre)+'</td><td class="n">'+f(g.post)+'</td><td class="n">'+(g.pre.median_delay!=null?g.pre.median_delay:'—')+'</td><td class="n">'+Math.max(g.pre.worst_delay||0,g.post.worst_delay||0)+'</td><td class="n">'+(g.pre.missed_time_pct!=null?g.pre.missed_time_pct+'%':'—')+'</td><td class="n">'+(g.pre.wrong_way_pct!=null?g.pre.wrong_way_pct+'%':'—')+'</td></tr>';}).join('');
+ var AL=R.alerts||{};el('al').textContent=(AL.pending==null?'n/a':AL.pending+' pending, '+AL.dead+' dead');
+ var E1=R.shadow_e1;el('e1').textContent=E1?(E1.on?'would hold a third in alts (not traded)':'off (not traded)'):'n/a';
// 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']];
+ var B=R.books, rows=[['The book (all three layers)','book'],['Same book on the old synthetic index','book_synthetic'],['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 Cc=B.costs||{};
+ el('recnote').innerHTML='<b>Read this honestly.</b> Every number is in-sample: v1 was designed on 2024-07 onward, and v2 changed the risk average after looking at both periods. The coin-level book buys every member of today’s basket lists that is trading on the fill day (point-in-time eligibility inside a survivor list, not historical membership) at the next close, lets weights drift, and pays 0.3% on every coin trade: '+
+ ((Cc.book||{}).turnover_per_year)+'x gross two-sided turnover a year (buys plus sells), a summed fee load of '+((Cc.book||{}).fee_drag_pct)+'% (each fee as a share of equity at that trade), ending equity '+((Cc.book||{}).ending_equity_lost_to_fees_pct)+'% below the same book at zero fees, '+Cc.false_switches_14d+' round trips reversed within 14 days. At 1% per trade the 2020 to 2024 figure falls to about +5790%. '+
+ 'It earns more than the old synthetic index because that index averaged log returns (a geometric mean, not a portfolio) and clipped days at 50%. Baskets are today’s surviving coins, which flatters 2020 to 2021; that bias is not removed.';
var e=R.evidence, g=function(a){return a[0]+' of '+a[1];};
+ var t=(e.basket_top||{}),tp=t.pre||{},tq=t.post||{};
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>']
+ ['Risk switch','BTC over its 100 day average, 1% band','Worst drop of holding BTC '+pct(B.holdout.hold_btc.maxdd)+' vs '+pct(B.holdout.btc_trend.maxdd)+' with the switch (2020 to 2024), '+pct(B.recent.hold_btc.maxdd)+' vs '+pct(B.recent.btc_trend.maxdd)+' since. 100 days was picked from about 35 variants after seeing both periods; the 1% band was kept from v1, not chosen as best (3% scored higher on return)','<span class="grade g2">in-sample rule evidence</span>'],
+ ['Risk switch neighbourhood','100 days with 0/1/3% bands, 75 to 150 days','100 (any band) and 150 beat 50 on return and drop in both periods; 75 and 125 did not in one. Selection-instability check (CSCV) over the 69 variants looked at: the in-sample winner fell below the out-of-sample median in 31% of splits on the old index and 50% on coin-level returns, so the exact setting is not supported; the in-sample winner still beat holding BTC in 73 to 81% of splits','<span class="grade g0">robustness check</span>'],
+ ['Basket top, red','70%+ of members beat BTC over 90d, then under 50% over 30d','Lagged BTC over 60 days: '+tp.events_lagged+' of '+tp.events+' events, '+tp.episodes_majority_lagged+' of '+tp.episodes+' distinct episodes (2020 to 2024); '+tq.events_lagged+' of '+tq.events+' events, '+tq.episodes_majority_lagged+' of '+tq.episodes+' episodes since','<span class="grade g2">in-sample rule evidence</span>'],
+ ['Alt bottom, green','alt clock 5% or less inside 60 days, then 30d clock 40%+, risk on','Acts as a safety filter: plain alts-whenever-BTC-trends made about the same in 2020 to 2024 and lost money since 2024. Entered the 2023 to 2024 alt run 68 days late','<span class="grade g2">in-sample rule evidence</span>'],
+ ['Alt top warning','alt clock 70%+ inside 30 days, fast clock rolled','As an automatic exit it cut 2020 to 2024 roughly in half. Shown only','<span class="grade g3">context only</span>'],
+ ['Crowding','funding in its own top fifth','Crowded baskets did better, not worse, over the next 30 days. A number only','<span class="grade g3">context only</span>'],
+ ['Trench window','BTC +10% in a week','Launchpad volume up 14 days later 11 of 14 (2024 on). Weak on meme prices','<span class="grade g3">context only</span>'],
+ ['Shadow E1','small early alt entry on a turning breadth','Registered 2026-09-28 with a hash, never traded or alerted. One descriptive pass: it would not have fixed 2023 to 2024. Judged only on the lockbox','<span class="grade g3">not tested live</span>'],
+ ['Alts vs BTC trend, breadth gates, stablecoins, basket washout','various','None beat the book in both periods','<span class="grade g3">rejected (in-sample)</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('');
});
@@ -204,7 +229,7 @@
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);});
+ [1,2,5,10,20,50,100,200,500].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>
test_v21.py
"""Tests for v2.1. Run: python3 test_v21.py (offline, uses the local store)."""
import json, os, sys, copy, time, importlib, tempfile, shutil
HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE)
import rotmap_v2_frozen as F
import rotmap as V
PX = json.load(open(HERE + "/store/px.json")); FD = json.load(open(HERE + "/store/fund.json"))
ok = lambda name: print("PASS", name)
# 1. v2.1 changes no decision: identical book weights on every day
Mf, Mv = F.Model(PX, FD), V.Model(PX, FD)
rf, rv = Mf.book_rule()[0], Mv.book_rule()[0]
lo = Mf.pos["2020-06-01"]
for k in range(lo, Mf.N):
assert rf(k) == rv(k), Mf.days[k]
ok("signal equivalence with frozen v2 on %d closes" % (Mf.N - lo))
# 2. continuity: a warm-started recent run equals the slice of the continuous run
M = Mv; sp = M.pos[V.SPLIT]; N = M.N - 1
full, _ = V.invest(M, M.book_rule()[0], lo, N)
warm, _ = V.invest(M, M.book_rule()[0], sp, N, warm_from=lo)
a, b = V.slice_stats(full, V.SPLIT), V.slice_stats(warm, V.SPLIT)
assert a == b, (a, b)
cold, _ = V.invest(M, M.book_rule()[0], sp, N)
ok("continuity: warm %s == slice %s (cold start would give %s)" % (b, a, V.slice_stats(cold, V.SPLIT)))
# and inside an alt leg the cold start really differs (the bug class the fix removes)
p = M.pos["2021-01-15"]
w2, _ = V.invest(M, M.book_rule()[0], p, N, warm_from=lo); c2, _ = V.invest(M, M.book_rule()[0], p, N)
assert V.slice_stats(w2, "2021-01-15") == V.slice_stats(full, "2021-01-15") != V.slice_stats(c2, "2021-01-15")
ok("boundary inside an alt leg: warm matches the continuous run, cold does not")
# 3. investable engine sanity: hold BTC = price ratio minus one fee
hb, _ = V.invest(M, lambda i: {"BTC": 1.0}, lo, N)
exp = M.p("BTC", N) / M.p("BTC", lo + 1) * (1 - V.FEE)
assert abs(hb[-1][1] - exp) < 1e-9
ok("investable engine: hold BTC matches price ratio after one fee")
# 4. outbox: a failed send is retried, never lost, never duplicated
tmp = tempfile.mkdtemp(); V.OUTBOX = tmp + "/ob.json"; V.JOURNAL = tmp + "/j.jsonl"
ids = V.enqueue([("call", "book", "GO TO CASH", "msg A")], "2026-09-27")
assert V.enqueue([("call", "book", "GO TO CASH", "msg A")], "2026-09-27") == [] # same transition, same close
st = V.flush(lambda m: (False, "timeout"))
assert st["pending"] == 1
sent = []
st = V.flush(lambda m: (sent.append(m) or True, None))
assert st["pending"] == 0 and len(sent) == 1 and ids[0] in sent[0]
assert V.flush(lambda m: (sent.append(m) or True, None))["pending"] == 0 and len(sent) == 1
box = json.load(open(V.OUTBOX)); box[0]["status"] = "pending"; box[0]["created"] -= 49 * 3600; json.dump(box, open(V.OUTBOX, "w"))
assert V.flush(lambda m: (False, "x"))["dead"] == 1
ok("outbox: retry after failure, exactly one delivery, dead letter after 48h")
# 5. health gate
import datetime as dt
now = dt.datetime.fromisoformat(max(PX["BTC"])) + dt.timedelta(days=1, hours=1)
assert V.health(M, PX, FD, {}, None, now)["status"] in ("OK", "DEGRADED")
late = now + dt.timedelta(days=1)
h = V.health(M, PX, FD, {}, None, late); assert h["status"] == "STALE" and h["problems"]
px2 = copy.deepcopy(PX); last = max(px2["BTC"])
alts = sorted(M.alts)
for s in alts[: int(len(alts) * 0.2)]:
px2[s].pop(last, None)
M2 = V.Model(px2, FD); h2 = V.health(M2, px2, FD, {}, None, now)
assert h2["status"] == "STALE" and "coverage" in h2["problems"][0]
px3 = copy.deepcopy(PX)
for s in M.mem["DEFI"][:6]:
px3[s].pop(last, None)
M3 = V.Model(px3, FD); h3 = V.health(M3, px3, FD, {}, None, now)
assert h3["status"] == "DEGRADED" and "DEFI" in h3["weak_baskets"], h3
ok("health: stale BTC -> STALE, 20% alts missing -> STALE, thin basket -> DEGRADED")
print("ALL TESTS PASSED")
# ---------------------------------------------------------------- r2 tests --
import multiprocessing as mp, math
# 6. capture endpoints: a book that holds exactly the winner earns exactly the winner's return
I = V.arith_indices(M)
runs = V.label_runs(M, I, lo)
r = next(x for x in runs if x["major"] and x["bucket"] == "BTC")
eq = {k: I["BTC"][k] for k in range(r["a"] - 5, r["b"] + 3)}
assert abs(V.capture(r, eq) - 100.0) < 0.05, V.capture(r, eq)
ok("capture: holding the winner over [a, b+1] scores 100 (endpoint aligned)")
# 7. coverage guard: a basket day with too few member closes is unknown, not renormalised
px4 = copy.deepcopy(PX); d = M.days[M.pos["2023-03-01"]]
for s in M.mem["MAJORS"][:10]:
px4[s].pop(d, None)
M4 = V.Model(px4, FD); cv = []; x = V.arith_index(M4, M4.mem["MAJORS"], coverage=cv)
k = M4.pos[d]; assert x[k] is None and cv[k - 1] < V.MIN_INDEX_COVERAGE
ok("coverage: a day with half the members missing is marked unknown")
# 8. outbox under overlap: parallel enqueuers and flushers lose nothing and send each id once
tmp2 = tempfile.mkdtemp(); V.OUTBOX = tmp2 + "/ob.json"; V.JOURNAL = tmp2 + "/j.jsonl"
SENT = tmp2 + "/sent.txt"
def _send(m):
with open(SENT, "a") as f: f.write(m.split("ref ")[-1][:12] + "\n")
time.sleep(0.01); return True, None
def _worker(n):
for j in range(20):
V.enqueue([("call", "book", "W%d-%d" % (n, j), "msg %d %d" % (n, j))], "2026-09-27")
if j % 3 == 0: V.flush(_send)
ps = [mp.Process(target=_worker, args=(n,)) for n in range(4)]
[p.start() for p in ps]; [p.join() for p in ps]
V.flush(_send)
box = json.load(open(V.OUTBOX)); sent = open(SENT).read().split()
assert len(box) == 80 and all(m["status"] == "delivered" for m in box), (len(box), set(m["status"] for m in box))
assert len(sent) == 80 and len(set(sent)) == 80, (len(sent), len(set(sent)))
ok("outbox: 4 overlapping writers and flushers, 80 events, 80 sends, no loss, no double send")
# 9. crash after Telegram accepted but before the delivered state is saved: resent once (at-least-once)
tmp3 = tempfile.mkdtemp(); V.OUTBOX = tmp3 + "/ob.json"; V.JOURNAL = tmp3 + "/j.jsonl"
V.enqueue([("call", "book", "X", "msg X")], "2026-09-27")
sent3 = []
real = V.atomic_json
def boom(path, obj):
raise SystemExit("crash after send")
V.atomic_json = boom
try:
V.flush(lambda m: (sent3.append(m) or True, None))
except SystemExit:
pass
V.atomic_json = real
st = V.flush(lambda m: (sent3.append(m) or True, None))
assert len(sent3) == 2 and st["pending"] == 0
ok("crash between send and save: message resent once on the next flush (known at-least-once duplicate)")
print("ALL r2 TESTS PASSED")
tests output
PASS signal equivalence with frozen v2 on 2310 closes
PASS continuity: warm {'total': 74.5, 'cagr': 28.2, 'maxdd': -29.7} == slice {'total': 74.5, 'cagr': 28.2, 'maxdd': -29.7} (cold start would give {'total': 74.5, 'cagr': 28.2, 'maxdd': -29.7})
PASS boundary inside an alt leg: warm matches the continuous run, cold does not
PASS investable engine: hold BTC matches price ratio after one fee
PASS outbox: retry after failure, exactly one delivery, dead letter after 48h
PASS health: stale BTC -> STALE, 20% alts missing -> STALE, thin basket -> DEGRADED
ALL TESTS PASSED
PASS capture: holding the winner over [a, b+1] scores 100 (endpoint aligned)
PASS coverage: a day with half the members missing is marked unknown
PASS outbox: 4 overlapping writers and flushers, 80 events, 80 sends, no loss, no double send
PASS crash between send and save: message resent once on the next flush (known at-least-once duplicate)
ALL r2 TESTS PASSED
OBJECTIVE_LOCKBOX.txt
ROTATION MAP LOCKBOX OBJECTIVE, registered 28 Sep 2026 (Bangkok), before any lockbox data exists.
Lockbox = every daily close from 2026-09-28 onward. Nothing below may be changed after registration.
Systems scored: v2 frozen (sha256 32a5fcfc0faa47dca6fd10d69da3d38dd15fbb64f30f53dbfc4b689b231a5b10), E1 shadow
(registration sha256 43f0ab5edb0866dfb5e165dbe6cfe66dfb5a580bc7559b2d8d5ad717f1432b83), and any later registered sleeve.
Comparator: BTC with the v2 risk switch only (100 day average, 1% band). Secondary comparator: hold BTC.
PRIMARY METRIC (one number, decides keep or drop of the alt leg):
Net ending equity of the system divided by net ending equity of the comparator, both run by the
coin-level engine with fills at close i+2 (one extra day) and 1.0% cost per side on every coin trade.
The alt leg earns its place only if this ratio is above 1.00 AND the lower end of a 90% block
bootstrap interval (block = 30 days, 2000 draws) is above 0.95.
REPORTED ALONGSIDE, NEVER OPTIMISED:
1. max drawdown of each system and the comparator
2. missed return: for each labelled rotation (45 day horizon, 21 day merge, 20% edge, labels computed
on the point-in-time top-50 CMC universe, weekly), the winner's return over the run not held
3. entry delay in days from run start to first holding
4. wrong-way exposure: share of days holding a basket that trails BTC by more than 20% over the next 45 days
5. gross two-sided turnover per year and the summed fee load
6. the same primary ratio at 0.3% / 0 extra days and at 2.0% / 2 extra days (sensitivity only)
WHEN: first reading 2027-09-28 or after three labelled rotations, whichever is later. Report the sample
count (days, rotations) and the interval. Before that date only data-health and alert reconciliation are
reported. No threshold of v2 or E1 is changed on the basis of lockbox data at any time.
pit.py (P5-lite loader)
"""P5-lite: point-in-time universe from weekly CoinMarketCap snapshots (top 1000 by
market cap on each Sunday 2019-06..2026-09, including coins that later died).
Compares survivor baskets (today's lists) with PIT baskets built only from what
was ranked on each date. Weekly sampling, weekly rebalance, no fees, for BOTH
sides, so the gap isolates membership. v2 rule and parameters untouched."""
import json, os, sys, glob, math
sys.path.insert(0, "/home/claude/rotmap/v21")
import rotmap as V
PX = json.load(open("/home/claude/rotmap/v21/store/px.json")); FD = json.load(open("/home/claude/rotmap/v21/store/fund.json"))
M = V.Model(PX, FD)
LO, SPLIT = "2020-06-01", "2024-07-01"
S = {}
for f in sorted(glob.glob("snap2/*.json")):
d = os.path.basename(f)[:10]; S[d] = {}
for r in json.load(open(f)):
S[d][r[0]] = r
D = sorted(S)
ETH, SOL = 1027, 5426
def clean(d):
btc = S[d][1][3]; eth = S[d].get(ETH, [0,0,0,0])[3]
out, px_of = [], {}
for r in sorted(S[d].values(), key=lambda r: -(r[4] or 0)):
px_of.setdefault(r[1].upper(), r[3] or 1e-18)
for r in S[d].values():
i, sym, rank, p, mc, vol, st, wr, name, me, ai, de = r
if i == 1 or st or wr or not p or not mc: continue
if "USD" in sym.upper() or "EUR" in sym.upper() or (0.97 <= p <= 1.03 and "USD" in name.upper()): continue
if sym[:1] in "wW" and sym[1:].upper() in px_of and abs(p / px_of[sym[1:].upper()] - 1) < 0.02: continue
if abs(p / btc - 1) < 0.03 or (eth and abs(p / eth - 1) < 0.03 and i != ETH): continue # wrapped/staked copies
out.append(r)
out.sort(key=lambda r: -r[4])
return out
C = {d: clean(d) for d in D}
def seasoned(d, k): # listed in the snapshot 4 weeks earlier (mirrors v2's 30 day join rule)
j = D.index(d)
return j >= 4 and k in S[D[j - 4]]
def pit_members(d, b):
c = [r for r in C[d] if seasoned(d, r[0])]
if b == "ALT100": return [r[0] for r in c[:100]]
if b == "ALT50": return [r[0] for r in c[:50]]
if b == "MATCH": # same count as v2's survivor alt universe eligible on that date
i = M.pos.get(d)
n = sum(1 for s in M.alts if i is not None and M.p(s, i) and d >= V._add(M.first[s], 30)) if i is not None else 0
return [r[0] for r in c[:max(n, 10)]]
if b == "ETH": return [ETH] if ETH in S[d] else []
if b == "SOL": return [SOL] if SOL in S[d] and seasoned(d, SOL) else []
if b == "MEMES": return [r[0] for r in c if r[9]][:15]
if b == "AI": return [r[0] for r in c if r[10] and not r[9]][:10]
if b == "DEFI": return [r[0] for r in c if r[11] and not r[9] and not r[10]][:17]
if b == "MAJORS": return [r[0] for r in c if not (r[9] or r[10] or r[11]) and r[0] not in (ETH, SOL)][:20]
MISSING = {}
def fwd(k, a, z):
"""price return of CMC id k from snapshot a to z; None if not ranked at z"""
pa = S[a][k][3]; r = S[z].get(k)
if r and r[3]: return r[3] / pa - 1
MISSING.setdefault((k, a, z), S[a][k][1])
if S[a][k][1] in MIGRATED: return 0.0 # token swap, not a death (EOS->A, MKR->SKY)
j = D.index(z)
for d in D[j + 1:j + 13]: # dropped out of the top 1000 briefly: use next sighting
if k in S[d] and S[d][k][3]: return S[d][k][3] / pa - 1
return None
MIGRATED = {"EOS", "MKR"}
DEAD = float(os.environ.get("DEAD", "-0.9")) # a top-ranked coin that leaves the top 1000 inside the window: booked at -90%
def pit_ret(d0, d1, b):
m = pit_members(d0, b)
if not m: return None
rs = [fwd(k, d0, d1) for k in m]
return sum(DEAD if r is None else r for r in rs) / len(rs)
def surv_ret(d0, d1, b):
i0, i1 = M.pos.get(d0), M.pos.get(d1)
if i0 is None or i1 is None: return None
m = [s for s in M.mem[b] if M.p(s, i0) and M.p(s, i1) and d0 >= V._add(M.first[s], 30)]
return sum(M.p(s, i1) / M.p(s, i0) - 1 for s in m) / len(m) if m else None
def btc_ret(d0, d1): return S[d1][1][3] / S[d0][1][3] - 1
fetch2.py (CMC snapshots)
import json, os, time, datetime as dt, urllib.request, concurrent.futures as cf
U="https://api.coinmarketcap.com/data-api/v3/cryptocurrency/listings/historical?date={}&limit=1000&start=1&convertId=2781"
def one(d):
f=f"snap2/{d}.json"
if os.path.exists(f): return d,"cached"
for t in range(4):
try:
r=urllib.request.urlopen(urllib.request.Request(U.format(d),headers={"User-Agent":"Mozilla/5.0"}),timeout=60)
data=json.load(r)["data"]; out=[]
for c in data:
q=c["quotes"][0]; tg=set(c.get("tags") or [])
out.append([c["id"],c["symbol"],c["cmcRank"],q["price"],q["marketCap"],q["volume24h"],
int(any("stablecoin" in x for x in tg)), int(any("wrapped" in x or "liquid-staking" in x or "tokenized-gold" in x or "bridged" in x or "tokenized-stock" in x for x in tg)), c["name"], int(bool(tg & {"memes","doggone-doggerel"})), int(bool(tg & {"ai-big-data","artificial-intelligence","ai-agents"})), int("defi" in tg)])
json.dump(out,open(f,"w")); return d,len(out)
except Exception as e:
time.sleep(3*(t+1)); err=str(e)
return d,"FAIL "+err
d=dt.date(2019,6,2); ds=[]
while d<=dt.date(2026,9,27): ds.append(d.isoformat()); d+=dt.timedelta(7)
with cf.ThreadPoolExecutor(4) as ex:
res=list(ex.map(one,ds))
bad=[r for r in res if isinstance(r[1],str) and r[1].startswith("FAIL")]
print(len(ds),"dates; fails",len(bad),bad[:5])
run_pit.py
import pit as P, json, math
from pit import M, V, D, S
lo = M.pos[P.LO]
# v2 book held on each day (decision at close i filled at close i+1): taken from the investable run
curve, _ = V.invest(M, M.book_rule()[0], lo, M.N - 1)
held = {d: bw for d, _, bw in curve}
W = [d for d in D if d >= "2020-06-07" and d in M.pos and d in held]
SECT = ["MAJORS", "MEMES", "AI", "DEFI"]
def week(d0, d1, mode):
bw = held[d0]; r = 0.0; used = {}
for b, w in bw.items():
if b == "BTC": x = P.btc_ret(d0, d1)
elif mode == "surv": x = P.surv_ret(d0, d1, b)
elif mode == "broad": x = P.pit_ret(d0, d1, "ALT50") if b not in ("ETH",) else P.pit_ret(d0, d1, "ETH")
else:
if b in SECT and len(P.pit_members(d0, b)) < 3: x = P.pit_ret(d0, d1, "ALT50")
else: x = P.pit_ret(d0, d1, b)
if x is None: x = 0.0
r += w * x
return r
res = {}
for mode in ("surv", "pit", "broad"):
eq, e, pk, dd = {}, 1.0, 1.0, 0.0
for a, z in zip(W, W[1:]):
e *= 1 + week(a, z, mode); eq[z] = e; pk = max(pk, e); dd = min(dd, e / pk - 1)
res[mode] = eq
def per(eq, a, z):
ks = [k for k in sorted(eq) if a <= k < z]
if not ks: return None
base = eq[max([k for k in sorted(eq) if k < a], default=ks[0])] if any(k < a for k in eq) else 1.0
e = [base] + [eq[k] for k in ks]; pk, dd = e[0], 0
for x in e: pk = max(pk, x); dd = min(dd, x / pk - 1)
return round(100 * (e[-1] / e[0] - 1), 1), round(100 * dd, 1)
print("Weekly-sampled v2 book, same decisions, no fees. total %, worst drop %")
for name, (a, z) in {"2020-06..2024-06": ("2020-06-01", P.SPLIT), "2024-07..now": (P.SPLIT, "2099")}.items():
print(" %-17s survivor lists %-16s PIT sectors %-16s PIT broad top50 %s" % (name, per(res["surv"], a, z), per(res["pit"], a, z), per(res["broad"], a, z)))
# basket-level bias, only in weeks the book held that basket
print("\nBasket bias in the weeks v2 held it (annualised, PIT minus survivor, weekly rebalance)")
for b in ["MAJORS", "MEMES", "AI", "DEFI"]:
g = []
for a, z in zip(W, W[1:]):
if b in held[a] and len(P.pit_members(a, b)) >= 3:
s, p = P.surv_ret(a, z, b), P.pit_ret(a, z, b)
if s is not None and p is not None: g.append((math.log(1 + p) - math.log(1 + s)))
print(" %-7s weeks %3d PIT minus survivor %+.1f%% per year" % (b, len(g), 100 * (math.exp(52 * sum(g) / len(g)) - 1) if g else float('nan')))
print("\nTop-ranked coins that left the top 1000 inside a week (booked at -90%%): %d cases" % len(P.MISSING))
print(" examples:", sorted(set(P.MISSING.values()))[:30])
json.dump({m: res[m] for m in res}, open("pit_books.json", "w"))
# baselines on the same weekly grid (no survivorship possible)
def base(fn):
eq, e = {}, 1.0
for a, z in zip(W, W[1:]):
e *= 1 + fn(a, z); eq[z] = e
return eq
hb = base(lambda a, z: P.btc_ret(a, z))
bt = base(lambda a, z: P.btc_ret(a, z) if M.risk_on(M.pos[a]) else 0.0) # r4: clean rule, switch read at the snapshot (r3 mixed in v2 fill timing)
bt_fill = base(lambda a, z: P.btc_ret(a, z) if ("BTC" in held[a] or M.risk_on(M.pos[a])) else 0.0) # r4: the r3 expression, kept to show the error: counts BTC also on days v2 was still filling out of BTC
for name, (a, z) in {"2020-06..2024-06": ("2020-06-01", P.SPLIT), "2024-07..now": (P.SPLIT, "2099")}.items():
print(" %-17s hold BTC %-16s BTC switch read at snapshot %-16s r3 printed (timing error) %s" % (name, per(hb, a, z), per(bt, a, z), per(bt_fill, a, z)))
clocks_pit.py
"""Breadth clocks on the point-in-time universe vs v2's survivor clocks, weekly."""
import pit as P
from pit import M, D, S
def clock(d, weeks, uni="ALT100"):
j = D.index(d)
if j < weeks: return None
a = D[j - weeks]; rb = P.btc_ret(a, d); m = P.pit_members(a, uni)
if not m: return None
win = 0
for k in m:
r = S[d].get(k)
if r and r[3] and r[3] / S[a][k][3] - 1 > rb: win += 1
return win / len(m)
def clock_sd(d, weeks, uni="ALT100"):
"""signal-date cohort: constituents listed AT d, restricted to coins already ranked
at the lookback start (adequate history); return over the same window"""
j = D.index(d)
if j < weeks: return None
a = D[j - weeks]; rb = P.btc_ret(a, d)
m = [k for k in P.pit_members(d, uni) if k in S[a] and S[a][k][3]]
if not m: return None
return sum(1 for k in m if S[d][k][3] / S[a][k][3] - 1 > rb) / len(m)
CLOCK = clock
W = [d for d in D if d >= "2019-12-01" and d in M.pos]
rows = []
def build_rows(fn):
rows.clear()
for d in W:
i = M.pos[d]
rows.append((d, fn(d, 13), fn(d, 4), M.c90[i], M.c30[i], M.risk_on(i)))
def fires(col90, col30):
out, prev = [], False
for k, (d, p90, p30, s90, s30, up) in enumerate(rows):
c90 = [r[col90] for r in rows[max(0, k - 8):k + 1] if r[col90] is not None]
washed = any(x <= 0.05 for x in c90)
c30 = rows[k][col30]
f = bool(up and washed and c30 is not None and c30 >= 0.40)
if f and not prev: out.append(d)
prev = f
return out
if __name__ == "__main__":
for NAME, FN in (("lookback-start cohort", clock), ("signal-date cohort", clock_sd)):
build_rows(FN); print("=====", NAME)
import statistics
print("agreement of weekly clocks (PIT top100 vs survivor list):")
for n, (a, b) in {"90d": (1, 3), "30d": (2, 4)}.items():
pr = [(r[a], r[b]) for r in rows if r[a] is not None and r[b] is not None]
mx = statistics.mean(x for x, _ in pr); my = statistics.mean(y for _, y in pr)
cov = sum((x - mx) * (y - my) for x, y in pr); vx = sum((x - mx) ** 2 for x, _ in pr); vy = sum((y - my) ** 2 for _, y in pr)
print(" %s clock: corr %.2f, mean PIT %.0f%% vs survivor %.0f%%, weeks %d" % (n, cov / (vx * vy) ** .5, 100 * mx, 100 * my, len(pr)))
print("washed-out weeks (90d clock <=5%%): PIT %d, survivor %d" % (sum(1 for r in rows if r[1] is not None and r[1] <= .05), sum(1 for r in rows if r[3] is not None and r[3] <= .05)))
print("PIT 90d clock minimum", min(r[1] for r in rows if r[1] is not None))
ps, ss = fires(1, 2), fires(3, 4)
print("alt-bottom fire weeks PIT: ", ps)
print("alt-bottom fire weeks survivor:", ss)
for q in (0.05, 0.10, 0.15):
print(" PIT weeks with 90d clock <= %d%%: %s" % (100*q, [r[0] for r in rows if r[1] is not None and r[1] <= q][:40]))
book_pit.py
"""The v2 rule, parameters unchanged, driven ENTIRELY by point-in-time data (weekly).
Risk switch: BTC 100d MA, 1% band (from v2, BTC has no survivorship).
Alt bottom: PIT 90d clock <=5% within 60 days, PIT 30d clock >=40%, risk on.
Basket top: PIT basket 90d clock >=70% within 30 days and 30d clock <50%.
Holdings: equal split over PIT ETH, SOL, MAJORS, MEMES, AI, DEFI (sector <3 members -> PIT top50)."""
import pit as P, sys
from pit import M, D, S
import clocks_pit as CP
UNI = sys.argv[1] if len(sys.argv) > 1 else "ALT100"
KIND = sys.argv[3] if len(sys.argv) > 3 else "start" # start = lookback-start cohort, signal = signal-date cohort
clock = CP.clock if KIND == "start" else CP.clock_sd
BASK = ["ETH", "SOL", "MAJORS", "MEMES", "AI", "DEFI"]
def bclock(d, weeks, b):
j = D.index(d)
if j < weeks: return None
a = D[j - weeks]; rb = P.btc_ret(a, d); m = P.pit_members(a, b)
if len(m) < 3: return None
return sum(1 for k in m if S[d].get(k) and S[d][k][3] and S[d][k][3] / S[a][k][3] - 1 > rb) / len(m)
W = [d for d in D if d >= "2019-12-01" and d in M.pos]
c90 = {d: clock(d, 13, UNI) for d in W}; c30 = {d: clock(d, 4, UNI) for d in W}
bc = {(d, b): (bclock(d, 13, b), bclock(d, 4, b)) for d in W for b in BASK[2:]}
on, dropped, hold = False, set(), {}
for k, d in enumerate(W):
up = M.risk_on(M.pos[d])
washed = any(c90[x] is not None and c90[x] <= 0.05 for x in W[max(0, k - 8):k + 1])
if on and not up: on = False
elif not on and up and washed and (c30[d] or 0) >= 0.40: on, dropped = True, set()
if not on: hold[d] = {"BTC": 1.0} if up else {}; continue
for b in BASK[2:]:
hi = any((bc[(x, b)][0] or 0) >= 0.70 for x in W[max(0, k - 4):k + 1])
if hi and bc[(d, b)][1] is not None and bc[(d, b)][1] < 0.50: dropped.add(b)
live = [b for b in BASK if b not in dropped]
w = {b: 1 / len(BASK) for b in live}
if len(live) < len(BASK): w["BTC"] = 1 - sum(w.values())
hold[d] = w
def wret(a, z, w):
r = 0.0
for b, x in w.items():
if b == "BTC": r += x * P.btc_ret(a, z)
elif b in ("ETH", "SOL"): r += x * (P.pit_ret(a, z, b) or 0.0)
else: r += x * (P.pit_ret(a, z, b if len(P.pit_members(a, b)) >= 3 else "ALT50") or 0.0)
return r
lag = int(sys.argv[2]) if len(sys.argv) > 2 else 0 # 0: held from the snapshot it is decided on (same as the survivor comparison); 1: one week later
eq, e = {}, 1.0
Wk = [d for d in W if d >= "2020-06-07"]
for j in range(lag, len(Wk) - 1):
e *= 1 + wret(Wk[j], Wk[j + 1], hold[Wk[j - lag]]); eq[Wk[j + 1]] = e
from run_pit import per
alt_weeks = sum(1 for d in Wk if any(b != "BTC" for b in hold[d]))
legs, prev = [], False
for d in Wk:
a = any(b != "BTC" for b in hold[d])
if a and not prev: legs.append(d)
prev = a
print("clock", KIND, "cohort, universe", UNI, "| weeks in alts", alt_weeks, "| alt entries", legs)
for name, (a, z) in {"2020-06..2024-06": ("2020-06-01", P.SPLIT), "2024-07..now": (P.SPLIT, "2099")}.items():
print(" %-17s PIT-driven v2 book %s" % (name, per(eq, a, z)))
it_exec_stress.py
"""Execution stress (audit r3 point 4). Same frozen decisions; fills later and costs higher.
delay d = filled at close i+1+d. cost = per side on every coin trade (fee + spread + impact)."""
import json, inspect, rotmap as V
PX = json.load(open("store/px.json")); FD = json.load(open("store/fund.json"))
M = V.Model(PX, FD); lo = M.pos["2020-06-01"]; sp = M.pos["2024-07-01"]
src = inspect.getsource(V.invest).replace("def invest(M, rule, lo, hi, fee=FEE, warm_from=None):", "def invest_d(M, rule, lo, hi, fee=FEE, warm_from=None, delay=0):").replace("pend.append((i + 1, d))", "pend.append((i + 1 + delay, d))")
ns = dict(V.__dict__); exec(src, ns); invest_d = ns["invest_d"]
# r4: the BTC switch and hold BTC go through the SAME engine, costs and delays (matched comparison)
RULES = {"v2 book": lambda: M.book_rule()[0], "BTC switch": lambda: (lambda k: {"BTC": 1.0} if M.risk_on(k) else {}),
"hold BTC": lambda: (lambda k: {"BTC": 1.0})}
print("total %% (worst drop %%); every system: same coin engine, same cost per side, same fill delay")
print("cost/side delay | 2020-06..2024-06: v2 book / BTC switch / hold BTC | 2024-07..now: v2 book / BTC switch / hold BTC | since 2024-07 book minus switch")
for fee in (0.003, 0.01, 0.02):
for dl in (0, 1, 2, 3):
out = {}
for n, mk in RULES.items():
c, _ = invest_d(M, mk(), lo, M.N - 1, fee=fee, delay=dl)
out[n] = (V.slice_stats(c, "2020-06-01", "2024-07-01"), V.slice_stats(c, "2024-07-01"))
f = lambda s: "%.1f (%.1f)" % (s["total"], s["maxdd"])
print(" %4.1f%% %d day | %s / %s / %s | %s / %s / %s | %+.1f pts" % (100 * fee, dl, f(out["v2 book"][0]), f(out["BTC switch"][0]), f(out["hold BTC"][0]),
f(out["v2 book"][1]), f(out["BTC switch"][1]), f(out["hold BTC"][1]), out["v2 book"][1]["total"] - out["BTC switch"][1]["total"]))
it_coverage.py
"""Historical member coverage of the corrected basket index (audit r2, point 3).
Eligible on day d = in the basket list, first candle 30+ days before d, and the
coin's last stored candle is on or after d. Covered = closes on d-1 and d."""
import json, rotmap as V
PX = json.load(open("store/px.json")); FD = json.load(open("store/fund.json"))
M = V.Model(PX, FD); lo = M.pos["2020-06-01"]
last = {s: max(v) for s, v in PX.items()}
tot = {}
for b in V.PRICE_BASKETS:
low, worst, gaps = 0, (1.0, None), []
for i in range(lo, M.N):
d, q = M.days[i], M.days[i - 1]
el = [s for s in M.mem[b] if V._add(M.first[s], 30) <= d <= last[s]]
if not el: continue
cov = sum(1 for s in el if PX[s].get(d) and PX[s].get(q)) / len(el)
if cov < 1: gaps.append((d, round(cov, 2)))
if cov < 0.8: low += 1
if cov < worst[0]: worst = (cov, d)
tot[b] = (len(gaps), low, worst)
print("%-7s days under 100%%: %4d under 80%%: %3d worst %.2f on %s first gaps %s" % (b, len(gaps), low, worst[0], worst[1], gaps[:4]))
it_pbo_invest.py
"""Probability of backtest overfitting (CSCV, Bailey et al. 2015) over the
variants actually looked at while building v1 and v2. Daily log returns of the
synthetic index backtest (the method used during selection), 2020-06..now."""
import sys, math, itertools, json
sys.path.insert(0, "/home/claude/rotmap/lab"); sys.path.insert(1, "/home/claude/rotmap/deploy")
from common import R, model, LO
import numpy as np, rotmap as RV
M = model(); lo, N = M.pos[LO], M.N - 1
import rules as Q, lab
if "ALTS" not in M.idx: M.idx["ALTS"] = lab.alts_idx(M)
btc = M.idx["BTC"]; base_risk = list(M._risk)
V = {}
def add(name, mk, risk=None):
M._risk = risk if risk is not None else base_risk
curve, _ = RV.invest(M, mk(), lo, N)
e = np.array([c[1] for c in curve]); V[name] = np.diff(np.log(e))
M._risk = base_risk
for n in (20, 30, 50, 75, 100, 125, 150, 200):
for b in (0.0, 0.01, 0.03):
rk = Q.trend_state(btc, n, b)
add("G risk %dd b%d" % (n, b * 100), lambda: M.book_rule()[0], rk)
add("BTC trend %dd b%d" % (n, b * 100), lambda: (lambda i: {"BTC": 1.0} if M._risk[i] else {}), rk)
for n in (30, 50, 100):
for b in (0.0, 0.02, 0.05):
add("RS gate %dd b%d" % (n, b * 100), lambda n=n, b=b: Q.rs_gate(M, n, b))
add("RS ETH 50 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)):
add("breadth %d/%d" % (on * 100, off * 100), lambda on=on, off=off: Q.breadth_gate(M, on, off))
for n in (50, 100):
add("G+RS %d" % n, lambda n=n: Q.book_plus_rs(M, n, 0.02))
add("basket RS %d all" % n, lambda n=n: Q.basket_rs(M, n, 0.02))
add("basket RS %d top2" % n, lambda n=n: Q.basket_rs(M, n, 0.02, 2))
add("alts when risk on", lambda: (lambda i: Q.alts_w(M, i) if M.risk_on(i) else {}))
names = list(V); X = np.vstack([V[k] for k in names]).T # T x K
# HOLD_BTC_BENCH: the family's real question is whether the in-sample winner beats plain BTC out of sample
hb, _ = RV.invest(M, lambda i: {'BTC': 1.0}, lo, N)
HB = np.diff(np.log(np.array([c[1] for c in hb])))
T, K = X.shape; S = 16; blk = np.array_split(np.arange(T), S)
mu = np.array([[X[b, k].mean() for k in range(K)] for b in blk]); sq = np.array([[(X[b, k] ** 2).mean() for k in range(K)] for b in blk])
nb = np.array([len(b) for b in blk])
def sharpe(sel):
w = nb[list(sel)]; m = (mu[list(sel)] * w[:, None]).sum(0) / w.sum(); q = (sq[list(sel)] * w[:, None]).sum(0) / w.sum()
return m / np.sqrt(np.maximum(q - m * m, 1e-12))
hbm = np.array([HB[b].mean() for b in blk]); hbq = np.array([(HB[b] ** 2).mean() for b in blk])
def hsharpe(sel):
w = nb[list(sel)]; m = (hbm[list(sel)] * w).sum() / w.sum(); q = (hbq[list(sel)] * w).sum() / w.sum()
return m / math.sqrt(max(q - m * m, 1e-12))
beat_hold = []
lam, deg = [], []
for IS in itertools.combinations(range(S), S // 2):
OS = tuple(sorted(set(range(S)) - set(IS)))
si, so = sharpe(IS), sharpe(OS); best = int(np.argmax(si))
rank = (so < so[best]).sum() + 1; w = rank / (K + 1); lam.append(math.log(w / (1 - w)))
deg.append(so[best] - np.median(so))
beat_hold.append(so[best] > hsharpe(OS))
lam = np.array(lam)
print("variants K=%d, days T=%d, blocks S=%d, combinations %d" % (K, T, S, len(lam)))
print("PBO = %.2f (share of splits where the in-sample winner lands below the out-of-sample median)" % (lam < 0).mean())
print("share of splits where the in-sample winner beats holding BTC out of sample: %.2f" % np.mean(beat_hold))
print("median logit %.2f; OOS Sharpe of IS winner minus OOS median: median %.4f" % (np.median(lam), np.median(deg)))
full = sharpe(range(S)); order = np.argsort(-full)
print("top 8 by full-period daily Sharpe:", [(names[i], round(float(full[i]) * math.sqrt(365), 2)) for i in order[:8]])
print("v2 book rank by Sharpe: %d of %d" % (list(order).index(names.index("G risk 100d b1")) + 1, K))
json.dump({"K": K, "T": T, "S": S, "pbo": float((lam < 0).mean()), "names": names}, open("pbo_invest.json", "w"))
it_lockrace.py
"""Shows why the outbox lock exists: the same overlap test with the lock removed."""
import json, os, sys, tempfile, time, multiprocessing as mp
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import rotmap as V
class nolock:
def __enter__(self): return self
def __exit__(self, *a): pass
V.outbox_lock = nolock
tmp = tempfile.mkdtemp(); V.OUTBOX = tmp + "/ob.json"; V.JOURNAL = tmp + "/j.jsonl"; SENT = tmp + "/s"
def _send(m):
open(SENT, "a").write(m.split("ref ")[-1][:12] + "\n"); time.sleep(0.01); return True, None
def w(n):
for j in range(20):
try:
V.enqueue([("call", "book", "W%d-%d" % (n, j), "m")], "d")
if j % 3 == 0: V.flush(_send)
except Exception: pass
if __name__ == "__main__":
ps = [mp.Process(target=w, args=(n,)) for n in range(4)]; [p.start() for p in ps]; [p.join() for p in ps]
V.flush(_send)
box = json.load(open(V.OUTBOX)); sent = open(SENT).read().split()
print("WITHOUT the lock: %d of 80 events survive in the outbox; %d sends, %d unique ids (%d duplicates)" % (len(box), len(sent), len(set(sent)), len(sent) - len(set(sent))))
print("WITH the lock (test_v21.py test 8): 80 of 80 kept, 80 sends, 0 duplicates")
common.py
"""Shared loader for the v2.1 audit work. Signals come from the FROZEN v2 module."""
import json, math, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import rotmap_v2_frozen as R
ST = "/home/claude/rotmap/deploy/store"
PX = json.load(open(ST + "/px.json")); FD = json.load(open(ST + "/fund.json"))
_M = None
def model():
global _M
if _M is None:
_M = R.Model(PX, FD)
return _M
LO, SPLIT = "2020-06-01", "2024-07-01"
def book_run(M):
"""One continuous run of the frozen book from LO to the last close."""
rule, _ = M.book_rule()
return M.backtest(rule, M.pos[LO], M.N - 1)
invest.py
"""Investable, point-in-time constituent backtest for the frozen v2 book.
Decision at close i -> trade at close i+1 (same lag as the index backtest).
Each basket weight is split equally over members that are live on the trade
day (price on that day, 30+ days since first candle). Coin returns are REAL
closes, never capped. Fees are charged on every coin-level trade."""
import math
from common import R
def coin_target(M, bw, i):
w = {}
for b, W in bw.items():
live = [s for s in M.mem[b] if M.p(s, i) and M.days[i] >= R._add(M.first[s], 30)]
if not live:
continue
for s in live:
w[s] = w.get(s, 0) + W / len(live)
return w
def run(M, rule, lo, hi, fee=0.003, mode="drift", warm_from=None):
"""mode: drift (trade only when the book changes), weekly, daily.
warm_from: start the rule (and holdings) earlier and only mark equity from lo."""
start = warm_from if warm_from is not None else lo
val = {} # coin -> value (in book units); cash is the rest
cash, eq, pend, last_bw = 1.0, 1.0, [], None
curve, turnover, fees_paid, trades = [], 0.0, 0.0, 0
lastpx = {}
for i in range(start, hi + 1):
# 1. mark to market
for s in list(val):
p0, p1 = lastpx.get(s), M.p(s, i)
if p1 and p0:
val[s] *= p1 / p0
if p1:
lastpx[s] = p1
tot = cash + sum(val.values())
# 2. execute a pending book decision, or a scheduled rebalance
tgt_bw = None
while pend and pend[0][0] <= i:
_, tgt_bw = pend.pop(0)
resched = (mode == "daily" or (mode == "weekly" and i % 7 == 0)) and last_bw
if tgt_bw is not None or resched:
bw = tgt_bw if tgt_bw is not None else last_bw
tw = coin_target(M, bw, i)
new = {s: tot * w for s, w in tw.items()}
keys = set(new) | set(val)
moved = sum(abs(new.get(s, 0) - val.get(s, 0)) for s in keys)
if moved > 1e-9 * tot:
cost = fee * moved
fees_paid += cost / tot * (1 if i >= lo else 0)
turnover += moved / tot if i >= lo else 0
trades += 1
tot -= cost
scale = tot / (tot + cost)
val = {s: v * scale for s, v in new.items() if v > 0}
for s in val:
lastpx[s] = M.p(s, i) or lastpx.get(s)
cash = tot - sum(val.values())
last_bw = bw
# 3. decision at this close, filled next close
d = rule(i)
if d != (pend[-1][1] if pend else last_bw):
pend.append((i + 1, d))
if i >= lo:
curve.append((M.days[i], cash + sum(val.values()), dict(last_bw or {})))
base = curve[0][1]
curve = [(d, v / base, w) for d, v, w in curve]
yrs = len(curve) / 365
return curve, {"turnover_x": round(turnover, 1), "fees_pct_of_equity": round(100 * fees_paid, 1),
"rebalances": trades, "turnover_per_year": round(turnover / yrs, 1)}
indices.py
"""Corrected basket indices. v2's Model._index averages LOG returns (a geometric
mean, not a portfolio) and caps each day at +-50%. A real equal-weight basket
earns the ARITHMETIC mean of member returns. Signals in v2 never read index
levels (only whether an index exists), so this changes grading and display,
not the book's decisions."""
import math
from common import R
def arith_index(M, members, cap=None, join=30, need=1):
out, lvl = [None] * M.N, None
for i in range(1, M.N):
d, q = M.days[i], M.days[i - 1]
r = []
for s in members:
a, b = M.px[s].get(q), M.px[s].get(d)
if a and b and d >= R._add(M.first[s], join):
x = b / a - 1
if cap is not None:
x = max(math.exp(-cap) - 1, min(math.exp(cap) - 1, x))
r.append(x)
if len(r) >= need:
lvl = (lvl or 100.0) * (1 + sum(r) / len(r))
out[i] = lvl
return out
def all_indices(M, cap=None, join=30):
I = {b: arith_index(M, M.mem[b], cap, join) for b in R.PRICE_BASKETS}
out, lvl = [None] * M.N, None
for i in range(1, M.N):
rs = [I[b][i] / I[b][i - 1] - 1 for b in R.ALT_BASKETS if I[b][i] and I[b][i - 1]]
if len(rs) >= 2:
lvl = (lvl or 100.0) * (1 + sum(rs) / len(rs))
out[i] = lvl
I["ALTS"] = out
return I
score.py
"""Rotation grading, v2.1. Hindsight labels grade only; no rule reads them."""
import math, statistics as st
from common import R, LO, SPLIT
def oracle(M, I, H=45, merge=21, edge=0.20, lo=LO):
lo = M.pos[lo]
def lr(k, a, b):
if k == "CASH": return 0.0
x = I[k]
return math.log(x[b] / x[a]) if x[a] and x[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 < merge:
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
e = r[l] - max(v for k, v in r.items() if k != l)
worst = min(r, key=r.get)
out.append({"a": a, "b": b, "bucket": l, "edge": math.exp(e) - 1, "r": r, "worst": worst,
"major": math.exp(e) - 1 >= edge})
return out
def bucket(w):
if not w: return "CASH"
return "ALTS" if sum(v for k, v in w.items() if k != "BTC") >= 0.5 else "BTC"
def grade(M, I, curve, runs, split=SPLIT, costs=None):
held = {M.pos[d]: bucket(w) for d, _, w in curve}
def dret(k, i):
if k == "CASH": return 0.0
x = I[k]; return math.log(x[i + 1] / x[i]) if x[i] and x[i + 1] else 0.0
res = {"pre": [], "post": []}
for r in runs:
if not r["major"]: continue
span = [k for k in range(r["a"], r["b"] + 1) if k in held]
if not span: continue
inb = [k for k in span if held[k] == r["bucket"]]
lag = (inb[0] - r["a"]) if inb else None
missed_ret = sum(dret(r["bucket"], k) - dret(held[k], k) for k in span if k + 1 < M.N)
wrong = sum(1 for k in span if held[k] == r["worst"])
row = {"from": M.days[r["a"]], "to": M.days[r["b"]], "bucket": r["bucket"], "edge": round(100 * r["edge"]),
"lag": lag, "held": round(100 * len(inb) / len(span)), "caught": len(inb) / len(span) >= 0.5,
"missed_ret": round(100 * (math.exp(missed_ret) - 1)), "wrong_days": wrong, "days": len(span)}
res["pre" if M.days[r["a"]] < split else "post"].append(row)
return res
def summarize(rows):
if not rows: return {"n": 0}
lags = [x["lag"] if x["lag"] is not None else x["days"] for x in rows]
days = sum(x["days"] for x in rows)
return {"n": len(rows), "caught": sum(x["caught"] for x in rows),
"median_delay": st.median(lags), "worst_delay": max(lags),
"missed_time_pct": round(100 * sum(x["days"] * (1 - x["held"] / 100) for x in rows) / days),
"wrong_way_pct": round(100 * sum(x["wrong_days"] for x in rows) / days)}
def false_switches(log, days=14):
"""Book switches reversed within `days` (a round trip that paid twice)."""
import datetime as dt
n = 0
for (d1, w1), (d2, w2), (d3, w3) in zip(log, log[1:], log[2:]):
if bucket(w1) == bucket(w3) and bucket(w2) != bucket(w1):
if (dt.date.fromisoformat(d3) - dt.date.fromisoformat(d2)).days <= days:
n += 1
return n
it_grid.py
import json, itertools
from common import R, model, LO, SPLIT, PX, FD
import indices, score, invest
# systems: frozen v2, v1 (50d switch, 3-coin index start), BTC switch (100d), hold BTC
def make(risk_ma, need):
R.RISK_MA, R.INDEX_NEED = risk_ma, need
m = R.Model(PX, FD); R.RISK_MA, R.INDEX_NEED = 100, 1; return m
Mv2 = make(100, 1); Mv1 = make(50, 3)
I = indices.all_indices(Mv2) # corrected, uncapped, one set for every system
lo, N = Mv2.pos[LO], Mv2.N - 1
curves = {}
for name, M, rule in (("v2 frozen book", Mv2, lambda M: M.book_rule()[0]), ("v1 book", Mv1, lambda M: M.book_rule()[0]),
("BTC switch 100d", Mv2, lambda M: (lambda i: {"BTC": 1.0} if M.risk_on(i) else {})),
("hold BTC", Mv2, lambda M: (lambda i: {"BTC": 1.0}))):
c, info = invest.run(M, rule(M), lo, N, fee=0.003, mode="drift")
c2, log = M.backtest(rule(M), lo, N)
curves[name] = (M, c, info, log)
out = {"grid": [], "systems": {}}
for name, (M, c, info, log) in curves.items():
sp = Mv2.pos[SPLIT] - lo
out["systems"][name] = {"pre": R.stats(c[:sp]), "post": R.stats(c[sp:]), "turnover_per_year": info["turnover_per_year"],
"fees_pct_of_equity": info["fees_pct_of_equity"], "false_switches_14d": score.false_switches(log)}
for H, mg, ed in itertools.product((30, 45, 60), (14, 21, 30), (0.15, 0.20, 0.30)):
runs = score.oracle(Mv2, I, H, mg, ed)
cell = {"H": H, "merge": mg, "edge": int(ed * 100)}
for name, (M, c, info, log) in curves.items():
g = score.grade(Mv2, I, c, runs)
cell[name] = {"pre": score.summarize(g["pre"]), "post": score.summarize(g["post"])}
if (H, mg, ed) == (45, 21, 0.20):
out["systems"][name]["rotations_45_21_20"] = g
out["grid"].append(cell)
json.dump(out, open("grid.json", "w"), indent=1)
print("SYSTEM SUMMARY (investable drift, 0.3% per coin trade, continuous run)")
for n, s in out["systems"].items():
print(" %-16s pre %+8.1f%% dd %5.1f | post %+6.1f%% dd %5.1f | turnover %sx/yr fees %s%% | false switches %s" % (
n, s["pre"]["total"], s["pre"]["maxdd"], s["post"]["total"], s["post"]["maxdd"], s["turnover_per_year"], s["fees_pct_of_equity"], s["false_switches_14d"]))
print("\nGRID: caught/major pre-Jul-2024 | post (v2 frozen, v1)")
for cell in out["grid"]:
a, b = cell["v2 frozen book"], cell["v1 book"]
f = lambda s: "%s/%s" % (s.get("caught", 0), s["n"])
print(" H%-2d merge%-2d edge%-2d v2 %-5s | %-4s delay med %-5s worst %-4s missed %3s%% wrong %2s%% v1 %-5s | %-4s" % (
cell["H"], cell["merge"], cell["edge"], f(a["pre"]), f(a["post"]), a["pre"].get("median_delay"), a["pre"].get("worst_delay"),
a["pre"].get("missed_time_pct"), a["pre"].get("wrong_way_pct"), f(b["pre"]), f(b["post"])))
print("\nv2 frozen, reference cell 45/21/20, every major rotation:")
for per in ("pre", "post"):
for r in out["systems"]["v2 frozen book"]["rotations_45_21_20"][per]: print(" ", per, r)
it_episodes.py
"""Basket-top evidence, recounted as distinct market episodes, on corrected indices."""
import math
from common import R, model, LO, SPLIT
import indices
M = model(); I = indices.all_indices(M); lo = M.pos[LO]
def fwd(k, i, n):
x = I[k]; return (x[i + n] / x[i] - 1) if i + n < M.N and x[i] and x[i + n] else None
ev = []
for b in M.b90:
last = -999
for k in range(lo + 1, M.N - 60):
if M.basket_top(b, k) and not M.basket_top(b, k - 1) and k - last > 45:
f, g = fwd(b, k, 60), fwd("BTC", k, 60)
if f is not None and g is not None:
ev.append((k, b, f < g, round(100 * (f - g))))
last = k
ev.sort()
eps, cur = [], []
for e in ev:
if cur and e[0] - cur[-1][0] > 60:
eps.append(cur); cur = []
cur.append(e)
if cur: eps.append(cur)
def wilson(k, n, z=1.96):
if n == 0: return (0, 0)
p = k / n; d = 1 + z * z / n; c = p + z * z / (2 * n); h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
return (round(100 * (c - h) / d), round(100 * (c + h) / d))
for per, f in (("2020-06..2024-06", lambda d: d < SPLIT), ("2024-07..now", lambda d: d >= SPLIT)):
E = [x for x in ev if f(M.days[x[0]])]; P = [p for p in eps if f(M.days[p[0][0]])]
k = sum(x[2] for x in E); ke = sum(1 for p in P if sum(x[2] for x in p) * 2 > len(p))
print("%s: basket events lagged BTC %d of %d; distinct episodes %d, majority-lagged %d of %d (95%% CI %s-%s%%)" % (
per, k, len(E), len(P), ke, len(P), *wilson(ke, len(P))))
for p in P:
print(" episode from %s: %s" % (M.days[p[0][0]], ", ".join("%s %s (%+d%% vs BTC)" % (x[1], "lagged" if x[2] else "beat", x[3]) for x in p)))
it_pbo.py
"""Probability of backtest overfitting (CSCV, Bailey et al. 2015) over the
variants actually looked at while building v1 and v2. Daily log returns of the
synthetic index backtest (the method used during selection), 2020-06..now."""
import sys, math, itertools, json
sys.path.insert(0, "/home/claude/rotmap/lab"); sys.path.insert(1, "/home/claude/rotmap/deploy")
from common import R, model, LO
import numpy as np
M = model(); lo, N = M.pos[LO], M.N - 1
import rules as Q, lab
if "ALTS" not in M.idx: M.idx["ALTS"] = lab.alts_idx(M)
btc = M.idx["BTC"]; base_risk = list(M._risk)
V = {}
def add(name, mk, risk=None):
M._risk = risk if risk is not None else base_risk
curve, _ = M.backtest(mk(), lo, N)
e = np.array([c[1] for c in curve]); V[name] = np.diff(np.log(e))
M._risk = base_risk
for n in (20, 30, 50, 75, 100, 125, 150, 200):
for b in (0.0, 0.01, 0.03):
rk = Q.trend_state(btc, n, b)
add("G risk %dd b%d" % (n, b * 100), lambda: M.book_rule()[0], rk)
add("BTC trend %dd b%d" % (n, b * 100), lambda: (lambda i: {"BTC": 1.0} if M._risk[i] else {}), rk)
for n in (30, 50, 100):
for b in (0.0, 0.02, 0.05):
add("RS gate %dd b%d" % (n, b * 100), lambda n=n, b=b: Q.rs_gate(M, n, b))
add("RS ETH 50 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)):
add("breadth %d/%d" % (on * 100, off * 100), lambda on=on, off=off: Q.breadth_gate(M, on, off))
for n in (50, 100):
add("G+RS %d" % n, lambda n=n: Q.book_plus_rs(M, n, 0.02))
add("basket RS %d all" % n, lambda n=n: Q.basket_rs(M, n, 0.02))
add("basket RS %d top2" % n, lambda n=n: Q.basket_rs(M, n, 0.02, 2))
add("alts when risk on", lambda: (lambda i: Q.alts_w(M, i) if M.risk_on(i) else {}))
names = list(V); X = np.vstack([V[k] for k in names]).T # T x K
# HOLD_BTC_BENCH: the family's real question is whether the in-sample winner beats plain BTC out of sample
hb, _ = M.backtest(lambda i: {'BTC': 1.0}, lo, N)
HB = np.diff(np.log(np.array([c[1] for c in hb])))
T, K = X.shape; S = 16; blk = np.array_split(np.arange(T), S)
mu = np.array([[X[b, k].mean() for k in range(K)] for b in blk]); sq = np.array([[(X[b, k] ** 2).mean() for k in range(K)] for b in blk])
nb = np.array([len(b) for b in blk])
def sharpe(sel):
w = nb[list(sel)]; m = (mu[list(sel)] * w[:, None]).sum(0) / w.sum(); q = (sq[list(sel)] * w[:, None]).sum(0) / w.sum()
return m / np.sqrt(np.maximum(q - m * m, 1e-12))
hbm = np.array([HB[b].mean() for b in blk]); hbq = np.array([(HB[b] ** 2).mean() for b in blk])
def hsharpe(sel):
w = nb[list(sel)]; m = (hbm[list(sel)] * w).sum() / w.sum(); q = (hbq[list(sel)] * w).sum() / w.sum()
return m / math.sqrt(max(q - m * m, 1e-12))
beat_hold = []
lam, deg = [], []
for IS in itertools.combinations(range(S), S // 2):
OS = tuple(sorted(set(range(S)) - set(IS)))
si, so = sharpe(IS), sharpe(OS); best = int(np.argmax(si))
rank = (so < so[best]).sum() + 1; w = rank / (K + 1); lam.append(math.log(w / (1 - w)))
deg.append(so[best] - np.median(so))
beat_hold.append(so[best] > hsharpe(OS))
lam = np.array(lam)
print("variants K=%d, days T=%d, blocks S=%d, combinations %d" % (K, T, S, len(lam)))
print("PBO = %.2f (share of splits where the in-sample winner lands below the out-of-sample median)" % (lam < 0).mean())
print("share of splits where the in-sample winner beats holding BTC out of sample: %.2f" % np.mean(beat_hold))
print("median logit %.2f; OOS Sharpe of IS winner minus OOS median: median %.4f" % (np.median(lam), np.median(deg)))
full = sharpe(range(S)); order = np.argsort(-full)
print("top 8 by full-period daily Sharpe:", [(names[i], round(float(full[i]) * math.sqrt(365), 2)) for i in order[:8]])
print("v2 book rank by Sharpe: %d of %d" % (list(order).index(names.index("G risk 100d b1")) + 1, K))
json.dump({"K": K, "T": T, "S": S, "pbo": float((lam < 0).mean()), "names": names}, open("pbo.json", "w"))
it_construction.py
"""Construction sensitivity (review Q6). Not tuning: v2 keeps 30 days and its
signal path; this shows the direction and size of each construction choice."""
import json, rotmap_sens as S
PX = json.load(open("store/px.json")); FD = json.load(open("store/fund.json"))
def run(join, cap):
S.JOIN, S.CAP = join, cap
M = S.Model(PX, FD); lo, N = M.pos["2020-06-01"], M.N - 1
syn, _ = M.backtest(M.book_rule()[0], lo, N)
inv, _ = S.invest(M, M.book_rule()[0], lo, N)
f = lambda c, a, z: S.slice_stats(c, a, z)
return [f(syn, "2020-06-01", "2024-06-30"), f(syn, "2024-07-01", None), f(inv, "2020-06-01", "2024-06-30"), f(inv, "2024-07-01", None)]
print("%-34s %-28s %-24s %-28s %s" % ("", "synthetic 20-24", "synthetic 24-now", "investable 20-24", "investable 24-now"))
for lab, j, c in (("v2 as published (join 30, cap 50%)", 30, 0.5), ("cap 30%", 30, 0.3), ("uncapped", 30, None),
("new listings from day 1 (join 0)", 0, 0.5), ("join 90 days", 90, 0.5)):
r = run(j, c)
print("%-34s " % lab + " ".join("%+9.1f%% dd %6.1f%% " % (x["total"], x["maxdd"]) for x in r))
it_e1_once.py
"""ONE descriptive run of the pre-registered shadow E1 on history. Contaminated
by construction (same periods); it can not promote E1. Printed once, not tuned."""
import json, sys
import rotmap as V
PX = json.load(open("store/px.json")); FD = json.load(open("store/fund.json"))
M = V.Model(PX, FD); lo = M.pos["2020-06-01"]; N = M.N - 1
I = V.arith_indices(M)
g_curve, _ = V.invest(M, M.book_rule()[0], lo, N)
er, es = V.e1_rule(M)
e_curve, e_cost = V.invest(M, er, lo, N)
# E1 episodes
er2, es2 = V.e1_rule(M); on, starts = False, []
for k in range(lo, N + 1):
er2(k)
if es2["on"] and not on: starts.append(k)
on = es2["on"]
print("E1 switched on %d times:" % len(starts), [M.days[k] for k in starts])
yrs = [("2020-06-01", "2020-12-31")] + [("%d-01-01" % y, "%d-12-31" % y) for y in range(2021, 2026)] + [("2026-01-01", None)]
print("%-12s %12s %12s" % ("fold", "v2 book", "v2 + E1"))
for a, z in yrs:
print("%-12s %+11.1f%% %+11.1f%%" % (a[:4], V.slice_stats(g_curve, a, z)["total"], V.slice_stats(e_curve, a, z)["total"]))
for lab, a, z in (("2020-06..2024-06", "2020-06-01", "2024-06-30"), ("2024-07..now", "2024-07-01", None)):
g, e = V.slice_stats(g_curve, a, z), V.slice_stats(e_curve, a, z)
print("%s v2 %+.1f%% dd %.1f | v2+E1 %+.1f%% dd %.1f" % (lab, g["total"], g["maxdd"], e["total"], e["maxdd"]))
for name, c in (("v2", g_curve), ("v2+E1", e_curve)):
rows = V.grade_runs(M, I, c, V.label_runs(M, I, lo))
alt = [r for r in rows if r["bucket"] == "ALTS"]
print(name, "all:", V.grade_summary(rows), "| alt runs:", [(r["from"], r["delay"], r["held"]) for r in alt])
warm.py
import sys, json
sys.path.insert(0, "/home/claude/rotmap/deploy")
import rotmap as R
ST = "/home/claude/rotmap/deploy/store"
M = R.Model(json.load(open(ST + "/px.json")), json.load(open(ST + "/fund.json")))
lo, pa, N = M.pos["2020-06-01"], M.pos["2024-07-01"], M.N - 1
rule, st = R.M if False else M.book_rule()
full, log = M.backtest(rule, lo, N)
# state of the book entering 2024-07-01 in the continuous run
r2, s2 = M.book_rule()
for k in range(lo, pa): r2(k)
print("continuous-run state on 2024-06-30:", {"on": s2["on"], "dropped": sorted(s2["dropped"])}, "holding", full[pa - lo - 1][2])
cold = R.stats(M.backtest(M.book_rule()[0], pa, N)[0])
warm = R.stats(full[pa - lo:])
print("recent COLD (published):", cold)
print("recent WARM (sliced) :", warm)
for a, z in (("2020-06-01", "2024-06-30"),):
print("holdout cold:", R.stats(M.backtest(M.book_rule()[0], M.pos[a], M.pos[z])[0]))
print("holdout warm:", R.stats(full[:M.pos[z] - lo + 1]))
print("\nBoundary inside an alt leg (demonstrates the bug class):")
for b in ("2021-01-15", "2021-02-15", "2023-12-15"):
p = M.pos[b]
c = R.stats(M.backtest(M.book_rule()[0], p, N)[0]); w = R.stats(full[p - lo:])
print(" from %s cold %+.1f%% (dd %.1f) warm %+.1f%% (dd %.1f) holding at boundary %s" % (b, c["total"], c["maxdd"], w["total"], w["maxdd"], {k: round(v, 2) for k, v in full[p - lo][2].items()}))
it_invest.py
from common import R, model, LO, SPLIT, book_run
import invest
M = model(); N = M.N - 1
lo, sp = M.pos[LO], M.pos[SPLIT]
full_idx, _ = book_run(M)
def slice_stats(curve, a, z):
c = curve[a:z + 1] if isinstance(a, int) else None
return R.stats(c)
print("Frozen v2 book. Periods measured inside ONE continuous run (warm start).\n")
rows = [("synthetic index, 0.3% on book switches only (published)", None)]
print("%-58s %-30s %-30s %s" % ("", "2020-06 .. 2024-06", "2024-07 .. now", "turnover / fees (full)"))
def show(name, curve, extra=""):
a = R.stats(curve[:sp - lo]); b = R.stats(curve[sp - lo:])
print("%-58s %+9.1f%% dd %6.1f%% %+7.1f%% dd %6.1f%% %s" % (name, a["total"], a["maxdd"], b["total"], b["maxdd"], extra))
show("synthetic index (published method)", full_idx)
for mode in ("drift", "weekly", "daily"):
for fee in (0.003, 0.01):
c, info = invest.run(M, M.book_rule()[0], lo, N, fee=fee, mode=mode)
show("investable %-6s fee %.1f%%" % (mode, fee * 100), c, "%sx/yr, fees %s%% of equity" % (info["turnover_per_year"], info["fees_pct_of_equity"]))
# continuity check for the investable engine: warm-started recent == slice of full
c_full, _ = invest.run(M, M.book_rule()[0], lo, N, mode="drift")
c_warm, _ = invest.run(M, M.book_rule()[0], sp, N, mode="drift", warm_from=lo)
a = R.stats(c_full[sp - lo:]); b = R.stats(c_warm)
print("\ncontinuity: slice of full %s vs warm-started run %s" % (a, b))
assert abs(a["total"] - b["total"]) < 0.05 and abs(a["maxdd"] - b["maxdd"]) < 0.05, "continuity broken"
print("continuity assert passed")
sanity_invest.py
from common import R, model, LO
import invest, math
M = model(); N = M.N - 1; lo = M.pos[LO]
c, info = invest.run(M, lambda i: {"BTC": 1.0}, lo, N, fee=0.003)
exp = M.p("BTC", N) / M.p("BTC", lo + 1) * (1 - 0.003)
print("hold BTC engine %.4f expected %.4f" % (c[-1][1], exp))
# single member basket vs index: ETH
c2, _ = invest.run(M, lambda i: {"ETH": 1.0}, lo, N, fee=0.0)
print("hold ETH engine %.4f expected %.4f" % (c2[-1][1], M.p("ETH", N) / M.p("ETH", lo + 1)))
# daily-rebalanced MAJORS: engine (daily, no fee) vs arithmetic and geometric index
c3, _ = invest.run(M, lambda i: {"MAJORS": 1.0}, lo, N, fee=0.0, mode="daily")
print("MAJORS daily-rebalanced engine x%.2f synthetic index x%.2f" % (c3[-1][1], M.idx["MAJORS"][N] / M.idx["MAJORS"][lo + 1]))
# how often does the +-50% cap bind on members on a held day?
hits = []
for b in R.ALT_BASKETS:
for s in M.mem[b]:
ks = sorted(M.px[s])
for a, z in zip(ks, ks[1:]):
r = math.log(M.px[s][z] / M.px[s][a])
if abs(r) > 0.5 and z >= LO: hits.append((z, s, round(math.exp(r) - 1, 2)))
print("daily moves beyond the +-50%% log cap since 2020-06: %d" % len(hits), hits[:12])
V2_FREEZE.sha256
32a5fcfc0faa47dca6fd10d69da3d38dd15fbb64f30f53dbfc4b689b231a5b10 rotmap_v2_frozen.py 6d2599aed695b03738a3b07c8ee7a71c01145586d9775460ba9c4a9d0d7a6c33 rotation_v2_frozen.html
V21_CANDIDATE.sha256
b8e7e2415f698ad0def32847f1bc79eb50dcab430be23bc36c5b0403502174fa rotmap.py c0d3c57adfac478804e4f4f6af4b574e1ba8edd3bff891ed10ace55847ddef30 rotation.html 94b87a7dd5a36b3775d515485b7648ddff6df997a3d455a5fea413117fb1ff0f test_v21.py
E1_REGISTRATION.sha256
43f0ab5edb0866dfb5e165dbe6cfe66dfb5a580bc7559b2d8d5ad717f1432b83 E1_SPEC.txt + e1_rule source, registered 2026-09-28, lockbox from the 2026-09-28 UTC close