const mongoose = require('mongoose'); const { Schema } = mongoose; const TRACKED_IOR_KEYS = [ 'ior_mn', 'ior_wmh_1', 'ior_wmh_2', 'ior_wmh_3', 'ior_wmc_1', 'ior_wmc_2', 'ior_wmc_3', 'ior_ot_1', 'ior_ot_2', 'ior_ot_3', 'ior_ot_4', 'ior_ot_5', ]; const TRACKED_IOR_SET = new Set(TRACKED_IOR_KEYS); const ODDS_HISTORY_START_OFFSET = 2 * 60 * 60 * 1000; const ODDS_HISTORY_END_OFFSET = 3 * 60 * 60 * 1000; const ODDS_HISTORY_RETENTION = 2 * 24 * 60 * 60 * 1000; const ODDS_HISTORY_CLEANUP_INTERVAL = 60 * 60 * 1000; const oddsPointSchema = new Schema({ time: { type: Number, required: true }, value: { type: Number, required: true }, source: { type: Number }, origin: { type: String }, }, { _id: false }); const oddsHistorySchema = new Schema({ eventId: { type: Number, required: true, unique: true, index: true }, originId: { type: Number }, leagueName: { type: String }, teamHomeName: { type: String }, teamAwayName: { type: String }, startTime: { type: Number, required: true, index: true }, endTime: { type: Number, required: true, index: true }, markets: { type: Map, of: [oddsPointSchema], default: {}, }, }, { timestamps: true }); const OddsHistory = mongoose.model('OddsHistory', oddsHistorySchema); const isTrackedKey = (key) => TRACKED_IOR_SET.has(key); const isInsideTrackWindow = (eventTime, recordTime = Date.now()) => { if (!eventTime) { return false; } return recordTime >= eventTime - ODDS_HISTORY_START_OFFSET && recordTime <= eventTime + ODDS_HISTORY_END_OFFSET; } const formatPoint = (event) => { const value = event?.v; if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { return null; } return { value, source: event.s, origin: event.r, }; } const appendChangedPoint = (markets, key, point, time) => { const history = markets.get(key) ?? []; const last = history[history.length - 1]; if (last && last.value === point.value && last.source === point.source && last.origin === point.origin) { return false; } history.push({ time, ...point }); markets.set(key, history); return true; } const recordGameOdds = async ({ game, events }) => { const eventId = +game?.eventId; const startTime = +game?.timestamp; const time = Date.now(); if (!eventId || !isInsideTrackWindow(startTime, time) || !events) { return { updated: false }; } const trackedEntries = Object.entries(events) .filter(([key]) => isTrackedKey(key)) .map(([key, event]) => [key, formatPoint(event)]) .filter(([, point]) => point); const doc = await OddsHistory.findOne({ eventId }); const markets = doc?.markets ?? new Map(); let changed = false; const currentKeys = new Set(trackedEntries.map(([key]) => key)); trackedEntries.forEach(([key, point]) => { if (appendChangedPoint(markets, key, point, time)) { changed = true; } }); TRACKED_IOR_KEYS.forEach(key => { if (currentKeys.has(key) || !markets.has(key)) { return; } if (appendChangedPoint(markets, key, { value: 0 }, time)) { changed = true; } }); if (!changed) { return { updated: false }; } await OddsHistory.findOneAndUpdate( { eventId }, { $set: { originId: game.originId, leagueName: game.leagueName, teamHomeName: game.teamHomeName, teamAwayName: game.teamAwayName, startTime, endTime: startTime + ODDS_HISTORY_END_OFFSET, markets, }, }, { upsert: true, new: true }, ); return { updated: true }; } const getGameOddsHistory = async (eventId) => { if (!eventId) { throw new Error('eventId is required'); } const history = await OddsHistory.findOne({ eventId: +eventId }).lean({ flattenMaps: true }); if (!history) { return null; } return { ...history, markets: history.markets ? Object.fromEntries(Object.entries(history.markets)) : {}, }; } const cleanupExpiredHistory = async () => { const expireTime = Date.now() - ODDS_HISTORY_RETENTION; return OddsHistory.deleteMany({ startTime: { $lt: expireTime } }); } const startCleanup = (logger = console) => { const cleanup = () => { cleanupExpiredHistory() .then(result => { if (result.deletedCount) { logger.out?.('odds history cleanup deleted %d records', result.deletedCount); } }) .catch(err => { logger.out?.('odds history cleanup failed: %s', err.message); }); }; cleanup(); return setInterval(cleanup, ODDS_HISTORY_CLEANUP_INTERVAL); } module.exports = { TRACKED_IOR_KEYS, cleanupExpiredHistory, getGameOddsHistory, isInsideTrackWindow, recordGameOdds, startCleanup, };