perp-cli 0.12.17 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -27
- package/dist/_stderr-filter.js +0 -1
- package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
- package/dist/agent-wallet/lighter-keystore.js +92 -10
- package/dist/agent-wallet/passphrase.d.ts +1 -1
- package/dist/agent-wallet/passphrase.js +16 -3
- package/dist/agent-wallet/store.d.ts +37 -4
- package/dist/agent-wallet/store.js +132 -0
- package/dist/agent-wallet/venue-keystore.d.ts +14 -0
- package/dist/agent-wallet/venue-keystore.js +121 -0
- package/dist/api/public/hyperliquid.js +4 -4
- package/dist/api/public/lighter.js +5 -5
- package/dist/api/public/pacifica.d.ts +0 -1
- package/dist/api/public/pacifica.js +4 -13
- package/dist/api/public/urls.d.ts +9 -1
- package/dist/api/public/urls.js +35 -13
- package/dist/arb/file-lock.d.ts +11 -0
- package/dist/arb/file-lock.js +74 -0
- package/dist/arb/index.d.ts +2 -2
- package/dist/arb/index.js +2 -2
- package/dist/arb/pending-entry.d.ts +32 -0
- package/dist/arb/pending-entry.js +77 -0
- package/dist/arb/sizing.d.ts +6 -13
- package/dist/arb/sizing.js +12 -51
- package/dist/arb/state.d.ts +45 -12
- package/dist/arb/state.js +214 -42
- package/dist/arb/utils.d.ts +20 -6
- package/dist/arb/utils.js +43 -39
- package/dist/bot/conditions.d.ts +4 -4
- package/dist/bot/conditions.js +11 -6
- package/dist/bot/config.d.ts +17 -1
- package/dist/bot/config.js +48 -12
- package/dist/bot/engine.d.ts +61 -2
- package/dist/bot/engine.js +610 -144
- package/dist/bot/fill-size.d.ts +7 -0
- package/dist/bot/fill-size.js +18 -0
- package/dist/bot/index.d.ts +1 -1
- package/dist/bot/index.js +1 -1
- package/dist/bot/perp-order.d.ts +99 -0
- package/dist/bot/perp-order.js +299 -0
- package/dist/bot/presets.d.ts +7 -0
- package/dist/bot/presets.js +17 -0
- package/dist/bot/quarantine.d.ts +75 -0
- package/dist/bot/quarantine.js +190 -0
- package/dist/bot/quote-rules.d.ts +23 -0
- package/dist/bot/quote-rules.js +69 -0
- package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
- package/dist/bot/strategies/avellaneda-mm.js +72 -70
- package/dist/bot/strategies/basis-arb.js +2 -0
- package/dist/bot/strategies/dca-strategy.d.ts +2 -0
- package/dist/bot/strategies/dca-strategy.js +5 -0
- package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
- package/dist/bot/strategies/funding-arb-strategy.js +571 -380
- package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
- package/dist/bot/strategies/funding-arb-utils.js +159 -195
- package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
- package/dist/bot/strategies/funding-arb-v2.js +258 -47
- package/dist/bot/strategies/funding-auto.js +215 -194
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
- package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
- package/dist/bot/strategies/spot-perp-executor.js +319 -120
- package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
- package/dist/bot/strategies/spot-perp-state.js +70 -0
- package/dist/bot/strategy-types.d.ts +6 -0
- package/dist/bot/tui/BotDashboard.d.ts +7 -2
- package/dist/bot/tui/BotDashboard.js +6 -3
- package/dist/bridge-engine.d.ts +120 -19
- package/dist/bridge-engine.js +710 -422
- package/dist/bridge-signers.d.ts +54 -0
- package/dist/bridge-signers.js +161 -0
- package/dist/cache.d.ts +4 -1
- package/dist/cache.js +11 -0
- package/dist/cli-flags.d.ts +50 -0
- package/dist/cli-flags.js +104 -0
- package/dist/cli-program.d.ts +35 -0
- package/dist/cli-program.js +303 -0
- package/dist/cli-spec.js +1 -1
- package/dist/client-id-tracker.js +11 -2
- package/dist/commands/account.d.ts +7 -1
- package/dist/commands/account.js +70 -92
- package/dist/commands/agent-import.d.ts +114 -0
- package/dist/commands/agent-import.js +794 -0
- package/dist/commands/agent.d.ts +232 -2
- package/dist/commands/agent.js +1858 -955
- package/dist/commands/alerts.d.ts +1 -0
- package/dist/commands/alerts.js +31 -28
- package/dist/commands/arb/index.d.ts +60 -1
- package/dist/commands/arb/index.js +381 -384
- package/dist/commands/arb-auto.d.ts +132 -4
- package/dist/commands/arb-auto.js +1474 -967
- package/dist/commands/arb.d.ts +3 -1
- package/dist/commands/arb.js +62 -30
- package/dist/commands/bot.js +127 -47
- package/dist/commands/bridge.d.ts +2 -1
- package/dist/commands/bridge.js +339 -192
- package/dist/commands/builder.d.ts +55 -0
- package/dist/commands/builder.js +387 -0
- package/dist/commands/funds-onchain.d.ts +32 -0
- package/dist/commands/funds-onchain.js +248 -0
- package/dist/commands/funds.d.ts +25 -0
- package/dist/commands/funds.js +925 -245
- package/dist/commands/history.js +102 -54
- package/dist/commands/init.d.ts +107 -15
- package/dist/commands/init.js +1116 -249
- package/dist/commands/jobs.d.ts +2 -1
- package/dist/commands/jobs.js +77 -15
- package/dist/commands/manage.js +136 -91
- package/dist/commands/market.d.ts +4 -1
- package/dist/commands/market.js +35 -32
- package/dist/commands/outcome.d.ts +10 -0
- package/dist/commands/outcome.js +396 -0
- package/dist/commands/plan.js +52 -12
- package/dist/commands/portfolio.d.ts +1 -0
- package/dist/commands/portfolio.js +60 -72
- package/dist/commands/rebalance.d.ts +2 -1
- package/dist/commands/rebalance.js +33 -43
- package/dist/commands/risk.js +46 -19
- package/dist/commands/settings.d.ts +1 -0
- package/dist/commands/settings.js +246 -51
- package/dist/commands/trade.d.ts +50 -0
- package/dist/commands/trade.js +1943 -695
- package/dist/commands/wallet.d.ts +30 -1
- package/dist/commands/wallet.js +357 -459
- package/dist/config.d.ts +60 -9
- package/dist/config.js +252 -82
- package/dist/constants.d.ts +29 -4
- package/dist/constants.js +62 -20
- package/dist/dashboard/server.d.ts +2 -0
- package/dist/dashboard/server.js +9 -6
- package/dist/dashboard/ui.js +6 -4
- package/dist/dashboard/ws-feeds.js +20 -8
- package/dist/dex-asset-map.js +5 -3
- package/dist/errors.d.ts +122 -0
- package/dist/errors.js +194 -0
- package/dist/event-stream.js +75 -19
- package/dist/evm-master-tx.d.ts +59 -0
- package/dist/evm-master-tx.js +154 -0
- package/dist/exchanges/adapter-network.d.ts +10 -0
- package/dist/exchanges/adapter-network.js +10 -0
- package/dist/exchanges/arcus/adapter.d.ts +196 -0
- package/dist/exchanges/arcus/adapter.js +1092 -0
- package/dist/exchanges/arcus/agent.d.ts +55 -0
- package/dist/exchanges/arcus/agent.js +188 -0
- package/dist/exchanges/arcus/client.d.ts +63 -0
- package/dist/exchanges/arcus/client.js +285 -0
- package/dist/exchanges/arcus/public.d.ts +65 -0
- package/dist/exchanges/arcus/public.js +138 -0
- package/dist/exchanges/arcus/signing.d.ts +176 -0
- package/dist/exchanges/arcus/signing.js +288 -0
- package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
- package/dist/exchanges/arcus/spot-chain.js +241 -0
- package/dist/exchanges/arcus/spot-router.d.ts +177 -0
- package/dist/exchanges/arcus/spot-router.js +183 -0
- package/dist/exchanges/arcus/spot.d.ts +109 -0
- package/dist/exchanges/arcus/spot.js +645 -0
- package/dist/exchanges/aster-funds.d.ts +127 -0
- package/dist/exchanges/aster-funds.js +252 -0
- package/dist/exchanges/aster-nonce.d.ts +2 -0
- package/dist/exchanges/aster-nonce.js +107 -0
- package/dist/exchanges/aster-spot.d.ts +117 -0
- package/dist/exchanges/aster-spot.js +686 -0
- package/dist/exchanges/aster-typed-data.d.ts +133 -7
- package/dist/exchanges/aster-typed-data.js +121 -7
- package/dist/exchanges/aster.d.ts +240 -6
- package/dist/exchanges/aster.js +1139 -195
- package/dist/exchanges/builder-fee.d.ts +254 -0
- package/dist/exchanges/builder-fee.js +421 -0
- package/dist/exchanges/canonical-symbols.d.ts +32 -0
- package/dist/exchanges/canonical-symbols.js +96 -0
- package/dist/exchanges/capabilities.d.ts +7 -1
- package/dist/exchanges/capabilities.js +7 -0
- package/dist/exchanges/hl-signing.d.ts +165 -0
- package/dist/exchanges/hl-signing.js +158 -0
- package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
- package/dist/exchanges/hyperliquid-outcome.js +646 -0
- package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
- package/dist/exchanges/hyperliquid-spot.js +308 -117
- package/dist/exchanges/hyperliquid.d.ts +462 -57
- package/dist/exchanges/hyperliquid.js +1575 -449
- package/dist/exchanges/index.d.ts +6 -0
- package/dist/exchanges/index.js +4 -0
- package/dist/exchanges/interface.d.ts +63 -10
- package/dist/exchanges/interface.js +18 -3
- package/dist/exchanges/ioc-outcome.d.ts +44 -0
- package/dist/exchanges/ioc-outcome.js +57 -0
- package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
- package/dist/exchanges/legacy-trading-v2.js +940 -0
- package/dist/exchanges/lighter-public.d.ts +43 -0
- package/dist/exchanges/lighter-public.js +276 -0
- package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
- package/dist/exchanges/lighter-rh-deposit.js +185 -0
- package/dist/exchanges/lighter-signer.d.ts +123 -0
- package/dist/exchanges/lighter-signer.js +215 -0
- package/dist/exchanges/lighter-spot.d.ts +91 -13
- package/dist/exchanges/lighter-spot.js +595 -233
- package/dist/exchanges/lighter.d.ts +611 -39
- package/dist/exchanges/lighter.js +1890 -649
- package/dist/exchanges/ondo/adapter.d.ts +152 -0
- package/dist/exchanges/ondo/adapter.js +811 -0
- package/dist/exchanges/ondo/agent.d.ts +31 -0
- package/dist/exchanges/ondo/agent.js +101 -0
- package/dist/exchanges/ondo/client.d.ts +60 -0
- package/dist/exchanges/ondo/client.js +297 -0
- package/dist/exchanges/ondo/funds.d.ts +95 -0
- package/dist/exchanges/ondo/funds.js +198 -0
- package/dist/exchanges/ondo/public.d.ts +95 -0
- package/dist/exchanges/ondo/public.js +214 -0
- package/dist/exchanges/ondo/spot.d.ts +127 -0
- package/dist/exchanges/ondo/spot.js +392 -0
- package/dist/exchanges/outcome-interface.d.ts +140 -0
- package/dist/exchanges/outcome-interface.js +16 -0
- package/dist/exchanges/pacifica-spot.d.ts +83 -0
- package/dist/exchanges/pacifica-spot.js +324 -0
- package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
- package/dist/exchanges/pacifica-typed-data.js +57 -9
- package/dist/exchanges/pacifica.d.ts +293 -4
- package/dist/exchanges/pacifica.js +1044 -90
- package/dist/exchanges/precision.d.ts +47 -0
- package/dist/exchanges/precision.js +209 -0
- package/dist/exchanges/qfex/adapter.d.ts +341 -0
- package/dist/exchanges/qfex/adapter.js +1386 -0
- package/dist/exchanges/qfex/agent.d.ts +58 -0
- package/dist/exchanges/qfex/agent.js +188 -0
- package/dist/exchanges/qfex/client.d.ts +54 -0
- package/dist/exchanges/qfex/client.js +138 -0
- package/dist/exchanges/qfex/errors.d.ts +70 -0
- package/dist/exchanges/qfex/errors.js +236 -0
- package/dist/exchanges/qfex/public.d.ts +122 -0
- package/dist/exchanges/qfex/public.js +268 -0
- package/dist/exchanges/qfex/signing.d.ts +160 -0
- package/dist/exchanges/qfex/signing.js +283 -0
- package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
- package/dist/exchanges/qfex/ws-transport.js +798 -0
- package/dist/exchanges/rate-limiter.d.ts +12 -0
- package/dist/exchanges/rate-limiter.js +64 -0
- package/dist/exchanges/registry.d.ts +14 -2
- package/dist/exchanges/registry.js +20 -32
- package/dist/exchanges/slippage.d.ts +37 -0
- package/dist/exchanges/slippage.js +70 -0
- package/dist/exchanges/spot-factory.d.ts +166 -0
- package/dist/exchanges/spot-factory.js +431 -0
- package/dist/exchanges/spot-interface.d.ts +98 -4
- package/dist/exchanges/spot-interface.js +27 -1
- package/dist/exchanges/spot-result.d.ts +79 -0
- package/dist/exchanges/spot-result.js +273 -0
- package/dist/exchanges/trading-v2.d.ts +117 -0
- package/dist/exchanges/trading-v2.js +301 -0
- package/dist/exchanges/venue-types.d.ts +231 -0
- package/dist/exchanges/venue-types.js +87 -0
- package/dist/exchanges/venue.d.ts +211 -0
- package/dist/exchanges/venue.js +132 -0
- package/dist/exchanges/venues/arcus.d.ts +8 -0
- package/dist/exchanges/venues/arcus.js +109 -0
- package/dist/exchanges/venues/aster.d.ts +49 -0
- package/dist/exchanges/venues/aster.js +176 -0
- package/dist/exchanges/venues/create-helpers.d.ts +18 -0
- package/dist/exchanges/venues/create-helpers.js +199 -0
- package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
- package/dist/exchanges/venues/hyperliquid.js +126 -0
- package/dist/exchanges/venues/index.d.ts +16 -0
- package/dist/exchanges/venues/index.js +26 -0
- package/dist/exchanges/venues/lighter-family.d.ts +92 -0
- package/dist/exchanges/venues/lighter-family.js +166 -0
- package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
- package/dist/exchanges/venues/lighter-rh.js +58 -0
- package/dist/exchanges/venues/lighter.d.ts +6 -0
- package/dist/exchanges/venues/lighter.js +55 -0
- package/dist/exchanges/venues/ondo.d.ts +29 -0
- package/dist/exchanges/venues/ondo.js +136 -0
- package/dist/exchanges/venues/pacifica.d.ts +8 -0
- package/dist/exchanges/venues/pacifica.js +95 -0
- package/dist/exchanges/venues/qfex.d.ts +39 -0
- package/dist/exchanges/venues/qfex.js +140 -0
- package/dist/execution-log.js +10 -2
- package/dist/funding/legacy-public.d.ts +31 -0
- package/dist/funding/legacy-public.js +369 -0
- package/dist/funding/normalize.d.ts +28 -16
- package/dist/funding/normalize.js +61 -29
- package/dist/funding/rates.d.ts +46 -11
- package/dist/funding/rates.js +228 -277
- package/dist/guardrail/perp-guardrail.js +0 -0
- package/dist/index.js +156 -684
- package/dist/jobs.d.ts +84 -6
- package/dist/jobs.js +239 -41
- package/dist/landing.d.ts +7 -2
- package/dist/landing.js +19 -3
- package/dist/mcp-server.d.ts +14 -4
- package/dist/mcp-server.js +821 -403
- package/dist/mcp-trade-guard.d.ts +16 -0
- package/dist/mcp-trade-guard.js +59 -0
- package/dist/onchain-balances.d.ts +40 -0
- package/dist/onchain-balances.js +173 -0
- package/dist/pacifica/client.d.ts +41 -10
- package/dist/pacifica/client.js +167 -63
- package/dist/pacifica/constants.d.ts +0 -1
- package/dist/pacifica/constants.js +0 -5
- package/dist/pacifica/deposit.d.ts +30 -3
- package/dist/pacifica/deposit.js +119 -2
- package/dist/pacifica/faucet.d.ts +29 -0
- package/dist/pacifica/faucet.js +100 -0
- package/dist/pacifica/sign-tx.d.ts +15 -0
- package/dist/pacifica/sign-tx.js +41 -0
- package/dist/pacifica/types/account.d.ts +28 -4
- package/dist/pacifica/types/lake.d.ts +20 -5
- package/dist/pacifica/types/lake.js +4 -0
- package/dist/pacifica/types/market.d.ts +5 -0
- package/dist/pacifica/types/order.d.ts +4 -1
- package/dist/pacifica/types/position.d.ts +9 -1
- package/dist/plan-executor.d.ts +10 -5
- package/dist/plan-executor.js +165 -54
- package/dist/position-history.js +13 -0
- package/dist/rebalance.d.ts +26 -2
- package/dist/rebalance.js +86 -7
- package/dist/relay-swap.d.ts +232 -0
- package/dist/relay-swap.js +630 -0
- package/dist/risk.js +5 -1
- package/dist/secret-env.d.ts +15 -0
- package/dist/secret-env.js +36 -0
- package/dist/settings.d.ts +169 -6
- package/dist/settings.js +219 -38
- package/dist/signer/ows-evm.d.ts +13 -0
- package/dist/signer/ows-evm.js +22 -0
- package/dist/smart-order.d.ts +9 -0
- package/dist/smart-order.js +53 -8
- package/dist/strategies/dca.d.ts +6 -2
- package/dist/strategies/dca.js +66 -26
- package/dist/strategies/funding-arb.d.ts +11 -1
- package/dist/strategies/funding-arb.js +202 -89
- package/dist/strategies/grid.d.ts +31 -0
- package/dist/strategies/grid.js +89 -11
- package/dist/strategies/pending-stop.d.ts +8 -0
- package/dist/strategies/pending-stop.js +12 -0
- package/dist/strategies/split-order.d.ts +45 -17
- package/dist/strategies/split-order.js +157 -91
- package/dist/strategies/trailing-stop.d.ts +2 -1
- package/dist/strategies/trailing-stop.js +52 -6
- package/dist/strategies/twap.d.ts +6 -1
- package/dist/strategies/twap.js +39 -11
- package/dist/trade-validator.d.ts +22 -0
- package/dist/trade-validator.js +119 -15
- package/dist/utils/numeric.d.ts +46 -0
- package/dist/utils/numeric.js +79 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.js +96 -19
- package/dist/withdraw-gate.d.ts +49 -0
- package/dist/withdraw-gate.js +131 -0
- package/package.json +15 -6
- package/skills/perp-cli/SKILL.md +159 -55
- package/skills/perp-cli/references/agent-operations.md +112 -58
- package/skills/perp-cli/references/commands.md +269 -73
- package/skills/perp-cli/references/strategies.md +34 -31
- package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
- package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
- package/skills/perp-cli/scripts/preflight.sh +21 -14
- package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
- package/skills/perp-cli/scripts/validate-arb.sh +31 -15
- package/vendor/lighter-signer/SHA256SUMS +2 -0
- package/vendor/lighter-signer/VERSION +8 -0
- package/vendor/lighter-signer/lighter-signer.wasm +0 -0
- package/vendor/lighter-signer/wasm_exec.js +575 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
- package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
package/dist/mcp-server.js
CHANGED
|
@@ -2,149 +2,219 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* MCP (Model Context Protocol) server for perp-cli.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Market data, account reads, CLI command suggestions, and two WRITE tools (`trade_execute`,
|
|
6
|
+
* `trade_close`) that place real orders through the same safety path as `perp trade`
|
|
7
|
+
* (local risk gate — no force override over MCP —, quarantine reject, execution log).
|
|
8
|
+
* Network: PERP_NETWORK (mainnet | testnet, validated like `-n`); unset = mainnet, the
|
|
9
|
+
* documented default. Every result's meta carries `network`.
|
|
10
|
+
* Adapters are created lazily from the registered agent / OWS wallet / settings (no CLI flags).
|
|
8
11
|
*/
|
|
9
|
-
|
|
12
|
+
// SSOT Rule #3: load only ~/.perp/.env, like the CLI (src/index.ts) — never a .env in the CWD
|
|
13
|
+
// (a project tree's leftover dev keys would silently become this server's credentials).
|
|
14
|
+
import { setQuarantineNetwork } from "./bot/quarantine.js";
|
|
15
|
+
import { config as loadDotenv } from "dotenv";
|
|
16
|
+
import { resolve as resolvePath } from "node:path";
|
|
17
|
+
loadDotenv({ path: resolvePath(process.env.HOME || "~", ".perp", ".env") });
|
|
18
|
+
import { registerAdapterNetwork } from "./exchanges/adapter-network.js";
|
|
19
|
+
import { builderPlanFor } from "./exchanges/builder-fee.js";
|
|
20
|
+
import { resolveAdapterSlippage } from "./exchanges/slippage.js";
|
|
10
21
|
import { createRequire } from "node:module";
|
|
11
22
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
23
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
24
|
import { z } from "zod";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
25
|
+
import { hasCredentials, listConfiguredAccountVenues, resolveCredentials, NO_CLI_CREDENTIAL_OPTS } from "./config.js";
|
|
26
|
+
import { getVenue, listVenues, listVenuesWithRole, networkConfig, READ_ONLY_CREDENTIALS, requireCapabilities } from "./exchanges/venue.js";
|
|
27
|
+
import "./exchanges/venues/index.js";
|
|
28
|
+
import { newVenueClientOrderId, placeAndConfirm, validateLegacyTif, validateOrderRequest } from "./exchanges/trading-v2.js";
|
|
29
|
+
import { closePerpVerified, isOutcomeUnknown } from "./bot/perp-order.js";
|
|
30
|
+
import { quarantineSymbol } from "./bot/quarantine.js";
|
|
31
|
+
import { fillSizeString, sizeDecimalsOf } from "./exchanges/spot-result.js";
|
|
32
|
+
import { tradingV2Of } from "./exchanges/legacy-trading-v2.js";
|
|
33
|
+
import { PerpError, classifyError, extractErrorMessage, isDefiniteOrderFailure, orderOutcomeUnknown, parsePositiveDecimal } from "./errors.js";
|
|
34
|
+
import { invalidateCache } from "./cache.js";
|
|
35
|
+
import { pingPacifica, pingHyperliquid, pingLighter, } from "./shared-api.js";
|
|
20
36
|
import { fetchAllFundingRates } from "./funding-rates.js";
|
|
21
37
|
import { loadArbState } from "./arb/state.js";
|
|
22
38
|
import { validateTrade } from "./trade-validator.js";
|
|
39
|
+
import { orderResultJson, symbolMatch } from "./utils.js";
|
|
40
|
+
import { logMcpExecution, mcpPreTradeGate, mcpReduceOnlyGate, resolveMcpNetwork } from "./mcp-trade-guard.js";
|
|
41
|
+
import { setSharedApiNetwork } from "./api/public/urls.js";
|
|
42
|
+
import { logClientId } from "./client-id-tracker.js";
|
|
43
|
+
import { loadSettings } from "./settings.js";
|
|
44
|
+
/**
|
|
45
|
+
* The one network this server serves: PERP_NETWORK, validated like `-n` (an invalid value
|
|
46
|
+
* makes the server refuse to start). Unset = mainnet, the documented default. Reported in
|
|
47
|
+
* every result's meta.network.
|
|
48
|
+
*/
|
|
49
|
+
const MCP_NETWORK = resolveMcpNetwork(process.env["PERP_NETWORK"]);
|
|
50
|
+
// Quarantine entries are network-scoped; the MCP server trades only MCP_NETWORK.
|
|
51
|
+
setQuarantineNetwork(MCP_NETWORK);
|
|
52
|
+
setSharedApiNetwork(MCP_NETWORK);
|
|
23
53
|
// ── Adapter cache & factory ──
|
|
54
|
+
//
|
|
55
|
+
// Same creation path and signer tiers as the CLI (agent → OWS master → PK;
|
|
56
|
+
// design §9.1 #1, §10.2). The MCP server has no CLI flags, so credentials come
|
|
57
|
+
// from the registered agent, settings.owsActiveWallet, and (legacy venues) env.
|
|
24
58
|
const adapters = new Map();
|
|
59
|
+
/** The network each adapter was created on (TradingV2 wrappers must use the same one). */
|
|
60
|
+
const adapterNetworks = new WeakMap();
|
|
61
|
+
function requireCreate(def) {
|
|
62
|
+
if (def.create === null) {
|
|
63
|
+
throw new PerpError("NOT_IMPLEMENTED", `${def.displayName} has no account/trading API`, {
|
|
64
|
+
remediation: `${def.id} is market-data only — use get_markets / funding tools`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return def.create;
|
|
68
|
+
}
|
|
25
69
|
async function getOrCreateAdapter(exchange) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
break;
|
|
38
|
-
}
|
|
39
|
-
case "hyperliquid": {
|
|
40
|
-
const hl = new HyperliquidAdapter(pk);
|
|
41
|
-
await hl.init();
|
|
42
|
-
adapter = hl;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
case "lighter": {
|
|
46
|
-
const lt = new LighterAdapter(pk);
|
|
47
|
-
await lt.init();
|
|
48
|
-
adapter = lt;
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
case "aster": {
|
|
52
|
-
const ast = new AsterAdapter(pk);
|
|
53
|
-
await ast.init();
|
|
54
|
-
adapter = ast;
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
default:
|
|
58
|
-
throw new Error(`Unknown exchange: ${exchange}. Supported: pacifica, hyperliquid, lighter, aster`);
|
|
70
|
+
const def = getVenue(exchange);
|
|
71
|
+
const cached = adapters.get(def.id);
|
|
72
|
+
if (cached)
|
|
73
|
+
return cached;
|
|
74
|
+
const create = requireCreate(def);
|
|
75
|
+
if (!(await hasCredentials(def.id, NO_CLI_CREDENTIAL_OPTS))) {
|
|
76
|
+
throw new PerpError("NO_SIGNER_AVAILABLE", `No credentials configured for ${def.id}`, {
|
|
77
|
+
remediation: def.masterKeyKind === "none"
|
|
78
|
+
? `Import an API key created on the ${def.displayName} website: perp wallet agent import ${def.id}`
|
|
79
|
+
: `Run: perp setup, or perp wallet agent approve ${def.id}`,
|
|
80
|
+
});
|
|
59
81
|
}
|
|
60
|
-
|
|
82
|
+
const network = MCP_NETWORK;
|
|
83
|
+
const adapter = await create({
|
|
84
|
+
network,
|
|
85
|
+
credentials: await resolveCredentials(def.id, NO_CLI_CREDENTIAL_OPTS, network),
|
|
86
|
+
noAgent: false,
|
|
87
|
+
hlDex: null,
|
|
88
|
+
readOnly: false,
|
|
89
|
+
// Builder fees follow settings.builder (user-configured, approval-confirmed); MCP has no per-call opt-out.
|
|
90
|
+
noBuilder: false,
|
|
91
|
+
});
|
|
92
|
+
adapters.set(def.id, adapter);
|
|
93
|
+
adapterNetworks.set(adapter, network);
|
|
94
|
+
registerAdapterNetwork(adapter, network);
|
|
61
95
|
return adapter;
|
|
62
96
|
}
|
|
63
|
-
/**
|
|
97
|
+
/** Signer-availability failures that route a read tool to the venue's public data path. */
|
|
98
|
+
const PUBLIC_FALLBACK_CODES = new Set(["NO_SIGNER_AVAILABLE", "AGENT_NOT_REGISTERED", "KEY_NOT_FOUND"]);
|
|
99
|
+
/**
|
|
100
|
+
* Adapter when credentials are configured and usable; null when there are none
|
|
101
|
+
* or adapter creation fails specifically for lack of a usable signer
|
|
102
|
+
* (NO_SIGNER_AVAILABLE / AGENT_NOT_REGISTERED / KEY_NOT_FOUND) — the caller then
|
|
103
|
+
* reads the venue's public data path. Every other creation error propagates.
|
|
104
|
+
*/
|
|
64
105
|
async function tryGetAdapter(exchange) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
106
|
+
const def = getVenue(exchange);
|
|
107
|
+
const cached = adapters.get(def.id);
|
|
108
|
+
if (cached)
|
|
109
|
+
return cached;
|
|
110
|
+
if (def.create === null || !(await hasCredentials(def.id, NO_CLI_CREDENTIAL_OPTS)))
|
|
111
|
+
return null;
|
|
68
112
|
try {
|
|
69
|
-
return await getOrCreateAdapter(
|
|
113
|
+
return await getOrCreateAdapter(def.id);
|
|
70
114
|
}
|
|
71
|
-
catch {
|
|
72
|
-
|
|
115
|
+
catch (e) {
|
|
116
|
+
if (e instanceof PerpError && PUBLIC_FALLBACK_CODES.has(e.structured.code))
|
|
117
|
+
return null;
|
|
118
|
+
throw e;
|
|
73
119
|
}
|
|
74
120
|
}
|
|
75
|
-
/** Fetch markets using public
|
|
121
|
+
/** Fetch markets using the venue's public data path (no key needed). */
|
|
76
122
|
async function fetchMarketsPublic(exchange) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const data = await fetchHyperliquidMeta();
|
|
84
|
-
return data.map(d => ({ symbol: d.symbol, markPrice: String(d.markPx), indexPrice: String(d.markPx), fundingRate: String(d.funding), volume24h: "0", openInterest: "0", maxLeverage: 50 }));
|
|
85
|
-
}
|
|
86
|
-
else if (ex === "lighter" || ex === "lt") {
|
|
87
|
-
const data = await fetchLighterOrderBookDetails();
|
|
88
|
-
return data.map(d => ({ symbol: d.symbol, markPrice: String(d.lastTradePrice), indexPrice: String(d.lastTradePrice), fundingRate: "0", volume24h: "0", openInterest: "0", maxLeverage: 20 }));
|
|
89
|
-
}
|
|
90
|
-
else if (ex === "aster" || ex === "ast") {
|
|
91
|
-
// Aster has no shared-api helper; AsterAdapter.getMarkets() uses
|
|
92
|
-
// public-only endpoints (exchangeInfo + ticker/24hr + premiumIndex).
|
|
93
|
-
const ast = new AsterAdapter();
|
|
94
|
-
await ast.init();
|
|
95
|
-
return ast.getMarkets();
|
|
123
|
+
return getVenue(exchange).publicData.fetchMarkets(MCP_NETWORK);
|
|
124
|
+
}
|
|
125
|
+
/** One book level from a venue payload; a level without a string price / size is a payload error (never "0"). */
|
|
126
|
+
function bookLevel(venue, symbol, price, size) {
|
|
127
|
+
if (typeof price !== "string" || typeof size !== "string") {
|
|
128
|
+
throw new PerpError("EXCHANGE_ERROR", `${venue} orderbook ${symbol}: level without price / size`, { exchange: venue });
|
|
96
129
|
}
|
|
97
|
-
|
|
130
|
+
return [price, size];
|
|
98
131
|
}
|
|
99
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Fetch an orderbook without credentials. Hyperliquid (native perps) and the Lighter family are
|
|
134
|
+
* read straight from their public REST endpoints and validated (HTTP status, payload shape, unknown
|
|
135
|
+
* symbol); every other venue goes through its read-only adapter (no key material), the same path
|
|
136
|
+
* `perp market book` takes without credentials. A venue without an account/trading API throws
|
|
137
|
+
* NOT_IMPLEMENTED (requireCreate).
|
|
138
|
+
*/
|
|
100
139
|
async function fetchOrderbookPublic(exchange, symbol) {
|
|
101
|
-
const
|
|
102
|
-
|
|
140
|
+
const def = getVenue(exchange);
|
|
141
|
+
const sym = symbol.toUpperCase();
|
|
142
|
+
if (def.id === "hyperliquid") {
|
|
103
143
|
const { HYPERLIQUID_API_URL } = await import("./api/public/urls.js");
|
|
104
144
|
const res = await fetch(HYPERLIQUID_API_URL, {
|
|
105
145
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
106
|
-
body: JSON.stringify({ type: "l2Book", coin:
|
|
146
|
+
body: JSON.stringify({ type: "l2Book", coin: sym }),
|
|
107
147
|
});
|
|
148
|
+
if (!res.ok)
|
|
149
|
+
throw new PerpError("EXCHANGE_ERROR", `${def.displayName} l2Book ${sym}: HTTP ${res.status}`, { exchange: def.id });
|
|
108
150
|
const data = await res.json();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
};
|
|
151
|
+
if (data === null) {
|
|
152
|
+
throw new PerpError("SYMBOL_NOT_FOUND", `${def.displayName} has no native perp ${sym}`, {
|
|
153
|
+
exchange: def.id,
|
|
154
|
+
remediation: `List markets with get_markets (exchange: ${def.id}); HIP-3 dex markets are read with the CLI: perp -e hyperliquid --dex <name> market book ${sym}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const levels = data.levels;
|
|
158
|
+
if (!Array.isArray(levels) || levels.length !== 2 || !Array.isArray(levels[0]) || !Array.isArray(levels[1])) {
|
|
159
|
+
throw new PerpError("EXCHANGE_ERROR", `${def.displayName} l2Book ${sym}: unexpected payload (no [bids, asks] levels)`, { exchange: def.id });
|
|
160
|
+
}
|
|
161
|
+
const side = (ls) => ls.map(l => bookLevel(def.id, sym, l.px, l.sz));
|
|
162
|
+
return { bids: side(levels[0]), asks: side(levels[1]) };
|
|
122
163
|
}
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
164
|
+
if (def.family === "lighter") {
|
|
165
|
+
const market = (await def.publicData.fetchMarkets(MCP_NETWORK)).find(m => m.symbol.toUpperCase() === sym);
|
|
166
|
+
if (!market) {
|
|
167
|
+
throw new PerpError("SYMBOL_NOT_FOUND", `${def.displayName} has no market ${sym}`, { exchange: def.id, remediation: `List markets with get_markets (exchange: ${def.id})` });
|
|
168
|
+
}
|
|
169
|
+
const res = await fetch(`${networkConfig(def, MCP_NETWORK).restUrl}/api/v1/orderBookOrders?market_id=${market.venueSymbol}&limit=20`);
|
|
170
|
+
if (!res.ok)
|
|
171
|
+
throw new PerpError("EXCHANGE_ERROR", `${def.displayName} orderBookOrders ${sym}: HTTP ${res.status}`, { exchange: def.id });
|
|
127
172
|
const data = await res.json();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await ast.init();
|
|
137
|
-
return ast.getOrderbook(symbol);
|
|
173
|
+
if (!Array.isArray(data.bids) || !Array.isArray(data.asks)) {
|
|
174
|
+
throw new PerpError("EXCHANGE_ERROR", `${def.displayName} orderBookOrders ${sym}: unexpected payload (no bids / asks arrays)`, { exchange: def.id });
|
|
175
|
+
}
|
|
176
|
+
const side = (ls) => ls.map(l => {
|
|
177
|
+
const lv = l;
|
|
178
|
+
return bookLevel(def.id, sym, lv.price, lv.remaining_base_amount !== undefined ? lv.remaining_base_amount : lv.size);
|
|
179
|
+
});
|
|
180
|
+
return { bids: side(data.bids), asks: side(data.asks) };
|
|
138
181
|
}
|
|
139
|
-
|
|
182
|
+
// Read-only adapter: no key material, public endpoints only.
|
|
183
|
+
const ro = await requireCreate(def)({ network: MCP_NETWORK, credentials: READ_ONLY_CREDENTIALS, noAgent: true, hlDex: null, readOnly: true, noBuilder: true });
|
|
184
|
+
return ro.getOrderbook(sym);
|
|
140
185
|
}
|
|
141
186
|
// ── JSON envelope helpers ──
|
|
142
187
|
function ok(data, meta) {
|
|
143
|
-
return JSON.stringify({ ok: true, data, meta }, null, 2);
|
|
188
|
+
return JSON.stringify({ ok: true, data, meta: { ...meta, network: MCP_NETWORK } }, null, 2);
|
|
144
189
|
}
|
|
145
|
-
|
|
146
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Error envelope from a thrown value: the STRUCTURED error (classifyError — a PerpError keeps its
|
|
192
|
+
* code / remediation / details verbatim), never just the message. An ORDER_PENDING order must
|
|
193
|
+
* reach the agent as code + "Do NOT resubmit" remediation, or it would retry and double-trade.
|
|
194
|
+
*/
|
|
195
|
+
function errOf(e, meta, top) {
|
|
196
|
+
const s = classifyError(e, typeof meta?.exchange === "string" ? meta.exchange : undefined);
|
|
197
|
+
return JSON.stringify({
|
|
198
|
+
ok: false,
|
|
199
|
+
error: s.message,
|
|
200
|
+
code: s.code,
|
|
201
|
+
retryable: s.retryable,
|
|
202
|
+
...(s.remediation !== undefined ? { remediation: s.remediation } : {}),
|
|
203
|
+
...(s.details !== undefined ? { details: s.details } : {}),
|
|
204
|
+
// Tool-specific top-level facts an agent must not miss (e.g. trade_execute executed:false / pending:true).
|
|
205
|
+
...top,
|
|
206
|
+
meta: { ...meta, network: MCP_NETWORK },
|
|
207
|
+
}, null, 2);
|
|
147
208
|
}
|
|
209
|
+
/** Hyperliquid scope of every MCP tool: the server builds adapters with no HIP-3 dex (hlDex null). */
|
|
210
|
+
const HL_SCOPE_NOTE = "Hyperliquid: native perps only (HIP-3 dexes via the CLI --dex).";
|
|
211
|
+
/**
|
|
212
|
+
* Venues with a `perp funds deposit <venue>` command. lighter takes a route (ethereum | cctp <chain>);
|
|
213
|
+
* qfex takes no amount (it shows the deposit address). Tested against the real command tree.
|
|
214
|
+
*/
|
|
215
|
+
export const MCP_DEPOSIT_VENUES = ["pacifica", "hyperliquid", "lighter", "lighter-rh", "aster", "ondo", "qfex"];
|
|
216
|
+
/** Venues with a `perp funds withdraw <venue>` command. Tested against the real command tree. */
|
|
217
|
+
export const MCP_WITHDRAW_VENUES = ["pacifica", "hyperliquid", "lighter", "aster", "arcus"];
|
|
148
218
|
// ── MCP Server ──
|
|
149
219
|
const _require = createRequire(import.meta.url);
|
|
150
220
|
const _pkg = _require("../package.json");
|
|
@@ -152,7 +222,7 @@ const server = new McpServer({ name: "perp-cli", version: _pkg.version }, { capa
|
|
|
152
222
|
// ============================================================
|
|
153
223
|
// Market Data tools (read-only, no private key needed)
|
|
154
224
|
// ============================================================
|
|
155
|
-
server.tool("get_markets",
|
|
225
|
+
server.tool("get_markets", `Get all available perpetual futures markets on an exchange, including price, funding rate, volume, and max leverage. Works without API keys (public market data). ${HL_SCOPE_NOTE}`, { exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`) }, async ({ exchange }) => {
|
|
156
226
|
try {
|
|
157
227
|
// Try adapter first (richer data), fall back to public API (no key needed)
|
|
158
228
|
const adapter = await tryGetAdapter(exchange);
|
|
@@ -164,11 +234,11 @@ server.tool("get_markets", "Get all available perpetual futures markets on an ex
|
|
|
164
234
|
return { content: [{ type: "text", text: ok(markets, { exchange, count: markets.length, source: "public" }) }] };
|
|
165
235
|
}
|
|
166
236
|
catch (e) {
|
|
167
|
-
return { content: [{ type: "text", text:
|
|
237
|
+
return { content: [{ type: "text", text: errOf(e, { exchange }) }], isError: true };
|
|
168
238
|
}
|
|
169
239
|
});
|
|
170
|
-
server.tool("get_orderbook",
|
|
171
|
-
exchange: z.string().describe(
|
|
240
|
+
server.tool("get_orderbook", `Get the order book (bids and asks) for a symbol on an exchange. Works without API keys on every venue with an account API (configured credentials → the account adapter; none → the public endpoint / read-only adapter); market-data-only venues have no orderbook path. ${HL_SCOPE_NOTE}`, {
|
|
241
|
+
exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`),
|
|
172
242
|
symbol: z.string().describe("Trading pair symbol, e.g. BTC, ETH, SOL"),
|
|
173
243
|
}, async ({ exchange, symbol }) => {
|
|
174
244
|
try {
|
|
@@ -182,10 +252,10 @@ server.tool("get_orderbook", "Get the order book (bids and asks) for a symbol on
|
|
|
182
252
|
return { content: [{ type: "text", text: ok(book, { exchange, symbol, source: "public" }) }] };
|
|
183
253
|
}
|
|
184
254
|
catch (e) {
|
|
185
|
-
return { content: [{ type: "text", text:
|
|
255
|
+
return { content: [{ type: "text", text: errOf(e, { exchange, symbol }) }], isError: true };
|
|
186
256
|
}
|
|
187
257
|
});
|
|
188
|
-
server.tool("get_funding_rates", "Compare funding rates across
|
|
258
|
+
server.tool("get_funding_rates", "Compare funding rates across every market-data venue. Returns rates per symbol with spread analysis", {
|
|
189
259
|
symbols: z
|
|
190
260
|
.array(z.string())
|
|
191
261
|
.optional()
|
|
@@ -202,105 +272,146 @@ server.tool("get_funding_rates", "Compare funding rates across all 4 exchanges (
|
|
|
202
272
|
};
|
|
203
273
|
}
|
|
204
274
|
catch (e) {
|
|
205
|
-
return { content: [{ type: "text", text:
|
|
275
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
206
276
|
}
|
|
207
277
|
});
|
|
208
|
-
server.tool("get_prices", "Get cross-exchange prices for symbols. Fetches mark prices from
|
|
278
|
+
server.tool("get_prices", "Get cross-exchange prices for symbols. Fetches mark prices from every market-data venue for comparison", {
|
|
209
279
|
symbols: z
|
|
210
280
|
.array(z.string())
|
|
211
281
|
.optional()
|
|
212
282
|
.describe("Symbols to fetch prices for (e.g. ['BTC','ETH']). Omit for top assets"),
|
|
213
283
|
}, async ({ symbols }) => {
|
|
214
284
|
try {
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const ast = new AsterAdapter();
|
|
218
|
-
await ast.init();
|
|
219
|
-
return ast.getMarkets();
|
|
220
|
-
})();
|
|
221
|
-
const [pacifica, hl, lighter, aster] = await Promise.all([
|
|
222
|
-
fetchPacificaPrices(),
|
|
223
|
-
fetchHyperliquidMeta(),
|
|
224
|
-
fetchLighterOrderBookDetails(),
|
|
225
|
-
asterMarkets,
|
|
226
|
-
]);
|
|
285
|
+
const venues = listVenuesWithRole("marketData").map(v => v.id);
|
|
286
|
+
const results = await Promise.allSettled(venues.map(ex => fetchMarketsPublic(ex)));
|
|
227
287
|
const filter = symbols ? new Set(symbols.map(s => s.toUpperCase())) : null;
|
|
228
|
-
// Build symbol → exchange prices map
|
|
288
|
+
// Build symbol → exchange prices map; per-venue failures are reported in meta.errors
|
|
229
289
|
const priceMap = new Map();
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
priceMap.
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
for (const m of lighter) {
|
|
247
|
-
const sym = m.symbol.toUpperCase();
|
|
248
|
-
if (filter && !filter.has(sym))
|
|
249
|
-
continue;
|
|
250
|
-
if (!priceMap.has(sym))
|
|
251
|
-
priceMap.set(sym, {});
|
|
252
|
-
priceMap.get(sym).lighter = m.lastTradePrice;
|
|
253
|
-
}
|
|
254
|
-
for (const a of aster) {
|
|
255
|
-
const sym = a.symbol.toUpperCase();
|
|
256
|
-
if (filter && !filter.has(sym))
|
|
257
|
-
continue;
|
|
258
|
-
if (!priceMap.has(sym))
|
|
259
|
-
priceMap.set(sym, {});
|
|
260
|
-
priceMap.get(sym).aster = Number(a.markPrice);
|
|
261
|
-
}
|
|
290
|
+
const errors = {};
|
|
291
|
+
results.forEach((r, i) => {
|
|
292
|
+
const ex = venues[i];
|
|
293
|
+
if (r.status === "rejected") {
|
|
294
|
+
errors[ex] = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
for (const m of r.value) {
|
|
298
|
+
const sym = m.symbol.toUpperCase();
|
|
299
|
+
if (filter && !filter.has(sym))
|
|
300
|
+
continue;
|
|
301
|
+
if (!priceMap.has(sym))
|
|
302
|
+
priceMap.set(sym, {});
|
|
303
|
+
priceMap.get(sym)[ex] = Number(m.markPrice);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
262
306
|
const data = Array.from(priceMap.entries()).map(([symbol, prices]) => ({ symbol, prices }));
|
|
263
|
-
return { content: [{ type: "text", text: ok(data, { count: data.length }) }] };
|
|
307
|
+
return { content: [{ type: "text", text: ok(data, { count: data.length, errors }) }] };
|
|
264
308
|
}
|
|
265
309
|
catch (e) {
|
|
266
|
-
return { content: [{ type: "text", text:
|
|
310
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
// ============================================================
|
|
314
|
+
// Outcome markets (Hyperliquid HIP-4) — public reads
|
|
315
|
+
// ============================================================
|
|
316
|
+
server.tool("get_outcome_markets", "List active Hyperliquid Outcome markets (HIP-4). Each market has parsed metadata (underlying asset, target price, expiry, period) and per-side info (Yes/No name, encoding, asset id, current mid). Public read — no API key needed.", {}, async () => {
|
|
317
|
+
try {
|
|
318
|
+
const { HyperliquidAdapter } = await import("./exchanges/hyperliquid.js");
|
|
319
|
+
const { HyperliquidOutcomeAdapter } = await import("./exchanges/hyperliquid-outcome.js");
|
|
320
|
+
const hl = new HyperliquidAdapter(undefined, MCP_NETWORK === "testnet");
|
|
321
|
+
await hl.init();
|
|
322
|
+
const out = new HyperliquidOutcomeAdapter(hl);
|
|
323
|
+
const markets = await out.getMarkets();
|
|
324
|
+
return { content: [{ type: "text", text: ok(markets, { count: markets.length, source: "outcomeMeta" }) }] };
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
server.tool("get_outcome_view", "Get a combined view of one outcome market: all sides' books in parallel + underlying mark price gap (vs targetPrice) + time to expiry + per-side implied probability (mid sum). For binary markets, the symmetric structure means Yes bid + No ask ≈ 1.0 — the view exposes both sides in a single round-trip and surfaces directional context (e.g., is BTC currently above the target). Public read.", {
|
|
331
|
+
outcome: z.number().int().nonnegative().describe("Outcome id from outcomeMeta (e.g., 1 for the BTC binary daily)"),
|
|
332
|
+
depth: z.number().int().min(1).max(50).optional().default(10).describe("Number of book levels per side"),
|
|
333
|
+
}, async ({ outcome, depth }) => {
|
|
334
|
+
try {
|
|
335
|
+
const { HyperliquidAdapter } = await import("./exchanges/hyperliquid.js");
|
|
336
|
+
const { HyperliquidOutcomeAdapter } = await import("./exchanges/hyperliquid-outcome.js");
|
|
337
|
+
const hl = new HyperliquidAdapter(undefined, MCP_NETWORK === "testnet");
|
|
338
|
+
await hl.init();
|
|
339
|
+
const out = new HyperliquidOutcomeAdapter(hl);
|
|
340
|
+
const view = await out.getView(outcome, depth);
|
|
341
|
+
return { content: [{ type: "text", text: ok(view, { outcome, depth }) }] };
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
return { content: [{ type: "text", text: errOf(e, { outcome }) }], isError: true };
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
server.tool("get_outcome_book", "Get the orderbook for one outcome side. Public read — no API key needed.", {
|
|
348
|
+
outcome: z.number().int().nonnegative().describe("Outcome id from outcomeMeta (e.g., 1 for the BTC binary daily)"),
|
|
349
|
+
side: z.number().int().min(0).max(9).describe("Side index within the outcome (0 = first sideSpecs entry like 'Yes', 1 = second like 'No')"),
|
|
350
|
+
}, async ({ outcome, side }) => {
|
|
351
|
+
try {
|
|
352
|
+
const { HyperliquidAdapter } = await import("./exchanges/hyperliquid.js");
|
|
353
|
+
const { HyperliquidOutcomeAdapter } = await import("./exchanges/hyperliquid-outcome.js");
|
|
354
|
+
const hl = new HyperliquidAdapter(undefined, MCP_NETWORK === "testnet");
|
|
355
|
+
await hl.init();
|
|
356
|
+
const out = new HyperliquidOutcomeAdapter(hl);
|
|
357
|
+
const book = await out.getOrderbook(outcome, side);
|
|
358
|
+
return { content: [{ type: "text", text: ok(book, { outcome, side, assetId: HyperliquidOutcomeAdapter.assetId(outcome, side) }) }] };
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
return { content: [{ type: "text", text: errOf(e, { outcome, side }) }], isError: true };
|
|
267
362
|
}
|
|
268
363
|
});
|
|
269
364
|
// ============================================================
|
|
270
365
|
// Account tools (need private key)
|
|
271
366
|
// ============================================================
|
|
272
|
-
server.tool("
|
|
367
|
+
server.tool("get_outcome_positions", "Get the active account's open outcome positions (size, entry notional, mark price, unrealized PnL). Requires a configured Hyperliquid wallet or agent.", {}, async () => {
|
|
368
|
+
try {
|
|
369
|
+
const { HyperliquidAdapter } = await import("./exchanges/hyperliquid.js");
|
|
370
|
+
const { HyperliquidOutcomeAdapter } = await import("./exchanges/hyperliquid-outcome.js");
|
|
371
|
+
const adapter = await getOrCreateAdapter("hyperliquid");
|
|
372
|
+
if (!(adapter instanceof HyperliquidAdapter)) {
|
|
373
|
+
throw new PerpError("CAPABILITY_UNSUPPORTED", "Outcome positions are Hyperliquid-only", { exchange: "hyperliquid" });
|
|
374
|
+
}
|
|
375
|
+
const out = new HyperliquidOutcomeAdapter(adapter);
|
|
376
|
+
const positions = await out.getPositions();
|
|
377
|
+
return { content: [{ type: "text", text: ok(positions, { count: positions.length }) }] };
|
|
378
|
+
}
|
|
379
|
+
catch (e) {
|
|
380
|
+
return { content: [{ type: "text", text: errOf(e, { exchange: "hyperliquid" }) }], isError: true };
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
server.tool("get_balance", `Get account balance (equity, available margin, margin used, unrealized PnL) on an exchange. ${HL_SCOPE_NOTE}`, { exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`) }, async ({ exchange }) => {
|
|
273
384
|
try {
|
|
274
385
|
const adapter = await getOrCreateAdapter(exchange);
|
|
275
386
|
const balance = await adapter.getBalance();
|
|
276
387
|
return { content: [{ type: "text", text: ok(balance, { exchange }) }] };
|
|
277
388
|
}
|
|
278
389
|
catch (e) {
|
|
279
|
-
return { content: [{ type: "text", text:
|
|
390
|
+
return { content: [{ type: "text", text: errOf(e, { exchange }) }], isError: true };
|
|
280
391
|
}
|
|
281
392
|
});
|
|
282
|
-
server.tool("get_positions",
|
|
393
|
+
server.tool("get_positions", `Get all open positions on an exchange, including size, entry price, PnL, leverage. ${HL_SCOPE_NOTE}`, { exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`) }, async ({ exchange }) => {
|
|
283
394
|
try {
|
|
284
395
|
const adapter = await getOrCreateAdapter(exchange);
|
|
285
396
|
const positions = await adapter.getPositions();
|
|
286
397
|
return { content: [{ type: "text", text: ok(positions, { exchange, count: positions.length }) }] };
|
|
287
398
|
}
|
|
288
399
|
catch (e) {
|
|
289
|
-
return { content: [{ type: "text", text:
|
|
400
|
+
return { content: [{ type: "text", text: errOf(e, { exchange }) }], isError: true };
|
|
290
401
|
}
|
|
291
402
|
});
|
|
292
|
-
server.tool("get_open_orders",
|
|
403
|
+
server.tool("get_open_orders", `Get all open/pending orders on an exchange. ${HL_SCOPE_NOTE}`, { exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`) }, async ({ exchange }) => {
|
|
293
404
|
try {
|
|
294
405
|
const adapter = await getOrCreateAdapter(exchange);
|
|
295
406
|
const orders = await adapter.getOpenOrders();
|
|
296
407
|
return { content: [{ type: "text", text: ok(orders, { exchange, count: orders.length }) }] };
|
|
297
408
|
}
|
|
298
409
|
catch (e) {
|
|
299
|
-
return { content: [{ type: "text", text:
|
|
410
|
+
return { content: [{ type: "text", text: errOf(e, { exchange }) }], isError: true };
|
|
300
411
|
}
|
|
301
412
|
});
|
|
302
|
-
server.tool("portfolio", "Cross-exchange portfolio summary: balances, positions, and risk metrics across all
|
|
303
|
-
const EXCHANGES =
|
|
413
|
+
server.tool("portfolio", "Cross-exchange portfolio summary: balances, positions, and risk metrics across all exchanges with configured credentials", {}, async () => {
|
|
414
|
+
const EXCHANGES = await listConfiguredAccountVenues();
|
|
304
415
|
const snapshots = await Promise.all(EXCHANGES.map(async (name) => {
|
|
305
416
|
try {
|
|
306
417
|
const adapter = await getOrCreateAdapter(name);
|
|
@@ -375,21 +486,22 @@ server.tool("portfolio", "Cross-exchange portfolio summary: balances, positions,
|
|
|
375
486
|
// ============================================================
|
|
376
487
|
server.tool("suggest_command", "Given a natural language trading goal, suggest the exact perp CLI commands to run. Does NOT execute anything — only returns commands for the user to review and run manually", {
|
|
377
488
|
goal: z.string().describe("Natural language goal, e.g. 'buy 0.1 BTC on pacifica', 'close all positions', 'check funding arb opportunities'"),
|
|
378
|
-
exchange: z.string().optional().describe(
|
|
489
|
+
exchange: z.string().optional().describe(`Preferred exchange (default: settings.defaultExchange, else pacifica — the CLI's -e default). Options: ${listVenuesWithRole("trading").map(v => v.id).join(", ")}`),
|
|
379
490
|
}, async ({ goal, exchange }) => {
|
|
380
491
|
try {
|
|
381
|
-
|
|
492
|
+
// Same default as the CLI's -e (src/index.ts): settings.defaultExchange, else pacifica. Aliases resolve; unknown names throw.
|
|
493
|
+
const ex = getVenue(exchange ?? (loadSettings().defaultExchange || "pacifica")).id;
|
|
382
494
|
const g = goal.toLowerCase();
|
|
383
495
|
const steps = [];
|
|
384
496
|
if (g.includes("buy") || g.includes("long")) {
|
|
385
497
|
const symbol = extractSymbol(g) || "BTC";
|
|
386
498
|
const size = extractNumber(g) || "<size>";
|
|
387
|
-
steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} buy ${size}`, description: "Pre-flight validation (
|
|
499
|
+
steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} buy ${size}`, description: "Pre-flight validation (read-only, sends nothing)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} buy ${size}`, description: `Buy ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
|
|
388
500
|
}
|
|
389
501
|
else if (g.includes("sell") || g.includes("short")) {
|
|
390
502
|
const symbol = extractSymbol(g) || "BTC";
|
|
391
503
|
const size = extractNumber(g) || "<size>";
|
|
392
|
-
steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} sell ${size}`, description: "Pre-flight validation (
|
|
504
|
+
steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} sell ${size}`, description: "Pre-flight validation (read-only, sends nothing)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} sell ${size}`, description: `Sell ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
|
|
393
505
|
}
|
|
394
506
|
else if (g.includes("limit")) {
|
|
395
507
|
const symbol = extractSymbol(g) || "BTC";
|
|
@@ -421,16 +533,16 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
|
|
|
421
533
|
}
|
|
422
534
|
else if (g.includes("tpsl") || g.includes("tp/sl") || g.includes("tp sl") || (g.includes("take profit") && g.includes("stop loss"))) {
|
|
423
535
|
const symbol = extractSymbol(g) || "<symbol>";
|
|
424
|
-
const side = g.includes("sell") || g.includes("short") ? "
|
|
425
|
-
steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json trade tpsl ${symbol} ${side} --tp <price> --sl <price>`, description: "Set TP/SL
|
|
536
|
+
const side = g.includes("sell") || g.includes("short") ? "short" : "long";
|
|
537
|
+
steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position (side and size)" }, { step: 2, command: `perp -e ${ex} --json trade tpsl ${symbol} ${side} --tp <price> --sl <price>`, description: "Set reduce-only TP/SL on the position (positionSide = long|short)", dangerous: true }, { step: 3, command: `perp -e ${ex} --json account orders`, description: "Verify TP/SL orders placed" });
|
|
426
538
|
}
|
|
427
539
|
else if (g.includes("trailing") || g.includes("trail")) {
|
|
428
540
|
const symbol = extractSymbol(g) || "<symbol>";
|
|
429
|
-
steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json
|
|
541
|
+
steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json strategy trailing-stop ${symbol} --trail <pct>`, description: "Run a client-side trailing stop (closes the position when the trail % is hit)", dangerous: true });
|
|
430
542
|
}
|
|
431
543
|
else if (g.includes("stop") || g.includes("sl") || g.includes("stop loss")) {
|
|
432
544
|
const symbol = extractSymbol(g) || "<symbol>";
|
|
433
|
-
steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json trade stop ${symbol} <side> <
|
|
545
|
+
steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json trade stop ${symbol} <side> <size> <stopPrice>`, description: "Place stop order", dangerous: true });
|
|
434
546
|
}
|
|
435
547
|
else if (g.includes("split") || g.includes("depth") || (g.includes("large") && (g.includes("order") || g.includes("buy") || g.includes("sell")))) {
|
|
436
548
|
const symbol = extractSymbol(g) || "<symbol>";
|
|
@@ -450,7 +562,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
|
|
|
450
562
|
steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position size" }, { step: 2, command: `perp -e ${ex} --json trade reduce ${symbol} ${pct}`, description: `Reduce ${symbol} position by ${pct}%`, dangerous: true });
|
|
451
563
|
}
|
|
452
564
|
else if (g.includes("edit") || g.includes("modify order")) {
|
|
453
|
-
steps.push({ step: 1, command: `perp -e ${ex} --json account orders`, description: "List open orders to find order ID" }, { step: 2, command: `perp -e ${ex} --json trade edit <symbol> <orderId> <
|
|
565
|
+
steps.push({ step: 1, command: `perp -e ${ex} --json account orders`, description: "List open orders to find order ID" }, { step: 2, command: `perp -e ${ex} --json trade edit <symbol> <orderId> <price> <size>`, description: "Modify the order", dangerous: true });
|
|
454
566
|
}
|
|
455
567
|
else if (g.includes("arb") || g.includes("arbitrage")) {
|
|
456
568
|
steps.push({ step: 1, command: "perp --json arb scan --min 5", description: "Scan funding rate arbitrage opportunities" }, { step: 2, command: "perp --json arb scan --gaps", description: "Check cross-exchange price gaps" }, { step: 3, command: "perp --json arb scan --hip3", description: "HIP-3 cross-dex arb opportunities (Hyperliquid)" });
|
|
@@ -466,7 +578,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
|
|
|
466
578
|
}
|
|
467
579
|
else if (g.includes("bridge") || g.includes("transfer") || g.includes("cross-chain")) {
|
|
468
580
|
const amount = extractNumber(g) || "<amount>";
|
|
469
|
-
steps.push({ step: 1, command: "perp --json bridge chains", description: "List supported chains" }, { step: 2, command: `perp --json bridge quote --from <chain> --to <chain> --amount ${amount}`, description: "Get bridge quote with fees" }, { step: 3, command: `perp --json bridge send --from <chain> --to <chain> --amount ${amount}`, description: "Execute bridge transfer", dangerous: true }, { step: 4, command: "perp --json bridge status <orderId>", description: "Track bridge completion" });
|
|
581
|
+
steps.push({ step: 1, command: "perp --json funds bridge chains", description: "List supported chains" }, { step: 2, command: `perp --json funds bridge quote --from <chain> --to <chain> --amount ${amount}`, description: "Get bridge quote with fees" }, { step: 3, command: `perp --json funds bridge send --from <chain> --to <chain> --amount ${amount}`, description: "Execute bridge transfer", dangerous: true }, { step: 4, command: "perp --json funds bridge status <orderId>", description: "Track bridge completion" });
|
|
470
582
|
}
|
|
471
583
|
else if (g.includes("grid") || g.includes("dca") || g.includes("bot")) {
|
|
472
584
|
const symbol = extractSymbol(g) || "<symbol>";
|
|
@@ -491,7 +603,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
|
|
|
491
603
|
}
|
|
492
604
|
}
|
|
493
605
|
else if (g.includes("setup") || g.includes("init") || g.includes("configure") || g.includes("connect") || g.includes("set key") || g.includes("set wallet") || g.includes("private key")) {
|
|
494
|
-
steps.push({ step: 1, command: "perp --json wallet show", description: "Check if any wallets are already configured" }, { step: 2, command: "perp --json wallet set <exchange>
|
|
606
|
+
steps.push({ step: 1, command: "perp --json wallet show", description: "Check if any wallets are already configured" }, { step: 2, command: "printf '%s' \"$KEY\" | OWS_PASSPHRASE=<pp> perp --json wallet set <exchange>", description: "Import the master key (stdin — never argv) into the OWS vault and make it the active wallet. wallet set takes pacifica, hyperliquid, lighter, aster (aliases: hl, pac, lt, ast); Lighter RH / Ondo / Arcus register with perp wallet agent approve <lighter-rh|ondo|arcus>, QFEX with perp wallet agent import qfex" }, { step: 3, command: "printf '%s' \"$KEY\" | OWS_PASSPHRASE=<pp> perp --json wallet set <exchange> --default", description: "Import key + make it the default exchange" }, { step: 4, command: "perp --json wallet show", description: "Verify wallet is configured (shows public address)" });
|
|
495
607
|
}
|
|
496
608
|
else if (g.includes("wallet") || g.includes("balance") || g.includes("on-chain")) {
|
|
497
609
|
steps.push({ step: 1, command: "perp --json wallet show", description: "Show configured wallets with public addresses" }, { step: 2, command: "perp --json wallet balance", description: "Check on-chain balances" });
|
|
@@ -518,7 +630,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
|
|
|
518
630
|
steps.push({ step: 1, command: "perp --json arb scan --gaps", description: "Cross-exchange price gaps" }, { step: 2, command: "perp --json arb scan --gaps --live", description: "Live gap monitor" });
|
|
519
631
|
}
|
|
520
632
|
else if (g.includes("rebalance")) {
|
|
521
|
-
steps.push({ step: 1, command: "perp --json rebalance check", description: "Check balance distribution" }, { step: 2, command: "perp --json rebalance plan", description: "Generate rebalance plan" }, { step: 3, command: "perp --json rebalance execute", description: "Execute rebalance", dangerous: true });
|
|
633
|
+
steps.push({ step: 1, command: "perp --json funds rebalance check", description: "Check balance distribution" }, { step: 2, command: "perp --json funds rebalance plan", description: "Generate rebalance plan" }, { step: 3, command: "perp --json funds rebalance execute", description: "Execute rebalance", dangerous: true });
|
|
522
634
|
}
|
|
523
635
|
else if (g.includes("job") || g.includes("running") || g.includes("background")) {
|
|
524
636
|
steps.push({ step: 1, command: "perp --json background list", description: "List running background jobs" });
|
|
@@ -534,11 +646,31 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
|
|
|
534
646
|
}
|
|
535
647
|
else if (g.includes("deposit")) {
|
|
536
648
|
const amount = extractNumber(g) || "<amount>";
|
|
537
|
-
steps.push({ step: 1, command: "perp --json wallet balance", description: "Check wallet balance" }
|
|
649
|
+
steps.push({ step: 1, command: "perp --json wallet balance", description: "Check wallet balance" });
|
|
650
|
+
if (ex === "lighter") {
|
|
651
|
+
// Lighter deposits pick a route: ethereum (L1 bridge) or cctp <chain> (USDC via CCTP).
|
|
652
|
+
steps.push({ step: 2, command: `perp --json funds deposit lighter ethereum ${amount}`, description: `Deposit $${amount} to lighter from Ethereum L1 (or: perp --json funds deposit lighter cctp <chain> ${amount})`, dangerous: true }, { step: 3, command: `perp -e ${ex} --json portfolio`, description: "Verify deposit arrived" });
|
|
653
|
+
}
|
|
654
|
+
else if (ex === "qfex") {
|
|
655
|
+
// QFEX: perp-cli shows the deposit address; the user sends USDC on Arbitrum One from their own wallet.
|
|
656
|
+
steps.push({ step: 2, command: "perp --json funds deposit qfex", description: "Show the QFEX USDC (Arbitrum One) deposit address — perp-cli sends nothing; transfer from your own wallet" }, { step: 3, command: `perp -e ${ex} --json portfolio`, description: "Verify deposit arrived" });
|
|
657
|
+
}
|
|
658
|
+
else if (MCP_DEPOSIT_VENUES.includes(ex)) {
|
|
659
|
+
steps.push({ step: 2, command: `perp --json funds deposit ${ex} ${amount}`, description: `Deposit $${amount} to ${ex}`, dangerous: true }, { step: 3, command: `perp -e ${ex} --json portfolio`, description: "Verify deposit arrived" });
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
steps.push({ step: 2, command: "perp --json funds info", description: `${ex} has no CLI deposit route (deposit on the venue website); CLI deposit venues: ${MCP_DEPOSIT_VENUES.join(", ")}` });
|
|
663
|
+
}
|
|
538
664
|
}
|
|
539
665
|
else if (g.includes("withdraw")) {
|
|
540
666
|
const amount = extractNumber(g) || "<amount>";
|
|
541
|
-
steps.push({ step: 1, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" }
|
|
667
|
+
steps.push({ step: 1, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" });
|
|
668
|
+
if (MCP_WITHDRAW_VENUES.includes(ex)) {
|
|
669
|
+
steps.push({ step: 2, command: `perp --json funds withdraw ${ex} ${amount}`, description: `Withdraw $${amount} from ${ex}`, dangerous: true }, { step: 3, command: "perp --json wallet balance", description: "Verify withdrawal received" });
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
steps.push({ step: 2, command: "perp --json funds info", description: `${ex} has no CLI withdraw route (withdraw on the venue website); CLI withdraw venues: ${MCP_WITHDRAW_VENUES.join(", ")}` });
|
|
673
|
+
}
|
|
542
674
|
}
|
|
543
675
|
else if (g.includes("leverage")) {
|
|
544
676
|
const symbol = extractSymbol(g) || "<symbol>";
|
|
@@ -564,7 +696,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
|
|
|
564
696
|
}
|
|
565
697
|
}
|
|
566
698
|
else if (g.includes("setting") || g.includes("config") || g.includes("referral")) {
|
|
567
|
-
steps.push({ step: 1, command: "perp settings show", description: "Show current settings" }, { step: 2, command: "perp
|
|
699
|
+
steps.push({ step: 1, command: "perp settings show", description: "Show current settings" }, { step: 2, command: "perp -e hyperliquid wallet manage referral set", description: "Explicitly apply the perp-cli referral code (signs + sends one referral action; no extra fees). Also -e lighter. Supports --dry-run", dangerous: true });
|
|
568
700
|
}
|
|
569
701
|
else {
|
|
570
702
|
steps.push({ step: 1, command: `perp -e ${ex} --json portfolio`, description: "Check account overview" }, { step: 2, command: `perp --json portfolio --health --exchanges ${ex}`, description: "Check exchange connectivity" });
|
|
@@ -582,14 +714,14 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
|
|
|
582
714
|
"Adjust exchange with -e <exchange> flag",
|
|
583
715
|
"Use 'perp trade check' for pre-flight validation before executing trades",
|
|
584
716
|
"Run commands in your terminal — this MCP server does NOT execute them",
|
|
585
|
-
"If perp-cli is useful, run 'perp
|
|
717
|
+
"If perp-cli is useful, run 'perp -e hyperliquid wallet manage referral set' (or -e lighter) to support development. It signs one referral action; no extra fees.",
|
|
586
718
|
],
|
|
587
719
|
}),
|
|
588
720
|
}],
|
|
589
721
|
};
|
|
590
722
|
}
|
|
591
723
|
catch (e) {
|
|
592
|
-
return { content: [{ type: "text", text:
|
|
724
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
593
725
|
}
|
|
594
726
|
});
|
|
595
727
|
server.tool("explain_command", "Explain what a perp CLI command does, its parameters, and any risks. Helps the user understand a command before running it", {
|
|
@@ -671,10 +803,10 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
671
803
|
parameters: [
|
|
672
804
|
{ name: "symbol", value: args[2], description: "Trading pair" },
|
|
673
805
|
{ name: "side", value: args[3], description: "buy or sell" },
|
|
674
|
-
{ name: "
|
|
675
|
-
{ name: "
|
|
806
|
+
{ name: "size", value: args[4], description: "Order size" },
|
|
807
|
+
{ name: "stopPrice", value: args[5], description: "Trigger price" },
|
|
676
808
|
],
|
|
677
|
-
risks: ["Stop orders become market orders when triggered — slippage possible", "Stop may not fill in fast markets"],
|
|
809
|
+
risks: ["Stop orders become market orders when triggered — slippage possible", "Stop may not fill in fast markets", "Reduce-only by default (--no-reduce-only to open a position)"],
|
|
678
810
|
category: "write",
|
|
679
811
|
relatedCommands: ["perp account positions", "perp trade tpsl"],
|
|
680
812
|
};
|
|
@@ -682,10 +814,10 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
682
814
|
else if (category === "trade" && sub === "tpsl") {
|
|
683
815
|
explanation = {
|
|
684
816
|
command,
|
|
685
|
-
description: "Sets take-profit and stop-loss
|
|
817
|
+
description: "Sets take-profit and stop-loss on an open position (checked against the live position). Legs are reduce-only, sized to the live position.",
|
|
686
818
|
parameters: [
|
|
687
819
|
{ name: "symbol", value: args[2], description: "Trading pair" },
|
|
688
|
-
{ name: "
|
|
820
|
+
{ name: "positionSide", value: args[3], description: "The position's side: long | short (buy/sell not accepted)" },
|
|
689
821
|
{ name: "--tp", description: "Take-profit price" },
|
|
690
822
|
{ name: "--sl", description: "Stop-loss price" },
|
|
691
823
|
],
|
|
@@ -728,16 +860,14 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
728
860
|
relatedCommands: ["perp account balance", "perp trade cancel-twap"],
|
|
729
861
|
};
|
|
730
862
|
}
|
|
731
|
-
else if (category === "trade" &&
|
|
863
|
+
else if (category === "trade" && sub === "pnl-track") {
|
|
732
864
|
explanation = {
|
|
733
865
|
command,
|
|
734
|
-
description:
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
category: sub === "trailing-stop" ? "write" : "read",
|
|
740
|
-
relatedCommands: ["perp account positions", "perp trade stop"],
|
|
866
|
+
description: "Starts real-time PnL tracking for open positions with visual updates",
|
|
867
|
+
parameters: [],
|
|
868
|
+
risks: [],
|
|
869
|
+
category: "read",
|
|
870
|
+
relatedCommands: ["perp account positions", "perp strategy trailing-stop <symbol> --trail <pct>"],
|
|
741
871
|
};
|
|
742
872
|
}
|
|
743
873
|
else if (category === "trade" && (sub === "reduce" || sub === "flatten" || sub === "close-all")) {
|
|
@@ -853,7 +983,8 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
853
983
|
relatedCommands: ["perp arb scan", "perp arb status", "perp arb scan --gaps"],
|
|
854
984
|
};
|
|
855
985
|
}
|
|
856
|
-
else if (category === "bridge") {
|
|
986
|
+
else if (category === "funds" && sub === "bridge") {
|
|
987
|
+
const bsub = args[2] ?? "";
|
|
857
988
|
explanation = {
|
|
858
989
|
command,
|
|
859
990
|
description: {
|
|
@@ -862,10 +993,10 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
862
993
|
send: "Executes a cross-chain USDC bridge transfer",
|
|
863
994
|
exchange: "Bridges USDC between exchange accounts",
|
|
864
995
|
status: "Checks the status of a bridge transfer",
|
|
865
|
-
}[
|
|
996
|
+
}[bsub] || `Bridge command: ${bsub}`,
|
|
866
997
|
parameters: [],
|
|
867
|
-
risks:
|
|
868
|
-
category:
|
|
998
|
+
risks: bsub === "send" || bsub === "exchange" ? ["Transfers real funds cross-chain — verify addresses carefully"] : [],
|
|
999
|
+
category: bsub === "send" || bsub === "exchange" ? "write" : "read",
|
|
869
1000
|
relatedCommands: ["perp funds bridge chains", "perp funds bridge quote"],
|
|
870
1001
|
};
|
|
871
1002
|
}
|
|
@@ -895,7 +1026,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
895
1026
|
command,
|
|
896
1027
|
description: `Automated strategy: ${sub}. Runs as a background job.`,
|
|
897
1028
|
parameters: args.slice(2).map((a, i) => ({ name: `arg${i}`, value: a, description: "See --help" })),
|
|
898
|
-
risks: ["Runs automated trades — monitor with 'perp background list'", "
|
|
1029
|
+
risks: ["Runs automated trades — monitor with 'perp background list'", "--dry-run runs it against a read-only adapter (no orders sent); strategies that cannot be simulated reject --dry-run"],
|
|
899
1030
|
category: "write",
|
|
900
1031
|
relatedCommands: ["perp background list", "perp background stop"],
|
|
901
1032
|
};
|
|
@@ -933,7 +1064,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
933
1064
|
else if (category === "settings") {
|
|
934
1065
|
explanation = {
|
|
935
1066
|
command,
|
|
936
|
-
description: { show: "Display current CLI settings",
|
|
1067
|
+
description: { show: "Display current CLI settings", set: "Set a specific setting value" }[sub] || `Settings: ${sub}`,
|
|
937
1068
|
parameters: [],
|
|
938
1069
|
risks: [],
|
|
939
1070
|
category: "read",
|
|
@@ -954,7 +1085,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
954
1085
|
return { content: [{ type: "text", text: ok(explanation) }] };
|
|
955
1086
|
}
|
|
956
1087
|
catch (e) {
|
|
957
|
-
return { content: [{ type: "text", text:
|
|
1088
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
958
1089
|
}
|
|
959
1090
|
});
|
|
960
1091
|
// ── Helper functions for suggest_command ──
|
|
@@ -1013,39 +1144,53 @@ server.tool("arb_scan", "Scan for funding rate arbitrage opportunities across ex
|
|
|
1013
1144
|
};
|
|
1014
1145
|
}
|
|
1015
1146
|
catch (e) {
|
|
1016
|
-
return { content: [{ type: "text", text:
|
|
1147
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
1017
1148
|
}
|
|
1018
1149
|
});
|
|
1019
|
-
server.tool("health_check", "Ping
|
|
1150
|
+
server.tool("health_check", "Ping every registered venue on the server's network and return connectivity status and latency. Pacifica / Hyperliquid / Lighter / Aster / QFEX: REST ping (status = HTTP status, 0 = no response); every other venue: its public market-data read (status null). A failure carries its error message.", {}, async () => {
|
|
1020
1151
|
try {
|
|
1021
|
-
|
|
1022
|
-
const pingAster = async () => {
|
|
1152
|
+
const timed = async (probe, fn) => {
|
|
1023
1153
|
const start = Date.now();
|
|
1024
1154
|
try {
|
|
1025
|
-
const
|
|
1026
|
-
return { ok:
|
|
1155
|
+
const r = await fn();
|
|
1156
|
+
return { ok: r.ok, latencyMs: Date.now() - start, status: r.status, error: r.ok ? null : `HTTP ${r.status}`, probe };
|
|
1027
1157
|
}
|
|
1028
|
-
catch {
|
|
1029
|
-
return { ok: false, latencyMs: Date.now() - start, status: 0 };
|
|
1158
|
+
catch (e) {
|
|
1159
|
+
return { ok: false, latencyMs: Date.now() - start, status: probe === "ping" ? 0 : null, error: extractErrorMessage(e), probe };
|
|
1030
1160
|
}
|
|
1031
1161
|
};
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1162
|
+
const restPing = (url) => timed("ping", async () => {
|
|
1163
|
+
const res = await fetch(url);
|
|
1164
|
+
return { ok: res.ok, status: res.status };
|
|
1165
|
+
});
|
|
1166
|
+
// Shared-api helpers report a transport failure as status 0 (no message).
|
|
1167
|
+
const helperPing = (fn) => async () => {
|
|
1168
|
+
const r = await fn();
|
|
1169
|
+
return { ...r, error: r.ok ? null : r.status === 0 ? "no response" : `HTTP ${r.status}`, probe: "ping" };
|
|
1170
|
+
};
|
|
1171
|
+
const pings = {
|
|
1172
|
+
pacifica: helperPing(pingPacifica),
|
|
1173
|
+
hyperliquid: helperPing(pingHyperliquid),
|
|
1174
|
+
lighter: helperPing(pingLighter),
|
|
1175
|
+
aster: () => restPing(`${networkConfig(getVenue("aster"), MCP_NETWORK).restUrl}/fapi/v1/time`),
|
|
1176
|
+
// QFEX: public GET /refdata for one instrument on the server's network.
|
|
1177
|
+
qfex: () => restPing(`${networkConfig(getVenue("qfex"), MCP_NETWORK).restUrl}/refdata?ticker=AAPL-USD`),
|
|
1044
1178
|
};
|
|
1179
|
+
const venues = listVenues();
|
|
1180
|
+
const probes = await Promise.all(venues.map(v => {
|
|
1181
|
+
const ping = pings[v.id];
|
|
1182
|
+
return ping !== undefined
|
|
1183
|
+
? ping()
|
|
1184
|
+
: timed("markets", async () => { await v.publicData.fetchMarkets(MCP_NETWORK); return { ok: true, status: null }; });
|
|
1185
|
+
}));
|
|
1186
|
+
const result = {};
|
|
1187
|
+
venues.forEach((v, i) => { result[v.id] = { ...probes[i], statusText: probes[i].ok ? "healthy" : "unreachable" }; });
|
|
1188
|
+
result.covered = venues.map(v => v.id);
|
|
1189
|
+
result.allHealthy = probes.every(p => p.ok);
|
|
1045
1190
|
return { content: [{ type: "text", text: ok(result) }] };
|
|
1046
1191
|
}
|
|
1047
1192
|
catch (e) {
|
|
1048
|
-
return { content: [{ type: "text", text:
|
|
1193
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
1049
1194
|
}
|
|
1050
1195
|
});
|
|
1051
1196
|
// ============================================================
|
|
@@ -1055,13 +1200,14 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1055
1200
|
const schema = {
|
|
1056
1201
|
schemaVersion: "2.0",
|
|
1057
1202
|
name: "perp",
|
|
1058
|
-
description:
|
|
1059
|
-
exchanges:
|
|
1203
|
+
description: `Multi-DEX Perpetual Futures CLI (${listVenues().map(v => v.displayName).join(", ")})`,
|
|
1204
|
+
exchanges: listVenues().map(v => ({ id: v.id, displayName: v.displayName, roles: v.roles })),
|
|
1060
1205
|
globalFlags: [
|
|
1061
|
-
{ flag: "-e, --exchange <name>", description:
|
|
1206
|
+
{ flag: "-e, --exchange <name>", description: `Exchange to use (${listVenues().map(v => v.id).join(", ")})`, default: "settings.defaultExchange, else pacifica" },
|
|
1062
1207
|
{ flag: "--json", description: "Output as JSON for structured parsing" },
|
|
1063
|
-
{ flag: "-n, --network <net>", description: "Network: mainnet or testnet", default: "mainnet" },
|
|
1064
|
-
{ flag: "--dry-run", description: "
|
|
1208
|
+
{ flag: "-n, --network <net>", description: "Network: mainnet or testnet (validated; a typo is a usage error)", default: "mainnet" },
|
|
1209
|
+
{ flag: "--dry-run", description: "Print the order/plan without sending anything (result has sent:false). Default-deny: commands that cannot honor it reject --dry-run instead of running live" },
|
|
1210
|
+
{ flag: "--dex <name>", description: "HIP-3 deployed dex name, Hyperliquid only (the MCP tools read/trade native Hyperliquid perps only). Example: perp -e hyperliquid --dex <name> market list" },
|
|
1065
1211
|
],
|
|
1066
1212
|
commands: {
|
|
1067
1213
|
market: {
|
|
@@ -1093,17 +1239,16 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1093
1239
|
},
|
|
1094
1240
|
},
|
|
1095
1241
|
trade: {
|
|
1096
|
-
description: "Trading commands (execute in terminal
|
|
1242
|
+
description: "Trading commands (execute in terminal; --dry-run prints the order without sending it)",
|
|
1097
1243
|
subcommands: {
|
|
1098
1244
|
market: { usage: "perp trade market <symbol> <buy|sell> <size>", description: "Market order" },
|
|
1099
1245
|
buy: { usage: "perp trade buy <symbol> <size>", description: "Shorthand market buy" },
|
|
1100
1246
|
sell: { usage: "perp trade sell <symbol> <size>", description: "Shorthand market sell" },
|
|
1101
1247
|
limit: { usage: "perp trade limit <symbol> <buy|sell> <price> <size>", description: "Limit order" },
|
|
1102
|
-
stop: { usage: "perp trade stop <symbol> <side> <
|
|
1103
|
-
tpsl: { usage: "perp trade tpsl <symbol> <
|
|
1248
|
+
stop: { usage: "perp trade stop <symbol> <side> <size> <stopPrice>", description: "Stop order (reduce-only by default; --no-reduce-only to open)" },
|
|
1249
|
+
tpsl: { usage: "perp trade tpsl <symbol> <long|short> --tp <p> --sl <p>", description: "TP/SL on an open position (reduce-only)" },
|
|
1104
1250
|
"scale-tp": { usage: "perp trade scale-tp <symbol> --levels '<p>:<pct>,...'", description: "Scaled take-profit" },
|
|
1105
1251
|
"scale-in": { usage: "perp trade scale-in <symbol> <side> --levels '<p>:<size>,...'", description: "Scaled entry" },
|
|
1106
|
-
"trailing-stop": { usage: "perp trade trailing-stop <symbol>", description: "Trailing stop" },
|
|
1107
1252
|
twap: { usage: "perp trade twap <symbol> <side> <size> <duration>", description: "TWAP execution" },
|
|
1108
1253
|
edit: { usage: "perp trade edit <symbol> <orderId> <price> <size>", description: "Modify existing order" },
|
|
1109
1254
|
cancel: { usage: "perp trade cancel <symbol> <orderId>", description: "Cancel order" },
|
|
@@ -1128,7 +1273,7 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1128
1273
|
subcommands: {
|
|
1129
1274
|
scan: { usage: "perp arb scan --min <pct>", description: "Find arb opportunities (use --rates, --gaps, --hip3, --positions, --basis, --compare, --history, --live)" },
|
|
1130
1275
|
auto: { usage: "perp arb auto --min-spread <pct>", description: "Auto-execute funding arb" },
|
|
1131
|
-
exec: { usage: "perp arb exec <symbol> <
|
|
1276
|
+
exec: { usage: "perp arb exec <symbol> <longExch> <shortExch> <sizeUsd>", description: "Execute arb trade on two exchanges (sizeUsd = USD notional per leg)" },
|
|
1132
1277
|
status: { usage: "perp arb status", description: "Current arb positions" },
|
|
1133
1278
|
close: { usage: "perp arb close <symbol>", description: "Close arb position" },
|
|
1134
1279
|
"history|log": { usage: "perp arb history", description: "Arb execution history" },
|
|
@@ -1149,10 +1294,10 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1149
1294
|
subcommands: {
|
|
1150
1295
|
start: { usage: "perp strategy start <config>", description: "Start bot from config file" },
|
|
1151
1296
|
twap: { usage: "perp strategy twap <symbol> <side> <size> <duration>", description: "TWAP execution" },
|
|
1152
|
-
grid: { usage: "perp strategy grid <symbol> --
|
|
1297
|
+
grid: { usage: "perp strategy grid <symbol> --upper <price> --lower <price> --grids <n> --size <base>", description: "Grid trading bot" },
|
|
1153
1298
|
dca: { usage: "perp strategy dca <symbol> <side> <amount> <interval>", description: "DCA bot" },
|
|
1154
1299
|
"funding-arb": { usage: "perp strategy funding-arb", description: "Funding arb bot" },
|
|
1155
|
-
"trailing-stop": { usage: "perp strategy trailing-stop <symbol>", description: "
|
|
1300
|
+
"trailing-stop": { usage: "perp strategy trailing-stop <symbol> --trail <pct>", description: "Client-side trailing stop (closes the position when the trail % is hit)" },
|
|
1156
1301
|
"quick-grid": { usage: "perp strategy quick-grid <symbol>", description: "Quick grid bot" },
|
|
1157
1302
|
"quick-dca": { usage: "perp strategy quick-dca <symbol> <side> <amount> <interval>", description: "Quick DCA bot" },
|
|
1158
1303
|
"quick-arb": { usage: "perp strategy quick-arb", description: "Quick arb bot" },
|
|
@@ -1169,17 +1314,18 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1169
1314
|
subcommands: {
|
|
1170
1315
|
show: { usage: "perp wallet show", description: "Show configured wallets with public addresses" },
|
|
1171
1316
|
list: { usage: "perp wallet list", description: "List all wallets (OWS vault + legacy)" },
|
|
1172
|
-
balance: { usage: "perp wallet balance [name]", description: "On-chain
|
|
1317
|
+
balance: { usage: "perp wallet balance [name]", description: "On-chain gas token + USDC/USDT on Arbitrum, Ethereum, Base, BNB Chain, Robinhood Chain (USDG) and Solana" },
|
|
1173
1318
|
generate: { usage: "perp wallet generate [name]", description: "Generate new OWS wallet (encrypted, multi-chain)" },
|
|
1174
|
-
import: { usage: "perp wallet import <
|
|
1319
|
+
import: { usage: "printf '%s' \"$KEY\" | OWS_PASSPHRASE=<pp> perp wallet import --name <name> [--chain evm|solana|dual]", description: "Import key into OWS vault (stdin / hidden prompt — never argv)" },
|
|
1175
1320
|
migrate: { usage: "perp wallet migrate", description: "Migrate legacy wallets to OWS vault" },
|
|
1176
|
-
set: { usage: "perp wallet set <exchange> <
|
|
1321
|
+
set: { usage: "perp wallet set <exchange> [--name <n>]", description: "Import an exchange key (hidden prompt / stdin; argv key rejected) into the OWS vault → active wallet" },
|
|
1177
1322
|
use: { usage: "perp wallet use <name>", description: "Set active wallet (OWS or legacy)" },
|
|
1178
1323
|
remove: { usage: "perp wallet remove <name>", description: "Remove a wallet" },
|
|
1179
1324
|
rename: { usage: "perp wallet rename <oldName> <newName>", description: "Rename a wallet" },
|
|
1180
1325
|
policy: { usage: "perp wallet policy <create|list|show|delete>", description: "OWS signing policies (guardrails for agent signing)" },
|
|
1181
1326
|
key: { usage: "perp wallet key <create|list|revoke>", description: "OWS API keys (scoped agent access tokens)" },
|
|
1182
1327
|
agent: { usage: "perp wallet agent <approve|list|revoke|rotate|verify>", description: "DEX-side agent wallet delegation (per-exchange)" },
|
|
1328
|
+
builder: { usage: "perp wallet builder <status|approve|revoke>", description: "Builder fees (off by default): status / master-signed approve / revoke of the builder set in settings builder.<venue> (approve/revoke: --dry-run, --yes)" },
|
|
1183
1329
|
deposit: { usage: "perp wallet deposit <walletName>", description: "MoonPay multi-chain deposit (auto-converts to USDC)" },
|
|
1184
1330
|
setup: { usage: "perp wallet setup", description: "One-click: wallet + guardrail policy + agent API key" },
|
|
1185
1331
|
backup: { usage: "perp wallet backup", description: "Backup encrypted OWS vault" },
|
|
@@ -1224,8 +1370,10 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1224
1370
|
usage: "perp funds <deposit|withdraw|transfer|bridge|rebalance|info> ...",
|
|
1225
1371
|
description: "Fund movement — deposit, withdraw, transfer, cross-chain bridge, inter-exchange rebalance",
|
|
1226
1372
|
subcommands: {
|
|
1227
|
-
|
|
1228
|
-
"
|
|
1373
|
+
[`deposit <${MCP_DEPOSIT_VENUES.filter(v => v !== "lighter" && v !== "qfex").join("|")}> <amount>`]: "Deposit USDC to an exchange account",
|
|
1374
|
+
"deposit qfex": "Show the QFEX USDC (Arbitrum One) deposit address — perp-cli sends nothing",
|
|
1375
|
+
"deposit lighter <ethereum|cctp> ...": "Deposit to Lighter: perp funds deposit lighter ethereum <amount> (L1 bridge) | perp funds deposit lighter cctp <chain> <amount> (CCTP)",
|
|
1376
|
+
[`withdraw <${MCP_WITHDRAW_VENUES.join("|")}> <amount>`]: "Withdraw USDC from an exchange account",
|
|
1229
1377
|
"transfer <amount> <address>": "HL internal transfer (instant)",
|
|
1230
1378
|
"bridge chains": "List supported chains and USDC addresses",
|
|
1231
1379
|
"bridge quote --from <chain> --to <chain> --amount <n>": "Get bridge quotes from all providers",
|
|
@@ -1238,6 +1386,19 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1238
1386
|
info: "Show all deposit/withdraw/bridge/rebalance routes & limits",
|
|
1239
1387
|
},
|
|
1240
1388
|
},
|
|
1389
|
+
outcome: {
|
|
1390
|
+
description: "Hyperliquid Outcome markets (HIP-4) — binary/range contracts",
|
|
1391
|
+
subcommands: {
|
|
1392
|
+
list: { usage: "perp outcome list", description: "Active outcome markets" },
|
|
1393
|
+
book: { usage: "perp outcome book <outcome> <side>", description: "Orderbook for one outcome side" },
|
|
1394
|
+
view: { usage: "perp outcome view <outcome>", description: "Both sides' books + underlying mark gap + expiry" },
|
|
1395
|
+
positions: { usage: "perp outcome positions", description: "Open outcome positions" },
|
|
1396
|
+
orders: { usage: "perp outcome orders", description: "Open outcome orders" },
|
|
1397
|
+
buy: { usage: "perp outcome buy <outcome> <side> [usd]", description: "Buy a side (USD notional, or --size <shares>; --limit <px>; supports --dry-run)" },
|
|
1398
|
+
sell: { usage: "perp outcome sell <outcome> <side> [usd]", description: "Sell a side (USD notional, --size <shares> or --all; supports --dry-run)" },
|
|
1399
|
+
cancel: { usage: "perp outcome cancel <outcome> <side> <oid>", description: "Cancel an open outcome order" },
|
|
1400
|
+
},
|
|
1401
|
+
},
|
|
1241
1402
|
portfolio: { usage: "perp portfolio", description: "Cross-exchange portfolio summary" },
|
|
1242
1403
|
status: { usage: "perp portfolio", description: "Full account overview" },
|
|
1243
1404
|
health: { usage: "perp portfolio --health", description: "Exchange connectivity check" },
|
|
@@ -1245,7 +1406,6 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1245
1406
|
description: "CLI settings",
|
|
1246
1407
|
subcommands: {
|
|
1247
1408
|
show: { usage: "perp settings show", description: "Current settings" },
|
|
1248
|
-
referrals: { usage: "perp settings referrals on|off", description: "Toggle referral codes (no extra fees)" },
|
|
1249
1409
|
},
|
|
1250
1410
|
},
|
|
1251
1411
|
// `agent` is now `wallet agent ...` — see `wallet.subcommands.agent` above.
|
|
@@ -1253,9 +1413,9 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1253
1413
|
tips: [
|
|
1254
1414
|
"Always use --json for structured output when automating",
|
|
1255
1415
|
"Use 'perp trade check' before executing trades for validation",
|
|
1256
|
-
"Use --dry-run to
|
|
1416
|
+
"Use --dry-run to print a trade's order without sending it (sent:false); commands that cannot honor it reject the flag",
|
|
1257
1417
|
"Numbers are strings to avoid float precision loss",
|
|
1258
|
-
"If perp-cli is useful, run 'perp
|
|
1418
|
+
"If perp-cli is useful, run 'perp -e hyperliquid wallet manage referral set' (or -e lighter) to support development. It signs one referral action; no extra fees.",
|
|
1259
1419
|
],
|
|
1260
1420
|
};
|
|
1261
1421
|
return { contents: [{ uri: "perp://schema", text: JSON.stringify(schema, null, 2), mimeType: "application/json" }] };
|
|
@@ -1263,67 +1423,132 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1263
1423
|
// ============================================================
|
|
1264
1424
|
// Trade execution tools (dry-run + confirm safety model)
|
|
1265
1425
|
// ============================================================
|
|
1266
|
-
|
|
1267
|
-
|
|
1426
|
+
/**
|
|
1427
|
+
* The OrderRequest trade_execute sends (and trade_preview checks): limit orders need a price and
|
|
1428
|
+
* a TIF the venue accepts; a market order carries the resolved slippage bound. Throws
|
|
1429
|
+
* INVALID_PARAMS / CAPABILITY_UNSUPPORTED before anything is read or sent.
|
|
1430
|
+
*/
|
|
1431
|
+
function mcpOrderRequest(adapter, p) {
|
|
1432
|
+
const def = getVenue(adapter.name);
|
|
1433
|
+
const caps = requireCapabilities(def);
|
|
1434
|
+
const isLimit = p.orderType === "limit";
|
|
1435
|
+
if (isLimit && !p.price)
|
|
1436
|
+
throw new PerpError("INVALID_PARAMS", "Price is required for limit orders");
|
|
1437
|
+
return {
|
|
1438
|
+
symbol: p.symbol, side: p.side, type: p.orderType, size: p.size,
|
|
1439
|
+
price: isLimit ? p.price : null,
|
|
1440
|
+
triggerPrice: null, triggerSource: null,
|
|
1441
|
+
tif: isLimit ? validateLegacyTif(def, caps, p.tif ?? "GTC") : null,
|
|
1442
|
+
reduceOnly: p.reduceOnly,
|
|
1443
|
+
clientOrderId: caps.clientOrderId && caps.orderAck === "async" ? newVenueClientOrderId(def) : null,
|
|
1444
|
+
slippage: isLimit ? null : resolveAdapterSlippage(adapter, p.slippageBps ?? null),
|
|
1445
|
+
tpsl: null,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
server.tool("trade_preview", `Preview a trade WITHOUT executing. Runs trade_execute's pre-send checks read-only (size parse, order validation incl. TIF, quarantine, local risk limits — never forced) and reports any refusal as valid:false with its code + remediation; also returns the estimated fill price from the book, notional, margin impact, the builder fee the order would carry, and the existing position. Venue trading fees are NOT estimated. ALWAYS call this before trade_execute and show the result to the user for confirmation. ${HL_SCOPE_NOTE}`, {
|
|
1449
|
+
exchange: z.string().describe(`Exchange: ${listVenues().filter(v => v.create !== null).map(v => v.id).join(", ")} (aliases accepted)`),
|
|
1268
1450
|
symbol: z.string().describe("Trading symbol (e.g., BTC, ETH, SOL)"),
|
|
1269
1451
|
side: z.enum(["buy", "sell"]).describe("Order side"),
|
|
1270
1452
|
size: z.string().describe("Order size (base currency units, e.g., '0.1' for 0.1 BTC)"),
|
|
1271
1453
|
orderType: z.enum(["market", "limit"]).default("market").describe("Order type"),
|
|
1272
1454
|
price: z.string().optional().describe("Limit price (required for limit orders)"),
|
|
1273
|
-
|
|
1455
|
+
tif: z.string().optional().describe("Limit orders: GTC (default) | IOC | FOK | ALO (post-only); venue aliases GTT / GTX / TOB accepted — checked like trade_execute"),
|
|
1456
|
+
reduceOnly: z.boolean().default(false).describe("Reduce-only order (close position only) — checked like trade_execute (quarantine applies only to non-reduce-only orders)"),
|
|
1457
|
+
}, async ({ exchange, symbol, side, size, orderType, price, tif, reduceOnly }) => {
|
|
1274
1458
|
try {
|
|
1275
1459
|
const adapter = await getOrCreateAdapter(exchange);
|
|
1460
|
+
const sym = symbol.toUpperCase();
|
|
1461
|
+
const isLimit = orderType === "limit";
|
|
1462
|
+
// trade_execute's pre-send checks, read-only. Each refusal keeps its code + remediation.
|
|
1463
|
+
const refusals = [];
|
|
1464
|
+
const refuse = (check, e) => {
|
|
1465
|
+
const s = classifyError(e, adapter.name);
|
|
1466
|
+
refusals.push({ check, code: s.code, message: s.message, remediation: s.remediation ?? null });
|
|
1467
|
+
};
|
|
1468
|
+
let parsedSize = null;
|
|
1469
|
+
try {
|
|
1470
|
+
parsedSize = parsePositiveDecimal(size, "size");
|
|
1471
|
+
}
|
|
1472
|
+
catch (e) {
|
|
1473
|
+
refuse("size", e);
|
|
1474
|
+
}
|
|
1475
|
+
if (parsedSize !== null) {
|
|
1476
|
+
try {
|
|
1477
|
+
const req = mcpOrderRequest(adapter, { symbol: sym, side, size: parsedSize, orderType, price, tif, reduceOnly, slippageBps: undefined });
|
|
1478
|
+
const def = getVenue(adapter.name);
|
|
1479
|
+
validateOrderRequest(def, requireCapabilities(def), req);
|
|
1480
|
+
}
|
|
1481
|
+
catch (e) {
|
|
1482
|
+
refuse("order", e);
|
|
1483
|
+
}
|
|
1484
|
+
try {
|
|
1485
|
+
await mcpPreTradeGate(adapter, { symbol: sym, size: Number(parsedSize), price: isLimit && price ? Number(price) : null, reduceOnly }, (m) => process.stderr.write(`${m}\n`));
|
|
1486
|
+
}
|
|
1487
|
+
catch (e) {
|
|
1488
|
+
refuse("gate", e);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1276
1491
|
const [balance, positions, orderbook] = await Promise.all([
|
|
1277
1492
|
adapter.getBalance(),
|
|
1278
1493
|
adapter.getPositions(),
|
|
1279
|
-
adapter.getOrderbook(
|
|
1494
|
+
adapter.getOrderbook(sym),
|
|
1280
1495
|
]);
|
|
1281
1496
|
// Estimate fill price from orderbook
|
|
1282
1497
|
const bestBid = orderbook.bids[0]?.[0];
|
|
1283
1498
|
const bestAsk = orderbook.asks[0]?.[0];
|
|
1284
|
-
const estPrice =
|
|
1499
|
+
const estPrice = isLimit && price
|
|
1285
1500
|
? price
|
|
1286
1501
|
: side === "buy" ? bestAsk : bestBid;
|
|
1287
|
-
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1502
|
+
// Unknown (unparseable size / empty book side) = null, never 0.
|
|
1503
|
+
const notional = estPrice && parsedSize !== null ? Number(estPrice) * Number(parsedSize) : null;
|
|
1504
|
+
const existingPos = positions.find(p => symbolMatch(p.symbol, symbol));
|
|
1290
1505
|
// SSOT rule #2: validation failure must surface as a structured invalid
|
|
1291
|
-
// result, NOT be swallowed into a fake `{ valid: true }`.
|
|
1292
|
-
// shape silently green-lit trades when the underlying balance/orderbook
|
|
1293
|
-
// fetches errored out.
|
|
1506
|
+
// result, NOT be swallowed into a fake `{ valid: true }`.
|
|
1294
1507
|
let validation;
|
|
1295
1508
|
// For a preview the user has not yet committed leverage, so 1x is the
|
|
1296
1509
|
// documented default exposed in the preview shape (no silent fallback —
|
|
1297
1510
|
// the caller can override via existingPos.leverage when re-entering an
|
|
1298
1511
|
// open position).
|
|
1299
1512
|
const previewLeverage = existingPos?.leverage ?? 1;
|
|
1300
|
-
|
|
1301
|
-
validation =
|
|
1302
|
-
}
|
|
1303
|
-
catch (err) {
|
|
1304
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1305
|
-
validation = {
|
|
1306
|
-
valid: false,
|
|
1307
|
-
checks: [{
|
|
1308
|
-
check: "symbol_valid",
|
|
1309
|
-
passed: false,
|
|
1310
|
-
message: `Validation could not run: ${msg}`,
|
|
1311
|
-
}],
|
|
1312
|
-
warnings: [],
|
|
1313
|
-
};
|
|
1513
|
+
if (parsedSize === null) {
|
|
1514
|
+
validation = { valid: false, checks: [{ check: "size", passed: false, message: refusals[0].message }], warnings: [] };
|
|
1314
1515
|
}
|
|
1516
|
+
else {
|
|
1517
|
+
try {
|
|
1518
|
+
validation = await validateTrade(adapter, {
|
|
1519
|
+
symbol: sym, side, size: Number(parsedSize), leverage: previewLeverage, type: orderType, reduceOnly,
|
|
1520
|
+
...(isLimit && price ? { price: Number(price) } : {}),
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
catch (err) {
|
|
1524
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1525
|
+
validation = {
|
|
1526
|
+
valid: false,
|
|
1527
|
+
checks: [{
|
|
1528
|
+
check: "symbol_valid",
|
|
1529
|
+
passed: false,
|
|
1530
|
+
message: `Validation could not run: ${msg}`,
|
|
1531
|
+
}],
|
|
1532
|
+
warnings: [],
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const builderPlan = await builderPlanFor(adapter, sym);
|
|
1315
1537
|
const preview = {
|
|
1316
|
-
|
|
1538
|
+
// false when trade_execute would refuse this order (see refusals) or the trade checks fail.
|
|
1539
|
+
valid: refusals.length === 0 && validation.valid,
|
|
1540
|
+
refusals,
|
|
1541
|
+
order: { exchange, symbol, side, size, orderType, price: price ?? estPrice ?? null, tif: tif ?? null, reduceOnly },
|
|
1317
1542
|
estimate: {
|
|
1318
|
-
fillPrice: estPrice ??
|
|
1319
|
-
notionalUsd: notional.toFixed(2),
|
|
1320
|
-
spread: bestBid && bestAsk ? ((Number(bestAsk) - Number(bestBid)) / Number(bestBid) * 100).toFixed(4) + "%" :
|
|
1543
|
+
fillPrice: estPrice ?? null,
|
|
1544
|
+
notionalUsd: notional !== null ? notional.toFixed(2) : null,
|
|
1545
|
+
spread: bestBid && bestAsk ? ((Number(bestAsk) - Number(bestBid)) / Number(bestBid) * 100).toFixed(4) + "%" : null,
|
|
1321
1546
|
},
|
|
1322
1547
|
account: {
|
|
1323
1548
|
equity: balance.equity,
|
|
1324
1549
|
available: balance.available,
|
|
1325
1550
|
marginUsed: balance.marginUsed,
|
|
1326
|
-
marginAfter: (Number(balance.marginUsed) + notional /
|
|
1551
|
+
marginAfter: notional !== null ? (Number(balance.marginUsed) + notional / previewLeverage).toFixed(2) : null,
|
|
1327
1552
|
},
|
|
1328
1553
|
existingPosition: existingPos ? {
|
|
1329
1554
|
side: existingPos.side,
|
|
@@ -1332,66 +1557,238 @@ server.tool("trade_preview", "Preview a trade WITHOUT executing. Returns estimat
|
|
|
1332
1557
|
unrealizedPnl: existingPos.unrealizedPnl,
|
|
1333
1558
|
} : null,
|
|
1334
1559
|
validation,
|
|
1560
|
+
// Builder fee the live order would carry (settings builder.<venue> + confirmed approval); null = none.
|
|
1561
|
+
builderFee: builderPlan.applied,
|
|
1562
|
+
builderNote: builderPlan.note,
|
|
1335
1563
|
warning: "This is a PREVIEW only. Call trade_execute to place the order after user confirms.",
|
|
1336
1564
|
};
|
|
1337
1565
|
return { content: [{ type: "text", text: ok(preview, { exchange, type: "preview" }) }] };
|
|
1338
1566
|
}
|
|
1339
1567
|
catch (e) {
|
|
1340
|
-
return { content: [{ type: "text", text:
|
|
1568
|
+
return { content: [{ type: "text", text: errOf(e, { exchange, symbol }) }], isError: true };
|
|
1341
1569
|
}
|
|
1342
1570
|
});
|
|
1343
|
-
|
|
1344
|
-
|
|
1571
|
+
/**
|
|
1572
|
+
* Place one order through TradingV2 (native on ondo/arcus, legacy wrapper on the four
|
|
1573
|
+
* pre-existing venues) and return OrderResult + execution (design §13.7). Pre-send
|
|
1574
|
+
* validation failures stay definite; anything thrown after the request may have reached
|
|
1575
|
+
* the venue becomes ORDER_PENDING ("do NOT resubmit", design §12.3).
|
|
1576
|
+
*/
|
|
1577
|
+
async function placeViaV2(adapter, req, what, remediation) {
|
|
1578
|
+
const def = getVenue(adapter.name);
|
|
1579
|
+
const caps = requireCapabilities(def);
|
|
1580
|
+
validateOrderRequest(def, caps, req);
|
|
1581
|
+
try {
|
|
1582
|
+
const network = adapterNetworks.get(adapter);
|
|
1583
|
+
if (network === undefined)
|
|
1584
|
+
throw new PerpError("INVALID_PARAMS", `${def.displayName}: adapter network unknown — refusing to place`);
|
|
1585
|
+
return await placeAndConfirm(tradingV2Of(adapter, network), caps, req);
|
|
1586
|
+
}
|
|
1587
|
+
catch (e) {
|
|
1588
|
+
throw isDefiniteOrderFailure(e) ? e : orderOutcomeUnknown(adapter.name, what, e, remediation);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
server.tool("trade_execute", `Execute a REAL trade on the server's network (meta.network; PERP_NETWORK, default mainnet). IMPORTANT: Always call trade_preview first and get explicit user confirmation before calling this tool. Refused with RISK_VIOLATION when the order breaches the local risk limits (perp risk set; there is no override over MCP) or when the symbol is quarantined (only reduce-only orders pass). Every order is written to the execution log. Supports market and limit orders. Returns the uniform OrderResult (status, finality, filledSize, execution: slippage / protection price / TIF sent) with the raw venue response under \`legacy\`, plus top-level filledSize / requestedSize / partial. Outcomes: an unknown outcome (transport failure after send, or a non-final venue ack) → ok:false, code ORDER_PENDING, executed:false, pending:true and a "do NOT resubmit" remediation — the symbol is quarantined for opening orders until a human clears it; a final rejection / cancel with nothing filled → ORDER_REJECTED, executed:false; a cancel after a partial fill → executed:true, partial:true with the real filledSize. ${HL_SCOPE_NOTE}`, {
|
|
1592
|
+
exchange: z.string().describe(`Exchange: ${listVenues().filter(v => v.create !== null).map(v => v.id).join(", ")} (aliases accepted)`),
|
|
1345
1593
|
symbol: z.string().describe("Trading symbol (e.g., BTC, ETH, SOL)"),
|
|
1346
1594
|
side: z.enum(["buy", "sell"]).describe("Order side"),
|
|
1347
1595
|
size: z.string().describe("Order size (base currency units)"),
|
|
1348
1596
|
orderType: z.enum(["market", "limit"]).default("market").describe("Order type"),
|
|
1349
1597
|
price: z.string().optional().describe("Limit price (required for limit orders)"),
|
|
1598
|
+
tif: z.string().optional().describe("Limit orders: GTC (default) | IOC | FOK | ALO (post-only); venue aliases GTT / GTX / TOB accepted"),
|
|
1350
1599
|
reduceOnly: z.boolean().default(false).describe("Reduce-only order (close position only)"),
|
|
1351
|
-
|
|
1600
|
+
slippageBps: z.number().optional().describe("Market orders: slippage bound in bps (default: settings defaultSlippageBps)"),
|
|
1601
|
+
}, async ({ exchange, symbol, side, size: rawSize, orderType, price, tif, reduceOnly, slippageBps }) => {
|
|
1352
1602
|
try {
|
|
1603
|
+
// One parsed size for the risk gate and the order ("1,000" / "abc" refused before any read or send).
|
|
1604
|
+
const size = parsePositiveDecimal(rawSize, "size");
|
|
1353
1605
|
const adapter = await getOrCreateAdapter(exchange);
|
|
1606
|
+
const def = getVenue(adapter.name);
|
|
1607
|
+
const caps = requireCapabilities(def);
|
|
1608
|
+
const sym = symbol.toUpperCase();
|
|
1609
|
+
const isLimit = orderType === "limit";
|
|
1610
|
+
const req = mcpOrderRequest(adapter, { symbol: sym, side, size, orderType, price, tif, reduceOnly, slippageBps });
|
|
1611
|
+
// Same pre-send path as `perp trade`: request validation, quarantine (non-reduce-only),
|
|
1612
|
+
// local risk limits (no force over MCP), then client-id + execution log around the send.
|
|
1613
|
+
validateOrderRequest(def, caps, req);
|
|
1614
|
+
await mcpPreTradeGate(adapter, { symbol: sym, size: Number(size), price: isLimit ? Number(price) : null, reduceOnly }, (m) => process.stderr.write(`${m}\n`));
|
|
1615
|
+
const orderType_ = isLimit ? "limit" : "market";
|
|
1616
|
+
const logType = isLimit ? "limit_order" : "market_order";
|
|
1617
|
+
const cid = req.clientOrderId;
|
|
1618
|
+
const nowIso = () => new Date().toISOString();
|
|
1619
|
+
if (cid !== null) {
|
|
1620
|
+
logClientId({ clientOrderId: cid, exchange: adapter.name, symbol: sym, side, size, ...(isLimit ? { price: price } : {}), type: orderType_, status: "pending", createdAt: nowIso(), updatedAt: nowIso() });
|
|
1621
|
+
}
|
|
1622
|
+
const logMeta = { reduceOnly, ...(cid !== null ? { clientOrderId: cid } : {}) };
|
|
1623
|
+
const priceLog = isLimit ? { price: price } : {};
|
|
1624
|
+
const errMeta = { exchange, symbol, type: "execution" };
|
|
1625
|
+
const qLog = (m) => process.stderr.write(`${m}\n`);
|
|
1626
|
+
const clearCmd = `${MCP_NETWORK === "testnet" ? "perp -n testnet" : "perp"} strategy quarantine-clear ${sym}`;
|
|
1627
|
+
// An unknown outcome on an opening order quarantines the symbol: the gate then refuses a
|
|
1628
|
+
// resubmit of the same order (only reduce-only orders pass) until a human clears it.
|
|
1629
|
+
const quarantineUnknown = (why) => {
|
|
1630
|
+
if (reduceOnly)
|
|
1631
|
+
return;
|
|
1632
|
+
quarantineSymbol("mcp-execute", sym, `trade_execute ${orderType} ${side} ${size} ${sym} on ${adapter.name}: ${why}`, `Check ${sym} on ${adapter.name} (get_open_orders / get_positions), resolve the order by hand, then: ${clearCmd}`, qLog);
|
|
1633
|
+
};
|
|
1354
1634
|
let result;
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1635
|
+
try {
|
|
1636
|
+
result = await placeViaV2(adapter, req, `${orderType} ${side} ${size} ${sym}`, `Do NOT resubmit — the order may have been placed. Call get_positions / get_open_orders for ${symbol} on ${exchange} first`);
|
|
1637
|
+
}
|
|
1638
|
+
catch (placeErr) {
|
|
1639
|
+
const unknown = isOutcomeUnknown(placeErr);
|
|
1640
|
+
logMcpExecution(MCP_NETWORK, { type: logType, exchange: adapter.name, symbol: sym, side, size, ...priceLog, status: unknown ? "unverified" : "failed", error: extractErrorMessage(placeErr), meta: logMeta });
|
|
1641
|
+
// An unknown outcome stays "pending" in the client-id log (it may have been placed).
|
|
1642
|
+
if (cid !== null && !unknown) {
|
|
1643
|
+
logClientId({ clientOrderId: cid, exchange: adapter.name, symbol: sym, side, size, type: orderType_, status: "failed", createdAt: nowIso(), updatedAt: nowIso() });
|
|
1644
|
+
}
|
|
1645
|
+
if (unknown)
|
|
1646
|
+
quarantineUnknown(`outcome unknown (${extractErrorMessage(placeErr)})`);
|
|
1647
|
+
return { content: [{ type: "text", text: errOf(placeErr, errMeta, unknown ? { executed: false, pending: true } : { executed: false }) }], isError: true };
|
|
1648
|
+
}
|
|
1649
|
+
// Settle what the venue reported (mirrors the CLI scale path, src/commands/trade.ts placeScaleLevels):
|
|
1650
|
+
// - not final → outcome unknown: ORDER_PENDING, never "success";
|
|
1651
|
+
// - final rejected / cancelled with nothing filled → ORDER_REJECTED;
|
|
1652
|
+
// - final cancelled with an unreported fill → the fill is unknown: ORDER_PENDING;
|
|
1653
|
+
// - final rejected / cancelled with a fill → executed, reported as the partial fill it is.
|
|
1654
|
+
const filled = result.filledSize === null ? null : Number(result.filledSize);
|
|
1655
|
+
const terminal = result.status === "rejected" || result.status === "cancelled";
|
|
1656
|
+
const fillUnknown = result.finality === "final" && result.status === "cancelled" && filled === null;
|
|
1657
|
+
if (result.finality !== "final" || fillUnknown) {
|
|
1658
|
+
const why = fillUnknown
|
|
1659
|
+
? `the venue cancelled the order without reporting its fill`
|
|
1660
|
+
: `the venue acknowledged the order without a final status (${result.status})`;
|
|
1661
|
+
logMcpExecution(MCP_NETWORK, { type: logType, exchange: adapter.name, symbol: sym, side, size, ...priceLog, status: "unverified", error: why, meta: logMeta });
|
|
1662
|
+
quarantineUnknown(why);
|
|
1663
|
+
const pe = new PerpError("ORDER_PENDING", `${adapter.name} ${orderType} ${side} ${size} ${sym}: outcome unknown — ${why}`, {
|
|
1664
|
+
exchange: adapter.name,
|
|
1665
|
+
remediation: result.remediation ?? `Do NOT resubmit — the order may have filled. Call get_open_orders / get_positions for ${symbol} on ${exchange} first`,
|
|
1666
|
+
});
|
|
1667
|
+
return { content: [{ type: "text", text: errOf(pe, errMeta, { executed: false, pending: true, result: orderResultJson(result) }) }], isError: true };
|
|
1668
|
+
}
|
|
1669
|
+
if (terminal && (filled === null || filled === 0)) {
|
|
1670
|
+
const why = `the venue ${result.status} the order${result.orderId !== null ? ` ${result.orderId}` : ""}`;
|
|
1671
|
+
logMcpExecution(MCP_NETWORK, { type: logType, exchange: adapter.name, symbol: sym, side, size, ...priceLog, status: "failed", error: why, meta: logMeta });
|
|
1672
|
+
if (cid !== null) {
|
|
1673
|
+
logClientId({ clientOrderId: cid, exchange: adapter.name, symbol: sym, side, size, type: orderType_, status: "failed", createdAt: nowIso(), updatedAt: nowIso() });
|
|
1674
|
+
}
|
|
1675
|
+
const pe = new PerpError("ORDER_REJECTED", `${adapter.name} ${orderType} ${side} ${size} ${sym}: ${why}; nothing filled`, { exchange: adapter.name });
|
|
1676
|
+
return { content: [{ type: "text", text: errOf(pe, errMeta, { executed: false, result: orderResultJson(result) }) }], isError: true };
|
|
1359
1677
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1678
|
+
const partial = terminal; // a terminal status with a fill > 0 — the rest was cancelled / rejected
|
|
1679
|
+
logMcpExecution(MCP_NETWORK, {
|
|
1680
|
+
type: logType, exchange: adapter.name, symbol: sym, side, size: partial ? result.filledSize : size, ...priceLog, status: "success",
|
|
1681
|
+
meta: { ...logMeta, ...(partial ? { partial: true, requested: size, venueStatus: result.status } : {}) },
|
|
1682
|
+
});
|
|
1683
|
+
if (cid !== null) {
|
|
1684
|
+
logClientId({ clientOrderId: cid, exchange: adapter.name, symbol: sym, side, size, type: orderType_, status: "submitted", createdAt: nowIso(), updatedAt: nowIso() });
|
|
1685
|
+
}
|
|
1686
|
+
// The order is placed: a failed position read below must NOT turn it into an error
|
|
1687
|
+
// (an agent would retry and double-trade). Report currentPosition: null + warning instead.
|
|
1688
|
+
// Exact symbol match; "-PERP" suffix-insensitive; uncached read.
|
|
1689
|
+
let currentPosition;
|
|
1690
|
+
let warning = partial
|
|
1691
|
+
? `Partially filled: ${result.filledSize} of ${size} ${sym}; the rest was ${result.status} by the venue. Do NOT resubmit the full size`
|
|
1692
|
+
: null;
|
|
1693
|
+
try {
|
|
1694
|
+
invalidateCache("acct");
|
|
1695
|
+
const positions = await adapter.getPositions();
|
|
1696
|
+
currentPosition = positions.find(p => symbolMatch(p.symbol, symbol)) ?? null;
|
|
1697
|
+
}
|
|
1698
|
+
catch (readErr) {
|
|
1699
|
+
currentPosition = null;
|
|
1700
|
+
const readWarning = `Order placed, but the post-order position read failed: ${extractErrorMessage(readErr)}. Do NOT resubmit — call get_positions for ${symbol} on ${exchange}`;
|
|
1701
|
+
warning = warning === null ? readWarning : `${warning}. ${readWarning}`;
|
|
1362
1702
|
}
|
|
1363
|
-
// Fetch updated position
|
|
1364
|
-
const positions = await adapter.getPositions();
|
|
1365
|
-
const pos = positions.find(p => p.symbol.toUpperCase().includes(symbol.toUpperCase()));
|
|
1366
1703
|
return {
|
|
1367
1704
|
content: [{
|
|
1368
1705
|
type: "text",
|
|
1369
1706
|
text: ok({
|
|
1370
1707
|
executed: true,
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1708
|
+
partial,
|
|
1709
|
+
// null = the venue did not report the fill (never "0" as a stand-in).
|
|
1710
|
+
filledSize: result.filledSize,
|
|
1711
|
+
requestedSize: size,
|
|
1712
|
+
order: { exchange, symbol, side, size, orderType, price, tif: req.tif },
|
|
1713
|
+
result: orderResultJson(result),
|
|
1714
|
+
currentPosition,
|
|
1715
|
+
...(warning !== null ? { warning } : {}),
|
|
1374
1716
|
}, { exchange, type: "execution" }),
|
|
1375
1717
|
}],
|
|
1376
1718
|
};
|
|
1377
1719
|
}
|
|
1378
1720
|
catch (e) {
|
|
1379
|
-
return { content: [{ type: "text", text:
|
|
1721
|
+
return { content: [{ type: "text", text: errOf(e, { exchange, symbol, type: "execution" }) }], isError: true };
|
|
1380
1722
|
}
|
|
1381
1723
|
});
|
|
1382
|
-
server.tool("trade_close",
|
|
1383
|
-
exchange: z.string().describe(
|
|
1724
|
+
server.tool("trade_close", `Close an existing position. IMPORTANT: Call trade_preview first with the opposite side to show impact, then get user confirmation. Verified close: \`closedSize\` is the measured / venue-final fill (one residual market close is attempted); a residual still open → ORDER_PARTIAL error (symbol quarantined). Includes the uniform OrderResult of the reduce-only market order. ${HL_SCOPE_NOTE}`, {
|
|
1725
|
+
exchange: z.string().describe(`Exchange: ${listVenues().filter(v => v.create !== null).map(v => v.id).join(", ")} (aliases accepted)`),
|
|
1384
1726
|
symbol: z.string().describe("Symbol to close (e.g., BTC, ETH)"),
|
|
1385
1727
|
}, async ({ exchange, symbol }) => {
|
|
1386
1728
|
try {
|
|
1387
1729
|
const adapter = await getOrCreateAdapter(exchange);
|
|
1730
|
+
const def = getVenue(adapter.name);
|
|
1731
|
+
const caps = requireCapabilities(def);
|
|
1732
|
+
// A venue whose reduce-only orders are gated refuses the close before any read.
|
|
1733
|
+
mcpReduceOnlyGate(adapter);
|
|
1734
|
+
// Size the reduce-only close from a fresh read, never a cached position.
|
|
1735
|
+
invalidateCache("acct");
|
|
1388
1736
|
const positions = await adapter.getPositions();
|
|
1389
|
-
const pos = positions.find(p => p.symbol
|
|
1737
|
+
const pos = positions.find(p => symbolMatch(p.symbol, symbol));
|
|
1390
1738
|
if (!pos || Number(pos.size) === 0) {
|
|
1391
|
-
|
|
1739
|
+
throw new PerpError("POSITION_NOT_FOUND", `No open position for ${symbol} on ${exchange}`, { exchange: adapter.name, remediation: `List open positions with get_positions (exchange: ${exchange})` });
|
|
1392
1740
|
}
|
|
1393
1741
|
const closeSide = pos.side === "long" ? "sell" : "buy";
|
|
1394
|
-
const
|
|
1742
|
+
const closeSize = pos.size.replace(/^-/, "");
|
|
1743
|
+
const req = {
|
|
1744
|
+
symbol: pos.symbol, side: closeSide, type: "market", size: closeSize,
|
|
1745
|
+
price: null, triggerPrice: null, triggerSource: null, tif: null, reduceOnly: true,
|
|
1746
|
+
clientOrderId: caps.clientOrderId && caps.orderAck === "async" ? newVenueClientOrderId(def) : null,
|
|
1747
|
+
slippage: resolveAdapterSlippage(adapter, null),
|
|
1748
|
+
tpsl: null,
|
|
1749
|
+
};
|
|
1750
|
+
const holder = { placed: null };
|
|
1751
|
+
const nowIso = () => new Date().toISOString();
|
|
1752
|
+
const dec = sizeDecimalsOf(closeSize);
|
|
1753
|
+
const qLog = (m) => process.stderr.write(`${m}\n`);
|
|
1754
|
+
const remediation = `Check ${pos.symbol} on ${adapter.name} (get_positions), close any residual by hand, then: perp strategy quarantine-clear ${pos.symbol}`;
|
|
1755
|
+
let c;
|
|
1756
|
+
try {
|
|
1757
|
+
// Verified close: the first leg is the TradingV2 reduce-only market order; the fill is
|
|
1758
|
+
// measured / venue-final, and one residual market close is attempted (closePerpVerified).
|
|
1759
|
+
c = await closePerpVerified(adapter, adapter.name, pos.symbol, closeSide, closeSize, {
|
|
1760
|
+
decimals: dec,
|
|
1761
|
+
slippage: req.slippage ?? undefined,
|
|
1762
|
+
place: async () => {
|
|
1763
|
+
if (req.clientOrderId !== null)
|
|
1764
|
+
logClientId({ clientOrderId: req.clientOrderId, exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closeSize, type: "market", status: "pending", createdAt: nowIso(), updatedAt: nowIso() });
|
|
1765
|
+
holder.placed = await placeViaV2(adapter, req, `reduce-only close ${closeSide} ${closeSize} ${pos.symbol}`, `Do NOT resubmit — the close may have executed. Call get_positions for ${symbol} on ${exchange} first`);
|
|
1766
|
+
return holder.placed.venueRaw;
|
|
1767
|
+
},
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
catch (e) {
|
|
1771
|
+
// An unknown outcome keeps the client id "pending"; a definite failure marks it failed.
|
|
1772
|
+
if (req.clientOrderId !== null && !isOutcomeUnknown(e))
|
|
1773
|
+
logClientId({ clientOrderId: req.clientOrderId, exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closeSize, type: "market", status: "failed", createdAt: nowIso(), updatedAt: nowIso() });
|
|
1774
|
+
if (isOutcomeUnknown(e))
|
|
1775
|
+
quarantineSymbol("mcp-close", pos.symbol, `trade_close ${pos.symbol} on ${adapter.name}: outcome unknown (${extractErrorMessage(e)})`, remediation, qLog);
|
|
1776
|
+
logMcpExecution(MCP_NETWORK, { type: "market_order", exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closeSize, status: isOutcomeUnknown(e) ? "unverified" : "failed", error: extractErrorMessage(e), meta: { reduceOnly: true, action: "close" } });
|
|
1777
|
+
throw e;
|
|
1778
|
+
}
|
|
1779
|
+
const closedSize = fillSizeString(c.closed, dec);
|
|
1780
|
+
const residual = fillSizeString(c.residual, dec);
|
|
1781
|
+
if (req.clientOrderId !== null)
|
|
1782
|
+
logClientId({ clientOrderId: req.clientOrderId, exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closeSize, type: "market", status: "submitted", createdAt: nowIso(), updatedAt: nowIso() });
|
|
1783
|
+
logMcpExecution(MCP_NETWORK, { type: "market_order", exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closedSize, status: c.residual > 0 ? "failed" : "success", ...(c.residual > 0 ? { error: `residual ${residual} still open` } : {}), meta: { reduceOnly: true, action: "close", requested: closeSize } });
|
|
1784
|
+
const closedPosition = { side: pos.side, size: pos.size, entryPrice: pos.entryPrice, unrealizedPnl: pos.unrealizedPnl };
|
|
1785
|
+
const entryResult = holder.placed !== null ? orderResultJson(holder.placed) : null;
|
|
1786
|
+
if (c.residual > 0) {
|
|
1787
|
+
quarantineSymbol("mcp-close", pos.symbol, `trade_close ${pos.symbol} on ${adapter.name}: closed ${closedSize} of ${closeSize}, ${residual} still open${c.note !== null ? ` (${c.note})` : ""}`, remediation, qLog);
|
|
1788
|
+
throw new PerpError("ORDER_PARTIAL", `${adapter.name} close ${pos.symbol}: closed ${closedSize} of ${closeSize}; ${residual} still open${c.note !== null ? ` (${c.note})` : ""}`, {
|
|
1789
|
+
exchange: adapter.name, closedSize, residual, closedPosition, result: entryResult, remediation,
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1395
1792
|
return {
|
|
1396
1793
|
content: [{
|
|
1397
1794
|
type: "text",
|
|
@@ -1399,14 +1796,16 @@ server.tool("trade_close", "Close an existing position. IMPORTANT: Call trade_pr
|
|
|
1399
1796
|
closed: true,
|
|
1400
1797
|
symbol,
|
|
1401
1798
|
exchange,
|
|
1402
|
-
|
|
1403
|
-
|
|
1799
|
+
closedSize,
|
|
1800
|
+
residual,
|
|
1801
|
+
closedPosition,
|
|
1802
|
+
result: entryResult,
|
|
1404
1803
|
}, { exchange, type: "close" }),
|
|
1405
1804
|
}],
|
|
1406
1805
|
};
|
|
1407
1806
|
}
|
|
1408
1807
|
catch (e) {
|
|
1409
|
-
return { content: [{ type: "text", text:
|
|
1808
|
+
return { content: [{ type: "text", text: errOf(e, { exchange, symbol, type: "close" }) }], isError: true };
|
|
1410
1809
|
}
|
|
1411
1810
|
});
|
|
1412
1811
|
// ============================================================
|
|
@@ -1418,34 +1817,35 @@ server.tool("get_funding_analysis", "Analyze funding income across exchanges —
|
|
|
1418
1817
|
symbol: z.string().optional().describe("Filter by symbol"),
|
|
1419
1818
|
}, async ({ period, exchange: exFilter, symbol: symFilter }) => {
|
|
1420
1819
|
try {
|
|
1421
|
-
const exchanges = exFilter ? [exFilter] :
|
|
1820
|
+
const exchanges = exFilter ? [exFilter] : await listConfiguredAccountVenues();
|
|
1422
1821
|
const periodMatch = period.match(/^(\d+)(d|w|m)$/);
|
|
1423
1822
|
const sinceMs = periodMatch
|
|
1424
1823
|
? Date.now() - parseInt(periodMatch[1]) * ({ d: 86400000, w: 604800000, m: 2592000000 }[periodMatch[2]] ?? 86400000)
|
|
1425
1824
|
: 0;
|
|
1426
1825
|
const allFunding = [];
|
|
1427
1826
|
const posNotionals = new Map();
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1827
|
+
// Per-venue failures are reported in meta.errors (never silently dropped from the totals).
|
|
1828
|
+
const errors = {};
|
|
1829
|
+
const settled = await Promise.allSettled(exchanges.map(async (ex) => {
|
|
1830
|
+
const adapter = await getOrCreateAdapter(ex);
|
|
1831
|
+
const [payments, positions] = await Promise.all([
|
|
1832
|
+
adapter.getFundingPayments(200),
|
|
1833
|
+
adapter.getPositions(),
|
|
1834
|
+
]);
|
|
1835
|
+
for (const p of payments) {
|
|
1836
|
+
if (sinceMs && p.time < sinceMs)
|
|
1837
|
+
continue;
|
|
1838
|
+
if (symFilter && !p.symbol.toUpperCase().includes(symFilter.toUpperCase()))
|
|
1839
|
+
continue;
|
|
1840
|
+
allFunding.push({ exchange: ex, symbol: p.symbol, payment: Number(p.payment), time: p.time });
|
|
1841
|
+
}
|
|
1842
|
+
for (const p of positions) {
|
|
1843
|
+
if (Number(p.size) > 0)
|
|
1844
|
+
posNotionals.set(`${ex}:${p.symbol}`, Math.abs(Number(p.size) * Number(p.markPrice)));
|
|
1446
1845
|
}
|
|
1447
|
-
catch { /* skip */ }
|
|
1448
1846
|
}));
|
|
1847
|
+
settled.forEach((r, i) => { if (r.status === "rejected")
|
|
1848
|
+
errors[exchanges[i]] = extractErrorMessage(r.reason); });
|
|
1449
1849
|
allFunding.sort((a, b) => a.time - b.time);
|
|
1450
1850
|
// Aggregate by exchange×symbol
|
|
1451
1851
|
const byExSym = new Map();
|
|
@@ -1461,7 +1861,7 @@ server.tool("get_funding_analysis", "Analyze funding income across exchanges —
|
|
|
1461
1861
|
const periodDays = sinceMs ? (Date.now() - sinceMs) / 86400000
|
|
1462
1862
|
: allFunding.length > 1 ? (allFunding[allFunding.length - 1].time - allFunding[0].time) / 86400000 : 1;
|
|
1463
1863
|
// Match to arb positions
|
|
1464
|
-
const arbState = loadArbState();
|
|
1864
|
+
const arbState = loadArbState(MCP_NETWORK);
|
|
1465
1865
|
const arbPositions = (arbState?.positions ?? []).map(pos => {
|
|
1466
1866
|
const lf = allFunding.filter(f => f.symbol === pos.symbol && f.exchange === pos.longExchange).reduce((s, f) => s + f.payment, 0);
|
|
1467
1867
|
const sf = allFunding.filter(f => f.symbol === pos.symbol && f.exchange === pos.shortExchange).reduce((s, f) => s + f.payment, 0);
|
|
@@ -1477,10 +1877,10 @@ server.tool("get_funding_analysis", "Analyze funding income across exchanges —
|
|
|
1477
1877
|
})),
|
|
1478
1878
|
arbPositions,
|
|
1479
1879
|
};
|
|
1480
|
-
return { content: [{ type: "text", text: ok(data) }] };
|
|
1880
|
+
return { content: [{ type: "text", text: ok(data, { errors }) }] };
|
|
1481
1881
|
}
|
|
1482
1882
|
catch (e) {
|
|
1483
|
-
return { content: [{ type: "text", text:
|
|
1883
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
1484
1884
|
}
|
|
1485
1885
|
});
|
|
1486
1886
|
server.tool("get_pnl_analysis", "P&L analysis combining trading fees and funding income — shows net profit/loss by exchange with daily breakdown", {
|
|
@@ -1488,43 +1888,44 @@ server.tool("get_pnl_analysis", "P&L analysis combining trading fees and funding
|
|
|
1488
1888
|
exchange: z.string().optional().describe("Filter by exchange"),
|
|
1489
1889
|
}, async ({ period, exchange: exFilter }) => {
|
|
1490
1890
|
try {
|
|
1491
|
-
const exchanges = exFilter ? [exFilter] :
|
|
1891
|
+
const exchanges = exFilter ? [exFilter] : await listConfiguredAccountVenues();
|
|
1492
1892
|
const periodMatch = period.match(/^(\d+)(d|w|m)$/);
|
|
1493
1893
|
const sinceMs = periodMatch
|
|
1494
1894
|
? Date.now() - parseInt(periodMatch[1]) * ({ d: 86400000, w: 604800000, m: 2592000000 }[periodMatch[2]] ?? 86400000)
|
|
1495
1895
|
: 0;
|
|
1496
1896
|
const byExchange = new Map();
|
|
1497
1897
|
const dailyMap = new Map();
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
for (const f of funding) {
|
|
1516
|
-
if (sinceMs && f.time < sinceMs)
|
|
1517
|
-
continue;
|
|
1518
|
-
exData.funding += Number(f.payment);
|
|
1519
|
-
const day = new Date(f.time).toISOString().slice(0, 10);
|
|
1520
|
-
const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
|
|
1521
|
-
d.funding += Number(f.payment);
|
|
1522
|
-
dailyMap.set(day, d);
|
|
1523
|
-
}
|
|
1524
|
-
byExchange.set(ex, exData);
|
|
1898
|
+
// Per-venue failures are reported in meta.errors (never silently dropped from the totals).
|
|
1899
|
+
const errors = {};
|
|
1900
|
+
const settled = await Promise.allSettled(exchanges.map(async (ex) => {
|
|
1901
|
+
const adapter = await getOrCreateAdapter(ex);
|
|
1902
|
+
const [trades, funding] = await Promise.all([adapter.getTradeHistory(200), adapter.getFundingPayments(200)]);
|
|
1903
|
+
const exData = byExchange.get(ex) ?? { trades: 0, volume: 0, fees: 0, funding: 0 };
|
|
1904
|
+
for (const t of trades) {
|
|
1905
|
+
if (sinceMs && t.time < sinceMs)
|
|
1906
|
+
continue;
|
|
1907
|
+
const notional = Number(t.price) * Number(t.size);
|
|
1908
|
+
exData.trades++;
|
|
1909
|
+
exData.volume += notional;
|
|
1910
|
+
exData.fees += Number(t.fee);
|
|
1911
|
+
const day = new Date(t.time).toISOString().slice(0, 10);
|
|
1912
|
+
const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
|
|
1913
|
+
d.fees += Number(t.fee);
|
|
1914
|
+
dailyMap.set(day, d);
|
|
1525
1915
|
}
|
|
1526
|
-
|
|
1916
|
+
for (const f of funding) {
|
|
1917
|
+
if (sinceMs && f.time < sinceMs)
|
|
1918
|
+
continue;
|
|
1919
|
+
exData.funding += Number(f.payment);
|
|
1920
|
+
const day = new Date(f.time).toISOString().slice(0, 10);
|
|
1921
|
+
const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
|
|
1922
|
+
d.funding += Number(f.payment);
|
|
1923
|
+
dailyMap.set(day, d);
|
|
1924
|
+
}
|
|
1925
|
+
byExchange.set(ex, exData);
|
|
1527
1926
|
}));
|
|
1927
|
+
settled.forEach((r, i) => { if (r.status === "rejected")
|
|
1928
|
+
errors[exchanges[i]] = extractErrorMessage(r.reason); });
|
|
1528
1929
|
let totalFees = 0, totalFunding = 0, totalVolume = 0, totalTrades = 0;
|
|
1529
1930
|
for (const d of byExchange.values()) {
|
|
1530
1931
|
totalFees += d.fees;
|
|
@@ -1537,17 +1938,17 @@ server.tool("get_pnl_analysis", "P&L analysis combining trading fees and funding
|
|
|
1537
1938
|
byExchange: Object.fromEntries([...byExchange.entries()].map(([ex, d]) => [ex, { ...d, netPnl: d.funding - d.fees }])),
|
|
1538
1939
|
daily: [...dailyMap.entries()].sort().map(([date, d]) => ({ date, fees: d.fees, funding: d.funding, net: d.funding - d.fees })),
|
|
1539
1940
|
};
|
|
1540
|
-
return { content: [{ type: "text", text: ok(data) }] };
|
|
1941
|
+
return { content: [{ type: "text", text: ok(data, { errors }) }] };
|
|
1541
1942
|
}
|
|
1542
1943
|
catch (e) {
|
|
1543
|
-
return { content: [{ type: "text", text:
|
|
1944
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
1544
1945
|
}
|
|
1545
1946
|
});
|
|
1546
1947
|
server.tool("get_arb_compare", "Compare active arbitrage positions side by side — ROI, funding income, price PnL, and annualized returns for each position", {
|
|
1547
1948
|
period: z.string().default("all").describe("Funding lookup period: 7d, 30d, 90d, or all"),
|
|
1548
1949
|
}, async ({ period }) => {
|
|
1549
1950
|
try {
|
|
1550
|
-
const arbState = loadArbState();
|
|
1951
|
+
const arbState = loadArbState(MCP_NETWORK);
|
|
1551
1952
|
const positions = arbState?.positions ?? [];
|
|
1552
1953
|
if (positions.length === 0) {
|
|
1553
1954
|
return { content: [{ type: "text", text: ok({ positions: [], message: "No active arb positions" }) }] };
|
|
@@ -1563,38 +1964,53 @@ server.tool("get_arb_compare", "Compare active arbitrage positions side by side
|
|
|
1563
1964
|
}
|
|
1564
1965
|
const fundingByExSym = new Map();
|
|
1565
1966
|
const currentPrices = new Map();
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
currentPrices.set(`${ex}:${p.symbol}`, Number(p.markPrice));
|
|
1967
|
+
// A venue whose reads failed has unknown funding / prices: its rows report null, the reason is in meta.errors.
|
|
1968
|
+
const errors = {};
|
|
1969
|
+
const venues = [...exchangeSet];
|
|
1970
|
+
const settled = await Promise.allSettled(venues.map(async (ex) => {
|
|
1971
|
+
const adapter = await getOrCreateAdapter(ex);
|
|
1972
|
+
const [funding, livePos] = await Promise.all([adapter.getFundingPayments(200), adapter.getPositions()]);
|
|
1973
|
+
for (const f of funding) {
|
|
1974
|
+
if (sinceMs && f.time < sinceMs)
|
|
1975
|
+
continue;
|
|
1976
|
+
const key = `${ex}:${f.symbol}`;
|
|
1977
|
+
fundingByExSym.set(key, (fundingByExSym.get(key) ?? 0) + Number(f.payment));
|
|
1578
1978
|
}
|
|
1579
|
-
|
|
1979
|
+
for (const p of livePos)
|
|
1980
|
+
currentPrices.set(`${ex}:${p.symbol}`, Number(p.markPrice));
|
|
1580
1981
|
}));
|
|
1581
|
-
|
|
1982
|
+
settled.forEach((r, i) => { if (r.status === "rejected")
|
|
1983
|
+
errors[venues[i]] = extractErrorMessage(r.reason); });
|
|
1984
|
+
// Entries without a verified entry price have no price PnL: listed apart, never priced at a guess.
|
|
1985
|
+
const priced = positions.filter((p) => p.entryLongPrice !== null && p.entryShortPrice !== null);
|
|
1986
|
+
const unpricedPositions = positions.filter(p => p.entryLongPrice === null || p.entryShortPrice === null).map(p => p.symbol);
|
|
1987
|
+
// Funding of a venue that answered: the sum of its payments (none = 0). A failed venue: unknown (null).
|
|
1988
|
+
const fundingOf = (ex, sym) => (ex in errors ? null : fundingByExSym.get(`${ex}:${sym}`) ?? 0);
|
|
1989
|
+
// Current mark price from the live position; none (failed read / no live leg) = unknown (null), never the entry price.
|
|
1990
|
+
const priceOf = (ex, sym) => currentPrices.get(`${ex}:${sym}`) ?? null;
|
|
1991
|
+
const rows = priced.map(pos => {
|
|
1582
1992
|
const daysHeld = Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000));
|
|
1583
|
-
const
|
|
1584
|
-
const
|
|
1585
|
-
const
|
|
1586
|
-
const
|
|
1587
|
-
const
|
|
1993
|
+
const lf = fundingOf(pos.longExchange, pos.symbol);
|
|
1994
|
+
const sf = fundingOf(pos.shortExchange, pos.symbol);
|
|
1995
|
+
const funding = lf !== null && sf !== null ? lf + sf : null;
|
|
1996
|
+
const longPrice = priceOf(pos.longExchange, pos.symbol);
|
|
1997
|
+
const shortPrice = priceOf(pos.shortExchange, pos.symbol);
|
|
1998
|
+
const pricePnl = longPrice !== null && shortPrice !== null
|
|
1999
|
+
? (longPrice - pos.entryLongPrice) * pos.longSize + (pos.entryShortPrice - shortPrice) * pos.shortSize
|
|
2000
|
+
: null;
|
|
2001
|
+
const totalPnl = pricePnl !== null && funding !== null ? pricePnl + funding : null;
|
|
1588
2002
|
const notional = pos.entryLongPrice * pos.longSize + pos.entryShortPrice * pos.shortSize;
|
|
1589
|
-
const roi = notional > 0 ? (totalPnl / notional) * 100 :
|
|
1590
|
-
const fundingRoi = notional > 0 ? (funding / notional) * 100 :
|
|
1591
|
-
return { symbol: pos.symbol, mode: pos.mode ?? "perp-perp", longExchange: pos.longExchange, shortExchange: pos.shortExchange, daysHeld, funding, pricePnl, totalPnl, roi, annualizedRoi:
|
|
1592
|
-
}).sort((a, b) => b.totalPnl - a.totalPnl);
|
|
1593
|
-
|
|
1594
|
-
|
|
2003
|
+
const roi = totalPnl !== null && notional > 0 ? (totalPnl / notional) * 100 : null;
|
|
2004
|
+
const fundingRoi = funding !== null && notional > 0 ? (funding / notional) * 100 : null;
|
|
2005
|
+
return { symbol: pos.symbol, mode: pos.mode ?? "perp-perp", longExchange: pos.longExchange, shortExchange: pos.shortExchange, daysHeld, funding, pricePnl, totalPnl, roi, annualizedRoi: fundingRoi !== null ? (fundingRoi / daysHeld) * 365 : null };
|
|
2006
|
+
}).sort((a, b) => (a.totalPnl === null ? 1 : b.totalPnl === null ? -1 : b.totalPnl - a.totalPnl));
|
|
2007
|
+
// A total over rows with an unknown value is itself unknown (null).
|
|
2008
|
+
const sumOrNull = (vals) => (vals.some(v => v === null) ? null : vals.reduce((s, v) => s + v, 0));
|
|
2009
|
+
const totals = { funding: sumOrNull(rows.map(r => r.funding)), pricePnl: sumOrNull(rows.map(r => r.pricePnl)), totalPnl: sumOrNull(rows.map(r => r.totalPnl)) };
|
|
2010
|
+
return { content: [{ type: "text", text: ok({ period, positionCount: rows.length, positions: rows, unpricedPositions, totals }, { errors }) }] };
|
|
1595
2011
|
}
|
|
1596
2012
|
catch (e) {
|
|
1597
|
-
return { content: [{ type: "text", text:
|
|
2013
|
+
return { content: [{ type: "text", text: errOf(e) }], isError: true };
|
|
1598
2014
|
}
|
|
1599
2015
|
});
|
|
1600
2016
|
// ============================================================
|
|
@@ -1602,27 +2018,28 @@ server.tool("get_arb_compare", "Compare active arbitrage positions side by side
|
|
|
1602
2018
|
// ============================================================
|
|
1603
2019
|
server.resource("market_prices", "market://prices", { mimeType: "application/json", description: "Live cross-exchange prices for all perpetual futures markets" }, async () => {
|
|
1604
2020
|
const prices = {};
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
prices[m.symbol]
|
|
1613
|
-
|
|
2021
|
+
const venues = listVenuesWithRole("marketData").map(v => v.id);
|
|
2022
|
+
// Per-venue failures are reported in meta.errors (like get_prices), never silently skipped.
|
|
2023
|
+
const errors = {};
|
|
2024
|
+
const settled = await Promise.allSettled(venues.map(async (ex) => {
|
|
2025
|
+
const markets = await fetchMarketsPublic(ex);
|
|
2026
|
+
for (const m of markets) {
|
|
2027
|
+
if (!prices[m.symbol])
|
|
2028
|
+
prices[m.symbol] = {};
|
|
2029
|
+
prices[m.symbol][ex] = m.markPrice;
|
|
1614
2030
|
}
|
|
1615
|
-
catch { /* skip */ }
|
|
1616
2031
|
}));
|
|
1617
|
-
|
|
2032
|
+
settled.forEach((r, i) => { if (r.status === "rejected")
|
|
2033
|
+
errors[venues[i]] = extractErrorMessage(r.reason); });
|
|
2034
|
+
return { contents: [{ uri: "market://prices", text: JSON.stringify({ ok: true, data: prices, meta: { timestamp: new Date().toISOString(), network: MCP_NETWORK, errors } }, null, 2), mimeType: "application/json" }] };
|
|
1618
2035
|
});
|
|
1619
2036
|
server.resource("funding_rates", "market://funding-rates", { mimeType: "application/json", description: "Current funding rates across all exchanges with spread analysis" }, async () => {
|
|
1620
2037
|
try {
|
|
1621
2038
|
const rates = await fetchAllFundingRates();
|
|
1622
|
-
return { contents: [{ uri: "market://funding-rates", text: JSON.stringify({ ok: true, data: rates, meta: { timestamp: new Date().toISOString() } }, null, 2), mimeType: "application/json" }] };
|
|
2039
|
+
return { contents: [{ uri: "market://funding-rates", text: JSON.stringify({ ok: true, data: rates, meta: { timestamp: new Date().toISOString(), network: MCP_NETWORK } }, null, 2), mimeType: "application/json" }] };
|
|
1623
2040
|
}
|
|
1624
2041
|
catch (e) {
|
|
1625
|
-
return { contents: [{ uri: "market://funding-rates", text:
|
|
2042
|
+
return { contents: [{ uri: "market://funding-rates", text: errOf(e), mimeType: "application/json" }] };
|
|
1626
2043
|
}
|
|
1627
2044
|
});
|
|
1628
2045
|
// ============================================================
|
|
@@ -1649,12 +2066,14 @@ server.prompt("trading-guide", "Step-by-step guide for trading with perp-cli via
|
|
|
1649
2066
|
|
|
1650
2067
|
3. **Preview Before Executing**
|
|
1651
2068
|
- ALWAYS use \`trade_preview\` before any trade
|
|
1652
|
-
- Show the user: estimated price,
|
|
2069
|
+
- Show the user: estimated price, notional, margin impact, builder fee, and any refusal (valid:false + code / remediation)
|
|
2070
|
+
- Venue trading fees are not estimated by the preview
|
|
1653
2071
|
- Wait for explicit user confirmation
|
|
1654
2072
|
|
|
1655
2073
|
4. **Execute with Confirmation**
|
|
1656
2074
|
- Only call \`trade_execute\` after user says "yes" or "confirm"
|
|
1657
2075
|
- Never execute trades without showing the preview first
|
|
2076
|
+
- ORDER_PENDING (pending:true) means the outcome is unknown: do NOT resubmit — check \`get_open_orders\` / \`get_positions\` (the symbol is quarantined for opening orders until a human clears it)
|
|
1658
2077
|
|
|
1659
2078
|
5. **Monitor After Execution**
|
|
1660
2079
|
- Use \`get_positions\` to verify the trade went through
|
|
@@ -1666,11 +2085,10 @@ server.prompt("trading-guide", "Step-by-step guide for trading with perp-cli via
|
|
|
1666
2085
|
- If the user asks to "buy" or "sell", preview first, then ask for confirmation
|
|
1667
2086
|
- Warn about high-leverage trades or large position sizes
|
|
1668
2087
|
|
|
1669
|
-
## Supported Exchanges
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
- **aster** (BNB Chain) — BNB Chain perpetual futures`,
|
|
2088
|
+
## Supported Exchanges (server network: ${MCP_NETWORK})
|
|
2089
|
+
${listVenues().map(v => `- **${v.id}** (${v.displayName}) — ${v.roles.join(", ")}`).join("\n")}
|
|
2090
|
+
|
|
2091
|
+
${HL_SCOPE_NOTE}`,
|
|
1674
2092
|
},
|
|
1675
2093
|
}],
|
|
1676
2094
|
}));
|