Codex commited on
Commit
d1c5d0f
·
1 Parent(s): b548c5a

Tighten lane minimum book thresholds

Browse files
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 || 3);
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 || 5);
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 || 3);
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 || 3);
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 < 2 || allImplied.length < 2) {
2300
  continue;
2301
  }
2302
 
@@ -4288,7 +4295,10 @@ export class MarketScanner {
4288
  continue;
4289
  }
4290
  const minBooks = lane === 'sportsbook'
4291
- ? Number(this.config.sharpEdgeAlertMinBooks ?? 5)
 
 
 
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 five books compared', async () => {
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: 5,
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: 5,
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 sportsbookRows = analyzeSharpMarkets(entries, {}, { lane: 'sportsbook' });
907
- const dfsRows = analyzeSharpMarkets(entries, {}, { lane: 'dfs' });
908
- const exchangeRows = analyzeSharpMarkets(entries, {}, { lane: 'exchange' });
 
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, 'Novig');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 () => {