mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
Merge branch 'main' into dev
This commit is contained in:
+24
-1
@@ -1,5 +1,5 @@
|
|||||||
# Exchange selection
|
# Exchange selection
|
||||||
EXCHANGE=aster # Pick aster (default) or grvt
|
EXCHANGE=aster # Pick aster (default) or grvt/lighter/backpack/paradex
|
||||||
|
|
||||||
# Aster API credentials
|
# Aster API credentials
|
||||||
ASTER_API_KEY=
|
ASTER_API_KEY=
|
||||||
@@ -87,3 +87,26 @@ EDGEX_PRIVATE_KEY=
|
|||||||
# EDGEX_WS_PUBLIC_URL=wss://quote.edgex.exchange
|
# EDGEX_WS_PUBLIC_URL=wss://quote.edgex.exchange
|
||||||
# EDGEX_WS_PRIVATE_URL=wss://quote.edgex.exchange
|
# EDGEX_WS_PRIVATE_URL=wss://quote.edgex.exchange
|
||||||
# EDGEX_ORDER_TTL_MS=21600000 # Order expiration window (ms), default 6 hours
|
# EDGEX_ORDER_TTL_MS=21600000 # Order expiration window (ms), default 6 hours
|
||||||
|
|
||||||
|
|
||||||
|
# Paradex exchange configuration
|
||||||
|
# Provide the EVM private key & wallet address for onboarded accounts.
|
||||||
|
# When EXCHANGE=paradex these values are used automatically.
|
||||||
|
|
||||||
|
PARADEX_PRIVATE_KEY=
|
||||||
|
PARADEX_WALLET_ADDRESS=
|
||||||
|
|
||||||
|
# Symbol defaults to TRADE_SYMBOL if omitted. Use ccxt unified format like BTC-USD-PERP.
|
||||||
|
# PARADEX_SYMBOL=BTC-USD-PERP
|
||||||
|
|
||||||
|
# Enable testnet endpoints by setting to "true"; defaults to false (mainnet).
|
||||||
|
# PARADEX_SANDBOX=false
|
||||||
|
|
||||||
|
# Force disabling ccxt.pro websocket usage by setting to "false" (pro is preferred when installed).
|
||||||
|
# PARADEX_USE_PRO=true
|
||||||
|
|
||||||
|
# Optional reconnect delay override (milliseconds, e.g., 2000). Leave blank for default.
|
||||||
|
# PARADEX_RECONNECT_DELAY_MS=
|
||||||
|
|
||||||
|
# Enable verbose adapter logging: set to "1" or "true"
|
||||||
|
# PARADEX_DEBUG=false
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { makerConfig, tradingConfig } from "../config";
|
import { makerConfig, tradingConfig } from "../config";
|
||||||
import {
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
createExchangeAdapter,
|
|
||||||
getExchangeDisplayName,
|
|
||||||
resolveExchangeId,
|
|
||||||
} from "../exchanges/create-adapter";
|
|
||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
import {
|
import {
|
||||||
MakerEngine,
|
MakerEngine,
|
||||||
type MakerEngineSnapshot,
|
type MakerEngineSnapshot,
|
||||||
@@ -147,52 +144,7 @@ async function runEngine<TSnapshot extends TrendEngineSnapshot | MakerEngineSnap
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAdapterOrThrow(symbol: string): ExchangeAdapter {
|
function createAdapterOrThrow(symbol: string): ExchangeAdapter {
|
||||||
const exchangeId = resolveExchangeId();
|
return buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TradeLogEntry = { time: string; type: string; detail: string };
|
type TradeLogEntry = { time: string; type: string; detail: string };
|
||||||
|
|||||||
+36
-2
@@ -10,9 +10,21 @@
|
|||||||
* - BACKPACK_SANDBOX: Set to "true" for sandbox mode
|
* - BACKPACK_SANDBOX: Set to "true" for sandbox mode
|
||||||
* - BACKPACK_DEBUG: Set to "true" for debug logging
|
* - 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
|
* 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 {
|
export interface TradingConfig {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
tradeAmount: number;
|
tradeAmount: number;
|
||||||
@@ -32,6 +44,28 @@ export interface TradingConfig {
|
|||||||
minBollingerBandwidth: number;
|
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 {
|
function parseNumber(value: string | undefined, fallback: number): number {
|
||||||
if (!value) return fallback;
|
if (!value) return fallback;
|
||||||
const next = Number(value);
|
const next = Number(value);
|
||||||
@@ -39,7 +73,7 @@ function parseNumber(value: string | undefined, fallback: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tradingConfig: TradingConfig = {
|
export const tradingConfig: TradingConfig = {
|
||||||
symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT",
|
symbol: resolveSymbolFromEnv(),
|
||||||
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
||||||
lossLimit: parseNumber(process.env.LOSS_LIMIT, 0.03),
|
lossLimit: parseNumber(process.env.LOSS_LIMIT, 0.03),
|
||||||
trailingProfit: parseNumber(process.env.TRAILING_PROFIT, 0.2),
|
trailingProfit: parseNumber(process.env.TRAILING_PROFIT, 0.2),
|
||||||
@@ -70,7 +104,7 @@ export interface MakerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const makerConfig: MakerConfig = {
|
export const makerConfig: MakerConfig = {
|
||||||
symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT",
|
symbol: resolveSymbolFromEnv(),
|
||||||
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
||||||
lossLimit: parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.03)),
|
lossLimit: parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.03)),
|
||||||
bidOffset: parseNumber(process.env.MAKER_BID_OFFSET, 0),
|
bidOffset: parseNumber(process.env.MAKER_BID_OFFSET, 0),
|
||||||
|
|||||||
@@ -238,10 +238,14 @@ export async function placeStopLossOrder(
|
|||||||
side,
|
side,
|
||||||
type,
|
type,
|
||||||
stopPrice: roundDownToTick(stopPrice, priceTick),
|
stopPrice: roundDownToTick(stopPrice, priceTick),
|
||||||
|
reduceOnly: "true",
|
||||||
closePosition: "true",
|
closePosition: "true",
|
||||||
timeInForce: "GTC",
|
timeInForce: "GTC",
|
||||||
quantity: roundQtyDownToStep(quantity, qtyStep),
|
quantity: roundQtyDownToStep(quantity, qtyStep),
|
||||||
|
triggerType: "STOP_LOSS",
|
||||||
};
|
};
|
||||||
|
// 部分交易所(例如 Paradex)要求 STOP_MARKET 同时提供 price 字段
|
||||||
|
params.price = params.stopPrice;
|
||||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import ccxt, { type Balances, type Order as CcxtOrder, type OrderBook as CcxtOrderBook, type Ticker as CcxtTicker } from "ccxt";
|
import ccxt, { type Balances, type Order as CcxtOrder, type OrderBook as CcxtOrderBook, type Ticker as CcxtTicker } from "ccxt";
|
||||||
import type {
|
import type {
|
||||||
AsterAccountSnapshot,
|
AsterAccountSnapshot,
|
||||||
|
AsterAccountPosition,
|
||||||
AsterOrder,
|
AsterOrder,
|
||||||
AsterDepth,
|
AsterDepth,
|
||||||
AsterTicker,
|
AsterTicker,
|
||||||
@@ -30,6 +31,8 @@ export class BackpackGateway {
|
|||||||
private readonly exchange: any;
|
private readonly exchange: any;
|
||||||
private readonly symbol: string;
|
private readonly symbol: string;
|
||||||
private marketSymbol: string;
|
private marketSymbol: string;
|
||||||
|
private market: any | null = null;
|
||||||
|
private isContractMarket = false;
|
||||||
private readonly logger: (context: string, error: unknown) => void;
|
private readonly logger: (context: string, error: unknown) => void;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
@@ -92,6 +95,8 @@ export class BackpackGateway {
|
|||||||
throw new Error(`Symbol ${requested} not found in Backpack markets`);
|
throw new Error(`Symbol ${requested} not found in Backpack markets`);
|
||||||
}
|
}
|
||||||
this.marketSymbol = resolved;
|
this.marketSymbol = resolved;
|
||||||
|
this.market = this.exchange.market(this.marketSymbol);
|
||||||
|
this.isContractMarket = Boolean(this.market?.contract);
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
this.logger("initialize", `Backpack gateway initialized for ${this.marketSymbol}`);
|
this.logger("initialize", `Backpack gateway initialized for ${this.marketSymbol}`);
|
||||||
@@ -189,8 +194,7 @@ export class BackpackGateway {
|
|||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const balance = await this.exchange.fetchBalance();
|
const accountSnapshot = await this.fetchAccountSnapshot();
|
||||||
const accountSnapshot = this.mapBalanceToAccountSnapshot(balance);
|
|
||||||
|
|
||||||
for (const listener of this.accountListeners) {
|
for (const listener of this.accountListeners) {
|
||||||
listener(accountSnapshot);
|
listener(accountSnapshot);
|
||||||
@@ -318,6 +322,9 @@ export class BackpackGateway {
|
|||||||
if (params.reduceOnly !== undefined) {
|
if (params.reduceOnly !== undefined) {
|
||||||
extraParams.reduceOnly = params.reduceOnly === "true";
|
extraParams.reduceOnly = params.reduceOnly === "true";
|
||||||
}
|
}
|
||||||
|
if (params.closePosition !== undefined) {
|
||||||
|
extraParams.closePosition = params.closePosition === "true";
|
||||||
|
}
|
||||||
|
|
||||||
const order = await this.exchange.createOrder(
|
const order = await this.exchange.createOrder(
|
||||||
symbol,
|
symbol,
|
||||||
@@ -360,30 +367,172 @@ export class BackpackGateway {
|
|||||||
|
|
||||||
// Mapping functions
|
// Mapping functions
|
||||||
private mapBalanceToAccountSnapshot(balance: Balances): AsterAccountSnapshot {
|
private mapBalanceToAccountSnapshot(balance: Balances): AsterAccountSnapshot {
|
||||||
const positions: any[] = []; // Backpack is spot-only, no positions
|
return this.mapBalanceToAccountSnapshotWithPositions(balance, []);
|
||||||
const assets: any[] = [];
|
}
|
||||||
|
|
||||||
for (const [currency, amount] of Object.entries(balance)) {
|
private async fetchAccountSnapshot(): Promise<AsterAccountSnapshot> {
|
||||||
if (typeof amount === 'object' && amount !== null) {
|
await this.ensureInitialized();
|
||||||
assets.push({
|
const balancePromise = this.exchange.fetchBalance();
|
||||||
asset: currency,
|
const positionsPromise = this.isContractMarket
|
||||||
walletBalance: amount.total?.toString() || "0",
|
? this.exchange.fetchPositions([this.marketSymbol]).catch((error: unknown) => {
|
||||||
availableBalance: amount.free?.toString() || "0",
|
this.logger("fetchPositions", error);
|
||||||
updateTime: Date.now(),
|
return [];
|
||||||
});
|
})
|
||||||
}
|
: Promise.resolve([]);
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const [balance, positions] = await Promise.all([balancePromise, positionsPromise]);
|
||||||
|
return this.mapBalanceToAccountSnapshotWithPositions(balance, positions ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapBalanceToAccountSnapshotWithPositions(balance: Balances, rawPositions: any[]): AsterAccountSnapshot {
|
||||||
|
const now = Date.now();
|
||||||
|
const assets = this.normalizeAssets(balance, now);
|
||||||
|
const positions = this.normalizePositions(rawPositions, now);
|
||||||
|
|
||||||
|
const totalWalletBalance = this.sumStrings(assets.map((asset) => asset.walletBalance));
|
||||||
|
const totalUnrealizedProfit = this.sumStrings(positions.map((position) => position.unrealizedProfit ?? "0"));
|
||||||
|
const availableBalance = this.sumStrings(assets.map((asset) => asset.availableBalance));
|
||||||
|
|
||||||
|
const snapshot: AsterAccountSnapshot = {
|
||||||
canTrade: true,
|
canTrade: true,
|
||||||
canDeposit: true,
|
canDeposit: true,
|
||||||
canWithdraw: true,
|
canWithdraw: true,
|
||||||
updateTime: Date.now(),
|
updateTime: now,
|
||||||
totalWalletBalance: balance.total?.toString() || "0",
|
totalWalletBalance,
|
||||||
totalUnrealizedProfit: "0",
|
totalUnrealizedProfit,
|
||||||
positions,
|
positions,
|
||||||
assets,
|
assets,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
snapshot.availableBalance = availableBalance;
|
||||||
|
snapshot.maxWithdrawAmount = availableBalance;
|
||||||
|
|
||||||
|
if (this.isContractMarket) {
|
||||||
|
const totalMarginBalance = this.addStrings(totalWalletBalance, totalUnrealizedProfit);
|
||||||
|
snapshot.totalMarginBalance = totalMarginBalance;
|
||||||
|
snapshot.totalCrossWalletBalance = totalWalletBalance;
|
||||||
|
snapshot.totalCrossUnPnl = totalUnrealizedProfit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAssets(balance: Balances, now: number): AsterAccountSnapshot["assets"] {
|
||||||
|
const metaKeys = new Set(["free", "used", "total", "info", "timestamp", "datetime", "debt"]);
|
||||||
|
const assets: AsterAccountSnapshot["assets"] = [];
|
||||||
|
|
||||||
|
for (const [currency, value] of Object.entries(balance)) {
|
||||||
|
if (metaKeys.has(currency)) continue;
|
||||||
|
if (!value || typeof value !== "object") continue;
|
||||||
|
|
||||||
|
const walletBalance = this.toStringAmount((value as any).total ?? (value as any).free ?? "0");
|
||||||
|
const availableBalance = this.toStringAmount((value as any).free ?? "0");
|
||||||
|
|
||||||
|
assets.push({
|
||||||
|
asset: currency,
|
||||||
|
walletBalance,
|
||||||
|
availableBalance,
|
||||||
|
updateTime: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePositions(rawPositions: any[], now: number): AsterAccountSnapshot["positions"] {
|
||||||
|
if (!Array.isArray(rawPositions)) return [];
|
||||||
|
|
||||||
|
const positions: AsterAccountSnapshot["positions"] = [];
|
||||||
|
|
||||||
|
for (const position of rawPositions) {
|
||||||
|
const info = position?.info ?? position ?? {};
|
||||||
|
const rawSymbol = position?.symbol ?? info.symbol ?? this.marketSymbol;
|
||||||
|
const rawContracts = position?.contracts ?? info.netExposureQuantity;
|
||||||
|
const derivedSide = (position?.side ?? info.side ?? this.deriveSideFromExposure(info)) ?? "long";
|
||||||
|
const rawSide = derivedSide.toString().toLowerCase();
|
||||||
|
const quantity = this.toNumber(rawContracts);
|
||||||
|
if (!quantity) continue;
|
||||||
|
|
||||||
|
const side = rawSide === "short" ? "short" : "long";
|
||||||
|
const signedQuantity = side === "short" ? -Math.abs(quantity) : Math.abs(quantity);
|
||||||
|
|
||||||
|
const normalized: AsterAccountPosition = {
|
||||||
|
symbol: rawSymbol,
|
||||||
|
positionAmt: signedQuantity.toString(),
|
||||||
|
entryPrice: this.toStringAmount(position?.entryPrice ?? info.entryPrice ?? "0"),
|
||||||
|
unrealizedProfit: this.toStringAmount(position?.unrealizedPnl ?? info.pnlUnrealized ?? "0"),
|
||||||
|
positionSide: side === "short" ? "SHORT" : "LONG",
|
||||||
|
updateTime: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const markPrice = this.toOptionalString(position?.markPrice ?? info.markPrice);
|
||||||
|
if (markPrice !== undefined) normalized.markPrice = markPrice;
|
||||||
|
|
||||||
|
const liquidationPrice = this.toOptionalString(position?.liquidationPrice ?? info.estLiquidationPrice);
|
||||||
|
if (liquidationPrice !== undefined) normalized.liquidationPrice = liquidationPrice;
|
||||||
|
|
||||||
|
const initialMargin = this.toOptionalString(position?.initialMargin ?? info.initialMargin);
|
||||||
|
if (initialMargin !== undefined) normalized.initialMargin = initialMargin;
|
||||||
|
|
||||||
|
const maintMargin = this.toOptionalString(position?.maintenanceMargin ?? info.maintenanceMargin);
|
||||||
|
if (maintMargin !== undefined) normalized.maintMargin = maintMargin;
|
||||||
|
|
||||||
|
const leverage = this.toOptionalString(position?.leverage ?? info.leverage);
|
||||||
|
if (leverage !== undefined) normalized.leverage = leverage;
|
||||||
|
|
||||||
|
normalized.marginType = "CROSSED";
|
||||||
|
|
||||||
|
positions.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveSideFromExposure(info: Record<string, unknown>): "long" | "short" | "flat" {
|
||||||
|
const exposure = this.toNumber(info?.netExposureNotional ?? info?.netCost ?? info?.netQuantity);
|
||||||
|
if (!exposure) return "flat";
|
||||||
|
return exposure < 0 ? "short" : "long";
|
||||||
|
}
|
||||||
|
|
||||||
|
private toStringAmount(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return "0";
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (value.trim() === "") return "0";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (!Number.isFinite(value)) return "0";
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
private toOptionalString(value: unknown): string | undefined {
|
||||||
|
const normalized = this.toStringAmount(value);
|
||||||
|
return normalized === "0" ? undefined : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toNumber(value: unknown): number {
|
||||||
|
const asString = this.toStringAmount(value);
|
||||||
|
const parsed = Number(asString);
|
||||||
|
if (!Number.isFinite(parsed)) return 0;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sumStrings(values: string[]): string {
|
||||||
|
let total = 0;
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) continue;
|
||||||
|
total += parsed;
|
||||||
|
}
|
||||||
|
return total.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private addStrings(a: string, b: string): string {
|
||||||
|
const sum = Number(a) + Number(b);
|
||||||
|
if (!Number.isFinite(sum)) return "0";
|
||||||
|
return sum.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapOrderToAsterOrder(order: CcxtOrder): AsterOrder {
|
private mapOrderToAsterOrder(order: CcxtOrder): AsterOrder {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GrvtExchangeAdapter, type GrvtCredentials } from "./grvt/adapter";
|
|||||||
import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter";
|
import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter";
|
||||||
import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter";
|
import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter";
|
||||||
import { EdgeXExchangeAdapter, type EdgeXCredentials } from "./edgex/adapter";
|
import { EdgeXExchangeAdapter, type EdgeXCredentials } from "./edgex/adapter";
|
||||||
|
import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter";
|
||||||
|
|
||||||
export interface ExchangeFactoryOptions {
|
export interface ExchangeFactoryOptions {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -13,9 +14,10 @@ export interface ExchangeFactoryOptions {
|
|||||||
lighter?: LighterCredentials;
|
lighter?: LighterCredentials;
|
||||||
backpack?: BackpackCredentials;
|
backpack?: BackpackCredentials;
|
||||||
edgex?: EdgeXCredentials;
|
edgex?: EdgeXCredentials;
|
||||||
|
paradex?: ParadexCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "edgex";
|
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "edgex" | "paradex";
|
||||||
|
|
||||||
export function resolveExchangeId(value?: string | null): SupportedExchangeId {
|
export function resolveExchangeId(value?: string | null): SupportedExchangeId {
|
||||||
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
|
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 === "lighter") return "lighter";
|
||||||
if (fallback === "backpack") return "backpack";
|
if (fallback === "backpack") return "backpack";
|
||||||
if (fallback === "edgex") return "edgex";
|
if (fallback === "edgex") return "edgex";
|
||||||
|
if (fallback === "paradex") return "paradex";
|
||||||
return "aster";
|
return "aster";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +37,7 @@ export function getExchangeDisplayName(id: SupportedExchangeId): string {
|
|||||||
if (id === "lighter") return "Lighter";
|
if (id === "lighter") return "Lighter";
|
||||||
if (id === "backpack") return "Backpack";
|
if (id === "backpack") return "Backpack";
|
||||||
if (id === "edgex") return "EdgeX";
|
if (id === "edgex") return "EdgeX";
|
||||||
|
if (id === "paradex") return "Paradex";
|
||||||
return "AsterDex";
|
return "AsterDex";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,5 +55,8 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange
|
|||||||
if (id === "edgex") {
|
if (id === "edgex") {
|
||||||
return new EdgeXExchangeAdapter(options.symbol, options.edgex);
|
return new EdgeXExchangeAdapter(options.symbol, options.edgex);
|
||||||
}
|
}
|
||||||
|
if (id === "paradex") {
|
||||||
|
return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol });
|
||||||
|
}
|
||||||
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
|
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const ENVIRONMENT_ALIASES: Record<string, keyof typeof ENVIRONMENT_HOSTS> = {
|
|||||||
production: "prod",
|
production: "prod",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MARK_PRICE_TRIGGER = "MARK";
|
const DEFAULT_MARK_PRICE_TRIGGER = "LAST";
|
||||||
const DEFAULT_TIME_IN_FORCE: GrvtTimeInForce = "GOOD_TILL_TIME";
|
const DEFAULT_TIME_IN_FORCE: GrvtTimeInForce = "GOOD_TILL_TIME";
|
||||||
const TRAILING_NOT_SUPPORTED_ERROR =
|
const TRAILING_NOT_SUPPORTED_ERROR =
|
||||||
"GRVT exchange adapter does not yet support trailing stop orders";
|
"GRVT exchange adapter does not yet support trailing stop orders";
|
||||||
@@ -1097,12 +1097,16 @@ function mapOrder(order: IOrder, symbol: string): AsterOrder {
|
|||||||
const avgFillPrice = Array.isArray(state?.avg_fill_price)
|
const avgFillPrice = Array.isArray(state?.avg_fill_price)
|
||||||
? state?.avg_fill_price?.[0] ?? undefined
|
? state?.avg_fill_price?.[0] ?? undefined
|
||||||
: state?.avg_fill_price ?? undefined;
|
: state?.avg_fill_price ?? undefined;
|
||||||
|
const hasTrigger = Boolean(trigger?.tpsl?.trigger_price);
|
||||||
|
const derivedType = hasTrigger
|
||||||
|
? (order.is_market ? "STOP_MARKET" : "LIMIT")
|
||||||
|
: (order.is_market ? "MARKET" : "LIMIT");
|
||||||
return {
|
return {
|
||||||
orderId: order.order_id ?? metadata?.client_order_id ?? cryptoRandomId(),
|
orderId: order.order_id ?? metadata?.client_order_id ?? cryptoRandomId(),
|
||||||
clientOrderId: metadata?.client_order_id ?? "",
|
clientOrderId: metadata?.client_order_id ?? "",
|
||||||
symbol,
|
symbol,
|
||||||
side: leg?.is_buying_asset ? "BUY" : "SELL",
|
side: leg?.is_buying_asset ? "BUY" : "SELL",
|
||||||
type: order.is_market ? "MARKET" : "LIMIT",
|
type: derivedType,
|
||||||
status: state?.status ?? "NEW",
|
status: state?.status ?? "NEW",
|
||||||
price: leg?.limit_price ?? "0",
|
price: leg?.limit_price ?? "0",
|
||||||
origQty: leg?.size ?? "0",
|
origQty: leg?.size ?? "0",
|
||||||
@@ -1333,7 +1337,7 @@ function buildUnsignedOrder(params: {
|
|||||||
|
|
||||||
function buildTriggerMetadata(params: CreateOrderParams): GrvtUnsignedOrder["metadata"]["trigger"] | undefined {
|
function buildTriggerMetadata(params: CreateOrderParams): GrvtUnsignedOrder["metadata"]["trigger"] | undefined {
|
||||||
if (params.type === "STOP_MARKET") {
|
if (params.type === "STOP_MARKET") {
|
||||||
const triggerType = params.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS";
|
const triggerType = params.triggerType ?? (params.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS");
|
||||||
const stopPrice = params.stopPrice ?? params.activationPrice;
|
const stopPrice = params.stopPrice ?? params.activationPrice;
|
||||||
if (!stopPrice) {
|
if (!stopPrice) {
|
||||||
throw new Error("GRVT stop orders require a stopPrice or activationPrice");
|
throw new Error("GRVT stop orders require a stopPrice or activationPrice");
|
||||||
|
|||||||
@@ -660,7 +660,13 @@ export class LighterGateway {
|
|||||||
|
|
||||||
private emitAccount(): void {
|
private emitAccount(): void {
|
||||||
if (!this.accountDetails) return;
|
if (!this.accountDetails) return;
|
||||||
const snapshot = toAccountSnapshot(this.displaySymbol, this.accountDetails, this.positions);
|
const snapshot = toAccountSnapshot(
|
||||||
|
this.displaySymbol,
|
||||||
|
this.accountDetails,
|
||||||
|
this.positions,
|
||||||
|
[],
|
||||||
|
{ marketSymbol: this.marketSymbol, marketId: this.marketId }
|
||||||
|
);
|
||||||
this.accountEvent.emit(snapshot);
|
this.accountEvent.emit(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,9 +140,21 @@ export function toAccountSnapshot(
|
|||||||
symbol: string,
|
symbol: string,
|
||||||
details: LighterAccountDetails,
|
details: LighterAccountDetails,
|
||||||
positions: LighterPosition[] = [],
|
positions: LighterPosition[] = [],
|
||||||
assets: AsterAccountAsset[] = []
|
assets: AsterAccountAsset[] = [],
|
||||||
|
options?: { marketSymbol?: string | null; marketId?: number | null }
|
||||||
): AsterAccountSnapshot {
|
): AsterAccountSnapshot {
|
||||||
const transformedPositions = positions.map((position) => lighterPositionToAster(symbol, position));
|
const targetSymbol = options?.marketSymbol?.toUpperCase();
|
||||||
|
const targetMarketId = options?.marketId;
|
||||||
|
const filteredPositions = positions.filter((position) => {
|
||||||
|
const marketMatches =
|
||||||
|
targetMarketId == null ||
|
||||||
|
(Number.isFinite(Number(position.market_id)) && Number(position.market_id) === Number(targetMarketId));
|
||||||
|
const symbolMatches =
|
||||||
|
!targetSymbol ||
|
||||||
|
(typeof position.symbol === "string" && position.symbol.toUpperCase() === targetSymbol);
|
||||||
|
return marketMatches && symbolMatches;
|
||||||
|
});
|
||||||
|
const transformedPositions = filteredPositions.map((position) => lighterPositionToAster(symbol, position));
|
||||||
const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0);
|
const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0);
|
||||||
const assetList = assets.length ? assets : defaultAsset(details);
|
const assetList = assets.length ? assets : defaultAsset(details);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export interface CreateOrderParams {
|
|||||||
timeInForce?: TimeInForce;
|
timeInForce?: TimeInForce;
|
||||||
reduceOnly?: StringBoolean;
|
reduceOnly?: StringBoolean;
|
||||||
closePosition?: StringBoolean;
|
closePosition?: StringBoolean;
|
||||||
|
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsterAccountPosition {
|
export interface AsterAccountPosition {
|
||||||
|
|||||||
+146
-17
@@ -9,7 +9,7 @@ import type {
|
|||||||
} from "../exchanges/types";
|
} from "../exchanges/types";
|
||||||
import { formatPriceToString } from "../utils/math";
|
import { formatPriceToString } from "../utils/math";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
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 { getPosition } from "../utils/strategy";
|
||||||
import type { PositionSnapshot } from "../utils/strategy";
|
import type { PositionSnapshot } from "../utils/strategy";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
@@ -49,12 +49,19 @@ export interface MakerEngineSnapshot {
|
|||||||
desiredOrders: DesiredOrder[];
|
desiredOrders: DesiredOrder[];
|
||||||
tradeLog: TradeLogEntry[];
|
tradeLog: TradeLogEntry[];
|
||||||
lastUpdated: number | null;
|
lastUpdated: number | null;
|
||||||
|
feedStatus: {
|
||||||
|
account: boolean;
|
||||||
|
orders: boolean;
|
||||||
|
depth: boolean;
|
||||||
|
ticker: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type MakerEvent = "update";
|
type MakerEvent = "update";
|
||||||
type MakerListener = (snapshot: MakerEngineSnapshot) => void;
|
type MakerListener = (snapshot: MakerEngineSnapshot) => void;
|
||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
|
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
|
||||||
|
|
||||||
export class MakerEngine {
|
export class MakerEngine {
|
||||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||||
@@ -78,6 +85,28 @@ export class MakerEngine {
|
|||||||
private initialOrderSnapshotReady = false;
|
private initialOrderSnapshotReady = false;
|
||||||
private initialOrderResetDone = false;
|
private initialOrderResetDone = false;
|
||||||
private entryPricePendingLogged = 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;
|
private readonly rateLimit: RateLimitController;
|
||||||
|
|
||||||
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
@@ -127,6 +156,11 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
const position = getPosition(snapshot, this.config.symbol);
|
const position = getPosition(snapshot, this.config.symbol);
|
||||||
this.sessionVolume.update(position, this.getReferencePrice());
|
this.sessionVolume.update(position, this.getReferencePrice());
|
||||||
|
if (!this.feedArrived.account) {
|
||||||
|
this.tradeLog.push("info", "账户快照已同步");
|
||||||
|
this.feedArrived.account = true;
|
||||||
|
}
|
||||||
|
this.feedStatus.account = true;
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
@@ -150,6 +184,11 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.initialOrderSnapshotReady = true;
|
this.initialOrderSnapshotReady = true;
|
||||||
|
if (!this.feedArrived.orders) {
|
||||||
|
this.tradeLog.push("info", "订单快照已返回");
|
||||||
|
this.feedArrived.orders = true;
|
||||||
|
}
|
||||||
|
this.feedStatus.orders = true;
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
@@ -163,6 +202,11 @@ export class MakerEngine {
|
|||||||
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
|
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
|
||||||
(depth) => {
|
(depth) => {
|
||||||
this.depthSnapshot = depth;
|
this.depthSnapshot = depth;
|
||||||
|
if (!this.feedArrived.depth) {
|
||||||
|
this.tradeLog.push("info", "获得最新深度行情");
|
||||||
|
this.feedArrived.depth = true;
|
||||||
|
}
|
||||||
|
this.feedStatus.depth = true;
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
@@ -176,6 +220,11 @@ export class MakerEngine {
|
|||||||
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
|
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
|
||||||
(ticker) => {
|
(ticker) => {
|
||||||
this.tickerSnapshot = ticker;
|
this.tickerSnapshot = ticker;
|
||||||
|
if (!this.feedArrived.ticker) {
|
||||||
|
this.tradeLog.push("info", "Ticker 已就绪");
|
||||||
|
this.feedArrived.ticker = true;
|
||||||
|
}
|
||||||
|
this.feedStatus.ticker = true;
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
@@ -185,18 +234,7 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Maker strategy does not consume klines, but subscribe to keep parity with other modules
|
// Maker strategy does not require realtime klines.
|
||||||
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)}`,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncLocksWithOrders(orders: AsterOrder[] | null | undefined): void {
|
private syncLocksWithOrders(orders: AsterOrder[] | null | undefined): void {
|
||||||
@@ -212,7 +250,12 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isReady(): boolean {
|
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> {
|
private async tick(): Promise<void> {
|
||||||
@@ -229,9 +272,11 @@ export class MakerEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.isReady()) {
|
if (!this.isReady()) {
|
||||||
|
this.logReadinessBlockers();
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.resetReadinessFlags();
|
||||||
if (!(await this.ensureStartupOrderReset())) {
|
if (!(await this.ensureStartupOrderReset())) {
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
return;
|
return;
|
||||||
@@ -253,7 +298,8 @@ export class MakerEngine {
|
|||||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
const absPosition = Math.abs(position.positionAmt);
|
const absPosition = Math.abs(position.positionAmt);
|
||||||
const desired: DesiredOrder[] = [];
|
const desired: DesiredOrder[] = [];
|
||||||
const canEnter = !this.rateLimit.shouldBlockEntries();
|
const insufficientActive = this.applyInsufficientBalanceState(Date.now());
|
||||||
|
const canEnter = !this.rateLimit.shouldBlockEntries() && !insufficientActive;
|
||||||
|
|
||||||
if (absPosition < EPS) {
|
if (absPosition < EPS) {
|
||||||
this.entryPricePendingLogged = false;
|
this.entryPricePendingLogged = false;
|
||||||
@@ -268,6 +314,7 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.desiredOrders = desired;
|
this.desiredOrders = desired;
|
||||||
|
this.logDesiredOrders(desired);
|
||||||
this.sessionVolume.update(position, this.getReferencePrice());
|
this.sessionVolume.update(position, this.getReferencePrice());
|
||||||
await this.syncOrders(desired);
|
await this.syncOrders(desired);
|
||||||
await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice));
|
await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice));
|
||||||
@@ -331,7 +378,11 @@ export class MakerEngine {
|
|||||||
|
|
||||||
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
|
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
|
||||||
const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId)));
|
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) {
|
for (const order of toCancel) {
|
||||||
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
|
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
|
||||||
@@ -385,7 +436,14 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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,
|
desiredOrders: this.desiredOrders,
|
||||||
tradeLog: this.tradeLog.all(),
|
tradeLog: this.tradeLog.all(),
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
|
feedStatus: { ...this.feedStatus },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getReferencePrice(): number | null {
|
private getReferencePrice(): number | null {
|
||||||
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,6 +449,10 @@ export class OffsetMakerEngine {
|
|||||||
|
|
||||||
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
|
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
|
||||||
const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId)));
|
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
|
// Coalesce reprices for entry orders: if within tick threshold or within dwell window, keep existing order
|
||||||
const adjustedTargets: DesiredOrder[] = targets.map((t) => ({ ...t }));
|
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) {
|
for (const order of toCancel) {
|
||||||
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
|
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
|
||||||
|
|||||||
@@ -242,6 +242,8 @@ export class TrendEngine {
|
|||||||
this.exchange.watchKlines.bind(this.exchange, this.config.symbol, this.config.klineInterval),
|
this.exchange.watchKlines.bind(this.exchange, this.config.symbol, this.config.klineInterval),
|
||||||
(klines) => {
|
(klines) => {
|
||||||
this.klineSnapshot = Array.isArray(klines) ? klines : [];
|
this.klineSnapshot = Array.isArray(klines) ? klines : [];
|
||||||
|
const latestSma = getSMA(this.klineSnapshot, 30);
|
||||||
|
this.lastSma30 = latestSma;
|
||||||
this.logKlineSnapshot();
|
this.logKlineSnapshot();
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
},
|
},
|
||||||
@@ -507,10 +509,21 @@ export class TrendEngine {
|
|||||||
}
|
}
|
||||||
this.entryPricePendingLogged = false;
|
this.entryPricePendingLogged = false;
|
||||||
const direction = position.positionAmt > 0 ? "long" : "short";
|
const direction = position.positionAmt > 0 ? "long" : "short";
|
||||||
|
const qtyAbs = Math.abs(position.positionAmt);
|
||||||
|
const depthBid = Number(this.depthSnapshot?.bids?.[0]?.[0]);
|
||||||
|
const depthAsk = Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
||||||
|
const closeSidePriceRaw = direction === "long" ? depthBid : depthAsk;
|
||||||
|
const effectiveClosePrice = Number.isFinite(closeSidePriceRaw)
|
||||||
|
? closeSidePriceRaw
|
||||||
|
: Number.isFinite(price)
|
||||||
|
? price
|
||||||
|
: position.entryPrice;
|
||||||
const pnl =
|
const pnl =
|
||||||
(direction === "long"
|
qtyAbs > 0
|
||||||
? price - position.entryPrice
|
? (direction === "long"
|
||||||
: position.entryPrice - price) * Math.abs(position.positionAmt);
|
? effectiveClosePrice - position.entryPrice
|
||||||
|
: position.entryPrice - effectiveClosePrice) * qtyAbs
|
||||||
|
: 0;
|
||||||
const unrealized = Number.isFinite(position.unrealizedProfit)
|
const unrealized = Number.isFinite(position.unrealizedProfit)
|
||||||
? position.unrealizedProfit
|
? position.unrealizedProfit
|
||||||
: null;
|
: null;
|
||||||
@@ -669,11 +682,7 @@ export class TrendEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const derivedLoss = pnl < -this.config.lossLimit;
|
const derivedLoss = pnl < -this.config.lossLimit;
|
||||||
const snapshotLoss = Boolean(
|
const snapshotLoss = derivedLoss;
|
||||||
unrealized != null &&
|
|
||||||
unrealized < -this.config.lossLimit &&
|
|
||||||
pnl <= 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (derivedLoss || snapshotLoss) {
|
if (derivedLoss || snapshotLoss) {
|
||||||
const result = { closed: false, pnl };
|
const result = { closed: false, pnl };
|
||||||
|
|||||||
+19
-49
@@ -1,11 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import { makerConfig } from "../config";
|
import { makerConfig } from "../config";
|
||||||
import {
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
createExchangeAdapter,
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
getExchangeDisplayName,
|
|
||||||
resolveExchangeId,
|
|
||||||
} from "../exchanges/create-adapter";
|
|
||||||
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
@@ -35,50 +32,7 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
let adapter;
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol });
|
||||||
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 engine = new MakerEngine(makerConfig, adapter);
|
const engine = new MakerEngine(makerConfig, adapter);
|
||||||
engineRef.current = engine;
|
engineRef.current = engine;
|
||||||
setSnapshot(engine.getSnapshot());
|
setSnapshot(engine.getSnapshot());
|
||||||
@@ -154,6 +108,13 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const lastLogs = snapshot.tradeLog.slice(-5);
|
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 (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
@@ -163,6 +124,15 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
|||||||
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, 2)} | 卖一价: {formatNumber(topAsk, 2)} | 点差: {spreadDisplay}
|
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, 2)} | 卖一价: {formatNumber(topAsk, 2)} | 点差: {spreadDisplay}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="gray">状态: {snapshot.ready ? "实时运行" : "等待市场数据"} | 按 Esc 返回策略选择</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>
|
||||||
|
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import { makerConfig } from "../config";
|
import { makerConfig } from "../config";
|
||||||
import {
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
createExchangeAdapter,
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
getExchangeDisplayName,
|
|
||||||
resolveExchangeId,
|
|
||||||
} from "../exchanges/create-adapter";
|
|
||||||
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
@@ -35,26 +32,7 @@ export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
let adapter;
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol });
|
||||||
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 engine = new OffsetMakerEngine(makerConfig, adapter);
|
const engine = new OffsetMakerEngine(makerConfig, adapter);
|
||||||
engineRef.current = engine;
|
engineRef.current = engine;
|
||||||
setSnapshot(engine.getSnapshot());
|
setSnapshot(engine.getSnapshot());
|
||||||
|
|||||||
+3
-25
@@ -1,11 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import { tradingConfig } from "../config";
|
import { tradingConfig } from "../config";
|
||||||
import {
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
createExchangeAdapter,
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
getExchangeDisplayName,
|
|
||||||
resolveExchangeId,
|
|
||||||
} from "../exchanges/create-adapter";
|
|
||||||
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
@@ -37,26 +34,7 @@ export function TrendApp({ onExit }: TrendAppProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
let adapter;
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol });
|
||||||
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 engine = new TrendEngine(tradingConfig, adapter);
|
const engine = new TrendEngine(tradingConfig, adapter);
|
||||||
engineRef.current = engine;
|
engineRef.current = engine;
|
||||||
setSnapshot(engine.getSnapshot());
|
setSnapshot(engine.getSnapshot());
|
||||||
|
|||||||
+18
-1
@@ -1,7 +1,14 @@
|
|||||||
export function isUnknownOrderError(error: unknown): boolean {
|
export function isUnknownOrderError(error: unknown): boolean {
|
||||||
const message = extractMessage(error);
|
const message = extractMessage(error);
|
||||||
if (!message) return false;
|
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 {
|
export function extractMessage(error: unknown): string {
|
||||||
@@ -36,3 +43,13 @@ export function isRateLimitError(error: unknown): boolean {
|
|||||||
message.includes("request rate")
|
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
@@ -32,10 +32,18 @@ export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSMA(values: AsterKline[], length: number): number | null {
|
export function getSMA(values: AsterKline[], length: number): number | null {
|
||||||
if (!values || values.length < length) return null;
|
if (!Array.isArray(values) || values.length < length) return null;
|
||||||
const closes = values.slice(-length).map((k) => Number(k.close));
|
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);
|
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 {
|
export function calcStopLossPrice(entryPrice: number, qty: number, side: "long" | "short", loss: number): number {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BackpackGateway } from "../src/exchanges/backpack/gateway";
|
||||||
|
|
||||||
|
describe("BackpackGateway account snapshots", () => {
|
||||||
|
const createGateway = () =>
|
||||||
|
new BackpackGateway({
|
||||||
|
apiKey: "key",
|
||||||
|
apiSecret: "secret",
|
||||||
|
symbol: "BTCUSDC",
|
||||||
|
logger: () => {},
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
it("maps spot balances without positions", () => {
|
||||||
|
const gateway = createGateway();
|
||||||
|
gateway.isContractMarket = false;
|
||||||
|
|
||||||
|
const balance = {
|
||||||
|
info: {},
|
||||||
|
free: { USDC: "10" },
|
||||||
|
used: { USDC: "5" },
|
||||||
|
total: { USDC: "15" },
|
||||||
|
USDC: { free: "10", used: "5", total: "15" },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const snapshot = gateway.mapBalanceToAccountSnapshotWithPositions(balance, []);
|
||||||
|
|
||||||
|
expect(snapshot.positions).toEqual([]);
|
||||||
|
expect(snapshot.totalWalletBalance).toBe("15");
|
||||||
|
expect(snapshot.totalUnrealizedProfit).toBe("0");
|
||||||
|
expect(snapshot.availableBalance).toBe("10");
|
||||||
|
expect(snapshot.maxWithdrawAmount).toBe("10");
|
||||||
|
expect(snapshot.totalMarginBalance).toBeUndefined();
|
||||||
|
expect(snapshot.assets).toHaveLength(1);
|
||||||
|
expect(snapshot.assets[0]).toMatchObject({
|
||||||
|
asset: "USDC",
|
||||||
|
walletBalance: "15",
|
||||||
|
availableBalance: "10",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes derivative positions when present", () => {
|
||||||
|
const gateway = createGateway();
|
||||||
|
gateway.isContractMarket = true;
|
||||||
|
gateway.marketSymbol = "BTC/USDC:USDC";
|
||||||
|
|
||||||
|
const balance = {
|
||||||
|
info: {},
|
||||||
|
free: { USDC: "80" },
|
||||||
|
used: { USDC: "20" },
|
||||||
|
total: { USDC: "100" },
|
||||||
|
USDC: { free: "80", used: "20", total: "100" },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const positions = [
|
||||||
|
{
|
||||||
|
symbol: "BTC/USDC:USDC",
|
||||||
|
contracts: "2",
|
||||||
|
side: "long",
|
||||||
|
entryPrice: "25000",
|
||||||
|
markPrice: "25200",
|
||||||
|
unrealizedPnl: "400",
|
||||||
|
info: {
|
||||||
|
estLiquidationPrice: "15000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
symbol: "ETH/USDC:USDC",
|
||||||
|
netExposureQuantity: "0.5",
|
||||||
|
netCost: "-100",
|
||||||
|
entryPrice: "3000",
|
||||||
|
pnlUnrealized: "-10",
|
||||||
|
markPrice: "2900",
|
||||||
|
estLiquidationPrice: "1000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const snapshot = gateway.mapBalanceToAccountSnapshotWithPositions(balance, positions);
|
||||||
|
|
||||||
|
expect(snapshot.positions).toHaveLength(2);
|
||||||
|
expect(snapshot.positions[0]).toMatchObject({
|
||||||
|
symbol: "BTC/USDC:USDC",
|
||||||
|
positionAmt: "2",
|
||||||
|
positionSide: "LONG",
|
||||||
|
entryPrice: "25000",
|
||||||
|
unrealizedProfit: "400",
|
||||||
|
markPrice: "25200",
|
||||||
|
liquidationPrice: "15000",
|
||||||
|
});
|
||||||
|
expect(snapshot.positions[1]).toMatchObject({
|
||||||
|
symbol: "ETH/USDC:USDC",
|
||||||
|
positionAmt: "-0.5",
|
||||||
|
positionSide: "SHORT",
|
||||||
|
entryPrice: "3000",
|
||||||
|
unrealizedProfit: "-10",
|
||||||
|
markPrice: "2900",
|
||||||
|
liquidationPrice: "1000",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.totalWalletBalance).toBe("100");
|
||||||
|
expect(snapshot.totalUnrealizedProfit).toBe("390");
|
||||||
|
expect(snapshot.totalMarginBalance).toBe("490");
|
||||||
|
expect(snapshot.totalCrossWalletBalance).toBe("100");
|
||||||
|
expect(snapshot.totalCrossUnPnl).toBe("390");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { resolveSymbolFromEnv } from "../src/config";
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSymbolFromEnv", () => {
|
||||||
|
it("prefers exchange-specific symbol when available", () => {
|
||||||
|
process.env.EXCHANGE = "backpack";
|
||||||
|
process.env.BACKPACK_SYMBOL = "ETHUSDC";
|
||||||
|
process.env.TRADE_SYMBOL = "BTCUSDT";
|
||||||
|
|
||||||
|
expect(resolveSymbolFromEnv()).toBe("ETHUSDC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to TRADE_SYMBOL when exchange symbol is missing", () => {
|
||||||
|
process.env.EXCHANGE = "paradex";
|
||||||
|
process.env.TRADE_SYMBOL = "ETH/USDC";
|
||||||
|
delete process.env.PARADEX_SYMBOL;
|
||||||
|
|
||||||
|
expect(resolveSymbolFromEnv()).toBe("ETH/USDC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses exchange-specific fallback when no env is defined", () => {
|
||||||
|
process.env.EXCHANGE = "paradex";
|
||||||
|
delete process.env.PARADEX_SYMBOL;
|
||||||
|
delete process.env.TRADE_SYMBOL;
|
||||||
|
|
||||||
|
expect(resolveSymbolFromEnv()).toBe("BTC/USDC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports resolving symbol for an explicit exchange id", () => {
|
||||||
|
delete process.env.EXCHANGE;
|
||||||
|
process.env.GRVT_SYMBOL = "ETHUSDT";
|
||||||
|
|
||||||
|
expect(resolveSymbolFromEnv("grvt")).toBe("ETHUSDT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import { createExchangeAdapter, resolveExchangeId } from "../src/exchanges/creat
|
|||||||
import { AsterExchangeAdapter } from "../src/exchanges/aster-adapter";
|
import { AsterExchangeAdapter } from "../src/exchanges/aster-adapter";
|
||||||
import { GrvtExchangeAdapter } from "../src/exchanges/grvt/adapter";
|
import { GrvtExchangeAdapter } from "../src/exchanges/grvt/adapter";
|
||||||
import { BackpackExchangeAdapter } from "../src/exchanges/backpack/adapter";
|
import { BackpackExchangeAdapter } from "../src/exchanges/backpack/adapter";
|
||||||
|
import { ParadexExchangeAdapter } from "../src/exchanges/paradex/adapter";
|
||||||
|
|
||||||
const ORIGINAL_ENV = { ...process.env };
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ describe("exchange factory", () => {
|
|||||||
expect(resolveExchangeId("Grvt")).toBe("grvt");
|
expect(resolveExchangeId("Grvt")).toBe("grvt");
|
||||||
expect(resolveExchangeId("ASTER")).toBe("aster");
|
expect(resolveExchangeId("ASTER")).toBe("aster");
|
||||||
expect(resolveExchangeId("BACKPACK")).toBe("backpack");
|
expect(resolveExchangeId("BACKPACK")).toBe("backpack");
|
||||||
|
expect(resolveExchangeId("PaRaDeX")).toBe("paradex");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates grvt adapter when EXCHANGE=grvt", () => {
|
it("creates grvt adapter when EXCHANGE=grvt", () => {
|
||||||
@@ -54,4 +56,15 @@ describe("exchange factory", () => {
|
|||||||
expect(adapter).toBeInstanceOf(BackpackExchangeAdapter);
|
expect(adapter).toBeInstanceOf(BackpackExchangeAdapter);
|
||||||
expect(adapter.id).toBe("backpack");
|
expect(adapter.id).toBe("backpack");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates paradex adapter when EXCHANGE=paradex", () => {
|
||||||
|
process.env.EXCHANGE = "paradex";
|
||||||
|
process.env.PARADEX_PRIVATE_KEY = "0x" + "1".repeat(64);
|
||||||
|
process.env.PARADEX_WALLET_ADDRESS = "0x" + "2".repeat(40);
|
||||||
|
process.env.PARADEX_SYMBOL = "BTC/USDC";
|
||||||
|
|
||||||
|
const adapter = createExchangeAdapter({ symbol: "BTC/USDC" });
|
||||||
|
expect(adapter).toBeInstanceOf(ParadexExchangeAdapter);
|
||||||
|
expect(adapter.id).toBe("paradex");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user