UNPKG

betfair-node

Version:

A comprehensive Node.js TypeScript library for the Betfair Exchange API, providing both JSON-RPC API integration and real-time Exchange Stream API support for automated betting and trading applications.

654 lines 26.9 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.shutdownRecorder = exports.listRecordedMarkets = exports.loadBasicRecord = exports.getRecordingStatus = exports.createRecordingMarketChangeCallback = exports.createRawDataCallback = exports.addMarketsToRecording = exports.getRecordingCompletionStatus = exports.updateBasicRecord = exports.recordRawTransmission = exports.stopRecording = exports.registerRecorderProcessCleanup = exports.startRecording = exports.createMarketRecorderState = void 0; const fs = __importStar(require("fs")); const path = __importStar(require("path")); const betfair_exchange_stream_api_types_1 = require("./betfair-exchange-stream-api-types"); const betfair_api_1 = require("./betfair-api"); const betfair_api_types_1 = require("./betfair-api-types"); /** * Creates initial market recorder state */ const createMarketRecorderState = (config) => { // Ensure output directory exists if (!fs.existsSync(config.outputDirectory)) { fs.mkdirSync(config.outputDirectory, { recursive: true }); } return { config: { recordingMode: 'finite', // Default to finite mode ...config, }, rawFileStreams: new Map(), basicRecords: new Map(), isRecording: false, subscribedMarkets: new Set(), completedMarkets: new Set(), enrichmentCache: new Map(), }; }; exports.createMarketRecorderState = createMarketRecorderState; /** * Starts recording for specified markets */ const startRecording = (state, marketIds) => { const updatedState = { ...state, isRecording: true, subscribedMarkets: new Set([...state.subscribedMarkets, ...marketIds]) }; // Initialize raw file streams if enabled if (state.config.enableRawRecording) { marketIds.forEach(marketId => { if (!state.rawFileStreams.has(marketId)) { const filename = `${state.config.rawFilePrefix || ''}${marketId}.txt`; const filepath = path.join(state.config.outputDirectory, filename); // Ensure the output directory still exists before creating the stream // This guards against cases where the directory may have been removed between state creation and startRecording const dir = state.config.outputDirectory; if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const writeStream = fs.createWriteStream(filepath, { flags: 'a' }); // Handle asynchronous stream errors gracefully to avoid unhandled errors during tests/cleanup writeStream.on('error', (err) => { console.error(`Raw recording stream error for market ${marketId} at ${filepath}:`, err); }); // Write header if (writeStream.writable) { writeStream.write(`# Raw market data for market: ${marketId}\n`); writeStream.write(`# Started at: ${new Date().toISOString()}\n`); writeStream.write(`# Format: Each line contains raw JSON transmission from Betfair Exchange Stream API\n\n`); } updatedState.rawFileStreams.set(marketId, writeStream); } }); } return updatedState; }; exports.startRecording = startRecording; // Register a process-level cleanup to close any open write streams before exit. // This is opt-in to avoid global side-effects unless explicitly enabled. const registerRecorderProcessCleanup = (state) => { const cleanup = () => { try { state.rawFileStreams.forEach((stream) => { try { stream.end(); } catch { } }); state.rawFileStreams.clear(); } catch { } }; // Use beforeExit so pending I/O can flush; guard against multiple registrations let registered = false; const registerOnce = () => { if (registered) return; registered = true; process.once('beforeExit', cleanup); process.once('exit', cleanup); }; registerOnce(); }; exports.registerRecorderProcessCleanup = registerRecorderProcessCleanup; /** * Stops recording and closes all file streams */ const stopRecording = (state) => { // Close all open raw file streams safely and clear state try { state.rawFileStreams.forEach((stream) => { try { stream.end(); } catch { } }); } catch { } return { ...state, rawFileStreams: new Map(), basicRecords: new Map(), isRecording: false, }; }; exports.stopRecording = stopRecording; /** * Records raw transmission data to file */ const recordRawTransmission = (state, rawData) => { if (!state.isRecording || !state.config.enableRawRecording) { return; } try { // Parse the raw data to extract market IDs for routing to specific files const parsedData = JSON.parse(rawData); // Handle market change messages - route to specific market files if (parsedData.mc && Array.isArray(parsedData.mc)) { parsedData.mc.forEach((marketChange) => { const marketId = marketChange.id; const stream = state.rawFileStreams.get(marketId); if (stream) { try { if (stream.writable) { stream.write(`${rawData}\n`); } } catch (err) { // On error, end and remove the stream to avoid future writes try { stream.end(); } catch { } state.rawFileStreams.delete(marketId); console.error(`Error writing raw transmission for ${marketId}:`, err); } } }); } else { // For non-market-specific messages (connection, status, etc.), write to all active streams state.rawFileStreams.forEach((stream, marketId) => { try { if (stream.writable) { stream.write(`${rawData}\n`); } } catch (err) { try { stream.end(); } catch { } state.rawFileStreams.delete(marketId); console.error(`Error broadcasting raw transmission to ${marketId}:`, err); } }); } } catch (error) { console.error('Error parsing raw transmission for routing:', error); // If parsing fails, write to all active streams to ensure no data is lost state.rawFileStreams.forEach((stream, marketId) => { try { if (stream.writable) { stream.write(`${rawData}\n`); } } catch (err) { try { stream.end(); } catch { } state.rawFileStreams.delete(marketId); console.error(`Error writing fallback raw transmission to ${marketId}:`, err); } }); } }; exports.recordRawTransmission = recordRawTransmission; /** * Calculates reconciled trading data from runner cache */ const calculateRunnerTradingData = (runnerCache) => { const trd = runnerCache.trd || []; if (trd.length === 0) { return { reconciledVolume: 0, totalTurnover: 0, volumeWeightedPrice: 0, tradingActivity: [] }; } // Filter out zero volume trades and calculate totals const activeTrades = trd.filter(([price, volume]) => volume > 0); const reconciledVolume = activeTrades.reduce((sum, [, volume]) => sum + volume, 0); const totalTurnover = activeTrades.reduce((sum, [price, volume]) => sum + (price * volume), 0); const volumeWeightedPrice = reconciledVolume > 0 ? totalTurnover / reconciledVolume : 0; // Calculate price range let priceRange; if (activeTrades.length > 0) { const prices = activeTrades.map(([price]) => price); priceRange = { highest: Math.max(...prices), lowest: Math.min(...prices), trades: activeTrades.length }; } // Get top 10 trading activities by volume const tradingActivity = activeTrades .sort(([, a], [, b]) => b - a) // Sort by volume desc .slice(0, 10); // Top 10 return { reconciledVolume, totalTurnover, volumeWeightedPrice, priceRange, tradingActivity }; }; /** * Reconciles a runner's trading data when market completes */ const reconcileRunnerData = (basicRunner, runnerCache, isMarketComplete) => { // console.log(`🔍 Reconciling runner ${basicRunner.id}: complete=${isMarketComplete}, tv=${runnerCache.tv}, trd=${runnerCache.trd?.length || 0}`); // Always use the latest TV from stream, regardless of market completion let reconciledRunner = { ...basicRunner, tv: runnerCache.tv || basicRunner.tv, // Use stream TV if available }; // Only do full reconciliation when market is complete and has trading data if (isMarketComplete && runnerCache.trd && runnerCache.trd.length > 0) { const tradingData = calculateRunnerTradingData(runnerCache); // console.log(`📈 Runner ${basicRunner.id} trading data:`, tradingData); reconciledRunner = { ...reconciledRunner, // Update tv with calculated volume instead of Betfair's (often 0) tv: Math.max(tradingData.reconciledVolume, runnerCache.tv || 0), // Add reconciliation data reconciledVolume: tradingData.reconciledVolume, totalTurnover: tradingData.totalTurnover, volumeWeightedPrice: tradingData.volumeWeightedPrice, priceRange: tradingData.priceRange, tradingActivity: tradingData.tradingActivity }; } return reconciledRunner; }; /** * Determines if a market is truly complete based on status and runner states * Fixes issue where Betfair's 'complete' flag is unreliable */ const isMarketTrulyComplete = (marketDef, runners) => { // Market is definitely complete if closed if (marketDef.status === betfair_exchange_stream_api_types_1.StreamMarketStatus.CLOSED) { return true; } // Market is NOT complete if still open or inactive if (marketDef.status === betfair_exchange_stream_api_types_1.StreamMarketStatus.OPEN || marketDef.status === betfair_exchange_stream_api_types_1.StreamMarketStatus.INACTIVE) { return false; } // For suspended markets, check if all runners have final status if (marketDef.status === betfair_exchange_stream_api_types_1.StreamMarketStatus.SUSPENDED) { const finalStatuses = [ betfair_exchange_stream_api_types_1.StreamRunnerStatus.WINNER, betfair_exchange_stream_api_types_1.StreamRunnerStatus.LOSER, betfair_exchange_stream_api_types_1.StreamRunnerStatus.PLACED, betfair_exchange_stream_api_types_1.StreamRunnerStatus.REMOVED_VACANT, betfair_exchange_stream_api_types_1.StreamRunnerStatus.REMOVED ]; const allRunnersHaveFinalStatus = runners.every(runner => finalStatuses.includes(runner.status)); return allRunnersHaveFinalStatus; } return false; }; /** * Enriches market data with REST API catalogue information */ const enrichMarketData = async (state, marketId, basicRecord) => { if (!state.config.enrichment?.enabled || !state.config.enrichment.apiState) { return basicRecord; } try { // Check cache first const cached = state.enrichmentCache.get(marketId); const cacheExpiryMs = (state.config.enrichment.cacheExpiryMinutes || 60) * 60 * 1000; let marketCatalogue; if (cached && (Date.now() - cached.cachedAt.getTime()) < cacheExpiryMs) { marketCatalogue = cached.catalogue; } else { // Fetch from API const catalogueResponse = await (0, betfair_api_1.listMarketCatalogue)(state.config.enrichment.apiState, { marketIds: [marketId] }, ['COMPETITION', 'EVENT', 'EVENT_TYPE', 'MARKET_DESCRIPTION', 'RUNNER_DESCRIPTION', 'RUNNER_METADATA'], betfair_api_types_1.MarketSort.FIRST_TO_START, 1 // maxResults ); if (catalogueResponse.data?.result && catalogueResponse.data.result.length > 0) { marketCatalogue = catalogueResponse.data.result[0]; // Cache the result if we have valid data if (marketCatalogue) { state.enrichmentCache.set(marketId, { catalogue: marketCatalogue, cachedAt: new Date() }); } } } if (!marketCatalogue) { console.warn(`🔍 No market catalogue found for ${marketId}`); return basicRecord; } // Enrich market data const enrichedRecord = { ...basicRecord, marketName: marketCatalogue.marketName || basicRecord.marketName, eventName: marketCatalogue.event?.name || basicRecord.eventName, runners: basicRecord.runners.map(runner => { // Find matching runner in catalogue const catalogueRunner = marketCatalogue.runners?.find((r) => r.selectionId === runner.id); return { ...runner, name: catalogueRunner?.runnerName || runner.name }; }) }; console.log(`✨ Enriched market ${marketId}: "${enrichedRecord.marketName}" in "${enrichedRecord.eventName}"`); return enrichedRecord; } catch (error) { console.warn(`⚠️ Failed to enrich market ${marketId}:`, error); return basicRecord; } }; /** * Updates basic market record from market cache */ const updateBasicRecord = (state, marketCache) => { if (!state.isRecording || !state.config.enableBasicRecording) { return; } try { const marketDef = marketCache.marketDefinition; const marketId = marketCache.marketId; // Create or update basic record const existing = state.basicRecords.get(marketId); const preCompleteCheck = isMarketTrulyComplete(marketDef, []); // Build/merge record const runners = Object.values(marketCache.runners).map(runner => { // Try to find runner name from market definition const runnerDef = marketDef.runners.find(r => r.id === runner.id); const runnerName = runnerDef ? getRunnerName(runnerDef) : `Runner ${runner.id}`; return { id: runner.id, name: runnerName, status: runner.status, adjustmentFactor: runner.adjustmentFactor, lastPriceTraded: runner.lastPriceTraded, totalMatched: runner.totalMatched, bsp: runnerDef?.bsp, ltp: runner.ltp, tv: runner.tv, // Will be updated by reconciliation if market complete spn: runner.spn, spf: runner.spf, finalStatus: runner.status, isWinner: runner.status === betfair_exchange_stream_api_types_1.StreamRunnerStatus.WINNER, }; }); // NOW determine if market is truly complete using the actual runners const isComplete = isMarketTrulyComplete(marketDef, runners); // Apply reconciliation to runners if market is complete const reconciledRunners = runners.map(runner => { const runnerCache = Object.values(marketCache.runners).find(r => r.id === runner.id); return runnerCache ? reconcileRunnerData(runner, runnerCache, isComplete) : runner; }); // Calculate market-level reconciliation data let reconciliationData = {}; if (isComplete) { const calculatedTotalTurnover = reconciledRunners.reduce((sum, runner) => sum + (runner.totalTurnover || 0), 0); const totalUniquePrice = reconciledRunners.reduce((sum, runner) => sum + (runner.priceRange?.trades || 0), 0); const marketDepth = reconciledRunners.reduce((sum, runner) => sum + (runner.reconciledVolume || runner.tv), 0); reconciliationData = { reconciled: true, reconciliationTime: new Date().toISOString(), calculatedTotalTurnover, liquidityProfile: { totalUniquePrice, marketDepth } }; } const basicRecord = { marketId, marketName: marketDef.name || `Market ${marketId}`, eventName: marketDef.eventName || 'Unknown Event', marketStatus: marketDef.status, marketTime: marketDef.marketTime, openDate: marketDef.openDate, totalMatched: marketDef.totalMatched, inPlay: marketDef.inPlay, bspReconciled: marketDef.bspReconciled, complete: isComplete, // Use our corrected logic numberOfWinners: marketDef.numberOfWinners, runners: reconciledRunners, // Use reconciled runners recordedAt: new Date().toISOString(), finalTotalMatched: isComplete ? marketDef.totalMatched : undefined, winners: reconciledRunners.filter(r => r.isWinner).map(r => r.id), ...reconciliationData // Add reconciliation data if market complete }; // Save to memory state.basicRecords.set(marketId, basicRecord); // If the market is complete, persist and close its raw stream if (basicRecord.complete) { saveBasicRecord(state.config, basicRecord); // Close raw stream for this market to prevent leaks in long-running sessions const rawStream = state.rawFileStreams.get(marketId); if (rawStream) { try { rawStream.end(); } catch { } state.rawFileStreams.delete(marketId); } // Track completion and possibly stop all if finite mode state.completedMarkets.add(marketId); checkAllMarketsComplete(state); } } catch (error) { console.error('Error updating basic record:', error); } }; exports.updateBasicRecord = updateBasicRecord; /** * Checks if all subscribed markets are complete and triggers callback if so */ const checkAllMarketsComplete = (state) => { if (state.config.recordingMode === 'perpetual') { return; // Never stop for perpetual recording } const allMarketsComplete = Array.from(state.subscribedMarkets).every(marketId => state.completedMarkets.has(marketId)); if (allMarketsComplete && state.subscribedMarkets.size > 0) { console.log(`✅ All ${state.subscribedMarkets.size} markets completed - recording finished!`); if (state.config.onAllMarketsComplete) { state.config.onAllMarketsComplete(); } } }; /** * Gets completion status for all markets */ const getRecordingCompletionStatus = (state) => { const totalMarkets = state.subscribedMarkets.size; const completedMarkets = state.completedMarkets.size; const pendingMarkets = Array.from(state.subscribedMarkets).filter(marketId => !state.completedMarkets.has(marketId)); const isAllComplete = state.config.recordingMode === 'finite' && totalMarkets > 0 && completedMarkets === totalMarkets; return { totalMarkets, completedMarkets, pendingMarkets, isAllComplete, recordingMode: state.config.recordingMode || 'finite', }; }; exports.getRecordingCompletionStatus = getRecordingCompletionStatus; /** * Adds new markets to an existing recording session */ const addMarketsToRecording = (state, marketIds) => { if (!state.isRecording) { console.warn('Cannot add markets: recording is not active'); return state; } console.log(`📈 Adding ${marketIds.length} new markets to recording`); return (0, exports.startRecording)(state, marketIds); }; exports.addMarketsToRecording = addMarketsToRecording; /** * Creates a raw data callback for recording raw transmissions */ const createRawDataCallback = (state) => { return (rawData) => { (0, exports.recordRawTransmission)(state, rawData); }; }; exports.createRawDataCallback = createRawDataCallback; /** * Creates a market change callback that handles basic recording */ const createRecordingMarketChangeCallback = (state, originalCallback) => { return (marketCache, deltas) => { // Update basic records if (state.config.enableBasicRecording) { Object.values(marketCache).forEach(cache => { (0, exports.updateBasicRecord)(state, cache); }); } // Call original callback if provided if (originalCallback) { originalCallback(marketCache, deltas); } }; }; exports.createRecordingMarketChangeCallback = createRecordingMarketChangeCallback; /** * Saves a basic market record to file */ const saveBasicRecord = (config, record) => { try { const filename = `${config.basicFilePrefix || 'basic_'}${record.marketId}.json`; const filepath = path.join(config.outputDirectory, filename); const recordWithMetadata = { ...record, recordingMetadata: { version: '1.0', createdAt: new Date().toISOString(), recordingType: 'basic', } }; fs.writeFileSync(filepath, JSON.stringify(recordWithMetadata, null, 2)); } catch (error) { console.error(`Error saving basic record for market ${record.marketId}:`, error); } }; /** * Gets runner name from runner definition (fallback to ID if no name available) */ const getRunnerName = (runnerDef) => { // Runner definitions might have different name fields depending on the sport if (runnerDef.name) return runnerDef.name; if (runnerDef.runnerName) return runnerDef.runnerName; if (runnerDef.selectionName) return runnerDef.selectionName; return `Runner ${runnerDef.id}`; }; /** * Gets recording status for a market */ const getRecordingStatus = (state, marketId) => { const hasRawStream = state.rawFileStreams.has(marketId); const hasBasicRecord = state.basicRecords.has(marketId); let rawFilePath; let basicFilePath; if (hasRawStream) { const filename = `${state.config.rawFilePrefix || ''}${marketId}.txt`; rawFilePath = path.join(state.config.outputDirectory, filename); } if (hasBasicRecord) { const filename = `${state.config.basicFilePrefix || 'basic_'}${marketId}.json`; basicFilePath = path.join(state.config.outputDirectory, filename); } return { isRecording: state.isRecording, hasRawStream, hasBasicRecord, rawFilePath, basicFilePath, }; }; exports.getRecordingStatus = getRecordingStatus; /** * Loads a basic market record from file */ const loadBasicRecord = (config, marketId) => { try { const filename = `${config.basicFilePrefix || 'basic_'}${marketId}.json`; const filepath = path.join(config.outputDirectory, filename); if (!fs.existsSync(filepath)) { return null; } const fileContent = fs.readFileSync(filepath, 'utf-8'); return JSON.parse(fileContent); } catch (error) { console.error(`Error loading basic record for market ${marketId}:`, error); return null; } }; exports.loadBasicRecord = loadBasicRecord; /** * Lists all recorded markets in the output directory */ const listRecordedMarkets = (config) => { try { const files = fs.readdirSync(config.outputDirectory); const basicPrefix = config.basicFilePrefix || 'basic_'; const rawPrefix = config.rawFilePrefix || ''; const basicRecords = files .filter(f => f.startsWith(basicPrefix) && f.endsWith('.json')) .map(f => f.replace(basicPrefix, '').replace('.json', '')); const rawRecords = files .filter(f => f.startsWith(rawPrefix) && f.endsWith('.txt') && !f.startsWith(basicPrefix)) .map(f => f.replace(rawPrefix, '').replace('.txt', '')); return { basicRecords, rawRecords }; } catch (error) { console.error('Error listing recorded markets:', error); return { basicRecords: [], rawRecords: [] }; } }; exports.listRecordedMarkets = listRecordedMarkets; const shutdownRecorder = (state) => { // Public helper for production apps to stop recording deterministically return (0, exports.stopRecording)(state); }; exports.shutdownRecorder = shutdownRecorder; //# sourceMappingURL=market-recorder.js.map