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,397 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { computeNetSpread, computeRoundTripCostPct, getNextSettlement, isNearSettlement, isSpreadReversed, } from "../commands/arb-auto.js";
|
|
3
|
+
/**
|
|
4
|
+
* Mirrors the direction logic in arb-auto.ts:
|
|
5
|
+
* Sort all available exchange rates by hourly-normalized rate.
|
|
6
|
+
* Long on the lowest-rate exchange, short on the highest.
|
|
7
|
+
*/
|
|
8
|
+
function determine3DexDirection(snap) {
|
|
9
|
+
return {
|
|
10
|
+
longExchange: snap.longExch,
|
|
11
|
+
shortExchange: snap.shortExch,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Mirrors the funding accumulation logic in arb-auto:
|
|
16
|
+
* Estimate funding collected based on rate differential and elapsed time.
|
|
17
|
+
*/
|
|
18
|
+
function accumulateFunding(pos, current, nowMs) {
|
|
19
|
+
const elapsedHours = (nowMs - pos.lastCheckTime) / (1000 * 60 * 60);
|
|
20
|
+
const notional = parseFloat(pos.size) * current.markPrice;
|
|
21
|
+
const rateFor = (e) => e === "pacifica" ? current.pacRate : e === "hyperliquid" ? current.hlRate : current.ltRate;
|
|
22
|
+
const longHourly = rateFor(pos.longExchange) / 1; // all exchanges are hourly
|
|
23
|
+
const shortHourly = rateFor(pos.shortExchange) / 1; // all exchanges are hourly
|
|
24
|
+
const hourlyIncome = (shortHourly - longHourly) * notional;
|
|
25
|
+
return hourlyIncome * elapsedHours;
|
|
26
|
+
}
|
|
27
|
+
// ──────────────────────────────────────────────
|
|
28
|
+
// 3-DEX direction
|
|
29
|
+
// ──────────────────────────────────────────────
|
|
30
|
+
describe("3-DEX direction determination", () => {
|
|
31
|
+
it("uses longExch/shortExch from snapshot (all 3 available)", () => {
|
|
32
|
+
const snap = {
|
|
33
|
+
symbol: "BTC",
|
|
34
|
+
pacRate: 0.0006, // pac = 0.0006/hr
|
|
35
|
+
hlRate: 0.0003, // hl = 0.0003/hr
|
|
36
|
+
ltRate: 0.0002, // lt = 0.0002/hr (lowest!)
|
|
37
|
+
spread: 240.9,
|
|
38
|
+
longExch: "lighter",
|
|
39
|
+
shortExch: "pacifica",
|
|
40
|
+
markPrice: 60000,
|
|
41
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
42
|
+
};
|
|
43
|
+
const { longExchange, shortExchange } = determine3DexDirection(snap);
|
|
44
|
+
expect(longExchange).toBe("lighter");
|
|
45
|
+
expect(shortExchange).toBe("pacifica");
|
|
46
|
+
});
|
|
47
|
+
it("handles pac vs hl only (lighter missing)", () => {
|
|
48
|
+
const snap = {
|
|
49
|
+
symbol: "ETH",
|
|
50
|
+
pacRate: 0.002, // pac = 0.002/hr (highest)
|
|
51
|
+
hlRate: 0.00005, // hl = 0.00005/hr (lowest)
|
|
52
|
+
ltRate: 0, // no lighter
|
|
53
|
+
spread: 175.2,
|
|
54
|
+
longExch: "hyperliquid",
|
|
55
|
+
shortExch: "pacifica",
|
|
56
|
+
markPrice: 3000,
|
|
57
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
58
|
+
};
|
|
59
|
+
const { longExchange, shortExchange } = determine3DexDirection(snap);
|
|
60
|
+
expect(longExchange).toBe("hyperliquid");
|
|
61
|
+
expect(shortExchange).toBe("pacifica");
|
|
62
|
+
});
|
|
63
|
+
it("handles lighter vs pac when HL missing", () => {
|
|
64
|
+
const snap = {
|
|
65
|
+
symbol: "SOL",
|
|
66
|
+
pacRate: 0.0001,
|
|
67
|
+
hlRate: 0,
|
|
68
|
+
ltRate: 0.001,
|
|
69
|
+
spread: 98.55,
|
|
70
|
+
longExch: "pacifica",
|
|
71
|
+
shortExch: "lighter",
|
|
72
|
+
markPrice: 150,
|
|
73
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
74
|
+
};
|
|
75
|
+
const { longExchange, shortExchange } = determine3DexDirection(snap);
|
|
76
|
+
expect(longExchange).toBe("pacifica");
|
|
77
|
+
expect(shortExchange).toBe("lighter");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
// ──────────────────────────────────────────────
|
|
81
|
+
// Funding accumulation
|
|
82
|
+
// ──────────────────────────────────────────────
|
|
83
|
+
describe("Funding accumulation tracking", () => {
|
|
84
|
+
const baseTime = Date.now();
|
|
85
|
+
it("accumulates positive income when spread is favorable", () => {
|
|
86
|
+
const pos = {
|
|
87
|
+
symbol: "BTC",
|
|
88
|
+
longExchange: "hyperliquid", // low funding
|
|
89
|
+
shortExchange: "pacifica", // high funding
|
|
90
|
+
size: "0.1",
|
|
91
|
+
entrySpread: 50,
|
|
92
|
+
entryTime: new Date().toISOString(),
|
|
93
|
+
entryMarkPrice: 60000,
|
|
94
|
+
accumulatedFundingUsd: 0,
|
|
95
|
+
lastCheckTime: baseTime,
|
|
96
|
+
};
|
|
97
|
+
const snap = {
|
|
98
|
+
symbol: "BTC",
|
|
99
|
+
pacRate: 0.000125, // pac = 0.000125/hr (all hourly now)
|
|
100
|
+
hlRate: 0.00005, // hl = 0.00005/hr
|
|
101
|
+
ltRate: 0,
|
|
102
|
+
spread: 65.7,
|
|
103
|
+
longExch: "hyperliquid",
|
|
104
|
+
shortExch: "pacifica",
|
|
105
|
+
markPrice: 60000,
|
|
106
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
107
|
+
};
|
|
108
|
+
// 1 hour elapsed
|
|
109
|
+
const income = accumulateFunding(pos, snap, baseTime + 3600_000);
|
|
110
|
+
// shortHourly = 0.000125
|
|
111
|
+
// longHourly = 0.00005
|
|
112
|
+
// diff = 0.000125 - 0.00005 = 0.000075
|
|
113
|
+
// notional = 0.1 * 60000 = 6000
|
|
114
|
+
// income = 0.000075 * 6000 * 1 = $0.45/hr
|
|
115
|
+
expect(income).toBeCloseTo(0.45, 2);
|
|
116
|
+
});
|
|
117
|
+
it("returns zero income when no time elapsed", () => {
|
|
118
|
+
const pos = {
|
|
119
|
+
symbol: "ETH",
|
|
120
|
+
longExchange: "lighter",
|
|
121
|
+
shortExchange: "pacifica",
|
|
122
|
+
size: "1",
|
|
123
|
+
entrySpread: 30,
|
|
124
|
+
entryTime: new Date().toISOString(),
|
|
125
|
+
entryMarkPrice: 3000,
|
|
126
|
+
accumulatedFundingUsd: 0,
|
|
127
|
+
lastCheckTime: baseTime,
|
|
128
|
+
};
|
|
129
|
+
const snap = {
|
|
130
|
+
symbol: "ETH",
|
|
131
|
+
pacRate: 0.0005, // all hourly now
|
|
132
|
+
hlRate: 0.0001,
|
|
133
|
+
ltRate: 0.0001,
|
|
134
|
+
spread: 43.8,
|
|
135
|
+
longExch: "lighter",
|
|
136
|
+
shortExch: "pacifica",
|
|
137
|
+
markPrice: 3000,
|
|
138
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
139
|
+
};
|
|
140
|
+
const income = accumulateFunding(pos, snap, baseTime); // same time
|
|
141
|
+
expect(income).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
it("scales with elapsed time", () => {
|
|
144
|
+
const pos = {
|
|
145
|
+
symbol: "BTC",
|
|
146
|
+
longExchange: "hyperliquid",
|
|
147
|
+
shortExchange: "pacifica",
|
|
148
|
+
size: "0.1",
|
|
149
|
+
entrySpread: 50,
|
|
150
|
+
entryTime: new Date().toISOString(),
|
|
151
|
+
entryMarkPrice: 60000,
|
|
152
|
+
accumulatedFundingUsd: 0,
|
|
153
|
+
lastCheckTime: baseTime,
|
|
154
|
+
};
|
|
155
|
+
const snap = {
|
|
156
|
+
symbol: "BTC",
|
|
157
|
+
pacRate: 0.000125,
|
|
158
|
+
hlRate: 0.00005,
|
|
159
|
+
ltRate: 0,
|
|
160
|
+
spread: 65.7,
|
|
161
|
+
longExch: "hyperliquid",
|
|
162
|
+
shortExch: "pacifica",
|
|
163
|
+
markPrice: 60000,
|
|
164
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
165
|
+
};
|
|
166
|
+
const income1h = accumulateFunding(pos, snap, baseTime + 3600_000);
|
|
167
|
+
const income24h = accumulateFunding(pos, snap, baseTime + 24 * 3600_000);
|
|
168
|
+
expect(income24h).toBeCloseTo(income1h * 24, 2);
|
|
169
|
+
});
|
|
170
|
+
it("can produce negative income if spread reverses", () => {
|
|
171
|
+
const pos = {
|
|
172
|
+
symbol: "BTC",
|
|
173
|
+
longExchange: "pacifica", // originally was low
|
|
174
|
+
shortExchange: "hyperliquid", // originally was high
|
|
175
|
+
size: "0.1",
|
|
176
|
+
entrySpread: 20,
|
|
177
|
+
entryTime: new Date().toISOString(),
|
|
178
|
+
entryMarkPrice: 60000,
|
|
179
|
+
accumulatedFundingUsd: 0,
|
|
180
|
+
lastCheckTime: baseTime,
|
|
181
|
+
};
|
|
182
|
+
// Now PAC rate is HIGHER than HL — bad for our direction
|
|
183
|
+
const snap = {
|
|
184
|
+
symbol: "BTC",
|
|
185
|
+
pacRate: 0.00025, // pac now high: 0.00025/hr
|
|
186
|
+
hlRate: 0.00001, // hl now low: 0.00001/hr
|
|
187
|
+
ltRate: 0,
|
|
188
|
+
spread: 218.3,
|
|
189
|
+
longExch: "hyperliquid",
|
|
190
|
+
shortExch: "pacifica",
|
|
191
|
+
markPrice: 60000,
|
|
192
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
193
|
+
};
|
|
194
|
+
const income = accumulateFunding(pos, snap, baseTime + 3600_000);
|
|
195
|
+
// shortHourly (HL) = 0.00001
|
|
196
|
+
// longHourly (PAC) = 0.00025
|
|
197
|
+
// diff = 0.00001 - 0.00025 = -0.00024
|
|
198
|
+
// notional = 6000
|
|
199
|
+
// income = -0.00024 * 6000 * 1 = -$1.44
|
|
200
|
+
expect(income).toBeLessThan(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
// ──────────────────────────────────────────────
|
|
204
|
+
// Entry/exit conditions with 3 DEXs
|
|
205
|
+
// ──────────────────────────────────────────────
|
|
206
|
+
describe("3-DEX entry/exit conditions", () => {
|
|
207
|
+
const minSpread = 30;
|
|
208
|
+
const closeSpread = 5;
|
|
209
|
+
it("enters when any 2-exchange spread exceeds threshold", () => {
|
|
210
|
+
// Only PAC and LT available, but spread is large
|
|
211
|
+
const absSpread = 45;
|
|
212
|
+
expect(absSpread >= minSpread).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
it("closes when best available spread drops below close threshold", () => {
|
|
215
|
+
const currentSpread = 3;
|
|
216
|
+
expect(currentSpread <= closeSpread).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
it("does not enter if max positions reached", () => {
|
|
219
|
+
const maxPositions = 3;
|
|
220
|
+
const openPositions = 3;
|
|
221
|
+
expect(openPositions >= maxPositions).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
it("skips symbol already in open positions", () => {
|
|
224
|
+
const openPositions = [{
|
|
225
|
+
symbol: "BTC",
|
|
226
|
+
longExchange: "hyperliquid",
|
|
227
|
+
shortExchange: "pacifica",
|
|
228
|
+
size: "0.1",
|
|
229
|
+
entrySpread: 40,
|
|
230
|
+
entryTime: new Date().toISOString(),
|
|
231
|
+
entryMarkPrice: 60000,
|
|
232
|
+
accumulatedFundingUsd: 0.5,
|
|
233
|
+
lastCheckTime: Date.now(),
|
|
234
|
+
}];
|
|
235
|
+
const alreadyOpen = openPositions.some(p => p.symbol === "BTC");
|
|
236
|
+
expect(alreadyOpen).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
// ──────────────────────────────────────────────
|
|
240
|
+
// Net spread calculation
|
|
241
|
+
// ──────────────────────────────────────────────
|
|
242
|
+
describe("computeNetSpread", () => {
|
|
243
|
+
it("correctly deducts annualized round-trip cost from gross spread", () => {
|
|
244
|
+
// gross=30%, hold=7d, roundTrip=0.14% → net = 30 - (0.14/7*365) = 30 - 7.3 = 22.7
|
|
245
|
+
const net = computeNetSpread(30, 7, 0.14);
|
|
246
|
+
expect(net).toBeCloseTo(22.7, 1);
|
|
247
|
+
});
|
|
248
|
+
it("returns gross spread when costs are zero", () => {
|
|
249
|
+
const net = computeNetSpread(50, 7, 0);
|
|
250
|
+
expect(net).toBe(50);
|
|
251
|
+
});
|
|
252
|
+
it("can produce negative net spread when costs exceed gross", () => {
|
|
253
|
+
// gross=5%, hold=1d, roundTrip=0.14% → net = 5 - (0.14*365) = 5 - 51.1 = -46.1
|
|
254
|
+
const net = computeNetSpread(5, 1, 0.14);
|
|
255
|
+
expect(net).toBeLessThan(0);
|
|
256
|
+
});
|
|
257
|
+
it("longer hold periods reduce annualized cost impact", () => {
|
|
258
|
+
const net7d = computeNetSpread(30, 7, 0.14);
|
|
259
|
+
const net30d = computeNetSpread(30, 30, 0.14);
|
|
260
|
+
expect(net30d).toBeGreaterThan(net7d);
|
|
261
|
+
});
|
|
262
|
+
it("includes bridge cost in net spread calculation", () => {
|
|
263
|
+
// Without bridge cost
|
|
264
|
+
const netNoBridge = computeNetSpread(30, 7, 0.14, 0, 100);
|
|
265
|
+
// With $0.50 bridge cost, $100 position
|
|
266
|
+
// bridgeRoundTripPct = (0.5 * 2 / 100) * 100 = 1%
|
|
267
|
+
// bridgeAnnualized = (1/7) * 365 = 52.14%
|
|
268
|
+
const netWithBridge = computeNetSpread(30, 7, 0.14, 0.5, 100);
|
|
269
|
+
expect(netWithBridge).toBeLessThan(netNoBridge);
|
|
270
|
+
// Difference should be the annualized bridge cost
|
|
271
|
+
const bridgeDiff = netNoBridge - netWithBridge;
|
|
272
|
+
expect(bridgeDiff).toBeCloseTo(52.14, 0);
|
|
273
|
+
});
|
|
274
|
+
it("bridge cost impact scales inversely with position size", () => {
|
|
275
|
+
const netSmall = computeNetSpread(30, 7, 0.14, 0.5, 50); // $50 position
|
|
276
|
+
const netLarge = computeNetSpread(30, 7, 0.14, 0.5, 500); // $500 position
|
|
277
|
+
// Larger positions dilute bridge cost
|
|
278
|
+
expect(netLarge).toBeGreaterThan(netSmall);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
describe("computeRoundTripCostPct", () => {
|
|
282
|
+
it("computes round-trip cost for same-fee exchanges", () => {
|
|
283
|
+
// 2 × (0.035% + 0.035%) + 2 × 0.05% = 0.24%
|
|
284
|
+
const cost = computeRoundTripCostPct("hyperliquid", "pacifica", 0.05);
|
|
285
|
+
expect(cost).toBeCloseTo(0.24, 4);
|
|
286
|
+
});
|
|
287
|
+
it("uses default slippage of 0.05%", () => {
|
|
288
|
+
const cost = computeRoundTripCostPct("hyperliquid", "lighter");
|
|
289
|
+
// 2 × (0.035% + 0.035%) + 2 × 0.05% = 0.24%
|
|
290
|
+
expect(cost).toBeCloseTo(0.24, 4);
|
|
291
|
+
});
|
|
292
|
+
it("handles custom slippage", () => {
|
|
293
|
+
const cost = computeRoundTripCostPct("hyperliquid", "pacifica", 0.1);
|
|
294
|
+
// 2 × (0.035% + 0.035%) + 2 × 0.1% = 0.34%
|
|
295
|
+
expect(cost).toBeCloseTo(0.34, 4);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
// ──────────────────────────────────────────────
|
|
299
|
+
// Spread reversal detection
|
|
300
|
+
// ──────────────────────────────────────────────
|
|
301
|
+
describe("Spread reversal detection", () => {
|
|
302
|
+
it("detects reversal when long exchange rate exceeds short", () => {
|
|
303
|
+
const snap = {
|
|
304
|
+
symbol: "BTC",
|
|
305
|
+
pacRate: 0.0001, // PAC low (was short, now low)
|
|
306
|
+
hlRate: 0.0005, // HL high (was long, now high)
|
|
307
|
+
ltRate: 0,
|
|
308
|
+
spread: 30,
|
|
309
|
+
longExch: "pacifica",
|
|
310
|
+
shortExch: "hyperliquid",
|
|
311
|
+
markPrice: 60000,
|
|
312
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
313
|
+
};
|
|
314
|
+
// Position: long HL, short PAC — but now HL hourly (0.0005) > PAC hourly (0.0001)
|
|
315
|
+
const reversed = isSpreadReversed("hyperliquid", "pacifica", snap);
|
|
316
|
+
expect(reversed).toBe(true);
|
|
317
|
+
});
|
|
318
|
+
it("does not flag reversal when spread is still favorable", () => {
|
|
319
|
+
const snap = {
|
|
320
|
+
symbol: "BTC",
|
|
321
|
+
pacRate: 0.001, // PAC high (0.001/hr)
|
|
322
|
+
hlRate: 0.00005, // HL low (0.00005/hr)
|
|
323
|
+
ltRate: 0,
|
|
324
|
+
spread: 65.7,
|
|
325
|
+
longExch: "hyperliquid",
|
|
326
|
+
shortExch: "pacifica",
|
|
327
|
+
markPrice: 60000,
|
|
328
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
329
|
+
};
|
|
330
|
+
// Position: long HL, short PAC — HL hourly (0.00005) < PAC hourly (0.001) → no reversal
|
|
331
|
+
const reversed = isSpreadReversed("hyperliquid", "pacifica", snap);
|
|
332
|
+
expect(reversed).toBe(false);
|
|
333
|
+
});
|
|
334
|
+
it("handles lighter vs pacifica reversal", () => {
|
|
335
|
+
const snap = {
|
|
336
|
+
symbol: "SOL",
|
|
337
|
+
pacRate: 0.0001, // PAC hourly = 0.0001
|
|
338
|
+
hlRate: 0,
|
|
339
|
+
ltRate: 0.0016, // LT 8h rate, hourly = 0.0016/8 = 0.0002
|
|
340
|
+
spread: 10,
|
|
341
|
+
longExch: "pacifica",
|
|
342
|
+
shortExch: "lighter",
|
|
343
|
+
markPrice: 150,
|
|
344
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
345
|
+
};
|
|
346
|
+
// Position: long LT, short PAC — LT hourly (0.0002) > PAC hourly (0.0001) → reversed
|
|
347
|
+
const reversed = isSpreadReversed("lighter", "pacifica", snap);
|
|
348
|
+
expect(reversed).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
// ──────────────────────────────────────────────
|
|
352
|
+
// Settlement timing awareness
|
|
353
|
+
// ──────────────────────────────────────────────
|
|
354
|
+
describe("Settlement timing awareness", () => {
|
|
355
|
+
it("getNextSettlement returns next hour for HL", () => {
|
|
356
|
+
// At 14:30 UTC, next HL settlement is at 15:00 UTC
|
|
357
|
+
const now = new Date("2025-01-15T14:30:00Z");
|
|
358
|
+
const next = getNextSettlement("hyperliquid", now);
|
|
359
|
+
expect(next.getUTCHours()).toBe(15);
|
|
360
|
+
expect(next.getUTCMinutes()).toBe(0);
|
|
361
|
+
});
|
|
362
|
+
it("getNextSettlement returns next hour for PAC (hourly like HL)", () => {
|
|
363
|
+
// At 06:00 UTC, next PAC settlement is at 07:00 UTC
|
|
364
|
+
const now = new Date("2025-01-15T06:30:00Z");
|
|
365
|
+
const next = getNextSettlement("pacifica", now);
|
|
366
|
+
expect(next.getUTCHours()).toBe(7);
|
|
367
|
+
});
|
|
368
|
+
it("getNextSettlement wraps to next day for PAC when at 23:xx", () => {
|
|
369
|
+
// At 23:30 UTC, next PAC settlement is at 00:00 next day
|
|
370
|
+
const now = new Date("2025-01-15T23:30:00Z");
|
|
371
|
+
const next = getNextSettlement("pacifica", now);
|
|
372
|
+
expect(next.getUTCHours()).toBe(0);
|
|
373
|
+
expect(next.getUTCDate()).toBe(16);
|
|
374
|
+
});
|
|
375
|
+
it("isNearSettlement blocks entry within 5 minutes of settlement", () => {
|
|
376
|
+
// 3 minutes before 08:00 UTC settlement — all exchanges settle hourly now
|
|
377
|
+
const now = new Date("2025-01-15T07:57:00Z");
|
|
378
|
+
const result = isNearSettlement("lighter", "pacifica", 5, now);
|
|
379
|
+
expect(result.blocked).toBe(true);
|
|
380
|
+
// Both lighter and pacifica settle at 08:00, lighter checked first
|
|
381
|
+
expect(result.exchange).toBe("lighter");
|
|
382
|
+
expect(result.minutesUntil).toBeLessThanOrEqual(5);
|
|
383
|
+
});
|
|
384
|
+
it("isNearSettlement allows entry far from settlement", () => {
|
|
385
|
+
// 30 minutes past the hour — next settlement is 30 min away
|
|
386
|
+
const now = new Date("2025-01-15T15:30:00Z");
|
|
387
|
+
const result = isNearSettlement("lighter", "pacifica", 5, now);
|
|
388
|
+
expect(result.blocked).toBe(false);
|
|
389
|
+
});
|
|
390
|
+
it("isNearSettlement checks both exchanges", () => {
|
|
391
|
+
// 2 minutes before hourly HL settlement
|
|
392
|
+
const now = new Date("2025-01-15T14:58:00Z");
|
|
393
|
+
const result = isNearSettlement("hyperliquid", "pacifica", 5, now);
|
|
394
|
+
expect(result.blocked).toBe(true);
|
|
395
|
+
expect(result.exchange).toBe("hyperliquid");
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { computeEnhancedStats, normalizeExchangePair, getTimeBucket, } from "../arb-history-stats.js";
|
|
3
|
+
function makeTrade(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
symbol: "ETH",
|
|
6
|
+
exchanges: "hyperliquid+pacifica",
|
|
7
|
+
entryDate: "2025-01-15T10:00:00.000Z",
|
|
8
|
+
exitDate: "2025-01-19T14:00:00.000Z",
|
|
9
|
+
holdDurationMs: 4 * 24 * 60 * 60 * 1000 + 4 * 60 * 60 * 1000, // 4d 4h
|
|
10
|
+
entrySpread: 35.0,
|
|
11
|
+
exitSpread: 8.0,
|
|
12
|
+
netReturn: 5.20,
|
|
13
|
+
status: "completed",
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
describe("normalizeExchangePair", () => {
|
|
18
|
+
it("converts exchange names to abbreviations and sorts", () => {
|
|
19
|
+
expect(normalizeExchangePair("hyperliquid+pacifica")).toBe("HL/PAC");
|
|
20
|
+
expect(normalizeExchangePair("pacifica+hyperliquid")).toBe("HL/PAC");
|
|
21
|
+
expect(normalizeExchangePair("lighter+hyperliquid")).toBe("HL/LT");
|
|
22
|
+
expect(normalizeExchangePair("lighter+pacifica")).toBe("LT/PAC");
|
|
23
|
+
});
|
|
24
|
+
it("handles unknown exchanges with truncation", () => {
|
|
25
|
+
expect(normalizeExchangePair("binance+coinbase")).toBe("BIN/COI");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe("getTimeBucket", () => {
|
|
29
|
+
it("returns correct 4-hour UTC buckets", () => {
|
|
30
|
+
expect(getTimeBucket("2025-01-15T00:00:00.000Z")).toBe("00-04 UTC");
|
|
31
|
+
expect(getTimeBucket("2025-01-15T03:59:59.000Z")).toBe("00-04 UTC");
|
|
32
|
+
expect(getTimeBucket("2025-01-15T04:00:00.000Z")).toBe("04-08 UTC");
|
|
33
|
+
expect(getTimeBucket("2025-01-15T08:30:00.000Z")).toBe("08-12 UTC");
|
|
34
|
+
expect(getTimeBucket("2025-01-15T12:00:00.000Z")).toBe("12-16 UTC");
|
|
35
|
+
expect(getTimeBucket("2025-01-15T16:45:00.000Z")).toBe("16-20 UTC");
|
|
36
|
+
expect(getTimeBucket("2025-01-15T20:00:00.000Z")).toBe("20-24 UTC");
|
|
37
|
+
expect(getTimeBucket("2025-01-15T23:59:59.000Z")).toBe("20-24 UTC");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe("computeEnhancedStats", () => {
|
|
41
|
+
it("handles empty history gracefully", () => {
|
|
42
|
+
const stats = computeEnhancedStats([]);
|
|
43
|
+
expect(stats.avgEntrySpread).toBe(0);
|
|
44
|
+
expect(stats.avgExitSpread).toBe(0);
|
|
45
|
+
expect(stats.avgSpreadDecay).toBe(0);
|
|
46
|
+
expect(stats.byExchangePair).toEqual([]);
|
|
47
|
+
expect(stats.byTimeOfDay).toEqual([]);
|
|
48
|
+
expect(stats.optimalHoldTime).toBeNull();
|
|
49
|
+
expect(stats.optimalHoldTimeMs).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
it("ignores open and failed trades in completed stats", () => {
|
|
52
|
+
const trades = [
|
|
53
|
+
makeTrade({ status: "open", netReturn: 0 }),
|
|
54
|
+
makeTrade({ status: "failed", netReturn: -1 }),
|
|
55
|
+
];
|
|
56
|
+
const stats = computeEnhancedStats(trades);
|
|
57
|
+
expect(stats.byExchangePair).toEqual([]);
|
|
58
|
+
expect(stats.byTimeOfDay).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
it("computes average entry/exit spreads correctly", () => {
|
|
61
|
+
const trades = [
|
|
62
|
+
makeTrade({ entrySpread: 30, exitSpread: 10 }),
|
|
63
|
+
makeTrade({ entrySpread: 40, exitSpread: 6 }),
|
|
64
|
+
];
|
|
65
|
+
const stats = computeEnhancedStats(trades);
|
|
66
|
+
expect(stats.avgEntrySpread).toBe(35);
|
|
67
|
+
expect(stats.avgExitSpread).toBe(8);
|
|
68
|
+
expect(stats.avgSpreadDecay).toBe(27); // (20 + 34) / 2
|
|
69
|
+
});
|
|
70
|
+
it("handles null spreads in averages", () => {
|
|
71
|
+
const trades = [
|
|
72
|
+
makeTrade({ entrySpread: 30, exitSpread: null }),
|
|
73
|
+
makeTrade({ entrySpread: null, exitSpread: 10 }),
|
|
74
|
+
];
|
|
75
|
+
const stats = computeEnhancedStats(trades);
|
|
76
|
+
expect(stats.avgEntrySpread).toBe(30);
|
|
77
|
+
expect(stats.avgExitSpread).toBe(10);
|
|
78
|
+
expect(stats.avgSpreadDecay).toBe(0); // neither has both
|
|
79
|
+
});
|
|
80
|
+
it("groups by exchange pair correctly", () => {
|
|
81
|
+
const trades = [
|
|
82
|
+
makeTrade({ exchanges: "hyperliquid+pacifica", netReturn: 10 }),
|
|
83
|
+
makeTrade({ exchanges: "hyperliquid+pacifica", netReturn: 5 }),
|
|
84
|
+
makeTrade({ exchanges: "hyperliquid+pacifica", netReturn: -2 }),
|
|
85
|
+
makeTrade({ exchanges: "lighter+pacifica", netReturn: 8 }),
|
|
86
|
+
makeTrade({ exchanges: "lighter+pacifica", netReturn: 3 }),
|
|
87
|
+
makeTrade({ exchanges: "hyperliquid+lighter", netReturn: 1 }),
|
|
88
|
+
];
|
|
89
|
+
const stats = computeEnhancedStats(trades);
|
|
90
|
+
expect(stats.byExchangePair).toHaveLength(3);
|
|
91
|
+
// Sorted by trade count descending
|
|
92
|
+
const hlPac = stats.byExchangePair.find(p => p.pair === "HL/PAC");
|
|
93
|
+
expect(hlPac).toBeDefined();
|
|
94
|
+
expect(hlPac.trades).toBe(3);
|
|
95
|
+
expect(hlPac.winRate).toBeCloseTo(66.67, 0);
|
|
96
|
+
expect(hlPac.avgNetPnl).toBeCloseTo(4.33, 1);
|
|
97
|
+
const ltPac = stats.byExchangePair.find(p => p.pair === "LT/PAC");
|
|
98
|
+
expect(ltPac).toBeDefined();
|
|
99
|
+
expect(ltPac.trades).toBe(2);
|
|
100
|
+
expect(ltPac.winRate).toBe(100);
|
|
101
|
+
expect(ltPac.avgNetPnl).toBe(5.5);
|
|
102
|
+
const hlLt = stats.byExchangePair.find(p => p.pair === "HL/LT");
|
|
103
|
+
expect(hlLt).toBeDefined();
|
|
104
|
+
expect(hlLt.trades).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
it("buckets by time of day correctly", () => {
|
|
107
|
+
const trades = [
|
|
108
|
+
makeTrade({ entryDate: "2025-01-15T01:00:00.000Z", netReturn: 12 }),
|
|
109
|
+
makeTrade({ entryDate: "2025-01-16T02:30:00.000Z", netReturn: 8 }),
|
|
110
|
+
makeTrade({ entryDate: "2025-01-17T03:00:00.000Z", netReturn: 16 }),
|
|
111
|
+
makeTrade({ entryDate: "2025-01-18T08:15:00.000Z", netReturn: 5 }),
|
|
112
|
+
makeTrade({ entryDate: "2025-01-19T09:00:00.000Z", netReturn: -3 }),
|
|
113
|
+
makeTrade({ entryDate: "2025-01-20T16:00:00.000Z", netReturn: -2 }),
|
|
114
|
+
makeTrade({ entryDate: "2025-01-20T18:30:00.000Z", netReturn: 1 }),
|
|
115
|
+
];
|
|
116
|
+
const stats = computeEnhancedStats(trades);
|
|
117
|
+
const bucket00 = stats.byTimeOfDay.find(b => b.bucket === "00-04 UTC");
|
|
118
|
+
expect(bucket00).toBeDefined();
|
|
119
|
+
expect(bucket00.trades).toBe(3);
|
|
120
|
+
expect(bucket00.winRate).toBe(100);
|
|
121
|
+
expect(bucket00.avgNetPnl).toBe(12);
|
|
122
|
+
const bucket08 = stats.byTimeOfDay.find(b => b.bucket === "08-12 UTC");
|
|
123
|
+
expect(bucket08).toBeDefined();
|
|
124
|
+
expect(bucket08.trades).toBe(2);
|
|
125
|
+
expect(bucket08.winRate).toBe(50);
|
|
126
|
+
const bucket16 = stats.byTimeOfDay.find(b => b.bucket === "16-20 UTC");
|
|
127
|
+
expect(bucket16).toBeDefined();
|
|
128
|
+
expect(bucket16.trades).toBe(2);
|
|
129
|
+
expect(bucket16.avgNetPnl).toBe(-0.5);
|
|
130
|
+
});
|
|
131
|
+
it("computes optimal hold time as median of winning trades", () => {
|
|
132
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
133
|
+
const trades = [
|
|
134
|
+
makeTrade({ holdDurationMs: 2 * dayMs, netReturn: 5 }), // win
|
|
135
|
+
makeTrade({ holdDurationMs: 4 * dayMs, netReturn: 10 }), // win
|
|
136
|
+
makeTrade({ holdDurationMs: 6 * dayMs, netReturn: 8 }), // win
|
|
137
|
+
makeTrade({ holdDurationMs: 8 * dayMs, netReturn: -3 }), // loss
|
|
138
|
+
makeTrade({ holdDurationMs: 10 * dayMs, netReturn: 12 }), // win
|
|
139
|
+
];
|
|
140
|
+
const stats = computeEnhancedStats(trades);
|
|
141
|
+
// Profitable hold times sorted: 2d, 4d, 6d, 10d
|
|
142
|
+
// Median of 4 items: (4d + 6d) / 2 = 5d
|
|
143
|
+
expect(stats.optimalHoldTimeMs).toBe(5 * dayMs);
|
|
144
|
+
expect(stats.optimalHoldTime).toBe("5d 0h");
|
|
145
|
+
});
|
|
146
|
+
it("optimal hold time with odd number of winning trades", () => {
|
|
147
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
148
|
+
const trades = [
|
|
149
|
+
makeTrade({ holdDurationMs: 3 * dayMs, netReturn: 2 }),
|
|
150
|
+
makeTrade({ holdDurationMs: 5 * dayMs, netReturn: 7 }),
|
|
151
|
+
makeTrade({ holdDurationMs: 7 * dayMs, netReturn: 1 }),
|
|
152
|
+
];
|
|
153
|
+
const stats = computeEnhancedStats(trades);
|
|
154
|
+
// Median of [3d, 5d, 7d] = 5d
|
|
155
|
+
expect(stats.optimalHoldTimeMs).toBe(5 * dayMs);
|
|
156
|
+
expect(stats.optimalHoldTime).toBe("5d 0h");
|
|
157
|
+
});
|
|
158
|
+
it("optimal hold time is null when no profitable trades", () => {
|
|
159
|
+
const trades = [
|
|
160
|
+
makeTrade({ netReturn: -5 }),
|
|
161
|
+
makeTrade({ netReturn: -2 }),
|
|
162
|
+
];
|
|
163
|
+
const stats = computeEnhancedStats(trades);
|
|
164
|
+
expect(stats.optimalHoldTime).toBeNull();
|
|
165
|
+
expect(stats.optimalHoldTimeMs).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
it("skips empty time-of-day buckets", () => {
|
|
168
|
+
const trades = [
|
|
169
|
+
makeTrade({ entryDate: "2025-01-15T02:00:00.000Z" }),
|
|
170
|
+
];
|
|
171
|
+
const stats = computeEnhancedStats(trades);
|
|
172
|
+
// Only the 00-04 bucket should appear
|
|
173
|
+
expect(stats.byTimeOfDay).toHaveLength(1);
|
|
174
|
+
expect(stats.byTimeOfDay[0].bucket).toBe("00-04 UTC");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
function determinArbDirection(snap) {
|
|
3
|
+
// Short the high-funding exchange (get paid), long the low-funding one
|
|
4
|
+
const shortExchange = snap.pacRate > snap.hlRate ? "pacifica" : "hyperliquid";
|
|
5
|
+
const longExchange = snap.pacRate > snap.hlRate ? "hyperliquid" : "pacifica";
|
|
6
|
+
return { longExchange, shortExchange };
|
|
7
|
+
}
|
|
8
|
+
function computeAnnualizedSpread(pacRate, hlRate) {
|
|
9
|
+
return (pacRate - hlRate) * 24 * 365 * 100;
|
|
10
|
+
}
|
|
11
|
+
describe("Arb direction logic", () => {
|
|
12
|
+
it("shorts high-funding exchange (Pacifica higher)", () => {
|
|
13
|
+
const snap = { symbol: "BTC", pacRate: 0.001, hlRate: 0.0002, spread: 87.6 };
|
|
14
|
+
const { longExchange, shortExchange } = determinArbDirection(snap);
|
|
15
|
+
// Pacifica rate is higher → short Pacifica (get paid funding), long HL
|
|
16
|
+
expect(shortExchange).toBe("pacifica");
|
|
17
|
+
expect(longExchange).toBe("hyperliquid");
|
|
18
|
+
});
|
|
19
|
+
it("shorts high-funding exchange (Hyperliquid higher)", () => {
|
|
20
|
+
const snap = { symbol: "ETH", pacRate: 0.0001, hlRate: 0.0008, spread: -76.65 };
|
|
21
|
+
const { longExchange, shortExchange } = determinArbDirection(snap);
|
|
22
|
+
// HL rate is higher → short HL, long Pacifica
|
|
23
|
+
expect(shortExchange).toBe("hyperliquid");
|
|
24
|
+
expect(longExchange).toBe("pacifica");
|
|
25
|
+
});
|
|
26
|
+
it("handles equal rates", () => {
|
|
27
|
+
const snap = { symbol: "SOL", pacRate: 0.0003, hlRate: 0.0003, spread: 0 };
|
|
28
|
+
const { longExchange, shortExchange } = determinArbDirection(snap);
|
|
29
|
+
// Equal rates → default to long pacifica (pacRate > hlRate is false)
|
|
30
|
+
expect(longExchange).toBe("pacifica");
|
|
31
|
+
expect(shortExchange).toBe("hyperliquid");
|
|
32
|
+
});
|
|
33
|
+
it("handles negative funding rates", () => {
|
|
34
|
+
const snap = { symbol: "DOGE", pacRate: -0.001, hlRate: -0.0002, spread: -87.6 };
|
|
35
|
+
const { longExchange, shortExchange } = determinArbDirection(snap);
|
|
36
|
+
// Pacifica rate is MORE negative → hlRate > pacRate → short HL, long Pacifica
|
|
37
|
+
expect(shortExchange).toBe("hyperliquid");
|
|
38
|
+
expect(longExchange).toBe("pacifica");
|
|
39
|
+
});
|
|
40
|
+
it("handles mixed sign funding rates", () => {
|
|
41
|
+
const snap = { symbol: "ARB", pacRate: 0.001, hlRate: -0.0005, spread: 164.25 };
|
|
42
|
+
const { longExchange, shortExchange } = determinArbDirection(snap);
|
|
43
|
+
// Pac is positive (longs pay), HL is negative (shorts pay)
|
|
44
|
+
// Short Pac (get paid) + Long HL (get paid) = double collect!
|
|
45
|
+
expect(shortExchange).toBe("pacifica");
|
|
46
|
+
expect(longExchange).toBe("hyperliquid");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe("Spread calculation", () => {
|
|
50
|
+
it("computes annualized spread correctly", () => {
|
|
51
|
+
// 0.01% per hour difference → 0.24% per day → 87.6% per year
|
|
52
|
+
const spread = computeAnnualizedSpread(0.0002, 0.0001);
|
|
53
|
+
expect(spread).toBeCloseTo(87.6, 1);
|
|
54
|
+
});
|
|
55
|
+
it("negative spread when HL rate is higher", () => {
|
|
56
|
+
const spread = computeAnnualizedSpread(0.0001, 0.0003);
|
|
57
|
+
expect(spread).toBeLessThan(0);
|
|
58
|
+
expect(Math.abs(spread)).toBeCloseTo(175.2, 1);
|
|
59
|
+
});
|
|
60
|
+
it("zero spread when rates are equal", () => {
|
|
61
|
+
const spread = computeAnnualizedSpread(0.0001, 0.0001);
|
|
62
|
+
expect(spread).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("Entry/exit conditions", () => {
|
|
66
|
+
const minSpread = 30; // 30% annual
|
|
67
|
+
const closeSpread = 5; // 5% annual
|
|
68
|
+
it("should enter when spread exceeds threshold", () => {
|
|
69
|
+
const absSpread = 45; // 45% > 30%
|
|
70
|
+
expect(absSpread >= minSpread).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
it("should NOT enter when spread below threshold", () => {
|
|
73
|
+
const absSpread = 20; // 20% < 30%
|
|
74
|
+
expect(absSpread >= minSpread).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
it("should close when spread drops below close threshold", () => {
|
|
77
|
+
const currentSpread = 3; // 3% < 5%
|
|
78
|
+
expect(currentSpread <= closeSpread).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
it("should NOT close when spread is still profitable", () => {
|
|
81
|
+
const currentSpread = 15; // 15% > 5%
|
|
82
|
+
expect(currentSpread <= closeSpread).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|