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,314 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { validatePlan, executePlan } from "../plan-executor.js";
3
+ // Mock execution-log to prevent file I/O during tests
4
+ vi.mock("../execution-log.js", () => ({
5
+ logExecution: vi.fn().mockReturnValue({ id: "mock", timestamp: new Date().toISOString() }),
6
+ }));
7
+ // ── Mock adapter ──
8
+ function mockAdapter(overrides) {
9
+ return {
10
+ name: "test",
11
+ getMarkets: vi.fn().mockResolvedValue([]),
12
+ getBalance: vi.fn().mockResolvedValue({ equity: "1000", available: "800", marginUsed: "200", unrealizedPnl: "0" }),
13
+ getPositions: vi.fn().mockResolvedValue([]),
14
+ getOpenOrders: vi.fn().mockResolvedValue([]),
15
+ getOrderbook: vi.fn().mockResolvedValue({ bids: [], asks: [] }),
16
+ marketOrder: vi.fn().mockResolvedValue({ orderId: "m1", status: "filled" }),
17
+ limitOrder: vi.fn().mockResolvedValue({ orderId: "l1", status: "open" }),
18
+ stopOrder: vi.fn().mockResolvedValue({ orderId: "s1" }),
19
+ cancelOrder: vi.fn().mockResolvedValue({ success: true }),
20
+ cancelAllOrders: vi.fn().mockResolvedValue({ cancelled: 0 }),
21
+ setLeverage: vi.fn().mockResolvedValue({ leverage: 10 }),
22
+ ...overrides,
23
+ };
24
+ }
25
+ describe("validatePlan — structural validation", () => {
26
+ it("accepts valid plan", () => {
27
+ const plan = {
28
+ version: "1.0",
29
+ steps: [
30
+ { id: "1", action: "market_order", params: { symbol: "BTC", side: "buy", size: "0.1" } },
31
+ { id: "2", action: "wait", params: { ms: 500 } },
32
+ ],
33
+ };
34
+ const r = validatePlan(plan);
35
+ expect(r.valid).toBe(true);
36
+ expect(r.errors).toHaveLength(0);
37
+ });
38
+ it("rejects non-object input", () => {
39
+ expect(validatePlan(null).valid).toBe(false);
40
+ expect(validatePlan("bad").valid).toBe(false);
41
+ });
42
+ it("rejects wrong version", () => {
43
+ const r = validatePlan({ version: "2.0", steps: [{ id: "1", action: "wait", params: { ms: 1 } }] });
44
+ expect(r.valid).toBe(false);
45
+ expect(r.errors[0]).toContain("version");
46
+ });
47
+ it("rejects missing steps", () => {
48
+ const r = validatePlan({ version: "1.0" });
49
+ expect(r.valid).toBe(false);
50
+ });
51
+ it("rejects empty steps array", () => {
52
+ const r = validatePlan({ version: "1.0", steps: [] });
53
+ expect(r.valid).toBe(false);
54
+ expect(r.errors[0]).toContain("no steps");
55
+ });
56
+ it("rejects duplicate step IDs", () => {
57
+ const r = validatePlan({
58
+ version: "1.0",
59
+ steps: [
60
+ { id: "dup", action: "wait", params: { ms: 1 } },
61
+ { id: "dup", action: "wait", params: { ms: 2 } },
62
+ ],
63
+ });
64
+ expect(r.valid).toBe(false);
65
+ expect(r.errors.some(e => e.includes("duplicate"))).toBe(true);
66
+ });
67
+ it("rejects invalid action", () => {
68
+ const r = validatePlan({ version: "1.0", steps: [{ id: "1", action: "fly_to_moon", params: {} }] });
69
+ expect(r.valid).toBe(false);
70
+ expect(r.errors[0]).toContain("invalid action");
71
+ });
72
+ it("requires symbol/side/size for order actions", () => {
73
+ const r = validatePlan({
74
+ version: "1.0",
75
+ steps: [{ id: "1", action: "market_order", params: {} }],
76
+ });
77
+ expect(r.valid).toBe(false);
78
+ expect(r.errors.some(e => e.includes("symbol"))).toBe(true);
79
+ expect(r.errors.some(e => e.includes("side"))).toBe(true);
80
+ expect(r.errors.some(e => e.includes("size"))).toBe(true);
81
+ });
82
+ it("requires price for limit_order", () => {
83
+ const r = validatePlan({
84
+ version: "1.0",
85
+ steps: [{ id: "1", action: "limit_order", params: { symbol: "BTC", side: "buy", size: "1" } }],
86
+ });
87
+ expect(r.valid).toBe(false);
88
+ expect(r.errors.some(e => e.includes("price"))).toBe(true);
89
+ });
90
+ it("requires triggerPrice for stop_order", () => {
91
+ const r = validatePlan({
92
+ version: "1.0",
93
+ steps: [{ id: "1", action: "stop_order", params: { symbol: "BTC", side: "sell", size: "1" } }],
94
+ });
95
+ expect(r.valid).toBe(false);
96
+ expect(r.errors.some(e => e.includes("triggerPrice"))).toBe(true);
97
+ });
98
+ it("requires ms for wait", () => {
99
+ const r = validatePlan({ version: "1.0", steps: [{ id: "1", action: "wait", params: {} }] });
100
+ expect(r.valid).toBe(false);
101
+ expect(r.errors.some(e => e.includes("ms"))).toBe(true);
102
+ });
103
+ it("rejects invalid onFailure value", () => {
104
+ const r = validatePlan({
105
+ version: "1.0",
106
+ steps: [{ id: "1", action: "wait", params: { ms: 1 }, onFailure: "panic" }],
107
+ });
108
+ expect(r.valid).toBe(false);
109
+ expect(r.errors.some(e => e.includes("onFailure"))).toBe(true);
110
+ });
111
+ it("detects broken dependsOn reference", () => {
112
+ const r = validatePlan({
113
+ version: "1.0",
114
+ steps: [{ id: "1", action: "wait", params: { ms: 1 }, dependsOn: "nonexistent" }],
115
+ });
116
+ expect(r.valid).toBe(false);
117
+ expect(r.errors.some(e => e.includes("dependsOn"))).toBe(true);
118
+ });
119
+ });
120
+ describe("executePlan — dry run", () => {
121
+ it("dry run produces dry_run status for all steps", async () => {
122
+ const plan = {
123
+ version: "1.0",
124
+ steps: [
125
+ { id: "1", action: "market_order", params: { symbol: "BTC", side: "buy", size: "0.1" } },
126
+ { id: "2", action: "check_balance", params: {} },
127
+ ],
128
+ };
129
+ const adapter = mockAdapter();
130
+ const r = await executePlan(adapter, plan, { dryRun: true });
131
+ expect(r.status).toBe("dry_run");
132
+ expect(r.steps).toHaveLength(2);
133
+ for (const s of r.steps) {
134
+ expect(s.status).toBe("dry_run");
135
+ }
136
+ // No actual calls should have been made
137
+ expect(adapter.marketOrder).not.toHaveBeenCalled();
138
+ });
139
+ });
140
+ describe("executePlan — live execution", () => {
141
+ it("executes market_order and wait sequentially", async () => {
142
+ const plan = {
143
+ version: "1.0",
144
+ steps: [
145
+ { id: "1", action: "market_order", params: { symbol: "BTC", side: "buy", size: "0.1" } },
146
+ { id: "2", action: "wait", params: { ms: 10 } },
147
+ ],
148
+ };
149
+ const adapter = mockAdapter();
150
+ const r = await executePlan(adapter, plan);
151
+ expect(r.status).toBe("completed");
152
+ expect(r.steps).toHaveLength(2);
153
+ expect(r.steps[0].status).toBe("success");
154
+ expect(r.steps[1].status).toBe("success");
155
+ expect(adapter.marketOrder).toHaveBeenCalledWith("BTC", "buy", "0.1");
156
+ });
157
+ it("executes check_balance and validates minimum", async () => {
158
+ const adapter = mockAdapter();
159
+ const plan = {
160
+ version: "1.0",
161
+ steps: [{ id: "1", action: "check_balance", params: { minAvailable: 500 } }],
162
+ };
163
+ const r = await executePlan(adapter, plan);
164
+ expect(r.status).toBe("completed");
165
+ expect(r.steps[0].status).toBe("success");
166
+ });
167
+ it("fails check_balance when balance is too low", async () => {
168
+ const adapter = mockAdapter({
169
+ getBalance: vi.fn().mockResolvedValue({ equity: "100", available: "50", marginUsed: "50", unrealizedPnl: "0" }),
170
+ });
171
+ const plan = {
172
+ version: "1.0",
173
+ steps: [{ id: "1", action: "check_balance", params: { minAvailable: 500 } }],
174
+ };
175
+ const r = await executePlan(adapter, plan);
176
+ expect(r.status).toBe("failed");
177
+ expect(r.steps[0].status).toBe("failed");
178
+ });
179
+ });
180
+ describe("executePlan — failure modes", () => {
181
+ it("aborts on failure by default", async () => {
182
+ const adapter = mockAdapter({
183
+ marketOrder: vi.fn().mockRejectedValue(new Error("Insufficient balance")),
184
+ });
185
+ const plan = {
186
+ version: "1.0",
187
+ steps: [
188
+ { id: "1", action: "market_order", params: { symbol: "BTC", side: "buy", size: "100" } },
189
+ { id: "2", action: "wait", params: { ms: 1 } },
190
+ ],
191
+ };
192
+ const r = await executePlan(adapter, plan);
193
+ expect(r.status).toBe("failed");
194
+ expect(r.steps).toHaveLength(1); // step 2 never reached
195
+ expect(r.steps[0].status).toBe("failed");
196
+ });
197
+ it("skips failed step when onFailure=skip", async () => {
198
+ const adapter = mockAdapter({
199
+ marketOrder: vi.fn().mockRejectedValue(new Error("Insufficient balance")),
200
+ });
201
+ const plan = {
202
+ version: "1.0",
203
+ steps: [
204
+ { id: "1", action: "market_order", params: { symbol: "BTC", side: "buy", size: "100" }, onFailure: "skip" },
205
+ { id: "2", action: "wait", params: { ms: 1 } },
206
+ ],
207
+ };
208
+ const r = await executePlan(adapter, plan);
209
+ expect(r.status).toBe("completed");
210
+ expect(r.steps[0].status).toBe("skipped");
211
+ expect(r.steps[1].status).toBe("success");
212
+ });
213
+ it("rolls back on failure when onFailure=rollback", async () => {
214
+ const adapter = mockAdapter({
215
+ limitOrder: vi.fn().mockRejectedValue(new Error("Price stale")),
216
+ });
217
+ const plan = {
218
+ version: "1.0",
219
+ steps: [
220
+ { id: "1", action: "market_order", params: { symbol: "BTC", side: "buy", size: "0.1" } },
221
+ { id: "2", action: "limit_order", params: { symbol: "ETH", side: "sell", size: "1", price: "2000" }, onFailure: "rollback" },
222
+ ],
223
+ };
224
+ const r = await executePlan(adapter, plan);
225
+ expect(r.status).toBe("failed");
226
+ expect(r.steps[0].status).toBe("success");
227
+ expect(r.steps[1].status).toBe("rolled_back");
228
+ // cancelAllOrders called during rollback
229
+ expect(adapter.cancelAllOrders).toHaveBeenCalled();
230
+ });
231
+ it("skips step when dependency failed (abort)", async () => {
232
+ const adapter = mockAdapter({
233
+ marketOrder: vi.fn().mockRejectedValue(new Error("fail")),
234
+ // Step 1 will abort by default, so step 2 never runs
235
+ });
236
+ const plan = {
237
+ version: "1.0",
238
+ steps: [
239
+ { id: "1", action: "market_order", params: { symbol: "BTC", side: "buy", size: "0.1" } },
240
+ { id: "2", action: "wait", params: { ms: 1 }, dependsOn: "1" },
241
+ ],
242
+ };
243
+ const r = await executePlan(adapter, plan);
244
+ expect(r.status).toBe("failed");
245
+ expect(r.steps).toHaveLength(1); // step 2 never reached due to abort
246
+ expect(r.steps[0].status).toBe("failed");
247
+ });
248
+ it("skips dependent step when dependency status is failed", async () => {
249
+ // Use two steps where step 1 fails with skip, then step 2 depends on step 3 which fails
250
+ const adapter = mockAdapter({
251
+ limitOrder: vi.fn().mockRejectedValue(new Error("fail")),
252
+ });
253
+ const plan = {
254
+ version: "1.0",
255
+ steps: [
256
+ { id: "1", action: "wait", params: { ms: 1 } },
257
+ { id: "2", action: "limit_order", params: { symbol: "BTC", side: "buy", size: "1", price: "50000" }, onFailure: "skip" },
258
+ { id: "3", action: "wait", params: { ms: 1 }, dependsOn: "2" },
259
+ ],
260
+ };
261
+ const r = await executePlan(adapter, plan);
262
+ // Step 2 is skipped (onFailure=skip), step 3 runs because "skipped" != "failed"
263
+ expect(r.steps[0].status).toBe("success");
264
+ expect(r.steps[1].status).toBe("skipped");
265
+ // Step 3 still runs — skipped deps are treated as present but not "failed"
266
+ expect(r.steps[2].status).toBe("success");
267
+ });
268
+ });
269
+ describe("executePlan — action types", () => {
270
+ it("executes set_leverage", async () => {
271
+ const adapter = mockAdapter();
272
+ const plan = {
273
+ version: "1.0",
274
+ steps: [{ id: "1", action: "set_leverage", params: { symbol: "BTC", leverage: 10, marginMode: "cross" } }],
275
+ };
276
+ const r = await executePlan(adapter, plan);
277
+ expect(r.status).toBe("completed");
278
+ expect(adapter.setLeverage).toHaveBeenCalledWith("BTC", 10, "cross");
279
+ });
280
+ it("executes cancel_order", async () => {
281
+ const adapter = mockAdapter();
282
+ const plan = {
283
+ version: "1.0",
284
+ steps: [{ id: "1", action: "cancel_order", params: { symbol: "BTC", orderId: "abc123" } }],
285
+ };
286
+ const r = await executePlan(adapter, plan);
287
+ expect(r.status).toBe("completed");
288
+ expect(adapter.cancelOrder).toHaveBeenCalledWith("BTC", "abc123");
289
+ });
290
+ it("executes close_position", async () => {
291
+ const adapter = mockAdapter({
292
+ getPositions: vi.fn().mockResolvedValue([
293
+ { symbol: "BTC", side: "long", size: "0.5", entryPrice: "65000", unrealizedPnl: "100", liquidationPrice: "60000", markPrice: "66000", leverage: "10", marginMode: "cross" },
294
+ ]),
295
+ });
296
+ const plan = {
297
+ version: "1.0",
298
+ steps: [{ id: "1", action: "close_position", params: { symbol: "BTC" } }],
299
+ };
300
+ const r = await executePlan(adapter, plan);
301
+ expect(r.status).toBe("completed");
302
+ expect(adapter.marketOrder).toHaveBeenCalledWith("BTC", "sell", "0.5");
303
+ });
304
+ it("fails close_position when no position exists", async () => {
305
+ const adapter = mockAdapter();
306
+ const plan = {
307
+ version: "1.0",
308
+ steps: [{ id: "1", action: "close_position", params: { symbol: "BTC" } }],
309
+ };
310
+ const r = await executePlan(adapter, plan);
311
+ expect(r.status).toBe("failed");
312
+ expect(r.steps[0].error?.message).toContain("No position found");
313
+ });
314
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,367 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { existsSync, unlinkSync, readFileSync, writeFileSync, renameSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { logPosition, readPositionHistory, getPositionStats, attachPositionLogger, } from "../position-history.js";
5
+ const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
6
+ const POSITIONS_FILE = resolve(PERP_DIR, "positions.jsonl");
7
+ const BACKUP_FILE = resolve(PERP_DIR, "positions.jsonl.test-backup");
8
+ beforeEach(() => {
9
+ // Backup existing file if present
10
+ if (existsSync(POSITIONS_FILE)) {
11
+ const content = readFileSync(POSITIONS_FILE, "utf-8");
12
+ writeFileSync(BACKUP_FILE, content);
13
+ }
14
+ // Clear for test
15
+ if (existsSync(POSITIONS_FILE))
16
+ unlinkSync(POSITIONS_FILE);
17
+ });
18
+ afterEach(() => {
19
+ // Restore original file
20
+ if (existsSync(POSITIONS_FILE))
21
+ unlinkSync(POSITIONS_FILE);
22
+ if (existsSync(BACKUP_FILE)) {
23
+ renameSync(BACKUP_FILE, POSITIONS_FILE);
24
+ }
25
+ });
26
+ function makeRecord(overrides) {
27
+ return {
28
+ id: `test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
29
+ exchange: "test",
30
+ symbol: "BTC",
31
+ side: "long",
32
+ entryPrice: "100000",
33
+ size: "0.1",
34
+ openedAt: new Date().toISOString(),
35
+ updatedAt: new Date().toISOString(),
36
+ status: "open",
37
+ ...overrides,
38
+ };
39
+ }
40
+ describe("logPosition + readPositionHistory", () => {
41
+ it("should log a position record and read it back", () => {
42
+ const record = makeRecord({ symbol: "BTC", side: "long" });
43
+ logPosition(record);
44
+ const records = readPositionHistory();
45
+ expect(records).toHaveLength(1);
46
+ expect(records[0].symbol).toBe("BTC");
47
+ expect(records[0].side).toBe("long");
48
+ expect(records[0].id).toBe(record.id);
49
+ });
50
+ it("should log multiple records", () => {
51
+ logPosition(makeRecord({ symbol: "BTC" }));
52
+ logPosition(makeRecord({ symbol: "ETH" }));
53
+ logPosition(makeRecord({ symbol: "SOL" }));
54
+ const records = readPositionHistory();
55
+ expect(records).toHaveLength(3);
56
+ });
57
+ it("should sort newest first", async () => {
58
+ const now = Date.now();
59
+ logPosition(makeRecord({ symbol: "FIRST", updatedAt: new Date(now - 2000).toISOString() }));
60
+ logPosition(makeRecord({ symbol: "SECOND", updatedAt: new Date(now - 1000).toISOString() }));
61
+ logPosition(makeRecord({ symbol: "THIRD", updatedAt: new Date(now).toISOString() }));
62
+ const records = readPositionHistory();
63
+ expect(records[0].symbol).toBe("THIRD");
64
+ expect(records[1].symbol).toBe("SECOND");
65
+ expect(records[2].symbol).toBe("FIRST");
66
+ });
67
+ it("should limit results", () => {
68
+ for (let i = 0; i < 10; i++) {
69
+ logPosition(makeRecord({ symbol: `SYM${i}` }));
70
+ }
71
+ const records = readPositionHistory({ limit: 3 });
72
+ expect(records).toHaveLength(3);
73
+ });
74
+ });
75
+ describe("readPositionHistory — filtering", () => {
76
+ it("should filter by symbol", () => {
77
+ logPosition(makeRecord({ symbol: "BTC" }));
78
+ logPosition(makeRecord({ symbol: "ETH" }));
79
+ logPosition(makeRecord({ symbol: "BTC-PERP" }));
80
+ const records = readPositionHistory({ symbol: "BTC" });
81
+ expect(records).toHaveLength(2);
82
+ expect(records.every(r => r.symbol.includes("BTC"))).toBe(true);
83
+ });
84
+ it("should filter by exchange", () => {
85
+ logPosition(makeRecord({ exchange: "hyperliquid" }));
86
+ logPosition(makeRecord({ exchange: "pacifica" }));
87
+ logPosition(makeRecord({ exchange: "hyperliquid" }));
88
+ const records = readPositionHistory({ exchange: "hyperliquid" });
89
+ expect(records).toHaveLength(2);
90
+ expect(records.every(r => r.exchange === "hyperliquid")).toBe(true);
91
+ });
92
+ it("should filter by status", () => {
93
+ logPosition(makeRecord({ status: "open" }));
94
+ logPosition(makeRecord({ status: "closed", closedAt: new Date().toISOString() }));
95
+ logPosition(makeRecord({ status: "updated" }));
96
+ logPosition(makeRecord({ status: "closed", closedAt: new Date().toISOString() }));
97
+ const records = readPositionHistory({ status: "closed" });
98
+ expect(records).toHaveLength(2);
99
+ expect(records.every(r => r.status === "closed")).toBe(true);
100
+ });
101
+ it("should filter by since date", () => {
102
+ const old = new Date(Date.now() - 7 * 86400000).toISOString();
103
+ const recent = new Date().toISOString();
104
+ logPosition(makeRecord({ symbol: "OLD", updatedAt: old }));
105
+ logPosition(makeRecord({ symbol: "NEW", updatedAt: recent }));
106
+ const since = new Date(Date.now() - 86400000).toISOString(); // 1 day ago
107
+ const records = readPositionHistory({ since });
108
+ expect(records).toHaveLength(1);
109
+ expect(records[0].symbol).toBe("NEW");
110
+ });
111
+ it("should combine filters", () => {
112
+ logPosition(makeRecord({ exchange: "hl", symbol: "BTC", status: "closed" }));
113
+ logPosition(makeRecord({ exchange: "hl", symbol: "ETH", status: "closed" }));
114
+ logPosition(makeRecord({ exchange: "pac", symbol: "BTC", status: "closed" }));
115
+ logPosition(makeRecord({ exchange: "hl", symbol: "BTC", status: "open" }));
116
+ const records = readPositionHistory({ exchange: "hl", symbol: "BTC", status: "closed" });
117
+ expect(records).toHaveLength(1);
118
+ expect(records[0].exchange).toBe("hl");
119
+ expect(records[0].symbol).toBe("BTC");
120
+ expect(records[0].status).toBe("closed");
121
+ });
122
+ });
123
+ describe("readPositionHistory — edge cases", () => {
124
+ it("should return empty array when file does not exist", () => {
125
+ const records = readPositionHistory();
126
+ expect(records).toEqual([]);
127
+ });
128
+ it("should handle empty file", () => {
129
+ writeFileSync(POSITIONS_FILE, "", { mode: 0o600 });
130
+ const records = readPositionHistory();
131
+ expect(records).toEqual([]);
132
+ });
133
+ it("should skip malformed lines", () => {
134
+ writeFileSync(POSITIONS_FILE, "not json\n" + JSON.stringify(makeRecord({ symbol: "OK" })) + "\n", { mode: 0o600 });
135
+ const records = readPositionHistory();
136
+ expect(records).toHaveLength(1);
137
+ expect(records[0].symbol).toBe("OK");
138
+ });
139
+ });
140
+ describe("getPositionStats", () => {
141
+ it("should return zeroed stats when no data", () => {
142
+ const stats = getPositionStats();
143
+ expect(stats.totalTrades).toBe(0);
144
+ expect(stats.wins).toBe(0);
145
+ expect(stats.losses).toBe(0);
146
+ expect(stats.winRate).toBe(0);
147
+ expect(stats.totalPnl).toBe(0);
148
+ expect(stats.avgPnl).toBe(0);
149
+ expect(stats.bestTrade).toBe(0);
150
+ expect(stats.worstTrade).toBe(0);
151
+ expect(stats.shortestTrade).toBe(0);
152
+ expect(stats.longestTrade).toBe(0);
153
+ });
154
+ it("should compute stats from closed positions", () => {
155
+ // 3 closed trades: +100, -50, +200
156
+ logPosition(makeRecord({
157
+ status: "closed",
158
+ realizedPnl: "100",
159
+ exchange: "hl",
160
+ symbol: "BTC",
161
+ duration: 60000,
162
+ }));
163
+ logPosition(makeRecord({
164
+ status: "closed",
165
+ realizedPnl: "-50",
166
+ exchange: "hl",
167
+ symbol: "ETH",
168
+ duration: 120000,
169
+ }));
170
+ logPosition(makeRecord({
171
+ status: "closed",
172
+ realizedPnl: "200",
173
+ exchange: "pac",
174
+ symbol: "BTC",
175
+ duration: 30000,
176
+ }));
177
+ // Open positions should be ignored
178
+ logPosition(makeRecord({
179
+ status: "open",
180
+ unrealizedPnl: "999",
181
+ exchange: "hl",
182
+ symbol: "SOL",
183
+ }));
184
+ const stats = getPositionStats();
185
+ expect(stats.totalTrades).toBe(3);
186
+ expect(stats.wins).toBe(2);
187
+ expect(stats.losses).toBe(1);
188
+ expect(stats.winRate).toBeCloseTo(66.67, 0);
189
+ expect(stats.totalPnl).toBe(250);
190
+ expect(stats.avgPnl).toBeCloseTo(83.33, 0);
191
+ expect(stats.bestTrade).toBe(200);
192
+ expect(stats.worstTrade).toBe(-50);
193
+ expect(stats.avgDuration).toBeCloseTo(70000, -3);
194
+ expect(stats.longestTrade).toBe(120000);
195
+ expect(stats.shortestTrade).toBe(30000);
196
+ });
197
+ it("should compute bySymbol stats", () => {
198
+ logPosition(makeRecord({ status: "closed", realizedPnl: "100", symbol: "BTC", duration: 1000 }));
199
+ logPosition(makeRecord({ status: "closed", realizedPnl: "-30", symbol: "BTC", duration: 2000 }));
200
+ logPosition(makeRecord({ status: "closed", realizedPnl: "50", symbol: "ETH", duration: 3000 }));
201
+ const stats = getPositionStats();
202
+ expect(stats.bySymbol.BTC.trades).toBe(2);
203
+ expect(stats.bySymbol.BTC.pnl).toBe(70);
204
+ expect(stats.bySymbol.BTC.winRate).toBe(50);
205
+ expect(stats.bySymbol.ETH.trades).toBe(1);
206
+ expect(stats.bySymbol.ETH.pnl).toBe(50);
207
+ expect(stats.bySymbol.ETH.winRate).toBe(100);
208
+ });
209
+ it("should compute byExchange stats", () => {
210
+ logPosition(makeRecord({ status: "closed", realizedPnl: "100", exchange: "hl", duration: 1000 }));
211
+ logPosition(makeRecord({ status: "closed", realizedPnl: "-20", exchange: "pac", duration: 2000 }));
212
+ logPosition(makeRecord({ status: "closed", realizedPnl: "50", exchange: "hl", duration: 3000 }));
213
+ const stats = getPositionStats();
214
+ expect(stats.byExchange.hl.trades).toBe(2);
215
+ expect(stats.byExchange.hl.pnl).toBe(150);
216
+ expect(stats.byExchange.pac.trades).toBe(1);
217
+ expect(stats.byExchange.pac.pnl).toBe(-20);
218
+ });
219
+ it("should filter stats by exchange", () => {
220
+ logPosition(makeRecord({ status: "closed", realizedPnl: "100", exchange: "hl", duration: 1000 }));
221
+ logPosition(makeRecord({ status: "closed", realizedPnl: "-20", exchange: "pac", duration: 2000 }));
222
+ const stats = getPositionStats({ exchange: "hl" });
223
+ expect(stats.totalTrades).toBe(1);
224
+ expect(stats.totalPnl).toBe(100);
225
+ });
226
+ });
227
+ describe("attachPositionLogger", () => {
228
+ it("should forward all events to the original callback", () => {
229
+ const received = [];
230
+ const originalOnEvent = (e) => received.push(e);
231
+ const wrapped = attachPositionLogger(originalOnEvent);
232
+ const heartbeat = {
233
+ type: "heartbeat",
234
+ exchange: "test",
235
+ timestamp: new Date().toISOString(),
236
+ data: { cycle: 1 },
237
+ };
238
+ wrapped(heartbeat);
239
+ expect(received).toHaveLength(1);
240
+ expect(received[0].type).toBe("heartbeat");
241
+ });
242
+ it("should log position_opened events", () => {
243
+ const received = [];
244
+ const wrapped = attachPositionLogger((e) => received.push(e));
245
+ const event = {
246
+ type: "position_opened",
247
+ exchange: "test-ex",
248
+ timestamp: new Date().toISOString(),
249
+ data: { symbol: "BTC", side: "long", size: "0.5", entryPrice: "65000", unrealizedPnl: "0" },
250
+ };
251
+ wrapped(event);
252
+ // Event should be forwarded
253
+ expect(received).toHaveLength(1);
254
+ // Position should be logged
255
+ const positions = readPositionHistory({ exchange: "test-ex" });
256
+ expect(positions).toHaveLength(1);
257
+ expect(positions[0].symbol).toBe("BTC");
258
+ expect(positions[0].side).toBe("long");
259
+ expect(positions[0].status).toBe("open");
260
+ expect(positions[0].entryPrice).toBe("65000");
261
+ expect(positions[0].size).toBe("0.5");
262
+ });
263
+ it("should log position_updated events", () => {
264
+ const wrapped = attachPositionLogger(() => { });
265
+ // First open the position
266
+ wrapped({
267
+ type: "position_opened",
268
+ exchange: "test-ex",
269
+ timestamp: new Date().toISOString(),
270
+ data: { symbol: "ETH", side: "short", size: "5", entryPrice: "2000", unrealizedPnl: "0" },
271
+ });
272
+ // Then update it
273
+ wrapped({
274
+ type: "position_updated",
275
+ exchange: "test-ex",
276
+ timestamp: new Date().toISOString(),
277
+ data: { symbol: "ETH", side: "short", size: "10", entryPrice: "2000", unrealizedPnl: "-20", prevSize: "5", prevSide: "short" },
278
+ });
279
+ const positions = readPositionHistory({ status: "updated" });
280
+ expect(positions).toHaveLength(1);
281
+ expect(positions[0].size).toBe("10");
282
+ expect(positions[0].status).toBe("updated");
283
+ expect(positions[0].meta?.prevSize).toBe("5");
284
+ });
285
+ it("should log position_closed events with duration", () => {
286
+ const wrapped = attachPositionLogger(() => { });
287
+ const openTime = new Date("2026-03-08T10:00:00.000Z");
288
+ const closeTime = new Date("2026-03-08T10:05:00.000Z");
289
+ wrapped({
290
+ type: "position_opened",
291
+ exchange: "test-ex",
292
+ timestamp: openTime.toISOString(),
293
+ data: { symbol: "SOL", side: "long", size: "100", entryPrice: "150", unrealizedPnl: "0" },
294
+ });
295
+ wrapped({
296
+ type: "position_closed",
297
+ exchange: "test-ex",
298
+ timestamp: closeTime.toISOString(),
299
+ data: { symbol: "SOL", side: "long", size: "100", entryPrice: "150", unrealizedPnl: "50" },
300
+ });
301
+ const closed = readPositionHistory({ status: "closed" });
302
+ expect(closed).toHaveLength(1);
303
+ expect(closed[0].status).toBe("closed");
304
+ expect(closed[0].realizedPnl).toBe("50");
305
+ expect(closed[0].closedAt).toBe(closeTime.toISOString());
306
+ expect(closed[0].duration).toBe(300000); // 5 minutes in ms
307
+ });
308
+ it("should track multiple positions independently", () => {
309
+ const wrapped = attachPositionLogger(() => { });
310
+ const ts = new Date().toISOString();
311
+ wrapped({
312
+ type: "position_opened",
313
+ exchange: "ex",
314
+ timestamp: ts,
315
+ data: { symbol: "BTC", side: "long", size: "0.1", entryPrice: "65000", unrealizedPnl: "0" },
316
+ });
317
+ wrapped({
318
+ type: "position_opened",
319
+ exchange: "ex",
320
+ timestamp: ts,
321
+ data: { symbol: "ETH", side: "short", size: "5", entryPrice: "2000", unrealizedPnl: "0" },
322
+ });
323
+ // Close only BTC
324
+ wrapped({
325
+ type: "position_closed",
326
+ exchange: "ex",
327
+ timestamp: ts,
328
+ data: { symbol: "BTC", side: "long", size: "0.1", entryPrice: "65000", unrealizedPnl: "100" },
329
+ });
330
+ const all = readPositionHistory({ exchange: "ex" });
331
+ // 2 opens + 1 close = 3 records
332
+ expect(all).toHaveLength(3);
333
+ const closed = readPositionHistory({ exchange: "ex", status: "closed" });
334
+ expect(closed).toHaveLength(1);
335
+ expect(closed[0].symbol).toBe("BTC");
336
+ });
337
+ it("should handle position_closed without prior open gracefully", () => {
338
+ const wrapped = attachPositionLogger(() => { });
339
+ // Close without opening (e.g., logger started mid-session)
340
+ wrapped({
341
+ type: "position_closed",
342
+ exchange: "test",
343
+ timestamp: new Date().toISOString(),
344
+ data: { symbol: "BTC", side: "long", size: "0.1", entryPrice: "65000", unrealizedPnl: "0" },
345
+ });
346
+ const closed = readPositionHistory({ status: "closed" });
347
+ expect(closed).toHaveLength(1);
348
+ // Duration should be undefined since we don't know when it opened
349
+ });
350
+ it("should not log non-position events", () => {
351
+ const wrapped = attachPositionLogger(() => { });
352
+ wrapped({
353
+ type: "order_placed",
354
+ exchange: "test",
355
+ timestamp: new Date().toISOString(),
356
+ data: { orderId: "o1", symbol: "BTC" },
357
+ });
358
+ wrapped({
359
+ type: "balance_update",
360
+ exchange: "test",
361
+ timestamp: new Date().toISOString(),
362
+ data: { equity: "1000" },
363
+ });
364
+ const positions = readPositionHistory();
365
+ expect(positions).toHaveLength(0);
366
+ });
367
+ });