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
|
@@ -1,18 +1,229 @@
|
|
|
1
|
+
import { ARB_MARGIN_BUFFER, marketLeverageOf, requiredArbMargin } from "../rebalance.js";
|
|
2
|
+
import { ERROR_CODES, PerpError } from "../errors.js";
|
|
3
|
+
import { unsupported } from "../exchanges/trading-v2.js";
|
|
4
|
+
import { tradingV2Of } from "../exchanges/legacy-trading-v2.js";
|
|
5
|
+
import { invalidateCache } from "../cache.js";
|
|
6
|
+
import { isDryRun, networkOf, supportsDryRun } from "../cli-flags.js";
|
|
1
7
|
import chalk from "chalk";
|
|
2
|
-
import { formatUsd, printJson, jsonOk, makeTable, logSettledRejections } from "../utils.js";
|
|
8
|
+
import { formatUsd, printJson, jsonOk, jsonError, makeTable, logSettledRejections, symbolMatch } from "../utils.js";
|
|
3
9
|
import { computeExecutableSize } from "../liquidity.js";
|
|
4
10
|
import { computeMatchedSize, computeSpotPerpMatchedSize } from "../arb/index.js";
|
|
5
|
-
import {
|
|
11
|
+
import { toHourlyRate } from "../funding.js";
|
|
12
|
+
import { toHourlyRateByBasis } from "../funding/normalize.js";
|
|
6
13
|
import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
|
|
7
|
-
import { scanDexArb } from "../dex-asset-map.js";
|
|
14
|
+
import { fetchAllDexAssets, findDexArbPairs, scanDexArb } from "../dex-asset-map.js";
|
|
8
15
|
import { logExecution, readExecutionLog } from "../execution-log.js";
|
|
9
16
|
import { smartOrder } from "../smart-order.js";
|
|
10
17
|
import { aggressiveSettleBoost, estimateFundingUntilSettlement, computeBasisRisk, notifyIfEnabled, } from "../arb-utils.js";
|
|
11
18
|
import { checkChainMargins, isCriticalMargin, shouldBlockEntries, computeAutoSize, } from "../cross-chain-margin.js";
|
|
12
|
-
import { loadArbState,
|
|
19
|
+
import { loadArbState, addPosition as persistAddPosition, removePosition as persistRemovePosition, updatePosition as persistUpdatePosition, createInitialState, createDaemonStateStore, } from "../arb-state.js";
|
|
13
20
|
import { handleRates, handleBasisScan, handleDexScan, handleCompare, handleFundingHistory, handleFundingPositions, fetchAllPrices, printGapTable, printTrackSummary, formatGapPrice, } from "./arb.js";
|
|
14
21
|
// ── Fee-Adjusted Net Spread Calculation ──
|
|
15
22
|
import { getTakerFee } from "../constants.js";
|
|
23
|
+
import { assertFundingArbVenue, getVenue, listVenuesWithRole, networkConfig, resolveVenueId, tryResolveVenueId } from "../exchanges/venue.js";
|
|
24
|
+
import { sharedApiNetwork } from "../api/public/urls.js";
|
|
25
|
+
import { findSpotQuoteBalance, readSpotQuoteAvailableFresh, requireBotSpotVenue, requireSpotAdapter, spotNetworkOf, spotPairSymbol, spotSharesPerpMargin, sweepSpotQuoteToPerp, transferQuoteToPerp, transferQuoteToSpot } from "../exchanges/spot-factory.js";
|
|
26
|
+
import { fillSizeString, readSpotBalancesFresh, sizeDecimalsOf, spotLotStepOf, spotMarketFill } from "../exchanges/spot-result.js";
|
|
27
|
+
import { floorSize, floorToStep, lotStepOf } from "../exchanges/precision.js";
|
|
28
|
+
import { MAX_PRICE_DEVIATION_PCT } from "../exchanges/spot-interface.js";
|
|
29
|
+
import { isQuarantined, quarantineEntryFor, quarantineKey, quarantineSymbol, setQuarantineNetwork } from "../bot/quarantine.js";
|
|
30
|
+
import { clearPendingEntry, orphanedPendingEntries, writePendingEntry } from "../arb/pending-entry.js";
|
|
31
|
+
import { closePerpVerified, isOutcomeUnknown, placePerpVerified, rejectedLotSize, trimFirstLegToHedge } from "../bot/perp-order.js";
|
|
32
|
+
/**
|
|
33
|
+
* The venue's published order minimums for `sym` (VenueMarket.minSize / minNotional — the same
|
|
34
|
+
* source `trade scale-in` checks its levels against); null = the venue publishes none.
|
|
35
|
+
* A market missing from the venue's list throws (Rule #2: no assumed minimum).
|
|
36
|
+
*/
|
|
37
|
+
export async function venueOrderMinimums(adapter, sym, network) {
|
|
38
|
+
const vm = (await tradingV2Of(adapter, network).getVenueMarkets()).find(m => symbolMatch(m.symbol, sym));
|
|
39
|
+
if (vm === undefined) {
|
|
40
|
+
throw new PerpError("SYMBOL_NOT_FOUND", `${adapter.name}: market ${sym} not found — its minimum order size is unknown`, {
|
|
41
|
+
exchange: adapter.name, remediation: `Check the symbol: perp market list -e ${adapter.name}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return { minSize: vm.minSize, minNotional: vm.minNotional };
|
|
45
|
+
}
|
|
46
|
+
/** The pair's minimums: the larger of the two legs' (both legs trade the same size); 0 = none published. */
|
|
47
|
+
export function pairOrderMinimums(legs) {
|
|
48
|
+
const parts = legs.map(l => {
|
|
49
|
+
const bits = [l.minNotional !== null ? `$${l.minNotional}` : null, l.minSize !== null ? `size ${l.minSize}` : null].filter(b => b !== null);
|
|
50
|
+
return `${l.exchange}: ${bits.length > 0 ? bits.join(", ") : "none published"}`;
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
minNotional: Math.max(0, ...legs.map(l => l.minNotional !== null ? Number(l.minNotional) : 0)),
|
|
54
|
+
minSize: Math.max(0, ...legs.map(l => l.minSize !== null ? Number(l.minSize) : 0)),
|
|
55
|
+
describe: parts.join("; "),
|
|
56
|
+
legs,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const errText = (e) => (e instanceof Error ? e.message : String(e));
|
|
60
|
+
function quarantinePair(source, sym, longExch, shortExch, reason, log) {
|
|
61
|
+
quarantineSymbol(source, sym, reason, `Check ${sym} on ${longExch} and ${shortExch} (perp -e ${longExch} account positions; perp -e ${shortExch} account positions); close or re-hedge by hand, then: perp strategy quarantine-clear ${sym}`, log);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Open long `size` on longExch, then short the long's verified fill on shortExch.
|
|
65
|
+
* `smart` sends the opening legs as IOC limits (smartOrder); rollback / trim are
|
|
66
|
+
* always reduce-only market orders.
|
|
67
|
+
*/
|
|
68
|
+
export async function openPerpPairVerified(p) {
|
|
69
|
+
const { source, sym, longAdapter, longExch, longSym, shortAdapter, shortExch, shortSym, size, smart, log } = p;
|
|
70
|
+
let dec = sizeDecimalsOf(size);
|
|
71
|
+
const q = (reason, exposure) => {
|
|
72
|
+
quarantinePair(source, sym, longExch, shortExch, reason, log);
|
|
73
|
+
return { status: "quarantined", reason, exposure };
|
|
74
|
+
};
|
|
75
|
+
const smartPlace = (a, legSym, side, sz) => smart ? { place: () => smartOrder(a, legSym, side, sz).then(r => r.result) } : {};
|
|
76
|
+
let longFilled;
|
|
77
|
+
try {
|
|
78
|
+
longFilled = (await placePerpVerified(longAdapter, longExch, longSym, "buy", size, smartPlace(longAdapter, longSym, "buy", size))).filledSize;
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (isOutcomeUnknown(e))
|
|
82
|
+
return q(`long ${size} on ${longExch} outcome unknown (${errText(e)}) — no short placed`, `long up to ${size} on ${longExch} (unconfirmed)`);
|
|
83
|
+
return { status: "not_opened", reason: `long leg on ${longExch}: ${errText(e)}`, rolledBack: null, lotSize: rejectedLotSize(e) };
|
|
84
|
+
}
|
|
85
|
+
const longOpen = fillSizeString(longFilled, dec);
|
|
86
|
+
let shortFilled;
|
|
87
|
+
let shortSize = longOpen;
|
|
88
|
+
try {
|
|
89
|
+
try {
|
|
90
|
+
shortFilled = (await placePerpVerified(shortAdapter, shortExch, shortSym, "sell", longOpen, smartPlace(shortAdapter, shortSym, "sell", longOpen))).filledSize;
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
const lot = p.shortLotRetry === true ? rejectedLotSize(e) : null;
|
|
94
|
+
if (lot === null)
|
|
95
|
+
throw e;
|
|
96
|
+
const lotDec = Math.max(0, Math.ceil(-Math.log10(lot) - 1e-9));
|
|
97
|
+
dec = Math.max(dec, lotDec);
|
|
98
|
+
shortSize = (Math.floor(longFilled / lot + 1e-9) * lot).toFixed(lotDec);
|
|
99
|
+
if (!(Number(shortSize) > 0))
|
|
100
|
+
throw e;
|
|
101
|
+
log(` short leg on ${shortExch} rejected for lot size ${lot} — placing the short once at ${shortSize} (long ${longOpen} verified, not resubmitted)`);
|
|
102
|
+
shortFilled = (await placePerpVerified(shortAdapter, shortExch, shortSym, "sell", shortSize, smartPlace(shortAdapter, shortSym, "sell", shortSize))).filledSize;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
const msg = errText(e);
|
|
107
|
+
if (isOutcomeUnknown(e)) {
|
|
108
|
+
// The short may exist: rolling the long back could leave a naked short.
|
|
109
|
+
return q(`short ${shortSize} on ${shortExch} outcome unknown (${msg}); long ${longOpen} open on ${longExch} — not rolled back`, `long ${longOpen} on ${longExch}; short up to ${shortSize} on ${shortExch} (unconfirmed)`);
|
|
110
|
+
}
|
|
111
|
+
log(` short leg on ${shortExch} failed: ${msg} — rolling back long ${longOpen} on ${longExch} (reduce-only)`);
|
|
112
|
+
let rb;
|
|
113
|
+
try {
|
|
114
|
+
rb = await closePerpVerified(longAdapter, longExch, longSym, "sell", longOpen, { decimals: dec });
|
|
115
|
+
}
|
|
116
|
+
catch (rbErr) {
|
|
117
|
+
return q(`short failed (${msg}) and the long rollback ${isOutcomeUnknown(rbErr) ? "is unconfirmed" : "failed"} (${errText(rbErr)})`, `long ${longOpen} on ${longExch} (unhedged${isOutcomeUnknown(rbErr) ? ", rollback unconfirmed" : ""})`);
|
|
118
|
+
}
|
|
119
|
+
if (rb.residual > 0) {
|
|
120
|
+
const residual = fillSizeString(rb.residual, dec);
|
|
121
|
+
return q(`short failed (${msg}); the long rollback closed ${fillSizeString(rb.closed, dec)} of ${longOpen}${rb.note !== null ? ` (${rb.note})` : ""}`, `long ${residual} on ${longExch} (unhedged)`);
|
|
122
|
+
}
|
|
123
|
+
return { status: "not_opened", reason: `short leg on ${shortExch}: ${msg}`, rolledBack: longOpen, lotSize: null };
|
|
124
|
+
}
|
|
125
|
+
const hedged = fillSizeString(shortFilled, dec);
|
|
126
|
+
let trimmed;
|
|
127
|
+
try {
|
|
128
|
+
trimmed = await trimFirstLegToHedge(longAdapter, longExch, longSym, "buy", longFilled, shortFilled, dec);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
return q(`short filled ${hedged} of ${longOpen}; trimming the long's excess on ${longExch} ${isOutcomeUnknown(e) ? "is unconfirmed" : "failed"} (${errText(e)})`, `long ${longOpen} on ${longExch} vs short ${hedged} on ${shortExch}`);
|
|
132
|
+
}
|
|
133
|
+
return { status: "opened", hedged, longFilled, shortFilled, trimmed };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Close a perp-perp pair: long first, then short — reduce-only, verified, no retry.
|
|
137
|
+
* A definite failure of the long close changes nothing ("failed": the caller may retry
|
|
138
|
+
* later and should quarantine after repeated failures). Once the long is closed, any
|
|
139
|
+
* short failure, an unknown outcome, or a residual that survives one reduce-only residual
|
|
140
|
+
* close (closePerpVerified) quarantines.
|
|
141
|
+
*/
|
|
142
|
+
export async function closePerpPairVerified(p) {
|
|
143
|
+
const { source, sym, longAdapter, longExch, longSym, longSize, shortAdapter, shortExch, shortSym, shortSize, smart, log } = p;
|
|
144
|
+
const closeOpts = (a, legSym, side, sz) => (smart ? { place: () => smartOrder(a, legSym, side, sz, { reduceOnly: true }).then(r => r.result) } : {});
|
|
145
|
+
const q = (reason) => {
|
|
146
|
+
quarantinePair(source, sym, longExch, shortExch, reason, log);
|
|
147
|
+
return { status: "quarantined", reason };
|
|
148
|
+
};
|
|
149
|
+
// Each leg: reduce-only verified close; a partial fill gets ONE reduce-only close of the
|
|
150
|
+
// measured residual (closePerpVerified); still not closed → quarantine.
|
|
151
|
+
let lc;
|
|
152
|
+
try {
|
|
153
|
+
lc = await closePerpVerified(longAdapter, longExch, longSym, "sell", longSize, closeOpts(longAdapter, longSym, "sell", longSize));
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
if (isOutcomeUnknown(e))
|
|
157
|
+
return q(`close of long ${longSize} on ${longExch} outcome unknown (${errText(e)}); short ${shortSize} on ${shortExch} left open`);
|
|
158
|
+
return { status: "failed", reason: `long close on ${longExch}: ${errText(e)}` };
|
|
159
|
+
}
|
|
160
|
+
if (lc.residual > 0) {
|
|
161
|
+
return q(`close of long on ${longExch} closed ${lc.closed} of ${longSize}, ${lc.residual} still open${lc.note !== null ? ` (${lc.note})` : ""}; short ${shortSize} on ${shortExch} not closed (legs unbalanced)`);
|
|
162
|
+
}
|
|
163
|
+
let sc;
|
|
164
|
+
try {
|
|
165
|
+
sc = await closePerpVerified(shortAdapter, shortExch, shortSym, "buy", shortSize, closeOpts(shortAdapter, shortSym, "buy", shortSize));
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
return q(`long closed on ${longExch}; close of short ${shortSize} on ${shortExch} ${isOutcomeUnknown(e) ? "has an unknown outcome" : "failed"} (${errText(e)}) — short unhedged`);
|
|
169
|
+
}
|
|
170
|
+
if (sc.residual > 0) {
|
|
171
|
+
return q(`long closed; close of short on ${shortExch} closed ${sc.closed} of ${shortSize}, ${sc.residual} still open${sc.note !== null ? ` (${sc.note})` : ""} — residual short unhedged`);
|
|
172
|
+
}
|
|
173
|
+
return { status: "closed" };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Graceful stop of a daemon loop: SIGINT / SIGTERM (`perp background stop`, Ctrl+C) only REQUEST
|
|
177
|
+
* the stop — the leg sequence (or rollback) in flight finishes, nothing new starts, then the loop
|
|
178
|
+
* ends. A repeated signal never exits between two legs (it is logged).
|
|
179
|
+
*/
|
|
180
|
+
export function gracefulStop(name) {
|
|
181
|
+
let stopping = false;
|
|
182
|
+
const onSignal = (sig) => {
|
|
183
|
+
if (stopping) {
|
|
184
|
+
console.error(chalk.yellow(`\n ${name}: already stopping (${sig}) — finishing the current leg sequence first`));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
stopping = true;
|
|
188
|
+
console.error(chalk.yellow(`\n ${name}: stop requested (${sig}) — finishing the current cycle, no new entries`));
|
|
189
|
+
};
|
|
190
|
+
const onInt = () => onSignal("SIGINT");
|
|
191
|
+
const onTerm = () => onSignal("SIGTERM");
|
|
192
|
+
process.on("SIGINT", onInt);
|
|
193
|
+
process.on("SIGTERM", onTerm);
|
|
194
|
+
return {
|
|
195
|
+
requested: () => stopping,
|
|
196
|
+
dispose: () => { process.removeListener("SIGINT", onInt); process.removeListener("SIGTERM", onTerm); },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/** Sleep `ms`, waking within 250 ms once `stop()` is true. */
|
|
200
|
+
export async function interruptibleSleep(ms, stop) {
|
|
201
|
+
const until = Date.now() + ms;
|
|
202
|
+
while (!stop() && Date.now() < until)
|
|
203
|
+
await new Promise(r => setTimeout(r, Math.min(250, until - Date.now())));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Startup: pending-entry records `source` left behind by a process that died mid-entry → the
|
|
207
|
+
* symbol is quarantined (legs may be open), then the record is cleared. An unreadable pending
|
|
208
|
+
* file throws (the daemon does not start on unknown exposure).
|
|
209
|
+
*/
|
|
210
|
+
export function quarantineOrphanedEntries(network, source, log) {
|
|
211
|
+
for (const e of orphanedPendingEntries(network, source)) {
|
|
212
|
+
quarantinePair(source, e.symbol, e.longExchange, e.shortExchange, `an entry (${e.size} per leg, started ${e.startedAt} by pid ${e.pid}) was in flight when that daemon stopped — legs may be open`, log);
|
|
213
|
+
clearPendingEntry(network, source, e.symbol);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** The entry's outcome is recorded: drop its pending record (a failed drop is logged — the next start quarantines it). */
|
|
217
|
+
export function finishPendingEntry(network, source, symbol, log) {
|
|
218
|
+
try {
|
|
219
|
+
clearPendingEntry(network, source, symbol);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
log(` CRITICAL: could not clear the pending-entry record of ${symbol} (${e instanceof Error ? e.message : String(e)}) — the next daemon start will quarantine ${symbol}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Consecutive definite close failures before the daemons quarantine a pair. */
|
|
226
|
+
const MAX_PAIR_CLOSE_FAILURES = 3;
|
|
16
227
|
/**
|
|
17
228
|
* Compute the estimated round-trip cost as a percentage of notional.
|
|
18
229
|
* Round-trip = 4 × taker fee + 2 × slippage (entry + exit for both legs).
|
|
@@ -45,46 +256,55 @@ export function computeNetSpread(grossAnnualPct, holdDays, roundTripCostPct, bri
|
|
|
45
256
|
return grossAnnualPct - annualizedCostPct - bridgeCostAnnualPct;
|
|
46
257
|
}
|
|
47
258
|
// ── Funding Settlement Timing ──
|
|
48
|
-
import {
|
|
259
|
+
import { getLastSettlement, settlementIntervalHours } from "../arb-utils.js";
|
|
260
|
+
/** The perp-perp snapshot only carries mark prices for the four legacy venues. */
|
|
261
|
+
function unknownArbVenue(ex) {
|
|
262
|
+
throw new PerpError("UNKNOWN_VENUE", `No mark price column for venue "${ex}" in the arb snapshot`, {
|
|
263
|
+
remediation: "Restrict the scan to pacifica, hyperliquid, lighter, aster",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/** Per-venue mark-price column of a snapshot + short display label (data-driven; one entry per scanned venue). */
|
|
267
|
+
const SNAPSHOT_MARK_COLUMNS = {
|
|
268
|
+
pacifica: { key: "pacMarkPrice", label: "PAC" },
|
|
269
|
+
hyperliquid: { key: "hlMarkPrice", label: "HL" },
|
|
270
|
+
lighter: { key: "ltMarkPrice", label: "LT" },
|
|
271
|
+
aster: { key: "astMarkPrice", label: "AST" },
|
|
272
|
+
};
|
|
273
|
+
/** Hourly funding rate of one venue column of a snapshot (Aster uses the symbol's own interval). */
|
|
274
|
+
function snapshotHourly(s, ex) {
|
|
275
|
+
switch (ex) {
|
|
276
|
+
case "pacifica": return toHourlyRate(s.pacRate, ex);
|
|
277
|
+
case "hyperliquid": return toHourlyRate(s.hlRate, ex);
|
|
278
|
+
case "lighter": return toHourlyRate(s.ltRate, ex);
|
|
279
|
+
case "aster":
|
|
280
|
+
if (s.astHours === null) {
|
|
281
|
+
throw new PerpError("INVALID_PARAMS", `Aster funding interval for ${s.symbol} is unknown`, {
|
|
282
|
+
remediation: "Aster funding basis is per symbol (/fapi/v3/fundingInfo); the symbol was not listed there",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return toHourlyRateByBasis(s.astRate, { kind: "perHours", hours: s.astHours });
|
|
286
|
+
default: return unknownArbVenue(ex);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
49
289
|
/**
|
|
50
|
-
* Get the next settlement time for an exchange.
|
|
51
|
-
*
|
|
290
|
+
* Get the next settlement time for an exchange (schedule anchored at 00:00 UTC).
|
|
291
|
+
* `marketIntervalHours` is the market's settlement interval; required for
|
|
292
|
+
* per-market venues (Aster). Unknown venues throw UNKNOWN_VENUE.
|
|
293
|
+
* A settlement exactly at `now` counts as the next one.
|
|
52
294
|
*/
|
|
53
|
-
export function getNextSettlement(exchange, now = new Date()) {
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
const currentHour = now.getUTCHours();
|
|
60
|
-
const currentMinutes = now.getUTCMinutes();
|
|
61
|
-
const currentSeconds = now.getUTCSeconds();
|
|
62
|
-
// Find the next settlement hour strictly in the future
|
|
63
|
-
// A settlement at the current hour is "next" only if we haven't reached it yet (min=0, sec=0)
|
|
64
|
-
for (const hour of schedule) {
|
|
65
|
-
if (hour > currentHour || (hour === currentHour && currentMinutes === 0 && currentSeconds === 0)) {
|
|
66
|
-
// This settlement is still in the future (or exactly now)
|
|
67
|
-
// But skip if hour === currentHour and we're past minute 0
|
|
68
|
-
if (hour === currentHour && (currentMinutes > 0 || currentSeconds > 0))
|
|
69
|
-
continue;
|
|
70
|
-
const next = new Date(now);
|
|
71
|
-
next.setUTCHours(hour, 0, 0, 0);
|
|
72
|
-
return next;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
// Wrap to next day's first settlement
|
|
76
|
-
const next = new Date(now);
|
|
77
|
-
next.setUTCDate(next.getUTCDate() + 1);
|
|
78
|
-
next.setUTCHours(schedule[0], 0, 0, 0);
|
|
79
|
-
return next;
|
|
295
|
+
export function getNextSettlement(exchange, now = new Date(), marketIntervalHours = null) {
|
|
296
|
+
const last = getLastSettlement(exchange, now, marketIntervalHours);
|
|
297
|
+
if (last.getTime() === now.getTime())
|
|
298
|
+
return last;
|
|
299
|
+
return new Date(last.getTime() + settlementIntervalHours(exchange, marketIntervalHours) * 3_600_000);
|
|
80
300
|
}
|
|
81
301
|
/**
|
|
82
302
|
* Check if we are within N minutes before a settlement event on either exchange.
|
|
83
303
|
* If so, we should avoid entering positions (rates may change).
|
|
84
304
|
*/
|
|
85
|
-
export function isNearSettlement(longExchange, shortExchange, bufferMinutes = 5, now = new Date()) {
|
|
305
|
+
export function isNearSettlement(longExchange, shortExchange, bufferMinutes = 5, now = new Date(), marketIntervalFor = () => null) {
|
|
86
306
|
for (const exch of [longExchange, shortExchange]) {
|
|
87
|
-
const nextSettle = getNextSettlement(exch, now);
|
|
307
|
+
const nextSettle = getNextSettlement(exch, now, marketIntervalFor(exch));
|
|
88
308
|
const minutesUntil = (nextSettle.getTime() - now.getTime()) / (1000 * 60);
|
|
89
309
|
if (minutesUntil <= bufferMinutes && minutesUntil >= 0) {
|
|
90
310
|
return { blocked: true, exchange: exch, minutesUntil };
|
|
@@ -98,13 +318,16 @@ export function isNearSettlement(longExchange, shortExchange, bufferMinutes = 5,
|
|
|
98
318
|
* meaning we're now paying on both sides instead of collecting.
|
|
99
319
|
*/
|
|
100
320
|
export function isSpreadReversed(longExchange, shortExchange, snapshot) {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const shortHourly = toHourlyRate(rateFor(shortExchange), shortExchange);
|
|
321
|
+
const longHourly = snapshotHourly(snapshot, longExchange);
|
|
322
|
+
const shortHourly = snapshotHourly(snapshot, shortExchange);
|
|
104
323
|
// Reversed if the long side rate exceeds the short side rate
|
|
105
324
|
return longHourly > shortHourly;
|
|
106
325
|
}
|
|
326
|
+
const _warnedArbPriceMismatch = new Set();
|
|
107
327
|
async function fetchFundingSpreads() {
|
|
328
|
+
// Selected network (review L7): the Pacifica / HL / Lighter raw fetchers follow -n via
|
|
329
|
+
// setSharedApiNetwork; Aster's REST base comes from the venue definition for that network.
|
|
330
|
+
const asterRest = networkConfig(getVenue("aster"), sharedApiNetwork()).restUrl;
|
|
108
331
|
// SSOT rule #2: each DEX fetch is independent. allSettled lets us keep the
|
|
109
332
|
// multi-DEX comparison alive when one DEX errors out, while the rejection
|
|
110
333
|
// reasons get logged to stderr (explicit failure, never silent).
|
|
@@ -113,14 +336,26 @@ async function fetchFundingSpreads() {
|
|
|
113
336
|
fetchHyperliquidMetaRaw(),
|
|
114
337
|
fetchLighterOrderBookDetailsRaw(),
|
|
115
338
|
fetchLighterFundingRatesRaw(),
|
|
116
|
-
fetch(
|
|
339
|
+
fetch(`${asterRest}/fapi/v1/premiumIndex`).then(r => r.json()),
|
|
340
|
+
fetch(`${asterRest}/fapi/v3/fundingInfo`).then(r => r.json()),
|
|
117
341
|
]);
|
|
118
|
-
logSettledRejections(settled, ["pacifica", "hyperliquid", "lighter:orderbook", "lighter:funding", "aster:premiumIndex"], "arb-auto");
|
|
342
|
+
logSettledRejections(settled, ["pacifica", "hyperliquid", "lighter:orderbook", "lighter:funding", "aster:premiumIndex", "aster:fundingInfo"], "arb-auto");
|
|
119
343
|
const pacRes = settled[0].status === "fulfilled" ? settled[0].value : null;
|
|
120
344
|
const hlRes = settled[1].status === "fulfilled" ? settled[1].value : null;
|
|
121
345
|
const ltDetailsRes = settled[2].status === "fulfilled" ? settled[2].value : null;
|
|
122
346
|
const ltFundingRes = settled[3].status === "fulfilled" ? settled[3].value : null;
|
|
123
|
-
|
|
347
|
+
// Both Aster calls are needed: a rate without its per-symbol interval cannot be normalized.
|
|
348
|
+
const astPremiums = settled[4].status === "fulfilled" && settled[5].status === "fulfilled"
|
|
349
|
+
? settled[4].value
|
|
350
|
+
: [];
|
|
351
|
+
const astFundingInfo = settled[5].status === "fulfilled" ? settled[5].value : [];
|
|
352
|
+
const astHoursBySym = new Map();
|
|
353
|
+
for (const row of Array.isArray(astFundingInfo) ? astFundingInfo : []) {
|
|
354
|
+
const rawSym = String(row.symbol ?? "");
|
|
355
|
+
const hours = Number(row.fundingIntervalHours);
|
|
356
|
+
if (rawSym.endsWith("USDT") && Number.isFinite(hours) && hours > 0)
|
|
357
|
+
astHoursBySym.set(rawSym.replace(/USDT$/, ""), hours);
|
|
358
|
+
}
|
|
124
359
|
const { rates: pacRates, prices: pacPrices } = parsePacificaRaw(pacRes);
|
|
125
360
|
const { rates: hlRates, prices: hlPrices } = parseHyperliquidMetaRaw(hlRes);
|
|
126
361
|
const { rates: ltRates, prices: ltPrices } = parseLighterRaw(ltDetailsRes, ltFundingRes);
|
|
@@ -131,6 +366,7 @@ async function fetchFundingSpreads() {
|
|
|
131
366
|
// Skip the symbol entirely if either field is missing or non-finite.
|
|
132
367
|
const astRates = new Map();
|
|
133
368
|
const astPrices = new Map();
|
|
369
|
+
const astNoInterval = [];
|
|
134
370
|
for (const p of astPremiums ?? []) {
|
|
135
371
|
const rawSym = String(p.symbol ?? "");
|
|
136
372
|
if (!rawSym.endsWith("USDT"))
|
|
@@ -143,9 +379,16 @@ async function fetchFundingSpreads() {
|
|
|
143
379
|
// symbol; skip rather than poison downstream math with a 0.
|
|
144
380
|
continue;
|
|
145
381
|
}
|
|
382
|
+
if (!astHoursBySym.has(sym)) {
|
|
383
|
+
astNoInterval.push(sym);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
146
386
|
astRates.set(sym, fundingRate);
|
|
147
387
|
astPrices.set(sym, markPrice);
|
|
148
388
|
}
|
|
389
|
+
if (astNoInterval.length > 0) {
|
|
390
|
+
process.stderr.write(`[arb-auto] aster: ${astNoInterval.length} symbol(s) excluded — no fundingIntervalHours in /fapi/v3/fundingInfo: ${astNoInterval.slice(0, 10).join(", ")}${astNoInterval.length > 10 ? ", …" : ""}\n`);
|
|
391
|
+
}
|
|
149
392
|
const snapshots = [];
|
|
150
393
|
const allSymbols = new Set([...pacRates.keys(), ...hlRates.keys(), ...ltRates.keys(), ...astRates.keys()]);
|
|
151
394
|
for (const sym of allSymbols) {
|
|
@@ -165,11 +408,53 @@ async function fetchFundingSpreads() {
|
|
|
165
408
|
available.push({ exchange: "aster", rate: ast });
|
|
166
409
|
if (available.length < 2)
|
|
167
410
|
continue;
|
|
168
|
-
|
|
411
|
+
// Same bare ticker on two venues is not proof of the same asset (e.g. PRL at
|
|
412
|
+
// a 167% price gap). Keep only venues whose mark is within
|
|
413
|
+
// MAX_PRICE_DEVIATION_PCT of the cross-venue median; a venue without a mark
|
|
414
|
+
// price cannot be checked and is dropped from the pairing.
|
|
415
|
+
const priceOf = (ex) => ex === "pacifica" ? pacPrices.get(sym) : ex === "hyperliquid" ? hlPrices.get(sym) : ex === "lighter" ? ltPrices.get(sym) : astPrices.get(sym);
|
|
416
|
+
const priced = available.filter(r => { const px = priceOf(r.exchange); return px !== undefined && px > 0; });
|
|
417
|
+
if (priced.length < 2)
|
|
418
|
+
continue;
|
|
419
|
+
const pxs = priced.map(r => priceOf(r.exchange)).sort((x, y) => x - y);
|
|
420
|
+
const medianPx = pxs.length % 2 === 1 ? pxs[(pxs.length - 1) / 2] : (pxs[pxs.length / 2 - 1] + pxs[pxs.length / 2]) / 2;
|
|
421
|
+
const consistent = priced.filter(r => {
|
|
422
|
+
const devPct = Math.abs(priceOf(r.exchange) - medianPx) / medianPx * 100;
|
|
423
|
+
if (devPct <= MAX_PRICE_DEVIATION_PCT)
|
|
424
|
+
return true;
|
|
425
|
+
const key = `${sym}:${r.exchange}`;
|
|
426
|
+
if (!_warnedArbPriceMismatch.has(key)) {
|
|
427
|
+
_warnedArbPriceMismatch.add(key);
|
|
428
|
+
process.stderr.write(`[arb-auto] excluded ${r.exchange} ${sym}: mark deviates ${devPct.toFixed(1)}% from cross-venue median (likely a different asset with the same ticker)\n`);
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
});
|
|
432
|
+
if (consistent.length < 2)
|
|
433
|
+
continue;
|
|
434
|
+
available.length = 0;
|
|
435
|
+
available.push(...consistent);
|
|
436
|
+
const astHours = astHoursBySym.get(sym) ?? null;
|
|
437
|
+
const norm = (r) => r.exchange === "aster"
|
|
438
|
+
? toHourlyRateByBasis(r.rate, { kind: "perHours", hours: astHours }) // present whenever ast is (filtered above)
|
|
439
|
+
: toHourlyRate(r.rate, r.exchange);
|
|
169
440
|
available.sort((a, b) => norm(a) - norm(b));
|
|
170
441
|
const lowest = available[0];
|
|
171
442
|
const highest = available[available.length - 1];
|
|
172
|
-
|
|
443
|
+
// Median filtering keeps venues near the centre, but the chosen pair itself
|
|
444
|
+
// must also agree on price (100 / 104.5 / 109 all pass the median test while
|
|
445
|
+
// the 100↔109 pair is 8.6% apart).
|
|
446
|
+
const pairLo = priceOf(lowest.exchange);
|
|
447
|
+
const pairHi = priceOf(highest.exchange);
|
|
448
|
+
const pairGapPct = Math.abs(pairLo - pairHi) / ((pairLo + pairHi) / 2) * 100;
|
|
449
|
+
if (pairGapPct > MAX_PRICE_DEVIATION_PCT) {
|
|
450
|
+
const key = `${sym}:${lowest.exchange}:${highest.exchange}`;
|
|
451
|
+
if (!_warnedArbPriceMismatch.has(key)) {
|
|
452
|
+
_warnedArbPriceMismatch.add(key);
|
|
453
|
+
process.stderr.write(`[arb-auto] excluded ${sym} ${lowest.exchange}↔${highest.exchange}: pair mark gap ${pairGapPct.toFixed(1)}% > ${MAX_PRICE_DEVIATION_PCT}%\n`);
|
|
454
|
+
}
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const spread = Math.abs(norm(highest) - norm(lowest)) * 24 * 365 * 100;
|
|
173
458
|
// Use best available mark price (prefer HL as most liquid, then PAC, then LT, then AST)
|
|
174
459
|
const markPrice = hlPrices.get(sym) ?? pacPrices.get(sym) ?? ltPrices.get(sym) ?? astPrices.get(sym) ?? 0;
|
|
175
460
|
snapshots.push({
|
|
@@ -186,6 +471,7 @@ async function fetchFundingSpreads() {
|
|
|
186
471
|
hlMarkPrice: hlPrices.get(sym) ?? 0,
|
|
187
472
|
ltMarkPrice: ltPrices.get(sym) ?? 0,
|
|
188
473
|
astMarkPrice: astPrices.get(sym) ?? 0,
|
|
474
|
+
astHours: ast !== undefined ? astHours : null,
|
|
189
475
|
});
|
|
190
476
|
}
|
|
191
477
|
return snapshots.sort((a, b) => Math.abs(b.spread) - Math.abs(a.spread));
|
|
@@ -196,10 +482,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
196
482
|
const arb = program.commands.find(c => c.name() === "arb");
|
|
197
483
|
if (!arb)
|
|
198
484
|
return;
|
|
485
|
+
// Quarantine checks / entries of every arb command are scoped to its -n network.
|
|
486
|
+
arb.hook("preAction", (_cmd, action) => setQuarantineNetwork(networkOf(action)));
|
|
199
487
|
// ── arb auto ── (daemon mode)
|
|
200
|
-
arb
|
|
201
|
-
.
|
|
202
|
-
.description("Auto-execute funding rate arbitrage (daemon). Use --hip3 for HIP-3 cross-dex mode.")
|
|
488
|
+
supportsDryRun(arb.command("auto"))
|
|
489
|
+
.description("Auto-execute funding rate arbitrage (daemon). Use --hip3 for HIP-3 cross-dex mode. Supports --dry-run (also inherited by --background).")
|
|
203
490
|
.option("--min-spread <pct>", "Min annual spread to enter (%)", "30")
|
|
204
491
|
.option("--close-spread <pct>", "Close when spread drops below (%)", "5")
|
|
205
492
|
.option("--size <usd>", "Position size per leg ($)", "100")
|
|
@@ -218,8 +505,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
218
505
|
.option("--notify <url>", "Webhook URL for notifications (Discord/Telegram/generic)")
|
|
219
506
|
.option("--notify-events <events>", "Comma-separated events: entry,exit,reversal,margin,basis", "entry,exit,reversal,margin,basis")
|
|
220
507
|
.option("--cooldown <minutes>", "Minutes to wait before re-entering a closed symbol", "30")
|
|
221
|
-
.option("--dry-run", "Simulate without executing trades")
|
|
222
508
|
.option("--background", "Run in background (tmux)")
|
|
509
|
+
.option("--job-id <id>", "Job ID (set by the background runner)")
|
|
223
510
|
.option("--hip3", "Run HIP-3 cross-dex auto arb instead of perp-perp")
|
|
224
511
|
.option("--min-oi <usd>", "Min OI (USD) to enter (hip3 mode)", "50000")
|
|
225
512
|
.option("--min-grade <grade>", "Min viability grade: A, B, C, D (hip3 mode)", "C")
|
|
@@ -234,12 +521,13 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
234
521
|
minOi: opts.minOi,
|
|
235
522
|
minGrade: opts.minGrade,
|
|
236
523
|
interval: opts.interval,
|
|
237
|
-
dryRun:
|
|
524
|
+
dryRun: isDryRun(program),
|
|
238
525
|
});
|
|
239
526
|
}
|
|
240
527
|
if (opts.background) {
|
|
241
|
-
const { startJob } = await import("../jobs.js");
|
|
528
|
+
const { startJob, jobSelectorsOf } = await import("../jobs.js");
|
|
242
529
|
const cliArgs = [
|
|
530
|
+
`arb`, `auto`,
|
|
243
531
|
`--min-spread`, opts.minSpread,
|
|
244
532
|
`--close-spread`, opts.closeSpread,
|
|
245
533
|
`--size`, opts.size,
|
|
@@ -250,14 +538,19 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
250
538
|
`--min-margin`, opts.minMargin,
|
|
251
539
|
`--cooldown`, opts.cooldown,
|
|
252
540
|
...(opts.symbols ? [`--symbols`, opts.symbols] : []),
|
|
253
|
-
...(opts.dryRun ? [`--dry-run`] : []),
|
|
254
541
|
...(opts.reversalExit === false ? [`--no-reversal-exit`] : []),
|
|
255
542
|
...(opts.settleAware === false ? [`--no-settle-aware`] : []),
|
|
256
|
-
|
|
543
|
+
`--min-size`, opts.minSize,
|
|
544
|
+
`--settle-strategy`, opts.settleStrategy,
|
|
545
|
+
`--max-basis`, opts.maxBasis,
|
|
546
|
+
`--notify-events`, opts.notifyEvents,
|
|
547
|
+
...(opts.notify ? [`--notify`, opts.notify] : []),
|
|
257
548
|
];
|
|
258
549
|
const job = startJob({
|
|
259
550
|
strategy: "funding-arb",
|
|
260
551
|
exchange: "multi",
|
|
552
|
+
// The background child inherits the root -n and --dry-run.
|
|
553
|
+
network: networkOf(program), dryRun: isDryRun(program), selectors: jobSelectorsOf(program),
|
|
261
554
|
params: { ...opts },
|
|
262
555
|
cliArgs,
|
|
263
556
|
});
|
|
@@ -289,13 +582,15 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
289
582
|
const minMarginPct = parseFloat(opts.minMargin);
|
|
290
583
|
const minSizeUsd = parseFloat(opts.minSize);
|
|
291
584
|
const filterSymbols = opts.symbols?.split(",").map(s => s.trim().toUpperCase());
|
|
292
|
-
const dryRun =
|
|
585
|
+
const dryRun = isDryRun(program);
|
|
293
586
|
const cooldownMinutes = parseFloat(opts.cooldown);
|
|
294
587
|
const openPositions = [];
|
|
295
588
|
// Track which exchanges have low margin (block entries)
|
|
296
589
|
const blockedExchanges = new Set();
|
|
297
590
|
// Track close times per symbol to prevent close→re-entry loops
|
|
298
591
|
const closeCooldowns = new Map(); // symbol → close timestamp ms
|
|
592
|
+
// Consecutive definite close failures per symbol (quarantined at MAX_PAIR_CLOSE_FAILURES)
|
|
593
|
+
const closeFailures = new Map();
|
|
299
594
|
// -- State Persistence: Initialize or recover --
|
|
300
595
|
const daemonConfig = {
|
|
301
596
|
minSpread,
|
|
@@ -306,10 +601,59 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
306
601
|
maxPositions,
|
|
307
602
|
settleStrategy: settleAwareEnabled ? "aware" : "disabled",
|
|
308
603
|
};
|
|
309
|
-
|
|
604
|
+
// The daemon manages perp-perp positions only. Spot-perp positions (persisted by
|
|
605
|
+
// `arb exec spot:<venue> ...`) are closed with `perp arb close <symbol>` (spot-perp
|
|
606
|
+
// path) — never as perp-perp. Warn once per symbol.
|
|
607
|
+
const warnedSpotPerp = new Set();
|
|
608
|
+
const isDaemonManaged = (p) => {
|
|
609
|
+
if (p.mode !== "spot-perp")
|
|
610
|
+
return true;
|
|
611
|
+
if (!warnedSpotPerp.has(p.symbol)) {
|
|
612
|
+
warnedSpotPerp.add(p.symbol);
|
|
613
|
+
console.log(chalk.yellow(` ${new Date().toLocaleTimeString()} SKIP: ${p.symbol} is a spot-perp position (spot ${p.spotExchange ?? p.longExchange} / perp ${p.shortExchange}) — not managed by this daemon; close it with: perp arb close ${p.symbol}`));
|
|
614
|
+
}
|
|
615
|
+
return false;
|
|
616
|
+
};
|
|
617
|
+
// State access for this daemon. Dry-run NEVER writes the real per-network arb state
|
|
618
|
+
// (review N3): it works on an in-memory copy seeded read-only from the real state, so a
|
|
619
|
+
// simulated entry is never picked up by a live run and a simulated close never drops a
|
|
620
|
+
// real position from tracking.
|
|
621
|
+
const st = createDaemonStateStore(networkOf(program), dryRun, daemonConfig);
|
|
622
|
+
// State entries the venues do not confirm (no long on longExchange + short on shortExchange):
|
|
623
|
+
// never counted toward --max-positions, never managed; they still block a new entry on the
|
|
624
|
+
// same symbol (the state is keyed by symbol). Drop one with: perp arb close <sym> --state-only.
|
|
625
|
+
const staleSymbols = new Set();
|
|
626
|
+
const confirmedOnVenues = async (p) => {
|
|
627
|
+
try {
|
|
628
|
+
const [longs, shorts] = await Promise.all([
|
|
629
|
+
(await getAdapterForExchange(p.longExchange)).getPositions(),
|
|
630
|
+
(await getAdapterForExchange(p.shortExchange)).getPositions(),
|
|
631
|
+
]);
|
|
632
|
+
const has = (list, side) => list.some(x => symbolMatch(x.symbol, p.symbol) && x.side === side && Math.abs(Number(x.size)) > 0);
|
|
633
|
+
if (has(longs, "long") && has(shorts, "short")) {
|
|
634
|
+
staleSymbols.delete(p.symbol);
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
if (!staleSymbols.has(p.symbol)) {
|
|
638
|
+
console.log(chalk.yellow(` ${new Date().toLocaleTimeString()} STALE STATE: ${p.symbol} (long ${p.longExchange} / short ${p.shortExchange}, since ${p.entryTime}) is not on the venues — not counted, not managed; drop it: perp arb close ${p.symbol} --state-only`));
|
|
639
|
+
}
|
|
640
|
+
staleSymbols.add(p.symbol);
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
catch (e) {
|
|
644
|
+
// Unknown (read failed): counted, so a possibly-live pair is never doubled.
|
|
645
|
+
console.log(chalk.yellow(` ${new Date().toLocaleTimeString()} ${p.symbol}: could not confirm the state entry on the venues (${e instanceof Error ? e.message : String(e)}) — counted as open`));
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
let daemonState = st.load();
|
|
310
650
|
if (daemonState && daemonState.positions.length > 0) {
|
|
311
|
-
// Crash recovery: restore positions from persisted state
|
|
651
|
+
// Crash recovery: restore positions from persisted state — only those the venues confirm.
|
|
312
652
|
for (const persisted of daemonState.positions) {
|
|
653
|
+
if (!isDaemonManaged(persisted))
|
|
654
|
+
continue;
|
|
655
|
+
if (!dryRun && !(await confirmedOnVenues(persisted)))
|
|
656
|
+
continue;
|
|
313
657
|
openPositions.push({
|
|
314
658
|
symbol: persisted.symbol,
|
|
315
659
|
longExchange: persisted.longExchange,
|
|
@@ -323,30 +667,23 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
323
667
|
});
|
|
324
668
|
}
|
|
325
669
|
daemonState.lastStartTime = new Date().toISOString();
|
|
326
|
-
|
|
670
|
+
st.save(daemonState);
|
|
327
671
|
if (!isJson()) {
|
|
328
|
-
console.log(chalk.yellow(` Recovered ${openPositions.length} position(s) from previous session.`));
|
|
672
|
+
console.log(chalk.yellow(` Recovered ${openPositions.length} perp-perp position(s) from previous session.`));
|
|
329
673
|
}
|
|
330
674
|
}
|
|
331
675
|
else {
|
|
332
676
|
daemonState = createInitialState(daemonConfig);
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
saveArbState(finalState);
|
|
344
|
-
}
|
|
345
|
-
if (!isJson())
|
|
346
|
-
console.log(chalk.yellow("\n Daemon stopped. State saved.\n"));
|
|
347
|
-
process.exit(0);
|
|
348
|
-
};
|
|
349
|
-
process.on("SIGINT", handleSigint);
|
|
677
|
+
st.save(daemonState);
|
|
678
|
+
}
|
|
679
|
+
// A previous daemon that died between two legs (crash / kill) left its pending-entry
|
|
680
|
+
// record: what is open is unknown → quarantine the symbol (never re-entered blind).
|
|
681
|
+
if (!dryRun)
|
|
682
|
+
quarantineOrphanedEntries(networkOf(program), "arb-auto", (m) => console.log(chalk.red(m)));
|
|
683
|
+
// Graceful stop (SIGINT / SIGTERM — `perp background stop`): the current cycle finishes the
|
|
684
|
+
// leg sequence (or rollback) it is in, starts nothing new, then the state is saved and the
|
|
685
|
+
// daemon exits. Never an exit between two legs.
|
|
686
|
+
const stop = gracefulStop("arb auto");
|
|
350
687
|
if (!isJson()) {
|
|
351
688
|
console.log(chalk.cyan.bold("\n Funding Rate Arb Bot\n"));
|
|
352
689
|
console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
|
|
@@ -370,7 +707,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
370
707
|
}
|
|
371
708
|
const cycle = async () => {
|
|
372
709
|
// Sync in-memory positions with persisted state (handles CLI manual closes)
|
|
373
|
-
const syncState =
|
|
710
|
+
const syncState = st.load();
|
|
374
711
|
if (syncState) {
|
|
375
712
|
const persistedSymbols = new Set(syncState.positions.map(p => p.symbol));
|
|
376
713
|
// Remove positions that were closed externally (e.g. via `perp arb close`)
|
|
@@ -382,7 +719,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
382
719
|
}
|
|
383
720
|
// Recover positions that exist in state but not in memory (e.g. added by another process)
|
|
384
721
|
for (const persisted of syncState.positions) {
|
|
722
|
+
if (!isDaemonManaged(persisted))
|
|
723
|
+
continue;
|
|
385
724
|
if (!openPositions.some(p => p.symbol === persisted.symbol)) {
|
|
725
|
+
if (!dryRun && !(await confirmedOnVenues(persisted)))
|
|
726
|
+
continue;
|
|
386
727
|
console.log(chalk.gray(` ${new Date().toLocaleTimeString()} SYNC: recovering ${persisted.symbol} from state`));
|
|
387
728
|
openPositions.push({
|
|
388
729
|
symbol: persisted.symbol,
|
|
@@ -399,7 +740,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
399
740
|
}
|
|
400
741
|
}
|
|
401
742
|
// Heartbeat check
|
|
402
|
-
const heartbeatState =
|
|
743
|
+
const heartbeatState = st.load();
|
|
403
744
|
if (heartbeatState?.lastSuccessfulScanTime) {
|
|
404
745
|
const lastSuccessMs = new Date(heartbeatState.lastSuccessfulScanTime).getTime();
|
|
405
746
|
const minutesSinceSuccess = (Date.now() - lastSuccessMs) / (1000 * 60);
|
|
@@ -419,160 +760,150 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
419
760
|
const now = new Date().toLocaleTimeString();
|
|
420
761
|
// Check for close conditions on open positions
|
|
421
762
|
for (let i = openPositions.length - 1; i >= 0; i--) {
|
|
763
|
+
if (stop.requested())
|
|
764
|
+
break; // stopping: no new close sequence
|
|
422
765
|
const pos = openPositions[i];
|
|
423
|
-
|
|
424
|
-
if (
|
|
766
|
+
// Quarantined (design §12.3): hands off until a human clears it.
|
|
767
|
+
if (isQuarantined(pos.symbol, (m) => console.log(chalk.red(m))))
|
|
425
768
|
continue;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
// Check basis risk (mark price divergence) — use prices from fetchFundingSpreads, no extra API calls
|
|
443
|
-
{
|
|
444
|
-
const priceFor = (e) => e === "pacifica" ? current.pacMarkPrice : e === "hyperliquid" ? current.hlMarkPrice : current.ltMarkPrice;
|
|
445
|
-
const bLP = priceFor(pos.longExchange);
|
|
446
|
-
const bSP = priceFor(pos.shortExchange);
|
|
447
|
-
if (bLP > 0 && bSP > 0) {
|
|
448
|
-
const basis = computeBasisRisk(bLP, bSP, maxBasisPct);
|
|
449
|
-
if (basis.warning) {
|
|
450
|
-
const bExA = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
451
|
-
console.log(chalk.yellow(` ${now} BASIS RISK ${pos.symbol}: Long ${bExA(pos.longExchange)} $${bLP.toFixed(4)} / ` +
|
|
452
|
-
`Short ${bExA(pos.shortExchange)} $${bSP.toFixed(4)} | Divergence: ${basis.divergencePct.toFixed(1)}%`));
|
|
453
|
-
await notifyIfEnabled(webhookUrl, notifyEvents, "basis", {
|
|
454
|
-
symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
|
|
455
|
-
divergencePct: basis.divergencePct,
|
|
456
|
-
});
|
|
457
|
-
}
|
|
769
|
+
// Per-position isolation: one position's failing exit check (e.g. an
|
|
770
|
+
// unknown funding basis in snapshotHourly) must not skip the others.
|
|
771
|
+
try {
|
|
772
|
+
const current = filtered.find(s => s.symbol === pos.symbol);
|
|
773
|
+
if (!current)
|
|
774
|
+
continue;
|
|
775
|
+
const currentSpread = Math.abs(current.spread);
|
|
776
|
+
let shouldClose = false;
|
|
777
|
+
let closeReason = "";
|
|
778
|
+
// Check spread-based close
|
|
779
|
+
if (currentSpread <= closeSpread) {
|
|
780
|
+
shouldClose = true;
|
|
781
|
+
closeReason = `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
|
|
458
782
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
:
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs`));
|
|
484
|
-
persistRemovePosition(pos.symbol);
|
|
485
|
-
closeCooldowns.set(pos.symbol, Date.now());
|
|
486
|
-
openPositions.splice(i, 1);
|
|
487
|
-
await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
|
|
783
|
+
// Check reversal-based close
|
|
784
|
+
if (!shouldClose && reversalExitEnabled && isSpreadReversed(pos.longExchange, pos.shortExchange, current)) {
|
|
785
|
+
shouldClose = true;
|
|
786
|
+
closeReason = "REVERSAL DETECTED — long exchange now has higher rate than short";
|
|
787
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "reversal", {
|
|
788
|
+
symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
// Check basis risk (mark price divergence) — use prices from fetchFundingSpreads, no extra API calls
|
|
792
|
+
{
|
|
793
|
+
const longCol = SNAPSHOT_MARK_COLUMNS[pos.longExchange];
|
|
794
|
+
const shortCol = SNAPSHOT_MARK_COLUMNS[pos.shortExchange];
|
|
795
|
+
const bLP = longCol ? current[longCol.key] : 0;
|
|
796
|
+
const bSP = shortCol ? current[shortCol.key] : 0;
|
|
797
|
+
if (!longCol || !shortCol) {
|
|
798
|
+
const missing = [!longCol ? pos.longExchange : null, !shortCol ? pos.shortExchange : null].filter(Boolean).join(", ");
|
|
799
|
+
console.log(chalk.yellow(` ${now} BASIS CHECK SKIPPED ${pos.symbol}: no mark price column for venue ${missing}`));
|
|
800
|
+
}
|
|
801
|
+
else if (bLP > 0 && bSP > 0) {
|
|
802
|
+
const basis = computeBasisRisk(bLP, bSP, maxBasisPct);
|
|
803
|
+
if (basis.warning) {
|
|
804
|
+
console.log(chalk.yellow(` ${now} BASIS RISK ${pos.symbol}: Long ${longCol.label} $${bLP.toFixed(4)} / ` +
|
|
805
|
+
`Short ${shortCol.label} $${bSP.toFixed(4)} | Divergence: ${basis.divergencePct.toFixed(1)}%`));
|
|
806
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "basis", {
|
|
488
807
|
symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
|
|
489
|
-
|
|
490
|
-
duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
|
|
808
|
+
divergencePct: basis.divergencePct,
|
|
491
809
|
});
|
|
492
810
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (shouldClose) {
|
|
814
|
+
console.log(chalk.yellow(` ${now} CLOSE ${pos.symbol} — ${closeReason}`));
|
|
815
|
+
const exitReason = closeReason.includes("REVERSAL") ? "reversal"
|
|
816
|
+
: closeReason.includes("spread") ? "spread"
|
|
817
|
+
: "manual";
|
|
818
|
+
if (!dryRun) {
|
|
819
|
+
try {
|
|
820
|
+
const longAdapter = await getAdapterForExchange(pos.longExchange);
|
|
821
|
+
const shortAdapter = await getAdapterForExchange(pos.shortExchange);
|
|
822
|
+
// Sequential, reduce-only, verified (design §12.3): long first, then
|
|
823
|
+
// short. A definite long-close failure changes nothing → retried next
|
|
824
|
+
// cycle, quarantined after MAX_PAIR_CLOSE_FAILURES in a row. An unknown
|
|
825
|
+
// outcome, a partial close or an unhedged leg quarantines (no retry).
|
|
826
|
+
const sizeStr = String(pos.size);
|
|
827
|
+
const closeRes = await closePerpPairVerified({
|
|
828
|
+
source: "arb-auto", sym: pos.symbol,
|
|
829
|
+
longAdapter, longExch: pos.longExchange, longSym: pos.symbol, longSize: sizeStr,
|
|
830
|
+
shortAdapter, shortExch: pos.shortExchange, shortSym: pos.symbol, shortSize: sizeStr, smart: false,
|
|
831
|
+
log: (m) => console.log(chalk.red(m)),
|
|
832
|
+
});
|
|
833
|
+
if (closeRes.status === "closed") {
|
|
834
|
+
closeFailures.delete(pos.symbol);
|
|
515
835
|
logExecution({
|
|
516
836
|
type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
|
|
517
|
-
symbol: pos.symbol, side: "close", size:
|
|
837
|
+
symbol: pos.symbol, side: "close", size: sizeStr,
|
|
518
838
|
status: "success", dryRun: false,
|
|
519
|
-
meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason
|
|
839
|
+
meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason },
|
|
520
840
|
});
|
|
521
|
-
|
|
841
|
+
console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs (reduce-only, verified)`));
|
|
842
|
+
st.remove(pos.symbol);
|
|
522
843
|
closeCooldowns.set(pos.symbol, Date.now());
|
|
523
844
|
openPositions.splice(i, 1);
|
|
845
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
|
|
846
|
+
symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
|
|
847
|
+
pnl: pos.accumulatedFundingUsd,
|
|
848
|
+
duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
else if (closeRes.status === "failed") {
|
|
852
|
+
const n = (closeFailures.get(pos.symbol) ?? 0) + 1;
|
|
853
|
+
closeFailures.set(pos.symbol, n);
|
|
854
|
+
logExecution({
|
|
855
|
+
type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
|
|
856
|
+
symbol: pos.symbol, side: "close", size: sizeStr,
|
|
857
|
+
status: "failed", dryRun: false,
|
|
858
|
+
error: `${closeRes.reason} (attempt ${n}/${MAX_PAIR_CLOSE_FAILURES}, nothing changed)`,
|
|
859
|
+
meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
|
|
860
|
+
});
|
|
861
|
+
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${closeRes.reason} — both legs still open (${n}/${MAX_PAIR_CLOSE_FAILURES})`));
|
|
862
|
+
if (n >= MAX_PAIR_CLOSE_FAILURES) {
|
|
863
|
+
quarantinePair("arb-auto", pos.symbol, pos.longExchange, pos.shortExchange, `close failed ${n} times in a row (last: ${closeRes.reason})`, (m) => console.log(chalk.red(m)));
|
|
864
|
+
}
|
|
865
|
+
// Do NOT splice — kept tracked; reduce-only makes the next attempt safe
|
|
524
866
|
}
|
|
525
867
|
else {
|
|
526
|
-
//
|
|
868
|
+
// Quarantined: kept in state (accurate: exposure may remain); hands off until cleared.
|
|
527
869
|
logExecution({
|
|
528
870
|
type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
|
|
529
|
-
symbol: pos.symbol, side: "close", size:
|
|
871
|
+
symbol: pos.symbol, side: "close", size: sizeStr,
|
|
530
872
|
status: "failed", dryRun: false,
|
|
531
|
-
error: `
|
|
532
|
-
meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason,
|
|
873
|
+
error: `QUARANTINED: ${closeRes.reason}`,
|
|
874
|
+
meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason, quarantined: true },
|
|
533
875
|
});
|
|
534
|
-
console.log(chalk.red.bold(` ${now}
|
|
876
|
+
console.log(chalk.red.bold(` ${now} CLOSE ${pos.symbol} QUARANTINED — ${closeRes.reason}. MANUAL CHECK REQUIRED`));
|
|
535
877
|
await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
|
|
536
|
-
exchange:
|
|
878
|
+
exchange: `${pos.longExchange}+${pos.shortExchange}`,
|
|
537
879
|
marginPct: 0, threshold: 0, symbol: pos.symbol,
|
|
538
|
-
message: `
|
|
880
|
+
message: `CLOSE QUARANTINED: ${closeRes.reason}. Manual check required.`,
|
|
539
881
|
});
|
|
540
|
-
// Do NOT splice — keep tracking so next cycle retries
|
|
541
882
|
}
|
|
542
883
|
}
|
|
543
|
-
|
|
544
|
-
// Both legs failed — keep position, retry next cycle
|
|
545
|
-
const longErr = longResult.reason;
|
|
546
|
-
const shortErr = shortResult.reason;
|
|
884
|
+
catch (err) {
|
|
547
885
|
logExecution({
|
|
548
886
|
type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
|
|
549
887
|
symbol: pos.symbol, side: "close", size: String(pos.size),
|
|
550
888
|
status: "failed", dryRun: false,
|
|
551
|
-
error:
|
|
889
|
+
error: err instanceof Error ? err.message : String(err),
|
|
552
890
|
meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
|
|
553
891
|
});
|
|
554
|
-
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}:
|
|
555
|
-
// Do NOT splice — retry next cycle
|
|
892
|
+
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${err instanceof Error ? err.message : err}`));
|
|
893
|
+
// Do NOT splice — nothing was sent (adapter creation failed); retry next cycle
|
|
556
894
|
}
|
|
557
895
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
error: err instanceof Error ? err.message : String(err),
|
|
564
|
-
meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
|
|
565
|
-
});
|
|
566
|
-
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${err instanceof Error ? err.message : err}`));
|
|
567
|
-
// Do NOT splice — retry next cycle
|
|
896
|
+
else {
|
|
897
|
+
// Dry run — remove from the in-memory (simulated) tracking only
|
|
898
|
+
st.remove(pos.symbol);
|
|
899
|
+
closeCooldowns.set(pos.symbol, Date.now());
|
|
900
|
+
openPositions.splice(i, 1);
|
|
568
901
|
}
|
|
569
902
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
openPositions.splice(i, 1);
|
|
575
|
-
}
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
906
|
+
console.error(chalk.red(` ${new Date().toLocaleTimeString()} EXIT CHECK FAILED ${pos.symbol} (${pos.longExchange}/${pos.shortExchange}): ${msg}`));
|
|
576
907
|
}
|
|
577
908
|
}
|
|
578
909
|
// Log next settlement times and funding estimation
|
|
@@ -663,13 +994,53 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
663
994
|
}
|
|
664
995
|
}
|
|
665
996
|
catch { /* margin check failed, continue without blocking */ }
|
|
666
|
-
//
|
|
997
|
+
// Entry decisions: every monitored symbol that is not entered gets its reason — logged per
|
|
998
|
+
// symbol for --symbols, summarized per cycle for a full scan (a silent run is impossible).
|
|
999
|
+
const skipCounts = new Map();
|
|
1000
|
+
const skip = (sym, reason) => {
|
|
1001
|
+
if (filterSymbols)
|
|
1002
|
+
console.log(chalk.gray(` ${now} SKIP ${sym}: ${reason}`));
|
|
1003
|
+
else {
|
|
1004
|
+
const k = reason.replace(/[-\d.]+/g, "#");
|
|
1005
|
+
skipCounts.set(k, (skipCounts.get(k) ?? 0) + 1);
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
for (const sym of filterSymbols ?? []) {
|
|
1009
|
+
if (!filtered.some(x => x.symbol === sym))
|
|
1010
|
+
console.log(chalk.gray(` ${now} SKIP ${sym}: not in this cycle's funding scan (no rate on two compared venues)`));
|
|
1011
|
+
}
|
|
1012
|
+
if (openPositions.length >= maxPositions) {
|
|
1013
|
+
console.log(chalk.gray(` ${now} NO ENTRY: max positions reached (${openPositions.length}/${maxPositions}: ${openPositions.map(p => `${p.symbol} ${p.longExchange}/${p.shortExchange}`).join(", ")})`));
|
|
1014
|
+
}
|
|
667
1015
|
if (openPositions.length < maxPositions) {
|
|
668
1016
|
for (const snap of filtered) {
|
|
669
|
-
if (
|
|
1017
|
+
if (stop.requested()) {
|
|
1018
|
+
console.log(chalk.yellow(` ${now} STOP requested — no new entries`));
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
if (openPositions.some(p => p.symbol === snap.symbol)) {
|
|
1022
|
+
skip(snap.symbol, "already open");
|
|
670
1023
|
continue;
|
|
671
|
-
|
|
1024
|
+
}
|
|
1025
|
+
// Global quarantine (design §12.3): unknown / unhedged outcome on this symbol somewhere.
|
|
1026
|
+
if (isQuarantined(snap.symbol, (m) => console.log(chalk.red(m)))) {
|
|
1027
|
+
skip(snap.symbol, "quarantined");
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
// A persisted spot-perp position on this symbol (not daemon-managed) blocks entry:
|
|
1031
|
+
// the state is keyed by symbol, so a new perp-perp entry would overwrite it.
|
|
1032
|
+
if (syncState?.positions.some(p => p.mode === "spot-perp" && p.symbol === snap.symbol)) {
|
|
1033
|
+
skip(snap.symbol, "a spot-perp arb state entry exists for this symbol");
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
if (staleSymbols.has(snap.symbol)) {
|
|
1037
|
+
skip(snap.symbol, `a stale arb state entry exists (drop it: perp arb close ${snap.symbol} --state-only)`);
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
if (openPositions.length >= maxPositions) {
|
|
1041
|
+
skip(snap.symbol, `max positions reached (${maxPositions})`);
|
|
672
1042
|
break;
|
|
1043
|
+
}
|
|
673
1044
|
// Cooldown: skip if recently closed to prevent close→re-entry loops
|
|
674
1045
|
const lastCloseMs = closeCooldowns.get(snap.symbol);
|
|
675
1046
|
if (lastCloseMs && cooldownMinutes > 0) {
|
|
@@ -687,18 +1058,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
687
1058
|
const shortExchange = snap.shortExch;
|
|
688
1059
|
// Block entries if either exchange has low margin
|
|
689
1060
|
if (blockedExchanges.has(longExchange) || blockedExchanges.has(shortExchange)) {
|
|
690
|
-
|
|
691
|
-
console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: margin too low on ${blockedExchanges.has(longExchange) ? longExchange : shortExchange}`));
|
|
692
|
-
}
|
|
1061
|
+
skip(snap.symbol, `entries blocked on ${blockedExchanges.has(longExchange) ? longExchange : shortExchange} (margin low / venue down)`);
|
|
693
1062
|
continue;
|
|
694
1063
|
}
|
|
695
1064
|
// Compute net spread after fees and bridge costs
|
|
696
1065
|
const roundTripCost = computeRoundTripCostPct(longExchange, shortExchange);
|
|
697
1066
|
const effectiveSizeUsd = sizeIsAuto ? 100 : sizeUsd; // Use $100 for net spread calc when auto
|
|
698
1067
|
const netSpread = computeNetSpread(grossSpread, holdDays, roundTripCost, bridgeCostUsd, effectiveSizeUsd);
|
|
699
|
-
// Settlement timing check with strategy
|
|
1068
|
+
// Settlement timing check with strategy (Aster interval is per symbol)
|
|
1069
|
+
const snapMarketInterval = (ex) => ex === "aster" ? snap.astHours : null;
|
|
700
1070
|
if (settleStrategy === "block") {
|
|
701
|
-
const settlCheck = isNearSettlement(longExchange, shortExchange);
|
|
1071
|
+
const settlCheck = isNearSettlement(longExchange, shortExchange, 5, new Date(), snapMarketInterval);
|
|
702
1072
|
if (settlCheck.blocked) {
|
|
703
1073
|
console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: ${settlCheck.minutesUntil?.toFixed(1)}m before ${settlCheck.exchange} settlement`));
|
|
704
1074
|
continue;
|
|
@@ -707,15 +1077,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
707
1077
|
// In aggressive mode, boost score for post-settlement entries
|
|
708
1078
|
let settleBoostMultiplier = 1.0;
|
|
709
1079
|
if (settleStrategy === "aggressive") {
|
|
710
|
-
settleBoostMultiplier = aggressiveSettleBoost(longExchange, shortExchange, 10, new Date());
|
|
1080
|
+
settleBoostMultiplier = aggressiveSettleBoost(longExchange, shortExchange, 10, new Date(), snapMarketInterval);
|
|
711
1081
|
if (settleBoostMultiplier > 1.0) {
|
|
712
1082
|
console.log(chalk.cyan(` ${now} BOOST ${snap.symbol}: post-settlement ${((settleBoostMultiplier - 1) * 100).toFixed(0)}% score boost`));
|
|
713
1083
|
}
|
|
714
1084
|
}
|
|
715
1085
|
// --min-spread compares against NET spread (with settle boost applied)
|
|
716
1086
|
const effectiveNetSpread = netSpread * settleBoostMultiplier;
|
|
717
|
-
if (effectiveNetSpread < minSpread)
|
|
1087
|
+
if (effectiveNetSpread < minSpread) {
|
|
1088
|
+
skip(snap.symbol, `net spread ${effectiveNetSpread.toFixed(2)}% < --min-spread ${minSpread}% (gross ${grossSpread.toFixed(2)}%, round-trip cost ${roundTripCost.toFixed(3)}% over ${holdDays}d; long ${longExchange} / short ${shortExchange})`);
|
|
718
1089
|
continue;
|
|
1090
|
+
}
|
|
719
1091
|
const rateForExch = (e) => e === "pacifica" ? snap.pacRate : e === "hyperliquid" ? snap.hlRate : e === "aster" ? snap.astRate : snap.ltRate;
|
|
720
1092
|
console.log(chalk.green(` ${now} ENTER ${snap.symbol} — gross ${grossSpread.toFixed(1)}% net ${netSpread.toFixed(1)}%` +
|
|
721
1093
|
` | Long ${longExchange} (${(rateForExch(longExchange) * 100).toFixed(4)}%)` +
|
|
@@ -747,126 +1119,106 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
747
1119
|
console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: auto-size $${actualSizeUsd} below min $${minSizeUsd}`));
|
|
748
1120
|
continue;
|
|
749
1121
|
}
|
|
750
|
-
|
|
1122
|
+
// Floored (never rounds up past the requested notional).
|
|
1123
|
+
const sizeInAsset = fillSizeString(actualSizeUsd / snap.markPrice, 4);
|
|
1124
|
+
if (!(Number(sizeInAsset) > 0)) {
|
|
1125
|
+
console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: size rounds to 0 ($${actualSizeUsd} / $${snap.markPrice})`));
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
/** Size actually hedged on both legs (verified); the requested size in dry-run. */
|
|
1129
|
+
let openedSize = sizeInAsset;
|
|
751
1130
|
if (!dryRun) {
|
|
1131
|
+
// Pending-entry record BEFORE the first leg (crash between legs → quarantine at the next start).
|
|
1132
|
+
const net = networkOf(program);
|
|
1133
|
+
try {
|
|
1134
|
+
writePendingEntry(net, { source: "arb-auto", symbol: snap.symbol, longExchange, shortExchange, size: sizeInAsset });
|
|
1135
|
+
}
|
|
1136
|
+
catch (e) {
|
|
1137
|
+
console.error(chalk.red(` ${now} SKIP ${snap.symbol}: could not record the pending entry (${e instanceof Error ? e.message : String(e)}) — nothing sent`));
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
const donePending = () => finishPendingEntry(net, "arb-auto", snap.symbol, (m) => console.error(chalk.red(m)));
|
|
1141
|
+
let legsStarted = false;
|
|
752
1142
|
try {
|
|
753
1143
|
const longAdapter = await getAdapterForExchange(longExchange);
|
|
754
1144
|
const shortAdapter = await getAdapterForExchange(shortExchange);
|
|
755
|
-
//
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1145
|
+
// Sequential verified legs (design §12.3): long, then short sized to the
|
|
1146
|
+
// long's verified fill; reduce-only rollback / trim; unknown → quarantine.
|
|
1147
|
+
legsStarted = true;
|
|
1148
|
+
const res = await openPerpPairVerified({
|
|
1149
|
+
source: "arb-auto", sym: snap.symbol,
|
|
1150
|
+
longAdapter, longExch: longExchange, longSym: snap.symbol, shortAdapter, shortExch: shortExchange, shortSym: snap.symbol,
|
|
1151
|
+
size: sizeInAsset, smart: false, log: (m) => console.log(chalk.red(m)),
|
|
1152
|
+
});
|
|
1153
|
+
legsStarted = false; // outcome known (opened / not opened / quarantined)
|
|
1154
|
+
if (res.status === "opened") {
|
|
1155
|
+
openedSize = res.hedged;
|
|
764
1156
|
logExecution({
|
|
765
1157
|
type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
|
|
766
|
-
symbol: snap.symbol, side: "entry", size:
|
|
1158
|
+
symbol: snap.symbol, side: "entry", size: res.hedged,
|
|
767
1159
|
status: "success", dryRun: false,
|
|
768
|
-
meta: { longExchange, shortExchange, grossSpread, netSpread, roundTripCost, markPrice: snap.markPrice },
|
|
1160
|
+
meta: { longExchange, shortExchange, grossSpread, netSpread, roundTripCost, markPrice: snap.markPrice, requested: sizeInAsset, trimmed: res.trimmed },
|
|
769
1161
|
});
|
|
770
|
-
console.log(chalk.green(` ${now} FILLED ${snap.symbol} — both legs @ ${
|
|
771
|
-
//
|
|
772
|
-
|
|
773
|
-
let verifiedShortSize = sizeInAsset;
|
|
1162
|
+
console.log(chalk.green(` ${now} FILLED ${snap.symbol} — both legs @ ${res.hedged} units (requested ${sizeInAsset}; $${actualSizeUsd} / $${snap.markPrice.toFixed(2)})`));
|
|
1163
|
+
// Persist to state file immediately (crash-safe) — the verified hedged size.
|
|
1164
|
+
const entryTime = new Date().toISOString();
|
|
774
1165
|
try {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1166
|
+
st.add({
|
|
1167
|
+
id: `${snap.symbol}-${Date.now()}`,
|
|
1168
|
+
symbol: snap.symbol,
|
|
1169
|
+
longExchange,
|
|
1170
|
+
shortExchange,
|
|
1171
|
+
longSize: parseFloat(res.hedged),
|
|
1172
|
+
shortSize: parseFloat(res.hedged),
|
|
1173
|
+
entryTime,
|
|
1174
|
+
entrySpread: grossSpread,
|
|
1175
|
+
entryLongPrice: snap.markPrice,
|
|
1176
|
+
entryShortPrice: snap.markPrice,
|
|
1177
|
+
accumulatedFunding: 0,
|
|
1178
|
+
lastCheckTime: entryTime,
|
|
1179
|
+
});
|
|
788
1180
|
}
|
|
789
|
-
catch {
|
|
790
|
-
//
|
|
791
|
-
|
|
1181
|
+
catch (e) {
|
|
1182
|
+
// A hedged pair the state cannot record (write failed / conflicting entry): never
|
|
1183
|
+
// left as silent exposure — quarantined with what is open.
|
|
1184
|
+
quarantinePair("arb-auto", snap.symbol, longExchange, shortExchange, `entry opened (long ${res.hedged} on ${longExchange} / short ${res.hedged} on ${shortExchange}) but the arb state could not record it: ${e instanceof Error ? e.message : String(e)}`, (m) => console.log(chalk.red(m)));
|
|
792
1185
|
}
|
|
793
|
-
|
|
794
|
-
const entryTime = new Date().toISOString();
|
|
795
|
-
persistAddPosition({
|
|
796
|
-
id: `${snap.symbol}-${Date.now()}`,
|
|
797
|
-
symbol: snap.symbol,
|
|
798
|
-
longExchange,
|
|
799
|
-
shortExchange,
|
|
800
|
-
longSize: parseFloat(verifiedLongSize),
|
|
801
|
-
shortSize: parseFloat(verifiedShortSize),
|
|
802
|
-
entryTime,
|
|
803
|
-
entrySpread: grossSpread,
|
|
804
|
-
entryLongPrice: snap.markPrice,
|
|
805
|
-
entryShortPrice: snap.markPrice,
|
|
806
|
-
accumulatedFunding: 0,
|
|
807
|
-
lastCheckTime: entryTime,
|
|
808
|
-
});
|
|
1186
|
+
donePending();
|
|
809
1187
|
await notifyIfEnabled(webhookUrl, notifyEvents, "entry", {
|
|
810
1188
|
symbol: snap.symbol, longExchange, shortExchange,
|
|
811
1189
|
size: actualSizeUsd, netSpread,
|
|
812
1190
|
});
|
|
813
1191
|
}
|
|
814
|
-
else if (
|
|
815
|
-
// One leg filled, one failed — ROLLBACK the successful leg
|
|
816
|
-
const filledSide = longOk ? "long" : "short";
|
|
817
|
-
const failedSide = longOk ? "short" : "long";
|
|
818
|
-
const filledAdapter = longOk ? longAdapter : shortAdapter;
|
|
819
|
-
const rollbackAction = longOk ? "sell" : "buy"; // reverse the filled side
|
|
820
|
-
const failedErr = longOk
|
|
821
|
-
? shortResult.reason
|
|
822
|
-
: longResult.reason;
|
|
823
|
-
console.log(chalk.yellow(` ${now} PARTIAL FILL ${snap.symbol}: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
|
|
824
|
-
// Attempt rollback with max 2 retries
|
|
825
|
-
let rollbackOk = false;
|
|
826
|
-
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
827
|
-
try {
|
|
828
|
-
await filledAdapter.marketOrder(snap.symbol, rollbackAction, sizeInAsset);
|
|
829
|
-
rollbackOk = true;
|
|
830
|
-
console.log(chalk.green(` ${now} ROLLBACK ${snap.symbol}: ${filledSide} leg closed (attempt ${attempt})`));
|
|
831
|
-
break;
|
|
832
|
-
}
|
|
833
|
-
catch (rollbackErr) {
|
|
834
|
-
console.log(chalk.red(` ${now} ROLLBACK ATTEMPT ${attempt}/2 FAILED ${snap.symbol}: ${rollbackErr instanceof Error ? rollbackErr.message : rollbackErr}`));
|
|
835
|
-
}
|
|
836
|
-
}
|
|
1192
|
+
else if (res.status === "not_opened") {
|
|
837
1193
|
logExecution({
|
|
838
1194
|
type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
|
|
839
1195
|
symbol: snap.symbol, side: "entry", size: sizeInAsset,
|
|
840
1196
|
status: "failed", dryRun: false,
|
|
841
|
-
error:
|
|
842
|
-
meta: { longExchange, shortExchange, grossSpread, netSpread
|
|
1197
|
+
error: `${res.reason}${res.rolledBack !== null ? ` — long ${res.rolledBack} rolled back (reduce-only, verified)` : ""}`,
|
|
1198
|
+
meta: { longExchange, shortExchange, grossSpread, netSpread },
|
|
843
1199
|
});
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
|
|
848
|
-
exchange: longOk ? longExchange : shortExchange,
|
|
849
|
-
marginPct: 0,
|
|
850
|
-
threshold: 0,
|
|
851
|
-
symbol: snap.symbol,
|
|
852
|
-
message: `IMBALANCE: ${filledSide} leg filled on ${longOk ? longExchange : shortExchange}, ${failedSide} failed, rollback failed. Manual close required.`,
|
|
853
|
-
});
|
|
854
|
-
}
|
|
855
|
-
continue; // don't add to openPositions
|
|
1200
|
+
console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: ${res.reason}${res.rolledBack !== null ? ` — rolled back long ${res.rolledBack}; no open exposure` : " — nothing opened"}`));
|
|
1201
|
+
donePending();
|
|
1202
|
+
continue;
|
|
856
1203
|
}
|
|
857
1204
|
else {
|
|
858
|
-
//
|
|
859
|
-
const longErr = longResult.reason;
|
|
860
|
-
const shortErr = shortResult.reason;
|
|
1205
|
+
donePending(); // quarantined (persisted) by openPerpPairVerified
|
|
861
1206
|
logExecution({
|
|
862
1207
|
type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
|
|
863
1208
|
symbol: snap.symbol, side: "entry", size: sizeInAsset,
|
|
864
1209
|
status: "failed", dryRun: false,
|
|
865
|
-
error: `
|
|
866
|
-
meta: { longExchange, shortExchange, grossSpread, netSpread },
|
|
1210
|
+
error: `QUARANTINED: ${res.reason}`,
|
|
1211
|
+
meta: { longExchange, shortExchange, grossSpread, netSpread, quarantined: true, exposure: res.exposure },
|
|
867
1212
|
});
|
|
868
|
-
console.
|
|
869
|
-
|
|
1213
|
+
console.log(chalk.red.bold(` ${now} IMBALANCE ${snap.symbol}: ${res.exposure} — quarantined, MANUAL CHECK REQUIRED`));
|
|
1214
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
|
|
1215
|
+
exchange: `${longExchange}+${shortExchange}`,
|
|
1216
|
+
marginPct: 0,
|
|
1217
|
+
threshold: 0,
|
|
1218
|
+
symbol: snap.symbol,
|
|
1219
|
+
message: `ENTRY QUARANTINED: ${res.reason}. Possible exposure: ${res.exposure}. Manual check required.`,
|
|
1220
|
+
});
|
|
1221
|
+
continue; // not tracked as a hedged pair; the quarantine blocks every path
|
|
870
1222
|
}
|
|
871
1223
|
}
|
|
872
1224
|
catch (err) {
|
|
@@ -877,6 +1229,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
877
1229
|
error: err instanceof Error ? err.message : String(err),
|
|
878
1230
|
meta: { longExchange, shortExchange, grossSpread, netSpread },
|
|
879
1231
|
});
|
|
1232
|
+
if (legsStarted) {
|
|
1233
|
+
// Threw inside the leg sequence: what is open is unknown → quarantine (never retried).
|
|
1234
|
+
quarantinePair("arb-auto", snap.symbol, longExchange, shortExchange, `entry threw mid-sequence (${err instanceof Error ? err.message : String(err)}) — legs may be open`, (m) => console.log(chalk.red(m)));
|
|
1235
|
+
}
|
|
1236
|
+
donePending();
|
|
880
1237
|
console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: ${err instanceof Error ? err.message : err}`));
|
|
881
1238
|
continue;
|
|
882
1239
|
}
|
|
@@ -886,16 +1243,16 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
886
1243
|
symbol: snap.symbol,
|
|
887
1244
|
longExchange,
|
|
888
1245
|
shortExchange,
|
|
889
|
-
size: parseFloat(
|
|
1246
|
+
size: parseFloat(openedSize),
|
|
890
1247
|
entrySpread: grossSpread,
|
|
891
1248
|
entryTime: posEntryTime,
|
|
892
1249
|
entryMarkPrice: snap.markPrice,
|
|
893
1250
|
accumulatedFundingUsd: 0,
|
|
894
1251
|
lastCheckTime: Date.now(),
|
|
895
1252
|
});
|
|
896
|
-
//
|
|
1253
|
+
// Dry-run: record the simulated entry in the in-memory state only (never the real file).
|
|
897
1254
|
if (dryRun) {
|
|
898
|
-
|
|
1255
|
+
st.add({
|
|
899
1256
|
id: `${snap.symbol}-${Date.now()}`,
|
|
900
1257
|
symbol: snap.symbol,
|
|
901
1258
|
longExchange,
|
|
@@ -912,6 +1269,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
912
1269
|
}
|
|
913
1270
|
}
|
|
914
1271
|
}
|
|
1272
|
+
if (skipCounts.size > 0) {
|
|
1273
|
+
console.log(chalk.gray(` ${now} NO ENTRY (${[...skipCounts.values()].reduce((x, y) => x + y, 0)} symbols): ${[...skipCounts].map(([k, n]) => `${n}× ${k}`).join("; ")}`));
|
|
1274
|
+
}
|
|
915
1275
|
// Accumulate estimated funding income & show status
|
|
916
1276
|
if (openPositions.length > 0) {
|
|
917
1277
|
const nowMs = Date.now();
|
|
@@ -922,9 +1282,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
922
1282
|
const elapsedHours = (nowMs - pos.lastCheckTime) / (1000 * 60 * 60);
|
|
923
1283
|
const notional = pos.size * current.markPrice;
|
|
924
1284
|
// Estimate funding collected: normalize all rates to hourly before comparing
|
|
925
|
-
const
|
|
926
|
-
const
|
|
927
|
-
const shortHourly = toHourlyRate(rateFor(pos.shortExchange), pos.shortExchange);
|
|
1285
|
+
const longHourly = snapshotHourly(current, pos.longExchange);
|
|
1286
|
+
const shortHourly = snapshotHourly(current, pos.shortExchange);
|
|
928
1287
|
// Income = short gets paid positive funding, long pays; net = (shortRate - longRate) * notional * hours
|
|
929
1288
|
const hourlyIncome = (shortHourly - longHourly) * notional;
|
|
930
1289
|
pos.accumulatedFundingUsd += hourlyIncome * elapsedHours;
|
|
@@ -937,20 +1296,31 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
937
1296
|
openPositions.map(p => `${p.symbol}(${p.entrySpread.toFixed(0)}% $${p.accumulatedFundingUsd.toFixed(3)})`).join(", ")));
|
|
938
1297
|
}
|
|
939
1298
|
// Update heartbeat: mark successful scan
|
|
940
|
-
const stateForHeartbeat =
|
|
1299
|
+
const stateForHeartbeat = st.load();
|
|
941
1300
|
if (stateForHeartbeat) {
|
|
942
1301
|
stateForHeartbeat.lastSuccessfulScanTime = new Date().toISOString();
|
|
943
1302
|
stateForHeartbeat.lastScanTime = new Date().toISOString();
|
|
944
|
-
|
|
1303
|
+
st.save(stateForHeartbeat);
|
|
945
1304
|
}
|
|
946
1305
|
}
|
|
947
1306
|
catch (err) {
|
|
948
1307
|
console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
949
1308
|
}
|
|
950
1309
|
};
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1310
|
+
// Serialized cycles (never overlapping): the next starts only after this one finished.
|
|
1311
|
+
while (!stop.requested()) {
|
|
1312
|
+
await cycle();
|
|
1313
|
+
await interruptibleSleep(intervalMs, stop.requested);
|
|
1314
|
+
}
|
|
1315
|
+
const finalState = st.load();
|
|
1316
|
+
if (finalState) {
|
|
1317
|
+
finalState.lastScanTime = new Date().toISOString();
|
|
1318
|
+
st.save(finalState);
|
|
1319
|
+
}
|
|
1320
|
+
stop.dispose();
|
|
1321
|
+
if (!isJson())
|
|
1322
|
+
console.log(chalk.yellow("\n Daemon stopped (current cycle finished). State saved.\n"));
|
|
1323
|
+
process.exit(0);
|
|
954
1324
|
});
|
|
955
1325
|
// ── arb scan ── (one-shot spread scan)
|
|
956
1326
|
arb
|
|
@@ -972,7 +1342,6 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
972
1342
|
.option("--symbols <list>", "Comma-separated list of symbols")
|
|
973
1343
|
.option("--all", "Include all symbols (not just top)")
|
|
974
1344
|
.option("--hours <n>", "Hours of history to show", "24")
|
|
975
|
-
.option("--exchange <ex>", "Filter by exchange")
|
|
976
1345
|
.option("--max-gap <pct>", "Max price gap % (hip3 mode)")
|
|
977
1346
|
.option("--include-native", "Include native HL perps (hip3 mode)", true)
|
|
978
1347
|
.option("--no-include-native", "Exclude native HL perps (hip3 mode)")
|
|
@@ -984,6 +1353,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
984
1353
|
.option("--duration <minutes>", "Track duration in minutes (with --track)", "10")
|
|
985
1354
|
.option("--beep", "Beep on large gaps (with --gaps)")
|
|
986
1355
|
.option("--check-liquidity", "Check orderbook depth in live mode (slower)")
|
|
1356
|
+
.option("--include-unverified", "With --rates / --compare: also show venues whose funding rate is unverified (qfex) — display only, never ranked for trading")
|
|
987
1357
|
.action(async (opts) => {
|
|
988
1358
|
// ── Price gap modes ──
|
|
989
1359
|
if (opts.gaps || opts.track || opts.alert) {
|
|
@@ -1178,18 +1548,26 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1178
1548
|
}
|
|
1179
1549
|
}
|
|
1180
1550
|
// Route to specialized handlers
|
|
1551
|
+
if (opts.includeUnverified === true && !opts.compare && !opts.rates) {
|
|
1552
|
+
throw new PerpError("INVALID_PARAMS", "--include-unverified applies to --rates / --compare only: unverified funding rates are shown, never ranked as arb opportunities", {
|
|
1553
|
+
remediation: "perp arb scan --rates --include-unverified (or: --compare <symbol> --include-unverified)",
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1181
1556
|
if (opts.compare)
|
|
1182
|
-
return handleCompare(isJson, opts.compare);
|
|
1557
|
+
return handleCompare(isJson, opts.compare, opts.includeUnverified === true);
|
|
1183
1558
|
if (opts.rates)
|
|
1184
|
-
return handleRates(isJson, { symbol: opts.symbol, symbols: opts.symbols, all: opts.all, minSpread: opts.min, top: opts.top });
|
|
1559
|
+
return handleRates(isJson, { symbol: opts.symbol, symbols: opts.symbols, all: opts.all, minSpread: opts.min, top: opts.top, includeUnverified: opts.includeUnverified === true });
|
|
1185
1560
|
if (opts.basis)
|
|
1186
1561
|
return handleBasisScan(isJson, { minBasis: opts.min, symbol: typeof opts.basis === "string" ? opts.basis : opts.symbol });
|
|
1187
1562
|
if (opts.hip3)
|
|
1188
1563
|
return handleDexScan(isJson, { minSpread: opts.min, maxGap: opts.maxGap || "5", includeNative: opts.includeNative !== false, top: opts.top || "20" });
|
|
1564
|
+
// The exchange filter (--history / --positions) is the ROOT -e/--exchange (commander gives it
|
|
1565
|
+
// to the root program): used only when passed on the command line, never the root default venue.
|
|
1566
|
+
const exchangeFilter = program.getOptionValueSource("exchange") === "cli" ? String(program.opts().exchange) : undefined;
|
|
1189
1567
|
if (opts.history)
|
|
1190
|
-
return handleFundingHistory(isJson, { symbol: (typeof opts.history === "string" ? opts.history : opts.symbol) || "ETH", hours: opts.hours || "24", exchange:
|
|
1568
|
+
return handleFundingHistory(isJson, { symbol: (typeof opts.history === "string" ? opts.history : opts.symbol) || "ETH", hours: opts.hours || "24", exchange: exchangeFilter });
|
|
1191
1569
|
if (opts.positions)
|
|
1192
|
-
return handleFundingPositions(isJson, getAdapterForExchange, { exchanges:
|
|
1570
|
+
return handleFundingPositions(isJson, getAdapterForExchange, { exchanges: exchangeFilter });
|
|
1193
1571
|
// Default: existing scan logic continues below
|
|
1194
1572
|
const minSpread = parseFloat(opts.min);
|
|
1195
1573
|
const holdDays = parseFloat(opts.holdDays);
|
|
@@ -1272,7 +1650,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1272
1650
|
const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
|
|
1273
1651
|
const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
1274
1652
|
// Compute price gap between long and short exchanges
|
|
1275
|
-
const pFor = (ex) => ex === "pacifica" ? s.pacMarkPrice : ex === "hyperliquid" ? s.hlMarkPrice : ex === "lighter" ? s.ltMarkPrice : s.astMarkPrice;
|
|
1653
|
+
const pFor = (ex) => ex === "pacifica" ? s.pacMarkPrice : ex === "hyperliquid" ? s.hlMarkPrice : ex === "lighter" ? s.ltMarkPrice : ex === "aster" ? s.astMarkPrice : unknownArbVenue(ex);
|
|
1276
1654
|
const longPrice = pFor(s.longExch);
|
|
1277
1655
|
const shortPrice = pFor(s.shortExch);
|
|
1278
1656
|
const mid = (longPrice + shortPrice) / 2;
|
|
@@ -1310,7 +1688,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1310
1688
|
const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
1311
1689
|
const grossColor = grossSpread >= 50 ? chalk.green.bold : grossSpread >= 30 ? chalk.green : chalk.yellow;
|
|
1312
1690
|
const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
1313
|
-
const priceFor = (ex) => ex === "pacifica" ? s.pacMarkPrice : ex === "hyperliquid" ? s.hlMarkPrice : ex === "lighter" ? s.ltMarkPrice : s.astMarkPrice;
|
|
1691
|
+
const priceFor = (ex) => ex === "pacifica" ? s.pacMarkPrice : ex === "hyperliquid" ? s.hlMarkPrice : ex === "lighter" ? s.ltMarkPrice : ex === "aster" ? s.astMarkPrice : unknownArbVenue(ex);
|
|
1314
1692
|
const longPrice = priceFor(s.longExch);
|
|
1315
1693
|
const shortPrice = priceFor(s.shortExch);
|
|
1316
1694
|
const mid = (longPrice + shortPrice) / 2;
|
|
@@ -1342,19 +1720,34 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1342
1720
|
console.log(chalk.gray(` Use 'perp arb auto --min-spread ${minSpread}' to auto-trade\n`));
|
|
1343
1721
|
});
|
|
1344
1722
|
// ── arb exec ── (validate orderbook + simultaneous dual-leg entry)
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1723
|
+
/**
|
|
1724
|
+
* arb exec outcome that is NOT a success (not entered / rolled back / pending / quarantined):
|
|
1725
|
+
* exit code 1, and in JSON mode an ok:false envelope carrying the structured result as
|
|
1726
|
+
* `details` — so a script never reads a failed or unconfirmed entry as success.
|
|
1727
|
+
*/
|
|
1728
|
+
const reportExecFailure = (code, message, details) => {
|
|
1729
|
+
process.exitCode = 1;
|
|
1730
|
+
if (!isJson())
|
|
1731
|
+
return;
|
|
1732
|
+
const remediation = typeof details.remediation === "string" ? details.remediation : undefined;
|
|
1733
|
+
printJson(jsonError(code, message, {
|
|
1734
|
+
status: ERROR_CODES[code].status,
|
|
1735
|
+
retryable: ERROR_CODES[code].retryable,
|
|
1736
|
+
details,
|
|
1737
|
+
...(remediation !== undefined ? { remediation } : {}),
|
|
1738
|
+
}));
|
|
1739
|
+
};
|
|
1740
|
+
supportsDryRun(arb.command("exec"))
|
|
1741
|
+
.description("Execute arb (supports --dry-run): validate orderbook depth, then enter the legs sequentially (long, then short sized to the verified long fill). Use spot:<exch> for spot+perp mode.")
|
|
1348
1742
|
.argument("<symbol>", "Symbol (e.g. BTC, ETH, ICP)")
|
|
1349
|
-
.argument("<longExch>", "Exchange to go LONG on (hl, pac, lighter, or spot
|
|
1743
|
+
.argument("<longExch>", "Exchange to go LONG on (hl, pac, lighter, or spot:<venue> for spot+perp — hl, lt, lrh, aster, ondo)")
|
|
1350
1744
|
.argument("<shortExch>", "Exchange to go SHORT on (hl, pac, lighter)")
|
|
1351
1745
|
.argument("<sizeUsd>", "Position size per leg in USD")
|
|
1352
1746
|
.option("--max-slippage <pct>", "Max slippage % per leg", "0.5")
|
|
1353
1747
|
.option("--leverage <n>", "Set leverage before entry (both exchanges)")
|
|
1354
1748
|
.option("--isolated", "Use isolated margin mode")
|
|
1355
1749
|
.option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
|
|
1356
|
-
.option("--
|
|
1357
|
-
.option("--reconcile", "Auto-adjust leg sizes to match after execution (trim larger side)")
|
|
1750
|
+
.option("--reconcile", "(always on) the long is trimmed reduce-only to the short's verified fill")
|
|
1358
1751
|
.action(async (symbol, longExch, shortExch, sizeUsdStr, opts) => {
|
|
1359
1752
|
// Detect spot: prefix -> route to spot-exec logic
|
|
1360
1753
|
if (longExch.startsWith("spot:")) {
|
|
@@ -1364,21 +1757,39 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1364
1757
|
const sym = symbol.toUpperCase();
|
|
1365
1758
|
const sizeUsd = parseFloat(sizeUsdStr);
|
|
1366
1759
|
const maxSlippage = parseFloat(opts.maxSlippage);
|
|
1367
|
-
const dryRun =
|
|
1760
|
+
const dryRun = isDryRun(program);
|
|
1368
1761
|
// Resolve exchange aliases
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1762
|
+
longExch = resolveVenueId(longExch);
|
|
1763
|
+
shortExch = resolveVenueId(shortExch);
|
|
1764
|
+
// Only venues with a trading role can hold a leg.
|
|
1765
|
+
for (const ex of [longExch, shortExch]) {
|
|
1766
|
+
const venue = getVenue(ex);
|
|
1767
|
+
if (!venue.roles.includes("trading")) {
|
|
1768
|
+
throw unsupported(venue.displayName, "trading", "no trading API — it cannot be an arb leg", `Use a trading venue: ${listVenuesWithRole("trading").map(v => v.id).join(", ")}`);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1372
1771
|
if (longExch === shortExch) {
|
|
1772
|
+
reportExecFailure("INVALID_PARAMS", "longExch and shortExch must be different", { error: "longExch and shortExch must be different" });
|
|
1373
1773
|
if (isJson())
|
|
1374
|
-
return
|
|
1774
|
+
return;
|
|
1375
1775
|
console.log(chalk.red(" Long and short exchange must be different."));
|
|
1376
1776
|
return;
|
|
1377
1777
|
}
|
|
1778
|
+
// Global quarantine (design §12.3): an unknown / unhedged outcome on this symbol
|
|
1779
|
+
// anywhere blocks new trades until a human clears it.
|
|
1780
|
+
const quarantined = quarantineEntryFor(sym, (m) => console.error(m));
|
|
1781
|
+
if (quarantined !== null) {
|
|
1782
|
+
throw new PerpError("RISK_VIOLATION", `${sym} is quarantined (${quarantined.strategy}: ${quarantined.reason})`, {
|
|
1783
|
+
remediation: `${quarantined.remediation} — then: perp strategy quarantine-clear ${sym}`,
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
// A venue whose funding rate is unverified is never a funding-arb leg (design §14.12 #6).
|
|
1787
|
+
assertFundingArbVenue(longExch);
|
|
1788
|
+
assertFundingArbVenue(shortExch);
|
|
1378
1789
|
const longAdapter = await getAdapterForExchange(longExch);
|
|
1379
1790
|
const shortAdapter = await getAdapterForExchange(shortExch);
|
|
1380
|
-
// 1. Set leverage if requested
|
|
1381
|
-
if (opts.leverage) {
|
|
1791
|
+
// 1. Set leverage if requested (a venue write — skipped under --dry-run, reported in the plan)
|
|
1792
|
+
if (opts.leverage && !dryRun) {
|
|
1382
1793
|
const lev = parseInt(opts.leverage);
|
|
1383
1794
|
const mode = opts.isolated ? "isolated" : "cross";
|
|
1384
1795
|
if (!isJson())
|
|
@@ -1389,15 +1800,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1389
1800
|
]);
|
|
1390
1801
|
if (longLev.status === "rejected") {
|
|
1391
1802
|
const msg = `Failed to set leverage on ${longExch}: ${longLev.reason instanceof Error ? longLev.reason.message : longLev.reason}`;
|
|
1803
|
+
reportExecFailure("EXCHANGE_ERROR", msg, { error: msg });
|
|
1392
1804
|
if (isJson())
|
|
1393
|
-
return
|
|
1805
|
+
return;
|
|
1394
1806
|
console.log(chalk.red(` ${msg}`));
|
|
1395
1807
|
return;
|
|
1396
1808
|
}
|
|
1397
1809
|
if (shortLev.status === "rejected") {
|
|
1398
1810
|
const msg = `Failed to set leverage on ${shortExch}: ${shortLev.reason instanceof Error ? shortLev.reason.message : shortLev.reason}`;
|
|
1811
|
+
reportExecFailure("EXCHANGE_ERROR", msg, { error: msg });
|
|
1399
1812
|
if (isJson())
|
|
1400
|
-
return
|
|
1813
|
+
return;
|
|
1401
1814
|
console.log(chalk.red(` ${msg}`));
|
|
1402
1815
|
return;
|
|
1403
1816
|
}
|
|
@@ -1425,8 +1838,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1425
1838
|
fillableUsd: Math.round(fillableUsd * 100) / 100,
|
|
1426
1839
|
validation,
|
|
1427
1840
|
};
|
|
1841
|
+
reportExecFailure("SIZE_TOO_LARGE", `Insufficient orderbook depth for $${sizeUsd} (fillable ~$${result.fillableUsd})`, result);
|
|
1428
1842
|
if (isJson())
|
|
1429
|
-
return
|
|
1843
|
+
return;
|
|
1430
1844
|
console.log(chalk.red(`\n Insufficient depth for $${sizeUsd}:`));
|
|
1431
1845
|
console.log(chalk.gray(` ${longExch} asks: $${longCheck.depthUsd.toFixed(0)} depth, ${longCheck.canFillFull ? "OK" : "NOT ENOUGH"}`));
|
|
1432
1846
|
console.log(chalk.gray(` ${shortExch} bids: $${shortCheck.depthUsd.toFixed(0)} depth, ${shortCheck.canFillFull ? "OK" : "NOT ENOUGH"}`));
|
|
@@ -1448,11 +1862,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1448
1862
|
const matchedBase = Math.min(longCheck.recommendedSize, shortCheck.recommendedSize);
|
|
1449
1863
|
const avgPrice = (longCheck.avgFillPrice + shortCheck.avgFillPrice) / 2;
|
|
1450
1864
|
const matchedUsdRaw = matchedBase * avgPrice;
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1865
|
+
// Each leg's minimum is the venue's own published one (market info), never a fixed table.
|
|
1866
|
+
const pairMin = pairOrderMinimums([
|
|
1867
|
+
{ exchange: longExch, ...(await venueOrderMinimums(longAdapter, sym, networkOf(program))) },
|
|
1868
|
+
{ exchange: shortExch, ...(await venueOrderMinimums(shortAdapter, sym, networkOf(program))) },
|
|
1869
|
+
]);
|
|
1870
|
+
const sizing = computeMatchedSize(matchedUsdRaw, avgPrice, longExch, shortExch, { longSizeDecimals: longDecimals, shortSizeDecimals: shortDecimals, minNotional: pairMin.minNotional });
|
|
1871
|
+
if (!sizing || Number(sizing.size) < pairMin.minSize) {
|
|
1872
|
+
const msg = `Order too small: $${matchedUsdRaw.toFixed(2)} is below the venue minimum (${pairMin.describe}). Request a larger size.`;
|
|
1873
|
+
reportExecFailure("SIZE_TOO_SMALL", msg, { error: msg, computedUsd: matchedUsdRaw, minimums: pairMin.legs });
|
|
1454
1874
|
if (isJson())
|
|
1455
|
-
return
|
|
1875
|
+
return;
|
|
1456
1876
|
console.log(chalk.red(`\n ${msg}`));
|
|
1457
1877
|
return;
|
|
1458
1878
|
}
|
|
@@ -1466,7 +1886,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1466
1886
|
// 4b. Dry-run: return simulated result without executing
|
|
1467
1887
|
if (dryRun) {
|
|
1468
1888
|
const result = {
|
|
1469
|
-
status: "filled", symbol: sym, dryRun: true,
|
|
1889
|
+
status: "filled", symbol: sym, dryRun: true, sent: false,
|
|
1890
|
+
leverage: opts.leverage ? { value: parseInt(opts.leverage), mode: opts.isolated ? "isolated" : "cross" } : null,
|
|
1470
1891
|
size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
|
|
1471
1892
|
long: { exchange: longExch, side: "buy", size: matchedSize, slippagePct: longCheck.slippagePct },
|
|
1472
1893
|
short: { exchange: shortExch, side: "sell", size: matchedSize, slippagePct: shortCheck.slippagePct },
|
|
@@ -1477,192 +1898,75 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1477
1898
|
return;
|
|
1478
1899
|
}
|
|
1479
1900
|
if (!isJson())
|
|
1480
|
-
console.log(chalk.gray(` Executing
|
|
1481
|
-
// 5.
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
if (
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
// Previously `.catch(() => [])` collapsed both into the same code path,
|
|
1495
|
-
// so a transient verify-side network blip would be misclassified as a
|
|
1496
|
-
// silent exchange reject and trigger an unnecessary rollback.
|
|
1497
|
-
const [longSettled, shortSettled] = await Promise.allSettled([
|
|
1498
|
-
longAdapter.getPositions(),
|
|
1499
|
-
shortAdapter.getPositions(),
|
|
1500
|
-
]);
|
|
1501
|
-
if (longSettled.status === "rejected" || shortSettled.status === "rejected") {
|
|
1502
|
-
const longErr = longSettled.status === "rejected" ? (longSettled.reason instanceof Error ? longSettled.reason.message : String(longSettled.reason)) : "ok";
|
|
1503
|
-
const shortErr = shortSettled.status === "rejected" ? (shortSettled.reason instanceof Error ? shortSettled.reason.message : String(shortSettled.reason)) : "ok";
|
|
1504
|
-
throw new Error(`arb-auto: cannot verify post-fill positions for ${sym} ` +
|
|
1505
|
-
`(long ${longExch}: ${longErr}; short ${shortExch}: ${shortErr}). ` +
|
|
1506
|
-
`Refusing to silently misclassify API failure as a silent reject.`);
|
|
1507
|
-
}
|
|
1508
|
-
const longPositions = longSettled.value;
|
|
1509
|
-
const shortPositions = shortSettled.value;
|
|
1510
|
-
const longPos = longPositions.find(p => p.symbol.toUpperCase().startsWith(sym));
|
|
1511
|
-
const shortPos = shortPositions.find(p => p.symbol.toUpperCase().startsWith(sym));
|
|
1512
|
-
if (!longPos && !shortPos) {
|
|
1513
|
-
// Both silently rejected
|
|
1514
|
-
const result = { status: "both_rejected", symbol: sym, size: matchedSize, message: "Both exchanges accepted order but no positions opened. Check min order size." };
|
|
1515
|
-
if (isJson())
|
|
1516
|
-
return printJson(jsonOk(result));
|
|
1517
|
-
console.log(chalk.red(` Both orders accepted but no positions opened. Min order size not met?\n`));
|
|
1518
|
-
return;
|
|
1519
|
-
}
|
|
1520
|
-
if (!longPos || !shortPos) {
|
|
1521
|
-
// One side silently rejected — close the other
|
|
1522
|
-
const openSide = longPos ? "long" : "short";
|
|
1523
|
-
const openAdapter = longPos ? longAdapter : shortAdapter;
|
|
1524
|
-
if (!isJson())
|
|
1525
|
-
console.log(chalk.yellow(` ${openSide} opened but other side silently rejected — closing ${openSide}...`));
|
|
1526
|
-
try {
|
|
1527
|
-
await openAdapter.marketOrder(sym, longPos ? "sell" : "buy", matchedSize);
|
|
1528
|
-
}
|
|
1529
|
-
catch { /* best effort */ }
|
|
1530
|
-
const result = { status: "silent_reject", symbol: sym, openedSide: openSide, closedSide: longPos ? "short" : "long", message: "One leg silently rejected (likely min order size). Rolled back." };
|
|
1531
|
-
if (isJson())
|
|
1532
|
-
return printJson(jsonOk(result));
|
|
1533
|
-
console.log(chalk.yellow(` Rolled back ${openSide} leg. Check exchange min order sizes.\n`));
|
|
1534
|
-
return;
|
|
1535
|
-
}
|
|
1536
|
-
// 7. Verify fill sizes match expected
|
|
1537
|
-
const expectedSize = parseFloat(matchedSize);
|
|
1538
|
-
const longFilled = Number(longPos.size);
|
|
1539
|
-
const shortFilled = Number(shortPos.size);
|
|
1540
|
-
const fillWarnings = [];
|
|
1541
|
-
if (longFilled < expectedSize * 0.5)
|
|
1542
|
-
fillWarnings.push(`Long fill ${longPos.size} < expected ${matchedSize} — partial fill?`);
|
|
1543
|
-
if (shortFilled < expectedSize * 0.5)
|
|
1544
|
-
fillWarnings.push(`Short fill ${shortPos.size} < expected ${matchedSize} — partial fill?`);
|
|
1545
|
-
// Size mismatch between legs
|
|
1546
|
-
const sizeDiffPct = Math.abs(longFilled - shortFilled) / Math.max(longFilled, shortFilled) * 100;
|
|
1547
|
-
if (sizeDiffPct > 5)
|
|
1548
|
-
fillWarnings.push(`Leg size mismatch: long ${longPos.size} vs short ${shortPos.size} (${sizeDiffPct.toFixed(1)}% diff)`);
|
|
1549
|
-
// 8. Reconcile — trim larger leg to match smaller
|
|
1550
|
-
let reconciled = false;
|
|
1551
|
-
let reconciledSize = "";
|
|
1552
|
-
if (opts.reconcile && sizeDiffPct > 1 && longFilled > 0 && shortFilled > 0) {
|
|
1553
|
-
const targetSize = Math.min(longFilled, shortFilled);
|
|
1554
|
-
const trimSide = longFilled > shortFilled ? "long" : "short";
|
|
1555
|
-
const trimAdapter = trimSide === "long" ? longAdapter : shortAdapter;
|
|
1556
|
-
const trimAction = trimSide === "long" ? "sell" : "buy"; // reduce long = sell, reduce short = buy
|
|
1557
|
-
const excess = Math.abs(longFilled - shortFilled);
|
|
1558
|
-
const trimDecimals = matchedSize.includes(".") ? matchedSize.split(".")[1].length : 0;
|
|
1559
|
-
const excessStr = excess.toFixed(trimDecimals);
|
|
1560
|
-
if (!isJson())
|
|
1561
|
-
console.log(chalk.cyan(` Reconciling: ${trimSide} has ${excess.toFixed(trimDecimals)} excess → trimming...`));
|
|
1562
|
-
try {
|
|
1563
|
-
if (opts.smart) {
|
|
1564
|
-
await smartOrder(trimAdapter, sym, trimAction, excessStr, { reduceOnly: true });
|
|
1565
|
-
}
|
|
1566
|
-
else {
|
|
1567
|
-
await trimAdapter.marketOrder(sym, trimAction, excessStr);
|
|
1568
|
-
}
|
|
1569
|
-
reconciled = true;
|
|
1570
|
-
reconciledSize = targetSize.toFixed(trimDecimals);
|
|
1571
|
-
fillWarnings.length = 0; // clear mismatch warnings
|
|
1572
|
-
if (!isJson())
|
|
1573
|
-
console.log(chalk.green(` Reconciled: both legs now ~${reconciledSize} units`));
|
|
1574
|
-
}
|
|
1575
|
-
catch (reconErr) {
|
|
1576
|
-
const msg = reconErr instanceof Error ? reconErr.message : String(reconErr);
|
|
1577
|
-
fillWarnings.push(`Reconcile failed (${trimSide} trim): ${msg}`);
|
|
1578
|
-
if (!isJson())
|
|
1579
|
-
console.log(chalk.yellow(` Reconcile failed: ${msg}`));
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
const fillStatus = fillWarnings.length > 0 ? "filled_unverified" : "filled";
|
|
1583
|
-
const finalSize = reconciled ? reconciledSize : matchedSize;
|
|
1901
|
+
console.log(chalk.gray(` Executing legs sequentially (long, then short sized to the verified long fill)...\n`));
|
|
1902
|
+
// 5. Sequential verified legs (design §12.3). The second leg is sized to the
|
|
1903
|
+
// first leg's VERIFIED fill, any shortfall is trimmed off the long
|
|
1904
|
+
// (reduce-only, verified — always, --reconcile is implied), a definite short
|
|
1905
|
+
// failure rolls the long back reduce-only, and an unknown outcome (incl. a
|
|
1906
|
+
// venue that accepted the order but the position did not move) quarantines
|
|
1907
|
+
// the symbol — nothing is retried.
|
|
1908
|
+
const res = await openPerpPairVerified({
|
|
1909
|
+
source: "arb-exec", sym, longAdapter, longExch, longSym: sym, shortAdapter, shortExch, shortSym: sym,
|
|
1910
|
+
size: matchedSize, smart: !!opts.smart, log: (m) => console.error(chalk.red(m)),
|
|
1911
|
+
});
|
|
1912
|
+
if (res.status === "opened") {
|
|
1913
|
+
const finalSize = res.hedged;
|
|
1914
|
+
const reconciled = res.trimmed !== "0";
|
|
1584
1915
|
logExecution({
|
|
1585
1916
|
type: "arb_entry", exchange: `${longExch}+${shortExch}`,
|
|
1586
1917
|
symbol: sym, side: "entry", size: finalSize,
|
|
1587
|
-
status:
|
|
1588
|
-
|
|
1918
|
+
status: "success", dryRun: false,
|
|
1919
|
+
// arbPairId / longExchange / shortExchange: the keys `arb status` provenance matches on.
|
|
1920
|
+
meta: { arbPairId: `${sym}:${longExch}:${shortExch}`, longExchange: longExch, shortExchange: shortExch, longExch, shortExch, matchedUsd, requested: matchedSize, longSlippage: longCheck.slippagePct, shortSlippage: shortCheck.slippagePct, reconciled, trimmed: res.trimmed },
|
|
1589
1921
|
});
|
|
1590
1922
|
const result = {
|
|
1591
|
-
status:
|
|
1592
|
-
long: { exchange: longExch, side: "buy", size:
|
|
1593
|
-
short: { exchange: shortExch, side: "sell", size:
|
|
1594
|
-
...(reconciled && { reconciled: true }),
|
|
1595
|
-
...(fillWarnings.length > 0 && { warnings: fillWarnings }),
|
|
1923
|
+
status: "filled", symbol: sym, size: finalSize, requestedSize: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
|
|
1924
|
+
long: { exchange: longExch, side: "buy", size: finalSize, slippagePct: longCheck.slippagePct },
|
|
1925
|
+
short: { exchange: shortExch, side: "sell", size: finalSize, slippagePct: shortCheck.slippagePct },
|
|
1926
|
+
...(reconciled && { reconciled: true, trimmed: res.trimmed }),
|
|
1596
1927
|
};
|
|
1597
1928
|
if (isJson())
|
|
1598
1929
|
return printJson(jsonOk(result));
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
else {
|
|
1605
|
-
console.log(chalk.green(` FILLED${reconciled ? " (reconciled)" : ""}: ${sym} ${finalSize} units (~$${matchedUsd.toFixed(2)})`));
|
|
1606
|
-
}
|
|
1607
|
-
console.log(chalk.green(` LONG ${longExch} ✓ (${reconciled ? reconciledSize : longPos.size})`));
|
|
1608
|
-
console.log(chalk.green(` SHORT ${shortExch} ✓ (${reconciled ? reconciledSize : shortPos.size})\n`));
|
|
1609
|
-
}
|
|
1610
|
-
else if (longOk !== shortOk) {
|
|
1611
|
-
// One leg failed — rollback
|
|
1612
|
-
const filledSide = longOk ? "long" : "short";
|
|
1613
|
-
const failedSide = longOk ? "short" : "long";
|
|
1614
|
-
const filledAdapter = longOk ? longAdapter : shortAdapter;
|
|
1615
|
-
const rollbackAction = longOk ? "sell" : "buy";
|
|
1616
|
-
const failedErr = longOk
|
|
1617
|
-
? shortResult.reason
|
|
1618
|
-
: longResult.reason;
|
|
1619
|
-
if (!isJson())
|
|
1620
|
-
console.log(chalk.yellow(` PARTIAL: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
|
|
1621
|
-
let rollbackOk = false;
|
|
1622
|
-
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1623
|
-
try {
|
|
1624
|
-
if (opts.smart) {
|
|
1625
|
-
await smartOrder(filledAdapter, sym, rollbackAction, matchedSize, { reduceOnly: true });
|
|
1626
|
-
}
|
|
1627
|
-
else {
|
|
1628
|
-
await filledAdapter.marketOrder(sym, rollbackAction, matchedSize);
|
|
1629
|
-
}
|
|
1630
|
-
rollbackOk = true;
|
|
1631
|
-
break;
|
|
1632
|
-
}
|
|
1633
|
-
catch { /* retry */ }
|
|
1634
|
-
}
|
|
1930
|
+
console.log(chalk.green(` FILLED${reconciled ? ` (long trimmed by ${res.trimmed})` : ""}: ${sym} ${finalSize} units (requested ${matchedSize}, ~$${matchedUsd.toFixed(2)})`));
|
|
1931
|
+
console.log(chalk.green(` LONG ${longExch} ✓ (${finalSize}, verified)`));
|
|
1932
|
+
console.log(chalk.green(` SHORT ${shortExch} ✓ (${finalSize}, verified)\n`));
|
|
1933
|
+
}
|
|
1934
|
+
else if (res.status === "not_opened") {
|
|
1635
1935
|
logExecution({
|
|
1636
1936
|
type: "arb_entry", exchange: `${longExch}+${shortExch}`,
|
|
1637
1937
|
symbol: sym, side: "entry", size: matchedSize,
|
|
1638
1938
|
status: "failed", dryRun: false,
|
|
1639
|
-
error:
|
|
1939
|
+
error: `${res.reason}${res.rolledBack !== null ? ` — long ${res.rolledBack} rolled back (reduce-only, verified)` : ""}`,
|
|
1640
1940
|
});
|
|
1641
1941
|
const result = {
|
|
1642
|
-
status: "
|
|
1643
|
-
|
|
1644
|
-
rollback: rollbackOk ? "success" : "FAILED — MANUAL CLOSE REQUIRED",
|
|
1942
|
+
status: res.rolledBack !== null ? "rolled_back" : "not_filled", symbol: sym,
|
|
1943
|
+
error: res.reason, rolledBack: res.rolledBack, openExposure: null,
|
|
1645
1944
|
};
|
|
1945
|
+
reportExecFailure("ORDER_REJECTED", `arb exec ${sym}: entry failed (${result.status}) — ${res.reason}`, result);
|
|
1646
1946
|
if (isJson())
|
|
1647
|
-
return
|
|
1648
|
-
console.log(
|
|
1649
|
-
|
|
1650
|
-
|
|
1947
|
+
return;
|
|
1948
|
+
console.log(chalk.red(` Entry failed: ${res.reason}`));
|
|
1949
|
+
console.log(res.rolledBack !== null
|
|
1950
|
+
? chalk.yellow(` Rolled back long ${res.rolledBack} on ${longExch} (reduce-only, verified flat) — no open exposure from this trade.\n`)
|
|
1951
|
+
: chalk.yellow(` Nothing was opened.\n`));
|
|
1651
1952
|
}
|
|
1652
1953
|
else {
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1954
|
+
logExecution({
|
|
1955
|
+
type: "arb_entry", exchange: `${longExch}+${shortExch}`,
|
|
1956
|
+
symbol: sym, side: "entry", size: matchedSize,
|
|
1957
|
+
status: "failed", dryRun: false,
|
|
1958
|
+
error: `QUARANTINED: ${res.reason}`,
|
|
1959
|
+
meta: { exposure: res.exposure },
|
|
1960
|
+
});
|
|
1656
1961
|
const result = {
|
|
1657
|
-
status: "
|
|
1658
|
-
|
|
1659
|
-
shortError: shortErr instanceof Error ? shortErr.message : String(shortErr),
|
|
1962
|
+
status: "quarantined", symbol: sym, error: res.reason, openExposure: res.exposure,
|
|
1963
|
+
remediation: `Check ${sym} on ${longExch} and ${shortExch}; close or re-hedge by hand, then: perp strategy quarantine-clear ${sym}`,
|
|
1660
1964
|
};
|
|
1965
|
+
reportExecFailure("ORDER_PARTIAL", `arb exec ${sym}: QUARANTINED — ${res.reason}`, result);
|
|
1661
1966
|
if (isJson())
|
|
1662
|
-
return
|
|
1663
|
-
console.log(chalk.red(`
|
|
1664
|
-
console.log(chalk.red(`
|
|
1665
|
-
console.log(chalk.red(` SHORT: ${result.shortError}\n`));
|
|
1967
|
+
return;
|
|
1968
|
+
console.log(chalk.red.bold(` QUARANTINED ${sym}: ${res.reason}`));
|
|
1969
|
+
console.log(chalk.red.bold(` Possible open exposure: ${res.exposure} — check manually!\n`));
|
|
1666
1970
|
}
|
|
1667
1971
|
});
|
|
1668
1972
|
// ── spot-exec logic (called from exec when longExch starts with "spot:") ──
|
|
@@ -1670,178 +1974,121 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1670
1974
|
const sym = symbol.toUpperCase();
|
|
1671
1975
|
const sizeUsd = parseFloat(sizeUsdStr);
|
|
1672
1976
|
const maxSlippage = parseFloat(opts.maxSlippage);
|
|
1673
|
-
const dryRun =
|
|
1977
|
+
const dryRun = isDryRun(program);
|
|
1674
1978
|
// Resolve exchange aliases
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
//
|
|
1679
|
-
|
|
1680
|
-
|
|
1979
|
+
spotExch = resolveVenueId(spotExch);
|
|
1980
|
+
perpExch = resolveVenueId(perpExch);
|
|
1981
|
+
// Validate spot exchange: bot/arb spot scope (§12.4 #3) — throws CAPABILITY_UNSUPPORTED
|
|
1982
|
+
// for venues without spot or outside the scope (pacifica shared margin, arcus swaps).
|
|
1983
|
+
const spotDef = requireBotSpotVenue(spotExch.replace(/-spot$/, ""));
|
|
1984
|
+
spotExch = spotDef.id;
|
|
1985
|
+
perpExch = tryResolveVenueId(perpExch) ?? perpExch;
|
|
1986
|
+
if (!getVenue(perpExch).roles.includes("trading")) {
|
|
1987
|
+
throw unsupported(perpExch, "trading", "no trading API — it cannot be the perp leg", `Use a trading venue: ${listVenuesWithRole("trading").map(v => v.id).join(", ")}`);
|
|
1988
|
+
}
|
|
1989
|
+
assertFundingArbVenue(perpExch);
|
|
1990
|
+
const spotAdapter = await requireSpotAdapter(spotDef.id, await getAdapterForExchange(spotDef.id));
|
|
1991
|
+
const perpAdapter = await getAdapterForExchange(perpExch);
|
|
1992
|
+
// Order of operations (C3 / M6): every validation (balances, orderbooks, price
|
|
1993
|
+
// identity, depth, sizing) runs BEFORE anything moves; dry-run returns before the
|
|
1994
|
+
// leverage change and any transfer; after a transfer, every abort transfers back.
|
|
1995
|
+
const lev = opts.leverage ? parseInt(opts.leverage) : null;
|
|
1996
|
+
const levMode = opts.isolated ? "isolated" : "cross";
|
|
1997
|
+
const sameVenue = spotDef.id === (tryResolveVenueId(perpExch) ?? perpExch);
|
|
1998
|
+
const fail = (msg, extra = {}) => {
|
|
1999
|
+
reportExecFailure("ORDER_REJECTED", msg, { error: msg, ...extra });
|
|
1681
2000
|
if (isJson())
|
|
1682
|
-
return
|
|
2001
|
+
return;
|
|
1683
2002
|
console.log(chalk.red(` ${msg}`));
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
const hlAdapter = await getAdapterForExchange("hyperliquid");
|
|
1692
|
-
const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
|
|
1693
|
-
await hlSpot.init();
|
|
1694
|
-
spotAdapter = hlSpot;
|
|
2003
|
+
};
|
|
2004
|
+
// Global quarantine (design §12.3): checked before anything is read or moved.
|
|
2005
|
+
const quarantined = quarantineEntryFor(sym, (m) => console.error(m));
|
|
2006
|
+
if (quarantined !== null) {
|
|
2007
|
+
throw new PerpError("RISK_VIOLATION", `${sym} is quarantined (${quarantined.strategy}: ${quarantined.reason})`, {
|
|
2008
|
+
remediation: `${quarantined.remediation} — then: perp strategy quarantine-clear ${sym}`,
|
|
2009
|
+
});
|
|
1695
2010
|
}
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
2011
|
+
// 1. Balance validation (errors propagate — no "continue anyway").
|
|
2012
|
+
// Shared spot/perp (HL unified): perp available is the spot purchasing power.
|
|
2013
|
+
// Separate: spot quote available + what can be transferred from the spot venue's perp account.
|
|
2014
|
+
const spotVenuePerp = await getAdapterForExchange(spotDef.id);
|
|
2015
|
+
const isShared = spotSharesPerpMargin(spotDef, spotVenuePerp);
|
|
2016
|
+
const spotVenuePerpAvail = Number((await spotVenuePerp.getBalance()).available);
|
|
2017
|
+
const perpAvail = sameVenue ? spotVenuePerpAvail : Number((await perpAdapter.getBalance()).available);
|
|
2018
|
+
if (!Number.isFinite(spotVenuePerpAvail) || !Number.isFinite(perpAvail)) {
|
|
2019
|
+
return fail(`Balance read returned a non-numeric available balance (spot venue ${spotVenuePerpAvail}, perp ${perpAvail})`);
|
|
1701
2020
|
}
|
|
1702
|
-
const
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
catch (e) {
|
|
1713
|
-
const msg = `Failed to set leverage: ${e instanceof Error ? e.message : e}`;
|
|
1714
|
-
if (isJson())
|
|
1715
|
-
return printJson(jsonOk({ error: msg }));
|
|
1716
|
-
console.log(chalk.red(` ${msg}`));
|
|
1717
|
-
return;
|
|
1718
|
-
}
|
|
2021
|
+
const quoteRow = isShared ? null : findSpotQuoteBalance(spotDef, spotNetworkOf(spotAdapter), await readSpotBalancesFresh(spotAdapter));
|
|
2022
|
+
const spotQuoteHave = isShared ? spotVenuePerpAvail : (quoteRow === null ? 0 : Number(quoteRow.available));
|
|
2023
|
+
const spotTarget = Math.ceil(sizeUsd * 1.1);
|
|
2024
|
+
// Separate model: only the shortfall is transferred from the spot venue's perp account.
|
|
2025
|
+
const transferWant = isShared ? 0 : Math.max(0, Math.ceil(spotTarget - spotQuoteHave));
|
|
2026
|
+
// Perp leg margin: notional / the leverage the venue applies to the market × ARB_MARGIN_BUFFER
|
|
2027
|
+
// (requiredArbMargin — the same rule as funding-arb; unknown leverage → 1x). Spot leg: 100%.
|
|
2028
|
+
let perpLeverage;
|
|
2029
|
+
try {
|
|
2030
|
+
perpLeverage = await marketLeverageOf(perpAdapter, sym);
|
|
1719
2031
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
const hlSpot = spotAdapter;
|
|
1723
|
-
const transferAmt = Math.ceil(sizeUsd * 1.1);
|
|
1724
|
-
try {
|
|
1725
|
-
const xferResult = await hlSpot.transferUsdcToSpot(transferAmt);
|
|
1726
|
-
if (xferResult?.status === "err" && xferResult.response?.includes("unified")) {
|
|
1727
|
-
if (!isJson())
|
|
1728
|
-
console.log(chalk.gray(` Unified account — no USDC transfer needed`));
|
|
1729
|
-
}
|
|
1730
|
-
else if (xferResult?.status !== "err") {
|
|
1731
|
-
if (!isJson())
|
|
1732
|
-
console.log(chalk.gray(` Transferred $${transferAmt} USDC perp→spot`));
|
|
1733
|
-
await new Promise(r => setTimeout(r, 500));
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1736
|
-
catch {
|
|
1737
|
-
// Non-critical — may be unified account or already have balance
|
|
1738
|
-
}
|
|
2032
|
+
catch (e) {
|
|
2033
|
+
return fail(`Could not read the ${sym} leverage on ${perpExch}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1739
2034
|
}
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
const
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
if (!isJson())
|
|
1747
|
-
console.log(chalk.gray(` Transferred $${transferAmt} USDC perp→spot on Lighter`));
|
|
1748
|
-
await new Promise(r => setTimeout(r, 300));
|
|
1749
|
-
}
|
|
1750
|
-
catch (e) {
|
|
1751
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1752
|
-
if (!isJson())
|
|
1753
|
-
console.log(chalk.yellow(` USDC transfer warning: ${msg}`));
|
|
1754
|
-
// Continue — spot order may still work if spot balance already exists
|
|
2035
|
+
const perpMarginNeed = requiredArbMargin(sizeUsd, perpLeverage);
|
|
2036
|
+
const marginText = `~$${perpMarginNeed.toFixed(2)} perp margin (${perpLeverage !== null ? `${perpLeverage}x` : "leverage unknown → 1x"}, ×${ARB_MARGIN_BUFFER} buffer)`;
|
|
2037
|
+
if (isShared) {
|
|
2038
|
+
const need = sameVenue ? sizeUsd + perpMarginNeed : sizeUsd;
|
|
2039
|
+
if (spotQuoteHave < need) {
|
|
2040
|
+
return fail(`Insufficient balance: have $${spotQuoteHave.toFixed(2)} on ${spotDef.displayName}, need ~$${need.toFixed(2)} ($${sizeUsd} spot${sameVenue ? ` + ${marginText}` : ""})`, { available: spotQuoteHave, required: need, perpLeverage });
|
|
1755
2041
|
}
|
|
1756
2042
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
let spotUsdcAvail;
|
|
1762
|
-
if (isUnified) {
|
|
1763
|
-
// Unified account: perp adapter's available balance = spot purchasing power
|
|
1764
|
-
const hlBalance = await perpAdapter.getBalance();
|
|
1765
|
-
spotUsdcAvail = Number(hlBalance?.available ?? 0);
|
|
1766
|
-
}
|
|
1767
|
-
else {
|
|
1768
|
-
const spotBalances = await spotAdapter.getSpotBalances();
|
|
1769
|
-
const spotUsdc = spotBalances.find(b => b.token === "USDC_SPOT" || b.token === "USDC");
|
|
1770
|
-
spotUsdcAvail = Number(spotUsdc?.available ?? 0);
|
|
1771
|
-
}
|
|
1772
|
-
// Check perp balance (for perp leg margin)
|
|
1773
|
-
const perpBalance = await perpAdapter.getBalance();
|
|
1774
|
-
const perpAvail = Number(perpBalance?.available ?? 0);
|
|
1775
|
-
// For unified accounts (same exchange), total needed = spotSize + perpMargin from same pool
|
|
1776
|
-
const totalNeeded = spotExch === perpExch ? sizeUsd * 1.5 : sizeUsd;
|
|
1777
|
-
const checkBalance = spotExch === perpExch ? spotUsdcAvail : spotUsdcAvail;
|
|
1778
|
-
if (checkBalance < totalNeeded * 0.9) {
|
|
1779
|
-
const msg = `Insufficient balance: have $${spotUsdcAvail.toFixed(2)}, need ~$${totalNeeded.toFixed(0)} (spot + perp margin)`;
|
|
1780
|
-
if (isJson())
|
|
1781
|
-
return printJson(jsonOk({ error: msg, spotUsdcAvail, required: totalNeeded }));
|
|
1782
|
-
console.log(chalk.red(` ${msg}`));
|
|
1783
|
-
return;
|
|
1784
|
-
}
|
|
1785
|
-
if (spotExch !== perpExch && perpAvail < sizeUsd * 0.5) {
|
|
1786
|
-
const msg = `Insufficient perp balance: have $${perpAvail.toFixed(2)} available on ${perpExch}, need margin for ~$${sizeUsd}`;
|
|
1787
|
-
if (isJson())
|
|
1788
|
-
return printJson(jsonOk({ error: msg, perpAvail, required: sizeUsd }));
|
|
1789
|
-
console.log(chalk.red(` ${msg}`));
|
|
1790
|
-
return;
|
|
2043
|
+
else {
|
|
2044
|
+
const fromSpotVenuePerp = transferWant + (sameVenue ? perpMarginNeed : 0);
|
|
2045
|
+
if (spotVenuePerpAvail < fromSpotVenuePerp) {
|
|
2046
|
+
return fail(`Insufficient balance on ${spotDef.displayName}: spot ${quoteRow?.token ?? "quote"} $${spotQuoteHave.toFixed(2)} + transferable perp $${spotVenuePerpAvail.toFixed(2)}; need $${spotTarget} in spot${sameVenue ? ` + ${marginText}` : ""}`, { spotQuoteAvail: spotQuoteHave, perpAvail: spotVenuePerpAvail, required: fromSpotVenuePerp, perpLeverage });
|
|
1791
2047
|
}
|
|
1792
2048
|
}
|
|
1793
|
-
|
|
1794
|
-
|
|
2049
|
+
if (!sameVenue && perpAvail < perpMarginNeed) {
|
|
2050
|
+
return fail(`Insufficient perp balance: have $${perpAvail.toFixed(2)} available on ${perpExch}, need ${marginText} for $${sizeUsd}`, { perpAvail, required: perpMarginNeed, perpLeverage });
|
|
1795
2051
|
}
|
|
1796
|
-
//
|
|
1797
|
-
const spotSymbol =
|
|
2052
|
+
// 2. Fetch orderbooks simultaneously
|
|
2053
|
+
const spotSymbol = spotPairSymbol(spotDef, spotNetworkOf(spotAdapter), sym);
|
|
1798
2054
|
if (!isJson())
|
|
1799
2055
|
console.log(chalk.gray(` Checking orderbook depth (spot ${spotExch} + perp ${perpExch})...`));
|
|
1800
2056
|
const [spotBook, perpBook] = await Promise.all([
|
|
1801
2057
|
spotAdapter.getSpotOrderbook(spotSymbol),
|
|
1802
2058
|
perpAdapter.getOrderbook(sym),
|
|
1803
2059
|
]);
|
|
1804
|
-
//
|
|
1805
|
-
// Prevents wrong-token trades if index mapping is incorrect
|
|
2060
|
+
// 2a. Price cross-validation: spot and perp must be the same underlying
|
|
1806
2061
|
const spotMid = spotBook.bids.length > 0 && spotBook.asks.length > 0
|
|
1807
2062
|
? (Number(spotBook.bids[0][0]) + Number(spotBook.asks[0][0])) / 2 : 0;
|
|
1808
2063
|
const perpMid = perpBook.bids.length > 0 && perpBook.asks.length > 0
|
|
1809
2064
|
? (Number(perpBook.bids[0][0]) + Number(perpBook.asks[0][0])) / 2 : 0;
|
|
1810
|
-
if (spotMid
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
return;
|
|
1818
|
-
}
|
|
2065
|
+
if (spotMid === 0)
|
|
2066
|
+
return fail(`Empty spot orderbook for ${sym} — cannot verify token identity`);
|
|
2067
|
+
if (perpMid === 0)
|
|
2068
|
+
return fail(`Empty perp orderbook for ${sym} on ${perpExch} — cannot verify token identity`);
|
|
2069
|
+
const priceDeviation = Math.abs(spotMid - perpMid) / perpMid * 100;
|
|
2070
|
+
if (priceDeviation > MAX_PRICE_DEVIATION_PCT) {
|
|
2071
|
+
return fail(`Price mismatch: spot mid $${spotMid.toFixed(4)} vs perp mid $${perpMid.toFixed(4)} (${priceDeviation.toFixed(1)}% deviation). Possible wrong token index — aborting.`, { spotMid, perpMid, deviationPct: priceDeviation });
|
|
1819
2072
|
}
|
|
1820
|
-
|
|
1821
|
-
const msg = `Empty spot orderbook for ${sym} — cannot verify token identity`;
|
|
1822
|
-
if (isJson())
|
|
1823
|
-
return printJson(jsonOk({ error: msg }));
|
|
1824
|
-
console.log(chalk.red(`\n ${msg}`));
|
|
1825
|
-
return;
|
|
1826
|
-
}
|
|
1827
|
-
// 3b. Validate depth
|
|
2073
|
+
// 2b. Validate depth
|
|
1828
2074
|
const spotCheck = computeExecutableSize(spotBook.asks, sizeUsd, maxSlippage);
|
|
1829
2075
|
const perpCheck = computeExecutableSize(perpBook.bids, sizeUsd, maxSlippage);
|
|
1830
2076
|
if (!spotCheck.canFillFull || !perpCheck.canFillFull) {
|
|
1831
2077
|
const fillableUsd = Math.min(spotCheck.maxSize * spotCheck.avgFillPrice, perpCheck.maxSize * perpCheck.avgFillPrice);
|
|
2078
|
+
reportExecFailure("SIZE_TOO_LARGE", `Insufficient orderbook depth for $${sizeUsd} (fillable ~$${(Math.round(fillableUsd * 100) / 100)})`, {
|
|
2079
|
+
error: "Insufficient orderbook depth",
|
|
2080
|
+
requestedUsd: sizeUsd, fillableUsd: Math.round(fillableUsd * 100) / 100,
|
|
2081
|
+
spot: { exchange: spotExch, depthUsd: spotCheck.depthUsd, canFill: spotCheck.canFillFull },
|
|
2082
|
+
perp: { exchange: perpExch, depthUsd: perpCheck.depthUsd, canFill: perpCheck.canFillFull },
|
|
2083
|
+
});
|
|
1832
2084
|
if (isJson())
|
|
1833
|
-
return
|
|
1834
|
-
error: "Insufficient orderbook depth",
|
|
1835
|
-
requestedUsd: sizeUsd, fillableUsd: Math.round(fillableUsd * 100) / 100,
|
|
1836
|
-
spot: { exchange: spotExch, depthUsd: spotCheck.depthUsd, canFill: spotCheck.canFillFull },
|
|
1837
|
-
perp: { exchange: perpExch, depthUsd: perpCheck.depthUsd, canFill: perpCheck.canFillFull },
|
|
1838
|
-
}));
|
|
2085
|
+
return;
|
|
1839
2086
|
console.log(chalk.red(`\n Insufficient depth for $${sizeUsd}:`));
|
|
1840
2087
|
console.log(chalk.gray(` ${spotExch} spot asks: $${spotCheck.depthUsd.toFixed(0)}`));
|
|
1841
2088
|
console.log(chalk.gray(` ${perpExch} perp bids: $${perpCheck.depthUsd.toFixed(0)}`));
|
|
1842
2089
|
return;
|
|
1843
2090
|
}
|
|
1844
|
-
//
|
|
2091
|
+
// 3. Compute matched size (with min order value enforcement)
|
|
1845
2092
|
const avgPrice = (spotCheck.avgFillPrice + perpCheck.avgFillPrice) / 2;
|
|
1846
2093
|
const inferDecimals = (levels) => {
|
|
1847
2094
|
for (const [, s] of levels) {
|
|
@@ -1854,146 +2101,229 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1854
2101
|
const spotDecimals = inferDecimals(spotBook.asks);
|
|
1855
2102
|
const matchedBase = Math.min(spotCheck.recommendedSize, perpCheck.recommendedSize);
|
|
1856
2103
|
const matchedUsdRaw = matchedBase * avgPrice;
|
|
1857
|
-
//
|
|
1858
|
-
// (e.g. HL $10 min) — rounds UP if floor would drop below minimum
|
|
2104
|
+
// computeSpotPerpMatchedSize enforces exchange min order values (e.g. HL $10 min)
|
|
1859
2105
|
const sizing = computeSpotPerpMatchedSize(matchedUsdRaw, avgPrice, spotExch, perpExch, spotDecimals);
|
|
1860
|
-
if (!sizing)
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
const matchedSize = sizing.size;
|
|
2106
|
+
if (!sizing)
|
|
2107
|
+
return fail(`Order too small: $${matchedUsdRaw.toFixed(2)} below exchange minimum. Request a larger size.`, { computedUsd: matchedUsdRaw });
|
|
2108
|
+
// Lot steps from the venue market info (unknown → throws, Rule #2): both legs are sent on
|
|
2109
|
+
// the lot grid, the hedge is floored to the perp lot and fills are judged with half-lot tolerance.
|
|
2110
|
+
const perpStep = await lotStepOf(perpAdapter, sym);
|
|
2111
|
+
const spotStep = await spotLotStepOf(spotAdapter, spotSymbol);
|
|
2112
|
+
const matchedSize = floorSize(floorSize(sizing.size, perpStep), spotStep);
|
|
1868
2113
|
const matchedUsd = sizing.notional;
|
|
2114
|
+
const sizeDec = sizeDecimalsOf(matchedSize);
|
|
1869
2115
|
if (!isJson()) {
|
|
1870
2116
|
console.log(chalk.cyan(`\n Spot+Perp Arb: ${sym}`));
|
|
1871
2117
|
console.log(chalk.gray(` SPOT ${spotExch}: buy ${matchedSize} (~$${(matchedBase * spotCheck.avgFillPrice).toFixed(2)})`));
|
|
1872
2118
|
console.log(chalk.gray(` PERP ${perpExch}: sell ${matchedSize} (~$${(matchedBase * perpCheck.avgFillPrice).toFixed(2)})`));
|
|
1873
|
-
console.log(chalk.gray(` Executing both legs simultaneously...\n`));
|
|
1874
2119
|
}
|
|
1875
|
-
//
|
|
2120
|
+
// 4. Dry-run: report the plan; nothing is changed (no leverage, no transfer, no order).
|
|
1876
2121
|
if (dryRun) {
|
|
1877
2122
|
const result = {
|
|
1878
|
-
status: "
|
|
2123
|
+
status: "simulated", mode: "spot-perp", symbol: sym, dryRun: true, sent: false,
|
|
1879
2124
|
size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
|
|
2125
|
+
transfer: transferWant > 0 ? { from: "perp", to: "spot", venue: spotDef.id, amount: transferWant } : null,
|
|
2126
|
+
leverage: lev === null ? null : { exchange: perpExch, leverage: lev, mode: levMode },
|
|
1880
2127
|
spot: { exchange: spotExch, side: "buy", size: matchedSize },
|
|
1881
2128
|
perp: { exchange: perpExch, side: "sell", size: matchedSize },
|
|
1882
2129
|
};
|
|
1883
2130
|
if (isJson())
|
|
1884
2131
|
return printJson(jsonOk(result));
|
|
2132
|
+
if (transferWant > 0)
|
|
2133
|
+
console.log(chalk.cyan(` [DRY-RUN] Would transfer $${transferWant} perp→spot on ${spotDef.displayName}`));
|
|
2134
|
+
if (lev !== null)
|
|
2135
|
+
console.log(chalk.cyan(` [DRY-RUN] Would set perp leverage ${lev}x ${levMode} on ${perpExch}`));
|
|
1885
2136
|
console.log(chalk.cyan(` [DRY-RUN] Would execute: spot buy ${matchedSize} on ${spotExch} + perp sell ${matchedSize} on ${perpExch}`));
|
|
1886
2137
|
return;
|
|
1887
2138
|
}
|
|
1888
|
-
//
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2139
|
+
// 5. Set perp leverage if requested (before any funds move)
|
|
2140
|
+
if (lev !== null) {
|
|
2141
|
+
if (!isJson())
|
|
2142
|
+
console.log(chalk.gray(` Setting perp leverage ${lev}x ${levMode} on ${perpExch}...`));
|
|
2143
|
+
try {
|
|
2144
|
+
await perpAdapter.setLeverage(sym, lev, levMode);
|
|
2145
|
+
}
|
|
2146
|
+
catch (e) {
|
|
2147
|
+
return fail(`Failed to set leverage: ${e instanceof Error ? e.message : e}`);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
// 6. Transfer the spot quote shortfall perp → spot (separate model only). A failed transfer stops here.
|
|
2151
|
+
let transferred = 0;
|
|
2152
|
+
if (transferWant > 0) {
|
|
2153
|
+
if (await transferQuoteToSpot(spotAdapter, transferWant)) {
|
|
2154
|
+
transferred = transferWant;
|
|
2155
|
+
if (!isJson())
|
|
2156
|
+
console.log(chalk.gray(` Transferred $${transferWant} perp→spot on ${spotDef.displayName}`));
|
|
2157
|
+
await new Promise(r => setTimeout(r, 500));
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
else if (!isJson()) {
|
|
2161
|
+
console.log(chalk.gray(isShared
|
|
2162
|
+
? ` Shared spot/perp balance on ${spotDef.displayName} — no transfer needed`
|
|
2163
|
+
: ` Spot ${quoteRow?.token ?? "quote"} balance already covers the order — no transfer`));
|
|
2164
|
+
}
|
|
2165
|
+
/** Abort path after a transfer: move it back and report the outcome (never silent). */
|
|
2166
|
+
const returnTransfer = async (warnings) => {
|
|
2167
|
+
if (transferred <= 0)
|
|
2168
|
+
return;
|
|
2169
|
+
try {
|
|
2170
|
+
await transferQuoteToPerp(spotAdapter, transferred);
|
|
2171
|
+
if (!isJson())
|
|
2172
|
+
console.log(chalk.gray(` Returned $${transferred} spot→perp on ${spotDef.displayName}`));
|
|
2173
|
+
}
|
|
2174
|
+
catch (e) {
|
|
2175
|
+
const w = `Transfer back of $${transferred} failed (funds remain in ${spotDef.id} spot): ${e instanceof Error ? e.message : String(e)}`;
|
|
2176
|
+
warnings.push(w);
|
|
2177
|
+
if (!isJson())
|
|
2178
|
+
console.log(chalk.yellow(` ⚠ ${w}`));
|
|
1901
2179
|
}
|
|
1902
|
-
return r;
|
|
1903
2180
|
};
|
|
2181
|
+
// 7. Execute both legs. Same exchange shares a nonce → sequential; else parallel.
|
|
2182
|
+
if (!isJson())
|
|
2183
|
+
console.log(chalk.gray(` Executing ${sameVenue ? "sequentially" : "both legs simultaneously"}...\n`));
|
|
2184
|
+
// The perp leg goes through placePerpVerified (design §12.3): a fill is the measured
|
|
2185
|
+
// position delta; an unknown outcome is ORDER_PENDING (quarantine, never roll back).
|
|
1904
2186
|
let spotResult;
|
|
1905
2187
|
let perpResult;
|
|
1906
|
-
if (
|
|
1907
|
-
// Sequential: spot first
|
|
1908
|
-
spotResult = await spotAdapter
|
|
2188
|
+
if (sameVenue) {
|
|
2189
|
+
// Sequential: spot first; the perp leg is sized to what the spot leg filled.
|
|
2190
|
+
spotResult = await spotMarketFill(spotAdapter, spotSymbol, "buy", matchedSize, spotStep)
|
|
1909
2191
|
.then(r => ({ status: "fulfilled", value: r }))
|
|
1910
2192
|
.catch(e => ({ status: "rejected", reason: e }));
|
|
1911
2193
|
if (spotResult.status === "fulfilled") {
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2194
|
+
const perpSize = floorToStep(spotResult.value.filledSize, perpStep);
|
|
2195
|
+
perpResult = Number(perpSize) > 0
|
|
2196
|
+
? await placePerpVerified(perpAdapter, perpExch, sym, "sell", perpSize, { stepSize: perpStep })
|
|
2197
|
+
.then(r => ({ status: "fulfilled", value: r }))
|
|
2198
|
+
.catch(e => ({ status: "rejected", reason: e }))
|
|
2199
|
+
: { status: "rejected", reason: new PerpError("ORDER_REJECTED", `Skipped: spot filled only dust (${spotResult.value.filledSize})`) };
|
|
1916
2200
|
}
|
|
1917
2201
|
else {
|
|
1918
|
-
perpResult = { status: "rejected", reason: new
|
|
2202
|
+
perpResult = { status: "rejected", reason: new PerpError("ORDER_REJECTED", "Skipped: spot failed") };
|
|
1919
2203
|
}
|
|
1920
2204
|
}
|
|
1921
2205
|
else {
|
|
1922
|
-
// Parallel: different exchanges, no nonce conflict
|
|
1923
2206
|
[spotResult, perpResult] = await Promise.allSettled([
|
|
1924
|
-
spotAdapter
|
|
1925
|
-
perpAdapter
|
|
2207
|
+
spotMarketFill(spotAdapter, spotSymbol, "buy", matchedSize, spotStep),
|
|
2208
|
+
placePerpVerified(perpAdapter, perpExch, sym, "sell", matchedSize, { stepSize: perpStep }),
|
|
1926
2209
|
]);
|
|
1927
2210
|
}
|
|
1928
|
-
const
|
|
2211
|
+
const errMsg = (e) => e instanceof Error ? e.message : String(e);
|
|
2212
|
+
const spotPending = spotResult.status === "rejected" && spotResult.reason instanceof PerpError && spotResult.reason.structured.code === "ORDER_PENDING";
|
|
2213
|
+
const spotFilled = spotResult.status === "fulfilled" ? spotResult.value.filledSize : 0;
|
|
2214
|
+
const hedgeSize = floorToStep(spotFilled, perpStep);
|
|
1929
2215
|
const perpOk = perpResult.status === "fulfilled";
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2216
|
+
const warnings = [];
|
|
2217
|
+
/** Set when a perp outcome is unknown: quarantine and stop (no spot sell-back, no transfer back). */
|
|
2218
|
+
let perpUnknown = perpResult.status === "rejected" && isOutcomeUnknown(perpResult.reason)
|
|
2219
|
+
? `perp sell on ${perpExch} outcome unknown: ${errMsg(perpResult.reason)}` : null;
|
|
2220
|
+
// 7a. Perp leg open (verified) while the spot leg filled less (failed / pending / partial):
|
|
2221
|
+
// reduce the perp short to the spot fill (reduce-only, verified) — never a naked short.
|
|
2222
|
+
let shortOpen = perpResult.status === "fulfilled" ? floorToStep(perpResult.value.filledSize, perpStep) : "0";
|
|
2223
|
+
if (perpOk && Number(hedgeSize) < Number(shortOpen)) {
|
|
2224
|
+
const excess = floorToStep(Number(shortOpen) - Number(hedgeSize), perpStep);
|
|
2225
|
+
const why = spotResult.status === "rejected"
|
|
2226
|
+
? `spot leg ${spotPending ? "PENDING" : "failed"}: ${errMsg(spotResult.reason)}`
|
|
2227
|
+
: `spot leg filled ${spotFilled} of ${matchedSize}`;
|
|
2228
|
+
if (!isJson())
|
|
2229
|
+
console.log(chalk.yellow(` ${why} — reducing perp short by ${excess} to match`));
|
|
2230
|
+
// One verified reduce-only close (+ one residual close inside closePerpVerified); never resubmitted.
|
|
1935
2231
|
try {
|
|
1936
|
-
await
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(baseToken));
|
|
1941
|
-
const spotTokenAmt = Number(spotTokenBal?.total ?? 0);
|
|
1942
|
-
const expectedSize = parseFloat(matchedSize);
|
|
1943
|
-
if (spotTokenAmt < expectedSize * 0.5) {
|
|
1944
|
-
fillVerified = false;
|
|
1945
|
-
verifyWarnings.push(`Spot buy may not have filled: expected ~${matchedSize} ${baseToken}, found ${spotTokenAmt.toFixed(6)}`);
|
|
1946
|
-
}
|
|
1947
|
-
// Verify perp: check short position exists
|
|
1948
|
-
const perpPositions = await perpAdapter.getPositions();
|
|
1949
|
-
const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
|
|
1950
|
-
if (!perpPos) {
|
|
1951
|
-
fillVerified = false;
|
|
1952
|
-
verifyWarnings.push(`Perp short position not found for ${sym} on ${perpExch} — fill may not have executed`);
|
|
1953
|
-
}
|
|
1954
|
-
else if (Number(perpPos.size) < expectedSize * 0.5) {
|
|
1955
|
-
verifyWarnings.push(`Perp short size ${perpPos.size} < expected ${matchedSize} — partial fill?`);
|
|
1956
|
-
}
|
|
2232
|
+
const c = await closePerpVerified(perpAdapter, perpExch, sym, "buy", excess, { decimals: sizeDecimalsOf(perpStep), stepSize: perpStep });
|
|
2233
|
+
shortOpen = floorToStep(Math.max(0, Number(shortOpen) - c.closed), perpStep);
|
|
2234
|
+
if (c.residual > 0)
|
|
2235
|
+
warnings.push(`Perp reduce of ${excess} ${sym} on ${perpExch} closed ${c.closed}, ${c.residual} still open${c.note !== null ? ` (${c.note})` : ""} — MANUAL CLOSE REQUIRED`);
|
|
1957
2236
|
}
|
|
1958
|
-
catch (
|
|
1959
|
-
|
|
1960
|
-
|
|
2237
|
+
catch (e) {
|
|
2238
|
+
if (isOutcomeUnknown(e))
|
|
2239
|
+
perpUnknown = `perp reduce of ${excess} on ${perpExch} outcome unknown: ${errMsg(e)} (short ${shortOpen} was open)`;
|
|
2240
|
+
else
|
|
2241
|
+
warnings.push(`Perp reduce of ${excess} ${sym} on ${perpExch} FAILED: ${errMsg(e)} — MANUAL CLOSE REQUIRED`);
|
|
1961
2242
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2243
|
+
}
|
|
2244
|
+
// 7a'. Unknown perp outcome: stop here. Selling the spot back could leave a naked short
|
|
2245
|
+
// and the short may or may not exist — quarantine for a human.
|
|
2246
|
+
if (perpUnknown !== null) {
|
|
2247
|
+
const pendingNote = spotPending ? `; spot buy ${matchedSize} also PENDING` : `; spot bought ${spotFilled}`;
|
|
2248
|
+
quarantineSymbol("arb-exec", sym, `arb exec spot:${spotExch}: ${perpUnknown}${pendingNote} — not rolled back`, `Check ${sym} on ${spotDef.id} spot (perp -e ${spotDef.id} trade spot-balance / spot-orders) and the ${perpExch} perp (perp -e ${perpExch} positions); hedge or close by hand`, (m) => console.error(m));
|
|
2249
|
+
logExecution({
|
|
2250
|
+
type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
|
|
2251
|
+
symbol: sym, side: "entry", size: matchedSize,
|
|
2252
|
+
status: "failed", dryRun: false,
|
|
2253
|
+
error: `${perpUnknown} — MANUAL CHECK REQUIRED`,
|
|
2254
|
+
meta: { mode: "spot-perp", spotPending, spotFilled, perpPending: true, ...(warnings.length > 0 && { warnings }) },
|
|
2255
|
+
});
|
|
2256
|
+
const result = {
|
|
2257
|
+
status: "perp_pending", mode: "spot-perp", symbol: sym,
|
|
2258
|
+
spotFilled, spotPending, perpError: perpUnknown,
|
|
2259
|
+
remediation: `Perp order outcome unknown — do NOT resubmit and do NOT sell the spot back blindly. Check: perp -e ${perpExch} positions; perp -e ${spotDef.id} trade spot-balance`,
|
|
2260
|
+
...(warnings.length > 0 && { warnings }),
|
|
2261
|
+
};
|
|
2262
|
+
reportExecFailure("ORDER_PENDING", `arb exec spot:${spotExch} ${sym}: perp leg outcome unknown (quarantined) — ${perpUnknown}`, result);
|
|
2263
|
+
if (isJson())
|
|
2264
|
+
return;
|
|
2265
|
+
for (const w of warnings)
|
|
2266
|
+
console.log(chalk.red.bold(` ${w}`));
|
|
2267
|
+
console.log(chalk.red.bold(` PERP ORDER PENDING — ${perpUnknown}. Spot ${spotPending ? "PENDING" : `bought ${spotFilled}`}. Nothing rolled back; ${sym} quarantined.\n`));
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
// 7b. Spot leg filled while the perp leg DEFINITELY failed: sell the spot fill back.
|
|
2271
|
+
let spotHeld = spotFilled;
|
|
2272
|
+
if (!perpOk && spotFilled > 0) {
|
|
2273
|
+
const perpErr = perpResult.reason;
|
|
2274
|
+
if (!isJson())
|
|
2275
|
+
console.log(chalk.yellow(` Perp leg failed (${errMsg(perpErr)}) — selling back ${hedgeSize} spot`));
|
|
2276
|
+
try {
|
|
2277
|
+
const rb = Number(hedgeSize) > 0 ? await spotMarketFill(spotAdapter, spotSymbol, "sell", hedgeSize, spotStep) : null;
|
|
2278
|
+
spotHeld = rb === null ? spotFilled : Math.max(0, spotFilled - rb.filledSize);
|
|
2279
|
+
if (spotHeld > 0)
|
|
2280
|
+
warnings.push(`Spot rollback left ${spotHeld} ${sym} on ${spotExch} — MANUAL CLOSE REQUIRED`);
|
|
1966
2281
|
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
// (rolling back based on uncertain data is worse than persisting)
|
|
1970
|
-
logExecution({
|
|
1971
|
-
type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
|
|
1972
|
-
symbol: sym, side: "entry", size: matchedSize,
|
|
1973
|
-
status: "unverified", dryRun: false,
|
|
1974
|
-
meta: { mode: "spot-perp", spotExch, perpExch, matchedUsd, warnings: verifyWarnings },
|
|
1975
|
-
});
|
|
2282
|
+
catch (e) {
|
|
2283
|
+
warnings.push(`Spot rollback FAILED: ${errMsg(e)} — ${spotFilled} ${sym} still held on ${spotExch}, MANUAL CLOSE REQUIRED`);
|
|
1976
2284
|
}
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2285
|
+
}
|
|
2286
|
+
// Hedged only when the perp short is VERIFIED (placePerpVerified measured it) and spot is held.
|
|
2287
|
+
const hedged = perpOk && Number(shortOpen) > 0 && spotHeld > 0;
|
|
2288
|
+
if (hedged) {
|
|
2289
|
+
if (spotResult.status === "fulfilled" && spotResult.value.status === "partial") {
|
|
2290
|
+
warnings.push(`Spot leg partially filled: ${spotFilled} of ${matchedSize} ${sym}; perp short sized to match`);
|
|
2291
|
+
}
|
|
2292
|
+
// Legs must match: an excess short whose reduce failed, or a verified short below the
|
|
2293
|
+
// spot held, is real unhedged exposure → quarantine and report it, never "filled".
|
|
2294
|
+
// Spot held on the perp lot grid: sub-lot spot dust cannot be hedged and is not an imbalance.
|
|
2295
|
+
const heldStr = floorToStep(spotHeld, perpStep);
|
|
2296
|
+
let unbalanced = null;
|
|
2297
|
+
if (Number(heldStr) > Number(shortOpen)) {
|
|
2298
|
+
unbalanced = `verified perp short ${shortOpen} < spot held ${heldStr} ${sym} — ${fillSizeString(spotHeld - Number(shortOpen), sizeDec)} spot is unhedged`;
|
|
2299
|
+
}
|
|
2300
|
+
else if (Number(shortOpen) > Number(heldStr)) {
|
|
2301
|
+
unbalanced = `perp short ${shortOpen} on ${perpExch} > spot held ${heldStr} ${sym} — ${fillSizeString(Number(shortOpen) - spotHeld, sizeDec)} short is naked (reduce did not complete)`;
|
|
2302
|
+
}
|
|
2303
|
+
if (unbalanced !== null) {
|
|
2304
|
+
warnings.push(unbalanced);
|
|
2305
|
+
quarantineSymbol("arb-exec", sym, `arb exec spot:${spotExch}: ${unbalanced}`, `Check ${sym} on ${spotDef.id} spot (perp -e ${spotDef.id} trade spot-balance) and the ${perpExch} perp (perp -e ${perpExch} positions); re-hedge or close by hand, then: perp strategy quarantine-clear ${sym}`, (m) => console.error(m));
|
|
1985
2306
|
}
|
|
1986
|
-
|
|
1987
|
-
|
|
2307
|
+
if (!isJson())
|
|
2308
|
+
for (const w of warnings)
|
|
2309
|
+
console.log(chalk.yellow(` ⚠ ${w}`));
|
|
2310
|
+
logExecution({
|
|
2311
|
+
type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
|
|
2312
|
+
symbol: sym, side: "entry", size: hedgeSize,
|
|
2313
|
+
status: "success", dryRun: false,
|
|
2314
|
+
meta: { mode: "spot-perp", spotExch, perpExch, matchedUsd, requestedSize: matchedSize, spotFilled, shortOpen, perpVerified: true, quarantined: unbalanced !== null, ...(warnings.length > 0 && { warnings }) },
|
|
2315
|
+
});
|
|
2316
|
+
// Persist the actual leg sizes
|
|
2317
|
+
persistAddPosition(networkOf(program), {
|
|
1988
2318
|
id: `${sym}-spot-${Date.now()}`,
|
|
1989
2319
|
symbol: sym,
|
|
1990
2320
|
longExchange: spotExch,
|
|
1991
2321
|
shortExchange: perpExch,
|
|
1992
|
-
longSize:
|
|
1993
|
-
shortSize: parseFloat(
|
|
2322
|
+
longSize: spotHeld,
|
|
2323
|
+
shortSize: parseFloat(shortOpen),
|
|
1994
2324
|
entryTime: new Date().toISOString(),
|
|
1995
2325
|
entrySpread: 0, // Will be updated on next scan
|
|
1996
|
-
entryLongPrice: spotCheck.avgFillPrice,
|
|
2326
|
+
entryLongPrice: spotResult.status === "fulfilled" && spotResult.value.avgPrice !== null ? spotResult.value.avgPrice : spotCheck.avgFillPrice,
|
|
1997
2327
|
entryShortPrice: perpCheck.avgFillPrice,
|
|
1998
2328
|
accumulatedFunding: 0,
|
|
1999
2329
|
lastCheckTime: new Date().toISOString(),
|
|
@@ -2001,143 +2331,120 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2001
2331
|
spotExchange: spotExch,
|
|
2002
2332
|
spotSymbol: spotSymbol,
|
|
2003
2333
|
});
|
|
2004
|
-
// Return leftover
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
const leftover = Number(usdcBal?.available ?? 0);
|
|
2011
|
-
if (leftover > 1) {
|
|
2012
|
-
const xfer = await hlSpot.transferUsdcToPerp(Math.floor(leftover));
|
|
2013
|
-
if (xfer?.status !== "err") {
|
|
2014
|
-
if (!isJson())
|
|
2015
|
-
console.log(chalk.gray(` Returned ~$${Math.floor(leftover)} USDC to perp account`));
|
|
2016
|
-
}
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
catch { /* non-critical — unified accounts don't need transfer */ }
|
|
2020
|
-
}
|
|
2021
|
-
const result = {
|
|
2022
|
-
status: fillVerified ? "filled" : "filled_unverified",
|
|
2023
|
-
mode: "spot-perp", symbol: sym,
|
|
2024
|
-
size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
|
|
2025
|
-
spot: { exchange: spotExch, side: "buy", size: matchedSize },
|
|
2026
|
-
perp: { exchange: perpExch, side: "sell", size: matchedSize },
|
|
2027
|
-
...(verifyWarnings.length > 0 && { warnings: verifyWarnings }),
|
|
2028
|
-
};
|
|
2029
|
-
if (isJson())
|
|
2030
|
-
return printJson(jsonOk(result));
|
|
2031
|
-
if (fillVerified) {
|
|
2032
|
-
console.log(chalk.green(` FILLED (verified): ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
|
|
2033
|
-
}
|
|
2034
|
-
else {
|
|
2035
|
-
console.log(chalk.yellow(` FILLED (unverified): ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
|
|
2334
|
+
// Return the leftover of what THIS trade transferred in (pre-existing spot quote is
|
|
2335
|
+
// never swept; no-op when shared with perp or nothing was transferred).
|
|
2336
|
+
try {
|
|
2337
|
+
const moved = transferred > 0 ? await sweepSpotQuoteToPerp(spotAdapter, 1, { keep: spotQuoteHave, max: transferred }) : 0;
|
|
2338
|
+
if (moved > 0 && !isJson())
|
|
2339
|
+
console.log(chalk.gray(` Returned ~$${moved.toFixed(2)} to perp account`));
|
|
2036
2340
|
}
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
const filledSide = spotOk ? "spot" : "perp";
|
|
2043
|
-
const failedSide = spotOk ? "perp" : "spot";
|
|
2044
|
-
const failedErr = spotOk
|
|
2045
|
-
? perpResult.reason
|
|
2046
|
-
: spotResult.reason;
|
|
2047
|
-
const failedErrMsg = failedErr instanceof Error ? failedErr.message : String(failedErr);
|
|
2048
|
-
if (!isJson()) {
|
|
2049
|
-
console.log(chalk.yellow(` PARTIAL: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
|
|
2050
|
-
console.log(chalk.red(` Reason: ${failedErrMsg}`));
|
|
2341
|
+
catch (e) {
|
|
2342
|
+
const w = `Leftover spot→perp transfer failed (funds remain in ${spotDef.id} spot): ${errMsg(e)}`;
|
|
2343
|
+
warnings.push(w);
|
|
2344
|
+
if (!isJson())
|
|
2345
|
+
console.log(chalk.yellow(` ⚠ ${w}`));
|
|
2051
2346
|
}
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2347
|
+
if (unbalanced !== null) {
|
|
2348
|
+
const result = {
|
|
2349
|
+
status: "quarantined", mode: "spot-perp", symbol: sym,
|
|
2350
|
+
exposure: { spotHeld: heldStr, perpShort: shortOpen, spotExchange: spotExch, perpExchange: perpExch },
|
|
2351
|
+
reason: unbalanced,
|
|
2352
|
+
remediation: `Legs unbalanced — do NOT resubmit. Re-hedge or close by hand (perp -e ${perpExch} positions; perp -e ${spotDef.id} trade spot-balance), then: perp strategy quarantine-clear ${sym}`,
|
|
2353
|
+
...(warnings.length > 0 && { warnings }),
|
|
2354
|
+
};
|
|
2355
|
+
reportExecFailure("ORDER_PARTIAL", `arb exec spot:${spotExch} ${sym}: QUARANTINED — ${unbalanced}`, result);
|
|
2356
|
+
if (isJson())
|
|
2357
|
+
return;
|
|
2358
|
+
console.log(chalk.red.bold(` QUARANTINED: ${sym} spot ${heldStr} / perp short ${shortOpen} — ${unbalanced}\n`));
|
|
2359
|
+
return;
|
|
2065
2360
|
}
|
|
2066
|
-
logExecution({
|
|
2067
|
-
type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
|
|
2068
|
-
symbol: sym, side: "entry", size: matchedSize,
|
|
2069
|
-
status: "failed", dryRun: false,
|
|
2070
|
-
error: `Partial: ${failedSide} failed (${failedErrMsg}). Rollback: ${rollbackOk ? "ok" : "FAILED"}`,
|
|
2071
|
-
});
|
|
2072
2361
|
const result = {
|
|
2073
|
-
status: "
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2362
|
+
status: spotFilled < Number(matchedSize) ? "partial_filled" : "filled",
|
|
2363
|
+
mode: "spot-perp", symbol: sym,
|
|
2364
|
+
size: hedgeSize, requestedSize: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
|
|
2365
|
+
spot: { exchange: spotExch, side: "buy", size: String(spotHeld) },
|
|
2366
|
+
perp: { exchange: perpExch, side: "sell", size: shortOpen },
|
|
2367
|
+
...(warnings.length > 0 && { warnings }),
|
|
2077
2368
|
};
|
|
2078
2369
|
if (isJson())
|
|
2079
2370
|
return printJson(jsonOk(result));
|
|
2080
|
-
console.log(
|
|
2081
|
-
|
|
2082
|
-
|
|
2371
|
+
console.log(chalk.green(` FILLED (perp verified): ${sym} spot ${spotHeld} / perp short ${shortOpen} (~$${matchedUsd.toFixed(2)} requested)`));
|
|
2372
|
+
console.log(chalk.green(` SPOT ${spotExch} ✓ (bought ${spotFilled})`));
|
|
2373
|
+
console.log(chalk.green(` PERP ${perpExch} ✓ (short ${shortOpen})\n`));
|
|
2374
|
+
return;
|
|
2083
2375
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
console.log(chalk.red(` Both legs failed:`));
|
|
2096
|
-
console.log(chalk.red(` SPOT: ${result.spotError}`));
|
|
2097
|
-
console.log(chalk.red(` PERP: ${result.perpError}\n`));
|
|
2376
|
+
// Not hedged: nothing (or only an unrecoverable remainder) is open. Return the transfer
|
|
2377
|
+
// unless the spot leg is pending (the quote may already be spent).
|
|
2378
|
+
if (!spotPending)
|
|
2379
|
+
await returnTransfer(warnings);
|
|
2380
|
+
const spotErrMsg = spotResult.status === "rejected" ? errMsg(spotResult.reason) : null;
|
|
2381
|
+
const perpErrMsg = perpResult.status === "rejected" ? errMsg(perpResult.reason) : null;
|
|
2382
|
+
const manual = warnings.some(w => w.includes("MANUAL")) || spotPending;
|
|
2383
|
+
if (manual) {
|
|
2384
|
+
// Persisted globally so the `arb auto` daemon, the bots and later `arb exec` runs
|
|
2385
|
+
// leave this symbol alone until a human resolves it.
|
|
2386
|
+
quarantineSymbol("arb-exec", sym, spotPending ? `arb exec spot:${spotExch}: spot buy ${matchedSize} PENDING (outcome unknown)` : `arb exec spot:${spotExch}: rollback incomplete — ${warnings.join("; ")}`, `Check ${sym} on ${spotDef.id} spot (perp -e ${spotDef.id} trade spot-balance / spot-orders) and the ${perpExch} perp (perp -e ${perpExch} positions)`, (m) => console.error(m));
|
|
2098
2387
|
}
|
|
2388
|
+
logExecution({
|
|
2389
|
+
type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
|
|
2390
|
+
symbol: sym, side: "entry", size: matchedSize,
|
|
2391
|
+
status: "failed", dryRun: false,
|
|
2392
|
+
error: `spot: ${spotErrMsg ?? `filled ${spotFilled}`}; perp: ${perpErrMsg ?? "ok"}${manual ? " — MANUAL CHECK REQUIRED" : ""}`,
|
|
2393
|
+
meta: { mode: "spot-perp", spotPending, spotHeld, shortOpen, ...(warnings.length > 0 && { warnings }) },
|
|
2394
|
+
});
|
|
2395
|
+
const result = {
|
|
2396
|
+
status: spotPending ? "spot_pending" : (spotErrMsg !== null && perpErrMsg !== null ? "both_failed" : "rolled_back"),
|
|
2397
|
+
mode: "spot-perp", symbol: sym,
|
|
2398
|
+
spotError: spotErrMsg, perpError: perpErrMsg,
|
|
2399
|
+
spotHeld, perpShortOpen: shortOpen,
|
|
2400
|
+
...(spotPending && { remediation: `Spot order outcome unknown — do NOT resubmit. Check: perp -e ${spotDef.id} trade spot-balance; the perp leg was reduced to match.` }),
|
|
2401
|
+
...(warnings.length > 0 && { warnings }),
|
|
2402
|
+
};
|
|
2403
|
+
// pending spot → ORDER_PENDING; an incomplete rollback (quarantined) → ORDER_PARTIAL; else nothing open → ORDER_REJECTED.
|
|
2404
|
+
reportExecFailure(spotPending ? "ORDER_PENDING" : manual ? "ORDER_PARTIAL" : "ORDER_REJECTED", `arb exec spot:${spotExch} ${sym}: not hedged (${result.status}${manual ? ", quarantined" : ""})`, result);
|
|
2405
|
+
if (isJson())
|
|
2406
|
+
return;
|
|
2407
|
+
if (spotErrMsg !== null)
|
|
2408
|
+
console.log(chalk.red(` SPOT: ${spotErrMsg}`));
|
|
2409
|
+
if (perpErrMsg !== null)
|
|
2410
|
+
console.log(chalk.red(` PERP: ${perpErrMsg}`));
|
|
2411
|
+
for (const w of warnings)
|
|
2412
|
+
console.log(chalk.red.bold(` ${w}`));
|
|
2413
|
+
if (spotPending)
|
|
2414
|
+
console.log(chalk.red.bold(` SPOT ORDER PENDING — outcome unknown, do NOT resubmit. Verify ${spotDef.id} spot balance manually.\n`));
|
|
2415
|
+
else if (!manual)
|
|
2416
|
+
console.log(chalk.yellow(` Not hedged — entry aborted: spot held ${spotHeld} ${sym}, perp short ${shortOpen}${spotErrMsg !== null && perpErrMsg !== null ? " (both legs rejected)" : " (the filled leg was rolled back)"}${warnings.length > 0 ? "; see warnings above" : ""}.\n`));
|
|
2099
2417
|
}
|
|
2100
2418
|
// ── Expose spot-close handler for consolidated "arb close" in arb/index.ts ──
|
|
2101
2419
|
handleSpotClose = async (sym_arg, opts_arg, getAdapter, isJsonFn) => {
|
|
2102
2420
|
const sym = sym_arg.toUpperCase();
|
|
2103
2421
|
// Try to find the position from state
|
|
2104
|
-
const state = loadArbState();
|
|
2422
|
+
const state = loadArbState(networkOf(program));
|
|
2105
2423
|
const pos = state?.positions.find(p => p.symbol.toUpperCase() === sym && p.mode === "spot-perp");
|
|
2106
|
-
const
|
|
2107
|
-
const
|
|
2108
|
-
const
|
|
2424
|
+
const venueIdOf = (v) => resolveVenueId(v.replace(/-spot$/i, ""));
|
|
2425
|
+
const spotRaw = opts_arg.spotExch ?? pos?.spotExchange ?? pos?.longExchange;
|
|
2426
|
+
const perpRaw = opts_arg.perpExch ?? pos?.shortExchange;
|
|
2427
|
+
const spotExch = spotRaw === undefined ? undefined : venueIdOf(spotRaw);
|
|
2428
|
+
const perpExch = perpRaw === undefined ? undefined : venueIdOf(perpRaw);
|
|
2109
2429
|
if (!spotExch || !perpExch) {
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
console.log(chalk.red(` ${msg}`));
|
|
2114
|
-
return;
|
|
2115
|
-
}
|
|
2116
|
-
// Load adapters
|
|
2117
|
-
const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
|
|
2118
|
-
const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
|
|
2119
|
-
let spotAdapter;
|
|
2120
|
-
if (spotExch === "hyperliquid") {
|
|
2121
|
-
const hlAdapter = await getAdapter("hyperliquid");
|
|
2122
|
-
const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
|
|
2123
|
-
await hlSpot.init();
|
|
2124
|
-
spotAdapter = hlSpot;
|
|
2125
|
-
}
|
|
2126
|
-
else {
|
|
2127
|
-
const ltAdapter = await getAdapter("lighter");
|
|
2128
|
-
const ltSpot = new LighterSpotAdapter(ltAdapter);
|
|
2129
|
-
await ltSpot.init();
|
|
2130
|
-
spotAdapter = ltSpot;
|
|
2430
|
+
throw new PerpError("INVALID_PARAMS", "Cannot determine the spot and perp exchanges of this spot-perp position", {
|
|
2431
|
+
remediation: `Pass both: perp arb close ${sym} --spot-exch <spot venue> --perp-exch <perp venue>`,
|
|
2432
|
+
});
|
|
2131
2433
|
}
|
|
2434
|
+
// Load adapters (bot/arb spot scope — §12.4 #3)
|
|
2435
|
+
const spotDef = requireBotSpotVenue(spotExch.replace(/-spot$/, ""));
|
|
2436
|
+
const spotAdapter = await requireSpotAdapter(spotDef.id, await getAdapter(spotDef.id));
|
|
2132
2437
|
const perpAdapter = await getAdapter(perpExch);
|
|
2133
|
-
const spotSymbol =
|
|
2134
|
-
// Find how much to close
|
|
2438
|
+
const spotSymbol = spotPairSymbol(spotDef, spotNetworkOf(spotAdapter), sym);
|
|
2439
|
+
// Find how much to close — fresh read (the reduce-only close is sized from it, never a cached position)
|
|
2440
|
+
invalidateCache("acct");
|
|
2135
2441
|
const perpPositions = await perpAdapter.getPositions();
|
|
2136
2442
|
const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
|
|
2137
2443
|
if (!perpPos) {
|
|
2138
2444
|
const msg = `No short perp position found for ${sym} on ${perpExch}`;
|
|
2445
|
+
process.exitCode = 1;
|
|
2139
2446
|
if (isJsonFn())
|
|
2140
|
-
return printJson(
|
|
2447
|
+
return printJson(jsonError("POSITION_NOT_FOUND", msg));
|
|
2141
2448
|
console.log(chalk.red(` ${msg}`));
|
|
2142
2449
|
return;
|
|
2143
2450
|
}
|
|
@@ -2148,122 +2455,168 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2148
2455
|
console.log(chalk.gray(` PERP ${perpExch}: buy ${closeSize} (close short)`));
|
|
2149
2456
|
console.log(chalk.gray(` Executing...\n`));
|
|
2150
2457
|
}
|
|
2151
|
-
//
|
|
2458
|
+
// Spot quote before the sale (uncached): only the proceeds above it are swept back.
|
|
2459
|
+
// Read before anything moves — a failure aborts the close.
|
|
2460
|
+
const preCloseQuote = await readSpotQuoteAvailableFresh(spotAdapter);
|
|
2461
|
+
// Same exchange → sequential (nonce collision), different → parallel.
|
|
2462
|
+
// The spot leg's REAL fill decides the outcome (§12.4 #5): only a confirmed spot sale
|
|
2463
|
+
// counts as closed; legs are re-matched so no naked short / unhedged spot is left.
|
|
2464
|
+
const sameVenue = spotDef.id === (tryResolveVenueId(perpExch) ?? perpExch);
|
|
2465
|
+
const closeDec = sizeDecimalsOf(closeSize);
|
|
2466
|
+
// The perp buy-back and every re-match order go through placePerpVerified (design
|
|
2467
|
+
// §12.3): only a measured position change counts; an unknown outcome stops the close.
|
|
2152
2468
|
let spotResult;
|
|
2153
2469
|
let perpResult;
|
|
2154
|
-
if (
|
|
2155
|
-
|
|
2156
|
-
spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize)
|
|
2470
|
+
if (sameVenue) {
|
|
2471
|
+
spotResult = await spotMarketFill(spotAdapter, spotSymbol, "sell", closeSize)
|
|
2157
2472
|
.then(r => ({ status: "fulfilled", value: r }))
|
|
2158
2473
|
.catch(e => ({ status: "rejected", reason: e }));
|
|
2159
|
-
perpResult = await perpAdapter
|
|
2474
|
+
perpResult = await placePerpVerified(perpAdapter, perpExch, sym, "buy", closeSize, { reduceOnly: true })
|
|
2160
2475
|
.then(r => ({ status: "fulfilled", value: r }))
|
|
2161
2476
|
.catch(e => ({ status: "rejected", reason: e }));
|
|
2162
2477
|
}
|
|
2163
2478
|
else {
|
|
2164
2479
|
[spotResult, perpResult] = await Promise.allSettled([
|
|
2165
|
-
spotAdapter
|
|
2166
|
-
perpAdapter
|
|
2480
|
+
spotMarketFill(spotAdapter, spotSymbol, "sell", closeSize),
|
|
2481
|
+
placePerpVerified(perpAdapter, perpExch, sym, "buy", closeSize, { reduceOnly: true }),
|
|
2167
2482
|
]);
|
|
2168
2483
|
}
|
|
2169
|
-
const
|
|
2484
|
+
const errMsg = (e) => e instanceof Error ? e.message : String(e);
|
|
2485
|
+
const spotPending = spotResult.status === "rejected" && spotResult.reason instanceof PerpError && spotResult.reason.structured.code === "ORDER_PENDING";
|
|
2486
|
+
const sold = spotResult.status === "fulfilled" ? Math.min(spotResult.value.filledSize, Number(closeSize)) : 0;
|
|
2487
|
+
const soldStr = fillSizeString(sold, closeDec);
|
|
2170
2488
|
const perpOk = perpResult.status === "fulfilled";
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2489
|
+
const closeWarnings = [];
|
|
2490
|
+
/** Set when a perp outcome is unknown: quarantine and stop (no re-short, no further reduce). */
|
|
2491
|
+
let perpUnknown = perpResult.status === "rejected" && isOutcomeUnknown(perpResult.reason)
|
|
2492
|
+
? `perp buy-back ${closeSize} on ${perpExch} outcome unknown: ${errMsg(perpResult.reason)}` : null;
|
|
2493
|
+
// Size still short on the perp (verified buy-back only), and spot still held (in closeSize units).
|
|
2494
|
+
const perpClosed = perpResult.status === "fulfilled" ? Number(fillSizeString(perpResult.value.filledSize, closeDec)) : 0;
|
|
2495
|
+
let shortLeft = Number(closeSize) - perpClosed;
|
|
2496
|
+
const spotLeft = Number(closeSize) - sold;
|
|
2497
|
+
if (perpUnknown === null && !spotPending) {
|
|
2498
|
+
const unhedgedSpot = Number(fillSizeString(Math.max(0, spotLeft - shortLeft), closeDec));
|
|
2499
|
+
const nakedShort = Number(fillSizeString(Math.max(0, shortLeft - spotLeft), closeDec));
|
|
2500
|
+
if (unhedgedSpot > 0) {
|
|
2501
|
+
// Only reachable after a VERIFIED buy-back: spot sold less — re-short the unsold spot to stay hedged.
|
|
2502
|
+
const reShort = fillSizeString(unhedgedSpot, closeDec);
|
|
2503
|
+
try {
|
|
2504
|
+
const rs = await placePerpVerified(perpAdapter, perpExch, sym, "sell", reShort);
|
|
2505
|
+
const reShorted = fillSizeString(rs.filledSize, closeDec);
|
|
2506
|
+
shortLeft += Number(reShorted);
|
|
2507
|
+
// A partial re-short (accepted partial fill) leaves part of the unsold spot unhedged.
|
|
2508
|
+
closeWarnings.push(Number(reShorted) < Number(reShort)
|
|
2509
|
+
? `Spot sold ${soldStr} of ${closeSize} ${sym}; re-short on ${perpExch} filled ${reShorted} of ${reShort} — ${fillSizeString(Number(reShort) - Number(reShorted), closeDec)} spot unhedged, MANUAL ACTION REQUIRED`
|
|
2510
|
+
: `Spot sold ${soldStr} of ${closeSize} ${sym}; re-shorted ${reShort} on ${perpExch} (verified) — stays open (hedged)`);
|
|
2186
2511
|
}
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
closeWarnings.push(`Perp short still open: ${remainingPerpPos.size} ${sym} on ${perpExch} (expected closed)`);
|
|
2512
|
+
catch (e) {
|
|
2513
|
+
if (isOutcomeUnknown(e))
|
|
2514
|
+
perpUnknown = `re-short of the unsold ${reShort} ${sym} on ${perpExch} outcome unknown: ${errMsg(e)}`;
|
|
2515
|
+
else
|
|
2516
|
+
closeWarnings.push(`Re-short of the unsold ${reShort} ${sym} FAILED: ${errMsg(e)} — unhedged spot on ${spotExch}, MANUAL ACTION REQUIRED`);
|
|
2193
2517
|
}
|
|
2194
2518
|
}
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2519
|
+
else if (nakedShort > 0) {
|
|
2520
|
+
// Spot sold more than the perp bought back: reduce the short to the spot left (no naked short).
|
|
2521
|
+
const reduce = fillSizeString(nakedShort, closeDec);
|
|
2522
|
+
try {
|
|
2523
|
+
const rd = await placePerpVerified(perpAdapter, perpExch, sym, "buy", reduce, { reduceOnly: true });
|
|
2524
|
+
const reduced = fillSizeString(rd.filledSize, closeDec);
|
|
2525
|
+
shortLeft -= Number(reduced);
|
|
2526
|
+
// A partial reduce (accepted partial fill) leaves part of the short naked.
|
|
2527
|
+
if (Number(reduced) < Number(reduce))
|
|
2528
|
+
closeWarnings.push(`Perp reduce on ${perpExch} filled ${reduced} of ${reduce} ${sym} — ${fillSizeString(Number(reduce) - Number(reduced), closeDec)} short naked, MANUAL CLOSE REQUIRED`);
|
|
2529
|
+
}
|
|
2530
|
+
catch (e) {
|
|
2531
|
+
if (isOutcomeUnknown(e))
|
|
2532
|
+
perpUnknown = `perp reduce of ${reduce} ${sym} on ${perpExch} outcome unknown: ${errMsg(e)}`;
|
|
2533
|
+
else
|
|
2534
|
+
closeWarnings.push(`Perp still short ${fillSizeString(shortLeft, closeDec)} ${sym} on ${perpExch} while ${soldStr} spot was sold (${errMsg(e)}) — MANUAL CLOSE REQUIRED`);
|
|
2201
2535
|
}
|
|
2202
2536
|
}
|
|
2203
|
-
|
|
2204
|
-
|
|
2537
|
+
}
|
|
2538
|
+
if (spotPending) {
|
|
2539
|
+
closeWarnings.push(`Spot sell PENDING (${errMsg(spotResult.reason)}) — outcome unknown, do NOT resubmit; verify ${spotDef.id} spot balance and the ${perpExch} short manually`);
|
|
2540
|
+
}
|
|
2541
|
+
if (perpUnknown !== null) {
|
|
2542
|
+
closeWarnings.push(`${perpUnknown} — do NOT resubmit; verify the ${perpExch} short and ${spotDef.id} spot balance manually`);
|
|
2543
|
+
}
|
|
2544
|
+
if (spotPending || perpUnknown !== null || closeWarnings.some(w => w.includes("MANUAL"))) {
|
|
2545
|
+
// Persisted globally: bots, the daemon and later arb runs leave the symbol alone.
|
|
2546
|
+
quarantineSymbol("arb-close", sym, `arb close spot:${spotExch}: ${spotPending || perpUnknown !== null ? closeWarnings.filter(w => w.includes("PENDING") || w.includes("unknown")).join("; ") : closeWarnings.join("; ")}`, `Check ${sym} on ${spotDef.id} spot (perp -e ${spotDef.id} trade spot-balance / spot-orders) and the ${perpExch} perp (perp -e ${perpExch} positions)`, (m) => console.error(m));
|
|
2547
|
+
}
|
|
2548
|
+
if (perpUnknown !== null) {
|
|
2549
|
+
// Stop: the perp position is unknown, so neither the stored legs nor the proceeds are touched.
|
|
2205
2550
|
logExecution({
|
|
2206
2551
|
type: "arb_close", exchange: `spot:${spotExch}+${perpExch}`,
|
|
2207
|
-
symbol: sym, side: "exit", size:
|
|
2208
|
-
status:
|
|
2209
|
-
|
|
2552
|
+
symbol: sym, side: "exit", size: soldStr,
|
|
2553
|
+
status: "failed", dryRun: false,
|
|
2554
|
+
error: `${perpUnknown} — MANUAL CHECK REQUIRED`,
|
|
2555
|
+
meta: { mode: "spot-perp", requestedSize: closeSize, spotSold: sold, spotPending, perpPending: true, warnings: closeWarnings },
|
|
2210
2556
|
});
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
const bals = await ltSpot.getSpotBalances();
|
|
2216
|
-
const usdcBal = bals.find(b => b.token === "USDC");
|
|
2217
|
-
const leftover = Number(usdcBal?.available ?? 0);
|
|
2218
|
-
if (leftover > 0.01) {
|
|
2219
|
-
await ltSpot.transferUsdcToPerp(leftover);
|
|
2220
|
-
if (!isJsonFn())
|
|
2221
|
-
console.log(chalk.gray(` Returned ~$${leftover.toFixed(2)} USDC spot→perp on Lighter`));
|
|
2222
|
-
}
|
|
2223
|
-
}
|
|
2224
|
-
catch { /* non-critical */ }
|
|
2225
|
-
}
|
|
2226
|
-
else if (spotExch === "hyperliquid") {
|
|
2227
|
-
try {
|
|
2228
|
-
const hlSpot = spotAdapter;
|
|
2229
|
-
const bals = await hlSpot.getSpotBalances();
|
|
2230
|
-
const usdcBal = bals.find(b => b.token.startsWith("USDC"));
|
|
2231
|
-
const leftover = Number(usdcBal?.available ?? 0);
|
|
2232
|
-
if (leftover > 1) {
|
|
2233
|
-
await hlSpot.transferUsdcToPerp(Math.floor(leftover));
|
|
2234
|
-
}
|
|
2235
|
-
}
|
|
2236
|
-
catch { /* non-critical */ }
|
|
2237
|
-
}
|
|
2238
|
-
const closeResult = {
|
|
2239
|
-
status: closeVerified ? "closed" : "closed_unverified",
|
|
2240
|
-
mode: "spot-perp", symbol: sym, size: closeSize,
|
|
2241
|
-
...(closeWarnings.length > 0 && { warnings: closeWarnings }),
|
|
2557
|
+
const result = {
|
|
2558
|
+
status: "perp_pending", mode: "spot-perp", symbol: sym,
|
|
2559
|
+
spotSold: soldStr, spotPending, perpError: perpUnknown, warnings: closeWarnings,
|
|
2560
|
+
remediation: `Perp order outcome unknown — do NOT resubmit. Check: perp -e ${perpExch} positions; perp -e ${spotDef.id} trade spot-balance`,
|
|
2242
2561
|
};
|
|
2243
2562
|
if (isJsonFn())
|
|
2244
|
-
return printJson(jsonOk(
|
|
2245
|
-
|
|
2246
|
-
console.log(chalk.
|
|
2563
|
+
return printJson(jsonOk(result));
|
|
2564
|
+
for (const w of closeWarnings)
|
|
2565
|
+
console.log(chalk.red.bold(` ${w}`));
|
|
2566
|
+
console.log(chalk.red.bold(` PERP ORDER PENDING — close stopped; spot ${spotPending ? "PENDING" : `sold ${soldStr}`}. ${sym} quarantined.\n`));
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
shortLeft = Number(fillSizeString(Math.max(0, shortLeft), closeDec));
|
|
2570
|
+
const fullyClosed = perpOk && sold > 0 && Number(soldStr) >= Number(closeSize) && shortLeft === 0 && !spotPending;
|
|
2571
|
+
if (fullyClosed) {
|
|
2572
|
+
persistRemovePosition(networkOf(program), sym);
|
|
2573
|
+
}
|
|
2574
|
+
else if (!spotPending && (shortLeft > 0 || spotLeft > 0)) {
|
|
2575
|
+
// Persist the legs that remain (actual sizes).
|
|
2576
|
+
persistUpdatePosition(networkOf(program), sym, { longSize: spotLeft, shortSize: shortLeft });
|
|
2577
|
+
}
|
|
2578
|
+
// Return spot quote proceeds back to perp account (no-op when shared with perp)
|
|
2579
|
+
if (sold > 0) {
|
|
2580
|
+
try {
|
|
2581
|
+
const moved = await sweepSpotQuoteToPerp(spotAdapter, 0.01, { keep: preCloseQuote, max: null });
|
|
2582
|
+
if (moved > 0 && !isJsonFn())
|
|
2583
|
+
console.log(chalk.gray(` Returned ~$${moved.toFixed(2)} spot→perp on ${spotDef.displayName}`));
|
|
2247
2584
|
}
|
|
2248
|
-
|
|
2249
|
-
|
|
2585
|
+
catch (e) {
|
|
2586
|
+
closeWarnings.push(`Spot→perp transfer of proceeds failed (funds remain in ${spotDef.id} spot): ${errMsg(e)}`);
|
|
2250
2587
|
}
|
|
2251
|
-
|
|
2588
|
+
}
|
|
2589
|
+
logExecution({
|
|
2590
|
+
type: "arb_close", exchange: `spot:${spotExch}+${perpExch}`,
|
|
2591
|
+
symbol: sym, side: "exit", size: soldStr,
|
|
2592
|
+
status: fullyClosed ? "success" : "failed", dryRun: false,
|
|
2593
|
+
meta: { mode: "spot-perp", requestedSize: closeSize, spotSold: sold, perpOk, spotPending, shortLeft, spotLeft, ...(closeWarnings.length > 0 && { warnings: closeWarnings }) },
|
|
2594
|
+
});
|
|
2595
|
+
const closeResult = {
|
|
2596
|
+
status: fullyClosed ? "closed" : spotPending ? "spot_pending" : "partial_close",
|
|
2597
|
+
mode: "spot-perp", symbol: sym, size: closeSize,
|
|
2598
|
+
spotSold: soldStr, perpClosed: perpOk,
|
|
2599
|
+
spotError: spotResult.status === "rejected" ? errMsg(spotResult.reason) : null,
|
|
2600
|
+
perpError: perpResult.status === "rejected" ? errMsg(perpResult.reason) : null,
|
|
2601
|
+
remaining: { spot: spotLeft, perpShort: shortLeft },
|
|
2602
|
+
...(closeWarnings.length > 0 && { warnings: closeWarnings }),
|
|
2603
|
+
};
|
|
2604
|
+
if (isJsonFn())
|
|
2605
|
+
return printJson(jsonOk(closeResult));
|
|
2606
|
+
for (const w of closeWarnings)
|
|
2607
|
+
console.log(chalk.yellow(` ⚠ ${w}`));
|
|
2608
|
+
if (fullyClosed) {
|
|
2609
|
+
console.log(chalk.green(` CLOSED: ${sym} ${closeSize} units`));
|
|
2610
|
+
console.log(chalk.green(` SPOT ${spotExch}: sold ${soldStr} ✓`));
|
|
2252
2611
|
console.log(chalk.green(` PERP ${perpExch}: bought (closed short) ✓\n`));
|
|
2253
2612
|
}
|
|
2254
2613
|
else {
|
|
2255
|
-
|
|
2256
|
-
if (
|
|
2257
|
-
|
|
2258
|
-
if (
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
if (isJsonFn())
|
|
2262
|
-
return printJson(jsonOk(result));
|
|
2263
|
-
console.log(chalk.red(` Close partially failed:`));
|
|
2264
|
-
for (const e of errors)
|
|
2265
|
-
console.log(chalk.red(` ${e}`));
|
|
2266
|
-
console.log(chalk.yellow(` Manual intervention may be needed.\n`));
|
|
2614
|
+
console.log(chalk.red(` Close incomplete: spot sold ${soldStr} of ${closeSize}, perp ${perpOk ? "closed" : "NOT closed"}`));
|
|
2615
|
+
if (closeResult.spotError)
|
|
2616
|
+
console.log(chalk.red(` SPOT: ${closeResult.spotError}`));
|
|
2617
|
+
if (closeResult.perpError)
|
|
2618
|
+
console.log(chalk.red(` PERP: ${closeResult.perpError}`));
|
|
2619
|
+
console.log(chalk.yellow(` Remaining: spot ${spotLeft} / perp short ${shortLeft}\n`));
|
|
2267
2620
|
}
|
|
2268
2621
|
};
|
|
2269
2622
|
// ── arb pnl logic (extracted as inner function, no command registration) ──
|
|
@@ -2493,9 +2846,77 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2493
2846
|
const gradeOrder = { A: 0, B: 1, C: 2, D: 3 };
|
|
2494
2847
|
const minGradeIdx = gradeOrder[opts.minGrade.toUpperCase()] ?? 2;
|
|
2495
2848
|
const intervalMs = parseInt(opts.interval) * 1000;
|
|
2496
|
-
//
|
|
2497
|
-
const dryRun =
|
|
2849
|
+
// Root --dry-run flag (never re-declared on the subcommand — the root would swallow it).
|
|
2850
|
+
const dryRun = isDryRun(program);
|
|
2498
2851
|
const openPositions = [];
|
|
2852
|
+
// Consecutive definite close failures per underlying (quarantined at MAX_PAIR_CLOSE_FAILURES)
|
|
2853
|
+
const dexCloseFailures = new Map();
|
|
2854
|
+
const net = networkOf(program);
|
|
2855
|
+
const qlog = (m) => console.error(chalk.red(m));
|
|
2856
|
+
if (!dryRun) {
|
|
2857
|
+
// A previous daemon that died between two legs → quarantine (legs may be open).
|
|
2858
|
+
quarantineOrphanedEntries(net, "arb-dex-auto", qlog);
|
|
2859
|
+
// Positions live only in memory: a restart re-reads what is held on the dexes, so a
|
|
2860
|
+
// held underlying is managed (closed when the spread converges), never re-entered.
|
|
2861
|
+
// Unpaired / size-mismatched legs are quarantined. An unreadable dex → not started.
|
|
2862
|
+
const assets = await fetchAllDexAssets();
|
|
2863
|
+
const pairsNow = findDexArbPairs(assets, { minAnnualSpread: 0, includeNative: true });
|
|
2864
|
+
const legs = new Map();
|
|
2865
|
+
for (const dex of [...new Set(assets.map(a => a.dex))]) {
|
|
2866
|
+
let list;
|
|
2867
|
+
try {
|
|
2868
|
+
list = await (await getHLAdapterForDex(dex)).getPositions();
|
|
2869
|
+
}
|
|
2870
|
+
catch (e) {
|
|
2871
|
+
throw new PerpError("EXCHANGE_ERROR", `arb auto --hip3: ${dex} positions unreadable at start (${e instanceof Error ? e.message : String(e)}) — not started (held pairs unknown)`, {
|
|
2872
|
+
exchange: "hyperliquid", remediation: `Check connectivity (perp -e hyperliquid account positions), then restart`,
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
for (const p of list) {
|
|
2876
|
+
const size = Math.abs(Number(p.size));
|
|
2877
|
+
if (!(size > 0))
|
|
2878
|
+
continue;
|
|
2879
|
+
const raw = p.symbol.toUpperCase();
|
|
2880
|
+
const asset = assets.find(a => a.dex === dex && (a.raw.toUpperCase() === raw || (dex === "hl" && a.base.toUpperCase() === raw)))
|
|
2881
|
+
?? assets.find(a => a.raw.toUpperCase() === raw);
|
|
2882
|
+
const assetRaw = asset?.raw ?? p.symbol;
|
|
2883
|
+
const pairOf = pairsNow.find(x => x.long.raw === assetRaw || x.short.raw === assetRaw);
|
|
2884
|
+
// One entry per venue symbol (a native read that also lists HIP-3 positions is not counted twice).
|
|
2885
|
+
legs.set(assetRaw.toUpperCase(), {
|
|
2886
|
+
dex: asset?.dex ?? dex, raw: assetRaw, base: asset?.base ?? raw, side: p.side, size, sizeStr: p.size.replace(/^-/, ""),
|
|
2887
|
+
underlying: pairOf?.underlying ?? quarantineKey(asset?.base ?? raw),
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
const all = [...legs.values()];
|
|
2892
|
+
const used = new Set();
|
|
2893
|
+
for (const l of all) {
|
|
2894
|
+
if (l.side !== "long" || used.has(l))
|
|
2895
|
+
continue;
|
|
2896
|
+
const sh = all.find(x => !used.has(x) && x.side === "short" && x.underlying === l.underlying && x.dex !== l.dex && x.size === l.size);
|
|
2897
|
+
if (sh === undefined)
|
|
2898
|
+
continue;
|
|
2899
|
+
used.add(l);
|
|
2900
|
+
used.add(sh);
|
|
2901
|
+
openPositions.push({ underlying: l.underlying, longDex: l.dex, longSymbol: l.raw, shortDex: sh.dex, shortSymbol: sh.raw, size: l.sizeStr, entrySpread: 0, entryTime: new Date().toISOString(), longPrice: 0, shortPrice: 0 });
|
|
2902
|
+
}
|
|
2903
|
+
const leftover = new Map();
|
|
2904
|
+
for (const l of all)
|
|
2905
|
+
if (!used.has(l))
|
|
2906
|
+
leftover.set(l.underlying, [...(leftover.get(l.underlying) ?? []), l]);
|
|
2907
|
+
for (const [u, group] of leftover) {
|
|
2908
|
+
if (isQuarantined(u, qlog))
|
|
2909
|
+
continue;
|
|
2910
|
+
const desc = group.map(l => `${l.side} ${l.sizeStr} ${l.raw}`).join(", ");
|
|
2911
|
+
const mismatched = group.some(l => l.side === "long") && group.some(l => l.side === "short");
|
|
2912
|
+
quarantineSymbol("arb-dex-auto", u, mismatched ? `recovery: ${u} long/short sizes do not match (${desc}) — not tracked as a pair` : `recovery: unpaired ${u} leg (${desc}) — no hedge found, not tracked`, `Check ${u} on Hyperliquid (perp -e hyperliquid account positions; every HIP-3 dex); close or re-hedge by hand, then: perp strategy quarantine-clear ${u}`, qlog);
|
|
2913
|
+
}
|
|
2914
|
+
if (openPositions.length > 0 && !isJson()) {
|
|
2915
|
+
console.log(chalk.yellow(` Recovered ${openPositions.length} pair(s) from the dexes: ${openPositions.map(p => `${p.underlying} ${p.longDex}/${p.shortDex} ${p.size}`).join(", ")}`));
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
// Graceful stop: the leg sequence in flight finishes, nothing new starts, then exit.
|
|
2919
|
+
const stop = gracefulStop("arb auto --hip3");
|
|
2499
2920
|
if (!isJson()) {
|
|
2500
2921
|
console.log(chalk.cyan.bold("\n HIP-3 Cross-Dex Arb Bot\n"));
|
|
2501
2922
|
console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
|
|
@@ -2517,6 +2938,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2517
2938
|
const now = new Date().toLocaleTimeString();
|
|
2518
2939
|
// Check close conditions
|
|
2519
2940
|
for (let i = openPositions.length - 1; i >= 0; i--) {
|
|
2941
|
+
if (stop.requested())
|
|
2942
|
+
break; // stopping: no new close sequence
|
|
2520
2943
|
const pos = openPositions[i];
|
|
2521
2944
|
// Find current pair for this position
|
|
2522
2945
|
const current = pairs.find(p => p.underlying === pos.underlying &&
|
|
@@ -2524,6 +2947,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2524
2947
|
(p.long.dex === pos.shortDex && p.short.dex === pos.longDex)));
|
|
2525
2948
|
const currentSpread = current?.annualSpread ?? 0;
|
|
2526
2949
|
if (currentSpread <= closeSpread || !current) {
|
|
2950
|
+
// Quarantined (design §12.3): hands off until a human clears it.
|
|
2951
|
+
if (isQuarantined(pos.underlying, (m) => console.error(chalk.red(m))))
|
|
2952
|
+
continue;
|
|
2527
2953
|
const reason = !current ? "pair disappeared" : `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
|
|
2528
2954
|
if (!isJson())
|
|
2529
2955
|
console.log(chalk.yellow(` ${now} CLOSE ${pos.underlying} — ${reason}`));
|
|
@@ -2531,18 +2957,46 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2531
2957
|
try {
|
|
2532
2958
|
const longAdapter = await getHLAdapterForDex(pos.longDex);
|
|
2533
2959
|
const shortAdapter = await getHLAdapterForDex(pos.shortDex);
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
symbol: pos.underlying, side: "close", size: String(pos.size),
|
|
2541
|
-
status: "success", dryRun: false,
|
|
2542
|
-
meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
|
|
2960
|
+
// Sequential, reduce-only, verified; unknown / unhedged → quarantine.
|
|
2961
|
+
const res = await closePerpPairVerified({
|
|
2962
|
+
source: "arb-dex-auto", sym: pos.underlying,
|
|
2963
|
+
longAdapter, longExch: pos.longDex, longSym: pos.longSymbol, longSize: pos.size,
|
|
2964
|
+
shortAdapter, shortExch: pos.shortDex, shortSym: pos.shortSymbol, shortSize: pos.size, smart: false,
|
|
2965
|
+
log: (m) => console.error(chalk.red(m)),
|
|
2543
2966
|
});
|
|
2544
|
-
if (
|
|
2545
|
-
|
|
2967
|
+
if (res.status === "closed") {
|
|
2968
|
+
dexCloseFailures.delete(pos.underlying);
|
|
2969
|
+
logExecution({
|
|
2970
|
+
type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
|
|
2971
|
+
symbol: pos.underlying, side: "close", size: String(pos.size),
|
|
2972
|
+
status: "success", dryRun: false,
|
|
2973
|
+
meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
|
|
2974
|
+
});
|
|
2975
|
+
if (!isJson())
|
|
2976
|
+
console.log(chalk.green(` ${now} CLOSED ${pos.underlying} — both legs (reduce-only, verified)`));
|
|
2977
|
+
openPositions.splice(i, 1);
|
|
2978
|
+
}
|
|
2979
|
+
else {
|
|
2980
|
+
let note = res.reason;
|
|
2981
|
+
if (res.status === "failed") {
|
|
2982
|
+
const n = (dexCloseFailures.get(pos.underlying) ?? 0) + 1;
|
|
2983
|
+
dexCloseFailures.set(pos.underlying, n);
|
|
2984
|
+
note = `${res.reason} — both legs still open (${n}/${MAX_PAIR_CLOSE_FAILURES})`;
|
|
2985
|
+
if (n >= MAX_PAIR_CLOSE_FAILURES) {
|
|
2986
|
+
quarantinePair("arb-dex-auto", pos.underlying, pos.longDex, pos.shortDex, `close failed ${n} times in a row (last: ${res.reason})`, (m) => console.error(chalk.red(m)));
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
logExecution({
|
|
2990
|
+
type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
|
|
2991
|
+
symbol: pos.underlying, side: "close", size: String(pos.size),
|
|
2992
|
+
status: "failed", dryRun: false,
|
|
2993
|
+
error: res.status === "quarantined" ? `QUARANTINED: ${note}` : note,
|
|
2994
|
+
meta: { longDex: pos.longDex, shortDex: pos.shortDex },
|
|
2995
|
+
});
|
|
2996
|
+
if (!isJson())
|
|
2997
|
+
console.error(chalk.red(` ${now} CLOSE ${res.status === "quarantined" ? "QUARANTINED" : "FAILED"} ${pos.underlying}: ${note}`));
|
|
2998
|
+
// Kept tracked: exposure remains (retried only after a definite, no-change failure).
|
|
2999
|
+
}
|
|
2546
3000
|
}
|
|
2547
3001
|
catch (err) {
|
|
2548
3002
|
logExecution({
|
|
@@ -2553,15 +3007,19 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2553
3007
|
meta: { longDex: pos.longDex, shortDex: pos.shortDex },
|
|
2554
3008
|
});
|
|
2555
3009
|
if (!isJson())
|
|
2556
|
-
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.underlying}: ${err instanceof Error ? err.message : err}`));
|
|
3010
|
+
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.underlying}: ${err instanceof Error ? err.message : err} (nothing sent)`));
|
|
2557
3011
|
}
|
|
2558
3012
|
}
|
|
2559
|
-
|
|
3013
|
+
else {
|
|
3014
|
+
openPositions.splice(i, 1);
|
|
3015
|
+
}
|
|
2560
3016
|
}
|
|
2561
3017
|
}
|
|
2562
3018
|
// Check entry conditions
|
|
2563
3019
|
if (openPositions.length < maxPositions) {
|
|
2564
3020
|
for (const pair of pairs) {
|
|
3021
|
+
if (stop.requested())
|
|
3022
|
+
break; // stopping: no new entries
|
|
2565
3023
|
if (openPositions.some(p => p.underlying === pair.underlying))
|
|
2566
3024
|
continue;
|
|
2567
3025
|
if (openPositions.length >= maxPositions)
|
|
@@ -2572,35 +3030,73 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2572
3030
|
continue;
|
|
2573
3031
|
if (gradeOrder[pair.viability] > minGradeIdx)
|
|
2574
3032
|
continue;
|
|
3033
|
+
if (isQuarantined(pair.underlying, (m) => console.error(chalk.red(m))))
|
|
3034
|
+
continue;
|
|
2575
3035
|
// Calculate size in asset units
|
|
2576
3036
|
const avgPrice = (pair.long.markPrice + pair.short.markPrice) / 2;
|
|
2577
3037
|
if (avgPrice <= 0)
|
|
2578
3038
|
continue;
|
|
2579
3039
|
const szDecimals = Math.min(pair.long.szDecimals, pair.short.szDecimals);
|
|
2580
3040
|
const rawSize = sizeUsd / avgPrice;
|
|
2581
|
-
const size = rawSize
|
|
3041
|
+
const size = fillSizeString(rawSize, szDecimals); // floored
|
|
3042
|
+
if (!(Number(size) > 0))
|
|
3043
|
+
continue;
|
|
2582
3044
|
if (!isJson()) {
|
|
2583
3045
|
console.log(chalk.green(` ${now} ENTER ${pair.underlying} — spread ${pair.annualSpread.toFixed(1)}% grade:${pair.viability} OI:$${formatUsd(pair.minOiUsd)}` +
|
|
2584
3046
|
`\n Long ${pair.long.dex}:${pair.long.base} (${(pair.long.fundingRate * 100).toFixed(4)}%)` +
|
|
2585
3047
|
` | Short ${pair.short.dex}:${pair.short.base} (${(pair.short.fundingRate * 100).toFixed(4)}%)` +
|
|
2586
3048
|
` | ${size} units @ $${avgPrice.toFixed(2)}`));
|
|
2587
3049
|
}
|
|
3050
|
+
let openedSize = size;
|
|
2588
3051
|
if (!dryRun) {
|
|
3052
|
+
// Pending-entry record BEFORE the first leg (crash between legs → quarantine at the next start).
|
|
3053
|
+
try {
|
|
3054
|
+
writePendingEntry(net, { source: "arb-dex-auto", symbol: pair.underlying, longExchange: pair.long.dex, shortExchange: pair.short.dex, size });
|
|
3055
|
+
}
|
|
3056
|
+
catch (e) {
|
|
3057
|
+
if (!isJson())
|
|
3058
|
+
console.error(chalk.red(` ${now} SKIP ${pair.underlying}: could not record the pending entry (${e instanceof Error ? e.message : String(e)}) — nothing sent`));
|
|
3059
|
+
continue;
|
|
3060
|
+
}
|
|
3061
|
+
let legsStarted = false;
|
|
2589
3062
|
try {
|
|
2590
3063
|
const longAdapter = await getHLAdapterForDex(pair.long.dex);
|
|
2591
3064
|
const shortAdapter = await getHLAdapterForDex(pair.short.dex);
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
3065
|
+
// Sequential verified legs; reduce-only rollback / trim; unknown → quarantine.
|
|
3066
|
+
legsStarted = true;
|
|
3067
|
+
const res = await openPerpPairVerified({
|
|
3068
|
+
source: "arb-dex-auto", sym: pair.underlying,
|
|
3069
|
+
longAdapter, longExch: pair.long.dex, longSym: pair.long.raw,
|
|
3070
|
+
shortAdapter, shortExch: pair.short.dex, shortSym: pair.short.raw,
|
|
3071
|
+
size, smart: false, log: (m) => console.error(chalk.red(m)),
|
|
3072
|
+
});
|
|
3073
|
+
legsStarted = false; // outcome known
|
|
3074
|
+
if (res.status !== "opened") {
|
|
3075
|
+
finishPendingEntry(net, "arb-dex-auto", pair.underlying, qlog);
|
|
3076
|
+
const error = res.status === "quarantined"
|
|
3077
|
+
? `QUARANTINED: ${res.reason} (possible exposure: ${res.exposure})`
|
|
3078
|
+
: `${res.reason}${res.rolledBack !== null ? ` — long ${res.rolledBack} rolled back` : ""}`;
|
|
3079
|
+
logExecution({
|
|
3080
|
+
type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
|
|
3081
|
+
symbol: pair.underlying, side: "entry", size,
|
|
3082
|
+
status: "failed", dryRun: false, error,
|
|
3083
|
+
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread },
|
|
3084
|
+
});
|
|
3085
|
+
if (!isJson())
|
|
3086
|
+
console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${error}`));
|
|
3087
|
+
continue;
|
|
3088
|
+
}
|
|
3089
|
+
openedSize = res.hedged;
|
|
3090
|
+
// Tracked in memory below (synchronously, before anything else can throw); a restart recovers it from the dexes.
|
|
3091
|
+
finishPendingEntry(net, "arb-dex-auto", pair.underlying, qlog);
|
|
2596
3092
|
logExecution({
|
|
2597
3093
|
type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
|
|
2598
|
-
symbol: pair.underlying, side: "entry", size,
|
|
3094
|
+
symbol: pair.underlying, side: "entry", size: res.hedged,
|
|
2599
3095
|
status: "success", dryRun: false,
|
|
2600
|
-
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread, viability: pair.viability, avgPrice },
|
|
3096
|
+
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread, viability: pair.viability, avgPrice, requested: size },
|
|
2601
3097
|
});
|
|
2602
3098
|
if (!isJson())
|
|
2603
|
-
console.log(chalk.green(` ${now} FILLED ${pair.underlying} — both legs`));
|
|
3099
|
+
console.log(chalk.green(` ${now} FILLED ${pair.underlying} — both legs @ ${res.hedged} (verified)`));
|
|
2604
3100
|
}
|
|
2605
3101
|
catch (err) {
|
|
2606
3102
|
logExecution({
|
|
@@ -2610,8 +3106,13 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2610
3106
|
error: err instanceof Error ? err.message : String(err),
|
|
2611
3107
|
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread },
|
|
2612
3108
|
});
|
|
3109
|
+
if (legsStarted) {
|
|
3110
|
+
// Threw inside the leg sequence: what is open is unknown → quarantine (never retried).
|
|
3111
|
+
quarantinePair("arb-dex-auto", pair.underlying, pair.long.dex, pair.short.dex, `entry threw mid-sequence (${err instanceof Error ? err.message : String(err)}) — legs may be open`, qlog);
|
|
3112
|
+
}
|
|
3113
|
+
finishPendingEntry(net, "arb-dex-auto", pair.underlying, qlog);
|
|
2613
3114
|
if (!isJson())
|
|
2614
|
-
console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${err instanceof Error ? err.message : err}`));
|
|
3115
|
+
console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${err instanceof Error ? err.message : err}${legsStarted ? " (quarantined)" : " (nothing sent)"}`));
|
|
2615
3116
|
continue;
|
|
2616
3117
|
}
|
|
2617
3118
|
}
|
|
@@ -2621,7 +3122,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2621
3122
|
longSymbol: pair.long.raw,
|
|
2622
3123
|
shortDex: pair.short.dex,
|
|
2623
3124
|
shortSymbol: pair.short.raw,
|
|
2624
|
-
size,
|
|
3125
|
+
size: openedSize,
|
|
2625
3126
|
entrySpread: pair.annualSpread,
|
|
2626
3127
|
entryTime: new Date().toISOString(),
|
|
2627
3128
|
longPrice: pair.long.markPrice,
|
|
@@ -2650,9 +3151,15 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2650
3151
|
console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
2651
3152
|
}
|
|
2652
3153
|
};
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
3154
|
+
// Serialized cycles (never overlapping): the next starts only after this one finished.
|
|
3155
|
+
while (!stop.requested()) {
|
|
3156
|
+
await cycle();
|
|
3157
|
+
await interruptibleSleep(intervalMs, stop.requested);
|
|
3158
|
+
}
|
|
3159
|
+
stop.dispose();
|
|
3160
|
+
if (!isJson())
|
|
3161
|
+
console.log(chalk.yellow(`\n HIP-3 daemon stopped (current cycle finished). ${openPositions.length} pair(s) left open: ${openPositions.map(p => `${p.underlying} ${p.longDex}/${p.shortDex}`).join(", ") || "none"}\n`));
|
|
3162
|
+
process.exit(0);
|
|
2656
3163
|
}
|
|
2657
3164
|
// Expose inner handlers for external use
|
|
2658
3165
|
handleArbPnlRef = _handleArbPnl;
|