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.
- Bug found and fixed (one day shift). CoinMarketCap's label D is the close at the END of day D. The OKX majors and BTC closes were filed one day late (daily return correlation with CoinMarketCap 0.01; after the fix 0.94 for the majors basket, 0.9997 for BTC, 0.999 for SOL/BTC). So the live majors gate and the Solana froth check were one day behind the baskets; the Robinhood and other 4 hour baskets were also labelled one day late; the provisional close filled the wrong day; and the weekly and monthly candles of CoinMarketCap baskets ended one day early (7 of 68 labels change). All fixed. The backtests were not affected (they used CoinMarketCap data on one clock).
- Extended. 10 definitions tested (20 day z score at the top of the range, % above the 20 day average, 30 day gain, RSI 80/85, gain from entry, day 8, sell all vs half). The current HOT rule stayed best in 2025 to 2026 ($1.83 per $1 lost vs $0.88 with no profit taking). It is now called EXTENDED: TAKE HALF, then EXTENDED: HOLD HALF.
- Rotating out of extended into fresh buys (when a tested basket turns bullish, move the remaining half of the weakest extended basket into it): Jan 2023 to Sep 2026 +568% (worst drop -31%) vs +364% without the swap and BTC +403% (worst -53%). Ranking fresh buys by 30 day or 7 day move gave the same picture; rising volume and higher RSI at the BUY also did better in both halves. Live: the swap shows up under Rotate out of.
- Risk off words, tested: WAITING FOR BOTTOM (still making new 20 day lows): turned bullish within 14 days only 12 to 18% of the time; BASING (no new low for 5+ days): 30 to 35%; ALMOST BULLISH (within 5% of turning): 49 to 73%; BLOCKED BY MARKET.
- Money flows. Stablecoin supply per chain from DefiLlama (bridge flow data is paid only). Tested on Solana, Base and TON baskets 2024 to 2026: stablecoin inflows and DEX volume did not predict basket prices, so the new section shows them as background with daily bars, the 30 day total, its share of the chain's dollars and its size versus the past year. Robinhood: about $1.06B of its $1.08B stablecoins sit in Morpho lending, which is why inflows there say little about meme prices.
- Robinhood section now shows the same word as the Baskets section for each index, the 7 day move and who led it; the conflicting temperature, flow and "rotating out" words are gone (also removed from the rotation strip).
- Layout: money flows (4), Robinhood this week (5), history (6) with chart 1 = what $1 became plus a strip showing where the money was, chart 2 = the % of 64 big coins beating BTC over 3 months (white) and 1 month (orange) with labelled zones; then last moves, colours, past results and report card at the bottom.
- Two more fixes after deploy: young baskets (Robinhood, Ink, Arc, Bankr, o1) now use finished days only (an unfinished day had produced a BUY for Ink stock tokens that was gone at the close), and the GeckoTerminal baskets refresh every 6 hours plus right after the 07:00 Bangkok close instead of hourly (hourly runs took 30+ minutes fighting other droplet services for GeckoTerminal). A stale provisional point from the old date logic was removed from state.json (backup state.json.bak-prov-*).
- Checks passing: page groups equal the feed, card words equal the feed, no risk off card green, Robinhood words equal the Baskets section, every basket has a read, 390 and 1280 px no errors, no overflow.
Iterations 71 to 130
| # | Test | Result |
|---|
| 71 | v4 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 |
| 72 | extended = 25% above 20d avg, half | 109 trades, $ per $1 lost all 2.81 / 2023-24 4.37 / 2025-26 1.16, weekly 2025-26 1.11 |
| 73 | extended = 40% above 20d avg, half | 109 trades, $ per $1 lost all 3.21 / 2023-24 4.96 / 2025-26 1.17, weekly 2025-26 1.1 |
| 74 | extended = 60% above 20d avg, half | 109 trades, $ per $1 lost all 3.21 / 2023-24 5.24 / 2025-26 0.88, weekly 2025-26 0.68 |
| 75 | extended = +50% in 30 days, half | 109 trades, $ per $1 lost all 2.81 / 2023-24 4.09 / 2025-26 1.18, weekly 2025-26 0.73 |
| 76 | extended = +100% in 30 days, half | 109 trades, $ per $1 lost all 3.07 / 2023-24 4.65 / 2025-26 1.15, weekly 2025-26 1.04 |
| 77 | extended = RSI 14 >= 80, half | 109 trades, $ per $1 lost all 3.06 / 2023-24 4.78 / 2025-26 1.15, weekly 2025-26 1.22 |
| 78 | extended = RSI 14 >= 85, half | 109 trades, $ per $1 lost all 3.0 / 2023-24 4.88 / 2025-26 0.93, weekly 2025-26 0.71 |
| 79 | extended = +30% from entry, half | 109 trades, $ per $1 lost all 3.06 / 2023-24 4.2 / 2025-26 1.63, weekly 2025-26 1.44 |
| 80 | extended = +50% from entry, half | 109 trades, $ per $1 lost all 3.11 / 2023-24 4.81 / 2025-26 1.16, weekly 2025-26 1.09 |
| 81 | extended = day 8 in trade, half | 109 trades, $ per $1 lost all 3.59 / 2023-24 5.38 / 2025-26 1.3, weekly 2025-26 0.93 |
| 82 | HOT but sell ALL | 257 trades, $ per $1 lost all 1.68 / 2023-24 1.82 / 2025-26 1.52, weekly 2025-26 1.53 |
| 83 | RSI>=80 sell ALL | 162 trades, $ per $1 lost all 2.33 / 2023-24 2.99 / 2025-26 1.45, weekly 2025-26 1.44 |
| 84 | HOT half, then RSI>=85 sell rest | 121 trades, $ per $1 lost all 2.6 / 2023-24 3.04 / 2025-26 1.99, weekly 2025-26 2.09 |
| 85 | HOT half, then +100% 30d sell rest | 131 trades, $ per $1 lost all 2.22 / 2023-24 2.29 / 2025-26 2.09, weekly 2025-26 1.32 |
| 86 | no extended rule at all | 109 trades, $ per $1 lost all 3.36 / 2023-24 5.52 / 2025-26 0.88, weekly 2025-26 0.68 |
| 87 | HOT half, exit 10d low | 131 trades, $ per $1 lost all 3.03 / 2023-24 4.0 / 2025-26 1.85, weekly 2025-26 0.87 |
| 88 | RSI>=80 half + 20d low exit | 101 trades, $ per $1 lost all 2.68 / 2023-24 4.15 / 2025-26 0.98, weekly 2025-26 1.03 |
| 89 | Risk off and still making new lows = WAITING FOR BOTTOM | turned bullish within 14 days: 2023-24 18%, 2025-26 11%; next 14 days median +0.0% / -3.2% |
| 90 | Risk off, no new low for 5+ days = BASING | turned bullish within 14 days: 2023-24 35%, 2025-26 30%; next 14 days median +0.5% / -3.4% |
| 91 | Risk off, within 5% of turning = ALMOST BULLISH | turned bullish within 14 days: 2023-24 48%, 2025-26 72%; next 14 days median +0.0% / +1.5% |
| 92 | BUY signals with a bigger 30 day move did better | 2023-24 $4.83 vs $3.72 per $1; 2025-26 $2.37 vs $1.43 |
| 93 | bigger 7 day move did better | $6.00 vs $3.13; $2.35 vs $1.19 |
| 94 | rising basket volume did better | $6.43 vs $3.22; $2.60 vs $1.42 |
| 95 | BTC up more in the past week | mixed: worse 2023-24, better 2025-26 |
| 96 | further above the 90 day low did better | $4.44 vs $4.13; $2.04 vs $1.53 |
| 97 | closer to the 90 day high | mixed |
| 98 | higher RSI at the BUY did better | $6.47 vs $1.61; $2.28 vs $1.56 |
| 99 | meme baskets did better than non memes | $5.28 vs $3.56; $2.05 vs $1.69 |
| 100 | BUY 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} |
| 101 | rank by 7 day move | +362% total, worst drop -26%, money in 21%, by year {'2023': 40, '2024': 167, '2025': -2, '2026': 26} |
| 102 | rank by rising volume | +326% total, worst drop -24%, money in 22%, by year {'2023': 40, '2024': 167, '2025': 4, '2026': 10} |
| 103 | rank by RSI | +305% total, worst drop -24%, money in 21%, by year {'2023': 40, '2024': 127, '2025': 4, '2026': 23} |
| 104 | rank by 7d move + volume | +385% total, worst drop -24%, money in 22%, by year {'2023': 40, '2024': 167, '2025': 6, '2026': 23} |
| 105 | rank 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} |
| 106 | rank 30d, swap extended for fresh BUY | +568% total, worst drop -31%, money in 23%, by year {'2023': 96, '2024': 146, '2025': 1, '2026': 37} |
| 107 | rank 7d move, 2 slots | +236% total, worst drop -34%, money in 20%, by year {'2023': 26, '2024': 114, '2025': -3, '2026': 28} |
| 108 | rank 7d move, 4 slots | +493% total, worst drop -23%, money in 20%, by year {'2023': 63, '2024': 181, '2025': 6, '2026': 21} |
| 109 | rank 7d move + swap, 2 slots | +677% total, worst drop -40%, money in 24%, by year {'2023': 148, '2024': 115, '2025': -3, '2026': 50} |
| 110 | same as 105 but only from 2025 | +46% total, worst drop -31%, money in 17%, by year {'2025': 4, '2026': 40} |
| 111 | same as 100 but only from 2025 | +31% total, worst drop -24%, money in 16%, by year {'2025': 5, '2026': 25} |
| 112 | live CoinMarketCap baskets vs independent rebuild from daily snapshots | 9 of 9 match (daily return correlation 0.996 to 1.000) |
| 113 | majors basket (OKX) vs CoinMarketCap natives | FAIL: correlation 0.01, the OKX closes were filed one day late |
| 114 | BTC: CoinMarketCap vs OKX closes | FAIL same cause: matched to 0.05% only when OKX is keyed by its candle open day |
| 115 | the provisional close | BUG: 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 |
| 116 | Robinhood index daily labels | BUG: filed one day late (+1); fixed; rebuilt vs official correlation -0.27 before, +0.35 after |
| 117 | Ink, Arc, Bankr, o1 4 hour baskets daily labels | same one day fix |
| 118 | weekly and monthly closes for CoinMarketCap baskets | BUG: each candle ended one day early; fixed; 7 of 68 labels changed |
| 119 | SOL/BTC froth check | same one day shift; after fix correlation 0.999 |
| 120 | after the fix: majors 0.94, BTC 0.9997, SOL/BTC 0.999 | PASS |
| 121 | research backtests | unaffected: they used CoinMarketCap data on one clock |
| 122 | stablecoin inflow 7 days vs basket returns (Solana, Base, TON 2024-26) | no consistent edge; mixed by chain and period |
| 123 | stablecoin inflow 30 days | no consistent edge |
| 124 | DEX volume rising vs falling | Solana 2024-25 rising better, 2025-26 reversed: no edge |
| 125 | closes labels after the date fix | 7 of 68 changed |
| 126 | page groups equal the feed groups | PASS |
| 127 | card words equal the feed words | PASS |
| 128 | no risk off card shown green | PASS |
| 129 | Robinhood section words equal the Baskets section | PASS (was contradicting: HOLD vs WAIT, MONEY COMING IN vs MONEY LEAVING) |
| 130 | every basket has a read | PASS |
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).
- When is it still a buy? Entering the same breakouts k days late, 2025 to 2026: day 0 $1.39 per $1 lost, day 1 $1.02, day 2 $0.96, day 3 $0.93, day 5 $0.55, day 8 $0.67, day 13 $0.61 (2023 to 2024 stayed positive but fell too). So BUY = the signal close and the next close only (and within 10% of the breakout level); after that HOLD, not a buy.
- What improved the rule in both halves: skip Solana breakouts when SOL gained 20%+ on BTC in 4 weeks (froth, from the macro study) and require 25%+ of the other baskets risk on. Together (#35): 2023 to 2024 $3.76 to $4.29 per $1 lost, 2025 to 2026 $1.39 to $1.83, per week clustered 2025 to 2026 $0.97 to $1.49. Thresholds 15/20/25% froth and 15/25/35% breadth all gave the same picture. Tried and not kept: buy late (#3 to #8), extension caps, half at day 8, all out day 13, hold 5 days, 10 or 20 day low exits, trailing after half (2025 to 2026 better, 2023 to 2024 worse), BTC above 50 or 20 day, no majors gate, no gate, +60/100% froth, 50% breadth (better recently, worse early), cooldowns, two closes, hot at z 2.5.
- New rule (slow, 30/15): 109 trades, +9.4% average, median +4.2%, 61% wins, $3.2 won per $1 lost. 2023 to 2024: 54 trades, +15.9% average, median +4.3%, 61% wins, $4.29 won per $1 lost. 2025 to 2026: 55 trades, +3.1% average, median +4.1%, 62% wins, $1.83 won per $1 lost.
- Phases and rotation: putting new money only on BUY days (3 slots) and holding until risk off: +364% Jan 2023 to Sep 2026, worst drop -24%, about 21% of the money in, vs BTC held +403% with a worst drop of -53%. Holding the 3 strongest risk on baskets every day instead: +389%, worst drop -37%.
- "Untested" solved with the fast rule. Young baskets (Robinhood indices, Robinhood memes from CMC, Ink, Arc, Bankr, o1) cannot use a 30 day window. The fast rule (10 day high in, 5 day low out, same filters) on the same 14 long baskets: 204 trades, +5.7% average, median +1.2%, 54% wins, $2.69 won per $1 lost; 2023 to 2024 $3.14, 2025 to 2026 $2.1 per $1 lost. 14/7 and 10/7 also positive, 7/4 weak.
- Robinhood check. Robinhood 30 / 10 / mid caps rebuilt point in time from the runtrack tracker prices (408 RH coins, membership only after a coin was knowable, dead coins kept), Jul to Oct 2026: fast rule caught the late August run and was out by 10 Sep (Robinhood 30 +37.5%, mid caps +67.5%, Robinhood 10 -13.7% and +5.7%); the slow rule bought the 1 Sep top and lost 34 to 36%. Robinhood memes correlate only +0.23 with BTC, so Robinhood moves on its own cycle; the BTC and majors gates were on for every Robinhood signal. 4 trades is a sanity check, not proof.
- Index construction (Ben: does daily equal weight mess the index?): members change weekly (Monday), weights reset to equal each day. Weekly drift like the official Robinhood Indices: about the same (2025 to 2026 $2.95 vs $1.87 per $1 on 6 baskets); top 30 members better than top 15 recently ($2.89), top 10 worse ($0.92). Not changed live yet (would need the daily history rebuilt).
- Dashboard: four groups on top; cards show only name, phase word, tag, two numbers and the chart, with a ? for the why, checks and members; tags: tested 2023-26 or fast rule with days of data; risk off cards grey, close to turning amber; Robinhood order Robinhood 30, Robinhood 10, then by member market cap; closes grouped by chain with titles and a memes line.
- Live 3 Oct close: no BUY phase anywhere; HOLD Solana apps, Base AI, Solana AI, majors, TON, Bonk.fun, Base apps, Pump.fun; rotate out of Arc launchpad tokens (fast rule); every Robinhood basket risk off. Live v4 scorecard counts from the 4 Oct close (ledger4). Raw data: http://159.223.88.168/hx-c9ois/edge/basket-rule-v4-2026-10-03/.
- Questions for the reviewer: (1) The froth and breadth filters were picked on all years at once, though they help both halves; is that enough? (2) Should the slow baskets also move to the fast rule (2025 to 2026 the fast rule was better, $2.10 vs $1.83)? (3) Four Robinhood trades: what is the minimum before trusting it?
All 70 iterations (trades, $ won per $1 lost: all / 2023-24 / 2025-26)
| # | Test | Result |
|---|
| 1 | baseline v3 (30/15, BTC100+majors, half at hot) | 118 trades, avg +8.1%, 2.65 / 3.76 / 1.39 |
| 2 | no half (just 15d low exit) | 118 trades, avg +11.9%, 3.0 / 5.15 / 0.72 |
| 3 | buy 1 day late | 118 trades, avg +7.6%, 2.42 / 3.85 / 1.02 |
| 4 | buy 2 days late | 117 trades, avg +5.8%, 2.06 / 3.14 / 0.96 |
| 5 | buy 3 days late | 116 trades, avg +6.0%, 2.08 / 3.28 / 0.93 |
| 6 | buy 5 days late | 114 trades, avg +2.7%, 1.41 / 2.88 / 0.55 |
| 7 | buy 8 days late | 110 trades, avg +2.0%, 1.29 / 1.87 / 0.67 |
| 8 | buy 13 days late | 89 trades, avg +1.5%, 1.23 / 1.75 / 0.61 |
| 9 | only if <=10% above breakout level | 117 trades, avg +7.8%, 2.52 / 3.87 / 1.19 |
| 10 | only if <=20% above breakout level | 118 trades, avg +8.0%, 2.58 / 3.58 / 1.39 |
| 11 | half at day 8 (breakout clock) | 118 trades, avg +9.5%, 3.22 / 5.42 / 1.0 |
| 12 | half at first of hot or day 8 | 118 trades, avg +7.6%, 2.93 / 4.66 / 1.06 |
| 13 | all out day 13 | 187 trades, avg +7.6%, 2.49 / 3.27 / 1.47 |
| 14 | hold 5 days only | 294 trades, avg +3.6%, 2.01 / 2.02 / 2.01 |
| 15 | half hot, rest 15d low, max 55d | 121 trades, avg +8.8%, 2.72 / 3.75 / 1.41 |
| 16 | exit at 10d low | 144 trades, avg +7.0%, 2.61 / 3.37 / 1.62 |
| 17 | exit at 20d low | 109 trades, avg +7.5%, 2.32 / 3.17 / 1.29 |
| 18 | half hot, then trail 7d low | 143 trades, avg +6.7%, 2.35 / 2.89 / 1.69 |
| 19 | half at first of hot/day8, then trail 7d low | 158 trades, avg +5.8%, 2.65 / 3.86 / 1.4 |
| 20 | gate BTC > 50d instead of 100d | 143 trades, avg +5.4%, 1.93 / 3.25 / 0.92 |
| 21 | gate BTC > 20d | 140 trades, avg +5.6%, 1.97 / 3.46 / 0.92 |
| 22 | BTC gate only, no majors gate | 129 trades, avg +7.4%, 2.4 / 3.97 / 1.1 |
| 23 | no market gate at all | 168 trades, avg +4.6%, 1.75 / 3.53 / 0.76 |
| 24 | skip if basket +100% in 30d (froth) | 115 trades, avg +7.5%, 2.48 / 3.47 / 1.37 |
| 25 | skip if basket +60% in 30d (froth) | 114 trades, avg +6.8%, 2.36 / 3.18 / 1.39 |
| 26 | skip Solana if SOL/BTC +20% in 4w | 111 trades, avg +9.4%, 3.19 / 4.25 / 1.83 |
| 27 | need 25% of other baskets on | 113 trades, avg +9.0%, 3.03 / 4.13 / 1.69 |
| 28 | need 50% of other baskets on | 103 trades, avg +7.8%, 2.74 / 2.83 / 2.52 |
| 29 | cooldown 5 days after a sell | 118 trades, avg +7.3%, 2.36 / 3.56 / 1.16 |
| 30 | need 2 closes above 30d high | 95 trades, avg +8.1%, 2.64 / 4.05 / 1.13 |
| 31 | hot = z>=2.5 | 118 trades, avg +10.2%, 2.86 / 4.59 / 1.06 |
| 32 | 2% costs | 118 trades, avg +7.1%, 2.33 / 3.37 / 1.17 |
| 33 | 20/10 windows | 158 trades, avg +8.2%, 3.02 / 4.24 / 1.61 |
| 34 | 50/20 windows | 91 trades, avg +8.1%, 2.46 / 3.62 / 1.14 |
| 35 | 26+27: SOL/BTC froth skip + 25% breadth | 109 trades, avg +9.4%, 3.2 / 4.29 / 1.83 |
| 36 | 35 + skip if +60% in 30d | 105 trades, avg +8.2%, 2.88 / 3.67 / 1.84 |
| 37 | 35 + trail 7d low after half | 128 trades, avg +8.0%, 2.93 / 3.85 / 2.02 |
| 38 | 35 + all out day 13 | 171 trades, avg +7.9%, 2.73 / 3.59 / 1.68 |
| 39 | 35 + entry <=20% above breakout | 108 trades, avg +9.3%, 3.09 / 4.05 / 1.82 |
| 40 | 35 with 20/10 windows | 143 trades, avg +8.6%, 3.28 / 4.57 / 1.84 |
| 41 | 35, SOL/BTC threshold 15% | 108 trades, avg +7.7%, 2.83 / 3.69 / 1.83 |
| 42 | 35, SOL/BTC threshold 25% | 110 trades, avg +9.5%, 3.25 / 4.39 / 1.83 |
| 43 | 35, breadth 15% | 110 trades, avg +9.4%, 3.13 / 4.11 / 1.83 |
| 44 | 35, breadth 35% | 107 trades, avg +8.9%, 3.04 / 3.73 / 1.99 |
| 45 | 35 + 1 day late entry | 109 trades, avg +8.2%, 2.65 / 4.09 / 1.21 |
| 46 | 35 + 2% cost | 109 trades, avg +8.4%, 2.8 / 3.86 / 1.51 |
| 47 | base rule, 3 strongest 30d | rotation: +281% total, worst -40%, money in 25% |
| 48 | new rule (35), 3 strongest 30d | rotation: +389% total, worst -37%, money in 24% |
| 49 | new rule, 3 freshest (newest risk on) | rotation: +275% total, worst -36%, money in 26% |
| 50 | new rule, money only on BUY day, hold till off | rotation: +364% total, worst -24%, money in 21% |
| 51 | new rule, 3 strongest, max 2 per chain | rotation: +390% total, worst -37%, money in 24% |
| 52 | new rule, 2 strongest | rotation: +456% total, worst -37%, money in 26% |
| 53 | new rule, 4 strongest | rotation: +350% total, worst -32%, money in 22% |
| 54 | fast 14/7 (with 35 filters) | 181 trades, avg +5.3%, 2.39 / 3.38 / 1.41 |
| 55 | fast 10/5 | 204 trades, avg +5.7%, 2.69 / 3.14 / 2.1 |
| 56 | fast 7/4 | 271 trades, avg +3.4%, 1.79 / 2.67 / 1.11 |
| 57 | fast 10/7 | 187 trades, avg +5.5%, 2.48 / 3.37 / 1.55 |
| 58 | fast 14/7, no half | 181 trades, avg +7.0%, 2.58 / 4.3 / 1.03 |
| 59 | equal weight reset daily, top 15 (current) | 60 trades, avg +12.9%, 4.03 / 5.29 / 1.87 |
| 60 | weekly drift (like Robinhood Indices), top 15 | 56 trades, avg +13.2%, 4.08 / 4.53 / 2.95 |
| 61 | top 10 | 63 trades, avg +9.1%, 2.69 / 4.74 / 0.92 |
| 62 | top 30 | 62 trades, avg +12.2%, 4.09 / 4.53 / 2.89 |
| 63 | Robinhood 30 rebuilt (tracker prices, dead coins kept), slow 30/15 | 1 trade: bought 1 Sep near the top, sold 14 Sep, -33.6% |
| 64 | Robinhood 30 rebuilt, fast 10/5 | 1 trade: 24 Aug to 10 Sep, +37.5% |
| 65 | Robinhood 10 rebuilt, fast 10/5 | 2 trades: -13.7%, +5.7% |
| 66 | Robinhood mid caps (11 to 30) rebuilt, fast 10/5 | 1 trade: 21 Aug to 9 Sep, +67.5% |
| 67 | Robinhood 10 rebuilt, slow 30/15 | 1 trade: 4 Sep to 19 Sep, -36.1% |
| 68 | Solana tracker coins top 30 (Apr to Oct 2026), fast vs slow | fast 2 trades -1.5%, -4.3%; slow 1 trade -17.8% |
| 69 | Official Robinhood Index (1 month), fast 10/5 | 1 trade 22 to 25 Sep, -11.6% |
| 70 | BTC and majors gates on Robinhood | no 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.
- Iteration 1, what a risk on state is. 18 baskets built from daily CoinMarketCap listings Sep 2022 to Sep 2026 (top 15 by market cap each Monday, $25k volume floor, dead coins count -50%); 4 thin baskets (TON, Tron, Sui, Avalanche memes, mostly flat lines) dropped, 14 kept. Tested 30+ trend definitions against a random exposure control (same time in market, same switching cost). Moving average and momentum states mostly just cut exposure. A 30 day high / 15 day low switch beat the control in 12 of 14 baskets, in both halves, and in Sep 2024 to Sep 2026 only 7% of the worst 14 day crashes started while it was on. The forward 14 day return when on vs off was about 0: the value is staying out of crashes, not forecasting.
- Iteration 2, filters from the shared ledger. Kept: BTC above its 100 day average (ledger: risk off meme buys lost), the majors basket itself risk on (a breakout the market does not share lost: since Sep 2024, breakouts with the majors off: 16 trades, -10.4% each; with few other baskets on: 11 trades, -12.7%), signal close entry (ledger: one day late halves the edge; here next day entry drops Sep 2024 on from $1.55 to $1.30 per $1), and take half when the basket runs hot (z >= 2, top of its 90 day range): since Sep 2024 a hot basket with BTC on was followed by a median -6.3% over 14 days (31% up), while in 2023 to mid 2024 it kept running (+11.8%), so half, not all. Killed for baskets: buy zones by depth (40 to 60% under the 90 day high: median -2.2% next 14 days; 60 to 80%: -3.8%; the coin level 50 to 70% dip edge does not carry to baskets because dead coins keep dragging), z score dips, stop losses (no gain), BTC gate alone (2025: $0.41 per $1).
- Iteration 3, rotation. Holding the strongest risk on baskets (30 day move) beat holding the weakest at every slot count (1, 2, 3, 4). 3 slots, unused slots in cash, 0.5% per change, Jan 2023 to Sep 2026: +353% (worst drop -40%, about 26% of the money in on average) vs BTC held all the time +400% (worst drop -53%); 3 weakest +112%. By year (log %, rotation vs BTC): 2023 +56 vs +98, 2024 +82 vs +76, 2025 -14 vs -6, 2026 +26 vs -6. This differs from the killed ledger line (always hold the top 14 day basket): there the basket was held even when risk off.
- The rule, as deployed (rule3.py, chains.py chains-3). RISK ON / BUY: daily close above the basket's highest close of the prior 30 days, while BTC closes above its 100 day average and the majors basket (BTC, ETH, SOL, XRP, BNB, DOGE, TRX, ADA, HYPE, LINK equal weight, OKX daily) is risk on by the same rule. TAKE HALF: the first time it runs hot. RISK OFF / SELL: daily close below its lowest close of the prior 15 days. ROTATE INTO: the 3 risk on tested baskets with the biggest 30 day move. Every card shows turns bullish above X% and turns bearish below Y%.
- Evidence. All 14 baskets, Jan 2023 to Sep 2026, 1% cost: 118 trades, +8.1% average, median +3.5%, 58% wins, $2.65 won per $1 lost. Sep 2024 on: 77 trades, +4.0% average, median +3.1%, 57% wins, $1.81 won per $1 lost. Per week clustered: 40 weeks, 55% up, $2.92 per $1. By year: 2023 20 trades, +19.0% average, median +3.6%, 65% wins, $8.48 won per $1 lost; 2024 40 trades, +11.8% average, median +3.9%, 55% wins, $2.83 won per $1 lost; 2025 37 trades, +0.4% average, median +0.7%, 51% wins, $1.06 won per $1 lost; 2026 21 trades, +4.5% average, median +5.9%, 71% wins, $2.72 won per $1 lost.
- Honest limits. The gates and the 30/15 lengths were chosen on the same data (20/10 and 50/20 also worked: Sep 2024 on $1.81 and $1.20 per $1). 2025 was flat. Robinhood, Ink, Arc, Bankr and o1 baskets are too young or rebuilt from today's coins, so they get the same rule labelled untested. The old chain-2 rule is kept as a record; its open Bonk.fun trade keeps its own sell by date; chain-2 BUY alerts are off.
- Live 2 Oct close. BTC +19.7% over its 100 day average, majors risk on. Risk on: Solana apps, Bonk.fun, Base AI, Solana AI, TON, Base apps, majors, Pump.fun. Rotate into: Solana apps (DeFi), Bonk.fun coins, Base AI (all three already ran hot, so half size). Risk off: Solana older memes (turns on above +8.2%), Base memes. Live scorecard counts from the 3 Oct close.
- Telegram. One message per BUY, TAKE HALF, SELL, CANCEL (provisional close not confirmed) and per change of the rotate into list, on the chain basket bot.
- Raw data: http://159.223.88.168/hx-c9ois/edge/basket-rule-v3-2026-10-02/ (bk_all.json and bk_clean.json = every basket line, evidence4.json with all 118 trades, all scripts).
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)
| Check | Trades | Avg | Median | Wins | $ won per $1 lost |
|---|
| sol_pump | 5 | +1.4% | +0.7% | 60% | 1.19 |
| sol_bonk | 1 | +5.9% | +5.9% | 100% | — |
| sol_memes | 10 | +23.0% | +7.0% | 80% | 8.18 |
| sol_ai | 10 | +5.6% | -1.9% | 50% | 1.69 |
| sol_defi | 13 | +15.8% | +1.9% | 54% | 3.60 |
| base_memes | 7 | +2.0% | +7.0% | 57% | 1.22 |
| base_ai | 4 | -0.0% | -6.7% | 25% | 1.00 |
| base_defi | 4 | +0.8% | +0.2% | 50% | 1.19 |
| ton_all | 4 | -3.2% | -3.3% | 25% | 0.46 |
| eth_memes | 15 | +11.3% | +6.2% | 80% | 5.58 |
| eth_ai | 10 | +3.9% | +2.5% | 60% | 1.91 |
| bnb_memes | 15 | +7.3% | +2.8% | 53% | 2.60 |
| bnb_ai | 3 | +13.1% | +5.0% | 67% | 4.18 |
| majors | 17 | +5.5% | +2.8% | 53% | 2.68 |
All 118 v3 trades (basket, entry, half taken, exit, result after 1% cost)
| Basket | Entry | Half | Exit | Result |
|---|
| sol_pump | 2025-04-28 | 2025-05-09 | 2025-05-29 | +34.7% |
| sol_pump | 2025-07-15 | | 2025-07-31 | +0.7% |
| sol_pump | 2025-09-13 | | 2025-09-22 | -22.1% |
| sol_pump | 2026-04-16 | 2026-05-10 | 2026-05-18 | +9.4% |
| sol_pump | 2026-08-20 | | 2026-09-01 | -15.6% |
| sol_bonk | 2026-05-06 | 2026-05-10 | 2026-06-02 | +5.9% |
| sol_memes | 2023-12-02 | 2023-12-08 | 2023-12-27 | +62.9% |
| sol_memes | 2024-01-13 | | 2024-01-30 | -25.0% |
| sol_memes | 2024-02-28 | 2024-02-29 | 2024-04-04 | +82.0% |
| sol_memes | 2024-05-23 | 2024-05-25 | 2024-06-11 | +1.8% |
| sol_memes | 2024-07-19 | 2024-07-21 | 2024-08-01 | +3.8% |
| sol_memes | 2024-09-24 | 2024-10-12 | 2024-12-17 | +78.1% |
| sol_memes | 2025-04-24 | 2025-05-10 | 2025-05-30 | +20.2% |
| sol_memes | 2025-07-13 | | 2025-07-28 | -7.0% |
| sol_memes | 2026-05-03 | 2026-05-04 | 2026-05-24 | +2.8% |
| sol_memes | 2026-08-19 | 2026-08-21 | 2026-09-10 | +10.3% |
| sol_ai | 2024-05-15 | 2024-05-21 | 2024-06-02 | +25.1% |
| sol_ai | 2024-07-21 | | 2024-07-30 | -18.7% |
| sol_ai | 2024-09-22 | 2024-09-26 | 2024-11-02 | +12.8% |
| sol_ai | 2024-11-07 | 2024-11-09 | 2025-01-12 | +85.1% |
| sol_ai | 2025-04-25 | | 2025-05-07 | -11.1% |
| sol_ai | 2025-05-08 | 2025-05-12 | 2025-05-30 | +6.8% |
| sol_ai | 2025-07-15 | | 2025-07-28 | -14.5% |
| sol_ai | 2025-09-13 | | 2025-09-22 | -25.7% |
| sol_ai | 2026-04-16 | 2026-05-04 | 2026-05-16 | +6.9% |
| sol_ai | 2026-08-19 | | 2026-09-01 | -10.6% |
| sol_defi | 2023-01-14 | 2023-01-15 | 2023-03-04 | +1.9% |
| sol_defi | 2023-04-16 | 2023-04-22 | 2023-05-06 | +4.3% |
| sol_defi | 2023-07-13 | | 2023-07-22 | -6.4% |
| sol_defi | 2023-10-30 | 2023-11-01 | 2024-01-05 | +223.1% |
| sol_defi | 2024-02-18 | 2024-02-19 | 2024-03-22 | +7.5% |
| sol_defi | 2024-07-20 | | 2024-08-02 | -13.9% |
| sol_defi | 2024-09-27 | | 2024-10-03 | -16.7% |
| sol_defi | 2024-10-27 | | 2024-11-04 | -10.5% |
| sol_defi | 2024-11-06 | 2024-11-07 | 2024-12-19 | +28.7% |
| sol_defi | 2025-04-24 | | 2025-06-04 | +7.8% |
| sol_defi | 2025-07-13 | 2025-07-21 | 2025-08-01 | +10.5% |
| sol_defi | 2025-09-18 | | 2025-09-25 | -17.2% |
| sol_defi | 2026-04-26 | | 2026-05-04 | -14.2% |
| base_memes | 2024-09-25 | | 2024-10-19 | +35.5% |
| base_memes | 2024-11-15 | | 2024-11-26 | -34.9% |
| base_memes | 2025-05-08 | 2025-05-10 | 2025-05-28 | +21.6% |
| base_memes | 2025-07-11 | 2025-07-18 | 2025-07-29 | +13.0% |
| base_memes | 2025-08-12 | | 2025-08-29 | -26.6% |
| base_memes | 2026-05-10 | 2026-05-14 | 2026-05-28 | -1.5% |
| base_memes | 2026-08-19 | 2026-08-21 | 2026-09-09 | +7.0% |
| base_ai | 2025-04-25 | 2025-05-10 | 2025-05-30 | +32.8% |
| base_ai | 2025-07-16 | | 2025-07-30 | -13.2% |
| base_ai | 2025-08-13 | | 2025-08-25 | -19.3% |
| base_ai | 2026-05-05 | 2026-05-06 | 2026-05-22 | -0.3% |
| base_defi | 2025-04-25 | | 2025-05-23 | +4.7% |
| base_defi | 2025-07-13 | | 2025-07-31 | -4.3% |
| base_defi | 2025-08-12 | 2025-08-13 | 2025-10-07 | +15.6% |
| base_defi | 2026-05-08 | | 2026-05-19 | -12.6% |
| ton_all | 2024-11-10 | | 2024-11-26 | -17.1% |
| ton_all | 2025-05-09 | | 2025-05-30 | -2.3% |
| ton_all | 2025-07-16 | | 2025-08-01 | -4.3% |
| ton_all | 2026-05-04 | 2026-05-05 | 2026-05-20 | +10.8% |
| eth_memes | 2023-01-11 | 2023-01-13 | 2023-03-03 | +25.3% |
| eth_memes | 2023-04-14 | | 2023-04-30 | -14.1% |
| eth_memes | 2023-07-21 | | 2023-08-16 | +15.5% |
| eth_memes | 2023-10-25 | 2023-10-26 | 2023-11-21 | +1.6% |
| eth_memes | 2023-12-03 | 2023-12-04 | 2023-12-26 | +6.9% |
| eth_memes | 2024-02-15 | 2024-02-18 | 2024-03-19 | +70.7% |
| eth_memes | 2024-05-15 | 2024-05-21 | 2024-06-08 | +21.5% |
| eth_memes | 2024-07-19 | | 2024-07-30 | -15.0% |
| eth_memes | 2024-09-24 | 2024-10-08 | 2024-11-02 | +36.2% |
| eth_memes | 2024-11-09 | 2024-11-11 | 2024-11-26 | +5.7% |
| eth_memes | 2024-12-06 | 2024-12-07 | 2024-12-17 | -7.7% |
| eth_memes | 2025-04-25 | 2025-05-08 | 2025-05-30 | +7.9% |
| eth_memes | 2025-07-11 | 2025-07-13 | 2025-07-31 | +5.3% |
| eth_memes | 2026-04-16 | 2026-05-10 | 2026-05-16 | +3.0% |
| eth_memes | 2026-08-20 | 2026-08-21 | 2026-09-15 | +6.2% |
| eth_ai | 2024-03-13 | | 2024-03-16 | -22.5% |
| eth_ai | 2024-05-20 | 2024-05-23 | 2024-06-11 | +24.6% |
| eth_ai | 2024-09-20 | | 2024-10-03 | -10.7% |
| eth_ai | 2024-10-11 | 2024-10-12 | 2024-10-26 | -3.4% |
| eth_ai | 2024-11-08 | 2024-11-10 | 2024-11-26 | +10.6% |
| eth_ai | 2025-04-25 | 2025-05-10 | 2025-05-25 | +30.4% |
| eth_ai | 2025-07-14 | 2025-07-16 | 2025-07-31 | +0.9% |
| eth_ai | 2025-08-12 | 2025-08-13 | 2025-08-31 | -6.6% |
| eth_ai | 2026-04-18 | | 2026-05-16 | +11.9% |
| eth_ai | 2026-08-19 | 2026-08-20 | 2026-09-06 | +4.1% |
| bnb_memes | 2023-01-16 | 2023-01-22 | 2023-02-13 | -2.4% |
| bnb_memes | 2023-03-17 | | 2023-04-01 | -4.0% |
| bnb_memes | 2023-07-18 | 2023-07-19 | 2023-08-02 | +8.1% |
| bnb_memes | 2023-10-29 | 2023-10-30 | 2023-11-21 | +2.8% |
| bnb_memes | 2023-11-29 | 2023-11-30 | 2024-01-03 | +19.5% |
| bnb_memes | 2024-02-17 | 2024-02-19 | 2024-04-11 | +102.7% |
| bnb_memes | 2024-05-23 | 2024-05-25 | 2024-06-17 | +25.2% |
| bnb_memes | 2024-07-20 | | 2024-07-31 | -22.9% |
| bnb_memes | 2024-09-26 | | 2024-10-17 | -9.9% |
| bnb_memes | 2024-11-23 | 2024-11-29 | 2024-12-17 | +4.0% |
| bnb_memes | 2025-04-26 | | 2025-05-05 | -16.2% |
| bnb_memes | 2025-05-09 | 2025-05-12 | 2025-06-12 | +4.2% |
| bnb_memes | 2025-07-21 | | 2025-08-05 | -9.2% |
| bnb_memes | 2025-08-12 | 2025-08-20 | 2025-08-27 | -4.2% |
| bnb_memes | 2026-04-16 | 2026-05-01 | 2026-05-17 | +12.6% |
| bnb_ai | 2025-07-11 | 2025-07-30 | 2025-08-25 | +5.0% |
| bnb_ai | 2025-09-17 | | 2025-09-21 | -12.4% |
| bnb_ai | 2026-04-16 | 2026-05-01 | 2026-05-17 | +46.7% |
| majors | 2023-01-11 | 2023-01-14 | 2023-02-13 | +14.9% |
| majors | 2023-03-17 | 2023-04-11 | 2023-04-21 | -3.1% |
| majors | 2023-05-02 | | 2023-05-06 | -14.0% |
| majors | 2023-07-13 | | 2023-07-29 | -6.7% |
| majors | 2023-10-25 | 2023-11-10 | 2024-01-06 | +43.4% |
| majors | 2024-01-11 | | 2024-01-23 | -13.8% |
| majors | 2024-02-07 | 2024-02-09 | 2024-04-12 | +47.0% |
| majors | 2024-05-15 | | 2024-05-17 | -7.8% |
| majors | 2024-05-18 | 2024-05-19 | 2024-06-04 | +4.4% |
| majors | 2024-07-19 | | 2024-07-31 | -7.5% |
| majors | 2024-09-20 | | 2024-11-04 | -0.2% |
| majors | 2024-11-06 | 2024-11-07 | 2024-12-19 | +18.3% |
| majors | 2025-04-24 | 2025-05-08 | 2025-05-30 | +7.1% |
| majors | 2025-07-11 | 2025-07-12 | 2025-08-01 | +2.8% |
| majors | 2025-08-12 | 2025-08-13 | 2025-09-25 | -2.5% |
| majors | 2026-04-16 | 2026-05-05 | 2026-05-22 | +3.1% |
| majors | 2026-08-19 | 2026-08-23 | 2026-09-15 | +8.3% |
Update 2 Oct 2026: trenches corrected, candle closes added
- Trenches (Rotation Watch, rotation.json). Its word BUY TRENCHES was retired. It fired on 61%% of days (561 of 915, Apr 2024 to Sep 2026) and the Solana and Base meme baskets after it did no better than any day (14 days: median -4.0%%, 41%% up, vs -3.8%% and 40%% on any day). It forecasts ACTIVITY, not prices: after the 16 BTC jumps of +10%% in a week, launchpad volume was higher 14 days later 12 times (median +15%%), but meme baskets were higher only 8 times (median -1%%). After 17 BTC drops of -10%% the meme baskets were HIGHER 11 times (median +6%%), so the old STAY OUT on BTC drops was wrong for prices. A trench share cooling 30%% off its 30 day high was followed by weaker meme prices (32%% up, median -6.7%%, n=38): kept as a mild warning.
- New rotation.json fields. word is now the trench PRICE word taken from the tested meme basket rule (BUY, HOLD, SELL, NO BUY); activity is the activity outlook (MORE TRENCH ACTIVITY AHEAD, LESS TRENCH ACTIVITY LIKELY, TRENCHES HEATING, TRENCHES COOLING, NO CLEAR CHANGE) with its receipt in activity_line. The dashboard shows both in a Trenches panel at the top of the chain baskets. Old rotation.py kept as rotation.py.bak-trench-1790944422.
- Candle closes (closes.py, closes.json). Countdown to the weekly (Monday 07:00 Bangkok) and monthly (1st, 07:00 Bangkok) close, heads up 1 day before and after a weekly close and 2 days before and after a monthly close, one label per candle for 10 majors (OKX UTC candles), a majors basket and every chain index, an overall word from very bullish to very bearish, drill down charts, Telegram heads up, 1 hour warning and result. Tested: weekly labels do not predict the next week (3,187 closes, up 45 to 57%% for every label); monthly new high closes were followed by an up month 58%% of the time (n=130).
- Raw data, public: http://159.223.88.168/hx-c9ois/edge/chain-baskets-2026-10-01/ (all.tgz bundles everything: evidence2.json, evidence3.json, bt_wide.json with all 91 trades, closes_hist.json, fairclock.json, idx_cmc.json, rotdb_daily.json, trench_words.json and the scripts).
Update 1 Oct 2026: the bigger test did not confirm the rule
- Ben asked for a much bigger sample. Same rule, nothing re-tuned, run on data back to Sep 2022 and on meme baskets of 9 more chains (Ethereum, BNB, Sui, Tron, Avalanche, TON, Robinhood; Arbitrum and Polygon had too few coins).
- Where the rule was found (Solana and Base memes): 33 trades, +7.8% average, $2.91 won per $1 lost.
- Everywhere else, never used to build it: 58 trades, -2.0% average, 36% wins, $0.75 won per $1 lost.
- All together: 91 trades, +1.6% average. Trades that start in the same week move together; counted per week it is about break even (46 weeks, -0.2%).
- Plain reading: the Solana and Base edge may be luck or specific to those two chains. It is not a general law. The dashboard now says this on the BUY cards and the scorecard and treats a BUY as a small test bet until the live scorecard proves it. Tables in section 4b.
- Dead coins: every daily basket is picked each Monday from the coins CoinMarketCap listed that day, so coins that later died count in both the past test and the live line. The 4 hour baskets (Arc, Ink, Bankr, o1, shadow) use today's coins and are flagged.
Read this first
- What it is. One public page for Ben (a crypto trader who wants plain buy, hold and sell words). Top: the frozen v2 rotation call (BTC, alts or cash). Then the chain baskets (Solana, Robinhood, Base, Arc, TON) with a word each. Then gauges, the v2 strip, history, proof tables, and the Robinhood Chain panel.
- What changed today (13 changes, plus Ink baskets on request). The chain basket rule was rebuilt after Ben judged the first version too weak (+2.9% median over 14 days, 56% wins). New rule, tested with dead coins included and 1% costs: 34 trades, +7.1% average, +7.4% median, 59% wins, $2.55 won per $1 lost. Section 2 lists all 13 changes with evidence.
- The weak spots, stated plainly. (1) Only 34 past trades. (2) The rule and the meme only choice were found on the same data; the walk forward check (section 4) is the only out of sample evidence. (3) Buying one day late cuts the average to +3.3%. (4) The second half of the test is weaker (+5.5% avg, median -1.8%) than the first (+9.6%). (5) 4 hour and Robinhood baskets are untested.
- Frozen. rotmap.py (v2) is untouched (md5 dc805b4db985d5a8c906a375061b1f14) because a pre registered lockbox test reads it no earlier than 28 Sep 2027. The Codex built Robinhood Indices site (/opt/robinhood-indices) is untouched.
- What to review. Section 9 lists questions. Section 8 is the list of 30 possible changes with status.
1 · The page, section by section
| Section | What it shows | Source |
|---|
| What to do now | The 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 baskets | Headline 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 switch | BTC vs its 100 day average: red OFF zone, grey hold strip (±1%), green ON zone, BTC marker. | rotmap.json |
| 2 · Broad alts vs BTC | 90 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 strip | v2 baskets (BTC, ETH, SOL, MAJORS, DEFI, AI, MEMES), launchpad activity, and the Robinhood indices as context cards. | rotmap.json, rh_drill.json |
| 4 · History | Breadth 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 8 | Report card of major rotations, past results, what each colour means with evidence grades, the system's last moves. | rotmap.json |
| 9 · Robinhood Chain tokens | Rotation 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)
| # | Change | From | What exactly | Evidence |
|---|
| 1 | BUY only for meme baskets, on a break of the 10 day high, plus a SELL rule | Top 3 #1 | BUY 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. |
| 2 | Live scorecard with a frozen trade ledger | Top 3 #2 | Every 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. |
| 3 | 4 hour shadow baskets | Top 3 #3 | The 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. |
| 4 | Every number after costs, with timing and cost stress shown | Top 10 | All 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. |
| 5 | Robustness grid and walk forward check | Top 10 | 27 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. |
| 6 | Telegram alert on every BUY and SELL | Top 10 | One 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%. |
| 7 | Closest to a buy, and a live preview | Top 10 | A 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. |
| 8 | Past record on every card, and past trades shaded on the charts | Top 10 | Each 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). |
| 9 | Robinhood point in time meme basket | Top 10 | A 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. |
| 10 | Fair breadth reading for Rotation v2 (display only) | Top 10 | Next 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. |
| 11 | v2 forward record since the freeze | Top 10 | Under 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. |
| 12 | Risk switch history inside the chain engine | Top 10 | The 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. |
| 13 | Overlap flags | Top 10 | A 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. |
| 14 | Ink (Kraken's chain) baskets | Ben's request during the build | Two 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
- Stages (stages.py, per bar): FALLING if the 90 bar low is 0 to 2 bars old; HOT if z of log price vs its 20 bar mean ≥ 2 and it sits in the top 20% of its 90 bar range; CONFIRMED UP if the low is ≤ 60 bars old, close above the prior 10 bar high, above a 20 bar EMA that is higher than 5 bars ago; TURNING UP if low 4 to 60 bars old and above a rising EMA; BOTTOMING if low ≤ 60 bars old otherwise; UPTREND above a rising EMA with an older low; else DRIFTING DOWN.
- BUY on the first CONFIRMED UP bar while the BTC risk switch is on (BTC daily close vs 100 day average: on above +1%, off below -1%, keeps state between). Traded baskets only: sol_pump, sol_bonk, sol_memes, base_memes. Entry at that close.
- SELL at the first close that is below the 20 bar EMA, or ≤ -12% from entry, or HOT, or 14 bars after entry. One trade at a time. 1% round trip cost.
- Baskets: up to 15 coins by market cap, chosen each Monday from that day's CoinMarketCap listing (two pages of 5,000), platform and tag rules in baskets_def.py, volume ≥ $25k, market cap > 0, stablecoins, wrapped and tokenized stock excluded. Equal weight daily returns clipped to -90%/+200%; a coin missing for 3+ days counts -50%.
- Words: traded baskets say BUY, HOLD (in trade), SELL, WAIT (breakout while risk off), WATCH: CLOSE (all but the break), DON'T CHASE (hot, not in a trade), WATCH, AVOID. Context baskets never say BUY. Shadow baskets say SHADOW BUY/HOLD/SELL.
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
| Check | Trades | Avg | Median | Wins | $ won per $1 lost |
|---|
| All traded baskets | 34 | +7.1% | +7.4% | 59% | 2.55 |
| Base memes | 10 | +10.7% | +12.6% | 60% | 3.10 |
| Bonk.fun coins | 7 | +6.3% | -4.8% | 43% | 1.95 |
| Solana older memes | 10 | +8.1% | +8.9% | 80% | 5.99 |
| Pump.fun coins | 7 | +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 rule | 44 | +0.1% | -4.4% | 39% | 1.03 |
Stress and variants
| Check | Trades | Avg | Median | Wins | $ won per $1 lost |
|---|
| enter next close | 33 | +3.3% | +2.4% | 61% | 1.69 |
| no costs | 34 | +8.1% | +8.4% | 59% | 2.94 |
| 2% round trip | 34 | +6.1% | +6.4% | 59% | 2.22 |
| no sell rule, hold 14 days | 29 | +2.4% | +2.2% | 59% | 1.30 |
| break of 30 bar high | 23 | +11.4% | +14.0% | 74% | 3.78 |
| stale price filter | 33 | +7.7% | +6.7% | 55% | 2.64 |
| members need $100k volume | 36 | +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 14 | 48 | +1.1% | +0.6% | 50% | 1.14 |
Walk forward
| Check | Trades | Avg | Median | Wins | $ won per $1 lost |
|---|
| Memes, first half | 13 | +9.6% | +9.8% | 77% | 3.80 |
| Non memes, first half | 22 | +0.9% | -2.3% | 41% | 1.20 |
| Memes, second half | 21 | +5.5% | -1.8% | 48% | 2.04 |
| Non memes, second half | 22 | -0.6% | -5.9% | 36% | 0.89 |
| Best first half combination (break 30, stop 8%, hold 21) in the second half | 14 | +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.
| Check | Trades | Avg | Median | Wins | $ won per $1 lost |
|---|
| A in sample | 33 | +7.8% | +8.0% | 64% | 2.91 |
| B old years | 2 | +34.9% | +34.9% | 50% | 3.99 |
| C new chains | 56 | -3.3% | -5.5% | 36% | 0.57 |
| out of sample (B+C) | 58 | -2.0% | -5.5% | 36% | 0.75 |
| everything | 91 | +1.6% | -3.6% | 46% | 1.24 |
| Check | Trades | Avg | Median | Wins | $ 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% | — |
| Check | Trades | Avg | Median | Wins | $ won per $1 lost |
|---|
| Year 2023 | 15 | +3.9% | -4.6% | 40% | 1.61 |
| Year 2024 | 18 | +1.0% | -2.6% | 50% | 1.16 |
| Year 2025 | 40 | -2.0% | -7.5% | 40% | 0.75 |
| Year 2026 | 18 | +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) | Events | Launchpad volume 14d later | Meme baskets 14d later |
|---|
| BTC +10% in 7 days | 16 | up 75%, median +15.0% | up 50%, median -1.0% |
| BTC -10% in 7 days | 17 | up 47%, median -14.2% | up 65%, median +6.2% |
| Trench share 30% above its 30 day low | 39 | up 62%, median +14.3% | up 46%, median -0.9% |
| Trench share 30% below its 30 day high | 38 | up 50%, median -1.2% | up 32%, median -6.7% |
| Any day (all days, not events) | 898 | up 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)
| Break | Stop | Hold | Trades | Avg | $ per $1 lost |
|---|
| 10 | 8% | 7 | 39 | +7.0% | 3.19 |
| 10 | 8% | 14 | 34 | +7.6% | 2.89 |
| 10 | 8% | 21 | 30 | +11.8% | 3.84 |
| 10 | 12% | 7 | 38 | +7.4% | 3.19 |
| 10 | 12% | 14 | 34 | +7.1% | 2.55 |
| 10 | 12% | 21 | 30 | +11.3% | 3.46 |
| 10 | 16% | 7 | 38 | +7.4% | 3.16 |
| 10 | 16% | 14 | 34 | +6.8% | 2.41 |
| 10 | 16% | 21 | 30 | +11.1% | 3.32 |
| 20 | 8% | 7 | 34 | +7.1% | 3.28 |
| 20 | 8% | 14 | 29 | +9.5% | 3.91 |
| 20 | 8% | 21 | 25 | +14.8% | 5.51 |
| 20 | 12% | 7 | 32 | +8.5% | 4.18 |
| 20 | 12% | 14 | 28 | +9.3% | 3.36 |
| 20 | 12% | 21 | 24 | +15.0% | 4.88 |
| 20 | 16% | 7 | 32 | +8.5% | 4.13 |
| 20 | 16% | 14 | 28 | +9.0% | 3.11 |
| 20 | 16% | 21 | 24 | +14.7% | 4.59 |
| 30 | 8% | 7 | 30 | +6.3% | 3.39 |
| 30 | 8% | 14 | 24 | +11.6% | 4.51 |
| 30 | 8% | 21 | 22 | +13.8% | 5.19 |
| 30 | 12% | 7 | 28 | +7.9% | 4.64 |
| 30 | 12% | 14 | 23 | +11.4% | 3.78 |
| 30 | 12% | 21 | 21 | +14.0% | 4.53 |
| 30 | 16% | 7 | 28 | +7.8% | 4.57 |
| 30 | 16% | 14 | 23 | +11.0% | 3.45 |
| 30 | 16% | 21 | 21 | +13.7% | 4.23 |
Tested and not adopted
| Idea | Result |
|---|
| 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 volume | 36 trades, +3.8% avg, $1.67 per $1 lost. Worse; not adopted. |
| Buy only the 3 strongest coins in the basket | Earlier 14 day hold test: raised the average but the typical trade fell to about 0%; not adopted. |
| Rising volume as a filter | Worse 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 UP | Earlier 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
| Basket | Signal | Exit | Result | Why | Days |
|---|
| Base memes | 2024-09-25 | 2024-10-09 | +40.4% | day 14 | 14 |
| Base memes | 2024-10-14 | 2024-10-18 | -14.7% | stop | 4 |
| Base memes | 2025-04-23 | 2025-04-29 | -11.1% | trend | 6 |
| Base memes | 2025-05-08 | 2025-05-10 | +27.2% | hot | 2 |
| Base memes | 2025-07-09 | 2025-07-18 | +44.1% | hot | 9 |
| Base memes | 2025-07-20 | 2025-07-23 | -14.4% | stop | 3 |
| Base memes | 2025-08-08 | 2025-08-09 | +10.2% | hot | 1 |
| Base memes | 2025-10-06 | 2025-10-07 | -11.0% | trend | 1 |
| Base memes | 2026-05-09 | 2026-05-14 | +15.0% | hot | 5 |
| Base memes | 2026-08-19 | 2026-08-21 | +21.8% | hot | 2 |
| Bonk.fun coins | 2025-10-01 | 2025-10-10 | -22.3% | stop | 9 |
| Bonk.fun coins | 2026-05-02 | 2026-05-03 | -4.8% | trend | 1 |
| Bonk.fun coins | 2026-05-04 | 2026-05-18 | +27.6% | day 14 | 14 |
| Bonk.fun coins | 2026-05-19 | 2026-05-28 | -6.5% | trend | 9 |
| Bonk.fun coins | 2026-08-19 | 2026-08-22 | +14.0% | hot | 3 |
| Bonk.fun coins | 2026-08-24 | 2026-09-07 | +48.3% | hot | 14 |
| Bonk.fun coins | 2026-09-19 | 2026-09-28 | -12.4% | trend | 9 |
| Solana older memes | 2024-07-19 | 2024-07-21 | +19.1% | hot | 2 |
| Solana older memes | 2024-09-19 | 2024-10-03 | +8.0% | trend | 14 |
| Solana older memes | 2024-10-11 | 2024-10-12 | +6.7% | hot | 1 |
| Solana older memes | 2024-10-18 | 2024-11-01 | +12.0% | day 14 | 14 |
| Solana older memes | 2025-04-22 | 2025-05-06 | +9.8% | day 14 | 14 |
| Solana older memes | 2025-05-08 | 2025-05-10 | +18.9% | hot | 2 |
| Solana older memes | 2025-08-10 | 2025-08-11 | -9.3% | trend | 1 |
| Solana older memes | 2026-05-01 | 2026-05-04 | +3.0% | hot | 3 |
| Solana older memes | 2026-08-19 | 2026-08-21 | +19.7% | hot | 2 |
| Solana older memes | 2026-09-21 | 2026-09-28 | -6.9% | trend | 7 |
| Pump.fun coins | 2025-01-04 | 2025-01-07 | -18.9% | stop | 3 |
| Pump.fun coins | 2025-04-22 | 2025-05-06 | +1.3% | day 14 | 14 |
| Pump.fun coins | 2025-05-08 | 2025-05-09 | +26.6% | hot | 1 |
| Pump.fun coins | 2025-07-20 | 2025-07-31 | -10.8% | trend | 11 |
| Pump.fun coins | 2025-10-03 | 2025-10-09 | -10.9% | trend | 6 |
| Pump.fun coins | 2026-05-01 | 2026-05-10 | +23.4% | hot | 9 |
| Pump.fun coins | 2026-08-19 | 2026-08-30 | -1.8% | trend | 11 |
5 · Operations
| Job | When (Bangkok) | Writes | Notes |
|---|
| rotmap.py (frozen v2) | daily after the UTC close | /opt/launchwatch/pub/rotmap.json | never edit; lockbox |
| robinhood-indices collector (Codex) | hourly at :05 | /opt/launchwatch/pub/robinhood-indices/market.json | not ours; do not touch |
| rh_drill.py | hourly at :15 | /opt/launchwatch/pub/rh_drill.json | reads 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.json | CoinMarketCap 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.json | price 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.py | hourly at :05, flock | /opt/launchwatch/pub/closes.json | OKX 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.py | hourly at :50 | Telegram | one 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
- 34 past trades is a small sample; a few big wins carry the average (average win +19.8%).
- CoinMarketCap tags decide basket membership; tags can be applied after the fact, so membership is only roughly point in time.
- The daily close is 00:00 UTC (07:00 Bangkok); the snapshot often appears hours later, so real entries lag the tested close.
- 4 hour baskets rebuild history from today's coins until they have live history (flagged on the cards).
- TON has about 10 liquid coins; Base memes about 8 to 10 with a market cap.
- Ink (Kraken's chain) has a small meme market (pools of $20k to $135k); its baskets are 4 hour, untested, and built from today's coins. Arc (Circle) is new and untested.
7 · How to reproduce
- 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).
- bt_final.py writes evidence2.json (uses rule2.py, the same code the live engine runs).
- fetch_top.py and fairclock.py for the fair reading.
- migrate2.py seeds the live state; chains.py runs hourly.
8 · 30 possible changes, with status
| # | Area | Change | Status |
|---|
| 1 | Rotation v2 | Fair point in time breadth reading beside the survivor reading | DONE (#10) |
| 2 | Rotation v2 | Forward record since the freeze, with the lockbox date | DONE (#11) |
| 3 | Rotation v2 | Register a v3 shadow book that uses the fair reading, run forward only (keeps the v2 lockbox clean) | PROPOSED next |
| 4 | Rotation v2 | Rebuild the 64 coin set point in time for the book itself (only as v3; changing v2 breaks the lockbox) | PROPOSED, needs Ben's call |
| 5 | Rotation v2 | Daily points on the history chart instead of weekly | PROPOSED |
| 6 | Rotation v2 | Show the BTC only switch as the headline baseline beside the system | PARTLY (section 6 table) |
| 7 | Rotation v2 | Crowding (funding) as a warning | REJECTED earlier (28 Sep test) |
| 8 | Chain baskets | BUY only for meme baskets, 10 day break | DONE (#1) |
| 9 | Chain baskets | SELL rule: 20 day trend, -12%, hot, 14 days | DONE (#1) |
| 10 | Chain baskets | Live scorecard with frozen ledger | DONE (#2) |
| 11 | Chain baskets | 4 hour shadow baskets | DONE (#3) |
| 12 | Chain baskets | Costs and late entry in every number | DONE (#4) |
| 13 | Chain baskets | Robustness grid and walk forward | DONE (#5) |
| 14 | Chain baskets | Telegram BUY and SELL alerts | DONE (#6) |
| 15 | Chain baskets | Closest to a buy plus live preview | DONE (#7) |
| 16 | Chain baskets | Past record per card, trades shaded on charts | DONE (#8) |
| 17 | Chain baskets | Risk switch history inside the engine | DONE (#12) |
| 18 | Chain baskets | Overlap flags | DONE (#13) |
| 19 | Chain baskets | Stale price filter | TESTED, marginal, not adopted |
| 20 | Chain baskets | $100k liquidity floor | TESTED, worse |
| 21 | Chain baskets | Buy the 3 strongest coins instead of the basket | TESTED, worse typical trade |
| 22 | Chain baskets | Rising volume filter | TESTED, worse |
| 23 | Chain baskets | Chain coin trend gate | TESTED, no help |
| 24 | Solana | Split Pump.fun coins by age (new graduates vs the old big names); it is the weakest traded basket | PROPOSED next |
| 25 | Solana | Bags and Believe baskets once their graduated pools pass $50k | WAITING for volume |
| 26 | Robinhood | Point in time meme basket from CoinMarketCap | DONE (#9) |
| 27 | Robinhood | Same rule on the official meme indices, as shadow | DONE (inside #3) |
| 28 | Robinhood | Backtest the flow checks (buy share, pool growth) once 60 days of hourly history exist | SCHEDULED for late Nov 2026 |
| 29 | Base | Widen 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 now | PROPOSED next |
| 30 | Base | Promote the Bankr and o1 4 hour baskets to shadow trades once they have 30 days of live history | PROPOSED |
9 · Questions for the reviewer
- 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?
- The second half is weaker (median about 0%). Is that decay, or noise at 20 trades?
- Should the break be 30 days (fewer, better trades: 23 trades, +11.4% avg) rather than 10?
- Is counting a coin that vanishes as -50% (tested also at -100%) reasonable?
- 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¤cy=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