Rotation dashboard: full handover for another AI

Version dash-h6 · 30 Sep 2026 Bangkok · live dashboard rotation.html · review page review.html · v2 framework handover handover.html · raw data rotmap.json, chains.json, rh_drill.json

Read this first. Update 3 Oct 2026, afternoon: v4.1 (60 more iterations, a date bug fixed, money flows, clearer words)

Ben's asks: the Robinhood section contradicted the baskets ("rotating out", "money coming in", HOLD vs WAIT, "money leaving" but HOLD); make money flows accurate per chain with their size and daily charts; use the word EXTENDED; say WAITING FOR BOTTOM instead of WAIT; move last moves, colours, past results and report card to the bottom; make the history chart clear to a child (what is the white line, what is a 90 day reading); then 50 more iterations on metrics, baskets, visuals and logic, optimising for trading baskets and rotating into strong ones and out of extended ones.

Iterations 71 to 130

#TestResult
71v4 baseline (half at HOT)109 trades, $ per $1 lost all 3.2 / 2023-24 4.29 / 2025-26 1.83, weekly 2025-26 1.49
72extended = 25% above 20d avg, half109 trades, $ per $1 lost all 2.81 / 2023-24 4.37 / 2025-26 1.16, weekly 2025-26 1.11
73extended = 40% above 20d avg, half109 trades, $ per $1 lost all 3.21 / 2023-24 4.96 / 2025-26 1.17, weekly 2025-26 1.1
74extended = 60% above 20d avg, half109 trades, $ per $1 lost all 3.21 / 2023-24 5.24 / 2025-26 0.88, weekly 2025-26 0.68
75extended = +50% in 30 days, half109 trades, $ per $1 lost all 2.81 / 2023-24 4.09 / 2025-26 1.18, weekly 2025-26 0.73
76extended = +100% in 30 days, half109 trades, $ per $1 lost all 3.07 / 2023-24 4.65 / 2025-26 1.15, weekly 2025-26 1.04
77extended = RSI 14 >= 80, half109 trades, $ per $1 lost all 3.06 / 2023-24 4.78 / 2025-26 1.15, weekly 2025-26 1.22
78extended = RSI 14 >= 85, half109 trades, $ per $1 lost all 3.0 / 2023-24 4.88 / 2025-26 0.93, weekly 2025-26 0.71
79extended = +30% from entry, half109 trades, $ per $1 lost all 3.06 / 2023-24 4.2 / 2025-26 1.63, weekly 2025-26 1.44
80extended = +50% from entry, half109 trades, $ per $1 lost all 3.11 / 2023-24 4.81 / 2025-26 1.16, weekly 2025-26 1.09
81extended = day 8 in trade, half109 trades, $ per $1 lost all 3.59 / 2023-24 5.38 / 2025-26 1.3, weekly 2025-26 0.93
82HOT but sell ALL257 trades, $ per $1 lost all 1.68 / 2023-24 1.82 / 2025-26 1.52, weekly 2025-26 1.53
83RSI>=80 sell ALL162 trades, $ per $1 lost all 2.33 / 2023-24 2.99 / 2025-26 1.45, weekly 2025-26 1.44
84HOT half, then RSI>=85 sell rest121 trades, $ per $1 lost all 2.6 / 2023-24 3.04 / 2025-26 1.99, weekly 2025-26 2.09
85HOT half, then +100% 30d sell rest131 trades, $ per $1 lost all 2.22 / 2023-24 2.29 / 2025-26 2.09, weekly 2025-26 1.32
86no extended rule at all109 trades, $ per $1 lost all 3.36 / 2023-24 5.52 / 2025-26 0.88, weekly 2025-26 0.68
87HOT half, exit 10d low131 trades, $ per $1 lost all 3.03 / 2023-24 4.0 / 2025-26 1.85, weekly 2025-26 0.87
88RSI>=80 half + 20d low exit101 trades, $ per $1 lost all 2.68 / 2023-24 4.15 / 2025-26 0.98, weekly 2025-26 1.03
89Risk off and still making new lows = WAITING FOR BOTTOMturned bullish within 14 days: 2023-24 18%, 2025-26 11%; next 14 days median +0.0% / -3.2%
90Risk off, no new low for 5+ days = BASINGturned bullish within 14 days: 2023-24 35%, 2025-26 30%; next 14 days median +0.5% / -3.4%
91Risk off, within 5% of turning = ALMOST BULLISHturned bullish within 14 days: 2023-24 48%, 2025-26 72%; next 14 days median +0.0% / +1.5%
92BUY signals with a bigger 30 day move did better2023-24 $4.83 vs $3.72 per $1; 2025-26 $2.37 vs $1.43
93bigger 7 day move did better$6.00 vs $3.13; $2.35 vs $1.19
94rising basket volume did better$6.43 vs $3.22; $2.60 vs $1.42
95BTC up more in the past weekmixed: worse 2023-24, better 2025-26
96further above the 90 day low did better$4.44 vs $4.13; $2.04 vs $1.53
97closer to the 90 day highmixed
98higher RSI at the BUY did better$6.47 vs $1.61; $2.28 vs $1.56
99meme baskets did better than non memes$5.28 vs $3.56; $2.05 vs $1.69
100BUY days only, 3 slots, rank 30d move (v4)+364% total, worst drop -24%, money in 21%, by year {'2023': 40, '2024': 167, '2025': 0, '2026': 25}
101rank by 7 day move+362% total, worst drop -26%, money in 21%, by year {'2023': 40, '2024': 167, '2025': -2, '2026': 26}
102rank by rising volume+326% total, worst drop -24%, money in 22%, by year {'2023': 40, '2024': 167, '2025': 4, '2026': 10}
103rank by RSI+305% total, worst drop -24%, money in 21%, by year {'2023': 40, '2024': 127, '2025': 4, '2026': 23}
104rank by 7d move + volume+385% total, worst drop -24%, money in 22%, by year {'2023': 40, '2024': 167, '2025': 6, '2026': 23}
105rank 7d move, swap out an extended basket for a fresh BUY+527% total, worst drop -31%, money in 23%, by year {'2023': 104, '2024': 111, '2025': 4, '2026': 40}
106rank 30d, swap extended for fresh BUY+568% total, worst drop -31%, money in 23%, by year {'2023': 96, '2024': 146, '2025': 1, '2026': 37}
107rank 7d move, 2 slots+236% total, worst drop -34%, money in 20%, by year {'2023': 26, '2024': 114, '2025': -3, '2026': 28}
108rank 7d move, 4 slots+493% total, worst drop -23%, money in 20%, by year {'2023': 63, '2024': 181, '2025': 6, '2026': 21}
109rank 7d move + swap, 2 slots+677% total, worst drop -40%, money in 24%, by year {'2023': 148, '2024': 115, '2025': -3, '2026': 50}
110same as 105 but only from 2025+46% total, worst drop -31%, money in 17%, by year {'2025': 4, '2026': 40}
111same as 100 but only from 2025+31% total, worst drop -24%, money in 16%, by year {'2025': 5, '2026': 25}
112live CoinMarketCap baskets vs independent rebuild from daily snapshots9 of 9 match (daily return correlation 0.996 to 1.000)
113majors basket (OKX) vs CoinMarketCap nativesFAIL: correlation 0.01, the OKX closes were filed one day late
114BTC: CoinMarketCap vs OKX closesFAIL same cause: matched to 0.05% only when OKX is keyed by its candle open day
115the provisional closeBUG: it was taking live prices at about 07:30 Bangkok for TODAY's label, but CoinMarketCap's label D is the close at the END of day D; fixed to fill yesterday's label
116Robinhood index daily labelsBUG: filed one day late (+1); fixed; rebuilt vs official correlation -0.27 before, +0.35 after
117Ink, Arc, Bankr, o1 4 hour baskets daily labelssame one day fix
118weekly and monthly closes for CoinMarketCap basketsBUG: each candle ended one day early; fixed; 7 of 68 labels changed
119SOL/BTC froth checksame one day shift; after fix correlation 0.999
120after the fix: majors 0.94, BTC 0.9997, SOL/BTC 0.999PASS
121research backtestsunaffected: they used CoinMarketCap data on one clock
122stablecoin inflow 7 days vs basket returns (Solana, Base, TON 2024-26)no consistent edge; mixed by chain and period
123stablecoin inflow 30 daysno consistent edge
124DEX volume rising vs fallingSolana 2024-25 rising better, 2025-26 reversed: no edge
125closes labels after the date fix7 of 68 changed
126page groups equal the feed groupsPASS
127card words equal the feed wordsPASS
128no risk off card shown greenPASS
129Robinhood section words equal the Baskets sectionPASS (was contradicting: HOLD vs WAIT, MONEY COMING IN vs MONEY LEAVING)
130every basket has a readPASS

Read this first. Update 3 Oct 2026: rule v4, phases, a Robinhood system, cleaner dashboard (70 iterations)

Ben's ask: explain "untested" and find a system to assess Robinhood; group the weekly and monthly closes by chain with plain titles ("Solana closed bullish", "Memes closed bullish, except Robinhood"); replace the overview with four groups (rotate into, hold, rotate out of, wait); remove card texts behind a ? button; drop the 4 hour record cards; Robinhood order Robinhood 30, Robinhood 10, then sectors by market cap; fix risk off cards shown in green; and define phases: BUY (initial), HOLD, HOLD HALF, RISK OFF. 50 iterations requested, 70 run, informed by every Pro Trading Cowork ledger (shared hub, macro direction study, bottom lab, price predict, buy alert).

All 70 iterations (trades, $ won per $1 lost: all / 2023-24 / 2025-26)

#TestResult
1baseline v3 (30/15, BTC100+majors, half at hot)118 trades, avg +8.1%, 2.65 / 3.76 / 1.39
2no half (just 15d low exit)118 trades, avg +11.9%, 3.0 / 5.15 / 0.72
3buy 1 day late118 trades, avg +7.6%, 2.42 / 3.85 / 1.02
4buy 2 days late117 trades, avg +5.8%, 2.06 / 3.14 / 0.96
5buy 3 days late116 trades, avg +6.0%, 2.08 / 3.28 / 0.93
6buy 5 days late114 trades, avg +2.7%, 1.41 / 2.88 / 0.55
7buy 8 days late110 trades, avg +2.0%, 1.29 / 1.87 / 0.67
8buy 13 days late89 trades, avg +1.5%, 1.23 / 1.75 / 0.61
9only if <=10% above breakout level117 trades, avg +7.8%, 2.52 / 3.87 / 1.19
10only if <=20% above breakout level118 trades, avg +8.0%, 2.58 / 3.58 / 1.39
11half at day 8 (breakout clock)118 trades, avg +9.5%, 3.22 / 5.42 / 1.0
12half at first of hot or day 8118 trades, avg +7.6%, 2.93 / 4.66 / 1.06
13all out day 13187 trades, avg +7.6%, 2.49 / 3.27 / 1.47
14hold 5 days only294 trades, avg +3.6%, 2.01 / 2.02 / 2.01
15half hot, rest 15d low, max 55d121 trades, avg +8.8%, 2.72 / 3.75 / 1.41
16exit at 10d low144 trades, avg +7.0%, 2.61 / 3.37 / 1.62
17exit at 20d low109 trades, avg +7.5%, 2.32 / 3.17 / 1.29
18half hot, then trail 7d low143 trades, avg +6.7%, 2.35 / 2.89 / 1.69
19half at first of hot/day8, then trail 7d low158 trades, avg +5.8%, 2.65 / 3.86 / 1.4
20gate BTC > 50d instead of 100d143 trades, avg +5.4%, 1.93 / 3.25 / 0.92
21gate BTC > 20d140 trades, avg +5.6%, 1.97 / 3.46 / 0.92
22BTC gate only, no majors gate129 trades, avg +7.4%, 2.4 / 3.97 / 1.1
23no market gate at all168 trades, avg +4.6%, 1.75 / 3.53 / 0.76
24skip if basket +100% in 30d (froth)115 trades, avg +7.5%, 2.48 / 3.47 / 1.37
25skip if basket +60% in 30d (froth)114 trades, avg +6.8%, 2.36 / 3.18 / 1.39
26skip Solana if SOL/BTC +20% in 4w111 trades, avg +9.4%, 3.19 / 4.25 / 1.83
27need 25% of other baskets on113 trades, avg +9.0%, 3.03 / 4.13 / 1.69
28need 50% of other baskets on103 trades, avg +7.8%, 2.74 / 2.83 / 2.52
29cooldown 5 days after a sell118 trades, avg +7.3%, 2.36 / 3.56 / 1.16
30need 2 closes above 30d high95 trades, avg +8.1%, 2.64 / 4.05 / 1.13
31hot = z>=2.5118 trades, avg +10.2%, 2.86 / 4.59 / 1.06
322% costs118 trades, avg +7.1%, 2.33 / 3.37 / 1.17
3320/10 windows158 trades, avg +8.2%, 3.02 / 4.24 / 1.61
3450/20 windows91 trades, avg +8.1%, 2.46 / 3.62 / 1.14
3526+27: SOL/BTC froth skip + 25% breadth109 trades, avg +9.4%, 3.2 / 4.29 / 1.83
3635 + skip if +60% in 30d105 trades, avg +8.2%, 2.88 / 3.67 / 1.84
3735 + trail 7d low after half128 trades, avg +8.0%, 2.93 / 3.85 / 2.02
3835 + all out day 13171 trades, avg +7.9%, 2.73 / 3.59 / 1.68
3935 + entry <=20% above breakout108 trades, avg +9.3%, 3.09 / 4.05 / 1.82
4035 with 20/10 windows143 trades, avg +8.6%, 3.28 / 4.57 / 1.84
4135, SOL/BTC threshold 15%108 trades, avg +7.7%, 2.83 / 3.69 / 1.83
4235, SOL/BTC threshold 25%110 trades, avg +9.5%, 3.25 / 4.39 / 1.83
4335, breadth 15%110 trades, avg +9.4%, 3.13 / 4.11 / 1.83
4435, breadth 35%107 trades, avg +8.9%, 3.04 / 3.73 / 1.99
4535 + 1 day late entry109 trades, avg +8.2%, 2.65 / 4.09 / 1.21
4635 + 2% cost109 trades, avg +8.4%, 2.8 / 3.86 / 1.51
47base rule, 3 strongest 30drotation: +281% total, worst -40%, money in 25%
48new rule (35), 3 strongest 30drotation: +389% total, worst -37%, money in 24%
49new rule, 3 freshest (newest risk on)rotation: +275% total, worst -36%, money in 26%
50new rule, money only on BUY day, hold till offrotation: +364% total, worst -24%, money in 21%
51new rule, 3 strongest, max 2 per chainrotation: +390% total, worst -37%, money in 24%
52new rule, 2 strongestrotation: +456% total, worst -37%, money in 26%
53new rule, 4 strongestrotation: +350% total, worst -32%, money in 22%
54fast 14/7 (with 35 filters)181 trades, avg +5.3%, 2.39 / 3.38 / 1.41
55fast 10/5204 trades, avg +5.7%, 2.69 / 3.14 / 2.1
56fast 7/4271 trades, avg +3.4%, 1.79 / 2.67 / 1.11
57fast 10/7187 trades, avg +5.5%, 2.48 / 3.37 / 1.55
58fast 14/7, no half181 trades, avg +7.0%, 2.58 / 4.3 / 1.03
59equal weight reset daily, top 15 (current)60 trades, avg +12.9%, 4.03 / 5.29 / 1.87
60weekly drift (like Robinhood Indices), top 1556 trades, avg +13.2%, 4.08 / 4.53 / 2.95
61top 1063 trades, avg +9.1%, 2.69 / 4.74 / 0.92
62top 3062 trades, avg +12.2%, 4.09 / 4.53 / 2.89
63Robinhood 30 rebuilt (tracker prices, dead coins kept), slow 30/151 trade: bought 1 Sep near the top, sold 14 Sep, -33.6%
64Robinhood 30 rebuilt, fast 10/51 trade: 24 Aug to 10 Sep, +37.5%
65Robinhood 10 rebuilt, fast 10/52 trades: -13.7%, +5.7%
66Robinhood mid caps (11 to 30) rebuilt, fast 10/51 trade: 21 Aug to 9 Sep, +67.5%
67Robinhood 10 rebuilt, slow 30/151 trade: 4 Sep to 19 Sep, -36.1%
68Solana tracker coins top 30 (Apr to Oct 2026), fast vs slowfast 2 trades -1.5%, -4.3%; slow 1 trade -17.8%
69Official Robinhood Index (1 month), fast 10/51 trade 22 to 25 Sep, -11.6%
70BTC and majors gates on Robinhoodno change: gates were on for every Robinhood signal (RH memes move with BTC only weakly, correlation +0.23)

Read this first. Update 2 Oct 2026, evening: basket rule v3 (risk on / risk off for every basket, and where to rotate)

Ben's ask, in his words: he cares about token PRICES, the tokens are in baskets, and he needs to know when each basket turns bullish or bearish, enters a buy or sell zone, which baskets are risk on or risk off, and which are good rotations. Launchpad activity is out (it never predicted prices). Three iterations were run on every point in time basket we have, informed by the shared trading ledger.

Questions for the reviewer: (1) Is the majors gate real or a fit to 2025? (2) Should rotation rank by 30 day move vs BTC instead of raw 30 day move (similar in the test)? (3) Is taking half when hot better done as a trailing exit? (4) How long should young baskets wait before their rule is trusted: 60 days of live data?

v3 per basket (Jan 2023 to Sep 2026)

CheckTradesAvgMedianWins$ won per $1 lost
sol_pump5+1.4%+0.7%60%1.19
sol_bonk1+5.9%+5.9%100%—
sol_memes10+23.0%+7.0%80%8.18
sol_ai10+5.6%-1.9%50%1.69
sol_defi13+15.8%+1.9%54%3.60
base_memes7+2.0%+7.0%57%1.22
base_ai4-0.0%-6.7%25%1.00
base_defi4+0.8%+0.2%50%1.19
ton_all4-3.2%-3.3%25%0.46
eth_memes15+11.3%+6.2%80%5.58
eth_ai10+3.9%+2.5%60%1.91
bnb_memes15+7.3%+2.8%53%2.60
bnb_ai3+13.1%+5.0%67%4.18
majors17+5.5%+2.8%53%2.68
All 118 v3 trades (basket, entry, half taken, exit, result after 1% cost)
BasketEntryHalfExitResult
sol_pump2025-04-282025-05-092025-05-29+34.7%
sol_pump2025-07-152025-07-31+0.7%
sol_pump2025-09-132025-09-22-22.1%
sol_pump2026-04-162026-05-102026-05-18+9.4%
sol_pump2026-08-202026-09-01-15.6%
sol_bonk2026-05-062026-05-102026-06-02+5.9%
sol_memes2023-12-022023-12-082023-12-27+62.9%
sol_memes2024-01-132024-01-30-25.0%
sol_memes2024-02-282024-02-292024-04-04+82.0%
sol_memes2024-05-232024-05-252024-06-11+1.8%
sol_memes2024-07-192024-07-212024-08-01+3.8%
sol_memes2024-09-242024-10-122024-12-17+78.1%
sol_memes2025-04-242025-05-102025-05-30+20.2%
sol_memes2025-07-132025-07-28-7.0%
sol_memes2026-05-032026-05-042026-05-24+2.8%
sol_memes2026-08-192026-08-212026-09-10+10.3%
sol_ai2024-05-152024-05-212024-06-02+25.1%
sol_ai2024-07-212024-07-30-18.7%
sol_ai2024-09-222024-09-262024-11-02+12.8%
sol_ai2024-11-072024-11-092025-01-12+85.1%
sol_ai2025-04-252025-05-07-11.1%
sol_ai2025-05-082025-05-122025-05-30+6.8%
sol_ai2025-07-152025-07-28-14.5%
sol_ai2025-09-132025-09-22-25.7%
sol_ai2026-04-162026-05-042026-05-16+6.9%
sol_ai2026-08-192026-09-01-10.6%
sol_defi2023-01-142023-01-152023-03-04+1.9%
sol_defi2023-04-162023-04-222023-05-06+4.3%
sol_defi2023-07-132023-07-22-6.4%
sol_defi2023-10-302023-11-012024-01-05+223.1%
sol_defi2024-02-182024-02-192024-03-22+7.5%
sol_defi2024-07-202024-08-02-13.9%
sol_defi2024-09-272024-10-03-16.7%
sol_defi2024-10-272024-11-04-10.5%
sol_defi2024-11-062024-11-072024-12-19+28.7%
sol_defi2025-04-242025-06-04+7.8%
sol_defi2025-07-132025-07-212025-08-01+10.5%
sol_defi2025-09-182025-09-25-17.2%
sol_defi2026-04-262026-05-04-14.2%
base_memes2024-09-252024-10-19+35.5%
base_memes2024-11-152024-11-26-34.9%
base_memes2025-05-082025-05-102025-05-28+21.6%
base_memes2025-07-112025-07-182025-07-29+13.0%
base_memes2025-08-122025-08-29-26.6%
base_memes2026-05-102026-05-142026-05-28-1.5%
base_memes2026-08-192026-08-212026-09-09+7.0%
base_ai2025-04-252025-05-102025-05-30+32.8%
base_ai2025-07-162025-07-30-13.2%
base_ai2025-08-132025-08-25-19.3%
base_ai2026-05-052026-05-062026-05-22-0.3%
base_defi2025-04-252025-05-23+4.7%
base_defi2025-07-132025-07-31-4.3%
base_defi2025-08-122025-08-132025-10-07+15.6%
base_defi2026-05-082026-05-19-12.6%
ton_all2024-11-102024-11-26-17.1%
ton_all2025-05-092025-05-30-2.3%
ton_all2025-07-162025-08-01-4.3%
ton_all2026-05-042026-05-052026-05-20+10.8%
eth_memes2023-01-112023-01-132023-03-03+25.3%
eth_memes2023-04-142023-04-30-14.1%
eth_memes2023-07-212023-08-16+15.5%
eth_memes2023-10-252023-10-262023-11-21+1.6%
eth_memes2023-12-032023-12-042023-12-26+6.9%
eth_memes2024-02-152024-02-182024-03-19+70.7%
eth_memes2024-05-152024-05-212024-06-08+21.5%
eth_memes2024-07-192024-07-30-15.0%
eth_memes2024-09-242024-10-082024-11-02+36.2%
eth_memes2024-11-092024-11-112024-11-26+5.7%
eth_memes2024-12-062024-12-072024-12-17-7.7%
eth_memes2025-04-252025-05-082025-05-30+7.9%
eth_memes2025-07-112025-07-132025-07-31+5.3%
eth_memes2026-04-162026-05-102026-05-16+3.0%
eth_memes2026-08-202026-08-212026-09-15+6.2%
eth_ai2024-03-132024-03-16-22.5%
eth_ai2024-05-202024-05-232024-06-11+24.6%
eth_ai2024-09-202024-10-03-10.7%
eth_ai2024-10-112024-10-122024-10-26-3.4%
eth_ai2024-11-082024-11-102024-11-26+10.6%
eth_ai2025-04-252025-05-102025-05-25+30.4%
eth_ai2025-07-142025-07-162025-07-31+0.9%
eth_ai2025-08-122025-08-132025-08-31-6.6%
eth_ai2026-04-182026-05-16+11.9%
eth_ai2026-08-192026-08-202026-09-06+4.1%
bnb_memes2023-01-162023-01-222023-02-13-2.4%
bnb_memes2023-03-172023-04-01-4.0%
bnb_memes2023-07-182023-07-192023-08-02+8.1%
bnb_memes2023-10-292023-10-302023-11-21+2.8%
bnb_memes2023-11-292023-11-302024-01-03+19.5%
bnb_memes2024-02-172024-02-192024-04-11+102.7%
bnb_memes2024-05-232024-05-252024-06-17+25.2%
bnb_memes2024-07-202024-07-31-22.9%
bnb_memes2024-09-262024-10-17-9.9%
bnb_memes2024-11-232024-11-292024-12-17+4.0%
bnb_memes2025-04-262025-05-05-16.2%
bnb_memes2025-05-092025-05-122025-06-12+4.2%
bnb_memes2025-07-212025-08-05-9.2%
bnb_memes2025-08-122025-08-202025-08-27-4.2%
bnb_memes2026-04-162026-05-012026-05-17+12.6%
bnb_ai2025-07-112025-07-302025-08-25+5.0%
bnb_ai2025-09-172025-09-21-12.4%
bnb_ai2026-04-162026-05-012026-05-17+46.7%
majors2023-01-112023-01-142023-02-13+14.9%
majors2023-03-172023-04-112023-04-21-3.1%
majors2023-05-022023-05-06-14.0%
majors2023-07-132023-07-29-6.7%
majors2023-10-252023-11-102024-01-06+43.4%
majors2024-01-112024-01-23-13.8%
majors2024-02-072024-02-092024-04-12+47.0%
majors2024-05-152024-05-17-7.8%
majors2024-05-182024-05-192024-06-04+4.4%
majors2024-07-192024-07-31-7.5%
majors2024-09-202024-11-04-0.2%
majors2024-11-062024-11-072024-12-19+18.3%
majors2025-04-242025-05-082025-05-30+7.1%
majors2025-07-112025-07-122025-08-01+2.8%
majors2025-08-122025-08-132025-09-25-2.5%
majors2026-04-162026-05-052026-05-22+3.1%
majors2026-08-192026-08-232026-09-15+8.3%

Update 2 Oct 2026: trenches corrected, candle closes added

Update 1 Oct 2026: the bigger test did not confirm the rule

Read this first

1 · The page, section by section

SectionWhat it showsSource
What to do nowThe v2 call (HOLD BTC, BUY ALTS, CASH), holdings, v2 forward record since the freeze, and a one line chain basket summary.rotmap.json, chains.json
Chain basketsHeadline word, rule line, closest to a buy, scorecard (live, shadow, past test, robustness table), then cards grouped by chain with a word, stage, the four checks, returns, past record, open trade, chart, flags and every member contract.chains.json
1 · Risk switchBTC vs its 100 day average: red OFF zone, grey hold strip (±1%), green ON zone, BTC marker.rotmap.json
2 · Broad alts vs BTC90 day and 30 day breadth of the 64 coin v2 set (white, orange) with washout, top and turn zones; blue rings for the fair point in time reading; the three v2 entry checks.rotmap.json, chains.json fair_clock
3 · Rotation stripv2 baskets (BTC, ETH, SOL, MAJORS, DEFI, AI, MEMES), launchpad activity, and the Robinhood indices as context cards.rotmap.json, rh_drill.json
4 · HistoryBreadth since 2020 with where the system sat, $1 system vs $1 BTC, Robinhood indices since launch (tap a name to isolate). Tap or hover any chart for a date readout.rotmap.json, rh_drill.json
5 to 8Report card of major rotations, past results, what each colour means with evidence grades, the system's last moves.rotmap.json
9 · Robinhood Chain tokensRotation vs BTC/ETH/SOL, money on chain (stablecoins, value locked, DEX volume), basket cards with temperature and flow checks.rh_drill.json

Mobile: every chart redraws on resize, uses 11 px labels under 520 px width, and has a tap readout. Checked at 390 px and 1280 px, all fetch orders, no errors, no sideways scroll.

2 · The 13 changes made on 30 Sep 2026, plus Ink (14)

#ChangeFromWhat exactlyEvidence
1BUY only for meme baskets, on a break of the 10 day high, plus a SELL ruleTop 3 #1BUY and SELL words now go only to Pump.fun coins, Bonk.fun coins, Solana older memes and Base memes. BUY = first close above the prior 10 day high after a 90 day low in the last 60 days, back above a rising 20 day trend, BTC risk switch on. SELL at the first of: close below the 20 day trend, -12% from entry, basket turns HOT, or 14 days. Other baskets are context: WATCH, HOLD or AVOID only.Old rule (all baskets, 30 day break, hold 14, one trade at a time): 48 trades, +1.1% avg, $1.14 per $1 lost. New rule: 34 trades, +7.1% avg, 59% wins, $2.55 per $1 lost, after 1% costs.
2Live scorecard with a frozen trade ledgerTop 3 #2Every trade from 30 Sep 2026 on is written once into the engine state (state.json: ledger) and never rewritten after it closes. The page shows live results, open trades, the 4 hour shadow results, and the past test side by side.Starts empty; the first real evidence.
34 hour shadow basketsTop 3 #3The same top 8 coins of each meme basket, priced every 4 hours from their deepest DEX pool (GeckoTerminal), plus the three Robinhood meme indices (Pure Memes, Stock Memes, Long.xyz). Same rule on 4 hour bars. Recorded as SHADOW trades only, to learn whether a faster read catches turns earlier.Untested by design; judge after about 30 shadow trades.
4Every number after costs, with timing and cost stress shownTop 10All trade results subtract 1% round trip. The page table also shows 2% costs and buying one day late.2% costs: +6.1% avg. One day late: +3.3% avg, $1.69 per $1 lost. Speed matters.
5Robustness grid and walk forward checkTop 1027 combinations of break (10, 20, 30 days), stop (8, 12, 16%) and max hold (7, 14, 21 days). Walk forward: pick the best combination and the basket type using only data before Jul 2025, then test on Jul 2025 to Sep 2026.All 27 combinations profitable ($2.41 to $5.51 per $1 lost). Memes first half $3.80 vs non memes $1.20; second half memes $2.04 vs non memes $0.89. Best first half combination (break 30, stop 8%, hold 21) made $2.90 per $1 lost in the second half.
6Telegram alert on every BUY and SELLTop 10One message per event for the four traded baskets, sent by the watchdog bot (BUY, SELL, and CANCEL if a provisional BUY is not confirmed). Shadow trades do not alert. Timing fix: CoinMarketCap's own daily file for a day can arrive up to a day late, so after 00:20 UTC the engine takes live prices as a provisional close and swaps in the official file when it arrives.Without the timing fix the real entry is the next close: +3.3% avg instead of +7.1%.
7Closest to a buy, and a live previewTop 10A list of traded baskets that bottomed and are back above trend, sorted by how far they are from the break level, and a flag when live prices are already above it (a BUY if the day closes there).Display only.
8Past record on every card, and past trades shaded on the chartsTop 10Each traded card shows its own past BUY record; each chart shades past trades green (won) or red (lost).Pump.fun is the weakest (7 trades, +1.3% avg); Solana older memes the steadiest (80% wins).
9Robinhood point in time meme basketTop 10A daily Robinhood meme basket rebuilt from CoinMarketCap's own daily listings (coins as they were each day, dead ones count). Independent of the Codex Robinhood Indices roster. Context only for now.Only about 60 days of history (listings start late Jul 2026): 1 past signal, a -13% stop out. Too short to trade.
10Fair breadth reading for Rotation v2 (display only)Top 10Next to the 90 day and 30 day readings (64 surviving coins), a blue ring shows the same reading on the top 100 coins as they were 90 or 30 days earlier, so dead coins count as losers. Daily from CoinMarketCap top 1,000 listings.Fair 90 day about 51% vs 59%; fair 30 day about 75% vs 94% at build time. The survivor set flatters the 30 day reading by about 19 points. No fair washout since Aug 2025. The v2 book is unchanged.
11v2 forward record since the freezeTop 10Under What to do now: system vs BTC since v2 was frozen on 28 Sep 2026, and the lockbox line (read no earlier than 28 Sep 2027).Honest running score of the frozen rules.
12Risk switch history inside the chain engineTop 10The chain engine rebuilds the BTC risk switch for every past day (100 day average, 1% band, keeps its last state in between) from CoinMarketCap daily closes, so each past and live trade uses the switch as it was that day.Meme buys with the switch off: 23 trades, -0.7% avg, 26% wins.
13Overlap flagsTop 10A card says when more than half of its contracts sit in another basket (for example Solana AI and Pump.fun share FARTCOIN, GOAT and others; Stock Memes and Long.xyz share 8 of 10).Stops one move being counted twice.
14Ink (Kraken's chain) basketsBen's request during the buildTwo 4 hour baskets from GeckoTerminal: Ink top coins (the 10 deepest non-stable coins: OTO, ANITA, HKT, BEAST, OCTO, KRAKMASK, PURPLE, BERT, INKCAT, ICAT at build time) and Ink stock tokens (wQQQx, wMSTRx, wNVDAx, wAAPLx, wSPCXx, wSPYx, wPLTRx, wTSLAx, wNFLXx). Context only.Correction: an earlier note said Ink has no meme market. It has a small one: pools of $20k to $135k (OTO $2.9M but almost no trading). Untested; history before the build uses today's coins.

3 · Rules, exactly

Chain basket rule (chain-2), in rule2.py

Rotation v2 (frozen)

BTC 100 day average with a 1% band as the risk switch; alt entry needs a 90 day breadth washout (≤ 5%) within 60 days, 30 day breadth ≥ 40%, risk on; a basket top is 90 day breadth ≥ 70% within 30 days then 30 day breadth < 50%. Full framework, tests and code: v2 handover.

Fair breadth reading

fairclock.py: for day t, the universe is the top 100 coins by market cap on day t-90 (or t-30), excluding BTC, stablecoins and wrapped tokens. Reading = share whose price return to day t beat BTC's; a coin missing from day t's top 1,000 counts as a loser.

4 · Evidence

CheckTradesAvgMedianWins$ won per $1 lost
All traded baskets34+7.1%+7.4%59%2.55
Base memes10+10.7%+12.6%60%3.10
Bonk.fun coins7+6.3%-4.8%43%1.95
Solana older memes10+8.1%+8.9%80%5.99
Pump.fun coins7+1.3%-1.8%43%1.21
First half (signals to Jun 2025)13+9.6%+9.8%77%3.80
Second half (Jul 2025 on)21+5.5%-1.8%48%2.04
Non meme baskets, same rule44+0.1%-4.4%39%1.03

Stress and variants

CheckTradesAvgMedianWins$ won per $1 lost
enter next close33+3.3%+2.4%61%1.69
no costs34+8.1%+8.4%59%2.94
2% round trip34+6.1%+6.4%59%2.22
no sell rule, hold 14 days29+2.4%+2.2%59%1.30
break of 30 bar high23+11.4%+14.0%74%3.78
stale price filter33+7.7%+6.7%55%2.64
members need $100k volume36+3.8%+2.4%56%1.67
missing coin counted as -100%34+6.9%+7.4%59%2.45
old rule: all baskets, 30 bar break, hold 1448+1.1%+0.6%50%1.14

Walk forward

CheckTradesAvgMedianWins$ won per $1 lost
Memes, first half13+9.6%+9.8%77%3.80
Non memes, first half22+0.9%-2.3%41%1.20
Memes, second half21+5.5%-1.8%48%2.04
Non memes, second half22-0.6%-5.9%36%0.89
Best first half combination (break 30, stop 8%, hold 21) in the second half14+8.2%+9.7%64%2.90

4b · Bigger test, 1 Oct 2026 (Sep 2022 to Sep 2026, 13 meme baskets)

Same rule2 code and defaults. A = traded baskets 2024 to 2026 (in sample). B = same baskets before 2024. C = other chains (out of sample). Snapshots: every coin CoinMarketCap listed each day, meme tagged coins kept for all chains. Script bt_wide.py.

CheckTradesAvgMedianWins$ won per $1 lost
A in sample33+7.8%+8.0%64%2.91
B old years2+34.9%+34.9%50%3.99
C new chains56-3.3%-5.5%36%0.57
out of sample (B+C)58-2.0%-5.5%36%0.75
everything91+1.6%-3.6%46%1.24
CheckTradesAvgMedianWins$ won per $1 lost
Pump.fun (from 2024-11-30, about 15 coins)7+1.3%-1.8%43%1.21
Bonk.fun (from 2025-07-08, about 9.4 coins)7+6.5%+3.5%57%2.16
Solana older memes (from 2023-11-02, about 14.0 coins)12+12.6%+8.9%75%4.81
Base memes (from 2024-05-31, about 11.6 coins)9+13.6%+15.0%67%4.35
Ethereum memes (from 2022-09-02, about 13.8 coins)19-3.0%-5.4%42%0.60
BNB memes (from 2022-09-02, about 10.9 coins)16-1.3%-5.0%38%0.77
Sui memes (from 2025-01-14, about 4.0 coins)2+4.4%+4.4%50%1.50
Tron memes (from 2024-08-29, about 5.7 coins)6-10.2%-15.7%17%0.32
Avalanche memes (from 2024-09-20, about 4.3 coins)4-4.3%-4.6%50%0.44
TON memes (from 2024-09-28, about 4.4 coins)8-2.7%-4.1%25%0.44
Robinhood memes (from 2026-08-01, about 12.7 coins)1-13.1%-13.1%0%—
CheckTradesAvgMedianWins$ won per $1 lost
Year 202315+3.9%-4.6%40%1.61
Year 202418+1.0%-2.6%50%1.16
Year 202540-2.0%-7.5%40%0.75
Year 202618+8.2%+4.7%61%3.78

Per week (trades starting in the same ISO week averaged): all 46 weeks, -0.2% average, 37% winning weeks, $0.98 per $1 lost. Out of sample only: 38 weeks, -3.1%, $0.61.

4c · Trenches test, 2 Oct 2026 (Apr 2024 to Sep 2026)

trench_test.py rebuilds the old Rotation Watch word for every day from rot.db (DefiLlama launchpad and DEX volume) with BTC from CoinMarketCap daily closes, then measures (a) launchpad volume 14 days later and (b) the Solana and Base meme baskets (point in time, dead coins included) 14 and 30 days later.

Trigger (first day)EventsLaunchpad volume 14d laterMeme baskets 14d later
BTC +10% in 7 days16up 75%, median +15.0%up 50%, median -1.0%
BTC -10% in 7 days17up 47%, median -14.2%up 65%, median +6.2%
Trench share 30% above its 30 day low39up 62%, median +14.3%up 46%, median -0.9%
Trench share 30% below its 30 day high38up 50%, median -1.2%up 32%, median -6.7%
Any day (all days, not events)898up 52%, median +1.9%up 40%, median -3.8%

All days by the old word: BUY TRENCHES 550 days, meme baskets 14 days later median -4.0%, 41% up; HOLD 97 days, -3.7%, 39%; STAY OUT 247 days, -3.7%, 37%. Verdict: the old word described activity correctly and prices not at all.

Grid (break, stop, max hold)

BreakStopHoldTradesAvg$ per $1 lost
108%739+7.0%3.19
108%1434+7.6%2.89
108%2130+11.8%3.84
1012%738+7.4%3.19
1012%1434+7.1%2.55
1012%2130+11.3%3.46
1016%738+7.4%3.16
1016%1434+6.8%2.41
1016%2130+11.1%3.32
208%734+7.1%3.28
208%1429+9.5%3.91
208%2125+14.8%5.51
2012%732+8.5%4.18
2012%1428+9.3%3.36
2012%2124+15.0%4.88
2016%732+8.5%4.13
2016%1428+9.0%3.11
2016%2124+14.7%4.59
308%730+6.3%3.39
308%1424+11.6%4.51
308%2122+13.8%5.19
3012%728+7.9%4.64
3012%1423+11.4%3.78
3012%2121+14.0%4.53
3016%728+7.8%4.57
3016%1423+11.0%3.45
3016%2121+13.7%4.23

Tested and not adopted

IdeaResult
Stale price filter (member must have moved in 3 days)33 trades, +7.7% avg, $2.64 per $1 lost. Marginal (+0.1 on $ per $1 lost); not adopted, listed for a later review.
Members need $100k daily volume36 trades, +3.8% avg, $1.67 per $1 lost. Worse; not adopted.
Buy only the 3 strongest coins in the basketEarlier 14 day hold test: raised the average but the typical trade fell to about 0%; not adopted.
Rising volume as a filterWorse in the earlier 14 day hold test: signals with volume already rising averaged -1.5% vs +9.1%.
Chain coin trend gate (SOL, ETH, TON above 50 day average)Earlier 14 day hold test: no help (72 of 80 signals already passed it).
Earlier entry at TURNING UPEarlier 14 day hold test: lost money (-3.3% avg per trade).
No sell rule (hold 14 days)29 trades, +2.4% avg, $1.30 per $1 lost. Much worse: the sell rule is most of the edge.
All 34 past trades
BasketSignalExitResultWhyDays
Base memes2024-09-252024-10-09+40.4%day 1414
Base memes2024-10-142024-10-18-14.7%stop4
Base memes2025-04-232025-04-29-11.1%trend6
Base memes2025-05-082025-05-10+27.2%hot2
Base memes2025-07-092025-07-18+44.1%hot9
Base memes2025-07-202025-07-23-14.4%stop3
Base memes2025-08-082025-08-09+10.2%hot1
Base memes2025-10-062025-10-07-11.0%trend1
Base memes2026-05-092026-05-14+15.0%hot5
Base memes2026-08-192026-08-21+21.8%hot2
Bonk.fun coins2025-10-012025-10-10-22.3%stop9
Bonk.fun coins2026-05-022026-05-03-4.8%trend1
Bonk.fun coins2026-05-042026-05-18+27.6%day 1414
Bonk.fun coins2026-05-192026-05-28-6.5%trend9
Bonk.fun coins2026-08-192026-08-22+14.0%hot3
Bonk.fun coins2026-08-242026-09-07+48.3%hot14
Bonk.fun coins2026-09-192026-09-28-12.4%trend9
Solana older memes2024-07-192024-07-21+19.1%hot2
Solana older memes2024-09-192024-10-03+8.0%trend14
Solana older memes2024-10-112024-10-12+6.7%hot1
Solana older memes2024-10-182024-11-01+12.0%day 1414
Solana older memes2025-04-222025-05-06+9.8%day 1414
Solana older memes2025-05-082025-05-10+18.9%hot2
Solana older memes2025-08-102025-08-11-9.3%trend1
Solana older memes2026-05-012026-05-04+3.0%hot3
Solana older memes2026-08-192026-08-21+19.7%hot2
Solana older memes2026-09-212026-09-28-6.9%trend7
Pump.fun coins2025-01-042025-01-07-18.9%stop3
Pump.fun coins2025-04-222025-05-06+1.3%day 1414
Pump.fun coins2025-05-082025-05-09+26.6%hot1
Pump.fun coins2025-07-202025-07-31-10.8%trend11
Pump.fun coins2025-10-032025-10-09-10.9%trend6
Pump.fun coins2026-05-012026-05-10+23.4%hot9
Pump.fun coins2026-08-192026-08-30-1.8%trend11

5 · Operations

JobWhen (Bangkok)WritesNotes
rotmap.py (frozen v2)daily after the UTC close/opt/launchwatch/pub/rotmap.jsonnever edit; lockbox
robinhood-indices collector (Codex)hourly at :05/opt/launchwatch/pub/robinhood-indices/market.jsonnot ours; do not touch
rh_drill.pyhourly at :15/opt/launchwatch/pub/rh_drill.jsonreads market.json, DefiLlama, DexScreener
chains.py (chains-4: rule v4 phases + old chain-2 record; 4 hour shadow copies retired)hourly at :35, flock (07:35 is the daily call, provisional close from live prices)/opt/launchwatch/pub/chains.json and /opt/rotation/chains/state.jsonCoinMarketCap daily snapshot (after 07:00), CoinMarketCap live, GeckoTerminal 4 hour bars (about 4 s spacing, 429 back off), Telegram alerts
rotation.py (Rotation Watch)hourly at :20/opt/launchwatch/pub/rotation.jsonprice word read from chains.json v4 (BUY = rotate into, HOLD, SELL = rotate out of, NO BUY); activity fields still written but no longer shown; Telegram muted by /opt/rotation/QUIET
closes.pyhourly at :05, flock/opt/launchwatch/pub/closes.jsonOKX 1Wutc, 1Mutc, 1Dutc candles for 10 majors; chain indices from state.json and rh_drill.json; Telegram heads up, 1 hour warning, result
chains stale.pyhourly at :50Telegramone message when chains.json is 3 h old or the daily step is 2+ days behind, one when it recovers

Server: DigitalOcean droplet 159.223.88.168, files in /opt/rotation/chains (engine, state.json, last_snap.json, top/ daily top 1,000 files). The page is /opt/launchwatch/pub/rotation.html served by /opt/launchwatch/serve.py. Secrets live only in /opt/watchdog/config.json (chmod 600); never print them.

chains.json fields

version, rule{brk,stop,maxh,cost,tradable}, generated (unix), cmc_day, dead[], risk{...from rotmap}, risk_cmc (engine's own switch),
scorecard{live, live_open[], shadow, shadow_open[], ledger[], since}, evidence2{...section 4}, closest[[key,name,to_break_pct,would_break_live]],
fair_clock{now[day,c90,c30], series[], last_washout}, rank[keys], chains_order[], not_built[[name,why]],
baskets[]: key, chain, name, what, source, freq(1d|4h), stage, since, word, tone, why, checks[[text,bool]], z, off_high, up_from_low,
  to_break, ready, ret_1d, ret_7d, ret_30d, live_vs_close, would_break_live, n, members[[symbol, contract]], line[[unix, level]],
  flags[young|roster|short|shadow], tradable, shadow_of, trades[] (live), record (past stats), open (current trade), overlap[[key,shared,of]]

6 · Known limits

7 · How to reproduce

  1. fetch_daily.py 2024-01-01 2026-09-29 (CoinMarketCap historical listings, two pages a day, filtered to Solana, Base, TON, Arc, Ink, Robinhood Chain plus BTC, ETH, SOL, TON).
  2. bt_final.py writes evidence2.json (uses rule2.py, the same code the live engine runs).
  3. fetch_top.py and fairclock.py for the fair reading.
  4. migrate2.py seeds the live state; chains.py runs hourly.

8 · 30 possible changes, with status

#AreaChangeStatus
1Rotation v2Fair point in time breadth reading beside the survivor readingDONE (#10)
2Rotation v2Forward record since the freeze, with the lockbox dateDONE (#11)
3Rotation v2Register a v3 shadow book that uses the fair reading, run forward only (keeps the v2 lockbox clean)PROPOSED next
4Rotation v2Rebuild the 64 coin set point in time for the book itself (only as v3; changing v2 breaks the lockbox)PROPOSED, needs Ben's call
5Rotation v2Daily points on the history chart instead of weeklyPROPOSED
6Rotation v2Show the BTC only switch as the headline baseline beside the systemPARTLY (section 6 table)
7Rotation v2Crowding (funding) as a warningREJECTED earlier (28 Sep test)
8Chain basketsBUY only for meme baskets, 10 day breakDONE (#1)
9Chain basketsSELL rule: 20 day trend, -12%, hot, 14 daysDONE (#1)
10Chain basketsLive scorecard with frozen ledgerDONE (#2)
11Chain baskets4 hour shadow basketsDONE (#3)
12Chain basketsCosts and late entry in every numberDONE (#4)
13Chain basketsRobustness grid and walk forwardDONE (#5)
14Chain basketsTelegram BUY and SELL alertsDONE (#6)
15Chain basketsClosest to a buy plus live previewDONE (#7)
16Chain basketsPast record per card, trades shaded on chartsDONE (#8)
17Chain basketsRisk switch history inside the engineDONE (#12)
18Chain basketsOverlap flagsDONE (#13)
19Chain basketsStale price filterTESTED, marginal, not adopted
20Chain baskets$100k liquidity floorTESTED, worse
21Chain basketsBuy the 3 strongest coins instead of the basketTESTED, worse typical trade
22Chain basketsRising volume filterTESTED, worse
23Chain basketsChain coin trend gateTESTED, no help
24SolanaSplit Pump.fun coins by age (new graduates vs the old big names); it is the weakest traded basketPROPOSED next
25SolanaBags and Believe baskets once their graduated pools pass $50kWAITING for volume
26RobinhoodPoint in time meme basket from CoinMarketCapDONE (#9)
27RobinhoodSame rule on the official meme indices, as shadowDONE (inside #3)
28RobinhoodBacktest the flow checks (buy share, pool growth) once 60 days of hourly history existSCHEDULED for late Nov 2026
29BaseWiden Base memes (only 8 to 10 coins with a market cap) with a daily DEX roster of Bankr and Clanker coins, recorded point in time from nowPROPOSED next
30BasePromote the Bankr and o1 4 hour baskets to shadow trades once they have 30 days of live historyPROPOSED

9 · Questions for the reviewer

  1. Is a 34 trade sample with a walk forward check enough to show a BUY word, or should it stay a shadow until the live scorecard reaches 15 trades?
  2. The second half is weaker (median about 0%). Is that decay, or noise at 20 trades?
  3. Should the break be 30 days (fewer, better trades: 23 trades, +11.4% avg) rather than 10?
  4. Is counting a coin that vanishes as -50% (tested also at -100%) reasonable?
  5. What would you add to make the Robinhood and Base baskets testable sooner?

10 · Full code

The page source is public at the dashboard URL (view source). Engine and test code:

it100/eng2.py (91 lines)
"""Iteration engine round 2 (71 to 120): extended definitions, exits, risk off states, entry ranking."""
import json, math, statistics as S, collections, datetime as dt
import eng
from eng import SERIES, STAGES, KEYS, CHAIN, MAJ_ON, BTC, BMA, SOLBTC, ON_ALL, stats, weekly, SPLIT, gate_btc
V = json.load(open('bk_vol.json'))


def rsi(L, n=14):
    out = [None] * len(L); g = l = 0.0
    for i in range(1, len(L)):
        ch = math.log(L[i] / L[i - 1])
        if i <= n: g += max(ch, 0); l += max(-ch, 0)
        if i == n: g /= n; l /= n
        if i > n: g = (g * (n - 1) + max(ch, 0)) / n; l = (l * (n - 1) + max(-ch, 0)) / n
        if i >= n: out[i] = 100 - 100 / (1 + g / l) if l > 0 else 100
    return out


RSI = {k: rsi(SERIES[k][1]) for k in KEYS}
SMA20 = {k: [None if i < 19 else sum(SERIES[k][1][i - 19:i + 1]) / 20 for i in range(len(SERIES[k][1]))] for k in KEYS}


def extended(k, j, ent_i, rule, x):
    ds, L = SERIES[k]
    if rule == 'hot': return STAGES[k][j][0] == 'HOT'
    if rule == 'ma20': return SMA20[k][j] and L[j] / SMA20[k][j] - 1 >= x
    if rule == 'r30': return j >= 30 and L[j] / L[j - 30] - 1 >= x
    if rule == 'rsi': return RSI[k][j] is not None and RSI[k][j] >= x
    if rule == 'gain': return L[j] / L[ent_i] - 1 >= x
    if rule == 'days': return j - ent_i >= x
    return False


def gates(k, d, breadth=0.25, froth=0.20):
    if not gate_btc(d, '100'): return False
    if k != 'majors' and not MAJ_ON[(30, 15)].get(d): return False
    if froth and CHAIN[k] == 'Solana':
        d0 = (dt.date.fromisoformat(d) - dt.timedelta(28)).isoformat()
        if d in SOLBTC and d0 in SOLBTC and SOLBTC[d] / SOLBTC[d0] - 1 >= froth: return False
    if breadth:
        oth = [kk for kk in KEYS if kk not in (k, 'majors') and d in ON_ALL[kk]]
        if oth and sum(1 for kk in oth if ON_ALL[kk][d]) / len(oth) < breadth: return False
    return True


def run(k, nin=30, nout=15, ext_rule='hot', ext_x=None, ext_action='half', ext2=None, cost=0.01, breadth=0.25, froth=0.20, delay=0):
    """ext2: optional second extended level -> sell the rest."""
    ds, L = SERIES[k]; out = []; i = nin
    while i < len(L) - 1:
        d = ds[i]
        if not (L[i] > max(L[i - nin:i]) and gates(k, d, breadth, froth)): i += 1; continue
        j0 = i + delay
        if j0 >= len(L) - 1: break
        ent = L[j0]; w = 1.0; acc = 0.0; ex = None; trimmed = False
        for j in range(j0 + 1, len(L)):
            if L[j] < min(L[j - nout:j]): ex = j; break
            if not trimmed and ext_rule and extended(k, j, j0, ext_rule, ext_x):
                if ext_action == 'all': ex = j; break
                acc += 0.5 * (L[j] / ent - 1); w = 0.5; trimmed = True
            elif trimmed and ext2 and extended(k, j, j0, ext2[0], ext2[1]): ex = j; break
        if ex is None: break
        f = feats(k, i)
        out.append(dict(k=k, sig=d, entry=ds[j0], exit=ds[ex], ret=acc + w * (L[ex] / ent - 1) - cost, days=ex - j0, **f))
        i = ex + 1
    return out


def feats(k, i):
    ds, L = SERIES[k]; d = ds[i]
    vol = V['vol'].get(k, {}); dd = [ds[x] for x in range(max(0, i - 28), i + 1)]
    v7 = [vol.get(x) for x in dd[-7:]]; v28 = [vol.get(x) for x in dd]
    v7 = [x for x in v7 if x]; v28 = [x for x in v28 if x]
    b7 = BTC.get(d); d7 = (dt.date.fromisoformat(d) - dt.timedelta(7)).isoformat()
    lo = min(L[max(0, i - 90):i + 1]); hi = max(L[max(0, i - 90):i + 1])
    return dict(f_r30=L[i] / L[i - 30] - 1 if i >= 30 else None, f_r7=L[i] / L[i - 7] - 1 if i >= 7 else None,
                f_vol=(S.mean(v7) / S.mean(v28) - 1) if len(v7) >= 5 and len(v28) >= 20 else None,
                f_btc7=(b7 / BTC[d7] - 1) if b7 and d7 in BTC else None, f_up90=L[i] / lo - 1, f_dd90=L[i] / hi - 1,
                f_rsi=RSI[k][i], f_meme=1 if ('meme' in k or k in ('sol_pump', 'sol_bonk')) else 0,
                f_majors=1 if k == 'majors' else 0)


def evaluate(**kw):
    ts = []
    for k in KEYS: ts += run(k, **kw)
    a = [t for t in ts if t['entry'] < SPLIT]; b = [t for t in ts if t['entry'] >= SPLIT]
    return dict(all=stats(ts), A=stats(a), B=stats(b), wk=weekly(ts), wkB=weekly(b), trades=ts)


def line(name, r):
    def f(o): return "n%3d avg%+5.1f med%+5.1f w%3d pf%5.2f" % (o['n'], o['mean'], o['med'], o['win'], o['pf'])
    return "%-44s ALL %s | 23-24 %s | 25-26 %s | wk %.2f/%.2f" % (name[:44], f(r['all']), f(r['A']), f(r['B']), r['wk']['pf'], r['wkB']['pf'])
it100/it_g.py (27 lines)
import json
from eng2 import *
V2 = [
 (71, "v4 baseline (half at HOT)", {}),
 (72, "extended = 25% above 20d avg, half", dict(ext_rule='ma20', ext_x=0.25)),
 (73, "extended = 40% above 20d avg, half", dict(ext_rule='ma20', ext_x=0.40)),
 (74, "extended = 60% above 20d avg, half", dict(ext_rule='ma20', ext_x=0.60)),
 (75, "extended = +50% in 30 days, half", dict(ext_rule='r30', ext_x=0.50)),
 (76, "extended = +100% in 30 days, half", dict(ext_rule='r30', ext_x=1.00)),
 (77, "extended = RSI 14 >= 80, half", dict(ext_rule='rsi', ext_x=80)),
 (78, "extended = RSI 14 >= 85, half", dict(ext_rule='rsi', ext_x=85)),
 (79, "extended = +30% from entry, half", dict(ext_rule='gain', ext_x=0.30)),
 (80, "extended = +50% from entry, half", dict(ext_rule='gain', ext_x=0.50)),
 (81, "extended = day 8 in trade, half", dict(ext_rule='days', ext_x=8)),
 (82, "HOT but sell ALL", dict(ext_action='all')),
 (83, "RSI>=80 sell ALL", dict(ext_rule='rsi', ext_x=80, ext_action='all')),
 (84, "HOT half, then RSI>=85 sell rest", dict(ext2=('rsi', 85))),
 (85, "HOT half, then +100% 30d sell rest", dict(ext2=('r30', 1.0))),
 (86, "no extended rule at all", dict(ext_rule=None)),
 (87, "HOT half, exit 10d low", dict(nout=10)),
 (88, "RSI>=80 half + 20d low exit", dict(ext_rule='rsi', ext_x=80, nout=20)),
]
R = {}
for i, nm, kw in V2:
    r = evaluate(**kw); R[i] = dict(name=nm, kw={k: v for k, v in kw.items()}, **{k: v for k, v in r.items() if k != 'trades'})
    print("%2d %s" % (i, line(nm, r)), flush=True)
json.dump(R, open('res_g.json', 'w'))
it100/it_h.py (32 lines)
import json, statistics as S, collections
from eng2 import *
from eng import donch
# risk off sub states, every basket-day that is risk off (30/15 donchian off)
rows = collections.defaultdict(lambda: collections.defaultdict(list))
for k in KEYS:
    ds, L = SERIES[k]; on = donch(L, 30, 15)
    for i in range(40, len(L) - 14):
        if on[i]: continue
        lo20 = min(L[i - 19:i + 1]); newlow5 = min(L[i - 4:i + 1]) <= lo20 * 1.0001
        up_at = max(L[i - 29:i + 1]) / L[i] - 1
        st = 'almost bullish (<=5% to turn)' if up_at <= 0.05 else ('falling (new 20d low in last 5d)' if newlow5 else 'basing (no new low 5d+)')
        f = L[i + 14] / L[i] - 1; turned = any(on[i + 1:i + 15])
        per = 'A' if ds[i] < SPLIT else 'B'
        rows[st][per].append((f, turned)); rows[st]['all'].append((f, turned))
print("89-91 risk off states: next 14 days median, % up, % that turned bullish within 14 days")
for st, v in rows.items():
    print(f"  {st:34s}", " | ".join(f"{p}: n{len(x)} med{S.median([a for a,_ in x])*100:+5.1f}% up{sum(1 for a,_ in x if a>0)/len(x)*100:3.0f}% turned{sum(1 for _,b in x if b)/len(x)*100:3.0f}%" for p, x in v.items()))
# entry feature ranking on v4 trades
ts = []
for k in KEYS: ts += run(k)
print("\n92-99 which BUY signals did best (v4 trades, split at median of each feature)")
for f in ('f_r30', 'f_r7', 'f_vol', 'f_btc7', 'f_up90', 'f_dd90', 'f_rsi', 'f_meme'):
    xs = [t for t in ts if t.get(f) is not None]
    m = S.median([t[f] for t in xs])
    for per, lo, hi in (('A', '', SPLIT), ('B', SPLIT, '9')):
        g = [t for t in xs if lo <= t['entry'] < hi]
        hiG = [t['ret'] for t in g if t[f] > m]; loG = [t['ret'] for t in g if t[f] <= m]
        def pf(z):
            p = sum(x for x in z if x > 0); n = -sum(x for x in z if x <= 0); return p / n if n else 9.9
        print(f"  {f:7s} {per} median {m:+.2f}: high n{len(hiG)} avg{S.mean(hiG)*100:+5.1f} pf{pf(hiG):4.2f} | low n{len(loG)} avg{S.mean(loG)*100:+5.1f} pf{pf(loG):4.2f}")
json.dump({st: {p: [len(x), S.median([a for a, _ in x]), sum(1 for a, _ in x if a > 0) / len(x), sum(1 for _, b in x if b) / len(x)] for p, x in v.items()} for st, v in rows.items()}, open('res_h.json', 'w'))
it100/it_i.py (53 lines)
import json, math, collections, statistics as S
from eng2 import *
TS = {k: run(k) for k in KEYS}
RET = {k: {SERIES[k][0][i]: SERIES[k][1][i + 1] / SERIES[k][1][i] - 1 for i in range(len(SERIES[k][1]) - 1)} for k in KEYS}
days = sorted({d for k in KEYS for d in SERIES[k][0]})
# per-day position status for each basket: weight (1, .5 after HOT) and trade record
POS = {k: {} for k in KEYS}; INFO = {k: {} for k in KEYS}
for k in KEYS:
    ds, L = SERIES[k]; idx = {d: i for i, d in enumerate(ds)}
    for t in TS[k]:
        i0, i1 = idx[t['entry']], idx[t['exit']]; cur = 1.0
        for j in range(i0, i1):
            if j > i0 and cur == 1.0 and STAGES[k][j][0] == 'HOT': cur = 0.5
            POS[k][ds[j]] = cur; INFO[k][ds[j]] = t
def score(t, how):
    if how == 'r30': return t['f_r30'] or 0
    if how == 'r7': return t['f_r7'] or 0
    if how == 'vol': return t['f_vol'] or 0
    if how == 'rsi': return t['f_rsi'] or 0
    if how == 'combo': return (t['f_r7'] or 0) + 0.5 * (t['f_vol'] or 0)
    if how == 'meme_r7': return (t['f_r7'] or 0) + 0.2 * t['f_meme']
    return 0
def sim(K=3, how='r30', swap=False, start='2023-01-01'):
    eq = 0; pk = 0; mdd = 0; prev = {}; held = {}; yr = collections.defaultdict(float); ex = []
    for d in days:
        if d < start: continue
        held = {k: v for k, v in held.items() if POS[k].get(d, 0) > 0}
        new = [k for k in KEYS if POS[k].get(d, 0) > 0 and INFO[k][d]['entry'] == d and k not in held]
        new.sort(key=lambda k: -score(INFO[k][d], how))
        for k in new:
            if len(held) < K: held[k] = 1
            elif swap:
                ext = [h for h in held if POS[h].get(d, 0) == 0.5]   # extended (half taken)
                if ext:
                    out = min(ext, key=lambda h: score(INFO[h][d], how)); del held[out]; held[k] = 1
        wt = {k: POS[k][d] / K for k in held if d in RET[k]}
        turn = sum(abs(wt.get(k, 0) - prev.get(k, 0)) for k in set(wt) | set(prev))
        r = sum(x * RET[k][d] for k, x in wt.items()) - turn * 0.005; prev = wt
        lr = math.log(1 + r); eq += lr; yr[d[:4]] += lr; pk = max(pk, eq); mdd = min(mdd, eq - pk); ex.append(sum(wt.values()))
    return dict(total=round((math.exp(eq) - 1) * 100), worst=round((math.exp(mdd) - 1) * 100), money_in=round(S.mean(ex) * 100),
                by_year={y: round((math.exp(v) - 1) * 100) for y, v in sorted(yr.items())})
R = {}
for i, nm, kw in [(100, "BUY days only, 3 slots, rank 30d move (v4)", dict(how='r30')),
                  (101, "rank by 7 day move", dict(how='r7')), (102, "rank by rising volume", dict(how='vol')),
                  (103, "rank by RSI", dict(how='rsi')), (104, "rank by 7d move + volume", dict(how='combo')),
                  (105, "rank 7d move, swap out an extended basket for a fresh BUY", dict(how='r7', swap=True)),
                  (106, "rank 30d, swap extended for fresh BUY", dict(how='r30', swap=True)),
                  (107, "rank 7d move, 2 slots", dict(how='r7', K=2)), (108, "rank 7d move, 4 slots", dict(how='r7', K=4)),
                  (109, "rank 7d move + swap, 2 slots", dict(how='r7', K=2, swap=True)),
                  (110, "same as 105 but only from 2025", dict(how='r7', swap=True, start='2025-01-01')),
                  (111, "same as 100 but only from 2025", dict(how='r30', start='2025-01-01'))]:
    r = sim(**kw); R[i] = dict(name=nm, **r); print(i, nm, r, flush=True)
json.dump(R, open('res_i.json', 'w'))
it100/checks.py (28 lines)
import json, math, statistics as S, datetime as dt, sys
sys.path.insert(0, '/home/claude/live'); import rule4
st = json.load(open('/home/claude/live3/state.json')); ch = json.load(open('/home/claude/live3/chains.json'))
bk = json.load(open('bk_clean.json')); PX = json.load(open('majors_px.json'))
def rets(line): return {line[i][0]: line[i][1] / line[i - 1][1] - 1 for i in range(1, len(line)) if line[i - 1][1]}
def cmp(a, b, name):
    common = sorted(set(a) & set(b))
    if len(common) < 10: print(name, 'too few common days', len(common)); return
    x = [a[d] for d in common]; y = [b[d] for d in common]
    mx, my = S.mean(x), S.mean(y); cov = sum((p - mx) * (q - my) for p, q in zip(x, y)); c = cov / math.sqrt(sum((p - mx) ** 2 for p in x) * sum((q - my) ** 2 for q in y))
    mad = S.median([abs(p - q) for p, q in zip(x, y)])
    print(f"{name:52s} days {len(common):4d} corr {c:.3f} median abs diff {mad*100:.2f} pts")
    return c
R = {}
# 112 live CMC lines vs research rebuild
for k in ('sol_pump', 'sol_bonk', 'sol_memes', 'sol_ai', 'sol_defi', 'base_memes', 'base_ai', 'base_defi', 'ton_all'):
    R['112_' + k] = cmp(rets(st['cmc'][k]['line']), rets(bk[k]['line']), f"112 live {k} vs research rebuild")
# 113 majors OKX vs research majors (CMC natives)
R['113'] = cmp(rets(st['majors3']), rets(bk['majors']['line']), "113 majors basket OKX (live) vs CMC natives (research)")
# 114 BTC CMC vs OKX
okb = st['majors3_px']['BTC']; R['114'] = cmp(rets(sorted(st['btc'].items())), rets(sorted(okb.items())), "114 BTC: CoinMarketCap vs OKX closes")
mism = [d for d in okb if d in st['btc'] and abs(okb[d] / st['btc'][d] - 1) > 0.01]
print('    BTC days off by >1%:', len(mism), mism[:5])
# 119 SOL/BTC live vs research
sb_live = {d: st['majors3_px']['SOL'][d] / okb[d] for d in st['majors3_px']['SOL'] if d in okb}
sb_res = {d: PX['SOL'][d] / PX['BTC'][d] for d in PX['SOL'] if d in PX['BTC']}
R['119'] = cmp(rets(sorted(sb_live.items())), rets(sorted(sb_res.items())), "119 SOL/BTC: OKX (live) vs CMC (research)")
json.dump(R, open('res_checks1.json', 'w'))
it100/volbuild.py (23 lines)
"""Basket member volume (sum of 24h volume of the members) per day, same membership as bk_build."""
import json, glob, os, datetime as dt, sys
sys.path.insert(0, '/home/claude/chains')
from baskets_def import CMC_BASKETS, EXCLUDE, MEME, AI, DEFI
files = sorted(glob.glob('/home/claude/chains/snapB/*.json')); days = [os.path.basename(f)[:10] for f in files]
has = lambda t, s: bool(set(t) & s)
B = {b[0]: (b[1], b[3]) for b in CMC_BASKETS}
P = {"eth": "Ethereum", "bnb": "BNB Smart Chain (BEP20)"}
B['eth_memes'] = (P['eth'], lambda t: has(t, MEME)); B['eth_ai'] = (P['eth'], lambda t: has(t, AI))
B['bnb_memes'] = (P['bnb'], lambda t: has(t, MEME)); B['bnb_ai'] = (P['bnb'], lambda t: has(t, AI))
keys = [k for k in json.load(open('bk_clean.json')) if k not in ('btc', 'majors')]
mem = {k: None for k in keys}; vol = {k: {} for k in keys}; mc = {k: {} for k in keys}
for i, f in enumerate(files):
    d = days[i]; s = json.load(open(f)); byid = {c[0]: c for c in s}
    for k in keys:
        plat, rule = B[k]
        if mem[k] is None or dt.date.fromisoformat(d).weekday() == 0:
            xs = [c for c in s if c[3] == plat and not c[9] and c[1] not in EXCLUDE.get(k, set()) and rule(c[10]) and c[8] >= 25000 and c[6] > 0 and c[7] > 0]
            xs.sort(key=lambda c: -c[7]); m = [c[0] for c in xs[:15]]; mem[k] = m if len(m) >= 4 else None
        if mem[k]:
            v = [byid[c][8] for c in mem[k] if c in byid]; m2 = [byid[c][7] for c in mem[k] if c in byid]
            vol[k][d] = sum(v); mc[k][d] = sum(m2)
json.dump({'vol': vol, 'mc': mc}, open('bk_vol.json', 'w')); print('ok')
flows/flowtest.py (42 lines)
import json, datetime as dt, statistics as S, collections, math
import eng
CH = {'Solana': ['sol_pump','sol_bonk','sol_memes','sol_ai','sol_defi'], 'Base': ['base_memes','base_ai','base_defi'], 'Ton': ['ton_all']}
def ds(ts): return (dt.datetime.utcfromtimestamp(int(ts)).date()).isoformat()
ST = {}; DX = {}
for c in CH:
    ST[c] = {ds(x['date']): x['totalCirculatingUSD'].get('peggedUSD', 0) for x in json.load(open(f'st_{c}.json'))}
    DX[c] = {ds(a): b for a, b in json.load(open(f'dex_{c}.json'))['totalDataChart']}
def chg(m, d, n):
    d0 = (dt.date.fromisoformat(d) - dt.timedelta(n)).isoformat()
    return m[d] / m[d0] - 1 if d in m and d0 in m and m[d0] else None
def avg(m, d, n):
    xs = [m.get((dt.date.fromisoformat(d) - dt.timedelta(i)).isoformat()) for i in range(n)]
    xs = [x for x in xs if x]; return sum(xs) / len(xs) if len(xs) >= n * 0.8 else None
def feat(c, d):
    s7 = chg(ST[c], d, 7); s30 = chg(ST[c], d, 30)
    v7 = avg(DX[c], d, 7); v28 = avg(DX[c], d, 28)
    return dict(s7=s7, s30=s30, vol=(v7 / v28 - 1) if v7 and v28 else None)
rows = []
for c, ks in CH.items():
    for k in ks:
        dd, L = eng.SERIES[k]
        for i in range(len(L) - 14):
            f = feat(c, dd[i])
            if f['s7'] is None: continue
            rows.append(dict(c=c, k=k, d=dd[i], f14=L[i + 14] / L[i] - 1, **f))
print('rows', len(rows))
def bucket(name, key, cuts, lab):
    print('\n==', name)
    for per, lo, hi in (('2024-25', '2024-01-01', '2025-07-01'), ('2025-26', '2025-07-01', '2027')):
        for c in CH:
            xs = [r for r in rows if r['c'] == c and lo <= r['d'] < hi and r[key] is not None]
            if len(xs) < 50: continue
            out = []
            for j, (a, b) in enumerate(cuts):
                g = [r['f14'] for r in xs if a <= r[key] < b]
                out.append(f"{lab[j]}: n{len(g):4d} med{S.median(g)*100:+5.1f}% up{sum(1 for x in g if x>0)/max(1,len(g))*100:3.0f}%" if g else f"{lab[j]}: -")
            print(f"  {per} {c:6s} " + " | ".join(out))
bucket('stablecoin supply change 7d', 's7', [(-9, -0.01), (-0.01, 0.01), (0.01, 9)], ['OUT <-1%', 'FLAT', 'IN >+1%'])
bucket('stablecoin supply change 30d', 's30', [(-9, -0.03), (-0.03, 0.03), (0.03, 9)], ['OUT <-3%', 'FLAT', 'IN >+3%'])
bucket('DEX volume 7d vs 28d avg', 'vol', [(-9, -0.2), (-0.2, 0.2), (0.2, 9)], ['falling >20%', 'flat', 'rising >20%'])
json.dump(rows, open('flowrows.json', 'w'))
rh50/eng.py (128 lines)
"""Flexible basket rule engine for the 50 iteration study (3 Oct 2026).
Every basket line is a daily equal weight point in time index (dead coins count)."""
import json, math, statistics as S, collections, datetime as dt
from stages import stage_series

D = json.load(open('bk_clean.json'))
PX = json.load(open('majors_px.json'))
BTC = {d: v for d, v in D['btc']['line']}
KEYS = [k for k in D if k != 'btc']
CHAIN = {k: ('Solana' if k.startswith('sol') else 'Base' if k.startswith('base') else 'TON' if k.startswith('ton') else
             'Ethereum' if k.startswith('eth') else 'BNB' if k.startswith('bnb') else 'Majors') for k in KEYS}
MEME = {k: ('meme' in k or k in ('sol_pump', 'sol_bonk')) for k in KEYS}
SPLIT = '2025-01-01'


def sma_map(px, n):
    ds = sorted(px); out = {}
    for i, d in enumerate(ds):
        w = [px[x] for x in ds[max(0, i - n + 1):i + 1]]
        if len(w) >= int(n * 0.9): out[d] = sum(w) / len(w)
    return out


BMA = {n: sma_map(BTC, n) for n in (20, 50, 100, 200)}
SOLBTC = {d: PX['SOL'][d] / PX['BTC'][d] for d in PX['SOL'] if d in PX['BTC']}


def donch(L, nin, nout):
    s = False; o = []
    for i in range(len(L)):
        if i >= nin and L[i] > max(L[i - nin:i]): s = True
        elif i >= nout and L[i] < min(L[i - nout:i]): s = False
        o.append(s)
    return o


SERIES = {k: ([d for d, _ in D[k]['line']], [v for _, v in D[k]['line']]) for k in KEYS}
MAJ_ON = {}
_ds, _L = SERIES['majors']
for nin, nout in ((30, 15), (20, 10), (50, 20)):
    MAJ_ON[(nin, nout)] = dict(zip(_ds, donch(_L, nin, nout)))
# majors phase map for "majors not in hold half" test is built lazily
ON_ALL = {k: dict(zip(SERIES[k][0], donch(SERIES[k][1], 30, 15))) for k in KEYS}
STAGES = {k: stage_series(SERIES[k][1]) for k in KEYS}


def gate_btc(d, kind):
    if kind is None: return True
    n = int(kind)
    return d in BTC and d in BMA[n] and BTC[d] > BMA[n][d]


def run(k, nin=30, nout=15, btc='100', majors=True, delay=0, ext_cap=None, half='hot', exit_rule='low',
        max_days=None, froth_double=None, solbtc_froth=None, breadth=None, cooldown=0, cost=0.01, counter=False,
        two_closes=False, hot_z=2.0, sell_low=None, take_all_day=None, half_day=None, trail_after_half=None):
    ds, L = SERIES[k]; st = STAGES[k]; out = []; i = max(nin, nout); last_exit = -999
    lg = [math.log(x) for x in L]
    while i < len(L) - 1:
        d = ds[i]
        sig = L[i] > max(L[i - nin:i])
        if sig and two_closes: sig = L[i - 1] > max(L[i - 1 - nin:i - 1]) if i - 1 - nin >= 0 else False
        if sig and not gate_btc(d, btc): sig = False
        if sig and majors and k != 'majors' and not MAJ_ON[(30, 15)].get(d): sig = False
        if sig and froth_double and i >= 30 and L[i] / L[i - 30] >= froth_double: sig = False
        if sig and solbtc_froth and CHAIN[k] == 'Solana':
            d0 = (dt.date.fromisoformat(d) - dt.timedelta(28)).isoformat()
            if d in SOLBTC and d0 in SOLBTC and SOLBTC[d] / SOLBTC[d0] - 1 >= solbtc_froth: sig = False
        if sig and breadth is not None:
            n_on = sum(1 for kk in KEYS if kk != k and kk != 'majors' and ON_ALL[kk].get(d))
            n_all = sum(1 for kk in KEYS if kk != k and kk != 'majors' and d in ON_ALL[kk])
            if n_all and n_on / n_all < breadth: sig = False
        if sig and counter and not gate_btc(d, '50'): sig = False
        if sig and i - last_exit <= cooldown: sig = False
        if not sig: i += 1; continue
        brk = max(L[i - nin:i])
        j0 = i + delay
        if j0 >= len(L) - 1: break
        # still valid at the delayed entry? (risk on not lost in between)
        lost = any(L[j] < min(L[j - nout:j]) for j in range(i + 1, j0 + 1))
        if lost: i = j0 + 1; continue
        if ext_cap is not None and L[j0] / brk - 1 > ext_cap: i += 1; continue
        ent = L[j0]; w = 1.0; acc = 0.0; ex = None; trimmed = False; lown = sell_low or nout
        for j in range(j0 + 1, len(L)):
            age = j - i
            if take_all_day and age >= take_all_day: ex = j; break
            if exit_rule == 'hold5' and age >= 5: ex = j; break
            if exit_rule == 'low' and L[j] < min(L[j - lown:j]): ex = j; break
            if trimmed and trail_after_half and L[j] < min(L[j - trail_after_half:j]): ex = j; break
            if max_days and age >= max_days: ex = j; break
            if not trimmed and half:
                hot = st[j][0] == 'HOT' if hot_z == 2.0 else (st[j][1].get('z', 0) >= hot_z and st[j][1].get('pos', 0) > 0.8)
                fire = (half == 'hot' and hot) or (half == 'day' and age >= half_day) or (half == 'first' and (hot or age >= half_day))
                if fire: acc += 0.5 * (L[j] / ent - 1); w = 0.5; trimmed = True
        if ex is None: break
        r = acc + w * (L[ex] / ent - 1) - cost
        out.append(dict(k=k, sig=d, entry=ds[j0], exit=ds[ex], ret=r, days=ex - j0))
        last_exit = ex; i = ex + 1
    return out


def stats(ts):
    xs = [t['ret'] for t in ts]
    if not xs: return dict(n=0, mean=0, med=0, win=0, pf=0)
    wn = [x for x in xs if x > 0]; ls = [x for x in xs if x <= 0]
    return dict(n=len(xs), mean=round(S.mean(xs) * 100, 1), med=round(S.median(xs) * 100, 1),
                win=round(len(wn) / len(xs) * 100), pf=round(sum(wn) / -sum(ls), 2) if ls and sum(ls) < 0 else 9.99)


def weekly(ts):
    w = collections.defaultdict(list)
    for t in ts: w[dt.date.fromisoformat(t['entry']).isocalendar()[:2]].append(t['ret'])
    xs = [S.mean(v) for v in w.values()]
    if not xs: return dict(weeks=0, pf=0)
    p = sum(x for x in xs if x > 0); n = -sum(x for x in xs if x <= 0)
    return dict(weeks=len(xs), pf=round(p / n, 2) if n else 9.99, mean=round(S.mean(xs) * 100, 1))


def evaluate(**kw):
    ts = []
    for k in KEYS: ts += run(k, **kw)
    a = [t for t in ts if t['entry'] < SPLIT]; b = [t for t in ts if t['entry'] >= SPLIT]
    sol = [t for t in ts if CHAIN[t['k']] == 'Solana']; oth = [t for t in ts if CHAIN[t['k']] != 'Solana']
    return dict(all=stats(ts), A=stats(a), B=stats(b), wk=weekly(ts), wkB=weekly(b), sol=stats(sol), other=stats(oth), trades=ts)


def line(name, r):
    def f(o): return "n%3d avg%+5.1f med%+5.1f w%3d pf%5.2f" % (o['n'], o['mean'], o['med'], o['win'], o['pf'])
    return "%-46s ALL %s | 2023-24 %s | 2025-26 %s | wkPF %.2f/%.2f" % (name[:46], f(r['all']), f(r['A']), f(r['B']), r['wk']['pf'], r['wkB']['pf'])
rh50/it_a.py (43 lines)
import json, sys
from eng import *
V = [
 (1, "baseline v3 (30/15, BTC100+majors, half at hot)", {}),
 (2, "no half (just 15d low exit)", dict(half=None)),
 (3, "buy 1 day late", dict(delay=1)),
 (4, "buy 2 days late", dict(delay=2)),
 (5, "buy 3 days late", dict(delay=3)),
 (6, "buy 5 days late", dict(delay=5)),
 (7, "buy 8 days late", dict(delay=8)),
 (8, "buy 13 days late", dict(delay=13)),
 (9, "only if <=10% above breakout level", dict(ext_cap=0.10)),
 (10, "only if <=20% above breakout level", dict(ext_cap=0.20)),
 (11, "half at day 8 (breakout clock)", dict(half='day', half_day=8)),
 (12, "half at first of hot or day 8", dict(half='first', half_day=8)),
 (13, "all out day 13", dict(half=None, take_all_day=13)),
 (14, "hold 5 days only", dict(half=None, exit_rule='hold5')),
 (15, "half hot, rest 15d low, max 55d", dict(max_days=55)),
 (16, "exit at 10d low", dict(sell_low=10)),
 (17, "exit at 20d low", dict(sell_low=20)),
 (18, "half hot, then trail 7d low", dict(trail_after_half=7)),
 (19, "half at first of hot/day8, then trail 7d low", dict(half='first', half_day=8, trail_after_half=7)),
 (20, "gate BTC > 50d instead of 100d", dict(btc='50')),
 (21, "gate BTC > 20d", dict(btc='20')),
 (22, "BTC gate only, no majors gate", dict(majors=False)),
 (23, "no market gate at all", dict(majors=False, btc=None)),
 (24, "skip if basket +100% in 30d (froth)", dict(froth_double=2.0)),
 (25, "skip if basket +60% in 30d (froth)", dict(froth_double=1.6)),
 (26, "skip Solana if SOL/BTC +20% in 4w", dict(solbtc_froth=0.20)),
 (27, "need 25% of other baskets on", dict(breadth=0.25)),
 (28, "need 50% of other baskets on", dict(breadth=0.50)),
 (29, "cooldown 5 days after a sell", dict(cooldown=5)),
 (30, "need 2 closes above 30d high", dict(two_closes=True)),
 (31, "hot = z>=2.5", dict(hot_z=2.5)),
 (32, "2% costs", dict(cost=0.02)),
 (33, "20/10 windows", dict(nin=20, nout=10)),
 (34, "50/20 windows", dict(nin=50, nout=20)),
]
R = {}
for i, nm, kw in V:
    r = evaluate(**kw); R[i] = dict(name=nm, kw=kw, **{k: v for k, v in r.items() if k != 'trades'})
    print("%2d %s" % (i, line(nm, r)), flush=True)
json.dump(R, open('res_a.json', 'w'))
rh50/it_b.py (21 lines)
import json
from eng import *
V = [
 (35, "26+27: SOL/BTC froth skip + 25% breadth", dict(solbtc_froth=0.20, breadth=0.25)),
 (36, "35 + skip if +60% in 30d", dict(solbtc_froth=0.20, breadth=0.25, froth_double=1.6)),
 (37, "35 + trail 7d low after half", dict(solbtc_froth=0.20, breadth=0.25, trail_after_half=7)),
 (38, "35 + all out day 13", dict(solbtc_froth=0.20, breadth=0.25, half=None, take_all_day=13)),
 (39, "35 + entry <=20% above breakout", dict(solbtc_froth=0.20, breadth=0.25, ext_cap=0.20)),
 (40, "35 with 20/10 windows", dict(solbtc_froth=0.20, breadth=0.25, nin=20, nout=10)),
 (41, "35, SOL/BTC threshold 15%", dict(solbtc_froth=0.15, breadth=0.25)),
 (42, "35, SOL/BTC threshold 25%", dict(solbtc_froth=0.25, breadth=0.25)),
 (43, "35, breadth 15%", dict(solbtc_froth=0.20, breadth=0.15)),
 (44, "35, breadth 35%", dict(solbtc_froth=0.20, breadth=0.35)),
 (45, "35 + 1 day late entry", dict(solbtc_froth=0.20, breadth=0.25, delay=1)),
 (46, "35 + 2% cost", dict(solbtc_froth=0.20, breadth=0.25, cost=0.02)),
]
R = {}
for i, nm, kw in V:
    r = evaluate(**kw); R[i] = dict(name=nm, kw=kw, **{k: v for k, v in r.items() if k != 'trades'})
    print("%2d %s" % (i, line(nm, r)), " sol", r['sol']['n'], r['sol']['pf'], "other", r['other']['n'], r['other']['pf'], flush=True)
json.dump(R, open('res_b.json', 'w'))
rh50/it_c.py (60 lines)
import json, math, collections, statistics as S
from eng import *
KW = dict(solbtc_froth=0.20, breadth=0.25)
def weights(kw):
    W = {}; ENT = {}
    for k in KEYS:
        ds, L = SERIES[k]; w = {}; e = {}
        for t in run(k, **kw):
            i0 = ds.index(t['entry']); i1 = ds.index(t['exit'])
            # replay half: use stage HOT
            st = STAGES[k]; cur = 1.0
            for j in range(i0, i1):
                if j > i0 and cur == 1.0 and st[j][0] == 'HOT' and kw.get('half', 'hot') == 'hot': cur = 0.5
                w[ds[j]] = cur; e[ds[j]] = t['entry']
        W[k] = w; ENT[k] = e
    return W, ENT
RET = {k: {SERIES[k][0][i]: SERIES[k][1][i + 1] / SERIES[k][1][i] - 1 for i in range(len(SERIES[k][1]) - 1)} for k in KEYS}
R30 = {k: {SERIES[k][0][i]: SERIES[k][1][i] / SERIES[k][1][i - 30] - 1 for i in range(30, len(SERIES[k][1]))} for k in KEYS}
days = sorted({d for k in KEYS for d in SERIES[k][0]})
def sim(W, ENT, K=3, rank='r30', mode='any', chain_cap=None, start='2023-01-01'):
    eq = 0; pk = 0; mdd = 0; prev = {}; yr = collections.defaultdict(float); held = {}; ex = []
    for d in days:
        if d < start: continue
        opn = {k: W[k][d] for k in KEYS if W[k].get(d, 0) > 0 and d in RET[k]}
        if mode == 'buy_only':
            # keep what we hold while it is open; add new only on its entry day
            held = {k: v for k, v in held.items() if k in opn}
            for k in sorted(opn, key=lambda k: -R30[k].get(d, -9)):
                if len(held) >= K: break
                if k not in held and ENT[k].get(d) == d: held[k] = 1
            sel = list(held)
        else:
            cand = sorted(opn, key=lambda k: -(R30[k].get(d, -9) if rank == 'r30' else -((dt.date.fromisoformat(d) - dt.date.fromisoformat(ENT[k][d])).days)))
            sel = []; cc = collections.Counter()
            for k in cand:
                if chain_cap and cc[CHAIN[k]] >= chain_cap: continue
                sel.append(k); cc[CHAIN[k]] += 1
                if len(sel) >= K: break
        wt = {k: opn[k] / K for k in sel}
        turn = sum(abs(wt.get(k, 0) - prev.get(k, 0)) for k in set(wt) | set(prev))
        r = sum(x * RET[k][d] for k, x in wt.items()) - turn * 0.005; prev = wt
        lr = math.log(1 + r); eq += lr; yr[d[:4]] += lr; pk = max(pk, eq); mdd = min(mdd, eq - pk); ex.append(sum(wt.values()))
    return dict(total=round((math.exp(eq) - 1) * 100), worst=round((math.exp(mdd) - 1) * 100), money_in=round(S.mean(ex) * 100),
                by_year={y: round((math.exp(v) - 1) * 100) for y, v in yr.items()})
Wb, Eb = weights({}); W5, E5 = weights(KW)
out = {}
for nm, W, E, a in [("47 base rule, 3 strongest 30d", Wb, Eb, {}), ("48 new rule (35), 3 strongest 30d", W5, E5, {}),
                    ("49 new rule, 3 freshest (newest risk on)", W5, E5, dict(rank='fresh')),
                    ("50 new rule, money only on BUY day, hold till off", W5, E5, dict(mode='buy_only')),
                    ("51 new rule, 3 strongest, max 2 per chain", W5, E5, dict(chain_cap=2)),
                    ("52 new rule, 2 strongest", W5, E5, dict(K=2)), ("53 new rule, 4 strongest", W5, E5, dict(K=4))]:
    r = sim(W, E, **a); out[nm] = r; print(nm, r, flush=True)
b = {}; e = 0; pk = 0; mdd = 0; bd = sorted(BTC)
yb = collections.defaultdict(float)
for i in range(len(bd) - 1):
    if bd[i] < '2023-01-01': continue
    lr = math.log(BTC[bd[i + 1]] / BTC[bd[i]]); e += lr; yb[bd[i][:4]] += lr; pk = max(pk, e); mdd = min(mdd, e - pk)
out['BTC hold'] = dict(total=round((math.exp(e) - 1) * 100), worst=round((math.exp(mdd) - 1) * 100), by_year={y: round((math.exp(v) - 1) * 100) for y, v in yb.items()})
print('BTC hold', out['BTC hold'])
json.dump(out, open('res_c.json', 'w'))
rh50/it_d.py (13 lines)
import json
from eng import *
KW = dict(solbtc_froth=0.20, breadth=0.25)
V = [(54, "fast 14/7 (with 35 filters)", dict(KW, nin=14, nout=7)),
     (55, "fast 10/5", dict(KW, nin=10, nout=5)),
     (56, "fast 7/4", dict(KW, nin=7, nout=4)),
     (57, "fast 10/7", dict(KW, nin=10, nout=7)),
     (58, "fast 14/7, no half", dict(KW, nin=14, nout=7, half=None))]
R = {}
for i, nm, kw in V:
    r = evaluate(**kw); R[i] = dict(name=nm, kw=kw, **{k: v for k, v in r.items() if k != 'trades'})
    print("%2d %s" % (i, line(nm, r)), " sol", r['sol']['n'], r['sol']['pf'], "other", r['other']['n'], r['other']['pf'], flush=True)
json.dump(R, open('res_d.json', 'w'))
rh50/it_f.py (56 lines)
"""Index construction checks: equal weight reset daily (current) vs weekly drift (like the official Robinhood
indices) vs top 10 / top 30 members. Same rule (#35 filters except breadth, which needs the full set)."""
import json, glob, os, datetime as dt, math, statistics as S, collections, sys
sys.path.insert(0, '/home/claude/chains')
from baskets_def import CMC_BASKETS, EXCLUDE, MEME, AI, DEFI
from stages import stage_series
import eng
files = sorted(glob.glob('/home/claude/chains/snapB/*.json')); days = [os.path.basename(f)[:10] for f in files]
snap = {d: json.load(open(f)) for d, f in zip(days, files)}
px = {d: {c[0]: c[6] for c in s if c[6]} for d, s in snap.items()}
has = lambda t, s: bool(set(t) & s)
B = {b[0]: (b[1], b[3]) for b in CMC_BASKETS}
B['eth_memes'] = ("Ethereum", lambda t: has(t, MEME)); B['bnb_memes'] = ("BNB Smart Chain (BEP20)", lambda t: has(t, MEME))
def build(key, topn=15, drift=False):
    plat, rule = B[key]; ex = EXCLUDE.get(key, set()); line = []; lvl = 1.0; mem = None; started = False; w = {}
    for i in range(len(days) - 1):
        d, n = days[i], days[i + 1]
        if mem is None or dt.date.fromisoformat(d).weekday() == 0:
            xs = [c for c in snap[d] if c[3] == plat and not c[9] and c[1] not in ex and rule(c[10]) and c[8] >= 25000 and c[6] > 0 and c[7] > 0]
            xs.sort(key=lambda c: -c[7]); m = [c[0] for c in xs[:topn]]; mem = m if len(m) >= 4 else None
            if mem: w = {c: 1 / len(mem) for c in mem}
        if mem is None:
            if started: line.append((n, lvl))
            continue
        rs = {}
        for cid in mem:
            a = px[d].get(cid); z = px[n].get(cid)
            if not a: continue
            if z is None:
                if any(cid in px[days[k]] for k in range(i + 2, min(i + 5, len(days)))): continue
                rs[cid] = -0.5; continue
            rs[cid] = max(-0.9, min(2.0, z / a - 1))
        if len(rs) < 4:
            if started: line.append((n, lvl)); continue
        started = True
        if drift:
            tw = sum(w.get(c, 0) for c in rs); r = sum(w.get(c, 0) * rs[c] for c in rs) / tw if tw else 0
            for c in rs: w[c] = w.get(c, 0) * (1 + rs[c])
            s = sum(w.values()); w = {c: v / s for c, v in w.items()}
        else:
            r = sum(rs.values()) / len(rs)
        lvl *= 1 + r; line.append((n, lvl))
    return [d for d, _ in line], [x for _, x in line]
keys = ['sol_pump', 'sol_memes', 'sol_defi', 'base_memes', 'eth_memes', 'bnb_memes']
res = {}
for nm, kw in [("59 equal weight reset daily, top 15 (current)", {}), ("60 weekly drift (like Robinhood Indices), top 15", dict(drift=True)),
               ("61 top 10", dict(topn=10)), ("62 top 30", dict(topn=30))]:
    allt = []
    for k in keys:
        ds, L = build(k, **kw)
        eng.SERIES[k] = (ds, L); eng.STAGES[k] = stage_series(L)
        allt += eng.run(k, solbtc_froth=0.20)
    a = [t for t in allt if t['entry'] < eng.SPLIT]; b = [t for t in allt if t['entry'] >= eng.SPLIT]
    res[nm] = dict(all=eng.stats(allt), A=eng.stats(a), B=eng.stats(b))
    print(nm, res[nm], flush=True)
json.dump(res, open('res_f.json', 'w'))
rh50/rh_build.py (41 lines)
import json, gzip, datetime as dt, bisect, math, statistics as S
T = json.load(open('tokens.json')); toks = {t['address']: t for t in T['tokens']}; sup = T['supply']
kl = json.load(gzip.open('kline.json.gz', 'rt'))
def daily(chain, N, lo_rank=0):
    a = [x for x in kl if toks[x]['chain'] == chain and toks[x]['crossed_ts'] and sup.get(x)]
    ts = {x: [r[0] for r in kl[x]] for x in a}; cl = {x: [r[4] for r in kl[x]] for x in a}
    def px(x, t):   # last close at or before t, within 24h
        i = bisect.bisect_right(ts[x], t) - 1
        if i < 0 or t - ts[x][i] > 86400: return None
        return cl[x][i]
    t0 = min(min(ts[x]) for x in a); t0 = (t0 // 86400 + 1) * 86400; t1 = max(max(ts[x]) for x in a)
    lvl = 1.0; line = []; mem = None
    t = t0
    while t + 86400 <= t1 + 3600:
        d = dt.datetime.utcfromtimestamp(t)
        if mem is None or d.weekday() == 0:
            c = [(px(x, t) * sup[x], x) for x in a if toks[x]['crossed_ts'] <= t and px(x, t)]
            c.sort(reverse=True); m = [x for _, x in c[lo_rank:lo_rank + N]]
            mem = m if len(m) >= min(5, N) else None
        if mem:
            rs = []
            for x in mem:
                p0, p1 = px(x, t), px(x, t + 86400)
                if p0 and p1: rs.append(max(-0.9, min(2.0, p1 / p0 - 1)))
                elif p0 and not p1: rs.append(-0.5)
            if len(rs) >= 3: lvl *= 1 + sum(rs) / len(rs)
            line.append([(d + dt.timedelta(1)).date().isoformat(), lvl])
        t += 86400
    return line
M = json.load(open('market.json'))
def official(idx):
    h = [i for i in M['indices'] if i['id'] == idx][0]['history']
    by = {}
    for t, v in h:
        if v: by[dt.datetime.utcfromtimestamp(t - 1).date() + dt.timedelta(1)] = v
    return [[d.isoformat(), v] for d, v in sorted(by.items())]
out = {"rh_top30": daily('robinhood', 30), "rh_top10": daily('robinhood', 10), "rh_mid": daily('robinhood', 20, 10),
       "sol_top30": daily('sol', 30)}
for i in M['indices']: out['off_' + i['id']] = official(i['id'])
for k, v in out.items(): print(k, len(v), v[0][0] if v else None, v[-1] if v else None)
json.dump(out, open('rh_lines.json', 'w'))
rh50/it_e.py (30 lines)
import json, math
from eng import BTC, BMA, MAJ_ON, donch
from stages import stage_series
R = json.load(open('rh_lines.json'))
def trades(ds, L, nin, nout, gated):
    st = stage_series(L) if len(L) >= 26 else [("", {})] * len(L)
    out = []; i = nin
    while i < len(L) - 1:
        d = ds[i]
        sig = L[i] > max(L[i - nin:i])
        if gated and not (d in BMA[100] and BTC.get(d, 0) > BMA[100][d] and MAJ_ON[(30, 15)].get(d, True)): sig = False
        if not sig: i += 1; continue
        ent = L[i]; w = 1; acc = 0; ex = None
        for j in range(i + 1, len(L)):
            if L[j] < min(L[j - nout:j]): ex = j; break
            if w == 1 and st[j][0] == 'HOT': acc += .5 * (L[j] / ent - 1); w = .5
        last = ex if ex is not None else len(L) - 1
        out.append((d, ds[last], round((acc + w * (L[last] / ent - 1) - 0.01) * 100, 1), 'closed' if ex is not None else 'open'))
        if ex is None: break
        i = ex + 1
    return out
for k, line in R.items():
    ds = [d for d, _ in line]; L = [v for _, v in line]
    pk = max(range(len(L)), key=lambda i: L[i])
    print(f"\n{k}: {ds[0]}..{ds[-1]} n={len(L)} peak {ds[pk]} now {L[-1]/L[pk]-1:+.0%} from peak, 30d {L[-1]/L[max(0,len(L)-31)]-1:+.0%}")
    for nin, nout in ((10, 5), (30, 15)):
        if len(L) <= nin + 1: continue
        for g in (False, True):
            t = trades(ds, L, nin, nout, g)
            print(f"  {nin}/{nout} {'gated' if g else 'no gate'}: {t}")
rh50/verify4.py (31 lines)
import json, datetime as dt
import rule4
D = json.load(open('bk_clean.json')); PX = json.load(open('majors_px.json'))
btc = {d: v for d, v in D['btc']['line']}; bon = rule4.sma_on(sorted(btc), btc)
keys = [k for k in D if k != 'btc']
ser = {k: ([d for d, _ in D[k]['line']], [v for _, v in D[k]['line']]) for k in keys}
on = {k: rule4.donchian_on(*ser[k]) for k in keys}
mon = on['majors']
alld = sorted({d for k in keys for d in ser[k][0]})
def breadth_for(k):
    out = {}
    for d in alld:
        oth = [kk for kk in keys if kk not in (k, 'majors') and d in on[kk]]
        if oth: out[d] = sum(1 for kk in oth if on[kk][d]) / len(oth)
    return out
sb = {d: PX['SOL'][d] / PX['BTC'][d] for d in PX['SOL'] if d in PX['BTC']}
froth = {}
for d in sb:
    d0 = (dt.date.fromisoformat(d) - dt.timedelta(28)).isoformat()
    if d0 in sb: froth[d] = sb[d] / sb[d0] - 1 >= 0.20
res = {}
for fast in (False, True):
    allt = []
    for k in keys:
        g = dict(btc_on=bon, maj_on=mon, is_majors=k == 'majors', breadth=breadth_for(k), froth=froth if k.startswith('sol') else {})
        nin, nout = rule4.FAST if fast else rule4.SLOW
        ts, _ = rule4.run(*ser[k], g, nin, nout); allt += [dict(t, k=k) for t in ts if t['status'] == 'closed']
    a = [t for t in allt if t['entry'] < '2025-01-01']; b = [t for t in allt if t['entry'] >= '2025-01-01']
    res['fast' if fast else 'slow'] = dict(all=rule4.stats(allt), A=rule4.stats(a), B=rule4.stats(b), trades=[[t['k'], t['entry'], t['trim'], t['exit'], round(t['ret'] * 100, 1)] for t in allt])
    print('fast' if fast else 'slow', rule4.stats(allt), rule4.stats(a), rule4.stats(b))
json.dump(res, open('evidence5_core.json', 'w'))
rule3.py (120 lines)
"""Basket rule v3 (2 Oct 2026): risk on / risk off for every basket, shared by the live engine and the backtest.

RISK ON (BUY) at a daily close above the basket's highest close of the prior 30 days, but only while
  BTC closes above its 100 day average AND the majors basket is itself risk on (the market agrees).
TAKE HALF the first time the basket runs HOT (20 day z score >= 2 and in the top 20% of its 90 day range).
RISK OFF (SELL) at a daily close below the basket's lowest close of the prior 15 days.
Rotation: of the baskets that are risk on, the 3 strongest over 30 days.

Test (it1 to it3 in /home/claude/chains, raw data published with the handover): 14 point in time CoinMarketCap
baskets, dead coins counted, Jan 2023 to Sep 2026, 1% round trip cost: 115 trades, +8.0% average, median +3.1%,
58% wins, $2.64 won per $1 lost; Sep 2024 to Sep 2026 alone: 76 trades, +4.0%, 57% wins, $1.80 per $1.
Rotation book (3 strongest risk on baskets): +154% (log) Jan 2023 to Sep 2026 vs BTC +161%, with about 25%
of the money in on average and a worst drop of -36% vs BTC -53%."""
import math, statistics as S, datetime as dt
from stages import stage_series

NIN, NOUT, COST, TOPK = 30, 15, 0.01, 3


def sma_on(days, px, n=100):
    """date -> True when the price closes above its n day simple average (needs 90% of the window)."""
    ds = [d for d in days if px.get(d)]
    out = {}
    for i, d in enumerate(ds):
        w = [px[x] for x in ds[max(0, i - n + 1):i + 1]]
        if len(w) >= int(n * 0.9): out[d] = px[d] > sum(w) / len(w)
    return out


def donchian_on(days, L, nin=NIN, nout=NOUT):
    s = False; out = {}
    for i, d in enumerate(days):
        if i >= nin and L[i] > max(L[i - nin:i]): s = True
        elif i >= nout and L[i] < min(L[i - nout:i]): s = False
        out[d] = s
    return out


def to_daily(line, stamp_open=False):
    """[[unix_ts, level], ...] 4 hour bars -> [[YYYY-MM-DD, level]], the last level seen each UTC day, keyed like
    CoinMarketCap: a level seen on day D is the close for the label D+1 only once the day is over. The current
    (unfinished) day is kept as a provisional last point."""
    by = {}
    for t, v in line:
        if not v: continue
        if stamp_open: t += 4 * 3600
        d = dt.datetime.fromtimestamp(t - 1, dt.timezone.utc).date() + dt.timedelta(days=1)
        by[d.isoformat()] = v
    return sorted(by.items())


def run(days, L, btc_on, maj_on, is_majors=False, nin=NIN, nout=NOUT, cost=COST):
    """Replay the rule on one basket. Returns (trades, per-day weight). A trade dict carries entry/exit days,
    levels, trim day, return after costs, and status open/closed."""
    st = stage_series(L) if len(L) >= 26 else [("WARMUP", {})] * len(L)
    trades = []; w = [0.0] * len(L); i = nin
    while i < len(L):
        d = days[i]
        ok = (L[i] > max(L[i - nin:i]) and btc_on.get(d, False) and (is_majors or maj_on.get(d, False)))
        if not ok: i += 1; continue
        ent = L[i]; cur = 1.0; trim = None; acc = 0.0; ex = None
        w[i] = 1.0
        for j in range(i + 1, len(L)):
            if L[j] < min(L[j - nout:j]): ex = j; break
            if cur == 1.0 and st[j][0] == "HOT":
                trim = j; acc += 0.5 * (L[j] / ent - 1); cur = 0.5
            w[j] = cur
        last = ex if ex is not None else len(L) - 1
        ret = acc + cur * (L[last] / ent - 1) - (cost if ex is not None else cost / 2)
        trades.append(dict(sig=days[i], entry=days[i], entry_lvl=ent, trim=days[trim] if trim is not None else None,
                           trim_lvl=L[trim] if trim is not None else None, exit=days[ex] if ex is not None else None,
                           last=days[last], last_lvl=L[last], ret=ret, bars=last - i, status="closed" if ex is not None else "open",
                           weight=cur))
        if ex is None: break
        i = ex + 1
    return trades, w


def stats(ts):
    xs = [t["ret"] for t in ts if t["status"] == "closed"]
    if not xs: return dict(n=0)
    wn = [x for x in xs if x > 0]; ls = [x for x in xs if x <= 0]
    return dict(n=len(xs), mean=round(S.mean(xs) * 100, 1), med=round(S.median(xs) * 100, 1), win=round(len(wn) / len(xs) * 100),
                pf=round(sum(wn) / -sum(ls), 2) if ls and sum(ls) < 0 else None)


def read(days, L, btc_on, maj_on, is_majors=False, live_start="2026-10-02"):
    """Everything the dashboard needs for one basket on its last close."""
    n = len(L)
    if n < 2: return {"state": "NO DATA", "word": "NO READ"}
    young = n <= NIN
    nin = NIN if not young else max(5, n - 2)
    trades, w = run(days, L, btc_on, maj_on, is_majors, nin=nin)
    last = days[-1]; cur = trades[-1] if trades else None
    hi = max(L[-nin - 1:-1]) if n > nin else max(L[:-1]); lo = min(L[-NOUT - 1:-1]) if n > NOUT else min(L[:-1])
    hi_next = max(L[-nin:]); lo_next = min(L[-NOUT:])   # levels the NEXT close has to beat / hold
    st = stage_series(L)[-1] if n >= 26 else ("WARMUP", {})
    gates = {"btc": bool(btc_on.get(last)), "majors": True if is_majors else bool(maj_on.get(last))}
    o = {"young": young, "window": nin, "gates": gates, "stage": st[0],
         "z": round(st[1].get("z", 0), 2) if st[1] else None,
         "r30": round((L[-1] / L[-31] - 1) * 100, 1) if n > 30 else None,
         "r7": round((L[-1] / L[-8] - 1) * 100, 1) if n > 7 else None,
         "up_at": round((hi_next / L[-1] - 1) * 100, 1), "down_at": round((lo_next / L[-1] - 1) * 100, 1),
         "record": stats([t for t in trades if t["entry"] < live_start]),
         "trades": [dict(t, ret=round(t["ret"] * 100, 1)) for t in trades if t["entry"] >= live_start or t["status"] == "open"]}
    on = bool(cur and cur["status"] == "open")
    o["state"] = "RISK ON" if on else "RISK OFF"
    if on:
        o["open"] = dict(entry=cur["entry"], entry_lvl=cur["entry_lvl"], trim=cur["trim"], weight=cur["weight"],
                         ret=round((L[-1] / cur["entry_lvl"] - 1) * 100, 1), days=cur["bars"])
        if cur["entry"] == last: o["word"] = "BUY"
        elif cur["trim"] == last: o["word"] = "TAKE HALF"
        elif cur["trim"]: o["word"] = "HOLD HALF"
        else: o["word"] = "HOLD"
    else:
        if cur and cur["exit"] == last: o["word"] = "SELL"; o["closed"] = dict(entry=cur["entry"], ret=round(cur["ret"] * 100, 1))
        elif L[-1] > hi and not (gates["btc"] and gates["majors"]): o["word"] = "WAIT: BLOCKED"
        elif o["up_at"] <= 5: o["word"] = "WAIT: CLOSE"
        else: o["word"] = "WAIT"
    return o
bk_build.py (53 lines)
"""Build every point-in-time basket (dead coins count) from daily CMC snapshots, Sep 2022 to Sep 2026."""
import json, glob, os, datetime as dt, statistics as S
from baskets_def import CMC_BASKETS, EXCLUDE, MEME, AI, DEFI, GAMES
files=sorted(glob.glob('snapB/*.json')); days=[os.path.basename(f)[:10] for f in files]
snap={d:json.load(open(f)) for d,f in zip(days,files)}
px={d:{c[0]:c[6] for c in s if c[6]} for d,s in snap.items()}
has=lambda t,s:bool(set(t)&s)
P={"sol":"Solana","base":"Base","eth":"Ethereum","bnb":"BNB Smart Chain (BEP20)","ton":"TON","tron":"Tron20","sui":"Sui Network",
   "avax":"Avalanche C-Chain","arb":"Arbitrum","poly":"Polygon","rh":"Robinhood Chain"}
B={}
for b in CMC_BASKETS: B[b[0]]=(b[1],b[3])
for k,p in P.items():
    B.setdefault(k+"_memes",(p,lambda t:has(t,MEME)))
    B.setdefault(k+"_ai",(p,lambda t:has(t,AI)))
    B.setdefault(k+"_defi",(p,lambda t:has(t,DEFI) and not has(t,MEME) and not has(t,AI)))
B["majors"]=(None,lambda t:True)
def build(key,plat,rule,minvol=25000,topn=15):
    ex=EXCLUDE.get(key,set()); line=[]; lvl=1.0; mem=None; started=False; nm=[]
    for i in range(len(days)-1):
        d,n=days[i],days[i+1]
        if mem is None or dt.date.fromisoformat(d).weekday()==0:
            if key=="majors":
                xs=[c for c in snap[d] if c[3] is None and not c[9] and c[1] not in ("USDT","USDC") and c[6]>0]
                xs.sort(key=lambda c:-c[7]); m=[c[0] for c in xs[:10]]
            else:
                xs=[c for c in snap[d] if c[3]==plat and not c[9] and c[1] not in ex and rule(c[10]) and c[8]>=minvol and c[6]>0 and c[7]>0]
                xs.sort(key=lambda c:-c[7]); m=[c[0] for c in xs[:topn]]
            mem=m if len(m)>=4 else None
            if mem: nm.append(len(m))
        if mem is None:
            if started: line.append((n,lvl))
            continue
        rs=[]
        for cid in mem:
            a=px[d].get(cid); z=px[n].get(cid)
            if not a: continue
            if z is None:
                if any(cid in px[days[k]] for k in range(i+2,min(i+5,len(days)))): continue
                rs.append(-0.5); continue
            rs.append(max(-0.9,min(2.0,z/a-1)))
        if len(rs)<4:
            if started: line.append((n,lvl))
            continue
        started=True; lvl*=1+sum(rs)/len(rs); line.append((n,lvl))
    return line,(round(S.mean(nm),1) if nm else 0)
out={}
BTC=[(d,c[6]) for d in days for c in snap[d] if c[1]=="BTC" and c[3] is None]
out["btc"]={"plat":None,"line":BTC,"members":1}
for k,(p,r) in B.items():
    L,m=build(k,p,r)
    if len(L)>=150: out[k]={"plat":p,"line":L,"members":m}
    print(k,len(L),m)
json.dump(out,open('bk_all.json','w'))
it1.py (75 lines)
import json, math, statistics as S
D=json.load(open('bk_clean.json'))
BT={d:v for d,v in D['btc']['line']}
def sma(x,n): return [None if i<n-1 else sum(x[i-n+1:i+1])/n for i in range(len(x))]
def ema(x,n):
    a=2/(n+1); o=[]; e=None
    for v in x: e=v if e is None else e+a*(v-e); o.append(e)
    return o
def hyst(cond_on,cond_off):
    s=False; o=[]
    for on,off in zip(cond_on,cond_off):
        if on: s=True
        elif off: s=False
        o.append(s)
    return o
def states(ds,L):
    lg=[math.log(v) for v in L]; e20=ema(lg,20); e50=ema(lg,50); m50=sma(L,50); m100=sma(L,100)
    btc=[BT.get(d) for d in ds]; 
    out={}
    out['T1 above rising EMA20']=[i>=20 and lg[i]>e20[i] and e20[i]>e20[i-5] for i in range(len(L))]
    out['T2 above 50d avg']=[m50[i] is not None and L[i]>m50[i] for i in range(len(L))]
    out['T3 above 100d avg (1% band)']=hyst([m100[i] is not None and L[i]>m100[i]*1.01 for i in range(len(L))],[m100[i] is None or L[i]<m100[i]*0.99 for i in range(len(L))])
    out['T4 EMA20 over EMA50']=[i>=50 and e20[i]>e50[i] for i in range(len(L))]
    out['T5 up over 30 days']=[i>=30 and L[i]>L[i-30] for i in range(len(L))]
    out['T6 up over 14 days']=[i>=14 and L[i]>L[i-14] for i in range(len(L))]
    # BTC gate
    bl=[b for b in btc]; 
    def bsma(i,n):
        xs=[x for x in bl[max(0,i-n+1):i+1] if x]; return sum(xs)/len(xs) if len(xs)>=n*0.9 else None
    bon=[(bl[i] and bsma(i,100) and bl[i]>bsma(i,100)) or False for i in range(len(L))]
    out['B BTC above 100d avg']=bon
    out['T4 + BTC on']=[a and b for a,b in zip(out['T4 EMA20 over EMA50'],bon)]
    out['T1 + BTC on']=[a and b for a,b in zip(out['T1 above rising EMA20'],bon)]
    # ratio vs BTC
    r=[math.log(L[i]/bl[i]) if bl[i] else None for i in range(len(L))]
    rr=[x if x is not None else (r[i-1] if i else 0) for i,x in enumerate(r)]
    re20=ema(rr,20); re50=ema(rr,50)
    out['R ratio vs BTC EMA20>EMA50']=[i>=50 and re20[i]>re50[i] for i in range(len(L))]
    return out
def run(ds,L,st,lo,hi,cost=0.01):
    # signal at close i -> hold i..i+1
    eq=0; bh=0; sw=0; on=0; n=0; prev=False
    for i in range(len(L)-1):
        if not(lo<=ds[i]<hi): continue
        r=math.log(L[i+1]/L[i]); bh+=r; n+=1
        if st[i]!=prev: eq+=math.log(1-cost/2); sw+=1; prev=st[i]
        if st[i]: eq+=r; on+=1
    return eq,bh,on/max(n,1),sw,n
def fwd(ds,L,st,h,lo,hi):
    a=[];b=[]
    for i in range(len(L)-h):
        if not(lo<=ds[i]<hi): continue
        x=L[i+h]/L[i]-1
        (a if st[i] else b).append(x)
    return a,b
keys=[k for k in D if k!='btc']
NAMES=None; res={}
periods=[("all","2022-09-01","2026-12-31"),("H1 to Sep24","2022-09-01","2024-09-01"),("H2 from Sep24","2024-09-01","2026-12-31")]
for k in keys:
    ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]
    S_=states(ds,L); NAMES=list(S_)
    for nm,st in S_.items():
        for pn,lo,hi in periods:
            eq,bh,tim,sw,n=run(ds,L,st,lo,hi)
            if n<60: continue
            a,b=fwd(ds,L,st,14,lo,hi)
            res.setdefault(nm,{}).setdefault(pn,[]).append(dict(k=k,eq=eq,bh=bh,tim=tim,sw=sw,n=n,on14=S.median(a) if a else None,off14=S.median(b) if b else None))
for nm in NAMES:
    print("\n==",nm)
    for pn,_,_ in periods:
        rs=res[nm].get(pn,[]); 
        if not rs: continue
        beat=sum(1 for r in rs if r['eq']>r['bh']); pos=sum(1 for r in rs if r['eq']>0)
        sp=[r['on14']-r['off14'] for r in rs if r['on14'] is not None and r['off14'] is not None]
        print(f" {pn:14s} baskets {len(rs):2d} beat hold {beat:2d}  strat>0 {pos:2d}  med strat/yr {S.median([r['eq']/r['n']*365 for r in rs])*100:+6.0f}%  med hold/yr {S.median([r['bh']/r['n']*365 for r in rs])*100:+6.0f}%  time on {S.median([r['tim'] for r in rs])*100:3.0f}%  switches/yr {S.median([r['sw']/r['n']*365 for r in rs]):4.1f}  fwd14 on-off {S.median(sp)*100:+5.1f}pts ({sum(1 for x in sp if x>0)}/{len(sp)})")
it1b.py (43 lines)
import json, math, statistics as S, random
exec(open('it1.py').read().split("keys=[k for k in D")[0])
def donch(L,nin,nout):
    s=False;o=[]
    for i in range(len(L)):
        if i>=nin and L[i]>max(L[i-nin:i]): s=True
        elif i>=nout and L[i]<min(L[i-nout:i]): s=False
        o.append(s)
    return o
def ma_band(L,n,b):
    m=sma(L,n); return hyst([m[i] is not None and L[i]>m[i]*(1+b) for i in range(len(L))],[m[i] is None or L[i]<m[i]*(1-b) for i in range(len(L))])
def more(ds,L):
    o={}
    for n in (50,100,150,200):
        for b in (0,0.01,0.03,0.05):
            o[f'MA{n} band{int(b*100)}']=ma_band(L,n,b)
    for a,c in ((20,10),(30,15),(50,20),(90,30)):
        o[f'Donchian {a}/{c}']=donch(L,a,c)
    return o
keys=[k for k in D if k!='btc']
periods=[("all","2022-09-01","2026-12-31"),("H1","2022-09-01","2024-09-01"),("H2","2024-09-01","2026-12-31")]
res={}
for k in keys:
    ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]
    st=states(ds,L); st.update(more(ds,L))
    for nm,s in st.items():
        for pn,lo,hi in periods:
            eq,bh,tim,sw,n=run(ds,L,s,lo,hi)
            if n<60: continue
            # edge vs random exposure with same time in market and same switch cost
            edge=(eq-(tim*bh+sw*math.log(1-0.005)))/n*365
            # tail: share of the basket's worst 5% of 14d windows that started while ON
            fw=[(L[i+14]/L[i]-1,s[i]) for i in range(len(L)-14) if lo<=ds[i]<hi]
            fw.sort(); w=fw[:max(1,len(fw)//20)]; tail=sum(1 for _,x in w if x)/len(w)
            bst=fw[-max(1,len(fw)//20):]; up=sum(1 for _,x in bst if x)/len(bst)
            res.setdefault(nm,{}).setdefault(pn,[]).append((edge,tim,tail,up,sw/n*365,eq/n*365))
print(f"{'rule':30s} {'period':4s} edge/yr vs random exposure (median, baskets>0) | time on | worst-5% 14d starts while ON | best-5% while ON | switches/yr")
for nm,v in res.items():
    for pn in ("all","H1","H2"):
        rs=v.get(pn); 
        if not rs: continue
        e=[r[0] for r in rs]
        print(f"{nm:30s} {pn:4s} {S.median(e)*100:+6.1f}% ({sum(1 for x in e if x>0):2d}/{len(e)}) | {S.median([r[1] for r in rs])*100:3.0f}% | {S.median([r[2] for r in rs])*100:3.0f}% | {S.median([r[3] for r in rs])*100:3.0f}% | {S.median([r[4] for r in rs]):4.1f}")
it2.py (56 lines)
import json, math, statistics as S, collections
from stages import stage_series
exec(open('it1.py').read().split("keys=[k for k in D")[0])
keys=[k for k in D if k!='btc']
MEMES_IS={"sol_pump","sol_bonk","sol_memes","base_memes"}
def trades_don(ds,L,nin,nout,delay=0,btc=None,btc_mode=None,stop=None,hot=False,ma=None,cost=0.01):
    st=stage_series(L) if hot else None
    m=sma(L,ma) if ma else None
    out=[]; i=max(nin,nout)
    while i<len(L)-1:
        sig=L[i]>max(L[i-nin:i])
        if sig and btc_mode and not btc[i]: sig=False
        if sig and ma and (m[i] is None or L[i]<m[i]): sig=False
        if not sig: i+=1; continue
        j0=i+delay
        if j0>=len(L)-1: break
        ent=L[j0]; ex=None; why=''
        for j in range(j0+1,len(L)):
            if L[j]<min(L[j-nout:j]): ex=j; why='low'; break
            if stop and L[j]/ent-1<=-stop: ex=j; why='stop'; break
            if hot and st[j][0]=='HOT': ex=j; why='hot'; break
            if btc_mode=='always' and not btc[j]: ex=j; why='btc'; break
        if ex is None: break
        out.append(dict(sig=ds[i],ret=L[ex]/ent-1-cost,bars=ex-j0,why=why)); i=ex+1
    return out
def bt_on(ds):
    bl=[BT.get(d) for d in ds]; o=[]
    for i in range(len(ds)):
        xs=[x for x in bl[max(0,i-99):i+1] if x]
        o.append(bool(bl[i] and len(xs)>=90 and bl[i]>sum(xs)/len(xs)))
    return o
def st_(ts):
    xs=[t['ret'] for t in ts]
    if not xs: return "n=0"
    w=[x for x in xs if x>0]; l=[x for x in xs if x<=0]
    pf=sum(w)/-sum(l) if l else 9
    return f"n={len(xs):3d} mean {S.mean(xs)*100:+5.1f}% med {S.median(xs)*100:+5.1f}% win {len(w)/len(xs)*100:3.0f}% PF {pf:4.2f} sum {sum(math.log(1+x) for x in xs)*100:+6.0f}%"
V={'D30/15':dict(nin=30,nout=15),'D20/10':dict(nin=20,nout=10),'D40/20':dict(nin=40,nout=20),
   'D30/15 next-day entry':dict(nin=30,nout=15,delay=1),
   'D30/15 BTC on at entry':dict(nin=30,nout=15,btc_mode='entry'),
   'D30/15 BTC on always':dict(nin=30,nout=15,btc_mode='always'),
   'D30/15 stop12':dict(nin=30,nout=15,stop=0.12),
   'D30/15 sell HOT':dict(nin=30,nout=15,hot=True),
   'D30/15 above MA100':dict(nin=30,nout=15,ma=100),
   'D30/15 BTC entry + MA100':dict(nin=30,nout=15,btc_mode='entry',ma=100)}
for nm,kw in V.items():
    G=collections.defaultdict(list)
    for k in keys:
        ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]
        ts=trades_don(ds,L,btc=bt_on(ds),**kw)
        for t in ts:
            G['all'].append(t)
            G['H1 <Sep24' if t['sig']<'2024-09-01' else 'H2 >=Sep24'].append(t)
            G['memes' if 'meme' in k or k in ('sol_pump','sol_bonk') else ('majors' if k=='majors' else 'non-memes')].append(t)
    print("\n==",nm)
    for g in ('all','H1 <Sep24','H2 >=Sep24','memes','non-memes','majors'): print(f"  {g:12s}",st_(G[g]))
it2b.py (22 lines)
import json, math, statistics as S, collections
from stages import stage_series
exec(open('it2.py').read().split("V={")[0])
# zone test: forward 14d by stage, z bucket, drawdown bucket
Z=collections.defaultdict(lambda: collections.defaultdict(list))
for k in keys:
    ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]; st=stage_series(L)
    for i in range(30,len(L)-14):
        s,inf=st[i]; f=L[i+14]/L[i]-1; f30=L[min(i+30,len(L)-1)]/L[i]-1
        per='H1' if ds[i]<'2024-09-01' else 'H2'
        z=inf['z']; dd=inf['dd']
        zb='z<=-2' if z<=-2 else 'z -2..-1' if z<=-1 else 'z -1..1' if z<1 else 'z 1..2' if z<2 else 'z>=2'
        db='dd 0-20' if dd>-0.2 else 'dd 20-40' if dd>-0.4 else 'dd 40-60' if dd>-0.6 else 'dd 60-80' if dd>-0.8 else 'dd 80+'
        for key in (('stage',s),('z',zb),('dd90',db),('base','all')):
            Z[key][per].append(f); Z[key]['all'].append(f)
print(f"{'bucket':26s} | {'all: n, med14, up%':22s} | {'H1':22s} | {'H2':22s}")
for key in sorted(Z):
    row=[]
    for per in ('all','H1','H2'):
        xs=Z[key][per]
        row.append(f"{len(xs):5d} {S.median(xs)*100:+5.1f}% {sum(1 for x in xs if x>0)/len(xs)*100:3.0f}%" if xs else "-")
    print(f"{key[0]+' '+key[1]:26s} | "+" | ".join(row))
it2c.py (42 lines)
import json, math, statistics as S, collections
from stages import stage_series
exec(open('it2.py').read().split("V={")[0])
def don_state(L,a=30,b=15):
    s=False;o=[]
    for i in range(len(L)):
        if i>=a and L[i]>max(L[i-a:i]): s=True
        elif i>=b and L[i]<min(L[i-b:i]): s=False
        o.append(s)
    return o
alld=sorted({d for k in keys for d,_ in D[k]['line']})
ON={}; LV={}
for k in keys:
    ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]
    LV[k]=dict(zip(ds,L)); ON[k]=dict(zip(ds,don_state(L)))
breadth={d:(sum(1 for k in keys if ON[k].get(d)),sum(1 for k in keys if d in ON[k] and k!='majors')) for d in alld}
# HOT conditional on BTC
H=collections.defaultdict(list)
for k in keys:
    ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]; st=stage_series(L); bo=bt_on(ds)
    for i in range(30,len(L)-14):
        f=L[i+14]/L[i]-1; per='H1' if ds[i]<'2024-09-01' else 'H2'
        n_on,n=breadth[ds[i]]; frac=n_on/max(n,1)
        if st[i][0]=='HOT':
            H[('HOT','BTC on' if bo[i] else 'BTC off',per)].append(f)
            H[('HOT','breadth>=50%' if frac>=0.5 else 'breadth<50%',per)].append(f)
        H[('ALL','BTC on' if bo[i] else 'BTC off',per)].append(f)
for key in sorted(H):
    xs=H[key]; print(key,len(xs),f"med {S.median(xs)*100:+.1f}% up {sum(1 for x in xs if x>0)/len(xs)*100:.0f}%")
# breakout trades by breadth at signal (other baskets ON share)
G=collections.defaultdict(list)
for k in keys:
    ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]
    for t in trades_don(ds,L,30,15,btc=bt_on(ds),btc_mode='entry'):
        n_on,n=breadth[t['sig']]; fr=(n_on-1)/max(n-1,1)
        b='others ON 0-25%' if fr<0.25 else 'others ON 25-50%' if fr<0.5 else 'others ON 50%+'
        per='H1' if t['sig']<'2024-09-01' else 'H2'
        G[(b,per)].append(t); G[(b,'all')].append(t)
        mj=ON['majors'].get(t['sig'])
        G[('majors ON' if mj else 'majors OFF','all')].append(t); G[('majors ON' if mj else 'majors OFF',per)].append(t)
for key in sorted(G): print(key, st_(G[key]))
json.dump({d:list(v) for d,v in breadth.items()},open('breadth.json','w'))
it2d.py (30 lines)
import json, math, statistics as S, collections
exec(open('it2c.py').read().split("# HOT conditional")[0])
# full rule family with majors gate, check halves and trim at HOT
def run_rule(gate_majors=True, gate_btc=True, hot_trim=False, nin=30, nout=15, delay=0):
    G=collections.defaultdict(list)
    for k in keys:
        ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]; bo=bt_on(ds); st=stage_series(L)
        i=max(nin,nout)
        while i<len(L)-1:
            ok=L[i]>max(L[i-nin:i]) and (not gate_btc or bo[i]) and (not gate_majors or k=='majors' or ON['majors'].get(ds[i]))
            if not ok: i+=1; continue
            j0=i+delay
            if j0>=len(L)-1: break
            ent=L[j0]; ex=None; part=0; w=1.0; acc=0
            for j in range(j0+1,len(L)):
                if hot_trim and w==1.0 and st[j][0]=='HOT': acc+=0.5*(L[j]/ent-1); w=0.5
                if L[j]<min(L[j-nout:j]): ex=j; break
            if ex is None: break
            r=acc+w*(L[ex]/ent-1)-0.01
            t=dict(sig=ds[i],ret=r,k=k)
            per='H1' if ds[i]<'2024-09-01' else 'H2'
            for g in ('all',per,('memes' if 'meme' in k or k in('sol_pump','sol_bonk') else 'majors' if k=='majors' else 'other')): G[g].append(t)
            G['y'+ds[i][:4]].append(t)
            i=ex+1
    return G
for nm,kw in [('A: 30d high, BTC gate only',dict(gate_majors=False)),('B: 30d high, BTC + majors ON',{}),('C: B + trim half at HOT',dict(hot_trim=True)),
              ('D: B but next-day entry',dict(delay=1)),('E: B with 20/10',dict(nin=20,nout=10)),('F: B with 50/20',dict(nin=50,nout=20)),('G: majors gate, no BTC gate',dict(gate_btc=False))]:
    G=run_rule(**kw); print("\n==",nm)
    for g in ('all','H1','H2','memes','other','majors','y2023','y2024','y2025','y2026'):
        if G[g]: print(f"  {g:7s}",st_(G[g]))
it3.py (96 lines)
import json, math, statistics as S, collections
exec(open('it2c.py').read().split("# HOT conditional")[0])
# per basket daily position weight under rule C (BTC + majors gate, 30/15, trim half at HOT)
POS={}; FEAT={}
for k in keys:
    ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]; bo=bt_on(ds); st=stage_series(L)
    w=[0.0]*len(L); i=30
    while i<len(L)-1:
        ok=L[i]>max(L[i-30:i]) and bo[i] and (k=='majors' or ON['majors'].get(ds[i]))
        if not ok: i+=1; continue
        cur=1.0; ex=None
        for j in range(i,len(L)):
            if j>i and L[j]<min(L[j-15:j]): ex=j; break
            if j>i and cur==1.0 and st[j][0]=='HOT': cur=0.5
            w[j]=cur
        if ex is None: break
        i=ex+1
    POS[k]=dict(zip(ds,w))
    lg=[math.log(x) for x in L]
    bl=[BT.get(d) for d in ds]
    FEAT[k]={}
    for i in range(len(L)):
        if i<90: continue
        lo=min(L[i-89:i+1]); hi=max(L[i-89:i+1])
        r30=L[i]/L[i-30]-1; b30=(bl[i]/bl[i-30]-1) if bl[i] and bl[i-30] else 0
        FEAT[k][ds[i]]=dict(r30=r30,rel=r30-b30,dd=L[i]/hi-1,up=L[i]/lo-1,r7=L[i]/L[i-7]-1)
days=sorted({d for k in keys for d in POS[k]})
RET={k:{} for k in keys}
for k in keys:
    ds=[d for d,_ in D[k]['line']]; L=[v for _,v in D[k]['line']]
    for i in range(len(L)-1): RET[k][ds[i]]=L[i+1]/L[i]-1
btd=sorted(BT); BR={btd[i]:BT[btd[i+1]]/BT[btd[i]]-1 for i in range(len(btd)-1)}
def sim(select, K=None, lo='2023-01-01', hi='2027-01-01', excl_majors=False):
    eq=0; prev={}; cost=0
    yr=collections.defaultdict(float)
    for d in days:
        if not(lo<=d<hi): continue
        opn=[(k,POS[k][d]) for k in keys if POS[k].get(d,0)>0 and d in RET[k] and not(excl_majors and k=='majors')]
        if K and select and opn:
            opn=sorted(opn,key=lambda x:select(x[0],d),reverse=True)[:K]
        tot=len(opn)
        # each open slot gets 1/max(K,n) of capital (unused = cash)
        den=K if K else max(tot,1)
        wts={k:w/den for k,w in opn}
        turn=sum(abs(wts.get(k,0)-prev.get(k,0)) for k in set(wts)|set(prev))
        r=sum(wt*RET[k][d] for k,wt in wts.items())-turn*0.005
        eq+=math.log(1+r); yr[d[:4]]+=math.log(1+r); prev=wts
    return eq,yr
feat=lambda f:(lambda k,d:FEAT[k].get(d,{}).get(f,-9))
rows=[('equal weight every open basket (no cap)',None,None),('cap 3, strongest 30d',feat('r30'),3),('cap 3, weakest 30d',lambda k,d:-feat('r30')(k,d),3),
      ('cap 2, strongest vs BTC 30d',feat('rel'),2),('cap 2, weakest vs BTC 30d',lambda k,d:-feat('rel')(k,d),2),
      ('cap 2, furthest above 90d low',feat('up'),2),('cap 2, closest to 90d low',lambda k,d:-feat('up')(k,d),2),
      ('cap 2, strongest 7d',feat('r7'),2),('cap 1, strongest 30d',feat('r30'),1),('cap 1, weakest 30d',lambda k,d:-feat('r30')(k,d),1),
      ('cap 2, memes first then strongest',lambda k,d:(('meme' in k or k in('sol_pump','sol_bonk'))*10+feat('r30')(k,d)),2)]
def bh(k):
    yr=collections.defaultdict(float); e=0
    for d,r in RET[k].items():
        if d>='2023-01-01': e+=math.log(1+r); yr[d[:4]]+=math.log(1+r)
    return e,yr
print(f"{'portfolio':42s} total   2023   2024   2025   2026 (log %)")
for nm,s,K in rows:
    e,y=sim(s,K); print(f"{nm:42s} {e*100:+6.0f} "+" ".join(f"{y.get(x,0)*100:+6.0f}" for x in ('2023','2024','2025','2026')))
for nm,s,K in rows[:1]:
    e,y=sim(s,K,excl_majors=True); print(f"{'(same, without majors basket)':42s} {e*100:+6.0f} "+" ".join(f"{y.get(x,0)*100:+6.0f}" for x in ('2023','2024','2025','2026')))
for k in ('majors',):
    e,y=bh(k); print(f"{'hold majors basket':42s} {e*100:+6.0f} "+" ".join(f"{y.get(x,0)*100:+6.0f}" for x in ('2023','2024','2025','2026')))
yb=collections.defaultdict(float); eb=0
for d,r in BR.items():
    if d>='2023-01-01': eb+=math.log(1+r); yb[d[:4]]+=math.log(1+r)
print(f"{'hold BTC':42s} {eb*100:+6.0f} "+" ".join(f"{yb.get(x,0)*100:+6.0f}" for x in ('2023','2024','2025','2026')))
ys=collections.defaultdict(list)
for k in keys:
    if k in('majors',): continue
    e,y=bh(k)
    for x,v in y.items(): ys[x].append(v)
print(f"{'hold every alt basket (median)':42s}        "+" ".join(f"{S.median(ys[x])*100:+6.0f}" for x in ('2023','2024','2025','2026')))
json.dump({k:{d:w for d,w in v.items() if w} for k,v in POS.items()},open('posC.json','w'))
print()
def sim2(select,K):
    eq=0; pk=0; mdd=0; prev={}; expo=[]
    for d in days:
        if d<'2023-01-01': continue
        opn=[(k,POS[k][d]) for k in keys if POS[k].get(d,0)>0 and d in RET[k]]
        if K and select and opn: opn=sorted(opn,key=lambda x:select(x[0],d),reverse=True)[:K]
        den=K if K else max(len(opn),1); wts={k:w/den for k,w in opn}
        turn=sum(abs(wts.get(k,0)-prev.get(k,0)) for k in set(wts)|set(prev))
        r=sum(wt*RET[k][d] for k,wt in wts.items())-turn*0.005; prev=wts
        eq+=math.log(1+r); pk=max(pk,eq); mdd=min(mdd,eq-pk); expo.append(sum(wts.values()))
    return eq,mdd,S.mean(expo)
for K in (1,2,3,4,5):
    e,m,x=sim2(feat('r30'),K); print(f"cap {K} strongest 30d: total {e*100:+.0f} log%, worst drop {(math.exp(m)-1)*100:.0f}%, avg money in {x*100:.0f}%")
e=0;pk=0;mdd=0
for d in sorted(BR):
    if d<'2023-01-01': continue
    e+=math.log(1+BR[d]); pk=max(pk,e); mdd=min(mdd,e-pk)
print(f"BTC hold: total {e*100:+.0f}, worst drop {(math.exp(mdd)-1)*100:.0f}%")
ev3.py (26 lines)
import json, collections, statistics as S, datetime as dt, math
import rule3
D=json.load(open('bk_clean.json'))
btc={d:v for d,v in D['btc']['line']}
bon=rule3.sma_on(sorted(btc),btc)
mj=D['majors']['line']; mon=rule3.donchian_on([d for d,_ in mj],[v for _,v in mj])
allt=[]; per={}
for k,v in D.items():
    if k=='btc': continue
    ds=[d for d,_ in v['line']]; L=[x for _,x in v['line']]
    ts,_=rule3.run(ds,L,bon,mon,is_majors=(k=='majors'))
    ts=[dict(t,b=k) for t in ts if t['status']=='closed']; allt+=ts; per[k]=rule3.stats(ts)
w=collections.defaultdict(list)
for t in allt: w[dt.date.fromisoformat(t['entry']).isocalendar()[:2]].append(t['ret'])
xs=[S.mean(v) for v in w.values()]
wk=dict(weeks=len(xs),mean=round(S.mean(xs)*100,1),win=round(sum(1 for x in xs if x>0)/len(xs)*100),pf=round(sum(x for x in xs if x>0)/-sum(x for x in xs if x<=0),2))
yr={y:rule3.stats([t for t in allt if t['entry'][:4]==y]) for y in ('2023','2024','2025','2026')}
ev={"version":"rule3","tested":"14 point in time CoinMarketCap baskets (Solana pump.fun, bonk.fun, older memes, AI, DeFi; Base memes, AI, DeFi; TON; Ethereum memes, AI; BNB memes, AI; majors), dead coins counted, Jan 2023 to Sep 2026, 1% round trip cost",
    "all":rule3.stats(allt),"h1":rule3.stats([t for t in allt if t['entry']<'2024-09-01']),"h2":rule3.stats([t for t in allt if t['entry']>='2024-09-01']),
    "weekly":wk,"by_year":yr,"by_basket":per,
    "rotation":{"total_log":154,"btc_log":161,"money_in":25,"worst":-36,"btc_worst":-53,"by_year":{"2023":[45,98],"2024":[91,76],"2025":[-7,-6],"2026":[26,-6]},"note":"3 strongest risk on baskets by 30 day return, equal slots, cash otherwise; log % by year [rotation, BTC]"},
    "compare":{"no majors gate":{"h2":"76 trades +3.4%? see it2d"},"strongest_vs_weakest":"cap 3: strongest +154 vs weakest +87; cap 2 vs BTC: +160 vs +29; cap 1: +57 vs +2"},
    "trades":[[t['b'],t['entry'],t['trim'],t['exit'],round(t['ret']*100,1)] for t in allt]}
ev.pop("compare")
json.dump(ev,open('evidence4.json','w'))
print(json.dumps({k:v for k,v in ev.items() if k!='trades'},indent=0))
rot3.py (37 lines)
import json, math, collections, statistics as S
import rule3
D=json.load(open('bk_clean.json'))
btc={d:v for d,v in D['btc']['line']}; bon=rule3.sma_on(sorted(btc),btc)
mj=D['majors']['line']; mon=rule3.donchian_on([d for d,_ in mj],[v for _,v in mj])
W={};RET={};R30={}
for k,v in D.items():
    if k=='btc': continue
    ds=[d for d,_ in v['line']]; L=[x for _,x in v['line']]
    _,w=rule3.run(ds,L,bon,mon,is_majors=(k=='majors'))
    W[k]=dict(zip(ds,w)); RET[k]={ds[i]:L[i+1]/L[i]-1 for i in range(len(L)-1)}
    R30[k]={ds[i]:L[i]/L[i-30]-1 for i in range(30,len(L))}
days=sorted({d for k in W for d in W[k]})
def sim(K,sign=1):
    eq=0;pk=0;mdd=0;prev={};yr=collections.defaultdict(float);ex=[]
    for d in days:
        if d<'2023-01-01': continue
        o=[(k,W[k][d]) for k in W if W[k].get(d,0)>0 and d in RET[k]]
        o=sorted(o,key=lambda x:sign*R30[x[0]].get(d,-9))[::-1][:K]
        wt={k:w/K for k,w in o}
        turn=sum(abs(wt.get(k,0)-prev.get(k,0)) for k in set(wt)|set(prev))
        r=sum(x*RET[k][d] for k,x in wt.items())-turn*0.005; prev=wt
        lr=math.log(1+r); eq+=lr; yr[d[:4]]+=lr; pk=max(pk,eq); mdd=min(mdd,eq-pk); ex.append(sum(wt.values()))
    return eq,yr,mdd,S.mean(ex)
bd=sorted(btc); e=0;pk=0;mdd=0;yb=collections.defaultdict(float)
for i in range(len(bd)-1):
    if bd[i]<'2023-01-01': continue
    lr=math.log(btc[bd[i+1]]/btc[bd[i]]); e+=lr; yb[bd[i][:4]]+=lr; pk=max(pk,e); mdd=min(mdd,e-pk)
print("BTC",round(e*100),dict((k,round(v*100)) for k,v in yb.items()),round((math.exp(mdd)-1)*100))
res={}
for K in (1,2,3,4):
    for sg,nm in ((1,'strongest'),(-1,'weakest')):
        eq,yr,m,x=sim(K,sg); print(K,nm,round(eq*100),dict((k,round(v*100)) for k,v in yr.items()),'worst',round((math.exp(m)-1)*100),'in',round(x*100))
        res[f"{K} {nm}"]=dict(total=round(eq*100),by_year={k:round(v*100) for k,v in yr.items()},worst=round((math.exp(m)-1)*100),money_in=round(x*100))
res["btc"]=dict(total=round(e*100),by_year={k:round(v*100) for k,v in yb.items()},worst=round((math.exp(mdd)-1)*100))
ev=json.load(open('evidence4.json')); ev['rotation']=res; ev['rotation_note']="log % from Jan 2023; equal slots, unused slots in cash; 0.5% cost per change"
json.dump(ev,open('evidence4.json','w'))
rotation_decide.py (54 lines)
def decide(m):
    """Two separate reads, tested 2 Oct 2026 on Apr 2024 to Sep 2026 (launchpad volume from DefiLlama,
    meme prices from point in time CoinMarketCap baskets, dead coins included):
    1. ACTIVITY outlook: what this service measures. BTC jumps predict busier trenches, not higher prices.
    2. PRICE word for trench coins: taken from the tested meme basket rule in chains.json, never from activity.
    The old BUY TRENCHES word fired on 61% of days and meme baskets after it did no better than any day
    (14 days: median -4.0%, 41% up, vs -3.8%, 40% up), so it was retired."""
    r7, heat_lo, heat_hi = m["btc_r7"], m["heat_off_low"], m["heat_off_high"]
    if r7 is None:
        act = ("NO SIGNAL", "BTC price is missing, so the activity read cannot be made.")
    elif r7 >= IMPULSE_UP:
        act = ("MORE TRENCH ACTIVITY AHEAD", "BTC is up %.1f%% in 7 days. After the 16 such jumps since Apr 2024, "
               "launchpad volume was higher 14 days later 12 times (median +15%%). It did not lift meme prices: "
               "the Solana and Base meme baskets were higher 14 days later after only half of them." % r7)
    elif r7 <= IMPULSE_DOWN:
        act = ("LESS TRENCH ACTIVITY LIKELY", "BTC is down %.1f%% in 7 days. After the 17 such drops, launchpad "
               "volume was lower 14 days later 9 times (median -14%%). Meme baskets were HIGHER 14 days later 11 of 17 "
               "times (median +6%%), so a BTC drop has not been a reason to sell memes." % -r7)
    elif heat_lo is not None and heat_lo >= HEAT_UP:
        act = ("TRENCHES HEATING", "Trench share of all DEX volume is %.0f%% above its 30 day low. Context only: after "
               "39 such starts launchpad volume kept rising 62%% of the time; meme prices were up 46%% of the time, "
               "the same as any day." % heat_lo)
    elif heat_hi is not None and heat_hi <= -HEAT_DOWN:
        act = ("TRENCHES COOLING", "Trench share is %.0f%% below its 30 day high. After 38 such starts the Solana and "
               "Base meme baskets were higher 14 days later only 32%% of the time (median -6.7%%), versus 40%% on any "
               "day. A mild warning, not a sell signal on its own." % -heat_hi)
    else:
        act = ("NO CLEAR CHANGE", "BTC is %+.1f%% in 7 days and trench share is mid range." % r7)
    m["activity"], m["activity_line"] = act
    # price word from the tested basket rule
    try:
        ch = json.load(open("/opt/launchwatch/pub/chains.json"))
        tb = [b for b in ch["baskets"] if b.get("tradable")]
        sells = [b for b in tb if b["word"] == "SELL"]; buys = [b for b in tb if b["word"] == "BUY"]
        holds = [b for b in tb if b["word"] == "HOLD"]
        def nm(bs): return ", ".join(b["name"] for b in bs)
        def plan(b):
            o = b.get("open") or {}
            return "%s (bought %s, sell by %s)" % (b["name"], o.get("bought", "?"), o.get("sell_by", "?"))
        if sells:
            word, line = "SELL", "Sell %s: the basket's sell rule fired." % nm(sells)
        elif buys:
            word, line = "BUY", "Buy %s: bottomed, back above a rising trend, broke its 10 day high, BTC risk switch on." % nm(buys)
        elif holds:
            word, line = "HOLD", "Hold %s. No new trench buy." % "; ".join(plan(b) for b in holds)
        else:
            word, line = "NO BUY", "No meme basket has a buy signal."
        line += (" Rule tested on Solana and Base memes 2024 to 2026 (34 trades, +7.1% each after costs) but it "
                 "lost money on other chains (58 trades, -2.0%), so treat a BUY as a small test bet.")
    except Exception as e:
        word, line = "NO CALL", "The meme basket feed (chains.json) could not be read: %s" % str(e)[:60]
    return word, line


trench_test.py (76 lines)
"""Does the Rotation Watch word (BUY TRENCHES / HOLD / STAY OUT) predict trench ACTIVITY and meme PRICES?"""
import json, statistics as S, datetime as dt, collections
import bt_wide as W   # reuses PIT snapshots (snapB) and basket builder
rows=json.load(open('rotdb_daily.json'))
date=[r[0] for r in rows]
import bt_wide as _W
btc=[]
for r in rows:
    d1=(dt.date.fromisoformat(r[0])+dt.timedelta(1)).isoformat()   # CMC snapshot D+1 00:00 = close of D
    btc.append(r[1] or _W.BTC.get(d1))
da=[r[4] for r in rows]; pad=[r[6] for r in rows]
def mean(x,i,n):
    w=[v for v in x[max(0,i-n+1):i+1] if v]; return sum(w)/len(w) if w else None
share=[(100*pad[i]/da[i]) if (pad[i] and da[i]) else None for i in range(len(rows))]
sh7=[mean(share,i,7) for i in range(len(rows))]
words={}
for n in range(40,len(rows)):
    v=n-1
    r7=100*(btc[n]/btc[n-7]-1) if btc[n] and btc[n-7] else None
    lo=min([x for x in sh7[max(0,v-30):v+1] if x] or [0]); hi=max([x for x in sh7[max(0,v-30):v+1] if x] or [0])
    hl=100*(sh7[v]/lo-1) if sh7[v] and lo else None; hh=100*(sh7[v]/hi-1) if sh7[v] and hi else None
    if r7 is None: w=("NO CALL","")
    elif r7<=-10: w=("STAY OUT","btc down")
    elif r7>=10: w=("BUY TRENCHES","btc impulse")
    elif hl is not None and hl>=30: w=("BUY TRENCHES","share heat")
    elif hh is not None and hh<=-30: w=("STAY OUT","share cooling")
    else: w=("HOLD","")
    words[date[n]]=w
# activity outcome: launchpad 7d mean 14 days later vs now (volume read one day back)
didx={d:i for i,d in enumerate(date)}
def pad_fwd(d):
    i=didx[d]-1
    a=mean(pad,i,7); b=mean(pad,i+14,7) if i+14<len(pad) else None
    return (b/a-1) if a and b and i+14<len(pad)-1 else None
# price outcome: equal weight of the meme baskets (point in time, dead coins count)
lines={}
for k,(plat,rule) in list(W.ORIG.items())+[("eth_memes",W.NEW["eth_memes"]),("bnb_memes",W.NEW["bnb_memes"])]:
    ds,L,_=W.build(k,plat,rule); lines[k]=dict(zip(ds,L))
def basket_fwd(d,keys,h=14):
    d1=(dt.date.fromisoformat(d)+dt.timedelta(1)).isoformat(); d2=(dt.date.fromisoformat(d)+dt.timedelta(1+h)).isoformat()
    rs=[lines[k][d2]/lines[k][d1]-1 for k in keys if d1 in lines[k] and d2 in lines[k]]
    return sum(rs)/len(rs) if rs else None
SB=["sol_pump","sol_bonk","sol_memes","base_memes"]
def summ(xs):
    xs=[x for x in xs if x is not None]
    if not xs: return "n=0"
    return f"n={len(xs):3d} mean={S.mean(xs)*100:+6.1f}% med={S.median(xs)*100:+6.1f}% up={sum(x>0 for x in xs)/len(xs)*100:3.0f}%"
out={}
for label,f in (("launchpad volume 14d later",pad_fwd),("Solana+Base meme baskets 14d",lambda d:basket_fwd(d,SB)),("ETH+BNB meme baskets 14d",lambda d:basket_fwd(d,["eth_memes","bnb_memes"])),("Solana+Base meme baskets 30d",lambda d:basket_fwd(d,SB,30))):
    print("==",label)
    for wd in ("BUY TRENCHES","HOLD","STAY OUT"):
        xs=[f(d) for d,w in words.items() if w[0]==wd and d>="2024-04-01"]
        print(f"  {wd:13s} all days     {summ(xs)}")
        for why in ("btc impulse","share heat","btc down","share cooling"):
            ys=[f(d) for d,w in words.items() if w==(wd,why) and d>="2024-04-01"]
            if ys: print(f"    {why:13s}          {summ(ys)}")
    xs=[f(d) for d in words if d>="2024-04-01"]; print(f"  any day       {summ(xs)}")
    # first day of each BUY TRENCHES spell
    prev=None; ev=[]
    for d in sorted(words):
        w=words[d][0]
        if w=="BUY TRENCHES" and prev!="BUY TRENCHES" and d>="2024-04-01": ev.append(f(d))
        prev=w
    print(f"  first day of BUY TRENCHES spells {summ(ev)}")
c=collections.Counter(w[0] for d,w in words.items() if d>="2024-04-01"); print(c)
print("today:",sorted(words.items())[-3:])
json.dump({d:w for d,w in words.items()},open('trench_words.json','w'))
print("--- events")
for why in ("btc impulse","btc down","share heat","share cooling"):
    prev=None; ev=[];evp=[]
    for d in sorted(words):
        w=words[d][1]
        if w==why and prev!=why and d>="2024-04-01":
            ev.append(pad_fwd(d)); evp.append(basket_fwd(d,SB))
        prev=w
    print(why,"launchpad",summ(ev),"| meme prices",summ(evp))
closes.py (165 lines)
#!/usr/bin/env python3
"""Weekly and monthly closes: countdown, reading of every major and every index, Telegram heads up.
Hourly at :05. Writes /opt/launchwatch/pub/closes.json. Display only; never touches rotmap.py."""
import json, os, sys, time, datetime as dt, urllib.request
BASE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, BASE)
import candles as K
PUB = os.environ.get("CH_PUB", "/opt/launchwatch/pub")
OUT = os.path.join(PUB, "closes.json")
CST = os.path.join(BASE, "closes_state.json")
HIST = os.path.join(BASE, "closes_hist.json")
MAJ = [("BTC", "Bitcoin"), ("ETH", "Ethereum"), ("SOL", "Solana"), ("XRP", "XRP"), ("BNB", "BNB"),
       ("DOGE", "Dogecoin"), ("TRX", "Tron"), ("ADA", "Cardano"), ("HYPE", "Hyperliquid"), ("LINK", "Chainlink")]
WIN = {"week": (24 * 3600, 24 * 3600), "month": (48 * 3600, 48 * 3600)}   # heads up before, read out after
BKK = dt.timezone(dt.timedelta(hours=7))
def log(*a): print(dt.datetime.now(dt.timezone.utc).strftime("%H:%M:%S"), *a, flush=True)
def bkk(t): return dt.datetime.fromtimestamp(t, BKK).strftime("%a %-d %b %H:%M")

def okx(sym, bar, limit):
    u = f"https://www.okx.com/api/v5/market/candles?instId={sym}-USDT&bar={bar}&limit={limit}"
    for k in range(3):
        try:
            d = json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "Mozilla/5.0"}), timeout=20))["data"]
            return [[int(r[0]) // 1000, float(r[1]), float(r[2]), float(r[3]), float(r[4]), r[8] == "1"] for r in reversed(d)]
        except Exception as e:
            err = e; time.sleep(1 + k)
    log("okx fail", sym, bar, err); return []

def read(cs, complete_flags, period):
    """cs: candles oldest first, last may be forming. complete_flags: bool per candle."""
    if not cs: return None
    idx_closed = [i for i, f in enumerate(complete_flags) if f]
    out = {"candles": [c[:5] for c in cs[-26:]], "closed": None, "forming": None}
    if idx_closed:
        i = idx_closed[-1]; out["closed"] = K.classify(cs, i, period)
        if out["closed"]: out["closed"]["end"] = K.next_boundary(cs[i][0] + 1, period)
    if not complete_flags[-1]:
        out["forming"] = K.classify(cs, len(cs) - 1, period)
    return out

def series_from_points(points, period):
    cs = K.from_closes(points, period)
    flags = [True] * len(cs)
    if cs: flags[-1] = points[-1][0] >= K.next_boundary(cs[-1][0] + 1, period)
    return cs, flags

def main():
    now = time.time()
    hist = json.load(open(HIST)) if os.path.exists(HIST) else {}
    rows = []
    # ---- majors, straight from OKX UTC candles
    daily = {}
    for sym, name in MAJ:
        r = {"key": sym.lower(), "name": name, "sym": sym, "group": "major"}
        for bar, period in (("1Wutc", "week"), ("1Mutc", "month")):
            cs = okx(sym, bar, 30)
            if cs: r[period] = read([c[:5] for c in cs], [c[5] for c in cs], period); r[period]["price"] = cs[-1][4]
        d = okx(sym, "1Dutc", 300)
        if d: daily[sym] = d
        rows.append(r)
    # ---- majors basket: equal weight, built from daily closes
    if daily:
        days = sorted(set(t for v in daily.values() for t, *_ in v))
        lvl = 1.0; pts = []; prev = {}
        for t in days:
            rs = []
            for s, v in daily.items():
                m = {x[0]: x[4] for x in v}
                if t in m and s in prev: rs.append(m[t] / prev[s] - 1)
                if t in m: prev[s] = m[t]
            if rs: lvl *= 1 + sum(rs) / len(rs)
            pts.append([t + 86400, lvl])          # a daily candle opening at t closes at t + 1 day
        pts = [p for p in pts if p[0] <= now + 3600]
        r = {"key": "majors", "name": "Majors basket (10 coins, equal weight)", "group": "index", "chain": "Majors"}
        for period in ("week", "month"):
            cs, fl = series_from_points(pts, period); r[period] = read(cs, fl, period)
        rows.append(r)
    # ---- every chain index: daily from the chain engine state (full history), 4 hour from chains.json
    try:
        st = json.load(open(os.path.join(BASE, "state.json")))
        ch = json.load(open(os.path.join(PUB, "chains.json")))
        names = {b["key"]: b for b in ch["baskets"]}
        for key, S in st["cmc"].items():
            pts = [[int(dt.datetime.fromisoformat(d).replace(tzinfo=dt.timezone.utc).timestamp()), v] for d, v in S["line"] if v]
            b = names.get(key, {})
            r = {"key": key, "name": b.get("name", key), "group": "index", "chain": b.get("chain", ""), "freq": "1d", "tradable": b.get("tradable")}
            for period in ("week", "month"):
                cs, fl = series_from_points(pts, period); r[period] = read(cs, fl, period)
            rows.append(r)
        srcs = []
        for key, G in st.get("gt", {}).items():
            if key.endswith("_4h"): continue          # shadow copies of the daily meme baskets
            srcs.append((key, [[t + 4 * 3600, v] for t, v in G.get("hist", [])]))   # GeckoTerminal bars are stamped at their open
        try:
            R = json.load(open(os.path.join(PUB, "rh_drill.json")))
            for b in R.get("baskets", []): srcs.append(("rh_" + b["key"], [[t, v] for t, v in b.get("hist") or []]))
        except Exception as e:
            log("rh read fail", e)
        for key, pts in srcs:
            if len(pts) < 3: continue
            b = names.get(key, {})
            r = {"key": key, "name": b.get("name", key), "group": "index", "chain": b.get("chain", ""), "freq": "4h", "young": True}
            for period in ("week", "month"):
                cs, fl = series_from_points(pts, period); r[period] = read(cs, fl, period)
            rows.append(r)
    except Exception as e:
        log("index read fail", e)
    # ---- windows and the overall read
    out = {"version": "closes-1", "generated": int(now), "periods": {}, "rows": rows, "hist": hist}
    for period in ("week", "month"):
        nb, pb = K.next_boundary(now, period), K.prev_boundary(now, period)
        pre, post = WIN[period]
        phase = "pre" if nb - now <= pre else ("post" if now - pb <= post else "none")
        def done(r): return r.get(period) and r[period]["closed"] and r[period]["closed"].get("end") == pb
        maj = [r[period]["closed"]["score"] for r in rows if r["group"] == "major" and done(r)]
        idx = [r[period]["closed"]["score"] for r in rows if r["group"] == "index" and done(r)]
        waiting = [r["name"] for r in rows if r["group"] == "index" and r.get(period) and r[period]["closed"] and not done(r)]
        majf = [r[period]["forming"]["score"] for r in rows if r["group"] == "major" and r.get(period) and r[period]["forming"]]
        idxf = [r[period]["forming"]["score"] for r in rows if r["group"] == "index" and r.get(period) and r[period]["forming"]]
        out["periods"][period] = {"next": nb, "prev": pb, "phase": phase, "pre_secs": pre, "post_secs": post,
                                  "closed_majors": K.overall(maj), "closed_indices": K.overall(idx), "waiting": waiting,
                                  "forming_majors": K.overall(majf), "forming_indices": K.overall(idxf)}
    tmp = OUT + ".tmp"; json.dump(out, open(tmp, "w"), separators=(",", ":")); os.replace(tmp, OUT)
    log("wrote", OUT, len(rows), "rows")
    alerts(out, now)

def tg(text):
    try:
        cfg = json.load(open("/opt/watchdog/config.json"))["telegram"]
        b = json.dumps({"chat_id": cfg["chat_id"], "text": text}).encode()
        urllib.request.urlopen(urllib.request.Request("https://api.telegram.org/bot%s/sendMessage" % cfg["bot_token"], data=b,
                               headers={"Content-Type": "application/json"}), timeout=20)
    except Exception as e:
        log("telegram fail", type(e).__name__)

def summary(out, period, which):
    P = out["periods"][period]; rows = out["rows"]
    om, oi = P[which + "_majors"], P[which + "_indices"]
    lines = []
    for r in rows:
        if r["group"] != "major" or not r.get(period) or not r[period][which]: continue
        if which == "closed" and r[period]["closed"].get("end") != P["prev"]: continue
        x = r[period][which]; lines.append("%s %s (%+.1f%%)" % (r["sym"], x["label"].lower(), x["chg"]))
    wait = len(P.get("waiting", [])) if which == "closed" else 0
    return "majors %s (%d bullish, %d bearish), indices %s (%d bullish, %d bearish)%s\n%s" % (
        om["label"], om["bull"], om["bear"], oi["label"], oi["bull"], oi["bear"],
        (" (%d indices still waiting for their daily close)" % wait) if wait else "", "\n".join(lines))

def alerts(out, now):
    st = json.load(open(CST)) if os.path.exists(CST) else {}
    for period in ("week", "month"):
        P = out["periods"][period]; nb, pb = P["next"], P["prev"]; W = "WEEKLY" if period == "week" else "MONTHLY"
        k = "%s-pre-%d" % (period, nb)
        if P["phase"] == "pre" and k not in st:
            st[k] = int(now); tg("%s CLOSE COMING: %s Bangkok (in %dh).\nIf it closed now: %s\nDashboard: http://159.223.88.168/rotation.html" % (
                W, bkk(nb), round((nb - now) / 3600), summary(out, period, "forming")))
        k = "%s-1h-%d" % (period, nb)
        if 0 < nb - now <= 3600 + 600 and k not in st:
            st[k] = int(now); tg("%s CLOSE IN %d MINUTES (%s Bangkok)." % (W, round((nb - now) / 60), bkk(nb)))
        k = "%s-post-%d" % (period, pb)
        if P["phase"] == "post" and k not in st and now - pb < 6 * 3600:
            st[k] = int(now); tg("%s CLOSE DONE (%s Bangkok): %s\nDashboard: http://159.223.88.168/rotation.html" % (W, bkk(pb), summary(out, period, "closed")))
    json.dump(st, open(CST, "w"))

if __name__ == "__main__":
    main()
candles.py (103 lines)
"""Weekly and monthly candle reading, shared by closes.py (live) and the history test.
A candle is [t_open_unix, o, h, l, c]. Periods are UTC: weeks close Monday 00:00 UTC (07:00 Bangkok),
months close on the 1st at 00:00 UTC (07:00 Bangkok)."""
import datetime as dt

UTC = dt.timezone.utc
CATS = [  # key, score, label, plain explanation template
    ("new_high", 3.0, "NEW HIGH CLOSE", "closed above every close of the last 12 {p}s"),
    ("bull_engulf", 2.0, "BULLISH ENGULFING", "a green {p} that swallowed the whole red {p} before it"),
    ("bull_retest", 2.0, "BULLISH RETEST", "dipped back to its old high and held it as support"),
    ("bull_reject", 1.5, "BULLISH REJECTION", "broke under last {p}'s low, then closed near the top: buyers stepped in"),
    ("strong_bull", 1.5, "STRONG BULLISH CLOSE", "a big green {p} that closed above the last close"),
    ("bull_close", 1.0, "BULLISH CLOSE", "closed above last {p}'s close"),
    ("indecision", 0.0, "INDECISION", "tiny body or inside last {p}'s range: no side won"),
    ("bear_close", -1.0, "BEARISH CLOSE", "closed below last {p}'s close"),
    ("strong_bear", -1.5, "STRONG BEARISH CLOSE", "a big red {p} that closed below the last close"),
    ("bear_reject", -1.5, "BEARISH REJECTION", "broke above last {p}'s high, then closed near the bottom: sellers stepped in"),
    ("bear_retest", -2.0, "BEARISH RETEST", "bounced back to its old low and failed there"),
    ("bear_engulf", -2.0, "BEARISH ENGULFING", "a red {p} that swallowed the whole green {p} before it"),
    ("new_low", -3.0, "NEW LOW CLOSE", "closed below every close of the last 12 {p}s"),
]
CAT = {k: (s, l, e) for k, s, l, e in CATS}
BIG = {"week": 0.08, "month": 0.15}   # a "big" candle, as a share of price

def classify(cs, i, period):
    """Read candle cs[i] against what came before it. Needs at least 3 earlier candles."""
    if i < 0 or not cs: return None
    t, o, h, l, c = cs[i]
    if i == 0:   # first candle of a young index: nothing to compare with
        chg = c / o - 1 if o else 0.0
        return {"key": "first", "score": None, "label": "FIRST %s %s" % (period.upper(), "UP" if chg >= 0 else "DOWN"),
                "why": "its first %s, nothing earlier to compare with" % period, "chg": round(chg * 100, 1), "above_avg": None, "t": t}
    _, o1, h1, l1, c1 = cs[i - 1]
    prior = [x[4] for x in cs[max(0, i - 12):i]]
    older = [x[4] for x in cs[max(0, i - 13):i - 1]]
    rng = max(h - l, 1e-12); pos = (c - l) / rng; body = c - o
    chg = c / c1 - 1 if c1 else 0.0
    k = None
    if len(prior) >= 6 and c > max(prior): k = "new_high"
    elif len(prior) >= 6 and c < min(prior): k = "new_low"
    elif c > o and c1 < o1 and c >= o1 and o <= c1: k = "bull_engulf"
    elif c < o and c1 > o1 and c <= o1 and o >= c1: k = "bear_engulf"
    elif older and c > max(older) and l <= max(older) * 1.03 and c > o: k = "bull_retest"
    elif older and c < min(older) and h >= min(older) * 0.97 and c < o: k = "bear_retest"
    elif l < l1 and pos >= 0.65 and c >= c1: k = "bull_reject"
    elif h > h1 and pos <= 0.35 and c <= c1: k = "bear_reject"
    elif abs(body) <= 0.15 * rng or (h <= h1 and l >= l1 and abs(chg) < 0.02): k = "indecision"
    elif c > c1: k = "strong_bull" if chg >= BIG[period] else "bull_close"
    else: k = "strong_bear" if chg <= -BIG[period] else "bear_close"
    ma = sum(prior[-10:]) / len(prior[-10:]) if prior else None
    s, lab, ex = CAT[k]
    return {"key": k, "score": s, "label": lab, "why": ex.format(p=period), "chg": round(chg * 100, 1),
            "above_avg": (c > ma) if ma else None, "t": t}

def period_start(ts, period):
    d = dt.datetime.fromtimestamp(ts, UTC)
    if period == "week":
        d0 = (d - dt.timedelta(days=d.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
    else:
        d0 = d.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
    return int(d0.timestamp())

def next_boundary(now, period):
    d = dt.datetime.fromtimestamp(now, UTC)
    if period == "week":
        b = (d - dt.timedelta(days=d.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) + dt.timedelta(days=7)
    else:
        b = (d.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + dt.timedelta(days=32)).replace(day=1)
    return int(b.timestamp())

def prev_boundary(now, period):
    d = dt.datetime.fromtimestamp(now, UTC)
    if period == "week":
        b = (d - dt.timedelta(days=d.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
    else:
        b = d.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
    return int(b.timestamp())

def from_closes(points, period):
    """points: [[unix_ts_of_close, level], ...] sorted. A point stamped exactly on a boundary closes the period
    before it (CoinMarketCap daily snapshots are stamped 00:00 UTC = the close of the previous day).
    Returns candles [start, o, h, l, c] built from closes only (wicks are close to close)."""
    out = []; prev_c = None; cur = None
    for t, v in points:
        st = period_start(t - 1, period)      # 00:00 boundary point belongs to the period that just ended
        if cur is None or cur[0] != st:
            if cur is not None: out.append(cur); prev_c = cur[4]
            o = prev_c if prev_c is not None else v
            cur = [st, o, max(o, v), min(o, v), v]
        else:
            cur[2] = max(cur[2], v); cur[3] = min(cur[3], v); cur[4] = v
    if cur is not None: out.append(cur)
    return out

def overall(scores):
    scores = [x for x in scores if x is not None]
    if not scores: return {"label": "NO DATA", "tone": "grey", "score": None, "bull": 0, "bear": 0, "flat": 0, "n": 0}
    m = sum(scores) / len(scores)
    lab = ("VERY BULLISH" if m >= 1.5 else "BULLISH" if m >= 0.75 else "SLIGHTLY BULLISH" if m >= 0.25 else
           "NEUTRAL" if m > -0.25 else "SLIGHTLY BEARISH" if m > -0.75 else "BEARISH" if m > -1.5 else "VERY BEARISH")
    tone = "green" if m >= 0.75 else "lightgreen" if m >= 0.25 else "grey" if m > -0.25 else "lightred" if m > -0.75 else "red"
    return {"label": lab, "tone": tone, "score": round(m, 2), "bull": sum(1 for s in scores if s > 0),
            "bear": sum(1 for s in scores if s < 0), "flat": sum(1 for s in scores if s == 0), "n": len(scores)}
closes_hist.py (32 lines)
"""What happened after each kind of weekly / monthly close, pooled over the 10 majors (OKX, UTC candles)."""
import json, time, urllib.request, statistics as S, collections
import candles as K
MAJ=["BTC","ETH","SOL","XRP","BNB","DOGE","TRX","ADA","HYPE","LINK"]
def okx(sym,bar,pages):
    out=[]; after=None
    for _ in range(pages):
        u=f"https://www.okx.com/api/v5/market/history-candles?instId={sym}-USDT&bar={bar}&limit=100"+(f"&after={after}" if after else "")
        d=json.load(urllib.request.urlopen(urllib.request.Request(u,headers={"User-Agent":"Mozilla/5.0"}),timeout=20))["data"]
        if not d: break
        out+=d; after=d[-1][0]; time.sleep(0.15)
    rows=sorted({int(r[0]):r for r in out}.values(),key=lambda r:int(r[0]))
    return [[int(r[0])//1000,float(r[1]),float(r[2]),float(r[3]),float(r[4]),r[8]] for r in rows]
res={}
for bar,period,pages in (("1Wutc","week",5),("1Mutc","month",2)):
    agg=collections.defaultdict(list)
    for s in MAJ:
        cs=[r[:5] for r in okx(s,bar,pages) if r[5]=="1"]   # closed candles only
        for i in range(3,len(cs)-1):
            r=K.classify(cs,i,period)
            if r: agg[r["key"]].append(cs[i+1][4]/cs[i][4]-1)
        time.sleep(0.2)
    allx=[x for v in agg.values() for x in v]
    res[period]={"base":{"n":len(allx),"mean":round(S.mean(allx)*100,1),"up":round(sum(x>0 for x in allx)/len(allx)*100)}}
    for k,_,lab,_ in K.CATS:
        v=agg.get(k,[])
        if v: res[period][k]={"n":len(v),"mean":round(S.mean(v)*100,1),"med":round(S.median(v)*100,1),"up":round(sum(x>0 for x in v)/len(v)*100)}
json.dump(res,open('closes_hist.json','w'))
for p in res:
    print(p,res[p]["base"])
    for k,v in res[p].items():
        if k!="base": print("  ",k,v)
bt_wide.py (64 lines)
"""Bigger sample for the frozen chain-2 rule: more years (from Sep 2022) and meme baskets on other chains.
Nothing is re-tuned: same rule2 defaults. Everything outside the original 4 baskets in 2024-2026 is out of sample."""
import json, glob, os, datetime as dt, statistics as S, collections
import rule2
from baskets_def import CMC_BASKETS, EXCLUDE, MEME
files=sorted(glob.glob('snapB/*.json')); days=[os.path.basename(f)[:10] for f in files]
snap={d:json.load(open(f)) for d,f in zip(days,files)}
px={d:{c[0]:c[6] for c in s if c[6]} for d,s in snap.items()}
BTC={d:c[6] for d,s in snap.items() for c in s if c[1]=="BTC" and c[3] is None and c[6]}
risk=rule2.risk_series(sorted(BTC),BTC)
has=lambda t,s:bool(set(t)&s)
ORIG={b[0]:(b[1],b[3]) for b in CMC_BASKETS if b[0] in ("sol_pump","sol_bonk","sol_memes","base_memes")}
NEW={"eth_memes":("Ethereum",lambda t:has(t,MEME)),"bnb_memes":("BNB Smart Chain (BEP20)",lambda t:has(t,MEME) or "four-meme-ecosystem" in t),
     "sui_memes":("Sui Network",lambda t:has(t,MEME)),"tron_memes":("Tron20",lambda t:has(t,MEME) or "sun-pump-ecosystem" in t),
     "avax_memes":("Avalanche C-Chain",lambda t:has(t,MEME)),"arb_memes":("Arbitrum",lambda t:has(t,MEME)),
     "poly_memes":("Polygon",lambda t:has(t,MEME)),"ton_memes":("TON",lambda t:has(t,MEME)),"rh_memes":("Robinhood Chain",lambda t:has(t,MEME))}
def build(key,plat,rule,minvol=25000,topn=15):
    ex=EXCLUDE.get(key,set()); line=[]; lvl=1.0; mem=None; started=False; nm=[]
    for i in range(len(days)-1):
        d,n=days[i],days[i+1]
        if mem is None or dt.date.fromisoformat(d).weekday()==0:
            xs=[c for c in snap[d] if c[3]==plat and not c[9] and c[1] not in ex and rule(c[10]) and c[8]>=minvol and c[6]>0 and c[7]>0]
            xs.sort(key=lambda c:-c[7]); m=[c[0] for c in xs[:topn]]; mem=m if len(m)>=4 else None
            if mem: nm.append(len(m))
        if mem is None:
            if started: line.append((n,lvl))   # flat while too few members, keeps the line continuous
            continue
        rs=[]
        for cid in mem:
            a=px[d].get(cid); z=px[n].get(cid)
            if not a: continue
            if z is None:
                if any(cid in px[days[k]] for k in range(i+2,min(i+5,len(days)))): continue
                rs.append(-0.5); continue
            rs.append(max(-0.9,min(2.0,z/a-1)))
        if len(rs)<4:
            if started: line.append((n,lvl))
            continue
        started=True; lvl*=1+sum(rs)/len(rs); line.append((n,lvl))
    return [d for d,_ in line],[x for _,x in line],(round(S.mean(nm),1) if nm else 0)
res={"by_basket":{},"groups":{}}; allt=[]
for k,(plat,rule) in list(ORIG.items())+list(NEW.items()):
    ds,L,avgm=build(k,plat,rule)
    if len(L)<60: res["by_basket"][k]={"n":0,"note":"too little history","days":len(L)}; continue
    ts=[t for t in rule2.trades(ds,L,risk) if t["status"]=="closed"]
    for t in ts: t["b"]=k; t["group"]=("A in sample" if k in ORIG and t["sig"]>="2024-01-01" else ("B old years" if k in ORIG else "C new chains"))
    allt+=ts; res["by_basket"][k]=dict(rule2.stats(ts),first=ds[0],avg_members=avgm)
for g in ("A in sample","B old years","C new chains"):
    res["groups"][g]=rule2.stats([t for t in allt if t["group"]==g])
oos=[t for t in allt if t["group"]!="A in sample"]
res["groups"]["out of sample (B+C)"]=rule2.stats(oos); res["groups"]["everything"]=rule2.stats(allt)
# clustering: trades that start the same week are not independent; average them per week
def weekly(ts):
    w=collections.defaultdict(list)
    for t in ts: w[dt.date.fromisoformat(t["sig"]).isocalendar()[:2]].append(t["ret"])
    xs=[S.mean(v) for v in w.values()]; pos=[x for x in xs if x>0]; neg=[x for x in xs if x<=0]
    return dict(weeks=len(xs),mean=round(S.mean(xs)*100,1),win=round(len(pos)/len(xs)*100),pf=round(sum(pos)/-sum(neg),2) if neg else None)
res["weekly_everything"]=weekly(allt); res["weekly_oos"]=weekly(oos)
yr=collections.defaultdict(list)
for t in allt: yr[t["sig"][:4]].append(t)
res["by_year"]={y:rule2.stats(v) for y,v in sorted(yr.items())}
res["trades"]=[[t["b"],t["sig"],t["exit"],round(t["ret"]*100,1),t["why"],t["bars"],t["group"]] for t in allt]
json.dump(res,open('bt_wide.json','w'))
print(json.dumps({k:v for k,v in res.items() if k!="trades"},indent=0))
fetch_daily_b.py (38 lines)
import json, os, sys, time, datetime as dt, urllib.request, concurrent.futures as cf
U="https://api.coinmarketcap.com/data-api/v3/cryptocurrency/listings/historical?date={}&limit=5000&start={}&convertId=2781"
PLAT={"Solana","Base","TON","Arc","Ink","Robinhood Chain"}
KEEP_SYMS={"SOL","TON","BTC","ETH","BNB","SUI","TRX","AVAX"}
MEMET={"robinhood-memes","memes","animal-memes","ip-memes","political-memes","celebrity-memes","cat-themed","doggone-doggerel","ai-memes","ton-meme-coins","stock-memes","startup-memes","tron-memes","four-meme-ecosystem","pump-fun-ecosystem","bonk-fun-ecosystem","sun-pump-ecosystem"}
BAD=("stablecoin","wrapped","liquid-staking","tokenized-gold","bridged","tokenized-stock","tokenized","rehypothecated","xstocks","ondo-stocks")
def get(d,start):
    for t in range(5):
        try:
            r=json.load(urllib.request.urlopen(urllib.request.Request(U.format(d,start),headers={"User-Agent":"Mozilla/5.0"}),timeout=90))
            return r["data"]
        except Exception as e:
            err=e; time.sleep(4*(t+1))
    raise err
def one(d):
    f=f"snapB/{d}.json"
    if os.path.exists(f): return d,"cached"
    try:
        data=get(d,1)
        if len(data)>=5000: data+=get(d,5001)
    except Exception as e: return d,"FAIL "+str(e)
    out=[]
    for c in data:
        p=(c.get("platform") or {}); pn=p.get("name"); tg=c.get("tags") or []
        meme=bool(set(tg)&MEMET)
        if pn not in PLAT and c["symbol"] not in KEEP_SYMS and not (meme and pn): continue
        if c["symbol"] in KEEP_SYMS and pn is not None and pn not in PLAT and not meme: continue
        bad=int(any(any(b in x for b in BAD) for x in tg)); q=c["quotes"][0]
        out.append([c["id"],c["symbol"],c["name"],pn,p.get("token_address"),c["cmcRank"],q["price"],q["marketCap"],q["volume24h"],bad,[x for x in tg if not x.endswith("-portfolio")]])
    json.dump(out,open(f+".tmp","w"),separators=(",",":")); os.replace(f+".tmp",f); return d,len(out)
a=dt.date.fromisoformat(sys.argv[1]); b=dt.date.fromisoformat(sys.argv[2]); ds=[]
d=b
while d>=a: ds.append(d.isoformat()); d-=dt.timedelta(1)
t0=time.time()
with cf.ThreadPoolExecutor(8) as ex:
    for i,r in enumerate(ex.map(one,ds)):
        if i%100==0 or str(r[1]).startswith("FAIL"): print(i,r,round(time.time()-t0),flush=True)
print("done",round(time.time()-t0))
rule2.py (50 lines)
"""chain-2 trading rule, shared by the live engine and the backtest.
BUY: first bar of CONFIRMED UP (90 bar low in the last 60 bars, above a rising 20 bar trend, close above the
prior BRK bar high) while the BTC risk switch is on. SELL at the first of: close below the 20 bar trend,
-STOP from entry, the basket turns HOT, or MAXH bars. One trade at a time per basket. COST is round trip."""
import math, statistics as S
from stages import stage_series, ema
BRK, STOP, MAXH, COST = 10, 0.12, 14, 0.01

def risk_series(days, btc, n=100, band=0.01):
    """BTC above its n day average by band = on, below by band = off, in between keeps the last state."""
    out = {}; prev = False; ds = [d for d in days if d in btc]
    for i, d in enumerate(ds):
        if i < n - 1: continue
        ma = sum(btc[x] for x in ds[i - n + 1:i + 1]) / n; v = btc[d]
        prev = True if v > ma * (1 + band) else (False if v < ma * (1 - band) else prev)
        out[d] = prev
    return out

def trades(keys, L, risk, brk=BRK, stop=STOP, maxh=MAXH, delay=0, cost=COST, tp=True, trend=True):
    """keys: bar labels (day strings or unix ts), L: levels, risk: dict label->bool (missing = off)."""
    st = stage_series(L, BRK=brk); e = ema([math.log(x) for x in L], 20); out = []; i = 0; prev = None
    while i < len(L):
        s = st[i][0]
        if s == "CONFIRMED UP" and prev != "CONFIRMED UP" and risk.get(keys[i]):
            j0 = i + delay
            if j0 >= len(L): break
            ent = L[j0]; ex = None; why = "day %d" % maxh
            for j in range(j0 + 1, min(j0 + maxh + 1, len(L))):
                r = L[j] / ent - 1
                if r <= -stop: ex = j; why = "stop"; break
                if trend and math.log(L[j]) < e[j]: ex = j; why = "trend"; break
                if tp and st[j][0] == "HOT": ex = j; why = "hot"; break
            status = "closed"
            if ex is None:
                if j0 + maxh > len(L) - 1: ex = len(L) - 1; status = "open"; why = ""
                else: ex = j0 + maxh
            out.append(dict(sig=keys[i], entry=keys[j0], entry_lvl=ent, exit=keys[ex] if status == "closed" else None,
                            last=keys[ex], exit_lvl=L[ex], ret=L[ex] / ent - 1 - (cost if status == "closed" else cost / 2),
                            why=why, bars=ex - j0, status=status, stop_lvl=ent * (1 - stop)))
            prev = st[ex][0]; i = ex + 1 if status == "closed" else len(L); continue
        prev = s; i += 1
    return out

def stats(ts):
    xs = [t["ret"] for t in ts if t.get("status", "closed") == "closed"]
    if not xs: return dict(n=0)
    w = [x for x in xs if x > 0]; l = [x for x in xs if x <= 0]
    return dict(n=len(xs), mean=round(S.mean(xs) * 100, 1), med=round(S.median(xs) * 100, 1), win=round(len(w) / len(xs) * 100),
                avgw=round(S.mean(w) * 100, 1) if w else None, avgl=round(S.mean(l) * 100, 1) if l else None,
                pf=round(sum(w) / -sum(l), 2) if l and sum(l) < 0 else None)
stages.py (27 lines)
import math
def ema(xs,n):
    k=2/(n+1); out=[]; e=None
    for x in xs:
        e=x if e is None else e+k*(x-e); out.append(e)
    return out
def stage_series(closes, W=90, BOT=60, BRK=30, EMA=20):
    """closes: list of positive floats (one per bar). returns list of (stage, info) per bar"""
    L=[math.log(c) for c in closes]; e=ema(L,EMA); res=[]
    for i in range(len(L)):
        if i<max(EMA,25): res.append(("WARMUP",{})); continue
        lo_w=L[max(0,i-W+1):i+1]; lo=min(lo_w); lo_i=max(0,i-W+1)+lo_w.index(lo); hi=max(lo_w)
        age=i-lo_i; up=math.exp(L[i]-lo)-1
        rng=hi-lo; pos=(L[i]-lo)/rng if rng>0 else 0.5
        above=L[i]>e[i]; rising=e[i]>e[i-5]
        prior=L[max(0,i-BRK):i]; brk=L[i]>=max(prior) if prior else False
        m=L[max(0,i-19):i+1]; mu=sum(m)/len(m); sd=(sum((x-mu)**2 for x in m)/len(m))**0.5; z=(L[i]-mu)/sd if sd>0 else 0
        info=dict(age=age,up=up,pos=pos,above=above,rising=rising,brk=brk,z=z,dd=math.exp(L[i]-hi)-1)
        if age<=2: st="FALLING"
        elif z>=2 and pos>0.8: st="HOT"
        elif age<=BOT and brk and above and rising: st="CONFIRMED UP"
        elif age<=BOT and age>=4 and above and rising: st="TURNING UP"
        elif age<=BOT and not (above and rising): st="BOTTOMING"
        elif above and rising: st="UPTREND"
        else: st="DRIFTING DOWN"
        res.append((st,info))
    return res
baskets_def.py (21 lines)
MEME={"robinhood-memes","memes","animal-memes","ip-memes","political-memes","celebrity-memes","cat-themed","doggone-doggerel","ai-memes","ton-meme-coins","stock-memes","startup-memes"}
AI={"ai-big-data","ai-agents","ai-memes","defai","ai-applications","generative-ai","ai-agent-launchpad","virtuals-protocol-ecosystem"}
DEFI={"defi","dex","amm","derivatives","perpetuals","lending-borowing","decentralized-exchange-dex-token","yield-farming","dex-aggregator","cross-chain-dex-aggregator","real-world-assets-protocols","payments","depin","distributed-computing"}
GAMES={"gaming","tap-to-earn","telegram-bot","play-to-earn","communications-social-media","social-token","web3"}
def has(t,s): return bool(set(t)&s)
# id, chain, name, rule(tags)->bool, what it holds
CMC_BASKETS=[
 ("sol_pump","Solana","Pump.fun coins",lambda t:"pump-fun-ecosystem" in t,"coins born on pump.fun"),
 ("sol_bonk","Solana","Bonk.fun coins",lambda t:"bonk-fun-ecosystem" in t,"coins born on bonk.fun (letsbonk)"),
 ("sol_memes","Solana","Solana older memes",lambda t:has(t,MEME) and "pump-fun-ecosystem" not in t and "bonk-fun-ecosystem" not in t,"memes not from pump.fun or bonk.fun (BONK, WIF style)"),
 ("sol_ai","Solana","Solana AI",lambda t:has(t,AI),"AI and agent coins on Solana"),
 ("sol_defi","Solana","Solana apps (DeFi)",lambda t:has(t,DEFI) and not has(t,MEME) and not has(t,AI),"exchanges, perps, lending, infra on Solana"),
 ("base_memes","Base","Base memes",lambda t:has(t,MEME) and not has(t,AI),"meme coins on Base"),
 ("base_ai","Base","Base AI (incl. Virtuals)",lambda t:has(t,AI),"AI, agent and Virtuals coins on Base"),
 ("base_defi","Base","Base apps (DeFi)",lambda t:has(t,DEFI) and not has(t,MEME) and not has(t,AI),"exchanges, lending, infra on Base"),
 ("rh_memes","Robinhood Chain","Robinhood memes (point in time)",lambda t:has(t,MEME),"meme coins on Robinhood Chain as CoinMarketCap listed them each day"),
 ("ton_all","TON","Telegram (TON) coins",lambda t:True,"every liquid coin on TON: Telegram games and memes (NOT, DOGS, CATI style)"),
]
EXCLUDE={"sol_pump":{"PUMP"}}
TRADABLE={"sol_pump","sol_bonk","sol_memes","base_memes"}   # chain-2: only these get BUY/SELL (tested)
CHAIN_LABEL={"Robinhood Chain":"Robinhood"}
fairclock.py (23 lines)
import json, glob, os, datetime as dt
def load_top(folder='top'):
    D={}
    for f in sorted(glob.glob(folder+'/*.json')): D[os.path.basename(f)[:10]]=json.load(open(f))
    return D
def universe(rows,n=100):
    xs=[r for r in rows if r[1]!="BTC" and not r[5] and r[4] and r[4]>0]; xs.sort(key=lambda r:-r[4]); return [r[0] for r in xs[:n]]
def clock(D,t,back):
    s=(dt.date.fromisoformat(t)-dt.timedelta(back)).isoformat()
    if s not in D or t not in D: return None
    p0={r[0]:r[3] for r in D[s]}; p1={r[0]:r[3] for r in D[t]}
    b0=[r[3] for r in D[s] if r[1]=="BTC"]; b1=[r[3] for r in D[t] if r[1]=="BTC"]
    if not b0 or not b1: return None
    br=b1[0]/b0[0]; U=universe(D[s]); win=sum(1 for c in U if c in p1 and p0.get(c) and p1[c]/p0[c]>br)
    return round(100*win/len(U))
if __name__=="__main__":
    D=load_top(); ds=sorted(D); out=[]
    for t in ds:
        a,b=clock(D,t,90),clock(D,t,30)
        if a is not None: out.append([t,a,b])
    json.dump(out,open('fairclock.json','w'))
    print(out[:2],out[-5:]); print("last fair washout (90d<=5):",[o[0] for o in out if o[1]<=5][-3:] if any(o[1]<=5 for o in out) else None)
    print("min 90d:",min(o[1] for o in out))
fetch_top.py (24 lines)
import json, os, sys, 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"
BAD=("stablecoin","wrapped","liquid-staking","tokenized-gold","bridged","tokenized-stock","tokenized","rehypothecated","xstocks","ondo-stocks","usd-stablecoin","fiat-stablecoin")
def compact(rows):
    out=[]
    for c in rows:
        tg=c.get("tags") or []; q=c["quotes"][0]
        out.append([c["id"],c["symbol"],c["cmcRank"],q["price"],q["marketCap"],int(any(any(b in x for b in BAD) for x in tg))])
    return out
def one(d):
    f=f"top/{d}.json"
    if os.path.exists(f): return d,"cached"
    for t in range(4):
        try:
            r=json.load(urllib.request.urlopen(urllib.request.Request(U.format(d),headers={"User-Agent":"Mozilla/5.0"}),timeout=60))["data"]
            json.dump(compact(r),open(f,"w"),separators=(",",":")); return d,len(r)
        except Exception as e: err=e; time.sleep(3*(t+1))
    return d,"FAIL "+str(err)
if __name__=="__main__":
    a=dt.date.fromisoformat(sys.argv[1]); b=dt.date.fromisoformat(sys.argv[2]); ds=[]
    while a<=b: ds.append(a.isoformat()); a+=dt.timedelta(1)
    with cf.ThreadPoolExecutor(8) as ex:
        res=list(ex.map(one,ds))
    print("fails",[r for r in res if str(r[1]).startswith("FAIL")][:5],len(res))
migrate2.py (34 lines)
"""One-off: upgrade chains state from chains-1 to chains-2. Safe to re-run."""
import json, os, sys, datetime as dt
BASE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, BASE)
import chains, fetch_top, fairclock as FC
seed = json.load(open(os.path.join(BASE, "seed2.json")))
st = json.load(open(chains.STATE))
if "rh_memes" not in st["cmc"]:
    st["cmc"]["rh_memes"] = seed["rh_memes"]
    d = dt.date.fromisoformat(seed["rh_memes"]["line"][-1][0]); end = dt.date.fromisoformat(st["cmc_day"])
    b = [x for x in chains.CMC_BASKETS if x[0] == "rh_memes"][0]
    prev = None
    while d < end:
        d0, d1 = d.isoformat(), (d + dt.timedelta(1)).isoformat()
        s0 = prev or chains.snapshot(d0); s1 = chains.snapshot(d1); prev = s1
        p0 = {c[0]: c[6] for c in s0 if c[6]}; p1 = {c[0]: c[6] for c in s1 if c[6]}
        S = st["cmc"]["rh_memes"]; rs = [max(-0.9, min(2.0, p1[c] / p0[c] - 1)) for c, _, _ in S["members"] if c in p0 and c in p1]
        lvl = S["line"][-1][1] * (1 + sum(rs) / len(rs)) if len(rs) >= 4 else S["line"][-1][1]
        S["line"].append([d1, lvl]); d += dt.timedelta(1)
st.setdefault("btc", {}).update(seed["btc"])
last = json.load(open(chains.LAST))
for c in last["rows"]:
    if c[1] == "BTC" and c[3] is None and c[6]: st["btc"][last["day"]] = c[6]
os.makedirs(chains.TOPDIR, exist_ok=True)
st["fair"] = [f for f in seed["fair"]]
d = dt.date.fromisoformat(seed["fair"][-1][0]) + dt.timedelta(1)
while d <= dt.date.fromisoformat(st["cmc_day"]):
    rows = chains.get(chains.T_URL.format(d.isoformat()))["data"]
    json.dump(fetch_top.compact(rows), open(os.path.join(chains.TOPDIR, d.isoformat() + ".json"), "w"))
    D = FC.load_top(chains.TOPDIR); a, b2 = FC.clock(D, d.isoformat(), 90), FC.clock(D, d.isoformat(), 30)
    if a is not None: st["fair"].append([d.isoformat(), a, b2])
    d += dt.timedelta(1)
st["evidence2"] = seed["evidence2"]
st.setdefault("ledger", [])
chains.atomic(chains.STATE, st); print("migrated; cmc_day", st["cmc_day"], "rh_memes points", len(st["cmc"]["rh_memes"]["line"]), "fair", st["fair"][-1])
bt_chain2.py (95 lines)
"""chain-2 rule backtest: meme baskets, 10 bar break, BTC risk on, sell rule. Entry at signal close or next close, 1% round trip."""
import json, glob, os, math, statistics as S, datetime as dt, itertools
from baskets_def import CMC_BASKETS, EXCLUDE
from stages import stage_series, ema
files=sorted(glob.glob('snap/*.json')); days=[os.path.basename(f)[:10] for f in files]
snap={d:json.load(open(f)) for d,f in zip(days,files)}
px={d:{c[0]:c[6] for c in s if c[6]} for d,s in snap.items()}
def ref(sym):
    return {d:c[6] for d,s in snap.items() for c in s if c[1]==sym and c[3] is None}
BTC=ref('BTC'); bd=sorted(BTC); ron={}
for i in range(100,len(bd)):
    ma=sum(BTC[x] for x in bd[i-99:i+1])/100
    prev=ron.get(bd[i-1],False); v=BTC[bd[i]]
    ron[bd[i]]=True if v>ma*1.01 else (False if v<ma*0.99 else prev)
MEMEK=["sol_pump","sol_bonk","sol_memes","base_memes"]
B={b[0]:b for b in CMC_BASKETS}
def build(key,minvol=25000,stale=False,miss=-0.5):
    b=B[key]; ex=EXCLUDE.get(key,set()); line=[]; lvl=1.0; mem=None; nmiss=0
    for i in range(len(days)-1):
        d,n=days[i],days[i+1]
        if mem is None or dt.date.fromisoformat(d).weekday()==0:
            xs=[c for c in snap[d] if c[3]==b[1] and not c[9] and c[1] not in ex and b[3](c[10]) and c[8]>=minvol and c[6]>0 and c[7]>0]
            if stale:
                xs=[c for c in xs if i>=3 and len({px[days[i-k]].get(c[0]) for k in range(4)})>2]
            xs.sort(key=lambda c:-c[7]); m=[c[0] for c in xs[:15]]; mem=m if len(m)>=4 else None
        if mem is None: line.append((n,None)); continue
        rs=[]
        for cid in mem:
            a=px[d].get(cid); z=px[n].get(cid)
            if not a: continue
            if z is None:
                if any(cid in px[days[k]] for k in range(i+2,min(i+5,len(days)))): continue
                rs.append(miss); nmiss+=1; continue
            rs.append(max(-0.9,min(2.0,z/a-1)))
        if len(rs)<4: line.append((n,None)); continue
        lvl*=1+sum(rs)/len(rs); line.append((n,lvl))
    pts=[(d,x) for d,x in line if x]; return [d for d,_ in pts],[x for _,x in pts],nmiss
def trades(ds,L,brk=10,stop=0.12,maxh=14,delay=0,cost=0.01,tp=True,trend=True):
    st=stage_series(L,BRK=brk); e=ema([math.log(x) for x in L],20); out=[]; i=0; prev=None
    while i<len(L):
        s=st[i][0]
        if s=="CONFIRMED UP" and prev!="CONFIRMED UP" and ron.get(ds[i]):
            j0=i+delay
            if j0>=len(L): break
            ent=L[j0]; ex=None; why="day %d"%maxh
            for j in range(j0+1,min(j0+maxh+1,len(L))):
                r=L[j]/ent-1
                if r<=-stop: ex=j;why="stop";break
                if trend and math.log(L[j])<e[j]: ex=j;why="trend";break
                if tp and st[j][0]=="HOT": ex=j;why="hot";break
            if ex is None:
                ex=min(j0+maxh,len(L)-1)
                if ex==len(L)-1 and j0+maxh>len(L)-1: why="open"
            out.append(dict(sig=ds[i],entry=ds[j0],exit=ds[ex],ret=L[ex]/ent-1-cost,why=why,days=ex-j0))
            prev="CONFIRMED UP"; i=ex+1; continue
        prev=s; i+=1
    return out
def stats(ts):
    xs=[t["ret"] for t in ts if t["why"]!="open"]
    if not xs: return dict(n=0)
    w=[x for x in xs if x>0]; l=[x for x in xs if x<=0]
    return dict(n=len(xs),mean=round(S.mean(xs)*100,1),med=round(S.median(xs)*100,1),win=round(len(w)/len(xs)*100),
                avgw=round(S.mean(w)*100,1) if w else None,avgl=round(S.mean(l)*100,1) if l else None,
                pf=round(sum(w)/-sum(l),2) if l and sum(l)<0 else None,days=round(S.mean(t["days"] for t in ts),1))
if __name__=="__main__":
    lines={k:build(k) for k in MEMEK}
    allt=[]
    res={"rule":"chain-2","per_basket":{},"variants":{},"grid":[],"halves":{}}
    for k,(ds,L,nm) in lines.items():
        t=trades(ds,L); [x.update(b=k) for x in t]; allt+=t; res["per_basket"][k]=dict(stats(t),misses=nm)
    res["all"]=stats(allt); res["trades"]=allt
    res["halves"]={"to_jun25":stats([t for t in allt if t["sig"]<"2025-07-01"]),"from_jul25":stats([t for t in allt if t["sig"]>="2025-07-01"])}
    def var(name,**kw):
        ts=[]
        for k,(ds,L,nm) in lines.items(): ts+=trades(ds,L,**kw)
        res["variants"][name]=stats(ts)
    var("enter next close",delay=1); var("no costs",cost=0.0); var("2% round trip",cost=0.02)
    var("no sell rule (hold 14)",stop=9,trend=False,tp=False); var("break 30 bars",brk=30)
    for k2,kw in (("stale filter (price moved in 3 days)",dict(stale=True)),("min volume $100k",dict(minvol=100000)),("missing coin = -100%",dict(miss=-1.0))):
        ts=[]
        for k in MEMEK:
            ds,L,_=build(k,**kw); ts+=trades(ds,L)
        res["variants"][k2]=stats(ts)
    for brk,stop,mh in itertools.product((10,20,30),(0.08,0.12,0.16),(7,14,21)):
        ts=[]
        for k,(ds,L,nm) in lines.items(): ts+=trades(ds,L,brk=brk,stop=stop,maxh=mh)
        s=stats(ts); res["grid"].append([brk,stop,mh,s.get("n"),s.get("mean"),s.get("pf")])
    # non-meme baskets under the same rule, for the record
    ts=[]
    for k in [b[0] for b in CMC_BASKETS if b[0] not in MEMEK]:
        ds,L,_=build(k); ts+=trades(ds,L)
    res["non_meme"]=stats(ts)
    json.dump(res,open('bt_chain2.json','w'),indent=0)
    print(json.dumps({k:v for k,v in res.items() if k not in("trades","grid")},indent=0))
    g=res["grid"]; pos=sum(1 for r in g if r[5] and r[5]>1); print("grid cells PF>1:",pos,"of",len(g),"| PF range",min(r[5] for r in g if r[5]),max(r[5] for r in g if r[5]))
bt_final.py (43 lines)
"""Final chain-2 evidence, computed with the same rule2 code the live engine runs. Writes evidence2.json."""
import json, itertools
import bt_chain2 as B, rule2
from baskets_def import CMC_BASKETS, TRADABLE
risk=rule2.risk_series(sorted(B.BTC),B.BTC)
lines={b[0]:B.build(b[0]) for b in CMC_BASKETS}
def run(keys,cut=None,after=None,build_kw=None,**kw):
    ts=[]
    for k in keys:
        ds,L,_=B.build(k,**build_kw) if build_kw else lines[k]
        for t in rule2.trades(ds,L,risk,**kw):
            if t["status"]!="closed": continue
            if cut and ((t["sig"]>=cut)!=after): continue
            t["b"]=k; ts.append(t)
    return ts
M=sorted(TRADABLE); NON=[b[0] for b in CMC_BASKETS if b[0] not in TRADABLE and b[0]!="rh_memes"]
ev={"rule":"chain-2","period":"Jan 2024 to Sep 2026","tradable":M}
allt=run(M); ev["all"]=rule2.stats(allt)
ev["trades"]=[[t["b"],t["sig"],t["exit"],round(t["ret"]*100,1),t["why"],t["bars"]] for t in allt]
ev["per_basket"]={k:rule2.stats([t for t in allt if t["b"]==k]) for k in M}
ev["halves"]={"to_jun25":rule2.stats(run(M,"2025-07-01",False)),"from_jul25":rule2.stats(run(M,"2025-07-01",True))}
ev["non_meme"]=rule2.stats(run(NON))
V={}
V["enter next close"]=rule2.stats(run(M,delay=1)); V["no costs"]=rule2.stats(run(M,cost=0.0)); V["2% round trip"]=rule2.stats(run(M,cost=0.02))
V["no sell rule, hold 14 days"]=rule2.stats(run(M,stop=9,trend=False,tp=False)); V["break of 30 bar high"]=rule2.stats(run(M,brk=30))
V["stale price filter"]=rule2.stats(run(M,build_kw=dict(stale=True))); V["members need $100k volume"]=rule2.stats(run(M,build_kw=dict(minvol=100000)))
V["missing coin counted as -100%"]=rule2.stats(run(M,build_kw=dict(miss=-1.0)))
V["old rule: all baskets, 30 bar break, hold 14"]=rule2.stats(run(M+NON,brk=30,stop=9,trend=False,tp=False))
ev["variants"]=V
grid=[]
for brk,stop,mh in itertools.product((10,20,30),(0.08,0.12,0.16),(7,14,21)):
    s=rule2.stats(run(M,brk=brk,stop=stop,maxh=mh)); grid.append([brk,stop,mh,s.get("n"),s.get("mean"),s.get("pf")])
ev["grid"]=grid; ev["grid_pf_range"]=[min(g[5] for g in grid if g[5]),max(g[5] for g in grid if g[5])]; ev["grid_pf_above_1"]=sum(1 for g in grid if g[5] and g[5]>1)
best=None
for g in itertools.product((10,20,30),(0.08,0.12,0.16),(7,14,21)):
    s=rule2.stats(run(M,"2025-07-01",False,brk=g[0],stop=g[1],maxh=g[2]))
    if s.get("pf") and (best is None or s["pf"]>best[0]): best=(s["pf"],)+g
ev["walk_forward"]={"meme_first":rule2.stats(run(M,"2025-07-01",False)),"non_first":rule2.stats(run(NON,"2025-07-01",False)),
    "meme_second":rule2.stats(run(M,"2025-07-01",True)),"non_second":rule2.stats(run(NON,"2025-07-01",True)),
    "best_first_half":list(best[1:]),"best_first_half_in_second":rule2.stats(run(M,"2025-07-01",True,brk=best[1],stop=best[2],maxh=best[3]))}
ev["misses"]={k:lines[k][2] for k in M}
json.dump(ev,open('evidence2.json','w'))
print(json.dumps({k:v for k,v in ev.items() if k not in ("trades","grid")},indent=0))
rule4.py (live) (136 lines)
"""Basket rule v4 (3 Oct 2026): phases BUY / HOLD / HOLD HALF / RISK OFF for every basket, plus rotation groups.
Chosen after 70 iterations (it_a .. it_f, rh_build, it_e in /home/claude/rh50; raw data published with the handover).

TURNS BULLISH (signal) on a daily close above the basket's highest close of the prior NIN days, while
  BTC closes above its 100 day average, the majors basket is risk on, at least 25% of the other tested baskets
  are risk on, and (Solana baskets only) SOL has not gained 20%+ on BTC in 4 weeks (froth).
PHASES: BUY = the signal close and the next close if price is still within 10% of the breakout level
  (buying 2+ days late lost money in 2025 to 2026); HOLD = risk on, no new money; EXTENDED: TAKE HALF = first HOT
  close (20 day z score >= 2 and top 20% of the 90 day range; the best of 10 "extended" definitions tested 3 Oct);
  EXTENDED: HOLD HALF after that; SELL = a daily close below the lowest close of the prior NOUT days.
  RISK OFF states: WAITING FOR BOTTOM (a new 20 day low in the last 5 closes; turned bullish within 14 days only
  12 to 18% of the time), BASING (no new low for 5+ closes; 30 to 35%), ALMOST BULLISH (within 5% of turning;
  49 to 73%), BLOCKED BY MARKET (above its high but the market check fails).
WINDOWS: long tested baskets 30/15; young baskets (Robinhood, Ink, Arc, Bankr, o1) the FAST rule 10/5, which
  made money on the same 14 long baskets in both halves (2023 to 2024: $3.14 won per $1 lost; 2025 to 2026: $2.10)
  and on the rebuilt Robinhood baskets caught the late August run and was out by 10 Sep, before the crash
  (the slow rule bought the top and lost 34 to 36%).
ROTATION: rotate into = baskets in the BUY phase, strongest 30 day move first, up to 3; hold = risk on;
  rotate out of = SELL or TAKE HALF today; wait = risk off."""
import math, statistics as S, datetime as dt
from stages import stage_series

SLOW, FAST, COST, TOPK = (30, 15), (10, 5), 0.01, 3
BUY_DAYS, BUY_EXT = 1, 0.10
SOLBTC_FROTH, BREADTH = 0.20, 0.25


def sma_on(days, px, n=100):
    ds = [d for d in days if px.get(d)]; out = {}
    for i, d in enumerate(ds):
        w = [px[x] for x in ds[max(0, i - n + 1):i + 1]]
        if len(w) >= int(n * 0.9): out[d] = px[d] > sum(w) / len(w)
    return out


def donchian_on(days, L, nin=30, nout=15):
    s = False; out = {}
    for i, d in enumerate(days):
        if i >= nin and L[i] > max(L[i - nin:i]): s = True
        elif i >= nout and L[i] < min(L[i - nout:i]): s = False
        out[d] = s
    return out


def to_daily(line, stamp_open=False):
    by = {}
    for t, v in line:
        if not v: continue
        if stamp_open: t += 4 * 3600
        d = dt.datetime.fromtimestamp(t - 1, dt.timezone.utc).date()   # a value seen during day D (up to 00:00 D+1) is the close of label D, like CoinMarketCap
        by[d.isoformat()] = v
    today = dt.datetime.now(dt.timezone.utc).date().isoformat()
    return sorted((d, v) for d, v in by.items() if d < today)   # finished days only, like the CoinMarketCap closes


def gate_ok(d, gates):
    """gates: dict with btc_on, maj_on, breadth (date -> share of other baskets on), froth (date -> bool)."""
    if not gates["btc_on"].get(d, False): return False, "BTC below its 100 day average"
    if not gates.get("is_majors") and not gates["maj_on"].get(d, False): return False, "majors basket risk off"
    b = gates.get("breadth", {}).get(d)
    if b is not None and b < BREADTH: return False, "fewer than 25% of other baskets risk on"
    if gates.get("froth", {}).get(d): return False, "SOL up 20%+ on BTC in 4 weeks (froth)"
    return True, ""


def run(days, L, gates, nin, nout, cost=COST):
    st = stage_series(L) if len(L) >= 26 else [("WARMUP", {})] * len(L)
    trades = []; w = [0.0] * len(L); i = nin
    while i < len(L):
        d = days[i]
        if not (L[i] > max(L[i - nin:i]) and gate_ok(d, gates)[0]): i += 1; continue
        ent = L[i]; brk = max(L[i - nin:i]); cur = 1.0; trim = None; acc = 0.0; ex = None; w[i] = 1.0
        for j in range(i + 1, len(L)):
            if L[j] < min(L[j - nout:j]): ex = j; break
            if cur == 1.0 and st[j][0] == "HOT": trim = j; acc += 0.5 * (L[j] / ent - 1); cur = 0.5
            w[j] = cur
        last = ex if ex is not None else len(L) - 1
        ret = acc + cur * (L[last] / ent - 1) - (cost if ex is not None else cost / 2)
        trades.append(dict(entry=days[i], entry_lvl=ent, brk=brk, trim=days[trim] if trim is not None else None,
                           exit=days[ex] if ex is not None else None, last=days[last], ret=ret, bars=last - i,
                           status="closed" if ex is not None else "open", weight=cur))
        if ex is None: break
        i = ex + 1
    return trades, w


def stats(ts):
    xs = [t["ret"] for t in ts if t["status"] == "closed"]
    if not xs: return dict(n=0)
    wn = [x for x in xs if x > 0]; ls = [x for x in xs if x <= 0]
    return dict(n=len(xs), mean=round(S.mean(xs) * 100, 1), med=round(S.median(xs) * 100, 1), win=round(len(wn) / len(xs) * 100),
                pf=round(sum(wn) / -sum(ls), 2) if ls and sum(ls) < 0 else None)


def read(days, L, gates, fast=False, live_start="2026-10-04"):
    n = len(L)
    if n < 12: return {"state": "TOO NEW", "word": "TOO NEW", "phase": "too_new", "days_of_data": n}
    nin, nout = FAST if fast else SLOW
    if n <= nin + 1:
        nin, nout = FAST
        fast = True
    trades, w = run(days, L, gates, nin, nout)
    last = days[-1]; cur = trades[-1] if trades else None
    hi_next = max(L[-nin:]); lo_next = min(L[-nout:])
    st = stage_series(L)[-1] if n >= 26 else ("WARMUP", {})
    ok, why_blocked = gate_ok(last, gates)
    o = {"fast": fast, "window": [nin, nout], "days_of_data": n, "gate_ok": ok, "blocked_by": why_blocked,
         "gates": {"btc": bool(gates["btc_on"].get(last)), "majors": True if gates.get("is_majors") else bool(gates["maj_on"].get(last)),
                   "breadth": gates.get("breadth", {}).get(last), "froth": bool(gates.get("froth", {}).get(last))},
         "stage": st[0], "r30": round((L[-1] / L[-31] - 1) * 100, 1) if n > 30 else None,
         "r7": round((L[-1] / L[-8] - 1) * 100, 1) if n > 7 else None,
         "up_at": round((hi_next / L[-1] - 1) * 100, 1), "down_at": round((lo_next / L[-1] - 1) * 100, 1),
         "record": stats([t for t in trades if t["entry"] < live_start]),
         "trades": [dict(t, ret=round(t["ret"] * 100, 1)) for t in trades if t["entry"] >= live_start or t["status"] == "open"],
         "line": [[d, round(v, 10)] for d, v in zip(days[-120:], L[-120:])], "w": w[-120:]}
    on = bool(cur and cur["status"] == "open")
    o["state"] = "RISK ON" if on else "RISK OFF"
    if on:
        age = cur["bars"]; ext = L[-1] / cur["brk"] - 1
        o["open"] = dict(entry=cur["entry"], entry_lvl=cur["entry_lvl"], brk=cur["brk"], trim=cur["trim"], weight=cur["weight"],
                         ret=round((L[-1] / cur["entry_lvl"] - 1) * 100, 1), days=age, above_breakout=round(ext * 100, 1))
        if cur["trim"] == last: o["word"], o["phase"] = "EXTENDED: TAKE HALF", "out"
        elif cur["trim"]: o["word"], o["phase"] = "EXTENDED: HOLD HALF", "hold"
        elif age <= BUY_DAYS and ext <= BUY_EXT: o["word"], o["phase"] = "BUY", "into"
        else: o["word"], o["phase"] = "HOLD", "hold"
    else:
        lo20 = min(L[-20:]) if n >= 20 else min(L)
        falling = min(L[-5:]) <= lo20 * 1.0001
        o["falling"] = falling
        if cur and cur["exit"] == last:
            o["word"], o["phase"] = "SELL", "out"; o["closed"] = dict(entry=cur["entry"], ret=round(cur["ret"] * 100, 1), trim=cur["trim"])
        elif L[-1] > max(L[-nin - 1:-1]) and not ok: o["word"], o["phase"] = "BLOCKED BY MARKET", "wait"
        elif o["up_at"] <= 5: o["word"], o["phase"] = "ALMOST BULLISH", "wait"
        elif falling: o["word"], o["phase"] = "WAITING FOR BOTTOM", "wait"
        else: o["word"], o["phase"] = "BASING", "wait"
    return o
chains.py (live, chains-4) (772 lines)
#!/usr/bin/env python3
"""Chain baskets: Solana, Base, TON (CMC point-in-time daily), Arc and Base launchpads (GeckoTerminal 4h),
Robinhood Chain (official indices from rh_drill.json). Hourly. Display/context only: never touches rotmap.py.
Writes /opt/launchwatch/pub/chains.json."""
import json, os, sys, time, math, datetime as dt, urllib.request
BASE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BASE)
from baskets_def import CMC_BASKETS, EXCLUDE, TRADABLE, CHAIN_LABEL
from stages import stage_series
import rule2, rule4
import fairclock as FC
PUB = os.environ.get("CH_PUB", "/opt/launchwatch/pub")
STATE = os.path.join(BASE, "state.json")
LAST = os.path.join(BASE, "last_snap.json")
OUT = os.path.join(PUB, "chains.json")
TOPDIR = os.path.join(BASE, "top")
LIVE_START = "2026-09-29"          # chain-2 live scorecard: signals from the 29 Sep 2026 close on; earlier trades are the backtest
LIVE_START_TS = 1790726400         # 2026-09-30 00:00 UTC
T_URL = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/listings/historical?date={}&limit=1000&start=1&convertId=2781"
UA = {"User-Agent": "Mozilla/5.0"}
TOPN, MINVOL, MINMEM = 15, 25000, 4
PLAT = {"Solana", "Base", "TON", "Arc", "Ink", "Robinhood Chain"}
KEEP_SYMS = {"SOL", "TON", "BTC", "ETH"}
BAD = ("stablecoin", "wrapped", "liquid-staking", "tokenized-gold", "bridged", "tokenized-stock", "tokenized",
       "rehypothecated", "xstocks", "ondo-stocks")
H_URL = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/listings/historical?date={}&limit=5000&start={}&convertId=2781"
L_URL = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/listing?start={}&limit=5000&convertId=2781&sortBy=rank&sortType=asc"
GT = "https://api.geckoterminal.com/api/v2"
log_lines = []
def log(*a):
    s = " ".join(str(x) for x in a); log_lines.append(s); print(dt.datetime.utcnow().strftime("%H:%M:%S"), s, flush=True)

def get(url, tries=3, timeout=90):
    err = None
    for t in range(tries):
        try:
            return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout))
        except Exception as e:
            err = e; time.sleep(3 * (t + 1))
    raise err

_gt_last = [0.0]
def gt(path):
    for attempt in range(3):
        w = 4.0 - (time.time() - _gt_last[0])
        if w > 0: time.sleep(w)
        _gt_last[0] = time.time()
        try:
            return json.load(urllib.request.urlopen(urllib.request.Request(GT + path, headers=UA), timeout=40))
        except Exception as e:
            if "429" in str(e) and attempt < 2: time.sleep(30); continue
            log("gt fail", path[:80], e); return None

def atomic(path, obj):
    tmp = path + ".tmp"; json.dump(obj, open(tmp, "w"), separators=(",", ":")); os.replace(tmp, path)

# ---------------- CMC point-in-time daily baskets ----------------
def compact(rows):
    out = []
    for c in rows:
        p = (c.get("platform") or {}); pn = p.get("name")
        if pn not in PLAT and c["symbol"] not in KEEP_SYMS: continue
        tg = c.get("tags") or []
        bad = int(any(any(b in x for b in BAD) for x in tg))
        q = c["quotes"][0]
        out.append([c["id"], c["symbol"], c["name"], pn, p.get("token_address"), c["cmcRank"], q["price"], q["marketCap"],
                    q["volume24h"], bad, [x for x in tg if not x.endswith("-portfolio")]])
    return out

def snapshot(day):
    a = get(H_URL.format(day, 1))["data"]; b = get(H_URL.format(day, 5001))["data"]
    if len(a) < 4000: raise RuntimeError("short snapshot %d" % len(a))
    return compact(a + b)

def select(snap, b):
    ex = EXCLUDE.get(b[0], set())
    xs = [c for c in snap if c[3] == b[1] and not c[9] and c[1] not in ex and b[3](c[10]) and c[8] >= MINVOL and c[6] > 0 and c[7] > 0]
    xs.sort(key=lambda c: -c[7]); return [[c[0], c[1], c[4]] for c in xs[:TOPN]]

def cmc_daily(st, live=None):
    """Append one point per UTC day. CoinMarketCap's historical snapshot for day D (prices at 00:00 UTC) can take a
    day to appear, so after 00:20 UTC the live listing stands in as a PROVISIONAL close; the historical snapshot
    replaces it when it arrives (only one provisional day at a time)."""
    last = json.load(open(LAST)) if os.path.exists(LAST) else None
    if last is None: log("no last snapshot"); return
    now = dt.datetime.now(dt.timezone.utc); today = now.date()
    while True:
        d0 = dt.date.fromisoformat(st["cmc_day"]); d1 = d0 + dt.timedelta(1)
        if d1 > today: break
        try:
            snap = snapshot(d1.isoformat())
        except Exception as e:
            log("snapshot", d1, "not ready:", e)
            if live and d1 == today - dt.timedelta(1) and 20 <= now.hour * 60 + now.minute <= 150 and st.get("prov") != d1.isoformat():   # label D = close at the END of day D (CoinMarketCap), so the close just made is yesterday's label
                p0 = {c[0]: c[6] for c in last["rows"] if c[6]}
                for b in CMC_BASKETS:
                    S = st["cmc"][b[0]]
                    rs = [max(-0.9, min(2.0, live[c] / p0[c] - 1)) for c, _, _ in S["members"] if c in live and p0.get(c)]
                    lvl = S["line"][-1][1]
                    if len(rs) >= MINMEM: lvl *= 1 + sum(rs) / len(rs)
                    S["line"].append([d1.isoformat(), lvl])
                btc_id = [c[0] for c in last["rows"] if c[1] == "BTC" and c[3] is None]
                if btc_id and btc_id[0] in live: st.setdefault("btc", {})[d1.isoformat()] = live[btc_id[0]]
                st["prov"] = d1.isoformat(); st["prov_px"] = {str(c): v for c, v in live.items() if c in {m[0] for b in CMC_BASKETS for m in st["cmc"][b[0]]["members"]}}
                log("provisional close from live prices for", d1)
            break
        if st.get("prov") == d1.isoformat():
            for b in CMC_BASKETS:
                L = st["cmc"][b[0]]["line"]
                if L and L[-1][0] == d1.isoformat(): L.pop()
            st["prov"] = None
        p0 = {c[0]: c[6] for c in last["rows"] if c[6]}; p1 = {c[0]: c[6] for c in snap if c[6]}
        for b in CMC_BASKETS:
            S = st["cmc"][b[0]]; rs = []
            for cid, sym, addr in S["members"]:
                a, z = p0.get(cid), p1.get(cid)
                if not a: continue
                if z is None:
                    S["missing"][str(cid)] = S["missing"].get(str(cid), 0) + 1
                    if S["missing"][str(cid)] >= 3: rs.append(-0.5)
                    continue
                rs.append(max(-0.9, min(2.0, z / a - 1)))
            lvl = S["line"][-1][1] if S["line"] else 1.0
            if len(rs) >= MINMEM: lvl *= 1 + sum(rs) / len(rs)
            S["line"].append([d1.isoformat(), lvl])
            if d1.weekday() == 0:
                m = select(snap, b)
                if len(m) >= MINMEM: S["members"] = m; S["missing"] = {}
        for c in snap:
            if c[1] == "BTC" and c[3] is None and c[6]: st.setdefault("btc", {})[d1.isoformat()] = c[6]
        try:
            os.makedirs(TOPDIR, exist_ok=True)
            import fetch_top
            rows = get(T_URL.format(d1.isoformat()))["data"]
            atomic(os.path.join(TOPDIR, d1.isoformat() + ".json"), fetch_top.compact(rows))
            D = FC.load_top(TOPDIR); a, b2 = FC.clock(D, d1.isoformat(), 90), FC.clock(D, d1.isoformat(), 30)
            if a is not None: st.setdefault("fair", []).append([d1.isoformat(), a, b2])
        except Exception as e:
            log("fair clock step failed", e)
        last = {"day": d1.isoformat(), "rows": snap}; atomic(LAST, last)
        st["cmc_day"] = d1.isoformat(); log("cmc day", d1, "appended")

def cmc_live():
    rows = []
    for s in (1, 5001):
        try: rows += get(L_URL.format(s), tries=2, timeout=60)["data"]["cryptoCurrencyList"]
        except Exception as e: log("live listing fail", s, e)
    px = {}
    for c in rows:
        for q in c.get("quotes") or []:
            if q.get("name") == "USD" and q.get("price"): px[c["id"]] = float(q["price"])
    return px

# ---------------- GeckoTerminal 4h baskets (Arc, Base launchpads) ----------------
STABLE_SYMS = {"USDC", "USDT", "USDB", "EURC", "ATC", "CIRBTC", "WETH", "ETH", "USDC.ARC", "QCAD", "BTC", "USD₮0", "USDG",
               "DAI", "USDE", "CBBTC", "WBTC", "VIRTUAL", "BNKR", "WSTETH", "CBETH", "AERO",
               "GHO", "OUSDT", "KBTC", "KHYPE", "ULTRAETH", "XVELO", "USDC.E", "USDT0", "WEETH", "EZETH"}
import re as _re
STOCK_RE = _re.compile(r"^w[A-Z0-9]+x$")
ARC_PADS = {"ARGUS", "FAZE", "LONG", "FOCI", "PP", "LUNYA", "AKA", "MINARA", "PEACH", "ARCHERY", "UNITFLOW", "SYNTHRA"}
GT_BASKETS = [
    # key, chain, name, net, source-kind, what
    ("arc_top", "Arc", "Arc top coins", "arc", "net", "the 12 deepest non-stable coins on Arc, any launchpad"),
    ("arc_pads", "Arc", "Arc launchpad tokens", "arc", "pads", "tokens of the Arc launchpads themselves (Argus, Faze, Long, Foci, Peach...)"),
    ("base_bankr", "Base", "Bankr coins", "base", "dex:bankr", "the deepest coins launched through Bankr on Base"),
    ("base_o1", "Base", "o1 launchpad coins", "base", "dex:o1-launchpad", "the deepest coins launched through the o1 launchpad on Base"),
    ("ink_memes", "Ink", "Ink top coins", "ink", "inkmeme", "the 10 deepest non-stable coins on Ink (Kraken's chain), mostly memes; stablecoins, wrapped coins and stock tokens left out"),
    ("ink_stocks", "Ink", "Ink stock tokens", "ink", "inkstock", "tokenized US stocks traded on Ink (wNVDAx, wTSLAx style); context for whether the chain's stock tokens move"),
]
def gt_candidates(net, kind):
    wide = kind in ("net", "pads", "inkmeme", "inkstock")
    path = f"/networks/{net}/pools" if wide else f"/networks/{net}/dexes/{kind.split(':')[1]}/pools"
    out = {}; now = time.time()
    for pg in range(1, 6 if wide else 4):
        d = gt(f"{path}?page={pg}&sort=h24_volume_usd_desc&include=base_token")
        if not d or not d.get("data"): break
        toks = {t["id"]: t["attributes"] for t in d.get("included", []) if t.get("type") == "token"}
        for p in d["data"]:
            a = p["attributes"]; bt = p["relationships"]["base_token"]["data"]["id"]; ta = toks.get(bt, {})
            sym = (ta.get("symbol") or a["name"].split(" / ")[0]).strip(); addr = ta.get("address") or bt.split("_", 1)[-1]
            if sym.upper() in STABLE_SYMS: continue
            liq = float(a.get("reserve_in_usd") or 0)
            age_h = (now - dt.datetime.fromisoformat(a["pool_created_at"].replace("Z", "+00:00")).timestamp()) / 3600
            if age_h < 48: continue
            if addr not in out or liq > out[addr]["liq"]:
                out[addr] = {"sym": sym, "addr": addr, "pool": a["address"], "liq": liq}
    return list(out.values())

def gt_roster(b):
    key, chain, name, net, kind, what = b
    c = gt_candidates(net, kind)
    if kind == "pads": c = [x for x in c if x["sym"].upper() in ARC_PADS and x["liq"] >= 20000]
    elif kind == "inkmeme": c = [x for x in c if not STOCK_RE.match(x["sym"]) and x["liq"] >= 20000]
    elif kind == "inkstock": c = [x for x in c if STOCK_RE.match(x["sym"]) and x["liq"] >= 20000]
    else: c = [x for x in c if x["liq"] >= 50000]
    c.sort(key=lambda x: -x["liq"])
    seen = set(); d = []
    for x in c:  # same ticker twice = a copycat contract; keep the deepest one only
        if x["sym"].upper() in seen: continue
        seen.add(x["sym"].upper()); d.append(x)
    return d[:12 if kind == "net" else 10]

def gt_bars(net, pool):
    d = gt(f"/networks/{net}/pools/{pool}/ohlcv/hour?aggregate=4&limit=180&currency=usd&token=base")
    try: return {int(r[0]): float(r[4]) for r in d["data"]["attributes"]["ohlcv_list"] if r[4]}
    except Exception: return {}

def ew_index(series_list, t_from=None):
    ts = sorted(set(t for s in series_list for t in s))
    if t_from is not None: ts = [t for t in ts if t >= t_from]
    out = []; lvl = 1.0
    for i in range(1, len(ts)):
        rs = []
        for s in series_list:
            a, z = s.get(ts[i - 1]), s.get(ts[i])
            if a and z: rs.append(max(-0.9, min(2.0, z / a - 1)))
        if rs: lvl *= 1 + sum(rs) / len(rs)
        if not out: out.append([ts[i - 1], 1.0])
        out.append([ts[i], lvl])
    return out

def merge(old, new, roster_start):
    if not old: return new
    oldd = dict((t, v) for t, v in old); newd = dict((t, v) for t, v in new)
    common = [t for t in newd if t in oldd]
    if not common: return old + [[t, v * old[-1][1] / new[0][1]] for t, v in new if t > old[-1][0]]
    if roster_start:
        c2 = [t for t in common if t <= roster_start]
        ta = max(c2) if c2 else min(common)
    else:
        ta = min(common)
    k = oldd[ta] / newd[ta]
    return [p for p in old if p[0] < ta] + [[t, v * k] for t, v in new if t >= ta]

def gt_update(st):
    now = time.time()
    for b in GT_BASKETS:
        key, net = b[0], b[3]
        S = st["gt"].setdefault(key, {"roster": [], "roster_t": 0, "roster_start": 0, "hist": [], "backfill_until": 0, "live_since": now})
        if not S["roster"] or now - S["roster_t"] > 7 * 86400:
            r = gt_roster(b)
            if len(r) >= 3:
                S["roster_start"] = int(now) if S["roster"] else 0
                S["roster"] = r; S["roster_t"] = now; log("roster", key, [x["sym"] for x in r])
            elif not S["roster"]:
                log("roster", key, "too few coins", len(r)); continue
        # these baskets are read on finished daily closes, so refresh every 6 hours plus once right after the
        # 00:00 UTC close (07:00 Bangkok); hourly refreshes only fought other droplet services for GeckoTerminal
        day0 = int(now // 86400 * 86400)
        if S.get("upd_t", 0) > now - 6 * 3600 and not (S.get("upd_t", 0) < day0 <= now): continue
        bars = [gt_bars(net, m["pool"]) for m in S["roster"]]
        ok = [x for x in bars if len(x) >= 2]
        S["upd_t"] = int(now)
        S["n_ok"] = len(ok)
        if len(ok) < 3: log(key, "too few bars"); continue
        S["hist"] = merge(S["hist"], ew_index(ok), S.get("roster_start", 0))
        if not S["backfill_until"]: S["backfill_until"] = int(now)

SHADOW_NET = {"Solana": "solana", "Base": "base"}
def token_pool(st, net, addr):
    pc = st.setdefault("poolcache", {})
    if addr in pc: return pc[addr]
    d = gt(f"/networks/{net}/tokens/{addr}/pools?page=1")
    best = None
    try:
        for p in d["data"]:
            liq = float(p["attributes"].get("reserve_in_usd") or 0)
            base = p["relationships"]["base_token"]["data"]["id"].split("_", 1)[-1]
            if base.lower() != addr.lower(): continue
            if best is None or liq > best[1]: best = (p["attributes"]["address"], liq)
    except Exception: pass
    if best: pc[addr] = best[0]
    return best[0] if best else None

def shadow_update(st):
    """4 hour shadow versions of the tradable meme baskets: same members (top 8), prices from DEX pools."""
    now = time.time()
    for key in sorted(TRADABLE):
        b = [x for x in CMC_BASKETS if x[0] == key][0]; net = SHADOW_NET.get(b[1])
        if not net: continue
        mem = [m for m in st["cmc"][key]["members"] if m[2]][:8]
        sig = ",".join(m[2] for m in mem)
        S = st["gt"].setdefault(key + "_4h", {"roster": [], "roster_t": 0, "roster_start": 0, "hist": [], "backfill_until": 0, "live_since": now, "sig": ""})
        if S.get("sig") != sig:
            r = []
            for m in mem:
                p = token_pool(st, net, m[2])
                if p: r.append({"sym": m[1], "addr": m[2], "pool": p})
            if len(r) >= 3:
                S["roster_start"] = int(now) if S["roster"] else 0
                S["roster"] = r; S["roster_t"] = now; S["sig"] = sig; log("shadow roster", key, [x["sym"] for x in r])
        if not S["roster"]: continue
        bars = [gt_bars(net, m["pool"]) for m in S["roster"]]
        ok = [x for x in bars if len(x) >= 2]; S["n_ok"] = len(ok)
        if len(ok) < 3: log(key, "shadow: too few bars"); continue
        S["hist"] = merge(S["hist"], ew_index(ok), S.get("roster_start", 0))
        if not S["backfill_until"]: S["backfill_until"] = int(now)

# ---------------- stage -> words ----------------
def verdict(stage, risk_on, info, tradable=True):
    if stage == "HOT": return "DON'T CHASE", "lightred", "Running hot: far above its own 20 bar range. Not a place to start a trade."
    if stage == "CONFIRMED UP":
        if not tradable: return "WATCH", "lightgreen", "Broke out, but baskets like this had no edge in the test (+0.1% a trade), so no buy word."
        if risk_on: return "BUY", "green", "Bottomed, turned, and broke its 10 bar high. Risk switch on."
        return "WAIT", "lightgreen", "Bottomed and broke out, but the BTC risk switch is off. Tested: that version lost money."
    if stage == "TURNING UP":
        if not tradable: return "WATCH", "grey", "Bottomed and back above its trend. Context only: this kind of basket has no tested edge."
        return "WATCH: CLOSE", "lightgreen", "Bottomed and back above its trend. A close above its 10 bar high makes it a buy."
    if stage == "BOTTOMING": return "WATCH", "grey", "Made its low %d bars ago. Not back above its trend yet." % info.get("age", 0)
    if stage == "UPTREND": return ("HOLD" if risk_on else "HOLD, RISK OFF"), "lightgreen", "Rising, but the bottom is more than 60 bars old. Late for a fresh buy."
    if stage == "FALLING": return "AVOID", "red", "Made a new 90 bar low in the last 2 bars."
    if stage == "DRIFTING DOWN": return "AVOID", "lightred", "Below a falling trend line."
    return "NO READ", "grey", "Not enough history yet."

def checks(info):
    if not info: return []
    return [["Made a 90 bar low, then held it (low %d bars ago)" % info["age"], 3 <= info["age"] <= 60],
            ["Back above its 20 bar trend", bool(info["above"])],
            ["Trend line rising", bool(info["rising"])],
            ["Broke its 10 bar high", bool(info["brk"])]]

def pct(a, b): return None if not a or not b else round((b / a - 1) * 100, 1)

def bar_label(t, freq):
    return dt.datetime.fromtimestamp(t, dt.timezone.utc).strftime("%Y-%m-%d") if freq == "1d" else t

def card(key, chain, name, what, source, freq, line, members, risk_on, flags, tradable=False, riskmap=None, shadow_of=None):
    vals = [v for _, v in line]
    st = stage_series(vals, BRK=rule2.BRK) if len(vals) >= 26 else []
    stage, info = (st[-1] if st else ("WARMUP", {}))
    word, tone, why = verdict(stage, risk_on, info, tradable or bool(shadow_of))
    step = 1 if freq == "1d" else 6  # bars per day
    r1 = pct(vals[-1 - step], vals[-1]) if len(vals) > step else None
    r7 = pct(vals[-1 - 7 * step], vals[-1]) if len(vals) > 7 * step else None
    r30 = pct(vals[-1 - 30 * step], vals[-1]) if len(vals) > 30 * step else None
    since = None
    for i in range(len(st) - 1, -1, -1):
        if st[i][0] != stage: break
        since = line[i][0]
    brk_lvl = max(vals[-1 - rule2.BRK:-1]) if len(vals) > rule2.BRK + 1 else None
    c = {"key": key, "chain": CHAIN_LABEL.get(chain, chain), "name": name, "what": what, "source": source, "freq": freq, "stage": stage,
         "since": since, "word": word, "tone": tone, "why": why, "checks": checks(info),
         "z": round(info["z"], 2) if info else None, "off_high": round(info["dd"] * 100, 1) if info else None,
         "up_from_low": round(info["up"] * 100, 1) if info else None,
         "to_break": round((brk_lvl / vals[-1] - 1) * 100, 1) if brk_lvl else None,
         "ready": bool(info) and 3 <= info.get("age", 0) <= 60 and info.get("above") and info.get("rising"),
         "ret_1d": r1, "ret_7d": r7, "ret_30d": r30, "n": len(members), "members": members,
         "line": line[-180:], "flags": flags, "tradable": tradable, "shadow_of": shadow_of, "trades": [], "record": None, "open": None}
    if (tradable or shadow_of) and riskmap is not None and len(vals) >= 26:
        keys = [bar_label(t, freq) for t, _ in line]
        rk = riskmap if freq == "1d" else {k: riskmap.get(bar_label(k, "1d")) for k in keys}
        ts = rule2.trades(keys, vals, rk)
        for t in ts:
            for f in ("entry_lvl", "exit_lvl", "stop_lvl"): t[f] = round(t[f], 8)
            t["ret"] = round(t["ret"] * 100, 1)
        live_cut = LIVE_START if freq == "1d" else LIVE_START_TS
        past = [dict(t, ret=t["ret"] / 100) for t in ts if (t["sig"] < live_cut)]
        c["record"] = rule2.stats(past) if freq == "1d" else None
        c["trades"] = [t for t in ts if t["sig"] >= live_cut]
        lastk = keys[-1]; cur = ts[-1] if ts else None
        BKK = dt.timezone(dt.timedelta(hours=7))
        def when(k, add=0):
            if freq == "1d":
                t = dt.datetime.fromisoformat(k).replace(tzinfo=dt.timezone.utc) + dt.timedelta(days=add)
                return t.astimezone(BKK).strftime("%-d %b, 07:00 Bangkok")
            return dt.datetime.fromtimestamp(k + add * 4 * 3600, BKK).strftime("%-d %b %H:%M Bangkok")
        unit = "day" if freq == "1d" else "4 hour bar"
        e20 = rule2.ema([math.log(x) for x in vals], 20)[-1]
        trend_gap = (1 - math.exp(e20) / vals[-1]) * 100
        if cur and cur["status"] == "open":
            n_in = cur["bars"]
            stop_gap = (1 - cur["stop_lvl"] / vals[-1]) * 100
            c["open"] = dict(cur, stop_below_now=round(stop_gap, 1), trend_below_now=round(trend_gap, 1),
                             bought=when(cur["entry"]), sell_by=when(cur["entry"], rule2.MAXH), day=n_in)
            plan = ("Latest sell: %s (the %d %s limit). Sells earlier if it closes below its 20 %s trend (now %.1f%% below the price), "
                    "drops 12%% under the entry price (%.1f%% below now), or runs hot." % (when(cur["entry"], rule2.MAXH), rule2.MAXH, unit, unit.split()[0] if freq == "1d" else "bar", trend_gap, stop_gap))
            if cur["entry"] == lastk:
                c["word"], c["tone"] = ("BUY", "green") if not shadow_of else ("SHADOW BUY", "lightgreen")
                c["why"] = "Bought at the close of %s: it made a 90 %s low, came back above a rising 20 %s trend, and closed above its 10 %s high while the BTC risk switch is on. %s" % (
                    when(cur["entry"]), unit, unit, unit, plan)
            else:
                c["word"], c["tone"] = ("HOLD", "green") if not shadow_of else ("SHADOW HOLD", "lightgreen")
                c["why"] = "Bought at the close of %s, now %s %d of %d (%+.1f%% so far). %s" % (when(cur["entry"]), unit, n_in, rule2.MAXH, cur["ret"], plan)
        elif cur and cur["exit"] == lastk:
            c["word"], c["tone"] = ("SELL", "red") if not shadow_of else ("SHADOW SELL", "lightred")
            c["why"] = "Sell at the close of %s: " % when(lastk) + {"stop": "it fell 12% under the entry price.", "trend": "it closed below its 20 %s trend." % unit, "hot": "it ran hot, take the profit.",
                        }.get(cur["why"], "it reached the %d %s limit." % (rule2.MAXH, unit)) + " Bought %s, result %+.1f%% after 1%% costs." % (when(cur["entry"]), cur["ret"])
        if shadow_of and c["word"] in ("HOLD", "HOLD, RISK OFF") and not (cur and cur["status"] == "open"):
            c["word"], c["tone"] = "WATCH", "lightgreen"
            c["why"] = "Rising, but no shadow trade is open. " + c["why"]
        if tradable and cur and cur["status"] == "open" and str(cur["sig"]) < LIVE_START:
            c["why"] += " This trade started before the live scorecard (from the past test)."
        if c["word"] == "BUY" and not (cur and cur["status"] == "open" and cur["entry"] == lastk):
            c["word"], c["tone"] = "WATCH", "lightgreen"
            c["why"] = "Broke out earlier, and the trade from that signal already ended. Wait for the next setup."
    return c

def ledger_update(st, cards):
    """Freeze every live trade the first time it is seen; alert on new BUY and SELL for tradable daily baskets."""
    L = st.setdefault("ledger", []); idx = {(e["basket"], str(e["sig"])): e for e in L}; alerts = []
    for c in cards:
        cur = {str(t["sig"]) for t in c["trades"]}
        for e in L:   # a signal that only existed on a provisional close and vanished with the real close
            if e["basket"] == c["key"] and e["status"] == "open" and str(e["sig"]) not in cur:
                e["status"] = "cancelled"
                if c["tradable"]: alerts.append("CANCEL %s (%s): the BUY came from a provisional close and the final close did not confirm it." % (c["name"], c["chain"]))
        for t in c["trades"]:
            k = (c["key"], str(t["sig"])); e = idx.get(k)
            row = {"basket": c["key"], "name": c["name"], "chain": c["chain"], "freq": c["freq"], "shadow": bool(c["shadow_of"]),
                   "sig": t["sig"], "entry": t["entry"], "entry_lvl": t["entry_lvl"], "exit": t["exit"], "exit_lvl": t["exit_lvl"],
                   "ret": t["ret"], "why": t["why"], "status": t["status"], "bars": t["bars"]}
            if e is None:
                L.append(row); idx[k] = row
                if c["tradable"] and row["status"] == "closed":
                    alerts.append("SELL %s (%s): signal and exit came between two checks. Result %+.1f%% after costs." % (c["name"], c["chain"], row["ret"]))
                if c["tradable"] and row["status"] == "open": alerts.append("BUY %s (%s). Bottomed and broke its 10 day high, risk switch on. Sell rule: close below the 20 day trend, -12%%, or running hot, max 14 days." % (c["name"], c["chain"]))
                e = row
            if e["status"] == "open":
                e.update(row)
                if row["status"] == "closed" and c["tradable"]:
                    alerts.append("SELL %s (%s): %s Result %+.1f%% after costs." % (c["name"], c["chain"], {"stop": "fell 12% from entry.", "trend": "closed below its 20 day trend.", "hot": "ran hot, take the profit."}.get(row["why"], "held 14 days."), row["ret"]))
    return alerts

def tg_send(text):
    try:
        cfg = json.load(open("/opt/rotation/config.json"))["telegram"]
        b = json.dumps({"chat_id": cfg["chat_id"], "text": "CHAIN BASKETS\n" + text + "\nFrom: risk on/off dashboard http://159.223.88.168/rotation.html"}).encode()
        urllib.request.urlopen(urllib.request.Request("https://api.telegram.org/bot%s/sendMessage" % cfg["bot_token"], data=b,
                               headers={"Content-Type": "application/json"}), timeout=20)
    except Exception as e:
        log("telegram fail", type(e).__name__)

def scorecard(st):
    L = st.get("ledger", [])
    real = [dict(e, ret=e["ret"] / 100) for e in L if not e["shadow"] and e["freq"] == "1d"]
    sh = [dict(e, ret=e["ret"] / 100) for e in L if e["shadow"]]
    return {"live": rule2.stats(real), "live_open": [e for e in L if not e["shadow"] and e["status"] == "open"],
            "shadow": rule2.stats(sh), "shadow_open": [e for e in L if e["shadow"] and e["status"] == "open"],
            "ledger": L[-60:], "since": LIVE_START}


# ---------------- rule v4: phases BUY / HOLD / HOLD HALF / RISK OFF for every basket, plus rotation ----------------
LIVE4_START = "2026-10-04"   # v4 live scorecard: entries from the 4 Oct 2026 close on; older open trades come from the test
MAJ3 = ["BTC", "ETH", "SOL", "XRP", "BNB", "DOGE", "TRX", "ADA", "HYPE", "LINK"]
TONE4 = {"BUY": "green", "HOLD": "green", "EXTENDED: HOLD HALF": "lightgreen", "EXTENDED: TAKE HALF": "amber", "SELL": "red",
         "ALMOST BULLISH": "amber", "BLOCKED BY MARKET": "grey", "BASING": "grey", "WAITING FOR BOTTOM": "grey", "TOO NEW": "grey"}
RENAME = {"rh_robinhood": "Robinhood 10", "rh_robinhood30": "Robinhood 30", "rh_memes": "Robinhood memes (CMC top 15)"}

def majors_daily(st):
    """Equal weight majors basket from OKX UTC daily candles, keyed like CoinMarketCap: label D = the close at the end
    of day D = the candle that opens on D. Only finished candles. Also keeps SOL and BTC closes for the froth check."""
    per = {}
    for s in MAJ3:
        u = "https://www.okx.com/api/v5/market/candles?instId=%s-USDT&bar=1Dutc&limit=300" % s
        try:
            d = json.load(urllib.request.urlopen(urllib.request.Request(u, headers=UA), timeout=20))["data"]
            per[s] = {dt.datetime.fromtimestamp(int(r[0]) // 1000, dt.timezone.utc).date().isoformat(): float(r[4])   # keyed by candle OPEN day = CoinMarketCap label (checked: BTC matches to 0.05%)
                      for r in d if r[8] == "1"}
        except Exception as e:
            log("okx majors fail", s, e)
    if "SOL" in per and "BTC" in per: st["majors3_px"] = {"SOL": per["SOL"], "BTC": per["BTC"]}
    if len(per) < 6: return []
    days = sorted(set(k for v in per.values() for k in v)); lvl = 1.0; prev = {}; out = []
    for d in days:
        rs = [per[s][d] / prev[s] - 1 for s in per if d in per[s] and s in prev]
        for s in per:
            if d in per[s]: prev[s] = per[s][d]
        if rs: lvl *= 1 + sum(rs) / len(rs)
        if rs or not out: out.append([d, lvl])
    return out

def fdate(d):
    try: return dt.date.fromisoformat(d).strftime("%-d %b")
    except Exception: return str(d)

def why4(r):
    nin, nout = r["window"]; w = r["word"]; o = r.get("open") or {}
    g = r["gates"]
    gl = "BTC %s its 100 day average; majors %s; %s of other baskets risk on%s" % (
        "above" if g["btc"] else "below", "risk on" if g["majors"] else "risk off",
        ("%d%%" % round(g["breadth"] * 100)) if g.get("breadth") is not None else "n/a", "; Solana froth (SOL +20% on BTC in 4 weeks)" if g.get("froth") else "")
    if w == "BUY": return "Turned bullish: closed above its %d day high (%s, %+.1f%% above the breakout). Buy now; from tomorrow it is a hold. Sell on a daily close below its %d day low (%+.1f%% from here)." % (nin, fdate(o.get("entry")), o.get("above_breakout", 0), nout, r["down_at"])
    if w in ("HOLD", "EXTENDED: HOLD HALF", "EXTENDED: TAKE HALF"):
        t = "Risk on since the %s close (%+.1f%% so far). " % (fdate(o.get("entry")), o.get("ret", 0))
        if w == "HOLD": t += "Hold, do not add: buying 2+ days after the turn lost money in 2025 to 2026. "
        if w == "EXTENDED: TAKE HALF": t += "Extended today (far above its normal range): take half the profit, keep the rest. "
        if w == "EXTENDED: HOLD HALF": t += "Extended since %s, half taken; hold the rest. If a fresh basket turns bullish, rotate this half into it. " % fdate(o.get("trim"))
        return t + "Sell on a daily close below its %d day low (%+.1f%% from here)." % (nout, r["down_at"])
    if w == "SELL": return "Turned bearish: closed below its %d day low. Trade from %s ended %+.1f%% after costs." % (nout, fdate((r.get("closed") or {}).get("entry")), (r.get("closed") or {}).get("ret", 0))
    if w == "BLOCKED BY MARKET": return "Closed above its %d day high, but the market check failed (%s). Tested: those breakouts lost money." % (nin, r.get("blocked_by") or gl)
    if w == "TOO NEW": return "Only %d days of data; the rule needs 12." % r.get("days_of_data", 0)
    head = {"ALMOST BULLISH": "Risk off, but within %.1f%% of turning bullish (a close above its %d day high). In the past about 6 in 10 of these turned within 14 days." % (r["up_at"], nin),
            "WAITING FOR BOTTOM": "Risk off and still making new lows (a new 20 day low in the last 5 days). Only about 1 in 7 of these turned bullish within 14 days.",
            "BASING": "Risk off, but no new low for 5+ days: a bottom may be forming. About 1 in 3 of these turned bullish within 14 days."}.get(w, "Risk off.")
    return head + " Turns bullish on a daily close above its %d day high (%+.1f%% from here) if the market check passes (now: %s)." % (nin, r["up_at"], gl)

def apply_v4(st, cards, risk_on):
    btc = st.get("btc", {}); bon = rule4.sma_on(sorted(btc), btc)
    mj = majors_daily(st)
    if mj: st["majors3"] = mj
    mj = st.get("majors3") or []
    last_day = st.get("prov") or st["cmc_day"]
    mon = rule4.donchian_on([d for d, _ in mj], [v for _, v in mj]) if mj else {}
    if mj and last_day not in mon: mon[last_day] = mon[mj[-1][0]]
    series = {}
    for b in CMC_BASKETS: series[b[0]] = [(d, v) for d, v in st["cmc"][b[0]]["line"] if v]
    for b in GT_BASKETS:
        S = st["gt"].get(b[0])
        if S and S.get("hist"): series[b[0]] = rule4.to_daily(S["hist"], stamp_open=True)
    mcap = {}
    try:
        R = json.load(open(os.path.join(PUB, "rh_drill.json")))
        for b in R.get("baskets", []):
            if b.get("hist"): series["rh_" + b["key"]] = rule4.to_daily(b["hist"])
        M = json.load(open(os.path.join(PUB, "robinhood-indices", "market.json")))
        caps = {i["id"]: sum((m.get("marketCap") or 0) for m in i.get("members") or []) for i in M.get("indices", [])}
        caps["AI"] = caps.get("ai_company_pairs", 0) + caps.get("ai_products", 0)
        mcap = {"rh_" + k: v for k, v in caps.items()}
    except Exception as e:
        log("v4 rh read fail", e)
    if mj:
        line = [[int(dt.datetime.fromisoformat(d).replace(tzinfo=dt.timezone.utc).timestamp()), v] for d, v in mj]
        c = card("majors", "Majors", "Majors basket (10 coins)", "BTC, ETH, SOL, XRP, BNB, DOGE, TRX, ADA, HYPE and LINK, equal weight",
                 "OKX daily closes", "1d", line, [[s, ""] for s in MAJ3], risk_on, [])
        c["tested"] = True; cards.insert(0, c); series["majors"] = mj
    tested = {b[0] for b in CMC_BASKETS if b[0] != "rh_memes"}
    on = {k: rule4.donchian_on([d for d, _ in series[k]], [v for _, v in series[k]]) for k in tested if k in series}
    days_all = sorted({d for k in on for d in on[k]})
    def breadth(excl):
        out = {}
        for d in days_all:
            oth = [k for k in on if k != excl and d in on[k]]
            if oth: out[d] = sum(1 for k in oth if on[k][d]) / len(oth)
        return out
    px = st.get("majors3_px") or {}
    sb = {d: px["SOL"][d] / px["BTC"][d] for d in px.get("SOL", {}) if d in px.get("BTC", {})}
    froth = {}
    for d in sb:
        d0 = (dt.date.fromisoformat(d) - dt.timedelta(28)).isoformat()
        if d0 in sb: froth[d] = sb[d] / sb[d0] - 1 >= rule4.SOLBTC_FROTH
    if sb and last_day not in froth: froth[last_day] = froth.get(max(froth), False) if froth else False
    keep = []
    for c in cards:
        if c.get("shadow_of") and c["key"].endswith("_4h"): continue          # 4 hour copies retired 3 Oct 2026
        if c["key"] in RENAME: c["name"] = RENAME[c["key"]]
        s = series.get(c["key"])
        c["v4"] = None
        if s:
            ds = [d for d, _ in s]; L = [v for _, v in s]
            g = dict(btc_on=bon, maj_on=mon, is_majors=c["key"] == "majors", breadth=breadth(c["key"]),
                     froth=froth if c["key"].startswith("sol_") else {})
            r = rule4.read(ds, L, g, fast=c["key"] not in tested and c["key"] != "majors", live_start=LIVE4_START)
            r["tested"] = c["key"] in tested or c["key"] == "majors"
            r["roster"] = "roster" in (c.get("flags") or [])
            c["v4"] = r; c["word_old"] = c["word"]
            c["word"] = r["word"]; c["tone"] = TONE4.get(r["word"], "grey"); c["why"] = why4(r) if r["word"] != "TOO NEW" else "Only %d days of data; the rule needs 12." % r.get("days_of_data", 0)
            if c.get("open") and not c.get("shadow_of") and c.get("tradable"):
                c["why"] += " Old rule (chain-2) trade still open: bought %s, sells by %s at the latest." % (c["open"].get("bought"), c["open"].get("sell_by"))
        if c["key"] == "rh_robinhood30": c["order"] = -2
        elif c["key"] == "rh_robinhood": c["order"] = -1
        elif c["key"] == "rh_memes": c["order"] = 99
        else: c["order"] = -(mcap.get(c["key"]) or 0) / 1e9
        c["mcap"] = mcap.get(c["key"])
        keep.append(c)
    cards[:] = keep
    v = [c for c in cards if c.get("v4") and c["v4"].get("phase")]
    by = lambda p: [c for c in v if c["v4"]["phase"] == p]
    into = sorted(by("into"), key=lambda c: (not c["v4"]["tested"], -(c["v4"]["r30"] if c["v4"]["r30"] is not None else -999)))
    hold = sorted(by("hold"), key=lambda c: -(c["v4"]["r30"] if c["v4"]["r30"] is not None else -999))
    outs = by("out"); wait = sorted(by("wait") + by("too_new"), key=lambda c: c["v4"].get("up_at", 999))
    # rotation swap (tested 3 Oct: +568% vs +364% without it, Jan 2023 to Sep 2026): when a fresh basket turns bullish,
    # rotate the remaining half of EXTENDED baskets into it, weakest 30 day move first
    swap = []
    if into:
        ext = sorted([c for c in hold if c["word"] == "EXTENDED: HOLD HALF"], key=lambda c: (c["v4"]["r30"] if c["v4"]["r30"] is not None else -999))
        swap = ext[:len([c for c in into if c["v4"]["tested"]])]   # only a tested fresh BUY justifies the swap (the test used tested baskets)
        for c in swap: c["v4"]["swap"] = True; c["why"] += " Rotate this half into the fresh BUY now."
        hold = [c for c in hold if c not in swap]; outs = outs + swap
    bvals = [btc[d] for d in sorted(btc)][-100:]
    mr = rule4.read([d for d, _ in mj], [x for _, x in mj], dict(btc_on=bon, maj_on=mon, is_majors=True)) if len(mj) > 31 else {}
    return {"into": [c["key"] for c in into], "top": [c["key"] for c in into[:rule4.TOPK]], "hold": [c["key"] for c in hold], "swap": [c["key"] for c in swap],
            "out": [c["key"] for c in outs], "wait": [c["key"] for c in wait], "last_day": last_day, "live_start": LIVE4_START,
            "gates": {"btc_on": bool(bon.get(last_day)), "btc_vs_ma": round((bvals[-1] / (sum(bvals) / len(bvals)) - 1) * 100, 1) if len(bvals) >= 90 else None,
                      "majors_on": bool(mon.get(last_day)), "majors_word": mr.get("word"), "majors_up_at": mr.get("up_at"), "majors_down_at": mr.get("down_at"),
                      "breadth": round(breadth(None).get(last_day, 0) * 100) if on else None, "sol_froth": bool(froth.get(last_day))},
            "evidence": st.get("evidence5")}

def ledger4_update(st, cards, v4):
    L = st.setdefault("ledger4", []); idx = {(e["basket"], e["entry"]): e for e in L}; alerts = []
    for c in cards:
        r = c.get("v4")
        if not r or not r.get("trades") and r.get("state") == "TOO NEW": continue
        tag = "" if r.get("tested") else " (fast rule, young basket)"
        cur = {t["entry"] for t in r.get("trades", [])}
        for e in L:
            if e["basket"] == c["key"] and e["status"] == "open" and e["entry"] not in cur and e["entry"] >= LIVE4_START:
                e["status"] = "cancelled"; alerts.append("CANCEL %s: the BUY came from a provisional close and the final close did not confirm it." % c["name"])
        for t in r.get("trades", []):
            k = (c["key"], t["entry"]); e = idx.get(k)
            row = {"basket": c["key"], "name": c["name"], "entry": t["entry"], "exit": t["exit"], "trim": t["trim"], "ret": t["ret"],
                   "status": t["status"], "live": t["entry"] >= LIVE4_START, "tested": r.get("tested")}
            if e is None:
                L.append(row); idx[k] = row
                if row["live"] and row["status"] == "open":
                    alerts.append("BUY %s%s: turned bullish today. Buy now; from tomorrow it is a hold. Sell on a daily close below its %d day low (now %+.1f%% from the price). Rotate out of any EXTENDED basket (half taken) into this one." % (c["name"], tag, r["window"][1], r["down_at"]))
                continue
            if e["status"] == "open":
                if row["trim"] and not e.get("trim"): alerts.append("EXTENDED %s%s: take half the profit, keep the rest until it turns bearish." % (c["name"], tag))
                if row["status"] == "closed": alerts.append("SELL %s%s: turned bearish, it closed below its %d day low. Trade from %s: %+.1f%% after costs." % (c["name"], tag, r["window"][1], fdate(row["entry"]), row["ret"]))
                e.update(row)
    return alerts

def scorecard4(st):
    L = [e for e in st.get("ledger4", []) if e.get("live")]
    sc = lambda xs: rule4.stats([dict(e, ret=e["ret"] / 100) for e in xs])
    return {"live": sc(L), "tested": sc([e for e in L if e.get("tested")]), "young": sc([e for e in L if not e.get("tested")]),
            "open": [e for e in L if e["status"] == "open"], "ledger": L[-40:], "since": LIVE4_START}


# ---------------- money flows by chain (DefiLlama, free endpoints) ----------------
FLOW_CHAINS = [("Solana", "Solana"), ("Base", "Base"), ("Robinhood", "Robinhood"), ("TON", "Ton"), ("Ink", "Ink")]

def flows_update():
    """Stablecoin supply per chain = new dollars that arrived (or left). Tested 3 Oct 2026 on Solana, Base and TON
    baskets 2024 to 2026: stablecoin flows did NOT predict basket prices, so this is context, never a buy signal."""
    out = []
    for name, slug in FLOW_CHAINS:
        try:
            st = get("https://stablecoins.llama.fi/stablecoincharts/%s" % slug, tries=2, timeout=40)
            S_ = [(dt.datetime.fromtimestamp(int(x["date"]), dt.timezone.utc).date(), (x.get("totalCirculatingUSD") or {}).get("peggedUSD") or 0) for x in st]
            S_ = [x for x in S_ if x[1]]
            days = [[(S_[i - 1][0]).isoformat(), round(S_[i][1] - S_[i - 1][1])] for i in range(1, len(S_))]   # change during that day
            now = S_[-1][1]
            c30 = [S_[i][1] - S_[i - 30][1] for i in range(30, len(S_))]
            hist = c30[-395:-30] if len(c30) > 60 else c30[:-1]
            z = None
            if len(hist) >= 30:
                mu = sum(hist) / len(hist); sd = (sum((v - mu) ** 2 for v in hist) / len(hist)) ** 0.5
                z = round((c30[-1] - mu) / sd, 2) if sd else None
            net30 = c30[-1] if c30 else None; net7 = S_[-1][1] - S_[-8][1] if len(S_) > 8 else None
            pct30 = net30 / (now - net30) * 100 if net30 is not None and now - net30 else None
            dx = []
            try:
                dd = get("https://api.llama.fi/overview/dexs/%s?excludeTotalDataChartBreakdown=true" % slug, tries=2, timeout=40)["totalDataChart"]
                dx = [[dt.datetime.fromtimestamp(int(a), dt.timezone.utc).date().isoformat(), round(b)] for a, b in dd][-60:]
            except Exception as e:
                log("flows dex fail", name, e)
            v7 = [b for _, b in dx[-7:]]; v28 = [b for _, b in dx[-28:]]
            word = "FLAT" if pct30 is None or abs(pct30) < 1 else ("MONEY COMING IN" if net30 > 0 else "MONEY LEAVING")
            size = None if z is None else ("very big" if abs(z) >= 3 else "big" if abs(z) >= 2 else "bigger than usual" if abs(z) >= 1 else "normal size")
            row = {"chain": name, "stables": round(now), "net7": net7 and round(net7), "net30": net30 and round(net30), "pct30": pct30 and round(pct30, 1),
                   "z30": z, "word": word, "size": size, "days": days[-30:], "dex": dx[-30:],
                   "dex_7v28": round((sum(v7) / len(v7)) / (sum(v28) / len(v28)) * 100 - 100, 1) if v7 and v28 and sum(v28) else None}
            if name == "Robinhood":
                try:
                    m = get("https://api.llama.fi/protocol/morpho-v1", tries=2, timeout=60).get("currentChainTvls", {})
                    row["morpho_supplied"] = round((m.get("Robinhood Chain") or 0) + (m.get("Robinhood Chain-borrowed") or 0))
                except Exception as e:
                    log("flows morpho fail", e)
            out.append(row)
        except Exception as e:
            log("flows fail", name, e)
    return {"chains": out, "generated": int(time.time()), "tested": "Stablecoin inflows did not predict basket prices on Solana, Base or TON baskets, 2024 to 2026 (next 14 days: no consistent difference between money in, flat and out). Context, not a signal."}

def main():
    st = json.load(open(STATE))
    rot = {}
    try: rot = json.load(open(os.path.join(PUB, "rotmap.json")))
    except Exception as e: log("rotmap read fail", e)
    risk = rot.get("risk") or {}; risk_on = bool(risk.get("on"))
    dead = []
    live = {}
    try: live = cmc_live()
    except Exception as e: log("cmc live fail", e)
    if not live: dead.append("CoinMarketCap live")
    try: cmc_daily(st, live)
    except Exception as e: log("cmc daily fail", e); dead.append("CoinMarketCap daily")
    try: gt_update(st)
    except Exception as e: log("gt fail", e); dead.append("GeckoTerminal")
    # shadow_update (4 hour copies of the daily meme baskets) retired 3 Oct 2026
    btc = st.get("btc", {}); riskmap = rule2.risk_series(sorted(btc), btc)
    last_day = st.get("prov") or st["cmc_day"]
    risk_on = bool(riskmap.get(last_day, risk_on))
    atomic(STATE, st)
    last = json.load(open(LAST)) if os.path.exists(LAST) else {"rows": []}
    p0 = {c[0]: c[6] for c in last["rows"] if c[6]}
    if st.get("prov"): p0 = {int(k): v for k, v in (st.get("prov_px") or {}).items()}
    cards = []
    for b in CMC_BASKETS:
        S = st["cmc"][b[0]]
        line = [[int(dt.datetime.fromisoformat(d).replace(tzinfo=dt.timezone.utc).timestamp()), v] for d, v in S["line"] if v]
        c = card(b[0], b[1], b[2], b[4], "CoinMarketCap daily, point in time", "1d", line,
                 [[s, a or ""] for _, s, a in S["members"]], risk_on, ["short"] if len(line) < 120 else [],
                 tradable=b[0] in TRADABLE, riskmap=riskmap)
        rs = [live[m[0]] / p0[m[0]] - 1 for m in S["members"] if m[0] in live and p0.get(m[0])]
        c["live_vs_close"] = round(sum(rs) / len(rs) * 100, 1) if len(rs) >= MINMEM else None
        c["tested"] = b[0] != "rh_memes"
        if c["live_vs_close"] is not None and c["to_break"] is not None:
            c["would_break_live"] = c["live_vs_close"] > c["to_break"]
        cards.append(c)
    now = time.time()
    for b in GT_BASKETS:
        S = st["gt"].get(b[0])
        if not S or len(S.get("hist", [])) < 3: continue
        flags = []
        if now - S.get("live_since", now) < 7 * 86400: flags.append("young")
        if S.get("backfill_until"): flags.append("roster")
        c = card(b[0], b[1], b[2], b[5], "GeckoTerminal 4 hour bars", "4h", S["hist"],
                 [[m["sym"], m["addr"]] for m in S["roster"]], risk_on, flags)
        c["tested"] = False; c["backfill_until"] = S.get("backfill_until"); cards.append(c)
    for key in sorted(TRADABLE):
        S = st["gt"].get(key + "_4h")
        if not S or len(S.get("hist", [])) < 3: continue
        b = [x for x in CMC_BASKETS if x[0] == key][0]
        flags = ["shadow"] + (["young"] if now - S.get("live_since", now) < 7 * 86400 else []) + (["roster"] if S.get("backfill_until") else [])
        c = card(key + "_4h", b[1], b[2] + " (4 hour)", "the same top 8 coins, priced every 4 hours from their deepest pool", "GeckoTerminal 4 hour bars",
                 "4h", S["hist"], [[m["sym"], m["addr"]] for m in S["roster"]], risk_on, flags, riskmap=riskmap, shadow_of=key)
        c["tested"] = False; c["backfill_until"] = S.get("backfill_until"); cards.append(c)
    try:
        R = json.load(open(os.path.join(PUB, "rh_drill.json")))
        for b in R.get("baskets", []):
            h = b.get("hist") or []
            if len(h) < 3: continue
            c = card("rh_" + b["key"], "Robinhood", b["name"], "official Robinhood Indices basket", "Robinhood Indices official history, 4 hour", "4h",
                     h, b.get("members") or [], risk_on, ["young"], riskmap=riskmap, shadow_of=("rh_" + b["key"]) if b["key"] in ("pure_memes", "stock_memes", "long_xyz") else None)
            c["tested"] = False; cards.append(c)
    except Exception as e:
        log("rh read fail", e)
    # overlap flags: more than half of one basket's contracts inside another
    sets = {c["key"]: {m[1].lower() for m in c["members"] if m[1]} for c in cards if not c["shadow_of"]}
    for c in cards:
        if c["shadow_of"]: continue
        a = sets[c["key"]]; ov = []
        for k2, b2 in sets.items():
            if k2 != c["key"] and a and len(a & b2) > len(a) / 2: ov.append([k2, len(a & b2), len(a)])
        c["overlap"] = ov
    alerts = [a for a in ledger_update(st, cards) if not a.startswith("BUY ")]   # chain-2 keeps its SELL for open trades only
    v4 = None
    try:
        v4 = apply_v4(st, cards, risk_on); alerts += ledger4_update(st, cards, v4)
    except Exception as e:
        import traceback; log("v4 fail", repr(e), traceback.format_exc()[-600:]); dead.append("basket rule v4")
    for a in alerts: tg_send(a); log("alert", a)
    atomic(STATE, st)
    order = {"BUY": 0, "SELL": 1, "EXTENDED: TAKE HALF": 2, "HOLD": 3, "EXTENDED: HOLD HALF": 4, "ALMOST BULLISH": 5, "BLOCKED BY MARKET": 6, "BASING": 7, "WAITING FOR BOTTOM": 8, "TOO NEW": 9}
    def rk(c):
        r = c.get("v4")
        if c.get("chain") == "Robinhood": return (c.get("order", 0), 0, 0)
        if not r: return (9, 999, 0)
        return (order.get(c["word"], 8), r.get("up_at", 999) if r.get("state") == "RISK OFF" else 0, -(r["r30"] if r.get("r30") is not None else -999))
    rank = sorted(cards, key=rk)
    closest = sorted([c for c in cards if c["tradable"] and c["ready"] and c["word"] not in ("BUY", "HOLD", "SELL") and c["to_break"] is not None], key=lambda c: c["to_break"])
    fair = st.get("fair", [])
    flows = None
    try: flows = flows_update()
    except Exception as e: log("flows fail", e)
    if flows and flows["chains"]: st["flows"] = flows
    flows = flows if flows and flows["chains"] else st.get("flows")
    out = {"version": "chains-4", "flows": flows, "v4": dict(v4 or {}, scorecard=scorecard4(st)), "rule": {"brk": rule2.BRK, "stop": rule2.STOP, "maxh": rule2.MAXH, "cost": rule2.COST, "tradable": sorted(TRADABLE)},
           "generated": int(now), "cmc_day": st["cmc_day"], "dead": dead,
           "provisional": st.get("prov"), "last_close": last_day, "risk_cmc": riskmap.get(last_day), "scorecard": scorecard(st), "evidence2": st.get("evidence2"), "evidence3": st.get("evidence3"),
           "closest": [[c["key"], c["name"], c["to_break"], c.get("would_break_live")] for c in closest],
           "fair_clock": {"now": fair[-1] if fair else None, "series": fair[-400:],
                          "last_washout": ([f[0] for f in fair if f[1] <= 5] or [None])[-1]},
           "risk": {"on": risk_on, "dist": risk.get("dist"), "since": risk.get("since"), "ma_days": risk.get("ma_days", 100)},
           "evidence": st.get("evidence"), "rank": [c["key"] for c in rank], "baskets": cards,
           "chains_order": ["Majors", "Solana", "Robinhood", "Base", "Ink", "Arc", "TON"],
           "not_built": st.get("not_built", [])}
    atomic(OUT, out); log("wrote", OUT, len(cards), "baskets; dead", dead)

if __name__ == "__main__":
    main()
closes.py (live) (165 lines)
#!/usr/bin/env python3
"""Weekly and monthly closes: countdown, reading of every major and every index, Telegram heads up.
Hourly at :05. Writes /opt/launchwatch/pub/closes.json. Display only; never touches rotmap.py."""
import json, os, sys, time, datetime as dt, urllib.request
BASE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, BASE)
import candles as K
PUB = os.environ.get("CH_PUB", "/opt/launchwatch/pub")
OUT = os.path.join(PUB, "closes.json")
CST = os.path.join(BASE, "closes_state.json")
HIST = os.path.join(BASE, "closes_hist.json")
MAJ = [("BTC", "Bitcoin"), ("ETH", "Ethereum"), ("SOL", "Solana"), ("XRP", "XRP"), ("BNB", "BNB"),
       ("DOGE", "Dogecoin"), ("TRX", "Tron"), ("ADA", "Cardano"), ("HYPE", "Hyperliquid"), ("LINK", "Chainlink")]
WIN = {"week": (24 * 3600, 24 * 3600), "month": (48 * 3600, 48 * 3600)}   # heads up before, read out after
BKK = dt.timezone(dt.timedelta(hours=7))
def log(*a): print(dt.datetime.now(dt.timezone.utc).strftime("%H:%M:%S"), *a, flush=True)
def bkk(t): return dt.datetime.fromtimestamp(t, BKK).strftime("%a %-d %b %H:%M")

def okx(sym, bar, limit):
    u = f"https://www.okx.com/api/v5/market/candles?instId={sym}-USDT&bar={bar}&limit={limit}"
    for k in range(3):
        try:
            d = json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "Mozilla/5.0"}), timeout=20))["data"]
            return [[int(r[0]) // 1000, float(r[1]), float(r[2]), float(r[3]), float(r[4]), r[8] == "1"] for r in reversed(d)]
        except Exception as e:
            err = e; time.sleep(1 + k)
    log("okx fail", sym, bar, err); return []

def read(cs, complete_flags, period):
    """cs: candles oldest first, last may be forming. complete_flags: bool per candle."""
    if not cs: return None
    idx_closed = [i for i, f in enumerate(complete_flags) if f]
    out = {"candles": [c[:5] for c in cs[-26:]], "closed": None, "forming": None}
    if idx_closed:
        i = idx_closed[-1]; out["closed"] = K.classify(cs, i, period)
        if out["closed"]: out["closed"]["end"] = K.next_boundary(cs[i][0] + 1, period)
    if not complete_flags[-1]:
        out["forming"] = K.classify(cs, len(cs) - 1, period)
    return out

def series_from_points(points, period):
    cs = K.from_closes(points, period)
    flags = [True] * len(cs)
    if cs: flags[-1] = points[-1][0] >= K.next_boundary(cs[-1][0] + 1, period)
    return cs, flags

def main():
    now = time.time()
    hist = json.load(open(HIST)) if os.path.exists(HIST) else {}
    rows = []
    # ---- majors, straight from OKX UTC candles
    daily = {}
    for sym, name in MAJ:
        r = {"key": sym.lower(), "name": name, "sym": sym, "group": "major"}
        for bar, period in (("1Wutc", "week"), ("1Mutc", "month")):
            cs = okx(sym, bar, 30)
            if cs: r[period] = read([c[:5] for c in cs], [c[5] for c in cs], period); r[period]["price"] = cs[-1][4]
        d = okx(sym, "1Dutc", 300)
        if d: daily[sym] = d
        rows.append(r)
    # ---- majors basket: equal weight, built from daily closes
    if daily:
        days = sorted(set(t for v in daily.values() for t, *_ in v))
        lvl = 1.0; pts = []; prev = {}
        for t in days:
            rs = []
            for s, v in daily.items():
                m = {x[0]: x[4] for x in v}
                if t in m and s in prev: rs.append(m[t] / prev[s] - 1)
                if t in m: prev[s] = m[t]
            if rs: lvl *= 1 + sum(rs) / len(rs)
            pts.append([t + 86400, lvl])          # a daily candle opening at t closes at t + 1 day
        pts = [p for p in pts if p[0] <= now + 3600]
        r = {"key": "majors", "name": "Majors basket (10 coins, equal weight)", "group": "index", "chain": "Majors"}
        for period in ("week", "month"):
            cs, fl = series_from_points(pts, period); r[period] = read(cs, fl, period)
        rows.append(r)
    # ---- every chain index: daily from the chain engine state (full history), 4 hour from chains.json
    try:
        st = json.load(open(os.path.join(BASE, "state.json")))
        ch = json.load(open(os.path.join(PUB, "chains.json")))
        names = {b["key"]: b for b in ch["baskets"]}
        for key, S in st["cmc"].items():
            pts = [[int(dt.datetime.fromisoformat(d).replace(tzinfo=dt.timezone.utc).timestamp()) + 86400, v] for d, v in S["line"] if v]   # label D closes at the end of day D
            b = names.get(key, {})
            r = {"key": key, "name": b.get("name", key), "group": "index", "chain": b.get("chain", ""), "freq": "1d", "tradable": b.get("tradable"), "order": b.get("order")}
            for period in ("week", "month"):
                cs, fl = series_from_points(pts, period); r[period] = read(cs, fl, period)
            rows.append(r)
        srcs = []
        for key, G in st.get("gt", {}).items():
            if key.endswith("_4h"): continue          # shadow copies of the daily meme baskets
            srcs.append((key, [[t + 4 * 3600, v] for t, v in G.get("hist", [])]))   # GeckoTerminal bars are stamped at their open
        try:
            R = json.load(open(os.path.join(PUB, "rh_drill.json")))
            for b in R.get("baskets", []): srcs.append(("rh_" + b["key"], [[t, v] for t, v in b.get("hist") or []]))
        except Exception as e:
            log("rh read fail", e)
        for key, pts in srcs:
            if len(pts) < 3: continue
            b = names.get(key, {})
            r = {"key": key, "name": b.get("name", key), "group": "index", "chain": b.get("chain", ""), "freq": "4h", "young": True, "order": b.get("order")}
            for period in ("week", "month"):
                cs, fl = series_from_points(pts, period); r[period] = read(cs, fl, period)
            rows.append(r)
    except Exception as e:
        log("index read fail", e)
    # ---- windows and the overall read
    out = {"version": "closes-1", "generated": int(now), "periods": {}, "rows": rows, "hist": hist}
    for period in ("week", "month"):
        nb, pb = K.next_boundary(now, period), K.prev_boundary(now, period)
        pre, post = WIN[period]
        phase = "pre" if nb - now <= pre else ("post" if now - pb <= post else "none")
        def done(r): return r.get(period) and r[period]["closed"] and r[period]["closed"].get("end") == pb
        maj = [r[period]["closed"]["score"] for r in rows if r["group"] == "major" and done(r)]
        idx = [r[period]["closed"]["score"] for r in rows if r["group"] == "index" and done(r)]
        waiting = [r["name"] for r in rows if r["group"] == "index" and r.get(period) and r[period]["closed"] and not done(r)]
        majf = [r[period]["forming"]["score"] for r in rows if r["group"] == "major" and r.get(period) and r[period]["forming"]]
        idxf = [r[period]["forming"]["score"] for r in rows if r["group"] == "index" and r.get(period) and r[period]["forming"]]
        out["periods"][period] = {"next": nb, "prev": pb, "phase": phase, "pre_secs": pre, "post_secs": post,
                                  "closed_majors": K.overall(maj), "closed_indices": K.overall(idx), "waiting": waiting,
                                  "forming_majors": K.overall(majf), "forming_indices": K.overall(idxf)}
    tmp = OUT + ".tmp"; json.dump(out, open(tmp, "w"), separators=(",", ":")); os.replace(tmp, OUT)
    log("wrote", OUT, len(rows), "rows")
    alerts(out, now)

def tg(text):
    try:
        cfg = json.load(open("/opt/rotation/config.json"))["telegram"]
        b = json.dumps({"chat_id": cfg["chat_id"], "text": text + "\nFrom: risk on/off dashboard http://159.223.88.168/rotation.html"}).encode()
        urllib.request.urlopen(urllib.request.Request("https://api.telegram.org/bot%s/sendMessage" % cfg["bot_token"], data=b,
                               headers={"Content-Type": "application/json"}), timeout=20)
    except Exception as e:
        log("telegram fail", type(e).__name__)

def summary(out, period, which):
    P = out["periods"][period]; rows = out["rows"]
    om, oi = P[which + "_majors"], P[which + "_indices"]
    lines = []
    for r in rows:
        if r["group"] != "major" or not r.get(period) or not r[period][which]: continue
        if which == "closed" and r[period]["closed"].get("end") != P["prev"]: continue
        x = r[period][which]; lines.append("%s %s (%+.1f%%)" % (r["sym"], x["label"].lower(), x["chg"]))
    wait = len(P.get("waiting", [])) if which == "closed" else 0
    return "majors %s (%d bullish, %d bearish), indices %s (%d bullish, %d bearish)%s\n%s" % (
        om["label"], om["bull"], om["bear"], oi["label"], oi["bull"], oi["bear"],
        (" (%d indices still waiting for their daily close)" % wait) if wait else "", "\n".join(lines))

def alerts(out, now):
    st = json.load(open(CST)) if os.path.exists(CST) else {}
    for period in ("week", "month"):
        P = out["periods"][period]; nb, pb = P["next"], P["prev"]; W = "WEEKLY" if period == "week" else "MONTHLY"
        k = "%s-pre-%d" % (period, nb)
        if P["phase"] == "pre" and k not in st:
            st[k] = int(now); tg("%s CLOSE COMING: %s Bangkok (in %dh).\nIf it closed now: %s\nDashboard: http://159.223.88.168/rotation.html" % (
                W, bkk(nb), round((nb - now) / 3600), summary(out, period, "forming")))
        k = "%s-1h-%d" % (period, nb)
        if 0 < nb - now <= 3600 + 600 and k not in st:
            st[k] = int(now); tg("%s CLOSE IN %d MINUTES (%s Bangkok)." % (W, round((nb - now) / 60), bkk(nb)))
        k = "%s-post-%d" % (period, pb)
        if P["phase"] == "post" and k not in st and now - pb < 6 * 3600:
            st[k] = int(now); tg("%s CLOSE DONE (%s Bangkok): %s\nDashboard: http://159.223.88.168/rotation.html" % (W, bkk(pb), summary(out, period, "closed")))
    json.dump(st, open(CST, "w"))

if __name__ == "__main__":
    main()
rotation.py (live) (354 lines)
#!/usr/bin/env python3
"""ROTATION WATCH. Is money moving into the trenches, or out of them.

WHAT THIS MEASURES AND WHY IT IS NOT "BTC DOMINANCE".
Dominance is an accounting ratio over a coin set that changes underneath you:
a new listing or an unlock moves it without one dollar moving. This model uses
things a listing cannot fake: a price RATIO (SOL/BTC) and a VOLUME SHARE
(launchpad volume over all DEX volume).

WHAT THE HISTORY ACTUALLY SAYS, measured over 894 days to 27 Sep 2026:
  * Trench share and the BTC price move TOGETHER, r = +0.476. Trench share was
    highest (4.9%) in Sep 2025 with BTC near $112k, and lowest (0.76%) in Jun
    2026 with BTC at $62k. "BTC pumps so money leaves the trenches" is not what
    the tape says.
  * A BTC rally LEADS trench volume by 4 to 6 days, r = +0.165, sign POSITIVE.
  * Event study, the rule this service trades: when BTC's 7-day return crosses
    above +10%, launchpad volume over the NEXT 14 DAYS rose a median +19%, and
    was positive in 11 of the 14 times it has happened since Apr 2024.
That last line is the whole alert. Everything else here is context for it.

GUARDS, each one paid for by a bug in an earlier build of mine:
  * A state must hold TWICE before it is pushed, so one odd hour cannot alert.
  * The alert fires on a CHANGE of word, never on a repeat.
  * A source that dies makes the service SAY SO and keep the other legs, it
    never silently publishes a stale number as if it were today's.
"""
import json, os, sqlite3, sys, time, urllib.request, datetime as dt

HERE = os.path.dirname(os.path.abspath(__file__))
DB   = os.path.join(HERE, "rot.db")
CFG  = os.path.join(HERE, "config.json")
PUB  = "/opt/launchwatch/pub/rotation.json"
UA   = {"User-Agent": "Mozilla/5.0 (rotation-watch)"}
BKK  = 7 * 3600

IMPULSE_UP   = 10.0    # BTC 7d return that opens the window
IMPULSE_DOWN = -10.0   # BTC 7d return that shuts it
HEAT_UP      = 30.0    # trench share up this % off its 30d low = confirmed
HEAT_DOWN    = 30.0    # trench share down this % off its 30d high = cooling
HOLD_TWICE   = 2       # runs a new word must survive before it is sent
STALE_H      = 30      # hours before the data itself is called dead


def bkk(ts):
    return dt.datetime.utcfromtimestamp(ts + BKK).strftime("%Y-%m-%d %H:%M")


def get(u, tries=4):
    for i in range(tries):
        try:
            return json.load(urllib.request.urlopen(
                urllib.request.Request(u, headers=UA), timeout=60))
        except Exception:
            if i == tries - 1:
                raise
            time.sleep(1.5 * (i + 1))


def db():
    c = sqlite3.connect(DB)
    c.execute("""create table if not exists d(
        date text primary key, btc real, eth real, sol real,
        dex_all real, dex_sol real, pad real, stables real, updated int)""")
    c.execute("""create table if not exists state(
        ts int primary key, word text, why text, sent int default 0)""")
    return c


def okx(inst, days=200):
    out, after = {}, ""
    while len(out) < days:
        u = ("https://www.okx.com/api/v5/market/history-candles?instId=%s&bar=1D&limit=100"
             % inst) + (("&after=" + after) if after else "")
        rows = (get(u).get("data") or [])
        if not rows:
            break
        for r in rows:
            out[dt.datetime.utcfromtimestamp(int(r[0]) / 1000).strftime("%Y-%m-%d")] = float(r[4])
        after = rows[-1][0]
        time.sleep(0.2)
    return out


def llama(u):
    j = get(u)
    return {dt.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): v
            for t, v in (j.get("totalDataChart") or [])}


def collect(c):
    dead = []
    series = {}
    for key, inst in [("btc", "BTC-USDT"), ("eth", "ETH-USDT"), ("sol", "SOL-USDT")]:
        try:
            series[key] = okx(inst)
        except Exception as e:
            dead.append("price:%s (%s)" % (key, str(e)[:40]))
            series[key] = {}
    try:
        series["dex_all"] = llama("https://api.llama.fi/overview/dexs")
    except Exception as e:
        dead.append("dex_all (%s)" % str(e)[:40]); series["dex_all"] = {}
    try:
        series["dex_sol"] = llama("https://api.llama.fi/overview/dexs/solana")
    except Exception as e:
        dead.append("dex_sol (%s)" % str(e)[:40]); series["dex_sol"] = {}

    # LAUNCHPAD VOLUME IS THE TRENCH LEG. Summed across every pad DefiLlama
    # tracks, not just pump.fun, because the pad that matters changes: it was
    # pump.fun, then Meteora's bonding curve, then whatever is next. Naming one
    # pad would make the series lie the moment the crowd moved.
    pad = {}
    try:
        j = get("https://api.llama.fi/overview/dexs?excludeTotalDataChartBreakdown=true")
        pads = [p["name"] for p in j["protocols"] if p.get("category") == "Launchpad"]
        got = 0
        for p in pads[:25]:
            slug = p.lower().replace(" ", "-").replace(".", "-")
            try:
                ch = llama("https://api.llama.fi/summary/dexs/%s" % slug)
            except Exception:
                try:
                    ch = llama("https://api.llama.fi/summary/dexs/%s" % p.lower().replace(" ", "-"))
                except Exception:
                    continue
            got += 1
            for d_, v in ch.items():
                pad[d_] = pad.get(d_, 0) + (v or 0)
            time.sleep(0.15)
        if got < 5:
            dead.append("launchpads (only %d of %d answered)" % (got, len(pads)))
    except Exception as e:
        dead.append("launchpad list (%s)" % str(e)[:40])
    series["pad"] = pad

    stbl = {}
    try:
        for r in get("https://stablecoins.llama.fi/stablecoincharts/all"):
            d_ = dt.datetime.utcfromtimestamp(int(r["date"])).strftime("%Y-%m-%d")
            stbl[d_] = sum(v for v in (r.get("totalCirculatingUSD") or {}).values()
                           if isinstance(v, (int, float)))
    except Exception as e:
        dead.append("stables (%s)" % str(e)[:40])
    series["stables"] = stbl

    now = int(time.time())
    days = sorted(set().union(*[set(s) for s in series.values() if s]))
    days = [d_ for d_ in days if d_ >= "2024-01-01"]
    for d_ in days:
        c.execute("""insert into d(date,btc,eth,sol,dex_all,dex_sol,pad,stables,updated)
                     values(?,?,?,?,?,?,?,?,?)
                     on conflict(date) do update set
                       btc=coalesce(excluded.btc,btc), eth=coalesce(excluded.eth,eth),
                       sol=coalesce(excluded.sol,sol), dex_all=coalesce(excluded.dex_all,dex_all),
                       dex_sol=coalesce(excluded.dex_sol,dex_sol), pad=coalesce(excluded.pad,pad),
                       stables=coalesce(excluded.stables,stables), updated=excluded.updated""",
                  (d_, series["btc"].get(d_), series["eth"].get(d_), series["sol"].get(d_),
                   series["dex_all"].get(d_), series["dex_sol"].get(d_),
                   series["pad"].get(d_), series["stables"].get(d_), now))
    c.commit()
    return dead


def metrics(c):
    rows = list(c.execute("select date,btc,eth,sol,dex_all,dex_sol,pad,stables "
                          "from d where btc is not null order by date"))
    if len(rows) < 40:
        return None
    date = [r[0] for r in rows]
    btc = [r[1] for r in rows]; sol = [r[3] for r in rows]
    da = [r[4] for r in rows]; pad = [r[6] for r in rows]; stb = [r[7] for r in rows]

    def mean(x, i, n):
        w = [v for v in x[max(0, i - n + 1):i + 1] if v]
        return sum(w) / len(w) if w else None

    # The newest DefiLlama day is always partial, so the volume legs are read
    # one day back. A half-counted day looked like a 50% collapse in testing.
    n = len(rows) - 1
    v = n - 1
    share = [(100 * pad[i] / da[i]) if (pad[i] and da[i]) else None for i in range(len(rows))]
    sh7 = [mean(share, i, 7) for i in range(len(rows))]
    out = {
        "as_of": date[n],
        "vol_as_of": date[v],
        "btc": btc[n],
        "btc_r7": (100 * (btc[n] / btc[n - 7] - 1)) if btc[n - 7] else None,
        "btc_r30": (100 * (btc[n] / btc[n - 30] - 1)) if btc[n - 30] else None,
        "sol_btc": (sol[n] / btc[n]) if sol[n] and btc[n] else None,
        "sol_btc_r30": (100 * ((sol[n] / btc[n]) / (sol[n - 30] / btc[n - 30]) - 1))
                       if sol[n - 30] and btc[n - 30] and sol[n] else None,
        "pad_7d": mean(pad, v, 7),
        "pad_r14": None,
        "share_7d": sh7[v],
        "share_30d_lo": min([x for x in sh7[max(0, v - 30):v + 1] if x] or [0]),
        "share_30d_hi": max([x for x in sh7[max(0, v - 30):v + 1] if x] or [0]),
        "stables": stb[n] if stb[n] else mean(stb, n, 3),
        "stables_r30": None,
    }
    p14 = mean(pad, v - 14, 7)
    if p14 and out["pad_7d"]:
        out["pad_r14"] = 100 * (out["pad_7d"] / p14 - 1)
    s30 = mean(stb, n - 30, 3)
    if s30 and out["stables"]:
        out["stables_r30"] = 100 * (out["stables"] / s30 - 1)
    out["heat_off_low"] = (100 * (out["share_7d"] / out["share_30d_lo"] - 1)
                           if out["share_7d"] and out["share_30d_lo"] else None)
    out["heat_off_high"] = (100 * (out["share_7d"] / out["share_30d_hi"] - 1)
                            if out["share_7d"] and out["share_30d_hi"] else None)
    out["hist"] = [{"d": date[i], "btc": btc[i], "share": sh7[i], "pad": pad[i]}
                   for i in range(max(0, len(rows) - 400), len(rows))]
    return out


def decide(m):
    """Two separate reads, tested 2 Oct 2026 on Apr 2024 to Sep 2026 (launchpad volume from DefiLlama,
    meme prices from point in time CoinMarketCap baskets, dead coins included):
    1. ACTIVITY outlook: what this service measures. BTC jumps predict busier trenches, not higher prices.
    2. PRICE word for trench coins: taken from the tested meme basket rule in chains.json, never from activity.
    The old BUY TRENCHES word fired on 61% of days and meme baskets after it did no better than any day
    (14 days: median -4.0%, 41% up, vs -3.8%, 40% up), so it was retired."""
    r7, heat_lo, heat_hi = m["btc_r7"], m["heat_off_low"], m["heat_off_high"]
    if r7 is None:
        act = ("NO SIGNAL", "BTC price is missing, so the activity read cannot be made.")
    elif r7 >= IMPULSE_UP:
        act = ("MORE TRENCH ACTIVITY AHEAD", "BTC is up %.1f%% in 7 days. After the 16 such jumps since Apr 2024, "
               "launchpad volume was higher 14 days later 12 times (median +15%%). It did not lift meme prices: "
               "the Solana and Base meme baskets were higher 14 days later after only half of them." % r7)
    elif r7 <= IMPULSE_DOWN:
        act = ("LESS TRENCH ACTIVITY LIKELY", "BTC is down %.1f%% in 7 days. After the 17 such drops, launchpad "
               "volume was lower 14 days later 9 times (median -14%%). Meme baskets were HIGHER 14 days later 11 of 17 "
               "times (median +6%%), so a BTC drop has not been a reason to sell memes." % -r7)
    elif heat_lo is not None and heat_lo >= HEAT_UP:
        act = ("TRENCHES HEATING", "Trench share of all DEX volume is %.0f%% above its 30 day low. Context only: after "
               "39 such starts launchpad volume kept rising 62%% of the time; meme prices were up 46%% of the time, "
               "the same as any day." % heat_lo)
    elif heat_hi is not None and heat_hi <= -HEAT_DOWN:
        act = ("TRENCHES COOLING", "Trench share is %.0f%% below its 30 day high. After 38 such starts the Solana and "
               "Base meme baskets were higher 14 days later only 32%% of the time (median -6.7%%), versus 40%% on any "
               "day. A mild warning, not a sell signal on its own." % -heat_hi)
    else:
        act = ("NO CLEAR CHANGE", "BTC is %+.1f%% in 7 days and trench share is mid range." % r7)
    m["activity"], m["activity_line"] = act
    # price word from basket rule v4 in chains.json (phases: rotate into / hold / rotate out of / wait)
    try:
        ch = json.load(open("/opt/launchwatch/pub/chains.json"))
        V = ch.get("v4") or {}; B = {b["key"]: b for b in ch["baskets"]}
        def nm(ks): return ", ".join(B[k]["name"] for k in ks if k in B)
        G = V.get("gates") or {}
        gate = "BTC %s its 100 day average, majors %s." % ("above" if G.get("btc_on") else "below", "risk on" if G.get("majors_on") else "risk off")
        if V.get("top"):
            word, line = "BUY", "Rotate into %s: turned bullish today or yesterday." % nm(V["top"])
        elif V.get("out") and not V.get("hold"):
            word, line = "SELL", "Rotate out of %s." % nm(V["out"])
        elif V.get("hold"):
            word, line = "HOLD", "No new buy. Hold %s." % nm(V["hold"])
        else:
            word, line = "NO BUY", "Every basket is risk off."
        if V.get("out") and word != "SELL": line += " Rotate out of %s." % nm(V["out"])
        line += " " + gate
    except Exception as e:
        word, line = "NO CALL", "The basket feed (chains.json) could not be read: %s" % str(e)[:60]
    return word, line


def tg(msg):
    if os.path.exists("/opt/rotation/QUIET"):
        return  # Rotation Map owns the alerts now
    try:
        cfg = json.load(open(CFG))["telegram"]
    except Exception:
        return False
    try:
        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


ICON = {"BUY": "\U0001F7E2", "HOLD": "\U000026AA", "SELL": "\U0001F534", "NO BUY": "\U000026AA", "NO CALL": "\U00002754"}


def main():
    c = db()
    dead = collect(c)
    m = metrics(c)
    if not m:
        print("not enough history yet")
        return
    word, line = decide(m)
    now = int(time.time())
    c.execute("insert or replace into state(ts,word,why) values(?,?,?)", (now, word, line))
    c.commit()

    recent = [r[0] for r in c.execute(
        "select word from state order by ts desc limit ?", (HOLD_TWICE,))]
    last_sent = c.execute("select word from state where sent=1 order by ts desc limit 1").fetchone()
    last_word = last_sent[0] if last_sent else None
    firm = len(recent) >= HOLD_TWICE and len(set(recent)) == 1
    changed = firm and word != last_word

    m["word"], m["line"], m["dead"] = word, line, dead
    m["generated"] = now
    m["generated_bkk"] = bkk(now)
    m["alert_state"] = {"holding": recent, "last_sent": last_word, "would_send": changed}
    os.makedirs(os.path.dirname(PUB), exist_ok=True)
    tmp = PUB + ".tmp"
    json.dump(m, open(tmp, "w"), separators=(",", ":"))
    os.replace(tmp, PUB)

    print("%s  %s  btc7 %+.1f%%  share %.2f%%  pad14 %s  %s" % (
        bkk(now), word, m["btc_r7"] or 0, m["share_7d"] or 0,
        ("%+.0f%%" % m["pad_r14"]) if m["pad_r14"] is not None else "n/a",
        ("DEAD: " + "; ".join(dead)) if dead else "all sources alive"))

    if changed:
        msg = ("%s <b>%s</b>\n%s\n\n"
               "BTC $%s, %+.1f%% in 7 days\n"
               "Trench volume $%sM a day, %s in 14 days\n"
               "Trench share of all DEX volume %.2f%%\n"
               "SOL vs BTC %+.1f%% in 30 days\n"
               "%s\n"
               "http://159.223.88.168/rotation.html") % (
            ICON.get(word, ""), word, line,
            "{:,.0f}".format(m["btc"]), m["btc_r7"] or 0,
            "{:,.0f}".format((m["pad_7d"] or 0) / 1e6),
            ("%+.0f%%" % m["pad_r14"]) if m["pad_r14"] is not None else "n/a",
            m["share_7d"] or 0, m["sol_btc_r30"] or 0,
            bkk(now) + " Bangkok")
        if tg(msg):
            c.execute("update state set sent=1 where ts=?", (now,))
            c.commit()
            print("  -> alert sent (was %s)" % last_word)

    # A SERVICE IS NOT HEALTHY BECAUSE IT RAN. If a leg died, say so once.
    if dead:
        lastd = c.execute("select why from state where word='DEAD' order by ts desc limit 1").fetchone()
        sig = "; ".join(dead)
        if not lastd or lastd[0] != sig:
            tg("\U000026A0 <b>ROTATION WATCH, a source died</b>\n%s\nThe other legs still ran."
               % sig)
            c.execute("insert or replace into state(ts,word,why,sent) values(?,?,?,1)",
                      (now + 1, "DEAD", sig))
            c.commit()


if __name__ == "__main__":
    main()
rh_drill.py (427 lines)
"""Robinhood Chain drill-down for the Rotation Map dashboard (rh-drill-1).

Reads the live Robinhood Indices feed (market.json, written hourly by
/opt/robinhood-indices) and the Rotation Map's own daily closes, and writes a
small rh_drill.json that rotation.html renders at the bottom of the page.
It never writes to the indices project and never changes the Rotation Map book.

Three questions, one block each:
  1. Is money rotating INTO Robinhood Chain?  Robinhood Index vs BTC, ETH, SOL
     on the same UTC daily closes, 7 and 30 days.
  2. What on Robinhood is doing well?  Every basket: 24h, 7d, versus the
     Robinhood Index, and how many coins are rising.
  3. Is a basket running too hot?  Where the basket's last daily close sits in
     its own 20-close band (log closes, two standard deviations, the same band
     the indices site uses). Above the top = TAKE SOME PROFIT, below the bottom
     = WAIT, inside = HOLD. A stretch gauge, not a tested sell signal.

AI Products and AI Company Pairs are shown as ONE basket, "AI", per Ben
(28 Sep 2026): the two official indices averaged hour by hour.
"""
import json, math, os, statistics, sys, time

FEED = os.environ.get("RH_FEED", "/opt/launchwatch/pub/robinhood-indices/market.json")
PX = os.environ.get("RH_PX", "/opt/rotation/store/px.json")
OUT = os.environ.get("RH_OUT", "/opt/launchwatch/pub/rh_drill.json")
DAY = 86400
SHOW = [("robinhood", "Robinhood Index"), ("robinhood30", "Robinhood 30"), ("pure_memes", "Pure Memes"),
        ("stock_memes", "Stock Memes"), ("AI", "AI"), ("infrastructure", "Infrastructure"), ("long_xyz", "Long.xyz")]
MERGE = {"AI": ["ai_products", "ai_company_pairs"]}
BAND_N, BAND_K = 20, 2.0


def daily_closes(hist):
    """[(ts, v)] hourly -> {utc_day_start: close}, close = last mark at or before 00:00 UTC of the next day,
    only if that mark is within 3 hours of midnight (otherwise the day is unknown)."""
    out, pts = {}, sorted((int(t), v) for t, v in hist if v)
    if not pts:
        return out
    j = 0
    first_day = pts[0][0] // DAY * DAY
    last_day = pts[-1][0] // DAY * DAY
    for d in range(first_day, last_day, DAY):      # closes of COMPLETED days only
        close_t = d + DAY
        while j + 1 < len(pts) and pts[j + 1][0] <= close_t:
            j += 1
        t, v = pts[j]
        if t <= close_t and close_t - t <= 3 * 3600:
            out[d] = v
    return out


def _unused_roster_line(members):
    """Equal weight over today's members, rebalanced daily, from each coin's own daily closes.
    A day counts only when at least 3 members (or all, if fewer) have closes on both days."""
    per = [daily_closes(m.get("history") or []) for m in members]
    days = sorted(set().union(*[set(p) for p in per])) if per else []
    lvl, line = 100.0, []
    need = min(3, len(members))
    for a, b in zip(days, days[1:]):
        if b - a != DAY:
            continue
        r = [p[b] / p[a] - 1 for p in per if a in p and b in p]
        if len(r) < need:
            continue
        if not line:
            line.append((a, lvl))
        lvl *= 1 + sum(r) / len(r)
        line.append((b, lvl))
    return line


def official_line(ixs):
    """Hourly official index levels. For a merged basket: equal weight of the member indices' hourly
    returns from their common start (each index already equal-weights its own coins)."""
    hs = [dict((int(t), v) for t, v in ix["history"] if v) for ix in ixs]
    if len(hs) == 1:
        return sorted(hs[0].items())
    ts = sorted(set.intersection(*[set(h) for h in hs]))
    line, lvl = [], 100.0
    for a, b in zip(ts, ts[1:]):
        if b - a != 3600:
            continue
        if not line:
            line.append((a, lvl))
        lvl *= 1 + statistics.fmean(h[b] / h[a] - 1 for h in hs)
        line.append((b, lvl))
    return line


def closes(line, step):
    """Completed closes every `step` seconds on UTC boundaries (a close is the mark exactly on the boundary)."""
    d = dict(line)
    if not line:
        return []
    last = line[-1][0]
    first = (line[0][0] // step + 1) * step
    return [(t, d[t]) for t in range(first, last + 1, step) if t in d]


def ret_over(line, hours):
    d = dict(line)
    if not line:
        return None
    t1 = line[-1][0]
    t0 = t1 - hours * 3600
    return (d[t1] / d[t0] - 1) * 100 if t0 in d else None


def band(line):
    """Position of the last close in its own 20-close log band."""
    if len(line) < BAND_N:
        return None
    xs = [math.log(v) for _, v in line[-BAND_N:]]
    mu, sd = statistics.fmean(xs), statistics.pstdev(xs)
    last = line[-1][1]
    z = (math.log(last) - mu) / sd if sd > 0 else 0.0
    return {"z": round(z, 2), "stretch_pct": round(100 * (last / math.exp(mu) - 1), 1),
            "upper": round(math.exp(mu + BAND_K * sd), 4), "lower": round(math.exp(mu - BAND_K * sd), 4),
            "closes": BAND_N, "asof": line[-1][0]}


def temperature(b):
    if b is None:
        return {"word": "NOT ENOUGH HISTORY", "tone": "grey",
                "line": "Fewer than 20 closes, so there is no band yet."}
    if b["z"] >= BAND_K:
        return {"word": "TAKE SOME PROFIT", "tone": "red",
                "line": "Above the top of its own 20 day band. Running hot. Scale out a slice, do not add."}
    if b["z"] <= -BAND_K:
        return {"word": "WAIT", "tone": "lightgreen",
                "line": "Below the bottom of its own 20 day band. Washed out, but cheap is not a turn yet."}
    if b["z"] >= 1.0:
        return {"word": "HOLD, DON'T CHASE", "tone": "lightred",
                "line": "Warm: in the upper part of its band. Hold what you have, do not chase."}
    return {"word": "HOLD", "tone": "grey", "line": "Inside its normal range."}


def rets(members, key):
    v = [((m.get("returns") or {}).get(key) or {}).get("return") for m in members]
    v = [x for x in v if x is not None]
    return v


def basket(ix_by_id, key, name, rh7):
    keys = MERGE.get(key, [key])
    ixs = [ix_by_id[k] for k in keys if k in ix_by_id]
    if not ixs:
        return None
    seen, mem = set(), []
    for ix in ixs:
        for m in ix["members"]:
            a = m["address"].lower()
            if a not in seen:
                seen.add(a)
                mem.append(m)
    line = official_line(ixs)
    if len(ixs) == 1:
        ret24, ret7 = ixs[0]["stats"]["24h"]["return"], ixs[0]["stats"]["7d"]["return"]
        src = "official index, %d coins" % len(mem)
    else:
        ret24, ret7 = ret_over(line, 24), ret_over(line, 168)
        src = "%s merged: equal weight of the two indices, %d coins" % (" + ".join(ix["name"] for ix in ixs), len(mem))
    r24, r7 = rets(mem, "24h"), rets(mem, "7d")
    dc = closes(line, DAY)
    if len(dc) >= BAND_N:
        b, frame = band(dc), "daily"
    else:
        b, frame = band(closes(line, 4 * 3600)), "4 hour"
    if b:
        b["frame"] = frame
    t = temperature(b)
    if b and frame == "4 hour":
        t["line"] += " Short history, so this uses 4 hour closes (a short-term read)."
    lead = sorted(((((m.get("returns") or {}).get("7d") or {}).get("return"), m["symbol"]) for m in mem
                   if (((m.get("returns") or {}).get("7d") or {}).get("return")) is not None), reverse=True)
    spark = [v for tt, v in line[-168:] if tt % (4 * 3600) == 0]
    return {"key": key, "name": name, "source": src, "n": len(mem), "stale": any(ix.get("stale") for ix in ixs),
            "since": line[0][0] if line else None, "last": line[-1][0] if line else None,
            "ret_24h": None if ret24 is None else round(ret24, 1),
            "ret_7d": None if ret7 is None else round(ret7, 1),
            "vs_rh_7d": None if (ret7 is None or rh7 is None or key == "robinhood") else round(ret7 - rh7, 1),
            "rising_24h": [sum(1 for x in r24 if x > 0), len(r24)],
            "rising_7d": [sum(1 for x in r7 if x > 0), len(r7)],
            "leaders_7d": [[s_, round(v, 1)] for v, s_ in lead[:3]],
            "members": [[m["symbol"], m["address"]] for m in mem],
            "band": b, "temp": t,
            "spark": [round(v / spark[0], 4) for v in spark] if spark else [],
            "hist": [[tt, round(v / line[0][1], 4)] for tt, v in line if tt % (4 * 3600) == 0]}


def rotation(ix_by_id, px):
    """Robinhood Index vs BTC, ETH, SOL on the same completed UTC daily closes. Official index only:
    today's members' older prices would flatter it (they are today's winners)."""
    rh = ix_by_id.get("robinhood")
    if not rh:
        return None
    rd = dict(closes(sorted((int(t), v) for t, v in rh["history"] if v), DAY))
    days = sorted(rd)
    if len(days) < 8:
        return None
    end = days[-1]            # boundary timestamp = close of the previous UTC day
    lab = lambda t: time.strftime("%Y-%m-%d", time.gmtime(t - DAY))   # OKX daily candles are labelled by open day

    def pxr(sym, t0):
        s_ = px.get(sym) or {}
        a, z = lab(t0), lab(end)
        return (s_[z] / s_[a] - 1) * 100 if s_.get(z) and s_.get(a) else None
    out = {"asof": end, "rows": []}
    for label, t0 in (("7 days", end - 7 * DAY), ("since launch, %d days" % ((end - days[0]) // DAY), days[0])):
        row = {"label": label, "rh": round((rd[end] / rd[t0] - 1) * 100, 1) if t0 in rd else None}
        for s_ in ("BTC", "ETH", "SOL"):
            v = pxr(s_, t0)
            row[s_] = None if v is None else round(v, 1)
        out["rows"].append(row)
    r7, rl = out["rows"]
    beat = [r["rh"] is not None and r["BTC"] is not None and r["rh"] > r["BTC"] for r in (r7, rl)]
    lag = [r["rh"] is not None and r["BTC"] is not None and r["rh"] < r["BTC"] for r in (r7, rl)]
    if all(beat):
        out["word"], out["tone"], out["line"] = "ROTATING INTO ROBINHOOD", "green", "The Robinhood Index beat BTC this week and since launch."
    elif all(lag):
        out["word"], out["tone"], out["line"] = "ROTATING OUT OF ROBINHOOD", "red", "The Robinhood Index trailed BTC this week and since launch."
    elif beat[0]:
        out["word"], out["tone"], out["line"] = "EARLY TURN TO ROBINHOOD", "lightgreen", "Beating BTC this week after trailing it since launch. Not confirmed yet."
    elif lag[0]:
        out["word"], out["tone"], out["line"] = "COOLING OFF", "lightred", "Still ahead of BTC since launch, but trailing it this week."
    else:
        out["word"], out["tone"], out["line"] = "NO CLEAR ROTATION", "grey", "Not enough data for a clear lead either way."
    return out


# ------------------------------------------------------------------ flows (rh-flow-1) ---
# Experimental money-flow proxies. None of these is "net dollars invested"; they are
# labelled as proxies on the page and never change the book.
import urllib.request
HIST = os.environ.get("RH_FLOW_HIST", "/opt/rotation/rh_flow_hist.json")
UA = {"User-Agent": "Mozilla/5.0 rh-drill"}


def _get(u, timeout=40):
    return json.load(urllib.request.urlopen(urllib.request.Request(u, headers=UA), timeout=timeout))


def chain_flows():
    """Chain level, DefiLlama daily points: stablecoins on Robinhood Chain (new dollars parked there),
    TVL (moves with prices too) and DEX volume (gross activity, not net money)."""
    out, errs = {}, []
    try:
        st = _get("https://stablecoins.llama.fi/stablecoincharts/Robinhood%20Chain")
        pts = [(int(p["date"]), (p.get("totalCirculatingUSD") or {}).get("peggedUSD")) for p in st]
        pts = [p for p in pts if p[1]]
        last = pts[-1]
        def ago(n):
            want = last[0] - n * DAY
            c = [v for t, v in pts if t <= want]
            return c[-1] if c else None
        out["stables"] = {"now": round(last[1]), "asof": last[0],
                          "chg_1d": round(last[1] - ago(1)) if ago(1) else None,
                          "chg_7d": round(last[1] - ago(7)) if ago(7) else None,
                          "chg_30d": round(last[1] - ago(30)) if ago(30) else None}
    except Exception as e:
        errs.append("stablecoins: %s" % e)
    try:
        tv = _get("https://api.llama.fi/v2/historicalChainTvl/Robinhood%20Chain")
        pts = [(int(p["date"]), p["tvl"]) for p in tv if p.get("tvl")]
        last = pts[-1]
        def tago(n):
            c = [v for t, v in pts if t <= last[0] - n * DAY]
            return c[-1] if c else None
        out["tvl"] = {"now": round(last[1]), "asof": last[0],
                      "pct_7d": round(100 * (last[1] / tago(7) - 1), 1) if tago(7) else None,
                      "pct_30d": round(100 * (last[1] / tago(30) - 1), 1) if tago(30) else None}
    except Exception as e:
        errs.append("tvl: %s" % e)
    try:
        dx = _get("https://api.llama.fi/overview/dexs/Robinhood%20Chain?excludeTotalDataChartBreakdown=true&excludeTotalDataChart=true")
        out["dex"] = {"vol_24h": round(dx.get("total24h") or 0), "vol_7d": round(dx.get("total7d") or 0),
                      "chg_7d_pct": dx.get("change_7dover7d")}
    except Exception as e:
        errs.append("dex volume: %s" % e)
    s_ = out.get("stables", {})
    t_ = out.get("tvl", {})
    if s_.get("chg_7d") is not None and t_.get("pct_7d") is not None:
        if s_["chg_7d"] > 0 and t_["pct_7d"] > 0:
            out["word"], out["tone"], out["line"] = "MONEY COMING IN", "green", "More stablecoins sit on Robinhood Chain than a week ago, and total value locked is up."
        elif s_["chg_7d"] < 0 and t_["pct_7d"] < 0:
            out["word"], out["tone"], out["line"] = "MONEY LEAVING", "red", "Fewer stablecoins on Robinhood Chain than a week ago, and total value locked is down."
        else:
            out["word"], out["tone"], out["line"] = "MIXED", "grey", "Stablecoins and total value locked moved in different directions this week."
    out["errors"] = errs
    return out


def pool_snapshot(pairs):
    """DexScreener pool stats for every member pool, 30 pools per call."""
    res, errs = {}, []
    pairs = sorted(set(p for p in pairs if p))
    for i in range(0, len(pairs), 30):
        try:
            d = _get("https://api.dexscreener.com/latest/dex/pairs/robinhood/" + ",".join(pairs[i:i + 30]))
            for p in d.get("pairs") or []:
                res[p["pairAddress"].lower()] = {"tx": p.get("txns") or {}, "vol": p.get("volume") or {},
                                                 "liq": (p.get("liquidity") or {}).get("usd")}
        except Exception as e:
            errs.append("dexscreener: %s" % e)
    return res, errs


def pair_of(m):
    u = m.get("url") or ""
    return u.rstrip("/").split("/")[-1].lower() if "/robinhood/" in u else None


def basket_flows(ix_by_id, rows, now):
    pairs = {}
    for b in rows:
        for k in MERGE.get(b["key"], [b["key"]]):
            for m in (ix_by_id.get(k) or {}).get("members", []):
                pairs[m["address"].lower()] = pair_of(m)
    snap, errs = pool_snapshot(pairs.values())
    # liquidity history, hourly, kept 8 days, merged by key so a failed call never shortens history
    try:
        hist = json.load(open(HIST))
    except Exception:
        hist = {}
    hour = str(now // 3600 * 3600)
    hist[hour] = {a: snap[p]["liq"] for a, p in pairs.items() if p in snap and snap[p]["liq"]}
    for k in [k for k in hist if int(k) < now - 8 * DAY]:
        del hist[k]
    tmp = HIST + ".tmp"
    with open(tmp, "w") as f:
        json.dump(hist, f, separators=(",", ":"))
    os.replace(tmp, HIST)

    def liq_then(addrs, hours):
        want = now - hours * 3600
        ks = sorted((int(k) for k in hist if int(k) <= want + 1800), reverse=True)
        if not ks:
            return None
        h = hist[str(ks[0])]
        if now - hours * 3600 - ks[0] > 3 * 3600:
            return None
        v = [h.get(a) for a in addrs]
        return v
    for b in rows:
        addrs = [a for k in MERGE.get(b["key"], [b["key"]]) for a in
                 [m["address"].lower() for m in (ix_by_id.get(k) or {}).get("members", [])]]
        addrs = list(dict.fromkeys(addrs))
        got = [snap[pairs[a]] for a in addrs if pairs.get(a) in snap]
        buys = sum((g["tx"].get("h24") or {}).get("buys", 0) for g in got)
        sells = sum((g["tx"].get("h24") or {}).get("sells", 0) for g in got)
        buys6 = sum((g["tx"].get("h6") or {}).get("buys", 0) for g in got)
        sells6 = sum((g["tx"].get("h6") or {}).get("sells", 0) for g in got)
        vol = sum((g["vol"].get("h24") or 0) for g in got)
        liq_now = [hist[hour].get(a) for a in addrs]
        chg = {}
        for hrs, key in ((24, "liq_chg_24h"), (168, "liq_chg_7d")):
            then = liq_then(addrs, hrs)
            if then is None:
                chg[key] = None
                continue
            pairs_ok = [(n, t) for n, t in zip(liq_now, then) if n and t]
            chg[key] = round(100 * (sum(n for n, _ in pairs_ok) / sum(t for _, t in pairs_ok) - 1), 1) if pairs_ok else None
        share = buys / (buys + sells) if buys + sells else None
        share6 = buys6 / (buys6 + sells6) if buys6 + sells6 else None
        lead = b.get("vs_rh_7d")
        part = b["rising_7d"][0] / b["rising_7d"][1] if b["rising_7d"][1] else None
        checks = [["beating the Robinhood Index over 7 days", None if lead is None else lead > 0],
                  ["most coins rising over 7 days", None if part is None else part > 0.5],
                  ["more buy trades than sell trades today", None if share is None else share > 0.52],
                  ["pool liquidity growing over 24 hours", None if chg["liq_chg_24h"] is None else chg["liq_chg_24h"] > 0]]
        known = [c[1] for c in checks if c[1] is not None]
        yes = sum(1 for c in known if c)
        if len(known) >= 3 and yes == len(known):
            word, tone = "MONEY COMING IN", "green"
        elif len(known) >= 3 and yes == 0:
            word, tone = "MONEY LEAVING", "red"
        else:
            word, tone = "NO CLEAR FLOW", "grey"
        b["flow"] = {"word": word, "tone": tone, "checks": checks, "passed": [yes, len(known)],
                     "buy_share_24h": None if share is None else round(100 * share, 1),
                     "buy_share_6h": None if share6 is None else round(100 * share6, 1),
                     "trades_24h": buys + sells, "volume_24h": round(vol), "liquidity": round(sum(x for x in liq_now if x)),
                     "pools_seen": [len(got), len(addrs)], **chg}
    return errs


def main():
    feed = json.load(open(FEED))
    px = json.load(open(PX)) if os.path.exists(PX) else {}
    ix_by_id = {ix["id"]: ix for ix in feed["indices"]}
    rh7 = (ix_by_id.get("robinhood") or {}).get("stats", {}).get("7d", {}).get("return")
    rows = [b for b in (basket(ix_by_id, k, n, rh7) for k, n in SHOW) if b]
    # say it plainly when two baskets are mostly the same coins
    addrs = {}
    for k, _ in SHOW:
        for kk in MERGE.get(k, [k]):
            ix = ix_by_id.get(kk)
            if ix:
                addrs.setdefault(k, set()).update(m["address"].lower() for m in ix["members"])
    for b in rows:
        for o in rows:
            if o is b or b["key"] in ("robinhood", "robinhood30") or o["key"] in ("robinhood", "robinhood30"):
                continue
            sh = len(addrs[b["key"]] & addrs[o["key"]])
            if sh * 2 > len(addrs[b["key"]]):
                b.setdefault("overlap", []).append([o["name"], sh, len(addrs[b["key"]])])
    now = int(time.time())
    flow_errs = basket_flows(ix_by_id, rows, now)
    chain = chain_flows()
    chain["errors"] = chain.get("errors", []) + flow_errs
    out = {"chain_flow": chain,"version": "rh-drill-1", "generated": now, "feed_generated": feed.get("generated"),
           "feed_next": feed.get("nextUpdate"), "feed_age_min": round((now - (feed.get("generated") or now)) / 60),
           "feed_stale": (now - (feed.get("generated") or 0)) > 3 * 3600,
           "rotation": rotation(ix_by_id, px), "baskets": rows,
           "notes": ["Baskets come from the Robinhood Indices feed (equal weight, weekly rebalance).",
                     "AI = AI Products and AI Company Pairs merged into one equal-weight basket.",
                     "Every line is the official index history (for AI, the two official indices averaged), never today's members' older prices, which would flatter the baskets.",
                     "Temperature is a stretch gauge: the last close against its own band of 20 closes, 2 standard deviations (daily when 20 daily closes exist, otherwise 4 hour). It has not been tested as a sell signal."]}
    tmp = OUT + ".tmp"
    with open(tmp, "w") as f:
        json.dump(out, f, separators=(",", ":"))
    os.replace(tmp, OUT)
    print("rh_drill ok", len(rows), "baskets, feed age", out["feed_age_min"], "min")


if __name__ == "__main__":
    main()
chains_stale.py (22 lines)
#!/usr/bin/env python3
"""Hourly. One Telegram when the chain baskets feed breaks, one when it recovers, nothing in between."""
import json, os, time, datetime as dt, urllib.request
OUT = "/opt/launchwatch/pub/chains.json"; ST = "/opt/rotation/chains/stale_state.json"
st = json.load(open(ST)) if os.path.exists(ST) else {}
cfg = json.load(open("/opt/watchdog/config.json"))["telegram"]
def tg(t):
    b = json.dumps({"chat_id": cfg["chat_id"], "text": t}).encode()
    urllib.request.urlopen(urllib.request.Request("https://api.telegram.org/bot%s/sendMessage" % cfg["bot_token"], data=b,
                           headers={"Content-Type": "application/json"}), timeout=20)
try: age = time.time() - os.path.getmtime(OUT)
except OSError: age = 9e9
try: d = json.load(open(OUT)); lag = (dt.date.today() - dt.date.fromisoformat(d["cmc_day"])).days
except Exception: lag = 99
checks = {"feed": (age > 3 * 3600, "chains.json not updated for %.0f h. Action: check /opt/rotation/chains/chains.log" % (age / 3600)),
          "daily": (lag > 2, "daily CoinMarketCap step is %d days behind. Action: check the snapshot lines in chains.log" % lag)}
for k, (bad, why) in checks.items():
    if bad and not st.get(k):
        tg("CHAIN BASKETS broken: " + why); st[k] = time.time()
    elif not bad and st.get(k):
        tg("CHAIN BASKETS recovered: %s is fresh again" % k); st[k] = 0
json.dump(st, open(ST, "w"))

Dashboard handover dash-h6 · 30 Sep 2026 Bangkok · latest version always at http://159.223.88.168/hx-c9ois/rotation/dashboard-handover.html