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,945 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { existsSync, unlinkSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
// ── Imports from actual source files ──
|
|
5
|
+
import { toHourlyRate, computeAnnualSpread, estimateHourlyFunding } from "../funding.js";
|
|
6
|
+
import { computeNetSpread, computeRoundTripCostPct, isNearSettlement, isSpreadReversed, getNextSettlement, } from "../commands/arb-auto.js";
|
|
7
|
+
import { getMinutesSinceSettlement, aggressiveSettleBoost, computeBasisRisk, formatNotifyMessage, notifyIfEnabled, getLastSettlement, } from "../arb-utils.js";
|
|
8
|
+
import { loadArbState, saveArbState, addPosition, removePosition, updatePosition, getPositions, createInitialState, setStateFilePath, resetStateFilePath, } from "../arb-state.js";
|
|
9
|
+
import { computeEnhancedStats, normalizeExchangePair, getTimeBucket, } from "../arb-history-stats.js";
|
|
10
|
+
import { logExecution, readExecutionLog } from "../execution-log.js";
|
|
11
|
+
// ── Test file paths ──
|
|
12
|
+
const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
|
|
13
|
+
const LOG_FILE = resolve(PERP_DIR, "executions.jsonl");
|
|
14
|
+
const LOG_BACKUP = resolve(PERP_DIR, "executions.jsonl.userflow-backup");
|
|
15
|
+
const TEST_STATE_DIR = resolve(PERP_DIR, "test-arb-userflow");
|
|
16
|
+
const TEST_STATE_FILE = resolve(TEST_STATE_DIR, "arb-state.json");
|
|
17
|
+
// ── Helpers ──
|
|
18
|
+
function makeDefaultConfig() {
|
|
19
|
+
return {
|
|
20
|
+
minSpread: 30,
|
|
21
|
+
closeSpread: 5,
|
|
22
|
+
size: 100,
|
|
23
|
+
holdDays: 7,
|
|
24
|
+
bridgeCost: 0.5,
|
|
25
|
+
maxPositions: 5,
|
|
26
|
+
settleStrategy: "aware",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function makePosition(overrides = {}) {
|
|
30
|
+
return {
|
|
31
|
+
id: `test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
32
|
+
symbol: "ETH",
|
|
33
|
+
longExchange: "hyperliquid",
|
|
34
|
+
shortExchange: "pacifica",
|
|
35
|
+
longSize: 0.5,
|
|
36
|
+
shortSize: 0.5,
|
|
37
|
+
entryTime: "2025-01-15T10:00:00.000Z",
|
|
38
|
+
entrySpread: 35.2,
|
|
39
|
+
entryLongPrice: 3200.5,
|
|
40
|
+
entryShortPrice: 3201.0,
|
|
41
|
+
accumulatedFunding: 0,
|
|
42
|
+
lastCheckTime: "2025-01-15T10:00:00.000Z",
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// ─────────────────────────────────────────────────────────
|
|
47
|
+
// Flow 1: Spread scan → opportunity evaluation → entry decision
|
|
48
|
+
// ─────────────────────────────────────────────────────────
|
|
49
|
+
describe("Flow 1: 스프레드 스캔 → 기회 평가 → 진입 판단", () => {
|
|
50
|
+
// Realistic funding rates:
|
|
51
|
+
// PAC: 0.005% per hour = 0.00005
|
|
52
|
+
// HL: 0.0001% per hour = 0.000001
|
|
53
|
+
// LT: 0.002% per hour = 0.00002
|
|
54
|
+
const pacRawRate = 0.00005;
|
|
55
|
+
const hlRawRate = 0.000001;
|
|
56
|
+
const ltRawRate = 0.00002;
|
|
57
|
+
it("toHourlyRate normalizes each exchange rate to per-hour", () => {
|
|
58
|
+
const pacHourly = toHourlyRate(pacRawRate, "pacifica");
|
|
59
|
+
const hlHourly = toHourlyRate(hlRawRate, "hyperliquid");
|
|
60
|
+
const ltHourly = toHourlyRate(ltRawRate, "lighter");
|
|
61
|
+
// PAC and HL are hourly, so rates remain unchanged
|
|
62
|
+
expect(pacHourly).toBe(pacRawRate);
|
|
63
|
+
expect(hlHourly).toBe(hlRawRate);
|
|
64
|
+
// Lighter API returns 8h rate, so hourly = raw / 8
|
|
65
|
+
expect(ltHourly).toBeCloseTo(ltRawRate / 8);
|
|
66
|
+
});
|
|
67
|
+
it("computeAnnualSpread finds best pair (PAC vs HL has largest spread)", () => {
|
|
68
|
+
const pacHl = computeAnnualSpread(pacRawRate, "pacifica", hlRawRate, "hyperliquid");
|
|
69
|
+
const pacLt = computeAnnualSpread(pacRawRate, "pacifica", ltRawRate, "lighter");
|
|
70
|
+
const hlLt = computeAnnualSpread(hlRawRate, "hyperliquid", ltRawRate, "lighter");
|
|
71
|
+
// PAC-HL has the biggest difference, then PAC-LT, then HL-LT
|
|
72
|
+
expect(pacHl).toBeGreaterThan(pacLt);
|
|
73
|
+
expect(pacLt).toBeGreaterThan(hlLt);
|
|
74
|
+
// PAC-HL: |0.00005 - 0.000001| * 8760 * 100 = 0.000049 * 876000 = 42.924%
|
|
75
|
+
expect(pacHl).toBeCloseTo(42.924, 1);
|
|
76
|
+
});
|
|
77
|
+
it("direction is correct: long HL (low rate), short PAC (high rate)", () => {
|
|
78
|
+
const pacHourly = toHourlyRate(pacRawRate, "pacifica");
|
|
79
|
+
const hlHourly = toHourlyRate(hlRawRate, "hyperliquid");
|
|
80
|
+
// PAC rate > HL rate → short PAC (receive funding), long HL (pay less)
|
|
81
|
+
expect(pacHourly).toBeGreaterThan(hlHourly);
|
|
82
|
+
// This means: longExchange = hyperliquid, shortExchange = pacifica
|
|
83
|
+
});
|
|
84
|
+
it("gross annual spread exceeds 30% minimum", () => {
|
|
85
|
+
const grossSpread = computeAnnualSpread(pacRawRate, "pacifica", hlRawRate, "hyperliquid");
|
|
86
|
+
expect(grossSpread).toBeGreaterThan(30);
|
|
87
|
+
});
|
|
88
|
+
it("computeRoundTripCostPct calculates trading costs", () => {
|
|
89
|
+
const cost = computeRoundTripCostPct("hyperliquid", "pacifica");
|
|
90
|
+
// 2 * (0.035 + 0.035) + 2 * 0.05 = 0.24%
|
|
91
|
+
expect(cost).toBeCloseTo(0.24, 2);
|
|
92
|
+
});
|
|
93
|
+
it("computeNetSpread subtracts costs from gross spread", () => {
|
|
94
|
+
const grossSpread = computeAnnualSpread(pacRawRate, "pacifica", hlRawRate, "hyperliquid");
|
|
95
|
+
const roundTripCost = computeRoundTripCostPct("hyperliquid", "pacifica");
|
|
96
|
+
// With a longer hold period, costs are amortized and net spread remains positive
|
|
97
|
+
const holdDays = 30;
|
|
98
|
+
const netSpread = computeNetSpread(grossSpread, holdDays, roundTripCost, 0.5, 100);
|
|
99
|
+
// Net should be less than gross
|
|
100
|
+
expect(netSpread).toBeLessThan(grossSpread);
|
|
101
|
+
// Positive with a reasonable hold period
|
|
102
|
+
expect(netSpread).toBeGreaterThan(0);
|
|
103
|
+
// Short hold period with bridge cost can make net negative (costs exceed spread)
|
|
104
|
+
const shortHoldNet = computeNetSpread(grossSpread, 1, roundTripCost, 0.5, 100);
|
|
105
|
+
expect(shortHoldNet).toBeLessThan(0);
|
|
106
|
+
});
|
|
107
|
+
it("net spread properly accounts for bridge costs", () => {
|
|
108
|
+
const grossSpread = computeAnnualSpread(pacRawRate, "pacifica", hlRawRate, "hyperliquid");
|
|
109
|
+
const roundTripCost = computeRoundTripCostPct("hyperliquid", "pacifica");
|
|
110
|
+
const noBridge = computeNetSpread(grossSpread, 7, roundTripCost, 0, 0);
|
|
111
|
+
const withBridge = computeNetSpread(grossSpread, 7, roundTripCost, 2, 100);
|
|
112
|
+
expect(withBridge).toBeLessThan(noBridge);
|
|
113
|
+
});
|
|
114
|
+
it("--min-spread threshold accepts good opportunity", () => {
|
|
115
|
+
const grossSpread = computeAnnualSpread(pacRawRate, "pacifica", hlRawRate, "hyperliquid");
|
|
116
|
+
const roundTripCost = computeRoundTripCostPct("hyperliquid", "pacifica");
|
|
117
|
+
const netSpread = computeNetSpread(grossSpread, 7, roundTripCost);
|
|
118
|
+
const minSpread = 30;
|
|
119
|
+
expect(netSpread >= minSpread).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it("--min-spread threshold rejects weak opportunity", () => {
|
|
122
|
+
// Use rates that produce a small spread
|
|
123
|
+
const weakPacRate = 0.000012;
|
|
124
|
+
const weakHlRate = 0.00001;
|
|
125
|
+
const grossSpread = computeAnnualSpread(weakPacRate, "pacifica", weakHlRate, "hyperliquid");
|
|
126
|
+
const roundTripCost = computeRoundTripCostPct("hyperliquid", "pacifica");
|
|
127
|
+
const netSpread = computeNetSpread(grossSpread, 7, roundTripCost);
|
|
128
|
+
const minSpread = 30;
|
|
129
|
+
expect(netSpread >= minSpread).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
it("longer hold period amortizes costs and increases net spread", () => {
|
|
132
|
+
const grossSpread = computeAnnualSpread(pacRawRate, "pacifica", hlRawRate, "hyperliquid");
|
|
133
|
+
const roundTripCost = computeRoundTripCostPct("hyperliquid", "pacifica");
|
|
134
|
+
const short7d = computeNetSpread(grossSpread, 7, roundTripCost, 0.5, 100);
|
|
135
|
+
const long30d = computeNetSpread(grossSpread, 30, roundTripCost, 0.5, 100);
|
|
136
|
+
expect(long30d).toBeGreaterThan(short7d);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
// ─────────────────────────────────────────────────────────
|
|
140
|
+
// Flow 2: Daemon lifecycle — entry → funding accumulation → exit
|
|
141
|
+
// ─────────────────────────────────────────────────────────
|
|
142
|
+
describe("Flow 2: 데몬 라이프사이클 — 진입 → 펀딩 축적 → 스프레드 하락 청산", () => {
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
if (!existsSync(TEST_STATE_DIR))
|
|
145
|
+
mkdirSync(TEST_STATE_DIR, { recursive: true });
|
|
146
|
+
if (existsSync(TEST_STATE_FILE))
|
|
147
|
+
unlinkSync(TEST_STATE_FILE);
|
|
148
|
+
if (existsSync(TEST_STATE_FILE + ".tmp"))
|
|
149
|
+
unlinkSync(TEST_STATE_FILE + ".tmp");
|
|
150
|
+
setStateFilePath(TEST_STATE_FILE);
|
|
151
|
+
// Backup execution log
|
|
152
|
+
if (existsSync(LOG_FILE))
|
|
153
|
+
writeFileSync(LOG_BACKUP, readFileSync(LOG_FILE, "utf-8"));
|
|
154
|
+
if (existsSync(LOG_FILE))
|
|
155
|
+
unlinkSync(LOG_FILE);
|
|
156
|
+
});
|
|
157
|
+
afterEach(() => {
|
|
158
|
+
if (existsSync(TEST_STATE_FILE))
|
|
159
|
+
unlinkSync(TEST_STATE_FILE);
|
|
160
|
+
if (existsSync(TEST_STATE_FILE + ".tmp"))
|
|
161
|
+
unlinkSync(TEST_STATE_FILE + ".tmp");
|
|
162
|
+
resetStateFilePath();
|
|
163
|
+
// Restore execution log
|
|
164
|
+
if (existsSync(LOG_FILE))
|
|
165
|
+
unlinkSync(LOG_FILE);
|
|
166
|
+
if (existsSync(LOG_BACKUP))
|
|
167
|
+
renameSync(LOG_BACKUP, LOG_FILE);
|
|
168
|
+
});
|
|
169
|
+
it("step 1: settlement timing does not block entry (mid-hour)", () => {
|
|
170
|
+
// At 14:30, next settlement is 15:00 → 30 min away, not within 5 min buffer
|
|
171
|
+
const now = new Date("2025-01-15T14:30:00Z");
|
|
172
|
+
const result = isNearSettlement("hyperliquid", "pacifica", 5, now);
|
|
173
|
+
expect(result.blocked).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
it("step 2: aggressiveSettleBoost applies when just after settlement", () => {
|
|
176
|
+
// 2 minutes after settlement at 14:00
|
|
177
|
+
const now = new Date("2025-01-15T14:02:00Z");
|
|
178
|
+
const boost = aggressiveSettleBoost("hyperliquid", "pacifica", 10, now);
|
|
179
|
+
expect(boost).toBeGreaterThan(1.0);
|
|
180
|
+
// At 2 min into 10 min window: 1 + 0.5*(1 - 2/10) = 1.4
|
|
181
|
+
expect(boost).toBeCloseTo(1.4, 1);
|
|
182
|
+
});
|
|
183
|
+
it("step 3: entry spread passes minSpread threshold", () => {
|
|
184
|
+
const pacRate = 0.00005;
|
|
185
|
+
const hlRate = 0.000001;
|
|
186
|
+
const grossSpread = computeAnnualSpread(pacRate, "pacifica", hlRate, "hyperliquid");
|
|
187
|
+
const rtCost = computeRoundTripCostPct("hyperliquid", "pacifica");
|
|
188
|
+
const netSpread = computeNetSpread(grossSpread, 7, rtCost);
|
|
189
|
+
const minSpread = 30;
|
|
190
|
+
expect(netSpread).toBeGreaterThanOrEqual(minSpread);
|
|
191
|
+
});
|
|
192
|
+
it("step 4: position tracked in arb-state after entry", () => {
|
|
193
|
+
const state = createInitialState(makeDefaultConfig());
|
|
194
|
+
saveArbState(state);
|
|
195
|
+
const pos = makePosition({
|
|
196
|
+
symbol: "BTC",
|
|
197
|
+
longExchange: "hyperliquid",
|
|
198
|
+
shortExchange: "pacifica",
|
|
199
|
+
entrySpread: 42.9,
|
|
200
|
+
longSize: 0.001,
|
|
201
|
+
shortSize: 0.001,
|
|
202
|
+
});
|
|
203
|
+
addPosition(pos);
|
|
204
|
+
const positions = getPositions();
|
|
205
|
+
expect(positions).toHaveLength(1);
|
|
206
|
+
expect(positions[0].symbol).toBe("BTC");
|
|
207
|
+
expect(positions[0].entrySpread).toBe(42.9);
|
|
208
|
+
});
|
|
209
|
+
it("step 5: funding accumulation over time (shortRate - longRate) x notional x hours", () => {
|
|
210
|
+
const shortRate = 0.00005; // PAC rate (short side receives)
|
|
211
|
+
const longRate = 0.000001; // HL rate (long side pays)
|
|
212
|
+
const notionalUsd = 1000;
|
|
213
|
+
const hours = 168; // 7 days
|
|
214
|
+
// Short side receives positive funding
|
|
215
|
+
const shortFundingPerHour = estimateHourlyFunding(shortRate, "pacifica", notionalUsd, "short");
|
|
216
|
+
// Long side pays positive funding (but rate is very low)
|
|
217
|
+
const longFundingPerHour = estimateHourlyFunding(longRate, "hyperliquid", notionalUsd, "long");
|
|
218
|
+
// Net hourly: short receives (negative = receive), long pays (positive = pay)
|
|
219
|
+
// Net = shortReceive - longPay = (-shortFunding) - longFunding
|
|
220
|
+
const netPerHour = Math.abs(shortFundingPerHour) - Math.abs(longFundingPerHour);
|
|
221
|
+
const totalFunding = netPerHour * hours;
|
|
222
|
+
expect(netPerHour).toBeGreaterThan(0); // Positive net collection
|
|
223
|
+
expect(totalFunding).toBeCloseTo((shortRate - longRate) * notionalUsd * hours, 4);
|
|
224
|
+
// (0.00005 - 0.000001) * 1000 * 168 = 0.000049 * 168000 = 8.232
|
|
225
|
+
expect(totalFunding).toBeCloseTo(8.232, 2);
|
|
226
|
+
});
|
|
227
|
+
it("step 6+7: spread drops below closeSpread, not reversed → exit", () => {
|
|
228
|
+
const closeSpread = 5;
|
|
229
|
+
// Current spread dropped to 3%
|
|
230
|
+
const currentGross = 3;
|
|
231
|
+
const rtCost = computeRoundTripCostPct("hyperliquid", "pacifica");
|
|
232
|
+
// For exit check we compare gross spread to closeSpread threshold
|
|
233
|
+
expect(currentGross <= closeSpread).toBe(true);
|
|
234
|
+
// Verify spread is NOT reversed (short still has higher rate)
|
|
235
|
+
const snapshot = {
|
|
236
|
+
symbol: "BTC",
|
|
237
|
+
pacRate: 0.000015, // still higher than HL
|
|
238
|
+
hlRate: 0.000012,
|
|
239
|
+
ltRate: 0.000013,
|
|
240
|
+
spread: currentGross,
|
|
241
|
+
longExch: "hyperliquid",
|
|
242
|
+
shortExch: "pacifica",
|
|
243
|
+
markPrice: 100000,
|
|
244
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
245
|
+
};
|
|
246
|
+
const reversed = isSpreadReversed("hyperliquid", "pacifica", snapshot);
|
|
247
|
+
expect(reversed).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
it("step 8+9: position removed and exit logged", () => {
|
|
250
|
+
const state = createInitialState(makeDefaultConfig());
|
|
251
|
+
saveArbState(state);
|
|
252
|
+
addPosition(makePosition({ symbol: "BTC" }));
|
|
253
|
+
// Remove the position
|
|
254
|
+
removePosition("BTC");
|
|
255
|
+
const positions = getPositions();
|
|
256
|
+
expect(positions).toHaveLength(0);
|
|
257
|
+
// Log the exit
|
|
258
|
+
const exitRecord = logExecution({
|
|
259
|
+
type: "arb_close",
|
|
260
|
+
exchange: "hyperliquid+pacifica",
|
|
261
|
+
symbol: "BTC",
|
|
262
|
+
side: "close",
|
|
263
|
+
size: "0.001",
|
|
264
|
+
status: "success",
|
|
265
|
+
dryRun: false,
|
|
266
|
+
meta: { exitReason: "spread", netPnl: 8.23 },
|
|
267
|
+
});
|
|
268
|
+
expect(exitRecord.type).toBe("arb_close");
|
|
269
|
+
expect(exitRecord.meta?.exitReason).toBe("spread");
|
|
270
|
+
const records = readExecutionLog({ type: "arb_close" });
|
|
271
|
+
expect(records).toHaveLength(1);
|
|
272
|
+
expect(records[0].meta?.exitReason).toBe("spread");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
// ─────────────────────────────────────────────────────────
|
|
276
|
+
// Flow 3: Reversal → emergency close
|
|
277
|
+
// ─────────────────────────────────────────────────────────
|
|
278
|
+
describe("Flow 3: 리버설 발생 → 긴급 청산", () => {
|
|
279
|
+
beforeEach(() => {
|
|
280
|
+
if (!existsSync(TEST_STATE_DIR))
|
|
281
|
+
mkdirSync(TEST_STATE_DIR, { recursive: true });
|
|
282
|
+
if (existsSync(TEST_STATE_FILE))
|
|
283
|
+
unlinkSync(TEST_STATE_FILE);
|
|
284
|
+
if (existsSync(TEST_STATE_FILE + ".tmp"))
|
|
285
|
+
unlinkSync(TEST_STATE_FILE + ".tmp");
|
|
286
|
+
setStateFilePath(TEST_STATE_FILE);
|
|
287
|
+
if (existsSync(LOG_FILE))
|
|
288
|
+
writeFileSync(LOG_BACKUP, readFileSync(LOG_FILE, "utf-8"));
|
|
289
|
+
if (existsSync(LOG_FILE))
|
|
290
|
+
unlinkSync(LOG_FILE);
|
|
291
|
+
});
|
|
292
|
+
afterEach(() => {
|
|
293
|
+
if (existsSync(TEST_STATE_FILE))
|
|
294
|
+
unlinkSync(TEST_STATE_FILE);
|
|
295
|
+
if (existsSync(TEST_STATE_FILE + ".tmp"))
|
|
296
|
+
unlinkSync(TEST_STATE_FILE + ".tmp");
|
|
297
|
+
resetStateFilePath();
|
|
298
|
+
if (existsSync(LOG_FILE))
|
|
299
|
+
unlinkSync(LOG_FILE);
|
|
300
|
+
if (existsSync(LOG_BACKUP))
|
|
301
|
+
renameSync(LOG_BACKUP, LOG_FILE);
|
|
302
|
+
});
|
|
303
|
+
it("detects reversal when long rate exceeds short rate", () => {
|
|
304
|
+
// Originally: long HL (low rate), short PAC (high rate)
|
|
305
|
+
// Reversed: HL rate now HIGHER than PAC rate
|
|
306
|
+
const snapshot = {
|
|
307
|
+
symbol: "ETH",
|
|
308
|
+
pacRate: 0.00001, // PAC rate dropped
|
|
309
|
+
hlRate: 0.00005, // HL rate surged
|
|
310
|
+
ltRate: 0.00002,
|
|
311
|
+
spread: 35,
|
|
312
|
+
longExch: "hyperliquid",
|
|
313
|
+
shortExch: "pacifica",
|
|
314
|
+
markPrice: 3200,
|
|
315
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
316
|
+
};
|
|
317
|
+
const reversed = isSpreadReversed("hyperliquid", "pacifica", snapshot);
|
|
318
|
+
expect(reversed).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
it("no reversal when short rate is still higher", () => {
|
|
321
|
+
const snapshot = {
|
|
322
|
+
symbol: "ETH",
|
|
323
|
+
pacRate: 0.00005,
|
|
324
|
+
hlRate: 0.000001,
|
|
325
|
+
ltRate: 0.00002,
|
|
326
|
+
spread: 42,
|
|
327
|
+
longExch: "hyperliquid",
|
|
328
|
+
shortExch: "pacifica",
|
|
329
|
+
markPrice: 3200,
|
|
330
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
331
|
+
};
|
|
332
|
+
const reversed = isSpreadReversed("hyperliquid", "pacifica", snapshot);
|
|
333
|
+
expect(reversed).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
it("full reversal flow: position open → reversal detected → exit logged → notification sent", async () => {
|
|
336
|
+
// Step 1: Position is open
|
|
337
|
+
const state = createInitialState(makeDefaultConfig());
|
|
338
|
+
saveArbState(state);
|
|
339
|
+
addPosition(makePosition({
|
|
340
|
+
symbol: "WIF",
|
|
341
|
+
longExchange: "hyperliquid",
|
|
342
|
+
shortExchange: "pacifica",
|
|
343
|
+
entrySpread: 45,
|
|
344
|
+
}));
|
|
345
|
+
expect(getPositions()).toHaveLength(1);
|
|
346
|
+
// Step 2: Reversal detected
|
|
347
|
+
const snapshot = {
|
|
348
|
+
symbol: "WIF",
|
|
349
|
+
pacRate: 0.000005,
|
|
350
|
+
hlRate: 0.00008,
|
|
351
|
+
ltRate: 0.00003,
|
|
352
|
+
spread: 65,
|
|
353
|
+
longExch: "hyperliquid",
|
|
354
|
+
shortExch: "pacifica",
|
|
355
|
+
markPrice: 2.5,
|
|
356
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
357
|
+
};
|
|
358
|
+
expect(isSpreadReversed("hyperliquid", "pacifica", snapshot)).toBe(true);
|
|
359
|
+
// Step 3: Exit logged with exitReason="reversal"
|
|
360
|
+
const exitRecord = logExecution({
|
|
361
|
+
type: "arb_close",
|
|
362
|
+
exchange: "hyperliquid+pacifica",
|
|
363
|
+
symbol: "WIF",
|
|
364
|
+
side: "close",
|
|
365
|
+
size: "100",
|
|
366
|
+
status: "success",
|
|
367
|
+
dryRun: false,
|
|
368
|
+
meta: { exitReason: "reversal" },
|
|
369
|
+
});
|
|
370
|
+
expect(exitRecord.meta?.exitReason).toBe("reversal");
|
|
371
|
+
// Step 4: Notification contains reversal info
|
|
372
|
+
const msg = formatNotifyMessage("reversal", { symbol: "WIF" });
|
|
373
|
+
expect(msg).toContain("REVERSAL");
|
|
374
|
+
expect(msg).toContain("WIF");
|
|
375
|
+
expect(msg).toContain("emergency close");
|
|
376
|
+
// Step 5: Notification sent with correct filtering
|
|
377
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
378
|
+
await notifyIfEnabled("https://discord.com/api/webhooks/test/abc", ["reversal", "entry", "exit"], "reversal", { symbol: "WIF" }, mockFetch);
|
|
379
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
380
|
+
// Step 6: Position removed from state
|
|
381
|
+
removePosition("WIF");
|
|
382
|
+
expect(getPositions()).toHaveLength(0);
|
|
383
|
+
});
|
|
384
|
+
it("notifyIfEnabled skips notification when event type not in enabled list", async () => {
|
|
385
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
386
|
+
await notifyIfEnabled("https://discord.com/api/webhooks/test/abc", ["entry", "exit"], // reversal NOT included
|
|
387
|
+
"reversal", { symbol: "WIF" }, mockFetch);
|
|
388
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
it("notifyIfEnabled skips notification when no webhook URL", async () => {
|
|
391
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
392
|
+
await notifyIfEnabled(undefined, ["reversal"], "reversal", { symbol: "WIF" }, mockFetch);
|
|
393
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
// ─────────────────────────────────────────────────────────
|
|
397
|
+
// Flow 4: Arb history stats analysis
|
|
398
|
+
// ─────────────────────────────────────────────────────────
|
|
399
|
+
describe("Flow 4: arb history 통계 분석", () => {
|
|
400
|
+
beforeEach(() => {
|
|
401
|
+
if (existsSync(LOG_FILE))
|
|
402
|
+
writeFileSync(LOG_BACKUP, readFileSync(LOG_FILE, "utf-8"));
|
|
403
|
+
if (existsSync(LOG_FILE))
|
|
404
|
+
unlinkSync(LOG_FILE);
|
|
405
|
+
});
|
|
406
|
+
afterEach(() => {
|
|
407
|
+
if (existsSync(LOG_FILE))
|
|
408
|
+
unlinkSync(LOG_FILE);
|
|
409
|
+
if (existsSync(LOG_BACKUP))
|
|
410
|
+
renameSync(LOG_BACKUP, LOG_FILE);
|
|
411
|
+
});
|
|
412
|
+
it("step 1-4: log arb entries and closes, read them back", () => {
|
|
413
|
+
// 3 arb_entry records
|
|
414
|
+
logExecution({
|
|
415
|
+
type: "arb_entry", exchange: "hyperliquid+pacifica", symbol: "BTC",
|
|
416
|
+
side: "entry", size: "0.01", status: "success", dryRun: false,
|
|
417
|
+
meta: { entrySpread: 45, longExchange: "hyperliquid", shortExchange: "pacifica" },
|
|
418
|
+
});
|
|
419
|
+
logExecution({
|
|
420
|
+
type: "arb_entry", exchange: "lighter+pacifica", symbol: "ETH",
|
|
421
|
+
side: "entry", size: "1.0", status: "success", dryRun: false,
|
|
422
|
+
meta: { entrySpread: 35, longExchange: "lighter", shortExchange: "pacifica" },
|
|
423
|
+
});
|
|
424
|
+
logExecution({
|
|
425
|
+
type: "arb_entry", exchange: "hyperliquid+lighter", symbol: "SOL",
|
|
426
|
+
side: "entry", size: "50", status: "success", dryRun: false,
|
|
427
|
+
meta: { entrySpread: 55, longExchange: "hyperliquid", shortExchange: "lighter" },
|
|
428
|
+
});
|
|
429
|
+
// 2 arb_close records (1 winner, 1 loser)
|
|
430
|
+
logExecution({
|
|
431
|
+
type: "arb_close", exchange: "hyperliquid+pacifica", symbol: "BTC",
|
|
432
|
+
side: "close", size: "0.01", status: "success", dryRun: false,
|
|
433
|
+
meta: { exitReason: "spread", netPnl: 15.5 },
|
|
434
|
+
});
|
|
435
|
+
logExecution({
|
|
436
|
+
type: "arb_close", exchange: "lighter+pacifica", symbol: "ETH",
|
|
437
|
+
side: "close", size: "1.0", status: "success", dryRun: false,
|
|
438
|
+
meta: { exitReason: "reversal", netPnl: -3.2 },
|
|
439
|
+
});
|
|
440
|
+
// SOL is still open (no close record)
|
|
441
|
+
const entries = readExecutionLog({ type: "arb_entry" });
|
|
442
|
+
expect(entries).toHaveLength(3);
|
|
443
|
+
const closes = readExecutionLog({ type: "arb_close" });
|
|
444
|
+
expect(closes).toHaveLength(2);
|
|
445
|
+
});
|
|
446
|
+
it("step 5-6: computeEnhancedStats produces correct metrics", () => {
|
|
447
|
+
// Build trades for stats
|
|
448
|
+
const trades = [
|
|
449
|
+
{
|
|
450
|
+
symbol: "BTC",
|
|
451
|
+
exchanges: "hyperliquid+pacifica",
|
|
452
|
+
entryDate: "2025-01-10T02:00:00Z", // 00-04 UTC bucket
|
|
453
|
+
exitDate: "2025-01-17T02:00:00Z",
|
|
454
|
+
holdDurationMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
455
|
+
entrySpread: 45,
|
|
456
|
+
exitSpread: 3,
|
|
457
|
+
netReturn: 15.5,
|
|
458
|
+
status: "completed",
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
symbol: "ETH",
|
|
462
|
+
exchanges: "lighter+pacifica",
|
|
463
|
+
entryDate: "2025-01-12T10:00:00Z", // 08-12 UTC bucket
|
|
464
|
+
exitDate: "2025-01-14T10:00:00Z",
|
|
465
|
+
holdDurationMs: 2 * 24 * 60 * 60 * 1000, // 2 days
|
|
466
|
+
entrySpread: 35,
|
|
467
|
+
exitSpread: 10,
|
|
468
|
+
netReturn: -3.2,
|
|
469
|
+
status: "completed",
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
symbol: "SOL",
|
|
473
|
+
exchanges: "hyperliquid+lighter",
|
|
474
|
+
entryDate: "2025-01-15T22:00:00Z", // 20-24 UTC bucket
|
|
475
|
+
exitDate: null,
|
|
476
|
+
holdDurationMs: 0,
|
|
477
|
+
entrySpread: 55,
|
|
478
|
+
exitSpread: null,
|
|
479
|
+
netReturn: 0,
|
|
480
|
+
status: "open",
|
|
481
|
+
},
|
|
482
|
+
];
|
|
483
|
+
const stats = computeEnhancedStats(trades);
|
|
484
|
+
// Only completed trades are included in stats
|
|
485
|
+
// avgEntrySpread: (45 + 35) / 2 = 40
|
|
486
|
+
expect(stats.avgEntrySpread).toBeCloseTo(40, 1);
|
|
487
|
+
// avgExitSpread: (3 + 10) / 2 = 6.5
|
|
488
|
+
expect(stats.avgExitSpread).toBeCloseTo(6.5, 1);
|
|
489
|
+
// avgSpreadDecay: ((45-3) + (35-10)) / 2 = (42 + 25) / 2 = 33.5
|
|
490
|
+
expect(stats.avgSpreadDecay).toBeCloseTo(33.5, 1);
|
|
491
|
+
});
|
|
492
|
+
it("step 6: byExchangePair groups correctly", () => {
|
|
493
|
+
const trades = [
|
|
494
|
+
{
|
|
495
|
+
symbol: "BTC", exchanges: "hyperliquid+pacifica",
|
|
496
|
+
entryDate: "2025-01-10T02:00:00Z", exitDate: "2025-01-17T02:00:00Z",
|
|
497
|
+
holdDurationMs: 7 * 24 * 3600000, entrySpread: 45, exitSpread: 3,
|
|
498
|
+
netReturn: 15.5, status: "completed",
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
symbol: "WIF", exchanges: "hyperliquid+pacifica",
|
|
502
|
+
entryDate: "2025-01-11T06:00:00Z", exitDate: "2025-01-13T06:00:00Z",
|
|
503
|
+
holdDurationMs: 2 * 24 * 3600000, entrySpread: 50, exitSpread: 8,
|
|
504
|
+
netReturn: 5.0, status: "completed",
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
symbol: "ETH", exchanges: "lighter+pacifica",
|
|
508
|
+
entryDate: "2025-01-12T10:00:00Z", exitDate: "2025-01-14T10:00:00Z",
|
|
509
|
+
holdDurationMs: 2 * 24 * 3600000, entrySpread: 35, exitSpread: 10,
|
|
510
|
+
netReturn: -3.2, status: "completed",
|
|
511
|
+
},
|
|
512
|
+
];
|
|
513
|
+
const stats = computeEnhancedStats(trades);
|
|
514
|
+
// HL/PAC should have 2 trades, LT/PAC should have 1
|
|
515
|
+
expect(stats.byExchangePair).toHaveLength(2);
|
|
516
|
+
const hlPac = stats.byExchangePair.find(p => p.pair === "HL/PAC");
|
|
517
|
+
expect(hlPac).toBeDefined();
|
|
518
|
+
expect(hlPac.trades).toBe(2);
|
|
519
|
+
expect(hlPac.winRate).toBe(100); // both winners
|
|
520
|
+
const ltPac = stats.byExchangePair.find(p => p.pair === "LT/PAC");
|
|
521
|
+
expect(ltPac).toBeDefined();
|
|
522
|
+
expect(ltPac.trades).toBe(1);
|
|
523
|
+
expect(ltPac.winRate).toBe(0); // loser
|
|
524
|
+
});
|
|
525
|
+
it("step 6: byTimeOfDay buckets entries correctly", () => {
|
|
526
|
+
const trades = [
|
|
527
|
+
{
|
|
528
|
+
symbol: "BTC", exchanges: "hyperliquid+pacifica",
|
|
529
|
+
entryDate: "2025-01-10T02:00:00Z", exitDate: "2025-01-17T02:00:00Z",
|
|
530
|
+
holdDurationMs: 7 * 24 * 3600000, entrySpread: 45, exitSpread: 3,
|
|
531
|
+
netReturn: 15.5, status: "completed",
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
symbol: "ETH", exchanges: "hyperliquid+pacifica",
|
|
535
|
+
entryDate: "2025-01-12T03:30:00Z", exitDate: "2025-01-14T10:00:00Z",
|
|
536
|
+
holdDurationMs: 2 * 24 * 3600000, entrySpread: 35, exitSpread: 10,
|
|
537
|
+
netReturn: -3.2, status: "completed",
|
|
538
|
+
},
|
|
539
|
+
];
|
|
540
|
+
const stats = computeEnhancedStats(trades);
|
|
541
|
+
// Both entries are in 00-04 UTC bucket
|
|
542
|
+
const bucket0004 = stats.byTimeOfDay.find(b => b.bucket === "00-04 UTC");
|
|
543
|
+
expect(bucket0004).toBeDefined();
|
|
544
|
+
expect(bucket0004.trades).toBe(2);
|
|
545
|
+
});
|
|
546
|
+
it("step 6: optimalHoldTime is median of profitable trades", () => {
|
|
547
|
+
const trades = [
|
|
548
|
+
{
|
|
549
|
+
symbol: "A", exchanges: "hyperliquid+pacifica",
|
|
550
|
+
entryDate: "2025-01-01T00:00:00Z", exitDate: "2025-01-04T00:00:00Z",
|
|
551
|
+
holdDurationMs: 3 * 24 * 3600000, entrySpread: 40, exitSpread: 3,
|
|
552
|
+
netReturn: 10, status: "completed",
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
symbol: "B", exchanges: "hyperliquid+pacifica",
|
|
556
|
+
entryDate: "2025-01-01T00:00:00Z", exitDate: "2025-01-08T00:00:00Z",
|
|
557
|
+
holdDurationMs: 7 * 24 * 3600000, entrySpread: 50, exitSpread: 4,
|
|
558
|
+
netReturn: 20, status: "completed",
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
symbol: "C", exchanges: "hyperliquid+pacifica",
|
|
562
|
+
entryDate: "2025-01-01T00:00:00Z", exitDate: "2025-01-03T00:00:00Z",
|
|
563
|
+
holdDurationMs: 2 * 24 * 3600000, entrySpread: 30, exitSpread: 15,
|
|
564
|
+
netReturn: -5, status: "completed", // loser, excluded from optimal calc
|
|
565
|
+
},
|
|
566
|
+
];
|
|
567
|
+
const stats = computeEnhancedStats(trades);
|
|
568
|
+
// Only profitable: A (3d) and B (7d) → median = (3+7)/2 = 5 days
|
|
569
|
+
const fiveDaysMs = 5 * 24 * 3600000;
|
|
570
|
+
expect(stats.optimalHoldTimeMs).toBeCloseTo(fiveDaysMs, -3);
|
|
571
|
+
expect(stats.optimalHoldTime).toBe("5d 0h");
|
|
572
|
+
});
|
|
573
|
+
it("step 7: exitReason appears in close records", () => {
|
|
574
|
+
logExecution({
|
|
575
|
+
type: "arb_close", exchange: "hyperliquid+pacifica", symbol: "BTC",
|
|
576
|
+
side: "close", size: "0.01", status: "success", dryRun: false,
|
|
577
|
+
meta: { exitReason: "spread" },
|
|
578
|
+
});
|
|
579
|
+
logExecution({
|
|
580
|
+
type: "arb_close", exchange: "lighter+pacifica", symbol: "ETH",
|
|
581
|
+
side: "close", size: "1.0", status: "success", dryRun: false,
|
|
582
|
+
meta: { exitReason: "reversal" },
|
|
583
|
+
});
|
|
584
|
+
const closes = readExecutionLog({ type: "arb_close" });
|
|
585
|
+
const reasons = closes.map(r => r.meta?.exitReason);
|
|
586
|
+
expect(reasons).toContain("spread");
|
|
587
|
+
expect(reasons).toContain("reversal");
|
|
588
|
+
});
|
|
589
|
+
it("normalizeExchangePair produces consistent abbreviations", () => {
|
|
590
|
+
expect(normalizeExchangePair("hyperliquid+pacifica")).toBe("HL/PAC");
|
|
591
|
+
expect(normalizeExchangePair("pacifica+hyperliquid")).toBe("HL/PAC"); // sorted
|
|
592
|
+
expect(normalizeExchangePair("lighter+pacifica")).toBe("LT/PAC");
|
|
593
|
+
expect(normalizeExchangePair("hyperliquid+lighter")).toBe("HL/LT");
|
|
594
|
+
});
|
|
595
|
+
it("getTimeBucket returns correct 4-hour UTC buckets", () => {
|
|
596
|
+
expect(getTimeBucket("2025-01-10T00:30:00Z")).toBe("00-04 UTC");
|
|
597
|
+
expect(getTimeBucket("2025-01-10T03:59:59Z")).toBe("00-04 UTC");
|
|
598
|
+
expect(getTimeBucket("2025-01-10T04:00:00Z")).toBe("04-08 UTC");
|
|
599
|
+
expect(getTimeBucket("2025-01-10T12:00:00Z")).toBe("12-16 UTC");
|
|
600
|
+
expect(getTimeBucket("2025-01-10T23:59:59Z")).toBe("20-24 UTC");
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
// ─────────────────────────────────────────────────────────
|
|
604
|
+
// Flow 5: Basis risk monitoring
|
|
605
|
+
// ─────────────────────────────────────────────────────────
|
|
606
|
+
describe("Flow 5: 베이시스 리스크 모니터링", () => {
|
|
607
|
+
it("no warning when prices are close together", () => {
|
|
608
|
+
const result = computeBasisRisk(100000, 100050, 3);
|
|
609
|
+
// |100000 - 100050| / 100025 * 100 ≈ 0.05% → no warning
|
|
610
|
+
expect(result.divergencePct).toBeLessThan(1);
|
|
611
|
+
expect(result.warning).toBe(false);
|
|
612
|
+
});
|
|
613
|
+
it("warning when 4% divergence", () => {
|
|
614
|
+
const result = computeBasisRisk(100000, 104000, 3);
|
|
615
|
+
// |100000 - 104000| / 102000 * 100 ≈ 3.92% → warning (> 3%)
|
|
616
|
+
expect(result.divergencePct).toBeCloseTo(3.92, 1);
|
|
617
|
+
expect(result.warning).toBe(true);
|
|
618
|
+
});
|
|
619
|
+
it("threshold is configurable: tight threshold triggers warning on smaller divergence", () => {
|
|
620
|
+
// 1% divergence with 0.5% threshold
|
|
621
|
+
const result = computeBasisRisk(100, 101, 0.5);
|
|
622
|
+
expect(result.warning).toBe(true);
|
|
623
|
+
expect(result.divergencePct).toBeCloseTo(1.0, 0);
|
|
624
|
+
});
|
|
625
|
+
it("threshold is configurable: loose threshold does not trigger on moderate divergence", () => {
|
|
626
|
+
// 2% divergence with 5% threshold
|
|
627
|
+
const result = computeBasisRisk(100, 102, 5);
|
|
628
|
+
expect(result.warning).toBe(false);
|
|
629
|
+
expect(result.divergencePct).toBeCloseTo(1.98, 1);
|
|
630
|
+
});
|
|
631
|
+
it("basis risk notification is formatted correctly", () => {
|
|
632
|
+
const msg = formatNotifyMessage("basis", {
|
|
633
|
+
symbol: "BTC",
|
|
634
|
+
divergencePct: 4.2,
|
|
635
|
+
longExchange: "HL",
|
|
636
|
+
shortExchange: "PAC",
|
|
637
|
+
});
|
|
638
|
+
expect(msg).toContain("BASIS RISK");
|
|
639
|
+
expect(msg).toContain("BTC");
|
|
640
|
+
expect(msg).toContain("4.2%");
|
|
641
|
+
expect(msg).toContain("HL/PAC");
|
|
642
|
+
});
|
|
643
|
+
it("zero or negative prices return safe defaults", () => {
|
|
644
|
+
expect(computeBasisRisk(0, 100).warning).toBe(false);
|
|
645
|
+
expect(computeBasisRisk(100, 0).warning).toBe(false);
|
|
646
|
+
expect(computeBasisRisk(0, 0).warning).toBe(false);
|
|
647
|
+
expect(computeBasisRisk(-1, 100).warning).toBe(false);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
// ─────────────────────────────────────────────────────────
|
|
651
|
+
// Flow 6: Crash recovery scenario
|
|
652
|
+
// ─────────────────────────────────────────────────────────
|
|
653
|
+
describe("Flow 6: 크래시 복구 시나리오", () => {
|
|
654
|
+
const TMP_FILE = TEST_STATE_FILE + ".tmp";
|
|
655
|
+
beforeEach(() => {
|
|
656
|
+
if (!existsSync(TEST_STATE_DIR))
|
|
657
|
+
mkdirSync(TEST_STATE_DIR, { recursive: true });
|
|
658
|
+
if (existsSync(TEST_STATE_FILE))
|
|
659
|
+
unlinkSync(TEST_STATE_FILE);
|
|
660
|
+
if (existsSync(TMP_FILE))
|
|
661
|
+
unlinkSync(TMP_FILE);
|
|
662
|
+
setStateFilePath(TEST_STATE_FILE);
|
|
663
|
+
});
|
|
664
|
+
afterEach(() => {
|
|
665
|
+
if (existsSync(TEST_STATE_FILE))
|
|
666
|
+
unlinkSync(TEST_STATE_FILE);
|
|
667
|
+
if (existsSync(TMP_FILE))
|
|
668
|
+
unlinkSync(TMP_FILE);
|
|
669
|
+
resetStateFilePath();
|
|
670
|
+
});
|
|
671
|
+
it("step 1-2: create state, add BTC and ETH positions", () => {
|
|
672
|
+
const state = createInitialState(makeDefaultConfig());
|
|
673
|
+
saveArbState(state);
|
|
674
|
+
addPosition(makePosition({
|
|
675
|
+
symbol: "BTC",
|
|
676
|
+
longExchange: "hyperliquid",
|
|
677
|
+
shortExchange: "pacifica",
|
|
678
|
+
longSize: 0.01,
|
|
679
|
+
shortSize: 0.01,
|
|
680
|
+
accumulatedFunding: 5.67,
|
|
681
|
+
}));
|
|
682
|
+
addPosition(makePosition({
|
|
683
|
+
symbol: "ETH",
|
|
684
|
+
longExchange: "lighter",
|
|
685
|
+
shortExchange: "pacifica",
|
|
686
|
+
longSize: 0.5,
|
|
687
|
+
shortSize: 0.5,
|
|
688
|
+
accumulatedFunding: 12.34,
|
|
689
|
+
}));
|
|
690
|
+
const positions = getPositions();
|
|
691
|
+
expect(positions).toHaveLength(2);
|
|
692
|
+
});
|
|
693
|
+
it("step 3-4: crash recovery — positions preserved after reload", () => {
|
|
694
|
+
// Set up state with positions
|
|
695
|
+
const state = createInitialState(makeDefaultConfig());
|
|
696
|
+
state.lastSuccessfulScanTime = "2025-01-15T14:30:00.000Z";
|
|
697
|
+
saveArbState(state);
|
|
698
|
+
addPosition(makePosition({
|
|
699
|
+
symbol: "BTC",
|
|
700
|
+
accumulatedFunding: 5.67,
|
|
701
|
+
}));
|
|
702
|
+
addPosition(makePosition({
|
|
703
|
+
symbol: "ETH",
|
|
704
|
+
accumulatedFunding: 12.34,
|
|
705
|
+
}));
|
|
706
|
+
// Simulate crash: just reload
|
|
707
|
+
const recovered = loadArbState();
|
|
708
|
+
expect(recovered).not.toBeNull();
|
|
709
|
+
expect(recovered.positions).toHaveLength(2);
|
|
710
|
+
const btc = recovered.positions.find(p => p.symbol === "BTC");
|
|
711
|
+
const eth = recovered.positions.find(p => p.symbol === "ETH");
|
|
712
|
+
expect(btc.accumulatedFunding).toBe(5.67);
|
|
713
|
+
expect(eth.accumulatedFunding).toBe(12.34);
|
|
714
|
+
});
|
|
715
|
+
it("step 5: lastSuccessfulScanTime preserved", () => {
|
|
716
|
+
const state = createInitialState(makeDefaultConfig());
|
|
717
|
+
state.lastSuccessfulScanTime = "2025-01-15T14:30:00.000Z";
|
|
718
|
+
saveArbState(state);
|
|
719
|
+
const recovered = loadArbState();
|
|
720
|
+
expect(recovered.lastSuccessfulScanTime).toBe("2025-01-15T14:30:00.000Z");
|
|
721
|
+
});
|
|
722
|
+
it("step 6: corrupt main file → .tmp recovery works", () => {
|
|
723
|
+
// Write valid state to .tmp
|
|
724
|
+
const state = createInitialState(makeDefaultConfig());
|
|
725
|
+
state.positions.push(makePosition({
|
|
726
|
+
symbol: "SOL",
|
|
727
|
+
accumulatedFunding: 99.99,
|
|
728
|
+
}));
|
|
729
|
+
writeFileSync(TMP_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
730
|
+
// Corrupt main file
|
|
731
|
+
writeFileSync(TEST_STATE_FILE, "corrupted{{{data", { mode: 0o600 });
|
|
732
|
+
// Recovery should use .tmp
|
|
733
|
+
const recovered = loadArbState();
|
|
734
|
+
expect(recovered).not.toBeNull();
|
|
735
|
+
expect(recovered.positions).toHaveLength(1);
|
|
736
|
+
expect(recovered.positions[0].symbol).toBe("SOL");
|
|
737
|
+
expect(recovered.positions[0].accumulatedFunding).toBe(99.99);
|
|
738
|
+
});
|
|
739
|
+
it("both main and .tmp corrupted → returns null (no crash)", () => {
|
|
740
|
+
writeFileSync(TEST_STATE_FILE, "corrupt main{{{", { mode: 0o600 });
|
|
741
|
+
writeFileSync(TMP_FILE, "corrupt tmp{{{", { mode: 0o600 });
|
|
742
|
+
const state = loadArbState();
|
|
743
|
+
expect(state).toBeNull();
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
// ─────────────────────────────────────────────────────────
|
|
747
|
+
// Flow 7: Heartbeat + exchange down scenario
|
|
748
|
+
// ─────────────────────────────────────────────────────────
|
|
749
|
+
describe("Flow 7: 하트비트 + 거래소 다운 시나리오", () => {
|
|
750
|
+
beforeEach(() => {
|
|
751
|
+
if (!existsSync(TEST_STATE_DIR))
|
|
752
|
+
mkdirSync(TEST_STATE_DIR, { recursive: true });
|
|
753
|
+
if (existsSync(TEST_STATE_FILE))
|
|
754
|
+
unlinkSync(TEST_STATE_FILE);
|
|
755
|
+
if (existsSync(TEST_STATE_FILE + ".tmp"))
|
|
756
|
+
unlinkSync(TEST_STATE_FILE + ".tmp");
|
|
757
|
+
setStateFilePath(TEST_STATE_FILE);
|
|
758
|
+
});
|
|
759
|
+
afterEach(() => {
|
|
760
|
+
if (existsSync(TEST_STATE_FILE))
|
|
761
|
+
unlinkSync(TEST_STATE_FILE);
|
|
762
|
+
if (existsSync(TEST_STATE_FILE + ".tmp"))
|
|
763
|
+
unlinkSync(TEST_STATE_FILE + ".tmp");
|
|
764
|
+
resetStateFilePath();
|
|
765
|
+
});
|
|
766
|
+
it("heartbeat: detects stale scan time (6 min ago exceeds 5 min threshold)", () => {
|
|
767
|
+
const now = new Date("2025-01-15T14:36:00Z");
|
|
768
|
+
const lastScanTime = "2025-01-15T14:30:00Z";
|
|
769
|
+
const minutesAgo = (now.getTime() - new Date(lastScanTime).getTime()) / (1000 * 60);
|
|
770
|
+
expect(minutesAgo).toBe(6);
|
|
771
|
+
const threshold = 5;
|
|
772
|
+
const isStale = minutesAgo > threshold;
|
|
773
|
+
expect(isStale).toBe(true);
|
|
774
|
+
});
|
|
775
|
+
it("heartbeat: formats warning message correctly", () => {
|
|
776
|
+
const msg = formatNotifyMessage("heartbeat", {
|
|
777
|
+
lastScanTime: "2025-01-15T14:30:00Z",
|
|
778
|
+
minutesAgo: 6,
|
|
779
|
+
});
|
|
780
|
+
expect(msg).toContain("HEARTBEAT");
|
|
781
|
+
expect(msg).toContain("6 minutes");
|
|
782
|
+
expect(msg).toContain("2025-01-15T14:30:00Z");
|
|
783
|
+
});
|
|
784
|
+
it("heartbeat: no warning when scan time is recent", () => {
|
|
785
|
+
const now = new Date("2025-01-15T14:31:00Z");
|
|
786
|
+
const lastScanTime = "2025-01-15T14:30:00Z";
|
|
787
|
+
const minutesAgo = (now.getTime() - new Date(lastScanTime).getTime()) / (1000 * 60);
|
|
788
|
+
expect(minutesAgo).toBe(1);
|
|
789
|
+
const isStale = minutesAgo > 5;
|
|
790
|
+
expect(isStale).toBe(false);
|
|
791
|
+
});
|
|
792
|
+
it("blocked exchange prevents new entry (maxPositions simulated with state)", () => {
|
|
793
|
+
const state = createInitialState({
|
|
794
|
+
...makeDefaultConfig(),
|
|
795
|
+
maxPositions: 2,
|
|
796
|
+
});
|
|
797
|
+
saveArbState(state);
|
|
798
|
+
// Add 2 positions — reaching max
|
|
799
|
+
addPosition(makePosition({ symbol: "BTC" }));
|
|
800
|
+
addPosition(makePosition({ symbol: "ETH" }));
|
|
801
|
+
const positions = getPositions();
|
|
802
|
+
const canEnter = positions.length < state.config.maxPositions;
|
|
803
|
+
expect(canEnter).toBe(false);
|
|
804
|
+
});
|
|
805
|
+
it("positions on down exchange are tracked for degraded state", () => {
|
|
806
|
+
const state = createInitialState(makeDefaultConfig());
|
|
807
|
+
saveArbState(state);
|
|
808
|
+
addPosition(makePosition({
|
|
809
|
+
symbol: "ETH",
|
|
810
|
+
longExchange: "hyperliquid",
|
|
811
|
+
shortExchange: "pacifica",
|
|
812
|
+
}));
|
|
813
|
+
addPosition(makePosition({
|
|
814
|
+
symbol: "BTC",
|
|
815
|
+
longExchange: "lighter",
|
|
816
|
+
shortExchange: "pacifica",
|
|
817
|
+
}));
|
|
818
|
+
const positions = getPositions();
|
|
819
|
+
const downExchange = "lighter";
|
|
820
|
+
const degradedPositions = positions.filter(p => p.longExchange === downExchange || p.shortExchange === downExchange);
|
|
821
|
+
expect(degradedPositions).toHaveLength(1);
|
|
822
|
+
expect(degradedPositions[0].symbol).toBe("BTC");
|
|
823
|
+
});
|
|
824
|
+
it("heartbeat notification is sent for stale scans", async () => {
|
|
825
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
826
|
+
await notifyIfEnabled("https://discord.com/api/webhooks/test/hb", ["heartbeat", "reversal"], "heartbeat", { lastScanTime: "2025-01-15T14:30:00Z", minutesAgo: 6 }, mockFetch);
|
|
827
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
828
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
829
|
+
expect(body.content).toContain("HEARTBEAT");
|
|
830
|
+
});
|
|
831
|
+
it("settlement timing: isNearSettlement blocks when within 5 minutes of next hour", () => {
|
|
832
|
+
// At 14:56, next settlement for hourly exchange is 15:00 → 4 min away → blocked
|
|
833
|
+
const now = new Date("2025-01-15T14:56:00Z");
|
|
834
|
+
const result = isNearSettlement("hyperliquid", "pacifica", 5, now);
|
|
835
|
+
expect(result.blocked).toBe(true);
|
|
836
|
+
expect(result.minutesUntil).toBeCloseTo(4, 0);
|
|
837
|
+
});
|
|
838
|
+
it("settlement timing: not blocked when far from settlement", () => {
|
|
839
|
+
const now = new Date("2025-01-15T14:30:00Z");
|
|
840
|
+
const result = isNearSettlement("hyperliquid", "pacifica", 5, now);
|
|
841
|
+
expect(result.blocked).toBe(false);
|
|
842
|
+
});
|
|
843
|
+
it("getNextSettlement returns correct next hour", () => {
|
|
844
|
+
const now = new Date("2025-01-15T14:30:00Z");
|
|
845
|
+
const next = getNextSettlement("hyperliquid", now);
|
|
846
|
+
expect(next.getUTCHours()).toBe(15);
|
|
847
|
+
expect(next.getUTCMinutes()).toBe(0);
|
|
848
|
+
});
|
|
849
|
+
it("getLastSettlement returns previous hour boundary", () => {
|
|
850
|
+
const now = new Date("2025-01-15T14:30:00Z");
|
|
851
|
+
const last = getLastSettlement("hyperliquid", now);
|
|
852
|
+
expect(last.getUTCHours()).toBe(14);
|
|
853
|
+
expect(last.getUTCMinutes()).toBe(0);
|
|
854
|
+
});
|
|
855
|
+
it("getMinutesSinceSettlement returns correct value mid-hour", () => {
|
|
856
|
+
const now = new Date("2025-01-15T14:45:00Z");
|
|
857
|
+
const mins = getMinutesSinceSettlement("hyperliquid", now);
|
|
858
|
+
expect(mins).toBeCloseTo(45, 0);
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
// ─────────────────────────────────────────────────────────
|
|
862
|
+
// Edge cases and additional cross-cutting scenarios
|
|
863
|
+
// ─────────────────────────────────────────────────────────
|
|
864
|
+
describe("Cross-cutting: edge cases and combined scenarios", () => {
|
|
865
|
+
beforeEach(() => {
|
|
866
|
+
if (!existsSync(TEST_STATE_DIR))
|
|
867
|
+
mkdirSync(TEST_STATE_DIR, { recursive: true });
|
|
868
|
+
if (existsSync(TEST_STATE_FILE))
|
|
869
|
+
unlinkSync(TEST_STATE_FILE);
|
|
870
|
+
if (existsSync(TEST_STATE_FILE + ".tmp"))
|
|
871
|
+
unlinkSync(TEST_STATE_FILE + ".tmp");
|
|
872
|
+
setStateFilePath(TEST_STATE_FILE);
|
|
873
|
+
if (existsSync(LOG_FILE))
|
|
874
|
+
writeFileSync(LOG_BACKUP, readFileSync(LOG_FILE, "utf-8"));
|
|
875
|
+
if (existsSync(LOG_FILE))
|
|
876
|
+
unlinkSync(LOG_FILE);
|
|
877
|
+
});
|
|
878
|
+
afterEach(() => {
|
|
879
|
+
if (existsSync(TEST_STATE_FILE))
|
|
880
|
+
unlinkSync(TEST_STATE_FILE);
|
|
881
|
+
if (existsSync(TEST_STATE_FILE + ".tmp"))
|
|
882
|
+
unlinkSync(TEST_STATE_FILE + ".tmp");
|
|
883
|
+
resetStateFilePath();
|
|
884
|
+
if (existsSync(LOG_FILE))
|
|
885
|
+
unlinkSync(LOG_FILE);
|
|
886
|
+
if (existsSync(LOG_BACKUP))
|
|
887
|
+
renameSync(LOG_BACKUP, LOG_FILE);
|
|
888
|
+
});
|
|
889
|
+
it("computeNetSpread returns negative for tiny spread with high costs", () => {
|
|
890
|
+
// Gross spread of 5%, high round-trip cost of 1%, hold 1 day
|
|
891
|
+
// Annualized cost: (1/1)*365 = 365% → net = 5 - 365 = -360%
|
|
892
|
+
const net = computeNetSpread(5, 1, 1);
|
|
893
|
+
expect(net).toBeLessThan(0);
|
|
894
|
+
});
|
|
895
|
+
it("isSpreadReversed handles lighter as long exchange", () => {
|
|
896
|
+
const snapshot = {
|
|
897
|
+
symbol: "SOL",
|
|
898
|
+
pacRate: 0.00001,
|
|
899
|
+
hlRate: 0.00002,
|
|
900
|
+
ltRate: 0.0004, // LT 8h rate surged: hourly = 0.0004/8 = 0.00005 > PAC 0.00001
|
|
901
|
+
spread: 35,
|
|
902
|
+
longExch: "lighter",
|
|
903
|
+
shortExch: "pacifica",
|
|
904
|
+
markPrice: 150,
|
|
905
|
+
pacMarkPrice: 0, hlMarkPrice: 0, ltMarkPrice: 0,
|
|
906
|
+
};
|
|
907
|
+
// Long LT hourly = 0.0004/8 = 0.00005, short PAC hourly = 0.00001 → reversed
|
|
908
|
+
expect(isSpreadReversed("lighter", "pacifica", snapshot)).toBe(true);
|
|
909
|
+
});
|
|
910
|
+
it("full entry+exit pipeline logs both records with matching symbols", () => {
|
|
911
|
+
const state = createInitialState(makeDefaultConfig());
|
|
912
|
+
saveArbState(state);
|
|
913
|
+
// Entry
|
|
914
|
+
addPosition(makePosition({ symbol: "DOGE", entrySpread: 60 }));
|
|
915
|
+
const entryRec = logExecution({
|
|
916
|
+
type: "arb_entry", exchange: "hyperliquid+pacifica", symbol: "DOGE",
|
|
917
|
+
side: "entry", size: "1000", status: "success", dryRun: false,
|
|
918
|
+
meta: { entrySpread: 60 },
|
|
919
|
+
});
|
|
920
|
+
// Some time passes, then exit
|
|
921
|
+
removePosition("DOGE");
|
|
922
|
+
const exitRec = logExecution({
|
|
923
|
+
type: "arb_close", exchange: "hyperliquid+pacifica", symbol: "DOGE",
|
|
924
|
+
side: "close", size: "1000", status: "success", dryRun: false,
|
|
925
|
+
meta: { exitReason: "spread", netPnl: 25.0 },
|
|
926
|
+
});
|
|
927
|
+
// Read back
|
|
928
|
+
const dogeRecords = readExecutionLog({ symbol: "DOGE" });
|
|
929
|
+
expect(dogeRecords).toHaveLength(2);
|
|
930
|
+
const types = dogeRecords.map(r => r.type).sort();
|
|
931
|
+
expect(types).toEqual(["arb_close", "arb_entry"]);
|
|
932
|
+
});
|
|
933
|
+
it("updatePosition tracks accumulated funding over time", () => {
|
|
934
|
+
const state = createInitialState(makeDefaultConfig());
|
|
935
|
+
saveArbState(state);
|
|
936
|
+
addPosition(makePosition({ symbol: "ETH", accumulatedFunding: 0 }));
|
|
937
|
+
// Simulate hourly funding updates
|
|
938
|
+
updatePosition("ETH", { accumulatedFunding: 0.05, lastCheckTime: "2025-01-15T11:00:00Z" });
|
|
939
|
+
updatePosition("ETH", { accumulatedFunding: 0.10, lastCheckTime: "2025-01-15T12:00:00Z" });
|
|
940
|
+
updatePosition("ETH", { accumulatedFunding: 0.15, lastCheckTime: "2025-01-15T13:00:00Z" });
|
|
941
|
+
const positions = getPositions();
|
|
942
|
+
expect(positions[0].accumulatedFunding).toBe(0.15);
|
|
943
|
+
expect(positions[0].lastCheckTime).toBe("2025-01-15T13:00:00Z");
|
|
944
|
+
});
|
|
945
|
+
});
|