Codex commited on
Commit
b87f8d7
·
1 Parent(s): 42d258f

Exclude live games from movement alerts

Browse files
Files changed (3) hide show
  1. src/db.js +9 -1
  2. src/market-scanner.js +36 -0
  3. test/market-scanner.test.js +133 -1
src/db.js CHANGED
@@ -250,6 +250,7 @@ export class BetStore {
250
  source TEXT NOT NULL,
251
  book TEXT NOT NULL,
252
  event_name TEXT,
 
253
  player_name TEXT NOT NULL,
254
  market_type TEXT NOT NULL,
255
  market_label TEXT NOT NULL,
@@ -262,6 +263,10 @@ export class BetStore {
262
  );
263
  `);
264
 
 
 
 
 
265
  await this.pool.query(`
266
  CREATE TABLE IF NOT EXISTS scan_reports (
267
  id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
@@ -733,6 +738,7 @@ export class BetStore {
733
  source,
734
  book,
735
  event_name,
 
736
  player_name,
737
  market_type,
738
  market_label,
@@ -742,7 +748,7 @@ export class BetStore {
742
  implied_probability,
743
  raw_label
744
  )
745
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
746
  `,
747
  [
748
  scanRunId,
@@ -750,6 +756,7 @@ export class BetStore {
750
  entry.source,
751
  entry.book,
752
  entry.eventName,
 
753
  entry.playerName,
754
  entry.marketType,
755
  entry.marketLabel,
@@ -810,6 +817,7 @@ export class BetStore {
810
  source: entry.source,
811
  book: entry.book,
812
  eventName: entry.event_name,
 
813
  playerName: entry.player_name,
814
  marketType: entry.market_type,
815
  marketLabel: entry.market_label,
 
250
  source TEXT NOT NULL,
251
  book TEXT NOT NULL,
252
  event_name TEXT,
253
+ event_commence_time TIMESTAMPTZ,
254
  player_name TEXT NOT NULL,
255
  market_type TEXT NOT NULL,
256
  market_label TEXT NOT NULL,
 
263
  );
264
  `);
265
 
266
+ await this.pool.query(`
267
+ ALTER TABLE scan_markets ADD COLUMN IF NOT EXISTS event_commence_time TIMESTAMPTZ;
268
+ `);
269
+
270
  await this.pool.query(`
271
  CREATE TABLE IF NOT EXISTS scan_reports (
272
  id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
 
738
  source,
739
  book,
740
  event_name,
741
+ event_commence_time,
742
  player_name,
743
  market_type,
744
  market_label,
 
748
  implied_probability,
749
  raw_label
750
  )
751
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
752
  `,
753
  [
754
  scanRunId,
 
756
  entry.source,
757
  entry.book,
758
  entry.eventName,
759
+ entry.eventCommenceTime ?? null,
760
  entry.playerName,
761
  entry.marketType,
762
  entry.marketLabel,
 
817
  source: entry.source,
818
  book: entry.book,
819
  eventName: entry.event_name,
820
+ eventCommenceTime: entry.event_commence_time?.toISOString?.() ?? (entry.event_commence_time ? String(entry.event_commence_time) : null),
821
  playerName: entry.player_name,
822
  marketType: entry.market_type,
823
  marketLabel: entry.market_label,
src/market-scanner.js CHANGED
@@ -527,6 +527,7 @@ export function normalizeOddsApiEntries(payload = []) {
527
  bookKey: String(bookmaker.key || '').trim(),
528
  eventName: `${event.away_team} @ ${event.home_team}`,
529
  eventId: event.id,
 
530
  team: null,
531
  playerName,
532
  playerKey: normalizePlayerName(playerName),
@@ -1136,6 +1137,33 @@ function extractNoisyStrikeoutEntries(text, section) {
1136
  return entries;
1137
  }
1138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1139
  function extractStrikeoutLaneEntriesFromSnippet(text, section) {
1140
  const rawLines = String(text ?? '')
1141
  .replace(/\r\n/g, '\n')
@@ -2210,6 +2238,7 @@ export function analyzeSharpMarkets(entries, config = {}) {
2210
  booksCompared: uniqueEntries.length,
2211
  marketStatus: normalizeBookFilter(sharpEntry.book) === 'Circa' ? 'circa' : 'mgm_fallback',
2212
  eventName: sharpEntry.eventName ?? bestSoftEntry.eventName ?? null,
 
2213
  entries: uniqueEntries,
2214
  softEntries,
2215
  };
@@ -4020,11 +4049,15 @@ export class MarketScanner {
4020
  const currentRun = analysis.scanRunId ? await this.store.getScanRunById(analysis.scanRunId) : null;
4021
  const previousRun = currentRun ? await this.store.getPreviousScanRun(currentRun.id, 'scan') : null;
4022
  if (currentRun && previousRun) {
 
4023
  const currentState = analyzeSharpMarkets(currentRun.entries, this.config).groupedState;
4024
  const previousState = analyzeSharpMarkets(previousRun.entries, this.config).groupedState;
4025
 
4026
  if (this.config.staleBookAlertsEnabled) {
4027
  for (const [comparisonKey, state] of currentState.entries()) {
 
 
 
4028
  const prior = previousState.get(comparisonKey);
4029
  if (!prior) {
4030
  continue;
@@ -4061,6 +4094,9 @@ export class MarketScanner {
4061
 
4062
  if (this.config.reverseAlertsEnabled) {
4063
  for (const [comparisonKey, state] of currentState.entries()) {
 
 
 
4064
  const prior = previousState.get(comparisonKey);
4065
  if (!prior) {
4066
  continue;
 
527
  bookKey: String(bookmaker.key || '').trim(),
528
  eventName: `${event.away_team} @ ${event.home_team}`,
529
  eventId: event.id,
530
+ eventCommenceTime: event.commence_time ?? null,
531
  team: null,
532
  playerName,
533
  playerKey: normalizePlayerName(playerName),
 
1137
  return entries;
1138
  }
1139
 
1140
+ function getEntryCommenceTime(entry) {
1141
+ const rawValue = entry?.eventCommenceTime ?? entry?.eventCommenceAt ?? null;
1142
+ if (!rawValue) {
1143
+ return null;
1144
+ }
1145
+
1146
+ const parsed = rawValue instanceof Date ? rawValue : new Date(rawValue);
1147
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
1148
+ }
1149
+
1150
+ function isPregameEntry(entry, now = new Date()) {
1151
+ if (entry?.source === 'circa') {
1152
+ return true;
1153
+ }
1154
+
1155
+ const commenceTime = getEntryCommenceTime(entry);
1156
+ if (!commenceTime) {
1157
+ return true;
1158
+ }
1159
+
1160
+ return commenceTime.getTime() > now.getTime();
1161
+ }
1162
+
1163
+ function isPregameSharpRow(row, now = new Date()) {
1164
+ return row?.entries?.some((entry) => isPregameEntry(entry, now)) ?? isPregameEntry(row, now);
1165
+ }
1166
+
1167
  function extractStrikeoutLaneEntriesFromSnippet(text, section) {
1168
  const rawLines = String(text ?? '')
1169
  .replace(/\r\n/g, '\n')
 
2238
  booksCompared: uniqueEntries.length,
2239
  marketStatus: normalizeBookFilter(sharpEntry.book) === 'Circa' ? 'circa' : 'mgm_fallback',
2240
  eventName: sharpEntry.eventName ?? bestSoftEntry.eventName ?? null,
2241
+ eventCommenceTime: sharpEntry.eventCommenceTime ?? bestSoftEntry.eventCommenceTime ?? null,
2242
  entries: uniqueEntries,
2243
  softEntries,
2244
  };
 
4049
  const currentRun = analysis.scanRunId ? await this.store.getScanRunById(analysis.scanRunId) : null;
4050
  const previousRun = currentRun ? await this.store.getPreviousScanRun(currentRun.id, 'scan') : null;
4051
  if (currentRun && previousRun) {
4052
+ const now = new Date();
4053
  const currentState = analyzeSharpMarkets(currentRun.entries, this.config).groupedState;
4054
  const previousState = analyzeSharpMarkets(previousRun.entries, this.config).groupedState;
4055
 
4056
  if (this.config.staleBookAlertsEnabled) {
4057
  for (const [comparisonKey, state] of currentState.entries()) {
4058
+ if (!isPregameSharpRow(state.sharpRow, now)) {
4059
+ continue;
4060
+ }
4061
  const prior = previousState.get(comparisonKey);
4062
  if (!prior) {
4063
  continue;
 
4094
 
4095
  if (this.config.reverseAlertsEnabled) {
4096
  for (const [comparisonKey, state] of currentState.entries()) {
4097
+ if (!isPregameSharpRow(state.sharpRow, now)) {
4098
+ continue;
4099
+ }
4100
  const prior = previousState.get(comparisonKey);
4101
  if (!prior) {
4102
  continue;
test/market-scanner.test.js CHANGED
@@ -27,6 +27,7 @@ test('normalizes odds api entries into shared market keys', () => {
27
  id: 'event-1',
28
  away_team: 'Yankees',
29
  home_team: 'Red Sox',
 
30
  bookmakers: [
31
  {
32
  title: 'FanDuel',
@@ -49,6 +50,7 @@ test('normalizes odds api entries into shared market keys', () => {
49
  assert.equal(entries[0].source, 'odds_api');
50
  assert.equal(entries[0].book, 'FanDuel');
51
  assert.equal(entries[0].playerName, 'Aaron Judge');
 
52
  });
53
 
54
  test('normalizes batter home run over outcomes as yes', () => {
@@ -391,7 +393,7 @@ test('fetches odds api entries from event-level endpoints', async () => {
391
  if (asString.includes('/events?')) {
392
  return {
393
  ok: true,
394
- json: async () => [{ id: 'event-1' }],
395
  };
396
  }
397
 
@@ -403,6 +405,7 @@ test('fetches odds api entries from event-level endpoints', async () => {
403
  id: 'event-1',
404
  away_team: 'Yankees',
405
  home_team: 'Red Sox',
 
406
  bookmakers: [
407
  {
408
  key: 'fanduel',
@@ -436,6 +439,7 @@ test('fetches odds api entries from event-level endpoints', async () => {
436
 
437
  assert.equal(entries.length, 1);
438
  assert.equal(entries[0].playerName, 'Aaron Judge');
 
439
  assert.ok(calls.some((url) => url.includes('/events?')));
440
  assert.ok(calls.some((url) => url.includes('/events/event-1/odds')));
441
  assert.ok(calls.some((url) => url.includes('regions=us')));
@@ -445,6 +449,134 @@ test('fetches odds api entries from event-level endpoints', async () => {
445
  }
446
  });
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  test('sharp analysis prefers Circa and falls back to BetMGM when Circa is missing', () => {
449
  const rows = analyzeSharpMarkets([
450
  {
 
27
  id: 'event-1',
28
  away_team: 'Yankees',
29
  home_team: 'Red Sox',
30
+ commence_time: '2026-04-07T23:10:00Z',
31
  bookmakers: [
32
  {
33
  title: 'FanDuel',
 
50
  assert.equal(entries[0].source, 'odds_api');
51
  assert.equal(entries[0].book, 'FanDuel');
52
  assert.equal(entries[0].playerName, 'Aaron Judge');
53
+ assert.equal(entries[0].eventCommenceTime, '2026-04-07T23:10:00Z');
54
  });
55
 
56
  test('normalizes batter home run over outcomes as yes', () => {
 
393
  if (asString.includes('/events?')) {
394
  return {
395
  ok: true,
396
+ json: async () => [{ id: 'event-1', commence_time: '2026-04-07T23:10:00Z' }],
397
  };
398
  }
399
 
 
405
  id: 'event-1',
406
  away_team: 'Yankees',
407
  home_team: 'Red Sox',
408
+ commence_time: '2026-04-07T23:10:00Z',
409
  bookmakers: [
410
  {
411
  key: 'fanduel',
 
439
 
440
  assert.equal(entries.length, 1);
441
  assert.equal(entries[0].playerName, 'Aaron Judge');
442
+ assert.equal(entries[0].eventCommenceTime, '2026-04-07T23:10:00Z');
443
  assert.ok(calls.some((url) => url.includes('/events?')));
444
  assert.ok(calls.some((url) => url.includes('/events/event-1/odds')));
445
  assert.ok(calls.some((url) => url.includes('regions=us')));
 
449
  }
450
  });
451
 
452
+ test('movement alerts skip games that have already started', async () => {
453
+ const sentPayloads = [];
454
+ const latestRun = {
455
+ id: 2,
456
+ entries: [
457
+ {
458
+ marketKey: 'julio|home_runs|yes|0.5',
459
+ source: 'odds_api',
460
+ book: 'Circa',
461
+ eventName: 'Mariners @ Astros',
462
+ eventCommenceTime: '2026-04-07T00:00:00Z',
463
+ playerName: 'Julio Rodriguez',
464
+ team: 'SEA',
465
+ marketType: 'batter_home_runs',
466
+ marketLabel: 'Home Runs',
467
+ side: 'yes',
468
+ lineValue: 0.5,
469
+ oddsInput: '+380',
470
+ impliedProbability: americanToImpliedProbability('+380'),
471
+ },
472
+ {
473
+ marketKey: 'julio|home_runs|yes|0.5',
474
+ source: 'odds_api',
475
+ book: 'FanDuel',
476
+ eventName: 'Mariners @ Astros',
477
+ eventCommenceTime: '2026-04-07T00:00:00Z',
478
+ playerName: 'Julio Rodriguez',
479
+ team: 'SEA',
480
+ marketType: 'batter_home_runs',
481
+ marketLabel: 'Home Runs',
482
+ side: 'yes',
483
+ lineValue: 0.5,
484
+ oddsInput: '+430',
485
+ impliedProbability: americanToImpliedProbability('+430'),
486
+ },
487
+ ],
488
+ };
489
+ const previousRun = {
490
+ id: 1,
491
+ entries: [
492
+ {
493
+ marketKey: 'julio|home_runs|yes|0.5',
494
+ source: 'odds_api',
495
+ book: 'Circa',
496
+ eventName: 'Mariners @ Astros',
497
+ eventCommenceTime: '2026-04-07T00:00:00Z',
498
+ playerName: 'Julio Rodriguez',
499
+ team: 'SEA',
500
+ marketType: 'batter_home_runs',
501
+ marketLabel: 'Home Runs',
502
+ side: 'yes',
503
+ lineValue: 0.5,
504
+ oddsInput: '+380',
505
+ impliedProbability: americanToImpliedProbability('+380'),
506
+ },
507
+ {
508
+ marketKey: 'julio|home_runs|yes|0.5',
509
+ source: 'odds_api',
510
+ book: 'FanDuel',
511
+ eventName: 'Mariners @ Astros',
512
+ eventCommenceTime: '2026-04-07T00:00:00Z',
513
+ playerName: 'Julio Rodriguez',
514
+ team: 'SEA',
515
+ marketType: 'batter_home_runs',
516
+ marketLabel: 'Home Runs',
517
+ side: 'yes',
518
+ lineValue: 0.5,
519
+ oddsInput: '+500',
520
+ impliedProbability: americanToImpliedProbability('+500'),
521
+ },
522
+ ],
523
+ };
524
+ const store = {
525
+ getLatestScanRun: async () => latestRun,
526
+ getScanRunById: async () => latestRun,
527
+ getPreviousScanRun: async () => previousRun,
528
+ canSendScanAlert: async () => true,
529
+ recordScanAlert: async () => {},
530
+ };
531
+
532
+ const scanner = new MarketScanner({
533
+ client: {
534
+ channels: {
535
+ fetch: async () => ({
536
+ isTextBased: () => true,
537
+ send: async (payload) => {
538
+ sentPayloads.push(payload);
539
+ return { id: `message-${sentPayloads.length}` };
540
+ },
541
+ }),
542
+ },
543
+ },
544
+ store,
545
+ embeds: {
546
+ buildCircaAlertEmbed: () => ({ data: { title: 'circa' } }),
547
+ buildSharpAlertEmbed: () => ({ data: { title: 'sharp' } }),
548
+ buildStaleBookAlertEmbed: () => ({ data: { title: 'stale' } }),
549
+ buildReverseAlertEmbed: () => ({ data: { title: 'reverse' } }),
550
+ },
551
+ config: {
552
+ enabled: true,
553
+ oddsWorkflowEnabled: true,
554
+ circaWorkflowEnabled: false,
555
+ scanAlertChannelId: 'alerts',
556
+ staleBookAlertsEnabled: true,
557
+ reverseAlertsEnabled: true,
558
+ sharpEdgeAlertsEnabled: false,
559
+ staleBookAlertThreshold: 0.01,
560
+ reverseAlertThreshold: 0.01,
561
+ scanAlertCooldownMinutes: 0,
562
+ marketBoardDefaultLimit: 10,
563
+ },
564
+ logger: {
565
+ log: () => {},
566
+ error: () => {},
567
+ },
568
+ });
569
+
570
+ scanner.collectMarketAnalysis = async () => ({
571
+ circaAlerts: [],
572
+ edgeRows: [],
573
+ scanRunId: 2,
574
+ });
575
+
576
+ await scanner.runDisagreementScan();
577
+ assert.equal(sentPayloads.length, 0);
578
+ });
579
+
580
  test('sharp analysis prefers Circa and falls back to BetMGM when Circa is missing', () => {
581
  const rows = analyzeSharpMarkets([
582
  {