OddsHistory.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import mongoose from 'mongoose';
  2. const { Schema } = mongoose;
  3. const TRACKED_IOR_KEYS = [
  4. 'ior_ot_1',
  5. 'ior_ot_2',
  6. 'ior_ot_3',
  7. 'ior_ot_4',
  8. ];
  9. const TRACKED_IOR_SET = new Set(TRACKED_IOR_KEYS);
  10. const ODDS_HISTORY_START_OFFSET = 2 * 60 * 60 * 1000;
  11. const ODDS_HISTORY_END_OFFSET = 3 * 60 * 60 * 1000;
  12. const ODDS_HISTORY_RETENTION = 3 * 24 * 60 * 60 * 1000;
  13. const ODDS_HISTORY_CLEANUP_INTERVAL = 5 * 60 * 1000;
  14. const oddsPointSchema = new Schema({
  15. time: { type: Number, required: true },
  16. value: { type: Number, required: true },
  17. source: { type: Number },
  18. origin: { type: String },
  19. }, { _id: false });
  20. const oddsHistorySchema = new Schema({
  21. eventId: { type: Number, required: true, unique: true, index: true },
  22. originId: { type: Number },
  23. leagueName: { type: String },
  24. teamHomeName: { type: String },
  25. teamAwayName: { type: String },
  26. startTime: { type: Number, required: true, index: true },
  27. endTime: { type: Number, required: true, index: true },
  28. markets: {
  29. type: Map,
  30. of: [oddsPointSchema],
  31. default: {},
  32. },
  33. }, { timestamps: true });
  34. const OddsHistory = mongoose.model('OddsHistory', oddsHistorySchema);
  35. const isTrackedKey = (key) => TRACKED_IOR_SET.has(key);
  36. const isInsideTrackWindow = (eventTime, recordTime = Date.now()) => {
  37. if (!eventTime) {
  38. return false;
  39. }
  40. return recordTime >= eventTime - ODDS_HISTORY_START_OFFSET &&
  41. recordTime <= eventTime + ODDS_HISTORY_END_OFFSET;
  42. }
  43. const formatPoint = (event) => {
  44. const value = event?.v;
  45. if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
  46. return null;
  47. }
  48. return {
  49. value,
  50. source: event.s,
  51. origin: event.r,
  52. };
  53. }
  54. const appendChangedPoint = (markets, key, point, time) => {
  55. const history = markets.get(key) ?? [];
  56. const last = history[history.length - 1];
  57. if (last && last.value === point.value && last.source === point.source && last.origin === point.origin) {
  58. return false;
  59. }
  60. history.push({ time, ...point });
  61. markets.set(key, history);
  62. return true;
  63. }
  64. const recordGameOdds = async ({ game, events }) => {
  65. const eventId = +game?.eventId;
  66. const startTime = +game?.timestamp;
  67. const time = Date.now();
  68. if (!eventId || !isInsideTrackWindow(startTime, time) || !events) {
  69. return { updated: false };
  70. }
  71. const trackedEntries = Object.entries(events)
  72. .filter(([key]) => isTrackedKey(key))
  73. .map(([key, event]) => [key, formatPoint(event)])
  74. .filter(([, point]) => point);
  75. const doc = await OddsHistory.findOne({ eventId });
  76. const markets = doc?.markets ?? new Map();
  77. let changed = false;
  78. const currentKeys = new Set(trackedEntries.map(([key]) => key));
  79. trackedEntries.forEach(([key, point]) => {
  80. if (appendChangedPoint(markets, key, point, time)) {
  81. changed = true;
  82. }
  83. });
  84. TRACKED_IOR_KEYS.forEach(key => {
  85. if (currentKeys.has(key) || !markets.has(key)) {
  86. return;
  87. }
  88. if (appendChangedPoint(markets, key, { value: 0 }, time)) {
  89. changed = true;
  90. }
  91. });
  92. if (!changed) {
  93. return { updated: false };
  94. }
  95. await OddsHistory.findOneAndUpdate(
  96. { eventId },
  97. {
  98. $set: {
  99. originId: game.originId,
  100. leagueName: game.leagueName,
  101. teamHomeName: game.teamHomeName,
  102. teamAwayName: game.teamAwayName,
  103. startTime,
  104. endTime: startTime + ODDS_HISTORY_END_OFFSET,
  105. markets,
  106. },
  107. },
  108. { upsert: true, new: true },
  109. );
  110. return { updated: true };
  111. }
  112. const getGameOddsHistory = async (eventId) => {
  113. if (!eventId) {
  114. throw new Error('eventId is required');
  115. }
  116. const history = await OddsHistory.findOne({ eventId: +eventId }).lean({ flattenMaps: true });
  117. if (!history) {
  118. return null;
  119. }
  120. const markets = Object.fromEntries(
  121. Object.entries(history.markets ?? {})
  122. .filter(([key, points]) => isTrackedKey(key) && points?.length),
  123. );
  124. return {
  125. ...history,
  126. markets,
  127. };
  128. }
  129. const getOddsHistoryGames = async ({ page = 1, pageSize = 50, status = -1, keyword } = {}) => {
  130. page = Math.max(1, +page || 1);
  131. pageSize = Math.min(50, Math.max(1, +pageSize || 50));
  132. status = +status;
  133. const now = Date.now();
  134. const query = { eventId: { $gt: 0 } };
  135. if (status === 1) {
  136. query.endTime = { $gte: now };
  137. }
  138. else if (status === 2) {
  139. query.endTime = { $lt: now };
  140. }
  141. if (keyword) {
  142. const pattern = new RegExp(String(keyword).trim(), 'i');
  143. query.$or = [
  144. { leagueName: pattern },
  145. { teamHomeName: pattern },
  146. { teamAwayName: pattern },
  147. ];
  148. if (/^\d+$/.test(String(keyword).trim())) {
  149. query.$or.push({ eventId: +keyword });
  150. }
  151. }
  152. const [histories, total] = await Promise.all([
  153. OddsHistory.find(query)
  154. .select('eventId leagueName teamHomeName teamAwayName startTime endTime markets updatedAt')
  155. .sort({ startTime: -1 })
  156. .skip((page - 1) * pageSize)
  157. .limit(pageSize)
  158. .lean({ flattenMaps: true }),
  159. OddsHistory.countDocuments(query),
  160. ]);
  161. const list = histories.map(history => ({
  162. eventId: history.eventId,
  163. leagueName: history.leagueName,
  164. teamHomeName: history.teamHomeName,
  165. teamAwayName: history.teamAwayName,
  166. startTime: history.startTime,
  167. endTime: history.endTime,
  168. marketCount: Object.entries(history.markets ?? {})
  169. .filter(([key, points]) => isTrackedKey(key) && points?.length).length,
  170. updatedAt: history.updatedAt,
  171. }));
  172. return { list, page, pageSize, total };
  173. }
  174. const cleanupExpiredHistory = async () => {
  175. const expireTime = Date.now() - ODDS_HISTORY_RETENTION;
  176. return OddsHistory.deleteMany({ startTime: { $lt: expireTime } });
  177. }
  178. const startCleanup = (logger = console) => {
  179. const cleanup = () => {
  180. cleanupExpiredHistory()
  181. .then(result => {
  182. if (result.deletedCount) {
  183. logger.out?.('odds history cleanup deleted %d records', result.deletedCount);
  184. }
  185. })
  186. .catch(err => {
  187. logger.out?.('odds history cleanup failed: %s', err.message);
  188. });
  189. };
  190. cleanup();
  191. return setInterval(cleanup, ODDS_HISTORY_CLEANUP_INTERVAL);
  192. }
  193. export {
  194. getGameOddsHistory,
  195. getOddsHistoryGames,
  196. recordGameOdds,
  197. startCleanup,
  198. };
  199. export default {
  200. getGameOddsHistory,
  201. getOddsHistoryGames,
  202. recordGameOdds,
  203. startCleanup,
  204. };