Add Nado documentation and examples, including new API endpoints, FAQs, and guides for using the TypeScript SDK. Update .env.example with additional configuration options.

This commit is contained in:
discountry
2025-12-19 01:38:09 +08:00
parent 624fecfa70
commit c69ea72860
148 changed files with 17031 additions and 27 deletions
+10 -3
View File
@@ -4,7 +4,7 @@ export interface CliOptions {
strategy?: StrategyId;
silent: boolean;
help: boolean;
exchange?: "aster" | "grvt" | "lighter" | "backpack";
exchange?: "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado";
}
const STRATEGY_VALUES = new Set<StrategyId>([
@@ -75,7 +75,14 @@ function assignStrategy(options: CliOptions, raw: string): void {
function assignExchange(options: CliOptions, raw: string): void {
const normalized = raw.trim().toLowerCase();
if (!normalized) return;
if (normalized === "aster" || normalized === "grvt" || normalized === "lighter" || normalized === "backpack") {
if (
normalized === "aster" ||
normalized === "grvt" ||
normalized === "lighter" ||
normalized === "backpack" ||
normalized === "paradex" ||
normalized === "nado"
) {
options.exchange = normalized as CliOptions["exchange"];
} else if (normalized === "gravity" || normalized === "grav" || normalized === "grv") {
options.exchange = "grvt";
@@ -84,7 +91,7 @@ function assignExchange(options: CliOptions, raw: string): void {
export function printCliHelp(): void {
// eslint-disable-next-line no-console
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado>] [--silent]\n\n` +
`Options:\n` +
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
` Aliases: offset, offset-maker for the offset maker engine.\n` +
+2 -2
View File
@@ -92,8 +92,8 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
}
const exchangeId = resolveExchangeId();
if (exchangeId !== "aster") {
throw new Error("Basis arbitrage strategy currently only supports the Aster exchange");
if (exchangeId !== "aster" && exchangeId !== "nado") {
throw new Error("Basis arbitrage strategy currently only supports the Aster and Nado exchanges");
}
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol);
const engine = new BasisArbEngine(basisConfig, adapter);
+5 -2
View File
@@ -31,6 +31,7 @@ const SYMBOL_PRIORITY_BY_EXCHANGE: Record<SupportedExchangeId, { envKeys: string
lighter: { envKeys: ["LIGHTER_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" },
backpack: { envKeys: ["BACKPACK_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDC" },
paradex: { envKeys: ["PARADEX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC/USDC" },
nado: { envKeys: ["NADO_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-PERP" },
};
export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | string | null): string {
@@ -149,13 +150,15 @@ const resolveBasisSymbol = (envKeys: string[], fallback: string): string => {
};
export const basisConfig: BasisArbConfig = {
// Default symbols depend on venue: Nado uses product symbols (e.g. BTC-PERP / KBTC), while Aster uses pair symbols.
// Users can always override via BASIS_* env vars.
futuresSymbol: resolveBasisSymbol(
["BASIS_FUTURES_SYMBOL", "ASTER_FUTURES_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
"ASTERUSDT"
(process.env.EXCHANGE ?? "").trim().toLowerCase() === "nado" ? "BTC-PERP" : "ASTERUSDT"
),
spotSymbol: resolveBasisSymbol(
["BASIS_SPOT_SYMBOL", "ASTER_SPOT_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
"ASTERUSDT"
(process.env.EXCHANGE ?? "").trim().toLowerCase() === "nado" ? "KBTC" : "ASTERUSDT"
),
refreshIntervalMs: parseNumber(process.env.BASIS_REFRESH_INTERVAL_MS, 1000),
maxLogEntries: parseNumber(process.env.BASIS_MAX_LOG_ENTRIES, 200),
+11
View File
@@ -27,6 +27,16 @@ export interface KlineListener {
(klines: AsterKline[]): void;
}
export interface FundingRateSnapshot {
symbol: string;
fundingRate: number;
updateTime: number;
}
export interface FundingRateListener {
(snapshot: FundingRateSnapshot): void;
}
export interface ExchangePrecision {
priceTick: number;
qtyStep: number;
@@ -45,6 +55,7 @@ export interface ExchangeAdapter {
watchDepth(symbol: string, cb: DepthListener): void;
watchTicker(symbol: string, cb: TickerListener): void;
watchKlines(symbol: string, interval: string, cb: KlineListener): void;
watchFundingRate?(symbol: string, cb: FundingRateListener): void;
createOrder(params: CreateOrderParams): Promise<AsterOrder>;
cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void>;
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
+8 -1
View File
@@ -4,6 +4,7 @@ import { GrvtExchangeAdapter, type GrvtCredentials } from "./grvt/adapter";
import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter";
import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter";
import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter";
import { NadoExchangeAdapter, type NadoCredentials } from "./nado/adapter";
export interface ExchangeFactoryOptions {
symbol: string;
@@ -13,9 +14,10 @@ export interface ExchangeFactoryOptions {
lighter?: LighterCredentials;
backpack?: BackpackCredentials;
paradex?: ParadexCredentials;
nado?: NadoCredentials;
}
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "paradex";
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado";
export function resolveExchangeId(value?: string | null): SupportedExchangeId {
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
@@ -26,6 +28,7 @@ export function resolveExchangeId(value?: string | null): SupportedExchangeId {
if (fallback === "lighter") return "lighter";
if (fallback === "backpack") return "backpack";
if (fallback === "paradex") return "paradex";
if (fallback === "nado") return "nado";
return "aster";
}
@@ -34,6 +37,7 @@ export function getExchangeDisplayName(id: SupportedExchangeId): string {
if (id === "lighter") return "Lighter";
if (id === "backpack") return "Backpack";
if (id === "paradex") return "Paradex";
if (id === "nado") return "Nado";
return "AsterDex";
}
@@ -51,5 +55,8 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange
if (id === "paradex") {
return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol });
}
if (id === "nado") {
return new NadoExchangeAdapter({ ...options.nado, symbol: options.symbol });
}
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
}
+219
View File
@@ -0,0 +1,219 @@
import { setTimeout, clearTimeout } from "timers";
import type {
AccountListener,
DepthListener,
ExchangeAdapter,
ExchangePrecision,
FundingRateListener,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import type { AsterOrder, CreateOrderParams } from "../types";
import { extractMessage } from "../../utils/errors";
import { NadoGateway, type NadoGatewayOptions } from "./gateway";
import type { ChainEnv } from "@nadohq/shared";
import type { Address } from "viem";
export interface NadoCredentials {
env?: ChainEnv;
symbol?: string;
subaccountOwner?: Address;
subaccountName?: string;
signerPrivateKey?: string;
gatewayWsUrl?: string;
subscriptionsWsUrl?: string;
archiveUrl?: string;
triggerUrl?: string;
pollIntervals?: NadoGatewayOptions["pollIntervals"];
marketSlippagePct?: number;
stopTriggerSource?: NadoGatewayOptions["stopTriggerSource"];
}
export class NadoExchangeAdapter implements ExchangeAdapter {
readonly id = "nado";
private readonly gateway: NadoGateway;
private readonly symbol: string;
private initPromise: Promise<void> | null = null;
private readonly initContexts = new Set<string>();
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private retryDelayMs = 3000;
private lastInitErrorAt = 0;
constructor(credentials: NadoCredentials = {}) {
const signerPrivateKey = credentials.signerPrivateKey ?? process.env.NADO_SIGNER_PRIVATE_KEY;
const subaccountOwner = (credentials.subaccountOwner ??
(process.env.NADO_SUBACCOUNT_OWNER as Address | undefined) ??
(process.env.NADO_EVM_ADDRESS as Address | undefined)) as Address | undefined;
const symbol =
credentials.symbol ??
process.env.NADO_SYMBOL ??
process.env.TRADE_SYMBOL ??
"BTC-PERP";
if (!signerPrivateKey) {
throw new Error("Missing NADO_SIGNER_PRIVATE_KEY environment variable");
}
if (!subaccountOwner) {
throw new Error("Missing NADO_SUBACCOUNT_OWNER (or NADO_EVM_ADDRESS) environment variable");
}
this.symbol = symbol;
this.gateway = new NadoGateway({
env: credentials.env,
symbol,
subaccountOwner,
subaccountName: credentials.subaccountName ?? process.env.NADO_SUBACCOUNT_NAME ?? "default",
signerPrivateKey,
gatewayWsUrl: credentials.gatewayWsUrl ?? process.env.NADO_GATEWAY_WS_URL,
subscriptionsWsUrl: credentials.subscriptionsWsUrl ?? process.env.NADO_SUBSCRIPTIONS_WS_URL,
archiveUrl: credentials.archiveUrl ?? process.env.NADO_ARCHIVE_URL,
triggerUrl: credentials.triggerUrl ?? process.env.NADO_TRIGGER_URL,
pollIntervals: credentials.pollIntervals,
marketSlippagePct:
credentials.marketSlippagePct ??
(process.env.NADO_MARKET_SLIPPAGE_PCT ? Number(process.env.NADO_MARKET_SLIPPAGE_PCT) : undefined),
stopTriggerSource:
credentials.stopTriggerSource ??
(process.env.NADO_STOP_TRIGGER_SOURCE as NadoGatewayOptions["stopTriggerSource"] | undefined),
logger: (context, error) => this.logError(context, error),
});
}
supportsTrailingStops(): boolean {
return false;
}
watchAccount(cb: AccountListener): void {
void this.ensureInitialized("watchAccount");
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
}
watchOrders(cb: OrderListener): void {
void this.ensureInitialized("watchOrders");
this.gateway.onOrders(this.safeInvoke("watchOrders", cb));
}
watchDepth(symbol: string, cb: DepthListener): void {
void this.ensureInitialized(`watchDepth:${symbol}`);
this.gateway.onDepth(symbol, this.safeInvoke("watchDepth", cb));
}
watchTicker(symbol: string, cb: TickerListener): void {
void this.ensureInitialized(`watchTicker:${symbol}`);
this.gateway.onTicker(symbol, this.safeInvoke("watchTicker", cb));
}
watchKlines(symbol: string, interval: string, cb: KlineListener): void {
void this.ensureInitialized(`watchKlines:${symbol}:${interval}`);
this.gateway.onKlines(symbol, interval, this.safeInvoke("watchKlines", cb));
}
watchFundingRate(symbol: string, cb: FundingRateListener): void {
void this.ensureInitialized(`watchFundingRate:${symbol}`);
this.gateway.onFundingRate(symbol, this.safeInvoke("watchFundingRate", cb));
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
await this.ensureInitialized("createOrder");
return this.gateway.createOrder(params);
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized("cancelOrder");
await this.gateway.cancelOrder(params);
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized("cancelOrders");
await this.gateway.cancelOrders(params);
}
async cancelAllOrders(params: { symbol: string }): Promise<void> {
await this.ensureInitialized("cancelAllOrders");
await this.gateway.cancelAllOrders(params);
}
async getPrecision(): Promise<ExchangePrecision | null> {
try {
return await this.gateway.getPrecision(this.symbol);
} catch (error) {
this.logError("getPrecision", error);
return null;
}
}
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
const wrapped = ((...args: any[]) => {
try {
cb(...args);
} catch (error) {
console.error(`[NadoExchangeAdapter] ${context} handler failed: ${extractMessage(error)}`);
}
}) as T;
return wrapped;
}
private ensureInitialized(context?: string): Promise<void> {
if (!this.initPromise) {
this.initContexts.clear();
this.initPromise = this.gateway
.ensureInitialized(this.symbol)
.then((value) => {
this.clearRetry();
return value;
})
.catch((error) => {
this.handleInitError("initialize", error);
this.initPromise = null;
this.scheduleRetry();
throw error;
});
}
if (context && !this.initContexts.has(context)) {
this.initContexts.add(context);
this.initPromise.catch((error) => {
this.handleInitError(context, error);
this.scheduleRetry();
});
}
return this.initPromise;
}
private scheduleRetry(): void {
if (this.retryTimer) return;
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
if (this.initPromise) return;
this.retryDelayMs = Math.min(this.retryDelayMs * 2, 60_000);
void this.ensureInitialized("retry");
}, this.retryDelayMs);
}
private clearRetry(): void {
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryDelayMs = 3000;
}
private handleInitError(context: string, error: unknown): void {
const now = Date.now();
if (now - this.lastInitErrorAt < 5000) return;
this.lastInitErrorAt = now;
console.error(`[NadoExchangeAdapter] ${context} failed`, error);
}
private logError(context: string, error: unknown): void {
const detail = extractMessage(error);
const message = `[NadoExchangeAdapter] ${context} failed: ${detail}`;
const criticalContexts = ["initialize", "accountPoll", "ordersPoll", "triggerOrdersPoll"];
if (criticalContexts.some((prefix) => context.startsWith(prefix)) || process.env.NADO_DEBUG === "1") {
console.error(message);
}
}
}
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
import type { AsterOrder, CreateOrderParams } from "../types";
import type {
BaseOrderIntent,
ClosePositionIntent,
LimitOrderIntent,
MarketOrderIntent,
StopOrderIntent,
TrailingStopOrderIntent,
} from "../order-schema";
import { toStringBoolean } from "../order-schema";
function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams {
if (params.quantity === undefined) {
params.quantity = intent.quantity;
}
if (params.timeInForce === undefined && intent.timeInForce) {
params.timeInForce = intent.timeInForce;
}
if (intent.reduceOnly !== undefined) {
params.reduceOnly = toStringBoolean(intent.reduceOnly);
}
if (intent.closePosition !== undefined) {
params.closePosition = toStringBoolean(intent.closePosition);
}
return params;
}
export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "LIMIT",
quantity: intent.quantity,
price: intent.price,
timeInForce: intent.timeInForce ?? "GTC",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
timeInForce: intent.timeInForce ?? "IOC",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "STOP_MARKET",
quantity: intent.quantity,
stopPrice: intent.stopPrice,
timeInForce: intent.timeInForce ?? "GTC",
reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
closePosition: toStringBoolean(intent.closePosition ?? true),
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise<AsterOrder> {
throw new Error("Nado exchange does not support trailing stop orders");
}
export async function createClosePositionOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
reduceOnly: "true",
closePosition: toStringBoolean(intent.closePosition ?? true),
timeInForce: intent.timeInForce ?? "IOC",
},
intent
);
return intent.adapter.createOrder(params);
}
+166
View File
@@ -0,0 +1,166 @@
export type NadoProductType = "spot" | "perp";
export interface NadoContractsResponse {
status: "success" | "failure";
data?: {
chain_id: string;
endpoint_addr: string;
};
error?: string;
error_code?: number;
request_type?: string;
}
export interface NadoSymbolsResponse {
status: "success" | "failure";
data?: {
symbols: Record<
string,
{
type: NadoProductType;
product_id: number;
symbol: string;
price_increment_x18: string;
size_increment: string;
min_size: string;
maker_fee_rate_x18?: string;
taker_fee_rate_x18?: string;
}
>;
};
error?: string;
error_code?: number;
request_type?: string;
}
export interface NadoSubaccountOrdersResponse {
status: "success" | "failure";
data?: {
sender: string;
product_id: number;
orders: Array<{
product_id: number;
sender: string;
price_x18: string;
amount: string;
expiration: string;
nonce: string;
unfilled_amount: string;
digest: string;
placed_at: number;
appendix?: string;
order_type?: string;
}>;
};
error?: string;
error_code?: number;
request_type?: string;
}
export interface NadoSubaccountInfoResponse {
status: "success" | "failure";
data?: {
subaccount: string;
exists: boolean;
healths: Array<{ assets: string; liabilities: string; health: string }>;
spot_balances: Array<{ product_id: number; balance: { amount: string } }>;
perp_balances: Array<{
product_id: number;
balance: {
amount: string;
v_quote_balance: string;
last_cumulative_funding_x18?: string;
};
}>;
spot_products: Array<{
product_id: number;
oracle_price_x18: string;
book_info?: {
size_increment: string;
price_increment_x18: string;
min_size: string;
};
}>;
perp_products: Array<{
product_id: number;
oracle_price_x18: string;
book_info?: {
size_increment: string;
price_increment_x18: string;
min_size: string;
};
}>;
};
error?: string;
error_code?: number;
request_type?: string;
}
export interface NadoSubscriptionAck {
result: unknown;
id: number;
}
export interface NadoOrderUpdateEvent {
type: "order_update";
timestamp: string;
product_id: number;
digest: string;
amount: string;
reason: "filled" | "cancelled" | "placed";
id?: number;
}
export interface NadoPositionChangeEvent {
type: "position_change";
timestamp: string;
product_id: number;
subaccount: string;
isolated: boolean;
amount: string;
v_quote_amount: string;
reason: string;
}
export interface NadoBestBidOfferEvent {
type: "best_bid_offer";
timestamp: string;
product_id: number;
bid_price: string;
bid_qty: string;
ask_price: string;
ask_qty: string;
}
export interface NadoTradeEvent {
type: "trade";
timestamp: string;
product_id: number;
price: string;
taker_qty: string;
maker_qty: string;
is_taker_buyer: boolean;
}
export interface NadoLatestCandlestickEvent {
type: "latest_candlestick";
timestamp: number;
product_id: number;
granularity: number;
open_x18: string;
high_x18: string;
low_x18: string;
close_x18: string;
volume: string;
}
export interface NadoFundingRateEvent {
type: "funding_rate";
// timestamp when the event was generated, in nanoseconds
timestamp: string;
product_id: number;
// latest 24hr funding rate, multiplied by 1e18
funding_rate_x18: string;
// epoch time in seconds when the funding rate was updated
update_time: string;
}
+10 -3
View File
@@ -13,8 +13,9 @@ import * as backpackOrders from "./backpack/order";
import * as grvtOrders from "./grvt/order";
import * as lighterOrders from "./lighter/order";
import * as paradexOrders from "./paradex/order";
import * as nadoOrders from "./nado/order";
type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex";
type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex" | "nado";
interface ExchangeOrderHandlers {
limit(intent: LimitOrderIntent): Promise<AsterOrder>;
@@ -60,9 +61,16 @@ const handlerMap: Record<ExchangeKey, ExchangeOrderHandlers> = {
trailingStop: paradexOrders.createTrailingStopOrder,
close: paradexOrders.createClosePositionOrder,
},
nado: {
limit: nadoOrders.createLimitOrder,
market: nadoOrders.createMarketOrder,
stop: nadoOrders.createStopOrder,
trailingStop: nadoOrders.createTrailingStopOrder,
close: nadoOrders.createClosePositionOrder,
},
};
const knownExchanges: ExchangeKey[] = ["aster", "backpack", "grvt", "lighter", "paradex"];
const knownExchanges: ExchangeKey[] = ["aster", "backpack", "grvt", "lighter", "paradex", "nado"];
function normalizeExchangeId(value: string | undefined | null): string | undefined {
if (!value) return undefined;
@@ -115,4 +123,3 @@ export function routeTrailingStopOrder(intent: TrailingStopOrderIntent): Promise
export function routeCloseOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
return getHandlers(intent).close(intent);
}
+38
View File
@@ -4,7 +4,9 @@ import type { AsterCredentials } from "./aster-adapter";
import type { LighterCredentials } from "./lighter/adapter";
import type { BackpackCredentials } from "./backpack/adapter";
import type { ParadexCredentials } from "./paradex/adapter";
import type { NadoCredentials } from "./nado/adapter";
import { t } from "../i18n";
import type { Address } from "viem";
interface BuildAdapterOptions {
symbol: string;
@@ -35,6 +37,11 @@ export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapt
return createExchangeAdapter({ exchange: id, symbol, paradex: credentials });
}
if (id === "nado") {
const credentials = resolveNadoCredentials(symbol);
return createExchangeAdapter({ exchange: id, symbol, nado: credentials });
}
return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } });
}
@@ -115,6 +122,37 @@ function resolveParadexCredentials(): ParadexCredentials {
return credentials;
}
function resolveNadoCredentials(symbol: string): NadoCredentials {
const signerPrivateKey = process.env.NADO_SIGNER_PRIVATE_KEY;
const subaccountOwner = process.env.NADO_SUBACCOUNT_OWNER ?? process.env.NADO_EVM_ADDRESS;
if (!signerPrivateKey || !subaccountOwner) {
throw new Error(t("env.missingNado"));
}
if (!isHex32(signerPrivateKey)) {
throw new Error(t("env.invalidNadoPrivateKey"));
}
if (!isHexAddress(subaccountOwner)) {
throw new Error(t("env.invalidNadoAddress"));
}
const credentials: NadoCredentials = {
symbol: process.env.NADO_SYMBOL ?? symbol,
signerPrivateKey,
subaccountOwner: subaccountOwner as Address,
subaccountName: process.env.NADO_SUBACCOUNT_NAME ?? undefined,
env: process.env.NADO_ENV as any,
gatewayWsUrl: process.env.NADO_GATEWAY_WS_URL ?? undefined,
subscriptionsWsUrl: process.env.NADO_SUBSCRIPTIONS_WS_URL ?? undefined,
archiveUrl: process.env.NADO_ARCHIVE_URL ?? undefined,
triggerUrl: process.env.NADO_TRIGGER_URL ?? undefined,
marketSlippagePct: parseOptionalNumber(process.env.NADO_MARKET_SLIPPAGE_PCT),
stopTriggerSource: process.env.NADO_STOP_TRIGGER_SOURCE as any,
};
return credentials;
}
function isHex32(value: string): boolean {
return /^0x[0-9a-fA-F]{64}$/.test(value.trim());
}
+38 -2
View File
@@ -229,8 +229,8 @@ const translations: Record<string, TranslationEntry> = {
"grid.direction.long": { zh: "多", en: "Long" },
"grid.direction.short": { zh: "空", en: "Short" },
"basis.onlyAster": {
zh: "期现套利策略目前仅支持 Aster 交易所。请设置 EXCHANGE=aster 后重试。",
en: "Basis arbitrage currently supports only Aster. Set EXCHANGE=aster and retry.",
zh: "期现套利策略目前仅支持 Aster / Nado 交易所。请设置 EXCHANGE=aster 或 EXCHANGE=nado 后重试。",
en: "Basis arbitrage currently supports only Aster and Nado. Set EXCHANGE=aster or EXCHANGE=nado and retry.",
},
"basis.startFailed": {
zh: "无法启动期现套利策略: {message}",
@@ -326,6 +326,18 @@ const translations: Record<string, TranslationEntry> = {
zh: "PARADEX_WALLET_ADDRESS 必须是有效的 0x 开头 40 字节十六进制地址",
en: "PARADEX_WALLET_ADDRESS must be a valid 0x-prefixed 40-byte hex address",
},
"env.missingNado": {
zh: "Nado 需要配置 NADO_SIGNER_PRIVATE_KEY 与 NADO_SUBACCOUNT_OWNER (或 NADO_EVM_ADDRESS)",
en: "Nado requires NADO_SIGNER_PRIVATE_KEY and NADO_SUBACCOUNT_OWNER (or NADO_EVM_ADDRESS)",
},
"env.invalidNadoPrivateKey": {
zh: "NADO_SIGNER_PRIVATE_KEY 必须是 0x 开头的 32 字节十六进制字符串",
en: "NADO_SIGNER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string",
},
"env.invalidNadoAddress": {
zh: "NADO_SUBACCOUNT_OWNER / NADO_EVM_ADDRESS 必须是有效的 0x 开头 40 字节十六进制地址",
en: "NADO_SUBACCOUNT_OWNER / NADO_EVM_ADDRESS must be a valid 0x-prefixed 40-byte hex address",
},
"log.subscribe.accountFail": {
zh: "订阅账户失败: {error}",
en: "Failed to subscribe account: {error}",
@@ -430,6 +442,14 @@ const translations: Record<string, TranslationEntry> = {
zh: "处理期货深度异常: {error}",
en: "Error processing futures depth: {error}",
},
"log.basis.subscribeSpotDepthFail": {
zh: "订阅现货深度失败: {error}",
en: "Failed to subscribe spot depth: {error}",
},
"log.basis.processSpotDepthError": {
zh: "处理现货深度异常: {error}",
en: "Error processing spot depth: {error}",
},
"log.basis.futuresReady": {
zh: "期货深度已就绪 ({symbol})",
en: "Futures depth ready ({symbol})",
@@ -438,6 +458,14 @@ const translations: Record<string, TranslationEntry> = {
zh: "获取现货盘口失败: {error}",
en: "Failed to fetch spot orderbook: {error}",
},
"log.basis.subscribeFundingRateFail": {
zh: "订阅资金费率失败: {error}",
en: "Failed to subscribe funding rate: {error}",
},
"log.basis.processFundingRateError": {
zh: "处理资金费率异常: {error}",
en: "Error processing funding rate: {error}",
},
"log.basis.fundingReady": {
zh: "资金费率已就绪 ({symbol})",
en: "Funding rate ready ({symbol})",
@@ -446,6 +474,14 @@ const translations: Record<string, TranslationEntry> = {
zh: "获取资金费率失败: {error}",
en: "Failed to fetch funding rate: {error}",
},
"log.basis.subscribeAccountFail": {
zh: "订阅账户快照失败: {error}",
en: "Failed to subscribe account snapshot: {error}",
},
"log.basis.processAccountError": {
zh: "处理账户快照异常: {error}",
en: "Error processing account snapshot: {error}",
},
"log.basis.spotBalanceError": {
zh: "获取现货余额失败: {error}",
en: "Failed to fetch spot balance: {error}",
+118 -10
View File
@@ -1,6 +1,6 @@
import type { BasisArbConfig } from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AsterDepth, AsterSpotBookTicker } from "../exchanges/types";
import type { ExchangeAdapter, FundingRateSnapshot } from "../exchanges/adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterSpotBookTicker } from "../exchanges/types";
import { AsterSpotRestClient, AsterRestClient } from "../exchanges/aster/client";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { StrategyEventEmitter } from "./common/event-emitter";
@@ -82,8 +82,8 @@ interface FuturesBalanceStateEntry {
export class BasisArbEngine {
private readonly events = new StrategyEventEmitter<BasisArbEvent, BasisArbSnapshot>();
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly spotClient: Pick<AsterSpotRestClient, "getBookTicker">;
private readonly futuresClient: Pick<AsterRestClient, "getPremiumIndex">;
private readonly spotClient: Pick<AsterSpotRestClient, "getBookTicker"> | null;
private readonly futuresClient: Pick<AsterRestClient, "getPremiumIndex"> | null;
private readonly now: () => number;
private readonly config: BasisArbConfig;
private readonly exchange: ExchangeAdapter;
@@ -109,8 +109,9 @@ export class BasisArbEngine {
constructor(config: BasisArbConfig, exchange: ExchangeAdapter, deps: BasisArbDependencies = {}) {
this.config = config;
this.exchange = exchange;
this.spotClient = deps.spotClient ?? new AsterSpotRestClient();
this.futuresClient = deps.futuresClient ?? new AsterRestClient();
const isAster = exchange.id === "aster";
this.spotClient = deps.spotClient ?? (isAster ? new AsterSpotRestClient() : null);
this.futuresClient = deps.futuresClient ?? (isAster ? new AsterRestClient() : null);
this.now = deps.now ?? (() => Date.now());
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.bootstrap();
@@ -118,6 +119,7 @@ export class BasisArbEngine {
start(): void {
if (this.timer) return;
if (this.exchange.id !== "aster") return;
this.timer = setInterval(() => {
void this.pollSpot();
void this.pollFunding();
@@ -164,6 +166,48 @@ export class BasisArbEngine {
processFail: (error) => t("log.basis.processFuturesDepthError", { error: String(error) }),
}
);
if (this.exchange.id === "nado") {
safeSubscribe<AsterDepth>(
this.exchange.watchDepth.bind(this.exchange, this.config.spotSymbol),
(depth) => {
this.applySpotDepth(depth);
},
log,
{
subscribeFail: (error) => t("log.basis.subscribeSpotDepthFail", { error: String(error) }),
processFail: (error) => t("log.basis.processSpotDepthError", { error: String(error) }),
}
);
if (typeof this.exchange.watchFundingRate === "function") {
safeSubscribe<FundingRateSnapshot>(
(cb) => {
this.exchange.watchFundingRate?.(this.config.futuresSymbol, cb);
},
(snapshot) => {
this.applyFundingRateSnapshot(snapshot);
},
log,
{
subscribeFail: (error) => t("log.basis.subscribeFundingRateFail", { error: String(error) }),
processFail: (error) => t("log.basis.processFundingRateError", { error: String(error) }),
}
);
}
safeSubscribe<AsterAccountSnapshot>(
this.exchange.watchAccount.bind(this.exchange),
(snapshot) => {
this.applyAccountSnapshot(snapshot);
},
log,
{
subscribeFail: (error) => t("log.basis.subscribeAccountFail", { error: String(error) }),
processFail: (error) => t("log.basis.processAccountError", { error: String(error) }),
}
);
}
}
private applyFuturesDepth(depth: AsterDepth): void {
@@ -189,7 +233,7 @@ export class BasisArbEngine {
}
private async pollSpot(): Promise<void> {
if (this.spotInFlight || this.stopped) return;
if (!this.spotClient || this.spotInFlight || this.stopped) return;
this.spotInFlight = true;
try {
const result = await this.spotClient.getBookTicker(this.config.spotSymbol);
@@ -208,7 +252,7 @@ export class BasisArbEngine {
}
private async pollFunding(): Promise<void> {
if (this.fundingInFlight || this.stopped) return;
if (!this.futuresClient || this.fundingInFlight || this.stopped) return;
this.fundingInFlight = true;
try {
const data = await this.futuresClient.getPremiumIndex(this.config.futuresSymbol);
@@ -237,7 +281,7 @@ export class BasisArbEngine {
}
private async pollSpotAccount(): Promise<void> {
if (this.spotAccountInFlight || this.stopped) return;
if (!this.spotClient || this.spotAccountInFlight || this.stopped) return;
this.spotAccountInFlight = true;
try {
// Spot balances via spot REST
@@ -267,7 +311,7 @@ export class BasisArbEngine {
}
private async pollFuturesAccount(): Promise<void> {
if (this.futuresAccountInFlight || this.stopped) return;
if (this.exchange.id !== "aster" || this.futuresAccountInFlight || this.stopped) return;
this.futuresAccountInFlight = true;
try {
// Futures balances via futures REST
@@ -316,6 +360,70 @@ export class BasisArbEngine {
this.emitUpdate();
}
private applySpotDepth(depth: AsterDepth): void {
if (!depth?.bids?.length || !depth?.asks?.length) {
return;
}
const topBid = Number(depth.bids[0]?.[0]);
const topAsk = Number(depth.asks[0]?.[0]);
if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) {
return;
}
this.spot.bid = topBid;
this.spot.ask = topAsk;
this.spot.updatedAt = depth.eventTime ?? depth.tradeTime ?? this.now();
if (!this.feedReady.spot) {
this.feedReady.spot = true;
this.tradeLog.push("info", t("log.basis.spotReady", { symbol: this.config.spotSymbol }));
}
if (this.feedReady.futures && this.feedReady.spot && this.marketReadyAt == null) {
this.marketReadyAt = this.now();
}
this.emitUpdate();
}
private applyFundingRateSnapshot(snapshot: FundingRateSnapshot): void {
const rate = snapshot.fundingRate;
if (!Number.isFinite(rate)) return;
this.funding.rate = rate;
this.funding.nextFundingTime = null;
this.funding.updatedAt = Number.isFinite(snapshot.updateTime) ? snapshot.updateTime : this.now();
if (!this.feedReady.funding) {
this.feedReady.funding = true;
this.tradeLog.push("info", t("log.basis.fundingReady", { symbol: this.config.futuresSymbol }));
}
this.emitUpdate();
}
private applyAccountSnapshot(snapshot: AsterAccountSnapshot): void {
const assets = Array.isArray(snapshot.assets) ? snapshot.assets : [];
const spotBalances: SpotBalanceStateEntry[] = [];
const futuresBalances: FuturesBalanceStateEntry[] = [];
for (const asset of assets) {
const name = String(asset.asset ?? "");
const wallet = Number(asset.walletBalance ?? 0);
const available = Number(asset.availableBalance ?? 0);
if (!name) continue;
if (!Number.isFinite(wallet) || !Number.isFinite(available)) continue;
if (Math.abs(wallet) === 0 && Math.abs(available) === 0) continue;
if (name === "USDT0") {
futuresBalances.push({ asset: name, wallet, available });
continue;
}
const locked = Math.max(wallet - available, 0);
spotBalances.push({ asset: name, free: available, locked });
}
spotBalances.sort((a, b) => a.asset.localeCompare(b.asset));
futuresBalances.sort((a, b) => a.asset.localeCompare(b.asset));
this.spotBalances = spotBalances;
this.futuresBalances = futuresBalances;
this.emitUpdate();
}
private emitUpdate(): void {
// Build a single snapshot, evaluate signals against EXACTLY the same data, then emit that snapshot
const snapshot = this.buildSnapshot();
+1 -1
View File
@@ -31,7 +31,7 @@ export function BasisApp({ onExit }: BasisAppProps) {
);
useEffect(() => {
if (exchangeId !== "aster") {
if (exchangeId !== "aster" && exchangeId !== "nado") {
setError(new Error(t("basis.onlyAster")));
return;
}