release 10 (7e2ff90c0e)
Browse files- CHANGELOG.md +232 -0
- console/src/components/DataTable.tsx +4 -3
- console/src/fly/Inspector.tsx +21 -2
- console/src/lib/format.ts +6 -0
- console/src/pages/Overview.tsx +25 -9
- console/src/theme/brand.css +3 -0
- fly_trader/__main__.py +36 -0
- fly_trader/agent/fly_live.py +53 -11
- fly_trader/agent/fly_session.py +95 -29
- fly_trader/agent/live_first.py +75 -13
- fly_trader/agent/minute_engine.py +62 -38
- fly_trader/agent/paper_trading.py +35 -5
- fly_trader/chain/balances.py +5 -2
- fly_trader/chain/rpc.py +22 -10
- fly_trader/config.py +31 -3
- fly_trader/db/schema.py +4 -1
- fly_trader/execution/broker_live.py +76 -47
- fly_trader/execution/worker.py +91 -33
- fly_trader/ingest/helius_feed.py +428 -0
- fly_trader/ingest/pumpstream.py +195 -59
- fly_trader/ingest/relay_feed.py +109 -0
- fly_trader/ingest/replay_pull.py +169 -11
- fly_trader/kalshi/strategies.py +6 -2
- fly_trader/ops/release.py +1 -1
- fly_trader/rh/corpus.py +50 -7
- fly_trader/rh/index.py +22 -15
- fly_trader/rh/live.py +50 -9
- fly_trader/rh/meta.py +43 -2
- fly_trader/rh/minutes.py +40 -15
- fly_trader/rh/prefetch.py +1 -1
- fly_trader/rh/prices.py +4 -4
- fly_trader/rh/schema.py +30 -0
- fly_trader/rh/stream.py +76 -3
- fly_trader/rh/token_check.py +131 -0
- fly_trader/rh/v4_universe.py +469 -0
- fly_trader/signer/core.py +16 -8
- fly_trader/train/corpus_meta.py +56 -9
- fly_trader/train/fly_replay.py +2 -2
- fly_trader/train/fly_selector.py +85 -34
- fly_trader/train/mature.py +88 -18
- fly_trader/train/replay_assemble.py +8 -4
- fly_trader/train/selector.py +21 -8
- fly_trader/train/strategies.py +78 -41
- fly_trader/train/wallet_skill.py +20 -11
- fly_trader/ui/api/inspector.py +15 -1
- fly_trader/ui/static/console/assets/index-BzBFluj-.css +0 -0
- fly_trader/ui/static/console/assets/index-VfXqQuzN.js +0 -0
- models/policies/fly_20261006T023153Z.pt +0 -3
- models/selectors/selector_20261003T201413Z.skops +0 -3
- 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 |
-
|
|
|
|
| 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=
|
| 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
|
| 191 |
function BookPanel() {
|
| 192 |
-
const { chain } = useApp()
|
| 193 |
const b = useTopic<Book>('overview.book', { chain })
|
| 194 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (${
|
| 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-
|
| 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-
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 169 |
-
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
| 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
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 13 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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, '')
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 679 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
| 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
|
| 5 |
-
holds a sale back -- and at once for the minute it starts in (a restart may have
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 21 |
-
|
| 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, " +
|
| 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
|
|
|
|
| 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
|
| 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:
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
| 451 |
st = None
|
| 452 |
-
if
|
| 453 |
try:
|
| 454 |
-
st = engine.paper_phase(*
|
| 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 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 35 |
tokens: dict[str, int] = {}
|
| 36 |
decimals: dict[str, int] = {}
|
| 37 |
deposits: dict[str, int] = {}
|
| 38 |
-
for acct in
|
| 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 |
-
|
| 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
|
| 165 |
-
# selector's buys and sizes
|
|
|
|
| 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 =
|
| 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 (
|
| 4 |
-
assert_signing_allowed() -> pre = snapshot_balances()
|
| 5 |
-
jupiterz) -> assert signatureFeePayer == taker -> orders row (status
|
| 6 |
-
signer slot -> POST /execute -> orders row updated -> poll getSignatureStatuses every
|
| 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
|
| 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 =
|
| 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 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 284 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}: {
|
| 305 |
delta = compute_delta(pre, post)
|
| 306 |
lam = int(delta["lamports_delta"])
|
| 307 |
tok = int(delta["token_deltas"].get(mint, 0))
|
| 308 |
-
if
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 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 =
|
| 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
|
| 459 |
-
signing to its outcome
|
| 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;
|
| 4 |
-
LiveBroker
|
|
|
|
| 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
|
| 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
|
| 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.
|
| 75 |
-
self.
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 120 |
-
queued ones, and wait for the
|
| 121 |
-
self.
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
|
| 132 |
def _loop(self) -> None:
|
| 133 |
-
while
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
self.
|
| 140 |
-
|
| 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.
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|
|
|
|
| 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
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
| 301 |
cur = int(now // 60) * 60
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
if now - self.last_event_wall >= FLUSH_FALLBACK_S:
|
| 304 |
wm = max(wm, int((now - FLUSH_FALLBACK_S) // 60) * 60)
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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.
|
| 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 |
-
|
| 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
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
| 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, {
|
| 60 |
|
| 61 |
|
| 62 |
-
def
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 11 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 179 |
-
|
|
|
|
| 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 =
|
| 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 (
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 333 |
-
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 56 |
-
(
|
|
|
|
| 57 |
insiders: dict[str, frozenset] = {}
|
| 58 |
base_cache: dict[tuple, tuple] = {}
|
| 59 |
-
ref: dict[str, deque] = {}; ref_day =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
rows: dict[tuple, _Row] = {}
|
| 61 |
for s in swaps:
|
| 62 |
ts = s["ts"].timestamp(); day = int(ts // 86400)
|
| 63 |
-
if
|
| 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["
|
| 69 |
ok = not dq or (statistics.median(dq) / PRICE_BAND <= p <= statistics.median(dq) * PRICE_BAND)
|
| 70 |
dq.append(p)
|
| 71 |
-
if not ok
|
| 72 |
-
continue
|
| 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(
|
| 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
|
| 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 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
progs = {m: (a["owner"] if a else TOKEN) for m, a in zip((in_m, out_m), mint_acc)}
|
| 146 |
-
addrs = [me] +
|
| 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 |
-
|
|
|
|
|
|
|
| 152 |
post_accs: list[dict | None] = []
|
| 153 |
-
for
|
| 154 |
-
sim =
|
| 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 =
|
| 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}'
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, " +
|
| 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
|
| 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")
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 469 |
-
eta_s=(total - done) * el /
|
| 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
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
return out
|
| 704 |
|
| 705 |
|
| 706 |
def is_current(meta: dict | None, version: dict | None = None) -> bool:
|
| 707 |
-
|
|
|
|
| 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
|
| 754 |
-
|
|
|
|
|
|
|
|
|
|
| 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``
|
|
|
|
|
|
|
|
|
|
| 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/``.
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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
|
| 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)
|
| 207 |
-
many days hands them in once, rather than opening
|
|
|
|
| 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,
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 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
|
| 57 |
-
|
| 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", "
|
| 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 = {"
|
| 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)
|
|
|
|
| 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.
|
| 25 |
-
|
|
|
|
| 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 |
-
|
| 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;
|
| 462 |
-
half's
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 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.
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, "
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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.
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|