fly-trader distribution: code, fly #62, selector #59, connectome, seed (branch distribution @ 5cb1652)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +15 -0
- .env.example +50 -0
- .gitignore +18 -0
- Dockerfile +21 -0
- LICENSE +21 -0
- README.md +150 -0
- config/mbon_valence.json +27 -0
- docker-compose.yml +39 -0
- docker/entrypoint.sh +38 -0
- fly_trader/__init__.py +3 -0
- fly_trader/__main__.py +229 -0
- fly_trader/agent/__init__.py +1 -0
- fly_trader/agent/fly_live.py +159 -0
- fly_trader/agent/fly_session.py +510 -0
- fly_trader/agent/handover.py +24 -0
- fly_trader/agent/minute_engine.py +342 -0
- fly_trader/agent/paper_trading.py +89 -0
- fly_trader/agent/rails.py +122 -0
- fly_trader/agent/runner.py +64 -0
- fly_trader/agent/selector_session.py +162 -0
- fly_trader/agent/sizing.py +106 -0
- fly_trader/brain/__init__.py +1 -0
- fly_trader/brain/connectome.py +171 -0
- fly_trader/brain/connectome_build.py +337 -0
- fly_trader/brain/plastic.py +275 -0
- fly_trader/brain/populations.py +611 -0
- fly_trader/chain/__init__.py +1 -0
- fly_trader/chain/balances.py +49 -0
- fly_trader/chain/cluster_guard.py +35 -0
- fly_trader/chain/jupiter_swap.py +221 -0
- fly_trader/chain/jupiter_tokens.py +82 -0
- fly_trader/chain/keys.py +141 -0
- fly_trader/chain/rpc.py +200 -0
- fly_trader/chain/signing.py +86 -0
- fly_trader/config.py +196 -0
- fly_trader/db/__init__.py +0 -0
- fly_trader/db/apilog.py +49 -0
- fly_trader/db/connection.py +85 -0
- fly_trader/db/queries.py +37 -0
- fly_trader/db/schema.py +363 -0
- fly_trader/execution/__init__.py +1 -0
- fly_trader/execution/broker_live.py +455 -0
- fly_trader/execution/broker_paper.py +97 -0
- fly_trader/execution/ledger.py +147 -0
- fly_trader/execution/worker.py +143 -0
- fly_trader/ingest/__init__.py +1 -0
- fly_trader/ingest/corpus_pull.py +439 -0
- fly_trader/ingest/discovery.py +80 -0
- fly_trader/ingest/pumpstream.py +488 -0
- 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")
|