Codex commited on
Commit ·
b87f8d7
1
Parent(s): 42d258f
Exclude live games from movement alerts
Browse files- src/db.js +9 -1
- src/market-scanner.js +36 -0
- 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 |
{
|