perp-cli 0.12.17 → 0.14.0
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 +286 -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 +286 -0
- package/dist/commands/funds.d.ts +25 -0
- package/dist/commands/funds.js +939 -245
- package/dist/commands/history.js +72 -45
- 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 +316 -337
- 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 +219 -6
- package/dist/exchanges/aster.js +1023 -153
- 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 +166 -0
- package/dist/exchanges/ondo/adapter.js +835 -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 +122 -0
- package/dist/exchanges/ondo/funds.js +290 -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 +288 -4
- package/dist/exchanges/pacifica.js +1013 -76
- 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 +1383 -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 +34 -0
- package/dist/exchanges/venues/ondo.js +140 -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 +28 -0
- package/dist/exchanges/venues/qfex.js +132 -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 +820 -402
- package/dist/mcp-trade-guard.d.ts +16 -0
- package/dist/mcp-trade-guard.js +59 -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 +142 -55
- package/skills/perp-cli/references/agent-operations.md +110 -56
- package/skills/perp-cli/references/commands.md +266 -71
- 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/commands/trade.js
CHANGED
|
@@ -1,24 +1,438 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { networkOf, supportsDryRun } from "../cli-flags.js";
|
|
2
|
+
import { printJson, errorAndExit, withJsonErrors, jsonOk, jsonError, symbolMatch, formatUsd, formatOrderResult, orderResultJson } from "../utils.js";
|
|
3
|
+
import { ERROR_CODES, extractErrorMessage, isDefiniteOrderFailure, parsePositiveDecimal, PerpError } from "../errors.js";
|
|
3
4
|
import { logExecution } from "../execution-log.js";
|
|
4
|
-
import { validateTrade } from "../trade-validator.js";
|
|
5
|
+
import { validateTrade, enforceOrderRisk } from "../trade-validator.js";
|
|
5
6
|
import { generateClientId, logClientId, isOrderDuplicate } from "../client-id-tracker.js";
|
|
6
7
|
import { smartOrder } from "../smart-order.js";
|
|
8
|
+
import { invalidateCache } from "../cache.js";
|
|
9
|
+
import { closePerpVerified, isOutcomeUnknown, placePerpVerified } from "../bot/perp-order.js";
|
|
10
|
+
import { iocLotTolerance, isIocOutcomeCapable } from "../exchanges/ioc-outcome.js";
|
|
11
|
+
import { quarantineEntryFor, quarantineSymbol } from "../bot/quarantine.js";
|
|
12
|
+
import { fillSizeString, sizeDecimalsOf } from "../exchanges/spot-result.js";
|
|
7
13
|
import chalk from "chalk";
|
|
8
|
-
import { hasPacificaSdk, isTwapCapable,
|
|
14
|
+
import { hasPacificaSdk, isTwapCapable, isQueryOrderCapable } from "../exchanges/capabilities.js";
|
|
15
|
+
import { getVenue, listVenuesWithRole, requireCapabilities } from "../exchanges/venue.js";
|
|
16
|
+
import "../exchanges/venues/index.js";
|
|
17
|
+
import { isTradingV2, newVenueClientOrderId, placeAndConfirm, reduceOnlyGateOf, stopOrderGateOf, unsupported, validateLegacyTif, validateOrderRequest } from "../exchanges/trading-v2.js";
|
|
18
|
+
import { placeTpSlLeg, tradingV2Of } from "../exchanges/legacy-trading-v2.js";
|
|
19
|
+
import { PACIFICA_UUID_RE } from "../exchanges/venues/pacifica.js";
|
|
20
|
+
import { HL_CLOID_RE } from "../exchanges/venues/hyperliquid.js";
|
|
21
|
+
import { ASTER_CLIENT_ID_RE } from "../exchanges/venues/aster.js";
|
|
22
|
+
import { parseOrderRef } from "../exchanges/venue-types.js";
|
|
23
|
+
import { resolveAdapterSlippage, resolveSlippage } from "../exchanges/slippage.js";
|
|
24
|
+
import { exactUnits, floorSize, floorToStep, lotStepOf, unitsToString } from "../exchanges/precision.js";
|
|
25
|
+
import { isSpotOpenOrdersCapable } from "../exchanges/spot-interface.js";
|
|
26
|
+
import { builderPlanFor } from "../exchanges/builder-fee.js";
|
|
27
|
+
import { HL_MIN_ORDER_VALUE_USD } from "../constants.js";
|
|
28
|
+
import { defaultSpotQuote, readSpotQuoteAvailableFresh, requireSpotAdapter, requireSpotDefinition, transferQuoteToPerp, transferQuoteToSpot, transferShortfallError } from "../exchanges/spot-factory.js";
|
|
29
|
+
// ── Uniform order surface (design §13) ──
|
|
30
|
+
//
|
|
31
|
+
// Every venue's trade market/buy/sell/limit/stop/edit/cancel goes through TradingV2:
|
|
32
|
+
// native on ondo/arcus, the legacy wrapper (exchanges/legacy-trading-v2.ts) on
|
|
33
|
+
// pacifica/hyperliquid/lighter/lighter-rh/aster. One default TIF (GTC), one result
|
|
34
|
+
// shape (OrderResult + execution), one printer.
|
|
35
|
+
/** Families with the legacy smart-order path (--smart: IOC at best book ± 1 tick). */
|
|
36
|
+
const LEGACY_ORDER_FAMILIES = new Set(["pacifica", "hyperliquid", "lighter", "aster"]);
|
|
37
|
+
/** Common default time-in-force for every venue (design §13.3; venues map GTC internally, e.g. GTT). */
|
|
38
|
+
const DEFAULT_TIF = "GTC";
|
|
39
|
+
function tradingVenueOf(adapter) {
|
|
40
|
+
const def = getVenue(adapter.name);
|
|
41
|
+
return { def, caps: requireCapabilities(def) };
|
|
42
|
+
}
|
|
43
|
+
function isLegacyOrderVenue(def) {
|
|
44
|
+
return LEGACY_ORDER_FAMILIES.has(def.family);
|
|
45
|
+
}
|
|
46
|
+
function parseSlippageBpsFlag(raw) {
|
|
47
|
+
if (raw === undefined)
|
|
48
|
+
return null;
|
|
49
|
+
const n = Number(raw);
|
|
50
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
51
|
+
throw new PerpError("INVALID_PARAMS", `--slippage-bps must be a positive number (got "${raw}")`);
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Client-order-id format accepted by each venue with client order ids. Arcus: 1–36 chars of
|
|
56
|
+
* [A-Za-z0-9_-] minus 3 so the "-tp"/"-sl" TP/SL leg suffix always fits;
|
|
57
|
+
* Ondo: 1–64 chars of [A-Za-z0-9_-]; Lighter(-RH): numeric clientOrderIndex (range
|
|
58
|
+
* checked by the adapter); Pacifica: a full UUID (docs.pacifica.fi create-limit-order:
|
|
59
|
+
* `client_order_id` "Full UUID string"); Hyperliquid: a 128-bit cloid, "0x" + 32 hex (HL_CLOID_RE);
|
|
60
|
+
* Aster: `newClientOrderId` ^[.A-Z:/a-z0-9_-]{1,36}$ (ASTER_CLIENT_ID_RE). Venues without client ids refuse --client-id / --auto-id
|
|
61
|
+
* (v2ClientOrderId) — an id is never accepted and then dropped.
|
|
62
|
+
*/
|
|
63
|
+
const V2_CLIENT_ID_RULES = {
|
|
64
|
+
arcus: { re: /^[A-Za-z0-9_-]{1,33}$/, desc: "1-33 chars of A-Z a-z 0-9 _ -" },
|
|
65
|
+
ondo: { re: /^[A-Za-z0-9_-]{1,64}$/, desc: "1-64 chars of A-Z a-z 0-9 _ -" },
|
|
66
|
+
lighter: { re: /^[1-9][0-9]{0,14}$/, desc: "a decimal integer from 1 to 2^48 - 1" },
|
|
67
|
+
"lighter-rh": { re: /^[1-9][0-9]{0,14}$/, desc: "a decimal integer from 1 to 2^48 - 1" },
|
|
68
|
+
pacifica: { re: PACIFICA_UUID_RE, desc: "a full UUID, e.g. f47ac10b-58cc-4372-a567-0e02b2c3d479" },
|
|
69
|
+
hyperliquid: { re: HL_CLOID_RE, desc: "a 128-bit hex cloid: 0x + 32 lowercase hex chars, e.g. 0x1234567890abcdef1234567890abcdef (or --auto-id)" },
|
|
70
|
+
aster: { re: ASTER_CLIENT_ID_RE, desc: "1-36 chars of A-Z a-z 0-9 . : / _ -" },
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Venues whose client order ids perp-cli GENERATES and never takes from the user: QFEX ids are
|
|
74
|
+
* not unique on the venue, so a reused id would attribute another order's fills / cancels to
|
|
75
|
+
* this one (design §14.12 #4c). --auto-id still works (the generated id is reported).
|
|
76
|
+
*/
|
|
77
|
+
const GENERATED_CLIENT_ID_ONLY = new Set(["qfex"]);
|
|
78
|
+
function validateV2ClientId(def, id) {
|
|
79
|
+
if (GENERATED_CLIENT_ID_ONLY.has(def.id)) {
|
|
80
|
+
throw unsupported(def.displayName, "clientOrderId", "client order ids are not unique on the venue, so perp-cli only correlates orders by ids it generates", `Omit --client-id on ${def.id} (use --auto-id, or nothing — the generated id is reported and works with: perp trade status c:<id> --symbol <S> -e ${def.id})`);
|
|
81
|
+
}
|
|
82
|
+
const rule = V2_CLIENT_ID_RULES[def.id];
|
|
83
|
+
if (rule === undefined) {
|
|
84
|
+
throw unsupported(def.displayName, "clientOrderId", "no client-order-id format rule for this venue", "Omit --client-id / --auto-id");
|
|
85
|
+
}
|
|
86
|
+
if (!rule.re.test(id)) {
|
|
87
|
+
throw new PerpError("INVALID_PARAMS", `Client order id "${id}" is not valid on ${def.displayName} (${rule.desc})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Venue client order id: the caller's --client-id/--auto-id (validated); else a generated id
|
|
92
|
+
* for async-ack venues; else null. A venue whose orders carry no client id through this CLI
|
|
93
|
+
* refuses a caller id up front (CAPABILITY_UNSUPPORTED, nothing sent):
|
|
94
|
+
* the order would be placed without it and `trade status c:<id>` / `trade cancel c:<id>` could
|
|
95
|
+
* never find it.
|
|
96
|
+
*/
|
|
97
|
+
function v2ClientOrderId(def, caps, userId) {
|
|
98
|
+
if (!caps.clientOrderId) {
|
|
99
|
+
if (userId !== undefined) {
|
|
100
|
+
throw unsupported(def.displayName, "clientOrderId", "orders on this venue carry no client order id through perp-cli, so the id would be dropped", `Omit --client-id / --auto-id on ${def.id} and use the venue order id the result reports (perp trade status <orderId> -e ${def.id})`);
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (userId !== undefined) {
|
|
105
|
+
validateV2ClientId(def, userId);
|
|
106
|
+
return userId;
|
|
107
|
+
}
|
|
108
|
+
return caps.orderAck === "async" ? newVenueClientOrderId(def) : null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* --auto-id: venue-format id where the venue has client ids; a venue without them refuses it
|
|
112
|
+
* (v2ClientOrderId — the locally generated id is never sent).
|
|
113
|
+
* Ondo / Pacifica limit orders: undefined — the adapter generates its own id, which encodes
|
|
114
|
+
* post-only (Ondo) / the TIF (Pacifica) for a later edit (design §13.10 #10, #11); the result
|
|
115
|
+
* reports that id. A Pacifica market order has no generated id, so --auto-id sends a UUID.
|
|
116
|
+
* Generated-only venues (qfex): undefined — v2ClientOrderId generates the venue id itself.
|
|
117
|
+
*/
|
|
118
|
+
function autoClientId(def, caps, orderType) {
|
|
119
|
+
if (def.id === "ondo" || GENERATED_CLIENT_ID_ONLY.has(def.id))
|
|
120
|
+
return undefined;
|
|
121
|
+
if (def.id === "pacifica" && orderType === "limit")
|
|
122
|
+
return undefined;
|
|
123
|
+
return caps.clientOrderId ? newVenueClientOrderId(def) : generateClientId();
|
|
124
|
+
}
|
|
125
|
+
/** Validated market-order request (`slippage` = resolveSlippage() output). */
|
|
126
|
+
function v2MarketRequest(def, caps, symbol, side, size, reduceOnly, slippage, clientId) {
|
|
127
|
+
const req = {
|
|
128
|
+
symbol, side, type: "market", size, price: null, triggerPrice: null, triggerSource: null, tif: null,
|
|
129
|
+
reduceOnly,
|
|
130
|
+
clientOrderId: v2ClientOrderId(def, caps, clientId),
|
|
131
|
+
slippage,
|
|
132
|
+
tpsl: null,
|
|
133
|
+
};
|
|
134
|
+
validateOrderRequest(def, caps, req);
|
|
135
|
+
return req;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* placeAndConfirm() plus the CLI notice for adapter-side size flooring
|
|
139
|
+
* (venueRaw.requestedSize/placedSize, Arcus) on stderr.
|
|
140
|
+
*/
|
|
141
|
+
async function placeV2(v2, caps, req) {
|
|
142
|
+
const result = await placeAndConfirm(v2, caps, req);
|
|
143
|
+
const raw = result.venueRaw;
|
|
144
|
+
if (raw !== null && typeof raw === "object" && typeof raw.requestedSize === "string" && typeof raw.placedSize === "string") {
|
|
145
|
+
console.error(chalk.yellow(` Notice: size ${raw.requestedSize} was floored to the venue step → ${raw.placedSize} placed`));
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
/** Unified human output for an order result (every venue). */
|
|
150
|
+
function printOrder(header, r) {
|
|
151
|
+
console.log(chalk.green(`\n ${header}`));
|
|
152
|
+
for (const line of formatOrderResult(r))
|
|
153
|
+
console.log(line);
|
|
154
|
+
console.log();
|
|
155
|
+
}
|
|
156
|
+
/** Unified human output for a CancelResult. */
|
|
157
|
+
function printCancel(r, symbol) {
|
|
158
|
+
const refText = (ref) => (ref.kind === "clientOrderId" ? `c:${ref.value}` : ref.value);
|
|
159
|
+
const pending = r.pending ?? [];
|
|
160
|
+
if (r.cancelled.length > 0) {
|
|
161
|
+
console.log(chalk.green(`\n Cancelled on ${r.venue} (${symbol}): ${r.cancelled.map(refText).join(", ")}`));
|
|
162
|
+
}
|
|
163
|
+
else if (r.failed.length === 0 && pending.length === 0) {
|
|
164
|
+
console.log(chalk.yellow(`\n No order cancelled on ${r.venue} (${symbol}).`));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
console.log();
|
|
168
|
+
}
|
|
169
|
+
for (const f of r.failed) {
|
|
170
|
+
console.log(chalk.red(` ✗ ${f.ref !== null ? refText(f.ref) : symbol}: ${f.error}`));
|
|
171
|
+
}
|
|
172
|
+
for (const p of pending) {
|
|
173
|
+
console.log(chalk.yellow(` ? ${p.ref !== null ? refText(p.ref) : symbol}: cancel PENDING — ${p.reason}. ${p.remediation}`));
|
|
174
|
+
}
|
|
175
|
+
console.log();
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A cancel with anything not proven cancelled (failed, or pending / still listed open after the
|
|
179
|
+
* verifying re-list) → ORDER_PARTIAL carrying the whole result (non-zero exit, also in --json);
|
|
180
|
+
* all failed (none cancelled, none pending) → ORDER_REJECTED (definitely nothing cancelled).
|
|
181
|
+
*/
|
|
182
|
+
export function cancelIncompleteError(r, scope) {
|
|
183
|
+
const pending = r.pending ?? [];
|
|
184
|
+
if (r.failed.length === 0 && pending.length === 0)
|
|
185
|
+
return null;
|
|
186
|
+
if (r.cancelled.length === 0 && pending.length === 0) {
|
|
187
|
+
// Every order definitely NOT cancelled (venue refusal confirmed by the order's own state, e.g.
|
|
188
|
+
// already filled): a definite rejection — nothing changed on the venue.
|
|
189
|
+
return new PerpError("ORDER_REJECTED", `${r.venue} cancel (${scope}) refused: ${r.failed.map(f => f.error).join("; ")}`, {
|
|
190
|
+
exchange: r.venue, cancelled: r.cancelled, failed: r.failed, pending, legacy: r.venueRaw,
|
|
191
|
+
remediation: `Nothing was cancelled — check open orders: perp account orders -e ${r.venue}`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return new PerpError("ORDER_PARTIAL", `${r.venue} cancel (${scope}): ${r.cancelled.length} confirmed cancelled, ${r.failed.length} failed, ${pending.length} pending / still open`, {
|
|
195
|
+
exchange: r.venue, cancelled: r.cancelled, failed: r.failed, pending, legacy: r.venueRaw,
|
|
196
|
+
remediation: `Check open orders before acting again: perp account orders -e ${r.venue}`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** JSON extra: the locally tracked CLI client id when the venue order does not carry it. */
|
|
200
|
+
function trackedId(r, clientId) {
|
|
201
|
+
return clientId !== undefined && r.clientOrderId !== clientId ? { trackedClientId: clientId } : undefined;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Close / flatten / reduce / stop and the `--tp/--sl` / `trade tpsl` legs are reduce-only orders.
|
|
205
|
+
* A venue whose reduce-only orders are currently refused says so UP FRONT — before an entry is sent ("entry filled, protection failed" never
|
|
206
|
+
* happens) and before flatten cancels anything.
|
|
207
|
+
*/
|
|
208
|
+
/**
|
|
209
|
+
* QFEX (orphanedReduceOnlyOrders): resting reduce-only / stop orders left on a symbol whose
|
|
210
|
+
* position is gone — the venue leaves them open (production 2026-10-12, Q2), and whether one the
|
|
211
|
+
* market reaches while flat opens a position is unproven. `cancel` false → a WARNING listing them
|
|
212
|
+
* with the cancel command; true (flatten) → each is cancelled (per-order proof) and the result
|
|
213
|
+
* returned. Venues without the method → null. A failed read is a WARNING (the close itself stands).
|
|
214
|
+
*/
|
|
215
|
+
async function orphanedReduceOnly(adapter, symbol, cancel) {
|
|
216
|
+
const fn = adapter.orphanedReduceOnlyOrders;
|
|
217
|
+
if (typeof fn !== "function")
|
|
218
|
+
return null;
|
|
219
|
+
let left;
|
|
220
|
+
try {
|
|
221
|
+
left = await fn.call(adapter, symbol);
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
process.stderr.write(chalk.yellow(` WARNING: could not check ${adapter.name} for reduce-only / stop orders left after the close (${extractErrorMessage(e)}) — check: perp account orders -e ${adapter.name}\n`));
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
if (left.length === 0)
|
|
228
|
+
return null;
|
|
229
|
+
if (!cancel) {
|
|
230
|
+
process.stderr.write(chalk.yellow(` WARNING: ${left.length} reduce-only / stop order(s) remain on ${adapter.name} with no position (the venue does not cancel them): `
|
|
231
|
+
+ `${left.map(o => `${o.symbol} ${o.type} ${o.side} ${o.quantity} @ ${o.price} (${o.orderId})`).join("; ")}. Cancel: ${left.map(o => `perp trade cancel ${o.symbol} ${o.orderId} -e ${adapter.name}`).join(" ; ")}\n`));
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
if (!isTradingV2(adapter))
|
|
235
|
+
throw new PerpError("NOT_IMPLEMENTED", `${adapter.name}: orphaned reduce-only orders can only be cancelled on a TradingV2 adapter`);
|
|
236
|
+
const v2 = adapter;
|
|
237
|
+
const out = { venue: adapter.name, cancelled: [], failed: [], pending: [], venueRaw: [] };
|
|
238
|
+
for (const o of left) {
|
|
239
|
+
const r = await v2.cancelByRef(o.symbol, { kind: "orderId", value: o.orderId });
|
|
240
|
+
out.cancelled.push(...r.cancelled);
|
|
241
|
+
out.failed.push(...r.failed);
|
|
242
|
+
out.pending.push(...(r.pending ?? []));
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
function refuseGatedReduceOnly(adapter) {
|
|
247
|
+
const gate = reduceOnlyGateOf(adapter);
|
|
248
|
+
if (gate !== null)
|
|
249
|
+
throw gate;
|
|
250
|
+
}
|
|
251
|
+
/** `--tp/--sl` and `trade tpsl` legs are stop orders: a venue that refuses stops (QFEX, Q21) says so before the entry is sent. */
|
|
252
|
+
function refuseGatedStops(adapter) {
|
|
253
|
+
const gate = stopOrderGateOf(adapter);
|
|
254
|
+
if (gate !== null)
|
|
255
|
+
throw gate;
|
|
256
|
+
}
|
|
257
|
+
/** Pre-validate an edit (amend) against the venue capabilities. */
|
|
258
|
+
function prevalidateAmend(adapter) {
|
|
259
|
+
const { def, caps } = tradingVenueOf(adapter);
|
|
260
|
+
if (caps.amend === null) {
|
|
261
|
+
throw unsupported(def.displayName, "amend", "the venue cannot edit orders", `Cancel and re-place the order: perp trade cancel -e ${def.id} ...`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function positivePriceOpt(raw, flag) {
|
|
265
|
+
if (raw === undefined)
|
|
266
|
+
return null;
|
|
267
|
+
const n = Number(raw);
|
|
268
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
269
|
+
throw new PerpError("INVALID_PARAMS", `${flag} must be a positive price (got "${raw}")`);
|
|
270
|
+
return raw;
|
|
271
|
+
}
|
|
272
|
+
/** --tp/--sl of an entry command; null when neither is given. */
|
|
273
|
+
function entryTpSl(opts) {
|
|
274
|
+
const tp = positivePriceOpt(opts.tp, "--tp");
|
|
275
|
+
const sl = positivePriceOpt(opts.sl, "--sl");
|
|
276
|
+
return tp === null && sl === null ? null : { tp, sl };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Direction check before anything is sent: a long needs tp > ref > sl, a short sl > ref > tp
|
|
280
|
+
* (ref = the entry's reference: mark for market entries, the limit price for limit entries,
|
|
281
|
+
* the live mark for an open position). Violations → INVALID_PARAMS.
|
|
282
|
+
*/
|
|
283
|
+
function checkTpSlDirection(positionSide, ref, refLabel, p) {
|
|
284
|
+
const bad = [];
|
|
285
|
+
if (p.tp !== null) {
|
|
286
|
+
const tp = Number(p.tp);
|
|
287
|
+
if (positionSide === "long" ? !(tp > ref) : !(tp < ref))
|
|
288
|
+
bad.push(`take-profit ${p.tp} must be ${positionSide === "long" ? "above" : "below"} the ${refLabel} ${ref}`);
|
|
289
|
+
}
|
|
290
|
+
if (p.sl !== null) {
|
|
291
|
+
const sl = Number(p.sl);
|
|
292
|
+
if (positionSide === "long" ? !(sl < ref) : !(sl > ref))
|
|
293
|
+
bad.push(`stop-loss ${p.sl} must be ${positionSide === "long" ? "below" : "above"} the ${refLabel} ${ref}`);
|
|
294
|
+
}
|
|
295
|
+
if (bad.length > 0) {
|
|
296
|
+
throw new PerpError("INVALID_PARAMS", `TP/SL direction for a ${positionSide} position: ${bad.join("; ")}`, {
|
|
297
|
+
remediation: positionSide === "long" ? "For a long: --tp above and --sl below the reference price" : "For a short: --tp below and --sl above the reference price",
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** The venue's current mark price for `symbol` (PRICE_STALE when it is not published / not positive). */
|
|
302
|
+
async function markPriceOf(adapter, symbol) {
|
|
303
|
+
const m = (await adapter.getMarkets()).find(x => symbolMatch(x.symbol, symbol));
|
|
304
|
+
const px = m === undefined ? NaN : Number(m.markPrice);
|
|
305
|
+
if (!(px > 0) || !Number.isFinite(px)) {
|
|
306
|
+
throw new PerpError("PRICE_STALE", `${adapter.name} ${symbol}: no mark price to check the TP/SL direction against`, { exchange: adapter.name });
|
|
307
|
+
}
|
|
308
|
+
return px;
|
|
309
|
+
}
|
|
310
|
+
function positionTpSlAdapter(adapter) {
|
|
311
|
+
const a = adapter;
|
|
312
|
+
if (typeof a.positionTpSlOrders !== "function" || typeof a.setPositionTpSl !== "function") {
|
|
313
|
+
throw new PerpError("NOT_IMPLEMENTED", `${adapter.name} adapter has no position-level TP/SL methods`);
|
|
314
|
+
}
|
|
315
|
+
return adapter;
|
|
316
|
+
}
|
|
317
|
+
/** Refuse when position-level TP/SL orders already exist (never overwrite, §13.10 #12). */
|
|
318
|
+
async function refuseExistingPositionTpSl(adapter, def, symbol) {
|
|
319
|
+
const existing = await adapter.positionTpSlOrders(symbol);
|
|
320
|
+
if (existing.length > 0) {
|
|
321
|
+
throw new PerpError("INVALID_PARAMS", `${def.displayName} ${symbol} already has position TP/SL (${existing.map(o => `${o.type} #${o.orderId}${o.stopPrice !== null ? ` @ ${o.stopPrice}` : ""}`).join(", ")}); refusing to overwrite it`, {
|
|
322
|
+
exchange: def.id,
|
|
323
|
+
remediation: `Cancel the existing TP/SL first (perp trade cancel-stop ${symbol} <orderId> -e ${def.id}), then set the new one`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function legLabel(l) {
|
|
328
|
+
return l.kind === "tp" ? `TP @ ${l.trigger}` : l.kind === "sl" ? `SL @ ${l.trigger}` : `position TP/SL ${l.trigger}`;
|
|
329
|
+
}
|
|
330
|
+
// ── Scaled orders (scale-tp / scale-in) ──
|
|
331
|
+
/**
|
|
332
|
+
* Sizes of scaled levels on the venue lot grid: the total is floored to `step`, each level is
|
|
333
|
+
* floor(total × pct / 100) lots and the LAST level takes the remainder — so every level is a
|
|
334
|
+
* multiple of the lot and the levels sum to the floored total. An off-lot level is refused by the
|
|
335
|
+
* venue (Pacifica 422 "Limit order amount 0.00205 is not a multiple of lot size"; Lighter 21706
|
|
336
|
+
* "invalid order base or quote amount" for 0.01475 TSLA at 4 size decimals). A level of zero lots
|
|
337
|
+
* → SIZE_TOO_SMALL.
|
|
338
|
+
*/
|
|
339
|
+
export function scaleLevelSizes(total, pcts, step) {
|
|
340
|
+
const units = exactUnits(floorSize(total, step), step);
|
|
341
|
+
if (units === null)
|
|
342
|
+
throw new PerpError("INVALID_PARAMS", `size ${total} cannot be placed on the lot ${step}`);
|
|
343
|
+
const out = [];
|
|
344
|
+
let used = 0n;
|
|
345
|
+
pcts.forEach((pct, i) => {
|
|
346
|
+
const u = i === pcts.length - 1 ? units - used : BigInt(Math.floor(Number(units) * pct / 100 + 1e-9));
|
|
347
|
+
used += u;
|
|
348
|
+
out.push(u);
|
|
349
|
+
});
|
|
350
|
+
const zero = out.findIndex(u => u <= 0n);
|
|
351
|
+
if (zero !== -1) {
|
|
352
|
+
throw new PerpError("SIZE_TOO_SMALL", `Level ${zero + 1} (${pcts[zero]}% of ${total}) is below the lot ${step}; nothing was sent`, {
|
|
353
|
+
remediation: `Use fewer levels or a larger total: every level must be at least one lot (${step})`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return out.map(u => unitsToString(u, step));
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Refuse (SIZE_TOO_SMALL, nothing sent) a scaled order with a level under the venue's published
|
|
360
|
+
* minimum order size / notional (VenueMarket.minSize / minNotional; null = none published on this
|
|
361
|
+
* data path → not checked). Reduce-only levels are held to it too: no venue's docs state an
|
|
362
|
+
* exemption (Pacifica market-info: `min_order_size` "Minimum order size (denominated in USD)",
|
|
363
|
+
* no exception listed).
|
|
364
|
+
*/
|
|
365
|
+
export function requireScaleLevelMinimums(venue, symbol, levels, min) {
|
|
366
|
+
levels.forEach((l, i) => {
|
|
367
|
+
const notional = Number(l.size) * Number(l.price);
|
|
368
|
+
const underSize = min.minSize !== null && Number(l.size) < Number(min.minSize);
|
|
369
|
+
const underNotional = min.minNotional !== null && notional < Number(min.minNotional);
|
|
370
|
+
if (!underSize && !underNotional)
|
|
371
|
+
return;
|
|
372
|
+
throw new PerpError("SIZE_TOO_SMALL", `${venue} ${symbol}: level ${i + 1} (${l.size} @ ${l.price} = $${notional.toFixed(2)}) is below the venue minimum ${underSize ? `size ${min.minSize}` : `order value $${min.minNotional}`}; nothing was sent`, {
|
|
373
|
+
exchange: venue, level: i + 1, size: l.size, price: l.price, minSize: min.minSize, minNotional: min.minNotional,
|
|
374
|
+
remediation: "Use fewer levels (or a larger total) so that every level meets the venue minimum",
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The failure a scaled order reports unless every level was placed: any unknown outcome →
|
|
380
|
+
* ORDER_PENDING; every level definitely refused → ORDER_REJECTED; otherwise ORDER_PARTIAL.
|
|
381
|
+
* details list the placed / failed / unknown / not-sent levels.
|
|
382
|
+
*/
|
|
383
|
+
export function scaleIncompleteError(venue, what, levels) {
|
|
384
|
+
const by = (o) => levels.filter(l => l.outcome === o);
|
|
385
|
+
const placed = by("placed"), rejected = by("rejected"), unknown = by("unknown"), notSent = by("notSent");
|
|
386
|
+
if (rejected.length === 0 && unknown.length === 0)
|
|
387
|
+
return null;
|
|
388
|
+
const details = { exchange: venue, placed, failed: rejected, unknown, notSent };
|
|
389
|
+
const hint = `perp account orders -e ${venue}`;
|
|
390
|
+
if (unknown.length > 0) {
|
|
391
|
+
return new PerpError("ORDER_PENDING", `${venue} ${what}: the outcome of level ${levels.indexOf(unknown[0]) + 1} is unknown; ${placed.length} placed, ${rejected.length} refused, ${notSent.length} not sent`, {
|
|
392
|
+
...details, remediation: `Do NOT resubmit — a level may have been placed. Check open orders: ${hint}`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
if (placed.length === 0) {
|
|
396
|
+
return new PerpError("ORDER_REJECTED", `${venue} ${what}: every level was refused, nothing was placed — ${rejected.map(l => l.result.error).join("; ")}`, details);
|
|
397
|
+
}
|
|
398
|
+
return new PerpError("ORDER_PARTIAL", `${venue} ${what}: ${placed.length} level(s) placed, ${rejected.length} refused`, {
|
|
399
|
+
...details, remediation: `Some levels are resting. Inspect with: ${hint}, then place the missing levels or cancel the placed ones.`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Pacifica native TWAP (create / cancel) is refused when signed by an agent wallet: live mainnet
|
|
404
|
+
* 2026-10-05, POST /orders/twap/create with `agent_wallet` → 400 signature_verification_failed
|
|
405
|
+
* ("signature does not match signer and canonical payload"), while the same request signed by
|
|
406
|
+
* the master was accepted (TWAP 121433, then cancelled). The REST docs have no TWAP page and the
|
|
407
|
+
* official python-sdk example signs with the account key only.
|
|
408
|
+
*/
|
|
409
|
+
function requirePacificaTwapSigner(adapter, what) {
|
|
410
|
+
const tier = adapter.activeSignerTier;
|
|
411
|
+
if (tier === "agent") {
|
|
412
|
+
throw unsupported("Pacifica", "agentSignedTwap", `the venue rejects an agent-signed ${what} (signature_verification_failed); only the account's own key is accepted`, "Sign this command with the master wallet: add --no-agent (the master wallet must be on this computer)");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** Venues whose cancel-by-reference needs no symbol (Ondo: DELETE /orders/<id> or client id). */
|
|
416
|
+
const SYMBOLLESS_CANCEL_VENUES = new Set(["ondo"]);
|
|
417
|
+
/** UUID (Pacifica / QFEX / Ondo order ids) — 8-4-4-4-12 hex. */
|
|
418
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
419
|
+
/** One-arg `trade cancel <x>`: true when x is an order id (numeric, UUID or 0x-hex), never a symbol. */
|
|
420
|
+
export function looksLikeOrderId(x) {
|
|
421
|
+
return /^\d+$/.test(x) || UUID_RE.test(x) || /^0x[0-9a-f]+$/i.test(x);
|
|
422
|
+
}
|
|
9
423
|
export function registerTradeCommands(program, getAdapter, isJson, isDryRun = () => false, getAdapterForExchange) {
|
|
10
424
|
/** Guard: if --dry-run is active, log the intended action and return without executing. */
|
|
11
425
|
function dryRunGuard(action, details) {
|
|
12
426
|
if (!isDryRun())
|
|
13
427
|
return false;
|
|
14
|
-
const info = { dryRun: true, action, ...details, timestamp: new Date().toISOString() };
|
|
428
|
+
const info = { dryRun: true, sent: false, action, ...details, timestamp: new Date().toISOString() };
|
|
15
429
|
if (isJson()) {
|
|
16
430
|
printJson(jsonOk(info));
|
|
17
431
|
}
|
|
18
432
|
else {
|
|
19
433
|
console.log(chalk.yellow(`\n [DRY RUN] Would ${action}:`));
|
|
20
434
|
for (const [k, v] of Object.entries(details)) {
|
|
21
|
-
console.log(chalk.gray(` ${k}: ${v}`));
|
|
435
|
+
console.log(chalk.gray(` ${k}: ${v !== null && typeof v === "object" ? JSON.stringify(v) : v}`));
|
|
22
436
|
}
|
|
23
437
|
console.log();
|
|
24
438
|
}
|
|
@@ -34,182 +448,469 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
34
448
|
});
|
|
35
449
|
return true;
|
|
36
450
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
451
|
+
/**
|
|
452
|
+
* dryRunGuard for a perp order: adds the builder fee the live order would carry
|
|
453
|
+
* (`builderFee`: { venue, identity, feeBps, feeKind } | null, plus `builderNote` when a
|
|
454
|
+
* configured fee would not be attached).
|
|
455
|
+
*/
|
|
456
|
+
async function orderDryRunGuard(action, adapter, symbol, details) {
|
|
457
|
+
if (!isDryRun())
|
|
458
|
+
return false;
|
|
459
|
+
const d = await builderPlanFor(adapter, symbol);
|
|
460
|
+
return dryRunGuard(action, { ...details, builderFee: d.applied, ...(d.note !== null ? { builderNote: d.note } : {}) });
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Pre-send gate of every order path that can open or flip a position, run BEFORE the dry-run
|
|
464
|
+
* return (a dry-run reports the refusal the live order would hit):
|
|
465
|
+
* - quarantine (non-reduce-only): an earlier unknown outcome on the symbol blocks new orders
|
|
466
|
+
* until a human clears it — the same gate as MCP (mcpPreTradeGate) and `arb exec`; --force
|
|
467
|
+
* does NOT bypass it (--force is the local risk-limit override only);
|
|
468
|
+
* - local risk limits (enforceOrderRisk; --force bypasses, reduce-only exempt).
|
|
469
|
+
*/
|
|
470
|
+
/** Opening orders on a quarantined symbol are refused (reduce-only passes); --force never bypasses it. */
|
|
471
|
+
function assertNotQuarantined(venue, symbol, reduceOnly) {
|
|
472
|
+
if (reduceOnly === true)
|
|
473
|
+
return;
|
|
474
|
+
const q = quarantineEntryFor(symbol, (m) => { if (!isJson())
|
|
475
|
+
console.error(m); });
|
|
476
|
+
if (q !== null) {
|
|
477
|
+
throw new PerpError("RISK_VIOLATION", `${symbol} is quarantined by ${q.strategy}: ${q.reason}`, {
|
|
478
|
+
exchange: venue,
|
|
479
|
+
remediation: `${q.remediation} — only reduce-only orders are accepted until a human clears it (perp strategy quarantine-clear ${symbol})`,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async function openingOrderGate(adapter, p) {
|
|
484
|
+
assertNotQuarantined(adapter.name, p.symbol, p.reduceOnly);
|
|
485
|
+
await enforceOrderRisk(adapter, p);
|
|
486
|
+
}
|
|
487
|
+
/** Mark price of `sym` (split sizing / risk); no usable mark → PRICE_STALE, nothing sent. */
|
|
488
|
+
async function splitMarkPrice(adapter, sym) {
|
|
489
|
+
const market = (await adapter.getMarkets()).find(m => symbolMatch(m.symbol, sym));
|
|
490
|
+
const markPrice = market !== undefined ? Number(market.markPrice) : NaN;
|
|
491
|
+
if (!(markPrice > 0)) {
|
|
492
|
+
throw new PerpError("PRICE_STALE", `${adapter.name}: no usable mark price for ${sym} — cannot size the split; nothing was sent`, { exchange: adapter.name });
|
|
493
|
+
}
|
|
494
|
+
return markPrice;
|
|
495
|
+
}
|
|
496
|
+
const network = () => networkOf(program);
|
|
497
|
+
const v2Of = (adapter) => tradingV2Of(adapter, network());
|
|
498
|
+
/**
|
|
499
|
+
* Validate every level of a scaled order BEFORE anything is sent (venue TIF / reduce-only rules,
|
|
500
|
+
* e.g. Ondo refuses a resting reduce-only order; venue minimum per level) and return the requests.
|
|
501
|
+
*/
|
|
502
|
+
async function scaleRequests(adapter, sym, side, reduceOnly, levels) {
|
|
503
|
+
const { def, caps } = tradingVenueOf(adapter);
|
|
504
|
+
const tif = validateLegacyTif(def, caps, DEFAULT_TIF);
|
|
505
|
+
const reqs = levels.map((l) => ({
|
|
506
|
+
symbol: sym, side, type: "limit", size: l.size, price: l.price, triggerPrice: null, triggerSource: null, tif,
|
|
507
|
+
reduceOnly, clientOrderId: v2ClientOrderId(def, caps, undefined), slippage: null, tpsl: null,
|
|
508
|
+
}));
|
|
509
|
+
for (const req of reqs)
|
|
510
|
+
validateOrderRequest(def, caps, req);
|
|
511
|
+
const { min } = await venueOrderRules(adapter, sym);
|
|
512
|
+
if (min !== null)
|
|
513
|
+
requireScaleLevelMinimums(def.id, sym, levels, min);
|
|
514
|
+
return reqs;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The venue's published tick and order minimums for `sym` (VenueMarket). A HIP-3 dex market
|
|
518
|
+
* ("xyz:NVDA") is not in the venue's public market list (native dex only): no tick there, but
|
|
519
|
+
* Hyperliquid's minimum order value is venue-wide, so it still applies. `min` null = the
|
|
520
|
+
* market has no VenueMarket row and the venue no venue-wide minimum → not checked.
|
|
521
|
+
*/
|
|
522
|
+
async function venueOrderRules(adapter, sym) {
|
|
523
|
+
const { def } = tradingVenueOf(adapter);
|
|
524
|
+
const vm = (await v2Of(adapter).getVenueMarkets()).find(m => symbolMatch(m.symbol, sym));
|
|
525
|
+
if (vm !== undefined)
|
|
526
|
+
return { tickSize: vm.tickSize, min: { minSize: vm.minSize, minNotional: vm.minNotional } };
|
|
527
|
+
return { tickSize: null, min: def.id === "hyperliquid" ? { minSize: null, minNotional: HL_MIN_ORDER_VALUE_USD } : null };
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Run a split order with the venue's lot / tick / minimums and report it. A run that did not
|
|
531
|
+
* fill completely is an error envelope (ORDER_REJECTED / ORDER_PENDING / ORDER_PARTIAL), never ok:true.
|
|
532
|
+
*/
|
|
533
|
+
async function runSplit(adapter, sym, side, totalSizeUsd, o) {
|
|
534
|
+
const { runSplitOrder, splitIncompleteError } = await import("../strategies/split-order.js");
|
|
535
|
+
const rules = await venueOrderRules(adapter, sym);
|
|
536
|
+
const result = await runSplitOrder(adapter, {
|
|
537
|
+
symbol: sym, side, totalSizeUsd, ...o,
|
|
538
|
+
stepSize: await lotStepOf(adapter, sym), tickSize: rules.tickSize,
|
|
539
|
+
minSize: rules.min !== null ? rules.min.minSize : null, minNotional: rules.min !== null ? rules.min.minNotional : null,
|
|
540
|
+
}, isJson() ? () => { } : (msg) => console.log(chalk.gray(` ${msg}`)));
|
|
541
|
+
const incomplete = splitIncompleteError(adapter.name, result);
|
|
542
|
+
if (incomplete !== null)
|
|
543
|
+
throw incomplete;
|
|
544
|
+
if (isJson())
|
|
545
|
+
return printJson(jsonOk(result));
|
|
546
|
+
console.log(`\n ${chalk.green(result.status.toUpperCase())} | ${result.slices.length} slices`);
|
|
547
|
+
console.log(` Filled: ${result.filledSize} / ${result.requestedSize} ${sym}${result.filledUsd !== null ? ` ($${formatUsd(result.filledUsd)} of $${formatUsd(result.requestedUsd)})` : ""}`);
|
|
548
|
+
console.log(` Avg Price: ${result.avgPrice !== null ? `$${result.avgPrice}` : "not reported by the venue"}`);
|
|
549
|
+
if (result.totalSlippagePct !== null)
|
|
550
|
+
console.log(` Slippage: ${result.totalSlippagePct.toFixed(3)}%`);
|
|
551
|
+
console.log(` Runtime: ${result.runtime.toFixed(1)}s\n`);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Send the levels of a scaled order one by one through the uniform order path. A level whose
|
|
555
|
+
* outcome is unknown (transport failure after sending, or a non-final ack) stops the run — the
|
|
556
|
+
* remaining levels are not sent. Throws scaleIncompleteError unless every level was placed.
|
|
557
|
+
*/
|
|
558
|
+
async function placeScaleLevels(adapter, action, sym, side, plan, reqs) {
|
|
559
|
+
const { caps } = tradingVenueOf(adapter);
|
|
560
|
+
const out = [];
|
|
561
|
+
let stopped = false;
|
|
562
|
+
for (const [i, level] of plan.entries()) {
|
|
563
|
+
if (stopped) {
|
|
564
|
+
out.push({ ...level, outcome: "notSent", result: { error: "not sent: an earlier level's outcome is unknown" } });
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const meta = { action, pct: level.pct, reduceOnly: reqs[i].reduceOnly };
|
|
568
|
+
const log = (status, error) => logExecution({
|
|
569
|
+
type: "limit_order", exchange: adapter.name, symbol: sym, side, size: level.size, price: level.price,
|
|
570
|
+
status, dryRun: false, ...(error !== undefined ? { error } : {}), meta,
|
|
571
|
+
});
|
|
572
|
+
try {
|
|
573
|
+
const r = await placeV2(v2Of(adapter), caps, reqs[i]);
|
|
574
|
+
if (r.finality !== "final") {
|
|
575
|
+
stopped = true;
|
|
576
|
+
log("unverified", "the venue acknowledged the order without a final status");
|
|
577
|
+
out.push({ ...level, outcome: "unknown", result: orderResultJson(r) });
|
|
578
|
+
}
|
|
579
|
+
else if (r.status === "rejected" || r.status === "cancelled") {
|
|
580
|
+
log("failed", `the venue ${r.status} the order`);
|
|
581
|
+
out.push({ ...level, outcome: "rejected", result: { error: `the venue ${r.status} the order${r.orderId !== null ? ` ${r.orderId}` : ""}` } });
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
log("success");
|
|
585
|
+
out.push({ ...level, outcome: "placed", result: orderResultJson(r) });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
const msg = extractErrorMessage(err);
|
|
590
|
+
const definite = isDefiniteOrderFailure(err);
|
|
591
|
+
if (!definite)
|
|
592
|
+
stopped = true;
|
|
593
|
+
log(definite ? "failed" : "unverified", msg);
|
|
594
|
+
out.push({ ...level, outcome: definite ? "rejected" : "unknown", result: { error: msg } });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
/** Print the per-level table of a scaled order (human mode). */
|
|
600
|
+
function printScaleLevels(title, sym, levels) {
|
|
601
|
+
console.log(chalk.green(`\n ${title}\n`));
|
|
602
|
+
for (const l of levels) {
|
|
603
|
+
const status = l.outcome === "placed" ? chalk.green("OK") : l.outcome === "rejected" ? chalk.red("FAILED") : l.outcome === "unknown" ? chalk.yellow("UNKNOWN") : chalk.gray("NOT SENT");
|
|
604
|
+
console.log(` ${status} $${l.price} — ${l.pct}% (${l.size} ${sym})${"error" in l.result ? chalk.gray(` — ${String(l.result.error)}`) : ""}`);
|
|
605
|
+
}
|
|
606
|
+
console.log();
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Validate an entry's --tp/--sl before anything is sent (design §13.10 #12): the venue has
|
|
610
|
+
* TP/SL, the entry opens (not reduce-only), separateOrders / positionLevel venues need an
|
|
611
|
+
* immediate entry (market or IOC — a resting entry has no verified fill to size the legs to),
|
|
612
|
+
* and the prices sit on the right sides of `ref`.
|
|
613
|
+
*/
|
|
614
|
+
function prevalidateEntryTpSl(def, caps, side, reduceOnly, immediate, ref, refLabel, prices) {
|
|
615
|
+
if (caps.tpsl === null) {
|
|
616
|
+
throw unsupported(def.displayName, "tpsl", "the venue has no TP/SL orders", `Use stop orders: perp trade stop -e ${def.id} ...`);
|
|
617
|
+
}
|
|
618
|
+
if (reduceOnly) {
|
|
619
|
+
throw new PerpError("INVALID_PARAMS", "--tp/--sl protect the position an entry opens; they cannot be combined with --reduce-only");
|
|
620
|
+
}
|
|
621
|
+
if (!immediate && (caps.tpsl === "separateOrders" || caps.tpsl === "positionLevel")) {
|
|
622
|
+
throw unsupported(def.displayName, "tpslRestingEntry", "TP/SL legs are sized to the entry's verified fill, and a resting entry has no fill yet", `Use a market (or --tif IOC) entry with --tp/--sl, or place the entry and later run: perp trade tpsl <symbol> <long|short> --tp <p> --sl <p> -e ${def.id}`);
|
|
623
|
+
}
|
|
624
|
+
checkTpSlDirection(side === "buy" ? "long" : "short", ref, refLabel, prices);
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Place an entry carrying --tp/--sl — one meaning on every venue: protect THIS entry's
|
|
628
|
+
* verified fill (design §13.5, §13.10 #12).
|
|
629
|
+
* - attached (ondo) / batchGroup (arcus): the venue attaches the legs to the entry.
|
|
630
|
+
* - separateOrders (hl, lighter, aster): entry fill VERIFIED (placePerpVerified: venue-final
|
|
631
|
+
* report cross-checked with the position delta), then reduce-only TP/SL triggers sized to it.
|
|
632
|
+
* - positionLevel (pacifica): entry fill verified, then position TP/SL (refused when TP/SL
|
|
633
|
+
* or a position already exists — the position-level TP/SL would cover more than this fill).
|
|
634
|
+
* Entry outcome unknown → ORDER_PENDING, no legs ("position may be unprotected"); entry ok and
|
|
635
|
+
* a leg fails → ORDER_PARTIAL (not retryable) with the entry result and the legs placed.
|
|
636
|
+
*/
|
|
637
|
+
async function placeProtectedEntry(adapter, def, caps, req, prices, slippage) {
|
|
638
|
+
const v2 = v2Of(adapter);
|
|
639
|
+
const sym = req.symbol;
|
|
640
|
+
const positionSide = req.side === "buy" ? "long" : "short";
|
|
641
|
+
// An entry against an existing opposite position reduces (or flips) it instead of opening:
|
|
642
|
+
// TP/SL sized to its fill would protect a position that does not exist. Refused on every venue.
|
|
643
|
+
invalidateCache("acct");
|
|
644
|
+
const existing = (await adapter.getPositions()).filter(p => symbolMatch(p.symbol, sym) && Number(p.size) !== 0);
|
|
645
|
+
const opposite = existing.find(p => p.side !== positionSide);
|
|
646
|
+
if (opposite !== undefined) {
|
|
647
|
+
throw new PerpError("INVALID_PARAMS", `--tp/--sl protect the position an entry opens, but this ${req.side} would reduce the existing ${opposite.side} ${opposite.size.replace(/^-/, "")} ${sym} position`, {
|
|
648
|
+
remediation: `Close or reduce the ${opposite.side} first (perp trade close ${sym} -e ${def.id}), or place the entry without --tp/--sl`,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
if (caps.tpsl === "attached" || caps.tpsl === "batchGroup") {
|
|
652
|
+
const entry = await placeV2(v2, caps, { ...req, tpsl: prices });
|
|
653
|
+
return { entry, filledSize: entry.filledSize ?? "unknown", method: caps.tpsl, legs: [] };
|
|
654
|
+
}
|
|
655
|
+
const closeSide = req.side === "buy" ? "sell" : "buy";
|
|
656
|
+
const tpslHint = `perp trade tpsl ${sym} ${positionSide}${prices.tp !== null ? ` --tp ${prices.tp}` : ""}${prices.sl !== null ? ` --sl ${prices.sl}` : ""} -e ${def.id}`;
|
|
657
|
+
let pacifica = null;
|
|
658
|
+
if (caps.tpsl === "positionLevel") {
|
|
659
|
+
pacifica = positionTpSlAdapter(adapter);
|
|
660
|
+
if (existing.length > 0) {
|
|
661
|
+
throw unsupported(def.displayName, "tpslPositionLevel", `TP/SL is position-level and would also cover the existing ${sym} position, not just this entry's fill`, `Place the entry without --tp/--sl, then run: perp trade tpsl ${sym} <long|short> --tp <p> --sl <p> -e ${def.id}`);
|
|
662
|
+
}
|
|
663
|
+
await refuseExistingPositionTpSl(pacifica, def, sym);
|
|
664
|
+
}
|
|
665
|
+
// Lot step from the venue market info: the entry is sent floored to it, its fill is judged
|
|
666
|
+
// against that sent size (half-lot tolerance) and the TP/SL legs are sized on the lot grid.
|
|
667
|
+
const step = await lotStepOf(adapter, sym);
|
|
668
|
+
const sent = { ...req, size: floorSize(req.size, step) };
|
|
669
|
+
const holder = { entry: null };
|
|
670
|
+
let fill;
|
|
671
|
+
try {
|
|
672
|
+
fill = await placePerpVerified(adapter, def.id, sym, sent.side, sent.size, {
|
|
673
|
+
reduceOnly: false,
|
|
674
|
+
stepSize: step,
|
|
675
|
+
place: async () => {
|
|
676
|
+
holder.entry = await placeV2(v2, caps, sent);
|
|
677
|
+
return holder.entry.venueRaw;
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
catch (e) {
|
|
682
|
+
if (isOutcomeUnknown(e)) {
|
|
683
|
+
throw new PerpError("ORDER_PENDING", `${extractErrorMessage(e)} — no TP/SL was placed: the position may be unprotected`, {
|
|
684
|
+
exchange: def.id,
|
|
685
|
+
entry: holder.entry !== null ? orderResultJson(holder.entry) : null,
|
|
686
|
+
remediation: `Do NOT resubmit. Check the position (perp account positions -e ${def.id}); once confirmed, protect it with: ${tpslHint}`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
throw e;
|
|
690
|
+
}
|
|
691
|
+
const entry = holder.entry;
|
|
692
|
+
const filled = floorToStep(fill.filledSize, step);
|
|
693
|
+
const legs = [];
|
|
694
|
+
const partial = (failed, e) => new PerpError("ORDER_PARTIAL", `Entry filled ${filled} ${sym} on ${def.displayName}, but ${failed} failed: ${extractErrorMessage(e)} — ${legs.length > 0 ? `placed: ${legs.map(legLabel).join(", ")}` : "no TP/SL placed"}`, {
|
|
695
|
+
exchange: def.id,
|
|
696
|
+
entry: entry !== null ? orderResultJson(entry) : null,
|
|
697
|
+
filledSize: filled,
|
|
698
|
+
legsPlaced: legs.map(l => ({ kind: l.kind, trigger: l.trigger, orderId: l.result !== null ? l.result.orderId : null })),
|
|
699
|
+
failedLeg: failed,
|
|
700
|
+
remediation: `Do NOT re-enter — the position is ${legs.length > 0 ? "only partly" : "NOT"} protected. Set the missing protection with: ${tpslHint}`,
|
|
701
|
+
});
|
|
702
|
+
if (!(Number(filled) > 0))
|
|
703
|
+
throw partial("TP/SL sizing", new Error(`verified fill ${fill.filledSize} is below one lot (${step})`));
|
|
704
|
+
if (pacifica !== null) {
|
|
705
|
+
const label = `tp ${prices.tp ?? "-"} / sl ${prices.sl ?? "-"}`;
|
|
706
|
+
try {
|
|
707
|
+
const raw = await pacifica.setPositionTpSl(sym, positionSide, { tp: prices.tp, tpLimit: null, sl: prices.sl });
|
|
708
|
+
legs.push({ kind: "tpsl", trigger: label, result: null, venueRaw: raw });
|
|
709
|
+
}
|
|
710
|
+
catch (e) {
|
|
711
|
+
throw partial(`position TP/SL (${label})`, e);
|
|
712
|
+
}
|
|
713
|
+
return { entry, filledSize: filled, method: "positionLevel", legs };
|
|
714
|
+
}
|
|
715
|
+
for (const [kind, trigger] of [["tp", prices.tp], ["sl", prices.sl]]) {
|
|
716
|
+
if (trigger === null)
|
|
717
|
+
continue;
|
|
718
|
+
try {
|
|
719
|
+
const r = await placeTpSlLeg(adapter, network(), { symbol: sym, closeSide, size: filled, trigger, kind, limitPrice: null, slippage });
|
|
720
|
+
legs.push({ kind, trigger, result: r, venueRaw: r.venueRaw });
|
|
721
|
+
}
|
|
722
|
+
catch (e) {
|
|
723
|
+
throw partial(legLabel({ kind, trigger }), e);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return { entry, filledSize: filled, method: "separateOrders", legs };
|
|
727
|
+
}
|
|
728
|
+
/** Human / JSON output of a protected entry. */
|
|
729
|
+
function printProtectedEntry(header, out, extra) {
|
|
730
|
+
const legsJson = out.legs.map(l => ({ kind: l.kind, trigger: l.trigger, ...(l.result !== null ? orderResultJson(l.result) : { venueRaw: l.venueRaw }) }));
|
|
731
|
+
if (isJson()) {
|
|
732
|
+
printJson(jsonOk({
|
|
733
|
+
...(out.entry !== null ? orderResultJson(out.entry, extra) : { entry: null, ...extra }),
|
|
734
|
+
verifiedFilledSize: out.filledSize,
|
|
735
|
+
tpsl: { method: out.method, legs: legsJson },
|
|
736
|
+
}));
|
|
74
737
|
return;
|
|
75
738
|
}
|
|
76
|
-
|
|
77
|
-
|
|
739
|
+
if (out.entry !== null)
|
|
740
|
+
printOrder(header, out.entry);
|
|
741
|
+
else
|
|
742
|
+
console.log(chalk.green(`\n ${header}\n`));
|
|
743
|
+
console.log(chalk.green(` Verified fill: ${out.filledSize} · TP/SL (${out.method})`));
|
|
744
|
+
if (out.legs.length === 0)
|
|
745
|
+
console.log(chalk.gray(" Legs attached to the entry by the venue (see execution notes)"));
|
|
746
|
+
for (const l of out.legs) {
|
|
747
|
+
console.log(chalk.green(` ${legLabel(l)}`));
|
|
748
|
+
if (l.result !== null)
|
|
749
|
+
for (const line of formatOrderResult(l.result))
|
|
750
|
+
console.log(line);
|
|
751
|
+
}
|
|
752
|
+
console.log();
|
|
753
|
+
}
|
|
754
|
+
/** One market-order path for trade market / buy / sell (every venue via TradingV2; --smart = legacy smart-order). */
|
|
755
|
+
async function runMarketOrder(sym, side, rawSize, opts) {
|
|
756
|
+
// One parsed size for the risk check and the order ("1,000" / "abc" refused before anything else).
|
|
757
|
+
const size = parsePositiveDecimal(rawSize, "size");
|
|
758
|
+
const slippageFlag = parseSlippageBpsFlag(opts.slippageBps);
|
|
759
|
+
if (opts.clientId && isOrderDuplicate(opts.clientId)) {
|
|
78
760
|
if (isJson())
|
|
79
|
-
return printJson(jsonOk({ duplicate: true, clientOrderId: clientId, message: "Order already submitted" }));
|
|
80
|
-
console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${clientId}). Skipping.\n`));
|
|
761
|
+
return printJson(jsonOk({ duplicate: true, clientOrderId: opts.clientId, message: "Order already submitted" }));
|
|
762
|
+
console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${opts.clientId}). Skipping.\n`));
|
|
81
763
|
return;
|
|
82
764
|
}
|
|
83
765
|
const adapter = await getAdapter();
|
|
84
|
-
|
|
766
|
+
const { def: venueDef, caps: venueCaps } = tradingVenueOf(adapter);
|
|
767
|
+
const clientId = opts.autoId ? autoClientId(venueDef, venueCaps, "market") : opts.clientId;
|
|
768
|
+
if (opts.smart && clientId !== undefined) {
|
|
769
|
+
throw unsupported(venueDef.displayName, "smartClientOrderId", "--smart orders are sent without a client order id, so the id would be dropped", "Omit --client-id / --auto-id, or omit --smart");
|
|
770
|
+
}
|
|
771
|
+
const reduceOnly = opts.reduceOnly === true;
|
|
772
|
+
const slippage = resolveSlippage(venueDef.id, slippageFlag);
|
|
773
|
+
if (opts.smart && !isLegacyOrderVenue(venueDef)) {
|
|
774
|
+
throw unsupported(venueDef.displayName, "smartOrder", "--smart needs the legacy limit-order path", "Omit --smart");
|
|
775
|
+
}
|
|
776
|
+
const prices = entryTpSl(opts);
|
|
777
|
+
if (prices !== null) {
|
|
778
|
+
if (opts.smart)
|
|
779
|
+
throw unsupported(venueDef.displayName, "tpslSmart", "--smart entries do not carry --tp/--sl", "Omit --smart");
|
|
780
|
+
refuseGatedReduceOnly(adapter);
|
|
781
|
+
refuseGatedStops(adapter);
|
|
782
|
+
prevalidateEntryTpSl(venueDef, venueCaps, side, reduceOnly, true, await markPriceOf(adapter, sym), "mark price", prices);
|
|
783
|
+
}
|
|
784
|
+
const req = opts.smart ? null : v2MarketRequest(venueDef, venueCaps, sym, side, size, reduceOnly, slippage, clientId);
|
|
785
|
+
// Read-only quarantine + local risk check BEFORE the dry-run return (review L5): a dry-run reports
|
|
786
|
+
// the same RISK_VIOLATION the live order would hit. --force skips the risk limits in both modes.
|
|
787
|
+
await openingOrderGate(adapter, { symbol: sym, size: Number(size), reduceOnly: opts.reduceOnly, force: opts.force });
|
|
788
|
+
if (await orderDryRunGuard("market_order", adapter, sym, { exchange: adapter.name, symbol: sym, side, size, smart: !!opts.smart, ...(prices !== null ? { tp: prices.tp ?? "none", sl: prices.sl ?? "none" } : {}) }))
|
|
85
789
|
return;
|
|
86
790
|
if (clientId) {
|
|
87
791
|
logClientId({
|
|
88
792
|
clientOrderId: clientId, exchange: adapter.name,
|
|
89
|
-
symbol:
|
|
793
|
+
symbol: sym, side, size, type: "market",
|
|
90
794
|
status: "pending", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
91
795
|
});
|
|
92
796
|
}
|
|
93
|
-
|
|
797
|
+
const meta = { ...(clientId ? { clientOrderId: clientId } : {}), ...(opts.smart ? { smart: true } : {}) };
|
|
798
|
+
let placed = null;
|
|
799
|
+
let smartResult = null;
|
|
800
|
+
let protectedOut = null;
|
|
94
801
|
try {
|
|
95
|
-
if (
|
|
96
|
-
const sr = await smartOrder(adapter,
|
|
97
|
-
|
|
802
|
+
if (req === null) {
|
|
803
|
+
const sr = await smartOrder(adapter, sym, side, size, { reduceOnly, slippage });
|
|
804
|
+
smartResult = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
|
|
805
|
+
}
|
|
806
|
+
else if (prices !== null) {
|
|
807
|
+
protectedOut = await placeProtectedEntry(adapter, venueDef, venueCaps, req, prices, slippage);
|
|
808
|
+
placed = protectedOut.entry;
|
|
98
809
|
}
|
|
99
810
|
else {
|
|
100
|
-
|
|
811
|
+
placed = await placeV2(v2Of(adapter), venueCaps, req);
|
|
101
812
|
}
|
|
102
|
-
logExecution({
|
|
103
|
-
type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(),
|
|
104
|
-
side: s, size, status: "success", dryRun: false,
|
|
105
|
-
meta: { ...(clientId ? { clientOrderId: clientId } : {}), ...(opts.smart ? { smart: true } : {}) },
|
|
106
|
-
});
|
|
813
|
+
logExecution({ type: "market_order", exchange: adapter.name, symbol: sym, side, size, status: "success", dryRun: false, meta });
|
|
107
814
|
}
|
|
108
815
|
catch (err) {
|
|
109
|
-
logExecution({
|
|
110
|
-
type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(),
|
|
111
|
-
side: s, size, status: "failed", dryRun: false,
|
|
112
|
-
error: extractErrorMessage(err),
|
|
113
|
-
meta: { ...(clientId ? { clientOrderId: clientId } : {}), ...(opts.smart ? { smart: true } : {}) },
|
|
114
|
-
});
|
|
816
|
+
logExecution({ type: "market_order", exchange: adapter.name, symbol: sym, side, size, status: isOutcomeUnknown(err) ? "unverified" : "failed", dryRun: false, error: extractErrorMessage(err), meta });
|
|
115
817
|
throw err;
|
|
116
818
|
}
|
|
117
819
|
if (clientId) {
|
|
118
820
|
logClientId({
|
|
119
821
|
clientOrderId: clientId, exchange: adapter.name,
|
|
120
|
-
symbol:
|
|
822
|
+
symbol: sym, side, size, type: "market",
|
|
121
823
|
status: "submitted", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
122
824
|
});
|
|
123
825
|
}
|
|
826
|
+
const header = `Market ${side.toUpperCase()} ${size} ${sym} on ${adapter.name}${opts.smart ? " (smart)" : ""}${clientId ? ` (id: ${clientId})` : ""}`;
|
|
827
|
+
if (protectedOut !== null)
|
|
828
|
+
return printProtectedEntry(header, protectedOut, placed !== null ? trackedId(placed, clientId) : undefined);
|
|
829
|
+
if (placed === null) {
|
|
830
|
+
// --smart keeps the raw smart-order result shape.
|
|
831
|
+
if (isJson())
|
|
832
|
+
return printJson(jsonOk(clientId ? { ...smartResult, clientOrderId: clientId } : smartResult));
|
|
833
|
+
console.log(chalk.green(`\n ${header} placed.\n`));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
124
836
|
if (isJson())
|
|
125
|
-
return printJson(jsonOk(
|
|
126
|
-
|
|
837
|
+
return printJson(jsonOk(orderResultJson(placed, trackedId(placed, clientId))));
|
|
838
|
+
printOrder(header, placed);
|
|
839
|
+
}
|
|
840
|
+
const trade = program.command("trade").description("Trading commands");
|
|
841
|
+
// === Generic commands (all exchanges) ===
|
|
842
|
+
supportsDryRun(trade.command("market <symbol> <side> <size>"))
|
|
843
|
+
.description("Place a market order (side: buy/sell)")
|
|
844
|
+
.option("--reduce-only", "Reduce only order")
|
|
845
|
+
.option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
|
|
846
|
+
.option("--split", "Use orderbook-aware split execution for large orders")
|
|
847
|
+
.option("--max-slippage <pct>", "--split only: max price impact per split slice (%) — not the market-order slippage bound", "0.3")
|
|
848
|
+
.option("--client-id <id>", "Client order ID for idempotent tracking")
|
|
849
|
+
.option("--auto-id", "Auto-generate a client order ID")
|
|
850
|
+
.option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
|
|
851
|
+
.option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
|
|
852
|
+
.option("--tp <price>", "Take-profit trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
|
|
853
|
+
.option("--sl <price>", "Stop-loss trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
|
|
854
|
+
.action(async (symbol, side, size, opts) => {
|
|
855
|
+
const s = side.toLowerCase();
|
|
856
|
+
if (s !== "buy" && s !== "sell")
|
|
857
|
+
errorAndExit("Side must be buy or sell");
|
|
858
|
+
const sym = symbol.toUpperCase();
|
|
859
|
+
// ── Split execution branch ──
|
|
860
|
+
if (opts.split) {
|
|
861
|
+
// runSplitOrder has no reduce-only path — refuse rather than silently place opening orders.
|
|
862
|
+
if (opts.reduceOnly)
|
|
863
|
+
errorAndExit("--reduce-only is not supported with --split");
|
|
864
|
+
if (opts.tp !== undefined || opts.sl !== undefined)
|
|
865
|
+
throw new PerpError("INVALID_PARAMS", "--tp/--sl are not supported with --split (no single verified entry fill to protect)");
|
|
866
|
+
const baseSize = Number(parsePositiveDecimal(size, "size"));
|
|
867
|
+
const adapter = await getAdapter();
|
|
868
|
+
// No mark price → the base size cannot be valued in USD; never treated as a USD amount.
|
|
869
|
+
const markPrice = await splitMarkPrice(adapter, sym);
|
|
870
|
+
// Split slices are opening orders: quarantine + risk limits before the dry-run return (--force: limits only).
|
|
871
|
+
await openingOrderGate(adapter, { symbol: sym, size: baseSize, price: markPrice, force: opts.force });
|
|
872
|
+
if (await orderDryRunGuard("split-order", adapter, sym, { exchange: adapter.name, symbol: sym, side: s, size }))
|
|
873
|
+
return;
|
|
874
|
+
await runSplit(adapter, sym, s, baseSize * markPrice, { maxSlippagePct: parseFloat(opts.maxSlippage ?? "0.3") });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
await runMarketOrder(sym, s, size, opts);
|
|
127
878
|
});
|
|
128
879
|
// Shortcuts: trade buy / trade sell
|
|
129
|
-
trade
|
|
130
|
-
.command("buy <symbol> <size>")
|
|
880
|
+
supportsDryRun(trade.command("buy <symbol> <size>"))
|
|
131
881
|
.description("Market buy (shortcut for: trade market <symbol> buy <size>)")
|
|
132
|
-
.option("-s, --slippage <pct>", "Slippage percent", "1")
|
|
133
882
|
.option("--reduce-only", "Reduce only order")
|
|
134
883
|
.option("--smart", "Smart execution: IOC limit at best ask + 1 tick")
|
|
135
884
|
.option("--client-id <id>", "Client order ID")
|
|
136
885
|
.option("--auto-id", "Auto-generate client order ID")
|
|
886
|
+
.option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
|
|
887
|
+
.option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
|
|
888
|
+
.option("--tp <price>", "Take-profit trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
|
|
889
|
+
.option("--sl <price>", "Stop-loss trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
|
|
137
890
|
.action(async (symbol, size, opts) => {
|
|
138
|
-
|
|
139
|
-
if (clientId && isOrderDuplicate(clientId)) {
|
|
140
|
-
if (isJson())
|
|
141
|
-
return printJson(jsonOk({ duplicate: true, clientOrderId: clientId, message: "Order already submitted" }));
|
|
142
|
-
console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${clientId}). Skipping.\n`));
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
const adapter = await getAdapter();
|
|
146
|
-
if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, smart: !!opts.smart }))
|
|
147
|
-
return;
|
|
148
|
-
let result;
|
|
149
|
-
try {
|
|
150
|
-
if (opts.smart) {
|
|
151
|
-
const sr = await smartOrder(adapter, symbol.toUpperCase(), "buy", size);
|
|
152
|
-
result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
|
|
153
|
-
}
|
|
154
|
-
else {
|
|
155
|
-
result = await adapter.marketOrder(symbol.toUpperCase(), "buy", size);
|
|
156
|
-
}
|
|
157
|
-
logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, status: "success", dryRun: false, meta: opts.smart ? { smart: true } : undefined });
|
|
158
|
-
}
|
|
159
|
-
catch (err) {
|
|
160
|
-
logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, status: "failed", dryRun: false, error: extractErrorMessage(err) });
|
|
161
|
-
throw err;
|
|
162
|
-
}
|
|
163
|
-
if (isJson())
|
|
164
|
-
return printJson(jsonOk(clientId ? { ...result, clientOrderId: clientId } : result));
|
|
165
|
-
console.log(chalk.green(`\n Market BUY ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
|
|
891
|
+
await runMarketOrder(symbol.toUpperCase(), "buy", size, opts);
|
|
166
892
|
});
|
|
167
|
-
trade
|
|
168
|
-
.command("sell <symbol> <size>")
|
|
893
|
+
supportsDryRun(trade.command("sell <symbol> <size>"))
|
|
169
894
|
.description("Market sell (shortcut for: trade market <symbol> sell <size>)")
|
|
170
|
-
.option("-s, --slippage <pct>", "Slippage percent", "1")
|
|
171
895
|
.option("--reduce-only", "Reduce only order")
|
|
172
896
|
.option("--smart", "Smart execution: IOC limit at best bid - 1 tick")
|
|
173
897
|
.option("--client-id <id>", "Client order ID")
|
|
174
898
|
.option("--auto-id", "Auto-generate client order ID")
|
|
899
|
+
.option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
|
|
900
|
+
.option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
|
|
901
|
+
.option("--tp <price>", "Take-profit trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
|
|
902
|
+
.option("--sl <price>", "Stop-loss trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
|
|
175
903
|
.action(async (symbol, size, opts) => {
|
|
176
|
-
|
|
177
|
-
if (clientId && isOrderDuplicate(clientId)) {
|
|
178
|
-
if (isJson())
|
|
179
|
-
return printJson(jsonOk({ duplicate: true, clientOrderId: clientId, message: "Order already submitted" }));
|
|
180
|
-
console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${clientId}). Skipping.\n`));
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
const adapter = await getAdapter();
|
|
184
|
-
if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, smart: !!opts.smart }))
|
|
185
|
-
return;
|
|
186
|
-
let result;
|
|
187
|
-
try {
|
|
188
|
-
if (opts.smart) {
|
|
189
|
-
const sr = await smartOrder(adapter, symbol.toUpperCase(), "sell", size);
|
|
190
|
-
result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
result = await adapter.marketOrder(symbol.toUpperCase(), "sell", size);
|
|
194
|
-
}
|
|
195
|
-
logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, status: "success", dryRun: false, meta: opts.smart ? { smart: true } : undefined });
|
|
196
|
-
}
|
|
197
|
-
catch (err) {
|
|
198
|
-
logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, status: "failed", dryRun: false, error: extractErrorMessage(err) });
|
|
199
|
-
throw err;
|
|
200
|
-
}
|
|
201
|
-
if (isJson())
|
|
202
|
-
return printJson(jsonOk(clientId ? { ...result, clientOrderId: clientId } : result));
|
|
203
|
-
console.log(chalk.green(`\n Market SELL ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
|
|
904
|
+
await runMarketOrder(symbol.toUpperCase(), "sell", size, opts);
|
|
204
905
|
});
|
|
205
906
|
// ── Split Order (orderbook-aware) ──
|
|
206
|
-
trade
|
|
207
|
-
.command("split <symbol> <side> <usd>")
|
|
907
|
+
supportsDryRun(trade.command("split <symbol> <side> <usd>"))
|
|
208
908
|
.description("Orderbook-aware split execution — breaks large orders into slices based on depth")
|
|
209
909
|
.option("--max-slippage <pct>", "Max slippage per slice (%)", "0.3")
|
|
210
910
|
.option("--max-slices <n>", "Max number of slices", "10")
|
|
211
911
|
.option("--delay <ms>", "Delay between slices in ms", "1000")
|
|
212
912
|
.option("--min-slice <usd>", "Minimum slice size in USD", "100")
|
|
913
|
+
.option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
|
|
213
914
|
.action(async (symbol, side, usd, opts) => {
|
|
214
915
|
const sym = symbol.toUpperCase();
|
|
215
916
|
const orderSide = side.toLowerCase();
|
|
@@ -219,70 +920,96 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
219
920
|
if (isNaN(totalUsd) || totalUsd <= 0)
|
|
220
921
|
errorAndExit("USD amount must be > 0");
|
|
221
922
|
const adapter = await getAdapter();
|
|
222
|
-
|
|
923
|
+
// Opening orders: quarantine + risk limits on size = usd / mark before the dry-run return (--force: limits only).
|
|
924
|
+
const markPrice = await splitMarkPrice(adapter, sym);
|
|
925
|
+
await openingOrderGate(adapter, { symbol: sym, size: totalUsd / markPrice, price: markPrice, force: opts.force });
|
|
926
|
+
if (await orderDryRunGuard("split-order", adapter, sym, { exchange: adapter.name, symbol: sym, side: orderSide, totalUsd, ...opts }))
|
|
223
927
|
return;
|
|
224
|
-
|
|
225
|
-
const result = await runSplitOrder(adapter, {
|
|
226
|
-
symbol: sym,
|
|
227
|
-
side: orderSide,
|
|
228
|
-
totalSizeUsd: totalUsd,
|
|
928
|
+
await runSplit(adapter, sym, orderSide, totalUsd, {
|
|
229
929
|
maxSlippagePct: parseFloat(opts.maxSlippage),
|
|
230
930
|
maxSlices: parseInt(opts.maxSlices),
|
|
231
931
|
delayMs: parseInt(opts.delay),
|
|
232
932
|
minSliceUsd: parseFloat(opts.minSlice),
|
|
233
|
-
}
|
|
234
|
-
if (isJson())
|
|
235
|
-
return printJson(jsonOk(result));
|
|
236
|
-
const statusColor = result.status === "complete" ? chalk.green
|
|
237
|
-
: result.status === "partial" ? chalk.yellow
|
|
238
|
-
: chalk.red;
|
|
239
|
-
console.log(`\n ${statusColor(result.status.toUpperCase())} | ${result.slices.length} slices`);
|
|
240
|
-
console.log(` Filled: $${formatUsd(result.filledUsd)} / $${formatUsd(result.requestedUsd)}`);
|
|
241
|
-
console.log(` Avg Price: $${formatUsd(result.avgPrice)}`);
|
|
242
|
-
console.log(` Slippage: ${result.totalSlippagePct.toFixed(3)}%`);
|
|
243
|
-
console.log(` Runtime: ${result.runtime.toFixed(1)}s\n`);
|
|
933
|
+
});
|
|
244
934
|
});
|
|
245
|
-
trade
|
|
246
|
-
.command("limit <symbol> <side> <price> <size>")
|
|
935
|
+
supportsDryRun(trade.command("limit <symbol> <side> <price> <size>"))
|
|
247
936
|
.description("Place a limit order")
|
|
248
|
-
.option("--tif <tif>", "Time in force: GTC, IOC
|
|
937
|
+
.option("--tif <tif>", "Time in force: GTC (default, every venue) | IOC | FOK | ALO (post-only); venue aliases GTT / GTX / TOB accepted")
|
|
249
938
|
.option("--reduce-only", "Reduce only order")
|
|
250
939
|
.option("--client-id <id>", "Client order ID for idempotent tracking")
|
|
251
940
|
.option("--auto-id", "Auto-generate a client order ID")
|
|
252
|
-
.
|
|
941
|
+
.option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
|
|
942
|
+
.option("--tp <price>", "Take-profit protecting this entry's fill (ondo / arcus: attached to any entry; other venues: --tif IOC entries only)")
|
|
943
|
+
.option("--sl <price>", "Stop-loss protecting this entry's fill (ondo / arcus: attached to any entry; other venues: --tif IOC entries only)")
|
|
944
|
+
.option("--slippage-bps <n>", "Slippage bound in bps for protection-priced TP/SL legs (default: settings slippageBps[venue] → defaultSlippageBps)")
|
|
945
|
+
.action(async (symbol, side, price, rawSize, opts) => {
|
|
253
946
|
const s = side.toLowerCase();
|
|
254
947
|
if (s !== "buy" && s !== "sell")
|
|
255
948
|
errorAndExit("Side must be buy or sell");
|
|
256
|
-
const
|
|
257
|
-
|
|
949
|
+
const sym = symbol.toUpperCase();
|
|
950
|
+
// One parsed size for the risk check and the order ("1,000" / "abc" refused up front).
|
|
951
|
+
const size = parsePositiveDecimal(rawSize, "size");
|
|
952
|
+
if (opts.clientId && isOrderDuplicate(opts.clientId)) {
|
|
258
953
|
if (isJson())
|
|
259
|
-
return printJson(jsonOk({ duplicate: true, clientOrderId: clientId, message: "Order already submitted" }));
|
|
260
|
-
console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${clientId}). Skipping.\n`));
|
|
954
|
+
return printJson(jsonOk({ duplicate: true, clientOrderId: opts.clientId, message: "Order already submitted" }));
|
|
955
|
+
console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${opts.clientId}). Skipping.\n`));
|
|
261
956
|
return;
|
|
262
957
|
}
|
|
263
958
|
const adapter = await getAdapter();
|
|
264
|
-
|
|
959
|
+
const { def: venueDef, caps: venueCaps } = tradingVenueOf(adapter);
|
|
960
|
+
const clientId = opts.autoId ? autoClientId(venueDef, venueCaps, "limit") : opts.clientId;
|
|
961
|
+
const tif = validateLegacyTif(venueDef, venueCaps, opts.tif ?? DEFAULT_TIF);
|
|
962
|
+
const req = {
|
|
963
|
+
symbol: sym, side: s, type: "limit", size, price,
|
|
964
|
+
triggerPrice: null, triggerSource: null, tif, reduceOnly: opts.reduceOnly === true,
|
|
965
|
+
clientOrderId: v2ClientOrderId(venueDef, venueCaps, clientId), slippage: null, tpsl: null,
|
|
966
|
+
};
|
|
967
|
+
validateOrderRequest(venueDef, venueCaps, req);
|
|
968
|
+
const prices = entryTpSl(opts);
|
|
969
|
+
if (prices !== null) {
|
|
970
|
+
if (!(Number(price) > 0))
|
|
971
|
+
throw new PerpError("INVALID_PARAMS", `limit price must be positive (got "${price}")`);
|
|
972
|
+
refuseGatedReduceOnly(adapter);
|
|
973
|
+
refuseGatedStops(adapter);
|
|
974
|
+
prevalidateEntryTpSl(venueDef, venueCaps, s, req.reduceOnly, tif === "IOC", Number(price), "limit price", prices);
|
|
975
|
+
}
|
|
976
|
+
// TP/SL legs of protection-priced venues (arcus, lighter) use the single resolved bound.
|
|
977
|
+
const slippageFlag = parseSlippageBpsFlag(opts.slippageBps);
|
|
978
|
+
if (slippageFlag !== null && prices === null) {
|
|
979
|
+
throw new PerpError("INVALID_PARAMS", "--slippage-bps applies to --tp/--sl legs of a limit order; a plain limit order has no slippage bound");
|
|
980
|
+
}
|
|
981
|
+
const tpslSlippage = prices !== null ? resolveSlippage(venueDef.id, slippageFlag) : null;
|
|
982
|
+
// Read-only quarantine + risk check before the dry-run return (L5) — same gate as the live order.
|
|
983
|
+
await openingOrderGate(adapter, { symbol: sym, size: Number(size), price: parseFloat(price), reduceOnly: opts.reduceOnly, force: opts.force });
|
|
984
|
+
if (await orderDryRunGuard("limit_order", adapter, sym, { exchange: adapter.name, symbol: sym, side: s, size, price, tif, ...(prices !== null ? { tp: prices.tp ?? "none", sl: prices.sl ?? "none" } : {}) }))
|
|
265
985
|
return;
|
|
266
986
|
if (clientId) {
|
|
267
987
|
logClientId({
|
|
268
988
|
clientOrderId: clientId, exchange: adapter.name,
|
|
269
|
-
symbol:
|
|
989
|
+
symbol: sym, side: s, size, type: "limit",
|
|
270
990
|
status: "pending", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
271
991
|
});
|
|
272
992
|
}
|
|
273
993
|
let result;
|
|
994
|
+
let protectedOut = null;
|
|
274
995
|
try {
|
|
275
|
-
|
|
996
|
+
if (prices !== null && tpslSlippage !== null) {
|
|
997
|
+
protectedOut = await placeProtectedEntry(adapter, venueDef, venueCaps, { ...req, slippage: venueCaps.tpsl === "batchGroup" ? tpslSlippage : null }, prices, tpslSlippage);
|
|
998
|
+
result = protectedOut.entry;
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
result = await placeV2(v2Of(adapter), venueCaps, req);
|
|
1002
|
+
}
|
|
276
1003
|
logExecution({
|
|
277
|
-
type: "limit_order", exchange: adapter.name, symbol:
|
|
1004
|
+
type: "limit_order", exchange: adapter.name, symbol: sym,
|
|
278
1005
|
side: s, size, price, status: "success", dryRun: false,
|
|
279
1006
|
meta: clientId ? { clientOrderId: clientId } : undefined,
|
|
280
1007
|
});
|
|
281
1008
|
}
|
|
282
1009
|
catch (err) {
|
|
283
1010
|
logExecution({
|
|
284
|
-
type: "limit_order", exchange: adapter.name, symbol:
|
|
285
|
-
side: s, size, price, status: "failed", dryRun: false,
|
|
1011
|
+
type: "limit_order", exchange: adapter.name, symbol: sym,
|
|
1012
|
+
side: s, size, price, status: isOutcomeUnknown(err) ? "unverified" : "failed", dryRun: false,
|
|
286
1013
|
error: extractErrorMessage(err),
|
|
287
1014
|
meta: clientId ? { clientOrderId: clientId } : undefined,
|
|
288
1015
|
});
|
|
@@ -291,132 +1018,167 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
291
1018
|
if (clientId) {
|
|
292
1019
|
logClientId({
|
|
293
1020
|
clientOrderId: clientId, exchange: adapter.name,
|
|
294
|
-
symbol:
|
|
1021
|
+
symbol: sym, side: s, size, type: "limit",
|
|
295
1022
|
status: "submitted", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
296
1023
|
});
|
|
297
1024
|
}
|
|
1025
|
+
const header = `Limit ${s.toUpperCase()} ${size} ${sym} @ $${price} on ${adapter.name}${clientId ? ` (id: ${clientId})` : ""}`;
|
|
1026
|
+
if (protectedOut !== null)
|
|
1027
|
+
return printProtectedEntry(header, protectedOut, result !== null ? trackedId(result, clientId) : undefined);
|
|
1028
|
+
if (result === null)
|
|
1029
|
+
throw new PerpError("EXCHANGE_ERROR", "unreachable: limit order produced no result");
|
|
298
1030
|
if (isJson())
|
|
299
|
-
return printJson(jsonOk(
|
|
300
|
-
|
|
1031
|
+
return printJson(jsonOk(orderResultJson(result, trackedId(result, clientId))));
|
|
1032
|
+
printOrder(header, result);
|
|
301
1033
|
});
|
|
302
|
-
trade
|
|
303
|
-
.
|
|
304
|
-
.description("Cancel order(s) — by orderId, by symbol + orderId, or by symbol name (cancels all orders for that symbol)")
|
|
1034
|
+
supportsDryRun(trade.command("cancel <symbolOrOrderId> [orderId]"))
|
|
1035
|
+
.description("Cancel order(s) — by orderId (or c:<clientOrderId> on ondo/arcus/qfex/pacifica), by symbol + orderId / c:<clientOrderId> (every venue with client ids: also hyperliquid, aster, lighter, lighter-rh), or by symbol name (cancels all orders for that symbol)")
|
|
305
1036
|
.action(async (symbolOrOrderId, orderId) => {
|
|
306
1037
|
const adapter = await getAdapter();
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
if (dryRunGuard("cancel", { exchange: adapter.name, symbol, orderId:
|
|
1038
|
+
const v2 = v2Of(adapter);
|
|
1039
|
+
/** Cancel one order by reference through TradingV2 and print the uniform result. */
|
|
1040
|
+
const cancelOne = async (symbol, refText) => {
|
|
1041
|
+
const ref = parseOrderRef(refText);
|
|
1042
|
+
if (dryRunGuard("cancel", { exchange: adapter.name, symbol, orderId: refText }))
|
|
312
1043
|
return;
|
|
1044
|
+
let result;
|
|
313
1045
|
try {
|
|
314
|
-
|
|
315
|
-
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { orderId:
|
|
316
|
-
if (isJson())
|
|
317
|
-
return printJson(jsonOk(result));
|
|
318
|
-
console.log(chalk.green(`\n Order ${oid} cancelled on ${adapter.name}.\n`));
|
|
1046
|
+
result = await v2.cancelByRef(symbol, ref);
|
|
1047
|
+
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: result.failed.length > 0 ? "failed" : (result.pending ?? []).length > 0 ? "unverified" : "success", dryRun: false, meta: { orderId: refText } });
|
|
319
1048
|
}
|
|
320
1049
|
catch (err) {
|
|
321
|
-
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { orderId:
|
|
1050
|
+
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { orderId: refText } });
|
|
322
1051
|
throw err;
|
|
323
1052
|
}
|
|
1053
|
+
const incomplete = cancelIncompleteError(result, symbol);
|
|
1054
|
+
if (incomplete !== null) {
|
|
1055
|
+
if (!isJson())
|
|
1056
|
+
printCancel(result, symbol);
|
|
1057
|
+
throw incomplete;
|
|
1058
|
+
}
|
|
1059
|
+
if (isJson())
|
|
1060
|
+
return printJson(jsonOk({ ...result, legacy: result.venueRaw }));
|
|
1061
|
+
printCancel(result, symbol);
|
|
1062
|
+
};
|
|
1063
|
+
if (orderId) {
|
|
1064
|
+
// cancel <symbol> <orderId>
|
|
1065
|
+
await cancelOne(symbolOrOrderId.toUpperCase(), orderId);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
// Single argument — an order reference (c:<clientOrderId>, numeric / UUID / 0x-hex order id)
|
|
1069
|
+
// or a symbol name. An explicit order reference is never treated as a symbol.
|
|
1070
|
+
const ref = parseOrderRef(symbolOrOrderId);
|
|
1071
|
+
const { def: venueDef } = tradingVenueOf(adapter);
|
|
1072
|
+
const twoArgHint = `perp trade cancel <symbol> ${symbolOrOrderId} -e ${venueDef.id}`;
|
|
1073
|
+
if (ref.kind === "clientOrderId") {
|
|
1074
|
+
// Open-order listings carry no client id, so the symbol cannot be looked up from it.
|
|
1075
|
+
if (!SYMBOLLESS_CANCEL_VENUES.has(venueDef.id)) {
|
|
1076
|
+
throw new PerpError("INVALID_PARAMS", `${venueDef.displayName} cancels a client order id within a market — the symbol is required`, {
|
|
1077
|
+
remediation: `Pass the symbol: ${twoArgHint}`,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
await cancelOne("", symbolOrOrderId);
|
|
324
1081
|
return;
|
|
325
1082
|
}
|
|
326
|
-
|
|
327
|
-
const isNumeric = /^\d+$/.test(symbolOrOrderId);
|
|
328
|
-
if (isNumeric) {
|
|
1083
|
+
if (looksLikeOrderId(symbolOrOrderId)) {
|
|
329
1084
|
// cancel <orderId> — look up symbol from open orders
|
|
330
1085
|
const oid = symbolOrOrderId;
|
|
331
1086
|
const orders = await adapter.getOpenOrders();
|
|
332
1087
|
const match = orders.find((o) => String(o.orderId) === oid);
|
|
333
1088
|
if (!match) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
const symbol = match.symbol;
|
|
341
|
-
if (dryRunGuard("cancel", { exchange: adapter.name, symbol, orderId: oid }))
|
|
342
|
-
return;
|
|
343
|
-
try {
|
|
344
|
-
const result = await adapter.cancelOrder(symbol, oid);
|
|
345
|
-
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { orderId: oid } });
|
|
346
|
-
if (isJson())
|
|
347
|
-
return printJson(jsonOk(result));
|
|
348
|
-
console.log(chalk.green(`\n Order ${oid} cancelled on ${adapter.name}.\n`));
|
|
349
|
-
}
|
|
350
|
-
catch (err) {
|
|
351
|
-
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { orderId: oid } });
|
|
352
|
-
throw err;
|
|
1089
|
+
throw new PerpError("ORDER_NOT_FOUND", `Order ${oid} not found in the open orders on ${venueDef.displayName}; nothing was cancelled`, {
|
|
1090
|
+
exchange: adapter.name,
|
|
1091
|
+
remediation: `Check open orders (perp account orders -e ${venueDef.id}); if it is a stop / trigger not listed there, pass the symbol: ${twoArgHint}`,
|
|
1092
|
+
});
|
|
353
1093
|
}
|
|
1094
|
+
await cancelOne(match.symbol, oid);
|
|
1095
|
+
return;
|
|
354
1096
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
if (dryRunGuard("cancel_by_symbol", { exchange: adapter.name, symbol, count: matching.length }))
|
|
368
|
-
return;
|
|
369
|
-
try {
|
|
370
|
-
const result = await adapter.cancelAllOrders(symbol);
|
|
371
|
-
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { bySymbol: true, count: matching.length } });
|
|
372
|
-
if (isJson())
|
|
373
|
-
return printJson(jsonOk({ cancelled: true, symbol, count: matching.length, result }));
|
|
374
|
-
console.log(chalk.green(`\n ${matching.length} order(s) for ${symbol} cancelled on ${adapter.name}.\n`));
|
|
375
|
-
}
|
|
376
|
-
catch (err) {
|
|
377
|
-
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { bySymbol: true } });
|
|
378
|
-
throw err;
|
|
379
|
-
}
|
|
1097
|
+
// cancel <symbol> — cancel all open orders for this symbol
|
|
1098
|
+
const symbol = symbolOrOrderId.toUpperCase();
|
|
1099
|
+
const orders = await adapter.getOpenOrders();
|
|
1100
|
+
const matching = orders.filter((o) => symbolMatch(o.symbol, symbol));
|
|
1101
|
+
if (matching.length === 0) {
|
|
1102
|
+
const err = `No open orders found for ${symbol}`;
|
|
1103
|
+
if (isJson())
|
|
1104
|
+
return printJson(jsonOk({ cancelled: false, symbol, reason: err }));
|
|
1105
|
+
console.log(chalk.yellow(`\n ${err}\n`));
|
|
1106
|
+
return;
|
|
380
1107
|
}
|
|
1108
|
+
if (dryRunGuard("cancel_by_symbol", { exchange: adapter.name, symbol, count: matching.length }))
|
|
1109
|
+
return;
|
|
1110
|
+
let result;
|
|
1111
|
+
try {
|
|
1112
|
+
result = await v2.cancelAllV2(symbol);
|
|
1113
|
+
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: result.failed.length > 0 ? "failed" : (result.pending ?? []).length > 0 ? "unverified" : "success", dryRun: false, meta: { bySymbol: true, count: matching.length } });
|
|
1114
|
+
}
|
|
1115
|
+
catch (err) {
|
|
1116
|
+
logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { bySymbol: true } });
|
|
1117
|
+
throw err;
|
|
1118
|
+
}
|
|
1119
|
+
const incomplete = cancelIncompleteError(result, symbol);
|
|
1120
|
+
if (incomplete !== null) {
|
|
1121
|
+
if (!isJson())
|
|
1122
|
+
printCancel(result, symbol);
|
|
1123
|
+
throw incomplete;
|
|
1124
|
+
}
|
|
1125
|
+
if (isJson())
|
|
1126
|
+
return printJson(jsonOk({ ...result, symbol, legacy: result.venueRaw }));
|
|
1127
|
+
printCancel(result, symbol);
|
|
381
1128
|
});
|
|
382
|
-
trade
|
|
383
|
-
.command("cancel-all")
|
|
1129
|
+
supportsDryRun(trade.command("cancel-all"))
|
|
384
1130
|
.description("Cancel all open orders")
|
|
385
1131
|
.action(async () => {
|
|
386
1132
|
const adapter = await getAdapter();
|
|
387
1133
|
if (dryRunGuard("cancel_all", { exchange: adapter.name }))
|
|
388
1134
|
return;
|
|
389
|
-
|
|
1135
|
+
// Per-order proof (cancelAllV2): only orders the venue result proves are reported cancelled.
|
|
1136
|
+
const result = await v2Of(adapter).cancelAllV2(null);
|
|
1137
|
+
logExecution({ type: "cancel", exchange: adapter.name, symbol: "", side: "cancel", size: "0", dryRun: false,
|
|
1138
|
+
status: result.failed.length > 0 ? "failed" : (result.pending ?? []).length > 0 ? "unverified" : "success", meta: { all: true, cancelled: result.cancelled.length } });
|
|
1139
|
+
const incomplete = cancelIncompleteError(result, "all");
|
|
1140
|
+
if (incomplete !== null) {
|
|
1141
|
+
if (!isJson())
|
|
1142
|
+
printCancel(result, "all");
|
|
1143
|
+
throw incomplete;
|
|
1144
|
+
}
|
|
390
1145
|
if (isJson())
|
|
391
|
-
return printJson(jsonOk(result));
|
|
392
|
-
|
|
1146
|
+
return printJson(jsonOk({ ...result, legacy: result.venueRaw }));
|
|
1147
|
+
printCancel(result, "all");
|
|
393
1148
|
});
|
|
394
1149
|
// === TWAP — Pacifica (seconds) + Hyperliquid (minutes) ===
|
|
395
1150
|
trade
|
|
396
1151
|
.command("twap <symbol> <side> <size> <duration>")
|
|
397
1152
|
.description("Place a TWAP order (Pacifica: seconds, HL: minutes, Lighter/any: client-side via --background)")
|
|
398
|
-
.option("
|
|
1153
|
+
.option("--slippage-bps <n>", "Slippage bound in bps for Pacifica native TWAP (default: settings slippageBps[venue] → defaultSlippageBps)")
|
|
399
1154
|
.option("--reduce-only", "Reduce only order")
|
|
400
1155
|
.option("--background", "Run client-side TWAP in background (tmux) — works on all exchanges")
|
|
401
1156
|
.option("--slices <n>", "Number of slices for client-side TWAP")
|
|
402
|
-
.
|
|
1157
|
+
.option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
|
|
1158
|
+
.action(async (symbol, side, rawSize, duration, opts) => {
|
|
403
1159
|
const s = side.toLowerCase();
|
|
404
1160
|
if (s !== "buy" && s !== "sell")
|
|
405
1161
|
errorAndExit("Side must be buy or sell");
|
|
1162
|
+
// One parsed size for the risk check and the order ("1,000" / "abc" refused up front).
|
|
1163
|
+
const size = parsePositiveDecimal(rawSize, "size");
|
|
406
1164
|
// --background → client-side TWAP via tmux job
|
|
407
1165
|
if (opts.background) {
|
|
408
|
-
const { startJob } = await import("../jobs.js");
|
|
409
|
-
const
|
|
1166
|
+
const { startJob, jobSelectorsOf } = await import("../jobs.js");
|
|
1167
|
+
const bgAdapter = await getAdapter();
|
|
1168
|
+
const exchange = bgAdapter.name;
|
|
1169
|
+
// Risk check (and its --force bypass) runs here: the child `strategy twap` has none.
|
|
1170
|
+
await openingOrderGate(bgAdapter, { symbol: symbol.toUpperCase(), size: Number(size), reduceOnly: opts.reduceOnly, force: opts.force });
|
|
410
1171
|
const cliArgs = [
|
|
411
|
-
symbol.toUpperCase(), s, size, duration,
|
|
1172
|
+
"strategy", "twap", symbol.toUpperCase(), s, size, duration,
|
|
412
1173
|
...(opts.slices ? ["--slices", opts.slices] : []),
|
|
1174
|
+
...(opts.reduceOnly ? ["--reduce-only"] : []),
|
|
413
1175
|
];
|
|
414
|
-
// Pass exchange flag through
|
|
415
1176
|
const job = startJob({
|
|
416
1177
|
strategy: "twap",
|
|
417
1178
|
exchange,
|
|
418
|
-
params: { symbol: symbol.toUpperCase(), side: s, size, duration, slices: opts.slices },
|
|
419
|
-
|
|
1179
|
+
params: { symbol: symbol.toUpperCase(), side: s, size, duration, slices: opts.slices, reduceOnly: opts.reduceOnly === true },
|
|
1180
|
+
venue: exchange, network: networkOf(program), dryRun: isDryRun(), selectors: jobSelectorsOf(program),
|
|
1181
|
+
cliArgs,
|
|
420
1182
|
});
|
|
421
1183
|
if (isJson())
|
|
422
1184
|
return printJson(jsonOk(job));
|
|
@@ -428,24 +1190,29 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
428
1190
|
return;
|
|
429
1191
|
}
|
|
430
1192
|
const adapter = await getAdapter();
|
|
1193
|
+
await openingOrderGate(adapter, { symbol: symbol.toUpperCase(), size: Number(size), reduceOnly: opts.reduceOnly, force: opts.force });
|
|
431
1194
|
// Native TWAP: Pacifica (SDK) or Hyperliquid (twapOrder)
|
|
432
1195
|
// Fallback: client-side TWAP for any exchange without native support
|
|
433
1196
|
let result;
|
|
434
1197
|
if (hasPacificaSdk(adapter)) {
|
|
1198
|
+
// One slippage resolver (design §13.2): bps → Pacifica's slippage_percent.
|
|
1199
|
+
const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
|
|
1200
|
+
requirePacificaTwapSigner(adapter, "TWAP order");
|
|
435
1201
|
const sdk = adapter.sdk;
|
|
436
1202
|
result = await sdk.createTWAP({
|
|
437
1203
|
symbol: symbol.toUpperCase(),
|
|
438
1204
|
amount: size,
|
|
439
1205
|
side: s === "buy" ? "bid" : "ask",
|
|
440
|
-
slippage_percent:
|
|
1206
|
+
slippage_percent: String(slippage.bps / 100),
|
|
441
1207
|
reduce_only: opts.reduceOnly ?? false,
|
|
442
1208
|
duration_in_seconds: parseInt(duration),
|
|
443
1209
|
}, adapter.publicKey, adapter.signer);
|
|
444
1210
|
}
|
|
445
1211
|
else if (isTwapCapable(adapter)) {
|
|
446
|
-
|
|
447
|
-
if (
|
|
448
|
-
errorAndExit("HL TWAP duration must be
|
|
1212
|
+
// Bounds (5 minutes – 7 days, $100 minimum total size) are pre-checked in twapOrder (hlAssertTwapBounds).
|
|
1213
|
+
if (!/^\d+$/.test(duration.trim()))
|
|
1214
|
+
errorAndExit("HL TWAP duration must be a whole number of minutes");
|
|
1215
|
+
const minutes = Number(duration.trim());
|
|
449
1216
|
result = await adapter.twapOrder(symbol.toUpperCase(), s, size, minutes, { reduceOnly: opts.reduceOnly });
|
|
450
1217
|
}
|
|
451
1218
|
else {
|
|
@@ -457,7 +1224,9 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
457
1224
|
totalSize: parseFloat(size),
|
|
458
1225
|
durationSec: parseInt(duration),
|
|
459
1226
|
slices: opts.slices ? parseInt(opts.slices) : undefined,
|
|
460
|
-
|
|
1227
|
+
reduceOnly: opts.reduceOnly === true,
|
|
1228
|
+
}, undefined, isJson() ? (m) => console.error(m) : undefined);
|
|
1229
|
+
// JSON mode: progress goes to stderr so stdout stays one JSON document.
|
|
461
1230
|
if (isJson())
|
|
462
1231
|
return printJson(jsonOk(twapResult));
|
|
463
1232
|
return;
|
|
@@ -466,104 +1235,199 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
466
1235
|
return printJson(jsonOk(result));
|
|
467
1236
|
console.log(chalk.green(`\n TWAP ${s.toUpperCase()} ${size} ${symbol.toUpperCase()} over ${duration} placed on ${adapter.name}.\n`));
|
|
468
1237
|
});
|
|
469
|
-
// === Stop / Trigger orders
|
|
470
|
-
trade
|
|
471
|
-
.
|
|
472
|
-
.description("Place a stop order")
|
|
1238
|
+
// === Stop / Trigger orders (every trading venue) ===
|
|
1239
|
+
supportsDryRun(trade.command("stop <symbol> <side> <size> <stopPrice>"))
|
|
1240
|
+
.description("Place a stop order (market trigger; --limit-price makes it stop-limit). Reduce-only by default on every venue (BEHAVIOR CHANGE: pass --no-reduce-only to let it open / flip a position). Limit-priced market triggers (lighter, arcus) fill only within trigger ± the slippage bound — a fast move can leave them unfilled")
|
|
473
1241
|
.option("--limit-price <price>", "Limit price (makes it stop-limit)")
|
|
474
|
-
.option("--reduce-only", "
|
|
475
|
-
.
|
|
1242
|
+
.option("--no-reduce-only", "Allow the triggered order to open or flip a position (default: reduce-only)")
|
|
1243
|
+
.option("--trigger-type <type>", "Trigger price source: mark | last | mid (pacifica; aster mark | last; other venues: unsupported)")
|
|
1244
|
+
.option("--slippage-bps <n>", "Bound for protection-priced market triggers (lighter, arcus) in bps, around the trigger price (default: settings)")
|
|
1245
|
+
.option("--force", "With --no-reduce-only: bypass local risk-limit checks (maxPosition / total exposure)")
|
|
1246
|
+
.action(async (symbol, side, rawSize, stopPrice, opts) => {
|
|
476
1247
|
const s = side.toLowerCase();
|
|
477
1248
|
if (s !== "buy" && s !== "sell")
|
|
478
1249
|
errorAndExit("Side must be buy or sell");
|
|
1250
|
+
const size = parsePositiveDecimal(rawSize, "size");
|
|
1251
|
+
if (opts.triggerType && !["mark", "last", "mid"].includes(opts.triggerType)) {
|
|
1252
|
+
errorAndExit("--trigger-type must be one of: mark, last, mid");
|
|
1253
|
+
}
|
|
1254
|
+
const sym = symbol.toUpperCase();
|
|
479
1255
|
const adapter = await getAdapter();
|
|
480
|
-
|
|
1256
|
+
const { def: venueDef, caps: venueCaps } = tradingVenueOf(adapter);
|
|
1257
|
+
const isLimit = opts.limitPrice !== undefined;
|
|
1258
|
+
const slippageFlag = parseSlippageBpsFlag(opts.slippageBps);
|
|
1259
|
+
if (slippageFlag !== null) {
|
|
1260
|
+
if (isLimit)
|
|
1261
|
+
throw new PerpError("INVALID_PARAMS", "--slippage-bps applies to market triggers only; a stop-limit executes at --limit-price");
|
|
1262
|
+
if (venueDef.family !== "lighter" && venueDef.id !== "arcus") {
|
|
1263
|
+
throw unsupported(venueDef.displayName, "stopSlippage", "market triggers on this venue are venue-native market orders (no slippage bound)", "Omit --slippage-bps, or use --limit-price");
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
const req = {
|
|
1267
|
+
symbol: sym, side: s, type: isLimit ? "stopLimit" : "stopMarket", size,
|
|
1268
|
+
price: isLimit ? opts.limitPrice : null,
|
|
1269
|
+
triggerPrice: stopPrice,
|
|
1270
|
+
triggerSource: opts.triggerType !== undefined ? opts.triggerType : null,
|
|
1271
|
+
tif: isLimit ? validateLegacyTif(venueDef, venueCaps, DEFAULT_TIF) : null,
|
|
1272
|
+
reduceOnly: opts.reduceOnly !== false,
|
|
1273
|
+
clientOrderId: v2ClientOrderId(venueDef, venueCaps, undefined),
|
|
1274
|
+
// Market triggers carry the resolved bound (used by protection-priced venues; native triggers ignore it).
|
|
1275
|
+
slippage: isLimit ? null : resolveSlippage(venueDef.id, slippageFlag),
|
|
1276
|
+
tpsl: null,
|
|
1277
|
+
};
|
|
1278
|
+
validateOrderRequest(venueDef, venueCaps, req);
|
|
1279
|
+
// --no-reduce-only can open / flip: quarantine + risk limits at the price it would execute at
|
|
1280
|
+
// (limit price, else the trigger), before the dry-run return. Reduce-only stops are exempt.
|
|
1281
|
+
if (!req.reduceOnly) {
|
|
1282
|
+
await openingOrderGate(adapter, { symbol: sym, size: Number(size), price: parseFloat(opts.limitPrice ?? stopPrice), force: opts.force });
|
|
1283
|
+
}
|
|
1284
|
+
if (await orderDryRunGuard("stop_order", adapter, sym, { exchange: adapter.name, symbol: sym, side: s, size, price: stopPrice, reduceOnly: req.reduceOnly }))
|
|
481
1285
|
return;
|
|
482
1286
|
let result;
|
|
483
1287
|
try {
|
|
484
|
-
result = await
|
|
485
|
-
logExecution({ type: "stop_order", exchange: adapter.name, symbol:
|
|
1288
|
+
result = await placeV2(v2Of(adapter), venueCaps, req);
|
|
1289
|
+
logExecution({ type: "stop_order", exchange: adapter.name, symbol: sym, side: s, size, price: stopPrice, status: "success", dryRun: false, meta: { reduceOnly: req.reduceOnly } });
|
|
486
1290
|
}
|
|
487
1291
|
catch (err) {
|
|
488
|
-
logExecution({ type: "stop_order", exchange: adapter.name, symbol:
|
|
1292
|
+
logExecution({ type: "stop_order", exchange: adapter.name, symbol: sym, side: s, size, price: stopPrice, status: isOutcomeUnknown(err) ? "unverified" : "failed", dryRun: false, error: extractErrorMessage(err), meta: { reduceOnly: req.reduceOnly } });
|
|
489
1293
|
throw err;
|
|
490
1294
|
}
|
|
491
1295
|
if (isJson())
|
|
492
|
-
return printJson(jsonOk(result));
|
|
493
|
-
|
|
494
|
-
});
|
|
495
|
-
// === TP/SL
|
|
496
|
-
trade
|
|
497
|
-
.
|
|
498
|
-
.
|
|
499
|
-
.option("--tp <price>", "Take
|
|
500
|
-
.option("--
|
|
501
|
-
.option("--
|
|
502
|
-
.option("--
|
|
503
|
-
.action(async (symbol,
|
|
504
|
-
const
|
|
505
|
-
if (
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1296
|
+
return printJson(jsonOk(orderResultJson(result, { reduceOnly: req.reduceOnly })));
|
|
1297
|
+
printOrder(`Stop ${s.toUpperCase()} ${size} ${sym} @ trigger $${stopPrice}${isLimit ? ` (limit $${opts.limitPrice})` : ""}${req.reduceOnly ? " (reduce-only)" : " (may open / flip)"} on ${adapter.name}`, result);
|
|
1298
|
+
});
|
|
1299
|
+
// === TP/SL on an open position (every venue with TP/SL) ===
|
|
1300
|
+
supportsDryRun(trade.command("tpsl <symbol> <positionSide>"))
|
|
1301
|
+
.description("Set take-profit / stop-loss on an OPEN position. positionSide = the position's side: long | short (BEHAVIOR CHANGE: buy/sell no longer accepted). Checked against the live position; legs are reduce-only and sized to the live position (or --size)")
|
|
1302
|
+
.option("--tp <price>", "Take-profit trigger price (long: above mark; short: below)")
|
|
1303
|
+
.option("--tp-limit <price>", "Take-profit limit price once triggered (default: market trigger)")
|
|
1304
|
+
.option("--sl <price>", "Stop-loss trigger price (long: below mark; short: above)")
|
|
1305
|
+
.option("--size <size>", "Protect only this much of the position (≤ position size; pacifica: whole position only)")
|
|
1306
|
+
.option("--slippage-bps <n>", "Bound for protection-priced market triggers (lighter, arcus) in bps (default: settings)")
|
|
1307
|
+
.action(async (symbol, positionSide, opts) => {
|
|
1308
|
+
const ps = positionSide.toLowerCase();
|
|
1309
|
+
if (ps !== "long" && ps !== "short") {
|
|
1310
|
+
throw new PerpError("INVALID_PARAMS", `positionSide must be long or short (got "${positionSide}")`, {
|
|
1311
|
+
remediation: "Pass the side of the position to protect: long or short (buy/sell were ambiguous and are no longer accepted)",
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
const prices = { tp: positivePriceOpt(opts.tp, "--tp"), sl: positivePriceOpt(opts.sl, "--sl") };
|
|
1315
|
+
if (prices.tp === null && prices.sl === null)
|
|
1316
|
+
throw new PerpError("INVALID_PARAMS", "Must specify --tp and/or --sl");
|
|
1317
|
+
const tpLimit = positivePriceOpt(opts.tpLimit, "--tp-limit");
|
|
1318
|
+
if (tpLimit !== null && prices.tp === null)
|
|
1319
|
+
throw new PerpError("INVALID_PARAMS", "--tp-limit needs --tp");
|
|
1320
|
+
const sym = symbol.toUpperCase();
|
|
509
1321
|
const adapter = await getAdapter();
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// TP/SL side is opposite of position: LONG position → "ask" to close
|
|
514
|
-
const params = {
|
|
515
|
-
symbol: symbol.toUpperCase(),
|
|
516
|
-
side: s === "buy" ? "ask" : "bid",
|
|
517
|
-
};
|
|
518
|
-
if (opts.tp)
|
|
519
|
-
params.take_profit = { stop_price: opts.tp, limit_price: opts.tpLimit };
|
|
520
|
-
if (opts.sl)
|
|
521
|
-
params.stop_loss = { stop_price: opts.sl };
|
|
522
|
-
const sdk = adapter.sdk;
|
|
523
|
-
const result = await sdk.setTPSL(params, adapter.publicKey, adapter.signer);
|
|
524
|
-
if (isJson())
|
|
525
|
-
return printJson(jsonOk(result));
|
|
526
|
-
console.log(chalk.green(`\n TP/SL set for ${symbol.toUpperCase()} on Pacifica.\n`));
|
|
1322
|
+
const { def, caps } = tradingVenueOf(adapter);
|
|
1323
|
+
if (caps.tpsl === null) {
|
|
1324
|
+
throw unsupported(def.displayName, "tpsl", "the venue has no TP/SL orders", `Use stop orders: perp trade stop -e ${def.id} ...`);
|
|
527
1325
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
1326
|
+
if (opts.size !== undefined && caps.tpsl === "positionLevel") {
|
|
1327
|
+
throw unsupported(def.displayName, "tpslSize", "TP/SL is position-level and always covers the whole position", "Omit --size");
|
|
1328
|
+
}
|
|
1329
|
+
refuseGatedReduceOnly(adapter);
|
|
1330
|
+
refuseGatedStops(adapter);
|
|
1331
|
+
const slippage = resolveSlippage(def.id, parseSlippageBpsFlag(opts.slippageBps));
|
|
1332
|
+
// Validate against the live position (side, size, mark).
|
|
1333
|
+
invalidateCache("acct");
|
|
1334
|
+
const pos = (await adapter.getPositions()).find(p => symbolMatch(p.symbol, sym));
|
|
1335
|
+
if (pos === undefined) {
|
|
1336
|
+
throw new PerpError("INVALID_PARAMS", `No open ${sym} position on ${def.displayName} to protect`, { exchange: def.id, remediation: `Check: perp account positions -e ${def.id}` });
|
|
1337
|
+
}
|
|
1338
|
+
if (pos.side !== ps) {
|
|
1339
|
+
throw new PerpError("INVALID_PARAMS", `The ${sym} position on ${def.displayName} is ${pos.side}, not ${ps}`, { exchange: def.id, remediation: `Run: perp trade tpsl ${sym} ${pos.side} ... -e ${def.id}` });
|
|
1340
|
+
}
|
|
1341
|
+
const posSize = pos.size.replace(/^-/, "");
|
|
1342
|
+
if (!(Number(posSize) > 0))
|
|
1343
|
+
throw new PerpError("EXCHANGE_ERROR", `${def.displayName} reports ${sym} position size "${pos.size}"`, { exchange: def.id });
|
|
1344
|
+
let size = posSize;
|
|
1345
|
+
if (opts.size !== undefined) {
|
|
1346
|
+
if (!(Number(opts.size) > 0))
|
|
1347
|
+
throw new PerpError("INVALID_PARAMS", `--size must be positive (got "${opts.size}")`);
|
|
1348
|
+
if (Number(opts.size) > Number(posSize))
|
|
1349
|
+
throw new PerpError("INVALID_PARAMS", `--size ${opts.size} exceeds the ${sym} position size ${posSize}`);
|
|
1350
|
+
size = opts.size;
|
|
1351
|
+
}
|
|
1352
|
+
const mark = Number(pos.markPrice) > 0 ? Number(pos.markPrice) : await markPriceOf(adapter, sym);
|
|
1353
|
+
checkTpSlDirection(ps, mark, "mark price", prices);
|
|
1354
|
+
const closeSide = ps === "long" ? "sell" : "buy";
|
|
1355
|
+
if (await orderDryRunGuard("tpsl", adapter, sym, { exchange: adapter.name, symbol: sym, positionSide: ps, size, tp: prices.tp ?? "none", sl: prices.sl ?? "none" }))
|
|
1356
|
+
return;
|
|
1357
|
+
const legs = [];
|
|
1358
|
+
const hint = `perp account orders -e ${def.id}`;
|
|
1359
|
+
const failAfter = (failed, e) => legs.length === 0 ? e : new PerpError("ORDER_PARTIAL", `TP/SL only partially set on ${sym}: ${legs.map(legLabel).join(", ")} placed, then ${failed} failed — ${extractErrorMessage(e)}`, {
|
|
1360
|
+
exchange: def.id,
|
|
1361
|
+
legsPlaced: legs.map(l => ({ kind: l.kind, trigger: l.trigger, orderId: l.result !== null ? l.result.orderId : null })),
|
|
1362
|
+
failedLeg: failed,
|
|
1363
|
+
remediation: `The position is protected on one side only. Inspect with: ${hint}, then place the missing leg or cancel the placed one.`,
|
|
1364
|
+
});
|
|
1365
|
+
if (caps.tpsl === "positionLevel") {
|
|
1366
|
+
const pac = positionTpSlAdapter(adapter);
|
|
1367
|
+
await refuseExistingPositionTpSl(pac, def, sym);
|
|
1368
|
+
const raw = await pac.setPositionTpSl(sym, ps, { tp: prices.tp, tpLimit, sl: prices.sl });
|
|
1369
|
+
legs.push({ kind: "tpsl", trigger: `tp ${prices.tp ?? "-"} / sl ${prices.sl ?? "-"}`, result: null, venueRaw: raw });
|
|
542
1370
|
}
|
|
543
1371
|
else {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
1372
|
+
for (const [kind, trigger] of [["tp", prices.tp], ["sl", prices.sl]]) {
|
|
1373
|
+
if (trigger === null)
|
|
1374
|
+
continue;
|
|
1375
|
+
const limitPrice = kind === "tp" ? tpLimit : null;
|
|
1376
|
+
try {
|
|
1377
|
+
let r;
|
|
1378
|
+
if (caps.tpsl === "separateOrders") {
|
|
1379
|
+
r = await placeTpSlLeg(adapter, network(), { symbol: sym, closeSide, size, trigger, kind, limitPrice, slippage });
|
|
1380
|
+
}
|
|
1381
|
+
else {
|
|
1382
|
+
// attached (ondo) / batchGroup (arcus): a reduce-only position stop / TPSL leg (type chosen by the venue adapter).
|
|
1383
|
+
const req = {
|
|
1384
|
+
symbol: sym, side: closeSide, type: limitPrice !== null ? "stopLimit" : "stopMarket", size,
|
|
1385
|
+
price: limitPrice, triggerPrice: trigger, triggerSource: null,
|
|
1386
|
+
tif: limitPrice !== null ? validateLegacyTif(def, caps, DEFAULT_TIF) : null,
|
|
1387
|
+
reduceOnly: true, clientOrderId: v2ClientOrderId(def, caps, undefined),
|
|
1388
|
+
slippage: limitPrice !== null ? null : slippage, tpsl: null,
|
|
1389
|
+
};
|
|
1390
|
+
validateOrderRequest(def, caps, req);
|
|
1391
|
+
r = await placeV2(v2Of(adapter), caps, req);
|
|
1392
|
+
}
|
|
1393
|
+
legs.push({ kind, trigger, result: r, venueRaw: r.venueRaw });
|
|
1394
|
+
}
|
|
1395
|
+
catch (e) {
|
|
1396
|
+
throw failAfter(legLabel({ kind, trigger }), e);
|
|
1397
|
+
}
|
|
552
1398
|
}
|
|
553
|
-
if (isJson())
|
|
554
|
-
return printJson(jsonOk(results));
|
|
555
|
-
console.log(chalk.green(`\n TP/SL set for ${symbol.toUpperCase()} on ${adapter.name}.\n`));
|
|
556
1399
|
}
|
|
1400
|
+
logExecution({ type: "stop_order", exchange: adapter.name, symbol: sym, side: closeSide, size, price: prices.sl ?? prices.tp ?? "", status: "success", dryRun: false, meta: { action: "tpsl", positionSide: ps, tp: prices.tp, sl: prices.sl } });
|
|
1401
|
+
if (isJson()) {
|
|
1402
|
+
return printJson(jsonOk({
|
|
1403
|
+
symbol: sym, positionSide: ps, size, method: caps.tpsl,
|
|
1404
|
+
legs: legs.map(l => ({ kind: l.kind, trigger: l.trigger, ...(l.result !== null ? orderResultJson(l.result) : { venueRaw: l.venueRaw }) })),
|
|
1405
|
+
}));
|
|
1406
|
+
}
|
|
1407
|
+
console.log(chalk.green(`\n TP/SL set for ${ps} ${size} ${sym} on ${def.displayName} (${caps.tpsl}, reduce-only):`));
|
|
1408
|
+
for (const l of legs) {
|
|
1409
|
+
console.log(chalk.green(` ${legLabel(l)}`));
|
|
1410
|
+
if (l.result !== null)
|
|
1411
|
+
for (const line of formatOrderResult(l.result))
|
|
1412
|
+
console.log(line);
|
|
1413
|
+
}
|
|
1414
|
+
console.log();
|
|
557
1415
|
});
|
|
558
1416
|
// === Scaled Take-Profit (분할익절) ===
|
|
559
|
-
trade
|
|
560
|
-
.command("scale-tp <symbol>")
|
|
1417
|
+
supportsDryRun(trade.command("scale-tp <symbol>"))
|
|
561
1418
|
.description("Place multiple take-profit limit orders at different price levels (분할익절)")
|
|
562
1419
|
.requiredOption("--levels <levels>", "[required] Comma-separated price:percent pairs (e.g., 72000:25,75000:25,80000:50)")
|
|
563
1420
|
.option("--size <size>", "Override total position size (default: uses current position)")
|
|
564
1421
|
.action(async (symbol, opts) => {
|
|
565
1422
|
const sym = symbol.toUpperCase();
|
|
566
1423
|
const adapter = await getAdapter();
|
|
1424
|
+
if (adapter.name === "ondo") {
|
|
1425
|
+
// docs.ondoperps.xyz take-profit-and-stop-loss: position TP/SL "Via Risk Management Module
|
|
1426
|
+
// [POST /v1/perps/stop_order] ... applies to the full position, with a limit of one TP and one
|
|
1427
|
+
// SL per market"; partial legs only "At Order Placement" (attached to each entry order). A
|
|
1428
|
+
// resting reduce-only limit is refused too (reduce_only_invalid_tif) — no multi-level TP on a held position.
|
|
1429
|
+
throw unsupported("Ondo", "scaleTp", "position-level stops allow one TP and one SL per market (docs: take-profit-and-stop-loss), and resting reduce-only limits are refused", `Set one (partial) take-profit: perp trade tpsl ${sym} <long|short> --tp <price> [--size <qty>] -e ondo, or attach --tp to each entry order`);
|
|
1430
|
+
}
|
|
567
1431
|
// Parse levels: "72000:25,75000:25,80000:50"
|
|
568
1432
|
const levels = opts.levels.split(",").map(l => {
|
|
569
1433
|
const [price, pct] = l.trim().split(":");
|
|
@@ -577,90 +1441,96 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
577
1441
|
errorAndExit(`Percentages must sum to 100%. Got: ${totalPct}%`);
|
|
578
1442
|
}
|
|
579
1443
|
// Get current position to determine size and side
|
|
580
|
-
let
|
|
1444
|
+
let total;
|
|
581
1445
|
let closeSide;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1446
|
+
// The position decides the close side (and, without --size, the total). symbolMatch: venues
|
|
1447
|
+
// report "ETH-PERP" (Hyperliquid) or "xyz:NVDA" (HIP-3) for the user's "ETH" / "NVDA".
|
|
1448
|
+
const pos = (await adapter.getPositions()).find(p => symbolMatch(p.symbol, sym));
|
|
1449
|
+
if (pos === undefined) {
|
|
1450
|
+
// Never a guessed side: a take-profit on the wrong side would not reduce the position.
|
|
1451
|
+
throw new PerpError("INVALID_PARAMS", `No open ${sym} position on ${adapter.name} — scale-tp places reduce-only take-profits on an open position`, {
|
|
1452
|
+
exchange: adapter.name, remediation: `Check: perp account positions -e ${adapter.name}`,
|
|
1453
|
+
});
|
|
588
1454
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1455
|
+
const held = pos.size.replace(/^-/, "");
|
|
1456
|
+
// Reduce-only take-profits can never add up to more than the position.
|
|
1457
|
+
if (opts.size !== undefined && Number(opts.size) > Number(held)) {
|
|
1458
|
+
throw new PerpError("SIZE_TOO_LARGE", `scale-tp --size ${opts.size} exceeds the open ${sym} ${pos.side} position (${held}) on ${adapter.name} — nothing was sent`, {
|
|
1459
|
+
exchange: adapter.name, position: held, positionSide: pos.side,
|
|
1460
|
+
remediation: `Use --size of at most ${held}, or omit --size to scale the whole position`,
|
|
1461
|
+
});
|
|
596
1462
|
}
|
|
1463
|
+
total = opts.size ? opts.size : held;
|
|
1464
|
+
closeSide = pos.side === "long" ? "sell" : "buy";
|
|
1465
|
+
// Level sizes on the venue lot (remainder to the last level), validated before anything is sent.
|
|
1466
|
+
const sizes = scaleLevelSizes(total, levels.map(l => l.pct), await lotStepOf(adapter, sym));
|
|
1467
|
+
const plan = levels.map((l, i) => ({ price: l.price, size: sizes[i], pct: l.pct }));
|
|
1468
|
+
const reqs = await scaleRequests(adapter, sym, closeSide, true, plan);
|
|
597
1469
|
if (dryRunGuard("scale_tp", {
|
|
598
1470
|
exchange: adapter.name, symbol: sym, side: closeSide,
|
|
599
|
-
totalSize:
|
|
600
|
-
levels:
|
|
1471
|
+
totalSize: total,
|
|
1472
|
+
levels: plan.map(l => `${l.price}@${l.pct}% (${l.size})`).join(", "),
|
|
601
1473
|
}))
|
|
602
1474
|
return;
|
|
603
1475
|
// Place reduce-only limit orders at each level
|
|
604
|
-
const results =
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
logExecution({
|
|
611
|
-
type: "limit_order", exchange: adapter.name, symbol: sym,
|
|
612
|
-
side: closeSide, size: levelSize, price: level.price,
|
|
613
|
-
status: "success", dryRun: false,
|
|
614
|
-
meta: { action: "scale-tp", pct: level.pct, reduceOnly: true },
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
catch (err) {
|
|
618
|
-
const msg = extractErrorMessage(err);
|
|
619
|
-
logExecution({
|
|
620
|
-
type: "limit_order", exchange: adapter.name, symbol: sym,
|
|
621
|
-
side: closeSide, size: levelSize, price: level.price,
|
|
622
|
-
status: "failed", dryRun: false, error: msg,
|
|
623
|
-
meta: { action: "scale-tp", pct: level.pct },
|
|
624
|
-
});
|
|
625
|
-
if (isJson()) {
|
|
626
|
-
results.push({ price: level.price, size: levelSize, pct: level.pct, result: { error: msg } });
|
|
627
|
-
}
|
|
628
|
-
else {
|
|
629
|
-
console.log(chalk.red(` Failed: ${level.price} x ${levelSize} — ${msg}`));
|
|
630
|
-
}
|
|
631
|
-
}
|
|
1476
|
+
const results = await placeScaleLevels(adapter, "scale-tp", sym, closeSide, plan, reqs);
|
|
1477
|
+
const incomplete = scaleIncompleteError(adapter.name, `scale-tp ${sym}`, results);
|
|
1478
|
+
if (incomplete !== null) {
|
|
1479
|
+
if (!isJson())
|
|
1480
|
+
printScaleLevels(`Scaled TP for ${sym} on ${adapter.name} is INCOMPLETE:`, sym, results);
|
|
1481
|
+
throw incomplete;
|
|
632
1482
|
}
|
|
633
1483
|
if (isJson())
|
|
634
|
-
return printJson(jsonOk({ symbol: sym, side: closeSide, totalSize, levels: results }));
|
|
635
|
-
|
|
636
|
-
for (const r of results) {
|
|
637
|
-
const status = r.result?.error ? chalk.red("FAILED") : chalk.green("OK");
|
|
638
|
-
console.log(` ${status} $${r.price} — ${r.pct}% (${r.size} ${sym})`);
|
|
639
|
-
}
|
|
640
|
-
console.log();
|
|
1484
|
+
return printJson(jsonOk({ symbol: sym, side: closeSide, totalSize: Number(total), levels: results }));
|
|
1485
|
+
printScaleLevels(`Scaled TP set for ${sym} on ${adapter.name}:`, sym, results);
|
|
641
1486
|
});
|
|
642
1487
|
// === Pacifica-only commands ===
|
|
643
|
-
trade
|
|
644
|
-
.
|
|
645
|
-
.
|
|
646
|
-
.
|
|
1488
|
+
supportsDryRun(trade.command("edit <symbol> <orderId> <price> <size>"))
|
|
1489
|
+
.description("Edit an existing order: new price and new open (unfilled) size; the order keeps its TIF / post-only (pacifica: re-created as ALO) — orderId, or c:<clientOrderId> on ondo/arcus/qfex (qfex: plain LIMIT / ALO only; the venue assigns a new order id, the client id is kept)")
|
|
1490
|
+
.option("--tif <tif>", "Time-in-force of the replacement (cancel+replace venues only, e.g. ondo; ALO keeps post-only)")
|
|
1491
|
+
.option("--force-tif", "Allow --tif to flip the order's post-only flag (ondo / pacifica); otherwise such a conflict is refused")
|
|
1492
|
+
.action(async (symbol, orderId, price, size, opts) => {
|
|
647
1493
|
const adapter = await getAdapter();
|
|
1494
|
+
prevalidateAmend(adapter);
|
|
1495
|
+
const { def: venueDef, caps: venueCaps } = tradingVenueOf(adapter);
|
|
1496
|
+
const amendTif = opts.tif !== undefined ? validateLegacyTif(venueDef, venueCaps, opts.tif) : null;
|
|
1497
|
+
if (opts.forceTif === true && amendTif === null)
|
|
1498
|
+
throw new PerpError("INVALID_PARAMS", "--force-tif needs --tif");
|
|
648
1499
|
if (dryRunGuard("edit_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), orderId, price, size }))
|
|
649
1500
|
return;
|
|
650
|
-
const result = await adapter.
|
|
1501
|
+
const result = await v2Of(adapter).amendOrder({ symbol: symbol.toUpperCase(), ref: parseOrderRef(orderId), price, size, tif: amendTif, forceTif: opts.forceTif === true });
|
|
651
1502
|
if (isJson())
|
|
652
|
-
return printJson(jsonOk(result));
|
|
653
|
-
|
|
1503
|
+
return printJson(jsonOk(orderResultJson(result)));
|
|
1504
|
+
printOrder(`Edit of ${orderId} → $${price} x ${size} on ${adapter.name}`, result);
|
|
654
1505
|
});
|
|
655
1506
|
trade
|
|
656
1507
|
.command("cancel-stop <symbol> <stopOrderId>")
|
|
657
|
-
.description("Cancel a stop order (
|
|
1508
|
+
.description("Cancel a stop / TP / SL order (pacifica; qfex: stop order id or c:<clientOrderId>; other venues: perp trade cancel)")
|
|
658
1509
|
.action(async (symbol, stopOrderId) => {
|
|
659
1510
|
const adapter = await getAdapter();
|
|
660
|
-
if (
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
1511
|
+
if (adapter.name === "qfex" && isTradingV2(adapter)) {
|
|
1512
|
+
// QFEX: cancelByRef reads the row first and routes a TAKE_PROFIT / STOP_LOSS row to cancel_stop_order.
|
|
1513
|
+
const sym = symbol.toUpperCase();
|
|
1514
|
+
if (dryRunGuard("cancel_stop", { exchange: adapter.name, symbol: sym, stopOrderId }))
|
|
1515
|
+
return;
|
|
1516
|
+
const r = await adapter.cancelByRef(sym, parseOrderRef(stopOrderId));
|
|
1517
|
+
const incomplete = cancelIncompleteError(r, sym);
|
|
1518
|
+
if (incomplete !== null) {
|
|
1519
|
+
if (!isJson())
|
|
1520
|
+
printCancel(r, sym);
|
|
1521
|
+
throw incomplete;
|
|
1522
|
+
}
|
|
1523
|
+
if (isJson())
|
|
1524
|
+
return printJson(jsonOk({ ...r, legacy: r.venueRaw }));
|
|
1525
|
+
printCancel(r, sym);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const pac = adapter;
|
|
1529
|
+
if (typeof pac.cancelStopOrder !== "function") {
|
|
1530
|
+
const { def } = tradingVenueOf(adapter);
|
|
1531
|
+
throw unsupported(def.displayName, "cancelStop", "stop orders are cancelled like any other order on this venue", `Use: perp trade cancel ${symbol.toUpperCase()} <orderId> -e ${def.id}`);
|
|
1532
|
+
}
|
|
1533
|
+
const result = await pac.cancelStopOrder(symbol, stopOrderId);
|
|
664
1534
|
if (isJson())
|
|
665
1535
|
return printJson(jsonOk(result));
|
|
666
1536
|
console.log(chalk.green(`\n Stop order ${stopOrderId} cancelled.\n`));
|
|
@@ -669,11 +1539,17 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
669
1539
|
.command("cancel-twap <symbol> <twapOrderId>")
|
|
670
1540
|
.description("Cancel a TWAP order")
|
|
671
1541
|
.action(async (symbol, twapOrderId) => {
|
|
1542
|
+
if (!/^\d+$/.test(twapOrderId)) {
|
|
1543
|
+
throw new PerpError("INVALID_PARAMS", `TWAP order id must be a whole number (got "${twapOrderId}")`, {
|
|
1544
|
+
remediation: "Pass the numeric TWAP id the venue reported when the TWAP was placed",
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
672
1547
|
const adapter = await getAdapter();
|
|
673
1548
|
let result;
|
|
674
1549
|
if (hasPacificaSdk(adapter)) {
|
|
1550
|
+
requirePacificaTwapSigner(adapter, "TWAP cancel");
|
|
675
1551
|
const sdk = adapter.sdk;
|
|
676
|
-
result = await sdk.cancelTWAP({ symbol: symbol.toUpperCase(),
|
|
1552
|
+
result = await sdk.cancelTWAP({ symbol: symbol.toUpperCase(), order_id: Number(twapOrderId) }, adapter.publicKey, adapter.signer);
|
|
677
1553
|
}
|
|
678
1554
|
else if (isTwapCapable(adapter)) {
|
|
679
1555
|
result = await adapter.twapCancel(symbol.toUpperCase(), Number(twapOrderId));
|
|
@@ -686,21 +1562,26 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
686
1562
|
console.log(chalk.green(`\n TWAP order ${twapOrderId} cancelled on ${adapter.name}.\n`));
|
|
687
1563
|
});
|
|
688
1564
|
// === Hyperliquid-only commands ===
|
|
689
|
-
trade
|
|
690
|
-
.command("leverage <symbol> <leverage>")
|
|
1565
|
+
supportsDryRun(trade.command("leverage <symbol> <leverage>"))
|
|
691
1566
|
.description("Set leverage for a symbol")
|
|
692
1567
|
.option("--isolated", "Use isolated margin mode (default: cross)")
|
|
693
1568
|
.action(async (symbol, leverage, opts) => {
|
|
1569
|
+
if (!/^[1-9]\d*$/.test(leverage.trim())) {
|
|
1570
|
+
throw new PerpError("INVALID_PARAMS", `leverage must be a positive whole number (got "${leverage}")`, {
|
|
1571
|
+
remediation: "Pass an integer leverage, e.g. perp trade leverage BTC 5",
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
const lev = Number(leverage.trim());
|
|
694
1575
|
const adapter = await getAdapter();
|
|
695
1576
|
const mode = opts.isolated ? "isolated" : "cross";
|
|
696
|
-
if (dryRunGuard("set_leverage", { exchange: adapter.name, symbol: symbol.toUpperCase(), leverage, mode }))
|
|
1577
|
+
if (dryRunGuard("set_leverage", { exchange: adapter.name, symbol: symbol.toUpperCase(), leverage: lev, mode }))
|
|
697
1578
|
return;
|
|
698
1579
|
try {
|
|
699
|
-
const result = await adapter.setLeverage(symbol.toUpperCase(),
|
|
1580
|
+
const result = await adapter.setLeverage(symbol.toUpperCase(), lev, mode);
|
|
700
1581
|
logExecution({
|
|
701
1582
|
type: "rebalance", exchange: adapter.name, symbol: symbol.toUpperCase(), side: mode,
|
|
702
1583
|
size: leverage, status: "success", dryRun: false,
|
|
703
|
-
meta: { action: "set_leverage", leverage:
|
|
1584
|
+
meta: { action: "set_leverage", leverage: lev, mode },
|
|
704
1585
|
});
|
|
705
1586
|
if (isJson())
|
|
706
1587
|
return printJson(jsonOk(result));
|
|
@@ -711,22 +1592,112 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
711
1592
|
type: "rebalance", exchange: adapter.name, symbol: symbol.toUpperCase(), side: mode,
|
|
712
1593
|
size: leverage, status: "failed", dryRun: false,
|
|
713
1594
|
error: extractErrorMessage(err),
|
|
714
|
-
meta: { action: "set_leverage", leverage:
|
|
1595
|
+
meta: { action: "set_leverage", leverage: lev, mode },
|
|
715
1596
|
});
|
|
716
1597
|
throw err;
|
|
717
1598
|
}
|
|
718
1599
|
});
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1600
|
+
/**
|
|
1601
|
+
* One verified reduce-only close (design §12.3, §13.10 #4): closePerpVerified (placePerpVerified
|
|
1602
|
+
* + one residual market close), so the reported `closed` is a measured / venue-final fill, never
|
|
1603
|
+
* the requested size. Logged "success" only when fully verified. A residual or an unknown outcome
|
|
1604
|
+
* quarantines the symbol (CLI path "trade-close"); a definite rejection propagates unchanged.
|
|
1605
|
+
*/
|
|
1606
|
+
async function closeVerified(adapter, symbol, originalSide, size, opts) {
|
|
1607
|
+
const side = originalSide === "long" ? "sell" : "buy";
|
|
1608
|
+
const dec = sizeDecimalsOf(size);
|
|
1609
|
+
let smart = null;
|
|
1610
|
+
const place = opts.smart
|
|
1611
|
+
? async () => {
|
|
1612
|
+
const sr = await smartOrder(adapter, symbol, side, size, { reduceOnly: true, slippage: opts.slippage });
|
|
1613
|
+
smart = { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice };
|
|
1614
|
+
return sr.result;
|
|
1615
|
+
}
|
|
1616
|
+
: undefined;
|
|
1617
|
+
const log = (m) => { if (!isJson())
|
|
1618
|
+
console.log(chalk.red(m)); };
|
|
1619
|
+
const meta = { action: opts.action, originalSide, smart: opts.smart };
|
|
1620
|
+
let c;
|
|
1621
|
+
try {
|
|
1622
|
+
c = await closePerpVerified(adapter, adapter.name, symbol, side, size, { decimals: dec, slippage: opts.slippage, ...(place !== undefined ? { place } : {}) });
|
|
1623
|
+
}
|
|
1624
|
+
catch (e) {
|
|
1625
|
+
logExecution({ type: "market_order", exchange: adapter.name, symbol, side, size, status: isOutcomeUnknown(e) ? "unverified" : "failed", dryRun: false, error: extractErrorMessage(e), meta });
|
|
1626
|
+
if (isOutcomeUnknown(e)) {
|
|
1627
|
+
quarantineSymbol("trade-close", symbol, `${opts.action} ${side} ${size} ${symbol} on ${adapter.name}: outcome unknown (${extractErrorMessage(e)})`, `Check ${symbol} on ${adapter.name} (perp -e ${adapter.name} account positions); fix by hand, then: perp strategy quarantine-clear ${symbol}`, log);
|
|
1628
|
+
}
|
|
1629
|
+
throw e;
|
|
1630
|
+
}
|
|
1631
|
+
const closed = fillSizeString(c.closed, dec);
|
|
1632
|
+
const residual = fillSizeString(c.residual, dec);
|
|
1633
|
+
if (c.residual > 0) {
|
|
1634
|
+
logExecution({ type: "market_order", exchange: adapter.name, symbol, side, size: closed, status: "failed", dryRun: false,
|
|
1635
|
+
error: `residual ${residual} of ${size} still open${c.note !== null ? ` (${c.note})` : ""}`, meta: { ...meta, requested: size, residual } });
|
|
1636
|
+
quarantineSymbol("trade-close", symbol, `${opts.action} ${symbol} on ${adapter.name}: closed ${closed} of ${size}, ${residual} still open${c.note !== null ? ` (${c.note})` : ""}`, `Check ${symbol} on ${adapter.name} (perp -e ${adapter.name} account positions); close the residual by hand, then: perp strategy quarantine-clear ${symbol}`, log);
|
|
1637
|
+
}
|
|
1638
|
+
else {
|
|
1639
|
+
logExecution({ type: "market_order", exchange: adapter.name, symbol, side, size: closed, status: "success", dryRun: false, meta: { ...meta, requested: size } });
|
|
1640
|
+
}
|
|
1641
|
+
return { symbol, side, originalSide, requested: size, closed, residual, note: c.note, smart };
|
|
1642
|
+
}
|
|
1643
|
+
/** Human line for one verified close. */
|
|
1644
|
+
function printCloseOutcome(o) {
|
|
1645
|
+
if (Number(o.residual) > 0) {
|
|
1646
|
+
console.log(chalk.red(` ✗ ${o.symbol}: closed ${o.closed} of ${o.requested} (${o.originalSide}) — ${o.residual} STILL OPEN${o.note !== null ? ` (${o.note})` : ""}; quarantined`));
|
|
1647
|
+
}
|
|
1648
|
+
else {
|
|
1649
|
+
console.log(chalk.green(` ✓ ${o.symbol}: closed ${o.closed} (${o.originalSide}, verified)${o.smart !== null ? ` [${o.smart.method}]` : ""}`));
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
/** Residuals or failed closes → ORDER_PARTIAL (non-zero exit) carrying every close outcome and failure. */
|
|
1653
|
+
function residualError(venue, outcomes, failures = []) {
|
|
1654
|
+
const open = outcomes.filter(o => Number(o.residual) > 0);
|
|
1655
|
+
if (open.length === 0 && failures.length === 0)
|
|
1656
|
+
return null;
|
|
1657
|
+
const parts = [
|
|
1658
|
+
...open.map(o => `${o.symbol} ${o.residual} still open after a verified close`),
|
|
1659
|
+
...failures.map(f => `${f.symbol} close failed: ${f.error}`),
|
|
1660
|
+
];
|
|
1661
|
+
return new PerpError("ORDER_PARTIAL", `${venue}: ${parts.join("; ")}`, {
|
|
1662
|
+
exchange: venue, closes: outcomes, failures,
|
|
1663
|
+
remediation: `Check positions (perp -e ${venue} account positions), close the residual by hand, then clear the quarantine: perp strategy quarantine-clear <symbol>`,
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
/** Close every position, continuing past a close that throws; returns all outcomes and failures. */
|
|
1667
|
+
async function closeEach(adapter, positions, opts) {
|
|
1668
|
+
const outcomes = [];
|
|
1669
|
+
const failures = [];
|
|
1670
|
+
for (const pos of positions) {
|
|
1671
|
+
const size = pos.size.replace(/^-/, "");
|
|
1672
|
+
if (!isJson())
|
|
1673
|
+
console.log(chalk.gray(` ${pos.side === "long" ? "SELL" : "BUY"} ${size} ${pos.symbol} (closing ${pos.side})...`));
|
|
1674
|
+
try {
|
|
1675
|
+
const o = await closeVerified(adapter, pos.symbol, pos.side, size, opts);
|
|
1676
|
+
outcomes.push(o);
|
|
1677
|
+
if (!isJson())
|
|
1678
|
+
printCloseOutcome(o);
|
|
1679
|
+
}
|
|
1680
|
+
catch (e) {
|
|
1681
|
+
const f = { symbol: pos.symbol, side: pos.side, size, error: extractErrorMessage(e), code: e instanceof PerpError ? e.structured.code : null };
|
|
1682
|
+
failures.push(f);
|
|
1683
|
+
if (!isJson())
|
|
1684
|
+
console.log(chalk.red(` ✗ ${pos.symbol}: close FAILED — ${f.error}`));
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return { outcomes, failures };
|
|
1688
|
+
}
|
|
1689
|
+
supportsDryRun(trade.command("close-all"))
|
|
1690
|
+
.description("Close all open positions (verified reduce-only closes on the opposite side)")
|
|
724
1691
|
.option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
|
|
1692
|
+
.option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
|
|
725
1693
|
.action(async (opts) => {
|
|
726
1694
|
await withJsonErrors(isJson(), async () => {
|
|
727
1695
|
const adapter = await getAdapter();
|
|
1696
|
+
refuseGatedReduceOnly(adapter);
|
|
1697
|
+
const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
|
|
728
1698
|
if (dryRunGuard("close_all", { exchange: adapter.name, smart: !!opts.smart }))
|
|
729
1699
|
return;
|
|
1700
|
+
invalidateCache("acct");
|
|
730
1701
|
const positions = await adapter.getPositions();
|
|
731
1702
|
if (positions.length === 0) {
|
|
732
1703
|
if (isJson())
|
|
@@ -736,115 +1707,102 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
736
1707
|
}
|
|
737
1708
|
if (!isJson())
|
|
738
1709
|
console.log(chalk.cyan(`\n Closing ${positions.length} position(s) on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
let result;
|
|
745
|
-
if (opts.smart) {
|
|
746
|
-
const sr = await smartOrder(adapter, pos.symbol, closeSide, pos.size, { reduceOnly: true });
|
|
747
|
-
result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice } };
|
|
748
|
-
}
|
|
749
|
-
else {
|
|
750
|
-
result = await adapter.marketOrder(pos.symbol, closeSide, pos.size);
|
|
751
|
-
}
|
|
752
|
-
results.push(result);
|
|
753
|
-
logExecution({
|
|
754
|
-
type: "market_order", exchange: adapter.name, symbol: pos.symbol,
|
|
755
|
-
side: closeSide, size: pos.size, status: "success", dryRun: false,
|
|
756
|
-
meta: { action: "close-all", originalSide: pos.side, smart: !!opts.smart },
|
|
757
|
-
});
|
|
758
|
-
}
|
|
1710
|
+
const { outcomes, failures } = await closeEach(adapter, positions, { smart: !!opts.smart, slippage, action: "close-all" });
|
|
1711
|
+
const partial = residualError(adapter.name, outcomes, failures);
|
|
1712
|
+
if (partial !== null)
|
|
1713
|
+
throw partial;
|
|
1714
|
+
await orphanedReduceOnly(adapter, null, false);
|
|
759
1715
|
if (isJson())
|
|
760
|
-
return printJson(jsonOk({ closed:
|
|
761
|
-
console.log(chalk.green(`\n Closed ${
|
|
1716
|
+
return printJson(jsonOk({ closed: outcomes.length, results: outcomes }));
|
|
1717
|
+
console.log(chalk.green(`\n Closed ${outcomes.length} position(s) on ${adapter.name} (verified).\n`));
|
|
762
1718
|
});
|
|
763
1719
|
});
|
|
764
|
-
trade
|
|
765
|
-
.
|
|
766
|
-
.description("Close a specific symbol's position")
|
|
1720
|
+
supportsDryRun(trade.command("close <symbol>"))
|
|
1721
|
+
.description("Close a specific symbol's position (verified reduce-only close)")
|
|
767
1722
|
.option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
|
|
1723
|
+
.option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
|
|
768
1724
|
.action(async (symbol, opts) => {
|
|
769
1725
|
await withJsonErrors(isJson(), async () => {
|
|
770
1726
|
const sym = symbol.toUpperCase();
|
|
771
1727
|
const adapter = await getAdapter();
|
|
1728
|
+
refuseGatedReduceOnly(adapter);
|
|
1729
|
+
const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
|
|
1730
|
+
invalidateCache("acct");
|
|
772
1731
|
const positions = await adapter.getPositions();
|
|
773
1732
|
const pos = positions.find(p => symbolMatch(p.symbol, sym));
|
|
774
1733
|
if (!pos) {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1734
|
+
throw new PerpError("POSITION_NOT_FOUND", `No open position for ${sym} on ${adapter.name}; nothing was closed`, {
|
|
1735
|
+
exchange: adapter.name, remediation: `Check positions: perp account positions -e ${adapter.name}`,
|
|
1736
|
+
});
|
|
778
1737
|
}
|
|
1738
|
+
const size = pos.size.replace(/^-/, "");
|
|
779
1739
|
const closeSide = pos.side === "long" ? "sell" : "buy";
|
|
780
|
-
if (
|
|
1740
|
+
if (await orderDryRunGuard("close", adapter, sym, { exchange: adapter.name, symbol: sym, side: closeSide, size, originalSide: pos.side, smart: !!opts.smart }))
|
|
781
1741
|
return;
|
|
782
1742
|
if (!isJson())
|
|
783
|
-
console.log(chalk.cyan(`\n Closing ${pos.side} ${
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
}
|
|
792
|
-
logExecution({
|
|
793
|
-
type: "market_order", exchange: adapter.name, symbol: sym,
|
|
794
|
-
side: closeSide, size: pos.size, status: "success", dryRun: false,
|
|
795
|
-
meta: { action: "close", originalSide: pos.side, smart: !!opts.smart },
|
|
796
|
-
});
|
|
1743
|
+
console.log(chalk.cyan(`\n Closing ${pos.side} ${size} ${sym} on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
|
|
1744
|
+
const o = await closeVerified(adapter, pos.symbol, pos.side, size, { smart: !!opts.smart, slippage, action: "close" });
|
|
1745
|
+
const partial = residualError(adapter.name, [o]);
|
|
1746
|
+
if (!isJson())
|
|
1747
|
+
printCloseOutcome(o);
|
|
1748
|
+
if (partial !== null)
|
|
1749
|
+
throw partial;
|
|
1750
|
+
await orphanedReduceOnly(adapter, sym, false);
|
|
797
1751
|
if (isJson())
|
|
798
|
-
return printJson(jsonOk(
|
|
799
|
-
console.log(
|
|
1752
|
+
return printJson(jsonOk({ ...o, verified: true }));
|
|
1753
|
+
console.log();
|
|
800
1754
|
});
|
|
801
1755
|
});
|
|
802
|
-
trade
|
|
803
|
-
.
|
|
804
|
-
.description("Cancel all orders AND close all positions (full cleanup)")
|
|
1756
|
+
supportsDryRun(trade.command("flatten"))
|
|
1757
|
+
.description("Cancel all orders AND close all positions (full cleanup, verified)")
|
|
805
1758
|
.option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
|
|
1759
|
+
.option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
|
|
806
1760
|
.action(async (opts) => {
|
|
807
1761
|
await withJsonErrors(isJson(), async () => {
|
|
808
1762
|
const adapter = await getAdapter();
|
|
1763
|
+
refuseGatedReduceOnly(adapter);
|
|
1764
|
+
const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
|
|
809
1765
|
if (dryRunGuard("flatten", { exchange: adapter.name, smart: !!opts.smart }))
|
|
810
1766
|
return;
|
|
811
1767
|
if (!isJson())
|
|
812
1768
|
console.log(chalk.cyan(`\n Flattening account on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
|
|
813
|
-
// Step 1: Cancel all orders
|
|
1769
|
+
// Step 1: Cancel all orders (per-order proof)
|
|
814
1770
|
if (!isJson())
|
|
815
1771
|
console.log(chalk.gray(" Cancelling all open orders..."));
|
|
816
|
-
const cancelResult = await adapter.
|
|
817
|
-
|
|
1772
|
+
const cancelResult = await v2Of(adapter).cancelAllV2(null);
|
|
1773
|
+
if (!isJson())
|
|
1774
|
+
printCancel(cancelResult, "all");
|
|
1775
|
+
// Step 2: Close all positions (verified)
|
|
1776
|
+
invalidateCache("acct");
|
|
818
1777
|
const positions = await adapter.getPositions();
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
meta: { action: "flatten", originalSide: pos.side, smart: !!opts.smart },
|
|
1778
|
+
const { outcomes, failures } = await closeEach(adapter, positions, { smart: !!opts.smart, slippage, action: "flatten" });
|
|
1779
|
+
// QFEX: reduce-only / stop orders still listed on a now-flat symbol (placed meanwhile, or not
|
|
1780
|
+
// covered by cancel-all) are cancelled one by one — the venue leaves them open (Q2).
|
|
1781
|
+
const orphanCancels = await orphanedReduceOnly(adapter, null, true);
|
|
1782
|
+
if (orphanCancels !== null) {
|
|
1783
|
+
cancelResult.cancelled.push(...orphanCancels.cancelled);
|
|
1784
|
+
cancelResult.failed.push(...orphanCancels.failed);
|
|
1785
|
+
(cancelResult.pending ??= []).push(...(orphanCancels.pending ?? []));
|
|
1786
|
+
}
|
|
1787
|
+
const partial = residualError(adapter.name, outcomes, failures);
|
|
1788
|
+
const cancelIncomplete = cancelIncompleteError(cancelResult, "all");
|
|
1789
|
+
if (partial !== null || cancelIncomplete !== null) {
|
|
1790
|
+
// Not fully flat: every cancel and close outcome so far rides on one ORDER_PARTIAL (non-zero exit).
|
|
1791
|
+
const messages = [cancelIncomplete?.message, partial?.message].filter((m) => typeof m === "string");
|
|
1792
|
+
throw new PerpError("ORDER_PARTIAL", `${adapter.name} flatten incomplete: ${messages.join(" | ")}`, {
|
|
1793
|
+
exchange: adapter.name, ordersCancelled: cancelResult, closes: outcomes, failures,
|
|
1794
|
+
remediation: `Check open orders and positions: perp -e ${adapter.name} account orders / account positions; clear a quarantine with perp strategy quarantine-clear <symbol>`,
|
|
837
1795
|
});
|
|
838
1796
|
}
|
|
839
1797
|
if (isJson())
|
|
840
|
-
return printJson(jsonOk({ ordersCancelled: cancelResult, positionsClosed:
|
|
841
|
-
console.log(chalk.green(`\n Flattened: cancelled
|
|
1798
|
+
return printJson(jsonOk({ ordersCancelled: cancelResult, positionsClosed: outcomes.length, closeResults: outcomes }));
|
|
1799
|
+
console.log(chalk.green(`\n Flattened: ${cancelResult.cancelled.length} order(s) cancelled + ${outcomes.length} position(s) closed (verified) on ${adapter.name}.\n`));
|
|
842
1800
|
});
|
|
843
1801
|
});
|
|
844
|
-
trade
|
|
845
|
-
.command("reduce <symbol> <percent>")
|
|
1802
|
+
supportsDryRun(trade.command("reduce <symbol> <percent>"))
|
|
846
1803
|
.description("Reduce a position by a percentage (e.g., perp trade reduce BTC 50)")
|
|
847
1804
|
.option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
|
|
1805
|
+
.option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
|
|
848
1806
|
.action(async (symbol, percent, opts) => {
|
|
849
1807
|
await withJsonErrors(isJson(), async () => {
|
|
850
1808
|
const sym = symbol.toUpperCase();
|
|
@@ -852,45 +1810,87 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
852
1810
|
if (isNaN(pct) || pct <= 0 || pct > 100)
|
|
853
1811
|
errorAndExit("Percent must be between 0 and 100");
|
|
854
1812
|
const adapter = await getAdapter();
|
|
1813
|
+
refuseGatedReduceOnly(adapter);
|
|
1814
|
+
const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
|
|
1815
|
+
invalidateCache("acct");
|
|
855
1816
|
const positions = await adapter.getPositions();
|
|
856
1817
|
const pos = positions.find(p => symbolMatch(p.symbol, sym));
|
|
857
1818
|
if (!pos) {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1819
|
+
throw new PerpError("POSITION_NOT_FOUND", `No open position for ${sym} on ${adapter.name}; nothing was reduced`, {
|
|
1820
|
+
exchange: adapter.name, remediation: `Check positions: perp account positions -e ${adapter.name}`,
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
// Reduce-only close size: position size × percent floored to the market's LOT step from the
|
|
1824
|
+
// venue market info (lotStepOf) — never to the decimals the position size happens to print
|
|
1825
|
+
// (QFEX GOLD-USD /refdata lot_size "0.0001": 50% of 0.003 is 0.0015, not 0.001). 100% →
|
|
1826
|
+
// the exact position size. Floored, so it can never exceed the position.
|
|
1827
|
+
const posSize = pos.size.replace(/^-/, "");
|
|
1828
|
+
let reduceSize = posSize;
|
|
1829
|
+
if (pct !== 100) {
|
|
1830
|
+
const sizeStep = await lotStepOf(adapter, pos.symbol);
|
|
1831
|
+
reduceSize = floorSize((parseFloat(posSize) * pct / 100).toFixed((sizeStep.split(".")[1] ?? "").length + 6), sizeStep);
|
|
1832
|
+
}
|
|
1833
|
+
if (parseFloat(reduceSize) > parseFloat(posSize)) {
|
|
1834
|
+
throw new PerpError("INVALID_PARAMS", `Computed reduce size ${reduceSize} exceeds position size ${posSize}`);
|
|
861
1835
|
}
|
|
862
|
-
const fullSize = parseFloat(pos.size);
|
|
863
|
-
const reduceSize = (fullSize * pct / 100).toString();
|
|
864
1836
|
const closeSide = pos.side === "long" ? "sell" : "buy";
|
|
865
|
-
if (
|
|
1837
|
+
if (await orderDryRunGuard("reduce", adapter, sym, { exchange: adapter.name, symbol: sym, side: closeSide, size: reduceSize, percent: pct, originalSize: pos.size, smart: !!opts.smart }))
|
|
866
1838
|
return;
|
|
867
1839
|
if (!isJson())
|
|
868
1840
|
console.log(chalk.cyan(`\n Reducing ${sym} ${pos.side} by ${pct}% (${reduceSize} of ${pos.size}) on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
}
|
|
877
|
-
logExecution({
|
|
878
|
-
type: "market_order", exchange: adapter.name, symbol: sym,
|
|
879
|
-
side: closeSide, size: reduceSize, status: "success", dryRun: false,
|
|
880
|
-
meta: { action: "reduce", percent: pct, originalSize: pos.size, originalSide: pos.side, smart: !!opts.smart },
|
|
881
|
-
});
|
|
1841
|
+
const o = await closeVerified(adapter, pos.symbol, pos.side, reduceSize, { smart: !!opts.smart, slippage, action: "reduce" });
|
|
1842
|
+
const partial = residualError(adapter.name, [o]);
|
|
1843
|
+
if (!isJson())
|
|
1844
|
+
printCloseOutcome(o);
|
|
1845
|
+
if (partial !== null)
|
|
1846
|
+
throw partial;
|
|
1847
|
+
await orphanedReduceOnly(adapter, sym, false);
|
|
882
1848
|
if (isJson())
|
|
883
|
-
return printJson(jsonOk({ reduced: true, percent: pct, sizeReduced: reduceSize, originalSize: pos.size, result }));
|
|
884
|
-
console.log(chalk.green(`\n Reduced ${sym} by ${
|
|
1849
|
+
return printJson(jsonOk({ reduced: true, percent: pct, sizeReduced: o.closed, requested: reduceSize, originalSize: pos.size, residual: o.residual, result: o }));
|
|
1850
|
+
console.log(chalk.green(`\n Reduced ${sym} by ${o.closed} (${closeSide}, verified) on ${adapter.name}.\n`));
|
|
885
1851
|
});
|
|
886
1852
|
});
|
|
887
1853
|
// ── Order Status Query ──
|
|
888
1854
|
trade
|
|
889
1855
|
.command("status <orderId>")
|
|
890
|
-
.description("Query order status by ID")
|
|
891
|
-
.
|
|
1856
|
+
.description("Query order status by ID (ondo/arcus/qfex/lighter/lighter-rh/aster: orderId or c:<clientOrderId>, with --symbol; pacifica/hyperliquid: orderId or c:<clientOrderId>)")
|
|
1857
|
+
.option("--symbol <symbol>", "Market of the order (required on ondo/arcus/qfex, and for c:<clientOrderId> on lighter / lighter-rh / aster)")
|
|
1858
|
+
.action(async (orderId, opts) => {
|
|
892
1859
|
await withJsonErrors(isJson(), async () => {
|
|
893
1860
|
const adapter = await getAdapter();
|
|
1861
|
+
if (isTradingV2(adapter)) {
|
|
1862
|
+
if (opts.symbol === undefined) {
|
|
1863
|
+
throw new PerpError("INVALID_PARAMS", `${adapter.venue.displayName} order status needs the market`, {
|
|
1864
|
+
remediation: `Pass --symbol <SYM>: perp trade status ${orderId} --symbol <SYM> -e ${adapter.venue.id}`,
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
const r = await adapter.getOrderStatus(opts.symbol.toUpperCase(), parseOrderRef(orderId));
|
|
1868
|
+
if (isJson())
|
|
1869
|
+
return printJson(jsonOk(orderResultJson(r)));
|
|
1870
|
+
printOrder(`Order ${orderId} (${r.symbol}) on ${adapter.name}`, r);
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
// c:<clientOrderId> on a legacy venue: the venue's own status query (pacifica, hyperliquid
|
|
1874
|
+
// orderStatus by cloid, aster GET /fapi/v3/order origClientOrderId, lighter / lighter-rh) —
|
|
1875
|
+
// never a local search that cannot match and answers ORDER_NOT_FOUND.
|
|
1876
|
+
const ref = parseOrderRef(orderId);
|
|
1877
|
+
if (ref.kind === "clientOrderId") {
|
|
1878
|
+
const def = getVenue(adapter.name);
|
|
1879
|
+
if (!requireCapabilities(def).clientOrderId) {
|
|
1880
|
+
throw unsupported(def.displayName, "statusByClientOrderId", "orders on this venue carry no client order id through perp-cli", `Query by venue order id: perp trade status <orderId> -e ${def.id}`);
|
|
1881
|
+
}
|
|
1882
|
+
// Pacifica / Hyperliquid query per account (the row names the market); the others per market.
|
|
1883
|
+
if (opts.symbol === undefined && def.family !== "pacifica" && def.family !== "hyperliquid") {
|
|
1884
|
+
throw new PerpError("INVALID_PARAMS", `${def.displayName} order status by client order id needs the market`, {
|
|
1885
|
+
remediation: `Pass --symbol <SYM>: perp trade status ${orderId} --symbol <SYM> -e ${def.id}`,
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
const r = await v2Of(adapter).getOrderStatus(opts.symbol !== undefined ? opts.symbol.toUpperCase() : "", ref);
|
|
1889
|
+
if (isJson())
|
|
1890
|
+
return printJson(jsonOk(orderResultJson(r)));
|
|
1891
|
+
printOrder(`Order ${orderId} (${r.symbol}) on ${adapter.name}`, r);
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
894
1894
|
if (isQueryOrderCapable(adapter)) {
|
|
895
1895
|
const result = await adapter.queryOrder(Number(orderId));
|
|
896
1896
|
if (isJson())
|
|
@@ -1016,12 +2016,12 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
1016
2016
|
});
|
|
1017
2017
|
});
|
|
1018
2018
|
// ── Scale-In (분할매수) ──
|
|
1019
|
-
trade
|
|
1020
|
-
.command("scale-in <symbol> <side>")
|
|
2019
|
+
supportsDryRun(trade.command("scale-in <symbol> <side>"))
|
|
1021
2020
|
.description("Place multiple limit orders at different price levels to build a position gradually (분할매수)")
|
|
1022
2021
|
.requiredOption("--levels <levels>", "Comma-separated price:percent pairs (e.g., 65000:30,63000:30,60000:40)")
|
|
1023
2022
|
.option("--size-usd <usd>", "Total USD amount to deploy across all levels")
|
|
1024
2023
|
.option("--size <base>", "Total base amount (e.g., 0.01 BTC)")
|
|
2024
|
+
.option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
|
|
1025
2025
|
.action(async (symbol, side, opts) => {
|
|
1026
2026
|
const sym = symbol.toUpperCase();
|
|
1027
2027
|
const s = side.toLowerCase();
|
|
@@ -1042,24 +2042,34 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
1042
2042
|
errorAndExit(`Percentages must sum to 100%. Got: ${totalPct}%`);
|
|
1043
2043
|
}
|
|
1044
2044
|
const adapter = await getAdapter();
|
|
1045
|
-
//
|
|
2045
|
+
// Quarantine first: it refuses the whole ladder before any market / lot read.
|
|
2046
|
+
assertNotQuarantined(adapter.name, sym, false);
|
|
2047
|
+
// Compute sizes for each level on the venue lot (validated before anything is sent)
|
|
2048
|
+
const step = await lotStepOf(adapter, sym);
|
|
1046
2049
|
let levelSizes;
|
|
1047
2050
|
if (opts.sizeUsd) {
|
|
1048
2051
|
const totalUsd = parseFloat(opts.sizeUsd);
|
|
1049
|
-
levelSizes = levels.map(l =>
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
2052
|
+
levelSizes = levels.map((l, i) => {
|
|
2053
|
+
const size = floorToStep(totalUsd * l.pct / 100 / parseFloat(l.price), step);
|
|
2054
|
+
if (!(Number(size) > 0)) {
|
|
2055
|
+
throw new PerpError("SIZE_TOO_SMALL", `Level ${i + 1} ($${(totalUsd * l.pct / 100).toFixed(2)} @ ${l.price}) is below the lot ${step}; nothing was sent`, {
|
|
2056
|
+
remediation: `Use fewer levels or a larger --size-usd: every level must be at least one lot (${step})`,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
return { price: l.price, pct: l.pct, size };
|
|
2060
|
+
});
|
|
1054
2061
|
}
|
|
1055
2062
|
else {
|
|
1056
|
-
const
|
|
1057
|
-
levelSizes = levels.map(l => ({
|
|
1058
|
-
price: l.price,
|
|
1059
|
-
pct: l.pct,
|
|
1060
|
-
size: (totalBase * l.pct / 100).toString(),
|
|
1061
|
-
}));
|
|
2063
|
+
const sizes = scaleLevelSizes(opts.size, levels.map(l => l.pct), step);
|
|
2064
|
+
levelSizes = levels.map((l, i) => ({ price: l.price, pct: l.pct, size: sizes[i] }));
|
|
1062
2065
|
}
|
|
2066
|
+
const reqs = await scaleRequests(adapter, sym, s, false, levelSizes);
|
|
2067
|
+
// Risk gate on the aggregate position being built (before the dry-run return — L5). scale-in is always an
|
|
2068
|
+
// opening flow (no reduce-only exemption); only --force bypasses. Notional
|
|
2069
|
+
// is the sum across levels, expressed as total base * volume-weighted price.
|
|
2070
|
+
const totalBase = levelSizes.reduce((acc, l) => acc + parseFloat(l.size), 0);
|
|
2071
|
+
const totalNotional = levelSizes.reduce((acc, l) => acc + parseFloat(l.size) * parseFloat(l.price), 0);
|
|
2072
|
+
await openingOrderGate(adapter, { symbol: sym, size: totalBase, price: totalBase > 0 ? totalNotional / totalBase : NaN, force: opts.force });
|
|
1063
2073
|
if (dryRunGuard("scale_in", {
|
|
1064
2074
|
exchange: adapter.name, symbol: sym, side: s,
|
|
1065
2075
|
totalSizeUsd: opts.sizeUsd ?? "N/A",
|
|
@@ -1068,42 +2078,16 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
1068
2078
|
}))
|
|
1069
2079
|
return;
|
|
1070
2080
|
// Place limit orders at each level (NOT reduce-only — opening positions)
|
|
1071
|
-
const results =
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
type: "limit_order", exchange: adapter.name, symbol: sym,
|
|
1078
|
-
side: s, size: level.size, price: level.price,
|
|
1079
|
-
status: "success", dryRun: false,
|
|
1080
|
-
meta: { action: "scale-in", pct: level.pct },
|
|
1081
|
-
});
|
|
1082
|
-
}
|
|
1083
|
-
catch (err) {
|
|
1084
|
-
const msg = extractErrorMessage(err);
|
|
1085
|
-
logExecution({
|
|
1086
|
-
type: "limit_order", exchange: adapter.name, symbol: sym,
|
|
1087
|
-
side: s, size: level.size, price: level.price,
|
|
1088
|
-
status: "failed", dryRun: false, error: msg,
|
|
1089
|
-
meta: { action: "scale-in", pct: level.pct },
|
|
1090
|
-
});
|
|
1091
|
-
if (isJson()) {
|
|
1092
|
-
results.push({ price: level.price, size: level.size, pct: level.pct, result: { error: msg } });
|
|
1093
|
-
}
|
|
1094
|
-
else {
|
|
1095
|
-
console.log(chalk.red(` Failed: ${level.price} x ${level.size} — ${msg}`));
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
2081
|
+
const results = await placeScaleLevels(adapter, "scale-in", sym, s, levelSizes, reqs);
|
|
2082
|
+
const incomplete = scaleIncompleteError(adapter.name, `scale-in ${sym}`, results);
|
|
2083
|
+
if (incomplete !== null) {
|
|
2084
|
+
if (!isJson())
|
|
2085
|
+
printScaleLevels(`Scale-in for ${sym} on ${adapter.name} is INCOMPLETE:`, sym, results);
|
|
2086
|
+
throw incomplete;
|
|
1098
2087
|
}
|
|
1099
2088
|
if (isJson())
|
|
1100
2089
|
return printJson(jsonOk({ symbol: sym, side: s, levels: results }));
|
|
1101
|
-
|
|
1102
|
-
for (const r of results) {
|
|
1103
|
-
const status = r.result?.error ? chalk.red("FAILED") : chalk.green("OK");
|
|
1104
|
-
console.log(` ${status} $${r.price} — ${r.pct}% (${r.size} ${sym})`);
|
|
1105
|
-
}
|
|
1106
|
-
console.log();
|
|
2090
|
+
printScaleLevels(`Scale-in orders placed for ${sym} on ${adapter.name}:`, sym, results);
|
|
1107
2091
|
});
|
|
1108
2092
|
// grid/dca/trailing-stop moved to 'perp strategy'
|
|
1109
2093
|
// ── PnL Tracker ──
|
|
@@ -1186,131 +2170,316 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
|
|
|
1186
2170
|
}
|
|
1187
2171
|
console.log(chalk.yellow(`\n PnL tracker stopped.\n`));
|
|
1188
2172
|
});
|
|
1189
|
-
// === Spot Trading ===
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
2173
|
+
// === Spot Trading (venue definitions, design §12) ===
|
|
2174
|
+
/** Spot adapter for the current venue via the factory; CAPABILITY_UNSUPPORTED when def.spot is null. */
|
|
2175
|
+
async function spotContext(confirmOnchainApprove) {
|
|
2176
|
+
const adapter = await getAdapter();
|
|
2177
|
+
const def = getVenue(adapter.name);
|
|
2178
|
+
const spotDef = requireSpotDefinition(def);
|
|
2179
|
+
if (confirmOnchainApprove && spotDef.accountModel !== "wallet") {
|
|
2180
|
+
throw unsupported(def.displayName, "onchainApprove", "--yes (on-chain approve) does not apply to this venue's spot", "Omit --yes (only on-chain wallet spot venues such as arcus send approve txs)");
|
|
2181
|
+
}
|
|
2182
|
+
const spot = await requireSpotAdapter(def.id, adapter, { confirmOnchainApprove });
|
|
2183
|
+
return { def, spotDef, spot };
|
|
2184
|
+
}
|
|
2185
|
+
/** Uniform feature name per spot capability (§13.6). */
|
|
2186
|
+
const SPOT_FEATURE = {
|
|
2187
|
+
limitOrder: "spotLimitOrder", orderbook: "spotOrderbook", cancel: "spotCancel", transfer: "spotTransfer",
|
|
2188
|
+
};
|
|
2189
|
+
function requireSpotCapability(def, spotDef, cap, what) {
|
|
2190
|
+
if (!spotDef.capabilities[cap]) {
|
|
2191
|
+
throw unsupported(def.displayName, SPOT_FEATURE[cap], `spot account model "${spotDef.accountModel}" has no ${what}`, spotDef.accountModel === "wallet"
|
|
2192
|
+
? `${def.displayName} spot is a market swap: use perp -e ${def.id} trade spot-buy|spot-sell`
|
|
2193
|
+
: spotDef.accountModel === "unified"
|
|
2194
|
+
? `${def.displayName} spot shares the perp account (one balance) — no transfer is needed`
|
|
2195
|
+
: `Use a venue whose spot supports ${what}`);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
function isSpotOrderResult(r) {
|
|
2199
|
+
if (r === null || typeof r !== "object")
|
|
2200
|
+
return false;
|
|
2201
|
+
const o = r;
|
|
2202
|
+
return typeof o.status === "string" && (o.finality === "final" || o.finality === "pending") && "receivedToken" in o;
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Uniform SpotOrderResult for a legacy raw spot result (HL statuses / Lighter sendTx ack).
|
|
2206
|
+
* A status / fill is reported only with proof: a venue-final IOC outcome (iocOutcomeOf),
|
|
2207
|
+
* HL `resting` (open) / `filled`; anything else is "pending" with a remediation — never "✓".
|
|
2208
|
+
*/
|
|
2209
|
+
function spotOrderResultOf(def, spot, symbol, size, raw, kind) {
|
|
2210
|
+
if (isSpotOrderResult(raw))
|
|
2211
|
+
return raw;
|
|
2212
|
+
const r = raw !== null && typeof raw === "object" ? raw : null;
|
|
2213
|
+
const sentSize = typeof r?.sentSize === "string" ? r.sentSize : size;
|
|
2214
|
+
const statuses = r?.response?.data?.statuses;
|
|
2215
|
+
const st0 = Array.isArray(statuses) && statuses[0] !== null && typeof statuses[0] === "object" ? statuses[0] : null;
|
|
2216
|
+
const filled = st0?.filled;
|
|
2217
|
+
const resting = st0?.resting;
|
|
2218
|
+
const idOf = (v) => (typeof v === "number" || (typeof v === "string" && v !== "") ? String(v) : null);
|
|
2219
|
+
const orderId = idOf(filled?.oid) ?? idOf(resting?.oid) ?? idOf(r?.clientOrderIndex) ?? idOf(r?.tx_hash);
|
|
2220
|
+
const avgPrice = typeof filled?.avgPx === "string" && Number(filled.avgPx) > 0 ? filled.avgPx : null;
|
|
2221
|
+
const base = { venue: def.id, symbol, orderId, avgPrice, receivedToken: null, venueRaw: raw };
|
|
2222
|
+
const outcome = isIocOutcomeCapable(spot) ? spot.iocOutcomeOf(raw) : null;
|
|
2223
|
+
if (outcome !== null) {
|
|
2224
|
+
const full = Number(sentSize) - Number(outcome.filledSize) <= iocLotTolerance(sentSize, outcome.filledSize);
|
|
2225
|
+
return { ...base, status: Number(outcome.filledSize) === 0 ? "cancelled" : full ? "filled" : "partial", filledSize: outcome.filledSize, finality: "final", remediation: null };
|
|
2226
|
+
}
|
|
2227
|
+
if (kind === "limit" && resting !== undefined) {
|
|
2228
|
+
return { ...base, status: "open", filledSize: "0", finality: "final", remediation: null };
|
|
2229
|
+
}
|
|
2230
|
+
const check = kind === "limit" ? `perp trade spot-orders ${symbol} -e ${def.id}` : `perp -e ${def.id} account spot-balance`;
|
|
2231
|
+
return {
|
|
2232
|
+
...base, status: "pending", filledSize: null, finality: "pending",
|
|
2233
|
+
remediation: `Sent; the venue response does not prove the outcome — do NOT resubmit. Check: ${check}`,
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
/** Human output: SpotOrderResult status / finality / remediation (legacy raw results are wrapped first). */
|
|
2237
|
+
function printSpotOrder(verb, def, symbol, size, result) {
|
|
2238
|
+
const ok = result.status === "filled" || result.status === "partial" || result.status === "open";
|
|
2239
|
+
const color = result.status === "rejected" ? chalk.red : ok && result.finality === "final" ? chalk.green : chalk.yellow;
|
|
2240
|
+
console.log(color(`\n Spot ${verb} ${size} ${result.symbol} on ${def.id}: ${result.status} (${result.finality})`));
|
|
2241
|
+
const parts = [
|
|
2242
|
+
result.orderId !== null ? `order ${result.orderId}` : null,
|
|
2243
|
+
result.filledSize !== null ? `filled ${result.filledSize}` : null,
|
|
2244
|
+
result.avgPrice !== null ? `avg ${result.avgPrice}` : null,
|
|
2245
|
+
result.receivedToken !== null ? `received ${result.receivedToken}` : null,
|
|
2246
|
+
].filter((x) => x !== null);
|
|
2247
|
+
if (parts.length > 0)
|
|
2248
|
+
console.log(chalk.gray(` ${parts.join(", ")}`));
|
|
2249
|
+
if (result.remediation !== null)
|
|
2250
|
+
console.log(chalk.yellow(` ${result.remediation}`));
|
|
2251
|
+
console.log();
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Execution record of a spot market order, same fields for every venue (design §13.8):
|
|
2255
|
+
* the resolved bound (bps + source) and, where the adapter reports it, the protection
|
|
2256
|
+
* price actually sent and its reference (`protection` on the raw result / venueRaw).
|
|
2257
|
+
*/
|
|
2258
|
+
function spotExecutionOf(spotDef, def, result, slippage) {
|
|
2259
|
+
const rec = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : null);
|
|
2260
|
+
const r = rec(result);
|
|
2261
|
+
const pr = rec(r?.protection) ?? rec(rec(r?.venueRaw)?.protection);
|
|
2262
|
+
const str = (v) => (typeof v === "string" && v !== "" ? v : null);
|
|
2263
|
+
const method = spotDef.accountModel === "wallet" ? "routerSwap" : def.family === "pacifica" ? "venueSlippage" : "protectedIoc";
|
|
2264
|
+
const notes = [];
|
|
2265
|
+
if (method === "routerSwap")
|
|
2266
|
+
notes.push(`swap with minimum received = quote × (1 − ${slippage.bps} bps); no protection price`);
|
|
2267
|
+
else if (method === "venueSlippage")
|
|
2268
|
+
notes.push(`sent slippage_percent ${slippage.bps / 100}; ${def.displayName} derives the protection price`);
|
|
2269
|
+
else if (pr === null)
|
|
2270
|
+
notes.push("protection price not reported by the adapter");
|
|
2271
|
+
else
|
|
2272
|
+
notes.push(`requested ${slippage.bps} bps; applied after price-grid alignment`);
|
|
2273
|
+
return {
|
|
2274
|
+
method,
|
|
2275
|
+
slippageBps: pr !== null && typeof pr.appliedBps === "number" ? pr.appliedBps : slippage.bps,
|
|
2276
|
+
slippageSource: slippage.source,
|
|
2277
|
+
protectionPrice: pr !== null ? str(pr.protectionPrice) : null,
|
|
2278
|
+
referencePrice: pr !== null ? str(pr.referencePrice) : null,
|
|
2279
|
+
tifRequested: null,
|
|
2280
|
+
tifSent: method === "protectedIoc" ? "IOC" : null,
|
|
2281
|
+
expiresAt: null,
|
|
2282
|
+
builderFee: null, // spot orders never carry a builder fee
|
|
2283
|
+
notes,
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
function printSpotExecution(e) {
|
|
2287
|
+
const parts = [e.method, `slippage ${e.slippageBps} bps (${e.slippageSource})`];
|
|
2288
|
+
if (e.protectionPrice !== null)
|
|
2289
|
+
parts.push(`protection ${e.protectionPrice}${e.referencePrice !== null ? ` (ref ${e.referencePrice})` : ""}`);
|
|
2290
|
+
console.log(chalk.gray(` Execution: ${parts.join(" · ")}`));
|
|
2291
|
+
for (const n of e.notes)
|
|
2292
|
+
console.log(chalk.gray(` Note: ${n}`));
|
|
2293
|
+
console.log();
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* One cancel result shape for every spot venue (SpotOrderResult). Legacy raw acks are
|
|
2297
|
+
* wrapped: HL statuses[0] "success" → cancelled (final), an `error` → ORDER_REJECTED;
|
|
2298
|
+
* any other ack (Lighter tx) → pending until the open orders confirm it.
|
|
2299
|
+
*/
|
|
2300
|
+
function spotCancelResult(def, symbol, orderId, raw) {
|
|
2301
|
+
if (isSpotOrderResult(raw))
|
|
2302
|
+
return raw;
|
|
2303
|
+
const statuses = raw?.response?.data?.statuses;
|
|
2304
|
+
const st0 = Array.isArray(statuses) ? statuses[0] : undefined;
|
|
2305
|
+
if (st0 !== null && typeof st0 === "object" && typeof st0.error === "string") {
|
|
2306
|
+
throw new PerpError("ORDER_REJECTED", `${def.displayName} spot cancel of ${orderId} refused: ${st0.error}`, { exchange: def.id });
|
|
2307
|
+
}
|
|
2308
|
+
const confirmed = st0 === "success";
|
|
2309
|
+
return {
|
|
2310
|
+
venue: def.id, symbol, orderId, status: confirmed ? "cancelled" : "pending", filledSize: null, avgPrice: null,
|
|
2311
|
+
finality: confirmed ? "final" : "pending", receivedToken: null,
|
|
2312
|
+
remediation: confirmed ? null : `Cancel sent; confirm with: perp trade spot-orders ${symbol} -e ${def.id}`,
|
|
2313
|
+
venueRaw: raw,
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
function spotExecStatus(result) {
|
|
2317
|
+
if (result.status === "rejected")
|
|
2318
|
+
return "failed";
|
|
2319
|
+
return result.finality === "pending" ? "unverified" : "success";
|
|
2320
|
+
}
|
|
2321
|
+
for (const [verb, side] of [["buy", "buy"], ["sell", "sell"]]) {
|
|
2322
|
+
supportsDryRun(trade.command(`spot-${verb} <symbol> <size>`))
|
|
2323
|
+
.description(`${verb === "buy" ? "Buy" : "Sell"} a spot token at market on any venue with spot (bare base uses the venue's default quote)`)
|
|
2324
|
+
.option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
|
|
2325
|
+
.option("--yes", "Allow the one-time on-chain approve(Permit2) tx (arcus; costs Robinhood Chain ETH gas)")
|
|
2326
|
+
.action(async (symbol, size, opts) => {
|
|
2327
|
+
await withJsonErrors(isJson(), async () => {
|
|
2328
|
+
const slippageFlag = parseSlippageBpsFlag(opts.slippageBps);
|
|
2329
|
+
const { def, spotDef, spot } = await spotContext(opts.yes === true);
|
|
2330
|
+
const slippage = resolveSlippage(def.id, slippageFlag);
|
|
2331
|
+
const sym = symbol.toUpperCase();
|
|
2332
|
+
if (dryRunGuard(`spot ${verb}`, { exchange: def.id, symbol: sym, side, size }))
|
|
2333
|
+
return;
|
|
2334
|
+
const raw = await spot.spotMarketOrder(sym, side, size, { slippage });
|
|
2335
|
+
const result = spotOrderResultOf(def, spot, sym, size, raw, "market");
|
|
2336
|
+
logExecution({ type: "market_order", exchange: def.id + "-spot", symbol: sym, side, size, status: spotExecStatus(result), dryRun: false, meta: { spot: true, result } });
|
|
2337
|
+
const execution = spotExecutionOf(spotDef, def, result, slippage);
|
|
2338
|
+
if (isJson()) {
|
|
2339
|
+
printJson(jsonOk({ order: result, symbol: sym, side, size, execution }));
|
|
1203
2340
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
const { LighterAdapter } = await import("../exchanges/lighter.js");
|
|
1208
|
-
if (adapter instanceof LighterAdapter) {
|
|
1209
|
-
spotAdapter = new LighterSpotAdapter(adapter);
|
|
2341
|
+
else {
|
|
2342
|
+
printSpotOrder(verb === "buy" ? "bought" : "sold", def, sym, size, result);
|
|
2343
|
+
printSpotExecution(execution);
|
|
1210
2344
|
}
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
2345
|
+
});
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
supportsDryRun(trade.command("spot-limit <symbol> <side> <price> <size>"))
|
|
2349
|
+
.description("Place a spot limit order (venues with spot limitOrder capability)")
|
|
2350
|
+
.option("--tif <tif>", "Time in force (venue-specific, e.g. GTC/IOC/ALO); omitted = the adapter's default")
|
|
2351
|
+
.action(async (symbol, side, price, size, opts) => {
|
|
2352
|
+
await withJsonErrors(isJson(), async () => {
|
|
2353
|
+
const s = side.toLowerCase();
|
|
2354
|
+
if (s !== "buy" && s !== "sell")
|
|
2355
|
+
throw new PerpError("INVALID_PARAMS", `Side must be buy or sell (got "${side}")`);
|
|
2356
|
+
const { def, spotDef, spot } = await spotContext(false);
|
|
2357
|
+
requireSpotCapability(def, spotDef, "limitOrder", "limit orders");
|
|
2358
|
+
const sym = symbol.toUpperCase();
|
|
2359
|
+
if (dryRunGuard("spot limit", { exchange: def.id, symbol: sym, side: s, price, size, tif: opts.tif ?? null }))
|
|
1216
2360
|
return;
|
|
2361
|
+
const raw = await spot.spotLimitOrder(sym, s, price, size, opts.tif !== undefined ? { tif: opts.tif } : undefined);
|
|
2362
|
+
const result = spotOrderResultOf(def, spot, sym, size, raw, "limit");
|
|
2363
|
+
logExecution({ type: "limit_order", exchange: def.id + "-spot", symbol: sym, side: s, size, price, status: spotExecStatus(result), dryRun: false, meta: { spot: true, tif: opts.tif ?? null, result } });
|
|
2364
|
+
if (isJson()) {
|
|
2365
|
+
printJson(jsonOk({ order: result, symbol: sym, side: s, price, size }));
|
|
2366
|
+
}
|
|
2367
|
+
else {
|
|
2368
|
+
printSpotOrder(`limit ${s} @ ${price}`, def, sym, size, result);
|
|
1217
2369
|
}
|
|
1218
|
-
|
|
1219
|
-
|
|
2370
|
+
});
|
|
2371
|
+
});
|
|
2372
|
+
supportsDryRun(trade.command("spot-cancel <symbol> <orderId>"))
|
|
2373
|
+
.description("Cancel a spot order (venues with spot cancel capability)")
|
|
2374
|
+
.action(async (symbol, orderId) => {
|
|
2375
|
+
await withJsonErrors(isJson(), async () => {
|
|
2376
|
+
const { def, spotDef, spot } = await spotContext(false);
|
|
2377
|
+
requireSpotCapability(def, spotDef, "cancel", "order cancel");
|
|
2378
|
+
const sym = symbol.toUpperCase();
|
|
2379
|
+
if (dryRunGuard("spot cancel", { exchange: def.id, symbol: sym, orderId }))
|
|
1220
2380
|
return;
|
|
1221
|
-
const result = await
|
|
1222
|
-
logExecution({ type: "
|
|
2381
|
+
const result = spotCancelResult(def, sym, orderId, await spot.spotCancelOrder(sym, orderId));
|
|
2382
|
+
logExecution({ type: "cancel", exchange: def.id + "-spot", symbol: sym, side: "cancel", size: "0", status: spotExecStatus(result), dryRun: false, meta: { spot: true, orderId, result } });
|
|
1223
2383
|
if (isJson()) {
|
|
1224
|
-
printJson(jsonOk({
|
|
2384
|
+
printJson(jsonOk({ cancelled: result, symbol: sym, orderId }));
|
|
1225
2385
|
}
|
|
1226
2386
|
else {
|
|
1227
|
-
|
|
2387
|
+
printSpotOrder("cancel", def, sym, orderId, result);
|
|
1228
2388
|
}
|
|
1229
2389
|
});
|
|
1230
2390
|
});
|
|
1231
2391
|
trade
|
|
1232
|
-
.command("spot-
|
|
1233
|
-
.description("
|
|
1234
|
-
.action(async (symbol
|
|
2392
|
+
.command("spot-orders [symbol]")
|
|
2393
|
+
.description("List open spot orders (optionally for one symbol)")
|
|
2394
|
+
.action(async (symbol) => {
|
|
1235
2395
|
await withJsonErrors(isJson(), async () => {
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
if (exchangeName === "hyperliquid") {
|
|
1240
|
-
const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
|
|
1241
|
-
const { HyperliquidAdapter } = await import("../exchanges/hyperliquid.js");
|
|
1242
|
-
if (adapter instanceof HyperliquidAdapter) {
|
|
1243
|
-
spotAdapter = new HyperliquidSpotAdapter(adapter);
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
else if (exchangeName === "lighter") {
|
|
1247
|
-
const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
|
|
1248
|
-
const { LighterAdapter } = await import("../exchanges/lighter.js");
|
|
1249
|
-
if (adapter instanceof LighterAdapter) {
|
|
1250
|
-
spotAdapter = new LighterSpotAdapter(adapter);
|
|
1251
|
-
}
|
|
2396
|
+
const { def, spotDef, spot } = await spotContext(false);
|
|
2397
|
+
if (spotDef.accountModel === "wallet" || !isSpotOpenOrdersCapable(spot)) {
|
|
2398
|
+
throw unsupported(def.displayName, "spotOpenOrders", spotDef.accountModel === "wallet" ? "spot is an on-chain swap that settles immediately — there are no resting orders" : "the spot adapter cannot list open orders", spotDef.accountModel === "wallet" ? `Check the wallet balance: perp -e ${def.id} trade spot-balance` : "Use the venue UI to inspect open spot orders");
|
|
1252
2399
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
2400
|
+
const orders = await spot.getSpotOpenOrders(symbol === undefined ? null : symbol.toUpperCase());
|
|
2401
|
+
if (isJson())
|
|
2402
|
+
return printJson(jsonOk({ exchange: def.id, orders }));
|
|
2403
|
+
console.log(chalk.cyan(`\n Open spot orders (${def.id}):\n`));
|
|
2404
|
+
if (orders.length === 0) {
|
|
2405
|
+
console.log(chalk.gray(" No open spot orders\n"));
|
|
1257
2406
|
return;
|
|
1258
2407
|
}
|
|
1259
|
-
await
|
|
1260
|
-
|
|
2408
|
+
const { makeTable } = await import("../utils.js");
|
|
2409
|
+
console.log(makeTable(["Order", "Symbol", "Side", "Price", "Size", "Filled"], orders.map(o => [o.orderId, o.symbol, o.side, o.price, o.size, o.filled])));
|
|
2410
|
+
console.log();
|
|
2411
|
+
});
|
|
2412
|
+
});
|
|
2413
|
+
supportsDryRun(trade.command("spot-transfer <direction> <amount>"))
|
|
2414
|
+
.description("Move the venue's spot quote asset between perp and spot (to-spot | to-perp). Shared-balance accounts (pacifica, HL unified, Lighter unified trading account): success no-op (execution.method sharedAccount); wallet spot (arcus): unsupported")
|
|
2415
|
+
.action(async (direction, amount) => {
|
|
2416
|
+
await withJsonErrors(isJson(), async () => {
|
|
2417
|
+
const dir = direction.toLowerCase();
|
|
2418
|
+
if (dir !== "to-spot" && dir !== "to-perp")
|
|
2419
|
+
throw new PerpError("INVALID_PARAMS", `Direction must be to-spot or to-perp (got "${direction}")`);
|
|
2420
|
+
const n = Number(amount);
|
|
2421
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
2422
|
+
throw new PerpError("INVALID_PARAMS", `Amount must be a positive number (got "${amount}")`);
|
|
2423
|
+
const { def, spotDef, spot } = await spotContext(false);
|
|
2424
|
+
// Wallet model (arcus): spot is the on-chain wallet, not an exchange account → uniform unsupported.
|
|
2425
|
+
// Unified accounts (pacifica, HL unified, Lighter account_trading_mode 1) → success no-op below (execution.method "sharedAccount").
|
|
2426
|
+
if (spotDef.accountModel === "wallet") {
|
|
2427
|
+
throw unsupported(def.displayName, "spotTransfer", "spot is the on-chain wallet (no exchange spot account to transfer into)", `Fund the wallet directly; check it with: perp -e ${def.id} trade spot-balance`);
|
|
2428
|
+
}
|
|
2429
|
+
const asset = defaultSpotQuote(def, network());
|
|
2430
|
+
if (dryRunGuard("spot transfer", { exchange: def.id, direction: dir, amount, asset }))
|
|
1261
2431
|
return;
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
2432
|
+
let moved;
|
|
2433
|
+
try {
|
|
2434
|
+
moved = dir === "to-spot" ? await transferQuoteToSpot(spot, n) : await transferQuoteToPerp(spot, n);
|
|
2435
|
+
}
|
|
2436
|
+
catch (e) {
|
|
2437
|
+
// Refused for lack of funds → say how much the source side can move (read live).
|
|
2438
|
+
throw await transferShortfallError(e, {
|
|
2439
|
+
venue: def.id, direction: dir, amount, asset,
|
|
2440
|
+
readAvailable: async () => {
|
|
2441
|
+
if (dir !== "to-spot")
|
|
2442
|
+
return readSpotQuoteAvailableFresh(spot);
|
|
2443
|
+
const perp = await getAdapter();
|
|
2444
|
+
// Aster margin is multi-asset: the account-wide available balance is not an amount of `asset`.
|
|
2445
|
+
return typeof perp.getPerpAssetTransferable === "function" ? perp.getPerpAssetTransferable(asset) : Number((await perp.getBalance()).available);
|
|
2446
|
+
},
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
const execution = moved
|
|
2450
|
+
? { method: "transfer", notes: [`${asset} ${dir === "to-spot" ? "perp → spot" : "spot → perp"}`] }
|
|
2451
|
+
: { method: "sharedAccount", notes: [`${def.displayName} spot and perp share one balance for this account — no transfer needed`] };
|
|
2452
|
+
if (isJson())
|
|
2453
|
+
return printJson(jsonOk({ exchange: def.id, direction: dir, amount, asset, transferred: moved, execution }));
|
|
2454
|
+
if (moved) {
|
|
2455
|
+
console.log(chalk.green(`\n ✓ Transferred ${amount} ${asset} ${dir === "to-spot" ? "perp → spot" : "spot → perp"} on ${def.id}`));
|
|
1266
2456
|
}
|
|
1267
2457
|
else {
|
|
1268
|
-
console.log(chalk.green(`\n ✓
|
|
2458
|
+
console.log(chalk.green(`\n ✓ Nothing to move on ${def.id}: spot and perp share one balance`));
|
|
1269
2459
|
}
|
|
2460
|
+
console.log(chalk.gray(` Execution: ${execution.method}\n`));
|
|
1270
2461
|
});
|
|
1271
2462
|
});
|
|
1272
2463
|
trade
|
|
1273
2464
|
.command("spot-balance")
|
|
1274
|
-
.description("Show spot token balances (
|
|
2465
|
+
.description("Show spot token balances (any venue with spot)")
|
|
1275
2466
|
.action(async () => {
|
|
1276
2467
|
await withJsonErrors(isJson(), async () => {
|
|
1277
|
-
const
|
|
1278
|
-
const
|
|
1279
|
-
let spotAdapter;
|
|
1280
|
-
if (exchangeName === "hyperliquid") {
|
|
1281
|
-
const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
|
|
1282
|
-
const { HyperliquidAdapter } = await import("../exchanges/hyperliquid.js");
|
|
1283
|
-
if (adapter instanceof HyperliquidAdapter) {
|
|
1284
|
-
spotAdapter = new HyperliquidSpotAdapter(adapter);
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
else if (exchangeName === "lighter") {
|
|
1288
|
-
const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
|
|
1289
|
-
const { LighterAdapter } = await import("../exchanges/lighter.js");
|
|
1290
|
-
if (adapter instanceof LighterAdapter) {
|
|
1291
|
-
spotAdapter = new LighterSpotAdapter(adapter);
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
if (!spotAdapter) {
|
|
1295
|
-
if (isJson())
|
|
1296
|
-
return printJson(jsonError("UNSUPPORTED", `${exchangeName} does not support spot trading`));
|
|
1297
|
-
errorAndExit(`${exchangeName} does not support spot trading`);
|
|
1298
|
-
return;
|
|
1299
|
-
}
|
|
1300
|
-
await spotAdapter.init();
|
|
1301
|
-
const balances = await spotAdapter.getSpotBalances();
|
|
2468
|
+
const { def, spot } = await spotContext(false);
|
|
2469
|
+
const balances = await spot.getSpotBalances();
|
|
1302
2470
|
const nonZero = balances.filter(b => Number(b.total) > 0);
|
|
1303
2471
|
if (isJson()) {
|
|
1304
|
-
printJson(jsonOk({ exchange:
|
|
2472
|
+
printJson(jsonOk({ exchange: def.id, balances: nonZero }));
|
|
1305
2473
|
}
|
|
1306
2474
|
else {
|
|
1307
|
-
console.log(chalk.cyan(`\n Spot Balances (${
|
|
2475
|
+
console.log(chalk.cyan(`\n Spot Balances (${def.id}):\n`));
|
|
1308
2476
|
if (nonZero.length === 0) {
|
|
1309
2477
|
console.log(chalk.gray(" No spot tokens\n"));
|
|
1310
2478
|
}
|
|
1311
2479
|
else {
|
|
1312
2480
|
for (const b of nonZero) {
|
|
1313
|
-
|
|
2481
|
+
const sharedNote = b.shared ? chalk.gray(" (shared with perp margin)") : "";
|
|
2482
|
+
console.log(` ${b.token.padEnd(10)} ${b.total.padEnd(15)} available: ${b.available}${sharedNote}`);
|
|
1314
2483
|
}
|
|
1315
2484
|
console.log();
|
|
1316
2485
|
}
|
|
@@ -1333,15 +2502,14 @@ function parseMultiLeg(spec) {
|
|
|
1333
2502
|
throw new Error(`Invalid leg format "${spec}" — expected exchange:symbol:side:size`);
|
|
1334
2503
|
}
|
|
1335
2504
|
const [exchange, symbol, side, size] = parts;
|
|
1336
|
-
// Normalize exchange aliases
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
if (!normalizedExchange) {
|
|
1343
|
-
throw new Error(`Unknown exchange "${exchange}" in leg spec`);
|
|
2505
|
+
// Normalize exchange aliases via the venue registry ("lit" kept as a legacy alias of lighter)
|
|
2506
|
+
const exRef = exchange.toLowerCase() === "lit" ? "lighter" : exchange;
|
|
2507
|
+
const trading = listVenuesWithRole("trading");
|
|
2508
|
+
const def = trading.find(v => v.id === exRef.toLowerCase() || v.aliases.includes(exRef.toLowerCase()));
|
|
2509
|
+
if (!def) {
|
|
2510
|
+
throw new Error(`Unknown exchange "${exchange}" in leg spec — valid: ${trading.map(v => v.id).join(", ")}`);
|
|
1344
2511
|
}
|
|
2512
|
+
const normalizedExchange = def.id;
|
|
1345
2513
|
if (side !== "buy" && side !== "sell") {
|
|
1346
2514
|
throw new Error(`Invalid side "${side}" — must be "buy" or "sell"`);
|
|
1347
2515
|
}
|
|
@@ -1357,12 +2525,18 @@ function registerMultiAction(parent, getAdapterForExchange, isJson, deprecated =
|
|
|
1357
2525
|
if (deprecated)
|
|
1358
2526
|
cmd._hidden = true;
|
|
1359
2527
|
cmd
|
|
1360
|
-
.option("--smart", "
|
|
2528
|
+
.option("--smart", "Send each opening leg as an IOC limit at best bid/ask + 1 tick (no market fallback: a leg that does not match fails)")
|
|
1361
2529
|
.option("--rollback", "Auto-rollback filled legs if any leg fails", true)
|
|
1362
2530
|
.option("--no-rollback", "Disable auto-rollback")
|
|
1363
|
-
.option("--timeout <ms>", "
|
|
2531
|
+
.option("--timeout <ms>", "Removed: legs are verified by their position, never timed out (passing it is an error)")
|
|
2532
|
+
.option("--force", "Bypass local risk-limit checks (maxPosition / total exposure) for every leg")
|
|
1364
2533
|
.action(async (legSpecs, opts) => {
|
|
1365
2534
|
await withJsonErrors(isJson(), async () => {
|
|
2535
|
+
if (opts.timeout !== undefined) {
|
|
2536
|
+
throw new PerpError("INVALID_PARAMS", "--timeout was removed from 'trade multi': each leg is verified by its position delta; an unconfirmed leg is reported (and quarantined), never treated as failed after a timeout", {
|
|
2537
|
+
remediation: "Drop --timeout",
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
1366
2540
|
// Parse legs
|
|
1367
2541
|
const legs = legSpecs.map(parseMultiLeg);
|
|
1368
2542
|
if (legs.length < 2) {
|
|
@@ -1388,101 +2562,175 @@ function registerMultiAction(parent, getAdapterForExchange, isJson, deprecated =
|
|
|
1388
2562
|
adapters.set(l.exchange, await getAdapterForExchange(l.exchange));
|
|
1389
2563
|
}
|
|
1390
2564
|
}
|
|
1391
|
-
//
|
|
1392
|
-
const
|
|
1393
|
-
|
|
2565
|
+
// Quarantined symbols (an earlier unknown outcome) are never traded automatically.
|
|
2566
|
+
for (const l of legs) {
|
|
2567
|
+
const q = quarantineEntryFor(l.symbol, (m) => { if (!isJson())
|
|
2568
|
+
console.error(m); });
|
|
2569
|
+
if (q !== null) {
|
|
2570
|
+
throw new PerpError("RISK_VIOLATION", `${l.symbol} is quarantined by ${q.strategy}: ${q.reason}`, { remediation: q.remediation });
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
// Risk gate every leg before any order goes out, so a breach aborts the
|
|
2574
|
+
// whole multi-leg up front. --force bypasses; the rollback orders below
|
|
2575
|
+
// are exempt (they reduce the just-opened legs).
|
|
2576
|
+
await Promise.all(legs.map((leg) => enforceOrderRisk(adapters.get(leg.exchange), {
|
|
2577
|
+
symbol: leg.symbol,
|
|
2578
|
+
size: parseFloat(leg.size),
|
|
2579
|
+
force: opts.force,
|
|
2580
|
+
})));
|
|
2581
|
+
// Lot step per leg from the venue market info (unknown → throws before any order goes out):
|
|
2582
|
+
// each leg is sent floored to it and its fill judged against that sent size.
|
|
2583
|
+
const legSteps = new Map();
|
|
2584
|
+
for (const l of legs)
|
|
2585
|
+
legSteps.set(l, await lotStepOf(adapters.get(l.exchange), l.symbol));
|
|
2586
|
+
// Execute legs ONE AT A TIME, each verified by its position delta (placePerpVerified):
|
|
2587
|
+
// parallel legs on one venue/symbol would corrupt each other's measurement. With
|
|
2588
|
+
// rollback on, the first failure stops further legs. An unknown outcome stops
|
|
2589
|
+
// everything: that leg is quarantined and NOTHING is rolled back (the unknown leg may
|
|
2590
|
+
// have executed — undoing the others could leave it naked).
|
|
2591
|
+
const log = (m) => { if (!isJson())
|
|
2592
|
+
console.log(chalk.red(m)); };
|
|
2593
|
+
const quarantineLeg = (leg, reason) => quarantineSymbol("trade-multi", leg.symbol, reason, `Check ${leg.symbol} on ${leg.exchange} (perp -e ${leg.exchange} account positions) and the other legs; fix by hand, then: perp strategy quarantine-clear ${leg.symbol}`, log);
|
|
2594
|
+
const legResults = legs.map(leg => ({ leg, status: "skipped", filledSize: null }));
|
|
2595
|
+
let stop = false;
|
|
2596
|
+
for (const r of legResults) {
|
|
2597
|
+
if (stop)
|
|
2598
|
+
break;
|
|
2599
|
+
const leg = r.leg;
|
|
1394
2600
|
const adapter = adapters.get(leg.exchange);
|
|
1395
|
-
const
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
meta: { result, smart: opts.smart }
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
2601
|
+
const step = legSteps.get(leg);
|
|
2602
|
+
try {
|
|
2603
|
+
const sentSize = floorSize(leg.size, step);
|
|
2604
|
+
const f = await placePerpVerified(adapter, leg.exchange, leg.symbol, leg.side, sentSize, {
|
|
2605
|
+
stepSize: step,
|
|
2606
|
+
...(opts.smart ? { place: () => smartOrder(adapter, leg.symbol, leg.side, sentSize).then(x => x.result) } : {}),
|
|
2607
|
+
});
|
|
2608
|
+
r.status = "filled";
|
|
2609
|
+
r.filledSize = floorToStep(f.filledSize, step);
|
|
2610
|
+
r.result = f.raw;
|
|
2611
|
+
// Accepted partial fill (venue-final IOC partial, or ≥ 80% measured): the legs no longer
|
|
2612
|
+
// match — reported as ORDER_PARTIAL; with rollback on it stops and is rolled back.
|
|
2613
|
+
if (Number(r.filledSize) < Number(sentSize)) {
|
|
2614
|
+
r.error = `partial fill: ${r.filledSize} of ${sentSize}${sentSize !== leg.size ? ` (requested ${leg.size}, floored to lot ${step})` : ""}`;
|
|
2615
|
+
if (opts.rollback)
|
|
2616
|
+
stop = true;
|
|
2617
|
+
}
|
|
2618
|
+
logExecution({ type: "multi_leg", exchange: leg.exchange, symbol: leg.symbol, side: leg.side, size: leg.size, status: "success", dryRun: false, meta: { result: f.raw, smart: opts.smart, measuredFill: f.filledSize } });
|
|
2619
|
+
}
|
|
2620
|
+
catch (e) {
|
|
2621
|
+
r.error = extractErrorMessage(e);
|
|
2622
|
+
logExecution({ type: "multi_leg", exchange: leg.exchange, symbol: leg.symbol, side: leg.side, size: leg.size, status: isOutcomeUnknown(e) ? "unverified" : "failed", error: r.error, dryRun: false, meta: { smart: opts.smart, pending: isOutcomeUnknown(e) } });
|
|
2623
|
+
if (isOutcomeUnknown(e)) {
|
|
2624
|
+
r.status = "unknown";
|
|
2625
|
+
quarantineLeg(leg, `trade multi: ${leg.side} ${leg.size} ${leg.symbol} on ${leg.exchange} outcome unknown (${r.error})`);
|
|
2626
|
+
stop = true;
|
|
2627
|
+
}
|
|
2628
|
+
else {
|
|
2629
|
+
r.status = "failed";
|
|
2630
|
+
if (opts.rollback)
|
|
2631
|
+
stop = true;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
1426
2635
|
const filled = legResults.filter(r => r.status === "filled");
|
|
1427
2636
|
const failed = legResults.filter(r => r.status === "failed");
|
|
1428
|
-
|
|
1429
|
-
|
|
2637
|
+
const unknown = legResults.filter(r => r.status === "unknown");
|
|
2638
|
+
// Rollback (definite failures or partial fills only): each verified-filled leg, reduce-only, sized to its
|
|
2639
|
+
// MEASURED fill, verified with one residual close. Rejected / unconfirmed / residual → quarantine.
|
|
2640
|
+
const partial = filled.filter(r => r.error !== undefined);
|
|
2641
|
+
if ((failed.length > 0 || partial.length > 0) && unknown.length === 0 && filled.length > 0 && opts.rollback) {
|
|
1430
2642
|
if (!isJson()) {
|
|
1431
|
-
console.log(chalk.yellow(`\n ${failed.length} leg(s) failed — rolling back ${filled.length} filled leg(s)...\n`));
|
|
2643
|
+
console.log(chalk.yellow(`\n ${failed.length} leg(s) failed${partial.length > 0 ? `, ${partial.length} partially filled` : ""} — rolling back ${filled.length} filled leg(s) (reduce-only)...\n`));
|
|
1432
2644
|
}
|
|
1433
|
-
for (const fr of filled) {
|
|
2645
|
+
for (const fr of [...filled].reverse()) {
|
|
1434
2646
|
const adapter = adapters.get(fr.leg.exchange);
|
|
1435
2647
|
const reverseSide = fr.leg.side === "buy" ? "sell" : "buy";
|
|
2648
|
+
const rbSize = fr.filledSize;
|
|
2649
|
+
let why = null;
|
|
1436
2650
|
try {
|
|
1437
|
-
await adapter.
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
type: "multi_leg_rollback",
|
|
1441
|
-
exchange: fr.leg.exchange,
|
|
1442
|
-
symbol: fr.leg.symbol,
|
|
1443
|
-
side: reverseSide,
|
|
1444
|
-
size: fr.leg.size,
|
|
1445
|
-
status: "success",
|
|
1446
|
-
dryRun: false,
|
|
1447
|
-
});
|
|
1448
|
-
if (!isJson()) {
|
|
1449
|
-
console.log(chalk.gray(` Rolled back: ${fr.leg.exchange} ${reverseSide} ${fr.leg.symbol} x ${fr.leg.size}`));
|
|
1450
|
-
}
|
|
2651
|
+
const c = await closePerpVerified(adapter, fr.leg.exchange, fr.leg.symbol, reverseSide, rbSize, { stepSize: legSteps.get(fr.leg) });
|
|
2652
|
+
if (c.residual > 0)
|
|
2653
|
+
why = `rollback closed ${c.closed} of ${rbSize}, ${c.residual} still open${c.note !== null ? ` (${c.note})` : ""}`;
|
|
1451
2654
|
}
|
|
1452
2655
|
catch (err) {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
2656
|
+
why = `rollback ${isOutcomeUnknown(err) ? "outcome unknown" : "failed"}: ${extractErrorMessage(err)}`;
|
|
2657
|
+
}
|
|
2658
|
+
logExecution({ type: "multi_leg_rollback", exchange: fr.leg.exchange, symbol: fr.leg.symbol, side: reverseSide, size: rbSize, status: why === null ? "success" : "failed", ...(why !== null ? { error: why } : {}), dryRun: false });
|
|
2659
|
+
if (why === null) {
|
|
2660
|
+
fr.status = "rolled_back";
|
|
2661
|
+
if (!isJson())
|
|
2662
|
+
console.log(chalk.gray(` Rolled back: ${fr.leg.exchange} ${reverseSide} ${fr.leg.symbol} x ${rbSize} (reduce-only)`));
|
|
2663
|
+
}
|
|
2664
|
+
else {
|
|
2665
|
+
fr.status = "rollback_failed";
|
|
2666
|
+
fr.error = why;
|
|
2667
|
+
quarantineLeg(fr.leg, `trade multi: ${fr.leg.side} ${rbSize} ${fr.leg.symbol} on ${fr.leg.exchange} filled; ${why}`);
|
|
1456
2668
|
}
|
|
1457
2669
|
}
|
|
1458
2670
|
}
|
|
2671
|
+
// Not a success unless every leg filled: unknown → ORDER_PENDING; a failed rollback or legs
|
|
2672
|
+
// left filled next to a failed one → ORDER_PARTIAL; failed with nothing left open →
|
|
2673
|
+
// ORDER_REJECTED. Scripts get ok:false + exit code 1 with the full per-leg details.
|
|
2674
|
+
const rollbackFailed = legResults.filter(r => r.status === "rollback_failed");
|
|
2675
|
+
const stillFilled = legResults.filter(r => r.status === "filled");
|
|
2676
|
+
const outcome = unknown.length > 0 ? "ORDER_PENDING"
|
|
2677
|
+
: rollbackFailed.length > 0 ? "ORDER_PARTIAL"
|
|
2678
|
+
: failed.length > 0 || partial.length > 0 ? (stillFilled.length > 0 ? "ORDER_PARTIAL" : "ORDER_REJECTED")
|
|
2679
|
+
: null;
|
|
2680
|
+
if (outcome !== null)
|
|
2681
|
+
process.exitCode = 1;
|
|
1459
2682
|
if (isJson()) {
|
|
1460
|
-
|
|
2683
|
+
const payload = {
|
|
1461
2684
|
legs: legResults.map(r => ({
|
|
1462
2685
|
exchange: r.leg.exchange,
|
|
1463
2686
|
symbol: r.leg.symbol,
|
|
1464
2687
|
side: r.leg.side,
|
|
1465
2688
|
size: r.leg.size,
|
|
2689
|
+
filledSize: r.filledSize,
|
|
1466
2690
|
status: r.status,
|
|
1467
2691
|
error: r.error,
|
|
1468
2692
|
})),
|
|
1469
2693
|
summary: {
|
|
1470
2694
|
total: legs.length,
|
|
1471
|
-
filled:
|
|
2695
|
+
filled: stillFilled.length,
|
|
1472
2696
|
failed: failed.length,
|
|
2697
|
+
unknown: unknown.length,
|
|
2698
|
+
skipped: legResults.filter(r => r.status === "skipped").length,
|
|
1473
2699
|
rolledBack: legResults.filter(r => r.status === "rolled_back").length,
|
|
2700
|
+
rollbackFailed: rollbackFailed.length,
|
|
2701
|
+
quarantined: [...new Set(legResults.filter(r => r.status === "unknown" || r.status === "rollback_failed").map(r => r.leg.symbol))],
|
|
1474
2702
|
},
|
|
1475
|
-
}
|
|
2703
|
+
};
|
|
2704
|
+
if (outcome === null) {
|
|
2705
|
+
printJson(jsonOk(payload));
|
|
2706
|
+
}
|
|
2707
|
+
else {
|
|
2708
|
+
const message = outcome === "ORDER_PENDING" ? `trade multi: ${unknown.length} leg(s) have an unknown outcome (quarantined)`
|
|
2709
|
+
: outcome === "ORDER_PARTIAL" ? `trade multi: ${failed.length} leg(s) failed${partial.length > 0 ? `, ${partial.length} partially filled` : ""}; ${rollbackFailed.length > 0 ? `${rollbackFailed.length} rollback(s) failed (quarantined)` : `${stillFilled.length} leg(s) remain filled`}`
|
|
2710
|
+
: `trade multi: ${failed.length} leg(s) failed${partial.length > 0 ? `, ${partial.length} partially filled` : ""}; nothing left open`;
|
|
2711
|
+
printJson(jsonError(outcome, message, {
|
|
2712
|
+
status: ERROR_CODES[outcome].status,
|
|
2713
|
+
retryable: false,
|
|
2714
|
+
details: payload,
|
|
2715
|
+
...(outcome !== "ORDER_REJECTED" ? { remediation: "Do NOT resubmit — check every leg's position (perp strategy quarantine; account positions per venue)" } : {}),
|
|
2716
|
+
}));
|
|
2717
|
+
}
|
|
1476
2718
|
}
|
|
1477
2719
|
else {
|
|
1478
2720
|
console.log(chalk.cyan.bold("\nResults:\n"));
|
|
1479
2721
|
for (const r of legResults) {
|
|
1480
2722
|
const icon = r.status === "filled" ? chalk.green("OK") :
|
|
1481
2723
|
r.status === "rolled_back" ? chalk.yellow("ROLLBACK") :
|
|
1482
|
-
chalk.
|
|
1483
|
-
|
|
2724
|
+
r.status === "skipped" ? chalk.gray("SKIP") :
|
|
2725
|
+
r.status === "unknown" ? chalk.red.bold("UNKNOWN (quarantined)") :
|
|
2726
|
+
r.status === "rollback_failed" ? chalk.red.bold("ROLLBACK FAILED (quarantined)") :
|
|
2727
|
+
chalk.red("FAIL");
|
|
2728
|
+
const fill = r.filledSize !== null && r.filledSize !== r.leg.size ? ` (filled ${r.filledSize})` : "";
|
|
2729
|
+
console.log(` ${icon} ${r.leg.exchange.padEnd(14)} ${r.leg.side} ${r.leg.symbol} x ${r.leg.size}${fill}${r.error ? ` — ${r.error}` : ""}`);
|
|
1484
2730
|
}
|
|
1485
|
-
console.log(`\n Total: ${legs.length} Filled: ${filled.length} Failed: ${failed.length} Rolled back: ${legResults.filter(r => r.status === "rolled_back").length}`);
|
|
2731
|
+
console.log(`\n Total: ${legs.length} Filled: ${legResults.filter(r => r.status === "filled").length} Failed: ${failed.length} Unknown: ${unknown.length} Rolled back: ${legResults.filter(r => r.status === "rolled_back").length}`);
|
|
2732
|
+
if (unknown.length > 0)
|
|
2733
|
+
console.log(chalk.red(" An unconfirmed leg was quarantined — do NOT resubmit; check the positions (perp strategy quarantine)."));
|
|
1486
2734
|
}
|
|
1487
2735
|
});
|
|
1488
2736
|
});
|