OddsHistory.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. const mongoose = require('mongoose');
  2. const { Schema } = mongoose;
  3. const TRACKED_IOR_KEYS = [
  4. 'ior_mn',
  5. 'ior_wmh_1',
  6. 'ior_wmh_2',
  7. 'ior_wmh_3',
  8. 'ior_wmc_1',
  9. 'ior_wmc_2',
  10. 'ior_wmc_3',
  11. 'ior_ot_1',
  12. 'ior_ot_2',
  13. 'ior_ot_3',
  14. 'ior_ot_4',
  15. 'ior_ot_5',
  16. ];
  17. const TRACKED_IOR_SET = new Set(TRACKED_IOR_KEYS);
  18. const ODDS_HISTORY_START_OFFSET = 2 * 60 * 60 * 1000;
  19. const ODDS_HISTORY_END_OFFSET = 3 * 60 * 60 * 1000;
  20. const ODDS_HISTORY_RETENTION = 2 * 24 * 60 * 60 * 1000;
  21. const ODDS_HISTORY_CLEANUP_INTERVAL = 60 * 60 * 1000;
  22. const oddsPointSchema = new Schema({
  23. time: { type: Number, required: true },
  24. value: { type: Number, required: true },
  25. source: { type: Number },
  26. origin: { type: String },
  27. }, { _id: false });
  28. const oddsHistorySchema = new Schema({
  29. eventId: { type: Number, required: true, unique: true, index: true },
  30. originId: { type: Number },
  31. leagueName: { type: String },
  32. teamHomeName: { type: String },
  33. teamAwayName: { type: String },
  34. startTime: { type: Number, required: true, index: true },
  35. endTime: { type: Number, required: true, index: true },
  36. markets: {
  37. type: Map,
  38. of: [oddsPointSchema],
  39. default: {},
  40. },
  41. }, { timestamps: true });
  42. const OddsHistory = mongoose.model('OddsHistory', oddsHistorySchema);
  43. const isTrackedKey = (key) => TRACKED_IOR_SET.has(key);
  44. const isInsideTrackWindow = (eventTime, recordTime = Date.now()) => {
  45. if (!eventTime) {
  46. return false;
  47. }
  48. return recordTime >= eventTime - ODDS_HISTORY_START_OFFSET &&
  49. recordTime <= eventTime + ODDS_HISTORY_END_OFFSET;
  50. }
  51. const formatPoint = (event) => {
  52. const value = event?.v;
  53. if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
  54. return null;
  55. }
  56. return {
  57. value,
  58. source: event.s,
  59. origin: event.r,
  60. };
  61. }
  62. const appendChangedPoint = (markets, key, point, time) => {
  63. const history = markets.get(key) ?? [];
  64. const last = history[history.length - 1];
  65. if (last && last.value === point.value && last.source === point.source && last.origin === point.origin) {
  66. return false;
  67. }
  68. history.push({ time, ...point });
  69. markets.set(key, history);
  70. return true;
  71. }
  72. const recordGameOdds = async ({ game, events }) => {
  73. const eventId = +game?.eventId;
  74. const startTime = +game?.timestamp;
  75. const time = Date.now();
  76. if (!eventId || !isInsideTrackWindow(startTime, time) || !events) {
  77. return { updated: false };
  78. }
  79. const trackedEntries = Object.entries(events)
  80. .filter(([key]) => isTrackedKey(key))
  81. .map(([key, event]) => [key, formatPoint(event)])
  82. .filter(([, point]) => point);
  83. const doc = await OddsHistory.findOne({ eventId });
  84. const markets = doc?.markets ?? new Map();
  85. let changed = false;
  86. const currentKeys = new Set(trackedEntries.map(([key]) => key));
  87. trackedEntries.forEach(([key, point]) => {
  88. if (appendChangedPoint(markets, key, point, time)) {
  89. changed = true;
  90. }
  91. });
  92. TRACKED_IOR_KEYS.forEach(key => {
  93. if (currentKeys.has(key) || !markets.has(key)) {
  94. return;
  95. }
  96. if (appendChangedPoint(markets, key, { value: 0 }, time)) {
  97. changed = true;
  98. }
  99. });
  100. if (!changed) {
  101. return { updated: false };
  102. }
  103. await OddsHistory.findOneAndUpdate(
  104. { eventId },
  105. {
  106. $set: {
  107. originId: game.originId,
  108. leagueName: game.leagueName,
  109. teamHomeName: game.teamHomeName,
  110. teamAwayName: game.teamAwayName,
  111. startTime,
  112. endTime: startTime + ODDS_HISTORY_END_OFFSET,
  113. markets,
  114. },
  115. },
  116. { upsert: true, new: true },
  117. );
  118. return { updated: true };
  119. }
  120. const getGameOddsHistory = async (eventId) => {
  121. if (!eventId) {
  122. throw new Error('eventId is required');
  123. }
  124. const history = await OddsHistory.findOne({ eventId: +eventId }).lean({ flattenMaps: true });
  125. if (!history) {
  126. return null;
  127. }
  128. return {
  129. ...history,
  130. markets: history.markets ? Object.fromEntries(Object.entries(history.markets)) : {},
  131. };
  132. }
  133. const cleanupExpiredHistory = async () => {
  134. const expireTime = Date.now() - ODDS_HISTORY_RETENTION;
  135. return OddsHistory.deleteMany({ startTime: { $lt: expireTime } });
  136. }
  137. const startCleanup = (logger = console) => {
  138. const cleanup = () => {
  139. cleanupExpiredHistory()
  140. .then(result => {
  141. if (result.deletedCount) {
  142. logger.out?.('odds history cleanup deleted %d records', result.deletedCount);
  143. }
  144. })
  145. .catch(err => {
  146. logger.out?.('odds history cleanup failed: %s', err.message);
  147. });
  148. };
  149. cleanup();
  150. return setInterval(cleanup, ODDS_HISTORY_CLEANUP_INTERVAL);
  151. }
  152. module.exports = {
  153. TRACKED_IOR_KEYS,
  154. cleanupExpiredHistory,
  155. getGameOddsHistory,
  156. isInsideTrackWindow,
  157. recordGameOdds,
  158. startCleanup,
  159. };