---
name: trench-model
description: The self-learning outcome model for GMGN trench tokens - what GMGN's API actually hands over per token, the labelling design that avoids leakage, and the build order. Read before any work on predicting which Robinhood Chain launch runs, and before calling any GMGN list or token endpoint.
sources: [cowork]
aliases: [trench model, outcome model, runner prediction, gmgn backend, self-learning backend, prediction model]
---
Concept written 13 Sep 2026. Companion to [[casig-gmgn-overlay]] (the star rating it will sit next to), [[techscan]] (the V4 website scan), [[gmgn-auto-trading]] (the CLI and the execution side). Concept page published as an artifact the same day.

# The ask
- [stated] Ben: build a backend that consumes everything GMGN shows on newly launched, close-to-migrating and migrated, "starting today", watches how they perform, which ones exit rug, which ones we thought were good but overlooked something. "Keep predicting, keep being wrong, keep identifying what's wrong, what you overlooked. Keep fine-tuning the model in the background over a couple of days, and we see the accuracy of the predictions improve and improve."
- [stated] The output he wants is five calls: big runner / could go 1 to 5 million / 5 to 50 million / will not run at all / scam, stay out.
- [stated] Priority order: accuracy and speed first, cheap second.

# MEASURED 13 Sep 2026, all live calls, nothing estimated
- **`POST /v1/trenches` returns all three trench columns in ONE call: 180 tokens (60 per column), 100 fields each, 0.30s.** This is the whole feature set Ben listed, pre-computed by GMGN. It is not a data-engineering problem.
- **CORRECTION to [[gmgn-auto-trading]]: that file says "Trenches is websocket-fed with no REST list endpoint found". That is wrong.** The endpoint exists and works.
- **The trenches call must be SIGNED.** An unsigned request with only `X-APIKEY` returns HTTP 200 and three EMPTY lists, from the droplet AND from Ben's Mac, so it is the signature, not the IP. `chain` must be in the QUERY, not the body ("missing chain" otherwise). `gmgn-cli market trenches --chain robinhood --limit 80` works and returns the 180 rows. The collector either shells the CLI or reproduces the Ed25519 signature from `~/.config/gmgn/keypair.pem`.
- **Rate limit is a leaky bucket, rate=20 capacity=20**, per `~/.claude/skills/gmgn-market/SKILL.md`. Weights: `rank` 1, `token_kline` 2, `trenches` 3, `token_signal` 3, `hot_searches` 3, `search` 1. **The docs page saying "1 request per second" is stale, like every other GMGN gitbook page.** A full trench sweep every 5s costs 0.6 of 20 units per second, about 3% of budget.
- `GET /v1/token/info` 0.38s, adds `wallet_tags_stat` (smart_wallets, fresh_wallets, renowned_wallets, sniper_wallets, whale_wallets, bundler_wallets, rat_trader_wallets), `launchpad_progress`, `visiting_count`, `ath_price`, `locked_ratio`, `migration_market_cap`, and a `price` block with buys/sells/volume/buy_volume/sell_volume/swaps at 1m/5m/1h/6h/24h plus `hot_level`.
- `GET /v1/market/token_kline?limit=1500` returns a token's entire life in one call (`limit` is the only paging parameter that works). **That is the label source and it is free.**
- Other live routes worth knowing: `/v1/token/security`, `/v1/market/token_top_holders`, `/v1/market/token_top_traders`, `/v1/market/rank`, `/v1/user/smartmoney`, `/v1/user/kol`, `POST /v1/market/token_signal`.
- The API key lives at **`/opt/common/gmgn_key`** on the droplet (37 chars). `/opt/xdeck/config.json` `api_key` is the twitterapi.io key, NOT the GMGN one: using it returns 401.

## The 100 trenches fields, grouped
- **Holders**: holder_count, top_10_holder_rate, top70_sniper_hold_rate, dev_team_hold_rate, creator_balance_rate, suspected_insider_hold_rate, bundler_trader_amount_rate, bundler_mhr, rat_trader_amount_rate, fresh_wallet_rate, bot_degen_rate, bot_degen_count, entrapment_ratio
- **Dev**: creator, creator_created_count, creator_created_inner_count, creator_created_open_count, **creator_created_open_ratio**, creator_token_status, dev_token_burn_amount, dev_token_burn_ratio, **fund_from / fund_from_address / fund_from_ts** (the funder graph, free)
- **Safety**: is_honeypot, owner_renounced, open_source, burn_status, buy_tax, sell_tax, total_buy_tax, total_sell_tax, total_fee, is_wash_trading, launch_flag, status
- **Paid promotion**: dexscr_ad, dexscr_boost_fee, dexscr_trending_bar, dexscr_update_link
- **Copy detection**: image_dup, twitter_dup, website_dup, telegram_dup, twitter_rename_count, twitter_change_flag, twitter_del_post_token_count, cto_flag, is_og, creation_tool
- **Attention**: visiting_count, callout_count, tg_call_count, smart_degen_count, renowned_count, x_user_follower, x_user_following, twitter_community_id, twitter_is_tweet
- **Flow and speed**: market_cap, liquidity, price, volume_24h, buys_24h, sells_24h, net_buy_24h, swaps_24h, **progress**, **complete_cost_time**, complete_timestamp, open_timestamp, created_timestamp, total_supply
- **The two nobody else is using**: `creator_created_open_ratio` is how many of this dev's prior launches actually graduated (one live row today: a creator with **533 tokens made and 0 opened**, which is a factory in one number). `progress` sampled twice is the bonding-curve SPEED, which is the thing Ben's eye already reads and which no single snapshot contains.

# THE DESIGN RULE THAT DECIDES WHETHER THIS WORKS
**Leakage.** If a row is recorded whenever we happen to look and scored against the peak the token ever reached, late snapshots of winners already contain the win. The model reads the answer off the page, backtests beautifully and loses money live.
- **Every training row is frozen at a FIXED AGE** (1m, 5m, 15m, 1h, 6h), and the outcome is measured only from candles AFTER that moment. This deserves a self-test that fails the build, in the style of `/opt/casig/selftest.py`.
- **Accuracy is the wrong score.** Most tokens go nowhere, so "will not run" on everything is ~90% accurate and useless. The two real numbers: **calibration** (when it says 30%, does ~30% of that group do it) and **paper P&L** on flat tickets.

# The five pieces
1. **Recorder** (day 1, ~200 lines, the only urgent one). Polls trenches every 5s, appends untouched, schedules per-token detail calls at fixed ages. **Today's snapshots cannot be recovered tomorrow: the chain replays, but GMGN's derived fields are current-state only.**
2. **Labeller** (day 2, ~150 lines). Runs a day behind on tokens older than 24h. From `token_kline`: peak mcap, minutes to peak, drawdown after, liquidity pulled.
3. **Scorer** (day 6, ~200 lines). **Two models**, because the two mistakes cost differently: a binary "does this die badly" and an ordinal "how high, given it does not". **Gradient-boosted trees**, not a neural net and not an LLM: tabular, ~100 numeric columns, trains in seconds on CPU, answers in microseconds, and is inspectable, which is what makes the error review possible at all.
4. **Report** (day 3, before the model, ~250 lines). Daily calibration and paper P&L, plus the worst confident-wrong calls in both directions with a per-token diff against the cohort it was predicted into.
5. **Overlay** (day 7, small patch). Bucket and confidence under the stars already on each casig card. No new dashboard.

## Buckets, each machine-checkable from candles alone
| label | rule |
|---|---|
| big runner | peak mcap > $50M after the snapshot |
| $5M to $50M | peak mcap in band |
| $1M to $5M | peak mcap in band |
| will not run | peak < 2x the mcap at snapshot time |
| scam | honeypot, or liquidity pulled, or -90% inside an hour with the dev selling |

# What "self-improving" honestly means. Three layers, two automatic.
- **Automatic, nightly**: labels land as tokens age out, model refits on everything. Genuinely compounds, needs nobody.
- **Automatic, daily**: the post-mortem report. Ranks confident-wrong calls, reports which fields that group shared.
- **NEEDS ME, weekly at first**: new features when the report says no existing column separates the misses. **A model cannot invent a measurement it has never been given**, and pretending this layer is automatic is how these projects quietly stop improving.

# Cost, and the one blocker
- **kookwatch cannot host this: 101 MB RAM free of 961, and no node installed.** Running a collector plus training there risks the 17 alert bots. **One dedicated box, about $24/month, is the entire budget.**
- GMGN API $0 inside the rate limit, chain and DexScreener $0, training $0 (no GPU, no model API), storage ~10 MB/day raw.

# Honest risks, stated before building
- **The first week has no history.** Candles give labels for past tokens, but GMGN's derived fields cannot be recovered for a past moment, so a proper training row cannot be manufactured from the past. The rules baseline (V4 stars plus hard filters) carries the first days, which is the reason it is built before the model.
- **It may not beat the rules.** If after two weeks of real data it cannot, the truthful conclusion is that these fields do not predict upside at that horizon and the value is the scam filter alone. The report should say that rather than dress it up.

# BUILT AND RUNNING, 13 Sep 2026 18:42 UTC. `/opt/trench`, service `trench.service` on kookwatch.
- [stated] Ben, going to sleep: run it all night on incoming GMGN launches, start predicting, learn from right and wrong calls, deploy upgraded versions automatically, and give daily stats on whether we are getting better or worse.
- [stated] Same session, two additions from him: store our own predictions as rows and check them later; include the casig 1-to-5 star rating; add time of day and day of week; and add **the current meta, based on all the other stuff being launched** ("What is the current meta or metas?").
- [stated] Then: "Do one more iteration to review all the fields that you're collecting to make sure that we have all the smart stuff that would help us later."

## CORRECTION to the concept above: the trenches call does NOT need a signature
Reading `~/.npm-global/lib/node_modules/gmgn-cli/dist/client/OpenApiClient.js` settles it. `/v1/trenches` is **exist auth, X-APIKEY only**; only swap and order routes are signed. The empty result was the BODY, not auth. The working call, now in `/opt/trench/gmgn.py`, is:
- query: `chain=robinhood&timestamp=<unix>&client_id=<uuid>`
- body: `{"version":"v2", "<type>":{"filters":["offchain","onchain"],"launchpad_platform_v2":true,"limit":80,"quote_address_type":[11,20,24,12,0]}}` for each of `new_creation`, `near_completion`, `completed`
- `quote_address_type` per chain: sol [4,5,3,1,13,0], bsc [6,7,1,16,8,3,9,10,2,17,18,0], base [11,3,12,13,0], eth [20,11,8,3,12,1,0], **robinhood [11,20,24,12,0]**
- **An empty or wrong body returns HTTP 200 with three EMPTY lists**, which is exactly what a signature failure looks like. That is what cost an hour.
- So **no node is needed and kookwatch can host it after all**. The $24 box is not required yet; revisit when the trainer outgrows the RAM.

## THE RATE-LIMIT TRAP, and it is worse than the docs say
- A 429 carries `reset_at`, but **retrying at the advertised reset RENEWS the ban**, because the request lands a moment early and GMGN extends a live cooldown by 5s per attempt up to 5 minutes. Three restarts in a row each got a fresh ~290s ban while making exactly one request per cycle.
- The fix in `gmgn.py`: **overshoot the reset by 25s plus 20s per consecutive refusal**, and reset that counter on the first success. Also the client runs its bucket at **rate 10 of the allowed 20**, deliberately, so trench can never be the reason another tool on this box gets banned.
- Practical rule for any future session: **do not hand-probe openapi.gmgn.ai while the collector is running.** My own probing is what caused every ban tonight.

## What runs, one process, five threads, `MemoryMax=340M`
| thread | cadence | job |
|---|---|---|
| collector | 8s | one trenches call, 180 rows, thin numeric row per token every poll |
| freezer | 20s | any token crossing an age band is frozen and predicted |
| labeller | 5m | tokens past 4h get a verdict from `token_kline`, then prunes |
| trainer | 45m | refit, compare on an unseen day, promote only if better |
| reporter | 10m | writes `/opt/launchwatch/pub/trench.html` and `stats.json` |

## Storage, because the naive version fills the disk in two days
At 180 rows every 6s the full payload is about **8 GB a day**. So: the thin numeric row (ts, addr, col, mcap, liq, prog, holders) every poll, and the **full payload zlib-compressed at most every 30s while a token is under two hours old and every 5 minutes after**, with payloads older than four days dropped and the numbers kept. Poll moved 6s to 8s. A 3 GB second swapfile was added (`/swapfile2`), so the box now has 4 GB of swap against 961 MB of RAM.

## 120 FEATURES, in five blocks. Order is frozen; new ones append, never insert.
1. **RAW (50)** straight off the payload: holder and sniper and bundler and insider and fresh-wallet rates, dev counts, honeypot, renounced, taxes, the four `dexscr_*` paid-promotion fields, the four `*_dup` copy flags, visiting_count, callout_count, tg_call_count, smart_degen_count, renowned_count, follower counts, mcap, liquidity, volume, buys, sells, net_buy, swaps, progress, complete_cost_time.
2. **DERIVED (26)**: age, logs, mcap/liq, buy-sell ratio, swaps and holders per minute, socials present, which column it sits in, and the **slopes** (progress per minute, mcap % per minute, holders per minute) which are the thing the eye reads and which no single snapshot contains. Plus `dev_factory` (20+ launches, none ever graduated), `dev_proven`, `dev_first_launch`.
3. **META (15)**, Ben's idea and the biggest accuracy lever: `tod_sin`/`tod_cos`, weekend, day of week, **launches in the last 2h**, and this token's **percentile within that cohort** for mcap, holders and curve speed, plus the regime itself (`meta_rug_24h`, `meta_run_24h`), the **share of the last day's runners that came from this launchpad**, and whether a word in its name appears in the name of something that ran today. Twenty holders in five minutes is strong on a dead night and weak in a run; only the percentile knows which.
4. **STARS (2)**: the casig 1-to-5 rating and whether it says scam, read from `http://127.0.0.1:8092`. Asked on first freeze, read on later bands, so the 1m row usually has no star and the 15m row does. **Zero stars means not rated, not bad**, and the model learns what a missing star is worth.
5. **EXTRA (28)**, the field-review pass:
   - **The operator graph as a live feature, and the only block nobody else can compute**: `fund_from_address` is in every row, so `funder_tokens_seen`, `funder_rug_rate`, `funder_run_rate`, `funder_age_h`, `creator_tokens_seen`, `creator_rug_rate` all come from our own table. GMGN tells you about the dev; only our history tells you about the wallet standing behind a dozen devs.
   - **What it is paired against**: WETH, USDG, a tokenized stock, or other. On this chain those are three different games and `quote_symbol` was going in the bin.
   - **How big they are buying**, which Ben named: volume over swaps is average ticket, split into buy and sell tickets and their skew. Whales and bots look nothing alike in it.
   - **Acceleration, not just slope**: last third of the history against the first third, for holders and for market cap. A runner is a token whose arrival rate is still RISING. Plus `off_high_pct` and `max_mcap_seen`.
   - Name shape (length, ascii, digits), follower log and follower/following ratio, `ever_completed`, launchpad flags, seconds since last seen.

## Promotion is automatic and gated
`fit_logistic` is L2 logistic regression in numpy: tiny, calibrated by construction, and **inspectable**, so `top_drivers()` can name the fields that moved a specific call, which is what makes the daily post-mortem possible. A challenger becomes champion only if its Brier score beats **both the base rate and the incumbent on the newest day, which it was never trained on**. Everything else keeps running on `rules-v0`, the hand-written model, which exists so there is something making calls from minute one and something the learned model has to beat.

## The report at `http://159.223.88.168/trench.html`
Brier per age band against the base rate, run-probability calibration bands ("it said 30%, it was 31%"), per-day paper P&L on flat $300 tickets, **what it got wrong** (confident and wrong in both directions, with the reason it gave), the latest calls, the live models, and a **meta block** answering Ben's question directly: launches in 2h, settled in 24h, rug rate, run rate, which launchpads the runners came from, and the words in the names of things that ran.

## Bugs found and fixed during the build, worth not repeating
- **A shadowed variable**: `last` held the payload dict and was then overwritten with a float, so the honeypot check called `.get` on a number. Renamed to `lastrow`.
- **`database is locked`**: five threads on one SQLite file. `PRAGMA busy_timeout=20000` so a writer waits instead of raising. A dropped snapshot is a training row that can never be recovered.
- The freezer only freezes inside a window (`band <= age <= band + 15min`), so a token first seen late never gets a row it did not live through. That is the leakage rule in code.

# THE RATE LIMIT, and the bug that was not mine. 13 Sep 2026, 19:20 UTC.
This cost most of the build and every fact here was measured, so it should never be rediscovered.

## The ban is on the IP, not the key, and every touch renews it
- Body: `{"code":429,"error":"RATE_LIMIT_BANNED","message":"IP is temporarily banned due to repeated rate limit violations","reset_at":...,"tier":"free"}`, header `x-ratelimit-reset`.
- **`reset_at` is not when the ban lifts, it is when it would lift if nobody touched it again.** Two calls six seconds apart pushed it from +293s to +299s. A process that politely retries at the advertised reset renews the ban forever, which is exactly what three restarts in a row did.
- So a client must **overshoot** (now 45s plus 30s per consecutive refusal) and the hold must **survive a restart**, or every restart pokes the wound.

## THE ROOT CAUSE WAS klines.service, and it had been broken for a while
- `/opt/runwatch/klines.py` had `time.sleep(0.5); continue` in front of the rate limit. Its log held **1,742 consecutive 429s**. Every one extended the shared IP ban by five seconds, so the whole droplet was permanently banned and **Ben's runner-board charts had been failing on it too**.
- This is the port kit's **L6 verbatim**: a rate limit is not an oversized request, and retrying it in a loop is a death spiral. It was already written down and it was still live in another service on the same box.

## THE FIX: one limiter for the whole droplet, `/opt/common/gmgnlimit.py`
- A leaky bucket plus a cooldown in `/opt/common/gmgn_state.json`, guarded by `flock`, shared by **trench, klines and xdeck**. A per-process limiter is worthless when the ban is per IP.
- `acquire(weight, timeout, priority=False)` / `ok()` / `banned(reset_at)` / `held_for()`.
- **Priority matters and is not a nicety.** The collector's data is perishable: a token's state at five minutes old exists once. The chart service is redrawing something it can redraw in a minute. Low-priority callers may not spend the bucket below a floor of 5, so the collector's weight-3 call always fits.
- Any future GMGN caller on this box **must** go through it. Use `sys.path.insert(0, "/opt/common")`.

## `/v1/trenches` IS LIMITED HARDER THAN THE OTHER ROUTES
- Measured at 3.5 shared units/s: the labeller's `token_kline` calls kept succeeding while **every** trenches call was refused. So the published `rate=20` bucket is not what binds, and the limit is per route. The weight table in the skill does not capture this.
- Settled cadence: **one trenches call every 45s (about 1,900 a day)**, shared budget 3.5 units/s, cap 10. klines stretched from a 45s sweep to 120s.

## THE 1-MINUTE BAND WAS DROPPED, and that is the honest half
With a 45s poll, a row labelled "1m" would really be 60 to 105 seconds old. **The whole design rests on a training row having a FIXED age, and a band whose age varies by 75% of itself is not one.** At 5 minutes the same slop is under 15%. Bands are now **5m, 15m, 1h, 6h**. The 40 rows already frozen at 1m were deleted rather than left as a stub band that never grows.

## Two more bugs worth not repeating
- **A write transaction must never span a network call.** The labeller looped 40 tokens, writing each and committing at the end, with a rate-limited HTTP call in between, so it held the SQLite write lock for minutes and the collector logged `database is locked` and dropped observations that can never be recovered. Now: fetch, write, commit, one token at a time, batch of 12.
- **Monitoring must not disturb what it monitors.** `store.db()` runs `CREATE TABLE IF NOT EXISTS`, which takes a write lock, so every status query I ran caused the lock errors I was investigating. `/opt/trench/peek.py` opens `file:...?mode=ro` instead. **Use `python3 /opt/trench/peek.py`, never an ad-hoc query.**
- And: **do not hand-probe openapi.gmgn.ai while the collector runs.** My own diagnostics caused several of the bans.

# STATE AT HANDOFF, 19:18 UTC
- `trench.service` active, 46 MB RSS, `MemoryMax=340M`. Collecting steadily: 2,340 observations, 261 tokens, 128 snapshots, 128 predictions, 15 labels, no 429 since the fix.
- Predictions carry their meta stamp, e.g. `dev made 548 tokens, none ever graduated [78 launches/2h, 0% running, no stars]`.
- The dev-factory rule is the one doing real work so far: creators with 92, 105, 346 and 548 launches and zero graduations, all called scam at minute five.
- Still on `rules-v0`. The first learned champion needs 120 rows and 12 positives in a band, so it should appear in the small hours as labels accumulate.
- Report at **http://159.223.88.168/trench.html**, rewritten every 10 minutes.
- **What to check first next session**: has a champion been promoted, what is the Brier against the base rate, what does the "what it got wrong" table say, and did the shared limiter hold overnight (`grep -c "rate limited" /opt/trench/trench.log`).

# REVIEW ITERATION, 13 Sep 2026 19:35 UTC. One real bug, one target redesign, four efficiency fixes.
- [stated] Ben: "Do an iteration to make sure that the way it's set up now is the best way... more efficient, better, and more precise. And execute the changes if there's something obvious."

## THE BUG: the label leaked, in the exact way this design exists to prevent
`label_one` took the peak over the token's **entire life** and the trainer compared it against the market cap at the snapshot. **A token that spiked at minute two and was snapshotted at minute five, after the spike, was labelled a winner.** The model would have learned to recognise tokens that had already run. Written at the top of this file as the one thing to get right, and sitting in the code anyway.
- Fix: a new **`outcome(addr, band, snap_ts, snap_mcap, peak_after, peak_after_ts, mult, rug_after, n_points)`** table, one row per snapshot, peak measured strictly after that snapshot's timestamp. The trainer and the report both join `outcome`, never `lab`. `lab` keeps the whole-life peak for reference only.
- **`/opt/trench/gate.py` now replays it**: a synthetic token spikes to $900k at minute two, falls to $8k by minute four, is snapshotted at minute five and then reaches $12k. The gate fails unless `peak_after` comes back as about $12k. Nine checks, all passing. Run it before any change ships.

## THE TARGET REDESIGN, forced by the measured distribution
- **40% of snapshots have `market_cap` 0**, because at five minutes the token genuinely has not traded (price 0, liquidity 0, swaps 0). A "3x from here" target is undefined for those and was silently discarding 40% of every band.
- **Measured peak market cap after a call, first 27 labels: p50 $5,677, p75 $61,964, p90 $227,382, max $345,410. Nothing reached $1M.** So a multiple was the wrong target and Ben's own buckets are absolute anyway.
- Target is now **absolute: does it reach $X**, with the threshold chosen per band by `pick_threshold()` as the highest rung of [50k, 100k, 250k, 500k, 1M, 5M] that still has at least 12 positives and under 60% positives. It rises on its own as the sample grows, so nothing needs retuning by hand. Currently $100k (22% base rate).
- **`bucket()` was projecting a peak as market cap times 8 or times 4, numbers invented before any data existed**, which printed "1-5M" on tokens whose realistic ceiling was a tenth of that. It now projects from the learned threshold, and a new **"could run"** label covers the $100k band this chain actually lives in. "1-5M" and "5-50M" only appear when the arithmetic genuinely reaches them.

## Efficiency, all of which would have got worse as the table grew
- `META.refresh` ran a correlated subquery `SELECT MAX(ts) FROM obs WHERE addr=t.addr` once per token per minute over a table growing by thousands of rows an hour. `tok.last_seen` already holds that instant.
- The collector issued **three statements per token per poll**, 540 round trips per cycle. Now three `executemany` calls.
- The freezer asked "does this snapshot exist" once per token per band. Now one query per cycle into a set.
- Pruning moved from every five minutes to hourly. Freeze window widened so the 6h band is reachable (`created_ts > now - 30h` instead of `last_seen > now - 2h`).
- The trainer's first-day fallback split sorted by **index**, which is insertion order, not time. Now sorted by snapshot timestamp.

## THE REAL CONSTRAINT IS THE GMGN RATE LIMIT, and polling faster collects less
- Successful trenches calls tonight: 19:15:20, 19:15:28, 19:20:44, then refused at 19:31:10. **A refusal costs a five-minute blackout for every caller on this IP**, so a 45s poll that gets banned once in ten attempts collects far less than a slower poll that never does. The limiter is not the constraint; GMGN is.
- **`klines.service` is stopped and disabled** at Ben's own suggestion, which freed the whole budget. The runners board does not need it: **`http://159.223.88.168/ds.html`** uses DexScreener embeds and `runwatch`'s own `runners.json` feed, neither of which touches GMGN. `runners.html` is the one that needed klines.
- Poll settled at 45s with the budget to itself. Age slop is handled rather than hidden: `age_s` is the first derived feature, so the model sees the true age of every row. **What must never happen is a band whose age varies wildly while claiming to be fixed**, which is why the 1m band was dropped rather than stretched.

## [stated] Ben is considering the $30/month GMGN plan (weight 20, about 20 requests a second, 24x the free rate). Take it: rate is the only thing constraining this system, and it buys back the 1-minute band plus per-token `token_info` and `token_security` calls at every band.

# PLUS PLAN LIVE, 13 Sep 2026 19:42 UTC. Measured, then spent.
- [stated] Ben bought the GMGN **Plus** plan ($30/month, 20 requests a second, 4x the free rate). Pro is 50/s. **Plus is correct and Pro would be idle**: the full design consumes about 1.5 requests a second at maximum ambition.
- **Verified before changing anything**: six back-to-back `/v1/trenches` calls all returned 180 rows at 0.34 to 0.47s, six `token/info` at 0.20s, `token/security` at 0.20s. **Zero refusals, where the free tier was refusing nine in ten.** `/opt/trench/ratetest.py` re-runs this test.
- Shared limiter raised to 14 units/s of the 20 allowed, cap 20. `klines.service` re-enabled and working again.

## THE BIGGEST ACCURACY GAP IN THE FIRST BUILD, and it was hiding in plain sight
**Every flow number on a trenches row is a 24-HOUR aggregate.** On a five-minute-old token, `volume_24h`, `buys_24h` and `net_buy_24h` are just its entire life, so the model had no way to separate "being bought right now" from "bought once at launch". That is the single thing a trench buyer actually reads, and it was missing from all 120 features.
- **`/v1/token/info` carries a price block at 1m, 5m, 1h, 6h and 24h**: buys, sells, volume, buy_volume, sell_volume, swaps, plus `hot_level`. It also carries **`wallet_tags_stat`, which is COUNTS** (smart, fresh, renowned, sniper, rat, whale, top, bundler) rather than the percentage rates the trenches row gives.
- **`/v1/token/security`** adds powers read from the contract: `is_honeypot`, `is_blacklist`, `is_renounced`, `renounced_mint`, `renounced_freeze_account`, `is_open_source`, `burn_ratio`, `average_tax`, `high_tax`, `can_not_sell`, `privileges` (a list), `lockInfo`. 28 fields.
- Both are now fetched **at every snapshot**, in `/opt/trench/info.py`, 48 new features. Security is cached per token; info is fetched fresh because its whole value is that it is a point in time.
- **A failed lookup writes zeros plus an explicit `info_ok` / `sec_ok` flag**, never a fabricated number, so the model learns what a missing lookup is worth.
- Verified live on KITSHU at the 15m band: `sells_5m 1`, `vol_5m 0.52`, `px_chg_5m -6.70%`, `px_chg_1h -6.19%`, `w_fresh 1`, `w_top 2`, `ath_over_now 1.10`. Real numbers, not zeros.

## Current shape
- **168 features**, five bands: **1m** (restored, because Ben buys in the first hour and the earliest honest call is the most valuable), 5m, 15m, 1h, 6h.
- **Poll every 10s**, so a "1m" row is 60 to 70 seconds old: a 17% spread, against the 75% that forced the band out on the free tier.
- Steady state at handoff: 7,738 observations, 338 tokens, 366 snapshots and predictions, 39 labels, 33 MB, **zero 429s since the upgrade**.
- `gate.py` all nine checks passing, including the leakage replay.

## The order these were cut and restored is the lesson
Every single thing removed earlier that night (the 1m band, per-token depth, the chart service) was removed for **rate**, not because it was wrong. When a constraint is doing that much damage, pricing it is the cheapest fix available: $30 bought back a band, 48 features and a working chart board in one step. **Check what a limit is actually costing before designing around it.**

# THREE REVIEW PASSES + THE WALLET LAYER, 13 Sep 2026 20:20 UTC
- [stated] Ben: "Do three more iterations to go over everything... our goal is to be able to say: This token is a scam. This token is going to go nowhere. This token is going to run a little bit, maybe 1 to 5 million. This token is going to go 5 to 50. This other token is going to be a huge runner."
- [stated] Ben, mid-session, a new hypothesis: **"I believe that wallets are going to play a crucial role here: which wallets are involved in the first minutes, which wallets get involved later. I have no proof of this, but this is definitely something that we should track."**

## THE BASE RATE, measured over 272 complete token lives (`/opt/trench/basecheck.py`)
Peak market cap, ever: p10 $6,658 | p25 $22,485 | **p50 $34,345** | p75 $95,833 | p90 $233,368 | p99 $991,163 | **max $2,011,403**.
How often each of Ben's five buckets is actually TRUE:
| bucket | frequency | n |
|---|---|---|
| nothing, under $100k | **76.1%** | 207 |
| $100k to $1M | **23.2%** | 63 |
| $1M to $5M | **0.7%** | 2 |
| $5M to $50M | **0.0%** | 0 |
| above $50M, huge runner | **0.0%** | 0 |
Biggest ever seen: PONSCUPINE $2,011,403 `0x860412a94963ef92561e79b2f91073e194670125`, BUTTHOLE $1,420,238 `0xbea92c5bffd98972d7b83b8a7e7f6d0209751e18`, AGI $991,163 `0x2dc1c86c568ee003c4a87922546fb2f213ec1e18`.
**So the top two of Ben's five words have never happened on this chain.** Trainable rungs: >=25k 168 positives, >=50k 107, >=100k 65, >=250k 22, >=500k only 5, >=1M only 2, >=5M zero. `LADDER` cut to **[25k, 50k, 100k, 250k]**; anything above is extrapolation and must be labelled as such, never as a learned probability.
`bucket()` no longer multiplies by invented constants. It reads `peak_dist(thr)` live out of our own `lab` table: median peak of tokens that cleared $100k is **$211,518**, p90 **$370,399**, from 46 real examples.

## THE WALLET LAYER, `/opt/trench/wallets.py`, 25 new features (193 total)
**`GET /v1/market/token_top_traders` is the unlock and it is exist auth, weight ~1.** 100 wallets per call, each carrying **`start_holding_at`** (unix seconds of their FIRST buy, so entry minute is a subtraction against `created_ts`), `end_holding_at`, `buy_volume_cur`, `sell_volume_cur`, `netflow_usd`, `profit`, `is_new`, `is_suspicious`, `is_on_curve`, `addr_type`, `wallet_tag_v2`, `amount_percentage`. `token_top_holders` returns the same shape; only one is needed.
- Tables: `wal` (one row per token-wallet-pull), `walrep` (measured track record), `walpair` (wallets that keep appearing together), `walpull`.
- **Two pulls per token**: `early` at 5-12 minutes old, `late` at 65-95 minutes. About 120 calls an hour, roughly 0.03 req/s of the 20 available.
- Reputation is **our own measured hit rate**, not GMGN's smart-money badge: shrunk toward the 23.9% base rate by `(hit + 0.239*3)/(n+3)` so a 1-of-1 wallet does not show a 4x edge.
- **Leak guards, both replayed in `gate.py`**: a roster pulled after a snapshot is invisible to that snapshot (`pull_ts <= as_of`); and reputation is built ONLY from tokens whose 6h window has closed, with the hit measured from the peak AFTER that wallet's own entry, never the token's whole life.
- `walfill.py` backfills rosters for already-closed tokens, which seeded the table in one pass instead of waiting a week.

## THE MISTAKE THIS PASS CAUGHT, and it is the Pons-factory mistake again
The first reputation build ranked as its best wallets: **`0x8366a39cc670b4001a1121b8f6a443a643e40951` (the Uniswap v4 PoolManager singleton), `0x000000000000000000000000000000000000dead` (burn), and `0x6f02324d20cc679d0e585290caa6b16bacbc0f77` (30 tokens, entry minute 0.0, buy volume $0.00)**. A contract holding every pool on the chain is early on every token that runs, so it scored a 2.0x edge that meant nothing. Same class as casig giving the Pons factory five stars.
**Mechanical fix, not a note**: an `INFRA` denylist (full 42-char addresses, reused from `/opt/casig/stars.py`), `MIN_BUY_USD = 25` so a wallet with no dollars spent is not a trader, `addr_type` now stored, and `is_trader()` applied in both `refresh()` and `vector()`. **Gate checks 14-16 fail the build if the PoolManager or a zero-spend wallet is ever rated again.**

## FIRST EVIDENCE FOR BEN'S HYPOTHESIS, after one hour
10,848 roster rows, 5,724 distinct wallets, 219 tokens. **817 wallets rated, 42 with 3+ closed tokens. 71 of the first 522 wallets (13.6%) already appear on more than one token, and 321 wallet PAIRS have shown up together on 2+ tokens.** Repeat early actors are real and measurable; whether they predict is the open question and the report now states it either way.

## OTHER FIXES THIS PASS
- **`store.reconcile()`**: `CREATE TABLE IF NOT EXISTS` silently skips new columns on an existing table, so patch19's four fixed-horizon columns were declared and never created and the labeller died on every insert. `reconcile()` now diffs the declared schema against `PRAGMA table_info` and ALTERs in what is missing, on every open. Every future column add is free.
- **Fixed horizon shipped**: `outcome` gained `peak_6h, peak_24h, ready_6h, ready_24h`; training uses `WHERE o.ready_6h=1` so a row's answer stops moving once settled. Relabel now runs hourly until a token is 48h old.
- **Feature selection**: 193 features against a few hundred rows overfits. `select_features()` ranks by absolute correlation **on the training split only** and keeps `max(8, n/12)`.
- **`created_ts` is the launch; `first_seen` is when we noticed.** Measured across 411 tokens the gap averages **578,607 seconds (6.7 days)**, max 60 days, because the completed column carries old tokens. Ageing by `first_seen` sent the "5 minute" wallet pull at week-old tokens and produced negative entry minutes on 181 of the first 233 rows. Every age filter and every entry-minute baseline now uses `created_ts`.
- **A query grew placeholders and kept its old bindings**: the widened relabel query had 3 `?` and 2 values, killing every labeller pass. Count them mechanically, not by eye.
- Report gained a **wallet panel** that states plainly whether any wallet feature is among the live model drivers. If the hypothesis is not paying for itself, the page says so.

## STATE, 20:17 UTC
`trench.service` active, 41 MB RSS. 41,390 observations, 448 tokens, 752 snapshots and predictions, 132 labels. **193 features, bands 1m/5m/15m/1h/6h, gate 16 of 16 passing.** Still on `rules-v0`; `walrep` needs ~6h of closed tokens before wallet features carry weight.
**Next session, check first**: has a champion been promoted, does the report's wallet panel say the wallet features are carrying weight, and what does `walrep` look like once a full day of closed tokens has landed.

# CORRECTION: I WAS WRONG ABOUT THE BASE RATE, AND BEN CAUGHT IT. 13 Sep 2026 20:45 UTC
- [stated] Ben: "That is absolutely not true. A lot of tokens have gone to 10 million, 20 million, 50 million... We have multiple coins in the hundreds of millions. We have AI, for example, that launched on long.xyz. We have Cashcat, I believe, which launched on Pons... there was one token that I was even in, Pair3x, that went to 7 million... you need to recheck your numbers there."

He was right on every single one. **The previous section's "$5M to $50M: 0.0%, above $50M: 0.0%" is WRONG and is superseded by everything below.**

## What the numbers actually are
`/v1/market/rank` carries **`history_highest_market_cap`**, every token's all time high, handed over directly with no candle arithmetic and no horizon. It also nests TWICE: `{"data":{"data":{"rank":[...]}}}`, and `order_by` must be one of default/volume/swaps/marketcap/holder_count/price ("weight" belongs to `/v1/market/search` and 400s here). Across 326 tokens (`/opt/trench/basecheck2.py`):

| | among tokens that trade | per launch (~6,500/day) |
|---|---|---|
| under $100k | 11.0% | |
| $100k to $1M | 29.4% | reaches $100k: **1 in 1,014** |
| $1M to $5M | 21.2% | reaches $1M: **1 in 1,846** |
| $5M to $50M | **32.5%** | reaches $5M: **1 in 2,877** |
| above $50M | **5.8%** | above $50M: **1 in 19,565** |

p50 $2,488,749 | p90 $31,358,595 | max **$990,116,930**.
Verified individually: PONS `0x39dbed3a2bd333467115de45665cc57f813c4571` ATH $990,116,930 · AI `0x2e8c31162b855a2ffa90f6f8634643ad6f111e18` ATH $432,201,716 (longxyz) · CASHCAT `0x020bfc650a365f8bb26819deaabf3e21291018b4` ATH $320,947,880 (noxa) · MEME `0x385f4f8ae47651ce5f58f5265395a669f8281e18` ATH $174,550,020 · BONER `0x98096d17e191b3da1d5f99a6d7b3584351b11e18` ATH $89,344,941 · PAIREX `0xb8cf4ad387cfd607c66a207cfffc46498aacd9d6` reached $7M, now $718,242.
Launchpads behind tokens above $5M: **pons_v2 45, longxyz 18, pons 9, noxa 9**, pools_trade 4, o1_rwa 3.

## WHY I WAS WRONG, and it was not the arithmetic
Checked directly: **AI, CASHCAT and PAIREX were NOT IN THE DATABASE AT ALL.** The market cap maths was fine. The sample was blind in two independent ways that compound:
1. **DISCOVERY.** The only source was the trenches board, which shows tokens in their first hours. Every big runner had graduated off it before this system existed, so no winner was ever a candidate.
2. **HORIZON.** Outcomes were measured over SIX HOURS on a chain whose runners take days. This is worse than missing them: **it was actively teaching the model that a day-three runner was a failure.**

**The lesson, and it generalises past this project: a base rate computed from whatever the collector happened to see is a measurement of the collector, not of the world.** Always check the sample against a few named cases you already believe in before trusting a distribution. Ben's three names took four minutes to check and overturned the whole conclusion.

## THE FIXES SHIPPED
- **Horizons to 30 days**: `outcome` now carries peak_6h/24h/72h/7d/30d with a ready flag each. `horizon_for(band)` picks the LONGEST horizon with >=60 settled rows, so training answers "does it run inside a day" today and lengthens on its own to 30 days as the table ages, with no constant to retune.
- **Relabelling decays with age** instead of hourly forever: hourly for the first day, every 6h to a week, daily to 30 days. Retention of the numeric rows extended past 30 days to match, or the outcome they were collected for can never be computed.
- **`risers` thread, a second discovery track**: polls `/v1/market/rank` across intervals and sort orders every 15 minutes for what is actually moving, regardless of age. First run pulled in **129 tokens the launch board never showed**, including AI, CASHCAT and PONS. It also writes their `history_highest_market_cap` straight into `lab` as a real outcome.
- **`tok.late=1`** on anything found that way. Late tokens count for base rates, outcomes and wallet reputation, and **can never be training rows** (the trainer joins `COALESCE(tk.late,0)=0`), because there is no honest minute-five feature vector for a token met on day three.
- **LADDER restored to [25k, 100k, 250k, 1M, 5M, 50M]**, `pick_threshold()` still only selects a rung with >=12 positives so it climbs on its own.
- **Wallet BASE_RATE is now MEASURED inside refresh()** from the very rows being aggregated, not typed in. Typing in a second hand-measured constant would have repeated exactly this error.
- **Gate checks 17-21**: a synthetic token sits flat two days then does $40M on day three. The 6h and 24h answers must be small (they were flat then), the 72h answer must be the full $40M, and an unclosed horizon must never read as a settled zero. 21 checks, all passing.

## Bugs hit while shipping this, all in the same family
- **`store.reconcile()` split a SQL comment on its commas** and tried `ALTER TABLE tok ADD COLUMN with REAL`. Now strips `--` and `/* */` before parsing and refuses any column name that is not a plain identifier.
- **`tok.late` was declared by a regex that did not match the table** (it ends `pad2 TEXT);` on one line). Two threads referenced a column that did not exist. Anchor on exact text, never on a pattern, when patching a schema.
- Gate check 13 went red the moment reputation started ageing by `created_ts`: **the fixture was wrong, not the code.** That is the gate doing its job.

## STATE, 20:48 UTC
75,950 observations, 652 tokens (129 late-discovered), 1,077 snapshots and predictions, 276 labels, 73 MB RSS, 193 features, gate 21 of 21. Wallet layer: **24,757 roster rows, 14,539 wallets, 531 tokens, 1,380 rated.**
**Known remaining bias, stated rather than hidden**: the wallet sample now over-represents winners, because the 129 late-discovered tokens are by construction the ones that ran, which is why the measured wallet base rate reads 54.4% and the best lift is only 1.48x. It will correct itself as ordinary tokens close. The honest fix later is a within-cohort comparison rather than one global base rate.
