Codex commited on
Commit ·
bf291c4
1
Parent(s): 50856b4
Finish dfs and exchange odds lane rollout
Browse files- src/embeds.js +21 -15
- src/market-scanner.js +109 -45
- test/alerts.test.js +23 -0
- test/market-scanner.test.js +4 -3
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`
|
| 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
|
| 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 |
-
`
|
| 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 |
-
`
|
| 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 |
-
`
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
`
|
| 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:
|
| 694 |
-
{ name: 'Best
|
| 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: '
|
| 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:
|
| 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: '
|
| 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:
|
| 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
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 '
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2246 |
|
| 2247 |
-
|
| 2248 |
-
|
| 2249 |
-
|
|
|
|
| 2250 |
|
| 2251 |
-
|
| 2252 |
-
|
| 2253 |
-
|
| 2254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2255 |
|
| 2256 |
-
|
| 2257 |
-
|
| 2258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2259 |
|
| 2260 |
-
const
|
| 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:
|
| 2267 |
-
playerName:
|
| 2268 |
-
playerKey: normalizePlayerName(
|
| 2269 |
-
team:
|
| 2270 |
-
marketType: normalizeSharpMarketType(
|
| 2271 |
-
marketLabel: MARKET_TYPE_LABELS[normalizeSharpMarketType(
|
| 2272 |
lane,
|
| 2273 |
laneLabel: getLaneLabel(lane),
|
| 2274 |
-
side:
|
| 2275 |
-
lineValue:
|
| 2276 |
-
sharpBook
|
| 2277 |
-
sharpOddsInput
|
| 2278 |
-
sharpImpliedProbability
|
| 2279 |
-
sharpSourceMode
|
| 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(
|
| 2285 |
marketWidthPct: Number(marketWidth.toFixed(6)),
|
| 2286 |
booksCompared: uniqueEntries.length,
|
| 2287 |
-
marketStatus:
|
| 2288 |
-
eventName:
|
| 2289 |
-
eventCommenceTime:
|
| 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 |
-
|
| 4159 |
-
|
| 4160 |
-
|
| 4161 |
-
|
| 4162 |
-
|
| 4163 |
-
|
| 4164 |
-
|
| 4165 |
-
|
| 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
|
|
|
|
| 4259 |
const previousSharp = prior.entries.find((entry) => normalizeBookFilter(entry.book) === currentSharpBook);
|
| 4260 |
-
if (!previousSharp || previousSharp.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
|
|
|
|
| 4298 |
const previousSharp = prior.entries.find((entry) => normalizeBookFilter(entry.book) === currentSharpBook);
|
| 4299 |
-
const sharpMoved = previousSharp && previousSharp.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, '
|
| 916 |
assert.equal(dfsRows.sharpRows[0].sharpSourceMode, 'dfs_consensus');
|
| 917 |
|
| 918 |
assert.equal(exchangeRows.sharpRows.length, 1);
|
| 919 |
-
assert.equal(exchangeRows.sharpRows[0].sharpBook, '
|
| 920 |
-
assert.equal(exchangeRows.sharpRows[0].sharpSourceMode, '
|
|
|
|
| 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 () => {
|