Rotation Map v2.1: response to the 28 Sep 2026 reviews (r4, dashboard update 30 Sep)

Version r6.2 · 1 Oct 2026 15:16 Bangkok · data through the 2026-09-27 UTC close · nothing here is deployed: the live service still runs frozen v2 · live dashboard rotation.html · v2 handover handover.html

Update 1 Oct 2026 (r6.2): the bigger test did not confirm the chain basket rule

Chain baskets rule rebuilt (r6), 30 Sep 2026

Chain baskets update (r5), 30 Sep 2026: earlier

Dashboard update, 30 Sep 2026: earlier

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.

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-062024-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)

r3 outputs are superseded by the r4 files printed in the block above (same scripts, extended).

Second review (r2): what changed in this version

Read this first (r1, corrected in place)

1 · Continuity (audit fix 1)

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)

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)

4 · Alert outbox (audit fix 4)

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

5 · Contamination (audit fix 5)

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

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

  1. 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.
  2. 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.
  3. 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.
  4. Exactly-once alerts are not possible on Telegram. The outbox gives at-least-once delivery with a visible ref id.
  5. 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.
  6. 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.
  7. 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)

#ChangeFiles and commands on the dropletCheck before and after
P0Freeze v2copy /opt/rotation/rotmap.py to rotmap_v2_frozen.py; store the sha256hash equals 32a5fcfc0faa47dc…
P1Service v2.1install rotmap.py (sha256 b8e7e2415f698ad0…); run test_v21.py on the droplet store; one --offline --dry runall tests pass; call and weights equal v2's; health OK
P2Alert deliverycron */10 * * * * cd /opt/rotation && python3 -W ignore rotmap.py --flush; keep the 4-hourly runinject a failing send in dry mode; the outbox shows pending then delivered
P3Monitoringextend fresh.py: alert on health STALE for more than 6h, outbox pending for more than 1h, or any dead letterfake-age tests as before
P4Dashboard v2.1install rotation.html (sha256 c0d3c57adfac4788…)390px and 1280px render, no console errors, stale state renders struck through
P5Point-in-time universe (research)monthly CMC snapshots for membership, Binance vision klines for delisted prices; rerun the grade and booksreport the survivorship gap as a number; no rule changes
P6Lockbox reviewno earlier than 2028-09-30 and after 3 completed major rotationspre-registered E1 criteria, v2 unchanged
—Rollbackcopy rotmap_v2_frozen.py back to rotmap.py, restore the saved v2 rotation.html, remove the flush crondashboard 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 &middot; Risk switch</h2>
  <div class="big" id="rbig">&mdash;</div><div class="sub" id="rsub"></div>
  <div class="risk"><i id="rneedle" style="position:absolute;top:-5px;width:3px;height:24px;border-radius:2px;background:var(--ink)"></i>
   <span style="position:absolute;left:50%;top:0;bottom:0;width:1px;background:var(--faint)"></span></div>
  <div class="scale"><span>-20%</span><span id="rma">100 day average</span><span>+20%</span></div>
  <div class="note" style="margin-top:10px">On above the average by 1%, off below it by 1%. Off means cash.</div></div>
 <div class="card gauge"><h2>2 &middot; Alt clock</h2>
  <div class="big" id="cbig">&mdash;</div><div class="sub" id="csub"></div>
  <div class="bar"><i id="c90"></i><i id="c30" class="b"></i></div>
  <div class="scale"><span>0 washed</span><span>share of alts beating BTC</span><span>top zone 100</span></div>
  <div class="note" style="margin-top:10px"><span style="color:var(--ink)">&#9646;</span> 90 day &nbsp; <span style="color:var(--accent)">&#9646;</span> 30 day.
  Green end: alts washed out, the next rotation's bottom zone. Red end: the alt rotation is topping.</div></div>
</div>

<div class="card"><h2>3 &middot; The rotation strip</h2><div class="strip" id="strip"></div>
 <div class="note" style="margin-top:12px"><b>Red</b> top confirmed &middot; <b>light red</b> extended, stop adding &middot;
 <b>green</b> bottom confirmed, part of the next rotation &middot; <b>light green</b> washed out, waiting for the turn &middot;
 grey nothing crossed. Orange % = weight in the book from the next close.</div></div>

<div class="card"><h2>4 &middot; Every rotation since 2020</h2>
 <canvas class="big" id="map" style="height:300px" 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 &middot; 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 &middot; 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 &middot; What each colour is built on</h2>
 <table><thead><tr><th>Signal</th><th>Rule</th><th>Evidence</th><th>Grade</th></tr></thead><tbody id="ev"></tbody></table></div>

<div class="card"><h2>8 &middot; The last rotations the book took</h2>
 <table><thead><tr><th>From</th><th>To</th><th>Held</th><th class="n">Leg</th></tr></thead><tbody id="legs"></tbody></table></div>

<footer>updated <span id="ft">&mdash;</span> Bangkok &middot; alerts <span id="al">&mdash;</span> &middot; shadow E1 <span id="e1">&mdash;</span> &middot; closed daily candles only &middot; alerts on Telegram when a colour or the call changes &middot;
<a href="/rotmap.json">raw numbers</a> &middot; <a href="/rotation_trench.html">trench detail</a></footer>
</div>
<script>
var C={red:'#f2555a',green:'#3ecf8e',accent:'#f0a83c',ink:'#e7eaf0',faint:'#6b7483',rule:'#20262f'};
function pct(v,d){return v==null?'—':(v>0?'+':'')+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> &nbsp;→&nbsp; from the next close <b>'+hold(R.want)+'</b>';
 // risk
 var rk=R.risk; el('rbig').textContent=(rk.on?'ON':'OFF')+'  '+pct(rk.dist,1); el('rbig').style.color=rk.on?C.green:C.red;
 el('rsub').textContent='BTC vs its '+(rk.ma_days||100)+' day average · switched '+(rk.on?'on':'off')+' '+rk.since; el('rma').textContent=(rk.ma_days||100)+' day average';
 var p=Math.max(0,Math.min(100,50+(rk.dist||0)*2.5)); el('rneedle').style.left='calc('+p+'% - 1px)';
 // clock
 var ck=R.clock; el('cbig').textContent=(ck.c90==null?'—':ck.c90+'%')+'  ·  30d '+(ck.c30==null?'—':ck.c30+'%');
 el('csub').textContent=ck.bottom?'BOTTOM CONFIRMED: alts washed out and turned':ck.washed_recent?'Washed out in the last 60 days, waiting for the turn':
  ck.top_warning?'Top warning: was 70%+ and the fast clock has rolled':(ck.c90>=70?'In the top zone':'Normal range. '+ck.n_alts+' alts counted');
 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 &middot; 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 &middot; Did it catch the big rotations?</h2>
- <div class="line" id="scoreline" style="margin-bottom:10px"></div>
- <div style="overflow-x:auto"><table><thead><tr><th>Rotation</th><th>Best place</th><th class="n">Won by</th><th class="n">Days late</th><th class="n">Time in it</th><th>Caught</th></tr></thead><tbody id="rots"></tbody></table></div>
- <div class="note" style="margin-top:10px">A major rotation is a stretch where cash, BTC or alts beat the next best place by 20% or more, labelled with hindsight (best place over the next 45 days, stretches under 21 days merged). Hindsight is only used to grade the system, never inside it. Caught means the book held that place for half the stretch or more.</div></div>
+ <div class="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 &middot; Track record, next-day execution, 0.3% per trade</h2>
- <table><thead><tr><th></th><th class="n">2020-06 to 2024-06</th><th class="n">max drop</th><th class="n">Recent 2024-07 to now</th><th class="n">max drop</th></tr></thead>
+<div class="card"><h2>6 &middot; 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 &middot; The last rotations the book took</h2>
  <table><thead><tr><th>From</th><th>To</th><th>Held</th><th class="n">Leg</th></tr></thead><tbody id="legs"></tbody></table></div>
 
-<footer>updated <span id="ft">&mdash;</span> Bangkok &middot; closed daily candles only &middot; alerts on Telegram when a colour or the call changes &middot;
+<footer>updated <span id="ft">&mdash;</span> Bangkok &middot; alerts <span id="al">&mdash;</span> &middot; shadow E1 <span id="e1">&mdash;</span> &middot; closed daily candles only &middot; alerts on Telegram when a colour or the call changes &middot;
 <a href="/rotmap.json">raw numbers</a> &middot; <a href="/rotation_trench.html">trench detail</a></footer>
 </div>
 <script>
 var C={red:'#f2555a',green:'#3ecf8e',accent:'#f0a83c',ink:'#e7eaf0',faint:'#6b7483',rule:'#20262f'};
-function pct(v,d){return v==null?'—':(v>0?'+':'')+v.toFixed(d==null?0:d)+'%';}
+function 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> &nbsp;→&nbsp; 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

Review response v2.1-r6.2 · 1 Oct 2026 15:16 Bangkok · latest version always at http://159.223.88.168/hx-c9ois/rotation/v21-review/review.html