bryceweiner commited on
Commit
0f038be
·
verified ·
1 Parent(s): cfcda44

fly-trader distribution: code, fly #62, selector #59, connectome, seed (branch distribution @ 5cb1652)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +15 -0
  2. .env.example +50 -0
  3. .gitignore +18 -0
  4. Dockerfile +21 -0
  5. LICENSE +21 -0
  6. README.md +150 -0
  7. config/mbon_valence.json +27 -0
  8. docker-compose.yml +39 -0
  9. docker/entrypoint.sh +38 -0
  10. fly_trader/__init__.py +3 -0
  11. fly_trader/__main__.py +229 -0
  12. fly_trader/agent/__init__.py +1 -0
  13. fly_trader/agent/fly_live.py +159 -0
  14. fly_trader/agent/fly_session.py +510 -0
  15. fly_trader/agent/handover.py +24 -0
  16. fly_trader/agent/minute_engine.py +342 -0
  17. fly_trader/agent/paper_trading.py +89 -0
  18. fly_trader/agent/rails.py +122 -0
  19. fly_trader/agent/runner.py +64 -0
  20. fly_trader/agent/selector_session.py +162 -0
  21. fly_trader/agent/sizing.py +106 -0
  22. fly_trader/brain/__init__.py +1 -0
  23. fly_trader/brain/connectome.py +171 -0
  24. fly_trader/brain/connectome_build.py +337 -0
  25. fly_trader/brain/plastic.py +275 -0
  26. fly_trader/brain/populations.py +611 -0
  27. fly_trader/chain/__init__.py +1 -0
  28. fly_trader/chain/balances.py +49 -0
  29. fly_trader/chain/cluster_guard.py +35 -0
  30. fly_trader/chain/jupiter_swap.py +221 -0
  31. fly_trader/chain/jupiter_tokens.py +82 -0
  32. fly_trader/chain/keys.py +141 -0
  33. fly_trader/chain/rpc.py +200 -0
  34. fly_trader/chain/signing.py +86 -0
  35. fly_trader/config.py +196 -0
  36. fly_trader/db/__init__.py +0 -0
  37. fly_trader/db/apilog.py +49 -0
  38. fly_trader/db/connection.py +85 -0
  39. fly_trader/db/queries.py +37 -0
  40. fly_trader/db/schema.py +363 -0
  41. fly_trader/execution/__init__.py +1 -0
  42. fly_trader/execution/broker_live.py +455 -0
  43. fly_trader/execution/broker_paper.py +97 -0
  44. fly_trader/execution/ledger.py +147 -0
  45. fly_trader/execution/worker.py +143 -0
  46. fly_trader/ingest/__init__.py +1 -0
  47. fly_trader/ingest/corpus_pull.py +439 -0
  48. fly_trader/ingest/discovery.py +80 -0
  49. fly_trader/ingest/pumpstream.py +488 -0
  50. fly_trader/ingest/replay_pull.py +315 -0
.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .venv
3
+ .env
4
+ data
5
+ logs
6
+ dist
7
+ web
8
+ media
9
+ tests
10
+ .pytest_cache
11
+ .ruff_cache
12
+ __pycache__
13
+ *.pyc
14
+ .agents
15
+ .claude
.env.example ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fly-trader distribution -- copy to .env and fill in the two API keys. Everything else is set for a 0.5 SOL wallet.
2
+ # Keep values bare: Docker Compose keeps a trailing "# comment" as part of an empty value, so comments live on their own lines.
3
+
4
+ # --- keys (never logged) ---
5
+ # Helius: https://dashboard.helius.dev (RPC for balances and transactions)
6
+ HELIUS_API_KEY=
7
+ # Jupiter: https://portal.jup.ag (swaps and token stats)
8
+ JUPITER_API_KEY=
9
+ # Leave empty. `docker compose exec fly fly-trader wallet new` writes it here.
10
+ BOT_PRIVATE_KEY=
11
+
12
+ # --- database (docker-compose.yml sets the container's URL; this one is for running outside Docker) ---
13
+ DATABASE_URL=postgresql:///fly_trader
14
+
15
+ # --- money ---
16
+ # Live money. Until the wallet is funded the engine trades paper and says so on the console.
17
+ LIVE_ENABLED=1
18
+ # What you fund the wallet with; also the paper warm-up bankroll.
19
+ CAPITAL_SOL=0.5
20
+ # Hard cap per position.
21
+ MAX_POSITION_SOL=0.05
22
+ # Per position, of the bankroll minus the gas reserve.
23
+ MAX_POSITION_FRACTION=0.10
24
+ # Per position, of the pool's SOL reserve (bounds price impact).
25
+ MAX_POOL_SHARE=0.02
26
+ # Smaller sizes are skipped.
27
+ MIN_POSITION_SOL=0.02
28
+ # Never spent (fees and token-account rent).
29
+ GAS_RESERVE_SOL=0.05
30
+ KELLY_FRACTION=0.25
31
+
32
+ # --- rails ---
33
+ # 20 % below the wallet's peak: stop entering ...
34
+ KILL_SWITCH_DRAWDOWN=0.20
35
+ # ... and sell everything open. Clear it on the console's "Safety & wallet" page or with `fly-trader reset-circuit --kill`.
36
+ KILL_SWITCH_LIQUIDATE=1
37
+
38
+ # --- paper warm-up, then live ---
39
+ # No minimum racing time; after 20 closed paper trades the fly trades the wallet; no race against the paper selector.
40
+ HANDOVER_DAYS=0
41
+ HANDOVER_MIN_TRADES=20
42
+ HANDOVER_BEAT_SELECTOR=0
43
+ # A restart keeps the paper book, so the warm-up count survives it.
44
+ RESET_ON_START=0
45
+
46
+ # --- runtime ---
47
+ SOLANA_CLUSTER=mainnet-beta
48
+ DEVICE=cpu
49
+ DATA_DIR=data
50
+ LOG_DIR=logs
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .venv/
3
+ data/
4
+ logs/
5
+ dist/
6
+ models/wallet_skill/*.parquet # ~300 MB: on Hugging Face, fetched by docker/entrypoint.sh
7
+ __pycache__/
8
+ *.pyc
9
+ .pytest_cache/
10
+ *.egg-info/
11
+ .streamlit/secrets.toml
12
+ .DS_Store
13
+ .agents/
14
+ .claude/
15
+
16
+ # large media assets - deployed straight to Gandi, not tracked in GitHub source repo
17
+ web/assets/*.mp4
18
+ media/*.mp4
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fly-trader: the trading engine and its console, on the CPU (a Linux container has no Metal GPU; the fly infers fine on CPU).
2
+ FROM python:3.13-slim
3
+
4
+ ENV PYTHONUNBUFFERED=1 \
5
+ UV_PROJECT_ENVIRONMENT=/app/.venv \
6
+ PATH="/app/.venv/bin:${PATH}"
7
+
8
+ RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client ca-certificates curl \
9
+ && rm -rf /var/lib/apt/lists/*
10
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
11
+
12
+ WORKDIR /app
13
+ COPY . .
14
+ # CPU-only torch first (the default Linux wheel pulls CUDA, ~3 GB); the project's own pin is then already satisfied.
15
+ RUN uv venv /app/.venv \
16
+ && uv pip install --index-url https://download.pytorch.org/whl/cpu "torch>=2.12" \
17
+ && uv pip install . \
18
+ && chmod +x /app/docker/entrypoint.sh
19
+
20
+ EXPOSE 8501
21
+ ENTRYPOINT ["/app/docker/entrypoint.sh"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fly-trader contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ pipeline_tag: reinforcement-learning
4
+ library_name: pytorch
5
+ tags:
6
+ - solana
7
+ - memecoin
8
+ - pump.fun
9
+ - trading
10
+ - connectome
11
+ - drosophila
12
+ - flywire
13
+ ---
14
+
15
+ # fly-trader — distribution
16
+
17
+ **A fruit-fly connectome that trades Solana memecoins with your money.**
18
+
19
+ > ## ⚠️ Read this first
20
+ >
21
+ > This software signs real transactions from a wallet you fund. It buys tokens that graduated from pump.fun, holds them
22
+ > for about two hours, and sells. **It can lose everything in that wallet, quickly.** The model was trained on a few
23
+ > months of data; the market it trades is adversarial and changes. Nothing here is a claim of profit, and nothing here is
24
+ > financial advice. Run it only with money you can lose entirely, on a wallet used for nothing else. MIT licence — no
25
+ > warranty of any kind. See [LICENSE](LICENSE).
26
+
27
+ This is the ready-to-run branch: the trading engine, its console, a trained fly, the selector that taught it, and the
28
+ connectome they run on. The training pipeline lives on [`master`](https://github.com/bryceweiner/fly-trader).
29
+
30
+ ## What it does
31
+
32
+ - Every minute it scores every PumpSwap token that graduated from pump.fun at least six hours ago, from live 1-minute
33
+ candles (a token needs that much history before its features mean anything).
34
+ - The fly — the FlyWire *Drosophila* central brain (41,756 neurons) as a rate network on its real signed synapses —
35
+ predicts each token's net return over the next 120 minutes. It was taught by a gradient-boosted selector; it then keeps
36
+ learning from what its own trades earn, through plasticity at its mushroom body's KC→MBON synapses.
37
+ - It buys when the predicted return clears its own buy line, sizes the position from measured certainty (a quarter
38
+ of the growth-optimal bet, capped), holds 120 minutes, sells.
39
+ - **It starts on paper.** After 20 closed paper trades it switches itself to the wallet and stays there. Before that,
40
+ and whenever the wallet is unfunded or a key is missing, it trades paper and says so on the console.
41
+ - **Rails:** 20 % below the wallet's peak it stops entering and sells every open position (the kill switch); three
42
+ failed transactions in a row trip a circuit breaker; a stale market feed halts entries; if live fills trail the paper
43
+ mirror by more than 2 points per trade over 50 trades, entries pause and it tells you.
44
+
45
+ ## Requirements
46
+
47
+ - Docker (Desktop on macOS/Windows, or Engine + Compose v2 on Linux). ~2 GB of disk for the image and database.
48
+ - A [Helius](https://dashboard.helius.dev) API key (free tier is enough) and a [Jupiter](https://portal.jup.ag) API key.
49
+ - 0.5 SOL you are prepared to lose. Not more: the caps below are sized for 0.5.
50
+ - Any CPU. No GPU is used.
51
+
52
+ ## Start trading
53
+
54
+ 1. Download this branch and open a terminal in it:
55
+ ```bash
56
+ git clone -b distribution https://github.com/bryceweiner/fly-trader.git && cd fly-trader
57
+ ```
58
+ 2. Create your `.env` and paste the two API keys into it:
59
+ ```bash
60
+ cp .env.example .env
61
+ ```
62
+ 3. Start it:
63
+ ```bash
64
+ docker compose up -d --build
65
+ ```
66
+ The first start builds the image (several minutes), creates the database, installs the models and starts the market
67
+ feed and the trading engine.
68
+ 4. Create the bot wallet — it prints the public key and writes the secret into your `.env`:
69
+ ```bash
70
+ docker compose exec fly fly-trader wallet new
71
+ ```
72
+ **Back up `.env` now.** It is the only copy of that key.
73
+ 5. Send **0.5 SOL** to the printed address, then restart the engine so it picks up the key:
74
+ ```bash
75
+ docker compose restart fly
76
+ ```
77
+ 6. Open the console: <http://localhost:8501>. Watch the **Overview** page.
78
+
79
+ That is all. From here it runs on its own. The first ~24 hours are a warm-up (it needs a day of candles before it
80
+ scores anything); then it trades paper until 20 trades have closed; then it trades the wallet.
81
+
82
+ ## Day to day
83
+
84
+ | Want to | Do |
85
+ |---|---|
86
+ | See what it is doing | <http://localhost:8501> — **Overview** (books, open positions, wealth), **Trades**, **Safety & wallet** (rails, wallet), **Processes** |
87
+ | Pause new entries | Safety & wallet → **Pause entries**, or `docker compose exec fly fly-trader pause-entries` |
88
+ | Clear a tripped kill switch | Safety & wallet → **Clear kill switch**, or `docker compose exec fly fly-trader reset-circuit --kill` (re-bases the peak) |
89
+ | Stop everything | `docker compose down` — **open positions stay open in the wallet**; start again and it sells them on schedule, or sell them yourself |
90
+ | See the logs | `docker compose logs -f fly` |
91
+ | Start again from nothing | `docker compose down -v` (deletes the database) and delete `data/` |
92
+
93
+ The console listens on `127.0.0.1` only. Do not expose it: it can start and stop the engine and clear the rails.
94
+
95
+ ## What is in the box
96
+
97
+ | Path | What |
98
+ |---|---|
99
+ | `models/policies/fly_*.pt` | the fly (`brain_snapshots` #62): taught by selector #59, two strategies (`ev`, `capitulation`), 120-minute hold, its own buy lines |
100
+ | `models/selectors/selector_*.joblib` | the selector that taught it (#59), kept so the console can show the paper selector alongside |
101
+ | `models/connectome/` | the FlyWire FAFB v783 central brain as the fly runs on it (14 MB), with its calibrated scale |
102
+ | `models/wallet_skill/` | the fitted wallet-skill inputs: `config.json` (the horizon and lookback the model was trained with) and the name of the newest per-day table. The table itself (~300 MB) is on Hugging Face; the container fetches it once at first start |
103
+ | `seed/seed.sql` | the database rows that admit the fly to trade: the model records, the passed replay verdict, autostart |
104
+ | `.env.example` | every knob, set for 0.5 SOL |
105
+ | `docker-compose.yml`, `Dockerfile`, `docker/entrypoint.sh` | one Postgres, one engine + console |
106
+
107
+ The fly's replay verdict shipped here: over the 28 evaluation days it made +1.52 % per trade after real costs (profit
108
+ factor 1.13) against −5.27 % for random picks of the same tokens. That is a backtest on one corpus, not a forecast.
109
+
110
+ ## Tuning
111
+
112
+ Edit `.env`, then `docker compose restart fly`. The ones that matter:
113
+
114
+ | Variable | Shipped | Meaning |
115
+ |---|---|---|
116
+ | `CAPITAL_SOL` | 0.5 | what you funded; also the paper warm-up bankroll |
117
+ | `MAX_POSITION_SOL` / `MAX_POSITION_FRACTION` | 0.05 / 0.10 | per-position caps (the smaller wins) |
118
+ | `GAS_RESERVE_SOL` | 0.05 | never spent |
119
+ | `KILL_SWITCH_DRAWDOWN` / `KILL_SWITCH_LIQUIDATE` | 0.20 / 1 | drawdown from peak that halts and sells everything |
120
+ | `HANDOVER_MIN_TRADES` | 20 | closed paper trades before it goes live |
121
+ | `LIVE_ENABLED` | 1 | set 0 to run paper only |
122
+
123
+ Scaling the wallet up scales the position caps with it (`MAX_POSITION_FRACTION`), but `MAX_POSITION_SOL` is a hard
124
+ ceiling: raise it deliberately or not at all.
125
+
126
+ ## Refreshing the model
127
+
128
+ The model is a snapshot of one training run. To ship a newer one, on a `master` checkout with its database, after a
129
+ passed replay:
130
+
131
+ ```bash
132
+ .venv/bin/python tools/make_seed.py --out dist
133
+ cp -R dist/models/* models/ && cp -R dist/seed/* seed/
134
+ ```
135
+
136
+ Commit `models/` and `seed/` on this branch. The seed is idempotent: an install that already has these rows is not
137
+ changed by it, so pull the branch, `docker compose up -d --build`, and the new model is in place.
138
+
139
+ ## Also on Hugging Face
140
+
141
+ - Model and this code: [`bryceweiner/fly-trader`](https://huggingface.co/bryceweiner/fly-trader)
142
+ - The training database (the full corpus, for retraining on `master`): [`bryceweiner/fly-trader-seed`](https://huggingface.co/datasets/bryceweiner/fly-trader-seed)
143
+
144
+ ## Credits
145
+
146
+ FlyWire consortium, Dorkenwald et al., *Neuronal wiring diagram of an adult brain*, Nature 2024; annotations Schlegel
147
+ et al. 2024; synapse signs Shiu et al. 2024. Connectome export: [snedea/flybrain](https://github.com/snedea/flybrain)
148
+ (MIT). Market data: [PumpAPI](https://pumpapi.io); routing: [Jupiter](https://jup.ag).
149
+
150
+ [MIT](LICENSE).
config/mbon_valence.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_citation": "Approach = MBON-11 (gamma1pedc>alpha/beta, GABA), 12, 13, 14, 15, 16, 18 (cholinergic) and avoidance = MBON-01..07 (all glutamatergic): Aso et al. 2014 eLife 3:e04580 (activation screen; 'all MBONs eliciting aversion were glutamatergic and all the MBONs eliciting attraction were either GABAergic or cholinergic'); NT per type from Aso et al. 2014 eLife 3:e04577 Table 1. MBON17 (alpha'3m) was tested jointly with MBON16 (alpha'3ap) in that screen. MBON19 and MBON23 are NOT in these lists: MBON19's NT is N.D. in Aso 2014 and MBON23 is a later hemibrain type (Li et al. 2020). MBON21, MBON29 (cholinergic, aversive) and MBON33 (attractive) per Rubin & Aso 2024 eLife 90523 (activation results read from partial text; verify). '-like' annotations are left unassigned (no valence evidence). PAM = reward, PPL1 = punishment: Aso et al. 2014 eLife 3:e04577 compartment map; Li et al. 2020 ('PPL1 DANs generally convey punishment, PAM DANs generally convey reward'). Learning = depression of opposite-valence KC->MBON synapses: Hige et al. 2015 Neuron, Owald et al. 2015 Neuron.",
3
+ "approach": [
4
+ "MBON11",
5
+ "MBON12",
6
+ "MBON13",
7
+ "MBON14",
8
+ "MBON15",
9
+ "MBON16",
10
+ "MBON17",
11
+ "MBON18",
12
+ "MBON33"
13
+ ],
14
+ "avoid": [
15
+ "MBON01",
16
+ "MBON02",
17
+ "MBON03",
18
+ "MBON04",
19
+ "MBON05",
20
+ "MBON06",
21
+ "MBON07",
22
+ "MBON21",
23
+ "MBON29"
24
+ ],
25
+ "dan_reward_prefix": "PAM",
26
+ "dan_punishment_prefix": "PPL1"
27
+ }
docker-compose.yml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fly-trader, fire-and-forget: one Postgres, one trading container. The console is published on 127.0.0.1 only --
2
+ # it can start and stop the workers and clear the kill switch, so it must never face the internet.
3
+ services:
4
+ db:
5
+ image: postgres:17
6
+ environment:
7
+ POSTGRES_USER: fly
8
+ POSTGRES_PASSWORD: fly
9
+ POSTGRES_DB: fly_trader
10
+ volumes:
11
+ - pgdata:/var/lib/postgresql/data
12
+ healthcheck:
13
+ test: ["CMD-SHELL", "pg_isready -U fly -d fly_trader"]
14
+ interval: 5s
15
+ timeout: 3s
16
+ retries: 30
17
+ restart: unless-stopped
18
+
19
+ fly:
20
+ build: .
21
+ env_file: .env
22
+ environment:
23
+ DATABASE_URL: postgresql://fly:fly@db:5432/fly_trader
24
+ DATA_DIR: /app/data
25
+ LOG_DIR: /app/logs
26
+ DEVICE: cpu
27
+ volumes:
28
+ - ./data:/app/data # models, the fly's plastic state, its hourly snapshots: survives restarts and rebuilds
29
+ - ./logs:/app/logs
30
+ - ./.env:/app/.env # `fly-trader wallet new` writes the bot key here
31
+ ports:
32
+ - "127.0.0.1:8501:8501"
33
+ depends_on:
34
+ db:
35
+ condition: service_healthy
36
+ restart: unless-stopped
37
+
38
+ volumes:
39
+ pgdata:
docker/entrypoint.sh ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # First boot: schema, the shipped models into the data volume, the seed rows that admit them. Every boot: the console,
3
+ # which autostarts the two trading workers (market feed + trading engine). Nothing here overwrites a running install.
4
+ set -e
5
+ cd /app
6
+
7
+ until pg_isready -d "$DATABASE_URL" >/dev/null 2>&1; do
8
+ echo "fly-trader: waiting for postgres"; sleep 2
9
+ done
10
+
11
+ fly-trader init-db
12
+
13
+ for sub in policies selectors connectome; do
14
+ mkdir -p "data/brain/$sub"
15
+ for f in models/$sub/*; do
16
+ [ -e "$f" ] || continue
17
+ [ -e "data/brain/$sub/$(basename "$f")" ] || cp "$f" "data/brain/$sub/"
18
+ done
19
+ done
20
+
21
+ # The wallet-skill inputs: the fitted config (in git) and the newest table (~300 MB: kept on Hugging Face, fetched once).
22
+ mkdir -p data/corpus/wallet_skill
23
+ for f in models/wallet_skill/*; do
24
+ [ -e "$f" ] || continue
25
+ [ -e "data/corpus/wallet_skill/$(basename "$f")" ] || cp "$f" data/corpus/wallet_skill/
26
+ done
27
+ T=$(cat models/wallet_skill/TABLE 2>/dev/null || true)
28
+ if [ -n "$T" ] && [ ! -e "data/corpus/wallet_skill/$T" ]; then
29
+ echo "fly-trader: fetching the wallet-skill table $T (~300 MB, once)"
30
+ curl -fsSL --retry 5 -o "data/corpus/wallet_skill/$T.part" "https://huggingface.co/bryceweiner/fly-trader/resolve/main/models/wallet_skill/$T" \
31
+ && mv "data/corpus/wallet_skill/$T.part" "data/corpus/wallet_skill/$T" \
32
+ || echo "fly-trader: WARNING could not fetch the wallet-skill table; the paper selector will not trade and the fly's skill inputs read as zero until it is present"
33
+ fi
34
+
35
+ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f seed/seed.sql
36
+
37
+ echo "fly-trader: console at http://localhost:8501"
38
+ exec fly-trader ui --port "${PORT:-8501}"
fly_trader/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """fly-trader: a FlyWire-connectome memecoin trader on Solana."""
2
+
3
+ __version__ = "0.1.0"
fly_trader/__main__.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fly-trader command line. Every command is a thin wrapper over library functions that the
2
+ Streamlit console also calls. Always run as ``.venv/bin/python -m fly_trader <command>``."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+
10
+ def _cmd_init_db(args):
11
+ from .db import schema
12
+ from .db.connection import database_name
13
+ created = schema.ensure_database()
14
+ version = schema.apply_schema()
15
+ tables = schema.table_names()
16
+ print(f"database={database_name()} created={created} schema_version={version} tables={len(tables)}")
17
+ for t in tables:
18
+ print(f" {t}")
19
+
20
+
21
+ def _cmd_wallet(args):
22
+ from .chain import keys
23
+ if args.action == "new":
24
+ print(keys.create_wallet())
25
+ else:
26
+ print(keys.show_wallet())
27
+
28
+
29
+ def _cmd_discover(args):
30
+ from .ingest import discovery
31
+ if args.once:
32
+ discovery.run_once()
33
+ else:
34
+ discovery.run_forever()
35
+
36
+
37
+ def _cmd_build_connectome(args):
38
+ from .brain import connectome_build
39
+ connectome_build.main(annotations=args.annotations)
40
+
41
+
42
+ def _cmd_run(args):
43
+ from .agent import runner
44
+ runner.main()
45
+
46
+
47
+ def _cmd_swap_smoke(args):
48
+ from .execution import broker_live
49
+ broker_live.swap_smoke(args.sol)
50
+
51
+
52
+ def _cmd_status(args):
53
+ from .db import queries
54
+ queries.print_status()
55
+
56
+
57
+ def _cmd_reset_circuit(args):
58
+ from .agent import rails
59
+ rails.reset_circuit(kill=args.kill)
60
+
61
+
62
+ def _cmd_pause(args):
63
+ from .agent import rails
64
+ rails.set_entries_paused(True)
65
+
66
+
67
+ def _cmd_resume(args):
68
+ from .agent import rails
69
+ rails.set_entries_paused(False)
70
+
71
+
72
+ def _cmd_reset_training(args):
73
+ from .ops.reset import reset_training_state
74
+ import json
75
+ print(json.dumps(reset_training_state(reason="cli", archive=not args.no_archive), indent=1))
76
+
77
+
78
+ def _cmd_ui(args):
79
+ from .ops.supervisor import run_console
80
+ run_console(port=args.port)
81
+
82
+
83
+ def _cmd_verify_fills(args):
84
+ from .execution import ledger
85
+ ledger.verify_fills()
86
+
87
+
88
+ def _cmd_close_atas(args):
89
+ from .execution import broker_live
90
+ broker_live.close_empty_atas()
91
+
92
+
93
+ def _cmd_replay_pull(args):
94
+ from .ingest import replay_pull
95
+ replay_pull.main()
96
+
97
+
98
+ def _cmd_pumpstream(args):
99
+ from .ingest import pumpstream
100
+ pumpstream.main()
101
+
102
+
103
+ def _cmd_build_corpus_features(args):
104
+ from .train import corpus_features
105
+ corpus_features.main()
106
+
107
+
108
+ def _cmd_refetch_pool_ids(args):
109
+ from .ingest import replay_pull
110
+ print(f"{replay_pull.refetch_missing_pool_id()} archive hours marked for re-download (run the replay worker or fly-trader replay-pull)")
111
+
112
+
113
+ def _cmd_fit_wallet_skill(args):
114
+ from .logging_setup import setup
115
+ from .train import wallet_skill
116
+ setup("train")
117
+ out = wallet_skill.fit(days=args.days)
118
+ print(json.dumps({k: out.get(k) for k in ("h", "L", "selection", "evaluation", "baseline", "beats_baseline", "candidates", "stopped")}, indent=1, default=str))
119
+
120
+
121
+ def _cmd_train_selector(args):
122
+ from .logging_setup import setup
123
+ from .train import selector
124
+ setup("train")
125
+ selector.main(days=args.days)
126
+
127
+
128
+ def _cmd_train_fly_selector(args):
129
+ from .logging_setup import setup
130
+ from .train import fly_selector
131
+ setup("train")
132
+ fly_selector.main(days=args.days, epochs=args.epochs)
133
+
134
+
135
+ def _cmd_fly_replay(args):
136
+ from .logging_setup import setup
137
+ from .train import fly_replay
138
+ setup("train")
139
+ out = fly_replay.main(days=args.days, start_day=args.start_day if args.start_day is not None else fly_replay.DEFAULT_START)
140
+ print(json.dumps({k: out.get(k) for k in ("S", "passed", "reason", "alpha", "half_life_days", "evaluation", "random", "frozen", "plastic_beats_frozen")}, indent=1, default=str))
141
+
142
+
143
+ def _cmd_selector_eval(args):
144
+ import runpy
145
+ runpy.run_module("fly_trader.train.selector_eval", run_name="__main__")
146
+
147
+
148
+ def _cmd_build_mature(args):
149
+ from .train import mature
150
+ mature.main()
151
+
152
+
153
+ def _cmd_build_corpus_meta(args):
154
+ from .train import corpus_meta
155
+ corpus_meta.main()
156
+
157
+
158
+ def _cmd_assemble_replay(args):
159
+ from .train import replay_assemble
160
+ replay_assemble.main()
161
+
162
+
163
+ def _cmd_backtest_corpus(args):
164
+ from .train import corpus_backtest
165
+ argv = ["--fees", args.fees]
166
+ if args.max_tokens: argv += ["--max-tokens", str(args.max_tokens)]
167
+ if args.days: argv += ["--days", str(args.days)]
168
+ if args.only: argv += ["--only", args.only]
169
+ argv += ["--universe", args.universe]
170
+ corpus_backtest.main(argv)
171
+
172
+
173
+ def _cmd_worker(args):
174
+ from .ops import procs
175
+ procs.worker_cli(args.action, args.name)
176
+
177
+
178
+ def build_parser() -> argparse.ArgumentParser:
179
+ p = argparse.ArgumentParser(prog="fly-trader")
180
+ sub = p.add_subparsers(dest="command", required=True)
181
+ sub.add_parser("init-db").set_defaults(fn=_cmd_init_db)
182
+ w = sub.add_parser("wallet"); w.add_argument("action", choices=["new", "show"]); w.set_defaults(fn=_cmd_wallet)
183
+ d = sub.add_parser("discover"); d.add_argument("--once", action="store_true"); d.set_defaults(fn=_cmd_discover)
184
+ sub.add_parser("replay-pull").set_defaults(fn=_cmd_replay_pull)
185
+ sub.add_parser("refetch-pool-ids", help="mark archive hours without pool_id for re-download").set_defaults(fn=_cmd_refetch_pool_ids)
186
+ 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)")
187
+ 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)
188
+ sub.add_parser("pumpstream").set_defaults(fn=_cmd_pumpstream)
189
+ sub.add_parser("build-corpus-features").set_defaults(fn=_cmd_build_corpus_features)
190
+ sub.add_parser("assemble-replay").set_defaults(fn=_cmd_assemble_replay)
191
+ sub.add_parser("build-corpus-meta").set_defaults(fn=_cmd_build_corpus_meta)
192
+ sub.add_parser("build-mature").set_defaults(fn=_cmd_build_mature)
193
+ sub.add_parser("selector-eval").set_defaults(fn=_cmd_selector_eval)
194
+ ts_ = sub.add_parser("train-selector"); ts_.add_argument("--days", type=int, default=None, help="most recent days only (default: the whole corpus)")
195
+ ts_.set_defaults(fn=_cmd_train_selector)
196
+ tf_ = sub.add_parser("train-fly-selector"); tf_.add_argument("--days", type=int, default=None, help="most recent days only (default: the whole corpus)")
197
+ tf_.add_argument("--epochs", type=int, default=None, help="cap on full passes over the distillation rows (default: until the fit stops improving)")
198
+ tf_.set_defaults(fn=_cmd_train_fly_selector)
199
+ fr = sub.add_parser("fly-replay", help="bootstrap the fly once, let it learn over the corpus, judge it (the gate before it trades)")
200
+ fr.add_argument("--days", type=int, default=None, help="most recent days only (default: the whole corpus)")
201
+ fr.add_argument("--start-day", type=int, default=None,
202
+ help="day index of the bootstrap (the selector teaches on the days before it); default: train/fly_replay.DEFAULT_START, "
203
+ "the earliest day that leaves the teacher's walk-forward enough days in each half")
204
+ fr.set_defaults(fn=_cmd_fly_replay)
205
+ bc = sub.add_parser("backtest-corpus"); bc.add_argument("--max-tokens", type=int, default=None); bc.add_argument("--days", type=int, default=None)
206
+ bc.add_argument("--fees", default="0.003,0.0055"); bc.add_argument("--only", default=None); bc.add_argument("--universe", default="graduation", choices=["graduation", "mature"]); bc.set_defaults(fn=_cmd_backtest_corpus)
207
+ b = sub.add_parser("build-connectome"); b.add_argument("--annotations", default=None); b.set_defaults(fn=_cmd_build_connectome)
208
+ sub.add_parser("run").set_defaults(fn=_cmd_run)
209
+ s = sub.add_parser("swap-smoke"); s.add_argument("--sol", type=float, default=0.01); s.set_defaults(fn=_cmd_swap_smoke)
210
+ sub.add_parser("status").set_defaults(fn=_cmd_status)
211
+ r = sub.add_parser("reset-circuit"); r.add_argument("--kill", action="store_true"); r.set_defaults(fn=_cmd_reset_circuit)
212
+ sub.add_parser("pause-entries").set_defaults(fn=_cmd_pause)
213
+ sub.add_parser("resume-entries").set_defaults(fn=_cmd_resume)
214
+ u = sub.add_parser("ui"); u.add_argument("--port", type=int, default=8501); u.set_defaults(fn=_cmd_ui)
215
+ rt = sub.add_parser("reset-training"); rt.add_argument("--no-archive", action="store_true"); rt.set_defaults(fn=_cmd_reset_training)
216
+ sub.add_parser("verify-fills").set_defaults(fn=_cmd_verify_fills)
217
+ sub.add_parser("close-empty-atas").set_defaults(fn=_cmd_close_atas)
218
+ wk = sub.add_parser("worker"); wk.add_argument("action", choices=["start", "stop", "status"]); wk.add_argument("name", nargs="?"); wk.set_defaults(fn=_cmd_worker)
219
+ return p
220
+
221
+
222
+ def main(argv=None) -> int:
223
+ args = build_parser().parse_args(argv)
224
+ rc = args.fn(args)
225
+ return rc if isinstance(rc, int) else 0
226
+
227
+
228
+ if __name__ == "__main__":
229
+ sys.exit(main())
fly_trader/agent/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
fly_trader/agent/fly_live.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live execution for the fly once it holds the selector's seat: its decisions also trade the bot wallet (book 'live').
2
+
3
+ The paper book ``paper_fly`` keeps running as the mirror. Each trade minute — only after the handover and only while
4
+ signing is allowed (LIVE_ENABLED=1 and the live prerequisites, chain/cluster_guard.py) — ``LiveMirror.minute``:
5
+ - drains the execution worker (execution/worker.py books live positions from balance-verified fills);
6
+ - exits: live positions held the hold are sold (exit slippage, the forced ladder once ``OVERDUE_S`` late), retried every
7
+ minute; ``DEAD_BAG_S`` past the hold a position that could not be sold is written off (dead-bag: closed at 0, so it
8
+ leaves wealth) and its tokens are swept every ``SWEEP_S``;
9
+ - entries: every paper entry of this minute is mirrored with the same score, line and certainty bands, sized from the
10
+ wallet (SOL balance + open live positions at cost as the bankroll, the SOL balance as cash; the gas reserve is never
11
+ spent — agent/sizing.py), one position per token, unless the kill switch, the circuit breaker or paused entries block;
12
+ - wealth: wallet SOL + open positions net of exit cost → ``wealth_marks`` (book 'live'); the kill switch on its own peak,
13
+ and with ``KILL_SWITCH_LIQUIDATE`` every open position is sold at the forced slippage while it stays on;
14
+ - gap: over the last ``GAP_TRADES`` closed live trades, the mean return per trade trailing the paper mirror's same
15
+ decisions by more than ``GAP_MAX`` pauses entries and raises an alert. Learning is unaffected: it learns from market
16
+ labels, not fills.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ from datetime import datetime, timedelta, timezone
23
+
24
+ import numpy as np
25
+
26
+ from .. import config
27
+ from ..chain.balances import snapshot_balances
28
+ from ..db.apilog import record_event
29
+ from ..execution import ledger
30
+ from ..execution.worker import ExecRequest
31
+ from ..market.exit_cost import exit_cost_fraction
32
+ from . import rails, sizing
33
+
34
+ log = logging.getLogger(__name__)
35
+ BOOK, MIRROR = "live", "paper_fly"
36
+ DEAD_BAG_S, OVERDUE_S, SWEEP_S = 3 * 3600.0, 600.0, 3600.0
37
+ GAP_TRADES, GAP_MAX = 50, 0.02
38
+
39
+
40
+ class LiveMirror:
41
+ def __init__(self, horizon_s: float, broker=None, worker=None):
42
+ if broker is None:
43
+ from ..execution.broker_live import LiveBroker
44
+ broker = LiveBroker()
45
+ if worker is None:
46
+ from ..execution.worker import ExecutionWorker
47
+ worker = ExecutionWorker(broker)
48
+ self.broker, self.worker, self.H = broker, worker, float(horizon_s)
49
+ self.last_sweep = 0.0
50
+
51
+ def _decision(self, conn, ctx, run_id, beat_id, p, reason: str, forced: bool) -> int:
52
+ 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",
53
+ (beat_id, run_id, ctx.m1, p["mint"], p["pool"], float(p["cost_sol"]), forced, reason, json.dumps({"book": BOOK}))).fetchone()["id"])
54
+
55
+ def minute(self, ctx, *, run_id: str, beat_id: int, entries: list, line: float, table: list | None) -> dict:
56
+ conn, m1 = ctx.conn, ctx.m1
57
+ drained = self.worker.drain()
58
+ for r in drained:
59
+ if not r.ok:
60
+ log.warning("live %s %s failed: %s", r.request.side, r.request.mint, r.error)
61
+ inflight = self.worker.pending()
62
+ snap = snapshot_balances(self.broker.rpc, self.broker.pubkey); sol_free = snap.lamports / config.LAMPORTS_PER_SOL
63
+ n_exit = n_dead = 0
64
+ for p in ledger.open_positions(conn, BOOK):
65
+ held = (m1 - p["opened_at"]).total_seconds(); H = float(p.get("hold_s") or self.H) # each position's own hold
66
+ if held < H or p["mint"] in inflight:
67
+ continue
68
+ if held >= H + DEAD_BAG_S:
69
+ did = self._decision(conn, ctx, run_id, beat_id, p, f"dead-bag: unsold {held / 3600:.1f} h after entry", True)
70
+ ledger.close_position(conn, position_id=int(p["id"]), exit_price=0.0, proceeds_sol=0.0, fees_sol=0.0, decision_id=did, forced_kind="dead_bag", ts=m1)
71
+ record_event("warning", "fly_live", f"dead-bag: {p['mint']} could not be sold; written off, its tokens are swept hourly", {"position": int(p["id"]), "cost_sol": float(p["cost_sol"])})
72
+ n_dead += 1
73
+ continue
74
+ overdue = held >= H + OVERDUE_S
75
+ did = self._decision(conn, ctx, run_id, beat_id, p, f"held {held / 60:.0f} min" + (" (retrying)" if overdue else ""), False)
76
+ n_exit += bool(self.worker.submit(ExecRequest(decision_id=did, mint=p["mint"], pool=p["pool"], side="sell", amount_in=int(p["qty"]),
77
+ slippage_bps=config.SLIPPAGE_FORCED_BPS if overdue else config.SLIPPAGE_EXIT_BPS,
78
+ max_slippage_bps=config.SLIPPAGE_FORCED_BPS, decimals=int(p.get("decimals") or 6), position_id=int(p["id"]))))
79
+ opens = ledger.open_positions(conn, BOOK); held_mints = {p["mint"] for p in opens} | set(inflight)
80
+ c = rails.load_circuit(conn)
81
+ blocked = "kill switch" if c.kill_switch else "circuit tripped" if c.tripped else "paused" if c.entries_paused else None
82
+ bankroll = sol_free + sum(float(p["cost_sol"]) for p in opens); cash = sol_free; n_enter = 0; skipped = []
83
+ for e in entries:
84
+ if e["mint"] in held_mints:
85
+ skipped.append((e["mint"], "held")); continue
86
+ size, why = sizing.size_position(e["score"], e.get("threshold", line), e.get("table") or table or [], bankroll, cash, e["info"]["resq"])
87
+ if blocked or size <= 0:
88
+ skipped.append((e["mint"], blocked or why)); continue
89
+ if self.worker.submit(ExecRequest(decision_id=e["decision_id"], mint=e["mint"], pool=e["info"]["pool"], side="buy", amount_in=int(size * config.LAMPORTS_PER_SOL),
90
+ slippage_bps=config.SLIPPAGE_ENTRY_BPS, max_slippage_bps=config.MAX_SLIPPAGE_ENTRY_BPS, decimals=int(e["info"]["decimals"] or 6), size_sol=size)):
91
+ n_enter += 1; cash -= size; held_mints.add(e["mint"])
92
+ conn.execute("UPDATE decisions SET book_targets = array_append(COALESCE(book_targets, '{}'), 'live') WHERE id = %s", (e["decision_id"],))
93
+ ledger.mark_positions(conn, BOOK, ctx.prices, {}, m1)
94
+ gross_v = exit_cost = exposure = 0.0
95
+ for p in ledger.open_positions(conn, BOOK):
96
+ px = float(p.get("last_mark_price") or p["entry_price"] or 0.0)
97
+ gross = int(p["qty"]) / 10 ** int(p.get("decimals") or 6) * px
98
+ gross_v += gross; exposure += float(p["cost_sol"])
99
+ exit_cost += gross * exit_cost_fraction(gross, ctx.resqs.get(p["mint"]) or ctx.last_resq(p["mint"]), ctx.mcap(p["mint"], px), p.get("program_label"), ctx.fees.get(p["mint"]))
100
+ wealth = sol_free + gross_v - exit_cost
101
+ pk = conn.execute("SELECT max(wealth) AS pk FROM wealth_marks WHERE book = %s", (BOOK,)).fetchone(); peak = max(float(pk["pk"] or 0.0), wealth)
102
+ conn.execute("INSERT INTO wealth_marks (beat_id, book, ts, sol_free, positions_value, exit_cost, wealth, peak, drawdown, exposure, n_open) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
103
+ "ON CONFLICT (beat_id, book) DO NOTHING",
104
+ (beat_id, BOOK, m1, sol_free, gross_v - exit_cost, exit_cost, wealth, peak, (1.0 - wealth / peak) if peak > 0 else 0.0, exposure, len(opens)))
105
+ killed = rails.check_drawdown(conn, wealth, peak)
106
+ n_liq = self.liquidate(conn, ctx, run_id, beat_id) if killed and config.KILL_SWITCH_LIQUIDATE else 0
107
+ gap = self.gap(conn)
108
+ if ctx.m1_epoch - self.last_sweep >= SWEEP_S:
109
+ self.last_sweep = ctx.m1_epoch; self.sweep(conn, snap, inflight, m1)
110
+ return {"stage": "trading live", "wallet_sol": sol_free, "wealth": wealth, "entered": n_enter, "exited": n_exit, "dead_bags": n_dead, "in_flight": len(inflight),
111
+ "blocked": blocked, "skipped": skipped[:10], "gap": gap, "failed_orders": sum(1 for r in drained if not r.ok), "liquidated": n_liq}
112
+
113
+ def liquidate(self, conn, ctx, run_id: str, beat_id: int) -> int:
114
+ """Sell every open live position at the forced slippage while the kill switch is on (``KILL_SWITCH_LIQUIDATE``):
115
+ a book that has lost ``KILL_SWITCH_DRAWDOWN`` of its peak stops holding, not only entering. Called every trade
116
+ minute the switch stays on, so a sell that fails is retried until the book is flat; positions already in flight
117
+ (this minute's scheduled exits included) are left to the worker."""
118
+ n = 0; inflight = self.worker.pending()
119
+ for p in ledger.open_positions(conn, BOOK):
120
+ if p["mint"] in inflight:
121
+ continue
122
+ did = self._decision(conn, ctx, run_id, beat_id, p, "kill switch: liquidating", True)
123
+ n += bool(self.worker.submit(ExecRequest(decision_id=did, mint=p["mint"], pool=p["pool"], side="sell", amount_in=int(p["qty"]),
124
+ slippage_bps=config.SLIPPAGE_FORCED_BPS, max_slippage_bps=config.SLIPPAGE_FORCED_BPS,
125
+ decimals=int(p.get("decimals") or 6), position_id=int(p["id"]))))
126
+ if n:
127
+ record_event("error", "fly_live", f"kill switch: liquidating {n} open live position(s)", {"positions": n})
128
+ return n
129
+
130
+ def gap(self, conn) -> dict:
131
+ """Live vs its paper mirror on the same decisions; pauses entries when live trails by more than ``GAP_MAX`` per trade."""
132
+ 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
133
+ FROM positions l JOIN positions p ON p.entry_decision_id = l.entry_decision_id AND p.book = '{MIRROR}' AND p.status = 'closed'
134
+ WHERE l.book = '{BOOK}' AND l.status = 'closed' AND l.entry_decision_id IS NOT NULL ORDER BY l.closed_at DESC LIMIT %s""", (GAP_TRADES,)).fetchall()
135
+ rl = np.array([r["rl"] for r in rows if r["rl"] is not None and r["rp"] is not None], dtype=np.float64)
136
+ rp = np.array([r["rp"] for r in rows if r["rl"] is not None and r["rp"] is not None], dtype=np.float64)
137
+ 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}
138
+ if len(rl) >= GAP_TRADES and rl.mean() - rp.mean() < -GAP_MAX:
139
+ c = rails.load_circuit(conn)
140
+ if not c.entries_paused:
141
+ conn.execute("UPDATE circuit_state SET entries_paused = true, updated_at = now() WHERE id = 1")
142
+ conn.execute("INSERT INTO circuit_events (kind, detail) VALUES ('gap_pause', %s)", (json.dumps(out),))
143
+ record_event("error", "fly_live", f"entries paused: live trails its paper mirror by {(rp.mean() - rl.mean()) * 100:.1f} points per trade over {len(rl)} trades", out)
144
+ out["paused"] = True
145
+ return out
146
+
147
+ def sweep(self, conn, snap, inflight: set, m1: datetime) -> int:
148
+ """Sell whatever is left of the last week's dead-bag tokens (forced slippage; proceeds reach the wallet)."""
149
+ n = 0
150
+ for r in conn.execute("SELECT DISTINCT mint FROM positions WHERE book = %s AND forced_exit_kind = 'dead_bag' AND closed_at > %s",
151
+ (BOOK, m1 - timedelta(days=7))).fetchall():
152
+ amt = snap.token(r["mint"])
153
+ if amt > 0 and r["mint"] not in inflight:
154
+ n += bool(self.worker.submit(ExecRequest(decision_id=None, mint=r["mint"], pool=None, side="sell", amount_in=amt, slippage_bps=config.SLIPPAGE_FORCED_BPS,
155
+ max_slippage_bps=config.SLIPPAGE_FORCED_BPS, decimals=int(snap.decimals.get(r["mint"], 6)), forced_kind="dead_bag_sweep")))
156
+ return n
157
+
158
+ def stop(self) -> None:
159
+ self.worker.stop()
fly_trader/agent/fly_session.py ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The plastic fly: trading its own book (``paper_fly``) on the minute engine and learning from the market every minute.
2
+
3
+ It starts once the fly design passed its replay (``train/fly_replay.verdict``, current definitions) and a deployable
4
+ bootstrap exists (``train/fly_selector.latest_deployable``); the replay's chosen configuration (α, half-life) is the
5
+ learning rate and forgetting of its mushroom body (``brain/plastic.py``). Each minute (agent/minute_engine.py):
6
+
7
+ 1. bars: minute start, open, close and exit cost of every traded mint with a pending tag feed the label resolver;
8
+ 2. labels: tags whose exit minute has now been seen are resolved exactly as ``train/decisions.build`` labels a row
9
+ (``resolve_label``) and teach the mushroom body; a label that cannot be resolved is dropped;
10
+ 3. calibration: at the first minute of each UTC day, the line and sizing of the plastic fly and of its frozen shadow
11
+ (the bootstrap, D = 0) from their scores of the minutes resolved in the last seven days (``train/fly_calibrate.py``);
12
+ 4. scoring: eligible minutes are scored by the plastic fly and its shadow, stored (``fly_scored``: pending rows keep the
13
+ feature vector, so tags survive restarts) and tagged; only minutes at or above the line teach it (brain/plastic.py);
14
+ 5. trading (fresh stream, trade minutes): the race rules (``agent/paper_trading.py``) at the plastic line and sizing.
15
+
16
+ Hourly: learning statistics (``fly_updates``), a snapshot of the plastic state (kind 'fly_plastic'; hourly for 7 days,
17
+ then one per day), the rollback checks (``train/fly_governance.py``; a rollback restores the newest good snapshot at
18
+ least a day old, or D = 0, pauses the checks a day, and the third within 7 days freezes learning and requests a
19
+ re-bootstrap), and the handover (``HANDOVER_DAYS`` of racing, at least ``HANDOVER_MIN_TRADES`` trades, realized P&L
20
+ at least the selector's). The state file ``data/brain/plastic/state.pt`` is written atomically every minute, before
21
+ tags are marked resolved: on restart, pending tags at or before its watermark count as applied.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import math
28
+ import os
29
+ import time
30
+ import uuid
31
+ from collections import deque
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ import numpy as np
36
+ import torch
37
+
38
+ from .. import config
39
+ from ..brain import plastic
40
+ from ..db.apilog import record_event
41
+ from ..db.connection import transaction
42
+ from ..execution import ledger
43
+ from ..execution.broker_paper import PaperBroker
44
+ from ..ops.reset import fly_state_dir
45
+ from ..train import fly_calibrate, fly_governance as gov, fly_selector
46
+ from ..train.decisions import X_COLS
47
+ from . import handover, paper_trading
48
+
49
+ log = logging.getLogger(__name__)
50
+ BOOK, KIND = "paper_fly", "fly"
51
+ BAR_KEEP = 270 # minutes of bars kept per watched mint (the longest strategy hold, 240, + next-open window + margin)
52
+ NEXT_OPEN_S = 120.0 # entry at the next traded minute's open when it comes within two minutes (decisions.build)
53
+ JUMP = 50.0
54
+ LABEL_LAG_S = 60.0
55
+ ROLLBACK_PAUSE_S, ROLLBACK_LIMIT, ROLLBACK_WINDOW_S, ROLLBACK_MIN_AGE_S = 86400.0, 3, 7 * 86400.0, 86400.0
56
+ SNAP_HOURLY_DAYS, SCORED_KEEP_DAYS = 7, 35
57
+ COMMAND_KEY = "fly_command"
58
+
59
+
60
+ def send_command(cmd: str) -> None:
61
+ """Queue an operator command for the running fly (applied at its next minute): 'pause', 'resume' or 'rollback'."""
62
+ assert cmd in ("pause", "resume", "rollback"), cmd
63
+ with transaction() as conn:
64
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
65
+ (COMMAND_KEY, json.dumps({"cmd": cmd, "at": datetime.now(timezone.utc).isoformat()})))
66
+
67
+
68
+ def resolve_label(bars: list, t: float, horizon_s: float) -> float | None:
69
+ """Net return of a row scored at minute start ``t`` from the mint's bars [(minute start, open, close, exit cost)]
70
+ in time order — ``decisions.build``'s ``fwd_pess``: entry at the next traded minute's open when it comes within
71
+ ``NEXT_OPEN_S`` (else the close), exit at the last close at or before ``t + horizon_s``, the one-side exit cost at
72
+ both ends. None when the row's own bar is missing, a price is not positive, or the exit minute jumped 50× from the
73
+ one before (the archive drops such exits)."""
74
+ rows = [b for b in bars if b[0] >= t]
75
+ if not rows or rows[0][0] != t:
76
+ return None
77
+ j = 0
78
+ for i, b in enumerate(rows):
79
+ if b[0] <= t + horizon_s:
80
+ j = i
81
+ else:
82
+ break
83
+ entry = rows[1][1] if len(rows) > 1 and rows[1][0] - t <= NEXT_OPEN_S and rows[1][1] else rows[0][2]
84
+ exit_px = rows[j][2]
85
+ if not entry or entry <= 0 or not exit_px or exit_px <= 0:
86
+ return None
87
+ if j > 0 and rows[j - 1][2] and not (1 / JUMP <= exit_px / rows[j - 1][2] <= JUMP):
88
+ return None
89
+ return float(exit_px / entry * (1 - rows[0][3]) * (1 - rows[j][3]) - 1)
90
+
91
+
92
+ def try_start(live: bool = False) -> tuple["FlyBook | None", str]:
93
+ """A FlyBook when the fly may trade, else (None, why)."""
94
+ from ..train import fly_replay
95
+ v = fly_replay.verdict()
96
+ if not v:
97
+ return None, "the fly's replay has not run on the current definitions (fly-trader fly-replay)"
98
+ if not v.get("passed"):
99
+ return None, f"the fly's replay did not pass: {v.get('reason')}"
100
+ with transaction() as conn:
101
+ boot = fly_selector.latest_deployable(conn)
102
+ if boot is None:
103
+ return None, "no deployable fly bootstrap (the training pipeline bootstraps one)"
104
+ try:
105
+ return FlyBook(boot, v, live=live), "ok"
106
+ except Exception as e:
107
+ log.exception("fly book failed to start")
108
+ return None, f"fly book failed to start: {type(e).__name__}: {e}"
109
+
110
+
111
+ class FlyBook:
112
+ name = "fly"
113
+
114
+ def __init__(self, boot: dict, verdict: dict, live: bool = False):
115
+ self.live = live; self.done = False; self.mirror = None
116
+ self.verdict = verdict
117
+ self.state_path = fly_state_dir() / "state.pt"
118
+ self._load_bootstrap(boot)
119
+ self.run_id = str(uuid.uuid4()); self.beat_no = 0; self.broker = PaperBroker(BOOK)
120
+ with transaction() as conn:
121
+ conn.execute("INSERT INTO runs (run_id, kind, config, brain_snapshot_id, status) VALUES (%s,%s,%s,%s,'running')",
122
+ (self.run_id, KIND, json.dumps({"channels": self.cfg, "book": BOOK}, default=str), self.boot_id))
123
+ self._restore_tags(conn)
124
+ record_event("info", "fly", "fly session started", {"run_id": self.run_id, "bootstrap": self.boot_id, "channels": self.cfg, "lines": self.lines["plastic"],
125
+ "pending": len(self.pending), "learning_frozen": self.learning_frozen})
126
+ log.info("fly session: bootstrap %d, channels %s, lines %s, %d pending tags", self.boot_id, self.cfg, self.lines["plastic"], len(self.pending))
127
+
128
+ # ---- model and state ----
129
+ def _load_bootstrap(self, boot: dict) -> None:
130
+ self.boot_id = int(boot["id"])
131
+ self.fly = fly_selector.load(boot["path"])
132
+ self.idx = np.asarray([X_COLS.index(c) for c in self.fly.cols], dtype=int)
133
+ self.names = list(self.fly.strategies); self.holds = {k: float(self.fly.rules[k]["hold_min"]) * 60.0 for k in self.names}
134
+ self.H = max(self.holds.values())
135
+ per = self.verdict.get("per_strategy") or {self.names[0]: {"alpha": self.verdict.get("alpha", 0.0), "half_life_days": self.verdict.get("half_life_days")}}
136
+ self.cfg = {k: (float((per.get(k) or {}).get("alpha") or 0.0), (per.get(k) or {}).get("half_life_days")) for k in self.names}
137
+ self.alpha = self.cfg[self.names[0]][0]; self.half_life = float(self.cfg[self.names[0]][1]) if self.cfg[self.names[0]][1] is not None else math.inf
138
+ net = self.fly.net
139
+ self.bank = plastic.PlasticBank(net, [(self.alpha, self.half_life)], fly_selector.SCALE, learn=net.learn.cpu().numpy(), read=net.read.cpu().numpy())
140
+ for j, k in enumerate(self.names): # each strategy's channel learns and forgets at its own replay-chosen rate
141
+ a, h = self.cfg[k]; cols = self.bank.learn[j]
142
+ self.bank.alpha[0, cols] = a; self.bank.half_life_s[0, cols] = float(h) * 86400.0 if h is not None else float("inf")
143
+ self.pending = plastic.PendingTags(net.n_kc, net.k_active)
144
+ self.bars: dict[str, deque] = {}; self.watch: dict[str, int] = {}
145
+ self.lines = {"plastic": dict(self.fly.lines), "frozen": dict(self.fly.lines)}
146
+ self.sizing = {"plastic": {k: list(v) for k, v in self.fly.sizings.items()}, "frozen": {k: list(v) for k, v in self.fly.sizings.items()}}
147
+ self.applied_through = -math.inf; self.learning_frozen = False; self.checks_paused_until = {k: 0.0 for k in self.names}
148
+ self.race_started_at: float | None = None; self.last_day: int | None = None; self.last_hour: int | None = None
149
+ self.rollbacks: dict = {k: [] for k in self.names}; self.nu_set = False; self.hour_stats = self._empty_stats(); self.last_checks: dict = {}
150
+ s = self._read_state()
151
+ if s and s.get("bootstrap_id") == self.boot_id and s.get("cfg") == self.cfg:
152
+ self.bank.load_state(s["bank"]); self.nu_set = True
153
+ self.lines, self.sizing = s["lines"], s["sizing"]
154
+ self.applied_through, self.learning_frozen, self.checks_paused_until = s["applied_through"], s["learning_frozen"], s["checks_paused_until"]
155
+ self.race_started_at, self.last_day, self.last_hour, self.rollbacks = s["race_started_at"], s["last_day"], s["last_hour"], s["rollbacks"]
156
+ elif s:
157
+ self.race_started_at = s.get("race_started_at") # a new bootstrap keeps racing the same book
158
+ with transaction() as conn:
159
+ conn.execute("UPDATE fly_scored SET state = 'dropped', x = NULL WHERE state = 'pending' AND bootstrap_id IS DISTINCT FROM %s", (self.boot_id,))
160
+
161
+ def _read_state(self) -> dict | None:
162
+ if not self.state_path.exists():
163
+ return None
164
+ try:
165
+ return torch.load(self.state_path, map_location="cpu", weights_only=False)
166
+ except Exception:
167
+ log.exception("fly state unreadable; starting from the bootstrap")
168
+ return None
169
+
170
+ def _state(self) -> dict:
171
+ return {"bootstrap_id": self.boot_id, "cfg": self.cfg, "bank": self.bank.state(), "lines": self.lines, "sizing": self.sizing,
172
+ "applied_through": self.applied_through, "learning_frozen": self.learning_frozen, "checks_paused_until": self.checks_paused_until,
173
+ "race_started_at": self.race_started_at, "last_day": self.last_day, "last_hour": self.last_hour, "rollbacks": self.rollbacks,
174
+ "saved_at": time.time()}
175
+
176
+ def _write_state(self) -> None:
177
+ self.state_path.parent.mkdir(parents=True, exist_ok=True)
178
+ tmp = self.state_path.with_suffix(".tmp")
179
+ torch.save(self._state(), tmp); os.replace(tmp, self.state_path)
180
+
181
+ def _restore_tags(self, conn) -> None:
182
+ rows = conn.execute("SELECT ts, mint, strategy, x, score, line FROM fly_scored WHERE state = 'pending' AND bootstrap_id = %s ORDER BY ts", (self.boot_id,)).fetchall()
183
+ rows = [r for r in rows if r["strategy"] in self.holds]
184
+ applied = [(r["ts"], r["mint"], r["strategy"]) for r in rows if r["ts"].timestamp() + self.holds[r["strategy"]] + LABEL_LAG_S <= self.applied_through]
185
+ if applied:
186
+ with conn.cursor() as cur:
187
+ cur.executemany("UPDATE fly_scored SET state = 'resolved', resolved_at = now(), x = NULL WHERE ts = %s AND mint = %s AND strategy = %s", applied)
188
+ todo = [r for r in rows if r["ts"].timestamp() + self.holds[r["strategy"]] + LABEL_LAG_S > self.applied_through and r["x"] is not None]
189
+ groups: dict[tuple, list] = {}
190
+ for r in todo:
191
+ groups.setdefault((r["ts"].timestamp(), r["strategy"]), []).append(r)
192
+ for (t, name), rs in sorted(groups.items()):
193
+ j = self.names.index(name)
194
+ Y, u0, k = self.fly.parts_all(np.asarray([r["x"] for r in rs], dtype=np.float32))
195
+ w = plastic.row_weights(torch.tensor([[float(r["score"]) for r in rs]]), torch.tensor([float(rs[0]["line"])]))
196
+ self.pending.push([(t, r["mint"], name) for r in rs], np.full(len(rs), t), t + self.holds[name] + LABEL_LAG_S, Y[:, j], u0, k, w, s=j)
197
+ for r in rs:
198
+ self.watch[r["mint"]] = self.watch.get(r["mint"], 0) + 1
199
+
200
+ def maybe_reload(self) -> bool:
201
+ """A newer deployable bootstrap (a re-bootstrap) replaces this one at once: D = 0, pending tags dropped, the book carries on."""
202
+ from ..train import fly_replay
203
+ with transaction() as conn:
204
+ boot = fly_selector.latest_deployable(conn)
205
+ v = fly_replay.verdict()
206
+ if boot is None or v is None or not v.get("passed") or int(boot["id"]) == self.boot_id:
207
+ return False
208
+ old = self.boot_id; self.verdict = v
209
+ self._write_state()
210
+ self._load_bootstrap(boot)
211
+ with transaction() as conn:
212
+ conn.execute("UPDATE runs SET brain_snapshot_id = %s WHERE run_id = %s", (self.boot_id, self.run_id))
213
+ self._write_state()
214
+ record_event("info", "fly", f"switched to fly bootstrap #{self.boot_id}", {"from": old, "to": self.boot_id, "lines": self.lines["plastic"]})
215
+ return True
216
+
217
+ # ---- engine interface ----
218
+ def on_bars(self, t_start: float, bars: dict) -> None:
219
+ for m in self.watch:
220
+ b = bars.get(m)
221
+ if b is not None:
222
+ q = self.bars.setdefault(m, deque(maxlen=BAR_KEEP))
223
+ if not q or q[-1][0] < b[0]:
224
+ q.append(b)
225
+
226
+ def open_mints(self, conn) -> set[str]:
227
+ return {p["mint"] for p in ledger.open_positions(conn, BOOK)}
228
+
229
+ def on_minute(self, ctx) -> dict:
230
+ conn, t_now = ctx.conn, ctx.t_start
231
+ self._commands(conn, ctx.m1_epoch)
232
+ learned = self._learn(conn, ctx.m1_epoch)
233
+ day = int(t_now // 86400)
234
+ if self.last_day is None:
235
+ self.last_day = day
236
+ elif day > self.last_day:
237
+ self.last_day = day; self._calibrate(conn, ctx.m1_epoch)
238
+ scored = self._score(conn, ctx)
239
+ drift = {k: float(self.bank.drift(j)[0]) if self.bank.learn.shape[0] > 1 else float(self.bank.drift()[0]) for j, k in enumerate(self.names)}
240
+ out = {"minute": ctx.m1.isoformat(), "eligible": len(ctx.mints), "picks": scored["picks"], "lines": self.lines["plastic"], "frozen_lines": self.lines["frozen"],
241
+ "line": self.lines["plastic"][self.names[0]], "frozen_line": self.lines["frozen"][self.names[0]],
242
+ "drift": max(drift.values()) if drift else 0.0, "drift_by_strategy": drift, "pending": len(self.pending), "learned": learned,
243
+ "learning_frozen": self.learning_frozen, "channels": self.cfg, "holds_min": {k: v / 60.0 for k, v in self.holds.items()}, "bootstrap": self.boot_id}
244
+ if ctx.trade and ctx.fresh:
245
+ if self.race_started_at is None:
246
+ self.race_started_at = ctx.m1_epoch
247
+ self.beat_no += 1
248
+ d = scored["decision"]
249
+ st = paper_trading.trade_minute(ctx, book=BOOK, run_id=self.run_id, beat_no=self.beat_no, broker=self.broker, kind=KIND, mints=ctx.mints, infos=ctx.infos,
250
+ scores=d["score"], threshold=d["threshold"], table=None, horizon_s=self.holds[self.names[0]], holds=d["hold_s"],
251
+ strategies=d["strategy"], tables=d["tables"])
252
+ out.update({k: v for k, v in st.items() if k != "entries"}); out["stage"] = "trading"
253
+ if self.live and handover.state(conn) is not None:
254
+ out["live"] = self._live(ctx, st)
255
+ else:
256
+ out["stage"] = "learning (not trading this minute)" if ctx.trade else "catching up"
257
+ hour = int(ctx.m1_epoch // 3600)
258
+ if self.last_hour is None:
259
+ self.last_hour = hour
260
+ elif hour > self.last_hour:
261
+ self.last_hour = hour; self._hourly(conn, ctx.m1_epoch)
262
+ self._write_state()
263
+ if learned.get("keys"):
264
+ with conn.cursor() as cur:
265
+ cur.executemany("UPDATE fly_scored SET label = %s, state = %s, resolved_at = now(), x = NULL WHERE ts = %s AND mint = %s AND strategy = %s", learned.pop("keys"))
266
+ learned.pop("keys", None)
267
+ out["checks"] = self.last_checks; out["updated_at"] = datetime.now(timezone.utc).isoformat()
268
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES ('fly_status', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
269
+ (json.dumps(out, default=str),))
270
+ return out
271
+
272
+ def _live(self, ctx, st: dict) -> dict:
273
+ """The fly holds the seat: mirror this minute on the bot wallet (agent/fly_live.py) while signing is allowed."""
274
+ from ..chain.cluster_guard import signing_allowed
275
+ if not signing_allowed():
276
+ return {"stage": "live gated: signing is not allowed (LIVE_ENABLED=1 and the live prerequisites)"}
277
+ if self.mirror is None:
278
+ from .fly_live import LiveMirror
279
+ self.mirror = LiveMirror(self.H)
280
+ record_event("info", "fly_live", "the fly trades the bot wallet", {"run_id": self.run_id})
281
+ return self.mirror.minute(ctx, run_id=self.run_id, beat_id=st["beat_id"], entries=st["entries"], line=self.lines["plastic"][self.names[0]],
282
+ table=self.sizing["plastic"][self.names[0]])
283
+
284
+ def finish(self) -> None:
285
+ if self.mirror is not None:
286
+ self.mirror.stop()
287
+ try:
288
+ self._write_state()
289
+ except Exception:
290
+ log.exception("fly state write failed at finish")
291
+ with transaction() as conn:
292
+ conn.execute("UPDATE runs SET ended_at = now(), status = 'finished' WHERE run_id = %s", (self.run_id,))
293
+ record_event("info", "fly", "fly session stopped", {"run_id": self.run_id, "minutes": self.beat_no})
294
+
295
+ # ---- learning ----
296
+ @staticmethod
297
+ def _empty_stats() -> dict:
298
+ return {"n": 0, "sum_delta": 0.0, "sum_abs": 0.0, "step": 0.0, "capped": 0, "unknown": 0}
299
+
300
+ def _learn(self, conn, now: float) -> dict:
301
+ tags = self.pending.pop_due(now, device=self.bank.dev)
302
+ if tags is None:
303
+ return {"n": 0}
304
+ labels = np.array([np.nan if (lb := resolve_label(list(self.bars.get(m, ())), t, self.holds[name])) is None else lb for t, m, name in tags.keys], dtype=np.float64)
305
+ ok = np.isfinite(labels)
306
+ for t, m, _ in tags.keys:
307
+ c = self.watch.get(m, 0) - 1
308
+ if c > 0:
309
+ self.watch[m] = c
310
+ else:
311
+ self.watch.pop(m, None); self.bars.pop(m, None)
312
+ stats = {"n": int(ok.sum()), "unknown": int((~ok).sum())}
313
+ if ok.any():
314
+ tv = tags.subset(ok)
315
+ if self.learning_frozen:
316
+ self.bank.decay_to(now)
317
+ else:
318
+ st = self.bank.update(tv, torch.tensor(labels[ok], dtype=torch.float32), now)
319
+ stats.update(mean_delta=st["mean_delta"][0], mean_abs_delta=st["mean_abs_delta"][0], step=st["step"][0], capped=bool(st["capped"][0]))
320
+ h = self.hour_stats; h["n"] += stats["n"]; h["sum_delta"] += st["mean_delta"][0] * stats["n"]; h["sum_abs"] += st["mean_abs_delta"][0] * stats["n"]
321
+ h["step"] += st["step"][0]; h["capped"] += int(st["capped"][0])
322
+ self.hour_stats["unknown"] += stats["unknown"]
323
+ self.applied_through = now
324
+ stats["keys"] = [(float(lb) if np.isfinite(lb) else None, "resolved" if np.isfinite(lb) else "unknown", datetime.fromtimestamp(t, timezone.utc), m, name)
325
+ for (t, m, name), lb in zip(tags.keys, labels)]
326
+ return stats
327
+
328
+ def _score(self, conn, ctx) -> dict:
329
+ 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),
330
+ "tables": [[] for _ in range(n)]}
331
+ if not n:
332
+ return {"decision": empty, "picks": 0}
333
+ X = ctx.X[:, self.idx]
334
+ Y, u0, k = self.fly.parts_all(X); trig = self.fly.triggers(ctx.X, X_COLS)
335
+ if not self.nu_set:
336
+ self.bank.estimate_nu(Y[:, 0], u0, k); self.nu_set = True
337
+ t = ctx.t_start; ts = datetime.fromtimestamp(t, timezone.utc); rows = []
338
+ V = np.full((n, len(self.names)), -np.inf)
339
+ for j, name in enumerate(self.names):
340
+ loc = np.flatnonzero(trig[:, j])
341
+ if not len(loc):
342
+ continue
343
+ li = torch.as_tensor(loc, device=Y.device)
344
+ with torch.no_grad():
345
+ sc, _ = self.bank.predict(Y[li, j], u0[li], k[li], s=np.full(len(loc), j)); sc = sc[0]
346
+ fr = self.bank.frozen(Y[li, j], u0[li], s=np.full(len(loc), j))
347
+ v, f = sc.float().cpu().numpy(), fr.float().cpu().numpy(); V[loc, j] = v
348
+ line, fline = self.lines["plastic"][name], self.lines["frozen"][name]
349
+ rows += [(ts, ctx.mints[i], name, int(self.holds[name] // 60), ctx.X[i].tolist(), float(v[q]), float(f[q]), line, fline, self.boot_id) for q, i in enumerate(loc)]
350
+ w = plastic.row_weights(sc[None], torch.tensor([line], dtype=sc.dtype, device=sc.device))
351
+ self.pending.push([(t, ctx.mints[i], name) for i in loc], np.full(len(loc), t), t + self.holds[name] + LABEL_LAG_S, Y[li, j], u0[li], k[li], w, s=j)
352
+ for i in loc:
353
+ m = ctx.mints[i]; self.watch[m] = self.watch.get(m, 0) + 1
354
+ q_ = self.bars.setdefault(m, deque(maxlen=BAR_KEEP)); b = ctx.bars.get(m)
355
+ if b is not None and (not q_ or q_[-1][0] < b[0]):
356
+ q_.append(b)
357
+ if rows:
358
+ with conn.cursor() as cur:
359
+ cur.executemany("INSERT INTO fly_scored (ts, mint, strategy, hold_min, x, score, frozen_score, line, frozen_line, bootstrap_id) "
360
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (ts, mint, strategy) DO NOTHING", rows)
361
+ self.fly.lines = dict(self.lines["plastic"]); self.fly.sizings = {k: list(v) for k, v in self.sizing["plastic"].items()}
362
+ d = fly_selector.fly_decide(self.fly, ctx.X, X_COLS, np.where(np.isfinite(V), V, -np.inf))
363
+ return {"decision": d, "picks": int(sum(1 for x in d["strategy"] if x is not None))}
364
+
365
+ # ---- daily and hourly ----
366
+ def _resolved(self, conn, since_resolved: float, cols: str, name: str) -> list[dict]:
367
+ """A strategy's resolved rows whose labels became known after ``since_resolved`` (epoch s)."""
368
+ return conn.execute(f"SELECT {cols} FROM fly_scored WHERE state = 'resolved' AND label IS NOT NULL AND bootstrap_id = %s AND strategy = %s AND ts >= %s ORDER BY ts",
369
+ (self.boot_id, name, datetime.fromtimestamp(since_resolved - self.holds[name] - LABEL_LAG_S, timezone.utc))).fetchall()
370
+
371
+ def _calibrate(self, conn, now: float) -> None:
372
+ day = datetime.fromtimestamp(now, timezone.utc).date()
373
+ for name in self.names:
374
+ rows = self._resolved(conn, now - fly_calibrate.WINDOW_DAYS * 86400.0, "ts, mint, score, frozen_score, label", name)
375
+ if not rows:
376
+ continue
377
+ ts = np.array([r["ts"].timestamp() for r in rows]); mint = np.array([r["mint"] for r in rows]); lab = np.array([r["label"] for r in rows], dtype=np.float64)
378
+ for arm, col in (("plastic", "score"), ("frozen", "frozen_score")):
379
+ cal = fly_calibrate.calibrate(ts, mint, self.holds[name], np.array([r[col] for r in rows], dtype=np.float64), lab,
380
+ prev_line=self.lines[arm][name], prev_sizing=self.sizing[arm][name])
381
+ if cal.changed:
382
+ record_event("info", "fly", f"{name} {arm} line {self.lines[arm][name] * 100:+.2f}% → {cal.line * 100:+.2f}%", {"trades": cal.trades, "mean": cal.mean, "total": cal.total})
383
+ self.lines[arm][name], self.sizing[arm][name] = cal.line, cal.sizing
384
+ conn.execute("INSERT INTO fly_calibrations (day, arm, line, sizing, trades, total, mean, window_days) VALUES (%s,%s,%s,%s,%s,%s,%s,%s) "
385
+ "ON CONFLICT (day, arm) DO UPDATE SET line = EXCLUDED.line, sizing = EXCLUDED.sizing, trades = EXCLUDED.trades, total = EXCLUDED.total, mean = EXCLUDED.mean",
386
+ (day, f"{arm}:{name}", cal.line, json.dumps(cal.sizing), cal.trades, cal.total, cal.mean, fly_calibrate.WINDOW_DAYS))
387
+
388
+ def _drift(self, j: int) -> float:
389
+ return float(self.bank.drift(j)[0]) if self.bank.learn.shape[0] > 1 else float(self.bank.drift()[0])
390
+
391
+ def _checks(self, conn, now: float) -> dict:
392
+ """The rollback checks per strategy (its own trades, its own channel's drift)."""
393
+ out = {}
394
+ for j, name in enumerate(self.names):
395
+ rows = self._resolved(conn, now - gov.SHADOW_DAYS * 86400.0, "ts, mint, score, line, frozen_score, frozen_line, label", name)
396
+ if rows:
397
+ ts = np.array([r["ts"].timestamp() for r in rows]); mint = np.array([r["mint"] for r in rows]); lab = np.array([r["label"] for r in rows], dtype=np.float64)
398
+ g = lambda c: np.array([r[c] for r in rows], dtype=np.float64)
399
+ H = self.holds[name]
400
+ rp = gov.picks_returns(ts, mint, H, g("score"), g("line"), lab); rf = gov.picks_returns(ts, mint, H, g("frozen_score"), g("frozen_line"), lab)
401
+ recent = ts + H + LABEL_LAG_S > now - gov.IC_HOURS * 3600.0
402
+ out[name] = gov.check((rp, rf), (g("score")[recent], lab[recent]), self._drift(j))
403
+ else:
404
+ out[name] = gov.check(None, None, self._drift(j))
405
+ return out
406
+
407
+ def _hourly(self, conn, now: float) -> None:
408
+ hour = datetime.fromtimestamp(math.floor(now / 3600) * 3600 - 3600, timezone.utc)
409
+ checks = self._checks(conn, now); self.last_checks = checks
410
+ h = self.hour_stats; first = checks.get(self.names[0]) or {}
411
+ conn.execute("INSERT INTO fly_updates (hour, n, mean_delta, mean_abs_delta, step, capped, drift, ic, detail) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (hour) DO NOTHING",
412
+ (hour, h["n"], h["sum_delta"] / h["n"] if h["n"] else None, h["sum_abs"] / h["n"] if h["n"] else None, h["step"], h["capped"],
413
+ max((c["drift"] for c in checks.values()), default=0.0), first.get("ic"), json.dumps({"unknown": h["unknown"], "checks": checks}, default=str)))
414
+ self.hour_stats = self._empty_stats()
415
+ good = not any(c["triggers"] for c in checks.values())
416
+ sid = self._snapshot(conn, now, good, checks)
417
+ for name, c in checks.items():
418
+ if c["triggers"] and now >= self.checks_paused_until.get(name, 0.0) and not self.learning_frozen:
419
+ self._rollback(conn, now, c, sid, name=name)
420
+ self._prune(conn, now)
421
+ self._handover_check(conn, now)
422
+
423
+ def _snapshot(self, conn, now: float, good: bool, checks: dict) -> int:
424
+ d = fly_state_dir(); d.mkdir(parents=True, exist_ok=True)
425
+ path = d / f"snap_{datetime.fromtimestamp(now, timezone.utc).strftime('%Y%m%dT%H%M')}.pt"
426
+ torch.save({"bank": self.bank.state(), "bootstrap_id": self.boot_id, "lines": self.lines, "sizing": self.sizing}, path)
427
+ note = {"bootstrap_id": self.boot_id, "data": fly_selector.FLY_VERSION, "good": good, "checks": checks,
428
+ "drift": max((c["drift"] for c in checks.values()), default=0.0), "ic24": {k: c.get("ic") for k, c in checks.items()}}
429
+ return int(conn.execute("INSERT INTO brain_snapshots (run_id, path, kind, note) VALUES (%s,%s,'fly_plastic',%s) RETURNING id",
430
+ (self.run_id, str(path), json.dumps(note, default=str))).fetchone()["id"])
431
+
432
+ def _commands(self, conn, now: float) -> None:
433
+ """Operator commands from the console (``send_command``): pause / resume learning, roll back now."""
434
+ r = conn.execute("SELECT value FROM ui_settings WHERE key = %s", (COMMAND_KEY,)).fetchone()
435
+ if not r or not r["value"]:
436
+ return
437
+ v = r["value"] if isinstance(r["value"], dict) else json.loads(r["value"])
438
+ conn.execute("DELETE FROM ui_settings WHERE key = %s", (COMMAND_KEY,))
439
+ cmd = v.get("cmd")
440
+ if cmd == "pause":
441
+ self.learning_frozen = True; record_event("info", "fly", "learning paused by the operator")
442
+ elif cmd == "resume":
443
+ self.learning_frozen = False; self.rollbacks = {}; record_event("info", "fly", "learning resumed by the operator")
444
+ elif cmd == "rollback":
445
+ for j, name in enumerate(self.names):
446
+ self._rollback(conn, now, {"triggers": ["operator"], "drift": self._drift(j)}, None, count=False, name=name)
447
+
448
+ def _rollback(self, conn, now: float, checks: dict, from_sid: int | None, count: bool = True, name: str | None = None) -> None:
449
+ """Restore one strategy's channel (its KC→MBON columns) from the newest good snapshot at least a day old, or the bootstrap."""
450
+ name = name or self.names[0]; j = self.names.index(name); cols = self.bank.learn[j]
451
+ target = None
452
+ for r in conn.execute("SELECT id, path, note, ts FROM brain_snapshots WHERE kind = 'fly_plastic' AND ts <= %s ORDER BY id DESC",
453
+ (datetime.fromtimestamp(now - ROLLBACK_MIN_AGE_S, timezone.utc),)).fetchall():
454
+ n = json.loads(r["note"] or "{}")
455
+ ok = n.get("good") or not ((n.get("checks") or {}).get(name) or {}).get("triggers")
456
+ if ok and n.get("bootstrap_id") == self.boot_id and Path(r["path"]).exists():
457
+ target = r; break
458
+ if target is not None:
459
+ snap = plastic.PlasticBank(self.fly.net, [(0.0, math.inf)], fly_selector.SCALE, learn=self.bank.learn.cpu().numpy(), read=self.bank.read.cpu().numpy())
460
+ st = torch.load(target["path"], map_location="cpu", weights_only=False)["bank"]
461
+ snap.D = torch.zeros_like(snap.D); snap.D[:, snap.mask.bool()] = st["D"].to(snap.dev).float()
462
+ self.bank.D[0][:, cols] = snap.D[0][:, cols]
463
+ else:
464
+ self.bank.D[0][:, cols] = 0.0
465
+ self.checks_paused_until[name] = now + ROLLBACK_PAUSE_S
466
+ self.rollbacks[name] = [t for t in self.rollbacks.get(name, []) if t > now - ROLLBACK_WINDOW_S] + ([now] if count else [])
467
+ conn.execute("INSERT INTO fly_rollbacks (reason, checks, from_snapshot, to_snapshot) VALUES (%s,%s,%s,%s)",
468
+ (f"{name}: " + ", ".join(checks["triggers"]), json.dumps(checks, default=str), from_sid, target["id"] if target is not None else None))
469
+ record_event("warning", "fly", f"{name} channel rolled back ({', '.join(checks['triggers'])})", {"to_snapshot": target["id"] if target is not None else "bootstrap (D = 0)", "checks": checks})
470
+ if count and len(self.rollbacks[name]) >= ROLLBACK_LIMIT:
471
+ self.learning_frozen = True
472
+ from ..train import pipeline
473
+ pipeline.request_run(fly=True, reason=f"{name}: {len(self.rollbacks[name])} rollbacks in 7 days")
474
+ record_event("error", "fly", f"learning frozen after {len(self.rollbacks[name])} {name} rollbacks in 7 days; re-bootstrap requested", {"rollbacks": self.rollbacks})
475
+
476
+ def _prune(self, conn, now: float) -> None:
477
+ rows = conn.execute("SELECT id, path, ts FROM brain_snapshots WHERE kind = 'fly_plastic' AND ts < %s ORDER BY ts DESC",
478
+ (datetime.fromtimestamp(now - SNAP_HOURLY_DAYS * 86400.0, timezone.utc),)).fetchall()
479
+ kept_days: set = set(); drop = []
480
+ for r in rows:
481
+ d = r["ts"].date()
482
+ if d in kept_days:
483
+ drop.append(r)
484
+ else:
485
+ kept_days.add(d)
486
+ for r in drop:
487
+ conn.execute("DELETE FROM brain_snapshots WHERE id = %s", (r["id"],))
488
+ try:
489
+ Path(r["path"]).unlink(missing_ok=True)
490
+ except OSError:
491
+ pass
492
+ conn.execute("DELETE FROM fly_scored WHERE state <> 'pending' AND ts < %s", (datetime.fromtimestamp(now - SCORED_KEEP_DAYS * 86400.0, timezone.utc),))
493
+
494
+ def _handover_check(self, conn, now: float) -> None:
495
+ """The fly takes the selector's seat once it has raced ``config.HANDOVER_DAYS`` (0: no minimum, the window is the
496
+ whole race), closed ``config.HANDOVER_MIN_TRADES`` paper trades and, when ``config.HANDOVER_BEAT_SELECTOR``,
497
+ realized at least the paper selector's P&L over that window."""
498
+ days = config.HANDOVER_DAYS
499
+ if handover.state(conn) is not None or self.race_started_at is None or now - self.race_started_at < days * 86400.0:
500
+ return
501
+ since = datetime.fromtimestamp(self.race_started_at if days <= 0 else now - days * 86400.0, timezone.utc)
502
+ pnl = {b: conn.execute("SELECT COALESCE(sum(realized_sol), 0) AS s, count(*) AS n FROM positions WHERE book = %s AND status = 'closed' AND closed_at >= %s",
503
+ (b, since)).fetchone() for b in (BOOK, "paper_selector")}
504
+ fly_pnl, sel_pnl, n = float(pnl[BOOK]["s"]), float(pnl["paper_selector"]["s"]), int(pnl[BOOK]["n"])
505
+ if n >= config.HANDOVER_MIN_TRADES and (fly_pnl >= sel_pnl or not config.HANDOVER_BEAT_SELECTOR):
506
+ detail = {"at": datetime.fromtimestamp(now, timezone.utc).isoformat(), "fly_pnl_sol": fly_pnl, "selector_pnl_sol": sel_pnl, "fly_trades": n,
507
+ "selector_trades": int(pnl["paper_selector"]["n"]), "days": days, "beat_selector": config.HANDOVER_BEAT_SELECTOR, "bootstrap": self.boot_id}
508
+ handover.record(conn, detail)
509
+ record_event("info", "handover", f"the fly takes the selector's seat: {fly_pnl:+.3f} SOL vs {sel_pnl:+.3f} SOL over {n} trades"
510
+ + (f" in {days} days" if days > 0 else ""), detail)
fly_trader/agent/handover.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The handover: the plastic fly takes the selector's seat (agent/fly_session.py decides; ui_settings['handover']).
2
+
3
+ After it the selector stops entering, exits to flat and is dropped while a fly trades; the weekly training schedule
4
+ stops (train/pipeline.due runs only on request). If no fly can trade (replaced definitions, a failed re-bootstrap), the
5
+ selector trades again until one can.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ KEY = "handover"
12
+
13
+
14
+ def state(conn) -> dict | None:
15
+ r = conn.execute("SELECT value FROM ui_settings WHERE key = %s", (KEY,)).fetchone()
16
+ if not r or r["value"] is None:
17
+ return None
18
+ v = r["value"] if isinstance(r["value"], dict) else json.loads(r["value"])
19
+ return v or None
20
+
21
+
22
+ def record(conn, detail: dict) -> None:
23
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
24
+ (KEY, json.dumps(detail, default=str)))
fly_trader/agent/minute_engine.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The trading engine on the live stream: every UTC minute, features once per mint, then every book trades on them.
2
+
3
+ Every UTC minute, once the stream has written it (``pumpstream_status.flushed_through``), the minute's rich candle per
4
+ mint (``pump_minutes``: open/high/low/close, SOL volume by side, buy/sell counts, distinct traders, quote reserve; the
5
+ same fields and filters ``train/mature.py`` builds from the archive) is fed to the live feature engine as two
6
+ synthetic trades, exactly as in training. Graduation time and creation/creator facts come from ``corpus_meta``, the
7
+ same source training uses. The full feature vector (``decisions.X_COLS``) of the rows in the models' universe
8
+ (``selector.in_universe``) that pass the eligibility gate (pool ≥ ``MIN_RESQ_SOL``, 15-minute volume ≥
9
+ ``MIN_VOL_15M_SOL``, no scale break — a close 50× from the previous one or a reserve above 100,000 SOL — since the
10
+ mint's state began) goes to every book; each picks its model's columns by name.
11
+
12
+ Books — ``SelectorBook`` (agent/selector_session.py) and ``FlyBook`` (agent/fly_session.py) — implement ``name``,
13
+ ``on_bars(t_start, bars)`` (every processed minute, warm-up included: open, close and exit cost of every traded mint),
14
+ ``on_minute(ctx)``, ``open_mints(conn)``, ``maybe_reload()``, ``done`` and ``finish()``. Each book trades inside its own
15
+ savepoint, so one book's failure never undoes the other's minute. Books join when they qualify (checked every
16
+ ``MODEL_CHECK_S``): the selector while it has a deployable model and has not handed its seat to a fly that is trading;
17
+ the fly once its design passed the replay and a deployable bootstrap exists. No entries or exits while the stream is
18
+ stale; missed minutes are replayed through the features without trading.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import math
25
+ import threading
26
+ import time
27
+ from collections import deque
28
+ from dataclasses import dataclass
29
+ from datetime import datetime, timezone
30
+
31
+ import numpy as np
32
+
33
+ from ..db.apilog import record_event
34
+ from ..db.connection import transaction
35
+ from ..market.exit_cost import PUMP_SUPPLY
36
+ from ..market.exit_cost import cost_at_size
37
+ from ..market.features import FEATURES, FIDX, TokenMeta, TokenState
38
+ from ..train.corpus_meta import FEATURE_COLS as META_COLS
39
+ from ..train.decisions import MIN_RESQ_SOL, MIN_VOL_15M_SOL, X_COLS
40
+ from ..train.flow import FlowWindow, MarketWindow
41
+ from ..train.mature import MASKED
42
+ from ..train.selector import in_universe
43
+
44
+ log = logging.getLogger(__name__)
45
+ WARMUP_MIN = 1440 # training warms each day on the previous day's candles
46
+ IDLE_EVICT_S = 86400 # training has no history for a mint idle since before the previous day
47
+ STREAM_STALE_S = 180 # no entries or exits when the stream's newest complete minute is older than this
48
+ META_TTL_S = 600
49
+ MODEL_CHECK_S = 600 # how often books reload newer models and new books are admitted
50
+
51
+
52
+ class MintState:
53
+ __slots__ = ("st", "meta", "hist", "decimals", "pool", "program_label", "graduated_at", "meta_row", "prev_close", "broken", "flow")
54
+
55
+ def __init__(self, mint: str, decimals: int, pool: str | None, program_label: str | None, graduated_at: float | None, meta_row: dict | None):
56
+ self.st = TokenState(mint); self.meta = TokenMeta(mint=mint, program_label=program_label or "Pump.fun Amm", graduated_at=graduated_at)
57
+ self.hist: deque = deque(maxlen=200) # (t_end, n_trades, n_traders) for the trailing hour
58
+ self.decimals, self.pool, self.program_label, self.graduated_at, self.meta_row = decimals, pool, program_label, graduated_at, meta_row
59
+ self.prev_close: float | None = None; self.broken = False
60
+ self.flow = FlowWindow() # wallet flow inputs (train/flow.py, as mature.build_day)
61
+
62
+
63
+ @dataclass
64
+ class Minute:
65
+ """What every book sees of one minute."""
66
+ conn: object
67
+ m0: datetime
68
+ m1: datetime
69
+ m1_epoch: float
70
+ agg: dict
71
+ X: np.ndarray # [n, len(X_COLS)] eligible rows in the universe
72
+ infos: list
73
+ mints: list
74
+ bars: dict # mint -> (minute start, open, close, exit cost) for every traded mint
75
+ prices: dict
76
+ resqs: dict
77
+ fees: dict
78
+ fresh: bool
79
+ trade: bool
80
+ engine: "MinuteEngine"
81
+
82
+ @property
83
+ def t_start(self) -> float:
84
+ return self.m1_epoch - 60.0
85
+
86
+ def last_resq(self, mint: str) -> float | None:
87
+ return self.engine._last_resq(self.conn, mint)
88
+
89
+ def mcap(self, mint: str, price: float | None) -> float | None:
90
+ return self.engine._mcap(mint, price)
91
+
92
+
93
+ class MinuteEngine:
94
+ def __init__(self, books: list | None = None, live: bool = False):
95
+ self.books = list(books or []); self.live = live
96
+ self.states: dict[str, MintState] = {}
97
+ self.meta_cache: dict[str, dict] = {}; self.last_sweep = time.time(); self.last_minute: float | None = None
98
+ self.market = MarketWindow() # mkt_vol_1h over every traded mint (as mature._market_windows)
99
+
100
+ def has_book(self, name: str) -> bool:
101
+ return any(b.name == name for b in self.books)
102
+
103
+ # ---- lookups ----
104
+ def _meta(self, conn, mint: str) -> dict | None:
105
+ """``corpus_meta`` row (graduation time + creation/creator features), cached for 10 minutes: rows appear at
106
+ graduation and the archive rebuild later replaces stream values, so the cache is refreshed."""
107
+ hit = self.meta_cache.get(mint)
108
+ if hit is None or time.time() - hit["at"] > META_TTL_S:
109
+ r = conn.execute("SELECT graduated_at, supply, " + ", ".join(META_COLS) + " FROM corpus_meta WHERE mint = %s", (mint,)).fetchone()
110
+ hit = self.meta_cache[mint] = {"row": dict(r) if r else None, "at": time.time()}
111
+ return hit["row"]
112
+
113
+ def _state(self, conn, mint: str, pool: str | None, program_label: str | None) -> MintState:
114
+ s = self.states.get(mint)
115
+ if s is None:
116
+ t = conn.execute("SELECT decimals FROM tokens WHERE mint = %s", (mint,)).fetchone()
117
+ s = MintState(mint, int((t or {}).get("decimals") or 6), pool, program_label, None, None)
118
+ self.states[mint] = s
119
+ row = self._meta(conn, mint)
120
+ s.meta_row = row
121
+ g = row["graduated_at"].timestamp() if row and row.get("graduated_at") else None
122
+ if g != s.graduated_at: # same source as training: corpus_meta.graduated_at
123
+ s.graduated_at = g; s.meta.graduated_at = g
124
+ s.meta.supply = float(row["supply"]) if row and row.get("supply") else PUMP_SUPPLY # as training (train/mature.build_day)
125
+ return s
126
+
127
+ def _last_resq(self, conn, mint: str) -> float | None:
128
+ s = self.states.get(mint)
129
+ if s is not None and s.st.last_res_quote_sol:
130
+ return s.st.last_res_quote_sol
131
+ r = conn.execute("SELECT resq_sol FROM pump_minutes WHERE mint = %s AND resq_sol IS NOT NULL ORDER BY ts DESC LIMIT 1", (mint,)).fetchone()
132
+ return float(r["resq_sol"]) if r else None
133
+
134
+ def _mcap(self, mint: str, price: float | None) -> float | None:
135
+ """Market cap in SOL (price × supply), which sets the pool fee tier; None without a price."""
136
+ s = self.states.get(mint)
137
+ return float(price) * (s.meta.supply if s is not None else PUMP_SUPPLY) if price else None
138
+
139
+ # ---- one minute ----
140
+ def _aggregate(self, conn, m0: datetime, m1: datetime) -> dict[str, dict]:
141
+ """PumpAPI minutes: every SOL-quoted PumpSwap pump.fun token, the same fields as the archive."""
142
+ rows = conn.execute("SELECT mint, pool_id, open, high, low, close, buy_sol, sell_sol, n_buys, n_sells, n_traders, resq_sol, fee_rate, "
143
+ "n_buyers, wash_sol, wash_buy_sol, top_sell_sol, insider_sell_sol, skill_buy FROM pump_minutes WHERE ts = %s", (m0,)).fetchall()
144
+ return {r["mint"]: {"open": r["open"], "high": r["high"], "low": r["low"], "close": r["close"], "buy": r["buy_sol"] or 0.0, "sell": r["sell_sol"] or 0.0,
145
+ "nb": r["n_buys"] or 0, "ns": r["n_sells"] or 0, "n_traders": int(r["n_traders"] or 0), "resq": r["resq_sol"],
146
+ "pool": r["pool_id"], "program_label": "Pump.fun Amm", "fee_rate": r["fee_rate"],
147
+ "n_buyers": r["n_buyers"], "wash_sol": r["wash_sol"], "wash_buy_sol": r["wash_buy_sol"], "top_sell_sol": r["top_sell_sol"],
148
+ "insider_sell_sol": r["insider_sell_sol"], "skill_buy": r["skill_buy"]} for r in rows if r["close"]}
149
+
150
+ def _features(self, conn, mint: str, a: dict, t_end: float) -> tuple[np.ndarray, dict]:
151
+ """The full feature vector (``X_COLS``) of a mint for the minute ending ``t_end``, and what trading needs."""
152
+ s = self._state(conn, mint, a["pool"], a["program_label"])
153
+ st, price, resq = s.st, float(a["close"]), a["resq"]
154
+ if (s.prev_close and not (1 / 50 <= price / s.prev_close <= 50)) or (resq is not None and resq > 1e5):
155
+ s.broken = True # train/decisions.py: a scale break makes the mint ineligible from that minute on
156
+ s.prev_close = price
157
+ if a["buy"] > 0:
158
+ st.append(t_end - 2e-3, price, a["buy"], True, None, resq)
159
+ if a["sell"] > 0 or a["buy"] <= 0:
160
+ st.append(t_end - 1e-3, price, a["sell"], False, None, resq)
161
+ s.hist.append((t_end, float(a["nb"] + a["ns"]), float(a["n_traders"])))
162
+ s.flow.append(t_end, a["buy"], a["sell"], a.get("n_buyers"), a.get("wash_sol"), a.get("wash_buy_sol"), a.get("top_sell_sol"), a.get("insider_sell_sol"), a.get("skill_buy"))
163
+ f, _mask = st.features(t_end, s.meta)
164
+ ht = np.array([h[0] for h in s.hist]); hn = np.array([h[1] for h in s.hist]); htr = np.array([h[2] for h in s.hist])
165
+ for k, w in (("1m", 60), ("5m", 300), ("15m", 900), ("1h", 3600)):
166
+ f[FIDX[f"logn_{k}"]] = math.log1p(float(hn[ht > t_end - w].sum()))
167
+ for name in MASKED:
168
+ f[FIDX[name]] = 0.0
169
+ if s.graduated_at is None:
170
+ f[FIDX["log_age_h"]] = 0.0
171
+ hod = ((t_end - 60.0) % 86400) / 3600.0 # minute start, as in train/decisions.py
172
+ meta = s.meta_row or {}
173
+ extra = {"traders_15m": float(htr[ht > t_end - 900].sum()), "traders_1h": float(htr[ht > t_end - 3600].sum()), "n_trades_1m": float(a["nb"] + a["ns"]),
174
+ "hod_s": math.sin(2 * math.pi * hod / 24), "hod_c": math.cos(2 * math.pi * hod / 24), "age_known": 1.0 if s.graduated_at is not None else 0.0,
175
+ "meta_known": 1.0 if meta.get("ttg_min") is not None else 0.0, **s.flow.features(t_end), "mkt_vol_1h": self.market.value(t_end)}
176
+ by_name = {**{n: float(f[i]) for i, n in enumerate(FEATURES)}, **extra,
177
+ **{c: float(meta.get(c)) if meta.get(c) is not None else 0.0 for c in META_COLS}}
178
+ x = np.nan_to_num(np.asarray([by_name.get(c, 0.0) for c in X_COLS], dtype=np.float32), nan=0.0, posinf=0.0, neginf=0.0)
179
+ info = {"price": price, "open": float(a["open"]) if a.get("open") else price, "resq": resq, "logvol_15m": f[FIDX["logvol_15m"]],
180
+ # "ec" is the training label's own cost model (market/exit_cost.cost_at_size), at the size the book trades
181
+ "ec": float(cost_at_size(f[FIDX["exit_cost_0p1"]], resq)), "age_h": (t_end - s.graduated_at) / 3600 if s.graduated_at else None,
182
+ "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,
183
+ "skill_missing": a.get("skill_buy") is None, "curve_known": bool(meta.get("curve_known"))}
184
+ return x, info
185
+
186
+ @staticmethod
187
+ def eligible(info: dict) -> bool:
188
+ 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)
189
+
190
+ def _stream_through(self) -> float | None:
191
+ with transaction() as conn:
192
+ r = conn.execute("SELECT value->>'flushed_through' AS ft FROM ui_settings WHERE key = 'pumpstream_status'").fetchone()
193
+ return datetime.fromisoformat(r["ft"]).timestamp() if r and r["ft"] else None
194
+
195
+ def ready_through(self, m1_epoch: float) -> float:
196
+ """End of the newest minute (at most ``m1``) the stream has written; A minute is
197
+ never read before it is written, so a lagging stream delays minutes instead of feeding them empty or partial."""
198
+ ft = self._stream_through()
199
+ return min(m1_epoch, ft + 60.0) if ft is not None else float("-inf")
200
+
201
+ def stream_fresh(self, m0_epoch: float) -> bool:
202
+ ft = self._stream_through()
203
+ return ft is not None and m0_epoch - ft <= STREAM_STALE_S
204
+
205
+ def _minute_rows(self, conn, agg: dict, m1_epoch: float) -> tuple[np.ndarray, list, list, dict]:
206
+ xs, infos, mints, bars = [], [], [], {}
207
+ self.market.add(m1_epoch, sum(float(a["buy"]) + float(a["sell"]) for a in agg.values()))
208
+ for mint, a in agg.items():
209
+ x, info = self._features(conn, mint, a, m1_epoch)
210
+ bars[mint] = (m1_epoch - 60.0, info["open"], info["price"], info["ec"])
211
+ if in_universe(x[None], X_COLS)[0] and self.eligible(info): # the models' universe (train/selector.in_universe)
212
+ xs.append(x); infos.append(info); mints.append(mint)
213
+ return (np.stack(xs) if xs else np.zeros((0, len(X_COLS)), np.float32)), infos, mints, bars
214
+
215
+ def warm_up(self, m1_epoch: float, minutes: int = WARMUP_MIN) -> int:
216
+ """Feed the last ``minutes`` closed minutes through the feature states (and the books' bars) without trading."""
217
+ n = 0
218
+ with transaction() as conn:
219
+ for k in range(minutes, 0, -1):
220
+ t1 = m1_epoch - 60 * k
221
+ agg = self._aggregate(conn, datetime.fromtimestamp(t1 - 60, timezone.utc), datetime.fromtimestamp(t1, timezone.utc))
222
+ bars = {}
223
+ self.market.add(t1, sum(float(a["buy"]) + float(a["sell"]) for a in agg.values()))
224
+ for mint, a in agg.items():
225
+ _x, info = self._features(conn, mint, a, t1); n += 1
226
+ bars[mint] = (t1 - 60.0, info["open"], info["price"], info["ec"])
227
+ for b in self.books:
228
+ b.on_bars(t1 - 60.0, bars)
229
+ log.info("warm-up: %d token-minutes over the last %d minutes (%d tokens)", n, minutes, len(self.states))
230
+ return n
231
+
232
+ def _sweep(self, now_s: float, held: set[str]) -> None:
233
+ if now_s - self.last_sweep < 3600:
234
+ return
235
+ self.last_sweep = now_s
236
+ idle = [m for m, s in self.states.items() if m not in held and (s.st.last_ts is None or s.st.last_ts < now_s - IDLE_EVICT_S)]
237
+ for m in idle:
238
+ self.states.pop(m, None); self.meta_cache.pop(m, None)
239
+ if idle:
240
+ log.info("evicted %d mint states idle for more than %d h", len(idle), IDLE_EVICT_S // 3600)
241
+
242
+ def run_minute(self, m1_epoch: float, trade: bool = True) -> dict:
243
+ m1 = datetime.fromtimestamp(m1_epoch, timezone.utc); m0 = datetime.fromtimestamp(m1_epoch - 60, timezone.utc)
244
+ out: dict = {"minute": m1.isoformat()}
245
+ with transaction() as conn:
246
+ agg = self._aggregate(conn, m0, m1)
247
+ X, infos, mints, bars = self._minute_rows(conn, agg, m1_epoch)
248
+ ctx = Minute(conn=conn, m0=m0, m1=m1, m1_epoch=m1_epoch, agg=agg, X=X, infos=infos, mints=mints, bars=bars,
249
+ prices={m: float(a["close"]) for m, a in agg.items()}, resqs={m: a["resq"] for m, a in agg.items()},
250
+ 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)
251
+ out.update(mints_traded=len(agg), eligible=len(mints)); held: set[str] = set()
252
+ for b in list(self.books):
253
+ try:
254
+ with conn.transaction(): # a savepoint per book
255
+ b.on_bars(m1_epoch - 60.0, bars)
256
+ out[b.name] = b.on_minute(ctx)
257
+ held |= set(b.open_mints(conn))
258
+ except Exception:
259
+ log.exception("%s: minute %s failed", b.name, m1.isoformat())
260
+ record_event("error", b.name, f"minute failed: {m1.isoformat()}")
261
+ self._sweep(m1_epoch, held)
262
+ for b in [b for b in self.books if b.done]:
263
+ b.finish(); self.books.remove(b)
264
+ return out
265
+
266
+
267
+ def _status(key: str, value: dict) -> None:
268
+ try:
269
+ with transaction() as conn:
270
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
271
+ (key, json.dumps({**value, "updated_at": datetime.now(timezone.utc).isoformat()}, default=str)))
272
+ except Exception:
273
+ log.debug("status write failed", exc_info=True)
274
+
275
+
276
+ def admit_books(engine: MinuteEngine, live: bool) -> list:
277
+ """Books that qualify now and are not running yet (the fly first: its presence decides whether the selector trades)."""
278
+ from . import fly_session, handover
279
+ from .selector_session import NoModel, SelectorBook
280
+ new = []
281
+ if not engine.has_book("fly"):
282
+ fb, why = fly_session.try_start(live)
283
+ if fb is not None:
284
+ new.append(fb)
285
+ else:
286
+ _status("fly_status", {"stage": "not trading", "detail": why})
287
+ fly_on = engine.has_book("fly") or bool(new)
288
+ with transaction() as conn:
289
+ handed = handover.state(conn) is not None
290
+ if not engine.has_book("selector") and not (handed and fly_on):
291
+ try:
292
+ new.append(SelectorBook())
293
+ except NoModel as e:
294
+ _status("selector_status", {"stage": "waiting for a model", "detail": str(e)})
295
+ return new
296
+
297
+
298
+ def main(stop_event: threading.Event | None = None, live: bool = False) -> None:
299
+ engine = MinuteEngine(live=live)
300
+ while not (stop_event is not None and stop_event.is_set()):
301
+ engine.books = admit_books(engine, live)
302
+ if engine.books:
303
+ break
304
+ log.info("trading engine waiting: no book qualifies yet")
305
+ (stop_event or threading.Event()).wait(MODEL_CHECK_S)
306
+ else:
307
+ return
308
+ m_start = math.floor(time.time() / 60) * 60
309
+ engine.warm_up(m_start); engine.last_minute = m_start - 60; last_check = time.time(); n_min = 0
310
+ try:
311
+ while not (stop_event is not None and stop_event.is_set()):
312
+ now = time.time(); m1 = math.floor(now / 60) * 60
313
+ if now - last_check >= MODEL_CHECK_S:
314
+ last_check = now
315
+ for b in engine.books:
316
+ try:
317
+ b.maybe_reload()
318
+ except Exception:
319
+ log.exception("%s: model reload check failed", b.name)
320
+ try:
321
+ engine.books += admit_books(engine, live)
322
+ except Exception:
323
+ log.exception("book admission failed")
324
+ if engine.books and m1 > engine.last_minute and now - m1 >= 4.0 and (upto := engine.ready_through(m1)) > engine.last_minute: # only minutes the stream has written
325
+ for minute in range(int(engine.last_minute) + 60, int(upto) + 1, 60):
326
+ try:
327
+ st = engine.run_minute(float(minute), trade=(minute == int(m1))) # missed minutes update features (and the fly's learning) only
328
+ n_min += 1
329
+ if minute == int(m1) and n_min % 10 == 0:
330
+ log.info("minute %s: traded %d eligible %d | %s", st["minute"], st.get("mints_traded", 0), st.get("eligible", 0),
331
+ " | ".join(f"{b.name}: picks {(st.get(b.name) or {}).get('picks')} open {(st.get(b.name) or {}).get('open')} wealth {(st.get(b.name) or {}).get('wealth')}"
332
+ for b in engine.books))
333
+ except Exception:
334
+ log.exception("minute %s failed", minute); record_event("error", "engine", f"minute failed: {minute}")
335
+ engine.last_minute = upto
336
+ time.sleep(0.5)
337
+ finally:
338
+ for b in engine.books:
339
+ try:
340
+ b.finish()
341
+ except Exception:
342
+ log.exception("%s: finish failed", b.name)
fly_trader/agent/paper_trading.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One trading minute of a paper book — the rules both race books share (the selector's and the fly's):
2
+
3
+ exits: positions held their own hold (the strategy's, stored on the position) or ``horizon_s`` are sold at this minute's
4
+ price (or the last traded price: the training label's exit); entries: scores at or above the book's line open a position sized from the book's certainty bands
5
+ (``agent/sizing.py``), one position per token, unless blocked (global kill switch, paused entries, the book's own
6
+ drawdown halt, or the caller's ``block``); marks and wealth per minute (positions net of exit cost, exposure at cost,
7
+ drawdown ≥ 0); the book's drawdown halt is checked on its own peak (``rails.check_book_drawdown``). Every score, entry,
8
+ exit and blocked pick is a ``decisions`` row.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ import numpy as np
15
+
16
+ from . import rails, sizing
17
+ from ..execution import ledger
18
+ from ..market.exit_cost import exit_cost_fraction
19
+
20
+
21
+ def trade_minute(ctx, *, book: str, run_id: str, beat_no: int, broker, kind: str, mints: list, infos: list, scores, threshold,
22
+ table: list | None, horizon_s: float, block: str | None = None, holds=None, strategies=None, allow=None, reasons=None,
23
+ tables: list | None = None) -> dict:
24
+ """Optional per-row arguments (the selector's strategy stack, train/strategies.decide): ``threshold`` may be per row,
25
+ ``holds`` (s), ``strategies``, ``tables`` (sizing) and ``allow`` / ``reasons`` (a blocked row is recorded with its reason).
26
+ Left out, the call behaves exactly as the single-strategy engine."""
27
+ conn, m1 = ctx.conn, ctx.m1
28
+ beat = conn.execute("INSERT INTO beats (run_id, ts, beat_no, n_slots_active, notes) VALUES (%s,%s,%s,%s,%s) RETURNING id",
29
+ (run_id, m1, beat_no, len(mints), json.dumps({"minute": m1.isoformat(), "mints_traded": len(ctx.agg), "book": book}))).fetchone()["id"]
30
+ n_exit = 0
31
+ for p in ledger.open_positions(conn, book):
32
+ held = (m1 - p["opened_at"]).total_seconds()
33
+ if held < float(p.get("hold_s") or horizon_s):
34
+ continue
35
+ did = conn.execute("INSERT INTO decisions (beat_id, run_id, ts, mint, pool, kind, size_sol, forced, reason, detail) VALUES (%s,%s,%s,%s,%s,%s,%s,false,%s,%s) RETURNING id",
36
+ (beat, run_id, m1, p["mint"], p["pool"], f"{kind}_exit", float(p["cost_sol"]), f"held {held/60:.0f} min", json.dumps({"book": book}))).fetchone()["id"]
37
+ px = ctx.prices.get(p["mint"]) or float(p.get("last_mark_price") or p["entry_price"])
38
+ broker.sell(conn, position=p, decision_id=did, price=ctx.prices.get(p["mint"]), res_quote_sol=ctx.resqs.get(p["mint"]) or ctx.last_resq(p["mint"]),
39
+ mcap_sol=ctx.mcap(p["mint"], px), program_label=p.get("program_label"), forced_kind=None, pool_fee=ctx.fees.get(p["mint"]), ts=m1)
40
+ n_exit += 1
41
+ opens_now = ledger.open_positions(conn, book); open_mints = {p["mint"] for p in opens_now}
42
+ cash = ledger.paper_cash(conn, book); n_enter = 0; picks = []; entries = []
43
+ bankroll = cash + sum(float(p["cost_sol"]) for p in opens_now) # wealth at cost: the sizing base (agent/sizing.py)
44
+ circuit = conn.execute("SELECT kill_switch, entries_paused FROM circuit_state WHERE id = 1").fetchone()
45
+ blocked = (block or ("kill switch" if circuit and circuit["kill_switch"] else None) or ("paused" if circuit and circuit["entries_paused"] else None)
46
+ or ("book halted" if rails.book_halted(conn, book) else None))
47
+ thr_v = np.broadcast_to(np.asarray(threshold, dtype=np.float64), (len(mints),)) if len(mints) else np.array([])
48
+ for k, (m, i, sc) in enumerate(zip(mints, infos, scores)):
49
+ thr_k = float(thr_v[k])
50
+ if not np.isfinite(sc) or sc < thr_k:
51
+ continue
52
+ picks.append((m, float(sc)))
53
+ tab_k = tables[k] if tables is not None else table
54
+ size, why = sizing.size_position(float(sc), thr_k, tab_k or [], bankroll, cash, i["resq"])
55
+ denied = allow is not None and not bool(allow[k])
56
+ if m in open_mints or blocked or denied or size <= 0:
57
+ rail = blocked or ("held" if m in open_mints else (str(reasons[k]) if denied and reasons is not None else "filtered") if denied else "sizing")
58
+ conn.execute("INSERT INTO decisions (beat_id, run_id, ts, mint, pool, kind, m_hat, size_sol, forced, rail, reason, detail) VALUES (%s,%s,%s,%s,%s,'blocked',%s,%s,false,%s,%s,%s)",
59
+ (beat, run_id, m1, m, i["pool"], float(sc), size, rail, why if rail == "sizing" else f"{kind} pick not taken",
60
+ json.dumps({"book": book, "score": float(sc), "threshold": thr_k, "sizing": why,
61
+ "strategy": None if strategies is None else strategies[k]})))
62
+ continue
63
+ hold_k = float(holds[k]) if holds is not None else None; strat_k = None if strategies is None else strategies[k]
64
+ 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",
65
+ (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],
66
+ 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"],
67
+ "strategy": strat_k, "hold_s": hold_k}))).fetchone()["id"]
68
+ fill = broker.buy(conn, decision_id=did, mint=m, pool=i["pool"], size_sol=size, price=i["price"], res_quote_sol=i["resq"],
69
+ mcap_sol=i["mcap"], decimals=i["decimals"], program_label=i["program_label"], pool_fee=i["fee_rate"], ts=m1, hold_s=hold_k, strategy=strat_k)
70
+ if fill.ok:
71
+ n_enter += 1; open_mints.add(m); cash -= size
72
+ 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, "strategy": strat_k})
73
+ else:
74
+ conn.execute("UPDATE decisions SET rail = %s, reason = %s WHERE id = %s", ("paper_fill", fill.reason, did))
75
+ ledger.mark_positions(conn, book, ctx.prices, {}, m1)
76
+ opens = ledger.open_positions(conn, book); gross_v = 0.0; exit_cost = 0.0; exposure = 0.0
77
+ for p in opens:
78
+ px = float(p.get("last_mark_price") or p["entry_price"])
79
+ gross = int(p["qty"]) / 10 ** int(p.get("decimals") or 6) * px
80
+ rq = ctx.resqs.get(p["mint"]) or ctx.last_resq(p["mint"])
81
+ gross_v += gross; exposure += float(p["cost_sol"])
82
+ exit_cost += gross * exit_cost_fraction(gross, rq, ctx.mcap(p["mint"], px), p.get("program_label"), ctx.fees.get(p["mint"]))
83
+ cash = ledger.paper_cash(conn, book); positions_net = gross_v - exit_cost; wealth = cash + positions_net
84
+ peak_row = conn.execute("SELECT max(wealth) AS pk FROM wealth_marks WHERE book = %s", (book,)).fetchone(); peak = max(float(peak_row["pk"] or 0.0), wealth)
85
+ conn.execute("INSERT INTO wealth_marks (beat_id, book, ts, sol_free, positions_value, exit_cost, wealth, peak, drawdown, exposure, n_open) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
86
+ (beat, book, m1, cash, positions_net, exit_cost, wealth, peak, (1.0 - wealth / peak) if peak > 0 else 0.0, exposure, len(opens)))
87
+ rails.check_book_drawdown(conn, book, wealth, peak) # the book's own kill switch: blocks its next minutes' entries
88
+ return {"beat_id": beat, "entries": entries, "entered": n_enter, "exited": n_exit, "open": len(opens), "cash": cash, "wealth": wealth, "picks": len(picks), "blocked": blocked,
89
+ "top_scores": sorted(picks, key=lambda x: -x[1])[:5], "open_mints": sorted({p["mint"] for p in opens})}
fly_trader/agent/rails.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Risk rails outside the model. KillSwitch (−KILL_SWITCH_DRAWDOWN from peak wealth → no entries), CircuitBreaker
2
+ (CIRCUIT_THRESHOLD consecutive execution failures), the rolling 24 h notional ledger, and PauseEntries (console
3
+ toggle). State lives in circuit_state / circuit_events / notional_ledger; the trading engine reads it before entries.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ from dataclasses import dataclass
10
+
11
+ from .. import config
12
+ from ..db.apilog import record_event
13
+ from ..db.connection import transaction
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class CircuitState:
20
+ fail_count: int = 0
21
+ tripped: bool = False
22
+ kill_switch: bool = False
23
+ kill_reason: str | None = None
24
+ peak_wealth: float | None = None
25
+ entries_paused: bool = False
26
+
27
+
28
+ def load_circuit(conn) -> CircuitState:
29
+ r = conn.execute("SELECT * FROM circuit_state WHERE id = 1").fetchone()
30
+ if not r:
31
+ return CircuitState()
32
+ return CircuitState(r["fail_count"], r["tripped"], r["kill_switch"], r["kill_reason"], r["peak_wealth"], r["entries_paused"])
33
+
34
+
35
+ def _event(conn, kind: str, detail: dict | None = None) -> None:
36
+ conn.execute("INSERT INTO circuit_events (kind, detail) VALUES (%s, %s)", (kind, json.dumps(detail or {}, default=str)))
37
+
38
+
39
+ def record_failure(conn, reason: str) -> CircuitState:
40
+ r = conn.execute(
41
+ "UPDATE circuit_state SET fail_count = fail_count + 1, last_failure_ts = now(), updated_at = now() WHERE id = 1 RETURNING fail_count, tripped"
42
+ ).fetchone()
43
+ tripped = r["tripped"]
44
+ if r["fail_count"] >= config.CIRCUIT_THRESHOLD and not tripped:
45
+ conn.execute("UPDATE circuit_state SET tripped = true, updated_at = now() WHERE id = 1")
46
+ _event(conn, "tripped", {"reason": reason, "fail_count": r["fail_count"]})
47
+ record_event("error", "rails", "circuit breaker tripped", {"reason": reason})
48
+ return load_circuit(conn)
49
+
50
+
51
+ def record_success(conn) -> None:
52
+ conn.execute("UPDATE circuit_state SET fail_count = 0, updated_at = now() WHERE id = 1")
53
+
54
+
55
+ def check_drawdown(conn, wealth: float, peak: float) -> bool:
56
+ """Trip the kill switch when ``wealth`` has fallen KILL_SWITCH_DRAWDOWN below ``peak`` (the book's own peak: the
57
+ trading engine passes the highest wealth mark of the current run). Returns whether the kill switch is on."""
58
+ st = load_circuit(conn)
59
+ if st.kill_switch:
60
+ return True
61
+ if peak > 0 and wealth <= peak * (1.0 - config.KILL_SWITCH_DRAWDOWN):
62
+ conn.execute("UPDATE circuit_state SET kill_switch = true, kill_reason = %s, peak_wealth = %s, updated_at = now() WHERE id = 1",
63
+ (f"wealth {wealth:.4f} SOL <= {1 - config.KILL_SWITCH_DRAWDOWN:.2f} x peak {peak:.4f} SOL", peak))
64
+ _event(conn, "kill_switch", {"wealth": wealth, "peak": peak})
65
+ record_event("error", "rails", "kill switch tripped: entries blocked", {"wealth": wealth, "peak": peak})
66
+ return True
67
+ return False
68
+
69
+
70
+ def check_book_drawdown(conn, book: str, wealth: float, peak: float) -> bool:
71
+ """A paper race book's own kill switch: KILL_SWITCH_DRAWDOWN below the book's peak halts that book's entries only,
72
+ so one book's drawdown never stops the other during the race. Returns whether the book is halted."""
73
+ if book_halted(conn, book):
74
+ return True
75
+ if peak > 0 and wealth <= peak * (1.0 - config.KILL_SWITCH_DRAWDOWN):
76
+ reason = f"wealth {wealth:.4f} SOL <= {1 - config.KILL_SWITCH_DRAWDOWN:.2f} x peak {peak:.4f} SOL"
77
+ conn.execute("INSERT INTO book_state (book, halted, reason, peak, updated_at) VALUES (%s, true, %s, %s, now()) "
78
+ "ON CONFLICT (book) DO UPDATE SET halted = true, reason = EXCLUDED.reason, peak = EXCLUDED.peak, updated_at = now()",
79
+ (book, reason, peak))
80
+ _event(conn, "book_halt", {"book": book, "wealth": wealth, "peak": peak})
81
+ record_event("error", "rails", f"{book}: drawdown halt, entries blocked", {"wealth": wealth, "peak": peak})
82
+ return True
83
+ return False
84
+
85
+
86
+ def book_halted(conn, book: str) -> bool:
87
+ r = conn.execute("SELECT halted FROM book_state WHERE book = %s", (book,)).fetchone()
88
+ return bool(r and r["halted"])
89
+
90
+
91
+ def clear_book_halt(book: str) -> None:
92
+ with transaction() as conn:
93
+ conn.execute("UPDATE book_state SET halted = false, reason = NULL, peak = NULL, updated_at = now() WHERE book = %s", (book,))
94
+ _event(conn, "book_resumed", {"book": book})
95
+ record_event("info", "rails", f"{book}: drawdown halt cleared")
96
+
97
+
98
+ def notional_24h(conn) -> float:
99
+ r = conn.execute("SELECT COALESCE(sum(sol), 0) AS s FROM notional_ledger WHERE ts > now() - interval '24 hours'").fetchone()
100
+ return float(r["s"])
101
+
102
+
103
+ def record_notional(conn, sol: float, kind: str = "entry") -> None:
104
+ conn.execute("INSERT INTO notional_ledger (sol, kind) VALUES (%s, %s)", (sol, kind))
105
+
106
+
107
+ def reset_circuit(kill: bool = False) -> None:
108
+ with transaction() as conn:
109
+ conn.execute("UPDATE circuit_state SET fail_count = 0, tripped = false, updated_at = now() WHERE id = 1")
110
+ _event(conn, "reset", {"kill": kill})
111
+ if kill:
112
+ conn.execute("UPDATE circuit_state SET kill_switch = false, kill_reason = NULL, peak_wealth = NULL, updated_at = now() WHERE id = 1")
113
+ record_event("info", "rails", "circuit reset" + (" incl. kill switch (peak re-based)" if kill else ""))
114
+ print("circuit reset" + (" + kill switch cleared" if kill else ""))
115
+
116
+
117
+ def set_entries_paused(paused: bool) -> None:
118
+ with transaction() as conn:
119
+ conn.execute("UPDATE circuit_state SET entries_paused = %s, updated_at = now() WHERE id = 1", (paused,))
120
+ _event(conn, "paused" if paused else "resumed", {})
121
+ record_event("info", "rails", "entries paused" if paused else "entries resumed")
122
+ print("entries paused" if paused else "entries resumed")
fly_trader/agent/runner.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The `runner` worker: the selector session (``agent/selector_session.py``) trading the paper book every minute on
2
+ the market feed. One runner per database (a Postgres advisory lock). ``LIVE_ENABLED=1`` passes the signing guard first."""
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from .. import config
8
+ from ..db.apilog import record_event
9
+ from ..db.connection import connect
10
+ from ..logging_setup import setup
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ RUNNER_LOCK_KEY = 0x666C795F72756E # "fly_run": one runner per database, held for the runner's lifetime
16
+
17
+
18
+ def acquire_runner_lock():
19
+ """Session advisory lock on a dedicated autocommit connection; closing the connection releases it.
20
+ Raises RuntimeError when another runner (process or console thread) already holds it."""
21
+ conn = connect(autocommit=True)
22
+ try:
23
+ ok = conn.execute("SELECT pg_try_advisory_lock(%s) AS ok", (RUNNER_LOCK_KEY,)).fetchone()["ok"]
24
+ except Exception:
25
+ conn.close()
26
+ raise
27
+ if not ok:
28
+ conn.close()
29
+ raise RuntimeError("another runner holds the runner lock; refusing to start a second one")
30
+ return conn
31
+
32
+
33
+ def main(stop_event=None) -> None:
34
+ setup("runner")
35
+ try:
36
+ lock_conn = acquire_runner_lock()
37
+ except RuntimeError as e:
38
+ log.error("%s", e)
39
+ record_event("error", "runner", str(e))
40
+ raise
41
+ try:
42
+ _main(stop_event)
43
+ finally:
44
+ lock_conn.close()
45
+
46
+
47
+ def _main(stop_event=None) -> None:
48
+ live = False
49
+ if config.LIVE_ENABLED:
50
+ from ..chain.cluster_guard import SigningRefused, assert_signing_allowed
51
+ try:
52
+ assert_signing_allowed(); live = True
53
+ except SigningRefused as e:
54
+ if config.SOLANA_CLUSTER != "mainnet-beta":
55
+ raise # a wrong cluster is a configuration error, never a wait
56
+ live = True # the per-minute gate (fly_session._live) keeps it in paper until the wallet is ready
57
+ log.error("LIVE_ENABLED=1 but %s -- trading paper only until that is fixed", e)
58
+ record_event("error", "runner", "live trading requested but not possible yet: holding in paper", {"why": str(e)})
59
+ if config.RESET_ON_START: # a fresh runner never carries over a previous run's books or stats
60
+ from ..ops.reset import reset_training_state
61
+ reset_training_state(reason="runner start")
62
+ from . import minute_engine
63
+ record_event("info", "runner", "runner started", {"live": live})
64
+ minute_engine.main(stop_event=stop_event, live=live)
fly_trader/agent/selector_session.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The selector's book: the GBM selector trading ``paper_selector`` on the minute engine (agent/minute_engine.py).
2
+
3
+ Each minute the engine's eligible rows (full feature vectors) are scored by the deployed selector (``brain_snapshots``
4
+ kind 'selector') with its columns picked by name; the book trades them with the shared paper rules
5
+ (agent/paper_trading.py): scores at or above the model's line open positions sized from its measured certainty, held
6
+ ``horizon_min`` minutes, then sold at the last traded price (the training label's exit). A newer deployable model is
7
+ picked up without a restart (``maybe_reload``). After the handover (agent/handover.py) the book stops entering while the
8
+ fly trades, exits to flat and retires.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+
17
+ import numpy as np
18
+
19
+ from ..db.apilog import record_event
20
+ from ..db.connection import transaction
21
+ from ..execution import ledger
22
+ from ..execution.broker_paper import PaperBroker
23
+ from ..train.decisions import X_COLS
24
+ from . import handover, paper_trading
25
+
26
+ log = logging.getLogger(__name__)
27
+ BOOK, KIND = "paper_selector", "selector"
28
+
29
+
30
+ class NoModel(RuntimeError):
31
+ """No selector has qualified to trade (current data and a profitable backtest after costs)."""
32
+
33
+
34
+ def _columns(model) -> np.ndarray:
35
+ missing = [c for c in model.cols if c not in X_COLS]
36
+ if missing:
37
+ raise RuntimeError(f"selector expects features the live engine does not produce: {missing}")
38
+ return np.asarray([X_COLS.index(c) for c in model.cols], dtype=int)
39
+
40
+
41
+ def pinned_snapshot() -> int | None:
42
+ """The selector snapshot the operator has pinned, or None. A pin freezes the book on one model: a forward test that
43
+ silently swapped models mid-run could not say which one earned the result."""
44
+ with transaction() as conn:
45
+ r = conn.execute("SELECT value FROM ui_settings WHERE key = 'pinned_selector_snapshot'").fetchone()
46
+ if not r:
47
+ return None
48
+ v = r["value"]
49
+ v = v.get("id") if isinstance(v, dict) else json.loads(v or "{}").get("id")
50
+ return int(v) if v is not None else None
51
+
52
+
53
+ class SelectorBook:
54
+ name = "selector"
55
+
56
+ def __init__(self):
57
+ from ..train import selector as sel
58
+ pin = pinned_snapshot()
59
+ self.model = sel.load_snapshot(pin) if pin is not None else sel.load_latest()
60
+ if self.model is None:
61
+ raise NoModel("no model has qualified to trade yet (trained on the current data, with a backtest that made money after costs and beat random picks)")
62
+ self.idx = _columns(self.model)
63
+ self.horizon_s = self.model.horizon_min * 60
64
+ self.broker = PaperBroker(BOOK); self.done = False
65
+ self.run_id = str(uuid.uuid4()); self.beat_no = 0
66
+ with transaction() as conn:
67
+ self.snapshot_id = pin if pin is not None else sel.latest_current(conn)["id"] # the snapshot actually loaded
68
+ conn.execute("INSERT INTO runs (run_id, kind, config, brain_snapshot_id, status) VALUES (%s,%s,%s,%s,'running')",
69
+ (self.run_id, KIND, json.dumps({"threshold": self.model.threshold, "horizon_min": self.model.horizon_min, "book": BOOK}, default=str), self.snapshot_id))
70
+ record_event("info", "selector", "selector session started", {"run_id": self.run_id, "snapshot": self.snapshot_id, "threshold": self.model.threshold, "horizon_min": self.model.horizon_min})
71
+ log.info("selector session: snapshot %d threshold %.4f horizon %d min", self.snapshot_id, self.model.threshold, self.model.horizon_min)
72
+
73
+ def maybe_reload(self) -> bool:
74
+ """Switch to a newer deployable model without a restart (the paper book and open positions carry on). While a
75
+ snapshot is pinned (``ui_settings['pinned_selector_snapshot']``) the book stays on it, so a forward test measures
76
+ one model instead of a blend of every retrain that lands during it."""
77
+ import joblib
78
+ from ..train import selector as sel
79
+ if pinned_snapshot() is not None:
80
+ return False
81
+ with transaction() as conn:
82
+ r = sel.latest_current(conn)
83
+ if not r or r["id"] == self.snapshot_id:
84
+ return False
85
+ m = joblib.load(r["path"])
86
+ try:
87
+ idx = _columns(m)
88
+ except RuntimeError as e:
89
+ log.error("model #%d: %s; keeping #%s", r["id"], e, self.snapshot_id)
90
+ return False
91
+ old = self.snapshot_id; self.model, self.idx, self.snapshot_id, self.horizon_s = m, idx, r["id"], m.horizon_min * 60
92
+ with transaction() as conn:
93
+ conn.execute("UPDATE runs SET brain_snapshot_id = %s, config = %s WHERE run_id = %s",
94
+ (r["id"], json.dumps({"threshold": m.threshold, "horizon_min": m.horizon_min, "book": BOOK}, default=str), self.run_id))
95
+ record_event("info", "selector", f"switched to model #{r['id']}", {"from": old, "to": r["id"], "threshold": m.threshold})
96
+ log.info("switched from model #%s to #%d (threshold %.4f)", old, r["id"], m.threshold)
97
+ return True
98
+
99
+ def on_bars(self, t_start: float, bars: dict) -> None:
100
+ pass
101
+
102
+ def open_mints(self, conn) -> set[str]:
103
+ return {p["mint"] for p in ledger.open_positions(conn, BOOK)}
104
+
105
+ def _decide(self, ctx) -> dict | None:
106
+ """The strategy stack's per-row decision (train/strategies.decide), with the live fail-closed rules: rows whose
107
+ inputs training had but live lacks are blocked — the skill table of the day missing while skill inputs are used, a
108
+ token graduated inside the archive whose curve facts are not known yet while rug inputs are used."""
109
+ if not (getattr(self.model, "stack", None) or {}).get("strategies") or not len(ctx.mints):
110
+ return None
111
+ d = self.model.decide(ctx.X, X_COLS, ctx.t_start)
112
+ groups = set((getattr(self.model, "metrics", None) or {}).get("groups") or [])
113
+ for k, i in enumerate(ctx.infos):
114
+ if not d["allow"][k]:
115
+ continue
116
+ if "skill" in groups and i.get("skill_missing"):
117
+ d["allow"][k] = False; d["reason"][k] = "fail closed: no wallet-skill table for today"
118
+ elif "rug" in groups and i.get("age_h") is not None and not i.get("curve_known"):
119
+ d["allow"][k] = False; d["reason"][k] = "fail closed: curve facts not known yet"
120
+ return d
121
+
122
+ def on_minute(self, ctx) -> dict:
123
+ conn = ctx.conn
124
+ d = self._decide(ctx)
125
+ if d is not None:
126
+ scores = np.where(np.isfinite(d["score"]), d["score"], -1.0); thr = d["threshold"]
127
+ else:
128
+ scores = self.model.score(ctx.X[:, self.idx]) if len(ctx.mints) else np.array([]); thr = self.model.threshold
129
+ summary = {"minute": ctx.m1.isoformat(), "mints_traded": len(ctx.agg), "eligible": len(ctx.mints),
130
+ "picks": int((scores >= thr).sum()) if len(scores) else 0,
131
+ "score_p99": float(np.percentile(scores, 99)) if len(scores) else None, "score_max": float(scores.max()) if len(scores) else None,
132
+ "threshold": self.model.threshold, "snapshot": self.snapshot_id,
133
+ "strategies": sorted(((getattr(self.model, "stack", None) or {}).get("strategies") or {"ev": None}).keys())}
134
+ if not ctx.trade:
135
+ return summary
136
+ if not ctx.fresh:
137
+ status = {**summary, "stage": "stream stale: holding (no entries or exits)", "updated_at": datetime.now(timezone.utc).isoformat()}
138
+ self._write_status(conn, status)
139
+ return status
140
+ handed = handover.state(conn) is not None and ctx.engine.has_book("fly")
141
+ self.beat_no += 1
142
+ extra = {} if d is None else {"holds": d["hold_s"], "strategies": d["strategy"], "allow": d["allow"], "reasons": d["reason"], "tables": d["tables"]}
143
+ st = paper_trading.trade_minute(ctx, book=BOOK, run_id=self.run_id, beat_no=self.beat_no, broker=self.broker, kind=KIND, mints=ctx.mints, infos=ctx.infos,
144
+ scores=scores, threshold=thr, table=getattr(self.model, "sizing", None), horizon_s=self.horizon_s,
145
+ block="handed over to the fly" if handed else None, **extra)
146
+ if handed and st["open"] == 0:
147
+ self.done = True
148
+ record_event("info", "selector", "selector retired: the fly holds the seat and the book is flat", {"run_id": self.run_id})
149
+ status = {**summary, **st, "stage": "retired (handed over to the fly)" if self.done else ("exiting to flat (handed over to the fly)" if handed else "trading"),
150
+ "latency_s": (datetime.now(timezone.utc) - ctx.m1).total_seconds(), "updated_at": datetime.now(timezone.utc).isoformat()}
151
+ self._write_status(conn, status)
152
+ return status
153
+
154
+ @staticmethod
155
+ def _write_status(conn, status: dict) -> None:
156
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES ('selector_status', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
157
+ (json.dumps(status, default=str),))
158
+
159
+ def finish(self) -> None:
160
+ with transaction() as conn:
161
+ conn.execute("UPDATE runs SET ended_at = now(), status = 'finished' WHERE run_id = %s", (self.run_id,))
162
+ record_event("info", "selector", "selector session stopped", {"run_id": self.run_id, "minutes": self.beat_no})
fly_trader/agent/sizing.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Position sizing: how much of the bankroll a buy gets, from how certain the model is that the trade will profit.
2
+
3
+ Certainty is measured, not assumed. During training the backtest's out-of-sample trades are grouped by how far their
4
+ score cleared the buy line (``build_table``: equal-count bands of ``score − threshold``). For each band the growth-optimal
5
+ (Kelly) fraction is found on the band's actual net returns — the bet f that maximises the average of log(1 + f·r), so the
6
+ rare −100 % rugs count in full; a band with no edge gets 0. Live (``size_position``): the current score's band →
7
+ ``KELLY_FRACTION`` of that fraction of the deployable bankroll (wealth at cost minus the gas reserve), capped at
8
+ ``MAX_POSITION_FRACTION`` of it, ``MAX_POOL_SHARE`` of the pool's quote reserve (bigger buys pay more price impact than
9
+ the 0.1 SOL the backtest measured) and the free cash above the reserve; below ``MIN_POSITION_SOL`` the buy is skipped.
10
+ A model without a table (trained before sizing) trades the fixed ``MAX_POSITION_SOL``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+
16
+ from .. import config
17
+
18
+ N_BANDS = 4
19
+ MIN_BAND_TRADES = 50
20
+ _F_GRID = np.linspace(0.0, 0.99, 100)
21
+
22
+
23
+ def kelly_fraction(r: np.ndarray) -> float:
24
+ """Fraction of the bankroll per bet that maximises mean log growth over the empirical net returns ``r`` (0 if no edge)."""
25
+ r = np.asarray(r, dtype=float)
26
+ r = r[np.isfinite(r)]
27
+ if len(r) == 0 or r.mean() <= 0:
28
+ return 0.0
29
+ growth = np.log1p(np.clip(_F_GRID[:, None] * r[None, :], -0.999999, None)).mean(axis=1)
30
+ return float(_F_GRID[int(np.argmax(growth))])
31
+
32
+
33
+ def build_table(margins: np.ndarray, returns: np.ndarray, n_bands: int = N_BANDS) -> list[dict]:
34
+ """Bands of score margin (score − the model's threshold) over out-of-sample trades, lowest first:
35
+ [{lo, n, mean, win, kelly}] where ``lo`` is the band's lowest margin (the first band starts at 0)."""
36
+ m = np.asarray(margins, dtype=float); r = np.asarray(returns, dtype=float)
37
+ ok = np.isfinite(m) & np.isfinite(r); m, r = m[ok], r[ok]
38
+ if len(r) < MIN_BAND_TRADES:
39
+ return []
40
+ k = max(1, min(n_bands, len(r) // MIN_BAND_TRADES))
41
+ edges = np.quantile(m, np.linspace(0, 1, k + 1)); edges[0] = 0.0
42
+ out = []
43
+ for b in range(k):
44
+ sel = (m >= edges[b]) & ((m < edges[b + 1]) if b < k - 1 else np.ones(len(m), bool))
45
+ rb = r[sel]
46
+ out.append({"lo": float(edges[b]), "n": int(len(rb)), "mean": float(rb.mean()) if len(rb) else None,
47
+ "win": float((rb > 0).mean()) if len(rb) else None, "kelly": kelly_fraction(rb)})
48
+ return out
49
+
50
+
51
+ def band_for(table: list[dict], margin: float) -> dict | None:
52
+ best = None
53
+ for b in table:
54
+ if margin >= b["lo"]:
55
+ best = b
56
+ return best
57
+
58
+
59
+ def size_position(score: float, threshold: float, table: list[dict] | None, bankroll_sol: float, cash_sol: float,
60
+ res_quote_sol: float | None) -> tuple[float, str]:
61
+ """(size in SOL, why). Size 0 means skip the buy."""
62
+ reserve = config.GAS_RESERVE_SOL; free = cash_sol - reserve
63
+ if not table:
64
+ size = config.MAX_POSITION_SOL
65
+ return (size, f"fixed {size:g} SOL (model has no sizing table)") if free >= size else (0.0, "not enough free cash")
66
+ b = band_for(table, score - threshold)
67
+ if b is None or b["kelly"] <= 0:
68
+ return 0.0, "no edge in this score band"
69
+ deployable = max(0.0, bankroll_sol - reserve)
70
+ size = config.KELLY_FRACTION * b["kelly"] * deployable
71
+ caps = {"bankroll cap": config.MAX_POSITION_FRACTION * deployable, "free cash": free}
72
+ if res_quote_sol:
73
+ caps["pool depth"] = config.MAX_POOL_SHARE * res_quote_sol
74
+ limit = min(caps, key=caps.get)
75
+ why = f"band from +{b['lo']:.3f} over the line: Kelly {b['kelly']:.2f} × {config.KELLY_FRACTION:g} of {deployable:.2f} SOL"
76
+ if caps[limit] < size:
77
+ size = caps[limit]; why += f", capped by {limit}"
78
+ if size < config.MIN_POSITION_SOL:
79
+ return 0.0, why + f" → {size:.3f} SOL is below the {config.MIN_POSITION_SOL:g} SOL minimum"
80
+ return float(size), why + f" → {size:.3f} SOL"
81
+
82
+
83
+ def simulate_bankroll(ts: np.ndarray, horizon_s, margins: np.ndarray, returns: np.ndarray, table: list[dict] | None,
84
+ start_sol: float | None = None, res_quote: np.ndarray | None = None, tables: list | None = None) -> dict:
85
+ """Replay out-of-sample trades in entry order with the sizing rule (positions overlap for ``horizon_s``; cash, the
86
+ reserve and — given ``res_quote``, each trade's pool quote reserve — the pool-depth cap are enforced, as live).
87
+ Returns the final bankroll, its multiple and the worst drawdown."""
88
+ start = float(start_sol or config.CAPITAL_SOL or 1.0); cash = start; open_: list[tuple[float, float, float]] = [] # (exit_ts, cost, return)
89
+ hv = np.asarray(horizon_s, dtype=np.float64); hold = np.full(len(ts), float(hv)) if hv.ndim == 0 else hv # per-trade holds allowed
90
+ peak = start; mdd = 0.0; n = 0
91
+ for i in np.argsort(ts, kind="stable"):
92
+ t = float(ts[i])
93
+ still = []
94
+ for x in open_:
95
+ if x[0] <= t:
96
+ cash += x[1] * (1 + x[2])
97
+ else:
98
+ still.append(x)
99
+ open_ = still
100
+ bankroll = cash + sum(x[1] for x in open_)
101
+ size, _ = size_position(float(margins[i]), 0.0, tables[i] if tables is not None else table, bankroll, cash, float(res_quote[i]) if res_quote is not None else None)
102
+ if size > 0:
103
+ cash -= size; open_.append((t + float(hold[i]), size, float(returns[i]))); n += 1
104
+ w = cash + sum(x[1] for x in open_); peak = max(peak, w); mdd = max(mdd, 1 - w / peak if peak > 0 else 0.0)
105
+ final = cash + sum(x[1] * (1 + x[2]) for x in open_)
106
+ return {"start_sol": start, "final_sol": final, "multiple": final / start if start > 0 else None, "max_drawdown": mdd, "trades": n}
fly_trader/brain/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
fly_trader/brain/connectome.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The FlyWire connectome the fly (``train/fly_selector.py``) is built on: the artifact written by ``build-connectome``
2
+ (``brain/connectome_build.py``), loaded as signed synapse counts with population ranges, and restricted to a set of
3
+ populations by ``SubConnectome``.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import torch
13
+
14
+ from .. import config
15
+
16
+ CONNECTOME_DIR = config.BRAIN_DIR / "connectome"
17
+ CURRENT_FILE = CONNECTOME_DIR / "current.txt"
18
+ SCALE_FILE = CONNECTOME_DIR / "scale.json"
19
+ AFFERENT_POPS = ("ORN_FOOD", "ORN_DANGER", "GRN_SWEET", "GRN_BITTER", "GRN_OTHER", "MECH_JO", "MECH_BRISTLE",
20
+ "MECH_OTHER", "THERMO_WARM", "THERMO_COOL", "THERMO_OTHER") # sensory neurons: where the features enter
21
+ EFFERENT_POP = "DESCENDING" # brain-to-body neurons: where the decoder reads
22
+
23
+
24
+ def resolve_device(name: str | None = None) -> torch.device:
25
+ """config.DEVICE with automatic fallback to CPU when MPS (or CUDA) is unavailable."""
26
+ name = (name or config.DEVICE or "cpu").lower()
27
+ if name.startswith("mps"):
28
+ if torch.backends.mps.is_available():
29
+ return torch.device("mps")
30
+ print("[connectome] MPS unavailable; falling back to cpu", file=sys.stderr)
31
+ return torch.device("cpu")
32
+ if name.startswith("cuda"):
33
+ if torch.cuda.is_available():
34
+ return torch.device(name)
35
+ print("[connectome] CUDA unavailable; falling back to cpu", file=sys.stderr)
36
+ return torch.device("cpu")
37
+ return torch.device("cpu")
38
+
39
+
40
+ def current_connectome_path() -> Path:
41
+ if not CURRENT_FILE.exists():
42
+ raise FileNotFoundError(f"{CURRENT_FILE} missing: run `fly-trader build-connectome` first")
43
+ name = CURRENT_FILE.read_text().strip()
44
+ path = Path(name) if Path(name).is_absolute() else CONNECTOME_DIR / name
45
+ if not path.exists():
46
+ raise FileNotFoundError(f"connectome {path} named in current.txt does not exist")
47
+ return path
48
+
49
+
50
+ def _scale_for(connectome_file: str) -> dict | None:
51
+ """The calibrated scale if scale.json exists and refers to this artifact."""
52
+ if not SCALE_FILE.exists():
53
+ return None
54
+ try:
55
+ d = json.loads(SCALE_FILE.read_text())
56
+ except Exception:
57
+ return None
58
+ return d if d.get("connectome") in (connectome_file, Path(connectome_file).name) else None
59
+
60
+
61
+ MB_POPS = ("KC", "MBON_APP", "MBON_AV", "MBON_OTHER") # the mushroom-body block the build keeps apart from the sparse graph
62
+ DAN_POPS = ("DAN_PAM", "DAN_PPL1", "DAN_OTHER")
63
+
64
+
65
+ def mbon_compartments(indices: torch.Tensor, values_raw: torch.Tensor, pop_ranges: dict, cell_type=None) -> tuple[np.ndarray, list[str]]:
66
+ """Each MBON column's compartment: the dopamine cell type (or, without cell types, the DAN population) sending it the
67
+ most synapse mass in the raw graph — before scale.json's gains silence DAN→MBON as fast edges (Aso et al. 2014: a
68
+ compartment = the DAN type innervating that MBON's dendrites). −1 when no DAN reaches the MBON."""
69
+ if not all(p in pop_ranges for p in ("MBON_APP", "MBON_OTHER")):
70
+ return np.zeros(0, int), []
71
+ m0, m1 = pop_ranges["MBON_APP"][0], pop_ranges["MBON_OTHER"][1]
72
+ post, pre = indices[0].numpy(), indices[1].numpy(); w = np.abs(values_raw.numpy())
73
+ dan = np.zeros(len(pre), bool); label = np.full(len(pre), "", dtype=object)
74
+ for pop in DAN_POPS:
75
+ if pop in pop_ranges:
76
+ a, b = pop_ranges[pop]; sel = (pre >= a) & (pre < b); dan |= sel
77
+ label[sel] = np.asarray(cell_type, dtype=object)[pre[sel]] if cell_type is not None else pop
78
+ e = dan & (post >= m0) & (post < m1)
79
+ names = sorted({str(x) or "DAN" for x in label[e]})
80
+ comp = np.full(m1 - m0, -1, int)
81
+ if not e.any():
82
+ return comp, names
83
+ mass = np.zeros((m1 - m0, len(names))); col = {n: i for i, n in enumerate(names)}
84
+ np.add.at(mass, (post[e] - m0, np.array([col[str(x) or "DAN"] for x in label[e]])), w[e])
85
+ has = mass.sum(1) > 0; comp[has] = mass[has].argmax(1)
86
+ return comp, names
87
+
88
+
89
+ def apply_pathway_gains(indices: torch.Tensor, values_raw: torch.Tensor, pop_ranges: dict, gains: dict) -> tuple[torch.Tensor, torch.Tensor]:
90
+ """Multiply the edges from population A to population B by ``gains['A->B']``; edges scaled to 0 are dropped.
91
+ scale.json silences the direct DAN→MBON/KC edges: dopamine acts on the mushroom body through KC→MBON plasticity,
92
+ not fast excitation (Hige 2015, Handler 2019)."""
93
+ post, pre = indices[0], indices[1]
94
+ v = values_raw.clone()
95
+ for key, g in (gains or {}).items():
96
+ a, _, b = key.partition("->")
97
+ if a not in pop_ranges or b not in pop_ranges:
98
+ continue
99
+ (a0, a1), (b0, b1) = pop_ranges[a], pop_ranges[b]
100
+ v[(pre >= a0) & (pre < a1) & (post >= b0) & (post < b1)] *= float(g)
101
+ keep = v != 0
102
+ return indices[:, keep], v[keep]
103
+
104
+
105
+ class Connectome:
106
+ """Signed synapse counts ``values_raw`` [E] on edges ``indices`` [2, E] (row = post, col = pre) over ``N`` neurons,
107
+ population ``pop_ranges``, and the weight scale ``s`` (scale.json when it refers to this artifact, else the build's
108
+ s0). The KC→MBON synapses are not in the sparse graph: the build keeps them as the dense block ``W_KM0``
109
+ [n_KC, n_MBON] (synapse counts; MBON columns = MBON_APP, MBON_AV, MBON_OTHER in population order) with the mask
110
+ ``M_KM`` of the pairs that exist. scale.json's ``pathway_gains`` are applied to the sparse graph. Tensors stay on
111
+ the CPU; ``device`` is where models built on it should run."""
112
+
113
+ def __init__(self, path: str | Path | None = None, device: torch.device | str | None = None):
114
+ path = Path(path) if path else current_connectome_path()
115
+ z = np.load(path, allow_pickle=False)
116
+ self.path = path
117
+ self.N = int(z["N"])
118
+ self.pop_ranges = {k: (int(a), int(b)) for k, (a, b) in json.loads(str(z["pop_ranges"])).items()}
119
+ scale = _scale_for(path.name)
120
+ self.pathway_gains = dict((scale or {}).get("pathway_gains") or {})
121
+ raw_i = torch.from_numpy(np.ascontiguousarray(z["indices"])).to(torch.int64); raw_v = torch.from_numpy(np.ascontiguousarray(z["values_raw"])).to(torch.float32)
122
+ self.mbon_compartment, self.compartment_names = mbon_compartments(raw_i, raw_v, self.pop_ranges, z["cell_type"] if "cell_type" in z.files else None)
123
+ self.indices, self.values_raw = apply_pathway_gains(torch.from_numpy(np.ascontiguousarray(z["indices"])).to(torch.int64),
124
+ torch.from_numpy(np.ascontiguousarray(z["values_raw"])).to(torch.float32),
125
+ self.pop_ranges, self.pathway_gains)
126
+ self.W_KM0 = torch.from_numpy(np.ascontiguousarray(z["W_KM0"])).to(torch.float32)
127
+ self.M_KM = torch.from_numpy(np.ascontiguousarray(z["M_KM"])).to(torch.bool)
128
+ self.spectral_radius_raw = float(z["spectral_radius_raw"])
129
+ self.content_sha256 = str(z["content_sha256"])
130
+ self.s = float(scale["s"]) if scale and "s" in scale else float(z["s0"])
131
+ self.device = device if isinstance(device, torch.device) else resolve_device(device)
132
+
133
+ @classmethod
134
+ def load(cls, path: str | Path | None = None, device: torch.device | str | None = None) -> "Connectome":
135
+ """The artifact named in data/brain/connectome/current.txt (or ``path``)."""
136
+ return cls(path, device=device)
137
+
138
+
139
+ class SubConnectome:
140
+ """A connectome restricted to a set of populations (e.g. everything but the optic lobes)."""
141
+
142
+ def __init__(self, c, exclude: tuple[str, ...] = ("VISUAL",)):
143
+ keep = torch.ones(c.N, dtype=torch.bool)
144
+ for name in exclude:
145
+ if name in c.pop_ranges:
146
+ lo, hi = c.pop_ranges[name]
147
+ keep[lo:hi] = False
148
+ new_index = torch.cumsum(keep.to(torch.int64), 0) - 1
149
+ post, pre = c.indices[0], c.indices[1]
150
+ m = keep[post] & keep[pre]
151
+ self.indices = torch.stack([new_index[post[m]], new_index[pre[m]]])
152
+ self.values_raw = c.values_raw[m].clone()
153
+ self.N = int(keep.sum())
154
+ self.pop_ranges = {}
155
+ for name, (lo, hi) in c.pop_ranges.items():
156
+ if name in exclude or hi <= lo:
157
+ continue
158
+ self.pop_ranges[name] = (int(new_index[lo]), int(new_index[hi - 1]) + 1)
159
+ self.device = c.device
160
+ self.s = getattr(c, "s", None)
161
+ self.spectral_radius_raw = getattr(c, "spectral_radius_raw", None)
162
+ self.content_sha256 = getattr(c, "content_sha256", None)
163
+ lost = [p for p in MB_POPS if p in c.pop_ranges and not bool(keep[c.pop_ranges[p][0]:c.pop_ranges[p][1]].all())]
164
+ if lost:
165
+ raise ValueError(f"a sub-connectome must keep the whole mushroom body (KC→MBON block); excluded: {lost}")
166
+ self.W_KM0 = getattr(c, "W_KM0", None) # unchanged: KC and MBON rows keep their order and ranges
167
+ self.mbon_compartment = getattr(c, "mbon_compartment", None); self.compartment_names = getattr(c, "compartment_names", [])
168
+ self.M_KM = getattr(c, "M_KM", None)
169
+ self.pathway_gains = getattr(c, "pathway_gains", {})
170
+ self.excluded = exclude
171
+ self.keep = keep
fly_trader/brain/connectome_build.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the FAFB v783 connectome artifact (``fly-trader build-connectome``).
2
+
3
+ Inputs (data/brain/raw/): flybrain ``connections.csv.gz`` (pre_root_id, post_root_id, neuropil,
4
+ syn_count, nt_type; one row per (pre, post, neuropil)), ``neurons.csv.gz``, ``classification.csv.gz``
5
+ and the Schlegel et al. 2024 annotation TSV. Output: ``data/brain/connectome/fafb783_<sha12>.npz``
6
+ (COO ``indices`` int64[2, nnz] with row = post, col = pre, sorted; ``values`` float32 pre-calibration
7
+ weights; ``values_raw`` = signed synapse counts; the dense plastic KC->MBON block ``W_KM0``) plus
8
+ ``current.txt`` naming the artifact, the ``populations`` table and a ``runs`` row.
9
+
10
+ Sign convention (config.NT_SIGN_MODE): ``shiu`` = GABA and glutamate inhibitory, everything else
11
+ excitatory (Shiu et al. 2024 Nature 634:210); ``flybrain`` = only GABA inhibitory (flybrain's
12
+ NT_SIGN). Weights are summed over neuropils per (pre, post) pair with the edge's own nt_type
13
+ (nt_type varies within a pre neuron in FlyWire's connection table); zero sums are dropped, as
14
+ flybrain does, which reproduces its 2,698,236 edges in ``flybrain`` mode.
15
+
16
+ Scale: s0 = 0.99 / rho(|W_raw|) (spectral radius by shifted power iteration) is only the INITIAL
17
+ guess, motivated by Costi et al. 2025 (PMC12109256), who ran the FlyWire connectome as an echo-state
18
+ reservoir rescaled to rho = 0.99 (tanh units, glutamate treated as excitatory there). The fly
19
+ (``train/fly_selector.py``) initialises its synaptic magnitudes from s (``scale.json`` when present, else s0).
20
+ KC->MBON magnitudes are forced positive because KC output synapses are cholinergic (Barnstedt et
21
+ al. 2016 Neuron), regardless of FlyWire's per-cell NT prediction.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import math
28
+ import os
29
+ import sys
30
+ import time
31
+ import uuid
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ import warnings
36
+
37
+ import numpy as np
38
+ import pyarrow as pa
39
+ import pyarrow.csv as pc
40
+ import torch
41
+
42
+ warnings.filterwarnings("ignore", message="Sparse CSR tensor support is in beta state")
43
+ warnings.filterwarnings("ignore", message="Sparse invariant checks are implicitly disabled")
44
+
45
+ from .. import config
46
+ from . import populations as P
47
+
48
+ CONNECTIONS_CSV = P.RAW_DIR / "connections.csv.gz"
49
+ CONNECTOME_DIR = config.BRAIN_DIR / "connectome"
50
+ CURRENT_FILE = CONNECTOME_DIR / "current.txt"
51
+ EXPECTED_EDGES = 2_698_236
52
+ W_CLIP = 1.0 # = theta (plan section 1.7)
53
+ TARGET_RADIUS = 0.99 # reservoir operating point (Vienna 2025)
54
+ POWER_ITER_MAX = 5000
55
+ POWER_ITER_TOL = 1e-9
56
+ SOURCE_FILES = ["connections.csv.gz", "neurons.csv.gz", "classification.csv.gz",
57
+ "Supplemental_file1_neuron_annotations.tsv", "consolidated_cell_types.csv.gz", "neuron_meta.json"]
58
+
59
+
60
+ def sha256_file(path: Path) -> str:
61
+ h = hashlib.sha256()
62
+ with open(path, "rb") as f:
63
+ for chunk in iter(lambda: f.read(1 << 20), b""):
64
+ h.update(chunk)
65
+ return h.hexdigest()
66
+
67
+
68
+ def nt_sign(nt: np.ndarray, mode: str) -> np.ndarray:
69
+ """+1/-1 per edge from the edge's nt_type (flybrain vocabulary ACH/GABA/GLUT/DA/SER/OCT)."""
70
+ nt = np.char.upper(nt.astype(str))
71
+ if mode == "shiu":
72
+ neg = (nt == "GABA") | (nt == "GLUT")
73
+ elif mode == "flybrain":
74
+ neg = nt == "GABA"
75
+ else:
76
+ raise ValueError(f"unknown NT_SIGN_MODE {mode!r} (shiu | flybrain)")
77
+ return np.where(neg, -1.0, 1.0)
78
+
79
+
80
+ def read_connections(path: Path = CONNECTIONS_CSV) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
81
+ tbl = pc.read_csv(str(path), convert_options=pc.ConvertOptions(column_types={
82
+ "pre_root_id": pa.int64(), "post_root_id": pa.int64(), "neuropil": pa.string(),
83
+ "syn_count": pa.int32(), "nt_type": pa.string()}))
84
+ pre = tbl["pre_root_id"].to_numpy()
85
+ post = tbl["post_root_id"].to_numpy()
86
+ syn = tbl["syn_count"].to_numpy().astype(np.int64)
87
+ nt = tbl["nt_type"].to_numpy(zero_copy_only=False).astype(str)
88
+ return pre, post, syn, nt
89
+
90
+
91
+ def root_id_lookup(canonical_root_ids: np.ndarray):
92
+ order = np.argsort(canonical_root_ids)
93
+ srt = canonical_root_ids[order]
94
+
95
+ def lookup(x: np.ndarray) -> np.ndarray:
96
+ pos = np.searchsorted(srt, x)
97
+ pos[pos >= len(srt)] = 0
98
+ return np.where(srt[pos] == x, order[pos], -1)
99
+ return lookup
100
+
101
+
102
+ def aggregate_edges(pre: np.ndarray, post: np.ndarray, syn: np.ndarray, nt: np.ndarray, N: int, mode: str) -> dict:
103
+ """Sum syn_count * sign over neuropils per (pre, post). Returns arrays over distinct pairs."""
104
+ key = pre.astype(np.int64) * N + post.astype(np.int64)
105
+ uniq, inv = np.unique(key, return_inverse=True)
106
+ n = len(uniq)
107
+ sgn = nt_sign(nt, mode)
108
+ signed = np.bincount(inv, weights=syn * sgn, minlength=n)
109
+ signed_fb = np.bincount(inv, weights=syn * nt_sign(nt, "flybrain"), minlength=n)
110
+ unsigned = np.bincount(inv, weights=syn, minlength=n)
111
+ return {
112
+ "pre": (uniq // N).astype(np.int64), "post": (uniq % N).astype(np.int64),
113
+ "signed": signed, "unsigned": unsigned,
114
+ "n_pairs": int(n), "nnz_mode": int((signed != 0).sum()), "nnz_flybrain": int((signed_fb != 0).sum()),
115
+ }
116
+
117
+
118
+ def spectral_radius(row: np.ndarray, col: np.ndarray, values_abs: np.ndarray, N: int,
119
+ max_iter: int = POWER_ITER_MAX, tol: float = POWER_ITER_TOL) -> dict:
120
+ """rho(|W|) by power iteration on the shifted matrix |W| + I (rho(|W| + I) = rho(|W|) + 1 for a
121
+ non-negative matrix; the shift makes the iteration converge for periodic components). Also
122
+ returns the Collatz-Wielandt bracket min_i (|W|x)_i / x_i <= rho <= max_i (|W|x)_i / x_i."""
123
+ idx = torch.from_numpy(np.stack([row, col]).astype(np.int64))
124
+ W = torch.sparse_coo_tensor(idx, torch.from_numpy(values_abs.astype(np.float64)), (N, N)).coalesce().to_sparse_csr()
125
+ x = torch.full((N, 1), 1.0 / math.sqrt(N), dtype=torch.float64)
126
+ lam_prev, it, converged = 0.0, 0, False
127
+ for it in range(1, max_iter + 1):
128
+ y = W @ x + x
129
+ lam = float(torch.linalg.vector_norm(y))
130
+ x = y / lam
131
+ if abs(lam - lam_prev) <= tol * lam:
132
+ converged = True
133
+ break
134
+ lam_prev = lam
135
+ wx = (W @ x).squeeze(1)
136
+ xs = x.squeeze(1)
137
+ ratio = wx / xs
138
+ return {"rho": lam - 1.0, "iterations": it, "converged": converged,
139
+ "cw_lower": float(ratio.min()), "cw_upper": float(ratio.max())}
140
+
141
+
142
+ def content_hash(indices: np.ndarray, values_raw: np.ndarray, W_KM0: np.ndarray, root_ids: np.ndarray,
143
+ pop_ranges_json: str, nt_sign_mode: str) -> str:
144
+ h = hashlib.sha256()
145
+ for part in (indices.tobytes(), values_raw.tobytes(), W_KM0.tobytes(), root_ids.tobytes(),
146
+ pop_ranges_json.encode(), nt_sign_mode.encode()):
147
+ h.update(part)
148
+ return h.hexdigest()
149
+
150
+
151
+ def _git_sha() -> str | None:
152
+ try:
153
+ import subprocess
154
+ out = subprocess.run(["git", "rev-parse", "HEAD"], cwd=config.REPO_ROOT, capture_output=True, text=True, timeout=5)
155
+ return out.stdout.strip() or None if out.returncode == 0 else None
156
+ except Exception:
157
+ return None
158
+
159
+
160
+ def persist(pops: P.Populations, metrics: dict, build_cfg: dict, file_sha: str, started_at: datetime,
161
+ annotations_label: str) -> str:
162
+ """populations rows (id = order index) + runs row; returns the run_id."""
163
+ from psycopg.types.json import Jsonb
164
+ from ..db.connection import transaction
165
+ run_id = str(uuid.uuid4())
166
+ with transaction() as conn:
167
+ with conn.cursor() as cur:
168
+ for i, name in enumerate(pops.order):
169
+ cur.execute("DELETE FROM populations WHERE name = %s AND id <> %s", (name, i))
170
+ cur.execute(
171
+ "INSERT INTO populations (id, name, n, kind, source) VALUES (%s, %s, %s, %s, %s) "
172
+ "ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, n = EXCLUDED.n, kind = EXCLUDED.kind, "
173
+ "source = EXCLUDED.source",
174
+ (i, name, pops.n(name), pops.kind.get(name, "other"), annotations_label))
175
+ cur.execute(
176
+ "INSERT INTO runs (run_id, kind, started_at, ended_at, git_sha, config, connectome_sha256, status, metrics) "
177
+ "VALUES (%s, 'connectome_build', %s, now(), %s, %s, %s, 'done', %s)",
178
+ (run_id, started_at, _git_sha(), Jsonb(build_cfg), file_sha, Jsonb(metrics)))
179
+ if pops.source != "annotations":
180
+ cur.execute(
181
+ "INSERT INTO events (level, source, message, detail) VALUES ('warning', 'connectome_build', %s, %s)",
182
+ ("cell types from flybrain classification.csv fallback (annotation TSV unavailable); "
183
+ "MBON valence by neurotransmitter, DAN valence by synapse counts",
184
+ Jsonb({"run_id": run_id})))
185
+ return run_id
186
+
187
+
188
+ def main(annotations: str | None = None, nt_sign_mode: str | None = None, source: str | None = None,
189
+ out_dir: str | Path | None = None, persist_db: bool = True) -> Path:
190
+ started_at = datetime.now(timezone.utc)
191
+ t0 = time.perf_counter()
192
+ mode = nt_sign_mode or config.NT_SIGN_MODE
193
+ out_dir = Path(out_dir) if out_dir else CONNECTOME_DIR
194
+ out_dir.mkdir(parents=True, exist_ok=True)
195
+
196
+ # ---- source hashes ----
197
+ source_sha = {}
198
+ for name in SOURCE_FILES:
199
+ p = P.RAW_DIR / name
200
+ if p.exists():
201
+ source_sha[name] = sha256_file(p)
202
+ if annotations:
203
+ source_sha[Path(annotations).name] = sha256_file(Path(annotations))
204
+
205
+ # ---- connections (canonical flybrain indices) ----
206
+ pre_rid, post_rid, syn, nt = read_connections()
207
+ n_rows = len(pre_rid)
208
+ neu_ids = np.asarray(__import__("pandas").read_csv(P.NEURONS_CSV, usecols=["root_id"], dtype={"root_id": np.int64})["root_id"])
209
+ N = len(neu_ids)
210
+ lookup = root_id_lookup(neu_ids)
211
+ pre_c, post_c = lookup(pre_rid), lookup(post_rid)
212
+ ok = (pre_c >= 0) & (post_c >= 0)
213
+ n_skipped = int((~ok).sum())
214
+ unknown_nt = int((~np.isin(np.char.upper(nt), ["ACH", "GABA", "GLUT", "DA", "SER", "OCT", "OA"])).sum())
215
+ pre_c, post_c, syn, nt = pre_c[ok], post_c[ok], syn[ok], nt[ok]
216
+ print(f"[build] connections rows={n_rows:,} skipped_unknown_root_id={n_skipped} unknown_nt_rows={unknown_nt} "
217
+ f"({time.perf_counter() - t0:.1f}s)")
218
+
219
+ # ---- populations ----
220
+ pops = P.build_populations(annotations=annotations, source=source, edges=(pre_c, post_c, syn.astype(np.float64)))
221
+ assert pops.N == N
222
+ inv = pops.inverse_index() # canonical -> sorted position
223
+ print(f"[build] populations source={pops.source} N={N:,} ({time.perf_counter() - t0:.1f}s)")
224
+
225
+ # ---- aggregate over neuropils ----
226
+ agg = aggregate_edges(pre_c, post_c, syn, nt, N, mode)
227
+ nnz_fb, nnz_mode, n_pairs = agg["nnz_flybrain"], agg["nnz_mode"], agg["n_pairs"]
228
+ edges_match = nnz_fb == EXPECTED_EDGES
229
+ print(f"[build] distinct (pre,post) pairs={n_pairs:,}; nonzero edges: flybrain-sign={nnz_fb:,} "
230
+ f"(expected {EXPECTED_EDGES:,} -> {'MATCH' if edges_match else 'MISMATCH'}), {mode}-sign={nnz_mode:,}")
231
+ if not edges_match:
232
+ print(f"[build] WARNING flybrain-mode edge count {nnz_fb:,} != {EXPECTED_EDGES:,}; continuing", file=sys.stderr)
233
+ keep = agg["signed"] != 0
234
+ pre_s = inv[agg["pre"][keep]] # sorted (population-order) indices
235
+ post_s = inv[agg["post"][keep]]
236
+ signed = agg["signed"][keep]
237
+ unsigned = agg["unsigned"][keep]
238
+
239
+ # ---- KC -> MBON block (dense, positive magnitudes) ----
240
+ kc0, kc1 = pops.ranges["KC"]
241
+ m0 = pops.ranges["MBON_APP"][0]
242
+ m1 = pops.ranges["MBON_OTHER"][1]
243
+ assert pops.ranges["MBON_APP"][1] == pops.ranges["MBON_AV"][0] and pops.ranges["MBON_AV"][1] == pops.ranges["MBON_OTHER"][0]
244
+ n_kc, n_mbon = kc1 - kc0, m1 - m0
245
+ is_km = (pre_s >= kc0) & (pre_s < kc1) & (post_s >= m0) & (post_s < m1)
246
+ W_KM0 = np.zeros((n_kc, n_mbon), dtype=np.float32)
247
+ W_KM0[pre_s[is_km] - kc0, post_s[is_km] - m0] = np.abs(unsigned[is_km]).astype(np.float32)
248
+ M_KM = W_KM0 != 0
249
+ n_km_neg = int((signed[is_km] < 0).sum())
250
+ rest = ~is_km
251
+ row = post_s[rest]
252
+ col = pre_s[rest]
253
+ values_raw = signed[rest].astype(np.float32)
254
+ order = np.lexsort((col, row)) # coalesced: sorted by (row, col)
255
+ row, col, values_raw = row[order], col[order], values_raw[order]
256
+ indices = np.stack([row, col]).astype(np.int64)
257
+ nnz = int(len(values_raw))
258
+ print(f"[build] KC->MBON pairs={int(M_KM.sum()):,} (non-cholinergic sign on {n_km_neg} pairs ignored, magnitudes kept); "
259
+ f"sparse nnz={nnz:,} ({time.perf_counter() - t0:.1f}s)")
260
+
261
+ # ---- spectral radius and initial scale ----
262
+ sr = spectral_radius(row, col, np.abs(values_raw), N)
263
+ rho = sr["rho"]
264
+ s0 = TARGET_RADIUS / rho
265
+ values = np.clip(s0 * values_raw, -W_CLIP, W_CLIP).astype(np.float32)
266
+ print(f"[build] rho(|W_raw|)={rho:.3f} (Collatz-Wielandt upper bound {sr['cw_upper']:.3f}, "
267
+ f"{sr['iterations']} iters, converged={sr['converged']}) s0={s0:.6g} ({time.perf_counter() - t0:.1f}s)")
268
+
269
+ # ---- artifact ----
270
+ pop_ranges_json = json.dumps(pops.ranges)
271
+ chash = content_hash(indices, values_raw, W_KM0, pops.root_ids, pop_ranges_json, mode)
272
+ fname = f"fafb783_{chash[:12]}.npz"
273
+ path = out_dir / fname
274
+ build_cfg = {
275
+ "nt_sign_mode": mode, "cell_types_source": pops.source,
276
+ "annotations": str(Path(annotations) if annotations else P.ANNOTATIONS_TSV),
277
+ "w_clip": W_CLIP, "target_radius": TARGET_RADIUS, "power_iter_max": POWER_ITER_MAX, "power_iter_tol": POWER_ITER_TOL,
278
+ "expected_edges": EXPECTED_EDGES, "torch": torch.__version__, "numpy": np.__version__,
279
+ "built_at": started_at.isoformat(), "content_sha256": chash, "file": fname,
280
+ "population_order": pops.order,
281
+ }
282
+ counts = pops.counts()
283
+ metrics = {
284
+ "N": N, "rows": n_rows, "distinct_pairs": n_pairs, "nnz_flybrain_mode": nnz_fb, "nnz_mode": nnz_mode,
285
+ "edges_expected": EXPECTED_EDGES, "edges_match": edges_match, "nnz_sparse": nnz,
286
+ "skipped_rows": n_skipped, "unknown_nt_rows": unknown_nt,
287
+ "kc_mbon_pairs": int(M_KM.sum()), "kc_mbon_max_syn": float(W_KM0.max()), "kc_mbon_mean_syn": float(W_KM0[M_KM].mean()),
288
+ "spectral_radius_raw": rho, "spectral_radius_bracket": [sr["cw_lower"], sr["cw_upper"]],
289
+ "power_iterations": sr["iterations"], "power_converged": sr["converged"], "s0": s0,
290
+ "n_glomeruli": len(pops.glomerulus_names), "orn_food_untyped": int((pops.glomerulus_of_orn < 0).sum()),
291
+ "populations": counts, "cell_types_source": pops.source,
292
+ "build_seconds": None,
293
+ }
294
+ if path.exists():
295
+ print(f"[build] {path} already exists with identical content hash; keeping it")
296
+ else:
297
+ tmp = out_dir / f"{fname}.tmp.npz" # numpy appends .npz to names that lack it
298
+ np.savez_compressed(
299
+ tmp, indices=indices, values=values, values_raw=values_raw, N=np.int64(N),
300
+ pop_ranges=np.array(pop_ranges_json), pop_order=np.array(pops.order),
301
+ root_ids=pops.root_ids, nt=pops.nt.astype(str), flybrain_group=pops.flybrain_group,
302
+ flybrain_group_names=np.array(pops.flybrain_group_names), cell_type=pops.cell_type.astype(str),
303
+ W_KM0=W_KM0, M_KM=M_KM,
304
+ kc_idx=pops.index("KC"), mbon_app_idx=pops.index("MBON_APP"), mbon_av_idx=pops.index("MBON_AV"),
305
+ mbon_other_idx=pops.index("MBON_OTHER"), dan_pam_idx=pops.index("DAN_PAM"), dan_ppl1_idx=pops.index("DAN_PPL1"),
306
+ glomerulus_of_orn=pops.glomerulus_of_orn, glomerulus_names=np.array(pops.glomerulus_names, dtype=str),
307
+ spectral_radius_raw=np.float64(rho), s0=np.float64(s0), nt_sign_mode=np.array(mode),
308
+ w_clip=np.float32(W_CLIP), source_sha256=np.array(json.dumps(source_sha)),
309
+ build_config=np.array(json.dumps(build_cfg)), content_sha256=np.array(chash),
310
+ canonical_index=pops.canonical_index,
311
+ )
312
+ os.replace(tmp, path)
313
+ file_sha = sha256_file(path)
314
+ CURRENT_FILE.parent.mkdir(parents=True, exist_ok=True)
315
+ if out_dir == CONNECTOME_DIR:
316
+ CURRENT_FILE.write_text(fname + "\n")
317
+ metrics["build_seconds"] = round(time.perf_counter() - t0, 1)
318
+ metrics["file_sha256"] = file_sha
319
+
320
+ # ---- report ----
321
+ print(f"[build] N={N:,} nnz={nnz:,} KC={counts['KC']} MBON_APP={counts['MBON_APP']} MBON_AV={counts['MBON_AV']} "
322
+ f"MBON_OTHER={counts['MBON_OTHER']} DAN_PAM={counts['DAN_PAM']} DAN_PPL1={counts['DAN_PPL1']} "
323
+ f"glomeruli={len(pops.glomerulus_names)}")
324
+ for name in pops.order:
325
+ print(f" {name:<13} {counts[name]:>7} kind={pops.kind.get(name, 'other')}")
326
+ print(f"[build] wrote {path} ({path.stat().st_size / 1e6:.1f} MB, sha256 {file_sha[:12]}); current.txt -> {fname}")
327
+ if counts["KC"] != P.EXPECTED_KC or (counts["MBON_APP"] + counts["MBON_AV"] + counts["MBON_OTHER"]) != P.EXPECTED_MBON:
328
+ raise RuntimeError("KC/MBON counts do not match FAFB v783 expectations") # build_populations already checks
329
+ if persist_db:
330
+ label = f"{pops.source}:{Path(build_cfg['annotations']).name if pops.source == 'annotations' else 'classification.csv.gz'}"
331
+ run_id = persist(pops, metrics, {"build": build_cfg, "env": config.summary()}, file_sha, started_at, label)
332
+ print(f"[build] runs row {run_id} (kind=connectome_build) and {len(pops.order)} populations rows written")
333
+ return path
334
+
335
+
336
+ if __name__ == "__main__":
337
+ main()
fly_trader/brain/plastic.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mushroom-body plasticity: after its bootstrap the fly keeps learning from the market, at the KC→MBON synapses only.
2
+
3
+ Rule (dopamine-gated three-factor; Hige 2015, Cohn 2015, Bennett 2021; masked like the connectome, FlyModel-style
4
+ associative updates, Shen/Dasgupta/Navlakha 2023). A plastic change ``D`` [n_KC, n_MBON] of the KC→MBON weights acts
5
+ at the network's last step (``FlyNet.readout``). For a scored minute with KC code ``k`` (presynaptic activity), MBON
6
+ activity ``m`` and, 120 minutes later, its realized net return ``r``:
7
+
8
+ δ = clip(clip(r, ±1) − ŷ, ±DELTA_CLIP) dopamine: the prediction error (ŷ recomputed with the current D)
9
+ g_j = c_j γ_j (1 − m_j²) / (σ_j · scale) how MBON j moves the prediction (∂ŷ/∂D_ij = k_i g_j, exact)
10
+ ΔD = (α/ν) · M ⊙ Σ_rows w δ k gᵀ w = 1 on minutes scored at/above the line, else 0
11
+
12
+ which is gradient descent on the weighted squared prediction error: reward (δ > 0) depresses KC→avoid-MBON synapses
13
+ (c < 0) and potentiates KC→approach ones; punishment the reverse. ``ν`` (the median squared norm of one row's update,
14
+ fixed at bootstrap) makes ``α`` the share of a row's error one update corrects. Changes decay toward the bootstrap weights
15
+ with half-life ``τ½`` in event time (dopamine-gated forgetting, Berry 2012), each update is capped at ``STEP_CAP`` of the
16
+ bootstrap weights' norm, and ``D`` stays within [−w0, 2·w0] (effective weights within [0, 3·w0]). ``β_pot`` < 1 damps
17
+ potentiation (depression-dominant; still a descent direction). ``D = 0`` is the frozen fly (the shadow).
18
+
19
+ The label arrives two hours after the decision, far beyond any biological eligibility window (seconds: Handler 2019),
20
+ so each scored minute leaves a synaptic tag (``PendingTags``: its KC code, MBON pre-activation and descending output)
21
+ that is captured when the label resolves (synaptic tag-and-capture, Frey & Morris 1997).
22
+
23
+ ``PlasticBank`` holds ``C`` configurations side by side (the replay's grid; 1 live).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import bisect
28
+ import itertools
29
+ import math
30
+ from dataclasses import dataclass
31
+
32
+ import numpy as np
33
+ import torch
34
+
35
+ DELTA_CLIP = 0.5
36
+ RET_CLIP = 1.0
37
+ TOP_WEIGHT = 10.0
38
+ STEP_CAP = 1e-3 # ‖ΔD‖_F per update ≤ STEP_CAP · ‖w0‖_F
39
+ DAY_S = 86400.0
40
+
41
+
42
+ @dataclass
43
+ class Tags:
44
+ """A batch of captured tags: row keys, the parts cached at scoring and each configuration's weight [C, B]."""
45
+ keys: list
46
+ ts: np.ndarray
47
+ y_dn: torch.Tensor # [B]
48
+ u0: torch.Tensor # [B, n_MBON]
49
+ k: torch.Tensor # [B, n_KC] dense KC code
50
+ w: torch.Tensor # [C, B]
51
+ s: np.ndarray = None # [B] strategy (dopamine channel) of each tag; None = channel 0
52
+
53
+ def __post_init__(self):
54
+ if self.s is None:
55
+ self.s = np.zeros(len(self.keys), dtype=np.int64)
56
+
57
+ def __len__(self) -> int:
58
+ return len(self.keys)
59
+
60
+ def subset(self, keep: np.ndarray) -> "Tags":
61
+ keep = np.asarray(keep, dtype=bool); i = torch.as_tensor(np.flatnonzero(keep), device=self.y_dn.device)
62
+ return Tags([k for k, m in zip(self.keys, keep) if m], self.ts[keep], self.y_dn[i], self.u0[i], self.k[i], self.w[:, i], self.s[keep])
63
+
64
+
65
+ class PendingTags:
66
+ """Scored minutes waiting for their labels, kept in due order (strategies hold for different times). The KC code is
67
+ stored sparsely (its active cells)."""
68
+
69
+ def __init__(self, n_kc: int, k_active: int):
70
+ self.n_kc, self.k_active = n_kc, k_active
71
+ self.q: list = []; self.n = 0; self._seq = itertools.count()
72
+
73
+ def __len__(self) -> int:
74
+ return self.n
75
+
76
+ def push(self, keys: list, ts: np.ndarray, due: float, y_dn: torch.Tensor, u0: torch.Tensor, k: torch.Tensor, w: torch.Tensor, s=None) -> None:
77
+ """Tags capturable from event time ``due`` on (all rows of one push share it); ``s``: their strategy channel(s)."""
78
+ if len(keys) == 0:
79
+ return
80
+ vals, idx = k.detach().topk(min(self.k_active, k.shape[1]), dim=1)
81
+ ch = np.broadcast_to(np.asarray(0 if s is None else s, dtype=np.int64), (len(keys),)).copy()
82
+ chunk = {"due": float(due), "keys": list(keys), "ts": np.asarray(ts, dtype=np.float64), "y_dn": y_dn.detach().float().cpu(), "s": ch,
83
+ "u0": u0.detach().float().cpu(), "idx": idx.to(torch.int32).cpu(), "vals": vals.float().cpu(), "w": w.detach().float().cpu()}
84
+ bisect.insort(self.q, (float(due), next(self._seq), chunk))
85
+ self.n += len(keys)
86
+
87
+ def pop_due(self, now: float, device=None) -> Tags | None:
88
+ out = []
89
+ while self.q and self.q[0][0] <= now:
90
+ out.append(self.q.pop(0)[2])
91
+ if not out:
92
+ return None
93
+ self.n -= sum(len(c["keys"]) for c in out)
94
+ return self._tags(out, device)
95
+
96
+ def clear(self) -> None:
97
+ self.q.clear(); self.n = 0
98
+
99
+ def _tags(self, chunks: list[dict], device=None) -> Tags:
100
+ idx = torch.cat([c["idx"] for c in chunks]).long(); vals = torch.cat([c["vals"] for c in chunks])
101
+ k = torch.zeros(len(idx), self.n_kc).scatter_(1, idx, vals)
102
+ dev = device or "cpu"
103
+ return Tags(keys=[key for c in chunks for key in c["keys"]], ts=np.concatenate([c["ts"] for c in chunks]),
104
+ y_dn=torch.cat([c["y_dn"] for c in chunks]).to(dev), u0=torch.cat([c["u0"] for c in chunks]).to(dev), k=k.to(dev),
105
+ w=torch.cat([c["w"] for c in chunks], dim=1).to(dev), s=np.concatenate([c["s"] for c in chunks]))
106
+
107
+
108
+ def assign_channels(comp: np.ndarray, km_mass: np.ndarray, c_sign: np.ndarray, n_strategies: int) -> tuple[np.ndarray, np.ndarray]:
109
+ """Each strategy's own dopamine channel: its mushroom-body compartments. ``comp`` [J]: each MBON's compartment (−1: no
110
+ DAN), ``km_mass`` [J]: KC→MBON synapse mass per MBON, ``c_sign`` [J]: readout sign (+1 approach, −1 avoid, 0 other).
111
+ Compartments are split by their MBONs' majority valence and, within each valence, dealt in descending synapse mass to
112
+ the strategy with the least mass so far (deterministic, balanced, every strategy gets both valences when there are
113
+ enough). Returns ``learn`` [S, J] (the strategy's own MBONs: its outcomes change only their KC synapses) and ``read``
114
+ [S, J] (its own + the MBONs no compartment claims, which stay fixed). One strategy: everything, as before; no DAN
115
+ data: every MBON is its own compartment."""
116
+ J = len(c_sign); S = int(n_strategies)
117
+ if S <= 1:
118
+ return np.ones((1, J), bool), np.ones((1, J), bool)
119
+ comp = np.asarray(comp, int) if comp is not None and len(comp) == J else np.full(J, -1)
120
+ if (comp < 0).all():
121
+ comp = np.arange(J)
122
+ learn = np.zeros((S, J), bool)
123
+ ids = sorted(set(comp[comp >= 0].tolist()))
124
+ val = {k: int(np.sign(np.sum(np.sign(c_sign[comp == k])))) for k in ids}
125
+ mass = {k: float(np.asarray(km_mass)[comp == k].sum()) for k in ids}
126
+ for v in (1, -1, 0):
127
+ load = np.zeros(S)
128
+ for k in sorted([k for k in ids if val[k] == v], key=lambda k: (-mass[k], k)):
129
+ s_ = int(np.argmin(load)); learn[s_, comp == k] = True; load[s_] += mass[k]
130
+ free = ~learn.any(0)
131
+ return learn, learn | free[None, :]
132
+
133
+
134
+ def row_weights(scores: torch.Tensor, lines: torch.Tensor) -> torch.Tensor:
135
+ """[C, B]: 1 where a configuration scored the row at or above its own line, else 0 — it learns from the outcome of
136
+ its own decisions and nothing else.
137
+
138
+ Until 2026-09-21 every candidate row was tagged, weighting the ones above the line 10x. Candidates are overwhelmingly
139
+ losers (4.58M of them averaged -5.70% over the 2026-07-27 replay while the rows actually traded averaged +6.30%), so
140
+ even at 10x the losing population outweighed the tradeable rows by more than 100:1 and dragged every prediction
141
+ toward the candidate mean. Learning then made the fly worse than the same network frozen (PF 1.012 vs 1.098). A row
142
+ below the line is not a decision, so it carries no weight."""
143
+ return torch.where(scores >= lines[:, None], 1.0, 0.0)
144
+
145
+
146
+ class PlasticBank:
147
+ """Plastic changes ``D`` [C, n_KC, n_MBON] of a FlyNet's KC→MBON weights under ``C`` configurations (α, τ½ in days;
148
+ τ½ = inf: no decay). The net is read, never modified."""
149
+
150
+ def __init__(self, net, configs: list[tuple[float, float]], scale: float, nu: float = 1.0, beta_pot: float = 1.0, step_cap: float = STEP_CAP,
151
+ learn: np.ndarray | None = None, read: np.ndarray | None = None):
152
+ self.net, self.scale, self.nu, self.beta_pot, self.step_cap = net, float(scale), float(nu), float(beta_pot), float(step_cap)
153
+ dev = net.dev
154
+ with torch.no_grad():
155
+ self.w0 = net.w_km().detach().clone()
156
+ self.mask = net.mask.float()
157
+ self.c = net.c.detach().clone(); self.gamma = net.gain[net.mb0:net.mb1].detach().clone()
158
+ mu, sd = net.mbon_stats(); self.mu, self.sigma = mu.detach().clone(), sd.detach().clone()
159
+ self.w0_norm = float(torch.linalg.norm(self.w0))
160
+ self.configs = [(float(a), float(h)) for a, h in configs]
161
+ C = len(self.configs)
162
+ J = self.w0.shape[1]
163
+ self.learn = torch.as_tensor(np.ones((1, J), bool) if learn is None else learn, device=dev) # [S, J] per-strategy channels
164
+ self.read = torch.as_tensor(np.ones((1, J), bool) if read is None else read, device=dev)
165
+ self.alpha = torch.tensor([a for a, _ in self.configs], device=dev)[:, None].repeat(1, J) # [C, J]: per-channel rates
166
+ self.half_life_s = torch.tensor([h * DAY_S if math.isfinite(h) else float("inf") for _, h in self.configs], device=dev)[:, None].repeat(1, J)
167
+ self.D = torch.zeros(C, *self.w0.shape, device=dev)
168
+ self.t_last: float | None = None
169
+ self.dev = dev
170
+
171
+ @property
172
+ def C(self) -> int:
173
+ return len(self.configs)
174
+
175
+ # ---- prediction ----
176
+ def _read(self, s, B: int) -> torch.Tensor:
177
+ """[B, J] the MBONs each row's strategy reads (all MBONs with one strategy)."""
178
+ if self.read.shape[0] == 1 or s is None:
179
+ return self.read[0].float()[None].expand(B, -1)
180
+ return self.read[torch.as_tensor(np.asarray(s), device=self.dev)].float()
181
+
182
+ def predict(self, y_dn: torch.Tensor, u0: torch.Tensor, k: torch.Tensor, D: torch.Tensor | None = None, s=None) -> tuple[torch.Tensor, torch.Tensor]:
183
+ """(scores [C, B] in net-return units, MBON activity [C, B, n_MBON]) under each configuration's ``D``; ``s``: each
184
+ row's strategy (its head output is ``y_dn``; it reads its own compartments and the unclaimed ones)."""
185
+ D = self.D if D is None else D
186
+ u = u0[None] + self.gamma * torch.einsum("bi,cij->cbj", k, D * self.mask)
187
+ m = torch.tanh(u)
188
+ return (y_dn[None] + (((m - self.mu) / self.sigma) * self._read(s, len(y_dn))) @ self.c) / self.scale, m
189
+
190
+ def frozen(self, y_dn: torch.Tensor, u0: torch.Tensor, s=None) -> torch.Tensor:
191
+ """The shadow: the bootstrap fly's score (D = 0) [B]."""
192
+ return (y_dn + (((torch.tanh(u0) - self.mu) / self.sigma) * self._read(s, len(y_dn))) @ self.c) / self.scale
193
+
194
+ def grad_factor(self, m: torch.Tensor, s=None) -> torch.Tensor:
195
+ """g [C, B, n_MBON] with ∂ŷ/∂D_ij = k_i g_j (zero on MBONs the row's strategy does not read)."""
196
+ return self.c * self.gamma * (1.0 - m * m) / (self.sigma * self.scale) * self._read(s, m.shape[1])
197
+
198
+ # ---- learning ----
199
+ def decay_to(self, t: float) -> None:
200
+ """Decay toward the bootstrap weights up to event time ``t`` (seconds)."""
201
+ if self.t_last is not None and t > self.t_last:
202
+ f = torch.pow(2.0, -(t - self.t_last) / self.half_life_s) # [C, J]; inf half-life → 1
203
+ self.D *= f[:, None, :]
204
+ self.t_last = t if self.t_last is None else max(self.t_last, t)
205
+
206
+ def estimate_nu(self, y_dn: torch.Tensor, u0: torch.Tensor, k: torch.Tensor) -> float:
207
+ """Median squared norm of one row's update direction at D = 0 (sets the scale ``α`` is measured in)."""
208
+ m = torch.tanh(u0)
209
+ g = self.c * self.gamma * (1.0 - m * m) / (self.sigma * self.scale) # [B, J]
210
+ sq = ((k * k) @ self.mask * g * g).sum(1)
211
+ v = float(sq.median()) if len(sq) else 0.0
212
+ self.nu = v if v > 0 else 1.0
213
+ return self.nu
214
+
215
+ def update(self, tags: Tags, r: torch.Tensor, t: float) -> dict:
216
+ """Capture ``tags`` with realized net returns ``r`` [B] at event time ``t``; returns per-configuration statistics."""
217
+ self.decay_to(t)
218
+ if len(tags) == 0:
219
+ return {"n": 0}
220
+ r = r.to(self.dev).float().clamp(-RET_CLIP, RET_CLIP)
221
+ yhat, m = self.predict(tags.y_dn, tags.u0, tags.k, s=tags.s)
222
+ delta = (r[None] - yhat).clamp(-DELTA_CLIP, DELTA_CLIP) # [C, B]
223
+ own = self.learn.float()[torch.as_tensor(tags.s, device=self.dev)] if self.learn.shape[0] > 1 else self.learn[0].float()[None]
224
+ g = self.grad_factor(m, tags.s) * own # [C, B, J]: only the row's own channel learns
225
+ dD = torch.einsum("bi,cbj->cij", tags.k, (tags.w * delta)[:, :, None] * g) * self.mask
226
+ if self.beta_pot != 1.0:
227
+ dD = torch.where(dD > 0, dD * self.beta_pot, dD)
228
+ dD = dD * (self.alpha / self.nu)[:, None, :]
229
+ norms = torch.linalg.norm(dD.flatten(1), dim=1)
230
+ cap = self.step_cap * self.w0_norm
231
+ capped = norms > cap
232
+ dD = dD * torch.where(capped, cap / norms.clamp(min=1e-30), torch.ones_like(norms))[:, None, None]
233
+ D = self.D + dD
234
+ self.D = torch.maximum(torch.minimum(D, 2.0 * self.w0), -self.w0) * self.mask
235
+ return {"n": len(tags), "mean_delta": delta.mean(1).tolist(), "mean_abs_delta": delta.abs().mean(1).tolist(),
236
+ "step": torch.linalg.norm(dD.flatten(1), dim=1).tolist(), "capped": capped.tolist(), "drift": self.drift().tolist()}
237
+
238
+ def drift(self, s: int | None = None) -> torch.Tensor:
239
+ """‖D‖_F / ‖w0‖_F per configuration (``s``: over that strategy's own channel only)."""
240
+ if s is None:
241
+ return torch.linalg.norm(self.D.flatten(1), dim=1) / max(self.w0_norm, 1e-30)
242
+ cols = self.learn[s]
243
+ return torch.linalg.norm(self.D[:, :, cols].flatten(1), dim=1) / max(float(torch.linalg.norm(self.w0[:, cols])), 1e-30)
244
+
245
+ # ---- state ----
246
+ def state(self) -> dict:
247
+ m = self.mask.bool()
248
+ return {"D": self.D[:, m].cpu(), "t_last": self.t_last, "configs": self.configs, "nu": self.nu, "beta_pot": self.beta_pot, "step_cap": self.step_cap,
249
+ "alpha": self.alpha.cpu(), "half_life_s": self.half_life_s.cpu(), "learn": self.learn.cpu(), "read": self.read.cpu()}
250
+
251
+ def load_state(self, s: dict) -> None:
252
+ if [tuple(c) for c in s["configs"]] != self.configs:
253
+ raise ValueError(f"plastic state for configurations {s['configs']} does not match {self.configs}")
254
+ D = torch.zeros_like(self.D); D[:, self.mask.bool()] = s["D"].to(self.dev).float()
255
+ self.D, self.t_last, self.nu = D, s["t_last"], float(s["nu"])
256
+ if "alpha" in s:
257
+ self.alpha, self.half_life_s = s["alpha"].to(self.dev), s["half_life_s"].to(self.dev)
258
+ self.learn, self.read = s["learn"].to(self.dev), s["read"].to(self.dev)
259
+ self.beta_pot, self.step_cap = float(s.get("beta_pot", self.beta_pot)), float(s.get("step_cap", self.step_cap))
260
+
261
+ def select(self, i: int) -> "PlasticBank":
262
+ """A single-configuration bank holding configuration ``i``'s current ``D`` (the replay's winner, going live)."""
263
+ b = PlasticBank(self.net, [self.configs[i]], self.scale, self.nu, self.beta_pot, self.step_cap, self.learn.cpu().numpy(), self.read.cpu().numpy())
264
+ b.D = self.D[i:i + 1].clone(); b.t_last = self.t_last
265
+ return b
266
+
267
+ def select_per_strategy(self, best: list[int]) -> "PlasticBank":
268
+ """One bank with each strategy's own winning configuration: strategy s's channel columns (D, α, half-life) come from
269
+ configuration ``best[s]``. Exact, because a strategy's value depends only on its own channel."""
270
+ b = self.select(best[0])
271
+ for s_, i in enumerate(best):
272
+ cols = self.learn[s_]
273
+ b.D[0][:, cols] = self.D[i][:, cols]; b.alpha[0, cols] = self.alpha[i, cols]; b.half_life_s[0, cols] = self.half_life_s[i, cols]
274
+ b.configs = [("per-strategy", tuple(best))]
275
+ return b
fly_trader/brain/populations.py ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Population index sets for the FlyWire FAFB v783 connectome.
2
+
3
+ The neuron table is flybrain's ``neurons.csv.gz`` (139,255 canonical root_ids, the same set and
4
+ order the connectome edges refer to) left-joined on ``root_id`` with the Schlegel et al. 2024
5
+ annotation TSV (``Supplemental_file1_neuron_annotations.tsv``: cell_class / cell_sub_class /
6
+ cell_type / top_nt). Neurotransmitter = ``top_nt`` when annotated, else flybrain's ``nt_type``
7
+ mapped to the same vocabulary.
8
+
9
+ Populations are *contiguous index ranges*: neurons are sorted by (population_id, root_id) so a
10
+ population is a slice, never a gather, on the GPU. Membership is first-match in
11
+ ``POPULATION_ORDER``; everything unmatched is ``OTHER``.
12
+
13
+ Valence tables (config/mbon_valence.json): Aso et al. 2014 eLife 3:e04580 (aversion-driving MBONs
14
+ are glutamatergic, attraction-driving MBONs GABAergic/cholinergic) and 3:e04577 (PAM = reward,
15
+ PPL1 = punishment). KC sparse code: Dasgupta, Stevens, Navlakha 2017 Science.
16
+
17
+ flybrain's 63 behavioural groups (``determine_group`` from snedea/flybrain
18
+ ``scripts/build_connectome.py``) are recomputed verbatim so group rates stay comparable with the
19
+ JavaScript demo; the DRIVE_HUNGER group (46 pars-intercerebralis neurons) is our HUNGER population.
20
+
21
+ Fallback (``config.CELL_TYPES_SOURCE == 'flybrain'`` or the TSV is absent): flybrain's
22
+ ``classification.csv.gz`` supplies the classes; MBON valence is assigned by neurotransmitter
23
+ (glutamate -> avoid, acetylcholine/gaba -> approach) and DAN valence by synapse counts onto avoid
24
+ vs approach MBONs (reward if more onto avoid MBONs: reward DANs depress avoidance synapses);
25
+ glomeruli come from the Codex ``consolidated_cell_types.csv.gz`` if it types >= 30 ORN
26
+ glomeruli, else a seeded random partition into 50 pseudo-glomeruli.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import sys
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+
35
+ import numpy as np
36
+ import pandas as pd
37
+
38
+ from .. import config
39
+
40
+ RAW_DIR = config.BRAIN_DIR / "raw"
41
+ NEURONS_CSV = RAW_DIR / "neurons.csv.gz"
42
+ CLASSIFICATION_CSV = RAW_DIR / "classification.csv.gz"
43
+ ANNOTATIONS_TSV = RAW_DIR / "Supplemental_file1_neuron_annotations.tsv"
44
+ CONSOLIDATED_TYPES_CSV = RAW_DIR / "consolidated_cell_types.csv.gz"
45
+ MBON_VALENCE_JSON = config.REPO_ROOT / "config" / "mbon_valence.json"
46
+
47
+ EXPECTED_N = 139_255
48
+ EXPECTED_KC = 5_177
49
+ EXPECTED_MBON = 96
50
+
51
+ # Local constants (not in config.py): pseudo-glomerulus fallback.
52
+ MIN_TYPED_GLOMERULI = 30
53
+ PSEUDO_GLOMERULI = 50
54
+
55
+ # First match wins; the remainder is OTHER.
56
+ POPULATION_ORDER: list[str] = [
57
+ "KC", "MBON_APP", "MBON_AV", "MBON_OTHER",
58
+ "DAN_PAM", "DAN_PPL1", "DAN_OTHER",
59
+ "ORN_DANGER", "ORN_FOOD", "ALPN", "ALLN", "LH",
60
+ "GRN_SWEET", "GRN_BITTER", "GRN_OTHER",
61
+ "MECH_JO", "MECH_BRISTLE", "MECH_OTHER",
62
+ "THERMO_WARM", "THERMO_COOL", "THERMO_OTHER",
63
+ "CX", "HUNGER", "DESCENDING", "VISUAL", "OTHER",
64
+ ]
65
+
66
+ # kind for the ``populations`` table: sensory | central | mb | motor | other
67
+ POPULATION_KIND: dict[str, str] = {
68
+ "KC": "mb", "MBON_APP": "mb", "MBON_AV": "mb", "MBON_OTHER": "mb",
69
+ "DAN_PAM": "mb", "DAN_PPL1": "mb", "DAN_OTHER": "mb",
70
+ "ORN_DANGER": "sensory", "ORN_FOOD": "sensory", "ALPN": "central", "ALLN": "central", "LH": "central",
71
+ "GRN_SWEET": "sensory", "GRN_BITTER": "sensory", "GRN_OTHER": "sensory",
72
+ "MECH_JO": "sensory", "MECH_BRISTLE": "sensory", "MECH_OTHER": "sensory",
73
+ "THERMO_WARM": "sensory", "THERMO_COOL": "sensory", "THERMO_OTHER": "sensory",
74
+ "CX": "central", "HUNGER": "central", "DESCENDING": "motor", "VISUAL": "sensory", "OTHER": "other",
75
+ }
76
+
77
+ NT_FLYBRAIN_TO_NAME: dict[str, str] = {
78
+ "ACH": "acetylcholine", "GABA": "gaba", "GLUT": "glutamate", "DA": "dopamine",
79
+ "SER": "serotonin", "OA": "octopamine", "OCT": "octopamine",
80
+ }
81
+
82
+ # ---------------------------------------------------------------------------------------------
83
+ # flybrain groups, ported verbatim from snedea/flybrain scripts/build_connectome.py
84
+ # ---------------------------------------------------------------------------------------------
85
+ FLYBRAIN_GROUPS: list[tuple[str, str]] = [
86
+ ("VIS_R1R6", "sensory"), ("VIS_R7R8", "sensory"), ("VIS_ME", "sensory"), ("VIS_LO", "sensory"),
87
+ ("VIS_LC", "sensory"), ("VIS_LPTC", "sensory"), ("OLF_ORN_FOOD", "sensory"), ("OLF_ORN_DANGER", "sensory"),
88
+ ("OLF_LN", "sensory"), ("OLF_PN", "sensory"), ("MECH_BRISTLE", "sensory"), ("MECH_JO", "sensory"),
89
+ ("MECH_CHORD", "sensory"), ("ANTENNAL_MECH", "sensory"), ("THERMO_WARM", "sensory"), ("THERMO_COOL", "sensory"),
90
+ ("NOCI", "sensory"),
91
+ ("MB_KC", "central"), ("MB_APL", "central"), ("MB_MBON_APP", "central"), ("MB_MBON_AV", "central"),
92
+ ("MB_DAN_REW", "central"), ("MB_DAN_PUN", "central"), ("LH_APP", "central"), ("LH_AV", "central"),
93
+ ("CX_EPG", "central"), ("CX_PFN", "central"), ("CX_FC", "central"), ("CX_HDELTA", "central"),
94
+ ("SEZ_FEED", "central"), ("SEZ_GROOM", "central"), ("SEZ_WATER", "central"), ("GUS_GRN_SWEET", "central"),
95
+ ("GUS_GRN_BITTER", "central"), ("GUS_GRN_WATER", "central"), ("GNG_DESC", "central"), ("CLOCK_DN", "central"),
96
+ ("DRIVE_HUNGER", "drives"), ("DRIVE_FEAR", "drives"), ("DRIVE_FATIGUE", "drives"), ("DRIVE_CURIOSITY", "drives"),
97
+ ("DRIVE_GROOM", "drives"),
98
+ ("DN_WALK", "motor"), ("DN_FLIGHT", "motor"), ("DN_TURN", "motor"), ("DN_BACKUP", "motor"),
99
+ ("DN_STARTLE", "motor"), ("VNC_CPG", "motor"), ("MN_LEG_L1", "motor"), ("MN_LEG_R1", "motor"),
100
+ ("MN_LEG_L2", "motor"), ("MN_LEG_R2", "motor"), ("MN_LEG_L3", "motor"), ("MN_LEG_R3", "motor"),
101
+ ("MN_WING_L", "motor"), ("MN_WING_R", "motor"), ("MN_PROBOSCIS", "motor"), ("MN_HEAD", "motor"),
102
+ ("MN_ABDOMEN", "motor"),
103
+ ("GENERIC_SENSORY", "sensory"), ("GENERIC_CENTRAL", "central"), ("GENERIC_DRIVES", "drives"),
104
+ ("GENERIC_MOTOR", "motor"),
105
+ ]
106
+ FLYBRAIN_GROUP_NAMES: list[str] = [name for name, _ in FLYBRAIN_GROUPS]
107
+ FLYBRAIN_GROUP_ID: dict[str, int] = {name: i for i, name in enumerate(FLYBRAIN_GROUP_NAMES)}
108
+ assert len(FLYBRAIN_GROUPS) == 63
109
+
110
+
111
+ def determine_group(flow: str, super_class: str, cls: str, sub_class: str, region: str, side: str = "") -> str:
112
+ """Map classification fields to one of the 63 group names (59 named + 4 generic).
113
+
114
+ Verbatim port of flybrain ``scripts/build_connectome.py::determine_group`` (inputs are the
115
+ lower-cased flow / super_class / class / sub_class / side columns of classification.csv).
116
+ """
117
+ # --- Visual system ---
118
+ if "visual" in cls or "optic" in cls or "visual" in super_class or "optic" in super_class:
119
+ if "photo_receptor" in sub_class or "photo" in sub_class:
120
+ if "r7" in sub_class or "r8" in sub_class or "uv" in sub_class or "pale" in sub_class:
121
+ return "VIS_R7R8"
122
+ return "VIS_R1R6"
123
+ if "ocellar" in sub_class or "ocellar" in cls:
124
+ return "VIS_R1R6"
125
+ if "lptc" in sub_class or "tangential" in sub_class:
126
+ return "VIS_LPTC"
127
+ if "lc" in sub_class or "loom" in sub_class or "lobula_columnar" in sub_class:
128
+ return "VIS_LC"
129
+ if "lobula" in sub_class and "plate" in sub_class:
130
+ return "VIS_LPTC"
131
+ if "lobula" in sub_class or sub_class == "lo":
132
+ return "VIS_LO"
133
+ if "medulla" in sub_class or "tm" in sub_class or "mi" in sub_class:
134
+ return "VIS_ME"
135
+ return "VIS_ME"
136
+
137
+ # --- Olfactory system ---
138
+ if "olfact" in cls:
139
+ if "pheromone" in sub_class or "avers" in sub_class or "danger" in sub_class:
140
+ return "OLF_ORN_DANGER"
141
+ return "OLF_ORN_FOOD"
142
+ if cls == "alpn":
143
+ return "OLF_PN"
144
+ if cls == "alln":
145
+ return "OLF_LN"
146
+
147
+ # --- Gustatory system ---
148
+ if "gustat" in cls:
149
+ if "bitter" in sub_class:
150
+ return "GUS_GRN_BITTER"
151
+ if "water" in sub_class:
152
+ return "GUS_GRN_WATER"
153
+ return "GUS_GRN_SWEET"
154
+
155
+ # --- Mechanosensory system ---
156
+ if "mechano" in cls:
157
+ if "wind" in sub_class or "gravity" in sub_class or "auditory" in sub_class:
158
+ return "MECH_JO"
159
+ if "groom" in sub_class:
160
+ return "MECH_BRISTLE"
161
+ if "bristle" in sub_class or "taste_peg" in sub_class:
162
+ return "MECH_BRISTLE"
163
+ if "chord" in sub_class or "propriocep" in sub_class:
164
+ return "MECH_CHORD"
165
+ if "antenna" in sub_class:
166
+ return "ANTENNAL_MECH"
167
+ return "MECH_BRISTLE"
168
+
169
+ # --- Thermosensory ---
170
+ if "thermo" in cls:
171
+ if "cool" in sub_class or "cold" in sub_class:
172
+ return "THERMO_COOL"
173
+ return "THERMO_WARM"
174
+
175
+ # --- Hygrosensory -> thermosensory ---
176
+ if "hygro" in cls:
177
+ if "dry" in sub_class:
178
+ return "THERMO_WARM"
179
+ return "THERMO_COOL"
180
+
181
+ # --- Nociceptive ---
182
+ if "nocicep" in cls:
183
+ return "NOCI"
184
+
185
+ # --- Unknown sensory -> generic sensory ---
186
+ if "unknown_sensory" in cls:
187
+ return "MECH_BRISTLE"
188
+
189
+ # --- Mushroom body: Kenyon cells ---
190
+ if "kenyon" in cls or cls == "kc":
191
+ return "MB_KC"
192
+
193
+ # --- Mushroom body output neurons (MBONs) ---
194
+ if cls == "mbon":
195
+ return "MB_MBON_APP"
196
+
197
+ # --- Mushroom body input neurons (MBINs) ---
198
+ if cls == "mbin":
199
+ return "MB_DAN_REW"
200
+
201
+ # --- Dopaminergic neurons (DANs) ---
202
+ if cls == "dan":
203
+ return "MB_DAN_REW"
204
+
205
+ # --- Lateral horn ---
206
+ if cls == "lhln" or cls == "lhcent":
207
+ return "LH_APP"
208
+
209
+ # --- Central complex ---
210
+ if cls == "cx":
211
+ if "ring" in sub_class:
212
+ return "CX_EPG"
213
+ if "tangential" in sub_class:
214
+ return "CX_HDELTA"
215
+ if "columnar" in sub_class:
216
+ return "CX_PFN"
217
+ return "CX_FC"
218
+
219
+ # --- Antennal lobe interneurons ---
220
+ if cls == "alin":
221
+ return "OLF_LN"
222
+ if cls == "alon":
223
+ return "OLF_PN"
224
+
225
+ # --- TuBu neurons (tubercle to bulb, visual -> CX pathway) ---
226
+ if cls == "tubu":
227
+ return "CX_EPG"
228
+
229
+ # --- TPN (taste projection neurons) ---
230
+ if cls == "tpn":
231
+ if "water" in sub_class:
232
+ return "GUS_GRN_WATER"
233
+ return "GUS_GRN_SWEET"
234
+
235
+ # --- MAL (medial accessory lobe) neurons ---
236
+ if cls == "mal":
237
+ return "CX_FC"
238
+
239
+ # --- Ascending neurons ---
240
+ if "ascending" in super_class or cls == "an":
241
+ return "GNG_DESC"
242
+
243
+ # --- Descending neurons ---
244
+ if "descend" in super_class or "descend" in cls:
245
+ if "dn1p" in sub_class:
246
+ return "DN_STARTLE"
247
+ if "dn3" in sub_class:
248
+ return "DN_WALK"
249
+ if "walk" in sub_class or "locomot" in sub_class:
250
+ return "DN_WALK"
251
+ if "flight" in sub_class:
252
+ return "DN_FLIGHT"
253
+ if "turn" in sub_class:
254
+ return "DN_TURN"
255
+ if "back" in sub_class:
256
+ return "DN_BACKUP"
257
+ if "startle" in sub_class or "escape" in sub_class or "giant" in sub_class:
258
+ return "DN_STARTLE"
259
+ return "GNG_DESC"
260
+
261
+ # --- Motor neurons ---
262
+ if "motor" in cls or "motor" in super_class:
263
+ if "proboscis" in sub_class:
264
+ return "MN_PROBOSCIS"
265
+ if "neck" in sub_class or "head" in sub_class:
266
+ return "MN_HEAD"
267
+ if "abdom" in sub_class or "crop" in sub_class:
268
+ return "MN_ABDOMEN"
269
+ if "ingestion" in sub_class or "haustellum" in sub_class or "salivary" in sub_class:
270
+ return "SEZ_FEED"
271
+ if "eye" in sub_class or "antenna" in sub_class:
272
+ return "MN_HEAD"
273
+ return "VNC_CPG"
274
+
275
+ # --- Clock neurons ---
276
+ if "clock" in cls or "circadian" in cls:
277
+ return "CLOCK_DN"
278
+
279
+ # --- Endocrine / neurosecretory ---
280
+ if "endocrine" in super_class:
281
+ if "pars_lateralis" in cls:
282
+ return "DRIVE_FATIGUE"
283
+ return "DRIVE_HUNGER"
284
+
285
+ # --- Fallback: use flow to pick a region-appropriate generic ---
286
+ if flow == "afferent":
287
+ return "GENERIC_SENSORY"
288
+ if flow == "efferent":
289
+ return "GENERIC_MOTOR"
290
+ return "GENERIC_CENTRAL"
291
+
292
+
293
+ # ---------------------------------------------------------------------------------------------
294
+ # Loading
295
+ # ---------------------------------------------------------------------------------------------
296
+ def load_neuron_table(annotations: str | Path | None = None, source: str | None = None) -> tuple[pd.DataFrame, str]:
297
+ """flybrain neurons (canonical order) + classification + annotations (left join).
298
+
299
+ Returns (table, source) where source is 'annotations' or 'flybrain'. Columns:
300
+ root_id (int64), fb_nt, fb_flow, fb_super_class, fb_class, fb_sub_class, fb_side (lower-cased,
301
+ as flybrain sees them), flybrain_group (uint16), annotated (bool), super_class, cell_class,
302
+ cell_sub_class, cell_type, top_nt, nt.
303
+ """
304
+ source = source or config.CELL_TYPES_SOURCE
305
+ neu = pd.read_csv(NEURONS_CSV, usecols=["root_id", "nt_type"], dtype={"root_id": np.int64, "nt_type": str},
306
+ keep_default_na=False)
307
+ if len(neu) != EXPECTED_N:
308
+ print(f"[populations] WARNING neurons.csv has {len(neu)} rows, expected {EXPECTED_N}", file=sys.stderr)
309
+ cls = pd.read_csv(CLASSIFICATION_CSV, dtype=str, keep_default_na=False)
310
+ cls["root_id"] = cls["root_id"].astype(np.int64)
311
+ for c in ("flow", "super_class", "class", "sub_class", "side"):
312
+ cls[c] = cls[c].str.strip().str.lower()
313
+ cls = cls.rename(columns={"flow": "fb_flow", "super_class": "fb_super_class", "class": "fb_class",
314
+ "sub_class": "fb_sub_class", "side": "fb_side"})
315
+ cls = cls[["root_id", "fb_flow", "fb_super_class", "fb_class", "fb_sub_class", "fb_side"]]
316
+ df = neu.rename(columns={"nt_type": "fb_nt"}).merge(cls, on="root_id", how="left")
317
+ for c in ("fb_flow", "fb_super_class", "fb_class", "fb_sub_class", "fb_side"):
318
+ df[c] = df[c].fillna("")
319
+ df["fb_nt"] = df["fb_nt"].str.strip().str.upper()
320
+
321
+ # flybrain group labels (classification.csv rows; neurons without a row keep GENERIC_CENTRAL)
322
+ groups = np.full(len(df), FLYBRAIN_GROUP_ID["GENERIC_CENTRAL"], dtype=np.uint16)
323
+ keyed = df[["fb_flow", "fb_super_class", "fb_class", "fb_sub_class", "fb_side"]].drop_duplicates()
324
+ cache: dict[tuple, int] = {}
325
+ for row in keyed.itertuples(index=False):
326
+ cache[tuple(row)] = FLYBRAIN_GROUP_ID[determine_group(row[0], row[1], row[2], row[3], "central", row[4])]
327
+ keys = list(zip(df["fb_flow"], df["fb_super_class"], df["fb_class"], df["fb_sub_class"], df["fb_side"]))
328
+ has_row = df["root_id"].isin(cls["root_id"]).to_numpy()
329
+ groups[has_row] = np.array([cache[k] for k, h in zip(keys, has_row) if h], dtype=np.uint16)
330
+ df["flybrain_group"] = groups
331
+
332
+ ann_path = Path(annotations) if annotations else ANNOTATIONS_TSV
333
+ use_ann = source == "annotations" and ann_path.exists()
334
+ if source == "annotations" and not ann_path.exists():
335
+ print(f"[populations] WARNING annotations TSV missing at {ann_path}; falling back to flybrain "
336
+ f"classification", file=sys.stderr)
337
+ if use_ann:
338
+ ann = pd.read_csv(ann_path, sep="\t", dtype=str, keep_default_na=False, low_memory=False,
339
+ usecols=["root_id", "super_class", "cell_class", "cell_sub_class", "cell_type", "top_nt"])
340
+ ann["root_id"] = ann["root_id"].astype(np.int64)
341
+ ann = ann.drop_duplicates("root_id")
342
+ ann["annotated"] = True
343
+ df = df.merge(ann, on="root_id", how="left")
344
+ df["annotated"] = df["annotated"].fillna(False).astype(bool)
345
+ for c in ("super_class", "cell_class", "cell_sub_class", "cell_type", "top_nt"):
346
+ df[c] = df[c].fillna("").str.strip()
347
+ src = "annotations"
348
+ else:
349
+ df["annotated"] = False
350
+ for c in ("super_class", "cell_class", "cell_sub_class", "cell_type", "top_nt"):
351
+ df[c] = ""
352
+ src = "flybrain"
353
+ fb_nt_named = df["fb_nt"].map(NT_FLYBRAIN_TO_NAME).fillna("")
354
+ df["nt"] = np.where(df["top_nt"] != "", df["top_nt"].str.lower(), fb_nt_named)
355
+ return df, src
356
+
357
+
358
+ def load_mbon_valence(path: str | Path | None = None) -> dict:
359
+ with open(path or MBON_VALENCE_JSON) as f:
360
+ return json.load(f)
361
+
362
+
363
+ def mbon_base_type(cell_type: str) -> str:
364
+ """Exact type only: '-like' annotations and combined labels ('MBON25,MBON34') stay unassignable
365
+ (no valence evidence for '-like' cells; literature review 2026-09-12)."""
366
+ return cell_type.strip()
367
+
368
+
369
+ # ---------------------------------------------------------------------------------------------
370
+ # Populations
371
+ # ---------------------------------------------------------------------------------------------
372
+ @dataclass
373
+ class Populations:
374
+ order: list[str]
375
+ ranges: dict[str, tuple[int, int]]
376
+ root_ids: np.ndarray # int64 [N], population order
377
+ nt: np.ndarray # str [N]
378
+ flybrain_group: np.ndarray # uint16 [N]
379
+ flybrain_group_names: list[str]
380
+ glomerulus_of_orn: np.ndarray # int32 [n_ORN_FOOD], -1 unknown
381
+ glomerulus_names: list[str]
382
+ canonical_index: np.ndarray # int64 [N]: sorted position i holds flybrain row canonical_index[i]
383
+ source: str = "annotations"
384
+ cell_type: np.ndarray = field(default_factory=lambda: np.zeros(0, dtype=object))
385
+ kind: dict[str, str] = field(default_factory=lambda: dict(POPULATION_KIND))
386
+
387
+ @property
388
+ def N(self) -> int:
389
+ return int(len(self.root_ids))
390
+
391
+ def n(self, name: str) -> int:
392
+ a, b = self.ranges[name]
393
+ return b - a
394
+
395
+ def index(self, name: str) -> np.ndarray:
396
+ a, b = self.ranges[name]
397
+ return np.arange(a, b, dtype=np.int64)
398
+
399
+ def slice(self, name: str) -> slice:
400
+ a, b = self.ranges[name]
401
+ return slice(a, b)
402
+
403
+ def population_id(self) -> np.ndarray:
404
+ pid = np.empty(self.N, dtype=np.int16)
405
+ for i, name in enumerate(self.order):
406
+ a, b = self.ranges[name]
407
+ pid[a:b] = i
408
+ return pid
409
+
410
+ def counts(self) -> dict[str, int]:
411
+ return {name: self.n(name) for name in self.order}
412
+
413
+ def inverse_index(self) -> np.ndarray:
414
+ """canonical flybrain row -> sorted position."""
415
+ inv = np.empty(self.N, dtype=np.int64)
416
+ inv[self.canonical_index] = np.arange(self.N, dtype=np.int64)
417
+ return inv
418
+
419
+
420
+ def _assign_annotations(df: pd.DataFrame, valence: dict) -> np.ndarray:
421
+ cc = df["cell_class"].to_numpy(dtype=object)
422
+ sc = df["cell_sub_class"].to_numpy(dtype=object)
423
+ ct = df["cell_type"].to_numpy(dtype=object)
424
+ base = np.array([mbon_base_type(t) for t in ct], dtype=object)
425
+ sup = np.where(df["annotated"].to_numpy(), df["super_class"].to_numpy(dtype=object),
426
+ df["fb_super_class"].to_numpy(dtype=object))
427
+ approach = set(valence["approach"])
428
+ avoid = set(valence["avoid"])
429
+ rew = valence.get("dan_reward_prefix", "PAM")
430
+ pun = valence.get("dan_punishment_prefix", "PPL1")
431
+ is_mbon = cc == "MBON"
432
+ is_dan = cc == "DAN"
433
+ is_olf = cc == "olfactory"
434
+ is_gus = cc == "gustatory"
435
+ is_mech = cc == "mechanosensory"
436
+ is_thermo = cc == "thermosensory"
437
+ is_hygro = cc == "hygrosensory"
438
+ starts = np.char.startswith
439
+ ct_s = ct.astype(str)
440
+ rules = {
441
+ "KC": cc == "Kenyon_Cell",
442
+ "MBON_APP": is_mbon & np.isin(base, list(approach)),
443
+ "MBON_AV": is_mbon & np.isin(base, list(avoid)),
444
+ "MBON_OTHER": is_mbon,
445
+ "DAN_PAM": is_dan & starts(ct_s, rew),
446
+ "DAN_PPL1": is_dan & starts(ct_s, pun),
447
+ "DAN_OTHER": is_dan,
448
+ "ORN_DANGER": is_olf & (sc == "pheromone"),
449
+ "ORN_FOOD": is_olf,
450
+ "ALPN": cc == "ALPN",
451
+ "ALLN": cc == "ALLN",
452
+ "LH": np.isin(cc, ["LHLN", "LHCENT"]),
453
+ "GRN_SWEET": is_gus & (sc == "sugar/water"),
454
+ "GRN_BITTER": is_gus & (sc == "bitter"),
455
+ "GRN_OTHER": is_gus,
456
+ "MECH_JO": is_mech & np.isin(sc, ["wind_gravity", "auditory"]),
457
+ "MECH_BRISTLE": is_mech & np.isin(sc, ["eye bristle", "head bristle"]),
458
+ "MECH_OTHER": is_mech,
459
+ "THERMO_WARM": (is_thermo & (sc == "heating")) | (is_hygro & (sc == "dry")),
460
+ "THERMO_COOL": (is_thermo & (sc == "cold")) | (is_hygro & np.isin(sc, ["cooling", "moist", "evaporative_cooling"])),
461
+ "THERMO_OTHER": is_thermo | is_hygro,
462
+ "CX": cc == "CX",
463
+ "HUNGER": df["flybrain_group"].to_numpy() == FLYBRAIN_GROUP_ID["DRIVE_HUNGER"],
464
+ "DESCENDING": sup == "descending",
465
+ "VISUAL": np.isin(sup, ["optic", "visual_projection", "visual_centrifugal"]) | (cc == "visual"),
466
+ }
467
+ return _first_match(rules, len(df))
468
+
469
+
470
+ def _contains(arr: np.ndarray, needle: str) -> np.ndarray:
471
+ return np.array([needle in s for s in arr], dtype=bool)
472
+
473
+
474
+ def _assign_flybrain(df: pd.DataFrame, dan_valence: np.ndarray | None) -> np.ndarray:
475
+ """Fallback partition from flybrain classification.csv (lower-cased fields)."""
476
+ cls = df["fb_class"].to_numpy(dtype=object)
477
+ sub = df["fb_sub_class"].to_numpy(dtype=object)
478
+ sup = df["fb_super_class"].to_numpy(dtype=object)
479
+ nt = df["nt"].to_numpy(dtype=object)
480
+ is_kc = _contains(cls, "kenyon") | (cls == "kc")
481
+ is_mbon = cls == "mbon"
482
+ is_dan = cls == "dan"
483
+ is_olf = _contains(cls, "olfact")
484
+ is_gus = _contains(cls, "gustat")
485
+ is_mech = _contains(cls, "mechano")
486
+ is_thermo = _contains(cls, "thermo")
487
+ is_hygro = _contains(cls, "hygro")
488
+ if dan_valence is None:
489
+ dan_valence = np.zeros(len(df), dtype=np.int8)
490
+ rules = {
491
+ "KC": is_kc,
492
+ "MBON_APP": is_mbon & np.isin(nt, ["acetylcholine", "gaba"]),
493
+ "MBON_AV": is_mbon & (nt == "glutamate"),
494
+ "MBON_OTHER": is_mbon,
495
+ "DAN_PAM": is_dan & (dan_valence > 0),
496
+ "DAN_PPL1": is_dan & (dan_valence < 0),
497
+ "DAN_OTHER": is_dan,
498
+ "ORN_DANGER": is_olf & (_contains(sub, "pheromone") | _contains(sub, "avers") | _contains(sub, "danger")),
499
+ "ORN_FOOD": is_olf,
500
+ "ALPN": cls == "alpn",
501
+ "ALLN": cls == "alln",
502
+ "LH": (cls == "lhln") | (cls == "lhcent"),
503
+ "GRN_SWEET": is_gus & (_contains(sub, "sugar") | _contains(sub, "water")),
504
+ "GRN_BITTER": is_gus & _contains(sub, "bitter"),
505
+ "GRN_OTHER": is_gus,
506
+ "MECH_JO": is_mech & (_contains(sub, "wind") | _contains(sub, "gravity") | _contains(sub, "auditory")),
507
+ "MECH_BRISTLE": is_mech & _contains(sub, "bristle"),
508
+ "MECH_OTHER": is_mech,
509
+ "THERMO_WARM": (is_thermo & (_contains(sub, "heat") | _contains(sub, "warm"))) | (is_hygro & _contains(sub, "dry")),
510
+ "THERMO_COOL": (is_thermo & (_contains(sub, "cold") | _contains(sub, "cool")))
511
+ | (is_hygro & (_contains(sub, "cool") | _contains(sub, "moist") | _contains(sub, "evap"))),
512
+ "THERMO_OTHER": is_thermo | is_hygro,
513
+ "CX": cls == "cx",
514
+ "HUNGER": df["flybrain_group"].to_numpy() == FLYBRAIN_GROUP_ID["DRIVE_HUNGER"],
515
+ "DESCENDING": _contains(sup, "descend"),
516
+ "VISUAL": np.isin(sup, ["optic", "visual_projection", "visual_centrifugal"]) | (cls == "visual"),
517
+ }
518
+ return _first_match(rules, len(df))
519
+
520
+
521
+ def _first_match(rules: dict[str, np.ndarray], n: int) -> np.ndarray:
522
+ pid = np.full(n, -1, dtype=np.int16)
523
+ for name in POPULATION_ORDER:
524
+ if name == "OTHER":
525
+ continue
526
+ m = rules[name] & (pid < 0)
527
+ pid[m] = POPULATION_ORDER.index(name)
528
+ pid[pid < 0] = POPULATION_ORDER.index("OTHER")
529
+ return pid
530
+
531
+
532
+ def fallback_dan_valence(df: pd.DataFrame, pid_partial: np.ndarray, edges: tuple[np.ndarray, np.ndarray, np.ndarray]) -> np.ndarray:
533
+ """+1 reward / -1 punishment / 0 unknown per canonical neuron, from synapse counts onto avoid vs
534
+ approach MBONs (reward DANs innervate the compartments whose MBONs drive avoidance)."""
535
+ pre, post, syn = edges
536
+ app = POPULATION_ORDER.index("MBON_APP")
537
+ av = POPULATION_ORDER.index("MBON_AV")
538
+ n = len(df)
539
+ onto_app = np.bincount(pre, weights=syn * (pid_partial[post] == app), minlength=n)
540
+ onto_av = np.bincount(pre, weights=syn * (pid_partial[post] == av), minlength=n)
541
+ val = np.zeros(n, dtype=np.int8)
542
+ val[onto_av > onto_app] = 1
543
+ val[(onto_app > 0) & (onto_app >= onto_av)] = -1
544
+ return val
545
+
546
+
547
+ def _glomeruli_annotations(cell_types: np.ndarray) -> tuple[np.ndarray, list[str]]:
548
+ names: list[str] = sorted({t[len("ORN_"):] for t in cell_types if t.startswith("ORN_") and len(t) > 4})
549
+ lut = {n: i for i, n in enumerate(names)}
550
+ gid = np.array([lut.get(t[len("ORN_"):], -1) if t.startswith("ORN_") else -1 for t in cell_types], dtype=np.int32)
551
+ return gid, names
552
+
553
+
554
+ def _glomeruli_fallback(root_ids: np.ndarray, seed: int) -> tuple[np.ndarray, list[str]]:
555
+ if CONSOLIDATED_TYPES_CSV.exists():
556
+ ct = pd.read_csv(CONSOLIDATED_TYPES_CSV, dtype=str, keep_default_na=False, usecols=["root_id", "primary_type"])
557
+ ct["root_id"] = ct["root_id"].astype(np.int64)
558
+ lut = dict(zip(ct["root_id"].to_numpy(), ct["primary_type"].to_numpy()))
559
+ types = np.array([lut.get(r, "") for r in root_ids], dtype=object)
560
+ gid, names = _glomeruli_annotations(types)
561
+ if len(names) >= MIN_TYPED_GLOMERULI:
562
+ return gid, names
563
+ rng = np.random.default_rng(seed)
564
+ gid = rng.integers(0, PSEUDO_GLOMERULI, size=len(root_ids)).astype(np.int32)
565
+ return gid, [f"pseudo_{i:02d}" for i in range(PSEUDO_GLOMERULI)]
566
+
567
+
568
+ def build_populations(annotations: str | Path | None = None, source: str | None = None,
569
+ edges: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None,
570
+ valence_path: str | Path | None = None, seed: int = 0, verbose: bool = True) -> Populations:
571
+ """Partition the neuron table into contiguous populations.
572
+
573
+ ``edges`` = (pre_canonical, post_canonical, syn_count) arrays; only needed for the flybrain
574
+ fallback (DAN valence by synapse counts).
575
+ """
576
+ df, src = load_neuron_table(annotations, source)
577
+ valence = load_mbon_valence(valence_path)
578
+ if src == "annotations":
579
+ pid = _assign_annotations(df, valence)
580
+ else:
581
+ pid = _assign_flybrain(df, None)
582
+ if edges is not None:
583
+ pid = _assign_flybrain(df, fallback_dan_valence(df, pid, edges))
584
+ elif verbose:
585
+ print("[populations] WARNING flybrain fallback without edges: DANs are all DAN_OTHER", file=sys.stderr)
586
+ root_ids = df["root_id"].to_numpy(dtype=np.int64)
587
+ perm = np.lexsort((root_ids, pid)) # by population, then root_id
588
+ pid_sorted = pid[perm]
589
+ ranges: dict[str, tuple[int, int]] = {}
590
+ for i, name in enumerate(POPULATION_ORDER):
591
+ a = int(np.searchsorted(pid_sorted, i, side="left"))
592
+ b = int(np.searchsorted(pid_sorted, i, side="right"))
593
+ ranges[name] = (a, b)
594
+ cell_type = df["cell_type"].to_numpy(dtype=object)[perm]
595
+ a, b = ranges["ORN_FOOD"]
596
+ if src == "annotations":
597
+ gid, gnames = _glomeruli_annotations(cell_type[a:b])
598
+ else:
599
+ gid, gnames = _glomeruli_fallback(root_ids[perm][a:b], seed)
600
+ pops = Populations(
601
+ order=list(POPULATION_ORDER), ranges=ranges, root_ids=root_ids[perm],
602
+ nt=df["nt"].to_numpy(dtype=object)[perm].astype(str), flybrain_group=df["flybrain_group"].to_numpy()[perm],
603
+ flybrain_group_names=list(FLYBRAIN_GROUP_NAMES), glomerulus_of_orn=gid, glomerulus_names=gnames,
604
+ canonical_index=perm.astype(np.int64), source=src, cell_type=cell_type,
605
+ )
606
+ n_kc = pops.n("KC")
607
+ n_mbon = pops.n("MBON_APP") + pops.n("MBON_AV") + pops.n("MBON_OTHER")
608
+ if n_kc != EXPECTED_KC or n_mbon != EXPECTED_MBON:
609
+ raise RuntimeError(f"population sanity check failed: KC={n_kc} (expected {EXPECTED_KC}), "
610
+ f"MBON={n_mbon} (expected {EXPECTED_MBON}); source={src}")
611
+ return pops
fly_trader/chain/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
fly_trader/chain/balances.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wallet balance snapshots and deltas (pattern: flywheel utils/balance_tracker.py:41-80).
2
+
3
+ A fill is verified ONLY by comparing a snapshot taken before the order with one taken after
4
+ confirmation (plan §9). SOL is tracked as lamports of the wallet itself; SPL balances are raw
5
+ integer amounts summed per mint across all accounts under both token programs (Jupiter wraps and
6
+ unwraps WSOL inside the transaction, so a SOL leg shows up as a lamport delta, not a WSOL delta).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+
13
+ from .. import config
14
+
15
+
16
+ @dataclass
17
+ class Snapshot:
18
+ lamports: int
19
+ tokens: dict[str, int]
20
+ ts: datetime
21
+ decimals: dict[str, int] = field(default_factory=dict)
22
+
23
+ def token(self, mint: str) -> int:
24
+ return int(self.tokens.get(mint, 0))
25
+
26
+ def to_json(self) -> dict:
27
+ return {"lamports": int(self.lamports), "tokens": {m: int(a) for m, a in self.tokens.items()},
28
+ "decimals": {m: int(d) for m, d in self.decimals.items()}, "ts": self.ts.isoformat()}
29
+
30
+
31
+ def snapshot_balances(rpc, pubkey) -> Snapshot:
32
+ lamports = int(rpc.get_balance(str(pubkey)))
33
+ tokens: dict[str, int] = {}
34
+ decimals: dict[str, int] = {}
35
+ for acct in rpc.get_token_accounts_by_owner(str(pubkey)):
36
+ mint = acct["mint"]
37
+ tokens[mint] = tokens.get(mint, 0) + int(acct["amount"])
38
+ if acct.get("decimals") is not None:
39
+ decimals[mint] = int(acct["decimals"])
40
+ return Snapshot(lamports=lamports, tokens=tokens, ts=config.utcnow(), decimals=decimals)
41
+
42
+
43
+ def compute_delta(pre: Snapshot, post: Snapshot) -> dict:
44
+ """``{"lamports_delta": int, "token_deltas": {mint: post - pre}}`` over every mint seen in either snapshot."""
45
+ mints = set(pre.tokens) | set(post.tokens)
46
+ return {
47
+ "lamports_delta": int(post.lamports) - int(pre.lamports),
48
+ "token_deltas": {m: int(post.tokens.get(m, 0)) - int(pre.tokens.get(m, 0)) for m in sorted(mints)},
49
+ }
fly_trader/chain/cluster_guard.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Default-deny signing gate (plan §9; pattern from better_bot code/solana_client.py:28-52).
2
+
3
+ Every path that signs a transaction calls ``assert_signing_allowed()`` first. Reads (balances,
4
+ quotes, signature statuses) never do. Signing is allowed only when ALL of these hold:
5
+ ``SOLANA_CLUSTER == 'mainnet-beta'`` (the only cluster where the graduated-memecoin universe
6
+ exists), ``LIVE_ENABLED=1``, and ``config.live_prerequisites_missing()`` is empty (capital, position
7
+ cap, gas reserve, bot key and API keys all set). ``config`` attributes are read at call time so a
8
+ test can monkeypatch them.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from .. import config
13
+
14
+
15
+ class SigningRefused(RuntimeError):
16
+ """Raised instead of signing when the live gate is not fully open."""
17
+
18
+
19
+ def assert_signing_allowed() -> None:
20
+ cluster = config.SOLANA_CLUSTER
21
+ if cluster != "mainnet-beta":
22
+ raise SigningRefused(f"refusing to sign on cluster {cluster!r}: only mainnet-beta is allowed")
23
+ if not config.LIVE_ENABLED:
24
+ raise SigningRefused("refusing to sign: LIVE_ENABLED is not set (default-deny)")
25
+ missing = config.live_prerequisites_missing()
26
+ if missing:
27
+ raise SigningRefused("refusing to sign: live prerequisites missing: " + ", ".join(missing))
28
+
29
+
30
+ def signing_allowed() -> bool:
31
+ try:
32
+ assert_signing_allowed()
33
+ return True
34
+ except SigningRefused:
35
+ return False
fly_trader/chain/jupiter_swap.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Jupiter Swap API v2 client: ``GET /order``, ``POST /execute``, ``GET /program-id-to-label``.
2
+
3
+ Base ``config.JUPITER_SWAP_BASE`` (https://api.jup.ag/swap/v2), header ``x-api-key``. Token-bucket
4
+ rate limits: one bucket (``config.JUPITER_RPS``) for ``/order`` and a separate one for ``/execute``
5
+ (Jupiter meters ``/execute`` separately). Every call is persisted to ``api_calls``
6
+ (service='jupiter'); the key is never stored (the request jsonb holds query params / a summary).
7
+
8
+ ``GET /order`` verified 2026-09-12 against mainnet (0.01 SOL -> USDC):
9
+ quote-only (no taker) -> HTTP 200 with fields: swapType ("aggregator"), inAmount, outAmount,
10
+ otherAmountThreshold, swapMode ("ExactIn"), slippageBps (0 when not requested), priceImpactPct
11
+ (string), routePlan [{percent, bps, usdValue, swapInfo {ammKey, label, inputMint, outputMint,
12
+ inAmount, outAmount}}], feeMint, feeBps (2 for SOL/USDC), platformFee {feeBps, feeMint},
13
+ signatureFeeLamports (0), signatureFeePayer (null), prioritizationFeeLamports (0),
14
+ prioritizationFeePayer (null), rentFeeLamports, rentFeePayer, transaction (null), gasless,
15
+ jitOptimized, taker (null), inputMint, outputMint, router ("metis"), guaranteedPrice, requestId,
16
+ inUsdValue, outUsdValue, swapUsdValue, priceImpact (number, percentage points), mode ("ultra" and
17
+ "manual" both observed), totalTime. No lastValidBlockHeight / expireAt / errorCode in the
18
+ quote-only response. feeBps was 2 for SOL/USDC (docs: 10 bps most pairs, 50 bps for tokens < 24 h).
19
+ with taker -> adds transaction (base64 v0, taker = fee payer, 1 required signature, no
20
+ pre-filled signatures), lastValidBlockHeight (STRING, e.g. "424496168"), signatureFeePayer ==
21
+ taker, signatureFeeLamports 5000, prioritizationFeeLamports/Payer, rentFeeLamports/Payer,
22
+ mode "manual". Documented error fields when the order cannot be built: errorCode
23
+ (aggregator: 1 insufficient funds, 2 insufficient SOL for gas, 3 below gasless minimum;
24
+ jupiterz: 1 insufficient balance, 2 missing ATA, 3 quote could not be built) + errorMessage.
25
+ Rate-limit headers: x-ratelimit-remaining / x-ratelimit-current / x-ratelimit-reset.
26
+
27
+ ``POST /execute`` (per the v2 reference; never exercised here): body {signedTransaction, requestId,
28
+ lastValidBlockHeight?(string)} -> {status "Success"|"Failed", signature, slot (string), code
29
+ (0 ok; -1 missing cached order, -2 invalid signed tx, -3 invalid message bytes; -1000 failed to
30
+ land, -1001 unknown, -1002 invalid tx, -1003 not fully signed, -1004 invalid block height;
31
+ -2000 RFQ failed to land, -2001 unknown, -2002 invalid payload, -2003 quote expired, -2004 swap
32
+ rejected), error, totalInputAmount, totalOutputAmount, inputAmountResult, outputAmountResult,
33
+ swapEvents [{inputMint, inputAmount, outputMint, outputAmount}]}. The same signedTransaction +
34
+ requestId is idempotent for ~2 minutes.
35
+
36
+ ``GET /program-id-to-label`` verified keyless at https://api.jup.ag/swap/v2/program-id-to-label
37
+ (107 entries; /swap/v1 and lite-api.jup.ag serve the same map). Cached to
38
+ ``data/jupiter_program_labels.json``.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import logging
44
+ import os
45
+ import time
46
+ from pathlib import Path
47
+
48
+ import httpx
49
+
50
+ from .. import config
51
+ from ..db.apilog import record_api_call
52
+ from ..logging_setup import scrub
53
+ from .jupiter_tokens import TokenBucket
54
+
55
+ log = logging.getLogger(__name__)
56
+
57
+ JUPITER_EXECUTE_RPS = config.JUPITER_RPS # separate bucket, same rate
58
+ JUPITER_LABELS_URL = "https://api.jup.ag/swap/v2/program-id-to-label"
59
+ JUPITER_LABELS_CACHE = config.DATA_DIR / "jupiter_program_labels.json"
60
+ JUPITER_LABELS_MAX_AGE_S = 86400.0
61
+ DEFAULT_EXCLUDE_ROUTERS = "jupiterz"
62
+
63
+
64
+ class JupiterError(RuntimeError):
65
+ def __init__(self, endpoint: str, status: int | None, detail: str = ""):
66
+ self.endpoint = endpoint
67
+ self.status = status
68
+ self.detail = detail
69
+ super().__init__(f"jupiter {endpoint}: HTTP {status}: {detail}")
70
+
71
+
72
+ def _read_labels_cache(path: Path) -> dict | None:
73
+ try:
74
+ with open(path, encoding="utf-8") as f:
75
+ data = json.load(f)
76
+ if isinstance(data, dict) and isinstance(data.get("labels"), dict):
77
+ return data
78
+ except (OSError, ValueError):
79
+ pass
80
+ return None
81
+
82
+
83
+ def fetch_program_labels(cache_path: Path | None = None, refresh: bool = False,
84
+ max_age_s: float = JUPITER_LABELS_MAX_AGE_S) -> dict[str, str]:
85
+ """Keyless ``GET /program-id-to-label`` with a JSON file cache; stale cache on network failure."""
86
+ path = Path(cache_path) if cache_path else JUPITER_LABELS_CACHE
87
+ cached = _read_labels_cache(path)
88
+ if cached and not refresh and time.time() - float(cached.get("fetched_at", 0)) < max_age_s:
89
+ return cached["labels"]
90
+ t0 = time.monotonic()
91
+ status = None
92
+ err = None
93
+ nbytes = None
94
+ try:
95
+ r = httpx.get(JUPITER_LABELS_URL, timeout=20)
96
+ status = r.status_code
97
+ nbytes = len(r.content)
98
+ body = r.json() if status < 400 else None
99
+ if status >= 400 or not isinstance(body, dict):
100
+ raise JupiterError("/program-id-to-label", status, scrub(r.text[:200]))
101
+ labels = {str(k): str(v) for k, v in body.items()}
102
+ except (JupiterError, httpx.HTTPError, ValueError) as e:
103
+ err = f"{type(e).__name__}: {scrub(str(e))}"
104
+ if cached:
105
+ log.warning("program-id-to-label fetch failed (%s); using stale cache", type(e).__name__)
106
+ return cached["labels"]
107
+ raise
108
+ finally:
109
+ record_api_call("jupiter", "swap/program-id-to-label", "GET", status,
110
+ int((time.monotonic() - t0) * 1000), err is None, err, response_bytes=nbytes)
111
+ try:
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ tmp = path.with_suffix(".tmp")
114
+ with open(tmp, "w", encoding="utf-8") as f:
115
+ json.dump({"fetched_at": time.time(), "url": JUPITER_LABELS_URL, "labels": labels}, f, indent=0)
116
+ os.replace(tmp, path)
117
+ except OSError as e:
118
+ log.warning("could not write %s: %s", path, type(e).__name__)
119
+ return labels
120
+
121
+
122
+ class JupiterSwap:
123
+ def __init__(self, api_key: str | None = None, rps: float | None = None, timeout_s: float = 30.0):
124
+ self.api_key = api_key or config.JUPITER_API_KEY
125
+ if not self.api_key:
126
+ raise RuntimeError("JUPITER_API_KEY is not set")
127
+ self.base = config.JUPITER_SWAP_BASE
128
+ self.order_bucket = TokenBucket(rps or config.JUPITER_RPS)
129
+ self.execute_bucket = TokenBucket(rps or JUPITER_EXECUTE_RPS)
130
+ self.client = httpx.Client(timeout=timeout_s, headers={"x-api-key": self.api_key})
131
+
132
+ def _request(self, method: str, path: str, *, bucket: TokenBucket, params: dict | None = None,
133
+ json_body: dict | None = None, log_request: dict | None = None, accept_error_body,
134
+ retries: int = 3) -> tuple[int, object, int]:
135
+ """One rate-limited request. Returns ``(http_status, parsed_body, latency_ms)``.
136
+ Raises ``JupiterError`` on transport errors and on HTTP >= 400 unless ``accept_error_body(body)``
137
+ says the JSON body is a structured Jupiter result the caller wants to see."""
138
+ bucket.acquire()
139
+ t0 = time.monotonic()
140
+ status: int | None = None
141
+ err: str | None = None
142
+ nbytes: int | None = None
143
+ ok = False
144
+ try:
145
+ for attempt in range(retries + 1):
146
+ try:
147
+ r = self.client.request(method, self.base + path, params=params, json=json_body)
148
+ except httpx.HTTPError as e:
149
+ raise JupiterError(path, None, f"{type(e).__name__}: {scrub(str(e))}") from None
150
+ status = r.status_code
151
+ nbytes = len(r.content)
152
+ if status == 429 and attempt < retries:
153
+ time.sleep(1.0 + attempt)
154
+ bucket.acquire()
155
+ continue
156
+ break
157
+ try:
158
+ body = r.json()
159
+ except ValueError:
160
+ body = None
161
+ latency = int((time.monotonic() - t0) * 1000)
162
+ if status >= 400 and not accept_error_body(body):
163
+ snippet = json.dumps(body)[:300] if body is not None else r.text[:300]
164
+ raise JupiterError(path, status, scrub(snippet))
165
+ if isinstance(body, dict):
166
+ if body.get("errorCode") is not None:
167
+ err = f"errorCode {body.get('errorCode')}: {body.get('errorMessage') or body.get('error') or ''}"
168
+ elif body.get("code") not in (None, 0):
169
+ err = f"code {body.get('code')}: {body.get('error') or body.get('status') or ''}"
170
+ ok = status < 400 and err is None
171
+ return status, body, latency
172
+ except JupiterError as e:
173
+ err = str(e)
174
+ raise
175
+ finally:
176
+ record_api_call("jupiter", f"swap{path}", method, status, int((time.monotonic() - t0) * 1000),
177
+ ok, scrub(err) if err else None, request=log_request, response_bytes=nbytes)
178
+
179
+ def order(self, input_mint: str, output_mint: str, amount: int, taker: str | None = None,
180
+ slippage_bps: int | None = None, exclude_routers: str | None = DEFAULT_EXCLUDE_ROUTERS,
181
+ exclude_dexes: str | None = None) -> dict:
182
+ """``GET /order``. Without ``taker`` this is a quote (no transaction is built). Returns the
183
+ parsed JSON plus ``_latency_ms`` and ``_http_status``; a body carrying ``errorCode`` is returned
184
+ as-is (the caller decides), other HTTP errors raise ``JupiterError``."""
185
+ params: dict = {"inputMint": input_mint, "outputMint": output_mint, "amount": str(int(amount))}
186
+ if taker:
187
+ params["taker"] = str(taker)
188
+ if slippage_bps is not None:
189
+ params["slippageBps"] = int(slippage_bps)
190
+ if exclude_routers:
191
+ params["excludeRouters"] = exclude_routers
192
+ if exclude_dexes:
193
+ params["excludeDexes"] = exclude_dexes
194
+ status, body, latency = self._request(
195
+ "GET", "/order", bucket=self.order_bucket, params=params, log_request={"params": params},
196
+ accept_error_body=lambda b: isinstance(b, dict) and "errorCode" in b)
197
+ if not isinstance(body, dict):
198
+ raise JupiterError("/order", status, "non-JSON response")
199
+ body["_latency_ms"] = latency
200
+ body["_http_status"] = status
201
+ return body
202
+
203
+ def execute(self, signed_transaction_b64: str, request_id: str, last_valid_block_height: int | None = None) -> dict:
204
+ """``POST /execute``. Returns the parsed JSON plus ``_latency_ms``/``_http_status``; a body with
205
+ ``status``/``code`` is returned even on HTTP errors so the broker can see the code."""
206
+ payload: dict = {"signedTransaction": signed_transaction_b64, "requestId": request_id}
207
+ if last_valid_block_height is not None:
208
+ payload["lastValidBlockHeight"] = str(int(last_valid_block_height))
209
+ summary = {"requestId": request_id, "lastValidBlockHeight": payload.get("lastValidBlockHeight"),
210
+ "signedTransactionBytes": len(signed_transaction_b64)}
211
+ status, body, latency = self._request(
212
+ "POST", "/execute", bucket=self.execute_bucket, json_body=payload, log_request=summary,
213
+ accept_error_body=lambda b: isinstance(b, dict) and ("code" in b or "status" in b))
214
+ if not isinstance(body, dict):
215
+ raise JupiterError("/execute", status, "non-JSON response")
216
+ body["_latency_ms"] = latency
217
+ body["_http_status"] = status
218
+ return body
219
+
220
+ def program_id_to_label(self, refresh: bool = False) -> dict[str, str]:
221
+ return fetch_program_labels(refresh=refresh)
fly_trader/chain/jupiter_tokens.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Jupiter Tokens API v2 client (https://api.jup.ag/tokens/v2).
2
+
3
+ Endpoint (verified 2026-09-12 with the paid key): /search?query=<csv of up to 100 mints>. Tokens carry organicScore,
4
+ holderCount, liquidity, mcap, stats5m/1h/6h/24h and audit: mintAuthorityDisabled,
5
+ freezeAuthorityDisabled, topHoldersPercentage, devBalancePercentage, devMints, devMigrations (isSus is
6
+ absent unless flagged). Rate limit headers: x-ratelimit-remaining/current/reset.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import threading
12
+ import time
13
+
14
+ import httpx
15
+
16
+ from .. import config
17
+ from ..db.apilog import record_api_call
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ class TokenBucket:
23
+ def __init__(self, rate_per_s: float, burst: int | None = None):
24
+ self.rate = rate_per_s
25
+ self.capacity = burst or max(1, int(rate_per_s))
26
+ self.tokens = float(self.capacity)
27
+ self.ts = time.monotonic()
28
+ self.lock = threading.Lock()
29
+
30
+ def acquire(self) -> None:
31
+ while True:
32
+ with self.lock:
33
+ now = time.monotonic()
34
+ self.tokens = min(self.capacity, self.tokens + (now - self.ts) * self.rate)
35
+ self.ts = now
36
+ if self.tokens >= 1:
37
+ self.tokens -= 1
38
+ return
39
+ wait = (1 - self.tokens) / self.rate
40
+ time.sleep(wait)
41
+
42
+
43
+ class JupiterTokens:
44
+ def __init__(self, api_key: str | None = None, rps: float | None = None):
45
+ self.api_key = api_key or config.JUPITER_API_KEY
46
+ if not self.api_key:
47
+ raise RuntimeError("JUPITER_API_KEY is not set")
48
+ self.base = config.JUPITER_TOKENS_BASE
49
+ self.bucket = TokenBucket(rps or config.JUPITER_RPS)
50
+ self.client = httpx.Client(timeout=30, headers={"x-api-key": self.api_key})
51
+
52
+ def _get(self, path: str, params: dict | None = None) -> list[dict]:
53
+ self.bucket.acquire()
54
+ t0 = time.monotonic()
55
+ status = None
56
+ err = None
57
+ try:
58
+ for attempt in range(4):
59
+ r = self.client.get(self.base + path, params=params)
60
+ status = r.status_code
61
+ if r.status_code == 429:
62
+ time.sleep(1.0 + attempt)
63
+ continue
64
+ r.raise_for_status()
65
+ data = r.json()
66
+ return data if isinstance(data, list) else []
67
+ raise httpx.HTTPStatusError("rate limited", request=r.request, response=r)
68
+ except Exception as e:
69
+ err = f"{type(e).__name__}: {e}"
70
+ raise
71
+ finally:
72
+ record_api_call("jupiter", f"tokens{path}", "GET", status, int((time.monotonic() - t0) * 1000),
73
+ err is None, err, request={"params": params} if params else None)
74
+
75
+ def search(self, query: str) -> list[dict]:
76
+ return self._get("/search", {"query": query})
77
+
78
+ def search_many(self, mints: list[str]) -> list[dict]:
79
+ out: list[dict] = []
80
+ for i in range(0, len(mints), 100):
81
+ out.extend(self.search(",".join(mints[i:i + 100])))
82
+ return out
fly_trader/chain/keys.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bot wallet keys (plan §9; pattern lifted from VOC dexlp/keys.py).
2
+
3
+ The secret lives ONLY in the gitignored ``.env`` as ``BOT_PRIVATE_KEY`` — base58 of the 64-byte
4
+ secret key (what ``create_wallet`` writes) or a JSON int array (what ``solana-keygen`` writes). It
5
+ is never logged, never printed and never placed in an exception message: errors name the source
6
+ and the failure type only. Note that ``str(Keypair)`` IS the base58 secret, so no Keypair object
7
+ is ever formatted, repr'd or passed to a logger in this module.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ from pathlib import Path
16
+
17
+ import base58
18
+ from psycopg.types.json import Jsonb
19
+ from solders.keypair import Keypair
20
+
21
+ from .. import config
22
+ from ..db.connection import transaction
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ ENV_NAME = "BOT_PRIVATE_KEY"
27
+ ENV_PATH = config.REPO_ROOT / ".env"
28
+ _ENV_LINE = re.compile(r"^\s*(?:export\s+)?BOT_PRIVATE_KEY\s*=")
29
+
30
+
31
+ class WalletKeyError(RuntimeError):
32
+ """Missing or malformed bot key. The message never includes secret material."""
33
+
34
+
35
+ class WalletExists(RuntimeError):
36
+ """``create_wallet`` refused because a BOT_PRIVATE_KEY already exists."""
37
+
38
+
39
+ def _keypair_from(raw: str | None) -> Keypair:
40
+ raw = (raw or "").strip()
41
+ if not raw:
42
+ raise WalletKeyError(f"{ENV_NAME} not set")
43
+ try:
44
+ if raw.startswith("["):
45
+ arr = json.loads(raw)
46
+ if not isinstance(arr, list):
47
+ raise ValueError("not a list")
48
+ data = bytes(int(x) & 0xFF for x in arr)
49
+ else:
50
+ data = base58.b58decode(raw)
51
+ except Exception as e: # never surface the value
52
+ raise WalletKeyError(f"could not decode {ENV_NAME}: {type(e).__name__}") from None
53
+ try:
54
+ if len(data) == 64:
55
+ return Keypair.from_bytes(data)
56
+ if len(data) == 32: # a bare seed (solana-keygen never writes this, but accept it)
57
+ return Keypair.from_seed(data)
58
+ except Exception as e:
59
+ raise WalletKeyError(f"invalid {ENV_NAME} bytes: {type(e).__name__}") from None
60
+ raise WalletKeyError(f"{ENV_NAME} must decode to 64 bytes (got {len(data)})")
61
+
62
+
63
+ def load_keypair(raw: str | None = None) -> Keypair:
64
+ """Keypair from ``BOT_PRIVATE_KEY`` (or ``raw``). Accepts base58 or a JSON int array."""
65
+ return _keypair_from(raw if raw is not None else os.environ.get(ENV_NAME))
66
+
67
+
68
+ def bot_pubkey(raw: str | None = None) -> str:
69
+ return str(load_keypair(raw).pubkey())
70
+
71
+
72
+ def key_available() -> bool:
73
+ try:
74
+ load_keypair()
75
+ return True
76
+ except WalletKeyError:
77
+ return False
78
+
79
+
80
+ def env_file_has_key(path: Path | None = None) -> bool:
81
+ path = Path(path) if path else ENV_PATH
82
+ if not path.exists():
83
+ return False
84
+ with open(path, encoding="utf-8") as f:
85
+ return any(_ENV_LINE.match(line) for line in f)
86
+
87
+
88
+ def create_wallet(env_path: Path | None = None, url: str | None = None) -> str:
89
+ """Generate the bot keypair once and append it to ``.env`` (mode 600). Returns the pubkey.
90
+
91
+ Refuses if ``BOT_PRIVATE_KEY`` is already present in the process environment or in the file.
92
+ A preceding newline is written when the file does not end with one. Only the pubkey is
93
+ logged; a ``wallet_events(kind='created')`` row records the event.
94
+ """
95
+ path = Path(env_path) if env_path else ENV_PATH
96
+ if os.environ.get(ENV_NAME, "").strip():
97
+ raise WalletExists(f"{ENV_NAME} is already set in the environment; refusing to create another wallet")
98
+ if env_file_has_key(path):
99
+ raise WalletExists(f"{path} already contains {ENV_NAME}; refusing to overwrite")
100
+ kp = Keypair()
101
+ pubkey = str(kp.pubkey())
102
+ secret_b58 = base58.b58encode(bytes(kp)).decode()
103
+ needs_newline = path.exists() and path.stat().st_size > 0 and not path.read_bytes().endswith(b"\n")
104
+ fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
105
+ try:
106
+ with os.fdopen(fd, "a", encoding="utf-8") as f:
107
+ if needs_newline:
108
+ f.write("\n")
109
+ f.write(f"{ENV_NAME}={secret_b58}\n")
110
+ finally:
111
+ os.chmod(path, 0o600)
112
+ del secret_b58, kp
113
+ try:
114
+ with transaction(url) as conn:
115
+ conn.execute(
116
+ "INSERT INTO wallet_events (kind, pubkey, detail) VALUES ('created', %s, %s)",
117
+ (pubkey, Jsonb({"env_path": str(path)})),
118
+ )
119
+ except Exception as e: # the key is already safely on disk; do not lose it over a DB hiccup
120
+ log.warning("wallet_events insert failed after wallet creation: %s", type(e).__name__)
121
+ log.info("bot wallet created: %s", pubkey)
122
+ return pubkey
123
+
124
+
125
+ def show_wallet(rpc=None, url: str | None = None) -> str:
126
+ """Pubkey + SOL balance (read-only RPC); records ``wallet_events(kind='balance_snapshot')``."""
127
+ pubkey = bot_pubkey()
128
+ if rpc is None:
129
+ from .rpc import HttpSolanaRpc
130
+ rpc = HttpSolanaRpc()
131
+ lamports = int(rpc.get_balance(pubkey))
132
+ sol = lamports / config.LAMPORTS_PER_SOL
133
+ try:
134
+ with transaction(url) as conn:
135
+ conn.execute(
136
+ "INSERT INTO wallet_events (kind, pubkey, detail) VALUES ('balance_snapshot', %s, %s)",
137
+ (pubkey, Jsonb({"lamports": lamports, "sol": sol})),
138
+ )
139
+ except Exception as e:
140
+ log.warning("wallet_events insert failed: %s", type(e).__name__)
141
+ return f"pubkey={pubkey} sol={sol:.9f} lamports={lamports}"
fly_trader/chain/rpc.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Solana JSON-RPC over Helius using httpx + solders only (no solana-py). Pattern: VOC
2
+ dexlp/ingest/helius_swaps.py HttpSolanaRpc.
3
+
4
+ The RPC URL carries the Helius API key: it is never logged, never stored in ``api_calls`` and never
5
+ placed in an exception message (httpx error text is scrubbed and re-raised as our own types).
6
+ Every call is persisted to ``api_calls`` (service='helius', endpoint=<method>) through
7
+ ``fly_trader.db.apilog.record_api_call``, which swallows DB errors so a database hiccup never
8
+ breaks an RPC call.
9
+
10
+ Response shapes verified against mainnet on 2026-09-12:
11
+ getBalance -> {"context": {"slot", "apiVersion"}, "value": <int lamports>}
12
+ getTokenAccountsByOwner (jsonParsed) -> {"context", "value": [{"pubkey", "account": {"lamports",
13
+ "owner": <program>, "data": {"program": "spl-token"|"spl-token-2022",
14
+ "parsed": {"type": "account", "info": {"mint", "owner", "state", "isNative",
15
+ "tokenAmount": {"amount": "<str>", "decimals", "uiAmount", "uiAmountString"},
16
+ "extensions": [...] (token-2022 only)}}}}}]}
17
+ getSignatureStatuses -> {"context", "value": [{"slot", "confirmations", "err", "status",
18
+ "confirmationStatus": processed|confirmed|finalized} | null]}
19
+ getLatestBlockhash -> {"context", "value": {"blockhash", "lastValidBlockHeight": <int>}}
20
+ getBlockHeight -> <int> (same units as lastValidBlockHeight)
21
+ getTransaction -> {"transaction", "meta": {"err", "status", "fee", "preBalances",
22
+ "postBalances", "preTokenBalances", "postTokenBalances", "innerInstructions",
23
+ "logMessages", "computeUnitsConsumed", ...}, "version", "slot", "blockTime"}
24
+ sendTransaction -> "<signature>"; malformed input returns error {"code": -32602, ...}
25
+ JSON-RPC batches are supported (array in, array out, matched by id).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import threading
31
+ import time
32
+ from typing import Any
33
+
34
+ import httpx
35
+
36
+ from .. import config
37
+ from ..db.apilog import record_api_call
38
+ from ..logging_setup import scrub
39
+
40
+ log = logging.getLogger(__name__)
41
+
42
+ TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
43
+ TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
44
+ TOKEN_PROGRAM_IDS = (TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID)
45
+ RPC_TIMEOUT_S = 15.0
46
+ COMMITMENT = "confirmed"
47
+
48
+
49
+ class RpcError(RuntimeError):
50
+ """A JSON-RPC level error ({"error": {code, message, data}})."""
51
+
52
+ def __init__(self, method: str, error: dict | Any):
53
+ err = error if isinstance(error, dict) else {"message": str(error)}
54
+ self.method = method
55
+ self.code = err.get("code")
56
+ self.message = str(err.get("message", ""))
57
+ self.data = err.get("data")
58
+ super().__init__(f"rpc {method} error {self.code}: {scrub(self.message)}")
59
+
60
+
61
+ class RpcTransportError(RuntimeError):
62
+ """HTTP/transport failure. Message never contains the URL."""
63
+
64
+
65
+ def _request_summary(method: str, params: list | None) -> dict:
66
+ if method == "sendTransaction" and params:
67
+ opts = params[1] if len(params) > 1 and isinstance(params[1], dict) else {}
68
+ return {"tx_b64_len": len(params[0]) if isinstance(params[0], str) else None, **opts}
69
+ if method == "getSignatureStatuses" and params:
70
+ # full signatures are 88 base58 chars and the log scrubber would redact them as secrets
71
+ return {"n": len(params[0]), "signature_prefixes": [str(s)[:12] for s in params[0]]}
72
+ if method == "getTransaction" and params:
73
+ return {"signature_prefix": str(params[0])[:12]}
74
+ return {"params": params}
75
+
76
+
77
+ class HttpSolanaRpc:
78
+ def __init__(self, url: str | None = None, timeout_s: float = RPC_TIMEOUT_S):
79
+ self._url = url or config.helius_http_url()
80
+ self._client = httpx.Client(timeout=timeout_s)
81
+ self._id = 0
82
+ self._lock = threading.Lock()
83
+
84
+ def _next_id(self) -> int:
85
+ with self._lock:
86
+ self._id += 1
87
+ return self._id
88
+
89
+ def _post(self, payload: dict | list, endpoint: str, summary: dict | None):
90
+ t0 = time.monotonic()
91
+ status: int | None = None
92
+ err: str | None = None
93
+ nbytes: int | None = None
94
+ try:
95
+ try:
96
+ r = self._client.post(self._url, json=payload)
97
+ except httpx.HTTPError as e:
98
+ raise RpcTransportError(f"{endpoint}: {type(e).__name__}: {scrub(str(e))}") from None
99
+ status = r.status_code
100
+ nbytes = len(r.content)
101
+ if status >= 400:
102
+ raise RpcTransportError(f"{endpoint}: HTTP {status}: {scrub(r.text[:200])}")
103
+ try:
104
+ body = r.json()
105
+ except ValueError:
106
+ raise RpcTransportError(f"{endpoint}: non-JSON response ({nbytes} bytes)") from None
107
+ if isinstance(payload, dict):
108
+ if not isinstance(body, dict):
109
+ raise RpcTransportError(f"{endpoint}: unexpected response shape {type(body).__name__}")
110
+ if body.get("error"):
111
+ raise RpcError(endpoint, body["error"])
112
+ return body.get("result")
113
+ if not isinstance(body, list):
114
+ raise RpcTransportError(f"{endpoint}: batch response is not a list")
115
+ by_id = {item.get("id"): item for item in body if isinstance(item, dict)}
116
+ results = []
117
+ for req in payload:
118
+ item = by_id.get(req["id"])
119
+ if item is None:
120
+ raise RpcTransportError(f"{endpoint}: missing batch response for id {req['id']}")
121
+ if item.get("error"):
122
+ raise RpcError(req["method"], item["error"])
123
+ results.append(item.get("result"))
124
+ return results
125
+ except Exception as e:
126
+ err = str(e)
127
+ raise
128
+ finally:
129
+ record_api_call("helius", endpoint, "POST", status, int((time.monotonic() - t0) * 1000),
130
+ err is None, err, request=summary, response_bytes=nbytes)
131
+
132
+ # ---- generic ----
133
+ def call(self, method: str, params: list | None = None):
134
+ payload = {"jsonrpc": "2.0", "id": self._next_id(), "method": method, "params": params or []}
135
+ return self._post(payload, method, _request_summary(method, params))
136
+
137
+ def batch(self, calls: list[tuple[str, list | None]]) -> list:
138
+ """Send several calls in one HTTP request; results in call order. Raises on the first error."""
139
+ if not calls:
140
+ return []
141
+ payload = [{"jsonrpc": "2.0", "id": self._next_id(), "method": m, "params": p or []} for m, p in calls]
142
+ methods = [m for m, _ in calls]
143
+ return self._post(payload, "batch:" + ",".join(methods), {"methods": methods})
144
+
145
+ # ---- typed helpers ----
146
+ def get_balance(self, pubkey) -> int:
147
+ res = self.call("getBalance", [str(pubkey), {"commitment": COMMITMENT}])
148
+ return int(res["value"])
149
+
150
+ def get_token_accounts_by_owner(self, pubkey) -> list[dict]:
151
+ """All token accounts of ``pubkey`` under BOTH token programs, as
152
+ ``{address, mint, amount:int, decimals, program}``."""
153
+ out: list[dict] = []
154
+ for program in TOKEN_PROGRAM_IDS:
155
+ res = self.call("getTokenAccountsByOwner",
156
+ [str(pubkey), {"programId": program}, {"encoding": "jsonParsed", "commitment": COMMITMENT}])
157
+ for item in (res or {}).get("value") or []:
158
+ try:
159
+ info = item["account"]["data"]["parsed"]["info"]
160
+ amt = info["tokenAmount"]
161
+ out.append({"address": item["pubkey"], "mint": info["mint"], "amount": int(amt["amount"]),
162
+ "decimals": int(amt["decimals"]), "program": program})
163
+ except (KeyError, TypeError, ValueError):
164
+ log.warning("unparsed token account %s", item.get("pubkey") if isinstance(item, dict) else "?")
165
+ return out
166
+
167
+ def get_signature_statuses(self, sigs: list[str], search_history: bool = False) -> list[dict | None]:
168
+ res = self.call("getSignatureStatuses", [list(sigs), {"searchTransactionHistory": bool(search_history)}])
169
+ value = (res or {}).get("value")
170
+ if not isinstance(value, list):
171
+ return [None] * len(sigs)
172
+ return value
173
+
174
+ def get_transaction(self, sig: str, encoding: str = "jsonParsed") -> dict | None:
175
+ return self.call("getTransaction", [sig, {"encoding": encoding, "maxSupportedTransactionVersion": 0,
176
+ "commitment": COMMITMENT}])
177
+
178
+ def get_latest_blockhash(self) -> dict:
179
+ """``{"blockhash": str, "last_valid_block_height": int}``."""
180
+ v = self.call("getLatestBlockhash", [{"commitment": COMMITMENT}])["value"]
181
+ return {"blockhash": v["blockhash"], "last_valid_block_height": int(v["lastValidBlockHeight"])}
182
+
183
+ def get_block_height(self) -> int:
184
+ return int(self.call("getBlockHeight", [{"commitment": COMMITMENT}]))
185
+
186
+ def get_account_info(self, pubkey) -> dict | None:
187
+ res = self.call("getAccountInfo", [str(pubkey), {"encoding": "jsonParsed", "commitment": COMMITMENT}])
188
+ return (res or {}).get("value")
189
+
190
+ def get_mint_decimals(self, mint: str) -> int | None:
191
+ try:
192
+ info = self.get_account_info(mint)
193
+ return int(info["data"]["parsed"]["info"]["decimals"])
194
+ except Exception:
195
+ return None
196
+
197
+ def send_transaction(self, tx_b64: str, skip_preflight: bool = False, max_retries: int = 3) -> str:
198
+ sig = self.call("sendTransaction", [tx_b64, {"encoding": "base64", "skipPreflight": bool(skip_preflight),
199
+ "preflightCommitment": COMMITMENT, "maxRetries": int(max_retries)}])
200
+ return str(sig)
fly_trader/chain/signing.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sign only our own signer slot of a Jupiter-built VersionedTransaction (plan §9).
2
+
3
+ The multi-signer trap (flywheel utils/transaction_signing.py:99-205): ``VersionedTransaction(msg,
4
+ [keypair])`` demands keypairs for EVERY required signer and replaces all signatures, which pushed
5
+ that code into rewriting account keys and the header — Jupiter then rejects the transaction (and a
6
+ rewritten fee payer would make us pay for someone else's message). Here the message, header and fee
7
+ payer are never touched: we locate our pubkey among the static account keys, require its index to
8
+ be below ``num_required_signatures``, sign the versioned message bytes, and drop the signature into
9
+ that slot while keeping any signatures already present (RFQ makers pre-sign their slot).
10
+
11
+ Verified 2026-09-12: a Swap v2 ``/order`` transaction with a taker is v0 with
12
+ ``num_required_signatures=1``, the taker as static key 0 (fee payer), 4 address-table lookups and no
13
+ pre-filled signatures.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+
19
+ from solders.message import to_bytes_versioned
20
+ from solders.signature import Signature
21
+ from solders.transaction import VersionedTransaction
22
+
23
+
24
+ class NotASigner(ValueError):
25
+ """Our pubkey is absent from the static keys or sits outside the signer range."""
26
+
27
+
28
+ def _as_tx(tx) -> VersionedTransaction:
29
+ if isinstance(tx, VersionedTransaction):
30
+ return tx
31
+ if isinstance(tx, str):
32
+ tx = base64.b64decode(tx)
33
+ return VersionedTransaction.from_bytes(bytes(tx))
34
+
35
+
36
+ def signer_index(tx, pubkey) -> int:
37
+ """Index of ``pubkey`` among the static account keys, verified to be a required signer."""
38
+ tx = _as_tx(tx)
39
+ msg = tx.message
40
+ keys = list(msg.account_keys)
41
+ if pubkey not in keys:
42
+ raise NotASigner(f"{pubkey} is not among the transaction's static account keys")
43
+ idx = keys.index(pubkey)
44
+ n_req = msg.header.num_required_signatures
45
+ if idx >= n_req:
46
+ raise NotASigner(f"{pubkey} is account {idx} but only the first {n_req} account(s) sign")
47
+ return idx
48
+
49
+
50
+ def sign_transaction_b64(tx_b64: str, keypair) -> tuple[str, int]:
51
+ """Return ``(base64 signed transaction, our signer index)``. Message/header/fee payer untouched."""
52
+ tx = _as_tx(tx_b64)
53
+ msg = tx.message
54
+ idx = signer_index(tx, keypair.pubkey())
55
+ n_req = msg.header.num_required_signatures
56
+ sigs = list(tx.signatures)
57
+ if len(sigs) < n_req:
58
+ sigs.extend([Signature.default()] * (n_req - len(sigs)))
59
+ sigs[idx] = keypair.sign_message(to_bytes_versioned(msg))
60
+ signed = VersionedTransaction.populate(msg, sigs)
61
+ return base64.b64encode(bytes(signed)).decode(), idx
62
+
63
+
64
+ def transaction_id(tx) -> str:
65
+ """The transaction signature (first signature) of a signed transaction."""
66
+ return str(_as_tx(tx).signatures[0])
67
+
68
+
69
+ def static_program_ids(tx) -> list[str]:
70
+ """Program ids of the compiled instructions that resolve to STATIC account keys, unique, in order.
71
+ Program ids that live in address lookup tables are not resolved in v1 (see ``n_lookup_programs``)."""
72
+ msg = _as_tx(tx).message
73
+ keys = [str(k) for k in msg.account_keys]
74
+ out: list[str] = []
75
+ for ci in msg.instructions:
76
+ i = ci.program_id_index
77
+ if i < len(keys) and keys[i] not in out:
78
+ out.append(keys[i])
79
+ return out
80
+
81
+
82
+ def n_lookup_programs(tx) -> int:
83
+ """How many compiled instructions reference a program id from an address lookup table."""
84
+ msg = _as_tx(tx).message
85
+ n_static = len(msg.account_keys)
86
+ return sum(1 for ci in msg.instructions if ci.program_id_index >= n_static)
fly_trader/config.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed configuration from the environment (.env is loaded once, never overriding real env vars).
2
+
3
+ Every knob lives here. Secret values are never printed; ``summary()`` returns the non-secret
4
+ configuration for persistence in ``runs.config``. All time handling is UTC.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from dotenv import load_dotenv
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parent.parent
15
+ load_dotenv(REPO_ROOT / ".env", override=False)
16
+
17
+ SECRET_ENV_NAMES = ("HELIUS_API_KEY", "JUPITER_API_KEY", "BOT_PRIVATE_KEY")
18
+
19
+
20
+ def utcnow() -> datetime:
21
+ return datetime.now(timezone.utc)
22
+
23
+
24
+ def env_str(name: str, default: str | None = None) -> str | None:
25
+ v = os.environ.get(name)
26
+ if v is None or v.strip() == "":
27
+ return default
28
+ return v.strip()
29
+
30
+
31
+ def env_int(name: str, default: int) -> int:
32
+ v = env_str(name)
33
+ return int(v) if v is not None else default
34
+
35
+
36
+ def env_float(name: str, default: float) -> float:
37
+ v = env_str(name)
38
+ return float(v) if v is not None else default
39
+
40
+
41
+ def env_bool(name: str, default: bool = False) -> bool:
42
+ v = env_str(name)
43
+ if v is None:
44
+ return default
45
+ return v.lower() in ("1", "true", "yes", "on")
46
+
47
+
48
+ def env_list(name: str, default: list[str]) -> list[str]:
49
+ v = env_str(name)
50
+ if v is None:
51
+ return list(default)
52
+ return [x.strip() for x in v.split(",") if x.strip()]
53
+
54
+ # ---- secrets (names only ever appear in logs) ----
55
+ HELIUS_API_KEY = env_str("HELIUS_API_KEY")
56
+ JUPITER_API_KEY = env_str("JUPITER_API_KEY")
57
+
58
+ # ---- paths ----
59
+ DATA_DIR = (REPO_ROOT / env_str("DATA_DIR", "data")).resolve()
60
+ LOG_DIR = (REPO_ROOT / env_str("LOG_DIR", "logs")).resolve()
61
+ BRAIN_DIR = DATA_DIR / "brain"
62
+ PG_ARCHIVE_DIR = DATA_DIR / "pg_archive"
63
+
64
+ # ---- database ----
65
+ DATABASE_URL = env_str("DATABASE_URL", "postgresql:///fly_trader")
66
+
67
+ # ---- chain / execution (default-deny) ----
68
+ SOLANA_CLUSTER = env_str("SOLANA_CLUSTER", "mainnet-beta")
69
+ LIVE_ENABLED = env_bool("LIVE_ENABLED", False)
70
+ CAPITAL_SOL = env_float("CAPITAL_SOL", 0.0)
71
+ MAX_POSITION_SOL = env_float("MAX_POSITION_SOL", 0.0)
72
+ GAS_RESERVE_SOL = env_float("GAS_RESERVE_SOL", 0.30)
73
+ # position sizing (agent/sizing.py): a fraction of the growth-optimal (Kelly) bet measured per score band in the backtest
74
+ KELLY_FRACTION = env_float("KELLY_FRACTION", 0.25) # quarter Kelly: the band estimates are noisy
75
+ MAX_POSITION_FRACTION = env_float("MAX_POSITION_FRACTION", 0.10) # of the deployable bankroll (wealth minus the gas reserve)
76
+ MAX_POOL_SHARE = env_float("MAX_POOL_SHARE", 0.02) # of the pool's quote reserve (bounds price impact)
77
+ # The training label charges what an exit really costs at the size the book takes. market/features.py prices
78
+ # exit_cost_0p1 for a fixed 0.1 SOL, but positions are Kelly-sized up to MAX_POSITION_FRACTION of the bankroll, and
79
+ # impact = value/(value + reserves) grows with size. 0 = the sizing rule's own ceiling (train/decisions.py).
80
+ LABEL_SIZE_SOL = env_float("LABEL_SIZE_SOL", 0.0)
81
+ MIN_POSITION_SOL = env_float("MIN_POSITION_SOL", 0.02) # smaller sizes are skipped
82
+ KILL_SWITCH_DRAWDOWN = env_float("KILL_SWITCH_DRAWDOWN", 0.30)
83
+ KILL_SWITCH_LIQUIDATE = env_bool("KILL_SWITCH_LIQUIDATE", False) # a tripped kill switch also sells every open live position (agent/fly_live.py)
84
+ CIRCUIT_THRESHOLD = env_int("CIRCUIT_THRESHOLD", 3)
85
+ EXECUTABILITY_MAX_IMPACT = env_float("EXECUTABILITY_MAX_IMPACT", 0.30)
86
+ SLIPPAGE_ENTRY_BPS = env_int("SLIPPAGE_ENTRY_BPS", 150)
87
+ SLIPPAGE_EXIT_BPS = env_int("SLIPPAGE_EXIT_BPS", 300)
88
+ SLIPPAGE_FORCED_BPS = env_int("SLIPPAGE_FORCED_BPS", 500)
89
+ SLIPPAGE_STEP_BPS = env_int("SLIPPAGE_STEP_BPS", 100)
90
+ MAX_SLIPPAGE_ENTRY_BPS = env_int("MAX_SLIPPAGE_ENTRY_BPS", 300)
91
+
92
+ HELIUS_HTTP = "https://mainnet.helius-rpc.com/"
93
+ JUPITER_SWAP_BASE = "https://api.jup.ag/swap/v2"
94
+ JUPITER_TOKENS_BASE = "https://api.jup.ag/tokens/v2"
95
+ JUPITER_RPS = env_float("JUPITER_RPS", 8.0)
96
+
97
+ WSOL_MINT = "So11111111111111111111111111111111111111112"
98
+ USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
99
+ LAMPORTS_PER_SOL = 1_000_000_000
100
+
101
+ # ---- token stats (Jupiter) ----
102
+ STATS_REFRESH_S = env_float("STATS_REFRESH_S", 600.0)
103
+
104
+ # ---- connectome (the fly) ----
105
+ DEVICE = env_str("DEVICE", "mps")
106
+ NT_SIGN_MODE = env_str("NT_SIGN_MODE", "shiu") # shiu | flybrain
107
+ CELL_TYPES_SOURCE = env_str("CELL_TYPES_SOURCE", "annotations") # annotations | flybrain
108
+
109
+ # ---- costs ----
110
+ LAMBDA_IMPACT_DLMM = env_float("LAMBDA_IMPACT_DLMM", 0.02)
111
+
112
+ # ---- training lifecycle ----
113
+ RESET_ON_START = env_bool("RESET_ON_START", True) # wipe the paper book and its stats when the runner starts
114
+
115
+ # ---- the fly's handover (agent/fly_session.py): when its paper race earns it the live seat ----
116
+ HANDOVER_DAYS = env_int("HANDOVER_DAYS", 14) # days of racing first; 0 = as soon as the trades are in
117
+ HANDOVER_MIN_TRADES = env_int("HANDOVER_MIN_TRADES", 30) # closed paper_fly trades required
118
+ HANDOVER_BEAT_SELECTOR = env_bool("HANDOVER_BEAT_SELECTOR", True) # and realized P&L at least the paper selector's over the window
119
+
120
+
121
+ def helius_http_url() -> str:
122
+ if not HELIUS_API_KEY:
123
+ raise RuntimeError("HELIUS_API_KEY is not set")
124
+ return f"{HELIUS_HTTP}?api-key={HELIUS_API_KEY}"
125
+
126
+
127
+ def summary() -> dict:
128
+ """Non-secret configuration snapshot for ``runs.config``."""
129
+ out = {}
130
+ for k, v in globals().items():
131
+ if k.isupper() and k not in SECRET_ENV_NAMES and not k.startswith("_"):
132
+ if isinstance(v, (str, int, float, bool, list, tuple)):
133
+ out[k] = v
134
+ elif isinstance(v, Path):
135
+ out[k] = str(v)
136
+ elif isinstance(v, dict):
137
+ out[k] = v
138
+ if "DATABASE_URL" in out: # may carry a password (URL or key=value form); the database name is enough
139
+ try:
140
+ from psycopg.conninfo import conninfo_to_dict
141
+ db = conninfo_to_dict(str(out["DATABASE_URL"])).get("dbname") or "?"
142
+ except Exception:
143
+ db = "?"
144
+ out["DATABASE_URL"] = f"postgresql:///{db}"
145
+ return out
146
+
147
+
148
+ def live_prerequisites_missing() -> list[str]:
149
+ missing = []
150
+ if SOLANA_CLUSTER != "mainnet-beta":
151
+ missing.append("SOLANA_CLUSTER must be mainnet-beta for live memecoin trading")
152
+ if not LIVE_ENABLED:
153
+ missing.append("LIVE_ENABLED=1")
154
+ if CAPITAL_SOL <= 0:
155
+ missing.append("CAPITAL_SOL")
156
+ if MAX_POSITION_SOL <= 0:
157
+ missing.append("MAX_POSITION_SOL")
158
+ if GAS_RESERVE_SOL <= 0:
159
+ missing.append("GAS_RESERVE_SOL")
160
+ if not env_str("BOT_PRIVATE_KEY"):
161
+ missing.append("BOT_PRIVATE_KEY")
162
+ if not JUPITER_API_KEY:
163
+ missing.append("JUPITER_API_KEY")
164
+ if not HELIUS_API_KEY:
165
+ missing.append("HELIUS_API_KEY")
166
+ return missing
167
+
168
+ # ---- historical corpus (every pump.fun graduation, pulled from the migration wallet + pump.fun's swap-api) ----
169
+ CORPUS_DIR = DATA_DIR / "corpus"
170
+ CORPUS_DAYS = env_int("CORPUS_DAYS", 240) # enumerate graduations this far back (must reach before CORPUS_PULL_BEFORE)
171
+ CORPUS_MIGRATION_AUTHORITY = env_str("CORPUS_MIGRATION_AUTHORITY", "39azUYFWPz3VHgKCf3VChUwbpURdCHRxjWVowf5jUJjg")
172
+ CORPUS_SWAP_API = env_str("CORPUS_SWAP_API", "https://swap-api.pump.fun")
173
+ CORPUS_PACE_S = env_float("CORPUS_PACE_S", 2.2) # seconds between swap-api requests (~30/min bucket; 1/s throttles after ~20)
174
+ CORPUS_PACE_MIN_S = env_float("CORPUS_PACE_MIN_S", 2.2)
175
+ CORPUS_CANDLE_1M_H = env_float("CORPUS_CANDLE_1M_H", 12.0) # 1-minute candles for this long after graduation
176
+ CORPUS_CANDLE_5M_D = env_float("CORPUS_CANDLE_5M_D", 7.0) # then 5-minute candles until this many days
177
+ CORPUS_MIN_LIFE_H = env_float("CORPUS_MIN_LIFE_H", 1.0) # per-trade rows only for tokens that traded at least this long
178
+ CORPUS_TRADES_H = env_float("CORPUS_TRADES_H", 3.0) # per-trade rows for the first hours after graduation
179
+ CORPUS_TRADES_DAYS = env_int("CORPUS_TRADES_DAYS", 400) # ... and only for graduations this recent (the 10 % sample bounds the cost)
180
+ CORPUS_TRADES_MAX_PAGES = env_int("CORPUS_TRADES_MAX_PAGES", 200) # 100 trades per page
181
+ CORPUS_TRADES_SAMPLE = env_float("CORPUS_TRADES_SAMPLE", 0.10) # share of surviving tokens that get per-trade rows (deterministic by mint)
182
+ CORPUS_MIN_AGE_H = env_float("CORPUS_MIN_AGE_H", 6.0) # pull a token only once its first hours are complete
183
+ CORPUS_RQ0_SOL = env_float("CORPUS_RQ0_SOL", 79.0) # PumpSwap quote reserve at migration (85 SOL curve − 6 SOL fee); R_q(t) ≈ RQ0·sqrt(p/p_grad)
184
+ CORPUS_FEATURES_DIR = CORPUS_DIR / "features"
185
+
186
+ # ---- PumpAPI historical replay (free hourly archive of decoded pump.fun / PumpSwap events since 2026-04-18) ----
187
+ REPLAY_URL = env_str("REPLAY_URL", "https://replay.pumpapi.io")
188
+ REPLAY_START = env_str("REPLAY_START", "2026-04-18")
189
+ REPLAY_PARALLEL = env_int("REPLAY_PARALLEL", 4) # concurrent hour downloads (~2-4 MB/s each)
190
+ REPLAY_ASSEMBLE = env_bool("REPLAY_ASSEMBLE", True) # 0: download and parse only (no assembly / corpus_meta / mature rounds)
191
+ REPLAY_DIR = CORPUS_DIR / "replay"
192
+ CORPUS_PULL_BEFORE = env_str("CORPUS_PULL_BEFORE", REPLAY_START) # swap-api puller only handles graduations before the replay archive begins
193
+
194
+ # ---- PumpAPI live stream (free firehose, same events as the replay archive; the selector's parity feed) ----
195
+ PUMPSTREAM_URL = env_str("PUMPSTREAM_URL", "wss://stream.pumpapi.io")
196
+ 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/__init__.py ADDED
File without changes
fly_trader/db/apilog.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persist every external API call to ``api_calls``. Never stores keys; never breaks the caller."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+
7
+ from .connection import transaction
8
+ from ..logging_setup import scrub
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def record_api_call(service: str, endpoint: str, method: str = "GET", status: int | None = None,
14
+ latency_ms: int | None = None, ok: bool = True, error: str | None = None,
15
+ request: dict | None = None, response_bytes: int | None = None) -> None:
16
+ try:
17
+ req_json = None
18
+ if request is not None:
19
+ req_json = json.loads(scrub(json.dumps(request, default=str)))
20
+ with transaction() as conn:
21
+ conn.execute(
22
+ "INSERT INTO api_calls (service, endpoint, method, status, latency_ms, ok, error, request, response_bytes) "
23
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)",
24
+ (service, endpoint, method, status, latency_ms, ok, scrub(error) if error else None,
25
+ json.dumps(req_json) if req_json is not None else None, response_bytes),
26
+ )
27
+ except Exception as e: # a DB hiccup must never break an API call
28
+ log.warning("api_calls insert failed: %s", type(e).__name__)
29
+
30
+
31
+ def _finite(x):
32
+ if isinstance(x, float) and (x != x or x in (float("inf"), float("-inf"))):
33
+ return None
34
+ if isinstance(x, dict):
35
+ return {k: _finite(v) for k, v in x.items()}
36
+ if isinstance(x, (list, tuple)):
37
+ return [_finite(v) for v in x]
38
+ return x
39
+
40
+
41
+ def record_event(level: str, source: str, message: str, detail: dict | None = None) -> None:
42
+ try:
43
+ with transaction() as conn:
44
+ conn.execute(
45
+ "INSERT INTO events (level, source, message, detail) VALUES (%s,%s,%s,%s)",
46
+ (level, source, scrub(message), json.dumps(_finite(detail), default=str) if detail is not None else None),
47
+ )
48
+ except Exception as e:
49
+ log.warning("events insert failed: %s", type(e).__name__)
fly_trader/db/connection.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """psycopg 3 connections to the fly_trader database.
2
+
3
+ Pattern from better_bot code/db_connection.py: a production database is refused while pytest is
4
+ running unless its name contains "test" (the autouse fixture in tests/conftest.py points
5
+ DATABASE_URL at fly_trader_test).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+ from contextlib import contextmanager
12
+
13
+ import psycopg
14
+ from psycopg.conninfo import conninfo_to_dict
15
+ from psycopg.rows import dict_row
16
+
17
+ from .. import config
18
+
19
+
20
+ class ProductionDatabaseUnderPytest(RuntimeError):
21
+ pass
22
+
23
+
24
+ def database_url() -> str:
25
+ return os.environ.get("DATABASE_URL") or config.DATABASE_URL
26
+
27
+
28
+ def database_name(url: str | None = None) -> str:
29
+ return conninfo_to_dict(url or database_url()).get("dbname") or "fly_trader" # URL or key=value DSN
30
+
31
+
32
+ def _refuse_production_under_pytest(url: str) -> None:
33
+ if "PYTEST_CURRENT_TEST" in os.environ and "test" not in database_name(url):
34
+ raise ProductionDatabaseUnderPytest(
35
+ f"refusing to open non-test database {database_name(url)!r} under pytest"
36
+ )
37
+
38
+
39
+ def connect(url: str | None = None, *, autocommit: bool = False) -> psycopg.Connection:
40
+ url = url or database_url()
41
+ _refuse_production_under_pytest(url)
42
+ last: Exception | None = None
43
+ for attempt in range(3): # a busy machine can refuse one connection; a long training run must not die for it,
44
+ # but an unreachable database must still report in seconds, not half a minute: three short attempts, ~10 s in all
45
+ try:
46
+ return psycopg.connect(url, autocommit=autocommit, row_factory=dict_row, options="-c timezone=UTC", connect_timeout=3)
47
+ except psycopg.OperationalError as e:
48
+ last = e
49
+ if attempt < 2:
50
+ time.sleep(0.25 * 2 ** attempt)
51
+ raise last
52
+
53
+
54
+ @contextmanager
55
+ def transaction(url: str | None = None):
56
+ """One transaction; commits on success, rolls back on exception."""
57
+ conn = connect(url)
58
+ try:
59
+ yield conn
60
+ conn.commit()
61
+ except Exception:
62
+ conn.rollback()
63
+ raise
64
+ finally:
65
+ conn.close()
66
+
67
+
68
+ def fetch_all(sql: str, params=None, url: str | None = None) -> list[dict]:
69
+ with connect(url) as conn:
70
+ with conn.cursor() as cur:
71
+ cur.execute(sql, params)
72
+ return cur.fetchall()
73
+
74
+
75
+ def fetch_one(sql: str, params=None, url: str | None = None) -> dict | None:
76
+ with connect(url) as conn:
77
+ with conn.cursor() as cur:
78
+ cur.execute(sql, params)
79
+ return cur.fetchone()
80
+
81
+
82
+ def execute(sql: str, params=None, url: str | None = None) -> None:
83
+ with transaction(url) as conn:
84
+ with conn.cursor() as cur:
85
+ cur.execute(sql, params)
fly_trader/db/queries.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read-side helpers for the console and `status`. Everything counts rows; nothing reads status flags."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ from .connection import connect
7
+
8
+
9
+ def q(sql: str, params=None) -> list[dict]:
10
+ with connect() as conn:
11
+ return [dict(r) for r in conn.execute(sql, params).fetchall()]
12
+
13
+
14
+ def q1(sql: str, params=None) -> dict | None:
15
+ rows = q(sql, params)
16
+ return rows[0] if rows else None
17
+
18
+
19
+ def status() -> dict:
20
+ out: dict = {}
21
+ out["wealth"] = q("""SELECT DISTINCT ON (book) book, ts, wealth, sol_free, positions_value, exposure, n_open, peak, drawdown
22
+ FROM wealth_marks ORDER BY book, ts DESC""")
23
+ out["positions"] = q("SELECT book, status, count(*) AS n, COALESCE(sum(realized_sol),0) AS realized FROM positions GROUP BY 1,2 ORDER BY 1,2")
24
+ out["fills"] = q("SELECT book, count(*) AS n, min(ts) AS first, max(ts) AS last FROM fills GROUP BY 1 ORDER BY 1")
25
+ out["beats_last_hour"] = (q1("SELECT count(*) AS n, max(ts) AS last FROM beats WHERE ts > now() - interval '1 hour'") or {})
26
+ out["decisions_last_hour"] = q("SELECT kind, COALESCE(rail,'') AS rail, count(*) AS n FROM decisions WHERE ts > now() - interval '1 hour' GROUP BY 1,2 ORDER BY 3 DESC")
27
+ out["tokens"] = q("SELECT watch_status, count(*) AS n FROM tokens GROUP BY 1 ORDER BY 1")
28
+ out["circuit"] = q1("SELECT * FROM circuit_state WHERE id = 1")
29
+ out["brain"] = q1("SELECT * FROM brain_state")
30
+ out["processes"] = q("SELECT name, pid, started_at, stopped_at, exit_code FROM processes ORDER BY started_at DESC LIMIT 10")
31
+ out["events"] = q("SELECT ts, level, source, message FROM events ORDER BY ts DESC LIMIT 10")
32
+ return out
33
+
34
+
35
+ def print_status() -> None:
36
+ s = status()
37
+ print(json.dumps(s, indent=1, default=str))
fly_trader/db/schema.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Versioned DDL ladder for the fly_trader database.
2
+
3
+ Pattern from better_bot code/db_schema.py: a singleton ``schema_version`` row, a cross-process
4
+ advisory lock around migrations, and an idempotent list of statements. Adding a migration means
5
+ appending a version-gated block AND bumping SCHEMA_VERSION, or it never runs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from datetime import datetime, timedelta, timezone
11
+
12
+ import psycopg
13
+ from psycopg import sql
14
+
15
+ from .. import config
16
+ from .connection import connect, database_name, database_url
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ SCHEMA_VERSION = 6 # 5: the plastic fly's tables and per-book halts (BASE_DDL); 6: the selector's strategy stack (MIGRATIONS[6])
21
+ _LOCK_KEY = 0x666C795F6D6967 # "fly_mig"
22
+
23
+ BASE_DDL: list[str] = [
24
+ """CREATE TABLE IF NOT EXISTS schema_version (
25
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
26
+ version bigint NOT NULL,
27
+ updated_at timestamptz NOT NULL DEFAULT now())""",
28
+ """CREATE TABLE IF NOT EXISTS wallet_events (
29
+ id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(),
30
+ kind text NOT NULL, pubkey text, detail jsonb)""",
31
+ """CREATE TABLE IF NOT EXISTS tokens (
32
+ mint text PRIMARY KEY, symbol text, name text, decimals int, token_program text,
33
+ launchpad text, partner_config text, graduated_pool text, graduated_at timestamptz,
34
+ first_pool_id text, first_pool_created_at timestamptz,
35
+ mint_auth_disabled boolean, freeze_auth_disabled boolean,
36
+ first_seen timestamptz NOT NULL DEFAULT now(), last_seen timestamptz NOT NULL DEFAULT now(),
37
+ watch_status text NOT NULL DEFAULT 'candidate', raw jsonb)""",
38
+ "CREATE INDEX IF NOT EXISTS tokens_graduated_at_idx ON tokens (graduated_at DESC)",
39
+ "CREATE INDEX IF NOT EXISTS tokens_watch_status_idx ON tokens (watch_status)",
40
+ """CREATE TABLE IF NOT EXISTS token_stats (
41
+ id bigserial PRIMARY KEY, mint text NOT NULL, ts timestamptz NOT NULL DEFAULT now(),
42
+ organic_score real, organic_label text, holder_count int, liquidity_usd double precision,
43
+ usd_price double precision, mcap double precision, top_holders_pct real, dev_balance_pct real,
44
+ dev_mints int, is_sus boolean, mint_auth_disabled boolean, freeze_auth_disabled boolean,
45
+ is_verified boolean, stats jsonb)""",
46
+ "CREATE INDEX IF NOT EXISTS token_stats_mint_ts_idx ON token_stats (mint, ts DESC)",
47
+ """CREATE TABLE IF NOT EXISTS watch_pools (
48
+ pool text PRIMARY KEY, mint text NOT NULL, quote_mint text, program_id text, program_label text,
49
+ base_vault text, quote_vault text, base_decimals int, quote_decimals int, source text,
50
+ active boolean NOT NULL DEFAULT true, tradable boolean NOT NULL DEFAULT true,
51
+ added_at timestamptz NOT NULL DEFAULT now(), deactivated_at timestamptz, reason text)""",
52
+ "CREATE INDEX IF NOT EXISTS watch_pools_active_idx ON watch_pools (active)",
53
+ "CREATE INDEX IF NOT EXISTS watch_pools_mint_idx ON watch_pools (mint)",
54
+ """CREATE TABLE IF NOT EXISTS swap_tape (
55
+ id bigserial, ts timestamptz NOT NULL, slot bigint, sig text, tx_index int,
56
+ pool text NOT NULL, mint text, side smallint NOT NULL,
57
+ amount_base numeric(30,0), amount_quote bigint, price_sol double precision,
58
+ signer text, res_base numeric(30,0), res_quote bigint, program_label text,
59
+ PRIMARY KEY (id, ts)) PARTITION BY RANGE (ts)""",
60
+ "CREATE INDEX IF NOT EXISTS swap_tape_pool_ts_idx ON swap_tape (pool, ts)",
61
+ """CREATE TABLE IF NOT EXISTS capture_status (
62
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
63
+ updated_at timestamptz NOT NULL DEFAULT now(), last_swap_ts timestamptz, rows_total bigint DEFAULT 0,
64
+ pools_subscribed int DEFAULT 0, subscription_id bigint, reconnects int DEFAULT 0,
65
+ decode_failures bigint DEFAULT 0, last_error text)""",
66
+ """CREATE TABLE IF NOT EXISTS runs (
67
+ run_id uuid PRIMARY KEY, kind text NOT NULL, started_at timestamptz NOT NULL DEFAULT now(),
68
+ ended_at timestamptz, git_sha text, config jsonb, connectome_sha256 text, encoder_sha256 text,
69
+ brain_snapshot_id bigint, corpus text, split jsonb, status text NOT NULL DEFAULT 'running',
70
+ metrics jsonb)""",
71
+ """CREATE TABLE IF NOT EXISTS populations (
72
+ id smallint PRIMARY KEY, name text NOT NULL UNIQUE, n int NOT NULL, kind text, source text)""",
73
+ """CREATE TABLE IF NOT EXISTS beats (
74
+ id bigserial PRIMARY KEY, run_id uuid, ts timestamptz NOT NULL DEFAULT now(), beat_no bigint,
75
+ sim_ts timestamptz, tape_last_id bigint, n_slots_active int, n_held_live int, n_held_paper int,
76
+ ticks int, gpu_ms int, total_ms int, feed_age_ms int, notes jsonb)""",
77
+ "CREATE INDEX IF NOT EXISTS beats_run_ts_idx ON beats (run_id, ts)",
78
+ "CREATE INDEX IF NOT EXISTS beats_ts_idx ON beats (ts)",
79
+ """CREATE TABLE IF NOT EXISTS beat_slots (
80
+ beat_id bigint NOT NULL, slot smallint NOT NULL, ts timestamptz NOT NULL,
81
+ mint text, pool text, dwell_beats int, features real[], feature_mask bigint, danger real,
82
+ portfolio real[], glomeruli real[], stim real[], kc_active_frac real, m_hat real,
83
+ rho_app real, rho_av real, dan_rew real, dan_pun real, delta_in real,
84
+ decision_kind text, decision_size real, softmax_p real,
85
+ PRIMARY KEY (beat_id, slot, ts)) PARTITION BY RANGE (ts)""",
86
+ "CREATE INDEX IF NOT EXISTS beat_slots_mint_ts_idx ON beat_slots (mint, ts)",
87
+ """CREATE TABLE IF NOT EXISTS brain_activity (
88
+ beat_id bigint PRIMARY KEY, group_rates real[], kc_sparsity real, mbon_app_rate real,
89
+ mbon_av_rate real, dan_rew_rate real, dan_pun_rate real, total_spikes bigint,
90
+ v_mean real, v_max real, nan_flag boolean DEFAULT false)""",
91
+ """CREATE TABLE IF NOT EXISTS decisions (
92
+ id bigserial PRIMARY KEY, beat_id bigint, run_id uuid, ts timestamptz NOT NULL DEFAULT now(),
93
+ slot smallint, mint text, pool text, kind text NOT NULL, m_hat real, size_sol real,
94
+ forced boolean NOT NULL DEFAULT false, rail text, reason text, softmax_p real,
95
+ book_targets text[], detail jsonb)""",
96
+ "CREATE INDEX IF NOT EXISTS decisions_ts_idx ON decisions (ts)",
97
+ "CREATE INDEX IF NOT EXISTS decisions_mint_ts_idx ON decisions (mint, ts)",
98
+ "CREATE INDEX IF NOT EXISTS decisions_kind_ts_idx ON decisions (kind, ts)",
99
+ """CREATE TABLE IF NOT EXISTS orders (
100
+ id bigserial PRIMARY KEY, decision_id bigint, book text NOT NULL, ts timestamptz NOT NULL DEFAULT now(),
101
+ attempt int NOT NULL DEFAULT 1, side text NOT NULL, input_mint text, output_mint text,
102
+ amount_in numeric(30,0), slippage_bps int, request jsonb, order_response jsonb,
103
+ execute_request jsonb, execute_response jsonb, request_id text, signature text,
104
+ last_valid_block_height bigint, router text, fee_bps int, program_ids text[],
105
+ status text, error_code int, error text, latency_ms int)""",
106
+ "CREATE INDEX IF NOT EXISTS orders_decision_idx ON orders (decision_id)",
107
+ "CREATE INDEX IF NOT EXISTS orders_signature_idx ON orders (signature)",
108
+ "CREATE INDEX IF NOT EXISTS orders_ts_idx ON orders (ts)",
109
+ """CREATE TABLE IF NOT EXISTS fills (
110
+ id bigserial PRIMARY KEY, order_id bigint, book text NOT NULL, ts timestamptz NOT NULL DEFAULT now(),
111
+ signature text, slot bigint, mint text, side text, token_delta numeric(30,0),
112
+ sol_delta_lamports bigint, price_sol double precision, fee_lamports bigint, platform_fee jsonb,
113
+ verified_by text, pre_snapshot jsonb, post_snapshot jsonb)""",
114
+ "CREATE INDEX IF NOT EXISTS fills_book_ts_idx ON fills (book, ts)",
115
+ "CREATE INDEX IF NOT EXISTS fills_mint_ts_idx ON fills (mint, ts)",
116
+ """CREATE TABLE IF NOT EXISTS positions (
117
+ id bigserial PRIMARY KEY, book text NOT NULL, mint text NOT NULL, pool text,
118
+ opened_at timestamptz NOT NULL DEFAULT now(), closed_at timestamptz,
119
+ entry_decision_id bigint, exit_decision_id bigint, qty numeric(30,0) NOT NULL DEFAULT 0,
120
+ cost_sol double precision NOT NULL DEFAULT 0, entry_price double precision, peak_price double precision,
121
+ last_mark_price double precision, last_mark_ts timestamptz, exit_price double precision,
122
+ realized_sol double precision, fees_sol double precision NOT NULL DEFAULT 0,
123
+ status text NOT NULL DEFAULT 'open', forced_exit_kind text, last_swap_ts timestamptz, satiety double precision NOT NULL DEFAULT 0)""",
124
+ "CREATE UNIQUE INDEX IF NOT EXISTS positions_open_uniq ON positions (book, mint) WHERE status = 'open'",
125
+ "CREATE INDEX IF NOT EXISTS positions_book_status_idx ON positions (book, status)",
126
+ """CREATE TABLE IF NOT EXISTS wealth_marks (
127
+ beat_id bigint NOT NULL, book text NOT NULL, ts timestamptz NOT NULL DEFAULT now(),
128
+ sol_free double precision, positions_value double precision, exit_cost double precision,
129
+ wealth double precision, peak double precision, drawdown real, exposure double precision, n_open int,
130
+ PRIMARY KEY (beat_id, book))""",
131
+ "CREATE INDEX IF NOT EXISTS wealth_marks_book_ts_idx ON wealth_marks (book, ts)",
132
+ """CREATE TABLE IF NOT EXISTS rewards (
133
+ beat_id bigint NOT NULL, slot smallint NOT NULL, book text, mint text,
134
+ r_slot real, r_global real, r_tilde real, m_prev real, m_now real, delta real, source text,
135
+ PRIMARY KEY (beat_id, slot))""",
136
+ """CREATE TABLE IF NOT EXISTS synapse_updates (
137
+ beat_id bigint PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), eta real, gamma real,
138
+ n_slots int, reward_source text, sum_abs real, max_abs real, frob real, frob_capped boolean,
139
+ n_pos int, n_neg int, n_clipped int, w_mean real, w_min real, w_max real,
140
+ delta_path text, delta_sha256 text)""",
141
+ """CREATE TABLE IF NOT EXISTS brain_snapshots (
142
+ id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), run_id uuid, beat_id bigint,
143
+ path text NOT NULL, sha256 text, kind text NOT NULL, promoted_at timestamptz, promoted_by text, note text)""",
144
+ """CREATE TABLE IF NOT EXISTS brain_state (
145
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
146
+ live_snapshot_id bigint, pending_snapshot_id bigint, updated_at timestamptz NOT NULL DEFAULT now())""",
147
+ "INSERT INTO brain_state (singleton) VALUES (true) ON CONFLICT DO NOTHING",
148
+ """CREATE TABLE IF NOT EXISTS encoder_state (
149
+ name text PRIMARY KEY, version int NOT NULL DEFAULT 1, seed bigint, path text, sha256 text,
150
+ running_mean real[], running_var real[], n bigint NOT NULL DEFAULT 0,
151
+ updated_at timestamptz NOT NULL DEFAULT now())""",
152
+ """CREATE TABLE IF NOT EXISTS circuit_state (
153
+ id int PRIMARY KEY CHECK (id = 1), fail_count int NOT NULL DEFAULT 0, last_failure_ts timestamptz,
154
+ tripped boolean NOT NULL DEFAULT false, kill_switch boolean NOT NULL DEFAULT false, kill_reason text,
155
+ peak_wealth double precision, entries_paused boolean NOT NULL DEFAULT false,
156
+ updated_at timestamptz NOT NULL DEFAULT now())""",
157
+ "INSERT INTO circuit_state (id) VALUES (1) ON CONFLICT DO NOTHING",
158
+ """CREATE TABLE IF NOT EXISTS circuit_events (
159
+ id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), kind text NOT NULL, detail jsonb)""",
160
+ """CREATE TABLE IF NOT EXISTS notional_ledger (
161
+ id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), sol double precision NOT NULL, kind text)""",
162
+ "CREATE INDEX IF NOT EXISTS notional_ledger_ts_idx ON notional_ledger (ts)",
163
+ """CREATE TABLE IF NOT EXISTS api_calls (
164
+ id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), service text NOT NULL, endpoint text,
165
+ method text, status int, latency_ms int, ok boolean, error text, request jsonb, response_bytes int)""",
166
+ "CREATE INDEX IF NOT EXISTS api_calls_ts_idx ON api_calls (ts)",
167
+ "CREATE INDEX IF NOT EXISTS api_calls_service_ok_ts_idx ON api_calls (service, ok, ts)",
168
+ """CREATE TABLE IF NOT EXISTS events (
169
+ id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), level text NOT NULL DEFAULT 'info',
170
+ source text, message text NOT NULL, detail jsonb)""",
171
+ "CREATE INDEX IF NOT EXISTS events_ts_idx ON events (ts)",
172
+ """CREATE TABLE IF NOT EXISTS slot_visits (
173
+ mint text PRIMARY KEY, last_visit_ts timestamptz, visits bigint NOT NULL DEFAULT 0)""",
174
+ """CREATE TABLE IF NOT EXISTS processes (
175
+ id bigserial PRIMARY KEY, name text NOT NULL, pid int NOT NULL, cmd text[],
176
+ started_at timestamptz NOT NULL DEFAULT now(), stopped_at timestamptz, exit_code int,
177
+ log_path text, started_by text)""",
178
+ "CREATE UNIQUE INDEX IF NOT EXISTS processes_live_uniq ON processes (name) WHERE stopped_at IS NULL",
179
+ """CREATE TABLE IF NOT EXISTS corpus_tokens (
180
+ mint text PRIMARY KEY, graduated_at timestamptz NOT NULL, grad_sig text, grad_slot bigint,
181
+ source text NOT NULL DEFAULT 'migration_wallet', status text NOT NULL DEFAULT 'pending',
182
+ life_h double precision, candles_1m int, candles_5m int, trades int, trades_through timestamptz,
183
+ candle_path text, trade_path text, last_error text,
184
+ added_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now())""",
185
+ "CREATE INDEX IF NOT EXISTS corpus_tokens_status_idx ON corpus_tokens (status, graduated_at DESC)",
186
+ """CREATE TABLE IF NOT EXISTS corpus_features (
187
+ mint text PRIMARY KEY, built_at timestamptz NOT NULL DEFAULT now(), rows int, trade_rows int, has_trades boolean, path text)""",
188
+ """CREATE TABLE IF NOT EXISTS replay_hours (
189
+ hour timestamptz PRIMARY KEY, status text NOT NULL DEFAULT 'done', trades int, events int, bytes bigint, took_s real,
190
+ last_error text, done_at timestamptz NOT NULL DEFAULT now())""",
191
+ """CREATE TABLE IF NOT EXISTS replay_days (
192
+ day date PRIMARY KEY, graduations int, tokens_written int, took_s real, assembled_at timestamptz NOT NULL DEFAULT now())""",
193
+ """CREATE TABLE IF NOT EXISTS corpus_meta (
194
+ mint text PRIMARY KEY, create_ts timestamptz, creator text, dev_sol double precision, dev_tokens double precision, dev_share real,
195
+ supply double precision, mayhem boolean, uri text, name text, symbol text, ttg_min double precision, rq0 double precision, pool_id text,
196
+ prior_launches int, prior_grads int, prior_known int, prior_rug_share real, prior_moon_share real,
197
+ own_dd60 real, own_max60 real, own_alive6h boolean, updated_at timestamptz NOT NULL DEFAULT now())""",
198
+ "CREATE INDEX IF NOT EXISTS corpus_meta_creator_idx ON corpus_meta (creator)",
199
+ "ALTER TABLE corpus_meta ADD COLUMN IF NOT EXISTS graduated_at timestamptz",
200
+ "CREATE INDEX IF NOT EXISTS corpus_meta_creator_g_idx ON corpus_meta (creator, graduated_at)",
201
+ """CREATE TABLE IF NOT EXISTS mature_days (
202
+ day date PRIMARY KEY, mints int, rows int, took_s real, built_at timestamptz NOT NULL DEFAULT now())""",
203
+ """CREATE TABLE IF NOT EXISTS pump_minutes (
204
+ mint text NOT NULL, ts timestamptz NOT NULL, pool_id text, open double precision, high double precision, low double precision, close double precision,
205
+ buy_sol double precision, sell_sol double precision, n_buys int, n_sells int, n_traders int, resq_sol double precision, fee_rate double precision,
206
+ PRIMARY KEY (mint, ts))""",
207
+ "CREATE INDEX IF NOT EXISTS pump_minutes_ts_idx ON pump_minutes (ts)",
208
+ """CREATE TABLE IF NOT EXISTS pump_events (
209
+ sig text PRIMARY KEY, ts timestamptz NOT NULL, action text NOT NULL, pool text, mint text, pool_id text, signer text, dev_sol double precision,
210
+ dev_tokens double precision, supply double precision, mayhem boolean, quote_in_pool double precision, name text, symbol text, uri text)""",
211
+ "CREATE INDEX IF NOT EXISTS pump_events_mint_idx ON pump_events (mint)",
212
+ "CREATE INDEX IF NOT EXISTS pump_events_creates_idx ON pump_events (signer, ts) WHERE action = 'create'",
213
+ # PumpSwap pools NOT created by a pump.fun migration (createPool by anyone): unburned liquidity, blocked from the universe
214
+ """CREATE TABLE IF NOT EXISTS pump_pools (
215
+ pool_id text PRIMARY KEY, mint text, created_by text, ts timestamptz, source text NOT NULL, added_at timestamptz NOT NULL DEFAULT now())""",
216
+ "CREATE INDEX IF NOT EXISTS pump_pools_mint_idx ON pump_pools (mint)",
217
+ """CREATE TABLE IF NOT EXISTS ui_settings (
218
+ key text PRIMARY KEY, value jsonb, updated_at timestamptz NOT NULL DEFAULT now())""",
219
+ # the plastic fly (agent/fly_session.py): every scored minute (pending rows are its synaptic tags; x is dropped once
220
+ # the label resolves), daily calibrations per arm (plastic / frozen), hourly learning statistics, rollbacks
221
+ """CREATE TABLE IF NOT EXISTS fly_scored (
222
+ ts timestamptz NOT NULL, mint text NOT NULL, x real[], score real, frozen_score real, line real, frozen_line real,
223
+ label real, state text NOT NULL DEFAULT 'pending', resolved_at timestamptz, bootstrap_id bigint,
224
+ PRIMARY KEY (ts, mint))""",
225
+ "CREATE INDEX IF NOT EXISTS fly_scored_state_ts_idx ON fly_scored (state, ts)",
226
+ """CREATE TABLE IF NOT EXISTS fly_calibrations (
227
+ day date NOT NULL, arm text NOT NULL, line real, sizing jsonb, trades int, total real, mean real, window_days int,
228
+ created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (day, arm))""",
229
+ """CREATE TABLE IF NOT EXISTS fly_updates (
230
+ hour timestamptz PRIMARY KEY, n int, mean_delta real, mean_abs_delta real, step real, capped int, drift real, ic real, detail jsonb)""",
231
+ """CREATE TABLE IF NOT EXISTS fly_rollbacks (
232
+ id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), reason text, checks jsonb, from_snapshot bigint, to_snapshot bigint)""",
233
+ # per-book entry halts of the paper race books (agent/rails.check_book_drawdown); the live book uses circuit_state
234
+ """CREATE TABLE IF NOT EXISTS book_state (
235
+ book text PRIMARY KEY, halted boolean NOT NULL DEFAULT false, reason text, peak double precision,
236
+ updated_at timestamptz NOT NULL DEFAULT now())""",
237
+ ]
238
+
239
+ # Version-gated migrations: {version: [statements]} applied when stored version < version.
240
+ MIGRATIONS: dict[int, list[str]] = {
241
+ 2: ["ALTER TABLE beat_slots ALTER COLUMN feature_mask TYPE bigint"],
242
+ 3: ["ALTER TABLE positions ADD COLUMN IF NOT EXISTS satiety double precision NOT NULL DEFAULT 0"],
243
+ 4: ["ALTER TABLE pump_minutes ADD COLUMN IF NOT EXISTS fee_rate double precision"], # the pool fee the stream reports as charged
244
+ # 6: the selector's strategy stack — per-minute wallet flow (train/flow.py), graduation-time curve facts and insiders
245
+ # (train/corpus_meta.py), per-position hold and strategy, the fly's per-strategy scored rows
246
+ 6: ["ALTER TABLE pump_minutes ADD COLUMN IF NOT EXISTS n_buyers int",
247
+ "ALTER TABLE pump_minutes ADD COLUMN IF NOT EXISTS wash_sol double precision",
248
+ "ALTER TABLE pump_minutes ADD COLUMN IF NOT EXISTS wash_buy_sol double precision",
249
+ "ALTER TABLE pump_minutes ADD COLUMN IF NOT EXISTS top_sell_sol double precision",
250
+ "ALTER TABLE pump_minutes ADD COLUMN IF NOT EXISTS insider_sell_sol double precision",
251
+ "ALTER TABLE pump_minutes ADD COLUMN IF NOT EXISTS skill_buy real[]",
252
+ "ALTER TABLE corpus_meta ADD COLUMN IF NOT EXISTS bundle_share real",
253
+ "ALTER TABLE corpus_meta ADD COLUMN IF NOT EXISTS dev_hold_share real",
254
+ "ALTER TABLE corpus_meta ADD COLUMN IF NOT EXISTS dev_sold_frac real",
255
+ "ALTER TABLE corpus_meta ADD COLUMN IF NOT EXISTS grad_hhi real",
256
+ "ALTER TABLE corpus_meta ADD COLUMN IF NOT EXISTS curve_known boolean",
257
+ "CREATE TABLE IF NOT EXISTS token_insiders (mint text NOT NULL, wallet text NOT NULL, kind text NOT NULL, PRIMARY KEY (mint, wallet))",
258
+ "ALTER TABLE positions ADD COLUMN IF NOT EXISTS hold_s double precision",
259
+ "ALTER TABLE positions ADD COLUMN IF NOT EXISTS strategy text",
260
+ "ALTER TABLE fly_scored ADD COLUMN IF NOT EXISTS strategy text NOT NULL DEFAULT 'ev'",
261
+ "ALTER TABLE fly_scored ADD COLUMN IF NOT EXISTS hold_min int",
262
+ "ALTER TABLE fly_scored DROP CONSTRAINT IF EXISTS fly_scored_pkey",
263
+ "ALTER TABLE fly_scored ADD PRIMARY KEY (ts, mint, strategy)"],
264
+ }
265
+
266
+
267
+ def ensure_database(url: str | None = None) -> bool:
268
+ """Create the database named in DATABASE_URL if it does not exist. Returns True if created."""
269
+ url = url or database_url()
270
+ name = database_name(url)
271
+ admin_url = psycopg.conninfo.make_conninfo(url, dbname="postgres") # same user/host/password, URL or key=value DSN
272
+ with psycopg.connect(admin_url, autocommit=True) as conn:
273
+ row = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)).fetchone()
274
+ if row:
275
+ return False
276
+ conn.execute(f'CREATE DATABASE "{name}"')
277
+ log.info("created database %s", name)
278
+ return True
279
+
280
+
281
+ def apply_schema(url: str | None = None) -> int:
282
+ """Apply the ladder under an advisory lock. Returns the resulting version."""
283
+ with connect(url) as conn:
284
+ with conn.cursor() as cur:
285
+ cur.execute("SELECT pg_advisory_lock(%s)", (_LOCK_KEY,))
286
+ try:
287
+ for stmt in BASE_DDL:
288
+ cur.execute(stmt)
289
+ cur.execute("SELECT version FROM schema_version WHERE singleton")
290
+ row = cur.fetchone()
291
+ current = int(row["version"]) if row else 0
292
+ for v in sorted(MIGRATIONS):
293
+ if current < v:
294
+ for stmt in MIGRATIONS[v]:
295
+ cur.execute(stmt)
296
+ current = v
297
+ target = max(current, SCHEMA_VERSION)
298
+ cur.execute(
299
+ "INSERT INTO schema_version (singleton, version) VALUES (true, %s) "
300
+ "ON CONFLICT (singleton) DO UPDATE SET version = EXCLUDED.version, updated_at = now()",
301
+ (target,),
302
+ )
303
+ conn.commit()
304
+ ensure_partitions(conn)
305
+ conn.commit()
306
+ return target
307
+ except Exception:
308
+ conn.rollback()
309
+ raise
310
+ finally:
311
+ cur.execute("SELECT pg_advisory_unlock(%s)", (_LOCK_KEY,))
312
+ conn.commit()
313
+
314
+
315
+ def _partition_name(base: str, start: datetime, granularity: str) -> str:
316
+ fmt = "%Y%m%d%H" if granularity == "hour" else "%Y%m%d"
317
+ return f"{base}_{start.strftime(fmt)}"
318
+
319
+
320
+ def ensure_partition(conn: psycopg.Connection, base: str, start: datetime, granularity: str) -> None:
321
+ start = start.astimezone(timezone.utc)
322
+ if granularity == "hour":
323
+ start = start.replace(minute=0, second=0, microsecond=0)
324
+ end = start + timedelta(hours=1)
325
+ else:
326
+ start = start.replace(hour=0, minute=0, second=0, microsecond=0)
327
+ end = start + timedelta(days=1)
328
+ name = _partition_name(base, start, granularity)
329
+ with conn.cursor() as cur:
330
+ cur.execute("SELECT 1 FROM pg_class WHERE relname = %s", (name,))
331
+ if cur.fetchone():
332
+ return
333
+ cur.execute(
334
+ sql.SQL("CREATE TABLE IF NOT EXISTS {} PARTITION OF {} FOR VALUES FROM ({}) TO ({})").format(
335
+ sql.Identifier(name), sql.Identifier(base), sql.Literal(start), sql.Literal(end)
336
+ )
337
+ )
338
+
339
+
340
+ def ensure_partitions(conn: psycopg.Connection, now: datetime | None = None) -> None:
341
+ """Partitions for the near past and future so writers never hit a missing partition."""
342
+ now = now or datetime.now(timezone.utc)
343
+ for h in range(-2, 4):
344
+ ensure_partition(conn, "swap_tape", now + timedelta(hours=h), "hour")
345
+ for d in range(-1, 3):
346
+ ensure_partition(conn, "beat_slots", now + timedelta(days=d), "day")
347
+
348
+
349
+ def ensure_partitions_for(conn: psycopg.Connection, ts: datetime) -> None:
350
+ """Partitions around an arbitrary (e.g. simulated) timestamp."""
351
+ ensure_partition(conn, "swap_tape", ts, "hour")
352
+ ensure_partition(conn, "swap_tape", ts + timedelta(hours=1), "hour")
353
+ ensure_partition(conn, "beat_slots", ts, "day")
354
+ ensure_partition(conn, "beat_slots", ts + timedelta(days=1), "day")
355
+
356
+
357
+ def table_names(url: str | None = None) -> list[str]:
358
+ with connect(url) as conn:
359
+ rows = conn.execute(
360
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public' "
361
+ "AND tablename NOT LIKE 'swap_tape_%%' AND tablename NOT LIKE 'beat_slots_%%' ORDER BY 1"
362
+ ).fetchall()
363
+ return [r["tablename"] for r in rows]
fly_trader/execution/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
fly_trader/execution/broker_live.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live execution through Jupiter Swap API v2 with balance-verified fills (plan §9).
2
+
3
+ Per attempt (one in-flight order per process, guarded by a module lock):
4
+ assert_signing_allowed() -> pre = snapshot_balances() -> GET /order (taker = bot, excludeRouters=
5
+ jupiterz) -> assert signatureFeePayer == taker -> orders row (status 'ordered') -> sign only our
6
+ signer slot -> POST /execute -> orders row updated -> poll getSignatureStatuses every 1 s until
7
+ confirmed or getBlockHeight() > lastValidBlockHeight (no other timeout) -> post = snapshot ->
8
+ fill iff the input balance decreased AND the output balance increased (a confirmed sell whose token
9
+ balance fell is a fill even when the SOL delta is <= 0: dust proceeds below fees) -> fills row.
10
+
11
+ Jupiter's execute status is recorded but never trusted alone: 'Failed' with moved balances is a
12
+ fill (verified_by='balance_delta_despite_failed'); 'Success' without movement is a fills row with
13
+ verified_by='unverified' plus an events row after a getTransaction check. On execute code -1000
14
+ (failed to land) or a slippage error with NO balance movement the order is retried with
15
+ slippage + step_bps up to max_slippage_bps; each attempt is its own orders row.
16
+
17
+ orders.status words: ordered | order_error | sign_error | executed | execute_error | confirmed |
18
+ failed_on_chain | expired | unsent. fills.verified_by: balance_delta |
19
+ balance_delta_despite_failed | unverified. price_sol = |sol delta| / 1e9 / (|token delta| /
20
+ 10^decimals) — an all-in effective price (transaction fee, priority fee and any ATA rent are inside
21
+ the SOL delta; fee_lamports carries the estimate so the ledger must not add it again).
22
+ Every write is committed immediately so a crash mid-poll leaves an auditable row for verify-fills.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import logging
28
+ import re
29
+ import threading
30
+ import time
31
+ from dataclasses import dataclass
32
+
33
+ from psycopg.types.json import Jsonb
34
+ from solders.hash import Hash
35
+ from solders.instruction import AccountMeta, Instruction
36
+ from solders.message import MessageV0
37
+ from solders.pubkey import Pubkey
38
+ from solders.transaction import VersionedTransaction
39
+
40
+ from .. import config
41
+ from ..chain.balances import Snapshot, compute_delta, snapshot_balances
42
+ from ..chain.cluster_guard import assert_signing_allowed
43
+ from ..chain.jupiter_swap import JupiterError, JupiterSwap
44
+ from ..db.connection import transaction
45
+ from ..chain.keys import load_keypair
46
+ from ..chain.rpc import TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, HttpSolanaRpc
47
+ from ..chain.signing import NotASigner, n_lookup_programs, sign_transaction_b64, static_program_ids, transaction_id
48
+ from ..db.apilog import record_event
49
+ from ..db.connection import connect, transaction
50
+ from ..logging_setup import scrub
51
+
52
+ log = logging.getLogger(__name__)
53
+
54
+ _INFLIGHT = threading.Lock() # one in-flight order at a time (plan §9)
55
+
56
+ CONFIRM_POLL_S = 1.0
57
+ BLOCKHASH_VALIDITY_BLOCKS = 150 # fallback horizon when /order carries no lastValidBlockHeight
58
+ CLOSE_ATA_BATCH = 10
59
+ SYSTEM_PROGRAM_ID = "11111111111111111111111111111111"
60
+ COMPUTE_BUDGET_PROGRAM_ID = "ComputeBudget111111111111111111111111111111"
61
+ ASSOCIATED_TOKEN_PROGRAM_ID = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
62
+ JUPITER_PROGRAM_IDS = ("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4",)
63
+ KNOWN_PROGRAM_IDS = frozenset({SYSTEM_PROGRAM_ID, COMPUTE_BUDGET_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
64
+ TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, *JUPITER_PROGRAM_IDS})
65
+ RETRY_CODES = frozenset({-1000}) # aggregator: failed to land
66
+ _SLIPPAGE_RE = re.compile(r"slippage|0x1771|\b6001\b", re.IGNORECASE) # Jupiter SlippageToleranceExceeded = 6001
67
+
68
+
69
+ @dataclass
70
+ class FillResult:
71
+ ok: bool
72
+ order_id: int | None
73
+ fill_id: int | None
74
+ signature: str | None
75
+ status: str | None
76
+ code: int | None
77
+ error: str | None
78
+ token_delta: int
79
+ lamports_delta: int
80
+ price_sol: float | None
81
+ attempts: int
82
+
83
+
84
+ def _int_or_none(v) -> int | None:
85
+ try:
86
+ return int(v) if v is not None else None
87
+ except (TypeError, ValueError):
88
+ return None
89
+
90
+
91
+ def await_confirmation(rpc, signature: str | None, last_valid_block_height: int | None) -> tuple[str, dict | None]:
92
+ """Poll until the signature is confirmed/finalized, fails on chain, or the chain's block height
93
+ passes ``last_valid_block_height`` with no status for the signature (it can then never land; a
94
+ signature already seen as processed keeps being polled until it confirms or its status disappears).
95
+ Sleeps 1 s between polls; there is deliberately no other timeout."""
96
+ if not signature:
97
+ return "unsent", None
98
+ if last_valid_block_height is None:
99
+ last_valid_block_height = rpc.get_block_height() + BLOCKHASH_VALIDITY_BLOCKS
100
+ while True:
101
+ st = (rpc.get_signature_statuses([signature]) or [None])[0]
102
+ if st:
103
+ if st.get("err") is not None:
104
+ return "failed_on_chain", st
105
+ if st.get("confirmationStatus") in ("confirmed", "finalized"):
106
+ return "confirmed", st
107
+ if not st and rpc.get_block_height() > last_valid_block_height:
108
+ return "expired", st
109
+ time.sleep(CONFIRM_POLL_S)
110
+
111
+
112
+ class LiveBroker:
113
+ def __init__(self, rpc=None, jup=None, keypair=None):
114
+ self._rpc = rpc
115
+ self._jup = jup
116
+ self._keypair = keypair
117
+ self._labels: dict[str, str] | None = None
118
+
119
+ @property
120
+ def rpc(self):
121
+ if self._rpc is None:
122
+ self._rpc = HttpSolanaRpc()
123
+ return self._rpc
124
+
125
+ @property
126
+ def jup(self):
127
+ if self._jup is None:
128
+ self._jup = JupiterSwap()
129
+ return self._jup
130
+
131
+ @property
132
+ def keypair(self):
133
+ if self._keypair is None:
134
+ self._keypair = load_keypair()
135
+ return self._keypair
136
+
137
+ @property
138
+ def pubkey(self) -> str:
139
+ return str(self.keypair.pubkey())
140
+
141
+ @property
142
+ def labels(self) -> dict[str, str]:
143
+ if self._labels is None:
144
+ try:
145
+ self._labels = dict(self.jup.program_id_to_label())
146
+ except Exception as e:
147
+ log.warning("program-id-to-label unavailable: %s", type(e).__name__)
148
+ self._labels = {}
149
+ return self._labels
150
+
151
+ # ---- public ----
152
+ def swap(self, conn, *, decision_id: int | None, book: str, side: str, mint: str, amount_in: int,
153
+ slippage_bps: int, max_slippage_bps: int, step_bps: int) -> FillResult:
154
+ """Buy (lamports in -> ``mint``) or sell (raw token units in -> SOL) with balance verification."""
155
+ assert_signing_allowed()
156
+ if side not in ("buy", "sell"):
157
+ raise ValueError(f"side must be 'buy' or 'sell', got {side!r}")
158
+ amount_in = int(amount_in)
159
+ if amount_in <= 0:
160
+ raise ValueError("amount_in must be positive")
161
+ if side == "buy":
162
+ input_mint, output_mint = config.WSOL_MINT, mint
163
+ else:
164
+ input_mint, output_mint = mint, config.WSOL_MINT
165
+ with _INFLIGHT:
166
+ slip = int(slippage_bps)
167
+ attempt = 0
168
+ while True:
169
+ attempt += 1
170
+ result, retryable = self._attempt(
171
+ conn, decision_id=decision_id, book=book, side=side, mint=mint, input_mint=input_mint,
172
+ output_mint=output_mint, amount_in=amount_in, slippage_bps=slip, attempt=attempt)
173
+ result.attempts = attempt
174
+ if result.ok or not retryable or step_bps <= 0 or slip + step_bps > max_slippage_bps:
175
+ if retryable and not result.ok:
176
+ log.warning("order %s not retried: slippage ladder exhausted at %d bps", result.order_id, slip)
177
+ return result
178
+ slip += int(step_bps)
179
+ log.warning("retrying %s %s after %s (code %s) with slippage %d bps",
180
+ side, mint, result.status, result.code, slip)
181
+
182
+ # ---- one attempt ----
183
+ def _attempt(self, conn, *, decision_id, book, side, mint, input_mint, output_mint, amount_in,
184
+ slippage_bps, attempt) -> tuple[FillResult, bool]:
185
+ taker = self.pubkey
186
+ pre = snapshot_balances(self.rpc, taker)
187
+ request = {"inputMint": input_mint, "outputMint": output_mint, "amount": str(amount_in), "taker": taker,
188
+ "slippageBps": slippage_bps, "excludeRouters": "jupiterz"}
189
+ base = dict(decision_id=decision_id, book=book, attempt=attempt, side=side, input_mint=input_mint,
190
+ output_mint=output_mint, amount_in=amount_in, slippage_bps=slippage_bps, request=request)
191
+
192
+ def fail(status, error, code=None, order_response=None, order_id=None):
193
+ if order_id is None:
194
+ order_id = self._insert_order(conn, **base, order_response=order_response, status=status,
195
+ error_code=code, error=error)
196
+ else:
197
+ self._update_order(conn, order_id, status=status, error_code=code, error=error)
198
+ log.error("%s %s attempt %d: %s: %s", side, mint, attempt, status, error)
199
+ return FillResult(False, order_id, None, None, status, code, error, 0, 0, None, attempt), False
200
+
201
+ # 1. order
202
+ try:
203
+ order = self.jup.order(input_mint, output_mint, amount_in, taker=taker, slippage_bps=slippage_bps)
204
+ except JupiterError as e:
205
+ return fail("order_error", scrub(str(e)))
206
+ err_code = _int_or_none(order.get("errorCode"))
207
+ tx_b64 = order.get("transaction")
208
+ if err_code is not None or not tx_b64:
209
+ msg = order.get("errorMessage") or order.get("error") or "no transaction in /order response"
210
+ return fail("order_error", scrub(str(msg)), err_code, order)
211
+ fee_payer = order.get("signatureFeePayer")
212
+ if fee_payer and fee_payer != taker:
213
+ return fail("order_error", f"signatureFeePayer {fee_payer} != taker", None, order)
214
+ lvbh = _int_or_none(order.get("lastValidBlockHeight"))
215
+ program_ids = static_program_ids(tx_b64)
216
+ unknown = [p for p in program_ids if p not in KNOWN_PROGRAM_IDS and p not in self.labels]
217
+ if unknown:
218
+ log.warning("order references unknown static program ids: %s", unknown)
219
+ n_lookup = n_lookup_programs(tx_b64)
220
+ if n_lookup:
221
+ log.warning("%d instruction(s) use program ids from lookup tables (unresolved in v1)", n_lookup)
222
+ order_id = self._insert_order(
223
+ conn, **base, order_response=order, request_id=order.get("requestId"), router=order.get("router"),
224
+ fee_bps=_int_or_none(order.get("feeBps")), program_ids=program_ids, lvbh=lvbh, status="ordered")
225
+
226
+ # 2. sign (our slot only)
227
+ try:
228
+ signed_b64, _idx = sign_transaction_b64(tx_b64, self.keypair)
229
+ except (NotASigner, ValueError) as e:
230
+ return fail("sign_error", f"{type(e).__name__}: {scrub(str(e))}", None, order_id=order_id)
231
+ if _idx != 0 and not order.get("gasless"):
232
+ return fail("sign_error", f"our key is signer {_idx}, but a non-gasless order must have the taker as fee payer", None, order_id=order_id)
233
+ tx_sig = transaction_id(signed_b64)
234
+
235
+ # 3. execute
236
+ exec_req = {"requestId": order.get("requestId"), "lastValidBlockHeight": lvbh, "signedTransaction": signed_b64}
237
+ ex: dict | None
238
+ try:
239
+ ex = self.jup.execute(signed_b64, order.get("requestId"), lvbh)
240
+ except JupiterError as e:
241
+ ex = None
242
+ code = None
243
+ error = scrub(str(e))
244
+ signature = tx_sig # the transaction may still have been broadcast: poll it anyway
245
+ self._update_order(conn, order_id, execute_request=exec_req, execute_response=None, signature=signature,
246
+ status="execute_error", error_code=None, error=error)
247
+ else:
248
+ code = _int_or_none(ex.get("code"))
249
+ error = ex.get("error")
250
+ signature = ex.get("signature") or tx_sig
251
+ if ex.get("signature") and ex["signature"] != tx_sig:
252
+ log.warning("execute signature %s differs from our transaction id %s", ex["signature"], tx_sig)
253
+ self._update_order(conn, order_id, execute_request=exec_req, execute_response=ex, signature=signature,
254
+ status="executed", error_code=code, error=error, latency_ms=ex.get("_latency_ms"))
255
+
256
+ # 4. confirm (or expire), 5. verify by balance deltas — retried: a transport error here must not leave a landed swap unrecorded
257
+ chain_status = st = post = None; last_exc = None
258
+ for k in range(4):
259
+ try:
260
+ if chain_status is None:
261
+ chain_status, st = await_confirmation(self.rpc, signature, lvbh)
262
+ self._update_order(conn, order_id, status=chain_status)
263
+ post = snapshot_balances(self.rpc, taker); break
264
+ except Exception as e: # RPC transport / RPC errors
265
+ last_exc = e; log.warning("confirm/verify attempt %d for %s failed: %s", k + 1, signature, scrub(str(e)))
266
+ time.sleep(2.0 * (k + 1))
267
+ if post is None:
268
+ fill_id = self._insert_fill(conn, order_id, book, signature, None, mint, side, 0, 0, None, None, order.get("platformFee"), "unknown", pre, pre)
269
+ record_event("error", "broker_live", "fill unknown: could not confirm or verify after retries; entries paused until reconciled (verify-fills)",
270
+ {"order_id": order_id, "signature": signature, "side": side, "mint": mint, "error": scrub(str(last_exc))[:200]})
271
+ try: # a landed but unrecorded swap must not be bought again or leave a stale position
272
+ with transaction() as c2:
273
+ c2.execute("UPDATE circuit_state SET entries_paused = true, updated_at = now() WHERE id = 1")
274
+ c2.execute("INSERT INTO circuit_events (kind, detail) VALUES ('unknown_fill_pause', %s)", (Jsonb({"order_id": order_id, "signature": signature, "mint": mint, "side": side}),))
275
+ except Exception:
276
+ log.exception("could not pause entries after an unknown fill")
277
+ raise RuntimeError(f"confirm/verify failed for {signature}: {type(last_exc).__name__}") from last_exc
278
+ delta = compute_delta(pre, post)
279
+ lam = int(delta["lamports_delta"])
280
+ tok = int(delta["token_deltas"].get(mint, 0))
281
+ # a confirmed sell that moved the tokens is a fill even at lam <= 0 (a rugged token's dust proceeds < fees)
282
+ verified = (lam < 0 and tok > 0) if side == "buy" else (tok < 0 and (lam > 0 or chain_status == "confirmed"))
283
+ jup_status = (ex or {}).get("status")
284
+ slot = _int_or_none((ex or {}).get("slot")) or _int_or_none((st or {}).get("slot"))
285
+ if verified:
286
+ verified_by = "balance_delta" if jup_status == "Success" else "balance_delta_despite_failed"
287
+ decimals = self._decimals(conn, mint, pre, post)
288
+ sol = abs(lam) if side == "buy" else max(lam, 0)
289
+ price = (sol / config.LAMPORTS_PER_SOL) / (abs(tok) / 10 ** decimals) if decimals is not None else None
290
+ fee = self._fee_lamports(side, lam, amount_in, ex, order)
291
+ fill_id = self._insert_fill(conn, order_id, book, signature, slot, mint, side, tok, lam, price, fee,
292
+ order.get("platformFee"), verified_by, pre, post)
293
+ log.info("fill %s: %s %s token_delta=%d lamports_delta=%d price_sol=%s (%s)",
294
+ fill_id, side, mint, tok, lam, price, verified_by)
295
+ return FillResult(True, order_id, fill_id, signature, chain_status, code, error, tok, lam, price, attempt), False
296
+ if jup_status == "Success" or chain_status == "confirmed":
297
+ tx_info = None
298
+ try:
299
+ tx_info = self.rpc.get_transaction(signature)
300
+ except Exception as e:
301
+ log.warning("getTransaction failed for %s: %s", signature, type(e).__name__)
302
+ meta = (tx_info or {}).get("meta") or {}
303
+ record_event("warning", "broker_live",
304
+ "fill unverified: execute reported success but balances did not move as expected",
305
+ {"order_id": order_id, "signature": signature, "side": side, "mint": mint,
306
+ "lamports_delta": lam, "token_delta": tok, "chain_status": chain_status,
307
+ "jupiter_status": jup_status, "tx_found": tx_info is not None,
308
+ "tx_err": meta.get("err"), "tx_fee": meta.get("fee"), "tx_slot": (tx_info or {}).get("slot")})
309
+ fill_id = self._insert_fill(conn, order_id, book, signature, slot, mint, side, tok, lam, None, None,
310
+ order.get("platformFee"), "unverified", pre, post)
311
+ return FillResult(False, order_id, fill_id, signature, chain_status, code, error, tok, lam, None, attempt), False
312
+ retryable = (code in RETRY_CODES) or bool(error and _SLIPPAGE_RE.search(str(error)))
313
+ log.error("%s %s attempt %d not filled: chain=%s jupiter=%s code=%s error=%s lamports_delta=%d token_delta=%d",
314
+ side, mint, attempt, chain_status, jup_status, code, error, lam, tok)
315
+ return FillResult(False, order_id, None, signature, chain_status, code, error, tok, lam, None, attempt), retryable
316
+
317
+ # ---- helpers ----
318
+ def _decimals(self, conn, mint: str, pre: Snapshot, post: Snapshot) -> int | None:
319
+ d = post.decimals.get(mint, pre.decimals.get(mint))
320
+ if d is not None:
321
+ return int(d)
322
+ try:
323
+ row = conn.execute("SELECT decimals FROM tokens WHERE mint = %s", (mint,)).fetchone()
324
+ if row and row["decimals"] is not None:
325
+ return int(row["decimals"])
326
+ except Exception:
327
+ conn.rollback()
328
+ try:
329
+ return self.rpc.get_mint_decimals(mint)
330
+ except Exception:
331
+ return None
332
+
333
+ @staticmethod
334
+ def _fee_lamports(side: str, lam: int, amount_in: int, ex: dict | None, order: dict) -> int | None:
335
+ """Buy: lamports beyond the swap input (tx fee + priority fee + ATA rent). Sell: SOL the route
336
+ produced that did not reach the wallet. Approximations from execute amounts, else the order's
337
+ fee fields."""
338
+ ex = ex or {}
339
+ if side == "buy":
340
+ total_in = _int_or_none(ex.get("totalInputAmount")) or amount_in
341
+ return abs(lam) - total_in
342
+ total_out = _int_or_none(ex.get("totalOutputAmount"))
343
+ if total_out is not None:
344
+ return total_out - lam
345
+ sig_fee = _int_or_none(order.get("signatureFeeLamports")) or 0
346
+ prio = _int_or_none(order.get("prioritizationFeeLamports")) or 0
347
+ return sig_fee + prio
348
+
349
+ def _insert_order(self, conn, *, decision_id, book, attempt, side, input_mint, output_mint, amount_in,
350
+ slippage_bps, request, order_response=None, request_id=None, router=None, fee_bps=None,
351
+ program_ids=None, lvbh=None, status, error_code=None, error=None) -> int:
352
+ row = conn.execute(
353
+ "INSERT INTO orders (decision_id, book, attempt, side, input_mint, output_mint, amount_in, slippage_bps, "
354
+ "request, order_response, request_id, router, fee_bps, program_ids, last_valid_block_height, status, "
355
+ "error_code, error) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id",
356
+ (decision_id, book, attempt, side, input_mint, output_mint, amount_in, slippage_bps, Jsonb(request),
357
+ Jsonb(order_response) if order_response is not None else None, request_id, router, fee_bps,
358
+ list(program_ids) if program_ids is not None else None, lvbh, status, error_code, error),
359
+ ).fetchone()
360
+ conn.commit()
361
+ return int(row["id"])
362
+
363
+ def _update_order(self, conn, order_id: int, **fields) -> None:
364
+ json_cols = {"execute_request", "execute_response", "order_response"}
365
+ cols, vals = [], []
366
+ for k, v in fields.items():
367
+ cols.append(f"{k} = %s")
368
+ vals.append(Jsonb(v) if k in json_cols and v is not None else v)
369
+ conn.execute(f"UPDATE orders SET {', '.join(cols)} WHERE id = %s", (*vals, order_id))
370
+ conn.commit()
371
+
372
+ def _insert_fill(self, conn, order_id, book, signature, slot, mint, side, token_delta, sol_delta_lamports,
373
+ price_sol, fee_lamports, platform_fee, verified_by, pre: Snapshot, post: Snapshot) -> int:
374
+ row = conn.execute(
375
+ "INSERT INTO fills (order_id, book, signature, slot, mint, side, token_delta, sol_delta_lamports, price_sol, "
376
+ "fee_lamports, platform_fee, verified_by, pre_snapshot, post_snapshot) "
377
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id",
378
+ (order_id, book, signature, slot, mint, side, token_delta, sol_delta_lamports, price_sol, fee_lamports,
379
+ Jsonb(platform_fee) if platform_fee is not None else None, verified_by,
380
+ Jsonb(pre.to_json()), Jsonb(post.to_json())),
381
+ ).fetchone()
382
+ conn.commit()
383
+ return int(row["id"])
384
+
385
+
386
+ def swap_smoke(sol: float) -> None:
387
+ """Operator smoke test (plan phase 3.12): SOL -> USDC then USDC -> SOL through the live broker.
388
+ Requires the full live gate (LIVE_ENABLED=1 etc.). Prints both FillResults and the net lamports."""
389
+ assert_signing_allowed()
390
+ lamports = int(round(float(sol) * config.LAMPORTS_PER_SOL))
391
+ broker = LiveBroker()
392
+ print(f"swap-smoke: wallet {broker.pubkey}: {sol} SOL -> USDC -> SOL")
393
+ with connect() as conn:
394
+ r1 = broker.swap(conn, decision_id=None, book="live", side="buy", mint=config.USDC_MINT, amount_in=lamports,
395
+ slippage_bps=config.SLIPPAGE_ENTRY_BPS, max_slippage_bps=config.MAX_SLIPPAGE_ENTRY_BPS,
396
+ step_bps=config.SLIPPAGE_STEP_BPS)
397
+ print(f"leg 1 (buy USDC): {r1}")
398
+ net = r1.lamports_delta
399
+ if r1.ok and r1.token_delta > 0:
400
+ r2 = broker.swap(conn, decision_id=None, book="live", side="sell", mint=config.USDC_MINT,
401
+ amount_in=r1.token_delta, slippage_bps=config.SLIPPAGE_EXIT_BPS,
402
+ max_slippage_bps=config.SLIPPAGE_FORCED_BPS, step_bps=config.SLIPPAGE_STEP_BPS)
403
+ print(f"leg 2 (sell USDC): {r2}")
404
+ net += r2.lamports_delta
405
+ else:
406
+ print("leg 2 skipped: leg 1 was not a verified fill")
407
+ print(f"net lamports delta: {net} ({net / config.LAMPORTS_PER_SOL:.9f} SOL)")
408
+
409
+
410
+ def close_empty_atas(rpc=None, keypair=None, url: str | None = None, batch: int = CLOSE_ATA_BATCH) -> dict:
411
+ """Close every zero-balance token account owned by the bot (rent back to the wallet).
412
+ SPL Token / Token-2022 CloseAccount = instruction index 9, accounts [account, destination, owner].
413
+ One v0 transaction per ``batch`` accounts; each is recorded as wallet_events(kind='ata_closed')."""
414
+ assert_signing_allowed()
415
+ kp = keypair or load_keypair()
416
+ owner = kp.pubkey()
417
+ owner_s = str(owner)
418
+ rpc = rpc or HttpSolanaRpc()
419
+ empties = [a for a in rpc.get_token_accounts_by_owner(owner_s) if int(a["amount"]) == 0]
420
+ result = {"pubkey": owner_s, "candidates": len(empties), "closed": 0, "signatures": [], "failed": []}
421
+ if not empties:
422
+ return result
423
+ for i in range(0, len(empties), batch):
424
+ chunk = empties[i:i + batch]
425
+ accounts = [a["address"] for a in chunk]
426
+ ixs = [Instruction(Pubkey.from_string(a["program"]), bytes([9]),
427
+ [AccountMeta(Pubkey.from_string(a["address"]), False, True),
428
+ AccountMeta(owner, False, True),
429
+ AccountMeta(owner, True, False)]) for a in chunk]
430
+ bh = rpc.get_latest_blockhash()
431
+ msg = MessageV0.try_compile(owner, ixs, [], Hash.from_string(bh["blockhash"]))
432
+ tx = VersionedTransaction(msg, [kp])
433
+ tx_b64 = base64.b64encode(bytes(tx)).decode()
434
+ try:
435
+ sig = rpc.send_transaction(tx_b64)
436
+ except Exception as e:
437
+ err = f"{type(e).__name__}: {scrub(str(e))}"
438
+ result["failed"].append({"accounts": accounts, "error": err})
439
+ record_event("error", "close_empty_atas", "close transaction rejected", {"accounts": accounts, "error": err})
440
+ continue
441
+ status, _st = await_confirmation(rpc, sig, bh["last_valid_block_height"])
442
+ detail = {"accounts": accounts, "signature": sig, "status": status}
443
+ try:
444
+ with transaction(url) as conn:
445
+ conn.execute("INSERT INTO wallet_events (kind, pubkey, detail) VALUES ('ata_closed', %s, %s)",
446
+ (owner_s, Jsonb(detail)))
447
+ except Exception as e:
448
+ log.warning("wallet_events insert failed: %s", type(e).__name__)
449
+ if status == "confirmed":
450
+ result["closed"] += len(chunk)
451
+ result["signatures"].append(sig)
452
+ else:
453
+ result["failed"].append(detail)
454
+ log.info("close_empty_atas: closed %d of %d (%d tx)", result["closed"], len(empties), len(result["signatures"]))
455
+ return result
fly_trader/execution/broker_paper.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PaperBroker: the same decisions executed at model prices.
2
+
3
+ Fill price = last tape price; cost = the real fees (pool fee by market cap, Jupiter 10 bps, network fee) + constant-product impact
4
+ of the position size against the pool's quote reserve (market/exit_cost.py). Deviation from the plan's
5
+ "first tape price ≥ 2 s after the decision" rule: fills are immediate at the last price; the
6
+ live-vs-mirror gap measures the difference.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+
14
+ from .. import config
15
+ from ..market.exit_cost import exit_cost_fraction, fee_fraction, impact_fraction
16
+ from . import ledger
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class PaperFill:
23
+ ok: bool
24
+ position_id: int | None
25
+ fill_id: int | None
26
+ price: float
27
+ sol_delta: float
28
+ token_delta: int
29
+ fee_sol: float
30
+ reason: str = ""
31
+
32
+
33
+ class PaperBroker:
34
+ def __init__(self, book: str):
35
+ assert book in ("paper_selector", "paper_fly") or book.startswith("replay_"), book # the race books; replay_* for tests
36
+ self.book = book
37
+
38
+ def buy(self, conn, *, decision_id: int | None, mint: str, pool: str | None, size_sol: float, price: float | None,
39
+ res_quote_sol: float | None, mcap_sol: float | None, decimals: int, program_label: str | None, pool_fee: float | None = None,
40
+ ts: datetime | None = None, hold_s: float | None = None, strategy: str | None = None) -> PaperFill:
41
+ """Fill at ``price`` pushed up by the constant-product impact, paying the real fees (market/exit_cost.py): the pool
42
+ fee the stream reports (``pool_fee``) or the schedule's rate for ``mcap_sol``, Jupiter's platform fee, the network fee."""
43
+ ts = ts or datetime.now(timezone.utc)
44
+ if not price or price <= 0:
45
+ return PaperFill(False, None, None, 0.0, 0.0, 0, 0.0, "no price")
46
+ if res_quote_sol is None or res_quote_sol <= 0:
47
+ return PaperFill(False, None, None, price, 0.0, 0, 0.0, "no liquidity estimate")
48
+ fee_frac = fee_fraction(size_sol, mcap_sol, pool_fee)
49
+ impact = impact_fraction(size_sol, res_quote_sol, program_label)
50
+ if impact > config.EXECUTABILITY_MAX_IMPACT:
51
+ # a router would not fill this either; refuse like the live executability check
52
+ return PaperFill(False, None, None, price, 0.0, 0, 0.0, f"impact {impact:.2f} > {config.EXECUTABILITY_MAX_IMPACT}")
53
+ eff_price = price * (1.0 + impact) # buying pushes the price up against us
54
+ tokens = size_sol * (1.0 - fee_frac) / eff_price
55
+ qty_raw = int(tokens * (10 ** decimals))
56
+ if qty_raw <= 0:
57
+ return PaperFill(False, None, None, price, 0.0, 0, 0.0, "zero quantity")
58
+ fee_sol = size_sol * fee_frac
59
+ pid = ledger.open_position(conn, book=self.book, mint=mint, pool=pool, qty_raw=qty_raw, cost_sol=size_sol,
60
+ entry_price=eff_price, decision_id=decision_id, fees_sol=fee_sol, ts=ts, hold_s=hold_s, strategy=strategy)
61
+ fid = ledger.record_fill(conn, order_id=None, book=self.book, mint=mint, side="buy", token_delta=qty_raw,
62
+ sol_delta_lamports=-int(size_sol * config.LAMPORTS_PER_SOL), price_sol=eff_price,
63
+ fee_lamports=int(fee_sol * config.LAMPORTS_PER_SOL), verified_by="model", ts=ts)
64
+ return PaperFill(True, pid, fid, eff_price, -size_sol, qty_raw, fee_sol)
65
+
66
+ def sell(self, conn, *, position: dict, decision_id: int | None, price: float | None, res_quote_sol: float | None,
67
+ mcap_sol: float | None, program_label: str | None, forced_kind: str | None, pool_fee: float | None = None,
68
+ ts: datetime | None = None, fraction: float = 1.0) -> PaperFill:
69
+ ts = ts or datetime.now(timezone.utc)
70
+ price = price or float(position.get("last_mark_price") or position.get("entry_price") or 0.0)
71
+ fraction = float(min(1.0, max(0.0, fraction)))
72
+ qty_raw = int(int(position["qty"]) * fraction) if fraction < 0.999 else int(position["qty"])
73
+ decimals = int(position.get("decimals") or 6)
74
+ gross = qty_raw / (10 ** decimals) * price
75
+ c = exit_cost_fraction(gross, res_quote_sol, mcap_sol, program_label, pool_fee)
76
+ proceeds = gross * (1.0 - c)
77
+ fee_sol = gross * fee_fraction(gross, mcap_sol, pool_fee)
78
+ if fraction < 0.999: # partial: shrink the position, realise the sold part
79
+ realized = ledger.realize_partial(conn, position_id=int(position["id"]), qty_raw=qty_raw,
80
+ cost_part=float(position["cost_sol"]) * fraction, proceeds_sol=proceeds,
81
+ fees_sol=fee_sol, price=price, ts=ts)
82
+ if realized is None:
83
+ return PaperFill(False, int(position["id"]), None, price, 0.0, 0, 0.0, "position not open")
84
+ fid = ledger.record_fill(conn, order_id=None, book=self.book, mint=position["mint"], side="sell", token_delta=-qty_raw,
85
+ sol_delta_lamports=int(proceeds * config.LAMPORTS_PER_SOL), price_sol=price * (1.0 - c),
86
+ fee_lamports=int(fee_sol * config.LAMPORTS_PER_SOL), verified_by="model", ts=ts)
87
+ return PaperFill(True, int(position["id"]), fid, price * (1.0 - c), proceeds, -qty_raw, fee_sol, reason=f"partial {fraction:.2f} realized {realized:+.5f} SOL")
88
+ realized = ledger.close_position(conn, position_id=int(position["id"]), exit_price=price * (1.0 - c),
89
+ proceeds_sol=proceeds, fees_sol=fee_sol, decision_id=decision_id,
90
+ forced_kind=forced_kind, ts=ts)
91
+ if realized is None:
92
+ return PaperFill(False, int(position["id"]), None, price, 0.0, 0, 0.0, "position not open")
93
+ fid = ledger.record_fill(conn, order_id=None, book=self.book, mint=position["mint"], side="sell", token_delta=-qty_raw,
94
+ sol_delta_lamports=int(proceeds * config.LAMPORTS_PER_SOL), price_sol=price * (1.0 - c),
95
+ fee_lamports=int(fee_sol * config.LAMPORTS_PER_SOL), verified_by="model", ts=ts)
96
+ return PaperFill(True, int(position["id"]), fid, price * (1.0 - c), proceeds, -qty_raw, fee_sol,
97
+ reason=f"realized {realized:+.5f} SOL")
fly_trader/execution/ledger.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Positions and fills bookkeeping per book (live, paper_selector).
2
+
3
+ Paper cash is derived, never stored: cash = CAPITAL_SOL + Σ realized_sol(all) − Σ cost_sol(open).
4
+ realized_sol accumulates per position: each partial sale books proceeds − the cost it retires (cost_sol
5
+ shrinks by that part) and the close adds proceeds − the remaining cost, so an open position may carry
6
+ realized P&L from partial sales.
7
+ Live cash is the on-chain SOL balance. Every open/close writes positions rows; every fill writes a
8
+ fills row (verified_by = 'balance_delta' for live, 'model' for paper).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from datetime import datetime, timezone
15
+
16
+ from .. import config
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def open_positions(conn, book: str) -> list[dict]:
22
+ rows = conn.execute(
23
+ """SELECT p.*, t.decimals, t.graduated_at, wp.program_label,
24
+ COALESCE(p.hold_s, (d.detail->>'hold_s')::float) AS hold_s, COALESCE(p.strategy, d.detail->>'strategy') AS strategy
25
+ FROM positions p LEFT JOIN tokens t ON t.mint = p.mint
26
+ LEFT JOIN watch_pools wp ON wp.pool = p.pool
27
+ LEFT JOIN decisions d ON d.id = p.entry_decision_id
28
+ WHERE p.book = %s AND p.status = 'open' ORDER BY p.opened_at""",
29
+ (book,),
30
+ ).fetchall()
31
+ return [dict(r) for r in rows]
32
+
33
+
34
+ def paper_cash(conn, book: str) -> float:
35
+ r = conn.execute(
36
+ """SELECT COALESCE(sum(realized_sol), 0) AS realized,
37
+ COALESCE(sum(cost_sol) FILTER (WHERE status = 'open'), 0) AS open_cost
38
+ FROM positions WHERE book = %s""",
39
+ (book,),
40
+ ).fetchone()
41
+ return config.CAPITAL_SOL + float(r["realized"]) - float(r["open_cost"])
42
+
43
+
44
+ def record_fill(conn, *, order_id: int | None, book: str, mint: str, side: str, token_delta: int,
45
+ sol_delta_lamports: int, price_sol: float, fee_lamports: int, verified_by: str,
46
+ signature: str | None = None, slot: int | None = None, ts: datetime | None = None,
47
+ pre: dict | None = None, post: dict | None = None) -> int:
48
+ row = conn.execute(
49
+ """INSERT INTO fills (order_id, book, ts, signature, slot, mint, side, token_delta, sol_delta_lamports,
50
+ price_sol, fee_lamports, verified_by, pre_snapshot, post_snapshot)
51
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
52
+ (order_id, book, ts or datetime.now(timezone.utc), signature, slot, mint, side, token_delta, sol_delta_lamports,
53
+ price_sol, fee_lamports, verified_by, json.dumps(pre) if pre else None, json.dumps(post) if post else None),
54
+ ).fetchone()
55
+ return int(row["id"])
56
+
57
+
58
+ def open_position(conn, *, book: str, mint: str, pool: str | None, qty_raw: int, cost_sol: float,
59
+ entry_price: float, decision_id: int | None, fees_sol: float, ts: datetime | None = None,
60
+ hold_s: float | None = None, strategy: str | None = None) -> int:
61
+ ts = ts or datetime.now(timezone.utc)
62
+ existing = conn.execute("SELECT id, qty, cost_sol, entry_price FROM positions WHERE book=%s AND mint=%s AND status='open'",
63
+ (book, mint)).fetchone()
64
+ if existing: # add to an open position: average the entry
65
+ q0, c0 = int(existing["qty"]), float(existing["cost_sol"])
66
+ q1, c1 = q0 + qty_raw, c0 + cost_sol
67
+ p1 = (float(existing["entry_price"] or entry_price) * q0 + entry_price * qty_raw) / max(q1, 1)
68
+ conn.execute("UPDATE positions SET qty=%s, cost_sol=%s, entry_price=%s, fees_sol=fees_sol+%s, last_mark_price=%s, last_mark_ts=%s WHERE id=%s",
69
+ (q1, c1, p1, fees_sol, entry_price, ts, existing["id"]))
70
+ return int(existing["id"])
71
+ row = conn.execute(
72
+ """INSERT INTO positions (book, mint, pool, opened_at, entry_decision_id, qty, cost_sol, entry_price, peak_price,
73
+ last_mark_price, last_mark_ts, fees_sol, status, last_swap_ts, hold_s, strategy)
74
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'open',%s,%s,%s) RETURNING id""",
75
+ (book, mint, pool, ts, decision_id, qty_raw, cost_sol, entry_price, entry_price, entry_price, ts, fees_sol, ts, hold_s, strategy),
76
+ ).fetchone()
77
+ return int(row["id"])
78
+
79
+
80
+ def realize_partial(conn, *, position_id: int, qty_raw: int, cost_part: float, proceeds_sol: float, fees_sol: float,
81
+ price: float, ts: datetime | None = None) -> float | None:
82
+ """Shrink an open position by qty_raw / cost_part and book proceeds − cost_part into realized_sol.
83
+ Returns this sale's realized P&L, or None when the position is no longer open (nothing booked)."""
84
+ ts = ts or datetime.now(timezone.utc)
85
+ realized = proceeds_sol - cost_part
86
+ r = conn.execute(
87
+ """UPDATE positions SET qty = qty - %s, cost_sol = cost_sol - %s, realized_sol = COALESCE(realized_sol, 0) + %s,
88
+ fees_sol = fees_sol + %s, last_mark_price=%s, last_mark_ts=%s WHERE id=%s AND status='open' RETURNING id""",
89
+ (qty_raw, cost_part, realized, fees_sol, price, ts, position_id)).fetchone()
90
+ return realized if r else None
91
+
92
+
93
+ def close_position(conn, *, position_id: int, exit_price: float, proceeds_sol: float, fees_sol: float,
94
+ decision_id: int | None, forced_kind: str | None, ts: datetime | None = None) -> float | None:
95
+ """Close an open position; realized_sol accumulates (earlier partial sales + proceeds − remaining cost).
96
+ Returns this sale's realized P&L, or None when the position was not open (already closed: nothing booked)."""
97
+ ts = ts or datetime.now(timezone.utc)
98
+ r = conn.execute(
99
+ """UPDATE positions SET status='closed', closed_at=%s, exit_decision_id=%s, exit_price=%s,
100
+ realized_sol=COALESCE(realized_sol, 0) + %s - cost_sol, fees_sol=fees_sol+%s, forced_exit_kind=%s,
101
+ last_mark_price=%s, last_mark_ts=%s WHERE id=%s AND status='open' RETURNING %s - cost_sol AS realized""",
102
+ (ts, decision_id, exit_price, proceeds_sol, fees_sol, forced_kind, exit_price, ts, position_id, proceeds_sol),
103
+ ).fetchone()
104
+ if r is None:
105
+ log.warning("close_position: position %s is not open; nothing booked", position_id)
106
+ return None
107
+ return float(r["realized"])
108
+
109
+
110
+ def mark_positions(conn, book: str, prices: dict[str, float], last_swaps: dict[str, float], ts: datetime,
111
+ dt_s: float = 0.0) -> None:
112
+ """Mark to the last price; track the peak; accrue satiety = Σ max(unrealized return, 0) × dt while in profit."""
113
+ rows = conn.execute("SELECT id, mint, peak_price, entry_price, satiety FROM positions WHERE book=%s AND status='open'", (book,)).fetchall()
114
+ for r in rows:
115
+ p = prices.get(r["mint"])
116
+ if p is None:
117
+ continue
118
+ peak = max(float(r["peak_price"] or 0.0), p)
119
+ entry = float(r["entry_price"] or 0.0)
120
+ u = (p / entry - 1.0) if entry > 0 else 0.0
121
+ sat = float(r["satiety"] or 0.0) + max(u, 0.0) * dt_s
122
+ ls = last_swaps.get(r["mint"])
123
+ conn.execute("UPDATE positions SET last_mark_price=%s, last_mark_ts=%s, peak_price=%s, satiety=%s, "
124
+ "last_swap_ts=COALESCE(%s, last_swap_ts) WHERE id=%s",
125
+ (p, ts, peak, sat, datetime.fromtimestamp(ls, tz=timezone.utc) if ls else None, r["id"]))
126
+
127
+
128
+ def verify_fills() -> None:
129
+ """Reconcile live positions against on-chain token balances; prints mismatches, writes wallet_events."""
130
+ from ..chain import keys
131
+ from ..chain.rpc import HttpSolanaRpc
132
+ from ..db.connection import transaction
133
+ rpc = HttpSolanaRpc()
134
+ pub = keys.bot_pubkey()
135
+ accounts = rpc.get_token_accounts_by_owner(pub)
136
+ onchain = {a["mint"]: int(a["amount"]) for a in accounts}
137
+ with transaction() as conn:
138
+ pos = open_positions(conn, "live")
139
+ mismatches = []
140
+ for p in pos:
141
+ have = onchain.get(p["mint"], 0)
142
+ if abs(have - int(p["qty"])) > max(1, int(p["qty"]) // 1000):
143
+ mismatches.append({"mint": p["mint"], "db_qty": int(p["qty"]), "onchain": have})
144
+ stray = [m for m, a in onchain.items() if a > 0 and m not in {p["mint"] for p in pos} and m != config.WSOL_MINT]
145
+ conn.execute("INSERT INTO wallet_events (kind, pubkey, detail) VALUES ('verify_fills', %s, %s)",
146
+ (pub, json.dumps({"mismatches": mismatches, "stray_mints": stray, "n_positions": len(pos)})))
147
+ print(json.dumps({"positions": len(pos), "mismatches": mismatches, "stray_mints": stray}, indent=1))
fly_trader/execution/worker.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Execution queue for the live book: the caller never blocks on Jupiter.
2
+
3
+ The caller enqueues ExecRequest objects; one background thread executes them sequentially through
4
+ LiveBroker (one in-flight order at a time, balance-verified); results are drained by the caller, which
5
+ opens/closes positions accordingly. Mints with an in-flight request are skipped.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import queue
11
+ import threading
12
+ import traceback
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timezone
15
+
16
+ from .. import config
17
+ from ..db.connection import transaction
18
+ from ..agent import rails
19
+ from . import ledger
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class ExecRequest:
26
+ decision_id: int | None
27
+ mint: str
28
+ pool: str | None
29
+ side: str # buy | sell
30
+ amount_in: int # lamports (buy) or raw token units (sell)
31
+ slippage_bps: int
32
+ max_slippage_bps: int
33
+ decimals: int
34
+ forced_kind: str | None = None
35
+ position_id: int | None = None
36
+ size_sol: float = 0.0
37
+ enqueued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
38
+
39
+
40
+ @dataclass
41
+ class ExecResult:
42
+ request: ExecRequest
43
+ ok: bool
44
+ fill_id: int | None = None
45
+ signature: str | None = None
46
+ token_delta: int = 0
47
+ lamports_delta: int = 0
48
+ price_sol: float | None = None
49
+ error: str | None = None
50
+ position_id: int | None = None
51
+ realized_sol: float | None = None
52
+
53
+
54
+ class ExecutionWorker:
55
+ def __init__(self, broker):
56
+ self.broker = broker
57
+ self.q: queue.Queue[ExecRequest] = queue.Queue()
58
+ self.results: queue.Queue[ExecResult] = queue.Queue()
59
+ self.inflight: set[str] = set()
60
+ self.lock = threading.Lock()
61
+ self.thread = threading.Thread(target=self._loop, name="runner-exec", daemon=True) # routed to the runner's log
62
+ self.stop_flag = False
63
+ self.thread.start()
64
+
65
+ def submit(self, req: ExecRequest) -> bool:
66
+ with self.lock:
67
+ if req.mint in self.inflight:
68
+ return False
69
+ self.inflight.add(req.mint)
70
+ self.q.put(req)
71
+ return True
72
+
73
+ def pending(self) -> set[str]:
74
+ with self.lock:
75
+ return set(self.inflight)
76
+
77
+ def drain(self) -> list[ExecResult]:
78
+ out = []
79
+ while True:
80
+ try:
81
+ out.append(self.results.get_nowait())
82
+ except queue.Empty:
83
+ break
84
+ return out
85
+
86
+ def stop(self) -> None:
87
+ self.stop_flag = True
88
+
89
+ def _loop(self) -> None:
90
+ while not self.stop_flag:
91
+ try:
92
+ req = self.q.get(timeout=1.0)
93
+ except queue.Empty:
94
+ continue
95
+ res = self._execute(req)
96
+ with self.lock:
97
+ self.inflight.discard(req.mint)
98
+ self.results.put(res)
99
+
100
+ def _execute(self, req: ExecRequest) -> ExecResult:
101
+ try:
102
+ with transaction() as conn:
103
+ fr = self.broker.swap(conn, decision_id=req.decision_id, book="live", side=req.side, mint=req.mint,
104
+ amount_in=req.amount_in, slippage_bps=req.slippage_bps,
105
+ max_slippage_bps=req.max_slippage_bps, step_bps=config.SLIPPAGE_STEP_BPS)
106
+ if not fr.ok:
107
+ rails.record_failure(conn, fr.error or fr.status or "swap failed")
108
+ return ExecResult(req, False, error=fr.error or fr.status)
109
+ rails.record_success(conn)
110
+ ts = datetime.now(timezone.utc)
111
+ if req.side == "buy":
112
+ cost = -fr.lamports_delta / config.LAMPORTS_PER_SOL
113
+ price = fr.price_sol or (cost / max(fr.token_delta / (10 ** req.decimals), 1e-12))
114
+ pid = ledger.open_position(conn, book="live", mint=req.mint, pool=req.pool, qty_raw=fr.token_delta,
115
+ cost_sol=cost, entry_price=price, decision_id=req.decision_id,
116
+ fees_sol=0.0, ts=ts)
117
+ rails.record_notional(conn, cost, "entry")
118
+ return ExecResult(req, True, fr.fill_id, fr.signature, fr.token_delta, fr.lamports_delta, price, position_id=pid)
119
+ else:
120
+ proceeds = fr.lamports_delta / config.LAMPORTS_PER_SOL
121
+ pos = conn.execute("SELECT * FROM positions WHERE id=%s FOR UPDATE", (req.position_id,)).fetchone()
122
+ price = fr.price_sol if fr.price_sol is not None else (proceeds / max(abs(fr.token_delta) / (10 ** req.decimals), 1e-12))
123
+ remaining = int(pos["qty"]) + fr.token_delta if pos else 0 # token_delta negative
124
+ if pos is None or pos["status"] != "open": # closed elsewhere: the fill row stands, nothing is booked twice
125
+ log.warning("sell of %s filled but position %s is not open; not booked", req.mint, req.position_id)
126
+ realized = None
127
+ elif remaining > max(1, int(pos["qty"]) // 200): # partial fill: keep the remainder open
128
+ realized = ledger.realize_partial(conn, position_id=req.position_id, qty_raw=-fr.token_delta,
129
+ cost_part=float(pos["cost_sol"]) * (1 - remaining / max(int(pos["qty"]), 1)),
130
+ proceeds_sol=proceeds, fees_sol=0.0, price=price, ts=ts)
131
+ else:
132
+ realized = ledger.close_position(conn, position_id=req.position_id, exit_price=price, proceeds_sol=proceeds,
133
+ fees_sol=0.0, decision_id=req.decision_id, forced_kind=req.forced_kind, ts=ts)
134
+ return ExecResult(req, True, fr.fill_id, fr.signature, fr.token_delta, fr.lamports_delta, price,
135
+ position_id=req.position_id, realized_sol=realized)
136
+ except Exception as e:
137
+ log.error("execution error for %s: %s\n%s", req.mint, e, traceback.format_exc())
138
+ try:
139
+ with transaction() as conn:
140
+ rails.record_failure(conn, f"{type(e).__name__}: {e}")
141
+ except Exception:
142
+ pass
143
+ return ExecResult(req, False, error=f"{type(e).__name__}: {e}")
fly_trader/ingest/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
fly_trader/ingest/corpus_pull.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Historical corpus puller (Supervisor thread ``corpus``).
2
+
3
+ Enumerates every pump.fun graduation by paging the migration authority's succeeded transactions through
4
+ Helius ``getTransactionsForAddress`` (newest first, back to ``CORPUS_DAYS``), then, per token and newest
5
+ first, pulls from pump.fun's own swap-api:
6
+
7
+ * candles in SOL: 1-minute for the first ``CORPUS_CANDLE_1M_H`` hours after graduation (plus the last 30
8
+ minutes of the bonding curve), then 5-minute until ``CORPUS_CANDLE_5M_D`` days (2–4 requests per token);
9
+ * tokens are pulled only once they are ``CORPUS_MIN_AGE_H`` old, so the first hours are complete;
10
+ * per-trade rows (wallet, side, SOL, tokens, price) for the first ``CORPUS_TRADES_H`` hours, for a deterministic
11
+ ``CORPUS_TRADES_SAMPLE`` share of the tokens that kept trading at least ``CORPUS_MIN_LIFE_H`` and graduated within
12
+ ``CORPUS_TRADES_DAYS`` (trades cost up to 100 pages per token; candles cost one to three).
13
+
14
+ Registry: ``corpus_tokens``. Data: ``data/corpus/candles/<mint>.parquet`` and ``data/corpus/trades/<mint>.parquet``
15
+ (append-only: a token already on disk is never rewritten). Progress: ``ui_settings['corpus_status']``.
16
+ The swap-api throttles bursts (Cloudflare 1015 after ~20 requests at 1/s), so requests are paced adaptively.
17
+ Measured 2026-09-13: ~36 graduations/hour; candle volume in SOL matches the tape within 0.5 %.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import logging
24
+ import threading
25
+ import time
26
+ from datetime import datetime, timedelta, timezone
27
+ from pathlib import Path
28
+
29
+ import httpx
30
+ import psycopg
31
+ import pyarrow as pa
32
+ import pyarrow.parquet as pq
33
+
34
+ from .. import config
35
+ from ..db.apilog import record_event
36
+ from ..db.connection import transaction
37
+ from ..logging_setup import setup
38
+
39
+ log = logging.getLogger(__name__)
40
+ WSOL = "So11111111111111111111111111111111111111112"
41
+ PUMP_PROGRAM = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"
42
+ SLOTS_PER_S = 2.5
43
+ RETRY_ERROR_H = 6.0 # a token that failed transiently (retries exhausted on 5xx/429, network, disk) is pulled again after this
44
+ CANDLE_SCHEMA = pa.schema([("ts", pa.timestamp("ms", tz="UTC")), ("interval", pa.string()), ("open", pa.float64()),
45
+ ("high", pa.float64()), ("low", pa.float64()), ("close", pa.float64()), ("volume_sol", pa.float64())])
46
+ TRADE_SCHEMA = pa.schema([("ts", pa.timestamp("ms", tz="UTC")), ("slot_index", pa.string()), ("tx", pa.string()), ("wallet", pa.string()),
47
+ ("side", pa.int8()), ("program", pa.string()), ("price_sol", pa.float64()), ("sol", pa.float64()), ("tokens", pa.float64())])
48
+
49
+
50
+ class Throttled(Exception):
51
+ pass
52
+
53
+
54
+ class Permanent(RuntimeError):
55
+ """A 4xx from the swap-api: the token's error row is never requeued."""
56
+
57
+
58
+ class Status:
59
+ """ui_settings['corpus_status'] writer (throttled to one write per 3 s unless forced)."""
60
+
61
+ def __init__(self):
62
+ self.s: dict = {"started_at": datetime.now(timezone.utc).isoformat(), "req_ok": 0, "req_429": 0, "candles_rows": 0, "trades_rows": 0,
63
+ "tokens_pulled": 0, "with_trades": 0}
64
+ self.lock = threading.Lock(); self.last = 0.0; self.t0 = time.time()
65
+
66
+ def update(self, force: bool = False, **kv) -> None:
67
+ with self.lock:
68
+ self.s.update(kv); self.s["updated_at"] = datetime.now(timezone.utc).isoformat()
69
+ hours = (time.time() - self.t0) / 3600
70
+ self.s["tokens_per_h"] = self.s["tokens_pulled"] / hours if hours > 0.01 else 0.0
71
+ if not force and time.time() - self.last < 3.0:
72
+ return
73
+ self.last = time.time(); payload = dict(self.s)
74
+ try:
75
+ with transaction() as conn:
76
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES ('corpus_status', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
77
+ (json.dumps(payload, default=str),))
78
+ except Exception:
79
+ log.debug("corpus status write failed", exc_info=True)
80
+
81
+ def bump(self, key: str, n: int = 1) -> None:
82
+ with self.lock:
83
+ self.s[key] = self.s.get(key, 0) + n
84
+
85
+
86
+ class Pacer:
87
+ """Adaptive spacing between swap-api requests: back off 0.5 s per throttle, creep back after 200 clean calls."""
88
+
89
+ def __init__(self, pace_s: float, floor_s: float):
90
+ self.pace = pace_s; self.floor = floor_s; self.last = 0.0; self.clean = 0
91
+
92
+ def wait(self, stop: threading.Event | None) -> None:
93
+ dt = time.time() - self.last
94
+ if dt < self.pace:
95
+ _sleep(self.pace - dt, stop)
96
+ self.last = time.time()
97
+
98
+ def ok(self) -> None:
99
+ self.clean += 1
100
+ if self.clean >= 200:
101
+ self.clean = 0; self.pace = max(self.floor, self.pace - 0.1)
102
+
103
+ def throttled(self, stop: threading.Event | None, retry_after: float | None) -> None:
104
+ self.clean = 0; self.pace = min(10.0, self.pace + 0.5)
105
+ _sleep(max(60.0, retry_after or 0.0), stop)
106
+
107
+
108
+ def _sleep(seconds: float, stop: threading.Event | None) -> None:
109
+ end = time.time() + seconds
110
+ while time.time() < end:
111
+ if stop is not None and stop.is_set():
112
+ return
113
+ time.sleep(min(1.0, end - time.time()))
114
+
115
+
116
+ def _db_retry(stop: threading.Event | None, what: str, fn, *args):
117
+ """``fn(*args)``, riding out Postgres outages (log, back off 5 s → 60 s, retry); None once ``stop`` is set."""
118
+ delay = 5.0
119
+ while not (stop is not None and stop.is_set()):
120
+ try:
121
+ return fn(*args)
122
+ except psycopg.Error as e:
123
+ log.warning("%s failed (%s: %s); retrying in %.0fs", what, type(e).__name__, str(e)[:160], delay)
124
+ _sleep(delay, stop); delay = min(60.0, delay * 2)
125
+ return None
126
+
127
+
128
+ class SwapApi:
129
+ def __init__(self, pacer: Pacer, status: Status, stop: threading.Event | None):
130
+ self.c = httpx.Client(base_url=config.CORPUS_SWAP_API, headers={"User-Agent": "Mozilla/5.0", "Accept": "application/json"}, timeout=30)
131
+ self.pacer, self.status, self.stop = pacer, status, stop
132
+
133
+ def get(self, path: str, params: dict):
134
+ for attempt in range(6):
135
+ if self.stop is not None and self.stop.is_set():
136
+ raise InterruptedError
137
+ self.pacer.wait(self.stop)
138
+ try:
139
+ r = self.c.get(path, params=params)
140
+ except httpx.HTTPError as e:
141
+ log.warning("swap-api %s: %s", path, e); _sleep(5.0 * (attempt + 1), self.stop); continue
142
+ if r.status_code == 200:
143
+ self.pacer.ok(); self.status.bump("req_ok"); return r.json()
144
+ if r.status_code == 429:
145
+ self.status.bump("req_429"); ra = r.headers.get("retry-after")
146
+ log.info("swap-api throttled; pace -> %.1f s", self.pacer.pace + 0.5)
147
+ self.pacer.throttled(self.stop, float(ra) if ra and ra.isdigit() else None); continue
148
+ if r.status_code >= 500:
149
+ _sleep(5.0 * (attempt + 1), self.stop); continue
150
+ raise (Permanent if 400 <= r.status_code < 500 else RuntimeError)(f"swap-api {path} HTTP {r.status_code}: {r.text[:160]}")
151
+ raise RuntimeError(f"swap-api {path}: gave up after retries")
152
+
153
+ def candles(self, mint: str, interval: str, created_ms: int, before_ms: int | None = None) -> list[dict]:
154
+ p = {"interval": interval, "limit": 1000, "createdTs": created_ms, "currency": "SOL"}
155
+ if before_ms is not None:
156
+ p["beforeTs"] = before_ms
157
+ j = self.get(f"/v2/coins/{mint}/candles", p)
158
+ return j if isinstance(j, list) else (j.get("candles") or [])
159
+
160
+ def trades(self, mint: str, cursor: str | None) -> tuple[list[dict], str | None]:
161
+ p = {"limit": 100}
162
+ if cursor:
163
+ p["cursor"] = cursor
164
+ j = self.get(f"/v2/coins/{mint}/trades", p)
165
+ return j.get("trades") or [], (j.get("pagination") or {}).get("nextCursor")
166
+
167
+
168
+ class Helius:
169
+ def __init__(self, stop: threading.Event | None):
170
+ if not config.HELIUS_API_KEY:
171
+ raise RuntimeError("HELIUS_API_KEY missing")
172
+ self.c = httpx.Client(timeout=120); self.stop = stop; self.last = 0.0
173
+
174
+ def gtfa(self, address: str, opts: dict) -> dict:
175
+ for attempt in range(5):
176
+ dt = time.time() - self.last
177
+ if dt < 0.3:
178
+ time.sleep(0.3 - dt)
179
+ self.last = time.time()
180
+ try:
181
+ r = self.c.post(f"{config.HELIUS_HTTP}?api-key={config.HELIUS_API_KEY}",
182
+ json={"jsonrpc": "2.0", "id": 1, "method": "getTransactionsForAddress", "params": [address, opts]})
183
+ j = r.json()
184
+ except (httpx.HTTPError, ValueError) as e:
185
+ log.warning("helius gTFA: %s", e); _sleep(3.0 * (attempt + 1), self.stop); continue
186
+ if "result" in j and j["result"] is not None:
187
+ return j["result"]
188
+ err = (j.get("error") or {}).get("message", "unknown error")
189
+ if "rate" in err.lower() or r.status_code == 429:
190
+ _sleep(5.0 * (attempt + 1), self.stop); continue
191
+ raise RuntimeError(f"helius gTFA: {err}")
192
+ raise RuntimeError("helius gTFA: gave up after retries")
193
+
194
+
195
+ # ---------------------------------------------------------------- enumeration
196
+ def _graduations_from_page(data: list[dict]) -> list[tuple[str, int, str, int]]:
197
+ """(mint, blockTime, signature, slot) for every succeeded migration transaction on the page."""
198
+ out = []
199
+ for tx in data:
200
+ msg = tx["transaction"]["message"]; keys = msg.get("accountKeys") or []
201
+ progs = set()
202
+ for ins in msg.get("instructions") or []:
203
+ pid = ins.get("programId")
204
+ if pid is None and "programIdIndex" in ins and ins["programIdIndex"] < len(keys):
205
+ k = keys[ins["programIdIndex"]]; pid = k["pubkey"] if isinstance(k, dict) else k
206
+ progs.add(pid)
207
+ if PUMP_PROGRAM not in progs:
208
+ continue
209
+ mints = {b.get("mint") for b in ((tx.get("meta") or {}).get("postTokenBalances") or [])} - {WSOL, None}
210
+ if len(mints) != 1:
211
+ continue
212
+ sig = tx["transaction"]["signatures"][0] if tx["transaction"].get("signatures") else None
213
+ out.append((mints.pop(), int(tx["blockTime"]), sig, int(tx["slot"])))
214
+ return out
215
+
216
+
217
+ def _upsert_graduations(rows: list[tuple[str, int, str, int]]) -> int:
218
+ if not rows:
219
+ return 0
220
+ with transaction() as conn:
221
+ before = conn.execute("SELECT count(*) AS n FROM corpus_tokens").fetchone()["n"]
222
+ conn.cursor().executemany(
223
+ "INSERT INTO corpus_tokens (mint, graduated_at, grad_sig, grad_slot) VALUES (%s, to_timestamp(%s), %s, %s) "
224
+ "ON CONFLICT (mint) DO UPDATE SET graduated_at = LEAST(corpus_tokens.graduated_at, EXCLUDED.graduated_at), "
225
+ "grad_slot = LEAST(corpus_tokens.grad_slot, EXCLUDED.grad_slot), updated_at = now()", rows)
226
+ after = conn.execute("SELECT count(*) AS n FROM corpus_tokens").fetchone()["n"]
227
+ return int(after - before)
228
+
229
+
230
+ def _enum_state() -> dict:
231
+ with transaction() as conn:
232
+ r = conn.execute("SELECT value FROM ui_settings WHERE key = 'corpus_enum'").fetchone()
233
+ v = r["value"] if r else {}
234
+ return v if isinstance(v, dict) else json.loads(v or "{}")
235
+
236
+
237
+ def _save_enum_state(state: dict) -> None:
238
+ with transaction() as conn:
239
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES ('corpus_enum', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
240
+ (json.dumps(state, default=str),))
241
+
242
+
243
+ def enumerate_graduations(hel: Helius, status: Status, stop: threading.Event | None, max_pages: int = 20) -> int:
244
+ """One enumeration round: (1) newest pages until a page adds nothing new, (2) up to ``max_pages`` of backfill
245
+ from the persisted pagination token until ``CORPUS_DAYS`` is reached. Returns rows added."""
246
+ addr = config.CORPUS_MIGRATION_AUTHORITY
247
+ pull_before = datetime.fromisoformat(config.CORPUS_PULL_BEFORE).replace(tzinfo=timezone.utc)
248
+ horizon = int(min(datetime.now(timezone.utc) - timedelta(days=config.CORPUS_DAYS), pull_before - timedelta(days=30)).timestamp())
249
+ state = _enum_state(); added = 0
250
+ # (1) incremental: newest first, stop when a full page adds nothing
251
+ token = None
252
+ for _ in range(500): # keeps walking after an outage until a page adds nothing
253
+ res = hel.gtfa(addr, {"limit": 100, "transactionDetails": "full", "sortOrder": "desc", "filters": {"status": "succeeded"},
254
+ **({"paginationToken": token} if token else {})})
255
+ rows = _graduations_from_page(res.get("data") or []); n = _upsert_graduations(rows); added += n
256
+ token = res.get("paginationToken")
257
+ status.update(stage="enumerating (new graduations)", enum_added=added)
258
+ if n == 0 or not token or (stop is not None and stop.is_set()):
259
+ break
260
+ # (2) backfill toward the horizon from where the last round stopped
261
+ if not state.get("backfill_done"):
262
+ token = state.get("token"); oldest = state.get("oldest_bt")
263
+ if token is None and oldest is None:
264
+ # first round: start a fresh newest-first walk that will proceed past the incremental pages
265
+ token = None
266
+ for _ in range(max_pages):
267
+ if stop is not None and stop.is_set():
268
+ break
269
+ res = hel.gtfa(addr, {"limit": 100, "transactionDetails": "full", "sortOrder": "desc", "filters": {"status": "succeeded"},
270
+ **({"paginationToken": token} if token else {})})
271
+ data = res.get("data") or []
272
+ rows = _graduations_from_page(data); added += _upsert_graduations(rows)
273
+ token = res.get("paginationToken")
274
+ bts = [int(t["blockTime"]) for t in data if t.get("blockTime")]
275
+ if bts:
276
+ oldest = min(bts)
277
+ state.update({"token": token, "oldest_bt": oldest})
278
+ status.update(stage="enumerating (backfill)", backfill_through=datetime.fromtimestamp(oldest, timezone.utc).isoformat() if oldest else None,
279
+ backfill_target=datetime.fromtimestamp(horizon, timezone.utc).date().isoformat(), enum_added=added)
280
+ if not token or (oldest is not None and oldest < horizon):
281
+ state["backfill_done"] = True; break
282
+ _save_enum_state(state)
283
+ status.update(backfill_done=bool(state.get("backfill_done")), force=True)
284
+ return added
285
+
286
+
287
+ # ---------------------------------------------------------------- per-token pull
288
+ def _write_atomic(table: pa.Table, path: Path) -> None:
289
+ tmp = path.with_name(path.name + ".tmp")
290
+ pq.write_table(table, tmp, compression="zstd"); tmp.replace(path)
291
+
292
+
293
+ def _candle_rows(cands: list[dict], interval: str) -> list[dict]:
294
+ return [{"ts": datetime.fromtimestamp(int(c["timestamp"]) / 1000, timezone.utc), "interval": interval, "open": float(c["open"]),
295
+ "high": float(c["high"]), "low": float(c["low"]), "close": float(c["close"]), "volume_sol": float(c["volume"])} for c in cands]
296
+
297
+
298
+ def _page_back(api: SwapApi, mint: str, interval: str, created_ms: int, before_ms: int, until_ms: int, max_pages: int) -> list[dict]:
299
+ """Candles with timestamp in [until_ms, before_ms), paging backwards."""
300
+ out, before = [], before_ms
301
+ for _ in range(max_pages):
302
+ page = api.candles(mint, interval, created_ms, before)
303
+ if not page:
304
+ break
305
+ out.extend(c for c in page if until_ms <= int(c["timestamp"]) < before_ms)
306
+ lo = min(int(c["timestamp"]) for c in page)
307
+ if lo <= until_ms or len(page) < 1000:
308
+ break
309
+ before = lo
310
+ return out
311
+
312
+
313
+ def pull_token(api: SwapApi, row: dict, status: Status) -> dict:
314
+ """Two to four swap-api requests per token: 1-minute candles for the first CORPUS_CANDLE_1M_H hours (plus the last
315
+ 30 curve minutes), then the newest 5-minute page (which also tells how long the token lived), paged back to the
316
+ 1-minute window and capped at CORPUS_CANDLE_5M_D days. Trades (first CORPUS_TRADES_H hours) for a deterministic
317
+ CORPUS_TRADES_SAMPLE share of tokens that lived ≥ CORPUS_MIN_LIFE_H and graduated within CORPUS_TRADES_DAYS."""
318
+ mint: str = row["mint"]; grad: datetime = row["graduated_at"]
319
+ g_ms = int(grad.timestamp() * 1000); created_ms = g_ms - 86_400_000
320
+ cdir = config.CORPUS_DIR / "candles"; tdir = config.CORPUS_DIR / "trades"
321
+ cpath, tpath = cdir / f"{mint}.parquet", tdir / f"{mint}.parquet"
322
+ upd: dict = {"status": "done", "candles_1m": 0, "candles_5m": 0, "trades": 0, "trades_through": None, "candle_path": None, "trade_path": None}
323
+ start_ms = g_ms - 30 * 60_000; win1_end = g_ms + int(config.CORPUS_CANDLE_1M_H * 3.6e6); win2_end = g_ms + int(config.CORPUS_CANDLE_5M_D * 86_400_000)
324
+ one_m = _page_back(api, mint, "1m", created_ms, win1_end, start_ms, max_pages=3)
325
+ newest5 = api.candles(mint, "5m", created_ms)
326
+ stamps = [int(c["timestamp"]) for c in one_m] + [int(c["timestamp"]) for c in newest5]
327
+ if not stamps:
328
+ upd["status"] = "empty"; return upd
329
+ last_ms = max(stamps); life_h = (last_ms - g_ms) / 3.6e6; upd["life_h"] = life_h
330
+ five_m = [c for c in newest5 if win1_end <= int(c["timestamp"]) < win2_end]
331
+ if newest5 and len(newest5) >= 1000 and min(int(c["timestamp"]) for c in newest5) > win1_end:
332
+ lo5 = min(int(c["timestamp"]) for c in newest5)
333
+ pages = min(20, int((min(lo5, win2_end) - win1_end) / (1000 * 300_000)) + 2) # enough pages to reach the 1-minute window's end
334
+ five_m += _page_back(api, mint, "5m", created_ms, min(lo5, win2_end), win1_end, max_pages=pages)
335
+ five_m = [c for c in five_m if int(c["timestamp"]) < win2_end]
336
+ rows = _candle_rows(one_m, "1m") + _candle_rows(five_m, "5m"); upd["candles_1m"] = len(one_m); upd["candles_5m"] = len(five_m)
337
+ if rows and not cpath.exists():
338
+ _write_atomic(pa.Table.from_pylist(rows, schema=CANDLE_SCHEMA), cpath)
339
+ upd["candle_path"] = str(cpath) if cpath.exists() else None
340
+ status.bump("candles_rows", len(rows))
341
+ recent = grad >= datetime.now(timezone.utc) - timedelta(days=config.CORPUS_TRADES_DAYS)
342
+ sampled = (int.from_bytes(hashlib.sha1(mint.encode()).digest()[:4], "big") / 2**32) < config.CORPUS_TRADES_SAMPLE
343
+ if life_h >= config.CORPUS_MIN_LIFE_H and recent and sampled and not tpath.exists():
344
+ end_ms = min(g_ms + int(config.CORPUS_TRADES_H * 3.6e6), last_ms + 60_000)
345
+ slot_t = int(row["grad_slot"] or 0) + int((end_ms - g_ms) / 1000 * SLOTS_PER_S)
346
+ cursor: str | None = f"{slot_t:010d}000000000000-{end_ms}"
347
+ trows: list[dict] = []; pages = 0; oldest_seen = end_ms
348
+ while cursor and pages < config.CORPUS_TRADES_MAX_PAGES:
349
+ trades, cursor = api.trades(mint, cursor); pages += 1
350
+ if not trades:
351
+ break
352
+ for t in trades:
353
+ ts = datetime.fromisoformat(t["timestamp"].replace("Z", "+00:00")); ms = int(ts.timestamp() * 1000)
354
+ oldest_seen = min(oldest_seen, ms)
355
+ if ms < start_ms:
356
+ continue
357
+ trows.append({"ts": ts, "slot_index": t.get("slotIndexId"), "tx": t.get("tx"), "wallet": t.get("userAddress"),
358
+ "side": 1 if t.get("type") == "buy" else -1, "program": t.get("program"),
359
+ "price_sol": float(t.get("priceSol") or 0.0), "sol": float(t.get("amountSol") or 0.0), "tokens": float(t.get("baseAmount") or 0.0)})
360
+ if oldest_seen < start_ms:
361
+ break
362
+ if trows:
363
+ _write_atomic(pa.Table.from_pylist(trows, schema=TRADE_SCHEMA), tpath)
364
+ upd.update(trades=len(trows), trades_through=datetime.fromtimestamp(end_ms / 1000, timezone.utc), trade_path=str(tpath))
365
+ status.bump("trades_rows", len(trows)); status.bump("with_trades")
366
+ return upd
367
+
368
+
369
+ def _next_pending(limit: int = 50) -> list[dict]:
370
+ """Pending tokens plus transient errors (last_error not 'permanent: …') that have cooled down RETRY_ERROR_H."""
371
+ with transaction() as conn:
372
+ return conn.execute("SELECT mint, graduated_at, grad_slot FROM corpus_tokens WHERE (status = 'pending' OR (status = 'error' "
373
+ "AND updated_at < now() - %s * interval '1 hour' AND COALESCE(last_error, '') NOT LIKE 'permanent:%%')) "
374
+ "AND graduated_at < now() - %s * interval '1 hour' AND graduated_at < %s::timestamptz ORDER BY graduated_at DESC LIMIT %s",
375
+ (RETRY_ERROR_H, config.CORPUS_MIN_AGE_H, config.CORPUS_PULL_BEFORE, limit)).fetchall()
376
+
377
+
378
+ def _mark(mint: str, upd: dict) -> None:
379
+ with transaction() as conn:
380
+ conn.execute("UPDATE corpus_tokens SET status = %s, life_h = %s, candles_1m = %s, candles_5m = %s, trades = %s, trades_through = %s, "
381
+ "candle_path = %s, trade_path = %s, last_error = %s, updated_at = now() WHERE mint = %s",
382
+ (upd.get("status", "done"), upd.get("life_h"), upd.get("candles_1m"), upd.get("candles_5m"), upd.get("trades"), upd.get("trades_through"),
383
+ upd.get("candle_path"), upd.get("trade_path"), upd.get("last_error"), mint))
384
+
385
+
386
+ def counts() -> dict:
387
+ with transaction() as conn:
388
+ rows = conn.execute("SELECT status, count(*) AS n FROM corpus_tokens GROUP BY status").fetchall()
389
+ return {r["status"]: int(r["n"]) for r in rows}
390
+
391
+
392
+ # ---------------------------------------------------------------- worker entry
393
+ def main(stop_event: threading.Event | None = None) -> None:
394
+ setup("corpus")
395
+ status = Status(); stop = stop_event
396
+ (config.CORPUS_DIR / "candles").mkdir(parents=True, exist_ok=True); (config.CORPUS_DIR / "trades").mkdir(parents=True, exist_ok=True)
397
+ pacer = Pacer(config.CORPUS_PACE_S, config.CORPUS_PACE_MIN_S); api = SwapApi(pacer, status, stop); hel = Helius(stop)
398
+ record_event("info", "corpus", "corpus puller started", {"days": config.CORPUS_DAYS, "trades_h": config.CORPUS_TRADES_H, "pace_s": pacer.pace})
399
+ log.info("corpus puller started (days=%d)", config.CORPUS_DAYS)
400
+ from ..train import corpus_features
401
+ builder = threading.Thread(target=corpus_features.build_loop, args=(stop,), name="corpus-features", daemon=True); builder.start()
402
+ try:
403
+ while not (stop is not None and stop.is_set()):
404
+ try:
405
+ added = enumerate_graduations(hel, status, stop)
406
+ if added:
407
+ log.info("enumerated %d new graduations", added)
408
+ except InterruptedError:
409
+ break
410
+ except Exception as e:
411
+ log.exception("enumeration failed"); record_event("error", "corpus", f"enumeration failed: {type(e).__name__}: {e}")
412
+ status.update(stage="enumeration error", last_error=str(e)[:200], force=True); _sleep(30, stop)
413
+ pending = _db_retry(stop, "corpus pending query", _next_pending)
414
+ if pending is None:
415
+ break
416
+ c = _db_retry(stop, "corpus counts", counts) or {}; status.update(stage="pulling" if pending else "idle", pending=c.get("pending", 0), done=c.get("done", 0), empty=c.get("empty", 0),
417
+ errors=c.get("error", 0), enumerated=sum(c.values()), pace_s=round(pacer.pace, 2), force=True)
418
+ if not pending:
419
+ _sleep(60, stop); continue
420
+ for row in pending:
421
+ if stop is not None and stop.is_set():
422
+ break
423
+ try:
424
+ upd = pull_token(api, row, status)
425
+ except InterruptedError:
426
+ break
427
+ except Exception as e: # a 4xx is permanent; anything else is requeued by _next_pending after RETRY_ERROR_H
428
+ perm = "permanent: " if isinstance(e, Permanent) else ""
429
+ log.warning("pull %s failed: %s%s", row["mint"], perm, e); upd = {"status": "error", "last_error": f"{perm}{type(e).__name__}: {e}"[:300]}
430
+ _db_retry(stop, "corpus mark", _mark, row["mint"], upd); status.bump("tokens_pulled")
431
+ c = _db_retry(stop, "corpus counts", counts) or {}
432
+ status.update(stage="pulling", last_mint=row["mint"], last_graduated=row["graduated_at"].isoformat(), pending=c.get("pending", 0), done=c.get("done", 0),
433
+ empty=c.get("empty", 0), errors=c.get("error", 0), enumerated=sum(c.values()), pace_s=round(pacer.pace, 2),
434
+ eta_h=(c.get("pending", 0) / status.s["tokens_per_h"]) if status.s.get("tokens_per_h") else None)
435
+ finally:
436
+ builder.join(30.0)
437
+ status.update(stage="stopped", force=True)
438
+ record_event("info", "corpus", "corpus puller stopped", {k: status.s.get(k) for k in ("tokens_pulled", "candles_rows", "trades_rows", "req_ok", "req_429")})
439
+ log.info("corpus puller stopped")
fly_trader/ingest/discovery.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Jupiter token stats (the `discover` worker).
2
+
3
+ Every STATS_REFRESH_S, token_stats via /search batches (100 mints per call) for every token of the selector's universe
4
+ (traded on the stream in the last hour, pool above its gate). Jupiter serves only current values, so this is how a
5
+ point-in-time history accrues for future model inputs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import signal
12
+ import time
13
+
14
+ from .. import config
15
+ from ..chain.jupiter_tokens import JupiterTokens
16
+ from ..db.apilog import record_event
17
+ from ..db.connection import transaction
18
+ from ..logging_setup import setup
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ def insert_stats(conn, tok: dict) -> None:
24
+ audit = tok.get("audit") or {}
25
+ stats = {k: tok.get(k) for k in ("stats5m", "stats1h", "stats6h", "stats24h")}
26
+ conn.execute(
27
+ """INSERT INTO token_stats (mint, organic_score, organic_label, holder_count, liquidity_usd, usd_price, mcap,
28
+ top_holders_pct, dev_balance_pct, dev_mints, is_sus, mint_auth_disabled, freeze_auth_disabled, is_verified, stats)
29
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
30
+ (tok["id"], tok.get("organicScore"), tok.get("organicScoreLabel"), tok.get("holderCount"), tok.get("liquidity"),
31
+ tok.get("usdPrice"), tok.get("mcap"), audit.get("topHoldersPercentage"), audit.get("devBalancePercentage"),
32
+ audit.get("devMints"), audit.get("isSus"), audit.get("mintAuthorityDisabled"), audit.get("freezeAuthorityDisabled"),
33
+ tok.get("isVerified"), json.dumps(stats, default=str)),
34
+ )
35
+
36
+
37
+ def refresh_stats(conn, client: JupiterTokens) -> dict:
38
+ """token_stats for the selector's universe: every pump.fun PumpSwap token the stream saw trade in the last hour with a
39
+ pool above the selector's gate, so a point-in-time history accrues for the tokens actually traded."""
40
+ from ..train.decisions import MIN_RESQ_SOL # lazy: keeps the training stack out of the worker's import
41
+ uni = conn.execute("SELECT DISTINCT mint FROM pump_minutes WHERE ts > now() - interval '1 hour' AND resq_sol >= %s",
42
+ (MIN_RESQ_SOL,)).fetchall()
43
+ mints = sorted(r["mint"] for r in uni)
44
+ toks = [t for t in client.search_many(mints) if t.get("id")] if mints else []
45
+ for tok in toks:
46
+ insert_stats(conn, tok)
47
+ return {"universe": len(mints), "refreshed": len(toks)}
48
+
49
+
50
+ def run_once() -> None:
51
+ setup("discover")
52
+ with transaction() as conn:
53
+ print(json.dumps(refresh_stats(conn, JupiterTokens()), indent=1))
54
+
55
+
56
+ def run_forever(stop_event=None) -> None:
57
+ """Loop until stop_event is set (console thread) or SIGTERM/SIGINT (CLI, main thread only)."""
58
+ import threading
59
+ setup("discover")
60
+ client = JupiterTokens(); stopped = {"v": False}
61
+ if stop_event is None and threading.current_thread() is threading.main_thread():
62
+ def _stop(*_):
63
+ stopped["v"] = True
64
+ signal.signal(signal.SIGTERM, _stop)
65
+ signal.signal(signal.SIGINT, _stop)
66
+ record_event("info", "discover", "token stats worker started", {"refresh_s": config.STATS_REFRESH_S})
67
+ while not stopped["v"] and not (stop_event is not None and stop_event.is_set()):
68
+ t0 = time.monotonic()
69
+ try:
70
+ with transaction() as conn:
71
+ log.info("stats refresh %s", refresh_stats(conn, client))
72
+ except Exception as e:
73
+ log.exception("token stats refresh error")
74
+ record_event("error", "discover", f"stats refresh error: {type(e).__name__}: {e}")
75
+ # sleep the remainder of the interval, waking early on stop
76
+ remaining = config.STATS_REFRESH_S - (time.monotonic() - t0)
77
+ while remaining > 0 and not stopped["v"] and not (stop_event is not None and stop_event.is_set()):
78
+ time.sleep(min(1.0, remaining))
79
+ remaining -= 1.0
80
+ record_event("info", "discover", "token stats worker stopped")
fly_trader/ingest/pumpstream.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PumpAPI live stream → per-minute rich candles (Supervisor thread ``pumpstream``).
2
+
3
+ ``wss://stream.pumpapi.io`` is a free, keyless firehose (~400–800 events/s, one connection per IP) whose events are
4
+ identical to the replay archive. This worker keeps only what the selector needs: PumpSwap (``pump-amm``, SOL-quoted)
5
+ buys and sells of pump.fun-origin mints, aggregated per (mint, minute) into the same fields ``train/mature.py``
6
+ derives from the archive, with the same filters: legs in a pool created directly rather than by a pump.fun migration
7
+ (``createPool`` with ``poolCreatedBy`` other than ``pump``; the owner can pull its unburned liquidity) are dropped — such pools
8
+ go to ``pump_pools`` as they are created, and the blocked set is reloaded from that table (archive backfill included) at
9
+ start and every 5 minutes —, the pool's quote reserve must lie in 0.001–100,000 SOL, a leg more
10
+ than 50× away from the median of the mint's previous 200 legs that UTC day is dropped, and each minute keeps the
11
+ pool with the most legs. Minutes are written to ``pump_minutes`` once complete: an event stamped ≥ 2 s after the
12
+ minute's end has arrived (event-time watermark) or, once no trade has arrived for 15 s, by wall clock; a leg for a minute
13
+ already written is dropped (never merged into it). ``pumpstream_status.flushed_through``
14
+ publishes the newest complete minute for the selector.
15
+
16
+ Lifecycle: ``create`` events go to ``pump_events`` as they arrive; at ``migrate`` a ``corpus_meta`` row is written
17
+ with the creation facts and the creator's point-in-time history (``train/corpus_meta.creator_history``, the training
18
+ definition); 61 minutes after a graduation its own 60-minute outcome is filled from the stream's minutes so later
19
+ graduations by the same creator see it, as in training. Minute rows older than ``PUMP_MINUTES_KEEP_DAYS`` are archived
20
+ to ``data/corpus/stream/<day>.parquet`` before removal. Database failures keep the data in memory for the next attempt.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ import logging
27
+ import math
28
+ import ssl
29
+ import statistics
30
+ import threading
31
+ import time
32
+ from collections import defaultdict, deque
33
+ from datetime import datetime, timedelta, timezone
34
+
35
+ import certifi
36
+ import orjson
37
+ import pyarrow as pa
38
+ import pyarrow.parquet as pq
39
+ import websockets
40
+
41
+ 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__)
49
+ WSOL = "So11111111111111111111111111111111111111112"
50
+ RESQ_BAND = (0.001, 100000.0) # train/mature.py: quote_in_pool BETWEEN 0.001 AND 100000
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_FALLBACK_S = 15.0
55
+ MAX_PENDING_CREATES = 200_000
56
+
57
+
58
+ def _txt(v):
59
+ """Postgres text cannot hold NUL bytes; one in a token name would otherwise wedge the create queue."""
60
+ return v.replace("\x00", "") if isinstance(v, str) else v
61
+
62
+
63
+ class Minute:
64
+ __slots__ = ("open", "high", "low", "close", "buy", "sell", "nb", "ns", "traders", "wallets", "resq", "pool_id", "fee_rate")
65
+
66
+ def __init__(self, pool_id: str | None = None):
67
+ self.open = None; self.high = -math.inf; self.low = math.inf; self.close = None; self.buy = 0.0; self.sell = 0.0
68
+ self.nb = 0; self.ns = 0; self.traders = set(); self.resq = None; self.pool_id = pool_id; self.fee_rate = None # the pool fee charged (poolFeeRate)
69
+ self.wallets: dict[str, list[float]] = {} # trader → [bought SOL, sold SOL] (train/flow.py)
70
+
71
+
72
+ class Aggregator:
73
+ def __init__(self):
74
+ self.minutes: dict[int, dict[str, dict[str, Minute]]] = defaultdict(lambda: defaultdict(dict)) # minute → mint → pool → Minute
75
+ self.ref: dict[str, deque] = {}; self.ref_day: int | None = None # trailing legs per mint, reset each UTC day
76
+ self.max_event_s = 0.0; self.last_event_wall = 0.0 # newest event time; wall clock of the last trade received
77
+ self.creates: dict[str, dict] = {} # mint → create facts (24 h)
78
+ self.curve: dict[str, dict] = {} # mint → bonding-curve wallets since its create (corpus_meta.curve_facts)
79
+ self.pending_creates: list[tuple[str, dict]] = [] # creates not yet written to pump_events
80
+ self.blocked: set[str] = set() # pool ids of directly created (custom) PumpSwap pools
81
+ self.pending_pools: list[tuple] = [] # custom pools not yet written to pump_pools
82
+ self.flushed_through: datetime | None = None # start of the newest complete minute written
83
+ self.insiders: dict[str, frozenset] = {} # mint → creator/bundle/early wallets (token_insiders)
84
+ self.skill: dict | None = None; self.skill_day: str | None = None # wallet → skill decile for the current UTC day (train/wallet_skill.py)
85
+ 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,
86
+ "custom_pools": 0, "blocked_pools": 0, "reconnects": 0, "outcomes_filled": 0, "started_at": datetime.now(timezone.utc).isoformat()}
87
+
88
+ def row(self, minute: int, mint: str) -> Minute | None:
89
+ """The minute's candle for a mint: the pool with the most legs (as in training)."""
90
+ pools = self.minutes.get(minute, {}).get(mint)
91
+ return max(pools.values(), key=lambda r: (r.nb + r.ns, r.buy + r.sell)) if pools else None
92
+
93
+ def ingest(self, e: dict) -> None:
94
+ self.stats["events"] += 1
95
+ a = e.get("action"); pool = e.get("pool"); mint = e.get("mint"); ts = e.get("timestamp")
96
+ if a == "createPool" and pool == "pump-amm" and (e.get("poolCreatedBy") or "custom") != "pump":
97
+ pid = e.get("poolId") # not a pump.fun migration: the owner can pull the liquidity unseen
98
+ if pid:
99
+ self.block([pid]); self.stats["custom_pools"] += 1
100
+ self.pending_pools.append((pid, mint, e.get("poolCreatedBy"), datetime.fromtimestamp(ts / 1000, timezone.utc) if ts is not None else None))
101
+ return
102
+ if ts is None or not mint:
103
+ return
104
+ if a in ("buy", "sell") and pool == "pump":
105
+ self._curve(e)
106
+ return
107
+ if a in ("buy", "sell"):
108
+ if pool != "pump-amm" or e.get("quoteMint") != WSOL or not str(mint).endswith("pump"):
109
+ return
110
+ if e.get("poolId") in self.blocked:
111
+ self.stats["dropped_custom_pool"] += 1
112
+ return
113
+ price = e.get("price"); q = e.get("quoteInPool")
114
+ if not price or price <= 0 or q is None or not (RESQ_BAND[0] <= float(q) <= RESQ_BAND[1]):
115
+ return
116
+ ts_s = ts / 1000.0; self.max_event_s = max(self.max_event_s, ts_s); self.last_event_wall = time.time()
117
+ day = int(ts_s // 86400)
118
+ if self.ref_day is None or day > self.ref_day: # a late event from the previous day never wipes today's references
119
+ self.ref = {}; self.ref_day = day
120
+ p = float(price)
121
+ legs = e.get("breakdown") or [{"action": a, "trader": e.get("txSigner"), "quoteAmount": e.get("quoteAmount")}]
122
+ dq = self.ref.get(mint)
123
+ if dq is None:
124
+ dq = self.ref[mint] = deque(maxlen=REF_LEGS)
125
+ ok = True
126
+ if dq:
127
+ pref = statistics.median(dq)
128
+ ok = pref / PRICE_BAND <= p <= pref * PRICE_BAND
129
+ for _ in legs:
130
+ dq.append(p)
131
+ if not ok:
132
+ self.stats["dropped_band"] += 1
133
+ return
134
+ m = int(ts_s // 60) * 60; pid = e.get("poolId") or ""
135
+ if self.flushed_through is not None and m <= self.flushed_through.timestamp():
136
+ self.stats["dropped_late"] += 1 # its minute is written and consumed; a partial re-write would corrupt it
137
+ return
138
+ row = self.minutes[m][mint].get(pid)
139
+ if row is None:
140
+ row = self.minutes[m][mint][pid] = Minute(pid or None)
141
+ row.open = p if row.open is None else row.open; row.high = max(row.high, p); row.low = min(row.low, p); row.close = p
142
+ for b in legs:
143
+ sol = float(b.get("quoteAmount") or 0.0)
144
+ if b.get("action") == "buy":
145
+ row.buy += sol; row.nb += 1
146
+ else:
147
+ row.sell += sol; row.ns += 1
148
+ trader = b.get("trader") or e.get("txSigner") # as replay_pull writes the archive's trader column
149
+ if trader:
150
+ row.traders.add(trader)
151
+ row.wallets.setdefault(trader, [0.0, 0.0])[0 if b.get("action") == "buy" else 1] += sol
152
+ row.resq = float(q)
153
+ if e.get("poolFeeRate") is not None:
154
+ row.fee_rate = float(e["poolFeeRate"])
155
+ self.stats["trades"] += 1
156
+ elif a == "create" and pool == "pump":
157
+ c = {"ts": ts, "creator": e.get("txSigner"), "dev_sol": e.get("quoteAmount"), "dev_tokens": e.get("initialBuy"), "supply": e.get("supply"),
158
+ "mayhem": e.get("mayhemMode"), "name": _txt(e.get("name")), "symbol": _txt(e.get("symbol")), "uri": _txt(e.get("uri")), "sig": e.get("signature")}
159
+ self.creates[mint] = c; self.pending_creates.append((mint, c))
160
+ self.curve[mint] = {"slot": int(e.get("block") or 0), "creator": e.get("txSigner"), "supply": e.get("supply"), "initial_buy": e.get("initialBuy"), "ts": ts, "wallets": {}}
161
+ self.stats["creates"] += 1
162
+ if len(self.creates) > 200_000:
163
+ cutoff = ts - 86_400_000
164
+ self.creates = {k: v for k, v in self.creates.items() if v["ts"] >= cutoff}
165
+ self.curve = {k: v for k, v in self.curve.items() if v["ts"] >= cutoff}
166
+ elif a == "migrate" and pool == "pump-amm":
167
+ self.stats["migrates"] += 1
168
+ self.write_creates() # the creator's own earlier creates must be visible to the history query
169
+ self._graduation(e)
170
+
171
+ def _curve(self, e: dict) -> None:
172
+ """A bonding-curve buy/sell of a token created while the stream ran: per wallet its first buy slot and tokens bought/sold."""
173
+ st = self.curve.get(e.get("mint"))
174
+ if st is None:
175
+ return # created before this process started: the archive fills its facts (fail closed until then)
176
+ slot = int(e.get("block") or 0)
177
+ for b in e.get("breakdown") or [{"action": e.get("action"), "trader": e.get("txSigner"), "tokenAmount": e.get("tokenAmount")}]:
178
+ w = b.get("trader") or e.get("txSigner")
179
+ if not w:
180
+ continue
181
+ rec = st["wallets"].setdefault(w, [None, 0.0, 0.0]); tok = float(b.get("tokenAmount") or 0.0)
182
+ if (b.get("action") or e.get("action")) == "buy":
183
+ rec[1] += tok; rec[0] = slot if rec[0] is None else min(rec[0], slot)
184
+ else:
185
+ rec[2] += tok
186
+
187
+ def write_creates(self) -> int:
188
+ """Persist creates as they arrive (keyed by signature); on failure they stay queued for the next attempt."""
189
+ rows, self.pending_creates = self.pending_creates, []
190
+ if not rows:
191
+ return 0
192
+ try:
193
+ with transaction() as conn:
194
+ conn.cursor().executemany("INSERT INTO pump_events (sig, ts, action, pool, mint, signer, dev_sol, dev_tokens, supply, mayhem, name, symbol, uri) "
195
+ "VALUES (%s,%s,'create','pump',%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (sig) DO NOTHING",
196
+ [(c["sig"], datetime.fromtimestamp(c["ts"] / 1000, timezone.utc), m, c["creator"], c["dev_sol"], c["dev_tokens"], c["supply"],
197
+ c["mayhem"], c["name"], c["symbol"], c["uri"]) for m, c in rows if c.get("sig")])
198
+ except Exception:
199
+ self.pending_creates = (rows + self.pending_creates)[-MAX_PENDING_CREATES:]
200
+ log.exception("create rows failed; %d queued for the next attempt", len(self.pending_creates))
201
+ return 0
202
+ return len(rows)
203
+
204
+ def block(self, pool_ids) -> None:
205
+ self.blocked.update(pool_ids); self.stats["blocked_pools"] = len(self.blocked)
206
+
207
+ def write_pools(self) -> int:
208
+ """Persist custom pools seen on the stream (training excludes the same pools); on failure they stay queued."""
209
+ rows, self.pending_pools = self.pending_pools, []
210
+ if not rows:
211
+ return 0
212
+ try:
213
+ with transaction() as conn:
214
+ conn.cursor().executemany("INSERT INTO pump_pools (pool_id, mint, created_by, ts, source) VALUES (%s,%s,%s,%s,'stream') ON CONFLICT (pool_id) DO NOTHING", rows)
215
+ except Exception:
216
+ self.pending_pools = (rows + self.pending_pools)[-MAX_PENDING_CREATES:]
217
+ log.exception("custom pool rows failed; %d queued for the next attempt", len(self.pending_pools))
218
+ return 0
219
+ return len(rows)
220
+
221
+ def _graduation(self, e: dict) -> None:
222
+ mint = e["mint"]; g = datetime.fromtimestamp(e["timestamp"] / 1000, timezone.utc); c = self.creates.get(mint)
223
+ try:
224
+ with transaction() as conn:
225
+ if c is None: # created before this process started: look it up
226
+ r = conn.execute("SELECT sig, ts, signer, dev_sol, dev_tokens, supply, mayhem, name, symbol, uri FROM pump_events WHERE mint = %s AND action = 'create' ORDER BY ts LIMIT 1", (mint,)).fetchone()
227
+ if r:
228
+ c = {"ts": r["ts"].timestamp() * 1000, "creator": r["signer"], "dev_sol": r["dev_sol"], "dev_tokens": r["dev_tokens"], "supply": r["supply"],
229
+ "mayhem": r["mayhem"], "name": r["name"], "symbol": r["symbol"], "uri": r["uri"], "sig": r["sig"]}
230
+ conn.execute("INSERT INTO pump_events (sig, ts, action, pool, mint, pool_id, signer, quote_in_pool) VALUES (%s,%s,'migrate','pump-amm',%s,%s,%s,%s) ON CONFLICT (sig) DO NOTHING",
231
+ (e.get("signature"), g, mint, e.get("poolId"), e.get("txSigner"), e.get("quoteInPool")))
232
+ ct = datetime.fromtimestamp(c["ts"] / 1000, timezone.utc) if c else None
233
+ h = creator_history(conn, c.get("creator") if c else None, ct, g)
234
+ 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,
235
+ prior_launches, prior_grads, prior_known, prior_rug_share, prior_moon_share)
236
+ 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
237
+ graduated_at = COALESCE(corpus_meta.graduated_at, EXCLUDED.graduated_at),
238
+ create_ts = COALESCE(corpus_meta.create_ts, EXCLUDED.create_ts), creator = COALESCE(corpus_meta.creator, EXCLUDED.creator),
239
+ dev_sol = COALESCE(corpus_meta.dev_sol, EXCLUDED.dev_sol), dev_tokens = COALESCE(corpus_meta.dev_tokens, EXCLUDED.dev_tokens),
240
+ dev_share = COALESCE(corpus_meta.dev_share, EXCLUDED.dev_share), supply = COALESCE(corpus_meta.supply, EXCLUDED.supply),
241
+ mayhem = COALESCE(corpus_meta.mayhem, EXCLUDED.mayhem), uri = COALESCE(corpus_meta.uri, EXCLUDED.uri),
242
+ name = COALESCE(corpus_meta.name, EXCLUDED.name), symbol = COALESCE(corpus_meta.symbol, EXCLUDED.symbol),
243
+ 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),
244
+ prior_launches = COALESCE(corpus_meta.prior_launches, EXCLUDED.prior_launches), prior_grads = COALESCE(corpus_meta.prior_grads, EXCLUDED.prior_grads),
245
+ prior_known = COALESCE(corpus_meta.prior_known, EXCLUDED.prior_known), prior_rug_share = COALESCE(corpus_meta.prior_rug_share, EXCLUDED.prior_rug_share),
246
+ prior_moon_share = COALESCE(corpus_meta.prior_moon_share, EXCLUDED.prior_moon_share), updated_at = now()""",
247
+ (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,
248
+ (float(c["dev_tokens"]) / float(c["supply"])) if c and c.get("dev_tokens") and c.get("supply") else None,
249
+ 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,
250
+ ((e["timestamp"] - c["ts"]) / 60000.0) if c else None, e.get("quoteInPool"), e.get("poolId"),
251
+ h["prior_launches"], h["prior_grads"], h["prior_known"], h["prior_rug_share"], h["prior_moon_share"]))
252
+ conn.execute("INSERT INTO corpus_tokens (mint, graduated_at, grad_slot, source, status) VALUES (%s,%s,%s,'stream','pending') ON CONFLICT (mint) DO NOTHING",
253
+ (mint, g, int(e.get("block") or 0)))
254
+ st = self.curve.pop(mint, None)
255
+ if st is not None: # the token's whole curve life was seen: the archive's definition (curve_facts)
256
+ facts, insiders = curve_facts(st["slot"], st["creator"], st["supply"], st["initial_buy"], st["wallets"])
257
+ conn.execute("UPDATE corpus_meta SET " + ", ".join(f"{c} = COALESCE({c}, %s)" for c in CURVE_COLS) + ", curve_known = COALESCE(curve_known, true) WHERE mint = %s",
258
+ (*[facts[c] for c in CURVE_COLS], mint))
259
+ conn.cursor().executemany("INSERT INTO token_insiders (mint, wallet, kind) VALUES (%s,%s,%s) ON CONFLICT (mint, wallet) DO NOTHING",
260
+ [(mint, w, k) for w, k in insiders])
261
+ self.insiders[mint] = frozenset(w for w, _ in insiders)
262
+ except Exception:
263
+ log.exception("graduation upsert failed for %s", mint)
264
+
265
+ def flush(self, before_minute: int) -> int:
266
+ """Write every minute strictly older than ``before_minute`` (seconds). Rows leave memory only after the commit."""
267
+ done = sorted(m for m in self.minutes if m < before_minute)
268
+ if not done:
269
+ return 0
270
+ rows = []
271
+ for m in done:
272
+ ts = datetime.fromtimestamp(m, timezone.utc)
273
+ for mint in self.minutes[m]:
274
+ r = self.row(m, mint)
275
+ if r is None or r.close is None:
276
+ continue
277
+ wc = minute_wallet_cols(r.wallets, self.insiders.get(mint, frozenset()), self.skill)
278
+ rows.append((mint, ts, r.pool_id, r.open, r.high, r.low, r.close, r.buy, r.sell, r.nb, r.ns, len(r.traders), r.resq, r.fee_rate,
279
+ wc["n_buyers"], wc["wash_sol"], wc["wash_buy_sol"], wc["top_sell_sol"], wc["insider_sell_sol"], wc["skill_buy"]))
280
+ if rows:
281
+ with transaction() as conn:
282
+ conn.cursor().executemany("INSERT INTO pump_minutes (mint, ts, pool_id, open, high, low, close, buy_sol, sell_sol, n_buys, n_sells, n_traders, resq_sol, fee_rate, "
283
+ "n_buyers, wash_sol, wash_buy_sol, top_sell_sol, insider_sell_sol, skill_buy) "
284
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (mint, ts) DO UPDATE SET close = EXCLUDED.close, high = greatest(pump_minutes.high, EXCLUDED.high), "
285
+ "low = least(pump_minutes.low, EXCLUDED.low), buy_sol = pump_minutes.buy_sol + EXCLUDED.buy_sol, sell_sol = pump_minutes.sell_sol + EXCLUDED.sell_sol, "
286
+ "n_buys = pump_minutes.n_buys + EXCLUDED.n_buys, n_sells = pump_minutes.n_sells + EXCLUDED.n_sells, n_traders = greatest(pump_minutes.n_traders, EXCLUDED.n_traders), "
287
+ "resq_sol = EXCLUDED.resq_sol, fee_rate = COALESCE(EXCLUDED.fee_rate, pump_minutes.fee_rate), n_buyers = EXCLUDED.n_buyers, wash_sol = EXCLUDED.wash_sol, "
288
+ "wash_buy_sol = EXCLUDED.wash_buy_sol, top_sell_sol = EXCLUDED.top_sell_sol, insider_sell_sol = EXCLUDED.insider_sell_sol, skill_buy = EXCLUDED.skill_buy", rows)
289
+ for m in done:
290
+ self.minutes.pop(m, None)
291
+ self.stats["flushed_minutes"] += len(done); self.stats["flushed_rows"] += len(rows)
292
+ newest = datetime.fromtimestamp(done[-1], timezone.utc)
293
+ if self.flushed_through is None or newest > self.flushed_through: # a late event for an old minute never rewinds it
294
+ self.flushed_through = newest
295
+ return len(rows)
296
+
297
+ def flush_bound(self, now: float) -> int:
298
+ """Minutes strictly before this start are complete: event-time watermark, or the wall-clock fallback once no trade
299
+ has arrived for ``FLUSH_FALLBACK_S`` (a lagging stream still delivers the minute's events, so it waits for them)."""
300
+ cur = int(now // 60) * 60
301
+ wm = int((self.max_event_s - FLUSH_GRACE_S) // 60) * 60
302
+ if now - self.last_event_wall >= FLUSH_FALLBACK_S:
303
+ wm = max(wm, int((now - FLUSH_FALLBACK_S) // 60) * 60)
304
+ return min(cur, wm)
305
+
306
+
307
+ def fill_outcomes() -> int:
308
+ """Own 60-minute outcome for stream graduations 61+ minutes old, from the stream's minutes (candle files replace it later)."""
309
+ with transaction() as conn:
310
+ todo = conn.execute("""SELECT mint, graduated_at FROM corpus_meta WHERE own_dd60 IS NULL AND graduated_at IS NOT NULL
311
+ AND graduated_at BETWEEN now() - interval '6 hours' AND now() - interval '61 minutes'""").fetchall()
312
+ n = 0
313
+ for t in todo:
314
+ rows = conn.execute("SELECT close FROM pump_minutes WHERE mint = %s AND ts >= %s AND ts < %s ORDER BY ts",
315
+ (t["mint"], t["graduated_at"], t["graduated_at"] + timedelta(minutes=60))).fetchall()
316
+ if not rows:
317
+ continue
318
+ closes = [float(r["close"]) for r in rows]; p0 = closes[0]
319
+ if p0 <= 0:
320
+ continue
321
+ conn.execute("UPDATE corpus_meta SET own_dd60 = %s, own_max60 = %s, updated_at = now() WHERE mint = %s AND own_dd60 IS NULL",
322
+ (min(closes) / p0 - 1.0, max(closes) / p0 - 1.0, t["mint"]))
323
+ n += 1
324
+ return n
325
+
326
+
327
+ def archive_old(keep_days: int) -> int:
328
+ """Archive minute rows older than ``keep_days`` to Parquet, then remove them from the table."""
329
+ cutoff = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=keep_days)
330
+ with transaction() as conn:
331
+ days = [r["d"] for r in conn.execute("SELECT DISTINCT date_trunc('day', ts) AS d FROM pump_minutes WHERE ts < %s", (cutoff,)).fetchall()]
332
+ n = 0
333
+ for d in days:
334
+ with transaction() as conn:
335
+ rows = conn.execute("SELECT * FROM pump_minutes WHERE ts >= %s AND ts < %s ORDER BY mint, ts", (d, d + timedelta(days=1))).fetchall()
336
+ if rows:
337
+ out = config.CORPUS_DIR / "stream"; out.mkdir(parents=True, exist_ok=True)
338
+ path = out / f"{d:%Y-%m-%d}.parquet"; tmp = path.with_name(path.name + ".tmp")
339
+ pq.write_table(pa.Table.from_pylist([dict(r) for r in rows]), tmp, compression="zstd"); tmp.replace(path)
340
+ conn.execute("DELETE FROM pump_minutes WHERE ts >= %s AND ts < %s", (d, d + timedelta(days=1)))
341
+ n += len(rows)
342
+ return n
343
+
344
+
345
+ def _status(agg: Aggregator, extra: dict) -> None:
346
+ try:
347
+ with transaction() as conn:
348
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES ('pumpstream_status', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
349
+ (json.dumps({**agg.stats, **extra, "updated_at": datetime.now(timezone.utc).isoformat()}, default=str),))
350
+ except Exception:
351
+ log.debug("pumpstream status write failed", exc_info=True)
352
+
353
+
354
+ def _logged(name: str, fn, *args):
355
+ try:
356
+ n = fn(*args)
357
+ if n:
358
+ log.info("%s: %d", name, n)
359
+ return n
360
+ except Exception:
361
+ log.exception("%s failed", name)
362
+ return 0
363
+
364
+
365
+ def _load_insiders(agg: Aggregator) -> None:
366
+ """Insider wallets (creator, bundle, early buyers: token_insiders) of every mint traded today not loaded yet; a token's
367
+ set is fixed at its graduation, so loaded sets are kept."""
368
+ todo = [m for m in agg.ref if m not in agg.insiders]
369
+ if not todo:
370
+ return
371
+ try:
372
+ with transaction() as conn:
373
+ rows = conn.execute("SELECT mint, wallet FROM token_insiders WHERE mint = ANY(%s)", (todo,)).fetchall()
374
+ except Exception:
375
+ log.exception("insider load failed; retried in 1 min")
376
+ return
377
+ got: dict[str, set] = {}
378
+ for r in rows:
379
+ got.setdefault(r["mint"], set()).add(r["wallet"])
380
+ for m in todo:
381
+ agg.insiders[m] = frozenset(got.get(m, ()))
382
+
383
+
384
+ def load_skill(agg: Aggregator, now: float | None = None) -> None:
385
+ """The wallet-skill table of the current UTC day (the file training joins for that day), once per day."""
386
+ from ..train.mature import SKILL_DIR
387
+ day = datetime.fromtimestamp(now or time.time(), timezone.utc).date().isoformat()
388
+ if agg.skill_day == day and agg.skill is not None:
389
+ return
390
+ f = SKILL_DIR / f"{day}.parquet"
391
+ if not f.exists():
392
+ have = sorted(SKILL_DIR.glob("????-??-??.parquet")) if SKILL_DIR.exists() else []
393
+ if not have:
394
+ agg.skill = None; agg.skill_day = day
395
+ return
396
+ f = have[-1] # a shipped table going stale beats none: without one the selector fails closed and the fly's skill inputs read as zero
397
+ log.warning("no wallet skill table for %s: using the newest on disk, %s (skill inputs %d days stale)", day, f.stem,
398
+ (datetime.fromisoformat(day) - datetime.fromisoformat(f.stem)).days)
399
+ t = pq.read_table(f, columns=["wallet", "bucket"])
400
+ agg.skill = dict(zip(t["wallet"].to_pylist(), t["bucket"].to_pylist())); agg.skill_day = day
401
+ log.info("wallet skill table for %s loaded: %d wallets", day, len(agg.skill))
402
+
403
+
404
+ def _load_blocked(agg: Aggregator) -> None:
405
+ """Union the custom pools in ``pump_pools`` (stream rows and the archive backfill) into the blocked set."""
406
+ try:
407
+ with transaction() as conn:
408
+ agg.block(blocked_pool_ids(conn))
409
+ except Exception:
410
+ log.exception("custom pool load failed; retried in 5 min")
411
+
412
+
413
+ async def _run(stop: threading.Event | None, agg: Aggregator) -> None:
414
+ ctx = ssl.create_default_context(cafile=certifi.where()); backoff = 1.0
415
+ _load_blocked(agg); last_pools = time.time(); last_ins = 0.0
416
+ try:
417
+ load_skill(agg)
418
+ except Exception:
419
+ log.exception("wallet skill load failed; retried in 5 min")
420
+ last_status = last_flush = last_outcomes = 0.0; last_archive = time.time() - 3000; last_msg = 0.0; rate_n = 0; rate_t = time.time()
421
+ bg: dict[str, asyncio.Task | None] = {"archive": None, "outcomes": None}
422
+ while not (stop is not None and stop.is_set()):
423
+ try:
424
+ async with websockets.connect(config.PUMPSTREAM_URL, ssl=ctx, max_size=8_000_000, ping_interval=20, ping_timeout=20) as ws:
425
+ log.info("pumpstream connected"); backoff = 1.0
426
+ while not (stop is not None and stop.is_set()):
427
+ try:
428
+ msg = await asyncio.wait_for(ws.recv(), timeout=30)
429
+ except asyncio.TimeoutError:
430
+ log.warning("pumpstream: no events for 30 s; reconnecting"); break
431
+ last_msg = time.time(); rate_n += 1
432
+ try:
433
+ e = orjson.loads(msg)
434
+ except orjson.JSONDecodeError:
435
+ continue
436
+ if isinstance(e, dict):
437
+ agg.ingest(e)
438
+ now = time.time()
439
+ if now - last_flush >= 1.0:
440
+ try:
441
+ agg.flush(agg.flush_bound(now))
442
+ except Exception:
443
+ log.exception("minute flush failed; rows kept for the next attempt")
444
+ last_flush = now
445
+ if now - last_pools >= 300:
446
+ _load_blocked(agg); last_pools = now
447
+ try:
448
+ load_skill(agg, now)
449
+ except Exception:
450
+ log.exception("wallet skill load failed; retried in 5 min")
451
+ if now - last_ins >= 60:
452
+ _load_insiders(agg); last_ins = now
453
+ if now - last_status >= 5.0:
454
+ agg.write_creates(); agg.write_pools()
455
+ _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,
456
+ "flushed_through": agg.flushed_through.isoformat() if agg.flushed_through else None,
457
+ "event_watermark": datetime.fromtimestamp(agg.max_event_s, timezone.utc).isoformat() if agg.max_event_s else None})
458
+ rate_n = 0; rate_t = now; last_status = now
459
+ if now - last_outcomes >= 60 and (bg["outcomes"] is None or bg["outcomes"].done()):
460
+ bg["outcomes"] = asyncio.create_task(asyncio.to_thread(_logged, "stream outcomes filled", fill_outcomes)); last_outcomes = now
461
+ if now - last_archive >= 3600 and (bg["archive"] is None or bg["archive"].done()):
462
+ bg["archive"] = asyncio.create_task(asyncio.to_thread(_logged, "archived old minute rows", archive_old, config.PUMP_MINUTES_KEEP_DAYS)); last_archive = now
463
+ except Exception as e:
464
+ agg.stats["reconnects"] += 1; log.warning("pumpstream: %s; reconnecting in %.0fs", e, backoff)
465
+ _status(agg, {"connected": False, "last_error": str(e)[:200]})
466
+ await asyncio.sleep(backoff); backoff = min(60.0, backoff * 2)
467
+ for t in bg.values():
468
+ if t is not None:
469
+ try:
470
+ await t
471
+ except Exception:
472
+ pass
473
+ try:
474
+ agg.flush(int(time.time() // 60) * 60 + 60)
475
+ except Exception:
476
+ log.exception("final minute flush failed")
477
+ agg.write_creates(); agg.write_pools()
478
+
479
+
480
+ def main(stop_event: threading.Event | None = None) -> None:
481
+ setup("pumpstream"); agg = Aggregator()
482
+ record_event("info", "pumpstream", "pumpstream started", {"url": config.PUMPSTREAM_URL})
483
+ try:
484
+ asyncio.run(_run(stop_event, agg))
485
+ finally:
486
+ _status(agg, {"connected": False, "stage": "stopped"})
487
+ record_event("info", "pumpstream", "pumpstream stopped", {k: agg.stats.get(k) for k in ("events", "trades", "dropped_band", "dropped_custom_pool", "flushed_rows", "creates",
488
+ "custom_pools", "migrates", "reconnects")})
fly_trader/ingest/replay_pull.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PumpAPI historical replay → hourly Parquet (Supervisor thread ``replay``).
2
+
3
+ pumpapi.io publishes every event its decoded data stream emitted, hour by hour, since 2026-04-18:
4
+ ``https://replay.pumpapi.io/YYYY/MM/DD/HH.jsonl.zst`` (~400 MB zstd, ~2 GB JSONL, ~1.7 M events; free, no key).
5
+ Each line is one decoded transaction: buys/sells with the trader, SOL and token amounts, price, and the pool's real
6
+ reserves; ``create`` with the creator wallet and metadata URI; ``migrate`` (graduation); ``createPool``.
7
+
8
+ This worker downloads ``REPLAY_PARALLEL`` hours at a time (measured ~2–4 MB/s per stream), parses each in one
9
+ thread and writes, per hour, ``data/corpus/replay/<date>/<HH>_trades.parquet`` (pump.fun bonding-curve and
10
+ PumpSwap buys/sells, one row per trader leg; signatures and per-mint constants are left out to keep it ~70 MB/hour, the
11
+ lifecycle table carries creator, pool id, metadata URI and the reserves at migration) and ``<HH>_events.parquet`` (every non-trade lifecycle event across
12
+ all pools, small). Hours are processed newest first so the current regime lands first, and hours that close while the backfill runs
13
+ are fetched ahead of it, so the archive stays within ~2 hours of live; the registry is ``replay_hours``. The compressed download is a temporary file and is removed after a successful parse — the
14
+ public archive remains the source of truth. Downstream: ``train/replay_assemble.py`` turns days into per-token
15
+ candle/trade files for the feature builder.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import io
20
+ import json
21
+ import logging
22
+ import queue
23
+ import threading
24
+ import time
25
+ from datetime import datetime, timedelta, timezone
26
+ from pathlib import Path
27
+
28
+ import httpx
29
+ import orjson
30
+ import pyarrow as pa
31
+ import pyarrow.parquet as pq
32
+ import zstandard
33
+
34
+ from .. import config
35
+ from ..db.apilog import record_event
36
+ from ..db.connection import transaction
37
+ from ..logging_setup import setup
38
+ from .corpus_pull import _db_retry, _sleep
39
+
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()),
47
+ ("quote_in_pool", pa.float64()), ("tokens_in_pool", pa.float64()), ("vquote", pa.float32()), ("n_legs", pa.int8()),
48
+ ("quote_mint", pa.dictionary(pa.int16(), pa.string())), ("pool_id", pa.dictionary(pa.int32(), pa.string()))])
49
+ EVENT_SCHEMA = pa.schema([("ts", pa.timestamp("ms", tz="UTC")), ("slot", pa.int64()), ("sig", pa.string()), ("action", pa.string()), ("pool", pa.string()),
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:
57
+ try:
58
+ return float(x) if x is not None else None
59
+ except (TypeError, ValueError):
60
+ return None
61
+
62
+
63
+ def _q(d: dict, new: str, old: str):
64
+ """A quote-side field under its current name, else under the archive's pre-2026-05-21 SOL-only name."""
65
+ v = d.get(new)
66
+ return v if v is not None else d.get(old)
67
+
68
+
69
+ def parse_hour(path: Path) -> tuple[pa.Table, pa.Table, int]:
70
+ """Stream-decompress one hour file into (trades, events, n_events)."""
71
+ T = {name: [] for name in TRADE_SCHEMA.names}; E = {name: [] for name in EVENT_SCHEMA.names}; n = 0
72
+ dctx = zstandard.ZstdDecompressor()
73
+ with open(path, "rb") as fh, dctx.stream_reader(fh) as reader:
74
+ for line in io.BufferedReader(reader, buffer_size=8 << 20):
75
+ if not line.strip():
76
+ continue
77
+ try:
78
+ e = orjson.loads(line)
79
+ except orjson.JSONDecodeError:
80
+ continue
81
+ n += 1
82
+ # the archive before 2026-05-21 names fields txType / solAmount / solInPool / vSolInBondingCurve; later: action / quote*
83
+ a = e.get("action") or e.get("txType"); pool = e.get("pool")
84
+ ts = e.get("timestamp")
85
+ if ts is None:
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")
93
+ for b in legs:
94
+ T["ts"].append(tsdt); T["slot"].append(int(e.get("block") or 0)); T["pool"].append(pool); T["mint"].append(e.get("mint"))
95
+ T["trader"].append(b.get("trader") or e.get("txSigner")); T["side"].append(1 if (b.get("action") or b.get("txType")) == "buy" else -1)
96
+ T["sol"].append(_f(_q(b, "quoteAmount", "solAmount")) or 0.0); T["tokens"].append(_f(b.get("tokenAmount")) or 0.0); T["price"].append(_f(e.get("price")))
97
+ T["quote_in_pool"].append(_f(_q(e, "quoteInPool", "solInPool"))); T["tokens_in_pool"].append(_f(e.get("tokensInPool"))); T["vquote"].append(_f(vq))
98
+ T["n_legs"].append(len(legs)); T["quote_mint"].append(e.get("quoteMint")); T["pool_id"].append(e.get("poolId"))
99
+ elif a not in SKIP_ACTIONS:
100
+ E["ts"].append(tsdt); E["slot"].append(int(e.get("block") or 0)); E["sig"].append(e.get("signature")); E["action"].append(a); E["pool"].append(pool)
101
+ E["mint"].append(e.get("mint")); E["pool_id"].append(e.get("poolId")); E["signer"].append(e.get("txSigner")); E["creator_fee_addr"].append(e.get("creatorFeeAddress"))
102
+ E["name"].append(e.get("name")); E["symbol"].append(e.get("symbol")); E["uri"].append(e.get("uri")); E["supply"].append(_f(e.get("supply")))
103
+ E["decimals"].append(int(e["decimals"]) if e.get("decimals") is not None else None); E["token_program"].append(e.get("tokenProgram"))
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
113
+ stream's blocked-pool filter and dominant-pool choice (train/mature.py). Returns the number of hours marked."""
114
+ hours = []
115
+ for f in sorted(config.REPLAY_DIR.glob("*/*_trades.parquet")):
116
+ if "pool_id" not in pq.read_schema(f).names:
117
+ d = datetime.fromisoformat(f.parent.name)
118
+ hours.append(datetime(d.year, d.month, d.day, int(f.name[:2]), tzinfo=timezone.utc))
119
+ if hours:
120
+ with transaction() as conn:
121
+ conn.cursor().executemany(HOUR_SQL, [(h, "refetch", "trades file lacks pool_id") for h in hours])
122
+ return len(hours)
123
+
124
+
125
+ def plan_hours(now: datetime | None = None) -> list[datetime]:
126
+ now = now or datetime.now(timezone.utc) # callers pass the same ``now`` they seed the re-plan bound with
127
+ start = datetime.fromisoformat(config.REPLAY_START).replace(tzinfo=timezone.utc)
128
+ last = (now - timedelta(hours=2)).replace(minute=0, second=0, microsecond=0)
129
+ with transaction() as conn:
130
+ done = {r["hour"] for r in conn.execute("SELECT hour FROM replay_hours WHERE status = 'done'").fetchall()}
131
+ hours = []; h = last
132
+ while h >= start:
133
+ if h not in done:
134
+ hours.append(h)
135
+ h -= timedelta(hours=1)
136
+ return hours # newest first
137
+
138
+
139
+ def _put_hour(sql: str, params: tuple) -> None:
140
+ with transaction() as conn:
141
+ conn.execute(sql, params)
142
+
143
+
144
+ def _kept_downloads(tmpdir: Path, planned: set[datetime]) -> list[tuple[datetime, Path, int, float]]:
145
+ """Complete hour files left by a stopped worker, for hours still planned: parsed instead of downloaded again.
146
+ Partial files are removed (the archive ignores Range requests, so a download cannot resume), as are complete
147
+ files for hours no longer planned."""
148
+ out = []
149
+ for f in sorted(tmpdir.glob("*")):
150
+ if f.suffix == ".zst":
151
+ try:
152
+ h = datetime.strptime(f.stem, "%Y%m%d%H").replace(tzinfo=timezone.utc)
153
+ except ValueError:
154
+ h = None
155
+ if h in planned:
156
+ out.append((h, f, f.stat().st_size, 0.0)); continue
157
+ f.unlink()
158
+ return out
159
+
160
+
161
+ def _download(client: httpx.Client, hour: datetime, tmp: Path, stop: threading.Event | None) -> int:
162
+ url = f"{config.REPLAY_URL}/{hour:%Y/%m/%d/%H}.jsonl.zst"
163
+ part = tmp.with_suffix(".part"); size = 0
164
+ with client.stream("GET", url) as r:
165
+ if r.status_code == 404:
166
+ return -1
167
+ r.raise_for_status()
168
+ with open(part, "wb") as f:
169
+ for chunk in r.iter_bytes(1 << 20):
170
+ if stop is not None and stop.is_set():
171
+ raise InterruptedError
172
+ f.write(chunk); size += len(chunk)
173
+ part.rename(tmp)
174
+ return size
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
+
182
+ def update(self, force=False, **kv):
183
+ with self.lock:
184
+ self.s.update(kv); el = time.time() - self.t0
185
+ self.s["mb_s"] = self.s["bytes"] / 1e6 / el if el > 1 else 0.0
186
+ self.s["hours_per_h"] = self.s["hours_done"] / el * 3600 if el > 1 else 0.0
187
+ remaining = self.s["hours_total"] - self.s["hours_done"]
188
+ self.s["eta_h"] = remaining / self.s["hours_per_h"] if self.s["hours_per_h"] else None
189
+ self.s["updated_at"] = datetime.now(timezone.utc).isoformat()
190
+ if not force and time.time() - self.last < 3.0:
191
+ return
192
+ self.last = time.time(); payload = dict(self.s)
193
+ try:
194
+ with transaction() as conn:
195
+ conn.execute("INSERT INTO ui_settings (key, value) VALUES ('replay_status', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
196
+ (json.dumps(payload, default=str),))
197
+ except Exception:
198
+ log.debug("replay status write failed", exc_info=True)
199
+
200
+
201
+ def main(stop_event: threading.Event | None = None) -> None:
202
+ setup("replay"); stop = stop_event
203
+ tmpdir = config.REPLAY_DIR / "_tmp"; tmpdir.mkdir(parents=True, exist_ok=True)
204
+ now0 = datetime.now(timezone.utc); hours = plan_hours(now0); status = _Status(len(hours))
205
+ preloaded = _kept_downloads(tmpdir, set(hours)) # finished before a restart: parse, don't download again
206
+ if preloaded:
207
+ kept = {p[0] for p in preloaded}; hours = [h for h in hours if h not in kept]
208
+ log.info("replay ingest: %d complete hour file(s) kept from before the restart", len(preloaded))
209
+ record_event("info", "replay", "replay ingest started", {"hours_pending": len(hours), "parallel": config.REPLAY_PARALLEL})
210
+ log.info("replay ingest: %d hours pending (newest first)", len(hours))
211
+ todo: queue.Queue = queue.Queue(); ready: queue.Queue = queue.Queue(maxsize=config.REPLAY_PARALLEL + 2)
212
+ for h in hours:
213
+ todo.put(h)
214
+ client = httpx.Client(headers={"User-Agent": "fly-trader replay ingest"}, timeout=httpx.Timeout(60.0, read=600.0), follow_redirects=True)
215
+
216
+ newest_planned = [(now0 - timedelta(hours=2)).replace(minute=0, second=0, microsecond=0)] # the plan's upper bound (same clock)
217
+ fresh: queue.Queue = queue.Queue() # hours that closed after the plan was made; served before the backfill
218
+ retried: dict[datetime, float] = {}
219
+
220
+ def replan():
221
+ last = (datetime.now(timezone.utc) - timedelta(hours=2)).replace(minute=0, second=0, microsecond=0)
222
+ h = newest_planned[0] + timedelta(hours=1)
223
+ while h <= last:
224
+ fresh.put(h); h += timedelta(hours=1)
225
+ if last > newest_planned[0]:
226
+ newest_planned[0] = last
227
+ # an hour fetched before the archive published it (404) or that failed is retried every 30 min for 12 h
228
+ try:
229
+ with transaction() as conn:
230
+ again = [r["hour"] for r in conn.execute("SELECT hour FROM replay_hours WHERE status IN ('missing','error') AND hour > now() - interval '12 hours'").fetchall()]
231
+ except Exception:
232
+ again = []
233
+ for h in again:
234
+ if time.time() - retried.get(h, 0.0) >= 1800:
235
+ retried[h] = time.time(); fresh.put(h)
236
+
237
+ def downloader():
238
+ while not (stop is not None and stop.is_set()):
239
+ try:
240
+ h = fresh.get_nowait()
241
+ except queue.Empty:
242
+ try:
243
+ h = todo.get_nowait()
244
+ except queue.Empty:
245
+ return
246
+ tmp = tmpdir / f"{h:%Y%m%d%H}.zst"
247
+ for attempt in range(4):
248
+ try:
249
+ t0 = time.time(); size = _download(client, h, tmp, stop)
250
+ while not (stop is not None and stop.is_set()):
251
+ try:
252
+ ready.put((h, tmp, size, time.time() - t0), timeout=5.0); break
253
+ except queue.Full:
254
+ continue
255
+ break
256
+ except InterruptedError:
257
+ return
258
+ except Exception as e:
259
+ log.warning("download %s failed (%d): %s", h, attempt, e); time.sleep(10 * (attempt + 1))
260
+ else:
261
+ while not (stop is not None and stop.is_set()):
262
+ try:
263
+ ready.put((h, None, 0, 0.0), timeout=5.0); break
264
+ except queue.Full:
265
+ continue
266
+
267
+ if config.REPLAY_ASSEMBLE: # REPLAY_ASSEMBLE=0: a download-only run (e.g. refetch-pool-ids) leaves the builds alone
268
+ from ..train import replay_assemble
269
+ assembler = threading.Thread(target=replay_assemble.assemble_loop, args=(stop,), name="replay-assemble", daemon=True); assembler.start()
270
+ threads = [threading.Thread(target=downloader, name=f"replay-dl-{i}", daemon=True) for i in range(config.REPLAY_PARALLEL)]
271
+ for t in threads:
272
+ t.start()
273
+ try:
274
+ while not (stop is not None and stop.is_set()):
275
+ try:
276
+ h, tmp, size, dl_s = preloaded.pop(0) if preloaded else ready.get(timeout=5.0)
277
+ except queue.Empty:
278
+ replan()
279
+ if not any(t.is_alive() for t in threads) and ready.empty():
280
+ if fresh.empty():
281
+ _sleep(300, stop); replan() # caught up: poll for the next closed hour
282
+ if fresh.empty():
283
+ continue
284
+ threads = [threading.Thread(target=downloader, name=f"replay-dl-{i}", daemon=True) for i in range(config.REPLAY_PARALLEL)]
285
+ for t in threads:
286
+ t.start()
287
+ continue
288
+ replan()
289
+ if tmp is None or size < 0:
290
+ _db_retry(stop, "replay_hours write", _put_hour, HOUR_SQL, (h, "missing" if size < 0 else "error", None if size < 0 else "download failed"))
291
+ status.update(errors=status.s["errors"] + (0 if size < 0 else 1)); continue
292
+ t0 = time.time()
293
+ try:
294
+ trades, events, n = parse_hour(tmp)
295
+ day = config.REPLAY_DIR / f"{h:%Y-%m-%d}"; day.mkdir(parents=True, exist_ok=True)
296
+ pq.write_table(trades, day / f"{h:%H}_trades.parquet", compression="zstd"); pq.write_table(events, day / f"{h:%H}_events.parquet", compression="zstd")
297
+ tmp.unlink()
298
+ _db_retry(stop, "replay_hours write", _put_hour,
299
+ "INSERT INTO replay_hours (hour, status, trades, events, bytes, took_s) VALUES (%s,'done',%s,%s,%s,%s) "
300
+ "ON CONFLICT (hour) DO UPDATE SET status = 'done', trades = EXCLUDED.trades, events = EXCLUDED.events, bytes = EXCLUDED.bytes, took_s = EXCLUDED.took_s, last_error = NULL, done_at = now()",
301
+ (h, trades.num_rows, events.num_rows, size, dl_s + time.time() - t0))
302
+ status.update(hours_done=status.s["hours_done"] + 1, trades=status.s["trades"] + trades.num_rows, events=status.s["events"] + events.num_rows,
303
+ bytes=status.s["bytes"] + size, last_hour=h.isoformat(), last_parse_s=round(time.time() - t0, 1), last_dl_s=round(dl_s, 1))
304
+ log.info("hour %s: %d events -> %d trade rows, %d lifecycle rows (%.0f MB, dl %.0fs, parse %.1fs)", h.isoformat(), n, trades.num_rows, events.num_rows, size / 1e6, dl_s, time.time() - t0)
305
+ except Exception as e:
306
+ log.exception("parse %s failed", h)
307
+ tmp.unlink(missing_ok=True) # the ~400 MB download; replan() refetches the hour
308
+ _db_retry(stop, "replay_hours write", _put_hour, HOUR_SQL, (h, "error", str(e)[:300]))
309
+ status.update(errors=status.s["errors"] + 1)
310
+ finally:
311
+ if stop is not None:
312
+ stop.set()
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")