Binance On-Chain Copy Trader
A scaffold for a copy-trading pipeline. This skill provides no strategy. What it provides is: a capability index, an event contract, an assembly structure, and three strategy hooks. The strategy itself is yours to build and refine.
Read this first
Every risk control, position-management scheme, and parameter value in this skill is a suggestion and an example. None of it is mandatory, and none of it constitutes a safety guarantee or investment advice.
- The scaffold guarantees only that it will run the checks you configure. It does not guarantee those checks are sufficient to protect your funds. Budget caps, stop-loss distance, liquidity floors — you choose all of them. Set them wide, leave them out, or switch off the honeypot block, and the scaffold will do exactly as told.
- The three bundled hooks are a minimal reference implementation that proves the pipeline runs. They are not a recommended strategy and carry no evidence of positive expectancy. Running them live as-is means funding an unvalidated strategy with real money.
- On-chain transactions are irreversible. Any automated strategy can lose money through market conditions you did not anticipate, changes in platform behavior, or defects in this scaffold. The risk is yours.
- Suggested path: run
dry-runlong enough to matter → readevents.jsonland build your own baseline → switch tolivewith an amount you can afford to lose entirely → tune from there.
Dependencies
Confirm the following skills are available in your current environment. This skill only orchestrates; every underlying capability comes from them:
| Skill | Provides | Used here |
|---|---|---|
binance-agentic-wallet |
Wallet, market orders, limit orders, quota, chain info | wallet market-order limit-order |
binance-wallet-tracker |
Address monitoring, real-time push, group management | tracker ws tracker token/tx tracker group/address |
| ↳ | Must include the Connection lifecycle and Signal field nullability sections — this skill's connection-supervision split and its first-push field assumptions depend on them directly | — |
binance-leaderboard |
Trader rankings, address scoring, holder lookup | leaderboard query/analyze/alpha-radar |
binance-trading-signal |
Smart-money signals, custom strategies, backtests | signal list signal strategy signal backtest |
query-token-audit |
Token security audit | Audit HTTP endpoint |
query-token-info |
(optional) Live token price and market data | Only if a hook needs a live price when the event does not carry one |
Verify:
baw --version
baw cli-check --required-version "$(grep -m1 requiredCliVersion SKILL.md | tr -dc '0-9.')" --json
# ↑ the frontmatter `requiredCliVersion` is the single source of truth for the version floor.
# Below it, `tracker ws --duration 0` has no keepalive and no reconnect, and the scaffold
# refuses to start.
Missing any one of them disables the corresponding stage: without binance-leaderboard there is
no systematic way to pick addresses; without binance-trading-signal you cannot create custom
signal strategies; without query-token-audit there is no audit data and hard_gate falls back
to require_audit to decide whether to pass or reject.
Install source: https://github.com/binance/binance-skills-hub
Getting started
The order that takes you from nothing to a scored dry-run. Each step points at the section below.
Note the ordering. Writing the strategy comes after the first dry-run, not before. That is deliberate: the whole premise of this scaffold is that thresholds should come from your own data, and before the first run you have none. So the first pass runs the bundled reference hooks unchanged — not because they are any good, but because they produce the dataset you need in order to write something better.
1 · Environment check → see "Pre-flight checks"
Confirm the wallet is CONNECTED, the target chain supports both market-order and limit-order,
and that quote-token and gas balances are sufficient. On a chain where limit-order is
unavailable you cannot place on-chain protective orders — accept that before continuing.
2 · Pick your trigger sources → see "Trigger sources" Address subscription, signal subscription, or both. This determines how you select sources next.
3 · Select sources
- Address source:
leaderboard queryfor candidates →leaderboard analyzefor the 6-dimension score on each (follow_friendlyis the most relevant dimension here) → write addresses intoaddrs.txt, one per line,#for comments, max 100 - Signal source:
signal exploreto browse official strategies /signal strategy createto build your own /signal strategy followto subscribe — or just use--smyand let the platform's own judgement drive it, with no configuration at all
The sort dimension materially changes the character of the candidate pool
(--sort-by 0=PnL 20=win rate 30=volume 50=trade count …), and individual dimensions can
saturate — over a short period, for instance, many addresses can tie at 100% win rate. Add your
own secondary criteria to break ties.
4 · Write the config → see "Configuration"
The first run writes an empty template to $COPYTRADER_HOME (default ~/.local/copytrader) and
refuses to start until every required field is filled, listing exactly which are missing.
Mechanism parameters (budget / per-trade size / concurrency / gas floor) are separate from
policy, which is passed through to your hooks. Leave mode as dry-run.
The values here are decisions, not defaults to be guessed — budget_usd in particular should be
an amount whose total loss is acceptable, because that is the number you will be asked to
acknowledge at step 9.
5 · Dry-run with the bundled hooks unchanged
cd <skill-dir>/scripts && python3 copytrader.py
Do not edit the hooks yet. What ships filters almost nothing (direction and freshness only), which makes it a poor strategy but a good instrument: it lets through a broad sample so you can see what the stream actually contains before you start rejecting things.
6 · Decide whether the plumbing works
tail -f $COPYTRADER_HOME/copytrader.log
Check:
- the startup log's
sourcesandpolicymatch what you intended events.jsonlis being written, and thedecisiondistribution is sane — are the rejection reasons the ones you wanted?- passing events have values for
lag_s,tax,audit_risk,liquidity,price— a missing value means some upstream call returned nothing conn_seqis not climbing fast (frequent reconnects mean an unstable stream)outcomes.jsonlstarts filling in once the first horizon elapsesstate.json'sdeployedstays at 0 under dry-run
This step is about the pipeline, not the strategy. Fix anything here before reading meaning into the data.
7 · Now write your strategy → see "Strategy hooks" and "Scoring decisions without spending money"
With a dataset in hand, edit the three functions in the ══-delimited block at the top of
scripts/copytrader.py: should_enter / size_position / exit_plan.
Join events.jsonl and outcomes.jsonl on key and let that decide your thresholds. Every gate
you add should be one you can point at in the data — and you should be able to see, in the
decision distribution, exactly what it rejected. Which parameters your hooks read is up to you;
put them in the policy section of config.json and they arrive verbatim.
8 · Iterate Change hooks → re-run → score again. This all works in dry-run, which is the point: outcome sampling does not need a fill, so a strategy can be evaluated from recorded data instead of from real money. Accumulating a usable sample size is otherwise the expensive part.
Resist concluding early. A handful of scored decisions tells you nothing, and a mean carried by one or two outliers tells you nothing either.
9 · Switch to live
Set mode to live and acknowledge the amount being risked — live mode refuses to start
until the acknowledgement file contains the current budget_usd:
echo <budget_usd> > $COPYTRADER_HOME/I_UNDERSTAND_THE_RISK
The operator has to confirm the amount; an agent may write the file once they have. What the gate is for is that the decision was made, and made about this specific number — not that a human typed the command. So the natural flow is: the agent states the budget and what a full loss of it would mean, the operator confirms, the agent writes the file and starts the run.
Why the amount rather than a bare touch: it cannot be inherited by copying someone else's
runtime directory, the number has to come from whoever decided it, and raising budget_usd
later invalidates the acknowledgement — more money at risk deserves a fresh decision rather
than a stale one.
Before this point you should already have: run dry-run long enough to accumulate a meaningful
sample, scored those decisions from outcomes.jsonl rather than from intuition, and concluded
that losing budget_usd in full is acceptable.
Dry-run does not record positions, so the budget and concurrency guardrails never fire — the dry-run pass count will therefore be higher than the number of positions live would actually open. Do not size live from a dry-run pass rate.
Capability map
Every stage of the pipeline maps to an existing capability. The tables below are an index of
where to look; treat each skill's own documentation and --help as authoritative for parameters.
Source selection — pick addresses for the address source; pick or build strategies for the signal source
| Capability | Command | Returns | Key parameters |
|---|---|---|---|
| Trader ranking | baw leaderboard query |
address · winRate · realizedPnl · realizedPnlPercent · totalVolume · totalTxCnt · buyTxCnt · tags · addressLabel | -c chain · -p 7d/30d/90d · -t ALL/KOL/MPC · --sort-by 0=PnL 20=win rate 30=volume 50=trades 60=activity 70=rate 80=token count · --page · --size (≤20) |
| Single-address score | baw leaderboard analyze |
6-dimension score (winrate / stability / drawdown / tags / pnl / follow_friendly) plus an AI archetype | -c · -a address · -p · --top-n (≤5000) |
| Reverse lookup by holding | baw leaderboard alpha-radar |
Addresses holding a given token | -c · -t comma-separated token addresses · -m minimum hits · -p |
| Token-level monitoring | baw tracker token |
Recent token activity | -c · -g group · --tag-type kol/smy · --token-size · --period 1m/5m/1h/4h/24h · --filter-risk |
| Trade-level monitoring | baw tracker tx |
Individual trades | same as above |
| Followed addresses | baw tracker follow |
The current follow list (read-only) | -c |
| Group management | baw tracker group / address |
Create groups, bulk import, label, link | see wallet-tracker docs |
Trigger sources — two peer real-time subscriptions
Both go through baw tracker ws and both are WebSocket push with latency of the same order.
The difference is who decides what is worth looking at: with the address source you name the
wallets; with the signal source the platform (or a strategy you built) decides. Run either alone
or both together (sources: ["address","signal"]).
A · Address subscription — you pick the wallets, it pushes every trade they make
| Command | Event semantics |
|---|---|
baw tracker ws --address-list <file> -c <chain> |
Every on-chain trade by the listed addresses, max 100 addresses |
baw tracker ws --address <addr> -c <chain> |
A single address |
baw tracker ws --following -c <chain> |
The current follow list; requires login, mutually exclusive with the other flags |
- How to select:
leaderboard query/analyze/alpha-radar, plustracker group/addressto build groups - Event granularity: one trade, one event; no re-pushes
- Direction:
tradeSideCategory— buys{11,19}, sells{21,29} - Chains:
-capplies; single chain - Entry price: the event carries
tokenPrice(the counterparty's actual fill price) - Delivery latency: measure it yourself. Expect a median on the order of seconds but a heavy tail (P90 can reach minutes). Record real latency from the event timestamp rather than assuming.
B · Signal subscription — the platform or your strategy decides, and pushes the verdict
| Command | Event semantics |
|---|---|
baw tracker ws --smy |
Smart-money signals (multi-address consensus). All chains; -c has no effect |
baw tracker ws --kol -c <chain> |
KOL-level events |
baw tracker ws --wallet BSC,SOL,BASE,ETH |
Chain-level wallet activity, comma-separated |
- How to select:
signal exploreto browse official strategies,signal strategy create/followto build or subscribe to custom ones (meme-rush / fomo-call),signal backtestto backtest - Event granularity: the same
signalIdis pushed repeatedly with state updates — on the order of tens to hundreds of pushes per signal. Deciding "this is a new signal" requires deduping bysignalId - Signal-only fields:
smartMoneyCount(how many smart-money addresses agree) ·exitRate(share already exited) ·maxGain·status statussemantics:valid/activelive ·timeoutexpired (one push at T+120min after the trigger) ·outDecline·exitRate- Chains: all-chain push; filter by
chainIdyourself - Entry price:
currentPrice(oftennull) oralertPrice(the historical price at trigger time — not a currently tradable price) - Delivery latency: measure it yourself, and mind the methodology — you must take the
minimum lag per
signalIdand count only genuine first pushes. Otherwise re-pushes turn "the signal's current age" into "latency" and the result comes out orders of magnitude too high.
Choosing between them
| Address source | Signal source | |
|---|---|---|
| Who decides what to watch | You (address selection) | Platform verdict / your custom strategy |
| Meaning of one event | One person's single trade | Consensus across several addresses |
| Coverage | Bounded by the 100-address cap | Platform-wide, all chains |
| Dedupe required | No | Yes (by signalId) |
| Timestamp field | ts |
signalTriggerTime |
| Event density | Depends on how active your chosen addresses are, and how many | Depends on platform verdicts and is unevenly distributed across chains — subscribing to one chain may mean long stretches with no events |
Shared parameter: --duration <sec> (0 or omitted = unlimited). Each source occupies one reader
thread in the scaffold; events are normalized by norm_address() / norm_signal() and merged
into a single queue.
Signal query and strategy management (REST, not push)
The real-time path for smart-money signals is
tracker ws --smyin the table above. This group is REST query and strategy management — for backfill, filtering, building custom strategies, and backtesting. It has nothing to do with latency.
| Capability | Command | Key parameters |
|---|---|---|
| Signal feed (backfill) | baw signal list |
-c chain · -s all/user/meme/smart-money · --time-range 5m/1h/24h (filters on signalTriggerTime) · --sort-by time/maxGain · -n (≤100) |
| Custom strategies | baw signal strategy create/update/delete/follow/unfollow/list/list-followed |
-c · -t meme-rush/fomo-call · --job-id · --config |
| Backtests | baw signal backtest list/detail/retry/schedule |
--interval 4H/6H/12H/24H/OFF |
| Backtest credits | baw signal credits |
— |
| Official strategy hall | baw signal explore |
— |
signal list returns fields from the same source as tracker ws --smy pushes (signalId values
line up), but neither is a superset of the other.
Risk control and execution
The capabilities below are available tools. Whether and how you combine them is decided by your hooks and your config. This skill does not mandate any particular check — it only guarantees that the checks you asked for run at the right moment.
| Capability | Command / endpoint |
|---|---|
| Token audit | POST https://web3.binance.com/bapi/defi/v1/public/wallet-direct/security/token/audit, body {binanceChainId, contractAddress, requestId}. Returns hasResult isSupported riskLevel extraInfo.buyTax/sellTax riskItems[].details[].isHit |
| Market data | GET .../buw/wallet/market/token/dynamic/info/ai?chainId=&contractAddress= → price liquidity holders volume24h |
| Quote | baw market-order quote --fromTokenQty --fromToken --toToken --binanceChainId --slippage |
| Market order | baw market-order swap (same parameters plus --mev --gasLevel) → returns orderId |
| Order lookup | baw market-order list → status toTokenActualQty txHash |
| Limit order | baw limit-order buy/sell --triggerPrice --fromTokenQty --fromToken --toToken --binanceChainId --slippage --mev --gasLevel |
| Cancel / list | baw limit-order cancel --strategyId · baw limit-order list |
| Guardrail readings | baw wallet balance · left-quota · tx-lock · settings · chains |
Event schema
Both sources are normalized by norm_address() / norm_signal() into one structure. Hooks only
ever see this:
{
"src": "address" | "signal",
"key": dedupe key (address stream = txHash; signal stream = "sig:" + signalId),
"ca": token contract address,
"token": token name,
"price": entry reference price (address stream = counterparty fill price `tokenPrice`;
signal stream = `currentPrice` or `alertPrice`),
"ts": event time (epoch seconds) — time.time() - ts is the delivery latency,
"chain": chain ID,
"side": 11/19 = buy, 21/29 = sell (always 11 on the signal stream),
"who": source identity (address stream = wallet label; signal stream = "SMY×N"),
"usd": USD size of the counterparty's trade,
"risk": tokenRiskLevel,
"top10": top-10 holder concentration,
"tags": token tag array,
"extra": source-specific fields
}
extra differs by source:
| Source | extra contents |
|---|---|
| address | addr |
| signal | signalId status smy (consensus count) exitRate liq entry_liq holders is_alpha launch_ms mcap sig_seq |
For the full set of raw push fields (including those dropped during normalization) see the
field tables in binance-wallet-tracker. Extend the norm_* functions to carry more of them
into extra when you need them.
Signal stream: field availability on first push vs re-push
Know this before you write any strategy. --smy is a state stream, not an event stream — the
same signalId is pushed repeatedly throughout its lifetime, and the first push and the
re-pushes carry different fields. A strategy can only use fields that are present on the first
push, because the decision has to be made then.
The authoritative definition lives in the Signal field nullability section of
binance-wallet-tracker/references/cli.md. The table below is how that lands at the assembly
layer:
| Field | First push | Usable for an entry decision? |
|---|---|---|
signalId ticker chainId contractAddress |
carried | Yes |
signalTriggerTime |
carried | Yes — compute delivery freshness from it |
alertPrice alertMarketCap |
carried | Yes (but note: historical price at trigger time, not a tradable price) |
alertLiquidity entryLiquidity |
carried | Yes |
top10 holders smartMoneyCount isAlpha launchTime |
carried | Yes |
status |
usually null |
No — see note 1 |
exitRate maxGain currentPrice |
usually null |
No — see note 2 |
Notes:
A
statusgate may only reject explicit bad values; a missing value must pass.statusis usuallynullon the first push (the lifecycle statestimeout/outDecline/exitRateare stamped on later pushes). Writingif st not in ("valid","active")silently drops almost every genuine first push, and the symptom is indistinguishable from "there are no signals on this chain", which makes it very hard to diagnose. Correct form:if st is not None and st not in ("valid", "active"): return False, f"status={st}"Do not use
exitRate/maxGain/currentPriceas entry conditions. They describe how a signal evolved after it fired; the server has not computed them at first-push time. A threshold gate on them never fires on a first push and only misjudges older signals you joined mid-life. Use them for exit logic or post-hoc correlation analysis instead — the fields are still written toevents.jsonlin full.Do not place orders against
alertPriceas if it were tradable. It is the historical price at trigger time; combined with delivery latency, using it as a fill price books an order that cannot fill. This scaffold treats it purely as a reference price handed to the hooks, while protective-order triggers are always derived from the real entry price computed asusd / actual filled quantity. Keep the two apart. If a hook needs a live price, query market data (query-token-info) and skip when unavailable.You do not need a market-data call for liquidity —
alertLiquidityis present on the first push, and market data often has no index for newly launched tokens, making it less reliable than the event's own field. Also note the signal source already filters on liquidity, so a liquidity floor on the signal stream is close to a no-op; do not expect it to carry your filtering.Expired re-pushes must be caught by a freshness gate, not by
status. Thestatus=timeoutexpiry notice always arrives at T+120min after the trigger; it carriesstatusbut itssignalTriggerTimeis old. The two gates back each other up:statuscatches explicit bad values, freshness catches anything stale in time.
Assembly structure
ws_reader(source) one thread per source; timestamps and enqueues only, never blocks
↓ queue
worker × N
↓
norm_* ─────────────► normalized event `ev`
↓
should_enter(ev, ctx) ← hook ① ctx.audit / liquidity are still None here
↓ pass
gate_ok() mechanism guardrails: budget / positions / gas / tx-lock / quota / STOP
↓
audit() ∥ token_market() fetch audit and market data concurrently, completing ctx
↓
hard_gate(ctx) mechanism-forced check: audit has a result + honeypot (configurable)
↓
should_enter(ev, ctx) ← hook ① again, now with audit and liquidity
↓ pass
size_position(ev, ctx) ← hook ② returns a USD amount
↓
market-order swap → actual_qty() reads toTokenActualQty from market-order list
↓
exit_plan(ev, ctx, entry, qty) ← hook ③ returns [(trigger_price, qty, slippage), ...]
↓
place_protection() places limit-order sells in the returned order
↓
reconcile() periodic: once any protective order fills, re-place against the
actual remaining balance
Why it is assembled this way
Three non-obvious choices in the pipeline, each resting on an interface contract. Confirm the contract still holds before changing them:
| Choice | Contract it rests on |
|---|---|
Filled quantity comes from market-order list, not from polling the wallet balance |
market-order swap returns orderId and neither a txHash nor a filled quantity; the quantity is toTokenActualQty in market-order list |
exit_plan can express a stop-loss |
limit-order sell accepts a triggerPrice below market and stays status: WORKING rather than filling immediately |
ws_reader only restarts at the process level |
Connection-level care is built into tracker ws --duration 0 (PING keepalive + auto-reconnect + backoff); the required CLI version is in the frontmatter's requiredCliVersion. The Connection lifecycle section of binance-wallet-tracker explicitly asks callers not to add a second layer — an outer rotation would kill a healthy connection, and an outer watchdog would abort a reconnect the CLI is already backing off for |
Two more choices affect how readings are interpreted: ws_reader records conn_uptime_s so
server-side delay can be told apart from post-reconnect replay; and the reader thread makes no
blocking calls, because otherwise processing time accumulates and inflates the arrival timestamps
of later events.
Strategy hooks
The three hooks are where you write your strategy. What ships in the repository is only an example that makes the pipeline run — every judgement and every threshold in it should be replaced by whatever your own analysis shows.
The three functions sit at the top of scripts/copytrader.py; the mechanism layer never touches
them. Signatures:
should_enter(ev, ctx) -> (bool, reason)
size_position(ev, ctx) -> float # USD; the mechanism layer then clamps to
# min(value, position_usd, remaining budget)
exit_plan(ev, ctx, entry_price, qty) -> [(trigger_price, sell_qty, slippage_pct), ...]
ctx contents:
| Key | Meaning |
|---|---|
audit |
Raw audit response (None on the first hook call) |
liquidity |
Liquidity from market data (None on the first call; the signal stream can fall back to extra.liq) |
price |
Entry reference price |
positions |
Current positions dict |
deployed |
USD deployed so far |
cfg |
Full config |
policy |
The policy section of config.json, passed through verbatim; the mechanism layer does not interpret its keys |
should_enter is called twice: once before spending anything on audit and market data
(only the event's own fields are available), and once after both are ready. Put cheap
judgements in the first call and anything that needs audit data in the second.
Helper: audit_tax(a) pulls buyTax + sellTax out of an audit response.
Mechanism guardrails
Two different things. Running a guardrail is the mechanism layer's job and hooks cannot get around it; the values and switches are your decision.
| Who decides | Note | |
|---|---|---|
| When a check runs and who runs it | The scaffold | e.g. "check the budget before every entry" cannot be switched off |
| What threshold a check uses, and whether it is enabled | You | How large budget_usd is, whether block_honeypot stays on — the scaffold executes whatever the config says |
| What to buy, how much, and how to exit once a check passes | Your hooks | The mechanism layer only clamps ceilings; it does not pick instruments |
So what follows is a suggested set of guardrails, not a safety guarantee. A config with
budget_usd: 1000000 and the honeypot block switched off will run perfectly happily.
The guardrail list
| Item | Behavior |
|---|---|
| Budget | Cumulative deployment never exceeds budget_usd; size_position's return value is clamped |
| Per trade | Never exceeds position_usd |
| Concurrency | Never exceeds max_positions |
| Gas | Stops opening when the native balance is below gas_floor_native; also stops if the balance cannot be read |
| Serialization | No orders while tx-lock reports LOCKED |
| Quota | No orders when left-quota is insufficient |
| Same token | A token already held is not opened again |
| Single instance | pidfile lock; a second start exits immediately |
| Gates | STOP halts new entries (existing protective orders are untouched); KILL exits the process |
| Mode | Under mode: dry-run no order request is issued at all |
| Live acknowledgement | mode: live refuses to start unless $COPYTRADER_HOME/I_UNDERSTAND_THE_RISK contains the current budget_usd. Cannot be inherited by copying a runtime directory, and raising the budget invalidates it |
Switching mode from dry-run to live requires the operator's explicit decision about a
specific amount, and the scaffold enforces that with the acknowledgement file above rather than
trusting a config edit alone. An agent may write the file after the operator confirms the amount.
The connection-supervision split
Connection-level care belongs to the CLI; process-level care belongs to this scaffold. Do not do both.
| Layer | Owner | What it does |
|---|---|---|
| Connection | CLI | tracker ws --duration 0 has it built in: a 30s application-level PING, auto-reconnect and re-SUBSCRIBE on onclose, exponential backoff (reset on any received push) |
| Process | This scaffold | Restarts the child process only when it actually exits (fixed short delay, proc_restart_sec), and writes a state["stream_alarm"] |
Why they must not be stacked (the Connection lifecycle section of binance-wallet-tracker
states this explicitly):
- an outer "active rotation" periodically kills a connection the CLI was maintaining perfectly well
- an outer "silence watchdog" force-kills the child while the CLI is mid-backoff, aborting a pending reconnect
- two nested backoffs make the real reconnect interval unpredictable
--duration N > 0 is different semantics: a single connection that rejects on disconnect and
does not reconnect. Use it for one-off sampling, never for a long-running process.
The version floor is hard. Built-in keepalive is a precondition of this scaffold. Below the required CLI version,
--duration 0has neither keepalive nor reconnect, and the scaffold no longer carries its own connection-level reconnect — running it anyway goes permanently silent after the first disconnect, and that is indistinguishable from a quiet market. The startup check verifies the version and refuses to run. The required version is the frontmatter'srequiredCliVersion;CLI_MINin the script matches it.
Check liveness with the right field
state["last_push_ms"] is the liveness signal — every push updates it, re-pushes included.
Do not use last_event_ms. It is written after deduplication, so it only reflects whether a
new signalId arrived. Half an hour of quiet on the signal stream is entirely normal, and
using that field for liveness turns a normal quiet spell into a false outage.
Profitable-exit bonus allowance (state.bonus_slots) — suggested, can be disabled
This is an example of one position-management idea, not a recommendation. It assumes "a profitable exit shows the strategy works in current conditions, so exposure can grow" — an assumption that holds in a trending market and amplifies drawdown in a choppy one. If you do not want it, make
position_usdan exact divisor ofbudget_usdand ignore the field.
Every exit that settles at a profit adds one position_usd slot of allowance:
position ceiling = max_positions + bonus_slots
capital ceiling = budget_usd + bonus_slots × position_usd
Losing exits add nothing. Nothing is added when proceeds cannot be determined
(proceeds is None) either — fail-closed: better to withhold allowance than to expand risk on a
guess. Proceeds are read from the on-chain receive amount of the filling transaction.
Same-token cooldown (policy.same_token_cooldown_h) — suggested default 24, adjustable, can be disabled
Within the cooldown window, the same contract address is bought only on the earliest signal.
"Do I currently hold it?" is not enough on its own — once a position closes, later signals for the
same token would pass again (a single token can produce several distinct signalIds in a day).
The cooldown origin is recorded in state.json.ca_last[ca], independent of position state.
Configuration
$COPYTRADER_HOME/config.json (default ~/.local/copytrader). The first run generates an
entirely empty template.
Resource guardrails and strategy parameters have no defaults — a missing one refuses startup. Values like stop-loss distance and per-trade size have to be the result of your own decision, not something copied. A stop-loss distance you never thought about is exactly as dangerous as a position size you never thought about, so the scaffold would rather not start. The one exception is
mode: absent meansdry-run, so an omission can never turn into live trading.
Required (mechanism layer)
| Key | Meaning | How to decide it |
|---|---|---|
chain |
Chain ID | baw wallet chains --json for the currently supported set |
quote_token |
Quote-token contract address | A stablecoin on that chain; must be lowercase |
sources |
Trigger sources | ["address"] / ["signal"] / both |
budget_usd |
Cumulative deployment cap | No reinvestment; monotonically increasing. Decide whether losing all of it is acceptable |
position_usd |
Per-trade size | Together with stop-loss distance, this sets the worst case per trade |
max_positions |
Concurrent position cap | Bounds total exposure together with budget_usd |
gas_floor_native |
Native-token floor | Stops opening below this; also stops if the balance cannot be read |
Required (strategy parameters, read by the reference hooks)
The policy section is passed to hooks verbatim and the mechanism layer does not interpret its
keys — replace the hooks and this table changes with them. The reference implementation requires:
| Key | Meaning |
|---|---|
follow_sides |
Which tradeSideCategory values to follow on the address stream |
max_event_age_sec |
Delivery freshness ceiling, in seconds |
sl_mult / tp_mult |
Stop-loss / take-profit trigger = entry price × this |
tp_fraction |
Fraction of the position sold at take-profit |
buy_slippage / sl_slippage / tp_slippage |
Slippage tolerance per stage (%). Stop-loss slippage should be wider than take-profit — a stop that cannot fill during a crash is not protection |
Has defaults (plumbing that does not affect capital safety)
mode (dry-run) · address_list (addrs.txt) · reconcile_sec · proc_restart_sec ·
workers · outcome_horizons_min ([5, 15, 60]; set to [] to switch outcome sampling off)
Optional
policy.block_honeypot (default true; disabling it is not advised) · policy.require_audit ·
policy.enrich_srcs · policy.same_token_cooldown_h · policy.min_liquidity_usd ·
policy.block_risk_levels · policy.max_sell_tax_pct · policy.max_commit_ratio
Run dry-run for a while first and read the decision distribution in events.jsonl to confirm
what each gate is actually rejecting, before deciding whether to go live.
Platform behavior notes
What cli-check actually returns
In baw cli-check --required-version X --json, success only reports that the command ran, and
is always true. Whether the version passes is in data.needUpdateCli:
{"success": true, "data": {"currentCliVersion": "x.y.z", "needUpdateCli": true}}
Treating success as "version is fine" makes the version gate useless: the command always
"succeeds", so the gate always passes. The correct test is data.needUpdateCli === false.
Address casing is not consistent across chains
Lowercase every contract address you pass to the CLI. Chains differ in how strictly they validate address format — some accept EIP-55 mixed case, others reject it outright:
[20003002] Invalid fromToken address
The difference is not documented, and the error points at "invalid address" rather than "wrong format", which reads as though the wrong token were configured.
The dangerous part is how it presents: every order on that leg fails, and "never filling
anything" looks exactly like "this chain has no signals" in every liveness metric. Unless failed
orders are counted explicitly, this can hide for a long time. That is why state["buy_fails"]
and the most recent error belong in whatever reporting you build.
Scoring decisions without spending money
events.jsonl alone cannot tell you whether a decision was any good — it records the decision,
not the result. So every decision that would have opened a position (dry-run included, where
nothing is bought) schedules price re-reads at outcome_horizons_min and appends them to
outcomes.jsonl:
{"key":"sig:…","ca":"0x…","token":"…","src":"signal","horizon_min":15,
"t0_ms":…,"entry":0.0123,"price":0.0141,"ret_pct":14.63,"sampled_ms":…}
Join on key to get one row per decision with its features and its forward return:
python3 - <<'EOF'
import json, collections
ev = {}
for l in open("events.jsonl"):
r = json.loads(l)
if r.get("decision") in ("DRY_RUN", "OPENED"): ev[r["key"]] = r
by = collections.defaultdict(dict)
for l in open("outcomes.jsonl"):
o = json.loads(l)
if o["key"] in ev and o.get("ret_pct") is not None:
by[o["key"]][o["horizon_min"]] = o["ret_pct"]
print(f"{len(by)} scored decisions")
for h in sorted({h for d in by.values() for h in d}):
v = sorted(d[h] for d in by.values() if h in d)
if v:
n = len(v)
print(f" {h:>3}min n={n:<4} median {v[n//2]:+7.1f}% "
f"win rate {sum(1 for x in v if x > 0)/n*100:.0f}%")
EOF
From there, split by whatever feature you are testing (liquidity, top10, tax, smy,
audit_risk, token age from launch_ms, …) and compare the distributions.
Read these numbers for what they are:
- They are marks, not fills. No slippage, no fees, no token tax, no gas, and no check that the position could actually have been exited at that price. Treat them as an upper bound on what the strategy could have captured.
- A horizon is an instant, not a path. A
+40%at 60 min does not mean a stop-loss was never touched on the way. Read several horizons together, and remember that a stop-loss between them changes the real outcome entirely. - Sample size still governs everything. A handful of scored decisions tells you nothing, and if the mean is carried by one or two outliers it tells you nothing either. Look at the median and the distribution, not the average.
- Samples read very late (after an outage, say) are marked
sampled_laterather than being quietly folded into the series.
Build your own baseline; do not use someone else's numbers
The quantities below shift with platform changes, chain, token type, and time of day, so any
hard-coded value goes stale. The scaffold writes them all into events.jsonl /
orders.jsonl precisely so that you can measure your own baseline:
| What to measure | How | The trap in the measurement itself |
|---|---|---|
| Delivery latency | lag_s = arrival time − event time |
On the signal stream, take the minimum lag per signalId and count only genuine first pushes (test: lag in the seconds range). Treating every re-push as an independent observation turns "the signal's current age" into latency and can be orders of magnitude off |
| Cause of latency | Use conn_uptime_s to separate "the server was slow" from "we just reconnected and got a replay" |
Without that field there is no attribution, only a blended number |
| Execution time | Per-step timings in latency_ms |
If the reader thread makes blocking calls, processing time accumulates into the measured latency of later events |
| Audit / market coverage | Distribution of hasResult and px_src |
Newly launched tokens are often unindexed in market data; coverage correlates strongly with token age, so pooling all ages together is meaningless |
| Gas cost per trade | Native-balance delta across trades ÷ number of trades | Placing and cancelling limit orders also costs gas; counting only swaps understates the cost of a f |
…(truncated)