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.
- package/README.md +4 -1
- package/dist/arb/utils.d.ts +2 -0
- package/dist/arb/utils.js +1 -1
- package/dist/commands/agent.js +19 -11
- package/dist/commands/arb/index.js +288 -31
- package/dist/commands/arb-auto.js +242 -63
- package/dist/commands/init.js +1 -1
- package/dist/commands/settings.d.ts +2 -1
- package/dist/commands/settings.js +133 -2
- package/dist/commands/trade.js +89 -35
- package/dist/constants.d.ts +14 -0
- package/dist/constants.js +40 -0
- package/dist/exchanges/hyperliquid.js +13 -22
- package/dist/exchanges/lighter.js +50 -3
- package/dist/exchanges/pacifica.js +1 -1
- package/dist/index.js +7 -7
- package/dist/mcp-server.js +6 -6
- package/dist/settings.d.ts +6 -0
- package/dist/settings.js +18 -2
- package/dist/smart-order.d.ts +44 -0
- package/dist/smart-order.js +134 -0
- package/dist/trade-validator.js +2 -1
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +31 -8
|
@@ -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")
|
package/dist/commands/trade.js
CHANGED
|
@@ -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
|
-
|
|
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 } :
|
|
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 } :
|
|
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
|
-
|
|
124
|
-
|
|
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}
|
|
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
|
-
|
|
156
|
-
|
|
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}
|
|
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
|
-
.
|
|
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}
|
|
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
|
-
|
|
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
|
-
.
|
|
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}
|
|
751
|
-
|
|
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}
|
|
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
|
-
.
|
|
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}
|
|
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
|
-
|
|
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
|
-
.
|
|
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}
|
|
820
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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, //
|
|
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, //
|
|
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
|
-
|
|
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
|
}
|