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
JavaScript
;
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