Codex commited on
Commit
5488d4e
·
1 Parent(s): 48b2af6

Add sharp market intelligence commands

Browse files
Files changed (7) hide show
  1. src/commands.js +144 -0
  2. src/config.js +14 -0
  3. src/db.js +89 -0
  4. src/embeds.js +210 -0
  5. src/index.js +153 -0
  6. src/market-scanner.js +562 -2
  7. test/market-scanner.test.js +88 -0
src/commands.js CHANGED
@@ -49,6 +49,96 @@ function addAnalyticsFilters(command, options = {}) {
49
  return command;
50
  }
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  export const commands = [
53
  new SlashCommandBuilder()
54
  .setName('bet')
@@ -355,6 +445,60 @@ export const commands = [
355
  { name: 'Caesars', value: 'Caesars' }
356
  )
357
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  new SlashCommandBuilder()
359
  .setName('alerts')
360
  .setDescription('Post the analyst alert-role embed to the welcome channel.'),
 
49
  return command;
50
  }
51
 
52
+ const MARKET_INTELLIGENCE_BOOK_CHOICES = [
53
+ { name: 'Circa', value: 'Circa' },
54
+ { name: 'FanDuel', value: 'FanDuel' },
55
+ { name: 'DraftKings', value: 'DraftKings' },
56
+ { name: 'BetMGM', value: 'BetMGM' },
57
+ { name: 'Caesars', value: 'Caesars' },
58
+ ];
59
+
60
+ const MARKET_INTELLIGENCE_MARKET_CHOICES = [
61
+ { name: 'Home Runs', value: 'home_runs' },
62
+ { name: 'Hits', value: 'hits' },
63
+ { name: 'Total Bases', value: 'total_bases' },
64
+ { name: 'RBIs', value: 'rbis' },
65
+ { name: 'Runs', value: 'runs' },
66
+ { name: 'Steals', value: 'steals' },
67
+ { name: 'Hits + Runs + RBIs', value: 'hits_runs_rbis' },
68
+ { name: 'Pitcher Strikeouts', value: 'pitcher_strikeouts_generic' },
69
+ ];
70
+
71
+ function addMarketIntelligenceFilters(command, options = {}) {
72
+ if (options.includeMarket !== false) {
73
+ command.addStringOption((option) =>
74
+ option
75
+ .setName('market')
76
+ .setDescription('Optional market filter.')
77
+ .setRequired(options.marketRequired === true)
78
+ .addChoices(...MARKET_INTELLIGENCE_MARKET_CHOICES)
79
+ );
80
+ }
81
+
82
+ if (options.includeBook) {
83
+ command.addStringOption((option) =>
84
+ option
85
+ .setName('book')
86
+ .setDescription('Optional book filter.')
87
+ .setRequired(options.bookRequired === true)
88
+ .addChoices(...MARKET_INTELLIGENCE_BOOK_CHOICES)
89
+ );
90
+ }
91
+
92
+ if (options.includeTeam) {
93
+ command.addStringOption((option) =>
94
+ option
95
+ .setName('team')
96
+ .setDescription('Optional team code filter, for example KCR or CHC.')
97
+ .setRequired(false)
98
+ );
99
+ }
100
+
101
+ if (options.includePlayer) {
102
+ command.addStringOption((option) =>
103
+ option
104
+ .setName('player')
105
+ .setDescription('Optional player filter.')
106
+ .setRequired(options.playerRequired === true)
107
+ );
108
+ }
109
+
110
+ if (options.includeMinEdge) {
111
+ command.addNumberOption((option) =>
112
+ option
113
+ .setName('min_edge')
114
+ .setDescription('Optional minimum sharp edge threshold as a decimal, for example 0.04 for 4%.')
115
+ .setRequired(false)
116
+ );
117
+ }
118
+
119
+ if (options.includeMinWidth) {
120
+ command.addNumberOption((option) =>
121
+ option
122
+ .setName('min_width')
123
+ .setDescription('Optional minimum market width threshold as a decimal, for example 0.05 for 5%.')
124
+ .setRequired(false)
125
+ );
126
+ }
127
+
128
+ if (options.includeLimit !== false) {
129
+ command.addIntegerOption((option) =>
130
+ option
131
+ .setName('limit')
132
+ .setDescription('Optional result limit.')
133
+ .setRequired(false)
134
+ .setMinValue(1)
135
+ .setMaxValue(25)
136
+ );
137
+ }
138
+
139
+ return command;
140
+ }
141
+
142
  export const commands = [
143
  new SlashCommandBuilder()
144
  .setName('bet')
 
445
  { name: 'Caesars', value: 'Caesars' }
446
  )
447
  ),
448
+ addMarketIntelligenceFilters(
449
+ new SlashCommandBuilder()
450
+ .setName('edgeboard')
451
+ .setDescription('Show the best current edges versus the sharp book.'),
452
+ { includeMarket: true, includeBook: true, includeTeam: true, includeMinEdge: true, includeLimit: true }
453
+ ),
454
+ addMarketIntelligenceFilters(
455
+ new SlashCommandBuilder()
456
+ .setName('playeredge')
457
+ .setDescription('Show sharp comparisons across all supported markets for one player.'),
458
+ { includePlayer: true, playerRequired: true, includeMarket: true, includeBook: true, includeMinEdge: true, includeLimit: true }
459
+ ),
460
+ addMarketIntelligenceFilters(
461
+ new SlashCommandBuilder()
462
+ .setName('marketedge')
463
+ .setDescription('Show the best current edges for one market.'),
464
+ { includeMarket: true, marketRequired: true, includeBook: true, includeTeam: true, includeMinEdge: true, includeLimit: true }
465
+ ),
466
+ addMarketIntelligenceFilters(
467
+ new SlashCommandBuilder()
468
+ .setName('widthboard')
469
+ .setDescription('Show the widest current market splits across books.'),
470
+ { includeMarket: true, includeBook: true, includeTeam: true, includeMinWidth: true, includeLimit: true }
471
+ ),
472
+ addMarketIntelligenceFilters(
473
+ new SlashCommandBuilder()
474
+ .setName('consensusvs')
475
+ .setDescription('Compare one book to the current sharp market.'),
476
+ { includeMarket: true, includeBook: true, bookRequired: true, includeTeam: true, includeMinEdge: true, includeLimit: true }
477
+ ),
478
+ addMarketIntelligenceFilters(
479
+ new SlashCommandBuilder()
480
+ .setName('steam')
481
+ .setDescription('Show recent price movement anchored to the current sharp market.'),
482
+ { includeMarket: true, includeBook: true, includeTeam: true, includePlayer: true, includeLimit: true }
483
+ ),
484
+ addMarketIntelligenceFilters(
485
+ new SlashCommandBuilder()
486
+ .setName('sharpboard')
487
+ .setDescription('Show the best current sharp-vs-soft values.'),
488
+ { includeMarket: true, includeBook: true, includeTeam: true, includeMinEdge: true, includeLimit: true }
489
+ ),
490
+ addMarketIntelligenceFilters(
491
+ new SlashCommandBuilder()
492
+ .setName('bookscoreboard')
493
+ .setDescription('Rank books by sharp mispricing count and size.'),
494
+ { includeMarket: true, includeBook: true, includeTeam: true, includeMinEdge: true, includeLimit: true }
495
+ ),
496
+ addMarketIntelligenceFilters(
497
+ new SlashCommandBuilder()
498
+ .setName('markethealth')
499
+ .setDescription('Show coverage and market quality by supported market.'),
500
+ { includeMarket: true, includeLimit: true }
501
+ ),
502
  new SlashCommandBuilder()
503
  .setName('alerts')
504
  .setDescription('Post the analyst alert-role embed to the welcome channel.'),
src/config.js CHANGED
@@ -30,6 +30,13 @@ export function getConfig() {
30
  const scanFrequencyMinutes = Number(process.env.SCAN_FREQUENCY_MINUTES || 15);
31
  const scanHttpTimeoutMs = Number(process.env.SCAN_HTTP_TIMEOUT_MS || 15000);
32
  const directOddsCacheTtlMs = Number(process.env.DIRECT_ODDS_CACHE_TTL_MS || 60000);
 
 
 
 
 
 
 
33
  const circaChannelId = process.env.CIRCA_CHANNEL_ID?.trim() || null;
34
  const circaDailyTime = process.env.CIRCA_DAILY_TIME?.trim() || '09:30';
35
  const circaTimeZone = process.env.CIRCA_TIMEZONE?.trim() || 'America/Chicago';
@@ -83,6 +90,13 @@ export function getConfig() {
83
  scanFrequencyMinutes,
84
  scanHttpTimeoutMs,
85
  directOddsCacheTtlMs,
 
 
 
 
 
 
 
86
  circaChannelId,
87
  circaDailyTime,
88
  circaTimeZone,
 
30
  const scanFrequencyMinutes = Number(process.env.SCAN_FREQUENCY_MINUTES || 15);
31
  const scanHttpTimeoutMs = Number(process.env.SCAN_HTTP_TIMEOUT_MS || 15000);
32
  const directOddsCacheTtlMs = Number(process.env.DIRECT_ODDS_CACHE_TTL_MS || 60000);
33
+ const sharpEdgeAlertThreshold = Number(process.env.SHARP_EDGE_ALERT_THRESHOLD || 0.04);
34
+ const staleBookAlertThreshold = Number(process.env.STALE_BOOK_ALERT_THRESHOLD || 0.025);
35
+ const reverseAlertThreshold = Number(process.env.REVERSE_ALERT_THRESHOLD || 0.03);
36
+ const marketBoardDefaultLimit = Number(process.env.MARKET_BOARD_DEFAULT_LIMIT || 10);
37
+ const sharpEdgeAlertsEnabled = process.env.SHARP_EDGE_ALERTS_ENABLED?.trim() !== 'false';
38
+ const staleBookAlertsEnabled = process.env.STALE_BOOK_ALERTS_ENABLED?.trim() !== 'false';
39
+ const reverseAlertsEnabled = process.env.REVERSE_ALERTS_ENABLED?.trim() !== 'false';
40
  const circaChannelId = process.env.CIRCA_CHANNEL_ID?.trim() || null;
41
  const circaDailyTime = process.env.CIRCA_DAILY_TIME?.trim() || '09:30';
42
  const circaTimeZone = process.env.CIRCA_TIMEZONE?.trim() || 'America/Chicago';
 
90
  scanFrequencyMinutes,
91
  scanHttpTimeoutMs,
92
  directOddsCacheTtlMs,
93
+ sharpEdgeAlertThreshold,
94
+ staleBookAlertThreshold,
95
+ reverseAlertThreshold,
96
+ marketBoardDefaultLimit,
97
+ sharpEdgeAlertsEnabled,
98
+ staleBookAlertsEnabled,
99
+ reverseAlertsEnabled,
100
  circaChannelId,
101
  circaDailyTime,
102
  circaTimeZone,
src/db.js CHANGED
@@ -772,6 +772,95 @@ export class BetStore {
772
  }
773
  }
774
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
775
  async hasScanReport(reportDate, reportType) {
776
  const { rows } = await this.pool.query(
777
  `
 
772
  }
773
  }
774
 
775
+ async getScanRunById(scanRunId) {
776
+ const { rows } = await this.pool.query(
777
+ `
778
+ SELECT *
779
+ FROM scan_runs
780
+ WHERE id = $1
781
+ LIMIT 1
782
+ `,
783
+ [scanRunId]
784
+ );
785
+
786
+ if (!rows[0]) {
787
+ return null;
788
+ }
789
+
790
+ const run = rows[0];
791
+ const entriesResult = await this.pool.query(
792
+ `
793
+ SELECT *
794
+ FROM scan_markets
795
+ WHERE scan_run_id = $1
796
+ ORDER BY player_name, market_type, side, line_value NULLS FIRST, book
797
+ `,
798
+ [scanRunId]
799
+ );
800
+
801
+ return {
802
+ id: Number(run.id),
803
+ scanType: run.scan_type,
804
+ status: run.status,
805
+ entryCount: Number(run.entry_count ?? 0),
806
+ createdAt: run.created_at?.toISOString?.() ?? String(run.created_at),
807
+ errorText: run.error_text ?? null,
808
+ entries: entriesResult.rows.map((entry) => ({
809
+ marketKey: entry.market_key,
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,
816
+ side: entry.side,
817
+ lineValue: numberOrNull(entry.line_value),
818
+ oddsInput: entry.odds_input,
819
+ impliedProbability: Number(entry.implied_probability),
820
+ rawLabel: entry.raw_label ?? null,
821
+ })),
822
+ };
823
+ }
824
+
825
+ async getLatestScanRun(scanType = null) {
826
+ const { rows } = await this.pool.query(
827
+ `
828
+ SELECT id
829
+ FROM scan_runs
830
+ ${scanType ? 'WHERE scan_type = $1' : ''}
831
+ ORDER BY created_at DESC, id DESC
832
+ LIMIT 1
833
+ `,
834
+ scanType ? [scanType] : []
835
+ );
836
+
837
+ if (!rows[0]) {
838
+ return null;
839
+ }
840
+
841
+ return this.getScanRunById(Number(rows[0].id));
842
+ }
843
+
844
+ async getPreviousScanRun(scanRunId, scanType = null) {
845
+ const { rows } = await this.pool.query(
846
+ `
847
+ SELECT id
848
+ FROM scan_runs
849
+ WHERE id < $1
850
+ ${scanType ? 'AND scan_type = $2' : ''}
851
+ ORDER BY id DESC
852
+ LIMIT 1
853
+ `,
854
+ scanType ? [scanRunId, scanType] : [scanRunId]
855
+ );
856
+
857
+ if (!rows[0]) {
858
+ return null;
859
+ }
860
+
861
+ return this.getScanRunById(Number(rows[0].id));
862
+ }
863
+
864
  async hasScanReport(reportDate, reportType) {
865
  const { rows } = await this.pool.query(
866
  `
src/embeds.js CHANGED
@@ -307,6 +307,7 @@ export function buildCommandsEmbed() {
307
  { name: '/oddsquota', value: 'Show live Odds API quota usage headers. Admin only.' },
308
  { name: '/scanrun', value: 'Run the market scanner manually. Admin only.' },
309
  { name: '/scanreport', value: 'Post the morning scan reports immediately. Admin only.' },
 
310
  { name: '/hrodds', value: 'Show live Home Run odds for one player across Caesars, BetMGM, and Circa, or filter to one book.' },
311
  { name: 'Other Odds Commands', value: '`/hitodds`, `/tbodds`, `/rbiodds`, `/runodds`, `/sbodds`, `/kodds` show live player odds across books.' },
312
  { name: '/circatest', value: 'Run a Circa OCR diagnostic preview. Admin only.' },
@@ -524,6 +525,200 @@ export function buildMarketTopEmbed(title, rows, options = {}) {
524
  return embed;
525
  }
526
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  export function buildCircaAlertEmbed(alert) {
528
  return new EmbedBuilder()
529
  .setColor(PALETTE.red)
@@ -811,18 +1006,33 @@ function truncate(value, maxLength) {
811
 
812
  function buildFilterBanner(filters, baseText) {
813
  const labels = [];
 
 
 
814
  if (filters.dateWindow && filters.dateWindow !== 'all') {
815
  labels.push(filters.dateWindow.toUpperCase());
816
  }
817
  if (filters.book) {
818
  labels.push(filters.book);
819
  }
 
 
 
 
 
 
820
  if (filters.sport) {
821
  labels.push(filters.sport);
822
  }
823
  if (filters.status) {
824
  labels.push(filters.status.toUpperCase());
825
  }
 
 
 
 
 
 
826
 
827
  if (labels.length === 0) {
828
  return baseText;
 
307
  { name: '/oddsquota', value: 'Show live Odds API quota usage headers. Admin only.' },
308
  { name: '/scanrun', value: 'Run the market scanner manually. Admin only.' },
309
  { name: '/scanreport', value: 'Post the morning scan reports immediately. Admin only.' },
310
+ { name: 'Sharp Market Commands', value: '`/edgeboard`, `/playeredge`, `/marketedge`, `/widthboard`, `/consensusvs`, `/steam`, `/sharpboard`, `/bookscoreboard`, `/markethealth` surface Circa-first market value and quality views.' },
311
  { name: '/hrodds', value: 'Show live Home Run odds for one player across Caesars, BetMGM, and Circa, or filter to one book.' },
312
  { name: 'Other Odds Commands', value: '`/hitodds`, `/tbodds`, `/rbiodds`, `/runodds`, `/sbodds`, `/kodds` show live player odds across books.' },
313
  { name: '/circatest', value: 'Run a Circa OCR diagnostic preview. Admin only.' },
 
525
  return embed;
526
  }
527
 
528
+ export function buildSharpEdgeBoardEmbed(title, rows, filters = {}) {
529
+ const embed = new EmbedBuilder()
530
+ .setColor(PALETTE.primary)
531
+ .setTitle(title)
532
+ .setDescription(buildFilterBanner(filters, 'Best current soft-book prices versus the sharp market.'));
533
+
534
+ if (!rows.length) {
535
+ return embed.addFields({ name: 'No rows', value: 'No sharp-comparison rows matched the current filters.' });
536
+ }
537
+
538
+ embed.addFields(
539
+ rows.map((row, index) => ({
540
+ name: `${index + 1}. ${row.playerName} - ${row.marketLabel}`,
541
+ value: [
542
+ `Sharp: ${row.sharpBook} @ ${row.sharpOddsInput} | Best Soft: ${row.bestSoftBook} @ ${row.bestSoftOddsInput}`,
543
+ `Edge: ${formatPercent(row.edgeImpliedPct * 100)} | Width: ${formatPercent(row.marketWidthPct * 100)}`,
544
+ `Books: ${row.booksCompared} | Status: ${row.sharpSourceMode === 'circa' ? 'Circa' : 'BetMGM fallback'}`,
545
+ ].join('\n'),
546
+ inline: false,
547
+ }))
548
+ );
549
+
550
+ return embed;
551
+ }
552
+
553
+ export function buildPlayerEdgeEmbed({ playerName, rows }, filters = {}) {
554
+ const embed = new EmbedBuilder()
555
+ .setColor(PALETTE.primary)
556
+ .setTitle('Player Edge')
557
+ .setDescription(buildFilterBanner(filters, `Current sharp comparisons for **${playerName}**.`));
558
+
559
+ if (!rows.length) {
560
+ return embed.addFields({ name: 'No rows', value: 'No sharp comparisons were found for that player.' });
561
+ }
562
+
563
+ embed.addFields(
564
+ rows.map((row) => ({
565
+ name: row.marketLabel,
566
+ value: [
567
+ `Sharp: ${row.sharpBook} @ ${row.sharpOddsInput} | Best Soft: ${row.bestSoftBook} @ ${row.bestSoftOddsInput}`,
568
+ `Edge: ${formatPercent(row.edgeImpliedPct * 100)} | Width: ${formatPercent(row.marketWidthPct * 100)}`,
569
+ ].join('\n'),
570
+ inline: false,
571
+ }))
572
+ );
573
+
574
+ return embed;
575
+ }
576
+
577
+ export function buildConsensusVsEmbed({ summary, rows }, filters = {}) {
578
+ const embed = new EmbedBuilder()
579
+ .setColor(PALETTE.secondary)
580
+ .setTitle(`Consensus Vs ${summary.book}`)
581
+ .setDescription(buildFilterBanner(filters, 'How this book compares to the current sharp market.'));
582
+
583
+ embed.addFields(
584
+ { name: 'Compared Rows', value: String(summary.comparedRows), inline: true },
585
+ { name: 'Positive Edges', value: String(summary.betterThanSharpCount), inline: true },
586
+ { name: 'Average Edge', value: formatPercent(summary.averageEdgePct * 100), inline: true },
587
+ );
588
+
589
+ if (!rows.length) {
590
+ return embed.addFields({ name: 'No rows', value: 'No sharp comparison rows matched this book and filter set.' });
591
+ }
592
+
593
+ embed.addFields(
594
+ rows.slice(0, 10).map((row, index) => ({
595
+ name: `${index + 1}. ${row.playerName} - ${row.marketLabel}`,
596
+ value: [
597
+ `Sharp: ${row.sharpBook} @ ${row.sharpOddsInput} | ${summary.book}: ${row.compareOddsInput}`,
598
+ `Edge: ${formatPercent(row.compareEdgePct * 100)} | Width: ${formatPercent(row.marketWidthPct * 100)}`,
599
+ ].join('\n'),
600
+ inline: false,
601
+ }))
602
+ );
603
+
604
+ return embed;
605
+ }
606
+
607
+ export function buildBookScoreboardEmbed(title, rows, filters = {}) {
608
+ const embed = new EmbedBuilder()
609
+ .setColor(PALETTE.primary)
610
+ .setTitle(title)
611
+ .setDescription(buildFilterBanner(filters, 'Books ranked by how often and how far they differ from sharp.'));
612
+
613
+ if (!rows.length) {
614
+ return embed.addFields({ name: 'No rows', value: 'No book comparison rows matched the current filters.' });
615
+ }
616
+
617
+ embed.addFields(
618
+ rows.map((row, index) => ({
619
+ name: `${index + 1}. ${row.book}`,
620
+ value: [
621
+ `Positive Edges: ${row.positiveEdges} / ${row.comparedRows}`,
622
+ `Avg Edge: ${formatPercent(row.averageEdgePct * 100)} | Max Edge: ${formatPercent(row.maxEdgePct * 100)}`,
623
+ `Avg Width: ${formatPercent(row.averageWidthPct * 100)}`,
624
+ ].join('\n'),
625
+ inline: false,
626
+ }))
627
+ );
628
+
629
+ return embed;
630
+ }
631
+
632
+ export function buildMarketHealthEmbed(title, rows, filters = {}) {
633
+ const embed = new EmbedBuilder()
634
+ .setColor(PALETTE.secondary)
635
+ .setTitle(title)
636
+ .setDescription(buildFilterBanner(filters, 'Coverage and quality snapshot across supported sharp markets.'));
637
+
638
+ if (!rows.length) {
639
+ return embed.addFields({ name: 'No rows', value: 'No market health rows matched the current filters.' });
640
+ }
641
+
642
+ embed.addFields(
643
+ rows.map((row, index) => ({
644
+ name: `${index + 1}. ${row.marketLabel}`,
645
+ value: [
646
+ `Rows: ${row.rows} | Avg Books: ${row.averageBooksCompared.toFixed(2)}`,
647
+ `Avg Width: ${formatPercent(row.averageWidthPct * 100)}`,
648
+ `Sharp Source: Circa ${row.circaRows} | MGM fallback ${row.mgmFallbackRows}`,
649
+ ].join('\n'),
650
+ inline: false,
651
+ }))
652
+ );
653
+
654
+ return embed;
655
+ }
656
+
657
+ export function buildSteamEmbed(title, rows, filters = {}) {
658
+ const embed = new EmbedBuilder()
659
+ .setColor(PALETTE.info)
660
+ .setTitle(title)
661
+ .setDescription(buildFilterBanner(filters, 'Recent movement compared to the prior stored scan snapshot.'));
662
+
663
+ if (!rows.length) {
664
+ return embed.addFields({ name: 'No rows', value: 'No tracked price movement matched the current filters.' });
665
+ }
666
+
667
+ embed.addFields(
668
+ rows.map((row, index) => ({
669
+ name: `${index + 1}. ${row.playerName} - ${row.marketLabel}`,
670
+ value: [
671
+ `Moved Book: ${row.movedBook} | ${row.oldOddsInput} -> ${row.newOddsInput}`,
672
+ `Sharp: ${row.sharpBook} @ ${row.sharpOddsInput} | Delta: ${formatPercent(row.impliedDeltaPct * 100)}`,
673
+ ].join('\n'),
674
+ inline: false,
675
+ }))
676
+ );
677
+
678
+ return embed;
679
+ }
680
+
681
+ export function buildSharpAlertEmbed(row) {
682
+ return new EmbedBuilder()
683
+ .setColor(PALETTE.red)
684
+ .setTitle('Sharp Edge Alert')
685
+ .setDescription(`${row.playerName} - ${row.marketLabel}`)
686
+ .addFields(
687
+ { name: 'Sharp', value: `${row.sharpBook} @ ${row.sharpOddsInput}`, inline: true },
688
+ { name: 'Best Soft', value: `${row.bestSoftBook} @ ${row.bestSoftOddsInput}`, inline: true },
689
+ { name: 'Edge', value: formatPercent(row.edgeImpliedPct * 100), inline: true },
690
+ { name: 'Width', value: formatPercent(row.marketWidthPct * 100), inline: true },
691
+ { name: 'Books Compared', value: String(row.booksCompared), inline: true },
692
+ { name: 'Sharp Source', value: row.sharpSourceMode === 'circa' ? 'Circa' : 'BetMGM fallback', inline: true },
693
+ );
694
+ }
695
+
696
+ export function buildStaleBookAlertEmbed(row, staleEntry) {
697
+ return new EmbedBuilder()
698
+ .setColor(PALETTE.red)
699
+ .setTitle('Stale Book Alert')
700
+ .setDescription(`${row.playerName} - ${row.marketLabel}`)
701
+ .addFields(
702
+ { name: 'Sharp', value: `${row.sharpBook} @ ${row.sharpOddsInput}`, inline: true },
703
+ { name: 'Stale Book', value: `${staleEntry.book} @ ${staleEntry.oddsInput}`, inline: true },
704
+ { name: 'Current Edge', value: formatPercent(((row.sharpImpliedProbability ?? 0) - (staleEntry.impliedProbability ?? 0)) * 100), inline: true },
705
+ )
706
+ .setFooter({ text: 'Sharp and at least one other book moved, but this book did not.' });
707
+ }
708
+
709
+ export function buildReverseAlertEmbed(row, movement) {
710
+ return new EmbedBuilder()
711
+ .setColor(PALETTE.muted)
712
+ .setTitle('Reverse Alert')
713
+ .setDescription(`${row.playerName} - ${row.marketLabel}`)
714
+ .addFields(
715
+ { name: 'Sharp', value: `${row.sharpBook} @ ${row.sharpOddsInput}`, inline: true },
716
+ { name: 'Soft Move', value: `${movement.book} ${movement.previousEntry.oddsInput} -> ${movement.currentEntry.oddsInput}`, inline: true },
717
+ { name: 'Move Size', value: formatPercent(movement.impliedDeltaPct * 100), inline: true },
718
+ )
719
+ .setFooter({ text: 'A soft book moved materially while the sharp book stayed flat.' });
720
+ }
721
+
722
  export function buildCircaAlertEmbed(alert) {
723
  return new EmbedBuilder()
724
  .setColor(PALETTE.red)
 
1006
 
1007
  function buildFilterBanner(filters, baseText) {
1008
  const labels = [];
1009
+ if (filters.market) {
1010
+ labels.push(filters.market);
1011
+ }
1012
  if (filters.dateWindow && filters.dateWindow !== 'all') {
1013
  labels.push(filters.dateWindow.toUpperCase());
1014
  }
1015
  if (filters.book) {
1016
  labels.push(filters.book);
1017
  }
1018
+ if (filters.team) {
1019
+ labels.push(filters.team.toUpperCase());
1020
+ }
1021
+ if (filters.player) {
1022
+ labels.push(filters.player);
1023
+ }
1024
  if (filters.sport) {
1025
  labels.push(filters.sport);
1026
  }
1027
  if (filters.status) {
1028
  labels.push(filters.status.toUpperCase());
1029
  }
1030
+ if (filters.minEdge !== undefined && filters.minEdge !== null) {
1031
+ labels.push(`MIN EDGE ${Number(filters.minEdge)}`);
1032
+ }
1033
+ if (filters.minWidth !== undefined && filters.minWidth !== null) {
1034
+ labels.push(`MIN WIDTH ${Number(filters.minWidth)}`);
1035
+ }
1036
 
1037
  if (labels.length === 0) {
1038
  return baseText;
src/index.js CHANGED
@@ -38,6 +38,15 @@ import {
38
  buildCircaFailureEmbed,
39
  buildCircaMarketEmbeds,
40
  buildCircaMovementEmbed,
 
 
 
 
 
 
 
 
 
41
  buildPlayerOddsEmbed,
42
  buildOddsApiQuotaEmbed,
43
  buildCircaPaginationRow,
@@ -95,6 +104,12 @@ async function main() {
95
  buildCircaFailureEmbed,
96
  buildCircaMarketEmbeds,
97
  buildCircaMovementEmbed,
 
 
 
 
 
 
98
  },
99
  logger: console,
100
  });
@@ -372,6 +387,51 @@ async function handleChatInput(interaction, store, config) {
372
  return;
373
  }
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  if (commandName === 'alerts') {
376
  await handleAlerts(interaction);
377
  return;
@@ -391,6 +451,18 @@ function getAnalyticsFilters(interaction) {
391
  };
392
  }
393
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  async function showBetModal(interaction) {
395
  const selectedBook = interaction.options.getString('book', true);
396
  const selectedSport = interaction.options.getString('sport', true);
@@ -1119,6 +1191,87 @@ async function handlePlayerOdds(interaction, config, marketType) {
1119
  }
1120
  }
1121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1122
  async function handleButton(interaction, store) {
1123
  const alertRoleName = parseAlertRoleButtonId(interaction.customId);
1124
  if (alertRoleName) {
 
38
  buildCircaFailureEmbed,
39
  buildCircaMarketEmbeds,
40
  buildCircaMovementEmbed,
41
+ buildSharpAlertEmbed,
42
+ buildSharpEdgeBoardEmbed,
43
+ buildBookScoreboardEmbed,
44
+ buildMarketHealthEmbed,
45
+ buildPlayerEdgeEmbed,
46
+ buildConsensusVsEmbed,
47
+ buildSteamEmbed,
48
+ buildStaleBookAlertEmbed,
49
+ buildReverseAlertEmbed,
50
  buildPlayerOddsEmbed,
51
  buildOddsApiQuotaEmbed,
52
  buildCircaPaginationRow,
 
104
  buildCircaFailureEmbed,
105
  buildCircaMarketEmbeds,
106
  buildCircaMovementEmbed,
107
+ buildSharpAlertEmbed,
108
+ buildSharpEdgeBoardEmbed,
109
+ buildBookScoreboardEmbed,
110
+ buildMarketHealthEmbed,
111
+ buildStaleBookAlertEmbed,
112
+ buildReverseAlertEmbed,
113
  },
114
  logger: console,
115
  });
 
387
  return;
388
  }
389
 
390
+ if (commandName === 'edgeboard') {
391
+ await handleSharpBoard(interaction, config, 'edgeboard');
392
+ return;
393
+ }
394
+
395
+ if (commandName === 'playeredge') {
396
+ await handleSharpBoard(interaction, config, 'playeredge');
397
+ return;
398
+ }
399
+
400
+ if (commandName === 'marketedge') {
401
+ await handleSharpBoard(interaction, config, 'marketedge');
402
+ return;
403
+ }
404
+
405
+ if (commandName === 'widthboard') {
406
+ await handleSharpBoard(interaction, config, 'widthboard');
407
+ return;
408
+ }
409
+
410
+ if (commandName === 'consensusvs') {
411
+ await handleSharpBoard(interaction, config, 'consensusvs');
412
+ return;
413
+ }
414
+
415
+ if (commandName === 'steam') {
416
+ await handleSharpBoard(interaction, config, 'steam');
417
+ return;
418
+ }
419
+
420
+ if (commandName === 'sharpboard') {
421
+ await handleSharpBoard(interaction, config, 'sharpboard');
422
+ return;
423
+ }
424
+
425
+ if (commandName === 'bookscoreboard') {
426
+ await handleSharpBoard(interaction, config, 'bookscoreboard');
427
+ return;
428
+ }
429
+
430
+ if (commandName === 'markethealth') {
431
+ await handleSharpBoard(interaction, config, 'markethealth');
432
+ return;
433
+ }
434
+
435
  if (commandName === 'alerts') {
436
  await handleAlerts(interaction);
437
  return;
 
451
  };
452
  }
453
 
454
+ function getMarketIntelligenceFilters(interaction) {
455
+ return {
456
+ market: interaction.options.getString('market') ?? undefined,
457
+ book: interaction.options.getString('book') ?? undefined,
458
+ team: interaction.options.getString('team') ?? undefined,
459
+ player: interaction.options.getString('player') ?? undefined,
460
+ minEdge: interaction.options.getNumber('min_edge') ?? undefined,
461
+ minWidth: interaction.options.getNumber('min_width') ?? undefined,
462
+ limit: interaction.options.getInteger('limit') ?? undefined,
463
+ };
464
+ }
465
+
466
  async function showBetModal(interaction) {
467
  const selectedBook = interaction.options.getString('book', true);
468
  const selectedSport = interaction.options.getString('sport', true);
 
1191
  }
1192
  }
1193
 
1194
+ async function handleSharpBoard(interaction, config, commandName) {
1195
+ await interaction.deferReply();
1196
+
1197
+ const scanner = interaction.client.__marketScanner;
1198
+ if (!scanner?.getStatus().oddsWorkflowEnabled) {
1199
+ await interaction.editReply({
1200
+ embeds: [buildErrorEmbed('Sharp lookup disabled', 'Set the Odds API environment variables before using these market-awareness commands.')],
1201
+ });
1202
+ return;
1203
+ }
1204
+
1205
+ const filters = getMarketIntelligenceFilters(interaction);
1206
+
1207
+ try {
1208
+ if (commandName === 'edgeboard') {
1209
+ const result = await scanner.getEdgeBoard(filters);
1210
+ await interaction.editReply({ embeds: [buildSharpEdgeBoardEmbed('Edge Board', result.rows, filters)] });
1211
+ return;
1212
+ }
1213
+
1214
+ if (commandName === 'sharpboard') {
1215
+ const result = await scanner.getSharpBoard(filters);
1216
+ await interaction.editReply({ embeds: [buildSharpEdgeBoardEmbed('Sharp Board', result.rows, filters)] });
1217
+ return;
1218
+ }
1219
+
1220
+ if (commandName === 'marketedge') {
1221
+ const market = interaction.options.getString('market', true);
1222
+ const result = await scanner.getMarketEdge(market, filters);
1223
+ await interaction.editReply({ embeds: [buildSharpEdgeBoardEmbed('Market Edge', result.rows, filters)] });
1224
+ return;
1225
+ }
1226
+
1227
+ if (commandName === 'widthboard') {
1228
+ const result = await scanner.getWidthBoard(filters);
1229
+ await interaction.editReply({ embeds: [buildSharpEdgeBoardEmbed('Width Board', result.rows, filters)] });
1230
+ return;
1231
+ }
1232
+
1233
+ if (commandName === 'playeredge') {
1234
+ const player = interaction.options.getString('player', true);
1235
+ const result = await scanner.getPlayerEdge(player, filters);
1236
+ await interaction.editReply({ embeds: [buildPlayerEdgeEmbed({ playerName: player, rows: result.rows }, filters)] });
1237
+ return;
1238
+ }
1239
+
1240
+ if (commandName === 'consensusvs') {
1241
+ const book = interaction.options.getString('book', true);
1242
+ const result = await scanner.getConsensusVsBook(book, filters);
1243
+ await interaction.editReply({ embeds: [buildConsensusVsEmbed(result, filters)] });
1244
+ return;
1245
+ }
1246
+
1247
+ if (commandName === 'bookscoreboard') {
1248
+ const result = await scanner.getBookScoreboard(filters);
1249
+ await interaction.editReply({ embeds: [buildBookScoreboardEmbed('Book Scoreboard', result.rows, filters)] });
1250
+ return;
1251
+ }
1252
+
1253
+ if (commandName === 'markethealth') {
1254
+ const result = await scanner.getMarketHealth(filters);
1255
+ await interaction.editReply({ embeds: [buildMarketHealthEmbed('Market Health', result.rows, filters)] });
1256
+ return;
1257
+ }
1258
+
1259
+ if (commandName === 'steam') {
1260
+ const result = await scanner.getSteamBoard(filters);
1261
+ await interaction.editReply({ embeds: [buildSteamEmbed('Steam', result.rows, filters)] });
1262
+ return;
1263
+ }
1264
+
1265
+ await interaction.editReply({
1266
+ embeds: [buildErrorEmbed('Command unavailable', 'That sharp market command is not wired up yet.')],
1267
+ });
1268
+ } catch (error) {
1269
+ await interaction.editReply({
1270
+ embeds: [buildErrorEmbed('Sharp view unavailable', error.message || 'The sharp market lookup hit an unexpected error.')],
1271
+ });
1272
+ }
1273
+ }
1274
+
1275
  async function handleButton(interaction, store) {
1276
  const alertRoleName = parseAlertRoleButtonId(interaction.customId);
1277
  if (alertRoleName) {
src/market-scanner.js CHANGED
@@ -2084,6 +2084,310 @@ export function analyzeMarkets(entries, config = {}) {
2084
  };
2085
  }
2086
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2087
  function normalizeCircaFilename(name) {
2088
  return normalizeWhitespace(String(name ?? ''))
2089
  .replace(/[\u200B-\u200D\uFEFF]/g, '')
@@ -3461,6 +3765,155 @@ export class MarketScanner {
3461
  };
3462
  }
3463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3464
  async maybeRunMorningReport(now = new Date()) {
3465
  if (!this.config.enabled) {
3466
  return null;
@@ -3503,6 +3956,15 @@ export class MarketScanner {
3503
  secondaryValue: (row) => `${row.bestOddsInput} -> ${row.worstOddsInput}`,
3504
  })],
3505
  });
 
 
 
 
 
 
 
 
 
3506
 
3507
  await this.store.recordScanReport(dateKey, 'morning', this.config.scanReportChannelId, discrepancyMessage.id, widthMessage.id);
3508
  this.status.lastReportAt = new Date().toISOString();
@@ -3537,6 +3999,98 @@ export class MarketScanner {
3537
  sentCount += 1;
3538
  }
3539
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3540
  this.status.lastAlertCount = sentCount;
3541
  return analysis;
3542
  }
@@ -3874,8 +4428,9 @@ export class MarketScanner {
3874
  minBooks: this.config.scanMinBooks,
3875
  disagreementThreshold: this.config.scanDisagreementThreshold,
3876
  });
 
3877
 
3878
- await this.store.recordMarketSnapshot(scanType, allEntries);
3879
  this.status.lastScanAt = new Date().toISOString();
3880
  this.status.lastScanError = null;
3881
  this.status.lastApiEntries = oddsEntries.length;
@@ -3883,7 +4438,12 @@ export class MarketScanner {
3883
  this.status.lastCircaFileName = circaResult.fileName ?? null;
3884
  this.status.lastCircaFingerprintAt = circaResult.seenAt ?? new Date().toISOString();
3885
 
3886
- return analysis;
 
 
 
 
 
3887
  } catch (error) {
3888
  this.status.lastScanError = error.message;
3889
  if (String(error.message ?? '').toLowerCase().includes('pdf') || String(error.message ?? '').toLowerCase().includes('ocr') || String(error.message ?? '').toLowerCase().includes('circa')) {
 
2084
  };
2085
  }
2086
 
2087
+ function normalizeSharpMarketType(marketType) {
2088
+ return normalizeCircaMarketType(marketType);
2089
+ }
2090
+
2091
+ function buildSharpComparisonKey(entry) {
2092
+ return [
2093
+ normalizePlayerName(entry.playerName),
2094
+ normalizeSharpMarketType(entry.marketType),
2095
+ entry.side || 'yes',
2096
+ entry.lineValue ?? 'na',
2097
+ ].join('|');
2098
+ }
2099
+
2100
+ function collapseEntriesByBook(entries = []) {
2101
+ const byBook = new Map();
2102
+ for (const entry of entries) {
2103
+ const book = normalizeBookFilter(entry.book) ?? entry.book ?? 'Unknown book';
2104
+ const current = byBook.get(book);
2105
+ if (!current || (entry.impliedProbability ?? Number.MAX_SAFE_INTEGER) < (current.impliedProbability ?? Number.MAX_SAFE_INTEGER)) {
2106
+ byBook.set(book, {
2107
+ ...entry,
2108
+ book,
2109
+ });
2110
+ }
2111
+ }
2112
+ return [...byBook.values()];
2113
+ }
2114
+
2115
+ function filterSharpRows(rows = [], filters = {}) {
2116
+ const normalizedBook = filters.book ? normalizeBookFilter(filters.book) : null;
2117
+ const normalizedMarket = filters.market ? normalizeSharpMarketType(filters.market) : null;
2118
+ const normalizedTeam = filters.team ? normalizeWhitespace(filters.team).toUpperCase() : null;
2119
+ const normalizedPlayer = filters.player ? normalizePlayerName(filters.player) : null;
2120
+ const minEdge = filters.minEdge !== undefined && filters.minEdge !== null ? Number(filters.minEdge) : null;
2121
+ const minWidth = filters.minWidth !== undefined && filters.minWidth !== null ? Number(filters.minWidth) : null;
2122
+
2123
+ return rows.filter((row) => {
2124
+ if (normalizedBook && normalizeBookFilter(row.bestSoftBook) !== normalizedBook && normalizeBookFilter(row.sharpBook) !== normalizedBook) {
2125
+ return false;
2126
+ }
2127
+ if (normalizedMarket && normalizeSharpMarketType(row.marketType) !== normalizedMarket) {
2128
+ return false;
2129
+ }
2130
+ if (normalizedTeam && normalizeWhitespace(row.team ?? '').toUpperCase() !== normalizedTeam) {
2131
+ return false;
2132
+ }
2133
+ if (normalizedPlayer && normalizePlayerName(row.playerName) !== normalizedPlayer) {
2134
+ return false;
2135
+ }
2136
+ if (minEdge !== null && !(row.edgeImpliedPct >= minEdge)) {
2137
+ return false;
2138
+ }
2139
+ if (minWidth !== null && !(row.marketWidthPct >= minWidth)) {
2140
+ return false;
2141
+ }
2142
+ return true;
2143
+ });
2144
+ }
2145
+
2146
+ function applyResultLimit(rows = [], limit) {
2147
+ const numericLimit = Number(limit);
2148
+ if (!Number.isFinite(numericLimit) || numericLimit <= 0) {
2149
+ return rows;
2150
+ }
2151
+ return rows.slice(0, numericLimit);
2152
+ }
2153
+
2154
+ export function analyzeSharpMarkets(entries, config = {}) {
2155
+ const grouped = new Map();
2156
+
2157
+ for (const entry of entries) {
2158
+ const key = buildSharpComparisonKey(entry);
2159
+ if (!grouped.has(key)) {
2160
+ grouped.set(key, []);
2161
+ }
2162
+ grouped.get(key).push(entry);
2163
+ }
2164
+
2165
+ const sharpRows = [];
2166
+ const groupedState = new Map();
2167
+
2168
+ for (const [comparisonKey, groupEntries] of grouped.entries()) {
2169
+ const uniqueEntries = collapseEntriesByBook(groupEntries);
2170
+ const sharpEntry = uniqueEntries.find((entry) => normalizeBookFilter(entry.book) === 'Circa')
2171
+ ?? uniqueEntries.find((entry) => normalizeBookFilter(entry.book) === 'BetMGM');
2172
+
2173
+ if (!sharpEntry) {
2174
+ continue;
2175
+ }
2176
+
2177
+ const softEntries = uniqueEntries.filter((entry) => normalizeBookFilter(entry.book) !== normalizeBookFilter(sharpEntry.book));
2178
+ if (softEntries.length === 0) {
2179
+ continue;
2180
+ }
2181
+
2182
+ const bestSoftEntry = softEntries.reduce((best, entry) =>
2183
+ (entry.impliedProbability ?? Number.MAX_SAFE_INTEGER) < (best.impliedProbability ?? Number.MAX_SAFE_INTEGER) ? entry : best,
2184
+ softEntries[0]);
2185
+
2186
+ const allImplied = uniqueEntries.map((entry) => entry.impliedProbability).filter((value) => value !== null && value !== undefined);
2187
+ const marketWidth = allImplied.length > 1 ? Math.max(...allImplied) - Math.min(...allImplied) : 0;
2188
+ const edgeImplied = (sharpEntry.impliedProbability ?? 0) - (bestSoftEntry.impliedProbability ?? 0);
2189
+
2190
+ const row = {
2191
+ comparisonKey,
2192
+ marketKey: sharpEntry.marketKey,
2193
+ playerName: sharpEntry.playerName,
2194
+ playerKey: normalizePlayerName(sharpEntry.playerName),
2195
+ team: sharpEntry.team ?? bestSoftEntry.team ?? null,
2196
+ marketType: normalizeSharpMarketType(sharpEntry.marketType),
2197
+ marketLabel: MARKET_TYPE_LABELS[normalizeSharpMarketType(sharpEntry.marketType)] ?? sharpEntry.marketLabel,
2198
+ side: sharpEntry.side,
2199
+ lineValue: sharpEntry.lineValue ?? null,
2200
+ sharpBook: normalizeBookFilter(sharpEntry.book) ?? sharpEntry.book,
2201
+ sharpOddsInput: sharpEntry.oddsInput,
2202
+ sharpImpliedProbability: sharpEntry.impliedProbability,
2203
+ sharpSourceMode: normalizeBookFilter(sharpEntry.book) === 'Circa' ? 'circa' : 'mgm_fallback',
2204
+ bestSoftBook: normalizeBookFilter(bestSoftEntry.book) ?? bestSoftEntry.book,
2205
+ bestSoftOddsInput: bestSoftEntry.oddsInput,
2206
+ bestSoftImpliedProbability: bestSoftEntry.impliedProbability,
2207
+ edgeImpliedPct: Number(edgeImplied.toFixed(6)),
2208
+ priceDeltaCents: (americanOddsToNumber(bestSoftEntry.oddsInput) ?? 0) - (americanOddsToNumber(sharpEntry.oddsInput) ?? 0),
2209
+ marketWidthPct: Number(marketWidth.toFixed(6)),
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
+ };
2216
+
2217
+ sharpRows.push(row);
2218
+ groupedState.set(comparisonKey, {
2219
+ comparisonKey,
2220
+ sharpRow: row,
2221
+ entries: uniqueEntries,
2222
+ sharpEntry,
2223
+ softEntries,
2224
+ bestSoftEntry,
2225
+ });
2226
+ }
2227
+
2228
+ const edgeRows = sharpRows
2229
+ .filter((row) => row.edgeImpliedPct > 0)
2230
+ .sort((left, right) => right.edgeImpliedPct - left.edgeImpliedPct || right.marketWidthPct - left.marketWidthPct);
2231
+
2232
+ const widthRows = [...sharpRows]
2233
+ .sort((left, right) => right.marketWidthPct - left.marketWidthPct || right.edgeImpliedPct - left.edgeImpliedPct);
2234
+
2235
+ const marketHealth = [...sharpRows]
2236
+ .reduce((map, row) => {
2237
+ const key = row.marketType;
2238
+ if (!map.has(key)) {
2239
+ map.set(key, {
2240
+ marketType: key,
2241
+ marketLabel: row.marketLabel,
2242
+ rows: 0,
2243
+ sharpCoverage: 0,
2244
+ widthTotal: 0,
2245
+ booksTotal: 0,
2246
+ circaRows: 0,
2247
+ mgmFallbackRows: 0,
2248
+ });
2249
+ }
2250
+ const current = map.get(key);
2251
+ current.rows += 1;
2252
+ current.sharpCoverage += 1;
2253
+ current.widthTotal += row.marketWidthPct;
2254
+ current.booksTotal += row.booksCompared;
2255
+ if (row.sharpSourceMode === 'circa') {
2256
+ current.circaRows += 1;
2257
+ } else {
2258
+ current.mgmFallbackRows += 1;
2259
+ }
2260
+ return map;
2261
+ }, new Map());
2262
+
2263
+ const marketHealthRows = [...marketHealth.values()]
2264
+ .map((row) => ({
2265
+ ...row,
2266
+ averageWidthPct: row.rows > 0 ? row.widthTotal / row.rows : 0,
2267
+ averageBooksCompared: row.rows > 0 ? row.booksTotal / row.rows : 0,
2268
+ }))
2269
+ .sort((left, right) => right.sharpCoverage - left.sharpCoverage || right.averageBooksCompared - left.averageBooksCompared);
2270
+
2271
+ return {
2272
+ sharpRows,
2273
+ edgeRows,
2274
+ widthRows,
2275
+ marketHealthRows,
2276
+ groupedState,
2277
+ };
2278
+ }
2279
+
2280
+ function summarizeBookVsSharp(rows, targetBook) {
2281
+ const normalizedTargetBook = normalizeBookFilter(targetBook);
2282
+ const comparisons = [];
2283
+
2284
+ for (const row of rows) {
2285
+ const targetEntry = row.entries.find((entry) => normalizeBookFilter(entry.book) === normalizedTargetBook);
2286
+ if (!targetEntry) {
2287
+ continue;
2288
+ }
2289
+ comparisons.push({
2290
+ ...row,
2291
+ compareBook: normalizedTargetBook,
2292
+ compareOddsInput: targetEntry.oddsInput,
2293
+ compareImpliedProbability: targetEntry.impliedProbability,
2294
+ compareEdgePct: Number(((row.sharpImpliedProbability ?? 0) - (targetEntry.impliedProbability ?? 0)).toFixed(6)),
2295
+ comparePriceDeltaCents: (americanOddsToNumber(targetEntry.oddsInput) ?? 0) - (americanOddsToNumber(row.sharpOddsInput) ?? 0),
2296
+ });
2297
+ }
2298
+
2299
+ const summary = {
2300
+ book: normalizedTargetBook,
2301
+ comparedRows: comparisons.length,
2302
+ betterThanSharpCount: comparisons.filter((row) => row.compareEdgePct > 0).length,
2303
+ averageEdgePct: comparisons.length > 0
2304
+ ? comparisons.reduce((sum, row) => sum + row.compareEdgePct, 0) / comparisons.length
2305
+ : 0,
2306
+ maxEdgePct: comparisons.length > 0
2307
+ ? Math.max(...comparisons.map((row) => row.compareEdgePct))
2308
+ : 0,
2309
+ };
2310
+
2311
+ comparisons.sort((left, right) => right.compareEdgePct - left.compareEdgePct || right.marketWidthPct - left.marketWidthPct);
2312
+ return {
2313
+ summary,
2314
+ rows: comparisons,
2315
+ };
2316
+ }
2317
+
2318
+ function summarizeBooksFromSharpRows(rows) {
2319
+ const grouped = new Map();
2320
+
2321
+ for (const row of rows) {
2322
+ for (const entry of row.softEntries) {
2323
+ const book = normalizeBookFilter(entry.book) ?? entry.book;
2324
+ if (!grouped.has(book)) {
2325
+ grouped.set(book, {
2326
+ book,
2327
+ comparedRows: 0,
2328
+ positiveEdges: 0,
2329
+ totalEdgePct: 0,
2330
+ maxEdgePct: 0,
2331
+ averageWidthPct: 0,
2332
+ totalWidthPct: 0,
2333
+ });
2334
+ }
2335
+
2336
+ const current = grouped.get(book);
2337
+ const edgePct = Number(((row.sharpImpliedProbability ?? 0) - (entry.impliedProbability ?? 0)).toFixed(6));
2338
+ current.comparedRows += 1;
2339
+ current.totalEdgePct += edgePct;
2340
+ current.totalWidthPct += row.marketWidthPct;
2341
+ if (edgePct > 0) {
2342
+ current.positiveEdges += 1;
2343
+ }
2344
+ if (edgePct > current.maxEdgePct) {
2345
+ current.maxEdgePct = edgePct;
2346
+ }
2347
+ }
2348
+ }
2349
+
2350
+ return [...grouped.values()]
2351
+ .map((row) => ({
2352
+ ...row,
2353
+ averageEdgePct: row.comparedRows > 0 ? row.totalEdgePct / row.comparedRows : 0,
2354
+ averageWidthPct: row.comparedRows > 0 ? row.totalWidthPct / row.comparedRows : 0,
2355
+ }))
2356
+ .sort((left, right) => right.positiveEdges - left.positiveEdges || right.maxEdgePct - left.maxEdgePct);
2357
+ }
2358
+
2359
+ function buildSharpAlertKey(prefix, row, book = null) {
2360
+ return [
2361
+ prefix,
2362
+ row.comparisonKey,
2363
+ normalizeBookFilter(row.sharpBook) ?? row.sharpBook,
2364
+ book ? normalizeBookFilter(book) ?? book : 'na',
2365
+ row.sharpOddsInput,
2366
+ row.bestSoftOddsInput ?? 'na',
2367
+ ].join('|');
2368
+ }
2369
+
2370
+ function buildSharpMovements(currentEntries, previousEntries) {
2371
+ const previousByBook = new Map(previousEntries.map((entry) => [normalizeBookFilter(entry.book) ?? entry.book, entry]));
2372
+ const currentByBook = new Map(currentEntries.map((entry) => [normalizeBookFilter(entry.book) ?? entry.book, entry]));
2373
+ const movements = [];
2374
+
2375
+ for (const [book, currentEntry] of currentByBook.entries()) {
2376
+ const previousEntry = previousByBook.get(book);
2377
+ if (!previousEntry || previousEntry.oddsInput === currentEntry.oddsInput) {
2378
+ continue;
2379
+ }
2380
+ movements.push({
2381
+ book,
2382
+ currentEntry,
2383
+ previousEntry,
2384
+ impliedDeltaPct: ((currentEntry.impliedProbability ?? 0) - (previousEntry.impliedProbability ?? 0)),
2385
+ });
2386
+ }
2387
+
2388
+ return movements;
2389
+ }
2390
+
2391
  function normalizeCircaFilename(name) {
2392
  return normalizeWhitespace(String(name ?? ''))
2393
  .replace(/[\u200B-\u200D\uFEFF]/g, '')
 
3765
  };
3766
  }
3767
 
3768
+ async getCurrentSharpAnalysis(options = {}) {
3769
+ const requestedMarkets = Array.isArray(options.oddsApiMarkets) && options.oddsApiMarkets.length > 0
3770
+ ? options.oddsApiMarkets
3771
+ : this.config.oddsApiMarkets;
3772
+ const [oddsEntries, circaSnapshot] = await Promise.all([
3773
+ this.getDirectOddsEntries(requestedMarkets),
3774
+ this.getCircaSnapshotForAnalysis().catch(() => null),
3775
+ ]);
3776
+ const circaEntries = circaSnapshot?.entries ?? [];
3777
+ const allEntries = [...oddsEntries, ...circaEntries];
3778
+ const legacy = analyzeMarkets(allEntries, {
3779
+ minBooks: this.config.scanMinBooks,
3780
+ disagreementThreshold: this.config.scanDisagreementThreshold,
3781
+ });
3782
+ const sharp = analyzeSharpMarkets(allEntries, this.config);
3783
+ return {
3784
+ ...legacy,
3785
+ ...sharp,
3786
+ allEntries,
3787
+ oddsEntries,
3788
+ circaSnapshot,
3789
+ };
3790
+ }
3791
+
3792
+ async getSharpBoard(filters = {}) {
3793
+ const analysis = await this.getCurrentSharpAnalysis();
3794
+ return {
3795
+ title: 'Sharp Board',
3796
+ rows: applyResultLimit(filterSharpRows(analysis.edgeRows, filters), filters.limit ?? this.config.marketBoardDefaultLimit),
3797
+ analysis,
3798
+ };
3799
+ }
3800
+
3801
+ async getEdgeBoard(filters = {}) {
3802
+ const analysis = await this.getCurrentSharpAnalysis();
3803
+ return {
3804
+ title: 'Edge Board',
3805
+ rows: applyResultLimit(filterSharpRows(analysis.edgeRows, filters), filters.limit ?? this.config.marketBoardDefaultLimit),
3806
+ analysis,
3807
+ };
3808
+ }
3809
+
3810
+ async getWidthBoard(filters = {}) {
3811
+ const analysis = await this.getCurrentSharpAnalysis();
3812
+ return {
3813
+ title: 'Width Board',
3814
+ rows: applyResultLimit(filterSharpRows(analysis.widthRows, filters), filters.limit ?? this.config.marketBoardDefaultLimit),
3815
+ analysis,
3816
+ };
3817
+ }
3818
+
3819
+ async getPlayerEdge(player, filters = {}) {
3820
+ const analysis = await this.getCurrentSharpAnalysis();
3821
+ return {
3822
+ title: 'Player Edge',
3823
+ rows: filterSharpRows(analysis.sharpRows, { ...filters, player })
3824
+ .sort((left, right) => right.edgeImpliedPct - left.edgeImpliedPct || right.marketWidthPct - left.marketWidthPct),
3825
+ analysis,
3826
+ };
3827
+ }
3828
+
3829
+ async getMarketEdge(market, filters = {}) {
3830
+ const analysis = await this.getCurrentSharpAnalysis();
3831
+ return {
3832
+ title: 'Market Edge',
3833
+ rows: applyResultLimit(
3834
+ filterSharpRows(analysis.edgeRows, { ...filters, market }),
3835
+ filters.limit ?? this.config.marketBoardDefaultLimit,
3836
+ ),
3837
+ analysis,
3838
+ };
3839
+ }
3840
+
3841
+ async getConsensusVsBook(book, filters = {}) {
3842
+ const analysis = await this.getCurrentSharpAnalysis();
3843
+ const filteredRows = filterSharpRows(analysis.sharpRows, filters);
3844
+ return summarizeBookVsSharp(filteredRows, book);
3845
+ }
3846
+
3847
+ async getBookScoreboard(filters = {}) {
3848
+ const analysis = await this.getCurrentSharpAnalysis();
3849
+ const rows = summarizeBooksFromSharpRows(filterSharpRows(analysis.sharpRows, filters));
3850
+ return {
3851
+ title: 'Book Scoreboard',
3852
+ rows: applyResultLimit(rows, filters.limit ?? this.config.marketBoardDefaultLimit),
3853
+ analysis,
3854
+ };
3855
+ }
3856
+
3857
+ async getMarketHealth(filters = {}) {
3858
+ const analysis = await this.getCurrentSharpAnalysis();
3859
+ const normalizedMarket = filters.market ? normalizeSharpMarketType(filters.market) : null;
3860
+ const rows = analysis.marketHealthRows.filter((row) => !normalizedMarket || row.marketType === normalizedMarket);
3861
+ return {
3862
+ title: 'Market Health',
3863
+ rows: applyResultLimit(rows, filters.limit ?? this.config.marketBoardDefaultLimit),
3864
+ analysis,
3865
+ };
3866
+ }
3867
+
3868
+ async getSteamBoard(filters = {}) {
3869
+ const currentRun = await this.store.getLatestScanRun('scan');
3870
+ if (!currentRun) {
3871
+ throw new Error('No completed scan snapshot is available yet.');
3872
+ }
3873
+ const previousRun = await this.store.getPreviousScanRun(currentRun.id, 'scan');
3874
+ if (!previousRun) {
3875
+ throw new Error('No prior scan snapshot is available yet.');
3876
+ }
3877
+
3878
+ const currentAnalysis = analyzeSharpMarkets(currentRun.entries, this.config);
3879
+ const previousGrouped = analyzeSharpMarkets(previousRun.entries, this.config).groupedState;
3880
+ const rows = [];
3881
+
3882
+ for (const [comparisonKey, state] of currentAnalysis.groupedState.entries()) {
3883
+ const previousState = previousGrouped.get(comparisonKey);
3884
+ if (!previousState) {
3885
+ continue;
3886
+ }
3887
+
3888
+ const movements = buildSharpMovements(state.entries, previousState.entries);
3889
+ for (const movement of movements) {
3890
+ rows.push({
3891
+ ...state.sharpRow,
3892
+ movedBook: movement.book,
3893
+ oldOddsInput: movement.previousEntry.oddsInput,
3894
+ newOddsInput: movement.currentEntry.oddsInput,
3895
+ impliedDeltaPct: movement.impliedDeltaPct,
3896
+ });
3897
+ }
3898
+ }
3899
+
3900
+ const filtered = filterSharpRows(rows, filters)
3901
+ .filter((row) => {
3902
+ if (filters.book) {
3903
+ return normalizeBookFilter(row.movedBook) === normalizeBookFilter(filters.book);
3904
+ }
3905
+ return true;
3906
+ })
3907
+ .sort((left, right) => Math.abs(right.impliedDeltaPct) - Math.abs(left.impliedDeltaPct));
3908
+
3909
+ return {
3910
+ title: 'Steam',
3911
+ rows: applyResultLimit(filtered, filters.limit ?? this.config.marketBoardDefaultLimit),
3912
+ currentRun,
3913
+ previousRun,
3914
+ };
3915
+ }
3916
+
3917
  async maybeRunMorningReport(now = new Date()) {
3918
  if (!this.config.enabled) {
3919
  return null;
 
3956
  secondaryValue: (row) => `${row.bestOddsInput} -> ${row.worstOddsInput}`,
3957
  })],
3958
  });
3959
+ await channel.send({
3960
+ embeds: [this.embeds.buildSharpEdgeBoardEmbed('Morning Sharp Board', applyResultLimit(analysis.edgeRows, this.config.marketBoardDefaultLimit))],
3961
+ });
3962
+ await channel.send({
3963
+ embeds: [this.embeds.buildBookScoreboardEmbed('Morning Book Scoreboard', applyResultLimit(summarizeBooksFromSharpRows(analysis.sharpRows), this.config.marketBoardDefaultLimit))],
3964
+ });
3965
+ await channel.send({
3966
+ embeds: [this.embeds.buildMarketHealthEmbed('Morning Market Health', applyResultLimit(analysis.marketHealthRows, this.config.marketBoardDefaultLimit))],
3967
+ });
3968
 
3969
  await this.store.recordScanReport(dateKey, 'morning', this.config.scanReportChannelId, discrepancyMessage.id, widthMessage.id);
3970
  this.status.lastReportAt = new Date().toISOString();
 
3999
  sentCount += 1;
4000
  }
4001
 
4002
+ if (this.config.sharpEdgeAlertsEnabled) {
4003
+ for (const row of analysis.edgeRows) {
4004
+ if (row.edgeImpliedPct < this.config.sharpEdgeAlertThreshold) {
4005
+ continue;
4006
+ }
4007
+ const alertKey = buildSharpAlertKey('sharp-edge', row, row.bestSoftBook);
4008
+ const cooldownOk = await this.store.canSendScanAlert(alertKey, this.config.scanAlertCooldownMinutes);
4009
+ if (!cooldownOk) {
4010
+ continue;
4011
+ }
4012
+ await channel.send({
4013
+ embeds: [this.embeds.buildSharpAlertEmbed(row)],
4014
+ });
4015
+ await this.store.recordScanAlert(alertKey);
4016
+ sentCount += 1;
4017
+ }
4018
+ }
4019
+
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;
4031
+ }
4032
+ const currentSharpBook = normalizeBookFilter(state.sharpEntry.book);
4033
+ const previousSharp = prior.entries.find((entry) => normalizeBookFilter(entry.book) === currentSharpBook);
4034
+ if (!previousSharp || previousSharp.oddsInput === state.sharpEntry.oddsInput) {
4035
+ continue;
4036
+ }
4037
+ const currentMoves = buildSharpMovements(state.entries, prior.entries);
4038
+ const movedBooks = new Set(currentMoves.map((movement) => movement.book));
4039
+ for (const softEntry of state.softEntries) {
4040
+ const softBook = normalizeBookFilter(softEntry.book);
4041
+ if (softBook === currentSharpBook || movedBooks.has(softBook)) {
4042
+ continue;
4043
+ }
4044
+ const edgePct = (state.sharpEntry.impliedProbability ?? 0) - (softEntry.impliedProbability ?? 0);
4045
+ if (edgePct < this.config.staleBookAlertThreshold) {
4046
+ continue;
4047
+ }
4048
+ const alertKey = buildSharpAlertKey('stale-book', state.sharpRow, softBook);
4049
+ const cooldownOk = await this.store.canSendScanAlert(alertKey, this.config.scanAlertCooldownMinutes);
4050
+ if (!cooldownOk) {
4051
+ continue;
4052
+ }
4053
+ await channel.send({
4054
+ embeds: [this.embeds.buildStaleBookAlertEmbed(state.sharpRow, softEntry)],
4055
+ });
4056
+ await this.store.recordScanAlert(alertKey);
4057
+ sentCount += 1;
4058
+ }
4059
+ }
4060
+ }
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;
4067
+ }
4068
+ const currentSharpBook = normalizeBookFilter(state.sharpEntry.book);
4069
+ const previousSharp = prior.entries.find((entry) => normalizeBookFilter(entry.book) === currentSharpBook);
4070
+ const sharpMoved = previousSharp && previousSharp.oddsInput !== state.sharpEntry.oddsInput;
4071
+ if (sharpMoved) {
4072
+ continue;
4073
+ }
4074
+ const currentMoves = buildSharpMovements(state.entries, prior.entries);
4075
+ for (const movement of currentMoves) {
4076
+ if (movement.book === currentSharpBook || Math.abs(movement.impliedDeltaPct) < this.config.reverseAlertThreshold) {
4077
+ continue;
4078
+ }
4079
+ const alertKey = buildSharpAlertKey('reverse', state.sharpRow, movement.book);
4080
+ const cooldownOk = await this.store.canSendScanAlert(alertKey, this.config.scanAlertCooldownMinutes);
4081
+ if (!cooldownOk) {
4082
+ continue;
4083
+ }
4084
+ await channel.send({
4085
+ embeds: [this.embeds.buildReverseAlertEmbed(state.sharpRow, movement)],
4086
+ });
4087
+ await this.store.recordScanAlert(alertKey);
4088
+ sentCount += 1;
4089
+ }
4090
+ }
4091
+ }
4092
+ }
4093
+
4094
  this.status.lastAlertCount = sentCount;
4095
  return analysis;
4096
  }
 
4428
  minBooks: this.config.scanMinBooks,
4429
  disagreementThreshold: this.config.scanDisagreementThreshold,
4430
  });
4431
+ const sharpAnalysis = analyzeSharpMarkets(allEntries, this.config);
4432
 
4433
+ const scanRunId = await this.store.recordMarketSnapshot(scanType, allEntries);
4434
  this.status.lastScanAt = new Date().toISOString();
4435
  this.status.lastScanError = null;
4436
  this.status.lastApiEntries = oddsEntries.length;
 
4438
  this.status.lastCircaFileName = circaResult.fileName ?? null;
4439
  this.status.lastCircaFingerprintAt = circaResult.seenAt ?? new Date().toISOString();
4440
 
4441
+ return {
4442
+ ...analysis,
4443
+ ...sharpAnalysis,
4444
+ allEntries,
4445
+ scanRunId,
4446
+ };
4447
  } catch (error) {
4448
  this.status.lastScanError = error.message;
4449
  if (String(error.message ?? '').toLowerCase().includes('pdf') || String(error.message ?? '').toLowerCase().includes('ocr') || String(error.message ?? '').toLowerCase().includes('circa')) {
test/market-scanner.test.js CHANGED
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
3
  import {
4
  americanToImpliedProbability,
5
  analyzeMarkets,
 
6
  buildMarketKey,
7
  extractCircaPdfFromArchiveBuffer,
8
  extractCircaFileCandidatesFromHtml,
@@ -444,6 +445,93 @@ test('fetches odds api entries from event-level endpoints', async () => {
444
  }
445
  });
446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  test('returns partial odds results when later event requests hit 429', async () => {
448
  const originalFetch = global.fetch;
449
  const calls = [];
 
3
  import {
4
  americanToImpliedProbability,
5
  analyzeMarkets,
6
+ analyzeSharpMarkets,
7
  buildMarketKey,
8
  extractCircaPdfFromArchiveBuffer,
9
  extractCircaFileCandidatesFromHtml,
 
445
  }
446
  });
447
 
448
+ test('sharp analysis prefers Circa and falls back to BetMGM when Circa is missing', () => {
449
+ const rows = analyzeSharpMarkets([
450
+ {
451
+ marketKey: 'judge-hr',
452
+ source: 'circa',
453
+ book: 'Circa',
454
+ eventName: 'Yankees @ Red Sox',
455
+ playerName: 'Aaron Judge',
456
+ team: 'NYY',
457
+ marketType: 'home_runs',
458
+ marketLabel: 'Home Runs',
459
+ side: 'yes',
460
+ lineValue: 0.5,
461
+ oddsInput: '400',
462
+ impliedProbability: americanToImpliedProbability('400'),
463
+ },
464
+ {
465
+ marketKey: 'judge-hr',
466
+ source: 'odds_api',
467
+ book: 'Caesars',
468
+ eventName: 'Yankees @ Red Sox',
469
+ playerName: 'Aaron Judge',
470
+ team: 'NYY',
471
+ marketType: 'batter_home_runs',
472
+ marketLabel: 'Home Runs',
473
+ side: 'yes',
474
+ lineValue: 0.5,
475
+ oddsInput: '450',
476
+ impliedProbability: americanToImpliedProbability('450'),
477
+ },
478
+ {
479
+ marketKey: 'soto-hr',
480
+ source: 'odds_api',
481
+ book: 'BetMGM',
482
+ eventName: 'Mets @ Braves',
483
+ playerName: 'Juan Soto',
484
+ team: 'NYM',
485
+ marketType: 'batter_home_runs',
486
+ marketLabel: 'Home Runs',
487
+ side: 'yes',
488
+ lineValue: 0.5,
489
+ oddsInput: '420',
490
+ impliedProbability: americanToImpliedProbability('420'),
491
+ },
492
+ {
493
+ marketKey: 'soto-hr',
494
+ source: 'odds_api',
495
+ book: 'FanDuel',
496
+ eventName: 'Mets @ Braves',
497
+ playerName: 'Juan Soto',
498
+ team: 'NYM',
499
+ marketType: 'batter_home_runs',
500
+ marketLabel: 'Home Runs',
501
+ side: 'yes',
502
+ lineValue: 0.5,
503
+ oddsInput: '470',
504
+ impliedProbability: americanToImpliedProbability('470'),
505
+ },
506
+ {
507
+ marketKey: 'acuna-hr',
508
+ source: 'odds_api',
509
+ book: 'FanDuel',
510
+ eventName: 'Mets @ Braves',
511
+ playerName: 'Ronald Acuna Jr.',
512
+ team: 'ATL',
513
+ marketType: 'batter_home_runs',
514
+ marketLabel: 'Home Runs',
515
+ side: 'yes',
516
+ lineValue: 0.5,
517
+ oddsInput: '300',
518
+ impliedProbability: americanToImpliedProbability('300'),
519
+ },
520
+ ]);
521
+
522
+ assert.equal(rows.sharpRows.length, 2);
523
+
524
+ const judge = rows.sharpRows.find((row) => row.playerName === 'Aaron Judge');
525
+ assert.equal(judge.sharpBook, 'Circa');
526
+ assert.equal(judge.sharpSourceMode, 'circa');
527
+
528
+ const soto = rows.sharpRows.find((row) => row.playerName === 'Juan Soto');
529
+ assert.equal(soto.sharpBook, 'BetMGM');
530
+ assert.equal(soto.sharpSourceMode, 'mgm_fallback');
531
+
532
+ assert.equal(rows.sharpRows.some((row) => row.playerName === 'Ronald Acuna Jr.'), false);
533
+ });
534
+
535
  test('returns partial odds results when later event requests hit 429', async () => {
536
  const originalFetch = global.fetch;
537
  const calls = [];