skayn-trading-sdk
Version:
Professional Bitcoin trading strategy backtesting framework with institutional-grade architecture
495 lines (414 loc) • 14.2 kB
JavaScript
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;