Codex commited on
Commit ·
d1c5d0f
1
Parent(s): b548c5a
Tighten lane minimum book thresholds
Browse files- src/config.js +4 -4
- src/market-scanner.js +12 -2
- test/market-scanner.test.js +269 -8
src/config.js
CHANGED
|
@@ -25,21 +25,21 @@ export function getConfig() {
|
|
| 25 |
const scanMorningTime = process.env.SCAN_MORNING_TIME?.trim() || '08:00';
|
| 26 |
const scanMorningFallbackTime = process.env.SCAN_MORNING_FALLBACK_TIME?.trim() || '09:45';
|
| 27 |
const scanTimeZone = process.env.SCAN_TIMEZONE?.trim() || 'America/Chicago';
|
| 28 |
-
const scanMinBooks = Number(process.env.SCAN_MIN_BOOKS ||
|
| 29 |
const scanDisagreementThreshold = Number(process.env.SCAN_DISAGREEMENT_THRESHOLD || 0.08);
|
| 30 |
const scanAlertCooldownMinutes = Number(process.env.SCAN_ALERT_COOLDOWN_MINUTES || 180);
|
| 31 |
const scanFrequencyMinutes = Number(process.env.SCAN_FREQUENCY_MINUTES || 15);
|
| 32 |
const scanHttpTimeoutMs = Number(process.env.SCAN_HTTP_TIMEOUT_MS || 15000);
|
| 33 |
const directOddsCacheTtlMs = Number(process.env.DIRECT_ODDS_CACHE_TTL_MS || 60000);
|
| 34 |
const sharpEdgeAlertThreshold = Number(process.env.SHARP_EDGE_ALERT_THRESHOLD || 0.04);
|
| 35 |
-
const sharpEdgeAlertMinBooks = Number(process.env.SHARP_EDGE_ALERT_MIN_BOOKS ||
|
| 36 |
const staleBookAlertThreshold = Number(process.env.STALE_BOOK_ALERT_THRESHOLD || 0.025);
|
| 37 |
const reverseAlertThreshold = Number(process.env.REVERSE_ALERT_THRESHOLD || 0.03);
|
| 38 |
-
const dfsScanMinBooks = Number(process.env.DFS_SCAN_MIN_BOOKS ||
|
| 39 |
const dfsEdgeAlertThreshold = Number(process.env.DFS_EDGE_ALERT_THRESHOLD || sharpEdgeAlertThreshold);
|
| 40 |
const dfsReverseAlertThreshold = Number(process.env.DFS_REVERSE_ALERT_THRESHOLD || reverseAlertThreshold);
|
| 41 |
const dfsStaleAlertThreshold = Number(process.env.DFS_STALE_ALERT_THRESHOLD || staleBookAlertThreshold);
|
| 42 |
-
const exchangeScanMinBooks = Number(process.env.EXCHANGE_SCAN_MIN_BOOKS ||
|
| 43 |
const exchangeEdgeAlertThreshold = Number(process.env.EXCHANGE_EDGE_ALERT_THRESHOLD || sharpEdgeAlertThreshold);
|
| 44 |
const exchangeReverseAlertThreshold = Number(process.env.EXCHANGE_REVERSE_ALERT_THRESHOLD || reverseAlertThreshold);
|
| 45 |
const exchangeStaleAlertThreshold = Number(process.env.EXCHANGE_STALE_ALERT_THRESHOLD || staleBookAlertThreshold);
|
|
|
|
| 25 |
const scanMorningTime = process.env.SCAN_MORNING_TIME?.trim() || '08:00';
|
| 26 |
const scanMorningFallbackTime = process.env.SCAN_MORNING_FALLBACK_TIME?.trim() || '09:45';
|
| 27 |
const scanTimeZone = process.env.SCAN_TIMEZONE?.trim() || 'America/Chicago';
|
| 28 |
+
const scanMinBooks = Number(process.env.SCAN_MIN_BOOKS || 6);
|
| 29 |
const scanDisagreementThreshold = Number(process.env.SCAN_DISAGREEMENT_THRESHOLD || 0.08);
|
| 30 |
const scanAlertCooldownMinutes = Number(process.env.SCAN_ALERT_COOLDOWN_MINUTES || 180);
|
| 31 |
const scanFrequencyMinutes = Number(process.env.SCAN_FREQUENCY_MINUTES || 15);
|
| 32 |
const scanHttpTimeoutMs = Number(process.env.SCAN_HTTP_TIMEOUT_MS || 15000);
|
| 33 |
const directOddsCacheTtlMs = Number(process.env.DIRECT_ODDS_CACHE_TTL_MS || 60000);
|
| 34 |
const sharpEdgeAlertThreshold = Number(process.env.SHARP_EDGE_ALERT_THRESHOLD || 0.04);
|
| 35 |
+
const sharpEdgeAlertMinBooks = Number(process.env.SHARP_EDGE_ALERT_MIN_BOOKS || 6);
|
| 36 |
const staleBookAlertThreshold = Number(process.env.STALE_BOOK_ALERT_THRESHOLD || 0.025);
|
| 37 |
const reverseAlertThreshold = Number(process.env.REVERSE_ALERT_THRESHOLD || 0.03);
|
| 38 |
+
const dfsScanMinBooks = Number(process.env.DFS_SCAN_MIN_BOOKS || 4);
|
| 39 |
const dfsEdgeAlertThreshold = Number(process.env.DFS_EDGE_ALERT_THRESHOLD || sharpEdgeAlertThreshold);
|
| 40 |
const dfsReverseAlertThreshold = Number(process.env.DFS_REVERSE_ALERT_THRESHOLD || reverseAlertThreshold);
|
| 41 |
const dfsStaleAlertThreshold = Number(process.env.DFS_STALE_ALERT_THRESHOLD || staleBookAlertThreshold);
|
| 42 |
+
const exchangeScanMinBooks = Number(process.env.EXCHANGE_SCAN_MIN_BOOKS || 4);
|
| 43 |
const exchangeEdgeAlertThreshold = Number(process.env.EXCHANGE_EDGE_ALERT_THRESHOLD || sharpEdgeAlertThreshold);
|
| 44 |
const exchangeReverseAlertThreshold = Number(process.env.EXCHANGE_REVERSE_ALERT_THRESHOLD || reverseAlertThreshold);
|
| 45 |
const exchangeStaleAlertThreshold = Number(process.env.EXCHANGE_STALE_ALERT_THRESHOLD || staleBookAlertThreshold);
|
src/market-scanner.js
CHANGED
|
@@ -2249,6 +2249,7 @@ function getSharpSourceMode(entry, lane = 'sportsbook') {
|
|
| 2249 |
|
| 2250 |
export function analyzeSharpMarkets(entries, config = {}, options = {}) {
|
| 2251 |
const lane = normalizeLaneFilter(options.lane);
|
|
|
|
| 2252 |
const grouped = new Map();
|
| 2253 |
|
| 2254 |
for (const entry of entries) {
|
|
@@ -2268,7 +2269,13 @@ export function analyzeSharpMarkets(entries, config = {}, options = {}) {
|
|
| 2268 |
|
| 2269 |
for (const [comparisonKey, groupEntries] of grouped.entries()) {
|
| 2270 |
const uniqueEntries = collapseEntriesByBook(groupEntries);
|
|
|
|
|
|
|
|
|
|
| 2271 |
const allImplied = uniqueEntries.map((entry) => entry.impliedProbability).filter((value) => value !== null && value !== undefined);
|
|
|
|
|
|
|
|
|
|
| 2272 |
const marketWidth = allImplied.length > 1 ? Math.max(...allImplied) - Math.min(...allImplied) : 0;
|
| 2273 |
let sharpEntry = pickLaneSharpEntry(uniqueEntries, lane);
|
| 2274 |
let softEntries = [];
|
|
@@ -2296,7 +2303,7 @@ export function analyzeSharpMarkets(entries, config = {}, options = {}) {
|
|
| 2296 |
sharpImpliedProbability = sharpEntry.impliedProbability;
|
| 2297 |
sharpSourceMode = getSharpSourceMode(sharpEntry, lane);
|
| 2298 |
} else {
|
| 2299 |
-
if (uniqueEntries.length <
|
| 2300 |
continue;
|
| 2301 |
}
|
| 2302 |
|
|
@@ -4288,7 +4295,10 @@ export class MarketScanner {
|
|
| 4288 |
continue;
|
| 4289 |
}
|
| 4290 |
const minBooks = lane === 'sportsbook'
|
| 4291 |
-
?
|
|
|
|
|
|
|
|
|
|
| 4292 |
: getLaneMinBooks(this.config, lane);
|
| 4293 |
const edgeThreshold = getLaneThreshold(this.config, lane, 'edge');
|
| 4294 |
for (const row of laneAnalysis.edgeRows) {
|
|
|
|
| 2249 |
|
| 2250 |
export function analyzeSharpMarkets(entries, config = {}, options = {}) {
|
| 2251 |
const lane = normalizeLaneFilter(options.lane);
|
| 2252 |
+
const laneMinBooks = getLaneMinBooks(config, lane);
|
| 2253 |
const grouped = new Map();
|
| 2254 |
|
| 2255 |
for (const entry of entries) {
|
|
|
|
| 2269 |
|
| 2270 |
for (const [comparisonKey, groupEntries] of grouped.entries()) {
|
| 2271 |
const uniqueEntries = collapseEntriesByBook(groupEntries);
|
| 2272 |
+
if (uniqueEntries.length < laneMinBooks) {
|
| 2273 |
+
continue;
|
| 2274 |
+
}
|
| 2275 |
const allImplied = uniqueEntries.map((entry) => entry.impliedProbability).filter((value) => value !== null && value !== undefined);
|
| 2276 |
+
if (allImplied.length < laneMinBooks) {
|
| 2277 |
+
continue;
|
| 2278 |
+
}
|
| 2279 |
const marketWidth = allImplied.length > 1 ? Math.max(...allImplied) - Math.min(...allImplied) : 0;
|
| 2280 |
let sharpEntry = pickLaneSharpEntry(uniqueEntries, lane);
|
| 2281 |
let softEntries = [];
|
|
|
|
| 2303 |
sharpImpliedProbability = sharpEntry.impliedProbability;
|
| 2304 |
sharpSourceMode = getSharpSourceMode(sharpEntry, lane);
|
| 2305 |
} else {
|
| 2306 |
+
if (uniqueEntries.length < laneMinBooks || allImplied.length < laneMinBooks) {
|
| 2307 |
continue;
|
| 2308 |
}
|
| 2309 |
|
|
|
|
| 4295 |
continue;
|
| 4296 |
}
|
| 4297 |
const minBooks = lane === 'sportsbook'
|
| 4298 |
+
? Math.max(
|
| 4299 |
+
Number(this.config.sharpEdgeAlertMinBooks ?? 6),
|
| 4300 |
+
getLaneMinBooks(this.config, lane),
|
| 4301 |
+
)
|
| 4302 |
: getLaneMinBooks(this.config, lane);
|
| 4303 |
const edgeThreshold = getLaneThreshold(this.config, lane, 'edge');
|
| 4304 |
for (const row of laneAnalysis.edgeRows) {
|
test/market-scanner.test.js
CHANGED
|
@@ -630,7 +630,7 @@ test('movement alerts skip games that have already started', async () => {
|
|
| 630 |
assert.equal(sentPayloads.length, 0);
|
| 631 |
});
|
| 632 |
|
| 633 |
-
test('sharp edge alerts require pregame rows and at least
|
| 634 |
const sentPayloads = [];
|
| 635 |
const scanner = new MarketScanner({
|
| 636 |
client: {
|
|
@@ -660,7 +660,7 @@ test('sharp edge alerts require pregame rows and at least five books compared',
|
|
| 660 |
scanAlertChannelId: 'alerts',
|
| 661 |
sharpEdgeAlertsEnabled: true,
|
| 662 |
sharpEdgeAlertThreshold: 0.04,
|
| 663 |
-
sharpEdgeAlertMinBooks:
|
| 664 |
scanAlertCooldownMinutes: 0,
|
| 665 |
staleBookAlertsEnabled: false,
|
| 666 |
reverseAlertsEnabled: false,
|
|
@@ -716,7 +716,7 @@ test('sharp edge alerts require pregame rows and at least five books compared',
|
|
| 716 |
sharpOddsInput: '+390',
|
| 717 |
bestSoftOddsInput: '+500',
|
| 718 |
edgeImpliedPct: 0.061,
|
| 719 |
-
booksCompared:
|
| 720 |
eventCommenceTime: '2099-04-08T00:00:00.000Z',
|
| 721 |
entries: [],
|
| 722 |
},
|
|
@@ -800,7 +800,7 @@ test('sharp analysis prefers Circa, then FanDuel, then BetMGM for sportsbook ref
|
|
| 800 |
oddsInput: '300',
|
| 801 |
impliedProbability: americanToImpliedProbability('300'),
|
| 802 |
},
|
| 803 |
-
]);
|
| 804 |
|
| 805 |
assert.equal(rows.sharpRows.length, 2);
|
| 806 |
|
|
@@ -845,6 +845,62 @@ test('sharp analysis keeps sportsbook, dfs, and exchange comparisons in separate
|
|
| 845 |
oddsInput: '+140',
|
| 846 |
impliedProbability: americanToImpliedProbability('+140'),
|
| 847 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 848 |
{
|
| 849 |
marketKey: 'judge-hit',
|
| 850 |
source: 'odds_api',
|
|
@@ -873,6 +929,34 @@ test('sharp analysis keeps sportsbook, dfs, and exchange comparisons in separate
|
|
| 873 |
oddsInput: '+125',
|
| 874 |
impliedProbability: americanToImpliedProbability('+125'),
|
| 875 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
{
|
| 877 |
marketKey: 'judge-hit',
|
| 878 |
source: 'odds_api',
|
|
@@ -901,11 +985,40 @@ test('sharp analysis keeps sportsbook, dfs, and exchange comparisons in separate
|
|
| 901 |
oddsInput: '+130',
|
| 902 |
impliedProbability: americanToImpliedProbability('+130'),
|
| 903 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
];
|
| 905 |
|
| 906 |
-
const
|
| 907 |
-
const
|
| 908 |
-
const
|
|
|
|
| 909 |
|
| 910 |
assert.equal(sportsbookRows.sharpRows.length, 1);
|
| 911 |
assert.equal(sportsbookRows.sharpRows[0].sharpBook, 'Circa');
|
|
@@ -918,7 +1031,155 @@ test('sharp analysis keeps sportsbook, dfs, and exchange comparisons in separate
|
|
| 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, '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
});
|
| 923 |
|
| 924 |
test('circa board reposts when a new same-day snapshot appears', async () => {
|
|
|
|
| 630 |
assert.equal(sentPayloads.length, 0);
|
| 631 |
});
|
| 632 |
|
| 633 |
+
test('sharp edge alerts require pregame rows and at least six books compared', async () => {
|
| 634 |
const sentPayloads = [];
|
| 635 |
const scanner = new MarketScanner({
|
| 636 |
client: {
|
|
|
|
| 660 |
scanAlertChannelId: 'alerts',
|
| 661 |
sharpEdgeAlertsEnabled: true,
|
| 662 |
sharpEdgeAlertThreshold: 0.04,
|
| 663 |
+
sharpEdgeAlertMinBooks: 6,
|
| 664 |
scanAlertCooldownMinutes: 0,
|
| 665 |
staleBookAlertsEnabled: false,
|
| 666 |
reverseAlertsEnabled: false,
|
|
|
|
| 716 |
sharpOddsInput: '+390',
|
| 717 |
bestSoftOddsInput: '+500',
|
| 718 |
edgeImpliedPct: 0.061,
|
| 719 |
+
booksCompared: 6,
|
| 720 |
eventCommenceTime: '2099-04-08T00:00:00.000Z',
|
| 721 |
entries: [],
|
| 722 |
},
|
|
|
|
| 800 |
oddsInput: '300',
|
| 801 |
impliedProbability: americanToImpliedProbability('300'),
|
| 802 |
},
|
| 803 |
+
], { scanMinBooks: 2 });
|
| 804 |
|
| 805 |
assert.equal(rows.sharpRows.length, 2);
|
| 806 |
|
|
|
|
| 845 |
oddsInput: '+140',
|
| 846 |
impliedProbability: americanToImpliedProbability('+140'),
|
| 847 |
},
|
| 848 |
+
{
|
| 849 |
+
marketKey: 'judge-hit',
|
| 850 |
+
source: 'odds_api',
|
| 851 |
+
book: 'DraftKings',
|
| 852 |
+
lane: 'sportsbook',
|
| 853 |
+
playerName: 'Aaron Judge',
|
| 854 |
+
team: 'NYY',
|
| 855 |
+
marketType: 'batter_hits',
|
| 856 |
+
marketLabel: 'Hits',
|
| 857 |
+
side: 'yes',
|
| 858 |
+
lineValue: 0.5,
|
| 859 |
+
oddsInput: '+142',
|
| 860 |
+
impliedProbability: americanToImpliedProbability('+142'),
|
| 861 |
+
},
|
| 862 |
+
{
|
| 863 |
+
marketKey: 'judge-hit',
|
| 864 |
+
source: 'odds_api',
|
| 865 |
+
book: 'BetMGM',
|
| 866 |
+
lane: 'sportsbook',
|
| 867 |
+
playerName: 'Aaron Judge',
|
| 868 |
+
team: 'NYY',
|
| 869 |
+
marketType: 'batter_hits',
|
| 870 |
+
marketLabel: 'Hits',
|
| 871 |
+
side: 'yes',
|
| 872 |
+
lineValue: 0.5,
|
| 873 |
+
oddsInput: '+145',
|
| 874 |
+
impliedProbability: americanToImpliedProbability('+145'),
|
| 875 |
+
},
|
| 876 |
+
{
|
| 877 |
+
marketKey: 'judge-hit',
|
| 878 |
+
source: 'odds_api',
|
| 879 |
+
book: 'Caesars',
|
| 880 |
+
lane: 'sportsbook',
|
| 881 |
+
playerName: 'Aaron Judge',
|
| 882 |
+
team: 'NYY',
|
| 883 |
+
marketType: 'batter_hits',
|
| 884 |
+
marketLabel: 'Hits',
|
| 885 |
+
side: 'yes',
|
| 886 |
+
lineValue: 0.5,
|
| 887 |
+
oddsInput: '+150',
|
| 888 |
+
impliedProbability: americanToImpliedProbability('+150'),
|
| 889 |
+
},
|
| 890 |
+
{
|
| 891 |
+
marketKey: 'judge-hit',
|
| 892 |
+
source: 'odds_api',
|
| 893 |
+
book: 'FanDuel',
|
| 894 |
+
lane: 'sportsbook',
|
| 895 |
+
playerName: 'Aaron Judge',
|
| 896 |
+
team: 'NYY',
|
| 897 |
+
marketType: 'batter_hits',
|
| 898 |
+
marketLabel: 'Hits',
|
| 899 |
+
side: 'yes',
|
| 900 |
+
lineValue: 0.5,
|
| 901 |
+
oddsInput: '+155',
|
| 902 |
+
impliedProbability: americanToImpliedProbability('+155'),
|
| 903 |
+
},
|
| 904 |
{
|
| 905 |
marketKey: 'judge-hit',
|
| 906 |
source: 'odds_api',
|
|
|
|
| 929 |
oddsInput: '+125',
|
| 930 |
impliedProbability: americanToImpliedProbability('+125'),
|
| 931 |
},
|
| 932 |
+
{
|
| 933 |
+
marketKey: 'judge-hit',
|
| 934 |
+
source: 'odds_api',
|
| 935 |
+
book: 'Betr Picks',
|
| 936 |
+
lane: 'dfs',
|
| 937 |
+
playerName: 'Aaron Judge',
|
| 938 |
+
team: 'NYY',
|
| 939 |
+
marketType: 'batter_hits',
|
| 940 |
+
marketLabel: 'Hits',
|
| 941 |
+
side: 'yes',
|
| 942 |
+
lineValue: 0.5,
|
| 943 |
+
oddsInput: '+118',
|
| 944 |
+
impliedProbability: americanToImpliedProbability('+118'),
|
| 945 |
+
},
|
| 946 |
+
{
|
| 947 |
+
marketKey: 'judge-hit',
|
| 948 |
+
source: 'odds_api',
|
| 949 |
+
book: 'DraftKings Pick6',
|
| 950 |
+
lane: 'dfs',
|
| 951 |
+
playerName: 'Aaron Judge',
|
| 952 |
+
team: 'NYY',
|
| 953 |
+
marketType: 'batter_hits',
|
| 954 |
+
marketLabel: 'Hits',
|
| 955 |
+
side: 'yes',
|
| 956 |
+
lineValue: 0.5,
|
| 957 |
+
oddsInput: '+132',
|
| 958 |
+
impliedProbability: americanToImpliedProbability('+132'),
|
| 959 |
+
},
|
| 960 |
{
|
| 961 |
marketKey: 'judge-hit',
|
| 962 |
source: 'odds_api',
|
|
|
|
| 985 |
oddsInput: '+130',
|
| 986 |
impliedProbability: americanToImpliedProbability('+130'),
|
| 987 |
},
|
| 988 |
+
{
|
| 989 |
+
marketKey: 'judge-hit',
|
| 990 |
+
source: 'odds_api',
|
| 991 |
+
book: 'Polymarket',
|
| 992 |
+
lane: 'exchange',
|
| 993 |
+
playerName: 'Aaron Judge',
|
| 994 |
+
team: 'NYY',
|
| 995 |
+
marketType: 'batter_hits',
|
| 996 |
+
marketLabel: 'Hits',
|
| 997 |
+
side: 'yes',
|
| 998 |
+
lineValue: 0.5,
|
| 999 |
+
oddsInput: '+128',
|
| 1000 |
+
impliedProbability: americanToImpliedProbability('+128'),
|
| 1001 |
+
},
|
| 1002 |
+
{
|
| 1003 |
+
marketKey: 'judge-hit',
|
| 1004 |
+
source: 'odds_api',
|
| 1005 |
+
book: 'ProphetX',
|
| 1006 |
+
lane: 'exchange',
|
| 1007 |
+
playerName: 'Aaron Judge',
|
| 1008 |
+
team: 'NYY',
|
| 1009 |
+
marketType: 'batter_hits',
|
| 1010 |
+
marketLabel: 'Hits',
|
| 1011 |
+
side: 'yes',
|
| 1012 |
+
lineValue: 0.5,
|
| 1013 |
+
oddsInput: '+135',
|
| 1014 |
+
impliedProbability: americanToImpliedProbability('+135'),
|
| 1015 |
+
},
|
| 1016 |
];
|
| 1017 |
|
| 1018 |
+
const config = { scanMinBooks: 6, dfsScanMinBooks: 4, exchangeScanMinBooks: 4 };
|
| 1019 |
+
const sportsbookRows = analyzeSharpMarkets(entries, config, { lane: 'sportsbook' });
|
| 1020 |
+
const dfsRows = analyzeSharpMarkets(entries, config, { lane: 'dfs' });
|
| 1021 |
+
const exchangeRows = analyzeSharpMarkets(entries, config, { lane: 'exchange' });
|
| 1022 |
|
| 1023 |
assert.equal(sportsbookRows.sharpRows.length, 1);
|
| 1024 |
assert.equal(sportsbookRows.sharpRows[0].sharpBook, 'Circa');
|
|
|
|
| 1031 |
assert.equal(exchangeRows.sharpRows.length, 1);
|
| 1032 |
assert.equal(exchangeRows.sharpRows[0].sharpBook, 'Exchange Consensus');
|
| 1033 |
assert.equal(exchangeRows.sharpRows[0].sharpSourceMode, 'exchange_consensus');
|
| 1034 |
+
assert.equal(exchangeRows.sharpRows[0].bestSoftBook, 'ProphetX');
|
| 1035 |
+
});
|
| 1036 |
+
|
| 1037 |
+
test('sharp analysis excludes rows below sportsbook, dfs, and exchange minimum book counts', () => {
|
| 1038 |
+
const config = { scanMinBooks: 6, dfsScanMinBooks: 4, exchangeScanMinBooks: 4 };
|
| 1039 |
+
const entries = [
|
| 1040 |
+
{
|
| 1041 |
+
marketKey: 'eligible-sportsbook',
|
| 1042 |
+
source: 'circa',
|
| 1043 |
+
book: 'Circa',
|
| 1044 |
+
lane: 'sportsbook',
|
| 1045 |
+
playerName: 'Aaron Judge',
|
| 1046 |
+
team: 'NYY',
|
| 1047 |
+
marketType: 'hits',
|
| 1048 |
+
marketLabel: 'Hits',
|
| 1049 |
+
side: 'yes',
|
| 1050 |
+
lineValue: 0.5,
|
| 1051 |
+
oddsInput: '+120',
|
| 1052 |
+
impliedProbability: americanToImpliedProbability('+120'),
|
| 1053 |
+
},
|
| 1054 |
+
...[
|
| 1055 |
+
['FanDuel', '+125'],
|
| 1056 |
+
['DraftKings', '+130'],
|
| 1057 |
+
['BetMGM', '+135'],
|
| 1058 |
+
['Caesars', '+140'],
|
| 1059 |
+
['Fanatics', '+145'],
|
| 1060 |
+
].map(([book, oddsInput]) => ({
|
| 1061 |
+
marketKey: 'eligible-sportsbook',
|
| 1062 |
+
source: 'odds_api',
|
| 1063 |
+
book,
|
| 1064 |
+
lane: 'sportsbook',
|
| 1065 |
+
playerName: 'Aaron Judge',
|
| 1066 |
+
team: 'NYY',
|
| 1067 |
+
marketType: 'batter_hits',
|
| 1068 |
+
marketLabel: 'Hits',
|
| 1069 |
+
side: 'yes',
|
| 1070 |
+
lineValue: 0.5,
|
| 1071 |
+
oddsInput,
|
| 1072 |
+
impliedProbability: americanToImpliedProbability(oddsInput),
|
| 1073 |
+
})),
|
| 1074 |
+
...[
|
| 1075 |
+
['Circa', '+150'],
|
| 1076 |
+
['FanDuel', '+155'],
|
| 1077 |
+
['DraftKings', '+160'],
|
| 1078 |
+
['BetMGM', '+165'],
|
| 1079 |
+
['Caesars', '+170'],
|
| 1080 |
+
].map(([book, oddsInput]) => ({
|
| 1081 |
+
marketKey: 'thin-sportsbook',
|
| 1082 |
+
source: book === 'Circa' ? 'circa' : 'odds_api',
|
| 1083 |
+
book,
|
| 1084 |
+
lane: 'sportsbook',
|
| 1085 |
+
playerName: 'Juan Soto',
|
| 1086 |
+
team: 'NYM',
|
| 1087 |
+
marketType: 'batter_hits',
|
| 1088 |
+
marketLabel: 'Hits',
|
| 1089 |
+
side: 'yes',
|
| 1090 |
+
lineValue: 0.5,
|
| 1091 |
+
oddsInput,
|
| 1092 |
+
impliedProbability: americanToImpliedProbability(oddsInput),
|
| 1093 |
+
})),
|
| 1094 |
+
...[
|
| 1095 |
+
['PrizePicks', '+110'],
|
| 1096 |
+
['Underdog Fantasy', '+115'],
|
| 1097 |
+
['Betr Picks', '+120'],
|
| 1098 |
+
['DraftKings Pick6', '+125'],
|
| 1099 |
+
].map(([book, oddsInput]) => ({
|
| 1100 |
+
marketKey: 'eligible-dfs',
|
| 1101 |
+
source: 'odds_api',
|
| 1102 |
+
book,
|
| 1103 |
+
lane: 'dfs',
|
| 1104 |
+
playerName: 'Kyle Schwarber',
|
| 1105 |
+
team: 'PHI',
|
| 1106 |
+
marketType: 'batter_hits',
|
| 1107 |
+
marketLabel: 'Hits',
|
| 1108 |
+
side: 'yes',
|
| 1109 |
+
lineValue: 0.5,
|
| 1110 |
+
oddsInput,
|
| 1111 |
+
impliedProbability: americanToImpliedProbability(oddsInput),
|
| 1112 |
+
})),
|
| 1113 |
+
...[
|
| 1114 |
+
['PrizePicks', '+110'],
|
| 1115 |
+
['Underdog Fantasy', '+115'],
|
| 1116 |
+
['Betr Picks', '+120'],
|
| 1117 |
+
].map(([book, oddsInput]) => ({
|
| 1118 |
+
marketKey: 'thin-dfs',
|
| 1119 |
+
source: 'odds_api',
|
| 1120 |
+
book,
|
| 1121 |
+
lane: 'dfs',
|
| 1122 |
+
playerName: 'Bryce Harper',
|
| 1123 |
+
team: 'PHI',
|
| 1124 |
+
marketType: 'batter_hits',
|
| 1125 |
+
marketLabel: 'Hits',
|
| 1126 |
+
side: 'yes',
|
| 1127 |
+
lineValue: 0.5,
|
| 1128 |
+
oddsInput,
|
| 1129 |
+
impliedProbability: americanToImpliedProbability(oddsInput),
|
| 1130 |
+
})),
|
| 1131 |
+
...[
|
| 1132 |
+
['Kalshi', '+110'],
|
| 1133 |
+
['Novig', '+115'],
|
| 1134 |
+
['Polymarket', '+120'],
|
| 1135 |
+
['ProphetX', '+125'],
|
| 1136 |
+
].map(([book, oddsInput]) => ({
|
| 1137 |
+
marketKey: 'eligible-exchange',
|
| 1138 |
+
source: 'odds_api',
|
| 1139 |
+
book,
|
| 1140 |
+
lane: 'exchange',
|
| 1141 |
+
playerName: 'Mookie Betts',
|
| 1142 |
+
team: 'LAD',
|
| 1143 |
+
marketType: 'batter_hits',
|
| 1144 |
+
marketLabel: 'Hits',
|
| 1145 |
+
side: 'yes',
|
| 1146 |
+
lineValue: 0.5,
|
| 1147 |
+
oddsInput,
|
| 1148 |
+
impliedProbability: americanToImpliedProbability(oddsInput),
|
| 1149 |
+
})),
|
| 1150 |
+
...[
|
| 1151 |
+
['Kalshi', '+110'],
|
| 1152 |
+
['Novig', '+115'],
|
| 1153 |
+
['Polymarket', '+120'],
|
| 1154 |
+
].map(([book, oddsInput]) => ({
|
| 1155 |
+
marketKey: 'thin-exchange',
|
| 1156 |
+
source: 'odds_api',
|
| 1157 |
+
book,
|
| 1158 |
+
lane: 'exchange',
|
| 1159 |
+
playerName: 'Shohei Ohtani',
|
| 1160 |
+
team: 'LAD',
|
| 1161 |
+
marketType: 'batter_hits',
|
| 1162 |
+
marketLabel: 'Hits',
|
| 1163 |
+
side: 'yes',
|
| 1164 |
+
lineValue: 0.5,
|
| 1165 |
+
oddsInput,
|
| 1166 |
+
impliedProbability: americanToImpliedProbability(oddsInput),
|
| 1167 |
+
})),
|
| 1168 |
+
];
|
| 1169 |
+
|
| 1170 |
+
const sportsbookRows = analyzeSharpMarkets(entries, config, { lane: 'sportsbook' });
|
| 1171 |
+
const dfsRows = analyzeSharpMarkets(entries, config, { lane: 'dfs' });
|
| 1172 |
+
const exchangeRows = analyzeSharpMarkets(entries, config, { lane: 'exchange' });
|
| 1173 |
+
|
| 1174 |
+
assert.deepEqual(sportsbookRows.sharpRows.map((row) => row.playerName), ['Aaron Judge']);
|
| 1175 |
+
assert.deepEqual(dfsRows.sharpRows.map((row) => row.playerName), ['Kyle Schwarber']);
|
| 1176 |
+
assert.deepEqual(exchangeRows.sharpRows.map((row) => row.playerName), ['Mookie Betts']);
|
| 1177 |
+
assert.ok(sportsbookRows.sharpRows.every((row) => row.booksCompared >= 6));
|
| 1178 |
+
assert.ok(dfsRows.sharpRows.every((row) => row.booksCompared >= 4));
|
| 1179 |
+
assert.ok(exchangeRows.sharpRows.every((row) => row.booksCompared >= 4));
|
| 1180 |
+
assert.ok(sportsbookRows.marketHealthRows.every((row) => row.averageBooksCompared >= 6));
|
| 1181 |
+
assert.ok(dfsRows.marketHealthRows.every((row) => row.averageBooksCompared >= 4));
|
| 1182 |
+
assert.ok(exchangeRows.marketHealthRows.every((row) => row.averageBooksCompared >= 4));
|
| 1183 |
});
|
| 1184 |
|
| 1185 |
test('circa board reposts when a new same-day snapshot appears', async () => {
|