perp-cli 0.6.3 → 0.7.1

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 (177) hide show
  1. package/README.md +7 -2
  2. package/dist/api/public/hyperliquid.d.ts +2 -2
  3. package/dist/api/public/hyperliquid.js +0 -10
  4. package/dist/api/public/lighter.d.ts +2 -1
  5. package/dist/api/public/pacifica.d.ts +2 -1
  6. package/dist/arb/index.d.ts +7 -4
  7. package/dist/arb/index.js +5 -4
  8. package/dist/arb/sizing.d.ts +1 -1
  9. package/dist/arb/state.d.ts +1 -1
  10. package/dist/arb/state.js +4 -3
  11. package/dist/arb/utils.d.ts +0 -10
  12. package/dist/arb/utils.js +0 -42
  13. package/dist/bot/apex/guard.d.ts +26 -0
  14. package/dist/bot/apex/guard.js +86 -0
  15. package/dist/bot/apex/index.d.ts +6 -0
  16. package/dist/bot/apex/index.js +4 -0
  17. package/dist/bot/apex/pulse.d.ts +22 -0
  18. package/dist/bot/apex/pulse.js +107 -0
  19. package/dist/bot/apex/radar.d.ts +17 -0
  20. package/dist/bot/apex/radar.js +150 -0
  21. package/dist/bot/conditions.d.ts +1 -1
  22. package/dist/bot/config.d.ts +5 -1
  23. package/dist/bot/config.js +1 -1
  24. package/dist/bot/engine.d.ts +23 -2
  25. package/dist/bot/engine.js +296 -351
  26. package/dist/bot/index.d.ts +16 -0
  27. package/dist/bot/index.js +10 -0
  28. package/dist/bot/indicators.d.ts +29 -0
  29. package/dist/bot/indicators.js +144 -0
  30. package/dist/bot/reflect.d.ts +34 -0
  31. package/dist/bot/reflect.js +155 -0
  32. package/dist/bot/strategies/aggressive-taker.d.ts +36 -0
  33. package/dist/bot/strategies/aggressive-taker.js +125 -0
  34. package/dist/bot/strategies/apex-strategy.d.ts +36 -0
  35. package/dist/bot/strategies/apex-strategy.js +185 -0
  36. package/dist/bot/strategies/avellaneda-mm.d.ts +27 -0
  37. package/dist/bot/strategies/avellaneda-mm.js +111 -0
  38. package/dist/bot/strategies/basis-arb.d.ts +27 -0
  39. package/dist/bot/strategies/basis-arb.js +97 -0
  40. package/dist/bot/strategies/claude-agent.d.ts +31 -0
  41. package/dist/bot/strategies/claude-agent.js +155 -0
  42. package/dist/bot/strategies/dca-strategy.d.ts +25 -0
  43. package/dist/bot/strategies/dca-strategy.js +59 -0
  44. package/dist/bot/strategies/engine-mm.d.ts +29 -0
  45. package/dist/bot/strategies/engine-mm.js +141 -0
  46. package/dist/bot/strategies/funding-arb-strategy.d.ts +32 -0
  47. package/dist/bot/strategies/funding-arb-strategy.js +168 -0
  48. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  49. package/dist/bot/strategies/funding-arb-v2.js +222 -0
  50. package/dist/bot/strategies/funding-auto.d.ts +1 -0
  51. package/dist/bot/strategies/funding-auto.js +572 -0
  52. package/dist/bot/strategies/grid-mm.d.ts +25 -0
  53. package/dist/bot/strategies/grid-mm.js +99 -0
  54. package/dist/bot/strategies/grid-strategy.d.ts +39 -0
  55. package/dist/bot/strategies/grid-strategy.js +176 -0
  56. package/dist/bot/strategies/hedge-agent.d.ts +25 -0
  57. package/dist/bot/strategies/hedge-agent.js +79 -0
  58. package/dist/bot/strategies/liquidation-mm.d.ts +25 -0
  59. package/dist/bot/strategies/liquidation-mm.js +108 -0
  60. package/dist/bot/strategies/mean-reversion.d.ts +35 -0
  61. package/dist/bot/strategies/mean-reversion.js +118 -0
  62. package/dist/bot/strategies/momentum-breakout.d.ts +36 -0
  63. package/dist/bot/strategies/momentum-breakout.js +159 -0
  64. package/dist/bot/strategies/regime-mm.d.ts +25 -0
  65. package/dist/bot/strategies/regime-mm.js +111 -0
  66. package/dist/bot/strategies/rfq-agent.d.ts +25 -0
  67. package/dist/bot/strategies/rfq-agent.js +71 -0
  68. package/dist/bot/strategies/simple-mm.d.ts +25 -0
  69. package/dist/bot/strategies/simple-mm.js +76 -0
  70. package/dist/bot/strategy-registry.d.ts +9 -0
  71. package/dist/bot/strategy-registry.js +14 -0
  72. package/dist/bot/strategy-types.d.ts +64 -0
  73. package/dist/bot/strategy-types.js +5 -0
  74. package/dist/bot/trade-journal.d.ts +27 -0
  75. package/dist/bot/trade-journal.js +53 -0
  76. package/dist/bot/tui/BotDashboard.d.ts +49 -0
  77. package/dist/bot/tui/BotDashboard.js +93 -0
  78. package/dist/bot/tui/index.d.ts +6 -0
  79. package/dist/bot/tui/index.js +8 -0
  80. package/dist/bridge-engine.d.ts +0 -1
  81. package/dist/bridge-engine.js +0 -1
  82. package/dist/cache.d.ts +0 -1
  83. package/dist/cache.js +0 -1
  84. package/dist/commands/account.d.ts +1 -1
  85. package/dist/commands/account.js +106 -75
  86. package/dist/commands/agent.d.ts +1 -1
  87. package/dist/commands/arb/index.d.ts +1 -1
  88. package/dist/commands/arb/index.js +1 -1
  89. package/dist/commands/arb-auto.d.ts +1 -1
  90. package/dist/commands/arb-auto.js +1 -1
  91. package/dist/commands/arb.d.ts +1 -1
  92. package/dist/commands/bot.d.ts +1 -1
  93. package/dist/commands/bot.js +198 -14
  94. package/dist/commands/dashboard.d.ts +1 -1
  95. package/dist/commands/dashboard.js +1 -1
  96. package/dist/commands/funds.d.ts +1 -1
  97. package/dist/commands/funds.js +33 -20
  98. package/dist/commands/history.d.ts +1 -1
  99. package/dist/commands/history.js +5 -4
  100. package/dist/commands/manage.d.ts +2 -3
  101. package/dist/commands/manage.js +11 -18
  102. package/dist/commands/market.d.ts +1 -1
  103. package/dist/commands/market.js +7 -7
  104. package/dist/commands/plan.d.ts +1 -1
  105. package/dist/commands/rebalance.d.ts +1 -1
  106. package/dist/commands/rebalance.js +8 -9
  107. package/dist/commands/risk.d.ts +1 -1
  108. package/dist/commands/settings.d.ts +1 -1
  109. package/dist/commands/settings.js +3 -1
  110. package/dist/commands/trade.d.ts +1 -1
  111. package/dist/commands/trade.js +39 -52
  112. package/dist/commands/wallet.d.ts +0 -2
  113. package/dist/commands/wallet.js +0 -5
  114. package/dist/constants.d.ts +0 -3
  115. package/dist/constants.js +0 -4
  116. package/dist/cross-chain-margin.d.ts +1 -1
  117. package/dist/dashboard/index.d.ts +4 -0
  118. package/dist/dashboard/index.js +3 -0
  119. package/dist/dashboard/server.d.ts +1 -1
  120. package/dist/dashboard/server.js +1 -1
  121. package/dist/dashboard/ws-feeds.d.ts +1 -1
  122. package/dist/event-stream.d.ts +1 -1
  123. package/dist/exchanges/aster.d.ts +58 -0
  124. package/dist/exchanges/aster.js +409 -0
  125. package/dist/exchanges/capabilities.d.ts +74 -0
  126. package/dist/exchanges/capabilities.js +44 -0
  127. package/dist/exchanges/hyperliquid-spot.d.ts +1 -1
  128. package/dist/exchanges/hyperliquid-spot.js +20 -12
  129. package/dist/exchanges/hyperliquid.d.ts +8 -2
  130. package/dist/exchanges/hyperliquid.js +21 -6
  131. package/dist/exchanges/index.d.ts +11 -0
  132. package/dist/exchanges/index.js +10 -0
  133. package/dist/exchanges/interface.d.ts +3 -0
  134. package/dist/exchanges/lighter-spot.d.ts +13 -0
  135. package/dist/exchanges/lighter-spot.js +98 -26
  136. package/dist/exchanges/lighter.d.ts +9 -3
  137. package/dist/exchanges/lighter.js +18 -8
  138. package/dist/exchanges/nado.d.ts +52 -0
  139. package/dist/exchanges/nado.js +223 -0
  140. package/dist/exchanges/pacifica.d.ts +7 -1
  141. package/dist/exchanges/pacifica.js +17 -2
  142. package/dist/exchanges/registry.d.ts +13 -0
  143. package/dist/exchanges/registry.js +37 -0
  144. package/dist/exchanges/spot-interface.d.ts +9 -0
  145. package/dist/funding/rates.js +1 -1
  146. package/dist/index.js +2 -6
  147. package/dist/jobs.js +1 -1
  148. package/dist/liquidity.d.ts +1 -2
  149. package/dist/liquidity.js +12 -5
  150. package/dist/mcp-server.js +4 -2
  151. package/dist/pacifica/constants.d.ts +0 -3
  152. package/dist/pacifica/constants.js +0 -4
  153. package/dist/pacifica/types/account.d.ts +0 -8
  154. package/dist/pacifica/types/lake.d.ts +0 -6
  155. package/dist/pacifica/types/position.d.ts +0 -12
  156. package/dist/pacifica/types/ws.d.ts +0 -24
  157. package/dist/pacifica/types/ws.js +1 -41
  158. package/dist/plan-executor.d.ts +1 -1
  159. package/dist/rebalance.d.ts +1 -7
  160. package/dist/rebalance.js +0 -6
  161. package/dist/risk.d.ts +1 -1
  162. package/dist/risk.js +3 -1
  163. package/dist/smart-order.d.ts +1 -1
  164. package/dist/strategies/dca.d.ts +1 -1
  165. package/dist/strategies/funding-arb.d.ts +1 -1
  166. package/dist/strategies/funding-arb.js +193 -181
  167. package/dist/strategies/grid.d.ts +1 -1
  168. package/dist/strategies/grid.js +1 -1
  169. package/dist/strategies/index.d.ts +12 -0
  170. package/dist/strategies/index.js +7 -0
  171. package/dist/strategies/split-order.d.ts +1 -1
  172. package/dist/strategies/trailing-stop.d.ts +1 -1
  173. package/dist/strategies/twap.d.ts +1 -1
  174. package/dist/trade-validator.d.ts +1 -1
  175. package/package.json +7 -1
  176. package/skills/perp-cli/SKILL.md +20 -0
  177. package/skills/perp-cli/references/commands.md +22 -8
@@ -1,67 +1,167 @@
1
1
  import { getMarketSnapshot, evaluateAllConditions } from "./conditions.js";
2
2
  import { updateJobState } from "../jobs.js";
3
- import { checkArbLiquidity } from "../liquidity.js";
4
- import { computeMatchedSize, reconcileArbFills } from "../arb-sizing.js";
3
+ import { getStrategy } from "./strategy-registry.js";
5
4
  import chalk from "chalk";
6
- export async function runBot(adapter, config, jobId, log = defaultLog, extraAdapters) {
5
+ // Auto-register built-in strategies
6
+ import "./strategies/grid-strategy.js";
7
+ import "./strategies/dca-strategy.js";
8
+ import "./strategies/funding-arb-strategy.js";
9
+ import "./strategies/simple-mm.js";
10
+ import "./strategies/engine-mm.js";
11
+ import "./strategies/avellaneda-mm.js";
12
+ import "./strategies/momentum-breakout.js";
13
+ import "./strategies/mean-reversion.js";
14
+ import "./strategies/aggressive-taker.js";
15
+ import "./strategies/funding-arb-v2.js";
16
+ import "./strategies/basis-arb.js";
17
+ import "./strategies/regime-mm.js";
18
+ import "./strategies/grid-mm.js";
19
+ import "./strategies/liquidation-mm.js";
20
+ import "./strategies/hedge-agent.js";
21
+ import "./strategies/rfq-agent.js";
22
+ import "./strategies/claude-agent.js";
23
+ import "./strategies/apex-strategy.js";
24
+ import "./strategies/funding-auto.js";
25
+ // ── TUI state emitter ──
26
+ class TuiEmitter {
27
+ stateListeners = [];
28
+ logListeners = [];
29
+ subscribe(onState, onLog) {
30
+ this.stateListeners.push(onState);
31
+ this.logListeners.push(onLog);
32
+ return () => {
33
+ this.stateListeners = this.stateListeners.filter((l) => l !== onState);
34
+ this.logListeners = this.logListeners.filter((l) => l !== onLog);
35
+ };
36
+ }
37
+ emitState(s) {
38
+ for (const l of this.stateListeners)
39
+ l(s);
40
+ }
41
+ emitLog(msg) {
42
+ const entry = { time: new Date().toLocaleTimeString(), message: msg };
43
+ for (const l of this.logListeners)
44
+ l(entry);
45
+ }
46
+ }
47
+ export async function runBot(adapter, config, jobId, log = defaultLog, extraAdapters, // for funding-arb (multi-exchange)
48
+ mode = "headless") {
7
49
  const state = {
8
50
  phase: "monitoring",
9
51
  startTime: Date.now(),
10
52
  equity: 0,
11
53
  peakEquity: 0,
12
54
  dailyPnl: 0,
55
+ dailyStartEquity: 0,
56
+ dailyStartDate: new Date().toISOString().slice(0, 10),
13
57
  fills: 0,
14
58
  totalPnl: 0,
15
59
  rebalanceCount: 0,
16
60
  lastRebalance: 0,
17
61
  strategyActive: false,
18
- gridOrders: new Map(),
19
- gridUpper: 0,
20
- gridLower: 0,
21
- dcaOrdersPlaced: 0,
22
- dcaLastOrder: 0,
23
- arbRunning: false,
24
- arbPositions: 0,
25
62
  };
63
+ // Resolve strategy from registry
64
+ const strategyFactory = getStrategy(config.strategy.type);
65
+ if (!strategyFactory)
66
+ throw new Error(`Unknown strategy: ${config.strategy.type}`);
67
+ const strategy = strategyFactory(config.strategy);
68
+ const strategyCtx = {
69
+ adapter,
70
+ symbol: config.symbol,
71
+ config: config.strategy,
72
+ state: new Map(),
73
+ tick: 0,
74
+ log,
75
+ };
76
+ // Pass extra adapters to strategy context for funding-arb
77
+ if (extraAdapters) {
78
+ strategyCtx.state.set("extraAdapters", extraAdapters);
79
+ }
80
+ const isDryRun = false; // TODO: wire up from config when dry-run mode is added
26
81
  let running = true;
82
+ // ── TUI / JSON mode setup ──
83
+ const tuiEmitter = new TuiEmitter();
84
+ let tuiUnmount;
85
+ let paused = false;
86
+ // Wrap log for TUI/JSON modes
87
+ const effectiveLog = mode === "json"
88
+ ? () => { } // JSON mode suppresses console log; NDJSON emitted per tick
89
+ : mode === "tui"
90
+ ? (msg) => tuiEmitter.emitLog(msg)
91
+ : log;
92
+ // Update strategy context to use effective log
93
+ strategyCtx.log = effectiveLog;
94
+ if (mode === "tui") {
95
+ try {
96
+ const { startDashboard } = await import("./tui/index.js");
97
+ const initialTuiState = buildTuiState(state, config, { price: 0, volatility24h: 0, fundingRate: 0, volume24h: 0 }, [], [], strategyCtx);
98
+ const result = startDashboard({
99
+ initialState: initialTuiState,
100
+ onQuit: () => { running = false; state.phase = "stopped"; },
101
+ onPause: () => { paused = !paused; },
102
+ subscribe: (onState, onLog) => tuiEmitter.subscribe(onState, onLog),
103
+ });
104
+ tuiUnmount = result.unmount;
105
+ }
106
+ catch {
107
+ // Fallback to headless if TUI fails (e.g., non-TTY)
108
+ // mode stays as-is but we just use the original log
109
+ }
110
+ }
27
111
  const shutdown = () => { running = false; state.phase = "stopped"; };
28
112
  process.on("SIGINT", shutdown);
29
113
  process.on("SIGTERM", shutdown);
30
- // Header
31
- log(chalk.cyan.bold(`\n ╔═══════════════════════════════════════╗`));
32
- log(chalk.cyan.bold(` ║ ${config.name.padEnd(37)}║`));
33
- log(chalk.cyan.bold(` ╚═══════════════════════════════════════╝\n`));
34
- log(` Exchange: ${chalk.white(config.exchange)}`);
35
- log(` Symbol: ${chalk.white(config.symbol)}`);
36
- log(` Strategy: ${chalk.white(config.strategy.type)}`);
37
- log(` Risk: max drawdown $${config.risk.max_drawdown} | max daily loss $${config.risk.max_daily_loss}`);
38
- log(` Entry: ${config.entry_conditions.map(c => c.type).join(" + ")}`);
39
- log(` Exit: ${config.exit_conditions.map(c => c.type).join(" | ") || "manual"}`);
40
- log("");
114
+ if (mode !== "tui") {
115
+ // Header (headless / json modes print header to console)
116
+ log(chalk.cyan.bold(`\n ╔═══════════════════════════════════════╗`));
117
+ log(chalk.cyan.bold(` ║ ${config.name.padEnd(37)}║`));
118
+ log(chalk.cyan.bold(` ╚═══════════════════════════════════════╝\n`));
119
+ log(` Exchange: ${chalk.white(config.exchange)}`);
120
+ log(` Symbol: ${chalk.white(config.symbol)}`);
121
+ log(` Strategy: ${chalk.white(config.strategy.type)}`);
122
+ log(` Risk: max drawdown $${config.risk.max_drawdown} | max daily loss $${config.risk.max_daily_loss}`);
123
+ log(` Entry: ${config.entry_conditions.map(c => c.type).join(" + ")}`);
124
+ log(` Exit: ${config.exit_conditions.map(c => c.type).join(" | ") || "manual"}`);
125
+ log("");
126
+ }
41
127
  // Get initial equity
42
128
  try {
43
129
  const bal = await adapter.getBalance();
44
130
  state.equity = parseFloat(bal.equity);
45
131
  state.peakEquity = state.equity;
46
- log(` Starting equity: $${state.equity.toFixed(2)}`);
132
+ state.dailyStartEquity = state.equity;
133
+ effectiveLog(` Starting equity: $${state.equity.toFixed(2)}`);
47
134
  }
48
135
  catch {
49
- log(chalk.yellow(` Could not fetch initial balance`));
136
+ effectiveLog(chalk.yellow(` Could not fetch initial balance`));
137
+ }
138
+ if (mode !== "tui") {
139
+ log(chalk.gray(`\n Monitoring conditions... (Ctrl+C to stop)\n`));
50
140
  }
51
- log(chalk.gray(`\n Monitoring conditions... (Ctrl+C to stop)\n`));
52
141
  try {
53
142
  while (running) {
143
+ // Honor pause toggle from TUI
144
+ if (paused) {
145
+ await sleep(500);
146
+ continue;
147
+ }
54
148
  const loopStart = Date.now();
55
149
  try {
56
- // Get market data
57
- const snapshot = await getMarketSnapshot(adapter, config.symbol);
150
+ // Get market data (enriched for strategy use)
151
+ const snapshot = await getEnrichedSnapshot(adapter, config.symbol);
58
152
  // Update equity
59
153
  try {
60
154
  const bal = await adapter.getBalance();
61
155
  state.equity = parseFloat(bal.equity);
62
156
  if (state.equity > state.peakEquity)
63
157
  state.peakEquity = state.equity;
64
- state.dailyPnl = state.equity - state.peakEquity; // simplified
158
+ // Reset daily baseline at day boundary
159
+ const today = new Date().toISOString().slice(0, 10);
160
+ if (today !== state.dailyStartDate) {
161
+ state.dailyStartEquity = state.equity;
162
+ state.dailyStartDate = today;
163
+ }
164
+ state.dailyPnl = state.equity - state.dailyStartEquity;
65
165
  }
66
166
  catch { /* non-critical */ }
67
167
  const context = {
@@ -74,24 +174,30 @@ export async function runBot(adapter, config, jobId, log = defaultLog, extraAdap
74
174
  if (state.phase === "monitoring") {
75
175
  const shouldEnter = evaluateAllConditions(config.entry_conditions, snapshot, context, "all");
76
176
  if (shouldEnter) {
77
- log(chalk.green(` ✓ Entry conditions met @ $${snapshot.price.toFixed(2)}`));
177
+ effectiveLog(` Entry conditions met @ $${snapshot.price.toFixed(2)}`);
78
178
  state.phase = "entering";
79
179
  }
80
180
  else {
81
- logStatus(log, state, snapshot, "waiting");
181
+ logStatus(effectiveLog, state, snapshot, "waiting");
82
182
  }
83
183
  }
84
184
  // ── Phase: Entering (start strategy) ──
85
185
  if (state.phase === "entering") {
86
186
  try {
87
- await startStrategy(adapter, config, state, snapshot, log, extraAdapters);
187
+ await strategy.init(strategyCtx, snapshot);
188
+ // Execute any initial actions (e.g., grid placement on first tick)
189
+ strategyCtx.tick = 0;
190
+ const initActions = await strategy.onTick(strategyCtx, snapshot);
191
+ await executeActions(adapter, config.symbol, initActions, effectiveLog, isDryRun);
192
+ state.fills += countFills(initActions);
193
+ strategyCtx.tick++;
88
194
  state.phase = "running";
89
195
  state.strategyActive = true;
90
- log(chalk.green(` ▶ Strategy started`));
196
+ effectiveLog(` Strategy started`);
91
197
  }
92
198
  catch (err) {
93
199
  const msg = err instanceof Error ? err.message : String(err);
94
- log(chalk.red(` ✗ Strategy start failed: ${msg}`));
200
+ effectiveLog(` Strategy start failed: ${msg}`);
95
201
  state.phase = "monitoring"; // retry
96
202
  }
97
203
  }
@@ -102,37 +208,54 @@ export async function runBot(adapter, config, jobId, log = defaultLog, extraAdap
102
208
  && evaluateAllConditions(config.exit_conditions, snapshot, context, "any");
103
209
  // Check risk limits
104
210
  const drawdown = state.peakEquity - state.equity;
105
- const riskBreached = drawdown > config.risk.max_drawdown;
211
+ const dailyLossBreached = state.dailyPnl < -config.risk.max_daily_loss;
212
+ const riskBreached = drawdown > config.risk.max_drawdown || dailyLossBreached;
106
213
  if (shouldExit || riskBreached) {
107
- const reason = riskBreached
108
- ? `drawdown $${drawdown.toFixed(2)} > limit $${config.risk.max_drawdown}`
109
- : "exit condition met";
110
- log(chalk.yellow(` ⚠ Exiting: ${reason}`));
214
+ const reason = dailyLossBreached
215
+ ? `daily loss $${Math.abs(state.dailyPnl).toFixed(2)} > limit $${config.risk.max_daily_loss}`
216
+ : drawdown > config.risk.max_drawdown
217
+ ? `drawdown $${drawdown.toFixed(2)} > limit $${config.risk.max_drawdown}`
218
+ : "exit condition met";
219
+ effectiveLog(` Exiting: ${reason}`);
111
220
  state.phase = "exiting";
112
221
  }
113
222
  else {
114
223
  // Manage running strategy
115
- await manageStrategy(adapter, config, state, snapshot, log, extraAdapters);
116
- logStatus(log, state, snapshot, "running");
224
+ const actions = await strategy.onTick(strategyCtx, snapshot);
225
+ await executeActions(adapter, config.symbol, actions, effectiveLog, isDryRun);
226
+ state.fills += countFills(actions);
227
+ strategyCtx.tick++;
228
+ logStatus(effectiveLog, state, snapshot, "running");
117
229
  }
118
230
  }
119
231
  // ── Phase: Exiting (close everything) ──
120
232
  if (state.phase === "exiting") {
121
- await stopStrategy(adapter, config, state, log);
233
+ const stopActions = await strategy.onStop(strategyCtx);
234
+ await executeActions(adapter, config.symbol, stopActions, effectiveLog, isDryRun);
122
235
  state.strategyActive = false;
123
236
  if (config.risk.pause_after_loss_sec > 0 && state.dailyPnl < 0) {
124
- log(chalk.yellow(` ⏸ Pausing ${config.risk.pause_after_loss_sec}s after loss`));
237
+ effectiveLog(` Pausing ${config.risk.pause_after_loss_sec}s after loss`);
125
238
  state.phase = "paused";
126
239
  await sleep(config.risk.pause_after_loss_sec * 1000);
127
240
  state.phase = "monitoring";
128
- log(chalk.gray(` Resuming monitoring...`));
241
+ effectiveLog(` Resuming monitoring...`);
129
242
  }
130
243
  else {
131
244
  // If exit due to risk breach, stop completely
132
- log(chalk.red(` ■ Bot stopped.`));
245
+ effectiveLog(` Bot stopped.`);
133
246
  break;
134
247
  }
135
248
  }
249
+ // ── Emit state update for TUI / JSON ──
250
+ const positions = await fetchPositions(adapter, config.symbol);
251
+ const openOrders = await fetchOpenOrders(adapter, config.symbol);
252
+ const tuiState = buildTuiState(state, config, snapshot, positions, openOrders, strategyCtx);
253
+ if (mode === "tui") {
254
+ tuiEmitter.emitState(tuiState);
255
+ }
256
+ else if (mode === "json") {
257
+ console.log(JSON.stringify(tuiState));
258
+ }
136
259
  // Update job state
137
260
  if (jobId) {
138
261
  updateJobState(jobId, {
@@ -150,7 +273,7 @@ export async function runBot(adapter, config, jobId, log = defaultLog, extraAdap
150
273
  }
151
274
  catch (err) {
152
275
  const msg = err instanceof Error ? err.message : String(err);
153
- log(chalk.red(` ✗ Loop error: ${msg}`));
276
+ effectiveLog(` Loop error: ${msg}`);
154
277
  }
155
278
  // Wait for next interval
156
279
  const elapsed = Date.now() - loopStart;
@@ -164,334 +287,83 @@ export async function runBot(adapter, config, jobId, log = defaultLog, extraAdap
164
287
  process.removeListener("SIGTERM", shutdown);
165
288
  // Cleanup: cancel any remaining orders
166
289
  if (state.strategyActive) {
167
- log(chalk.gray(` Cleaning up orders...`));
290
+ effectiveLog(` Cleaning up orders...`);
168
291
  try {
169
292
  await adapter.cancelAllOrders(config.symbol);
170
293
  }
171
294
  catch { /* best effort */ }
172
295
  }
296
+ // Unmount TUI if active
297
+ if (tuiUnmount) {
298
+ tuiUnmount();
299
+ }
173
300
  }
174
301
  const runtime = Math.floor((Date.now() - state.startTime) / 1000);
175
- log(chalk.cyan.bold(`\n Bot "${config.name}" finished`));
176
- log(` Runtime: ${formatDuration(runtime)} | Fills: ${state.fills} | PnL: $${state.totalPnl.toFixed(2)}`);
177
- log(` Equity: $${state.equity.toFixed(2)} (peak: $${state.peakEquity.toFixed(2)})\n`);
302
+ if (mode === "json") {
303
+ console.log(JSON.stringify({ event: "done", fills: state.fills, totalPnl: state.totalPnl, runtime, equity: state.equity, peakEquity: state.peakEquity }));
304
+ }
305
+ else if (mode !== "tui") {
306
+ log(chalk.cyan.bold(`\n Bot "${config.name}" finished`));
307
+ log(` Runtime: ${formatDuration(runtime)} | Fills: ${state.fills} | PnL: $${state.totalPnl.toFixed(2)}`);
308
+ log(` Equity: $${state.equity.toFixed(2)} (peak: $${state.peakEquity.toFixed(2)})\n`);
309
+ }
178
310
  if (jobId) {
179
311
  updateJobState(jobId, { status: "done" });
180
312
  }
181
313
  return { fills: state.fills, totalPnl: state.totalPnl, runtime };
182
314
  }
183
- // ── Strategy lifecycle ──
184
- async function startStrategy(adapter, config, state, snapshot, log, extraAdapters) {
185
- const strat = config.strategy;
186
- if (strat.type === "grid") {
187
- await startGrid(adapter, config.symbol, strat, state, snapshot, log);
188
- }
189
- else if (strat.type === "dca") {
190
- state.dcaOrdersPlaced = 0;
191
- state.dcaLastOrder = 0;
192
- log(` [DCA] Ready: ${strat.amount} ${config.symbol} every ${strat.interval_sec}s`);
193
- }
194
- else if (strat.type === "funding-arb") {
195
- state.arbRunning = true;
196
- state.arbPositions = 0;
197
- log(` [ARB] Funding arb ready | spread >= ${strat.min_spread}% | size $${strat.size_usd}`);
198
- log(` [ARB] Exchanges: ${strat.exchanges.join(", ")}`);
199
- }
200
- }
201
- async function manageStrategy(adapter, config, state, snapshot, log, extraAdapters) {
202
- const strat = config.strategy;
203
- if (strat.type === "grid") {
204
- await manageGrid(adapter, config.symbol, strat, state, snapshot, log);
205
- }
206
- else if (strat.type === "dca") {
207
- await manageDCA(adapter, config.symbol, strat, state, snapshot, log);
208
- }
209
- else if (strat.type === "funding-arb") {
210
- await manageFundingArb(adapter, config.symbol, strat, state, snapshot, log, extraAdapters);
211
- }
212
- }
213
- async function stopStrategy(adapter, config, state, log) {
214
- log(` Cancelling all orders...`);
215
- try {
216
- await adapter.cancelAllOrders(config.symbol);
217
- }
218
- catch { /* best effort */ }
219
- state.gridOrders.clear();
220
- }
221
- // ── Grid strategy ──
222
- async function startGrid(adapter, symbol, params, state, snapshot, log) {
223
- // Determine price range
224
- if (params.range_mode === "auto") {
225
- const pct = params.range_pct ?? 3;
226
- state.gridUpper = snapshot.price * (1 + pct / 100);
227
- state.gridLower = snapshot.price * (1 - pct / 100);
228
- log(` [GRID] Auto range: $${state.gridLower.toFixed(2)} - $${state.gridUpper.toFixed(2)} (±${pct}%)`);
229
- }
230
- else {
231
- if (!params.upper || !params.lower)
232
- throw new Error("Fixed grid requires upper and lower");
233
- state.gridUpper = params.upper;
234
- state.gridLower = params.lower;
235
- }
236
- // Set leverage
237
- if (params.leverage) {
238
- try {
239
- await adapter.setLeverage(symbol, params.leverage);
240
- log(` [GRID] Leverage: ${params.leverage}x`);
241
- }
242
- catch { /* non-critical */ }
243
- }
244
- // Place grid orders
245
- await placeGridOrders(adapter, symbol, params, state, snapshot.price, log);
246
- }
247
- async function placeGridOrders(adapter, symbol, params, state, currentPrice, log) {
248
- const step = (state.gridUpper - state.gridLower) / (params.grids - 1);
249
- const sizePerGrid = params.size / params.grids;
250
- let placed = 0;
251
- // Cancel existing orders first
252
- if (state.gridOrders.size > 0) {
253
- try {
254
- await adapter.cancelAllOrders(symbol);
255
- }
256
- catch { /* */ }
257
- state.gridOrders.clear();
258
- }
259
- for (let i = 0; i < params.grids; i++) {
260
- const price = state.gridLower + step * i;
261
- // Determine side based on current price
262
- let side;
263
- if (params.side === "long")
264
- side = "buy";
265
- else if (params.side === "short")
266
- side = "sell";
267
- else
268
- side = price < currentPrice ? "buy" : "sell";
269
- try {
270
- const result = await adapter.limitOrder(symbol, side, price.toFixed(2), String(sizePerGrid));
271
- const orderId = String(result?.orderId ?? result?.oid ?? result?.id ?? "");
272
- state.gridOrders.set(i, orderId);
273
- placed++;
315
+ // ── Action executor ──
316
+ async function executeActions(adapter, symbol, actions, log, dryRun = false) {
317
+ for (const action of actions) {
318
+ if (dryRun) {
319
+ log(` [DRY-RUN] ${action.type}: ${JSON.stringify(action)}`);
320
+ continue;
274
321
  }
275
- catch {
276
- // skip failed grid line
277
- }
278
- }
279
- log(` [GRID] Placed ${placed}/${params.grids} orders (step: $${step.toFixed(2)}, size: ${sizePerGrid.toFixed(6)})`);
280
- }
281
- async function manageGrid(adapter, symbol, params, state, snapshot, log) {
282
- const step = (state.gridUpper - state.gridLower) / (params.grids - 1);
283
- const sizePerGrid = params.size / params.grids;
284
- // Check for fills
285
- try {
286
- const openOrders = await adapter.getOpenOrders();
287
- const openIds = new Set(openOrders.filter(o => o.symbol.toUpperCase() === symbol.toUpperCase()).map(o => o.orderId));
288
- let newFills = 0;
289
- for (const [idx, orderId] of state.gridOrders.entries()) {
290
- if (!openIds.has(orderId)) {
291
- // Order filled — place opposite order
292
- newFills++;
293
- state.fills++;
294
- state.totalPnl += step * sizePerGrid;
295
- // Determine new side and price
296
- const oldPrice = state.gridLower + step * idx;
297
- const wasBuy = oldPrice < snapshot.price;
298
- const newSide = wasBuy ? "sell" : "buy";
299
- const newPrice = wasBuy ? oldPrice + step : oldPrice - step;
300
- if (newPrice >= state.gridLower && newPrice <= state.gridUpper) {
301
- try {
302
- const result = await adapter.limitOrder(symbol, newSide, newPrice.toFixed(2), String(sizePerGrid));
303
- const newOrderId = String(result?.orderId ?? result?.oid ?? result?.id ?? "");
304
- state.gridOrders.set(idx, newOrderId);
305
- }
306
- catch { /* skip */ }
322
+ switch (action.type) {
323
+ case "place_order":
324
+ if (action.orderType === "market") {
325
+ await adapter.marketOrder(symbol, action.side, action.size);
307
326
  }
308
327
  else {
309
- state.gridOrders.delete(idx);
328
+ await adapter.limitOrder(symbol, action.side, action.price, action.size, {
329
+ reduceOnly: action.reduceOnly,
330
+ tif: action.tif,
331
+ });
310
332
  }
311
- }
312
- }
313
- if (newFills > 0) {
314
- log(chalk.green(` [GRID] ${newFills} fill(s) | Total: ${state.fills} | Est. PnL: $${state.totalPnl.toFixed(2)}`));
315
- }
316
- }
317
- catch { /* retry next loop */ }
318
- // Auto-rebalance if price exits range
319
- if (params.rebalance) {
320
- const outOfRange = snapshot.price > state.gridUpper || snapshot.price < state.gridLower;
321
- const cooldownOk = Date.now() - state.lastRebalance > params.rebalance_cooldown * 1000;
322
- if (outOfRange && cooldownOk) {
323
- const pct = params.range_pct ?? 3;
324
- state.gridUpper = snapshot.price * (1 + pct / 100);
325
- state.gridLower = snapshot.price * (1 - pct / 100);
326
- state.rebalanceCount++;
327
- state.lastRebalance = Date.now();
328
- log(chalk.yellow(` [GRID] Rebalance #${state.rebalanceCount}: price $${snapshot.price.toFixed(2)} outside range → new $${state.gridLower.toFixed(2)} - $${state.gridUpper.toFixed(2)}`));
329
- await placeGridOrders(adapter, symbol, params, state, snapshot.price, log);
330
- }
331
- }
332
- }
333
- // ── DCA strategy ──
334
- async function manageDCA(adapter, symbol, params, state, snapshot, log) {
335
- // Check if it's time for next order
336
- const timeSinceLast = (Date.now() - state.dcaLastOrder) / 1000;
337
- if (state.dcaLastOrder > 0 && timeSinceLast < params.interval_sec)
338
- return;
339
- // Check order limit
340
- if (params.total_orders > 0 && state.dcaOrdersPlaced >= params.total_orders)
341
- return;
342
- // Check price limit
343
- if (params.price_limit) {
344
- // For DCA, we assume buying — skip if price above limit
345
- if (snapshot.price > params.price_limit) {
346
- return;
333
+ break;
334
+ case "cancel_order":
335
+ await adapter.cancelOrder(symbol, action.orderId);
336
+ break;
337
+ case "cancel_all":
338
+ await adapter.cancelAllOrders(symbol);
339
+ break;
340
+ case "edit_order":
341
+ await adapter.editOrder(symbol, action.orderId, action.price, action.size);
342
+ break;
343
+ case "set_leverage":
344
+ await adapter.setLeverage(symbol, action.leverage, action.marginMode);
345
+ break;
346
+ case "noop":
347
+ break;
347
348
  }
348
349
  }
349
- // Place market order
350
- try {
351
- const side = "buy"; // DCA is typically buying
352
- await adapter.marketOrder(symbol, side, String(params.amount));
353
- state.dcaOrdersPlaced++;
354
- state.dcaLastOrder = Date.now();
355
- state.fills++;
356
- const progress = params.total_orders > 0 ? ` (${state.dcaOrdersPlaced}/${params.total_orders})` : "";
357
- log(chalk.green(` [DCA] Order #${state.dcaOrdersPlaced}${progress}: buy ${params.amount} ${symbol} @ $${snapshot.price.toFixed(2)}`));
358
- }
359
- catch (err) {
360
- const msg = err instanceof Error ? err.message : String(err);
361
- log(chalk.red(` [DCA] Order failed: ${msg}`));
362
- }
363
350
  }
364
- async function fetchRatesFromAdapter(adapter, exchangeName) {
365
- try {
366
- const markets = await adapter.getMarkets();
367
- return markets.map(m => ({
368
- symbol: m.symbol,
369
- rate: parseFloat(m.fundingRate),
370
- price: parseFloat(m.markPrice),
371
- }));
372
- }
373
- catch {
374
- return [];
375
- }
376
- }
377
- async function manageFundingArb(adapter, symbol, params, state, _snapshot, log, extraAdapters) {
378
- // Build adapter map from primary + extras
379
- const adapters = new Map();
380
- adapters.set(adapter.name.toLowerCase(), adapter);
381
- if (extraAdapters) {
382
- for (const [name, a] of extraAdapters)
383
- adapters.set(name, a);
384
- }
385
- if (adapters.size < 2) {
386
- log(chalk.yellow(` [ARB] Need 2+ exchanges, have ${adapters.size}. Skipping.`));
387
- return;
388
- }
389
- // Fetch rates from all exchanges
390
- const ratesByExchange = new Map();
391
- for (const [name, a] of adapters) {
392
- const rates = await fetchRatesFromAdapter(a, name);
393
- const map = new Map();
394
- for (const r of rates)
395
- map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price });
396
- ratesByExchange.set(name, map);
397
- }
398
- // Find opportunities: largest spread between any two exchanges
399
- const exchangeNames = [...ratesByExchange.keys()];
400
- const opportunities = [];
401
- // Collect all symbols
402
- const allSymbols = new Set();
403
- for (const [, map] of ratesByExchange) {
404
- for (const sym of map.keys())
405
- allSymbols.add(sym);
406
- }
407
- for (const sym of allSymbols) {
408
- let minRate = Infinity, maxRate = -Infinity;
409
- let minExchange = "", maxExchange = "";
410
- for (const exName of exchangeNames) {
411
- const rate = ratesByExchange.get(exName)?.get(sym)?.rate;
412
- if (rate === undefined)
413
- continue;
414
- if (rate < minRate) {
415
- minRate = rate;
416
- minExchange = exName;
417
- }
418
- if (rate > maxRate) {
419
- maxRate = rate;
420
- maxExchange = exName;
421
- }
422
- }
423
- if (minExchange && maxExchange && minExchange !== maxExchange) {
424
- const { computeAnnualSpread: cas } = await import("../funding.js");
425
- const spread = cas(maxRate, maxExchange, minRate, minExchange);
426
- if (spread >= params.min_spread) {
427
- opportunities.push({
428
- symbol: sym,
429
- longExchange: minExchange, // long where rate is low (we receive funding)
430
- shortExchange: maxExchange, // short where rate is high (we pay less or receive)
431
- longRate: minRate,
432
- shortRate: maxRate,
433
- spread,
434
- });
435
- }
436
- }
437
- }
438
- // Sort by spread descending
439
- opportunities.sort((a, b) => b.spread - a.spread);
440
- if (opportunities.length > 0) {
441
- const top = opportunities.slice(0, 3);
442
- for (const opp of top) {
443
- log(` [ARB] ${opp.symbol}: ${opp.spread.toFixed(1)}% spread (long ${opp.longExchange} ${(opp.longRate * 100).toFixed(4)}% / short ${opp.shortExchange} ${(opp.shortRate * 100).toFixed(4)}%)`);
444
- }
445
- // Auto-execute if under position limit
446
- if (state.arbPositions < params.max_positions && opportunities.length > 0) {
447
- const best = opportunities[0];
448
- const longAdapter = adapters.get(best.longExchange);
449
- const shortAdapter = adapters.get(best.shortExchange);
450
- if (longAdapter && shortAdapter) {
451
- // Calculate size from USD
452
- const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
453
- if (price > 0) {
454
- // Liquidity check & size adjustment
455
- const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, params.size_usd, 0.5, (msg) => log(chalk.yellow(` ${msg}`)));
456
- if (!liq.viable)
457
- return;
458
- // Compute matched size for both legs
459
- const matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
460
- if (!matched) {
461
- log(chalk.yellow(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`));
462
- return;
463
- }
464
- try {
465
- log(chalk.cyan(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`));
466
- await Promise.all([
467
- longAdapter.marketOrder(best.symbol, "buy", matched.size),
468
- shortAdapter.marketOrder(best.symbol, "sell", matched.size),
469
- ]);
470
- // Verify fills match, correct if needed
471
- try {
472
- const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => log(chalk.yellow(` ${msg}`)));
473
- if (!recon.matched) {
474
- log(chalk.red(` [ARB] WARNING: fills not matched after correction attempt`));
475
- }
476
- }
477
- catch { /* non-critical */ }
478
- state.arbPositions++;
479
- state.fills += 2;
480
- log(chalk.green(` [ARB] Position opened! (${state.arbPositions}/${params.max_positions})`));
481
- }
482
- catch (err) {
483
- const msg = err instanceof Error ? err.message : String(err);
484
- log(chalk.red(` [ARB] Execution failed: ${msg}`));
485
- }
486
- }
487
- }
488
- }
489
- }
490
- else {
491
- log(chalk.gray(` [ARB] No opportunities >= ${params.min_spread}% spread`));
492
- }
351
+ // ── Enriched snapshot ──
352
+ async function getEnrichedSnapshot(adapter, symbol) {
353
+ const base = await getMarketSnapshot(adapter, symbol);
354
+ const [orderbook, klines, markets] = await Promise.all([
355
+ adapter.getOrderbook(symbol).catch(() => ({ bids: [], asks: [] })),
356
+ adapter.getKlines(symbol, "1h", Date.now() - 24 * 60 * 60 * 1000, Date.now()).catch(() => []),
357
+ adapter.getMarkets().catch(() => []),
358
+ ]);
359
+ const market = markets.find(m => m.symbol.toUpperCase().includes(symbol.toUpperCase()));
360
+ return { ...base, klines, orderbook, openInterest: market?.openInterest ?? "0" };
493
361
  }
494
362
  // ── Utils ──
363
+ /** Count fill-producing actions (market orders and limit orders) */
364
+ function countFills(actions) {
365
+ return actions.filter(a => a.type === "place_order" && a.orderType === "market").length;
366
+ }
495
367
  function logStatus(log, state, snapshot, phase) {
496
368
  const runtime = formatDuration(Math.floor((Date.now() - state.startTime) / 1000));
497
369
  const vol = snapshot.volatility24h.toFixed(1);
@@ -517,3 +389,76 @@ function formatDuration(sec) {
517
389
  function sleep(ms) {
518
390
  return new Promise(r => setTimeout(r, ms));
519
391
  }
392
+ // ── TUI helpers ──
393
+ async function fetchPositions(adapter, symbol) {
394
+ try {
395
+ const positions = await adapter.getPositions();
396
+ return positions
397
+ .filter(p => p.symbol.toUpperCase().includes(symbol.toUpperCase()) && parseFloat(p.size) !== 0)
398
+ .map(p => ({
399
+ symbol: p.symbol,
400
+ side: p.side,
401
+ size: p.size,
402
+ entryPrice: p.entryPrice,
403
+ markPrice: p.markPrice,
404
+ unrealizedPnl: p.unrealizedPnl,
405
+ }));
406
+ }
407
+ catch {
408
+ return [];
409
+ }
410
+ }
411
+ async function fetchOpenOrders(adapter, symbol) {
412
+ try {
413
+ const orders = await adapter.getOpenOrders();
414
+ return orders
415
+ .filter(o => o.symbol.toUpperCase().includes(symbol.toUpperCase()))
416
+ .map(o => ({
417
+ orderId: o.orderId,
418
+ side: o.side,
419
+ price: o.price,
420
+ size: o.size,
421
+ type: o.type,
422
+ }));
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ }
428
+ function buildTuiState(state, config, snapshot, positions, openOrders, strategyCtx) {
429
+ // Extract serializable strategy state from the Map
430
+ const strategyState = {};
431
+ for (const [k, v] of strategyCtx.state) {
432
+ // Skip non-serializable values (adapters, functions)
433
+ if (typeof v === "function" || (typeof v === "object" && v !== null && !(Array.isArray(v)) && v instanceof Map))
434
+ continue;
435
+ try {
436
+ JSON.stringify(v);
437
+ strategyState[k] = v;
438
+ }
439
+ catch {
440
+ // skip non-serializable
441
+ }
442
+ }
443
+ return {
444
+ phase: state.phase,
445
+ equity: state.equity,
446
+ peakEquity: state.peakEquity,
447
+ dailyPnl: state.dailyPnl,
448
+ fills: state.fills,
449
+ totalPnl: state.totalPnl,
450
+ runtime: Math.floor((Date.now() - state.startTime) / 1000),
451
+ strategy: config.strategy.type,
452
+ symbol: config.symbol,
453
+ exchange: config.exchange,
454
+ price: snapshot.price,
455
+ volume24h: snapshot.volume24h ?? 0,
456
+ openInterest: "0",
457
+ fundingRate: snapshot.fundingRate,
458
+ volatility24h: snapshot.volatility24h,
459
+ positions,
460
+ openOrders,
461
+ strategyState,
462
+ tick: strategyCtx.tick,
463
+ };
464
+ }