Codex commited on
Commit
7161e89
·
1 Parent(s): b8c237d

Add hosted matchup commands

Browse files
Files changed (8) hide show
  1. package-lock.json +7 -0
  2. package.json +1 -0
  3. src/commands.js +83 -0
  4. src/config.js +12 -0
  5. src/embeds.js +247 -0
  6. src/index.js +123 -0
  7. src/matchups.js +1005 -0
  8. test/matchups.test.js +152 -0
package-lock.json CHANGED
@@ -11,6 +11,7 @@
11
  "chart.js": "^4.5.0",
12
  "discord.js": "^14.25.1",
13
  "dotenv": "^17.2.3",
 
14
  "pdfjs-dist": "^5.6.205",
15
  "pg": "^8.16.3",
16
  "skia-canvas": "^3.0.8",
@@ -867,6 +868,12 @@
867
  "node": ">= 14"
868
  }
869
  },
 
 
 
 
 
 
870
  "node_modules/idb-keyval": {
871
  "version": "6.2.2",
872
  "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
 
11
  "chart.js": "^4.5.0",
12
  "discord.js": "^14.25.1",
13
  "dotenv": "^17.2.3",
14
+ "hyparquet": "^1.25.5",
15
  "pdfjs-dist": "^5.6.205",
16
  "pg": "^8.16.3",
17
  "skia-canvas": "^3.0.8",
 
868
  "node": ">= 14"
869
  }
870
  },
871
+ "node_modules/hyparquet": {
872
+ "version": "1.25.5",
873
+ "resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.25.5.tgz",
874
+ "integrity": "sha512-YHEmaVX9ctI4uGzCdXHCPu8tEu2TJmO0H9s59z2JSe3kkc/S0Ltm+zpbJRCCLQWNu1SaocFcGCPTT2vl52BnTg==",
875
+ "license": "MIT"
876
+ },
877
  "node_modules/idb-keyval": {
878
  "version": "6.2.2",
879
  "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
package.json CHANGED
@@ -15,6 +15,7 @@
15
  "chart.js": "^4.5.0",
16
  "discord.js": "^14.25.1",
17
  "dotenv": "^17.2.3",
 
18
  "pdfjs-dist": "^5.6.205",
19
  "pg": "^8.16.3",
20
  "skia-canvas": "^3.0.8",
 
15
  "chart.js": "^4.5.0",
16
  "discord.js": "^14.25.1",
17
  "dotenv": "^17.2.3",
18
+ "hyparquet": "^1.25.5",
19
  "pdfjs-dist": "^5.6.205",
20
  "pg": "^8.16.3",
21
  "skia-canvas": "^3.0.8",
src/commands.js CHANGED
@@ -158,6 +158,62 @@ function addMarketIntelligenceFilters(command, options = {}) {
158
  return command;
159
  }
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  export const commands = [
162
  new SlashCommandBuilder()
163
  .setName('bet')
@@ -518,6 +574,33 @@ export const commands = [
518
  .setDescription('Show coverage and market quality by supported market.'),
519
  { includeMarket: true, includeLimit: true }
520
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  new SlashCommandBuilder()
522
  .setName('alerts')
523
  .setDescription('Post the analyst alert-role embed to the welcome channel.'),
 
158
  return command;
159
  }
160
 
161
+ function addMatchupOptions(command, options = {}) {
162
+ if (options.includePlayer) {
163
+ command.addStringOption((option) =>
164
+ option
165
+ .setName('player')
166
+ .setDescription('Player name to look up.')
167
+ .setRequired(true)
168
+ );
169
+ }
170
+
171
+ if (options.includePlayerType) {
172
+ command.addStringOption((option) =>
173
+ option
174
+ .setName('player_type')
175
+ .setDescription('Optionally force hitter or pitcher context.')
176
+ .setRequired(false)
177
+ .addChoices(
178
+ { name: 'Auto', value: 'auto' },
179
+ { name: 'Hitter', value: 'hitter' },
180
+ { name: 'Pitcher', value: 'pitcher' }
181
+ )
182
+ );
183
+ }
184
+
185
+ if (options.includeTeam) {
186
+ command.addStringOption((option) =>
187
+ option
188
+ .setName('team')
189
+ .setDescription('Optional team filter, for example CHC or KCR.')
190
+ .setRequired(false)
191
+ );
192
+ }
193
+
194
+ if (options.includeDate !== false) {
195
+ command.addStringOption((option) =>
196
+ option
197
+ .setName('date')
198
+ .setDescription('Optional slate date in YYYY-MM-DD format. Defaults to the latest available slate.')
199
+ .setRequired(false)
200
+ );
201
+ }
202
+
203
+ if (options.includeLimit !== false) {
204
+ command.addIntegerOption((option) =>
205
+ option
206
+ .setName('limit')
207
+ .setDescription('Optional result limit.')
208
+ .setRequired(false)
209
+ .setMinValue(1)
210
+ .setMaxValue(15)
211
+ );
212
+ }
213
+
214
+ return command;
215
+ }
216
+
217
  export const commands = [
218
  new SlashCommandBuilder()
219
  .setName('bet')
 
574
  .setDescription('Show coverage and market quality by supported market.'),
575
  { includeMarket: true, includeLimit: true }
576
  ),
577
+ addMatchupOptions(
578
+ new SlashCommandBuilder()
579
+ .setName('matchuphitters')
580
+ .setDescription('Show the best hitter matchups from the hosted MLB artifact slate.'),
581
+ { includeTeam: true, includeDate: true, includeLimit: true }
582
+ ),
583
+ addMatchupOptions(
584
+ new SlashCommandBuilder()
585
+ .setName('matchuppitchers')
586
+ .setDescription('Show the best pitcher matchups from the hosted MLB artifact slate.'),
587
+ { includeTeam: true, includeDate: true, includeLimit: true }
588
+ ),
589
+ addMatchupOptions(
590
+ new SlashCommandBuilder()
591
+ .setName('playercontext')
592
+ .setDescription('Show matchup context for one hitter or pitcher.'),
593
+ { includePlayer: true, includePlayerType: true, includeDate: true, includeLimit: false }
594
+ ),
595
+ addMatchupOptions(
596
+ new SlashCommandBuilder()
597
+ .setName('bestmatchups')
598
+ .setDescription('Show a compact board of the best hitter matchups for the active slate.'),
599
+ { includeTeam: true, includeDate: true, includeLimit: true }
600
+ ),
601
+ new SlashCommandBuilder()
602
+ .setName('matchuphealth')
603
+ .setDescription('Show hosted artifact freshness and Cockroach fallback status for matchup data.'),
604
  new SlashCommandBuilder()
605
  .setName('alerts')
606
  .setDescription('Post the analyst alert-role embed to the welcome channel.'),
src/config.js CHANGED
@@ -42,6 +42,9 @@ export function getConfig() {
42
  const circaTimeZone = process.env.CIRCA_TIMEZONE?.trim() || 'America/Chicago';
43
  const circaRetryMinutes = Number(process.env.CIRCA_RETRY_MINUTES || 30);
44
  const circaMovementFrequencyMinutes = Number(process.env.CIRCA_MOVEMENT_FREQUENCY_MINUTES || 5);
 
 
 
45
 
46
  if (!token) {
47
  throw new Error('Missing DISCORD_TOKEN in environment.');
@@ -57,6 +60,15 @@ export function getConfig() {
57
  databaseUrl,
58
  port,
59
  adminRoleName,
 
 
 
 
 
 
 
 
 
60
  scanner: {
61
  enabled: Boolean(
62
  circaDropboxUrl
 
42
  const circaTimeZone = process.env.CIRCA_TIMEZONE?.trim() || 'America/Chicago';
43
  const circaRetryMinutes = Number(process.env.CIRCA_RETRY_MINUTES || 30);
44
  const circaMovementFrequencyMinutes = Number(process.env.CIRCA_MOVEMENT_FREQUENCY_MINUTES || 5);
45
+ const matchupHostedBaseUrl = process.env.MLB_HOSTED_BASE_URL?.trim() || null;
46
+ const matchupArtifactCacheTtlMs = Number(process.env.MATCHUP_ARTIFACT_CACHE_TTL_MS || 300000);
47
+ const matchupFallbackDays = Number(process.env.MATCHUP_FALLBACK_DAYS || 7);
48
 
49
  if (!token) {
50
  throw new Error('Missing DISCORD_TOKEN in environment.');
 
60
  databaseUrl,
61
  port,
62
  adminRoleName,
63
+ matchups: {
64
+ enabled: Boolean(matchupHostedBaseUrl || databaseUrl),
65
+ hosted: {
66
+ baseUrl: matchupHostedBaseUrl,
67
+ cacheTtlMs: matchupArtifactCacheTtlMs,
68
+ fallbackDays: matchupFallbackDays,
69
+ },
70
+ databaseUrl,
71
+ },
72
  scanner: {
73
  enabled: Boolean(
74
  circaDropboxUrl
src/embeds.js CHANGED
@@ -922,6 +922,214 @@ export function buildCircaMovementEmbed(movement, snapshot) {
922
  .setFooter({ text: 'Posted only because this Circa prop moved from the prior seen snapshot.' });
923
  }
924
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
925
  function escapeCsv(value) {
926
  const stringValue = String(value);
927
  if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
@@ -1023,6 +1231,45 @@ function formatUnits(value) {
1023
  return `${(value ?? 0).toFixed(2)}u`;
1024
  }
1025
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1026
  function formatSelection(side, lineValue) {
1027
  const normalizedSide = String(side ?? '').trim().toLowerCase();
1028
  const sideLabel = {
 
922
  .setFooter({ text: 'Posted only because this Circa prop moved from the prior seen snapshot.' });
923
  }
924
 
925
+ export function buildMatchupHittersEmbed(result, filters = {}) {
926
+ const embed = new EmbedBuilder()
927
+ .setColor(PALETTE.primary)
928
+ .setTitle('Matchup Hitters')
929
+ .setDescription(
930
+ buildFilterBanner(
931
+ filters,
932
+ `Top hitter matchups from **${String(result.source ?? 'unknown').toUpperCase()}** for **${result.resolvedDate ?? 'unknown slate'}**.`
933
+ )
934
+ );
935
+
936
+ if (result.warning) {
937
+ embed.addFields({ name: 'Source Note', value: truncate(result.warning, 256), inline: false });
938
+ }
939
+
940
+ if (!result.rows?.length) {
941
+ return embed.addFields({ name: 'Board', value: 'No hitter matchup rows were available for that filter set.' });
942
+ }
943
+
944
+ embed.addFields(
945
+ result.rows.map((row, index) => ({
946
+ name: `${index + 1}. ${row.hitter_name ?? 'Unknown Hitter'}${row.team ? ` (${row.team})` : ''}`,
947
+ value: [
948
+ `Matchup: ${formatMetricNumber(row.matchup_score)} | Ceiling: ${formatMetricNumber(row.ceiling_score)} | Zone Fit: ${formatMetricNumber(row.zone_fit_score)}`,
949
+ `xwOBA: ${formatMetricDecimal(row.xwoba)} | Likely: ${formatMetricNumber(row.likely_starter_score)} | HH%: ${formatMetricPercent(row.hard_hit_pct)}`,
950
+ `Opponent: ${row.opponent_team ?? 'N/A'}${row.opposing_pitcher_name ? ` vs ${row.opposing_pitcher_name}` : ''}${row.opposing_pitcher_hand ? ` (${row.opposing_pitcher_hand})` : ''}`,
951
+ ].join('\n'),
952
+ inline: false,
953
+ }))
954
+ );
955
+
956
+ return embed;
957
+ }
958
+
959
+ export function buildMatchupPitchersEmbed(result, filters = {}) {
960
+ const embed = new EmbedBuilder()
961
+ .setColor(PALETTE.secondary)
962
+ .setTitle('Matchup Pitchers')
963
+ .setDescription(
964
+ buildFilterBanner(
965
+ filters,
966
+ `Top pitcher matchups from **${String(result.source ?? 'unknown').toUpperCase()}** for **${result.resolvedDate ?? 'unknown slate'}**.`
967
+ )
968
+ );
969
+
970
+ if (result.warning) {
971
+ embed.addFields({ name: 'Source Note', value: truncate(result.warning, 256), inline: false });
972
+ }
973
+
974
+ if (!result.rows?.length) {
975
+ return embed.addFields({ name: 'Board', value: 'No pitcher matchup rows were available for that filter set.' });
976
+ }
977
+
978
+ embed.addFields(
979
+ result.rows.map((row, index) => ({
980
+ name: `${index + 1}. ${row.pitcher_name ?? 'Unknown Pitcher'}${row.team ? ` (${row.team})` : ''}`,
981
+ value: [
982
+ `Pitch Score: ${formatMetricNumber(row.pitcher_score)} | Strikeout: ${formatMetricNumber(row.strikeout_score)} | Matchup Adj: ${formatMetricNumber(row.pitcher_matchup_adjustment)}`,
983
+ `xwOBA: ${formatMetricDecimal(row.xwoba)} | CSW%: ${formatMetricPercent(row.csw_pct)} | SwStr%: ${formatMetricPercent(row.swstr_pct)}`,
984
+ `Opponent: ${row.opponent_team ?? 'N/A'} | Lineup Quality: ${formatMetricNumber(row.opponent_lineup_quality)} | Lineup Count: ${formatMetricNumber(row.lineup_hitter_count, 0)}`,
985
+ ].join('\n'),
986
+ inline: false,
987
+ }))
988
+ );
989
+
990
+ return embed;
991
+ }
992
+
993
+ export function buildBestMatchupsEmbed(result, filters = {}) {
994
+ const embed = new EmbedBuilder()
995
+ .setColor(PALETTE.accent)
996
+ .setTitle('Best Matchups')
997
+ .setDescription(
998
+ buildFilterBanner(
999
+ filters,
1000
+ `Compact hitter board from **${String(result.source ?? 'unknown').toUpperCase()}** for **${result.resolvedDate ?? 'unknown slate'}**.`
1001
+ )
1002
+ );
1003
+
1004
+ if (!result.rows?.length) {
1005
+ return embed.addFields({ name: 'Board', value: 'No matchup hitters were available for that slate.' });
1006
+ }
1007
+
1008
+ embed.addFields(
1009
+ {
1010
+ name: 'Top Board',
1011
+ value: result.rows.map((row, index) => (
1012
+ `${index + 1}. **${row.hitter_name ?? 'Unknown'}**${row.team ? ` (${row.team})` : ''} | `
1013
+ + `Matchup ${formatMetricNumber(row.matchup_score)} | `
1014
+ + `Ceiling ${formatMetricNumber(row.ceiling_score)} | `
1015
+ + `xwOBA ${formatMetricDecimal(row.xwoba)}`
1016
+ )).join('\n'),
1017
+ inline: false,
1018
+ }
1019
+ );
1020
+
1021
+ if (result.warning) {
1022
+ embed.addFields({ name: 'Source Note', value: truncate(result.warning, 256), inline: false });
1023
+ }
1024
+
1025
+ return embed;
1026
+ }
1027
+
1028
+ export function buildPlayerContextEmbed(result) {
1029
+ const baseDescription = [
1030
+ `Source: **${String(result.source ?? 'unknown').toUpperCase()}**`,
1031
+ `Slate: **${result.resolvedDate ?? 'unknown'}**`,
1032
+ result.team ? `Team: **${result.team}**` : null,
1033
+ result.opponentTeam ? `Opponent: **${result.opponentTeam}**` : null,
1034
+ ].filter(Boolean).join(' | ');
1035
+
1036
+ const embed = new EmbedBuilder()
1037
+ .setColor(result.playerType === 'pitcher' ? PALETTE.secondary : PALETTE.primary)
1038
+ .setTitle(`${result.name ?? 'Player Context'}${result.playerType ? ` - ${capitalize(result.playerType)}` : ''}`)
1039
+ .setDescription(baseDescription);
1040
+
1041
+ if (result.metrics?.length) {
1042
+ embed.addFields({
1043
+ name: 'Overview',
1044
+ value: result.metrics
1045
+ .map((metric) => `${metric.label}: ${formatMetricAuto(metric.value, metric.label)}`)
1046
+ .join(' | '),
1047
+ inline: false,
1048
+ });
1049
+ }
1050
+
1051
+ if (result.playerType === 'hitter') {
1052
+ embed.addFields({
1053
+ name: 'Today',
1054
+ value: [
1055
+ result.opposingPitcherName ? `Opposing Pitcher: ${result.opposingPitcherName}` : null,
1056
+ result.hand ? `Pitcher Hand: ${result.hand}` : null,
1057
+ ].filter(Boolean).join(' | ') || 'No same-day opponent context was available.',
1058
+ inline: false,
1059
+ });
1060
+ }
1061
+
1062
+ if (result.rolling?.length) {
1063
+ embed.addFields({
1064
+ name: 'Rolling',
1065
+ value: result.rolling
1066
+ .map((row) => `${row.label}: ${formatMetricDecimal(row.value)}`)
1067
+ .join('\n'),
1068
+ inline: false,
1069
+ });
1070
+ }
1071
+
1072
+ if (result.zones?.length) {
1073
+ embed.addFields({
1074
+ name: result.playerType === 'pitcher' ? 'Zones To Watch' : 'Best Zones',
1075
+ value: result.zones
1076
+ .map((row) => `${row.label}: ${formatMetricAuto(row.value, row.metricKey)}${row.sample !== null ? ` | Sample ${formatMetricNumber(row.sample, 0)}` : ''}`)
1077
+ .join('\n'),
1078
+ inline: false,
1079
+ });
1080
+ }
1081
+
1082
+ if (result.arsenal?.length) {
1083
+ embed.addFields({
1084
+ name: 'Arsenal',
1085
+ value: result.arsenal
1086
+ .map((row) => `${row.pitchType}: Usage ${formatMetricPercent(row.usagePct)}${row.velocity !== null ? ` | Velo ${formatMetricNumber(row.velocity)}` : ''}${row.whiffRate !== null ? ` | Whiff ${formatMetricPercent(row.whiffRate)}` : ''}`)
1087
+ .join('\n'),
1088
+ inline: false,
1089
+ });
1090
+ }
1091
+
1092
+ if (result.countUsage?.length) {
1093
+ embed.addFields({
1094
+ name: 'Count Usage',
1095
+ value: result.countUsage
1096
+ .map((row) => `${row.countBucket} ${String(row.batterSide).toUpperCase()}: ${row.pitchType} ${formatMetricPercent(row.usagePct)}`)
1097
+ .join('\n'),
1098
+ inline: false,
1099
+ });
1100
+ }
1101
+
1102
+ return embed;
1103
+ }
1104
+
1105
+ export function buildMatchupHealthEmbed(result) {
1106
+ return new EmbedBuilder()
1107
+ .setColor(PALETTE.info)
1108
+ .setTitle('Matchup Health')
1109
+ .addFields(
1110
+ {
1111
+ name: 'Hosted Artifacts',
1112
+ value: [
1113
+ `Configured: ${result.hosted?.configured ? 'Yes' : 'No'}`,
1114
+ `Latest Date: ${result.hosted?.latestDate ?? 'Unavailable'}`,
1115
+ `Cache Entries: ${result.hosted?.cacheEntries ?? 0}`,
1116
+ `Cache TTL: ${formatMetricNumber((result.hosted?.cacheTtlMs ?? 0) / 1000, 0)}s`,
1117
+ result.hosted?.error ? `Error: ${truncate(result.hosted.error, 180)}` : null,
1118
+ ].filter(Boolean).join('\n'),
1119
+ inline: false,
1120
+ },
1121
+ {
1122
+ name: 'Cockroach Fallback',
1123
+ value: [
1124
+ `Configured: ${result.fallback?.configured ? 'Yes' : 'No'}`,
1125
+ `Latest Date: ${result.fallback?.latestDate ?? 'Unavailable'}`,
1126
+ result.fallback?.error ? `Error: ${truncate(result.fallback.error, 180)}` : null,
1127
+ ].filter(Boolean).join('\n'),
1128
+ inline: false,
1129
+ }
1130
+ );
1131
+ }
1132
+
1133
  function escapeCsv(value) {
1134
  const stringValue = String(value);
1135
  if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
 
1231
  return `${(value ?? 0).toFixed(2)}u`;
1232
  }
1233
 
1234
+ function formatMetricNumber(value, digits = 1) {
1235
+ const numeric = Number(value);
1236
+ if (!Number.isFinite(numeric)) {
1237
+ return 'N/A';
1238
+ }
1239
+ return numeric.toFixed(digits);
1240
+ }
1241
+
1242
+ function formatMetricDecimal(value) {
1243
+ const numeric = Number(value);
1244
+ if (!Number.isFinite(numeric)) {
1245
+ return 'N/A';
1246
+ }
1247
+ return numeric.toFixed(3);
1248
+ }
1249
+
1250
+ function formatMetricPercent(value) {
1251
+ const numeric = Number(value);
1252
+ if (!Number.isFinite(numeric)) {
1253
+ return 'N/A';
1254
+ }
1255
+ return `${numeric.toFixed(1)}%`;
1256
+ }
1257
+
1258
+ function formatMetricAuto(value, labelOrKey) {
1259
+ const key = String(labelOrKey ?? '').toLowerCase();
1260
+ if (key.includes('%') || key.endsWith('_pct') || key.includes('usage') || key.includes('whiff') || key.includes('strike')) {
1261
+ return formatMetricPercent(value);
1262
+ }
1263
+ if (key.includes('xwoba')) {
1264
+ return formatMetricDecimal(value);
1265
+ }
1266
+ return formatMetricNumber(value);
1267
+ }
1268
+
1269
+ function capitalize(value) {
1270
+ return value ? `${value[0].toUpperCase()}${value.slice(1)}` : '';
1271
+ }
1272
+
1273
  function formatSelection(side, lineValue) {
1274
  const normalizedSide = String(side ?? '').trim().toLowerCase();
1275
  const sideLabel = {
src/index.js CHANGED
@@ -26,6 +26,7 @@ import {
26
  buildAlertsButtonRows,
27
  buildAlertsEmbed,
28
  buildBankrollEmbed,
 
29
  buildBetSavedEmbed,
30
  buildBetsEmbed,
31
  buildBetsPaginationRow,
@@ -54,7 +55,11 @@ import {
54
  buildEditBetEmbed,
55
  buildErrorEmbed,
56
  buildExportAttachment,
 
 
 
57
  buildMarketTopEmbed,
 
58
  buildResolveAllEmbed,
59
  buildResolveEmbed,
60
  buildRoiEmbed,
@@ -70,6 +75,7 @@ import {
70
  import { parseBulkAddInput } from './bulk-add.js';
71
  import { parseBetIdList } from './resolve-bulk.js';
72
  import { MarketScanner } from './market-scanner.js';
 
73
 
74
  const BET_MODAL_PREFIX = 'bet-entry-modal';
75
  const BULK_ADD_MODAL_PREFIX = 'bulk-add-modal';
@@ -94,6 +100,9 @@ async function main() {
94
  const client = new Client({
95
  intents: [GatewayIntentBits.Guilds],
96
  });
 
 
 
97
  const scanner = new MarketScanner({
98
  client,
99
  store,
@@ -114,6 +123,7 @@ async function main() {
114
  logger: console,
115
  });
116
  client.__marketScanner = scanner;
 
117
 
118
  const healthServer = http.createServer((request, response) => {
119
  response.writeHead(200, { 'Content-Type': 'application/json' });
@@ -196,6 +206,7 @@ async function main() {
196
  console.log('Shutting down...');
197
  healthServer.close();
198
  await scanner.stop();
 
199
  client.destroy();
200
  await store.close();
201
  process.exit(0);
@@ -432,6 +443,31 @@ async function handleChatInput(interaction, store, config) {
432
  return;
433
  }
434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  if (commandName === 'alerts') {
436
  await handleAlerts(interaction);
437
  return;
@@ -463,6 +499,16 @@ function getMarketIntelligenceFilters(interaction) {
463
  };
464
  }
465
 
 
 
 
 
 
 
 
 
 
 
466
  async function showBetModal(interaction) {
467
  const selectedBook = interaction.options.getString('book', true);
468
  const selectedSport = interaction.options.getString('sport', true);
@@ -1272,6 +1318,83 @@ async function handleSharpBoard(interaction, config, commandName) {
1272
  }
1273
  }
1274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1275
  async function handleButton(interaction, store) {
1276
  const alertRoleName = parseAlertRoleButtonId(interaction.customId);
1277
  if (alertRoleName) {
 
26
  buildAlertsButtonRows,
27
  buildAlertsEmbed,
28
  buildBankrollEmbed,
29
+ buildBestMatchupsEmbed,
30
  buildBetSavedEmbed,
31
  buildBetsEmbed,
32
  buildBetsPaginationRow,
 
55
  buildEditBetEmbed,
56
  buildErrorEmbed,
57
  buildExportAttachment,
58
+ buildMatchupHealthEmbed,
59
+ buildMatchupHittersEmbed,
60
+ buildMatchupPitchersEmbed,
61
  buildMarketTopEmbed,
62
+ buildPlayerContextEmbed,
63
  buildResolveAllEmbed,
64
  buildResolveEmbed,
65
  buildRoiEmbed,
 
75
  import { parseBulkAddInput } from './bulk-add.js';
76
  import { parseBetIdList } from './resolve-bulk.js';
77
  import { MarketScanner } from './market-scanner.js';
78
+ import { MatchupService } from './matchups.js';
79
 
80
  const BET_MODAL_PREFIX = 'bet-entry-modal';
81
  const BULK_ADD_MODAL_PREFIX = 'bulk-add-modal';
 
100
  const client = new Client({
101
  intents: [GatewayIntentBits.Guilds],
102
  });
103
+ const matchupService = new MatchupService(config.matchups, {
104
+ logger: console,
105
+ });
106
  const scanner = new MarketScanner({
107
  client,
108
  store,
 
123
  logger: console,
124
  });
125
  client.__marketScanner = scanner;
126
+ client.__matchupService = matchupService;
127
 
128
  const healthServer = http.createServer((request, response) => {
129
  response.writeHead(200, { 'Content-Type': 'application/json' });
 
206
  console.log('Shutting down...');
207
  healthServer.close();
208
  await scanner.stop();
209
+ await matchupService.close();
210
  client.destroy();
211
  await store.close();
212
  process.exit(0);
 
443
  return;
444
  }
445
 
446
+ if (commandName === 'matchuphitters') {
447
+ await handleMatchupCommand(interaction, config, 'matchuphitters');
448
+ return;
449
+ }
450
+
451
+ if (commandName === 'matchuppitchers') {
452
+ await handleMatchupCommand(interaction, config, 'matchuppitchers');
453
+ return;
454
+ }
455
+
456
+ if (commandName === 'playercontext') {
457
+ await handleMatchupCommand(interaction, config, 'playercontext');
458
+ return;
459
+ }
460
+
461
+ if (commandName === 'bestmatchups') {
462
+ await handleMatchupCommand(interaction, config, 'bestmatchups');
463
+ return;
464
+ }
465
+
466
+ if (commandName === 'matchuphealth') {
467
+ await handleMatchupHealth(interaction, config);
468
+ return;
469
+ }
470
+
471
  if (commandName === 'alerts') {
472
  await handleAlerts(interaction);
473
  return;
 
499
  };
500
  }
501
 
502
+ function getMatchupFilters(interaction) {
503
+ return {
504
+ team: interaction.options.getString('team') ?? undefined,
505
+ player: interaction.options.getString('player') ?? undefined,
506
+ playerType: interaction.options.getString('player_type') ?? undefined,
507
+ date: interaction.options.getString('date') ?? undefined,
508
+ limit: interaction.options.getInteger('limit') ?? undefined,
509
+ };
510
+ }
511
+
512
  async function showBetModal(interaction) {
513
  const selectedBook = interaction.options.getString('book', true);
514
  const selectedSport = interaction.options.getString('sport', true);
 
1318
  }
1319
  }
1320
 
1321
+ async function handleMatchupCommand(interaction, config, commandName) {
1322
+ await interaction.deferReply();
1323
+
1324
+ const matchupService = interaction.client.__matchupService;
1325
+ if (!config.matchups.enabled || !matchupService) {
1326
+ await interaction.editReply({
1327
+ embeds: [buildErrorEmbed('Matchups unavailable', 'Configure MLB_HOSTED_BASE_URL or Cockroach access before using matchup commands.')],
1328
+ });
1329
+ return;
1330
+ }
1331
+
1332
+ const filters = getMatchupFilters(interaction);
1333
+
1334
+ try {
1335
+ if (commandName === 'matchuphitters') {
1336
+ const result = await matchupService.getTopHitters(filters);
1337
+ await interaction.editReply({ embeds: [buildMatchupHittersEmbed(result, filters)] });
1338
+ return;
1339
+ }
1340
+
1341
+ if (commandName === 'matchuppitchers') {
1342
+ const result = await matchupService.getTopPitchers(filters);
1343
+ await interaction.editReply({ embeds: [buildMatchupPitchersEmbed(result, filters)] });
1344
+ return;
1345
+ }
1346
+
1347
+ if (commandName === 'bestmatchups') {
1348
+ const result = await matchupService.getBestMatchups(filters);
1349
+ await interaction.editReply({ embeds: [buildBestMatchupsEmbed(result, filters)] });
1350
+ return;
1351
+ }
1352
+
1353
+ if (commandName === 'playercontext') {
1354
+ const result = await matchupService.getPlayerContext(filters);
1355
+ await interaction.editReply({ embeds: [buildPlayerContextEmbed(result, filters)] });
1356
+ return;
1357
+ }
1358
+
1359
+ await interaction.editReply({
1360
+ embeds: [buildErrorEmbed('Command unavailable', 'That matchup command is not wired up yet.')],
1361
+ });
1362
+ } catch (error) {
1363
+ await interaction.editReply({
1364
+ embeds: [buildErrorEmbed('Matchup data unavailable', error.message || 'The matchup lookup hit an unexpected error.')],
1365
+ });
1366
+ }
1367
+ }
1368
+
1369
+ async function handleMatchupHealth(interaction, config) {
1370
+ const isAdmin = await memberHasRoleName(interaction, config.adminRoleName);
1371
+ if (!isAdmin) {
1372
+ await denyAdminOnly(interaction, config.adminRoleName);
1373
+ return;
1374
+ }
1375
+
1376
+ const matchupService = interaction.client.__matchupService;
1377
+ if (!config.matchups.enabled || !matchupService) {
1378
+ await interaction.reply({
1379
+ embeds: [buildErrorEmbed('Matchups unavailable', 'Configure MLB_HOSTED_BASE_URL or Cockroach access before using matchup commands.')],
1380
+ flags: MessageFlags.Ephemeral,
1381
+ });
1382
+ return;
1383
+ }
1384
+
1385
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
1386
+ try {
1387
+ const result = await matchupService.getHealth(getMatchupFilters(interaction));
1388
+ await interaction.editReply({
1389
+ embeds: [buildMatchupHealthEmbed(result)],
1390
+ });
1391
+ } catch (error) {
1392
+ await interaction.editReply({
1393
+ embeds: [buildErrorEmbed('Matchup health unavailable', error.message || 'The matchup health lookup hit an unexpected error.')],
1394
+ });
1395
+ }
1396
+ }
1397
+
1398
  async function handleButton(interaction, store) {
1399
  const alertRoleName = parseAlertRoleButtonId(interaction.customId);
1400
  if (alertRoleName) {
src/matchups.js ADDED
@@ -0,0 +1,1005 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Pool } from 'pg';
2
+ import { asyncBufferFromUrl, parquetReadObjects } from 'hyparquet';
3
+
4
+ const DEFAULT_MATCHUP_LIMIT = 10;
5
+ const DEFAULT_RECENT_WINDOW = 'season';
6
+ const DEFAULT_SPLIT_KEY = 'overall';
7
+ const DEFAULT_WEIGHTED_MODE = 'weighted';
8
+ const DEFAULT_FALLBACK_DAYS = 7;
9
+ const COCKROACH_RETRY_CODES = new Set(['40001']);
10
+ const PROFILE_CANDIDATE_LIMIT = 5;
11
+
12
+ const HITTER_COLUMNS = [
13
+ 'game_pk',
14
+ 'team',
15
+ 'opponent_team',
16
+ 'opposing_pitcher_name',
17
+ 'opposing_pitcher_hand',
18
+ 'batter',
19
+ 'player_id',
20
+ 'hitter_name',
21
+ 'split_key',
22
+ 'recent_window',
23
+ 'weighted_mode',
24
+ 'matchup_score',
25
+ 'ceiling_score',
26
+ 'zone_fit_score',
27
+ 'likely_starter_score',
28
+ 'xwoba',
29
+ 'pulled_barrel_pct',
30
+ 'sweet_spot_pct',
31
+ 'barrel_bip_pct',
32
+ 'hard_hit_pct',
33
+ ];
34
+
35
+ const PITCHER_COLUMNS = [
36
+ 'game_pk',
37
+ 'team',
38
+ 'opponent_team',
39
+ 'pitcher_id',
40
+ 'player_id',
41
+ 'pitcher_name',
42
+ 'p_throws',
43
+ 'split_key',
44
+ 'recent_window',
45
+ 'weighted_mode',
46
+ 'pitcher_score',
47
+ 'strikeout_score',
48
+ 'raw_pitcher_score',
49
+ 'raw_strikeout_score',
50
+ 'pitcher_matchup_adjustment',
51
+ 'strikeout_matchup_adjustment',
52
+ 'opponent_lineup_quality',
53
+ 'opponent_contact_threat',
54
+ 'opponent_whiff_tendency',
55
+ 'opponent_family_fit_allowed',
56
+ 'lineup_source',
57
+ 'lineup_hitter_count',
58
+ 'xwoba',
59
+ 'csw_pct',
60
+ 'swstr_pct',
61
+ 'putaway_pct',
62
+ 'ball_pct',
63
+ 'siera',
64
+ 'gb_pct',
65
+ 'gb_fb_ratio',
66
+ 'barrel_bip_pct',
67
+ 'hard_hit_pct',
68
+ ];
69
+
70
+ const SLATE_COLUMNS = [
71
+ 'game_pk',
72
+ 'away_team',
73
+ 'home_team',
74
+ 'away_probable_pitcher',
75
+ 'home_probable_pitcher',
76
+ 'away_probable_hand',
77
+ 'home_probable_hand',
78
+ ];
79
+
80
+ const ROSTER_COLUMNS = ['team', 'player_id', 'player_name'];
81
+ const EXCLUSION_COLUMNS = ['player_id', 'exclude_from_hitter_tables'];
82
+
83
+ const REUSABLE_HITTER_COLUMNS = [
84
+ 'team',
85
+ 'batter',
86
+ 'player_id',
87
+ 'hitter_name',
88
+ 'split_key',
89
+ 'recent_window',
90
+ 'weighted_mode',
91
+ 'matchup_score',
92
+ 'ceiling_score',
93
+ 'zone_fit_score',
94
+ 'likely_starter_score',
95
+ 'xwoba',
96
+ 'pulled_barrel_pct',
97
+ 'sweet_spot_pct',
98
+ 'barrel_bip_pct',
99
+ 'hard_hit_pct',
100
+ ];
101
+
102
+ const REUSABLE_PITCHER_COLUMNS = [
103
+ 'team',
104
+ 'pitcher_id',
105
+ 'player_id',
106
+ 'pitcher_name',
107
+ 'p_throws',
108
+ 'split_key',
109
+ 'recent_window',
110
+ 'weighted_mode',
111
+ 'pitcher_score',
112
+ 'strikeout_score',
113
+ 'raw_pitcher_score',
114
+ 'raw_strikeout_score',
115
+ 'pitcher_matchup_adjustment',
116
+ 'strikeout_matchup_adjustment',
117
+ 'xwoba',
118
+ 'csw_pct',
119
+ 'swstr_pct',
120
+ 'putaway_pct',
121
+ 'ball_pct',
122
+ 'siera',
123
+ 'gb_pct',
124
+ 'gb_fb_ratio',
125
+ 'barrel_bip_pct',
126
+ 'hard_hit_pct',
127
+ ];
128
+
129
+ const HITTER_ROLLING_COLUMNS = [
130
+ 'batter',
131
+ 'player_id',
132
+ 'hitter_name',
133
+ 'window_label',
134
+ 'window_size',
135
+ 'xwoba',
136
+ 'hard_hit_pct',
137
+ 'barrel_bip_pct',
138
+ 'sweet_spot_pct',
139
+ ];
140
+
141
+ const PITCHER_ROLLING_COLUMNS = [
142
+ 'pitcher_id',
143
+ 'player_id',
144
+ 'pitcher_name',
145
+ 'window_label',
146
+ 'window_size',
147
+ 'xwoba',
148
+ 'csw_pct',
149
+ 'swstr_pct',
150
+ 'putaway_pct',
151
+ 'ball_pct',
152
+ ];
153
+
154
+ const BATTER_ZONE_COLUMNS = [
155
+ 'batter',
156
+ 'player_id',
157
+ 'hitter_name',
158
+ 'zone_label',
159
+ 'zone',
160
+ 'xwoba',
161
+ 'hard_hit_pct',
162
+ 'barrel_bip_pct',
163
+ 'sample_size',
164
+ 'bip',
165
+ ];
166
+
167
+ const PITCHER_ZONE_COLUMNS = [
168
+ 'pitcher_id',
169
+ 'player_id',
170
+ 'pitcher_name',
171
+ 'zone_label',
172
+ 'zone',
173
+ 'xwoba_allowed',
174
+ 'hard_hit_pct_allowed',
175
+ 'barrel_bip_pct_allowed',
176
+ 'sample_size',
177
+ 'bip',
178
+ ];
179
+
180
+ const ARSENAL_COLUMNS = [
181
+ 'pitcher_id',
182
+ 'player_id',
183
+ 'pitcher_name',
184
+ 'pitch_type',
185
+ 'usage_pct',
186
+ 'avg_velocity',
187
+ 'avg_spin_rate',
188
+ 'whiff_rate',
189
+ 'called_strike_rate',
190
+ 'putaway_rate',
191
+ ];
192
+
193
+ const COUNT_USAGE_COLUMNS = [
194
+ 'pitcher_id',
195
+ 'player_id',
196
+ 'pitcher_name',
197
+ 'count_bucket',
198
+ 'batter_side_key',
199
+ 'pitch_type',
200
+ 'usage_pct',
201
+ ];
202
+
203
+ function addDays(value, delta) {
204
+ const next = new Date(`${value}T12:00:00Z`);
205
+ next.setUTCDate(next.getUTCDate() + delta);
206
+ return next.toISOString().slice(0, 10);
207
+ }
208
+
209
+ function parseDateOrToday(value) {
210
+ if (!value) {
211
+ return new Date().toISOString().slice(0, 10);
212
+ }
213
+
214
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
215
+ return value;
216
+ }
217
+
218
+ throw new Error('Dates must use YYYY-MM-DD format.');
219
+ }
220
+
221
+ function limitOrDefault(value, max = 15) {
222
+ const numeric = Number(value ?? DEFAULT_MATCHUP_LIMIT);
223
+ if (!Number.isFinite(numeric) || numeric <= 0) {
224
+ return DEFAULT_MATCHUP_LIMIT;
225
+ }
226
+ return Math.min(max, Math.max(1, Math.floor(numeric)));
227
+ }
228
+
229
+ function normalizeText(value) {
230
+ return String(value ?? '').trim().toLowerCase();
231
+ }
232
+
233
+ function normalizeTeam(value) {
234
+ return String(value ?? '').trim().toUpperCase();
235
+ }
236
+
237
+ function numberOrNull(value) {
238
+ const numeric = Number(value);
239
+ return Number.isFinite(numeric) ? numeric : null;
240
+ }
241
+
242
+ function compareNullableDescending(left, right) {
243
+ const leftValue = numberOrNull(left);
244
+ const rightValue = numberOrNull(right);
245
+ if (leftValue === null && rightValue === null) {
246
+ return 0;
247
+ }
248
+ if (leftValue === null) {
249
+ return 1;
250
+ }
251
+ if (rightValue === null) {
252
+ return -1;
253
+ }
254
+ return rightValue - leftValue;
255
+ }
256
+
257
+ function sortHitters(rows) {
258
+ return [...rows].sort((left, right) =>
259
+ compareNullableDescending(left.matchup_score, right.matchup_score)
260
+ || compareNullableDescending(left.ceiling_score, right.ceiling_score)
261
+ || compareNullableDescending(left.likely_starter_score, right.likely_starter_score)
262
+ || compareNullableDescending(left.xwoba, right.xwoba)
263
+ || String(left.hitter_name ?? '').localeCompare(String(right.hitter_name ?? ''))
264
+ );
265
+ }
266
+
267
+ function sortPitchers(rows) {
268
+ return [...rows].sort((left, right) =>
269
+ compareNullableDescending(left.pitcher_score, right.pitcher_score)
270
+ || compareNullableDescending(left.strikeout_score, right.strikeout_score)
271
+ || compareNullableDescending(left.pitcher_matchup_adjustment, right.pitcher_matchup_adjustment)
272
+ || compareNullableDescending(left.xwoba, right.xwoba)
273
+ || String(left.pitcher_name ?? '').localeCompare(String(right.pitcher_name ?? ''))
274
+ );
275
+ }
276
+
277
+ function keepRowForDefaults(row) {
278
+ const splitKey = String(row.split_key ?? DEFAULT_SPLIT_KEY);
279
+ const recentWindow = String(row.recent_window ?? DEFAULT_RECENT_WINDOW);
280
+ const weightedMode = String(row.weighted_mode ?? DEFAULT_WEIGHTED_MODE);
281
+ return splitKey === DEFAULT_SPLIT_KEY
282
+ && recentWindow === DEFAULT_RECENT_WINDOW
283
+ && weightedMode === DEFAULT_WEIGHTED_MODE;
284
+ }
285
+
286
+ function mapSlateTeams(slateRows) {
287
+ const lookup = new Map();
288
+ for (const row of slateRows) {
289
+ const awayTeam = String(row.away_team ?? '').trim();
290
+ const homeTeam = String(row.home_team ?? '').trim();
291
+ if (!awayTeam || !homeTeam) {
292
+ continue;
293
+ }
294
+
295
+ lookup.set(awayTeam, {
296
+ gamePk: row.game_pk ?? null,
297
+ opponentTeam: homeTeam,
298
+ opposingPitcherName: row.home_probable_pitcher ?? null,
299
+ opposingPitcherHand: row.home_probable_hand ?? null,
300
+ });
301
+ lookup.set(homeTeam, {
302
+ gamePk: row.game_pk ?? null,
303
+ opponentTeam: awayTeam,
304
+ opposingPitcherName: row.away_probable_pitcher ?? null,
305
+ opposingPitcherHand: row.away_probable_hand ?? null,
306
+ });
307
+ }
308
+ return lookup;
309
+ }
310
+
311
+ function buildExclusionSet(rows) {
312
+ const values = new Set();
313
+ for (const row of rows) {
314
+ const playerId = row.player_id;
315
+ if (playerId === null || playerId === undefined || playerId === '') {
316
+ continue;
317
+ }
318
+ const shouldExclude = row.exclude_from_hitter_tables === true
319
+ || String(row.exclude_from_hitter_tables ?? '').toLowerCase() === 'true'
320
+ || String(row.exclude_from_hitter_tables ?? '') === '1';
321
+ if (shouldExclude) {
322
+ values.add(String(playerId));
323
+ }
324
+ }
325
+ return values;
326
+ }
327
+
328
+ function withDefaults(row, slateLookup) {
329
+ const team = String(row.team ?? '').trim();
330
+ const slate = slateLookup.get(team) ?? {};
331
+ return {
332
+ ...row,
333
+ team,
334
+ game_pk: row.game_pk ?? slate.gamePk ?? null,
335
+ opponent_team: row.opponent_team ?? slate.opponentTeam ?? null,
336
+ opposing_pitcher_name: row.opposing_pitcher_name ?? slate.opposingPitcherName ?? null,
337
+ opposing_pitcher_hand: row.opposing_pitcher_hand ?? slate.opposingPitcherHand ?? null,
338
+ };
339
+ }
340
+
341
+ function pickMetrics(row, metricKeys) {
342
+ return metricKeys
343
+ .map(([label, key]) => ({ label, key, value: numberOrNull(row[key]) }))
344
+ .filter((item) => item.value !== null);
345
+ }
346
+
347
+ function buildRollingSummary(rows, metricKey, valueKey) {
348
+ return rows
349
+ .map((row) => ({
350
+ label: row[metricKey] ?? (row.window_size ? `Rolling ${row.window_size}` : null),
351
+ value: numberOrNull(row[valueKey]),
352
+ }))
353
+ .filter((row) => row.label && row.value !== null)
354
+ .slice(0, 3);
355
+ }
356
+
357
+ function buildZoneSummary(rows, nameKey, valueKeys) {
358
+ return [...rows]
359
+ .map((row) => {
360
+ const label = row.zone_label ?? row.zone ?? null;
361
+ const metric = valueKeys
362
+ .map((key) => ({ key, value: numberOrNull(row[key]) }))
363
+ .find((item) => item.value !== null);
364
+ return {
365
+ label,
366
+ metricKey: metric?.key ?? null,
367
+ value: metric?.value ?? null,
368
+ sample: numberOrNull(row.sample_size ?? row.bip),
369
+ playerName: row[nameKey] ?? null,
370
+ };
371
+ })
372
+ .filter((row) => row.label && row.value !== null)
373
+ .sort((left, right) => compareNullableDescending(left.value, right.value))
374
+ .slice(0, 3);
375
+ }
376
+
377
+ function buildArsenalSummary(rows) {
378
+ return [...rows]
379
+ .map((row) => ({
380
+ pitchType: row.pitch_type ?? null,
381
+ usagePct: numberOrNull(row.usage_pct),
382
+ velocity: numberOrNull(row.avg_velocity),
383
+ whiffRate: numberOrNull(row.whiff_rate),
384
+ calledStrikeRate: numberOrNull(row.called_strike_rate),
385
+ }))
386
+ .filter((row) => row.pitchType && row.usagePct !== null)
387
+ .sort((left, right) => compareNullableDescending(left.usagePct, right.usagePct))
388
+ .slice(0, 4);
389
+ }
390
+
391
+ function buildCountUsageSummary(rows) {
392
+ return [...rows]
393
+ .map((row) => ({
394
+ countBucket: row.count_bucket ?? null,
395
+ batterSide: row.batter_side_key ?? 'all',
396
+ pitchType: row.pitch_type ?? null,
397
+ usagePct: numberOrNull(row.usage_pct),
398
+ }))
399
+ .filter((row) => row.countBucket && row.pitchType && row.usagePct !== null)
400
+ .sort((left, right) => compareNullableDescending(left.usagePct, right.usagePct))
401
+ .slice(0, 4);
402
+ }
403
+
404
+ function findBestPlayerMatch(rows, key, playerName) {
405
+ const normalizedNeedle = normalizeText(playerName);
406
+ const exact = rows.find((row) => normalizeText(row[key]) === normalizedNeedle);
407
+ if (exact) {
408
+ return exact;
409
+ }
410
+ return rows.find((row) => normalizeText(row[key]).includes(normalizedNeedle)) ?? null;
411
+ }
412
+
413
+ async function sleep(ms) {
414
+ await new Promise((resolve) => setTimeout(resolve, ms));
415
+ }
416
+
417
+ export class HostedArtifactSource {
418
+ constructor(config = {}, options = {}) {
419
+ this.baseUrl = String(config.baseUrl ?? '').trim().replace(/\/$/, '');
420
+ this.cacheTtlMs = Number(config.cacheTtlMs ?? 5 * 60 * 1000);
421
+ this.fallbackDays = Number(config.fallbackDays ?? DEFAULT_FALLBACK_DAYS);
422
+ this.logger = options.logger ?? console;
423
+ this.readParquetImpl = options.readParquetImpl ?? readParquetFromUrl;
424
+ this.cache = new Map();
425
+ }
426
+
427
+ isConfigured() {
428
+ return Boolean(this.baseUrl);
429
+ }
430
+
431
+ async getLatestAvailableDate(targetDate) {
432
+ if (!this.isConfigured()) {
433
+ return null;
434
+ }
435
+
436
+ const safeTargetDate = parseDateOrToday(targetDate);
437
+ for (let offset = 0; offset <= this.fallbackDays; offset += 1) {
438
+ const candidate = addDays(safeTargetDate, -offset);
439
+ try {
440
+ await this.readDailyFile(candidate, 'slate.parquet', SLATE_COLUMNS);
441
+ return candidate;
442
+ } catch (error) {
443
+ this.logger?.debug?.('Hosted slate check failed', { candidate, error: error.message });
444
+ }
445
+ }
446
+
447
+ return null;
448
+ }
449
+
450
+ async readDailyFile(targetDate, filename, columns) {
451
+ const safeDate = parseDateOrToday(targetDate);
452
+ const url = `${this.baseUrl}/daily/${safeDate}/${filename}`;
453
+ return this.readCached(url, columns);
454
+ }
455
+
456
+ async readReusableFile(filename, columns) {
457
+ const url = `${this.baseUrl}/reusable/${filename}`;
458
+ return this.readCached(url, columns);
459
+ }
460
+
461
+ async readCached(url, columns) {
462
+ const cacheKey = `${url}|${(columns ?? []).join(',')}`;
463
+ const now = Date.now();
464
+ const cached = this.cache.get(cacheKey);
465
+ if (cached && cached.expiresAt > now) {
466
+ return cached.rows;
467
+ }
468
+
469
+ const rows = await this.readParquetImpl(url, columns);
470
+ this.cache.set(cacheKey, {
471
+ expiresAt: now + this.cacheTtlMs,
472
+ rows,
473
+ });
474
+ return rows;
475
+ }
476
+
477
+ async getTopHitters(options = {}) {
478
+ const targetDate = parseDateOrToday(options.date);
479
+ const resolvedDate = await this.getLatestAvailableDate(targetDate);
480
+ if (!resolvedDate) {
481
+ throw new Error('No hosted matchup slate was available in the fallback window.');
482
+ }
483
+
484
+ const [slateRows, hitterRows, exclusionRows] = await Promise.all([
485
+ this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
486
+ this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
487
+ this.readDailyFile(resolvedDate, 'hitter_pitcher_exclusions.parquet', EXCLUSION_COLUMNS)
488
+ .catch(() => []),
489
+ ]);
490
+
491
+ const exclusionSet = buildExclusionSet(exclusionRows);
492
+ const slateLookup = mapSlateTeams(slateRows);
493
+ const filteredRows = hitterRows
494
+ .filter(keepRowForDefaults)
495
+ .map((row) => withDefaults(row, slateLookup))
496
+ .filter((row) => !options.team || normalizeTeam(row.team) === normalizeTeam(options.team))
497
+ .filter((row) => {
498
+ const playerId = row.batter ?? row.player_id;
499
+ return !exclusionSet.has(String(playerId ?? ''));
500
+ });
501
+
502
+ return {
503
+ source: 'hosted',
504
+ resolvedDate,
505
+ rows: sortHitters(filteredRows).slice(0, limitOrDefault(options.limit)),
506
+ };
507
+ }
508
+
509
+ async getTopPitchers(options = {}) {
510
+ const targetDate = parseDateOrToday(options.date);
511
+ const resolvedDate = await this.getLatestAvailableDate(targetDate);
512
+ if (!resolvedDate) {
513
+ throw new Error('No hosted pitcher slate was available in the fallback window.');
514
+ }
515
+
516
+ const [slateRows, pitcherRows] = await Promise.all([
517
+ this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
518
+ this.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
519
+ ]);
520
+
521
+ const slateLookup = mapSlateTeams(slateRows);
522
+ const filteredRows = pitcherRows
523
+ .filter(keepRowForDefaults)
524
+ .map((row) => withDefaults(row, slateLookup))
525
+ .filter((row) => !options.team || normalizeTeam(row.team) === normalizeTeam(options.team));
526
+
527
+ return {
528
+ source: 'hosted',
529
+ resolvedDate,
530
+ rows: sortPitchers(filteredRows).slice(0, limitOrDefault(options.limit)),
531
+ };
532
+ }
533
+
534
+ async getBestMatchups(options = {}) {
535
+ const result = await this.getTopHitters({
536
+ ...options,
537
+ limit: limitOrDefault(options.limit, 12),
538
+ });
539
+ return {
540
+ ...result,
541
+ rows: result.rows,
542
+ };
543
+ }
544
+
545
+ async getPlayerContext(options = {}) {
546
+ const targetDate = parseDateOrToday(options.date);
547
+ const resolvedDate = await this.getLatestAvailableDate(targetDate);
548
+ if (!resolvedDate) {
549
+ throw new Error('No hosted player context was available in the fallback window.');
550
+ }
551
+
552
+ const [slateRows, dailyHitterRows, dailyPitcherRows, reusableHitters, reusablePitchers, hitterRollingRows, pitcherRollingRows, batterZoneRows, pitcherZoneRows, arsenalRows, countUsageRows] = await Promise.all([
553
+ this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
554
+ this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
555
+ this.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
556
+ this.readReusableFile('hitter_metrics.parquet', REUSABLE_HITTER_COLUMNS),
557
+ this.readReusableFile('pitcher_metrics.parquet', REUSABLE_PITCHER_COLUMNS),
558
+ this.readReusableFile('hitter_rolling.parquet', HITTER_ROLLING_COLUMNS).catch(() => []),
559
+ this.readReusableFile('pitcher_rolling.parquet', PITCHER_ROLLING_COLUMNS).catch(() => []),
560
+ this.readReusableFile('batter_zone_profiles.parquet', BATTER_ZONE_COLUMNS).catch(() => []),
561
+ this.readReusableFile('pitcher_zone_profiles.parquet', PITCHER_ZONE_COLUMNS).catch(() => []),
562
+ this.readReusableFile('pitcher_arsenal.parquet', ARSENAL_COLUMNS).catch(() => []),
563
+ this.readReusableFile('pitcher_usage_by_count.parquet', COUNT_USAGE_COLUMNS).catch(() => []),
564
+ ]);
565
+
566
+ const slateLookup = mapSlateTeams(slateRows);
567
+ const normalizedType = normalizeText(options.playerType || 'auto');
568
+
569
+ const dailyHitters = dailyHitterRows.filter(keepRowForDefaults).map((row) => withDefaults(row, slateLookup));
570
+ const dailyPitchers = dailyPitcherRows.filter(keepRowForDefaults).map((row) => withDefaults(row, slateLookup));
571
+ const baseHitters = reusableHitters.filter(keepRowForDefaults);
572
+ const basePitchers = reusablePitchers.filter(keepRowForDefaults);
573
+
574
+ const hitterMatch = normalizedType === 'pitcher' ? null : findBestPlayerMatch(dailyHitters, 'hitter_name', options.player)
575
+ ?? findBestPlayerMatch(baseHitters, 'hitter_name', options.player);
576
+ const pitcherMatch = normalizedType === 'hitter' ? null : findBestPlayerMatch(dailyPitchers, 'pitcher_name', options.player)
577
+ ?? findBestPlayerMatch(basePitchers, 'pitcher_name', options.player);
578
+
579
+ if (hitterMatch) {
580
+ const playerId = String(hitterMatch.batter ?? hitterMatch.player_id ?? '');
581
+ return {
582
+ source: 'hosted',
583
+ resolvedDate,
584
+ playerType: 'hitter',
585
+ name: hitterMatch.hitter_name,
586
+ team: hitterMatch.team ?? null,
587
+ opponentTeam: hitterMatch.opponent_team ?? null,
588
+ opposingPitcherName: hitterMatch.opposing_pitcher_name ?? null,
589
+ hand: hitterMatch.opposing_pitcher_hand ?? null,
590
+ overview: hitterMatch,
591
+ metrics: pickMetrics(hitterMatch, [
592
+ ['Matchup', 'matchup_score'],
593
+ ['Ceiling', 'ceiling_score'],
594
+ ['Zone Fit', 'zone_fit_score'],
595
+ ['Likely', 'likely_starter_score'],
596
+ ['xwOBA', 'xwoba'],
597
+ ['HH%', 'hard_hit_pct'],
598
+ ['Brl/BIP%', 'barrel_bip_pct'],
599
+ ]),
600
+ rolling: buildRollingSummary(
601
+ hitterRollingRows.filter((row) => String(row.batter ?? row.player_id ?? '') === playerId),
602
+ 'window_label',
603
+ 'xwoba'
604
+ ),
605
+ zones: buildZoneSummary(
606
+ batterZoneRows.filter((row) => String(row.batter ?? row.player_id ?? '') === playerId),
607
+ 'hitter_name',
608
+ ['xwoba', 'hard_hit_pct', 'barrel_bip_pct']
609
+ ),
610
+ arsenal: [],
611
+ countUsage: [],
612
+ };
613
+ }
614
+
615
+ if (pitcherMatch) {
616
+ const playerId = String(pitcherMatch.pitcher_id ?? pitcherMatch.player_id ?? '');
617
+ return {
618
+ source: 'hosted',
619
+ resolvedDate,
620
+ playerType: 'pitcher',
621
+ name: pitcherMatch.pitcher_name,
622
+ team: pitcherMatch.team ?? null,
623
+ opponentTeam: pitcherMatch.opponent_team ?? null,
624
+ hand: pitcherMatch.p_throws ?? null,
625
+ overview: pitcherMatch,
626
+ metrics: pickMetrics(pitcherMatch, [
627
+ ['Pitch Score', 'pitcher_score'],
628
+ ['Strikeout', 'strikeout_score'],
629
+ ['Matchup Adj', 'pitcher_matchup_adjustment'],
630
+ ['K Adj', 'strikeout_matchup_adjustment'],
631
+ ['xwOBA', 'xwoba'],
632
+ ['CSW%', 'csw_pct'],
633
+ ['SwStr%', 'swstr_pct'],
634
+ ]),
635
+ rolling: buildRollingSummary(
636
+ pitcherRollingRows.filter((row) => String(row.pitcher_id ?? row.player_id ?? '') === playerId),
637
+ 'window_label',
638
+ 'xwoba'
639
+ ),
640
+ zones: buildZoneSummary(
641
+ pitcherZoneRows.filter((row) => String(row.pitcher_id ?? row.player_id ?? '') === playerId),
642
+ 'pitcher_name',
643
+ ['xwoba_allowed', 'hard_hit_pct_allowed', 'barrel_bip_pct_allowed']
644
+ ),
645
+ arsenal: buildArsenalSummary(
646
+ arsenalRows.filter((row) => String(row.pitcher_id ?? row.player_id ?? '') === playerId)
647
+ ),
648
+ countUsage: buildCountUsageSummary(
649
+ countUsageRows.filter((row) => String(row.pitcher_id ?? row.player_id ?? '') === playerId)
650
+ ),
651
+ };
652
+ }
653
+
654
+ throw new Error(`No hosted matchup profile matched "${options.player}".`);
655
+ }
656
+
657
+ async getHealth(options = {}) {
658
+ const targetDate = parseDateOrToday(options.date);
659
+ const latestDate = await this.getLatestAvailableDate(targetDate);
660
+ return {
661
+ configured: this.isConfigured(),
662
+ baseUrl: this.baseUrl || null,
663
+ latestDate,
664
+ cacheEntries: this.cache.size,
665
+ cacheTtlMs: this.cacheTtlMs,
666
+ };
667
+ }
668
+ }
669
+
670
+ export class CockroachMatchupSource {
671
+ constructor(databaseUrl, options = {}) {
672
+ const usesSsl = /sslmode=(require|verify-ca|verify-full)/i.test(databaseUrl);
673
+ this.pool = options.pool ?? new Pool({
674
+ connectionString: databaseUrl,
675
+ ssl: usesSsl ? { rejectUnauthorized: false } : undefined,
676
+ });
677
+ this.logger = options.logger ?? console;
678
+ this.retryLimit = Number(options.retryLimit ?? 3);
679
+ }
680
+
681
+ async close() {
682
+ await this.pool.end();
683
+ }
684
+
685
+ async query(text, values = [], attempt = 1) {
686
+ try {
687
+ return await this.pool.query(text, values);
688
+ } catch (error) {
689
+ if (COCKROACH_RETRY_CODES.has(error?.code) && attempt < this.retryLimit) {
690
+ await sleep(50 * attempt);
691
+ return this.query(text, values, attempt + 1);
692
+ }
693
+ throw error;
694
+ }
695
+ }
696
+
697
+ async getLatestSnapshotDate() {
698
+ const { rows } = await this.query(
699
+ `
700
+ SELECT GREATEST(
701
+ COALESCE((SELECT MAX(slate_date)::date FROM public.hitter_model_snapshots), DATE '1970-01-01'),
702
+ COALESCE((SELECT MAX(slate_date)::date FROM public.pitcher_model_snapshots), DATE '1970-01-01')
703
+ ) AS latest_slate_date
704
+ `
705
+ );
706
+ return rows[0]?.latest_slate_date ?? null;
707
+ }
708
+
709
+ async getTopHitters(options = {}) {
710
+ const limit = limitOrDefault(options.limit);
711
+ const values = [options.date ?? null, options.team ?? null, limit];
712
+ const { rows } = await this.query(
713
+ `
714
+ WITH target_date AS (
715
+ SELECT COALESCE($1::date, (SELECT MAX(slate_date)::date FROM public.hitter_model_snapshots)) AS slate_date
716
+ )
717
+ SELECT
718
+ slate_date::date AS slate_date,
719
+ team,
720
+ hitter_name,
721
+ matchup_score,
722
+ ceiling_score,
723
+ zone_fit_score,
724
+ likely_starter_score,
725
+ xwoba
726
+ FROM public.hitter_model_snapshots
727
+ WHERE slate_date::date = (SELECT slate_date FROM target_date)
728
+ AND split_key = $4
729
+ AND recent_window = $5
730
+ AND weighted_mode = $6
731
+ AND ($2::text IS NULL OR team = $2::text)
732
+ ORDER BY matchup_score DESC NULLS LAST, ceiling_score DESC NULLS LAST, likely_starter_score DESC NULLS LAST, xwoba DESC NULLS LAST
733
+ LIMIT $3
734
+ `,
735
+ [...values, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE]
736
+ );
737
+
738
+ return {
739
+ source: 'cockroach',
740
+ resolvedDate: rows[0]?.slate_date ?? null,
741
+ rows,
742
+ };
743
+ }
744
+
745
+ async getTopPitchers(options = {}) {
746
+ const limit = limitOrDefault(options.limit);
747
+ const values = [options.date ?? null, options.team ?? null, limit];
748
+ const { rows } = await this.query(
749
+ `
750
+ WITH target_date AS (
751
+ SELECT COALESCE($1::date, (SELECT MAX(slate_date)::date FROM public.pitcher_model_snapshots)) AS slate_date
752
+ )
753
+ SELECT
754
+ slate_date::date AS slate_date,
755
+ team,
756
+ pitcher_name,
757
+ p_throws,
758
+ pitcher_score,
759
+ strikeout_score,
760
+ raw_pitcher_score,
761
+ raw_strikeout_score,
762
+ pitcher_matchup_adjustment,
763
+ strikeout_matchup_adjustment,
764
+ opponent_lineup_quality,
765
+ opponent_contact_threat,
766
+ opponent_whiff_tendency,
767
+ lineup_source,
768
+ lineup_hitter_count,
769
+ xwoba,
770
+ csw_pct,
771
+ swstr_pct,
772
+ putaway_pct,
773
+ ball_pct,
774
+ siera,
775
+ gb_pct,
776
+ gb_fb_ratio,
777
+ barrel_bip_pct,
778
+ hard_hit_pct
779
+ FROM public.pitcher_model_snapshots
780
+ WHERE slate_date::date = (SELECT slate_date FROM target_date)
781
+ AND split_key = $4
782
+ AND recent_window = $5
783
+ AND weighted_mode = $6
784
+ AND ($2::text IS NULL OR team = $2::text)
785
+ ORDER BY pitcher_score DESC NULLS LAST, strikeout_score DESC NULLS LAST, pitcher_matchup_adjustment DESC NULLS LAST, xwoba ASC NULLS LAST
786
+ LIMIT $3
787
+ `,
788
+ [...values, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE]
789
+ );
790
+
791
+ return {
792
+ source: 'cockroach',
793
+ resolvedDate: rows[0]?.slate_date ?? null,
794
+ rows,
795
+ };
796
+ }
797
+
798
+ async getBestMatchups(options = {}) {
799
+ return this.getTopHitters({
800
+ ...options,
801
+ limit: limitOrDefault(options.limit, 12),
802
+ });
803
+ }
804
+
805
+ async getPlayerContext(options = {}) {
806
+ const hitterResult = normalizeText(options.playerType) === 'pitcher'
807
+ ? { rows: [] }
808
+ : await this.query(
809
+ `
810
+ SELECT
811
+ slate_date::date AS slate_date,
812
+ team,
813
+ hitter_name,
814
+ matchup_score,
815
+ ceiling_score,
816
+ zone_fit_score,
817
+ likely_starter_score,
818
+ xwoba,
819
+ hard_hit_pct,
820
+ barrel_bip_pct
821
+ FROM public.hitter_model_snapshots
822
+ WHERE slate_date::date = COALESCE($2::date, (SELECT MAX(slate_date)::date FROM public.hitter_model_snapshots))
823
+ AND split_key = $3
824
+ AND recent_window = $4
825
+ AND weighted_mode = $5
826
+ AND LOWER(hitter_name) LIKE LOWER($1)
827
+ ORDER BY CASE WHEN LOWER(hitter_name) = LOWER($6) THEN 0 ELSE 1 END, matchup_score DESC NULLS LAST
828
+ LIMIT $7
829
+ `,
830
+ [`%${options.player}%`, options.date ?? null, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE, options.player, PROFILE_CANDIDATE_LIMIT]
831
+ );
832
+
833
+ const pitcherResult = normalizeText(options.playerType) === 'hitter'
834
+ ? { rows: [] }
835
+ : await this.query(
836
+ `
837
+ SELECT
838
+ slate_date::date AS slate_date,
839
+ team,
840
+ pitcher_name,
841
+ p_throws,
842
+ pitcher_score,
843
+ strikeout_score,
844
+ pitcher_matchup_adjustment,
845
+ strikeout_matchup_adjustment,
846
+ xwoba,
847
+ csw_pct,
848
+ swstr_pct
849
+ FROM public.pitcher_model_snapshots
850
+ WHERE slate_date::date = COALESCE($2::date, (SELECT MAX(slate_date)::date FROM public.pitcher_model_snapshots))
851
+ AND split_key = $3
852
+ AND recent_window = $4
853
+ AND weighted_mode = $5
854
+ AND LOWER(pitcher_name) LIKE LOWER($1)
855
+ ORDER BY CASE WHEN LOWER(pitcher_name) = LOWER($6) THEN 0 ELSE 1 END, pitcher_score DESC NULLS LAST
856
+ LIMIT $7
857
+ `,
858
+ [`%${options.player}%`, options.date ?? null, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE, options.player, PROFILE_CANDIDATE_LIMIT]
859
+ );
860
+
861
+ const hitter = hitterResult.rows[0] ?? null;
862
+ const pitcher = pitcherResult.rows[0] ?? null;
863
+
864
+ if (hitter) {
865
+ return {
866
+ source: 'cockroach',
867
+ resolvedDate: hitter.slate_date ?? null,
868
+ playerType: 'hitter',
869
+ name: hitter.hitter_name,
870
+ team: hitter.team ?? null,
871
+ opponentTeam: null,
872
+ opposingPitcherName: null,
873
+ hand: null,
874
+ overview: hitter,
875
+ metrics: pickMetrics(hitter, [
876
+ ['Matchup', 'matchup_score'],
877
+ ['Ceiling', 'ceiling_score'],
878
+ ['Zone Fit', 'zone_fit_score'],
879
+ ['Likely', 'likely_starter_score'],
880
+ ['xwOBA', 'xwoba'],
881
+ ['HH%', 'hard_hit_pct'],
882
+ ['Brl/BIP%', 'barrel_bip_pct'],
883
+ ]),
884
+ rolling: [],
885
+ zones: [],
886
+ arsenal: [],
887
+ countUsage: [],
888
+ };
889
+ }
890
+
891
+ if (pitcher) {
892
+ return {
893
+ source: 'cockroach',
894
+ resolvedDate: pitcher.slate_date ?? null,
895
+ playerType: 'pitcher',
896
+ name: pitcher.pitcher_name,
897
+ team: pitcher.team ?? null,
898
+ opponentTeam: null,
899
+ hand: pitcher.p_throws ?? null,
900
+ overview: pitcher,
901
+ metrics: pickMetrics(pitcher, [
902
+ ['Pitch Score', 'pitcher_score'],
903
+ ['Strikeout', 'strikeout_score'],
904
+ ['Matchup Adj', 'pitcher_matchup_adjustment'],
905
+ ['K Adj', 'strikeout_matchup_adjustment'],
906
+ ['xwOBA', 'xwoba'],
907
+ ['CSW%', 'csw_pct'],
908
+ ['SwStr%', 'swstr_pct'],
909
+ ]),
910
+ rolling: [],
911
+ zones: [],
912
+ arsenal: [],
913
+ countUsage: [],
914
+ };
915
+ }
916
+
917
+ throw new Error(`No Cockroach matchup profile matched "${options.player}".`);
918
+ }
919
+
920
+ async getHealth(options = {}) {
921
+ const latestDate = await this.getLatestSnapshotDate();
922
+ return {
923
+ configured: true,
924
+ latestDate: latestDate ?? null,
925
+ requestedDate: options.date ?? null,
926
+ };
927
+ }
928
+ }
929
+
930
+ export class MatchupService {
931
+ constructor(config = {}, options = {}) {
932
+ this.logger = options.logger ?? console;
933
+ this.hosted = options.hosted ?? new HostedArtifactSource(config.hosted ?? {}, { logger: this.logger });
934
+ this.fallback = options.fallback ?? new CockroachMatchupSource(config.databaseUrl, { logger: this.logger });
935
+ }
936
+
937
+ async close() {
938
+ await this.fallback?.close?.();
939
+ }
940
+
941
+ async runHostedFirst(methodName, options = {}) {
942
+ let hostedError = null;
943
+ if (this.hosted?.isConfigured?.()) {
944
+ try {
945
+ return await this.hosted[methodName](options);
946
+ } catch (error) {
947
+ hostedError = error;
948
+ this.logger?.warn?.(`Hosted matchup source failed for ${methodName}`, {
949
+ error: error.message,
950
+ options,
951
+ });
952
+ }
953
+ }
954
+
955
+ const result = await this.fallback[methodName](options);
956
+ if (hostedError) {
957
+ result.warning = hostedError.message;
958
+ }
959
+ return result;
960
+ }
961
+
962
+ async getTopHitters(options = {}) {
963
+ return this.runHostedFirst('getTopHitters', options);
964
+ }
965
+
966
+ async getTopPitchers(options = {}) {
967
+ return this.runHostedFirst('getTopPitchers', options);
968
+ }
969
+
970
+ async getBestMatchups(options = {}) {
971
+ return this.runHostedFirst('getBestMatchups', options);
972
+ }
973
+
974
+ async getPlayerContext(options = {}) {
975
+ return this.runHostedFirst('getPlayerContext', options);
976
+ }
977
+
978
+ async getHealth(options = {}) {
979
+ const [hosted, fallback] = await Promise.all([
980
+ this.hosted.getHealth(options).catch((error) => ({
981
+ configured: this.hosted?.isConfigured?.() ?? false,
982
+ latestDate: null,
983
+ error: error.message,
984
+ })),
985
+ this.fallback.getHealth(options).catch((error) => ({
986
+ configured: true,
987
+ latestDate: null,
988
+ error: error.message,
989
+ })),
990
+ ]);
991
+
992
+ return {
993
+ hosted,
994
+ fallback,
995
+ };
996
+ }
997
+ }
998
+
999
+ export async function readParquetFromUrl(url, columns) {
1000
+ const file = await asyncBufferFromUrl({ url });
1001
+ return parquetReadObjects({
1002
+ file,
1003
+ columns,
1004
+ });
1005
+ }
test/matchups.test.js ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { commands } from '../src/commands.js';
4
+ import { buildMatchupHittersEmbed, buildPlayerContextEmbed } from '../src/embeds.js';
5
+ import { HostedArtifactSource, MatchupService } from '../src/matchups.js';
6
+
7
+ test('hosted artifact source resolves latest available daily slate and caches parquet reads', async () => {
8
+ const responses = new Map([
9
+ ['https://example.test/daily/2026-04-06/slate.parquet', [
10
+ {
11
+ game_pk: 10,
12
+ away_team: 'CHC',
13
+ home_team: 'MIL',
14
+ away_probable_pitcher: 'Shota Imanaga',
15
+ home_probable_pitcher: 'Freddy Peralta',
16
+ away_probable_hand: 'L',
17
+ home_probable_hand: 'R',
18
+ },
19
+ ]],
20
+ ['https://example.test/daily/2026-04-06/daily_hitter_metrics.parquet', [
21
+ {
22
+ team: 'CHC',
23
+ hitter_name: 'Seiya Suzuki',
24
+ batter: 100,
25
+ split_key: 'overall',
26
+ recent_window: 'season',
27
+ weighted_mode: 'weighted',
28
+ matchup_score: 82.4,
29
+ ceiling_score: 79.5,
30
+ zone_fit_score: 70.1,
31
+ likely_starter_score: 98.2,
32
+ xwoba: 0.398,
33
+ hard_hit_pct: 48.2,
34
+ },
35
+ ]],
36
+ ['https://example.test/daily/2026-04-06/hitter_pitcher_exclusions.parquet', []],
37
+ ]);
38
+ const calls = [];
39
+ const source = new HostedArtifactSource(
40
+ {
41
+ baseUrl: 'https://example.test',
42
+ cacheTtlMs: 60_000,
43
+ fallbackDays: 2,
44
+ },
45
+ {
46
+ readParquetImpl: async (url) => {
47
+ calls.push(url);
48
+ if (!responses.has(url)) {
49
+ throw new Error(`Missing ${url}`);
50
+ }
51
+ return responses.get(url);
52
+ },
53
+ logger: { debug() {}, warn() {} },
54
+ }
55
+ );
56
+
57
+ const first = await source.getTopHitters({ date: '2026-04-07' });
58
+ const second = await source.getTopHitters({ date: '2026-04-07' });
59
+
60
+ assert.equal(first.resolvedDate, '2026-04-06');
61
+ assert.equal(first.rows[0].hitter_name, 'Seiya Suzuki');
62
+ assert.equal(first.rows[0].opponent_team, 'MIL');
63
+ assert.equal(second.rows[0].opposing_pitcher_name, 'Freddy Peralta');
64
+ assert.equal(calls.filter((url) => url.includes('2026-04-06/slate.parquet')).length, 1);
65
+ assert.equal(calls.filter((url) => url.includes('daily_hitter_metrics.parquet')).length, 1);
66
+ });
67
+
68
+ test('matchup service falls back to Cockroach when hosted source fails', async () => {
69
+ const service = new MatchupService(
70
+ { databaseUrl: 'postgresql://test', hosted: { baseUrl: 'https://example.test' } },
71
+ {
72
+ hosted: {
73
+ isConfigured: () => true,
74
+ async getTopPitchers() {
75
+ throw new Error('hosted down');
76
+ },
77
+ async getHealth() {
78
+ return { configured: true, latestDate: null };
79
+ },
80
+ },
81
+ fallback: {
82
+ async getTopPitchers() {
83
+ return {
84
+ source: 'cockroach',
85
+ resolvedDate: '2026-04-06',
86
+ rows: [{ pitcher_name: 'Tarik Skubal', pitcher_score: 91.2, strikeout_score: 88.1 }],
87
+ };
88
+ },
89
+ async getHealth() {
90
+ return { configured: true, latestDate: '2026-04-06' };
91
+ },
92
+ async close() {},
93
+ },
94
+ logger: { warn() {} },
95
+ }
96
+ );
97
+
98
+ const result = await service.getTopPitchers({ date: '2026-04-07' });
99
+
100
+ assert.equal(result.source, 'cockroach');
101
+ assert.equal(result.rows[0].pitcher_name, 'Tarik Skubal');
102
+ assert.match(result.warning, /hosted down/i);
103
+ });
104
+
105
+ test('commands include new matchup commands', () => {
106
+ const names = commands.map((command) => command.name);
107
+ assert.ok(names.includes('matchuphitters'));
108
+ assert.ok(names.includes('matchuppitchers'));
109
+ assert.ok(names.includes('playercontext'));
110
+ assert.ok(names.includes('bestmatchups'));
111
+ assert.ok(names.includes('matchuphealth'));
112
+ });
113
+
114
+ test('matchup embeds render core matchup data clearly', () => {
115
+ const hittersEmbed = buildMatchupHittersEmbed({
116
+ source: 'hosted',
117
+ resolvedDate: '2026-04-06',
118
+ rows: [{
119
+ hitter_name: 'Aaron Judge',
120
+ team: 'NYY',
121
+ matchup_score: 85.1,
122
+ ceiling_score: 90.2,
123
+ zone_fit_score: 78.4,
124
+ likely_starter_score: 99.0,
125
+ xwoba: 0.455,
126
+ hard_hit_pct: 57.2,
127
+ opponent_team: 'BOS',
128
+ opposing_pitcher_name: 'Tanner Houck',
129
+ opposing_pitcher_hand: 'R',
130
+ }],
131
+ });
132
+ const playerEmbed = buildPlayerContextEmbed({
133
+ source: 'hosted',
134
+ resolvedDate: '2026-04-06',
135
+ playerType: 'pitcher',
136
+ name: 'Paul Skenes',
137
+ team: 'PIT',
138
+ metrics: [
139
+ { label: 'Pitch Score', value: 92.2 },
140
+ { label: 'Strikeout', value: 89.5 },
141
+ ],
142
+ rolling: [{ label: 'Rolling 5', value: 0.245 }],
143
+ zones: [{ label: 'Up-In', metricKey: 'xwoba_allowed', value: 0.198, sample: 22 }],
144
+ arsenal: [{ pitchType: 'Four-Seam', usagePct: 41.2, velocity: 99.1, whiffRate: 31.4 }],
145
+ countUsage: [{ countBucket: 'Putaway', batterSide: 'R', pitchType: 'Slider', usagePct: 38.2 }],
146
+ });
147
+
148
+ assert.equal(hittersEmbed.data.title, 'Matchup Hitters');
149
+ assert.match(hittersEmbed.data.fields[0].name, /Aaron Judge/);
150
+ assert.equal(playerEmbed.data.title, 'Paul Skenes - Pitcher');
151
+ assert.ok(playerEmbed.data.fields.some((field) => field.name === 'Arsenal'));
152
+ });