Codex commited on
Commit ·
7161e89
1
Parent(s): b8c237d
Add hosted matchup commands
Browse files- package-lock.json +7 -0
- package.json +1 -0
- src/commands.js +83 -0
- src/config.js +12 -0
- src/embeds.js +247 -0
- src/index.js +123 -0
- src/matchups.js +1005 -0
- 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 |
+
});
|