mirror of
https://github.com/GMGNAI/gmgn-skills.git
synced 2026-11-02 07:46:11 +00:00
* feat(wallet-analysis): add four-gate wallet copy-trade dossier skill Answers "should I copy this wallet, and what happens to me if I do?" with four independent pass/fail gates instead of a blended score: authenticity, currency (is the edge still working this week), reachability (can you get filled), survivability (does it cut losses). Each gate prints the single number that decided it, and the verdict is a pure function of the gates. analyze.py is read-only — portfolio stats/profits/activity/holdings/ created-tokens only, no signing. gen_fixtures.py builds eight synthetic wallets, each engineered to fail exactly one gate, so analyze.py can be verified offline with no API key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): stop letting third-party labels decide the verdict A live run on 0xa7d4ffc4eca3c71af150ce302560a9d04a1d2b9f returned `🔴 别碰` on a wallet with $459K of genuine realized profit. Two independent false positives caused it, and both were refutable from data already present in the same response. - `wash_trader` now has to be corroborated before it can veto G1. `conviction_share` measures the fraction of realized gains coming from positions that netted more than their own cost basis or cleared $1k per exit — self-dealing nets ~0 per round trip and cannot reach either bar. At >=50% the tag is demoted to a visible caution carrying the number that refuted it; below 50% G1 fails and prints the share; unmeasurable (no holdings) is now G1 ⚪, never a manufactured ❌. - `token.is_honeypot` is refuted by `history_total_sells > 0` on the same row. Seven "honeypots" failed G4 on one wallet; it had sold one of them 101 times. They were transfer-restricted tokenised-stock contracts. - Position scale is read from holdings, not from clip size. A wallet laddering a $54K position out of $3.4K clips was labelled "ordinary trading wallet" on `avg_buy_usd` alone. Report changes, from review feedback: - Identity moves above the gates and prints the full `x.com/<handle>`; a missing X account is stated rather than silently omitted. - New profit-engine attribution (撒网命中 / 周转磨利 / 选币重仓 / 分散积累) from trade cadence x gain concentration. A return figure alone does not say whether the edge is speed or selection, and the two are copied in completely different ways. - Verdict layer rewritten for plain language: a verb the reader can act on, everyday words for the cause, one imperative sentence for the action. `token_num < 5` is ⚪, not 🔴 — unmeasured is not measured-bad. G3 ❌ no longer short-circuits G4 ❌; both problems get stated. `put()` now budgets for the wider of prefix and `hang`, closing the one way a caller could exceed the column limit while still going through it. Fixtures 9 -> 12: `tagged-not-washing`, `thin-sample`, `unverifiable-wash`, `unreachable-and-no-cut`. The wash_trader tag now has three fixtures covering corroborated / uncheckable / refuted; a change that makes any two agree is a regression. All 11 verdict branches are covered. 12 fixtures x 2 languages, zero lines over the width limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(portfolio): correct holdings/activity field names against live API The documented names for `portfolio holdings` and `portfolio activity` did not match gmgn-cli 1.5.8, and are not accepted as aliases. This is where gmgn-wallet-analysis got its original field names from, and why its first cut read zeros off a live wallet: a wrong name returns `undefined`, which becomes 0, which renders as "this wallet made nothing" — an error that looks like an answer. Corrected against real responses: holdings array -> `list` cost -> `accu_cost` profit_change -> `total_profit_pnl` buy_tx_count / sell_tx_count -> `history_total_buys` / `history_total_sells` token.address -> `token.token_address` (that name is activity's) transaction_hash -> `tx_hash` type -> `event_type` Also: `avg_cost` is not returned (derive from `accu_cost / balance`), and `--sell-out` does not exist — gmgn-cli rejects it as an unknown option. Adds a response-envelope table, since the five routes do not share a shape: stats is bare, profits wraps a single row in `{"list":[…]}`, activity uses `activities`, holdings uses `list`, created-tokens puts rows under `tokens`. Documents fields that were missing entirely and are load-bearing for wallet analysis: `token.is_honeypot` (noting that `history_total_sells > 0` on the same row contradicts it — a honeypot cannot be sold), `token.launchpad_platform`, `token.liquidity`, `buy_cost_usd` (a sell row's cost basis), the gas/fee fields, and the realized/unrealized pnl split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(wallet-analysis): merge the style-label layer into the dossier The dossier had numbers, gates and a verdict but no name a reader could repeat out loud. The 4x5 title grid (frequency x P&L) and the speed subtitle come in from gmgn-wallet-style, with four corrections made on the way, each because the original mis-labelled a wallet we had already cross-checked against gmgn.ai's own leaderboard. - No ⭐「官方认证」badge. It fired on any non-empty `common.tags`, so it rendered `wash_trader` under a commendation glyph — the exact thing this skill's tag rules forbid. Tags keep going through TAGS/severity and the corroboration check. - The speed subtitle reads the median copy window, not `avg_holding_period`. The mean counts bags never sold, so it called a 2-minute scalper 「波段流 1-7 天」; someone acting on that holds for days a position the wallet exits in minutes. - P5 needs ROI > 50% plus ONE of {win rate >= 50%, heavy-loss share < 15%}, not all three. Requiring all three demoted a wallet sitting at #3 on GMGN's BSC 7D leaderboard to P4: memecoin P&L is low-hit-rate with a fat right tail, so a 50% win-rate gate penalises exactly the wallets that work. The `token_num >= 5` floor on P5 is kept. - The P5 gloss names the corroborator that carried it, so 「强盈」 is never a bare claim. 「高频且高胜率低回撤」 above a 33.3% win rate was a heading contradicting its own contents. Also: no label at all when `token_num < 5` — the verdict already says ⚪ 看不出来, and 「稳步选手 · 正收益,无明显短板」 beside it contradicts it. Badges merged: 彩票型, 分批止盈, 集中押注, 固定时段. The last two are gated on sample size, which the original was not: top-3 of 3 tokens is 100% by arithmetic, and any sample spanning under 12 hours "clusters" inside a 6-hour window by arithmetic. The original fired both on a 14.2-hour sample and emitted no warning. Not merged: 囤积中 / 派发中 duplicate the existing 24h posture reading, and 抢跑党 needs a per-chain gas constant that would go stale silently — gas_share and gas_drag already carry that signal, normalised against the wallet's own trade size. 12 fixtures x 2 languages, 8 distinct grid cells exercised, zero lines over the width limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(wallet-analysis): sync trigger words with the merged style layer The description still said the skill gives "a decision rather than a label or a score" — as of the style merge it gives both. That routed 「这个钱包什么风格」 to gmgn-wallet-style, which returns a strict subset: the same title and subtitle without the four gates, the profit engine, or the size cap. Adds the style trigger phrases, and states that a bare wallet address defaults here rather than being contested between the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(wallet-analysis): English-only source, translations in lang/zh.json analyze.py carried every user-facing string twice, as `_(zh, en)`, which made Chinese prose part of the program: 250 call sites, two tables holding both languages inline, and seven `if ZH` branches picking a list separator. Adding a third language meant editing every line of the file, and CLAUDE.md's "SKILL.md is English only" rule had quietly stopped holding. English is now the source of truth. `T("english template", *args)` looks the string up in `lang/<code>.json` and falls back to English when the key is absent, so a partial or missing translation degrades to English rather than to a crash or a blank line. Templates use positional placeholders ({0}, {1}) so a translator can move a value that reads in a different position in their language. - 247 call sites rewritten mechanically via the AST, so the English text and its interpolations are preserved exactly. - TAGS (20 rows), TITLES (20 cells), GATE_NAMES and GATE_GLOSS reduced to English-only; their Chinese moved to the table. - The 7 `"、".join(...) if ZH else ", ".join(...)` branches collapse into joinsym(), keyed on an explicit `__list_separator__` rather than on ", " — too generic to be safe as a table key. - The `ZH` global is gone; `load_lang(code)` replaces it. - Four call sites that concatenated conditionals into a sentence were restructured first, so every string is one whole translatable template rather than a sum of fragments. Two real bugs fell out of the conversion: - `archetype()` returned a rendered fallback string that two callers then sniffed with `startswith("普通") or startswith("ordinary")`. A language-dependent test inside language-agnostic code, correct only because both translations had been checked by hand. It now returns [] and each caller supplies its own fallback. - An English template carried full-width CJK brackets around a tag name; now plain quotes. This is the one intentional output change. Verification: all 12 fixtures x 2 languages were snapshotted before the refactor and diffed after every step. Output is byte-identical apart from the bracket fix and one fixture's romanised twitter_name. Deleting lang/zh.json renders clean English, confirming the fallback path. SKILL.md and gen_fixtures.py are English throughout. Two Chinese fragments are deliberately kept in SKILL.md and annotated as such: the trigger phrases in `description:` and the 「CA」/「合约」/「代币」 list in Step 1 are things a user literally types, and translating them would stop the skill triggering on Chinese input. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(wallet-analysis): stop committing generated fixtures gen_fixtures.py rebuilds all 12 fixtures deterministically and is the single source of truth for what each one is meant to test. Committing the generated JSON next to it lets the two drift apart silently: a reviewer reading the generator cannot see that the checked-in copy no longer matches, and every threshold change produced a diff in 12 files that carried no information. Verified regenerable: deleting fixtures/, re-running gen_fixtures.py, and diffing all 12 fixtures x 2 languages against the pre-deletion snapshot gives 24/24 byte-identical output. lang/zh.json stays committed — it is not generated, and without it analyze.py has no Chinese output at all. The .gitignore says so, so the distinction is not left to be re-derived. The verification snippets in SKILL.md now run gen_fixtures.py first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): measure emoji width correctly and align the report dwidth() was wrong about emoji in both directions, and every column in the report inherited the error. ⚙️ U+2699 + U+FE0F measured 3, renders 2 🕸️ U+1F578 + U+FE0F measured 4, renders 2 ⚡ ⚪ ✅ U+26A1/26AA/2705 measured 1, renders 2 The variation selector was counted as two columns and the U+2600-27BF emoji as one. The first padded every column they appeared in short; the second is the dangerous direction — lines carrying those glyphs could exceed COL in a real terminal while the width check called them safe. The rule is that a base character followed by U+FE0F renders two columns wide, which is what the selector means, so this needs no enumeration of `⚙ ⚔ ✂ ✈ ...`. Box drawing (U+2500-257F) stays one column: the report's own rules and bars are built from it. wrap() had the same disagreement in a worse place: it summed cwidth per codepoint, so `⚔️` measured 1 there and 2 in dwidth(). Two such glyphs on a line put it 2 columns over — the English `grinder` speed read reached 78. Both now apply the same pairing rule. Layout, all of it alignment rather than taste: - The label column is measured, not hardcoded to 10. "key numbers" is 11 columns, so that row went unpadded and its continuation lines sat a column off from every other row in the block. - WHO IT IS is one label grid instead of three indents (style at 10, its gloss at 13, identity and every badge at 2). It read as a wall, and each badge took a line of its own; they now share one wrapped row. - Risk-flag and reassurance continuations hang under their text. At the bullet's own indent a wrapped flag read as a second flag. - A wrap never splits an emoji from the phrase it labels — lines used to end in a bare "✂️" with its name orphaned below. Tracked as a flag rather than read back from the buffer, because an emoji sequence ends in a zero-width selector whose base may not be wide on its own. - The "→" row's continuations hang under the arrow's text. 12 fixtures x 2 languages, all 24 outputs within 76 columns measured with the corrected width. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(wallet-analysis): ship three files, drop the dev-only ones The shipped skill is SKILL.md + analyze.py + lang/zh.json. gen_fixtures.py and .gitignore are removed: neither is reachable at runtime, and the .gitignore existed only to ignore the fixtures/ that gen_fixtures.py produced, so the two go together. This is a real loss, stated plainly rather than glossed. gen_fixtures.py was the only offline verification this skill had — twelve synthetic wallets covering all eleven verdict branches, the three-way wash_trader matched set (corroborated / uncheckable / refuted), and the column-width rule. It needed no API key, and it is what proved the English-only refactor behaviour-preserving across 250 rewritten call sites. Without it every change to analyze.py has to be checked against live wallets, where the data moves between runs and the route budget rate-limits after a handful of attempts. It stays recoverable in git history on this branch. SKILL.md's Verification section now says so and documents how to verify by hand, instead of describing a suite that is no longer here. The width-check snippet in SKILL.md was also carrying the naive `sum(2 if ord(c) > 0x2E7F else 1 ...)` formula — the exact bug fixed in the previous commit, which reports a 78-column line as safe. It now imports dwidth() from analyze.py so the check and the renderer can never disagree again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): report why holdings failed, not one guess Every `holdings` failure was reported as "needs GMGN_PRIVATE_KEY / critical auth", whatever the cause. Observed live several times in a row: the dossier told the reader to go and configure a credential that was already configured, while the real cause — a rate-limit refusal — went unmentioned. Wrong instruction, and it hid the actual reason. Now split three ways. A limiter refusal says so and says to re-run; an auth failure keeps the credential wording; anything else is reported verbatim rather than guessed at, because a wrong attribution sends a reader down the wrong path more expensively than no attribution does. This matters beyond the limiter. `holdings` can fail on a network error, an auth change or a renamed field, and each of those would have been labelled a missing private key. Verified against all three branches offline (429 / missing key / 401 / network), with the Chinese placeholders substituting correctly in each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(wallet-analysis): lead with a decision card, keep the evidence below it Two audiences were fighting over the same screen. Someone who pasted an address needs to stop reading after a few lines; whoever checks the work needs every number. Evidence-first served neither, and the review feedback was blunt about it: too wordy, hide the judgement logic, give conclusions. The report now opens with a decision card and puts the reasoning after it. `--brief` prints the card alone; the default prints both. Nothing is removed — the entire previous output is the second layer, byte-identical (verified across 12 fixtures x 2 languages). The headline is money rather than a ratio: "$1,000 -> $1,621" instead of "+62.1%". Same figure, no conversion asked of the reader, and no extra API call — it is roi_7d wearing clothes a newcomer already owns. Hiding the reasoning obliges the card to be MORE careful than the evidence layer, not less. Five rules, each of which the first cut broke: - The four marks read the gates. They were hardcoded to ✓, so a "🔴 DO NOT COPY" card carried "✓ the record is real" — asserting the opposite of its own headline, on the very check that caused it. - No headline figure when G1 fails. The card showed "$1,000 -> $1,122" for a wash-trading wallet; G1 failing means the P&L is the thing in dispute, so quoting it presents a disputed number as an achieved one. - No "how to follow" under a red verdict. It printed a size cap and a copy window beneath DO NOT COPY — directions for doing the thing the headline forbids. - No editorial the flags contradict. "Not a wallet that only churns" appeared on a card flagged for wash trading. - No card at all when any gate is ⚪. The card has nowhere to put "not measured", and one missing tick reads as a complete verdict with one fewer reason. card_blocked() withholds it; the evidence layer, which can print ⚪, carries the whole answer. Also fixes a translation-key collision this change walked into: the card's gate label reused the key "cuts losses", which the numbers panel's win-rate chip already owned, silently rewriting that chip in eight fixtures. The table has one slot per English string; the card's label is keyed "it cuts losses" and SKILL.md now warns about the hazard. usd_exact() exists because usd() renders the headline as "$1.0K -> $1.6K", which both rounds away the thing being demonstrated and returns to the abstraction the card was built to avoid. The size cap uses it too — that number gets typed into a swap. Verification: evidence layer byte-identical across all 24 fixture/language combinations; 48 runs (both modes) within the 76-column limit; card withheld on all four fixtures with an unmeasured gate; both polarities inspected by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(wallet-analysis): put cadence, return and amount on the card Reviewing the card against the fields the old first page carried turned up three numbers that nothing else on the card implies: - trades per day — 16/day and 725/day are the difference between something a person can do and something only a script can. The copy window hints at it; it does not say it. - the 7d return as a percentage — the figure people actually quote to each other. The money headline replaced it as the hook, not as the number. - the realized amount — the only thing on the card that shows the scale THIS wallet works at. The size cap describes the reader's size, not its. Everything else the old page 1 carried is unchanged in the evidence layer, which remains byte-identical across all 24 fixture/language combinations. Adding the line walked straight back into the defect fixed one commit ago, one line lower down: the card said "its profit figures are not trustworthy" and then printed "7d 12.2% · made $101.1K". Trade cadence is a fact about behaviour and survives a failed G1; a return and an amount are P&L claims and do not, so they are dropped there. The key-collision guard added last commit earned its place immediately — it refused `7d {0}`, already owned by the numbers panel's form curve, which would have silently rewritten that row the way `cuts losses` did. The card's keys are `{0} over 7 days` and `made {0} in that week`. Verified on the live wallet 0xa7d4…2b9f: 5.4s, zero data gaps, all four gates measured, card rendered. A prior run of the same wallet was rate-limited into two unmeasured gates and correctly produced no card at all rather than one with four green ticks over an unverified P&L. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): reconcile the win rate with the outcome chart The report printed two numbers that contradict each other and left the reader to notice. On a live wallet: 188 of 209 tokens in the 0-200% band, next to a 23.9% win rate. If that band meant "gained 0-200%", the win rate would be 90%. Checked against the raw response rather than guessed at: winrate 0.2388 -> about 50 of 209 tokens pnl_0x_2x_num 188 sell 48 50 winners against 48 sells in the window. The only reading that satisfies both numbers is that the 0-200% band absorbs every token with no realized result yet — bought, not yet sold, so realized ROI is 0, which sits on that band's lower edge. Its size is therefore not a count of wins. Left alone, the chart actively misleads: a full-width green bar over 188 tokens makes a 24%-win-rate wallet look like it wins nine times out of ten, and a reader who spots the clash concludes one of the two figures is broken. Now stated in both places a reader meets it: - under the chart, naming the arithmetic — how many the band holds, how many the win rate implies, and how many are simply unsettled - in the win-rate row, which gives the realized-winner count instead of a bare percentage over a denominator that means something else Gated on >=20 tokens and the gap being >=25% of the band, so a wallet whose two figures already agree gets no note. One of twelve fixtures triggers it — the one modelled on this wallet. Verified: ignoring whitespace, the only substantive change to the evidence layer is the win-rate row. Three neighbouring panel rows shift by a column because the panel is column-aligned and that cell got longer. 48 runs within the width limit. This is the skill's own "reconcile contradictory numbers rather than printing both" rule, which the distribution chart had been violating since it was added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): use the fees actually paid, and make --size do something Two defects found by auditing what the skill computes against what it shows, and what the API returns against what it reads. **Friction was estimated while the real figure sat unread.** `portfolio stats` reports `bought_fee` and `sold_fee` for the window. Both were ignored. Friction was inferred instead from the gas median of a 300-row activity sample multiplied by the trade count — a sample-based estimate standing in for an exact number in a response already in hand. On 0xa7d4…2b9f the gap is not subtle: estimated gas $0.02 avg -> "0.0% of profit, friction manageable" actual bought_fee $2,924 + sold_fee $1,484 = $4,408 against $167,237 realized = 2.6% Two orders of magnitude, and the report was asserting "friction manageable" off the wrong one. It now uses the paid fees where the fields exist, keeps the sample estimate as the fallback where they do not, and labels which of the two it is showing — the estimate now reads "(estimated)" rather than passing as measurement. This feeds G3, whose reachability test fails at >=25% of profit, so the gate is now decided by the real number too. No fixture changes verdict: they carry no fee fields and take the fallback path. **--size accepted a value and did nothing with it.** It was parsed, stored on m["my_size"] and never read again — a silently inert flag. It has an obvious job, since size_cap already encodes the rule: above the wallet's own clip your fills are worse than the ones its record was built on, so its results stop describing you. Passing --size now states the multiple in the card's action block and in the numbers panel: the $5,000 you asked about is 11.7x its own clip of $427 — at that size your fills are worse than the ones this record was built on Nothing prints when the flag is absent. The flag is documented in SKILL.md, which had never mentioned it. Verified: across 24 fixture/language runs the only line that changes is the friction row, and only by gaining the "(estimated)" label — every percentage is identical. Neighbouring panel rows shift a column because that cell got longer. All runs within the width limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(wallet-analysis): surface four figures that were computed and discarded The audit that found the friction defect also found four things the skill either computed or received and then never showed. Each answers a question a reader asks out loud and the report could not previously answer. native_balance — the dry powder, in the chain's native token. GMGN's own leaderboard puts it in column two; this fetched it and dropped it. Now on the provenance line: "159.1 BNB on hand". last_timestamp — freshness. Every other figure in the report is silent about whether the wallet is still trading, so a dossier on a wallet that went quiet three days ago read exactly like one on a wallet trading right now. Now stated under the 24h posture, and past 48 hours it says so plainly: "last trade 5.0 days ago — every figure here describes a wallet that has since gone quiet". realized_all / unrealized — the all-time figures existed only as a percentage. "How much has this wallet ever made" is the most natural question there is, and the amount was computed and thrown away. New panel row: "all time $1.90M realized · $4.0K on paper". entry_sub100k / accum_window_s — both computed, neither shown. The first turns G3's "median entry $20K" into "…and 100% of its entries are under $100k", which is the stronger statement. The second gives the ladder badge a duration: asserting a wallet ladders in on a buy COUNT alone never said whether that happens over minutes or over days. Of 98 metrics, 11 were computed and never read. Four are now shown; the remaining seven (pcr_source, tag_sev, follow_count, gas_share, dev_ath_mc, realized_1d, my_size's raw form) are internal bookkeeping or genuinely redundant. Verified across 24 fixture/language runs: every changed line is either the friction row or G3's entry row, which are exactly the two this and the previous commit touch. 48 runs including --brief stay within the width limit. The new fields degrade silently when absent — fixtures carry no native_balance or last_timestamp and print neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(wallet-analysis)!: emit native markdown instead of terminal columns The skill printed column-aligned terminal text and every markdown version of it in this project was produced by hand afterwards. That is a defect with two edges: what an agent pipeline receives is not what a reader was shown, and SKILL.md's own "paste stdout verbatim" instruction could not be followed by anyone who wanted readable output. analyze.py now emits markdown directly — headings, tables, blockquotes, bold. Same two layers, same content, same metrics; only the renderer changed. The entire monospace grid is deleted with it: COL, BREAK_AFTER, wrap(), put(), wpad(), dwidth(), cwidth(), ZERO_WIDTH, WIDE_RANGES, WIDE_SYMBOLS and is_emoji_cp(). Roughly 120 lines existed to pad columns with spaces to a 76-display-column budget, and they kept producing a bug class of their own: a variation selector measured two columns while its emoji measured one, and wrap() and dwidth() disagreed by two, putting a line past the limit while every check called it safe. Markdown tables align themselves, so none of it is needed and none of it can rot. Verification changed shape accordingly. The old check counted display columns; the new one checks that the markdown is valid — every table has a delimiter row and a constant column count, `#` is followed by a space, bold markers pair, cell values are escaped so a pipe in a symbol cannot split a row, and the only raw HTML is the `<br>` used inside table cells. Clean across 48 runs (12 fixtures x 2 languages x with/without --brief). SKILL.md carries the checker. Two things the conversion surfaced and fixed: - "RISK FLAGS (2)" carried three bullets, because the reassurances were appended to the same list. They now sit under their own CLEARED heading, so the count matches its contents. - "0.0% of entries under $100k" printed a zero as if it were a finding. Guarded at the call site. SKILL.md's paste-verbatim rule now says what it means: do not reformat the output into your own tables, because that is exactly how the two drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): translate the five strings the markdown rewrite reworded The markdown renderer rephrased five strings — adding bold markers, a table header — and a reworded string is a new key. All five fell back to English mid-report, so the Chinese output carried "50 positions · $14.5K total", "24h bought / sold", "profit concentration ..." and "live book: unavailable" verbatim. Nothing failed: T() falls back to English by design, which is right for a missing translation and silent for a regressed one. Also splits the holdings table's column key. It used `value`, which the numbers panel already owns, and the table has one slot per English string; it is now `market value`. Same collision class as `cuts losses` before it, caught this time by checking before writing. Adds a completeness check to SKILL.md — walk the AST for literal T() keys and diff against the table. And a warning not to run that check in reverse. Using it to prune "unreferenced" entries deleted live translations here: several strings reach T() through a variable and the scan cannot see them — the TAGS, TITLES, GATE_NAMES, GATE_GLOSS and GATE_PLAIN tables, plus the holdings sentence built as a conditional expression. Unused entries are harmless bloat; a deleted live one breaks exactly one language. Verified on the live wallet: no English leaks in the Chinese report, and 48 fixture/language/mode runs still emit valid markdown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): drop empty-header tables and give the report an H1 Three layout defects in the markdown output, all visible the moment it is rendered rather than read as source. **Four tables had an empty header row.** The speed read, the identity block and the 24h buy list are key/value pairs, not tabular data, and md_table() emitted `| | |` for them — which renders as a blank header band above the content and reads as a broken table. They are definition lists: `- **label** — value` says the same thing with no empty band. The outcome distribution genuinely is tabular and now has real headers instead of blanks. **`<br>` leaked out of its table cell.** It existed so a cell could hold two lines; once those blocks stopped being tables it rendered as literal text in the middle of a sentence. A continuation line indented two spaces stays inside its list item and needs no tag, so the output now contains no raw HTML at all and the checker rejects every tag rather than allowing one. **The document had no H1 when the card was withheld.** With a card the report opens `# verdict` … `# EVIDENCE`; without one it opened at `##`, so nothing was the top level. The verdict heading is now `#` when there is no card above it. The checker in SKILL.md gains all three: empty header rows, any raw HTML, and a document whose first heading is not `#`. Clean across 48 runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): label each gate once, and fix the heading hierarchy Readability pass on the markdown output. **A gate was labelled three times.** `✅ G1 真实性(数据可信吗)` carries an internal code, a formal noun and a plain-language gloss, all naming the same test. The code is bookkeeping that belongs in the source; the formal noun ("AUTHENTICITY") is not the question a reader is asking. The gloss is — so the gloss is now the whole label, and it reads as the question it always was: ### ✅ 数据可信吗 ### ✅ 现在还在赚吗 ### ✅ 你吃得到吗 ### ✅ 它会砍仓吗 The section heading also carried a `⚪G1 ✅G2 ❌G3 ✅G4` strip that restated the four marks appearing immediately below it. Gone. **Gates were bold paragraphs, not headings**, so four of them in sequence had no hierarchy and did not appear in any outline. They are `###` under their section now. **Heading levels skipped.** The card's own sections were `###` directly under a `#`, which reads as a missing level in any outline view. They are `##`. The distribution moves from `##` to `###` because it is a footnote to the numbers panel rather than a peer of it — eight top-level sections in the evidence layer was more navigation than the content needs. The checker in SKILL.md gains a level-sequence walk, so a future skip fails rather than being noticed by eye. Clean across 48 runs in both languages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): explain the size cap on every path, not just one The size cap's derivation lived on the `p["G3"] is True` branch of actions(), so it printed only when reachability passed. The run that most needs it is the other one: G3 fails, the reader is told not to mirror the wallet, and the card still shows "start no larger than $132" — a bare number sitting next to "do not copy", with the reasoning nowhere on the page. Sizing and reachability answer different questions. Gating one on the other was a control-flow mistake, not a missing sentence: the text existed and was unreachable exactly when it mattered. It now prints whenever a cap exists, worded so it holds under "do not mirror" as well: "keep it at or under $132 — half of the $263 this wallet puts on a buy. Past its own size your fill is worse than the ones its record was built on, so its numbers stop describing you." The quote-before-sending line stays on the G3-pass branch, since that one is genuinely about being able to get filled. Worth recording what this was not: the report DOES explain why the copy window uses a median rather than the mean — the hold_conflict note says the mean is dragged up by bags never sold. Both numbers looked unexplained; only one was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wallet-analysis): issue the decisive call first, not last A full dossier costs weight 26-28 against a rate-limit bucket of 20, so on a cold-ish bucket something gets refused. `holdings` was issued last, at cumulative weight 26, which made the most decisive call the guaranteed casualty — and it is decisive three times over: G1's corroboration of a `wash_trader` tag, G4's honeypot check and the profit engine all read it. Lose it and the verdict falls to HOLD OFF on a wallet whose tag was in fact refutable. Observed on five consecutive live runs of the same wallet, across waits of 280s, 290s, 400s and 420s. Waiting does not fix it: one run cannot fit in one bucket, so the tail is refused whatever the starting level. The gate-critical set now goes first and fits inside one bucket: stats_7d(3) -> profits_all(3) -> holdings(5) = 11 verdict decidable activity(3 x 3 = 9) = 20 window, entry band stats_30d(3), profits_1d(3) = 26 depth only profits_all ranks above the two windows because G2 is unevaluable without it, while stats_30d and profits_1d only add depth to readings that already exist — which makes them the correct things to lose. Same routes, same parameters, same page count: only the sequence moved. All 24 fixture/language outputs are byte-identical across the change, which is the claim that matters — no threshold, formula or verdict row is touched. This is the ordering half of the closed #2. The pacing and caching in that PR were rejected on the grounds that the API being integrated does not rate-limit; the ordering costs nothing either way and fixes the failure that is happening today against gmgn-cli. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: chenjunfeng <chenjunfeng@gmgn.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>