Enhance NadoGateway with min size policy handling and related utility functions. Update .env.example to include NADO_MIN_SIZE_POLICY configuration option.

This commit is contained in:
discountry
2025-12-19 11:11:35 +08:00
parent c69ea72860
commit 84e8ce1d43
2 changed files with 138 additions and 20 deletions
+5
View File
@@ -134,6 +134,11 @@ NADO_MARKET_SLIPPAGE_PCT=0.01
# Optional: stop trigger source for STOP_MARKET orders (oracle | last | mid)
NADO_STOP_TRIGGER_SOURCE=oracle
# Optional: how to handle orders smaller than Nado min_size (USDT0 notional)
# - adjust: round quantity up to the minimum allowed size (default)
# - reject: throw an error instead of auto-adjusting
NADO_MIN_SIZE_POLICY=adjust
# Optional endpoint overrides
# NADO_GATEWAY_WS_URL=wss://gateway.prod.nado.xyz/v1/ws
# NADO_SUBSCRIPTIONS_WS_URL=wss://gateway.prod.nado.xyz/v1/subscribe
+133 -20
View File
@@ -1,7 +1,7 @@
import NodeWebSocket from "ws";
import BigNumber from "bignumber.js";
import { IndexerClient } from "@nadohq/indexer-client";
import { TriggerClient } from "@nadohq/trigger-client";
import { TriggerClient, type PriceTriggerRequirementType } from "@nadohq/trigger-client";
import { ENGINE_WS_CLIENT_ENDPOINTS, ENGINE_WS_SUBSCRIPTION_CLIENT_ENDPOINTS } from "@nadohq/engine-client";
import type { ChainEnv, WalletClientWithAccount } from "@nadohq/shared";
import {
@@ -12,7 +12,8 @@ import {
getOrderVerifyingAddress,
packOrderAppendix,
} from "@nadohq/shared";
import { createWalletClient, custom, privateKeyToAccount, type Address } from "viem";
import { createWalletClient, custom, type Address } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import type {
AccountListener,
DepthListener,
@@ -32,7 +33,6 @@ import type {
CreateOrderParams,
TimeInForce,
} from "../types";
import type { PriceTriggerRequirementType } from "@nadohq/trigger-client";
import type {
NadoBestBidOfferEvent,
NadoContractsResponse,
@@ -62,6 +62,9 @@ const WS_PING_INTERVAL_MS = 30_000;
const WS_STALE_TIMEOUT_MS = 75_000;
const DEFAULT_MARKET_SLIPPAGE_PCT = 0.01;
const X18_BIGINT = 1_000_000_000_000_000_000n;
type MinSizePolicy = "adjust" | "reject";
export interface NadoGatewayOptions {
env?: ChainEnv;
@@ -80,6 +83,7 @@ export interface NadoGatewayOptions {
};
marketSlippagePct?: number;
stopTriggerSource?: NadoTriggerPriceSource;
minSizePolicy?: MinSizePolicy;
logger?: (context: string, error: unknown) => void;
}
@@ -201,6 +205,47 @@ function nowMs(): number {
return Date.now();
}
function absBigInt(value: bigint): bigint {
return value < 0n ? -value : value;
}
function ceilDiv(numerator: bigint, denominator: bigint): bigint {
if (denominator === 0n) {
throw new Error("Division by zero");
}
const quotient = numerator / denominator;
return numerator % denominator === 0n ? quotient : quotient + 1n;
}
function roundDownToIncrement(value: bigint, increment: bigint): bigint {
if (increment <= 0n) return value;
return (value / increment) * increment;
}
function roundUpToIncrement(value: bigint, increment: bigint): bigint {
if (increment <= 0n) return value;
const remainder = value % increment;
if (remainder === 0n) return value;
return value + (increment - remainder);
}
function alignPriceX18(
priceX18: bigint,
incrementX18: bigint,
side: "BUY" | "SELL",
mode: "aggressive" | "passive"
): bigint {
if (incrementX18 <= 0n) return priceX18;
if (mode === "aggressive") {
return side === "BUY"
? roundUpToIncrement(priceX18, incrementX18)
: roundDownToIncrement(priceX18, incrementX18);
}
return side === "BUY"
? roundDownToIncrement(priceX18, incrementX18)
: roundUpToIncrement(priceX18, incrementX18);
}
function nsToMs(ns: string): number {
try {
const nanos = BigInt(ns);
@@ -273,6 +318,7 @@ export class NadoGateway {
private readonly pollIntervals: PollIntervals;
private readonly marketSlippagePct: number;
private readonly stopTriggerSource: NadoTriggerPriceSource;
private readonly minSizePolicy: MinSizePolicy;
private readonly symbolMetaBySymbol = new Map<string, SymbolMeta>();
private readonly symbolMetaByProductId = new Map<number, SymbolMeta>();
@@ -351,6 +397,7 @@ export class NadoGateway {
const slippage = options.marketSlippagePct ?? safeToNumber(process.env.NADO_MARKET_SLIPPAGE_PCT ?? "");
this.marketSlippagePct = Number.isFinite(slippage) && slippage > 0 ? slippage : DEFAULT_MARKET_SLIPPAGE_PCT;
this.stopTriggerSource = options.stopTriggerSource ?? (process.env.NADO_STOP_TRIGGER_SOURCE as NadoTriggerPriceSource) ?? "oracle";
this.minSizePolicy = options.minSizePolicy ?? ((process.env.NADO_MIN_SIZE_POLICY ?? "").trim().toLowerCase() === "reject" ? "reject" : "adjust");
this.primarySymbol = options.symbol;
this.rememberDisplaySymbol(this.primarySymbol);
@@ -607,12 +654,12 @@ export class NadoGateway {
if (!meta) return null;
const priceTick = fromX18(meta.priceIncrementX18).toNumber();
const qtyStep = fromX18(meta.sizeIncrementX18).toNumber();
const minBaseAmount = fromX18(meta.minSizeX18).toNumber();
const minQuoteAmount = fromX18(meta.minSizeX18).toNumber();
return {
priceTick,
qtyStep,
marketId: meta.productId,
minBaseAmount,
minQuoteAmount,
};
}
@@ -1439,7 +1486,7 @@ export class NadoGateway {
},
primaryType: "StreamAuthentication",
message: {
sender,
sender: sender as `0x${string}`,
expiration: BigInt(expirationMs),
},
});
@@ -1759,7 +1806,27 @@ export class NadoGateway {
throw new Error("Invalid order price");
}
const amountX18 = toX18BigInt(qty);
const amountX18Raw = toX18BigInt(qty);
const priceX18 = toX18BigInt(price);
const minSizeX18 = BigInt(meta.minSizeX18);
const sizeIncrementX18 = BigInt(meta.sizeIncrementX18);
const notionalX18 = (absBigInt(amountX18Raw) * priceX18) / X18_BIGINT;
const amountX18 = (() => {
if (notionalX18 >= minSizeX18) return absBigInt(amountX18Raw);
const required = roundUpToIncrement(ceilDiv(minSizeX18 * X18_BIGINT, priceX18), sizeIncrementX18);
if (this.minSizePolicy === "reject") {
const current = fromX18(notionalX18.toString()).toFixed();
const minimum = fromX18(minSizeX18.toString()).toFixed();
const requiredQty = fromX18(required.toString()).toFixed();
throw new Error(
`Order size too small: notional ${current} < min_size ${minimum} (USDT0). ` +
`At price ${new BigNumber(price).toFixed()} you need qty >= ${requiredQty}.`
);
}
return required;
})();
const signedAmount = side === "SELL" ? -amountX18 : amountX18;
const appendix = packOrderAppendix({
@@ -1827,7 +1894,7 @@ export class NadoGateway {
type: "LIMIT",
status: "NEW",
price: String(price),
origQty: String(qty),
origQty: fromX18(amountX18.toString()).toFixed(),
executedQty: "0",
stopPrice: "0",
time: nowMs(),
@@ -1844,6 +1911,7 @@ export class NadoGateway {
if (!Number.isFinite(qty) || qty <= 0) {
throw new Error("Invalid order quantity");
}
const priceIncrementX18 = BigInt(meta.priceIncrementX18);
const bbo = this.bestBidOfferByProductId.get(meta.productId);
const reference = (() => {
if (bbo) {
@@ -1857,11 +1925,33 @@ export class NadoGateway {
if (!reference) {
throw new Error("Market order rejected: missing best bid/offer");
}
const limitPrice = side === "BUY"
const limitPriceRaw = side === "BUY"
? reference.multipliedBy(1 + this.marketSlippagePct)
: reference.multipliedBy(1 - this.marketSlippagePct);
const limitPriceX18 = alignPriceX18(toX18BigInt(limitPriceRaw), priceIncrementX18, side, "aggressive");
const limitPrice = fromX18(limitPriceX18.toString());
const amountX18Raw = toX18BigInt(qty);
const priceX18 = limitPriceX18;
const minSizeX18 = BigInt(meta.minSizeX18);
const sizeIncrementX18 = BigInt(meta.sizeIncrementX18);
const notionalX18 = (absBigInt(amountX18Raw) * priceX18) / X18_BIGINT;
const amountX18 = (() => {
if (notionalX18 >= minSizeX18) return absBigInt(amountX18Raw);
const required = roundUpToIncrement(ceilDiv(minSizeX18 * X18_BIGINT, priceX18), sizeIncrementX18);
if (this.minSizePolicy === "reject") {
const current = fromX18(notionalX18.toString()).toFixed();
const minimum = fromX18(minSizeX18.toString()).toFixed();
const requiredQty = fromX18(required.toString()).toFixed();
throw new Error(
`Order size too small: notional ${current} < min_size ${minimum} (USDT0). ` +
`At price ${limitPrice.toFixed()} you need qty >= ${requiredQty}.`
);
}
return required;
})();
const amountX18 = toX18BigInt(qty);
const signedAmount = side === "SELL" ? -amountX18 : amountX18;
const appendix = packOrderAppendix({
@@ -1916,7 +2006,7 @@ export class NadoGateway {
type: "MARKET",
status: "NEW",
price: limitPrice.toFixed(),
origQty: String(qty),
origQty: fromX18(amountX18.toString()).toFixed(),
executedQty: "0",
stopPrice: "0",
time: nowMs(),
@@ -1933,18 +2023,41 @@ export class NadoGateway {
if (!Number.isFinite(qty) || qty <= 0) {
throw new Error("Invalid order quantity");
}
const priceIncrementX18 = BigInt(meta.priceIncrementX18);
const stopPrice = params.stopPrice ?? 0;
if (!Number.isFinite(stopPrice) || stopPrice <= 0) {
throw new Error("Invalid stop price");
}
const amountX18 = toX18BigInt(qty);
const signedAmount = side === "SELL" ? -amountX18 : amountX18;
const stopPriceX18 = alignPriceX18(toX18BigInt(stopPrice), priceIncrementX18, side, "passive");
const limitFromStopRaw = side === "BUY"
? fromX18(stopPriceX18.toString()).multipliedBy(1 + this.marketSlippagePct)
: fromX18(stopPriceX18.toString()).multipliedBy(1 - this.marketSlippagePct);
const limitFromStopX18 = alignPriceX18(toX18BigInt(limitFromStopRaw), priceIncrementX18, side, "aggressive");
const limitFromStop = fromX18(limitFromStopX18.toString());
const stopPriceX18 = toX18BigInt(stopPrice);
const limitFromStop = side === "BUY"
? fromX18(stopPriceX18).multipliedBy(1 + this.marketSlippagePct)
: fromX18(stopPriceX18).multipliedBy(1 - this.marketSlippagePct);
const amountX18Raw = toX18BigInt(qty);
const priceX18 = limitFromStopX18;
const minSizeX18 = BigInt(meta.minSizeX18);
const sizeIncrementX18 = BigInt(meta.sizeIncrementX18);
const notionalX18 = (absBigInt(amountX18Raw) * priceX18) / X18_BIGINT;
const amountX18 = (() => {
if (notionalX18 >= minSizeX18) return absBigInt(amountX18Raw);
const required = roundUpToIncrement(ceilDiv(minSizeX18 * X18_BIGINT, priceX18), sizeIncrementX18);
if (this.minSizePolicy === "reject") {
const current = fromX18(notionalX18.toString()).toFixed();
const minimum = fromX18(minSizeX18.toString()).toFixed();
const requiredQty = fromX18(required.toString()).toFixed();
throw new Error(
`Order size too small: notional ${current} < min_size ${minimum} (USDT0). ` +
`At price ${limitFromStop.toFixed()} you need qty >= ${requiredQty}.`
);
}
return required;
})();
const signedAmount = side === "SELL" ? -amountX18 : amountX18;
const appendix = packOrderAppendix({
orderExecutionType: "ioc",
@@ -1981,7 +2094,7 @@ export class NadoGateway {
type: "price",
criteria: {
type: requirementType,
triggerPrice: new BigNumber(stopPrice).toFixed(),
triggerPrice: fromX18(stopPriceX18.toString()).toFixed(),
},
} as any,
} as any);
@@ -2011,9 +2124,9 @@ export class NadoGateway {
type: "STOP_MARKET",
status: "NEW",
price: limitFromStop.toFixed(),
origQty: String(qty),
origQty: fromX18(amountX18.toString()).toFixed(),
executedQty: "0",
stopPrice: String(stopPrice),
stopPrice: fromX18(stopPriceX18.toString()).toFixed(),
time: nowMs(),
updateTime: nowMs(),
reduceOnly: true,