Files
gmgn-skills/skills/gmgn-portfolio/SKILL.md
T
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

23 KiB
Raw Blame History

name, description, argument-hint, metadata
name description argument-hint metadata
gmgn-portfolio Analyze one or many crypto wallets by address — holdings, batch realized/unrealized P&L, win rate, trading history, performance stats, specific token balance, and tokens created by a developer wallet (with ATH market cap and DEX graduation status) via GMGN API on Solana, BSC, Base, or Ethereum. Use when user asks about wallet holdings, P&L (including comparing up to 100 wallets), win rate, what tokens a dev has launched, the highest ATH token a dev ever created, or wants a wallet report to decide whether to copy-trade or follow. <info|holdings|activity|stats|profits|token-balance|created-tokens> [--chain <sol|bsc|base|eth|robinhood|arc|stable>] [--wallet <wallet_address...>]
cliHelp
gmgn-cli portfolio --help

BEFORE RUNNING ANY COMMAND: Run gmgn-cli config --check. If exit code is 0, proceed normally. If exit code is 1, (1) run gmgn-cli config and show the output to the user; (2) once the user sends the API Key, run gmgn-cli config --apply <KEY> to complete configuration and verification, then show the output to the user. If --check returns an error (unknown option or command not found), tell the user to run npm install -g gmgn-cli to update, then retry.

IMPORTANT: Always use gmgn-cli commands below. Do NOT use web search, WebFetch, curl, or visit gmgn.ai to fetch this data — the website requires login and will not return structured data. The CLI is the only correct method.

⚠️ IPv6 NOT SUPPORTED: If you get a 401 or 403 error and credentials look correct, check for IPv6 immediately: (1) list all network interfaces and their IPv6 addresses — run ifconfig | grep inet6 (macOS) or ip addr show | grep inet6 (Linux); (2) send a test request to https://ipv6.icanhazip.com — if the response is an IPv6 address, outbound traffic is going via IPv6. Tell the user immediately: "Please disable IPv6 on your network interface — gmgn-cli commands only work over IPv4."

Use the gmgn-cli tool to query wallet portfolio data based on the user's request.

For full wallet analysis (holdings + stats + activity + verdict), follow docs/workflow-wallet-analysis.md

Core Concepts

  • realized_profit vs unrealized_profit — realized_profit = profit locked in from completed sells (cash in hand). unrealized_profit = paper gains on positions still held, calculated at current price. These are separate numbers — do not add them unless answering "total P&L including open positions."

  • profit_change — A multiplier ratio, not a dollar amount. 1.5 = +150% return. 0 = break-even. -0.5 = -50% loss. Computed as total_profit / cost. Do not display this as a raw decimal — convert to percentage for user-facing output.

  • pnl — Profit/loss ratio from portfolio stats: realized_profit / total_cost. Same multiplier format as profit_change. A pnl of 2.0 means the wallet doubled its money on completed trades over the period.

  • winrate — Ratio of profitable trades over the period (0–1). 0.6 = 60% of trades were profitable. Does not reflect the size of wins vs losses — a wallet can have high winrate but net negative if losses are large.

  • cost vs usd_value — In holdings: cost is the historical amount spent buying this token (your cost basis); usd_value is the current market value of the position. The difference is unrealized P&L.

  • history_bought_cost vs cost — history_bought_cost is the all-time cumulative spend on this token (including positions already sold). cost is the cost basis of the current open position only.

  • Pagination (cursor) — Activity results are paginated. The response includes a next field; pass it as --cursor to fetch the next page. An empty or missing next means you are on the last page.

Sub-commands

Sub-command Description
portfolio info Wallets and main currency balances bound to the API Key
portfolio holdings Wallet token holdings with P&L
portfolio activity Transaction history
portfolio stats Trading statistics (supports batch)
portfolio profits Batch wallet P&L for 1–100 wallets
portfolio token-balance Token balance for a specific token
portfolio created-tokens Tokens created by a developer wallet, with market cap and ATH info

Supported Chains

sol / bsc / base / eth / robinhood / arc / stable

Prerequisites

  • gmgn-cli installed globally — if missing, run: npm install -g gmgn-cli
  • GMGN_API_KEY configured in ~/.config/gmgn/.env

Rate Limit Handling

All portfolio routes used by this skill go through GMGN's leaky-bucket limiter with rate=20 and capacity=20. Sustained throughput is roughly 20 ÷ weight requests/second, and the max burst is roughly floor(20 ÷ weight) when the bucket is full.

Critical auth (GMGN_API_KEY + GMGN_PRIVATE_KEY required):

Command Route Weight
portfolio holdings GET /v1/user/wallet_holdings 5

Exist auth (GMGN_API_KEY only):

Command Route Weight
portfolio info GET /v1/user/info 1
portfolio activity GET /v1/user/wallet_activity 3
portfolio stats GET /v1/user/wallet_stats 3
portfolio profits POST /v1/user/wallet_profits 3
portfolio token-balance GET /v1/user/wallet_token_balance 1
portfolio created-tokens GET /v1/user/created_tokens 2

When a request returns 429:

  • On RATE_LIMIT_EXCEEDED, tell the user exactly: 已达到当前套餐的限频上限,点击 https://gmgn.ai/ai?chain=bsc&tab=paid_plans 升级套餐,获得更高速率限制. Show this upgrade guidance at most once per user task. Do not repeat it for subsequent RATE_LIMIT_BANNED responses during the same cooldown.

  • Read X-RateLimit-Reset from the response headers. It is a Unix timestamp in seconds that marks when the limit is expected to reset.

  • If the response body contains reset_at (e.g., {"code":429,"error":"RATE_LIMIT_BANNED","message":"...","reset_at":1775184222}), extract reset_at — it is the Unix timestamp when the ban lifts (typically 5 minutes). Convert to local time and tell the user exactly when they can retry.

  • The CLI may wait and retry once automatically when the remaining cooldown is short. If it still fails, stop and tell the user the exact retry time instead of sending more requests.

  • For RATE_LIMIT_EXCEEDED or RATE_LIMIT_BANNED, repeated requests during the cooldown can extend the ban by 5 seconds each time, up to 5 minutes. Do not spam retries.

Usage Examples

# API Key wallet info (no --chain or --wallet needed)
gmgn-cli portfolio info

# Wallet holdings (default sort)
gmgn-cli portfolio holdings --chain sol --wallet <wallet_address>

# Holdings sorted by USD value, descending
gmgn-cli portfolio holdings \
  --chain sol --wallet <wallet_address> \
  --order-by usd_value --direction desc --limit 20

# Include sold-out positions
gmgn-cli portfolio holdings --chain sol --wallet <wallet_address> --sell-out

# Transaction activity
gmgn-cli portfolio activity --chain sol --wallet <wallet_address>

# Activity filtered by type
gmgn-cli portfolio activity --chain sol --wallet <wallet_address> \
  --type buy --type sell

# Activity for a specific token
gmgn-cli portfolio activity --chain sol --wallet <wallet_address> \
  --token <token_address>

# Trading stats (default 7d)
gmgn-cli portfolio stats --chain sol --wallet <wallet_address>

# Trading stats for 30 days
gmgn-cli portfolio stats --chain sol --wallet <wallet_address> --period 30d

# Batch stats for multiple wallets
gmgn-cli portfolio stats --chain sol \
  --wallet <wallet_1> --wallet <wallet_2>

# Batch P&L for multiple wallets (default 7d)
gmgn-cli portfolio profits --chain sol \
  --wallet <wallet_1> --wallet <wallet_2>

# All-time P&L for up to 100 wallets
gmgn-cli portfolio profits --chain sol \
  --wallet <wallet_1> --wallet <wallet_2> --period all

# Token balance
gmgn-cli portfolio token-balance \
  --chain sol --wallet <wallet_address> --token <token_address>

# Tokens created by a developer wallet
gmgn-cli portfolio created-tokens --chain sol --wallet <wallet_address>

# Created tokens sorted by all-time high market cap
gmgn-cli portfolio created-tokens \
  --chain sol --wallet <wallet_address> \
  --order-by token_ath_mc --direction desc

# Only migrated tokens
gmgn-cli portfolio created-tokens \
  --chain sol --wallet <wallet_address> --migrate-state migrated

# ETH wallet holdings
gmgn-cli portfolio holdings --chain eth --wallet <0x_wallet_address>

# ETH wallet transaction activity
gmgn-cli portfolio activity --chain eth --wallet <0x_wallet_address>

# ETH token balance
gmgn-cli portfolio token-balance \
  --chain eth --wallet <0x_wallet_address> --token <0x_token_address>

portfolio created-tokens Options

Option Description
--order-by <field> Sort field: market_cap / token_ath_mc
--direction <asc|desc> Sort direction (default desc)
--migrate-state <state> Filter by migration status: migrated (graduated to DEX) / non_migrated (still on bonding curve)

portfolio holdings Options

Option Description
--limit <n> Page size (default 20, max 50)
--cursor <cursor> Pagination cursor
--order-by <field> Sort field: usd_value / last_active_timestamp / realized_profit / unrealized_profit / total_profit / history_bought_cost / history_sold_income (default usd_value)
--direction <asc|desc> Sort direction (default desc)
--hide-abnormal <bool> Hide abnormal positions: true / false (default: false)
--hide-airdrop <bool> Hide airdrop positions: true / false (default: true)
--hide-closed <bool> Hide closed positions: true / false (default: true)
--hide-open Hide open positions

portfolio activity Options

Option Description
--token <address> Filter by token
--limit <n> Page size
--cursor <cursor> Pagination cursor (pass the next value from the previous response)
--type <type> Repeatable: buy / sell / transferIn / transferOut / add / remove

The activity response includes a next field. Pass it to --cursor to fetch the next page.

portfolio stats Options

Option Description
--period <period> Stats period: 7d / 30d (default 7d)

portfolio profits Options

Option Description
--wallet <address...> One or more wallet addresses (required, max 100)
--period <period> P&L period: 1d / 7d / 30d / all (default 7d)

Response Field Reference

Response envelopes — each route wraps differently

Check the envelope before reaching for a field name. Reading a field off the wrong level returns undefined, which becomes 0, which reads as a real answer ("this wallet made nothing") rather than as an error.

Route Top-level shape Where the rows are
portfolio stats bare object (array for batch) the object itself
portfolio profits {"list": [ {…} ]} list[0] — a single row, still inside an array
portfolio activity {"activities": [...], "next": …} activities — not list
portfolio holdings {"list": [...], "next": …} list — not holdings
portfolio created-tokens bare object tokens, plus aggregate counts at the top level

Some deployments additionally wrap the whole body in {"data": …}.

portfolio holdings — Key Fields

Rows come back under list, with a next cursor. Confirmed against gmgn-cli 1.5.8 live responses — several names differ from what earlier versions of this doc claimed, and the old names are not accepted as aliases.

Field Description
token.token_address Token contract address (not token.address — that name is activity's)
token.symbol / token.name Token ticker and full name
token.price Current token price in USD
token.is_honeypot Ships inline — no gmgn-token security call needed. A true here is contradicted by history_total_sells > 0 on the same row: a honeypot cannot be sold, and transfer-restricted RWA / tokenised-stock contracts trip naive simulators
token.launchpad_platform / token.launchpad Where the token came from — the basis for "where does this wallet hunt"
token.liquidity, token.max_supply, token.total_supply, token.creation_timestamp Also inline
balance Current token balance (human-readable units)
usd_value Current USD value of this position
accu_cost Cost basis of the position still held (not cost)
history_bought_cost / history_sold_income All-time buy cost / sell proceeds
realized_profit Profit from completed sells (USD)
unrealized_profit Profit on current unsold holdings at current price (USD)
total_profit realized_profit + unrealized_profit (USD)
total_profit_pnl Total profit ratio (not profit_change); realized_profit_pnl / unrealized_profit_pnl are the split
history_total_buys / history_total_sells Buy / sell transaction counts (not buy_tx_count / sell_tx_count)
history_total_transfer_ins / _outs Transfer counts — airdrops and internal moves, not trades
start_holding_at / end_holding_at / last_active_timestamp Position lifetime
wallet_token_tags Per-position tags

There is no --sell-out flag — gmgn-cli 1.5.8 rejects it as an unknown option. avg_cost is not returned; derive it from accu_cost / balance.

portfolio activity — Key Fields

Rows come back under activities, with a next cursor for pagination.

Field Description
tx_hash On-chain transaction hash (not transaction_hash)
event_type Transaction type: buy / sell / transferIn / transferOut. Some chains return type instead — read event_type ?? type. Transfer rows are airdrops and internal moves, not trades — exclude them from any ratio
buy_cost_usd On a sell row, the cost basis of what was sold — cost_usd - buy_cost_usd is that exit's realized P&L
gas_usd / priority_fee / tip_fee Friction. Compare gas_usd against per-trade net, not against nothing
launchpad_platform Where the token came from
token.address Token contract address
token.symbol Token ticker
token_amount Token quantity in this transaction
cost_usd USD value of this transaction
price Token price denominated in the quote token of the trading pair at time of transaction
price_usd Token price in USD at time of transaction
timestamp Unix timestamp of the transaction
next Pagination cursor — pass to --cursor to fetch the next page

portfolio stats — Key Fields

The response is an object (or array for batch). Key fields:

Field Description
realized_profit Total realized profit over the period (USD)
unrealized_profit Total unrealized profit on open positions (USD)
winrate Win rate — ratio of profitable trades (0–1)
total_cost Total amount spent buying in the period (USD)
buy_count Number of buy transactions
sell_count Number of sell transactions
pnl Profit/loss ratio = realized_profit / total_cost

The response also includes a common object when available (absent if the upstream identity service is unavailable):

Field Description
common.avatar Wallet avatar URL
common.name Display name
common.ens ENS domain (EVM chains only)
common.tag Primary wallet tag
common.tags All wallet tags (e.g. ["smart_money"])
common.twitter_username Twitter handle
common.twitter_name Twitter display name
common.followers_count Twitter follower count
common.is_blue_verified Twitter blue-verified badge
common.follow_count Number of GMGN users following this wallet
common.remark_count Number of GMGN users who have remarked this wallet
common.created_token_count Tokens created by this wallet
common.created_at Wallet creation time (Unix seconds) — records when the first funding transaction arrived; use this as the wallet's age indicator
common.fund_from Funding source label
common.fund_from_address Address that funded this wallet
common.fund_amount Funding amount

Use common.tags and common.twitter_username when building a wallet profile narrative. If common is absent in the response, omit identity fields silently — do not report it as an error.

portfolio profits — Key Fields

The response has a list array with one item per wallet. Monetary values are decimal strings; parse them with decimal arithmetic rather than binary floating point when exact calculations matter.

Field Description
wallet_address Wallet address
realized_profit Realized profit in the selected period
realized_profit_cost Cost basis associated with selected-period realized profit
buy / sell Buy and sell counts in the selected period
unrealized_profit Unrealized profit on current holdings
total_realized_profit All-time realized profit
total_realized_profit_cost Cost basis associated with all-time realized profit
total_profit Total profit
total_cost Total cost basis

portfolio created-tokens — Key Fields

The response data object has a tokens array plus aggregate stats.

Top-level fields:

Field Description
last_create_timestamp Unix timestamp of the most recent token creation
inner_count Number of tokens still on the bonding curve (NOT graduated)
open_count Number of tokens that have graduated to DEX
open_ratio Graduation rate (string, e.g. "0.25")

Total created = inner_count + open_count. Do NOT use len(tokens) as the total — the tokens array is capped at 100 entries and may be truncated. | creator_ath_info | Best-performing token created by this wallet (ATH market cap) | | tokens | Array of created tokens — see below |

creator_ath_info fields:

Field Description
creator Wallet address
ath_token Token address with highest ATH market cap
ath_mc ATH market cap (USD string)
token_symbol / token_name Token ticker and name
token_logo Logo URL

Per-token fields (tokens[*]):

Field Description
token_address Token contract address
symbol Token ticker
chain Chain name
create_timestamp Unix timestamp of creation
is_open true if graduated to DEX
market_cap Current market cap (USD string)
token_ath_mc All-time high market cap (USD string)
pool_liquidity Current liquidity (USD string)
holders Current holder count
swap_1h Swap count in the last hour
volume_1h Trading volume in the last hour (USD string)
launchpad_platform Launch platform name (e.g. Pump.fun)
is_pump true if launched on Pump.fun
bundler_rate Bundler participation rate (0–1)
cto_flag true if community-takeover token

Do NOT guess field names not listed here. If a field appears in the response but is not in this table, do not interpret it without reading the raw output first.

Output Format

Do NOT dump raw JSON. Always parse and present data in the structured formats below. Use --raw only when piping to jq or further processing.

portfolio holdings — Holdings Table

Present a table sorted by usd_value (descending). Show total portfolio value at the top.

Wallet: {wallet} | Chain: {chain}
Total value: ~${sum of usd_value across all positions}

# | Token | Balance | USD Value | Total P&L | P&L% | Avg Cost | Buys / Sells

Flag positions where profit_change is strongly negative (e.g. < -50%) or positive (e.g. > 200%) with a brief note.

portfolio activity — Activity Feed

Present as a chronological list (newest first). Use human-readable timestamps.

{type} {token.symbol}  |  {token_amount} tokens  |  ${cost_usd}  |  {timestamp}  |  tx: {short hash}

Group by token if the user asks about a specific token.

portfolio stats — Stats Summary

Wallet: {wallet} | Period: {period}
Realized P&L:   ${realized_profit}
Unrealized P&L: ${unrealized_profit}
Win Rate:        {winrate × 100}%
Total Spent:     ${total_cost}
Buys / Sells:    {buy_count} / {sell_count}
PnL Ratio:       {pnl}x
[Identity:       {common.name or common.twitter_username} | Tags: {common.tags}]

Show the [Identity: ...] line only if common is present in the response. For batch queries (multiple wallets), present one summary block per wallet.

Notes

  • portfolio holdings uses critical auth (GMGN_API_KEY + GMGN_PRIVATE_KEY required — CLI signs the request automatically). All other portfolio commands use exist auth (API Key only, no signature required).
  • portfolio stats supports multiple --wallet flags for batch queries
  • Use --raw to get single-line JSON for further processing
  • Input validation — Wallet and token addresses are validated against the expected chain format at runtime (sol: base58 32–44 chars; bsc/base/eth: 0x + 40 hex digits). The CLI exits with an error on invalid input.
  • For follow-wallet, KOL, and Smart Money trade records, use the gmgn-track skill (track follow-wallet / track kol / track smartmoney)

Workflow

For full wallet analysis including trade history and follow-through on top holdings, see docs/workflow-wallet-analysis.md

For in-depth trading style analysis, copy-trade ROI estimation, and smart money leaderboard comparison, see docs/workflow-smart-money-profile.md

When to use which:

  • User asks "is this wallet worth following" → docs/workflow-wallet-analysis.md
  • User asks "what's this wallet's trading style", "when does he take profit", "smart money profile", "if I copied this wallet what would my return be" → docs/workflow-smart-money-profile.md
  • User wants to compare multiple smart money wallets by winrate/PnL → docs/workflow-smart-money-profile.md Step 5 (leaderboard)
  • User asks "what tokens did this dev create", "dev 发过哪些币", "查一下这个 dev 的代币", "dev 创建记录" → use portfolio created-tokens --chain <chain> --wallet <creator_address> directly. Get the creator address first via token info if only a token address is given.