OddsHistory.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  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 = 3 * 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 getOddsHistoryGames = async ({ page = 1, pageSize = 50, status = -1, keyword } = {}) => {
  134. page = Math.max(1, +page || 1);
  135. pageSize = Math.min(50, Math.max(1, +pageSize || 50));
  136. status = +status;
  137. const now = Date.now();
  138. const query = { eventId: { $gt: 0 } };
  139. if (status === 1) {
  140. query.endTime = { $gte: now };
  141. }
  142. else if (status === 2) {
  143. query.endTime = { $lt: now };
  144. }
  145. if (keyword) {
  146. const pattern = new RegExp(String(keyword).trim(), 'i');
  147. query.$or = [
  148. { leagueName: pattern },
  149. { teamHomeName: pattern },
  150. { teamAwayName: pattern },
  151. ];
  152. if (/^\d+$/.test(String(keyword).trim())) {
  153. query.$or.push({ eventId: +keyword });
  154. }
  155. }
  156. const [histories, total] = await Promise.all([
  157. OddsHistory.find(query)
  158. .select('eventId leagueName teamHomeName teamAwayName startTime endTime markets updatedAt')
  159. .sort({ startTime: -1 })
  160. .skip((page - 1) * pageSize)
  161. .limit(pageSize)
  162. .lean({ flattenMaps: true }),
  163. OddsHistory.countDocuments(query),
  164. ]);
  165. const list = histories.map(history => ({
  166. eventId: history.eventId,
  167. leagueName: history.leagueName,
  168. teamHomeName: history.teamHomeName,
  169. teamAwayName: history.teamAwayName,
  170. startTime: history.startTime,
  171. endTime: history.endTime,
  172. marketCount: Object.values(history.markets ?? {}).filter(points => points?.length).length,
  173. updatedAt: history.updatedAt,
  174. }));
  175. return { list, page, pageSize, total };
  176. }
  177. const cleanupExpiredHistory = async () => {
  178. const expireTime = Date.now() - ODDS_HISTORY_RETENTION;
  179. return OddsHistory.deleteMany({ startTime: { $lt: expireTime } });
  180. }
  181. const startCleanup = (logger = console) => {
  182. const cleanup = () => {
  183. cleanupExpiredHistory()
  184. .then(result => {
  185. if (result.deletedCount) {
  186. logger.out?.('odds history cleanup deleted %d records', result.deletedCount);
  187. }
  188. })
  189. .catch(err => {
  190. logger.out?.('odds history cleanup failed: %s', err.message);
  191. });
  192. };
  193. cleanup();
  194. return setInterval(cleanup, ODDS_HISTORY_CLEANUP_INTERVAL);
  195. }
  196. module.exports = {
  197. TRACKED_IOR_KEYS,
  198. cleanupExpiredHistory,
  199. getGameOddsHistory,
  200. getOddsHistoryGames,
  201. isInsideTrackWindow,
  202. recordGameOdds,
  203. startCleanup,
  204. };