From c1591b0e803362fee2bf20ca1924cbff3e42aa57 Mon Sep 17 00:00:00 2001 From: Judas Date: Fri, 29 May 2026 15:17:20 +0700 Subject: [PATCH] Add agent-battle-arena skill A trading-agent competition: pick a personality (meme hunter, conservative DCA, degen sniper, whale follower, AI narrative trader), raise an agent, and climb a weekly leaderboard ranked on PnL, drawdown, win rate, rugs avoided, best call, and worst trade. - Deterministic simulated market with rug events (TypeScript, zero runtime deps) - 5 personality strategies + paper-trading engine - Real trading opt-in via the Bankr Agent API (gated behind ARENA_LIVE + key) - SKILL.md + references/ (personalities, leaderboard, trading-modes, workflow) --- agent-battle-arena/.gitignore | 6 + agent-battle-arena/SKILL.md | 136 +++++++++ agent-battle-arena/package.json | 23 ++ .../references/arena-workflow.md | 70 +++++ agent-battle-arena/references/leaderboard.md | 60 ++++ .../references/personalities.md | 101 +++++++ .../references/trading-modes.md | 67 +++++ agent-battle-arena/src/cli.ts | 265 ++++++++++++++++++ agent-battle-arena/src/engine/arena.ts | 135 +++++++++ agent-battle-arena/src/engine/bankrBroker.ts | 121 ++++++++ agent-battle-arena/src/engine/broker.ts | 14 + agent-battle-arena/src/engine/simBroker.ts | 76 +++++ agent-battle-arena/src/market/market.ts | 189 +++++++++++++ agent-battle-arena/src/metrics/leaderboard.ts | 90 ++++++ .../src/personalities/aiNarrativeTrader.ts | 47 ++++ .../src/personalities/conservativeDca.ts | 49 ++++ .../src/personalities/degenSniper.ts | 44 +++ .../src/personalities/helpers.ts | 27 ++ agent-battle-arena/src/personalities/index.ts | 18 ++ .../src/personalities/memeHunter.ts | 49 ++++ .../src/personalities/whaleFollower.ts | 46 +++ agent-battle-arena/src/store/store.ts | 37 +++ agent-battle-arena/src/types.ts | 134 +++++++++ agent-battle-arena/src/util/rng.ts | 43 +++ agent-battle-arena/tsconfig.json | 16 ++ 25 files changed, 1863 insertions(+) create mode 100644 agent-battle-arena/.gitignore create mode 100644 agent-battle-arena/SKILL.md create mode 100644 agent-battle-arena/package.json create mode 100644 agent-battle-arena/references/arena-workflow.md create mode 100644 agent-battle-arena/references/leaderboard.md create mode 100644 agent-battle-arena/references/personalities.md create mode 100644 agent-battle-arena/references/trading-modes.md create mode 100644 agent-battle-arena/src/cli.ts create mode 100644 agent-battle-arena/src/engine/arena.ts create mode 100644 agent-battle-arena/src/engine/bankrBroker.ts create mode 100644 agent-battle-arena/src/engine/broker.ts create mode 100644 agent-battle-arena/src/engine/simBroker.ts create mode 100644 agent-battle-arena/src/market/market.ts create mode 100644 agent-battle-arena/src/metrics/leaderboard.ts create mode 100644 agent-battle-arena/src/personalities/aiNarrativeTrader.ts create mode 100644 agent-battle-arena/src/personalities/conservativeDca.ts create mode 100644 agent-battle-arena/src/personalities/degenSniper.ts create mode 100644 agent-battle-arena/src/personalities/helpers.ts create mode 100644 agent-battle-arena/src/personalities/index.ts create mode 100644 agent-battle-arena/src/personalities/memeHunter.ts create mode 100644 agent-battle-arena/src/personalities/whaleFollower.ts create mode 100644 agent-battle-arena/src/store/store.ts create mode 100644 agent-battle-arena/src/types.ts create mode 100644 agent-battle-arena/src/util/rng.ts create mode 100644 agent-battle-arena/tsconfig.json diff --git a/agent-battle-arena/.gitignore b/agent-battle-arena/.gitignore new file mode 100644 index 0000000000..772bc62dfd --- /dev/null +++ b/agent-battle-arena/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.arena/ +.bankr/ +.env +*.log +.DS_Store diff --git a/agent-battle-arena/SKILL.md b/agent-battle-arena/SKILL.md new file mode 100644 index 0000000000..04e84645ff --- /dev/null +++ b/agent-battle-arena/SKILL.md @@ -0,0 +1,136 @@ +--- +name: agent-battle-arena +description: Run a trading-agent competition. Players create an AI trading agent, pick a personality (meme hunter, conservative DCA, degen sniper, whale follower, AI narrative trader), and a weekly leaderboard ranks them on PnL, drawdown, win rate, rugs avoided, best call, and worst trade. Defaults to safe paper-trading; real trades are an opt-in via the Bankr Agent API. Use when the user wants an agent battle/arena/tournament, a trading leaderboard, to "raise" or pick a trading-bot personality, or to compare trading strategies head-to-head. +metadata: + { + "clawdbot": + { + "emoji": "⚔️", + "homepage": "https://github.com/BankrBot/skills", + "requires": { "bins": ["node"] }, + }, + } +--- + +# Agent Battle Arena + +A competition where AI agents trade against each other. Each player creates one +agent and picks a **personality** — they don't need to know how to trade. Every +season (default one week) a **leaderboard** ranks the agents. + +The point: you raise an agent and choose its character. The strategy does the +trading; you watch it climb (or tank) the board. + +- **Default mode is paper-trading** — real market dynamics, simulated money, zero + financial risk. Perfect for tournaments and demos. +- **Real trading is opt-in** — flip an agent to `--mode real` and the orders + route through the [Bankr Agent API](https://github.com/BankrBot/skills/tree/main/bankr). + Gated behind explicit env flags so nobody trades real funds by accident. + +## Requirements + +- Node.js ≥ 22.6 (runs the TypeScript engine directly — no build step, no runtime deps). +- For real mode only: a write-enabled Bankr API key (`bk_...`) from + [bankr.bot/api](https://bankr.bot/api) and the `bankr` CLI / Agent API. + +## Quick start + +```bash +# from the skill directory +node src/cli.ts seed-demo # create an arena with all 5 demo personalities +node src/cli.ts run --all # play out the whole season (paper trades) +node src/cli.ts leaderboard # see the ranked board + weekly highlights +``` + +Or in one shot: `npm run demo`. + +## The five personalities + +| Key | Character | One-liner | +|-----|-----------|-----------| +| `meme-hunter` | Meme Hunter | Chases momentum memecoins; skips anything that smells like a rug. | +| `conservative-dca` | Conservative DCA | DCAs into blue chips. Lowest drawdown, steady curve. | +| `degen-sniper` | Degen Sniper | Apes fresh runners with size and high rug tolerance. High variance. | +| `whale-follower` | Whale Follower | Mirrors smart-money flow. Buys accumulation, exits distribution. | +| `ai-narrative-trader` | AI Narrative Trader | Rotates into rising-mindshare narratives, exits when the story fades. | + +Full strategy logic and the signals each one reads: [references/personalities.md](references/personalities.md). + +## Creating agents + +```bash +node src/cli.ts new --weeks 1 # fresh empty arena (seeded market) +node src/cli.ts add-agent --name "PepeRadar" \ + --personality meme-hunter --owner alice --cash 1000 +node src/cli.ts list # who's in the arena +``` + +Each player runs `add-agent` once with their chosen `--personality`. Same arena, +same seeded market → fair head-to-head. + +## Running a season + +```bash +node src/cli.ts run --rounds 24 # advance 24 ticks (≈ 1 day at hourly resolution) +node src/cli.ts run --all # finish the season +``` + +A "tick" is one market step (think hourly). A 1-week season = 168 ticks. The +market is **deterministic from a seed**, so a season is reproducible and the +same for every agent — see [references/arena-workflow.md](references/arena-workflow.md). + +## The leaderboard + +`node src/cli.ts leaderboard` ranks agents by PnL% and reports, per agent: + +- **PnL** (USD and %) — equity vs starting bankroll +- **Max drawdown** — worst peak-to-trough on the equity curve +- **Win rate** — share of closed trades that were profitable +- **Rugs avoided** — risky tokens the agent flagged and skipped that later rugged +- **Best call** / **Worst trade** — top and bottom closed trades by % + +Exact definitions and how each is computed: [references/leaderboard.md](references/leaderboard.md). + +`node src/cli.ts agent ` shows one agent's full card: positions, recent +trades with reasons, and its highlights. + +## Real trading (opt-in) + +Paper is the default. To let an agent trade **real funds** through Bankr: + +```bash +export ARENA_LIVE=1 +export BANKR_API_KEY=bk_your_write_enabled_key +export ARENA_MAX_TRADE_USD=25 # per-trade cap (default 25) +node src/cli.ts add-agent --name LiveBot --personality whale-follower --mode real +node src/cli.ts run --rounds 1 +``` + +Without `ARENA_LIVE=1` **and** a key, real mode refuses to run. Orders become +natural-language Bankr prompts (`Buy $25 of WETH on base`) executed via the +Agent API. **Start tiny, use a dedicated agent wallet.** Full safety guidance: +[references/trading-modes.md](references/trading-modes.md). + +## Command reference + +| Command | Description | +|---------|-------------| +| `seed-demo [--seed N] [--weeks W\|--ticks T]` | Create an arena with all 5 demo agents | +| `new [--seed N] [--weeks W\|--ticks T]` | Create an empty arena | +| `add-agent --name --personality

[--owner o] [--mode sim\|real] [--cash N]` | Add an agent | +| `list` | List agents | +| `run [--rounds N \| --all]` | Advance the season (default 24 ticks) | +| `leaderboard` (alias `lb`) | Ranked board + highlights | +| `agent ` | Inspect one agent | +| `personalities` | List the 5 personalities | +| `reset` | Delete the current arena | + +State persists to `.arena/state.json`. Override the location with `ARENA_DIR`. + +## Extending + +Add a personality by implementing the `Strategy` interface in +`src/personalities/` and registering it in `src/personalities/index.ts`. A +strategy reads market signals (momentum, liquidity, whale flow, narrative score, +rug risk) and returns buy/sell **orders** plus **skips** (tokens it refused on +risk grounds — the basis of the "rugs avoided" metric). diff --git a/agent-battle-arena/package.json b/agent-battle-arena/package.json new file mode 100644 index 0000000000..9e14841392 --- /dev/null +++ b/agent-battle-arena/package.json @@ -0,0 +1,23 @@ +{ + "name": "agent-battle-arena", + "version": "0.1.0", + "description": "Agents compete with real or simulated trading strategies. Pick a personality, raise your agent, climb the weekly leaderboard. Bankr-native.", + "type": "module", + "engines": { + "node": ">=22.6.0" + }, + "bin": { + "arena": "src/cli.ts" + }, + "scripts": { + "arena": "node src/cli.ts", + "demo": "node src/cli.ts seed-demo && node src/cli.ts run --rounds 168 && node src/cli.ts leaderboard", + "typecheck": "tsc --noEmit" + }, + "keywords": ["bankr", "agent", "trading", "arena", "leaderboard", "crypto"], + "license": "MIT", + "devDependencies": { + "typescript": "^5.6.0", + "@types/node": "^22.0.0" + } +} diff --git a/agent-battle-arena/references/arena-workflow.md b/agent-battle-arena/references/arena-workflow.md new file mode 100644 index 0000000000..bead898a06 --- /dev/null +++ b/agent-battle-arena/references/arena-workflow.md @@ -0,0 +1,70 @@ +# Arena workflow + +How a season runs, end to end. + +## Concepts + +- **Arena / season** — one competition over a fixed number of ticks. State lives + in `.arena/state.json` (override dir with `ARENA_DIR`). +- **Tick** — one market step. Treat it as ~1 hour; a 1-week season = **168 ticks**. +- **Seed** — the market is fully determined by `(seed, seasonTicks)`. Same seed → + same market for everyone. Reproducible and fair. +- **Agent** — one player's entry: a name, an owner, a personality, a mode, and a + starting bankroll (default $1,000). + +## Running a one-week tournament + +```bash +# 1. Create the week's arena. Use the week number as the seed for a +# fresh-but-reproducible market. +node src/cli.ts new --weeks 1 --seed 2026_22 + +# 2. Each player adds their agent (once). +node src/cli.ts add-agent --name "PepeRadar" --personality meme-hunter --owner alice +node src/cli.ts add-agent --name "SteadyHands" --personality conservative-dca --owner bob +node src/cli.ts add-agent --name "ApeFirst" --personality degen-sniper --owner carol +node src/cli.ts add-agent --name "WhaleWatch" --personality whale-follower --owner dave +node src/cli.ts add-agent --name "NarrativeMax" --personality ai-narrative-trader --owner erin + +# 3. Play it out — all at once, or in daily chunks. +node src/cli.ts run --all # whole week +# …or advance gradually for a daily check-in: +node src/cli.ts run --rounds 24 # day 1 +node src/cli.ts leaderboard # standings so far +node src/cli.ts run --rounds 24 # day 2 … + +# 4. Publish final standings. +node src/cli.ts leaderboard +node src/cli.ts agent ApeFirst # deep-dive any agent +``` + +Or just demo it: `node src/cli.ts seed-demo && node src/cli.ts run --all && node src/cli.ts leaderboard`. + +## What happens each tick (`src/engine/arena.ts → runRounds`) + +1. **Settle rugs.** Any token that rugs this tick is recorded; agents holding it + are force-liquidated at the collapse price (booked as a `RUGGED:` trade). +2. **Decisions.** Each agent's strategy runs `decide({ snapshot, agent })`, + returning orders and skips. New distinct skips are stored. +3. **Execution.** Orders go to the agent's broker — `SimBroker` for sim, the + gated `BankrBroker` for real. +4. **Mark to market.** Every agent records an equity snapshot for the curve that + drives PnL and drawdown. + +The season ends when `tick` reaches `seasonTicks`. + +## Resetting / new seasons + +```bash +node src/cli.ts reset # delete current arena +node src/cli.ts new --weeks 2 # longer season +``` + +Because everything derives from the seed, you can re-run an identical season any +time by reusing the same `--seed`. + +## Mixed sim + real + +You can keep most agents on `sim` and run one on `--mode real` in the same arena +to benchmark a live agent against paper opponents. Real mode requires the opt-in +env flags — see [trading-modes.md](trading-modes.md). diff --git a/agent-battle-arena/references/leaderboard.md b/agent-battle-arena/references/leaderboard.md new file mode 100644 index 0000000000..1e4e483567 --- /dev/null +++ b/agent-battle-arena/references/leaderboard.md @@ -0,0 +1,60 @@ +# Leaderboard metrics + +`node src/cli.ts leaderboard` ranks all agents and prints highlights. Metrics are +computed in `src/metrics/leaderboard.ts` from each agent's trade log and equity +curve. Agents are **ranked by PnL%** (descending). + +Each agent records an equity snapshot every tick (`cash + Σ positions valued at +the current mark price`), giving an equity curve the metrics read from. + +## Metrics + +### PnL (USD and %) +`equityUsd − startingCashUsd`, where `equityUsd` is the last point on the equity +curve. Percent is relative to the starting bankroll. + +### Max drawdown +Largest peak-to-trough drop on the equity curve: +`max over time of (runningPeak − equity) / runningPeak`. Reported as a negative +percent. Lower magnitude = steadier. This is where Conservative DCA wins. + +### Win rate +Of all **closed** trades (sells with a realized PnL), the fraction with +`realizedPnl > 0`. Shown as `—` when an agent has no closed trades yet (e.g. a +pure accumulator still holding everything). + +### Rugs avoided +Distinct tokens the agent **skipped for risk reasons** (`Decision.skips`) that +**later actually rugged**, and which the agent was *not* holding when the rug +fired. This rewards strategies that read `rugRisk` and stay away. A skip only +counts if it happened on or before the rug tick. + +### Rugs held (💥) +Distinct tokens the agent was **still holding when they rugged**. On a rug the +engine force-liquidates the position at the collapse price, booking the loss as a +closed trade tagged `RUGGED:`. Degen Sniper, with its high rug tolerance, is the +usual victim. + +### Best call / Worst trade +The closed trades with the highest and lowest realized **PnL %**. PnL% per trade +is `realizedPnl / costBasis`, where cost basis is derived as +`proceeds − realizedPnl`. A held rug typically shows up as the worst trade +(≈ −90%). + +## Reading the board + +``` + # AGENT STYLE PnL PnL% MaxDD Win Rug✓ + 🥇 PepeRadar meme-hunter $619.36 +61.9% -5.2% 60% 2 +``` + +- **Rug✓** column = rugs avoided. +- The **Highlights** block under the table shows equity, best call, worst trade, + and a 💥 flag with the count if the agent got rugged. + +## Weekly cadence + +A "week" is just a season of 168 ticks (`--weeks 1`). To run a recurring weekly +tournament: start a fresh arena each week (`new --weeks 1 --seed `), have +players re-add their agents, `run --all`, then publish `leaderboard`. Using the +week number as the seed makes every week a fresh-but-reproducible market. diff --git a/agent-battle-arena/references/personalities.md b/agent-battle-arena/references/personalities.md new file mode 100644 index 0000000000..95be02f22b --- /dev/null +++ b/agent-battle-arena/references/personalities.md @@ -0,0 +1,101 @@ +# Personalities + +Every agent is driven by one personality — a `Strategy` that, each tick, reads +market signals and returns **orders** (buy/sell) and **skips** (tokens it +evaluated and refused on risk grounds). Skips that later rug become the agent's +"rugs avoided" count. + +Each token tick exposes these signals (see `src/types.ts → TokenTick`): + +| Signal | Meaning | +|--------|---------| +| `priceChange1h` / `priceChange24h` | momentum over the last 1 / 24 ticks | +| `liquidityUsd` | pool depth — a proxy for how safe/exitable a token is | +| `volume24hUsd` | activity | +| `whaleNetFlowUsd` | net smart-money flow this tick (+ buy / − sell) | +| `narrativeScore` | 0–1 mindshare of the token's narrative | +| `rugRisk` | 0–1 model score; ramps up in the ~8 ticks before a real rug | +| `rugged` | true on the tick a rug actually fires | + +Implementations live in `src/personalities/`. + +--- + +## Meme Hunter — `meme-hunter` + +Chases momentum. Buys tokens up >25% on the day that are still green on the hour +and have at least $50k liquidity, up to 6 positions at ~8% of bankroll each. + +- **Skips** any candidate with `rugRisk > 0.5` (records the skip → rug avoided). +- **Exits** a holding at +60% (take profit), −25% (stop), or the moment its + `rugRisk` spikes above 0.5. + +Profile: lots of small momentum bets, decent win rate, moderate drawdown. + +## Conservative DCA — `conservative-dca` + +Boring on purpose. Each tick it dollar-cost-averages ~1.2% of bankroll split +across the two deepest-liquidity, lowest-risk assets (`rugRisk < 0.05`, liquidity +> $3M — i.e. blue chips). + +- **Skips** everything with `rugRisk ≥ 0.1`, so it racks up rugs-avoided. +- **Sells** only as capital preservation, if a holding falls below −40%. + +Profile: smallest drawdown, steady positive curve, few or no closed trades, wins +on *not blowing up*. + +## Degen Sniper — `degen-sniper` + +Max risk, max size. Snipes fresh explosive movers (`priceChange1h > 8%`, volume +> $20k) with ~22% of bankroll per position, up to 3 at a time. + +- **Tolerates** rug risk up to 0.75 — so it sometimes holds a token through a + rug (this is the personality most likely to show 💥 rugged on the board). +- **Flips fast**: banks at +40%, cuts at −20%. + +Profile: highest variance — biggest best-calls and worst-trades. + +## Whale Follower — `whale-follower` + +Mirrors smart money. Buys when `whaleNetFlowUsd` exceeds ~4% of liquidity +(accumulation), ~12% of bankroll per position, up to 5. + +- **Skips** `rugRisk > 0.45` ("whales avoid honeypots") → good rug-avoid record. +- **Exits** when whale flow turns sharply negative (distribution) or risk spikes, + with a −30% backstop. + +Profile: trend-following, balanced risk, follows the strongest flows. + +## AI Narrative Trader — `ai-narrative-trader` + +Trades the story. Buys tokens with `narrativeScore > 0.65` that aren't breaking +down, ~15% of bankroll per position, up to 4. + +- **Skips** `rugRisk > 0.45`. +- **Exits** when `narrativeScore` fades below 0.4, trims half at +80%, cuts at −35%. + +Profile: catches narrative runners (often the largest best-calls), rotates out +when mindshare cools. + +--- + +## Adding your own + +```ts +// src/personalities/myStrategy.ts +import type { Strategy } from '../types.ts'; +export const myStrategy: Strategy = { + personality: 'my-key', + label: 'My Strategy', + blurb: 'One line describing the edge.', + decide({ snapshot, agent }) { + const orders = []; + const skips = []; + // inspect snapshot.tokens, agent.positions, agent.cashUsd … + return { orders, skips }; + }, +}; +``` + +Add the key to `PERSONALITIES` in `src/types.ts` and register the strategy in +`src/personalities/index.ts`. diff --git a/agent-battle-arena/references/trading-modes.md b/agent-battle-arena/references/trading-modes.md new file mode 100644 index 0000000000..127cb194c6 --- /dev/null +++ b/agent-battle-arena/references/trading-modes.md @@ -0,0 +1,67 @@ +# Trading modes: sim vs real + +Every agent has a `mode`: `sim` (default) or `real`. An arena can mix both. + +## Sim (paper trading) — default + +- No real funds. Pure in-memory accounting (`src/engine/simBroker.ts`) against the + simulated market price, with a flat 0.3% fee per fill. +- The market is generated deterministically from `(seed, seasonTicks)` in + `src/market/market.ts`, so seasons are reproducible and identical for every + agent. It includes blue chips, memes, narrative tokens, micros, whale flow, + narrative scores, and scheduled **rug events** with a risk ramp beforehand. +- This is what you want for tournaments, demos, and strategy comparison. + +## Real — opt-in, via Bankr + +Real mode routes orders through the [Bankr Agent API](https://github.com/BankrBot/skills/tree/main/bankr) +(`src/engine/bankrBroker.ts`). Orders are turned into natural-language prompts: + +- buy → `Buy $ of on ` +- sell → `Sell % of my on ` + +submitted to `POST /agent/prompt` and polled to completion. + +### Safety gates (all required) + +| Env var | Purpose | +|---------|---------| +| `ARENA_LIVE=1` | Hard switch. Without it, constructing the real broker **throws** — nothing trades. | +| `BANKR_API_KEY=bk_...` | A **write-enabled** Bankr key. Read-only keys get 403 on trades. | +| `ARENA_MAX_TRADE_USD` | Per-trade USD cap (default **25**). Every buy is clamped to this. | +| `ARENA_CHAIN` | Chain for trades (default `base`). | + +If `ARENA_LIVE` is unset or the key is missing, `run` aborts with a clear error +before any order is placed. + +### Strong recommendations + +- **Use a dedicated agent wallet** funded with a small amount — never your main + wallet. If a key leaks, only the agent funds are exposed. +- Set Bankr **wallet-level limits** at [bankr.bot](https://bankr.bot) → Security + (daily + per-tx spending caps, permitted recipients). These apply on top of the + arena's own cap. +- **Start tiny.** Run `--rounds 1` and inspect the agent card before letting it + loop. +- Real mode is meant for running an agent **live, a tick at a time** (e.g. via a + scheduler), not for replaying a 168-tick backtest with real money — that would + fire 168× the trades. + +### Accounting caveat + +In real mode the engine mirrors fills using the simulated mark price as a +best-effort estimate so the leaderboard still renders. The **source of truth for +real balances is Bankr** — verify with `bankr wallet portfolio --pnl`. + +## Getting a Bankr key + +```bash +bun install -g @bankr/cli # or: npm install -g @bankr/cli +bankr login email you@example.com +bankr login email you@example.com --code --accept-terms \ + --key-name "Arena Agent" --agent-api --read-write +bankr whoami +``` + +See the bankr skill's `references/safety.md` for the full security model +(IP allowlisting, recipient allowlists, incident response, key rotation). diff --git a/agent-battle-arena/src/cli.ts b/agent-battle-arena/src/cli.ts new file mode 100644 index 0000000000..b0cb06bf03 --- /dev/null +++ b/agent-battle-arena/src/cli.ts @@ -0,0 +1,265 @@ +#!/usr/bin/env node +import { rmSync } from 'node:fs'; +import { PERSONALITIES, type Personality, type TradeMode } from './types.ts'; +import { STRATEGIES } from './personalities/index.ts'; +import { addAgent, createArena, runRounds } from './engine/arena.ts'; +import { leaderboard, computeScore } from './metrics/leaderboard.ts'; +import { hasState, loadState, saveState, seasonFor, statePath } from './store/store.ts'; +import type { Broker } from './engine/broker.ts'; + +// ---------- tiny arg parser ---------- +function parseArgs(argv: string[]): { _: string[]; flags: Record } { + const _: string[] = []; + const flags: Record = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + flags[key] = true; + } else { + flags[key] = next; + i++; + } + } else { + _.push(a); + } + } + return { _, flags }; +} + +// ---------- formatting ---------- +const usd = (n: number) => (n < 0 ? '-$' : '$') + Math.abs(n).toLocaleString('en-US', { maximumFractionDigits: 2, minimumFractionDigits: 2 }); +const pct = (n: number) => (n >= 0 ? '+' : '') + (n * 100).toFixed(1) + '%'; +const pad = (s: string, n: number) => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)); +const padL = (s: string, n: number) => (s.length >= n ? s : ' '.repeat(n - s.length) + s); + +function die(msg: string): never { + console.error('error: ' + msg); + process.exit(1); +} + +// ---------- commands ---------- +function cmdNew(flags: Record) { + const seed = flags.seed ? Number(flags.seed) : Math.floor(Math.random() * 1e9); + const ticks = flags.ticks ? Number(flags.ticks) : flags.weeks ? Number(flags.weeks) * 168 : 168; + const state = createArena(seed, ticks); + saveState(state); + console.log(`Created arena ${state.seasonId}`); + console.log(` seed: ${seed} season: ${ticks} ticks (${(ticks / 168).toFixed(1)} week)`); + console.log(` state: ${statePath()}`); + console.log(`\nNext: arena add-agent --name "..." --personality --owner you`); +} + +function cmdSeedDemo(flags: Record) { + const seed = flags.seed ? Number(flags.seed) : 42; + const ticks = flags.ticks ? Number(flags.ticks) : flags.weeks ? Number(flags.weeks) * 168 : 168; + const state = createArena(seed, ticks); + const names: Record = { + 'meme-hunter': 'PepeRadar', + 'conservative-dca': 'SteadyHands', + 'degen-sniper': 'ApeFirst', + 'whale-follower': 'WhaleWatch', + 'ai-narrative-trader': 'NarrativeMax', + }; + for (const p of PERSONALITIES) { + addAgent(state, { name: names[p], owner: 'demo', personality: p, mode: 'sim', startingCashUsd: 1000 }); + } + saveState(state); + console.log(`Seeded demo arena ${state.seasonId} (seed ${seed}, ${ticks} ticks) with ${PERSONALITIES.length} agents:`); + for (const p of PERSONALITIES) console.log(` • ${pad(names[p], 14)} ${p}`); + console.log(`\nNext: arena run --all then arena leaderboard`); +} + +function cmdAddAgent(flags: Record) { + const state = loadState(); + const name = String(flags.name ?? ''); + const personality = String(flags.personality ?? '') as Personality; + if (!name) die('--name is required'); + if (!PERSONALITIES.includes(personality)) die(`--personality must be one of: ${PERSONALITIES.join(', ')}`); + const mode = (String(flags.mode ?? 'sim') as TradeMode); + if (mode !== 'sim' && mode !== 'real') die('--mode must be sim or real'); + const owner = String(flags.owner ?? 'you'); + const cash = flags.cash ? Number(flags.cash) : 1000; + const agent = addAgent(state, { name, owner, personality, mode, startingCashUsd: cash }); + saveState(state); + console.log(`Added ${agent.name} [${agent.personality}] mode=${agent.mode} bankroll=${usd(cash)} → ${agent.id}`); + if (mode === 'real') { + console.log(' ⚠ real mode trades use actual funds via Bankr. Requires ARENA_LIVE=1 and BANKR_API_KEY at run time.'); + } +} + +function cmdList() { + const state = loadState(); + const agents = Object.values(state.agents); + if (!agents.length) return console.log('No agents yet. Add one with: arena add-agent ...'); + console.log(`Arena ${state.seasonId} — tick ${state.tick}/${state.seasonTicks}\n`); + console.log(pad('ID', 12) + pad('NAME', 16) + pad('PERSONALITY', 20) + pad('MODE', 6) + pad('OWNER', 12)); + for (const a of agents) { + console.log(pad(a.id, 12) + pad(a.name, 16) + pad(a.personality, 20) + pad(a.mode, 6) + pad(a.owner, 12)); + } +} + +async function cmdRun(flags: Record) { + const state = loadState(); + const season = seasonFor(state); + const remaining = season.snapshots.length - state.tick; + if (remaining <= 0) return console.log('Season already complete. Start a new one with: arena new'); + const rounds = flags.all ? remaining : flags.rounds ? Number(flags.rounds) : Math.min(24, remaining); + + let realBroker: Broker | undefined; + const needsReal = Object.values(state.agents).some((a) => a.mode === 'real'); + if (needsReal) { + const { BankrBroker } = await import('./engine/bankrBroker.ts'); + realBroker = new BankrBroker(); // throws clearly if not opted-in + console.log('⚠ real-mode agents present — executing live trades via Bankr.'); + } + + const processed = await runRounds(state, season, rounds, realBroker); + saveState(state); + console.log(`Ran ${processed} ticks → now at ${state.tick}/${state.seasonTicks}.`); + if (state.tick >= state.seasonTicks) console.log('Season complete. Run: arena leaderboard'); +} + +function rankBadge(i: number): string { + return ['🥇', '🥈', '🥉'][i] ?? ` ${i + 1}.`; +} + +function cmdLeaderboard() { + const state = loadState(); + const board = leaderboard(state); + if (!board.length) return console.log('No agents to rank yet.'); + + console.log(`\n AGENT BATTLE ARENA — ${state.seasonId}`); + console.log(` tick ${state.tick}/${state.seasonTicks} · seed ${state.seed} · rugs this season: ${state.ruggedTokens.length}\n`); + + const header = + ' ' + pad('#', 4) + pad('AGENT', 15) + pad('STYLE', 19) + + padL('PnL', 11) + padL('PnL%', 9) + padL('MaxDD', 9) + padL('Win', 7) + padL('Rug✓', 7); + console.log(header); + console.log(' ' + '─'.repeat(header.length - 2)); + + board.forEach((s, i) => { + const winStr = s.closedTrades ? (s.winRate * 100).toFixed(0) + '%' : '—'; + const row = + ' ' + pad(rankBadge(i), 4) + pad(s.name, 15) + pad(s.personality, 19) + + padL(usd(s.pnlUsd), 11) + padL(pct(s.pnlPct), 9) + + padL(pct(-s.maxDrawdownPct), 9) + padL(winStr, 7) + + padL(String(s.rugsAvoided), 7); + console.log(row); + }); + + console.log('\n Highlights'); + for (const s of board) { + const best = s.bestCall ? `${s.bestCall.symbol} ${pct(s.bestCall.pnlPct)}` : '—'; + const worst = s.worstTrade ? `${s.worstTrade.symbol} ${pct(s.worstTrade.pnlPct)}` : '—'; + const rug = s.rugsHeld > 0 ? ` 💥 rugged ${s.rugsHeld}` : ''; + console.log(` • ${pad(s.name, 14)} equity ${padL(usd(s.equityUsd), 11)} best: ${pad(best, 22)} worst: ${pad(worst, 22)}${rug}`); + } + console.log(''); +} + +function cmdAgent(args: string[]) { + const state = loadState(); + const q = (args[0] ?? '').toLowerCase(); + if (!q) die('usage: arena agent '); + const agent = Object.values(state.agents).find((a) => a.id.toLowerCase() === q || a.name.toLowerCase() === q); + if (!agent) die(`no agent matching "${q}"`); + const s = computeScore(state, agent); + + console.log(`\n${agent.name} [${agent.personality}] mode=${agent.mode} owner=${agent.owner}`); + console.log(STRATEGIES[agent.personality].blurb); + console.log(`\n equity ${usd(s.equityUsd)} (start ${usd(agent.startingCashUsd)})`); + console.log(` PnL ${usd(s.pnlUsd)} (${pct(s.pnlPct)})`); + console.log(` max drawdown ${pct(-s.maxDrawdownPct)}`); + console.log(` win rate ${s.closedTrades ? (s.winRate * 100).toFixed(0) + '%' : '—'} over ${s.closedTrades} closed trades`); + console.log(` rugs avoided ${s.rugsAvoided} rugs held ${s.rugsHeld}`); + if (s.bestCall) console.log(` best call ${s.bestCall.symbol} ${pct(s.bestCall.pnlPct)} (${usd(s.bestCall.usd)})`); + if (s.worstTrade) console.log(` worst trade ${s.worstTrade.symbol} ${pct(s.worstTrade.pnlPct)} (${usd(s.worstTrade.usd)})`); + + const open = Object.values(agent.positions).filter((p) => p.qty > 0); + if (open.length) { + console.log('\n open positions:'); + for (const p of open) console.log(` ${pad(p.symbol, 14)} qty ${p.qty.toPrecision(4)} @ avg ${p.avgPrice.toPrecision(4)}`); + } + const recent = agent.trades.slice(-8); + if (recent.length) { + console.log('\n recent trades:'); + for (const t of recent) { + const pnl = t.realizedPnl !== undefined ? ` pnl ${usd(t.realizedPnl)}` : ''; + console.log(` t${padL(String(t.tick), 3)} ${pad(t.side.toUpperCase(), 4)} ${pad(t.symbol, 12)} ${usd(t.usd)}${pnl} — ${t.reason}`); + } + } + console.log(''); +} + +function cmdPersonalities() { + console.log('\nAvailable personalities:\n'); + for (const p of PERSONALITIES) { + const s = STRATEGIES[p]; + console.log(` ${pad(p, 20)} ${s.label}`); + console.log(` ${' '.repeat(20)} ${s.blurb}\n`); + } +} + +function cmdReset() { + const path = statePath(); + if (!hasState()) return console.log('Nothing to reset.'); + rmSync(path, { force: true }); + console.log(`Removed ${path}`); +} + +function help() { + console.log(` +Agent Battle Arena — agents compete with simulated or real trading strategies. + +Usage: arena [options] + +Commands: + seed-demo [--seed N] [--ticks T|--weeks W] Create an arena with all 5 demo agents + new [--seed N] [--ticks T|--weeks W] Create an empty arena (default 1 week = 168 ticks) + add-agent --name --personality

Add an agent + [--owner o] [--mode sim|real] [--cash N] + list List agents + run [--rounds N | --all] Advance the season (default 24 ticks) + leaderboard Show the ranked board + highlights + agent Inspect one agent + personalities List the 5 personalities + reset Delete the current arena state + help This message + +Personalities: ${PERSONALITIES.join(', ')} + +Real trading (opt-in): set ARENA_LIVE=1 and BANKR_API_KEY, then add agents with --mode real. +Quick start: arena seed-demo && arena run --all && arena leaderboard +`); +} + +// ---------- dispatch ---------- +async function main() { + const argv = process.argv.slice(2); + const { _, flags } = parseArgs(argv); + const cmd = _[0] ?? 'help'; + switch (cmd) { + case 'new': return cmdNew(flags); + case 'seed-demo': return cmdSeedDemo(flags); + case 'add-agent': return cmdAddAgent(flags); + case 'list': return cmdList(); + case 'run': return cmdRun(flags); + case 'leaderboard': case 'lb': return cmdLeaderboard(); + case 'agent': return cmdAgent(_.slice(1)); + case 'personalities': return cmdPersonalities(); + case 'reset': return cmdReset(); + case 'help': case '--help': case '-h': return help(); + default: + console.error(`unknown command: ${cmd}`); + help(); + process.exit(1); + } +} + +main().catch((err) => { + console.error('error: ' + (err instanceof Error ? err.message : String(err))); + process.exit(1); +}); diff --git a/agent-battle-arena/src/engine/arena.ts b/agent-battle-arena/src/engine/arena.ts new file mode 100644 index 0000000000..16463e69f3 --- /dev/null +++ b/agent-battle-arena/src/engine/arena.ts @@ -0,0 +1,135 @@ +import type { AgentState, ArenaState, Personality, TradeMode } from '../types.ts'; +import type { Season } from '../market/market.ts'; +import { getStrategy } from '../personalities/index.ts'; +import { type Broker, newTradeId } from './broker.ts'; +import { SimBroker } from './simBroker.ts'; + +export function createArena(seed: number, seasonTicks: number): ArenaState { + return { + seasonId: 'season_' + Math.random().toString(36).slice(2, 8), + seed, + seasonTicks, + tick: 0, + startedAt: new Date().toISOString(), + agents: {}, + ruggedTokens: [], + }; +} + +export function addAgent( + state: ArenaState, + opts: { name: string; owner: string; personality: Personality; mode?: TradeMode; startingCashUsd?: number }, +): AgentState { + const id = 'agt_' + Math.random().toString(36).slice(2, 8); + const starting = opts.startingCashUsd ?? 1000; + const agent: AgentState = { + id, + name: opts.name, + owner: opts.owner, + personality: opts.personality, + mode: opts.mode ?? 'sim', + startingCashUsd: starting, + cashUsd: starting, + positions: {}, + trades: [], + skips: [], + equityCurve: [], + createdAt: new Date().toISOString(), + }; + state.agents[id] = agent; + return agent; +} + +export function equityOf(agent: AgentState, prices: Record): number { + let eq = agent.cashUsd; + for (const pos of Object.values(agent.positions)) { + if (pos.qty > 0) eq += pos.qty * (prices[pos.symbol] ?? 0); + } + return eq; +} + +// Advance the season up to `rounds` ticks. `realBroker` is only constructed by +// the caller when at least one agent runs in real mode. +export async function runRounds( + state: ArenaState, + season: Season, + rounds: number, + realBroker?: Broker, +): Promise { + const sim = new SimBroker(); + const ruggedSet = new Set(state.ruggedTokens.map((r) => r.symbol)); + let processed = 0; + + for (let step = 0; step < rounds; step++) { + const idx = state.tick; + if (idx >= season.snapshots.length) break; + const snap = season.snapshots[idx]; + + // 1) settle rugs that fire this tick — holders get liquidated at the collapse price + for (const t of Object.values(snap.tokens)) { + if (!t.rugged) continue; + if (!ruggedSet.has(t.symbol)) { + ruggedSet.add(t.symbol); + state.ruggedTokens.push({ symbol: t.symbol, tick: idx }); + } + for (const agent of Object.values(state.agents)) { + const pos = agent.positions[t.symbol]; + if (pos && pos.qty > 0) { + const realizedPnl = (t.price - pos.avgPrice) * pos.qty; + agent.cashUsd += pos.qty * t.price; + agent.trades.push({ + id: newTradeId(), + agentId: agent.id, + tick: idx, + symbol: t.symbol, + side: 'sell', + qty: pos.qty, + price: t.price, + usd: pos.qty * t.price, + reason: 'RUGGED: liquidated at rug price', + realizedPnl, + }); + delete agent.positions[t.symbol]; + } + } + } + + // 2) each agent decides and trades + for (const agent of Object.values(state.agents)) { + const strat = getStrategy(agent.personality); + const decision = strat.decide({ snapshot: snap, agent }); + + // record distinct skips (first time a symbol is skipped) + const seen = new Set(agent.skips.map((s) => s.symbol)); + for (const sk of decision.skips) { + if (!seen.has(sk.symbol)) { + seen.add(sk.symbol); + agent.skips.push(sk); + } + } + + const broker = agent.mode === 'real' ? realBroker : sim; + if (!broker) { + throw new Error(`Agent ${agent.name} is in real mode but no real broker is available.`); + } + + for (const order of decision.orders) { + const t = snap.tokens[order.symbol]; + if (!t) continue; + await broker.execute(agent, order, t, idx); + } + } + + // 3) mark-to-market equity snapshot for every agent + const prices: Record = {}; + for (const t of Object.values(snap.tokens)) prices[t.symbol] = t.price; + for (const agent of Object.values(state.agents)) { + agent.equityCurve.push({ tick: idx, equityUsd: equityOf(agent, prices) }); + } + + state.tick = idx + 1; + processed++; + } + + return processed; +} diff --git a/agent-battle-arena/src/engine/bankrBroker.ts b/agent-battle-arena/src/engine/bankrBroker.ts new file mode 100644 index 0000000000..d2a697adf2 --- /dev/null +++ b/agent-battle-arena/src/engine/bankrBroker.ts @@ -0,0 +1,121 @@ +import type { AgentState, Order, TokenTick, Trade } from '../types.ts'; +import { type Broker, newTradeId } from './broker.ts'; + +const API_URL = process.env.BANKR_API_URL ?? 'https://api.bankr.bot'; + +// Real-money broker. Executes through the Bankr Agent API. Heavily gated: +// nothing fires unless the operator has explicitly opted in via env flags. +// +// ARENA_LIVE=1 hard switch — without it, every call throws +// BANKR_API_KEY=bk_... a write-enabled Bankr API key +// ARENA_MAX_TRADE_USD=25 per-trade USD cap (default 25) +// ARENA_CHAIN=base chain for trades (default base) +// +// Accounting is mirrored from the simulated mark price as a best-effort +// estimate; the source of truth for real balances is `bankr wallet portfolio`. +export class BankrBroker implements Broker { + readonly mode = 'real' as const; + + private apiKey: string; + private maxTradeUsd: number; + private chain: string; + + constructor() { + if (process.env.ARENA_LIVE !== '1') { + throw new Error( + 'Real trading is disabled. Set ARENA_LIVE=1 to enable live Bankr execution (real funds at risk).', + ); + } + const key = process.env.BANKR_API_KEY; + if (!key) { + throw new Error('BANKR_API_KEY is required for real mode. Create a write-enabled key at https://bankr.bot/api'); + } + this.apiKey = key; + this.maxTradeUsd = Number(process.env.ARENA_MAX_TRADE_USD ?? 25); + this.chain = process.env.ARENA_CHAIN ?? 'base'; + } + + async execute(agent: AgentState, order: Order, tick: TokenTick, tickIndex: number): Promise { + const price = tick.price; + if (price <= 0) return null; + + let prompt: string; + let estUsd: number; + + if (order.side === 'buy') { + const spend = Math.min(order.usd, this.maxTradeUsd, agent.cashUsd); + if (spend < 1) return null; + estUsd = spend; + prompt = `Buy $${spend.toFixed(2)} of ${order.symbol} on ${this.chain}`; + } else { + const pos = agent.positions[order.symbol]; + if (!pos || pos.qty <= 0) return null; + const pct = Math.round((order.fraction ?? 1) * 100); + estUsd = pos.qty * price; + prompt = `Sell ${pct}% of my ${order.symbol} on ${this.chain}`; + } + + const response = await this.runPrompt(prompt); + + // mirror accounting using the mark price (best-effort estimate) + const synthetic: Order = order; + this.mirror(agent, synthetic, price, tickIndex); + + const last = agent.trades[agent.trades.length - 1]; + if (last) { + last.reason = `[LIVE] ${order.reason} — ${response.slice(0, 80)}`; + } + void estUsd; + return last ?? null; + } + + private mirror(agent: AgentState, order: Order, price: number, tickIndex: number) { + if (order.side === 'buy') { + const spend = Math.min(order.usd, this.maxTradeUsd, agent.cashUsd); + const qty = spend / price; + agent.cashUsd -= spend; + const pos = agent.positions[order.symbol]; + if (pos && pos.qty > 0) { + const cost = pos.avgPrice * pos.qty + price * qty; + pos.qty += qty; + pos.avgPrice = cost / pos.qty; + } else { + agent.positions[order.symbol] = { symbol: order.symbol, qty, avgPrice: price, openedTick: tickIndex }; + } + agent.trades.push({ id: newTradeId(), agentId: agent.id, tick: tickIndex, symbol: order.symbol, side: 'buy', qty, price, usd: spend, reason: order.reason }); + } else { + const pos = agent.positions[order.symbol]; + if (!pos) return; + const qty = pos.qty * (order.fraction ?? 1); + const proceeds = qty * price; + const realizedPnl = (price - pos.avgPrice) * qty; + agent.cashUsd += proceeds; + pos.qty -= qty; + if (pos.qty <= 1e-9) delete agent.positions[order.symbol]; + agent.trades.push({ id: newTradeId(), agentId: agent.id, tick: tickIndex, symbol: order.symbol, side: 'sell', qty, price, usd: proceeds, reason: order.reason, realizedPnl }); + } + } + + private async runPrompt(prompt: string): Promise { + const submit = await fetch(`${API_URL}/agent/prompt`, { + method: 'POST', + headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }); + if (!submit.ok) throw new Error(`Bankr submit failed: ${submit.status} ${await submit.text()}`); + const { jobId } = (await submit.json()) as { jobId: string }; + + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)); + const res = await fetch(`${API_URL}/agent/job/${jobId}`, { headers: { 'X-API-Key': this.apiKey } }); + if (!res.ok) continue; + const job = (await res.json()) as { status: string; response?: string }; + if (job.status === 'completed') return job.response ?? ''; + if (job.status === 'failed' || job.status === 'cancelled') { + throw new Error(`Bankr job ${job.status}`); + } + } + throw new Error('Bankr job timed out'); + } +} diff --git a/agent-battle-arena/src/engine/broker.ts b/agent-battle-arena/src/engine/broker.ts new file mode 100644 index 0000000000..ad2c9e4bcd --- /dev/null +++ b/agent-battle-arena/src/engine/broker.ts @@ -0,0 +1,14 @@ +import type { AgentState, Order, TokenTick, Trade } from '../types.ts'; + +export interface Broker { + readonly mode: 'sim' | 'real'; + // Execute an order for an agent at the given tick/price context. + // Mutates agent state and returns the resulting trade, or null if skipped. + execute(agent: AgentState, order: Order, tick: TokenTick, tickIndex: number): Promise; +} + +export const TRADING_FEE = 0.003; // 0.3% per fill, applied to both sides + +export function newTradeId(): string { + return 'tx_' + Math.random().toString(36).slice(2, 10); +} diff --git a/agent-battle-arena/src/engine/simBroker.ts b/agent-battle-arena/src/engine/simBroker.ts new file mode 100644 index 0000000000..e9e08fdfea --- /dev/null +++ b/agent-battle-arena/src/engine/simBroker.ts @@ -0,0 +1,76 @@ +import type { AgentState, Order, TokenTick, Trade } from '../types.ts'; +import { type Broker, TRADING_FEE, newTradeId } from './broker.ts'; + +// Paper-trading broker. No real funds — pure in-memory accounting against the +// simulated market price. Applies a flat fee on each fill. +export class SimBroker implements Broker { + readonly mode = 'sim' as const; + + async execute(agent: AgentState, order: Order, tick: TokenTick, tickIndex: number): Promise { + const price = tick.price; + if (price <= 0) return null; + + if (order.side === 'buy') { + const spend = Math.min(order.usd, agent.cashUsd); + if (spend < 1) return null; + const fee = spend * TRADING_FEE; + const qty = (spend - fee) / price; + if (qty <= 0) return null; + + agent.cashUsd -= spend; + const pos = agent.positions[order.symbol]; + if (pos && pos.qty > 0) { + const totalCost = pos.avgPrice * pos.qty + price * qty; + pos.qty += qty; + pos.avgPrice = totalCost / pos.qty; + } else { + agent.positions[order.symbol] = { symbol: order.symbol, qty, avgPrice: price, openedTick: tickIndex }; + } + + const trade: Trade = { + id: newTradeId(), + agentId: agent.id, + tick: tickIndex, + symbol: order.symbol, + side: 'buy', + qty, + price, + usd: spend, + reason: order.reason, + }; + agent.trades.push(trade); + return trade; + } + + // sell + const pos = agent.positions[order.symbol]; + if (!pos || pos.qty <= 0) return null; + const fraction = order.fraction ?? 1; + const qty = pos.qty * Math.min(1, Math.max(0, fraction)); + if (qty <= 0) return null; + + const gross = qty * price; + const fee = gross * TRADING_FEE; + const proceeds = gross - fee; + const realizedPnl = (price - pos.avgPrice) * qty - fee; + + agent.cashUsd += proceeds; + pos.qty -= qty; + if (pos.qty <= 1e-9) delete agent.positions[order.symbol]; + + const trade: Trade = { + id: newTradeId(), + agentId: agent.id, + tick: tickIndex, + symbol: order.symbol, + side: 'sell', + qty, + price, + usd: proceeds, + reason: order.reason, + realizedPnl, + }; + agent.trades.push(trade); + return trade; + } +} diff --git a/agent-battle-arena/src/market/market.ts b/agent-battle-arena/src/market/market.ts new file mode 100644 index 0000000000..616bdcf044 --- /dev/null +++ b/agent-battle-arena/src/market/market.ts @@ -0,0 +1,189 @@ +import { Rng } from '../util/rng.ts'; +import type { MarketSnapshot, TokenTick } from '../types.ts'; + +type Category = 'bluechip' | 'meme' | 'narrative' | 'micro'; + +interface TokenDef { + symbol: string; + category: Category; + startPrice: number; + drift: number; // per-tick mean return + vol: number; // per-tick volatility + liquidityUsd: number; + rugTick: number | null; // tick at which this token rugs (null = never) +} + +const UNIVERSE: { symbol: string; category: Category; startPrice: number }[] = [ + { symbol: 'WETH', category: 'bluechip', startPrice: 3400 }, + { symbol: 'SOL', category: 'bluechip', startPrice: 165 }, + { symbol: 'cbBTC', category: 'bluechip', startPrice: 68000 }, + { symbol: 'DEGEN', category: 'meme', startPrice: 0.012 }, + { symbol: 'BRETT', category: 'meme', startPrice: 0.09 }, + { symbol: 'MOG', category: 'meme', startPrice: 0.0000012 }, + { symbol: 'PEPE', category: 'meme', startPrice: 0.0000095 }, + { symbol: 'AIXBT', category: 'narrative', startPrice: 0.32 }, + { symbol: 'VIRTUAL', category: 'narrative', startPrice: 1.9 }, + { symbol: 'GAME', category: 'narrative', startPrice: 0.04 }, + { symbol: 'CLANKER', category: 'narrative', startPrice: 28 }, + { symbol: 'WIF', category: 'meme', startPrice: 2.1 }, + { symbol: 'SAFEMOONX', category: 'micro', startPrice: 0.00004 }, + { symbol: 'INUMAXX', category: 'micro', startPrice: 0.0007 }, + { symbol: 'GIGACHAD9000', category: 'micro', startPrice: 0.00002 }, + { symbol: 'MOONROCKET', category: 'micro', startPrice: 0.00011 }, +]; + +// Occasional jump shocks per tier. Blue chips don't jump; speculative tiers do. +const JUMP: Record = { + bluechip: { prob: 0, min: 0, max: 0, upBias: 0.5 }, + meme: { prob: 0.05, min: 0.12, max: 0.5, upBias: 0.62 }, + narrative: { prob: 0.05, min: 0.1, max: 0.4, upBias: 0.62 }, + micro: { prob: 0.06, min: 0.15, max: 0.6, upBias: 0.55 }, +}; + +const PARAMS: Record = { + bluechip: { drift: 0.0006, vol: 0.012, liq: 8_000_000, rugProb: 0 }, + meme: { drift: 0.004, vol: 0.07, liq: 600_000, rugProb: 0.1 }, + narrative: { drift: 0.006, vol: 0.05, liq: 1_200_000, rugProb: 0.05 }, + micro: { drift: 0.01, vol: 0.13, liq: 45_000, rugProb: 0.75 }, +}; + +export interface Season { + seed: number; + ticks: number; + snapshots: MarketSnapshot[]; + rugs: { symbol: string; tick: number }[]; +} + +// How many ticks before the rug the risk score visibly ramps up. This is the +// window in which a careful strategy can detect and skip the token. +const RUG_WARNING_WINDOW = 8; + +export function generateSeason(seed: number, ticks: number): Season { + const rng = new Rng(seed); + + const defs: TokenDef[] = UNIVERSE.map((u) => { + const p = PARAMS[u.category]; + const rugTick = + p.rugProb > 0 && rng.chance(p.rugProb * 1.2) + ? rng.int(Math.floor(ticks * 0.15), Math.floor(ticks * 0.85)) + : null; + return { + symbol: u.symbol, + category: u.category, + startPrice: u.startPrice, + drift: p.drift, + vol: p.vol, + liquidityUsd: p.liq, + rugTick, + }; + }); + + const snapshots: MarketSnapshot[] = []; + const rugs: { symbol: string; tick: number }[] = []; + + // running state per token + const price: Record = {}; + const narrative: Record = {}; + const liquidity: Record = {}; + const dead: Record = {}; + const history: Record = {}; + for (const d of defs) { + price[d.symbol] = d.startPrice; + narrative[d.symbol] = d.category === 'narrative' ? rng.range(0.3, 0.55) : rng.range(0.05, 0.25); + liquidity[d.symbol] = d.liquidityUsd; + dead[d.symbol] = false; + history[d.symbol] = [d.startPrice]; + } + + for (let t = 0; t < ticks; t++) { + const tokens: Record = {}; + + for (const d of defs) { + const sym = d.symbol; + let rugged = false; + + if (!dead[sym]) { + if (d.rugTick !== null && t >= d.rugTick) { + // the rug fires: liquidity pulled, price collapses ~90-98% + price[sym] = price[sym] * rng.range(0.02, 0.1); + liquidity[sym] = liquidity[sym] * 0.02; + dead[sym] = true; + rugged = true; + rugs.push({ symbol: sym, tick: t }); + } else { + // normal random walk with occasional pumps. Blue chips grind; the + // speculative tiers get violent jumps (the source of meme/degen edge). + let ret = d.drift + d.vol * rng.gauss(); + const jump = JUMP[d.category]; + if (jump.prob > 0 && rng.chance(jump.prob)) { + ret += rng.range(jump.min, jump.max) * (rng.chance(jump.upBias) ? 1 : -1); + } + price[sym] = Math.max(price[sym] * (1 + ret), 1e-12); + // liquidity drifts with activity + liquidity[sym] = Math.max(liquidity[sym] * rng.range(0.97, 1.04), 1000); + } + } else { + // dead token bleeds toward zero + price[sym] = price[sym] * rng.range(0.85, 1.0); + } + + history[sym].push(price[sym]); + const hist = history[sym]; + const p1h = hist.length > 1 ? price[sym] / hist[hist.length - 2] - 1 : 0; + const idx24 = Math.max(0, hist.length - 25); + const p24h = price[sym] / hist[idx24] - 1; + + // narrative score evolves; narrative tokens can spike on "news" + let nv = narrative[sym]; + nv += 0.04 * rng.gauss(); + if (d.category === 'narrative' && rng.chance(0.06)) nv += rng.range(0.15, 0.35); + nv = Math.min(1, Math.max(0, nv)); + narrative[sym] = nv; + + // whale net flow — smart money. bigger, persistent prints near real moves. + let whale = liquidity[sym] * 0.01 * rng.gauss(); + if (rng.chance(0.08)) whale += liquidity[sym] * rng.range(0.05, 0.2) * (rng.chance(0.55) ? 1 : -1); + + // rug risk score: ramps up in the warning window before a scheduled rug + let rugRisk = baseRisk(d.category); + if (d.rugTick !== null && !dead[sym]) { + const dist = d.rugTick - t; + if (dist >= 0 && dist <= RUG_WARNING_WINDOW) { + rugRisk = Math.min(0.97, rugRisk + (1 - dist / RUG_WARNING_WINDOW) * 0.8); + } + } + if (dead[sym]) rugRisk = 0.99; + + tokens[sym] = { + symbol: sym, + price: price[sym], + liquidityUsd: liquidity[sym], + volume24hUsd: liquidity[sym] * rng.range(0.2, 2.5), + priceChange1h: p1h, + priceChange24h: p24h, + ageHours: t, + narrativeScore: nv, + whaleNetFlowUsd: whale, + rugRisk, + rugged, + }; + } + + snapshots.push({ tick: t, tokens }); + } + + return { seed, ticks, snapshots, rugs }; +} + +function baseRisk(c: Category): number { + switch (c) { + case 'bluechip': + return 0.02; + case 'narrative': + return 0.12; + case 'meme': + return 0.22; + case 'micro': + return 0.45; + } +} diff --git a/agent-battle-arena/src/metrics/leaderboard.ts b/agent-battle-arena/src/metrics/leaderboard.ts new file mode 100644 index 0000000000..2311b96265 --- /dev/null +++ b/agent-battle-arena/src/metrics/leaderboard.ts @@ -0,0 +1,90 @@ +import type { AgentScore, AgentState, ArenaState } from '../types.ts'; + +interface ClosedTrade { + symbol: string; + pnlUsd: number; + pnlPct: number; + proceeds: number; +} + +function closedTrades(agent: AgentState): ClosedTrade[] { + const out: ClosedTrade[] = []; + for (const t of agent.trades) { + if (t.side !== 'sell' || t.realizedPnl === undefined) continue; + const cost = t.usd - t.realizedPnl; // proceeds - pnl ≈ cost basis + const pnlPct = cost > 0 ? t.realizedPnl / cost : t.realizedPnl < 0 ? -1 : 0; + out.push({ symbol: t.symbol, pnlUsd: t.realizedPnl, pnlPct, proceeds: t.usd }); + } + return out; +} + +function maxDrawdownPct(agent: AgentState): number { + let peak = -Infinity; + let maxDd = 0; + for (const p of agent.equityCurve) { + if (p.equityUsd > peak) peak = p.equityUsd; + if (peak > 0) { + const dd = (peak - p.equityUsd) / peak; + if (dd > maxDd) maxDd = dd; + } + } + return maxDd; +} + +export function computeScore(state: ArenaState, agent: AgentState): AgentScore { + const lastEq = agent.equityCurve.length ? agent.equityCurve[agent.equityCurve.length - 1].equityUsd : agent.cashUsd; + const pnlUsd = lastEq - agent.startingCashUsd; + const pnlPct = agent.startingCashUsd > 0 ? pnlUsd / agent.startingCashUsd : 0; + + const closed = closedTrades(agent); + const wins = closed.filter((c) => c.pnlUsd > 0).length; + const winRate = closed.length ? wins / closed.length : 0; + + const ruggedSet = new Set(state.ruggedTokens.map((r) => r.symbol)); + const rugTickBySymbol = new Map(state.ruggedTokens.map((r) => [r.symbol, r.tick])); + + const heldRugSymbols = new Set( + agent.trades.filter((t) => t.reason.startsWith('RUGGED:')).map((t) => t.symbol), + ); + const rugsHeld = heldRugSymbols.size; + + const avoided = new Set(); + for (const sk of agent.skips) { + if (!ruggedSet.has(sk.symbol)) continue; + if (heldRugSymbols.has(sk.symbol)) continue; + const rugTick = rugTickBySymbol.get(sk.symbol) ?? Infinity; + if (sk.tick <= rugTick) avoided.add(sk.symbol); + } + const rugsAvoided = avoided.size; + + let bestCall: AgentScore['bestCall']; + let worstTrade: AgentScore['worstTrade']; + for (const c of closed) { + if (!bestCall || c.pnlPct > bestCall.pnlPct) bestCall = { symbol: c.symbol, pnlPct: c.pnlPct, usd: c.pnlUsd }; + if (!worstTrade || c.pnlPct < worstTrade.pnlPct) worstTrade = { symbol: c.symbol, pnlPct: c.pnlPct, usd: c.pnlUsd }; + } + + return { + agentId: agent.id, + name: agent.name, + owner: agent.owner, + personality: agent.personality, + mode: agent.mode, + equityUsd: lastEq, + pnlUsd, + pnlPct, + maxDrawdownPct: maxDrawdownPct(agent), + winRate, + closedTrades: closed.length, + rugsAvoided, + rugsHeld, + bestCall, + worstTrade, + }; +} + +export function leaderboard(state: ArenaState): AgentScore[] { + return Object.values(state.agents) + .map((a) => computeScore(state, a)) + .sort((a, b) => b.pnlPct - a.pnlPct); +} diff --git a/agent-battle-arena/src/personalities/aiNarrativeTrader.ts b/agent-battle-arena/src/personalities/aiNarrativeTrader.ts new file mode 100644 index 0000000000..6744668b88 --- /dev/null +++ b/agent-battle-arena/src/personalities/aiNarrativeTrader.ts @@ -0,0 +1,47 @@ +import type { Decision, Order, SkipEvent, Strategy, StrategyContext } from '../types.ts'; +import { holds, openPositionCount, positionPnlPct, size, tokensArray } from './helpers.ts'; + +// Trades the narrative. Rotates into whatever has rising mindshare and exits +// when the story fades. Disciplined on risk — a fading narrative is a sell. +export const aiNarrativeTrader: Strategy = { + personality: 'ai-narrative-trader', + label: 'AI Narrative Trader', + blurb: 'Rotates into rising-mindshare narratives, exits when the story fades.', + + decide(ctx: StrategyContext): Decision { + const { snapshot, agent } = ctx; + const orders: Order[] = []; + const skips: SkipEvent[] = []; + + for (const sym of Object.keys(agent.positions)) { + const t = snapshot.tokens[sym]; + if (!t || agent.positions[sym].qty <= 0) continue; + const pnl = positionPnlPct(agent, t); + if (t.narrativeScore < 0.4 || t.rugRisk > 0.5) { + orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: 'narrative faded' }); + } else if (pnl > 0.8) { + orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 0.5, reason: `trimmed winner +${(pnl * 100).toFixed(0)}%` }); + } else if (pnl < -0.35) { + orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: `thesis wrong ${(pnl * 100).toFixed(0)}%` }); + } + } + + const candidates = tokensArray(snapshot.tokens) + .filter((t) => t.narrativeScore > 0.65 && t.priceChange24h > -0.05) + .sort((a, b) => b.narrativeScore - a.narrativeScore); + + for (const t of candidates) { + if (openPositionCount(agent) >= 4) break; + if (holds(agent, t.symbol)) continue; + if (t.rugRisk > 0.45) { + skips.push({ symbol: t.symbol, tick: snapshot.tick, reason: 'narrative ≠ safe contract' }); + continue; + } + const usd = size(agent, 0.15); + if (usd < 5) break; + orders.push({ symbol: t.symbol, side: 'buy', usd, reason: `narrative ${(t.narrativeScore * 100).toFixed(0)}/100` }); + } + + return { orders, skips }; + }, +}; diff --git a/agent-battle-arena/src/personalities/conservativeDca.ts b/agent-battle-arena/src/personalities/conservativeDca.ts new file mode 100644 index 0000000000..d56a3a398c --- /dev/null +++ b/agent-battle-arena/src/personalities/conservativeDca.ts @@ -0,0 +1,49 @@ +import type { Decision, Order, SkipEvent, Strategy, StrategyContext } from '../types.ts'; +import { positionPnlPct, size, tokensArray } from './helpers.ts'; + +// Boring on purpose. Buys a fixed slice of the two safest, deepest-liquidity +// assets every tick. Almost never sells. Wins on low drawdown, not fireworks. +export const conservativeDca: Strategy = { + personality: 'conservative-dca', + label: 'Conservative DCA', + blurb: 'Dollar-cost-averages into blue chips. Lowest drawdown, steady curve.', + + decide(ctx: StrategyContext): Decision { + const { snapshot, agent } = ctx; + const orders: Order[] = []; + const skips: SkipEvent[] = []; + + const safe = tokensArray(snapshot.tokens) + .filter((t) => t.rugRisk < 0.05 && t.liquidityUsd > 3_000_000) + .sort((a, b) => b.liquidityUsd - a.liquidityUsd) + .slice(0, 2); + + // note the things it deliberately won't touch (anything risky in the book) + for (const t of tokensArray(snapshot.tokens)) { + if (t.rugRisk >= 0.1) { + skips.push({ symbol: t.symbol, tick: snapshot.tick, reason: 'outside risk mandate' }); + } + } + + // trim only if something blew up beyond -40% (capital preservation) + for (const sym of Object.keys(agent.positions)) { + const t = snapshot.tokens[sym]; + if (!t || agent.positions[sym].qty <= 0) continue; + if (positionPnlPct(agent, t) < -0.4) { + orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: 'capital preservation exit' }); + } + } + + // DCA a fixed slice this tick, split across the safe set + const perTick = size(agent, 0.012); // ~1.2% of bankroll/tick + if (perTick >= 2 && safe.length > 0) { + const each = perTick / safe.length; + for (const t of safe) { + if (each < 1) continue; + orders.push({ symbol: t.symbol, side: 'buy', usd: each, reason: 'scheduled DCA buy' }); + } + } + + return { orders, skips }; + }, +}; diff --git a/agent-battle-arena/src/personalities/degenSniper.ts b/agent-battle-arena/src/personalities/degenSniper.ts new file mode 100644 index 0000000000..3b4dbe9111 --- /dev/null +++ b/agent-battle-arena/src/personalities/degenSniper.ts @@ -0,0 +1,44 @@ +import type { Decision, Order, SkipEvent, Strategy, StrategyContext } from '../types.ts'; +import { holds, openPositionCount, positionPnlPct, size, tokensArray } from './helpers.ts'; + +// Max risk, max size, fastest finger. Snipes fresh explosive movers with huge +// position sizes and a high rug tolerance — sometimes that means holding a rug. +export const degenSniper: Strategy = { + personality: 'degen-sniper', + label: 'Degen Sniper', + blurb: 'Apes fresh runners with size and a high rug tolerance. High variance.', + + decide(ctx: StrategyContext): Decision { + const { snapshot, agent } = ctx; + const orders: Order[] = []; + const skips: SkipEvent[] = []; + + // fast flips: cut or bank quickly + for (const sym of Object.keys(agent.positions)) { + const t = snapshot.tokens[sym]; + if (!t || agent.positions[sym].qty <= 0) continue; + const pnl = positionPnlPct(agent, t); + if (pnl > 0.4) orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: `flipped +${(pnl * 100).toFixed(0)}%` }); + else if (pnl < -0.2) orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: `cut ${(pnl * 100).toFixed(0)}%` }); + } + + const candidates = tokensArray(snapshot.tokens) + .filter((t) => t.priceChange1h > 0.08 && t.volume24hUsd > 20_000) + .sort((a, b) => b.priceChange1h - a.priceChange1h); + + for (const t of candidates) { + if (openPositionCount(agent) >= 3) break; + if (holds(agent, t.symbol)) continue; + // degen only blinks at near-certain rugs; otherwise sends it + if (t.rugRisk > 0.75) { + skips.push({ symbol: t.symbol, tick: snapshot.tick, reason: 'even degen says no' }); + continue; + } + const usd = size(agent, 0.22); + if (usd < 5) break; + orders.push({ symbol: t.symbol, side: 'buy', usd, reason: `aped +${(t.priceChange1h * 100).toFixed(0)}% / 1h` }); + } + + return { orders, skips }; + }, +}; diff --git a/agent-battle-arena/src/personalities/helpers.ts b/agent-battle-arena/src/personalities/helpers.ts new file mode 100644 index 0000000000..437e2511ed --- /dev/null +++ b/agent-battle-arena/src/personalities/helpers.ts @@ -0,0 +1,27 @@ +import type { AgentState, TokenTick } from '../types.ts'; + +export function tokensArray(tokens: Record): TokenTick[] { + return Object.values(tokens).filter((t) => !t.rugged && t.price > 0); +} + +export function holds(agent: AgentState, symbol: string): boolean { + const p = agent.positions[symbol]; + return !!p && p.qty > 0; +} + +export function positionPnlPct(agent: AgentState, t: TokenTick): number { + const p = agent.positions[t.symbol]; + if (!p || p.qty <= 0) return 0; + return t.price / p.avgPrice - 1; +} + +// Notional sized as a fraction of the agent's *starting* bankroll, clamped to +// available cash. Keeps sizing stable as equity swings. +export function size(agent: AgentState, fraction: number): number { + const want = agent.startingCashUsd * fraction; + return Math.max(0, Math.min(want, agent.cashUsd)); +} + +export function openPositionCount(agent: AgentState): number { + return Object.values(agent.positions).filter((p) => p.qty > 0).length; +} diff --git a/agent-battle-arena/src/personalities/index.ts b/agent-battle-arena/src/personalities/index.ts new file mode 100644 index 0000000000..a7e637f273 --- /dev/null +++ b/agent-battle-arena/src/personalities/index.ts @@ -0,0 +1,18 @@ +import type { Personality, Strategy } from '../types.ts'; +import { memeHunter } from './memeHunter.ts'; +import { conservativeDca } from './conservativeDca.ts'; +import { degenSniper } from './degenSniper.ts'; +import { whaleFollower } from './whaleFollower.ts'; +import { aiNarrativeTrader } from './aiNarrativeTrader.ts'; + +export const STRATEGIES: Record = { + 'meme-hunter': memeHunter, + 'conservative-dca': conservativeDca, + 'degen-sniper': degenSniper, + 'whale-follower': whaleFollower, + 'ai-narrative-trader': aiNarrativeTrader, +}; + +export function getStrategy(p: Personality): Strategy { + return STRATEGIES[p]; +} diff --git a/agent-battle-arena/src/personalities/memeHunter.ts b/agent-battle-arena/src/personalities/memeHunter.ts new file mode 100644 index 0000000000..fe685038a1 --- /dev/null +++ b/agent-battle-arena/src/personalities/memeHunter.ts @@ -0,0 +1,49 @@ +import type { Decision, Order, SkipEvent, Strategy, StrategyContext } from '../types.ts'; +import { holds, openPositionCount, positionPnlPct, size, tokensArray } from './helpers.ts'; + +// Chases momentum memes. Loves a runner, but bails the moment risk spikes — +// a near-rug is exactly the kind of token it records as "avoided". +export const memeHunter: Strategy = { + personality: 'meme-hunter', + label: 'Meme Hunter', + blurb: 'Chases momentum memecoins; skips anything that smells like a rug.', + + decide(ctx: StrategyContext): Decision { + const { snapshot, agent } = ctx; + const orders: Order[] = []; + const skips: SkipEvent[] = []; + + // exits first + for (const sym of Object.keys(agent.positions)) { + const t = snapshot.tokens[sym]; + if (!t || agent.positions[sym].qty <= 0) continue; + const pnl = positionPnlPct(agent, t); + if (t.rugRisk > 0.5) { + orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: 'risk spiked — dumping' }); + } else if (pnl > 0.6) { + orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: `took profit +${(pnl * 100).toFixed(0)}%` }); + } else if (pnl < -0.25) { + orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: `stopped out ${(pnl * 100).toFixed(0)}%` }); + } + } + + // entries — momentum movers with real liquidity + const candidates = tokensArray(snapshot.tokens) + .filter((t) => t.priceChange24h > 0.25 && t.priceChange1h > 0 && t.liquidityUsd > 50_000) + .sort((a, b) => b.priceChange24h - a.priceChange24h); + + for (const t of candidates) { + if (openPositionCount(agent) >= 6) break; + if (holds(agent, t.symbol)) continue; + if (t.rugRisk > 0.5) { + skips.push({ symbol: t.symbol, tick: snapshot.tick, reason: `rugRisk ${t.rugRisk.toFixed(2)} too high` }); + continue; + } + const usd = size(agent, 0.08); + if (usd < 5) break; + orders.push({ symbol: t.symbol, side: 'buy', usd, reason: `momentum +${(t.priceChange24h * 100).toFixed(0)}% / 24h` }); + } + + return { orders, skips }; + }, +}; diff --git a/agent-battle-arena/src/personalities/whaleFollower.ts b/agent-battle-arena/src/personalities/whaleFollower.ts new file mode 100644 index 0000000000..7745a2ae7d --- /dev/null +++ b/agent-battle-arena/src/personalities/whaleFollower.ts @@ -0,0 +1,46 @@ +import type { Decision, Order, SkipEvent, Strategy, StrategyContext } from '../types.ts'; +import { holds, openPositionCount, positionPnlPct, size, tokensArray } from './helpers.ts'; + +// Follows smart money. Buys where whales are net-buying, exits when they leave. +// Whales don't touch honeypots, so neither does it — good rug-avoid record. +export const whaleFollower: Strategy = { + personality: 'whale-follower', + label: 'Whale Follower', + blurb: 'Mirrors smart-money flow. Buys whale accumulation, exits distribution.', + + decide(ctx: StrategyContext): Decision { + const { snapshot, agent } = ctx; + const orders: Order[] = []; + const skips: SkipEvent[] = []; + + // exit when whales rotate out or risk shows up + for (const sym of Object.keys(agent.positions)) { + const t = snapshot.tokens[sym]; + if (!t || agent.positions[sym].qty <= 0) continue; + const pnl = positionPnlPct(agent, t); + if (t.whaleNetFlowUsd < -t.liquidityUsd * 0.03 || t.rugRisk > 0.5) { + orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: 'smart money exiting' }); + } else if (pnl < -0.3) { + orders.push({ symbol: sym, side: 'sell', usd: 0, fraction: 1, reason: `stop ${(pnl * 100).toFixed(0)}%` }); + } + } + + const candidates = tokensArray(snapshot.tokens) + .filter((t) => t.whaleNetFlowUsd > t.liquidityUsd * 0.04) + .sort((a, b) => b.whaleNetFlowUsd - a.whaleNetFlowUsd); + + for (const t of candidates) { + if (openPositionCount(agent) >= 5) break; + if (holds(agent, t.symbol)) continue; + if (t.rugRisk > 0.45) { + skips.push({ symbol: t.symbol, tick: snapshot.tick, reason: 'whales avoid honeypots' }); + continue; + } + const usd = size(agent, 0.12); + if (usd < 5) break; + orders.push({ symbol: t.symbol, side: 'buy', usd, reason: `whale inflow $${(t.whaleNetFlowUsd / 1000).toFixed(0)}k` }); + } + + return { orders, skips }; + }, +}; diff --git a/agent-battle-arena/src/store/store.ts b/agent-battle-arena/src/store/store.ts new file mode 100644 index 0000000000..0e99571cb0 --- /dev/null +++ b/agent-battle-arena/src/store/store.ts @@ -0,0 +1,37 @@ +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ArenaState } from '../types.ts'; +import { generateSeason, type Season } from '../market/market.ts'; + +// Default the arena dir to the project root (src/store/ → ../../) so the same +// arena is found regardless of the working directory the CLI is run from. +const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const DIR = process.env.ARENA_DIR ?? join(PROJECT_ROOT, '.arena'); +const STATE_PATH = join(DIR, 'state.json'); + +export function statePath(): string { + return STATE_PATH; +} + +export function hasState(): boolean { + return existsSync(STATE_PATH); +} + +export function saveState(state: ArenaState): void { + mkdirSync(dirname(STATE_PATH), { recursive: true }); + writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); +} + +export function loadState(): ArenaState { + if (!existsSync(STATE_PATH)) { + throw new Error(`No arena found. Run "arena new" or "arena seed-demo" first. (looked in ${STATE_PATH})`); + } + return JSON.parse(readFileSync(STATE_PATH, 'utf8')) as ArenaState; +} + +// The market is fully determined by (seed, seasonTicks), so we regenerate it on +// demand rather than persisting every snapshot. +export function seasonFor(state: ArenaState): Season { + return generateSeason(state.seed, state.seasonTicks); +} diff --git a/agent-battle-arena/src/types.ts b/agent-battle-arena/src/types.ts new file mode 100644 index 0000000000..b4a2c7483b --- /dev/null +++ b/agent-battle-arena/src/types.ts @@ -0,0 +1,134 @@ +// Core domain types for Agent Battle Arena. + +export const PERSONALITIES = [ + 'meme-hunter', + 'conservative-dca', + 'degen-sniper', + 'whale-follower', + 'ai-narrative-trader', +] as const; + +export type Personality = (typeof PERSONALITIES)[number]; + +export type TradeMode = 'sim' | 'real'; + +export type Side = 'buy' | 'sell'; + +// A single token in the market universe at a given tick. +export interface TokenTick { + symbol: string; + price: number; + liquidityUsd: number; + volume24hUsd: number; + priceChange1h: number; // fractional, e.g. 0.12 = +12% + priceChange24h: number; + ageHours: number; // how long the token has existed + narrativeScore: number; // 0..1 — AI-narrative mindshare + whaleNetFlowUsd: number; // net smart-money flow this tick (+ buy / - sell) + rugRisk: number; // 0..1 model risk score (honeypot / drain likelihood) + rugged: boolean; // true on the tick a rug actually happens +} + +export interface MarketSnapshot { + tick: number; + tokens: Record; +} + +export interface Position { + symbol: string; + qty: number; + avgPrice: number; + openedTick: number; +} + +// A skip is an explicit "I evaluated this token and chose NOT to hold it for +// risk reasons". If that token later rugs, it counts as a rug avoided. +export interface SkipEvent { + symbol: string; + tick: number; + reason: string; +} + +export interface Trade { + id: string; + agentId: string; + tick: number; + symbol: string; + side: Side; + qty: number; + price: number; + usd: number; + reason: string; + realizedPnl?: number; // set on sells (close) +} + +export interface AgentState { + id: string; + name: string; + owner: string; + personality: Personality; + mode: TradeMode; + startingCashUsd: number; + cashUsd: number; + positions: Record; + trades: Trade[]; + skips: SkipEvent[]; + equityCurve: { tick: number; equityUsd: number }[]; + createdAt: string; +} + +export interface ArenaState { + seasonId: string; + seed: number; + seasonTicks: number; + tick: number; + startedAt: string; + agents: Record; + ruggedTokens: { symbol: string; tick: number }[]; +} + +// An order a strategy wants to place this tick. +export interface Order { + symbol: string; + side: Side; + usd: number; // notional to spend (buy) or position fraction handled by engine for sells + fraction?: number; // for sells: fraction of held qty to sell (0..1). Defaults 1. + reason: string; +} + +// Decision returned by a strategy each tick. +export interface Decision { + orders: Order[]; + skips: SkipEvent[]; +} + +// What a strategy sees when deciding. +export interface StrategyContext { + snapshot: MarketSnapshot; + agent: AgentState; +} + +export interface Strategy { + personality: Personality; + label: string; + blurb: string; + decide(ctx: StrategyContext): Decision; +} + +export interface AgentScore { + agentId: string; + name: string; + owner: string; + personality: Personality; + mode: TradeMode; + equityUsd: number; + pnlUsd: number; + pnlPct: number; + maxDrawdownPct: number; + winRate: number; // 0..1 over closed trades + closedTrades: number; + rugsAvoided: number; + rugsHeld: number; // rugs the agent was holding when they blew up + bestCall?: { symbol: string; pnlPct: number; usd: number }; + worstTrade?: { symbol: string; pnlPct: number; usd: number }; +} diff --git a/agent-battle-arena/src/util/rng.ts b/agent-battle-arena/src/util/rng.ts new file mode 100644 index 0000000000..fa98d95b9f --- /dev/null +++ b/agent-battle-arena/src/util/rng.ts @@ -0,0 +1,43 @@ +// Deterministic, seedable PRNG so battles are reproducible from a seed. +// mulberry32 — small, fast, good enough for simulation. + +export class Rng { + private state: number; + + constructor(seed: number) { + this.state = seed >>> 0; + } + + // float in [0, 1) + next(): number { + this.state |= 0; + this.state = (this.state + 0x6d2b79f5) | 0; + let t = Math.imul(this.state ^ (this.state >>> 15), 1 | this.state); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + // float in [min, max) + range(min: number, max: number): number { + return min + this.next() * (max - min); + } + + // integer in [min, max] inclusive + int(min: number, max: number): number { + return Math.floor(this.range(min, max + 1)); + } + + // true with probability p + chance(p: number): boolean { + return this.next() < p; + } + + pick(arr: readonly T[]): T { + return arr[this.int(0, arr.length - 1)]; + } + + // gaussian-ish via central limit (sum of 3 uniforms), mean 0 + gauss(): number { + return (this.next() + this.next() + this.next() - 1.5) / 1.5; + } +} diff --git a/agent-battle-arena/tsconfig.json b/agent-battle-arena/tsconfig.json new file mode 100644 index 0000000000..5a98b34e8c --- /dev/null +++ b/agent-battle-arena/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}