bryceweiner commited on
Commit
c4633e1
·
verified ·
1 Parent(s): d6c8925

release 10 (7e2ff90c0e)

Browse files
Files changed (50) hide show
  1. CHANGELOG.md +232 -0
  2. console/src/components/DataTable.tsx +4 -3
  3. console/src/fly/Inspector.tsx +21 -2
  4. console/src/lib/format.ts +6 -0
  5. console/src/pages/Overview.tsx +25 -9
  6. console/src/theme/brand.css +3 -0
  7. fly_trader/__main__.py +36 -0
  8. fly_trader/agent/fly_live.py +53 -11
  9. fly_trader/agent/fly_session.py +95 -29
  10. fly_trader/agent/live_first.py +75 -13
  11. fly_trader/agent/minute_engine.py +62 -38
  12. fly_trader/agent/paper_trading.py +35 -5
  13. fly_trader/chain/balances.py +5 -2
  14. fly_trader/chain/rpc.py +22 -10
  15. fly_trader/config.py +31 -3
  16. fly_trader/db/schema.py +4 -1
  17. fly_trader/execution/broker_live.py +76 -47
  18. fly_trader/execution/worker.py +91 -33
  19. fly_trader/ingest/helius_feed.py +428 -0
  20. fly_trader/ingest/pumpstream.py +195 -59
  21. fly_trader/ingest/relay_feed.py +109 -0
  22. fly_trader/ingest/replay_pull.py +169 -11
  23. fly_trader/kalshi/strategies.py +6 -2
  24. fly_trader/ops/release.py +1 -1
  25. fly_trader/rh/corpus.py +50 -7
  26. fly_trader/rh/index.py +22 -15
  27. fly_trader/rh/live.py +50 -9
  28. fly_trader/rh/meta.py +43 -2
  29. fly_trader/rh/minutes.py +40 -15
  30. fly_trader/rh/prefetch.py +1 -1
  31. fly_trader/rh/prices.py +4 -4
  32. fly_trader/rh/schema.py +30 -0
  33. fly_trader/rh/stream.py +76 -3
  34. fly_trader/rh/token_check.py +131 -0
  35. fly_trader/rh/v4_universe.py +469 -0
  36. fly_trader/signer/core.py +16 -8
  37. fly_trader/train/corpus_meta.py +56 -9
  38. fly_trader/train/fly_replay.py +2 -2
  39. fly_trader/train/fly_selector.py +85 -34
  40. fly_trader/train/mature.py +88 -18
  41. fly_trader/train/replay_assemble.py +8 -4
  42. fly_trader/train/selector.py +21 -8
  43. fly_trader/train/strategies.py +78 -41
  44. fly_trader/train/wallet_skill.py +20 -11
  45. fly_trader/ui/api/inspector.py +15 -1
  46. fly_trader/ui/static/console/assets/index-BzBFluj-.css +0 -0
  47. fly_trader/ui/static/console/assets/index-VfXqQuzN.js +0 -0
  48. models/policies/fly_20261006T023153Z.pt +0 -3
  49. models/selectors/selector_20261003T201413Z.skops +0 -3
  50. models/wallet_skill/2026-10-11.parquet +0 -3
CHANGELOG.md CHANGED
@@ -3,6 +3,238 @@
3
  Releases are identified by date. The Docker distribution (branch `distribution`, Hugging Face `bryceweiner/fly-trader`)
4
  ships signed releases built from this development line.
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  ## 2026-10-18, release 9: The Overview page charts each chain's setups, and the money's book comes first
7
 
8
  Published to Hugging Face as signed release 9. Fly #157 and selector #154 are unchanged, and so are the definitions they
 
3
  Releases are identified by date. The Docker distribution (branch `distribution`, Hugging Face `bryceweiner/fly-trader`)
4
  ships signed releases built from this development line.
5
 
6
+ ## 2026-10-23, release 10: A retrained selector trades `ev` again, and live money follows the seat
7
+
8
+ Published to Hugging Face as signed release 10. It ships selector #560 and fly #561 in place of selector #154 and fly
9
+ #157:
10
+
11
+ - **Selector #560** is fitted through 2026-10-20 on this release's rules and trades `ev` again besides `capitulation`. On
12
+ the 18 days 2026-10-03..10-20, out of sample for both, its holdout-blind copy made +9.71 % per trade over 393 trades
13
+ (62 % winners, profit factor 2.08); #154 made +9.64 % over 392 (58 %, 1.95).
14
+ - **Fly #561** is taught by it. Its own decisions reach the wallet only once it wins its 14-day race (Live money below).
15
+
16
+ ### Upgrading
17
+
18
+ - **Nothing to do on a Docker install:** its updater installs this release within the hour, as usual.
19
+ - **A server on the host updater** (`deploy/fly_update.py`) holds this code release for 24 hours, or applies it at once
20
+ with `fly_update.py approve 10`.
21
+ - **The corpus is rebuilt once on start,** because the Solana universe changed: about 5 hours in a child process at the
22
+ lowest CPU priority. The rebuild retires no model.
23
+ - **New tables and columns are added on start:** `corpus_meta.grad_quote`; on Robinhood Chain, `rh_v4_keys`,
24
+ `rh_v4_tokens` and new `rh_pools` and `rh_meta` columns.
25
+ - **Robinhood Chain installs:** run `fly-trader rh-v4-backfill` once, under `nice -n 19` and apart from the console, so
26
+ v4 pools older than the console's watch are found.
27
+ - **New settings:** `EXEC_THREADS` (default 4), `SOL_FEED` (default `pumpapi`), `STREAM_RELAY_URL`, `STREAM_RELAY_TOKEN`,
28
+ `STREAM_RELAY_CA_FILE`, `HELIUS_FEED_COMMITMENT`, `REFETCH_PARALLEL`, `REFETCH_MAX_LAG_S` and `REFETCH_SKIP_RECENT_H`.
29
+ - **On an install whose fly already won its race, fly #561 inherits that seat** and trades live without racing first.
30
+ The next release makes every new fly race.
31
+
32
+ ### Live money
33
+
34
+ - **Minutes are processed 2–3× faster: a minute is ready a median 2.8 s after its end, down from 6.4 s.** Measured with
35
+ `tools/latency_probe.py` against the 2026-10-19 baseline. Figures are seconds after the minute ends:
36
+
37
+ | | Old median | New median | Old p90 | New p90 | Old max | New max |
38
+ |---|---|---|---|---|---|---|
39
+ | Stream publishes the minute | 4.3 | 2.3 | 16.3 | 9.9 | 17.6 | 41.9 |
40
+ | Selector done (the console's "latency") | 6.4 | 2.8 | 16.9 | 10.3 | 18.4 | 42.5 |
41
+ | Publish → selector done | 0.7 | 0.5 | 1.1 | 1.0 | 4.3 | 3.7 |
42
+
43
+ - The worst minute waited 41.9 s for PumpAPI's stream to publish it. The feed is now the remaining delay, not the
44
+ engine (see Platform).
45
+ - The fly's status records each minute's timing: when its rows were ready, when the fly decided and when the live
46
+ orders were queued (`seen_s`, `scored_s`, `orders_s`). The first minutes after the change: Solana 2.42, 2.47 and
47
+ 2.48 s; Robinhood Chain 4.79, 4.84 and 4.85 s. That is about 50 ms from a minute's data to its live orders.
48
+ - **The fly decides a live minute on one pass of its teacher.**
49
+ - It takes the selector's decision once and scores only the rows the selector would buy, the input of its learned veto.
50
+ - Every other row's score, its learning tag and the brain view wait until after the live orders.
51
+ - Live scoring takes about 6 ms, down from a median 94 ms (150–400 ms when the GPU was idle), with the same decisions
52
+ on 40 real minutes.
53
+ - **Solana swaps run in parallel.**
54
+ - Up to four swaps are in flight at once (`EXEC_THREADS`): one per token, and each token's in order.
55
+ - Each fill is read from its own transaction, since another swap may land between a swap's two balance reads.
56
+ - If a transaction cannot be read while other swaps are in flight, the fill is recorded as unknown and entries pause
57
+ until `verify-fills` reconciles it.
58
+ - Robinhood Chain swaps stay one at a time.
59
+ - **Nothing in front of a live order waits that need not.**
60
+ - The wallet's balance is read while the minute is scored.
61
+ - The balances before a swap are read beside Jupiter's quote and the signing, in one round trip.
62
+ - The signer's lookups run side by side, and a swap's confirmation is checked every slot instead of every second.
63
+ - **The engine wakes the moment a minute is written,** instead of looking every 250 ms.
64
+ - The stream checks for complete minutes every 50 ms, on its own clock.
65
+ - Both chains run their live phases and send their boundary sales at the same time.
66
+ - Paper work steps aside when another chain's minute lands meanwhile.
67
+ - **A failed live entry is tried again only while the fly would still buy the token, at that minute's price.**
68
+ - Before, every retry was checked against its decision's price. Once a token ran more than 3% (`MAX_ENTRY_CHASE`),
69
+ every retry was refused as "price ran" until the two retry minutes were up: a runner missed once was missed for good.
70
+ - Each minute's plan now says, for every token paper holds, whether it would buy it again and what paper would pay at
71
+ that minute's price. A retry goes out on that minute's score and against that price, or waits, recorded as
72
+ "no longer a buy" with the score against its line. Both chains.
73
+ - **Sell now: any open live position can be sold from the Overview's open positions, on both chains.**
74
+ - After a confirmation, the wallet sells the whole position at once, at the exit's slippage of 3% stepping up to at most
75
+ 5%, and books it like any exit. The console waits up to 90 s and says what the sale brought, or why it failed.
76
+ - The fly's paper book keeps its own position. An operator's sale counts neither in the live-vs-paper gap that pauses
77
+ entries nor in what the fly learns about its fills.
78
+ - **Live money follows the seat: until the fly wins its race, the wallet trades the selector's decisions.**
79
+ - Before, with `LIVE_WITHOUT_HANDOVER` on, the wallet traded the fly's decisions whoever held the seat. Those are the
80
+ selector's picks less what the fly's learning vetoes, so an untested fly's learning reached live money before its race.
81
+ - Now, until the handover, a pick the fly's learning vetoes still goes live as the selector decided it. It stays out of
82
+ the fly's paper book, which is what the race compares with the selector's. Once the fly holds the seat, its vetoes
83
+ bind the wallet too. A pick the selector itself blocks never goes live. Both chains.
84
+ - **A Robinhood Chain minute is written in milliseconds at any hour, not up to 2.5 s late in the UTC day.**
85
+ - Writing each minute re-read every swap since 00:00 UTC to warm the price filter (the median of a coin's previous 200
86
+ prices that day): about 130,000 swaps and 2.1–2.5 s by 23:59, in front of that minute's live entries.
87
+ - It now reads only each coin's last 200 prices that day: 0.03–0.07 s at 23:59. On 18 real ranges (single minutes at
88
+ 23:59, noon and now, 240 minutes, across midnight, a whole day, and four quote assets) the rows are identical.
89
+ - **The Robinhood Chain corpus is built in a child process, never in the stream's thread.**
90
+ - At 00:00 UTC the stream built the previous day's training files itself: about 30 s on 2026-10-23, after that minute's
91
+ write and before the next one's. The other v4 pools (The universe) add several times the swaps.
92
+ - The stream now only looks at the files and starts `fly-trader rh-build-corpus` at the lowest CPU priority, one at a
93
+ time.
94
+ - **Two reads the latency probe caught after the 2026-10-23 restart are cheap now, and off the live path.**
95
+ - The Solana stream reloads the blocked custom pools every 5 minutes on the loop that flushes its minutes. Since the
96
+ suffix-less graduates, that read probed `corpus_meta` once per pool row: up to 3.5 s. It now joins the two tables
97
+ (the same 32,308 pools, 1.9 s → 65 ms on a copy) and runs on a thread.
98
+ - `build-mature` read the same pools for every day of a rebuild; it now reads them once per round.
99
+ - The Robinhood Chain corpus read every coin's median fee over all of `rh_minutes` (13 s) for every day it built. It now
100
+ reads them once per run, in the middle of a minute, and a run builds up to 31 days.
101
+
102
+ ### The universe
103
+
104
+ - **Solana: pump.fun graduates whose mint does not end in "pump" are traded too.**
105
+ - They are 4–22 % of a day's graduations (40–60 a day this week), with the same launchpad, creator history, burned pool
106
+ liquidity and token rules as the rest. A mint-suffix test dropped them in the stream, the corpus and the wallet skill.
107
+ - A coin now counts as pump.fun's when its mint ends in "pump" or a pump.fun migration recorded it: the stream adds a
108
+ graduate the moment it sees the migration and reloads `corpus_meta`'s every 5 minutes. Their custom pools stay blocked.
109
+ - On 2026-10-21 they add 1,582 mints to the day's 10,754 and 5.5 % of its volume. No existing row changes.
110
+ - The corpus is rebuilt to include them (aggregation version 5). The current selector and fly keep trading through it.
111
+ - **Solana: a pump.fun coin's Meteora DLMM pools make its minutes too.**
112
+ - Each minute keeps the pool with the most legs, as before, now among the coin's SOL-quoted PumpSwap and DLMM pools.
113
+ The stream and the corpus apply the same rule: on 2026-10-22 15:00 UTC both gave 24,552 minute rows for 3,078 coins.
114
+ - In that hour 99 % of minutes kept their pool, 210 minutes of 39 coins moved to a DLMM pool, and 101 coins gained 937
115
+ minutes. Coins graduated into a pool quoted in TTWO, PUMP, WBTC or USDC (Dacity, Macca, pill, baton, Au) have their
116
+ only SOL market there.
117
+ - A graduation's quote asset is now kept (`corpus_meta.grad_quote`): from the migration, or for an older coin from trades
118
+ in its own pool. A coin graduated in another quote has its dev buy and seed reserve in that quote's units, so those two
119
+ inputs read as unknown for it, live and in training. 17 % of graduations on 2026-10-20 were not in SOL.
120
+ - Paper fills charge the minute's pool fee, so a DLMM minute costs that pool's dynamic fee.
121
+ - **Robinhood Chain: a Pons coin's own Uniswap v4 pools elsewhere, and plain v4 memecoins, are traded too.**
122
+ - The stream already read every PoolManager log and kept only the Pons pools'. In one hour on 2026-10-23, 1,480 v4
123
+ pools traded 40,960 times; the Pons pools made 3,375 of those swaps. 85 hookless pools pairing a coin with ETH, WETH or
124
+ USDG cleared the bot's floors, with 17,051 swaps.
125
+ - A pool is looked at once it trades: its last half hour must clear the RH book's floors (reserve and 15-minute
126
+ volume, in ETH). Of 1.07 million v4 pools on the chain, only those few hundred a day are registered.
127
+ - A new pool qualifies when it pairs a coin with ETH, WETH or USDG (`RH_V4_QUOTES`) and has no hook. A graduated Pons
128
+ coin's other pool may use any priced quote.
129
+ - Hooked pools stay out unless named in `RH_V4_HOOKS`. In a hookless pool the Swap event's amounts equal the
130
+ PoolManager's token flows exactly (230 swaps measured). The dynamic-fee hook of ORBIO's and ZZZ's other USDG pools
131
+ (`0x08e5…a080`) moved up to 6 times more than its Swap events show. The Pons coins' main outside pools are hookless,
132
+ with fees of 0.8–3 %.
133
+ - A plain coin's contract is checked first, busiest coins first, one a minute (`RH_V4_CHECKS_PER_SLOT`). It is refused
134
+ when its owner or an admin role can mint, upgrade, blacklist, pause, set taxes or hooks, or switch trading; when it
135
+ has a built-in tax; when it is not 18 decimals; and when it is a major, stablecoin or tokenized stock. On 15 known
136
+ coins the check matched a manual review.
137
+ - Its creator is the sender of its first pool's initialization, with the same history inputs as a Pons deployer. Its
138
+ age counts from that pool. What only a Pons launch records reads as unknown.
139
+ - Nothing of this runs in front of a minute: the stream keeps the pass's logs after the minute is written, and the rest
140
+ runs mid-minute once that minute is written and no live order waits on either chain. Only the contract checks read
141
+ the network: the public RPC, once per read, never after 40 s into the minute.
142
+ - A pool goes live with its coin's meta in one commit, between two passes, so the stream indexes every swap from the
143
+ next block on. A plain coin trades only after the engine has watched it for 3 hours, the longest feature window.
144
+ - `fly-trader rh-v4-backfill` (its own process) imports every pool's key since block 0, so pools older than the
145
+ console's watch can be found. It then backfills each live pool's history before it went live.
146
+ - A coin with several live pools takes each minute from the one with the most swaps, as on Solana. A non-Pons pool's
147
+ swaps carry its LP fee. WETH counts as ETH.
148
+
149
+ ### The selector
150
+
151
+ - **A strategy's win filter chooses only its cut-off, at the strategy's own line, and must beat having no filter.**
152
+ - Before, the filter searched the line too. In selector #154 it moved `ev`'s line from 0.038 to 0.115, which `ev`'s live
153
+ scores never reached, so `ev` stayed deployed but never traded.
154
+ - **The book keeps the passed strategies, and the combination rule, that together do best on the selection half.**
155
+ - Every subset of the strategies that passed is scored as one book under each rule. A strategy the book does better
156
+ without is dropped in the open, as its own "combination" row with the numbers.
157
+ - The combination step no longer says a rule "made more" when no book cleared the bars.
158
+
159
+ ### Training
160
+
161
+ - **A corpus rebuild never retires a model.**
162
+ - The corpus build versions (`mature.AGG_VERSION`, `rh/corpus.RH_AGG_VERSION`) say when the corpus files are rebuilt,
163
+ not what a model's inputs, labels or costs mean. They are no longer part of a model's definitions.
164
+ - Before, raising one retired every selector and fly and stopped all trading until a retrain and replay, even for data
165
+ already in the corpus. Models, flies and replay verdicts that still carry the old keys stay current.
166
+ - A changed or missing definition still retires a model, as before.
167
+ - **A rebuild of more than three days runs in a child process at the lowest CPU priority.**
168
+ - Re-featuring the whole archive takes about 5 hours (~100 s a day). It no longer shares the console's interpreter with
169
+ the live minute pipeline. A day's routine build stays in the console, as before.
170
+ - **The wallet skill counts SOL-quoted legs only.** A leg quoted in another token counted its amount as SOL.
171
+ - **The fly's training writes a checkpoint after every epoch, and resumes from it.**
172
+ - Before, a bootstrap (10–20 hours) kept its progress in memory only. A crash or a stop lost every finished epoch,
173
+ and training could not go on past its epoch cap.
174
+ - Now each epoch saves the weights, the optimiser, the history, the best epoch so far and the random state. A stopped
175
+ or crashed bootstrap resumes after its last finished epoch, and a finished one can train on past the cap it ran under.
176
+ - A checkpoint written for other training data is set aside, never resumed. The live bootstrap deletes its checkpoint
177
+ once the fly is saved.
178
+ - **A new fly may race once its calibration week made money over 30 trades, the race's own minimum (was 100).**
179
+ - The wallet trades the selector's decisions until the fly wins its race, so the calibration week need not prove more
180
+ than the race will. A quiet week (2026-10-15..21) gave the new fly 88 trades at +2.96%, which the old bar refused.
181
+ - A losing calibration week still never deploys a fly. The Kalshi fly keeps 100.
182
+
183
+ ### The console
184
+
185
+ - **The fly decision inspector shows the token's price and volume in the minute the fly last scored it.** Each has a
186
+ green or red arrow for its move over the five minutes before: the price against the close five minutes earlier, the
187
+ volume against the five minutes' average. Both are plain decimals, never scientific notation.
188
+ - **The Overview's open positions table fits its card without scrolling sideways, from 1200 px wide up.**
189
+ - The card takes seven twelfths of the row and the wealth chart five, the other way round from before. The chart
190
+ still shows its whole history.
191
+ - The table's headers wrap onto two lines, its cells pad less, and a long token name ends in an ellipsis.
192
+
193
+ ### Platform
194
+
195
+ - **The archive's hour files keep every venue's SOL-quoted trades, not only pump.fun's curve and PumpSwap.**
196
+ - Meteora DLMM and DAMM, Raydium, Orca and other launchpads' curves add about a third to the rows (~90 MB an hour).
197
+ The lifecycle events keep the quote mint. Both files are written atomically and the trades file says what it keeps.
198
+ - `fly-trader refetch-archive` downloads the older hours again in its own process, 2 at a time (`REFETCH_PARALLEL`).
199
+ It starts no download while the live stream is more than 15 s behind (`REFETCH_MAX_LAG_S`), leaves the newest 6 hours
200
+ to the console, and rebuilds each finished day itself. About 4,500 hours, roughly 2.7 TB of downloads; run it under
201
+ `nice -n 19` only after the console runs this code. Progress: `ui_settings['replay_refetch_status']`.
202
+ - A day is rebuilt whenever one of its hour files is newer than its aggregate.
203
+ - **The stream relay forwards pump.fun graduates without the "pump" suffix and every SOL-quoted DLMM trade.**
204
+ - **A stream relay is ready, but switched off.**
205
+ - `stream_relay/` holds PumpAPI's stream on a server near its source. It forwards only the events and fields the
206
+ minutes use: about 33 KB/s at the busiest minute, instead of the whole stream.
207
+ - It serves them over TLS with a token, and resumes without loss after a dropped connection.
208
+ - With `SOL_FEED=relay` it becomes the minutes' clock, and this machine's own PumpAPI connection stays as its backup.
209
+ - Setup is in `stream_relay/README.md`. `fly-trader relay-check` tests a relay without switching anything.
210
+ - New settings: `SOL_FEED` (default `pumpapi`), `STREAM_RELAY_URL`, `STREAM_RELAY_TOKEN` and `STREAM_RELAY_CA_FILE`.
211
+ - **A Helius decoder is built, but switched off** (`SOL_FEED=helius`).
212
+ - It turns pump.fun and PumpSwap transactions into PumpAPI's events exactly as the archive has them: 312 of 312 events
213
+ per sampled slot, and trades, creates and migrations field for field.
214
+ - Helius's websockets deliver only about 1 MB/s in all to this machine, and drop trades when the market is busy.
215
+
216
+ ### Tools
217
+
218
+ - **`tools/make_seed.py` judges the stored replay verdict as the fly session and `apply-release` do,** with the corpus
219
+ build versions set aside (`selector.defs_match`). It compared the verdict's definitions exactly, so the corpus rebuild
220
+ that retires no model still blocked a release.
221
+ - **`tools/latency_probe.py` and `tools/latency_report.py` measure the minute pipeline from outside the console.**
222
+ - They sample, four times a second, when the stream publishes each minute, what SQL the console is running, when each
223
+ book's status lands, the console's CPU and every live order. The report turns that into per-minute timelines.
224
+ - They're read-only and need no restart. Run them before and after a change to the minute pipeline.
225
+ - The 2026-10-19 baseline of the old code is in `logs/latency_baseline_20261019/`. A minute was ready a median 6.4 s
226
+ after its end (p90 16.9 s). Almost all of that was the wait for the stream to publish the minute; everything after
227
+ publishing took 0.7 s.
228
+ - **`tools/strategy_study/` re-checks the trading strategy against fresh data without saving a model.**
229
+ - It runs the trainer's own capitulation fit and judges it on the trainer's selection half, evaluation half and holdout.
230
+ - Then it compares the live selector's decisions on days after its training, sells the same trades after every hold
231
+ from 10 to 480 minutes, and tests floors on liquidity, volume, activity, age and wash share.
232
+ - It is safe to run beside live trading. The dataset is built in a separate process and read from disk, and a guard
233
+ stops the run if the live console's minutes slow down or its memory is squeezed.
234
+ - The 2026-10-19 run kept selector #154's strategy and 240-minute hold. #154 made +8.6% per Solana trade on the 16 days
235
+ after its training, against +8.9% in its backtest. Holds from 180 to 480 minutes did about equally well, and no
236
+ floor beat the noise.
237
+
238
  ## 2026-10-18, release 9: The Overview page charts each chain's setups, and the money's book comes first
239
 
240
  Published to Hugging Face as signed release 9. Fly #157 and selector #154 are unchanged, and so are the definitions they
console/src/components/DataTable.tsx CHANGED
@@ -32,9 +32,10 @@ function pages(cur: number, total: number): (number | '…')[] {
32
  return out
33
  }
34
 
35
- export function DataTable<R extends Record<string, unknown>>({ rows, cols, height = 560, filename = 'table', onRowClick, empty = 'No rows.', search = true, initialSort, pageSize }: {
 
36
  rows: R[]; cols: Col<R>[]; height?: number; filename?: string; onRowClick?: (r: R) => void; empty?: ReactNode; search?: boolean
37
- initialSort?: { key: string; desc?: boolean }; pageSize?: number
38
  }) {
39
  const fb = useFeedback()
40
  const [sort, setSort] = useState<{ key: string; desc: boolean } | null>(initialSort ? { key: initialSort.key, desc: !!initialSort.desc } : null)
@@ -97,7 +98,7 @@ export function DataTable<R extends Record<string, unknown>>({ rows, cols, heigh
97
  {search && <label className="dt-search">Search:<input className="form-control form-control-sm" value={filter} onChange={(e) => { setFilter(e.target.value); setPage(0) }} /></label>}
98
  </div>
99
  <div className="ftable-wrap" style={{ maxHeight: height }}>
100
- <table className="ftable">
101
  <thead>
102
  <tr>
103
  {cols.map((c) => (
 
32
  return out
33
  }
34
 
35
+ /** ``compact`` fits a narrow card: header labels wrap onto two lines and the cells pad less. */
36
+ export function DataTable<R extends Record<string, unknown>>({ rows, cols, height = 560, filename = 'table', onRowClick, empty = 'No rows.', search = true, initialSort, pageSize, compact = false }: {
37
  rows: R[]; cols: Col<R>[]; height?: number; filename?: string; onRowClick?: (r: R) => void; empty?: ReactNode; search?: boolean
38
+ initialSort?: { key: string; desc?: boolean }; pageSize?: number; compact?: boolean
39
  }) {
40
  const fb = useFeedback()
41
  const [sort, setSort] = useState<{ key: string; desc: boolean } | null>(initialSort ? { key: initialSort.key, desc: !!initialSort.desc } : null)
 
98
  {search && <label className="dt-search">Search:<input className="form-control form-control-sm" value={filter} onChange={(e) => { setFilter(e.target.value); setPage(0) }} /></label>}
99
  </div>
100
  <div className="ftable-wrap" style={{ maxHeight: height }}>
101
+ <table className={`ftable ${compact ? 'compact' : ''}`}>
102
  <thead>
103
  <tr>
104
  {cols.map((c) => (
console/src/fly/Inspector.tsx CHANGED
@@ -9,7 +9,7 @@ import { useFeedback } from '../components/feedback'
9
  import { Callout, Caption, Card, Empty, Expander, Icon, KVList, Loaded, Md, Metric, Metrics, ProgressList, RowGroup, Segmented, Toggle, Widget, WidgetGrid,
10
  type WidgetColor } from '../components/ui'
11
  import { useStored, useTopic } from '../lib/hooks'
12
- import { ago, amount, dt, int, num, pct, shortAddr, tone, usd } from '../lib/format'
13
 
14
  type Scored = { ts: string; strategy: string; side?: string; score: number | null; frozen_score: number | null; line: number | null; label: number | null; state: string; teacher_allow?: boolean | null }
15
  type Latest = Scored & { hold_min?: number | null; frozen_line?: number | null; bootstrap_id?: number | null }
@@ -27,9 +27,10 @@ type Fill = { id: number; ts: string; book: string; side: string; amount: number
27
  type Pathway = { kc: number; mbon: number; delta: number; ratio: number; strategy: string; valence: string; kc_type: string | null; mbon_type: string | null }
28
  type Pathways = { mode: string; items: Pathway[]; note: string | null; changed: number; reference: string | null; bootstrap: number | null; running: boolean
29
  n_pairs?: number; drift: Record<string, number> }
 
30
  type TokenData = {
31
  chain: string; chain_name: string; unit: string; mint: string; label: string; symbol: string | null; name: string | null; graduated_at: string | null; creator: string | null
32
- explorer: string; books: { selector: string; fly: string; live: string }; fly_score: string; teacher_gate: boolean; learned_veto: number
33
  series: Scored[]; stats: Stat[]; total: Stat | null; latest: Latest[]; explain: string; decisions: Decision[]; positions: Position[]; fills: Fill[]
34
  pnl: { closed: number; realized: number; open: number; wins: number }; pathways: Pathways
35
  }
@@ -170,6 +171,23 @@ function DecisionTable({ rows, unit, kalshi }: { rows: Decision[]; unit: string;
170
  return <DataTable rows={rows} cols={cols} filename="inspector-decisions" empty={kalshi ? 'No decisions on the Kalshi books.' : "No decisions on this chain's books."} />
171
  }
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  /** What the fly would do with the newest scored minute, as colour widgets (states, not text). */
174
  function VerdictWidgets({ d }: { d: TokenData }) {
175
  const r = d.latest[0]
@@ -229,6 +247,7 @@ function TokenBody({ chain, mint, mode, setMode }: { chain: string; mint: string
229
  <a className="btn btn-sm btn-light" href={d.explorer} target="_blank" rel="noreferrer"><Icon name="external" />{chain === 'sol' ? 'Solscan' : 'Explorer'}</a>
230
  </span>
231
  </div>
 
232
  <VerdictWidgets d={d} />
233
  <div className="mt-3" />
234
  <WidgetGrid min={320}>
 
9
  import { Callout, Caption, Card, Empty, Expander, Icon, KVList, Loaded, Md, Metric, Metrics, ProgressList, RowGroup, Segmented, Toggle, Widget, WidgetGrid,
10
  type WidgetColor } from '../components/ui'
11
  import { useStored, useTopic } from '../lib/hooks'
12
+ import { ago, amount, dt, hm, int, num, pct, plain, shortAddr, tone, usd } from '../lib/format'
13
 
14
  type Scored = { ts: string; strategy: string; side?: string; score: number | null; frozen_score: number | null; line: number | null; label: number | null; state: string; teacher_allow?: boolean | null }
15
  type Latest = Scored & { hold_min?: number | null; frozen_line?: number | null; bootstrap_id?: number | null }
 
27
  type Pathway = { kc: number; mbon: number; delta: number; ratio: number; strategy: string; valence: string; kc_type: string | null; mbon_type: string | null }
28
  type Pathways = { mode: string; items: Pathway[]; note: string | null; changed: number; reference: string | null; bootstrap: number | null; running: boolean
29
  n_pairs?: number; drift: Record<string, number> }
30
+ type Minute = { ts: string; price: number | null; volume: number | null; price_5m: number | null; volume_5m: number }
31
  type TokenData = {
32
  chain: string; chain_name: string; unit: string; mint: string; label: string; symbol: string | null; name: string | null; graduated_at: string | null; creator: string | null
33
+ explorer: string; books: { selector: string; fly: string; live: string }; minute?: Minute | null; fly_score: string; teacher_gate: boolean; learned_veto: number
34
  series: Scored[]; stats: Stat[]; total: Stat | null; latest: Latest[]; explain: string; decisions: Decision[]; positions: Position[]; fills: Fill[]
35
  pnl: { closed: number; realized: number; open: number; wins: number }; pathways: Pathways
36
  }
 
171
  return <DataTable rows={rows} cols={cols} filename="inspector-decisions" empty={kalshi ? 'No decisions on the Kalshi books.' : "No decisions on this chain's books."} />
172
  }
173
 
174
+ /** The token's price and volume in the newest scored minute, each with an up/down arrow against the five minutes before. */
175
+ function MinuteWidgets({ m, unit, scored }: { m: Minute; unit: string; scored: boolean }) {
176
+ const dp = m.price !== null && m.price_5m ? m.price / m.price_5m - 1 : null
177
+ const dv = m.volume !== null && m.volume_5m > 0 ? m.volume / m.volume_5m - 1 : null
178
+ const which = scored ? 'the minute the fly last scored it' : 'its newest traded minute (the fly has not scored it)'
179
+ return (
180
+ <Metrics min={320}>
181
+ <Metric compact label={`Price · ${hm(m.ts)}`} value={m.price === null ? '—' : `${plain(m.price)} ${unit}`}
182
+ delta={dp === null ? 'no price five minutes before' : `${pct(dp, 1)} over 5 min`} tone={dp ? 'normal' : 'off'} icon="candlestick"
183
+ help={`The close of ${which}, ${dt(m.ts)} UTC, in ${unit} per token; the arrow is its change from the close five minutes earlier.`} />
184
+ <Metric compact label={`Volume · ${hm(m.ts)}`} value={m.volume === null ? '—' : `${plain(m.volume)} ${unit}`}
185
+ delta={dv !== null ? `${pct(dv, 0)} vs 5-min average` : m.volume ? 'up from none in the five minutes before' : 'no volume'} tone={dv || (dv === null && m.volume) ? 'normal' : 'off'}
186
+ icon="chart" help={`Buys plus sells in ${which}, ${dt(m.ts)} UTC; the arrow compares it with the mean volume of the five minutes before (a minute without trades counts as none).`} />
187
+ </Metrics>
188
+ )
189
+ }
190
+
191
  /** What the fly would do with the newest scored minute, as colour widgets (states, not text). */
192
  function VerdictWidgets({ d }: { d: TokenData }) {
193
  const r = d.latest[0]
 
247
  <a className="btn btn-sm btn-light" href={d.explorer} target="_blank" rel="noreferrer"><Icon name="external" />{chain === 'sol' ? 'Solscan' : 'Explorer'}</a>
248
  </span>
249
  </div>
250
+ {d.minute && <><MinuteWidgets m={d.minute} unit={u} scored={d.latest.length > 0} /><div className="mt-3" /></>}
251
  <VerdictWidgets d={d} />
252
  <div className="mt-3" />
253
  <WidgetGrid min={320}>
console/src/lib/format.ts CHANGED
@@ -50,6 +50,12 @@ export function sig(x: Num, digits = 4, signed = false): string {
50
  return (signed && v >= 0 ? '+' : '') + s
51
  }
52
 
 
 
 
 
 
 
53
  export function sci(x: Num, digits = 3): string {
54
  const v = n(x)
55
  return v === null ? '—' : v.toExponential(digits)
 
50
  return (signed && v >= 0 ? '+' : '') + s
51
  }
52
 
53
+ /** Significant digits as a plain decimal, never scientific notation: 3.079e-7 → "0.0000003079". */
54
+ export function plain(x: Num, digits = 4): string {
55
+ const v = n(x)
56
+ return v === null ? '—' : v.toLocaleString('en-US', { maximumSignificantDigits: digits })
57
+ }
58
+
59
  export function sci(x: Num, digits = 3): string {
60
  const v = n(x)
61
  return v === null ? '—' : v.toExponential(digits)
console/src/pages/Overview.tsx CHANGED
@@ -6,6 +6,7 @@ import { BrainPanel } from '../brain'
6
  import { PageHead } from '../components/ChainPicker'
7
  import { BarChart, LineChart, RadialGauge, type Series } from '../components/Charts'
8
  import { DataTable, type Col } from '../components/DataTable'
 
9
  import { HourWheel, hourColor, type HourBucket } from '../components/HourWheel'
10
  import { Callout, Caption, Card, Loaded, Md, Metric, Metrics, ProgressList, RowGroup, Segmented, Widget, WidgetGrid } from '../components/ui'
11
  import { useApp, type Market } from '../context'
@@ -54,8 +55,9 @@ type Minute = {
54
  }
55
  type RaceBook = { book: string; title: string; pnl: number | null; n: number; daily: number[] }
56
  type Race = { hidden: boolean; unit: string; handover?: boolean; success?: string | null; caption?: string | null; metrics?: RaceBook[]; series?: Series[]; days?: string[] }
57
- type Position = { mint: string; token: string; strategy: string; held_min: number; exits_in_min: number; cost: number; ret: number | null }
58
- type Book = { unit: string; history: [string, number][]; positions: Position[]; live?: boolean; book?: string; title?: string; sub?: string }
 
59
 
60
  /** Python's %g: significant digits, trailing zeros dropped, an exponent outside 1e-4..1e±digits. */
61
  function g(x: number | null | undefined, digits = 6): string {
@@ -187,31 +189,45 @@ function RacePanel() {
187
  )
188
  }
189
 
190
- /** The selector book: its whole wealth history, and the open positions. */
191
  function BookPanel() {
192
- const { chain } = useApp()
193
  const b = useTopic<Book>('overview.book', { chain })
194
- const cols = (u: string): Col<Position>[] => [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  { key: 'token', label: 'token', render: (r) => <TokenButton chain={chain} mint={r.mint} token={r.token} /> },
196
  { key: 'strategy', label: 'strategy' },
197
  { key: 'held_min', label: 'held (min)', num: true, render: (r) => num(r.held_min, 0) },
198
  { key: 'exits_in_min', label: 'exits in (min)', num: true, render: (r) => num(r.exits_in_min, 0) },
199
- { key: 'cost', label: `cost (${u})`, num: true, render: (r) => g(r.cost, 4) },
200
  { key: 'ret', label: 'return (gross)', num: true, render: (r) => (r.ret === null ? null : <span className={`text-${tone(r.ret)}`}>{pctPts(r.ret, 2, true)}</span>) },
201
  ]
202
  return (
203
  <div className="row gx-4">
204
- <div className="col-12 col-xl-7 d-flex">
205
  <Card title={b.data?.title ?? 'Paper wealth'} icon="wealth" sub={b.data?.sub ?? 'the selector book since it started'} className="flex-fill">
206
  <Loaded state={b} height={260}>
207
  {(d) => d.history.length ? <LineChart series={[{ name: 'wealth', data: d.history }]} height={280} yFormat={(v) => `${v.toFixed(2)} ${d.unit}`} /> : <Caption>No marks yet.</Caption>}
208
  </Loaded>
209
  </Card>
210
  </div>
211
- <div className="col-12 col-xl-5 d-flex">
212
  <Card title="Open positions" icon="open" className="flex-fill">
213
  <Loaded state={b}>
214
- {(d) => d.positions.length ? <DataTable rows={d.positions} cols={cols(d.unit)} pageSize={10} filename="open-positions" /> : <Caption>None open.</Caption>}
215
  </Loaded>
216
  </Card>
217
  </div>
 
6
  import { PageHead } from '../components/ChainPicker'
7
  import { BarChart, LineChart, RadialGauge, type Series } from '../components/Charts'
8
  import { DataTable, type Col } from '../components/DataTable'
9
+ import { ActionButton } from '../components/feedback'
10
  import { HourWheel, hourColor, type HourBucket } from '../components/HourWheel'
11
  import { Callout, Caption, Card, Loaded, Md, Metric, Metrics, ProgressList, RowGroup, Segmented, Widget, WidgetGrid } from '../components/ui'
12
  import { useApp, type Market } from '../context'
 
55
  }
56
  type RaceBook = { book: string; title: string; pnl: number | null; n: number; daily: number[] }
57
  type Race = { hidden: boolean; unit: string; handover?: boolean; success?: string | null; caption?: string | null; metrics?: RaceBook[]; series?: Series[]; days?: string[] }
58
+ type Position = { id: number; mint: string; token: string; strategy: string; held_min: number; exits_in_min: number; cost: number; ret: number | null }
59
+ type Book = { unit: string; history: [string, number][]; positions: Position[]; live?: boolean; book?: string; title?: string; sub?: string; observer?: boolean
60
+ sell?: { from_pct: number; to_pct: number } }
61
 
62
  /** Python's %g: significant digits, trailing zeros dropped, an exponent outside 1e-4..1e±digits. */
63
  function g(x: number | null | undefined, digits = 6): string {
 
189
  )
190
  }
191
 
192
+ /** The money's book: its whole wealth history, and the open positions (a live one can be sold now, after a confirmation). */
193
  function BookPanel() {
194
+ const { chain, market } = useApp()
195
  const b = useTopic<Book>('overview.book', { chain })
196
+ const sellCol = (d: Book): Col<Position> => ({
197
+ key: 'sell', label: '', value: () => '',
198
+ render: (r) => (
199
+ <ActionButton label="Sell now" icon="payments" name="overview.sell_now" body={{ chain, position_id: r.id }} variant="outline-danger" disabled={d.observer}
200
+ help={d.observer ? 'This console only observes: sell from the console that hosts the workers.' : undefined}
201
+ confirm={{
202
+ title: `Sell ${r.token} now?`, confirmLabel: 'Sell now', danger: true,
203
+ body: <>Sells all of it from the {market?.name ?? chain} wallet now, at market: slippage from {d.sell?.from_pct}% stepping up to at most {d.sell?.to_pct}%.
204
+ The result is booked on the live book. Cost {g(r.cost, 4)} {d.unit}{r.ret === null ? '' : `, ${pctPts(r.ret, 2, true)} gross at the last mark`}.
205
+ The fly's paper book keeps its own position. A sale cannot be undone.</>,
206
+ }} />
207
+ ),
208
+ })
209
+ const cols = (d: Book): Col<Position>[] => [
210
+ ...(d.live && d.sell ? [sellCol(d)] : []),
211
  { key: 'token', label: 'token', render: (r) => <TokenButton chain={chain} mint={r.mint} token={r.token} /> },
212
  { key: 'strategy', label: 'strategy' },
213
  { key: 'held_min', label: 'held (min)', num: true, render: (r) => num(r.held_min, 0) },
214
  { key: 'exits_in_min', label: 'exits in (min)', num: true, render: (r) => num(r.exits_in_min, 0) },
215
+ { key: 'cost', label: `cost (${d.unit})`, num: true, render: (r) => g(r.cost, 4) },
216
  { key: 'ret', label: 'return (gross)', num: true, render: (r) => (r.ret === null ? null : <span className={`text-${tone(r.ret)}`}>{pctPts(r.ret, 2, true)}</span>) },
217
  ]
218
  return (
219
  <div className="row gx-4">
220
+ <div className="col-12 col-xl-5 d-flex">
221
  <Card title={b.data?.title ?? 'Paper wealth'} icon="wealth" sub={b.data?.sub ?? 'the selector book since it started'} className="flex-fill">
222
  <Loaded state={b} height={260}>
223
  {(d) => d.history.length ? <LineChart series={[{ name: 'wealth', data: d.history }]} height={280} yFormat={(v) => `${v.toFixed(2)} ${d.unit}`} /> : <Caption>No marks yet.</Caption>}
224
  </Loaded>
225
  </Card>
226
  </div>
227
+ <div className="col-12 col-xl-7 d-flex">
228
  <Card title="Open positions" icon="open" className="flex-fill">
229
  <Loaded state={b}>
230
+ {(d) => d.positions.length ? <DataTable rows={d.positions} cols={cols(d)} pageSize={10} filename="open-positions" compact /> : <Caption>None open.</Caption>}
231
  </Loaded>
232
  </Card>
233
  </div>
console/src/theme/brand.css CHANGED
@@ -267,6 +267,9 @@ pre.log { background: rgb(0 0 0 / 35%); border: 1px solid var(--fly-line); borde
267
  .ftable tbody tr.clickable { cursor: pointer; }
268
  .ftable th.num, .ftable td.num { text-align: right; font-variant-numeric: tabular-nums; }
269
  .ftable td.wrap { white-space: normal; min-width: 220px; }
 
 
 
270
  .ftable .pos { color: var(--fly-lime); }
271
  .ftable .neg { color: #ff8da1; }
272
  .dt-bottom { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.75rem; font-size: 13.5px; }
 
267
  .ftable tbody tr.clickable { cursor: pointer; }
268
  .ftable th.num, .ftable td.num { text-align: right; font-variant-numeric: tabular-nums; }
269
  .ftable td.wrap { white-space: normal; min-width: 220px; }
270
+ .ftable.compact thead th { white-space: normal; padding: 0.5rem 0.55rem; line-height: 1.3; vertical-align: bottom; }
271
+ .ftable.compact td { padding: 0.45rem 0.55rem; }
272
+ .ftable.compact td .link-btn { display: inline-block; max-width: 10rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
273
  .ftable .pos { color: var(--fly-lime); }
274
  .ftable .neg { color: #ff8da1; }
275
  .dt-bottom { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.75rem; font-size: 13.5px; }
fly_trader/__main__.py CHANGED
@@ -117,11 +117,37 @@ def _cmd_pumpstream(args):
117
  pumpstream.main()
118
 
119
 
 
 
 
 
 
 
120
  def _cmd_build_corpus_features(args):
121
  from .train import corpus_features
122
  corpus_features.main()
123
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def _cmd_refetch_pool_ids(args):
126
  from .ingest import replay_pull
127
  print(f"{replay_pull.refetch_missing_pool_id()} archive hours marked for re-download (run the replay worker or fly-trader replay-pull)")
@@ -329,10 +355,20 @@ def build_parser() -> argparse.ArgumentParser:
329
  fw = sub.add_parser("fit-wallet-skill", help="fit the wallet-skill tables' hold and lookback by the selector's objective (one-off, many hours, resumable)")
330
  fw.add_argument("--days", type=int, default=None, help="most recent days only (default: the whole corpus)"); fw.set_defaults(fn=_cmd_fit_wallet_skill)
331
  sub.add_parser("pumpstream").set_defaults(fn=_cmd_pumpstream)
 
 
332
  sub.add_parser("build-corpus-features").set_defaults(fn=_cmd_build_corpus_features)
333
  sub.add_parser("assemble-replay").set_defaults(fn=_cmd_assemble_replay)
334
  sub.add_parser("build-corpus-meta").set_defaults(fn=_cmd_build_corpus_meta)
335
  sub.add_parser("build-mature").set_defaults(fn=_cmd_build_mature)
 
 
 
 
 
 
 
 
336
  sub.add_parser("selector-eval").set_defaults(fn=_cmd_selector_eval)
337
  ts_ = sub.add_parser("train-selector"); ts_.add_argument("--days", type=int, default=None, help="most recent days only (default: the whole corpus)")
338
  ts_.set_defaults(fn=_cmd_train_selector)
 
117
  pumpstream.main()
118
 
119
 
120
+ def _cmd_relay_check(args):
121
+ import json
122
+ from .ingest import relay_feed
123
+ print(json.dumps(relay_feed.check(args.seconds), indent=1))
124
+
125
+
126
  def _cmd_build_corpus_features(args):
127
  from .train import corpus_features
128
  corpus_features.main()
129
 
130
 
131
+ def _cmd_rh_build_corpus(args):
132
+ from .logging_setup import setup
133
+ from .rh import corpus
134
+ setup("rh_corpus")
135
+ print(f"rh corpus: {corpus.run_locked(max_days=args.days)}")
136
+
137
+
138
+ def _cmd_rh_v4_backfill(args):
139
+ from .rh import v4_universe
140
+ out = v4_universe.backfill_main(keys=not args.no_keys, history=not args.no_history)
141
+ print(f"rh v4 backfill: {out.get('keys', 0)} pool keys imported; history of {out.get('pools', 0)} live pools in {out.get('steps', 0)} steps; "
142
+ f"{out.get('outcomes', 0)} first-hour outcomes")
143
+
144
+
145
+ def _cmd_refetch_archive(args):
146
+ from .ingest import replay_pull
147
+ out = replay_pull.refetch_main()
148
+ print(f"archive refetch: {out['done']} of {out['planned']} hours rewritten, {out['failed']} failed")
149
+
150
+
151
  def _cmd_refetch_pool_ids(args):
152
  from .ingest import replay_pull
153
  print(f"{replay_pull.refetch_missing_pool_id()} archive hours marked for re-download (run the replay worker or fly-trader replay-pull)")
 
355
  fw = sub.add_parser("fit-wallet-skill", help="fit the wallet-skill tables' hold and lookback by the selector's objective (one-off, many hours, resumable)")
356
  fw.add_argument("--days", type=int, default=None, help="most recent days only (default: the whole corpus)"); fw.set_defaults(fn=_cmd_fit_wallet_skill)
357
  sub.add_parser("pumpstream").set_defaults(fn=_cmd_pumpstream)
358
+ rc = sub.add_parser("relay-check", help="connect to the stream relay (STREAM_RELAY_URL) for a while and report what arrives; switches nothing")
359
+ rc.add_argument("--seconds", type=float, default=30.0); rc.set_defaults(fn=_cmd_relay_check)
360
  sub.add_parser("build-corpus-features").set_defaults(fn=_cmd_build_corpus_features)
361
  sub.add_parser("assemble-replay").set_defaults(fn=_cmd_assemble_replay)
362
  sub.add_parser("build-corpus-meta").set_defaults(fn=_cmd_build_corpus_meta)
363
  sub.add_parser("build-mature").set_defaults(fn=_cmd_build_mature)
364
+ p_rc = sub.add_parser("rh-build-corpus", help="build the due Robinhood Chain corpus days (the stream starts it as a child process)")
365
+ p_rc.add_argument("--days", type=int, default=31, help="days at most per run (each run reads every minute's fee once)")
366
+ p_rc.set_defaults(fn=_cmd_rh_build_corpus)
367
+ p_v4 = sub.add_parser("rh-v4-backfill", help="import every Robinhood Chain v4 pool key, then backfill the live v4 pools' history (run apart from the console, under nice)")
368
+ p_v4.add_argument("--no-keys", action="store_true", help="skip the pool-key import")
369
+ p_v4.add_argument("--no-history", action="store_true", help="skip the live pools' history")
370
+ p_v4.set_defaults(fn=_cmd_rh_v4_backfill)
371
+ sub.add_parser("refetch-archive", help="download older archive hours again to keep every venue's SOL legs (run apart from the console, under nice)").set_defaults(fn=_cmd_refetch_archive)
372
  sub.add_parser("selector-eval").set_defaults(fn=_cmd_selector_eval)
373
  ts_ = sub.add_parser("train-selector"); ts_.add_argument("--days", type=int, default=None, help="most recent days only (default: the whole corpus)")
374
  ts_.set_defaults(fn=_cmd_train_selector)
fly_trader/agent/fly_live.py CHANGED
@@ -82,6 +82,7 @@ class LiveMirror:
82
  self.mark_lock = threading.Lock() # the worker's thread and the engine's both mark
83
  self.last_mark: dict | None = None
84
  self.wallet_sol: float | None = None
 
85
  self.worker.on_done = self._after_order
86
 
87
  def _decision(self, conn, m1, run_id, beat_id, p, reason: str, forced: bool) -> int:
@@ -119,9 +120,14 @@ class LiveMirror:
119
  slippage_bps=config.SLIPPAGE_ENTRY_BPS, max_slippage_bps=config.MAX_SLIPPAGE_ENTRY_BPS, decimals=int(e["info"]["decimals"] or 6),
120
  size_sol=size, hold_s=e.get("hold_s"), strategy=e.get("strategy"), ref_price=e.get("ref_price"), retry=retry))
121
 
122
- def _sell(self, p: dict, did: int, slip: int) -> bool:
 
 
 
 
123
  return self.worker.submit(ExecRequest(decision_id=did, mint=p["mint"], pool=p["pool"], side="sell", amount_in=int(p["qty"]), slippage_bps=slip,
124
- max_slippage_bps=config.SLIPPAGE_FORCED_BPS, decimals=int(p.get("decimals") or 6), position_id=int(p["id"])))
 
125
 
126
  def _held(self, p: dict, m1: datetime) -> tuple[float, float]:
127
  """(seconds held, its hold): from its entry decision's minute (paper's clock), not from its fill. A position that
@@ -162,11 +168,39 @@ class LiveMirror:
162
  record_event("error", "fly_live", f"kill switch: liquidating {n_liq} open live position(s)", {"positions": n_liq})
163
  return {"exited": n_exit, "liquidated": n_liq}
164
 
165
- def minute(self, ctx, *, run_id: str, beat_id: int, entries: list, line: float, table: list | None) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  """The minute's entries, sent the moment the fly has decided (agent/fly_session.FlyBook.on_minute): the worker's
167
  results first (a failed buy is kept for another try), the minute's sales if the clock has not sent them, then the
168
- retries and the paper entries. The wallet is read only when there is something to buy; the wealth mark waits for
169
- ``idle``."""
 
170
  conn, m1 = ctx.conn, ctx.m1
171
  drained = self.worker.drain()
172
  for r in drained:
@@ -181,7 +215,7 @@ class LiveMirror:
181
  blocked = "kill switch" if c.kill_switch else "circuit tripped" if rails.breaker_blocks(c) else "paused" if c.entries_paused else None
182
  n_enter = n_retry = 0; skipped = []
183
  if self.failed or entries:
184
- sol_free = self.broker.rpc.get_balance(self.broker.pubkey) / config.LAMPORTS_PER_SOL; self.wallet_sol = sol_free
185
  reserved = treasury = 0.0
186
  if config.VAULT_ENABLED: # SOL owed to vault lockers is never traded; the treasury's idle SOL is bankroll
187
  from ..vault import nav as vault_nav
@@ -195,13 +229,16 @@ class LiveMirror:
195
  if over:
196
  self.failed.pop(did); skipped.append((e["mint"], "retries exhausted"))
197
  self._blocked(conn, ctx, run_id, beat_id, e, "retries exhausted", f"{over}; last error: {(f.get('error') or '')[:200]}", tries=f["tries"]); continue
198
- rail = blocked or ("stale minute" if stale else None) or ("held" if e["mint"] in held_mints else None)
 
 
199
  size, why = (0.0, "") if rail else sizing.size_position(e["score"], e.get("threshold", line), e.get("table") or table or [], bankroll, cash,
200
  ctx.resqs.get(e["mint"]) or e["info"]["resq"], flat=config.FLY_SIZING == "flat")
201
  if rail or size <= 0:
202
- skipped.append((e["mint"], rail or why)); self._blocked(conn, ctx, run_id, beat_id, e, rail or "sizing", f"retry not sent: {rail or why}", size, retry=True); continue
 
203
  if self._buy(e, size, retry=f["tries"]):
204
- self.failed.pop(did); self.sent[did] = {**f, "tries": f["tries"] + 1}
205
  n_enter += 1; n_retry += 1; cash -= size; held_mints.add(e["mint"])
206
  for e in entries:
207
  if e["mint"] in held_mints:
@@ -295,6 +332,9 @@ class LiveMirror:
295
  except Exception:
296
  log.warning("live mark after an order failed", exc_info=True)
297
  live_first.changed()
 
 
 
298
 
299
  def mark_due(self, conn=None, force: bool = False) -> dict | None:
300
  """The waiting minute's wealth mark: wallet SOL + open positions net of exit cost at the minute's prices, and the
@@ -363,10 +403,12 @@ class LiveMirror:
363
  self.worker.busy.release()
364
 
365
  def gap(self, conn) -> dict:
366
- """Live vs its paper mirror on the same decisions; pauses entries when live trails by more than ``GAP_MAX`` per trade."""
 
367
  rows = conn.execute(f"""SELECT l.realized_sol / NULLIF(l.cost_sol, 0) AS rl, p.realized_sol / NULLIF(p.cost_sol, 0) AS rp
368
  FROM positions l JOIN positions p ON p.entry_decision_id = l.entry_decision_id AND p.book = '{MIRROR}' AND p.status = 'closed'
369
- WHERE l.book = '{BOOK}' AND l.status = 'closed' AND l.entry_decision_id IS NOT NULL ORDER BY l.closed_at DESC LIMIT %s""", (GAP_TRADES,)).fetchall()
 
370
  rl = np.array([r["rl"] for r in rows if r["rl"] is not None and r["rp"] is not None], dtype=np.float64)
371
  rp = np.array([r["rp"] for r in rows if r["rl"] is not None and r["rp"] is not None], dtype=np.float64)
372
  out = {"trades": int(len(rl)), "live_mean": float(rl.mean()) if len(rl) else None, "mirror_mean": float(rp.mean()) if len(rp) else None}
 
82
  self.mark_lock = threading.Lock() # the worker's thread and the engine's both mark
83
  self.last_mark: dict | None = None
84
  self.wallet_sol: float | None = None
85
+ self.sale_waits: dict[int, list] = {} # decision id -> [Event, result]: an operator's Sell Now waiting for its sale (``sell_now``)
86
  self.worker.on_done = self._after_order
87
 
88
  def _decision(self, conn, m1, run_id, beat_id, p, reason: str, forced: bool) -> int:
 
120
  slippage_bps=config.SLIPPAGE_ENTRY_BPS, max_slippage_bps=config.MAX_SLIPPAGE_ENTRY_BPS, decimals=int(e["info"]["decimals"] or 6),
121
  size_sol=size, hold_s=e.get("hold_s"), strategy=e.get("strategy"), ref_price=e.get("ref_price"), retry=retry))
122
 
123
+ def read_balance(self) -> int:
124
+ """The wallet's SOL (lamports); started beside the minute's scoring by live_first.prefetch."""
125
+ return int(self.broker.rpc.get_balance(self.broker.pubkey))
126
+
127
+ def _sell(self, p: dict, did: int, slip: int, forced_kind: str | None = None) -> bool:
128
  return self.worker.submit(ExecRequest(decision_id=did, mint=p["mint"], pool=p["pool"], side="sell", amount_in=int(p["qty"]), slippage_bps=slip,
129
+ max_slippage_bps=config.SLIPPAGE_FORCED_BPS, decimals=int(p.get("decimals") or 6), position_id=int(p["id"]),
130
+ forced_kind=forced_kind))
131
 
132
  def _held(self, p: dict, m1: datetime) -> tuple[float, float]:
133
  """(seconds held, its hold): from its entry decision's minute (paper's clock), not from its fill. A position that
 
168
  record_event("error", "fly_live", f"kill switch: liquidating {n_liq} open live position(s)", {"positions": n_liq})
169
  return {"exited": n_exit, "liquidated": n_liq}
170
 
171
+ def sell_now(self, conn, position_id: int) -> int:
172
+ """The operator's Sell Now (the console's open positions, agent/live_first.sell_now): one open position of this book
173
+ sold at once, at the exit's slippage stepping up to the forced one, and booked by the worker like any exit, as a
174
+ forced 'operator' exit (the live-vs-paper gap and the fly's fill pathway leave it out). Its decision id; ValueError
175
+ with the reason when nothing was sent (the caller's transaction then rolls its decision back)."""
176
+ with self.exit_lock: # the clock's boundary sales check the same in-flight set: one sale per position
177
+ p = next((p for p in ledger.open_positions(conn, BOOK) if int(p["id"]) == int(position_id)), None)
178
+ if p is None:
179
+ raise ValueError("this position is no longer open")
180
+ if p["mint"] in self.worker.pending():
181
+ raise ValueError("an order of this token is already queued or in flight")
182
+ did = self._decision(conn, datetime.now(timezone.utc), None, None, p, "sold now by the operator", True)
183
+ self.sale_waits[did] = [threading.Event(), None]
184
+ if not self._sell(p, did, config.SLIPPAGE_EXIT_BPS, "operator"):
185
+ self.sale_waits.pop(did, None)
186
+ raise ValueError("an order of this token is already queued or in flight")
187
+ return did
188
+
189
+ def sale_result(self, did: int, timeout: float):
190
+ """The worker's result of a ``sell_now`` sale once booked, waiting at most ``timeout`` s (None: not yet)."""
191
+ w = self.sale_waits.get(did)
192
+ if w is None:
193
+ return None
194
+ w[0].wait(timeout)
195
+ self.sale_waits.pop(did, None)
196
+ return w[1]
197
+
198
+ def minute(self, ctx, *, run_id: str, beat_id: int, entries: list, line: float, table: list | None, rebuy: dict | None = None) -> dict:
199
  """The minute's entries, sent the moment the fly has decided (agent/fly_session.FlyBook.on_minute): the worker's
200
  results first (a failed buy is kept for another try), the minute's sales if the clock has not sent them, then the
201
+ retries and the paper entries. A retry goes out only where this minute would buy its token again (``rebuy``,
202
+ agent/paper_trading.plan_minute), on this minute's score and against this minute's price, never its decision's.
203
+ The wallet is read only when there is something to buy; the wealth mark waits for ``idle``."""
204
  conn, m1 = ctx.conn, ctx.m1
205
  drained = self.worker.drain()
206
  for r in drained:
 
215
  blocked = "kill switch" if c.kill_switch else "circuit tripped" if rails.breaker_blocks(c) else "paused" if c.entries_paused else None
216
  n_enter = n_retry = 0; skipped = []
217
  if self.failed or entries:
218
+ sol_free = live_first.take_balance(self, ctx.m1_epoch) / config.LAMPORTS_PER_SOL; self.wallet_sol = sol_free
219
  reserved = treasury = 0.0
220
  if config.VAULT_ENABLED: # SOL owed to vault lockers is never traded; the treasury's idle SOL is bankroll
221
  from ..vault import nav as vault_nav
 
229
  if over:
230
  self.failed.pop(did); skipped.append((e["mint"], "retries exhausted"))
231
  self._blocked(conn, ctx, run_id, beat_id, e, "retries exhausted", f"{over}; last error: {(f.get('error') or '')[:200]}", tries=f["tries"]); continue
232
+ v = (rebuy or {}).get(e["mint"]) or {"why": "not scored this minute"} # this minute's verdict on buying it again
233
+ e = {**e, **{k: v[k] for k in ("score", "threshold", "table", "ref_price") if k in v}} # its score and price now, not its decision's
234
+ rail = blocked or ("stale minute" if stale else None) or ("held" if e["mint"] in held_mints else None) or (None if "ref_price" in v else "no longer a buy")
235
  size, why = (0.0, "") if rail else sizing.size_position(e["score"], e.get("threshold", line), e.get("table") or table or [], bankroll, cash,
236
  ctx.resqs.get(e["mint"]) or e["info"]["resq"], flat=config.FLY_SIZING == "flat")
237
  if rail or size <= 0:
238
+ why = v["why"] if rail == "no longer a buy" else rail or why
239
+ skipped.append((e["mint"], why)); self._blocked(conn, ctx, run_id, beat_id, e, rail or "sizing", f"retry not sent: {why}", size, retry=True); continue
240
  if self._buy(e, size, retry=f["tries"]):
241
+ self.failed.pop(did); self.sent[did] = {**f, "e": e, "tries": f["tries"] + 1}
242
  n_enter += 1; n_retry += 1; cash -= size; held_mints.add(e["mint"])
243
  for e in entries:
244
  if e["mint"] in held_mints:
 
332
  except Exception:
333
  log.warning("live mark after an order failed", exc_info=True)
334
  live_first.changed()
335
+ w = self.sale_waits.get(res.request.decision_id) if res is not None else None
336
+ if w is not None: # an operator's Sell Now waiting for this sale (``sell_now``)
337
+ w[1] = res; w[0].set()
338
 
339
  def mark_due(self, conn=None, force: bool = False) -> dict | None:
340
  """The waiting minute's wealth mark: wallet SOL + open positions net of exit cost at the minute's prices, and the
 
403
  self.worker.busy.release()
404
 
405
  def gap(self, conn) -> dict:
406
+ """Live vs its paper mirror on the same decisions; pauses entries when live trails by more than ``GAP_MAX`` per trade.
407
+ An operator's Sell Now (forced 'operator') is the operator's choice, not an execution gap: left out."""
408
  rows = conn.execute(f"""SELECT l.realized_sol / NULLIF(l.cost_sol, 0) AS rl, p.realized_sol / NULLIF(p.cost_sol, 0) AS rp
409
  FROM positions l JOIN positions p ON p.entry_decision_id = l.entry_decision_id AND p.book = '{MIRROR}' AND p.status = 'closed'
410
+ WHERE l.book = '{BOOK}' AND l.status = 'closed' AND l.entry_decision_id IS NOT NULL AND COALESCE(l.forced_exit_kind, '') <> 'operator'
411
+ ORDER BY l.closed_at DESC LIMIT %s""", (GAP_TRADES,)).fetchall()
412
  rl = np.array([r["rl"] for r in rows if r["rl"] is not None and r["rp"] is not None], dtype=np.float64)
413
  rp = np.array([r["rp"] for r in rows if r["rl"] is not None and r["rp"] is not None], dtype=np.float64)
414
  out = {"trades": int(len(rl)), "live_mean": float(rl.mean()) if len(rl) else None, "mirror_mean": float(rp.mean()) if len(rp) else None}
fly_trader/agent/fly_session.py CHANGED
@@ -6,11 +6,16 @@ learning rate and forgetting of its mushroom body (``brain/plastic.py``). Each m
6
  first (CLAUDE.md): ``on_minute`` runs before any other book's minute, ``after_minute`` once every chain's live orders are out.
7
 
8
  1. bars: minute start, open, close and exit cost of every traded mint with a pending tag feed the label resolver;
9
- 2. scoring (``on_minute``): eligible minutes are scored by the plastic fly and its shadow and tagged; only minutes at or
10
- above the line teach it (brain/plastic.py);
 
 
 
11
  3. trading (``on_minute``; fresh stream, trade minutes): the race rules (``agent/paper_trading.py``) at the plastic line
12
- and sizing plan the paper book's minute, and once the fly holds the seat the wallet's orders on its entries go out
13
- (agent/fly_live.py, rh/live.py; the wallet's scheduled sales left at the minute boundary, agent/live_first.py);
 
 
14
  4. after the live orders (``after_minute``): the scored rows stored (``fly_scored``: pending rows keep the feature
15
  vector, so tags survive restarts), the paper trades booked, then outcomes: tags whose exit minute has now been seen
16
  teach the mushroom body's outcome pathway what the row made -- the book's realized return when it traded the row,
@@ -34,6 +39,7 @@ import json
34
  import logging
35
  import math
36
  import os
 
37
  import time
38
  import uuid
39
  from collections import deque
@@ -145,6 +151,7 @@ class FlyBook:
145
  def __init__(self, boot: dict, verdict: dict, live: bool = False, chain: str = "sol", brain: "FlyBook | None" = None):
146
  self.live = live; self.done = False
147
  self._phase: dict | None = None; self._scored_rows: list = []; self._activity: tuple | None = None # the live phase's, written after it
 
148
  self.verdict = verdict
149
  self.chain = chain; self.market = markets.for_chain(chain); self.brain = brain
150
  self.name = "fly" if chain == "sol" else f"fly_{chain}"
@@ -156,6 +163,7 @@ class FlyBook:
156
  conn.execute("INSERT INTO runs (run_id, kind, config, brain_snapshot_id, status) VALUES (%s,%s,%s,%s,'running')",
157
  (self.run_id, self.KIND, json.dumps({"channels": self.cfg, "book": self.BOOK, "chain": chain}, default=str), self.boot_id))
158
  self._restore_tags(conn)
 
159
  record_event("info", "fly", "fly session started", {"run_id": self.run_id, "bootstrap": self.boot_id, "channels": self.cfg, "lines": self.lines["plastic"],
160
  "pending": len(self.pending), "learning_frozen": self.learning_frozen})
161
  log.info("fly session: bootstrap %d on %s, channels %s, lines %s, %d pending tags", self.boot_id, brain_device.describe(self.fly.net.dev), self.cfg,
@@ -166,13 +174,14 @@ class FlyBook:
166
  b = self.brain
167
  if b is not None: # another chain's book: the Solana book's fly and plastic memory, this chain's own lines and tags
168
  self.boot_id = b.boot_id; self.fly = b.fly; self.connectome_name = b.connectome_name; self.capture_ok = False
 
169
  self.teacher_models = b.teacher_models; self.idx = b.idx; self.names = b.names; self.holds = b.holds; self.H = b.H
170
  self.teacher_live, self.teacher_groups = b.teacher_live, b.teacher_groups
171
  self.cfg = b.cfg; self.alpha = b.alpha; self.half_life = b.half_life; self.bank = b.bank
172
  net = self.fly.net
173
  else:
174
  self.boot_id = int(boot["id"])
175
- self.fly = fly_selector.load(boot["path"])
176
  try:
177
  self.connectome_name = current_connectome_path().name # stamped on the activity files the console draws
178
  except Exception:
@@ -421,20 +430,29 @@ class FlyBook:
421
  are out."""
422
  conn = ctx.conn; self._phase = None
423
  self._commands(conn, ctx.m1_epoch)
424
- scored = self._score(conn, ctx)
 
 
 
 
 
425
  plan = live = None
426
  if ctx.trade and ctx.fresh:
427
  if self.race_started_at is None:
428
  self.race_started_at = ctx.m1_epoch
429
  self.beat_no += 1
430
- d = scored["decision"]
 
 
 
431
  plan = paper_trading.plan_minute(ctx, book=self.BOOK, run_id=self.run_id, beat_no=self.beat_no, broker=self.broker, kind=self.KIND, mints=ctx.mints,
432
  infos=ctx.infos, scores=d["score"], threshold=d["threshold"], table=None, horizon_s=self.holds[self.names[0]],
433
  holds=d["hold_s"], strategies=d["strategy"], tables=d["tables"], flat=config.FLY_SIZING == "flat",
434
- allow=d.get("allow"), reasons=d.get("reason"))
435
  if self.trades_live(conn):
436
- live = self._go_live(ctx, plan["beat_id"], plan["entries"])
437
- self._phase = {"m1": ctx.m1, "picks": scored["picks"], "plan": plan, "live": live}
 
438
  return {"minute": ctx.m1.isoformat(), "picks": scored["picks"], "live": live}
439
 
440
  def after_minute(self, ctx) -> dict:
@@ -444,6 +462,9 @@ class FlyBook:
444
  conn = ctx.conn
445
  ph = self._phase if self._phase is not None and self._phase["m1"] == ctx.m1 else {"picks": 0, "plan": None, "live": None}
446
  self._phase = None
 
 
 
447
  self._store_scored(conn)
448
  st = paper_trading.book_minute(ctx, ph["plan"]) if ph["plan"] is not None else None
449
  learned = self._learn(conn, ctx.m1_epoch)
@@ -459,7 +480,7 @@ class FlyBook:
459
  "learning_frozen": (self.brain or self).learning_frozen, "frozen_strategies": (self.brain or self).frozen, "channels": self.cfg,
460
  "holds_min": {k: v / 60.0 for k, v in self.holds.items()},
461
  "bootstrap": self.boot_id,
462
- "device": str(self.fly.net.dev)}
463
  if st is not None:
464
  out.update({k: v for k, v in st.items() if k != "entries"}); out["stage"] = "trading"
465
  if ph["live"] is not None:
@@ -471,6 +492,7 @@ class FlyBook:
471
  self.last_hour = hour
472
  elif hour > self.last_hour:
473
  self.last_hour = hour; self._hourly(conn, ctx.m1_epoch)
 
474
  self._write_capture()
475
  self._write_state()
476
  if learned.get("keys"): # a row still waiting for its live fill keeps its features (tags survive restarts)
@@ -502,26 +524,26 @@ class FlyBook:
502
  """The chain's live mirror, one per process and owned by the runner (agent/live_first.py); None until made."""
503
  return live_first.mirror_of(self.chain)
504
 
505
- def _go_live(self, ctx, beat_id: int, entries: list) -> dict:
506
  """The wallet's orders of this minute, in their own savepoint: a wallet RPC timeout must not roll back the paper plan."""
507
  try:
508
  with ctx.conn.transaction():
509
- return self._live(ctx, beat_id, entries)
510
  except Exception as e:
511
  log.exception("%s: live minute %s failed; the paper minute stands", self.name, ctx.m1.isoformat())
512
  record_event("error", "fly_live", f"live minute failed: {type(e).__name__}: {str(e)[:160]}", {"chain": self.chain, "minute": ctx.m1.isoformat()})
513
  return {"stage": f"live minute failed: {type(e).__name__}"}
514
 
515
- def _live(self, ctx, beat_id: int, entries: list) -> dict:
516
  """Mirror this minute's entries on the bot wallet (agent/fly_live.py; Robinhood Chain: the ETH wallet, rh/live.py)
517
- while signing is allowed."""
518
  why = self._signing()
519
  if why:
520
  return {"stage": f"live gated: {why}"}
521
  mirror = live_first.mirror(self.chain, self.H) # the clock may have made it while this book was loading
522
  if self.chain != "sol":
523
- return mirror.minute(ctx, run_id=self.run_id, beat_id=beat_id, entries=entries)
524
- return mirror.minute(ctx, run_id=self.run_id, beat_id=beat_id, entries=entries, line=self.lines["plastic"][self.names[0]],
525
  table=self.sizing["plastic"][self.names[0]])
526
 
527
  def _live_after(self, ctx, beat_id: int) -> dict:
@@ -648,8 +670,9 @@ class FlyBook:
648
  return {}, {}
649
  ts = sorted({datetime.fromtimestamp(t + 60.0, timezone.utc) for t, _, _ in keys}); mints = sorted({m for _, m, _ in keys})
650
  rows = conn.execute("SELECT d.ts, d.mint, d.detail->>'strategy' AS strategy, p.book, p.status, p.realized_sol / NULLIF(p.cost_sol, 0) AS ret "
651
- "FROM decisions d JOIN positions p ON p.entry_decision_id = d.id AND p.book = ANY(%s) " # an aborted RH buy (no tokens, gas only) was no trade
652
- "AND COALESCE(p.forced_exit_kind, '') <> 'aborted' WHERE d.kind = %s AND d.ts = ANY(%s) AND d.mint = ANY(%s)",
 
653
  ([self.BOOK, self.market.live_book], f"{self.KIND}_enter", ts, mints)).fetchall()
654
  book: dict = {}; live: dict = {}
655
  for r in rows:
@@ -657,7 +680,44 @@ class FlyBook:
657
  (book if r["book"] == self.BOOK else live)[key] = (r["status"], float(r["ret"]) if r["status"] == "closed" and r["ret"] is not None else None)
658
  return book, live
659
 
660
- def _score(self, conn, ctx) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  n = len(ctx.mints); empty = {"strategy": np.full(n, None, dtype=object), "score": np.full(n, -1.0), "hold_s": np.zeros(n), "threshold": np.full(n, np.inf),
662
  "tables": [[] for _ in range(n)]}
663
  if not n:
@@ -675,8 +735,11 @@ class FlyBook:
675
  gated = config.FLY_TEACHER_GATE
676
  if config.FLY_SCORE == "teacher" and self.teacher_live is None: # it starts from its teacher's score: none, no score
677
  return {"decision": no_teacher(n), "picks": 0}
678
- td = self._teacher_decision(ctx) if gated and self.teacher_live is not None else None
679
- tv = self._teacher_scores(ctx.X, t)
 
 
 
680
  for j, name in enumerate(self.names):
681
  loc = np.flatnonzero(trig[:, j])
682
  if tv is not None:
@@ -734,20 +797,23 @@ class FlyBook:
734
  return y_own
735
  return self.bank.head_for(torch.as_tensor(tv[loc, j], dtype=torch.float32, device=self.bank.dev), u0, s=np.full(len(loc), j))
736
 
737
- def _teacher_decision(self, ctx) -> dict:
738
- """The deployed selector's decision on this minute's rows, with its live fail-closed rules."""
 
739
  from .selector_session import fail_closed
740
- return fail_closed(self.teacher_groups, ctx.infos, strategies.decide(self.teacher_live, ctx.X, X_COLS, ctx.t_start))
 
741
 
742
  def _gated(self, ctx, V: np.ndarray, F: np.ndarray, td: dict | None = None) -> dict:
743
  """The fly's decision under its teacher: each row's strategy, trigger, line, win filter, gates, dump veto and
744
  fail-closed rules are the deployed selector's (agent/selector_session.fail_closed), so a fresh bootstrap trades the
745
  selector's set; the fly drops a teacher trade only where its learning has moved its score more than
746
- ``FLY_LEARNED_VETO`` below its bootstrap score. A row the teacher blocks is recorded with the teacher's reason."""
 
747
  n = len(ctx.mints)
748
  td = td if td is not None else self._teacher_decision(ctx)
749
  strat = np.full(n, None, dtype=object); score = np.full(n, -1.0); hold = np.zeros(n); thr = np.full(n, np.inf)
750
- tables = [[] for _ in range(n)]; allow = np.zeros(n, bool); reason = np.full(n, "", dtype=object)
751
  for i in range(n):
752
  s_ = td["strategy"][i]
753
  if s_ is None or s_ not in self.names:
@@ -759,10 +825,10 @@ class FlyBook:
759
  if not td["allow"][i]:
760
  reason[i] = f"selector: {td['reason'][i]}"
761
  elif np.isfinite(v) and np.isfinite(f) and v < f - config.FLY_LEARNED_VETO:
762
- reason[i] = f"learned veto: score {v:.3f} < bootstrap {f:.3f} - {config.FLY_LEARNED_VETO:g}"
763
  else:
764
  allow[i] = True; reason[i] = "trade"
765
- return {"strategy": strat, "score": score, "hold_s": hold, "threshold": thr, "tables": tables, "allow": allow, "reason": reason}
766
 
767
  def _capture(self, H: torch.Tensor, trig: np.ndarray, t: float) -> None:
768
  """The console's brain view: this minute's mean activity over the rows some strategy's trigger fired on (every
 
6
  first (CLAUDE.md): ``on_minute`` runs before any other book's minute, ``after_minute`` once every chain's live orders are out.
7
 
8
  1. bars: minute start, open, close and exit cost of every traded mint with a pending tag feed the label resolver;
9
+ 2. scoring: with the teacher gate on, ``on_minute`` takes the teacher's decision from one pass of its stack and the fly
10
+ scores only the rows the teacher would trade, its learned veto's input (``_decide``); every eligible row's plastic and
11
+ shadow score and its tag wait for ``after_minute`` (``_score`` on the same pass). Ungated, or at a session's first
12
+ scored minute (the readout's ν, the fill pathways need every row), ``on_minute`` scores and tags every row (``_score``).
13
+ Only minutes at or above the line teach it (brain/plastic.py);
14
  3. trading (``on_minute``; fresh stream, trade minutes): the race rules (``agent/paper_trading.py``) at the plastic line
15
+ and sizing plan the paper book's minute, and the wallet's orders go out on the seat holder's decision (agent/fly_live.py,
16
+ rh/live.py; the wallet's scheduled sales left at the minute boundary, agent/live_first.py): the fly's once it holds the
17
+ seat; before that (LIVE_WITHOUT_HANDOVER) the selector's -- the fly's learned veto binds only its paper book, which is
18
+ what its race measures;
19
  4. after the live orders (``after_minute``): the scored rows stored (``fly_scored``: pending rows keep the feature
20
  vector, so tags survive restarts), the paper trades booked, then outcomes: tags whose exit minute has now been seen
21
  teach the mushroom body's outcome pathway what the row made -- the book's realized return when it traded the row,
 
39
  import logging
40
  import math
41
  import os
42
+ import threading
43
  import time
44
  import uuid
45
  from collections import deque
 
151
  def __init__(self, boot: dict, verdict: dict, live: bool = False, chain: str = "sol", brain: "FlyBook | None" = None):
152
  self.live = live; self.done = False
153
  self._phase: dict | None = None; self._scored_rows: list = []; self._activity: tuple | None = None # the live phase's, written after it
154
+ self._live_pass: tuple | None = None # (minute, teacher decision, teacher scores) the live phase decided on (_decide)
155
  self.verdict = verdict
156
  self.chain = chain; self.market = markets.for_chain(chain); self.brain = brain
157
  self.name = "fly" if chain == "sol" else f"fly_{chain}"
 
163
  conn.execute("INSERT INTO runs (run_id, kind, config, brain_snapshot_id, status) VALUES (%s,%s,%s,%s,'running')",
164
  (self.run_id, self.KIND, json.dumps({"channels": self.cfg, "book": self.BOOK, "chain": chain}, default=str), self.boot_id))
165
  self._restore_tags(conn)
166
+ self.has_seat = handover.state(conn, chain) is not None # whose decision the wallet trades (on_minute); refreshed after each minute's live orders
167
  record_event("info", "fly", "fly session started", {"run_id": self.run_id, "bootstrap": self.boot_id, "channels": self.cfg, "lines": self.lines["plastic"],
168
  "pending": len(self.pending), "learning_frozen": self.learning_frozen})
169
  log.info("fly session: bootstrap %d on %s, channels %s, lines %s, %d pending tags", self.boot_id, brain_device.describe(self.fly.net.dev), self.cfg,
 
174
  b = self.brain
175
  if b is not None: # another chain's book: the Solana book's fly and plastic memory, this chain's own lines and tags
176
  self.boot_id = b.boot_id; self.fly = b.fly; self.connectome_name = b.connectome_name; self.capture_ok = False
177
+ self.fly_lock = b.fly_lock # the chains' live phases run at once: one model, one at a time
178
  self.teacher_models = b.teacher_models; self.idx = b.idx; self.names = b.names; self.holds = b.holds; self.H = b.H
179
  self.teacher_live, self.teacher_groups = b.teacher_live, b.teacher_groups
180
  self.cfg = b.cfg; self.alpha = b.alpha; self.half_life = b.half_life; self.bank = b.bank
181
  net = self.fly.net
182
  else:
183
  self.boot_id = int(boot["id"])
184
+ self.fly = fly_selector.load(boot["path"]); self.fly_lock = threading.Lock()
185
  try:
186
  self.connectome_name = current_connectome_path().name # stamped on the activity files the console draws
187
  except Exception:
 
430
  are out."""
431
  conn = ctx.conn; self._phase = None
432
  self._commands(conn, ctx.m1_epoch)
433
+ if self._fast():
434
+ scored = self._decide(ctx)
435
+ else:
436
+ with self.fly_lock: # every row on the model another chain's live phase may be using
437
+ scored = self._score(conn, ctx)
438
+ timing = {"seen_s": ctx.lag_s, "scored_s": time.time() - ctx.m1_epoch} # after the minute's end: the engine had its rows, the fly decided
439
  plan = live = None
440
  if ctx.trade and ctx.fresh:
441
  if self.race_started_at is None:
442
  self.race_started_at = ctx.m1_epoch
443
  self.beat_no += 1
444
+ d = scored["decision"]; vetoed = d.get("vetoed")
445
+ # until the fly has won its race the selector holds the seat: its decision goes live, and the fly's learned veto
446
+ # binds only the paper book (the race measures it)
447
+ live_allow = d["allow"] | vetoed if vetoed is not None and vetoed.any() and not self.has_seat else None
448
  plan = paper_trading.plan_minute(ctx, book=self.BOOK, run_id=self.run_id, beat_no=self.beat_no, broker=self.broker, kind=self.KIND, mints=ctx.mints,
449
  infos=ctx.infos, scores=d["score"], threshold=d["threshold"], table=None, horizon_s=self.holds[self.names[0]],
450
  holds=d["hold_s"], strategies=d["strategy"], tables=d["tables"], flat=config.FLY_SIZING == "flat",
451
+ allow=d.get("allow"), reasons=d.get("reason"), live_allow=live_allow)
452
  if self.trades_live(conn):
453
+ live = self._go_live(ctx, plan["beat_id"], plan["entries"], plan["rebuy"])
454
+ timing["orders_s"] = time.time() - ctx.m1_epoch # the wallet's orders queued
455
+ self._phase = {"m1": ctx.m1, "picks": scored["picks"], "plan": plan, "live": live, "timing": timing}
456
  return {"minute": ctx.m1.isoformat(), "picks": scored["picks"], "live": live}
457
 
458
  def after_minute(self, ctx) -> dict:
 
462
  conn = ctx.conn
463
  ph = self._phase if self._phase is not None and self._phase["m1"] == ctx.m1 else {"picks": 0, "plan": None, "live": None}
464
  self._phase = None
465
+ lp, self._live_pass = self._live_pass, None
466
+ if lp is not None and lp[0] == ctx.m1: # the live phase decided on its teacher's pass: every row's score and tag now
467
+ self._score(conn, ctx, live_pass=lp[1:])
468
  self._store_scored(conn)
469
  st = paper_trading.book_minute(ctx, ph["plan"]) if ph["plan"] is not None else None
470
  learned = self._learn(conn, ctx.m1_epoch)
 
480
  "learning_frozen": (self.brain or self).learning_frozen, "frozen_strategies": (self.brain or self).frozen, "channels": self.cfg,
481
  "holds_min": {k: v / 60.0 for k, v in self.holds.items()},
482
  "bootstrap": self.boot_id,
483
+ "device": str(self.fly.net.dev), "timing": ph.get("timing")}
484
  if st is not None:
485
  out.update({k: v for k, v in st.items() if k != "entries"}); out["stage"] = "trading"
486
  if ph["live"] is not None:
 
492
  self.last_hour = hour
493
  elif hour > self.last_hour:
494
  self.last_hour = hour; self._hourly(conn, ctx.m1_epoch)
495
+ self.has_seat = handover.state(conn, self.chain) is not None # for the next live phase: a handover, or the seat cleared by hand
496
  self._write_capture()
497
  self._write_state()
498
  if learned.get("keys"): # a row still waiting for its live fill keeps its features (tags survive restarts)
 
524
  """The chain's live mirror, one per process and owned by the runner (agent/live_first.py); None until made."""
525
  return live_first.mirror_of(self.chain)
526
 
527
+ def _go_live(self, ctx, beat_id: int, entries: list, rebuy: dict) -> dict:
528
  """The wallet's orders of this minute, in their own savepoint: a wallet RPC timeout must not roll back the paper plan."""
529
  try:
530
  with ctx.conn.transaction():
531
+ return self._live(ctx, beat_id, entries, rebuy)
532
  except Exception as e:
533
  log.exception("%s: live minute %s failed; the paper minute stands", self.name, ctx.m1.isoformat())
534
  record_event("error", "fly_live", f"live minute failed: {type(e).__name__}: {str(e)[:160]}", {"chain": self.chain, "minute": ctx.m1.isoformat()})
535
  return {"stage": f"live minute failed: {type(e).__name__}"}
536
 
537
+ def _live(self, ctx, beat_id: int, entries: list, rebuy: dict) -> dict:
538
  """Mirror this minute's entries on the bot wallet (agent/fly_live.py; Robinhood Chain: the ETH wallet, rh/live.py)
539
+ while signing is allowed; a failed entry goes again only where this minute would buy its token again (``rebuy``)."""
540
  why = self._signing()
541
  if why:
542
  return {"stage": f"live gated: {why}"}
543
  mirror = live_first.mirror(self.chain, self.H) # the clock may have made it while this book was loading
544
  if self.chain != "sol":
545
+ return mirror.minute(ctx, run_id=self.run_id, beat_id=beat_id, entries=entries, rebuy=rebuy)
546
+ return mirror.minute(ctx, run_id=self.run_id, beat_id=beat_id, entries=entries, rebuy=rebuy, line=self.lines["plastic"][self.names[0]],
547
  table=self.sizing["plastic"][self.names[0]])
548
 
549
  def _live_after(self, ctx, beat_id: int) -> dict:
 
670
  return {}, {}
671
  ts = sorted({datetime.fromtimestamp(t + 60.0, timezone.utc) for t, _, _ in keys}); mints = sorted({m for _, m, _ in keys})
672
  rows = conn.execute("SELECT d.ts, d.mint, d.detail->>'strategy' AS strategy, p.book, p.status, p.realized_sol / NULLIF(p.cost_sol, 0) AS ret "
673
+ "FROM decisions d JOIN positions p ON p.entry_decision_id = d.id AND p.book = ANY(%s) " # an aborted RH buy (no tokens, gas only) was no trade,
674
+ "AND COALESCE(p.forced_exit_kind, '') NOT IN ('aborted', 'operator') " # an operator's Sell Now no fill of the fly's
675
+ "WHERE d.kind = %s AND d.ts = ANY(%s) AND d.mint = ANY(%s)",
676
  ([self.BOOK, self.market.live_book], f"{self.KIND}_enter", ts, mints)).fetchall()
677
  book: dict = {}; live: dict = {}
678
  for r in rows:
 
680
  (book if r["book"] == self.BOOK else live)[key] = (r["status"], float(r["ret"]) if r["status"] == "closed" and r["ret"] is not None else None)
681
  return book, live
682
 
683
+ def _fast(self) -> bool:
684
+ """Whether the live phase decides on its teacher's pass alone (``_decide``): the gate is on with its teacher loaded,
685
+ and no one-time choice of the session (the readout's ν, the fill pathways) needs every row of the minute first."""
686
+ return bool(config.FLY_TEACHER_GATE) and self.teacher_live is not None and self.nu_set and not self.fill_choice
687
+
688
+ def _decide(self, ctx) -> dict:
689
+ """The live phase's scoring (live money first, CLAUDE.md): one pass of the teacher's stack over the minute's rows
690
+ gives its decision and its scores, then the fly scores only the rows its teacher would trade -- the input of its
691
+ learned veto, the one place its score decides. Every other row's score, the tags that teach the fly, the brain view
692
+ and the blind teacher's budget wait for ``after_minute`` (``_score`` on this pass). The decision is ``_score``'s."""
693
+ n = len(ctx.mints)
694
+ if not n:
695
+ return self._score(ctx.conn, ctx)
696
+ per = strategies.decide_all(self.teacher_live, ctx.X, X_COLS, ctx.t_start)
697
+ td = self._teacher_decision(ctx, per)
698
+ tv = fly_selector.teacher_scores_of(per, self.names, n) if config.FLY_SCORE == "teacher" else None
699
+ V = np.full((n, len(self.names)), -np.inf); F = np.full((n, len(self.names)), np.nan)
700
+ need = np.asarray([i for i in range(n) if td["allow"][i] and td["strategy"][i] in self.names], dtype=int)
701
+ if len(need):
702
+ Xn = ctx.X[need]; trig = self.fly.triggers(Xn, X_COLS)
703
+ with self.fly_lock: # another chain's live phase may be scoring on the same model
704
+ Y, u0, k = self.fly.parts_all(Xn[:, self.idx])
705
+ for j, name in enumerate(self.names): # as _score: the row's strategy, where the fly's trigger fires and its teacher scores it
706
+ q = np.asarray([p for p, i in enumerate(need) if td["strategy"][i] == name and trig[p, j] and (tv is None or np.isfinite(tv[i, j]))], dtype=int)
707
+ if not len(q):
708
+ continue
709
+ li = torch.as_tensor(q, device=Y.device); s = np.full(len(q), j)
710
+ y = self._head(Y[li, j], tv, need[q], j, u0[li])
711
+ with torch.no_grad():
712
+ sc, _ = self.bank.predict(y, u0[li], k[li], s=s); fr = self.bank.frozen(y, u0[li], s=s)
713
+ V[need[q], j] = sc[0].float().cpu().numpy(); F[need[q], j] = fr.float().cpu().numpy()
714
+ self._live_pass = (ctx.m1, td, tv)
715
+ d = self._gated(ctx, V, F, td)
716
+ return {"decision": d, "picks": int(d["allow"].sum())}
717
+
718
+ def _score(self, conn, ctx, live_pass: tuple | None = None) -> dict:
719
+ """Every eligible row scored by the plastic fly and its shadow and tagged, and the minute's decision. ``live_pass``:
720
+ (teacher decision, teacher scores) the live phase already decided on (``_decide``), reused."""
721
  n = len(ctx.mints); empty = {"strategy": np.full(n, None, dtype=object), "score": np.full(n, -1.0), "hold_s": np.zeros(n), "threshold": np.full(n, np.inf),
722
  "tables": [[] for _ in range(n)]}
723
  if not n:
 
735
  gated = config.FLY_TEACHER_GATE
736
  if config.FLY_SCORE == "teacher" and self.teacher_live is None: # it starts from its teacher's score: none, no score
737
  return {"decision": no_teacher(n), "picks": 0}
738
+ if live_pass is not None:
739
+ td, tv = live_pass
740
+ else:
741
+ td = self._teacher_decision(ctx) if gated and self.teacher_live is not None else None
742
+ tv = self._teacher_scores(ctx.X, t)
743
  for j, name in enumerate(self.names):
744
  loc = np.flatnonzero(trig[:, j])
745
  if tv is not None:
 
797
  return y_own
798
  return self.bank.head_for(torch.as_tensor(tv[loc, j], dtype=torch.float32, device=self.bank.dev), u0, s=np.full(len(loc), j))
799
 
800
+ def _teacher_decision(self, ctx, per: dict | None = None) -> dict:
801
+ """The deployed selector's decision on this minute's rows, with its live fail-closed rules. ``per``: its stack's
802
+ ``decide_all`` on them, when the caller has it."""
803
  from .selector_session import fail_closed
804
+ kw = {} if per is None else {"per": per}
805
+ return fail_closed(self.teacher_groups, ctx.infos, strategies.decide(self.teacher_live, ctx.X, X_COLS, ctx.t_start, **kw))
806
 
807
  def _gated(self, ctx, V: np.ndarray, F: np.ndarray, td: dict | None = None) -> dict:
808
  """The fly's decision under its teacher: each row's strategy, trigger, line, win filter, gates, dump veto and
809
  fail-closed rules are the deployed selector's (agent/selector_session.fail_closed), so a fresh bootstrap trades the
810
  selector's set; the fly drops a teacher trade only where its learning has moved its score more than
811
+ ``FLY_LEARNED_VETO`` below its bootstrap score (``vetoed``: those rows, which go live as the selector decided them
812
+ while it holds the seat, ``on_minute``). A row the teacher blocks is recorded with the teacher's reason."""
813
  n = len(ctx.mints)
814
  td = td if td is not None else self._teacher_decision(ctx)
815
  strat = np.full(n, None, dtype=object); score = np.full(n, -1.0); hold = np.zeros(n); thr = np.full(n, np.inf)
816
+ tables = [[] for _ in range(n)]; allow = np.zeros(n, bool); vetoed = np.zeros(n, bool); reason = np.full(n, "", dtype=object)
817
  for i in range(n):
818
  s_ = td["strategy"][i]
819
  if s_ is None or s_ not in self.names:
 
825
  if not td["allow"][i]:
826
  reason[i] = f"selector: {td['reason'][i]}"
827
  elif np.isfinite(v) and np.isfinite(f) and v < f - config.FLY_LEARNED_VETO:
828
+ reason[i] = f"learned veto: score {v:.3f} < bootstrap {f:.3f} - {config.FLY_LEARNED_VETO:g}"; vetoed[i] = True
829
  else:
830
  allow[i] = True; reason[i] = "trade"
831
+ return {"strategy": strat, "score": score, "hold_s": hold, "threshold": thr, "tables": tables, "allow": allow, "vetoed": vetoed, "reason": reason}
832
 
833
  def _capture(self, H: torch.Tensor, trig: np.ndarray, t: float) -> None:
834
  """The console's brain view: this minute's mean activity over the rows some strategy's trigger fired on (every
fly_trader/agent/live_first.py CHANGED
@@ -1,8 +1,12 @@
1
  """Live money first and fast over paper, always (CLAUDE.md): when the live books' work runs.
2
 
3
  - ``LiveClock``: the live chains' sales due at a minute boundary go out the moment it passes, from a thread of their own
4
- (``runner-live-clock``), started with the runner -- before its books load and the engine warms up, so a restart never
5
- holds a sale back -- and at once for the minute it starts in (a restart may have straddled a boundary).
 
 
 
 
6
  - One live mirror per chain per process (``mirror``): made by whichever comes first, the clock at a boundary or the
7
  fly's first live minute, and stopped by the runner when it stops (``stop_mirrors``), never by a book.
8
  - The engine runs every chain's newest minute for its live books first (the fly scores, its paper book plans, the
@@ -21,6 +25,7 @@ import logging
21
  import math
22
  import threading
23
  import time
 
24
  from datetime import datetime, timezone
25
 
26
  from .. import config
@@ -33,6 +38,35 @@ MID_MINUTE = (20.0, 40.0) # seconds into a minute when slow wallet bookkeepi
33
  _MIRRORS: dict[str, object] = {}
34
  _MIRRORS_LOCK = threading.Lock()
35
  _LISTENERS: list = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  def may_mark(now: float | None = None) -> bool:
@@ -116,6 +150,30 @@ def chain_exits(chain: str, m1_epoch: float, live: bool, run_id: str | None = No
116
  return mirror(chain).exits(conn, m1=datetime.fromtimestamp(m1_epoch, timezone.utc), run_id=run_id)
117
 
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def idle_mirrors() -> None:
120
  """Between minutes: each mirror's bookkeeping that waits for an idle wallet (the wealth mark, sweeps, the reconcile)."""
121
  for chain, m in list(_MIRRORS.items()):
@@ -164,17 +222,21 @@ class LiveClock:
164
  self.tick(math.floor(time.time() / 60.0) * 60.0) # the boundary just passed (a late wake takes the newest)
165
 
166
  def tick(self, m1_epoch: float) -> None:
167
- for chain in self.chains:
168
- run_id = next((b.run_id for b in list(self.books) if getattr(b, "LIVE", False) and getattr(b, "chain", "sol") == chain), None)
169
- try:
170
- out = chain_exits(chain, m1_epoch, self.live, run_id)
171
- n = (out or {}).get("exited", 0) + (out or {}).get("liquidated", 0)
172
- if n:
173
- log.info("%s: %d live sale(s) sent at the minute boundary", chain, n)
174
- except Exception as e:
175
- log.exception("%s: live exits at the minute boundary failed", chain)
176
- record_event("error", "fly_live" if chain == "sol" else "rh_live", f"live exits at the minute boundary failed: {type(e).__name__}: {str(e)[:160]}",
177
- {"chain": chain})
 
 
 
 
178
 
179
  def close(self, timeout: float = 10.0) -> None:
180
  self.stop_flag.set()
 
1
  """Live money first and fast over paper, always (CLAUDE.md): when the live books' work runs.
2
 
3
  - ``LiveClock``: the live chains' sales due at a minute boundary go out the moment it passes, from a thread of their own
4
+ (``runner-live-clock``), every chain's side by side, started with the runner -- before its books load and the engine
5
+ warms up, so a restart never holds a sale back -- and at once for the minute it starts in (a restart may have
6
+ straddled a boundary).
7
+ - Anything that can run beside a live order does: the engine wakes the moment a stream writes a minute
8
+ (``minute_published``), each chain's wallet balance is read while its minute's features and scores are computed
9
+ (``prefetch``), and the chains' live phases run at once (agent/minute_engine.run_ready).
10
  - One live mirror per chain per process (``mirror``): made by whichever comes first, the clock at a boundary or the
11
  fly's first live minute, and stopped by the runner when it stops (``stop_mirrors``), never by a book.
12
  - The engine runs every chain's newest minute for its live books first (the fly scores, its paper book plans, the
 
25
  import math
26
  import threading
27
  import time
28
+ from concurrent.futures import ThreadPoolExecutor
29
  from datetime import datetime, timezone
30
 
31
  from .. import config
 
38
  _MIRRORS: dict[str, object] = {}
39
  _MIRRORS_LOCK = threading.Lock()
40
  _LISTENERS: list = []
41
+ _PUBLISHED = threading.Event()
42
+ _PREFETCH = ThreadPoolExecutor(max_workers=2, thread_name_prefix="runner-prefetch") # the live minutes' wallet reads
43
+ _CLOCK = ThreadPoolExecutor(max_workers=4, thread_name_prefix="runner-live-clock") # every chain's boundary sales at once
44
+
45
+
46
+ def minute_published() -> None:
47
+ """A chain's stream has written a minute (ingest/pumpstream.py, rh/minutes.py): the engine's loop wakes at once."""
48
+ _PUBLISHED.set()
49
+
50
+
51
+ def wait_published(timeout: float) -> None:
52
+ """The engine's loop between polls: back at the next published minute, else after ``timeout``."""
53
+ _PUBLISHED.wait(timeout)
54
+ _PUBLISHED.clear()
55
+
56
+
57
+ def prefetch(chain: str, live: bool, m1_epoch: float) -> None:
58
+ """At a live minute's start, the chain's wallet balance read starts on its own thread, beside the minute's features
59
+ and the fly's scoring; the mirror sizes the minute's entries on it (``take_balance``)."""
60
+ m = _MIRRORS.get(chain)
61
+ if m is None or not live or not hasattr(m, "read_balance") or signing_refused(chain):
62
+ return
63
+ m.balance_ahead = (m1_epoch, _PREFETCH.submit(m.read_balance))
64
+
65
+
66
+ def take_balance(m, m1_epoch: float):
67
+ """The balance ``prefetch`` started for this minute (its error raised here), else read now."""
68
+ ahead, m.balance_ahead = getattr(m, "balance_ahead", None), None
69
+ return ahead[1].result() if ahead is not None and ahead[0] == m1_epoch else m.read_balance()
70
 
71
 
72
  def may_mark(now: float | None = None) -> bool:
 
150
  return mirror(chain).exits(conn, m1=datetime.fromtimestamp(m1_epoch, timezone.utc), run_id=run_id)
151
 
152
 
153
+ SELL_NOW_WAIT_S = 90.0 # how long the operator's Sell Now waits for its sale to be booked (an RH sale can take a minute)
154
+
155
+
156
+ def sell_now(chain: str, position_id: int, wait_s: float = SELL_NOW_WAIT_S) -> dict:
157
+ """The operator's Sell Now (the console's open positions, ui/api/overview.py): the chain's mirror sells one open live
158
+ position at once (its ``sell_now``), then this waits up to ``wait_s`` for the sale to be booked. {"booked", "realized",
159
+ "cost", "error"} (not booked and no error: not filled yet); ValueError with the reason when nothing was sent."""
160
+ m = mirror_of(chain)
161
+ if m is None:
162
+ raise ValueError("this chain's live mirror is not running in this console")
163
+ why = signing_refused(chain)
164
+ if why:
165
+ raise ValueError(f"its wallet may not sign: {why}")
166
+ with transaction() as conn:
167
+ did = m.sell_now(conn, position_id)
168
+ res = m.sale_result(did, wait_s)
169
+ with transaction() as conn:
170
+ p = conn.execute("SELECT status, realized_sol, cost_sol FROM positions WHERE id = %s", (position_id,)).fetchone()
171
+ booked = p is not None and p["status"] == "closed"
172
+ return {"booked": booked, "realized": float(p["realized_sol"]) if booked and p["realized_sol"] is not None else None,
173
+ "cost": float(p["cost_sol"]) if p is not None else None,
174
+ "error": None if booked or res is None else (res.error or "it sold only part of the position: the rest stays open")}
175
+
176
+
177
  def idle_mirrors() -> None:
178
  """Between minutes: each mirror's bookkeeping that waits for an idle wallet (the wealth mark, sweeps, the reconcile)."""
179
  for chain, m in list(_MIRRORS.items()):
 
222
  self.tick(math.floor(time.time() / 60.0) * 60.0) # the boundary just passed (a late wake takes the newest)
223
 
224
  def tick(self, m1_epoch: float) -> None:
225
+ """Every live chain's sales due at the boundary, the chains side by side: one chain's slow reads never hold
226
+ another's sales back."""
227
+ list(_CLOCK.map(lambda chain: self._tick(chain, m1_epoch), self.chains))
228
+
229
+ def _tick(self, chain: str, m1_epoch: float) -> None:
230
+ run_id = next((b.run_id for b in list(self.books) if getattr(b, "LIVE", False) and getattr(b, "chain", "sol") == chain), None)
231
+ try:
232
+ out = chain_exits(chain, m1_epoch, self.live, run_id)
233
+ n = (out or {}).get("exited", 0) + (out or {}).get("liquidated", 0)
234
+ if n:
235
+ log.info("%s: %d live sale(s) sent at the minute boundary", chain, n)
236
+ except Exception as e:
237
+ log.exception("%s: live exits at the minute boundary failed", chain)
238
+ record_event("error", "fly_live" if chain == "sol" else "rh_live", f"live exits at the minute boundary failed: {type(e).__name__}: {str(e)[:160]}",
239
+ {"chain": chain})
240
 
241
  def close(self, timeout: float = 10.0) -> None:
242
  self.stop_flag.set()
fly_trader/agent/minute_engine.py CHANGED
@@ -17,9 +17,9 @@ savepoint, so one book's failure never undoes the other's minute.
17
  Live money first and fast over paper, always (CLAUDE.md, agent/live_first.py). A book that trades live money (``LIVE``:
18
  the fly) runs its minute in two parts: ``on_minute`` in the minute's live phase (``live_phase``: the features, then the
19
  fly scores, plans and sends the wallet's orders; committed at once), ``after_minute`` in its paper phase
20
- (``paper_phase``: its paper trades, learning and bookkeeping, then every other book's minute). The loop runs every
21
- chain's newest minute's live phase before any chain's paper phase, and the live mirrors' idle bookkeeping between
22
- minutes. The wallet's scheduled sales go out at each minute boundary from the live clock's own thread
23
  (``live_first.chain_exits``), started with the runner: a restart never holds them behind the books loading or the warm-up.
24
 
25
  Books join when they qualify (checked every ``MODEL_CHECK_S``, mid-minute): the selector while it has a deployable
@@ -27,7 +27,7 @@ model and has not handed its seat to a fly that is trading;
27
  the fly once its design passed the replay and a deployable bootstrap exists. No entries or exits while the stream is
28
  stale; missed minutes are replayed through the features without trading; a minute traded more than ``ENTRY_MAX_LAG_S``
29
  after its end (``Minute.lag_s``) takes no entries (agent/paper_trading.py), only exits. A minute runs as soon as its
30
- stream has written it (polled every ``POLL_S``).
31
  """
32
  from __future__ import annotations
33
 
@@ -37,6 +37,7 @@ import math
37
  import threading
38
  import time
39
  from collections import deque
 
40
  from dataclasses import dataclass, replace
41
  from datetime import datetime, timezone
42
 
@@ -48,7 +49,7 @@ from ..db.connection import transaction
48
  from ..market.exit_cost import PUMP_SUPPLY
49
  from ..market.exit_cost import cost_at_size, cost_label
50
  from ..market.features import FEATURES, FIDX, TokenMeta, TokenState
51
- from ..train.corpus_meta import FEATURE_COLS as META_COLS
52
  from ..train.decisions import MIN_RESQ_SOL, MIN_VOL_15M_SOL, X_COLS
53
  from ..train.flow import FlowWindow, MarketWindow
54
  from ..train.mature import MASKED
@@ -57,15 +58,18 @@ from . import live_first
57
 
58
  log = logging.getLogger(__name__)
59
  WARMUP_MIN = 1440 # training warms each day on the previous day's candles
 
 
60
  IDLE_EVICT_S = 86400 # training has no history for a mint idle since before the previous day
61
  STREAM_STALE_S = 180 # no entries or exits when the stream's newest complete minute is older than this
62
  META_TTL_S = 600
63
  MODEL_CHECK_S = 600 # how often books reload newer models and new books are admitted
64
- POLL_S = 0.25 # how often the loop looks for a minute its stream has written
 
65
 
66
 
67
  class MintState:
68
- __slots__ = ("st", "meta", "hist", "decimals", "pool", "program_label", "graduated_at", "meta_row", "prev_close", "broken", "flow", "fee")
69
 
70
  def __init__(self, mint: str, decimals: int, pool: str | None, program_label: str | None, graduated_at: float | None, meta_row: dict | None):
71
  self.st = TokenState(mint); self.meta = TokenMeta(mint=mint, program_label=program_label or "Pump.fun Amm", graduated_at=graduated_at)
@@ -74,6 +78,7 @@ class MintState:
74
  self.prev_close: float | None = None; self.broken = False
75
  self.flow = FlowWindow() # wallet flow inputs (train/flow.py, as mature.build_day)
76
  self.fee: float | None = None # the last pool fee a minute of this mint reported (Minute.fee)
 
77
 
78
 
79
  @dataclass
@@ -151,9 +156,9 @@ class MinuteEngine:
151
  hit = self.meta_cache.get(mint)
152
  if hit is None or time.time() - hit["at"] > META_TTL_S:
153
  if self.rh: # Robinhood Chain: rh_meta, the same columns (rh/meta.py), plus the quote class
154
- r = conn.execute("SELECT graduated_at, supply, quote_class, " + ", ".join(META_COLS) + " FROM rh_meta WHERE mint = %s", (mint,)).fetchone()
155
  else:
156
- r = conn.execute("SELECT graduated_at, supply, " + ", ".join(META_COLS) + " FROM corpus_meta WHERE mint = %s", (mint,)).fetchone()
157
  hit = self.meta_cache[mint] = {"row": dict(r) if r else None, "at": time.time()}
158
  return hit["row"]
159
 
@@ -207,7 +212,8 @@ class MinuteEngine:
207
 
208
  # ---- one minute ----
209
  def _aggregate(self, conn, m0: datetime, m1: datetime) -> dict[str, dict]:
210
- """PumpAPI minutes: every SOL-quoted PumpSwap pump.fun token, the same fields as the archive. Robinhood Chain: the
 
211
  rh_minutes of graduated Pons pools in ETH (rh/minutes.py), under the same keys."""
212
  if self.rh:
213
  rows = conn.execute("SELECT mint, pool_id, open, high, low, close, buy_eth, sell_eth, n_buys, n_sells, n_traders, resq_eth, fee_rate, "
@@ -229,6 +235,8 @@ class MinuteEngine:
229
  """The full feature vector (``X_COLS``) of a mint for the minute ending ``t_end``, and what trading needs."""
230
  s = self._state(conn, mint, a["pool"], a["program_label"])
231
  st, price, resq = s.st, float(a["close"]), a["resq"]
 
 
232
  if self.rh:
233
  s.meta.pool_fee = a.get("fee_rate") # the Pons hook fee this minute charged (the cost model's fee)
234
  if a.get("fee_rate") is not None:
@@ -267,11 +275,13 @@ class MinuteEngine:
267
  "age_h": (t_end - s.graduated_at) / 3600 if s.graduated_at else None,
268
  "mcap": price * s.meta.supply, "fee_rate": a.get("fee_rate"), "decimals": s.decimals, "pool": s.pool, "program_label": s.program_label, "broken": s.broken,
269
  "skill_missing": a.get("skill_buy") is None, "curve_known": bool(meta.get("curve_known")), "chain": self.spec.chain,
270
- "quote_class": meta.get("quote_class")}
271
  return x, info
272
 
273
  def eligible(self, info: dict) -> bool:
274
  if self.rh: # the same gates in the chain's unit (x K), as training (train/decisions)
 
 
275
  return not info["broken"] and info["resq"] is not None and info["resq"] >= self.spec.min_resq() and info["logvol_15m"] >= math.log1p(self.spec.min_vol_15m())
276
  return not info["broken"] and info["resq"] is not None and info["resq"] >= MIN_RESQ_SOL and info["logvol_15m"] >= math.log1p(MIN_VOL_15M_SOL)
277
 
@@ -333,6 +343,9 @@ class MinuteEngine:
333
  other chain's paper work runs before these orders (live money first, CLAUDE.md)."""
334
  m1 = datetime.fromtimestamp(m1_epoch, timezone.utc); m0 = datetime.fromtimestamp(m1_epoch - 60, timezone.utc)
335
  out: dict = {"minute": m1.isoformat()}
 
 
 
336
  with transaction() as conn:
337
  agg = self._aggregate(conn, m0, m1)
338
  X, infos, mints, bars = self._minute_rows(conn, agg, m1_epoch)
@@ -341,7 +354,7 @@ class MinuteEngine:
341
  fees={m: a.get("fee_rate") for m, a in agg.items()}, fresh=trade and self.stream_fresh(m1_epoch - 60), trade=trade, engine=self,
342
  market=self.spec, lag_s=time.time() - m1_epoch)
343
  out.update(mints_traded=len(agg), eligible=len(mints))
344
- for b in [b for b in self.my_books() if getattr(b, "LIVE", False)]:
345
  try:
346
  with conn.transaction(): # a savepoint per book
347
  b.on_bars(m1_epoch - 60.0, bars)
@@ -424,38 +437,49 @@ def admit_books(engine: MinuteEngine, live: bool) -> list:
424
  return new
425
 
426
 
 
 
 
 
 
 
 
 
427
  def run_ready(engines: list, m1: float) -> list:
428
  """Every chain's minutes its stream has written up to wall-clock minute ``m1``: older ones whole (catching up: features
429
- and the fly's learning only), then each chain's newest -- live money first: every chain's live phase before any
430
- chain's paper phase. [(engine, minute, its minute's output or None when it failed)] of the newest minutes."""
431
- staged = []
432
- for engine in engines:
433
- if not engine.my_books() or m1 <= engine.last_minute:
434
- continue
435
- if (upto := engine.ready_through(m1)) <= engine.last_minute: # only minutes this chain's stream has written
436
- continue
437
- for minute in range(int(engine.last_minute) + 60, int(upto), 60):
438
- try:
439
- engine.run_minute(float(minute), trade=engine.tradeable(minute, m1))
440
- except Exception:
441
- log.exception("%s minute %s failed", engine.spec.chain, minute); record_event("error", "engine", f"{engine.spec.chain} minute failed: {minute}")
442
- staged.append((engine, int(upto)))
443
- live = {}
444
- for engine, minute in staged:
445
- try:
446
- live[id(engine)] = engine.live_phase(float(minute), trade=engine.tradeable(minute, m1))
447
- except Exception:
448
- log.exception("%s live minute %s failed", engine.spec.chain, minute); record_event("error", "engine", f"{engine.spec.chain} live minute failed: {minute}")
449
- ran = []
450
- for engine, minute in staged:
 
 
 
451
  st = None
452
- if id(engine) in live:
453
  try:
454
- st = engine.paper_phase(*live[id(engine)])
455
  except Exception:
456
  log.exception("%s minute %s failed", engine.spec.chain, minute); record_event("error", "engine", f"{engine.spec.chain} minute failed: {minute}")
457
- engine.last_minute = minute
458
  ran.append((engine, minute, st))
 
459
  return ran
460
 
461
 
@@ -509,7 +533,7 @@ def main(stop_event: threading.Event | None = None, live: bool = False) -> None:
509
  for b in engine.my_books()))
510
  if not ran: # between minutes: bookkeeping that waits for an idle wallet
511
  live_first.idle_mirrors()
512
- time.sleep(POLL_S)
513
  finally:
514
  clock.close()
515
  for b in books:
 
17
  Live money first and fast over paper, always (CLAUDE.md, agent/live_first.py). A book that trades live money (``LIVE``:
18
  the fly) runs its minute in two parts: ``on_minute`` in the minute's live phase (``live_phase``: the features, then the
19
  fly scores, plans and sends the wallet's orders; committed at once), ``after_minute`` in its paper phase
20
+ (``paper_phase``: its paper trades, learning and bookkeeping, then every other book's minute). The loop runs the
21
+ chains' newest minutes' live phases at once, before any chain's paper phase (and a minute written during the paper
22
+ phases has its live phase before the next one), and the live mirrors' idle bookkeeping between minutes. The wallet's scheduled sales go out at each minute boundary from the live clock's own thread
23
  (``live_first.chain_exits``), started with the runner: a restart never holds them behind the books loading or the warm-up.
24
 
25
  Books join when they qualify (checked every ``MODEL_CHECK_S``, mid-minute): the selector while it has a deployable
 
27
  the fly once its design passed the replay and a deployable bootstrap exists. No entries or exits while the stream is
28
  stale; missed minutes are replayed through the features without trading; a minute traded more than ``ENTRY_MAX_LAG_S``
29
  after its end (``Minute.lag_s``) takes no entries (agent/paper_trading.py), only exits. A minute runs as soon as its
30
+ stream has written it (the stream's publish wakes the loop, live_first.minute_published; else polled every ``POLL_S``).
31
  """
32
  from __future__ import annotations
33
 
 
37
  import threading
38
  import time
39
  from collections import deque
40
+ from concurrent.futures import ThreadPoolExecutor
41
  from dataclasses import dataclass, replace
42
  from datetime import datetime, timezone
43
 
 
49
  from ..market.exit_cost import PUMP_SUPPLY
50
  from ..market.exit_cost import cost_at_size, cost_label
51
  from ..market.features import FEATURES, FIDX, TokenMeta, TokenState
52
+ from ..train.corpus_meta import FEATURE_COLS as META_COLS, sol_select
53
  from ..train.decisions import MIN_RESQ_SOL, MIN_VOL_15M_SOL, X_COLS
54
  from ..train.flow import FlowWindow, MarketWindow
55
  from ..train.mature import MASKED
 
58
 
59
  log = logging.getLogger(__name__)
60
  WARMUP_MIN = 1440 # training warms each day on the previous day's candles
61
+ V4_WARM_H = 3.0 # a plain v4 memecoin trades only once the engine has fed it this long (the longest feature window):
62
+ # it goes live mid-session without the history before (rh/v4_universe.go_live)
63
  IDLE_EVICT_S = 86400 # training has no history for a mint idle since before the previous day
64
  STREAM_STALE_S = 180 # no entries or exits when the stream's newest complete minute is older than this
65
  META_TTL_S = 600
66
  MODEL_CHECK_S = 600 # how often books reload newer models and new books are admitted
67
+ POLL_S = 0.25 # how often the loop looks for a minute its stream has written (a stream's publish wakes it at once)
68
+ _LIVE_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="runner-live") # every chain's live phase at once
69
 
70
 
71
  class MintState:
72
+ __slots__ = ("st", "meta", "hist", "decimals", "pool", "program_label", "graduated_at", "meta_row", "prev_close", "broken", "flow", "fee", "first_seen")
73
 
74
  def __init__(self, mint: str, decimals: int, pool: str | None, program_label: str | None, graduated_at: float | None, meta_row: dict | None):
75
  self.st = TokenState(mint); self.meta = TokenMeta(mint=mint, program_label=program_label or "Pump.fun Amm", graduated_at=graduated_at)
 
78
  self.prev_close: float | None = None; self.broken = False
79
  self.flow = FlowWindow() # wallet flow inputs (train/flow.py, as mature.build_day)
80
  self.fee: float | None = None # the last pool fee a minute of this mint reported (Minute.fee)
81
+ self.first_seen: float | None = None # end of the first minute this engine fed (warm-up included)
82
 
83
 
84
  @dataclass
 
156
  hit = self.meta_cache.get(mint)
157
  if hit is None or time.time() - hit["at"] > META_TTL_S:
158
  if self.rh: # Robinhood Chain: rh_meta, the same columns (rh/meta.py), plus the quote class
159
+ r = conn.execute("SELECT graduated_at, supply, quote_class, source, " + ", ".join(META_COLS) + " FROM rh_meta WHERE mint = %s", (mint,)).fetchone()
160
  else:
161
+ r = conn.execute("SELECT graduated_at, supply, " + sol_select(META_COLS) + " FROM corpus_meta WHERE mint = %s", (mint,)).fetchone()
162
  hit = self.meta_cache[mint] = {"row": dict(r) if r else None, "at": time.time()}
163
  return hit["row"]
164
 
 
212
 
213
  # ---- one minute ----
214
  def _aggregate(self, conn, m0: datetime, m1: datetime) -> dict[str, dict]:
215
+ """PumpAPI minutes: every pump.fun token's SOL-quoted PumpSwap or Meteora DLMM pool with the most legs that minute, the
216
+ same fields as the archive. Robinhood Chain: the
217
  rh_minutes of graduated Pons pools in ETH (rh/minutes.py), under the same keys."""
218
  if self.rh:
219
  rows = conn.execute("SELECT mint, pool_id, open, high, low, close, buy_eth, sell_eth, n_buys, n_sells, n_traders, resq_eth, fee_rate, "
 
235
  """The full feature vector (``X_COLS``) of a mint for the minute ending ``t_end``, and what trading needs."""
236
  s = self._state(conn, mint, a["pool"], a["program_label"])
237
  st, price, resq = s.st, float(a["close"]), a["resq"]
238
+ if s.first_seen is None:
239
+ s.first_seen = t_end
240
  if self.rh:
241
  s.meta.pool_fee = a.get("fee_rate") # the Pons hook fee this minute charged (the cost model's fee)
242
  if a.get("fee_rate") is not None:
 
275
  "age_h": (t_end - s.graduated_at) / 3600 if s.graduated_at else None,
276
  "mcap": price * s.meta.supply, "fee_rate": a.get("fee_rate"), "decimals": s.decimals, "pool": s.pool, "program_label": s.program_label, "broken": s.broken,
277
  "skill_missing": a.get("skill_buy") is None, "curve_known": bool(meta.get("curve_known")), "chain": self.spec.chain,
278
+ "quote_class": meta.get("quote_class"), "source": meta.get("source"), "seen_h": (t_end - s.first_seen) / 3600.0}
279
  return x, info
280
 
281
  def eligible(self, info: dict) -> bool:
282
  if self.rh: # the same gates in the chain's unit (x K), as training (train/decisions)
283
+ if info.get("source") == "v4" and (info.get("seen_h") or 0.0) < V4_WARM_H:
284
+ return False # its feature windows are not full yet (V4_WARM_H)
285
  return not info["broken"] and info["resq"] is not None and info["resq"] >= self.spec.min_resq() and info["logvol_15m"] >= math.log1p(self.spec.min_vol_15m())
286
  return not info["broken"] and info["resq"] is not None and info["resq"] >= MIN_RESQ_SOL and info["logvol_15m"] >= math.log1p(MIN_VOL_15M_SOL)
287
 
 
343
  other chain's paper work runs before these orders (live money first, CLAUDE.md)."""
344
  m1 = datetime.fromtimestamp(m1_epoch, timezone.utc); m0 = datetime.fromtimestamp(m1_epoch - 60, timezone.utc)
345
  out: dict = {"minute": m1.isoformat()}
346
+ live_books = [b for b in self.my_books() if getattr(b, "LIVE", False)]
347
+ if trade and live_books:
348
+ live_first.prefetch(self.spec.chain, self.live, m1_epoch) # the wallet's balance, read beside the features and scores
349
  with transaction() as conn:
350
  agg = self._aggregate(conn, m0, m1)
351
  X, infos, mints, bars = self._minute_rows(conn, agg, m1_epoch)
 
354
  fees={m: a.get("fee_rate") for m, a in agg.items()}, fresh=trade and self.stream_fresh(m1_epoch - 60), trade=trade, engine=self,
355
  market=self.spec, lag_s=time.time() - m1_epoch)
356
  out.update(mints_traded=len(agg), eligible=len(mints))
357
+ for b in live_books:
358
  try:
359
  with conn.transaction(): # a savepoint per book
360
  b.on_bars(m1_epoch - 60.0, bars)
 
437
  return new
438
 
439
 
440
+ def _live(engine, minute: int, trade: bool):
441
+ try:
442
+ return engine.live_phase(float(minute), trade=trade)
443
+ except Exception:
444
+ log.exception("%s live minute %s failed", engine.spec.chain, minute); record_event("error", "engine", f"{engine.spec.chain} live minute failed: {minute}")
445
+ return None
446
+
447
+
448
  def run_ready(engines: list, m1: float) -> list:
449
  """Every chain's minutes its stream has written up to wall-clock minute ``m1``: older ones whole (catching up: features
450
+ and the fly's learning only), then each chain's newest -- live money first: the chains' live phases at once
451
+ (``_LIVE_POOL``), every paper phase after them, and before each paper phase the live phase of a minute another
452
+ chain's stream wrote meanwhile. [(engine, minute, its minute's output or None when it failed)] of the newest minutes."""
453
+ papers, ran = [], []
454
+
455
+ def stage(m1_now: float) -> None:
456
+ staged = []
457
+ for engine in engines:
458
+ if not engine.my_books() or m1_now <= engine.last_minute:
459
+ continue
460
+ if (upto := engine.ready_through(m1_now)) <= engine.last_minute: # only minutes this chain's stream has written
461
+ continue
462
+ for minute in range(int(engine.last_minute) + 60, int(upto), 60):
463
+ try:
464
+ engine.run_minute(float(minute), trade=engine.tradeable(minute, m1_now))
465
+ except Exception:
466
+ log.exception("%s minute %s failed", engine.spec.chain, minute); record_event("error", "engine", f"{engine.spec.chain} minute failed: {minute}")
467
+ engine.last_minute = int(upto)
468
+ staged.append((engine, int(upto), engine.tradeable(upto, m1_now)))
469
+ lives = list(_LIVE_POOL.map(lambda s: _live(*s), staged)) if len(staged) > 1 else [_live(*s) for s in staged]
470
+ papers.extend((engine, minute, lv) for (engine, minute, _), lv in zip(staged, lives))
471
+
472
+ stage(m1)
473
+ while papers:
474
+ engine, minute, lv = papers.pop(0)
475
  st = None
476
+ if lv is not None:
477
  try:
478
+ st = engine.paper_phase(*lv)
479
  except Exception:
480
  log.exception("%s minute %s failed", engine.spec.chain, minute); record_event("error", "engine", f"{engine.spec.chain} minute failed: {minute}")
 
481
  ran.append((engine, minute, st))
482
+ stage(math.floor(time.time() / 60) * 60) # a minute written meanwhile: its live phase before the next paper phase
483
  return ran
484
 
485
 
 
533
  for b in engine.my_books()))
534
  if not ran: # between minutes: bookkeeping that waits for an idle wallet
535
  live_first.idle_mirrors()
536
+ live_first.wait_published(POLL_S) # back the moment a stream writes a minute
537
  finally:
538
  clock.close()
539
  for b in books:
fly_trader/agent/paper_trading.py CHANGED
@@ -32,14 +32,22 @@ from ..market.exit_cost import exit_cost_fraction
32
 
33
  def plan_minute(ctx, *, book: str, run_id: str, beat_no: int, broker, kind: str, mints: list, infos: list, scores, threshold,
34
  table: list | None, horizon_s: float, block: str | None = None, holds=None, strategies=None, allow=None, reasons=None,
35
- tables: list | None = None, flat: bool = False) -> dict:
36
  """The minute's beat, exits and entries, decided and quoted but not booked: every entry and blocked pick is already a
37
  ``decisions`` row (the live mirror buys under the entry's id); ``book_minute`` books the rest.
38
 
39
  Optional per-row arguments (the selector's strategy stack, train/strategies.decide): ``threshold`` may be per row,
40
  ``holds`` (s), ``strategies``, ``tables`` (sizing) and ``allow`` / ``reasons`` (a blocked row is recorded with its reason).
41
  Left out, the call behaves exactly as the single-strategy engine. ``flat``: every buy the same share of the bankroll
42
- (agent/sizing.size_position), the fly's rule (``config.FLY_SIZING``)."""
 
 
 
 
 
 
 
 
43
  conn, m1 = ctx.conn, ctx.m1
44
  beat = conn.execute("INSERT INTO beats (run_id, ts, beat_no, n_slots_active, notes) VALUES (%s,%s,%s,%s,%s) RETURNING id",
45
  (run_id, m1, beat_no, len(mints), json.dumps({"minute": m1.isoformat(), "mints_traded": len(ctx.agg), "book": book}))).fetchone()["id"]
@@ -55,6 +63,7 @@ def plan_minute(ctx, *, book: str, run_id: str, beat_no: int, broker, kind: str,
55
  exits.append((p, held, kw, broker.quote_sell(position=p, **kw)))
56
  leaving = {int(p["id"]) for p, *_ in exits}
57
  opens_now = [p for p in opens_before if int(p["id"]) not in leaving]; open_mints = {p["mint"] for p in opens_now}
 
58
  cash = ledger.paper_cash(conn, book) + sum(q for *_, q in exits); n_enter = 0; picks = []; entries = []; buys = []
59
  bankroll = cash + sum(float(p["cost_sol"]) for p in opens_now) # wealth at cost: the sizing base (agent/sizing.py)
60
  from ..markets import for_book
@@ -65,8 +74,11 @@ def plan_minute(ctx, *, book: str, run_id: str, beat_no: int, broker, kind: str,
65
  or ("paused" if circuit and circuit["entries_paused"] else None) or ("book halted" if rails.book_halted(conn, book) else None))
66
  thr_v = np.broadcast_to(np.asarray(threshold, dtype=np.float64), (len(mints),)) if len(mints) else np.array([])
67
  for k, (m, i, sc) in enumerate(zip(mints, infos, scores)):
68
- thr_k = float(thr_v[k])
69
  if not np.isfinite(sc) or sc < thr_k:
 
 
 
70
  continue
71
  picks.append((m, float(sc)))
72
  tab_k = tables[k] if tables is not None else table
@@ -78,8 +90,26 @@ def plan_minute(ctx, *, book: str, run_id: str, beat_no: int, broker, kind: str,
78
  (beat, run_id, m1, m, i["pool"], float(sc), size, rail, why if rail == "sizing" else f"{kind} pick not taken",
79
  json.dumps({"book": book, "score": float(sc), "threshold": thr_k, "sizing": why,
80
  "strategy": None if strategies is None else strategies[k]})))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  continue
82
- hold_k = float(holds[k]) if holds is not None else None; strat_k = None if strategies is None else strategies[k]
83
  did = conn.execute("INSERT INTO decisions (beat_id, run_id, ts, mint, pool, kind, m_hat, size_sol, forced, reason, book_targets, detail) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,false,%s,%s,%s) RETURNING id",
84
  (beat, run_id, m1, m, i["pool"], f"{kind}_enter", float(sc), size, f"{strat_k + ': ' if strat_k else ''}score {sc:.3f} ≥ {thr_k:.3f}; {why}", [book],
85
  json.dumps({"book": book, "score": float(sc), "threshold": thr_k, "size_sol": size, "sizing": why, "resq": i["resq"], "age_h": i["age_h"], "price": i["price"],
@@ -94,7 +124,7 @@ def plan_minute(ctx, *, book: str, run_id: str, beat_no: int, broker, kind: str,
94
  else:
95
  conn.execute("UPDATE decisions SET rail = %s, reason = %s WHERE id = %s", ("paper_fill", q.reason, did))
96
  return {"book": book, "run_id": run_id, "kind": kind, "broker": broker, "beat_id": beat, "exits": exits, "buys": buys, "entries": entries,
97
- "entered": n_enter, "picks": picks, "blocked": blocked, "market": market}
98
 
99
 
100
  def book_minute(ctx, plan: dict) -> dict:
 
32
 
33
  def plan_minute(ctx, *, book: str, run_id: str, beat_no: int, broker, kind: str, mints: list, infos: list, scores, threshold,
34
  table: list | None, horizon_s: float, block: str | None = None, holds=None, strategies=None, allow=None, reasons=None,
35
+ tables: list | None = None, flat: bool = False, live_allow=None) -> dict:
36
  """The minute's beat, exits and entries, decided and quoted but not booked: every entry and blocked pick is already a
37
  ``decisions`` row (the live mirror buys under the entry's id); ``book_minute`` books the rest.
38
 
39
  Optional per-row arguments (the selector's strategy stack, train/strategies.decide): ``threshold`` may be per row,
40
  ``holds`` (s), ``strategies``, ``tables`` (sizing) and ``allow`` / ``reasons`` (a blocked row is recorded with its reason).
41
  Left out, the call behaves exactly as the single-strategy engine. ``flat``: every buy the same share of the bankroll
42
+ (agent/sizing.size_position), the fly's rule (``config.FLY_SIZING``). ``live_allow``: per row, what the wallet may buy
43
+ where that differs from this book: a row the book is denied (``allow``) but ``live_allow`` allows is planned for the
44
+ live mirror alone (``live_only``: its own entry decision, quoted and sized as the book would; never booked here, the
45
+ book's cash and holdings untouched) -- the fly's learned veto binds its paper book while the selector holds the seat.
46
+
47
+ ``rebuy``: each token the book keeps through the minute and has a row of, this minute's verdict on buying it again --
48
+ its score and line, and where it would (allowed, not blocked; the book's own cash aside) its sizing table and paper's
49
+ fill price at this minute's price for what the book paid (``ref_price``), else why not (``why``). A live mirror tries
50
+ a failed entry again only on such a buy, at that price (agent/fly_live.py, rh/live.py)."""
51
  conn, m1 = ctx.conn, ctx.m1
52
  beat = conn.execute("INSERT INTO beats (run_id, ts, beat_no, n_slots_active, notes) VALUES (%s,%s,%s,%s,%s) RETURNING id",
53
  (run_id, m1, beat_no, len(mints), json.dumps({"minute": m1.isoformat(), "mints_traded": len(ctx.agg), "book": book}))).fetchone()["id"]
 
63
  exits.append((p, held, kw, broker.quote_sell(position=p, **kw)))
64
  leaving = {int(p["id"]) for p, *_ in exits}
65
  opens_now = [p for p in opens_before if int(p["id"]) not in leaving]; open_mints = {p["mint"] for p in opens_now}
66
+ paid = {p["mint"]: float(p["cost_sol"]) for p in opens_now}; rebuy = {} # a token it keeps: what it paid, this minute's verdict on buying it again
67
  cash = ledger.paper_cash(conn, book) + sum(q for *_, q in exits); n_enter = 0; picks = []; entries = []; buys = []
68
  bankroll = cash + sum(float(p["cost_sol"]) for p in opens_now) # wealth at cost: the sizing base (agent/sizing.py)
69
  from ..markets import for_book
 
74
  or ("paused" if circuit and circuit["entries_paused"] else None) or ("book halted" if rails.book_halted(conn, book) else None))
75
  thr_v = np.broadcast_to(np.asarray(threshold, dtype=np.float64), (len(mints),)) if len(mints) else np.array([])
76
  for k, (m, i, sc) in enumerate(zip(mints, infos, scores)):
77
+ thr_k = float(thr_v[k]); strat_k = None if strategies is None else strategies[k]
78
  if not np.isfinite(sc) or sc < thr_k:
79
+ if m in paid:
80
+ rebuy[m] = {"score": float(sc), "threshold": thr_k,
81
+ "why": f"{strat_k + ': ' if strat_k else ''}score {sc:.3f} < {thr_k:.3f}" if np.isfinite(thr_k) else "no strategy picks it"}
82
  continue
83
  picks.append((m, float(sc)))
84
  tab_k = tables[k] if tables is not None else table
 
90
  (beat, run_id, m1, m, i["pool"], float(sc), size, rail, why if rail == "sizing" else f"{kind} pick not taken",
91
  json.dumps({"book": book, "score": float(sc), "threshold": thr_k, "sizing": why,
92
  "strategy": None if strategies is None else strategies[k]})))
93
+ if m in paid: # it keeps the token: would this minute buy it again, and paper's fill now for what it paid
94
+ q = None if blocked or denied else broker.quote_buy(size_sol=paid[m], price=i["price"], res_quote_sol=i["resq"], mcap_sol=i["mcap"], decimals=i["decimals"],
95
+ program_label=ctx.cost_label(m, i["program_label"]), pool_fee=ctx.fee(m))
96
+ rebuy[m] = {"score": float(sc), "threshold": thr_k, **({"table": tab_k, "ref_price": paid[m] / (q.token_delta / 10 ** int(i["decimals"]))} if q is not None and q.ok
97
+ else {"why": blocked or (q.reason if q is not None else str(reasons[k]) if reasons is not None else "filtered")})}
98
+ if denied and live_allow is not None and bool(live_allow[k]) and not blocked and m not in open_mints and size > 0:
99
+ hold_k = float(holds[k]) if holds is not None else None # the wallet's buy alone: this book stays as it is
100
+ did = conn.execute("INSERT INTO decisions (beat_id, run_id, ts, mint, pool, kind, m_hat, size_sol, forced, reason, book_targets, detail) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,false,%s,%s,%s) RETURNING id",
101
+ (beat, run_id, m1, m, i["pool"], f"{kind}_enter", float(sc), size, f"live only ({rail} binds {book}): {strat_k + ': ' if strat_k else ''}score {sc:.3f} ≥ {thr_k:.3f}; {why}", None,
102
+ json.dumps({"book": book, "live_only": True, "score": float(sc), "threshold": thr_k, "size_sol": size, "sizing": why, "resq": i["resq"], "age_h": i["age_h"],
103
+ "price": i["price"], "strategy": strat_k, "hold_s": hold_k}))).fetchone()["id"]
104
+ q = broker.quote_buy(size_sol=size, price=i["price"], res_quote_sol=i["resq"], mcap_sol=i["mcap"], decimals=i["decimals"],
105
+ program_label=ctx.cost_label(m, i["program_label"]), pool_fee=ctx.fee(m))
106
+ if q.ok:
107
+ entries.append({"mint": m, "decision_id": did, "score": float(sc), "size_sol": size, "info": i, "threshold": thr_k, "table": tab_k, "hold_s": hold_k,
108
+ "strategy": strat_k, "live_only": True, "ref_price": size / (q.token_delta / 10 ** int(i["decimals"]))})
109
+ else:
110
+ conn.execute("UPDATE decisions SET rail = %s, reason = %s WHERE id = %s", ("paper_fill", q.reason, did))
111
  continue
112
+ hold_k = float(holds[k]) if holds is not None else None
113
  did = conn.execute("INSERT INTO decisions (beat_id, run_id, ts, mint, pool, kind, m_hat, size_sol, forced, reason, book_targets, detail) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,false,%s,%s,%s) RETURNING id",
114
  (beat, run_id, m1, m, i["pool"], f"{kind}_enter", float(sc), size, f"{strat_k + ': ' if strat_k else ''}score {sc:.3f} ≥ {thr_k:.3f}; {why}", [book],
115
  json.dumps({"book": book, "score": float(sc), "threshold": thr_k, "size_sol": size, "sizing": why, "resq": i["resq"], "age_h": i["age_h"], "price": i["price"],
 
124
  else:
125
  conn.execute("UPDATE decisions SET rail = %s, reason = %s WHERE id = %s", ("paper_fill", q.reason, did))
126
  return {"book": book, "run_id": run_id, "kind": kind, "broker": broker, "beat_id": beat, "exits": exits, "buys": buys, "entries": entries,
127
+ "entered": n_enter, "picks": picks, "blocked": blocked, "market": market, "rebuy": rebuy}
128
 
129
 
130
  def book_minute(ctx, plan: dict) -> dict:
fly_trader/chain/balances.py CHANGED
@@ -31,11 +31,14 @@ class Snapshot:
31
 
32
 
33
  def snapshot_balances(rpc, pubkey) -> Snapshot:
34
- lamports = int(rpc.get_balance(str(pubkey)))
 
 
 
35
  tokens: dict[str, int] = {}
36
  decimals: dict[str, int] = {}
37
  deposits: dict[str, int] = {}
38
- for acct in rpc.get_token_accounts_by_owner(str(pubkey)):
39
  mint = acct["mint"]
40
  tokens[mint] = tokens.get(mint, 0) + int(acct["amount"])
41
  if acct.get("decimals") is not None:
 
31
 
32
 
33
  def snapshot_balances(rpc, pubkey) -> Snapshot:
34
+ if hasattr(rpc, "wallet_state"): # one round trip (chain/rpc.HttpSolanaRpc)
35
+ lamports, accts = rpc.wallet_state(str(pubkey)); lamports = int(lamports)
36
+ else:
37
+ lamports, accts = int(rpc.get_balance(str(pubkey))), rpc.get_token_accounts_by_owner(str(pubkey))
38
  tokens: dict[str, int] = {}
39
  decimals: dict[str, int] = {}
40
  deposits: dict[str, int] = {}
41
+ for acct in accts:
42
  mint = acct["mint"]
43
  tokens[mint] = tokens.get(mint, 0) + int(acct["amount"])
44
  if acct.get("decimals") is not None:
fly_trader/chain/rpc.py CHANGED
@@ -80,6 +80,21 @@ def _request_summary(method: str, params: list | None) -> dict:
80
  return {"params": params}
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  class HttpSolanaRpc:
84
  def __init__(self, url: str | None = None, timeout_s: float = RPC_TIMEOUT_S):
85
  self._url = url or config.helius_http_url()
@@ -184,18 +199,15 @@ class HttpSolanaRpc:
184
  for program in TOKEN_PROGRAM_IDS:
185
  res = self.call("getTokenAccountsByOwner",
186
  [str(pubkey), {"programId": program}, {"encoding": "jsonParsed", "commitment": commitment}])
187
- for item in (res or {}).get("value") or []:
188
- try:
189
- info = item["account"]["data"]["parsed"]["info"]
190
- amt = info["tokenAmount"]
191
- out.append({"address": item["pubkey"], "mint": info["mint"], "amount": int(amt["amount"]),
192
- "decimals": int(amt["decimals"]), "program": program,
193
- "lamports": int(item["account"].get("lamports") or 0),
194
- "is_native": bool(info.get("isNative")), "state": info.get("state")})
195
- except (KeyError, TypeError, ValueError):
196
- log.warning("unparsed token account %s", item.get("pubkey") if isinstance(item, dict) else "?")
197
  return out
198
 
 
 
 
 
 
 
199
  def get_signature_statuses(self, sigs: list[str], search_history: bool = False) -> list[dict | None]:
200
  res = self.call("getSignatureStatuses", [list(sigs), {"searchTransactionHistory": bool(search_history)}])
201
  value = (res or {}).get("value")
 
80
  return {"params": params}
81
 
82
 
83
+ def _token_accounts(res, program: str) -> list[dict]:
84
+ out = []
85
+ for item in (res or {}).get("value") or []:
86
+ try:
87
+ info = item["account"]["data"]["parsed"]["info"]
88
+ amt = info["tokenAmount"]
89
+ out.append({"address": item["pubkey"], "mint": info["mint"], "amount": int(amt["amount"]),
90
+ "decimals": int(amt["decimals"]), "program": program,
91
+ "lamports": int(item["account"].get("lamports") or 0),
92
+ "is_native": bool(info.get("isNative")), "state": info.get("state")})
93
+ except (KeyError, TypeError, ValueError):
94
+ log.warning("unparsed token account %s", item.get("pubkey") if isinstance(item, dict) else "?")
95
+ return out
96
+
97
+
98
  class HttpSolanaRpc:
99
  def __init__(self, url: str | None = None, timeout_s: float = RPC_TIMEOUT_S):
100
  self._url = url or config.helius_http_url()
 
199
  for program in TOKEN_PROGRAM_IDS:
200
  res = self.call("getTokenAccountsByOwner",
201
  [str(pubkey), {"programId": program}, {"encoding": "jsonParsed", "commitment": commitment}])
202
+ out += _token_accounts(res, program)
 
 
 
 
 
 
 
 
 
203
  return out
204
 
205
+ def wallet_state(self, pubkey, commitment: str = COMMITMENT) -> tuple[int, list[dict]]:
206
+ """(lamports, ``get_token_accounts_by_owner``) in one round trip (a JSON-RPC batch): a swap's balance snapshot."""
207
+ res = self.batch([("getBalance", [str(pubkey), {"commitment": commitment}])] +
208
+ [("getTokenAccountsByOwner", [str(pubkey), {"programId": p}, {"encoding": "jsonParsed", "commitment": commitment}]) for p in TOKEN_PROGRAM_IDS])
209
+ return int(res[0]["value"]), [a for r, p in zip(res[1:], TOKEN_PROGRAM_IDS) for a in _token_accounts(r, p)]
210
+
211
  def get_signature_statuses(self, sigs: list[str], search_history: bool = False) -> list[dict | None]:
212
  res = self.call("getSignatureStatuses", [list(sigs), {"searchTransactionHistory": bool(search_history)}])
213
  value = (res or {}).get("value")
fly_trader/config.py CHANGED
@@ -17,7 +17,7 @@ load_dotenv(REPO_ROOT / ".env", override=False)
17
  SECRET_ENV_NAMES = ("HELIUS_API_KEY", "JUPITER_API_KEY", "BOT_PRIVATE_KEY", "KALSHI_API_KEY_ID", "KALSHI_PRIVATE_KEY_PATH",
18
  "RELAY_SECRET", "TELEGRAM_BOT_TOKEN", "VAULT_BACKUP_HF_TOKEN", "VAULT_SOLANA_RPC_URL", "SIGNER_RPC_URL",
19
  "SOLANA_CHECK_RPC_URL", "HEALTHCHECK_URL", "RH_BOT_PRIVATE_KEY", "RH_RPC_URL_LOGS", "ENVIO_API_TOKEN", "QUICKNODE_API_KEY",
20
- "QUICKNODE_RH_API_KEY", "QUICKNODE_RH_API_URL", "RH_RPC_URL_TX")
21
 
22
 
23
  def utcnow() -> datetime:
@@ -102,8 +102,10 @@ SLIPPAGE_EXIT_BPS = env_int("SLIPPAGE_EXIT_BPS", 300)
102
  SLIPPAGE_FORCED_BPS = env_int("SLIPPAGE_FORCED_BPS", 500)
103
  SLIPPAGE_STEP_BPS = env_int("SLIPPAGE_STEP_BPS", 100)
104
  MAX_SLIPPAGE_ENTRY_BPS = env_int("MAX_SLIPPAGE_ENTRY_BPS", 300)
 
105
 
106
  HELIUS_HTTP = "https://mainnet.helius-rpc.com/"
 
107
  JUPITER_SWAP_BASE = "https://api.jup.ag/swap/v2"
108
  JUPITER_TOKENS_BASE = "https://api.jup.ag/tokens/v2"
109
  JUPITER_RPS = env_float("JUPITER_RPS", 8.0)
@@ -161,8 +163,9 @@ RESET_ON_START = env_bool("RESET_ON_START", True) # wipe the paper book and its
161
  HANDOVER_DAYS = env_int("HANDOVER_DAYS", 14) # days of racing first; 0 = as soon as the trades are in
162
  HANDOVER_MIN_TRADES = env_int("HANDOVER_MIN_TRADES", 30) # closed paper_fly trades required
163
  HANDOVER_BEAT_SELECTOR = env_bool("HANDOVER_BEAT_SELECTOR", True) # and realized P&L at least the paper selector's over the window
164
- # Trade the wallet as soon as signing is allowed, without waiting for the handover: the fly's teacher-gated decisions (the
165
- # selector's buys and sizes, less what its learning vetoes) are mirrored onto the wallet whoever holds the seat.
 
166
  LIVE_WITHOUT_HANDOVER = env_bool("LIVE_WITHOUT_HANDOVER", False)
167
 
168
 
@@ -172,6 +175,12 @@ def helius_http_url() -> str:
172
  return f"{HELIUS_HTTP}?api-key={HELIUS_API_KEY}"
173
 
174
 
 
 
 
 
 
 
175
  def summary() -> dict:
176
  """Non-secret configuration snapshot for ``runs.config``."""
177
  out = {}
@@ -341,6 +350,12 @@ V3_FACTORY = env_str("V3_FACTORY", "0x1f7d7550b1b028f7571e69a784071f0205fd2efa")
341
  V3_QUOTER_V2 = env_str("V3_QUOTER_V2", "0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7")
342
  RH_WETH = env_str("RH_WETH", "0x0bd7d308f8e1639fab988df18a8011f41eacad73") # SwapRouter02.WETH9()
343
  RH_USDG = env_str("RH_USDG", "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168")
 
 
 
 
 
 
344
  KYBER_API = env_str("KYBER_API", "https://aggregator-api.kyberswap.com/robinhood/api/v1")
345
  KYBER_ROUTER = env_str("KYBER_ROUTER", "0x6131B5fae19EA4f9D964eAc0408E4408b66337b5") # MetaAggregationRouterV2: the only router a built Kyber tx may call
346
  KYBER_CLIENT_ID = env_str("KYBER_CLIENT_ID", "fly-trader")
@@ -432,9 +447,22 @@ REPLAY_URL = env_str("REPLAY_URL", "https://replay.pumpapi.io")
432
  REPLAY_START = env_str("REPLAY_START", "2026-04-18")
433
  REPLAY_PARALLEL = env_int("REPLAY_PARALLEL", 4) # concurrent hour downloads (~2-4 MB/s each)
434
  REPLAY_ASSEMBLE = env_bool("REPLAY_ASSEMBLE", True) # 0: download and parse only (no assembly / corpus_meta / mature rounds)
 
 
 
 
 
435
  REPLAY_DIR = CORPUS_DIR / "replay"
436
  CORPUS_PULL_BEFORE = env_str("CORPUS_PULL_BEFORE", REPLAY_START) # swap-api puller only handles graduations before the replay archive begins
437
 
438
  # ---- PumpAPI live stream (free firehose, same events as the replay archive; the selector's parity feed) ----
439
  PUMPSTREAM_URL = env_str("PUMPSTREAM_URL", "wss://stream.pumpapi.io")
 
 
 
 
 
 
 
 
440
  PUMP_MINUTES_KEEP_DAYS = env_int("PUMP_MINUTES_KEEP_DAYS", 7) # older minute rows are archived to Parquet, then removed from the table
 
17
  SECRET_ENV_NAMES = ("HELIUS_API_KEY", "JUPITER_API_KEY", "BOT_PRIVATE_KEY", "KALSHI_API_KEY_ID", "KALSHI_PRIVATE_KEY_PATH",
18
  "RELAY_SECRET", "TELEGRAM_BOT_TOKEN", "VAULT_BACKUP_HF_TOKEN", "VAULT_SOLANA_RPC_URL", "SIGNER_RPC_URL",
19
  "SOLANA_CHECK_RPC_URL", "HEALTHCHECK_URL", "RH_BOT_PRIVATE_KEY", "RH_RPC_URL_LOGS", "ENVIO_API_TOKEN", "QUICKNODE_API_KEY",
20
+ "QUICKNODE_RH_API_KEY", "QUICKNODE_RH_API_URL", "RH_RPC_URL_TX", "STREAM_RELAY_TOKEN")
21
 
22
 
23
  def utcnow() -> datetime:
 
102
  SLIPPAGE_FORCED_BPS = env_int("SLIPPAGE_FORCED_BPS", 500)
103
  SLIPPAGE_STEP_BPS = env_int("SLIPPAGE_STEP_BPS", 100)
104
  MAX_SLIPPAGE_ENTRY_BPS = env_int("MAX_SLIPPAGE_ENTRY_BPS", 300)
105
+ EXEC_THREADS = env_int("EXEC_THREADS", 4) # Solana live swaps in flight at once, one per mint (execution/worker.py)
106
 
107
  HELIUS_HTTP = "https://mainnet.helius-rpc.com/"
108
+ HELIUS_WSS = "wss://mainnet.helius-rpc.com/"
109
  JUPITER_SWAP_BASE = "https://api.jup.ag/swap/v2"
110
  JUPITER_TOKENS_BASE = "https://api.jup.ag/tokens/v2"
111
  JUPITER_RPS = env_float("JUPITER_RPS", 8.0)
 
163
  HANDOVER_DAYS = env_int("HANDOVER_DAYS", 14) # days of racing first; 0 = as soon as the trades are in
164
  HANDOVER_MIN_TRADES = env_int("HANDOVER_MIN_TRADES", 30) # closed paper_fly trades required
165
  HANDOVER_BEAT_SELECTOR = env_bool("HANDOVER_BEAT_SELECTOR", True) # and realized P&L at least the paper selector's over the window
166
+ # Trade the wallet as soon as signing is allowed, without waiting for the handover: the seat holder's decisions are
167
+ # mirrored onto the wallet -- the selector's buys and sizes until the fly wins its race (the fly's learned veto binds only
168
+ # its paper book, which the race measures), the fly's teacher-gated decisions (less what its learning vetoes) after.
169
  LIVE_WITHOUT_HANDOVER = env_bool("LIVE_WITHOUT_HANDOVER", False)
170
 
171
 
 
175
  return f"{HELIUS_HTTP}?api-key={HELIUS_API_KEY}"
176
 
177
 
178
+ def helius_wss_url() -> str:
179
+ if not HELIUS_API_KEY:
180
+ raise RuntimeError("HELIUS_API_KEY is not set")
181
+ return f"{HELIUS_WSS}?api-key={HELIUS_API_KEY}"
182
+
183
+
184
  def summary() -> dict:
185
  """Non-secret configuration snapshot for ``runs.config``."""
186
  out = {}
 
350
  V3_QUOTER_V2 = env_str("V3_QUOTER_V2", "0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7")
351
  RH_WETH = env_str("RH_WETH", "0x0bd7d308f8e1639fab988df18a8011f41eacad73") # SwapRouter02.WETH9()
352
  RH_USDG = env_str("RH_USDG", "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168")
353
+ # Robinhood Chain's other Uniswap v4 pools (rh/v4_universe.py): a Pons coin's own pools elsewhere and plain v4 memecoins
354
+ RH_V4_ENABLED = env_bool("RH_V4_ENABLED", True) # find, check and index them (0: Pons pools only, as before 2026-10-23)
355
+ RH_V4_QUOTES = tuple(a.strip().lower() for a in env_str("RH_V4_QUOTES", RH_USDG).split(",") if a.strip()) # beside ETH and WETH: a plain memecoin's quote
356
+ RH_V4_HOOKS = tuple(a.strip().lower() for a in env_str("RH_V4_HOOKS", "").split(",") if a.strip()) # hooks allowed besides none (fees outside the Swap event)
357
+ RH_V4_CHECKS_PER_SLOT = env_int("RH_V4_CHECKS_PER_SLOT", 1) # contract checks a minute (each ~10 reads of the public RPC)
358
+ RH_V4_BACKFILL_RPM = env_float("RH_V4_BACKFILL_RPM", 30) # fly-trader rh-v4-backfill's own HyperSync pace (the console keeps its share)
359
  KYBER_API = env_str("KYBER_API", "https://aggregator-api.kyberswap.com/robinhood/api/v1")
360
  KYBER_ROUTER = env_str("KYBER_ROUTER", "0x6131B5fae19EA4f9D964eAc0408E4408b66337b5") # MetaAggregationRouterV2: the only router a built Kyber tx may call
361
  KYBER_CLIENT_ID = env_str("KYBER_CLIENT_ID", "fly-trader")
 
447
  REPLAY_START = env_str("REPLAY_START", "2026-04-18")
448
  REPLAY_PARALLEL = env_int("REPLAY_PARALLEL", 4) # concurrent hour downloads (~2-4 MB/s each)
449
  REPLAY_ASSEMBLE = env_bool("REPLAY_ASSEMBLE", True) # 0: download and parse only (no assembly / corpus_meta / mature rounds)
450
+ # ``fly-trader refetch-archive`` (ingest/replay_pull.refetch_main): hours written before the hour files kept every venue's SOL
451
+ # legs are downloaded again, beside the console, at these settings; it starts no download while the live stream lags
452
+ REFETCH_PARALLEL = env_int("REFETCH_PARALLEL", 2) # concurrent hour downloads
453
+ REFETCH_MAX_LAG_S = env_float("REFETCH_MAX_LAG_S", 15.0) # the live stream's newest event this far behind its status: wait
454
+ REFETCH_SKIP_RECENT_H = env_int("REFETCH_SKIP_RECENT_H", 6) # the newest hours are the console's replay worker's
455
  REPLAY_DIR = CORPUS_DIR / "replay"
456
  CORPUS_PULL_BEFORE = env_str("CORPUS_PULL_BEFORE", REPLAY_START) # swap-api puller only handles graduations before the replay archive begins
457
 
458
  # ---- PumpAPI live stream (free firehose, same events as the replay archive; the selector's parity feed) ----
459
  PUMPSTREAM_URL = env_str("PUMPSTREAM_URL", "wss://stream.pumpapi.io")
460
+ # pumpapi: PumpAPI's stream alone | relay: PumpAPI's events from our relay near its source (stream_relay/, ingest/relay_feed.py)
461
+ # build the live minutes, PumpAPI's own stream their backup | helius: our own decoder (ingest/helius_feed.py) does, but
462
+ # Helius's websockets delivered ~1 MB/s in all to this machine (2026-10-19), lagging and dropping half the trades when busy
463
+ SOL_FEED = env_str("SOL_FEED", "pumpapi")
464
+ STREAM_RELAY_URL = env_str("STREAM_RELAY_URL") # wss://<server>:<port>/stream (stream_relay/README.md)
465
+ STREAM_RELAY_TOKEN = env_str("STREAM_RELAY_TOKEN") # the relay's RELAY_TOKEN
466
+ STREAM_RELAY_CA_FILE = env_str("STREAM_RELAY_CA_FILE") # the CA of a self-signed relay certificate (else the usual roots)
467
+ HELIUS_FEED_COMMITMENT = env_str("HELIUS_FEED_COMMITMENT", "processed")
468
  PUMP_MINUTES_KEEP_DAYS = env_int("PUMP_MINUTES_KEEP_DAYS", 7) # older minute rows are archived to Parquet, then removed from the table
fly_trader/db/schema.py CHANGED
@@ -19,7 +19,7 @@ from .connection import connect, database_name, database_url
19
 
20
  log = logging.getLogger(__name__)
21
 
22
- SCHEMA_VERSION = 12 # 5: the plastic fly's tables and per-book halts (BASE_DDL); 6: the selector's strategy stack (MIGRATIONS[6]); 7: Kalshi (MIGRATIONS[7]); 8: Kalshi minute extremes
23
  _LOCK_KEY = 0x666C795F6D6967 # "fly_mig"
24
  # Applying the DDL takes ACCESS EXCLUSIVE on the tables it touches (Postgres takes it for ALTER TABLE ... ADD COLUMN IF NOT
25
  # EXISTS even when the column exists) and holds it to the end of the transaction, while live workers write those tables.
@@ -381,6 +381,9 @@ MIGRATIONS: dict[int, list[str]] = {
381
  # taker bought YES at (sold NO at 100 − it): a resting bid fills when a taker sells into it (kalshi/decisions.maker_filled)
382
  12: ["ALTER TABLE kalshi_minutes ADD COLUMN IF NOT EXISTS yes_sold_low real",
383
  "ALTER TABLE kalshi_minutes ADD COLUMN IF NOT EXISTS yes_bought_high real"],
 
 
 
384
  }
385
 
386
 
 
19
 
20
  log = logging.getLogger(__name__)
21
 
22
+ SCHEMA_VERSION = 13 # 13: a graduation's quote asset (corpus_meta.grad_quote); 5: the plastic fly's tables and per-book halts (BASE_DDL); 6: the selector's strategy stack (MIGRATIONS[6]); 7: Kalshi (MIGRATIONS[7]); 8: Kalshi minute extremes
23
  _LOCK_KEY = 0x666C795F6D6967 # "fly_mig"
24
  # Applying the DDL takes ACCESS EXCLUSIVE on the tables it touches (Postgres takes it for ALTER TABLE ... ADD COLUMN IF NOT
25
  # EXISTS even when the column exists) and holds it to the end of the transaction, while live workers write those tables.
 
381
  # taker bought YES at (sold NO at 100 − it): a resting bid fills when a taker sells into it (kalshi/decisions.maker_filled)
382
  12: ["ALTER TABLE kalshi_minutes ADD COLUMN IF NOT EXISTS yes_sold_low real",
383
  "ALTER TABLE kalshi_minutes ADD COLUMN IF NOT EXISTS yes_bought_high real"],
384
+ # 13: the quote asset a pump.fun graduation's pool was seeded in (its PumpSwap pool's quote mint): a graduation in USDC,
385
+ # a stock token or another coin keeps rq0 and dev_sol in those units, so they are not SOL inputs (corpus_meta.sol_select)
386
+ 13: ["ALTER TABLE corpus_meta ADD COLUMN IF NOT EXISTS grad_quote text"],
387
  }
388
 
389
 
fly_trader/execution/broker_live.py CHANGED
@@ -1,10 +1,11 @@
1
  """Live execution through Jupiter Swap API v2 with balance-verified fills (plan §9).
2
 
3
- Per attempt (one in-flight order per process, guarded by a module lock):
4
- assert_signing_allowed() -> pre = snapshot_balances() -> GET /order (taker = bot, excludeRouters=
5
- jupiterz) -> assert signatureFeePayer == taker -> orders row (status 'ordered') -> sign only our
6
- signer slot -> POST /execute -> orders row updated -> poll getSignatureStatuses every 1 s until
7
- confirmed or getBlockHeight() > lastValidBlockHeight (no other timeout) -> post = snapshot ->
 
8
  fill iff the input balance decreased AND the output balance increased (a confirmed sell whose token
9
  balance fell is a fill even when the SOL delta is <= 0: dust proceeds below fees) -> fills row.
10
 
@@ -29,6 +30,7 @@ import logging
29
  import re
30
  import threading
31
  import time
 
32
  from dataclasses import dataclass
33
 
34
  from psycopg.types.json import Jsonb
@@ -49,11 +51,14 @@ from . import ledger
49
 
50
  log = logging.getLogger(__name__)
51
 
52
- _INFLIGHT = threading.Lock() # one in-flight order at a time (plan §9)
53
  _UNSETTLED: dict[str, dict] = {} # token-account closes sent whose wait ended before they landed or expired (``settle_closes``)
54
  _UNSETTLED_LOCK = threading.Lock()
 
 
 
55
 
56
- CONFIRM_POLL_S = 1.0
57
  BLOCKHASH_VALIDITY_BLOCKS = 150 # fallback horizon when /order carries no lastValidBlockHeight
58
  CLOSE_ATA_BATCH = 10
59
  BASE_FEE_LAMPORTS = 5_000 # one signature, no priority fee: what the signer's close transaction pays
@@ -178,29 +183,35 @@ class LiveBroker:
178
  input_mint, output_mint = config.WSOL_MINT, mint
179
  else:
180
  input_mint, output_mint = mint, config.WSOL_MINT
181
- with _INFLIGHT:
182
- slip = int(slippage_bps)
183
- attempt = 0
184
- while True:
185
- attempt += 1
 
 
186
  result, retryable = self._attempt(
187
  conn, decision_id=decision_id, book=book, side=side, mint=mint, input_mint=input_mint,
188
  output_mint=output_mint, amount_in=amount_in, slippage_bps=slip, attempt=attempt,
189
- ref_price=ref_price if side == "buy" else None, decimals=decimals)
190
- result.attempts = attempt
191
- if result.ok or not retryable or step_bps <= 0 or slip + step_bps > max_slippage_bps:
192
- if retryable and not result.ok:
193
- log.warning("order %s not retried: slippage ladder exhausted at %d bps", result.order_id, slip)
194
- return result
195
- slip += int(step_bps)
196
- log.warning("retrying %s %s after %s (code %s) with slippage %d bps",
197
- side, mint, result.status, result.code, slip)
 
 
 
198
 
199
  # ---- one attempt ----
200
  def _attempt(self, conn, *, decision_id, book, side, mint, input_mint, output_mint, amount_in,
201
- slippage_bps, attempt, ref_price=None, decimals=None) -> tuple[FillResult, bool]:
 
202
  taker = self.pubkey
203
- pre = snapshot_balances(self.rpc, taker)
204
  request = {"inputMint": input_mint, "outputMint": output_mint, "amount": str(amount_in), "taker": taker,
205
  "slippageBps": slippage_bps, "excludeRouters": EXCLUDE_ROUTERS}
206
  base = dict(decision_id=decision_id, book=book, attempt=attempt, side=side, input_mint=input_mint,
@@ -258,6 +269,10 @@ class LiveBroker:
258
  except SignerUnavailable as e:
259
  return fail("sign_error", scrub(str(e)), None, order_id=order_id)
260
  signed_b64, tx_sig = signed["tx"], signed["signature"]
 
 
 
 
261
 
262
  # 3. execute
263
  exec_req = {"requestId": order.get("requestId"), "lastValidBlockHeight": lvbh, "signedTransaction": signed_b64}
@@ -280,20 +295,30 @@ class LiveBroker:
280
  self._update_order(conn, order_id, execute_request=exec_req, execute_response=ex, signature=signature,
281
  status="executed", error_code=code, error=error, latency_ms=ex.get("_latency_ms"))
282
 
283
- # 4. confirm (or expire), 5. verify by balance deltas — retried: a transport error here must not leave a landed swap unrecorded
284
- chain_status = st = post = None; last_exc = None
 
285
  for k in range(4):
286
  try:
287
  if chain_status is None:
288
  chain_status, st = await_confirmation(self.rpc, signature, lvbh)
289
  self._update_order(conn, order_id, status=chain_status)
290
- post = snapshot_balances(self.rpc, taker); break
 
 
291
  except Exception as e: # RPC transport / RPC errors
292
  last_exc = e; log.warning("confirm/verify attempt %d for %s failed: %s", k + 1, signature, scrub(str(e)))
293
  time.sleep(2.0 * (k + 1))
294
- if post is None:
295
- fill_id = self._insert_fill(conn, order_id, book, signature, None, mint, side, 0, 0, None, None, order.get("platformFee"), "unknown", pre, pre)
296
- record_event("error", "broker_live", "fill unknown: could not confirm or verify after retries; entries paused until reconciled (verify-fills)",
 
 
 
 
 
 
 
297
  {"order_id": order_id, "signature": signature, "side": side, "mint": mint, "error": scrub(str(last_exc))[:200]})
298
  try: # a landed but unrecorded swap must not be bought again or leave a stale position
299
  with transaction() as c2:
@@ -301,23 +326,15 @@ class LiveBroker:
301
  c2.execute("INSERT INTO circuit_events (kind, detail) VALUES ('unknown_fill_pause', %s)", (Jsonb({"order_id": order_id, "signature": signature, "mint": mint, "side": side}),))
302
  except Exception:
303
  log.exception("could not pause entries after an unknown fill")
304
- raise RuntimeError(f"confirm/verify failed for {signature}: {type(last_exc).__name__}") from last_exc
305
  delta = compute_delta(pre, post)
306
  lam = int(delta["lamports_delta"])
307
  tok = int(delta["token_deltas"].get(mint, 0))
308
- if (config.VAULT_ENABLED or _UNSETTLED) and chain_status == "confirmed":
309
- # the vault fly's wallet also receives deposits and pays claims, and a token-account close left unsettled may
310
- # land at any time: take the swap's own deltas from the transaction, so SOL landing between the two snapshots
311
- # never becomes part of a trade's cost or proceeds
312
- try:
313
- exact = tx_deltas(self.rpc.get_transaction(signature), str(taker), mint)
314
- except Exception as e:
315
- exact = None; log.warning("getTransaction for exact fill deltas failed: %s", type(e).__name__)
316
- if exact is not None:
317
- if exact != (lam, tok):
318
- log.warning("fill %s: snapshot deltas (%d, %d) differ from the transaction's (%d, %d); using the transaction",
319
- signature[:12], lam, tok, exact[0], exact[1])
320
- lam, tok = exact
321
  # the rent a first buy pays to open the mint's token account (~0.0015 SOL) is a deposit the account's close returns,
322
  # not the trade: the trade's SOL, its price and the position's cost leave it out (it is kept on the position)
323
  dep = int(post.deposits.get(mint, 0)) - int(pre.deposits.get(mint, 0))
@@ -338,9 +355,9 @@ class LiveBroker:
338
  fill_id, side, mint, tok, lam, price, verified_by)
339
  return FillResult(True, order_id, fill_id, signature, chain_status, code, error, tok, lam, price, attempt, dep), False
340
  if jup_status == "Success" or chain_status == "confirmed":
341
- tx_info = None
342
  try:
343
- tx_info = self.rpc.get_transaction(signature)
344
  except Exception as e:
345
  log.warning("getTransaction failed for %s: %s", signature, type(e).__name__)
346
  meta = (tx_info or {}).get("meta") or {}
@@ -359,6 +376,18 @@ class LiveBroker:
359
  return FillResult(False, order_id, None, signature, chain_status, code, error, tok, lam, None, attempt), retryable
360
 
361
  # ---- helpers ----
 
 
 
 
 
 
 
 
 
 
 
 
362
  def _decimals(self, conn, mint: str, pre: Snapshot, post: Snapshot) -> int | None:
363
  d = post.decimals.get(mint, pre.decimals.get(mint))
364
  if d is not None:
@@ -455,8 +484,8 @@ def close_empty_atas(rpc=None, signer=None, url: str | None = None, batch: int =
455
  until=None) -> dict:
456
  """Close every zero-balance token account owned by the bot (rent back to the wallet) and book what each returned
457
  (``_book_close``). The signer builds and signs the CloseAccount transactions (it re-checks that each account is ours
458
- and empty); each is recorded as wallet_events(kind='ata_closed'). A transaction holds the in-flight lock from its
459
- signing to its outcome, so no swap's balance snapshots straddle it. ``until``: checked while a close is awaited and
460
  before each batch; its True starts no other and leaves the one in flight unsettled (``settle_closes`` books it; until
461
  then swaps take their deltas from their own transactions)."""
462
  assert_signing_allowed()
 
1
  """Live execution through Jupiter Swap API v2 with balance-verified fills (plan §9).
2
 
3
+ Per attempt (several mints' swaps in flight at once, one per mint: execution/worker.py):
4
+ assert_signing_allowed() -> pre = snapshot_balances() beside GET /order (taker = bot, excludeRouters=
5
+ jupiterz) and the signing, taken before anything is sent -> assert signatureFeePayer == taker -> orders row (status
6
+ 'ordered') -> sign only our signer slot -> POST /execute -> orders row updated -> poll getSignatureStatuses every
7
+ ``CONFIRM_POLL_S`` until confirmed or getBlockHeight() > lastValidBlockHeight (no other timeout) -> post = snapshot
8
+ beside the swap's own transaction, whose deltas are the fill (another swap may land between the two snapshots) ->
9
  fill iff the input balance decreased AND the output balance increased (a confirmed sell whose token
10
  balance fell is a fill even when the SOL delta is <= 0: dust proceeds below fees) -> fills row.
11
 
 
30
  import re
31
  import threading
32
  import time
33
+ from concurrent.futures import ThreadPoolExecutor
34
  from dataclasses import dataclass
35
 
36
  from psycopg.types.json import Jsonb
 
51
 
52
  log = logging.getLogger(__name__)
53
 
54
+ _INFLIGHT = threading.Lock() # one token-account close at a time (``close_empty_atas``)
55
  _UNSETTLED: dict[str, dict] = {} # token-account closes sent whose wait ended before they landed or expired (``settle_closes``)
56
  _UNSETTLED_LOCK = threading.Lock()
57
+ _IO = ThreadPoolExecutor(max_workers=8, thread_name_prefix="runner-exec-io") # a swap's reads beside its order, signing and confirmation
58
+ _SWAPS = {"active": 0, "started": 0} # swaps in flight now / ever: whether another swap's balances moved between a swap's snapshots
59
+ _SWAPS_LOCK = threading.Lock()
60
 
61
+ CONFIRM_POLL_S = 0.4 # one slot
62
  BLOCKHASH_VALIDITY_BLOCKS = 150 # fallback horizon when /order carries no lastValidBlockHeight
63
  CLOSE_ATA_BATCH = 10
64
  BASE_FEE_LAMPORTS = 5_000 # one signature, no priority fee: what the signer's close transaction pays
 
183
  input_mint, output_mint = config.WSOL_MINT, mint
184
  else:
185
  input_mint, output_mint = mint, config.WSOL_MINT
186
+ slip = int(slippage_bps)
187
+ attempt = 0
188
+ while True:
189
+ attempt += 1
190
+ with _SWAPS_LOCK:
191
+ seen = (_SWAPS["active"], _SWAPS["started"]); _SWAPS["active"] += 1; _SWAPS["started"] += 1
192
+ try:
193
  result, retryable = self._attempt(
194
  conn, decision_id=decision_id, book=book, side=side, mint=mint, input_mint=input_mint,
195
  output_mint=output_mint, amount_in=amount_in, slippage_bps=slip, attempt=attempt,
196
+ ref_price=ref_price if side == "buy" else None, decimals=decimals, seen=seen)
197
+ finally:
198
+ with _SWAPS_LOCK:
199
+ _SWAPS["active"] -= 1
200
+ result.attempts = attempt
201
+ if result.ok or not retryable or step_bps <= 0 or slip + step_bps > max_slippage_bps:
202
+ if retryable and not result.ok:
203
+ log.warning("order %s not retried: slippage ladder exhausted at %d bps", result.order_id, slip)
204
+ return result
205
+ slip += int(step_bps)
206
+ log.warning("retrying %s %s after %s (code %s) with slippage %d bps",
207
+ side, mint, result.status, result.code, slip)
208
 
209
  # ---- one attempt ----
210
  def _attempt(self, conn, *, decision_id, book, side, mint, input_mint, output_mint, amount_in,
211
+ slippage_bps, attempt, ref_price=None, decimals=None, seen=(0, 0)) -> tuple[FillResult, bool]:
212
+ """``seen``: (swaps in flight, swaps started) when this one began (``swap``)."""
213
  taker = self.pubkey
214
+ pre_f = _IO.submit(snapshot_balances, self.rpc, taker) # the balances before, read beside the order and the signing
215
  request = {"inputMint": input_mint, "outputMint": output_mint, "amount": str(amount_in), "taker": taker,
216
  "slippageBps": slippage_bps, "excludeRouters": EXCLUDE_ROUTERS}
217
  base = dict(decision_id=decision_id, book=book, attempt=attempt, side=side, input_mint=input_mint,
 
269
  except SignerUnavailable as e:
270
  return fail("sign_error", scrub(str(e)), None, order_id=order_id)
271
  signed_b64, tx_sig = signed["tx"], signed["signature"]
272
+ try:
273
+ pre = pre_f.result() # taken before anything is sent
274
+ except Exception as e:
275
+ return fail("unsent", f"the balances before could not be read: {type(e).__name__}: {scrub(str(e))}", None, order_id=order_id)
276
 
277
  # 3. execute
278
  exec_req = {"requestId": order.get("requestId"), "lastValidBlockHeight": lvbh, "signedTransaction": signed_b64}
 
295
  self._update_order(conn, order_id, execute_request=exec_req, execute_response=ex, signature=signature,
296
  status="executed", error_code=code, error=error, latency_ms=ex.get("_latency_ms"))
297
 
298
+ # 4. confirm (or expire), 5. verify: the balances after, beside the swap's own transaction — retried: a transport
299
+ # error here must not leave a landed swap unrecorded
300
+ chain_status = st = post = tx = None; last_exc = None
301
  for k in range(4):
302
  try:
303
  if chain_status is None:
304
  chain_status, st = await_confirmation(self.rpc, signature, lvbh)
305
  self._update_order(conn, order_id, status=chain_status)
306
+ post_f = _IO.submit(snapshot_balances, self.rpc, taker)
307
+ tx_f = _IO.submit(self._landed, signature) if chain_status == "confirmed" else None
308
+ post = post_f.result(); tx = tx_f.result() if tx_f is not None else None; break
309
  except Exception as e: # RPC transport / RPC errors
310
  last_exc = e; log.warning("confirm/verify attempt %d for %s failed: %s", k + 1, signature, scrub(str(e)))
311
  time.sleep(2.0 * (k + 1))
312
+ # another swap, a vault deposit or claim, or a token-account close left unsettled may move the wallet between the
313
+ # two snapshots: the fill is the swap's own transaction's deltas, so SOL landing meanwhile never becomes part of a
314
+ # trade's cost or proceeds; with other swaps in flight the snapshots cannot stand in for an unreadable transaction
315
+ exact = tx_deltas(tx, str(taker), mint) if tx else None
316
+ with _SWAPS_LOCK:
317
+ moved = seen[0] > 0 or _SWAPS["started"] - seen[1] > 1
318
+ if post is None or (chain_status == "confirmed" and exact is None and moved):
319
+ why = "could not confirm or verify after retries" if post is None else "its transaction could not be read and other swaps moved the wallet meanwhile"
320
+ fill_id = self._insert_fill(conn, order_id, book, signature, None, mint, side, 0, 0, None, None, order.get("platformFee"), "unknown", pre, post or pre)
321
+ record_event("error", "broker_live", f"fill unknown: {why}; entries paused until reconciled (verify-fills)",
322
  {"order_id": order_id, "signature": signature, "side": side, "mint": mint, "error": scrub(str(last_exc))[:200]})
323
  try: # a landed but unrecorded swap must not be bought again or leave a stale position
324
  with transaction() as c2:
 
326
  c2.execute("INSERT INTO circuit_events (kind, detail) VALUES ('unknown_fill_pause', %s)", (Jsonb({"order_id": order_id, "signature": signature, "mint": mint, "side": side}),))
327
  except Exception:
328
  log.exception("could not pause entries after an unknown fill")
329
+ raise RuntimeError(f"confirm/verify failed for {signature}: {why}") from last_exc
330
  delta = compute_delta(pre, post)
331
  lam = int(delta["lamports_delta"])
332
  tok = int(delta["token_deltas"].get(mint, 0))
333
+ if exact is not None and chain_status == "confirmed":
334
+ if exact != (lam, tok):
335
+ log.warning("fill %s: snapshot deltas (%d, %d) differ from the transaction's (%d, %d); using the transaction",
336
+ signature[:12], lam, tok, exact[0], exact[1])
337
+ lam, tok = exact
 
 
 
 
 
 
 
 
338
  # the rent a first buy pays to open the mint's token account (~0.0015 SOL) is a deposit the account's close returns,
339
  # not the trade: the trade's SOL, its price and the position's cost leave it out (it is kept on the position)
340
  dep = int(post.deposits.get(mint, 0)) - int(pre.deposits.get(mint, 0))
 
355
  fill_id, side, mint, tok, lam, price, verified_by)
356
  return FillResult(True, order_id, fill_id, signature, chain_status, code, error, tok, lam, price, attempt, dep), False
357
  if jup_status == "Success" or chain_status == "confirmed":
358
+ tx_info = tx
359
  try:
360
+ tx_info = tx_info or self.rpc.get_transaction(signature)
361
  except Exception as e:
362
  log.warning("getTransaction failed for %s: %s", signature, type(e).__name__)
363
  meta = (tx_info or {}).get("meta") or {}
 
376
  return FillResult(False, order_id, None, signature, chain_status, code, error, tok, lam, None, attempt), retryable
377
 
378
  # ---- helpers ----
379
+ def _landed(self, signature: str) -> dict | None:
380
+ """The confirmed swap's transaction; None when unreadable (a node may not serve it the moment it confirms)."""
381
+ for k in range(3):
382
+ try:
383
+ tx = self.rpc.get_transaction(signature)
384
+ except Exception as e:
385
+ tx = None; log.warning("getTransaction for the fill of %s failed: %s", signature[:12], type(e).__name__)
386
+ if tx:
387
+ return tx
388
+ time.sleep(0.3 * (k + 1))
389
+ return None
390
+
391
  def _decimals(self, conn, mint: str, pre: Snapshot, post: Snapshot) -> int | None:
392
  d = post.decimals.get(mint, pre.decimals.get(mint))
393
  if d is not None:
 
484
  until=None) -> dict:
485
  """Close every zero-balance token account owned by the bot (rent back to the wallet) and book what each returned
486
  (``_book_close``). The signer builds and signs the CloseAccount transactions (it re-checks that each account is ours
487
+ and empty); each is recorded as wallet_events(kind='ata_closed'). A transaction holds the close lock from its
488
+ signing to its outcome; a swap meanwhile takes its fill from its own transaction. ``until``: checked while a close is awaited and
489
  before each batch; its True starts no other and leaves the one in flight unsettled (``settle_closes`` books it; until
490
  then swaps take their deltas from their own transactions)."""
491
  assert_signing_allowed()
fly_trader/execution/worker.py CHANGED
@@ -1,13 +1,14 @@
1
  """Execution queue for the live book: the caller never blocks on Jupiter.
2
 
3
- The caller enqueues ExecRequest objects; one background thread executes them sequentially through
4
- LiveBroker (one in-flight order at a time, balance-verified); results are drained by the caller, which
 
5
  opens/closes positions accordingly. A mint with a request queued or in flight takes no other, except a buy queued
6
  behind a lone sale of it (its paper book sold and bought the token again in one minute): the sale runs first, and the
7
- buy is refused as held if the sale left the position open. ``busy`` is held from a swap to its booking, so whoever reads
8
- the wallet and the books under it sees them agree. A sale that leaves the live book without its mint marks the mint's
9
  token account ``emptied``: the mirror closes it mid-minute with nothing queued (agent/fly_live.close_accounts), never in
10
- front of an order, and books the rent it returns. ``stop`` lets the request in flight finish (bounded) and drops the
11
  queued ones: exits are retried next minute, entries by the mirror (agent/fly_live.py).
12
  """
13
  from __future__ import annotations
@@ -15,7 +16,10 @@ from __future__ import annotations
15
  import logging
16
  import queue
17
  import threading
 
18
  import traceback
 
 
19
  from dataclasses import dataclass, field
20
  from datetime import datetime, timezone
21
 
@@ -64,29 +68,76 @@ class ExecResult:
64
  closed: bool = False # a sale that closed its position (or swept a written-off one)
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  class ExecutionWorker:
68
- def __init__(self, broker):
69
  self.broker = broker
70
- self.q: queue.Queue[ExecRequest] = queue.Queue()
71
  self.results: queue.Queue[ExecResult] = queue.Queue()
72
  self.inflight: dict[str, list[str]] = {} # mint -> sides queued or in flight, in order
73
  self.lock = threading.Lock()
74
- self.busy = threading.Lock() # held from a request's swap to its booking
75
- self.thread = threading.Thread(target=self._loop, name="runner-exec", daemon=True) # routed to the runner's log
 
 
76
  self.stop_flag = False
77
  self.on_done = None # called with each result once booked (the mirror's live stats: agent/fly_live.py)
78
  self.emptied: set[str] = set() # mints whose sale left their token account empty: closed by the mirror, mid-minute
79
- self.thread.start()
 
 
 
 
80
 
81
  def submit(self, req: ExecRequest) -> bool:
82
  """Queue ``req``; False when its mint already has a request queued or in flight (a buy may queue behind a lone
83
- sale of it) or the worker is stopping."""
84
  with self.lock:
85
  sides = self.inflight.get(req.mint)
86
  if self.stop_flag or (sides and not (req.side == "buy" and sides == ["sell"])):
87
  return False
88
  self.inflight.setdefault(req.mint, []).append(req.side)
89
- self.q.put(req)
 
 
90
  return True
91
 
92
  def pending(self) -> set[str]:
@@ -108,37 +159,44 @@ class ExecutionWorker:
108
  return out
109
 
110
  def _release(self, req: ExecRequest) -> None:
 
111
  with self.lock:
112
  sides = self.inflight.get(req.mint)
113
  if sides and req.side in sides:
114
  sides.remove(req.side)
115
  if not sides:
116
  self.inflight.pop(req.mint, None)
 
 
 
 
 
117
 
118
  def stop(self, timeout: float = STOP_JOIN_S) -> None:
119
- """Let the request in flight finish — a process killed mid-swap can leave a landed swap unbooked — drop the
120
- queued ones, and wait for the thread (at most ``timeout``)."""
121
- self.stop_flag = True
122
- while True:
123
- try:
124
- self._release(self.q.get_nowait())
125
- except queue.Empty:
126
- break
127
- if self.thread.is_alive() and self.thread is not threading.current_thread():
128
- self.thread.join(timeout)
129
- if self.thread.is_alive():
130
- log.error("execution worker: the request in flight did not finish within %.0f s of the stop", timeout)
 
 
131
 
132
  def _loop(self) -> None:
133
- while not self.stop_flag:
134
- try:
135
- req = self.q.get(timeout=1.0)
136
- except queue.Empty:
137
- continue
138
- if self.stop_flag: # stopping: a queued request is dropped, never started
139
- self._release(req)
140
- break
141
- with self.busy:
142
  res = self._execute(req)
143
  if res.ok and res.closed:
144
  self._emptied(req)
 
1
  """Execution queue for the live book: the caller never blocks on Jupiter.
2
 
3
+ The caller enqueues ExecRequest objects; ``threads`` background threads (config.EXEC_THREADS) execute them through
4
+ LiveBroker, several mints' swaps in flight at once and each mint's one at a time, in order (each fill is read from its
5
+ own transaction, execution/broker_live.py); results are drained by the caller, which
6
  opens/closes positions accordingly. A mint with a request queued or in flight takes no other, except a buy queued
7
  behind a lone sale of it (its paper book sold and bought the token again in one minute): the sale runs first, and the
8
+ buy is refused as held if the sale left the position open. ``busy`` is held (shared) from each swap to its booking, so
9
+ whoever reads the wallet and the books under it alone sees them agree. A sale that leaves the live book without its mint marks the mint's
10
  token account ``emptied``: the mirror closes it mid-minute with nothing queued (agent/fly_live.close_accounts), never in
11
+ front of an order, and books the rent it returns. ``stop`` lets the requests in flight finish (bounded) and drops the
12
  queued ones: exits are retried next minute, entries by the mirror (agent/fly_live.py).
13
  """
14
  from __future__ import annotations
 
16
  import logging
17
  import queue
18
  import threading
19
+ import time
20
  import traceback
21
+ from collections import deque
22
+ from contextlib import contextmanager
23
  from dataclasses import dataclass, field
24
  from datetime import datetime, timezone
25
 
 
68
  closed: bool = False # a sale that closed its position (or swept a written-off one)
69
 
70
 
71
+ class SharedLock:
72
+ """``busy``: every swap in flight holds it shared, any number at once (``shared``); a wealth mark or a token-account
73
+ close holds it alone (``acquire``/``release``, as a Lock), so none reads the wallet and the books while a swap is
74
+ between its fill and its booking. A swap waits only behind one holding it alone, never behind one waiting (live
75
+ money first)."""
76
+
77
+ def __init__(self):
78
+ self._cv = threading.Condition(); self._shared = 0; self._alone = False
79
+
80
+ def acquire(self, blocking: bool = True, timeout: float = -1) -> bool:
81
+ with self._cv:
82
+ free = lambda: not self._shared and not self._alone
83
+ if not (free() if not blocking else self._cv.wait_for(free, None if timeout is None or timeout < 0 else timeout)):
84
+ return False
85
+ self._alone = True
86
+ return True
87
+
88
+ def release(self) -> None:
89
+ with self._cv:
90
+ self._alone = False; self._cv.notify_all()
91
+
92
+ def __enter__(self):
93
+ self.acquire()
94
+ return self
95
+
96
+ def __exit__(self, *exc) -> None:
97
+ self.release()
98
+
99
+ @contextmanager
100
+ def shared(self):
101
+ with self._cv:
102
+ self._cv.wait_for(lambda: not self._alone)
103
+ self._shared += 1
104
+ try:
105
+ yield
106
+ finally:
107
+ with self._cv:
108
+ self._shared -= 1; self._cv.notify_all()
109
+
110
+
111
  class ExecutionWorker:
112
+ def __init__(self, broker, threads: int | None = None):
113
  self.broker = broker
 
114
  self.results: queue.Queue[ExecResult] = queue.Queue()
115
  self.inflight: dict[str, list[str]] = {} # mint -> sides queued or in flight, in order
116
  self.lock = threading.Lock()
117
+ self.cv = threading.Condition(self.lock)
118
+ self.waiting: dict[str, deque] = {} # mint -> its requests not started yet, in order
119
+ self.ready: deque[str] = deque() # mints whose next request may start (none of theirs in flight)
120
+ self.busy = SharedLock() # held (shared) from each swap to its booking
121
  self.stop_flag = False
122
  self.on_done = None # called with each result once booked (the mirror's live stats: agent/fly_live.py)
123
  self.emptied: set[str] = set() # mints whose sale left their token account empty: closed by the mirror, mid-minute
124
+ n = max(1, int(threads if threads is not None else config.EXEC_THREADS))
125
+ self.threads = [threading.Thread(target=self._loop, name="runner-exec" + (f"-{i}" if i else ""), daemon=True) for i in range(n)] # routed to the runner's log
126
+ self.thread = self.threads[0]
127
+ for t in self.threads:
128
+ t.start()
129
 
130
  def submit(self, req: ExecRequest) -> bool:
131
  """Queue ``req``; False when its mint already has a request queued or in flight (a buy may queue behind a lone
132
+ sale of it) or the worker is stopping. A mint with nothing queued or in flight starts at once on a free thread."""
133
  with self.lock:
134
  sides = self.inflight.get(req.mint)
135
  if self.stop_flag or (sides and not (req.side == "buy" and sides == ["sell"])):
136
  return False
137
  self.inflight.setdefault(req.mint, []).append(req.side)
138
+ self.waiting.setdefault(req.mint, deque()).append(req)
139
+ if not sides:
140
+ self.ready.append(req.mint); self.cv.notify()
141
  return True
142
 
143
  def pending(self) -> set[str]:
 
159
  return out
160
 
161
  def _release(self, req: ExecRequest) -> None:
162
+ """``req`` is done (or dropped): its mint's next request, if one is queued, may start."""
163
  with self.lock:
164
  sides = self.inflight.get(req.mint)
165
  if sides and req.side in sides:
166
  sides.remove(req.side)
167
  if not sides:
168
  self.inflight.pop(req.mint, None)
169
+ if self.waiting.get(req.mint):
170
+ if not self.stop_flag:
171
+ self.ready.append(req.mint); self.cv.notify()
172
+ else:
173
+ self.waiting.pop(req.mint, None)
174
 
175
  def stop(self, timeout: float = STOP_JOIN_S) -> None:
176
+ """Let the requests in flight finish — a process killed mid-swap can leave a landed swap unbooked — drop the
177
+ queued ones, and wait for the threads (at most ``timeout`` in all)."""
178
+ with self.lock:
179
+ self.stop_flag = True
180
+ dropped = [r for q in self.waiting.values() for r in q]
181
+ self.waiting.clear(); self.ready.clear(); self.cv.notify_all()
182
+ for r in dropped:
183
+ self._release(r)
184
+ deadline = time.monotonic() + timeout
185
+ others = [t for t in self.threads if t is not threading.current_thread()]
186
+ for t in others:
187
+ t.join(max(0.0, deadline - time.monotonic()))
188
+ if any(t.is_alive() for t in others):
189
+ log.error("execution worker: a request in flight did not finish within %.0f s of the stop", timeout)
190
 
191
  def _loop(self) -> None:
192
+ while True:
193
+ with self.lock:
194
+ while not self.stop_flag and not self.ready:
195
+ self.cv.wait(1.0)
196
+ if self.stop_flag: # stopping: a queued request is dropped, never started
197
+ return
198
+ mint = self.ready.popleft(); req = self.waiting[mint].popleft()
199
+ with self.busy.shared():
 
200
  res = self._execute(req)
201
  if res.ok and res.closed:
202
  self._emptied(req)
fly_trader/ingest/helius_feed.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Our own Solana feed for the live minutes: pump.fun and PumpSwap events from Helius's standard websockets, decoded into
2
+ the event dicts PumpAPI's stream sends, so ingest/pumpstream.Aggregator builds the same minutes training reads from
3
+ PumpAPI's archive (checked field by field against it on 2026-10-19).
4
+
5
+ - Transport: ``logsSubscribe`` (processed) on each program, one connection each. A transaction's logs carry its events
6
+ as ``Program data:`` lines, but Solana cuts logs at 10 KB: every migration and the wash bots' 4-swap transactions lose
7
+ events that way. A cut-off transaction is fetched whole (``getTransaction``, confirmed) and decoded from its inner
8
+ instructions (the events' self-CPI copies) instead; its logs are never used.
9
+ - PumpSwap trades name their pool, not their mints: ``Pools`` maps pool -> (base, quote) mint from the stream's recent
10
+ minutes, the pool creations seen, and the pool accounts themselves (``getMultipleAccounts``) for a pool first seen.
11
+ - PumpAPI's definitions (archive): a trade's SOL is the pool-side quote amount; ``quoteInPool`` and the price are the
12
+ pool's state after the transaction's last swap in that pool, the price counting the pool's virtual quote reserve;
13
+ the trader is the event's user; the timestamp is ``TS_OFFSET_S`` after the block's second (PumpAPI stamps on receipt);
14
+ a create's dev buy is its creator's buys in the same transaction and a mayhem-mode coin's supply counts the agent's
15
+ billion; a migration's ``quoteInPool`` is the pool's real quote after its boost (``InitBoostEvent``).
16
+ - ``pending``: per minute, transactions still being fetched or waiting for their pool: the minute's flush waits for them
17
+ (``Aggregator.flush_bound``), at most ``pumpstream.HOLD_MAX_S``.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import base64
23
+ import hashlib
24
+ import json
25
+ import logging
26
+ import ssl
27
+ import time
28
+ from collections import Counter
29
+
30
+ import base58
31
+ import certifi
32
+ import orjson
33
+ import websockets
34
+
35
+ from .. import config
36
+
37
+ log = logging.getLogger(__name__)
38
+ PUMP = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P" # pump.fun bonding curves
39
+ AMM = "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA" # PumpSwap
40
+ WSOL = "So11111111111111111111111111111111111111112"
41
+ TS_OFFSET_S = 0.9 # PumpAPI's stamp after the block's second (archive ts - blockTime: p05 0.79, p50 0.90, p95 1.12 s)
42
+ EVENT_IX_TAG = bytes.fromhex("e445a52e51cb9a1d") # Anchor's self-CPI event instruction
43
+ EVENTS = ("BuyEvent", "SellEvent", "CreatePoolEvent", "InitBoostEvent", "TradeEvent", "CreateEvent", "CompletePumpAmmMigrationEvent")
44
+ DISC = {hashlib.sha256(f"event:{n}".encode()).digest()[:8]: n for n in EVENTS}
45
+ BACKFILL_RETRY_S, BACKFILL_GIVE_UP_S, BACKFILL_BATCH = 0.3, 8.0, 25
46
+ RESOLVE_BATCH, STALE_S = 100, 5.0
47
+
48
+
49
+ def _u64(b: bytes, o: int) -> int:
50
+ return int.from_bytes(b[o:o + 8], "little")
51
+
52
+
53
+ def _pk(b: bytes, o: int) -> str:
54
+ return base58.b58encode(b[o:o + 32]).decode()
55
+
56
+
57
+ def _str(b: bytes, o: int) -> tuple[str, int]:
58
+ n = int.from_bytes(b[o:o + 4], "little")
59
+ return b[o + 4:o + 4 + n].decode("utf-8", "replace").replace("\x00", ""), o + 4 + n
60
+
61
+
62
+ def log_events(logs: list[str]) -> list[tuple[str, bytes]]:
63
+ """(event name, payload) of every known event in a transaction's logs, in order."""
64
+ out = []
65
+ for line in logs or ():
66
+ if line.startswith("Program data: "):
67
+ try:
68
+ b = base64.b64decode(line[14:])
69
+ except ValueError:
70
+ continue
71
+ n = DISC.get(b[:8])
72
+ if n:
73
+ out.append((n, b[8:]))
74
+ return out
75
+
76
+
77
+ def cpi_events(tx: dict) -> list[tuple[str, bytes]]:
78
+ """The same from a whole transaction (getTransaction, json): the events' self-CPI copies in its inner instructions,
79
+ which the 10 KB log limit never cuts."""
80
+ meta = tx.get("meta") or {}
81
+ la = meta.get("loadedAddresses") or {}
82
+ keys = list(tx["transaction"]["message"]["accountKeys"]) + list(la.get("writable") or []) + list(la.get("readonly") or [])
83
+ out = []
84
+ for grp in sorted(meta.get("innerInstructions") or [], key=lambda g: g.get("index", 0)):
85
+ for ix in grp.get("instructions") or []:
86
+ if keys[ix["programIdIndex"]] not in (PUMP, AMM):
87
+ continue
88
+ d = base58.b58decode(ix["data"])
89
+ if d[:8] == EVENT_IX_TAG:
90
+ n = DISC.get(d[8:16])
91
+ if n:
92
+ out.append((n, d[16:]))
93
+ return out
94
+
95
+
96
+ def amm_trade(name: str, p: bytes) -> dict:
97
+ """A PumpSwap Buy/SellEvent: the fields PumpAPI's trade needs (raw units). The virtual quote reserve sits after the
98
+ buy's variable-length instruction name; a sale has none."""
99
+ if name == "SellEvent":
100
+ vq_at = 384
101
+ else:
102
+ vq_at = 397 + int.from_bytes(p[393:397], "little") + 32
103
+ vq = int.from_bytes(p[vq_at:vq_at + 16], "little", signed=True) if len(p) >= vq_at + 16 else 0
104
+ return {"side": "buy" if name == "BuyEvent" else "sell", "ts": _u64(p, 0), "base": _u64(p, 8), "pbr": _u64(p, 40), "pqr": _u64(p, 48),
105
+ "q": _u64(p, 56), "fee_bps": _u64(p, 64) + _u64(p, 80) + _u64(p, 336), "pool": _pk(p, 112), "user": _pk(p, 144), "vq": max(vq, 0)}
106
+
107
+
108
+ def curve_trade(p: bytes) -> dict:
109
+ return {"mint": _pk(p, 0), "sol": _u64(p, 32), "token": _u64(p, 40), "buy": bool(p[48]), "user": _pk(p, 49), "ts": _u64(p, 81)}
110
+
111
+
112
+ def create(p: bytes) -> dict:
113
+ name, o = _str(p, 0); symbol, o = _str(p, o); uri, o = _str(p, o)
114
+ return {"name": name, "symbol": symbol, "uri": uri, "mint": _pk(p, o), "user": _pk(p, o + 64), "creator": _pk(p, o + 96),
115
+ "ts": _u64(p, o + 128), "supply": _u64(p, o + 160), "mayhem": bool(p[o + 200]) if len(p) > o + 200 else False}
116
+
117
+
118
+ def create_pool(p: bytes) -> dict:
119
+ return {"ts": _u64(p, 0), "creator": _pk(p, 10), "base": _pk(p, 42), "quote": _pk(p, 74), "pool_quote": _u64(p, 132), "pool": _pk(p, 165)}
120
+
121
+
122
+ def migration(p: bytes) -> dict:
123
+ return {"user": _pk(p, 0), "mint": _pk(p, 32), "sol": _u64(p, 72), "ts": _u64(p, 120), "pool": _pk(p, 128)}
124
+
125
+
126
+ def init_boost(p: bytes) -> dict:
127
+ return {"ts": _u64(p, 0), "mint": _pk(p, 8), "pool": _pk(p, 72), "real_quote_after": _u64(p, 120)}
128
+
129
+
130
+ def _ms(ts: int) -> float:
131
+ return (ts + TS_OFFSET_S) * 1000.0
132
+
133
+
134
+ def build(sig: str, slot: int, evs: list[tuple[str, bytes]], pools: dict, payer: str | None = None, pumpfun=None) -> tuple[list[dict], set]:
135
+ """PumpAPI-shaped events of one transaction, and the PumpSwap pools whose mints are not known yet (then nothing is
136
+ built: the caller resolves them and builds again)."""
137
+ trades, curve, creates, cpools = [], [], [], []
138
+ mig = boost = None
139
+ for n, p in evs:
140
+ try:
141
+ if n in ("BuyEvent", "SellEvent"):
142
+ trades.append(amm_trade(n, p))
143
+ elif n == "TradeEvent":
144
+ curve.append(curve_trade(p))
145
+ elif n == "CreateEvent":
146
+ creates.append(create(p))
147
+ elif n == "CreatePoolEvent":
148
+ cpools.append(create_pool(p))
149
+ elif n == "CompletePumpAmmMigrationEvent":
150
+ mig = migration(p)
151
+ elif n == "InitBoostEvent":
152
+ boost = init_boost(p)
153
+ except (IndexError, ValueError):
154
+ log.warning("undecodable %s in %s", n, sig[:12])
155
+ for c in cpools: # a pool created here names its mints for the trades after it
156
+ pools[c["pool"]] = (c["base"], c["quote"])
157
+ unknown = {t["pool"] for t in trades if t["pool"] not in pools}
158
+ if unknown:
159
+ return [], unknown
160
+ out: list[dict] = []
161
+ for c in creates: # the create first: the curve state its dev buy lands in
162
+ dev = [t for t in curve if t["mint"] == c["mint"] and t["buy"] and t["user"] in (c["user"], c["creator"])]
163
+ out.append({"action": "create", "pool": "pump", "mint": c["mint"], "timestamp": _ms(c["ts"]), "txSigner": c["user"],
164
+ "quoteAmount": sum(t["sol"] for t in dev) / 1e9, "initialBuy": sum(t["token"] for t in dev) / 1e6,
165
+ "supply": c["supply"] / 1e6 * (2 if c["mayhem"] else 1), "mayhemMode": c["mayhem"], "name": c["name"], "symbol": c["symbol"],
166
+ "uri": c["uri"], "signature": sig, "block": slot})
167
+ by_mint: dict[str, list] = {}
168
+ for t in curve:
169
+ by_mint.setdefault(t["mint"], []).append(t)
170
+ for mint, legs in by_mint.items():
171
+ out.append({"action": "buy" if legs[0]["buy"] else "sell", "pool": "pump", "mint": mint, "timestamp": _ms(legs[0]["ts"]), "block": slot,
172
+ "txSigner": legs[0]["user"], "tokenAmount": legs[0]["token"] / 1e6, "signature": sig,
173
+ "breakdown": [{"action": "buy" if t["buy"] else "sell", "trader": t["user"], "tokenAmount": t["token"] / 1e6} for t in legs]})
174
+ by_pool: dict[str, list] = {}
175
+ for t in trades:
176
+ by_pool.setdefault(t["pool"], []).append(t)
177
+ for pool, legs in by_pool.items():
178
+ base, quote = pools[pool]
179
+ last = legs[-1]; sgn = 1 if last["side"] == "buy" else -1
180
+ post_q, post_b = last["pqr"] + sgn * last["q"], last["pbr"] - sgn * last["base"]
181
+ dec = 6 if (pumpfun(base) if pumpfun is not None else base.endswith("pump")) else None # only pump.fun coins (6 decimals) are priced: the minutes keep no other
182
+ out.append({"action": legs[0]["side"], "pool": "pump-amm", "mint": base, "quoteMint": quote, "poolId": pool, "timestamp": _ms(legs[0]["ts"]),
183
+ "price": ((post_q + last["vq"]) / 1e9) / (post_b / 10 ** dec) if dec is not None and post_b > 0 else None,
184
+ "quoteInPool": post_q / 1e9, "quoteAmount": legs[0]["q"] / 1e9, "txSigner": legs[0]["user"], "poolFeeRate": last["fee_bps"] / 1e4,
185
+ "signature": sig, "block": slot,
186
+ "breakdown": [{"action": t["side"], "trader": t["user"], "quoteAmount": t["q"] / 1e9} for t in legs]})
187
+ for c in cpools:
188
+ if mig is not None and mig["pool"] == c["pool"]:
189
+ out.append({"action": "migrate", "pool": "pump-amm", "mint": mig["mint"], "timestamp": _ms(mig["ts"]), "signature": sig, "poolId": c["pool"],
190
+ "txSigner": payer or mig["user"], "block": slot,
191
+ "quoteInPool": (boost["real_quote_after"] if boost and boost["pool"] == c["pool"] else c["pool_quote"]) / 1e9})
192
+ else: # created directly, not by a pump.fun migration: the owner can pull it
193
+ out.append({"action": "createPool", "pool": "pump-amm", "poolId": c["pool"], "mint": c["base"], "poolCreatedBy": c["creator"],
194
+ "timestamp": _ms(c["ts"]), "signature": sig})
195
+ return out, set()
196
+
197
+
198
+ def minute_of(evs: list[tuple[str, bytes]]) -> int | None:
199
+ """The minute (start, epoch s) a transaction's events are stamped in, from the first event carrying a timestamp."""
200
+ for n, p in evs:
201
+ try:
202
+ ts = {"BuyEvent": 0, "SellEvent": 0, "CreatePoolEvent": 0, "InitBoostEvent": 0}.get(n)
203
+ t = _u64(p, ts) if ts is not None else curve_trade(p)["ts"] if n == "TradeEvent" else None
204
+ except (IndexError, ValueError):
205
+ t = None
206
+ if t:
207
+ return int((t + TS_OFFSET_S) // 60) * 60
208
+ return None
209
+
210
+
211
+ class Pools:
212
+ """pool -> (base mint, quote mint) of PumpSwap pools: seeded from the stream's recent minutes, learned from pool
213
+ creations, else read from the pool account (``base_mint`` at byte 43, ``quote_mint`` at 75)."""
214
+
215
+ def __init__(self):
216
+ self.map: dict[str, tuple[str, str]] = {}
217
+
218
+ def seed(self) -> int:
219
+ from ..db.connection import transaction
220
+ try:
221
+ with transaction() as conn:
222
+ rows = conn.execute("SELECT DISTINCT ON (pool_id) pool_id, mint FROM pump_minutes WHERE pool_id IS NOT NULL AND ts > now() - interval '3 days'").fetchall()
223
+ except Exception:
224
+ log.exception("pool map seed failed; pools are read as they come")
225
+ return 0
226
+ for r in rows:
227
+ self.map.setdefault(r["pool_id"], (r["mint"], WSOL))
228
+ return len(rows)
229
+
230
+ def resolve(self, rpc, pools: list[str]) -> int:
231
+ """Read unknown pool accounts (``rpc``: chain/rpc.HttpSolanaRpc); a pool that is not a PumpSwap pool maps to
232
+ ('', '') so its trades are dropped."""
233
+ n = 0
234
+ for i in range(0, len(pools), RESOLVE_BATCH):
235
+ chunk = pools[i:i + RESOLVE_BATCH]
236
+ v = (rpc.call("getMultipleAccounts", [chunk, {"encoding": "base64", "commitment": "processed"}]) or {}).get("value") or []
237
+ for pool, acc in zip(chunk, v):
238
+ if acc and acc.get("owner") == AMM:
239
+ d = base64.b64decode(acc["data"][0])
240
+ self.map[pool] = (_pk(d, 43), _pk(d, 75)) if len(d) >= 107 else ("", "")
241
+ else:
242
+ self.map[pool] = ("", "")
243
+ n += 1
244
+ return n
245
+
246
+
247
+ class HeliusFeed:
248
+ """Both programs' logs on their own connections; decoded transactions go to ``emit(events, source='helius')``, each
249
+ once (a migration comes on both). ``wm``: the newest PumpSwap event stamp (epoch s) — the minutes' clock while the
250
+ feed is up (``up``), ``grace_s`` back."""
251
+ source = "helius"
252
+ grace_s = 0.5 # its stamps are its blocks' seconds (+TS_OFFSET_S) in slot order: a minute is whole at the next second's first event
253
+
254
+ def __init__(self, emit, rpc=None, pumpfun=None):
255
+ """``pumpfun``: mint → is a pump.fun coin (pumpstream.Aggregator.pumpfun: the suffix, or a graduate without it)."""
256
+ self.emit, self.pools, self.pumpfun = emit, Pools(), pumpfun
257
+ self._rpc = rpc
258
+ self.delivered: dict[str, float] = {} # signature -> when it went out
259
+ self.wm = 0.0; self.last_amm = 0.0; self.connected: dict[str, bool] = {AMM: False, PUMP: False}
260
+ self.pending: Counter = Counter() # minute start -> transactions of it still on their way
261
+ self.fetching: dict[str, dict] = {} # signature -> {slot, minute, first, n}: cut-off transactions to fetch
262
+ self.waiting: list[dict] = [] # transactions waiting for their pools' mints
263
+ self.stats = Counter()
264
+
265
+ @property
266
+ def rpc(self):
267
+ if self._rpc is None:
268
+ from ..chain.rpc import HttpSolanaRpc
269
+ self._rpc = HttpSolanaRpc()
270
+ return self._rpc
271
+
272
+ def up(self, now: float | None = None) -> bool:
273
+ now = time.time() if now is None else now
274
+ return self.connected.get(AMM, False) and now - self.last_amm < STALE_S
275
+
276
+ # ---- one transaction ----
277
+ def _deliver(self, sig: str, slot: int, evs: list, payer: str | None, minute: int | None) -> bool:
278
+ """Build and emit, once per transaction; False (kept back) while a pool's mints are unknown."""
279
+ if sig in self.delivered:
280
+ return True
281
+ out, unknown = build(sig, slot, evs, self.pools.map, payer, self.pumpfun)
282
+ if unknown:
283
+ return False
284
+ now = time.time(); self.delivered[sig] = now
285
+ if len(self.delivered) > 200_000:
286
+ self.delivered = {s: t for s, t in self.delivered.items() if now - t < 600}
287
+ if out:
288
+ self.emit(out, "helius")
289
+ for e in out:
290
+ if e["pool"] == "pump-amm" and e["action"] in ("buy", "sell"):
291
+ self.wm = max(self.wm, e["timestamp"] / 1000.0)
292
+ return True
293
+
294
+ def on_logs(self, program: str, msg: dict) -> None:
295
+ r = msg["params"]["result"]; v = r["value"]; slot = int(r["context"]["slot"])
296
+ if program == AMM:
297
+ self.last_amm = time.time()
298
+ self.stats["msgs"] += 1
299
+ if v.get("err") is not None:
300
+ return
301
+ logs = v.get("logs") or []; sig = v["signature"]
302
+ evs = log_events(logs)
303
+ if any("Log truncated" in line for line in logs): # its events are fetched whole: the logs lost some
304
+ if sig not in self.fetching:
305
+ m = minute_of(evs)
306
+ self.fetching[sig] = {"slot": slot, "minute": m, "first": time.time(), "n": 0}
307
+ if m is not None:
308
+ self.pending[m] += 1
309
+ self.stats["truncated"] += 1
310
+ return
311
+ if not evs:
312
+ return
313
+ m = minute_of(evs)
314
+ if not self._deliver(sig, slot, evs, None, m):
315
+ self.waiting.append({"sig": sig, "slot": slot, "evs": evs, "payer": None, "minute": m})
316
+ if m is not None:
317
+ self.pending[m] += 1
318
+
319
+ # ---- the workers ----
320
+ async def _fetch_loop(self, stop) -> None:
321
+ while not (stop is not None and stop.is_set()):
322
+ await asyncio.sleep(BACKFILL_RETRY_S)
323
+ await self._fetch_round()
324
+
325
+ async def _fetch_round(self) -> None:
326
+ """Cut-off transactions, fetched whole in batches once confirmed; given up ``BACKFILL_GIVE_UP_S`` after first seen
327
+ (a processed transaction whose fork was dropped never confirms)."""
328
+ todo = list(self.fetching.items())[:BACKFILL_BATCH]
329
+ if not todo:
330
+ return
331
+ calls = [("getTransaction", [s, {"encoding": "json", "maxSupportedTransactionVersion": 1, "commitment": "confirmed"}]) for s, _ in todo]
332
+ try:
333
+ res = await asyncio.to_thread(self.rpc.batch, calls)
334
+ except Exception as e:
335
+ self.stats["fetch_errors"] += 1
336
+ log.warning("helius backfill batch failed: %s", type(e).__name__)
337
+ res = [None] * len(todo)
338
+ now = time.time()
339
+ for (sig, f), tx in zip(todo, res):
340
+ if tx:
341
+ self.fetching.pop(sig, None)
342
+ evs = cpi_events(tx); payer = tx["transaction"]["message"]["accountKeys"][0]
343
+ m = f["minute"] if f["minute"] is not None else minute_of(evs)
344
+ if f["minute"] is not None:
345
+ self.pending[f["minute"]] -= 1
346
+ if evs and not self._deliver(sig, int(tx.get("slot") or f["slot"]), evs, payer, m):
347
+ self.waiting.append({"sig": sig, "slot": int(tx.get("slot") or f["slot"]), "evs": evs, "payer": payer, "minute": m})
348
+ if m is not None:
349
+ self.pending[m] += 1
350
+ self.stats["backfilled"] += 1
351
+ elif now - f["first"] > BACKFILL_GIVE_UP_S:
352
+ self.fetching.pop(sig, None)
353
+ if f["minute"] is not None:
354
+ self.pending[f["minute"]] -= 1
355
+ self.stats["backfill_lost"] += 1
356
+ self._tidy()
357
+
358
+ async def _resolve_loop(self, stop) -> None:
359
+ while not (stop is not None and stop.is_set()):
360
+ await asyncio.sleep(0.05)
361
+ await self._resolve_round()
362
+
363
+ async def _resolve_round(self) -> None:
364
+ """Transactions waiting for their pools' mints: the unknown pools read in one batch, then built again."""
365
+ if not self.waiting:
366
+ return
367
+ need = sorted({t["pool"] for w in self.waiting for n, p in w["evs"] if n in ("BuyEvent", "SellEvent")
368
+ for t in (amm_trade(n, p),) if t["pool"] not in self.pools.map})
369
+ if need:
370
+ try:
371
+ self.stats["pools_read"] += await asyncio.to_thread(self.pools.resolve, self.rpc, need)
372
+ except Exception as e:
373
+ self.stats["resolve_errors"] += 1
374
+ log.warning("helius pool read failed: %s", type(e).__name__)
375
+ return
376
+ still = []
377
+ for w in self.waiting:
378
+ if self._deliver(w["sig"], w["slot"], w["evs"], w["payer"], w["minute"]):
379
+ if w["minute"] is not None:
380
+ self.pending[w["minute"]] -= 1
381
+ else:
382
+ still.append(w)
383
+ self.waiting = still
384
+ self._tidy()
385
+
386
+ def _tidy(self) -> None:
387
+ for m in [m for m, n in self.pending.items() if n <= 0]:
388
+ del self.pending[m]
389
+
390
+ async def _connection(self, program: str, stop) -> None:
391
+ ctx = ssl.create_default_context(cafile=certifi.where()); backoff = 0.5
392
+ while not (stop is not None and stop.is_set()):
393
+ try:
394
+ async with websockets.connect(config.helius_wss_url(), ssl=ctx, max_size=16_000_000, ping_interval=20, ping_timeout=20) as ws:
395
+ await ws.send(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "logsSubscribe",
396
+ "params": [{"mentions": [program]}, {"commitment": config.HELIUS_FEED_COMMITMENT}]}))
397
+ log.info("helius feed: subscribed to %s logs", program[:4]); backoff = 0.5
398
+ while not (stop is not None and stop.is_set()):
399
+ raw = await asyncio.wait_for(ws.recv(), timeout=30)
400
+ self.stats["bytes"] += len(raw)
401
+ m = orjson.loads(raw)
402
+ if m.get("method") == "logsNotification":
403
+ self.connected[program] = True
404
+ try:
405
+ self.on_logs(program, m)
406
+ except Exception:
407
+ self.stats["decode_errors"] += 1
408
+ log.exception("helius feed: a %s notification failed to decode", program[:4])
409
+ elif "error" in m:
410
+ raise RuntimeError(f"logsSubscribe refused: {m['error']}")
411
+ except Exception as e:
412
+ self.stats["reconnects"] += 1
413
+ log.warning("helius feed (%s): %s; reconnecting in %.1fs", program[:4], str(e)[:200], backoff)
414
+ finally:
415
+ self.connected[program] = False
416
+ if stop is not None and stop.is_set():
417
+ break
418
+ await asyncio.sleep(backoff); backoff = min(10.0, backoff * 2)
419
+
420
+ async def run(self, stop) -> None:
421
+ n = await asyncio.to_thread(self.pools.seed)
422
+ log.info("helius feed: %d pools known from the stream's minutes", n)
423
+ await asyncio.gather(self._connection(AMM, stop), self._connection(PUMP, stop), self._fetch_loop(stop), self._resolve_loop(stop))
424
+
425
+ def status(self) -> dict:
426
+ return {**{k: int(v) for k, v in self.stats.items()}, "up": self.up(), "connected": {k[:4]: v for k, v in self.connected.items()},
427
+ "fetching": len(self.fetching), "waiting": len(self.waiting), "pools": len(self.pools.map), "watermark": self.wm,
428
+ "lag_s": round(time.time() - self.wm, 2) if self.wm else None}
fly_trader/ingest/pumpstream.py CHANGED
@@ -1,8 +1,15 @@
1
- """PumpAPI live stream → per-minute rich candles (Supervisor thread ``pumpstream``).
2
 
3
  ``wss://stream.pumpapi.io`` is a free, keyless firehose (~400–800 events/s, one connection per IP) whose events are
4
- identical to the replay archive. This worker keeps only what the selector needs: PumpSwap (``pump-amm``, SOL-quoted)
5
- buys and sells of pump.fun-origin mints, aggregated per (mint, minute) into the same fields ``train/mature.py``
 
 
 
 
 
 
 
6
  derives from the archive, with the same filters: legs in a pool created directly rather than by a pump.fun migration
7
  (``createPool`` with ``poolCreatedBy`` other than ``pump``; the owner can pull its unburned liquidity) are dropped — such pools
8
  go to ``pump_pools`` as they are created, and the blocked set is reloaded from that table (archive backfill included) at
@@ -16,7 +23,9 @@ publishes the newest complete minute for the engine the moment it is written (th
16
  Lifecycle: ``create`` events go to ``pump_events`` as they arrive; at ``migrate`` a ``corpus_meta`` row is written
17
  with the creation facts and the creator's point-in-time history (``train/corpus_meta.creator_history``, the training
18
  definition); 61 minutes after a graduation its own 60-minute outcome is filled from the stream's minutes so later
19
- graduations by the same creator see it, as in training. Minute rows older than ``PUMP_MINUTES_KEEP_DAYS`` are archived
 
 
20
  to ``data/corpus/stream/<day>.parquet`` before removal. Database failures keep the data in memory for the next attempt.
21
  """
22
  from __future__ import annotations
@@ -42,7 +51,7 @@ from .. import config
42
  from ..db.apilog import record_event
43
  from ..db.connection import transaction
44
  from ..logging_setup import setup
45
- from ..train.corpus_meta import CURVE_COLS, blocked_pool_ids, creator_history, curve_facts
46
  from ..train.flow import minute_wallet_cols
47
 
48
  log = logging.getLogger(__name__)
@@ -51,8 +60,11 @@ RESQ_BAND = (0.001, 100000.0) # train/mature.py: quote_in_pool BETWEEN 0.001
51
  PRICE_BAND = 50.0 # train/mature.py: within 50x of the trailing median of the mint's previous 200 legs
52
  REF_LEGS = 200
53
  FLUSH_GRACE_S = 2.0
54
- FLUSH_CHECK_S = 0.25 # how often complete minutes are looked for: a written minute is published at once (live money first)
55
  FLUSH_FALLBACK_S = 15.0
 
 
 
56
  MAX_PENDING_CREATES = 200_000
57
 
58
 
@@ -79,19 +91,66 @@ class Aggregator:
79
  self.curve: dict[str, dict] = {} # mint → bonding-curve wallets since its create (corpus_meta.curve_facts)
80
  self.pending_creates: list[tuple[str, dict]] = [] # creates not yet written to pump_events
81
  self.blocked: set[str] = set() # pool ids of directly created (custom) PumpSwap pools
 
 
82
  self.pending_pools: list[tuple] = [] # custom pools not yet written to pump_pools
83
  self.flushed_through: datetime | None = None # start of the newest complete minute written
84
  self.insiders: dict[str, frozenset] = {} # mint → creator/bundle/early wallets (token_insiders)
85
  self.skill: dict | None = None; self.skill_day: str | None = None # wallet → skill decile for the current UTC day (train/wallet_skill.py)
86
  self.stats = {"events": 0, "trades": 0, "dropped_band": 0, "dropped_late": 0, "dropped_custom_pool": 0, "flushed_minutes": 0, "flushed_rows": 0, "creates": 0, "migrates": 0,
87
- "custom_pools": 0, "blocked_pools": 0, "reconnects": 0, "outcomes_filled": 0, "started_at": datetime.now(timezone.utc).isoformat()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  def row(self, minute: int, mint: str) -> Minute | None:
90
  """The minute's candle for a mint: the pool with the most legs (as in training)."""
91
  pools = self.minutes.get(minute, {}).get(mint)
92
  return max(pools.values(), key=lambda r: (r.nb + r.ns, r.buy + r.sell)) if pools else None
93
 
94
- def ingest(self, e: dict) -> None:
 
 
 
 
95
  self.stats["events"] += 1
96
  a = e.get("action"); pool = e.get("pool"); mint = e.get("mint"); ts = e.get("timestamp")
97
  if a == "createPool" and pool == "pump-amm" and (e.get("poolCreatedBy") or "custom") != "pump":
@@ -106,7 +165,13 @@ class Aggregator:
106
  self._curve(e)
107
  return
108
  if a in ("buy", "sell"):
109
- if pool != "pump-amm" or e.get("quoteMint") != WSOL or not str(mint).endswith("pump"):
 
 
 
 
 
 
110
  return
111
  if e.get("poolId") in self.blocked:
112
  self.stats["dropped_custom_pool"] += 1
@@ -115,6 +180,8 @@ class Aggregator:
115
  if not price or price <= 0 or q is None or not (RESQ_BAND[0] <= float(q) <= RESQ_BAND[1]):
116
  return
117
  ts_s = ts / 1000.0; self.max_event_s = max(self.max_event_s, ts_s); self.last_event_wall = time.time()
 
 
118
  day = int(ts_s // 86400)
119
  if self.ref_day is None or day > self.ref_day: # a late event from the previous day never wipes today's references
120
  self.ref = {}; self.ref_day = day
@@ -166,6 +233,8 @@ class Aggregator:
166
  self.curve = {k: v for k, v in self.curve.items() if v["ts"] >= cutoff}
167
  elif a == "migrate" and pool == "pump-amm":
168
  self.stats["migrates"] += 1
 
 
169
  self.write_creates() # the creator's own earlier creates must be visible to the history query
170
  self._graduation(e)
171
 
@@ -205,6 +274,19 @@ class Aggregator:
205
  def block(self, pool_ids) -> None:
206
  self.blocked.update(pool_ids); self.stats["blocked_pools"] = len(self.blocked)
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  def write_pools(self) -> int:
209
  """Persist custom pools seen on the stream (training excludes the same pools); on failure they stay queued."""
210
  rows, self.pending_pools = self.pending_pools, []
@@ -233,8 +315,8 @@ class Aggregator:
233
  ct = datetime.fromtimestamp(c["ts"] / 1000, timezone.utc) if c else None
234
  h = creator_history(conn, c.get("creator") if c else None, ct, g)
235
  conn.execute("""INSERT INTO corpus_meta (mint, graduated_at, create_ts, creator, dev_sol, dev_tokens, dev_share, supply, mayhem, uri, name, symbol, ttg_min, rq0, pool_id,
236
- prior_launches, prior_grads, prior_known, prior_rug_share, prior_moon_share)
237
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (mint) DO UPDATE SET
238
  graduated_at = COALESCE(corpus_meta.graduated_at, EXCLUDED.graduated_at),
239
  create_ts = COALESCE(corpus_meta.create_ts, EXCLUDED.create_ts), creator = COALESCE(corpus_meta.creator, EXCLUDED.creator),
240
  dev_sol = COALESCE(corpus_meta.dev_sol, EXCLUDED.dev_sol), dev_tokens = COALESCE(corpus_meta.dev_tokens, EXCLUDED.dev_tokens),
@@ -244,12 +326,13 @@ class Aggregator:
244
  ttg_min = COALESCE(corpus_meta.ttg_min, EXCLUDED.ttg_min), rq0 = COALESCE(corpus_meta.rq0, EXCLUDED.rq0), pool_id = COALESCE(corpus_meta.pool_id, EXCLUDED.pool_id),
245
  prior_launches = COALESCE(corpus_meta.prior_launches, EXCLUDED.prior_launches), prior_grads = COALESCE(corpus_meta.prior_grads, EXCLUDED.prior_grads),
246
  prior_known = COALESCE(corpus_meta.prior_known, EXCLUDED.prior_known), prior_rug_share = COALESCE(corpus_meta.prior_rug_share, EXCLUDED.prior_rug_share),
247
- prior_moon_share = COALESCE(corpus_meta.prior_moon_share, EXCLUDED.prior_moon_share), updated_at = now()""",
 
248
  (mint, g, ct, c.get("creator") if c else None, c.get("dev_sol") if c else None, c.get("dev_tokens") if c else None,
249
  (float(c["dev_tokens"]) / float(c["supply"])) if c and c.get("dev_tokens") and c.get("supply") else None,
250
  c.get("supply") if c else None, c.get("mayhem") if c else None, c.get("uri") if c else None, c.get("name") if c else None, c.get("symbol") if c else None,
251
  ((e["timestamp"] - c["ts"]) / 60000.0) if c else None, e.get("quoteInPool"), e.get("poolId"),
252
- h["prior_launches"], h["prior_grads"], h["prior_known"], h["prior_rug_share"], h["prior_moon_share"]))
253
  conn.execute("INSERT INTO corpus_tokens (mint, graduated_at, grad_slot, source, status) VALUES (%s,%s,%s,'stream','pending') ON CONFLICT (mint) DO NOTHING",
254
  (mint, g, int(e.get("block") or 0)))
255
  st = self.curve.pop(mint, None)
@@ -296,13 +379,28 @@ class Aggregator:
296
  return len(rows)
297
 
298
  def flush_bound(self, now: float) -> int:
299
- """Minutes strictly before this start are complete: event-time watermark, or the wall-clock fallback once no trade
300
- has arrived for ``FLUSH_FALLBACK_S`` (a lagging stream still delivers the minute's events, so it waits for them)."""
 
 
 
301
  cur = int(now // 60) * 60
302
- wm = int((self.max_event_s - FLUSH_GRACE_S) // 60) * 60
 
 
 
 
303
  if now - self.last_event_wall >= FLUSH_FALLBACK_S:
304
  wm = max(wm, int((now - FLUSH_FALLBACK_S) // 60) * 60)
305
- return min(cur, wm)
 
 
 
 
 
 
 
 
306
 
307
 
308
  def fill_outcomes() -> int:
@@ -403,82 +501,120 @@ def load_skill(agg: Aggregator, now: float | None = None) -> None:
403
 
404
 
405
  def _load_blocked(agg: Aggregator) -> None:
406
- """Union the custom pools in ``pump_pools`` (stream rows and the archive backfill) into the blocked set."""
 
407
  try:
408
  with transaction() as conn:
409
  agg.block(blocked_pool_ids(conn))
 
410
  except Exception:
411
  log.exception("custom pool load failed; retried in 5 min")
412
 
413
 
414
- async def _run(stop: threading.Event | None, agg: Aggregator) -> None:
 
415
  ctx = ssl.create_default_context(cafile=certifi.where()); backoff = 1.0
416
- _load_blocked(agg); last_pools = time.time(); last_ins = 0.0
417
- try:
418
- load_skill(agg)
419
- except Exception:
420
- log.exception("wallet skill load failed; retried in 5 min")
421
- last_status = last_flush = last_outcomes = 0.0; last_archive = time.time() - 3000; last_msg = 0.0; rate_n = 0; rate_t = time.time()
422
- bg: dict[str, asyncio.Task | None] = {"archive": None, "outcomes": None}
423
  while not (stop is not None and stop.is_set()):
424
  try:
425
  async with websockets.connect(config.PUMPSTREAM_URL, ssl=ctx, max_size=8_000_000, ping_interval=20, ping_timeout=20) as ws:
426
- log.info("pumpstream connected"); backoff = 1.0
427
  while not (stop is not None and stop.is_set()):
428
  try:
429
  msg = await asyncio.wait_for(ws.recv(), timeout=30)
430
  except asyncio.TimeoutError:
431
  log.warning("pumpstream: no events for 30 s; reconnecting"); break
432
- last_msg = time.time(); rate_n += 1
433
  try:
434
  e = orjson.loads(msg)
435
  except orjson.JSONDecodeError:
436
  continue
437
  if isinstance(e, dict):
438
- agg.ingest(e)
439
- now = time.time()
440
- if now - last_flush >= FLUSH_CHECK_S:
441
- before = agg.flushed_through
442
- try:
443
- agg.flush(agg.flush_bound(now))
444
- except Exception:
445
- log.exception("minute flush failed; rows kept for the next attempt")
446
- last_flush = now
447
- if agg.flushed_through != before:
448
- last_status = 0.0 # a minute was written: the engine hears of it now, not at the next 5 s status
449
- if now - last_pools >= 300:
450
- _load_blocked(agg); last_pools = now
451
- try:
452
- load_skill(agg, now)
453
- except Exception:
454
- log.exception("wallet skill load failed; retried in 5 min")
455
- if now - last_ins >= 60:
456
- _load_insiders(agg); last_ins = now
457
- if now - last_status >= 5.0:
458
- agg.write_creates(); agg.write_pools()
459
- _status(agg, {"events_per_s": rate_n / max(now - rate_t, 1e-9), "pending_minutes": len(agg.minutes), "lag_s": now - last_msg, "connected": True,
460
- "flushed_through": agg.flushed_through.isoformat() if agg.flushed_through else None,
461
- "event_watermark": datetime.fromtimestamp(agg.max_event_s, timezone.utc).isoformat() if agg.max_event_s else None})
462
- rate_n = 0; rate_t = now; last_status = now
463
- if now - last_outcomes >= 60 and (bg["outcomes"] is None or bg["outcomes"].done()):
464
- bg["outcomes"] = asyncio.create_task(asyncio.to_thread(_logged, "stream outcomes filled", fill_outcomes)); last_outcomes = now
465
- if now - last_archive >= 3600 and (bg["archive"] is None or bg["archive"].done()):
466
- bg["archive"] = asyncio.create_task(asyncio.to_thread(_logged, "archived old minute rows", archive_old, config.PUMP_MINUTES_KEEP_DAYS)); last_archive = now
467
  except Exception as e:
468
  agg.stats["reconnects"] += 1; log.warning("pumpstream: %s; reconnecting in %.0fs", e, backoff)
469
- _status(agg, {"connected": False, "last_error": str(e)[:200]})
470
  await asyncio.sleep(backoff); backoff = min(60.0, backoff * 2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  for t in bg.values():
472
  if t is not None:
473
  try:
474
  await t
475
  except Exception:
476
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  try:
478
  agg.flush(int(time.time() // 60) * 60 + 60)
479
  except Exception:
480
  log.exception("final minute flush failed")
481
- agg.write_creates(); agg.write_pools()
482
 
483
 
484
  def main(stop_event: threading.Event | None = None) -> None:
 
1
+ """PumpAPI live stream and our own feed → per-minute rich candles (Supervisor thread ``pumpstream``).
2
 
3
  ``wss://stream.pumpapi.io`` is a free, keyless firehose (~400–800 events/s, one connection per IP) whose events are
4
+ identical to the replay archive. From Asia it falls behind and reconnects about once a minute, losing ~40 % of the legs
5
+ (2026-10-19). A primary feed can take over as the minutes' clock, PumpAPI's own stream staying its backup:
6
+ ``SOL_FEED=relay`` (ingest/relay_feed.py: PumpAPI's events from our relay near its source, stream_relay/) or
7
+ ``SOL_FEED=helius`` (ingest/helius_feed.py: the same events decoded from Helius). Each transaction is taken once from
8
+ whichever feed brought it first (``Aggregator.ingest_from``), and the backup's clock counts only while the primary is down.
9
+ This worker keeps only what the selector needs: SOL-quoted buys and sells in PumpSwap (``pump-amm``) and Meteora DLMM pools
10
+ (``corpus_meta.UNIVERSE_POOLS``; DLMM since 2026-10-23) of pump.fun-origin mints (a ``pump`` mint, or a pump.fun graduate
11
+ without the suffix: one whose migration the stream saw or ``corpus_meta`` holds, reloaded every 5 minutes), aggregated per
12
+ (mint, minute) into the same fields ``train/mature.py``
13
  derives from the archive, with the same filters: legs in a pool created directly rather than by a pump.fun migration
14
  (``createPool`` with ``poolCreatedBy`` other than ``pump``; the owner can pull its unburned liquidity) are dropped — such pools
15
  go to ``pump_pools`` as they are created, and the blocked set is reloaded from that table (archive backfill included) at
 
23
  Lifecycle: ``create`` events go to ``pump_events`` as they arrive; at ``migrate`` a ``corpus_meta`` row is written
24
  with the creation facts and the creator's point-in-time history (``train/corpus_meta.creator_history``, the training
25
  definition); 61 minutes after a graduation its own 60-minute outcome is filled from the stream's minutes so later
26
+ graduations by the same creator see it, as in training. A graduation's quote asset goes to ``corpus_meta.grad_quote`` (at the
27
+ migration, or for an older graduate from trades in its own pool in another quote: ``corpus_meta.record_grad_quotes``), so its
28
+ quote-unit inputs are read as SOL only when they are (``corpus_meta.sol_select``). Minute rows older than ``PUMP_MINUTES_KEEP_DAYS`` are archived
29
  to ``data/corpus/stream/<day>.parquet`` before removal. Database failures keep the data in memory for the next attempt.
30
  """
31
  from __future__ import annotations
 
51
  from ..db.apilog import record_event
52
  from ..db.connection import transaction
53
  from ..logging_setup import setup
54
+ from ..train.corpus_meta import CURVE_COLS, UNIVERSE_POOLS, blocked_pool_ids, creator_history, curve_facts, pumpfun_graduates, record_grad_quotes
55
  from ..train.flow import minute_wallet_cols
56
 
57
  log = logging.getLogger(__name__)
 
60
  PRICE_BAND = 50.0 # train/mature.py: within 50x of the trailing median of the mint's previous 200 legs
61
  REF_LEGS = 200
62
  FLUSH_GRACE_S = 2.0
63
+ FLUSH_CHECK_S = 0.05 # how often complete minutes are looked for: a written minute is published at once (live money first)
64
  FLUSH_FALLBACK_S = 15.0
65
+ HELIUS_GRACE_S = 0.5 # our feed's stamps are its blocks' seconds (+0.9 s) in slot order: a minute is whole at the next second's first event
66
+ HOLD_MAX_S = 3.0 # the longest a minute waits for its cut-off transactions and unknown pools (ingest/helius_feed.py)
67
+ CLAIM_KEEP_S = 900.0
68
  MAX_PENDING_CREATES = 200_000
69
 
70
 
 
91
  self.curve: dict[str, dict] = {} # mint → bonding-curve wallets since its create (corpus_meta.curve_facts)
92
  self.pending_creates: list[tuple[str, dict]] = [] # creates not yet written to pump_events
93
  self.blocked: set[str] = set() # pool ids of directly created (custom) PumpSwap pools
94
+ self.grads: set[str] = set() # pump.fun graduates whose mint lacks the "pump" suffix
95
+ self.quote_seen: set[tuple] = set(); self.pending_quotes: list[tuple] = [] # (mint, pool, quote) of graduates' pools in another quote
96
  self.pending_pools: list[tuple] = [] # custom pools not yet written to pump_pools
97
  self.flushed_through: datetime | None = None # start of the newest complete minute written
98
  self.insiders: dict[str, frozenset] = {} # mint → creator/bundle/early wallets (token_insiders)
99
  self.skill: dict | None = None; self.skill_day: str | None = None # wallet → skill decile for the current UTC day (train/wallet_skill.py)
100
  self.stats = {"events": 0, "trades": 0, "dropped_band": 0, "dropped_late": 0, "dropped_custom_pool": 0, "flushed_minutes": 0, "flushed_rows": 0, "creates": 0, "migrates": 0,
101
+ "custom_pools": 0, "blocked_pools": 0, "reconnects": 0, "outcomes_filled": 0, "duplicates": 0, "started_at": datetime.now(timezone.utc).isoformat()}
102
+ self.feed = None # our own feed (ingest/helius_feed.HeliusFeed): the minutes' clock while it is up
103
+ self.wm_pumpapi = 0.0 # PumpAPI's newest trade time: the clock while our feed is down
104
+ self.claims: dict[tuple, str] = {}; self.claim_q: deque = deque() # (signature, pool/kind) -> the feed whose copy was taken
105
+ self.held: dict[int, float] = {} # minute -> when its flush first waited for our feed
106
+
107
+ def key(self, e: dict) -> tuple | None:
108
+ """One transaction's events in one pool (or of one kind): what the two feeds' copies have in common."""
109
+ sig, a = e.get("signature"), e.get("action")
110
+ if not sig:
111
+ return None
112
+ if a in ("buy", "sell"):
113
+ return (sig, "curve", e.get("mint")) if e.get("pool") == "pump" else (sig, e.get("poolId") or "")
114
+ if a in ("create", "migrate"):
115
+ return (sig, a)
116
+ if a == "createPool":
117
+ return (sig, a, e.get("poolId"))
118
+ return None
119
+
120
+ def ingest_from(self, e: dict, source: str) -> None:
121
+ """``ingest`` once per transaction across the two feeds: whichever brought it first owns it and the other's
122
+ copies are dropped (a feed may send one transaction as several events of one key: all taken). An event without a
123
+ signature cannot be matched: the backup's is dropped while the primary feed is up."""
124
+ if self.feed is None: # PumpAPI alone (config.SOL_FEED = pumpapi)
125
+ return self.ingest(e, source)
126
+ k = self.key(e)
127
+ if k is None:
128
+ if source != getattr(self.feed, "source", "helius") and self.feed.up():
129
+ self.stats["duplicates"] += 1
130
+ return
131
+ else:
132
+ by = self.claims.get(k)
133
+ if by is not None and by != source:
134
+ self.stats["duplicates"] += 1
135
+ return
136
+ if by is None:
137
+ self.claims[k] = source; self.claim_q.append((time.time(), k))
138
+ self.ingest(e, source)
139
+
140
+ def expire_claims(self, now: float) -> None:
141
+ while self.claim_q and now - self.claim_q[0][0] > CLAIM_KEEP_S:
142
+ self.claims.pop(self.claim_q.popleft()[1], None)
143
 
144
  def row(self, minute: int, mint: str) -> Minute | None:
145
  """The minute's candle for a mint: the pool with the most legs (as in training)."""
146
  pools = self.minutes.get(minute, {}).get(mint)
147
  return max(pools.values(), key=lambda r: (r.nb + r.ns, r.buy + r.sell)) if pools else None
148
 
149
+ def pumpfun(self, mint) -> bool:
150
+ """A pump.fun coin: the ``pump`` suffix, or a graduate without it (its migration seen here or in ``corpus_meta``)."""
151
+ return bool(mint) and (str(mint).endswith("pump") or mint in self.grads)
152
+
153
+ def ingest(self, e: dict, source: str = "pumpapi") -> None:
154
  self.stats["events"] += 1
155
  a = e.get("action"); pool = e.get("pool"); mint = e.get("mint"); ts = e.get("timestamp")
156
  if a == "createPool" and pool == "pump-amm" and (e.get("poolCreatedBy") or "custom") != "pump":
 
165
  self._curve(e)
166
  return
167
  if a in ("buy", "sell"):
168
+ if pool not in UNIVERSE_POOLS or not self.pumpfun(mint):
169
+ return
170
+ if e.get("quoteMint") != WSOL:
171
+ if pool == "pump-amm": # maybe the graduate's own pool, seeded in another quote: its graduation's quote
172
+ k = (mint, e.get("poolId"), e.get("quoteMint"))
173
+ if k[1] and k[2] and k not in self.quote_seen:
174
+ self.quote_seen.add(k); self.pending_quotes.append(k)
175
  return
176
  if e.get("poolId") in self.blocked:
177
  self.stats["dropped_custom_pool"] += 1
 
180
  if not price or price <= 0 or q is None or not (RESQ_BAND[0] <= float(q) <= RESQ_BAND[1]):
181
  return
182
  ts_s = ts / 1000.0; self.max_event_s = max(self.max_event_s, ts_s); self.last_event_wall = time.time()
183
+ if source == "pumpapi":
184
+ self.wm_pumpapi = max(self.wm_pumpapi, ts_s)
185
  day = int(ts_s // 86400)
186
  if self.ref_day is None or day > self.ref_day: # a late event from the previous day never wipes today's references
187
  self.ref = {}; self.ref_day = day
 
233
  self.curve = {k: v for k, v in self.curve.items() if v["ts"] >= cutoff}
234
  elif a == "migrate" and pool == "pump-amm":
235
  self.stats["migrates"] += 1
236
+ if not str(mint).endswith("pump"):
237
+ self.grads.add(mint) # a pump.fun graduate without the suffix: its PumpSwap legs are universe legs
238
  self.write_creates() # the creator's own earlier creates must be visible to the history query
239
  self._graduation(e)
240
 
 
274
  def block(self, pool_ids) -> None:
275
  self.blocked.update(pool_ids); self.stats["blocked_pools"] = len(self.blocked)
276
 
277
+ def write_quotes(self) -> int:
278
+ """Graduation quotes learnt from graduates' own pools (``corpus_meta.record_grad_quotes``); kept on failure."""
279
+ rows, self.pending_quotes = self.pending_quotes, []
280
+ if not rows:
281
+ return 0
282
+ try:
283
+ with transaction() as conn:
284
+ return record_grad_quotes(conn, rows)
285
+ except Exception:
286
+ self.pending_quotes = (rows + self.pending_quotes)[-MAX_PENDING_CREATES:]
287
+ log.exception("graduation quotes failed; %d queued for the next attempt", len(self.pending_quotes))
288
+ return 0
289
+
290
  def write_pools(self) -> int:
291
  """Persist custom pools seen on the stream (training excludes the same pools); on failure they stay queued."""
292
  rows, self.pending_pools = self.pending_pools, []
 
315
  ct = datetime.fromtimestamp(c["ts"] / 1000, timezone.utc) if c else None
316
  h = creator_history(conn, c.get("creator") if c else None, ct, g)
317
  conn.execute("""INSERT INTO corpus_meta (mint, graduated_at, create_ts, creator, dev_sol, dev_tokens, dev_share, supply, mayhem, uri, name, symbol, ttg_min, rq0, pool_id,
318
+ prior_launches, prior_grads, prior_known, prior_rug_share, prior_moon_share, grad_quote)
319
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (mint) DO UPDATE SET
320
  graduated_at = COALESCE(corpus_meta.graduated_at, EXCLUDED.graduated_at),
321
  create_ts = COALESCE(corpus_meta.create_ts, EXCLUDED.create_ts), creator = COALESCE(corpus_meta.creator, EXCLUDED.creator),
322
  dev_sol = COALESCE(corpus_meta.dev_sol, EXCLUDED.dev_sol), dev_tokens = COALESCE(corpus_meta.dev_tokens, EXCLUDED.dev_tokens),
 
326
  ttg_min = COALESCE(corpus_meta.ttg_min, EXCLUDED.ttg_min), rq0 = COALESCE(corpus_meta.rq0, EXCLUDED.rq0), pool_id = COALESCE(corpus_meta.pool_id, EXCLUDED.pool_id),
327
  prior_launches = COALESCE(corpus_meta.prior_launches, EXCLUDED.prior_launches), prior_grads = COALESCE(corpus_meta.prior_grads, EXCLUDED.prior_grads),
328
  prior_known = COALESCE(corpus_meta.prior_known, EXCLUDED.prior_known), prior_rug_share = COALESCE(corpus_meta.prior_rug_share, EXCLUDED.prior_rug_share),
329
+ prior_moon_share = COALESCE(corpus_meta.prior_moon_share, EXCLUDED.prior_moon_share),
330
+ grad_quote = COALESCE(corpus_meta.grad_quote, EXCLUDED.grad_quote), updated_at = now()""",
331
  (mint, g, ct, c.get("creator") if c else None, c.get("dev_sol") if c else None, c.get("dev_tokens") if c else None,
332
  (float(c["dev_tokens"]) / float(c["supply"])) if c and c.get("dev_tokens") and c.get("supply") else None,
333
  c.get("supply") if c else None, c.get("mayhem") if c else None, c.get("uri") if c else None, c.get("name") if c else None, c.get("symbol") if c else None,
334
  ((e["timestamp"] - c["ts"]) / 60000.0) if c else None, e.get("quoteInPool"), e.get("poolId"),
335
+ h["prior_launches"], h["prior_grads"], h["prior_known"], h["prior_rug_share"], h["prior_moon_share"], e.get("quoteMint")))
336
  conn.execute("INSERT INTO corpus_tokens (mint, graduated_at, grad_slot, source, status) VALUES (%s,%s,%s,'stream','pending') ON CONFLICT (mint) DO NOTHING",
337
  (mint, g, int(e.get("block") or 0)))
338
  st = self.curve.pop(mint, None)
 
379
  return len(rows)
380
 
381
  def flush_bound(self, now: float) -> int:
382
+ """Minutes strictly before this start are complete. The clock is our feed's while it is up (its newest PumpSwap
383
+ stamp: its events come in slot order), else PumpAPI's event-time watermark, or the wall-clock fallback once no
384
+ trade has arrived for ``FLUSH_FALLBACK_S`` (a lagging stream still delivers the minute's events, so it waits for
385
+ them). A minute whose cut-off transactions or unknown pools our feed is still fetching waits for them, at most
386
+ ``HOLD_MAX_S``."""
387
  cur = int(now // 60) * 60
388
+ feed = self.feed
389
+ if feed is not None and feed.up(now):
390
+ wm = int((feed.wm - getattr(feed, "grace_s", HELIUS_GRACE_S)) // 60) * 60
391
+ else:
392
+ wm = int(((self.wm_pumpapi if feed is not None else self.max_event_s) - FLUSH_GRACE_S) // 60) * 60
393
  if now - self.last_event_wall >= FLUSH_FALLBACK_S:
394
  wm = max(wm, int((now - FLUSH_FALLBACK_S) // 60) * 60)
395
+ bound = min(cur, wm)
396
+ if feed is not None:
397
+ for m in sorted(m for m, n in feed.pending.items() if n > 0 and m < bound):
398
+ if now - self.held.setdefault(m, now) < HOLD_MAX_S:
399
+ bound = m
400
+ break
401
+ for m in [m for m in self.held if m < bound - 600]:
402
+ del self.held[m]
403
+ return bound
404
 
405
 
406
  def fill_outcomes() -> int:
 
501
 
502
 
503
  def _load_blocked(agg: Aggregator) -> None:
504
+ """Union the custom pools in ``pump_pools`` (stream rows and the archive backfill) into the blocked set, and the pump.fun
505
+ graduates without the ``pump`` suffix (``corpus_meta``: the archive's and the stream's migrations) into ``grads``."""
506
  try:
507
  with transaction() as conn:
508
  agg.block(blocked_pool_ids(conn))
509
+ agg.grads.update(pumpfun_graduates(conn))
510
  except Exception:
511
  log.exception("custom pool load failed; retried in 5 min")
512
 
513
 
514
+ async def _pumpapi(stop: threading.Event | None, agg: Aggregator, io: dict) -> None:
515
+ """PumpAPI's stream: every event to the aggregator (our feed's backup while it runs, Aggregator.ingest_from)."""
516
  ctx = ssl.create_default_context(cafile=certifi.where()); backoff = 1.0
 
 
 
 
 
 
 
517
  while not (stop is not None and stop.is_set()):
518
  try:
519
  async with websockets.connect(config.PUMPSTREAM_URL, ssl=ctx, max_size=8_000_000, ping_interval=20, ping_timeout=20) as ws:
520
+ log.info("pumpstream connected"); backoff = 1.0; io["connected"] = True
521
  while not (stop is not None and stop.is_set()):
522
  try:
523
  msg = await asyncio.wait_for(ws.recv(), timeout=30)
524
  except asyncio.TimeoutError:
525
  log.warning("pumpstream: no events for 30 s; reconnecting"); break
526
+ io["last_msg"] = time.time(); io["rate_n"] += 1
527
  try:
528
  e = orjson.loads(msg)
529
  except orjson.JSONDecodeError:
530
  continue
531
  if isinstance(e, dict):
532
+ agg.ingest_from(e, "pumpapi")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  except Exception as e:
534
  agg.stats["reconnects"] += 1; log.warning("pumpstream: %s; reconnecting in %.0fs", e, backoff)
535
+ io["connected"] = False; io["last_error"] = str(e)[:200]
536
  await asyncio.sleep(backoff); backoff = min(60.0, backoff * 2)
537
+ io["connected"] = False
538
+
539
+
540
+ async def _housekeeping(stop: threading.Event | None, agg: Aggregator, io: dict) -> None:
541
+ """The minutes' flush on its own clock (``FLUSH_CHECK_S``: a feed's reconnect never holds a minute back), the
542
+ engine woken the moment one is written (agent/live_first.minute_published), then the status and the periodic loads."""
543
+ last_pools = time.time(); last_ins = 0.0; last_status = last_outcomes = 0.0; last_archive = time.time() - 3000
544
+ bg: dict[str, asyncio.Task | None] = {"archive": None, "outcomes": None, "blocked": None}
545
+ while not (stop is not None and stop.is_set()):
546
+ await asyncio.sleep(FLUSH_CHECK_S)
547
+ now = time.time()
548
+ try:
549
+ before = agg.flushed_through
550
+ try:
551
+ agg.flush(agg.flush_bound(now))
552
+ except Exception:
553
+ log.exception("minute flush failed; rows kept for the next attempt")
554
+ published = agg.flushed_through != before
555
+ if published or now - last_status >= 5.0: # a minute was written: the engine hears of it now, not at the next 5 s status
556
+ agg.write_creates(); agg.write_pools(); agg.write_quotes()
557
+ _status(agg, {"events_per_s": io["rate_n"] / max(now - io["rate_t"], 1e-9), "pending_minutes": len(agg.minutes), "lag_s": now - io["last_msg"],
558
+ "connected": io["connected"] or bool(agg.feed and agg.feed.up(now)), "pumpapi_connected": io["connected"], "last_error": io["last_error"],
559
+ "source": agg.feed.source if agg.feed is not None and agg.feed.up(now) else "pumpapi", "feed": agg.feed.status() if agg.feed is not None else None,
560
+ "flushed_through": agg.flushed_through.isoformat() if agg.flushed_through else None,
561
+ "event_watermark": datetime.fromtimestamp(agg.max_event_s, timezone.utc).isoformat() if agg.max_event_s else None})
562
+ io["rate_n"] = 0; io["rate_t"] = now; last_status = now
563
+ if published:
564
+ from ..agent import live_first
565
+ live_first.minute_published()
566
+ agg.expire_claims(now)
567
+ if now - last_pools >= 300:
568
+ if bg["blocked"] is None or bg["blocked"].done(): # on a thread: the minutes' flush never waits for the read
569
+ bg["blocked"] = asyncio.create_task(asyncio.to_thread(_load_blocked, agg))
570
+ last_pools = now
571
+ try:
572
+ load_skill(agg, now)
573
+ except Exception:
574
+ log.exception("wallet skill load failed; retried in 5 min")
575
+ if now - last_ins >= 60:
576
+ _load_insiders(agg); last_ins = now
577
+ if now - last_outcomes >= 60 and (bg["outcomes"] is None or bg["outcomes"].done()):
578
+ bg["outcomes"] = asyncio.create_task(asyncio.to_thread(_logged, "stream outcomes filled", fill_outcomes)); last_outcomes = now
579
+ if now - last_archive >= 3600 and (bg["archive"] is None or bg["archive"].done()):
580
+ bg["archive"] = asyncio.create_task(asyncio.to_thread(_logged, "archived old minute rows", archive_old, config.PUMP_MINUTES_KEEP_DAYS)); last_archive = now
581
+ except Exception:
582
+ log.exception("pumpstream housekeeping failed (retried at once)")
583
  for t in bg.values():
584
  if t is not None:
585
  try:
586
  await t
587
  except Exception:
588
  pass
589
+
590
+
591
+ async def _run(stop: threading.Event | None, agg: Aggregator) -> None:
592
+ """PumpAPI's stream, a primary feed (config.SOL_FEED = relay: our relay near PumpAPI; helius: our own decoder) that
593
+ is the minutes' clock while up, PumpAPI's own stream its backup, and the flush, side by side."""
594
+ _load_blocked(agg)
595
+ try:
596
+ load_skill(agg)
597
+ except Exception:
598
+ log.exception("wallet skill load failed; retried in 5 min")
599
+ io = {"connected": False, "last_msg": 0.0, "rate_n": 0, "rate_t": time.time(), "last_error": None}
600
+ jobs = [_pumpapi(stop, agg, io), _housekeeping(stop, agg, io)]
601
+ def emit(events, source):
602
+ for e in events:
603
+ agg.ingest_from(e, source)
604
+ if config.SOL_FEED == "helius":
605
+ from .helius_feed import HeliusFeed
606
+ agg.feed = HeliusFeed(emit, pumpfun=agg.pumpfun)
607
+ jobs.append(agg.feed.run(stop))
608
+ elif config.SOL_FEED == "relay": # PumpAPI's events from our relay near its source (stream_relay/)
609
+ from .relay_feed import RelayFeed
610
+ agg.feed = RelayFeed(emit)
611
+ jobs.append(agg.feed.run(stop))
612
+ await asyncio.gather(*jobs)
613
  try:
614
  agg.flush(int(time.time() // 60) * 60 + 60)
615
  except Exception:
616
  log.exception("final minute flush failed")
617
+ agg.write_creates(); agg.write_pools(); agg.write_quotes()
618
 
619
 
620
  def main(stop_event: threading.Event | None = None) -> None:
fly_trader/ingest/relay_feed.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The stream relay's client: PumpAPI's events, filtered and trimmed by stream_relay/relay.py on a server near PumpAPI,
2
+ over one compressed connection that resumes where it left off (nothing lost within the relay's ring).
3
+
4
+ With ``SOL_FEED=relay`` it is the minutes' clock (ingest/pumpstream.Aggregator.flush_bound: the relay's watermark as of
5
+ the events it has sent, ``grace_s`` back, as PumpAPI's own stream) and this machine's own PumpAPI connection is its
6
+ backup; each transaction is taken once (``Aggregator.ingest_from``). ``check`` connects without switching anything and
7
+ reports what arrives (``fly-trader relay-check``)."""
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import ssl
14
+ import time
15
+ from collections import Counter
16
+
17
+ import certifi
18
+ import orjson
19
+ import websockets
20
+
21
+ from .. import config
22
+
23
+ log = logging.getLogger(__name__)
24
+ GRACE_S = 2.0 # the events carry PumpAPI's stamps (its receive times): the grace of its own stream (pumpstream.FLUSH_GRACE_S)
25
+ STALE_S = 5.0 # the relay heartbeats every second: silence this long means it is down
26
+
27
+
28
+ class RelayFeed:
29
+ source = "relay"
30
+ grace_s = GRACE_S
31
+
32
+ def __init__(self, emit):
33
+ self.emit = emit
34
+ self.next_seq: int | None = None # the next event wanted: a reconnect resumes there
35
+ self.wm = 0.0; self.last_msg = 0.0; self.connected = False; self.upstream_up = False
36
+ self.pending: Counter = Counter() # nothing arrives later than its batch: no minute waits on the relay
37
+ self.stats = Counter()
38
+
39
+ def up(self, now: float | None = None) -> bool:
40
+ now = time.time() if now is None else now
41
+ return self.connected and self.upstream_up and now - self.last_msg < STALE_S
42
+
43
+ def on_message(self, m: dict) -> None:
44
+ self.last_msg = time.time()
45
+ if "gap" in m: # the relay's ring no longer holds what this client missed
46
+ a, b = m["gap"]; self.stats["gaps"] += 1; self.stats["lost"] += max(0, b - a); self.next_seq = b
47
+ log.warning("stream relay: %d events lost while disconnected (beyond its buffer)", b - a)
48
+ return
49
+ if "hello" in m:
50
+ self.next_seq = m["hello"]["next"]
51
+ return
52
+ evs = m.get("events") or []
53
+ if self.next_seq is not None and m["from"] != self.next_seq:
54
+ log.warning("stream relay: batch from %s, expected %s", m["from"], self.next_seq)
55
+ if evs:
56
+ self.emit(evs, self.source); self.stats["events"] += len(evs)
57
+ self.next_seq = m["from"] + len(evs)
58
+ self.wm = max(self.wm, float(m.get("wm") or 0.0)); self.upstream_up = bool(m.get("up"))
59
+
60
+ async def run(self, stop) -> None:
61
+ if not (config.STREAM_RELAY_URL and config.STREAM_RELAY_TOKEN):
62
+ log.error("SOL_FEED=relay needs STREAM_RELAY_URL and STREAM_RELAY_TOKEN (stream_relay/README.md): PumpAPI's stream alone meanwhile")
63
+ return
64
+ tls = ssl.create_default_context(cafile=config.STREAM_RELAY_CA_FILE or certifi.where()) if config.STREAM_RELAY_URL.startswith("wss") else None
65
+ backoff = 0.5
66
+ while not (stop is not None and stop.is_set()):
67
+ try:
68
+ async with websockets.connect(config.STREAM_RELAY_URL, ssl=tls, additional_headers={"Authorization": f"Bearer {config.STREAM_RELAY_TOKEN}"},
69
+ compression="deflate", max_size=64_000_000, ping_interval=10, ping_timeout=10, open_timeout=10) as ws:
70
+ await ws.send(json.dumps({"resume": self.next_seq}))
71
+ self.connected = True; backoff = 0.5
72
+ log.info("stream relay connected (resuming at %s)", self.next_seq)
73
+ while not (stop is not None and stop.is_set()):
74
+ self.on_message(orjson.loads(await asyncio.wait_for(ws.recv(), timeout=STALE_S * 2)))
75
+ except Exception as e:
76
+ self.stats["reconnects"] += 1
77
+ log.warning("stream relay: %s; reconnecting in %.1fs", str(e)[:200], backoff)
78
+ finally:
79
+ self.connected = False
80
+ if stop is not None and stop.is_set():
81
+ break
82
+ await asyncio.sleep(backoff); backoff = min(10.0, backoff * 2)
83
+
84
+ def status(self) -> dict:
85
+ return {**{k: int(v) for k, v in self.stats.items()}, "up": self.up(), "connected": self.connected, "upstream_up": self.upstream_up,
86
+ "next_seq": self.next_seq, "watermark": self.wm, "lag_s": round(time.time() - self.wm, 2) if self.wm else None}
87
+
88
+
89
+ def check(seconds: float = 30.0) -> dict:
90
+ """Connect to the relay for ``seconds`` without touching the live stream and report what arrived."""
91
+ if not (config.STREAM_RELAY_URL and config.STREAM_RELAY_TOKEN):
92
+ return {"error": "STREAM_RELAY_URL and STREAM_RELAY_TOKEN are not set in .env (stream_relay/README.md)"}
93
+ kinds: Counter = Counter(); lags: list[float] = []
94
+ feed = RelayFeed(lambda evs, src: kinds.update((e.get("pool"), e.get("action")) for e in evs))
95
+
96
+ async def go():
97
+ stop = asyncio.Event(); task = asyncio.create_task(feed.run(stop)); t0 = time.time()
98
+ while time.time() - t0 < seconds:
99
+ await asyncio.sleep(1.0)
100
+ if feed.wm:
101
+ lags.append(time.time() - feed.wm)
102
+ stop.set()
103
+ await asyncio.sleep(1.5); task.cancel()
104
+
105
+ asyncio.run(go())
106
+ lags.sort()
107
+ out = {"seconds": seconds, "events_per_s": round(sum(kinds.values()) / seconds, 1), "by_kind": {f"{p}/{a}": n for (p, a), n in kinds.most_common()},
108
+ "lag_s_p50": round(lags[len(lags) // 2], 2) if lags else None, "lag_s_max": round(lags[-1], 2) if lags else None, **feed.status()}
109
+ return out
fly_trader/ingest/replay_pull.py CHANGED
@@ -7,9 +7,12 @@ reserves; ``create`` with the creator wallet and metadata URI; ``migrate`` (grad
7
 
8
  This worker downloads ``REPLAY_PARALLEL`` hours at a time (measured ~2–4 MB/s per stream), parses each in one
9
  thread and writes, per hour, ``data/corpus/replay/<date>/<HH>_trades.parquet`` (pump.fun bonding-curve and
10
- PumpSwap buys/sells, one row per trader leg; signatures and per-mint constants are left out to keep it ~70 MB/hour, the
11
- lifecycle table carries creator, pool id, metadata URI and the reserves at migration) and ``<HH>_events.parquet`` (every non-trade lifecycle event across
12
- all pools, small). Hours are processed newest first so the current regime lands first, and hours that close while the backfill runs
 
 
 
13
  are fetched ahead of it, so the archive stays within ~2 hours of live; the registry is ``replay_hours``. The compressed download is a temporary file and is removed after a successful parse — the
14
  public archive remains the source of truth. Downstream: ``train/replay_assemble.py`` turns days into per-token
15
  candle/trade files for the feature builder.
@@ -19,6 +22,7 @@ from __future__ import annotations
19
  import io
20
  import json
21
  import logging
 
22
  import queue
23
  import threading
24
  import time
@@ -40,7 +44,9 @@ from .corpus_pull import _db_retry, _sleep
40
  log = logging.getLogger(__name__)
41
  HOUR_SQL = ("INSERT INTO replay_hours (hour, status, last_error) VALUES (%s, %s, %s) "
42
  "ON CONFLICT (hour) DO UPDATE SET status = EXCLUDED.status, last_error = EXCLUDED.last_error, done_at = now()")
43
- TRADE_POOLS = {"pump", "pump-amm"}
 
 
44
  SKIP_ACTIONS = {"buy", "sell", "transfer", "add", "remove", "claimCreatorFees"}
45
  TRADE_SCHEMA = pa.schema([("ts", pa.timestamp("ms", tz="UTC")), ("slot", pa.int64()), ("pool", pa.dictionary(pa.int8(), pa.string())), ("mint", pa.string()),
46
  ("trader", pa.string()), ("side", pa.int8()), ("sol", pa.float64()), ("tokens", pa.float64()), ("price", pa.float64()),
@@ -50,7 +56,8 @@ EVENT_SCHEMA = pa.schema([("ts", pa.timestamp("ms", tz="UTC")), ("slot", pa.int6
50
  ("mint", pa.string()), ("pool_id", pa.string()), ("signer", pa.string()), ("creator_fee_addr", pa.string()), ("name", pa.string()),
51
  ("symbol", pa.string()), ("uri", pa.string()), ("supply", pa.float64()), ("decimals", pa.int16()), ("token_program", pa.string()),
52
  ("initial_buy", pa.float64()), ("quote_amount", pa.float64()), ("quote_in_pool", pa.float64()), ("tokens_in_pool", pa.float64()),
53
- ("mayhem", pa.bool_()), ("pool_created_by", pa.string()), ("mint_authority", pa.string()), ("freeze_authority", pa.string())])
 
54
 
55
 
56
  def _f(x) -> float | None:
@@ -86,7 +93,7 @@ def parse_hour(path: Path) -> tuple[pa.Table, pa.Table, int]:
86
  continue
87
  tsdt = datetime.fromtimestamp(int(ts) / 1000, timezone.utc)
88
  if a in ("buy", "sell"):
89
- if pool not in TRADE_POOLS:
90
  continue
91
  legs = e.get("breakdown") or [{"action": a, "trader": e.get("txSigner"), "tokenAmount": e.get("tokenAmount"), "quoteAmount": _q(e, "quoteAmount", "solAmount")}]
92
  vq = _q(e, "vQuoteInBondingCurve", "vSolInBondingCurve") if pool == "pump" else _q(e, "virtualQuoteInPool", "virtualSolInPool")
@@ -104,9 +111,26 @@ def parse_hour(path: Path) -> tuple[pa.Table, pa.Table, int]:
104
  E["initial_buy"].append(_f(e.get("initialBuy"))); E["quote_amount"].append(_f(_q(e, "quoteAmount", "solAmount"))); E["quote_in_pool"].append(_f(_q(e, "quoteInPool", "solInPool")))
105
  E["tokens_in_pool"].append(_f(e.get("tokensInPool"))); E["mayhem"].append(bool(e.get("mayhemMode")) if e.get("mayhemMode") is not None else None)
106
  E["pool_created_by"].append(e.get("poolCreatedBy")); E["mint_authority"].append(e.get("mintAuthority")); E["freeze_authority"].append(e.get("freezeAuthority"))
 
107
  return pa.table(T, schema=TRADE_SCHEMA), pa.table(E, schema=EVENT_SCHEMA), n
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  def refetch_missing_pool_id() -> int:
111
  """Hours whose trades file predates the ``pool_id`` column (written 2026-08-20..09-13 by an older parser) are marked
112
  ``refetch``: ``plan_hours`` downloads them again and ``parse_hour`` writes the column, so the archive applies the live
@@ -175,7 +199,8 @@ def _download(client: httpx.Client, hour: datetime, tmp: Path, stop: threading.E
175
 
176
 
177
  class _Status:
178
- def __init__(self, total: int):
 
179
  self.s = {"hours_total": total, "hours_done": 0, "trades": 0, "events": 0, "bytes": 0, "started_at": datetime.now(timezone.utc).isoformat(), "errors": 0}
180
  self.lock = threading.Lock(); self.last = 0.0; self.t0 = time.time()
181
 
@@ -192,8 +217,8 @@ class _Status:
192
  self.last = time.time(); payload = dict(self.s)
193
  try:
194
  with transaction() as conn:
195
- conn.execute("INSERT INTO ui_settings (key, value) VALUES ('replay_status', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
196
- (json.dumps(payload, default=str),))
197
  except Exception:
198
  log.debug("replay status write failed", exc_info=True)
199
 
@@ -292,8 +317,7 @@ def main(stop_event: threading.Event | None = None) -> None:
292
  t0 = time.time()
293
  try:
294
  trades, events, n = parse_hour(tmp)
295
- day = config.REPLAY_DIR / f"{h:%Y-%m-%d}"; day.mkdir(parents=True, exist_ok=True)
296
- pq.write_table(trades, day / f"{h:%H}_trades.parquet", compression="zstd"); pq.write_table(events, day / f"{h:%H}_events.parquet", compression="zstd")
297
  tmp.unlink()
298
  _db_retry(stop, "replay_hours write", _put_hour,
299
  "INSERT INTO replay_hours (hour, status, trades, events, bytes, took_s) VALUES (%s,'done',%s,%s,%s,%s) "
@@ -313,3 +337,137 @@ def main(stop_event: threading.Event | None = None) -> None:
313
  status.update(stage="stopped", force=True)
314
  record_event("info", "replay", "replay ingest stopped", {k: status.s.get(k) for k in ("hours_done", "hours_total", "trades", "events", "bytes", "errors")})
315
  log.info("replay ingest stopped")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  This worker downloads ``REPLAY_PARALLEL`` hours at a time (measured ~2–4 MB/s per stream), parses each in one
9
  thread and writes, per hour, ``data/corpus/replay/<date>/<HH>_trades.parquet`` (pump.fun bonding-curve and
10
+ PumpSwap buys/sells in any quote, and since 2026-10-23 every other venue's SOL-quoted buys/sells: Meteora DLMM, DAMM,
11
+ Raydium, Orca, other launchpads' curves; one row per trader leg; signatures and per-mint constants are left out to keep it
12
+ ~90 MB/hour, the lifecycle table carries creator, pool id, metadata URI and the reserves at migration) and
13
+ ``<HH>_events.parquet`` (every non-trade lifecycle event across all pools, small; the quote mint since 2026-10-23). Both are
14
+ written atomically; the trades file is stamped with what it keeps (``TRADES_VERSION``), and ``refetch_main`` downloads
15
+ the hours stamped otherwise again in its own process (``fly-trader refetch-archive``). Hours are processed newest first so the current regime lands first, and hours that close while the backfill runs
16
  are fetched ahead of it, so the archive stays within ~2 hours of live; the registry is ``replay_hours``. The compressed download is a temporary file and is removed after a successful parse — the
17
  public archive remains the source of truth. Downstream: ``train/replay_assemble.py`` turns days into per-token
18
  candle/trade files for the feature builder.
 
22
  import io
23
  import json
24
  import logging
25
+ import os
26
  import queue
27
  import threading
28
  import time
 
44
  log = logging.getLogger(__name__)
45
  HOUR_SQL = ("INSERT INTO replay_hours (hour, status, last_error) VALUES (%s, %s, %s) "
46
  "ON CONFLICT (hour) DO UPDATE SET status = EXCLUDED.status, last_error = EXCLUDED.last_error, done_at = now()")
47
+ TRADE_POOLS = {"pump", "pump-amm"} # kept in any quote; every other venue's legs are kept when SOL-quoted
48
+ WSOL = "So11111111111111111111111111111111111111112"
49
+ TRADES_VERSION = "pumpfun+sol-1" # the trades file's ``fly_pools`` stamp: what its legs cover (refetch_main)
50
  SKIP_ACTIONS = {"buy", "sell", "transfer", "add", "remove", "claimCreatorFees"}
51
  TRADE_SCHEMA = pa.schema([("ts", pa.timestamp("ms", tz="UTC")), ("slot", pa.int64()), ("pool", pa.dictionary(pa.int8(), pa.string())), ("mint", pa.string()),
52
  ("trader", pa.string()), ("side", pa.int8()), ("sol", pa.float64()), ("tokens", pa.float64()), ("price", pa.float64()),
 
56
  ("mint", pa.string()), ("pool_id", pa.string()), ("signer", pa.string()), ("creator_fee_addr", pa.string()), ("name", pa.string()),
57
  ("symbol", pa.string()), ("uri", pa.string()), ("supply", pa.float64()), ("decimals", pa.int16()), ("token_program", pa.string()),
58
  ("initial_buy", pa.float64()), ("quote_amount", pa.float64()), ("quote_in_pool", pa.float64()), ("tokens_in_pool", pa.float64()),
59
+ ("mayhem", pa.bool_()), ("pool_created_by", pa.string()), ("mint_authority", pa.string()), ("freeze_authority", pa.string()),
60
+ ("quote_mint", pa.string())])
61
 
62
 
63
  def _f(x) -> float | None:
 
93
  continue
94
  tsdt = datetime.fromtimestamp(int(ts) / 1000, timezone.utc)
95
  if a in ("buy", "sell"):
96
+ if pool not in TRADE_POOLS and not (pool and e.get("quoteMint") == WSOL):
97
  continue
98
  legs = e.get("breakdown") or [{"action": a, "trader": e.get("txSigner"), "tokenAmount": e.get("tokenAmount"), "quoteAmount": _q(e, "quoteAmount", "solAmount")}]
99
  vq = _q(e, "vQuoteInBondingCurve", "vSolInBondingCurve") if pool == "pump" else _q(e, "virtualQuoteInPool", "virtualSolInPool")
 
111
  E["initial_buy"].append(_f(e.get("initialBuy"))); E["quote_amount"].append(_f(_q(e, "quoteAmount", "solAmount"))); E["quote_in_pool"].append(_f(_q(e, "quoteInPool", "solInPool")))
112
  E["tokens_in_pool"].append(_f(e.get("tokensInPool"))); E["mayhem"].append(bool(e.get("mayhemMode")) if e.get("mayhemMode") is not None else None)
113
  E["pool_created_by"].append(e.get("poolCreatedBy")); E["mint_authority"].append(e.get("mintAuthority")); E["freeze_authority"].append(e.get("freezeAuthority"))
114
+ E["quote_mint"].append(e.get("quoteMint"))
115
  return pa.table(T, schema=TRADE_SCHEMA), pa.table(E, schema=EVENT_SCHEMA), n
116
 
117
 
118
+ def write_hour(day: Path, h: datetime, trades: pa.Table, events: pa.Table) -> None:
119
+ """The hour's two files, each written atomically (a reader never meets half a file, and a rewrite by ``refetch_main``
120
+ replaces it whole), the trades file stamped with what its legs cover (``TRADES_VERSION``)."""
121
+ day.mkdir(parents=True, exist_ok=True)
122
+ trades = trades.replace_schema_metadata({**(trades.schema.metadata or {}), b"fly_pools": TRADES_VERSION.encode()})
123
+ for t, name in ((trades, f"{h:%H}_trades.parquet"), (events, f"{h:%H}_events.parquet")):
124
+ tmp = day / f"{name}.{os.getpid()}.{threading.get_ident()}.tmp"
125
+ pq.write_table(t, tmp, compression="zstd"); tmp.replace(day / name)
126
+
127
+
128
+ def trades_version(path) -> str | None:
129
+ """The ``fly_pools`` stamp of an hour's trades file (None: written before 2026-10-23, PumpSwap and the curve only)."""
130
+ v = (pq.read_schema(path).metadata or {}).get(b"fly_pools")
131
+ return v.decode() if v else None
132
+
133
+
134
  def refetch_missing_pool_id() -> int:
135
  """Hours whose trades file predates the ``pool_id`` column (written 2026-08-20..09-13 by an older parser) are marked
136
  ``refetch``: ``plan_hours`` downloads them again and ``parse_hour`` writes the column, so the archive applies the live
 
199
 
200
 
201
  class _Status:
202
+ def __init__(self, total: int, key: str = "replay_status"):
203
+ self.key = key
204
  self.s = {"hours_total": total, "hours_done": 0, "trades": 0, "events": 0, "bytes": 0, "started_at": datetime.now(timezone.utc).isoformat(), "errors": 0}
205
  self.lock = threading.Lock(); self.last = 0.0; self.t0 = time.time()
206
 
 
217
  self.last = time.time(); payload = dict(self.s)
218
  try:
219
  with transaction() as conn:
220
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
221
+ (self.key, json.dumps(payload, default=str)))
222
  except Exception:
223
  log.debug("replay status write failed", exc_info=True)
224
 
 
317
  t0 = time.time()
318
  try:
319
  trades, events, n = parse_hour(tmp)
320
+ write_hour(config.REPLAY_DIR / f"{h:%Y-%m-%d}", h, trades, events)
 
321
  tmp.unlink()
322
  _db_retry(stop, "replay_hours write", _put_hour,
323
  "INSERT INTO replay_hours (hour, status, trades, events, bytes, took_s) VALUES (%s,'done',%s,%s,%s,%s) "
 
337
  status.update(stage="stopped", force=True)
338
  record_event("info", "replay", "replay ingest stopped", {k: status.s.get(k) for k in ("hours_done", "hours_total", "trades", "events", "bytes", "errors")})
339
  log.info("replay ingest stopped")
340
+
341
+
342
+ # ---------------------------------------------------------------- downloading the archive again (fly-trader refetch-archive)
343
+ def refetch_plan(now: datetime | None = None) -> list[datetime]:
344
+ """Hours whose trades file is stamped otherwise than ``TRADES_VERSION`` (written before the files kept every venue's SOL
345
+ legs), newest first, leaving the newest ``REFETCH_SKIP_RECENT_H`` hours to the console's replay worker."""
346
+ now = now or datetime.now(timezone.utc)
347
+ cut = now - timedelta(hours=config.REFETCH_SKIP_RECENT_H)
348
+ out = []
349
+ for f in config.REPLAY_DIR.glob("*/*_trades.parquet"):
350
+ try:
351
+ d = datetime.fromisoformat(f.parent.name)
352
+ h = datetime(d.year, d.month, d.day, int(f.name[:2]), tzinfo=timezone.utc)
353
+ except ValueError:
354
+ continue
355
+ if h < cut and trades_version(f) != TRADES_VERSION:
356
+ out.append(h)
357
+ return sorted(out, reverse=True)
358
+
359
+
360
+ def live_ok() -> tuple[bool, str]:
361
+ """The live stream keeps up: its status is fresh and its newest event is at most ``REFETCH_MAX_LAG_S`` behind the
362
+ status write. A refetch download competes with it for this machine's connection, so none starts while it lags."""
363
+ try:
364
+ with transaction() as conn:
365
+ r = conn.execute("SELECT value, updated_at FROM ui_settings WHERE key = 'pumpstream_status'").fetchone()
366
+ except Exception as e:
367
+ return False, f"status unreadable ({e})"
368
+ if not r:
369
+ return False, "no stream status"
370
+ v = r["value"] if isinstance(r["value"], dict) else json.loads(r["value"] or "{}")
371
+ upd = r["updated_at"]; age = (datetime.now(timezone.utc) - upd).total_seconds()
372
+ if age > 60:
373
+ return False, f"stream status {age:.0f} s old"
374
+ wm = v.get("event_watermark")
375
+ if not wm:
376
+ return False, "no stream watermark"
377
+ lag = (upd - datetime.fromisoformat(wm)).total_seconds()
378
+ return (lag <= config.REFETCH_MAX_LAG_S, f"stream {lag:.0f} s behind")
379
+
380
+
381
+ def refetch_main(stop_event: threading.Event | None = None) -> dict:
382
+ """Download again every hour ``refetch_plan`` lists, ``REFETCH_PARALLEL`` at a time, in this process (run it apart from
383
+ the console, at low priority: ``nice -n 19 fly-trader refetch-archive``). Each hour is parsed and written as the replay
384
+ worker would (``write_hour``); a download starts only while the live stream keeps up (``live_ok``). When a day's hours
385
+ are all rewritten its aggregate is stale (train/mature.agg_stale: inputs newer) and a build round is tried here, never
386
+ waiting behind another process's round. Status: ``ui_settings['replay_refetch_status']``."""
387
+ setup("replay_refetch"); stop = stop_event or threading.Event()
388
+ hours = refetch_plan(); status = _Status(len(hours), "replay_refetch_status"); status.update(stage="refetching", force=True)
389
+ by_day: dict = {}
390
+ for h in hours:
391
+ by_day.setdefault(h.date(), set()).add(h)
392
+ log.info("archive refetch: %d hours on %d days to download again (%d at a time)", len(hours), len(by_day), config.REFETCH_PARALLEL)
393
+ tmpdir = config.REPLAY_DIR / "_refetch_tmp"; tmpdir.mkdir(parents=True, exist_ok=True)
394
+ for f in tmpdir.glob("*"):
395
+ f.unlink()
396
+ todo: queue.Queue = queue.Queue(); ready: queue.Queue = queue.Queue(maxsize=config.REFETCH_PARALLEL + 1)
397
+ for h in hours:
398
+ todo.put(h)
399
+ client = httpx.Client(headers={"User-Agent": "fly-trader replay refetch"}, timeout=httpx.Timeout(60.0, read=600.0), follow_redirects=True)
400
+ paused = {"why": None}
401
+
402
+ def downloader():
403
+ while not stop.is_set():
404
+ ok, why = live_ok()
405
+ if not ok:
406
+ if paused["why"] is None:
407
+ log.info("refetch paused: %s", why)
408
+ paused["why"] = why; status.update(paused=why); stop.wait(30.0); continue
409
+ if paused["why"] is not None:
410
+ log.info("refetch resumed"); paused["why"] = None; status.update(paused=None)
411
+ try:
412
+ h = todo.get_nowait()
413
+ except queue.Empty:
414
+ return
415
+ tmp = tmpdir / f"{h:%Y%m%d%H}.zst"
416
+ for attempt in range(4):
417
+ try:
418
+ t0 = time.time(); size = _download(client, h, tmp, stop)
419
+ ready.put((h, tmp, size, time.time() - t0)); break
420
+ except InterruptedError:
421
+ return
422
+ except Exception as e:
423
+ log.warning("refetch download %s failed (%d): %s", h, attempt, e); stop.wait(10 * (attempt + 1))
424
+ else:
425
+ ready.put((h, None, 0, 0.0))
426
+
427
+ threads = [threading.Thread(target=downloader, name=f"replay_refetch-dl-{i}", daemon=True) for i in range(config.REFETCH_PARALLEL)]
428
+ for t in threads:
429
+ t.start()
430
+ from ..train import mature
431
+ rebuild: dict = {"thread": None}
432
+ done = failed = 0
433
+ try:
434
+ while not stop.is_set() and (any(t.is_alive() for t in threads) or not ready.empty()):
435
+ try:
436
+ h, tmp, size, dl_s = ready.get(timeout=5.0)
437
+ except queue.Empty:
438
+ continue
439
+ if tmp is None or size < 0:
440
+ failed += 1; status.update(errors=failed)
441
+ log.warning("refetch %s: %s; the hour keeps its old file", h, "not in the archive" if size < 0 else "download failed")
442
+ else:
443
+ t0 = time.time()
444
+ try:
445
+ trades, events, n = parse_hour(tmp)
446
+ write_hour(config.REPLAY_DIR / f"{h:%Y-%m-%d}", h, trades, events)
447
+ done += 1
448
+ status.update(hours_done=done, bytes=status.s["bytes"] + size, last_hour=h.isoformat(), last_parse_s=round(time.time() - t0, 1), last_dl_s=round(dl_s, 1))
449
+ log.info("refetched %s: %d events -> %d trade rows (%.0f MB, dl %.0fs, parse %.1fs)", h.isoformat(), n, trades.num_rows, size / 1e6, dl_s, time.time() - t0)
450
+ except Exception:
451
+ failed += 1; status.update(errors=failed); log.exception("refetch parse %s failed; the hour keeps its old file", h)
452
+ finally:
453
+ tmp.unlink(missing_ok=True)
454
+ day = by_day.get(h.date())
455
+ if day is not None:
456
+ day.discard(h)
457
+ if not day: # the day is rewritten: rebuild it here (one round at a time)
458
+ del by_day[h.date()]
459
+ if rebuild["thread"] is None or not rebuild["thread"].is_alive():
460
+ def _round(d=h.date()):
461
+ try:
462
+ n = mature.try_loop_once()
463
+ log.info("refetch: build round after %s: %s", d, "another process holds the build" if n is None else f"{n} day steps")
464
+ except Exception:
465
+ log.exception("refetch: build round after %s failed (the console's round rebuilds it)", d)
466
+ rebuild["thread"] = threading.Thread(target=_round, name="replay_refetch-build", daemon=True); rebuild["thread"].start()
467
+ finally:
468
+ stop.set()
469
+ if rebuild["thread"] is not None:
470
+ rebuild["thread"].join() # its day's files are written atomically either way
471
+ status.update(stage="stopped", force=True)
472
+ log.info("archive refetch stopped: %d hours rewritten, %d failed, %d planned", done, failed, len(hours))
473
+ return {"planned": len(hours), "done": done, "failed": failed}
fly_trader/kalshi/strategies.py CHANGED
@@ -91,8 +91,12 @@ def oos(ds, rows: np.ndarray, y: np.ndarray, ci: np.ndarray, H, stop, label_: st
91
 
92
 
93
  def fit_final(ds, rows: np.ndarray, ci: np.ndarray, H):
94
- """The deployed model of a strategy: a classifier of the side paying, on its candidate rows (at most ds.train_max_rows)."""
95
- rows = S.cap_rows(ds, np.flatnonzero(rows), 99)
 
 
 
 
96
  scaler = RobustScaler.fit(ds.X[np.ix_(rows, ci)], seed=99)
97
  m = HistGradientBoostingClassifier(**S._gbm_params(), random_state=99); m.fit(scaler.transform(ds.X[np.ix_(rows, ci)]), ds.outcome[rows].astype(np.int8))
98
  return m, scaler
 
91
 
92
 
93
  def fit_final(ds, rows: np.ndarray, ci: np.ndarray, H):
94
+ """The deployed model of a strategy: a classifier of the side paying, on its candidate rows (at most ds.train_max_rows).
95
+ ``rows``: row indices, as train/strategies.final_models passes them (a boolean mask is accepted too). From 2026-10-07
96
+ to 2026-10-12 this took the indices through np.flatnonzero a second time, which turned them into 0..n−1: every final
97
+ and holdout model (selectors 188, 220, 249) trained on the corpus's first n rows instead of the strategy's own."""
98
+ rows = np.asarray(rows)
99
+ rows = S.cap_rows(ds, np.flatnonzero(rows) if rows.dtype == bool else rows, 99)
100
  scaler = RobustScaler.fit(ds.X[np.ix_(rows, ci)], seed=99)
101
  m = HistGradientBoostingClassifier(**S._gbm_params(), random_state=99); m.fit(scaler.transform(ds.X[np.ix_(rows, ci)]), ds.outcome[rows].astype(np.int8))
102
  return m, scaler
fly_trader/ops/release.py CHANGED
@@ -102,7 +102,7 @@ def apply_release(release_dir: Path, bootstrap: bool = False) -> dict:
102
  if not ok:
103
  raise ReleaseError(f"the release's fly is not deployable on this code: {why}")
104
  v = seed["verdict"]
105
- if not v.get("passed") or v.get("data") != fly_selector.verdict_version():
106
  raise ReleaseError("the release's replay verdict did not pass on this code's definitions")
107
  ids: dict[str, int] = {}
108
  with transaction() as conn:
 
102
  if not ok:
103
  raise ReleaseError(f"the release's fly is not deployable on this code: {why}")
104
  v = seed["verdict"]
105
+ if not v.get("passed") or not fly_selector.selector.defs_match(v.get("data"), fly_selector.verdict_version()):
106
  raise ReleaseError("the release's replay verdict did not pass on this code's definitions")
107
  ids: dict[str, int] = {}
108
  with transaction() as conn:
fly_trader/rh/corpus.py CHANGED
@@ -9,10 +9,15 @@
9
  4. ``rh/wallet_skill.build_wallet_day(D − 1)`` once D's aggregate and part exist.
10
 
11
  A day is built once complete (the minutes cursor past its end); a part records ``fly_chain = rh`` and ``fly_rh_agg``.
 
 
 
12
  """
13
  from __future__ import annotations
14
 
 
15
  import logging
 
16
  from datetime import date, datetime, timedelta, timezone
17
 
18
  import pyarrow as pa
@@ -48,19 +53,29 @@ def aggregate(d: date) -> int:
48
 
49
 
50
  def _meta_extra() -> tuple[dict, dict, dict]:
51
- """(graduations, supplies, TokenMeta extras) for every graduated launch."""
 
52
  k = config.RH_ETH_PER_SOL
53
  with transaction() as conn:
54
- g = conn.execute("SELECT mint, graduated_at FROM rh_meta").fetchall()
55
  fees = conn.execute("SELECT mint, percentile_cont(0.5) WITHIN GROUP (ORDER BY fee_rate) AS f FROM rh_minutes WHERE fee_rate IS NOT NULL GROUP BY mint").fetchall()
56
  grads = {r["mint"]: r["graduated_at"] for r in g}
57
  fee = {r["mint"]: float(r["f"]) for r in fees}
58
  extra = {m: {"ec_size": 0.1 * k, "pool_fee": fee.get(m, config.RH_HOOK_FEE)} for m in grads}
59
- return grads, {m: 1e9 for m in grads}, extra
60
 
61
 
62
- def build_day(d: date) -> int:
63
- grads, supplies, extra = _meta_extra()
 
 
 
 
 
 
 
 
 
64
  return mature.build_day(d, grads=grads, supplies=supplies, src_dir=MATURE_DIR, out_dir=FEAT_DIR, program_label=PONS_LABEL, meta_extra=extra,
65
  extra_md={"fly_chain": "rh", "fly_rh_agg": RH_AGG_VERSION})
66
 
@@ -86,14 +101,42 @@ def run_once(max_days: int = 3) -> dict:
86
  last = complete_through()
87
  if last is None:
88
  return {"days": 0}
89
- d = date.fromisoformat(config.RH_START_DAY); done = 0; out = {}
90
  while d <= last and done < max_days:
91
  part = FEAT_DIR / d.isoformat() / "part.parquet"; agg = MATURE_DIR / f"{d.isoformat()}.parquet"
92
  if not agg.exists() or not part_current(part):
93
  wallet_skill.write_table(d)
94
  n_agg = aggregate(d)
95
- n_part = build_day(d) if n_agg else 0
 
 
96
  wallet_skill.build_wallet_day(d - timedelta(days=1))
97
  out[d.isoformat()] = {"minutes": n_agg, "rows": n_part}; done += 1
98
  d += timedelta(days=1)
99
  return {"days": done, "built": out}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  4. ``rh/wallet_skill.build_wallet_day(D − 1)`` once D's aggregate and part exist.
10
 
11
  A day is built once complete (the minutes cursor past its end); a part records ``fly_chain = rh`` and ``fly_rh_agg``.
12
+ The stream builds no day itself: once one is due it starts ``fly-trader rh-build-corpus`` (``run_locked``) as a child
13
+ process at the lowest CPU priority. A day took ~30 s in the stream's thread at 00:00 UTC on 2026-10-23, in front of the
14
+ next minute's write, and the other v4 pools (rh/v4_universe.py) add several times the swaps.
15
  """
16
  from __future__ import annotations
17
 
18
+ import fcntl
19
  import logging
20
+ import time
21
  from datetime import date, datetime, timedelta, timezone
22
 
23
  import pyarrow as pa
 
53
 
54
 
55
  def _meta_extra() -> tuple[dict, dict, dict]:
56
+ """(graduations, supplies, TokenMeta extras) for every graduated launch. Its fee medians read all of rh_minutes (13 s on
57
+ 2026-10-23): ``run_once`` reads it once a run, in the middle of a minute (``_quiet``)."""
58
  k = config.RH_ETH_PER_SOL
59
  with transaction() as conn:
60
+ g = conn.execute("SELECT mint, graduated_at, supply FROM rh_meta").fetchall()
61
  fees = conn.execute("SELECT mint, percentile_cont(0.5) WITHIN GROUP (ORDER BY fee_rate) AS f FROM rh_minutes WHERE fee_rate IS NOT NULL GROUP BY mint").fetchall()
62
  grads = {r["mint"]: r["graduated_at"] for r in g}
63
  fee = {r["mint"]: float(r["f"]) for r in fees}
64
  extra = {m: {"ec_size": 0.1 * k, "pool_fee": fee.get(m, config.RH_HOOK_FEE)} for m in grads}
65
+ return grads, {r["mint"]: float(r["supply"]) if r["supply"] else 1e9 for r in g}, extra # a plain v4 memecoin's own supply
66
 
67
 
68
+ def _quiet() -> None:
69
+ """Wait for the middle of a minute (agent/live_first.MID_MINUTE) before a long read of the database: the live
70
+ pipeline's reads run in the first seconds after a minute's end."""
71
+ from ..agent import live_first
72
+ while not live_first.mid_minute():
73
+ time.sleep(1.0)
74
+
75
+
76
+ def build_day(d: date, meta: tuple | None = None) -> int:
77
+ """Day ``d``'s feature rows; ``meta``: ``_meta_extra()``'s result, read here when not given."""
78
+ grads, supplies, extra = meta if meta is not None else _meta_extra()
79
  return mature.build_day(d, grads=grads, supplies=supplies, src_dir=MATURE_DIR, out_dir=FEAT_DIR, program_label=PONS_LABEL, meta_extra=extra,
80
  extra_md={"fly_chain": "rh", "fly_rh_agg": RH_AGG_VERSION})
81
 
 
101
  last = complete_through()
102
  if last is None:
103
  return {"days": 0}
104
+ d = date.fromisoformat(config.RH_START_DAY); done = 0; out = {}; meta = None
105
  while d <= last and done < max_days:
106
  part = FEAT_DIR / d.isoformat() / "part.parquet"; agg = MATURE_DIR / f"{d.isoformat()}.parquet"
107
  if not agg.exists() or not part_current(part):
108
  wallet_skill.write_table(d)
109
  n_agg = aggregate(d)
110
+ if n_agg and meta is None:
111
+ _quiet(); meta = _meta_extra() # once a run, not once a day
112
+ n_part = build_day(d, meta) if n_agg else 0
113
  wallet_skill.build_wallet_day(d - timedelta(days=1))
114
  out[d.isoformat()] = {"minutes": n_agg, "rows": n_part}; done += 1
115
  d += timedelta(days=1)
116
  return {"days": done, "built": out}
117
+
118
+
119
+ def due(days: int = 3) -> bool:
120
+ """Whether one of the last ``days`` complete days lacks its corpus files (``run_once`` would build it)."""
121
+ last = complete_through()
122
+ if last is None:
123
+ return False
124
+ first = date.fromisoformat(config.RH_START_DAY)
125
+ for i in range(days):
126
+ d = last - timedelta(days=i)
127
+ if d < first:
128
+ break
129
+ if not (MATURE_DIR / f"{d.isoformat()}.parquet").exists() or not part_current(FEAT_DIR / d.isoformat() / "part.parquet"):
130
+ return True
131
+ return False
132
+
133
+
134
+ def run_locked(max_days: int = 3) -> dict:
135
+ """``run_once`` under a file lock, one builder at a time across processes (``fly-trader rh-build-corpus``)."""
136
+ MATURE_DIR.mkdir(parents=True, exist_ok=True)
137
+ with open(MATURE_DIR / ".rh_build.lock", "w") as lock:
138
+ try:
139
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
140
+ except BlockingIOError:
141
+ return {"days": 0, "busy": True}
142
+ return run_once(max_days)
fly_trader/rh/index.py CHANGED
@@ -7,8 +7,10 @@ Per block range, in order:
7
  2. those events applied in (block, log index) order → rh_tokens, rh_pools, rh_insiders; a graduation also pulls that
8
  launch's curve trades from its own curve contract (launch → graduation, address-filtered): only the ~1 % of launches
9
  that graduate are ever traded, and their curve phase is all the features need (curve facts, time to graduate);
10
- 3. the v4 PoolManager's Initialize / Swap / ModifyLiquidity logs for the Pons pools known by then (topic1 = pool id,
11
- 200 ids per request), each swap matched to its hook fee by (tx, pool) in log order;
 
 
12
  4. each swap's trader from the memecoin's own Transfer logs in that transaction (address-filtered, one request per 200
13
  tokens): the wallet whose token balance rose on a buy (fell on a sell), not the pool manager or a router — the swap
14
  event itself names the router (measured 2026-09-28: per-swap tx lookups made a 55-day backfill take ~43 days);
@@ -196,11 +198,13 @@ def apply_pons(conn, rpc, events: list[dict]) -> dict:
196
  return n
197
 
198
 
199
- def apply_v4(conn, rpc, logs: list[dict], fees: list[dict]) -> dict:
200
- """v4 logs of Pons pools (+ their hook fees) → rh_swaps / rh_liquidity / rh_pools state."""
 
 
201
  pools = {r["pool_id"]: dict(r) for r in conn.execute(
202
- "SELECT p.pool_id, p.token, p.token_is_0, p.quote_asset, p.hook_fee, p.hook_fee_v, a.decimals AS qdec FROM rh_pools p JOIN rh_assets a ON a.asset = p.quote_asset "
203
- "WHERE p.is_pons").fetchall()}
204
  fee_q: dict[tuple, list] = {}; observed: dict[str, int] = {}
205
  for f in fees:
206
  fee_q.setdefault((f["tx_hash"], f["pool_id"]), []).append(f)
@@ -225,9 +229,10 @@ def apply_v4(conn, rpc, logs: list[dict], fees: list[dict]) -> dict:
225
  if e is None or e["pool_id"] not in pools:
226
  continue
227
  p = pools[e["pool_id"]]
228
- if e["event"] == "Initialize":
229
- conn.execute("UPDATE rh_pools SET sqrt_price_x96 = %s, tick = %s, fee = %s, tick_spacing = %s, hooks = %s, updated_block = %s WHERE pool_id = %s",
230
- (e["sqrt_price_x96"], e["tick"], e["fee"], e["tick_spacing"], e["hooks"], e["block"], e["pool_id"]))
 
231
  elif e["event"] == "ModifyLiquidity":
232
  conn.execute("INSERT INTO rh_liquidity (block, log_index, tx_hash, ts, pool_id, sender, tick_lower, tick_upper, liquidity_delta, salt) "
233
  "VALUES (%s, %s, %s, to_timestamp(%s), %s, %s, %s, %s, %s, %s) ON CONFLICT DO NOTHING",
@@ -239,9 +244,11 @@ def apply_v4(conn, rpc, logs: list[dict], fees: list[dict]) -> dict:
239
  side = 1 if tok_amt > 0 else -1 # the swapper received tokens: a buy
240
  fq = fee_q.get((e["tx_hash"], e["pool_id"])) or []
241
  fee = fq.pop(0) if fq else None
242
- price_q = v4.token_price_in_quote(e["sqrt_price_x96"], ti0, PONS_DECIMALS, qdec)
243
  fee_frac = None
244
- if fee:
 
 
245
  # the hook takes fee + tax out of the pool's gross output, which is the Swap event's amount of that currency:
246
  # the swapper receives (1 − r) of it (live fills receive exactly that), so r = taken / gross. Until 2026-10-17
247
  # this stored taken / (gross + taken), 0.0099 for a 1 % fee: those rows have fee_v NULL and readers convert them.
@@ -267,8 +274,8 @@ def apply_v4(conn, rpc, logs: list[dict], fees: list[dict]) -> dict:
267
  conn.execute(f"UPDATE rh_pools SET hook_fee = (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY {FEE_FRAC}) FROM rh_swaps "
268
  "WHERE pool_id = %s AND hook_fee_raw IS NOT NULL AND fee_frac IS NOT NULL), hook_fee_v = 2 WHERE pool_id = %s", (pid, pid))
269
  for pid, e in last_state.items():
270
- conn.execute("UPDATE rh_pools SET sqrt_price_x96 = %s, liquidity = %s, tick = %s, updated_block = %s WHERE pool_id = %s",
271
- (e["sqrt_price_x96"], e["liquidity"], e["tick"], e["block"], pid))
272
  return n
273
 
274
 
@@ -281,11 +288,11 @@ def step(conn, rpc, lo: int, hi: int) -> dict:
281
  evs = [e for e in evs if e is not None]
282
  fill_timestamps(rpc, evs, lo, hi)
283
  n = apply_pons(conn, rpc, evs)
284
- ids = [r["pool_id"] for r in conn.execute("SELECT pool_id FROM rh_pools WHERE is_pons ORDER BY pool_id").fetchall()]
285
  need = [r["pool_id"] for r in conn.execute("SELECT pool_id FROM rh_pools WHERE is_pons AND hook_fee IS NULL ORDER BY pool_id").fetchall()]
286
  fee_topic = [FEE_TOPIC]
287
  v4_logs, fee_logs = [], []
288
- if bulk and hi - lo + 1 <= LIVE_SPAN_BULK: # a live range: every PoolManager event, matched to Pons pools here
289
  mine = set(ids) # (10k+ pool ids make a ~0.7 MB filter; HyperSync caps bodies at 2 MB)
290
  v4_logs = [g for g in _get_logs(rpc, [config.V4_POOL_MANAGER], lo, hi, [topics0]) if len(g["topics"]) > 1 and g["topics"][1].lower() in mine]
291
  else:
 
7
  2. those events applied in (block, log index) order → rh_tokens, rh_pools, rh_insiders; a graduation also pulls that
8
  launch's curve trades from its own curve contract (launch → graduation, address-filtered): only the ~1 % of launches
9
  that graduate are ever traded, and their curve phase is all the features need (curve facts, time to graduate);
10
+ 3. the v4 PoolManager's Initialize / Swap / ModifyLiquidity logs for the Pons pools known by then and the other v4 pools
11
+ indexed since 2026-10-23 (rh/v4_universe.py: a Pons coin's own pools elsewhere, plain v4 memecoins; topic1 = pool id,
12
+ 200 ids per request), each Pons swap matched to its hook fee by (tx, pool) in log order, another pool's swap priced at
13
+ the LP fee its Swap event reports;
14
  4. each swap's trader from the memecoin's own Transfer logs in that transaction (address-filtered, one request per 200
15
  tokens): the wallet whose token balance rose on a buy (fell on a sell), not the pool manager or a router — the swap
16
  event itself names the router (measured 2026-09-28: per-swap tx lookups made a 55-day backfill take ~43 days);
 
198
  return n
199
 
200
 
201
+ def apply_v4(conn, rpc, logs: list[dict], fees: list[dict], pool_ids: list[str] | None = None) -> dict:
202
+ """v4 logs of the indexed pools (Pons pools + their hook fees, and the other indexed v4 pools) → rh_swaps / rh_liquidity /
203
+ rh_pools state. ``pool_ids``: exactly these pools (a live pool's history before its ``live_from``: rh/v4_universe)."""
204
+ sel = "p.pool_id = ANY(%s)" if pool_ids is not None else "(p.is_pons OR p.indexed)"
205
  pools = {r["pool_id"]: dict(r) for r in conn.execute(
206
+ "SELECT p.pool_id, p.token, p.token_is_0, p.quote_asset, p.hook_fee, p.hook_fee_v, p.is_pons, a.decimals AS qdec FROM rh_pools p JOIN rh_assets a ON a.asset = p.quote_asset "
207
+ "WHERE " + sel, (list(pool_ids),) if pool_ids is not None else ()).fetchall()}
208
  fee_q: dict[tuple, list] = {}; observed: dict[str, int] = {}
209
  for f in fees:
210
  fee_q.setdefault((f["tx_hash"], f["pool_id"]), []).append(f)
 
229
  if e is None or e["pool_id"] not in pools:
230
  continue
231
  p = pools[e["pool_id"]]
232
+ if e["event"] == "Initialize": # (a history backfill never rolls a live pool's state back)
233
+ conn.execute("UPDATE rh_pools SET sqrt_price_x96 = %s, tick = %s, fee = %s, tick_spacing = %s, hooks = %s, updated_block = %s WHERE pool_id = %s "
234
+ "AND (updated_block IS NULL OR updated_block <= %s)",
235
+ (e["sqrt_price_x96"], e["tick"], e["fee"], e["tick_spacing"], e["hooks"], e["block"], e["pool_id"], e["block"]))
236
  elif e["event"] == "ModifyLiquidity":
237
  conn.execute("INSERT INTO rh_liquidity (block, log_index, tx_hash, ts, pool_id, sender, tick_lower, tick_upper, liquidity_delta, salt) "
238
  "VALUES (%s, %s, %s, to_timestamp(%s), %s, %s, %s, %s, %s, %s) ON CONFLICT DO NOTHING",
 
244
  side = 1 if tok_amt > 0 else -1 # the swapper received tokens: a buy
245
  fq = fee_q.get((e["tx_hash"], e["pool_id"])) or []
246
  fee = fq.pop(0) if fq else None
247
+ price_q = v4.token_price_in_quote(e["sqrt_price_x96"], ti0, PONS_DECIMALS, qdec) # 18 decimals: rh/token_check admits no other
248
  fee_frac = None
249
+ if not p["is_pons"]:
250
+ fee_frac = float(e.get("fee") or 0) / 1e6; fee_v = 2 # the swap's LP fee (hundredths of a bip)
251
+ elif fee:
252
  # the hook takes fee + tax out of the pool's gross output, which is the Swap event's amount of that currency:
253
  # the swapper receives (1 − r) of it (live fills receive exactly that), so r = taken / gross. Until 2026-10-17
254
  # this stored taken / (gross + taken), 0.0099 for a 1 % fee: those rows have fee_v NULL and readers convert them.
 
274
  conn.execute(f"UPDATE rh_pools SET hook_fee = (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY {FEE_FRAC}) FROM rh_swaps "
275
  "WHERE pool_id = %s AND hook_fee_raw IS NOT NULL AND fee_frac IS NOT NULL), hook_fee_v = 2 WHERE pool_id = %s", (pid, pid))
276
  for pid, e in last_state.items():
277
+ conn.execute("UPDATE rh_pools SET sqrt_price_x96 = %s, liquidity = %s, tick = %s, updated_block = %s WHERE pool_id = %s "
278
+ "AND (updated_block IS NULL OR updated_block <= %s)", (e["sqrt_price_x96"], e["liquidity"], e["tick"], e["block"], pid, e["block"]))
279
  return n
280
 
281
 
 
288
  evs = [e for e in evs if e is not None]
289
  fill_timestamps(rpc, evs, lo, hi)
290
  n = apply_pons(conn, rpc, evs)
291
+ ids = [r["pool_id"] for r in conn.execute("SELECT pool_id FROM rh_pools WHERE is_pons OR indexed ORDER BY pool_id").fetchall()]
292
  need = [r["pool_id"] for r in conn.execute("SELECT pool_id FROM rh_pools WHERE is_pons AND hook_fee IS NULL ORDER BY pool_id").fetchall()]
293
  fee_topic = [FEE_TOPIC]
294
  v4_logs, fee_logs = [], []
295
+ if bulk and hi - lo + 1 <= LIVE_SPAN_BULK: # a live range: every PoolManager event, matched to the indexed pools here
296
  mine = set(ids) # (10k+ pool ids make a ~0.7 MB filter; HyperSync caps bodies at 2 MB)
297
  v4_logs = [g for g in _get_logs(rpc, [config.V4_POOL_MANAGER], lo, hi, [topics0]) if len(g["topics"]) > 1 and g["topics"][1].lower() in mine]
298
  else:
fly_trader/rh/live.py CHANGED
@@ -89,12 +89,17 @@ class RhLiveMirror:
89
  self.last_mark: dict | None = None
90
  self.last_reconciled: dict | None = None
91
  self.wallet_eth: float | None = None
 
92
  self.ex.on_done = self._after_order
93
 
94
  @property
95
  def wallet(self):
96
  return self.ex.wallet
97
 
 
 
 
 
98
  def _decision(self, conn, m1, run_id, beat_id, p, reason: str, forced: bool) -> int:
99
  return int(conn.execute("INSERT INTO decisions (beat_id, run_id, ts, mint, pool, kind, size_sol, forced, reason, detail) VALUES (%s,%s,%s,%s,%s,'fly_exit',%s,%s,%s,%s) RETURNING id",
100
  (beat_id, run_id, m1, p["mint"], p["pool"], float(p["cost_sol"]), forced, reason, json.dumps({"book": BOOK}))).fetchone()["id"])
@@ -172,11 +177,39 @@ class RhLiveMirror:
172
  record_event("error", "rh_live", f"kill switch: liquidating {n_liq} open live RH position(s)", {"positions": n_liq})
173
  return {"exited": n_exit, "liquidated": n_liq}
174
 
175
- def minute(self, ctx, *, run_id: str, beat_id: int, entries: list) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  """The minute's entries, sent the moment the fly has decided (agent/fly_session.FlyBook.on_minute): the executor's
177
  results first (a failed buy is kept for another try), the minute's sales if the clock has not sent them, then the
178
- retries and the paper entries. The wallet is read only when there is something to buy; the wealth mark waits for
179
- ``idle``."""
 
180
  conn, m1 = ctx.conn, ctx.m1
181
  drained = self.ex.drain()
182
  for r in drained:
@@ -191,7 +224,7 @@ class RhLiveMirror:
191
  blocked = "kill switch" if c.kill_switch else "circuit tripped" if rails.breaker_blocks(c) else "paused" if c.entries_paused else None
192
  n_enter = n_retry = 0; skipped = []
193
  if self.failed or entries:
194
- reserved = reserved_wei(conn); native_eth = self.wallet.balance() / A.WEI; self.wallet_eth = native_eth
195
  committed = self.ex.committed_wei() / A.WEI
196
  bankroll = native_eth + sum(float(p["cost_sol"]) for p in opens if not self._dead(p, m1)) - reserved / A.WEI # not a dead-bag ``after`` writes off
197
  cash = native_eth - reserved / A.WEI - committed
@@ -201,14 +234,18 @@ class RhLiveMirror:
201
  if over:
202
  self.failed.pop(did); skipped.append((mint, "retries exhausted"))
203
  self._blocked(conn, ctx, run_id, beat_id, e, "retries exhausted", f"{over}; last error: {(f.get('error') or '')[:200]}", tries=f["tries"]); continue
 
 
204
  pool = pool_of(conn, e["info"].get("pool")); quote = ((pool or {}).get("quote_asset") or ZERO).lower()
205
- rail = blocked or ("stale minute" if stale else None) or ("held" if mint in held_mints else None) or ("base mark stale" if A.base_mark(conn, quote)[1] else None)
 
206
  size, why = (0.0, "") if rail else sizing.size_position(e["score"], e.get("threshold", 0.0), e.get("table") or [], bankroll, cash,
207
  ctx.resqs.get(e["mint"]) or e["info"]["resq"], flat=config.FLY_SIZING == "flat", market=RH)
208
  if rail or size <= 0:
209
- skipped.append((mint, rail or why)); self._blocked(conn, ctx, run_id, beat_id, e, rail or "sizing", f"retry not sent: {rail or why}", size, retry=True); continue
 
210
  if self._buy(e, mint, quote, pool, size, retry=f["tries"]):
211
- self.failed.pop(did); self.sent[did] = {**f, "tries": f["tries"] + 1}
212
  n_enter += 1; n_retry += 1; cash -= size; held_mints.add(mint)
213
  for e in entries:
214
  mint = e["mint"].lower()
@@ -276,6 +313,9 @@ class RhLiveMirror:
276
  except Exception:
277
  log.warning("live rh mark after an intent failed", exc_info=True)
278
  live_first.changed()
 
 
 
279
 
280
  def mark_due(self, conn=None, force: bool = False) -> dict | None:
281
  """The waiting minute's wealth mark (rh/accounting.wealth at the minute's prices) and circuit 3's kill switch on the
@@ -329,8 +369,9 @@ class RhLiveMirror:
329
  def gap(self, conn) -> dict:
330
  rows = conn.execute("""SELECT l.realized_sol / NULLIF(l.cost_sol, 0) AS rl, p.realized_sol / NULLIF(p.cost_sol, 0) AS rp
331
  FROM positions l JOIN positions p ON p.entry_decision_id = l.entry_decision_id AND p.book = %s AND p.status = 'closed'
332
- WHERE l.book = %s AND l.status = 'closed' AND l.entry_decision_id IS NOT NULL ORDER BY l.closed_at DESC LIMIT %s""",
333
- (MIRROR, BOOK, GAP_TRADES)).fetchall()
 
334
  pairs = [(r["rl"], r["rp"]) for r in rows if r["rl"] is not None and r["rp"] is not None]
335
  rl = np.array([a for a, _ in pairs], dtype=np.float64); rp = np.array([b for _, b in pairs], dtype=np.float64)
336
  out = {"trades": int(len(rl)), "live_mean": float(rl.mean()) if len(rl) else None, "mirror_mean": float(rp.mean()) if len(rp) else None}
 
89
  self.last_mark: dict | None = None
90
  self.last_reconciled: dict | None = None
91
  self.wallet_eth: float | None = None
92
+ self.sale_waits: dict[int, list] = {} # decision id -> [Event, result]: an operator's Sell Now waiting for its sale (``sell_now``)
93
  self.ex.on_done = self._after_order
94
 
95
  @property
96
  def wallet(self):
97
  return self.ex.wallet
98
 
99
+ def read_balance(self) -> int:
100
+ """The wallet's ETH (wei); started beside the minute's scoring by live_first.prefetch."""
101
+ return int(self.wallet.balance())
102
+
103
  def _decision(self, conn, m1, run_id, beat_id, p, reason: str, forced: bool) -> int:
104
  return int(conn.execute("INSERT INTO decisions (beat_id, run_id, ts, mint, pool, kind, size_sol, forced, reason, detail) VALUES (%s,%s,%s,%s,%s,'fly_exit',%s,%s,%s,%s) RETURNING id",
105
  (beat_id, run_id, m1, p["mint"], p["pool"], float(p["cost_sol"]), forced, reason, json.dumps({"book": BOOK}))).fetchone()["id"])
 
177
  record_event("error", "rh_live", f"kill switch: liquidating {n_liq} open live RH position(s)", {"positions": n_liq})
178
  return {"exited": n_exit, "liquidated": n_liq}
179
 
180
+ def sell_now(self, conn, position_id: int) -> int:
181
+ """The operator's Sell Now (the console's open positions, agent/live_first.sell_now): one open position of this book
182
+ sold at once, at the exit's slippage stepping up to the forced one, and booked by the executor like any exit, as a
183
+ forced 'operator' exit (the live-vs-paper gap and the fly's fill pathway leave it out). Its decision id; ValueError
184
+ with the reason when nothing was sent (the caller's transaction then rolls its decision back)."""
185
+ with self.exit_lock: # the clock's boundary sales check the same in-flight set: one sale per position
186
+ p = next((p for p in ledger.open_positions(conn, BOOK) if int(p["id"]) == int(position_id)), None)
187
+ if p is None:
188
+ raise ValueError("this position is no longer open")
189
+ if p["mint"].lower() in self.ex.pending():
190
+ raise ValueError("an order of this token is already queued or in flight")
191
+ did = self._decision(conn, datetime.now(timezone.utc), None, None, p, "sold now by the operator", True)
192
+ self.sale_waits[did] = [threading.Event(), None]
193
+ if not self._sell(conn, p, did, config.SLIPPAGE_EXIT_BPS, "operator"):
194
+ self.sale_waits.pop(did, None)
195
+ raise ValueError("an order of this token is already queued or in flight")
196
+ return did
197
+
198
+ def sale_result(self, did: int, timeout: float):
199
+ """The executor's result of a ``sell_now`` sale once booked, waiting at most ``timeout`` s (None: not yet)."""
200
+ w = self.sale_waits.get(did)
201
+ if w is None:
202
+ return None
203
+ w[0].wait(timeout)
204
+ self.sale_waits.pop(did, None)
205
+ return w[1]
206
+
207
+ def minute(self, ctx, *, run_id: str, beat_id: int, entries: list, rebuy: dict | None = None) -> dict:
208
  """The minute's entries, sent the moment the fly has decided (agent/fly_session.FlyBook.on_minute): the executor's
209
  results first (a failed buy is kept for another try), the minute's sales if the clock has not sent them, then the
210
+ retries and the paper entries. A retry goes out only where this minute would buy its token again (``rebuy``,
211
+ agent/paper_trading.plan_minute), on this minute's score and against this minute's price, never its decision's.
212
+ The wallet is read only when there is something to buy; the wealth mark waits for ``idle``."""
213
  conn, m1 = ctx.conn, ctx.m1
214
  drained = self.ex.drain()
215
  for r in drained:
 
224
  blocked = "kill switch" if c.kill_switch else "circuit tripped" if rails.breaker_blocks(c) else "paused" if c.entries_paused else None
225
  n_enter = n_retry = 0; skipped = []
226
  if self.failed or entries:
227
+ reserved = reserved_wei(conn); native_eth = live_first.take_balance(self, ctx.m1_epoch) / A.WEI; self.wallet_eth = native_eth
228
  committed = self.ex.committed_wei() / A.WEI
229
  bankroll = native_eth + sum(float(p["cost_sol"]) for p in opens if not self._dead(p, m1)) - reserved / A.WEI # not a dead-bag ``after`` writes off
230
  cash = native_eth - reserved / A.WEI - committed
 
234
  if over:
235
  self.failed.pop(did); skipped.append((mint, "retries exhausted"))
236
  self._blocked(conn, ctx, run_id, beat_id, e, "retries exhausted", f"{over}; last error: {(f.get('error') or '')[:200]}", tries=f["tries"]); continue
237
+ v = (rebuy or {}).get(e["mint"]) or {"why": "not scored this minute"} # this minute's verdict on buying it again
238
+ e = {**e, **{k: v[k] for k in ("score", "threshold", "table", "ref_price") if k in v}} # its score and price now, not its decision's
239
  pool = pool_of(conn, e["info"].get("pool")); quote = ((pool or {}).get("quote_asset") or ZERO).lower()
240
+ rail = (blocked or ("stale minute" if stale else None) or ("held" if mint in held_mints else None) or (None if "ref_price" in v else "no longer a buy")
241
+ or ("base mark stale" if A.base_mark(conn, quote)[1] else None))
242
  size, why = (0.0, "") if rail else sizing.size_position(e["score"], e.get("threshold", 0.0), e.get("table") or [], bankroll, cash,
243
  ctx.resqs.get(e["mint"]) or e["info"]["resq"], flat=config.FLY_SIZING == "flat", market=RH)
244
  if rail or size <= 0:
245
+ why = v["why"] if rail == "no longer a buy" else rail or why
246
+ skipped.append((mint, why)); self._blocked(conn, ctx, run_id, beat_id, e, rail or "sizing", f"retry not sent: {why}", size, retry=True); continue
247
  if self._buy(e, mint, quote, pool, size, retry=f["tries"]):
248
+ self.failed.pop(did); self.sent[did] = {**f, "e": e, "tries": f["tries"] + 1}
249
  n_enter += 1; n_retry += 1; cash -= size; held_mints.add(mint)
250
  for e in entries:
251
  mint = e["mint"].lower()
 
313
  except Exception:
314
  log.warning("live rh mark after an intent failed", exc_info=True)
315
  live_first.changed()
316
+ w = self.sale_waits.get(res.request.decision_id) if res is not None else None
317
+ if w is not None: # an operator's Sell Now waiting for this sale (``sell_now``)
318
+ w[1] = res; w[0].set()
319
 
320
  def mark_due(self, conn=None, force: bool = False) -> dict | None:
321
  """The waiting minute's wealth mark (rh/accounting.wealth at the minute's prices) and circuit 3's kill switch on the
 
369
  def gap(self, conn) -> dict:
370
  rows = conn.execute("""SELECT l.realized_sol / NULLIF(l.cost_sol, 0) AS rl, p.realized_sol / NULLIF(p.cost_sol, 0) AS rp
371
  FROM positions l JOIN positions p ON p.entry_decision_id = l.entry_decision_id AND p.book = %s AND p.status = 'closed'
372
+ WHERE l.book = %s AND l.status = 'closed' AND l.entry_decision_id IS NOT NULL AND COALESCE(l.forced_exit_kind, '') <> 'operator'
373
+ ORDER BY l.closed_at DESC LIMIT %s""",
374
+ (MIRROR, BOOK, GAP_TRADES)).fetchall() # an operator's Sell Now is the operator's choice, not an execution gap
375
  pairs = [(r["rl"], r["rp"]) for r in rows if r["rl"] is not None and r["rp"] is not None]
376
  rl = np.array([a for a, _ in pairs], dtype=np.float64); rp = np.array([b for _, b in pairs], dtype=np.float64)
377
  out = {"trades": int(len(rl)), "live_mean": float(rl.mean()) if len(rl) else None, "mirror_mean": float(rp.mean()) if len(rp) else None}
fly_trader/rh/meta.py CHANGED
@@ -86,6 +86,47 @@ def outcomes(conn, token: str, graduated_at) -> tuple[float, float] | None:
86
  COLS = ("mint", "graduated_at", "create_ts", "creator", "quote_asset", "quote_class", "supply", "ttg_min", "dev_sol", "dev_tokens", "dev_share", "mayhem", "rq0",
87
  "pool_id", "prior_launches", "prior_grads", "prior_known", "prior_rug_share", "prior_moon_share", "bundle_share", "dev_hold_share", "dev_sold_frac",
88
  "grad_hhi", "curve_known")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
 
91
  def run_once(limit: int = 500) -> dict:
@@ -101,8 +142,8 @@ def run_once(limit: int = 500) -> dict:
101
  continue
102
  conn.execute(f"INSERT INTO rh_meta ({', '.join(COLS)}) VALUES ({', '.join(['%s'] * len(COLS))}) ON CONFLICT (mint) DO NOTHING", [row.get(k) for k in COLS])
103
  n_new += 1
104
- due = conn.execute("SELECT m.mint, m.graduated_at FROM rh_meta m WHERE m.own_dd60 IS NULL AND m.graduated_at + interval '61 minutes' <= "
105
- "(SELECT max(ts) FROM rh_minutes) LIMIT %s", (limit,)).fetchall()
106
  for r in due:
107
  o = outcomes(conn, r["mint"], r["graduated_at"])
108
  if o is not None:
 
86
  COLS = ("mint", "graduated_at", "create_ts", "creator", "quote_asset", "quote_class", "supply", "ttg_min", "dev_sol", "dev_tokens", "dev_share", "mayhem", "rq0",
87
  "pool_id", "prior_launches", "prior_grads", "prior_known", "prior_rug_share", "prior_moon_share", "bundle_share", "dev_hold_share", "dev_sold_frac",
88
  "grad_hhi", "curve_known")
89
+ V4_COLS = COLS + ("source",)
90
+
91
+
92
+ def _history(conn, creator: str | None, since, g, mint: str) -> dict:
93
+ """A creator's earlier launches (Pons and plain v4) and graduations with their first-hour outcomes, as ``build``."""
94
+ if not creator:
95
+ return {"prior_launches": None, "prior_grads": None, "prior_known": None, "prior_rug_share": None, "prior_moon_share": None}
96
+ n = conn.execute("SELECT (SELECT count(*) FROM rh_tokens WHERE creator = %(c)s AND launch_ts < %(t)s) + "
97
+ "(SELECT count(*) FROM rh_v4_tokens WHERE creator = %(c)s AND first_ts < %(t)s) AS n", {"c": creator, "t": since}).fetchone()["n"]
98
+ r = conn.execute("""SELECT count(*) AS grads,
99
+ count(*) FILTER (WHERE own_dd60 IS NOT NULL AND graduated_at + interval '60 minutes' <= %(g)s) AS known,
100
+ avg(CASE WHEN own_dd60 <= -0.9 THEN 1.0 ELSE 0.0 END) FILTER (WHERE own_dd60 IS NOT NULL AND graduated_at + interval '60 minutes' <= %(g)s) AS rug,
101
+ avg(CASE WHEN own_max60 >= 1.0 THEN 1.0 ELSE 0.0 END) FILTER (WHERE own_dd60 IS NOT NULL AND graduated_at + interval '60 minutes' <= %(g)s) AS moon
102
+ FROM rh_meta WHERE creator = %(c)s AND graduated_at < %(g)s AND mint <> %(m)s""", {"c": creator, "g": g, "m": mint}).fetchone()
103
+ return {"prior_launches": int(n), "prior_grads": int(r["grads"]), "prior_known": int(r["known"]),
104
+ "prior_rug_share": float(r["rug"]) if r["rug"] is not None else None, "prior_moon_share": float(r["moon"]) if r["moon"] is not None else None}
105
+
106
+
107
+ def build_v4(conn, token: str) -> dict | None:
108
+ """rh_meta facts of a plain v4 memecoin that passed its contract check (rh/v4_universe.py): its age counts from its
109
+ first pool's initialization (``graduated_at``: the 6 h floor), its creator is that pool's initializer, and what only a
110
+ Pons launch records (curve, dev buy, seed reserve, time to graduate) is unknown, as for a Solana coin graduated before
111
+ the archive. Supply in whole tokens (18 decimals)."""
112
+ t = conn.execute("SELECT v.*, a.class AS qclass FROM rh_v4_tokens v LEFT JOIN rh_assets a ON a.asset = v.quote_asset WHERE v.token = %s AND v.status = 'ok'",
113
+ (token,)).fetchone()
114
+ if not t or t["first_ts"] is None:
115
+ return None
116
+ g = t["first_ts"]
117
+ return {"mint": token, "graduated_at": g, "create_ts": g, "creator": t["creator"], "quote_asset": t["quote_asset"], "quote_class": t["qclass"] or "eth",
118
+ "supply": float(t["supply_raw"]) / TOKEN if t["supply_raw"] is not None else None, "ttg_min": None, "dev_sol": None, "dev_tokens": None,
119
+ "dev_share": None, "mayhem": False, "rq0": None, "pool_id": t["first_pool"], **_history(conn, t["creator"], g, g, token),
120
+ "bundle_share": None, "dev_hold_share": None, "dev_sold_frac": None, "grad_hhi": None, "curve_known": 0.0, "source": "v4"}
121
+
122
+
123
+ def write_v4(conn, token: str) -> bool:
124
+ """``build_v4`` into rh_meta (idempotent); False when the coin has no accepted row."""
125
+ row = build_v4(conn, token)
126
+ if row is None:
127
+ return False
128
+ conn.execute(f"INSERT INTO rh_meta ({', '.join(V4_COLS)}) VALUES ({', '.join(['%s'] * len(V4_COLS))}) ON CONFLICT (mint) DO NOTHING", [row.get(k) for k in V4_COLS])
129
+ return True
130
 
131
 
132
  def run_once(limit: int = 500) -> dict:
 
142
  continue
143
  conn.execute(f"INSERT INTO rh_meta ({', '.join(COLS)}) VALUES ({', '.join(['%s'] * len(COLS))}) ON CONFLICT (mint) DO NOTHING", [row.get(k) for k in COLS])
144
  n_new += 1
145
+ due = conn.execute("SELECT m.mint, m.graduated_at FROM rh_meta m WHERE m.own_dd60 IS NULL AND m.source = 'pons' AND m.graduated_at + interval '61 minutes' <= "
146
+ "(SELECT max(ts) FROM rh_minutes) LIMIT %s", (limit,)).fetchall() # a plain v4 coin's: rh/v4_universe.backfill_history
147
  for r in due:
148
  o = outcomes(conn, r["mint"], r["graduated_at"])
149
  if o is not None:
fly_trader/rh/minutes.py CHANGED
@@ -2,15 +2,22 @@
2
 
3
  The same rules as the Solana stream (ingest/pumpstream.Aggregator) and the archive (train/mature.aggregate_table):
4
  - a swap counts only while its pool's reserve is plausible (``RESQ_BAND`` scaled by K = ETH per SOL) and its price is
5
- within ``PRICE_BAND`` (50×) of the median of the mint's previous ``REF_LEGS`` (200) prices that UTC day;
 
6
  - open/high/low/close = the pool price after each swap (sqrtPriceX96 of the Swap event), in ETH per token: the quote price
7
  times the quote asset's ETH value that minute (rh/prices.py; ETH-quoted pools: 1). A minute whose non-ETH quote has no
8
  mark yet is not written (it cannot be expressed in ETH);
9
  - buy/sell volume in ETH; resq = the pool's virtual quote reserve in ETH (full-range locked liquidity: constant product);
10
- - fee_rate = the volume-weighted Pons hook fee + tax fraction (the analogue of pump_minutes.fee_rate, the pool fee charged);
 
 
 
11
  - the wallet columns (buyers, wash, top seller, insider sells, buys per skill decile) by train/flow.minute_wallet_cols.
12
 
13
- Minutes are recomputed from the swaps, never accumulated, so a live pass and a backfill write identical rows.
 
 
 
14
  """
15
  from __future__ import annotations
16
 
@@ -42,9 +49,10 @@ class _Row:
42
  self.resq_eth = self.resq_q = None; self.fee_w = 0.0; self.fee_v = 0.0
43
 
44
 
45
- def aggregate(conn, t0: int, t1: int, skill: dict | None = None, quote_asset: str | None = None) -> list[tuple]:
46
  """rh_minutes rows for every minute in [t0, t1) (epoch s, minute-aligned). ``quote_asset``: only the pools quoted in
47
- it (a repair after that asset's ETH marks changed)."""
 
48
  k = config.RH_ETH_PER_SOL; lo_band, hi_band = RESQ_BAND_SOL[0] * k, RESQ_BAND_SOL[1] * k
49
  day0 = (t0 // 86400) * 86400
50
  swaps = conn.execute(
@@ -52,24 +60,34 @@ def aggregate(conn, t0: int, t1: int, skill: dict | None = None, quote_asset: st
52
  "CASE WHEN s.fee_v = 2 OR s.fee_frac IS NULL OR s.fee_frac >= 1 THEN s.fee_frac ELSE s.fee_frac / (1 - s.fee_frac) END AS fee_frac, " # rh/index.FEE_FRAC
53
  "p.quote_asset, a.decimals AS qdec, a.class AS cls "
54
  "FROM rh_swaps s JOIN rh_pools p ON p.pool_id = s.pool_id JOIN rh_assets a ON a.asset = p.quote_asset "
55
- "WHERE s.ts >= to_timestamp(%s) AND s.ts < to_timestamp(%s) AND (%s::text IS NULL OR p.quote_asset = %s) ORDER BY s.ts, s.block, s.log_index",
56
- (day0, t1, quote_asset, quote_asset)).fetchall()
 
57
  insiders: dict[str, frozenset] = {}
58
  base_cache: dict[tuple, tuple] = {}
59
- ref: dict[str, deque] = {}; ref_day = None
 
 
 
 
 
 
 
 
 
60
  rows: dict[tuple, _Row] = {}
61
  for s in swaps:
62
  ts = s["ts"].timestamp(); day = int(ts // 86400)
63
- if ref_day is None or day != ref_day:
64
  ref = {}; ref_day = day
65
  p = float(s["price_q"] or 0.0)
66
  if p <= 0:
67
  continue
68
- dq = ref.setdefault(s["token"], deque(maxlen=REF_LEGS))
69
  ok = not dq or (statistics.median(dq) / PRICE_BAND <= p <= statistics.median(dq) * PRICE_BAND)
70
  dq.append(p)
71
- if not ok or ts < t0:
72
- continue # before t0: only the median's history
73
  m = int(ts // 60) * 60
74
  key_b = (s["quote_asset"], m)
75
  if key_b not in base_cache:
@@ -80,9 +98,9 @@ def aggregate(conn, t0: int, t1: int, skill: dict | None = None, quote_asset: st
80
  resq_eth = float(s["resq_q"] or 0.0) * base
81
  if not (lo_band <= resq_eth <= hi_band):
82
  continue
83
- r = rows.get((s["token"], m))
84
  if r is None:
85
- r = rows[(s["token"], m)] = _Row(s["pool_id"], s["quote_asset"], s["cls"], base)
86
  pe = p * base
87
  r.open = pe if r.open is None else r.open; r.high = max(r.high, pe); r.low = min(r.low, pe); r.close = pe; r.close_q = p
88
  vol = float(s["quote_raw"] or 0) / 10 ** int(s["qdec"]) * base
@@ -95,8 +113,13 @@ def aggregate(conn, t0: int, t1: int, skill: dict | None = None, quote_asset: st
95
  r.resq_eth, r.resq_q = resq_eth, float(s["resq_q"] or 0.0)
96
  if s["fee_frac"] is not None:
97
  r.fee_w += float(s["fee_frac"]) * vol; r.fee_v += vol
 
 
 
 
 
98
  out = []
99
- for (mint, m), r in sorted(rows.items(), key=lambda kv: (kv[0][1], kv[0][0])):
100
  if mint not in insiders:
101
  insiders[mint] = frozenset(x["wallet"] for x in conn.execute("SELECT wallet FROM rh_insiders WHERE mint = %s", (mint,)).fetchall())
102
  wc = minute_wallet_cols(r.wallets, insiders[mint], skill)
@@ -151,4 +174,6 @@ def run_once(rpc, skill: dict | None = None, max_minutes: int = 240) -> dict:
151
  (json.dumps({"through": t1}),))
152
  conn.execute("INSERT INTO ui_settings (key, value) VALUES ('rh_stream_status', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
153
  (json.dumps({"flushed_through": datetime.fromtimestamp(t1 - 60, timezone.utc).isoformat(), "rows": n}),))
 
 
154
  return {"minutes": (t1 - done) // 60, "rows": n, "through": t1}
 
2
 
3
  The same rules as the Solana stream (ingest/pumpstream.Aggregator) and the archive (train/mature.aggregate_table):
4
  - a swap counts only while its pool's reserve is plausible (``RESQ_BAND`` scaled by K = ETH per SOL) and its price is
5
+ within ``PRICE_BAND`` (50×) of the median of the pool's previous ``REF_LEGS`` (200) prices that UTC day (a Pons coin has
6
+ one Pons pool: until 2026-10-23 the median was keyed by the mint, the same prices);
7
  - open/high/low/close = the pool price after each swap (sqrtPriceX96 of the Swap event), in ETH per token: the quote price
8
  times the quote asset's ETH value that minute (rh/prices.py; ETH-quoted pools: 1). A minute whose non-ETH quote has no
9
  mark yet is not written (it cannot be expressed in ETH);
10
  - buy/sell volume in ETH; resq = the pool's virtual quote reserve in ETH (full-range locked liquidity: constant product);
11
+ - fee_rate = the volume-weighted Pons hook fee + tax fraction, or another v4 pool's LP fee (the analogue of
12
+ pump_minutes.fee_rate, the pool fee charged);
13
+ - a coin with several indexed pools (a Pons coin's own pools elsewhere, a plain v4 memecoin's pools: rh/v4_universe.py)
14
+ takes each minute from the pool with the most swaps, as the Solana stream and archive take the pool with the most legs;
15
  - the wallet columns (buyers, wash, top seller, insider sells, buys per skill decile) by train/flow.minute_wallet_cols.
16
 
17
+ Minutes are recomputed from the swaps, never accumulated, so a live pass and a backfill write identical rows. Only the
18
+ median needs swaps before the first minute: each pool's last ``REF_LEGS`` prices that day are read for it (until 2026-10-23
19
+ every swap since 00:00 UTC was re-read for each live minute: ~2.3 s per minute by 23:59, in front of the RH live entries).
20
+ Only the Pons pools and the other v4 pools that went live (``indexed``) count, unless ``tokens`` names the coins.
21
  """
22
  from __future__ import annotations
23
 
 
49
  self.resq_eth = self.resq_q = None; self.fee_w = 0.0; self.fee_v = 0.0
50
 
51
 
52
+ def aggregate(conn, t0: int, t1: int, skill: dict | None = None, quote_asset: str | None = None, tokens: list[str] | None = None) -> list[tuple]:
53
  """rh_minutes rows for every minute in [t0, t1) (epoch s, minute-aligned). ``quote_asset``: only the pools quoted in
54
+ it (a repair after that asset's ETH marks changed); ``tokens``: only these coins, every pool of theirs indexed so far
55
+ (a backfill of their history; otherwise only live pools count)."""
56
  k = config.RH_ETH_PER_SOL; lo_band, hi_band = RESQ_BAND_SOL[0] * k, RESQ_BAND_SOL[1] * k
57
  day0 = (t0 // 86400) * 86400
58
  swaps = conn.execute(
 
60
  "CASE WHEN s.fee_v = 2 OR s.fee_frac IS NULL OR s.fee_frac >= 1 THEN s.fee_frac ELSE s.fee_frac / (1 - s.fee_frac) END AS fee_frac, " # rh/index.FEE_FRAC
61
  "p.quote_asset, a.decimals AS qdec, a.class AS cls "
62
  "FROM rh_swaps s JOIN rh_pools p ON p.pool_id = s.pool_id JOIN rh_assets a ON a.asset = p.quote_asset "
63
+ "WHERE s.ts >= to_timestamp(%(t0)s) AND s.ts < to_timestamp(%(t1)s) AND (%(qa)s::text IS NULL OR p.quote_asset = %(qa)s) "
64
+ "AND (CASE WHEN %(toks)s::text[] IS NULL THEN p.is_pons OR p.indexed ELSE s.token = ANY(%(toks)s::text[]) END) ORDER BY s.ts, s.block, s.log_index",
65
+ {"t0": t0, "t1": t1, "qa": quote_asset, "toks": tokens}).fetchall()
66
  insiders: dict[str, frozenset] = {}
67
  base_cache: dict[tuple, tuple] = {}
68
+ ref: dict[str, deque] = {}; ref_day = day0 // 86400
69
+ first_day = sorted({(s["token"], s["pool_id"]) for s in swaps if s["ts"].timestamp() < day0 + 86400})
70
+ if first_day and t0 > day0: # the median's state at t0: each pool's last prices that day
71
+ for h in conn.execute(
72
+ "SELECT u.pool_id, h.price_q FROM unnest(%s::text[], %s::text[]) AS u(token, pool_id) CROSS JOIN LATERAL ("
73
+ "SELECT s.price_q, s.ts, s.block, s.log_index FROM rh_swaps s WHERE s.token = u.token AND s.pool_id = u.pool_id "
74
+ "AND s.ts >= to_timestamp(%s) AND s.ts < to_timestamp(%s) AND s.price_q > 0 "
75
+ "ORDER BY s.ts DESC, s.block DESC, s.log_index DESC LIMIT %s) h ORDER BY u.pool_id, h.ts, h.block, h.log_index",
76
+ ([t for t, _ in first_day], [p for _, p in first_day], day0, t0, REF_LEGS)).fetchall():
77
+ ref.setdefault(h["pool_id"], deque(maxlen=REF_LEGS)).append(float(h["price_q"]))
78
  rows: dict[tuple, _Row] = {}
79
  for s in swaps:
80
  ts = s["ts"].timestamp(); day = int(ts // 86400)
81
+ if day != ref_day:
82
  ref = {}; ref_day = day
83
  p = float(s["price_q"] or 0.0)
84
  if p <= 0:
85
  continue
86
+ dq = ref.setdefault(s["pool_id"], deque(maxlen=REF_LEGS))
87
  ok = not dq or (statistics.median(dq) / PRICE_BAND <= p <= statistics.median(dq) * PRICE_BAND)
88
  dq.append(p)
89
+ if not ok:
90
+ continue
91
  m = int(ts // 60) * 60
92
  key_b = (s["quote_asset"], m)
93
  if key_b not in base_cache:
 
98
  resq_eth = float(s["resq_q"] or 0.0) * base
99
  if not (lo_band <= resq_eth <= hi_band):
100
  continue
101
+ r = rows.get((s["token"], m, s["pool_id"]))
102
  if r is None:
103
+ r = rows[(s["token"], m, s["pool_id"])] = _Row(s["pool_id"], s["quote_asset"], s["cls"], base)
104
  pe = p * base
105
  r.open = pe if r.open is None else r.open; r.high = max(r.high, pe); r.low = min(r.low, pe); r.close = pe; r.close_q = p
106
  vol = float(s["quote_raw"] or 0) / 10 ** int(s["qdec"]) * base
 
113
  r.resq_eth, r.resq_q = resq_eth, float(s["resq_q"] or 0.0)
114
  if s["fee_frac"] is not None:
115
  r.fee_w += float(s["fee_frac"]) * vol; r.fee_v += vol
116
+ best: dict[tuple, _Row] = {} # per (coin, minute): the pool with the most swaps
117
+ for (mint, m, _pool), r in rows.items():
118
+ b = best.get((mint, m))
119
+ if b is None or (r.nb + r.ns, r.buy + r.sell) > (b.nb + b.ns, b.buy + b.sell):
120
+ best[(mint, m)] = r
121
  out = []
122
+ for (mint, m), r in sorted(best.items(), key=lambda kv: (kv[0][1], kv[0][0])):
123
  if mint not in insiders:
124
  insiders[mint] = frozenset(x["wallet"] for x in conn.execute("SELECT wallet FROM rh_insiders WHERE mint = %s", (mint,)).fetchall())
125
  wc = minute_wallet_cols(r.wallets, insiders[mint], skill)
 
174
  (json.dumps({"through": t1}),))
175
  conn.execute("INSERT INTO ui_settings (key, value) VALUES ('rh_stream_status', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
176
  (json.dumps({"flushed_through": datetime.fromtimestamp(t1 - 60, timezone.utc).isoformat(), "rows": n}),))
177
+ from ..agent import live_first
178
+ live_first.minute_published() # committed: the engine's loop wakes now, not at its next poll
179
  return {"minutes": (t1 - done) // 60, "rows": n, "through": t1}
fly_trader/rh/prefetch.py CHANGED
@@ -112,7 +112,7 @@ def selections(conn) -> list[dict]:
112
  H.selection([config.PONS_HOOK], [[index.FEE_TOPIC]])]
113
  hops: dict = {}
114
  for r in conn.execute("SELECT DISTINCT a.asset, a.ref_path::text AS ref_path FROM rh_assets a JOIN rh_pools p ON p.quote_asset = a.asset "
115
- "WHERE p.is_pons AND a.class <> 'eth' AND a.ref_path IS NOT NULL").fetchall():
116
  for h in json.loads(r["ref_path"]):
117
  hops.setdefault(prices._hop_key(h), h)
118
  plain = sorted({h["emitter"].lower() for h in hops.values() if not prices._is_id(h["pool"])})
 
112
  H.selection([config.PONS_HOOK], [[index.FEE_TOPIC]])]
113
  hops: dict = {}
114
  for r in conn.execute("SELECT DISTINCT a.asset, a.ref_path::text AS ref_path FROM rh_assets a JOIN rh_pools p ON p.quote_asset = a.asset "
115
+ "WHERE (p.is_pons OR p.indexed) AND a.class <> 'eth' AND a.ref_path IS NOT NULL").fetchall():
116
  for h in json.loads(r["ref_path"]):
117
  hops.setdefault(prices._hop_key(h), h)
118
  plain = sorted({h["emitter"].lower() for h in hops.values() if not prices._is_id(h["pool"])})
fly_trader/rh/prices.py CHANGED
@@ -175,9 +175,9 @@ def scan_asset(conn, rpc, asset: str, hops: list[dict], lo: int, hi: int, state:
175
 
176
 
177
  def base_eth(conn, asset: str | None, ts) -> tuple[float | None, bool]:
178
- """(ETH per whole unit of ``asset`` at minute ``ts``, stale). ETH itself is 1. The last mark at or before the minute;
179
- stale when older than ``STALE_S`` or flagged stale."""
180
- if asset is None or int(asset, 16) == 0:
181
  return 1.0, False
182
  r = conn.execute("SELECT ts, price_eth, stale FROM rh_base_prices WHERE asset = %s AND ts <= %s ORDER BY ts DESC LIMIT 1", (asset.lower(), ts)).fetchone()
183
  if r is None:
@@ -195,7 +195,7 @@ def run_once(max_ranges: int = 1, rpc=None) -> dict:
195
  with transaction() as conn:
196
  top = conn.execute("SELECT block, detail FROM rh_scan WHERE name = 'rh'").fetchone()
197
  rows = conn.execute("SELECT DISTINCT a.asset, a.decimals, a.ref_path FROM rh_assets a JOIN rh_pools p ON p.quote_asset = a.asset "
198
- "WHERE p.is_pons AND a.class <> 'eth'").fetchall()
199
  if not top:
200
  return out
201
  through = int(top["block"])
 
175
 
176
 
177
  def base_eth(conn, asset: str | None, ts) -> tuple[float | None, bool]:
178
+ """(ETH per whole unit of ``asset`` at minute ``ts``, stale). ETH itself is 1, and so is WETH (a plain v4 memecoin's pool
179
+ may be quoted in either). The last mark at or before the minute; stale when older than ``STALE_S`` or flagged stale."""
180
+ if asset is None or int(asset, 16) == 0 or asset.lower() == config.RH_WETH.lower():
181
  return 1.0, False
182
  r = conn.execute("SELECT ts, price_eth, stale FROM rh_base_prices WHERE asset = %s AND ts <= %s ORDER BY ts DESC LIMIT 1", (asset.lower(), ts)).fetchone()
183
  if r is None:
 
195
  with transaction() as conn:
196
  top = conn.execute("SELECT block, detail FROM rh_scan WHERE name = 'rh'").fetchone()
197
  rows = conn.execute("SELECT DISTINCT a.asset, a.decimals, a.ref_path FROM rh_assets a JOIN rh_pools p ON p.quote_asset = a.asset "
198
+ "WHERE (p.is_pons OR p.indexed) AND a.class <> 'eth'").fetchall()
199
  if not top:
200
  return out
201
  through = int(top["block"])
fly_trader/rh/schema.py CHANGED
@@ -139,6 +139,36 @@ RH_DDL: list[str] = [
139
  "ALTER TABLE positions ADD COLUMN IF NOT EXISTS gas_q double precision NOT NULL DEFAULT 0",
140
  "ALTER TABLE fly_scored ADD COLUMN IF NOT EXISTS chain text NOT NULL DEFAULT 'sol'",
141
  "ALTER TABLE notional_ledger ADD COLUMN IF NOT EXISTS market text NOT NULL DEFAULT 'sol'",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  # rails circuit 3 (fly_trader/markets.RH_CIRCUIT); the id CHECK only allowed 1 before the Kalshi migration dropped it,
143
  # and an install still on the distribution's older ladder may carry it
144
  "ALTER TABLE circuit_state DROP CONSTRAINT IF EXISTS circuit_state_id_check",
 
139
  "ALTER TABLE positions ADD COLUMN IF NOT EXISTS gas_q double precision NOT NULL DEFAULT 0",
140
  "ALTER TABLE fly_scored ADD COLUMN IF NOT EXISTS chain text NOT NULL DEFAULT 'sol'",
141
  "ALTER TABLE notional_ledger ADD COLUMN IF NOT EXISTS market text NOT NULL DEFAULT 'sol'",
142
+ # ---- other Uniswap v4 pools (rh/v4_universe.py, 2026-10-23): a Pons coin's own pools outside its Pons pool ('pons_other')
143
+ # and plain memecoin pools ('v4'), registered once active past the floors. ``indexed``: live, its swaps indexed like a
144
+ # Pons pool's (rh/index.apply_v4) from block ``live_from``; ``ready_through``: its history before that, backfilled to
145
+ # this block (fly-trader rh-v4-backfill); ``lp_fee``: the static LP fee (None: dynamic, read per swap); ``swaps_seen``:
146
+ # its last half hour's swaps while not live (the contract checks take the busiest coins first)
147
+ "ALTER TABLE rh_pools ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'pons'",
148
+ "ALTER TABLE rh_pools ADD COLUMN IF NOT EXISTS indexed boolean NOT NULL DEFAULT false",
149
+ "ALTER TABLE rh_pools ADD COLUMN IF NOT EXISTS live_from bigint",
150
+ "ALTER TABLE rh_pools ADD COLUMN IF NOT EXISTS ready_through bigint",
151
+ "ALTER TABLE rh_pools ADD COLUMN IF NOT EXISTS lp_fee double precision",
152
+ "ALTER TABLE rh_pools ADD COLUMN IF NOT EXISTS init_tx text",
153
+ "ALTER TABLE rh_pools ADD COLUMN IF NOT EXISTS swaps_seen int NOT NULL DEFAULT 0",
154
+ "ALTER TABLE rh_pools ADD COLUMN IF NOT EXISTS last_swap_at timestamptz",
155
+ "CREATE INDEX IF NOT EXISTS rh_pools_indexed_idx ON rh_pools (indexed) WHERE indexed",
156
+ # every v4 pool's key from its Initialize (1.07 M pools by 2026-10-23; the Pons hook's are rh_pools'): what a pool
157
+ # is, once it trades. ``hooks`` NULL: none
158
+ """CREATE TABLE IF NOT EXISTS rh_v4_keys (
159
+ pool_id text PRIMARY KEY, currency0 text NOT NULL, currency1 text NOT NULL, fee int, tick_spacing int, hooks text,
160
+ init_block bigint NOT NULL, init_ts timestamptz, init_tx text, sqrt_price_x96 numeric(78,0), tick int)""",
161
+ "CREATE INDEX IF NOT EXISTS rh_v4_keys_c0_idx ON rh_v4_keys (currency0)",
162
+ "CREATE INDEX IF NOT EXISTS rh_v4_keys_c1_idx ON rh_v4_keys (currency1)",
163
+ # a plain v4 memecoin: its contract check (rh/token_check.py) and the facts its rh_meta row is built from
164
+ """CREATE TABLE IF NOT EXISTS rh_v4_tokens (
165
+ token text PRIMARY KEY, symbol text, name text, decimals int, supply_raw numeric(78,0), creator text,
166
+ first_pool text, first_block bigint, first_ts timestamptz, quote_asset text,
167
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ok', 'refused', 'error')), reason text, detail jsonb,
168
+ attempts int NOT NULL DEFAULT 0, checked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now())""",
169
+ "CREATE INDEX IF NOT EXISTS rh_v4_tokens_status_idx ON rh_v4_tokens (status)",
170
+ # where an rh_meta row comes from: a Pons launch ('pons') or a plain v4 memecoin ('v4', graduated_at = its first pool)
171
+ "ALTER TABLE rh_meta ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'pons'",
172
  # rails circuit 3 (fly_trader/markets.RH_CIRCUIT); the id CHECK only allowed 1 before the Kalshi migration dropped it,
173
  # and an install still on the distribution's older ladder may carry it
174
  "ALTER TABLE circuit_state DROP CONSTRAINT IF EXISTS circuit_state_id_check",
fly_trader/rh/stream.py CHANGED
@@ -10,6 +10,9 @@ from __future__ import annotations
10
 
11
  import json
12
  import logging
 
 
 
13
  import threading
14
  import time
15
  from datetime import datetime, timezone
@@ -26,6 +29,8 @@ LIVE_LAG_BLOCKS = 5_000 # closer than this to the head counts as live
26
  RPC_CATCHUP_S = 3.0 # between catch-up passes while HyperSync is down (each reads ~2-3 times from the public RPC)
27
  SLOW_EVERY_S = 60.0 # meta and corpus refresh with each written minute, else at least this often
28
  _SLOW = {"at": 0.0}
 
 
29
 
30
 
31
  def _status(**kv) -> None:
@@ -82,13 +87,18 @@ def one_pass(rpc, skill=None) -> dict:
82
  px = prices.run_once(max_ranges=BACKFILL_RANGES if behind > LIVE_LAG_BLOCKS else 2, rpc=rpc); lap("prices")
83
  from . import wallet_skill
84
  wallet_skill.daily(); lap("skill_tables") # today's and tomorrow's tables, before their minutes are written
85
- mn = minutes.run_once(src, wallet_skill.load_today() if skill is None else skill); lap("minutes")
86
- from . import corpus, meta
 
 
 
 
 
87
  mt, cp = {}, {}
88
  if mn.get("minutes") or time.monotonic() - _SLOW["at"] >= SLOW_EVERY_S: # after a written minute: passes in between stay short
89
  _SLOW["at"] = time.monotonic()
90
  mt = meta.run_once(); lap("meta")
91
- cp = corpus.run_once(max_days=1) if behind <= LIVE_LAG_BLOCKS * 20 else {} # the training corpus follows once near the head
92
  lap("corpus")
93
  out = {"head": head, "index_through": ix.get("through"), "behind_blocks": head - (ix.get("through") or 0), "index": ix.get("counts"), "prices": px,
94
  "minutes": mn, "meta": mt, "corpus": cp, "mode": "backfill" if behind > LIVE_LAG_BLOCKS else "live", "timings_s": tm,
@@ -118,7 +128,70 @@ def main(stop_event: threading.Event | None = None) -> None:
118
  except Exception as e:
119
  log.exception("rh_stream pass failed: %s", str(e)[:200]); _status(error=str(e)[:300])
120
  stop.wait(10.0); continue
 
 
121
  stop.wait(_wait_s((out.get("minutes") or {}).get("through")))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
 
124
  def _wait_s(written_through: int | None, now: float | None = None) -> float:
 
10
 
11
  import json
12
  import logging
13
+ import os
14
+ import subprocess
15
+ import sys
16
  import threading
17
  import time
18
  from datetime import datetime, timezone
 
29
  RPC_CATCHUP_S = 3.0 # between catch-up passes while HyperSync is down (each reads ~2-3 times from the public RPC)
30
  SLOW_EVERY_S = 60.0 # meta and corpus refresh with each written minute, else at least this often
31
  _SLOW = {"at": 0.0}
32
+ _V4 = {"minute": None} # the minute whose v4 slot ran (rh/v4_universe.slot: once a minute)
33
+ _CORPUS: dict = {"proc": None} # the child building a due corpus day (rh/corpus.run_locked)
34
 
35
 
36
  def _status(**kv) -> None:
 
87
  px = prices.run_once(max_ranges=BACKFILL_RANGES if behind > LIVE_LAG_BLOCKS else 2, rpc=rpc); lap("prices")
88
  from . import wallet_skill
89
  wallet_skill.daily(); lap("skill_tables") # today's and tomorrow's tables, before their minutes are written
90
+ try:
91
+ mn = minutes.run_once(src, wallet_skill.load_today() if skill is None else skill); lap("minutes")
92
+ finally:
93
+ if pre is not None and config.RH_V4_ENABLED: # after the minute is written: the other v4 pools' logs, for the slot
94
+ from . import v4_universe
95
+ v4_universe.note(pre.logs)
96
+ from . import meta
97
  mt, cp = {}, {}
98
  if mn.get("minutes") or time.monotonic() - _SLOW["at"] >= SLOW_EVERY_S: # after a written minute: passes in between stay short
99
  _SLOW["at"] = time.monotonic()
100
  mt = meta.run_once(); lap("meta")
101
+ cp = corpus_apart() if behind <= LIVE_LAG_BLOCKS * 20 else {} # the training corpus follows once near the head
102
  lap("corpus")
103
  out = {"head": head, "index_through": ix.get("through"), "behind_blocks": head - (ix.get("through") or 0), "index": ix.get("counts"), "prices": px,
104
  "minutes": mn, "meta": mt, "corpus": cp, "mode": "backfill" if behind > LIVE_LAG_BLOCKS else "live", "timings_s": tm,
 
128
  except Exception as e:
129
  log.exception("rh_stream pass failed: %s", str(e)[:200]); _status(error=str(e)[:300])
130
  stop.wait(10.0); continue
131
+ if config.RH_V4_ENABLED and out.get("mode") == "live":
132
+ v4_slot(out)
133
  stop.wait(_wait_s((out.get("minutes") or {}).get("through")))
134
+ _stop_corpus()
135
+
136
+
137
+ def corpus_apart() -> dict:
138
+ """A due corpus day is built by a child process at the lowest CPU priority (``fly-trader rh-build-corpus``), never in
139
+ this thread (rh/corpus.py). One child at a time; this only looks at the files and starts it."""
140
+ from . import corpus
141
+ p = _CORPUS["proc"]
142
+ if p is not None and p.poll() is None:
143
+ return {"building": p.pid}
144
+ if p is not None:
145
+ _CORPUS["proc"] = None
146
+ if p.returncode:
147
+ log.warning("rh corpus child %d exited %d (its log: rh_corpus.log)", p.pid, p.returncode)
148
+ if not corpus.due():
149
+ return {}
150
+ nice = ["/usr/bin/nice", "-n", "19"] if os.path.exists("/usr/bin/nice") else [] # not preexec_fn: unsafe in a threaded process
151
+ p = _CORPUS["proc"] = subprocess.Popen([*nice, sys.executable, "-m", "fly_trader", "rh-build-corpus"], stdout=subprocess.DEVNULL)
152
+ log.info("rh corpus: a day is due, built by child process %d at the lowest CPU priority", p.pid)
153
+ return {"started": p.pid}
154
+
155
+
156
+ def _stop_corpus() -> None:
157
+ p = _CORPUS["proc"]
158
+ if p is not None and p.poll() is None:
159
+ p.terminate() # every file is written atomically: an ended child loses only its day
160
+ try:
161
+ p.wait(timeout=30)
162
+ except subprocess.TimeoutExpired:
163
+ p.kill()
164
+
165
+
166
+ def _orders_pending() -> bool:
167
+ """A live order queued or in flight on either chain (the live mirrors, agent/live_first.py)."""
168
+ from ..agent import live_first
169
+ for chain in ("sol", "rh"):
170
+ m = live_first.mirror_of(chain)
171
+ q = getattr(m, "ex", None) or getattr(m, "worker", None)
172
+ try:
173
+ if q is not None and q.pending():
174
+ return True
175
+ except Exception:
176
+ return True
177
+ return False
178
+
179
+
180
+ def v4_slot(out: dict, now: float | None = None) -> dict:
181
+ """The other v4 pools' work (rh/v4_universe.slot) once a minute, in this thread between passes: mid-minute
182
+ (agent/live_first.MID_MINUTE), once the newest minute is written, while HyperSync serves the live logs and no live
183
+ order is queued or in flight on either chain. It never waits for its window, and reads the RPC only until the
184
+ window's end. ``out``: the pass just made."""
185
+ from ..agent import live_first
186
+ from . import v4_universe
187
+ t = time.time() if now is None else now; m = int(t // 60)
188
+ through = int((out.get("minutes") or {}).get("through") or 0)
189
+ if _V4["minute"] == m or not live_first.mid_minute(t) or through < m * 60 or prefetch.hypersync_down() or _orders_pending():
190
+ return {}
191
+ _V4["minute"] = m
192
+ res = v4_universe.slot(deadline=m * 60 + live_first.MID_MINUTE[1], now=t)
193
+ _status(v4=res)
194
+ return res
195
 
196
 
197
  def _wait_s(written_through: int | None, now: float | None = None) -> float:
fly_trader/rh/token_check.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A plain Uniswap v4 memecoin's contract, checked before its pools are indexed (rh/v4_universe.py): what its owner can
2
+ do to holders.
3
+
4
+ Measured 2026-10-23 on the 118 tokens of Robinhood Chain pools that cleared the floors ($97M of 24 h volume): 76 % of the
5
+ volume had no owner or admin powers (every Pons coin, Clanker, Pons V1, Pools.trade); the rest held upgradeable sell gates
6
+ and blacklists under Pons tickers, owner-set taxes, bridge peers that mint, and built-in taxes.
7
+
8
+ ``check(rpc, token)`` reads the code (a minimal proxy is followed to its implementation; an EIP-1967 or beacon proxy is
9
+ upgradeable and refused) and the dispatcher's selectors, and refuses a token whose owner (``owner()``, not renounced) or
10
+ AccessControl roles hold a power (``POWERS``), one with a built-in buy or sell tax (``TAXES``), one that is not 18 decimals,
11
+ and one whose name or symbol marks a major, a stablecoin or a tokenized stock. Functions are matched by selector: a power
12
+ under an unusual name is missed, which the floors and the creator history (rh/meta.py) still stand behind.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ from . import abi
19
+
20
+ POWERS: dict[str, tuple[str, ...]] = {
21
+ "mint": ("mint(address,uint256)", "mint(uint256)", "mintTo(address,uint256)", "grantMintRole(address)", "grantMintAndBurnRoles(address)",
22
+ "setMinter(address,bool)", "addMinter(address)", "setPeer(uint32,bytes32)"),
23
+ "upgrade": ("upgradeTo(address)", "upgradeToAndCall(address,bytes)", "upgradeToSellGate()", "upgradeToBlacklist()"),
24
+ "blacklist": ("setBlacklist(address,bool)", "setBlacklistBatch(address[],bool)", "addBlacklistAddress(address)", "blacklist(address)",
25
+ "addToBlacklist(address)", "blacklistAddress(address,bool)", "setSellTarget(address,bool)", "setSellTargets(address[],bool)",
26
+ "blockVenue(address)", "setTransferAllowed(address,bool)", "setBot(address,bool)", "setBots(address[],bool)", "addBots(address[])",
27
+ "freeze(address)"),
28
+ "pause": ("pause()",),
29
+ "tax setter": ("setFee(uint256)", "setFees(uint256,uint256)", "setTaxes(uint256,uint256)", "setTaxBps(uint16,uint16)", "setProjectTaxRates(uint16,uint16)",
30
+ "setRateControl(uint16,uint16)", "setBuyFee(uint256)", "setSellFee(uint256)", "updateFees(uint256,uint256)"),
31
+ "transfer hook": ("setHook(address)", "setGateHook(address,bool)", "setPoolManagerGate(bool,bool)", "setMsgInspector(address)"),
32
+ "trading switch": ("enableTrading()", "openTrading()", "setMaxTxAmount(uint256)", "setMaxWalletSize(uint256)", "setLaunchLimits(uint16,uint16)",
33
+ "lockPool(address)", "unlockPool()", "setLimitExempt(address,bool)", "setWalletExempt(address,bool)", "disableBalanceLimit()",
34
+ "enableTransfers()"),
35
+ }
36
+ TAXES = ("buyTax()", "sellTax()", "buyFee()", "sellFee()", "taxWallet()", "buyTaxBps()", "sellTaxBps()", "totalBuyFeeBasisPoints()", "totalSellFeeBasisPoints()",
37
+ "projectBuyTaxBasisPoints()", "projectSellTaxBasisPoints()", "totalBuyTaxBasisPoints()", "totalSellTaxBasisPoints()", "buyFeePercent()",
38
+ "sellFeePercent()", "buyFeeBasisPoints()", "sellFeeBasisPoints()", "taxFee()", "getTaxConfig()")
39
+ ROLES = ("hasRole(bytes32,address)", "grantRole(bytes32,address)")
40
+ SEL: dict[str, tuple[str, str]] = {abi.selector(s).hex(): (cat, s) for cat, sigs in POWERS.items() for s in sigs}
41
+ TAX_SEL = {abi.selector(s).hex() for s in TAXES}
42
+ ROLE_SEL = {abi.selector(s).hex() for s in ROLES}
43
+ OWNER_SEL = abi.selector("owner()").hex()
44
+ IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" # EIP-1967 implementation
45
+ BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50" # EIP-1967 beacon
46
+ CLONE_PREFIXES = ("363d3d373d3d3d363d73", "365f5f375f5f365f73", "3d3d3d3d363d3d37363d73") # EIP-1167, its PUSH0 form, 0age's
47
+ DEAD = {0, 0xdead, int("000000000000000000000000000000000000dEaD", 16)}
48
+ MAJORS = {"ETH", "WETH", "USDG", "USDC", "USDT", "U", "USD", "DAI", "PYUSD", "USDE", "CBBTC", "WBTC", "HBTC", "BTC", "TBTC", "WSTETH", "STETH", "WEETH",
49
+ "EZETH", "RETH", "CBETH", "LINK", "HYPE", "TAO", "SOL", "WSOL", "ARB", "OP", "UNI", "AAVE", "VIRTUAL", "LIT", "RAM", "UP", "PONS"}
50
+ STOCK_NAME = re.compile(r"robinhood token|tokeni[sz]ed|xstock|ondo|wrapped|bridged|\bstock\b", re.I)
51
+
52
+
53
+ def selectors(code_hex: str) -> set[str]:
54
+ """The dispatcher's function selectors: each PUSH4 followed by EQ, DUP2, GT or LT, walking opcodes (PUSH data skipped)."""
55
+ b = bytes.fromhex(code_hex[2:] if code_hex.startswith("0x") else code_hex); i = 0; out = set()
56
+ while i < len(b):
57
+ op = b[i]
58
+ if op == 0x63 and i + 5 < len(b) and b[i + 5] in (0x14, 0x81, 0x11, 0x10):
59
+ out.add(b[i + 1:i + 5].hex())
60
+ i += 1 + (op - 0x5f if 0x60 <= op <= 0x7f else 0)
61
+ return out
62
+
63
+
64
+ def clone_target(code_hex: str) -> str | None:
65
+ """The implementation a minimal proxy delegates to (EIP-1167 and its two shorter forms), else None."""
66
+ c = code_hex[2:] if code_hex.startswith("0x") else code_hex
67
+ for p in CLONE_PREFIXES:
68
+ if c.startswith(p) and len(c) >= len(p) + 40:
69
+ return "0x" + c[len(p):len(p) + 40]
70
+ return None
71
+
72
+
73
+ def judge(sels: set[str], *, owner_live: bool, upgradeable: bool, decimals: int | None, symbol: str | None, name: str | None) -> tuple[bool, str, list[str]]:
74
+ """(accepted, reason, the powers found). Pure: ``check`` reads what it needs from the chain."""
75
+ powers = sorted({SEL[s][0] for s in sels if s in SEL})
76
+ if upgradeable:
77
+ return False, "upgradeable proxy: its owner can replace the code", powers
78
+ if decimals != 18:
79
+ return False, f"{decimals} decimals (only 18 are traded)", powers
80
+ sym = (symbol or "").strip().upper(); nm = name or ""
81
+ if sym in MAJORS or STOCK_NAME.search(nm):
82
+ return False, f"a major, stablecoin or tokenized asset ({symbol}, {name})", powers
83
+ if sels & TAX_SEL:
84
+ return False, "a built-in buy or sell tax", powers
85
+ if powers and (owner_live or sels & ROLE_SEL):
86
+ return False, ("its owner" if owner_live else "an admin role") + " can " + ", ".join(powers), powers
87
+ return True, "no owner or admin powers" if not powers else "powers with a renounced owner (" + ", ".join(powers) + ")", powers
88
+
89
+
90
+ def _word_address(v) -> int:
91
+ try:
92
+ return int(v, 16) if isinstance(v, str) and v.startswith("0x") else 0
93
+ except ValueError:
94
+ return 0
95
+
96
+
97
+ def _str(v) -> str | None:
98
+ if not isinstance(v, str) or len(v) < 2 + 128:
99
+ return None
100
+ try:
101
+ return abi.decode(["string"], bytes.fromhex(v[2:]))[0]
102
+ except Exception:
103
+ return None
104
+
105
+
106
+ def check(rpc, token: str) -> dict:
107
+ """The contract check of ``token`` (two batched RPC reads at most). ``ok`` False with ``reason`` when refused; an
108
+ unreadable token raises (the caller retries later)."""
109
+ t = token.lower()
110
+ call = lambda sig: ("eth_call", [{"to": t, "data": "0x" + abi.selector(sig).hex()}, "latest"]) # noqa: E731
111
+ r = rpc.batch([("eth_getCode", [t, "latest"]), ("eth_getStorageAt", [t, IMPL_SLOT, "latest"]), ("eth_getStorageAt", [t, BEACON_SLOT, "latest"]),
112
+ call("decimals()"), call("symbol()"), call("name()"), call("totalSupply()"), call("owner()")])
113
+ code = r[0] if isinstance(r[0], str) else None
114
+ if not code or code == "0x":
115
+ raise RuntimeError(f"no code at {t}")
116
+ ok = lambda v: isinstance(v, str) # noqa: E731 (an EvmRpcError otherwise)
117
+ upgradeable = (ok(r[1]) and _word_address(r[1]) != 0) or (ok(r[2]) and _word_address(r[2]) != 0)
118
+ impl = clone_target(code); body = code
119
+ if impl:
120
+ body = rpc.batch([("eth_getCode", [impl, "latest"])])[0]
121
+ body = body if isinstance(body, str) else "0x"
122
+ sels = selectors(body)
123
+ dec = int(r[3], 16) if ok(r[3]) and r[3] not in ("0x", "") else None
124
+ owner = ("0x" + r[7][-40:]) if ok(r[7]) and len(r[7]) >= 42 else None
125
+ owner_live = OWNER_SEL in sels and owner is not None and int(owner, 16) not in DEAD
126
+ supply = int(r[6], 16) if ok(r[6]) and r[6] not in ("0x", "") else None
127
+ sym, nm = _str(r[4]), _str(r[5])
128
+ accepted, reason, powers = judge(sels, owner_live=owner_live, upgradeable=upgradeable, decimals=dec, symbol=sym, name=nm)
129
+ return {"ok": accepted, "reason": reason, "powers": powers, "decimals": dec, "symbol": sym, "name": nm, "supply_raw": supply, "impl": impl,
130
+ "owner": owner, "owner_live": owner_live, "upgradeable": upgradeable, "taxes": bool(sels & TAX_SEL), "roles": bool(sels & ROLE_SEL),
131
+ "code_len": (len(body) - 2) // 2, "n_selectors": len(sels)}
fly_trader/rh/v4_universe.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Robinhood Chain's other Uniswap v4 pools (2026-10-23): a Pons coin's own pools outside its Pons pool, and plain v4
2
+ memecoins, traded like the Pons pools once they qualify.
3
+
4
+ Measured 2026-10-23: 1.07 million v4 pools had been initialized on the chain, 700,000 of them without a hook. In one
5
+ hour 1,480 pools traded (40,960 swaps, 3,375 of them in Pons pools); 85 hookless pools pairing a coin with ETH, WETH or
6
+ USDG cleared the bot's floors (17,051 swaps), and 64 hooked ones did (4,810).
7
+
8
+ Every PoolManager log already reaches the stream each live pass (rh/prefetch.py), and the index step dropped those
9
+ outside the Pons pools (rh/index.step). Nothing below runs in front of a minute's publish:
10
+
11
+ 1. ``note`` (rh/stream.one_pass, once the pass's minutes are written): the pass's Initialize and Swap logs are kept.
12
+ 2. ``slot`` (rh/stream.v4_slot: mid-minute, once the newest minute is written, while no live order waits):
13
+ - the kept Initialize logs go to ``rh_v4_keys``, every pool's key (pools initialized before the stream kept them come
14
+ from ``fly-trader rh-v4-backfill``); the kept swaps to each pool's activity (``_ACT``, 15-minute buckets);
15
+ - a pool whose last half hour cleared the bot's floors (reserve and 15-minute volume, in ETH) is classified from its
16
+ key (``classify``) and registered in ``rh_pools`` (kind 'pons_other' or 'v4', not yet ``indexed``), and a plain
17
+ coin is queued in ``rh_v4_tokens``;
18
+ - ``RH_V4_CHECKS_PER_SLOT`` plain coins have their contract checked (rh/token_check.py), the busiest first;
19
+ - a qualifying pool goes live: its coin's rh_meta row (rh/meta.write_v4) and ``indexed`` with ``live_from`` (the
20
+ next block the stream indexes) in one commit, so the next pass indexes its swaps with the Pons pools'.
21
+ 3. ``backfill_main`` (``fly-trader rh-v4-backfill``, its own process): every Initialize since block 0 into
22
+ ``rh_v4_keys``, then each live pool's swaps before ``live_from`` with its coin's minutes over them, and a plain
23
+ coin's first-hour outcomes once its history is complete.
24
+
25
+ Only the slot's contract checks reach the network: the RPC, each read once with a short timeout and none after the
26
+ slot's end (``SlotRpc``). A rate limit or the slot's end leaves the rest for the next minute.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ import threading
33
+ import time
34
+ from datetime import datetime, timezone
35
+
36
+ from .. import config
37
+ from ..db.connection import transaction
38
+ from ..vault.evm import EvmRpc, EvmRpcError
39
+ from . import token_check, v4
40
+ from .rpc import RhRpc, _rate_limited
41
+
42
+ log = logging.getLogger(__name__)
43
+ ZERO = "0x" + "00" * 20
44
+ DYNAMIC_FEE = 0x800000 # a v4 pool's fee field with this flag: the LP fee is set per swap (the Swap event's fee)
45
+ BUCKET_S = 900 # a pool's activity in 15-minute buckets, the volume floor's window
46
+ MIN_SWAPS = 5 # swaps over the current and the previous bucket before a pool is looked at
47
+ RECHECK_S = 900.0 # a pool below the floors, or without a known key, is looked at again after this long
48
+ FORGET_S = 86400.0 # a pool that is no coin's pool (a hook, two quote assets): after this long
49
+ KEEP_SWAPS = 200_000 # swap logs kept between slots (~5 h at 2026-10-23's rate); older ones are dropped
50
+ SLOT_TIMEOUT_S = 5.0 # one RPC read of a slot
51
+ MAX_ATTEMPTS = 5 # contract checks of a coin before it is given up ('error')
52
+ HISTORY_CHUNK = 100_000 # backfill_main: blocks per history step (~3 h of chain)
53
+ KEYS_STEP = 2_000_000 # backfill_main: blocks per key-import step
54
+ _STASH: dict = {"inits": [], "swaps": []}
55
+ _ACT: dict[str, list] = {} # pool id -> [bucket, n, |amount0|, |amount1|, n before, |amount0| before, |amount1| before, sqrtPriceX96, tick, last ts]
56
+ _KNOWN: set[str] = set() # pools found in rh_pools (Pons pools, registered pools)
57
+ _SEEN: dict[str, float] = {} # pools looked at and not registered -> when to look again
58
+ _RPC: dict = {"rpc": None}
59
+
60
+
61
+ class SlotOver(RuntimeError):
62
+ """The slot's time is up, or the RPC refused it a read: the rest waits for the next minute."""
63
+
64
+
65
+ class SlotRpc(RhRpc):
66
+ """The RPC for a slot's contract checks: each read once (no rate-limit waits), with a short timeout, and none after
67
+ ``deadline`` (epoch s); a rate limit or a timeout ends the slot's reads (``SlotOver``)."""
68
+
69
+ def __init__(self, url: str):
70
+ super().__init__(url, service="rh_v4", timeout=SLOT_TIMEOUT_S)
71
+ self.deadline = 0.0
72
+
73
+ def _guard(self, what: str, fn):
74
+ if time.time() > self.deadline:
75
+ raise SlotOver(f"the slot ended before {what}")
76
+ try:
77
+ return fn()
78
+ except EvmRpcError as e:
79
+ if _rate_limited(e) or "Timeout" in str(e):
80
+ raise SlotOver(f"{what}: {str(e)[:120]}") from None
81
+ raise
82
+
83
+ def call(self, method: str, params: list | None = None):
84
+ return self._guard(method, lambda: EvmRpc.call(self, method, params))
85
+
86
+ def batch(self, calls: list[tuple[str, list]]) -> list:
87
+ return self._guard("a batch", lambda: self._batch(calls))
88
+
89
+
90
+ def slot_rpc(deadline: float) -> SlotRpc:
91
+ r = _RPC["rpc"]
92
+ if r is None:
93
+ r = _RPC["rpc"] = SlotRpc(config.RH_RPC_URL_LOGS or config.RH_RPC_URL)
94
+ r.deadline = deadline
95
+ return r
96
+
97
+
98
+ # ---------------------------------------------------------------- what qualifies
99
+ def _weth() -> str:
100
+ return config.RH_WETH.lower()
101
+
102
+
103
+ def quote_ok_v4(asset: str) -> bool:
104
+ """A plain memecoin's quote: ETH, WETH or one of ``RH_V4_QUOTES``."""
105
+ a = asset.lower()
106
+ return a in (ZERO, _weth()) or a in config.RH_V4_QUOTES
107
+
108
+
109
+ def hook_ok(hooks: str | None) -> bool:
110
+ h = (hooks or ZERO).lower()
111
+ return h == ZERO or h in config.RH_V4_HOOKS
112
+
113
+
114
+ def classify(key: dict, assets: dict[str, dict], pons: dict[str, str]) -> dict | None:
115
+ """{"token", "quote", "kind"} for a pool key, or None (a Pons pool, a pair of two quote assets or of two unknown
116
+ tokens, a quote with no ETH mark, a hook that is not allowed). ``assets``: rh_assets by address (``priced``: class
117
+ 'eth' or a reference path); ``pons``: the Pons launches among the pair -> status. A Pons coin's other pool counts once
118
+ the coin graduated; a Pons coin is never a plain v4 coin."""
119
+ c0, c1 = key["currency0"].lower(), key["currency1"].lower(); hooks = (key.get("hooks") or ZERO).lower()
120
+ if hooks == config.PONS_HOOK.lower() or not hook_ok(hooks):
121
+ return None
122
+ for tok, q in ((c0, c1), (c1, c0)):
123
+ if pons.get(tok) == "graduated" and q not in pons and (q in (ZERO, _weth()) or (assets.get(q) or {}).get("priced")):
124
+ return {"token": tok, "quote": q, "kind": "pons_other"}
125
+ if c0 in pons or c1 in pons:
126
+ return None
127
+ q0, q1 = quote_ok_v4(c0), quote_ok_v4(c1)
128
+ if q0 == q1:
129
+ return None
130
+ q, tok = (c0, c1) if q0 else (c1, c0)
131
+ if tok in assets or tok == _weth():
132
+ return None # a quote asset (stable, stock, BTC) is not a memecoin
133
+ return {"token": tok, "quote": q, "kind": "v4"}
134
+
135
+
136
+ def floors() -> tuple[float, float]:
137
+ """(reserve, 15-minute volume) in ETH: the RH book's entry floors (markets.RH, train/decisions)."""
138
+ from ..train.decisions import MIN_RESQ_SOL, MIN_VOL_15M_SOL
139
+ return MIN_RESQ_SOL * config.RH_ETH_PER_SOL, MIN_VOL_15M_SOL * config.RH_ETH_PER_SOL
140
+
141
+
142
+ def activity(a: list, bucket: int) -> tuple[int, int, int]:
143
+ """(swaps, max |amount0|, max |amount1|) of a pool's buckets within the last half hour (``_ACT`` row)."""
144
+ cur = a[0] >= bucket - 1; prev = a[0] - 1 >= bucket - 1
145
+ n = (a[1] if cur else 0) + (a[4] if prev else 0)
146
+ return n, max(a[2] if cur else 0, a[5] if prev else 0), max(a[3] if cur else 0, a[6] if prev else 0)
147
+
148
+
149
+ def clears(a: list, bucket: int, token_is_0: bool, qdec: int, base: float) -> tuple[bool, float, float]:
150
+ """(clears the floors, reserve ETH, best 15-minute volume ETH) of a pool from its activity and its last swap."""
151
+ _n, v0, v1 = activity(a, bucket)
152
+ if not a[7] or not a[8]:
153
+ return False, 0.0, 0.0 # no price or no liquidity after its last swap
154
+ resq = v4.quote_reserve(int(a[7]), int(a[8]), token_is_0, qdec) * base
155
+ vol = (v1 if token_is_0 else v0) / 10 ** qdec * base
156
+ min_resq, min_vol = floors()
157
+ return resq >= min_resq and vol >= min_vol, resq, vol
158
+
159
+
160
+ # ---------------------------------------------------------------- the stream's pass and the slot
161
+ def note(logs: list[dict]) -> None:
162
+ """Keep a pass's PoolManager Initialize and Swap logs for the next slot (rh/stream.one_pass, after its minutes)."""
163
+ pm = config.V4_POOL_MANAGER.lower(); init, swap = v4.TOPIC["Initialize"], v4.TOPIC["Swap"]
164
+ for g in logs:
165
+ t = g.get("topics") or ()
166
+ if not t or (g.get("address") or "").lower() != pm:
167
+ continue
168
+ t0 = t[0].lower()
169
+ if t0 == swap:
170
+ _STASH["swaps"].append(g)
171
+ elif t0 == init:
172
+ _STASH["inits"].append(g)
173
+ if len(_STASH["swaps"]) > KEEP_SWAPS:
174
+ del _STASH["swaps"][:len(_STASH["swaps"]) - KEEP_SWAPS]
175
+
176
+
177
+ def tally(swaps: list[dict], now: float) -> int:
178
+ """Kept swap logs into each pool's activity (the current 15-minute bucket and the one before); idle pools dropped."""
179
+ n = 0
180
+ for g in swaps:
181
+ e = v4.decode(g)
182
+ if not e or e["event"] != "Swap":
183
+ continue
184
+ ts = e["ts"] or now; b = int(ts // BUCKET_S); a = _ACT.get(e["pool_id"])
185
+ if a is None:
186
+ a = _ACT[e["pool_id"]] = [b, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0]
187
+ if b > a[0]:
188
+ a[4:7] = a[1:4] if b == a[0] + 1 else [0, 0, 0]
189
+ a[0], a[1], a[2], a[3] = b, 0, 0, 0
190
+ elif b < a[0]:
191
+ continue
192
+ a[1] += 1; a[2] += abs(int(e["amount0"])); a[3] += abs(int(e["amount1"]))
193
+ a[7], a[8], a[9], a[10] = e["sqrt_price_x96"], e["liquidity"], e["tick"], ts
194
+ n += 1
195
+ cut = int(now // BUCKET_S) - 1
196
+ for pid in [p for p, a in _ACT.items() if a[0] < cut]:
197
+ del _ACT[pid]
198
+ return n
199
+
200
+
201
+ def save_keys(conn, rpc, inits: list[dict]) -> int:
202
+ """Decoded Initialize events -> ``rh_v4_keys`` (all but the Pons hook's pools: those are indexed as Pons pools)."""
203
+ pons_hook = config.PONS_HOOK.lower()
204
+ evs = [e for e in inits if (e.get("hooks") or ZERO).lower() != pons_hook]
205
+ if not evs:
206
+ return 0
207
+ if any(not e.get("ts") for e in evs): # logs the RPC served while HyperSync was down carry no time
208
+ from . import index
209
+ index.fill_timestamps(rpc, evs, min(e["block"] for e in evs), max(e["block"] for e in evs))
210
+ conn.cursor().executemany(
211
+ "INSERT INTO rh_v4_keys (pool_id, currency0, currency1, fee, tick_spacing, hooks, init_block, init_ts, init_tx, sqrt_price_x96, tick) "
212
+ "VALUES (%s, %s, %s, %s, %s, %s, %s, to_timestamp(%s), %s, %s, %s) ON CONFLICT (pool_id) DO NOTHING",
213
+ [(e["pool_id"], e["currency0"].lower(), e["currency1"].lower(), e.get("fee"), e.get("tick_spacing"),
214
+ None if (e.get("hooks") or ZERO).lower() == ZERO else e["hooks"].lower(), e["block"], e.get("ts"), e.get("tx_hash"),
215
+ e.get("sqrt_price_x96"), e.get("tick")) for e in evs])
216
+ return len(evs)
217
+
218
+
219
+ def _assets(conn) -> dict[str, dict]:
220
+ return {r["asset"]: {"class": r["class"], "decimals": r["decimals"], "priced": r["class"] == "eth" or bool(r["has_path"])}
221
+ for r in conn.execute("SELECT asset, class, decimals, ref_path IS NOT NULL AS has_path FROM rh_assets").fetchall()}
222
+
223
+
224
+ def discover(conn, now: float) -> dict:
225
+ """Register the active pools that cleared the floors over the last half hour (``classify`` on their keys)."""
226
+ from . import prices
227
+ b = int(now // BUCKET_S); out = {"looked": 0, "registered": 0, "no_key": 0, "below_floors": 0, "not_coin_pools": 0}
228
+ for pid in [p for p, when in _SEEN.items() if when <= now]:
229
+ del _SEEN[pid]
230
+ todo = [pid for pid, a in _ACT.items() if pid not in _KNOWN and pid not in _SEEN and activity(a, b)[0] >= MIN_SWAPS]
231
+ if not todo:
232
+ return out
233
+ known = {r["pool_id"] for r in conn.execute("SELECT pool_id FROM rh_pools WHERE pool_id = ANY(%s)", (todo,)).fetchall()}
234
+ _KNOWN.update(known)
235
+ todo = [p for p in todo if p not in known]
236
+ keys = {r["pool_id"]: dict(r) for r in conn.execute("SELECT * FROM rh_v4_keys WHERE pool_id = ANY(%s)", (todo,)).fetchall()}
237
+ side = sorted({k["currency0"] for k in keys.values()} | {k["currency1"] for k in keys.values()})
238
+ pons = {r["token"]: r["status"] for r in conn.execute("SELECT token, status FROM rh_tokens WHERE token = ANY(%s)", (side,)).fetchall()} if side else {}
239
+ assets = _assets(conn); at = datetime.fromtimestamp(now, timezone.utc)
240
+ for pid in todo:
241
+ out["looked"] += 1; k = keys.get(pid)
242
+ if k is None:
243
+ _SEEN[pid] = now + RECHECK_S; out["no_key"] += 1; continue # initialized before the stream kept keys
244
+ c = classify(k, assets, pons)
245
+ if c is None:
246
+ _SEEN[pid] = now + FORGET_S; out["not_coin_pools"] += 1; continue
247
+ if c["quote"] == _weth() and c["quote"] not in assets:
248
+ conn.execute("INSERT INTO rh_assets (asset, symbol, class, decimals) VALUES (%s, 'WETH', 'eth', 18) ON CONFLICT (asset) DO NOTHING", (c["quote"],))
249
+ assets = _assets(conn)
250
+ qa = assets.get(c["quote"]) or ({"decimals": 18} if c["quote"] == ZERO else None)
251
+ base = prices.base_eth(conn, c["quote"], at)[0] if qa else None
252
+ if qa is None or base is None:
253
+ _SEEN[pid] = now + RECHECK_S; out["below_floors"] += 1; continue
254
+ ti0 = k["currency0"].lower() == c["token"]
255
+ ok, resq, vol = clears(_ACT[pid], b, ti0, int(qa["decimals"]), float(base))
256
+ if not ok:
257
+ _SEEN[pid] = now + RECHECK_S; out["below_floors"] += 1; continue
258
+ register(conn, k, c, activity(_ACT[pid], b)[0])
259
+ _KNOWN.add(pid); out["registered"] += 1
260
+ log.info("rh v4: %s pool %s of %s registered (reserve %.2f ETH, 15-min volume %.2f ETH)", c["kind"], pid[:10], c["token"][:10], resq, vol)
261
+ return out
262
+
263
+
264
+ def register(conn, key: dict, c: dict, swaps: int) -> None:
265
+ """A qualifying pool into ``rh_pools`` (not yet indexed); a plain coin into ``rh_v4_tokens`` (pending its check),
266
+ dated by the first pool of it ever initialized."""
267
+ tok = c["token"]; fee = int(key.get("fee") or 0); a = _ACT.get(key["pool_id"]) or [None] * 11
268
+ if c["kind"] == "v4":
269
+ first = conn.execute("SELECT pool_id, init_block, init_ts FROM rh_v4_keys WHERE currency0 = %(t)s OR currency1 = %(t)s "
270
+ "ORDER BY init_block LIMIT 1", {"t": tok}).fetchone() or key
271
+ conn.execute("INSERT INTO rh_v4_tokens (token, first_pool, first_block, first_ts, quote_asset) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (token) DO NOTHING",
272
+ (tok, first["pool_id"], first["init_block"], first["init_ts"], c["quote"]))
273
+ conn.execute("INSERT INTO rh_pools (pool_id, token, currency0, currency1, token_is_0, fee, tick_spacing, hooks, quote_asset, init_block, init_ts, is_pons, "
274
+ "kind, indexed, lp_fee, init_tx, sqrt_price_x96, tick, swaps_seen, last_swap_at) "
275
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,false,%s,false,%s,%s,%s,%s,%s,to_timestamp(%s)) ON CONFLICT (pool_id) DO NOTHING",
276
+ (key["pool_id"], tok, key["currency0"].lower(), key["currency1"].lower(), key["currency0"].lower() == tok, fee, key.get("tick_spacing"),
277
+ (key.get("hooks") or ZERO).lower(), c["quote"], key["init_block"], key["init_ts"], c["kind"],
278
+ None if fee & DYNAMIC_FEE else fee / 1e6, key.get("init_tx"), a[7] or key.get("sqrt_price_x96"), a[9] if a[9] is not None else key.get("tick"),
279
+ swaps, a[10]))
280
+
281
+
282
+ def refresh_activity(conn, now: float) -> None:
283
+ """Registered pools not yet live: their last half hour's swaps (``run_checks`` takes the busiest coins first)."""
284
+ b = int(now // BUCKET_S)
285
+ vals = [(pid, activity(a, b)[0], a[10]) for pid, a in _ACT.items() if pid in _KNOWN]
286
+ if vals:
287
+ conn.cursor().executemany("UPDATE rh_pools SET swaps_seen = %s, last_swap_at = to_timestamp(%s) WHERE pool_id = %s AND NOT is_pons AND NOT indexed",
288
+ [(n, ts, pid) for pid, n, ts in vals])
289
+
290
+
291
+ def run_checks(conn, rpc, limit: int | None = None) -> dict:
292
+ """The contract check (rh/token_check.check) of the busiest pending coins; the creator is the sender of the coin's
293
+ first pool's initialization. A ``SlotOver`` ends the checks without counting an attempt."""
294
+ limit = config.RH_V4_CHECKS_PER_SLOT if limit is None else limit
295
+ todo = conn.execute("""SELECT v.token, (SELECT k.init_tx FROM rh_v4_keys k WHERE k.pool_id = v.first_pool) AS init_tx,
296
+ (SELECT coalesce(sum(p.swaps_seen), 0) FROM rh_pools p WHERE p.token = v.token) AS swaps
297
+ FROM rh_v4_tokens v WHERE v.status = 'pending' AND v.attempts < %s ORDER BY swaps DESC, v.first_ts DESC LIMIT %s""",
298
+ (MAX_ATTEMPTS, limit)).fetchall()
299
+ out = {"ok": 0, "refused": 0, "error": 0}
300
+ for t in todo:
301
+ try:
302
+ r = token_check.check(rpc, t["token"])
303
+ tx = rpc.tx_by_hash(t["init_tx"]) if t["init_tx"] else None
304
+ creator = ((tx or {}).get("from") or "").lower() or None
305
+ except SlotOver:
306
+ raise
307
+ except Exception as e:
308
+ conn.execute("UPDATE rh_v4_tokens SET attempts = attempts + 1, reason = %s, status = CASE WHEN attempts + 1 >= %s THEN 'error' ELSE status END, "
309
+ "checked_at = now() WHERE token = %s", (str(e)[:300], MAX_ATTEMPTS, t["token"]))
310
+ out["error"] += 1; continue
311
+ st = "ok" if r["ok"] else "refused"
312
+ conn.execute("UPDATE rh_v4_tokens SET status = %s, reason = %s, detail = %s, symbol = %s, name = %s, decimals = %s, supply_raw = %s, creator = %s, "
313
+ "attempts = attempts + 1, checked_at = now() WHERE token = %s",
314
+ (st, r["reason"], json.dumps(r, default=str), r["symbol"], r["name"], r["decimals"], r["supply_raw"], creator, t["token"]))
315
+ out[st] += 1
316
+ log.info("rh v4 coin %s (%s): %s, %s", t["token"], r["symbol"], st, r["reason"])
317
+ return out
318
+
319
+
320
+ def go_live(conn, cursor: int) -> int:
321
+ """Each registered pool that qualifies now (a graduated Pons coin's, or an accepted plain coin's) goes live: its
322
+ coin's rh_meta row and ``indexed`` with ``live_from`` = the next block the stream indexes, in this commit. Runs in
323
+ the stream's thread between passes, so the cursor cannot move under it."""
324
+ from . import meta
325
+ rows = conn.execute("""SELECT p.pool_id, p.token, p.kind FROM rh_pools p LEFT JOIN rh_v4_tokens v ON v.token = p.token
326
+ WHERE NOT p.is_pons AND NOT p.indexed AND p.kind IN ('pons_other', 'v4')
327
+ AND ((p.kind = 'pons_other' AND EXISTS (SELECT 1 FROM rh_meta m WHERE m.mint = p.token)) OR (p.kind = 'v4' AND v.status = 'ok'))
328
+ ORDER BY p.swaps_seen DESC""").fetchall()
329
+ n = 0
330
+ for r in rows:
331
+ if r["kind"] == "v4" and not meta.write_v4(conn, r["token"]):
332
+ continue
333
+ conn.execute("UPDATE rh_pools SET indexed = true, live_from = %s WHERE pool_id = %s", (cursor + 1, r["pool_id"]))
334
+ n += 1
335
+ log.info("rh v4: %s pool %s of %s live from block %d", r["kind"], r["pool_id"][:10], r["token"][:10], cursor + 1)
336
+ return n
337
+
338
+
339
+ def slot(deadline: float, now: float | None = None) -> dict:
340
+ """One minute's work (module docstring, step 2), in the stream's thread between passes. What it did, for the
341
+ stream's status."""
342
+ t = time.time() if now is None else now; t0 = time.monotonic()
343
+ out: dict = {"at": datetime.fromtimestamp(t, timezone.utc).isoformat()}
344
+ inits, swaps = _STASH["inits"], _STASH["swaps"]; _STASH["inits"], _STASH["swaps"] = [], []
345
+ out["swaps"] = tally(swaps, t)
346
+ rpc = slot_rpc(deadline)
347
+ try:
348
+ with transaction() as conn:
349
+ out["keys"] = save_keys(conn, rpc, [e for e in (v4.decode(g) for g in inits) if e])
350
+ out.update(discover(conn, t))
351
+ refresh_activity(conn, t)
352
+ except SlotOver as e:
353
+ _STASH["inits"][:0] = inits; out["stopped"] = str(e)[:200]
354
+ except Exception as e:
355
+ _STASH["inits"][:0] = inits; out["error"] = str(e)[:200]; log.exception("rh v4 slot: discovery failed")
356
+ try:
357
+ with transaction() as conn:
358
+ out["checks"] = run_checks(conn, rpc)
359
+ except SlotOver as e:
360
+ out["stopped"] = str(e)[:200]
361
+ except Exception as e:
362
+ out["error"] = str(e)[:200]; log.exception("rh v4 slot: contract checks failed")
363
+ try:
364
+ with transaction() as conn:
365
+ cur = conn.execute("SELECT block FROM rh_scan WHERE name = 'rh'").fetchone()
366
+ out["live"] = go_live(conn, int(cur["block"])) if cur else 0
367
+ out["v4"] = status(conn)
368
+ except Exception as e:
369
+ out["error"] = str(e)[:200]; log.exception("rh v4 slot: going live failed")
370
+ out["active_pools"] = len(_ACT); out["took_s"] = round(time.monotonic() - t0, 2)
371
+ return out
372
+
373
+
374
+ def status(conn) -> dict:
375
+ """Counts for the console and the logs."""
376
+ r = conn.execute("""SELECT count(*) FILTER (WHERE kind = 'pons_other') AS pons_other, count(*) FILTER (WHERE kind = 'v4') AS v4,
377
+ count(*) FILTER (WHERE indexed) AS live FROM rh_pools WHERE NOT is_pons""").fetchone()
378
+ t = {x["status"]: x["n"] for x in conn.execute("SELECT status, count(*) AS n FROM rh_v4_tokens GROUP BY 1").fetchall()}
379
+ return {**dict(r), "coins": t}
380
+
381
+
382
+ # ---------------------------------------------------------------- fly-trader rh-v4-backfill (its own process)
383
+ def import_keys(rpc, stop: threading.Event) -> int:
384
+ """Every Initialize from block 0 to the stream's cursor into ``rh_v4_keys`` (resumable: cursor ``rh_v4_inits``)."""
385
+ from . import index
386
+ n = 0
387
+ with transaction() as conn:
388
+ r = conn.execute("SELECT block FROM rh_scan WHERE name = 'rh_v4_inits'").fetchone()
389
+ top = conn.execute("SELECT block FROM rh_scan WHERE name = %s", (index.CURSOR,)).fetchone()
390
+ frm = int(r["block"]) + 1 if r else 0; to = int(top["block"]) if top else 0
391
+ while frm <= to and not stop.is_set():
392
+ hi = min(to, frm + KEYS_STEP - 1)
393
+ logs = index._get_logs(rpc, [config.V4_POOL_MANAGER], frm, hi, [[v4.TOPIC["Initialize"]]])
394
+ with transaction() as conn:
395
+ k = save_keys(conn, rpc, [e for e in (v4.decode(g) for g in logs) if e])
396
+ conn.execute("INSERT INTO rh_scan (name, block) VALUES ('rh_v4_inits', %s) ON CONFLICT (name) DO UPDATE SET block = EXCLUDED.block, updated_at = now()", (hi,))
397
+ n += k; log.info("rh v4 keys: blocks %d..%d, %d pools", frm, hi, k)
398
+ frm = hi + 1
399
+ return n
400
+
401
+
402
+ def backfill_pool(conn, rpc, p: dict, hi: int) -> dict:
403
+ """One history step of a live pool: its logs from ``p["lo"]`` to ``hi`` (< its ``live_from``), its coin's minutes
404
+ over them recomputed (each day with that day's skill table, never past the minutes the stream has written), and
405
+ ``ready_through = hi``."""
406
+ from . import index, minutes, wallet_skill
407
+ lo = int(p["lo"])
408
+ logs = index._get_logs(rpc, [config.V4_POOL_MANAGER], lo, hi, [index.V4_TOPICS0, [p["pool_id"]]])
409
+ n = index.apply_v4(conn, rpc, logs, [], pool_ids=[p["pool_id"]])
410
+ evs = [e for e in (v4.decode(g) for g in logs) if e and e.get("ts")]
411
+ if evs:
412
+ r = conn.execute("SELECT detail FROM rh_scan WHERE name = 'rh_minutes'").fetchone()
413
+ through = int(((r["detail"] or {}) if r else {}).get("through") or 0)
414
+ t0 = int(min(e["ts"] for e in evs) // 60) * 60; t1 = min(through, int(max(e["ts"] for e in evs) // 60) * 60 + 60)
415
+ d = t0 - t0 % 86400
416
+ while d < t1:
417
+ a, z = max(t0, d), min(t1, d + 86400)
418
+ if z > a:
419
+ minutes.write(conn, minutes.aggregate(conn, a, z, wallet_skill.table_for(datetime.fromtimestamp(d, timezone.utc).date()), tokens=[p["token"]]))
420
+ d += 86400
421
+ conn.execute("UPDATE rh_pools SET ready_through = %s WHERE pool_id = %s", (hi, p["pool_id"]))
422
+ return {"logs": len(logs), **n}
423
+
424
+
425
+ def backfill_history(rpc, stop: threading.Event) -> dict:
426
+ """Each live pool's swaps before its ``live_from`` (busiest first), then a plain coin's first-hour outcomes once its
427
+ pools' histories are complete."""
428
+ from . import meta
429
+ out = {"pools": 0, "steps": 0, "outcomes": 0}
430
+ with transaction() as conn:
431
+ pools = [dict(r) for r in conn.execute(
432
+ """SELECT pool_id, token, kind, live_from, coalesce(ready_through, init_block - 1) + 1 AS lo FROM rh_pools
433
+ WHERE NOT is_pons AND indexed AND live_from IS NOT NULL AND coalesce(ready_through, init_block - 1) < live_from - 1
434
+ ORDER BY swaps_seen DESC""").fetchall()]
435
+ for p in pools:
436
+ while not stop.is_set() and int(p["lo"]) <= int(p["live_from"]) - 1:
437
+ hi = min(int(p["live_from"]) - 1, int(p["lo"]) + HISTORY_CHUNK - 1)
438
+ with transaction() as conn:
439
+ r = backfill_pool(conn, rpc, p, hi)
440
+ out["steps"] += 1; p["lo"] = hi + 1
441
+ log.info("rh v4 history %s (%s): through block %d, %s", p["pool_id"][:10], p["kind"], hi, r)
442
+ out["pools"] += 1
443
+ if stop.is_set():
444
+ break
445
+ with transaction() as conn: # plain coins whose every live pool has its history
446
+ due = conn.execute("""SELECT m.mint, m.graduated_at FROM rh_meta m WHERE m.source = 'v4' AND m.own_dd60 IS NULL
447
+ AND NOT EXISTS (SELECT 1 FROM rh_pools p WHERE p.token = m.mint AND p.indexed AND NOT p.is_pons
448
+ AND coalesce(p.ready_through, p.init_block - 1) < p.live_from - 1)""").fetchall()
449
+ for r in due:
450
+ o = meta.outcomes(conn, r["mint"], r["graduated_at"])
451
+ if o is not None:
452
+ conn.execute("UPDATE rh_meta SET own_dd60 = %s, own_max60 = %s, updated_at = now() WHERE mint = %s", (o[0], o[1], r["mint"])); out["outcomes"] += 1
453
+ return out
454
+
455
+
456
+ def backfill_main(stop_event: threading.Event | None = None, keys: bool = True, history: bool = True) -> dict:
457
+ """``fly-trader rh-v4-backfill``: the key import, then the live pools' histories. Run it apart from the console, under
458
+ nice; it paces HyperSync at ``RH_V4_BACKFILL_RPM`` of its own, so the console keeps its share of the plan."""
459
+ from ..logging_setup import setup
460
+ from .rpc import logs_rpc
461
+ setup("rh_v4_backfill"); stop = stop_event or threading.Event()
462
+ config.RH_HYPERSYNC_RPM = config.RH_V4_BACKFILL_RPM
463
+ rpc = logs_rpc(); out: dict = {}
464
+ if keys:
465
+ out["keys"] = import_keys(rpc, stop)
466
+ if history and not stop.is_set():
467
+ out.update(backfill_history(rpc, stop))
468
+ log.info("rh v4 backfill done: %s", out)
469
+ return out
fly_trader/signer/core.py CHANGED
@@ -21,6 +21,7 @@ import os
21
  import subprocess
22
  import tempfile
23
  import time
 
24
  from dataclasses import dataclass, field
25
 
26
  from solders.address_lookup_table_account import AddressLookupTable
@@ -40,6 +41,7 @@ from .rpc import Rpc
40
  RENT_EXEMPT_MIN = 890_880 # a 0-byte system account: the treasury PDA must keep this much
41
  LAMPORTS = 1_000_000_000
42
  SIM_MAX_ACCOUNTS = 20 # the RPC caps accounts per simulation (30, or fewer: it has said 27)
 
43
 
44
 
45
  @dataclass
@@ -140,18 +142,25 @@ class Signer:
140
  raise PolicyError("daily buy volume cap reached", code="cap")
141
  vt = VersionedTransaction.from_bytes(base64.b64decode(tx))
142
  chk = policy.check_swap_static(vt, me, self._resolve_alt, self.cfg.max_priority_lamports)
143
- # every token account the trading wallet owns, plus the two this swap may open
144
- mint_acc = self.rpc.accounts([in_m, out_m])
 
 
 
 
 
145
  progs = {m: (a["owner"] if a else TOKEN) for m, a in zip((in_m, out_m), mint_acc)}
146
- addrs = [me] + self.rpc.token_accounts(me, TOKEN) + self.rpc.token_accounts(me, TOKEN_2022)
147
  for m in (in_m, out_m):
148
  a = policy.ata(me, m, progs[m])
149
  if a not in addrs:
150
  addrs.append(a)
151
- pre = self._snapshot(self.rpc.accounts(addrs), addrs)
 
 
152
  post_accs: list[dict | None] = []
153
- for i in range(0, len(addrs), SIM_MAX_ACCOUNTS): # in chunks: the RPC caps accounts per simulation
154
- sim = self.rpc.simulate(tx, addrs[i:i + SIM_MAX_ACCOUNTS])
155
  if sim.get("err") is not None:
156
  raise PolicyError(f"the swap fails in simulation: {sim['err']} {(sim.get('logs') or [])[-3:]}", code="in_flight")
157
  post_accs += [None if a is None else {"lamports": a["lamports"], "owner": Pubkey.from_string(a["owner"]), "data": base64.b64decode(a["data"][0])}
@@ -159,9 +168,8 @@ class Signer:
159
  post = self._snapshot(post_accs, addrs)
160
  slack = policy.BASE_FEE_LAMPORTS * vt.message.header.num_required_signatures + chk.priority_lamports + 10_000
161
  eff = policy.check_swap_effects(pre, post, in_mint=in_m, out_mint=out_m, in_amount=int(in_amount), min_out=int(min_out), slack_lamports=slack)
162
- tok = out_m if buy else in_m
163
  tok_acc = mint_acc[1] if buy else mint_acc[0]
164
- prices = self.rpc.usd_prices([str(tok), str(NATIVE_MINT)])
165
  ratio = policy.check_value("buy" if buy else "sell", eff["sol"], abs(eff["token"]), tok_acc["data"][44] if tok_acc else 0,
166
  prices.get(str(tok)), prices.get(str(NATIVE_MINT)), self.cfg.min_value_ratio)
167
  sigs = list(vt.signatures)
 
21
  import subprocess
22
  import tempfile
23
  import time
24
+ from concurrent.futures import ThreadPoolExecutor
25
  from dataclasses import dataclass, field
26
 
27
  from solders.address_lookup_table_account import AddressLookupTable
 
41
  RENT_EXEMPT_MIN = 890_880 # a 0-byte system account: the treasury PDA must keep this much
42
  LAMPORTS = 1_000_000_000
43
  SIM_MAX_ACCOUNTS = 20 # the RPC caps accounts per simulation (30, or fewer: it has said 27)
44
+ _IO = ThreadPoolExecutor(max_workers=6, thread_name_prefix="signer-io") # a swap's reads, side by side
45
 
46
 
47
  @dataclass
 
142
  raise PolicyError("daily buy volume cap reached", code="cap")
143
  vt = VersionedTransaction.from_bytes(base64.b64decode(tx))
144
  chk = policy.check_swap_static(vt, me, self._resolve_alt, self.cfg.max_priority_lamports)
145
+ # every token account the trading wallet owns, plus the two this swap may open (the reads side by side: a live
146
+ # order waits on this signature)
147
+ tok = out_m if buy else in_m
148
+ f_mints = _IO.submit(self.rpc.accounts, [in_m, out_m])
149
+ f_spl, f_t22 = _IO.submit(self.rpc.token_accounts, me, TOKEN), _IO.submit(self.rpc.token_accounts, me, TOKEN_2022)
150
+ f_prices = _IO.submit(self.rpc.usd_prices, [str(tok), str(NATIVE_MINT)])
151
+ mint_acc = f_mints.result()
152
  progs = {m: (a["owner"] if a else TOKEN) for m, a in zip((in_m, out_m), mint_acc)}
153
+ addrs = [me] + f_spl.result() + f_t22.result()
154
  for m in (in_m, out_m):
155
  a = policy.ata(me, m, progs[m])
156
  if a not in addrs:
157
  addrs.append(a)
158
+ f_pre = _IO.submit(self.rpc.accounts, addrs)
159
+ f_sims = [_IO.submit(self.rpc.simulate, tx, addrs[i:i + SIM_MAX_ACCOUNTS]) for i in range(0, len(addrs), SIM_MAX_ACCOUNTS)] # in chunks: the RPC caps accounts per simulation
160
+ pre = self._snapshot(f_pre.result(), addrs)
161
  post_accs: list[dict | None] = []
162
+ for f in f_sims:
163
+ sim = f.result()
164
  if sim.get("err") is not None:
165
  raise PolicyError(f"the swap fails in simulation: {sim['err']} {(sim.get('logs') or [])[-3:]}", code="in_flight")
166
  post_accs += [None if a is None else {"lamports": a["lamports"], "owner": Pubkey.from_string(a["owner"]), "data": base64.b64decode(a["data"][0])}
 
168
  post = self._snapshot(post_accs, addrs)
169
  slack = policy.BASE_FEE_LAMPORTS * vt.message.header.num_required_signatures + chk.priority_lamports + 10_000
170
  eff = policy.check_swap_effects(pre, post, in_mint=in_m, out_mint=out_m, in_amount=int(in_amount), min_out=int(min_out), slack_lamports=slack)
 
171
  tok_acc = mint_acc[1] if buy else mint_acc[0]
172
+ prices = f_prices.result()
173
  ratio = policy.check_value("buy" if buy else "sell", eff["sol"], abs(eff["token"]), tok_acc["data"][44] if tok_acc else 0,
174
  prices.get(str(tok)), prices.get(str(NATIVE_MINT)), self.cfg.min_value_ratio)
175
  sigs = list(vt.signatures)
fly_trader/train/corpus_meta.py CHANGED
@@ -19,6 +19,10 @@ the stream fills ``own_dd60``/``own_max60`` from its own minutes at that time, t
19
  the assembled candle files. Runs inside the ``replay`` worker after each assembly round and as
20
  ``fly-trader build-corpus-meta``. It first copies the archive's directly created PumpSwap pools into ``pump_pools``
21
  (``backfill_pump_pools``), which the stream and ``train/mature.py`` exclude from the universe (``blocked_pool_ids``).
 
 
 
 
22
  """
23
  from __future__ import annotations
24
 
@@ -35,11 +39,39 @@ from .. import config
35
  from ..db.connection import transaction
36
 
37
  log = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
38
  CURVE_COLS = ["bundle_share", "dev_hold_share", "dev_sold_frac", "grad_hhi"]
39
  FEATURE_COLS = ["ttg_min", "dev_sol", "dev_share", "mayhem", "rq0", "prior_launches", "prior_grads", "prior_known", "prior_rug_share", "prior_moon_share",
40
  *CURVE_COLS, "curve_known"]
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def curve_facts(create_slot: int | None, creator: str | None, supply: float | None, initial_buy: float | None, wallets: dict) -> tuple[dict, list[tuple[str, str]]]:
44
  """Graduation-time rug facts from a token's bonding-curve trading, one definition for the archive (``update_curve_facts``)
45
  and the live stream (``ingest/pumpstream.py``). ``wallets``: wallet → [first buy slot or None, tokens bought, tokens sold]
@@ -99,9 +131,12 @@ def rebuild(outcome_batch: int = 20000) -> int:
99
  n_pp = backfill_pump_pools() # before mature.loop_once aggregates the new days
100
  creates =con.execute(f"""SELECT mint, min(ts) AS create_ts, arg_min(signer, ts) AS creator, arg_min(quote_amount, ts) AS dev_sol,
101
  arg_min(initial_buy, ts) AS dev_tokens, arg_min(supply, ts) AS supply, arg_min(mayhem, ts) AS mayhem, arg_min(uri, ts) AS uri,
102
- arg_min(name, ts) AS name, arg_min(symbol, ts) AS symbol, arg_min(sig, ts) AS sig FROM read_parquet('{ev}') WHERE action = 'create' AND pool = 'pump' GROUP BY mint""").df()
103
- migr = con.execute(f"""SELECT mint, min(ts) AS g, arg_min(quote_in_pool, ts) AS rq0, arg_min(pool_id, ts) AS pool_id
104
- FROM read_parquet('{ev}') WHERE action = 'migrate' AND pool = 'pump-amm' GROUP BY mint""").df()
 
 
 
105
  con.close()
106
  meta = migr.merge(creates, on="mint", how="left")
107
  meta["ttg_min"] = (meta["g"] - meta["create_ts"]).dt.total_seconds() / 60.0
@@ -140,12 +175,13 @@ def rebuild(outcome_batch: int = 20000) -> int:
140
  meta = meta.merge(hist[["mint", "prior_grads", "prior_known", "prior_rug_share", "prior_moon_share"]], on="mint", how="left")
141
  meta["graduated_at"] = meta["g"]
142
  cols = ["mint", "graduated_at", "create_ts", "creator", "dev_sol", "dev_tokens", "dev_share", "supply", "mayhem", "uri", "name", "symbol", "ttg_min", "rq0", "pool_id",
143
- "prior_launches", "prior_grads", "prior_known", "prior_rug_share", "prior_moon_share", "own_dd60", "own_max60", "own_alive6h"]
 
144
  rows = [tuple(_pg(v) for v in rec) for rec in meta[cols].itertuples(index=False, name=None)]
145
  with transaction() as conn:
146
  conn.cursor().executemany(
147
  "INSERT INTO corpus_meta (" + ",".join(cols) + ") VALUES (" + ",".join(["%s"] * len(cols)) + ") ON CONFLICT (mint) DO UPDATE SET " +
148
- ", ".join(f"{c} = EXCLUDED.{c}" for c in cols if c != "mint") + ", updated_at = now()", rows)
149
  n_cr = _backfill_creates(creates)
150
  n_curve = update_curve_facts()
151
  log.info("corpus_meta rebuilt: %d graduated tokens (%d with creator, %d with outcomes), %d archive creates added to pump_events, %d custom pools to pump_pools, "
@@ -300,9 +336,20 @@ def backfill_pump_pools() -> int:
300
 
301
 
302
  def blocked_pool_ids(conn) -> list[str]:
303
- """Blocked pools that can carry universe legs. The stream and ``train/mature.py`` both keep only pump.fun-origin mints
304
- (suffix ``pump``) before this check, so pools of other mints never match either way."""
305
- return [r["pool_id"] for r in conn.execute("SELECT pool_id FROM pump_pools WHERE mint IS NULL OR right(mint, 4) = 'pump'").fetchall()]
 
 
 
 
 
 
 
 
 
 
 
306
 
307
 
308
  def creator_history(conn, creator: str | None, create_ts, graduated_at) -> dict:
@@ -328,7 +375,7 @@ def creator_history(conn, creator: str | None, create_ts, graduated_at) -> dict:
328
 
329
  def load_features() -> pd.DataFrame:
330
  with transaction() as conn:
331
- rows = conn.execute("SELECT mint, " + ", ".join(FEATURE_COLS) + " FROM corpus_meta").fetchall()
332
  df = pd.DataFrame(rows)
333
  if df.empty:
334
  return pd.DataFrame(columns=["mint"] + FEATURE_COLS)
 
19
  the assembled candle files. Runs inside the ``replay`` worker after each assembly round and as
20
  ``fly-trader build-corpus-meta``. It first copies the archive's directly created PumpSwap pools into ``pump_pools``
21
  (``backfill_pump_pools``), which the stream and ``train/mature.py`` exclude from the universe (``blocked_pool_ids``).
22
+
23
+ The universe's coins are pump.fun's: a mint ending in ``pump`` or one this table holds (a pump.fun migration recorded it).
24
+ Since 2026-10-23 the second half counts too: 4-22 % of daily graduations carry a mint without the suffix, with the same
25
+ launchpad, creator history, burned pool liquidity and token rules as the rest (``pumpfun_graduates``).
26
  """
27
  from __future__ import annotations
28
 
 
39
  from ..db.connection import transaction
40
 
41
  log = logging.getLogger(__name__)
42
+ WSOL = "So11111111111111111111111111111111111111112"
43
+ # Inputs in the graduation's quote units: SOL for a SOL graduation, but USDC, a stock token's or another coin's units when
44
+ # pump.fun seeded the pool in those (17 % of graduations on 2026-10-20). They are SOL inputs only when the quote is SOL or
45
+ # not known (the archive before 2026-10-23 did not keep it); otherwise they read as unknown (``sol_select``).
46
+ QUOTE_UNIT_COLS = ("dev_sol", "rq0")
47
+ # the venues whose SOL legs make a pump.fun coin's minutes, live (ingest/pumpstream.py) and in training (train/mature.py):
48
+ # its PumpSwap pools and, since 2026-10-23, its Meteora DLMM pools (each minute keeps the pool with the most legs)
49
+ UNIVERSE_POOLS = ("pump-amm", "meteora-dlmm")
50
  CURVE_COLS = ["bundle_share", "dev_hold_share", "dev_sold_frac", "grad_hhi"]
51
  FEATURE_COLS = ["ttg_min", "dev_sol", "dev_share", "mayhem", "rq0", "prior_launches", "prior_grads", "prior_known", "prior_rug_share", "prior_moon_share",
52
  *CURVE_COLS, "curve_known"]
53
 
54
 
55
+ def sol_select(cols) -> str:
56
+ """The SELECT list for ``cols`` of ``corpus_meta`` with ``QUOTE_UNIT_COLS`` as SOL inputs: NULL (unknown) when the
57
+ graduation's pool was seeded in another quote (``grad_quote``). Live (agent/minute_engine.py) and training
58
+ (``load_features``) read them the same way."""
59
+ return ", ".join(f"CASE WHEN grad_quote IS NULL OR grad_quote = '{WSOL}' THEN {c} END AS {c}" if c in QUOTE_UNIT_COLS else c for c in cols)
60
+
61
+
62
+ def record_grad_quotes(conn, seen) -> int:
63
+ """``seen``: (mint, pool id, quote mint) of trades the stream saw in a graduate's own pool (``corpus_meta.pool_id``):
64
+ that pool's quote is the graduation's. Fills ``grad_quote`` where it is still unknown (graduations before the archive
65
+ kept the migration's quote). Returns rows updated."""
66
+ seen = [t for t in seen if all(t)]
67
+ if not seen:
68
+ return 0
69
+ cur = conn.cursor()
70
+ cur.executemany("UPDATE corpus_meta SET grad_quote = %s, updated_at = now() WHERE mint = %s AND pool_id = %s AND grad_quote IS NULL",
71
+ [(q, m, p) for m, p, q in seen])
72
+ return max(cur.rowcount, 0)
73
+
74
+
75
  def curve_facts(create_slot: int | None, creator: str | None, supply: float | None, initial_buy: float | None, wallets: dict) -> tuple[dict, list[tuple[str, str]]]:
76
  """Graduation-time rug facts from a token's bonding-curve trading, one definition for the archive (``update_curve_facts``)
77
  and the live stream (``ingest/pumpstream.py``). ``wallets``: wallet → [first buy slot or None, tokens bought, tokens sold]
 
131
  n_pp = backfill_pump_pools() # before mature.loop_once aggregates the new days
132
  creates =con.execute(f"""SELECT mint, min(ts) AS create_ts, arg_min(signer, ts) AS creator, arg_min(quote_amount, ts) AS dev_sol,
133
  arg_min(initial_buy, ts) AS dev_tokens, arg_min(supply, ts) AS supply, arg_min(mayhem, ts) AS mayhem, arg_min(uri, ts) AS uri,
134
+ arg_min(name, ts) AS name, arg_min(symbol, ts) AS symbol, arg_min(sig, ts) AS sig FROM read_parquet('{ev}', union_by_name = true)
135
+ WHERE action = 'create' AND pool = 'pump' GROUP BY mint""").df()
136
+ ev_cols = [c[0] for c in con.execute(f"SELECT * FROM read_parquet('{ev}', union_by_name = true) LIMIT 0").description]
137
+ gq = "arg_min(quote_mint, ts) FILTER (WHERE quote_mint IS NOT NULL)" if "quote_mint" in ev_cols else "NULL::VARCHAR" # kept since 2026-10-23
138
+ migr = con.execute(f"""SELECT mint, min(ts) AS g, arg_min(quote_in_pool, ts) AS rq0, arg_min(pool_id, ts) AS pool_id, {gq} AS grad_quote
139
+ FROM read_parquet('{ev}', union_by_name = true) WHERE action = 'migrate' AND pool = 'pump-amm' GROUP BY mint""").df()
140
  con.close()
141
  meta = migr.merge(creates, on="mint", how="left")
142
  meta["ttg_min"] = (meta["g"] - meta["create_ts"]).dt.total_seconds() / 60.0
 
175
  meta = meta.merge(hist[["mint", "prior_grads", "prior_known", "prior_rug_share", "prior_moon_share"]], on="mint", how="left")
176
  meta["graduated_at"] = meta["g"]
177
  cols = ["mint", "graduated_at", "create_ts", "creator", "dev_sol", "dev_tokens", "dev_share", "supply", "mayhem", "uri", "name", "symbol", "ttg_min", "rq0", "pool_id",
178
+ "prior_launches", "prior_grads", "prior_known", "prior_rug_share", "prior_moon_share", "own_dd60", "own_max60", "own_alive6h", "grad_quote"]
179
+ keep_known = {"grad_quote"} # the archive's quote when it has one, else the stream's (record_grad_quotes)
180
  rows = [tuple(_pg(v) for v in rec) for rec in meta[cols].itertuples(index=False, name=None)]
181
  with transaction() as conn:
182
  conn.cursor().executemany(
183
  "INSERT INTO corpus_meta (" + ",".join(cols) + ") VALUES (" + ",".join(["%s"] * len(cols)) + ") ON CONFLICT (mint) DO UPDATE SET " +
184
+ ", ".join(f"{c} = COALESCE(EXCLUDED.{c}, corpus_meta.{c})" if c in keep_known else f"{c} = EXCLUDED.{c}" for c in cols if c != "mint") + ", updated_at = now()", rows)
185
  n_cr = _backfill_creates(creates)
186
  n_curve = update_curve_facts()
187
  log.info("corpus_meta rebuilt: %d graduated tokens (%d with creator, %d with outcomes), %d archive creates added to pump_events, %d custom pools to pump_pools, "
 
336
 
337
 
338
  def blocked_pool_ids(conn) -> list[str]:
339
+ """Blocked pools that can carry universe legs: those of pump.fun's coins (a ``pump`` mint or a graduate in this table).
340
+ The stream and ``train/mature.py`` keep only those coins before this check, so pools of other mints never match either
341
+ way (and the set stays ~10 % of ``pump_pools``). Read as two halves joined on the indexes: one ``corpus_meta`` probe per
342
+ pool row took 3.5 s (2026-10-23)."""
343
+ return [r["pool_id"] for r in conn.execute("""SELECT pool_id FROM pump_pools WHERE mint IS NULL OR right(mint, 4) = 'pump'
344
+ UNION ALL
345
+ SELECT p.pool_id FROM pump_pools p JOIN corpus_meta c ON c.mint = p.mint
346
+ WHERE right(p.mint, 4) <> 'pump'""").fetchall()]
347
+
348
+
349
+ def pumpfun_graduates(conn) -> list[str]:
350
+ """pump.fun graduates whose mint does not end in ``pump`` (a migration recorded them here): universe coins as much as
351
+ the suffixed ones, which the stream and the archive aggregation recognise by the suffix alone."""
352
+ return [r["mint"] for r in conn.execute("SELECT mint FROM corpus_meta WHERE right(mint, 4) <> 'pump'").fetchall()]
353
 
354
 
355
  def creator_history(conn, creator: str | None, create_ts, graduated_at) -> dict:
 
375
 
376
  def load_features() -> pd.DataFrame:
377
  with transaction() as conn:
378
+ rows = conn.execute("SELECT mint, " + sol_select(FEATURE_COLS) + " FROM corpus_meta").fetchall()
379
  df = pd.DataFrame(rows)
380
  if df.empty:
381
  return pd.DataFrame(columns=["mint"] + FEATURE_COLS)
fly_trader/train/fly_replay.py CHANGED
@@ -429,7 +429,7 @@ def merge_chains(row, out: dict, only_chains: tuple) -> dict:
429
  """The stored verdict with ``out``'s verdicts for ``only_chains`` put in; the other chains keep theirs (each with the
430
  replay start it was judged from). A stored verdict on other definitions is replaced whole."""
431
  old = (row["value"] if isinstance(row["value"], dict) else json.loads(row["value"])) if row else None
432
- if not old or old.get("data") != out.get("data") or "chains" not in old:
433
  return out
434
  chains = dict(old["chains"])
435
  for c in only_chains:
@@ -581,7 +581,7 @@ def verdict() -> dict | None:
581
  with transaction() as conn:
582
  r = conn.execute("SELECT value FROM ui_settings WHERE key = %s", (KEY,)).fetchone()
583
  v = (r["value"] if isinstance(r["value"], dict) else json.loads(r["value"] or "{}")) if r else None
584
- return v if v and v.get("data") == fly_selector.verdict_version() else None
585
 
586
 
587
  def main(days: int | None = None, start_day: int = START_DAY, stop_event: threading.Event | None = None, only_chains: tuple | None = None) -> dict:
 
429
  """The stored verdict with ``out``'s verdicts for ``only_chains`` put in; the other chains keep theirs (each with the
430
  replay start it was judged from). A stored verdict on other definitions is replaced whole."""
431
  old = (row["value"] if isinstance(row["value"], dict) else json.loads(row["value"])) if row else None
432
+ if not old or not isinstance(out.get("data"), dict) or not selector.defs_match(old.get("data"), out["data"]) or "chains" not in old:
433
  return out
434
  chains = dict(old["chains"])
435
  for c in only_chains:
 
581
  with transaction() as conn:
582
  r = conn.execute("SELECT value FROM ui_settings WHERE key = %s", (KEY,)).fetchone()
583
  v = (r["value"] if isinstance(r["value"], dict) else json.loads(r["value"] or "{}")) if r else None
584
+ return v if v and selector.defs_match(v.get("data"), fly_selector.verdict_version()) else None
585
 
586
 
587
  def main(days: int | None = None, start_day: int = START_DAY, stop_event: threading.Event | None = None, only_chains: tuple | None = None) -> dict:
fly_trader/train/fly_selector.py CHANGED
@@ -69,6 +69,7 @@ EPOCHS, BATCH = 8, 512 # EPOCHS only caps the runtime; early stopping belo
69
  TOP_WEIGHT = 10.0 # loss weight of the teacher's top 1 % of training minutes
70
  DISTIL_TOP, DISTIL_SAMPLE = 0.05, 3_000_000
71
  RECAL_ROWS = 200_000
 
72
  VAL_ROWS, VAL_MIN_ROWS = 200_000, 100_000 # distillation rows held out to judge the fit; a smaller slice is too noisy to stop on
73
  MIN_EPOCHS = 2 # never stop on one epoch: it has nothing to be compared against
74
  LR_GRAPH, LR_HEAD = 1e-4, 1e-3
@@ -427,13 +428,38 @@ def _val_mse(net: FlyNet, scaler: RobustScaler, ds: DecisionSet, idx: np.ndarray
427
  return float(e.mean()), float(e.std() / max(len(e) ** 0.5, 1.0))
428
 
429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  def train_fly(ds: DecisionSet, rows: np.ndarray, teacher, epochs: int = EPOCHS, batch: int = BATCH, stop: threading.Event | None = None,
431
  seed: int = 0, graph=None, device=None, net_kwargs: dict | None = None, model_cls=None, target_clip: tuple = (-1.0, 1.0),
432
- horizon_min: int | None = None) -> tuple[FlyModel | None, dict]:
433
  """Distil ``teacher`` (a StackTeacher, or anything with ``score`` and ``scaler``) into a FlyNet over the training rows
434
  ``rows`` (mask): one head per strategy, each learning its strategy's targets on that strategy's candidates.
435
  ``net_kwargs`` (``aff_rows``, ``scale``), ``model_cls`` and ``target_clip`` let another trading type (kalshi/fly.py) run
436
- its own fly through this loop; the memecoin defaults are unchanged."""
 
 
 
437
  brain_device.seed_all(seed); rng = np.random.default_rng(seed); dev = torch.device(device) if device else _device()
438
  n_s = len(teacher.strategies) if isinstance(teacher, StackTeacher) else 1
439
  net = FlyNet(graph if graph is not None else _graph(), obs_dim=len(ds.cols), device=dev, n_strategies=n_s, **(net_kwargs or {}))
@@ -451,8 +477,18 @@ def train_fly(ds: DecisionSet, rows: np.ndarray, teacher, epochs: int = EPOCHS,
451
  col = T[:, j]; hd.bias.fill_(float(np.nanmean(col)) * net.scale if np.isfinite(col).any() else 0.0); hd.weight.mul_(0.1)
452
  batch = min(batch, net.batch_rows(ceiling=batch, training=True)) # a training step keeps every propagation step: ~4x a forward pass
453
  opt = torch.optim.Adam(net.param_groups(LR_GRAPH, LR_HEAD)); n_b = max(1, len(use) // batch); total = max(1, epochs * n_b); hist = []; t0 = time.time()
454
- best = best_v = prev_v = early = None
455
- for ep in range(epochs):
 
 
 
 
 
 
 
 
 
 
456
  perm = use[rng.permutation(len(use))]; tot = 0.0; net.train()
457
  for b in range(n_b):
458
  if stop is not None and stop.is_set():
@@ -464,24 +500,27 @@ def train_fly(ds: DecisionSet, rows: np.ndarray, teacher, epochs: int = EPOCHS,
464
  loss = (w * (net.forward_all(x) - t) ** 2).sum() / w.sum().clamp(min=1e-9)
465
  opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(), 5.0); opt.step(); tot += loss.item()
466
  if b % 200 == 0:
467
- done = ep * n_b + b + 1; el = time.time() - t0
468
- prog.update("fly: learning from the selector", done, total, epoch=ep, loss=tot / (b + 1), rows_per_s=done * batch / max(el, 1e-9),
469
- eta_s=(total - done) * el / done)
470
  hist.append({"epoch": ep, "mse": tot / n_b, "secs": time.time() - t0, "rows": min(len(use), n_b * batch)})
471
  log.info("fly epoch %d: weighted mse %.4f over %d rows (%.0fs)", ep, tot / n_b, min(len(use), n_b * batch), time.time() - t0)
472
- if not len(val):
473
- continue
474
- vm, vse = _val_mse(net, teacher.scaler, ds, idx, T, W, val, dev, batch)
475
- hist[-1]["val_mse"], hist[-1]["val_se"] = vm, vse
476
- log.info("fly epoch %d: held-out weighted mse %.4f +/- %.4f over %d rows", ep, vm, vse, len(val))
477
- if best_v is None or vm < best_v:
478
- best_v, best = vm, {k: v.detach().clone() for k, v in net.state_dict().items()}
479
- gain = None if prev_v is None else prev_v - vm
480
- stop_now = ep + 1 >= MIN_EPOCHS and gain is not None and gain <= vse
481
- prev_v = vm
482
- if stop_now:
483
- early = f"epoch {ep} moved the held-out error by {gain:+.4f}, inside its standard error {vse:.4f}"
484
- log.info("fly: stopping early - %s", early)
 
 
 
485
  break
486
  if best is not None:
487
  net.load_state_dict(best) # the epoch that generalised best, not merely the last one
@@ -617,12 +656,13 @@ def fly_decide(fly: FlyModel, X: np.ndarray, cols: list[str], values: np.ndarray
617
 
618
 
619
  def bootstrap(ds: DecisionSet, S: date, epochs: int = EPOCHS, stop: threading.Event | None = None, seed: int = 0, graph=None,
620
- device=None, teacher=None, teacher_note: str | None = None) -> tuple[FlyModel | None, dict]:
621
  """The one time the selector teaches: a fly ready to trade from day ``S`` on, with its own line and sizing per strategy.
622
  ``teacher``: the live path passes the deployed selector (``deployed_teacher``); left None, the stack is refit on the
623
  days before ``S − 8`` — which is what the replay wants, since there the refit is the honest out-of-sample teacher (a
624
  set without the stack's inputs or per-hold labels — synthetic tests — gets the single-model selector).
625
- ``teacher_note`` records in the snapshot which selector taught this fly."""
 
626
  from .decisions import LEGACY_COLS
627
  train_end = S - timedelta(days=CALIB_DAYS + PURGE_DAYS)
628
  train = ds.day < train_end; uni = selector.in_universe(ds.X, ds.cols)
@@ -638,7 +678,7 @@ def bootstrap(ds: DecisionSet, S: date, epochs: int = EPOCHS, stop: threading.Ev
638
  return None, {"S": str(S), "gates_ok": False, "gate_failures": ["the teacher stack has no strategy on the training days"], "teacher_stack": stack_info}
639
  else:
640
  teacher = selector.fit(ds, train, seed=7)
641
- fly, fit_info = train_fly(ds, train & uni, teacher, epochs=epochs, stop=stop, seed=seed, graph=graph, device=device)
642
  if fly is None:
643
  return None, {"stopped": True, **fit_info}
644
  s_epoch = datetime(S.year, S.month, S.day, tzinfo=timezone.utc).timestamp()
@@ -695,16 +735,23 @@ def teacher_scores(models: dict, X: np.ndarray, ts, names: list[str], cols: list
695
  X = np.atleast_2d(X); out = np.full((len(X), len(names)), np.nan)
696
  tsa = np.broadcast_to(np.asarray(ts, dtype=np.float64), (len(X),))
697
  for i in range(0, len(X), chunk):
698
- per = strategies.decide_all(models, X[i:i + chunk], cols, tsa[i:i + chunk])
699
- for j, k in enumerate(names):
700
- if k in per:
701
- sc = per[k]["score"]
702
- out[i:i + chunk, j] = np.where(per[k]["trig"] & np.isfinite(sc), np.clip(sc, -1.0, 1.0), np.nan)
 
 
 
 
 
 
703
  return out
704
 
705
 
706
  def is_current(meta: dict | None, version: dict | None = None) -> bool:
707
- return bool(meta) and meta.get("data") == (version or FLY_VERSION)
 
708
 
709
 
710
  def save(fly: FlyModel, metrics: dict, run_id: str | None = None, kind: str = "fly_selector", subdir: str = "policies", version: dict | None = None,
@@ -750,14 +797,17 @@ def latest_current(conn, kind: str = "fly_selector", version: dict | None = None
750
 
751
 
752
  def deployable(meta: dict | None, version: dict | None = None) -> tuple[bool, str]:
753
- """A bootstrap may trade: current definitions, healthy network (``GATES``) and its own line made money over at least
754
- 100 trades on its calibration week."""
 
 
 
755
  if not is_current(meta, version):
756
  return False, "trained on other definitions"
757
  if not meta.get("gates_ok"):
758
  return False, "; ".join(meta.get("gate_failures") or ["network gates failed"])
759
  cal = meta.get("calibration") or {}
760
- if (cal.get("trades") or 0) < selector.MIN_LINE_TRADES:
761
  return False, f"too few calibration trades ({cal.get('trades') or 0})"
762
  if not (isinstance(cal.get("mean"), (int, float)) and math.isfinite(cal["mean"]) and cal["mean"] > 0): # NaN never passes
763
  return False, f"its calibration week lost money ({(cal.get('mean') or 0) * 100:+.2f}% per trade)"
@@ -787,11 +837,12 @@ def main(days: int | None = None, epochs: int | None = None, stop_event: threadi
787
  ds = build(days=days, horizon_min=HOLD_MIN, holds=HOLDS_MIN); S = ds.days[-1] + timedelta(days=1)
788
  teacher, note, taught_by = deployed_teacher(ds, ds.day < S - timedelta(days=CALIB_DAYS + PURGE_DAYS), calib_start=S - timedelta(days=CALIB_DAYS))
789
  log.info("fly bootstrap teacher: %s", note)
790
- fly, info = bootstrap(ds, S, epochs=EPOCHS if epochs is None else epochs, stop=stop_event, teacher=teacher, teacher_note=note)
791
  if fly is None:
792
- return {"stopped": True}
793
  info["teacher_snapshot"] = taught_by
794
  record_event("info", "fly_selector", "fly bootstrap", {k: info.get(k) for k in ("S", "lines", "calibration", "diagnostics", "gates_ok", "gate_failures", "teacher")})
795
  path, sid = save(fly, info)
 
796
  prog.update("fly: done", 1, 1, force=True, snapshot_id=sid, line=info["line"], gates_ok=info["gates_ok"], diagnostics=info["diagnostics"])
797
  return {**info, "snapshot_id": sid, "path": str(path)}
 
69
  TOP_WEIGHT = 10.0 # loss weight of the teacher's top 1 % of training minutes
70
  DISTIL_TOP, DISTIL_SAMPLE = 0.05, 3_000_000
71
  RECAL_ROWS = 200_000
72
+ CHECKPOINT = config.BRAIN_DIR / "policies" / "fly_bootstrap.ckpt.pt" # the live bootstrap's epochs so far (main): a stopped run resumes
73
  VAL_ROWS, VAL_MIN_ROWS = 200_000, 100_000 # distillation rows held out to judge the fit; a smaller slice is too noisy to stop on
74
  MIN_EPOCHS = 2 # never stop on one epoch: it has nothing to be compared against
75
  LR_GRAPH, LR_HEAD = 1e-4, 1e-3
 
428
  return float(e.mean()), float(e.std() / max(len(e) ** 0.5, 1.0))
429
 
430
 
431
+ def _resume(path, fp: dict) -> dict | None:
432
+ """The checkpoint at ``path`` when it was written for this same training set (``fp``): training resumes after its last
433
+ epoch. One written for other data is set aside (``.stale``) and training starts fresh."""
434
+ p = Path(path)
435
+ if not p.exists():
436
+ return None
437
+ ck = model_io.torch_load(p)
438
+ if ck.get("fingerprint") != fp:
439
+ stale = p.with_name(p.name + ".stale"); p.replace(stale)
440
+ log.warning("fly checkpoint %s was written for other training data (%s; now %s): set aside as %s, training starts fresh",
441
+ p.name, ck.get("fingerprint"), fp, stale.name)
442
+ return None
443
+ log.info("fly training resumes after epoch %d from %s", int(ck["epoch"]), p)
444
+ return ck
445
+
446
+
447
+ def _write_checkpoint(path, obj: dict) -> None:
448
+ """``obj`` at ``path``, atomically: a run killed while writing leaves the previous epoch's checkpoint whole."""
449
+ p = Path(path); p.parent.mkdir(parents=True, exist_ok=True); tmp = p.with_name(p.name + ".tmp")
450
+ model_io.torch_save(obj, tmp); tmp.replace(p)
451
+
452
+
453
  def train_fly(ds: DecisionSet, rows: np.ndarray, teacher, epochs: int = EPOCHS, batch: int = BATCH, stop: threading.Event | None = None,
454
  seed: int = 0, graph=None, device=None, net_kwargs: dict | None = None, model_cls=None, target_clip: tuple = (-1.0, 1.0),
455
+ horizon_min: int | None = None, checkpoint=None) -> tuple[FlyModel | None, dict]:
456
  """Distil ``teacher`` (a StackTeacher, or anything with ``score`` and ``scaler``) into a FlyNet over the training rows
457
  ``rows`` (mask): one head per strategy, each learning its strategy's targets on that strategy's candidates.
458
  ``net_kwargs`` (``aff_rows``, ``scale``), ``model_cls`` and ``target_clip`` let another trading type (kalshi/fly.py) run
459
+ its own fly through this loop; the memecoin defaults are unchanged. ``checkpoint``: a file written after every epoch
460
+ (weights, optimiser, history, the best epoch so far, the random state): a run that dies resumes after its last
461
+ finished epoch, and a finished one goes on past the cap it ran under when ``epochs`` is raised. A file written for
462
+ other training data is set aside, never resumed."""
463
  brain_device.seed_all(seed); rng = np.random.default_rng(seed); dev = torch.device(device) if device else _device()
464
  n_s = len(teacher.strategies) if isinstance(teacher, StackTeacher) else 1
465
  net = FlyNet(graph if graph is not None else _graph(), obs_dim=len(ds.cols), device=dev, n_strategies=n_s, **(net_kwargs or {}))
 
477
  col = T[:, j]; hd.bias.fill_(float(np.nanmean(col)) * net.scale if np.isfinite(col).any() else 0.0); hd.weight.mul_(0.1)
478
  batch = min(batch, net.batch_rows(ceiling=batch, training=True)) # a training step keeps every propagation step: ~4x a forward pass
479
  opt = torch.optim.Adam(net.param_groups(LR_GRAPH, LR_HEAD)); n_b = max(1, len(use) // batch); total = max(1, epochs * n_b); hist = []; t0 = time.time()
480
+ best = best_v = prev_v = early = None; start = 0
481
+ fp = {"training_rows": int(len(idx)), "rows": int(len(use)), "val_rows": int(len(val)), "seed": int(seed), "obs_dim": int(len(ds.cols)),
482
+ "strategies": list(teacher.strategies) if isinstance(teacher, StackTeacher) else []}
483
+ ck = _resume(checkpoint, fp) if checkpoint is not None else None
484
+ if ck is not None:
485
+ net.load_state_dict(ck["state_dict"])
486
+ if ck.get("opt") is not None:
487
+ opt.load_state_dict(ck["opt"])
488
+ if ck.get("rng") is not None:
489
+ rng.bit_generator.state = ck["rng"]
490
+ hist, best, best_v, prev_v, early, start = list(ck["hist"]), ck.get("best"), ck.get("best_v"), ck.get("prev_v"), ck.get("early"), int(ck["epoch"]) + 1
491
+ for ep in range(start, epochs if early is None else start):
492
  perm = use[rng.permutation(len(use))]; tot = 0.0; net.train()
493
  for b in range(n_b):
494
  if stop is not None and stop.is_set():
 
500
  loss = (w * (net.forward_all(x) - t) ** 2).sum() / w.sum().clamp(min=1e-9)
501
  opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(), 5.0); opt.step(); tot += loss.item()
502
  if b % 200 == 0:
503
+ done = ep * n_b + b + 1; here = (ep - start) * n_b + b + 1; el = time.time() - t0 # ``here``: this run's steps (a resumed run's rate)
504
+ prog.update("fly: learning from the selector", done, total, epoch=ep, loss=tot / (b + 1), rows_per_s=here * batch / max(el, 1e-9),
505
+ eta_s=(total - done) * el / here)
506
  hist.append({"epoch": ep, "mse": tot / n_b, "secs": time.time() - t0, "rows": min(len(use), n_b * batch)})
507
  log.info("fly epoch %d: weighted mse %.4f over %d rows (%.0fs)", ep, tot / n_b, min(len(use), n_b * batch), time.time() - t0)
508
+ if len(val):
509
+ vm, vse = _val_mse(net, teacher.scaler, ds, idx, T, W, val, dev, batch)
510
+ hist[-1]["val_mse"], hist[-1]["val_se"] = vm, vse
511
+ log.info("fly epoch %d: held-out weighted mse %.4f +/- %.4f over %d rows", ep, vm, vse, len(val))
512
+ if best_v is None or vm < best_v:
513
+ best_v, best = vm, {k: v.detach().clone() for k, v in net.state_dict().items()}
514
+ gain = None if prev_v is None else prev_v - vm
515
+ stop_now = ep + 1 >= MIN_EPOCHS and gain is not None and gain <= vse
516
+ prev_v = vm
517
+ if stop_now:
518
+ early = f"epoch {ep} moved the held-out error by {gain:+.4f}, inside its standard error {vse:.4f}"
519
+ log.info("fly: stopping early - %s", early)
520
+ if checkpoint is not None:
521
+ _write_checkpoint(checkpoint, {"fingerprint": fp, "epoch": ep, "state_dict": net.state_dict(), "opt": opt.state_dict(), "best": best,
522
+ "best_v": best_v, "prev_v": prev_v, "hist": hist, "rng": rng.bit_generator.state, "early": early})
523
+ if early is not None:
524
  break
525
  if best is not None:
526
  net.load_state_dict(best) # the epoch that generalised best, not merely the last one
 
656
 
657
 
658
  def bootstrap(ds: DecisionSet, S: date, epochs: int = EPOCHS, stop: threading.Event | None = None, seed: int = 0, graph=None,
659
+ device=None, teacher=None, teacher_note: str | None = None, checkpoint=None) -> tuple[FlyModel | None, dict]:
660
  """The one time the selector teaches: a fly ready to trade from day ``S`` on, with its own line and sizing per strategy.
661
  ``teacher``: the live path passes the deployed selector (``deployed_teacher``); left None, the stack is refit on the
662
  days before ``S − 8`` — which is what the replay wants, since there the refit is the honest out-of-sample teacher (a
663
  set without the stack's inputs or per-hold labels — synthetic tests — gets the single-model selector).
664
+ ``teacher_note`` records in the snapshot which selector taught this fly. ``checkpoint``: the training's per-epoch
665
+ checkpoint file (``train_fly``)."""
666
  from .decisions import LEGACY_COLS
667
  train_end = S - timedelta(days=CALIB_DAYS + PURGE_DAYS)
668
  train = ds.day < train_end; uni = selector.in_universe(ds.X, ds.cols)
 
678
  return None, {"S": str(S), "gates_ok": False, "gate_failures": ["the teacher stack has no strategy on the training days"], "teacher_stack": stack_info}
679
  else:
680
  teacher = selector.fit(ds, train, seed=7)
681
+ fly, fit_info = train_fly(ds, train & uni, teacher, epochs=epochs, stop=stop, seed=seed, graph=graph, device=device, checkpoint=checkpoint)
682
  if fly is None:
683
  return None, {"stopped": True, **fit_info}
684
  s_epoch = datetime(S.year, S.month, S.day, tzinfo=timezone.utc).timestamp()
 
735
  X = np.atleast_2d(X); out = np.full((len(X), len(names)), np.nan)
736
  tsa = np.broadcast_to(np.asarray(ts, dtype=np.float64), (len(X),))
737
  for i in range(0, len(X), chunk):
738
+ out[i:i + chunk] = teacher_scores_of(strategies.decide_all(models, X[i:i + chunk], cols, tsa[i:i + chunk]), names, len(X[i:i + chunk]))
739
+ return out
740
+
741
+
742
+ def teacher_scores_of(per: dict, names: list[str], n: int) -> np.ndarray:
743
+ """``teacher_scores`` of ``n`` rows from the stack's ``strategies.decide_all`` result on them (no second pass)."""
744
+ out = np.full((n, len(names)), np.nan)
745
+ for j, k in enumerate(names):
746
+ if k in per:
747
+ sc = per[k]["score"]
748
+ out[:, j] = np.where(per[k]["trig"] & np.isfinite(sc), np.clip(sc, -1.0, 1.0), np.nan)
749
  return out
750
 
751
 
752
  def is_current(meta: dict | None, version: dict | None = None) -> bool:
753
+ """Trained on exactly the current definitions, the retired corpus build versions set aside (``selector.defs_match``)."""
754
+ return bool(meta) and selector.defs_match(meta.get("data"), version or FLY_VERSION)
755
 
756
 
757
  def save(fly: FlyModel, metrics: dict, run_id: str | None = None, kind: str = "fly_selector", subdir: str = "policies", version: dict | None = None,
 
797
 
798
 
799
  def deployable(meta: dict | None, version: dict | None = None) -> tuple[bool, str]:
800
+ """A bootstrap may race for the seat: current definitions, healthy network (``GATES``) and its own line made money on
801
+ its calibration week over at least the race's own minimum of trades (``config.HANDOVER_MIN_TRADES``). The wallet
802
+ trades the selector's decisions until the fly wins its race (agent/fly_session.py), so a calibration week need not
803
+ prove more than the race will: a quiet week (2026-10-15..21) gave a new fly 88 trades, under the 100 asked before.
804
+ Another trading type (``version``: Kalshi) keeps ``selector.MIN_LINE_TRADES``."""
805
  if not is_current(meta, version):
806
  return False, "trained on other definitions"
807
  if not meta.get("gates_ok"):
808
  return False, "; ".join(meta.get("gate_failures") or ["network gates failed"])
809
  cal = meta.get("calibration") or {}
810
+ if (cal.get("trades") or 0) < (config.HANDOVER_MIN_TRADES if version in (None, FLY_VERSION) else selector.MIN_LINE_TRADES):
811
  return False, f"too few calibration trades ({cal.get('trades') or 0})"
812
  if not (isinstance(cal.get("mean"), (int, float)) and math.isfinite(cal["mean"]) and cal["mean"] > 0): # NaN never passes
813
  return False, f"its calibration week lost money ({(cal.get('mean') or 0) * 100:+.2f}% per trade)"
 
837
  ds = build(days=days, horizon_min=HOLD_MIN, holds=HOLDS_MIN); S = ds.days[-1] + timedelta(days=1)
838
  teacher, note, taught_by = deployed_teacher(ds, ds.day < S - timedelta(days=CALIB_DAYS + PURGE_DAYS), calib_start=S - timedelta(days=CALIB_DAYS))
839
  log.info("fly bootstrap teacher: %s", note)
840
+ fly, info = bootstrap(ds, S, epochs=EPOCHS if epochs is None else epochs, stop=stop_event, teacher=teacher, teacher_note=note, checkpoint=CHECKPOINT)
841
  if fly is None:
842
+ return {"stopped": True} # its checkpoint stays: the next run on the same data resumes it
843
  info["teacher_snapshot"] = taught_by
844
  record_event("info", "fly_selector", "fly bootstrap", {k: info.get(k) for k in ("S", "lines", "calibration", "diagnostics", "gates_ok", "gate_failures", "teacher")})
845
  path, sid = save(fly, info)
846
+ CHECKPOINT.unlink(missing_ok=True)
847
  prog.update("fly: done", 1, 1, force=True, snapshot_id=sid, line=info["line"], gates_ok=info["gates_ok"], diagnostics=info["diagnostics"])
848
  return {**info, "snapshot_id": sid, "path": str(path)}
fly_trader/train/mature.py CHANGED
@@ -2,7 +2,10 @@
2
  as feature rows — the population the live bot actually meets on Jupiter's lists, as opposed to the first 12 hours
3
  after graduation covered by ``replay_assemble``.
4
 
5
- Stage 1 (``aggregate_day``): all ``pump-amm`` trade legs of pump.fun-origin mints (suffix ``pump``, SOL-quoted) of a UTC day,
 
 
 
6
  except legs in pools created directly rather than by a pump.fun migration (``pump_pools``: their owner can pull the unburned
7
  liquidity, and the withdrawal is not in the trade data, so the price just stops after a pump),
8
  with causal filters only (the live stream applies the same ones): quote reserve 0.001–100,000 SOL, a leg more than 50× away
@@ -19,7 +22,10 @@ mask as absent), Jupiter stats stay masked. Age and price-vs-graduation come fro
19
  graduation is inside the archive (``corpus_meta.graduated_at``, the source live uses too); older tokens carry NaN age.
20
  Output: ``data/corpus/features_mature/<day>/part.parquet``. Both outputs carry a version in their Parquet metadata
21
  (``AGG_VERSION``, ``market.features.FEATURE_VERSION``); ``loop_once`` rebuilds any file from another version and moves
22
- the old one to ``data/corpus/_mature_stale/``. Feature parts also record how many of their mints had a known graduation
 
 
 
23
  (``fly_known``); a part is rebuilt once ``corpus_meta`` dates more of them (older days are backfilled after newer ones).
24
  """
25
  from __future__ import annotations
@@ -29,6 +35,8 @@ import logging
29
  import math
30
  import os
31
  import shutil
 
 
32
  import threading
33
  import time
34
  from datetime import date, datetime, timedelta, timezone
@@ -45,14 +53,16 @@ from ..db.connection import transaction
45
  from ..market.exit_cost import PUMP_SUPPLY
46
  from ..market.features import FEATURE_VERSION, FIDX, TokenMeta, TokenState
47
  from .corpus_features import SCHEMA as FEAT_SCHEMA, PRE_COLS, _epoch_s, _row
48
- from .corpus_meta import blocked_pool_ids
49
  from .flow import FLOW_COLS, MARKET_COLS, N_SKILL, SKILL_COLS, UNKNOWN, FlowWindow, MarketWindow
50
 
51
  log = logging.getLogger(__name__)
52
  MATURE_DIR = config.CORPUS_DIR / "mature"
53
  MATURE_FEAT_DIR = config.CORPUS_DIR / "features_mature"
54
  STALE_DIR = config.CORPUS_DIR / "_mature_stale"
55
- AGG_VERSION = 4 # 2: causal price band and per-minute dominant pool; 3: directly created (custom) PumpSwap pools excluded; 4: wallet flow columns (train/flow.py)
 
 
56
  KNOWN_REBUILD_FRAC, KNOWN_REBUILD_MIN = 0.01, 25 # a part is rebuilt once corpus_meta dates this many more of its mints
57
  MASKED = ["logsigners_15m", "logsigners_1h", "hawkes", "log_since_last", "vpin_15m"]
58
  EXTRA = ["traders_15m", "traders_1h", "n_trades_1m"] + FLOW_COLS + SKILL_COLS + MARKET_COLS
@@ -79,11 +89,21 @@ def _skill_version() -> str:
79
  return skill_version()
80
 
81
 
 
 
 
 
 
 
 
 
 
 
82
  def agg_stale(path) -> bool:
83
- """An aggregate to rebuild: another aggregation version, or skill buckets from another skill version while its day now
84
- has a table of the version in force (days without a table keep 'none')."""
85
  p = Path(path)
86
- if part_version(p) != AGG_VERSION:
87
  return True
88
  want = _skill_version()
89
  return want != "none" and (SKILL_DIR / f"{p.stem}.parquet").exists() and agg_skill(p) != want
@@ -200,25 +220,30 @@ def days_ready() -> list[date]:
200
  return out
201
 
202
 
203
- def aggregate_table(files: list[str], skill, blocked: list[str] | None = None, insiders: dict | None = None) -> pa.Table:
204
  """A day's minute aggregates from its hour files, with ``skill`` (wallet → decile table, or None) as the day's skill
205
  table — ``aggregate_day``'s SQL, also used to rebuild the skill inputs under a candidate table (train/wallet_skill.fit).
206
- ``blocked`` (pool ids) and ``insiders`` (mint -> wallets) are read from Postgres when not given; a caller that aggregates
207
- many days hands them in once, rather than opening two connections per day."""
 
208
  con = duckdb.connect()
209
  cols = [c[0] for c in con.execute("SELECT * FROM read_parquet(?, union_by_name = true) LIMIT 0", [files]).description]
210
  quote_filter = "AND (quote_mint IS NULL OR quote_mint = 'So11111111111111111111111111111111111111112')" if "quote_mint" in cols else ""
211
  pool_sel = "pool_id" if "pool_id" in cols else "NULL AS pool_id"
212
- if blocked is None:
213
  with transaction() as conn:
214
- blocked = blocked_pool_ids(conn)
 
215
  con.register("blocked", pa.table({"pool_id": pa.array(blocked, pa.string())}))
 
216
  pool_filter = "AND (pool_id IS NULL OR pool_id NOT IN (SELECT pool_id FROM blocked))" if "pool_id" in cols else ""
217
  # causal filters only (nothing later in the day decides which earlier trades exist); the live stream applies the same:
218
- # no directly created pool, reserve band, a leg within 50x of the median of the mint's previous 200 legs, and per (mint, minute) the pool with the most legs
 
219
  con.execute(f"""CREATE TEMP TABLE raw AS SELECT mint, ts, slot, trader, side, sol, price, quote_in_pool, {pool_sel}
220
  FROM read_parquet(?, union_by_name = true)
221
- WHERE pool = 'pump-amm' AND price > 0 AND mint LIKE '%pump' AND quote_in_pool BETWEEN 0.001 AND 100000 {quote_filter} {pool_filter}""", [files])
 
222
  con.execute("""CREATE TEMP TABLE banded AS SELECT * EXCLUDE (pref) FROM (
223
  SELECT *, median(price) OVER (PARTITION BY mint ORDER BY ts, slot ROWS BETWEEN 200 PRECEDING AND 1 PRECEDING) AS pref FROM raw)
224
  WHERE pref IS NULL OR price BETWEEN pref / 50 AND pref * 50""")
@@ -260,7 +285,8 @@ def aggregate_table(files: list[str], skill, blocked: list[str] | None = None, i
260
  return tab
261
 
262
 
263
- def aggregate_day(d: date) -> int:
 
264
  t0 = time.time(); files = _hour_files(d)
265
  if not files:
266
  return 0
@@ -269,7 +295,7 @@ def aggregate_day(d: date) -> int:
269
  return 0
270
  MATURE_DIR.mkdir(parents=True, exist_ok=True)
271
  skill = skill_table_for(d)
272
- tab = aggregate_table(files, skill)
273
  write_part(tab, MATURE_DIR / f"{d.isoformat()}.parquet", AGG_VERSION, {"fly_skill": _skill_version() if skill is not None else "none"})
274
  n_mints = len(pa.compute.unique(tab["mint"]))
275
  with transaction() as conn:
@@ -386,6 +412,46 @@ def _knows_more(part: Path, grads: dict) -> bool:
386
  return grew >= max(KNOWN_REBUILD_MIN, KNOWN_REBUILD_FRAC * len(mints))
387
 
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  def loop_once() -> int:
390
  """One build round. A file lock serialises rounds across processes (the console's replay worker and the CLI)."""
391
  MATURE_DIR.mkdir(parents=True, exist_ok=True)
@@ -407,8 +473,12 @@ def _loop_once() -> int:
407
  _retire(MATURE_FEAT_DIR / dd.isoformat() / "part.parquet", f"feat_{dd}")
408
  with transaction() as conn:
409
  conn.execute("DELETE FROM mature_days WHERE day = %s", (d,))
410
- for d in days_ready():
411
- aggregate_day(d); n += 1
 
 
 
 
412
  grads = _graduations() # corpus_meta.rebuild reads the day's events before every round, so its graduations are known
413
  for f in sorted(MATURE_DIR.glob("*.parquet"), reverse=True):
414
  d = date.fromisoformat(f.stem)
 
2
  as feature rows — the population the live bot actually meets on Jupiter's lists, as opposed to the first 12 hours
3
  after graduation covered by ``replay_assemble``.
4
 
5
+ Stage 1 (``aggregate_day``): all PumpSwap (``pump-amm``) and Meteora DLMM trade legs (``UNIVERSE_POOLS``: since
6
+ 2026-10-23 a coin's DLMM pool competes for its minute like any other pool, and is the only SOL market of a coin graduated
7
+ into a USDC, stock-token or other-coin pool) of pump.fun-origin mints (suffix ``pump``, or a pump.fun graduate without it:
8
+ ``corpus_meta.pumpfun_graduates``; SOL-quoted) of a UTC day,
9
  except legs in pools created directly rather than by a pump.fun migration (``pump_pools``: their owner can pull the unburned
10
  liquidity, and the withdrawal is not in the trade data, so the price just stops after a pump),
11
  with causal filters only (the live stream applies the same ones): quote reserve 0.001–100,000 SOL, a leg more than 50× away
 
22
  graduation is inside the archive (``corpus_meta.graduated_at``, the source live uses too); older tokens carry NaN age.
23
  Output: ``data/corpus/features_mature/<day>/part.parquet``. Both outputs carry a version in their Parquet metadata
24
  (``AGG_VERSION``, ``market.features.FEATURE_VERSION``); ``loop_once`` rebuilds any file from another version and moves
25
+ the old one to ``data/corpus/_mature_stale/``. ``AGG_VERSION`` only says when the files are rebuilt: it is not a model
26
+ definition (``train/selector.DATA_VERSION``), so a rebuild never retires a model. A rebuild of more than
27
+ ``BULK_REBUILD_DAYS`` days runs in a child process at the lowest CPU priority (``loop_once_apart``), never beside the
28
+ console's live minute pipeline. Feature parts also record how many of their mints had a known graduation
29
  (``fly_known``); a part is rebuilt once ``corpus_meta`` dates more of them (older days are backfilled after newer ones).
30
  """
31
  from __future__ import annotations
 
35
  import math
36
  import os
37
  import shutil
38
+ import subprocess
39
+ import sys
40
  import threading
41
  import time
42
  from datetime import date, datetime, timedelta, timezone
 
53
  from ..market.exit_cost import PUMP_SUPPLY
54
  from ..market.features import FEATURE_VERSION, FIDX, TokenMeta, TokenState
55
  from .corpus_features import SCHEMA as FEAT_SCHEMA, PRE_COLS, _epoch_s, _row
56
+ from .corpus_meta import UNIVERSE_POOLS, blocked_pool_ids, pumpfun_graduates
57
  from .flow import FLOW_COLS, MARKET_COLS, N_SKILL, SKILL_COLS, UNKNOWN, FlowWindow, MarketWindow
58
 
59
  log = logging.getLogger(__name__)
60
  MATURE_DIR = config.CORPUS_DIR / "mature"
61
  MATURE_FEAT_DIR = config.CORPUS_DIR / "features_mature"
62
  STALE_DIR = config.CORPUS_DIR / "_mature_stale"
63
+ AGG_VERSION = 5 # 2: causal price band and per-minute dominant pool; 3: directly created (custom) PumpSwap pools excluded; 4: wallet flow columns (train/flow.py);
64
+ # 5: pump.fun graduates whose mint lacks the "pump" suffix included (corpus_meta.pumpfun_graduates)
65
+ BULK_REBUILD_DAYS = 3 # more stale aggregates than this are rebuilt by a child process at the lowest CPU priority (live money first)
66
  KNOWN_REBUILD_FRAC, KNOWN_REBUILD_MIN = 0.01, 25 # a part is rebuilt once corpus_meta dates this many more of its mints
67
  MASKED = ["logsigners_15m", "logsigners_1h", "hawkes", "log_since_last", "vpin_15m"]
68
  EXTRA = ["traders_15m", "traders_1h", "n_trades_1m"] + FLOW_COLS + SKILL_COLS + MARKET_COLS
 
89
  return skill_version()
90
 
91
 
92
+ def _inputs_newer(p: Path) -> bool:
93
+ """A day's hour files written after its aggregate: an hour fetched late, or the archive downloaded again
94
+ (``replay_pull.refetch_main``) to keep more venues' legs."""
95
+ try:
96
+ t = p.stat().st_mtime
97
+ return any(os.path.getmtime(f) > t for f in _hour_files(date.fromisoformat(p.stem)))
98
+ except (OSError, ValueError):
99
+ return False
100
+
101
+
102
  def agg_stale(path) -> bool:
103
+ """An aggregate to rebuild: another aggregation version, hour files newer than it, or skill buckets from another skill
104
+ version while its day now has a table of the version in force (days without a table keep 'none')."""
105
  p = Path(path)
106
+ if part_version(p) != AGG_VERSION or _inputs_newer(p):
107
  return True
108
  want = _skill_version()
109
  return want != "none" and (SKILL_DIR / f"{p.stem}.parquet").exists() and agg_skill(p) != want
 
220
  return out
221
 
222
 
223
+ def aggregate_table(files: list[str], skill, blocked: list[str] | None = None, insiders: dict | None = None, grads: list[str] | None = None) -> pa.Table:
224
  """A day's minute aggregates from its hour files, with ``skill`` (wallet → decile table, or None) as the day's skill
225
  table — ``aggregate_day``'s SQL, also used to rebuild the skill inputs under a candidate table (train/wallet_skill.fit).
226
+ ``blocked`` (pool ids), ``insiders`` (mint -> wallets) and ``grads`` (pump.fun graduates without the ``pump`` suffix)
227
+ are read from Postgres when not given; a caller that aggregates many days hands them in once, rather than opening
228
+ connections per day."""
229
  con = duckdb.connect()
230
  cols = [c[0] for c in con.execute("SELECT * FROM read_parquet(?, union_by_name = true) LIMIT 0", [files]).description]
231
  quote_filter = "AND (quote_mint IS NULL OR quote_mint = 'So11111111111111111111111111111111111111112')" if "quote_mint" in cols else ""
232
  pool_sel = "pool_id" if "pool_id" in cols else "NULL AS pool_id"
233
+ if blocked is None or grads is None:
234
  with transaction() as conn:
235
+ blocked = blocked_pool_ids(conn) if blocked is None else blocked
236
+ grads = pumpfun_graduates(conn) if grads is None else grads
237
  con.register("blocked", pa.table({"pool_id": pa.array(blocked, pa.string())}))
238
+ con.register("grads", pa.table({"mint": pa.array(grads, pa.string())}))
239
  pool_filter = "AND (pool_id IS NULL OR pool_id NOT IN (SELECT pool_id FROM blocked))" if "pool_id" in cols else ""
240
  # causal filters only (nothing later in the day decides which earlier trades exist); the live stream applies the same:
241
+ # pump.fun's coins only, no directly created pool, reserve band, a leg within 50x of the median of the mint's previous 200 legs,
242
+ # and per (mint, minute) the pool with the most legs
243
  con.execute(f"""CREATE TEMP TABLE raw AS SELECT mint, ts, slot, trader, side, sol, price, quote_in_pool, {pool_sel}
244
  FROM read_parquet(?, union_by_name = true)
245
+ WHERE pool IN ({", ".join(f"'{x}'" for x in UNIVERSE_POOLS)}) AND price > 0 AND (mint LIKE '%pump' OR mint IN (SELECT mint FROM grads))
246
+ AND quote_in_pool BETWEEN 0.001 AND 100000 {quote_filter} {pool_filter}""", [files])
247
  con.execute("""CREATE TEMP TABLE banded AS SELECT * EXCLUDE (pref) FROM (
248
  SELECT *, median(price) OVER (PARTITION BY mint ORDER BY ts, slot ROWS BETWEEN 200 PRECEDING AND 1 PRECEDING) AS pref FROM raw)
249
  WHERE pref IS NULL OR price BETWEEN pref / 50 AND pref * 50""")
 
285
  return tab
286
 
287
 
288
+ def aggregate_day(d: date, blocked: list[str] | None = None, grads: list[str] | None = None) -> int:
289
+ """Aggregate day ``d`` (``aggregate_table``); ``blocked`` and ``grads`` as there, read here when not given."""
290
  t0 = time.time(); files = _hour_files(d)
291
  if not files:
292
  return 0
 
295
  return 0
296
  MATURE_DIR.mkdir(parents=True, exist_ok=True)
297
  skill = skill_table_for(d)
298
+ tab = aggregate_table(files, skill, blocked=blocked, grads=grads)
299
  write_part(tab, MATURE_DIR / f"{d.isoformat()}.parquet", AGG_VERSION, {"fly_skill": _skill_version() if skill is not None else "none"})
300
  n_mints = len(pa.compute.unique(tab["mint"]))
301
  with transaction() as conn:
 
412
  return grew >= max(KNOWN_REBUILD_MIN, KNOWN_REBUILD_FRAC * len(mints))
413
 
414
 
415
+ def stale_aggregates() -> list[Path]:
416
+ """Aggregates the next round rebuilds: another aggregation (or skill) version, with their hour files at hand."""
417
+ return [f for f in sorted(MATURE_DIR.glob("*.parquet")) if agg_stale(f) and _hour_files(date.fromisoformat(f.stem))]
418
+
419
+
420
+ def loop_once_apart(stop_event: threading.Event | None = None) -> int | None:
421
+ """One build round in a child process (``fly-trader build-mature``) at the lowest CPU priority, waited for. A version
422
+ change re-aggregates and re-features every day (~100 s a day, ~5 h for the whole archive): that work must not share the
423
+ console's interpreter with the live minute pipeline (CLAUDE.md, live money first). On stop the child is ended; every
424
+ file is written atomically, so an ended round loses only its unfinished day. Its exit code, or None when stopped."""
425
+ nice = ["/usr/bin/nice", "-n", "19"] if os.path.exists("/usr/bin/nice") else [] # not preexec_fn: unsafe in a threaded process
426
+ proc = subprocess.Popen([*nice, sys.executable, "-m", "fly_trader", "build-mature"], stdout=subprocess.DEVNULL)
427
+ log.info("mature build: %d stale day(s), rebuilt by child process %d at the lowest CPU priority", len(stale_aggregates()), proc.pid)
428
+ while proc.poll() is None:
429
+ if stop_event is not None and stop_event.is_set():
430
+ proc.terminate()
431
+ try:
432
+ proc.wait(timeout=30)
433
+ except subprocess.TimeoutExpired:
434
+ proc.kill()
435
+ log.info("mature build child %d ended on stop", proc.pid)
436
+ return None
437
+ time.sleep(2.0)
438
+ if proc.returncode:
439
+ log.warning("mature build child %d exited %d (its log: replay.log)", proc.pid, proc.returncode)
440
+ return proc.returncode
441
+
442
+
443
+ def try_loop_once() -> int | None:
444
+ """``loop_once`` unless another process holds the build lock (then None at once): the archive refetch rebuilds the
445
+ days it rewrote without waiting hours behind a rebuild that will see them anyway."""
446
+ MATURE_DIR.mkdir(parents=True, exist_ok=True)
447
+ with open(MATURE_DIR / ".build.lock", "w") as lock:
448
+ try:
449
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
450
+ except BlockingIOError:
451
+ return None
452
+ return _loop_once()
453
+
454
+
455
  def loop_once() -> int:
456
  """One build round. A file lock serialises rounds across processes (the console's replay worker and the CLI)."""
457
  MATURE_DIR.mkdir(parents=True, exist_ok=True)
 
473
  _retire(MATURE_FEAT_DIR / dd.isoformat() / "part.parquet", f"feat_{dd}")
474
  with transaction() as conn:
475
  conn.execute("DELETE FROM mature_days WHERE day = %s", (d,))
476
+ days = days_ready()
477
+ if days:
478
+ with transaction() as conn: # the custom pools and suffix-less graduates once a round, not once a day
479
+ blocked, pf = blocked_pool_ids(conn), pumpfun_graduates(conn)
480
+ for d in days:
481
+ aggregate_day(d, blocked=blocked, grads=pf); n += 1
482
  grads = _graduations() # corpus_meta.rebuild reads the day's events before every round, so its graduations are known
483
  for f in sorted(MATURE_DIR.glob("*.parquet"), reverse=True):
484
  d = date.fromisoformat(f.stem)
fly_trader/train/replay_assemble.py CHANGED
@@ -77,7 +77,7 @@ def assemble_day(d: date) -> tuple[int, int]:
77
  t0 = time.time(); nxt = d + timedelta(days=1)
78
  con = duckdb.connect()
79
  ev_files = _files(d, "events", range(24))
80
- grads = con.execute("SELECT mint, min(ts) AS g, arg_min(slot, ts) AS slot, arg_min(quote_in_pool, ts) AS rq0 FROM read_parquet(?) "
81
  "WHERE action = 'migrate' AND pool = 'pump-amm' AND mint IS NOT NULL GROUP BY mint", [ev_files]).fetchall()
82
  if not grads:
83
  return 0, 0
@@ -87,8 +87,9 @@ def assemble_day(d: date) -> tuple[int, int]:
87
  [v for g in grads for v in (g[0], g[1])])
88
  # every trade leg of a graduated mint within [g − 30 min, g + 36 h]
89
  con.execute("CREATE TEMP TABLE tr0 AS SELECT t.mint, t.ts, t.slot, t.pool, t.trader, t.side, t.sol, t.tokens, t.price, t.quote_in_pool, "
90
- "epoch_ms(t.ts) - epoch_ms(g.g) AS rel_ms FROM read_parquet(?) t JOIN grads g USING (mint) "
91
- "WHERE t.ts >= g.g - INTERVAL 30 MINUTE AND t.ts < g.g + INTERVAL 36 HOUR AND t.price > 0 "
 
92
  "AND (t.pool = 'pump' OR t.quote_in_pool BETWEEN 0.001 AND 100000)", [tr_files])
93
  band_amm(con, "tr0", "tr")
94
  (cdir := config.CORPUS_DIR / "candles").mkdir(parents=True, exist_ok=True); (tdir := config.CORPUS_DIR / "trades").mkdir(parents=True, exist_ok=True)
@@ -162,7 +163,10 @@ def assemble_loop(stop_event: threading.Event | None = None, idle_s: float = 60.
162
  log.exception("corpus_meta rebuild failed; mature build waits for the next round")
163
  else:
164
  try:
165
- mature.loop_once()
 
 
 
166
  except Exception:
167
  log.exception("mature universe build failed")
168
  end = time.time() + idle_s
 
77
  t0 = time.time(); nxt = d + timedelta(days=1)
78
  con = duckdb.connect()
79
  ev_files = _files(d, "events", range(24))
80
+ grads = con.execute("SELECT mint, min(ts) AS g, arg_min(slot, ts) AS slot, arg_min(quote_in_pool, ts) AS rq0 FROM read_parquet(?, union_by_name = true) "
81
  "WHERE action = 'migrate' AND pool = 'pump-amm' AND mint IS NOT NULL GROUP BY mint", [ev_files]).fetchall()
82
  if not grads:
83
  return 0, 0
 
87
  [v for g in grads for v in (g[0], g[1])])
88
  # every trade leg of a graduated mint within [g − 30 min, g + 36 h]
89
  con.execute("CREATE TEMP TABLE tr0 AS SELECT t.mint, t.ts, t.slot, t.pool, t.trader, t.side, t.sol, t.tokens, t.price, t.quote_in_pool, "
90
+ "epoch_ms(t.ts) - epoch_ms(g.g) AS rel_ms FROM read_parquet(?, union_by_name = true) t JOIN grads g USING (mint) "
91
+ "WHERE t.pool IN ('pump', 'pump-amm') " # the hour files also keep other venues' SOL legs since 2026-10-23
92
+ "AND t.ts >= g.g - INTERVAL 30 MINUTE AND t.ts < g.g + INTERVAL 36 HOUR AND t.price > 0 "
93
  "AND (t.pool = 'pump' OR t.quote_in_pool BETWEEN 0.001 AND 100000)", [tr_files])
94
  band_amm(con, "tr0", "tr")
95
  (cdir := config.CORPUS_DIR / "candles").mkdir(parents=True, exist_ok=True); (tdir := config.CORPUS_DIR / "trades").mkdir(parents=True, exist_ok=True)
 
163
  log.exception("corpus_meta rebuild failed; mature build waits for the next round")
164
  else:
165
  try:
166
+ if len(mature.stale_aggregates()) > mature.BULK_REBUILD_DAYS:
167
+ mature.loop_once_apart(stop_event) # a version change's rebuild: hours of work, kept out of the console
168
+ else:
169
+ mature.loop_once()
170
  except Exception:
171
  log.exception("mature universe build failed")
172
  end = time.time() + idle_s
fly_trader/train/selector.py CHANGED
@@ -39,32 +39,33 @@ from ..db.connection import transaction
39
  from ..market.features import FEATURE_VERSION
40
  from . import model_io, progress as prog
41
  from .decisions import HOLD_MIN, MIN_RESQ_SOL, MIN_VOL_15M_SOL, X_COLS, DecisionSet, build, evaluate, random_trades, rank_corr, summarize, taken_rows
42
- from .mature import AGG_VERSION
43
  from .scaling import RobustScaler
44
 
45
  log = logging.getLogger(__name__)
46
  SELECTOR_DIR = config.BRAIN_DIR / "selectors"
47
  # the data and model definitions a selector was trained on (the input columns by hash, so dropping or adding one retires
48
  # old models); a model from other definitions is never loaded or shown. The fly has its own (train/fly_selector.FLY_VERSION),
49
- # so a change to the fly never retires a selector.
 
 
 
50
  def _skill_version() -> str:
51
  from .wallet_skill import skill_version
52
  return skill_version()
53
 
54
 
55
  def _rh_version() -> dict:
56
- """The Robinhood Chain half of the combined dataset: its aggregation, skill space and cost constants (a model trained
57
- before RH rows existed, or on other RH definitions, is not current). Not RH_ENABLED: whether an install indexes and
58
  trades RH is its own choice, and a combined model trades Solana alone (until 2026-10-10 an install with RH off
59
  computed "chains": "sol", refused the shipped combined models and crash-looped at boot)."""
60
  from .. import config
61
- from ..rh.corpus import RH_AGG_VERSION
62
  from ..rh.wallet_skill import skill_version as rh_skill
63
- return {"chains": "sol+rh-1", "rh_agg": RH_AGG_VERSION, "rh_skill": rh_skill(),
64
  "rh_costs": f"pons-kyber-1:{config.RH_ETH_PER_SOL:g}:{config.RH_LABEL_SIZE_ETH:g}:{config.RH_TX_FEE_ETH:g}:{config.RH_HOOK_FEE:g}"}
65
 
66
 
67
- DATA_VERSION = {"agg": AGG_VERSION, "features": FEATURE_VERSION, "costs": "real-fees-sized-1", "selector": "multi-1",
68
  "cols": hashlib.sha1(",".join(X_COLS).encode()).hexdigest()[:8], "skill": _skill_version(), "meta": "rug-1", **_rh_version()}
69
  WARMUP_DAYS, BLOCK_DAYS = 21, 7 # walk-forward: the first 21 days only train; every later day is tested, refit every 7 days
70
  MIN_AGE_H = 6.0 # trade only tokens at least this long past graduation (unknown age = graduated before the archive); fitted, see decisions.HOLD_MIN
@@ -81,9 +82,21 @@ def in_universe(X: np.ndarray, cols: list[str]) -> np.ndarray:
81
  return (X[:, c["age_known"]] == 0) | (np.expm1(X[:, c["log_age_h"]]) >= MIN_AGE_H)
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  def is_current(meta: dict | None) -> bool:
85
  """Trained on every definition in ``DATA_VERSION``. Keys a model carries that are no longer definitions (the fly's,
86
- which moved to ``FLY_VERSION``) are ignored; a model saved before the column hash existed is judged on the rest."""
87
  d = (meta or {}).get("data")
88
  return bool(meta) and isinstance(d, dict) and all(d.get(k) == v for k, v in DATA_VERSION.items() if k != "cols" or "cols" in d)
89
 
 
39
  from ..market.features import FEATURE_VERSION
40
  from . import model_io, progress as prog
41
  from .decisions import HOLD_MIN, MIN_RESQ_SOL, MIN_VOL_15M_SOL, X_COLS, DecisionSet, build, evaluate, random_trades, rank_corr, summarize, taken_rows
 
42
  from .scaling import RobustScaler
43
 
44
  log = logging.getLogger(__name__)
45
  SELECTOR_DIR = config.BRAIN_DIR / "selectors"
46
  # the data and model definitions a selector was trained on (the input columns by hash, so dropping or adding one retires
47
  # old models); a model from other definitions is never loaded or shown. The fly has its own (train/fly_selector.FLY_VERSION),
48
+ # so a change to the fly never retires a selector. The corpus build versions (mature.AGG_VERSION, rh/corpus.RH_AGG_VERSION)
49
+ # are not definitions: they say when the corpus files are rebuilt (which tokens and legs a day holds), not what a model's
50
+ # inputs, labels or costs mean, so a rebuild never retires a model (until 2026-10-23 they were keys here, and widening the
51
+ # universe to data already in the corpus would have stopped all trading until a retrain).
52
  def _skill_version() -> str:
53
  from .wallet_skill import skill_version
54
  return skill_version()
55
 
56
 
57
  def _rh_version() -> dict:
58
+ """The Robinhood Chain half of the combined dataset: its skill space and cost constants (a model trained before RH
59
+ rows existed, or on other RH definitions, is not current). Not RH_ENABLED: whether an install indexes and
60
  trades RH is its own choice, and a combined model trades Solana alone (until 2026-10-10 an install with RH off
61
  computed "chains": "sol", refused the shipped combined models and crash-looped at boot)."""
62
  from .. import config
 
63
  from ..rh.wallet_skill import skill_version as rh_skill
64
+ return {"chains": "sol+rh-1", "rh_skill": rh_skill(),
65
  "rh_costs": f"pons-kyber-1:{config.RH_ETH_PER_SOL:g}:{config.RH_LABEL_SIZE_ETH:g}:{config.RH_TX_FEE_ETH:g}:{config.RH_HOOK_FEE:g}"}
66
 
67
 
68
+ DATA_VERSION = {"features": FEATURE_VERSION, "costs": "real-fees-sized-1", "selector": "multi-1",
69
  "cols": hashlib.sha1(",".join(X_COLS).encode()).hexdigest()[:8], "skill": _skill_version(), "meta": "rug-1", **_rh_version()}
70
  WARMUP_DAYS, BLOCK_DAYS = 21, 7 # walk-forward: the first 21 days only train; every later day is tested, refit every 7 days
71
  MIN_AGE_H = 6.0 # trade only tokens at least this long past graduation (unknown age = graduated before the archive); fitted, see decisions.HOLD_MIN
 
82
  return (X[:, c["age_known"]] == 0) | (np.expm1(X[:, c["log_age_h"]]) >= MIN_AGE_H)
83
 
84
 
85
+ RETIRED_KEYS = ("agg", "rh_agg") # the corpus build versions, dropped from the definitions 2026-10-23
86
+
87
+
88
+ def defs_match(stored, current: dict) -> bool:
89
+ """A stored definitions record (a fly's or a replay verdict's ``data``) equals ``current`` once the keys retired from the
90
+ definitions (``RETIRED_KEYS``, when ``current`` no longer has them) are set aside. Retiring a key never retires a fly;
91
+ every other difference still does, an extra key included (a distilled fly's ``distil`` keeps it out)."""
92
+ if not isinstance(stored, dict):
93
+ return False
94
+ return {k: v for k, v in stored.items() if k in current or k not in RETIRED_KEYS} == current
95
+
96
+
97
  def is_current(meta: dict | None) -> bool:
98
  """Trained on every definition in ``DATA_VERSION``. Keys a model carries that are no longer definitions (the fly's,
99
+ which moved to ``FLY_VERSION``; the corpus build versions) are ignored; a model saved before the column hash existed is judged on the rest."""
100
  d = (meta or {}).get("data")
101
  return bool(meta) and isinstance(d, dict) and all(d.get(k) == v for k, v in DATA_VERSION.items() if k != "cols" or "cols" in d)
102
 
fly_trader/train/strategies.py CHANGED
@@ -18,15 +18,18 @@ hold, its own line):
18
  - ``breakout``: organic buying pressure at a new high (org_imb_5m > 0, buyers_slope > 0, at the 1 h or 3 h high);
19
  thresholds on organic imbalance, buyer growth and organic volume (order-flow and attention evidence, wash removed).
20
  A per-strategy classifier of P(net return > 0), trained only on that strategy's out-of-sample candidates (nested
21
- walk-forward; the primary score is one of its inputs), filters trades (Joubert 2022 meta-labeling). A multiclass
 
22
  GBM of the loss tail gives P(return ≤ d) for dump levels d at tail quantiles of the selection half's pick returns
23
  and vetoes buys (MemeTrans/MELT: rug risk). Hours and market-volume deciles whose selection-half profit factor is below 1
24
- are blocked. Where several strategies fire on a token-minute, the one with the larger measured Kelly fraction (its
25
- sizing band) or the higher score wins — whichever rule made more on the selection half.
 
26
  """
27
  from __future__ import annotations
28
 
29
  import hashlib
 
30
  import logging
31
  import threading
32
  from dataclasses import dataclass, field
@@ -188,9 +191,10 @@ def beats_random_removal(ds: DecisionSet, pick_before: np.ndarray, pick_after: n
188
 
189
  # ---------------------------------------------------------------- walk-forward primitives
190
  def _cache_path(ds: DecisionSet, cols: list[str], tag: str) -> "Path":
 
191
  from .selector import DATA_VERSION
192
- k = repr((str(ds.days[0]), str(ds.days[-1]), int(len(ds.y)), float(ds.horizon_s), list(cols), tag,
193
- DATA_VERSION.get("agg"), DATA_VERSION.get("features"), DATA_VERSION.get("costs"), OBJECTIVE)
194
  + (("label-known-purge",) if getattr(ds, "label_ts", None) is not None else ())
195
  + ((("data", getattr(ds, "data_version")),) if getattr(ds, "data_version", None) is not None else ()) # e.g. the Kalshi feature version
196
  + ((("train_max_rows", int(getattr(ds, "train_max_rows"))),) if getattr(ds, "train_max_rows", None) else ()))
@@ -456,32 +460,32 @@ def fit_strategy(ds: DecisionSet, name: str, cols: list[str], uni: np.ndarray, s
456
 
457
  # ---------------------------------------------------------------- meta-label, veto, gates
458
  def fit_meta(ds: DecisionSet, s: StrategyFit, uni: np.ndarray, sel: np.ndarray, day0, stop=None) -> tuple[np.ndarray, float | None, dict]:
459
- sp = s.spec or memecoin_spec()
460
  """Nested: a classifier of P(net return > 0 over the strategy's hold) on its OOS candidates (primary score ≥ 0 and
461
- the fitted trigger), walk-forward, the primary score among its inputs; the cut-off (deciles among the selection
462
- half's candidates) jointly with the line."""
 
 
 
 
463
  X, c = ds.X, ds.cols; ci = np.asarray([c.index(x) for x in s.cols]); y_r = sp.label(ds, s.hold_min)
464
  rows = uni & sp.base_mask(s.name, X, c, s.high) & trigger_mask(s.name, s.thr, X, c, sp) & np.isfinite(s.oos) & (s.oos >= 0) & np.isfinite(y_r)
465
  log.info("meta-label %s: %d candidate rows of %d, %d inputs, hold %d min", s.name, int(rows.sum()), len(ds.y), len(ci) + 1, s.hold_min)
466
  feats = lambda idx: np.c_[X[np.ix_(idx, ci)], s.oos[idx]]
467
  p = wf_classify(ds, rows, feats, (y_r > 0).astype(np.int8), 2, stop)[:, 1]
468
- log.info("meta-label %s: classifiers done, %.1f GB resident; choosing the line and cut-off", s.name, _rss_gb())
469
- best = None; best_p = None; trials = 0; seen = None
470
- lines = [s.line] + quantile_grid(s.oos[sel & rows]); cuts = quantile_grid(p[sel & rows])
471
- log.info("meta-label %s: %d lines x %d cut-offs to try", s.name, len(lines), len(cuts))
472
- for line in lines:
473
- for cut in cuts:
474
- trials += 1
475
- if trials % 25 == 0:
476
- log.info("meta-label %s: trial %d/%d, %.1f GB resident", s.name, trials, len(lines) * len(cuts), _rss_gb())
477
- pk = sel & rows & (s.oos >= line) & (p >= cut)
478
- sc = score_pick(ds, pk, sp.hold_s(ds, s.hold_min), y_r, day0)
479
- if sc.n > 0 and (seen is None or sc.total > seen.total):
480
- seen = sc # the most profitable cut-off that actually traded
481
- if better(sc, best):
482
- best = sc; best_p = (line, cut)
483
- return p, (best_p[1] if best_p else None), {"line": best_p[0] if best_p else None, "selection": best.dict() if best else None, "trials": trials,
484
- "best_seen": seen.dict() if seen else None}
485
 
486
 
487
  def system_pick(ds: DecisionSet, fits: list[StrategyFit], rows: np.ndarray, uni: np.ndarray, combine: str, tables: dict | None = None):
@@ -559,6 +563,15 @@ def _comp(name, passed, reason, sel=None, ev=None, params=None, trials=0, best=N
559
  return c
560
 
561
 
 
 
 
 
 
 
 
 
 
562
  def fit_stack(ds: DecisionSet, stop: threading.Event | None = None, holdout_days_n: int | None = None, spec: "StackSpec | None" = None) -> Stack:
563
  """Every component in order, each on top of the accepted ones; see the module docstring. ``holdout_days_n``: how many
564
  of the last days to withhold from every fit (default ``HOLDOUT_DAYS``). A caller whose ``ds`` is already restricted to
@@ -639,22 +652,45 @@ def fit_stack(ds: DecisionSet, stop: threading.Event | None = None, holdout_days
639
  for f in fits:
640
  y_r = sp.label(ds, f.hold_min); t = taken_idx(ds.ts, ds.mint, sp.hold_s(ds, f.hold_min), np.flatnonzero(f.pick(ds, sel, uni)))
641
  tables[f.name] = sizing.build_table(f.oos[t] - f.line, y_r[t])
642
- # 3. combination rule
643
- best_c = None; combine = "score"
644
- for rule in ("kelly", "score"):
645
- pk, hd, rt, _ = system_pick(ds, fits, sel, uni, rule, tables)
646
- s = score_pick(ds, pk, hd, rt, day0)
647
- if better(s, best_c):
648
- best_c = s; combine = rule
649
- comps.append(_comp("combination", True, f"'{combine}' made more on the selection half", best_c.dict() if best_c else None, None, {"rule": combine}))
650
- # 4. meta-label per strategy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  for f in fits:
652
  p, cut, info = fit_meta(ds, f, uni, sel, day0, stop)
653
  if cut is None:
654
- comps.append(_comp(f"meta-label: {f.name}", False, "no cut-off cleared the bars on the selection half", trials=info["trials"], best=info.get("best_seen"))); continue
 
655
  log.info("meta-label %s: scoring the book before the filter (%.1f GB)", f.name, _rss_gb())
656
  before_s = score_pick(ds, *system_pick(ds, fits, sel, uni, combine, tables)[:3], day0)
657
- old = (f.meta_p, f.meta_cut, f.line); f.meta_p, f.meta_cut = p, cut; f.line = info["line"] if info["line"] is not None else f.line
658
  log.info("meta-label %s: scoring the book after the filter (%.1f GB)", f.name, _rss_gb())
659
  after_sel = score_pick(ds, *system_pick(ds, fits, sel, uni, combine, tables)[:3], day0)
660
  log.info("meta-label %s: evaluation half, unfiltered book (%.1f GB)", f.name, _rss_gb())
@@ -670,7 +706,7 @@ def fit_stack(ds: DecisionSet, stop: threading.Event | None = None, holdout_days
670
  else "rejected (selection, evaluation or random-removal test)", after_sel.dict(), {**ev_s.dict(), "random_removal": rnd}, {"cut": cut, "line": f.line},
671
  info["trials"], best=info.get("best_seen")))
672
  if not ok:
673
- f.meta_p, f.meta_cut, f.line = old
674
  # 5. veto
675
  veto = None
676
  pk, hd, rt, who = system_pick(ds, fits, tested, uni, combine, tables)
@@ -710,7 +746,7 @@ def fit_stack(ds: DecisionSet, stop: threading.Event | None = None, holdout_days
710
 
711
  def _without_meta(f: StrategyFit, old) -> StrategyFit:
712
  import copy
713
- g = copy.copy(f); g.meta_p, g.meta_cut, g.line = old
714
  return g
715
 
716
 
@@ -834,11 +870,12 @@ def decide_all(models: dict, X: np.ndarray, cols: list[str], t_start, spec: "Sta
834
  return out
835
 
836
 
837
- def decide(models: dict, X: np.ndarray, cols: list[str], t_start, spec: "StackSpec | None" = None) -> dict:
838
  """The selector's decision for each row of one minute (``t_start`` = the minute's start): which strategy trades it,
839
- its predicted net return, hold, line and sizing table, and whether a filter blocks it (with the reason)."""
 
840
  sp = spec or memecoin_spec()
841
- X = np.atleast_2d(X); n = len(X); per = decide_all(models, X, cols, t_start, sp)
842
  best_key = np.full(n, -np.inf); strat = np.full(n, None, dtype=object); score = np.full(n, -1.0); hold = np.zeros(n)
843
  thr = np.full(n, np.inf); tables = [[] for _ in range(n)]; allow = np.zeros(n, bool); reason = np.full(n, "no strategy fires", dtype=object)
844
  for name, d in per.items():
 
18
  - ``breakout``: organic buying pressure at a new high (org_imb_5m > 0, buyers_slope > 0, at the 1 h or 3 h high);
19
  thresholds on organic imbalance, buyer growth and organic volume (order-flow and attention evidence, wash removed).
20
  A per-strategy classifier of P(net return > 0), trained only on that strategy's out-of-sample candidates (nested
21
+ walk-forward; the primary score is one of its inputs), filters trades (Joubert 2022 meta-labeling): at the strategy's
22
+ own line it chooses only its cut-off, which must beat no cut-off at all. A multiclass
23
  GBM of the loss tail gives P(return ≤ d) for dump levels d at tail quantiles of the selection half's pick returns
24
  and vetoes buys (MemeTrans/MELT: rug risk). Hours and market-volume deciles whose selection-half profit factor is below 1
25
+ are blocked. The book keeps the passed strategies, and the rule between them, that together score best on the selection
26
+ half: a strategy the book does better without is dropped there. Where several of its strategies fire on a token-minute,
27
+ the one with the larger measured Kelly fraction (its sizing band) or the higher score wins.
28
  """
29
  from __future__ import annotations
30
 
31
  import hashlib
32
+ import itertools
33
  import logging
34
  import threading
35
  from dataclasses import dataclass, field
 
191
 
192
  # ---------------------------------------------------------------- walk-forward primitives
193
  def _cache_path(ds: DecisionSet, cols: list[str], tag: str) -> "Path":
194
+ from .mature import AGG_VERSION
195
  from .selector import DATA_VERSION
196
+ k = repr((str(ds.days[0]), str(ds.days[-1]), int(len(ds.y)), float(ds.horizon_s), list(cols), tag, # the corpus build version keys
197
+ AGG_VERSION, DATA_VERSION.get("features"), DATA_VERSION.get("costs"), OBJECTIVE) # the cache, not the model's validity
198
  + (("label-known-purge",) if getattr(ds, "label_ts", None) is not None else ())
199
  + ((("data", getattr(ds, "data_version")),) if getattr(ds, "data_version", None) is not None else ()) # e.g. the Kalshi feature version
200
  + ((("train_max_rows", int(getattr(ds, "train_max_rows"))),) if getattr(ds, "train_max_rows", None) else ()))
 
460
 
461
  # ---------------------------------------------------------------- meta-label, veto, gates
462
  def fit_meta(ds: DecisionSet, s: StrategyFit, uni: np.ndarray, sel: np.ndarray, day0, stop=None) -> tuple[np.ndarray, float | None, dict]:
 
463
  """Nested: a classifier of P(net return > 0 over the strategy's hold) on its OOS candidates (primary score ≥ 0 and
464
+ the fitted trigger), walk-forward, the primary score among its inputs; then its cut-off at the strategy's own line
465
+ (deciles among the selection half's trades), against no cut-off at all. Until 2026-10-19 the line was chosen jointly
466
+ with the cut-off: on selector #154 that moved ev's line from 0.038 to 0.115, and the filter priced the strategy out of
467
+ the book instead of filtering it. ``cut`` None: no cut-off cleared the bars, or trading without one did better
468
+ (``info["unfiltered"]``)."""
469
+ sp = s.spec or memecoin_spec()
470
  X, c = ds.X, ds.cols; ci = np.asarray([c.index(x) for x in s.cols]); y_r = sp.label(ds, s.hold_min)
471
  rows = uni & sp.base_mask(s.name, X, c, s.high) & trigger_mask(s.name, s.thr, X, c, sp) & np.isfinite(s.oos) & (s.oos >= 0) & np.isfinite(y_r)
472
  log.info("meta-label %s: %d candidate rows of %d, %d inputs, hold %d min", s.name, int(rows.sum()), len(ds.y), len(ci) + 1, s.hold_min)
473
  feats = lambda idx: np.c_[X[np.ix_(idx, ci)], s.oos[idx]]
474
  p = wf_classify(ds, rows, feats, (y_r > 0).astype(np.int8), 2, stop)[:, 1]
475
+ log.info("meta-label %s: classifiers done, %.1f GB resident; choosing the cut-off", s.name, _rss_gb())
476
+ trade = sel & rows & (s.oos >= s.line) & np.isfinite(p) # its trades where a classifier existed: every trial on the same rows
477
+ best = None; best_cut = None; trials = 0; seen = None
478
+ cuts = [None] + quantile_grid(p[trade]) # no cut-off first: a cut-off must beat it outright
479
+ log.info("meta-label %s: %d cut-offs to try at its line %.4f", s.name, len(cuts), s.line)
480
+ for cut in cuts:
481
+ trials += 1
482
+ sc = score_pick(ds, trade if cut is None else trade & (p >= cut), sp.hold_s(ds, s.hold_min), y_r, day0)
483
+ if sc.n > 0 and (seen is None or sc.total > seen.total):
484
+ seen = sc # the most profitable cut-off that actually traded
485
+ if better(sc, best):
486
+ best = sc; best_cut = cut
487
+ return p, best_cut, {"selection": best.dict() if best else None, "trials": trials, "best_seen": seen.dict() if seen else None,
488
+ "unfiltered": best is not None and best_cut is None}
 
 
 
489
 
490
 
491
  def system_pick(ds: DecisionSet, fits: list[StrategyFit], rows: np.ndarray, uni: np.ndarray, combine: str, tables: dict | None = None):
 
563
  return c
564
 
565
 
566
+ def _dropped(best: Score, with_it: Score) -> str:
567
+ """Why the book left a strategy out: the chosen book, which lacks it, beat every book that had it on the selection half."""
568
+ if best.admissible and not with_it.admissible:
569
+ return f"dropped from the book: without it the book meets {bars(best.gate)} on the selection half, with it never"
570
+ if best.base_ok and not with_it.base_ok:
571
+ return "dropped from the book: every book with it missed the bars on the selection half"
572
+ return f"dropped from the book: the book made {best.total:.1f} on the selection half without it, {with_it.total:.1f} at best with it"
573
+
574
+
575
  def fit_stack(ds: DecisionSet, stop: threading.Event | None = None, holdout_days_n: int | None = None, spec: "StackSpec | None" = None) -> Stack:
576
  """Every component in order, each on top of the accepted ones; see the module docstring. ``holdout_days_n``: how many
577
  of the last days to withhold from every fit (default ``HOLDOUT_DAYS``). A caller whose ``ds`` is already restricted to
 
652
  for f in fits:
653
  y_r = sp.label(ds, f.hold_min); t = taken_idx(ds.ts, ds.mint, sp.hold_s(ds, f.hold_min), np.flatnonzero(f.pick(ds, sel, uni)))
654
  tables[f.name] = sizing.build_table(f.oos[t] - f.line, y_r[t])
655
+ # 3. the book: which passed strategies it keeps and the rule between them -- every subset under each rule, scored as one
656
+ # book on the selection half. A strategy the book does better without is dropped here, in the open: until 2026-10-19
657
+ # only the rule was chosen, logged as having "made more" even when no rule cleared the bars (selector #154), and the
658
+ # win filter then priced ev out of the book with its line instead.
659
+ best_c = None; combine = "score"; keep = list(fits); with_it = {}; seen = None; n_books = 0
660
+ for k in range(len(fits), 0, -1): # larger books first: on a tie the book keeps more strategies
661
+ for sub in itertools.combinations(fits, k):
662
+ for rule in (("kelly", "score") if k > 1 else ("kelly",)): # alone, a strategy trades the same rows under either rule
663
+ n_books += 1
664
+ s = score_pick(ds, *system_pick(ds, list(sub), sel, uni, rule, tables)[:3], day0)
665
+ if s.n > 0 and (seen is None or s.total > seen.total):
666
+ seen = s
667
+ for f in sub: # the best book each strategy was part of, for the record
668
+ w = with_it.get(f.name)
669
+ if w is None or (s.admissible, s.base_ok, s.total) > (w.admissible, w.base_ok, w.total):
670
+ with_it[f.name] = s
671
+ if better(s, best_c):
672
+ best_c = s; combine = rule; keep = list(sub)
673
+ if best_c is None:
674
+ comps.append(_comp("combination", False, f"no book of the passed strategies cleared the bars on the selection half; all kept under '{combine}'",
675
+ params={"rule": combine, "strategies": [f.name for f in fits]}, trials=n_books, best=seen.dict() if seen else None))
676
+ else:
677
+ kept = [f.name for f in keep]
678
+ comps.append(_comp("combination", True, f"{' + '.join(kept)} under '{combine}' made the most on the selection half", best_c.dict(), None,
679
+ {"rule": combine, "strategies": kept}, n_books, best=seen.dict() if seen else None))
680
+ for f in fits:
681
+ if f.name not in kept:
682
+ comps.append(_comp(f"combination: {f.name}", False, _dropped(best_c, with_it[f.name]), with_it[f.name].dict(),
683
+ params={"hold_min": f.hold_min, "line": f.line}, best=with_it[f.name].dict()))
684
+ fits = keep; tables = {n: t for n, t in tables.items() if n in kept}
685
+ # 4. meta-label per strategy: a filter at the strategy's own line (fit_meta never moves it)
686
  for f in fits:
687
  p, cut, info = fit_meta(ds, f, uni, sel, day0, stop)
688
  if cut is None:
689
+ comps.append(_comp(f"meta-label: {f.name}", False, "trading without a cut-off did better on the selection half" if info["unfiltered"]
690
+ else "no cut-off cleared the bars on the selection half", trials=info["trials"], best=info.get("best_seen"))); continue
691
  log.info("meta-label %s: scoring the book before the filter (%.1f GB)", f.name, _rss_gb())
692
  before_s = score_pick(ds, *system_pick(ds, fits, sel, uni, combine, tables)[:3], day0)
693
+ old = (f.meta_p, f.meta_cut); f.meta_p, f.meta_cut = p, cut
694
  log.info("meta-label %s: scoring the book after the filter (%.1f GB)", f.name, _rss_gb())
695
  after_sel = score_pick(ds, *system_pick(ds, fits, sel, uni, combine, tables)[:3], day0)
696
  log.info("meta-label %s: evaluation half, unfiltered book (%.1f GB)", f.name, _rss_gb())
 
706
  else "rejected (selection, evaluation or random-removal test)", after_sel.dict(), {**ev_s.dict(), "random_removal": rnd}, {"cut": cut, "line": f.line},
707
  info["trials"], best=info.get("best_seen")))
708
  if not ok:
709
+ f.meta_p, f.meta_cut = old
710
  # 5. veto
711
  veto = None
712
  pk, hd, rt, who = system_pick(ds, fits, tested, uni, combine, tables)
 
746
 
747
  def _without_meta(f: StrategyFit, old) -> StrategyFit:
748
  import copy
749
+ g = copy.copy(f); g.meta_p, g.meta_cut = old
750
  return g
751
 
752
 
 
870
  return out
871
 
872
 
873
+ def decide(models: dict, X: np.ndarray, cols: list[str], t_start, spec: "StackSpec | None" = None, per: dict | None = None) -> dict:
874
  """The selector's decision for each row of one minute (``t_start`` = the minute's start): which strategy trades it,
875
+ its predicted net return, hold, line and sizing table, and whether a filter blocks it (with the reason). ``per``: this
876
+ minute's ``decide_all`` when the caller has it already (one pass over the stack)."""
877
  sp = spec or memecoin_spec()
878
+ X = np.atleast_2d(X); n = len(X); per = decide_all(models, X, cols, t_start, sp) if per is None else per
879
  best_key = np.full(n, -np.inf); strat = np.full(n, None, dtype=object); score = np.full(n, -1.0); hold = np.zeros(n)
880
  thr = np.full(n, np.inf); tables = [[] for _ in range(n)]; allow = np.zeros(n, bool); reason = np.full(n, "no strategy fires", dtype=object)
881
  for name, d in per.items():
fly_trader/train/wallet_skill.py CHANGED
@@ -70,9 +70,18 @@ def build_wallet_day(d: date, out_dir: Path | None = None) -> int:
70
  if inp is None:
71
  return 0
72
  trades, aggs, parts = inp
 
 
 
 
73
  con = duckdb.connect()
74
- con.execute("""CREATE TEMP TABLE legs AS SELECT mint, trader, ts, side, sol, price FROM read_parquet(?, union_by_name = true)
75
- WHERE pool = 'pump-amm' AND mint LIKE '%pump' AND price > 0 AND sol > 0 AND trader IS NOT NULL""", [trades])
 
 
 
 
 
76
  con.execute("CREATE TEMP TABLE closes AS SELECT mint, ts AS mts, close FROM read_parquet(?) ORDER BY mint, mts", [aggs])
77
  con.execute("CREATE TEMP TABLE ec AS SELECT mint, ts AS mts, exit_cost_0p1 AS ec FROM read_parquet(?)", [parts])
78
  out = []
@@ -177,18 +186,18 @@ def daily() -> int:
177
  return n
178
 
179
 
180
- def _lookups() -> tuple[list[str], dict]:
181
- """The blocked pools and token insiders, read once: a fit aggregates 149 days per candidate, and reconnecting for each
182
- one cost thousands of connections and lost a 20-hour run to a connection timeout."""
183
  from ..db.connection import transaction
184
- from .corpus_meta import blocked_pool_ids
185
  with transaction() as conn:
186
- blocked = blocked_pool_ids(conn)
187
  ins: dict = {}
188
  for r in conn.execute("SELECT mint, wallet FROM token_insiders").fetchall():
189
  ins.setdefault(r["mint"], []).append(r["wallet"])
190
- log.info("wallet skill: %d blocked pools, insiders for %d tokens (read once)", len(blocked), len(ins))
191
- return blocked, ins
192
 
193
 
194
  def _day_skill(d: date, h: int, L: int, day_dir: Path | None = None, lookups=None):
@@ -197,8 +206,8 @@ def _day_skill(d: date, h: int, L: int, day_dir: Path | None = None, lookups=Non
197
  files = mature._hour_files(d)
198
  if not files or mature._lacks_pool_id(files):
199
  return None
200
- blocked, insiders = lookups if lookups is not None else (None, None)
201
- tab = mature.aggregate_table(files, skill_table(d, h, L, day_dir), blocked=blocked, insiders=insiders)
202
  sb = np.zeros((tab.num_rows, flow.N_SKILL))
203
  col = tab["skill_buy"].combine_chunks()
204
  if col.null_count < len(col):
 
70
  if inp is None:
71
  return 0
72
  trades, aggs, parts = inp
73
+ from ..db.connection import transaction
74
+ from .corpus_meta import pumpfun_graduates
75
+ with transaction() as conn:
76
+ grads = pumpfun_graduates(conn)
77
  con = duckdb.connect()
78
+ con.register("grads", pa.table({"mint": pa.array(grads, pa.string())}))
79
+ cols = [c[0] for c in con.execute("SELECT * FROM read_parquet(?, union_by_name = true) LIMIT 0", [trades]).description]
80
+ quote_filter = "AND (quote_mint IS NULL OR quote_mint = 'So11111111111111111111111111111111111111112')" if "quote_mint" in cols else ""
81
+ # the universe's legs (mature.aggregate_table): pump.fun's coins, SOL-quoted (a leg in another quote is not a SOL amount)
82
+ con.execute(f"""CREATE TEMP TABLE legs AS SELECT mint, trader, ts, side, sol, price FROM read_parquet(?, union_by_name = true)
83
+ WHERE pool IN ({", ".join(f"'{x}'" for x in mature.UNIVERSE_POOLS)}) AND (mint LIKE '%pump' OR mint IN (SELECT mint FROM grads)) AND price > 0 AND sol > 0
84
+ AND trader IS NOT NULL {quote_filter}""", [trades])
85
  con.execute("CREATE TEMP TABLE closes AS SELECT mint, ts AS mts, close FROM read_parquet(?) ORDER BY mint, mts", [aggs])
86
  con.execute("CREATE TEMP TABLE ec AS SELECT mint, ts AS mts, exit_cost_0p1 AS ec FROM read_parquet(?)", [parts])
87
  out = []
 
186
  return n
187
 
188
 
189
+ def _lookups() -> tuple[list[str], dict, list[str]]:
190
+ """The blocked pools, token insiders and pump.fun graduates without the suffix, read once: a fit aggregates 149 days per
191
+ candidate, and reconnecting for each one cost thousands of connections and lost a 20-hour run to a connection timeout."""
192
  from ..db.connection import transaction
193
+ from .corpus_meta import blocked_pool_ids, pumpfun_graduates
194
  with transaction() as conn:
195
+ blocked = blocked_pool_ids(conn); grads = pumpfun_graduates(conn)
196
  ins: dict = {}
197
  for r in conn.execute("SELECT mint, wallet FROM token_insiders").fetchall():
198
  ins.setdefault(r["mint"], []).append(r["wallet"])
199
+ log.info("wallet skill: %d blocked pools, insiders for %d tokens, %d graduates without the suffix (read once)", len(blocked), len(ins), len(grads))
200
+ return blocked, ins, grads
201
 
202
 
203
  def _day_skill(d: date, h: int, L: int, day_dir: Path | None = None, lookups=None):
 
206
  files = mature._hour_files(d)
207
  if not files or mature._lacks_pool_id(files):
208
  return None
209
+ blocked, insiders, grads = lookups if lookups is not None else (None, None, None)
210
+ tab = mature.aggregate_table(files, skill_table(d, h, L, day_dir), blocked=blocked, insiders=insiders, grads=grads)
211
  sb = np.zeros((tab.num_rows, flow.N_SKILL))
212
  col = tab["skill_buy"].combine_chunks()
213
  if col.null_count < len(col):
fly_trader/ui/api/inspector.py CHANGED
@@ -125,6 +125,20 @@ def _explain_token(latest: list[dict], chain: str) -> str:
125
  return "\n\n".join(out)
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  @topic("inspector.token", every=10)
129
  def token(params: dict) -> dict:
130
  """One memecoin token through the fly's eyes and the books' ledgers."""
@@ -175,7 +189,7 @@ def token(params: dict) -> dict:
175
  return {"chain": chain, "chain_name": m.name, "unit": m.unit, "mint": mint, "label": labels([mint]).get(mint, mint[:6] + "…"),
176
  "symbol": meta.get("symbol"), "name": meta.get("name"), "graduated_at": meta.get("graduated_at"), "creator": meta.get("creator"),
177
  "explorer": explorer(chain, "address", mint), "books": {"selector": m.selector_book, "fly": m.fly_book, "live": m.live_book},
178
- "fly_score": config.FLY_SCORE, "teacher_gate": bool(config.FLY_TEACHER_GATE), "learned_veto": config.FLY_LEARNED_VETO,
179
  "series": series, "stats": stats, "total": total, "latest": latest, "explain": _explain_token(latest, chain),
180
  "decisions": decisions, "positions": positions, "fills": fills,
181
  "pnl": {"closed": len(closed), "realized": sum(_f(p["realized_sol"]) or 0.0 for p in closed), "open": sum(1 for p in positions if p["status"] == "open"),
 
125
  return "\n\n".join(out)
126
 
127
 
128
+ def _minute_bar(chain: str, mint: str, at) -> dict | None:
129
+ """The token's minute at ``at`` (the newest scored minute; None: its newest traded minute): its close and its volume
130
+ (buys + sells, in its chain's unit), with the close five minutes earlier and the mean volume of the five minutes before
131
+ it (a minute without trades counts as none) -- the inspector's up/down arrows."""
132
+ tbl, vol = ("rh_minutes", "COALESCE(buy_eth, 0) + COALESCE(sell_eth, 0)") if chain == "rh" else ("pump_minutes", "COALESCE(buy_sol, 0) + COALESCE(sell_sol, 0)")
133
+ cur = q1(f"SELECT ts, close, {vol} AS volume FROM {tbl} WHERE mint = %s{' AND ts <= %s' if at else ''} ORDER BY ts DESC LIMIT 1", (mint, at) if at else (mint,))
134
+ if not cur:
135
+ return None
136
+ then = q1(f"SELECT close FROM {tbl} WHERE mint = %s AND ts <= %s - interval '5 minutes' AND close IS NOT NULL ORDER BY ts DESC LIMIT 1", (mint, cur["ts"]))
137
+ before = q1(f"SELECT sum({vol}) AS v FROM {tbl} WHERE mint = %s AND ts >= %s - interval '5 minutes' AND ts < %s", (mint, cur["ts"], cur["ts"]))
138
+ return {"ts": cur["ts"], "price": _f(cur["close"]), "volume": _f(cur["volume"]), "price_5m": _f(then["close"]) if then else None,
139
+ "volume_5m": (_f(before["v"]) or 0.0) / 5.0}
140
+
141
+
142
  @topic("inspector.token", every=10)
143
  def token(params: dict) -> dict:
144
  """One memecoin token through the fly's eyes and the books' ledgers."""
 
189
  return {"chain": chain, "chain_name": m.name, "unit": m.unit, "mint": mint, "label": labels([mint]).get(mint, mint[:6] + "…"),
190
  "symbol": meta.get("symbol"), "name": meta.get("name"), "graduated_at": meta.get("graduated_at"), "creator": meta.get("creator"),
191
  "explorer": explorer(chain, "address", mint), "books": {"selector": m.selector_book, "fly": m.fly_book, "live": m.live_book},
192
+ "minute": _minute_bar(chain, mint, last_ts), "fly_score": config.FLY_SCORE, "teacher_gate": bool(config.FLY_TEACHER_GATE), "learned_veto": config.FLY_LEARNED_VETO,
193
  "series": series, "stats": stats, "total": total, "latest": latest, "explain": _explain_token(latest, chain),
194
  "decisions": decisions, "positions": positions, "fills": fills,
195
  "pnl": {"closed": len(closed), "realized": sum(_f(p["realized_sol"]) or 0.0 for p in closed), "open": sum(1 for p in positions if p["status"] == "open"),
fly_trader/ui/static/console/assets/index-BzBFluj-.css DELETED
The diff for this file is too large to render. See raw diff
 
fly_trader/ui/static/console/assets/index-VfXqQuzN.js DELETED
The diff for this file is too large to render. See raw diff
 
models/policies/fly_20261006T023153Z.pt DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:73141bf59bd4321c66f28e4c5a7c83f994adf744574de8b17fe5d3ca94fcc1e7
3
- size 28525445
 
 
 
 
models/selectors/selector_20261003T201413Z.skops DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:b38f0633ee4a9b693bfb9a01a5e5de044e2ce317e08fb9726e77b48dfbb603fe
3
- size 40812282
 
 
 
 
models/wallet_skill/2026-10-11.parquet DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:34c0faf085f068c217a98d21722aa44a57907b466547f723ffa3fc1e6ee6287c
3
- size 281123254