UNPKG

skayn-trading-sdk

Version:

Professional Bitcoin trading strategy backtesting framework with institutional-grade architecture

495 lines (414 loc) 14.2 kB
const WebSocket = require('ws'); const EventEmitter = require('events'); const logger = require('../utils/logger'); /** * Live Binance WebSocket Data Provider * Real-time price data following SDK plugin architecture * * Data Flow: Binance WebSocket → LiveBinanceProvider → Strategy → Execution → Risk Management */ class LiveBinanceProvider extends EventEmitter { constructor(options = {}) { super(); this.config = { symbol: options.symbol || 'BTCUSDT', reconnectDelay: options.reconnectDelay || 5000, maxReconnectAttempts: options.maxReconnectAttempts || 5, heartbeatInterval: options.heartbeatInterval || 30000, maxHistorySize: options.maxHistorySize || 100, ...options }; // Connection state this.ws = null; this.isConnected = false; this.reconnectAttempts = 0; this.lastHeartbeat = null; this.heartbeatTimer = null; this.connectionStartTime = null; // Data state this.currentPrice = null; this.lastTicker = null; this.priceHistory = []; // Statistics this.totalMessages = 0; this.lastPriceUpdate = null; this.lastLogTime = 0; // WebSocket URL this.wsUrl = `wss://stream.binance.com:9443/ws/${this.config.symbol.toLowerCase()}@ticker`; } /** * Connect to Binance WebSocket stream * @returns {Promise<boolean>} Connection success */ async connect() { return new Promise((resolve, reject) => { try { logger.info('🔌 Connecting to Binance WebSocket...', { symbol: this.config.symbol, url: this.wsUrl }); this.ws = new WebSocket(this.wsUrl); this.connectionStartTime = Date.now(); this.ws.on('open', () => { this.isConnected = true; this.reconnectAttempts = 0; this.lastHeartbeat = Date.now(); logger.info('✅ Binance WebSocket connected - real-time price stream active'); this.startHeartbeatMonitor(); this.emit('connected'); resolve(true); }); this.ws.on('message', (data) => { this.handleMessage(data); }); this.ws.on('error', (error) => { logger.error('❌ Binance WebSocket error', { error: error.message }); this.isConnected = false; this.emit('error', error); if (this.reconnectAttempts === 0) { reject(error); } }); this.ws.on('close', (code, reason) => { this.isConnected = false; this.stopHeartbeatMonitor(); logger.warn('🔌 Binance WebSocket disconnected', { code, reason: reason.toString() }); this.emit('disconnected', { code, reason }); this.attemptReconnect(); }); // Connection timeout (10 seconds) setTimeout(() => { if (!this.isConnected) { reject(new Error('Binance WebSocket connection timeout')); } }, 10000); } catch (error) { reject(error); } }); } /** * Handle incoming WebSocket price messages * @param {Buffer} data - Raw WebSocket data */ handleMessage(data) { try { const ticker = JSON.parse(data); this.totalMessages++; this.lastHeartbeat = Date.now(); // Validate ticker data if (!ticker.c || !ticker.s || ticker.s !== this.config.symbol.toUpperCase()) { return; } // Extract and validate price const price = parseFloat(ticker.c); if (!price || price <= 0) { logger.warn('Invalid price data from Binance', { price, symbol: ticker.s }); return; } // Update current state this.currentPrice = price; this.lastTicker = ticker; this.lastPriceUpdate = Date.now(); // Create standardized price data for strategies const priceData = { // Core price data (what strategies expect) price: price, close: price, open: parseFloat(ticker.o), high: parseFloat(ticker.h), low: parseFloat(ticker.l), volume: parseFloat(ticker.v), timestamp: new Date().toISOString(), // Market statistics change24h: parseFloat(ticker.P), changeAbs: parseFloat(ticker.p), high24h: parseFloat(ticker.h), low24h: parseFloat(ticker.l), // Spread approximation (for compatibility) bid: price - (price * 0.0001), ask: price + (price * 0.0001), spread: price * 0.0002, // Metadata source: 'binance_websocket', symbol: ticker.s, trades: parseInt(ticker.n) }; // Add to rolling history this.addToHistory(priceData); // Emit for strategies and subscribers this.emit('price', priceData); this.emit('priceUpdate', priceData); // Throttled logging (every 20 messages or 15 seconds) with enhanced info if (this.totalMessages % 20 === 0 || (Date.now() - this.lastLogTime > 15000)) { const changeIcon = priceData.change24h > 0 ? '📈' : '📉'; const changeText = `${priceData.change24h > 0 ? '+' : ''}${priceData.change24h.toFixed(2)}%`; // Calculate volume in millions for readability const volumeMB = (priceData.volume / 1000000).toFixed(1); // Calculate 24h range percentage const rangePercent = ((priceData.high24h - priceData.low24h) / priceData.low24h * 100).toFixed(1); // Recent momentum (short term price action) const recentPrices = this.priceHistory.slice(-5).map(p => p.price); let momentumIcon = '⚪'; if (recentPrices.length >= 3) { const recentChange = (price - recentPrices[0]) / recentPrices[0]; if (recentChange > 0.001) momentumIcon = '🟢'; // +0.1% recent momentum else if (recentChange < -0.001) momentumIcon = '🔴'; // -0.1% recent momentum } logger.info(`💰 BTC: $${price.toFixed(2)} ${changeIcon} ${changeText} ${momentumIcon} | Vol: ${volumeMB}M | Range: ${rangePercent}% | ${this.totalMessages} msgs, ${this.getUptimeString()}`); this.lastLogTime = Date.now(); } } catch (error) { logger.error('Failed to parse Binance message', { error: error.message }); } } /** * Add price to rolling history * @param {Object} priceData - Standardized price data */ addToHistory(priceData) { this.priceHistory.push(priceData); // Maintain rolling window if (this.priceHistory.length > this.config.maxHistorySize) { this.priceHistory = this.priceHistory.slice(-this.config.maxHistorySize); } } /** * Get current price (main interface for strategies) * @returns {number|null} Current Bitcoin price */ getCurrentPrice() { return this.currentPrice; } /** * Get latest price (alias for compatibility) * @returns {number|null} Current Bitcoin price */ getLatestPrice() { return this.currentPrice; } /** * Get recent price history (for technical indicators) * @param {number} limit - Number of recent prices * @returns {Array} Price history array */ getPriceHistory(limit = 50) { return this.priceHistory.slice(-limit); } /** * Subscribe to real-time price updates * @param {Function} callback - Price update callback * @returns {Function} Unsubscribe function */ subscribe(callback) { if (typeof callback !== 'function') { throw new Error('Callback must be a function'); } this.on('price', callback); // Return unsubscribe function return () => { this.off('price', callback); }; } /** * Start connection health monitoring */ startHeartbeatMonitor() { this.heartbeatTimer = setInterval(() => { if (!this.lastHeartbeat) return; const timeSinceHeartbeat = Date.now() - this.lastHeartbeat; const maxGap = this.config.heartbeatInterval * 2; if (timeSinceHeartbeat > maxGap) { logger.warn('💓 WebSocket heartbeat timeout - forcing reconnection', { timeSinceHeartbeat: `${timeSinceHeartbeat}ms` }); if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.close(); } } }, this.config.heartbeatInterval); } /** * Stop heartbeat monitoring */ stopHeartbeatMonitor() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } /** * Attempt reconnection with exponential backoff */ attemptReconnect() { if (this.reconnectAttempts >= this.config.maxReconnectAttempts) { logger.error('❌ Max reconnection attempts reached - switching to fallback', { attempts: this.reconnectAttempts }); this.emit('maxReconnectsReached'); return; } this.reconnectAttempts++; const delay = Math.min( this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000 ); logger.info('🔄 Reconnecting to Binance WebSocket...', { attempt: this.reconnectAttempts, delayMs: delay }); setTimeout(() => { if (!this.isConnected) { this.connect().catch(error => { logger.error('🔄 Reconnection failed', { error: error.message }); }); } }, delay); } /** * Disconnect from WebSocket * @returns {Promise<void>} */ async disconnect() { logger.info('🔌 Disconnecting from Binance WebSocket'); this.isConnected = false; this.stopHeartbeatMonitor(); if (this.ws) { this.ws.close(); this.ws = null; } this.emit('disconnected', { reason: 'manual' }); logger.info('✅ Binance WebSocket disconnected'); } /** * Get connection and data status * @returns {Object} Status information */ getStatus() { const uptime = this.connectionStartTime ? Date.now() - this.connectionStartTime : 0; return { isConnected: this.isConnected, symbol: this.config.symbol, currentPrice: this.currentPrice, priceHistorySize: this.priceHistory.length, totalMessages: this.totalMessages, uptime: uptime, uptimeHours: (uptime / (1000 * 60 * 60)).toFixed(2), reconnectAttempts: this.reconnectAttempts, lastPriceUpdate: this.lastPriceUpdate }; } /** * Get market metrics for strategy analysis * Compatible with momentum strategy expectations * @returns {Object} Market analysis metrics */ getMarketMetrics() { const history = this.getPriceHistory(100); // Get enough data for indicators if (history.length < 20) { return { trend: 'SIDEWAYS', strength: 0, rsi: 50, momentum: 0, volume: { relative: 1 } }; } const prices = history.map(h => parseFloat(h.price)); const currentPrice = this.getCurrentPrice(); // Calculate basic momentum (10-period price change) const momentum10 = prices.length >= 10 ? (currentPrice - prices[prices.length - 10]) / prices[prices.length - 10] : 0; // Simple RSI calculation const rsi = this.calculateSimpleRSI(prices, 14); // Basic trend detection const sma20 = prices.slice(-20).reduce((sum, p) => sum + p, 0) / 20; const sma50 = prices.slice(-50).reduce((sum, p) => sum + p, 0) / 50; let trend = 'SIDEWAYS'; if (currentPrice > sma20 && sma20 > sma50) trend = 'BULLISH'; else if (currentPrice < sma20 && sma20 < sma50) trend = 'BEARISH'; return { trend, strength: Math.abs(momentum10) * 100, rsi: rsi, momentum: momentum10, volume: { relative: 1 }, // WebSocket doesn't provide volume history currentPrice, sma20, sma50 }; } /** * Simple RSI calculation * @param {number[]} prices - Price array * @param {number} period - RSI period * @returns {number} RSI value */ calculateSimpleRSI(prices, period = 14) { if (prices.length < period + 1) return 50; let gains = 0; let losses = 0; // Calculate initial average gain/loss for (let i = 1; i <= period; i++) { const change = prices[i] - prices[i - 1]; if (change > 0) gains += change; else losses -= change; } const avgGain = gains / period; const avgLoss = losses / period; if (avgLoss === 0) return 100; const rs = avgGain / avgLoss; return 100 - (100 / (1 + rs)); } /** * Get uptime as readable string * @returns {string} Uptime string */ getUptimeString() { const uptime = this.connectionStartTime ? Date.now() - this.connectionStartTime : 0; const hours = Math.floor(uptime / (1000 * 60 * 60)); const minutes = Math.floor((uptime % (1000 * 60 * 60)) / (1000 * 60)); return `${hours}h ${minutes}m`; } /** * Test connection for debugging * @param {number} durationMs - Test duration * @returns {Promise<Object>} Test results */ async test(durationMs = 10000) { const startTime = Date.now(); const startMessages = this.totalMessages; const testPrices = []; const priceHandler = (priceData) => { testPrices.push({ price: priceData.price, timestamp: priceData.timestamp, change24h: priceData.change24h }); }; this.on('price', priceHandler); try { if (!this.isConnected) { await this.connect(); } await new Promise(resolve => setTimeout(resolve, durationMs)); return { success: true, duration: Date.now() - startTime, messagesReceived: this.totalMessages - startMessages, pricesCollected: testPrices.length, avgMessagesPerSecond: Math.round((this.totalMessages - startMessages) / (durationMs / 1000)), samplePrices: testPrices.slice(0, 5) }; } finally { this.off('price', priceHandler); } } } module.exports = LiveBinanceProvider;