perp-cli 0.3.6 → 0.3.8

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 CHANGED
@@ -288,7 +288,7 @@ perp settings set referralCodes.hyperliquid MYCODE
288
288
  - **TypeScript** + Node.js (ESM)
289
289
  - **Solana**: `@solana/web3.js`, `tweetnacl`, `bs58`
290
290
  - **EVM**: `ethers` v6
291
- - **Exchanges**: `hyperliquid` SDK, `lighter-sdk` (WASM), `@pacifica/sdk`
291
+ - **Exchanges**: `hyperliquid` SDK, `lighter-ts-sdk` (WASM), `@pacifica/sdk`
292
292
  - **Bridge**: Circle CCTP V2 (Solana ↔ EVM), deBridge DLN (fallback)
293
293
  - **Testing**: Vitest (860+ tests)
294
294
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, LIQUIDATION_DISTANCE_HARD_CAP } from "../risk.js";
2
+ import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, effectiveLimit, LIQUIDATION_DISTANCE_HARD_CAP } from "../risk.js";
3
3
  const defaultLimits = {
4
4
  maxDrawdownUsd: 500,
5
5
  maxPositionUsd: 5000,
@@ -264,3 +264,42 @@ describe("LIQUIDATION_DISTANCE_HARD_CAP", () => {
264
264
  expect(LIQUIDATION_DISTANCE_HARD_CAP).toBe(20);
265
265
  });
266
266
  });
267
+ describe("Percentage-based Risk Limits", () => {
268
+ it("effectiveLimit should return min of USD and pct-of-equity", () => {
269
+ expect(effectiveLimit(500, 10, 1000)).toBe(100); // 10% of $1000 = $100 < $500
270
+ expect(effectiveLimit(500, 10, 10000)).toBe(500); // 10% of $10000 = $1000 > $500
271
+ expect(effectiveLimit(500, undefined, 1000)).toBe(500); // no pct → use USD
272
+ expect(effectiveLimit(500, 10, 0)).toBe(500); // zero equity → use USD
273
+ });
274
+ it("should use pct-based drawdown when equity is small", () => {
275
+ const limits = { ...defaultLimits, maxDrawdownPct: 10 };
276
+ // equity = $100, 10% = $10 effective drawdown limit. uPnL = -$15 > $10 → critical
277
+ const balances = [{ exchange: "test", balance: makeBalance(100, 80, 20, -15) }];
278
+ const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.0001, 100000, 2, -15) }];
279
+ const result = assessRisk(balances, positions, limits);
280
+ expect(result.violations.some(v => v.rule === "max_drawdown")).toBe(true);
281
+ expect(result.level).toBe("critical");
282
+ });
283
+ it("should use pct-based position limit when equity is small", () => {
284
+ const limits = { ...defaultLimits, maxPositionPct: 25 };
285
+ // equity = $200, 25% = $50 effective position limit. Position = $100 > $50 → violation
286
+ const balances = [{ exchange: "test", balance: makeBalance(200, 150, 50, 0) }];
287
+ const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.001, 100000, 2, 0) }]; // $100
288
+ const result = assessRisk(balances, positions, limits);
289
+ expect(result.violations.some(v => v.rule === "max_position_size")).toBe(true);
290
+ });
291
+ it("preTradeCheck should use pct-based limits", () => {
292
+ const limits = { ...defaultLimits, maxPositionPct: 25 };
293
+ // equity = $200, 25% = $50 effective position limit
294
+ const assessment = assessRisk([{ exchange: "test", balance: makeBalance(200, 150, 50, 0) }], [], limits);
295
+ expect(preTradeCheck(assessment, 60, 2).allowed).toBe(false); // $60 > $50
296
+ expect(preTradeCheck(assessment, 40, 2).allowed).toBe(true); // $40 < $50
297
+ });
298
+ it("should use stricter of USD and pct limits", () => {
299
+ const limits = { ...defaultLimits, maxPositionUsd: 100, maxPositionPct: 25 };
300
+ // equity = $1000, 25% = $250. USD limit = $100. Effective = $100 (USD is stricter)
301
+ const assessment = assessRisk([{ exchange: "test", balance: makeBalance(1000, 800, 200, 0) }], [], limits);
302
+ expect(preTradeCheck(assessment, 120, 2).allowed).toBe(false); // $120 > $100
303
+ expect(preTradeCheck(assessment, 80, 2).allowed).toBe(true); // $80 < $100
304
+ });
305
+ });
@@ -292,6 +292,7 @@ export function registerManageCommands(program, getAdapter, isJson, getPacificaA
292
292
  // Auto-save to .env
293
293
  setEnvVar("LIGHTER_API_KEY", privateKey);
294
294
  setEnvVar("LIGHTER_ACCOUNT_INDEX", String(adapter.accountIndex));
295
+ setEnvVar("LIGHTER_API_KEY_INDEX", String(keyIndex));
295
296
  if (isJson()) {
296
297
  return printJson(jsonOk({
297
298
  privateKey,
@@ -90,10 +90,14 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
90
90
  risk
91
91
  .command("limits")
92
92
  .description("View or set risk limits")
93
- .option("--max-drawdown <usd>", "Max unrealized loss before closing all")
94
- .option("--max-position <usd>", "Max single position notional")
95
- .option("--max-exposure <usd>", "Max total exposure across all positions")
96
- .option("--daily-loss <usd>", "Daily realized loss limit")
93
+ .option("--max-drawdown <usd>", "Max unrealized loss (USD)")
94
+ .option("--max-drawdown-pct <pct>", "Max unrealized loss (% of equity)")
95
+ .option("--max-position <usd>", "Max single position notional (USD)")
96
+ .option("--max-position-pct <pct>", "Max single position (% of equity)")
97
+ .option("--max-exposure <usd>", "Max total exposure (USD)")
98
+ .option("--max-exposure-pct <pct>", "Max total exposure (% of equity)")
99
+ .option("--daily-loss <usd>", "Daily realized loss limit (USD)")
100
+ .option("--daily-loss-pct <pct>", "Daily loss limit (% of equity)")
97
101
  .option("--max-positions <n>", "Max number of simultaneous positions")
98
102
  .option("--max-leverage <n>", "Max leverage per position")
99
103
  .option("--max-margin <pct>", "Max margin utilization %")
@@ -101,24 +105,33 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
101
105
  .option("--reset", "Reset all limits to defaults")
102
106
  .action(async (opts) => {
103
107
  let limits = loadRiskLimits();
104
- const hasUpdate = opts.maxDrawdown || opts.maxPosition || opts.maxExposure ||
105
- opts.dailyLoss || opts.maxPositions || opts.maxLeverage || opts.maxMargin ||
108
+ const hasUpdate = opts.maxDrawdown || opts.maxDrawdownPct || opts.maxPosition || opts.maxPositionPct ||
109
+ opts.maxExposure || opts.maxExposurePct || opts.dailyLoss || opts.dailyLossPct ||
110
+ opts.maxPositions || opts.maxLeverage || opts.maxMargin ||
106
111
  opts.minLiqDistance || opts.reset;
107
112
  if (opts.reset) {
108
113
  limits = {
109
114
  maxDrawdownUsd: 500, maxPositionUsd: 5000, maxTotalExposureUsd: 20000,
110
115
  dailyLossLimitUsd: 200, maxPositions: 10, maxLeverage: 20, maxMarginUtilization: 80,
111
- minLiquidationDistance: 30,
116
+ minLiquidationDistance: 30, maxDrawdownPct: 10, maxPositionPct: 25,
112
117
  };
113
118
  }
114
119
  if (opts.maxDrawdown)
115
120
  limits.maxDrawdownUsd = parseFloat(opts.maxDrawdown);
121
+ if (opts.maxDrawdownPct)
122
+ limits.maxDrawdownPct = parseFloat(opts.maxDrawdownPct);
116
123
  if (opts.maxPosition)
117
124
  limits.maxPositionUsd = parseFloat(opts.maxPosition);
125
+ if (opts.maxPositionPct)
126
+ limits.maxPositionPct = parseFloat(opts.maxPositionPct);
118
127
  if (opts.maxExposure)
119
128
  limits.maxTotalExposureUsd = parseFloat(opts.maxExposure);
129
+ if (opts.maxExposurePct)
130
+ limits.maxExposurePct = parseFloat(opts.maxExposurePct);
120
131
  if (opts.dailyLoss)
121
132
  limits.dailyLossLimitUsd = parseFloat(opts.dailyLoss);
133
+ if (opts.dailyLossPct)
134
+ limits.dailyLossPct = parseFloat(opts.dailyLossPct);
122
135
  if (opts.maxPositions)
123
136
  limits.maxPositions = parseInt(opts.maxPositions);
124
137
  if (opts.maxLeverage)
@@ -140,15 +153,22 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
140
153
  saveRiskLimits(limits);
141
154
  if (isJson())
142
155
  return printJson(jsonOk(limits));
156
+ const fmtLimit = (usd, pct) => {
157
+ const parts = [`$${formatUsd(usd)}`];
158
+ if (pct != null)
159
+ parts.push(chalk.cyan(`${pct}% of equity`));
160
+ return parts.join(" / ");
161
+ };
143
162
  console.log(chalk.cyan.bold(`\n Risk Limits ${hasUpdate ? "(updated)" : ""}\n`));
144
- console.log(` Max Drawdown: $${formatUsd(limits.maxDrawdownUsd)}`);
145
- console.log(` Max Position Size: $${formatUsd(limits.maxPositionUsd)}`);
146
- console.log(` Max Total Exposure: $${formatUsd(limits.maxTotalExposureUsd)}`);
147
- console.log(` Daily Loss Limit: $${formatUsd(limits.dailyLossLimitUsd)}`);
163
+ console.log(` Max Drawdown: ${fmtLimit(limits.maxDrawdownUsd, limits.maxDrawdownPct)}`);
164
+ console.log(` Max Position Size: ${fmtLimit(limits.maxPositionUsd, limits.maxPositionPct)}`);
165
+ console.log(` Max Total Exposure: ${fmtLimit(limits.maxTotalExposureUsd, limits.maxExposurePct)}`);
166
+ console.log(` Daily Loss Limit: ${fmtLimit(limits.dailyLossLimitUsd, limits.dailyLossPct)}`);
148
167
  console.log(` Max Positions: ${limits.maxPositions}`);
149
168
  console.log(` Max Leverage: ${limits.maxLeverage}x`);
150
169
  console.log(` Max Margin Util: ${limits.maxMarginUtilization}%`);
151
- console.log(` Min Liq Distance: ${limits.minLiquidationDistance}% ${chalk.gray(`(hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`)}\n`);
170
+ console.log(` Min Liq Distance: ${limits.minLiquidationDistance}% ${chalk.gray(`(hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`)}`);
171
+ console.log(chalk.gray(`\n When both USD and % are set, the stricter limit applies.\n`));
152
172
  console.log(chalk.gray(` Config file: ~/.perp/risk.json\n`));
153
173
  });
154
174
  // ── risk check ── (pre-trade check, for agent use)
@@ -389,9 +389,11 @@ export function registerWalletCommands(program, isJson) {
389
389
  const { LighterAdapter } = await import("../exchanges/lighter.js");
390
390
  const adapter = new LighterAdapter(normalized);
391
391
  await adapter.init();
392
- const { privateKey: apiKey } = await adapter.setupApiKey();
392
+ const apiKeyIndex = 2;
393
+ const { privateKey: apiKey } = await adapter.setupApiKey(apiKeyIndex);
393
394
  setEnvVar("LIGHTER_API_KEY", apiKey);
394
395
  setEnvVar("LIGHTER_ACCOUNT_INDEX", String(adapter.accountIndex));
396
+ setEnvVar("LIGHTER_API_KEY_INDEX", String(apiKeyIndex));
395
397
  lighterApiSetup = { apiKey, accountIndex: adapter.accountIndex };
396
398
  }
397
399
  catch (e) {
@@ -51,7 +51,7 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
51
51
  cancelOrder(symbol: string, orderId: string): Promise<import("hyperliquid").CancelOrderResponse>;
52
52
  cancelAllOrders(symbol?: string): Promise<import("hyperliquid").CancelOrderResponse[]>;
53
53
  editOrder(symbol: string, orderId: string, price: string, size: string): Promise<unknown>;
54
- setLeverage(symbol: string, leverage: number, marginMode?: "cross" | "isolated"): Promise<unknown>;
54
+ setLeverage(symbol: string, leverage: number, marginMode?: "cross" | "isolated"): Promise<any>;
55
55
  stopOrder(symbol: string, side: "buy" | "sell", size: string, triggerPrice: string, opts?: {
56
56
  limitPrice?: string;
57
57
  reduceOnly?: boolean;
@@ -90,14 +90,14 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
90
90
  twapCancel(symbol: string, twapId: number): Promise<unknown>;
91
91
  /**
92
92
  * Update leverage for a symbol.
93
- * Python SDK: update_leverage(leverage, name, is_cross)
93
+ * Uses SDK's built-in updateLeverage method.
94
94
  */
95
- updateLeverage(symbol: string, leverage: number, isCross?: boolean): Promise<unknown>;
95
+ updateLeverage(symbol: string, leverage: number, isCross?: boolean): Promise<any>;
96
96
  /**
97
97
  * Update isolated margin for a position.
98
98
  * amount > 0 to add margin, amount < 0 to remove
99
99
  */
100
- updateIsolatedMargin(symbol: string, amount: number): Promise<unknown>;
100
+ updateIsolatedMargin(symbol: string, amount: number): Promise<any>;
101
101
  /**
102
102
  * Withdraw from Hyperliquid L1 bridge.
103
103
  * Python SDK: withdraw_from_bridge(amount, destination) → action type "withdraw3"
@@ -586,31 +586,17 @@ export class HyperliquidAdapter {
586
586
  }
587
587
  /**
588
588
  * Update leverage for a symbol.
589
- * Python SDK: update_leverage(leverage, name, is_cross)
589
+ * Uses SDK's built-in updateLeverage method.
590
590
  */
591
591
  async updateLeverage(symbol, leverage, isCross = true) {
592
- const assetIndex = this.getAssetIndex(symbol);
593
- const action = {
594
- type: "updateLeverage",
595
- asset: assetIndex,
596
- isCross,
597
- leverage,
598
- };
599
- return this._sendExchangeAction(action);
592
+ return this.sdk.exchange.updateLeverage(symbol, isCross ? "cross" : "isolated", leverage);
600
593
  }
601
594
  /**
602
595
  * Update isolated margin for a position.
603
596
  * amount > 0 to add margin, amount < 0 to remove
604
597
  */
605
598
  async updateIsolatedMargin(symbol, amount) {
606
- const assetIndex = this.getAssetIndex(symbol);
607
- const action = {
608
- type: "updateIsolatedMargin",
609
- asset: assetIndex,
610
- isBuy: true,
611
- ntli: Math.round(amount * 1e6), // USD to 6 decimals
612
- };
613
- return this._sendExchangeAction(action);
599
+ return this.sdk.exchange.updateIsolatedMargin(symbol, amount > 0, Math.round(Math.abs(amount) * 1e6));
614
600
  }
615
601
  /**
616
602
  * Withdraw from Hyperliquid L1 bridge.
@@ -1,9 +1,10 @@
1
1
  import type { ExchangeAdapter, ExchangeMarketInfo, ExchangePosition, ExchangeOrder, ExchangeBalance, ExchangeTrade, ExchangeFundingPayment, ExchangeKline } from "./interface.js";
2
- type LighterSignerClientType = import("lighter-sdk").LighterSignerClient;
2
+ import type { WasmSignerClient as WasmSignerClientType } from "lighter-ts-sdk";
3
3
  export declare class LighterAdapter implements ExchangeAdapter {
4
4
  readonly name = "lighter";
5
5
  private _signer;
6
6
  private _accountIndex;
7
+ private _apiKeyIndex;
7
8
  private _address;
8
9
  private _marketMap;
9
10
  private _marketDecimals;
@@ -23,7 +24,11 @@ export declare class LighterAdapter implements ExchangeAdapter {
23
24
  apiKey?: string;
24
25
  accountIndex?: number;
25
26
  });
26
- get signer(): LighterSignerClientType;
27
+ get signer(): {
28
+ createAuthToken(deadline: number): Promise<{
29
+ authToken: string;
30
+ }>;
31
+ } & WasmSignerClientType;
27
32
  get accountIndex(): number;
28
33
  get address(): string;
29
34
  get evmKey(): string;
@@ -137,12 +142,13 @@ export declare class LighterAdapter implements ExchangeAdapter {
137
142
  private sendTx;
138
143
  private restGet;
139
144
  private ensureSigner;
140
- private static getWasmOps;
145
+ private static _wasmClient;
146
+ private static getWasmClient;
141
147
  /**
142
148
  * Generate a new Lighter API key pair using the WASM signer.
143
149
  * Returns { privateKey, publicKey } (both 0x-prefixed, 40 bytes).
144
150
  */
145
- static generateApiKey(seed?: string): Promise<{
151
+ static generateApiKey(): Promise<{
146
152
  privateKey: string;
147
153
  publicKey: string;
148
154
  }>;
@@ -156,4 +162,3 @@ export declare class LighterAdapter implements ExchangeAdapter {
156
162
  publicKey: string;
157
163
  }>;
158
164
  }
159
- export {};
@@ -1,11 +1,8 @@
1
- import { createRequire } from "node:module";
2
- // Use createRequire to load the CJS build of lighter-sdk
3
- // (the ESM build has a broken require polyfill that fails on Node built-ins)
4
- const require = createRequire(import.meta.url);
5
1
  export class LighterAdapter {
6
2
  name = "lighter";
7
3
  _signer;
8
4
  _accountIndex = -1;
5
+ _apiKeyIndex;
9
6
  _address;
10
7
  _marketMap = new Map(); // symbol → marketIndex
11
8
  _marketDecimals = new Map(); // symbol → decimals
@@ -26,6 +23,7 @@ export class LighterAdapter {
26
23
  this._evmKey = evmKey;
27
24
  this._apiKey = opts?.apiKey || process.env.LIGHTER_API_KEY || "";
28
25
  this._accountIndexInit = opts?.accountIndex ?? parseInt(process.env.LIGHTER_ACCOUNT_INDEX || "-1");
26
+ this._apiKeyIndex = parseInt(process.env.LIGHTER_API_KEY_INDEX || "2");
29
27
  this._address = "";
30
28
  this._testnet = testnet;
31
29
  this._readOnly = !this._apiKey;
@@ -35,7 +33,16 @@ export class LighterAdapter {
35
33
  this._chainId = testnet ? 300 : 304;
36
34
  }
37
35
  get signer() {
38
- return this._signer;
36
+ const self = this;
37
+ // Compatibility wrapper: ws-feeds.ts expects createAuthToken(deadline) → { authToken }
38
+ return Object.create(this._signer, {
39
+ createAuthToken: {
40
+ value: async (deadline) => {
41
+ const token = await self._signer.createAuthToken(deadline, self._apiKeyIndex, self._accountIndex);
42
+ return { authToken: token };
43
+ },
44
+ },
45
+ });
39
46
  }
40
47
  get accountIndex() {
41
48
  return this._accountIndex;
@@ -65,33 +72,37 @@ export class LighterAdapter {
65
72
  // Auto-generate API key if we have PK but no API key and account exists
66
73
  if (!this._apiKey && this._accountIndex >= 0) {
67
74
  try {
68
- const { privateKey: apiKey } = await this.setupApiKey();
75
+ const autoKeyIndex = 2; // default for auto-setup
76
+ const { privateKey: apiKey } = await this.setupApiKey(autoKeyIndex);
69
77
  this._apiKey = apiKey;
78
+ this._apiKeyIndex = autoKeyIndex;
70
79
  // Save to .env for future use
71
80
  try {
72
81
  const { setEnvVar } = await import("../commands/init.js");
73
82
  setEnvVar("LIGHTER_API_KEY", apiKey);
74
83
  setEnvVar("LIGHTER_ACCOUNT_INDEX", String(this._accountIndex));
84
+ setEnvVar("LIGHTER_API_KEY_INDEX", String(autoKeyIndex));
75
85
  }
76
86
  catch { /* non-critical — env save may fail in some contexts */ }
77
87
  }
78
- catch {
79
- // Auto-setup failed (no gas, network issue, etc.) continue in read-only mode
88
+ catch (e) {
89
+ // Auto-setup failed log the error and continue in read-only mode
90
+ const msg = e instanceof Error ? e.message : String(e);
91
+ console.error(`[lighter] API key auto-setup failed: ${msg}. Trading will be read-only. Run 'perp -e lighter manage setup-api-key' to retry.`);
80
92
  }
81
93
  }
82
94
  // Initialize signer for trading if we have an API key
83
95
  if (this._apiKey) {
84
- const { LighterSignerClient } = require("lighter-sdk");
85
- this._signer = new LighterSignerClient({
96
+ const { WasmSignerClient } = await import("lighter-ts-sdk");
97
+ this._signer = new WasmSignerClient({});
98
+ await this._signer.initialize();
99
+ await this._signer.createClient({
86
100
  url: this._baseUrl,
87
101
  privateKey: this._apiKey,
88
102
  chainId: this._chainId,
103
+ apiKeyIndex: this._apiKeyIndex,
89
104
  accountIndex: this._accountIndex,
90
- apiKeyIndex: parseInt(process.env.LIGHTER_API_KEY_INDEX || "3"),
91
- loaderType: "wasm",
92
105
  });
93
- // Only initialize (load WASM + create client), skip checkClient which does HTTP from WASM
94
- await this._signer.initialize();
95
106
  this._readOnly = false;
96
107
  }
97
108
  // Build symbol → marketIndex map + decimals from orderBookDetails
@@ -230,8 +241,7 @@ export class LighterAdapter {
230
241
  if (this._readOnly)
231
242
  throw new Error("Auth requires API key");
232
243
  const deadline = Math.floor(Date.now() / 1000) + 3600;
233
- const auth = await this._signer.createAuthToken(deadline);
234
- return auth.authToken;
244
+ return this._signer.createAuthToken(deadline, this._apiKeyIndex, this._accountIndex);
235
245
  }
236
246
  async restGetAuth(path, params) {
237
247
  const auth = await this.getAuthToken();
@@ -291,7 +301,7 @@ export class LighterAdapter {
291
301
  baseAmount,
292
302
  price: Math.max(slippageTicks, 1),
293
303
  isAsk: side === "sell" ? 1 : 0,
294
- type: 1, // ORDER_TYPE_MARKET
304
+ orderType: 1, // ORDER_TYPE_MARKET
295
305
  timeInForce: 0, // IOC (Immediate or Cancel)
296
306
  reduceOnly: 0,
297
307
  triggerPrice: 0,
@@ -311,7 +321,7 @@ export class LighterAdapter {
311
321
  baseAmount,
312
322
  price: priceTicks,
313
323
  isAsk: side === "sell" ? 1 : 0,
314
- type: 0, // ORDER_TYPE_LIMIT
324
+ orderType: 0, // ORDER_TYPE_LIMIT
315
325
  timeInForce: 1, // GTT (Good Till Time) — rests on orderbook
316
326
  reduceOnly: opts?.reduceOnly ? 1 : 0,
317
327
  triggerPrice: 0,
@@ -324,13 +334,19 @@ export class LighterAdapter {
324
334
  this.ensureSigner();
325
335
  const nonce = await this.getNextNonce();
326
336
  const marketIndex = this.getMarketIndex(symbol);
327
- const signed = await this._signer.signCancelOrder(marketIndex, parseInt(orderId), nonce);
337
+ const signed = await this._signer.signCancelOrder({
338
+ marketIndex, orderIndex: parseInt(orderId), nonce,
339
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
340
+ });
328
341
  return this.sendTx(signed);
329
342
  }
330
343
  async cancelAllOrders(_symbol) {
331
344
  this.ensureSigner();
332
345
  const nonce = await this.getNextNonce();
333
- const signed = await this._signer.signCancelAllOrders(0, Math.floor(Date.now() / 1000) + 86400, nonce);
346
+ const signed = await this._signer.signCancelAllOrders({
347
+ timeInForce: 0, time: Math.floor(Date.now() / 1000) + 86400, nonce,
348
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
349
+ });
334
350
  return this.sendTx(signed);
335
351
  }
336
352
  async modifyOrder(symbol, orderId, price, size) {
@@ -345,6 +361,8 @@ export class LighterAdapter {
345
361
  price: priceTicks,
346
362
  triggerPrice: 0,
347
363
  nonce,
364
+ apiKeyIndex: this._apiKeyIndex,
365
+ accountIndex: this._accountIndex,
348
366
  });
349
367
  if (signed.error) {
350
368
  throw new Error(`Signer: ${signed.error}`);
@@ -374,7 +392,7 @@ export class LighterAdapter {
374
392
  baseAmount,
375
393
  price: Math.max(priceTicks, 1),
376
394
  isAsk: side === "sell" ? 1 : 0,
377
- type: isMarket ? 1 : 0,
395
+ orderType: isMarket ? 1 : 0,
378
396
  timeInForce: isMarket ? 0 : 1, // IOC for market, GTT for limit
379
397
  reduceOnly: opts?.reduceOnly ? 1 : 0,
380
398
  triggerPrice: triggerTicks,
@@ -390,16 +408,22 @@ export class LighterAdapter {
390
408
  // fraction = initial margin fraction in basis points: 10000/leverage
391
409
  const fraction = Math.round(10000 / leverage);
392
410
  const mode = marginMode === "isolated" ? 1 : 0;
393
- const signed = await this._signer.signUpdateLeverage(marketIndex, fraction, mode, nonce);
411
+ const signed = await this._signer.signUpdateLeverage({
412
+ marketIndex, fraction, marginMode: mode, nonce,
413
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
414
+ });
394
415
  if (signed.error) {
395
416
  throw new Error(`Signer: ${signed.error}`);
396
417
  }
397
418
  return this.sendTx(signed);
398
419
  }
399
- async withdraw(amount, assetId = 2, routeType = 0) {
420
+ async withdraw(amount, assetId = 3, routeType = 0) {
400
421
  this.ensureSigner();
401
422
  const nonce = await this.getNextNonce();
402
- const signed = await this._signer.signWithdraw(amount, assetId, routeType, nonce);
423
+ const signed = await this._signer.signWithdraw({
424
+ usdcAmount: amount, assetIndex: assetId, routeType, nonce,
425
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
426
+ });
403
427
  return this.sendTx(signed);
404
428
  }
405
429
  // ── Interface aliases ──
@@ -680,16 +704,19 @@ export class LighterAdapter {
680
704
  * Direct REST GET helper.
681
705
  */
682
706
  async getNextNonce() {
683
- const apiKeyIndex = parseInt(process.env.LIGHTER_API_KEY_INDEX || "3");
684
707
  const res = await this.restGet("/nextNonce", {
685
708
  account_index: String(this._accountIndex),
686
- api_key_index: String(apiKeyIndex),
709
+ api_key_index: String(this._apiKeyIndex),
687
710
  });
688
711
  return res.nonce ?? res.next_nonce ?? 0;
689
712
  }
690
713
  async signOrder(params) {
691
714
  try {
692
- const result = await this._signer.signCreateOrder(params);
715
+ const result = await this._signer.signCreateOrder({
716
+ ...params,
717
+ apiKeyIndex: this._apiKeyIndex,
718
+ accountIndex: this._accountIndex,
719
+ });
693
720
  if (result.error) {
694
721
  throw new Error(`Signer: ${result.error}`);
695
722
  }
@@ -737,23 +764,23 @@ export class LighterAdapter {
737
764
  "then set LIGHTER_API_KEY in your .env");
738
765
  }
739
766
  }
740
- static async getWasmOps() {
741
- const { WasmLoader } = require("lighter-sdk");
742
- const loader = WasmLoader.getInstance();
743
- await loader.load({});
744
- return loader.getOperations();
767
+ static _wasmClient = null;
768
+ static async getWasmClient() {
769
+ if (LighterAdapter._wasmClient)
770
+ return LighterAdapter._wasmClient;
771
+ const { WasmSignerClient } = await import("lighter-ts-sdk");
772
+ const client = new WasmSignerClient({});
773
+ await client.initialize();
774
+ LighterAdapter._wasmClient = client;
775
+ return client;
745
776
  }
746
777
  /**
747
778
  * Generate a new Lighter API key pair using the WASM signer.
748
779
  * Returns { privateKey, publicKey } (both 0x-prefixed, 40 bytes).
749
780
  */
750
- static async generateApiKey(seed) {
751
- const ops = await LighterAdapter.getWasmOps();
752
- const result = ops
753
- .GenerateAPIKey(seed ?? `pacifica-cli-${Date.now()}-${Math.random()}`);
754
- if (result.err)
755
- throw new Error(`GenerateAPIKey failed: ${result.err}`);
756
- return { privateKey: result.privateKey, publicKey: result.publicKey };
781
+ static async generateApiKey() {
782
+ const client = await LighterAdapter.getWasmClient();
783
+ return client.generateAPIKey();
757
784
  }
758
785
  /**
759
786
  * Generate an API key and register it on-chain via ChangePubKey.
@@ -769,14 +796,23 @@ export class LighterAdapter {
769
796
  account_index: String(this._accountIndex),
770
797
  api_key_index: String(apiKeyIndex),
771
798
  });
772
- const nonce = nonceRes.next_nonce ?? 0;
799
+ const nonce = nonceRes.nonce ?? nonceRes.next_nonce ?? 0;
773
800
  // 3. Create signer client with new key and sign ChangePubKey
774
- const ops = await LighterAdapter.getWasmOps();
775
- const opsAny = ops;
776
- // CreateClient with the new API key
777
- opsAny.CreateClient(this._baseUrl, privateKey, this._chainId, apiKeyIndex, this._accountIndex);
801
+ const client = await LighterAdapter.getWasmClient();
802
+ await client.createClient({
803
+ url: this._baseUrl,
804
+ privateKey,
805
+ chainId: this._chainId,
806
+ apiKeyIndex,
807
+ accountIndex: this._accountIndex,
808
+ });
778
809
  // Sign ChangePubKey
779
- const signed = await opsAny.SignChangePubKey(publicKey, nonce, apiKeyIndex, this._accountIndex);
810
+ const signed = await client.signChangePubKey({
811
+ pubkey: publicKey,
812
+ nonce,
813
+ apiKeyIndex,
814
+ accountIndex: this._accountIndex,
815
+ });
780
816
  if (signed.error)
781
817
  throw new Error(`SignChangePubKey failed: ${signed.error}`);
782
818
  if (!signed.txInfo || !signed.messageToSign) {
package/dist/index.js CHANGED
@@ -48,7 +48,7 @@ const _defaultExchange = _settings.defaultExchange || "pacifica";
48
48
  program
49
49
  .name("perp")
50
50
  .description("Multi-DEX Perpetual Futures CLI (Pacifica, Hyperliquid, Lighter)")
51
- .version("0.3.6")
51
+ .version("0.3.7")
52
52
  .option("-e, --exchange <exchange>", `Exchange: pacifica, hyperliquid, lighter (default: ${_defaultExchange})`, _defaultExchange)
53
53
  .option("-n, --network <network>", "Network: mainnet or testnet", "mainnet")
54
54
  .option("-k, --private-key <key>", "Private key")
@@ -383,7 +383,7 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
383
383
  process.env.LIGHTER_PRIVATE_KEY);
384
384
  if (!status.hasWallets && !hasEnvKey && !settings.defaultExchange) {
385
385
  // Fresh install — onboarding
386
- console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(" v0.3.6\n"));
386
+ console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(" v0.3.7\n"));
387
387
  console.log(" Multi-DEX perpetual futures CLI for Pacifica, Hyperliquid, and Lighter.\n");
388
388
  console.log(` Get started: ${chalk.cyan("perp init")}`);
389
389
  console.log(chalk.gray(`\n Or explore without a wallet:`));
@@ -396,7 +396,7 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
396
396
  // Configured — show status overview
397
397
  const defaultEx = settings.defaultExchange || "pacifica";
398
398
  const activeEntries = Object.entries(status.active);
399
- console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(" v0.3.6\n"));
399
+ console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(" v0.3.7\n"));
400
400
  console.log(` Default exchange: ${chalk.cyan(defaultEx)}`);
401
401
  if (activeEntries.length > 0) {
402
402
  console.log(chalk.white.bold("\n Wallets:"));
@@ -56,7 +56,7 @@ function err(error, meta) {
56
56
  return JSON.stringify({ ok: false, error, meta }, null, 2);
57
57
  }
58
58
  // ── MCP Server ──
59
- const server = new McpServer({ name: "perp-cli", version: "0.3.6" }, { capabilities: { tools: {}, resources: {} } });
59
+ const server = new McpServer({ name: "perp-cli", version: "0.3.7" }, { capabilities: { tools: {}, resources: {} } });
60
60
  // ============================================================
61
61
  // Market Data tools (read-only, no private key needed)
62
62
  // ============================================================
package/dist/risk.d.ts CHANGED
@@ -8,7 +8,13 @@ export interface RiskLimits {
8
8
  maxLeverage: number;
9
9
  maxMarginUtilization: number;
10
10
  minLiquidationDistance: number;
11
+ maxDrawdownPct?: number;
12
+ maxPositionPct?: number;
13
+ maxExposurePct?: number;
14
+ dailyLossPct?: number;
11
15
  }
16
+ /** Resolve effective USD limit: min of fixed USD and pct-of-equity (if both set) */
17
+ export declare function effectiveLimit(usdLimit: number, pctLimit: number | undefined, totalEquity: number): number;
12
18
  /** Hard cap: liquidation distance can NEVER be set below this % */
13
19
  export declare const LIQUIDATION_DISTANCE_HARD_CAP = 20;
14
20
  export declare function loadRiskLimits(): RiskLimits;
package/dist/risk.js CHANGED
@@ -9,7 +9,16 @@ const DEFAULT_LIMITS = {
9
9
  maxLeverage: 20,
10
10
  maxMarginUtilization: 80,
11
11
  minLiquidationDistance: 30,
12
+ maxDrawdownPct: 10,
13
+ maxPositionPct: 25,
12
14
  };
15
+ /** Resolve effective USD limit: min of fixed USD and pct-of-equity (if both set) */
16
+ export function effectiveLimit(usdLimit, pctLimit, totalEquity) {
17
+ if (pctLimit == null || totalEquity <= 0)
18
+ return usdLimit;
19
+ const pctInUsd = (pctLimit / 100) * totalEquity;
20
+ return Math.min(usdLimit, pctInUsd);
21
+ }
13
22
  /** Hard cap: liquidation distance can NEVER be set below this % */
14
23
  export const LIQUIDATION_DISTANCE_HARD_CAP = 20;
15
24
  const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
@@ -87,32 +96,36 @@ export function assessRisk(balances, positions, limits) {
87
96
  maxLeverageUsed = lev;
88
97
  }
89
98
  const marginUtilization = totalEquity > 0 ? (totalMarginUsed / totalEquity) * 100 : 0;
99
+ // Resolve effective limits (min of USD and % of equity)
100
+ const effDrawdown = effectiveLimit(lim.maxDrawdownUsd, lim.maxDrawdownPct, totalEquity);
101
+ const effPosition = effectiveLimit(lim.maxPositionUsd, lim.maxPositionPct, totalEquity);
102
+ const effExposure = effectiveLimit(lim.maxTotalExposureUsd, lim.maxExposurePct, totalEquity);
90
103
  // Check violations
91
- if (totalUnrealizedPnl < -lim.maxDrawdownUsd) {
104
+ if (totalUnrealizedPnl < -effDrawdown) {
92
105
  violations.push({
93
106
  rule: "max_drawdown",
94
107
  severity: "critical",
95
- message: `Unrealized loss $${Math.abs(totalUnrealizedPnl).toFixed(2)} exceeds max drawdown $${lim.maxDrawdownUsd}`,
108
+ message: `Unrealized loss $${Math.abs(totalUnrealizedPnl).toFixed(2)} exceeds max drawdown $${effDrawdown.toFixed(2)}${lim.maxDrawdownPct != null ? ` (${lim.maxDrawdownPct}% of equity)` : ""}`,
96
109
  current: Math.abs(totalUnrealizedPnl),
97
- limit: lim.maxDrawdownUsd,
110
+ limit: effDrawdown,
98
111
  });
99
112
  }
100
- if (largestPositionUsd > lim.maxPositionUsd) {
113
+ if (largestPositionUsd > effPosition) {
101
114
  violations.push({
102
115
  rule: "max_position_size",
103
116
  severity: "high",
104
- message: `Largest position $${largestPositionUsd.toFixed(2)} exceeds limit $${lim.maxPositionUsd}`,
117
+ message: `Largest position $${largestPositionUsd.toFixed(2)} exceeds limit $${effPosition.toFixed(2)}${lim.maxPositionPct != null ? ` (${lim.maxPositionPct}% of equity)` : ""}`,
105
118
  current: largestPositionUsd,
106
- limit: lim.maxPositionUsd,
119
+ limit: effPosition,
107
120
  });
108
121
  }
109
- if (totalExposure > lim.maxTotalExposureUsd) {
122
+ if (totalExposure > effExposure) {
110
123
  violations.push({
111
124
  rule: "max_total_exposure",
112
125
  severity: "high",
113
- message: `Total exposure $${totalExposure.toFixed(2)} exceeds limit $${lim.maxTotalExposureUsd}`,
126
+ message: `Total exposure $${totalExposure.toFixed(2)} exceeds limit $${effExposure.toFixed(2)}${lim.maxExposurePct != null ? ` (${lim.maxExposurePct}% of equity)` : ""}`,
114
127
  current: totalExposure,
115
- limit: lim.maxTotalExposureUsd,
128
+ limit: effExposure,
116
129
  });
117
130
  }
118
131
  if (positions.length > lim.maxPositions) {
@@ -205,11 +218,14 @@ export function preTradeCheck(assessment, newOrderNotional, newOrderLeverage) {
205
218
  return { allowed: false, reason: "Trading suspended: critical risk violation active" };
206
219
  }
207
220
  const lim = assessment.limits;
208
- if (newOrderNotional > lim.maxPositionUsd) {
209
- return { allowed: false, reason: `Order notional $${newOrderNotional.toFixed(0)} exceeds max position size $${lim.maxPositionUsd}` };
210
- }
211
- if (assessment.metrics.totalExposure + newOrderNotional > lim.maxTotalExposureUsd) {
212
- return { allowed: false, reason: `Would exceed total exposure limit ($${(assessment.metrics.totalExposure + newOrderNotional).toFixed(0)} > $${lim.maxTotalExposureUsd})` };
221
+ const equity = assessment.metrics.totalEquity;
222
+ const effPosition = effectiveLimit(lim.maxPositionUsd, lim.maxPositionPct, equity);
223
+ const effExposure = effectiveLimit(lim.maxTotalExposureUsd, lim.maxExposurePct, equity);
224
+ if (newOrderNotional > effPosition) {
225
+ return { allowed: false, reason: `Order notional $${newOrderNotional.toFixed(0)} exceeds max position size $${effPosition.toFixed(0)}${lim.maxPositionPct != null ? ` (${lim.maxPositionPct}% of $${equity.toFixed(0)} equity)` : ""}` };
226
+ }
227
+ if (assessment.metrics.totalExposure + newOrderNotional > effExposure) {
228
+ return { allowed: false, reason: `Would exceed total exposure limit ($${(assessment.metrics.totalExposure + newOrderNotional).toFixed(0)} > $${effExposure.toFixed(0)})` };
213
229
  }
214
230
  if (assessment.metrics.positionCount + 1 > lim.maxPositions) {
215
231
  return { allowed: false, reason: `Would exceed max positions (${assessment.metrics.positionCount + 1} > ${lim.maxPositions})` };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter",
5
5
  "bin": {
6
6
  "perp": "./dist/index.js",
@@ -46,7 +46,7 @@
46
46
  "dotenv": "^16.5.0",
47
47
  "ethers": "^6.13.2",
48
48
  "hyperliquid": "^1.7.7",
49
- "lighter-sdk": "^0.0.17",
49
+ "lighter-ts-sdk": "^1.0.10",
50
50
  "tweetnacl": "^1.0.3",
51
51
  "ws": "^8.19.0",
52
52
  "yaml": "^2.8.2",
@@ -5,7 +5,7 @@ allowed-tools: "Bash(perp:*), Bash(npx perp-cli:*), Bash(npx -y perp-cli:*)"
5
5
  license: MIT
6
6
  metadata:
7
7
  author: hypurrquant
8
- version: "0.3.6"
8
+ version: "0.3.8"
9
9
  mcp-server: perp-cli
10
10
  ---
11
11
 
@@ -79,15 +79,52 @@ perp --json portfolio # unified multi-exchange view
79
79
 
80
80
  ### Trade execution (MANDATORY checklist)
81
81
  ```
82
+ BEFORE ANY TRADE:
83
+ 0. perp --json portfolio → check TOTAL equity + per-exchange balances
84
+ - Single position notional < 25% of TOTAL equity
85
+ - Each exchange MUST have sufficient balance for its leg
86
+ - Notional ≠ margin required. Check available balance on EACH exchange.
87
+ - If balance is insufficient, bridge first or reduce size.
88
+
82
89
  1. perp --json risk status → check risk level (STOP if critical)
83
- 2. perp --json -e <EX> account info → verify balance
90
+ 2. perp --json -e <EX> account info → verify EXCHANGE-SPECIFIC balance
84
91
  3. perp --json -e <EX> market mid <SYM> → current price
85
- 4. perp --json risk check --notional <$> --leverage <L> → risk pre-check
86
- 5. perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> trade validation
87
- 6. [Show order details + risk assessment to user, get explicit confirmation]
88
- 7. perp --json -e <EX> trade market <SYM> <SIDE> <SIZE> → execute
89
- 8. perp --json -e <EX> account positions → verify result + check liquidation price
92
+ 4. perp --json -e <EX> trade leverage <SYM> <N> --isolated set leverage + isolated margin FIRST
93
+ 5. perp --json risk check --notional <$> --leverage <L> → risk pre-check
94
+ 6. perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> --leverage <L> → trade validation
95
+ trade check does NOT read exchange-set leverage. ALWAYS pass --leverage explicitly.
96
+ 7. [Show order details + risk assessment to user, get explicit confirmation]
97
+ 8. perp --json -e <EX> trade market <SYM> <SIDE> <SIZE> → execute
98
+ 9. perp --json -e <EX> account positions → verify result + check liquidation price
99
+ ```
100
+
101
+ ### Post-entry monitoring (MANDATORY while positions are open)
102
+ ```
103
+ Every 15 minutes:
104
+ perp --json risk status → overall risk level + violations
105
+ perp --json risk liquidation-distance → % from liq price for ALL positions
106
+ perp --json -e <EX> account positions → check each position P&L
107
+
108
+ Every 1 hour (at funding settlement):
109
+ perp --json arb rates → is spread still profitable?
110
+ perp --json portfolio → total equity across exchanges
111
+ Compare both legs' unrealized P&L — they should roughly offset
112
+
113
+ Exit triggers:
114
+ - Spread below breakeven (including fees) → show exit plan, get user approval
115
+ - risk status level = "critical" or canTrade = false → reduce immediately
116
+ - One leg closed unexpectedly → close the other leg IMMEDIATELY
117
+ - Target hold duration reached → re-evaluate or exit
118
+ ```
119
+
120
+ ### Knowing your capital (CHECK BEFORE ANY DECISION)
121
+ ```
122
+ perp --json wallet show → configured wallets + addresses
123
+ perp --json wallet balance → on-chain USDC (in wallet, NOT on exchange)
124
+ perp --json -e <EX> account info → exchange balance (available for trading)
125
+ perp --json portfolio → unified view: equity, margin, P&L per exchange
90
126
  ```
127
+ **On-chain balance ≠ exchange balance.** Always check both. Capital must be deposited to exchange before trading.
91
128
 
92
129
  For full command reference, see `references/commands.md`.
93
130
  For agent-specific operations (setup flows, deposit/withdraw, order types, idempotency), see `references/agent-operations.md`.
@@ -47,6 +47,8 @@ perp --json trade edit <SYMBOL> <ORDER_ID> <PRICE> <SIZE>
47
47
  perp --json trade cancel <SYMBOL> <ORDER_ID>
48
48
  perp --json trade cancel-all
49
49
  perp --json trade check <SYMBOL> <SIDE> <SIZE> # pre-flight validation (no execution)
50
+ perp --json trade check <SYM> <SIDE> <SIZE> --leverage 3 # check with specific leverage
51
+ # NOTE: trade check does NOT read exchange-set leverage. Always pass --leverage explicitly.
50
52
 
51
53
  # Position management
52
54
  perp --json trade close <SYMBOL> # close single position
@@ -236,18 +236,24 @@ perp --json -e <EX> account settings # shows leverage and margin_mode pe
236
236
 
237
237
  ### Risk Limits — Configure Before Trading
238
238
 
239
- Set your risk limits FIRST, before any trading activity:
239
+ Set your risk limits FIRST, before any trading activity.
240
+ Limits can be set in **USD or % of equity** (when both are set, the stricter one applies):
240
241
  ```bash
241
242
  perp --json risk limits \
242
243
  --max-leverage 5 \
243
244
  --max-margin 60 \
245
+ --max-drawdown-pct 10 \
246
+ --max-position-pct 25 \
247
+ --max-drawdown 500 \
244
248
  --max-position 5000 \
245
249
  --max-exposure 20000 \
246
- --max-drawdown 500 \
247
250
  --daily-loss 200 \
248
251
  --min-liq-distance 30
249
252
  ```
250
253
 
254
+ **Use % limits for small portfolios.** A $65 portfolio with `--max-drawdown 500` is meaningless.
255
+ With `--max-drawdown-pct 10`, the effective limit auto-adjusts to $6.50.
256
+
251
257
  **IMPORTANT: Ask the user about their risk tolerance BEFORE setting limits.** Key questions:
252
258
  - "How much leverage are you comfortable with?" (default: 5x for arb)
253
259
  - "What's your maximum acceptable loss?" (default: $500)
@@ -310,6 +316,26 @@ Rules of thumb:
310
316
  - **Total margin used < 60% of total equity** — leave buffer for adverse moves
311
317
  - **Capital in transit (bridging) counts as "at risk"** — it's not available for margin
312
318
 
319
+ ### Per-Exchange Balance Constraints
320
+
321
+ **CRITICAL: You can only trade with the balance available ON THAT EXCHANGE.**
322
+
323
+ Each exchange holds its own separate balance. Before entering any arb:
324
+ ```bash
325
+ perp --json -e <EX_A> account info # check available balance on exchange A
326
+ perp --json -e <EX_B> account info # check available balance on exchange B
327
+ ```
328
+
329
+ **Size each leg to fit the available balance on that exchange:**
330
+ ```
331
+ Wrong: total portfolio = $65 → 25% = $16 per leg → but Exchange A only has $10
332
+ Right: Exchange A has $10, Exchange B has $15 → max leg size = $10 (limited by smaller side)
333
+ ```
334
+
335
+ If one exchange has insufficient balance:
336
+ - Reduce position size to fit the smaller balance, OR
337
+ - Bridge capital first (but account for bridge fees + time + unhedged risk during transit)
338
+
313
339
  ### Stop Loss for Arb Positions
314
340
 
315
341
  Even "market-neutral" arb can lose money if: