OddsHistory.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  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 TRACKED_PLATFORMS = ['pc', 'ob'];
  11. const TRACKED_PLATFORM_SET = new Set(TRACKED_PLATFORMS);
  12. const DEFAULT_PLATFORM = 'pc';
  13. const ODDS_HISTORY_START_OFFSET = 2 * 60 * 60 * 1000;
  14. const ODDS_HISTORY_END_OFFSET = 3 * 60 * 60 * 1000;
  15. const ODDS_HISTORY_RETENTION = 3 * 24 * 60 * 60 * 1000;
  16. const ODDS_HISTORY_CLEANUP_INTERVAL = 5 * 60 * 1000;
  17. const oddsPointSchema = new Schema({
  18. time: { type: Number, required: true },
  19. value: { type: Number, required: true },
  20. source: { type: Number },
  21. origin: { type: String },
  22. }, { _id: false });
  23. const oddsHistorySchema = new Schema({
  24. eventId: { type: Number, required: true, unique: true, index: true },
  25. originId: { type: Number },
  26. leagueName: { type: String },
  27. teamHomeName: { type: String },
  28. teamAwayName: { type: String },
  29. startTime: { type: Number, required: true, index: true },
  30. endTime: { type: Number, required: true, index: true },
  31. markets: {
  32. type: Map,
  33. of: [oddsPointSchema],
  34. default: {},
  35. },
  36. }, { timestamps: true });
  37. const OddsHistory = mongoose.model('OddsHistory', oddsHistorySchema);
  38. const isTrackedKey = (key) => TRACKED_IOR_SET.has(key);
  39. const normalizePlatform = (platform) => {
  40. return TRACKED_PLATFORM_SET.has(platform) ? platform : DEFAULT_PLATFORM;
  41. }
  42. const getMarketKey = (platform, ior) => {
  43. return `${normalizePlatform(platform)}:${ior}`;
  44. }
  45. const parseMarketKey = (key) => {
  46. if (isTrackedKey(key)) {
  47. return { ior: key, platform: DEFAULT_PLATFORM };
  48. }
  49. const [platform, ior] = String(key).split(/[:.]/);
  50. if (TRACKED_PLATFORM_SET.has(platform) && isTrackedKey(ior)) {
  51. return { ior, platform };
  52. }
  53. return null;
  54. }
  55. const getMarketEntries = (markets = {}) => {
  56. if (markets instanceof Map) {
  57. return Array.from(markets.entries());
  58. }
  59. return Object.entries(markets);
  60. }
  61. const normalizeMarkets = (markets = {}) => {
  62. const normalized = {};
  63. getMarketEntries(markets).forEach(([key, points]) => {
  64. const parsed = parseMarketKey(key);
  65. if (!parsed || !points?.length) {
  66. return;
  67. }
  68. const marketKey = getMarketKey(parsed.platform, parsed.ior);
  69. normalized[marketKey] = [
  70. ...(normalized[marketKey] ?? []),
  71. ...points,
  72. ].sort((a, b) => a.time - b.time);
  73. });
  74. return normalized;
  75. }
  76. const migrateLegacyMarkets = (markets) => {
  77. getMarketEntries(markets).forEach(([key, points]) => {
  78. const parsed = parseMarketKey(key);
  79. if (!parsed || !points?.length) {
  80. return;
  81. }
  82. const marketKey = getMarketKey(parsed.platform, parsed.ior);
  83. if (key === marketKey) {
  84. return;
  85. }
  86. if (!markets.has(marketKey)) {
  87. markets.set(marketKey, points);
  88. }
  89. else {
  90. markets.set(marketKey, [
  91. ...markets.get(marketKey),
  92. ...points,
  93. ].sort((a, b) => a.time - b.time));
  94. }
  95. markets.delete(key);
  96. });
  97. }
  98. const isInsideTrackWindow = (eventTime, recordTime = Date.now()) => {
  99. if (!eventTime) {
  100. return false;
  101. }
  102. return recordTime >= eventTime - ODDS_HISTORY_START_OFFSET &&
  103. recordTime <= eventTime + ODDS_HISTORY_END_OFFSET;
  104. }
  105. const formatPoint = (event) => {
  106. const value = event?.v;
  107. if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
  108. return null;
  109. }
  110. return {
  111. value,
  112. source: event.s,
  113. origin: event.r,
  114. };
  115. }
  116. const appendChangedPoint = (markets, key, point, time) => {
  117. const history = markets.get(key) ?? [];
  118. const last = history[history.length - 1];
  119. if (last && last.value === point.value && last.source === point.source && last.origin === point.origin) {
  120. return false;
  121. }
  122. history.push({ time, ...point });
  123. markets.set(key, history);
  124. return true;
  125. }
  126. const recordGameOdds = async ({ game, events, platform = DEFAULT_PLATFORM }) => {
  127. const eventId = +game?.eventId;
  128. const startTime = +game?.timestamp;
  129. const time = Date.now();
  130. platform = normalizePlatform(platform);
  131. if (!eventId || !isInsideTrackWindow(startTime, time) || !events) {
  132. return { updated: false };
  133. }
  134. const trackedEntries = Object.entries(events)
  135. .filter(([key]) => isTrackedKey(key))
  136. .map(([key, event]) => [key, formatPoint(event)])
  137. .filter(([, point]) => point);
  138. const doc = await OddsHistory.findOne({ eventId });
  139. const markets = doc?.markets ?? new Map();
  140. migrateLegacyMarkets(markets);
  141. let changed = false;
  142. const currentKeys = new Set(trackedEntries.map(([key]) => getMarketKey(platform, key)));
  143. trackedEntries.forEach(([key, point]) => {
  144. if (appendChangedPoint(markets, getMarketKey(platform, key), point, time)) {
  145. changed = true;
  146. }
  147. });
  148. TRACKED_IOR_KEYS.forEach(key => {
  149. const marketKey = getMarketKey(platform, key);
  150. if (currentKeys.has(marketKey) || !markets.has(marketKey)) {
  151. return;
  152. }
  153. if (appendChangedPoint(markets, marketKey, { value: 0 }, time)) {
  154. changed = true;
  155. }
  156. });
  157. if (!changed) {
  158. return { updated: false };
  159. }
  160. await OddsHistory.findOneAndUpdate(
  161. { eventId },
  162. {
  163. $set: {
  164. originId: game.originId,
  165. leagueName: game.leagueName,
  166. teamHomeName: game.teamHomeName,
  167. teamAwayName: game.teamAwayName,
  168. startTime,
  169. endTime: startTime + ODDS_HISTORY_END_OFFSET,
  170. markets,
  171. },
  172. },
  173. { upsert: true, new: true },
  174. );
  175. return { updated: true };
  176. }
  177. const getGameOddsHistory = async (eventId) => {
  178. if (!eventId) {
  179. throw new Error('eventId is required');
  180. }
  181. const history = await OddsHistory.findOne({ eventId: +eventId }).lean({ flattenMaps: true });
  182. if (!history) {
  183. return null;
  184. }
  185. const markets = normalizeMarkets(history.markets);
  186. return {
  187. ...history,
  188. markets,
  189. };
  190. }
  191. const getOddsHistoryGames = async ({ page = 1, pageSize = 50, status = -1, keyword } = {}) => {
  192. page = Math.max(1, +page || 1);
  193. pageSize = Math.min(50, Math.max(1, +pageSize || 50));
  194. status = +status;
  195. const now = Date.now();
  196. const query = { eventId: { $gt: 0 } };
  197. if (status === 1) {
  198. query.endTime = { $gte: now };
  199. }
  200. else if (status === 2) {
  201. query.endTime = { $lt: now };
  202. }
  203. if (keyword) {
  204. const pattern = new RegExp(String(keyword).trim(), 'i');
  205. query.$or = [
  206. { leagueName: pattern },
  207. { teamHomeName: pattern },
  208. { teamAwayName: pattern },
  209. ];
  210. if (/^\d+$/.test(String(keyword).trim())) {
  211. query.$or.push({ eventId: +keyword });
  212. }
  213. }
  214. const [histories, total] = await Promise.all([
  215. OddsHistory.find(query)
  216. .select('eventId leagueName teamHomeName teamAwayName startTime endTime markets updatedAt')
  217. .sort({ startTime: -1 })
  218. .skip((page - 1) * pageSize)
  219. .limit(pageSize)
  220. .lean({ flattenMaps: true }),
  221. OddsHistory.countDocuments(query),
  222. ]);
  223. const list = histories.map(history => ({
  224. eventId: history.eventId,
  225. leagueName: history.leagueName,
  226. teamHomeName: history.teamHomeName,
  227. teamAwayName: history.teamAwayName,
  228. startTime: history.startTime,
  229. endTime: history.endTime,
  230. marketCount: Object.keys(normalizeMarkets(history.markets)).length,
  231. updatedAt: history.updatedAt,
  232. }));
  233. return { list, page, pageSize, total };
  234. }
  235. const cleanupExpiredHistory = async () => {
  236. const expireTime = Date.now() - ODDS_HISTORY_RETENTION;
  237. return OddsHistory.deleteMany({ startTime: { $lt: expireTime } });
  238. }
  239. const startCleanup = (logger = console) => {
  240. const cleanup = () => {
  241. cleanupExpiredHistory()
  242. .then(result => {
  243. if (result.deletedCount) {
  244. logger.out?.('odds history cleanup deleted %d records', result.deletedCount);
  245. }
  246. })
  247. .catch(err => {
  248. logger.out?.('odds history cleanup failed: %s', err.message);
  249. });
  250. };
  251. cleanup();
  252. return setInterval(cleanup, ODDS_HISTORY_CLEANUP_INTERVAL);
  253. }
  254. export {
  255. getGameOddsHistory,
  256. getOddsHistoryGames,
  257. recordGameOdds,
  258. startCleanup,
  259. };
  260. export default {
  261. getGameOddsHistory,
  262. getOddsHistoryGames,
  263. recordGameOdds,
  264. startCleanup,
  265. };