Files
gmgn-skills/skills/gmgn-portfolio
02ebdf427c feat(wallet-analysis): add four-gate wallet copy-trade dossier skill (#207)
* 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>
2026-08-27 19:05:14 +08:00
..