perp-cli 0.12.15 → 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 +278 -31
- 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 +74 -84
- 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 +157 -682
- package/dist/jobs.d.ts +84 -6
- package/dist/jobs.js +239 -41
- package/dist/landing.d.ts +14 -0
- package/dist/landing.js +36 -0
- 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 +338 -134
- package/skills/perp-cli/references/agent-operations.md +181 -92
- package/skills/perp-cli/references/commands.md +475 -132
- package/skills/perp-cli/references/strategies.md +44 -38
- package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
- package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
- 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 +33 -16
- 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,58 @@
|
|
|
1
|
+
import { isDryRun, networkOf, supportsDryRun } from "../../cli-flags.js";
|
|
1
2
|
import chalk from "chalk";
|
|
2
3
|
import { makeTable, formatUsd, formatPnl, printJson, jsonOk, logSettledRejections } from "../../utils.js";
|
|
3
4
|
import { SPOT_PERP_TOKEN_MAP } from "../../exchanges/index.js";
|
|
4
5
|
import { readExecutionLog, logExecution } from "../../execution-log.js";
|
|
5
|
-
import { toHourlyRate
|
|
6
|
+
import { toHourlyRate } from "../../funding.js";
|
|
6
7
|
import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../../shared-api.js";
|
|
7
8
|
import { computeBasisRisk } from "../../arb-utils.js";
|
|
8
|
-
import { smartOrder } from "../../smart-order.js";
|
|
9
9
|
import { removePosition as persistRemovePosition, getPositions as getArbStatePositions, loadArbState } from "../../arb-state.js";
|
|
10
10
|
import { fetchAllBalances, computeRebalancePlan } from "../../rebalance.js";
|
|
11
11
|
import { EXCHANGE_TO_CHAIN, getBestQuote } from "../../bridge-engine.js";
|
|
12
12
|
import { computeEnhancedStats } from "../../arb-history-stats.js";
|
|
13
|
+
import { listBotSpotVenues, nonQuoteSpotBalances, requireSpotAdapter, spotNetworkOf } from "../../exchanges/spot-factory.js";
|
|
14
|
+
import { listConfiguredAccountVenues } from "../../config.js";
|
|
15
|
+
import { closePerpPairVerified, handleSpotClose } from "../arb-auto.js";
|
|
16
|
+
import { quarantineEntryFor } from "../../bot/quarantine.js";
|
|
17
|
+
import { PerpError, classifyError, parsePositiveDecimal } from "../../errors.js";
|
|
18
|
+
import { listVenuesWithRole, resolveVenueId } from "../../exchanges/venue.js";
|
|
13
19
|
// ── Helpers ──
|
|
14
20
|
import { DEFAULT_TAKER_FEE as TAKER_FEE } from "../../constants.js";
|
|
15
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Venues an arb leg can sit on: every registered trading venue with a configured
|
|
23
|
+
* credential source (registry order). Never a hard-coded list — a venue added to the
|
|
24
|
+
* registry is covered by `arb status` / `arb close` without touching this file.
|
|
25
|
+
*/
|
|
26
|
+
export async function listArbVenues() {
|
|
27
|
+
const configured = await listConfiguredAccountVenues(); // registers the venues
|
|
28
|
+
const trading = new Set(listVenuesWithRole("trading").map(v => v.id));
|
|
29
|
+
return configured.filter(v => trading.has(v));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Positions of every venue in `venues`. A venue whose adapter cannot be built or whose
|
|
33
|
+
* positions read fails is an error (Rule #2): an unread venue is not "no positions" —
|
|
34
|
+
* a hidden leg would make a live pair look closed.
|
|
35
|
+
*/
|
|
36
|
+
export async function readArbVenuePositions(venues, getAdapter) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const exchange of venues) {
|
|
39
|
+
let positions;
|
|
40
|
+
try {
|
|
41
|
+
positions = await (await getAdapter(exchange)).getPositions();
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
const c = classifyError(e, exchange);
|
|
45
|
+
throw new PerpError(c.code, `Cannot read positions on ${exchange}: ${c.message}`, {
|
|
46
|
+
exchange,
|
|
47
|
+
venuesChecked: [...venues],
|
|
48
|
+
remediation: `Arb pairs are detected from every configured venue's positions, so none may be skipped. Check the venue: perp account positions -e ${exchange}`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
for (const position of positions)
|
|
52
|
+
out.push({ exchange, position });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
16
56
|
function formatDuration(ms) {
|
|
17
57
|
const hours = Math.floor(ms / (1000 * 60 * 60));
|
|
18
58
|
const days = Math.floor(hours / 24);
|
|
@@ -24,6 +64,10 @@ function formatDuration(ms) {
|
|
|
24
64
|
const minutes = Math.floor(ms / (1000 * 60));
|
|
25
65
|
return `${minutes}m`;
|
|
26
66
|
}
|
|
67
|
+
/** hourly rate of a rate entry: the entry's own interval (Aster, per symbol) or the venue basis. */
|
|
68
|
+
function entryHourly(e) {
|
|
69
|
+
return e.fundingHours !== null ? e.rate / e.fundingHours : toHourlyRate(e.rate, e.exchange);
|
|
70
|
+
}
|
|
27
71
|
async function fetchFundingRatesMap() {
|
|
28
72
|
const rateMap = new Map();
|
|
29
73
|
// SSOT rule #2: each DEX is independent. allSettled keeps the comparison
|
|
@@ -35,19 +79,28 @@ async function fetchFundingRatesMap() {
|
|
|
35
79
|
fetchLighterOrderBookDetails(),
|
|
36
80
|
fetchLighterFundingRates(),
|
|
37
81
|
fetch("https://fapi.asterdex.com/fapi/v1/premiumIndex").then(r => r.json()),
|
|
82
|
+
fetch("https://fapi.asterdex.com/fapi/v3/fundingInfo").then(r => r.json()),
|
|
38
83
|
]);
|
|
39
|
-
logSettledRejections(settled, ["pacifica", "hyperliquid", "lighter:orderbook", "lighter:funding", "aster:premiumIndex"], "arb");
|
|
84
|
+
logSettledRejections(settled, ["pacifica", "hyperliquid", "lighter:orderbook", "lighter:funding", "aster:premiumIndex", "aster:fundingInfo"], "arb");
|
|
40
85
|
const pacAssets = settled[0].status === "fulfilled" ? settled[0].value : [];
|
|
41
86
|
const hlAssets = settled[1].status === "fulfilled" ? settled[1].value : [];
|
|
42
87
|
const ltDetails = settled[2].status === "fulfilled" ? settled[2].value : [];
|
|
43
88
|
const ltFunding = settled[3].status === "fulfilled" ? settled[3].value : [];
|
|
44
|
-
|
|
45
|
-
const
|
|
89
|
+
// Aster funding basis is per symbol: a rate is usable only with its fundingInfo interval.
|
|
90
|
+
const asterPremiums = settled[4].status === "fulfilled" && settled[5].status === "fulfilled" ? settled[4].value : [];
|
|
91
|
+
const asterInfo = settled[5].status === "fulfilled" ? settled[5].value : [];
|
|
92
|
+
const asterHours = new Map();
|
|
93
|
+
for (const row of Array.isArray(asterInfo) ? asterInfo : []) {
|
|
94
|
+
const h = Number(row.fundingIntervalHours);
|
|
95
|
+
if (typeof row.symbol === "string" && Number.isFinite(h) && h > 0)
|
|
96
|
+
asterHours.set(row.symbol, h);
|
|
97
|
+
}
|
|
98
|
+
const addRate = (sym, exchange, rate, markPrice, fundingHours = null) => {
|
|
46
99
|
if (!sym)
|
|
47
100
|
return;
|
|
48
101
|
if (!rateMap.has(sym))
|
|
49
102
|
rateMap.set(sym, []);
|
|
50
|
-
rateMap.get(sym).push({ exchange, rate, markPrice });
|
|
103
|
+
rateMap.get(sym).push({ exchange, rate, markPrice, fundingHours });
|
|
51
104
|
};
|
|
52
105
|
for (const p of pacAssets)
|
|
53
106
|
addRate(p.symbol, "pacifica", p.funding, p.mark);
|
|
@@ -87,10 +140,71 @@ async function fetchFundingRatesMap() {
|
|
|
87
140
|
const markPrice = Number(p.markPrice);
|
|
88
141
|
if (!Number.isFinite(fundingRate) || !Number.isFinite(markPrice) || markPrice <= 0)
|
|
89
142
|
continue;
|
|
90
|
-
|
|
143
|
+
const hours = asterHours.get(rawSym);
|
|
144
|
+
if (hours === undefined)
|
|
145
|
+
continue; // not in /fapi/v3/fundingInfo → cannot normalize
|
|
146
|
+
addRate(rawSym.replace(/USDT$/, ""), "aster", fundingRate, markPrice, hours);
|
|
91
147
|
}
|
|
92
148
|
return rateMap;
|
|
93
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* State-file entries the venues do not confirm: no live pair with the same symbol and
|
|
152
|
+
* legs. `arb status` reports them apart from the live pairs (the venue is the source of truth).
|
|
153
|
+
*/
|
|
154
|
+
export function staleArbStateEntries(persisted, livePerpPerp, liveSpotPerp) {
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const st of persisted) {
|
|
157
|
+
const sym = st.symbol.toUpperCase();
|
|
158
|
+
const live = st.mode === "spot-perp"
|
|
159
|
+
? liveSpotPerp.some(p => p.symbol === sym && p.perpExchange === st.shortExchange)
|
|
160
|
+
: livePerpPerp.some(p => p.symbol === sym && p.longExchange === st.longExchange && p.shortExchange === st.shortExchange);
|
|
161
|
+
if (live)
|
|
162
|
+
continue;
|
|
163
|
+
out.push({
|
|
164
|
+
id: st.id, symbol: sym, mode: st.mode === "spot-perp" ? "spot-perp" : "perp-perp",
|
|
165
|
+
longExchange: st.longExchange, shortExchange: st.shortExchange, entryTime: st.entryTime,
|
|
166
|
+
reason: st.mode === "spot-perp"
|
|
167
|
+
? `no ${sym} perp short on ${st.shortExchange}`
|
|
168
|
+
: `no ${sym} long on ${st.longExchange} with a short on ${st.shortExchange}`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** Per-leg engine entries (funding-arb-strategy: one record per leg) within this window form one pair entry. */
|
|
174
|
+
const LEG_PAIRING_WINDOW_MS = 10 * 60_000;
|
|
175
|
+
/**
|
|
176
|
+
* Whether a live long/short pair was opened by arb: an arb state entry for exactly these legs
|
|
177
|
+
* ("state"), else the latest successful arb_entry for exactly these legs in the execution log —
|
|
178
|
+
* combined (meta.arbPairId / meta.longExchange + shortExchange) or per-leg (meta.leg long on the
|
|
179
|
+
* long venue + leg short on the short venue within 10 min) — with no successful arb_close of the
|
|
180
|
+
* pair after it ("executionLog"). Anything else is "inferred". Never a symbol-only match.
|
|
181
|
+
*/
|
|
182
|
+
export function arbPairProvenance(symbol, longExchange, shortExchange, persisted, records) {
|
|
183
|
+
const sym = symbol.toUpperCase();
|
|
184
|
+
if (persisted.some(p => p.mode !== "spot-perp" && p.symbol.toUpperCase() === sym && p.longExchange === longExchange && p.shortExchange === shortExchange))
|
|
185
|
+
return "state";
|
|
186
|
+
const pairId = `${sym}:${longExchange}:${shortExchange}`;
|
|
187
|
+
const mine = records.filter(r => r.status === "success" && r.symbol.toUpperCase() === sym);
|
|
188
|
+
const combined = (r) => r.meta?.arbPairId === pairId || (r.meta?.longExchange === longExchange && r.meta?.shortExchange === shortExchange);
|
|
189
|
+
const t = (r) => new Date(r.timestamp).getTime();
|
|
190
|
+
let entryAt = -Infinity;
|
|
191
|
+
for (const r of mine.filter(x => x.type === "arb_entry")) {
|
|
192
|
+
if (combined(r)) {
|
|
193
|
+
entryAt = Math.max(entryAt, t(r));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (r.meta?.leg === "long" && r.exchange === longExchange) {
|
|
197
|
+
const short = mine.find(x => x.type === "arb_entry" && x.meta?.leg === "short" && x.exchange === shortExchange && Math.abs(t(x) - t(r)) <= LEG_PAIRING_WINDOW_MS);
|
|
198
|
+
if (short !== undefined)
|
|
199
|
+
entryAt = Math.max(entryAt, t(r), t(short));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (entryAt === -Infinity)
|
|
203
|
+
return "inferred";
|
|
204
|
+
const closedAfter = mine.some(r => r.type === "arb_close" && t(r) > entryAt
|
|
205
|
+
&& (combined(r) || (r.meta?.leg === "long" && r.exchange === longExchange) || (r.meta?.leg === "short" && r.exchange === shortExchange)));
|
|
206
|
+
return closedAfter ? "inferred" : "executionLog";
|
|
207
|
+
}
|
|
94
208
|
function findArbEntryForPair(symbol, longExchange, shortExchange) {
|
|
95
209
|
const entries = readExecutionLog({ type: "arb_entry", symbol });
|
|
96
210
|
const successful = entries.filter(e => e.status === "success");
|
|
@@ -118,7 +232,7 @@ function getCurrentSpreadForSymbol(symbol, longExchange, shortExchange, rateMap)
|
|
|
118
232
|
const shortRate = rates.find(r => r.exchange === shortExchange);
|
|
119
233
|
if (!longRate || !shortRate)
|
|
120
234
|
return null;
|
|
121
|
-
return
|
|
235
|
+
return Math.abs(entryHourly(shortRate) - entryHourly(longRate)) * 24 * 365 * 100;
|
|
122
236
|
}
|
|
123
237
|
// ── Registration ──
|
|
124
238
|
export function registerArbManageCommands(program, getAdapterForExchange, isJson) {
|
|
@@ -136,26 +250,18 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
136
250
|
console.log(chalk.cyan("\n Checking arb positions across exchanges...\n"));
|
|
137
251
|
// Fetch positions from all exchanges
|
|
138
252
|
const allPositions = [];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
unrealizedPnl: Number(p.unrealizedPnl),
|
|
152
|
-
leverage: p.leverage,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
// exchange not configured, skip
|
|
158
|
-
}
|
|
253
|
+
const venuesChecked = await listArbVenues();
|
|
254
|
+
for (const { exchange, position: p } of await readArbVenuePositions(venuesChecked, getAdapterForExchange)) {
|
|
255
|
+
allPositions.push({
|
|
256
|
+
exchange,
|
|
257
|
+
symbol: p.symbol.replace("-PERP", "").toUpperCase(),
|
|
258
|
+
side: p.side,
|
|
259
|
+
size: Math.abs(Number(p.size)),
|
|
260
|
+
entryPrice: Number(p.entryPrice),
|
|
261
|
+
markPrice: Number(p.markPrice),
|
|
262
|
+
unrealizedPnl: Number(p.unrealizedPnl),
|
|
263
|
+
leverage: p.leverage,
|
|
264
|
+
});
|
|
159
265
|
}
|
|
160
266
|
// Group by symbol to detect arb pairs
|
|
161
267
|
const bySymbol = new Map();
|
|
@@ -166,6 +272,8 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
166
272
|
}
|
|
167
273
|
// Find arb pairs: same symbol, different exchanges, one long + one short
|
|
168
274
|
const arbPairs = [];
|
|
275
|
+
const persistedForPairs = getArbStatePositions(networkOf(program));
|
|
276
|
+
const arbRecords = [...readExecutionLog({ type: "arb_entry" }), ...readExecutionLog({ type: "arb_close" })];
|
|
169
277
|
// Fetch current funding rates for spread calculation
|
|
170
278
|
let rateMap;
|
|
171
279
|
try {
|
|
@@ -222,6 +330,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
222
330
|
symbol,
|
|
223
331
|
longExchange: longPos.exchange,
|
|
224
332
|
shortExchange: shortPos.exchange,
|
|
333
|
+
provenance: arbPairProvenance(symbol, longPos.exchange, shortPos.exchange, persistedForPairs, arbRecords),
|
|
225
334
|
longPosition: {
|
|
226
335
|
side: "long",
|
|
227
336
|
size: longPos.size,
|
|
@@ -270,34 +379,23 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
270
379
|
};
|
|
271
380
|
// Collect spot balances per exchange
|
|
272
381
|
const spotBalancesByExchange = new Map();
|
|
273
|
-
|
|
274
|
-
|
|
382
|
+
// Bot/arb spot scope (§12.4 #3): configured venues with a separate spot balance + transfer.
|
|
383
|
+
const configuredVenues = new Set(await listConfiguredAccountVenues());
|
|
384
|
+
for (const def of listBotSpotVenues()) {
|
|
385
|
+
if (!configuredVenues.has(def.id))
|
|
386
|
+
continue;
|
|
275
387
|
try {
|
|
276
|
-
const adapter = await getAdapterForExchange(
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const hlSpot = new HyperliquidSpotAdapter(adapter);
|
|
280
|
-
await hlSpot.init();
|
|
281
|
-
const balances = await hlSpot.getSpotBalances();
|
|
282
|
-
spotBalancesByExchange.set(exName, balances.filter(b => {
|
|
283
|
-
const base = b.token.replace(/-SPOT$/i, "").toUpperCase();
|
|
284
|
-
return base !== "USDC" && Number(b.total) > 0;
|
|
285
|
-
}));
|
|
286
|
-
}
|
|
287
|
-
else if (exName === "lighter") {
|
|
288
|
-
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
289
|
-
const ltSpot = new LighterSpotAdapter(adapter);
|
|
290
|
-
await ltSpot.init();
|
|
291
|
-
const balances = await ltSpot.getSpotBalances();
|
|
292
|
-
spotBalancesByExchange.set(exName, balances.filter(b => b.token !== "USDC" && b.token !== "USDC_SPOT" && Number(b.total) > 0));
|
|
293
|
-
}
|
|
388
|
+
const adapter = await getAdapterForExchange(def.id);
|
|
389
|
+
const spot = await requireSpotAdapter(def.id, adapter);
|
|
390
|
+
spotBalancesByExchange.set(def.id, nonQuoteSpotBalances(def, spotNetworkOf(spot), await spot.getSpotBalances()));
|
|
294
391
|
}
|
|
295
|
-
catch {
|
|
296
|
-
|
|
392
|
+
catch (e) {
|
|
393
|
+
process.stderr.write(`[warn] ${def.displayName} spot balances unavailable: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
297
394
|
}
|
|
298
395
|
}
|
|
299
396
|
// Load persisted arb state for spot-perp entries
|
|
300
|
-
const
|
|
397
|
+
const persistedAll = getArbStatePositions(networkOf(program));
|
|
398
|
+
const persistedSpotPerp = persistedAll.filter(p => p.mode === "spot-perp");
|
|
301
399
|
const persistedSymbols = new Set(persistedSpotPerp.map(p => p.symbol.toUpperCase()));
|
|
302
400
|
// Match: spot balance + perp short on same symbol (different or same exchange)
|
|
303
401
|
for (const [spotEx, balances] of spotBalancesByExchange) {
|
|
@@ -313,14 +411,18 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
313
411
|
if (refPrice > 0 && spotAmount * refPrice < 1)
|
|
314
412
|
continue;
|
|
315
413
|
for (const shortPos of matchingShorts) {
|
|
316
|
-
// Check if this short is already used by a perp-perp pair
|
|
317
|
-
const usedByPerpPerp = arbPairs.some(ap => ap.symbol === perpSymbol && ap.shortExchange === shortPos.exchange);
|
|
414
|
+
// Check if this short is already used by a CONFIRMED perp-perp pair
|
|
415
|
+
const usedByPerpPerp = arbPairs.some(ap => ap.symbol === perpSymbol && ap.shortExchange === shortPos.exchange && ap.provenance !== "inferred");
|
|
318
416
|
if (usedByPerpPerp)
|
|
319
417
|
continue;
|
|
320
418
|
// Look up persisted state for hold time and entry info
|
|
321
419
|
const persisted = persistedSpotPerp.find(p => p.symbol.toUpperCase() === perpSymbol
|
|
322
420
|
&& p.spotExchange === spotEx
|
|
323
421
|
&& p.shortExchange === shortPos.exchange);
|
|
422
|
+
// The arb's own spot size (the state entry), never the whole holding (pre-existing spot is reported apart).
|
|
423
|
+
const heldSpotAmount = spotAmount;
|
|
424
|
+
const arbSpotAmount = persisted !== undefined ? Math.min(persisted.longSize, heldSpotAmount) : heldSpotAmount;
|
|
425
|
+
const otherSpotAmount = Math.max(0, heldSpotAmount - arbSpotAmount);
|
|
324
426
|
const entryTime = persisted?.entryTime ?? null;
|
|
325
427
|
const holdDurationMs = entryTime ? Date.now() - new Date(entryTime).getTime() : null;
|
|
326
428
|
const holdDuration = holdDurationMs ? formatDuration(holdDurationMs) : null;
|
|
@@ -328,10 +430,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
328
430
|
const rates = rateMap.get(perpSymbol);
|
|
329
431
|
const perpRate = rates?.find(r => r.exchange === shortPos.exchange);
|
|
330
432
|
const fundingRate = perpRate?.rate ?? 0;
|
|
331
|
-
const hourlyRate = fundingRate ?
|
|
433
|
+
const hourlyRate = perpRate && fundingRate ? entryHourly(perpRate) : 0;
|
|
332
434
|
const annualPct = hourlyRate * 8760 * 100;
|
|
333
435
|
const perpNotional = shortPos.size * shortPos.markPrice;
|
|
334
|
-
const spotValueUsd =
|
|
436
|
+
const spotValueUsd = arbSpotAmount * shortPos.markPrice; // approx using perp mark price
|
|
335
437
|
// Funding income: fetch actual payments from exchange API
|
|
336
438
|
let actualFundingIncome = 0;
|
|
337
439
|
try {
|
|
@@ -368,7 +470,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
368
470
|
spotExchange: spotEx,
|
|
369
471
|
perpExchange: shortPos.exchange,
|
|
370
472
|
spotToken: bal.token.replace(/-SPOT$/i, ""),
|
|
371
|
-
spotAmount,
|
|
473
|
+
spotAmount: arbSpotAmount,
|
|
474
|
+
heldSpotAmount,
|
|
475
|
+
otherSpotAmount,
|
|
476
|
+
confirmed: persisted !== undefined,
|
|
372
477
|
spotValueUsd,
|
|
373
478
|
perpSide: "short",
|
|
374
479
|
perpSize: shortPos.size,
|
|
@@ -383,7 +488,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
383
488
|
holdDurationMs,
|
|
384
489
|
estimatedFundingIncome,
|
|
385
490
|
estimatedFees: entryFees,
|
|
386
|
-
netPnl: (spotValueUsd - shortPos.entryPrice *
|
|
491
|
+
netPnl: (spotValueUsd - shortPos.entryPrice * arbSpotAmount) + shortPos.unrealizedPnl + estimatedFundingIncome - entryFees,
|
|
387
492
|
dailyFundingEstimate,
|
|
388
493
|
});
|
|
389
494
|
}
|
|
@@ -402,7 +507,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
402
507
|
const holdDurationMs = Date.now() - new Date(persisted.entryTime).getTime();
|
|
403
508
|
const rates = rateMap.get(sym);
|
|
404
509
|
const perpRate = rates?.find(r => r.exchange === persisted.shortExchange);
|
|
405
|
-
const hourlyRate = perpRate ?
|
|
510
|
+
const hourlyRate = perpRate ? entryHourly(perpRate) : 0;
|
|
406
511
|
const perpNotional = shortPos.size * shortPos.markPrice;
|
|
407
512
|
spotPerpPositions.push({
|
|
408
513
|
symbol: sym,
|
|
@@ -411,6 +516,9 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
411
516
|
perpExchange: persisted.shortExchange,
|
|
412
517
|
spotToken: "?",
|
|
413
518
|
spotAmount: 0,
|
|
519
|
+
heldSpotAmount: 0,
|
|
520
|
+
otherSpotAmount: 0,
|
|
521
|
+
confirmed: true,
|
|
414
522
|
spotValueUsd: 0,
|
|
415
523
|
perpSide: "short",
|
|
416
524
|
perpSize: shortPos.size,
|
|
@@ -429,6 +537,13 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
429
537
|
dailyFundingEstimate: Math.abs(hourlyRate) * perpNotional * 24,
|
|
430
538
|
});
|
|
431
539
|
}
|
|
540
|
+
// State vs venue: the venue is the source of truth. A pair is reported only when the
|
|
541
|
+
// venues show its legs; the state file contributes entry time / funding to a pair that
|
|
542
|
+
// is live. State entries with no live counterpart are listed apart, never as positions.
|
|
543
|
+
const staleStateEntries = staleArbStateEntries(persistedAll, arbPairs, spotPerpPositions);
|
|
544
|
+
// Pairs only inferred from a long and a short sharing the symbol are reported apart (never as arb positions).
|
|
545
|
+
const unconfirmedPairs = arbPairs.filter(p => p.provenance === "inferred");
|
|
546
|
+
arbPairs.splice(0, arbPairs.length, ...arbPairs.filter(p => p.provenance !== "inferred"));
|
|
432
547
|
// ── JSON output ──
|
|
433
548
|
if (isJson()) {
|
|
434
549
|
const totalNotionalPP = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
|
|
@@ -440,6 +555,9 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
440
555
|
return printJson(jsonOk({
|
|
441
556
|
perpPerpPairs: arbPairs,
|
|
442
557
|
spotPerpPairs: spotPerpPositions,
|
|
558
|
+
venuesChecked,
|
|
559
|
+
staleStateEntries,
|
|
560
|
+
unconfirmedPairs,
|
|
443
561
|
aggregate: {
|
|
444
562
|
perpPerpCount: arbPairs.length,
|
|
445
563
|
spotPerpCount: spotPerpPositions.length,
|
|
@@ -454,8 +572,14 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
454
572
|
},
|
|
455
573
|
}));
|
|
456
574
|
}
|
|
575
|
+
for (const st of staleStateEntries) {
|
|
576
|
+
console.log(chalk.yellow(` Stale state entry (not on the venue, not counted): ${st.symbol} ${st.mode} ${st.longExchange}/${st.shortExchange} since ${st.entryTime} — drop it: perp arb close ${st.symbol} --state-only`));
|
|
577
|
+
}
|
|
578
|
+
for (const u of unconfirmedPairs) {
|
|
579
|
+
console.log(chalk.gray(` Unconfirmed (inferred, not counted): ${u.symbol} long ${u.longExchange} ${u.longPosition.size} / short ${u.shortExchange} ${u.shortPosition.size} — no arb state / entry record for these legs; close only knowingly: perp arb close ${u.symbol} --pair ${u.longExchange}:${u.shortExchange}`));
|
|
580
|
+
}
|
|
457
581
|
if (arbPairs.length === 0 && spotPerpPositions.length === 0) {
|
|
458
|
-
console.log(chalk.gray(
|
|
582
|
+
console.log(chalk.gray(` No open arb positions found on ${venuesChecked.join(", ")}.\n`));
|
|
459
583
|
return;
|
|
460
584
|
}
|
|
461
585
|
// ── Perp-Perp table ──
|
|
@@ -505,7 +629,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
505
629
|
const legRows = [
|
|
506
630
|
[
|
|
507
631
|
chalk.green("Spot"),
|
|
508
|
-
`${sp.spotAmount.toFixed(4)} ${sp.spotToken}`,
|
|
632
|
+
`${sp.spotAmount.toFixed(4)} ${sp.spotToken}${sp.otherSpotAmount > 0 ? chalk.gray(` (+${sp.otherSpotAmount.toFixed(4)} other)`) : ""}${sp.confirmed ? "" : chalk.yellow(" (unconfirmed)")}`,
|
|
509
633
|
`$${sp.perpEntryPrice.toFixed(4)}`,
|
|
510
634
|
`$${spotPriceEach.toFixed(4)}`,
|
|
511
635
|
formatPnl(spotPnl),
|
|
@@ -564,178 +688,107 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
564
688
|
// Always show actual funding payments earned
|
|
565
689
|
await printFundingEarned(getAdapterForExchange, isJson, { period: opts.period, symbol: opts.symbol });
|
|
566
690
|
});
|
|
691
|
+
/**
|
|
692
|
+
* `arb close <sym> --state-only`: delete the arb state entry of `sym` ONLY when the venues show
|
|
693
|
+
* no live position for it (the same reconciliation as `arb status` staleStateEntries). Without
|
|
694
|
+
* --yes (or with --dry-run) the entry is only shown. Nothing is sent to any venue.
|
|
695
|
+
*/
|
|
696
|
+
async function dropStaleStateEntry(sym, yes, dryRun) {
|
|
697
|
+
const net = networkOf(program);
|
|
698
|
+
const entries = getArbStatePositions(net).filter(p => p.symbol.toUpperCase() === sym);
|
|
699
|
+
if (entries.length === 0) {
|
|
700
|
+
throw new PerpError("POSITION_NOT_FOUND", `No arb state entry for ${sym} on ${net}`, { remediation: "See the state entries: perp arb status" });
|
|
701
|
+
}
|
|
702
|
+
const venuesChecked = await listArbVenues();
|
|
703
|
+
const live = (await readArbVenuePositions(venuesChecked, getAdapterForExchange))
|
|
704
|
+
.filter(x => x.position.symbol.replace("-PERP", "").toUpperCase() === sym)
|
|
705
|
+
.map(x => ({ exchange: x.exchange, side: x.position.side }));
|
|
706
|
+
const livePerp = live.filter(l => l.side === "long").flatMap(l => live.filter(s => s.side === "short" && s.exchange !== l.exchange).map(s => ({ symbol: sym, longExchange: l.exchange, shortExchange: s.exchange })));
|
|
707
|
+
const liveSpot = live.filter(l => l.side === "short").map(s => ({ symbol: sym, spotExchange: "", perpExchange: s.exchange }));
|
|
708
|
+
const stale = staleArbStateEntries(entries, livePerp, liveSpot);
|
|
709
|
+
if (stale.length !== entries.length) {
|
|
710
|
+
throw new PerpError("INVALID_PARAMS", `The ${sym} arb state entry is not stale: the venues still show its legs — close it instead: perp arb close ${sym}`, {
|
|
711
|
+
live, remediation: `Close the position: perp arb close ${sym}`,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
if (!yes || dryRun) {
|
|
715
|
+
if (isJson())
|
|
716
|
+
return printJson(jsonOk({ symbol: sym, staleStateEntries: stale, deleted: false, remediation: `Confirm: perp arb close ${sym} --state-only --yes` }));
|
|
717
|
+
for (const st of stale)
|
|
718
|
+
console.log(chalk.yellow(` Stale ${st.mode} ${st.symbol} ${st.longExchange}/${st.shortExchange} since ${st.entryTime} (${st.reason})`));
|
|
719
|
+
console.log(chalk.gray(` Nothing deleted. Confirm: perp arb close ${sym} --state-only --yes\n`));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
persistRemovePosition(net, sym);
|
|
723
|
+
if (isJson())
|
|
724
|
+
return printJson(jsonOk({ symbol: sym, staleStateEntries: stale, deleted: true }));
|
|
725
|
+
console.log(chalk.green(` Dropped the stale ${sym} arb state entry (${net}). No order was sent.\n`));
|
|
726
|
+
}
|
|
567
727
|
// ── arb close ──
|
|
568
|
-
arb
|
|
569
|
-
.
|
|
570
|
-
.description("Manually close an arb position on both exchanges")
|
|
571
|
-
.option("--dry-run", "Show what would happen without executing")
|
|
728
|
+
supportsDryRun(arb.command("close <symbol>"))
|
|
729
|
+
.description("Manually close an arb position on both exchanges (supports --dry-run)")
|
|
572
730
|
.option("--pair <pair>", "Specify arb pair as longExchange:shortExchange (e.g. pacifica:hyperliquid)")
|
|
573
731
|
.option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
|
|
574
|
-
.option("--spot-exch <exchange>", "Override spot exchange for spot-perp close (hl
|
|
575
|
-
.option("--perp-exch <exchange>", "Override perp exchange for spot-perp close")
|
|
576
|
-
.
|
|
732
|
+
.option("--spot-exch <exchange>", "Override spot exchange for spot-perp close (a bot spot venue: hl, lt, lrh, aster, ondo)")
|
|
733
|
+
.option("--perp-exch <exchange>", "Override perp exchange for spot-perp close. With both --spot-exch and --perp-exch the close needs no arb-state entry: it sells the spot and buys back the live perp short of <symbol> on those venues")
|
|
734
|
+
.option("--force", "Act on a quarantined symbol (after checking its positions by hand)")
|
|
735
|
+
.option("--state-only", "Drop a STALE arb state entry of <symbol> (one `arb status` lists under staleStateEntries: no live position on the venues) — sends no order; refused while the venues still show it")
|
|
736
|
+
.option("--yes", "With --state-only: confirm the deletion (without it, the entry is only shown)")
|
|
737
|
+
.action(async (symbol, opts, cmd) => {
|
|
577
738
|
const sym = symbol.toUpperCase();
|
|
578
|
-
const dryRun =
|
|
739
|
+
const dryRun = isDryRun(cmd);
|
|
740
|
+
if (opts.stateOnly) {
|
|
741
|
+
await dropStaleStateEntry(sym, opts.yes === true, dryRun);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
// Global quarantine (design §12.3): an unknown / unhedged outcome was recorded for
|
|
745
|
+
// this symbol. A human may still close it, but only knowingly (--force).
|
|
746
|
+
const quarantined = quarantineEntryFor(sym, (m) => console.error(chalk.red(m)));
|
|
747
|
+
if (quarantined !== null) {
|
|
748
|
+
if (!opts.force && !dryRun) {
|
|
749
|
+
throw new PerpError("RISK_VIOLATION", `${sym} is quarantined (${quarantined.strategy}: ${quarantined.reason})`, {
|
|
750
|
+
remediation: `${quarantined.remediation}. To close it anyway after checking the positions: perp arb close ${sym} --force`,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
if (!isJson())
|
|
754
|
+
console.error(chalk.yellow(` WARNING: ${sym} is quarantined (${quarantined.strategy}: ${quarantined.reason})${opts.force ? " — proceeding (--force)" : ""}. The quarantine is NOT cleared by this close: perp strategy quarantine-clear ${sym}`));
|
|
755
|
+
}
|
|
579
756
|
// Check if this is a spot-perp position (auto-detect from arb state)
|
|
580
|
-
const arbState = loadArbState();
|
|
757
|
+
const arbState = loadArbState(networkOf(program));
|
|
581
758
|
const statePos = arbState?.positions?.find(p => p.symbol.toUpperCase() === sym && p.mode === "spot-perp");
|
|
582
|
-
|
|
759
|
+
// Explicit --spot-exch + --perp-exch select the spot-perp close without a state entry
|
|
760
|
+
// (e.g. a pair opened by a strategy bot); the handler sizes it from live positions.
|
|
761
|
+
if ((opts.spotExch === undefined) !== (opts.perpExch === undefined)) {
|
|
762
|
+
throw new PerpError("INVALID_PARAMS", "--spot-exch and --perp-exch must be given together", {
|
|
763
|
+
remediation: `perp arb close ${sym} --spot-exch <spot venue> --perp-exch <perp venue>`,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
const explicitSpotPerp = opts.spotExch !== undefined && opts.perpExch !== undefined;
|
|
767
|
+
if (statePos || explicitSpotPerp) {
|
|
583
768
|
// ── Inline spot-perp close logic ──
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
const
|
|
769
|
+
// Venue ids through the registry (every alias, e.g. lrh / ast); a stored spot venue may carry "-spot".
|
|
770
|
+
const venueIdOf = (v) => resolveVenueId(v.replace(/-spot$/i, ""));
|
|
771
|
+
const spotRaw = opts.spotExch ?? statePos?.spotExchange ?? statePos?.longExchange;
|
|
772
|
+
const perpRaw = opts.perpExch ?? statePos?.shortExchange;
|
|
773
|
+
const spotExch = spotRaw === undefined ? undefined : venueIdOf(spotRaw);
|
|
774
|
+
const perpExch = perpRaw === undefined ? undefined : venueIdOf(perpRaw);
|
|
587
775
|
if (!spotExch || !perpExch) {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
return printJson(jsonOk({ error: msg }));
|
|
591
|
-
console.log(chalk.red(` ${msg}`));
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
// Load spot adapter
|
|
595
|
-
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
596
|
-
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
597
|
-
let spotAdapter;
|
|
598
|
-
if (spotExch === "hyperliquid") {
|
|
599
|
-
const hlAdapter = await getAdapterForExchange("hyperliquid");
|
|
600
|
-
const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
|
|
601
|
-
await hlSpot.init();
|
|
602
|
-
spotAdapter = hlSpot;
|
|
603
|
-
}
|
|
604
|
-
else {
|
|
605
|
-
const ltAdapter = await getAdapterForExchange("lighter");
|
|
606
|
-
const ltSpot = new LighterSpotAdapter(ltAdapter);
|
|
607
|
-
await ltSpot.init();
|
|
608
|
-
spotAdapter = ltSpot;
|
|
609
|
-
}
|
|
610
|
-
const perpAdapter = await getAdapterForExchange(perpExch);
|
|
611
|
-
const spotSymbol = `${sym}/USDC`;
|
|
612
|
-
// Find how much to close
|
|
613
|
-
const perpPositions = await perpAdapter.getPositions();
|
|
614
|
-
const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
|
|
615
|
-
if (!perpPos) {
|
|
616
|
-
const msg = `No short perp position found for ${sym} on ${perpExch}`;
|
|
617
|
-
if (isJson())
|
|
618
|
-
return printJson(jsonOk({ error: msg }));
|
|
619
|
-
console.log(chalk.red(` ${msg}`));
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
const closeSize = perpPos.size;
|
|
623
|
-
if (!isJson()) {
|
|
624
|
-
console.log(chalk.cyan(`\n Closing Spot+Perp Arb: ${sym}`));
|
|
625
|
-
console.log(chalk.gray(` SPOT ${spotExch}: sell ${closeSize}`));
|
|
626
|
-
console.log(chalk.gray(` PERP ${perpExch}: buy ${closeSize} (close short)`));
|
|
627
|
-
console.log(chalk.gray(` Executing...\n`));
|
|
628
|
-
}
|
|
629
|
-
// Same exchange -> sequential (nonce collision), different -> parallel
|
|
630
|
-
let spotResult;
|
|
631
|
-
let perpResult;
|
|
632
|
-
if (spotExch === perpExch) {
|
|
633
|
-
spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize)
|
|
634
|
-
.then(r => ({ status: "fulfilled", value: r }))
|
|
635
|
-
.catch(e => ({ status: "rejected", reason: e }));
|
|
636
|
-
perpResult = await perpAdapter.marketOrder(sym, "buy", closeSize)
|
|
637
|
-
.then(r => ({ status: "fulfilled", value: r }))
|
|
638
|
-
.catch(e => ({ status: "rejected", reason: e }));
|
|
639
|
-
}
|
|
640
|
-
else {
|
|
641
|
-
[spotResult, perpResult] = await Promise.allSettled([
|
|
642
|
-
spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize),
|
|
643
|
-
perpAdapter.marketOrder(sym, "buy", closeSize),
|
|
644
|
-
]);
|
|
645
|
-
}
|
|
646
|
-
const spotOk = spotResult.status === "fulfilled";
|
|
647
|
-
const perpOk = perpResult.status === "fulfilled";
|
|
648
|
-
if (spotOk && perpOk) {
|
|
649
|
-
// Post-close fill verification
|
|
650
|
-
let closeVerified = true;
|
|
651
|
-
const closeWarnings = [];
|
|
652
|
-
try {
|
|
653
|
-
await new Promise(r => setTimeout(r, 800));
|
|
654
|
-
const spotBals = await spotAdapter.getSpotBalances();
|
|
655
|
-
const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(sym));
|
|
656
|
-
const remainingSpotAmt = Number(spotTokenBal?.total ?? 0);
|
|
657
|
-
const closedSize = parseFloat(closeSize);
|
|
658
|
-
if (remainingSpotAmt > closedSize * 0.5) {
|
|
659
|
-
closeVerified = false;
|
|
660
|
-
closeWarnings.push(`Spot sell may not have filled: still have ${remainingSpotAmt.toFixed(6)} ${sym} (expected ~0)`);
|
|
661
|
-
}
|
|
662
|
-
const perpPositionsAfter = await perpAdapter.getPositions();
|
|
663
|
-
const remainingPerpPos = perpPositionsAfter.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
|
|
664
|
-
if (remainingPerpPos && Number(remainingPerpPos.size) > closedSize * 0.5) {
|
|
665
|
-
closeVerified = false;
|
|
666
|
-
closeWarnings.push(`Perp short still open: ${remainingPerpPos.size} ${sym} on ${perpExch} (expected closed)`);
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
catch (verifyErr) {
|
|
670
|
-
closeWarnings.push(`Close verification failed: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
|
|
671
|
-
}
|
|
672
|
-
if (closeWarnings.length > 0 && !isJson()) {
|
|
673
|
-
for (const w of closeWarnings)
|
|
674
|
-
console.log(chalk.yellow(` Warning: ${w}`));
|
|
675
|
-
}
|
|
676
|
-
persistRemovePosition(sym);
|
|
677
|
-
logExecution({
|
|
678
|
-
type: "arb_close", exchange: `spot:${spotExch}+${perpExch}`,
|
|
679
|
-
symbol: sym, side: "exit", size: closeSize,
|
|
680
|
-
status: closeVerified ? "success" : "unverified", dryRun: false,
|
|
681
|
-
meta: { mode: "spot-perp", ...(closeWarnings.length > 0 && { warnings: closeWarnings }) },
|
|
776
|
+
throw new PerpError("INVALID_PARAMS", "Cannot determine the spot and perp exchanges of this spot-perp position", {
|
|
777
|
+
remediation: `Pass both: perp arb close ${sym} --spot-exch <spot venue> --perp-exch <perp venue>`,
|
|
682
778
|
});
|
|
683
|
-
// Return spot USDC proceeds back to perp account
|
|
684
|
-
if (spotExch === "lighter") {
|
|
685
|
-
try {
|
|
686
|
-
const ltSpot = spotAdapter;
|
|
687
|
-
const bals = await ltSpot.getSpotBalances();
|
|
688
|
-
const usdcBal = bals.find(b => b.token === "USDC");
|
|
689
|
-
const leftover = Number(usdcBal?.available ?? 0);
|
|
690
|
-
if (leftover > 0.01) {
|
|
691
|
-
await ltSpot.transferUsdcToPerp(leftover);
|
|
692
|
-
if (!isJson())
|
|
693
|
-
console.log(chalk.gray(` Returned ~$${leftover.toFixed(2)} USDC spot->perp on Lighter`));
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
catch { /* non-critical */ }
|
|
697
|
-
}
|
|
698
|
-
else if (spotExch === "hyperliquid") {
|
|
699
|
-
try {
|
|
700
|
-
const hlSpot = spotAdapter;
|
|
701
|
-
const bals = await hlSpot.getSpotBalances();
|
|
702
|
-
const usdcBal = bals.find(b => b.token.startsWith("USDC"));
|
|
703
|
-
const leftover = Number(usdcBal?.available ?? 0);
|
|
704
|
-
if (leftover > 1)
|
|
705
|
-
await hlSpot.transferUsdcToPerp(Math.floor(leftover));
|
|
706
|
-
}
|
|
707
|
-
catch { /* non-critical */ }
|
|
708
|
-
}
|
|
709
|
-
const closeResult = {
|
|
710
|
-
status: closeVerified ? "closed" : "closed_unverified",
|
|
711
|
-
mode: "spot-perp", symbol: sym, size: closeSize,
|
|
712
|
-
...(closeWarnings.length > 0 && { warnings: closeWarnings }),
|
|
713
|
-
};
|
|
714
|
-
if (isJson())
|
|
715
|
-
return printJson(jsonOk(closeResult));
|
|
716
|
-
if (closeVerified) {
|
|
717
|
-
console.log(chalk.green(` CLOSED (verified): ${sym} ${closeSize} units`));
|
|
718
|
-
}
|
|
719
|
-
else {
|
|
720
|
-
console.log(chalk.yellow(` CLOSED (unverified): ${sym} ${closeSize} units -- check positions manually`));
|
|
721
|
-
}
|
|
722
|
-
console.log(chalk.green(` SPOT ${spotExch}: sold`));
|
|
723
|
-
console.log(chalk.green(` PERP ${perpExch}: bought (closed short)\n`));
|
|
724
779
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
errors.push(`Spot sell failed: ${spotResult.reason}`);
|
|
729
|
-
if (!perpOk)
|
|
730
|
-
errors.push(`Perp buy failed: ${perpResult.reason}`);
|
|
731
|
-
const result = { status: "partial_close", symbol: sym, spotOk, perpOk, errors };
|
|
780
|
+
if (dryRun) {
|
|
781
|
+
// Dry-run: nothing is sold, bought or transferred.
|
|
782
|
+
const plan = { status: "simulated", dryRun: true, sent: false, mode: "spot-perp", symbol: sym, spotExchange: spotExch, perpExchange: perpExch, longSize: statePos ? statePos.longSize : null, shortSize: statePos ? statePos.shortSize : null };
|
|
732
783
|
if (isJson())
|
|
733
|
-
return printJson(jsonOk(
|
|
734
|
-
console.log(chalk.
|
|
735
|
-
|
|
736
|
-
console.log(chalk.red(` ${e}`));
|
|
737
|
-
console.log(chalk.yellow(` Manual intervention may be needed.\n`));
|
|
784
|
+
return printJson(jsonOk(plan));
|
|
785
|
+
console.log(chalk.cyan(` [DRY-RUN] Would close spot-perp ${sym}: sell spot on ${spotExch} + buy back the perp short on ${perpExch}`));
|
|
786
|
+
return;
|
|
738
787
|
}
|
|
788
|
+
// One implementation for spot-perp close (fill-checked legs, §12.4 #5): arb-auto's handler.
|
|
789
|
+
if (handleSpotClose === undefined)
|
|
790
|
+
throw new Error("spot-perp close handler is not registered (registerArbAutoCommands not called)");
|
|
791
|
+
await handleSpotClose(sym, { spotExch, perpExch }, getAdapterForExchange, isJson);
|
|
739
792
|
return;
|
|
740
793
|
}
|
|
741
794
|
// ── Perp-perp close logic (original) ──
|
|
@@ -746,91 +799,84 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
746
799
|
}
|
|
747
800
|
// Find positions for this symbol across all exchanges
|
|
748
801
|
const allPositions = [];
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
catch {
|
|
770
|
-
// exchange not configured, skip
|
|
771
|
-
}
|
|
802
|
+
const venuesChecked = await listArbVenues();
|
|
803
|
+
for (const { exchange, position: p } of await readArbVenuePositions(venuesChecked, getAdapterForExchange)) {
|
|
804
|
+
const normalized = p.symbol.replace("-PERP", "").toUpperCase();
|
|
805
|
+
if (normalized !== sym)
|
|
806
|
+
continue;
|
|
807
|
+
allPositions.push({
|
|
808
|
+
exchange,
|
|
809
|
+
symbol: normalized,
|
|
810
|
+
rawSymbol: p.symbol,
|
|
811
|
+
side: p.side,
|
|
812
|
+
size: Math.abs(Number(p.size)),
|
|
813
|
+
sizeRaw: String(p.size).trim().replace(/^[-+]/, ""), // venue's own size string, used verbatim for the close
|
|
814
|
+
entryPrice: Number(p.entryPrice),
|
|
815
|
+
markPrice: Number(p.markPrice),
|
|
816
|
+
unrealizedPnl: Number(p.unrealizedPnl),
|
|
817
|
+
});
|
|
772
818
|
}
|
|
773
|
-
// Find all possible arb pairs (long on one exchange, short on another)
|
|
819
|
+
// Find all possible arb pairs (long on one exchange, short on another), each with its provenance:
|
|
820
|
+
// only pairs opened by arb (state / entry record) are closed without --pair.
|
|
774
821
|
const possiblePairs = [];
|
|
775
822
|
const longs = allPositions.filter(p => p.side === "long");
|
|
776
823
|
const shorts = allPositions.filter(p => p.side === "short");
|
|
824
|
+
const persistedForClose = getArbStatePositions(networkOf(program));
|
|
825
|
+
const closeRecords = [...readExecutionLog({ type: "arb_entry", symbol: sym }), ...readExecutionLog({ type: "arb_close", symbol: sym })];
|
|
777
826
|
for (const l of longs) {
|
|
778
827
|
for (const s of shorts) {
|
|
779
828
|
if (l.exchange !== s.exchange)
|
|
780
|
-
possiblePairs.push({ long: l, short: s });
|
|
829
|
+
possiblePairs.push({ long: l, short: s, provenance: arbPairProvenance(sym, l.exchange, s.exchange, persistedForClose, closeRecords) });
|
|
781
830
|
}
|
|
782
831
|
}
|
|
832
|
+
const confirmedPairs = possiblePairs.filter(p => p.provenance !== "inferred");
|
|
783
833
|
// If --pair specified, filter to that specific pair
|
|
784
834
|
let longPos;
|
|
785
835
|
let shortPos;
|
|
786
836
|
if (opts.pair) {
|
|
787
|
-
const
|
|
837
|
+
const parts = opts.pair.split(":");
|
|
838
|
+
if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
|
|
839
|
+
throw new PerpError("INVALID_PARAMS", `--pair must be longExchange:shortExchange, got "${opts.pair}"`, { remediation: `Example: perp arb close ${sym} --pair aster:arcus` });
|
|
840
|
+
}
|
|
841
|
+
const longEx = resolveVenueId(parts[0]);
|
|
842
|
+
const shortEx = resolveVenueId(parts[1]);
|
|
788
843
|
const match = possiblePairs.find(p => p.long.exchange === longEx && p.short.exchange === shortEx);
|
|
789
844
|
if (match) {
|
|
790
845
|
longPos = match.long;
|
|
791
846
|
shortPos = match.short;
|
|
792
847
|
}
|
|
793
848
|
}
|
|
794
|
-
else if (
|
|
795
|
-
longPos =
|
|
796
|
-
shortPos =
|
|
797
|
-
}
|
|
798
|
-
else if (possiblePairs.length >
|
|
799
|
-
//
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
}))
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
console.log(chalk.white(" Available pairs:"));
|
|
815
|
-
for (const p of possiblePairs) {
|
|
816
|
-
console.log(chalk.gray(` --pair ${p.long.exchange}:${p.short.exchange} (long ${p.long.size} / short ${p.short.size})`));
|
|
817
|
-
}
|
|
818
|
-
console.log();
|
|
819
|
-
return;
|
|
849
|
+
else if (confirmedPairs.length === 1) {
|
|
850
|
+
longPos = confirmedPairs[0].long;
|
|
851
|
+
shortPos = confirmedPairs[0].short;
|
|
852
|
+
}
|
|
853
|
+
else if (confirmedPairs.length > 1 || possiblePairs.length > 0) {
|
|
854
|
+
// Several arb pairs, or only pairs inferred from unrelated positions: never guessed — --pair required.
|
|
855
|
+
const onlyInferred = confirmedPairs.length === 0;
|
|
856
|
+
throw new PerpError("INVALID_PARAMS", onlyInferred
|
|
857
|
+
? `No ${sym} pair opened by arb (no arb state / entry record for these legs); ${possiblePairs.length} unconfirmed long/short combination(s) found. Use --pair to close one knowingly.`
|
|
858
|
+
: `Multiple arb pairs found for ${sym}. Use --pair to specify which one to close.`, {
|
|
859
|
+
pairs: possiblePairs.map(p => ({
|
|
860
|
+
arbPairId: `${sym}:${p.long.exchange}:${p.short.exchange}`,
|
|
861
|
+
longExchange: p.long.exchange,
|
|
862
|
+
shortExchange: p.short.exchange,
|
|
863
|
+
longSize: p.long.size,
|
|
864
|
+
shortSize: p.short.size,
|
|
865
|
+
provenance: p.provenance,
|
|
866
|
+
})),
|
|
867
|
+
remediation: `Pick one: ${(onlyInferred ? possiblePairs : confirmedPairs).map(p => `--pair ${p.long.exchange}:${p.short.exchange}`).join(" | ")}`,
|
|
868
|
+
});
|
|
820
869
|
}
|
|
821
870
|
if (!longPos || !shortPos) {
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
832
|
-
console.log();
|
|
833
|
-
return;
|
|
871
|
+
const found = allPositions.map(p => `${p.side} ${p.sizeRaw} on ${p.exchange}`).join(", ");
|
|
872
|
+
throw new PerpError("POSITION_NOT_FOUND", `No arb pair found for ${sym}${opts.pair ? ` on ${opts.pair}` : ""}. Need long and short on different exchanges.`, {
|
|
873
|
+
symbol: sym,
|
|
874
|
+
venuesChecked,
|
|
875
|
+
positions: allPositions.map(p => ({ exchange: p.exchange, side: p.side, size: p.sizeRaw })),
|
|
876
|
+
remediation: allPositions.length > 0
|
|
877
|
+
? `${sym} positions found: ${found}. Close a single leg with: perp trade close ${sym} -e <exchange>`
|
|
878
|
+
: `No ${sym} position on ${venuesChecked.join(", ")}. See the live pairs: perp arb status`,
|
|
879
|
+
});
|
|
834
880
|
}
|
|
835
881
|
// Calculate estimated PnL
|
|
836
882
|
const totalUpnl = longPos.unrealizedPnl + shortPos.unrealizedPnl;
|
|
@@ -851,6 +897,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
851
897
|
if (dryRun) {
|
|
852
898
|
const result = {
|
|
853
899
|
dryRun: true,
|
|
900
|
+
sent: false,
|
|
854
901
|
symbol: sym,
|
|
855
902
|
longExchange: longPos.exchange,
|
|
856
903
|
shortExchange: shortPos.exchange,
|
|
@@ -871,77 +918,36 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
871
918
|
console.log(chalk.yellow(` BUY ${shortPos.size} ${shortPos.rawSymbol} on ${shortPos.exchange} (close short)\n`));
|
|
872
919
|
return;
|
|
873
920
|
}
|
|
874
|
-
//
|
|
875
|
-
|
|
921
|
+
// Close the legs sequentially (long, then short), reduce-only and verified, each
|
|
922
|
+
// sized from the venue's own position size. No retries: a definite failure of the
|
|
923
|
+
// long close changes nothing; an unknown outcome or a leg left open after the
|
|
924
|
+
// other closed quarantines the symbol (design §12.3).
|
|
876
925
|
const results = [];
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
console.log(chalk.green(` Closed ${label} on ${exchange}: ${side.toUpperCase()} ${size} ${sym}${opts.smart ? " (smart)" : ""}${attempt > 0 ? ` (retry ${attempt})` : ""}`));
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
catch (err) {
|
|
894
|
-
lastError = err instanceof Error ? err.message : String(err);
|
|
895
|
-
if (attempt < MAX_RETRIES) {
|
|
896
|
-
if (!isJson())
|
|
897
|
-
console.log(chalk.yellow(` Retry ${attempt + 1}/${MAX_RETRIES} for ${label} on ${exchange}...`));
|
|
898
|
-
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
|
899
|
-
}
|
|
900
|
-
}
|
|
926
|
+
const closeRes = await closePerpPairVerified({
|
|
927
|
+
source: "arb-close", sym,
|
|
928
|
+
longAdapter: await getAdapterForExchange(longPos.exchange), longExch: longPos.exchange, longSym: longPos.rawSymbol, longSize: longPos.sizeRaw,
|
|
929
|
+
shortAdapter: await getAdapterForExchange(shortPos.exchange), shortExch: shortPos.exchange, shortSym: shortPos.rawSymbol, shortSize: shortPos.sizeRaw,
|
|
930
|
+
smart: !!opts.smart,
|
|
931
|
+
log: (m) => console.error(chalk.red(m)),
|
|
932
|
+
});
|
|
933
|
+
if (closeRes.status === "closed") {
|
|
934
|
+
results.push({ exchange: longPos.exchange, action: "sell (close long)", status: "success" });
|
|
935
|
+
results.push({ exchange: shortPos.exchange, action: "buy (close short)", status: "success" });
|
|
936
|
+
if (!isJson()) {
|
|
937
|
+
console.log(chalk.green(` Closed long on ${longPos.exchange}: SELL ${longPos.sizeRaw} ${sym} (reduce-only, verified${opts.smart ? ", smart" : ""})`));
|
|
938
|
+
console.log(chalk.green(` Closed short on ${shortPos.exchange}: BUY ${shortPos.sizeRaw} ${sym} (reduce-only, verified${opts.smart ? ", smart" : ""})`));
|
|
901
939
|
}
|
|
902
|
-
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
results.push({ exchange: `${longPos.exchange}+${shortPos.exchange}`, action: "close", status: closeRes.status, error: closeRes.reason });
|
|
903
943
|
if (!isJson())
|
|
904
|
-
console.error(chalk.red(`
|
|
905
|
-
}
|
|
906
|
-
// Close both legs concurrently
|
|
907
|
-
await Promise.all([
|
|
908
|
-
closeWithRetry(longPos.exchange, longPos.rawSymbol, "sell", longPos.size, "long"),
|
|
909
|
-
closeWithRetry(shortPos.exchange, shortPos.rawSymbol, "buy", shortPos.size, "short"),
|
|
910
|
-
]);
|
|
911
|
-
const allSuccess = results.every(r => r.status === "success");
|
|
912
|
-
const anySuccess = results.some(r => r.status === "success");
|
|
913
|
-
// Verify positions are actually closed
|
|
914
|
-
let verification;
|
|
915
|
-
if (anySuccess) {
|
|
916
|
-
verification = [];
|
|
917
|
-
for (const r of results) {
|
|
918
|
-
if (r.status !== "success")
|
|
919
|
-
continue;
|
|
920
|
-
try {
|
|
921
|
-
const adapter = await getAdapterForExchange(r.exchange);
|
|
922
|
-
const positions = await adapter.getPositions();
|
|
923
|
-
const remaining = positions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym);
|
|
924
|
-
verification.push({
|
|
925
|
-
exchange: r.exchange,
|
|
926
|
-
remainingSize: remaining ? Math.abs(Number(remaining.size)) : 0,
|
|
927
|
-
closed: !remaining || Math.abs(Number(remaining.size)) < 0.0001,
|
|
928
|
-
});
|
|
929
|
-
}
|
|
930
|
-
catch {
|
|
931
|
-
verification.push({ exchange: r.exchange, remainingSize: -1, closed: false });
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
const notClosed = verification.filter(v => !v.closed);
|
|
935
|
-
if (notClosed.length > 0 && !isJson()) {
|
|
936
|
-
console.log(chalk.yellow(`\n Warning: Position may not be fully closed on: ${notClosed.map(v => v.exchange).join(", ")}`));
|
|
937
|
-
}
|
|
944
|
+
console.error(chalk.red(` ${closeRes.status === "quarantined" ? "QUARANTINED" : "Close failed (nothing changed, both legs open)"}: ${closeRes.reason}`));
|
|
938
945
|
}
|
|
946
|
+
const allSuccess = closeRes.status === "closed";
|
|
939
947
|
// Remove from persisted daemon state (so daemon doesn't try to re-close)
|
|
940
948
|
if (allSuccess) {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
}
|
|
944
|
-
catch { /* state may not exist */ }
|
|
949
|
+
// No catch (Rule #2): an absent state file is a no-op; a wrong-network/unreadable one must surface.
|
|
950
|
+
persistRemovePosition(networkOf(program), sym);
|
|
945
951
|
}
|
|
946
952
|
// Log to execution log
|
|
947
953
|
const arbPairId = `${sym}:${longPos.exchange}:${shortPos.exchange}`;
|
|
@@ -970,22 +976,21 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
970
976
|
symbol: sym,
|
|
971
977
|
longExchange: longPos.exchange,
|
|
972
978
|
shortExchange: shortPos.exchange,
|
|
973
|
-
status: allSuccess ? "success" :
|
|
979
|
+
status: allSuccess ? "success" : closeRes.status,
|
|
974
980
|
unrealizedPnl: totalUpnl,
|
|
975
981
|
estimatedFees: totalFees,
|
|
976
982
|
netPnl,
|
|
977
983
|
results,
|
|
978
|
-
verification,
|
|
979
984
|
}));
|
|
980
985
|
}
|
|
981
|
-
if (
|
|
982
|
-
console.log(chalk.
|
|
986
|
+
if (closeRes.status === "quarantined") {
|
|
987
|
+
console.log(chalk.red.bold(`\n ${sym} quarantined — a leg may still be open. Check both venues by hand.\n`));
|
|
983
988
|
}
|
|
984
989
|
else if (allSuccess) {
|
|
985
990
|
console.log(chalk.green(`\n Arb position ${sym} closed successfully.\n`));
|
|
986
991
|
}
|
|
987
992
|
else {
|
|
988
|
-
console.log(chalk.red(`\n Failed to close arb position ${sym}. Both legs
|
|
993
|
+
console.log(chalk.red(`\n Failed to close arb position ${sym}. Both legs are still open (hedged).\n`));
|
|
989
994
|
}
|
|
990
995
|
});
|
|
991
996
|
// ── arb history ──
|
|
@@ -1280,15 +1285,15 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
1280
1285
|
console.log(chalk.gray(`\n Override with 'perp arb auto --min-spread 20 --size 50 ...'\n`));
|
|
1281
1286
|
console.log(chalk.gray(` Run 'perp arb auto --help' for all options.\n`));
|
|
1282
1287
|
});
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
.description("Cross-exchange balance rebalancing for arb")
|
|
1288
|
+
// Plan-only (never moves funds) — --dry-run only labels the plan.
|
|
1289
|
+
supportsDryRun(arb.command("rebalance"))
|
|
1290
|
+
.description("Cross-exchange balance rebalancing plan for arb (read-only; execute with perp funds rebalance execute)")
|
|
1286
1291
|
.option("--check", "Show current balance distribution")
|
|
1287
1292
|
.option("--target <ratio>", "Target distribution ratio (e.g., '50:50' for 2 exchanges, '33:33:33' for 3)")
|
|
1288
1293
|
.option("--amount <usd>", "Total amount to rebalance")
|
|
1289
|
-
.option("--dry-run", "Show plan without executing")
|
|
1290
1294
|
.option("--exchanges <list>", "Comma-separated exchanges", "lighter,pacifica,hyperliquid")
|
|
1291
|
-
.action(async (opts) => {
|
|
1295
|
+
.action(async (opts, cmd) => {
|
|
1296
|
+
const dryRun = isDryRun(cmd);
|
|
1292
1297
|
const exchangeNames = opts.exchanges.split(",").map(e => e.trim());
|
|
1293
1298
|
const adapters = new Map();
|
|
1294
1299
|
for (const name of exchangeNames) {
|
|
@@ -1378,7 +1383,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
1378
1383
|
// If --amount specified, scale moves proportionally
|
|
1379
1384
|
let moves = plan.moves;
|
|
1380
1385
|
if (opts.amount) {
|
|
1381
|
-
const requestedAmount =
|
|
1386
|
+
const requestedAmount = Number(parsePositiveDecimal(opts.amount, "--amount"));
|
|
1382
1387
|
const totalMoveAmount = moves.reduce((s, m) => s + m.amount, 0);
|
|
1383
1388
|
if (totalMoveAmount > 0) {
|
|
1384
1389
|
const scale = Math.min(1, requestedAmount / totalMoveAmount);
|
|
@@ -1416,7 +1421,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
1416
1421
|
}));
|
|
1417
1422
|
if (isJson()) {
|
|
1418
1423
|
return printJson(jsonOk({
|
|
1419
|
-
status:
|
|
1424
|
+
status: dryRun ? "dry_run" : "planned",
|
|
1420
1425
|
target: weights,
|
|
1421
1426
|
moves: moveDetails,
|
|
1422
1427
|
snapshots,
|
|
@@ -1454,7 +1459,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
1454
1459
|
}
|
|
1455
1460
|
const totalFees = moveDetails.reduce((s, m) => s + m.bridgeFee, 0);
|
|
1456
1461
|
console.log(chalk.gray(` Total bridge fees: ~$${totalFees.toFixed(2)}`));
|
|
1457
|
-
if (
|
|
1462
|
+
if (dryRun) {
|
|
1458
1463
|
console.log(chalk.yellow("\n [DRY RUN] No transfers executed.\n"));
|
|
1459
1464
|
}
|
|
1460
1465
|
else {
|
|
@@ -1469,24 +1474,15 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
1469
1474
|
console.log(chalk.cyan(`\n Fetching funding payments (last ${periodDays} days)...\n`));
|
|
1470
1475
|
// Fetch positions to identify arb pairs
|
|
1471
1476
|
const fundingPositions = [];
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
side: p.side,
|
|
1482
|
-
size: Math.abs(Number(p.size)),
|
|
1483
|
-
markPrice: Number(p.markPrice),
|
|
1484
|
-
});
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
catch {
|
|
1488
|
-
// exchange not configured
|
|
1489
|
-
}
|
|
1477
|
+
const fundingVenues = await listArbVenues();
|
|
1478
|
+
for (const { exchange, position: p } of await readArbVenuePositions(fundingVenues, getAdapter)) {
|
|
1479
|
+
fundingPositions.push({
|
|
1480
|
+
exchange,
|
|
1481
|
+
symbol: p.symbol.replace("-PERP", "").toUpperCase(),
|
|
1482
|
+
side: p.side,
|
|
1483
|
+
size: Math.abs(Number(p.size)),
|
|
1484
|
+
markPrice: Number(p.markPrice),
|
|
1485
|
+
});
|
|
1490
1486
|
}
|
|
1491
1487
|
// Identify arb pairs (long on one exchange, short on another)
|
|
1492
1488
|
const arbPairKeys = new Set();
|
|
@@ -1508,7 +1504,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
1508
1504
|
}
|
|
1509
1505
|
const allPayments = [];
|
|
1510
1506
|
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
|
|
1511
|
-
for (const exName of
|
|
1507
|
+
for (const exName of fundingVenues) {
|
|
1512
1508
|
try {
|
|
1513
1509
|
const adapter = await getAdapter(exName);
|
|
1514
1510
|
const payments = await adapter.getFundingPayments(200);
|
|
@@ -1526,8 +1522,9 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
1526
1522
|
});
|
|
1527
1523
|
}
|
|
1528
1524
|
}
|
|
1529
|
-
catch {
|
|
1530
|
-
//
|
|
1525
|
+
catch (e) {
|
|
1526
|
+
// Reported, not hidden: this venue's payments are missing from the totals below.
|
|
1527
|
+
process.stderr.write(`[warn] ${exName} funding payments unavailable (left out of the totals): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1531
1528
|
}
|
|
1532
1529
|
}
|
|
1533
1530
|
// Group by symbol, then by exchange
|