import ccxt from 'ccxt';
import PQueue from 'p-queue';
import Decimal from 'decimal.js';
Decimal.set({ precision: 20, rounding: Decimal.ROUND_DOWN });
export class ExchangeAdapter {
#exchange;
#rateLimiter;
#exchangeName;
#marketsLoaded = false;
constructor(exchangeName, credentials = {}, marketType = 'spot') {
this.#exchangeName = exchangeName;
const ExchangeClass = ccxt[exchangeName];
if (!ExchangeClass) {
throw new Error(`Exchange ${exchangeName} not supported`);
}
const config = {
apiKey: credentials.apiKey,
secret: credentials.secret,
enableRateLimit: true,
timeout: 30000,
options: {
defaultType: marketType,
adjustForTimeDifference: true
}
};
// Add password for exchanges that require it (Bitget, OKX, etc.)
if (credentials.password) {
config.password = credentials.password;
}
this.#exchange = new ExchangeClass(config);
if (exchangeName === 'mexc') {
this.#exchange.options.unavailableContracts = {};
}
this.#rateLimiter = new PQueue({
concurrency: 3,
interval: 1000,
intervalCap: 10
});
}
async #call(fn, timeout = 30000) {
return this.#rateLimiter.add(async () => {
const result = await Promise.race([
fn(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout)
)
]);
return result;
});
}
async #ensureMarkets() {
if (!this.#marketsLoaded) {
await this.#call(() => this.#exchange.loadMarkets());
this.#marketsLoaded = true;
}
}
async getBalance(currency = null) {
const marketType = this.#exchange.options.defaultType;
const params = marketType === 'swap' || marketType === 'future' ? { type: marketType } : {};
const balance = await this.#call(() => this.#exchange.fetchBalance(params));
if (currency) {
const b = balance[currency];
return {
currency,
free: b?.free?.toString() || '0',
used: b?.used?.toString() || '0',
total: b?.total?.toString() || '0'
};
}
const result = {};
for (const [curr, bal] of Object.entries(balance)) {
if (['free', 'used', 'total', 'info', 'timestamp', 'datetime'].includes(curr)) continue;
if (!bal || typeof bal !== 'object') continue;
const total = bal.total ?? bal.free ?? 0;
if (total && new Decimal(total).gt(0)) {
result[curr] = {
currency: curr,
free: (bal.free ?? 0).toString(),
used: (bal.used ?? 0).toString(),
total: total.toString()
};
}
}
return result;
}
async getTicker(symbol) {
await this.#ensureMarkets();
const isPerpetual = symbol.includes(':');
const originalDefaultType = this.#exchange.options.defaultType;
try {
if (isPerpetual && originalDefaultType !== 'swap') {
this.#exchange.options.defaultType = 'swap';
this.#marketsLoaded = false;
await this.#call(() => this.#exchange.loadMarkets(true));
this.#marketsLoaded = true;
}
let marketSymbol = symbol;
if (isPerpetual && this.#exchangeName === 'mexc') {
marketSymbol = symbol.split(':')[0];
}
const t = await this.#call(() => this.#exchange.fetchTicker(marketSymbol));
return {
symbol,
bid: t.bid?.toString(),
ask: t.ask?.toString(),
last: t.last?.toString(),
volume: t.baseVolume?.toString(),
quoteVolume: t.quoteVolume?.toString()
};
} finally {
if (isPerpetual && originalDefaultType !== 'swap') {
this.#exchange.options.defaultType = originalDefaultType;
}
}
}
async getOrderbook(symbol, limit = 20) {
await this.#ensureMarkets();
const ob = await this.#call(() => this.#exchange.fetchOrderBook(symbol, limit));
return {
symbol,
bids: ob.bids.map(([p, a]) => ({ price: p.toString(), amount: a.toString() })),
asks: ob.asks.map(([p, a]) => ({ price: p.toString(), amount: a.toString() }))
};
}
async getOHLCV(symbol, timeframe = '1h', limit = 100) {
await this.#ensureMarkets();
const ohlcv = await this.#call(() => this.#exchange.fetchOHLCV(symbol, timeframe, undefined, limit));
return ohlcv.map(([ts, o, h, l, c, v]) => ({
timestamp: ts,
open: o.toString(),
high: h.toString(),
low: l.toString(),
close: c.toString(),
volume: v.toString()
}));
}
async getAllTickers(quote = 'USDT') {
await this.#ensureMarkets();
const tickers = await this.#call(() => this.#exchange.fetchTickers(), 60000);
return Object.values(tickers)
.filter(t => t.symbol.endsWith(`/${quote}`) && t.bid && t.ask)
.map(t => {
const bid = new Decimal(t.bid);
const ask = new Decimal(t.ask);
const spread = ask.minus(bid);
const spreadPct = spread.div(bid).times(100);
return {
symbol: t.symbol,
bid: bid.toString(),
ask: ask.toString(),
last: t.last?.toString() || '0',
spreadPercent: spreadPct.toFixed(4),
volume24h: t.quoteVolume?.toString() || '0'
};
})
.sort((a, b) => parseFloat(b.volume24h) - parseFloat(a.volume24h));
}
async placeOrder({ symbol, side, type, amount, price = null, leverage = null, stopLoss = null, takeProfit = null, reduceOnly = null, clientOrderId = null }) {
await this.#ensureMarkets();
const isPerpetual = symbol.includes(':');
const originalDefaultType = this.#exchange.options.defaultType;
try {
if (isPerpetual && originalDefaultType !== 'swap') {
this.#exchange.options.defaultType = 'swap';
this.#marketsLoaded = false;
await this.#call(() => this.#exchange.loadMarkets(true));
this.#marketsLoaded = true;
}
let marketSymbol = symbol;
if (isPerpetual && this.#exchangeName === 'mexc') {
marketSymbol = symbol.split(':')[0];
}
const params = {};
if (stopLoss) params.stopLoss = { triggerPrice: stopLoss };
if (takeProfit) params.takeProfit = { triggerPrice: takeProfit };
if (reduceOnly !== null) params.reduceOnly = reduceOnly;
if (clientOrderId) params.clientOrderId = clientOrderId;
const order = await this.#call(() =>
this.#exchange.createOrder(marketSymbol, type, side, amount, price || undefined, params)
);
return {
id: order.id,
clientOrderId: order.clientOrderId,
symbol,
side,
type,
amount: order.amount?.toString(),
price: order.price?.toString(),
status: order.status,
leverage: leverage?.toString()
};
} finally {
if (isPerpetual && originalDefaultType !== 'swap') {
this.#exchange.options.defaultType = originalDefaultType;
}
}
}
async cancelOrder(orderId, symbol) {
const result = await this.#call(() => this.#exchange.cancelOrder(orderId, symbol));
return { id: result.id, status: 'cancelled' };
}
async cancelAllOrders(symbol = null) {
await this.#ensureMarkets();
const result = await this.#call(() => this.#exchange.cancelAllOrders(symbol || undefined));
return {
success: true,
cancelled: Array.isArray(result) ? result.length : 0,
symbol: symbol || 'all'
};
}
async getOpenOrders(symbol = null) {
await this.#ensureMarkets();
const orders = await this.#call(() => this.#exchange.fetchOpenOrders(symbol || undefined));
return orders.map(o => ({
id: o.id,
symbol: o.symbol,
side: o.side,
type: o.type,
amount: o.amount?.toString(),
price: o.price?.toString(),
filled: o.filled?.toString(),
status: o.status
}));
}
async getPositions() {
const marketType = this.#exchange.options.defaultType;
if (marketType !== 'swap' && marketType !== 'future') {
return [];
}
try {
const positions = await this.#call(() => this.#exchange.fetchPositions());
return positions
.filter(p => p.contracts && parseFloat(p.contracts) !== 0)
.map(p => ({
symbol: p.symbol,
side: p.side,
contracts: p.contracts?.toString(),
contractSize: p.contractSize?.toString(),
notionalValue: p.notional?.toString() || '0',
leverage: p.leverage?.toString() || '1',
entryPrice: p.entryPrice?.toString(),
markPrice: p.markPrice?.toString(),
liquidationPrice: p.liquidationPrice?.toString(),
unrealizedPnl: p.unrealizedPnl?.toString() || '0',
percentage: p.percentage?.toString() || '0',
marginType: p.marginType || 'isolated',
timestamp: p.timestamp
}));
} catch (error) {
throw new Error(`Failed to fetch positions: ${error.message}`);
}
}
async setLeverage(symbol, leverage, params = {}) {
const isPerpetual = symbol.includes(':');
const originalDefaultType = this.#exchange.options.defaultType;
try {
if (isPerpetual && originalDefaultType !== 'swap') {
this.#exchange.options.defaultType = 'swap';
this.#marketsLoaded = false;
await this.#call(() => this.#exchange.loadMarkets(true));
this.#marketsLoaded = true;
}
await this.#call(() => this.#exchange.setLeverage(leverage, symbol, params));
return {
symbol,
leverage: leverage.toString(),
timestamp: new Date().toISOString()
};
} finally {
if (isPerpetual && originalDefaultType !== 'swap') {
this.#exchange.options.defaultType = originalDefaultType;
}
}
}
async getLeverage(symbol, params = {}) {
const isPerpetual = symbol.includes(':');
const originalDefaultType = this.#exchange.options.defaultType;
try {
if (isPerpetual && originalDefaultType !== 'swap') {
this.#exchange.options.defaultType = 'swap';
this.#marketsLoaded = false;
await this.#call(() => this.#exchange.loadMarkets(true));
this.#marketsLoaded = true;
}
const positions = await this.#call(() => this.#exchange.fetchPositions([symbol], params));
const position = positions.find(p => p.symbol === symbol);
return {
symbol,
leverage: (position?.leverage || 1).toString(),
timestamp: new Date().toISOString()
};
} catch (error) {
return {
symbol,
leverage: '1',
timestamp: new Date().toISOString(),
note: 'No position found or leverage not set'
};
} finally {
if (isPerpetual && originalDefaultType !== 'swap') {
this.#exchange.options.defaultType = originalDefaultType;
}
}
}
async close() {
await this.#exchange.close?.();
}
get name() { return this.#exchangeName; }
}