Codex commited on
Commit
bf291c4
·
1 Parent(s): 50856b4

Finish dfs and exchange odds lane rollout

Browse files
src/embeds.js CHANGED
@@ -311,9 +311,9 @@ export function buildCommandsEmbed() {
311
  { name: '/oddsquota', value: 'Show live Odds API quota usage headers. Admin only.' },
312
  { name: '/scanrun', value: 'Run the market scanner manually. Admin only.' },
313
  { name: '/scanreport', value: 'Post the morning scan reports immediately. Admin only.' },
314
- { name: 'Sharp Market Commands', value: '`/edgeboard`, `/playeredge`, `/marketedge`, `/widthboard`, `/consensusvs`, `/steam`, `/sharpboard`, `/bookscoreboard`, `/markethealth` surface Circa-first market value and quality views.' },
315
  { name: '/hrodds', value: 'Show live Home Run odds for one player across Caesars, BetMGM, and Circa, or filter to one book.' },
316
- { name: 'Other Odds Commands', value: '`/hitodds`, `/tbodds`, `/rbiodds`, `/runodds`, `/sbodds`, `/kodds` show live player odds across books.' },
317
  { name: '/circatest', value: 'Run a Circa OCR diagnostic preview. Admin only.' },
318
  { name: 'Circa Market Commands', value: '`/circamarket`, `/circahr`, `/circahits`, `/circatb`, `/circarbis`, `/circaruns`, `/circasb`, `/circahrri`, `/circak` post the latest parsed Circa markets in the channel where you run them.' },
319
  { name: '/alerts', value: 'Post the public analyst alert-role panel to the welcome channel. Only for jew_olympics.' },
@@ -543,7 +543,7 @@ export function buildSharpEdgeBoardEmbed(title, rows, filters = {}) {
543
  rows.map((row, index) => ({
544
  name: `${index + 1}. ${row.playerName} - ${row.marketLabel}`,
545
  value: [
546
- `Sharp: ${row.sharpBook} @ ${row.sharpOddsInput} | Best Soft: ${row.bestSoftBook} @ ${row.bestSoftOddsInput}`,
547
  `Edge: ${formatPercent(row.edgeImpliedPct * 100)} | Width: ${formatPercent(row.marketWidthPct * 100)}`,
548
  `Books: ${row.booksCompared} | Status: ${describeSharpSource(row)}`,
549
  ].join('\n'),
@@ -568,7 +568,7 @@ export function buildPlayerEdgeEmbed({ playerName, rows }, filters = {}) {
568
  rows.map((row) => ({
569
  name: row.marketLabel,
570
  value: [
571
- `Sharp: ${row.sharpBook} @ ${row.sharpOddsInput} | Best Soft: ${row.bestSoftBook} @ ${row.bestSoftOddsInput}`,
572
  `Edge: ${formatPercent(row.edgeImpliedPct * 100)} | Width: ${formatPercent(row.marketWidthPct * 100)}`,
573
  ].join('\n'),
574
  inline: false,
@@ -598,7 +598,7 @@ export function buildConsensusVsEmbed({ summary, rows }, filters = {}) {
598
  rows.slice(0, 10).map((row, index) => ({
599
  name: `${index + 1}. ${row.playerName} - ${row.marketLabel}`,
600
  value: [
601
- `Sharp: ${row.sharpBook} @ ${row.sharpOddsInput} | ${summary.book}: ${row.compareOddsInput}`,
602
  `Edge: ${formatPercent(row.compareEdgePct * 100)} | Width: ${formatPercent(row.marketWidthPct * 100)}`,
603
  ].join('\n'),
604
  inline: false,
@@ -612,7 +612,7 @@ export function buildBookScoreboardEmbed(title, rows, filters = {}) {
612
  const embed = new EmbedBuilder()
613
  .setColor(PALETTE.primary)
614
  .setTitle(buildLaneTitle(title, filters.lane ?? rows[0]?.lane))
615
- .setDescription(buildFilterBanner(filters, 'Books ranked by how often and how far they differ from sharp.'));
616
 
617
  if (!rows.length) {
618
  return embed.addFields({ name: 'No rows', value: 'No book comparison rows matched the current filters.' });
@@ -649,7 +649,9 @@ export function buildMarketHealthEmbed(title, rows, filters = {}) {
649
  value: [
650
  `Rows: ${row.rows} | Avg Books: ${row.averageBooksCompared.toFixed(2)}`,
651
  `Avg Width: ${formatPercent(row.averageWidthPct * 100)}`,
652
- `Sharp Source: Circa ${row.circaRows} | MGM fallback ${row.mgmFallbackRows}`,
 
 
653
  ].join('\n'),
654
  inline: false,
655
  }))
@@ -674,7 +676,7 @@ export function buildSteamEmbed(title, rows, filters = {}) {
674
  value: [
675
  `Selection: ${formatSelection(row.side, row.lineValue)}`,
676
  `Moved Book: ${row.movedBook} | ${formatAmericanOdds(row.oldOddsInput)} -> ${formatAmericanOdds(row.newOddsInput)}`,
677
- `Sharp: ${row.sharpBook} @ ${formatAmericanOdds(row.sharpOddsInput)} | Delta: ${formatPercent(row.impliedDeltaPct * 100)}`,
678
  ].join('\n'),
679
  inline: false,
680
  }))
@@ -690,12 +692,12 @@ export function buildSharpAlertEmbed(row) {
690
  .setDescription(`${row.playerName} - ${row.marketLabel}`)
691
  .addFields(
692
  { name: 'Selection', value: formatSelection(row.side, row.lineValue), inline: true },
693
- { name: 'Sharp', value: `${row.sharpBook} @ ${formatAmericanOdds(row.sharpOddsInput)}`, inline: true },
694
- { name: 'Best Soft', value: `${row.bestSoftBook} @ ${formatAmericanOdds(row.bestSoftOddsInput)}`, inline: true },
695
  { name: 'Edge', value: formatPercent(row.edgeImpliedPct * 100), inline: true },
696
  { name: 'Width', value: formatPercent(row.marketWidthPct * 100), inline: true },
697
  { name: 'Books Compared', value: String(row.booksCompared), inline: true },
698
- { name: 'Sharp Source', value: describeSharpSource(row), inline: true },
699
  );
700
  }
701
 
@@ -706,11 +708,11 @@ export function buildStaleBookAlertEmbed(row, staleEntry) {
706
  .setDescription(`${row.playerName} - ${row.marketLabel}`)
707
  .addFields(
708
  { name: 'Selection', value: formatSelection(row.side, row.lineValue), inline: true },
709
- { name: 'Sharp', value: `${row.sharpBook} @ ${formatAmericanOdds(row.sharpOddsInput)}`, inline: true },
710
  { name: 'Stale Book', value: `${staleEntry.book} @ ${formatAmericanOdds(staleEntry.oddsInput)}`, inline: true },
711
  { name: 'Current Edge', value: formatPercent(((row.sharpImpliedProbability ?? 0) - (staleEntry.impliedProbability ?? 0)) * 100), inline: true },
712
  )
713
- .setFooter({ text: 'Sharp and at least one other book moved, but this book did not.' });
714
  }
715
 
716
  export function buildReverseAlertEmbed(row, movement) {
@@ -720,7 +722,7 @@ export function buildReverseAlertEmbed(row, movement) {
720
  .setDescription(`${row.playerName} - ${row.marketLabel}`)
721
  .addFields(
722
  { name: 'Selection', value: formatSelection(row.side, row.lineValue), inline: true },
723
- { name: 'Sharp', value: `${row.sharpBook} @ ${formatAmericanOdds(row.sharpOddsInput)}`, inline: true },
724
  {
725
  name: 'Soft Move',
726
  value: `${movement.book} ${formatSelection(movement.currentEntry.side, movement.currentEntry.lineValue)} ${formatAmericanOdds(movement.previousEntry.oddsInput)} -> ${formatAmericanOdds(movement.currentEntry.oddsInput)}`,
@@ -1458,11 +1460,15 @@ function describeSharpSource(row) {
1458
  return 'DFS consensus';
1459
  }
1460
  if (row.lane === 'exchange') {
1461
- return 'Exchange best price';
1462
  }
1463
  return row.sharpSourceMode === 'circa' ? 'Circa' : 'BetMGM fallback';
1464
  }
1465
 
 
 
 
 
1466
  function buildLaneTitle(baseTitle, lane) {
1467
  const laneLabel = formatLaneLabel(lane);
1468
  return laneLabel === 'SPORTSBOOK' ? baseTitle : `${laneLabel} ${baseTitle}`;
 
311
  { name: '/oddsquota', value: 'Show live Odds API quota usage headers. Admin only.' },
312
  { name: '/scanrun', value: 'Run the market scanner manually. Admin only.' },
313
  { name: '/scanreport', value: 'Post the morning scan reports immediately. Admin only.' },
314
+ { name: 'Sharp Market Commands', value: '`/edgeboard`, `/playeredge`, `/marketedge`, `/widthboard`, `/consensusvs`, `/steam`, `/sharpboard`, `/bookscoreboard`, `/markethealth` cover sportsbook comparisons. `/dfsedgeboard`, `/dfsplayeredge`, `/dfsmarketedge`, `/dfswidthboard`, `/dfsconsensusvs` and `/exchangeedgeboard`, `/exchangeplayeredge`, `/exchangemarketedge`, `/exchangewidthboard`, `/exchangeconsensusvs` mirror those views for DFS and exchange lanes.' },
315
  { name: '/hrodds', value: 'Show live Home Run odds for one player across Caesars, BetMGM, and Circa, or filter to one book.' },
316
+ { name: 'Other Odds Commands', value: '`/hitodds`, `/tbodds`, `/rbiodds`, `/runodds`, `/sbodds`, `/kodds` stay sportsbook-only. `/dfsodds` and `/exchangeodds` show direct prices inside the DFS or exchange lane.' },
317
  { name: '/circatest', value: 'Run a Circa OCR diagnostic preview. Admin only.' },
318
  { name: 'Circa Market Commands', value: '`/circamarket`, `/circahr`, `/circahits`, `/circatb`, `/circarbis`, `/circaruns`, `/circasb`, `/circahrri`, `/circak` post the latest parsed Circa markets in the channel where you run them.' },
319
  { name: '/alerts', value: 'Post the public analyst alert-role panel to the welcome channel. Only for jew_olympics.' },
 
543
  rows.map((row, index) => ({
544
  name: `${index + 1}. ${row.playerName} - ${row.marketLabel}`,
545
  value: [
546
+ `${getReferenceLabel(row)}: ${row.sharpBook} @ ${row.sharpOddsInput} | Best Price: ${row.bestSoftBook} @ ${row.bestSoftOddsInput}`,
547
  `Edge: ${formatPercent(row.edgeImpliedPct * 100)} | Width: ${formatPercent(row.marketWidthPct * 100)}`,
548
  `Books: ${row.booksCompared} | Status: ${describeSharpSource(row)}`,
549
  ].join('\n'),
 
568
  rows.map((row) => ({
569
  name: row.marketLabel,
570
  value: [
571
+ `${getReferenceLabel(row)}: ${row.sharpBook} @ ${row.sharpOddsInput} | Best Price: ${row.bestSoftBook} @ ${row.bestSoftOddsInput}`,
572
  `Edge: ${formatPercent(row.edgeImpliedPct * 100)} | Width: ${formatPercent(row.marketWidthPct * 100)}`,
573
  ].join('\n'),
574
  inline: false,
 
598
  rows.slice(0, 10).map((row, index) => ({
599
  name: `${index + 1}. ${row.playerName} - ${row.marketLabel}`,
600
  value: [
601
+ `${getReferenceLabel(row)}: ${row.sharpBook} @ ${row.sharpOddsInput} | ${summary.book}: ${row.compareOddsInput}`,
602
  `Edge: ${formatPercent(row.compareEdgePct * 100)} | Width: ${formatPercent(row.marketWidthPct * 100)}`,
603
  ].join('\n'),
604
  inline: false,
 
612
  const embed = new EmbedBuilder()
613
  .setColor(PALETTE.primary)
614
  .setTitle(buildLaneTitle(title, filters.lane ?? rows[0]?.lane))
615
+ .setDescription(buildFilterBanner(filters, 'Books ranked by how often and how far they differ from the current lane reference.'));
616
 
617
  if (!rows.length) {
618
  return embed.addFields({ name: 'No rows', value: 'No book comparison rows matched the current filters.' });
 
649
  value: [
650
  `Rows: ${row.rows} | Avg Books: ${row.averageBooksCompared.toFixed(2)}`,
651
  `Avg Width: ${formatPercent(row.averageWidthPct * 100)}`,
652
+ row.lane === 'sportsbook'
653
+ ? `Sharp Source: Circa ${row.circaRows} | MGM fallback ${row.mgmFallbackRows}`
654
+ : `Reference: ${row.lane === 'dfs' ? 'DFS consensus baseline' : 'Exchange consensus baseline'} | Rows ${row.consensusRows}`,
655
  ].join('\n'),
656
  inline: false,
657
  }))
 
676
  value: [
677
  `Selection: ${formatSelection(row.side, row.lineValue)}`,
678
  `Moved Book: ${row.movedBook} | ${formatAmericanOdds(row.oldOddsInput)} -> ${formatAmericanOdds(row.newOddsInput)}`,
679
+ `${getReferenceLabel(row)}: ${row.sharpBook} @ ${formatAmericanOdds(row.sharpOddsInput)} | Delta: ${formatPercent(row.impliedDeltaPct * 100)}`,
680
  ].join('\n'),
681
  inline: false,
682
  }))
 
692
  .setDescription(`${row.playerName} - ${row.marketLabel}`)
693
  .addFields(
694
  { name: 'Selection', value: formatSelection(row.side, row.lineValue), inline: true },
695
+ { name: getReferenceLabel(row), value: `${row.sharpBook} @ ${formatAmericanOdds(row.sharpOddsInput)}`, inline: true },
696
+ { name: 'Best Price', value: `${row.bestSoftBook} @ ${formatAmericanOdds(row.bestSoftOddsInput)}`, inline: true },
697
  { name: 'Edge', value: formatPercent(row.edgeImpliedPct * 100), inline: true },
698
  { name: 'Width', value: formatPercent(row.marketWidthPct * 100), inline: true },
699
  { name: 'Books Compared', value: String(row.booksCompared), inline: true },
700
+ { name: 'Reference Source', value: describeSharpSource(row), inline: true },
701
  );
702
  }
703
 
 
708
  .setDescription(`${row.playerName} - ${row.marketLabel}`)
709
  .addFields(
710
  { name: 'Selection', value: formatSelection(row.side, row.lineValue), inline: true },
711
+ { name: getReferenceLabel(row), value: `${row.sharpBook} @ ${formatAmericanOdds(row.sharpOddsInput)}`, inline: true },
712
  { name: 'Stale Book', value: `${staleEntry.book} @ ${formatAmericanOdds(staleEntry.oddsInput)}`, inline: true },
713
  { name: 'Current Edge', value: formatPercent(((row.sharpImpliedProbability ?? 0) - (staleEntry.impliedProbability ?? 0)) * 100), inline: true },
714
  )
715
+ .setFooter({ text: 'The lane reference improved while this book stayed stale.' });
716
  }
717
 
718
  export function buildReverseAlertEmbed(row, movement) {
 
722
  .setDescription(`${row.playerName} - ${row.marketLabel}`)
723
  .addFields(
724
  { name: 'Selection', value: formatSelection(row.side, row.lineValue), inline: true },
725
+ { name: getReferenceLabel(row), value: `${row.sharpBook} @ ${formatAmericanOdds(row.sharpOddsInput)}`, inline: true },
726
  {
727
  name: 'Soft Move',
728
  value: `${movement.book} ${formatSelection(movement.currentEntry.side, movement.currentEntry.lineValue)} ${formatAmericanOdds(movement.previousEntry.oddsInput)} -> ${formatAmericanOdds(movement.currentEntry.oddsInput)}`,
 
1460
  return 'DFS consensus';
1461
  }
1462
  if (row.lane === 'exchange') {
1463
+ return 'Exchange consensus';
1464
  }
1465
  return row.sharpSourceMode === 'circa' ? 'Circa' : 'BetMGM fallback';
1466
  }
1467
 
1468
+ function getReferenceLabel(row) {
1469
+ return row?.lane === 'sportsbook' ? 'Sharp' : 'Reference';
1470
+ }
1471
+
1472
  function buildLaneTitle(baseTitle, lane) {
1473
  const laneLabel = formatLaneLabel(lane);
1474
  return laneLabel === 'SPORTSBOOK' ? baseTitle : `${laneLabel} ${baseTitle}`;
src/market-scanner.js CHANGED
@@ -422,6 +422,20 @@ export function americanToImpliedProbability(oddsInput) {
422
  return Number((Math.abs(odds) / (Math.abs(odds) + 100)).toFixed(6));
423
  }
424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  function normalizeSide({ marketKey, outcomeName, description, point }) {
426
  const name = normalizeWhitespace(outcomeName).toLowerCase();
427
  const desc = normalizeWhitespace(description).toLowerCase();
@@ -2162,7 +2176,12 @@ function filterSharpRows(rows = [], filters = {}) {
2162
  if (normalizedLane && normalizeLaneFilter(row.lane) !== normalizedLane) {
2163
  return false;
2164
  }
2165
- if (normalizedBook && normalizeBookFilter(row.bestSoftBook) !== normalizedBook && normalizeBookFilter(row.sharpBook) !== normalizedBook) {
 
 
 
 
 
2166
  return false;
2167
  }
2168
  if (normalizedMarket && normalizeSharpMarketType(row.marketType) !== normalizedMarket) {
@@ -2218,7 +2237,7 @@ function getSharpSourceMode(entry, lane = 'sportsbook') {
2218
  if (normalizedLane === 'dfs') {
2219
  return 'dfs_consensus';
2220
  }
2221
- return 'exchange_best';
2222
  }
2223
 
2224
  export function analyzeSharpMarkets(entries, config = {}, options = {}) {
@@ -2242,51 +2261,82 @@ export function analyzeSharpMarkets(entries, config = {}, options = {}) {
2242
 
2243
  for (const [comparisonKey, groupEntries] of grouped.entries()) {
2244
  const uniqueEntries = collapseEntriesByBook(groupEntries);
2245
- const sharpEntry = pickLaneSharpEntry(uniqueEntries, lane);
 
 
 
 
 
 
 
 
 
 
 
 
 
2246
 
2247
- if (!sharpEntry) {
2248
- continue;
2249
- }
 
2250
 
2251
- const softEntries = uniqueEntries.filter((entry) => normalizeBookFilter(entry.book) !== normalizeBookFilter(sharpEntry.book));
2252
- if (softEntries.length === 0) {
2253
- continue;
2254
- }
 
 
 
 
 
 
 
2255
 
2256
- const bestSoftEntry = softEntries.reduce((best, entry) =>
2257
- (entry.impliedProbability ?? Number.MAX_SAFE_INTEGER) < (best.impliedProbability ?? Number.MAX_SAFE_INTEGER) ? entry : best,
2258
- softEntries[0]);
 
 
 
 
 
 
 
 
 
 
 
 
2259
 
2260
- const allImplied = uniqueEntries.map((entry) => entry.impliedProbability).filter((value) => value !== null && value !== undefined);
2261
- const marketWidth = allImplied.length > 1 ? Math.max(...allImplied) - Math.min(...allImplied) : 0;
2262
- const edgeImplied = (sharpEntry.impliedProbability ?? 0) - (bestSoftEntry.impliedProbability ?? 0);
2263
 
2264
  const row = {
2265
  comparisonKey,
2266
- marketKey: sharpEntry.marketKey,
2267
- playerName: sharpEntry.playerName,
2268
- playerKey: normalizePlayerName(sharpEntry.playerName),
2269
- team: sharpEntry.team ?? bestSoftEntry.team ?? null,
2270
- marketType: normalizeSharpMarketType(sharpEntry.marketType),
2271
- marketLabel: MARKET_TYPE_LABELS[normalizeSharpMarketType(sharpEntry.marketType)] ?? sharpEntry.marketLabel,
2272
  lane,
2273
  laneLabel: getLaneLabel(lane),
2274
- side: sharpEntry.side,
2275
- lineValue: sharpEntry.lineValue ?? null,
2276
- sharpBook: normalizeBookFilter(sharpEntry.book) ?? sharpEntry.book,
2277
- sharpOddsInput: sharpEntry.oddsInput,
2278
- sharpImpliedProbability: sharpEntry.impliedProbability,
2279
- sharpSourceMode: getSharpSourceMode(sharpEntry, lane),
2280
  bestSoftBook: normalizeBookFilter(bestSoftEntry.book) ?? bestSoftEntry.book,
2281
  bestSoftOddsInput: bestSoftEntry.oddsInput,
2282
  bestSoftImpliedProbability: bestSoftEntry.impliedProbability,
2283
  edgeImpliedPct: Number(edgeImplied.toFixed(6)),
2284
- priceDeltaCents: (americanOddsToNumber(bestSoftEntry.oddsInput) ?? 0) - (americanOddsToNumber(sharpEntry.oddsInput) ?? 0),
2285
  marketWidthPct: Number(marketWidth.toFixed(6)),
2286
  booksCompared: uniqueEntries.length,
2287
- marketStatus: getSharpSourceMode(sharpEntry, lane),
2288
- eventName: sharpEntry.eventName ?? bestSoftEntry.eventName ?? null,
2289
- eventCommenceTime: sharpEntry.eventCommenceTime ?? bestSoftEntry.eventCommenceTime ?? null,
2290
  entries: uniqueEntries,
2291
  softEntries,
2292
  };
@@ -2323,6 +2373,7 @@ export function analyzeSharpMarkets(entries, config = {}, options = {}) {
2323
  booksTotal: 0,
2324
  circaRows: 0,
2325
  mgmFallbackRows: 0,
 
2326
  });
2327
  }
2328
  const current = map.get(key);
@@ -2334,6 +2385,8 @@ export function analyzeSharpMarkets(entries, config = {}, options = {}) {
2334
  current.circaRows += 1;
2335
  } else if (row.sharpSourceMode === 'mgm_fallback') {
2336
  current.mgmFallbackRows += 1;
 
 
2337
  }
2338
  return map;
2339
  }, new Map());
@@ -2402,6 +2455,7 @@ function summarizeBooksFromSharpRows(rows) {
2402
  if (!grouped.has(book)) {
2403
  grouped.set(book, {
2404
  book,
 
2405
  comparedRows: 0,
2406
  positiveEdges: 0,
2407
  totalEdgePct: 0,
@@ -4155,15 +4209,23 @@ export class MarketScanner {
4155
  secondaryValue: (row) => `${row.bestOddsInput} -> ${row.worstOddsInput}`,
4156
  })],
4157
  });
4158
- await channel.send({
4159
- embeds: [this.embeds.buildSharpEdgeBoardEmbed('Morning Sharp Board', applyResultLimit(analysis.edgeRows, this.config.marketBoardDefaultLimit))],
4160
- });
4161
- await channel.send({
4162
- embeds: [this.embeds.buildBookScoreboardEmbed('Morning Book Scoreboard', applyResultLimit(summarizeBooksFromSharpRows(analysis.sharpRows), this.config.marketBoardDefaultLimit))],
4163
- });
4164
- await channel.send({
4165
- embeds: [this.embeds.buildMarketHealthEmbed('Morning Market Health', applyResultLimit(analysis.marketHealthRows, this.config.marketBoardDefaultLimit))],
4166
- });
 
 
 
 
 
 
 
 
4167
 
4168
  await this.store.recordScanReport(dateKey, 'morning', this.config.scanReportChannelId, discrepancyMessage.id, widthMessage.id);
4169
  this.status.lastReportAt = new Date().toISOString();
@@ -4255,9 +4317,10 @@ export class MarketScanner {
4255
  if (!prior) {
4256
  continue;
4257
  }
4258
- const currentSharpBook = normalizeBookFilter(state.sharpEntry.book);
 
4259
  const previousSharp = prior.entries.find((entry) => normalizeBookFilter(entry.book) === currentSharpBook);
4260
- if (!previousSharp || previousSharp.oddsInput === state.sharpEntry.oddsInput) {
4261
  continue;
4262
  }
4263
  const currentMoves = buildSharpMovements(state.entries, prior.entries);
@@ -4294,9 +4357,10 @@ export class MarketScanner {
4294
  if (!prior) {
4295
  continue;
4296
  }
4297
- const currentSharpBook = normalizeBookFilter(state.sharpEntry.book);
 
4298
  const previousSharp = prior.entries.find((entry) => normalizeBookFilter(entry.book) === currentSharpBook);
4299
- const sharpMoved = previousSharp && previousSharp.oddsInput !== state.sharpEntry.oddsInput;
4300
  if (sharpMoved) {
4301
  continue;
4302
  }
 
422
  return Number((Math.abs(odds) / (Math.abs(odds) + 100)).toFixed(6));
423
  }
424
 
425
+ function impliedProbabilityToAmericanOdds(impliedProbability) {
426
+ const probability = Number(impliedProbability);
427
+ if (!Number.isFinite(probability) || probability <= 0 || probability >= 1) {
428
+ return null;
429
+ }
430
+
431
+ if (probability >= 0.5) {
432
+ return String(-Math.round((probability / (1 - probability)) * 100));
433
+ }
434
+
435
+ const odds = Math.round(((1 - probability) / probability) * 100);
436
+ return `+${odds}`;
437
+ }
438
+
439
  function normalizeSide({ marketKey, outcomeName, description, point }) {
440
  const name = normalizeWhitespace(outcomeName).toLowerCase();
441
  const desc = normalizeWhitespace(description).toLowerCase();
 
2176
  if (normalizedLane && normalizeLaneFilter(row.lane) !== normalizedLane) {
2177
  return false;
2178
  }
2179
+ if (
2180
+ normalizedBook
2181
+ && normalizeBookFilter(row.bestSoftBook) !== normalizedBook
2182
+ && normalizeBookFilter(row.sharpBook) !== normalizedBook
2183
+ && !row.entries?.some((entry) => normalizeBookFilter(entry.book) === normalizedBook)
2184
+ ) {
2185
  return false;
2186
  }
2187
  if (normalizedMarket && normalizeSharpMarketType(row.marketType) !== normalizedMarket) {
 
2237
  if (normalizedLane === 'dfs') {
2238
  return 'dfs_consensus';
2239
  }
2240
+ return 'exchange_consensus';
2241
  }
2242
 
2243
  export function analyzeSharpMarkets(entries, config = {}, options = {}) {
 
2261
 
2262
  for (const [comparisonKey, groupEntries] of grouped.entries()) {
2263
  const uniqueEntries = collapseEntriesByBook(groupEntries);
2264
+ const allImplied = uniqueEntries.map((entry) => entry.impliedProbability).filter((value) => value !== null && value !== undefined);
2265
+ const marketWidth = allImplied.length > 1 ? Math.max(...allImplied) - Math.min(...allImplied) : 0;
2266
+ let sharpEntry = pickLaneSharpEntry(uniqueEntries, lane);
2267
+ let softEntries = [];
2268
+ let bestSoftEntry = null;
2269
+ let sharpBook = null;
2270
+ let sharpOddsInput = null;
2271
+ let sharpImpliedProbability = null;
2272
+ let sharpSourceMode = null;
2273
+
2274
+ if (lane === 'sportsbook') {
2275
+ if (!sharpEntry) {
2276
+ continue;
2277
+ }
2278
 
2279
+ softEntries = uniqueEntries.filter((entry) => normalizeBookFilter(entry.book) !== normalizeBookFilter(sharpEntry.book));
2280
+ if (softEntries.length === 0) {
2281
+ continue;
2282
+ }
2283
 
2284
+ bestSoftEntry = softEntries.reduce((best, entry) =>
2285
+ (entry.impliedProbability ?? Number.MAX_SAFE_INTEGER) < (best.impliedProbability ?? Number.MAX_SAFE_INTEGER) ? entry : best,
2286
+ softEntries[0]);
2287
+ sharpBook = normalizeBookFilter(sharpEntry.book) ?? sharpEntry.book;
2288
+ sharpOddsInput = sharpEntry.oddsInput;
2289
+ sharpImpliedProbability = sharpEntry.impliedProbability;
2290
+ sharpSourceMode = getSharpSourceMode(sharpEntry, lane);
2291
+ } else {
2292
+ if (uniqueEntries.length < 2 || allImplied.length < 2) {
2293
+ continue;
2294
+ }
2295
 
2296
+ bestSoftEntry = uniqueEntries.reduce((best, entry) =>
2297
+ (entry.impliedProbability ?? Number.MAX_SAFE_INTEGER) < (best.impliedProbability ?? Number.MAX_SAFE_INTEGER) ? entry : best,
2298
+ uniqueEntries[0]);
2299
+ softEntries = uniqueEntries;
2300
+ sharpBook = lane === 'dfs' ? 'DFS Consensus' : 'Exchange Consensus';
2301
+ sharpImpliedProbability = Number((allImplied.reduce((sum, value) => sum + value, 0) / allImplied.length).toFixed(6));
2302
+ sharpOddsInput = impliedProbabilityToAmericanOdds(sharpImpliedProbability) ?? 'N/A';
2303
+ sharpSourceMode = getSharpSourceMode(bestSoftEntry, lane);
2304
+ sharpEntry = {
2305
+ book: sharpBook,
2306
+ oddsInput: sharpOddsInput,
2307
+ impliedProbability: sharpImpliedProbability,
2308
+ eventCommenceTime: bestSoftEntry.eventCommenceTime ?? null,
2309
+ };
2310
+ }
2311
 
2312
+ const edgeImplied = (sharpImpliedProbability ?? 0) - (bestSoftEntry.impliedProbability ?? 0);
 
 
2313
 
2314
  const row = {
2315
  comparisonKey,
2316
+ marketKey: bestSoftEntry.marketKey,
2317
+ playerName: bestSoftEntry.playerName,
2318
+ playerKey: normalizePlayerName(bestSoftEntry.playerName),
2319
+ team: bestSoftEntry.team ?? null,
2320
+ marketType: normalizeSharpMarketType(bestSoftEntry.marketType),
2321
+ marketLabel: MARKET_TYPE_LABELS[normalizeSharpMarketType(bestSoftEntry.marketType)] ?? bestSoftEntry.marketLabel,
2322
  lane,
2323
  laneLabel: getLaneLabel(lane),
2324
+ side: bestSoftEntry.side,
2325
+ lineValue: bestSoftEntry.lineValue ?? null,
2326
+ sharpBook,
2327
+ sharpOddsInput,
2328
+ sharpImpliedProbability,
2329
+ sharpSourceMode,
2330
  bestSoftBook: normalizeBookFilter(bestSoftEntry.book) ?? bestSoftEntry.book,
2331
  bestSoftOddsInput: bestSoftEntry.oddsInput,
2332
  bestSoftImpliedProbability: bestSoftEntry.impliedProbability,
2333
  edgeImpliedPct: Number(edgeImplied.toFixed(6)),
2334
+ priceDeltaCents: (americanOddsToNumber(bestSoftEntry.oddsInput) ?? 0) - (americanOddsToNumber(sharpOddsInput) ?? 0),
2335
  marketWidthPct: Number(marketWidth.toFixed(6)),
2336
  booksCompared: uniqueEntries.length,
2337
+ marketStatus: sharpSourceMode,
2338
+ eventName: bestSoftEntry.eventName ?? null,
2339
+ eventCommenceTime: bestSoftEntry.eventCommenceTime ?? null,
2340
  entries: uniqueEntries,
2341
  softEntries,
2342
  };
 
2373
  booksTotal: 0,
2374
  circaRows: 0,
2375
  mgmFallbackRows: 0,
2376
+ consensusRows: 0,
2377
  });
2378
  }
2379
  const current = map.get(key);
 
2385
  current.circaRows += 1;
2386
  } else if (row.sharpSourceMode === 'mgm_fallback') {
2387
  current.mgmFallbackRows += 1;
2388
+ } else {
2389
+ current.consensusRows += 1;
2390
  }
2391
  return map;
2392
  }, new Map());
 
2455
  if (!grouped.has(book)) {
2456
  grouped.set(book, {
2457
  book,
2458
+ lane: row.lane,
2459
  comparedRows: 0,
2460
  positiveEdges: 0,
2461
  totalEdgePct: 0,
 
4209
  secondaryValue: (row) => `${row.bestOddsInput} -> ${row.worstOddsInput}`,
4210
  })],
4211
  });
4212
+ const laneAnalyses = analysis.laneAnalyses ?? { sportsbook: analysis };
4213
+ for (const lane of ['sportsbook', 'dfs', 'exchange']) {
4214
+ const laneAnalysis = laneAnalyses[lane];
4215
+ if (!laneAnalysis?.sharpRows?.length) {
4216
+ continue;
4217
+ }
4218
+ const laneFilters = { lane };
4219
+ await channel.send({
4220
+ embeds: [this.embeds.buildSharpEdgeBoardEmbed('Morning Sharp Board', applyResultLimit(laneAnalysis.edgeRows, this.config.marketBoardDefaultLimit), laneFilters)],
4221
+ });
4222
+ await channel.send({
4223
+ embeds: [this.embeds.buildBookScoreboardEmbed('Morning Book Scoreboard', applyResultLimit(summarizeBooksFromSharpRows(laneAnalysis.sharpRows), this.config.marketBoardDefaultLimit), laneFilters)],
4224
+ });
4225
+ await channel.send({
4226
+ embeds: [this.embeds.buildMarketHealthEmbed('Morning Market Health', applyResultLimit(laneAnalysis.marketHealthRows, this.config.marketBoardDefaultLimit), laneFilters)],
4227
+ });
4228
+ }
4229
 
4230
  await this.store.recordScanReport(dateKey, 'morning', this.config.scanReportChannelId, discrepancyMessage.id, widthMessage.id);
4231
  this.status.lastReportAt = new Date().toISOString();
 
4317
  if (!prior) {
4318
  continue;
4319
  }
4320
+ const currentReferenceEntry = lane === 'sportsbook' ? state.sharpEntry : state.bestSoftEntry;
4321
+ const currentSharpBook = normalizeBookFilter(currentReferenceEntry?.book);
4322
  const previousSharp = prior.entries.find((entry) => normalizeBookFilter(entry.book) === currentSharpBook);
4323
+ if (!previousSharp || previousSharp.oddsInput === currentReferenceEntry?.oddsInput) {
4324
  continue;
4325
  }
4326
  const currentMoves = buildSharpMovements(state.entries, prior.entries);
 
4357
  if (!prior) {
4358
  continue;
4359
  }
4360
+ const currentReferenceEntry = lane === 'sportsbook' ? state.sharpEntry : state.bestSoftEntry;
4361
+ const currentSharpBook = normalizeBookFilter(currentReferenceEntry?.book);
4362
  const previousSharp = prior.entries.find((entry) => normalizeBookFilter(entry.book) === currentSharpBook);
4363
+ const sharpMoved = previousSharp && previousSharp.oddsInput !== currentReferenceEntry?.oddsInput;
4364
  if (sharpMoved) {
4365
  continue;
4366
  }
test/alerts.test.js CHANGED
@@ -6,6 +6,7 @@ import {
6
  buildAlertsEmbed,
7
  buildCommandsEmbed,
8
  buildAlertRoleButtonId,
 
9
  buildReverseAlertEmbed,
10
  parseAlertRoleButtonId,
11
  } from '../src/embeds.js';
@@ -19,9 +20,31 @@ test('registers the alerts slash command', () => {
19
  test('lists alerts in the public commands embed', () => {
20
  const embed = buildCommandsEmbed().toJSON();
21
  const alertsField = embed.fields.find((field) => field.name === '/alerts');
 
 
22
 
23
  assert.ok(alertsField);
24
  assert.match(alertsField.value, /jew_olympics/i);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  });
26
 
27
  test('builds the alerts embed with all analyst lines', () => {
 
6
  buildAlertsEmbed,
7
  buildCommandsEmbed,
8
  buildAlertRoleButtonId,
9
+ buildMarketHealthEmbed,
10
  buildReverseAlertEmbed,
11
  parseAlertRoleButtonId,
12
  } from '../src/embeds.js';
 
20
  test('lists alerts in the public commands embed', () => {
21
  const embed = buildCommandsEmbed().toJSON();
22
  const alertsField = embed.fields.find((field) => field.name === '/alerts');
23
+ const sharpCommandsField = embed.fields.find((field) => field.name === 'Sharp Market Commands');
24
+ const otherOddsField = embed.fields.find((field) => field.name === 'Other Odds Commands');
25
 
26
  assert.ok(alertsField);
27
  assert.match(alertsField.value, /jew_olympics/i);
28
+ assert.ok(sharpCommandsField);
29
+ assert.match(sharpCommandsField.value, /dfsedgeboard/i);
30
+ assert.match(sharpCommandsField.value, /exchangeedgeboard/i);
31
+ assert.ok(otherOddsField);
32
+ assert.match(otherOddsField.value, /dfsodds/i);
33
+ assert.match(otherOddsField.value, /exchangeodds/i);
34
+ });
35
+
36
+ test('market health embed uses lane-appropriate reference language', () => {
37
+ const embed = buildMarketHealthEmbed('Market Health', [{
38
+ marketLabel: 'Hits',
39
+ lane: 'dfs',
40
+ rows: 4,
41
+ averageBooksCompared: 3.5,
42
+ averageWidthPct: 0.041,
43
+ consensusRows: 4,
44
+ }], { lane: 'dfs' }).toJSON();
45
+
46
+ assert.match(embed.fields[0].value, /DFS consensus baseline/i);
47
+ assert.doesNotMatch(embed.fields[0].value, /MGM fallback/i);
48
  });
49
 
50
  test('builds the alerts embed with all analyst lines', () => {
test/market-scanner.test.js CHANGED
@@ -912,12 +912,13 @@ test('sharp analysis keeps sportsbook, dfs, and exchange comparisons in separate
912
  assert.equal(sportsbookRows.sharpRows[0].lane, 'sportsbook');
913
 
914
  assert.equal(dfsRows.sharpRows.length, 1);
915
- assert.equal(dfsRows.sharpRows[0].sharpBook, 'Underdog Fantasy');
916
  assert.equal(dfsRows.sharpRows[0].sharpSourceMode, 'dfs_consensus');
917
 
918
  assert.equal(exchangeRows.sharpRows.length, 1);
919
- assert.equal(exchangeRows.sharpRows[0].sharpBook, 'Novig');
920
- assert.equal(exchangeRows.sharpRows[0].sharpSourceMode, 'exchange_best');
 
921
  });
922
 
923
  test('circa board reposts when a new same-day snapshot appears', async () => {
 
912
  assert.equal(sportsbookRows.sharpRows[0].lane, 'sportsbook');
913
 
914
  assert.equal(dfsRows.sharpRows.length, 1);
915
+ assert.equal(dfsRows.sharpRows[0].sharpBook, 'DFS Consensus');
916
  assert.equal(dfsRows.sharpRows[0].sharpSourceMode, 'dfs_consensus');
917
 
918
  assert.equal(exchangeRows.sharpRows.length, 1);
919
+ assert.equal(exchangeRows.sharpRows[0].sharpBook, 'Exchange Consensus');
920
+ assert.equal(exchangeRows.sharpRows[0].sharpSourceMode, 'exchange_consensus');
921
+ assert.equal(exchangeRows.sharpRows[0].bestSoftBook, 'Novig');
922
  });
923
 
924
  test('circa board reposts when a new same-day snapshot appears', async () => {