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.
Files changed (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -0
  3. package/dist/__tests__/alert-logic.test.d.ts +1 -0
  4. package/dist/__tests__/alert-logic.test.js +107 -0
  5. package/dist/__tests__/arb-auto-3dex.test.d.ts +1 -0
  6. package/dist/__tests__/arb-auto-3dex.test.js +397 -0
  7. package/dist/__tests__/arb-history-stats.test.d.ts +1 -0
  8. package/dist/__tests__/arb-history-stats.test.js +176 -0
  9. package/dist/__tests__/arb-logic.test.d.ts +1 -0
  10. package/dist/__tests__/arb-logic.test.js +84 -0
  11. package/dist/__tests__/arb-manage.test.d.ts +1 -0
  12. package/dist/__tests__/arb-manage.test.js +253 -0
  13. package/dist/__tests__/arb-new-features.test.d.ts +1 -0
  14. package/dist/__tests__/arb-new-features.test.js +457 -0
  15. package/dist/__tests__/arb-sizing.test.d.ts +1 -0
  16. package/dist/__tests__/arb-sizing.test.js +48 -0
  17. package/dist/__tests__/arb-state.test.d.ts +1 -0
  18. package/dist/__tests__/arb-state.test.js +284 -0
  19. package/dist/__tests__/arb-userflow.test.d.ts +1 -0
  20. package/dist/__tests__/arb-userflow.test.js +945 -0
  21. package/dist/__tests__/arb-utils.test.d.ts +1 -0
  22. package/dist/__tests__/arb-utils.test.js +264 -0
  23. package/dist/__tests__/bot-conditions.test.d.ts +1 -0
  24. package/dist/__tests__/bot-conditions.test.js +341 -0
  25. package/dist/__tests__/client-id-tracker.test.d.ts +1 -0
  26. package/dist/__tests__/client-id-tracker.test.js +137 -0
  27. package/dist/__tests__/commands/new-atomic-commands.test.d.ts +1 -0
  28. package/dist/__tests__/commands/new-atomic-commands.test.js +502 -0
  29. package/dist/__tests__/commands/order-intent.test.d.ts +1 -0
  30. package/dist/__tests__/commands/order-intent.test.js +600 -0
  31. package/dist/__tests__/commands/trade-commands.test.d.ts +1 -0
  32. package/dist/__tests__/commands/trade-commands.test.js +821 -0
  33. package/dist/__tests__/config.test.d.ts +1 -0
  34. package/dist/__tests__/config.test.js +86 -0
  35. package/dist/__tests__/cross-chain-margin.test.d.ts +1 -0
  36. package/dist/__tests__/cross-chain-margin.test.js +287 -0
  37. package/dist/__tests__/dex-asset-map.test.d.ts +1 -0
  38. package/dist/__tests__/dex-asset-map.test.js +191 -0
  39. package/dist/__tests__/errors.test.d.ts +1 -0
  40. package/dist/__tests__/errors.test.js +110 -0
  41. package/dist/__tests__/event-stream.test.d.ts +1 -0
  42. package/dist/__tests__/event-stream.test.js +276 -0
  43. package/dist/__tests__/exchanges/interface.test.d.ts +1 -0
  44. package/dist/__tests__/exchanges/interface.test.js +132 -0
  45. package/dist/__tests__/exchanges/mock-adapter.d.ts +69 -0
  46. package/dist/__tests__/exchanges/mock-adapter.js +137 -0
  47. package/dist/__tests__/execution-log.test.d.ts +1 -0
  48. package/dist/__tests__/execution-log.test.js +106 -0
  49. package/dist/__tests__/funding-calc.test.d.ts +1 -0
  50. package/dist/__tests__/funding-calc.test.js +71 -0
  51. package/dist/__tests__/funding-history.test.d.ts +1 -0
  52. package/dist/__tests__/funding-history.test.js +343 -0
  53. package/dist/__tests__/funding-rates.test.d.ts +1 -0
  54. package/dist/__tests__/funding-rates.test.js +342 -0
  55. package/dist/__tests__/funding.test.d.ts +1 -0
  56. package/dist/__tests__/funding.test.js +173 -0
  57. package/dist/__tests__/gap-logic.test.d.ts +1 -0
  58. package/dist/__tests__/gap-logic.test.js +43 -0
  59. package/dist/__tests__/hip3-dex.test.d.ts +1 -0
  60. package/dist/__tests__/hip3-dex.test.js +234 -0
  61. package/dist/__tests__/integration/agent-features.integration.test.d.ts +1 -0
  62. package/dist/__tests__/integration/agent-features.integration.test.js +553 -0
  63. package/dist/__tests__/integration/atomic-commands.integration.test.d.ts +13 -0
  64. package/dist/__tests__/integration/atomic-commands.integration.test.js +246 -0
  65. package/dist/__tests__/integration/bridge-simulation.integration.test.d.ts +1 -0
  66. package/dist/__tests__/integration/bridge-simulation.integration.test.js +453 -0
  67. package/dist/__tests__/integration/bridge-strict.integration.test.d.ts +1 -0
  68. package/dist/__tests__/integration/bridge-strict.integration.test.js +812 -0
  69. package/dist/__tests__/integration/bridge.integration.test.d.ts +1 -0
  70. package/dist/__tests__/integration/bridge.integration.test.js +309 -0
  71. package/dist/__tests__/integration/cli-e2e.integration.test.d.ts +1 -0
  72. package/dist/__tests__/integration/cli-e2e.integration.test.js +202 -0
  73. package/dist/__tests__/integration/dex-arb.integration.test.d.ts +1 -0
  74. package/dist/__tests__/integration/dex-arb.integration.test.js +116 -0
  75. package/dist/__tests__/integration/envelope-consistency.integration.test.d.ts +13 -0
  76. package/dist/__tests__/integration/envelope-consistency.integration.test.js +205 -0
  77. package/dist/__tests__/integration/hip3-dex.integration.test.d.ts +1 -0
  78. package/dist/__tests__/integration/hip3-dex.integration.test.js +147 -0
  79. package/dist/__tests__/integration/hyperliquid.integration.test.d.ts +1 -0
  80. package/dist/__tests__/integration/hyperliquid.integration.test.js +79 -0
  81. package/dist/__tests__/integration/lighter.integration.test.d.ts +1 -0
  82. package/dist/__tests__/integration/lighter.integration.test.js +53 -0
  83. package/dist/__tests__/integration/new-commands-e2e.integration.test.d.ts +9 -0
  84. package/dist/__tests__/integration/new-commands-e2e.integration.test.js +236 -0
  85. package/dist/__tests__/integration/order-verification.integration.test.d.ts +1 -0
  86. package/dist/__tests__/integration/order-verification.integration.test.js +321 -0
  87. package/dist/__tests__/integration/pacifica.integration.test.d.ts +1 -0
  88. package/dist/__tests__/integration/pacifica.integration.test.js +75 -0
  89. package/dist/__tests__/integration/response-shapes.integration.test.d.ts +1 -0
  90. package/dist/__tests__/integration/response-shapes.integration.test.js +278 -0
  91. package/dist/__tests__/liquidity.test.d.ts +1 -0
  92. package/dist/__tests__/liquidity.test.js +225 -0
  93. package/dist/__tests__/plan-executor.test.d.ts +1 -0
  94. package/dist/__tests__/plan-executor.test.js +314 -0
  95. package/dist/__tests__/position-history.test.d.ts +1 -0
  96. package/dist/__tests__/position-history.test.js +367 -0
  97. package/dist/__tests__/retry.test.d.ts +1 -0
  98. package/dist/__tests__/retry.test.js +310 -0
  99. package/dist/__tests__/risk-assessment.test.d.ts +1 -0
  100. package/dist/__tests__/risk-assessment.test.js +145 -0
  101. package/dist/__tests__/security-adversarial.test.d.ts +1 -0
  102. package/dist/__tests__/security-adversarial.test.js +574 -0
  103. package/dist/__tests__/strategies.test.d.ts +1 -0
  104. package/dist/__tests__/strategies.test.js +539 -0
  105. package/dist/__tests__/trade-execution.test.d.ts +1 -0
  106. package/dist/__tests__/trade-execution.test.js +129 -0
  107. package/dist/__tests__/trade-validator.test.d.ts +1 -0
  108. package/dist/__tests__/trade-validator.test.js +655 -0
  109. package/dist/__tests__/utils.test.d.ts +1 -0
  110. package/dist/__tests__/utils.test.js +76 -0
  111. package/dist/api/public/hyperliquid.d.ts +18 -0
  112. package/dist/api/public/hyperliquid.js +82 -0
  113. package/dist/api/public/index.d.ts +8 -0
  114. package/dist/api/public/index.js +8 -0
  115. package/dist/api/public/lighter.d.ts +24 -0
  116. package/dist/api/public/lighter.js +100 -0
  117. package/dist/api/public/pacifica.d.ts +17 -0
  118. package/dist/api/public/pacifica.js +54 -0
  119. package/dist/api/public/urls.d.ts +12 -0
  120. package/dist/api/public/urls.js +33 -0
  121. package/dist/arb/history-stats.d.ts +44 -0
  122. package/dist/arb/history-stats.js +135 -0
  123. package/dist/arb/index.d.ts +4 -0
  124. package/dist/arb/index.js +4 -0
  125. package/dist/arb/sizing.d.ts +23 -0
  126. package/dist/arb/sizing.js +96 -0
  127. package/dist/arb/state.d.ts +51 -0
  128. package/dist/arb/state.js +112 -0
  129. package/dist/arb/utils.d.ts +81 -0
  130. package/dist/arb/utils.js +267 -0
  131. package/dist/arb-history-stats.d.ts +5 -0
  132. package/dist/arb-history-stats.js +5 -0
  133. package/dist/arb-sizing.d.ts +5 -0
  134. package/dist/arb-sizing.js +5 -0
  135. package/dist/arb-state.d.ts +5 -0
  136. package/dist/arb-state.js +5 -0
  137. package/dist/arb-utils.d.ts +5 -0
  138. package/dist/arb-utils.js +5 -0
  139. package/dist/bot/conditions.d.ts +32 -0
  140. package/dist/bot/conditions.js +141 -0
  141. package/dist/bot/config.d.ts +76 -0
  142. package/dist/bot/config.js +160 -0
  143. package/dist/bot/engine.d.ts +8 -0
  144. package/dist/bot/engine.js +519 -0
  145. package/dist/bot/presets.d.ts +11 -0
  146. package/dist/bot/presets.js +296 -0
  147. package/dist/bridge-engine.d.ts +133 -0
  148. package/dist/bridge-engine.js +1487 -0
  149. package/dist/cache.d.ts +25 -0
  150. package/dist/cache.js +99 -0
  151. package/dist/cli-spec.d.ts +50 -0
  152. package/dist/cli-spec.js +75 -0
  153. package/dist/client-id-tracker.d.ts +25 -0
  154. package/dist/client-id-tracker.js +76 -0
  155. package/dist/commands/account.d.ts +3 -0
  156. package/dist/commands/account.js +425 -0
  157. package/dist/commands/agent.d.ts +3 -0
  158. package/dist/commands/agent.js +386 -0
  159. package/dist/commands/alert.d.ts +2 -0
  160. package/dist/commands/alert.js +421 -0
  161. package/dist/commands/analytics.d.ts +3 -0
  162. package/dist/commands/analytics.js +311 -0
  163. package/dist/commands/arb/index.d.ts +3 -0
  164. package/dist/commands/arb/index.js +921 -0
  165. package/dist/commands/arb-auto.d.ts +54 -0
  166. package/dist/commands/arb-auto.js +1328 -0
  167. package/dist/commands/arb-manage.d.ts +5 -0
  168. package/dist/commands/arb-manage.js +5 -0
  169. package/dist/commands/arb.d.ts +2 -0
  170. package/dist/commands/arb.js +347 -0
  171. package/dist/commands/backtest.d.ts +2 -0
  172. package/dist/commands/backtest.js +327 -0
  173. package/dist/commands/bot.d.ts +3 -0
  174. package/dist/commands/bot.js +412 -0
  175. package/dist/commands/bridge.d.ts +2 -0
  176. package/dist/commands/bridge.js +396 -0
  177. package/dist/commands/dashboard.d.ts +3 -0
  178. package/dist/commands/dashboard.js +176 -0
  179. package/dist/commands/deposit.d.ts +4 -0
  180. package/dist/commands/deposit.js +573 -0
  181. package/dist/commands/dex.d.ts +3 -0
  182. package/dist/commands/dex.js +114 -0
  183. package/dist/commands/env.d.ts +2 -0
  184. package/dist/commands/env.js +136 -0
  185. package/dist/commands/funding.d.ts +2 -0
  186. package/dist/commands/funding.js +347 -0
  187. package/dist/commands/gap.d.ts +2 -0
  188. package/dist/commands/gap.js +305 -0
  189. package/dist/commands/health.d.ts +2 -0
  190. package/dist/commands/health.js +67 -0
  191. package/dist/commands/history.d.ts +2 -0
  192. package/dist/commands/history.js +235 -0
  193. package/dist/commands/init.d.ts +15 -0
  194. package/dist/commands/init.js +266 -0
  195. package/dist/commands/jobs.d.ts +2 -0
  196. package/dist/commands/jobs.js +133 -0
  197. package/dist/commands/manage.d.ts +4 -0
  198. package/dist/commands/manage.js +309 -0
  199. package/dist/commands/market.d.ts +3 -0
  200. package/dist/commands/market.js +225 -0
  201. package/dist/commands/plan.d.ts +3 -0
  202. package/dist/commands/plan.js +95 -0
  203. package/dist/commands/portfolio.d.ts +3 -0
  204. package/dist/commands/portfolio.js +169 -0
  205. package/dist/commands/rebalance.d.ts +3 -0
  206. package/dist/commands/rebalance.js +293 -0
  207. package/dist/commands/risk.d.ts +3 -0
  208. package/dist/commands/risk.js +169 -0
  209. package/dist/commands/run.d.ts +3 -0
  210. package/dist/commands/run.js +202 -0
  211. package/dist/commands/settings.d.ts +2 -0
  212. package/dist/commands/settings.js +102 -0
  213. package/dist/commands/stream.d.ts +5 -0
  214. package/dist/commands/stream.js +123 -0
  215. package/dist/commands/trade.d.ts +3 -0
  216. package/dist/commands/trade.js +1273 -0
  217. package/dist/commands/wallet.d.ts +14 -0
  218. package/dist/commands/wallet.js +602 -0
  219. package/dist/commands/withdraw.d.ts +3 -0
  220. package/dist/commands/withdraw.js +187 -0
  221. package/dist/config.d.ts +5 -0
  222. package/dist/config.js +68 -0
  223. package/dist/cross-chain-margin.d.ts +46 -0
  224. package/dist/cross-chain-margin.js +107 -0
  225. package/dist/dashboard/server.d.ts +80 -0
  226. package/dist/dashboard/server.js +340 -0
  227. package/dist/dashboard/ui.d.ts +4 -0
  228. package/dist/dashboard/ui.js +538 -0
  229. package/dist/dashboard/ws-feeds.d.ts +29 -0
  230. package/dist/dashboard/ws-feeds.js +660 -0
  231. package/dist/dex-asset-map.d.ts +80 -0
  232. package/dist/dex-asset-map.js +201 -0
  233. package/dist/errors.d.ts +109 -0
  234. package/dist/errors.js +84 -0
  235. package/dist/event-stream.d.ts +25 -0
  236. package/dist/event-stream.js +168 -0
  237. package/dist/exchanges/hyperliquid.d.ts +212 -0
  238. package/dist/exchanges/hyperliquid.js +931 -0
  239. package/dist/exchanges/interface.d.ts +95 -0
  240. package/dist/exchanges/interface.js +5 -0
  241. package/dist/exchanges/lighter.d.ts +159 -0
  242. package/dist/exchanges/lighter.js +793 -0
  243. package/dist/exchanges/pacifica.d.ts +51 -0
  244. package/dist/exchanges/pacifica.js +248 -0
  245. package/dist/execution-log.d.ts +36 -0
  246. package/dist/execution-log.js +102 -0
  247. package/dist/funding/history.d.ts +63 -0
  248. package/dist/funding/history.js +266 -0
  249. package/dist/funding/index.d.ts +3 -0
  250. package/dist/funding/index.js +3 -0
  251. package/dist/funding/normalize.d.ts +39 -0
  252. package/dist/funding/normalize.js +66 -0
  253. package/dist/funding/rates.d.ts +45 -0
  254. package/dist/funding/rates.js +172 -0
  255. package/dist/funding-history.d.ts +5 -0
  256. package/dist/funding-history.js +5 -0
  257. package/dist/funding-rates.d.ts +5 -0
  258. package/dist/funding-rates.js +5 -0
  259. package/dist/funding.d.ts +5 -0
  260. package/dist/funding.js +5 -0
  261. package/dist/index.d.ts +2 -0
  262. package/dist/index.js +458 -0
  263. package/dist/jobs.d.ts +37 -0
  264. package/dist/jobs.js +152 -0
  265. package/dist/liquidity.d.ts +34 -0
  266. package/dist/liquidity.js +100 -0
  267. package/dist/mcp-server.d.ts +9 -0
  268. package/dist/mcp-server.js +1206 -0
  269. package/dist/pacifica/client.d.ts +111 -0
  270. package/dist/pacifica/client.js +310 -0
  271. package/dist/pacifica/constants.d.ts +27 -0
  272. package/dist/pacifica/constants.js +47 -0
  273. package/dist/pacifica/deposit.d.ts +14 -0
  274. package/dist/pacifica/deposit.js +78 -0
  275. package/dist/pacifica/index.d.ts +6 -0
  276. package/dist/pacifica/index.js +11 -0
  277. package/dist/pacifica/signing.d.ts +49 -0
  278. package/dist/pacifica/signing.js +97 -0
  279. package/dist/pacifica/types/account.d.ts +42 -0
  280. package/dist/pacifica/types/account.js +1 -0
  281. package/dist/pacifica/types/index.d.ts +6 -0
  282. package/dist/pacifica/types/index.js +6 -0
  283. package/dist/pacifica/types/lake.d.ts +18 -0
  284. package/dist/pacifica/types/lake.js +1 -0
  285. package/dist/pacifica/types/market.d.ts +64 -0
  286. package/dist/pacifica/types/market.js +1 -0
  287. package/dist/pacifica/types/order.d.ts +92 -0
  288. package/dist/pacifica/types/order.js +1 -0
  289. package/dist/pacifica/types/position.d.ts +25 -0
  290. package/dist/pacifica/types/position.js +1 -0
  291. package/dist/pacifica/types/ws.d.ts +34 -0
  292. package/dist/pacifica/types/ws.js +41 -0
  293. package/dist/pacifica/ws-client.d.ts +42 -0
  294. package/dist/pacifica/ws-client.js +180 -0
  295. package/dist/plan-executor.d.ts +48 -0
  296. package/dist/plan-executor.js +280 -0
  297. package/dist/position-history.d.ts +68 -0
  298. package/dist/position-history.js +222 -0
  299. package/dist/rebalance.d.ts +64 -0
  300. package/dist/rebalance.js +142 -0
  301. package/dist/retry.d.ts +74 -0
  302. package/dist/retry.js +129 -0
  303. package/dist/risk.d.ts +48 -0
  304. package/dist/risk.js +156 -0
  305. package/dist/settings.d.ts +19 -0
  306. package/dist/settings.js +45 -0
  307. package/dist/shared-api.d.ts +5 -0
  308. package/dist/shared-api.js +5 -0
  309. package/dist/strategies/dca.d.ts +25 -0
  310. package/dist/strategies/dca.js +114 -0
  311. package/dist/strategies/funding-arb.d.ts +15 -0
  312. package/dist/strategies/funding-arb.js +281 -0
  313. package/dist/strategies/grid.d.ts +34 -0
  314. package/dist/strategies/grid.js +185 -0
  315. package/dist/strategies/trailing-stop.d.ts +17 -0
  316. package/dist/strategies/trailing-stop.js +121 -0
  317. package/dist/strategies/twap.d.ts +20 -0
  318. package/dist/strategies/twap.js +78 -0
  319. package/dist/trade-validator.d.ts +39 -0
  320. package/dist/trade-validator.js +154 -0
  321. package/dist/utils.d.ts +38 -0
  322. package/dist/utils.js +110 -0
  323. package/package.json +63 -0
  324. package/skills/perp-cli/SKILL.md +149 -0
  325. 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
+ });