feat: 添加 Paradex 适配器支持,更新环境变量配置和适配器构建逻辑

This commit is contained in:
discountry
2025-10-06 18:06:23 +08:00
parent ccc3a2bf89
commit cfd5f4309d
18 changed files with 1681 additions and 177 deletions
+3 -51
View File
@@ -1,10 +1,7 @@
import { makerConfig, tradingConfig } from "../config";
import {
createExchangeAdapter,
getExchangeDisplayName,
resolveExchangeId,
} from "../exchanges/create-adapter";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import type { ExchangeAdapter } from "../exchanges/adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import {
MakerEngine,
type MakerEngineSnapshot,
@@ -147,52 +144,7 @@ async function runEngine<TSnapshot extends TrendEngineSnapshot | MakerEngineSnap
}
function createAdapterOrThrow(symbol: string): ExchangeAdapter {
const exchangeId = resolveExchangeId();
if (exchangeId === "aster") {
const apiKey = process.env.ASTER_API_KEY;
const apiSecret = process.env.ASTER_API_SECRET;
if (!apiKey || !apiSecret) {
throw new Error("Missing ASTER_API_KEY or ASTER_API_SECRET environment variables");
}
return createExchangeAdapter({
exchange: exchangeId,
symbol,
aster: { apiKey, apiSecret },
});
}
if (exchangeId === "lighter") {
const accountIndex = parseInt(process.env.LIGHTER_ACCOUNT_INDEX ?? "", 10);
const privateKey = process.env.LIGHTER_API_PRIVATE_KEY;
if (!Number.isFinite(accountIndex) || !privateKey) {
throw new Error("LIGHTER_ACCOUNT_INDEX and LIGHTER_API_PRIVATE_KEY environment variables are required for Lighter exchange");
}
const apiKeyIndex = process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0;
const lighterSymbol = process.env.LIGHTER_SYMBOL ?? symbol;
const marketId = process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined;
const priceDecimals = process.env.LIGHTER_PRICE_DECIMALS ? Number(process.env.LIGHTER_PRICE_DECIMALS) : undefined;
const sizeDecimals = process.env.LIGHTER_SIZE_DECIMALS ? Number(process.env.LIGHTER_SIZE_DECIMALS) : undefined;
return createExchangeAdapter({
exchange: exchangeId,
symbol,
lighter: {
marketSymbol: lighterSymbol,
accountIndex,
apiPrivateKey: privateKey,
apiKeyIndex,
baseUrl: process.env.LIGHTER_BASE_URL,
environment: process.env.LIGHTER_ENV,
marketId,
priceDecimals,
sizeDecimals,
},
});
}
return createExchangeAdapter({
exchange: exchangeId,
symbol,
grvt: { symbol },
});
return buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
}
type TradeLogEntry = { time: string; type: string; detail: string };
+36 -2
View File
@@ -10,9 +10,21 @@
* - BACKPACK_SANDBOX: Set to "true" for sandbox mode
* - BACKPACK_DEBUG: Set to "true" for debug logging
*
* Environment Variables for Paradex Exchange:
* - PARADEX_PRIVATE_KEY: Required EVM private key for REST/WS authentication
* - PARADEX_WALLET_ADDRESS: Required wallet address matching the private key
* - PARADEX_SYMBOL: Override symbol (defaults to TRADE_SYMBOL)
* - PARADEX_SANDBOX: Set to "true" to use testnet endpoints
* - PARADEX_USE_PRO: Set to "false" to disable ccxt.pro websocket feeds
* - PARADEX_RECONNECT_DELAY_MS: Optional websocket reconnect delay in ms (default 2000)
* - PARADEX_DEBUG: Set to "true" for verbose Paradex adapter logging
*
* Usage: Set EXCHANGE=backpack to use Backpack exchange
* Set EXCHANGE=paradex to use Paradex exchange
*/
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
export interface TradingConfig {
symbol: string;
tradeAmount: number;
@@ -32,6 +44,28 @@ export interface TradingConfig {
minBollingerBandwidth: number;
}
const SYMBOL_PRIORITY_BY_EXCHANGE: Record<SupportedExchangeId, { envKeys: string[]; fallback: string }> = {
aster: { envKeys: ["ASTER_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" },
grvt: { envKeys: ["GRVT_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" },
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" },
};
export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | string | null): string {
const exchangeId = explicitExchangeId
? resolveExchangeId(explicitExchangeId)
: resolveExchangeId();
const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId];
for (const key of envKeys) {
const value = process.env[key];
if (value && value.trim()) {
return value.trim();
}
}
return fallback;
}
function parseNumber(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const next = Number(value);
@@ -39,7 +73,7 @@ function parseNumber(value: string | undefined, fallback: number): number {
}
export const tradingConfig: TradingConfig = {
symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT",
symbol: resolveSymbolFromEnv(),
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
lossLimit: parseNumber(process.env.LOSS_LIMIT, 0.03),
trailingProfit: parseNumber(process.env.TRAILING_PROFIT, 0.2),
@@ -70,7 +104,7 @@ export interface MakerConfig {
}
export const makerConfig: MakerConfig = {
symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT",
symbol: resolveSymbolFromEnv(),
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
lossLimit: parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.03)),
bidOffset: parseNumber(process.env.MAKER_BID_OFFSET, 0),
+2
View File
@@ -242,6 +242,8 @@ export async function placeStopLossOrder(
timeInForce: "GTC",
quantity: roundQtyDownToStep(quantity, qtyStep),
};
// 部分交易所(例如 Paradex)要求 STOP_MARKET 同时提供 price 字段
params.price = params.stopPrice;
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
+8 -1
View File
@@ -3,6 +3,7 @@ import { AsterExchangeAdapter, type AsterCredentials } from "./aster-adapter";
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";
export interface ExchangeFactoryOptions {
symbol: string;
@@ -11,9 +12,10 @@ export interface ExchangeFactoryOptions {
grvt?: GrvtCredentials;
lighter?: LighterCredentials;
backpack?: BackpackCredentials;
paradex?: ParadexCredentials;
}
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack";
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "paradex";
export function resolveExchangeId(value?: string | null): SupportedExchangeId {
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
@@ -23,6 +25,7 @@ export function resolveExchangeId(value?: string | null): SupportedExchangeId {
if (fallback === "grvt") return "grvt";
if (fallback === "lighter") return "lighter";
if (fallback === "backpack") return "backpack";
if (fallback === "paradex") return "paradex";
return "aster";
}
@@ -30,6 +33,7 @@ export function getExchangeDisplayName(id: SupportedExchangeId): string {
if (id === "grvt") return "GRVT";
if (id === "lighter") return "Lighter";
if (id === "backpack") return "Backpack";
if (id === "paradex") return "Paradex";
return "AsterDex";
}
@@ -44,5 +48,8 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange
if (id === "backpack") {
return new BackpackExchangeAdapter({ ...options.backpack, symbol: options.symbol });
}
if (id === "paradex") {
return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol });
}
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
}
+210
View File
@@ -0,0 +1,210 @@
import { setTimeout, clearTimeout } from "timers";
import type {
AccountListener,
DepthListener,
ExchangeAdapter,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import type { AsterOrder, CreateOrderParams } from "../types";
import { extractMessage } from "../../utils/errors";
import { ParadexGateway, type ParadexGatewayOptions } from "./gateway";
export interface ParadexCredentials {
privateKey?: string;
walletAddress?: string;
sandbox?: boolean;
pollIntervals?: ParadexGatewayOptions["pollIntervals"];
watchReconnectDelayMs?: number;
usePro?: boolean;
symbol?: string;
}
export class ParadexExchangeAdapter implements ExchangeAdapter {
readonly id = "paradex";
private readonly gateway: ParadexGateway;
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: ParadexCredentials = {}) {
const privateKey = credentials.privateKey ?? process.env.PARADEX_PRIVATE_KEY;
const walletAddress = credentials.walletAddress ?? process.env.PARADEX_WALLET_ADDRESS;
const sandbox = credentials.sandbox ?? (process.env.PARADEX_SANDBOX === "true");
const symbol = credentials.symbol ?? process.env.PARADEX_SYMBOL ?? process.env.TRADE_SYMBOL ?? "BTC/USDC";
const usePro = credentials.usePro ?? this.parseBooleanEnv(process.env.PARADEX_USE_PRO);
const watchReconnectDelayMs =
credentials.watchReconnectDelayMs ?? this.parseNumberEnv(process.env.PARADEX_RECONNECT_DELAY_MS);
this.gateway = new ParadexGateway({
symbol,
displaySymbol: symbol,
privateKey,
walletAddress,
sandbox,
pollIntervals: credentials.pollIntervals,
watchReconnectDelayMs,
usePro,
logger: (context, error) => this.logError(context, error),
});
this.symbol = symbol;
}
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(this.safeInvoke("watchDepth", cb));
}
watchTicker(symbol: string, cb: TickerListener): void {
void this.ensureInitialized(`watchTicker:${symbol}`);
this.gateway.onTicker(this.safeInvoke("watchTicker", cb));
}
watchKlines(symbol: string, interval: string, cb: KlineListener): void {
void this.ensureInitialized(`watchKlines:${symbol}:${interval}`);
this.gateway.watchKlines(interval, this.safeInvoke("watchKlines", 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);
}
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
const wrapped = ((...args: any[]) => {
try {
cb(...args);
} catch (error) {
console.error(`[ParadexExchangeAdapter] ${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(`[ParadexExchangeAdapter] ${context} failed`, error);
}
private logError(context: string, error: unknown): void {
const detail = extractMessage(error);
if (context === "initialize" && typeof error === "string" && /initialized/i.test(error)) {
if (process.env.PARADEX_DEBUG === "1" || process.env.PARADEX_DEBUG === "true") {
console.info(`[ParadexExchangeAdapter] ${error}`);
}
return;
}
const message = `[ParadexExchangeAdapter] ${context} failed: ${detail}`;
const criticalContexts = [
"initialize",
"accountPoll",
"watchBalanceLoop",
"orderPoll",
"orderPollOpen",
"orderPollClosed",
];
if (
criticalContexts.some((prefix) => context.startsWith(prefix)) ||
process.env.PARADEX_DEBUG === "1" ||
process.env.PARADEX_DEBUG === "true"
) {
console.error(message);
}
}
private parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) return undefined;
const normalized = value.trim().toLowerCase();
if (["false", "0", "no", "off", ""].includes(normalized)) return false;
return true;
}
private parseNumberEnv(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
}
+995
View File
@@ -0,0 +1,995 @@
import ccxt, {
type Balances,
type Order as CcxtOrder,
type OrderBook as CcxtOrderBook,
type OHLCV as CcxtOhlcv,
type Ticker as CcxtTicker,
} from "ccxt";
import { createRequire } from "module";
import type {
AsterAccountAsset,
AsterAccountSnapshot,
AsterDepth,
AsterKline,
AsterOrder,
AsterTicker,
CreateOrderParams,
OrderType,
} from "../types";
import type {
AccountListener,
DepthListener,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import { extractMessage } from "../../utils/errors";
const require = createRequire(import.meta.url);
function loadCcxtPro(): any | null {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require("ccxt.pro");
return mod?.default ?? mod;
} catch (_error) {
return null;
}
}
export interface ParadexGatewayOptions {
symbol: string;
displaySymbol: string;
privateKey?: string;
walletAddress?: string;
paradexAccount?: {
privateKey: string;
publicKey: string;
address: string;
};
sandbox?: boolean;
logger?: (context: string, error: unknown) => void;
pollIntervals?: {
account?: number;
orders?: number;
depth?: number;
ticker?: number;
klines?: number;
};
usePro?: boolean;
watchReconnectDelayMs?: number;
}
interface ProLoopControl {
running: boolean;
}
type ParadexPollingConfig = {
account: number;
orders: number;
depth: number;
ticker: number;
klines: number;
};
export class ParadexGateway {
private readonly exchange: any;
private readonly hasPro: boolean;
private readonly symbol: string;
private marketSymbol: string;
private readonly displaySymbol: string;
private readonly logger: (context: string, error: unknown) => void;
private readonly pollIntervals: ParadexPollingConfig;
private readonly reconnectDelayMs: number;
private initialized = false;
private initPromise: Promise<void> | null = null;
private destroyed = false;
private onboardingChecked = false;
private accountListeners = new Set<AccountListener>();
private orderListeners = new Set<OrderListener>();
private depthListeners = new Set<DepthListener>();
private tickerListeners = new Set<TickerListener>();
private klineListeners = new Map<string, Set<KlineListener>>();
private readonly localOrders = new Map<string, AsterOrder>();
private lastBalanceSnapshot: AsterAccountSnapshot | null = null;
private accountPollTimer: NodeJS.Timeout | null = null;
private orderPollTimer: NodeJS.Timeout | null = null;
private depthPollTimer: NodeJS.Timeout | null = null;
private tickerPollTimer: NodeJS.Timeout | null = null;
private klinePollTimers = new Map<string, NodeJS.Timeout>();
private proLoops = new Map<string, ProLoopControl>();
private klineProLoops = new Map<string, ProLoopControl>();
constructor(options: ParadexGatewayOptions) {
this.symbol = options.symbol;
this.marketSymbol = this.symbol;
this.displaySymbol = options.displaySymbol;
this.logger = options.logger ?? ((context, error) => console.error(`[ParadexGateway] ${context}:`, error));
this.pollIntervals = {
account: options.pollIntervals?.account ?? 5000,
orders: options.pollIntervals?.orders ?? 2000,
depth: options.pollIntervals?.depth ?? 1000,
ticker: options.pollIntervals?.ticker ?? 2000,
klines: options.pollIntervals?.klines ?? 5000,
};
this.reconnectDelayMs = options.watchReconnectDelayMs ?? 2000;
const ccxtProModule = options.usePro === false ? null : loadCcxtPro();
this.hasPro = Boolean(ccxtProModule && ccxtProModule.paradex);
const ExchangeCtor = this.hasPro ? ccxtProModule.paradex : (ccxt as any).paradex;
const exchangeOptions: Record<string, unknown> = {
enableRateLimit: true,
timeout: 30000,
sandboxMode: options.sandbox ?? false,
};
if (options.privateKey) {
exchangeOptions.privateKey = options.privateKey;
}
exchangeOptions.walletAddress = options.walletAddress;
this.exchange = new ExchangeCtor(exchangeOptions);
}
async ensureInitialized(symbol?: string): Promise<void> {
if (this.destroyed) {
throw new Error("Paradex gateway destroyed");
}
if (this.initialized) {
if (symbol && symbol.toUpperCase() !== this.marketSymbol) {
const resolved = this.resolveMarketSymbol(symbol.toUpperCase());
if (!resolved) {
throw new Error(`Symbol ${symbol} not found in Paradex markets`);
}
this.marketSymbol = resolved;
}
return;
}
if (this.initPromise) return this.initPromise;
this.initPromise = this.doInitialize(symbol)
.then((value) => {
this.initialized = true;
return value;
})
.catch((error) => {
this.initPromise = null;
throw error;
});
return this.initPromise;
}
private async doInitialize(symbol?: string): Promise<void> {
try {
await this.exchange.loadMarkets();
const requested = symbol ?? this.symbol;
const resolved = this.resolveMarketSymbol(requested);
if (!resolved) {
throw new Error(`Symbol ${requested} not found in Paradex markets`);
}
this.marketSymbol = resolved;
await this.verifyAccountAccess();
this.logger("initialize", `Paradex gateway initialized for ${this.marketSymbol}${this.hasPro ? " (pro)" : ""}`);
} catch (error) {
this.logger("initialize", error);
throw error;
}
}
private resolveMarketSymbol(requested: string): string | null {
const symbol = (requested ?? "").trim();
if (!symbol) {
return null;
}
const markets = this.exchange.markets ?? {};
const marketsById = this.exchange.markets_by_id ?? {};
if (markets[symbol]) {
return markets[symbol].symbol;
}
if (typeof this.exchange.market === "function") {
try {
const market = this.exchange.market(symbol);
if (market?.symbol) {
return market.symbol;
}
} catch {
/* ignore */
}
}
const lower = symbol.toLowerCase();
for (const candidate of this.exchange.symbols ?? []) {
if (candidate.toLowerCase() === lower && markets[candidate]) {
return markets[candidate].symbol;
}
}
if (marketsById[symbol]) {
const entry = marketsById[symbol];
if (Array.isArray(entry) && entry.length > 0) {
return (entry[0] as any).symbol ?? null;
}
return (entry as any).symbol ?? null;
}
for (const [id, market] of Object.entries(marketsById)) {
if (id.toLowerCase() === lower) {
if (Array.isArray(market) && market.length > 0) {
return (market[0] as any).symbol ?? null;
}
return (market as any).symbol ?? null;
}
}
return null;
}
destroy(): void {
this.destroyed = true;
for (const [, control] of this.proLoops) {
control.running = false;
}
for (const [, control] of this.klineProLoops) {
control.running = false;
}
this.clearPolling();
if (typeof this.exchange.close === "function") {
try {
void this.exchange.close();
} catch (error) {
this.logger("destroy", error);
}
}
}
private clearPolling(): void {
if (this.accountPollTimer) {
clearInterval(this.accountPollTimer);
this.accountPollTimer = null;
}
if (this.orderPollTimer) {
clearInterval(this.orderPollTimer);
this.orderPollTimer = null;
}
if (this.depthPollTimer) {
clearInterval(this.depthPollTimer);
this.depthPollTimer = null;
}
if (this.tickerPollTimer) {
clearInterval(this.tickerPollTimer);
this.tickerPollTimer = null;
}
for (const timer of this.klinePollTimers.values()) {
clearInterval(timer);
}
this.klinePollTimers.clear();
}
onAccount(callback: AccountListener): void {
this.accountListeners.add(callback);
if (this.lastBalanceSnapshot) {
try {
callback(this.lastBalanceSnapshot);
} catch (error) {
this.logger("accountReplay", error);
}
}
if (this.hasPro) {
this.startProLoop("account", () => this.watchAccountLoop());
} else {
this.startAccountPolling();
}
}
onOrders(callback: OrderListener): void {
this.orderListeners.add(callback);
this.emitCurrentOrders();
if (this.hasPro) {
this.startProLoop("orders", () => this.watchOrdersLoop());
} else {
this.startOrderPolling();
}
}
onDepth(callback: DepthListener): void {
this.depthListeners.add(callback);
if (this.hasPro) {
this.startProLoop("depth", () => this.watchDepthLoop());
} else {
this.startDepthPolling();
}
}
onTicker(callback: TickerListener): void {
this.tickerListeners.add(callback);
if (this.hasPro) {
this.startProLoop("ticker", () => this.watchTickerLoop());
} else {
this.startTickerPolling();
}
}
watchKlines(interval: string, callback: KlineListener): void {
const normalizedInterval = this.normalizeInterval(interval);
if (!this.klineListeners.has(normalizedInterval)) {
this.klineListeners.set(normalizedInterval, new Set());
}
this.klineListeners.get(normalizedInterval)?.add(callback);
if (this.hasPro) {
this.startKlineProLoop(normalizedInterval);
} else {
this.startKlinePolling(normalizedInterval);
}
}
private startProLoop(key: string, pump: () => Promise<void>): void {
if (this.proLoops.has(key)) return;
const control: ProLoopControl = { running: true };
this.proLoops.set(key, control);
void this.runProLoop(key, control, pump);
}
private async runProLoop(key: string, control: ProLoopControl, pump: () => Promise<void>): Promise<void> {
while (!this.destroyed && control.running) {
try {
await this.ensureInitialized();
await pump();
} catch (error) {
this.logger(`${key}Loop`, error);
await this.sleep(this.reconnectDelayMs);
}
}
this.proLoops.delete(key);
}
private startKlineProLoop(interval: string): void {
if (this.klineProLoops.has(interval)) return;
const control: ProLoopControl = { running: true };
this.klineProLoops.set(interval, control);
void this.runKlineProLoop(interval, control);
}
private async runKlineProLoop(interval: string, control: ProLoopControl): Promise<void> {
const key = `klines:${interval}`;
while (!this.destroyed && control.running) {
try {
await this.ensureInitialized();
if (!this.klineListeners.get(interval)?.size) {
await this.sleep(500);
continue;
}
const raw = (await this.exchange.watchOHLCV(this.marketSymbol, interval)) as unknown;
const candles = Array.isArray(raw) && Array.isArray(raw[0]) ? (raw as CcxtOhlcv[]) : [raw as CcxtOhlcv];
const klines = candles
.filter((candle) => Array.isArray(candle) && candle.length >= 6)
.map((candle) => this.mapOHLCVToKline(candle as CcxtOhlcv, interval));
for (const listener of this.klineListeners.get(interval) ?? []) {
listener(klines);
}
} catch (error) {
this.logger(key, error);
await this.sleep(this.reconnectDelayMs);
}
}
this.klineProLoops.delete(interval);
}
private async watchAccountLoop(): Promise<void> {
if (!this.accountListeners.size) {
await this.sleep(500);
return;
}
await this.fetchAndEmitAccount();
const hasWatchPositions = typeof (this.exchange as any).watchPositions === "function";
if (hasWatchPositions) {
try {
const raw = await (this.exchange as any).watchPositions([this.marketSymbol]);
this.logger("watchPositionsRaw", JSON.stringify(raw));
if (Array.isArray(raw)) {
const snapshot = this.mapBalanceToAccountSnapshotFromPositions(raw);
this.lastBalanceSnapshot = snapshot;
for (const listener of this.accountListeners) {
listener(snapshot);
}
}
} catch (error) {
this.logger("watchPositions", error);
}
}
await this.sleep(this.pollIntervals.account);
}
private async watchOrdersLoop(): Promise<void> {
if (!this.orderListeners.size) {
await this.sleep(500);
return;
}
const rawSymbol = this.exchange.marketId(this.displaySymbol) ?? this.marketSymbol;
const raw = (await this.exchange.watchOrders(rawSymbol)) as unknown;
const ordersArray = Array.isArray(raw) ? (raw as CcxtOrder[]) : [raw as CcxtOrder];
this.updateOrdersFromRemote(ordersArray, []);
}
private async watchDepthLoop(): Promise<void> {
if (!this.depthListeners.size) {
await this.sleep(500);
return;
}
const depth = (await this.exchange.watchOrderBook(this.marketSymbol, 50)) as CcxtOrderBook;
const mapped = this.mapOrderBookToDepth(depth);
for (const listener of this.depthListeners) {
listener(mapped);
}
}
private async watchTickerLoop(): Promise<void> {
if (!this.tickerListeners.size) {
await this.sleep(500);
return;
}
const ticker = (await this.exchange.watchTicker(this.marketSymbol)) as CcxtTicker;
const mapped = this.mapTickerToAsterTicker(ticker);
for (const listener of this.tickerListeners) {
listener(mapped);
}
}
private startAccountPolling(): void {
if (this.accountPollTimer) return;
const poll = async () => {
try {
await this.ensureInitialized();
await this.fetchAndEmitAccount();
} catch (error) {
this.logger("accountPoll", error);
}
};
void poll();
this.accountPollTimer = setInterval(() => void poll(), this.pollIntervals.account);
}
private startOrderPolling(): void {
if (this.orderPollTimer) return;
const poll = async () => {
try {
await this.ensureInitialized();
let openOrders: CcxtOrder[] = [];
let closedOrders: CcxtOrder[] = [];
try {
openOrders = (await this.exchange.fetchOpenOrders(this.marketSymbol)) as CcxtOrder[];
} catch (error) {
this.logger("orderPollOpen", error);
}
try {
closedOrders = (await this.exchange.fetchClosedOrders(
this.marketSymbol,
undefined,
50
)) as CcxtOrder[];
} catch (error) {
this.logger("orderPollClosed", error);
}
this.updateOrdersFromRemote(openOrders, closedOrders);
} catch (error) {
this.logger("orderPoll", error);
this.emitCurrentOrders();
}
};
void poll();
this.orderPollTimer = setInterval(() => void poll(), this.pollIntervals.orders);
}
private startDepthPolling(): void {
if (this.depthPollTimer) return;
const poll = async () => {
try {
await this.ensureInitialized();
const orderbook = (await this.exchange.fetchOrderBook(this.marketSymbol, 50)) as CcxtOrderBook;
const depth = this.mapOrderBookToDepth(orderbook);
for (const listener of this.depthListeners) {
listener(depth);
}
} catch (error) {
this.logger("depthPoll", error);
}
};
void poll();
this.depthPollTimer = setInterval(() => void poll(), this.pollIntervals.depth);
}
private startTickerPolling(): void {
if (this.tickerPollTimer) return;
const poll = async () => {
try {
await this.ensureInitialized();
const ticker = (await this.exchange.fetchTicker(this.marketSymbol)) as CcxtTicker;
const mapped = this.mapTickerToAsterTicker(ticker);
for (const listener of this.tickerListeners) {
listener(mapped);
}
} catch (error) {
this.logger("tickerPoll", error);
}
};
void poll();
this.tickerPollTimer = setInterval(() => void poll(), this.pollIntervals.ticker);
}
private startKlinePolling(interval: string): void {
if (this.klinePollTimers.has(interval)) return;
const poll = async () => {
try {
await this.ensureInitialized();
const ohlcv = (await this.exchange.fetchOHLCV(this.marketSymbol, interval, undefined, 100)) as CcxtOhlcv[];
const klines = ohlcv
.filter((candle) => Array.isArray(candle) && candle.length >= 6)
.map((candle) => this.mapOHLCVToKline(candle as CcxtOhlcv, interval));
for (const listener of this.klineListeners.get(interval) ?? []) {
listener(klines);
}
} catch (error) {
this.logger(`klinePoll:${interval}`, error);
}
};
void poll();
this.klinePollTimers.set(interval, setInterval(() => void poll(), this.pollIntervals.klines));
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
await this.ensureInitialized(params.symbol);
const symbol = this.marketSymbol;
const type = this.mapOrderTypeToCcxt(params.type);
const side = params.side.toLowerCase();
const amount = params.quantity;
const price = params.price;
const extraParams: Record<string, unknown> = {};
if (params.stopPrice !== undefined) extraParams.stopPrice = params.stopPrice;
if (params.timeInForce) extraParams.timeInForce = params.timeInForce;
if (params.reduceOnly !== undefined) {
extraParams.reduceOnly = params.reduceOnly === "true";
}
try {
const order = (await this.exchange.createOrder(
symbol,
type,
side,
amount,
price,
extraParams
)) as CcxtOrder;
const mapped = this.mapOrderToAsterOrder(order);
this.upsertLocalOrder(mapped);
return mapped;
} catch (error) {
throw new Error(`Paradex createOrder failed: ${extractMessage(error)}`);
}
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized(params.symbol);
try {
await this.exchange.cancelOrder(params.orderId, this.marketSymbol);
this.removeLocalOrder(String(params.orderId));
} catch (error) {
throw new Error(`Paradex cancelOrder failed: ${extractMessage(error)}`);
}
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized(params.symbol);
const errors: Array<{ id: number | string; error: unknown }> = [];
await Promise.all(
params.orderIdList.map(async (orderId) => {
try {
await this.exchange.cancelOrder(orderId, this.marketSymbol);
this.removeLocalOrder(String(orderId));
} catch (error) {
errors.push({ id: orderId, error });
}
})
);
if (errors.length) {
const messages = errors.map((entry) => `${entry.id}: ${extractMessage(entry.error)}`).join("; ");
throw new Error(`Paradex cancelOrders failed for ${messages}`);
}
}
async cancelAllOrders(_params: { symbol: string }): Promise<void> {
await this.ensureInitialized();
try {
if (typeof this.exchange.cancelAllOrders === "function") {
await this.exchange.cancelAllOrders(this.marketSymbol);
} else {
const openOrders = (await this.exchange.fetchOpenOrders(this.marketSymbol)) as CcxtOrder[];
await Promise.all(openOrders.map((order) => this.exchange.cancelOrder(order.id, this.marketSymbol)));
}
this.localOrders.clear();
this.emitCurrentOrders();
} catch (error) {
throw new Error(`Paradex cancelAllOrders failed: ${extractMessage(error)}`);
}
}
private mapBalanceToAccountSnapshot(balance: Balances): AsterAccountSnapshot {
const now = Date.now();
const rawPositions = (() => {
const info = (balance as unknown as { info?: { positions?: unknown } })?.info;
const positionsValue = info?.positions;
if (!positionsValue) return [] as Array<any>;
if (Array.isArray(positionsValue)) return positionsValue as Array<any>;
return Object.values(positionsValue as Record<string, unknown>);
})();
const positions = this.normalizePositions(rawPositions, now);
this.logger("positions", JSON.stringify({ raw: rawPositions, mapped: positions }));
this.logger(
"balanceSnapshot",
JSON.stringify({
total: balance.total,
free: balance.free,
used: balance.used,
})
);
const free = (balance.free ?? {}) as Record<string, number | undefined>;
const used = (balance.used ?? {}) as Record<string, number | undefined>;
const total = (balance.total ?? {}) as Record<string, number | undefined>;
const assetKeys = new Set<string>([
...Object.keys(free),
...Object.keys(used),
...Object.keys(total),
]);
const assets: AsterAccountAsset[] = Array.from(assetKeys).map((asset) => ({
asset,
walletBalance: String(total[asset] ?? 0),
availableBalance: String(free[asset] ?? 0),
updateTime: now,
}));
const totalWalletBalance = Array.from(assetKeys).reduce((acc, asset) => {
const value = total[asset];
return acc + (typeof value === "number" ? value : Number(value ?? 0));
}, 0);
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: now,
totalWalletBalance: totalWalletBalance.toString(),
totalUnrealizedProfit: positions
.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0)
.toString(),
positions,
assets,
};
}
private mapBalanceToAccountSnapshotFromPositions(rawPositions: any[]): AsterAccountSnapshot {
const now = Date.now();
const positions = this.normalizePositions(rawPositions, now);
this.logger("positions", JSON.stringify({ raw: rawPositions, mapped: positions }));
const snapshot = this.lastBalanceSnapshot ?? {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: now,
totalWalletBalance: "0",
totalUnrealizedProfit: "0",
positions: [],
assets: [],
};
return {
...snapshot,
updateTime: now,
totalUnrealizedProfit: positions
.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0)
.toString(),
positions,
};
}
private normalizePositions(rawPositions: any[], now: number): AsterAccountSnapshot["positions"] {
return rawPositions
.filter((pos) => pos)
.map((pos: any) => {
const rawSymbol =
pos?.symbol ?? pos?.instrument ?? pos?.market ?? pos?.info?.market ?? this.marketSymbol;
const normalizedSymbol =
rawSymbol === this.marketSymbol || rawSymbol === this.displaySymbol
? this.displaySymbol
: String(rawSymbol ?? this.displaySymbol);
const quantityRaw =
pos?.positionAmt ?? pos?.contracts ?? pos?.size ?? pos?.amount ?? pos?.info?.size ?? 0;
let quantityNum = Number(quantityRaw);
if (!Number.isFinite(quantityNum)) {
quantityNum = Number(pos?.size ?? pos?.positionAmt ?? 0);
}
const rawSide = String(
pos?.side ?? pos?.info?.side ?? pos?.positionSide ?? pos?.position_side ?? ""
).toLowerCase();
const isShort = rawSide.includes("short") || rawSide.includes("sell");
const positionAmt = isShort ? -Math.abs(quantityNum) : Math.abs(quantityNum);
const entryPrice =
pos?.entryPrice ??
pos?.averageEntryPrice ??
pos?.info?.average_entry_price ??
pos?.entry_price ??
"0";
const unrealized =
pos?.unrealizedPnl ??
pos?.info?.unrealized_pnl ??
pos?.unrealized_profit ??
"0";
return {
symbol: normalizedSymbol,
positionAmt: positionAmt.toString(),
entryPrice: String(entryPrice ?? "0"),
unrealizedProfit: String(unrealized ?? "0"),
positionSide: "BOTH" as const,
updateTime: now,
};
});
}
private mapOrderToAsterOrder(order: CcxtOrder): AsterOrder {
const side = (order.side ?? "buy").toUpperCase() as "BUY" | "SELL";
const mappedType = this.mapCcxtOrderTypeToAster(order.type);
return {
orderId: String(order.id ?? ""),
clientOrderId: (order.clientOrderId as any as string) || "",
symbol: this.displaySymbol,
side,
type: mappedType,
status: (order.status as any as string) || "",
price: order.price?.toString() || "0",
origQty: order.amount?.toString() || "0",
executedQty: order.filled?.toString() || "0",
stopPrice: order.stopPrice?.toString() || "0",
time: order.timestamp || Date.now(),
updateTime: order.lastUpdateTimestamp || Date.now(),
reduceOnly: Boolean((order.info?.reduceOnly as boolean | undefined) ?? false),
closePosition: Boolean((order.info?.closePosition as boolean | undefined) ?? false),
avgPrice: order.average?.toString(),
cumQuote: order.cost?.toString(),
};
}
private mapOrderBookToDepth(orderbook: CcxtOrderBook): AsterDepth {
return {
lastUpdateId: orderbook.nonce || Date.now(),
bids: (orderbook.bids || [])
.filter((t) => t && t.length >= 2)
.map(([price, amount]) => [String(price ?? 0), String(amount ?? 0)]),
asks: (orderbook.asks || [])
.filter((t) => t && t.length >= 2)
.map(([price, amount]) => [String(price ?? 0), String(amount ?? 0)]),
eventTime: orderbook.timestamp,
};
}
private mapTickerToAsterTicker(ticker: CcxtTicker): AsterTicker {
return {
symbol: ticker.symbol,
lastPrice: ticker.last?.toString() || "0",
openPrice: ticker.open?.toString() || "0",
highPrice: ticker.high?.toString() || "0",
lowPrice: ticker.low?.toString() || "0",
volume: ticker.baseVolume?.toString() || "0",
quoteVolume: ticker.quoteVolume?.toString() || "0",
eventTime: ticker.timestamp,
};
}
private mapOHLCVToKline(candle: CcxtOhlcv, interval: string): AsterKline {
const [timestampRaw, openRaw, highRaw, lowRaw, closeRaw, volumeRaw] = candle;
const timestamp = typeof timestampRaw === "number" && Number.isFinite(timestampRaw)
? timestampRaw
: Date.now();
const open = Number(openRaw ?? 0);
const high = Number(highRaw ?? 0);
const low = Number(lowRaw ?? 0);
const close = Number(closeRaw ?? 0);
const volume = Number(volumeRaw ?? 0);
return {
openTime: timestamp,
closeTime: timestamp + this.getIntervalMs(interval),
open: open.toString(),
high: high.toString(),
low: low.toString(),
close: close.toString(),
volume: volume.toString(),
numberOfTrades: 0,
};
}
private mapOrderTypeToCcxt(type: string): string {
const typeMap: Record<string, string> = {
LIMIT: "limit",
MARKET: "market",
STOP_MARKET: "stop",
TRAILING_STOP_MARKET: "trailing-stop",
};
return typeMap[type] || "limit";
}
private mapCcxtOrderTypeToAster(type: string | undefined): OrderType {
const typeMap: Record<string, OrderType> = {
limit: "LIMIT",
market: "MARKET",
stop: "STOP_MARKET",
"trailing-stop": "TRAILING_STOP_MARKET",
};
return type ? typeMap[type] ?? "LIMIT" : "LIMIT";
}
private normalizeInterval(interval: string): string {
const map: Record<string, string> = {
"1m": "1m",
"3m": "3m",
"5m": "5m",
"15m": "15m",
"30m": "30m",
"1h": "1h",
"4h": "4h",
"1d": "1d",
};
return map[interval] ?? "1m";
}
private getIntervalMs(interval: string): number {
const base: Record<string, number> = {
"1m": 60 * 1000,
"3m": 3 * 60 * 1000,
"5m": 5 * 60 * 1000,
"15m": 15 * 60 * 1000,
"30m": 30 * 60 * 1000,
"1h": 60 * 60 * 1000,
"4h": 4 * 60 * 60 * 1000,
"1d": 24 * 60 * 60 * 1000,
};
return base[interval] ?? 60 * 1000;
}
private upsertLocalOrder(order: AsterOrder): void {
const key = String(order.orderId);
if (this.isOrderClosed(order)) {
this.localOrders.delete(key);
this.emitCurrentOrders();
return;
}
this.localOrders.set(key, order);
this.emitCurrentOrders();
}
private removeLocalOrder(orderId: string): void {
if (this.localOrders.delete(orderId)) {
this.emitCurrentOrders();
}
}
private updateOrdersFromRemote(open: CcxtOrder[], closed: CcxtOrder[]): void {
const nextOpen = new Map<string, AsterOrder>();
for (const order of open) {
const mapped = this.mapOrderToAsterOrder(order);
if (!this.isOrderClosed(mapped)) {
nextOpen.set(String(mapped.orderId), mapped);
}
}
this.localOrders.clear();
for (const [id, order] of nextOpen.entries()) {
this.localOrders.set(id, order);
}
this.emitCurrentOrders();
}
private emitCurrentOrders(): void {
if (!this.orderListeners.size) return;
const open = Array.from(this.localOrders.values()).filter((order) => !this.isOrderClosed(order));
for (const listener of this.orderListeners) {
try {
listener(open);
} catch (error) {
this.logger("emitOrders", error);
}
}
}
private isOrderClosed(order: AsterOrder): boolean {
const status = (order.status ?? "").toUpperCase();
if (
status.includes("CLOSE") ||
status.includes("FILLED") ||
status.includes("CANCEL") ||
status.includes("REJECT")
) {
return true;
}
const orig = Number(order.origQty ?? 0);
const filled = Number(order.executedQty ?? 0);
if (Number.isFinite(orig) && Number.isFinite(filled) && Math.abs(orig - filled) < 1e-12) {
return true;
}
return false;
}
private sleep(duration: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, duration);
});
}
private async fetchAndEmitAccount(): Promise<void> {
const balance = (await this.exchange.fetchBalance()) as Balances;
await this.attachPositions(balance);
const snapshot = this.mapBalanceToAccountSnapshot(balance);
this.lastBalanceSnapshot = snapshot;
for (const listener of this.accountListeners) {
listener(snapshot);
}
}
private async attachPositions(balance: Balances): Promise<void> {
const fetchPositions = (this.exchange as any).fetchPositions;
if (typeof fetchPositions !== "function") return;
try {
const positions = await fetchPositions.call(this.exchange);
this.logger("fetchPositionsResponse", JSON.stringify(positions));
if (Array.isArray(positions)) {
((balance as unknown as { info?: Record<string, unknown> }).info ??= {}).positions = positions;
}
} catch (error) {
this.logger("fetchPositions", error);
}
}
private async verifyAccountAccess(): Promise<void> {
if (this.onboardingChecked) return;
try {
await this.exchange.fetchBalance();
this.onboardingChecked = true;
} catch (error) {
if (this.isNotOnboardedError(error)) {
throw new Error(
"Paradex account is not onboarded. Please complete the /onboarding flow on Paradex before running the bot."
);
}
throw error;
}
}
private isNotOnboardedError(error: unknown): boolean {
const message = extractMessage(error).toUpperCase();
if (message.includes("NOT_ONBOARDED")) return true;
const body = (error as any)?.body ?? (error as any)?.response;
if (typeof body === "string" && body.toUpperCase().includes("NOT_ONBOARDED")) return true;
if (body && typeof body === "object") {
const serialized = JSON.stringify(body).toUpperCase();
if (serialized.includes("NOT_ONBOARDED")) return true;
}
return false;
}
}
+137
View File
@@ -0,0 +1,137 @@
import type { ExchangeAdapter } from "./adapter";
import { createExchangeAdapter, resolveExchangeId, type SupportedExchangeId } from "./create-adapter";
import type { AsterCredentials } from "./aster-adapter";
import type { LighterCredentials } from "./lighter/adapter";
import type { BackpackCredentials } from "./backpack/adapter";
import type { ParadexCredentials } from "./paradex/adapter";
interface BuildAdapterOptions {
symbol: string;
exchangeId?: string | SupportedExchangeId;
}
export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapter {
const id = resolveExchangeId(options.exchangeId);
const symbol = options.symbol;
if (id === "aster") {
const credentials = resolveAsterCredentials();
return createExchangeAdapter({ exchange: id, symbol, aster: credentials });
}
if (id === "lighter") {
const credentials = resolveLighterCredentials(symbol);
return createExchangeAdapter({ exchange: id, symbol, lighter: credentials });
}
if (id === "backpack") {
const credentials = resolveBackpackCredentials(symbol);
return createExchangeAdapter({ exchange: id, symbol, backpack: credentials });
}
if (id === "paradex") {
const credentials = resolveParadexCredentials();
return createExchangeAdapter({ exchange: id, symbol, paradex: credentials });
}
return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } });
}
function resolveAsterCredentials(): AsterCredentials {
const apiKey = process.env.ASTER_API_KEY;
const apiSecret = process.env.ASTER_API_SECRET;
if (!apiKey || !apiSecret) {
throw new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量");
}
return { apiKey, apiSecret };
}
function resolveLighterCredentials(symbol: string): LighterCredentials {
const accountIndexRaw = process.env.LIGHTER_ACCOUNT_INDEX;
const apiPrivateKey = process.env.LIGHTER_API_PRIVATE_KEY;
if (!accountIndexRaw || !apiPrivateKey) {
throw new Error("缺少 LIGHTER_ACCOUNT_INDEX 或 LIGHTER_API_PRIVATE_KEY 环境变量");
}
const accountIndex = Number(accountIndexRaw);
if (!Number.isInteger(accountIndex)) {
throw new Error("LIGHTER_ACCOUNT_INDEX 必须是整数");
}
const credentials: LighterCredentials = {
displaySymbol: symbol,
accountIndex,
apiPrivateKey,
apiKeyIndex: process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0,
environment: process.env.LIGHTER_ENV,
baseUrl: process.env.LIGHTER_BASE_URL,
l1Address: process.env.LIGHTER_L1_ADDRESS,
marketSymbol: process.env.LIGHTER_SYMBOL,
marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined,
priceDecimals: process.env.LIGHTER_PRICE_DECIMALS ? Number(process.env.LIGHTER_PRICE_DECIMALS) : undefined,
sizeDecimals: process.env.LIGHTER_SIZE_DECIMALS ? Number(process.env.LIGHTER_SIZE_DECIMALS) : undefined,
};
return credentials;
}
function resolveBackpackCredentials(symbol: string): BackpackCredentials {
const apiKey = process.env.BACKPACK_API_KEY;
const apiSecret = process.env.BACKPACK_API_SECRET;
if (!apiKey || !apiSecret) {
throw new Error("缺少 BACKPACK_API_KEY 或 BACKPACK_API_SECRET 环境变量");
}
const credentials: BackpackCredentials = {
apiKey,
apiSecret,
password: process.env.BACKPACK_PASSWORD,
subaccount: process.env.BACKPACK_SUBACCOUNT,
symbol: process.env.BACKPACK_SYMBOL ?? symbol,
sandbox: parseOptionalBoolean(process.env.BACKPACK_SANDBOX),
};
return credentials;
}
function resolveParadexCredentials(): ParadexCredentials {
const privateKey = process.env.PARADEX_PRIVATE_KEY;
const walletAddress = process.env.PARADEX_WALLET_ADDRESS;
if (!privateKey || !walletAddress) {
throw new Error("Paradex 需要配置 PARADEX_PRIVATE_KEY 与 PARADEX_WALLET_ADDRESS");
}
if (!isHex32(privateKey)) {
throw new Error("PARADEX_PRIVATE_KEY 必须是 0x 开头的 32 字节十六进制字符串");
}
if (!isHexAddress(walletAddress)) {
throw new Error("PARADEX_WALLET_ADDRESS 必须是有效的 0x 开头 40 字节十六进制地址");
}
const credentials: ParadexCredentials = {
privateKey,
walletAddress,
sandbox: parseOptionalBoolean(process.env.PARADEX_SANDBOX),
usePro: parseOptionalBoolean(process.env.PARADEX_USE_PRO),
watchReconnectDelayMs: parseOptionalNumber(process.env.PARADEX_RECONNECT_DELAY_MS),
};
return credentials;
}
function isHex32(value: string): boolean {
return /^0x[0-9a-fA-F]{64}$/.test(value.trim());
}
function isHexAddress(value: string): boolean {
return /^0x[0-9a-fA-F]{40}$/.test(value.trim());
}
function parseOptionalBoolean(value: string | undefined): boolean | undefined {
if (value == null) return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (["false", "0", "no", "off"].includes(normalized)) return false;
return true;
}
function parseOptionalNumber(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
+146 -17
View File
@@ -9,7 +9,7 @@ import type {
} from "../exchanges/types";
import { formatPriceToString } from "../utils/math";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { isUnknownOrderError, isRateLimitError } from "../utils/errors";
import { extractMessage, isInsufficientBalanceError, isUnknownOrderError, isRateLimitError } from "../utils/errors";
import { getPosition } from "../utils/strategy";
import type { PositionSnapshot } from "../utils/strategy";
import { computePositionPnl } from "../utils/pnl";
@@ -49,12 +49,19 @@ export interface MakerEngineSnapshot {
desiredOrders: DesiredOrder[];
tradeLog: TradeLogEntry[];
lastUpdated: number | null;
feedStatus: {
account: boolean;
orders: boolean;
depth: boolean;
ticker: boolean;
};
}
type MakerEvent = "update";
type MakerListener = (snapshot: MakerEngineSnapshot) => void;
const EPS = 1e-5;
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
export class MakerEngine {
private accountSnapshot: AsterAccountSnapshot | null = null;
@@ -78,6 +85,28 @@ export class MakerEngine {
private initialOrderSnapshotReady = false;
private initialOrderResetDone = false;
private entryPricePendingLogged = false;
private readinessLogged = {
account: false,
depth: false,
ticker: false,
orders: false,
};
private feedArrived = {
account: false,
depth: false,
ticker: false,
orders: false,
};
private feedStatus = {
account: false,
depth: false,
ticker: false,
orders: false,
};
private insufficientBalanceCooldownUntil = 0;
private insufficientBalanceNotified = false;
private lastInsufficientMessage: string | null = null;
private lastDesiredSummary: string | null = null;
private readonly rateLimit: RateLimitController;
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
@@ -127,6 +156,11 @@ export class MakerEngine {
}
const position = getPosition(snapshot, this.config.symbol);
this.sessionVolume.update(position, this.getReferencePrice());
if (!this.feedArrived.account) {
this.tradeLog.push("info", "账户快照已同步");
this.feedArrived.account = true;
}
this.feedStatus.account = true;
this.emitUpdate();
},
log,
@@ -150,6 +184,11 @@ export class MakerEngine {
}
}
this.initialOrderSnapshotReady = true;
if (!this.feedArrived.orders) {
this.tradeLog.push("info", "订单快照已返回");
this.feedArrived.orders = true;
}
this.feedStatus.orders = true;
this.emitUpdate();
},
log,
@@ -163,6 +202,11 @@ export class MakerEngine {
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
(depth) => {
this.depthSnapshot = depth;
if (!this.feedArrived.depth) {
this.tradeLog.push("info", "获得最新深度行情");
this.feedArrived.depth = true;
}
this.feedStatus.depth = true;
this.emitUpdate();
},
log,
@@ -176,6 +220,11 @@ export class MakerEngine {
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
(ticker) => {
this.tickerSnapshot = ticker;
if (!this.feedArrived.ticker) {
this.tradeLog.push("info", "Ticker 已就绪");
this.feedArrived.ticker = true;
}
this.feedStatus.ticker = true;
this.emitUpdate();
},
log,
@@ -185,18 +234,7 @@ export class MakerEngine {
}
);
// Maker strategy does not consume klines, but subscribe to keep parity with other modules
safeSubscribe<AsterKline[]>(
this.exchange.watchKlines.bind(this.exchange, this.config.symbol, "1m"),
(_klines) => {
/* no-op */
},
log,
{
subscribeFail: (error) => `订阅K线失败: ${String(error)}`,
processFail: (error) => `K线推送处理异常: ${String(error)}`,
}
);
// Maker strategy does not require realtime klines.
}
private syncLocksWithOrders(orders: AsterOrder[] | null | undefined): void {
@@ -212,7 +250,12 @@ export class MakerEngine {
}
private isReady(): boolean {
return Boolean(this.accountSnapshot && this.depthSnapshot);
return Boolean(
this.feedStatus.account &&
this.feedStatus.depth &&
this.feedStatus.ticker &&
this.feedStatus.orders
);
}
private async tick(): Promise<void> {
@@ -229,9 +272,11 @@ export class MakerEngine {
return;
}
if (!this.isReady()) {
this.logReadinessBlockers();
this.emitUpdate();
return;
}
this.resetReadinessFlags();
if (!(await this.ensureStartupOrderReset())) {
this.emitUpdate();
return;
@@ -253,7 +298,8 @@ export class MakerEngine {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const absPosition = Math.abs(position.positionAmt);
const desired: DesiredOrder[] = [];
const canEnter = !this.rateLimit.shouldBlockEntries();
const insufficientActive = this.applyInsufficientBalanceState(Date.now());
const canEnter = !this.rateLimit.shouldBlockEntries() && !insufficientActive;
if (absPosition < EPS) {
this.entryPricePendingLogged = false;
@@ -268,6 +314,7 @@ export class MakerEngine {
}
this.desiredOrders = desired;
this.logDesiredOrders(desired);
this.sessionVolume.update(position, this.getReferencePrice());
await this.syncOrders(desired);
await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice));
@@ -331,7 +378,11 @@ export class MakerEngine {
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId)));
const { toCancel, toPlace } = makeOrderPlan(availableOrders, targets);
const openOrders = availableOrders.filter((order) => {
const status = (order.status ?? "").toUpperCase();
return !status.includes("CLOSED") && !status.includes("FILLED") && !status.includes("CANCELED");
});
const { toCancel, toPlace } = makeOrderPlan(openOrders, targets);
for (const order of toCancel) {
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
@@ -385,7 +436,14 @@ export class MakerEngine {
}
);
} catch (error) {
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
if (isInsufficientBalanceError(error)) {
this.registerInsufficientBalance(error);
break;
}
this.tradeLog.push(
"error",
`挂单失败(${target.side} ${target.price}): ${extractMessage(error)}`
);
}
}
}
@@ -500,10 +558,81 @@ export class MakerEngine {
desiredOrders: this.desiredOrders,
tradeLog: this.tradeLog.all(),
lastUpdated: Date.now(),
feedStatus: { ...this.feedStatus },
};
}
private getReferencePrice(): number | null {
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
}
private logReadinessBlockers(): void {
if (!this.feedStatus.account && !this.readinessLogged.account) {
this.tradeLog.push("info", "等待账户快照同步,尚未开始做市");
this.readinessLogged.account = true;
}
if (!this.feedStatus.depth && !this.readinessLogged.depth) {
this.tradeLog.push("info", "等待深度行情推送,尚未开始做市");
this.readinessLogged.depth = true;
}
if (!this.feedStatus.ticker && !this.readinessLogged.ticker) {
this.tradeLog.push("info", "等待Ticker推送,尚未开始做市");
this.readinessLogged.ticker = true;
}
if (!this.feedStatus.orders && !this.readinessLogged.orders) {
this.tradeLog.push("info", "等待订单快照返回,尚未执行初始化撤单");
this.readinessLogged.orders = true;
}
}
private resetReadinessFlags(): void {
this.readinessLogged = {
account: false,
depth: false,
ticker: false,
orders: false,
};
}
private logDesiredOrders(desired: DesiredOrder[]): void {
if (!desired.length) {
if (this.lastDesiredSummary !== "none") {
this.tradeLog.push("info", "当前无目标挂单,等待下一次刷新");
this.lastDesiredSummary = "none";
}
return;
}
const summary = desired
.map((order) => `${order.side}@${order.price}${order.reduceOnly ? "(RO)" : ""}`)
.join(" | ");
if (summary !== this.lastDesiredSummary) {
this.tradeLog.push("info", `目标挂单: ${summary}`);
this.lastDesiredSummary = summary;
}
}
private registerInsufficientBalance(error: unknown): void {
const now = Date.now();
const detail = extractMessage(error);
const alreadyActive = now < this.insufficientBalanceCooldownUntil;
if (alreadyActive && detail === this.lastInsufficientMessage) {
this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS;
return;
}
this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS;
this.lastInsufficientMessage = detail;
const seconds = Math.ceil(INSUFFICIENT_BALANCE_COOLDOWN_MS / 1000);
this.tradeLog.push("warn", `余额不足,暂停新挂单 ${seconds}s: ${detail}`);
this.insufficientBalanceNotified = true;
}
private applyInsufficientBalanceState(now: number): boolean {
const active = now < this.insufficientBalanceCooldownUntil;
if (!active && this.insufficientBalanceNotified) {
this.tradeLog.push("info", "余额检测恢复,重新尝试挂单");
this.insufficientBalanceNotified = false;
this.lastInsufficientMessage = null;
}
return active;
}
}
+5 -1
View File
@@ -449,6 +449,10 @@ export class OffsetMakerEngine {
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId)));
const openOrders = availableOrders.filter((order) => {
const status = (order.status ?? "").toUpperCase();
return !status.includes("CLOSED") && !status.includes("FILLED") && !status.includes("CANCELED");
});
// Coalesce reprices for entry orders: if within tick threshold or within dwell window, keep existing order
const adjustedTargets: DesiredOrder[] = targets.map((t) => ({ ...t }));
@@ -474,7 +478,7 @@ export class OffsetMakerEngine {
}
}
const { toCancel, toPlace } = makeOrderPlan(availableOrders, adjustedTargets);
const { toCancel, toPlace } = makeOrderPlan(openOrders, adjustedTargets);
for (const order of toCancel) {
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
+2
View File
@@ -242,6 +242,8 @@ export class TrendEngine {
this.exchange.watchKlines.bind(this.exchange, this.config.symbol, this.config.klineInterval),
(klines) => {
this.klineSnapshot = Array.isArray(klines) ? klines : [];
const latestSma = getSMA(this.klineSnapshot, 30);
this.lastSma30 = latestSma;
this.logKlineSnapshot();
this.emitUpdate();
},
+19 -49
View File
@@ -1,11 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { makerConfig } from "../config";
import {
createExchangeAdapter,
getExchangeDisplayName,
resolveExchangeId,
} from "../exchanges/create-adapter";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
@@ -35,50 +32,7 @@ export function MakerApp({ onExit }: MakerAppProps) {
useEffect(() => {
try {
let adapter;
if (exchangeId === "aster") {
const apiKey = process.env.ASTER_API_KEY;
const apiSecret = process.env.ASTER_API_SECRET;
if (!apiKey || !apiSecret) {
setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量"));
return;
}
adapter = createExchangeAdapter({
exchange: exchangeId,
symbol: makerConfig.symbol,
aster: { apiKey, apiSecret },
});
} else if (exchangeId === "lighter") {
const accountIndexRaw = process.env.LIGHTER_ACCOUNT_INDEX;
const apiPrivateKey = process.env.LIGHTER_API_PRIVATE_KEY;
if (!accountIndexRaw || !apiPrivateKey) {
setError(new Error("缺少 LIGHTER_ACCOUNT_INDEX 或 LIGHTER_API_PRIVATE_KEY 环境变量"));
return;
}
adapter = createExchangeAdapter({
exchange: exchangeId,
symbol: makerConfig.symbol,
lighter: {
displaySymbol: makerConfig.symbol,
accountIndex: parseInt(accountIndexRaw, 10),
apiPrivateKey,
apiKeyIndex: process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0,
environment: process.env.LIGHTER_ENV,
baseUrl: process.env.LIGHTER_BASE_URL,
marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined,
marketSymbol: process.env.LIGHTER_SYMBOL,
priceDecimals: process.env.LIGHTER_PRICE_DECIMALS ? Number(process.env.LIGHTER_PRICE_DECIMALS) : undefined,
sizeDecimals: process.env.LIGHTER_SIZE_DECIMALS ? Number(process.env.LIGHTER_SIZE_DECIMALS) : undefined,
l1Address: process.env.LIGHTER_L1_ADDRESS,
},
});
} else {
adapter = createExchangeAdapter({
exchange: exchangeId,
symbol: makerConfig.symbol,
grvt: { symbol: makerConfig.symbol },
});
}
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol });
const engine = new MakerEngine(makerConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
@@ -154,6 +108,13 @@ export function MakerApp({ onExit }: MakerAppProps) {
];
const lastLogs = snapshot.tradeLog.slice(-5);
const feedStatus = snapshot.feedStatus;
const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [
{ key: "account", label: "账户" },
{ key: "orders", label: "订单" },
{ key: "depth", label: "深度" },
{ key: "ticker", label: "Ticker" },
];
return (
<Box flexDirection="column" paddingX={1}>
@@ -163,6 +124,15 @@ export function MakerApp({ onExit }: MakerAppProps) {
: {exchangeName} : {snapshot.symbol} : {formatNumber(topBid, 2)} : {formatNumber(topAsk, 2)} : {spreadDisplay}
</Text>
<Text color="gray">: {snapshot.ready ? "实时运行" : "等待市场数据"} Esc </Text>
<Text>
:
{feedEntries.map((entry, index) => (
<Text key={entry.key} color={feedStatus[entry.key] ? "green" : "red"}>
{index === 0 ? " " : " "}
{entry.label}
</Text>
))}
</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
+3 -25
View File
@@ -1,11 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { makerConfig } from "../config";
import {
createExchangeAdapter,
getExchangeDisplayName,
resolveExchangeId,
} from "../exchanges/create-adapter";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
@@ -35,26 +32,7 @@ export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
useEffect(() => {
try {
let adapter;
if (exchangeId === "aster") {
const apiKey = process.env.ASTER_API_KEY;
const apiSecret = process.env.ASTER_API_SECRET;
if (!apiKey || !apiSecret) {
setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量"));
return;
}
adapter = createExchangeAdapter({
exchange: exchangeId,
symbol: makerConfig.symbol,
aster: { apiKey, apiSecret },
});
} else {
adapter = createExchangeAdapter({
exchange: exchangeId,
symbol: makerConfig.symbol,
grvt: { symbol: makerConfig.symbol },
});
}
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol });
const engine = new OffsetMakerEngine(makerConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
+3 -25
View File
@@ -1,11 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { tradingConfig } from "../config";
import {
createExchangeAdapter,
getExchangeDisplayName,
resolveExchangeId,
} from "../exchanges/create-adapter";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
import { formatNumber } from "../utils/format";
import { DataTable, type TableColumn } from "./components/DataTable";
@@ -37,26 +34,7 @@ export function TrendApp({ onExit }: TrendAppProps) {
useEffect(() => {
try {
let adapter;
if (exchangeId === "aster") {
const apiKey = process.env.ASTER_API_KEY;
const apiSecret = process.env.ASTER_API_SECRET;
if (!apiKey || !apiSecret) {
setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量"));
return;
}
adapter = createExchangeAdapter({
exchange: exchangeId,
symbol: tradingConfig.symbol,
aster: { apiKey, apiSecret },
});
} else {
adapter = createExchangeAdapter({
exchange: exchangeId,
symbol: tradingConfig.symbol,
grvt: { symbol: tradingConfig.symbol },
});
}
const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol });
const engine = new TrendEngine(tradingConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
+18 -1
View File
@@ -1,7 +1,14 @@
export function isUnknownOrderError(error: unknown): boolean {
const message = extractMessage(error);
if (!message) return false;
return message.includes("Unknown order") || message.includes("code\":-2011");
const upper = message.toUpperCase();
return (
upper.includes("UNKNOWN ORDER") ||
upper.includes("CODE\":-2011") ||
upper.includes("ORDER_ID_NOT_FOUND") ||
upper.includes("ORDER_IS_CLOSED") ||
upper.includes("COULD NOT FIND ORDER")
);
}
export function extractMessage(error: unknown): string {
@@ -36,3 +43,13 @@ export function isRateLimitError(error: unknown): boolean {
message.includes("request rate")
);
}
export function isInsufficientBalanceError(error: unknown): boolean {
const message = extractMessage(error).toUpperCase();
return (
message.includes("INSUFFICIENT") ||
message.includes("NOT_ENOUGH_BALANCE") ||
message.includes("INSUFFICIENT_BALANCE") ||
message.includes("NOT ENOUGH")
);
}
+11 -3
View File
@@ -32,10 +32,18 @@ export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: strin
}
export function getSMA(values: AsterKline[], length: number): number | null {
if (!values || values.length < length) return null;
const closes = values.slice(-length).map((k) => Number(k.close));
if (!Array.isArray(values) || values.length < length) return null;
const window = values.slice(-length);
const closes = window.map((kline) => Number(kline.close));
if (closes.some((price) => !Number.isFinite(price))) {
return null;
}
const sum = closes.reduce((acc, current) => acc + current, 0);
return sum / closes.length;
if (!Number.isFinite(sum)) {
return null;
}
const average = sum / closes.length;
return Number.isFinite(average) ? average : null;
}
export function calcStopLossPrice(entryPrice: number, qty: number, side: "long" | "short", loss: number): number {