perp-cli 0.3.3
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/LICENSE +21 -0
- package/README.md +293 -0
- package/dist/__tests__/alert-logic.test.d.ts +1 -0
- package/dist/__tests__/alert-logic.test.js +107 -0
- package/dist/__tests__/arb-auto-3dex.test.d.ts +1 -0
- package/dist/__tests__/arb-auto-3dex.test.js +397 -0
- package/dist/__tests__/arb-history-stats.test.d.ts +1 -0
- package/dist/__tests__/arb-history-stats.test.js +176 -0
- package/dist/__tests__/arb-logic.test.d.ts +1 -0
- package/dist/__tests__/arb-logic.test.js +84 -0
- package/dist/__tests__/arb-manage.test.d.ts +1 -0
- package/dist/__tests__/arb-manage.test.js +253 -0
- package/dist/__tests__/arb-new-features.test.d.ts +1 -0
- package/dist/__tests__/arb-new-features.test.js +457 -0
- package/dist/__tests__/arb-sizing.test.d.ts +1 -0
- package/dist/__tests__/arb-sizing.test.js +48 -0
- package/dist/__tests__/arb-state.test.d.ts +1 -0
- package/dist/__tests__/arb-state.test.js +284 -0
- package/dist/__tests__/arb-userflow.test.d.ts +1 -0
- package/dist/__tests__/arb-userflow.test.js +945 -0
- package/dist/__tests__/arb-utils.test.d.ts +1 -0
- package/dist/__tests__/arb-utils.test.js +264 -0
- package/dist/__tests__/bot-conditions.test.d.ts +1 -0
- package/dist/__tests__/bot-conditions.test.js +341 -0
- package/dist/__tests__/client-id-tracker.test.d.ts +1 -0
- package/dist/__tests__/client-id-tracker.test.js +137 -0
- package/dist/__tests__/commands/new-atomic-commands.test.d.ts +1 -0
- package/dist/__tests__/commands/new-atomic-commands.test.js +502 -0
- package/dist/__tests__/commands/order-intent.test.d.ts +1 -0
- package/dist/__tests__/commands/order-intent.test.js +600 -0
- package/dist/__tests__/commands/trade-commands.test.d.ts +1 -0
- package/dist/__tests__/commands/trade-commands.test.js +821 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +86 -0
- package/dist/__tests__/cross-chain-margin.test.d.ts +1 -0
- package/dist/__tests__/cross-chain-margin.test.js +287 -0
- package/dist/__tests__/dex-asset-map.test.d.ts +1 -0
- package/dist/__tests__/dex-asset-map.test.js +191 -0
- package/dist/__tests__/errors.test.d.ts +1 -0
- package/dist/__tests__/errors.test.js +110 -0
- package/dist/__tests__/event-stream.test.d.ts +1 -0
- package/dist/__tests__/event-stream.test.js +276 -0
- package/dist/__tests__/exchanges/interface.test.d.ts +1 -0
- package/dist/__tests__/exchanges/interface.test.js +132 -0
- package/dist/__tests__/exchanges/mock-adapter.d.ts +69 -0
- package/dist/__tests__/exchanges/mock-adapter.js +137 -0
- package/dist/__tests__/execution-log.test.d.ts +1 -0
- package/dist/__tests__/execution-log.test.js +106 -0
- package/dist/__tests__/funding-calc.test.d.ts +1 -0
- package/dist/__tests__/funding-calc.test.js +71 -0
- package/dist/__tests__/funding-history.test.d.ts +1 -0
- package/dist/__tests__/funding-history.test.js +343 -0
- package/dist/__tests__/funding-rates.test.d.ts +1 -0
- package/dist/__tests__/funding-rates.test.js +342 -0
- package/dist/__tests__/funding.test.d.ts +1 -0
- package/dist/__tests__/funding.test.js +173 -0
- package/dist/__tests__/gap-logic.test.d.ts +1 -0
- package/dist/__tests__/gap-logic.test.js +43 -0
- package/dist/__tests__/hip3-dex.test.d.ts +1 -0
- package/dist/__tests__/hip3-dex.test.js +234 -0
- package/dist/__tests__/integration/agent-features.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/agent-features.integration.test.js +553 -0
- package/dist/__tests__/integration/atomic-commands.integration.test.d.ts +13 -0
- package/dist/__tests__/integration/atomic-commands.integration.test.js +246 -0
- package/dist/__tests__/integration/bridge-simulation.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/bridge-simulation.integration.test.js +453 -0
- package/dist/__tests__/integration/bridge-strict.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/bridge-strict.integration.test.js +812 -0
- package/dist/__tests__/integration/bridge.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/bridge.integration.test.js +309 -0
- package/dist/__tests__/integration/cli-e2e.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/cli-e2e.integration.test.js +202 -0
- package/dist/__tests__/integration/dex-arb.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/dex-arb.integration.test.js +116 -0
- package/dist/__tests__/integration/envelope-consistency.integration.test.d.ts +13 -0
- package/dist/__tests__/integration/envelope-consistency.integration.test.js +205 -0
- package/dist/__tests__/integration/hip3-dex.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/hip3-dex.integration.test.js +147 -0
- package/dist/__tests__/integration/hyperliquid.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/hyperliquid.integration.test.js +79 -0
- package/dist/__tests__/integration/lighter.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/lighter.integration.test.js +53 -0
- package/dist/__tests__/integration/new-commands-e2e.integration.test.d.ts +9 -0
- package/dist/__tests__/integration/new-commands-e2e.integration.test.js +236 -0
- package/dist/__tests__/integration/order-verification.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/order-verification.integration.test.js +321 -0
- package/dist/__tests__/integration/pacifica.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/pacifica.integration.test.js +75 -0
- package/dist/__tests__/integration/response-shapes.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/response-shapes.integration.test.js +278 -0
- package/dist/__tests__/liquidity.test.d.ts +1 -0
- package/dist/__tests__/liquidity.test.js +225 -0
- package/dist/__tests__/plan-executor.test.d.ts +1 -0
- package/dist/__tests__/plan-executor.test.js +314 -0
- package/dist/__tests__/position-history.test.d.ts +1 -0
- package/dist/__tests__/position-history.test.js +367 -0
- package/dist/__tests__/retry.test.d.ts +1 -0
- package/dist/__tests__/retry.test.js +310 -0
- package/dist/__tests__/risk-assessment.test.d.ts +1 -0
- package/dist/__tests__/risk-assessment.test.js +145 -0
- package/dist/__tests__/security-adversarial.test.d.ts +1 -0
- package/dist/__tests__/security-adversarial.test.js +574 -0
- package/dist/__tests__/strategies.test.d.ts +1 -0
- package/dist/__tests__/strategies.test.js +539 -0
- package/dist/__tests__/trade-execution.test.d.ts +1 -0
- package/dist/__tests__/trade-execution.test.js +129 -0
- package/dist/__tests__/trade-validator.test.d.ts +1 -0
- package/dist/__tests__/trade-validator.test.js +655 -0
- package/dist/__tests__/utils.test.d.ts +1 -0
- package/dist/__tests__/utils.test.js +76 -0
- package/dist/api/public/hyperliquid.d.ts +18 -0
- package/dist/api/public/hyperliquid.js +82 -0
- package/dist/api/public/index.d.ts +8 -0
- package/dist/api/public/index.js +8 -0
- package/dist/api/public/lighter.d.ts +24 -0
- package/dist/api/public/lighter.js +100 -0
- package/dist/api/public/pacifica.d.ts +17 -0
- package/dist/api/public/pacifica.js +54 -0
- package/dist/api/public/urls.d.ts +12 -0
- package/dist/api/public/urls.js +33 -0
- package/dist/arb/history-stats.d.ts +44 -0
- package/dist/arb/history-stats.js +135 -0
- package/dist/arb/index.d.ts +4 -0
- package/dist/arb/index.js +4 -0
- package/dist/arb/sizing.d.ts +23 -0
- package/dist/arb/sizing.js +96 -0
- package/dist/arb/state.d.ts +51 -0
- package/dist/arb/state.js +112 -0
- package/dist/arb/utils.d.ts +81 -0
- package/dist/arb/utils.js +267 -0
- package/dist/arb-history-stats.d.ts +5 -0
- package/dist/arb-history-stats.js +5 -0
- package/dist/arb-sizing.d.ts +5 -0
- package/dist/arb-sizing.js +5 -0
- package/dist/arb-state.d.ts +5 -0
- package/dist/arb-state.js +5 -0
- package/dist/arb-utils.d.ts +5 -0
- package/dist/arb-utils.js +5 -0
- package/dist/bot/conditions.d.ts +32 -0
- package/dist/bot/conditions.js +141 -0
- package/dist/bot/config.d.ts +76 -0
- package/dist/bot/config.js +160 -0
- package/dist/bot/engine.d.ts +8 -0
- package/dist/bot/engine.js +519 -0
- package/dist/bot/presets.d.ts +11 -0
- package/dist/bot/presets.js +296 -0
- package/dist/bridge-engine.d.ts +133 -0
- package/dist/bridge-engine.js +1487 -0
- package/dist/cache.d.ts +25 -0
- package/dist/cache.js +99 -0
- package/dist/cli-spec.d.ts +50 -0
- package/dist/cli-spec.js +75 -0
- package/dist/client-id-tracker.d.ts +25 -0
- package/dist/client-id-tracker.js +76 -0
- package/dist/commands/account.d.ts +3 -0
- package/dist/commands/account.js +425 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.js +386 -0
- package/dist/commands/alert.d.ts +2 -0
- package/dist/commands/alert.js +421 -0
- package/dist/commands/analytics.d.ts +3 -0
- package/dist/commands/analytics.js +311 -0
- package/dist/commands/arb/index.d.ts +3 -0
- package/dist/commands/arb/index.js +921 -0
- package/dist/commands/arb-auto.d.ts +54 -0
- package/dist/commands/arb-auto.js +1328 -0
- package/dist/commands/arb-manage.d.ts +5 -0
- package/dist/commands/arb-manage.js +5 -0
- package/dist/commands/arb.d.ts +2 -0
- package/dist/commands/arb.js +347 -0
- package/dist/commands/backtest.d.ts +2 -0
- package/dist/commands/backtest.js +327 -0
- package/dist/commands/bot.d.ts +3 -0
- package/dist/commands/bot.js +412 -0
- package/dist/commands/bridge.d.ts +2 -0
- package/dist/commands/bridge.js +396 -0
- package/dist/commands/dashboard.d.ts +3 -0
- package/dist/commands/dashboard.js +176 -0
- package/dist/commands/deposit.d.ts +4 -0
- package/dist/commands/deposit.js +573 -0
- package/dist/commands/dex.d.ts +3 -0
- package/dist/commands/dex.js +114 -0
- package/dist/commands/env.d.ts +2 -0
- package/dist/commands/env.js +136 -0
- package/dist/commands/funding.d.ts +2 -0
- package/dist/commands/funding.js +347 -0
- package/dist/commands/gap.d.ts +2 -0
- package/dist/commands/gap.js +305 -0
- package/dist/commands/health.d.ts +2 -0
- package/dist/commands/health.js +67 -0
- package/dist/commands/history.d.ts +2 -0
- package/dist/commands/history.js +235 -0
- package/dist/commands/init.d.ts +15 -0
- package/dist/commands/init.js +266 -0
- package/dist/commands/jobs.d.ts +2 -0
- package/dist/commands/jobs.js +133 -0
- package/dist/commands/manage.d.ts +4 -0
- package/dist/commands/manage.js +309 -0
- package/dist/commands/market.d.ts +3 -0
- package/dist/commands/market.js +225 -0
- package/dist/commands/plan.d.ts +3 -0
- package/dist/commands/plan.js +95 -0
- package/dist/commands/portfolio.d.ts +3 -0
- package/dist/commands/portfolio.js +169 -0
- package/dist/commands/rebalance.d.ts +3 -0
- package/dist/commands/rebalance.js +293 -0
- package/dist/commands/risk.d.ts +3 -0
- package/dist/commands/risk.js +169 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +202 -0
- package/dist/commands/settings.d.ts +2 -0
- package/dist/commands/settings.js +102 -0
- package/dist/commands/stream.d.ts +5 -0
- package/dist/commands/stream.js +123 -0
- package/dist/commands/trade.d.ts +3 -0
- package/dist/commands/trade.js +1273 -0
- package/dist/commands/wallet.d.ts +14 -0
- package/dist/commands/wallet.js +602 -0
- package/dist/commands/withdraw.d.ts +3 -0
- package/dist/commands/withdraw.js +187 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +68 -0
- package/dist/cross-chain-margin.d.ts +46 -0
- package/dist/cross-chain-margin.js +107 -0
- package/dist/dashboard/server.d.ts +80 -0
- package/dist/dashboard/server.js +340 -0
- package/dist/dashboard/ui.d.ts +4 -0
- package/dist/dashboard/ui.js +538 -0
- package/dist/dashboard/ws-feeds.d.ts +29 -0
- package/dist/dashboard/ws-feeds.js +660 -0
- package/dist/dex-asset-map.d.ts +80 -0
- package/dist/dex-asset-map.js +201 -0
- package/dist/errors.d.ts +109 -0
- package/dist/errors.js +84 -0
- package/dist/event-stream.d.ts +25 -0
- package/dist/event-stream.js +168 -0
- package/dist/exchanges/hyperliquid.d.ts +212 -0
- package/dist/exchanges/hyperliquid.js +931 -0
- package/dist/exchanges/interface.d.ts +95 -0
- package/dist/exchanges/interface.js +5 -0
- package/dist/exchanges/lighter.d.ts +159 -0
- package/dist/exchanges/lighter.js +793 -0
- package/dist/exchanges/pacifica.d.ts +51 -0
- package/dist/exchanges/pacifica.js +248 -0
- package/dist/execution-log.d.ts +36 -0
- package/dist/execution-log.js +102 -0
- package/dist/funding/history.d.ts +63 -0
- package/dist/funding/history.js +266 -0
- package/dist/funding/index.d.ts +3 -0
- package/dist/funding/index.js +3 -0
- package/dist/funding/normalize.d.ts +39 -0
- package/dist/funding/normalize.js +66 -0
- package/dist/funding/rates.d.ts +45 -0
- package/dist/funding/rates.js +172 -0
- package/dist/funding-history.d.ts +5 -0
- package/dist/funding-history.js +5 -0
- package/dist/funding-rates.d.ts +5 -0
- package/dist/funding-rates.js +5 -0
- package/dist/funding.d.ts +5 -0
- package/dist/funding.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +458 -0
- package/dist/jobs.d.ts +37 -0
- package/dist/jobs.js +152 -0
- package/dist/liquidity.d.ts +34 -0
- package/dist/liquidity.js +100 -0
- package/dist/mcp-server.d.ts +9 -0
- package/dist/mcp-server.js +1206 -0
- package/dist/pacifica/client.d.ts +111 -0
- package/dist/pacifica/client.js +310 -0
- package/dist/pacifica/constants.d.ts +27 -0
- package/dist/pacifica/constants.js +47 -0
- package/dist/pacifica/deposit.d.ts +14 -0
- package/dist/pacifica/deposit.js +78 -0
- package/dist/pacifica/index.d.ts +6 -0
- package/dist/pacifica/index.js +11 -0
- package/dist/pacifica/signing.d.ts +49 -0
- package/dist/pacifica/signing.js +97 -0
- package/dist/pacifica/types/account.d.ts +42 -0
- package/dist/pacifica/types/account.js +1 -0
- package/dist/pacifica/types/index.d.ts +6 -0
- package/dist/pacifica/types/index.js +6 -0
- package/dist/pacifica/types/lake.d.ts +18 -0
- package/dist/pacifica/types/lake.js +1 -0
- package/dist/pacifica/types/market.d.ts +64 -0
- package/dist/pacifica/types/market.js +1 -0
- package/dist/pacifica/types/order.d.ts +92 -0
- package/dist/pacifica/types/order.js +1 -0
- package/dist/pacifica/types/position.d.ts +25 -0
- package/dist/pacifica/types/position.js +1 -0
- package/dist/pacifica/types/ws.d.ts +34 -0
- package/dist/pacifica/types/ws.js +41 -0
- package/dist/pacifica/ws-client.d.ts +42 -0
- package/dist/pacifica/ws-client.js +180 -0
- package/dist/plan-executor.d.ts +48 -0
- package/dist/plan-executor.js +280 -0
- package/dist/position-history.d.ts +68 -0
- package/dist/position-history.js +222 -0
- package/dist/rebalance.d.ts +64 -0
- package/dist/rebalance.js +142 -0
- package/dist/retry.d.ts +74 -0
- package/dist/retry.js +129 -0
- package/dist/risk.d.ts +48 -0
- package/dist/risk.js +156 -0
- package/dist/settings.d.ts +19 -0
- package/dist/settings.js +45 -0
- package/dist/shared-api.d.ts +5 -0
- package/dist/shared-api.js +5 -0
- package/dist/strategies/dca.d.ts +25 -0
- package/dist/strategies/dca.js +114 -0
- package/dist/strategies/funding-arb.d.ts +15 -0
- package/dist/strategies/funding-arb.js +281 -0
- package/dist/strategies/grid.d.ts +34 -0
- package/dist/strategies/grid.js +185 -0
- package/dist/strategies/trailing-stop.d.ts +17 -0
- package/dist/strategies/trailing-stop.js +121 -0
- package/dist/strategies/twap.d.ts +20 -0
- package/dist/strategies/twap.js +78 -0
- package/dist/trade-validator.d.ts +39 -0
- package/dist/trade-validator.js +154 -0
- package/dist/utils.d.ts +38 -0
- package/dist/utils.js +110 -0
- package/package.json +63 -0
- package/skills/perp-cli/SKILL.md +149 -0
- package/skills/perp-cli/references/commands.md +143 -0
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { formatUsd, printJson, jsonOk } from "../utils.js";
|
|
3
|
+
import { computeAnnualSpread, toHourlyRate } from "../funding.js";
|
|
4
|
+
import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
|
|
5
|
+
import { scanDexArb } from "../dex-asset-map.js";
|
|
6
|
+
import { logExecution, readExecutionLog } from "../execution-log.js";
|
|
7
|
+
import { aggressiveSettleBoost, estimateFundingUntilSettlement, computeBasisRisk, notifyIfEnabled, } from "../arb-utils.js";
|
|
8
|
+
import { checkChainMargins, isCriticalMargin, shouldBlockEntries, computeAutoSize, } from "../cross-chain-margin.js";
|
|
9
|
+
import { loadArbState, saveArbState, createInitialState, } from "../arb-state.js";
|
|
10
|
+
// ── Fee-Adjusted Net Spread Calculation ──
|
|
11
|
+
/** Default taker fee per exchange (as fraction, e.g. 0.00035 = 0.035%) */
|
|
12
|
+
const TAKER_FEES = {
|
|
13
|
+
hyperliquid: 0.00035,
|
|
14
|
+
pacifica: 0.00035,
|
|
15
|
+
lighter: 0.00035,
|
|
16
|
+
};
|
|
17
|
+
function getTakerFee(exchange) {
|
|
18
|
+
return TAKER_FEES[exchange.toLowerCase()] ?? 0.00035;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Compute the estimated round-trip cost as a percentage of notional.
|
|
22
|
+
* Round-trip = 4 × taker fee + 2 × slippage (entry + exit for both legs).
|
|
23
|
+
*/
|
|
24
|
+
export function computeRoundTripCostPct(longExchange, shortExchange, slippagePct = 0.05) {
|
|
25
|
+
const longFee = getTakerFee(longExchange) * 100; // convert to pct
|
|
26
|
+
const shortFee = getTakerFee(shortExchange) * 100;
|
|
27
|
+
// Entry: long taker + short taker + slippage on each
|
|
28
|
+
// Exit: long taker + short taker + slippage on each
|
|
29
|
+
return 2 * (longFee + shortFee) + 2 * slippagePct;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Compute net annualized spread after deducting round-trip costs amortized over hold period.
|
|
33
|
+
*
|
|
34
|
+
* Net = grossAnnualPct - (roundTripCostPct / holdDays * 365) - (bridgeCostPct annualized)
|
|
35
|
+
*
|
|
36
|
+
* @param grossAnnualPct - Gross annual spread in %
|
|
37
|
+
* @param holdDays - Expected holding period in days for cost amortization
|
|
38
|
+
* @param roundTripCostPct - Total round-trip cost as % of notional
|
|
39
|
+
* @param bridgeCostUsd - One-way bridge cost in USD (doubled for round-trip)
|
|
40
|
+
* @param positionSizeUsd - Position size per leg in USD (for bridge cost %)
|
|
41
|
+
*/
|
|
42
|
+
export function computeNetSpread(grossAnnualPct, holdDays, roundTripCostPct, bridgeCostUsd = 0, positionSizeUsd = 0) {
|
|
43
|
+
const annualizedCostPct = (roundTripCostPct / holdDays) * 365;
|
|
44
|
+
let bridgeCostAnnualPct = 0;
|
|
45
|
+
if (bridgeCostUsd > 0 && positionSizeUsd > 0) {
|
|
46
|
+
const bridgeRoundTripPct = (bridgeCostUsd * 2 / positionSizeUsd) * 100;
|
|
47
|
+
bridgeCostAnnualPct = (bridgeRoundTripPct / holdDays) * 365;
|
|
48
|
+
}
|
|
49
|
+
return grossAnnualPct - annualizedCostPct - bridgeCostAnnualPct;
|
|
50
|
+
}
|
|
51
|
+
// ── Funding Settlement Timing ──
|
|
52
|
+
/** Settlement schedules per exchange (UTC hours when settlement occurs) */
|
|
53
|
+
const SETTLEMENT_SCHEDULES = {
|
|
54
|
+
hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
|
|
55
|
+
pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
|
|
56
|
+
lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Get the next settlement time for an exchange.
|
|
60
|
+
* @returns Date of next settlement
|
|
61
|
+
*/
|
|
62
|
+
export function getNextSettlement(exchange, now = new Date()) {
|
|
63
|
+
const schedule = SETTLEMENT_SCHEDULES[exchange.toLowerCase()];
|
|
64
|
+
if (!schedule || schedule.length === 0) {
|
|
65
|
+
// Default: every hour
|
|
66
|
+
return getNextSettlement("pacifica", now);
|
|
67
|
+
}
|
|
68
|
+
const currentHour = now.getUTCHours();
|
|
69
|
+
const currentMinutes = now.getUTCMinutes();
|
|
70
|
+
const currentSeconds = now.getUTCSeconds();
|
|
71
|
+
// Find the next settlement hour strictly in the future
|
|
72
|
+
// A settlement at the current hour is "next" only if we haven't reached it yet (min=0, sec=0)
|
|
73
|
+
for (const hour of schedule) {
|
|
74
|
+
if (hour > currentHour || (hour === currentHour && currentMinutes === 0 && currentSeconds === 0)) {
|
|
75
|
+
// This settlement is still in the future (or exactly now)
|
|
76
|
+
// But skip if hour === currentHour and we're past minute 0
|
|
77
|
+
if (hour === currentHour && (currentMinutes > 0 || currentSeconds > 0))
|
|
78
|
+
continue;
|
|
79
|
+
const next = new Date(now);
|
|
80
|
+
next.setUTCHours(hour, 0, 0, 0);
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Wrap to next day's first settlement
|
|
85
|
+
const next = new Date(now);
|
|
86
|
+
next.setUTCDate(next.getUTCDate() + 1);
|
|
87
|
+
next.setUTCHours(schedule[0], 0, 0, 0);
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Check if we are within N minutes before a settlement event on either exchange.
|
|
92
|
+
* If so, we should avoid entering positions (rates may change).
|
|
93
|
+
*/
|
|
94
|
+
export function isNearSettlement(longExchange, shortExchange, bufferMinutes = 5, now = new Date()) {
|
|
95
|
+
for (const exch of [longExchange, shortExchange]) {
|
|
96
|
+
const nextSettle = getNextSettlement(exch, now);
|
|
97
|
+
const minutesUntil = (nextSettle.getTime() - now.getTime()) / (1000 * 60);
|
|
98
|
+
if (minutesUntil <= bufferMinutes && minutesUntil >= 0) {
|
|
99
|
+
return { blocked: true, exchange: exch, minutesUntil };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { blocked: false };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Detect if the funding spread has reversed direction for an open position.
|
|
106
|
+
* A reversal means the long exchange now has a HIGHER hourly rate than the short exchange,
|
|
107
|
+
* meaning we're now paying on both sides instead of collecting.
|
|
108
|
+
*/
|
|
109
|
+
export function isSpreadReversed(longExchange, shortExchange, snapshot) {
|
|
110
|
+
const rateFor = (e) => e === "pacifica" ? snapshot.pacRate : e === "hyperliquid" ? snapshot.hlRate : snapshot.ltRate;
|
|
111
|
+
const longHourly = toHourlyRate(rateFor(longExchange), longExchange);
|
|
112
|
+
const shortHourly = toHourlyRate(rateFor(shortExchange), shortExchange);
|
|
113
|
+
// Reversed if the long side rate exceeds the short side rate
|
|
114
|
+
return longHourly > shortHourly;
|
|
115
|
+
}
|
|
116
|
+
async function fetchFundingSpreads() {
|
|
117
|
+
const [pacRes, hlRes, ltDetailsRes, ltFundingRes] = await Promise.all([
|
|
118
|
+
fetchPacificaPricesRaw(),
|
|
119
|
+
fetchHyperliquidMetaRaw(),
|
|
120
|
+
fetchLighterOrderBookDetailsRaw(),
|
|
121
|
+
fetchLighterFundingRatesRaw(),
|
|
122
|
+
]);
|
|
123
|
+
const { rates: pacRates, prices: pacPrices } = parsePacificaRaw(pacRes);
|
|
124
|
+
const { rates: hlRates, prices: hlPrices } = parseHyperliquidMetaRaw(hlRes);
|
|
125
|
+
const { rates: ltRates, prices: ltPrices } = parseLighterRaw(ltDetailsRes, ltFundingRes);
|
|
126
|
+
const snapshots = [];
|
|
127
|
+
const allSymbols = new Set([...pacRates.keys(), ...hlRates.keys(), ...ltRates.keys()]);
|
|
128
|
+
for (const sym of allSymbols) {
|
|
129
|
+
const pac = pacRates.get(sym);
|
|
130
|
+
const hl = hlRates.get(sym);
|
|
131
|
+
const lt = ltRates.get(sym);
|
|
132
|
+
// Need at least 2 exchanges
|
|
133
|
+
const available = [];
|
|
134
|
+
if (pac !== undefined)
|
|
135
|
+
available.push({ exchange: "pacifica", rate: pac });
|
|
136
|
+
if (hl !== undefined)
|
|
137
|
+
available.push({ exchange: "hyperliquid", rate: hl });
|
|
138
|
+
if (lt !== undefined)
|
|
139
|
+
available.push({ exchange: "lighter", rate: lt });
|
|
140
|
+
if (available.length < 2)
|
|
141
|
+
continue;
|
|
142
|
+
const norm = (r) => toHourlyRate(r.rate, r.exchange);
|
|
143
|
+
available.sort((a, b) => norm(a) - norm(b));
|
|
144
|
+
const lowest = available[0];
|
|
145
|
+
const highest = available[available.length - 1];
|
|
146
|
+
const spread = computeAnnualSpread(highest.rate, highest.exchange, lowest.rate, lowest.exchange);
|
|
147
|
+
// Use best available mark price (prefer HL as most liquid, then PAC, then LT)
|
|
148
|
+
const markPrice = hlPrices.get(sym) ?? pacPrices.get(sym) ?? ltPrices.get(sym) ?? 0;
|
|
149
|
+
snapshots.push({
|
|
150
|
+
symbol: sym,
|
|
151
|
+
pacRate: pac ?? 0,
|
|
152
|
+
hlRate: hl ?? 0,
|
|
153
|
+
ltRate: lt ?? 0,
|
|
154
|
+
spread,
|
|
155
|
+
longExch: lowest.exchange, // long where funding is lowest
|
|
156
|
+
shortExch: highest.exchange, // short where funding is highest
|
|
157
|
+
markPrice,
|
|
158
|
+
pacMarkPrice: pacPrices.get(sym) ?? 0,
|
|
159
|
+
hlMarkPrice: hlPrices.get(sym) ?? 0,
|
|
160
|
+
ltMarkPrice: ltPrices.get(sym) ?? 0,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return snapshots.sort((a, b) => Math.abs(b.spread) - Math.abs(a.spread));
|
|
164
|
+
}
|
|
165
|
+
export function registerArbAutoCommands(program, getAdapterForExchange, isJson, getHLAdapterForDex) {
|
|
166
|
+
const arb = program.commands.find(c => c.name() === "arb");
|
|
167
|
+
if (!arb)
|
|
168
|
+
return;
|
|
169
|
+
// ── arb auto ── (daemon mode)
|
|
170
|
+
arb
|
|
171
|
+
.command("auto")
|
|
172
|
+
.description("Auto-execute funding rate arbitrage (daemon)")
|
|
173
|
+
.option("--min-spread <pct>", "Min annual spread to enter (%)", "30")
|
|
174
|
+
.option("--close-spread <pct>", "Close when spread drops below (%)", "5")
|
|
175
|
+
.option("--size <usd>", "Position size per leg ($)", "100")
|
|
176
|
+
.option("--min-size <usd>", "Min position size floor for auto-sizing ($ notional)", "30")
|
|
177
|
+
.option("--max-positions <n>", "Max simultaneous arb positions", "5")
|
|
178
|
+
.option("--symbols <list>", "Comma-separated symbols to monitor (default: all)")
|
|
179
|
+
.option("--interval <seconds>", "Check interval", "60")
|
|
180
|
+
.option("--hold-days <days>", "Expected hold period for cost amortization", "7")
|
|
181
|
+
.option("--bridge-cost <usd>", "One-way bridge cost in USD", "0.5")
|
|
182
|
+
.option("--no-reversal-exit", "Disable emergency exit on spread reversal")
|
|
183
|
+
.option("--settle-aware", "Avoid entries near funding settlement (default: true)")
|
|
184
|
+
.option("--no-settle-aware", "Disable settlement timing awareness")
|
|
185
|
+
.option("--min-margin <pct>", "Warn/block when margin ratio drops below this %", "30")
|
|
186
|
+
.option("--settle-strategy <mode>", "Settlement timing: block (default), aggressive, off", "block")
|
|
187
|
+
.option("--max-basis <pct>", "Max basis risk (mark price divergence %)", "3")
|
|
188
|
+
.option("--notify <url>", "Webhook URL for notifications (Discord/Telegram/generic)")
|
|
189
|
+
.option("--notify-events <events>", "Comma-separated events: entry,exit,reversal,margin,basis", "entry,exit,reversal,margin,basis")
|
|
190
|
+
.option("--dry-run", "Simulate without executing trades")
|
|
191
|
+
.option("--background", "Run in background (tmux)")
|
|
192
|
+
.action(async (opts) => {
|
|
193
|
+
if (opts.background) {
|
|
194
|
+
const { startJob } = await import("../jobs.js");
|
|
195
|
+
const cliArgs = [
|
|
196
|
+
`--min-spread`, opts.minSpread,
|
|
197
|
+
`--close-spread`, opts.closeSpread,
|
|
198
|
+
`--size`, opts.size,
|
|
199
|
+
`--max-positions`, opts.maxPositions,
|
|
200
|
+
`--interval`, opts.interval,
|
|
201
|
+
`--hold-days`, opts.holdDays,
|
|
202
|
+
`--bridge-cost`, opts.bridgeCost,
|
|
203
|
+
`--min-margin`, opts.minMargin,
|
|
204
|
+
...(opts.symbols ? [`--symbols`, opts.symbols] : []),
|
|
205
|
+
...(opts.dryRun ? [`--dry-run`] : []),
|
|
206
|
+
...(opts.reversalExit === false ? [`--no-reversal-exit`] : []),
|
|
207
|
+
...(opts.settleAware === false ? [`--no-settle-aware`] : []),
|
|
208
|
+
...(opts.minSpread ? [`--auto-execute`] : []),
|
|
209
|
+
];
|
|
210
|
+
const job = startJob({
|
|
211
|
+
strategy: "funding-arb",
|
|
212
|
+
exchange: "multi",
|
|
213
|
+
params: { ...opts },
|
|
214
|
+
cliArgs,
|
|
215
|
+
});
|
|
216
|
+
if (isJson())
|
|
217
|
+
return printJson(jsonOk(job));
|
|
218
|
+
console.log(chalk.green(`\n Funding arb bot started in background.`));
|
|
219
|
+
console.log(` ID: ${chalk.white.bold(job.id)}`);
|
|
220
|
+
console.log(` Min spread: ${opts.minSpread}% | Size: $${opts.size}`);
|
|
221
|
+
console.log(` Logs: ${chalk.gray(`perp jobs logs ${job.id}`)}`);
|
|
222
|
+
console.log(` Stop: ${chalk.gray(`perp jobs stop ${job.id}`)}\n`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const minSpread = parseFloat(opts.minSpread);
|
|
226
|
+
const closeSpread = parseFloat(opts.closeSpread);
|
|
227
|
+
const sizeIsAuto = opts.size.toLowerCase() === "auto";
|
|
228
|
+
const sizeUsd = sizeIsAuto ? 0 : parseFloat(opts.size);
|
|
229
|
+
const maxPositions = parseInt(opts.maxPositions);
|
|
230
|
+
const intervalMs = parseInt(opts.interval) * 1000;
|
|
231
|
+
const holdDays = parseFloat(opts.holdDays);
|
|
232
|
+
const bridgeCostUsd = parseFloat(opts.bridgeCost);
|
|
233
|
+
const reversalExitEnabled = opts.reversalExit !== false;
|
|
234
|
+
const settleAwareEnabled = opts.settleAware !== false;
|
|
235
|
+
const settleStrategy = (opts.settleStrategy || "block");
|
|
236
|
+
const maxBasisPct = parseFloat(opts.maxBasis);
|
|
237
|
+
const webhookUrl = opts.notify;
|
|
238
|
+
const notifyEvents = opts.notifyEvents
|
|
239
|
+
.split(",").map(e => e.trim()).filter(Boolean);
|
|
240
|
+
const minMarginPct = parseFloat(opts.minMargin);
|
|
241
|
+
const minSizeUsd = parseFloat(opts.minSize);
|
|
242
|
+
const filterSymbols = opts.symbols?.split(",").map(s => s.trim().toUpperCase());
|
|
243
|
+
const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
|
|
244
|
+
const openPositions = [];
|
|
245
|
+
// Track which exchanges have low margin (block entries)
|
|
246
|
+
const blockedExchanges = new Set();
|
|
247
|
+
// -- State Persistence: Initialize or recover --
|
|
248
|
+
const daemonConfig = {
|
|
249
|
+
minSpread,
|
|
250
|
+
closeSpread,
|
|
251
|
+
size: (typeof sizeIsAuto !== "undefined" && sizeIsAuto ? "auto" : sizeUsd),
|
|
252
|
+
holdDays,
|
|
253
|
+
bridgeCost: bridgeCostUsd,
|
|
254
|
+
maxPositions,
|
|
255
|
+
settleStrategy: settleAwareEnabled ? "aware" : "disabled",
|
|
256
|
+
};
|
|
257
|
+
let daemonState = loadArbState();
|
|
258
|
+
if (daemonState && daemonState.positions.length > 0) {
|
|
259
|
+
// Crash recovery: restore positions from persisted state
|
|
260
|
+
for (const persisted of daemonState.positions) {
|
|
261
|
+
openPositions.push({
|
|
262
|
+
symbol: persisted.symbol,
|
|
263
|
+
longExchange: persisted.longExchange,
|
|
264
|
+
shortExchange: persisted.shortExchange,
|
|
265
|
+
size: String(persisted.longSize),
|
|
266
|
+
entrySpread: persisted.entrySpread,
|
|
267
|
+
entryTime: persisted.entryTime,
|
|
268
|
+
entryMarkPrice: persisted.entryLongPrice,
|
|
269
|
+
accumulatedFundingUsd: persisted.accumulatedFunding,
|
|
270
|
+
lastCheckTime: new Date(persisted.lastCheckTime).getTime(),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
daemonState.lastStartTime = new Date().toISOString();
|
|
274
|
+
saveArbState(daemonState);
|
|
275
|
+
if (!isJson()) {
|
|
276
|
+
console.log(chalk.yellow(` Recovered ${openPositions.length} position(s) from previous session.`));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
daemonState = createInitialState(daemonConfig);
|
|
281
|
+
saveArbState(daemonState);
|
|
282
|
+
}
|
|
283
|
+
// SIGINT handler: save final state before exit
|
|
284
|
+
const handleSigint = () => {
|
|
285
|
+
const finalState = loadArbState();
|
|
286
|
+
if (finalState) {
|
|
287
|
+
finalState.lastScanTime = new Date().toISOString();
|
|
288
|
+
saveArbState(finalState);
|
|
289
|
+
}
|
|
290
|
+
if (!isJson())
|
|
291
|
+
console.log(chalk.yellow("\n Daemon stopped. State saved.\n"));
|
|
292
|
+
process.exit(0);
|
|
293
|
+
};
|
|
294
|
+
process.on("SIGINT", handleSigint);
|
|
295
|
+
if (!isJson()) {
|
|
296
|
+
console.log(chalk.cyan.bold("\n Funding Rate Arb Bot\n"));
|
|
297
|
+
console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
|
|
298
|
+
console.log(` Enter spread: >= ${minSpread}% annual (net, after fees)`);
|
|
299
|
+
console.log(` Close spread: <= ${closeSpread}% annual`);
|
|
300
|
+
console.log(` Size per leg: ${sizeIsAuto ? chalk.cyan("auto (dynamic)") : `$${sizeUsd}`}`);
|
|
301
|
+
if (sizeIsAuto)
|
|
302
|
+
console.log(` Min size: $${minSizeUsd} (floor for auto)`);
|
|
303
|
+
console.log(` Max positions: ${maxPositions}`);
|
|
304
|
+
console.log(` Hold period: ${holdDays} days (cost amortization)`);
|
|
305
|
+
console.log(` Bridge cost: $${bridgeCostUsd} per transfer`);
|
|
306
|
+
console.log(` Min margin: ${minMarginPct}% (block entries below this)`);
|
|
307
|
+
console.log(` Max basis: ${maxBasisPct}% (warn on price divergence)`);
|
|
308
|
+
console.log(` Reversal exit: ${reversalExitEnabled ? chalk.green("ON") : chalk.yellow("OFF")}`);
|
|
309
|
+
console.log(` Settle strat: ${settleStrategy === "aggressive" ? chalk.cyan("AGGRESSIVE") : settleStrategy === "off" ? chalk.yellow("OFF") : chalk.green("BLOCK")}`);
|
|
310
|
+
console.log(` Notifications: ${webhookUrl ? chalk.green("ON") : chalk.gray("OFF")}${webhookUrl ? ` (${notifyEvents.join(",")})` : ""}`);
|
|
311
|
+
console.log(` Symbols: ${filterSymbols?.join(", ") || "all"}`);
|
|
312
|
+
console.log(` Interval: ${opts.interval}s`);
|
|
313
|
+
console.log(chalk.gray("\n Monitoring... (Ctrl+C to stop)\n"));
|
|
314
|
+
}
|
|
315
|
+
const cycle = async () => {
|
|
316
|
+
// Heartbeat check
|
|
317
|
+
const heartbeatState = loadArbState();
|
|
318
|
+
if (heartbeatState?.lastSuccessfulScanTime) {
|
|
319
|
+
const lastSuccessMs = new Date(heartbeatState.lastSuccessfulScanTime).getTime();
|
|
320
|
+
const minutesSinceSuccess = (Date.now() - lastSuccessMs) / (1000 * 60);
|
|
321
|
+
if (minutesSinceSuccess > 5) {
|
|
322
|
+
console.log(chalk.yellow(` ${new Date().toLocaleTimeString()} HEARTBEAT WARNING: no successful scan for ${minutesSinceSuccess.toFixed(0)} minutes`));
|
|
323
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "heartbeat", {
|
|
324
|
+
lastScanTime: heartbeatState.lastSuccessfulScanTime,
|
|
325
|
+
minutesAgo: minutesSinceSuccess,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const spreads = await fetchFundingSpreads();
|
|
331
|
+
const filtered = filterSymbols
|
|
332
|
+
? spreads.filter(s => filterSymbols.includes(s.symbol))
|
|
333
|
+
: spreads;
|
|
334
|
+
const now = new Date().toLocaleTimeString();
|
|
335
|
+
// Check for close conditions on open positions
|
|
336
|
+
for (let i = openPositions.length - 1; i >= 0; i--) {
|
|
337
|
+
const pos = openPositions[i];
|
|
338
|
+
const current = filtered.find(s => s.symbol === pos.symbol);
|
|
339
|
+
if (!current)
|
|
340
|
+
continue;
|
|
341
|
+
const currentSpread = Math.abs(current.spread);
|
|
342
|
+
let shouldClose = false;
|
|
343
|
+
let closeReason = "";
|
|
344
|
+
// Check spread-based close
|
|
345
|
+
if (currentSpread <= closeSpread) {
|
|
346
|
+
shouldClose = true;
|
|
347
|
+
closeReason = `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
|
|
348
|
+
}
|
|
349
|
+
// Check reversal-based close
|
|
350
|
+
if (!shouldClose && reversalExitEnabled && isSpreadReversed(pos.longExchange, pos.shortExchange, current)) {
|
|
351
|
+
shouldClose = true;
|
|
352
|
+
closeReason = "REVERSAL DETECTED — long exchange now has higher rate than short";
|
|
353
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "reversal", {
|
|
354
|
+
symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Check basis risk (mark price divergence) — use prices from fetchFundingSpreads, no extra API calls
|
|
358
|
+
{
|
|
359
|
+
const priceFor = (e) => e === "pacifica" ? current.pacMarkPrice : e === "hyperliquid" ? current.hlMarkPrice : current.ltMarkPrice;
|
|
360
|
+
const bLP = priceFor(pos.longExchange);
|
|
361
|
+
const bSP = priceFor(pos.shortExchange);
|
|
362
|
+
if (bLP > 0 && bSP > 0) {
|
|
363
|
+
const basis = computeBasisRisk(bLP, bSP, maxBasisPct);
|
|
364
|
+
if (basis.warning) {
|
|
365
|
+
const bExA = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
366
|
+
console.log(chalk.yellow(` ${now} BASIS RISK ${pos.symbol}: Long ${bExA(pos.longExchange)} $${bLP.toFixed(4)} / ` +
|
|
367
|
+
`Short ${bExA(pos.shortExchange)} $${bSP.toFixed(4)} | Divergence: ${basis.divergencePct.toFixed(1)}%`));
|
|
368
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "basis", {
|
|
369
|
+
symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
|
|
370
|
+
divergencePct: basis.divergencePct,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (shouldClose) {
|
|
376
|
+
console.log(chalk.yellow(` ${now} CLOSE ${pos.symbol} — ${closeReason}`));
|
|
377
|
+
if (!dryRun) {
|
|
378
|
+
try {
|
|
379
|
+
// Close both legs
|
|
380
|
+
const longAdapter = await getAdapterForExchange(pos.longExchange);
|
|
381
|
+
const shortAdapter = await getAdapterForExchange(pos.shortExchange);
|
|
382
|
+
await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
|
|
383
|
+
await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
384
|
+
// Determine exit reason tag
|
|
385
|
+
const exitReason = closeReason.includes("REVERSAL") ? "reversal"
|
|
386
|
+
: closeReason.includes("spread") ? "spread"
|
|
387
|
+
: "manual";
|
|
388
|
+
logExecution({
|
|
389
|
+
type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
|
|
390
|
+
symbol: pos.symbol, side: "close", size: pos.size,
|
|
391
|
+
status: "success", dryRun: false,
|
|
392
|
+
meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason },
|
|
393
|
+
});
|
|
394
|
+
console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs`));
|
|
395
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
|
|
396
|
+
symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
|
|
397
|
+
pnl: pos.accumulatedFundingUsd,
|
|
398
|
+
duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
const exitReason = closeReason.includes("REVERSAL") ? "reversal"
|
|
403
|
+
: closeReason.includes("spread") ? "spread"
|
|
404
|
+
: "manual";
|
|
405
|
+
logExecution({
|
|
406
|
+
type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
|
|
407
|
+
symbol: pos.symbol, side: "close", size: pos.size,
|
|
408
|
+
status: "failed", dryRun: false,
|
|
409
|
+
error: err instanceof Error ? err.message : String(err),
|
|
410
|
+
meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
|
|
411
|
+
});
|
|
412
|
+
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${err instanceof Error ? err.message : err}`));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
openPositions.splice(i, 1);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Log next settlement times and funding estimation
|
|
419
|
+
if (!isJson() && settleStrategy !== "off") {
|
|
420
|
+
const nowDate = new Date();
|
|
421
|
+
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
422
|
+
const nextHL = getNextSettlement("hyperliquid", nowDate);
|
|
423
|
+
const nextPAC = getNextSettlement("pacifica", nowDate);
|
|
424
|
+
const nextLT = getNextSettlement("lighter", nowDate);
|
|
425
|
+
const fmtMin = (d) => Math.max(0, Math.round((d.getTime() - nowDate.getTime()) / 60000));
|
|
426
|
+
const hoursUntilPAC = (nextPAC.getTime() - nowDate.getTime()) / 3600000;
|
|
427
|
+
const hUTC = nextPAC.getUTCHours().toString().padStart(2, "0");
|
|
428
|
+
console.log(chalk.gray(` ${now} Next settlements: HL ${fmtMin(nextHL)}m | PAC ${fmtMin(nextPAC)}m | LT ${fmtMin(nextLT)}m`));
|
|
429
|
+
for (const fPos of openPositions) {
|
|
430
|
+
const fSnap = filtered.find(s => s.symbol === fPos.symbol);
|
|
431
|
+
if (!fSnap)
|
|
432
|
+
continue;
|
|
433
|
+
const fRateFor = (e) => e === "pacifica" ? fSnap.pacRate : e === "hyperliquid" ? fSnap.hlRate : fSnap.ltRate;
|
|
434
|
+
const fHlHourly = toHourlyRate(fRateFor("hyperliquid"), "hyperliquid");
|
|
435
|
+
const fPacHourly = toHourlyRate(fRateFor("pacifica"), "pacifica");
|
|
436
|
+
const fNotional = parseFloat(fPos.size) * fSnap.markPrice;
|
|
437
|
+
const fEst = estimateFundingUntilSettlement(fHlHourly, fPacHourly, fNotional, hoursUntilPAC);
|
|
438
|
+
console.log(chalk.gray(` ${now} ${fPos.symbol} Next PAC: ${hUTC}:00 UTC (${hoursUntilPAC.toFixed(1)}h) | ` +
|
|
439
|
+
`HL cum: ~$${fEst.hlCumulative.toFixed(4)} | PAC pmt: ~$${fEst.pacPayment.toFixed(4)} | ` +
|
|
440
|
+
`Net: ~$${fEst.netFunding.toFixed(4)}`));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// ── Cross-chain margin monitoring ──
|
|
444
|
+
blockedExchanges.clear();
|
|
445
|
+
// Exchange status check — infer from fetchFundingSpreads results
|
|
446
|
+
// If an exchange returned rates in the scan, it's up. No extra API calls needed.
|
|
447
|
+
const downExchanges = new Set();
|
|
448
|
+
const hasHL = filtered.some(s => s.hlRate !== 0) || spreads.some(s => s.hlRate !== 0);
|
|
449
|
+
const hasLT = filtered.some(s => s.ltRate !== 0) || spreads.some(s => s.ltRate !== 0);
|
|
450
|
+
const hasPAC = filtered.some(s => s.pacRate !== 0) || spreads.some(s => s.pacRate !== 0);
|
|
451
|
+
if (!hasHL) {
|
|
452
|
+
downExchanges.add("hyperliquid");
|
|
453
|
+
blockedExchanges.add("hyperliquid");
|
|
454
|
+
}
|
|
455
|
+
if (!hasLT) {
|
|
456
|
+
downExchanges.add("lighter");
|
|
457
|
+
blockedExchanges.add("lighter");
|
|
458
|
+
}
|
|
459
|
+
if (!hasPAC) {
|
|
460
|
+
downExchanges.add("pacifica");
|
|
461
|
+
blockedExchanges.add("pacifica");
|
|
462
|
+
}
|
|
463
|
+
for (const name of downExchanges) {
|
|
464
|
+
console.log(chalk.red(` ${now} EXCHANGE DOWN: ${name} — blocking new entries`));
|
|
465
|
+
}
|
|
466
|
+
// Mark existing positions on down exchanges as degraded
|
|
467
|
+
for (const pos of openPositions) {
|
|
468
|
+
if (downExchanges.has(pos.longExchange) || downExchanges.has(pos.shortExchange)) {
|
|
469
|
+
console.log(chalk.yellow(` ${now} DEGRADED ${pos.symbol}: ${downExchanges.has(pos.longExchange) ? pos.longExchange : pos.shortExchange} is down`));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
// Only check margins when we have open positions (avoid slow adapter init)
|
|
474
|
+
const needMarginCheck = openPositions.length > 0;
|
|
475
|
+
const adaptersMap = new Map();
|
|
476
|
+
if (needMarginCheck) {
|
|
477
|
+
const marginExchanges = ["hyperliquid", "lighter", "pacifica"];
|
|
478
|
+
for (const name of marginExchanges) {
|
|
479
|
+
try {
|
|
480
|
+
adaptersMap.set(name, await getAdapterForExchange(name));
|
|
481
|
+
}
|
|
482
|
+
catch { /* skip */ }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (adaptersMap.size > 0) {
|
|
486
|
+
const marginStatuses = await checkChainMargins(adaptersMap, minMarginPct);
|
|
487
|
+
for (const ms of marginStatuses) {
|
|
488
|
+
if (isCriticalMargin(ms)) {
|
|
489
|
+
console.log(chalk.red.bold(` ${now} EMERGENCY: ${ms.exchange} margin ratio ${ms.marginRatio.toFixed(1)}% — CRITICAL (below 15%)`));
|
|
490
|
+
blockedExchanges.add(ms.exchange);
|
|
491
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
|
|
492
|
+
exchange: ms.exchange, marginPct: ms.marginRatio, threshold: 15,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
else if (shouldBlockEntries(ms, minMarginPct)) {
|
|
496
|
+
console.log(chalk.yellow(` ${now} WARNING: ${ms.exchange} margin ${ms.marginRatio.toFixed(1)}% below ${minMarginPct}% — blocking new entries`));
|
|
497
|
+
blockedExchanges.add(ms.exchange);
|
|
498
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
|
|
499
|
+
exchange: ms.exchange, marginPct: ms.marginRatio, threshold: minMarginPct,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch { /* margin check failed, continue without blocking */ }
|
|
506
|
+
// Check for entry conditions
|
|
507
|
+
if (openPositions.length < maxPositions) {
|
|
508
|
+
for (const snap of filtered) {
|
|
509
|
+
if (openPositions.some(p => p.symbol === snap.symbol))
|
|
510
|
+
continue;
|
|
511
|
+
if (openPositions.length >= maxPositions)
|
|
512
|
+
break;
|
|
513
|
+
const grossSpread = Math.abs(snap.spread);
|
|
514
|
+
// Determine direction using all 3 exchanges: short the high-funding, long the low-funding
|
|
515
|
+
const longExchange = snap.longExch;
|
|
516
|
+
const shortExchange = snap.shortExch;
|
|
517
|
+
// Block entries if either exchange has low margin
|
|
518
|
+
if (blockedExchanges.has(longExchange) || blockedExchanges.has(shortExchange)) {
|
|
519
|
+
if (!isJson()) {
|
|
520
|
+
console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: margin too low on ${blockedExchanges.has(longExchange) ? longExchange : shortExchange}`));
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// Compute net spread after fees and bridge costs
|
|
525
|
+
const roundTripCost = computeRoundTripCostPct(longExchange, shortExchange);
|
|
526
|
+
const effectiveSizeUsd = sizeIsAuto ? 100 : sizeUsd; // Use $100 for net spread calc when auto
|
|
527
|
+
const netSpread = computeNetSpread(grossSpread, holdDays, roundTripCost, bridgeCostUsd, effectiveSizeUsd);
|
|
528
|
+
// --min-spread now compares against NET spread
|
|
529
|
+
if (netSpread < minSpread)
|
|
530
|
+
continue;
|
|
531
|
+
// Settlement timing check with strategy
|
|
532
|
+
if (settleStrategy === "block") {
|
|
533
|
+
const settlCheck = isNearSettlement(longExchange, shortExchange);
|
|
534
|
+
if (settlCheck.blocked) {
|
|
535
|
+
console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: ${settlCheck.minutesUntil?.toFixed(1)}m before ${settlCheck.exchange} settlement`));
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// In aggressive mode, boost score for post-settlement entries
|
|
540
|
+
let settleBoostMultiplier = 1.0;
|
|
541
|
+
if (settleStrategy === "aggressive") {
|
|
542
|
+
settleBoostMultiplier = aggressiveSettleBoost(longExchange, shortExchange, 10, new Date());
|
|
543
|
+
if (settleBoostMultiplier > 1.0) {
|
|
544
|
+
console.log(chalk.cyan(` ${now} BOOST ${snap.symbol}: post-settlement ${((settleBoostMultiplier - 1) * 100).toFixed(0)}% score boost`));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const rateForExch = (e) => e === "pacifica" ? snap.pacRate : e === "hyperliquid" ? snap.hlRate : snap.ltRate;
|
|
548
|
+
console.log(chalk.green(` ${now} ENTER ${snap.symbol} — gross ${grossSpread.toFixed(1)}% net ${netSpread.toFixed(1)}%` +
|
|
549
|
+
` | Long ${longExchange} (${(rateForExch(longExchange) * 100).toFixed(4)}%)` +
|
|
550
|
+
` | Short ${shortExchange} (${(rateForExch(shortExchange) * 100).toFixed(4)}%)`));
|
|
551
|
+
// Calculate size in asset units from USD and mark price
|
|
552
|
+
if (snap.markPrice <= 0) {
|
|
553
|
+
console.error(chalk.red(` ${now} SKIP ${snap.symbol}: no mark price available`));
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
// Dynamic sizing: compute auto size if --size auto
|
|
557
|
+
let actualSizeUsd = sizeUsd;
|
|
558
|
+
if (sizeIsAuto) {
|
|
559
|
+
try {
|
|
560
|
+
const longAdapter = await getAdapterForExchange(longExchange);
|
|
561
|
+
const shortAdapter = await getAdapterForExchange(shortExchange);
|
|
562
|
+
actualSizeUsd = await computeAutoSize(longAdapter, shortAdapter, snap.symbol, 0.3);
|
|
563
|
+
if (actualSizeUsd <= 0) {
|
|
564
|
+
console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: auto-size returned $0 (insufficient depth/margin)`));
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
console.log(chalk.gray(` ${now} Auto-size ${snap.symbol}: $${actualSizeUsd}`));
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: auto-size failed — ${err instanceof Error ? err.message : err}`));
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (sizeIsAuto && actualSizeUsd < minSizeUsd) {
|
|
575
|
+
console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: auto-size $${actualSizeUsd} below min $${minSizeUsd}`));
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const sizeInAsset = (actualSizeUsd / snap.markPrice).toFixed(4);
|
|
579
|
+
if (!dryRun) {
|
|
580
|
+
try {
|
|
581
|
+
const longAdapter = await getAdapterForExchange(longExchange);
|
|
582
|
+
const shortAdapter = await getAdapterForExchange(shortExchange);
|
|
583
|
+
// Use Promise.allSettled for safe dual-leg entry
|
|
584
|
+
const [longResult, shortResult] = await Promise.allSettled([
|
|
585
|
+
longAdapter.marketOrder(snap.symbol, "buy", sizeInAsset),
|
|
586
|
+
shortAdapter.marketOrder(snap.symbol, "sell", sizeInAsset),
|
|
587
|
+
]);
|
|
588
|
+
const longOk = longResult.status === "fulfilled";
|
|
589
|
+
const shortOk = shortResult.status === "fulfilled";
|
|
590
|
+
if (longOk && shortOk) {
|
|
591
|
+
// Both legs filled successfully
|
|
592
|
+
logExecution({
|
|
593
|
+
type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
|
|
594
|
+
symbol: snap.symbol, side: "entry", size: sizeInAsset,
|
|
595
|
+
status: "success", dryRun: false,
|
|
596
|
+
meta: { longExchange, shortExchange, grossSpread, netSpread, roundTripCost, markPrice: snap.markPrice },
|
|
597
|
+
});
|
|
598
|
+
console.log(chalk.green(` ${now} FILLED ${snap.symbol} — both legs @ ${sizeInAsset} units ($${actualSizeUsd} / $${snap.markPrice.toFixed(2)})`));
|
|
599
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "entry", {
|
|
600
|
+
symbol: snap.symbol, longExchange, shortExchange,
|
|
601
|
+
size: actualSizeUsd, netSpread,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
else if (longOk !== shortOk) {
|
|
605
|
+
// One leg filled, one failed — ROLLBACK the successful leg
|
|
606
|
+
const filledSide = longOk ? "long" : "short";
|
|
607
|
+
const failedSide = longOk ? "short" : "long";
|
|
608
|
+
const filledAdapter = longOk ? longAdapter : shortAdapter;
|
|
609
|
+
const rollbackAction = longOk ? "sell" : "buy"; // reverse the filled side
|
|
610
|
+
const failedErr = longOk
|
|
611
|
+
? shortResult.reason
|
|
612
|
+
: longResult.reason;
|
|
613
|
+
console.log(chalk.yellow(` ${now} PARTIAL FILL ${snap.symbol}: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
|
|
614
|
+
// Attempt rollback with max 2 retries
|
|
615
|
+
let rollbackOk = false;
|
|
616
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
617
|
+
try {
|
|
618
|
+
await filledAdapter.marketOrder(snap.symbol, rollbackAction, sizeInAsset);
|
|
619
|
+
rollbackOk = true;
|
|
620
|
+
console.log(chalk.green(` ${now} ROLLBACK ${snap.symbol}: ${filledSide} leg closed (attempt ${attempt})`));
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
catch (rollbackErr) {
|
|
624
|
+
console.log(chalk.red(` ${now} ROLLBACK ATTEMPT ${attempt}/2 FAILED ${snap.symbol}: ${rollbackErr instanceof Error ? rollbackErr.message : rollbackErr}`));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
logExecution({
|
|
628
|
+
type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
|
|
629
|
+
symbol: snap.symbol, side: "entry", size: sizeInAsset,
|
|
630
|
+
status: "failed", dryRun: false,
|
|
631
|
+
error: `Partial fill: ${failedSide} failed (${failedErr instanceof Error ? failedErr.message : String(failedErr)}). Rollback: ${rollbackOk ? "success" : "FAILED"}`,
|
|
632
|
+
meta: { longExchange, shortExchange, grossSpread, netSpread, partialFill: filledSide, rollbackSuccess: rollbackOk },
|
|
633
|
+
});
|
|
634
|
+
if (!rollbackOk) {
|
|
635
|
+
// Critical: manual intervention required
|
|
636
|
+
console.log(chalk.red.bold(` ${now} IMBALANCE ${snap.symbol}: ${filledSide} leg open, rollback failed — MANUAL CLOSE REQUIRED`));
|
|
637
|
+
await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
|
|
638
|
+
exchange: longOk ? longExchange : shortExchange,
|
|
639
|
+
marginPct: 0,
|
|
640
|
+
threshold: 0,
|
|
641
|
+
symbol: snap.symbol,
|
|
642
|
+
message: `IMBALANCE: ${filledSide} leg filled on ${longOk ? longExchange : shortExchange}, ${failedSide} failed, rollback failed. Manual close required.`,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
continue; // don't add to openPositions
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
// Both legs failed
|
|
649
|
+
const longErr = longResult.reason;
|
|
650
|
+
const shortErr = shortResult.reason;
|
|
651
|
+
logExecution({
|
|
652
|
+
type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
|
|
653
|
+
symbol: snap.symbol, side: "entry", size: sizeInAsset,
|
|
654
|
+
status: "failed", dryRun: false,
|
|
655
|
+
error: `Both legs failed. Long: ${longErr instanceof Error ? longErr.message : String(longErr)}, Short: ${shortErr instanceof Error ? shortErr.message : String(shortErr)}`,
|
|
656
|
+
meta: { longExchange, shortExchange, grossSpread, netSpread },
|
|
657
|
+
});
|
|
658
|
+
console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: both legs rejected`));
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
logExecution({
|
|
664
|
+
type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
|
|
665
|
+
symbol: snap.symbol, side: "entry", size: sizeInAsset,
|
|
666
|
+
status: "failed", dryRun: false,
|
|
667
|
+
error: err instanceof Error ? err.message : String(err),
|
|
668
|
+
meta: { longExchange, shortExchange, grossSpread, netSpread },
|
|
669
|
+
});
|
|
670
|
+
console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: ${err instanceof Error ? err.message : err}`));
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
openPositions.push({
|
|
675
|
+
symbol: snap.symbol,
|
|
676
|
+
longExchange,
|
|
677
|
+
shortExchange,
|
|
678
|
+
size: sizeInAsset,
|
|
679
|
+
entrySpread: grossSpread,
|
|
680
|
+
entryTime: new Date().toISOString(),
|
|
681
|
+
entryMarkPrice: snap.markPrice,
|
|
682
|
+
accumulatedFundingUsd: 0,
|
|
683
|
+
lastCheckTime: Date.now(),
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// Accumulate estimated funding income & show status
|
|
688
|
+
if (openPositions.length > 0) {
|
|
689
|
+
const nowMs = Date.now();
|
|
690
|
+
for (const pos of openPositions) {
|
|
691
|
+
const current = filtered.find(s => s.symbol === pos.symbol);
|
|
692
|
+
if (!current)
|
|
693
|
+
continue;
|
|
694
|
+
const elapsedHours = (nowMs - pos.lastCheckTime) / (1000 * 60 * 60);
|
|
695
|
+
const notional = parseFloat(pos.size) * current.markPrice;
|
|
696
|
+
// Estimate funding collected: long collects from low-rate side, short from high-rate side
|
|
697
|
+
const rateFor = (e) => e === "pacifica" ? current.pacRate : e === "hyperliquid" ? current.hlRate : current.ltRate;
|
|
698
|
+
const longHourly = rateFor(pos.longExchange);
|
|
699
|
+
const shortHourly = rateFor(pos.shortExchange);
|
|
700
|
+
// Income = short gets paid positive funding, long pays; net = (shortRate - longRate) * notional * hours
|
|
701
|
+
const hourlyIncome = (shortHourly - longHourly) * notional;
|
|
702
|
+
pos.accumulatedFundingUsd += hourlyIncome * elapsedHours;
|
|
703
|
+
pos.lastCheckTime = nowMs;
|
|
704
|
+
}
|
|
705
|
+
const totalFunding = openPositions.reduce((s, p) => s + p.accumulatedFundingUsd, 0);
|
|
706
|
+
const fundingColor = totalFunding >= 0 ? chalk.green : chalk.red;
|
|
707
|
+
console.log(chalk.gray(` ${now} Status: ${openPositions.length} positions | ` +
|
|
708
|
+
`Est. funding: ${fundingColor(`$${totalFunding.toFixed(4)}`)} — ` +
|
|
709
|
+
openPositions.map(p => `${p.symbol}(${p.entrySpread.toFixed(0)}% $${p.accumulatedFundingUsd.toFixed(3)})`).join(", ")));
|
|
710
|
+
}
|
|
711
|
+
// Update heartbeat: mark successful scan
|
|
712
|
+
const stateForHeartbeat = loadArbState();
|
|
713
|
+
if (stateForHeartbeat) {
|
|
714
|
+
stateForHeartbeat.lastSuccessfulScanTime = new Date().toISOString();
|
|
715
|
+
stateForHeartbeat.lastScanTime = new Date().toISOString();
|
|
716
|
+
saveArbState(stateForHeartbeat);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
await cycle();
|
|
724
|
+
setInterval(cycle, intervalMs);
|
|
725
|
+
await new Promise(() => { }); // keep alive
|
|
726
|
+
});
|
|
727
|
+
// ── arb scan ── (one-shot spread scan)
|
|
728
|
+
arb
|
|
729
|
+
.command("scan")
|
|
730
|
+
.description("Scan current funding rate spreads")
|
|
731
|
+
.option("--min <pct>", "Min annual spread to show", "10")
|
|
732
|
+
.option("--hold-days <days>", "Expected hold period for cost calc", "7")
|
|
733
|
+
.option("--bridge-cost <usd>", "One-way bridge cost in USD", "0.5")
|
|
734
|
+
.option("--size <usd>", "Position size per leg ($) for cost calc", "100")
|
|
735
|
+
.action(async (opts) => {
|
|
736
|
+
const minSpread = parseFloat(opts.min);
|
|
737
|
+
const holdDays = parseFloat(opts.holdDays);
|
|
738
|
+
const bridgeCostUsd = parseFloat(opts.bridgeCost);
|
|
739
|
+
const sizeUsd = parseFloat(opts.size);
|
|
740
|
+
if (!isJson())
|
|
741
|
+
console.log(chalk.cyan("\n Scanning funding rate spreads...\n"));
|
|
742
|
+
const spreads = await fetchFundingSpreads();
|
|
743
|
+
const filtered = spreads.filter(s => Math.abs(s.spread) >= minSpread);
|
|
744
|
+
if (isJson()) {
|
|
745
|
+
const enriched = filtered.map(s => {
|
|
746
|
+
const grossSpread = Math.abs(s.spread);
|
|
747
|
+
const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
|
|
748
|
+
const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
749
|
+
return { ...s, grossSpread, netSpread: net, estFeesPct: rtCost };
|
|
750
|
+
});
|
|
751
|
+
return printJson(jsonOk(enriched));
|
|
752
|
+
}
|
|
753
|
+
if (filtered.length === 0) {
|
|
754
|
+
console.log(chalk.gray(` No spreads above ${minSpread}%\n`));
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
// Header
|
|
758
|
+
console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"GROSS".padEnd(8)} ${"NET".padEnd(8)} ${"FEES".padEnd(7)} ${"DIR".padEnd(7)} RATES`));
|
|
759
|
+
for (const s of filtered) {
|
|
760
|
+
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
761
|
+
const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
|
|
762
|
+
const grossSpread = Math.abs(s.spread);
|
|
763
|
+
const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
|
|
764
|
+
const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
765
|
+
const grossColor = grossSpread >= 30 ? chalk.green : chalk.yellow;
|
|
766
|
+
const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
767
|
+
const rates = [];
|
|
768
|
+
if (s.pacRate)
|
|
769
|
+
rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
|
|
770
|
+
if (s.hlRate)
|
|
771
|
+
rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%`);
|
|
772
|
+
if (s.ltRate)
|
|
773
|
+
rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%`);
|
|
774
|
+
console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
|
|
775
|
+
`${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
776
|
+
`${netColor(`${netSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
777
|
+
`${chalk.gray(`${rtCost.toFixed(2)}%`.padEnd(7))} ` +
|
|
778
|
+
`${direction.padEnd(7)} ` +
|
|
779
|
+
rates.join(" "));
|
|
780
|
+
}
|
|
781
|
+
console.log(chalk.gray(`\n ${filtered.length} opportunities above ${minSpread}% gross annual spread`));
|
|
782
|
+
console.log(chalk.gray(` Net spread assumes ${holdDays}d hold, $${bridgeCostUsd} bridge cost, $${sizeUsd} size`));
|
|
783
|
+
console.log(chalk.gray(` * Spreads are estimates based on current rates — actual may vary`));
|
|
784
|
+
console.log(chalk.gray(` Use 'perp arb auto --min-spread ${minSpread}' to auto-trade\n`));
|
|
785
|
+
});
|
|
786
|
+
// ── arb pnl ── (check arb position PnL)
|
|
787
|
+
arb
|
|
788
|
+
.command("pnl")
|
|
789
|
+
.description("Check PnL of current arb positions across exchanges")
|
|
790
|
+
.action(async () => {
|
|
791
|
+
if (!isJson())
|
|
792
|
+
console.log(chalk.cyan("\n Checking arb positions...\n"));
|
|
793
|
+
const exchangeNames = ["hyperliquid", "lighter", "pacifica"];
|
|
794
|
+
const allPositions = [];
|
|
795
|
+
for (const exName of exchangeNames) {
|
|
796
|
+
try {
|
|
797
|
+
const adapter = await getAdapterForExchange(exName);
|
|
798
|
+
const positions = await adapter.getPositions();
|
|
799
|
+
for (const p of positions) {
|
|
800
|
+
allPositions.push({
|
|
801
|
+
exchange: exName,
|
|
802
|
+
symbol: p.symbol.replace("-PERP", ""),
|
|
803
|
+
side: p.side,
|
|
804
|
+
size: Math.abs(Number(p.size)),
|
|
805
|
+
entry: Number(p.entryPrice),
|
|
806
|
+
mark: Number(p.markPrice),
|
|
807
|
+
upnl: Number(p.unrealizedPnl),
|
|
808
|
+
lev: p.leverage,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
// exchange not configured, skip
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (allPositions.length === 0) {
|
|
817
|
+
console.log(chalk.gray(" No positions found on any exchange.\n"));
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
// Group by symbol to find arb pairs
|
|
821
|
+
const bySymbol = new Map();
|
|
822
|
+
for (const p of allPositions) {
|
|
823
|
+
const key = p.symbol.toUpperCase();
|
|
824
|
+
if (!bySymbol.has(key))
|
|
825
|
+
bySymbol.set(key, []);
|
|
826
|
+
bySymbol.get(key).push(p);
|
|
827
|
+
}
|
|
828
|
+
// Get current funding rates
|
|
829
|
+
const spreads = await fetchFundingSpreads();
|
|
830
|
+
const spreadMap = new Map(spreads.map(s => [s.symbol.toUpperCase(), s]));
|
|
831
|
+
// Get fee rates (approximate)
|
|
832
|
+
const TAKER_FEE = 0.00035; // ~0.035% typical
|
|
833
|
+
// Fetch actual settled funding payments from each exchange
|
|
834
|
+
const actualFundingByExSymbol = new Map(); // "exchange:SYMBOL" → total settled USD
|
|
835
|
+
for (const exName of exchangeNames) {
|
|
836
|
+
try {
|
|
837
|
+
const adapter = await getAdapterForExchange(exName);
|
|
838
|
+
const payments = await adapter.getFundingPayments(100);
|
|
839
|
+
for (const fp of payments) {
|
|
840
|
+
const sym = fp.symbol.replace("-PERP", "").toUpperCase();
|
|
841
|
+
const key = `${exName}:${sym}`;
|
|
842
|
+
actualFundingByExSymbol.set(key, (actualFundingByExSymbol.get(key) ?? 0) + Number(fp.payment));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
// exchange not configured or API error, skip
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (isJson()) {
|
|
850
|
+
const result = [...bySymbol.entries()].map(([symbol, positions]) => {
|
|
851
|
+
const spread = spreadMap.get(symbol);
|
|
852
|
+
// Sum actual funding across exchanges for this symbol
|
|
853
|
+
let actualFunding = 0;
|
|
854
|
+
for (const p of positions) {
|
|
855
|
+
actualFunding += actualFundingByExSymbol.get(`${p.exchange}:${symbol}`) ?? 0;
|
|
856
|
+
}
|
|
857
|
+
return { symbol, positions, currentSpread: spread?.spread ?? 0, actualFunding };
|
|
858
|
+
});
|
|
859
|
+
return printJson(jsonOk(result));
|
|
860
|
+
}
|
|
861
|
+
let totalUpnl = 0;
|
|
862
|
+
let totalFees = 0;
|
|
863
|
+
let totalEstFunding = 0;
|
|
864
|
+
let totalActualFunding = 0;
|
|
865
|
+
for (const [symbol, positions] of bySymbol) {
|
|
866
|
+
const isArb = positions.length >= 2 && positions.some(p => p.side === "long") && positions.some(p => p.side === "short");
|
|
867
|
+
const spread = spreadMap.get(symbol);
|
|
868
|
+
console.log(chalk.white.bold(` ${symbol}`) + (isArb ? chalk.green(" [ARB PAIR]") : chalk.gray(" [single]")));
|
|
869
|
+
for (const p of positions) {
|
|
870
|
+
const sideColor = p.side === "long" ? chalk.green : chalk.red;
|
|
871
|
+
const pnlColor = p.upnl >= 0 ? chalk.green : chalk.red;
|
|
872
|
+
const notional = p.size * p.mark;
|
|
873
|
+
const entryFee = p.size * p.entry * TAKER_FEE;
|
|
874
|
+
totalFees += entryFee;
|
|
875
|
+
totalUpnl += p.upnl;
|
|
876
|
+
console.log(` ${sideColor(p.side.toUpperCase().padEnd(6))} ${p.exchange.padEnd(13)} ` +
|
|
877
|
+
`size: ${p.size.toFixed(2).padEnd(8)} entry: $${p.entry.toFixed(4).padEnd(10)} ` +
|
|
878
|
+
`mark: $${p.mark.toFixed(4).padEnd(10)} uPnL: ${pnlColor(p.upnl >= 0 ? "+" : "")}$${p.upnl.toFixed(4)}`);
|
|
879
|
+
console.log(chalk.gray(` notional: $${notional.toFixed(2)} est.entry fee: $${entryFee.toFixed(4)} lev: ${p.lev}x`));
|
|
880
|
+
}
|
|
881
|
+
if (spread) {
|
|
882
|
+
const grossSpread = Math.abs(spread.spread);
|
|
883
|
+
const rtCost = computeRoundTripCostPct(spread.longExch, spread.shortExch);
|
|
884
|
+
const netSpread = computeNetSpread(grossSpread, 7, rtCost, 0.5, 100);
|
|
885
|
+
const grossColor = grossSpread >= 20 ? chalk.green : grossSpread >= 10 ? chalk.yellow : chalk.gray;
|
|
886
|
+
const netColor = netSpread >= 15 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
887
|
+
// Estimate hourly funding income for this pair
|
|
888
|
+
const longPos = positions.find(p => p.side === "long");
|
|
889
|
+
const shortPos = positions.find(p => p.side === "short");
|
|
890
|
+
if (longPos && shortPos) {
|
|
891
|
+
const avgNotional = (longPos.size * longPos.mark + shortPos.size * shortPos.mark) / 2;
|
|
892
|
+
const hourlyIncome = (grossSpread / 100) / (24 * 365) * avgNotional;
|
|
893
|
+
const dailyIncome = hourlyIncome * 24;
|
|
894
|
+
console.log(chalk.cyan(` Gross: ${grossColor(`${grossSpread.toFixed(1)}%`)} | ` +
|
|
895
|
+
`Net: ${netColor(`${netSpread.toFixed(1)}%`)} | ` +
|
|
896
|
+
`Fees: ${chalk.gray(`${rtCost.toFixed(2)}%`)} | ` +
|
|
897
|
+
`Est. income: $${hourlyIncome.toFixed(4)}/hr, $${dailyIncome.toFixed(3)}/day`));
|
|
898
|
+
// Look up entry time from execution log for funding estimation
|
|
899
|
+
const entryLog = readExecutionLog({ type: "arb_entry", symbol }).filter(e => e.status === "success")[0];
|
|
900
|
+
const entryTime = entryLog?.timestamp ? new Date(entryLog.timestamp).getTime() : null;
|
|
901
|
+
const holdHours = entryTime ? (Date.now() - entryTime) / (1000 * 60 * 60) : null;
|
|
902
|
+
// Estimated funding based on current spread × hold time
|
|
903
|
+
const estFunding = holdHours !== null ? hourlyIncome * holdHours : 0;
|
|
904
|
+
// Actual settled funding from exchange APIs
|
|
905
|
+
let actualFunding = 0;
|
|
906
|
+
for (const p of positions) {
|
|
907
|
+
actualFunding += actualFundingByExSymbol.get(`${p.exchange}:${symbol}`) ?? 0;
|
|
908
|
+
}
|
|
909
|
+
totalEstFunding += estFunding;
|
|
910
|
+
totalActualFunding += actualFunding;
|
|
911
|
+
const diff = actualFunding - estFunding;
|
|
912
|
+
const diffColor = Math.abs(diff) < 0.01 ? chalk.gray : diff >= 0 ? chalk.green : chalk.red;
|
|
913
|
+
const fmtVal = (v) => v >= 0 ? `$${v.toFixed(4)}` : `-$${Math.abs(v).toFixed(4)}`;
|
|
914
|
+
console.log(chalk.white(` Funding: Est. ${chalk.yellow(fmtVal(estFunding))} / ` +
|
|
915
|
+
`Actual ${chalk.cyan(fmtVal(actualFunding))} / ` +
|
|
916
|
+
`Diff: ${diffColor(fmtVal(diff))}`));
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
console.log();
|
|
920
|
+
}
|
|
921
|
+
// Summary
|
|
922
|
+
const exitFees = allPositions.reduce((s, p) => s + p.size * p.mark * TAKER_FEE, 0);
|
|
923
|
+
totalFees += exitFees;
|
|
924
|
+
const netPnl = totalUpnl - totalFees + totalActualFunding;
|
|
925
|
+
console.log(chalk.white.bold(" Summary"));
|
|
926
|
+
const upnlColor = totalUpnl >= 0 ? chalk.green : chalk.red;
|
|
927
|
+
const netColor = netPnl >= 0 ? chalk.green : chalk.red;
|
|
928
|
+
console.log(` Unrealized PnL: ${upnlColor(`$${totalUpnl.toFixed(4)}`)}`);
|
|
929
|
+
console.log(` Est. fees (in+out): ${chalk.red(`-$${totalFees.toFixed(4)}`)}`);
|
|
930
|
+
if (totalActualFunding !== 0 || totalEstFunding !== 0) {
|
|
931
|
+
const fundingDiff = totalActualFunding - totalEstFunding;
|
|
932
|
+
const diffPct = totalEstFunding !== 0 ? ((fundingDiff / Math.abs(totalEstFunding)) * 100).toFixed(1) : "N/A";
|
|
933
|
+
console.log(` Est. funding: ${chalk.yellow(`$${totalEstFunding.toFixed(4)}`)}`);
|
|
934
|
+
console.log(` Actual funding: ${chalk.cyan(`$${totalActualFunding.toFixed(4)}`)}`);
|
|
935
|
+
console.log(` Funding diff: ${fundingDiff >= 0 ? chalk.green(`+$${fundingDiff.toFixed(4)}`) : chalk.red(`-$${Math.abs(fundingDiff).toFixed(4)}`)} (${diffPct}%)`);
|
|
936
|
+
}
|
|
937
|
+
console.log(` Net (if closed now): ${netColor(`$${netPnl.toFixed(4)}`)}`);
|
|
938
|
+
console.log(chalk.gray(` (Fees assume ${(TAKER_FEE * 100).toFixed(3)}% taker. Actual may vary.)`));
|
|
939
|
+
console.log(chalk.gray(` * Net includes actual settled funding where available.\n`));
|
|
940
|
+
});
|
|
941
|
+
// ── arb monitor ── (live monitoring with liquidity)
|
|
942
|
+
arb
|
|
943
|
+
.command("monitor")
|
|
944
|
+
.description("Live-monitor funding spreads with liquidity data")
|
|
945
|
+
.option("--min <pct>", "Min annual spread to show", "20")
|
|
946
|
+
.option("--interval <sec>", "Refresh interval in seconds", "60")
|
|
947
|
+
.option("--top <n>", "Show top N opportunities", "15")
|
|
948
|
+
.option("--check-liquidity", "Check orderbook depth (slower)")
|
|
949
|
+
.option("--hold-days <days>", "Expected hold period for net spread calc", "7")
|
|
950
|
+
.option("--bridge-cost <usd>", "One-way bridge cost in USD", "0.5")
|
|
951
|
+
.option("--size <usd>", "Position size per leg ($) for cost calc", "100")
|
|
952
|
+
.action(async (opts) => {
|
|
953
|
+
const minSpread = parseFloat(opts.min);
|
|
954
|
+
const intervalSec = parseInt(opts.interval);
|
|
955
|
+
const topN = parseInt(opts.top);
|
|
956
|
+
const checkLiq = opts.checkLiquidity ?? false;
|
|
957
|
+
const holdDays = parseFloat(opts.holdDays);
|
|
958
|
+
const bridgeCostUsd = parseFloat(opts.bridgeCost);
|
|
959
|
+
const sizeUsd = parseFloat(opts.size);
|
|
960
|
+
let cycle = 0;
|
|
961
|
+
if (!isJson()) {
|
|
962
|
+
console.log(chalk.cyan.bold("\n Funding Arb Monitor"));
|
|
963
|
+
console.log(chalk.gray(` Min spread: ${minSpread}% | Refresh: ${intervalSec}s | Top: ${topN}`));
|
|
964
|
+
console.log(chalk.gray(` Net spread: ${holdDays}d hold, $${bridgeCostUsd} bridge, $${sizeUsd} size`));
|
|
965
|
+
if (checkLiq)
|
|
966
|
+
console.log(chalk.gray(` Liquidity check: ON`));
|
|
967
|
+
console.log(chalk.gray(` Press Ctrl+C to stop\n`));
|
|
968
|
+
}
|
|
969
|
+
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
970
|
+
while (true) {
|
|
971
|
+
cycle++;
|
|
972
|
+
const ts = new Date().toLocaleTimeString();
|
|
973
|
+
try {
|
|
974
|
+
const spreads = await fetchFundingSpreads();
|
|
975
|
+
const filtered = spreads
|
|
976
|
+
.filter(s => Math.abs(s.spread) >= minSpread)
|
|
977
|
+
.slice(0, topN);
|
|
978
|
+
// Clear previous output (move cursor up)
|
|
979
|
+
if (cycle > 1) {
|
|
980
|
+
const linesToClear = filtered.length + 4;
|
|
981
|
+
process.stdout.write(`\x1b[${linesToClear}A\x1b[J`);
|
|
982
|
+
}
|
|
983
|
+
console.log(chalk.gray(` ${ts} — Cycle ${cycle} | ${filtered.length} opportunities >= ${minSpread}%\n`));
|
|
984
|
+
if (filtered.length === 0) {
|
|
985
|
+
console.log(chalk.gray(` No opportunities found.\n`));
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
// Optionally check liquidity for top entries
|
|
989
|
+
const liqData = new Map();
|
|
990
|
+
if (checkLiq && filtered.length > 0) {
|
|
991
|
+
const topCheck = filtered.slice(0, 5); // check liquidity for top 5 only
|
|
992
|
+
await Promise.allSettled(topCheck.map(async (s) => {
|
|
993
|
+
try {
|
|
994
|
+
const [hlOB, ltOB] = await Promise.all([
|
|
995
|
+
fetchHLOrderbook(s.symbol),
|
|
996
|
+
fetchLighterOrderbook(s.symbol),
|
|
997
|
+
]);
|
|
998
|
+
const hlDepth = hlOB.reduce((sum, l) => sum + l[0] * l[1], 0);
|
|
999
|
+
const ltDepth = ltOB.reduce((sum, l) => sum + l[0] * l[1], 0);
|
|
1000
|
+
// Price gap between best ask (buy side) and best bid (sell side)
|
|
1001
|
+
const hlBest = hlOB[0]?.[0] ?? 0;
|
|
1002
|
+
const ltBest = ltOB[0]?.[0] ?? 0;
|
|
1003
|
+
const gap = hlBest && ltBest
|
|
1004
|
+
? (Math.abs(hlBest - ltBest) / Math.min(hlBest, ltBest) * 100).toFixed(3)
|
|
1005
|
+
: "?";
|
|
1006
|
+
liqData.set(s.symbol, { hlDepth: Math.round(hlDepth), ltDepth: Math.round(ltDepth), gap });
|
|
1007
|
+
}
|
|
1008
|
+
catch { /* skip */ }
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
1011
|
+
for (const s of filtered) {
|
|
1012
|
+
const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
|
|
1013
|
+
const grossSpread = Math.abs(s.spread);
|
|
1014
|
+
const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
|
|
1015
|
+
const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
1016
|
+
const grossColor = grossSpread >= 50 ? chalk.green.bold
|
|
1017
|
+
: grossSpread >= 30 ? chalk.green
|
|
1018
|
+
: chalk.yellow;
|
|
1019
|
+
const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
1020
|
+
const rates = [];
|
|
1021
|
+
if (s.pacRate)
|
|
1022
|
+
rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
|
|
1023
|
+
if (s.hlRate)
|
|
1024
|
+
rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%`);
|
|
1025
|
+
if (s.ltRate)
|
|
1026
|
+
rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%`);
|
|
1027
|
+
let liqInfo = "";
|
|
1028
|
+
const ld = liqData.get(s.symbol);
|
|
1029
|
+
if (ld) {
|
|
1030
|
+
liqInfo = chalk.gray(` | depth: HL $${ld.hlDepth.toLocaleString()} LT $${ld.ltDepth.toLocaleString()} gap:${ld.gap}%`);
|
|
1031
|
+
}
|
|
1032
|
+
console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
|
|
1033
|
+
`${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
1034
|
+
`${netColor(`net:${netSpread.toFixed(1)}%`.padEnd(12))} ` +
|
|
1035
|
+
`${direction.padEnd(7)} ` +
|
|
1036
|
+
rates.join(" ") +
|
|
1037
|
+
liqInfo);
|
|
1038
|
+
}
|
|
1039
|
+
console.log(chalk.gray(` * Net spreads are predicted estimates (${holdDays}d hold, $${bridgeCostUsd} bridge)`));
|
|
1040
|
+
console.log();
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
catch (err) {
|
|
1044
|
+
console.log(chalk.red(` ${ts} Error: ${err instanceof Error ? err.message : err}\n`));
|
|
1045
|
+
}
|
|
1046
|
+
await new Promise(r => setTimeout(r, intervalSec * 1000));
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
// ── arb dex-monitor ── (live HIP-3 cross-dex monitoring)
|
|
1050
|
+
arb
|
|
1051
|
+
.command("dex-monitor")
|
|
1052
|
+
.description("Live-monitor HIP-3 cross-dex funding arb opportunities")
|
|
1053
|
+
.option("--min <pct>", "Min annual spread to show", "10")
|
|
1054
|
+
.option("--interval <sec>", "Refresh interval in seconds", "60")
|
|
1055
|
+
.option("--top <n>", "Show top N opportunities", "20")
|
|
1056
|
+
.option("--include-native", "Include native HL perps", true)
|
|
1057
|
+
.option("--no-include-native", "Exclude native HL perps")
|
|
1058
|
+
.action(async (opts) => {
|
|
1059
|
+
const minSpread = parseFloat(opts.min);
|
|
1060
|
+
const intervalSec = parseInt(opts.interval);
|
|
1061
|
+
const topN = parseInt(opts.top);
|
|
1062
|
+
let cycle = 0;
|
|
1063
|
+
if (!isJson()) {
|
|
1064
|
+
console.log(chalk.cyan.bold("\n HIP-3 Cross-Dex Arb Monitor"));
|
|
1065
|
+
console.log(chalk.gray(` Min spread: ${minSpread}% | Refresh: ${intervalSec}s | Top: ${topN}`));
|
|
1066
|
+
console.log(chalk.gray(` Native HL: ${opts.includeNative ? "ON" : "OFF"}`));
|
|
1067
|
+
console.log(chalk.gray(` Press Ctrl+C to stop\n`));
|
|
1068
|
+
}
|
|
1069
|
+
while (true) {
|
|
1070
|
+
cycle++;
|
|
1071
|
+
const ts = new Date().toLocaleTimeString();
|
|
1072
|
+
try {
|
|
1073
|
+
const pairs = await scanDexArb({
|
|
1074
|
+
minAnnualSpread: minSpread,
|
|
1075
|
+
includeNative: opts.includeNative,
|
|
1076
|
+
});
|
|
1077
|
+
const shown = pairs.slice(0, topN);
|
|
1078
|
+
// Clear previous output
|
|
1079
|
+
if (cycle > 1) {
|
|
1080
|
+
const linesToClear = shown.length + 4;
|
|
1081
|
+
process.stdout.write(`\x1b[${linesToClear}A\x1b[J`);
|
|
1082
|
+
}
|
|
1083
|
+
console.log(chalk.gray(` ${ts} — Cycle ${cycle} | ${shown.length}/${pairs.length} opportunities >= ${minSpread}%\n`));
|
|
1084
|
+
if (shown.length === 0) {
|
|
1085
|
+
console.log(chalk.gray(` No opportunities found.\n`));
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
for (const p of shown) {
|
|
1089
|
+
const spreadColor = p.annualSpread >= 50 ? chalk.green.bold
|
|
1090
|
+
: p.annualSpread >= 20 ? chalk.green : chalk.yellow;
|
|
1091
|
+
const viabilityColor = p.viability === "A" ? chalk.green.bold
|
|
1092
|
+
: p.viability === "B" ? chalk.green
|
|
1093
|
+
: p.viability === "C" ? chalk.yellow
|
|
1094
|
+
: chalk.red;
|
|
1095
|
+
const longFund = (p.long.fundingRate * 100).toFixed(4);
|
|
1096
|
+
const shortFund = (p.short.fundingRate * 100).toFixed(4);
|
|
1097
|
+
const fmtOi = p.minOiUsd >= 1_000_000 ? `$${(p.minOiUsd / 1_000_000).toFixed(1)}M`
|
|
1098
|
+
: p.minOiUsd >= 1_000 ? `$${(p.minOiUsd / 1_000).toFixed(0)}K`
|
|
1099
|
+
: `$${p.minOiUsd.toFixed(0)}`;
|
|
1100
|
+
console.log(` ${chalk.white.bold(p.underlying.padEnd(10))} ` +
|
|
1101
|
+
`${spreadColor(`${p.annualSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
1102
|
+
`${viabilityColor(p.viability)} ` +
|
|
1103
|
+
`L:${p.long.dex}(${longFund}%) ` +
|
|
1104
|
+
`S:${p.short.dex}(${shortFund}%) ` +
|
|
1105
|
+
`$${formatUsd(p.long.markPrice)} ` +
|
|
1106
|
+
chalk.gray(`gap:${p.priceGapPct.toFixed(3)}%`) + ` ` +
|
|
1107
|
+
viabilityColor(`OI:${fmtOi}`));
|
|
1108
|
+
}
|
|
1109
|
+
console.log();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
catch (err) {
|
|
1113
|
+
console.log(chalk.red(` ${ts} Error: ${err instanceof Error ? err.message : err}\n`));
|
|
1114
|
+
}
|
|
1115
|
+
await new Promise(r => setTimeout(r, intervalSec * 1000));
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
// ── arb dex-auto ── (HIP-3 cross-dex auto arb)
|
|
1119
|
+
if (getHLAdapterForDex) {
|
|
1120
|
+
arb
|
|
1121
|
+
.command("dex-auto")
|
|
1122
|
+
.description("Auto-execute HIP-3 cross-dex funding arb (long low-funding, short high-funding)")
|
|
1123
|
+
.option("--min-spread <pct>", "Min annual spread to enter (%)", "30")
|
|
1124
|
+
.option("--close-spread <pct>", "Close when spread drops below (%)", "5")
|
|
1125
|
+
.option("--size <usd>", "Position size per leg ($)", "100")
|
|
1126
|
+
.option("--max-positions <n>", "Max simultaneous arb positions", "5")
|
|
1127
|
+
.option("--min-oi <usd>", "Min OI (USD) to enter", "50000")
|
|
1128
|
+
.option("--min-grade <grade>", "Min viability grade: A, B, C, D", "C")
|
|
1129
|
+
.option("--interval <seconds>", "Check interval", "120")
|
|
1130
|
+
.option("--dry-run", "Simulate without executing trades")
|
|
1131
|
+
.action(async (opts) => {
|
|
1132
|
+
const minSpread = parseFloat(opts.minSpread);
|
|
1133
|
+
const closeSpread = parseFloat(opts.closeSpread);
|
|
1134
|
+
const sizeUsd = parseFloat(opts.size);
|
|
1135
|
+
const maxPositions = parseInt(opts.maxPositions);
|
|
1136
|
+
const minOi = parseFloat(opts.minOi);
|
|
1137
|
+
const gradeOrder = { A: 0, B: 1, C: 2, D: 3 };
|
|
1138
|
+
const minGradeIdx = gradeOrder[opts.minGrade.toUpperCase()] ?? 2;
|
|
1139
|
+
const intervalMs = parseInt(opts.interval) * 1000;
|
|
1140
|
+
// Check both subcommand option and global/argv (Commander may route --dry-run to parent)
|
|
1141
|
+
const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
|
|
1142
|
+
const openPositions = [];
|
|
1143
|
+
if (!isJson()) {
|
|
1144
|
+
console.log(chalk.cyan.bold("\n HIP-3 Cross-Dex Arb Bot\n"));
|
|
1145
|
+
console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
|
|
1146
|
+
console.log(` Enter spread: >= ${minSpread}% annual`);
|
|
1147
|
+
console.log(` Close spread: <= ${closeSpread}% annual`);
|
|
1148
|
+
console.log(` Size per leg: $${sizeUsd}`);
|
|
1149
|
+
console.log(` Max positions: ${maxPositions}`);
|
|
1150
|
+
console.log(` Min OI: $${formatUsd(minOi)}`);
|
|
1151
|
+
console.log(` Min grade: ${opts.minGrade.toUpperCase()}`);
|
|
1152
|
+
console.log(` Interval: ${opts.interval}s`);
|
|
1153
|
+
console.log(chalk.gray("\n Monitoring... (Ctrl+C to stop)\n"));
|
|
1154
|
+
}
|
|
1155
|
+
const cycle = async () => {
|
|
1156
|
+
try {
|
|
1157
|
+
const pairs = await scanDexArb({
|
|
1158
|
+
minAnnualSpread: 0, // get all, filter ourselves
|
|
1159
|
+
includeNative: true,
|
|
1160
|
+
});
|
|
1161
|
+
const now = new Date().toLocaleTimeString();
|
|
1162
|
+
// Check close conditions
|
|
1163
|
+
for (let i = openPositions.length - 1; i >= 0; i--) {
|
|
1164
|
+
const pos = openPositions[i];
|
|
1165
|
+
// Find current pair for this position
|
|
1166
|
+
const current = pairs.find(p => p.underlying === pos.underlying &&
|
|
1167
|
+
((p.long.dex === pos.longDex && p.short.dex === pos.shortDex) ||
|
|
1168
|
+
(p.long.dex === pos.shortDex && p.short.dex === pos.longDex)));
|
|
1169
|
+
const currentSpread = current?.annualSpread ?? 0;
|
|
1170
|
+
if (currentSpread <= closeSpread || !current) {
|
|
1171
|
+
const reason = !current ? "pair disappeared" : `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
|
|
1172
|
+
if (!isJson())
|
|
1173
|
+
console.log(chalk.yellow(` ${now} CLOSE ${pos.underlying} — ${reason}`));
|
|
1174
|
+
if (!dryRun) {
|
|
1175
|
+
try {
|
|
1176
|
+
const longAdapter = await getHLAdapterForDex(pos.longDex);
|
|
1177
|
+
const shortAdapter = await getHLAdapterForDex(pos.shortDex);
|
|
1178
|
+
await Promise.all([
|
|
1179
|
+
longAdapter.marketOrder(pos.longSymbol, "sell", pos.size),
|
|
1180
|
+
shortAdapter.marketOrder(pos.shortSymbol, "buy", pos.size),
|
|
1181
|
+
]);
|
|
1182
|
+
logExecution({
|
|
1183
|
+
type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
|
|
1184
|
+
symbol: pos.underlying, side: "close", size: pos.size,
|
|
1185
|
+
status: "success", dryRun: false,
|
|
1186
|
+
meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
|
|
1187
|
+
});
|
|
1188
|
+
if (!isJson())
|
|
1189
|
+
console.log(chalk.green(` ${now} CLOSED ${pos.underlying} — both legs`));
|
|
1190
|
+
}
|
|
1191
|
+
catch (err) {
|
|
1192
|
+
logExecution({
|
|
1193
|
+
type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
|
|
1194
|
+
symbol: pos.underlying, side: "close", size: pos.size,
|
|
1195
|
+
status: "failed", dryRun: false,
|
|
1196
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1197
|
+
meta: { longDex: pos.longDex, shortDex: pos.shortDex },
|
|
1198
|
+
});
|
|
1199
|
+
if (!isJson())
|
|
1200
|
+
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.underlying}: ${err instanceof Error ? err.message : err}`));
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
openPositions.splice(i, 1);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
// Check entry conditions
|
|
1207
|
+
if (openPositions.length < maxPositions) {
|
|
1208
|
+
for (const pair of pairs) {
|
|
1209
|
+
if (openPositions.some(p => p.underlying === pair.underlying))
|
|
1210
|
+
continue;
|
|
1211
|
+
if (openPositions.length >= maxPositions)
|
|
1212
|
+
break;
|
|
1213
|
+
if (pair.annualSpread < minSpread)
|
|
1214
|
+
continue;
|
|
1215
|
+
if (pair.minOiUsd < minOi)
|
|
1216
|
+
continue;
|
|
1217
|
+
if (gradeOrder[pair.viability] > minGradeIdx)
|
|
1218
|
+
continue;
|
|
1219
|
+
// Calculate size in asset units
|
|
1220
|
+
const avgPrice = (pair.long.markPrice + pair.short.markPrice) / 2;
|
|
1221
|
+
if (avgPrice <= 0)
|
|
1222
|
+
continue;
|
|
1223
|
+
const szDecimals = Math.min(pair.long.szDecimals, pair.short.szDecimals);
|
|
1224
|
+
const rawSize = sizeUsd / avgPrice;
|
|
1225
|
+
const size = rawSize.toFixed(szDecimals);
|
|
1226
|
+
if (!isJson()) {
|
|
1227
|
+
console.log(chalk.green(` ${now} ENTER ${pair.underlying} — spread ${pair.annualSpread.toFixed(1)}% grade:${pair.viability} OI:$${formatUsd(pair.minOiUsd)}` +
|
|
1228
|
+
`\n Long ${pair.long.dex}:${pair.long.base} (${(pair.long.fundingRate * 100).toFixed(4)}%)` +
|
|
1229
|
+
` | Short ${pair.short.dex}:${pair.short.base} (${(pair.short.fundingRate * 100).toFixed(4)}%)` +
|
|
1230
|
+
` | ${size} units @ $${avgPrice.toFixed(2)}`));
|
|
1231
|
+
}
|
|
1232
|
+
if (!dryRun) {
|
|
1233
|
+
try {
|
|
1234
|
+
const longAdapter = await getHLAdapterForDex(pair.long.dex);
|
|
1235
|
+
const shortAdapter = await getHLAdapterForDex(pair.short.dex);
|
|
1236
|
+
await Promise.all([
|
|
1237
|
+
longAdapter.marketOrder(pair.long.raw, "buy", size),
|
|
1238
|
+
shortAdapter.marketOrder(pair.short.raw, "sell", size),
|
|
1239
|
+
]);
|
|
1240
|
+
logExecution({
|
|
1241
|
+
type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
|
|
1242
|
+
symbol: pair.underlying, side: "entry", size,
|
|
1243
|
+
status: "success", dryRun: false,
|
|
1244
|
+
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread, viability: pair.viability, avgPrice },
|
|
1245
|
+
});
|
|
1246
|
+
if (!isJson())
|
|
1247
|
+
console.log(chalk.green(` ${now} FILLED ${pair.underlying} — both legs`));
|
|
1248
|
+
}
|
|
1249
|
+
catch (err) {
|
|
1250
|
+
logExecution({
|
|
1251
|
+
type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
|
|
1252
|
+
symbol: pair.underlying, side: "entry", size,
|
|
1253
|
+
status: "failed", dryRun: false,
|
|
1254
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1255
|
+
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread },
|
|
1256
|
+
});
|
|
1257
|
+
if (!isJson())
|
|
1258
|
+
console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${err instanceof Error ? err.message : err}`));
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
openPositions.push({
|
|
1263
|
+
underlying: pair.underlying,
|
|
1264
|
+
longDex: pair.long.dex,
|
|
1265
|
+
longSymbol: pair.long.raw,
|
|
1266
|
+
shortDex: pair.short.dex,
|
|
1267
|
+
shortSymbol: pair.short.raw,
|
|
1268
|
+
size,
|
|
1269
|
+
entrySpread: pair.annualSpread,
|
|
1270
|
+
entryTime: new Date().toISOString(),
|
|
1271
|
+
longPrice: pair.long.markPrice,
|
|
1272
|
+
shortPrice: pair.short.markPrice,
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
// Status
|
|
1277
|
+
if (isJson()) {
|
|
1278
|
+
printJson(jsonOk({
|
|
1279
|
+
timestamp: new Date().toISOString(),
|
|
1280
|
+
openPositions,
|
|
1281
|
+
availablePairs: pairs.filter(p => p.annualSpread >= minSpread && p.minOiUsd >= minOi).length,
|
|
1282
|
+
}));
|
|
1283
|
+
}
|
|
1284
|
+
else if (openPositions.length > 0) {
|
|
1285
|
+
console.log(chalk.gray(` ${now} Positions: ${openPositions.length}/${maxPositions} — ` +
|
|
1286
|
+
openPositions.map(p => `${p.underlying}(${p.entrySpread.toFixed(0)}%)`).join(", ")));
|
|
1287
|
+
}
|
|
1288
|
+
else {
|
|
1289
|
+
console.log(chalk.gray(` ${now} No positions. ${pairs.filter(p => p.annualSpread >= minSpread).length} pairs above ${minSpread}%`));
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
catch (err) {
|
|
1293
|
+
if (!isJson())
|
|
1294
|
+
console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
await cycle();
|
|
1298
|
+
setInterval(cycle, intervalMs);
|
|
1299
|
+
await new Promise(() => { }); // keep alive
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
// ── Orderbook helpers for monitor ──
|
|
1304
|
+
async function fetchHLOrderbook(symbol) {
|
|
1305
|
+
const { HYPERLIQUID_API_URL } = await import("../shared-api.js");
|
|
1306
|
+
const res = await fetch(HYPERLIQUID_API_URL, {
|
|
1307
|
+
method: "POST",
|
|
1308
|
+
headers: { "Content-Type": "application/json" },
|
|
1309
|
+
body: JSON.stringify({ type: "l2Book", coin: symbol }),
|
|
1310
|
+
});
|
|
1311
|
+
const json = await res.json();
|
|
1312
|
+
const bids = json.levels?.[0] ?? [];
|
|
1313
|
+
return bids.slice(0, 10).map((l) => [Number(l.px), Number(l.sz)]);
|
|
1314
|
+
}
|
|
1315
|
+
async function fetchLighterOrderbook(symbol) {
|
|
1316
|
+
const { LIGHTER_API_URL } = await import("../shared-api.js");
|
|
1317
|
+
const detailsRes = await fetch(`${LIGHTER_API_URL}/api/v1/orderBookDetails`);
|
|
1318
|
+
const details = await detailsRes.json();
|
|
1319
|
+
const m = (details.order_book_details ?? [])
|
|
1320
|
+
.find(d => d.symbol === symbol);
|
|
1321
|
+
if (!m)
|
|
1322
|
+
return [];
|
|
1323
|
+
const marketId = Number(m.market_id);
|
|
1324
|
+
const obRes = await fetch(`${LIGHTER_API_URL}/api/v1/orderBookOrders?market_id=${marketId}&limit=10`);
|
|
1325
|
+
const ob = await obRes.json();
|
|
1326
|
+
const bids = ob.bids ?? [];
|
|
1327
|
+
return bids.map(l => [Number(l.price), Number(l.remaining_base_amount)]);
|
|
1328
|
+
}
|