From 8a008b2d447be94a02499d95bd441c48612bf634 Mon Sep 17 00:00:00 2001 From: przemekink Date: Wed, 18 Mar 2026 12:43:04 +0100 Subject: [PATCH 1/4] fix math, add nlp info --- src/__tests__/handlers/trade.test.ts | 458 +++++++++++++++++++++++ src/__tests__/utils/orderBuilder.test.ts | 32 +- src/commands/trade.ts | 139 ++++++- src/handlers/account.ts | 138 ++++++- src/handlers/nlp.ts | 65 +++- src/handlers/trade.ts | 367 +++++++++++++++++- src/output/summaryTable.ts | 49 ++- src/utils/orderBuilder.ts | 88 ++++- src/utils/positionCalcs.ts | 72 ++++ 9 files changed, 1339 insertions(+), 69 deletions(-) create mode 100644 src/__tests__/handlers/trade.test.ts create mode 100644 src/utils/positionCalcs.ts diff --git a/src/__tests__/handlers/trade.test.ts b/src/__tests__/handlers/trade.test.ts new file mode 100644 index 0000000..de4028f --- /dev/null +++ b/src/__tests__/handlers/trade.test.ts @@ -0,0 +1,458 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ +import BigNumber from 'bignumber.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + placeOrderWithTpSl, + placeTpSlFromPosition, +} from '../../handlers/trade.js'; + +// --------------------------------------------------------------------------- +// Mock @nadohq/client +// --------------------------------------------------------------------------- + +const E18 = new BigNumber('1000000000000000000'); + +vi.mock('@nadohq/client', async () => { + const BN = (await import('bignumber.js')).default; + const _E18 = new BN('1000000000000000000'); + return { + addDecimals: (v: unknown) => new BN(String(v)).times(_E18), + removeDecimals: (v: unknown) => new BN(String(v)).dividedBy(_E18), + toBigDecimal: (v: bigint | number) => new BN(v.toString()), + getOrderNonce: () => '0xdeadbeef', + packOrderAppendix: (opts: Record) => + BigInt( + (opts.orderExecutionType === 'ioc' ? 1 : 0) | + (opts.reduceOnly ? 2 : 0), + ), + ProductEngineType: { PERP: 'perp', SPOT: 'spot' }, + }; +}); + +vi.mock('../../utils/requireSigner.js', () => ({ + requireSigner: vi.fn(), +})); + +vi.mock('../../utils/orderBuilder.js', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + buildEngineOrder: vi.fn().mockResolvedValue({ + productId: 1, + order: { + price: '50000', + amount: '10000000000000000', + expiration: 1700000000000, + nonce: '0xdeadbeef', + appendix: 1n, + }, + }), + buildPriceTriggerOrder: vi.fn().mockResolvedValue({ + productId: 1, + order: { + price: '51000', + amount: '10000000000000000', + expiration: 1700000000000, + nonce: '0xdeadbeef', + appendix: 3n, + }, + }), + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockCtx(overrides?: { + placeOrdersResult?: any; + placeTriggerOrderResult?: any; + summaryBalances?: any[]; + isolatedPositions?: any[]; +}): any { + return { + subaccountOwner: '0x1234567890abcdef1234567890abcdef12345678', + subaccountName: 'default', + client: { + market: { + getAllMarkets: vi.fn().mockResolvedValue([ + { + productId: 1, + priceIncrement: new BigNumber('0.1'), + sizeIncrement: new BigNumber('1000000000000000'), + }, + ]), + getLatestMarketPrice: vi.fn().mockResolvedValue({ + bid: new BigNumber(50000), + ask: new BigNumber(50100), + }), + placeOrders: vi.fn().mockResolvedValue( + overrides?.placeOrdersResult ?? { + status: 'success', + data: [{ digest: '0xorderdigest123', error: null }], + }, + ), + placeOrder: vi.fn().mockResolvedValue( + overrides?.placeOrdersResult ?? { + status: 'success', + data: { digest: '0xorderdigest123', error: null }, + }, + ), + placeTriggerOrder: vi.fn().mockResolvedValue( + overrides?.placeTriggerOrderResult ?? { + status: 'success', + data: { digest: '0xtriggerdigest456' }, + }, + ), + }, + subaccount: { + getSubaccountSummary: vi.fn().mockResolvedValue({ + balances: overrides?.summaryBalances ?? [], + }), + getIsolatedPositions: vi + .fn() + .mockResolvedValue(overrides?.isolatedPositions ?? []), + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// placeOrderWithTpSl +// --------------------------------------------------------------------------- + +describe('placeOrderWithTpSl', () => { + beforeEach(() => vi.clearAllMocks()); + + it('places main order + TP + SL trigger orders', async () => { + const ctx = mockCtx(); + + const result = await placeOrderWithTpSl(ctx, { + order: { + productId: 1, + side: 'long', + amount: 0.01, + slippagePct: 2, + }, + tp: 95000, + sl: 80000, + }); + + expect(ctx.client.market.placeOrders).toHaveBeenCalledTimes(1); + expect(ctx.client.market.placeTriggerOrder).toHaveBeenCalledTimes(2); + + const triggerCalls = ctx.client.market.placeTriggerOrder.mock.calls; + + // TP: long position → oracle_price_above + const tpCall = triggerCalls[0][0]; + expect(tpCall.triggerCriteria.criteria.type).toBe('oracle_price_above'); + expect(tpCall.triggerCriteria.criteria.triggerPrice).toBe(95000); + expect(tpCall.triggerCriteria.criteria.dependency).toBeUndefined(); + + // SL: long position → oracle_price_below + const slCall = triggerCalls[1][0]; + expect(slCall.triggerCriteria.criteria.type).toBe('oracle_price_below'); + expect(slCall.triggerCriteria.criteria.triggerPrice).toBe(80000); + expect(slCall.triggerCriteria.criteria.dependency).toBeUndefined(); + + const data = result.data as any; + expect(data.order).toBeDefined(); + expect(data.tp).toBeDefined(); + expect(data.sl).toBeDefined(); + }); + + it('places only TP when sl is not provided', async () => { + const ctx = mockCtx(); + + const result = await placeOrderWithTpSl(ctx, { + order: { + productId: 1, + side: 'short', + amount: 1.0, + slippagePct: 2, + }, + tp: 2000, + }); + + expect(ctx.client.market.placeTriggerOrder).toHaveBeenCalledTimes(1); + + // Short + TP → oracle_price_below + const tpCall = ctx.client.market.placeTriggerOrder.mock.calls[0][0]; + expect(tpCall.triggerCriteria.criteria.type).toBe('oracle_price_below'); + expect(tpCall.triggerCriteria.criteria.triggerPrice).toBe(2000); + + const data = result.data as any; + expect(data.tp).toBeDefined(); + expect(data.sl).toBeUndefined(); + }); + + it('places only SL when tp is not provided', async () => { + const ctx = mockCtx(); + + const result = await placeOrderWithTpSl(ctx, { + order: { + productId: 1, + side: 'long', + amount: 0.5, + slippagePct: 2, + }, + sl: 40000, + }); + + expect(ctx.client.market.placeTriggerOrder).toHaveBeenCalledTimes(1); + + // Long + SL → oracle_price_below + const slCall = ctx.client.market.placeTriggerOrder.mock.calls[0][0]; + expect(slCall.triggerCriteria.criteria.type).toBe('oracle_price_below'); + + const data = result.data as any; + expect(data.tp).toBeUndefined(); + expect(data.sl).toBeDefined(); + }); + + it('includes dependency for limit orders', async () => { + const ctx = mockCtx(); + + await placeOrderWithTpSl(ctx, { + order: { + productId: 1, + side: 'long', + amount: 0.01, + price: 49000, + slippagePct: 2, + }, + tp: 95000, + sl: 80000, + }); + + const triggerCalls = ctx.client.market.placeTriggerOrder.mock.calls; + + expect(triggerCalls[0][0].triggerCriteria.criteria.dependency).toEqual({ + digest: '0xorderdigest123', + onPartialFill: true, + }); + expect(triggerCalls[1][0].triggerCriteria.criteria.dependency).toEqual({ + digest: '0xorderdigest123', + onPartialFill: true, + }); + }); + + it('uses correct trigger types for short positions', async () => { + const ctx = mockCtx(); + + await placeOrderWithTpSl(ctx, { + order: { + productId: 1, + side: 'short', + amount: 0.01, + }, + tp: 2000, + sl: 3000, + }); + + const triggerCalls = ctx.client.market.placeTriggerOrder.mock.calls; + + // Short + TP → oracle_price_below + expect(triggerCalls[0][0].triggerCriteria.criteria.type).toBe( + 'oracle_price_below', + ); + // Short + SL → oracle_price_above + expect(triggerCalls[1][0].triggerCriteria.criteria.type).toBe( + 'oracle_price_above', + ); + }); +}); + +// --------------------------------------------------------------------------- +// placeTpSlFromPosition +// --------------------------------------------------------------------------- + +describe('placeTpSlFromPosition', () => { + beforeEach(() => vi.clearAllMocks()); + + it('auto-detects long cross position and places TP', async () => { + const ctx = mockCtx({ + summaryBalances: [ + { + productId: 1, + type: 'perp', + amount: new BigNumber('10000000000000000'), + oraclePrice: new BigNumber(50000), + }, + ], + }); + + const result = await placeTpSlFromPosition(ctx, { + productId: 1, + triggerPrice: 95000, + type: 'tp', + }); + + expect(ctx.client.market.placeTriggerOrder).toHaveBeenCalledTimes(1); + + const data = result.data as any; + expect(data.summary.positionSide).toBe('long'); + expect(data.summary.closeSide).toBe('short'); + expect(data.summary.triggerType).toBe('oracle_price_above'); + expect(data.summary.triggerPrice).toBe(95000); + }); + + it('auto-detects short cross position and places SL', async () => { + const ctx = mockCtx({ + summaryBalances: [ + { + productId: 1, + type: 'perp', + amount: new BigNumber('-10000000000000000'), + oraclePrice: new BigNumber(50000), + }, + ], + }); + + const result = await placeTpSlFromPosition(ctx, { + productId: 1, + triggerPrice: 55000, + type: 'sl', + }); + + const data = result.data as any; + expect(data.summary.positionSide).toBe('short'); + expect(data.summary.closeSide).toBe('long'); + // Short + SL → oracle_price_above + expect(data.summary.triggerType).toBe('oracle_price_above'); + }); + + it('auto-detects short position and places TP', async () => { + const ctx = mockCtx({ + summaryBalances: [ + { + productId: 1, + type: 'perp', + amount: new BigNumber('-10000000000000000'), + oraclePrice: new BigNumber(50000), + }, + ], + }); + + const result = await placeTpSlFromPosition(ctx, { + productId: 1, + triggerPrice: 45000, + type: 'tp', + }); + + const data = result.data as any; + // Short + TP → oracle_price_below + expect(data.summary.triggerType).toBe('oracle_price_below'); + expect(data.summary.closeSide).toBe('long'); + }); + + it('uses full position size when amount is omitted', async () => { + const positionAmount = new BigNumber('50000000000000000'); // 0.05 in x18 + const ctx = mockCtx({ + summaryBalances: [ + { + productId: 1, + type: 'perp', + amount: positionAmount, + oraclePrice: new BigNumber(50000), + }, + ], + }); + + const result = await placeTpSlFromPosition(ctx, { + productId: 1, + triggerPrice: 95000, + type: 'tp', + }); + + const data = result.data as any; + expect(data.summary.amount).toBeCloseTo(0.05); + }); + + it('uses explicit amount when provided', async () => { + const ctx = mockCtx({ + summaryBalances: [ + { + productId: 1, + type: 'perp', + amount: new BigNumber('100000000000000000'), + oraclePrice: new BigNumber(50000), + }, + ], + }); + + const result = await placeTpSlFromPosition(ctx, { + productId: 1, + triggerPrice: 95000, + type: 'tp', + amount: 0.005, + }); + + const data = result.data as any; + expect(data.summary.amount).toBe(0.005); + }); + + it('falls back to isolated position when no cross position exists', async () => { + const ctx = mockCtx({ + summaryBalances: [], + isolatedPositions: [ + { + baseBalance: { + productId: 1, + type: 'perp', + amount: new BigNumber('20000000000000000'), + oraclePrice: new BigNumber(50000), + }, + }, + ], + }); + + const result = await placeTpSlFromPosition(ctx, { + productId: 1, + triggerPrice: 60000, + type: 'tp', + }); + + const data = result.data as any; + expect(data.summary.positionSide).toBe('long'); + }); + + it('throws when no position is found', async () => { + const ctx = mockCtx({ + summaryBalances: [], + isolatedPositions: [], + }); + + await expect( + placeTpSlFromPosition(ctx, { + productId: 1, + triggerPrice: 95000, + type: 'tp', + }), + ).rejects.toThrow('No open position found'); + }); + + it('reports stop-limit order type when price is provided', async () => { + const ctx = mockCtx({ + summaryBalances: [ + { + productId: 1, + type: 'perp', + amount: new BigNumber('10000000000000000'), + oraclePrice: new BigNumber(50000), + }, + ], + }); + + const result = await placeTpSlFromPosition(ctx, { + productId: 1, + triggerPrice: 95000, + type: 'tp', + price: 94500, + }); + + const data = result.data as any; + expect(data.summary.orderType).toBe('stop-limit @ $94500'); + }); +}); diff --git a/src/__tests__/utils/orderBuilder.test.ts b/src/__tests__/utils/orderBuilder.test.ts index ee0d685..54ae75d 100644 --- a/src/__tests__/utils/orderBuilder.test.ts +++ b/src/__tests__/utils/orderBuilder.test.ts @@ -193,7 +193,7 @@ const AMOUNT_0_01_X18 = '10000000000000000'; describe('buildEngineOrder', () => { beforeEach(() => _resetMarketDataCache()); - it('builds a market long order (ioc, ask + slippage)', async () => { + it('builds a market long order (ioc, bid + slippage)', async () => { const client = mockClient(50000, 50100); const result = await buildEngineOrder({ client, @@ -204,11 +204,11 @@ describe('buildEngineOrder', () => { reduceOnly: false, }); - // ask 50100 * 1.02 = 51102, rounded to 0.1 increment = "51102" + // bid 50000 * 1.02 = 51000, rounded to 0.1 increment = "51000" expect(result).toEqual({ productId: 1, order: { - price: '51102', + price: '51000', amount: AMOUNT_0_01_X18, expiration: expect.any(Number), nonce: '0xdeadbeef', @@ -217,7 +217,7 @@ describe('buildEngineOrder', () => { }); }); - it('builds a market short order (ioc, bid - slippage)', async () => { + it('builds a market short order (ioc, ask - slippage)', async () => { const client = mockClient(50000, 50100); const result = await buildEngineOrder({ client, @@ -228,11 +228,11 @@ describe('buildEngineOrder', () => { reduceOnly: false, }); - // bid 50000 * 0.98 = 49000, rounded to 0.1 = "49000" + // ask 50100 * 0.98 = 49098, rounded to 0.1 = "49098" expect(result).toEqual({ productId: 1, order: { - price: '49000', + price: '49098', amount: `-${AMOUNT_0_01_X18}`, expiration: expect.any(Number), nonce: '0xdeadbeef', @@ -289,8 +289,8 @@ describe('buildEngineOrder', () => { }); }); - it('throws when ask is zero for a long market order', async () => { - const client = mockClient(50000, 0); + it('throws when bid is zero for a long market order', async () => { + const client = mockClient(0, 50100); await expect( buildEngineOrder({ client, @@ -299,11 +299,11 @@ describe('buildEngineOrder', () => { orderExecutionType: 'ioc', reduceOnly: false, }), - ).rejects.toThrow('No ask price'); + ).rejects.toThrow('No bid price'); }); - it('throws when bid is zero for a short market order', async () => { - const client = mockClient(0, 50100); + it('throws when ask is zero for a short market order', async () => { + const client = mockClient(50000, 0); await expect( buildEngineOrder({ client, @@ -312,7 +312,7 @@ describe('buildEngineOrder', () => { orderExecutionType: 'ioc', reduceOnly: false, }), - ).rejects.toThrow('No bid price'); + ).rejects.toThrow('No ask price'); }); it('throws for unknown product when no marketData provided', async () => { @@ -352,7 +352,7 @@ describe('buildEngineOrder', () => { describe('buildPriceTriggerOrder', () => { beforeEach(() => _resetMarketDataCache()); - it('builds a stop-market long trigger (ioc, ask + slippage, reduceOnly)', async () => { + it('builds a stop-market long trigger (ioc, bid + slippage, reduceOnly)', async () => { const client = mockClient(50000, 50100); const result = await buildPriceTriggerOrder({ client, @@ -362,11 +362,11 @@ describe('buildPriceTriggerOrder', () => { reduceOnly: true, }); - // no price -> market order uses ask * (1 + slippage) = 50100 * 1.02 = 51102 + // no price -> market order uses bid * (1 + slippage) = 50000 * 1.02 = 51000 expect(result).toEqual({ productId: 1, order: { - price: '51102', + price: '51000', amount: AMOUNT_0_01_X18, expiration: expect.any(Number), nonce: '0xdeadbeef', @@ -412,7 +412,7 @@ describe('buildPriceTriggerOrder', () => { expect(result).toEqual({ productId: 1, order: { - price: '51102', + price: '51000', amount: AMOUNT_0_01_X18, expiration: expect.any(Number), nonce: '0xdeadbeef', diff --git a/src/commands/trade.ts b/src/commands/trade.ts index 3624266..70352dc 100644 --- a/src/commands/trade.ts +++ b/src/commands/trade.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { Argument, Command, Option } from 'commander'; import { + cancelAllProductOrders, cancelAndPlace, cancelOrders, cancelProductOrders, @@ -11,7 +12,9 @@ import { closeAllPositions, closePosition, placeOrder, + placeOrderWithTpSl, placeTriggerOrder, + placeTpSlFromPosition, placeTwapOrder, type TriggerType, } from '../handlers/trade.js'; @@ -33,13 +36,15 @@ const TRIGGER_TYPES: TriggerType[] = [ export function createTradeCommand(): Command { const trade = new Command('trade').description( - 'Trading commands: long, short, buy, sell, close, cancel, twap, trigger', + 'Trading commands: long, short, buy, sell, close, cancel, twap, trigger, tp, sl', ); addPerpCommand(trade, 'long', 'long'); addPerpCommand(trade, 'short', 'short'); addSpotCommand(trade, 'buy', 'long'); addSpotCommand(trade, 'sell', 'short'); + addTpSlCommand(trade, 'tp', 'tp'); + addTpSlCommand(trade, 'sl', 'sl'); trade .command('close') @@ -163,7 +168,9 @@ export function createTradeCommand(): Command { trade .command('cancel-all') - .description('Cancel all open orders for a market') + .description( + 'Cancel all open orders and trigger orders (TP/SL/TWAP) for a market', + ) .argument('', 'Symbol, name, or product ID') .action(async (marketArg: string, _opts: unknown, cmd: Command) => { await runConfirmed(cmd, async (ctx) => { @@ -172,13 +179,14 @@ export function createTradeCommand(): Command { const summary = [ '', chalk.bold(` Cancel all orders on ${m.marketName}`), + chalk.dim(' Includes limit orders and trigger orders (TP/SL/TWAP)'), '', ].join('\n'); return { summary, execute: () => - cancelProductOrders(ctx, { productIds: [m.productId] }), + cancelAllProductOrders(ctx, { productIds: [m.productId] }), }; }); }); @@ -497,6 +505,8 @@ function addPerpCommand( .option('--tif ', 'Time in force: gtc, ioc, fok, post_only') .option('--reduce-only', 'Reduce-only order') .option('--slippage ', 'Slippage tolerance %', '2') + .option('--tp ', 'Take-profit trigger price') + .option('--sl ', 'Stop-loss trigger price') .action( async ( marketArg: string, @@ -508,6 +518,8 @@ function addPerpCommand( tif?: string; reduceOnly?: boolean; slippage: string; + tp?: string; + sl?: string; }, cmd: Command, ) => { @@ -519,6 +531,14 @@ function addPerpCommand( ); const amount = parseFloat(amountStr); const price = opts.price ? parseFloat(opts.price) : undefined; + const tp = opts.tp ? parseFloat(opts.tp) : undefined; + const sl = opts.sl ? parseFloat(opts.sl) : undefined; + const hasTpSl = tp != null || sl != null; + + const tpTriggerType = + side === 'long' ? 'oracle_price_above' : 'oracle_price_below'; + const slTriggerType = + side === 'long' ? 'oracle_price_below' : 'oracle_price_above'; const summary = [ '', @@ -529,29 +549,35 @@ function addPerpCommand( opts.tif ? ` TIF: ${opts.tif}` : null, opts.reduceOnly ? ' Reduce: yes' : null, !price ? ` Slippage: ${opts.slippage}%` : null, + tp != null + ? ` TP: $${tp} (${tpTriggerType}, stop-market)` + : null, + sl != null + ? ` SL: $${sl} (${slTriggerType}, stop-market)` + : null, '', ] .filter((line): line is string => line != null) .join('\n'); + const orderArgs = { + productId: m.productId, + side, + amount, + price, + marginMode: opts.margin as 'cross' | 'isolated', + leverage: opts.leverage ? parseFloat(opts.leverage) : undefined, + timeInForce: opts.tif, + reduceOnly: opts.reduceOnly ?? false, + slippagePct: parseFloat(opts.slippage), + }; + return { summary, execute: () => - placeOrder(ctx, { - orders: { - productId: m.productId, - side, - amount, - price, - marginMode: opts.margin as 'cross' | 'isolated', - leverage: opts.leverage - ? parseFloat(opts.leverage) - : undefined, - timeInForce: opts.tif, - reduceOnly: opts.reduceOnly ?? false, - slippagePct: parseFloat(opts.slippage), - }, - }), + hasTpSl + ? placeOrderWithTpSl(ctx, { order: orderArgs, tp, sl }) + : placeOrder(ctx, { orders: orderArgs }), }; }); }, @@ -626,3 +652,80 @@ function addSpotCommand( }, ); } + +function addTpSlCommand( + parent: Command, + name: 'tp' | 'sl', + type: 'tp' | 'sl', +): void { + const label = type === 'tp' ? 'Take-profit' : 'Stop-loss'; + + parent + .command(name) + .description( + `Place a ${label.toLowerCase()} trigger order on an existing position`, + ) + .argument('', 'Symbol, name, or product ID (e.g. BTC, ETH, 1)') + .argument('', 'Trigger price') + .argument('[amount]', 'Order size (omit to close entire position)') + .option('-p, --price ', 'Limit price (stop-limit instead of stop-market)') + .option('-m, --margin ', 'Margin mode: cross or isolated', 'cross') + .option('--leverage ', 'Leverage for isolated margin') + .option('--slippage ', 'Slippage tolerance %', '2') + .action( + async ( + marketArg: string, + triggerPriceStr: string, + amountStr: string | undefined, + opts: { + price?: string; + margin: string; + leverage?: string; + slippage: string; + }, + cmd: Command, + ) => { + await runConfirmed(cmd, async (ctx) => { + const m = await resolveMarketArg( + ctx, + marketArg, + ProductEngineType.PERP, + ); + const triggerPrice = parseFloat(triggerPriceStr); + const amount = amountStr ? parseFloat(amountStr) : undefined; + const price = opts.price ? parseFloat(opts.price) : undefined; + + const summary = [ + '', + chalk.bold(` ${label} on ${m.marketName}`), + ` Trigger: $${triggerPrice}`, + amount != null + ? ` Amount: ${amount}` + : ' Amount: full position', + ` Order: ${price ? `stop-limit @ $${price}` : 'stop-market'}`, + ` Margin: ${opts.margin}`, + opts.leverage ? ` Leverage: ${opts.leverage}x` : null, + !price ? ` Slippage: ${opts.slippage}%` : null, + '', + ] + .filter((line): line is string => line != null) + .join('\n'); + + return { + summary, + execute: () => + placeTpSlFromPosition(ctx, { + productId: m.productId, + triggerPrice, + type, + amount, + price, + marginMode: opts.margin as 'cross' | 'isolated', + leverage: opts.leverage ? parseFloat(opts.leverage) : undefined, + slippagePct: parseFloat(opts.slippage), + }), + }; + }); + }, + ); +} diff --git a/src/handlers/account.ts b/src/handlers/account.ts index 44c7078..748f08c 100644 --- a/src/handlers/account.ts +++ b/src/handlers/account.ts @@ -1,14 +1,29 @@ import type { NadoClient } from '@nadohq/client'; -import { ProductEngineType, removeDecimals } from '@nadohq/client'; +import { nowInSeconds, ProductEngineType, removeDecimals, toBigDecimal } from '@nadohq/client'; import type { IndexerMatchEvent } from '@nadohq/indexer-client'; import BigNumber from 'bignumber.js'; import type { NadoContext } from '../context.js'; import { ToolExecutionError } from '../utils/errors.js'; import { fmtNumber, fmtUsd } from '../utils/formatting.js'; +import { + calcEstimatedLiquidationPrice, + calcPnl, + calcPnlFrac, + calcPerpEntryCostBeforeLeverage, +} from '../utils/positionCalcs.js'; import { getMarkets } from '../utils/resolveMarket.js'; import type { CommandResult } from './types.js'; +export interface PositionEnrichment { + productId: number; + entryPrice: number | null; + exitPrice: number | null; + unrealizedPnl: number | null; + pnlFrac: number | null; + liquidationPrice: number | null; +} + // --------------------------------------------------------------------------- // get_subaccount_summary // --------------------------------------------------------------------------- @@ -37,6 +52,126 @@ export async function getSubaccountSummary( getMarkets(ctx.dataEnv, ctx.chainEnv).catch(() => []), ]); + // Collect perp product IDs for enrichment queries + const perpIds: number[] = []; + for (const b of summary.balances) { + if (b.type === ProductEngineType.PERP && !b.amount.isZero()) { + perpIds.push(b.productId); + } + } + for (const iso of isolatedPositions) { + if (!iso.baseBalance.amount.isZero()) { + perpIds.push(iso.baseBalance.productId); + } + } + + // Fetch snapshots (for entry price) and market prices (for exit price) in parallel + const [snapshotResp, marketPricesResp] = await Promise.all([ + perpIds.length > 0 + ? ctx.client.context.indexerClient + .getMultiSubaccountSnapshots({ + subaccounts: [ + { + subaccountOwner: args.subaccountOwner, + subaccountName: args.subaccountName, + }, + ], + timestamps: [nowInSeconds()], + }) + .catch(() => null) + : null, + perpIds.length > 0 + ? ctx.client.market + .getLatestMarketPrices({ productIds: perpIds }) + .catch(() => null) + : null, + ]); + + // Build lookup maps from the enrichment data + const netEntryByProduct = new Map(); + const longWeightInitByProduct = new Map(); + if (snapshotResp) { + const snapshotsByAccount = Object.values(snapshotResp.snapshots); + for (const byTimestamp of snapshotsByAccount) { + for (const snap of Object.values(byTimestamp)) { + for (const bal of snap.balances) { + const neu = bal.trackedVars?.netEntryUnrealized; + if (neu != null && !toBigDecimal(neu).isZero()) { + netEntryByProduct.set(bal.productId, toBigDecimal(neu)); + } + const lwi = bal.state?.market?.product?.longWeightInitial; + if (lwi != null) { + longWeightInitByProduct.set(bal.productId, toBigDecimal(lwi)); + } + } + } + } + } + + const exitPriceByProduct = new Map(); + if (marketPricesResp) { + for (const mp of marketPricesResp.marketPrices) { + exitPriceByProduct.set(mp.productId, { bid: mp.bid, ask: mp.ask }); + } + } + + // Compute per-position enrichments + const positionEnrichments = new Map(); + + for (const b of summary.balances) { + if (b.type !== ProductEngineType.PERP || b.amount.isZero()) continue; + + const amount = b.amount; + const isLong = amount.isPositive(); + const oracle = b.oraclePrice; + + const mp = exitPriceByProduct.get(b.productId); + const exitRaw = isLong ? mp?.bid : mp?.ask; + const exitPrice = exitRaw && exitRaw.gt(0) ? exitRaw : oracle; + + const netEntry = netEntryByProduct.get(b.productId); + let entryPrice: number | null = null; + let pnl: number | null = null; + let pnlFrac: number | null = null; + + if (netEntry && !amount.isZero()) { + entryPrice = netEntry.div(amount).abs().toNumber(); + const pnlBN = calcPnl(amount, exitPrice, netEntry); + pnl = removeDecimals(pnlBN).toNumber(); + + const lwi = longWeightInitByProduct.get(b.productId) ?? b.longWeightInitial; + if (lwi) { + const entryCost = calcPerpEntryCostBeforeLeverage(lwi, netEntry); + const entryCostDecimal = removeDecimals(entryCost); + pnlFrac = entryCostDecimal.gt(0.01) + ? calcPnlFrac(removeDecimals(pnlBN), entryCostDecimal).toNumber() + : null; + } + } + + const maintHealth = b.healthContributions?.maintenance ?? summary.health?.maintenance?.health; + let liquidationPrice: number | null = null; + if (maintHealth != null) { + const liq = calcEstimatedLiquidationPrice({ + maintHealth: summary.health.maintenance.health, + amount, + oraclePrice: oracle, + longWeightMaintenance: b.longWeightMaintenance, + shortWeightMaintenance: b.shortWeightMaintenance, + }); + liquidationPrice = liq ? liq.toNumber() : null; + } + + positionEnrichments.set(b.productId, { + productId: b.productId, + entryPrice, + exitPrice: exitPrice.toNumber(), + unrealizedPnl: pnl, + pnlFrac, + liquidationPrice, + }); + } + const symbolByProductId = new Map( markets.map((m) => [m.productId, m.symbol]), ); @@ -56,6 +191,7 @@ export async function getSubaccountSummary( ...b, })), isolatedPositions: enrichedIsolated, + positionEnrichments: Object.fromEntries(positionEnrichments), }; const nonZeroBalances = summary.balances.filter((b) => !b.amount.isZero()); diff --git a/src/handlers/nlp.ts b/src/handlers/nlp.ts index 727f61a..806223e 100644 --- a/src/handlers/nlp.ts +++ b/src/handlers/nlp.ts @@ -1,10 +1,14 @@ import { addDecimals } from '@nadohq/client'; +import BigNumber from 'bignumber.js'; import type { NadoContext } from '../context.js'; import { ToolExecutionError } from '../utils/errors.js'; import { requireSigner } from '../utils/requireSigner.js'; import type { CommandResult } from './types.js'; +const SEQUENCER_FEE_USDT = 1; +const NLP_PRODUCT_ID = 11; + // --------------------------------------------------------------------------- // get_nlp_pool_info // --------------------------------------------------------------------------- @@ -96,13 +100,18 @@ export async function mintNlp( requireSigner('mint_nlp', ctx); try { + const nlpOraclePrice = await getNlpOraclePrice(ctx); + const estimate = nlpOraclePrice + ? estimateMint(args.quoteAmount, nlpOraclePrice) + : null; + const data = await ctx.client.spot.mintNlp({ subaccountOwner: ctx.subaccountOwner, subaccountName: ctx.subaccountName, quoteAmount: addDecimals(args.quoteAmount), spotLeverage: args.spotLeverage, }); - return { data }; + return { data: { ...data, estimate } }; } catch (err) { throw new ToolExecutionError( 'mint_nlp', @@ -127,12 +136,17 @@ export async function burnNlp( requireSigner('burn_nlp', ctx); try { + const nlpOraclePrice = await getNlpOraclePrice(ctx); + const estimate = nlpOraclePrice + ? estimateBurn(args.nlpAmount, nlpOraclePrice) + : null; + const data = await ctx.client.spot.burnNlp({ subaccountOwner: ctx.subaccountOwner, subaccountName: ctx.subaccountName, nlpAmount: addDecimals(args.nlpAmount), }); - return { data }; + return { data: { ...data, estimate } }; } catch (err) { throw new ToolExecutionError( 'burn_nlp', @@ -141,3 +155,50 @@ export async function burnNlp( ); } } + +// --------------------------------------------------------------------------- +// Estimation helpers +// --------------------------------------------------------------------------- + +async function getNlpOraclePrice(ctx: NadoContext): Promise { + try { + const allMarkets = await ctx.client.market.getAllMarkets(); + const nlpMarket = allMarkets.find((m) => m.productId === NLP_PRODUCT_ID); + return nlpMarket?.product?.oraclePrice ?? null; + } catch { + return null; + } +} + +function estimateMint(quoteAmount: number, nlpOraclePrice: BigNumber) { + const afterFee = quoteAmount - SEQUENCER_FEE_USDT; + const estimatedNlpAmount = + afterFee > 0 ? new BigNumber(afterFee).div(nlpOraclePrice).toNumber() : 0; + + return { + inputUsdt: quoteAmount, + sequencerFeeUsdt: SEQUENCER_FEE_USDT, + nlpSharePrice: nlpOraclePrice.toNumber(), + estimatedNlpTokens: estimatedNlpAmount, + }; +} + +function estimateBurn(nlpAmount: number, nlpOraclePrice: BigNumber) { + const quoteAmount = new BigNumber(nlpAmount).times(nlpOraclePrice); + const nlpFee = BigNumber.min( + quoteAmount, + BigNumber.max(1, quoteAmount.times(0.001)), + ); + const totalFee = nlpFee.plus(SEQUENCER_FEE_USDT); + const estimatedUsdt = quoteAmount.minus(totalFee); + + return { + inputNlpTokens: nlpAmount, + nlpSharePrice: nlpOraclePrice.toNumber(), + grossQuoteUsdt: quoteAmount.toNumber(), + nlpFeeUsdt: nlpFee.toNumber(), + sequencerFeeUsdt: SEQUENCER_FEE_USDT, + totalFeeUsdt: totalFee.toNumber(), + estimatedOutputUsdt: estimatedUsdt.toNumber(), + }; +} diff --git a/src/handlers/trade.ts b/src/handlers/trade.ts index ce7ca22..7e963c3 100644 --- a/src/handlers/trade.ts +++ b/src/handlers/trade.ts @@ -26,6 +26,10 @@ import { import { requireSigner } from '../utils/requireSigner.js'; import type { CommandResult } from './types.js'; +type TpSlTriggerType = + | 'oracle_price_above' + | 'oracle_price_below'; + const REDUCE_ONLY_IOC_APPENDIX = packOrderAppendix({ orderExecutionType: 'ioc', reduceOnly: true, @@ -129,6 +133,245 @@ export async function placeOrder( } } +// --------------------------------------------------------------------------- +// place_order with inline TP/SL +// --------------------------------------------------------------------------- + +export interface PlaceOrderWithTpSlArgs { + order: PlaceOrderSingle; + tp?: number; + sl?: number; +} + +export async function placeOrderWithTpSl( + ctx: NadoContext, + args: PlaceOrderWithTpSlArgs, +): Promise { + requireSigner('place_order', ctx); + + const orderResult = await placeOrder(ctx, { orders: args.order }); + + const resultData = orderResult.data as { + data?: Array<{ digest?: string }> | { digest?: string }; + }; + const digest: string | undefined = Array.isArray(resultData.data) + ? resultData.data[0]?.digest + : resultData.data?.digest; + const isLimitOrder = args.order.price != null; + const closeSide: BalanceSide = + args.order.side === 'long' ? 'short' : 'long'; + + const buildTriggerArgs = ( + triggerPrice: number, + triggerType: TpSlTriggerType, + ): PlaceTriggerOrderArgs => ({ + productId: args.order.productId, + side: closeSide, + amount: args.order.amount, + triggerPrice, + triggerType, + reduceOnly: true, + marginMode: args.order.marginMode, + leverage: args.order.leverage, + slippagePct: args.order.slippagePct, + }); + + const dependency = + isLimitOrder && digest + ? { digest, onPartialFill: true } + : undefined; + + const triggerPromises: Promise[] = []; + + if (args.tp != null) { + const tpTriggerType: TpSlTriggerType = + args.order.side === 'long' ? 'oracle_price_above' : 'oracle_price_below'; + triggerPromises.push( + placeTriggerOrderWithDependency( + ctx, + buildTriggerArgs(args.tp, tpTriggerType), + dependency, + ), + ); + } + + if (args.sl != null) { + const slTriggerType: TpSlTriggerType = + args.order.side === 'long' ? 'oracle_price_below' : 'oracle_price_above'; + triggerPromises.push( + placeTriggerOrderWithDependency( + ctx, + buildTriggerArgs(args.sl, slTriggerType), + dependency, + ), + ); + } + + const triggerResults = await Promise.all(triggerPromises); + + return { + data: { + order: orderResult.data, + tp: args.tp != null ? triggerResults.shift()?.data : undefined, + sl: args.sl != null ? triggerResults.shift()?.data : undefined, + }, + }; +} + +async function placeTriggerOrderWithDependency( + ctx: NadoContext, + args: PlaceTriggerOrderArgs, + dependency?: { digest: string; onPartialFill: boolean }, +): Promise { + requireSigner('place_trigger_order', ctx); + + try { + const orderParams = await buildPriceTriggerOrder({ + client: ctx.client, + productId: args.productId, + amount: args.side === 'short' ? -args.amount : args.amount, + price: args.price, + slippagePct: args.slippagePct ?? DEFAULT_SLIPPAGE_PCT, + reduceOnly: args.reduceOnly ?? true, + marginMode: args.marginMode ?? 'cross', + leverage: args.leverage, + }); + + const data = await ctx.client.market.placeTriggerOrder({ + productId: orderParams.productId, + order: { + subaccountOwner: ctx.subaccountOwner, + subaccountName: ctx.subaccountName, + ...orderParams.order, + }, + triggerCriteria: { + type: 'price' as const, + criteria: { + triggerPrice: args.triggerPrice, + type: args.triggerType, + dependency, + }, + }, + borrowMargin: args.borrowMargin, + }); + return { data }; + } catch (err) { + throw new ToolExecutionError( + 'place_trigger_order', + `Failed to place TP/SL trigger ${args.side} order for product ${args.productId}`, + err, + ); + } +} + +// --------------------------------------------------------------------------- +// place TP/SL from existing position +// --------------------------------------------------------------------------- + +export interface PlaceTpSlFromPositionArgs { + productId: number; + triggerPrice: number; + type: 'tp' | 'sl'; + amount?: number; + price?: number; + slippagePct?: number; + marginMode?: 'cross' | 'isolated'; + leverage?: number; +} + +export async function placeTpSlFromPosition( + ctx: NadoContext, + args: PlaceTpSlFromPositionArgs, +): Promise { + requireSigner('place_trigger_order', ctx); + + try { + const [summary, isolatedPositions] = await Promise.all([ + ctx.client.subaccount.getSubaccountSummary({ + subaccountOwner: ctx.subaccountOwner, + subaccountName: ctx.subaccountName, + }), + ctx.client.subaccount + .getIsolatedPositions({ + subaccountOwner: ctx.subaccountOwner, + subaccountName: ctx.subaccountName, + }) + .catch(() => []), + ]); + + const crossBalance = summary.balances.find( + (b) => + b.productId === args.productId && b.type === ProductEngineType.PERP, + ); + const hasCrossPosition = crossBalance && !crossBalance.amount.isZero(); + + const isolatedPos = isolatedPositions.find( + (p) => + p.baseBalance.productId === args.productId && + !p.baseBalance.amount.isZero(), + ); + + if (!hasCrossPosition && !isolatedPos) { + throw new Error( + `No open position found for product ${args.productId}. ` + + 'Use "nado account summary" to check current positions.', + ); + } + + const balance = hasCrossPosition ? crossBalance : isolatedPos!.baseBalance; + const positionSide: BalanceSide = balance.amount.isPositive() + ? 'long' + : 'short'; + const closeSide: BalanceSide = positionSide === 'long' ? 'short' : 'long'; + + const isTriggerPriceAbove = + positionSide === 'long' + ? args.type === 'tp' + : args.type !== 'tp'; + const triggerType: TpSlTriggerType = isTriggerPriceAbove + ? 'oracle_price_above' + : 'oracle_price_below'; + + const positionSize = removeDecimals(balance.amount.abs()).toNumber(); + const amount = args.amount ?? positionSize; + + const result = await placeTriggerOrder(ctx, { + productId: args.productId, + side: closeSide, + amount, + triggerPrice: args.triggerPrice, + triggerType, + reduceOnly: true, + marginMode: args.marginMode, + leverage: args.leverage, + price: args.price, + slippagePct: args.slippagePct, + }); + + return { + data: { + ...result.data as Record, + summary: { + type: args.type, + productId: args.productId, + positionSide, + closeSide, + triggerType, + triggerPrice: args.triggerPrice, + amount, + orderType: args.price ? `stop-limit @ $${args.price}` : 'stop-market', + }, + }, + }; + } catch (err) { + throw new ToolExecutionError( + 'place_trigger_order', + `Failed to place ${args.type.toUpperCase()} for product ${args.productId}`, + err, + ); + } +} + // --------------------------------------------------------------------------- // cancel_and_place // --------------------------------------------------------------------------- @@ -262,15 +505,25 @@ export async function closePosition( ); if (!hasCrossPosition && !isolatedPos) { - throw new Error( - `No open position found for product ${args.productId} (checked both cross and isolated margin). ` + - 'Use "nado account summary" to check current positions.', - ); + return { + data: { + summary: { + productId: args.productId, + positionsClosed: 0, + note: + 'No open position found (checked both cross and isolated margin). ' + + 'Cancelled any remaining orders and trigger orders (TP/SL).', + }, + }, + }; } - // 3. Build close order using oracle price (like e2e cleanup) + // 3. Build close order using orderbook price with slippage const balance = hasCrossPosition ? crossBalance : isolatedPos!.baseBalance; const marginMode = hasCrossPosition ? 'cross' : 'isolated'; + const marketPrice = await ctx.client.market.getLatestMarketPrice({ + productId: args.productId, + }); const orderParams = buildCloseOrderFromBalance( balance, marketByProductId, @@ -278,6 +531,7 @@ export async function closePosition( marginMode, ctx.subaccountOwner, ctx.subaccountName, + marketPrice, ); const result = await ctx.client.market.placeOrder(orderParams); @@ -359,10 +613,32 @@ export async function closeAllPositions( }); if (crossPositions.length === 0 && filteredIsolated.length === 0) { - throw new Error( - 'No open perp positions found (checked both cross and isolated margin). ' + - 'Use "nado account summary" to verify.', - ); + // No open positions — clean up any orphaned trigger orders + // (e.g. leftover TP after SL closed the position, or vice versa) + const cleanupProductIds = + args.productIds ?? + allMarkets + .filter((m) => m.type === ProductEngineType.PERP) + .map((m) => m.productId); + + if (cleanupProductIds.length > 0) { + await ctx.client.market + .cancelTriggerProductOrders({ + subaccountOwner: ctx.subaccountOwner, + subaccountName: ctx.subaccountName, + productIds: cleanupProductIds, + }) + .catch(warnOnCancelFailure('cancel orphaned trigger orders')); + } + + return { + data: { + summary: { + positionsClosed: 0, + note: 'No open perp positions found. Cancelled any remaining trigger orders (TP/SL).', + }, + }, + }; } // 2. Cancel open orders on affected products to free margin @@ -390,9 +666,19 @@ export async function closeAllPositions( .catch(warnOnCancelFailure('cancel trigger orders')), ]); - // 3. Build close orders using oracle prices + // 3. Fetch orderbook prices and build close orders const marketByProductId = new Map(allMarkets.map((m) => [m.productId, m])); + const pricesResp = await ctx.client.market.getLatestMarketPrices({ + productIds: affectedProductIds, + }); + const marketPriceByProductId = new Map( + pricesResp.marketPrices.map((mp: { productId: number; bid: BigNumber; ask: BigNumber }) => [ + mp.productId, + { bid: mp.bid, ask: mp.ask }, + ]), + ); + const closeOrders = [ ...crossPositions.map((b) => buildCloseOrderFromBalance( @@ -402,6 +688,7 @@ export async function closeAllPositions( 'cross', ctx.subaccountOwner, ctx.subaccountName, + marketPriceByProductId.get(b.productId), ), ), ...filteredIsolated.map((p) => @@ -412,6 +699,7 @@ export async function closeAllPositions( 'isolated', ctx.subaccountOwner, ctx.subaccountName, + marketPriceByProductId.get(p.baseBalance.productId), ), ), ]; @@ -456,9 +744,12 @@ export async function closeAllPositions( } /** - * Builds a close order from a position balance using the oracle price. - * Follows the same pattern as the SDK e2e cleanup: negate the balance, - * round to market increments, apply slippage to oracle price. + * Builds a close order from a position balance using the orderbook price. + * Uses bid/ask from the orderbook with slippage applied, matching the trade + * app behavior. Falls back to oracle price when the orderbook side is empty. + * + * Close long (sell): safeAsk × (1 − slippage) + * Close short (buy): safeBid × (1 + slippage) */ function buildCloseOrderFromBalance( balance: BalanceWithProduct, @@ -470,6 +761,7 @@ function buildCloseOrderFromBalance( marginMode: 'cross' | 'isolated', subaccountOwner: string, subaccountName: string, + marketPrice?: { bid: BigNumber; ask: BigNumber }, ): { productId: number; order: { @@ -501,8 +793,14 @@ function buildCloseOrderFromBalance( const slippageMultiplier = isBuy ? 1 + slippagePct / 100 : 1 - slippagePct / 100; + + // Use orderbook bid (close short) or ask (close long), fall back to oracle + const basePrice = isBuy + ? (marketPrice?.bid.gt(0) ? marketPrice.bid : balance.oraclePrice) + : (marketPrice?.ask.gt(0) ? marketPrice.ask : balance.oraclePrice); + const closePrice = roundToIncrement( - balance.oraclePrice.times(slippageMultiplier), + basePrice.times(slippageMultiplier), market.priceIncrement, ); @@ -587,6 +885,47 @@ export async function cancelProductOrders( } } +// --------------------------------------------------------------------------- +// cancel_all_product_orders (engine + trigger) +// --------------------------------------------------------------------------- + +export async function cancelAllProductOrders( + ctx: NadoContext, + args: CancelProductOrdersArgs, +): Promise { + requireSigner('cancel_product_orders', ctx); + + try { + const [engineResult, triggerResult] = await Promise.allSettled([ + ctx.client.market.cancelProductOrders({ + subaccountOwner: ctx.subaccountOwner, + subaccountName: ctx.subaccountName, + productIds: args.productIds, + }), + ctx.client.market.cancelTriggerProductOrders({ + subaccountOwner: ctx.subaccountOwner, + subaccountName: ctx.subaccountName, + productIds: args.productIds, + }), + ]); + + return { + data: { + orders: + engineResult.status === 'fulfilled' ? engineResult.value : null, + triggerOrders: + triggerResult.status === 'fulfilled' ? triggerResult.value : null, + }, + }; + } catch (err) { + throw new ToolExecutionError( + 'cancel_product_orders', + `Failed to cancel orders for products ${fmtProductIds(args.productIds)}. Use get_all_markets to verify product IDs.`, + err, + ); + } +} + // --------------------------------------------------------------------------- // cancel_trigger_orders // --------------------------------------------------------------------------- diff --git a/src/output/summaryTable.ts b/src/output/summaryTable.ts index 3a43a5c..869b8be 100644 --- a/src/output/summaryTable.ts +++ b/src/output/summaryTable.ts @@ -34,6 +34,14 @@ interface IsolatedPosition { }; } +interface PositionEnrichment { + entryPrice: number | null; + exitPrice: number | null; + unrealizedPnl: number | null; + pnlFrac: number | null; + liquidationPrice: number | null; +} + interface Summary { health: { initial: HealthBucket; @@ -41,6 +49,7 @@ interface Summary { }; balances: Balance[]; isolatedPositions: IsolatedPosition[]; + positionEnrichments?: Record; } const fromX18 = (v: string) => removeDecimals(v).toNumber(); @@ -52,9 +61,30 @@ function fmtAmt(n: number): string { return fmtNumber(n, Math.abs(n) >= 0.001 ? 6 : 8); } +function fmtPnl(n: number | null): string { + if (n == null) return chalk.dim('-'); + const prefix = n >= 0 ? '+' : ''; + const str = `${prefix}${fmtUsd(n)}`; + return n >= 0 ? chalk.green(str) : chalk.red(str); +} + +function fmtPnlPct(frac: number | null): string { + if (frac == null) return chalk.dim('-'); + const pct = frac * 100; + const prefix = pct >= 0 ? '+' : ''; + const str = `${prefix}${pct.toFixed(2)}%`; + return pct >= 0 ? chalk.green(str) : chalk.red(str); +} + +function fmtPrice(n: number | null): string { + if (n == null) return chalk.dim('-'); + return fmtUsd(n); +} + export function formatSummaryTable(data: unknown): string { const d = toPrintableObject(data) as Summary; const sections: string[] = []; + const enrichments = d.positionEnrichments ?? {}; const healthTable = new Table({ head: ['', 'Health', 'Assets', 'Liabilities'].map((h) => chalk.cyan(h)), @@ -114,9 +144,17 @@ export function formatSummaryTable(data: unknown): string { if (crossPerps.length > 0) { const crossTable = new Table({ - head: ['Market', 'Side', 'Size', 'Oracle', 'Notional'].map((h) => - chalk.cyan(h), - ), + head: [ + 'Market', + 'Side', + 'Size', + 'Entry', + 'Oracle', + 'Notional', + 'uPnL', + 'uPnL %', + 'Liq Price', + ].map((h) => chalk.cyan(h)), }); for (const b of crossPerps) { @@ -124,13 +162,18 @@ export function formatSummaryTable(data: unknown): string { const side = size > 0 ? 'long' : 'short'; const oracle = parseFloat(b.oraclePrice); const notional = Math.abs(size) * oracle; + const e = enrichments[b.productId]; crossTable.push([ b.symbol ?? `#${b.productId}`, side, fmtAmt(Math.abs(size)), + fmtPrice(e?.entryPrice ?? null), fmtUsd(oracle), fmtUsd(notional), + fmtPnl(e?.unrealizedPnl ?? null), + fmtPnlPct(e?.pnlFrac ?? null), + fmtPrice(e?.liquidationPrice ?? null), ]); } diff --git a/src/utils/orderBuilder.ts b/src/utils/orderBuilder.ts index fa0bd98..59a1197 100644 --- a/src/utils/orderBuilder.ts +++ b/src/utils/orderBuilder.ts @@ -49,6 +49,8 @@ export interface BuiltOrderParams { export interface MarketData { priceIncrement: BigNumber; sizeIncrement: BigNumber; + maxLeverage?: number; + oraclePrice?: BigNumber; } let allMarketsCache: @@ -73,9 +75,18 @@ export async function resolveMarketData( `Unknown product ${productId}. Use get_all_markets to find valid product IDs.`, ); } + + const longWeightInitial = market.product?.longWeightInitial; + const maxLeverage = + longWeightInitial && !longWeightInitial.eq(1) + ? Math.round(1 / (1 - longWeightInitial.toNumber())) + : undefined; + return { priceIncrement: market.priceIncrement, sizeIncrement: market.sizeIncrement, + maxLeverage, + oraclePrice: market.product?.oraclePrice, }; } @@ -112,27 +123,31 @@ async function resolvePrice( const marketPrice = await client.market.getLatestMarketPrice({ productId }); const slippageFrac = slippagePct / 100; + // Match the trade app: start from the same-side top of book and use + // slippage to cross the spread. + // Buy (long): safeBid × (1 + slippage) + // Sell (short): safeAsk × (1 − slippage) if (isLong) { - const askPrice = marketPrice.ask; - if (askPrice.lte(0)) { + const bidPrice = marketPrice.bid; + if (bidPrice.lte(0)) { throw new Error( - `No ask price available for product ${productId}. Cannot place market buy order.`, + `No bid price available for product ${productId}. Cannot place market buy order.`, ); } return roundToIncrement( - askPrice.times(1 + slippageFrac), + bidPrice.times(1 + slippageFrac), priceIncrement, ).toFixed(); } - const bidPrice = marketPrice.bid; - if (bidPrice.lte(0)) { + const askPrice = marketPrice.ask; + if (askPrice.lte(0)) { throw new Error( - `No bid price available for product ${productId}. Cannot place market sell order.`, + `No ask price available for product ${productId}. Cannot place market sell order.`, ); } return roundToIncrement( - bidPrice.times(1 - slippageFrac), + askPrice.times(1 - slippageFrac), priceIncrement, ).toFixed(); } @@ -143,16 +158,49 @@ function resolveIsolatedMargin( leverage: number | undefined, amount: number, resolvedPrice: string, + opts?: { + isMarketOrder: boolean; + oraclePrice?: BigNumber; + maxLeverage?: number; + }, ): { margin: BigDecimalish } | undefined { if (marginMode !== 'isolated') return undefined; if (reduceOnly) return { margin: 0 }; if (leverage == null) { throw new Error('leverage is required when marginMode is "isolated".'); } + + const effectiveLeverage = + opts?.maxLeverage != null + ? Math.min(leverage, opts.maxLeverage - 0.2) + : leverage; + + const signedAmount = toBigDecimal(amount); + const orderPrice = toBigDecimal(resolvedPrice); + + const marginWithoutInitialPnl = signedAmount + .times(orderPrice) + .dividedBy(effectiveLeverage) + .abs(); + + // For market orders, adjust margin to offset potential negative PnL from + // oracle-to-orderbook price deviation (matching trade app behavior). + let takerMarginAdjustment = new BigNumber(0); + if (opts?.isMarketOrder && opts.oraclePrice) { + const weight = signedAmount.isPositive() + ? 1 - 1 / effectiveLeverage + : 1 + 1 / effectiveLeverage; + takerMarginAdjustment = BigNumber.max( + signedAmount + .negated() + .times(opts.oraclePrice.minus(orderPrice)) + .times(weight), + 0, + ); + } + return { - margin: addDecimals( - toBigDecimal(Math.abs(amount)).times(resolvedPrice).dividedBy(leverage), - ), + margin: addDecimals(marginWithoutInitialPnl.plus(takerMarginAdjustment)), }; } @@ -188,8 +236,8 @@ export async function buildEngineOrder( } = input; const isLong = amount > 0; - const { priceIncrement, sizeIncrement } = - marketData ?? (await resolveMarketData(client, productId)); + const resolved = marketData ?? (await resolveMarketData(client, productId)); + const { priceIncrement, sizeIncrement } = resolved; const { signedAmountX18 } = resolveAmount(amount, sizeIncrement); const resolvedPrice = await resolvePrice( client, @@ -205,6 +253,11 @@ export async function buildEngineOrder( leverage, amount, resolvedPrice, + { + isMarketOrder: price == null, + oraclePrice: resolved.oraclePrice, + maxLeverage: resolved.maxLeverage, + }, ); const appendix = packOrderAppendix({ @@ -257,8 +310,8 @@ export async function buildPriceTriggerOrder( } = input; const isLong = amount > 0; - const { priceIncrement, sizeIncrement } = - marketData ?? (await resolveMarketData(client, productId)); + const resolved = marketData ?? (await resolveMarketData(client, productId)); + const { priceIncrement, sizeIncrement } = resolved; const { signedAmountX18 } = resolveAmount(amount, sizeIncrement); const resolvedPrice = await resolvePrice( client, @@ -274,6 +327,11 @@ export async function buildPriceTriggerOrder( leverage, amount, resolvedPrice, + { + isMarketOrder: price == null, + oraclePrice: resolved.oraclePrice, + maxLeverage: resolved.maxLeverage, + }, ); const executionType = price == null ? 'ioc' : 'default'; diff --git a/src/utils/positionCalcs.ts b/src/utils/positionCalcs.ts new file mode 100644 index 0000000..ae1c7ed --- /dev/null +++ b/src/utils/positionCalcs.ts @@ -0,0 +1,72 @@ +import BigNumber from 'bignumber.js'; + +/** + * Calculates the estimated liquidation price of a perp position. + * + * Long: oraclePrice − (maintHealth / amount / longWeightMaintenance) + * Short: oraclePrice + (maintHealth / |amount| / shortWeightMaintenance) + * + * Returns null when the position is zero or liquidation price is unreasonable + * (negative for longs, or >10× oracle for shorts). + */ +export function calcEstimatedLiquidationPrice(params: { + maintHealth: BigNumber; + amount: BigNumber; + oraclePrice: BigNumber; + longWeightMaintenance: BigNumber; + shortWeightMaintenance: BigNumber; +}): BigNumber | null { + const { maintHealth, amount, oraclePrice, longWeightMaintenance, shortWeightMaintenance } = + params; + + if (amount.isZero()) return null; + + if (amount.isPositive()) { + const liqPrice = oraclePrice.minus( + maintHealth.div(amount).div(longWeightMaintenance), + ); + return liqPrice.lte(0) ? null : liqPrice; + } + + const liqPrice = oraclePrice.plus( + maintHealth.div(amount.abs()).div(shortWeightMaintenance), + ); + return liqPrice.gte(oraclePrice.times(10)) ? null : liqPrice; +} + +/** + * Calculates unrealized PnL: positionAmount × price − netEntry + */ +export function calcPnl( + positionAmount: BigNumber, + price: BigNumber, + netEntry: BigNumber, +): BigNumber { + return positionAmount.times(price).minus(netEntry); +} + +/** + * Calculates PnL fraction: pnl / |denominator|, returning zero when + * denominator is effectively zero. + */ +export function calcPnlFrac( + pnl: BigNumber, + denominator: BigNumber, +): BigNumber { + if (denominator.abs().lte(0.01)) return new BigNumber(0); + return pnl.div(denominator.abs()); +} + +/** + * Computes the entry cost before leverage for a perp position. + * + * leverageAdjustment = 1 − longWeightInitial + * entryCost = |netEntry| × leverageAdjustment + */ +export function calcPerpEntryCostBeforeLeverage( + longWeightInitial: BigNumber, + netEntryUnrealized: BigNumber, +): BigNumber { + const leverageAdjustment = new BigNumber(1).minus(longWeightInitial); + return netEntryUnrealized.abs().times(leverageAdjustment); +} From 2a6b19c67778e4b63e6f9d9d5ddd44abce3ce177 Mon Sep 17 00:00:00 2001 From: przemekink Date: Wed, 18 Mar 2026 13:03:58 +0100 Subject: [PATCH 2/4] Add legal disclaimer, update README, update SKILLS file --- DISCLAIMER.md | 66 +++++++ README.md | 398 ++++++++++++++++++++++++++++++++++--------- package.json | 1 + skills/nado/SKILL.md | 33 +++- 4 files changed, 408 insertions(+), 90 deletions(-) create mode 100644 DISCLAIMER.md diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 0000000..16cb1cf --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,66 @@ +# Disclaimer (CLI) + +## Experimental Software + +This software is experimental and under active development. It is provided "as is" without warranty of any kind, express or implied. Use it at your own risk. + +## Not Financial Advice + +This Command Line Interface ("CLI") is a command-line interface for the Nado decentralized exchange. It does not provide financial advice, trading recommendations, or investment guidance. It executes commands exactly as instructed. Any trading decisions are yours alone. + +## Real Assets, Real Risk + +Commands executed through this CLI interact with Nado's live decentralized exchange protocols and can result in real financial transactions. Swaps, transfers, liquidity operations, and other on-chain actions are irreversible once confirmed on the blockchain. Incorrect commands, software bugs, smart contract vulnerabilities, or agent errors can result in partial or total loss of funds. + +Before using this tool with real assets: + +- Test your workflows on supported testnets when available. +- Validate transactions before signing and broadcasting. +- Use wallet connections and API credentials with only the permissions you need. +- Start with small amounts. +- Understand the smart contracts and protocols you are interacting with. + +## AI Agent Use + +When used by AI agents or automated systems, the same risks apply. The agent executes commands based on its programming and the instructions it receives. Neither the CLI nor the agent validates whether a trade or transaction is financially sound. + +If you grant an AI agent access to your wallet or credentials: + +- You are responsible for all actions the agent takes on your behalf. +- The agent can initiate swaps, provide or remove liquidity, approve token spending, and execute other on-chain transactions as permitted by the granted access. +- Use the `[dangerous]` field in the tool catalog to identify high-risk commands. +- Restrict wallet permissions and token approvals to the minimum necessary for your use case. +- Monitor agent sessions actively; do not leave agents unattended with broad transaction authority. + +## Decentralized Exchange Risks + +In addition to the risks above, use of a decentralized exchange carries inherent risks including but not limited to: + +- Smart contract bugs or exploits. +- Impermanent loss when providing liquidity. +- Slippage, front-running, and MEV (maximal extractable value) attacks. +- Token contract risks, including malicious or compromised tokens. +- Network congestion, failed transactions, and gas fee volatility. +- Regulatory uncertainty in your jurisdiction. + +You are solely responsible for understanding these risks before transacting. + +## Liability + +Spira Arc Inc., its authors, and contributors accept no liability for financial losses, failed transactions, incorrect executions, smart contract exploits, or any other damages resulting from the use of this software, whether used manually or by an automated agent. + +This software is provided without any guarantee of uptime, accuracy, or fitness for a particular purpose. + +## Support and Responsible Disclosure + +This tool is open-sourced under the MIT license by Spira Arc Inc. (Nado). Bug reports and feature requests are handled through [GitHub Issues](https://github.com/nadohq/nado-cli/issues). For security vulnerabilities, please follow responsible disclosure practices and contact the team directly before filing a public issue, by contacting [email]. + +## Credential and Wallet Security + +Your API credentials, wallet private keys, and seed phrases grant access to your funds. Treat them like passwords: + +- Never share them in public repositories, logs, or chat messages. +- Never pass secrets via command-line arguments in shared or logged environments — use environment variables or secure input methods. +- Rotate API keys regularly. +- Use the most restrictive permissions and token approvals possible for your use case. +- Revoke token approvals and API access when no longer needed. diff --git a/README.md b/README.md index 3caac90..19ada70 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,214 @@ # nado-cli -Command-line interface for the [Nado Protocol](https://nado.xyz) — perpetual futures and spot trading on the Ink blockchain. +![version](https://img.shields.io/npm/v/@nadohq/nado-cli?color=blue) +![platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey) + +CLI for the [Nado Protocol](https://nado.xyz) — perpetual futures, spot trading, and liquidity provision on the Ink blockchain. + +Full protocol access. Mainnet and testnet. Cross and isolated margin. + +Pair with [`@nadohq/nado-mcp`](https://github.com/nadohq/nado-mcp) for MCP server support in Cursor, Claude, Codex, Copilot, Gemini, Goose, and other agents. + +> [!CAUTION] +> Experimental software. Interacts with the live Nado Protocol on the Ink blockchain and can execute real financial transactions including leveraged perpetual futures. Read [DISCLAIMER.md](DISCLAIMER.md) before using with real funds or AI agents. + +## Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [What You Can Trade](#what-you-can-trade) +- [Commands](#commands) +- [Configuration](#configuration) +- [For AI Agents](#for-ai-agents) +- [Examples](#examples) +- [Development](#development) +- [Disclaimer](#disclaimer) ## Installation +Requires [Bun](https://bun.sh) or [Node.js](https://nodejs.org/) 18+. + +### bun + +```bash +bun install -g @nadohq/nado-cli +``` + +### npm + ```bash npm install -g @nadohq/nado-cli ``` -Or run without installing: +### Run without installing ```bash -npx @nadohq/nado-cli --help -# or bunx @nadohq/nado-cli --help +# or +npx @nadohq/nado-cli --help +``` + +Verify it works: + +```bash +nado --version +nado market list --format json ``` -## Setup +
+Build from source -### Prerequisites +```bash +git clone https://github.com/nadohq/nado-cli.git +cd nado-cli +bun install +bun run build +bun link # makes `nado` available globally +``` + +
-- **Node.js** 18+ (or [Bun](https://bun.sh)) -- A Nado account with a wallet private key (required for account queries and trading) +## Quick Start -### Install from source +Public market data requires no credentials: ```bash -git clone && cd nado-cli -bun install # or npm install -bun run build # or npm run build +nado market list # All available markets +nado market price BTC # Best bid/ask +nado market tickers --market perp # 24h perp tickers +nado market candles BTC --period 3600 # 1h OHLCV candles +nado market orderbook BTC --depth 20 # Orderbook depth ``` -Link the binary globally so `nado` is available anywhere: +With authentication (see [Configuration](#configuration)): ```bash -npm link -# or -bun link +nado account summary # Balances, health, positions +nado trade long BTC 0.01 # Market long BTC-PERP +nado trade buy ETH 1.0 # Market buy ETH spot +nado trade close BTC # Close a position ``` -### Verify installation +For humans (interactive setup, REPL): ```bash -nado --version -nado --help +nado setup # 1-click trading wizard +nado shell # Interactive REPL +nado account summary # Table output by default ``` +## What You Can Trade + +One CLI covers perps, spot, and liquidity provision on the Nado Protocol. + +| Asset class | Description | Margin | Example | +|---|---|---|---| +| **Perpetual futures** | BTC, ETH, SOL, and more | Cross or isolated, configurable leverage | `nado trade long BTC 0.01 -m isolated --leverage 10` | +| **Spot** | Buy and sell tokens directly | — | `nado trade buy ETH 1.0 -p 2500` | +| **NLP vault** | Provide liquidity, earn yield | — | `nado nlp mint 1000` | + +Spot product IDs are even (0, 2, 4…). Perp product IDs are odd (1, 3, 5…). Product 0 is USDT0 (the quote token). Use `nado market list` to discover all available markets and their product IDs. + +## Commands + +~40 commands across 8 groups. + +| Group | Commands | Auth | Description | +|-------|----------|------|-------------| +| market | 7 | No | Prices, orderbook, candles, funding, tickers | +| account | 6 | Yes | Balances, positions, orders, history, fees, stats | +| trade | 14 | Yes | Long, short, buy, sell, close, cancel, TWAP, triggers, TP/SL | +| funds | 3 | Yes | Deposit, withdraw, transfer between subaccounts | +| nlp | 5 | Yes | NLP vault: info, balance, mint, burn, snapshots | +| auth | 3 | Mixed | Credential management, linked signers | +| setup | 1 | No | Interactive 1-click trading wizard | +| shell | 1 | No | Interactive REPL | + +14 commands are write operations (orders, closes, cancels, deposits, withdrawals, transfers, mints, burns). All write commands show a confirmation summary before executing. Use `-y` / `--force` to skip confirmations (use with caution). + +
+Full command reference + +### Market Data (Public) + +| Command | Description | +|---------|-------------| +| `nado market list` | List all available markets | +| `nado market price ` | Best bid/ask for a market | +| `nado market prices ` | Bid/ask for multiple markets | +| `nado market orderbook [--depth 10] [--watch]` | Orderbook depth (with optional live WebSocket mode) | +| `nado market candles [--period 3600] [--limit 20]` | OHLCV candlestick data | +| `nado market funding ` | Current funding rate for a perp market | +| `nado market tickers [--market spot\|perp]` | 24h ticker data for all markets | + +### Account Data (Private) + +| Command | Description | +|---------|-------------| +| `nado account summary` | Subaccount summary with balances and health | +| `nado account positions` | All open perp positions (cross and isolated) | +| `nado account orders [MARKET...]` | Open orders (all or filtered by market) | +| `nado account history [--limit 100] [--product-ids BTC,ETH]` | Historical orders (filled, cancelled, expired) | +| `nado account fees` | Maker/taker fee rates | +| `nado account stats [--days 30] [--product-ids BTC]` | Trading statistics over a time period | + +### Trading (Private) + +| Command | Description | +|---------|-------------| +| `nado trade long [-p PRICE] [-m cross\|isolated] [--leverage N] [--tp PRICE] [--sl PRICE]` | Perp long order | +| `nado trade short [-p PRICE] [-m cross\|isolated] [--leverage N] [--tp PRICE] [--sl PRICE]` | Perp short order | +| `nado trade buy [-p PRICE] [--no-borrow]` | Spot buy order | +| `nado trade sell [-p PRICE] [--no-borrow]` | Spot sell order | +| `nado trade tp [AMOUNT] [-p PRICE]` | Take-profit on existing position | +| `nado trade sl [AMOUNT] [-p PRICE]` | Stop-loss on existing position | +| `nado trade close [--slippage PCT]` | Close an open position | +| `nado trade close-all [--side long\|short] [--product-ids IDS]` | Close all open positions | +| `nado trade cancel ` | Cancel specific orders by digest | +| `nado trade cancel-all ` | Cancel all orders and triggers for a market | +| `nado trade cancel-trigger ` | Cancel specific trigger orders | +| `nado trade cancel-trigger-all ` | Cancel all trigger orders for a market | +| `nado trade cancel-and-place --cancel-digests D --cancel-market M [-p PRICE]` | Atomic cancel + place | +| `nado trade twap [--interval SEC]` | TWAP order split across time | +| `nado trade trigger --trigger-price P --trigger-type TYPE [-p PRICE]` | Conditional trigger order | + +### Funds (Private) + +| Command | Description | +|---------|-------------| +| `nado funds deposit [--product-id 0]` | Deposit collateral from wallet into subaccount | +| `nado funds withdraw [--product-id 0]` | Withdraw collateral from subaccount to wallet | +| `nado funds transfer [--from NAME] [--to NAME]` | Transfer USDT0 between subaccounts | + +### NLP Vault (Private) + +| Command | Description | +|---------|-------------| +| `nado nlp info` | NLP vault pool info and current state | +| `nado nlp balance` | Locked/unlocked NLP balances | +| `nado nlp mint ` | Deposit USDT0 to mint NLP tokens | +| `nado nlp burn ` | Burn NLP tokens to withdraw USDT0 | +| `nado nlp snapshots [--granularity 86400] [--limit 100]` | Historical vault snapshots | + +### Auth & Utility + +| Command | Description | +|---------|-------------| +| `nado auth set [KEY] [VALUE]` | Configure credentials and defaults | +| `nado auth whoami` | Show current identity and config | +| `nado auth link-signer
` | Link a signer for 1-click trading | +| `nado setup` | Interactive 1-click trading setup wizard | +| `nado shell` | Interactive REPL with tab completion and history | + +
+ ## Configuration Settings are resolved with this precedence: **CLI flags > environment variables > config file > defaults**. ### Config file -Create `~/.config/nado/config.toml`: +Store credentials in `~/.config/nado/config.toml`: ```toml [default] @@ -64,14 +220,16 @@ private_key = "0x..." subaccount_owner = "0x..." # only needed when using a linked signer ``` +Or use the interactive wizard: `nado setup` or `nado auth set`. + ### Environment variables | Variable | Description | |---|---| -| `DATA_ENV` | `nadoMainnet` or `nadoTestnet` | -| `PRIVATE_KEY` | Wallet private key | +| `PRIVATE_KEY` | Wallet or linked signer private key | | `SUBACCOUNT_OWNER` | Explicit subaccount owner address | | `SUBACCOUNT_NAME` | Subaccount name (default: `"default"`) | +| `DATA_ENV` | `nadoMainnet` or `nadoTestnet` | | `RPC_URL` | Custom RPC endpoint | | `NADO_FORMAT` | Default output format: `table` or `json` | @@ -83,114 +241,190 @@ subaccount_owner = "0x..." # only needed when using a linked signer --owner
Subaccount owner address --subaccount Subaccount name -y, --force Skip confirmation prompts +--dry-run Show what would be executed without submitting ``` Output defaults to `table` in a terminal (TTY) and `json` when piped. -## Commands +### Security -### Trading +- Config file is created with restricted permissions +- Private keys are never logged, printed in output, or included in error messages +- Use linked signers (1-click trading via `nado setup`) instead of your main wallet key +- The linked signer is a hot key with limited scope — your main wallet key is never stored +- For automation, prefer environment variables over config files +- The `--dry-run` flag lets you inspect what would execute without submitting transactions -#### Perps (long / short) +## For AI Agents + +If you're an AI agent or building one, the CLI is designed for structured, programmatic use: ```bash -nado trade long BTC 0.01 # Market long BTC-PERP -nado trade short ETH 0.5 -p 2500 # Limit short ETH-PERP @ $2500 -nado trade long SOL 10 -m isolated --leverage 5 # 5x isolated long -nado trade short BTC 0.01 --tif post_only -p 70000 # Post-only limit +nado [args...] --format json 2>/dev/null ``` -#### Spot (buy / sell) +- stdout is always valid JSON on success or a structured error on failure. +- Exit code 0 means success. Non-zero means failure. +- stderr carries diagnostics only. Never parse stderr for data. +- All write operations show a confirmation summary. Use `-y` to skip for automation. +- Market resolution is flexible: use symbols (`BTC`, `ETH`), full names (`BTC-PERP`), or product IDs (`1`, `2`). + +**Agent invocation pattern:** ```bash -nado trade buy BTC 0.1 # Market buy BTC spot -nado trade sell ETH 1.0 # Market sell ETH spot -nado trade buy BTC 0.05 -p 65000 # Limit buy BTC spot @ $65000 -nado trade sell ETH 2.0 --no-borrow # Sell without borrowing +# Read operations — no confirmation needed +nado market price BTC --format json 2>/dev/null +nado account summary --format json 2>/dev/null + +# Write operations — use -y to skip interactive confirmation +nado trade long BTC 0.01 -y --format json 2>/dev/null +nado trade close BTC -y --format json 2>/dev/null ``` -Market resolution is type-aware: `long`/`short` resolve to perp markets, `buy`/`sell` resolve to spot markets. Use exact product IDs or full market names (e.g. `BTC-PERP`, `ETH`) to be explicit. +## Examples -#### Other trade commands +### Market long with TP/SL ```bash -nado trade close BTC # Close a position -nado trade close-all # Close all positions -nado trade cancel BTC # Cancel specific order -nado trade cancel-all BTC # Cancel all orders on a market -nado trade twap BTC long 0.1 10 # TWAP long 0.1 BTC over 10min -nado trade trigger BTC long 0.01 --trigger-price 60000 --trigger-type oracle_price_below +nado trade long BTC 0.01 --tp 75000 --sl 60000 ``` -### Market data +### Isolated margin with leverage ```bash -nado market list # List all available markets -nado market price 2 # BTC-PERP best bid/ask -nado market prices 2 4 8 # Multiple markets at once -nado market orderbook 2 --depth 20 # Orderbook depth -nado market candles 2 --period 3600 --limit 10 # 1h OHLCV candles -nado market funding 2 # Current funding rate -nado market tickers # 24h tickers for all markets -nado market tickers --market perp # Perp tickers only +nado trade long SOL 10 -m isolated --leverage 5 ``` -### Account +### Limit order -All account commands use your configured wallet. Override with `--owner
`. +```bash +nado trade short ETH 0.5 -p 2500 --tif post_only +``` + +### TWAP over 10 minutes ```bash -nado account summary # Balances, health, positions -nado account positions # Isolated margin positions -nado account orders 2 4 # Open orders for products 2 and 4 -nado account history --limit 50 # Recent filled/cancelled orders -nado account history --product-ids 2,4 # Filter by market -nado account fees # Maker/taker fee rates -nado account stats # 30-day trading statistics -nado account stats --days 7 # Weekly stats +nado trade twap BTC long 0.1 10 --interval 30 ``` -### Output formats +### Quick portfolio check (JSON, pipe to jq) ```bash -nado market price 2 # Table (default in terminal) -nado market price 2 --format json # JSON -nado market price 2 --format json | jq .bid # Pipe-friendly +nado account summary --format json | jq '.health' +nado account positions --format json | jq '.crossPositions' ``` -### Interactive shell +### Live orderbook streaming ```bash -nado shell +nado market orderbook BTC --watch ``` -Starts an interactive REPL with tab completion and persistent history. Commands are the same as the CLI but without the `nado` prefix: +### Paper-style exploration (testnet) +```bash +nado --network nadoTestnet account summary +nado --network nadoTestnet trade long BTC 0.01 ``` -nado> market price 2 -┌─────────┬───────┬───────┐ -│ Product │ Bid │ Ask │ -├─────────┼───────┼───────┤ -│ 2 │ 72410 │ 72411 │ -└─────────┴───────┴───────┘ + +### Interactive REPL + +```bash +nado shell +``` + +``` +nado> market price BTC +┌────────────┬─────────┬───────┬───────┐ +│ Market │ Product │ Bid │ Ask │ +├────────────┼─────────┼───────┼───────┤ +│ BTC-PERP │ 2 │ 72410 │ 72411 │ +└────────────┴─────────┴───────┴───────┘ nado> account fees nado> help nado> exit ``` -Shell-specific commands: `help`, `clear`, `exit`/`quit`. +
+Output & Errors + +### JSON (`--format json`) + +The primary output format for agents. Every command returns valid JSON on stdout. + +```bash +nado market price BTC --format json +``` + +### Table (`--format table`) + +Human-readable tables. Default when running in a terminal (TTY). + +``` +┌────────────┬─────────┬───────┬───────┐ +│ Market │ Product │ Bid │ Ask │ +├────────────┼─────────┼───────┼───────┤ +│ BTC-PERP │ 2 │ 72410 │ 72411 │ +└────────────┴─────────┴───────┴───────┘ +``` + +### Auto-detection -## Product IDs +When stdout is a TTY, output defaults to `table`. When piped (`| jq .`), it defaults to `json`. Override with `--format`. -Spot products have even IDs (0, 2, 4…). Perp products have odd IDs (1, 3, 5…). Product 0 is USDT0 (the quote token). +### Write operation confirmations -Use `nado market list` to list all product IDs. +All write operations (orders, cancels, deposits, withdrawals, transfers, mints, burns) display a summary and prompt for confirmation before executing: + +``` + Long 0.01 BTC-PERP + Type: market + Margin: cross + Slippage: 2% + + Proceed? (y/N) +``` + +Use `-y` / `--force` to skip. Use `--dry-run` to see the summary without executing. + +
+ +
+Troubleshooting + +**"Private key not configured" or authentication errors** + +Run `nado setup` to configure 1-click trading, or set `PRIVATE_KEY` and `SUBACCOUNT_OWNER` environment variables. Verify with `nado auth whoami`. + +**"No market found" for a symbol** + +Use `nado market list` to see all available markets. You can use symbols (`BTC`), names (`BTC-PERP`), or product IDs (`1`). + +**Transaction fails or reverts** + +Check your subaccount balance with `nado account summary`. Ensure sufficient margin for the order size and leverage. For deposits, ensure your wallet has the token balance and enough native token for gas. + +**Orderbook errors on some products** + +Some protocol products (e.g. NLP pool token) exist on-chain but have no orderbook. The CLI handles this gracefully when querying all markets. + +**Shell history not saving** + +History is stored in `~/.config/nado/history`. Ensure the directory exists and is writable. + +
## Development ```bash -bun run dev # Watch mode (rebuilds on change) -bun run typecheck # Type checking -bun run lint # Lint + format -bun run build # Production build +bun install # install dependencies +bun run dev # watch mode (rebuilds on change) +bun run build # production build +bun run test # run tests +bun run typecheck # type checking +bun run lint # lint + format ``` + +## Disclaimer + +See [DISCLAIMER.md](DISCLAIMER.md). diff --git a/package.json b/package.json index 31b8390..6508d5a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "type": "module", "description": "CLI for the Nado Protocol", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/nadohq/nado-cli.git" diff --git a/skills/nado/SKILL.md b/skills/nado/SKILL.md index 24b30f1..325114c 100644 --- a/skills/nado/SKILL.md +++ b/skills/nado/SKILL.md @@ -11,7 +11,7 @@ requires: - PRIVATE_KEY install: - - npm install -g @nadohq/nado-cli + - bun install -g @nadohq/nado-cli config: requiredEnv: @@ -33,7 +33,7 @@ This skill enables you to: - **Trade Spot Tokens** — Buy and sell spot assets with optional borrowing - **Monitor Positions in Real-Time** — WebSocket-powered live orderbook, color-coded PnL, and account health - **Manage the NLP Vault** — Deposit USDT0 to mint NLP tokens, burn to withdraw, and track vault performance -- **Use Advanced Order Types** — TWAP execution, trigger orders (stop-loss / take-profit), post-only, reduce-only +- **Use Advanced Order Types** — Inline TP/SL on entry, standalone TP/SL on existing positions, TWAP execution, trigger orders, atomic cancel-and-place, post-only, reduce-only - **Run an Interactive Shell** — REPL with tab completion and persistent history ## Setup Instructions @@ -47,15 +47,17 @@ which nado If not found, install it: ```bash +bun install -g @nadohq/nado-cli +# or npm install -g @nadohq/nado-cli ``` Or run without installing: ```bash -npx @nadohq/nado-cli --help -# or bunx @nadohq/nado-cli --help +# or +npx @nadohq/nado-cli --help ``` ### 2. Verify Installation @@ -181,6 +183,7 @@ nado trade long BTC 0.01 # Market long BTC-PERP nado trade short ETH 0.5 -p 2500 # Limit short ETH-PERP @ $2500 nado trade long SOL 10 -m isolated --leverage 5 # 5x isolated long nado trade short BTC 0.01 --tif post_only -p 70000 # Post-only limit +nado trade long BTC 0.01 --tp 75000 --sl 60000 # Long with inline TP/SL ``` ### Trading — Spot (buy / sell) @@ -192,6 +195,15 @@ nado trade buy BTC 0.05 -p 65000 # Limit buy BTC spot @ $65000 nado trade sell ETH 2.0 --no-borrow # Sell without borrowing ``` +### Trading — TP/SL on Existing Positions + +```bash +nado trade tp BTC 75000 # Take-profit on BTC position (full close) +nado trade sl BTC 60000 # Stop-loss on BTC position (full close) +nado trade tp BTC 75000 0.005 # TP on partial amount +nado trade sl BTC 60000 0.005 -p 59500 # SL with stop-limit price +``` + ### Trading — Close, Cancel, TWAP, Trigger ```bash @@ -209,6 +221,9 @@ nado trade cancel-all BTC # Cancel all orders on a market nado trade cancel-trigger BTC # Cancel specific trigger order nado trade cancel-trigger-all BTC # Cancel all trigger orders on a market +# Atomic cancel + place +nado trade cancel-and-place BTC long 0.01 --cancel-digests --cancel-market BTC -p 65000 + # TWAP execution nado trade twap BTC long 0.1 10 # TWAP long 0.1 BTC over 10 minutes nado trade twap ETH short 1.0 30 --interval 60 # TWAP short with 60s intervals @@ -316,11 +331,13 @@ nado account fees # Current fee tier ### Enter a leveraged perp position with stop-loss ```bash -# Open 5x isolated long on BTC -nado trade long BTC 0.01 -m isolated --leverage 5 +# Open 5x isolated long with inline TP/SL (single command) +nado trade long BTC 0.01 -m isolated --leverage 5 --tp 75000 --sl 58000 -# Set a stop-loss -nado trade trigger BTC short 0.01 --trigger-price 58000 --trigger-type oracle_price_below +# Or set TP/SL separately on an existing position +nado trade long BTC 0.01 -m isolated --leverage 5 +nado trade sl BTC 58000 +nado trade tp BTC 75000 ``` ### Provide liquidity via NLP vault From d247c124f5baa2d09d434f29e9d1facd89a71d65 Mon Sep 17 00:00:00 2001 From: przemekink Date: Wed, 18 Mar 2026 13:16:48 +0100 Subject: [PATCH 3/4] Fix lint --- src/__tests__/handlers/trade.test.ts | 9 +++---- src/commands/trade.ts | 8 +++--- src/handlers/account.ts | 21 +++++++++++---- src/handlers/trade.ts | 38 +++++++++++++--------------- src/utils/positionCalcs.ts | 14 +++++----- 5 files changed, 50 insertions(+), 40 deletions(-) diff --git a/src/__tests__/handlers/trade.test.ts b/src/__tests__/handlers/trade.test.ts index de4028f..95143b7 100644 --- a/src/__tests__/handlers/trade.test.ts +++ b/src/__tests__/handlers/trade.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any */ import BigNumber from 'bignumber.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,8 +11,6 @@ import { // Mock @nadohq/client // --------------------------------------------------------------------------- -const E18 = new BigNumber('1000000000000000000'); - vi.mock('@nadohq/client', async () => { const BN = (await import('bignumber.js')).default; const _E18 = new BN('1000000000000000000'); @@ -23,8 +21,7 @@ vi.mock('@nadohq/client', async () => { getOrderNonce: () => '0xdeadbeef', packOrderAppendix: (opts: Record) => BigInt( - (opts.orderExecutionType === 'ioc' ? 1 : 0) | - (opts.reduceOnly ? 2 : 0), + (opts.orderExecutionType === 'ioc' ? 1 : 0) | (opts.reduceOnly ? 2 : 0), ), ProductEngineType: { PERP: 'perp', SPOT: 'spot' }, }; @@ -35,7 +32,7 @@ vi.mock('../../utils/requireSigner.js', () => ({ })); vi.mock('../../utils/orderBuilder.js', async (importOriginal) => { - const actual = (await importOriginal()) as Record; + const actual = await importOriginal(); return { ...actual, buildEngineOrder: vi.fn().mockResolvedValue({ diff --git a/src/commands/trade.ts b/src/commands/trade.ts index 70352dc..9fcce89 100644 --- a/src/commands/trade.ts +++ b/src/commands/trade.ts @@ -6,15 +6,14 @@ import { cancelAllProductOrders, cancelAndPlace, cancelOrders, - cancelProductOrders, cancelTriggerOrders, cancelTriggerProductOrders, closeAllPositions, closePosition, placeOrder, placeOrderWithTpSl, - placeTriggerOrder, placeTpSlFromPosition, + placeTriggerOrder, placeTwapOrder, type TriggerType, } from '../handlers/trade.js'; @@ -668,7 +667,10 @@ function addTpSlCommand( .argument('', 'Symbol, name, or product ID (e.g. BTC, ETH, 1)') .argument('', 'Trigger price') .argument('[amount]', 'Order size (omit to close entire position)') - .option('-p, --price ', 'Limit price (stop-limit instead of stop-market)') + .option( + '-p, --price ', + 'Limit price (stop-limit instead of stop-market)', + ) .option('-m, --margin ', 'Margin mode: cross or isolated', 'cross') .option('--leverage ', 'Leverage for isolated margin') .option('--slippage ', 'Slippage tolerance %', '2') diff --git a/src/handlers/account.ts b/src/handlers/account.ts index 748f08c..e348fac 100644 --- a/src/handlers/account.ts +++ b/src/handlers/account.ts @@ -1,5 +1,10 @@ import type { NadoClient } from '@nadohq/client'; -import { nowInSeconds, ProductEngineType, removeDecimals, toBigDecimal } from '@nadohq/client'; +import { + nowInSeconds, + ProductEngineType, + removeDecimals, + toBigDecimal, +} from '@nadohq/client'; import type { IndexerMatchEvent } from '@nadohq/indexer-client'; import BigNumber from 'bignumber.js'; @@ -8,9 +13,9 @@ import { ToolExecutionError } from '../utils/errors.js'; import { fmtNumber, fmtUsd } from '../utils/formatting.js'; import { calcEstimatedLiquidationPrice, + calcPerpEntryCostBeforeLeverage, calcPnl, calcPnlFrac, - calcPerpEntryCostBeforeLeverage, } from '../utils/positionCalcs.js'; import { getMarkets } from '../utils/resolveMarket.js'; import type { CommandResult } from './types.js'; @@ -108,7 +113,10 @@ export async function getSubaccountSummary( } } - const exitPriceByProduct = new Map(); + const exitPriceByProduct = new Map< + number, + { bid: BigNumber; ask: BigNumber } + >(); if (marketPricesResp) { for (const mp of marketPricesResp.marketPrices) { exitPriceByProduct.set(mp.productId, { bid: mp.bid, ask: mp.ask }); @@ -139,7 +147,8 @@ export async function getSubaccountSummary( const pnlBN = calcPnl(amount, exitPrice, netEntry); pnl = removeDecimals(pnlBN).toNumber(); - const lwi = longWeightInitByProduct.get(b.productId) ?? b.longWeightInitial; + const lwi = + longWeightInitByProduct.get(b.productId) ?? b.longWeightInitial; if (lwi) { const entryCost = calcPerpEntryCostBeforeLeverage(lwi, netEntry); const entryCostDecimal = removeDecimals(entryCost); @@ -149,7 +158,9 @@ export async function getSubaccountSummary( } } - const maintHealth = b.healthContributions?.maintenance ?? summary.health?.maintenance?.health; + const maintHealth = + b.healthContributions?.maintenance ?? + summary.health?.maintenance?.health; let liquidationPrice: number | null = null; if (maintHealth != null) { const liq = calcEstimatedLiquidationPrice({ diff --git a/src/handlers/trade.ts b/src/handlers/trade.ts index 7e963c3..1b7f65a 100644 --- a/src/handlers/trade.ts +++ b/src/handlers/trade.ts @@ -26,9 +26,7 @@ import { import { requireSigner } from '../utils/requireSigner.js'; import type { CommandResult } from './types.js'; -type TpSlTriggerType = - | 'oracle_price_above' - | 'oracle_price_below'; +type TpSlTriggerType = 'oracle_price_above' | 'oracle_price_below'; const REDUCE_ONLY_IOC_APPENDIX = packOrderAppendix({ orderExecutionType: 'ioc', @@ -158,8 +156,7 @@ export async function placeOrderWithTpSl( ? resultData.data[0]?.digest : resultData.data?.digest; const isLimitOrder = args.order.price != null; - const closeSide: BalanceSide = - args.order.side === 'long' ? 'short' : 'long'; + const closeSide: BalanceSide = args.order.side === 'long' ? 'short' : 'long'; const buildTriggerArgs = ( triggerPrice: number, @@ -177,9 +174,7 @@ export async function placeOrderWithTpSl( }); const dependency = - isLimitOrder && digest - ? { digest, onPartialFill: true } - : undefined; + isLimitOrder && digest ? { digest, onPartialFill: true } : undefined; const triggerPromises: Promise[] = []; @@ -325,9 +320,7 @@ export async function placeTpSlFromPosition( const closeSide: BalanceSide = positionSide === 'long' ? 'short' : 'long'; const isTriggerPriceAbove = - positionSide === 'long' - ? args.type === 'tp' - : args.type !== 'tp'; + positionSide === 'long' ? args.type === 'tp' : args.type !== 'tp'; const triggerType: TpSlTriggerType = isTriggerPriceAbove ? 'oracle_price_above' : 'oracle_price_below'; @@ -350,7 +343,7 @@ export async function placeTpSlFromPosition( return { data: { - ...result.data as Record, + ...(result.data as Record), summary: { type: args.type, productId: args.productId, @@ -673,10 +666,12 @@ export async function closeAllPositions( productIds: affectedProductIds, }); const marketPriceByProductId = new Map( - pricesResp.marketPrices.map((mp: { productId: number; bid: BigNumber; ask: BigNumber }) => [ - mp.productId, - { bid: mp.bid, ask: mp.ask }, - ]), + pricesResp.marketPrices.map( + (mp: { productId: number; bid: BigNumber; ask: BigNumber }) => [ + mp.productId, + { bid: mp.bid, ask: mp.ask }, + ], + ), ); const closeOrders = [ @@ -796,8 +791,12 @@ function buildCloseOrderFromBalance( // Use orderbook bid (close short) or ask (close long), fall back to oracle const basePrice = isBuy - ? (marketPrice?.bid.gt(0) ? marketPrice.bid : balance.oraclePrice) - : (marketPrice?.ask.gt(0) ? marketPrice.ask : balance.oraclePrice); + ? marketPrice?.bid.gt(0) + ? marketPrice.bid + : balance.oraclePrice + : marketPrice?.ask.gt(0) + ? marketPrice.ask + : balance.oraclePrice; const closePrice = roundToIncrement( basePrice.times(slippageMultiplier), @@ -911,8 +910,7 @@ export async function cancelAllProductOrders( return { data: { - orders: - engineResult.status === 'fulfilled' ? engineResult.value : null, + orders: engineResult.status === 'fulfilled' ? engineResult.value : null, triggerOrders: triggerResult.status === 'fulfilled' ? triggerResult.value : null, }, diff --git a/src/utils/positionCalcs.ts b/src/utils/positionCalcs.ts index ae1c7ed..c8c0c60 100644 --- a/src/utils/positionCalcs.ts +++ b/src/utils/positionCalcs.ts @@ -16,8 +16,13 @@ export function calcEstimatedLiquidationPrice(params: { longWeightMaintenance: BigNumber; shortWeightMaintenance: BigNumber; }): BigNumber | null { - const { maintHealth, amount, oraclePrice, longWeightMaintenance, shortWeightMaintenance } = - params; + const { + maintHealth, + amount, + oraclePrice, + longWeightMaintenance, + shortWeightMaintenance, + } = params; if (amount.isZero()) return null; @@ -49,10 +54,7 @@ export function calcPnl( * Calculates PnL fraction: pnl / |denominator|, returning zero when * denominator is effectively zero. */ -export function calcPnlFrac( - pnl: BigNumber, - denominator: BigNumber, -): BigNumber { +export function calcPnlFrac(pnl: BigNumber, denominator: BigNumber): BigNumber { if (denominator.abs().lte(0.01)) return new BigNumber(0); return pnl.div(denominator.abs()); } From e289975de4f9aa059ae6c8528185d8f7844e5b23 Mon Sep 17 00:00:00 2001 From: przemekink Date: Wed, 18 Mar 2026 13:18:40 +0100 Subject: [PATCH 4/4] Add missing cast --- src/__tests__/handlers/trade.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/handlers/trade.test.ts b/src/__tests__/handlers/trade.test.ts index 95143b7..eea290e 100644 --- a/src/__tests__/handlers/trade.test.ts +++ b/src/__tests__/handlers/trade.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ import BigNumber from 'bignumber.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -32,7 +32,7 @@ vi.mock('../../utils/requireSigner.js', () => ({ })); vi.mock('../../utils/orderBuilder.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = (await importOriginal()) as Record; return { ...actual, buildEngineOrder: vi.fn().mockResolvedValue({