Codex commited on
Commit ·
5488d4e
1
Parent(s): 48b2af6
Add sharp market intelligence commands
Browse files- src/commands.js +144 -0
- src/config.js +14 -0
- src/db.js +89 -0
- src/embeds.js +210 -0
- src/index.js +153 -0
- src/market-scanner.js +562 -2
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = [];
|