import * as ccxt from 'ccxt'; import { config } from '../config/index.js'; import logger from '../utils/logger.js'; import { IExchangeConnector, Candle, ExchangeCapabilities, ExchangeOrderCorrelation } from './types.js'; export class CCXTConnector implements IExchangeConnector { private client: ccxt.Exchange; constructor(apiKey?: string, apiSecret?: string) { const exchangeId = config.EXCHANGE as keyof typeof ccxt; const exchangeClass = ccxt[exchangeId] as any; if (!exchangeClass) { throw new Error(`Exchange ${config.EXCHANGE} not supported by CCXT`); } const auth: any = {}; const resolvedApiKey = apiKey || config.CCXT_API_KEY; const resolvedApiSecret = apiSecret || config.CCXT_API_SECRET; if (resolvedApiKey && resolvedApiKey !== 'your_key') { auth.apiKey = resolvedApiKey; } if (resolvedApiSecret && resolvedApiSecret !== 'your_secret') { auth.secret = resolvedApiSecret; } this.client = new exchangeClass({ ...auth, enableRateLimit: true, }); } public getCapabilities(): ExchangeCapabilities { return { fetchOpenOrders: !!this.client.has['fetchOpenOrders'], fetchClosedOrders: !!this.client.has['fetchClosedOrders'], shorting: !!this.client.has['createMarketBuyOrder'] && !!this.client.has['createMarketSellOrder'], margin: !!this.client.has['margin'], leverage: !!this.client.has['setLeverage'], tradingWindow: false // CCXT doesn't have a unified market clock usually }; } async fetchOHLCV(symbol: string, timeframe: string, limit: number = 100): Promise { try { // Translate Alpaca style '1Min' to CCXT style '1m' let translatedTimeframe = timeframe; if (timeframe === '1Min') translatedTimeframe = '1m'; if (timeframe === '5Min') translatedTimeframe = '5m'; if (timeframe === '15Min') translatedTimeframe = '15m'; if (timeframe === '1Hour') translatedTimeframe = '1h'; if (timeframe === '1Day') translatedTimeframe = '1d'; logger.info(`[CCXT] Fetching data for ${symbol} via ${config.EXCHANGE} (${translatedTimeframe})...`); const candles = await this.client.fetchOHLCV(symbol, translatedTimeframe, undefined, limit); return candles.map((c: any) => ({ timestamp: c[0], open: c[1], high: c[2], low: c[3], close: c[4], volume: c[5], })); } catch (error) { logger.error(`[CCXT] Error: ${error}`); throw error; } } async placeOrder( symbol: string, side: 'buy' | 'sell', qty: number, type: 'market' | 'limit', price?: number, stopLoss?: number, takeProfit?: number, clientOrderId?: string, correlation?: ExchangeOrderCorrelation ): Promise { try { logger.info(`[CCXT] Placing ${side} ${type} order for ${qty} ${symbol}...`); const params: any = {}; if (clientOrderId) params.clientOrderId = clientOrderId; if (stopLoss !== undefined) params.stopLoss = stopLoss; if (takeProfit !== undefined) params.takeProfit = takeProfit; return await this.client.createOrder(symbol, type, side, qty, price, params); } catch (error) { logger.error(`[CCXT] Order Error: ${error}`); throw error; } } async getOrder(orderId: string, symbol?: string): Promise { try { logger.info(`[CCXT] Fetching order ${orderId} (${symbol || 'unknown symbol'})...`); // CCXT often requires the symbol for fetchOrder return await this.client.fetchOrder(orderId, symbol); } catch (error: any) { logger.error(`[CCXT] GetOrder Error for ${orderId}: ${error.message || error}`); return null; } } async getPosition(symbol: string): Promise { try { const positions = await this.client.fetchPositions([symbol]); return positions.find(p => p.symbol === symbol) || null; } catch (error: any) { logger.error(`[CCXT] GetPosition Error for ${symbol}: ${error.message || error}`); throw error; } } async isTradingWindowOpen(): Promise { // Crypto venues are effectively 24/7 for this bot's supported flow. return true; } private normalizeDataSymbol(symbol?: string): string { const candidate = String(symbol || '').toUpperCase(); if (!candidate) return candidate; if (candidate.includes('/')) { return candidate; } if (candidate.endsWith('USDT')) { return `${candidate.slice(0, -4)}/USDT`; } if (candidate.endsWith('USD')) { return `${candidate.slice(0, -3)}/USD`; } return candidate; } async cancelOrder(orderId: string, symbol?: string): Promise { try { logger.info(`[CCXT] Cancelling order ${orderId} (${symbol || 'unknown symbol'})...`); await this.client.cancelOrder(orderId, symbol); return true; } catch (error: any) { logger.error(`[CCXT] CancelOrder Error for ${orderId}: ${error.message || error}`); return false; } } async fetchOpenOrders(symbols?: string[]): Promise { if (!this.client.fetchOpenOrders) return []; try { const orders = await this.client.fetchOpenOrders(); if (!symbols || symbols.length === 0) return orders; const normalizedTargets = new Set(symbols.map((s) => this.normalizeDataSymbol(s))); return orders.filter((order: any) => normalizedTargets.has(this.normalizeDataSymbol(order.symbol))); } catch (error: any) { logger.error(`[CCXT] Fetch open orders failed: ${error.message || error}`); return []; } } async fetchClosedOrders( symbols?: string[], options?: { after?: Date; limit?: number; maxPages?: number; } ): Promise { if (!this.client.fetchClosedOrders) return []; const since = options?.after instanceof Date && Number.isFinite(options.after.getTime()) ? options.after.getTime() : undefined; const limit = Math.max(1, Math.min(500, Math.floor(Number(options?.limit || 500)))); try { if (!symbols || symbols.length === 0) { return await this.client.fetchClosedOrders(undefined, since, limit); } const collected: any[] = []; for (const symbol of symbols) { try { const rows = await this.client.fetchClosedOrders(symbol, since, limit); if (Array.isArray(rows)) { collected.push(...rows); } } catch (symbolError: any) { logger.warn(`[CCXT] Fetch closed orders failed for ${symbol}: ${symbolError.message || symbolError}`); } } return collected; } catch (error: any) { logger.error(`[CCXT] Fetch closed orders failed: ${error.message || error}`); return []; } } }