mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
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:
+10
-3
@@ -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` +
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user