perp-cli 0.4.8 → 0.4.10

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 (45) hide show
  1. package/README.md +44 -5
  2. package/dist/arb/reconcile.d.ts +28 -0
  3. package/dist/arb/reconcile.js +162 -0
  4. package/dist/commands/account.d.ts +1 -1
  5. package/dist/commands/account.js +366 -62
  6. package/dist/commands/agent.js +9 -5
  7. package/dist/commands/arb/index.js +243 -49
  8. package/dist/commands/arb-auto.js +67 -19
  9. package/dist/commands/arb.d.ts +2 -1
  10. package/dist/commands/arb.js +535 -131
  11. package/dist/commands/bot.js +207 -0
  12. package/dist/commands/funds.d.ts +4 -0
  13. package/dist/commands/funds.js +741 -0
  14. package/dist/commands/history.d.ts +2 -1
  15. package/dist/commands/history.js +508 -5
  16. package/dist/commands/manage.js +5 -3
  17. package/dist/commands/market.js +34 -1
  18. package/dist/commands/pnl.js +1 -1
  19. package/dist/commands/risk.d.ts +2 -0
  20. package/dist/commands/risk.js +56 -0
  21. package/dist/commands/settings.js +147 -0
  22. package/dist/commands/trade.d.ts +1 -1
  23. package/dist/commands/trade.js +241 -259
  24. package/dist/commands/wallet.d.ts +4 -0
  25. package/dist/commands/wallet.js +10 -0
  26. package/dist/config.d.ts +2 -2
  27. package/dist/config.js +11 -3
  28. package/dist/exchanges/hyperliquid-spot.d.ts +1 -0
  29. package/dist/exchanges/hyperliquid-spot.js +13 -21
  30. package/dist/exchanges/lighter-spot.js +13 -15
  31. package/dist/exchanges/lighter.js +82 -21
  32. package/dist/exchanges/pacifica.js +12 -0
  33. package/dist/execution-log.d.ts +1 -1
  34. package/dist/index.js +39 -123
  35. package/dist/mcp-server.js +43 -96
  36. package/dist/strategies/split-order.d.ts +49 -0
  37. package/dist/strategies/split-order.js +138 -0
  38. package/package.json +1 -1
  39. package/skills/perp-cli/SKILL.md +13 -26
  40. package/skills/perp-cli/references/agent-operations.md +19 -9
  41. package/skills/perp-cli/references/commands.md +38 -31
  42. package/skills/perp-cli/references/strategies.md +26 -16
  43. package/skills/perp-cli/scripts/funding-analysis.sh +2 -2
  44. package/skills/perp-cli/scripts/preflight.sh +1 -1
  45. package/skills/perp-cli/scripts/spot-perp-scan.sh +1 -1
@@ -12,7 +12,7 @@ function pac(adapter) {
12
12
  throw new Error("This command requires --exchange pacifica");
13
13
  return adapter;
14
14
  }
15
- export function registerTradeCommands(program, getAdapter, isJson, isDryRun = () => false) {
15
+ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = () => false, getAdapterForExchange) {
16
16
  /** Guard: if --dry-run is active, log the intended action and return without executing. */
17
17
  function dryRunGuard(action, details) {
18
18
  if (!isDryRun())
@@ -29,11 +29,11 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
29
29
  console.log();
30
30
  }
31
31
  logExecution({
32
- type: action.includes("limit") ? "limit_order" : action.includes("stop") ? "stop_order" : action.includes("cancel") ? "cancel" : "market_order",
32
+ type: action.includes("split") ? "split_order" : action.includes("limit") ? "limit_order" : action.includes("stop") ? "stop_order" : action.includes("cancel") ? "cancel" : "market_order",
33
33
  exchange: details.exchange ?? "unknown",
34
34
  symbol: (details.symbol ?? "").toUpperCase(),
35
35
  side: details.side ?? "",
36
- size: String(details.size ?? "0"),
36
+ size: String(details.size ?? details.totalUsd ?? "0"),
37
37
  price: details.price,
38
38
  status: "simulated",
39
39
  dryRun: true,
@@ -48,12 +48,37 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
48
48
  .option("-s, --slippage <pct>", "Slippage percent", "1")
49
49
  .option("--reduce-only", "Reduce only order")
50
50
  .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
51
+ .option("--split", "Use orderbook-aware split execution for large orders")
52
+ .option("--max-slippage <pct>", "Max slippage per split slice (%)", "0.3")
51
53
  .option("--client-id <id>", "Client order ID for idempotent tracking")
52
54
  .option("--auto-id", "Auto-generate a client order ID")
53
55
  .action(async (symbol, side, size, opts) => {
54
56
  const s = side.toLowerCase();
55
57
  if (s !== "buy" && s !== "sell")
56
58
  errorAndExit("Side must be buy or sell");
59
+ const sym = symbol.toUpperCase();
60
+ // ── Split execution branch ──
61
+ if (opts.split) {
62
+ const adapter = await getAdapter();
63
+ if (dryRunGuard("split-order", { exchange: adapter.name, symbol: sym, side: s, size }))
64
+ return;
65
+ const { runSplitOrder } = await import("../strategies/split-order.js");
66
+ const markets = await adapter.getMarkets();
67
+ const market = markets.find(m => symbolMatch(m.symbol, sym));
68
+ const markPrice = market ? parseFloat(market.markPrice) : 0;
69
+ const notionalUsd = markPrice > 0 ? parseFloat(size) * markPrice : parseFloat(size);
70
+ const result = await runSplitOrder(adapter, {
71
+ symbol: sym,
72
+ side: s,
73
+ totalSizeUsd: notionalUsd,
74
+ maxSlippagePct: parseFloat(opts.maxSlippage ?? "0.3"),
75
+ }, isJson() ? () => { } : (msg) => console.log(chalk.gray(` ${msg}`)));
76
+ if (isJson())
77
+ return printJson(jsonOk(result));
78
+ const statusColor = result.status === "complete" ? chalk.green : result.status === "partial" ? chalk.yellow : chalk.red;
79
+ console.log(`\n ${statusColor(result.status.toUpperCase())} | ${result.slices.length} slices | $${formatUsd(result.filledUsd)} filled | slippage: ${result.totalSlippagePct.toFixed(3)}%\n`);
80
+ return;
81
+ }
57
82
  const clientId = opts.autoId ? generateClientId() : opts.clientId;
58
83
  if (clientId && isOrderDuplicate(clientId)) {
59
84
  if (isJson())
@@ -62,7 +87,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
62
87
  return;
63
88
  }
64
89
  const adapter = await getAdapter();
65
- if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: s, size, smart: !!opts.smart }))
90
+ if (dryRunGuard("market_order", { exchange: adapter.name, symbol: sym, side: s, size, smart: !!opts.smart }))
66
91
  return;
67
92
  if (clientId) {
68
93
  logClientId({
@@ -186,6 +211,47 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
186
211
  console.log(chalk.green(`\n Market SELL ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
187
212
  printJson(jsonOk(result));
188
213
  });
214
+ // ── Split Order (orderbook-aware) ──
215
+ trade
216
+ .command("split <symbol> <side> <usd>")
217
+ .description("Orderbook-aware split execution — breaks large orders into slices based on depth")
218
+ .option("--max-slippage <pct>", "Max slippage per slice (%)", "0.3")
219
+ .option("--max-slices <n>", "Max number of slices", "10")
220
+ .option("--delay <ms>", "Delay between slices in ms", "1000")
221
+ .option("--min-slice <usd>", "Minimum slice size in USD", "100")
222
+ .action(async (symbol, side, usd, opts) => {
223
+ const sym = symbol.toUpperCase();
224
+ const orderSide = side.toLowerCase();
225
+ const totalUsd = parseFloat(usd);
226
+ if (!["buy", "sell"].includes(orderSide))
227
+ errorAndExit("Side must be 'buy' or 'sell'");
228
+ if (isNaN(totalUsd) || totalUsd <= 0)
229
+ errorAndExit("USD amount must be > 0");
230
+ const exchange = program.opts().exchange ?? "unknown";
231
+ if (dryRunGuard("split-order", { exchange, symbol: sym, side: orderSide, totalUsd, ...opts }))
232
+ return;
233
+ const adapter = await getAdapter();
234
+ const { runSplitOrder } = await import("../strategies/split-order.js");
235
+ const result = await runSplitOrder(adapter, {
236
+ symbol: sym,
237
+ side: orderSide,
238
+ totalSizeUsd: totalUsd,
239
+ maxSlippagePct: parseFloat(opts.maxSlippage),
240
+ maxSlices: parseInt(opts.maxSlices),
241
+ delayMs: parseInt(opts.delay),
242
+ minSliceUsd: parseFloat(opts.minSlice),
243
+ }, isJson() ? () => { } : (msg) => console.log(chalk.gray(` ${msg}`)));
244
+ if (isJson())
245
+ return printJson(jsonOk(result));
246
+ const statusColor = result.status === "complete" ? chalk.green
247
+ : result.status === "partial" ? chalk.yellow
248
+ : chalk.red;
249
+ console.log(`\n ${statusColor(result.status.toUpperCase())} | ${result.slices.length} slices`);
250
+ console.log(` Filled: $${formatUsd(result.filledUsd)} / $${formatUsd(result.requestedUsd)}`);
251
+ console.log(` Avg Price: $${formatUsd(result.avgPrice)}`);
252
+ console.log(` Slippage: ${result.totalSlippagePct.toFixed(3)}%`);
253
+ console.log(` Runtime: ${result.runtime.toFixed(1)}s\n`);
254
+ });
189
255
  trade
190
256
  .command("limit <symbol> <side> <price> <size>")
191
257
  .description("Place a limit order")
@@ -622,120 +688,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
622
688
  throw err;
623
689
  }
624
690
  });
625
- // ── Grid Bot (shortcut with --background) ──
626
- trade
627
- .command("grid <symbol>")
628
- .description("Run grid trading bot (foreground or --background)")
629
- .requiredOption("--upper <price>", "Upper price bound")
630
- .requiredOption("--lower <price>", "Lower price bound")
631
- .option("--grids <n>", "Number of grid lines", "10")
632
- .option("--size <size>", "Total position size (base)", "0.1")
633
- .option("--side <side>", "Grid bias: long, short, neutral", "neutral")
634
- .option("--leverage <n>", "Leverage to set")
635
- .option("--interval <sec>", "Check interval in seconds", "10")
636
- .option("--max-runtime <sec>", "Max runtime in seconds (0 = forever)", "0")
637
- .option("--trailing-stop <pct>", "Stop if equity drops by this % from peak")
638
- .option("--background", "Run in background (tmux)")
639
- .action(async (symbol, opts) => {
640
- const exchange = (await getAdapter()).name;
641
- const cliArgs = [
642
- `-e`, exchange, symbol.toUpperCase(),
643
- `--upper`, opts.upper, `--lower`, opts.lower,
644
- `--grids`, opts.grids, `--size`, opts.size,
645
- `--side`, opts.side, `--interval`, opts.interval,
646
- `--max-runtime`, opts.maxRuntime,
647
- ...(opts.leverage ? [`--leverage`, opts.leverage] : []),
648
- ...(opts.trailingStop ? [`--trailing-stop`, opts.trailingStop] : []),
649
- ];
650
- if (opts.background) {
651
- const { startJob } = await import("../jobs.js");
652
- const job = startJob({
653
- strategy: "grid",
654
- exchange,
655
- params: { symbol: symbol.toUpperCase(), ...opts },
656
- cliArgs,
657
- });
658
- if (isJson())
659
- return printJson(jsonOk(job));
660
- console.log(chalk.green(`\n Grid bot started in background.`));
661
- console.log(` ID: ${chalk.white.bold(job.id)}`);
662
- console.log(` Range: $${opts.lower} - $${opts.upper} | ${opts.grids} grids`);
663
- console.log(` Logs: ${chalk.gray(`perp jobs logs ${job.id}`)}`);
664
- console.log(` Stop: ${chalk.gray(`perp jobs stop ${job.id}`)}\n`);
665
- return;
666
- }
667
- // Foreground: delegate to `run grid`
668
- const { runGrid } = await import("../strategies/grid.js");
669
- const adapter = await getAdapter();
670
- const log = (msg) => {
671
- const ts = new Date().toLocaleTimeString();
672
- console.log(`${chalk.gray(ts)} ${msg}`);
673
- };
674
- await runGrid(adapter, {
675
- symbol: symbol.toUpperCase(),
676
- side: opts.side,
677
- upperPrice: parseFloat(opts.upper),
678
- lowerPrice: parseFloat(opts.lower),
679
- grids: parseInt(opts.grids),
680
- totalSize: parseFloat(opts.size),
681
- leverage: opts.leverage ? parseInt(opts.leverage) : undefined,
682
- intervalSec: parseInt(opts.interval),
683
- maxRuntime: parseInt(opts.maxRuntime),
684
- trailingStop: opts.trailingStop ? parseFloat(opts.trailingStop) : undefined,
685
- }, undefined, log);
686
- });
687
- // ── DCA (shortcut with --background) ──
688
- trade
689
- .command("dca <symbol> <side> <amount> <interval>")
690
- .description("Run DCA strategy (foreground or --background)")
691
- .option("--orders <n>", "Total number of orders (0 = unlimited)", "0")
692
- .option("--price-limit <price>", "Stop buying above / selling below this price")
693
- .option("--max-runtime <sec>", "Max runtime in seconds (0 = forever)", "0")
694
- .option("--background", "Run in background (tmux)")
695
- .action(async (symbol, side, amount, interval, opts) => {
696
- const s = side.toLowerCase();
697
- if (s !== "buy" && s !== "sell")
698
- errorAndExit("Side must be buy or sell");
699
- const exchange = (await getAdapter()).name;
700
- const cliArgs = [
701
- `-e`, exchange, symbol.toUpperCase(), s, amount, interval,
702
- `--orders`, opts.orders, `--max-runtime`, opts.maxRuntime,
703
- ...(opts.priceLimit ? [`--price-limit`, opts.priceLimit] : []),
704
- ];
705
- if (opts.background) {
706
- const { startJob } = await import("../jobs.js");
707
- const job = startJob({
708
- strategy: "dca",
709
- exchange,
710
- params: { symbol: symbol.toUpperCase(), side: s, amount, interval, ...opts },
711
- cliArgs,
712
- });
713
- if (isJson())
714
- return printJson(jsonOk(job));
715
- console.log(chalk.green(`\n DCA started in background.`));
716
- console.log(` ID: ${chalk.white.bold(job.id)}`);
717
- console.log(` ${s.toUpperCase()} ${amount} ${symbol.toUpperCase()} every ${interval}s`);
718
- console.log(` Logs: ${chalk.gray(`perp jobs logs ${job.id}`)}`);
719
- console.log(` Stop: ${chalk.gray(`perp jobs stop ${job.id}`)}\n`);
720
- return;
721
- }
722
- // Foreground
723
- const { runDCA } = await import("../strategies/dca.js");
724
- const adapter = await getAdapter();
725
- const log = (msg) => {
726
- const ts = new Date().toLocaleTimeString();
727
- console.log(`${chalk.gray(ts)} ${msg}`);
728
- };
729
- await runDCA(adapter, {
730
- symbol: symbol.toUpperCase(),
731
- side: s,
732
- amountPerOrder: parseFloat(amount),
733
- intervalSec: parseInt(interval),
734
- totalOrders: parseInt(opts.orders),
735
- priceLimit: opts.priceLimit ? parseFloat(opts.priceLimit) : undefined,
736
- maxRuntime: parseInt(opts.maxRuntime),
737
- }, undefined, log);
738
- });
691
+ // grid/dca moved to 'perp bot grid' / 'perp bot dca'
739
692
  // ── Position Management Shortcuts ──
740
693
  trade
741
694
  .command("close-all")
@@ -1124,147 +1077,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
1124
1077
  }
1125
1078
  console.log();
1126
1079
  });
1127
- // ── Trailing Stop ──
1128
- trade
1129
- .command("trailing-stop <symbol>")
1130
- .description("Client-side trailing stop that monitors price and closes position when price drops by X% from peak")
1131
- .requiredOption("--trail <pct>", "Trail percentage (e.g., 3 = close when price drops 3% from high)")
1132
- .option("--interval <sec>", "Check interval in seconds", "5")
1133
- .option("--activation <price>", "Only start trailing after price reaches this level")
1134
- .option("--background", "Run in background (tmux)")
1135
- .action(async (symbol, opts) => {
1136
- const sym = symbol.toUpperCase();
1137
- const trailPct = parseFloat(opts.trail);
1138
- const intervalSec = parseInt(opts.interval);
1139
- const activationPrice = opts.activation ? parseFloat(opts.activation) : undefined;
1140
- if (isNaN(trailPct) || trailPct <= 0)
1141
- errorAndExit("Trail percentage must be > 0");
1142
- const exchange = (await getAdapter()).name;
1143
- // --background → run via tmux
1144
- if (opts.background) {
1145
- const { startJob } = await import("../jobs.js");
1146
- const cliArgs = [
1147
- `-e`, exchange, sym,
1148
- `--trail`, opts.trail,
1149
- `--interval`, opts.interval,
1150
- ...(opts.activation ? [`--activation`, opts.activation] : []),
1151
- ];
1152
- const job = startJob({
1153
- strategy: "trailing-stop",
1154
- exchange,
1155
- params: { symbol: sym, trail: trailPct, interval: intervalSec, activation: activationPrice },
1156
- cliArgs,
1157
- });
1158
- if (isJson())
1159
- return printJson(jsonOk(job));
1160
- console.log(chalk.green(`\n Trailing stop started in background.`));
1161
- console.log(` ID: ${chalk.white.bold(job.id)}`);
1162
- console.log(` Trail: ${trailPct}%${activationPrice ? ` | Activation: $${activationPrice}` : ""}`);
1163
- console.log(` Logs: ${chalk.gray(`perp jobs logs ${job.id}`)}`);
1164
- console.log(` Stop: ${chalk.gray(`perp jobs stop ${job.id}`)}\n`);
1165
- return;
1166
- }
1167
- // Foreground: run trailing stop loop
1168
- const adapter = await getAdapter();
1169
- // Auto-detect position side
1170
- const positions = await adapter.getPositions();
1171
- const pos = positions.find(p => symbolMatch(p.symbol, sym));
1172
- if (!pos)
1173
- errorAndExit(`No open position for ${sym}. Open a position first.`);
1174
- const positionSide = pos.side; // "long" or "short"
1175
- const closeSide = positionSide === "long" ? "sell" : "buy";
1176
- const posSize = pos.size;
1177
- console.log(chalk.cyan(`\n Trailing Stop for ${sym} (${positionSide} ${posSize})`));
1178
- console.log(chalk.cyan(` Trail: ${trailPct}% | Interval: ${intervalSec}s${activationPrice ? ` | Activation: $${activationPrice}` : ""}`));
1179
- console.log(chalk.gray(` Press Ctrl+C to cancel.\n`));
1180
- let peakPrice = 0;
1181
- let activated = !activationPrice; // if no activation price, start immediately
1182
- let running = true;
1183
- const cleanup = () => { running = false; };
1184
- process.on("SIGINT", cleanup);
1185
- process.on("SIGTERM", cleanup);
1186
- try {
1187
- while (running) {
1188
- const markets = await adapter.getMarkets();
1189
- const market = markets.find(m => symbolMatch(m.symbol, sym));
1190
- if (!market) {
1191
- console.log(chalk.yellow(` Market data for ${sym} not found, retrying...`));
1192
- await new Promise(r => setTimeout(r, intervalSec * 1000));
1193
- continue;
1194
- }
1195
- const currentPrice = parseFloat(market.markPrice);
1196
- // Check activation
1197
- if (!activated && activationPrice) {
1198
- if (positionSide === "long" && currentPrice >= activationPrice) {
1199
- activated = true;
1200
- console.log(chalk.green(` Activated at $${currentPrice.toFixed(2)} (>= $${activationPrice})`));
1201
- }
1202
- else if (positionSide === "short" && currentPrice <= activationPrice) {
1203
- activated = true;
1204
- console.log(chalk.green(` Activated at $${currentPrice.toFixed(2)} (<= $${activationPrice})`));
1205
- }
1206
- else {
1207
- const ts = new Date().toLocaleTimeString();
1208
- console.log(chalk.gray(` ${ts} | $${currentPrice.toFixed(2)} | Waiting for activation ($${activationPrice})...`));
1209
- await new Promise(r => setTimeout(r, intervalSec * 1000));
1210
- continue;
1211
- }
1212
- }
1213
- // Update peak
1214
- if (positionSide === "long") {
1215
- if (currentPrice > peakPrice)
1216
- peakPrice = currentPrice;
1217
- const dropPct = ((peakPrice - currentPrice) / peakPrice) * 100;
1218
- const ts = new Date().toLocaleTimeString();
1219
- console.log(chalk.gray(` ${ts} | Price: $${currentPrice.toFixed(2)} | Peak: $${peakPrice.toFixed(2)} | Drop: ${dropPct.toFixed(2)}%`));
1220
- if (dropPct >= trailPct) {
1221
- console.log(chalk.red(`\n TRAILING STOP TRIGGERED! Price dropped ${dropPct.toFixed(2)}% from peak $${peakPrice.toFixed(2)}`));
1222
- console.log(chalk.red(` Closing ${positionSide} ${posSize} ${sym}...\n`));
1223
- const result = await adapter.marketOrder(sym, closeSide, posSize);
1224
- logExecution({
1225
- type: "market_order", exchange: adapter.name, symbol: sym,
1226
- side: closeSide, size: posSize, status: "success", dryRun: false,
1227
- meta: { action: "trailing-stop", trailPct, peakPrice, triggerPrice: currentPrice },
1228
- });
1229
- if (isJson())
1230
- return printJson(jsonOk({ triggered: true, peakPrice, triggerPrice: currentPrice, dropPct, result }));
1231
- console.log(chalk.green(` Position closed. Peak: $${peakPrice.toFixed(2)}, Exit: $${currentPrice.toFixed(2)}\n`));
1232
- return;
1233
- }
1234
- }
1235
- else {
1236
- // Short position: track lowest price, trigger when price rises
1237
- if (peakPrice === 0 || currentPrice < peakPrice)
1238
- peakPrice = currentPrice;
1239
- const risePct = ((currentPrice - peakPrice) / peakPrice) * 100;
1240
- const ts = new Date().toLocaleTimeString();
1241
- console.log(chalk.gray(` ${ts} | Price: $${currentPrice.toFixed(2)} | Trough: $${peakPrice.toFixed(2)} | Rise: ${risePct.toFixed(2)}%`));
1242
- if (risePct >= trailPct) {
1243
- console.log(chalk.red(`\n TRAILING STOP TRIGGERED! Price rose ${risePct.toFixed(2)}% from trough $${peakPrice.toFixed(2)}`));
1244
- console.log(chalk.red(` Closing ${positionSide} ${posSize} ${sym}...\n`));
1245
- const result = await adapter.marketOrder(sym, closeSide, posSize);
1246
- logExecution({
1247
- type: "market_order", exchange: adapter.name, symbol: sym,
1248
- side: closeSide, size: posSize, status: "success", dryRun: false,
1249
- meta: { action: "trailing-stop", trailPct, troughPrice: peakPrice, triggerPrice: currentPrice },
1250
- });
1251
- if (isJson())
1252
- return printJson(jsonOk({ triggered: true, troughPrice: peakPrice, triggerPrice: currentPrice, risePct, result }));
1253
- console.log(chalk.green(` Position closed. Trough: $${peakPrice.toFixed(2)}, Exit: $${currentPrice.toFixed(2)}\n`));
1254
- return;
1255
- }
1256
- }
1257
- await new Promise(r => setTimeout(r, intervalSec * 1000));
1258
- }
1259
- }
1260
- finally {
1261
- process.removeListener("SIGINT", cleanup);
1262
- process.removeListener("SIGTERM", cleanup);
1263
- }
1264
- if (isJson())
1265
- return printJson(jsonOk({ triggered: false, reason: "cancelled" }));
1266
- console.log(chalk.yellow(`\n Trailing stop cancelled.\n`));
1267
- });
1080
+ // grid/dca/trailing-stop moved to 'perp bot'
1268
1081
  // ── PnL Tracker ──
1269
1082
  trade
1270
1083
  .command("pnl-track")
@@ -1345,4 +1158,173 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
1345
1158
  }
1346
1159
  console.log(chalk.yellow(`\n PnL tracker stopped.\n`));
1347
1160
  });
1161
+ // ── Multi-leg orders ──────────────────────────────────────────────────
1162
+ if (getAdapterForExchange) {
1163
+ const tradeCmd = program.commands.find(c => c.name() === "trade");
1164
+ if (tradeCmd) {
1165
+ registerMultiAction(tradeCmd, getAdapterForExchange, isJson);
1166
+ }
1167
+ // Keep deprecated top-level 'multi' alias (hidden from help)
1168
+ registerMultiAction(program, getAdapterForExchange, isJson, true);
1169
+ }
1170
+ }
1171
+ function parseMultiLeg(spec) {
1172
+ const parts = spec.split(":");
1173
+ if (parts.length !== 4) {
1174
+ throw new Error(`Invalid leg format "${spec}" — expected exchange:symbol:side:size`);
1175
+ }
1176
+ const [exchange, symbol, side, size] = parts;
1177
+ // Normalize exchange aliases
1178
+ const exMap = {
1179
+ hl: "hyperliquid", pac: "pacifica", lit: "lighter",
1180
+ hyperliquid: "hyperliquid", pacifica: "pacifica", lighter: "lighter",
1181
+ };
1182
+ const normalizedExchange = exMap[exchange.toLowerCase()];
1183
+ if (!normalizedExchange) {
1184
+ throw new Error(`Unknown exchange "${exchange}" in leg spec`);
1185
+ }
1186
+ if (side !== "buy" && side !== "sell") {
1187
+ throw new Error(`Invalid side "${side}" — must be "buy" or "sell"`);
1188
+ }
1189
+ if (isNaN(Number(size)) || Number(size) <= 0) {
1190
+ throw new Error(`Invalid size "${size}" — must be a positive number`);
1191
+ }
1192
+ return { exchange: normalizedExchange, symbol: symbol.toUpperCase(), side, size };
1193
+ }
1194
+ function registerMultiAction(parent, getAdapterForExchange, isJson, deprecated = false) {
1195
+ const cmd = parent
1196
+ .command("multi <legs...>")
1197
+ .description(deprecated ? "Use 'perp trade multi'" : "Execute multi-leg orders (exchange:symbol:side:size)");
1198
+ if (deprecated)
1199
+ cmd._hidden = true;
1200
+ cmd
1201
+ .option("--smart", "Use smart order (IOC limit + fallback) for each leg")
1202
+ .option("--rollback", "Auto-rollback filled legs if any leg fails", true)
1203
+ .option("--no-rollback", "Disable auto-rollback")
1204
+ .option("--timeout <ms>", "Per-leg timeout in milliseconds", "30000")
1205
+ .action(async (legSpecs, opts) => {
1206
+ await withJsonErrors(isJson(), async () => {
1207
+ // Parse legs
1208
+ const legs = legSpecs.map(parseMultiLeg);
1209
+ if (legs.length < 2) {
1210
+ const err = "Multi-leg requires at least 2 legs";
1211
+ if (isJson()) {
1212
+ printJson(jsonError("INVALID_ARGS", err));
1213
+ return;
1214
+ }
1215
+ throw new Error(err);
1216
+ }
1217
+ if (!isJson()) {
1218
+ console.log(chalk.cyan.bold("Multi-Leg Order\n"));
1219
+ for (const l of legs) {
1220
+ const color = l.side === "buy" ? chalk.green : chalk.red;
1221
+ console.log(` ${l.exchange.padEnd(14)} ${color(l.side.toUpperCase())} ${l.symbol} x ${l.size}`);
1222
+ }
1223
+ console.log();
1224
+ }
1225
+ // Get adapters (deduplicated)
1226
+ const adapters = new Map();
1227
+ for (const l of legs) {
1228
+ if (!adapters.has(l.exchange)) {
1229
+ adapters.set(l.exchange, await getAdapterForExchange(l.exchange));
1230
+ }
1231
+ }
1232
+ // Execute all legs simultaneously
1233
+ const timeoutMs = parseInt(opts.timeout);
1234
+ const results = await Promise.allSettled(legs.map(async (leg) => {
1235
+ const adapter = adapters.get(leg.exchange);
1236
+ const orderPromise = opts.smart
1237
+ ? smartOrder(adapter, leg.symbol, leg.side, leg.size).then(r => r.result)
1238
+ : adapter.marketOrder(leg.symbol, leg.side, leg.size);
1239
+ // Apply timeout
1240
+ const result = await Promise.race([
1241
+ orderPromise,
1242
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Leg timeout")), timeoutMs)),
1243
+ ]);
1244
+ // Log execution
1245
+ logExecution({
1246
+ type: "multi_leg",
1247
+ exchange: leg.exchange,
1248
+ symbol: leg.symbol,
1249
+ side: leg.side,
1250
+ size: leg.size,
1251
+ status: "success",
1252
+ dryRun: false,
1253
+ meta: { result, smart: opts.smart },
1254
+ });
1255
+ return { leg, status: "filled", result };
1256
+ }));
1257
+ // Process results
1258
+ const legResults = results.map((r, i) => {
1259
+ if (r.status === "fulfilled")
1260
+ return r.value;
1261
+ return {
1262
+ leg: legs[i],
1263
+ status: "failed",
1264
+ error: r.reason instanceof Error ? r.reason.message : String(r.reason),
1265
+ };
1266
+ });
1267
+ const filled = legResults.filter(r => r.status === "filled");
1268
+ const failed = legResults.filter(r => r.status === "failed");
1269
+ // Rollback if partial fill and rollback enabled
1270
+ if (failed.length > 0 && filled.length > 0 && opts.rollback) {
1271
+ if (!isJson()) {
1272
+ console.log(chalk.yellow(`\n ${failed.length} leg(s) failed — rolling back ${filled.length} filled leg(s)...\n`));
1273
+ }
1274
+ for (const fr of filled) {
1275
+ const adapter = adapters.get(fr.leg.exchange);
1276
+ const reverseSide = fr.leg.side === "buy" ? "sell" : "buy";
1277
+ try {
1278
+ await adapter.marketOrder(fr.leg.symbol, reverseSide, fr.leg.size);
1279
+ fr.status = "rolled_back";
1280
+ logExecution({
1281
+ type: "multi_leg_rollback",
1282
+ exchange: fr.leg.exchange,
1283
+ symbol: fr.leg.symbol,
1284
+ side: reverseSide,
1285
+ size: fr.leg.size,
1286
+ status: "success",
1287
+ dryRun: false,
1288
+ });
1289
+ if (!isJson()) {
1290
+ console.log(chalk.gray(` Rolled back: ${fr.leg.exchange} ${reverseSide} ${fr.leg.symbol} x ${fr.leg.size}`));
1291
+ }
1292
+ }
1293
+ catch (err) {
1294
+ if (!isJson()) {
1295
+ console.log(chalk.red(` Rollback failed: ${fr.leg.exchange} ${fr.leg.symbol} — ${err instanceof Error ? err.message : err}`));
1296
+ }
1297
+ }
1298
+ }
1299
+ }
1300
+ if (isJson()) {
1301
+ printJson(jsonOk({
1302
+ legs: legResults.map(r => ({
1303
+ exchange: r.leg.exchange,
1304
+ symbol: r.leg.symbol,
1305
+ side: r.leg.side,
1306
+ size: r.leg.size,
1307
+ status: r.status,
1308
+ error: r.error,
1309
+ })),
1310
+ summary: {
1311
+ total: legs.length,
1312
+ filled: legResults.filter(r => r.status === "filled").length,
1313
+ failed: failed.length,
1314
+ rolledBack: legResults.filter(r => r.status === "rolled_back").length,
1315
+ },
1316
+ }));
1317
+ }
1318
+ else {
1319
+ console.log(chalk.cyan.bold("\nResults:\n"));
1320
+ for (const r of legResults) {
1321
+ const icon = r.status === "filled" ? chalk.green("OK") :
1322
+ r.status === "rolled_back" ? chalk.yellow("ROLLBACK") :
1323
+ chalk.red("FAIL");
1324
+ console.log(` ${icon} ${r.leg.exchange.padEnd(14)} ${r.leg.side} ${r.leg.symbol} x ${r.leg.size}${r.error ? ` — ${r.error}` : ""}`);
1325
+ }
1326
+ console.log(`\n Total: ${legs.length} Filled: ${filled.length} Failed: ${failed.length} Rolled back: ${legResults.filter(r => r.status === "rolled_back").length}`);
1327
+ }
1328
+ });
1329
+ });
1348
1330
  }
@@ -11,4 +11,8 @@ export declare function getWalletSetupStatus(): {
11
11
  };
12
12
  /** Exported so config.ts can resolve the active wallet key */
13
13
  export declare function getActiveWalletKey(exchange: string): string | null;
14
+ /** Get wallet key by name. Returns null if wallet doesn't exist. */
15
+ export declare function getWalletKeyByName(name: string): string | null;
16
+ /** Get all wallet names. */
17
+ export declare function getWalletNames(): string[];
14
18
  export declare function registerWalletCommands(program: Command, isJson: () => boolean): void;
@@ -42,6 +42,16 @@ export function getActiveWalletKey(exchange) {
42
42
  return null;
43
43
  return store.wallets[name]?.privateKey ?? null;
44
44
  }
45
+ /** Get wallet key by name. Returns null if wallet doesn't exist. */
46
+ export function getWalletKeyByName(name) {
47
+ const store = loadStore();
48
+ return store.wallets[name]?.privateKey ?? null;
49
+ }
50
+ /** Get all wallet names. */
51
+ export function getWalletNames() {
52
+ const store = loadStore();
53
+ return Object.keys(store.wallets);
54
+ }
45
55
  async function getSolanaBalances(address, isTestnet) {
46
56
  const { Connection, PublicKey } = await import("@solana/web3.js");
47
57
  const rpcUrl = isTestnet
package/dist/config.d.ts CHANGED
@@ -4,7 +4,7 @@ export type Exchange = "pacifica" | "hyperliquid" | "lighter";
4
4
  * Try to load a private key without throwing if none is found.
5
5
  * Returns null if no key is configured.
6
6
  */
7
- export declare function tryLoadPrivateKey(exchange: Exchange, pkOverride?: string): Promise<string | null>;
8
- export declare function loadPrivateKey(exchange: Exchange, pkOverride?: string): Promise<string>;
7
+ export declare function tryLoadPrivateKey(exchange: Exchange, pkOverride?: string, walletName?: string): Promise<string | null>;
8
+ export declare function loadPrivateKey(exchange: Exchange, pkOverride?: string, walletName?: string): Promise<string>;
9
9
  export declare function parseSolanaKeypair(input: string): Keypair;
10
10
  export declare function isEvmPrivateKey(input: string): boolean;
package/dist/config.js CHANGED
@@ -7,15 +7,23 @@ import { existsSync, readFileSync } from "fs";
7
7
  * Try to load a private key without throwing if none is found.
8
8
  * Returns null if no key is configured.
9
9
  */
10
- export async function tryLoadPrivateKey(exchange, pkOverride) {
10
+ export async function tryLoadPrivateKey(exchange, pkOverride, walletName) {
11
11
  try {
12
- return await loadPrivateKey(exchange, pkOverride);
12
+ return await loadPrivateKey(exchange, pkOverride, walletName);
13
13
  }
14
14
  catch {
15
15
  return null;
16
16
  }
17
17
  }
18
- export async function loadPrivateKey(exchange, pkOverride) {
18
+ export async function loadPrivateKey(exchange, pkOverride, walletName) {
19
+ // 0. Wallet name override (--wallet flag)
20
+ if (walletName) {
21
+ const { getWalletKeyByName } = await import("./commands/wallet.js");
22
+ const key = getWalletKeyByName(walletName);
23
+ if (!key)
24
+ throw new Error(`Wallet "${walletName}" not found. Run: perp wallet list`);
25
+ return key;
26
+ }
19
27
  // 1. CLI flag
20
28
  if (pkOverride)
21
29
  return pkOverride;
@@ -15,6 +15,7 @@ export declare class HyperliquidSpotAdapter implements SpotAdapter {
15
15
  private _spotAssetReverse;
16
16
  private _spotDecimals;
17
17
  private _initialized;
18
+ private _cachedMetaCtx;
18
19
  constructor(hlAdapter: HyperliquidAdapter);
19
20
  /** Load spot metadata from the Hyperliquid spot API */
20
21
  init(): Promise<void>;