perp-cli 0.3.19 → 0.4.2

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.
@@ -1,8 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { printJson, jsonOk, withJsonErrors } from "../utils.js";
3
3
  import { loadSettings, saveSettings } from "../settings.js";
4
- export function registerSettingsCommands(program, isJson) {
5
- const settings = program.command("settings").description("CLI settings (referrals, defaults)");
4
+ export function registerSettingsCommands(program, isJson, getAdapterForExchange) {
5
+ const settings = program.command("settings").description("CLI settings (referrals, defaults, fees)");
6
6
  // ── settings show ──
7
7
  settings
8
8
  .command("show")
@@ -19,6 +19,12 @@ export function registerSettingsCommands(program, isJson) {
19
19
  console.log(` Pacifica: ${s.referralCodes.pacifica || chalk.gray("(none)")}`);
20
20
  console.log(` Hyperliquid: ${s.referralCodes.hyperliquid || chalk.gray("(none)")}`);
21
21
  console.log(` Lighter: ${s.referralCodes.lighter || chalk.gray("(none)")}`);
22
+ console.log(chalk.white.bold("\n Fee Tiers:"));
23
+ for (const [ex, fee] of Object.entries(s.fees)) {
24
+ const tPct = (fee.taker * 100).toFixed(4);
25
+ const mPct = (fee.maker * 100).toFixed(4);
26
+ console.log(` ${ex.padEnd(14)} taker: ${tPct}% maker: ${mPct}%`);
27
+ }
22
28
  console.log(chalk.gray(`\n Config: ~/.perp/settings.json\n`));
23
29
  });
24
30
  });
@@ -58,6 +64,131 @@ export function registerSettingsCommands(program, isJson) {
58
64
  }
59
65
  });
60
66
  });
67
+ // ── settings fees ──
68
+ const fees = settings
69
+ .command("fees")
70
+ .description("Show or sync exchange fee tiers");
71
+ fees
72
+ .command("show")
73
+ .description("Show current fee tiers")
74
+ .action(async () => {
75
+ await withJsonErrors(isJson(), async () => {
76
+ const s = loadSettings();
77
+ if (isJson())
78
+ return printJson(jsonOk(s.fees));
79
+ console.log(chalk.cyan.bold("\n Exchange Fee Tiers\n"));
80
+ for (const [ex, fee] of Object.entries(s.fees)) {
81
+ const tPct = (fee.taker * 100).toFixed(4);
82
+ const mPct = (fee.maker * 100).toFixed(4);
83
+ console.log(` ${ex.padEnd(14)} taker: ${chalk.yellow(tPct + "%")} maker: ${chalk.green(mPct + "%")}`);
84
+ }
85
+ console.log(chalk.gray(`\n Sync from exchanges: perp settings fees sync`));
86
+ console.log(chalk.gray(` Manual set: perp settings fees set <exchange> <taker> <maker>\n`));
87
+ });
88
+ });
89
+ fees
90
+ .command("sync")
91
+ .description("Fetch fee tiers from exchange APIs and save")
92
+ .option("--exchange <name>", "Sync only a specific exchange")
93
+ .action(async (opts) => {
94
+ await withJsonErrors(isJson(), async () => {
95
+ if (!getAdapterForExchange) {
96
+ console.error(chalk.red(" Fee sync requires exchange adapters. Set up keys first with `perp init`."));
97
+ return;
98
+ }
99
+ const s = loadSettings();
100
+ const exchanges = opts.exchange ? [opts.exchange.toLowerCase()] : ["hyperliquid", "pacifica", "lighter"];
101
+ const results = {};
102
+ for (const ex of exchanges) {
103
+ try {
104
+ if (ex === "lighter") {
105
+ // Lighter has 0% fees
106
+ s.fees.lighter = { taker: 0, maker: 0 };
107
+ results.lighter = { taker: 0, maker: 0, source: "known (0% fee exchange)" };
108
+ if (!isJson())
109
+ console.log(chalk.green(` ✓ lighter: taker 0% / maker 0% (no fees)`));
110
+ continue;
111
+ }
112
+ if (ex === "hyperliquid") {
113
+ const adapter = await getAdapterForExchange("hyperliquid");
114
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115
+ const hlAdapter = adapter;
116
+ if (typeof hlAdapter.getUserFees === "function") {
117
+ const feeData = await hlAdapter.getUserFees();
118
+ // HL returns: { activeReferralDiscount, dailyUserVlm, feeSchedule: { ... }, userCrossRate, userAddRate }
119
+ // userCrossRate is the effective taker fee, userAddRate is the maker fee
120
+ const taker = parseFloat(feeData?.userCrossRate ?? feeData?.takerRate ?? "0.00035");
121
+ const maker = parseFloat(feeData?.userAddRate ?? feeData?.makerRate ?? "0.00002");
122
+ s.fees.hyperliquid = { taker: Math.abs(taker), maker };
123
+ results.hyperliquid = { taker: Math.abs(taker), maker, source: "API (getUserFees)" };
124
+ if (!isJson()) {
125
+ console.log(chalk.green(` ✓ hyperliquid: taker ${(Math.abs(taker) * 100).toFixed(4)}% / maker ${(maker * 100).toFixed(4)}%`));
126
+ }
127
+ }
128
+ else {
129
+ if (!isJson())
130
+ console.log(chalk.yellow(` ⚠ hyperliquid: getUserFees not available, keeping current`));
131
+ }
132
+ continue;
133
+ }
134
+ if (ex === "pacifica") {
135
+ const adapter = await getAdapterForExchange("pacifica");
136
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
+ const pacAdapter = adapter;
138
+ if (typeof pacAdapter.sdk?.getAccount === "function" && pacAdapter.publicKey) {
139
+ const info = await pacAdapter.sdk.getAccount(pacAdapter.publicKey);
140
+ const taker = parseFloat(info?.taker_fee ?? "0.00035");
141
+ const maker = parseFloat(info?.maker_fee ?? "0.0001");
142
+ s.fees.pacifica = { taker, maker };
143
+ results.pacifica = { taker, maker, source: "API (getAccount)" };
144
+ if (!isJson()) {
145
+ console.log(chalk.green(` ✓ pacifica: taker ${(taker * 100).toFixed(4)}% / maker ${(maker * 100).toFixed(4)}%`));
146
+ }
147
+ }
148
+ else {
149
+ if (!isJson())
150
+ console.log(chalk.yellow(` ⚠ pacifica: account API not available, keeping current`));
151
+ }
152
+ continue;
153
+ }
154
+ if (!isJson())
155
+ console.log(chalk.yellow(` ⚠ ${ex}: unknown exchange, skipped`));
156
+ }
157
+ catch (err) {
158
+ if (!isJson()) {
159
+ console.log(chalk.red(` ✗ ${ex}: ${err instanceof Error ? err.message : String(err)}`));
160
+ }
161
+ }
162
+ }
163
+ saveSettings(s);
164
+ if (isJson())
165
+ return printJson(jsonOk(results));
166
+ console.log(chalk.gray("\n Fees saved to ~/.perp/settings.json\n"));
167
+ });
168
+ });
169
+ fees
170
+ .command("set")
171
+ .description("Manually set fee tier for an exchange")
172
+ .argument("<exchange>", "Exchange name")
173
+ .argument("<taker>", "Taker fee as percentage (e.g. 0.035 for 0.035%)")
174
+ .argument("<maker>", "Maker fee as percentage (e.g. 0.01 for 0.01%)")
175
+ .action(async (exchange, takerStr, makerStr) => {
176
+ await withJsonErrors(isJson(), async () => {
177
+ const s = loadSettings();
178
+ const ex = exchange.toLowerCase();
179
+ const taker = parseFloat((parseFloat(takerStr) / 100).toPrecision(10));
180
+ const maker = parseFloat((parseFloat(makerStr) / 100).toPrecision(10));
181
+ if (isNaN(taker) || isNaN(maker)) {
182
+ console.error(chalk.red(" Invalid fee values. Use percentages, e.g.: perp settings fees set hyperliquid 0.035 0.002"));
183
+ return;
184
+ }
185
+ s.fees[ex] = { taker, maker };
186
+ saveSettings(s);
187
+ if (isJson())
188
+ return printJson(jsonOk({ exchange: ex, taker, maker }));
189
+ console.log(chalk.green(`\n ${ex}: taker ${takerStr}% / maker ${makerStr}%\n`));
190
+ });
191
+ });
61
192
  // ── settings set ──
62
193
  settings
63
194
  .command("set")
@@ -5,6 +5,7 @@ import { printJson, errorAndExit, withJsonErrors, jsonOk, jsonError, symbolMatch
5
5
  import { logExecution } from "../execution-log.js";
6
6
  import { validateTrade } from "../trade-validator.js";
7
7
  import { generateClientId, logClientId, isOrderDuplicate } from "../client-id-tracker.js";
8
+ import { smartOrder } from "../smart-order.js";
8
9
  import chalk from "chalk";
9
10
  function pac(adapter) {
10
11
  if (!(adapter instanceof PacificaAdapter))
@@ -46,6 +47,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
46
47
  .description("Place a market order (side: buy/sell)")
47
48
  .option("-s, --slippage <pct>", "Slippage percent", "1")
48
49
  .option("--reduce-only", "Reduce only order")
50
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
49
51
  .option("--client-id <id>", "Client order ID for idempotent tracking")
50
52
  .option("--auto-id", "Auto-generate a client order ID")
51
53
  .action(async (symbol, side, size, opts) => {
@@ -60,7 +62,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
60
62
  return;
61
63
  }
62
64
  const adapter = await getAdapter();
63
- if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: s, size }))
65
+ if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: s, size, smart: !!opts.smart }))
64
66
  return;
65
67
  if (clientId) {
66
68
  logClientId({
@@ -71,11 +73,17 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
71
73
  }
72
74
  let result;
73
75
  try {
74
- result = await adapter.marketOrder(symbol.toUpperCase(), s, size);
76
+ if (opts.smart) {
77
+ const sr = await smartOrder(adapter, symbol.toUpperCase(), s, size);
78
+ result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
79
+ }
80
+ else {
81
+ result = await adapter.marketOrder(symbol.toUpperCase(), s, size);
82
+ }
75
83
  logExecution({
76
84
  type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(),
77
85
  side: s, size, status: "success", dryRun: false,
78
- meta: clientId ? { clientOrderId: clientId } : undefined,
86
+ meta: { ...(clientId ? { clientOrderId: clientId } : {}), ...(opts.smart ? { smart: true } : {}) },
79
87
  });
80
88
  }
81
89
  catch (err) {
@@ -83,7 +91,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
83
91
  type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(),
84
92
  side: s, size, status: "failed", dryRun: false,
85
93
  error: err instanceof Error ? err.message : String(err),
86
- meta: clientId ? { clientOrderId: clientId } : undefined,
94
+ meta: { ...(clientId ? { clientOrderId: clientId } : {}), ...(opts.smart ? { smart: true } : {}) },
87
95
  });
88
96
  throw err;
89
97
  }
@@ -96,7 +104,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
96
104
  }
97
105
  if (isJson())
98
106
  return printJson(jsonOk(clientId ? { ...result, clientOrderId: clientId } : result));
99
- console.log(chalk.green(`\n Market ${s.toUpperCase()} ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${clientId ? ` (id: ${clientId})` : ""}\n`));
107
+ console.log(chalk.green(`\n Market ${s.toUpperCase()} ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${opts.smart ? " (smart)" : ""}${clientId ? ` (id: ${clientId})` : ""}\n`));
100
108
  printJson(jsonOk(result));
101
109
  });
102
110
  // Shortcuts: trade buy / trade sell
@@ -105,6 +113,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
105
113
  .description("Market buy (shortcut for: trade market <symbol> buy <size>)")
106
114
  .option("-s, --slippage <pct>", "Slippage percent", "1")
107
115
  .option("--reduce-only", "Reduce only order")
116
+ .option("--smart", "Smart execution: IOC limit at best ask + 1 tick")
108
117
  .option("--client-id <id>", "Client order ID")
109
118
  .option("--auto-id", "Auto-generate client order ID")
110
119
  .action(async (symbol, size, opts) => {
@@ -116,12 +125,18 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
116
125
  return;
117
126
  }
118
127
  const adapter = await getAdapter();
119
- if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size }))
128
+ if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, smart: !!opts.smart }))
120
129
  return;
121
130
  let result;
122
131
  try {
123
- result = await adapter.marketOrder(symbol.toUpperCase(), "buy", size);
124
- logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, status: "success", dryRun: false });
132
+ if (opts.smart) {
133
+ const sr = await smartOrder(adapter, symbol.toUpperCase(), "buy", size);
134
+ result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
135
+ }
136
+ else {
137
+ result = await adapter.marketOrder(symbol.toUpperCase(), "buy", size);
138
+ }
139
+ logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, status: "success", dryRun: false, meta: opts.smart ? { smart: true } : undefined });
125
140
  }
126
141
  catch (err) {
127
142
  logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, status: "failed", dryRun: false, error: err instanceof Error ? err.message : String(err) });
@@ -129,7 +144,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
129
144
  }
130
145
  if (isJson())
131
146
  return printJson(jsonOk(clientId ? { ...result, clientOrderId: clientId } : result));
132
- console.log(chalk.green(`\n Market BUY ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.\n`));
147
+ console.log(chalk.green(`\n Market BUY ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
133
148
  printJson(jsonOk(result));
134
149
  });
135
150
  trade
@@ -137,6 +152,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
137
152
  .description("Market sell (shortcut for: trade market <symbol> sell <size>)")
138
153
  .option("-s, --slippage <pct>", "Slippage percent", "1")
139
154
  .option("--reduce-only", "Reduce only order")
155
+ .option("--smart", "Smart execution: IOC limit at best bid - 1 tick")
140
156
  .option("--client-id <id>", "Client order ID")
141
157
  .option("--auto-id", "Auto-generate client order ID")
142
158
  .action(async (symbol, size, opts) => {
@@ -148,12 +164,18 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
148
164
  return;
149
165
  }
150
166
  const adapter = await getAdapter();
151
- if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size }))
167
+ if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, smart: !!opts.smart }))
152
168
  return;
153
169
  let result;
154
170
  try {
155
- result = await adapter.marketOrder(symbol.toUpperCase(), "sell", size);
156
- logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, status: "success", dryRun: false });
171
+ if (opts.smart) {
172
+ const sr = await smartOrder(adapter, symbol.toUpperCase(), "sell", size);
173
+ result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
174
+ }
175
+ else {
176
+ result = await adapter.marketOrder(symbol.toUpperCase(), "sell", size);
177
+ }
178
+ logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, status: "success", dryRun: false, meta: opts.smart ? { smart: true } : undefined });
157
179
  }
158
180
  catch (err) {
159
181
  logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, status: "failed", dryRun: false, error: err instanceof Error ? err.message : String(err) });
@@ -161,7 +183,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
161
183
  }
162
184
  if (isJson())
163
185
  return printJson(jsonOk(clientId ? { ...result, clientOrderId: clientId } : result));
164
- console.log(chalk.green(`\n Market SELL ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.\n`));
186
+ console.log(chalk.green(`\n Market SELL ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
165
187
  printJson(jsonOk(result));
166
188
  });
167
189
  trade
@@ -697,10 +719,11 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
697
719
  trade
698
720
  .command("close-all")
699
721
  .description("Close all open positions (market orders on opposite side)")
700
- .action(async () => {
722
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
723
+ .action(async (opts) => {
701
724
  await withJsonErrors(isJson(), async () => {
702
725
  const adapter = await getAdapter();
703
- if (dryRunGuard("close_all", { exchange: adapter.name }))
726
+ if (dryRunGuard("close_all", { exchange: adapter.name, smart: !!opts.smart }))
704
727
  return;
705
728
  const positions = await adapter.getPositions();
706
729
  if (positions.length === 0) {
@@ -710,18 +733,25 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
710
733
  return;
711
734
  }
712
735
  if (!isJson())
713
- console.log(chalk.cyan(`\n Closing ${positions.length} position(s) on ${adapter.name}...\n`));
736
+ console.log(chalk.cyan(`\n Closing ${positions.length} position(s) on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
714
737
  const results = [];
715
738
  for (const pos of positions) {
716
739
  const closeSide = pos.side === "long" ? "sell" : "buy";
717
740
  if (!isJson())
718
741
  console.log(chalk.gray(` ${closeSide.toUpperCase()} ${pos.size} ${pos.symbol} (closing ${pos.side})...`));
719
- const result = await adapter.marketOrder(pos.symbol, closeSide, pos.size);
742
+ let result;
743
+ if (opts.smart) {
744
+ const sr = await smartOrder(adapter, pos.symbol, closeSide, pos.size, { reduceOnly: true });
745
+ result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice } };
746
+ }
747
+ else {
748
+ result = await adapter.marketOrder(pos.symbol, closeSide, pos.size);
749
+ }
720
750
  results.push(result);
721
751
  logExecution({
722
752
  type: "market_order", exchange: adapter.name, symbol: pos.symbol,
723
753
  side: closeSide, size: pos.size, status: "success", dryRun: false,
724
- meta: { action: "close-all", originalSide: pos.side },
754
+ meta: { action: "close-all", originalSide: pos.side, smart: !!opts.smart },
725
755
  });
726
756
  }
727
757
  if (isJson())
@@ -732,7 +762,8 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
732
762
  trade
733
763
  .command("close <symbol>")
734
764
  .description("Close a specific symbol's position")
735
- .action(async (symbol) => {
765
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
766
+ .action(async (symbol, opts) => {
736
767
  await withJsonErrors(isJson(), async () => {
737
768
  const sym = symbol.toUpperCase();
738
769
  const adapter = await getAdapter();
@@ -744,31 +775,39 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
744
775
  errorAndExit(`No open position for ${sym}`);
745
776
  }
746
777
  const closeSide = pos.side === "long" ? "sell" : "buy";
747
- if (dryRunGuard("close", { exchange: adapter.name, symbol: sym, side: closeSide, size: pos.size, originalSide: pos.side }))
778
+ if (dryRunGuard("close", { exchange: adapter.name, symbol: sym, side: closeSide, size: pos.size, originalSide: pos.side, smart: !!opts.smart }))
748
779
  return;
749
780
  if (!isJson())
750
- console.log(chalk.cyan(`\n Closing ${pos.side} ${pos.size} ${sym} on ${adapter.name}...\n`));
751
- const result = await adapter.marketOrder(sym, closeSide, pos.size);
781
+ console.log(chalk.cyan(`\n Closing ${pos.side} ${pos.size} ${sym} on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
782
+ let result;
783
+ if (opts.smart) {
784
+ const sr = await smartOrder(adapter, sym, closeSide, pos.size, { reduceOnly: true });
785
+ result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice } };
786
+ }
787
+ else {
788
+ result = await adapter.marketOrder(sym, closeSide, pos.size);
789
+ }
752
790
  logExecution({
753
791
  type: "market_order", exchange: adapter.name, symbol: sym,
754
792
  side: closeSide, size: pos.size, status: "success", dryRun: false,
755
- meta: { action: "close", originalSide: pos.side },
793
+ meta: { action: "close", originalSide: pos.side, smart: !!opts.smart },
756
794
  });
757
795
  if (isJson())
758
796
  return printJson(jsonOk(result));
759
- console.log(chalk.green(`\n Closed ${pos.side} ${pos.size} ${sym} on ${adapter.name}.\n`));
797
+ console.log(chalk.green(`\n Closed ${pos.side} ${pos.size} ${sym} on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
760
798
  });
761
799
  });
762
800
  trade
763
801
  .command("flatten")
764
802
  .description("Cancel all orders AND close all positions (full cleanup)")
765
- .action(async () => {
803
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
804
+ .action(async (opts) => {
766
805
  await withJsonErrors(isJson(), async () => {
767
806
  const adapter = await getAdapter();
768
- if (dryRunGuard("flatten", { exchange: adapter.name }))
807
+ if (dryRunGuard("flatten", { exchange: adapter.name, smart: !!opts.smart }))
769
808
  return;
770
809
  if (!isJson())
771
- console.log(chalk.cyan(`\n Flattening account on ${adapter.name}...\n`));
810
+ console.log(chalk.cyan(`\n Flattening account on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
772
811
  // Step 1: Cancel all orders
773
812
  if (!isJson())
774
813
  console.log(chalk.gray(" Cancelling all open orders..."));
@@ -780,12 +819,19 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
780
819
  const closeSide = pos.side === "long" ? "sell" : "buy";
781
820
  if (!isJson())
782
821
  console.log(chalk.gray(` ${closeSide.toUpperCase()} ${pos.size} ${pos.symbol} (closing ${pos.side})...`));
783
- const result = await adapter.marketOrder(pos.symbol, closeSide, pos.size);
822
+ let result;
823
+ if (opts.smart) {
824
+ const sr = await smartOrder(adapter, pos.symbol, closeSide, pos.size, { reduceOnly: true });
825
+ result = sr.result;
826
+ }
827
+ else {
828
+ result = await adapter.marketOrder(pos.symbol, closeSide, pos.size);
829
+ }
784
830
  closeResults.push(result);
785
831
  logExecution({
786
832
  type: "market_order", exchange: adapter.name, symbol: pos.symbol,
787
833
  side: closeSide, size: pos.size, status: "success", dryRun: false,
788
- meta: { action: "flatten", originalSide: pos.side },
834
+ meta: { action: "flatten", originalSide: pos.side, smart: !!opts.smart },
789
835
  });
790
836
  }
791
837
  if (isJson())
@@ -796,7 +842,8 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
796
842
  trade
797
843
  .command("reduce <symbol> <percent>")
798
844
  .description("Reduce a position by a percentage (e.g., perp trade reduce BTC 50)")
799
- .action(async (symbol, percent) => {
845
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
846
+ .action(async (symbol, percent, opts) => {
800
847
  await withJsonErrors(isJson(), async () => {
801
848
  const sym = symbol.toUpperCase();
802
849
  const pct = parseFloat(percent);
@@ -813,19 +860,26 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
813
860
  const fullSize = parseFloat(pos.size);
814
861
  const reduceSize = (fullSize * pct / 100).toString();
815
862
  const closeSide = pos.side === "long" ? "sell" : "buy";
816
- if (dryRunGuard("reduce", { exchange: adapter.name, symbol: sym, side: closeSide, size: reduceSize, percent: pct, originalSize: pos.size }))
863
+ if (dryRunGuard("reduce", { exchange: adapter.name, symbol: sym, side: closeSide, size: reduceSize, percent: pct, originalSize: pos.size, smart: !!opts.smart }))
817
864
  return;
818
865
  if (!isJson())
819
- console.log(chalk.cyan(`\n Reducing ${sym} ${pos.side} by ${pct}% (${reduceSize} of ${pos.size}) on ${adapter.name}...\n`));
820
- const result = await adapter.marketOrder(sym, closeSide, reduceSize);
866
+ console.log(chalk.cyan(`\n Reducing ${sym} ${pos.side} by ${pct}% (${reduceSize} of ${pos.size}) on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
867
+ let result;
868
+ if (opts.smart) {
869
+ const sr = await smartOrder(adapter, sym, closeSide, reduceSize, { reduceOnly: true });
870
+ result = sr.result;
871
+ }
872
+ else {
873
+ result = await adapter.marketOrder(sym, closeSide, reduceSize);
874
+ }
821
875
  logExecution({
822
876
  type: "market_order", exchange: adapter.name, symbol: sym,
823
877
  side: closeSide, size: reduceSize, status: "success", dryRun: false,
824
- meta: { action: "reduce", percent: pct, originalSize: pos.size, originalSide: pos.side },
878
+ meta: { action: "reduce", percent: pct, originalSize: pos.size, originalSide: pos.side, smart: !!opts.smart },
825
879
  });
826
880
  if (isJson())
827
881
  return printJson(jsonOk({ reduced: true, percent: pct, sizeReduced: reduceSize, originalSize: pos.size, result }));
828
- console.log(chalk.green(`\n Reduced ${sym} by ${pct}% (${closeSide} ${reduceSize}) on ${adapter.name}.\n`));
882
+ console.log(chalk.green(`\n Reduced ${sym} by ${pct}% (${closeSide} ${reduceSize}) on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
829
883
  });
830
884
  });
831
885
  // ── Order Status Query ──
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared constants for perp-cli.
3
+ */
4
+ import { type ExchangeFees } from "./settings.js";
5
+ /** Default taker fee per exchange (as fraction, e.g. 0.00035 = 0.035%) */
6
+ export declare const TAKER_FEES: Record<string, number>;
7
+ /** Get taker fee for an exchange. Uses settings if available, falls back to defaults. */
8
+ export declare function getTakerFee(exchange: string): number;
9
+ /** Get maker fee for an exchange. Uses settings if available. */
10
+ export declare function getMakerFee(exchange: string): number;
11
+ /** Get full fee info for an exchange. */
12
+ export declare function getExchangeFees(exchange: string): ExchangeFees;
13
+ /** Default taker fee for single-exchange use (when exchange is unknown). */
14
+ export declare const DEFAULT_TAKER_FEE = 0.00035;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared constants for perp-cli.
3
+ */
4
+ import { loadSettings } from "./settings.js";
5
+ /** Default taker fee per exchange (as fraction, e.g. 0.00035 = 0.035%) */
6
+ export const TAKER_FEES = {
7
+ hyperliquid: 0.00035,
8
+ pacifica: 0.00035,
9
+ lighter: 0,
10
+ };
11
+ /** Get taker fee for an exchange. Uses settings if available, falls back to defaults. */
12
+ export function getTakerFee(exchange) {
13
+ const key = exchange.toLowerCase();
14
+ try {
15
+ const settings = loadSettings();
16
+ if (settings.fees[key]?.taker !== undefined) {
17
+ return settings.fees[key].taker;
18
+ }
19
+ }
20
+ catch { /* fallback to hardcoded */ }
21
+ return TAKER_FEES[key] ?? 0.00035;
22
+ }
23
+ /** Get maker fee for an exchange. Uses settings if available. */
24
+ export function getMakerFee(exchange) {
25
+ const key = exchange.toLowerCase();
26
+ try {
27
+ const settings = loadSettings();
28
+ if (settings.fees[key]?.maker !== undefined) {
29
+ return settings.fees[key].maker;
30
+ }
31
+ }
32
+ catch { /* fallback */ }
33
+ return 0;
34
+ }
35
+ /** Get full fee info for an exchange. */
36
+ export function getExchangeFees(exchange) {
37
+ return { taker: getTakerFee(exchange), maker: getMakerFee(exchange) };
38
+ }
39
+ /** Default taker fee for single-exchange use (when exchange is unknown). */
40
+ export const DEFAULT_TAKER_FEE = 0.00035;
@@ -406,29 +406,20 @@ export class HyperliquidAdapter {
406
406
  return result;
407
407
  }
408
408
  async limitOrder(symbol, side, price, size, opts) {
409
- const tif = (opts?.tif ?? "Gtc");
409
+ // Normalize TIF: accept "IOC"/"GTC"/"ALO" (uppercase) or native "Ioc"/"Gtc"/"Alo"
410
+ const rawTif = opts?.tif ?? "Gtc";
411
+ const tifMap = { IOC: "Ioc", GTC: "Gtc", ALO: "Alo" };
412
+ const tif = (tifMap[rawTif.toUpperCase()] ?? rawTif);
410
413
  const reduceOnly = opts?.reduceOnly ?? false;
411
- let result;
412
- if (this._dex) {
413
- result = await this._rawPlaceOrder({
414
- assetIndex: this.getAssetIndex(symbol.toUpperCase()),
415
- isBuy: side === "buy",
416
- price,
417
- size,
418
- orderType: { limit: { tif } },
419
- reduceOnly,
420
- });
421
- }
422
- else {
423
- result = await this.sdk.exchange.placeOrder({
424
- coin: symbol.toUpperCase(),
425
- is_buy: side === "buy",
426
- sz: parseFloat(size),
427
- limit_px: parseFloat(price),
428
- order_type: { limit: { tif } },
429
- reduce_only: reduceOnly,
430
- });
431
- }
414
+ // Use _rawPlaceOrder for both DEX and non-DEX (SDK exchange.placeOrder is unavailable)
415
+ const result = await this._rawPlaceOrder({
416
+ assetIndex: this.getAssetIndex(symbol.toUpperCase()),
417
+ isBuy: side === "buy",
418
+ price,
419
+ size,
420
+ orderType: { limit: { tif } },
421
+ reduceOnly,
422
+ });
432
423
  await this._invalidateAccountCache();
433
424
  return result;
434
425
  }
@@ -334,6 +334,8 @@ export class LighterAdapter {
334
334
  const nonce = await this.getNextNonce();
335
335
  const marketIndex = this.getMarketIndex(symbol);
336
336
  const { baseAmount, priceTicks } = this.toTicks(symbol, parseFloat(size), parseFloat(price));
337
+ // Map TIF string → Lighter numeric: IOC=0, GTT/GTC=1
338
+ const isIoc = opts?.tif?.toUpperCase() === "IOC";
337
339
  const signed = await this.signOrder({
338
340
  marketIndex,
339
341
  clientOrderIndex: 0,
@@ -341,10 +343,10 @@ export class LighterAdapter {
341
343
  price: priceTicks,
342
344
  isAsk: side === "sell" ? 1 : 0,
343
345
  orderType: 0, // ORDER_TYPE_LIMIT
344
- timeInForce: 1, // GTT (Good Till Time) — rests on orderbook
346
+ timeInForce: isIoc ? 0 : 1, // 0=IOC (immediate fill or cancel), 1=GTT (rests on book)
345
347
  reduceOnly: opts?.reduceOnly ? 1 : 0,
346
348
  triggerPrice: 0,
347
- orderExpiry: -1, // DEFAULT_28_DAY_ORDER_EXPIRY (WASM auto-computes 28 days)
349
+ orderExpiry: isIoc ? 0 : -1, // IOC: no expiry; GTT: 28-day default
348
350
  nonce,
349
351
  });
350
352
  return this.sendTx(signed);
@@ -878,7 +880,52 @@ export class LighterAdapter {
878
880
  }
879
881
  const result = await sendRes.json();
880
882
  if (result.code !== 200) {
881
- throw new Error(`ChangePubKey failed: ${result.message ?? JSON.stringify(result)}`);
883
+ // Retry with fresh nonce if invalid nonce (pending tx or stale nonce)
884
+ if (result.message && /invalid nonce/i.test(result.message)) {
885
+ const MAX_NONCE_RETRIES = 3;
886
+ let lastError = result.message;
887
+ for (let attempt = 1; attempt <= MAX_NONCE_RETRIES; attempt++) {
888
+ await new Promise(r => setTimeout(r, 500 * attempt));
889
+ // Re-fetch nonce from API (may have updated since first call)
890
+ const freshNonceRes = await this.restGet("/nextNonce", {
891
+ account_index: String(this._accountIndex),
892
+ api_key_index: String(apiKeyIndex),
893
+ });
894
+ const freshNonce = (freshNonceRes.nonce ?? freshNonceRes.next_nonce ?? nonce) + attempt;
895
+ const retrySigned = await client.signChangePubKey({
896
+ pubkey: publicKey, nonce: freshNonce, apiKeyIndex, accountIndex: this._accountIndex,
897
+ });
898
+ if (retrySigned.error || !retrySigned.txInfo || !retrySigned.messageToSign) {
899
+ lastError = retrySigned.error ?? "incomplete response";
900
+ continue;
901
+ }
902
+ const retryTxInfo = JSON.parse(retrySigned.txInfo);
903
+ retryTxInfo.L1Sig = await wallet.signMessage(retrySigned.messageToSign);
904
+ const retryRes = await fetch(`${this._baseUrl}/api/v1/sendTx`, {
905
+ method: "POST",
906
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
907
+ body: new URLSearchParams({
908
+ tx_type: String(retrySigned.txType ?? 0),
909
+ tx_info: JSON.stringify(retryTxInfo),
910
+ }),
911
+ });
912
+ if (!retryRes.ok) {
913
+ lastError = `sendTx ${retryRes.status}: ${await retryRes.text()}`;
914
+ continue;
915
+ }
916
+ const retryResult = await retryRes.json();
917
+ if (retryResult.code === 200) {
918
+ return { privateKey, publicKey }; // Success on retry
919
+ }
920
+ lastError = retryResult.message ?? JSON.stringify(retryResult);
921
+ if (!/invalid nonce/i.test(lastError))
922
+ break; // Different error, stop retrying
923
+ }
924
+ throw new Error(`ChangePubKey failed after ${MAX_NONCE_RETRIES} nonce retries: ${lastError}`);
925
+ }
926
+ else {
927
+ throw new Error(`ChangePubKey failed: ${result.message ?? JSON.stringify(result)}`);
928
+ }
882
929
  }
883
930
  return { privateKey, publicKey };
884
931
  }
@@ -242,7 +242,7 @@ export class PacificaAdapter {
242
242
  return history.slice(0, limit).map((h) => ({
243
243
  time: Number(h.created_at ?? 0),
244
244
  symbol: String(h.symbol ?? ""),
245
- payment: String(h.amount ?? "0"),
245
+ payment: String(h.payout ?? "0"),
246
246
  }));
247
247
  }
248
248
  }