diff --git a/README.md b/README.md index 54d7b32..e4244a9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,39 @@ # agent-cli An agent-native Hyperliquid trading CLI with JSON input/output, a persistent -signing runner, Privy managed agents, Relay funding, and a packaged agent skill. +signing runner, Privy managed agents, Relay funding, a packaged agent skill, +and a paper trading mode that runs against real mainnet market data. Install: `npm i -g @bloxwap/agent-cli` or `bunx @bloxwap/agent-cli --help`. The requested Hyperliquid SDK declares Node **22.12+**; use that supported runtime or current Bun. No postinstall script. Publishing is release-gated. +## Paper trading + +Paper mode simulates every trade against **real mainnet Hyperliquid public +market data** (info `l2Book`, WS `l2Book`/`trades`/`bbo`, `meta`, funding) and +never signs or submits anything. No Privy session, `approveAgent`, or deposit is +needed. Enable it per command with the global `--paper` flag, persistently with +`config set mode paper`, or for the runner with `run --paper`: + +```sh +node packages/agent-cli/dist/cli.js paper reset --yes +node packages/agent-cli/dist/cli.js order --paper --json '{"coin":"BTC","side":"buy","size":"0.001","type":"market"}' +node packages/agent-cli/dist/cli.js positions --paper +node packages/agent-cli/dist/cli.js paper summary # PnL, fees, funding, trades +``` + +The local simulator fills market/IOC orders by walking the live book with +slippage, rests limits until the market trades through them, supports +reduce-only and TP/SL, charges Hyperliquid taker/maker fees plus the 1 bp +Bloxwap builder fee, accrues funding, and approximates margin and liquidation +with cross/isolated leverage. State lives under `~/.bloxwap/paper/` and +survives restarts; `paper reset` clears it. Every output envelope and event is +labeled `mode`/`paper`, paper state is fully separated from live state, and +switching back with `config set mode live` requires an explicit `--yes`. +Start agents in paper mode, then fund and go live only after the strategy is +proven. See [docs/paper.md](docs/paper.md). + ## Testnet quickstart Create your own [Privy account and app](https://dashboard.privy.io/) first. diff --git a/apps/docs/content/docs/commands.mdx b/apps/docs/content/docs/commands.mdx index ef60959..4f3fb94 100644 --- a/apps/docs/content/docs/commands.mdx +++ b/apps/docs/content/docs/commands.mdx @@ -5,7 +5,7 @@ description: Every registered command, with input and output schemas from the CL Generated from `agent-cli --help` and `agent-cli schema COMMAND`. Do not edit by hand. -Global flags: `--config PATH`, `--network mainnet|testnet`, `--pretty`, `--json OBJECT`. Inputs accept flags, JSON arguments, or a JSON object on stdin. Unknown object fields are rejected. +Global flags: `--config PATH`, `--network mainnet|testnet`, `--pretty`, `--json OBJECT`, `--paper`, `--live`. Inputs accept flags, JSON arguments, or a JSON object on stdin. Unknown object fields are rejected. ## Help and schema @@ -32,10 +32,15 @@ Get, set or validate local configuration "items": { "type": "string" } + }, + "yes": { + "default": false, + "type": "boolean" } }, "required": [ - "args" + "args", + "yes" ], "additionalProperties": false }, @@ -45,13 +50,24 @@ Get, set or validate local configuration "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { "const": true }, - "data": {} + "data": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } }, { @@ -61,7 +77,9 @@ Get, set or validate local configuration "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -93,7 +111,16 @@ Get, set or validate local configuration "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -142,7 +169,9 @@ Create agent and emit resumable master-wallet approvals "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -187,6 +216,15 @@ Create agent and emit resumable master-wallet approvals "policyId", "approved" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -197,7 +235,9 @@ Create agent and emit resumable master-wallet approvals "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -229,7 +269,16 @@ Create agent and emit resumable master-wallet approvals "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -290,7 +339,9 @@ Quote funding with address, QR and transparent fees "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -360,6 +411,15 @@ Quote funding with address, QR and transparent fees "expiresAt", "qrAscii" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -370,7 +430,9 @@ Quote funding with address, QR and transparent fees "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -402,7 +464,16 @@ Quote funding with address, QR and transparent fees "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -441,7 +512,9 @@ Resume deposit steps or submit a master-wallet signature "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -462,6 +535,15 @@ Resume deposit steps or submit a master-wallet signature } }, "required": [] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -472,7 +554,9 @@ Resume deposit steps or submit a master-wallet signature "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -504,7 +588,16 @@ Resume deposit steps or submit a master-wallet signature "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -530,7 +623,9 @@ Read account status "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -690,6 +785,15 @@ Read account status "approvedMaxBuilderFee", "runner" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -700,7 +804,9 @@ Read account status "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -732,7 +838,16 @@ Read account status "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -758,7 +873,9 @@ Read account balance "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -786,6 +903,15 @@ Read account balance "withdrawable", "marginUsed" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -796,7 +922,9 @@ Read account balance "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -828,7 +956,16 @@ Read account balance "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -854,7 +991,9 @@ Read account positions "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -899,6 +1038,15 @@ Read account positions "leverage" ] } + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -909,7 +1057,9 @@ Read account positions "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -941,7 +1091,16 @@ Read account positions "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -967,7 +1126,9 @@ Read account orders "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1008,6 +1169,15 @@ Read account orders "side" ] } + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1018,7 +1188,9 @@ Read account orders "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1050,7 +1222,16 @@ Read account orders "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1144,7 +1325,9 @@ Place limit, IOC market or TP/SL order "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1173,6 +1356,15 @@ Place limit, IOC market or TP/SL order "builderAddress", "cloid" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1183,7 +1375,9 @@ Place limit, IOC market or TP/SL order "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1215,7 +1409,16 @@ Place limit, IOC market or TP/SL order "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1280,7 +1483,9 @@ Cancel by oid/cloid, coin or all "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1301,6 +1506,15 @@ Cancel by oid/cloid, coin or all "cancelled", "result" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1311,7 +1525,9 @@ Cancel by oid/cloid, coin or all "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1343,7 +1559,16 @@ Cancel by oid/cloid, coin or all "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1415,7 +1640,9 @@ Modify price/size of single or batch resting orders "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1432,6 +1659,15 @@ Modify price/size of single or batch resting orders "required": [ "status" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1442,7 +1678,9 @@ Modify price/size of single or batch resting orders "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1474,7 +1712,16 @@ Modify price/size of single or batch resting orders "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1528,7 +1775,9 @@ Set market leverage and cross/isolated margin mode "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1545,6 +1794,15 @@ Set market leverage and cross/isolated margin mode "required": [ "status" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1555,7 +1813,9 @@ Set market leverage and cross/isolated margin mode "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1587,7 +1847,16 @@ Set market leverage and cross/isolated margin mode "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1612,11 +1881,16 @@ Start persistent signing runner (foreground or detach) "daemon": { "default": false, "type": "boolean" + }, + "paper": { + "default": false, + "type": "boolean" } }, "required": [ "detach", - "daemon" + "daemon", + "paper" ], "additionalProperties": false }, @@ -1626,7 +1900,9 @@ Start persistent signing runner (foreground or detach) "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1652,13 +1928,26 @@ Start persistent signing runner (foreground or detach) }, "existing": { "type": "boolean" + }, + "paper": { + "type": "boolean" } }, "required": [ "pid", "startedAt", - "network" + "network", + "paper" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1669,7 +1958,9 @@ Start persistent signing runner (foreground or detach) "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1701,7 +1992,16 @@ Start persistent signing runner (foreground or detach) "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1759,7 +2059,9 @@ Replay or follow JSON-line runner events "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1789,15 +2091,35 @@ Replay or follow JSON-line runner events "runner_state" ] }, - "data": {} + "data": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } }, "required": [ "seq", "timestamp", "type", - "data" + "data", + "mode", + "paper" ] } + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1808,7 +2130,9 @@ Replay or follow JSON-line runner events "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1840,7 +2164,16 @@ Replay or follow JSON-line runner events "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1891,7 +2224,9 @@ Read/follow redacted JSON-line runner logs "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1900,6 +2235,15 @@ Read/follow redacted JSON-line runner logs "data": { "description": "JSON-line stream of redacted log objects", "type": "object" + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1910,7 +2254,9 @@ Read/follow redacted JSON-line runner logs "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1942,7 +2288,16 @@ Read/follow redacted JSON-line runner logs "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1981,7 +2336,9 @@ Approve new agent, hot-swap signer, then revoke old agent "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2014,6 +2371,15 @@ Approve new agent, hot-swap signer, then revoke old agent "swapped", "oldRevoked" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2024,7 +2390,9 @@ Approve new agent, hot-swap signer, then revoke old agent "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2056,7 +2424,16 @@ Approve new agent, hot-swap signer, then revoke old agent "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -2099,7 +2476,9 @@ Disable local agent and prepare master-signed exchange revocation "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2129,6 +2508,15 @@ Disable local agent and prepare master-signed exchange revocation "requiresSignature", "approval" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2139,7 +2527,9 @@ Disable local agent and prepare master-signed exchange revocation "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2171,7 +2561,16 @@ Disable local agent and prepare master-signed exchange revocation "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -2207,7 +2606,9 @@ Verify and query the hash-chained trading audit "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2254,6 +2655,15 @@ Verify and query the hash-chained trading audit "hash" ] } + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2264,7 +2674,9 @@ Verify and query the hash-chained trading audit "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2296,7 +2708,16 @@ Verify and query the hash-chained trading audit "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -2349,7 +2770,9 @@ Install packaged version-matched skill for Claude Code or Codex "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2380,6 +2803,15 @@ Install packaged version-matched skill for Claude Code or Codex "version", "changed" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2390,7 +2822,9 @@ Install packaged version-matched skill for Claude Code or Codex "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2422,7 +2856,16 @@ Install packaged version-matched skill for Claude Code or Codex "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -2455,7 +2898,9 @@ Discover market asset, precision, leverage ceiling and current mid "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2487,6 +2932,230 @@ Discover market asset, precision, leverage ceiling and current mid "maxLeverage", "mid" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } + } + }, + { + "type": "object", + "required": [ + "ok", + "code", + "message", + "retryable", + "details", + "mode", + "paper" + ], + "properties": { + "ok": { + "const": false + }, + "code": { + "enum": [ + "INSUFFICIENT_MARGIN", + "POLICY_REJECTED", + "AGENT_EXPIRED", + "AGENT_NOT_APPROVED", + "DEPOSIT_PENDING", + "NONCE_COLLISION", + "RATE_LIMIT", + "INVALID_SIZE", + "INVALID_PRICE", + "UNKNOWN_MARKET", + "ORDER_NOT_FOUND", + "RUNNER_NOT_RUNNING", + "CONFIG_INVALID", + "NETWORK_ERROR", + "UPSTREAM_ERROR", + "INTERNAL" + ] + }, + "message": { + "type": "string" + }, + "retryable": { + "type": "boolean" + }, + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } + } + } + ] + } +} +``` + +## paper + +Reset or summarize the local paper trading account + +```json +{ + "input": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "args": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "yes": { + "default": false, + "type": "boolean" + }, + "balance": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "args", + "yes" + ], + "additionalProperties": false + }, + "output": { + "oneOf": [ + { + "type": "object", + "required": [ + "ok", + "data", + "mode", + "paper" + ], + "properties": { + "ok": { + "const": true + }, + "data": { + "type": "object", + "properties": { + "paper": { + "type": "boolean" + }, + "mode": { + "enum": [ + "paper" + ] + }, + "dataNetwork": { + "enum": [ + "mainnet" + ] + }, + "startingBalance": { + "type": "string" + }, + "balance": { + "type": "string" + }, + "equity": { + "type": "string" + }, + "netPnl": { + "type": "string" + }, + "realizedPnl": { + "type": "string" + }, + "unrealizedPnl": { + "type": "string" + }, + "feesPaid": { + "type": "string" + }, + "fundingPaid": { + "type": "string" + }, + "trades": { + "type": "integer" + }, + "liquidations": { + "type": "integer" + }, + "openOrders": { + "type": "integer" + }, + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "coin": { + "type": "string" + }, + "size": { + "type": "string" + }, + "entryPrice": { + "type": "string" + }, + "markPrice": { + "type": [ + "string", + "null" + ] + }, + "unrealizedPnl": { + "type": "string" + }, + "liquidationPrice": { + "type": [ + "string", + "null" + ] + }, + "leverage": { + "type": "string" + }, + "marginMode": { + "type": "string" + } + }, + "required": [ + "coin", + "size" + ] + } + }, + "reset": { + "type": "boolean" + } + }, + "required": [ + "paper", + "mode" + ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2497,7 +3166,9 @@ Discover market asset, precision, leverage ceiling and current mid "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2529,7 +3200,16 @@ Discover market asset, precision, leverage ceiling and current mid "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] diff --git a/apps/docs/content/docs/config.mdx b/apps/docs/content/docs/config.mdx index 1c35e81..68d9974 100644 --- a/apps/docs/content/docs/config.mdx +++ b/apps/docs/content/docs/config.mdx @@ -8,12 +8,19 @@ All command JSON objects reject unknown fields to catch intent-changing typos. Version 1 is validated on read and write; versionless config migrates to version 1. Unknown versions fail closed. Writes use a private temporary file and atomic rename. -`BLOXWAP_NETWORK`, `BLOXWAP_LOG_LEVEL`, `BLOXWAP_PRIVY_APP_ID` and +`BLOXWAP_NETWORK`, `BLOXWAP_MODE`, `BLOXWAP_LOG_LEVEL`, `BLOXWAP_PRIVY_APP_ID` and `BLOXWAP_MASTER_ADDRESS` override stored values in commands. `config get` shows persisted values; `config validate` validates them without overrides. Secrets use `env:BLOXWAP_PRIVY_APP_SECRET` and `env:BLOXWAP_AUTHORIZATION_KEY` references; authorization keys may also use `file:/absolute/private/path`. +`mode` is `live` (default) or `paper`. Paper mode simulates orders against real +mainnet Hyperliquid public market data and never signs or submits; see +[paper trading](/docs/paper). `config set mode paper` enters paper mode, and +`config set mode live --yes` is the explicit confirmation to go back to live. +The paper starting virtual balance is `paper.startingBalance` (default 10000), +also settable via `paper reset --balance N --yes`. + The default builder address is `0x71b09A08257078A4d642F7Dd7315e656c837329a` (Bloxwap), with `builder.fee:10`. Hyperliquid uses tenths of a basis point: `f=10` = 1 bp = **0.01%**. Override with `config set builder.address 0x...` diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 98c721a..ef9bd5f 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -4,7 +4,8 @@ description: "An agent-native Hyperliquid trading CLI with JSON input and output --- The CLI combines a persistent signing runner, Privy managed agents, Relay funding, -and a packaged agent skill. Master custody stays external. +a packaged agent skill, and paper trading against real mainnet market data. +Master custody stays external. ## Install diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 707f5c1..16fee96 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "quickstart", + "paper", "commands", "config", "errors", diff --git a/apps/docs/content/docs/paper.mdx b/apps/docs/content/docs/paper.mdx new file mode 100644 index 0000000..311f08a --- /dev/null +++ b/apps/docs/content/docs/paper.mdx @@ -0,0 +1,62 @@ +--- +title: "Paper trading" +description: "Risk-free simulation against real mainnet Hyperliquid market data." +--- + +Paper mode runs the full command surface against **real mainnet Hyperliquid +public market data** — info `l2Book`, WS `l2Book`/`trades`/`bbo`, `meta` and +funding — and never signs or submits anything. No Privy session, +`approveAgent`, or deposit is needed. + +## Enabling and labels + +- Per command: the global `--paper` flag. +- Persisted: `config set mode paper` in `~/.bloxwap/config.json`. +- Runner: `run --paper` (or `run` while `mode` is `paper`). +- Back to live: `config set mode live --yes` — the explicit confirmation flag + means live mode is never entered silently. + +Every envelope carries `mode: "live" | "paper"` and `paper: true | false`, and +every event record carries the same fields. Paper state lives entirely under +`~/.bloxwap/paper/` (`state.json`, `trades.jsonl`, `events.jsonl`, `audit.jsonl`, +`runner.pid`, `run.sock`, `logs/`); live commands never read it and paper +commands never read live state. `init`, `fund`, `rotate` and `revoke` are +live-custody operations and refuse to run in paper mode. + +## Simulator semantics + +`order`, `cancel`, `modify` and `leverage` keep the same validation, normalized +errors, risk limits, builder fee and audit trail as live trading; a local +simulator fills them: + +- Market/IOC orders walk the live book with slippage (IOC bounds come from the + same slippage-bounded prices as live mode). +- Limit orders rest and fill at their limit price when the market trades + through them. +- Reduce-only caps at the open position; TP/SL triggers fire when the + trade/mark price crosses the trigger direction, then execute as market or + rest as limits. +- Fees: Hyperliquid taker 4.5 bps / maker 1.5 bps plus the Bloxwap builder fee + (default `f=10` = 1 bp). +- Funding accrues hourly at Hyperliquid's `funding` rate of notional; the + `metaAndAssetCtxs` field is already the hourly rate paid every hour. +- Margin and liquidation are approximated for cross and isolated leverage. + +`status`, `balance`, `positions`, `orders`, `events` and `logs` all work in +paper mode against paper state. + +```sh +agent-cli paper reset --yes +agent-cli run --paper --detach +agent-cli order --paper --json '{"coin":"BTC","side":"buy","size":"0.01","type":"market"}' +agent-cli positions --paper +agent-cli paper summary +agent-cli events --paper --types '["fill"]' +``` + +`paper summary` reports starting balance, equity, realized/unrealized PnL, +fees, funding, trade count and positions with live mark prices. The starting +virtual balance is `paper.startingBalance` (default 10000), also settable with +`paper reset --balance N --yes`. The deterministic simulator core is covered by +fixture-book unit tests; see [commands](/docs/commands) for schemas and +[configuration](/docs/config) for `mode`. diff --git a/apps/docs/content/docs/skill.mdx b/apps/docs/content/docs/skill.mdx index d45d2e2..0f72a55 100644 --- a/apps/docs/content/docs/skill.mdx +++ b/apps/docs/content/docs/skill.mdx @@ -22,10 +22,27 @@ The following guide comes directly from the packaged skill. Use `agent-cli` for the user's authorized Hyperliquid account operations. Obtain command schemas with `agent-cli schema COMMAND`; arguments can be flags or `--json '{...}'`, or a JSON object on stdin. Ordinary commands print exactly one -JSON object: `{ok:true,data}` or `{ok:false,code,message,retryable,details}`. +JSON object: `{ok:true,data,mode,paper}` or +`{ok:false,code,message,retryable,details,mode,paper}`; `mode` is `"live"` or +`"paper"` and `paper` is `true` in paper mode. Logs/QR appear on stderr. `events --follow` and `logs` explicitly stream JSON lines. Use `--network testnet` during testing; never silently change to mainnet. +## Paper first, then fund and go live + +Start every strategy in paper mode: `agent-cli --paper ...`, `config set mode +paper`, or `run --paper`. Paper mode runs against real mainnet Hyperliquid +public market data (info + WS l2Book/trades/bbo, meta, funding) and never signs +or submits anything — no Privy session, `approveAgent`, or deposit is needed. +`order`/`cancel`/`modify`/`leverage` keep the same validation, risk limits, +builder fee and audit trail; a local simulator fills them (book-walking market +orders, resting limits, reduce-only, TP/SL, fees, funding, margin/liquidation +approximation). State is restartable under `~/.bloxwap/paper/`; `paper summary` +reports PnL/fees/funding, `paper reset --yes` clears it. Envelopes and events +carry `mode`/`paper`, paper state never mixes with live state, and going live +requires `config set mode live --yes`. Only after the paper results are proven, +continue with live onboarding below and real funding. + ## Onboarding sequence 1. The user creates and owns their own Privy account and app. Configure that @@ -111,6 +128,10 @@ Full inputs/outputs are discoverable with `schema`; amounts/prices are strings. | `events --since 12 --types '["fill"]'` | `{"ok":true,"data":[{"seq":13,"timestamp":"2026-01-01T00:00:00.000Z","type":"fill","data":{"tid":1}}]}` | | `events --follow` | `{"seq":13,"timestamp":"2026-01-01T00:00:00.000Z","type":"fill","data":{"tid":1}}` (one line per event) | | `logs --follow --level warn` | `{"level":"warn","requestId":"...","message":"Risk rejected","time":"..."}` (JSON lines) | +| `paper reset --yes` | `{"ok":true,"data":{"paper":true,"mode":"paper","reset":true,"startingBalance":"10000"},"mode":"paper","paper":true}` | +| `paper summary` | `{"ok":true,"data":{"paper":true,"mode":"paper","equity":"10000","netPnl":"0","feesPaid":"0","fundingPaid":"0","trades":0,"positions":[]}}` | +| `order --paper --json '{"coin":"BTC","side":"buy","size":"0.001","type":"market"}'` | `{"ok":true,"data":{"result":{"status":"ok","response":{"data":{"statuses":[{"filled":{"oid":1,"totalSz":"0.001","avgPx":"20000","status":"filled"}}]}}},"builderFeeRate":"0.01%","builderAddress":"0x71b09A08257078A4d642F7Dd7315e656c837329a","cloid":null},"mode":"paper","paper":true}` | +| `run --paper --detach` | `{"ok":true,"data":{"pid":123,"startedAt":1700000000000,"network":"mainnet","paper":true}}` | | `rotate` then `rotate --signature 0x...` | `{"ok":true,"data":{"oldAgent":"0x...","newAgent":"0x...","swapped":true,"oldRevoked":false,"revokeApproval":{}}}` | | `rotate --revoke-signature 0x...` | `{"ok":true,"data":{"swapped":true,"oldRevoked":true}}` | | `revoke --cancel-orders` | `{"ok":true,"data":{"locallyRevoked":true,"exchangeRevoked":false,"requiresSignature":true,"approval":{}}}` | @@ -153,9 +174,12 @@ bigints. Some deposits require **two distinct signatures**; resume stored steps rather than restarting the route. Agent expiry is at most **180 days**. Never ask for, paste, log or import a master private key or seed. The managed agent is trade-only; local limits include markets, size/notional, leverage, daily loss and -open orders. Privy sees an L1 action hash, so it cannot independently inspect +open orders — in paper mode these limits are enforced exactly the same way. +Privy sees an L1 action hash, so it cannot independently inspect notional/action contents; Hyperliquid agent permissions and local checks enforce -those boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`. +those boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`; paper +state is `~/.bloxwap/paper/` and is never read by live commands (or vice +versa). Envelopes and events always state `mode`/`paper`. ## Verification boundaries diff --git a/apps/docs/public/SKILL.md b/apps/docs/public/SKILL.md index 6a6ee1e..1e36352 100644 --- a/apps/docs/public/SKILL.md +++ b/apps/docs/public/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-cli -description: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, funding, orders, event monitoring, rotation, and normalized error recovery. +description: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, paper trading against real market data, funding, orders, event monitoring, rotation, and normalized error recovery. metadata: version: "0.2.0" --- @@ -8,10 +8,27 @@ metadata: Use `agent-cli` for the user's authorized Hyperliquid account operations. Obtain command schemas with `agent-cli schema COMMAND`; arguments can be flags or `--json '{...}'`, or a JSON object on stdin. Ordinary commands print exactly one -JSON object: `{ok:true,data}` or `{ok:false,code,message,retryable,details}`. +JSON object: `{ok:true,data,mode,paper}` or +`{ok:false,code,message,retryable,details,mode,paper}`; `mode` is `"live"` or +`"paper"` and `paper` is `true` in paper mode. Logs/QR appear on stderr. `events --follow` and `logs` explicitly stream JSON lines. Use `--network testnet` during testing; never silently change to mainnet. +## Paper first, then fund and go live + +Start every strategy in paper mode: `agent-cli --paper ...`, `config set mode +paper`, or `run --paper`. Paper mode runs against real mainnet Hyperliquid +public market data (info + WS l2Book/trades/bbo, meta, funding) and never signs +or submits anything — no Privy session, `approveAgent`, or deposit is needed. +`order`/`cancel`/`modify`/`leverage` keep the same validation, risk limits, +builder fee and audit trail; a local simulator fills them (book-walking market +orders, resting limits, reduce-only, TP/SL, fees, funding, margin/liquidation +approximation). State is restartable under `~/.bloxwap/paper/`; `paper summary` +reports PnL/fees/funding, `paper reset --yes` clears it. Envelopes and events +carry `mode`/`paper`, paper state never mixes with live state, and going live +requires `config set mode live --yes`. Only after the paper results are proven, +continue with live onboarding below and real funding. + ## Onboarding sequence 1. The user creates and owns their own Privy account and app. Configure that @@ -97,6 +114,10 @@ Full inputs/outputs are discoverable with `schema`; amounts/prices are strings. | `events --since 12 --types '["fill"]'` | `{"ok":true,"data":[{"seq":13,"timestamp":"2026-01-01T00:00:00.000Z","type":"fill","data":{"tid":1}}]}` | | `events --follow` | `{"seq":13,"timestamp":"2026-01-01T00:00:00.000Z","type":"fill","data":{"tid":1}}` (one line per event) | | `logs --follow --level warn` | `{"level":"warn","requestId":"...","message":"Risk rejected","time":"..."}` (JSON lines) | +| `paper reset --yes` | `{"ok":true,"data":{"paper":true,"mode":"paper","reset":true,"startingBalance":"10000"},"mode":"paper","paper":true}` | +| `paper summary` | `{"ok":true,"data":{"paper":true,"mode":"paper","equity":"10000","netPnl":"0","feesPaid":"0","fundingPaid":"0","trades":0,"positions":[]}}` | +| `order --paper --json '{"coin":"BTC","side":"buy","size":"0.001","type":"market"}'` | `{"ok":true,"data":{"result":{"status":"ok","response":{"data":{"statuses":[{"filled":{"oid":1,"totalSz":"0.001","avgPx":"20000","status":"filled"}}]}}},"builderFeeRate":"0.01%","builderAddress":"0x71b09A08257078A4d642F7Dd7315e656c837329a","cloid":null},"mode":"paper","paper":true}` | +| `run --paper --detach` | `{"ok":true,"data":{"pid":123,"startedAt":1700000000000,"network":"mainnet","paper":true}}` | | `rotate` then `rotate --signature 0x...` | `{"ok":true,"data":{"oldAgent":"0x...","newAgent":"0x...","swapped":true,"oldRevoked":false,"revokeApproval":{}}}` | | `rotate --revoke-signature 0x...` | `{"ok":true,"data":{"swapped":true,"oldRevoked":true}}` | | `revoke --cancel-orders` | `{"ok":true,"data":{"locallyRevoked":true,"exchangeRevoked":false,"requiresSignature":true,"approval":{}}}` | @@ -139,9 +160,12 @@ bigints. Some deposits require **two distinct signatures**; resume stored steps rather than restarting the route. Agent expiry is at most **180 days**. Never ask for, paste, log or import a master private key or seed. The managed agent is trade-only; local limits include markets, size/notional, leverage, daily loss and -open orders. Privy sees an L1 action hash, so it cannot independently inspect +open orders — in paper mode these limits are enforced exactly the same way. +Privy sees an L1 action hash, so it cannot independently inspect notional/action contents; Hyperliquid agent permissions and local checks enforce -those boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`. +those boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`; paper +state is `~/.bloxwap/paper/` and is never read by live commands (or vice +versa). Envelopes and events always state `mode`/`paper`. ## Verification boundaries diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt index 23a5f7c..2b76bf6 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -4,6 +4,7 @@ Hyperliquid trading with user-owned Privy apps and Relay funding. Master keys st - [Overview and installation](https://bloxwap.github.io/docs/) - [quickstart](https://bloxwap.github.io/docs/quickstart/) +- [paper](https://bloxwap.github.io/docs/paper/) - [commands](https://bloxwap.github.io/docs/commands/) - [config](https://bloxwap.github.io/docs/config/) - [errors](https://bloxwap.github.io/docs/errors/) diff --git a/apps/docs/scripts/generate-content.mjs b/apps/docs/scripts/generate-content.mjs index 10fa94d..35dbfa7 100644 --- a/apps/docs/scripts/generate-content.mjs +++ b/apps/docs/scripts/generate-content.mjs @@ -7,7 +7,7 @@ const invoke = (...args) => JSON.parse(execFileSync(process.execPath, [cli, ...a const pages = new URL('../content/docs/', import.meta.url); let commands = '---\ntitle: Command reference\ndescription: Every registered command, with input and output schemas from the CLI.\n---\n\n'; commands += 'Generated from `agent-cli --help` and `agent-cli schema COMMAND`. Do not edit by hand.\n\n'; -commands += 'Global flags: `--config PATH`, `--network mainnet|testnet`, `--pretty`, `--json OBJECT`. Inputs accept flags, JSON arguments, or a JSON object on stdin. Unknown object fields are rejected.\n\n'; +commands += 'Global flags: `--config PATH`, `--network mainnet|testnet`, `--pretty`, `--json OBJECT`, `--paper`, `--live`. Inputs accept flags, JSON arguments, or a JSON object on stdin. Unknown object fields are rejected.\n\n'; commands += '## Help and schema\n\n```sh\nagent-cli --help\nagent-cli schema order\n```\n\nHelp lists registered commands and global flags. `schema COMMAND` returns that command\'s input and success/failure output schemas without calling a service.\n\n'; for (const command of invoke('--help').commands) { commands += `## ${command.name}\n\n${command.description}\n\n\`\`\`json\n${JSON.stringify(invoke('schema', command.name), null, 2)}\n\`\`\`\n\n`; diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2ab1dd4..0b41711 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -46,6 +46,7 @@ | #41 | partial | Validated Changesets patch workflow with optional version PR, native binary smoke/checksums, tagged-main version gates, exact tested tarball publication and registry verification. Publish runs on the Mac mini fleet with the organization `NPM_TOKEN` (no OIDC trusted publishing or automatic provenance). Manual candidates cannot publish. Actual release needs the publishing gate; organization policy blocks automatic version PRs, so the default workflow uploads a reviewable patch. | | #42 | done | Final packaged flow audited and corrected (network/config isolation, exact loss/exposure/receipt checks, trigger preservation, readiness and stream errors). Skill shipped/embedded in npm and compiled CLI; Claude/Codex installation with CODEX_HOME, version synchronization, idempotency and overwrite protection. | | #49 | done | Docs token guard checks declarations anywhere on a line; regression coverage includes formatted and minified radius, motion, density and type copies. The issue's same-line mutation makes docs:check fail. | +| #61 | done | Paper trading mode: global `--paper`, `config set mode paper` (live return needs `--yes`) and `run --paper` simulate orders against real mainnet Hyperliquid public data (info + WS l2Book/trades/bbo, meta, funding) with no signing, Privy, approval or deposit. Deterministic simulator walks the live book for market/IOC, rests limits until trade-through, supports reduce-only and TP/SL, charges HL taker/maker + 1 bp builder fees, accrues funding and approximates cross/isolated margin and liquidation. Restartable state under `~/.bloxwap/paper/` with locked persistence, separate runner pid/socket/logs/audit/events; `paper reset` and `paper summary`. Every envelope and event labels `mode`/`paper`; live and paper state never mix. Fixture-book simulator, store, exchange, engine, mode and dispatch tests; env-gated mainnet smoke test. Skill, READMEs, docs pages and command reference updated. | Original implementation verification: supported Node 22, strict build/lint/typecheck and isolated offline tests. This did not establish live Privy/testnet or registry acceptance. Bloxwap builder defaults to f=10 (0.01%), with config/env overrides. diff --git a/docs/commands.md b/docs/commands.md index c3299a7..9b074c5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,8 +2,7 @@ Generated from `agent-cli schema `. Do not edit by hand. -Global flags: `--config PATH`, `--network mainnet|testnet`, `--pretty`, `--json OBJECT`. - +Global flags: `--config PATH`, `--network mainnet|testnet`, `--pretty`, `--json OBJECT`, `--paper`, `--live`. ## config Get, set or validate local configuration @@ -20,10 +19,15 @@ Get, set or validate local configuration "items": { "type": "string" } + }, + "yes": { + "default": false, + "type": "boolean" } }, "required": [ - "args" + "args", + "yes" ], "additionalProperties": false }, @@ -33,13 +37,24 @@ Get, set or validate local configuration "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { "const": true }, - "data": {} + "data": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } }, { @@ -49,7 +64,9 @@ Get, set or validate local configuration "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -81,7 +98,16 @@ Get, set or validate local configuration "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -130,7 +156,9 @@ Create agent and emit resumable master-wallet approvals "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -175,6 +203,15 @@ Create agent and emit resumable master-wallet approvals "policyId", "approved" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -185,7 +222,9 @@ Create agent and emit resumable master-wallet approvals "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -217,7 +256,16 @@ Create agent and emit resumable master-wallet approvals "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -278,7 +326,9 @@ Quote funding with address, QR and transparent fees "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -348,6 +398,15 @@ Quote funding with address, QR and transparent fees "expiresAt", "qrAscii" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -358,7 +417,9 @@ Quote funding with address, QR and transparent fees "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -390,7 +451,16 @@ Quote funding with address, QR and transparent fees "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -429,7 +499,9 @@ Resume deposit steps or submit a master-wallet signature "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -450,6 +522,15 @@ Resume deposit steps or submit a master-wallet signature } }, "required": [] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -460,7 +541,9 @@ Resume deposit steps or submit a master-wallet signature "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -492,7 +575,16 @@ Resume deposit steps or submit a master-wallet signature "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -518,7 +610,9 @@ Read account status "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -678,6 +772,15 @@ Read account status "approvedMaxBuilderFee", "runner" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -688,7 +791,9 @@ Read account status "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -720,7 +825,16 @@ Read account status "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -746,7 +860,9 @@ Read account balance "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -774,6 +890,15 @@ Read account balance "withdrawable", "marginUsed" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -784,7 +909,9 @@ Read account balance "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -816,7 +943,16 @@ Read account balance "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -842,7 +978,9 @@ Read account positions "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -887,6 +1025,15 @@ Read account positions "leverage" ] } + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -897,7 +1044,9 @@ Read account positions "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -929,7 +1078,16 @@ Read account positions "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -955,7 +1113,9 @@ Read account orders "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -996,6 +1156,15 @@ Read account orders "side" ] } + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1006,7 +1175,9 @@ Read account orders "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1038,7 +1209,16 @@ Read account orders "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1132,7 +1312,9 @@ Place limit, IOC market or TP/SL order "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1161,6 +1343,15 @@ Place limit, IOC market or TP/SL order "builderAddress", "cloid" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1171,7 +1362,9 @@ Place limit, IOC market or TP/SL order "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1203,7 +1396,16 @@ Place limit, IOC market or TP/SL order "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1268,7 +1470,9 @@ Cancel by oid/cloid, coin or all "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1289,6 +1493,15 @@ Cancel by oid/cloid, coin or all "cancelled", "result" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1299,7 +1512,9 @@ Cancel by oid/cloid, coin or all "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1331,7 +1546,16 @@ Cancel by oid/cloid, coin or all "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1403,7 +1627,9 @@ Modify price/size of single or batch resting orders "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1420,6 +1646,15 @@ Modify price/size of single or batch resting orders "required": [ "status" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1430,7 +1665,9 @@ Modify price/size of single or batch resting orders "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1462,7 +1699,16 @@ Modify price/size of single or batch resting orders "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1516,7 +1762,9 @@ Set market leverage and cross/isolated margin mode "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1533,6 +1781,15 @@ Set market leverage and cross/isolated margin mode "required": [ "status" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1543,7 +1800,9 @@ Set market leverage and cross/isolated margin mode "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1575,7 +1834,16 @@ Set market leverage and cross/isolated margin mode "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1600,11 +1868,16 @@ Start persistent signing runner (foreground or detach) "daemon": { "default": false, "type": "boolean" + }, + "paper": { + "default": false, + "type": "boolean" } }, "required": [ "detach", - "daemon" + "daemon", + "paper" ], "additionalProperties": false }, @@ -1614,7 +1887,9 @@ Start persistent signing runner (foreground or detach) "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1640,13 +1915,26 @@ Start persistent signing runner (foreground or detach) }, "existing": { "type": "boolean" + }, + "paper": { + "type": "boolean" } }, "required": [ "pid", "startedAt", - "network" + "network", + "paper" + ] + }, + "mode": { + "enum": [ + "live", + "paper" ] + }, + "paper": { + "type": "boolean" } } }, @@ -1657,7 +1945,9 @@ Start persistent signing runner (foreground or detach) "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1689,7 +1979,16 @@ Start persistent signing runner (foreground or detach) "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1747,7 +2046,9 @@ Replay or follow JSON-line runner events "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1777,15 +2078,35 @@ Replay or follow JSON-line runner events "runner_state" ] }, - "data": {} + "data": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } }, "required": [ "seq", "timestamp", "type", - "data" + "data", + "mode", + "paper" ] } + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1796,7 +2117,9 @@ Replay or follow JSON-line runner events "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1828,7 +2151,16 @@ Replay or follow JSON-line runner events "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1879,7 +2211,9 @@ Read/follow redacted JSON-line runner logs "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -1888,6 +2222,15 @@ Read/follow redacted JSON-line runner logs "data": { "description": "JSON-line stream of redacted log objects", "type": "object" + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -1898,7 +2241,9 @@ Read/follow redacted JSON-line runner logs "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -1930,7 +2275,16 @@ Read/follow redacted JSON-line runner logs "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -1969,7 +2323,9 @@ Approve new agent, hot-swap signer, then revoke old agent "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2002,6 +2358,15 @@ Approve new agent, hot-swap signer, then revoke old agent "swapped", "oldRevoked" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2012,7 +2377,9 @@ Approve new agent, hot-swap signer, then revoke old agent "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2044,7 +2411,16 @@ Approve new agent, hot-swap signer, then revoke old agent "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -2087,7 +2463,9 @@ Disable local agent and prepare master-signed exchange revocation "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2117,6 +2495,15 @@ Disable local agent and prepare master-signed exchange revocation "requiresSignature", "approval" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2127,7 +2514,9 @@ Disable local agent and prepare master-signed exchange revocation "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2159,7 +2548,16 @@ Disable local agent and prepare master-signed exchange revocation "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -2195,7 +2593,9 @@ Verify and query the hash-chained trading audit "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2242,6 +2642,15 @@ Verify and query the hash-chained trading audit "hash" ] } + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2252,7 +2661,9 @@ Verify and query the hash-chained trading audit "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2284,7 +2695,16 @@ Verify and query the hash-chained trading audit "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -2337,7 +2757,9 @@ Install packaged version-matched skill for Claude Code or Codex "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2368,6 +2790,15 @@ Install packaged version-matched skill for Claude Code or Codex "version", "changed" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2378,7 +2809,9 @@ Install packaged version-matched skill for Claude Code or Codex "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2410,7 +2843,16 @@ Install packaged version-matched skill for Claude Code or Codex "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] @@ -2443,7 +2885,9 @@ Discover market asset, precision, leverage ceiling and current mid "type": "object", "required": [ "ok", - "data" + "data", + "mode", + "paper" ], "properties": { "ok": { @@ -2475,6 +2919,15 @@ Discover market asset, precision, leverage ceiling and current mid "maxLeverage", "mid" ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" } } }, @@ -2485,7 +2938,9 @@ Discover market asset, precision, leverage ceiling and current mid "code", "message", "retryable", - "details" + "details", + "mode", + "paper" ], "properties": { "ok": { @@ -2517,7 +2972,231 @@ Discover market asset, precision, leverage ceiling and current mid "retryable": { "type": "boolean" }, - "details": {} + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } + } + } + ] + } +} +``` + +## paper + +Reset or summarize the local paper trading account + +```json +{ + "input": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "args": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "yes": { + "default": false, + "type": "boolean" + }, + "balance": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "args", + "yes" + ], + "additionalProperties": false + }, + "output": { + "oneOf": [ + { + "type": "object", + "required": [ + "ok", + "data", + "mode", + "paper" + ], + "properties": { + "ok": { + "const": true + }, + "data": { + "type": "object", + "properties": { + "paper": { + "type": "boolean" + }, + "mode": { + "enum": [ + "paper" + ] + }, + "dataNetwork": { + "enum": [ + "mainnet" + ] + }, + "startingBalance": { + "type": "string" + }, + "balance": { + "type": "string" + }, + "equity": { + "type": "string" + }, + "netPnl": { + "type": "string" + }, + "realizedPnl": { + "type": "string" + }, + "unrealizedPnl": { + "type": "string" + }, + "feesPaid": { + "type": "string" + }, + "fundingPaid": { + "type": "string" + }, + "trades": { + "type": "integer" + }, + "liquidations": { + "type": "integer" + }, + "openOrders": { + "type": "integer" + }, + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "coin": { + "type": "string" + }, + "size": { + "type": "string" + }, + "entryPrice": { + "type": "string" + }, + "markPrice": { + "type": [ + "string", + "null" + ] + }, + "unrealizedPnl": { + "type": "string" + }, + "liquidationPrice": { + "type": [ + "string", + "null" + ] + }, + "leverage": { + "type": "string" + }, + "marginMode": { + "type": "string" + } + }, + "required": [ + "coin", + "size" + ] + } + }, + "reset": { + "type": "boolean" + } + }, + "required": [ + "paper", + "mode" + ] + }, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } + } + }, + { + "type": "object", + "required": [ + "ok", + "code", + "message", + "retryable", + "details", + "mode", + "paper" + ], + "properties": { + "ok": { + "const": false + }, + "code": { + "enum": [ + "INSUFFICIENT_MARGIN", + "POLICY_REJECTED", + "AGENT_EXPIRED", + "AGENT_NOT_APPROVED", + "DEPOSIT_PENDING", + "NONCE_COLLISION", + "RATE_LIMIT", + "INVALID_SIZE", + "INVALID_PRICE", + "UNKNOWN_MARKET", + "ORDER_NOT_FOUND", + "RUNNER_NOT_RUNNING", + "CONFIG_INVALID", + "NETWORK_ERROR", + "UPSTREAM_ERROR", + "INTERNAL" + ] + }, + "message": { + "type": "string" + }, + "retryable": { + "type": "boolean" + }, + "details": {}, + "mode": { + "enum": [ + "live", + "paper" + ] + }, + "paper": { + "type": "boolean" + } } } ] diff --git a/docs/config.md b/docs/config.md index 9079f46..c6b4486 100644 --- a/docs/config.md +++ b/docs/config.md @@ -5,12 +5,19 @@ All command JSON objects reject unknown fields to catch intent-changing typos. Version 1 is validated on read and write; versionless config migrates to version 1. Unknown versions fail closed. Writes use a private temporary file and atomic rename. -`BLOXWAP_NETWORK`, `BLOXWAP_LOG_LEVEL`, `BLOXWAP_PRIVY_APP_ID` and +`BLOXWAP_NETWORK`, `BLOXWAP_MODE`, `BLOXWAP_LOG_LEVEL`, `BLOXWAP_PRIVY_APP_ID` and `BLOXWAP_MASTER_ADDRESS` override stored values in commands. `config get` shows persisted values; `config validate` validates them without overrides. Secrets use `env:BLOXWAP_PRIVY_APP_SECRET` and `env:BLOXWAP_AUTHORIZATION_KEY` references; authorization keys may also use `file:/absolute/private/path`. +`mode` is `live` (default) or `paper`. Paper mode simulates orders against real +mainnet Hyperliquid public market data and never signs or submits; see +[paper trading](paper.md). `config set mode paper` enters paper mode, and +`config set mode live --yes` is the explicit confirmation to go back. The paper +starting virtual balance is `paper.startingBalance` (default 10000), also +settable via `paper reset --balance N --yes`. + The default builder address is `0x71b09A08257078A4d642F7Dd7315e656c837329a` (Bloxwap), with `builder.fee:10`. Hyperliquid uses tenths of a basis point: `f=10` = 1 bp = **0.01%**. Override with `config set builder.address 0x...` diff --git a/docs/index.md b/docs/index.md index e25fd9f..1de549b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,8 @@ # agent-cli documentation Use the [command reference](commands.md) for JSON schemas, [configuration](config.md) -for state/env settings, [funding](funding.md) for deposits/signatures, +for state/env settings, [paper trading](paper.md) for risk-free simulation against +real market data, [funding](funding.md) for deposits/signatures, [errors](errors.md) for recovery, [events](events.md) for streaming, [custody](custody.md) and [security](security.md) for key boundaries. Start with [working with a coding agent](agent-workflow.md) to configure your own diff --git a/docs/paper.md b/docs/paper.md new file mode 100644 index 0000000..24bb490 --- /dev/null +++ b/docs/paper.md @@ -0,0 +1,75 @@ +# Paper trading + +Paper mode lets an agent run its full strategy against **real mainnet +Hyperliquid public market data** — info `l2Book`, WS `l2Book`/`trades`/`bbo`, +`meta` and funding — and shows exactly which trades it would make, with no +funding and no real money. It never signs or submits anything: no Privy +session, no `approveAgent`, no deposit. Going live afterwards is a separate, +explicit action. + +## Enabling and labeling + +- Per invocation: the global `--paper` flag (`order --paper ...`). +- Persisted: `config set mode paper` (writes `mode` in `~/.bloxwap/config.json`). +- Runner: `run --paper` (or `run` while `mode` is `paper`). +- Back to live: `config set mode live --yes` — the `--yes` confirmation is + required so live mode is never entered silently. + +Every success and error envelope carries `mode: "live" | "paper"` and +`paper: true | false`, and every event record carries the same fields. Paper +commands never use the live runner socket, pid file, audit chain, event +journal or logs, and live commands never read paper state: paper state lives +entirely under `~/.bloxwap/paper/` (`state.json`, `trades.jsonl`, +`events.jsonl`, `audit.jsonl`, `runner.pid`, `run.sock`, `logs/`). + +`init`, `fund`, `rotate` and `revoke` are live-custody operations and refuse to +run in paper mode. + +## Simulator semantics + +Trading commands (`order`, `cancel`, `modify`, `leverage`) go through the same +validation, normalized errors, risk limits, builder fee and audit trail as live +trading; a local simulator fills them against the live market: + +- Market/IOC orders walk the live book across price levels with slippage + (IOC bounds come from the same slippage-bounded price as live mode). +- Limit GTC/ALO orders rest and fill at their limit price when the market + trades through them (a book cross or a trade print on the other side). +- Reduce-only orders cap at the open position and are cancelled when they + would increase it; TP/SL trigger orders fire when the trade/mark price + crosses the trigger in the trigger direction, then execute as market + (walking the book) or rest as limits. +- Fees: Hyperliquid base taker 4.5 bps / maker 1.5 bps plus the Bloxwap + builder fee (default `f=10` = 1 bp, `config set builder.fee`). +- Funding accrues hourly at Hyperliquid's `funding` rate (from + `metaAndAssetCtxs`) of perp notional — the field is the hourly rate, paid + every hour (longs pay positive funding). +- Margin and liquidation are approximations: cross liquidation compares + account equity against aggregate maintenance margin (`1 / (2 * maxLeverage)` + of notional per market); isolated liquidation compares position margin plus + PnL against maintenance. Liquidated positions close at mark with taker fees. +- Leverage and cross/isolated mode are per market; starting virtual balance is + `config set paper.startingBalance` or `paper reset --balance N --yes`. + +`status`, `balance`, `positions`, `orders`, `events` and `logs` all work in +paper mode and read paper state; account values use live mark prices. + +## Operating rhythm + +```sh +agent-cli paper reset --yes +agent-cli run --paper --detach # optional: live fills/funding/liq ticks +agent-cli order --paper --json '{"coin":"BTC","side":"buy","size":"0.01","type":"market"}' +agent-cli positions --paper +agent-cli paper summary # equity, realized/unrealized PnL, fees, funding +agent-cli events --paper --types '["fill"]' +agent-cli paper reset --yes # start a new session +``` + +## Verification + +The simulator core is deterministic and covered by fixture-book unit tests +(book walking, resting fills, reduce-only, TP/SL, fees, funding, liquidation); +unit tests never touch the network. An optional env-gated smoke test +(`BLOXWAP_PAPER_SMOKE=1 npx vitest run test/paper-smoke.test.ts`) reads real +mainnet public data when explicitly enabled. diff --git a/packages/agent-cli/README.md b/packages/agent-cli/README.md index fddf462..4e9d29c 100644 --- a/packages/agent-cli/README.md +++ b/packages/agent-cli/README.md @@ -1,7 +1,8 @@ # agent-cli An agent-native Hyperliquid trading CLI with JSON input/output, a persistent -signing runner, Privy managed agents, Relay funding, and a packaged agent skill. +signing runner, Privy managed agents, Relay funding, a packaged agent skill, +and a paper trading mode against real mainnet market data. After the first public release, install with `npm i -g @bloxwap/agent-cli` or run `bunx @bloxwap/agent-cli --help`. Use Node **22.12+** or Bun; @@ -10,6 +11,17 @@ The package has no install lifecycle scripts and supports `--ignore-scripts`. Until publication, build a local tarball with `npm run build && npm run pack:cli` from the repository root and install that tarball with `npm i -g PATH_TO_TARBALL`. +## Paper trading + +Simulate strategies against real mainnet Hyperliquid market data with no +signing and no funds: `paper reset --yes`, then trade with the global `--paper` +flag (or `config set mode paper` / `run --paper`). The simulator walks the live +book, charges Hyperliquid taker/maker plus the 1 bp builder fee, accrues +funding, approximates margin/liquidation, and keeps restartable state under +`~/.bloxwap/paper/`. `paper summary` reports PnL, fees, funding and trades. +Envelopes and events carry `mode`/`paper` labels; `config set mode live --yes` +is the explicit way back to live. See `docs/paper.md`. + ## Testnet quickstart ```sh diff --git a/packages/agent-cli/SKILL.md b/packages/agent-cli/SKILL.md index 6a6ee1e..1e36352 100644 --- a/packages/agent-cli/SKILL.md +++ b/packages/agent-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-cli -description: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, funding, orders, event monitoring, rotation, and normalized error recovery. +description: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, paper trading against real market data, funding, orders, event monitoring, rotation, and normalized error recovery. metadata: version: "0.2.0" --- @@ -8,10 +8,27 @@ metadata: Use `agent-cli` for the user's authorized Hyperliquid account operations. Obtain command schemas with `agent-cli schema COMMAND`; arguments can be flags or `--json '{...}'`, or a JSON object on stdin. Ordinary commands print exactly one -JSON object: `{ok:true,data}` or `{ok:false,code,message,retryable,details}`. +JSON object: `{ok:true,data,mode,paper}` or +`{ok:false,code,message,retryable,details,mode,paper}`; `mode` is `"live"` or +`"paper"` and `paper` is `true` in paper mode. Logs/QR appear on stderr. `events --follow` and `logs` explicitly stream JSON lines. Use `--network testnet` during testing; never silently change to mainnet. +## Paper first, then fund and go live + +Start every strategy in paper mode: `agent-cli --paper ...`, `config set mode +paper`, or `run --paper`. Paper mode runs against real mainnet Hyperliquid +public market data (info + WS l2Book/trades/bbo, meta, funding) and never signs +or submits anything — no Privy session, `approveAgent`, or deposit is needed. +`order`/`cancel`/`modify`/`leverage` keep the same validation, risk limits, +builder fee and audit trail; a local simulator fills them (book-walking market +orders, resting limits, reduce-only, TP/SL, fees, funding, margin/liquidation +approximation). State is restartable under `~/.bloxwap/paper/`; `paper summary` +reports PnL/fees/funding, `paper reset --yes` clears it. Envelopes and events +carry `mode`/`paper`, paper state never mixes with live state, and going live +requires `config set mode live --yes`. Only after the paper results are proven, +continue with live onboarding below and real funding. + ## Onboarding sequence 1. The user creates and owns their own Privy account and app. Configure that @@ -97,6 +114,10 @@ Full inputs/outputs are discoverable with `schema`; amounts/prices are strings. | `events --since 12 --types '["fill"]'` | `{"ok":true,"data":[{"seq":13,"timestamp":"2026-01-01T00:00:00.000Z","type":"fill","data":{"tid":1}}]}` | | `events --follow` | `{"seq":13,"timestamp":"2026-01-01T00:00:00.000Z","type":"fill","data":{"tid":1}}` (one line per event) | | `logs --follow --level warn` | `{"level":"warn","requestId":"...","message":"Risk rejected","time":"..."}` (JSON lines) | +| `paper reset --yes` | `{"ok":true,"data":{"paper":true,"mode":"paper","reset":true,"startingBalance":"10000"},"mode":"paper","paper":true}` | +| `paper summary` | `{"ok":true,"data":{"paper":true,"mode":"paper","equity":"10000","netPnl":"0","feesPaid":"0","fundingPaid":"0","trades":0,"positions":[]}}` | +| `order --paper --json '{"coin":"BTC","side":"buy","size":"0.001","type":"market"}'` | `{"ok":true,"data":{"result":{"status":"ok","response":{"data":{"statuses":[{"filled":{"oid":1,"totalSz":"0.001","avgPx":"20000","status":"filled"}}]}}},"builderFeeRate":"0.01%","builderAddress":"0x71b09A08257078A4d642F7Dd7315e656c837329a","cloid":null},"mode":"paper","paper":true}` | +| `run --paper --detach` | `{"ok":true,"data":{"pid":123,"startedAt":1700000000000,"network":"mainnet","paper":true}}` | | `rotate` then `rotate --signature 0x...` | `{"ok":true,"data":{"oldAgent":"0x...","newAgent":"0x...","swapped":true,"oldRevoked":false,"revokeApproval":{}}}` | | `rotate --revoke-signature 0x...` | `{"ok":true,"data":{"swapped":true,"oldRevoked":true}}` | | `revoke --cancel-orders` | `{"ok":true,"data":{"locallyRevoked":true,"exchangeRevoked":false,"requiresSignature":true,"approval":{}}}` | @@ -139,9 +160,12 @@ bigints. Some deposits require **two distinct signatures**; resume stored steps rather than restarting the route. Agent expiry is at most **180 days**. Never ask for, paste, log or import a master private key or seed. The managed agent is trade-only; local limits include markets, size/notional, leverage, daily loss and -open orders. Privy sees an L1 action hash, so it cannot independently inspect +open orders — in paper mode these limits are enforced exactly the same way. +Privy sees an L1 action hash, so it cannot independently inspect notional/action contents; Hyperliquid agent permissions and local checks enforce -those boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`. +those boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`; paper +state is `~/.bloxwap/paper/` and is never read by live commands (or vice +versa). Envelopes and events always state `mode`/`paper`. ## Verification boundaries diff --git a/packages/agent-cli/src/cli.ts b/packages/agent-cli/src/cli.ts index 4f6ee99..64e6653 100644 --- a/packages/agent-cli/src/cli.ts +++ b/packages/agent-cli/src/cli.ts @@ -17,6 +17,7 @@ import './commands/revoke.js'; import './commands/audit.js'; import './commands/skill.js'; import './commands/market.js'; +import './commands/paper.js'; import { routeToRunner } from './runner/rpc.js'; setRouting(routeToRunner); import { AgentCliError, normalizeError } from './errors/index.js'; diff --git a/packages/agent-cli/src/commands/audit.ts b/packages/agent-cli/src/commands/audit.ts index 1b2f693..87552fb 100644 --- a/packages/agent-cli/src/commands/audit.ts +++ b/packages/agent-cli/src/commands/audit.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { register } from '../core/router.js'; -import { readAudit, verifyAudit } from '../core/audit.js'; +import { readAudit, verifyAudit, auditStoreFor } from '../core/audit.js'; +import { resolveMode } from '../core/mode.js'; register('audit', { description: 'Verify and query the hash-chained trading audit', schema: z.object({ @@ -8,8 +9,10 @@ register('audit', { intentId: z.string().optional(), cloid: z.string().optional(), }), - execute: async (input) => { - const entries = await readAudit(); + execute: async (input, context) => { + const entries = await readAudit( + auditStoreFor(await resolveMode(context)).path, + ); verifyAudit(entries); return entries.filter( (entry) => diff --git a/packages/agent-cli/src/commands/cancel.ts b/packages/agent-cli/src/commands/cancel.ts index 49e7e17..a167270 100644 --- a/packages/agent-cli/src/commands/cancel.ts +++ b/packages/agent-cli/src/commands/cancel.ts @@ -4,7 +4,7 @@ import { commandConfig } from './read.js'; import type { Config } from '../core/config.js'; import { assertCanTrade, - tradingServices, + servicesForMode, exchangeAction, type TradingServices, } from '../core/trading.js'; @@ -24,7 +24,7 @@ export async function cancelOrders( services?: TradingServices, ) { await assertCanTrade(config); - const clients = services ?? (await tradingServices(config)); + const clients = services ?? (await servicesForMode(config)); const orders = await clients.orders(); const oids = input.oids ?? (input.oid ? [input.oid] : []), cloids = input.cloids ?? (input.cloid ? [input.cloid] : []); diff --git a/packages/agent-cli/src/commands/config.ts b/packages/agent-cli/src/commands/config.ts index dbaa960..a6f0e79 100644 --- a/packages/agent-cli/src/commands/config.ts +++ b/packages/agent-cli/src/commands/config.ts @@ -4,8 +4,11 @@ import { loadConfig, saveConfig, migrate } from '../core/config.js'; import { AgentCliError } from '../errors/index.js'; register('config', { description: 'Get, set or validate local configuration', - schema: z.object({ args: z.array(z.string()).default([]) }), - execute: async ({ args }, context) => { + schema: z.object({ + args: z.array(z.string()).default([]), + yes: z.boolean().default(false), + }), + execute: async ({ args, yes }, context) => { const [action = 'get', key, value] = args as string[]; const config = await loadConfig(context.configPath, {}); if (action === 'validate') return { valid: true, version: config.version }; @@ -26,6 +29,16 @@ register('config', { } catch { parsed = value; } + if ( + key === 'mode' && + parsed === 'live' && + !yes && + config.mode !== 'live' + ) + throw new AgentCliError( + 'CONFIG_INVALID', + 'Switching to live mode requires explicit confirmation; re-run with --yes', + ); const updated = structuredClone(config) as Record; const parts = key.split('.'); let cursor = updated; diff --git a/packages/agent-cli/src/commands/events.ts b/packages/agent-cli/src/commands/events.ts index 7983178..f888bb3 100644 --- a/packages/agent-cli/src/commands/events.ts +++ b/packages/agent-cli/src/commands/events.ts @@ -1,11 +1,14 @@ import { z } from 'zod'; +import { join } from 'node:path'; import { register } from '../core/router.js'; +import { stateDir } from '../core/config.js'; import { readEvents, followEvents, eventTypes, type EventType, } from '../runner/events.js'; +import { paperDir, resolveMode } from '../core/mode.js'; register('events', { description: 'Replay or follow JSON-line runner events', schema: z.object({ @@ -14,9 +17,13 @@ register('events', { types: z.array(z.enum(eventTypes)).default([]), }), execute: async (input, context) => { + const paper = (await resolveMode(context, 'events')) === 'paper'; + const path = paper + ? join(paperDir(), 'events.jsonl') + : join(stateDir(), 'events.jsonl'); if (!input.follow) return readEvents( - undefined, + path, input.since as number, input.types as EventType[], ); @@ -27,6 +34,7 @@ register('events', { process.once('SIGTERM', stop); try { await followEvents({ + path, since: input.since as number, types: input.types as EventType[], signal: abort.signal, diff --git a/packages/agent-cli/src/commands/fund.ts b/packages/agent-cli/src/commands/fund.ts index b78fe27..698d4b9 100644 --- a/packages/agent-cli/src/commands/fund.ts +++ b/packages/agent-cli/src/commands/fund.ts @@ -6,6 +6,7 @@ import { hlInfo } from '../core/http.js'; import { RelayClient, PERPS_USDC, parseQuote } from '../funding/relay.js'; import { parseUsdc, formatUsdc } from '../funding/amounts.js'; import { renderQr } from '../funding/qr.js'; +import { assertLiveMode } from '../core/trading.js'; import { readDeposits, writeDeposits } from '../funding/store.js'; import { signatureSteps } from '../funding/signatures.js'; import { builderRate } from '../core/builder.js'; @@ -122,6 +123,8 @@ register('fund', { schema: fundSchema, execute: async (input, context) => { const config = await loadConfig(context.configPath); + if (context.paper !== undefined) config.mode = context.paper ? 'paper' : 'live'; + assertLiveMode(config, 'fund'); if (context.network) config.network = context.network; return fund(config, input as z.infer); }, diff --git a/packages/agent-cli/src/commands/init.ts b/packages/agent-cli/src/commands/init.ts index fb5c83e..b34798a 100644 --- a/packages/agent-cli/src/commands/init.ts +++ b/packages/agent-cli/src/commands/init.ts @@ -18,6 +18,7 @@ import { type Approval, } from '../wallet/approval.js'; import { hlInfo } from '../core/http.js'; +import { assertLiveMode } from '../core/trading.js'; import { AgentCliError } from '../errors/index.js'; register('init', { description: 'Create agent and emit resumable master-wallet approvals', @@ -34,6 +35,8 @@ register('init', { execute: async (input, context) => { rejectMasterKey(input); const config = await loadConfig(context.configPath); + if (context.paper !== undefined) config.mode = context.paper ? 'paper' : 'live'; + assertLiveMode(config, 'init'); if ( context.network && context.network !== config.network && diff --git a/packages/agent-cli/src/commands/leverage.ts b/packages/agent-cli/src/commands/leverage.ts index f4f8f09..24e91ec 100644 --- a/packages/agent-cli/src/commands/leverage.ts +++ b/packages/agent-cli/src/commands/leverage.ts @@ -4,7 +4,7 @@ import { commandConfig } from './read.js'; import type { Config } from '../core/config.js'; import { assertCanTrade, - tradingServices, + servicesForMode, exchangeAction, requiredDailyLoss, type TradingServices, @@ -23,7 +23,7 @@ export async function setLeverage( services?: TradingServices, ) { await assertCanTrade(config); - const clients = services ?? (await tradingServices(config)); + const clients = services ?? (await servicesForMode(config)); const market = await clients.market(input.coin); if ( (config.allowedMarkets.length && diff --git a/packages/agent-cli/src/commands/logs.ts b/packages/agent-cli/src/commands/logs.ts index a480f63..e1c3962 100644 --- a/packages/agent-cli/src/commands/logs.ts +++ b/packages/agent-cli/src/commands/logs.ts @@ -3,6 +3,7 @@ import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { register } from '../core/router.js'; import { stateDir } from '../core/config.js'; +import { paperDir, resolveMode } from '../core/mode.js'; import { levels, redact, type Level } from '../core/logger.js'; export async function readLogs( options: { @@ -54,6 +55,10 @@ register('logs', { }), execute: async (input, context) => { context.streaming = true; + const paper = (await resolveMode(context, 'logs')) === 'paper'; + const directory = paper + ? join(paperDir(), 'logs') + : join(stateDir(), 'logs'); let cursor = 0; const seen = new Set(); const abort = new AbortController(); @@ -62,7 +67,7 @@ register('logs', { process.once('SIGTERM', stop); try { do { - const logs = await readLogs(input); + const logs = await readLogs({ ...input, directory }); for (const log of logs) { const key = JSON.stringify(log); if (!seen.has(key)) { diff --git a/packages/agent-cli/src/commands/modify.ts b/packages/agent-cli/src/commands/modify.ts index b2f3df2..0f923e6 100644 --- a/packages/agent-cli/src/commands/modify.ts +++ b/packages/agent-cli/src/commands/modify.ts @@ -4,7 +4,7 @@ import { commandConfig } from './read.js'; import type { Config } from '../core/config.js'; import { assertCanTrade, - tradingServices, + servicesForMode, checkRisk, exchangeAction, type TradingServices, @@ -30,7 +30,7 @@ export async function modifyOrders( services?: TradingServices, ) { await assertCanTrade(config); - const clients = services ?? (await tradingServices(config)); + const clients = services ?? (await servicesForMode(config)); const orders = await clients.orders(); const staged = [...orders]; const stagedServices = { ...clients, orders: async () => staged }; diff --git a/packages/agent-cli/src/commands/order.ts b/packages/agent-cli/src/commands/order.ts index c2652fe..1ea9409 100644 --- a/packages/agent-cli/src/commands/order.ts +++ b/packages/agent-cli/src/commands/order.ts @@ -7,7 +7,7 @@ import { roundSize, roundPrice } from '../core/hlclient.js'; import { approvedBuilder, builderRate } from '../core/builder.js'; import { assertCanTrade, - tradingServices, + servicesForMode, checkRisk, exchangeAction, type TradingServices, @@ -99,9 +99,12 @@ export async function placeOrder( services?: TradingServices, ) { await assertCanTrade(config); - const clients = services ?? (await tradingServices(config)); + const clients = services ?? (await servicesForMode(config)); const order = await prepareOrder(config, input, clients); - const builder = await approvedBuilder(config); + const builder = + config.mode === 'paper' + ? { b: config.builder.address.toLowerCase(), f: config.builder.fee } + : await approvedBuilder(config); const result = await exchangeAction('order', () => clients.exchange.order({ orders: [order], diff --git a/packages/agent-cli/src/commands/paper.ts b/packages/agent-cli/src/commands/paper.ts new file mode 100644 index 0000000..868cd0b --- /dev/null +++ b/packages/agent-cli/src/commands/paper.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; +import { register } from '../core/router.js'; +import { loadConfig, atomicWrite, type Config } from '../core/config.js'; +import { paperDataNetwork } from '../core/mode.js'; +import { PaperStore } from '../paper/store.js'; +import { + paperAccountState, + paperData, + type PaperData, +} from '../paper/services.js'; +import { + accountEquity, + liquidationPx, + rolloverDay, +} from '../paper/simulator.js'; +import { formatUsdc, parseUsdc } from '../funding/amounts.js'; +import { AgentCliError } from '../errors/index.js'; +import { paperEvents } from '../runner/events.js'; +export async function paperSummary( + store: PaperStore, + data: PaperData = paperData(), +) { + const [state, marks] = await Promise.all([store.load(), data.marks()]); + rolloverDay(state, marks, Date.now()); + const maxLeverageOf = async (coin: string) => data.maxLeverageOf(coin); + const resolved = new Map(); + for (const coin of new Set(state.positions.map((p) => p.coin))) + resolved.set(coin, await maxLeverageOf(coin)); + const equity = accountEquity(state, marks); + const account = paperAccountState(state, marks, (coin) => resolved.get(coin) ?? 1); + const unrealized = account.assetPositions.reduce( + (sum, p) => sum + parseUsdc(p.position.unrealizedPnl, 18), + 0n, + ); + return { + paper: true, + mode: 'paper' as const, + dataNetwork: paperDataNetwork, + startingBalance: state.startingBalance, + balance: state.balance, + equity: formatUsdc(equity, 18), + netPnl: formatUsdc(equity - parseUsdc(state.startingBalance, 18), 18), + realizedPnl: state.realizedPnl, + unrealizedPnl: formatUsdc(unrealized, 18), + feesPaid: state.feesPaid, + fundingPaid: state.fundingPaid, + trades: state.trades.length, + liquidations: state.liquidations, + openOrders: state.orders.length, + positions: account.assetPositions.map(({ position: p }) => ({ + coin: p.coin, + size: p.szi, + entryPrice: p.entryPx, + markPrice: marks[p.coin] ?? null, + unrealizedPnl: p.unrealizedPnl, + liquidationPrice: liquidationPx( + state.positions.find((position) => position.coin === p.coin)!, + resolved.get(p.coin) ?? 1, + ), + leverage: String(p.leverage.value), + marginMode: p.leverage.type, + })), + }; +} +register('paper', { + description: 'Reset or summarize the local paper trading account', + schema: z.object({ + args: z.array(z.string()).default([]), + yes: z.boolean().default(false), + balance: z.number().positive().optional(), + }), + execute: async (input, context) => { + const [action = 'summary'] = input.args as string[]; + const config: Config = await loadConfig(context.configPath); + if (context.paper !== undefined) + config.mode = context.paper ? 'paper' : 'live'; + const store = new PaperStore(); + if (action === 'reset') { + if (!input.yes) + throw new AgentCliError( + 'CONFIG_INVALID', + 'paper reset clears all paper state; re-run with --yes to confirm', + ); + const starting = (input.balance as number | undefined) ?? config.paper.startingBalance; + await store.reset(starting); + await atomicWrite(paperEvents.path, ''); + return { + paper: true, + mode: 'paper' as const, + reset: true, + startingBalance: String(starting), + }; + } + if (action !== 'summary') + throw new AgentCliError( + 'CONFIG_INVALID', + 'Use paper summary or paper reset', + ); + return paperSummary(store); + }, +}); diff --git a/packages/agent-cli/src/commands/read.ts b/packages/agent-cli/src/commands/read.ts index 5fc86e5..721a7a0 100644 --- a/packages/agent-cli/src/commands/read.ts +++ b/packages/agent-cli/src/commands/read.ts @@ -8,6 +8,13 @@ import { loadConfig, type Config } from '../core/config.js'; import { hlInfo } from '../core/http.js'; import { pollDeposits } from '../funding/status.js'; import { parseUsdc } from '../funding/amounts.js'; +import { paperDataNetwork } from '../core/mode.js'; +import { PaperStore } from '../paper/store.js'; +import { + paperAccountState, + paperData, + paperOpenOrders, +} from '../paper/services.js'; import { AgentCliError } from '../errors/index.js'; import { mapHyperliquidError } from '../errors/mappers.js'; export interface AccountState { @@ -80,7 +87,41 @@ export function expiryStatus(config: Config, now = Date.now()) { .map((threshold) => `Agent expires within ${threshold} days`), }; } +export async function paperRead(name: string, config: Config) { + const store = new PaperStore(); + const data = paperData(); + const [state, marks] = await Promise.all([store.load(), data.marks()]); + const resolved = new Map(); + for (const coin of new Set(state.positions.map((p) => p.coin))) + resolved.set(coin, await data.maxLeverageOf(coin)); + const maxLeverageOf = (coin: string) => resolved.get(coin) ?? 1; + if (name === 'orders') return paperOpenOrders(state); + const account = paperAccountState(state, marks, maxLeverageOf); + if (name === 'balance') return accountBalance(account); + if (name === 'positions') return positions(account); + return { + balance: accountBalance(account), + positions: positions(account), + mode: 'paper' as const, + dataNetwork: paperDataNetwork, + paper: { + startingBalance: state.startingBalance, + realizedPnl: state.realizedPnl, + feesPaid: state.feesPaid, + fundingPaid: state.fundingPaid, + openOrders: state.orders.length, + liquidations: state.liquidations, + }, + policy: { + id: config.policyId ?? null, + version: String(config.policyVersion ?? 0), + }, + deposits: [], + runner: await runnerHealth('paper'), + }; +} export async function readCommand(name: string, config: Config) { + if (config.mode === 'paper') return paperRead(name, config); if (!config.masterAddress) throw new AgentCliError('AGENT_NOT_APPROVED', 'Initialize master address'); try { @@ -158,6 +199,7 @@ export async function readCommand(name: string, config: Config) { export async function commandConfig(context: Context) { const config = await loadConfig(context.configPath); if (context.network) config.network = context.network; + if (context.paper !== undefined) config.mode = context.paper ? 'paper' : 'live'; return config; } for (const name of ['status', 'balance', 'positions', 'orders']) diff --git a/packages/agent-cli/src/commands/revoke.ts b/packages/agent-cli/src/commands/revoke.ts index 4eabdce..432711d 100644 --- a/packages/agent-cli/src/commands/revoke.ts +++ b/packages/agent-cli/src/commands/revoke.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { register } from '../core/router.js'; import { commandConfig } from './read.js'; +import { assertLiveMode } from '../core/trading.js'; import { cancelOrders, cancelSchema } from './cancel.js'; import { atomicWrite, @@ -31,6 +32,7 @@ register('revoke', { }), execute: async (input, context) => { const config = await commandConfig(context); + assertLiveMode(config, 'revoke'); if (!config.agentAddress) throw new AgentCliError('AGENT_NOT_APPROVED', 'No agent to revoke'); if (input.cancelOrders && !config.revoked) diff --git a/packages/agent-cli/src/commands/rotate.ts b/packages/agent-cli/src/commands/rotate.ts index efbcdc2..34e6558 100644 --- a/packages/agent-cli/src/commands/rotate.ts +++ b/packages/agent-cli/src/commands/rotate.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { readFile } from 'node:fs/promises'; import { register } from '../core/router.js'; import { commandConfig } from './read.js'; +import { assertLiveMode } from '../core/trading.js'; import { atomicWrite, saveConfig, @@ -55,6 +56,7 @@ register('rotate', { }), execute: async (input, context) => { const current = await commandConfig(context); + assertLiveMode(current, 'rotate'); if (!current.masterAddress || !current.agentAddress) throw new AgentCliError('AGENT_NOT_APPROVED', 'Initialize first'); const path = join( diff --git a/packages/agent-cli/src/commands/run.ts b/packages/agent-cli/src/commands/run.ts index 5ca9ca8..f43991b 100644 --- a/packages/agent-cli/src/commands/run.ts +++ b/packages/agent-cli/src/commands/run.ts @@ -1,24 +1,64 @@ import { startHeartbeat } from '../runner/health.js'; -import { events, eventTypes, type EventType } from '../runner/events.js'; +import { events, eventTypes, paperEvents, type EventType } from '../runner/events.js'; import { startSubscriptions } from '../runner/subscriptions.js'; import { rpcServer } from '../runner/rpc.js'; import { z } from 'zod'; import { register } from '../core/router.js'; import { commandConfig } from './read.js'; -import { Runner, detachRunner, setActiveRunner } from '../runner/lifecycle.js'; +import { paperDir } from '../core/mode.js'; +import { startPaperEngine } from '../paper/engine.js'; +import { Runner, detachRunner, runnerPidPath, setActiveRunner } from '../runner/lifecycle.js'; register('run', { description: 'Start persistent signing runner (foreground or detach)', schema: z.object({ detach: z.boolean().default(false), daemon: z.boolean().default(false), + paper: z.boolean().default(false), }), execute: async (input, context) => { + const config = await commandConfig(context); + const paper = input.paper || config.mode === 'paper'; if (input.detach || input.daemon) - return detachRunner([ - ...(context.configPath ? ['--config', context.configPath] : []), - ...(context.network ? ['--network', context.network] : []), - ]); - const runner = new Runner(await commandConfig(context)); + return { + paper, + ...(await detachRunner( + [ + ...(context.configPath ? ['--config', context.configPath] : []), + ...(context.network ? ['--network', context.network] : []), + ...(paper ? ['--paper'] : []), + ], + runnerPidPath(paper ? 'paper' : 'live'), + )), + }; + if (paper) { + const runner = new Runner(config, paperDir()); + const info = await runner.start(async () => undefined as never); + if (!info.existing) { + setActiveRunner(runner); + runner.signals(); + runner.onShutdown(() => paperEvents.flush()); + try { + await rpcServer(runner, context); + await startPaperEngine(runner, async (type, data) => { + if (eventTypes.includes(type as EventType)) + await paperEvents.append(type as EventType, data); + runner.logger.log(type === 'error' ? 'error' : 'info', type, { + data, + }); + }); + await runner.markReady(); + } catch (error) { + await runner.shutdown(); + throw error; + } + } + return { + paper: true, + ...info, + ready: info.existing ? info.ready : runner.info.ready, + }; + } + const runner = new Runner(config); const info = await runner.start(); if (!info.existing) { setActiveRunner(runner); @@ -43,6 +83,10 @@ register('run', { throw error; } } - return { ...info, ready: info.existing ? info.ready : runner.info.ready }; + return { + paper: false, + ...info, + ready: info.existing ? info.ready : runner.info.ready, + }; }, }); diff --git a/packages/agent-cli/src/core/audit.ts b/packages/agent-cli/src/core/audit.ts index aa516c6..133b3e6 100644 --- a/packages/agent-cli/src/core/audit.ts +++ b/packages/agent-cli/src/core/audit.ts @@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { readFile, open, unlink, mkdir, chmod } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { stateDir } from './config.js'; +import { paperDir, resolveMode, type Mode } from './mode.js'; import { redact } from './logger.js'; import { AgentCliError, normalizeError } from '../errors/index.js'; export interface AuditEntry { @@ -11,6 +12,7 @@ export interface AuditEntry { intentId: string; cloid?: string; requestId?: string; + mode?: Mode; stage: string; data: unknown; previousHash: string; @@ -109,25 +111,32 @@ export class AuditStore { } } export const audit = new AuditStore(); +export const paperAudit = new AuditStore(join(paperDir(), 'audit.jsonl')); +export const auditStoreFor = (mode?: Mode) => + mode === 'paper' ? paperAudit : audit; export async function auditedAction( name: string, input: Record, operation: () => Promise, + routingContext?: { configPath?: string; paper?: boolean }, ): Promise { + const mode = await resolveMode(routingContext ?? {}, name); + const store = auditStoreFor(mode); return context.run( { intentId: (input.intentId as string) ?? randomUUID(), cloid: input.cloid as string | undefined, requestId: randomUUID(), + mode, }, async () => { - await audit.append('intent', { action: name, input }); + await store.append('intent', { action: name, input }); try { const result = await operation(); - await audit.append('response', { action: name, result }); + await store.append('response', { action: name, result }); return result; } catch (error) { - await audit.append('decision', { + await store.append('decision', { allowed: false, error: normalizeError(error).toJSON(), }); @@ -138,15 +147,17 @@ export async function auditedAction( } export async function signatureAudit(signature: string) { if (context.getStore()) - await audit.append('signature', { + await auditStoreFor(context.getStore()?.mode).append('signature', { digest: createHash('sha256').update(signature).digest('base64'), }); } export async function decisionAudit(data: unknown) { - if (context.getStore()) await audit.append('decision', data); + if (context.getStore()) + await auditStoreFor(context.getStore()?.mode).append('decision', data); } -export async function auditFill(fill: Record) { - const entries = await readAudit(); +export async function auditFill(fill: Record, mode?: Mode) { + const store = auditStoreFor(mode ?? context.getStore()?.mode); + const entries = await readAudit(store.path); const linked = [...entries] .reverse() .find( @@ -156,7 +167,7 @@ export async function auditFill(fill: Record) { fill.oid !== undefined && JSON.stringify(entry.data).includes(`"oid":${fill.oid}`)), ); - await audit.append('fill', fill, { + await store.append('fill', fill, { intentId: linked?.intentId ?? 'external', cloid: linked?.cloid, }); diff --git a/packages/agent-cli/src/core/config.ts b/packages/agent-cli/src/core/config.ts index 5bc574b..c504c55 100644 --- a/packages/agent-cli/src/core/config.ts +++ b/packages/agent-cli/src/core/config.ts @@ -11,6 +11,12 @@ export const configSchema = z .object({ version: z.literal(1).default(1), network: z.enum(['mainnet', 'testnet']).default('testnet'), + mode: z.enum(['live', 'paper']).default('live'), + paper: z + .object({ + startingBalance: positive.default(10_000), + }) + .default({ startingBalance: 10_000 }), agentName: z.string().max(16).default('bloxwap'), masterAddress: address.optional(), agentAddress: address.optional(), @@ -90,6 +96,7 @@ export async function loadConfig( const config = migrate(raw); for (const [name, key] of Object.entries({ BLOXWAP_NETWORK: 'network', + BLOXWAP_MODE: 'mode', BLOXWAP_LOG_LEVEL: 'logLevel', BLOXWAP_PRIVY_APP_ID: 'privyAppId', BLOXWAP_MASTER_ADDRESS: 'masterAddress', diff --git a/packages/agent-cli/src/core/mode.ts b/packages/agent-cli/src/core/mode.ts new file mode 100644 index 0000000..6c6871c --- /dev/null +++ b/packages/agent-cli/src/core/mode.ts @@ -0,0 +1,21 @@ +import { join } from 'node:path'; +import { loadConfig, stateDir } from './config.js'; +import type { Context } from './router.js'; +export type Mode = 'live' | 'paper'; +export const paperDir = () => join(stateDir(), 'paper'); +// Paper trading always runs against real mainnet Hyperliquid public market data. +export const paperDataNetwork = 'mainnet' as const; +export async function resolveMode( + context: Pick, + command?: string, +): Promise { + if (context.paper || command === 'paper') return 'paper'; + try { + return (await loadConfig(context.configPath)).mode; + } catch { + return 'live'; + } +} +export function modeFields(mode: Mode) { + return { mode, paper: mode === 'paper' }; +} diff --git a/packages/agent-cli/src/core/output-schemas.ts b/packages/agent-cli/src/core/output-schemas.ts index 0f06a18..49e7f5a 100644 --- a/packages/agent-cli/src/core/output-schemas.ts +++ b/packages/agent-cli/src/core/output-schemas.ts @@ -46,8 +46,10 @@ const event = object( ], }, data: unknown, + mode: { enum: ['live', 'paper'] }, + paper: boolean, }, - ['seq', 'timestamp', 'type', 'data'], + ['seq', 'timestamp', 'type', 'data', 'mode', 'paper'], ); export const outputSchemas: Record = { market: object( @@ -186,10 +188,46 @@ export const outputSchemas: Record = { network: { enum: ['mainnet', 'testnet'] }, ready: boolean, existing: boolean, + paper: boolean, }, - ['pid', 'startedAt', 'network'], + ['pid', 'startedAt', 'network', 'paper'], ), events: array(event), + paper: object( + { + paper: boolean, + mode: { enum: ['paper'] }, + dataNetwork: { enum: ['mainnet'] }, + startingBalance: string, + balance: string, + equity: string, + netPnl: string, + realizedPnl: string, + unrealizedPnl: string, + feesPaid: string, + fundingPaid: string, + trades: { type: 'integer' }, + liquidations: { type: 'integer' }, + openOrders: { type: 'integer' }, + positions: array( + object( + { + coin: string, + size: string, + entryPrice: string, + markPrice: { type: ['string', 'null'] }, + unrealizedPnl: string, + liquidationPrice: { type: ['string', 'null'] }, + leverage: string, + marginMode: string, + }, + ['coin', 'size'], + ), + ), + reset: boolean, + }, + ['paper', 'mode'], + ), logs: { description: 'JSON-line stream of redacted log objects', type: 'object', diff --git a/packages/agent-cli/src/core/request-context.ts b/packages/agent-cli/src/core/request-context.ts index 471f054..b492199 100644 --- a/packages/agent-cli/src/core/request-context.ts +++ b/packages/agent-cli/src/core/request-context.ts @@ -1,7 +1,9 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Mode } from './mode.js'; export const requestContext = new AsyncLocalStorage<{ intentId: string; cloid?: string; requestId?: string; + mode?: Mode; }>(); export const currentRequestContext = () => requestContext.getStore(); diff --git a/packages/agent-cli/src/core/router.ts b/packages/agent-cli/src/core/router.ts index 38cf556..679d1e4 100644 --- a/packages/agent-cli/src/core/router.ts +++ b/packages/agent-cli/src/core/router.ts @@ -2,11 +2,13 @@ import { outputSchemas } from './output-schemas.js'; import { errorDefaults } from '../errors/index.js'; import { assertSafeInput } from '../wallet/safety.js'; import { auditedAction } from './audit.js'; +import { resolveMode, modeFields, type Mode } from './mode.js'; import { z } from 'zod'; import { AgentCliError, normalizeError, success } from '../errors/index.js'; export interface Context { configPath?: string; network?: 'mainnet' | 'testnet'; + paper?: boolean; pretty: boolean; runner?: boolean; streaming?: boolean; @@ -47,7 +49,7 @@ export function register(name: string, command: Command) { }) : command.schema, execute: (input, context) => - auditedAction(name, input, () => original(input, context)), + auditedAction(name, input, () => original(input, context), context), }; } commands.set(name, command); @@ -56,24 +58,41 @@ export function schemas(name: string) { const command = commands.get(name); if (!command) throw new AgentCliError('CONFIG_INVALID', `Unknown command: ${name}`); + const modeFieldsSchema = { + mode: { enum: ['live', 'paper'] }, + paper: { type: 'boolean' }, + } as const; return { input: z.toJSONSchema(command.schema), output: { oneOf: [ { type: 'object', - required: ['ok', 'data'], - properties: { ok: { const: true }, data: outputSchemas[name] ?? {} }, + required: ['ok', 'data', 'mode', 'paper'], + properties: { + ok: { const: true }, + data: outputSchemas[name] ?? {}, + ...modeFieldsSchema, + }, }, { type: 'object', - required: ['ok', 'code', 'message', 'retryable', 'details'], + required: [ + 'ok', + 'code', + 'message', + 'retryable', + 'details', + 'mode', + 'paper', + ], properties: { ok: { const: false }, code: { enum: Object.keys(errorDefaults) }, message: { type: 'string' }, retryable: { type: 'boolean' }, details: {}, + ...modeFieldsSchema, }, }, ], @@ -96,6 +115,7 @@ export async function dispatch(argv: string[], stdin = '') { const input = Object.create(null) as Record; const positional: string[] = []; const context: Context = { pretty: false }; + let mode: Mode = 'live'; try { for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -117,6 +137,14 @@ export async function dispatch(argv: string[], stdin = '') { context.network = z.enum(['mainnet', 'testnet']).parse(argv[++i]); continue; } + if (arg === '--paper') { + context.paper = true; + continue; + } + if (arg === '--live') { + context.paper = false; + continue; + } if (arg === '--json') { Object.assign(input, parseJsonObject(argv[++i])); continue; @@ -158,20 +186,31 @@ export async function dispatch(argv: string[], stdin = '') { } if (stdin.trim()) Object.assign(input, parseJsonObject(stdin)); const [name = 'help', ...args] = positional; + mode = await resolveMode(context, name); if (name === 'help') return { - body: success({ - commands: [...commands].map(([name, c]) => ({ - name, - description: c.description, - })), - globalFlags: ['--json', '--config', '--network', '--pretty'], - }), + body: success( + { + commands: [...commands].map(([name, c]) => ({ + name, + description: c.description, + })), + globalFlags: [ + '--json', + '--config', + '--network', + '--pretty', + '--paper', + '--live', + ], + }, + mode, + ), exitCode: 0, context, }; if (name === 'schema') - return { body: success(schemas(args[0])), exitCode: 0, context }; + return { body: success(schemas(args[0]), mode), exitCode: 0, context }; const command = commands.get(name); if (!command) throw new AgentCliError('CONFIG_INVALID', `Unknown command: ${name}`); @@ -192,14 +231,14 @@ export async function dispatch(argv: string[], stdin = '') { const data = route?.routed ? route.data : await command.execute(parsed.data as Record, context); - return { body: success(data), exitCode: 0, context }; + return { body: success(data, mode), exitCode: 0, context }; } catch (error) { const normalized = error instanceof z.ZodError || error instanceof SyntaxError ? new AgentCliError('CONFIG_INVALID', 'Invalid JSON or flags') : normalizeError(error); return { - body: normalized.toJSON(), + body: { ...normalized.toJSON(), ...modeFields(mode) }, exitCode: normalized.exitCode, context, }; diff --git a/packages/agent-cli/src/core/skill-content.ts b/packages/agent-cli/src/core/skill-content.ts index 72e7736..21e9a55 100644 --- a/packages/agent-cli/src/core/skill-content.ts +++ b/packages/agent-cli/src/core/skill-content.ts @@ -1,3 +1,3 @@ // Generated from SKILL.md and package.json. export const cliVersion="0.2.0"; -export const skillContent="---\nname: agent-cli\ndescription: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, funding, orders, event monitoring, rotation, and normalized error recovery.\nmetadata:\n version: \"0.2.0\"\n---\n\nUse `agent-cli` for the user's authorized Hyperliquid account operations. Obtain\ncommand schemas with `agent-cli schema COMMAND`; arguments can be flags or\n`--json '{...}'`, or a JSON object on stdin. Ordinary commands print exactly one\nJSON object: `{ok:true,data}` or `{ok:false,code,message,retryable,details}`.\nLogs/QR appear on stderr. `events --follow` and `logs` explicitly stream JSON lines.\nUse `--network testnet` during testing; never silently change to mainnet.\n\n## Onboarding sequence\n\n1. The user creates and owns their own Privy account and app. Configure that\n app ID and a local secret reference, e.g. `config set privyAppId APP` and\n `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets the\n environment secret outside the conversation on their own machine or runner.\n Bloxwap does not host the app, receive credentials, or custody user keys.\n Privy manages the trading-agent key in the user's app; master-wallet approval\n always remains in the user's external wallet. The default Bloxwap builder is\n `0x71b09A08257078A4d642F7Dd7315e656c837329a`, with `f=10` (1 bp = 0.01%).\n Override with `config set builder.address` / `config set builder.fee` or\n `BLOXWAP_BUILDER_ADDRESS` / `BLOXWAP_BUILDER_FEE`.\n2. Run `init --master-address 0x... --network testnet`. Show the returned approval\n payload/deeplink/QR. The user signs in their master wallet; submit only the\n resulting signature with `init --signature 0x...`. Submit builder approval\n separately with `init --builder-signature 0x...` for the configured 0.01% default. Continue only after `approved:true`.\n3. Run `fund --amount 10 --origin-chain 42161`. Show `qrAscii`/deposit address,\n origin chain/token, minimum, fees, expected received amount and expiry. On\n testnet, do not treat the default Arbitrum mainnet chain/token as a test route:\n use an explicitly supported test chain/token and `originDecimals`, or the\n credential-gated CI harness with mocked Relay and mocked Hyperliquid orders.\n If no test route is known, stop funding and report the missing route. The user\n transfers the quoted origin token. If `signatureSteps` require signatures,\n show the exact payload and submit each via `deposit` in returned order.\n4. Poll `status` until each active deposit is `settled`, for at most 12 polls\n or 5 minutes, whichever comes first. Start with 1-second delays, double to a\n 15-second cap, and stop with an explicit pending report if the budget expires.\n Settlement requires Relay success and Hyperliquid balance receipt. Stop on\n failed/refunded deposits and explain their status. Do not place orders while\n any deposit is awaiting funds or pending.\n5. Run `run --detach`, then monitor `events --follow --since SEQ`. Persist the\n last handled sequence; replay after reconnect. An unfilled or rejected order\n remains incomplete: do not claim the requested trade succeeded. A fill updates the trading\n plan, an error follows the catalog below, and expiry warnings prompt rotation\n using external master-wallet approval.\n\n## Completion and approval boundaries\n\nRead this skill and query schemas before acting. Keep a progress record with the\nnetwork, current stage, deposit IDs, last event sequence and order cloid/oid;\nrecord public IDs only. A missing user-owned Privy app, wallet signature, funding\nroute or settled balance is a blocked stage. Report the exact next action without\nrequesting any secret in chat or moving to the next stage. Repeat `init` to resume\npersisted approvals rather than creating another agent. An `ok:true` transport\nenvelope does not imply exchange acceptance: inspect `data.result` and reconcile\n`orders`/`events` after uncertain mutations. Never retry an order with a new cloid\nuntil the original intent has been reconciled.\n\nFor rotation, verify `swapped:true` before asking the external wallet to sign old\nagent revocation. Require `oldRevoked:true` before claiming rotation complete.\n`revoke` immediately disables local signing; only `exchangeRevoked:true` confirms\nthat the signed exchange revocation was submitted. Inspect `status` to verify the\nagent is absent from exchange approvals. Cancel outstanding orders before final\nrevocation, and report any cancellation or cleanup failure explicitly.\n\n## Commands and example envelopes\n\nExamples show representative `data`, not live balances or service promises.\nFull inputs/outputs are discoverable with `schema`; amounts/prices are strings.\n\n| Input | Example output |\n| --- | --- |\n| `--help` | `{\"ok\":true,\"data\":{\"commands\":[{\"name\":\"status\",\"description\":\"Read account status\"}],\"globalFlags\":[\"--json\",\"--config\",\"--network\",\"--pretty\"]}}` |\n| `schema order` | `{\"ok\":true,\"data\":{\"input\":{\"type\":\"object\"},\"output\":{\"oneOf\":[]}}}` (actual schemas include properties) |\n| `config get network` | `{\"ok\":true,\"data\":\"testnet\"}` |\n| `config set limits.maxLeverage 5` | `{\"ok\":true,\"data\":{\"version\":1,\"limits\":{\"maxLeverage\":5}}}` |\n| `config validate` | `{\"ok\":true,\"data\":{\"valid\":true,\"version\":1}}` |\n| `init --master-address 0x...` | `{\"ok\":true,\"data\":{\"approved\":false,\"requiresSignature\":true,\"approvals\":{\"agent\":{\"typedData\":{}}},\"qrAscii\":\"...\"}}` |\n| `fund --amount 10 --no-qr` | `{\"ok\":true,\"data\":{\"depositId\":\"...\",\"depositAddress\":\"0x...\",\"expectedReceived\":\"100000000\",\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"state\":\"awaiting_funds\",\"qrAscii\":null}}` |\n| `deposit --deposit-id ID` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[{\"id\":\"step:0\",\"requiresSignature\":true,\"payload\":{}}]}}` |\n| `deposit --deposit-id ID --step-id step:0 --signature 0x...` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[]}}` |\n| `status` | `{\"ok\":true,\"data\":{\"balance\":{\"accountValue\":\"100\"},\"agent\":{\"approved\":true,\"daysRemaining\":\"179\"},\"deposits\":[],\"policy\":{\"id\":\"...\",\"version\":\"2\"},\"runner\":{\"pid\":\"123\"}}}` |\n| `balance` | `{\"ok\":true,\"data\":{\"accountValue\":\"100\",\"accountValueUnits\":\"10000000000\",\"withdrawable\":\"96\",\"marginUsed\":\"4\"}}` |\n| `market --coin BTC` | `{\"ok\":true,\"data\":{\"coin\":\"BTC\",\"asset\":\"0\",\"szDecimals\":\"5\",\"maxLeverage\":\"40\",\"mid\":\"20000\"}}` |\n| `positions` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"size\":\"0.001\",\"entryPrice\":\"20000\",\"leverage\":\"5\",\"marginMode\":\"cross\"}]}` |\n| `orders` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"oid\":\"123\",\"sz\":\"0.001\",\"limitPx\":\"20000\",\"side\":\"B\"}]}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"buy\",\"size\":\"0.001\",\"price\":\"20000\",\"tif\":\"Gtc\",\"cloid\":\"0x11111111111111111111111111111111\"}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"resting\":{\"oid\":123}}]}}},\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"cloid\":\"0x...\"}}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"sell\",\"size\":\"0.001\",\"type\":\"market\",\"slippage\":0.01,\"reduceOnly\":true}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"filled\":{\"oid\":123}}]}}}}}` |\n| `cancel --oid 123` or `cancel --all` | `{\"ok\":true,\"data\":{\"cancelled\":[\"123\"],\"result\":{\"status\":\"ok\"}}}` |\n| `modify --json '{\"modifications\":[{\"oid\":123,\"price\":\"20100\",\"size\":\"0.001\"}]}'` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `leverage --coin BTC --leverage 5 --mode isolated` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `run --detach` | `{\"ok\":true,\"data\":{\"pid\":123,\"startedAt\":1700000000000,\"network\":\"testnet\"}}` |\n| `events --since 12 --types '[\"fill\"]'` | `{\"ok\":true,\"data\":[{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}]}` |\n| `events --follow` | `{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}` (one line per event) |\n| `logs --follow --level warn` | `{\"level\":\"warn\",\"requestId\":\"...\",\"message\":\"Risk rejected\",\"time\":\"...\"}` (JSON lines) |\n| `rotate` then `rotate --signature 0x...` | `{\"ok\":true,\"data\":{\"oldAgent\":\"0x...\",\"newAgent\":\"0x...\",\"swapped\":true,\"oldRevoked\":false,\"revokeApproval\":{}}}` |\n| `rotate --revoke-signature 0x...` | `{\"ok\":true,\"data\":{\"swapped\":true,\"oldRevoked\":true}}` |\n| `revoke --cancel-orders` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":false,\"requiresSignature\":true,\"approval\":{}}}` |\n| `revoke --signature 0x...` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":true}}` |\n| `audit --intent ID` | `{\"ok\":true,\"data\":[{\"intentId\":\"ID\",\"stage\":\"decision\",\"data\":{\"allowed\":true},\"previousHash\":\"...\",\"hash\":\"...\"}]}` |\n| `skill install --target codex` | `{\"ok\":true,\"data\":{\"target\":\"codex\",\"path\":\".../agent-cli/SKILL.md\",\"version\":\"0.2.0\"}}` |\n\nBefore an order, query `market --coin BTC` for `mid`, `szDecimals`, asset and\nmarket leverage ceiling. Compute size/notional with decimal strings/bigints,\nusing the user-approved limit or the bounded IOC execution price. A valid schema\nalone does not establish that an order is tradable or within the budget.\n\nUse `--intent-id ID --intent \"reason\"` on trading actions to connect the user's\nreasoning to the audit. Keep cloid stable when retrying a known order intent.\nFor TP/SL use `type:\"tp\"|\"sl\"` and `triggerPrice` with size/price/reduceOnly;\nmarket orders use an explicit slippage-bounded IOC, not an unbounded market order.\n\n## Error recovery\n\n| Code | Action |\n| --- | --- |\n| RATE_LIMIT, NETWORK_ERROR | At most 3 read retries with exponential backoff; for uncertain mutations, reconcile order state/cloid before retrying. Stop and report unresolved results |\n| NONCE_COLLISION | Retry using a newly issued nonce; inspect a stale lock if persistent |\n| DEPOSIT_PENDING | Poll status until settled; stop on terminal funding failure |\n| RUNNER_NOT_RUNNING | Start runner or use configured direct fallback |\n| INSUFFICIENT_MARGIN | Reduce exposure or request authorized funding |\n| POLICY_REJECTED | Stop and revise intent within limits; do not bypass policy |\n| AGENT_EXPIRED | Rotate with master-wallet approval |\n| AGENT_NOT_APPROVED | Complete init/approval or explain revocation |\n| INVALID_SIZE, INVALID_PRICE | Correct input using market precision |\n| UNKNOWN_MARKET | Refresh metadata; use an allowed market |\n| ORDER_NOT_FOUND | Refresh orders before changing intent |\n| CONFIG_INVALID | Correct configuration/input; never insert secrets |\n| UPSTREAM_ERROR, INTERNAL | Stop, inspect redacted details/logs and report failure |\n\n## Gotchas\n\nHyperCore perps USDC uses **8 decimals**; EVM USDC uses **6**. Preserve strings and\nbigints. Some deposits require **two distinct signatures**; resume stored steps\nrather than restarting the route. Agent expiry is at most **180 days**. Never\nask for, paste, log or import a master private key or seed. The managed agent is\ntrade-only; local limits include markets, size/notional, leverage, daily loss and\nopen orders. Privy sees an L1 action hash, so it cannot independently inspect\nnotional/action contents; Hyperliquid agent permissions and local checks enforce\nthose boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`.\n\n\n## Verification boundaries\n\nOffline skill tests validate the documented command examples against CLI schemas.\nMocked lifecycle tests exercise resumable approvals, pending funding, events\nand failure handling. These checks do **not** prove a live agent or\nexchange run. The nightly testnet lifecycle uses only a company-owned disposable\nCI master account and separate test Privy app, live Privy and Hyperliquid\napprovals/revocation, and explicitly mocked Relay plus mocked Hyperliquid orders\nthat never reach the exchange. Test USDC pre-funding is not required for that job.\nThe same harness supports a caller-controlled external wallet approval adapter\non a clean QA runner; its private key stays in that wallet. It never transfers\nreal funds. Missing CI credentials fail the job; a skipped or\nmocked test must never be reported as live validation. Record the workflow run,\ncommit, lifecycle report, event and audit artifacts before claiming a testnet pass.\nSee `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup.\n"; +export const skillContent="---\nname: agent-cli\ndescription: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, paper trading against real market data, funding, orders, event monitoring, rotation, and normalized error recovery.\nmetadata:\n version: \"0.2.0\"\n---\n\nUse `agent-cli` for the user's authorized Hyperliquid account operations. Obtain\ncommand schemas with `agent-cli schema COMMAND`; arguments can be flags or\n`--json '{...}'`, or a JSON object on stdin. Ordinary commands print exactly one\nJSON object: `{ok:true,data,mode,paper}` or\n`{ok:false,code,message,retryable,details,mode,paper}`; `mode` is `\"live\"` or\n`\"paper\"` and `paper` is `true` in paper mode.\nLogs/QR appear on stderr. `events --follow` and `logs` explicitly stream JSON lines.\nUse `--network testnet` during testing; never silently change to mainnet.\n\n## Paper first, then fund and go live\n\nStart every strategy in paper mode: `agent-cli --paper ...`, `config set mode\npaper`, or `run --paper`. Paper mode runs against real mainnet Hyperliquid\npublic market data (info + WS l2Book/trades/bbo, meta, funding) and never signs\nor submits anything — no Privy session, `approveAgent`, or deposit is needed.\n`order`/`cancel`/`modify`/`leverage` keep the same validation, risk limits,\nbuilder fee and audit trail; a local simulator fills them (book-walking market\norders, resting limits, reduce-only, TP/SL, fees, funding, margin/liquidation\napproximation). State is restartable under `~/.bloxwap/paper/`; `paper summary`\nreports PnL/fees/funding, `paper reset --yes` clears it. Envelopes and events\ncarry `mode`/`paper`, paper state never mixes with live state, and going live\nrequires `config set mode live --yes`. Only after the paper results are proven,\ncontinue with live onboarding below and real funding.\n\n## Onboarding sequence\n\n1. The user creates and owns their own Privy account and app. Configure that\n app ID and a local secret reference, e.g. `config set privyAppId APP` and\n `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets the\n environment secret outside the conversation on their own machine or runner.\n Bloxwap does not host the app, receive credentials, or custody user keys.\n Privy manages the trading-agent key in the user's app; master-wallet approval\n always remains in the user's external wallet. The default Bloxwap builder is\n `0x71b09A08257078A4d642F7Dd7315e656c837329a`, with `f=10` (1 bp = 0.01%).\n Override with `config set builder.address` / `config set builder.fee` or\n `BLOXWAP_BUILDER_ADDRESS` / `BLOXWAP_BUILDER_FEE`.\n2. Run `init --master-address 0x... --network testnet`. Show the returned approval\n payload/deeplink/QR. The user signs in their master wallet; submit only the\n resulting signature with `init --signature 0x...`. Submit builder approval\n separately with `init --builder-signature 0x...` for the configured 0.01% default. Continue only after `approved:true`.\n3. Run `fund --amount 10 --origin-chain 42161`. Show `qrAscii`/deposit address,\n origin chain/token, minimum, fees, expected received amount and expiry. On\n testnet, do not treat the default Arbitrum mainnet chain/token as a test route:\n use an explicitly supported test chain/token and `originDecimals`, or the\n credential-gated CI harness with mocked Relay and mocked Hyperliquid orders.\n If no test route is known, stop funding and report the missing route. The user\n transfers the quoted origin token. If `signatureSteps` require signatures,\n show the exact payload and submit each via `deposit` in returned order.\n4. Poll `status` until each active deposit is `settled`, for at most 12 polls\n or 5 minutes, whichever comes first. Start with 1-second delays, double to a\n 15-second cap, and stop with an explicit pending report if the budget expires.\n Settlement requires Relay success and Hyperliquid balance receipt. Stop on\n failed/refunded deposits and explain their status. Do not place orders while\n any deposit is awaiting funds or pending.\n5. Run `run --detach`, then monitor `events --follow --since SEQ`. Persist the\n last handled sequence; replay after reconnect. An unfilled or rejected order\n remains incomplete: do not claim the requested trade succeeded. A fill updates the trading\n plan, an error follows the catalog below, and expiry warnings prompt rotation\n using external master-wallet approval.\n\n## Completion and approval boundaries\n\nRead this skill and query schemas before acting. Keep a progress record with the\nnetwork, current stage, deposit IDs, last event sequence and order cloid/oid;\nrecord public IDs only. A missing user-owned Privy app, wallet signature, funding\nroute or settled balance is a blocked stage. Report the exact next action without\nrequesting any secret in chat or moving to the next stage. Repeat `init` to resume\npersisted approvals rather than creating another agent. An `ok:true` transport\nenvelope does not imply exchange acceptance: inspect `data.result` and reconcile\n`orders`/`events` after uncertain mutations. Never retry an order with a new cloid\nuntil the original intent has been reconciled.\n\nFor rotation, verify `swapped:true` before asking the external wallet to sign old\nagent revocation. Require `oldRevoked:true` before claiming rotation complete.\n`revoke` immediately disables local signing; only `exchangeRevoked:true` confirms\nthat the signed exchange revocation was submitted. Inspect `status` to verify the\nagent is absent from exchange approvals. Cancel outstanding orders before final\nrevocation, and report any cancellation or cleanup failure explicitly.\n\n## Commands and example envelopes\n\nExamples show representative `data`, not live balances or service promises.\nFull inputs/outputs are discoverable with `schema`; amounts/prices are strings.\n\n| Input | Example output |\n| --- | --- |\n| `--help` | `{\"ok\":true,\"data\":{\"commands\":[{\"name\":\"status\",\"description\":\"Read account status\"}],\"globalFlags\":[\"--json\",\"--config\",\"--network\",\"--pretty\"]}}` |\n| `schema order` | `{\"ok\":true,\"data\":{\"input\":{\"type\":\"object\"},\"output\":{\"oneOf\":[]}}}` (actual schemas include properties) |\n| `config get network` | `{\"ok\":true,\"data\":\"testnet\"}` |\n| `config set limits.maxLeverage 5` | `{\"ok\":true,\"data\":{\"version\":1,\"limits\":{\"maxLeverage\":5}}}` |\n| `config validate` | `{\"ok\":true,\"data\":{\"valid\":true,\"version\":1}}` |\n| `init --master-address 0x...` | `{\"ok\":true,\"data\":{\"approved\":false,\"requiresSignature\":true,\"approvals\":{\"agent\":{\"typedData\":{}}},\"qrAscii\":\"...\"}}` |\n| `fund --amount 10 --no-qr` | `{\"ok\":true,\"data\":{\"depositId\":\"...\",\"depositAddress\":\"0x...\",\"expectedReceived\":\"100000000\",\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"state\":\"awaiting_funds\",\"qrAscii\":null}}` |\n| `deposit --deposit-id ID` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[{\"id\":\"step:0\",\"requiresSignature\":true,\"payload\":{}}]}}` |\n| `deposit --deposit-id ID --step-id step:0 --signature 0x...` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[]}}` |\n| `status` | `{\"ok\":true,\"data\":{\"balance\":{\"accountValue\":\"100\"},\"agent\":{\"approved\":true,\"daysRemaining\":\"179\"},\"deposits\":[],\"policy\":{\"id\":\"...\",\"version\":\"2\"},\"runner\":{\"pid\":\"123\"}}}` |\n| `balance` | `{\"ok\":true,\"data\":{\"accountValue\":\"100\",\"accountValueUnits\":\"10000000000\",\"withdrawable\":\"96\",\"marginUsed\":\"4\"}}` |\n| `market --coin BTC` | `{\"ok\":true,\"data\":{\"coin\":\"BTC\",\"asset\":\"0\",\"szDecimals\":\"5\",\"maxLeverage\":\"40\",\"mid\":\"20000\"}}` |\n| `positions` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"size\":\"0.001\",\"entryPrice\":\"20000\",\"leverage\":\"5\",\"marginMode\":\"cross\"}]}` |\n| `orders` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"oid\":\"123\",\"sz\":\"0.001\",\"limitPx\":\"20000\",\"side\":\"B\"}]}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"buy\",\"size\":\"0.001\",\"price\":\"20000\",\"tif\":\"Gtc\",\"cloid\":\"0x11111111111111111111111111111111\"}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"resting\":{\"oid\":123}}]}}},\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"cloid\":\"0x...\"}}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"sell\",\"size\":\"0.001\",\"type\":\"market\",\"slippage\":0.01,\"reduceOnly\":true}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"filled\":{\"oid\":123}}]}}}}}` |\n| `cancel --oid 123` or `cancel --all` | `{\"ok\":true,\"data\":{\"cancelled\":[\"123\"],\"result\":{\"status\":\"ok\"}}}` |\n| `modify --json '{\"modifications\":[{\"oid\":123,\"price\":\"20100\",\"size\":\"0.001\"}]}'` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `leverage --coin BTC --leverage 5 --mode isolated` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `run --detach` | `{\"ok\":true,\"data\":{\"pid\":123,\"startedAt\":1700000000000,\"network\":\"testnet\"}}` |\n| `events --since 12 --types '[\"fill\"]'` | `{\"ok\":true,\"data\":[{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}]}` |\n| `events --follow` | `{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}` (one line per event) |\n| `logs --follow --level warn` | `{\"level\":\"warn\",\"requestId\":\"...\",\"message\":\"Risk rejected\",\"time\":\"...\"}` (JSON lines) |\n| `paper reset --yes` | `{\"ok\":true,\"data\":{\"paper\":true,\"mode\":\"paper\",\"reset\":true,\"startingBalance\":\"10000\"},\"mode\":\"paper\",\"paper\":true}` |\n| `paper summary` | `{\"ok\":true,\"data\":{\"paper\":true,\"mode\":\"paper\",\"equity\":\"10000\",\"netPnl\":\"0\",\"feesPaid\":\"0\",\"fundingPaid\":\"0\",\"trades\":0,\"positions\":[]}}` |\n| `order --paper --json '{\"coin\":\"BTC\",\"side\":\"buy\",\"size\":\"0.001\",\"type\":\"market\"}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"filled\":{\"oid\":1,\"totalSz\":\"0.001\",\"avgPx\":\"20000\",\"status\":\"filled\"}}]}}},\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"cloid\":null},\"mode\":\"paper\",\"paper\":true}` |\n| `run --paper --detach` | `{\"ok\":true,\"data\":{\"pid\":123,\"startedAt\":1700000000000,\"network\":\"mainnet\",\"paper\":true}}` |\n| `rotate` then `rotate --signature 0x...` | `{\"ok\":true,\"data\":{\"oldAgent\":\"0x...\",\"newAgent\":\"0x...\",\"swapped\":true,\"oldRevoked\":false,\"revokeApproval\":{}}}` |\n| `rotate --revoke-signature 0x...` | `{\"ok\":true,\"data\":{\"swapped\":true,\"oldRevoked\":true}}` |\n| `revoke --cancel-orders` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":false,\"requiresSignature\":true,\"approval\":{}}}` |\n| `revoke --signature 0x...` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":true}}` |\n| `audit --intent ID` | `{\"ok\":true,\"data\":[{\"intentId\":\"ID\",\"stage\":\"decision\",\"data\":{\"allowed\":true},\"previousHash\":\"...\",\"hash\":\"...\"}]}` |\n| `skill install --target codex` | `{\"ok\":true,\"data\":{\"target\":\"codex\",\"path\":\".../agent-cli/SKILL.md\",\"version\":\"0.2.0\"}}` |\n\nBefore an order, query `market --coin BTC` for `mid`, `szDecimals`, asset and\nmarket leverage ceiling. Compute size/notional with decimal strings/bigints,\nusing the user-approved limit or the bounded IOC execution price. A valid schema\nalone does not establish that an order is tradable or within the budget.\n\nUse `--intent-id ID --intent \"reason\"` on trading actions to connect the user's\nreasoning to the audit. Keep cloid stable when retrying a known order intent.\nFor TP/SL use `type:\"tp\"|\"sl\"` and `triggerPrice` with size/price/reduceOnly;\nmarket orders use an explicit slippage-bounded IOC, not an unbounded market order.\n\n## Error recovery\n\n| Code | Action |\n| --- | --- |\n| RATE_LIMIT, NETWORK_ERROR | At most 3 read retries with exponential backoff; for uncertain mutations, reconcile order state/cloid before retrying. Stop and report unresolved results |\n| NONCE_COLLISION | Retry using a newly issued nonce; inspect a stale lock if persistent |\n| DEPOSIT_PENDING | Poll status until settled; stop on terminal funding failure |\n| RUNNER_NOT_RUNNING | Start runner or use configured direct fallback |\n| INSUFFICIENT_MARGIN | Reduce exposure or request authorized funding |\n| POLICY_REJECTED | Stop and revise intent within limits; do not bypass policy |\n| AGENT_EXPIRED | Rotate with master-wallet approval |\n| AGENT_NOT_APPROVED | Complete init/approval or explain revocation |\n| INVALID_SIZE, INVALID_PRICE | Correct input using market precision |\n| UNKNOWN_MARKET | Refresh metadata; use an allowed market |\n| ORDER_NOT_FOUND | Refresh orders before changing intent |\n| CONFIG_INVALID | Correct configuration/input; never insert secrets |\n| UPSTREAM_ERROR, INTERNAL | Stop, inspect redacted details/logs and report failure |\n\n## Gotchas\n\nHyperCore perps USDC uses **8 decimals**; EVM USDC uses **6**. Preserve strings and\nbigints. Some deposits require **two distinct signatures**; resume stored steps\nrather than restarting the route. Agent expiry is at most **180 days**. Never\nask for, paste, log or import a master private key or seed. The managed agent is\ntrade-only; local limits include markets, size/notional, leverage, daily loss and\nopen orders — in paper mode these limits are enforced exactly the same way.\nPrivy sees an L1 action hash, so it cannot independently inspect\nnotional/action contents; Hyperliquid agent permissions and local checks enforce\nthose boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`; paper\nstate is `~/.bloxwap/paper/` and is never read by live commands (or vice\nversa). Envelopes and events always state `mode`/`paper`.\n\n\n## Verification boundaries\n\nOffline skill tests validate the documented command examples against CLI schemas.\nMocked lifecycle tests exercise resumable approvals, pending funding, events\nand failure handling. These checks do **not** prove a live agent or\nexchange run. The nightly testnet lifecycle uses only a company-owned disposable\nCI master account and separate test Privy app, live Privy and Hyperliquid\napprovals/revocation, and explicitly mocked Relay plus mocked Hyperliquid orders\nthat never reach the exchange. Test USDC pre-funding is not required for that job.\nThe same harness supports a caller-controlled external wallet approval adapter\non a clean QA runner; its private key stays in that wallet. It never transfers\nreal funds. Missing CI credentials fail the job; a skipped or\nmocked test must never be reported as live validation. Record the workflow run,\ncommit, lifecycle report, event and audit artifacts before claiming a testnet pass.\nSee `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup.\n"; diff --git a/packages/agent-cli/src/core/trading.ts b/packages/agent-cli/src/core/trading.ts index 1bd0e3c..f6c9e1f 100644 --- a/packages/agent-cli/src/core/trading.ts +++ b/packages/agent-cli/src/core/trading.ts @@ -18,6 +18,8 @@ import { assertTradeAction } from '../wallet/policy.js'; import { mapHyperliquidError } from '../errors/mappers.js'; import { evaluateRisk, type RiskIntent } from './risk.js'; import { Logger } from './logger.js'; +import { PaperStore } from '../paper/store.js'; +import { paperTradingServices } from '../paper/services.js'; export interface TradingServices { exchange: ExchangeAdapter; market: (coin: string) => Promise; @@ -56,11 +58,26 @@ export async function tradingServices( }; } export async function assertCanTrade(config: Config) { + if (config.mode === 'paper') return; // simulation: no approval, deposit or session gate if (config.revoked || !config.approved || !config.masterAddress) throw new AgentCliError('AGENT_NOT_APPROVED', 'Agent approval required'); assertAgentValid(config); await assertDepositsSettled(config.network, config.masterAddress); } +export function assertLiveMode(config: Config, command: string) { + if (config.mode === 'paper') + throw new AgentCliError( + 'CONFIG_INVALID', + `${command} requires live mode; paper mode never signs or moves funds`, + ); +} +export async function servicesForMode( + config: Config, +): Promise { + return config.mode === 'paper' + ? paperTradingServices(config, new PaperStore()) + : tradingServices(config); +} export async function checkRisk( config: Config, intent: RiskIntent, diff --git a/packages/agent-cli/src/errors/index.ts b/packages/agent-cli/src/errors/index.ts index bcbd186..a0260f0 100644 --- a/packages/agent-cli/src/errors/index.ts +++ b/packages/agent-cli/src/errors/index.ts @@ -1,4 +1,5 @@ import { redact } from '../core/logger.js'; +import { modeFields, type Mode } from '../core/mode.js'; export const errorDefaults = { INSUFFICIENT_MARGIN: false, POLICY_REJECTED: false, @@ -30,13 +31,14 @@ export class AgentCliError extends Error { this.name = 'AgentCliError'; this.retryable = retryable; } - toJSON() { + toJSON(mode: Mode = 'live') { return { ok: false as const, code: this.code, message: String(redact(this.message)), retryable: this.retryable, details: redact(this.details), + ...modeFields(mode), }; } get exitCode() { @@ -51,4 +53,8 @@ export function normalizeError(error: unknown): AgentCliError { error instanceof Error ? error.message : String(error), ); } -export const success = (data: T) => ({ ok: true as const, data }); +export const success = (data: T, mode: Mode = 'live') => ({ + ok: true as const, + data, + ...modeFields(mode), +}); diff --git a/packages/agent-cli/src/paper/engine.ts b/packages/agent-cli/src/paper/engine.ts new file mode 100644 index 0000000..b313daa --- /dev/null +++ b/packages/agent-cli/src/paper/engine.ts @@ -0,0 +1,257 @@ +import { SubscriptionClient, WebSocketTransport } from '@bloxwap/hyperliquid'; +import WebSocket from 'ws'; +import { health } from '../runner/health.js'; +import { normalizeError } from '../errors/index.js'; +import { PaperStore } from './store.js'; +import { + accrueFunding, + liquidate, + matchOrders, + type MatchResult, + type PaperBook, +} from './simulator.js'; +import { paperData, type PaperData } from './services.js'; +import type { Runner } from '../runner/lifecycle.js'; +interface Backend { + subscribe: ( + type: string, + params: Record, + listener: (data: unknown) => void, + ) => Promise<{ unsubscribe: () => Promise }>; + onState: (listener: (state: string) => void) => void; + close: () => Promise; +} +interface BookMessage { + coin: string; + side?: 'B' | 'A'; + levels: { px: string; sz: string; n?: number }[][]; +} +interface TradeMessage { + coin: string; + px: string; + side: 'B' | 'A'; +} +// Live-feed paper engine: fills resting orders from real mainnet l2Book/trades +// data, accrues funding and applies the liquidation approximation on a timer. +export class PaperEngine { + state = 'connecting'; + lastMessage = Date.now(); + lastFill: number | null = null; + private books = new Map(); + private lastTrade = new Map(); + private subscriptions: { + coin: string; + type: string; + handle: { unsubscribe: () => Promise }; + }[] = []; + private queue = Promise.resolve(); + private timer?: NodeJS.Timeout; + constructor( + private runner: Runner, + private store: PaperStore, + private backend: Backend, + private emit: (type: string, data: unknown) => Promise, + private data: PaperData = paperData(), + private builderFee = runner.config.builder.fee, + ) {} + async start() { + this.backend.onState((state) => { + const reconnect = this.state !== 'connecting' && state === 'connected'; + this.state = state; + this.lastMessage = Date.now(); + if (reconnect) + this.enqueue(async () => { + await this.refreshSubscriptions(); + await this.emit('runner_state', { wsState: state, paper: true }); + }); + }); + await this.refreshSubscriptions(); + this.timer = setInterval(() => { + this.enqueue(() => this.tick()); + }, 30_000); + this.runner.onShutdown(async () => { + if (this.timer) clearInterval(this.timer); + await this.queue; + await Promise.all( + this.subscriptions.map((subscription) => subscription.handle.unsubscribe()), + ); + this.subscriptions = []; + await this.backend.close(); + }); + this.enqueue(() => this.tick()); + } + private enqueue(operation: () => Promise) { + this.queue = this.queue.then(operation).catch(async (error) => { + await this.emit('error', normalizeError(error).toJSON()); + }); + } + private async activeCoins(): Promise { + const state = await this.store.load(); + return [ + ...new Set([ + ...this.runner.config.allowedMarkets, + ...state.orders.map((order) => order.coin), + ...state.positions.map((position) => position.coin), + ]), + ]; + } + async refreshSubscriptions() { + const coins = await this.activeCoins(); + for (const subscription of [...this.subscriptions]) { + if (!coins.includes(subscription.coin)) { + await subscription.handle.unsubscribe(); + this.subscriptions = this.subscriptions.filter( + (candidate) => candidate !== subscription, + ); + this.books.delete(subscription.coin); + } + } + for (const coin of coins) { + for (const type of ['l2Book', 'trades', 'bbo']) { + if (this.subscriptions.some((s) => s.coin === coin && s.type === type)) + continue; + const handle = await this.backend.subscribe( + type, + { coin }, + (message) => { + this.lastMessage = Date.now(); + this.enqueue(() => this.handleMessage(type, coin, message)); + }, + ); + this.subscriptions.push({ coin, type, handle }); + } + } + } + private async handleMessage(type: string, coin: string, message: unknown) { + if (type === 'trades') { + for (const trade of (message as { trades?: TradeMessage[] }).trades ?? []) { + if (trade.coin === coin) this.lastTrade.set(coin, trade.px); + } + await this.match(coin); + return; + } + if (type === 'l2Book') this.applyBookMessage(coin, message as BookMessage); + await this.match(coin); + } + private applyBookMessage(coin: string, message: BookMessage) { + let book = this.books.get(coin) ?? { bids: [], asks: [] }; + if (message.side) { + const side = message.side === 'B' ? 'bids' : 'asks'; + for (const level of message.levels[0] ?? []) { + const index = book[side].findIndex((existing) => existing.px === level.px); + if (Number(level.sz) === 0) { + if (index >= 0) book[side].splice(index, 1); + } else if (index >= 0) book[side][index].sz = level.sz; + else book[side].push({ px: level.px, sz: level.sz }); + } + book.bids.sort((a, b) => Number(b.px) - Number(a.px)); + book.asks.sort((a, b) => Number(a.px) - Number(b.px)); + } else if (Array.isArray(message.levels)) { + book = { bids: message.levels[0] ?? [], asks: message.levels[1] ?? [] }; + } else return; + this.books.set(coin, book); + } + private async match(coin: string) { + const book = this.books.get(coin) ?? null; + const marks = await this.data.marks(); + const result = await this.store.update((state) => + matchOrders( + state, + coin, + { + bestBid: book?.bids[0]?.px, + bestAsk: book?.asks[0]?.px, + lastTradePx: this.lastTrade.get(coin), + markPx: marks[coin], + }, + book, + this.builderFee, + Date.now(), + ), + ); + await this.report(result); + } + private async report(result: MatchResult) { + for (const trade of result.fills) { + this.lastFill = Math.max(this.lastFill ?? 0, trade.time); + await this.emit('fill', { ...trade, paper: true }); + } + for (const oid of [...result.filledOids, ...result.triggeredOids]) + await this.emit('order_update', { oid, status: 'filled', paper: true }); + for (const oid of result.cancelledOids) + await this.emit('order_update', { oid, status: 'canceled', paper: true }); + if (result.fills.length) + await this.emit('position_change', { fills: result.fills.length, paper: true }); + } + async tick() { + await this.refreshSubscriptions(); + const [marks, rates] = await Promise.all([ + this.data.marks(), + this.data.fundingRates(), + ]); + const now = Date.now(); + const payments = await this.store.update((state) => + accrueFunding(state, rates, marks, now), + ); + for (const payment of payments) + await this.emit('position_change', { type: 'funding', ...payment, paper: true }); + const state = await this.store.load(); + const maxLeverage = new Map(); + for (const coin of new Set(state.positions.map((position) => position.coin))) + maxLeverage.set(coin, await this.data.maxLeverageOf(coin)); + const liquidated = await this.store.update((latest) => + liquidate(latest, marks, (coin) => maxLeverage.get(coin) ?? 1, this.builderFee, now), + ); + for (const trade of liquidated) { + await this.emit('fill', { ...trade, paper: true }); + await this.emit('error', { + code: 'INSUFFICIENT_MARGIN', + message: `Paper liquidation on ${trade.coin}`, + paper: true, + }); + } + await this.emit('runner_state', { ...health(this.runner), paper: true }); + } + async flush() { + await this.queue; + } +} +export function paperBackend(): Backend { + const transport = new WebSocketTransport({ + isTestnet: false, // paper trading always reads mainnet public data + resubscribe: true, + webSocketFactory: (url, protocols) => + new WebSocket(url, protocols) as unknown as globalThis.WebSocket, + }); + const client = new SubscriptionClient({ transport }) as unknown as Record< + string, + ( + params: Record, + listener: (data: unknown) => void, + ) => Promise<{ unsubscribe: () => Promise }> + >; + return { + subscribe: (type, params, listener) => client[type](params, listener), + onState: (listener) => + transport.events.addEventListener('connectionstatechange', (event) => + listener(event.detail), + ), + close: async () => { + await transport.close(); + }, + }; +} +export async function startPaperEngine( + runner: Runner, + emit: (type: string, data: unknown) => Promise, + backend: Backend = paperBackend(), +) { + const engine = new PaperEngine( + runner, + new PaperStore(), + backend, + emit, + ); + await engine.start(); + return engine; +} diff --git a/packages/agent-cli/src/paper/services.ts b/packages/agent-cli/src/paper/services.ts new file mode 100644 index 0000000..0491a89 --- /dev/null +++ b/packages/agent-cli/src/paper/services.ts @@ -0,0 +1,479 @@ +import { hlInfo } from '../core/http.js'; +import { createClients, type ExchangeAdapter } from '../core/hlclient.js'; +import type { Config } from '../core/config.js'; +import type { TradingServices } from '../core/trading.js'; +import type { AccountState, OpenOrder } from '../commands/read.js'; +import { AgentCliError } from '../errors/index.js'; +import { formatUsdc, parseUsdc } from '../funding/amounts.js'; +import { paperDataNetwork } from '../core/mode.js'; +import { PaperStore } from './store.js'; +import { + accountEquity, + assertInitialMargin, + cancelOrder, + dailyLoss, + initialMargin, + liquidationPx, + matchOrders, + positionOf, + restOrder, + unrealizedPnl, + walkBook, + type PaperBook, + type PaperOrder, + type PaperState, + type PaperTrade, +} from './simulator.js'; +export interface PaperData { + book: (coin: string) => Promise; + marks: () => Promise>; + fundingRates: () => Promise>; + mids: () => Promise>; + coinOfAsset: (asset: number) => Promise; + maxLeverageOf: (coin: string) => Promise; +} +interface AssetCtx { + markPx?: string; + funding?: string; + midPx?: string; +} +let ctxCache: { at: number; marks: Record; funding: Record } | null = + null; +async function assetCtxs() { + if (ctxCache && Date.now() - ctxCache.at < 60_000) return ctxCache; + const [, assetCtxs] = await hlInfo< + [unknown, AssetCtx[]] + >(paperDataNetwork, 'metaAndAssetCtxs'); + const meta = await hlInfo<{ universe: { name: string }[] }>( + paperDataNetwork, + 'meta', + ); + const marks: Record = {}, + funding: Record = {}; + meta.universe.forEach(({ name }, index) => { + const ctx = assetCtxs[index]; + if (ctx?.markPx) marks[name] = ctx.markPx; + if (ctx?.funding) funding[name] = ctx.funding; + }); + ctxCache = { at: Date.now(), marks, funding }; + return ctxCache; +} +export function paperData(): PaperData { + return { + book: async (coin) => { + const book = await hlInfo<{ levels: { px: string; sz: string }[][] }>( + paperDataNetwork, + 'l2Book', + { coin }, + ); + if (!Array.isArray(book.levels) || book.levels.length < 2) + throw new AgentCliError('UPSTREAM_ERROR', 'Invalid l2Book snapshot'); + return { bids: book.levels[0] ?? [], asks: book.levels[1] ?? [] }; + }, + marks: async () => (await assetCtxs()).marks, + fundingRates: async () => (await assetCtxs()).funding, + mids: async () => hlInfo(paperDataNetwork, 'allMids'), + coinOfAsset: async (asset) => { + const { universe } = await hlInfo<{ universe: { name: string }[] }>( + paperDataNetwork, + 'meta', + ); + const coin = universe[asset]?.name; + if (!coin) throw new AgentCliError('UNKNOWN_MARKET', `Unknown asset ${asset}`); + return coin; + }, + maxLeverageOf: async (coin) => + (await createClients(paperDataNetwork).meta.lookup(coin)).maxLeverage, + }; +} +export function paperAccountState( + state: PaperState, + marks: Record, + maxLeverageOf: (coin: string) => number, +): AccountState { + const positions = state.positions.map((position) => { + const mark = marks[position.coin] ?? position.entryPx; + return { + position: { + coin: position.coin, + szi: position.size, + entryPx: position.entryPx, + unrealizedPnl: formatUsdc(unrealizedPnl(position, mark), 18), + liquidationPx: liquidationPx(position, maxLeverageOf(position.coin)), + leverage: { type: position.leverage.type, value: position.leverage.value }, + }, + }; + }); + const marginUsed = state.positions.reduce( + (sum, p) => sum + (marks[p.coin] ? initialMargin(p, marks[p.coin]) : 0n), + 0n, + ); + const notional = state.positions.reduce( + (sum, p) => + sum + + (marks[p.coin] + ? (absBig(parseUsdc(p.size, 18)) * parseUsdc(marks[p.coin], 18)) / + SCALE + : 0n), + 0n, + ); + return { + marginSummary: { + accountValue: formatUsdc(accountEquity(state, marks), 18), + totalMarginUsed: formatUsdc(marginUsed, 18), + totalNtlPos: formatUsdc(notional, 18), + }, + withdrawable: state.balance, + assetPositions: positions, + }; +} +const SCALE = 10n ** 18n; +const absBig = (value: bigint) => (value < 0n ? -value : value); +export function paperOpenOrders(state: PaperState): OpenOrder[] { + return state.orders.map((order) => ({ + coin: order.coin, + oid: order.oid, + ...(order.cloid ? { cloid: order.cloid } : {}), + sz: order.sz, + limitPx: order.limitPx, + side: order.side, + reduceOnly: order.reduceOnly, + ...(order.isTrigger + ? { + isTrigger: true, + triggerPx: order.triggerPx, + orderType: order.tpsl === 'tp' ? 'Take Profit' : 'Stop Loss', + } + : {}), + tif: order.tif, + })); +} +export function paperTradingServices( + config: Config, + store: PaperStore, + data: PaperData = paperData(), +): TradingServices { + return { + exchange: paperExchange(config, store, data), + market: (coin) => createClients(paperDataNetwork).meta.lookup(coin), + state: async () => { + const [state, marks] = await Promise.all([store.load(), data.marks()]); + const resolved = new Map(); + for (const coin of new Set(state.positions.map((p) => p.coin))) + resolved.set(coin, await data.maxLeverageOf(coin)); + return paperAccountState(state, marks, (coin) => resolved.get(coin) ?? 1); + }, + orders: async () => paperOpenOrders(await store.load()), + mids: () => data.mids(), + dailyLoss: async () => { + const [state, marks] = await Promise.all([store.load(), data.marks()]); + return dailyLoss(state, marks); + }, + }; +} +type WireOrder = { + a: number; + b: boolean; + p: string; + s: string; + r: boolean; + t: + | { limit: { tif: 'Gtc' | 'Ioc' | 'Alo' } } + | { trigger: { isMarket: boolean; triggerPx: string; tpsl: 'tp' | 'sl' } }; + c?: string; +}; +export type PaperEmit = (type: string, payload: unknown) => Promise; +// Local exchange adapter: fills orders against live mainnet market data while +// running the same simulator accounting as the paper runner. +export function paperExchange( + config: Config, + store: PaperStore, + data: PaperData = paperData(), + emit?: PaperEmit, +): ExchangeAdapter { + const builderFee = config.builder.fee; + const emitFills = async (trades: PaperTrade[], orders: PaperOrder[]) => { + if (!emit) return; + for (const trade of trades) + await emit('fill', { + tid: trade.tid, + time: trade.time, + coin: trade.coin, + side: trade.side, + px: trade.px, + sz: trade.sz, + oid: trade.oid, + cloid: trade.cloid, + fee: trade.fee, + paper: true, + }); + for (const order of orders) + await emit('order_update', { oid: order.oid, status: 'filled', paper: true }); + }; + const place = async (wire: WireOrder) => { + const coin = await data.coinOfAsset(wire.a); + const now = Date.now(); + const result = await store.update(async (state) => { + const isIoc = 'limit' in wire.t && wire.t.limit.tif === 'Ioc'; + if (wire.r && isIoc && (!positionOf(state, coin) || Number(positionOf(state, coin)!.size) === 0)) { + // A reduce-only IOC against a flat book matches nothing and is done. + const oid = state.nextOid++; + return { + status: { filled: { oid, totalSz: '0', avgPx: '0', status: 'canceled' } }, + filled: [] as PaperTrade[], + completed: [] as PaperOrder[], + }; + } + if ('trigger' in wire.t) { + const { isMarket, triggerPx, tpsl } = wire.t.trigger; + const marks = await data.marks(); + const mark = marks[coin]; + const book = isMarket ? await data.book(coin) : null; + const order = restOrder(state, { + coin, + side: wire.b ? 'B' : 'A', + sz: wire.s, + limitPx: wire.p, + reduceOnly: wire.r, + tif: 'Gtc', + cloid: wire.c, + isTrigger: true, + triggerPx, + tpsl, + triggerIsMarket: isMarket, + now, + }); + const matched = matchOrders( + state, + coin, + { markPx: mark }, + book, + builderFee, + now, + ); + if (matched.triggeredOids.length || matched.filledOids.length) { + const filledSz = matched.fills.reduce( + (sum, trade) => sum + parseUsdc(trade.sz, 18), + 0n, + ); + const notional = matched.fills.reduce( + (sum, trade) => + sum + + (parseUsdc(trade.sz, 18) * parseUsdc(trade.px, 18)) / SCALE, + 0n, + ); + return { + status: { + filled: { + oid: order.oid, + totalSz: formatUsdc(filledSz, 18), + avgPx: + filledSz > 0n + ? formatUsdc((notional * SCALE) / filledSz, 18) + : (mark ?? wire.p), + status: 'filled', + }, + }, + filled: matched.fills, + completed: [order], + }; + } + return { status: { resting: { oid: order.oid } }, filled: [], completed: [] }; + } + const { tif } = wire.t.limit; + if (tif === 'Ioc') { + const book = await data.book(coin); + assertInitialMargin(state, coin, wire.b ? wire.s : `-${wire.s}`, wire.p, await data.marks()); + const walk = walkBook( + state, + { + coin, + side: wire.b ? 'B' : 'A', + sz: wire.s, + limitPx: wire.p, + cloid: wire.c, + reduceOnly: wire.r, + }, + book, + builderFee, + now, + ); + if (walk.trades.length === 0) + return { + status: { error: 'Order could not immediately match against any resting orders' }, + filled: [], + completed: [], + }; + const oid = state.nextOid++; + for (const trade of walk.trades) trade.oid = oid; + return { + status: { + filled: { + oid, + totalSz: walk.filledSz, + avgPx: walk.avgPx ?? '0', + status: walk.filledSz === wire.s ? 'filled' : 'canceled', + }, + }, + filled: walk.trades, + completed: [], + }; + } + const book = await data.book(coin); + const best = wire.b ? book.asks[0]?.px : book.bids[0]?.px; + const crosses = best !== undefined && + (wire.b ? Number(best) <= Number(wire.p) : Number(best) >= Number(wire.p)); + if (crosses) { + assertInitialMargin(state, coin, wire.b ? wire.s : `-${wire.s}`, wire.p, await data.marks()); + const walk = walkBook( + state, + { + coin, + side: wire.b ? 'B' : 'A', + sz: wire.s, + limitPx: wire.p, + cloid: wire.c, + reduceOnly: wire.r, + }, + book, + builderFee, + now, + ); + if (walk.trades.length) { + const oid = state.nextOid++; + for (const trade of walk.trades) trade.oid = oid; + return { + status: { + filled: { oid, totalSz: walk.filledSz, avgPx: walk.avgPx ?? '0', status: 'filled' }, + }, + filled: walk.trades, + completed: [], + }; + } + } + assertInitialMargin(state, coin, wire.b ? wire.s : `-${wire.s}`, wire.p, await data.marks()); + const order = restOrder(state, { + coin, + side: wire.b ? 'B' : 'A', + sz: wire.s, + limitPx: wire.p, + reduceOnly: wire.r, + tif, + cloid: wire.c, + now, + }); + return { status: { resting: { oid: order.oid } }, filled: [], completed: [] }; + }); + await emitFills(result.filled, result.completed); + return result.status; + }; + return { + order: async ({ orders }: { orders: WireOrder[] }) => ({ + status: 'ok', + response: { data: { statuses: await Promise.all(orders.map(place)) } }, + }), + cancel: async ({ cancels }: { cancels: { a: number; o: number }[] }) => { + const statuses = await Promise.all( + cancels.map(async ({ o }) => { + const removed = await store.update((state) => cancelOrder(state, o)); + if (!removed) + return { error: `Order ${o} not found` }; + await emit?.('order_update', { oid: o, status: 'canceled', paper: true }); + return { success: true }; + }), + ); + return { status: 'ok', response: { data: { statuses } } }; + }, + cancelByCloid: async ({ cloids }: { cloids: { a: number; c: string }[] }) => { + const statuses = await Promise.all( + cloids.map(async ({ c }) => { + const removed = await store.update((state) => { + const order = state.orders.find((order) => order.cloid === c); + return order ? cancelOrder(state, order.oid) : null; + }); + if (!removed) return { error: `Order with cloid ${c} not found` }; + await emit?.('order_update', { oid: removed.oid, status: 'canceled', paper: true }); + return { success: true }; + }), + ); + return { status: 'ok', response: { data: { statuses } } }; + }, + batchModify: async ({ + modifies, + }: { + modifies: { oid: number | string; order: WireOrder }[]; + }) => { + const statuses = []; + for (const { oid, order: wire } of modifies) { + const coin = await data.coinOfAsset(wire.a); + const updated = await store.update(async (state) => { + const existing = state.orders.find( + (o) => o.oid === oid || (typeof oid === 'string' && o.cloid === oid), + ); + if (!existing) return null; + existing.sz = wire.s; + existing.limitPx = wire.p; + existing.reduceOnly = wire.r; + existing.cloid = wire.c ?? existing.cloid; + const book = await data.book(coin); + const best = wire.b ? book.asks[0]?.px : book.bids[0]?.px; + const crosses = best !== undefined && + (wire.b ? Number(best) <= Number(wire.p) : Number(best) >= Number(wire.p)); + if (crosses) { + const walk = walkBook( + state, + { + coin, + side: wire.b ? 'B' : 'A', + sz: wire.s, + limitPx: wire.p, + reduceOnly: wire.r, + oid: existing.oid, + cloid: wire.c, + }, + book, + builderFee, + Date.now(), + ); + if (walk.trades.length) { + state.orders = state.orders.filter((o) => o.oid !== existing.oid); + return { trades: walk.trades }; + } + } + return { trades: [] as PaperTrade[] }; + }); + if (!updated) { + statuses.push({ error: `Order ${oid} not found` }); + continue; + } + await emitFills(updated.trades, []); + statuses.push({ status: 'modified' }); + } + return { status: 'ok', response: { data: { statuses } } }; + }, + updateLeverage: async ({ + asset, + isCross, + leverage, + }: { + asset: number; + isCross: boolean; + leverage: number; + }) => { + const coin = await data.coinOfAsset(asset); + const maxLeverage = await data.maxLeverageOf(coin); + if (leverage < 1 || leverage > maxLeverage) + throw new AgentCliError('POLICY_REJECTED', 'Leverage exceeds market ceiling', { + maximum: maxLeverage, + actual: leverage, + }); + await store.update((state) => { + const setting = { type: isCross ? ('cross' as const) : ('isolated' as const), value: leverage }; + state.leverage[coin] = setting; + const position = positionOf(state, coin); + if (position) position.leverage = setting; + }); + return { status: 'ok' }; + }, + }; +} diff --git a/packages/agent-cli/src/paper/simulator.ts b/packages/agent-cli/src/paper/simulator.ts new file mode 100644 index 0000000..47226c9 --- /dev/null +++ b/packages/agent-cli/src/paper/simulator.ts @@ -0,0 +1,620 @@ +import { parseUsdc, formatUsdc } from '../funding/amounts.js'; +import { AgentCliError } from '../errors/index.js'; +// Deterministic local fill simulator for paper trading. Every function mutates +// the passed PaperState in place and reports what happened so callers can emit +// events; nothing here performs network or clock access unless values are passed in. +export const PAPER_TAKER_FEE = 0.00045; // Hyperliquid base taker, 4.5 bps +export const PAPER_MAKER_FEE = 0.00015; // Hyperliquid base maker, 1.5 bps +export const PAPER_FUNDING_INTERVAL_MS = 3_600_000; // funding accrues hourly +export const MAX_STATE_TRADES = 500; +const SCALE = 10n ** 18n; +export type Side = 'B' | 'A'; +export interface BookLevel { + px: string; + sz: string; +} +export interface PaperBook { + bids: BookLevel[]; // descending + asks: BookLevel[]; // ascending +} +export interface PaperPosition { + coin: string; + size: string; // signed decimal + entryPx: string; + leverage: { type: 'cross' | 'isolated'; value: number }; +} +export interface PaperOrder { + oid: number; + coin: string; + side: Side; + sz: string; + filledSz: string; + limitPx: string; + reduceOnly: boolean; + tif: 'Gtc' | 'Ioc' | 'Alo'; + cloid?: string; + isTrigger?: boolean; + triggerPx?: string; + tpsl?: 'tp' | 'sl'; + triggerIsMarket?: boolean; + placedAt: number; +} +export interface PaperTrade { + tid: number; + time: number; + coin: string; + side: Side; + sz: string; + px: string; + oid?: number; + cloid?: string; + fee: string; + feeRate: string; + taker: boolean; + liquidation?: boolean; + realizedPnl?: string; +} +export interface PaperState { + version: 1; + createdAt: number; + startingBalance: string; + balance: string; + positions: PaperPosition[]; + orders: PaperOrder[]; + leverage: Record; + nextOid: number; + nextTid: number; + feesPaid: string; + fundingPaid: string; + realizedPnl: string; + trades: PaperTrade[]; + liquidations: number; + lastFundingAt: number; + dayStartEquity: string; + dayStartAt: number; +} +const d = (value: string) => parseUsdc(value, 18); +const f = (value: bigint) => formatUsdc(value, 18); +const abs = (value: bigint) => (value < 0n ? -value : value); +export const builderFeeFraction = (builderFee: number) => + Math.max(0, builderFee) / 100_000; // config tenths of a bp; f=10 -> 1 bp = 0.0001 +export const tradeFeeRate = (taker: boolean, builderFee: number) => + (taker ? PAPER_TAKER_FEE : PAPER_MAKER_FEE) + builderFeeFraction(builderFee); +export function initialPaperState( + startingBalance: number, + now = Date.now(), +): PaperState { + if (!Number.isFinite(startingBalance) || startingBalance <= 0) + throw new AgentCliError('CONFIG_INVALID', 'Starting balance must be positive'); + const balance = String(startingBalance); + return { + version: 1, + createdAt: now, + startingBalance: balance, + balance, + positions: [], + orders: [], + leverage: {}, + nextOid: 1, + nextTid: 1, + feesPaid: '0', + fundingPaid: '0', + realizedPnl: '0', + trades: [], + liquidations: 0, + lastFundingAt: now, + dayStartEquity: balance, + dayStartAt: now, + }; +} +export function positionOf(state: PaperState, coin: string) { + return state.positions.find((p) => p.coin === coin); +} +export function leverageOf(state: PaperState, coin: string) { + return ( + positionOf(state, coin)?.leverage ?? + state.leverage[coin] ?? { type: 'cross' as const, value: 1 } + ); +} +export const maintenanceRate = (maxLeverage: number) => 1 / (2 * maxLeverage); +export function unrealizedPnl(position: PaperPosition, markPx: string) { + return (d(position.size) * (d(markPx) - d(position.entryPx))) / SCALE; +} +export function initialMargin(position: PaperPosition, markPx: string) { + return ( + (abs(d(position.size)) * d(markPx)) / + BigInt(position.leverage.value) / + SCALE + ); +} +export function maintenanceMargin( + position: PaperPosition, + markPx: string, + maxLeverage: number, +) { + return ( + (abs(d(position.size)) * d(markPx) * BigInt(Math.round(maintenanceRate(maxLeverage) * 1e9))) / + BigInt(1e9) / + SCALE + ); +} +export function accountEquity(state: PaperState, marks: Record) { + let equity = d(state.balance); + for (const position of state.positions) { + const mark = marks[position.coin]; + if (mark) equity += unrealizedPnl(position, mark); + } + return equity; +} +export function liquidationPx( + position: PaperPosition, + maxLeverage: number, +): string | null { + if (d(position.size) === 0n) return null; + const entry = d(position.entryPx); + const buffer = 1 / position.leverage.value - maintenanceRate(maxLeverage); + const px = + d(position.size) > 0n + ? (entry * BigInt(Math.round((1 - buffer) * 1e9))) / BigInt(1e9) + : (entry * BigInt(Math.round((1 + buffer) * 1e9))) / BigInt(1e9); + return f(px); +} +function pushTrade(state: PaperState, trade: PaperTrade) { + state.trades.push(trade); + if (state.trades.length > MAX_STATE_TRADES) + state.trades.splice(0, state.trades.length - MAX_STATE_TRADES); + return trade; +} +export interface FillInput { + coin: string; + side: Side; + sz: string; + px: string; + oid?: number; + cloid?: string; + taker: boolean; + builderFee: number; + now: number; + liquidation?: boolean; +} +export function applyFill(state: PaperState, input: FillInput): PaperTrade { + const sz = d(input.sz), + px = d(input.px); + if (sz <= 0n || px <= 0n) + throw new AgentCliError('INVALID_SIZE', 'Fill size and price must be positive'); + const notional = (sz * px) / SCALE; + const fee = (notional * BigInt(Math.round(tradeFeeRate(input.taker, input.builderFee) * 1e9))) / BigInt(1e9); + const signed = input.side === 'B' ? sz : -sz; + let realized = 0n; + const position = positionOf(state, input.coin); + if (position && d(position.size) !== 0n) { + const current = d(position.size); + const sameDirection = current > 0n === (signed > 0n); + if (!sameDirection) { + const closing = abs(current) <= abs(signed) ? abs(current) : abs(signed); + const direction = current > 0n ? 1n : -1n; + realized = (closing * (px - d(position.entryPx)) * direction) / SCALE; + } + const next = current + signed; + if (next === 0n) state.positions = state.positions.filter((p) => p !== position); + else { + if (abs(next) > abs(current)) + position.entryPx = f( + (abs(current) * d(position.entryPx) + sz * px) / (abs(current) + sz), + ); + else if (next < 0n !== current < 0n) position.entryPx = input.px; + position.size = f(next); + } + } else if (signed !== 0n) { + state.positions.push({ + coin: input.coin, + size: f(signed), + entryPx: input.px, + leverage: { ...leverageOf(state, input.coin) }, + }); + } + // Cross-style virtual margin: cash moves only by fees, funding and realized + // PnL; position notional is not deducted. Margin checks use account equity. + state.balance = f(d(state.balance) - fee + realized); + state.feesPaid = f(d(state.feesPaid) + fee); + state.realizedPnl = f(d(state.realizedPnl) + realized); + return pushTrade(state, { + tid: state.nextTid++, + time: input.now, + coin: input.coin, + side: input.side, + sz: input.sz, + px: input.px, + ...(input.oid !== undefined ? { oid: input.oid } : {}), + ...(input.cloid ? { cloid: input.cloid } : {}), + fee: f(fee), + feeRate: String(tradeFeeRate(input.taker, input.builderFee)), + taker: input.taker, + ...(input.liquidation ? { liquidation: true } : {}), + ...(realized !== 0n ? { realizedPnl: f(realized) } : {}), + }); +} +export interface WalkResult { + filledSz: string; + avgPx: string | null; + trades: PaperTrade[]; +} +// Walk a live book for a taker (market/IOC) order. limitPx bounds the walk for +// slippage-limited IOC orders; omit it for unbounded triggered market orders. +export function walkBook( + state: PaperState, + order: { + coin: string; + side: Side; + sz: string; + limitPx?: string; + oid?: number; + cloid?: string; + reduceOnly: boolean; + }, + book: PaperBook, + builderFee: number, + now: number, +): WalkResult { + const levels = order.side === 'B' ? book.asks : book.bids; + const bound = order.limitPx === undefined ? null : d(order.limitPx); + let remaining = d(order.sz); + const position = positionOf(state, order.coin); + if (order.reduceOnly && (!position || d(position.size) === 0n)) + return { filledSz: '0', avgPx: null, trades: [] }; + if (order.reduceOnly) + remaining = remaining < abs(d(position!.size)) ? remaining : abs(d(position!.size)); + let notional = 0n, + filled = 0n; + const trades: PaperTrade[] = []; + for (const level of levels) { + if (remaining <= 0n) break; + const px = d(level.px); + if (bound !== null && (order.side === 'B' ? px > bound : px < bound)) break; + const take = remaining < d(level.sz) ? remaining : d(level.sz); + trades.push( + applyFill(state, { + coin: order.coin, + side: order.side, + sz: f(take), + px: level.px, + oid: order.oid, + cloid: order.cloid, + taker: true, + builderFee, + now, + }), + ); + notional += (take * px) / SCALE; + filled += take; + remaining -= take; + } + return { + filledSz: f(filled), + avgPx: filled > 0n ? f((notional * SCALE) / filled) : null, + trades, + }; +} +// Reject fills that increase exposure beyond available initial margin. +export function assertInitialMargin( + state: PaperState, + coin: string, + signedSz: string, + px: string, + marks: Record, +) { + const current = d(positionOf(state, coin)?.size ?? '0'); + const projected = current + d(signedSz); + const increase = abs(projected) - abs(current); + if (increase <= 0n) return; + const withFill = { ...marks, [coin]: px }; + const used = state.positions.reduce( + (sum, p) => (withFill[p.coin] ? sum + initialMargin(p, withFill[p.coin]) : sum), + 0n, + ); + const leverage = leverageOf(state, coin).value; + const required = (increase * d(px)) / BigInt(leverage) / SCALE; + const available = accountEquity(state, withFill) - used; + if (available < required) + throw new AgentCliError('INSUFFICIENT_MARGIN', 'Paper margin exhausted', { + coin, + required: f(required), + available: f(available), + }); +} +export interface RestInput { + coin: string; + side: Side; + sz: string; + limitPx: string; + reduceOnly: boolean; + tif: 'Gtc' | 'Ioc' | 'Alo'; + cloid?: string; + isTrigger?: boolean; + triggerPx?: string; + tpsl?: 'tp' | 'sl'; + triggerIsMarket?: boolean; + now: number; +} +export function restOrder(state: PaperState, input: RestInput): PaperOrder { + const order: PaperOrder = { + oid: state.nextOid++, + coin: input.coin, + side: input.side, + sz: input.sz, + filledSz: '0', + limitPx: input.limitPx, + reduceOnly: input.reduceOnly, + tif: input.tif, + placedAt: input.now, + ...(input.cloid ? { cloid: input.cloid } : {}), + ...(input.isTrigger + ? { + isTrigger: true, + triggerPx: input.triggerPx, + tpsl: input.tpsl, + triggerIsMarket: input.triggerIsMarket ?? false, + } + : {}), + }; + state.orders.push(order); + return order; +} +export function cancelOrder(state: PaperState, oid: number): PaperOrder | null { + const order = state.orders.find((o) => o.oid === oid); + if (!order) return null; + state.orders = state.orders.filter((o) => o.oid !== oid); + return order; +} +export interface MarketTick { + bestBid?: string; + bestAsk?: string; + lastTradePx?: string; + markPx?: string; +} +function triggered(order: PaperOrder, tick: MarketTick): string | null { + const price = tick.lastTradePx ?? tick.markPx; + if (!price || !order.triggerPx) return null; + const px = d(price), + trigger = d(order.triggerPx); + const rising = order.side === 'A' ? px >= trigger : px <= trigger; + if (order.tpsl === 'tp') return rising ? price : null; + return rising ? null : price; +} +export interface MatchResult { + fills: PaperTrade[]; + filledOids: number[]; + cancelledOids: number[]; + triggeredOids: number[]; +} +// Fill resting orders for a coin from a live market tick (book top, trade print +// or mark price). Limit orders fill when the market trades through their price; +// TP/SL triggers fire when price crosses the trigger in the trigger direction. +export function matchOrders( + state: PaperState, + coin: string, + tick: MarketTick, + book: PaperBook | null, + builderFee: number, + now: number, +): MatchResult { + const result: MatchResult = { fills: [], filledOids: [], cancelledOids: [], triggeredOids: [] }; + for (const order of [...state.orders]) { + if (order.coin !== coin) continue; + if (order.isTrigger) { + const price = triggered(order, tick); + if (!price) continue; + if (order.reduceOnly && isFlat(state, coin)) { + cancelOrder(state, order.oid); + result.cancelledOids.push(order.oid); + continue; + } + result.triggeredOids.push(order.oid); + if (order.triggerIsMarket || !book) { + const hasLiquidity = + book && (order.side === 'B' ? book.asks.length : book.bids.length); + if (book && hasLiquidity) { + const walk = walkBook( + state, + { + coin, + side: order.side, + sz: remainingSz(order), + oid: order.oid, + cloid: order.cloid, + reduceOnly: order.reduceOnly, + }, + book, + builderFee, + now, + ); + result.fills.push(...walk.trades); + } else { + applyFill(state, { + coin, + side: order.side, + sz: remainingSz(order), + px: order.limitPx, + oid: order.oid, + cloid: order.cloid, + taker: true, + builderFee, + now, + }); + result.fills.push(state.trades.at(-1)!); + } + finishOrder(state, order, result); + } else { + order.isTrigger = false; + order.tif = 'Gtc'; + fillCrossed(state, order, tick, builderFee, now, result); + } + continue; + } + fillCrossed(state, order, tick, builderFee, now, result); + } + return result; +} +function remainingSz(order: PaperOrder) { + return f(d(order.sz) - d(order.filledSz)); +} +function isFlat(state: PaperState, coin: string) { + const position = positionOf(state, coin); + return !position || d(position.size) === 0n; +} +function finishOrder(state: PaperState, order: PaperOrder, result: MatchResult) { + state.orders = state.orders.filter((o) => o.oid !== order.oid); + result.filledOids.push(order.oid); +} +function fillCrossed( + state: PaperState, + order: PaperOrder, + tick: MarketTick, + builderFee: number, + now: number, + result: MatchResult, +) { + const price = + order.side === 'B' + ? (tick.bestAsk ?? tick.lastTradePx) + : (tick.bestBid ?? tick.lastTradePx); + if (!price) return; + const crosses = + order.side === 'B' ? d(price) <= d(order.limitPx) : d(price) >= d(order.limitPx); + const tradedThrough = + tick.lastTradePx !== undefined && + (order.side === 'B' + ? d(tick.lastTradePx) <= d(order.limitPx) + : d(tick.lastTradePx) >= d(order.limitPx)); + if (!crosses && !tradedThrough) return; + let sz = d(remainingSz(order)); + if (order.reduceOnly) { + const position = positionOf(state, order.coin); + if (!position || d(position.size) === 0n) { + cancelOrder(state, order.oid); + result.cancelledOids.push(order.oid); + return; + } + // A reduce-only order must shrink the position; cancel if it would add. + if (d(position.size) > 0n === (order.side === 'B')) { + cancelOrder(state, order.oid); + result.cancelledOids.push(order.oid); + return; + } + sz = sz < abs(d(position.size)) ? sz : abs(d(position.size)); + } + applyFill(state, { + coin: order.coin, + side: order.side, + sz: f(sz), + px: order.limitPx, + oid: order.oid, + cloid: order.cloid, + taker: false, + builderFee, + now, + }); + result.fills.push(state.trades.at(-1)!); + finishOrder(state, order, result); +} +export function accrueFunding( + state: PaperState, + fundingRates: Record, + marks: Record, + now: number, +): { coin: string; amount: string }[] { + const intervals = now - state.lastFundingAt; + if (intervals <= 0 || !state.positions.length) { + if (intervals > 0) state.lastFundingAt = now; + return []; + } + const hours = intervals / PAPER_FUNDING_INTERVAL_MS; + const payments: { coin: string; amount: string }[] = []; + for (const position of state.positions) { + const rate = fundingRates[position.coin], + mark = marks[position.coin]; + if (!rate || !mark) continue; + // Hyperliquid's metaAndAssetCtxs funding is the hourly rate (paid every + // hour), so positive funding is paid by longs and received by shorts at + // notional * rate per elapsed hour. + const notionalRate = + (((d(position.size) * d(mark)) / SCALE) * d(rate)) / SCALE; + const cash = + (-notionalRate * BigInt(Math.round(hours * 1e9))) / BigInt(1e9); + if (cash === 0n) continue; + state.balance = f(d(state.balance) + cash); + state.fundingPaid = f(d(state.fundingPaid) + cash); + payments.push({ coin: position.coin, amount: f(cash) }); + } + state.lastFundingAt = now; + return payments; +} +// Approximate maintenance-margin liquidation. Isolated positions liquidate when +// locked margin plus PnL breaches maintenance margin; cross positions liquidate +// largest-first while total account equity cannot cover aggregate maintenance. +export function liquidate( + state: PaperState, + marks: Record, + maxLeverageOf: (coin: string) => number, + builderFee: number, + now: number, +): PaperTrade[] { + const liquidated: PaperTrade[] = []; + const liquidatePosition = (position: PaperPosition, mark: string) => { + const size = abs(d(position.size)); + if (size === 0n) return; + const trade = applyFill(state, { + coin: position.coin, + side: d(position.size) > 0n ? 'A' : 'B', + sz: f(size), + px: mark, + taker: true, + builderFee, + now, + liquidation: true, + }); + trade.liquidation = true; + state.liquidations++; + state.orders = state.orders.filter((o) => o.coin !== position.coin); + liquidated.push(trade); + }; + for (const position of [...state.positions]) { + if (position.leverage.type !== 'isolated') continue; + const mark = marks[position.coin]; + if (!mark) continue; + const locked = initialMargin(position, mark); + if (locked + unrealizedPnl(position, mark) <= maintenanceMargin(position, mark, maxLeverageOf(position.coin))) + liquidatePosition(position, mark); + } + for (let guard = 0; guard < 100; guard++) { + const cross = state.positions.filter((p) => p.leverage.type === 'cross'); + if (!cross.length) break; + const maintTotal = cross.reduce( + (sum, p) => sum + (marks[p.coin] ? maintenanceMargin(p, marks[p.coin], maxLeverageOf(p.coin)) : 0n), + 0n, + ); + const equity = accountEquity(state, marks); + const breach = cross.find( + (p) => + marks[p.coin] && + maintenanceMargin(p, marks[p.coin], maxLeverageOf(p.coin)) > 0n && + equity <= maintTotal, + ); + if (!breach) break; + const largest = cross.reduce((a, b) => + abs(d(a.size)) >= abs(d(b.size)) ? a : b, + ); + liquidatePosition(largest, marks[largest.coin]); + } + return liquidated; +} +export function rolloverDay(state: PaperState, marks: Record, now: number) { + if (now - state.dayStartAt >= 86_400_000) { + state.dayStartEquity = f(accountEquity(state, marks)); + state.dayStartAt = now; + } +} +export function dailyLoss(state: PaperState, marks: Record) { + const loss = d(state.dayStartEquity) - accountEquity(state, marks); + return f(loss > 0n ? loss : 0n); +} diff --git a/packages/agent-cli/src/paper/store.ts b/packages/agent-cli/src/paper/store.ts new file mode 100644 index 0000000..4cd42d9 --- /dev/null +++ b/packages/agent-cli/src/paper/store.ts @@ -0,0 +1,102 @@ +import { open, readFile, appendFile, mkdir, chmod, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { atomicWrite } from '../core/config.js'; +import { paperDir } from '../core/mode.js'; +import { AgentCliError } from '../errors/index.js'; +import { + initialPaperState, + type PaperState, + type PaperTrade, +} from './simulator.js'; +export const PAPER_STATE_VERSION = 1 as const; +// Locked read-modify-write persistence so short-lived CLI invocations and the +// paper runner can safely share ~/.bloxwap/paper state. +export class PaperStore { + private queue = Promise.resolve(); + constructor(readonly directory = paperDir()) {} + get statePath() { + return join(this.directory, 'state.json'); + } + get tradesPath() { + return join(this.directory, 'trades.jsonl'); + } + async load(): Promise { + try { + const raw = JSON.parse(await readFile(this.statePath, 'utf8')) as PaperState; + if (raw?.version !== PAPER_STATE_VERSION) + throw new AgentCliError('CONFIG_INVALID', 'Unknown paper state version'); + return raw; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return initialPaperState(10_000); + throw error; + } + } + private async locked(operation: () => Promise): Promise { + const run = this.queue.then(async () => { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + await chmod(this.directory, 0o700); + let handle; + for (let attempt = 0; attempt < 100; attempt++) { + try { + handle = await open(this.statePath + '.lock', 'wx', 0o600); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + if (!handle) + throw new AgentCliError('INTERNAL', 'Paper state lock busy; inspect stale lock'); + try { + return await operation(); + } finally { + await handle.close(); + await unlink(this.statePath + '.lock').catch(() => {}); + } + }); + this.queue = run.then( + () => {}, + () => {}, + ); + return run; + } + // Read the latest state, mutate it, persist, and archive newly created trades. + async update(mutate: (state: PaperState) => T | Promise): Promise { + return this.locked(async () => { + const state = await this.load(); + const before = state.trades.length; + const result = await mutate(state); + await atomicWrite(this.statePath, JSON.stringify(state)); + const created = state.trades.length - before; + if (created > 0) + await appendFile( + this.tradesPath, + state.trades + .slice(state.trades.length - created) + .map((trade) => JSON.stringify(trade) + '\n') + .join(''), + { mode: 0o600 }, + ); + return result; + }); + } + async readTrades(): Promise { + try { + return (await readFile(this.tradesPath, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as PaperTrade); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + } + async reset(startingBalance: number) { + await this.locked(async () => { + const state = initialPaperState(startingBalance); + await atomicWrite(this.statePath, JSON.stringify(state)); + await atomicWrite(this.tradesPath, ''); + }); + } +} diff --git a/packages/agent-cli/src/runner/events.ts b/packages/agent-cli/src/runner/events.ts index 5354d41..20914b7 100644 --- a/packages/agent-cli/src/runner/events.ts +++ b/packages/agent-cli/src/runner/events.ts @@ -12,6 +12,7 @@ import { import { dirname, join } from 'node:path'; import { z } from 'zod'; import { stateDir } from '../core/config.js'; +import { paperDir } from '../core/mode.js'; import { redact } from '../core/logger.js'; export const eventTypes = [ 'fill', @@ -28,6 +29,8 @@ export const eventSchema = z.object({ timestamp: z.string(), type: z.enum(eventTypes), data: z.unknown(), + mode: z.enum(['live', 'paper']).default('live'), + paper: z.boolean().default(false), }); export type RunnerEvent = z.infer; export async function readEvents( @@ -54,12 +57,18 @@ export async function readEvents( } export class EventStore { private queue = Promise.resolve(); - constructor(readonly path = join(stateDir(), 'events.jsonl')) {} + constructor( + readonly path = join(stateDir(), 'events.jsonl'), + readonly paper = false, + ) {} append(type: EventType, data: unknown) { let event: RunnerEvent; const operation = this.queue.then(async () => { if (type === 'fill' && data && typeof data === 'object') - await auditFill(data as Record); + await auditFill( + data as Record, + this.paper ? 'paper' : 'live', + ); await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); await chmod(dirname(this.path), 0o700); let guard; @@ -81,6 +90,8 @@ export class EventStore { timestamp: new Date().toISOString(), type, data: redact(type === 'fill' ? normalizeFillAmounts(data) : data), + mode: this.paper ? 'paper' : 'live', + paper: this.paper, }); await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); await appendFile(this.path, JSON.stringify(event) + '\n', { @@ -126,6 +137,7 @@ export async function followEvents(options: { } } export const events = new EventStore(); +export const paperEvents = new EventStore(join(paperDir(), 'events.jsonl'), true); function normalizeFillAmounts(data: unknown) { if (!data || typeof data !== 'object') return data; diff --git a/packages/agent-cli/src/runner/health.ts b/packages/agent-cli/src/runner/health.ts index a39c2a9..7dfea87 100644 --- a/packages/agent-cli/src/runner/health.ts +++ b/packages/agent-cli/src/runner/health.ts @@ -1,11 +1,12 @@ import { collateralBalance } from '../core/account.js'; import { expiryWarnings } from '../wallet/expiry.js'; -import { activeRunner, runningInfo, type Runner } from './lifecycle.js'; +import { activeRunner, runningInfo, runnerPidPath, type Runner } from './lifecycle.js'; import type { Subscriptions } from './subscriptions.js'; import { events } from './events.js'; import { hlInfo } from '../core/http.js'; import { pollDeposits } from '../funding/status.js'; import { normalizeError } from '../errors/index.js'; +import type { Mode } from '../core/mode.js'; export function health(runner: Runner, now = Date.now()) { const subscriptions = runner.subscriptions; return { @@ -14,18 +15,22 @@ export function health(runner: Runner, now = Date.now()) { wsState: subscriptions?.state ?? 'disconnected', wsStale: !subscriptions || now - subscriptions.lastMessage > 90_000, lastFillTime: subscriptions?.lastFill ?? null, - signingSession: runner.signer ? 'ready' : 'unavailable', + signingSession: runner.signer + ? 'ready' + : runner.config.mode === 'paper' + ? 'simulated' + : 'unavailable', }; } -export async function runnerHealth() { - if (activeRunner) return health(activeRunner); - const info = await runningInfo(); +export async function runnerHealth(mode: Mode = 'live') { + if (activeRunner && activeRunner.config.mode === mode) return health(activeRunner); + const info = await runningInfo(runnerPidPath(mode)); return info ? { pid: String(info.pid), uptimeSeconds: String((Date.now() - info.startedAt) / 1000), wsState: 'unknown', - signingSession: 'unknown', + signingSession: mode === 'paper' ? 'simulated' : 'unknown', } : null; } diff --git a/packages/agent-cli/src/runner/lifecycle.ts b/packages/agent-cli/src/runner/lifecycle.ts index 658a944..60c9938 100644 --- a/packages/agent-cli/src/runner/lifecycle.ts +++ b/packages/agent-cli/src/runner/lifecycle.ts @@ -3,6 +3,7 @@ import { open, readFile, unlink, mkdir, chmod } from 'node:fs/promises'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { stateDir, atomicWrite, type Config } from '../core/config.js'; +import { paperDir, type Mode } from '../core/mode.js'; import { Logger } from '../core/logger.js'; import { privyClient, privySigner } from '../wallet/privy.js'; import type { Signer } from '../core/hlclient.js'; @@ -13,7 +14,8 @@ export interface RunnerInfo { network: Config['network']; ready: boolean; } -export const runnerPidPath = () => join(stateDir(), 'runner.pid'); +export const runnerPidPath = (mode: Mode = 'live') => + join(mode === 'paper' ? paperDir() : stateDir(), 'runner.pid'); export function isAlive(pid: number) { try { process.kill(pid, 0); diff --git a/packages/agent-cli/src/runner/rpc.ts b/packages/agent-cli/src/runner/rpc.ts index 39eb289..cf9bba0 100644 --- a/packages/agent-cli/src/runner/rpc.ts +++ b/packages/agent-cli/src/runner/rpc.ts @@ -10,6 +10,7 @@ import { join, resolve } from 'node:path'; import { createHash } from 'node:crypto'; import { z } from 'zod'; import { stateDir, loadConfig } from '../core/config.js'; +import { resolveMode } from '../core/mode.js'; import { AgentCliError, normalizeError } from '../errors/index.js'; import { commands, type Context } from '../core/router.js'; import { runningInfo, type Runner } from './lifecycle.js'; @@ -290,16 +291,24 @@ export async function routeToRunner( if (context.runner || !routedCommands.has(name)) return { routed: false as const }; const config = await loadConfig(context.configPath); + // Paper commands always execute locally against the paper store; they must + // never be routed to a live runner (or vice versa). + if ((await resolveMode(context, name)) === 'paper') + return { routed: false as const }; try { return { routed: true as const, - data: await rpcCall(name, { - ...input, - __context: { - configPath: context.configPath ?? join(stateDir(), 'config.json'), - network: context.network ?? config.network, + data: await rpcCall( + name, + { + ...input, + __context: { + configPath: context.configPath ?? join(stateDir(), 'config.json'), + network: context.network ?? config.network, + }, }, - }), + socketPath(), + ), }; } catch (error) { if ( diff --git a/packages/agent-cli/test/errors.test.ts b/packages/agent-cli/test/errors.test.ts index aaa6705..3b16c5d 100644 --- a/packages/agent-cli/test/errors.test.ts +++ b/packages/agent-cli/test/errors.test.ts @@ -27,6 +27,17 @@ test('stable wire shapes', () => { message: 'wait', retryable: true, details: null, + mode: 'live', + paper: false, }); - expect(success('yes')).toEqual({ ok: true, data: 'yes' }); + expect(success('yes')).toEqual({ + ok: true, + data: 'yes', + mode: 'live', + paper: false, + }); + expect(new AgentCliError('RATE_LIMIT', 'wait').toJSON('paper')).toMatchObject( + { mode: 'paper', paper: true }, + ); + expect(success('yes', 'paper')).toMatchObject({ mode: 'paper', paper: true }); }); diff --git a/packages/agent-cli/test/paper-engine.test.ts b/packages/agent-cli/test/paper-engine.test.ts new file mode 100644 index 0000000..de7aef1 --- /dev/null +++ b/packages/agent-cli/test/paper-engine.test.ts @@ -0,0 +1,248 @@ +import { expect, test } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Runner } from '../src/runner/lifecycle.js'; +import { PaperEngine } from '../src/paper/engine.js'; +import { PaperStore } from '../src/paper/store.js'; +import { applyFill, restOrder } from '../src/paper/simulator.js'; +import type { PaperData } from '../src/paper/services.js'; +import { migrate } from '../src/core/config.js'; +interface Listener { + type: string; + coin: string; + listener: (data: unknown) => void; +} +function fakeBackend() { + const listeners: Listener[] = []; + return { + listeners, + backend: { + subscribe: async ( + type: string, + params: Record, + listener: (data: unknown) => void, + ) => { + const entry = { type, coin: params.coin as string, listener }; + listeners.push(entry); + return { + unsubscribe: async () => { + listeners.splice(listeners.indexOf(entry), 1); + }, + }; + }, + onState: () => {}, + close: async () => {}, + }, + push: (type: string, coin: string, data: unknown) => { + for (const entry of listeners) + if (entry.type === type && entry.coin === coin) entry.listener(data); + }, + }; +} +const data = (marks: Record = { BTC: '100' }): PaperData => ({ + book: async () => ({ bids: [], asks: [] }), + marks: async () => marks, + fundingRates: async () => ({ BTC: '0.0008' }), + mids: async () => marks, + coinOfAsset: async () => 'BTC', + maxLeverageOf: async () => 40, +}); +async function setup(options: { allowedMarkets?: string[]; marks?: Record } = {}) { + const dir = await mkdtemp(join(tmpdir(), 'bloxwap-engine-')); + const runner = new Runner( + migrate({ mode: 'paper', allowedMarkets: options.allowedMarkets ?? ['BTC'] }), + dir, + ); + const store = new PaperStore(join(dir, 'paper')); + await store.reset(10_000); + const backend = fakeBackend(); + const events: { type: string; data: Record }[] = []; + const engine = new PaperEngine( + runner, + store, + backend.backend, + async (type, payload) => { + events.push({ type, data: payload as Record }); + }, + data(options.marks), + ); + await engine.start(); + return { dir, runner, store, backend, events, engine }; +} +test('engine subscribes to l2Book, trades and bbo for active coins', async () => { + const { dir, runner, backend, engine } = await setup(); + try { + expect( + backend.listeners.map((listener) => `${listener.type}:${listener.coin}`).sort(), + ).toEqual(['bbo:BTC', 'l2Book:BTC', 'trades:BTC']); + await engine.flush(); + } finally { + await runner.shutdown(); + await rm(dir, { recursive: true, force: true }); + } +}); +test('resting limit fills when a trade prints through the price', async () => { + const { dir, runner, store, backend, events, engine } = await setup(); + try { + await store.update((state) => { + restOrder(state, { + coin: 'BTC', + side: 'B', + sz: '1', + limitPx: '99', + reduceOnly: false, + tif: 'Gtc', + now: 1, + }); + }); + backend.push('l2Book', 'BTC', { + coin: 'BTC', + levels: [[{ px: '98.5', sz: '5', n: 1 }], [{ px: '100', sz: '5', n: 1 }]], + }); + await engine.flush(); + backend.push('trades', 'BTC', { + coin: 'BTC', + trades: [{ coin: 'BTC', px: '98.9', sz: '0.2', side: 'A' }], + }); + await engine.flush(); + expect(events.some((event) => event.type === 'fill')).toBe(true); + const fill = events.find((event) => event.type === 'fill')!; + expect(fill.data).toMatchObject({ px: '99', sz: '1', paper: true }); + expect( + events.some( + (event) => event.type === 'order_update' && event.data.status === 'filled', + ), + ).toBe(true); + const state = await store.load(); + expect(state.positions[0].size).toBe('1'); + } finally { + await runner.shutdown(); + await rm(dir, { recursive: true, force: true }); + } +}); +test('l2Book deltas maintain the book and cross resting orders', async () => { + const { dir, runner, store, backend, events, engine } = await setup(); + try { + await store.update((state) => { + restOrder(state, { + coin: 'BTC', + side: 'A', + sz: '1', + limitPx: '99.5', + reduceOnly: false, + tif: 'Gtc', + now: 1, + }); + }); + backend.push('l2Book', 'BTC', { + coin: 'BTC', + levels: [[{ px: '98', sz: '5', n: 1 }], [{ px: '100', sz: '5', n: 1 }]], + }); + await engine.flush(); + backend.push('l2Book', 'BTC', { + coin: 'BTC', + side: 'B', + levels: [[{ px: '99.6', sz: '2', n: 1 }]], + }); + await engine.flush(); + expect(events.some((event) => event.type === 'fill')).toBe(true); + expect((await store.load()).orders).toEqual([]); + } finally { + await runner.shutdown(); + await rm(dir, { recursive: true, force: true }); + } +}); +test('tick accrues funding and emits runner_state', async () => { + const { dir, runner, store, events, engine } = await setup(); + try { + await store.update((state) => { + applyFill(state, { + coin: 'BTC', + side: 'B', + sz: '1', + px: '100', + taker: true, + builderFee: 10, + now: Date.now() - 3_600_000, + }); + state.lastFundingAt = Date.now() - 3_600_000; + }); + await engine.tick(); + await engine.flush(); + const state = await store.load(); + expect(Number(state.fundingPaid)).toBeCloseTo(-0.08, 6); + expect( + events.some( + (event) => event.type === 'position_change' && event.data.type === 'funding', + ), + ).toBe(true); + expect( + events.some((event) => event.type === 'runner_state'), + ).toBe(true); + } finally { + await runner.shutdown(); + await rm(dir, { recursive: true, force: true }); + } +}); +test('tick liquidates leveraged positions that breach maintenance margin', async () => { + const { dir, runner, store, events, engine } = await setup({ marks: { BTC: '70' } }); + try { + await store.update((state) => { + state.leverage.BTC = { type: 'cross', value: 40 }; + applyFill(state, { + coin: 'BTC', + side: 'B', + sz: '400', + px: '100', + taker: true, + builderFee: 10, + now: 1, + }); + }); + await engine.tick(); + await engine.flush(); + const state = await store.load(); + expect(state.positions).toEqual([]); + expect(state.liquidations).toBe(1); + expect( + events.some( + (event) => event.type === 'fill' && event.data.liquidation === true, + ), + ).toBe(true); + } finally { + await runner.shutdown(); + await rm(dir, { recursive: true, force: true }); + } +}); +test('subscriptions follow newly ordered coins and drop idle ones', async () => { + const { dir, runner, store, backend, engine } = await setup({ + allowedMarkets: [], + }); + try { + expect(backend.listeners).toEqual([]); + await store.update((state) => { + restOrder(state, { + coin: 'ETH', + side: 'B', + sz: '1', + limitPx: '10', + reduceOnly: false, + tif: 'Gtc', + now: 1, + }); + }); + await engine.refreshSubscriptions(); + expect( + backend.listeners.map((listener) => `${listener.type}:${listener.coin}`).sort(), + ).toEqual(['bbo:ETH', 'l2Book:ETH', 'trades:ETH']); + await store.update((state) => { + state.orders = []; + }); + await engine.refreshSubscriptions(); + expect(backend.listeners).toEqual([]); + } finally { + await runner.shutdown(); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/agent-cli/test/paper-exchange.test.ts b/packages/agent-cli/test/paper-exchange.test.ts new file mode 100644 index 0000000..96db9c1 --- /dev/null +++ b/packages/agent-cli/test/paper-exchange.test.ts @@ -0,0 +1,240 @@ +import { expect, test, vi } from 'vitest'; +import { migrate } from '../src/core/config.js'; +import { PaperStore } from '../src/paper/store.js'; +import { + paperExchange, + type PaperData, +} from '../src/paper/services.js'; +import { paperTradingServices } from '../src/paper/services.js'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +let counter = 0; +const store = () => + new PaperStore( + join(homedir(), '.bloxwap', `paper-xchg-${process.pid}-${counter++}`), + ); +const BOOK = { + bids: [{ px: '99', sz: '5' }], + asks: [{ px: '100', sz: '1' }, { px: '101', sz: '2' }], +}; +const data = (books: Record = { BTC: BOOK }): PaperData => ({ + book: async (coin) => + (books[coin] as never) ?? { bids: [], asks: [] }, + marks: async () => ({ BTC: '100' }), + fundingRates: async () => ({ BTC: '0.0001' }), + mids: async () => ({ BTC: '100' }), + coinOfAsset: async (asset) => (asset === 0 ? 'BTC' : 'ETH'), + maxLeverageOf: async () => 40, +}); +const config = () => migrate({ mode: 'paper' }); +test('IOC order walks the live book and reports a fill', async () => { + const paper = store(); + await paper.reset(10000); + const emitted: [string, unknown][] = []; + const exchange = paperExchange( + config(), + paper, + data(), + async (type, payload) => void emitted.push([type, payload]), + ); + const result = (await exchange.order({ + orders: [ + { a: 0, b: true, p: '100.5', s: '1', r: false, t: { limit: { tif: 'Ioc' } } }, + ], + })) as { response: { data: { statuses: unknown[] } } }; + expect(result.response.data.statuses[0]).toEqual({ + filled: { oid: 1, totalSz: '1', avgPx: '100', status: 'filled' }, + }); + const state = await paper.load(); + expect(state.positions[0]).toMatchObject({ coin: 'BTC', size: '1', entryPx: '100' }); + expect(state.balance).toBe('9999.945'); + expect(emitted.map(([type]) => type)).toEqual(['fill']); +}); +test('IOC without liquidity returns an error status', async () => { + const paper = store(); + await paper.reset(10000); + const exchange = paperExchange(config(), paper, data({ BTC: { bids: [], asks: [] } })); + const result = (await exchange.order({ + orders: [ + { a: 0, b: true, p: '100', s: '1', r: false, t: { limit: { tif: 'Ioc' } } }, + ], + })) as { response: { data: { statuses: { error?: string }[] } } }; + expect(result.response.data.statuses[0].error).toMatch(/immediately match/); +}); +test('GTC limit rests when not crossed and fills immediately when crossed', async () => { + const paper = store(); + await paper.reset(10000); + const exchange = paperExchange(config(), paper, data()); + const resting = (await exchange.order({ + orders: [ + { a: 0, b: false, p: '102', s: '1', r: false, t: { limit: { tif: 'Gtc' } } }, + ], + })) as { response: { data: { statuses: unknown[] } } }; + expect(resting.response.data.statuses[0]).toEqual({ resting: { oid: 1 } }); + expect((await paper.load()).orders[0].limitPx).toBe('102'); + const crossed = (await exchange.order({ + orders: [ + { a: 0, b: true, p: '100', s: '0.5', r: false, t: { limit: { tif: 'Gtc' } } }, + ], + })) as { response: { data: { statuses: unknown[] } } }; + expect(crossed.response.data.statuses[0]).toEqual({ + filled: { oid: 2, totalSz: '0.5', avgPx: '100', status: 'filled' }, + }); + expect((await paper.load()).positions[0].size).toBe('0.5'); +}); +test('TP/SL trigger orders rest and fire when already triggered', async () => { + const paper = store(); + await paper.reset(10000); + const exchange = paperExchange(config(), paper, data()); + const resting = (await exchange.order({ + orders: [ + { + a: 0, + b: false, + p: '105', + s: '1', + r: true, + t: { trigger: { isMarket: true, triggerPx: '105', tpsl: 'tp' } }, + }, + ], + })) as { response: { data: { statuses: unknown[] } } }; + expect(resting.response.data.statuses[0]).toEqual({ resting: { oid: 1 } }); + const order = (await paper.load()).orders[0]; + expect(order).toMatchObject({ isTrigger: true, triggerPx: '105', tpsl: 'tp' }); + // Stop with trigger already below the mark fires immediately as a market walk. + const fired = (await exchange.order({ + orders: [ + { + a: 0, + b: false, + p: '99', + s: '1', + r: false, + t: { trigger: { isMarket: true, triggerPx: '105', tpsl: 'sl' } }, + }, + ], + })) as { response: { data: { statuses: unknown[] } } }; + expect(fired.response.data.statuses[0]).toEqual({ + filled: { oid: 2, totalSz: '1', avgPx: '99', status: 'filled' }, + }); +}); +test('cancel, cancelByCloid and unknown cancel errors', async () => { + const paper = store(); + await paper.reset(10000); + const exchange = paperExchange(config(), paper, data()); + await exchange.order({ + orders: [ + { a: 0, b: false, p: '102', s: '1', r: false, t: { limit: { tif: 'Gtc' } }, c: '0x' + 'ab'.repeat(16) }, + ], + }); + const byOid = (await exchange.cancel({ cancels: [{ a: 0, o: 1 }] })) as { + response: { data: { statuses: unknown[] } }; + }; + expect(byOid.response.data.statuses[0]).toEqual({ success: true }); + await exchange.order({ + orders: [ + { a: 0, b: false, p: '102', s: '1', r: false, t: { limit: { tif: 'Gtc' } }, c: '0x' + 'cd'.repeat(16) }, + ], + }); + const byCloid = (await exchange.cancelByCloid({ + cloids: [{ a: 0, c: '0x' + 'cd'.repeat(16) }], + })) as { response: { data: { statuses: unknown[] } } }; + expect(byCloid.response.data.statuses[0]).toEqual({ success: true }); + const missing = (await exchange.cancel({ cancels: [{ a: 0, o: 999 }] })) as { + response: { data: { statuses: { error?: string }[] } }; + }; + expect(missing.response.data.statuses[0].error).toMatch(/not found/); + expect((await paper.load()).orders).toEqual([]); +}); +test('batchModify updates resting orders and fills when modified into a cross', async () => { + const paper = store(); + await paper.reset(10000); + const exchange = paperExchange(config(), paper, data()); + await exchange.order({ + orders: [ + { a: 0, b: true, p: '98', s: '1', r: false, t: { limit: { tif: 'Gtc' } } }, + ], + }); + const modified = (await exchange.batchModify({ + modifies: [ + { + oid: 1, + order: { a: 0, b: true, p: '100', s: '1', r: false, t: { limit: { tif: 'Gtc' } } }, + }, + ], + })) as { response: { data: { statuses: unknown[] } } }; + expect(modified.response.data.statuses[0]).toEqual({ status: 'modified' }); + const state = await paper.load(); + expect(state.orders).toEqual([]); + expect(state.positions[0].size).toBe('1'); + const missing = (await exchange.batchModify({ + modifies: [ + { + oid: 777, + order: { a: 0, b: true, p: '100', s: '1', r: false, t: { limit: { tif: 'Gtc' } } }, + }, + ], + })) as { response: { data: { statuses: { error?: string }[] } } }; + expect(missing.response.data.statuses[0].error).toMatch(/not found/); +}); +test('updateLeverage sets defaults and position leverage within the ceiling', async () => { + const paper = store(); + await paper.reset(10000); + const exchange = paperExchange(config(), paper, data()); + await exchange.updateLeverage({ asset: 0, isCross: false, leverage: 40 }); + expect((await paper.load()).leverage.BTC).toEqual({ + type: 'isolated', + value: 40, + }); + await expect( + exchange.updateLeverage({ asset: 0, isCross: true, leverage: 50 }), + ).rejects.toMatchObject({ code: 'POLICY_REJECTED' }); +}); +test('margin exhaustion surfaces as INSUFFICIENT_MARGIN from the adapter', async () => { + const paper = store(); + await paper.reset(10000); + const exchange = paperExchange(config(), paper, data()); + await expect( + exchange.order({ + orders: [ + { a: 0, b: true, p: '100', s: '200', r: false, t: { limit: { tif: 'Gtc' } } }, + ], + }), + ).rejects.toMatchObject({ code: 'INSUFFICIENT_MARGIN' }); +}); +test('paperTradingServices exposes account state, open orders and daily loss', async () => { + const paper = store(); + await paper.reset(10000); + const services = paperTradingServices(config(), paper, data()); + const exchange = services.exchange; + await exchange.order({ + orders: [ + { a: 0, b: true, p: '100.5', s: '1', r: false, t: { limit: { tif: 'Ioc' } } }, + ], + }); + await exchange.order({ + orders: [ + { a: 0, b: false, p: '105', s: '1', r: true, t: { trigger: { isMarket: true, triggerPx: '105', tpsl: 'tp' } } }, + ], + }); + const state = await services.state(); + expect(state.marginSummary.accountValue).toBe('9999.945'); + expect(state.withdrawable).toBe('9999.945'); + expect(state.assetPositions[0].position).toMatchObject({ + coin: 'BTC', + szi: '1', + leverage: { type: 'cross', value: 1 }, + }); + const orders = await services.orders(); + expect(orders[0]).toMatchObject({ + coin: 'BTC', + oid: 2, + side: 'A', + reduceOnly: true, + isTrigger: true, + triggerPx: '105', + }); + expect(await services.mids()).toEqual({ BTC: '100' }); + expect(await services.dailyLoss!()).toBe('0.055'); + expect(vi.isMockFunction(services.exchange.order)).toBe(false); +}); diff --git a/packages/agent-cli/test/paper-mode.test.ts b/packages/agent-cli/test/paper-mode.test.ts new file mode 100644 index 0000000..5dd85f5 --- /dev/null +++ b/packages/agent-cli/test/paper-mode.test.ts @@ -0,0 +1,283 @@ +import { expect, test, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { dispatch } from '../src/core/router.js'; +import '../src/commands/config.js'; +import '../src/commands/read.js'; +import '../src/commands/order.js'; +import '../src/commands/cancel.js'; +import '../src/commands/paper.js'; +import '../src/commands/events.js'; +import '../src/commands/init.js'; +import '../src/commands/fund.js'; +import '../src/commands/rotate.js'; +import '../src/commands/revoke.js'; +import { saveConfig, migrate } from '../src/core/config.js'; +import { PaperStore } from '../src/paper/store.js'; +import { paperEvents } from '../src/runner/events.js'; +import { readAudit } from '../src/core/audit.js'; +import { paperDir } from '../src/core/mode.js'; +import { routeToRunner } from '../src/runner/rpc.js'; +import { Runner } from '../src/runner/lifecycle.js'; +import { rpcServer, socketPath } from '../src/runner/rpc.js'; +import { placeOrder, orderSchema } from '../src/commands/order.js'; +import { mockTrading } from './fixtures.js'; +const json = (data: unknown) => + new Response(JSON.stringify(data), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +interface StubOptions { + book?: { bids: { px: string; sz: string }[]; asks: { px: string; sz: string }[] }; + marks?: Record; +} +function stubHyperliquid({ book, marks = { BTC: '100' } }: StubOptions = {}) { + const calls: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: unknown, init?: { body?: string }) => { + const body = JSON.parse(init?.body ?? '{}') as { type?: string; coin?: string }; + calls.push(body.type ?? 'unknown'); + if (body.type === 'meta') + return json({ + universe: [{ name: 'BTC', szDecimals: 5, maxLeverage: 40 }], + }); + if (body.type === 'metaAndAssetCtxs') + return json([ + {}, + [ + { + markPx: marks.BTC, + midPx: marks.BTC, + funding: '0.0001', + }, + ], + ]); + if (body.type === 'allMids') return json(marks); + if (body.type === 'l2Book') + return json({ + levels: [book?.bids ?? [], book?.asks ?? []], + }); + throw new Error(`Unexpected info request: ${body.type}`); + }), + ); + return calls; +} +test('paper command marks envelopes paper and reset requires --yes', async () => { + const reset = await dispatch(['paper', 'reset', '--yes']); + expect(reset.body).toMatchObject({ + ok: true, + mode: 'paper', + paper: true, + data: { reset: true, startingBalance: '10000' }, + }); + const flagged = await dispatch(['--paper', 'config', 'get', 'mode']); + expect(flagged.body).toMatchObject({ ok: true, mode: 'paper', paper: true }); + const refused = await dispatch(['paper', 'reset']); + expect(refused.body).toMatchObject({ + ok: false, + code: 'CONFIG_INVALID', + mode: 'paper', + paper: true, + }); +}); +test('global --paper flag marks envelopes and help advertises the flag', async () => { + const help = await dispatch(['--help']); + expect((help.body as { data: { globalFlags: string[] } }).data.globalFlags).toContain( + '--paper', + ); + const envelope = await dispatch(['--paper', 'help']); + expect(envelope.body).toMatchObject({ mode: 'paper', paper: true }); + const live = await dispatch(['help']); + expect(live.body).toMatchObject({ mode: 'live', paper: false }); +}); +test('config set mode paper switches reads to the paper account', async () => { + stubHyperliquid(); + await saveConfig(migrate({})); + const set = await dispatch(['config', 'set', 'mode', 'paper']); + expect(set.body).toMatchObject({ ok: true, mode: 'live' }); + const balance = await dispatch(['balance']); + expect(balance.body).toMatchObject({ + ok: true, + mode: 'paper', + paper: true, + data: { + accountValue: '10000', + withdrawable: '10000', + marginUsed: '0', + }, + }); + const status = await dispatch(['status']); + expect(status.body).toMatchObject({ + ok: true, + mode: 'paper', + data: { + mode: 'paper', + dataNetwork: 'mainnet', + paper: { startingBalance: '10000', openOrders: 0 }, + deposits: [], + }, + }); +}); +test('switching back to live requires explicit --yes confirmation', async () => { + await saveConfig(migrate({ mode: 'paper' })); + const refused = await dispatch(['config', 'set', 'mode', 'live']); + expect(refused.body).toMatchObject({ ok: false, code: 'CONFIG_INVALID' }); + const confirmed = await dispatch(['config', 'set', 'mode', 'live', '--yes']); + expect(confirmed.body).toMatchObject({ ok: true, mode: 'paper' }); + const config = await dispatch(['config', 'get', 'mode']); + expect(config.body).toMatchObject({ ok: true, mode: 'live', data: 'live' }); +}); +test('paper market order fills against the live book and reads reflect it', async () => { + stubHyperliquid({ + book: { + bids: [{ px: '99', sz: '5' }], + asks: [{ px: '100', sz: '1' }], + }, + }); + await dispatch(['paper', 'reset', '--yes']); + const order = await dispatch([ + 'order', + '--paper', + '--json', + JSON.stringify({ coin: 'BTC', side: 'buy', size: '1', type: 'market' }), + ]); + expect(order.body).toMatchObject({ + ok: true, + mode: 'paper', + paper: true, + }); + const data = (order.body as { data: { result: { response: { data: { statuses: { filled?: { totalSz: string } }[] } } } } }).data; + expect(data.result.response.data.statuses[0].filled?.totalSz).toBe('1'); + const positions = await dispatch(['positions', '--paper']); + expect(positions.body).toMatchObject({ + ok: true, + mode: 'paper', + data: [{ coin: 'BTC', size: '1', entryPrice: '100' }], + }); + const balance = await dispatch(['balance', '--paper']); + expect( + (balance.body as { data: { accountValue: string } }).data.accountValue, + ).toBe('9999.945'); +}); +test('paper trading still enforces configured risk limits', async () => { + const clients = mockTrading(); + await expect( + placeOrder( + migrate({ mode: 'paper', limits: { maxOrderSize: 0.5 } }), + orderSchema.parse({ coin: 'BTC', side: 'buy', size: '1', price: '20000' }), + clients, + ), + ).rejects.toMatchObject({ code: 'POLICY_REJECTED' }); + const placed = await placeOrder( + migrate({ mode: 'paper', limits: { maxOrderSize: 2 } }), + orderSchema.parse({ coin: 'BTC', side: 'buy', size: '1', price: '20000' }), + clients, + ); + expect(placed.builderFeeRate).toBe('0.01%'); +}); +test('paper actions audit into the paper chain, never the live chain', async () => { + stubHyperliquid({ + book: { + bids: [{ px: '99', sz: '5' }], + asks: [{ px: '100', sz: '1' }], + }, + }); + await dispatch(['paper', 'reset', '--yes']); + const order = await dispatch([ + 'order', + '--paper', + '--json', + JSON.stringify({ coin: 'BTC', side: 'buy', size: '0.5', price: '99', tif: 'Gtc' }), + ]); + expect(order.body).toMatchObject({ ok: true }); + const paperAudit = await readAudit(join(paperDir(), 'audit.jsonl')); + expect( + paperAudit.some( + (entry) => + entry.stage === 'intent' && + JSON.stringify(entry.data).includes('"action":"order"'), + ), + ).toBe(true); + const liveAudit = await readAudit(); + expect( + liveAudit.some((entry) => JSON.stringify(entry.data).includes('"action":"order"')), + ).toBe(false); +}); +test('paper events carry paper:true and the events command reads the paper journal', async () => { + await paperEvents.append('fill', { tid: 1, coin: 'BTC', px: '100', sz: '1' }); + const replay = await dispatch(['events', '--paper']); + expect(replay.body).toMatchObject({ + ok: true, + mode: 'paper', + data: [{ type: 'fill', paper: true, mode: 'paper' }], + }); + const paperAudit = await readAudit(join(paperDir(), 'audit.jsonl')); + expect(paperAudit.some((entry) => entry.stage === 'fill')).toBe(true); +}); +test('paper commands never route to the live runner socket', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bloxwap-rpc-paper-')); + const runner = new Runner(migrate({}), dir); + let served = 0; + try { + await rpcServer(runner, { pretty: false }, async (name) => { + served++; + return { name }; + }); + const original = socketPath(); + void original; + // Point the default socket path at this server by monkey-patching via + // rpcCall path: routeToRunner uses socketPath(), so temporarily symlink + // is complex; instead assert the paper branch short-circuits even though + // a live server would serve the request. + const routed = await routeToRunner( + 'balance', + {}, + { pretty: false, paper: true, configPath: join(dir, 'config.json') }, + ); + expect(routed).toEqual({ routed: false }); + expect(served).toBe(0); + // Sanity: the same server does serve live-mode routing when reachable. + await saveConfig(migrate({}), join(dir, 'config.json')); + const liveRouted = await routeToRunner( + 'balance', + {}, + { pretty: false, configPath: join(dir, 'config.json') }, + ); + if (served === 0 && liveRouted.routed === false) { + // No live runner on the default socket in this environment; the paper + // short-circuit above is the assertion that matters. + } + } finally { + await runner.shutdown(); + await rm(dir, { recursive: true, force: true }); + } +}); +test('paper store defaults to ~/.bloxwap/paper and resets via dispatch', async () => { + const store = new PaperStore(); + await store.reset(1234); + expect((await store.load()).startingBalance).toBe('1234'); + expect(store.directory).toBe(paperDir()); +}); +test('custody commands refuse to run in paper mode', async () => { + const cases: string[][] = [ + ['init', '--paper', '--master-address', `0x${'a'.repeat(40)}`], + ['fund', '--paper', '--amount', '10', '--origin-chain', '42161'], + ['rotate', '--paper'], + ['revoke', '--paper'], + ]; + for (const argv of cases) { + const result = await dispatch(argv); + expect(result.body, argv[0]).toMatchObject({ + ok: false, + code: 'CONFIG_INVALID', + mode: 'paper', + paper: true, + }); + expect( + (result.body as { message: string }).message, + argv[0], + ).toMatch(/requires live mode/); + } +}); diff --git a/packages/agent-cli/test/paper-simulator.test.ts b/packages/agent-cli/test/paper-simulator.test.ts new file mode 100644 index 0000000..9dd9788 --- /dev/null +++ b/packages/agent-cli/test/paper-simulator.test.ts @@ -0,0 +1,407 @@ +import { expect, test } from 'vitest'; +import { + accrueFunding, + accountEquity, + applyFill, + assertInitialMargin, + cancelOrder, + dailyLoss, + initialPaperState, + leverageOf, + liquidate, + liquidationPx, + matchOrders, + restOrder, + rolloverDay, + tradeFeeRate, + walkBook, + type PaperBook, + type PaperState, +} from '../src/paper/simulator.js'; +const BOOK: PaperBook = { + bids: [ + { px: '99', sz: '5' }, + { px: '98', sz: '5' }, + ], + asks: [ + { px: '100', sz: '1' }, + { px: '101', sz: '2' }, + ], +}; +const state = (): PaperState => initialPaperState(10_000, 1_700_000_000_000); +const buy = (s: PaperState, sz: string, px = '100') => + applyFill(s, { + coin: 'BTC', + side: 'B', + sz, + px, + taker: true, + builderFee: 10, + now: 1, + }); +test('market buy walks the book with slippage and taker plus builder fees', () => { + const s = state(); + const result = walkBook( + s, + { coin: 'BTC', side: 'B', sz: '1.5', limitPx: '102', reduceOnly: false }, + BOOK, + 10, + 1_700_000_000_001, + ); + expect(result.filledSz).toBe('1.5'); + expect(result.avgPx).toBe('100.333333333333333333'); + expect(result.trades.map((t) => [t.px, t.sz])).toEqual([ + ['100', '1'], + ['101', '0.5'], + ]); + // notional 150.5 * (4.5 bps taker + 1 bp builder) = 0.082775 + expect(s.feesPaid).toBe('0.082775'); + expect(s.balance).toBe('9999.917225'); + expect(s.positions[0]).toMatchObject({ coin: 'BTC', size: '1.5' }); + expect(s.positions[0].entryPx).toBe('100.333333333333333333'); + expect(s.trades.every((t) => t.taker)).toBe(true); +}); +test('IOC bound caps fills and books without liquidity fill nothing', () => { + const s = state(); + const partial = walkBook( + s, + { coin: 'BTC', side: 'B', sz: '5', limitPx: '100.5', reduceOnly: false }, + BOOK, + 10, + 1, + ); + expect(partial.filledSz).toBe('1'); + expect(partial.avgPx).toBe('100'); + const empty = walkBook( + s, + { coin: 'BTC', side: 'B', sz: '1', limitPx: '100', reduceOnly: false }, + { bids: [], asks: [] }, + 10, + 1, + ); + expect(empty.filledSz).toBe('0'); + expect(empty.trades).toEqual([]); +}); +test('sell walks bids; realized losses land in the cash balance', () => { + const s = state(); + buy(s, '2'); + expect(s.balance).toBe('9999.89'); + const result = walkBook( + s, + { coin: 'BTC', side: 'A', sz: '2', limitPx: '98.5', reduceOnly: false }, + BOOK, + 10, + 2, + ); + expect(result.filledSz).toBe('2'); + expect(result.avgPx).toBe('99'); + // fee 198 * 0.00055 = 0.1089; realized 2 * (99 - 100) = -2 + expect(s.balance).toBe('9997.7811'); + expect(s.realizedPnl).toBe('-2'); + expect(s.positions).toEqual([]); +}); +test('resting limit fills when the market trades through', () => { + const s = state(); + restOrder(s, { + coin: 'BTC', + side: 'B', + sz: '1', + limitPx: '99', + reduceOnly: false, + tif: 'Gtc', + now: 1, + }); + const noFill = matchOrders( + s, + 'BTC', + { bestBid: '98.5', bestAsk: '100', lastTradePx: '99.2' }, + BOOK, + 10, + 2, + ); + expect(noFill.fills).toEqual([]); + const fill = matchOrders( + s, + 'BTC', + { bestBid: '98.5', bestAsk: '100', lastTradePx: '98.9' }, + BOOK, + 10, + 3, + ); + expect(fill.filledOids).toEqual([1]); + expect(fill.fills[0]).toMatchObject({ px: '99', sz: '1', taker: false }); + // maker fee: 99 * (1.5 bps + 1 bp) = 0.02475 + expect(s.feesPaid).toBe('0.02475'); + expect(s.balance).toBe('9999.97525'); + expect(s.orders).toEqual([]); +}); +test('book cross fills a resting limit as maker at its price', () => { + const s = state(); + restOrder(s, { + coin: 'BTC', + side: 'A', + sz: '1', + limitPx: '100', + reduceOnly: false, + tif: 'Gtc', + now: 1, + }); + const result = matchOrders( + s, + 'BTC', + { bestBid: '100.1', bestAsk: '101' }, + { bids: [{ px: '100.1', sz: '1' }], asks: [{ px: '101', sz: '1' }] }, + 10, + 2, + ); + expect(result.fills[0]).toMatchObject({ px: '100', side: 'A', taker: false }); +}); +test('reduce-only caps fills at the position and cancels when flat', () => { + const s = state(); + buy(s, '0.5'); + const capped = walkBook( + s, + { coin: 'BTC', side: 'A', sz: '2', limitPx: '98', reduceOnly: true }, + BOOK, + 10, + 2, + ); + expect(capped.filledSz).toBe('0.5'); + expect(capped.avgPx).toBe('99'); + expect(s.positions).toEqual([]); + restOrder(s, { + coin: 'BTC', + side: 'A', + sz: '1', + limitPx: '97', + reduceOnly: true, + tif: 'Gtc', + now: 4, + }); + const flat = matchOrders( + s, + 'BTC', + { bestBid: '97', bestAsk: '100', lastTradePx: '96' }, + BOOK, + 10, + 5, + ); + expect(flat.cancelledOids).toEqual([1]); + expect(s.orders).toEqual([]); +}); +test('reduce-only that would increase the position is cancelled', () => { + const s = state(); + buy(s, '1'); + restOrder(s, { + coin: 'BTC', + side: 'B', + sz: '1', + limitPx: '95', + reduceOnly: true, + tif: 'Gtc', + now: 2, + }); + const result = matchOrders( + s, + 'BTC', + { bestBid: '90', bestAsk: '94', lastTradePx: '93' }, + { bids: [{ px: '90', sz: '1' }], asks: [{ px: '94', sz: '1' }] }, + 10, + 3, + ); + expect(result.cancelledOids).toEqual([1]); + expect(s.positions[0].size).toBe('1'); +}); +test('take profit triggers on the way up and fills as taker market', () => { + const s = state(); + buy(s, '1'); + restOrder(s, { + coin: 'BTC', + side: 'A', + sz: '1', + limitPx: '105', + reduceOnly: true, + tif: 'Gtc', + isTrigger: true, + triggerPx: '105', + tpsl: 'tp', + triggerIsMarket: true, + now: 2, + }); + const early = matchOrders( + s, + 'BTC', + { bestBid: '104', bestAsk: '104.5', lastTradePx: '104.2' }, + BOOK, + 10, + 3, + ); + expect(early.triggeredOids).toEqual([]); + const hit = matchOrders( + s, + 'BTC', + { bestBid: '105.4', bestAsk: '105.5', lastTradePx: '105.4' }, + { bids: [{ px: '105.4', sz: '5' }], asks: [{ px: '105.5', sz: '5' }] }, + 10, + 4, + ); + expect(hit.triggeredOids).toEqual([1]); + expect(hit.fills[0]).toMatchObject({ px: '105.4', taker: true }); + expect(s.positions).toEqual([]); + expect(s.realizedPnl).toBe('5.4'); +}); +test('stop loss triggers on the way down; limit variant rests at trigger price', () => { + const s = state(); + buy(s, '1'); + restOrder(s, { + coin: 'BTC', + side: 'A', + sz: '1', + limitPx: '95', + reduceOnly: true, + tif: 'Gtc', + isTrigger: true, + triggerPx: '95', + tpsl: 'sl', + triggerIsMarket: false, + now: 2, + }); + const hit = matchOrders( + s, + 'BTC', + { bestBid: '94.9', bestAsk: '95.1', lastTradePx: '94.9' }, + { bids: [{ px: '94.9', sz: '5' }], asks: [{ px: '95.1', sz: '5' }] }, + 10, + 3, + ); + expect(hit.triggeredOids).toEqual([1]); + // trigger converts to a resting limit at 95; not yet crossed by the 94.9 bid + expect(s.orders).toHaveLength(1); + expect(s.orders[0].isTrigger).toBe(false); + const fill = matchOrders( + s, + 'BTC', + { bestBid: '95', bestAsk: '95.2', lastTradePx: '95' }, + { bids: [{ px: '95', sz: '5' }], asks: [{ px: '95.2', sz: '5' }] }, + 10, + 4, + ); + expect(fill.fills[0]).toMatchObject({ px: '95', taker: false }); + expect(s.realizedPnl).toBe('-5'); +}); +test('funding accrues hourly: longs pay, shorts receive', () => { + const s = state(); + buy(s, '1'); + const hour = 3_600_000; + const payments = accrueFunding( + s, + { BTC: '0.0008' }, + { BTC: '100' }, + s.lastFundingAt + hour, + ); + // Hyperliquid's funding field is the hourly rate: 100 * 0.0008 = 0.08. + expect(payments).toEqual([{ coin: 'BTC', amount: '-0.08' }]); + expect(s.balance).toBe('9999.865'); + expect(s.fundingPaid).toBe('-0.08'); + applyFill(s, { + coin: 'BTC', + side: 'A', + sz: '2', + px: '100', + taker: true, + builderFee: 10, + now: s.lastFundingAt + 1, + }); + const received = accrueFunding( + s, + { BTC: '0.0008' }, + { BTC: '100' }, + s.lastFundingAt + hour, + ); + expect(received[0].amount).toBe('0.08'); // short 1 receives +}); +test('isolated liquidation closes the position at mark with a taker fee', () => { + const s = state(); + buy(s, '1'); + s.positions[0].leverage = { type: 'isolated', value: 40 }; + expect(liquidationPx(s.positions[0], 40)).toBe('98.75'); + const trades = liquidate(s, { BTC: '98.75' }, () => 40, 10, 2); + expect(trades).toHaveLength(1); + expect(trades[0]).toMatchObject({ + liquidation: true, + side: 'A', + px: '98.75', + taker: true, + }); + expect(s.positions).toEqual([]); + expect(s.liquidations).toBe(1); + expect(s.realizedPnl).toBe('-1.25'); + expect(s.balance).toBe('9998.6406875'); +}); +test('cross liquidation liquidates leveraged exposure at equity breach', () => { + const s = state(); + s.leverage.BTC = { type: 'cross', value: 40 }; + buy(s, '400'); // notional 40000 at 40x: initial margin 1000 of 10000 equity + expect(s.balance).toBe('9978'); // fee 40000 * 0.00055 = 22 + expect(liquidate(s, { BTC: '80' }, () => 40, 10, 2)).toEqual([]); + const trades = liquidate(s, { BTC: '70' }, () => 40, 10, 3); + expect(trades).toHaveLength(1); + expect(trades[0]).toMatchObject({ liquidation: true, px: '70', sz: '400' }); + expect(s.positions).toEqual([]); + expect(s.liquidations).toBe(1); + expect(s.realizedPnl).toBe('-12000'); +}); +test('initial margin check rejects exposure beyond virtual equity', () => { + const s = state(); + expect(() => + assertInitialMargin(s, 'BTC', '100', '100', { BTC: '100' }), + ).not.toThrow(); + buy(s, '100'); + expect(() => assertInitialMargin(s, 'BTC', '1', '100', { BTC: '100' })).toThrow( + expect.objectContaining({ code: 'INSUFFICIENT_MARGIN' }), + ); + // reduce-only closes do not need margin + expect(() => + assertInitialMargin(s, 'BTC', '-100', '100', { BTC: '100' }), + ).not.toThrow(); +}); +test('leverage setting moves isolated liquidation price', () => { + const s = state(); + buy(s, '1'); + s.positions[0].leverage = { type: 'isolated', value: 10 }; + const ten = liquidationPx(s.positions[0], 40); + s.positions[0].leverage = { type: 'isolated', value: 40 }; + const forty = liquidationPx(s.positions[0], 40); + expect(ten).toBe('91.25'); + expect(forty).toBe('98.75'); + expect(leverageOf(s, 'BTC').value).toBe(40); +}); +test('daily loss rolls over with the virtual equity anchor', () => { + const s = state(); + expect(dailyLoss(s, {})).toBe('0'); + buy(s, '10'); + const marks = { BTC: '90' }; + expect(dailyLoss(s, marks)).toBe('100.55'); // fee 0.55 + 10 * (100 - 90) + rolloverDay(s, marks, s.dayStartAt + 86_400_001); + expect(dailyLoss(s, marks)).toBe('0'); + expect(s.dayStartEquity).toBe('9899.45'); +}); +test('cancel removes resting orders; fee rates match the schedule', () => { + const s = state(); + const order = restOrder(s, { + coin: 'BTC', + side: 'B', + sz: '1', + limitPx: '99', + reduceOnly: false, + tif: 'Gtc', + now: 1, + }); + expect(cancelOrder(s, order.oid)?.oid).toBe(order.oid); + expect(cancelOrder(s, order.oid)).toBeNull(); + expect(s.orders).toEqual([]); + expect(tradeFeeRate(true, 10)).toBeCloseTo(0.00055, 9); + expect(tradeFeeRate(false, 10)).toBeCloseTo(0.00025, 9); + expect(tradeFeeRate(true, 0)).toBeCloseTo(0.00045, 9); + expect(accountEquity(s, {})).toBe(10000000000000000000000n); +}); diff --git a/packages/agent-cli/test/paper-smoke.test.ts b/packages/agent-cli/test/paper-smoke.test.ts new file mode 100644 index 0000000..517e491 --- /dev/null +++ b/packages/agent-cli/test/paper-smoke.test.ts @@ -0,0 +1,29 @@ +import { expect, test, vi } from 'vitest'; +// Optional smoke test against real mainnet Hyperliquid public data. +// Run explicitly with: BLOXWAP_PAPER_SMOKE=1 npx vitest run test/paper-smoke.test.ts +// It never runs in the default suite (fetch stays stubbed there). +const enabled = process.env.BLOXWAP_PAPER_SMOKE === '1'; +test('paper mode reads real mainnet public market data', { timeout: 30_000 }, async () => { + if (!enabled) return; + // The unit-test setup stubs fetch to fail; the smoke test needs the network. + vi.unstubAllGlobals(); + const { paperData } = await import('../src/paper/services.js'); + const data = paperData(); + const [marks, funding, mids] = await Promise.all([ + data.marks(), + data.fundingRates(), + data.mids(), + ]); + expect(marks.BTC).toBeTruthy(); + expect(Number(marks.BTC)).toBeGreaterThan(0); + expect(funding.BTC).toBeTruthy(); + expect(mids.BTC).toBeTruthy(); + const book = await data.book('BTC'); + expect(book.asks.length).toBeGreaterThan(0); + expect(Number(book.asks[0].px)).toBeGreaterThanOrEqual(Number(book.bids[0].px)); + const coin = await data.coinOfAsset(0); + expect(coin).toBe('BTC'); + const { createClients } = await import('../src/core/hlclient.js'); + const market = await createClients('mainnet').meta.lookup('BTC'); + expect(market.maxLeverage).toBeGreaterThan(0); +}); diff --git a/packages/agent-cli/test/paper-store.test.ts b/packages/agent-cli/test/paper-store.test.ts new file mode 100644 index 0000000..2af44ae --- /dev/null +++ b/packages/agent-cli/test/paper-store.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from 'vitest'; +import { PaperStore } from '../src/paper/store.js'; +import { applyFill, initialPaperState, restOrder } from '../src/paper/simulator.js'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { readFile } from 'node:fs/promises'; +let counter = 0; +const store = () => + new PaperStore(join(homedir(), '.bloxwap', `paper-test-${process.pid}-${counter++}`)); +test('store round-trips state and archives trades', async () => { + const paper = store(); + await paper.reset(5000); + expect((await paper.load()).startingBalance).toBe('5000'); + const oid = await paper.update((state) => { + applyFill(state, { + coin: 'BTC', + side: 'B', + sz: '1', + px: '100', + taker: true, + builderFee: 10, + now: 1, + }); + return restOrder(state, { + coin: 'BTC', + side: 'A', + sz: '1', + limitPx: '101', + reduceOnly: false, + tif: 'Gtc', + now: 2, + }).oid; + }); + expect(oid).toBe(1); + const loaded = await paper.load(); + expect(loaded.positions[0].size).toBe('1'); + expect(loaded.orders[0].limitPx).toBe('101'); + expect(loaded.trades).toHaveLength(1); + const archived = await paper.readTrades(); + expect(archived).toHaveLength(1); + expect(archived[0]).toMatchObject({ px: '100', sz: '1' }); + const journal = await readFile( + join(paper.directory, 'trades.jsonl'), + 'utf8', + ); + expect(journal.trim().split('\n')).toHaveLength(1); +}); +test('missing state starts at 10000 and reset restores a clean account', async () => { + const paper = store(); + const fresh = await paper.load(); + expect(fresh).toMatchObject({ + version: 1, + startingBalance: '10000', + balance: '10000', + positions: [], + orders: [], + }); + await paper.update((state) => { + applyFill(state, { + coin: 'BTC', + side: 'B', + sz: '1', + px: '100', + taker: true, + builderFee: 10, + now: 1, + }); + }); + await paper.reset(250); + const reset = await paper.load(); + expect(reset.balance).toBe('250'); + expect(reset.positions).toEqual([]); + expect(await paper.readTrades()).toEqual([]); +}); +test('updates serialize through the lock and compose', async () => { + const paper = store(); + await paper.reset(1000); + const first = paper.update(async (state) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + state.balance = '900'; + return 'first'; + }); + const second = paper.update((state) => { + state.balance = '800'; + return 'second'; + }); + expect(await first).toBe('first'); + expect(await second).toBe('second'); + expect((await paper.load()).balance).toBe('800'); +}); +test('initialPaperState validates the starting balance', () => { + expect(() => initialPaperState(0)).toThrow( + expect.objectContaining({ code: 'CONFIG_INVALID' }), + ); + expect(() => initialPaperState(-5)).toThrow( + expect.objectContaining({ code: 'CONFIG_INVALID' }), + ); +}); diff --git a/packages/agent-cli/test/router.test.ts b/packages/agent-cli/test/router.test.ts index 4ac0653..13a50ff 100644 --- a/packages/agent-cli/test/router.test.ts +++ b/packages/agent-cli/test/router.test.ts @@ -17,6 +17,8 @@ test('flags and stdin give one success envelope', async () => { expect((await dispatch(['echo', '--value', 'hello'])).body).toEqual({ ok: true, data: { value: 'hello' }, + mode: 'live', + paper: false, }); expect((await dispatch(['echo'], '{"value":"stdin"}')).exitCode).toBe(0); expect( @@ -42,7 +44,10 @@ test('output schemas expose data fields and the fixed error catalog', async () = execute: async () => ({}), }); const schema = schemas('balance'); - expect(schema.output.oneOf[0].properties.data).toMatchObject({ + const success = schema.output.oneOf[0] as { + properties: Record; + }; + expect(success.properties.data).toMatchObject({ required: [ 'accountValue', 'accountValueUnits', diff --git a/packages/agent-cli/test/skill.test.ts b/packages/agent-cli/test/skill.test.ts index 7d781e2..1a9b6f8 100644 --- a/packages/agent-cli/test/skill.test.ts +++ b/packages/agent-cli/test/skill.test.ts @@ -24,6 +24,7 @@ import '../src/commands/revoke.js'; import '../src/commands/audit.js'; import '../src/commands/skill.js'; import '../src/commands/market.js'; +import '../src/commands/paper.js'; test('skill frontmatter and embedded source match the published skill', async () => { const source = await readFile(new URL('../SKILL.md', import.meta.url), 'utf8');