Codex commited on
Commit ·
b24e6f2
1
Parent(s): e169425
Match hosted hitter commands to live app parity
Browse files- src/matchups.js +495 -70
- test/matchups.test.js +69 -5
src/matchups.js
CHANGED
|
@@ -6,8 +6,18 @@ 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 |
const MLB_TEAM_ALIASES = new Map([
|
| 12 |
['ARI', ['ari', 'arizona', 'diamondbacks', 'arizona diamondbacks']],
|
| 13 |
['ATL', ['atl', 'atlanta', 'braves', 'atlanta braves']],
|
|
@@ -50,6 +60,7 @@ const HITTER_COLUMNS = [
|
|
| 50 |
'batter',
|
| 51 |
'player_id',
|
| 52 |
'hitter_name',
|
|
|
|
| 53 |
'split_key',
|
| 54 |
'recent_window',
|
| 55 |
'weighted_mode',
|
|
@@ -58,6 +69,8 @@ const HITTER_COLUMNS = [
|
|
| 58 |
'zone_fit_score',
|
| 59 |
'likely_starter_score',
|
| 60 |
'xwoba',
|
|
|
|
|
|
|
| 61 |
'fb_pct',
|
| 62 |
'pulled_barrel_pct',
|
| 63 |
'sweet_spot_pct',
|
|
@@ -276,6 +289,16 @@ function normalizeText(value) {
|
|
| 276 |
return String(value ?? '').trim().toLowerCase();
|
| 277 |
}
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
function normalizeTeam(value) {
|
| 280 |
return String(value ?? '').trim().toUpperCase();
|
| 281 |
}
|
|
@@ -343,6 +366,14 @@ function compareNullableDescending(left, right) {
|
|
| 343 |
return rightValue - leftValue;
|
| 344 |
}
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
function sortHitters(rows) {
|
| 347 |
return [...rows].sort((left, right) =>
|
| 348 |
compareNullableDescending(left.matchup_score, right.matchup_score)
|
|
@@ -649,6 +680,32 @@ function buildBestMatchupBoardRows(rows) {
|
|
| 649 |
return sortHitters(boardRows);
|
| 650 |
}
|
| 651 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
function keepRowForDefaults(row) {
|
| 653 |
const splitKey = String(row.split_key ?? DEFAULT_SPLIT_KEY);
|
| 654 |
const recentWindow = String(row.recent_window ?? DEFAULT_RECENT_WINDOW);
|
|
@@ -714,6 +771,44 @@ function buildRosterLookup(rows) {
|
|
| 714 |
return lookup;
|
| 715 |
}
|
| 716 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
function resolveHostedPlayerName(row, rosterLookup, type = 'hitter') {
|
| 718 |
const rawName = type === 'pitcher'
|
| 719 |
? String(row.pitcher_name ?? row.player_name ?? '').trim()
|
|
@@ -756,6 +851,232 @@ function withDefaults(row, slateLookup, options = {}) {
|
|
| 756 |
};
|
| 757 |
}
|
| 758 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
function pickMetrics(row, metricKeys) {
|
| 760 |
return metricKeys
|
| 761 |
.map(([label, key]) => ({ label, key, value: numberOrNull(row[key]) }))
|
|
@@ -839,6 +1160,8 @@ export class HostedArtifactSource {
|
|
| 839 |
this.fallbackDays = Number(config.fallbackDays ?? DEFAULT_FALLBACK_DAYS);
|
| 840 |
this.logger = options.logger ?? console;
|
| 841 |
this.readParquetImpl = options.readParquetImpl ?? readParquetFromUrl;
|
|
|
|
|
|
|
| 842 |
this.cache = new Map();
|
| 843 |
}
|
| 844 |
|
|
@@ -892,37 +1215,177 @@ export class HostedArtifactSource {
|
|
| 892 |
return rows;
|
| 893 |
}
|
| 894 |
|
| 895 |
-
async
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 896 |
const targetDate = parseDateOrToday(options.date);
|
| 897 |
const resolvedDate = await this.getLatestAvailableDate(targetDate);
|
| 898 |
if (!resolvedDate) {
|
| 899 |
throw new Error('No hosted matchup slate was available in the fallback window.');
|
| 900 |
}
|
| 901 |
|
| 902 |
-
const [slateRows, hitterRows, exclusionRows, rosterRows] = await Promise.all([
|
| 903 |
this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
|
| 904 |
this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
|
|
|
|
| 905 |
this.readDailyFile(resolvedDate, 'hitter_pitcher_exclusions.parquet', EXCLUSION_COLUMNS).catch(() => []),
|
| 906 |
this.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
|
|
|
|
|
|
|
| 907 |
]);
|
| 908 |
|
| 909 |
-
const
|
|
|
|
|
|
|
|
|
|
| 910 |
const slateLookup = mapSlateTeams(slateRows);
|
|
|
|
| 911 |
const rosterLookup = buildRosterLookup(rosterRows);
|
| 912 |
-
const
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
});
|
| 920 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
return {
|
| 922 |
source: 'hosted',
|
| 923 |
resolvedDate,
|
| 924 |
-
|
| 925 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
}
|
| 927 |
|
| 928 |
async getTopPitchers(options = {}) {
|
|
@@ -953,64 +1416,12 @@ export class HostedArtifactSource {
|
|
| 953 |
}
|
| 954 |
|
| 955 |
async getBestMatchups(options = {}) {
|
| 956 |
-
const
|
| 957 |
-
const resolvedDate = await this.getLatestAvailableDate(targetDate);
|
| 958 |
-
if (!resolvedDate) {
|
| 959 |
-
throw new Error('No hosted matchup slate was available in the fallback window.');
|
| 960 |
-
}
|
| 961 |
-
|
| 962 |
-
const [slateRows, hitterRows, exclusionRows, rosterRows, batterZoneRows, pitcherZoneRows] = await Promise.all([
|
| 963 |
-
this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
|
| 964 |
-
this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
|
| 965 |
-
this.readDailyFile(resolvedDate, 'hitter_pitcher_exclusions.parquet', EXCLUSION_COLUMNS).catch(() => []),
|
| 966 |
-
this.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
|
| 967 |
-
this.readDailyFile(resolvedDate, 'daily_batter_zone_profiles.parquet', BATTER_ZONE_COLUMNS).catch(() => []),
|
| 968 |
-
this.readDailyFile(resolvedDate, 'daily_pitcher_zone_profiles.parquet', PITCHER_ZONE_COLUMNS).catch(() => []),
|
| 969 |
-
]);
|
| 970 |
-
|
| 971 |
-
const slateLookup = mapSlateTeams(slateRows);
|
| 972 |
-
const exclusionSet = buildExclusionSet(exclusionRows);
|
| 973 |
-
const rosterLookup = buildRosterLookup(rosterRows);
|
| 974 |
-
const rosterIdsByTeam = new Map();
|
| 975 |
-
for (const row of rosterRows) {
|
| 976 |
-
const team = normalizeTeam(row.team);
|
| 977 |
-
const playerId = String(row.player_id ?? '').trim();
|
| 978 |
-
if (!team || !playerId) {
|
| 979 |
-
continue;
|
| 980 |
-
}
|
| 981 |
-
const bucket = rosterIdsByTeam.get(team) ?? new Set();
|
| 982 |
-
bucket.add(playerId);
|
| 983 |
-
rosterIdsByTeam.set(team, bucket);
|
| 984 |
-
}
|
| 985 |
-
|
| 986 |
-
const scoredRows = [];
|
| 987 |
-
for (const [team, slate] of slateLookup.entries()) {
|
| 988 |
-
if (!rowMatchesTeamFilter(team, options.team)) {
|
| 989 |
-
continue;
|
| 990 |
-
}
|
| 991 |
-
|
| 992 |
-
const splitKey = slate.opposingPitcherHand === 'R'
|
| 993 |
-
? 'vs_rhp'
|
| 994 |
-
: slate.opposingPitcherHand === 'L'
|
| 995 |
-
? 'vs_lhp'
|
| 996 |
-
: DEFAULT_SPLIT_KEY;
|
| 997 |
-
const rosterPlayerIds = rosterIdsByTeam.get(normalizeTeam(team)) ?? null;
|
| 998 |
-
const teamRows = hitterRows
|
| 999 |
-
.filter((row) => normalizeTeam(row.team) === normalizeTeam(team))
|
| 1000 |
-
.filter((row) => String(row.split_key ?? '') === splitKey)
|
| 1001 |
-
.filter((row) => String(row.recent_window ?? '') === DEFAULT_RECENT_WINDOW)
|
| 1002 |
-
.filter((row) => String(row.weighted_mode ?? '') === DEFAULT_WEIGHTED_MODE)
|
| 1003 |
-
.filter((row) => !rosterPlayerIds || rosterPlayerIds.has(String(row.batter ?? row.player_id ?? '')))
|
| 1004 |
-
.filter((row) => !exclusionSet.has(String(row.batter ?? row.player_id ?? '')))
|
| 1005 |
-
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }));
|
| 1006 |
-
|
| 1007 |
-
scoredRows.push(...addHitterMatchupScore(teamRows, batterZoneRows, pitcherZoneRows));
|
| 1008 |
-
}
|
| 1009 |
-
|
| 1010 |
return {
|
| 1011 |
-
source:
|
| 1012 |
-
resolvedDate,
|
| 1013 |
-
|
|
|
|
| 1014 |
};
|
| 1015 |
}
|
| 1016 |
|
|
@@ -1456,7 +1867,7 @@ export class MatchupService {
|
|
| 1456 |
if (this.hosted?.isConfigured?.()) {
|
| 1457 |
try {
|
| 1458 |
const hostedResult = await this.hosted[methodName](options);
|
| 1459 |
-
if (methodName === 'getTopHitters') {
|
| 1460 |
return await this.enrichHostedHitters(hostedResult, options, methodName);
|
| 1461 |
}
|
| 1462 |
return hostedResult;
|
|
@@ -1555,6 +1966,20 @@ export class MatchupService {
|
|
| 1555 |
}
|
| 1556 |
}
|
| 1557 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1558 |
export async function readParquetFromUrl(url, columns) {
|
| 1559 |
const file = await asyncBufferFromUrl({ url });
|
| 1560 |
return parquetReadObjects({
|
|
|
|
| 6 |
const DEFAULT_SPLIT_KEY = 'overall';
|
| 7 |
const DEFAULT_WEIGHTED_MODE = 'weighted';
|
| 8 |
const DEFAULT_FALLBACK_DAYS = 7;
|
| 9 |
+
const DEFAULT_ROTOWIRE_URL = 'https://www.rotowire.com/baseball/daily-lineups.php';
|
| 10 |
const COCKROACH_RETRY_CODES = new Set(['40001']);
|
| 11 |
const PROFILE_CANDIDATE_LIMIT = 5;
|
| 12 |
+
const ROTOWIRE_LINEUP_STATUS = new Map([
|
| 13 |
+
['Confirmed Lineup', 'confirmed'],
|
| 14 |
+
['Expected Lineup', 'expected'],
|
| 15 |
+
['Unknown Lineup', 'unknown'],
|
| 16 |
+
]);
|
| 17 |
+
const ROTOWIRE_POSITION_TOKENS = new Set(['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH', 'P']);
|
| 18 |
+
const ROTOWIRE_STOP_TOKENS = new Set(['Home Run Odds', 'Starting Pitcher Intel']);
|
| 19 |
+
const ROTOWIRE_TIME_RE = /^\d{1,2}:\d{2}\s(?:AM|PM)\sET$/;
|
| 20 |
+
const ROTOWIRE_PRICE_RE = /^\$\d[\d,]*$/;
|
| 21 |
const MLB_TEAM_ALIASES = new Map([
|
| 22 |
['ARI', ['ari', 'arizona', 'diamondbacks', 'arizona diamondbacks']],
|
| 23 |
['ATL', ['atl', 'atlanta', 'braves', 'atlanta braves']],
|
|
|
|
| 60 |
'batter',
|
| 61 |
'player_id',
|
| 62 |
'hitter_name',
|
| 63 |
+
'stand',
|
| 64 |
'split_key',
|
| 65 |
'recent_window',
|
| 66 |
'weighted_mode',
|
|
|
|
| 69 |
'zone_fit_score',
|
| 70 |
'likely_starter_score',
|
| 71 |
'xwoba',
|
| 72 |
+
'swstr_pct',
|
| 73 |
+
'barrel_bbe_pct',
|
| 74 |
'fb_pct',
|
| 75 |
'pulled_barrel_pct',
|
| 76 |
'sweet_spot_pct',
|
|
|
|
| 289 |
return String(value ?? '').trim().toLowerCase();
|
| 290 |
}
|
| 291 |
|
| 292 |
+
function normalizeName(value) {
|
| 293 |
+
return String(value ?? '')
|
| 294 |
+
.normalize('NFKD')
|
| 295 |
+
.replace(/[\u0300-\u036f]/g, '')
|
| 296 |
+
.toLowerCase()
|
| 297 |
+
.replace(/[^a-z0-9]+/g, ' ')
|
| 298 |
+
.trim()
|
| 299 |
+
.replace(/\s+/g, ' ');
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
function normalizeTeam(value) {
|
| 303 |
return String(value ?? '').trim().toUpperCase();
|
| 304 |
}
|
|
|
|
| 366 |
return rightValue - leftValue;
|
| 367 |
}
|
| 368 |
|
| 369 |
+
function sortHittersLiveApp(rows) {
|
| 370 |
+
return [...rows].sort((left, right) =>
|
| 371 |
+
compareNullableDescending(left.matchup_score, right.matchup_score)
|
| 372 |
+
|| compareNullableDescending(left.xwoba, right.xwoba)
|
| 373 |
+
|| String(left.hitter_name ?? '').localeCompare(String(right.hitter_name ?? ''))
|
| 374 |
+
);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
function sortHitters(rows) {
|
| 378 |
return [...rows].sort((left, right) =>
|
| 379 |
compareNullableDescending(left.matchup_score, right.matchup_score)
|
|
|
|
| 680 |
return sortHitters(boardRows);
|
| 681 |
}
|
| 682 |
|
| 683 |
+
function buildBestMatchupBoardRowsLiveApp(rows) {
|
| 684 |
+
if (!rows?.length) {
|
| 685 |
+
return [];
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
const rowsWithGame = rows.filter((row) => row.game_pk !== null && row.game_pk !== undefined && row.game_pk !== '');
|
| 689 |
+
if (!rowsWithGame.length) {
|
| 690 |
+
return sortHittersLiveApp(rows);
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
const grouped = new Map();
|
| 694 |
+
for (const row of rowsWithGame) {
|
| 695 |
+
const key = String(row.game_pk);
|
| 696 |
+
const bucket = grouped.get(key) ?? [];
|
| 697 |
+
bucket.push(row);
|
| 698 |
+
grouped.set(key, bucket);
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
const boardRows = [];
|
| 702 |
+
for (const gameRows of grouped.values()) {
|
| 703 |
+
boardRows.push(...sortHittersLiveApp(gameRows).slice(0, 3));
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
return sortHittersLiveApp(boardRows);
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
function keepRowForDefaults(row) {
|
| 710 |
const splitKey = String(row.split_key ?? DEFAULT_SPLIT_KEY);
|
| 711 |
const recentWindow = String(row.recent_window ?? DEFAULT_RECENT_WINDOW);
|
|
|
|
| 771 |
return lookup;
|
| 772 |
}
|
| 773 |
|
| 774 |
+
function buildRosterIdsByTeam(rows) {
|
| 775 |
+
const lookup = new Map();
|
| 776 |
+
for (const row of rows) {
|
| 777 |
+
const team = normalizeTeam(row.team);
|
| 778 |
+
const playerId = String(row.player_id ?? '').trim();
|
| 779 |
+
if (!team || !playerId) {
|
| 780 |
+
continue;
|
| 781 |
+
}
|
| 782 |
+
const bucket = lookup.get(team) ?? new Set();
|
| 783 |
+
bucket.add(playerId);
|
| 784 |
+
lookup.set(team, bucket);
|
| 785 |
+
}
|
| 786 |
+
return lookup;
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
function buildRosterNameLookupByTeam(rows) {
|
| 790 |
+
const lookup = new Map();
|
| 791 |
+
for (const row of rows) {
|
| 792 |
+
const team = normalizeTeam(row.team);
|
| 793 |
+
const playerName = String(row.player_name ?? '').trim();
|
| 794 |
+
const playerId = String(row.player_id ?? '').trim();
|
| 795 |
+
if (!team || !playerName || !playerId) {
|
| 796 |
+
continue;
|
| 797 |
+
}
|
| 798 |
+
let teamLookup = lookup.get(team);
|
| 799 |
+
if (!teamLookup) {
|
| 800 |
+
teamLookup = {
|
| 801 |
+
exact: new Map(),
|
| 802 |
+
normalized: new Map(),
|
| 803 |
+
};
|
| 804 |
+
lookup.set(team, teamLookup);
|
| 805 |
+
}
|
| 806 |
+
teamLookup.exact.set(playerName.toLowerCase(), playerId);
|
| 807 |
+
teamLookup.normalized.set(normalizeName(playerName), playerId);
|
| 808 |
+
}
|
| 809 |
+
return lookup;
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
function resolveHostedPlayerName(row, rosterLookup, type = 'hitter') {
|
| 813 |
const rawName = type === 'pitcher'
|
| 814 |
? String(row.pitcher_name ?? row.player_name ?? '').trim()
|
|
|
|
| 851 |
};
|
| 852 |
}
|
| 853 |
|
| 854 |
+
function decodeHtmlEntities(value) {
|
| 855 |
+
return String(value ?? '')
|
| 856 |
+
.replace(/ /gi, ' ')
|
| 857 |
+
.replace(/&/gi, '&')
|
| 858 |
+
.replace(/"/gi, '"')
|
| 859 |
+
.replace(/'/gi, '\'')
|
| 860 |
+
.replace(/'/gi, '\'')
|
| 861 |
+
.replace(/</gi, '<')
|
| 862 |
+
.replace(/>/gi, '>');
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
function extractRotowireTokens(html) {
|
| 866 |
+
return decodeHtmlEntities(
|
| 867 |
+
String(html ?? '')
|
| 868 |
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
| 869 |
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
| 870 |
+
.replace(/<[^>]+>/g, '\n')
|
| 871 |
+
)
|
| 872 |
+
.split(/\r?\n/)
|
| 873 |
+
.map((token) => token.trim())
|
| 874 |
+
.filter(Boolean);
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
function nextNonPriceToken(tokens, startIndex) {
|
| 878 |
+
let index = startIndex;
|
| 879 |
+
while (index < tokens.length) {
|
| 880 |
+
if (!ROTOWIRE_PRICE_RE.test(tokens[index])) {
|
| 881 |
+
return { token: tokens[index], index };
|
| 882 |
+
}
|
| 883 |
+
index += 1;
|
| 884 |
+
}
|
| 885 |
+
return { token: null, index };
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
function parseRotowireLineupSide(tokens, startIndex) {
|
| 889 |
+
const rawStatus = tokens[startIndex];
|
| 890 |
+
const status = ROTOWIRE_LINEUP_STATUS.get(rawStatus) ?? 'unknown';
|
| 891 |
+
let index = startIndex + 1;
|
| 892 |
+
const players = [];
|
| 893 |
+
|
| 894 |
+
if (status === 'unknown') {
|
| 895 |
+
while (index < tokens.length) {
|
| 896 |
+
const token = tokens[index];
|
| 897 |
+
if (
|
| 898 |
+
ROTOWIRE_LINEUP_STATUS.has(token)
|
| 899 |
+
|| ROTOWIRE_STOP_TOKENS.has(token)
|
| 900 |
+
|| token.startsWith('Umpire:')
|
| 901 |
+
|| ROTOWIRE_TIME_RE.test(token)
|
| 902 |
+
) {
|
| 903 |
+
break;
|
| 904 |
+
}
|
| 905 |
+
index += 1;
|
| 906 |
+
}
|
| 907 |
+
return { lineup: { status, players }, nextIndex: index };
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
let slot = 1;
|
| 911 |
+
while (index < tokens.length) {
|
| 912 |
+
const token = tokens[index];
|
| 913 |
+
if (
|
| 914 |
+
ROTOWIRE_LINEUP_STATUS.has(token)
|
| 915 |
+
|| ROTOWIRE_STOP_TOKENS.has(token)
|
| 916 |
+
|| token.startsWith('Umpire:')
|
| 917 |
+
|| ROTOWIRE_TIME_RE.test(token)
|
| 918 |
+
) {
|
| 919 |
+
break;
|
| 920 |
+
}
|
| 921 |
+
if (ROTOWIRE_POSITION_TOKENS.has(token)) {
|
| 922 |
+
const next = nextNonPriceToken(tokens, index + 1);
|
| 923 |
+
if (
|
| 924 |
+
next.token
|
| 925 |
+
&& !ROTOWIRE_POSITION_TOKENS.has(next.token)
|
| 926 |
+
&& !ROTOWIRE_STOP_TOKENS.has(next.token)
|
| 927 |
+
&& !ROTOWIRE_LINEUP_STATUS.has(next.token)
|
| 928 |
+
) {
|
| 929 |
+
players.push({
|
| 930 |
+
slot,
|
| 931 |
+
player_name: next.token,
|
| 932 |
+
position: token,
|
| 933 |
+
});
|
| 934 |
+
slot += 1;
|
| 935 |
+
index = next.index + 1;
|
| 936 |
+
continue;
|
| 937 |
+
}
|
| 938 |
+
}
|
| 939 |
+
index += 1;
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
return { lineup: { status, players }, nextIndex: index };
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
function parseRotowireLineups(html, validTeams) {
|
| 946 |
+
const tokens = extractRotowireTokens(html);
|
| 947 |
+
const validTeamSet = new Set(validTeams.map((team) => normalizeTeam(team)));
|
| 948 |
+
const lineups = {};
|
| 949 |
+
let index = 0;
|
| 950 |
+
|
| 951 |
+
while (index < tokens.length) {
|
| 952 |
+
if (!ROTOWIRE_TIME_RE.test(tokens[index])) {
|
| 953 |
+
index += 1;
|
| 954 |
+
continue;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
const teamTokens = [];
|
| 958 |
+
let scanIndex = index + 1;
|
| 959 |
+
while (scanIndex < tokens.length && scanIndex < index + 25 && teamTokens.length < 2) {
|
| 960 |
+
const token = normalizeTeam(tokens[scanIndex]);
|
| 961 |
+
if (validTeamSet.has(token) && !teamTokens.includes(token)) {
|
| 962 |
+
teamTokens.push(token);
|
| 963 |
+
}
|
| 964 |
+
scanIndex += 1;
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
if (teamTokens.length !== 2) {
|
| 968 |
+
index += 1;
|
| 969 |
+
continue;
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
const [awayTeam, homeTeam] = teamTokens;
|
| 973 |
+
let firstStatusIndex = null;
|
| 974 |
+
for (let candidate = scanIndex; candidate < Math.min(tokens.length, scanIndex + 80); candidate += 1) {
|
| 975 |
+
if (ROTOWIRE_LINEUP_STATUS.has(tokens[candidate])) {
|
| 976 |
+
firstStatusIndex = candidate;
|
| 977 |
+
break;
|
| 978 |
+
}
|
| 979 |
+
}
|
| 980 |
+
if (firstStatusIndex === null) {
|
| 981 |
+
index += 1;
|
| 982 |
+
continue;
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
const awayResult = parseRotowireLineupSide(tokens, firstStatusIndex);
|
| 986 |
+
let secondStatusIndex = null;
|
| 987 |
+
for (let candidate = awayResult.nextIndex; candidate < Math.min(tokens.length, awayResult.nextIndex + 80); candidate += 1) {
|
| 988 |
+
if (ROTOWIRE_LINEUP_STATUS.has(tokens[candidate])) {
|
| 989 |
+
secondStatusIndex = candidate;
|
| 990 |
+
break;
|
| 991 |
+
}
|
| 992 |
+
}
|
| 993 |
+
if (secondStatusIndex === null) {
|
| 994 |
+
index = awayResult.nextIndex;
|
| 995 |
+
continue;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
const homeResult = parseRotowireLineupSide(tokens, secondStatusIndex);
|
| 999 |
+
lineups[awayTeam] = awayResult.lineup;
|
| 1000 |
+
lineups[homeTeam] = homeResult.lineup;
|
| 1001 |
+
index = homeResult.nextIndex;
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
return lineups;
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
function resolveRotowireLineups(lineups, rosterRows) {
|
| 1008 |
+
if (!lineups || !Object.keys(lineups).length || !rosterRows?.length) {
|
| 1009 |
+
return lineups ?? {};
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
const rosterLookupByTeam = buildRosterNameLookupByTeam(rosterRows);
|
| 1013 |
+
const resolved = {};
|
| 1014 |
+
|
| 1015 |
+
for (const [team, payload] of Object.entries(lineups)) {
|
| 1016 |
+
const teamLookup = rosterLookupByTeam.get(normalizeTeam(team)) ?? { exact: new Map(), normalized: new Map() };
|
| 1017 |
+
const players = (payload?.players ?? []).map((player) => {
|
| 1018 |
+
const playerName = String(player.player_name ?? '');
|
| 1019 |
+
const exactPlayerId = teamLookup.exact.get(playerName.toLowerCase());
|
| 1020 |
+
const normalizedPlayerId = teamLookup.normalized.get(normalizeName(playerName));
|
| 1021 |
+
return {
|
| 1022 |
+
...player,
|
| 1023 |
+
player_id: exactPlayerId ?? normalizedPlayerId ?? null,
|
| 1024 |
+
};
|
| 1025 |
+
});
|
| 1026 |
+
resolved[normalizeTeam(team)] = {
|
| 1027 |
+
status: payload?.status ?? 'unknown',
|
| 1028 |
+
players,
|
| 1029 |
+
};
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
return resolved;
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
function applyProjectedLineup(rows, team, rotowireLineups) {
|
| 1036 |
+
if (!rows.length || !rotowireLineups) {
|
| 1037 |
+
return rows;
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
const payload = rotowireLineups[normalizeTeam(team)];
|
| 1041 |
+
if (!payload || !['confirmed', 'expected'].includes(String(payload.status ?? ''))) {
|
| 1042 |
+
return rows;
|
| 1043 |
+
}
|
| 1044 |
+
|
| 1045 |
+
const slotByPlayerId = new Map();
|
| 1046 |
+
for (const player of payload.players ?? []) {
|
| 1047 |
+
const playerId = String(player.player_id ?? '').trim();
|
| 1048 |
+
if (!playerId) {
|
| 1049 |
+
continue;
|
| 1050 |
+
}
|
| 1051 |
+
slotByPlayerId.set(playerId, Number(player.slot ?? null));
|
| 1052 |
+
}
|
| 1053 |
+
|
| 1054 |
+
if (slotByPlayerId.size < 7) {
|
| 1055 |
+
return rows;
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
return rows
|
| 1059 |
+
.filter((row) => slotByPlayerId.has(String(row.batter ?? row.player_id ?? '').trim()))
|
| 1060 |
+
.map((row) => ({
|
| 1061 |
+
...row,
|
| 1062 |
+
projected_lineup_slot: slotByPlayerId.get(String(row.batter ?? row.player_id ?? '').trim()) ?? null,
|
| 1063 |
+
}))
|
| 1064 |
+
.sort((left, right) => {
|
| 1065 |
+
const leftSlot = numberOrNull(left.projected_lineup_slot);
|
| 1066 |
+
const rightSlot = numberOrNull(right.projected_lineup_slot);
|
| 1067 |
+
if (leftSlot === null && rightSlot === null) {
|
| 1068 |
+
return 0;
|
| 1069 |
+
}
|
| 1070 |
+
if (leftSlot === null) {
|
| 1071 |
+
return 1;
|
| 1072 |
+
}
|
| 1073 |
+
if (rightSlot === null) {
|
| 1074 |
+
return -1;
|
| 1075 |
+
}
|
| 1076 |
+
return leftSlot - rightSlot;
|
| 1077 |
+
});
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
function pickMetrics(row, metricKeys) {
|
| 1081 |
return metricKeys
|
| 1082 |
.map(([label, key]) => ({ label, key, value: numberOrNull(row[key]) }))
|
|
|
|
| 1160 |
this.fallbackDays = Number(config.fallbackDays ?? DEFAULT_FALLBACK_DAYS);
|
| 1161 |
this.logger = options.logger ?? console;
|
| 1162 |
this.readParquetImpl = options.readParquetImpl ?? readParquetFromUrl;
|
| 1163 |
+
this.fetchTextImpl = options.fetchTextImpl ?? defaultFetchText;
|
| 1164 |
+
this.rotowireUrl = String(config.rotowireUrl ?? DEFAULT_ROTOWIRE_URL);
|
| 1165 |
this.cache = new Map();
|
| 1166 |
}
|
| 1167 |
|
|
|
|
| 1215 |
return rows;
|
| 1216 |
}
|
| 1217 |
|
| 1218 |
+
async fetchRotowireLineups(targetDate, validTeams, rosterRows = []) {
|
| 1219 |
+
if (!this.fetchTextImpl || !validTeams?.length) {
|
| 1220 |
+
return {};
|
| 1221 |
+
}
|
| 1222 |
+
|
| 1223 |
+
const cacheKey = `rotowire|${parseDateOrToday(targetDate)}|${[...validTeams].sort().join(',')}`;
|
| 1224 |
+
const now = Date.now();
|
| 1225 |
+
const cached = this.cache.get(cacheKey);
|
| 1226 |
+
if (cached && cached.expiresAt > now) {
|
| 1227 |
+
return cached.rows;
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
try {
|
| 1231 |
+
const html = await this.fetchTextImpl(this.rotowireUrl, parseDateOrToday(targetDate));
|
| 1232 |
+
const parsed = parseRotowireLineups(html, validTeams);
|
| 1233 |
+
const resolved = resolveRotowireLineups(parsed, rosterRows);
|
| 1234 |
+
this.cache.set(cacheKey, {
|
| 1235 |
+
expiresAt: now + this.cacheTtlMs,
|
| 1236 |
+
rows: resolved,
|
| 1237 |
+
});
|
| 1238 |
+
return resolved;
|
| 1239 |
+
} catch (error) {
|
| 1240 |
+
this.logger?.debug?.('Rotowire lineup fetch failed', {
|
| 1241 |
+
targetDate: parseDateOrToday(targetDate),
|
| 1242 |
+
error: error.message,
|
| 1243 |
+
});
|
| 1244 |
+
return {};
|
| 1245 |
+
}
|
| 1246 |
+
}
|
| 1247 |
+
|
| 1248 |
+
async buildHostedHitterParityResult(options = {}) {
|
| 1249 |
const targetDate = parseDateOrToday(options.date);
|
| 1250 |
const resolvedDate = await this.getLatestAvailableDate(targetDate);
|
| 1251 |
if (!resolvedDate) {
|
| 1252 |
throw new Error('No hosted matchup slate was available in the fallback window.');
|
| 1253 |
}
|
| 1254 |
|
| 1255 |
+
const [slateRows, hitterRows, pitcherRows, exclusionRows, rosterRows, batterZoneRows, pitcherZoneRows] = await Promise.all([
|
| 1256 |
this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
|
| 1257 |
this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
|
| 1258 |
+
this.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
|
| 1259 |
this.readDailyFile(resolvedDate, 'hitter_pitcher_exclusions.parquet', EXCLUSION_COLUMNS).catch(() => []),
|
| 1260 |
this.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
|
| 1261 |
+
this.readDailyFile(resolvedDate, 'daily_batter_zone_profiles.parquet', BATTER_ZONE_COLUMNS).catch(() => []),
|
| 1262 |
+
this.readDailyFile(resolvedDate, 'daily_pitcher_zone_profiles.parquet', PITCHER_ZONE_COLUMNS).catch(() => []),
|
| 1263 |
]);
|
| 1264 |
|
| 1265 |
+
const split = String(options.split ?? DEFAULT_SPLIT_KEY);
|
| 1266 |
+
const recentWindow = String(options.recentWindow ?? DEFAULT_RECENT_WINDOW);
|
| 1267 |
+
const weightedMode = String(options.weightedMode ?? DEFAULT_WEIGHTED_MODE);
|
| 1268 |
+
const likelyOnly = options.likelyOnly === true;
|
| 1269 |
const slateLookup = mapSlateTeams(slateRows);
|
| 1270 |
+
const exclusionSet = buildExclusionSet(exclusionRows);
|
| 1271 |
const rosterLookup = buildRosterLookup(rosterRows);
|
| 1272 |
+
const rosterIdsByTeam = buildRosterIdsByTeam(rosterRows);
|
| 1273 |
+
const validTeams = [...new Set(slateRows.flatMap((row) => [normalizeTeam(row.away_team), normalizeTeam(row.home_team)]).filter(Boolean))];
|
| 1274 |
+
const rotowireLineups = await this.fetchRotowireLineups(resolvedDate, validTeams, rosterRows);
|
| 1275 |
+
const filteredPitchers = pitcherRows.filter((row) =>
|
| 1276 |
+
String(row.split_key ?? DEFAULT_SPLIT_KEY) === split
|
| 1277 |
+
&& String(row.recent_window ?? DEFAULT_RECENT_WINDOW) === recentWindow
|
| 1278 |
+
&& String(row.weighted_mode ?? DEFAULT_WEIGHTED_MODE) === weightedMode
|
| 1279 |
+
);
|
| 1280 |
+
const pitchersById = new Map();
|
| 1281 |
+
for (const row of filteredPitchers) {
|
| 1282 |
+
pitchersById.set(String(row.pitcher_id ?? row.player_id ?? ''), row);
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
const allRows = [];
|
| 1286 |
+
const boardRows = [];
|
| 1287 |
+
for (const slateRow of slateRows) {
|
| 1288 |
+
const gamePk = slateRow.game_pk ?? null;
|
| 1289 |
+
const awayTeam = normalizeTeam(slateRow.away_team);
|
| 1290 |
+
const homeTeam = normalizeTeam(slateRow.home_team);
|
| 1291 |
+
const awayPitcher = pitchersById.get(String(slateRow.away_probable_pitcher_id ?? '')) ?? null;
|
| 1292 |
+
const homePitcher = pitchersById.get(String(slateRow.home_probable_pitcher_id ?? '')) ?? null;
|
| 1293 |
+
const awayHand = String(homePitcher?.p_throws ?? slateRow.home_probable_hand ?? '').trim().toUpperCase() || null;
|
| 1294 |
+
const homeHand = String(awayPitcher?.p_throws ?? slateRow.away_probable_hand ?? '').trim().toUpperCase() || null;
|
| 1295 |
+
|
| 1296 |
+
const awayRows = this.prepareHostedTeamHitters({
|
| 1297 |
+
team: awayTeam,
|
| 1298 |
+
slateLookup,
|
| 1299 |
+
rosterLookup,
|
| 1300 |
+
rosterIdsByTeam,
|
| 1301 |
+
exclusionSet,
|
| 1302 |
+
rotowireLineups,
|
| 1303 |
+
hitterRows,
|
| 1304 |
+
opposingPitcherHand: awayHand,
|
| 1305 |
+
split,
|
| 1306 |
+
recentWindow,
|
| 1307 |
+
weightedMode,
|
| 1308 |
+
likelyOnly,
|
| 1309 |
+
});
|
| 1310 |
+
const homeRows = this.prepareHostedTeamHitters({
|
| 1311 |
+
team: homeTeam,
|
| 1312 |
+
slateLookup,
|
| 1313 |
+
rosterLookup,
|
| 1314 |
+
rosterIdsByTeam,
|
| 1315 |
+
exclusionSet,
|
| 1316 |
+
rotowireLineups,
|
| 1317 |
+
hitterRows,
|
| 1318 |
+
opposingPitcherHand: homeHand,
|
| 1319 |
+
split,
|
| 1320 |
+
recentWindow,
|
| 1321 |
+
weightedMode,
|
| 1322 |
+
likelyOnly,
|
| 1323 |
});
|
| 1324 |
|
| 1325 |
+
const awayScored = addHitterMatchupScore(awayRows, batterZoneRows, pitcherZoneRows);
|
| 1326 |
+
const homeScored = addHitterMatchupScore(homeRows, batterZoneRows, pitcherZoneRows);
|
| 1327 |
+
const combinedGameRows = sortHittersLiveApp([...awayScored, ...homeScored]);
|
| 1328 |
+
allRows.push(...combinedGameRows);
|
| 1329 |
+
boardRows.push(...combinedGameRows.slice(0, 3));
|
| 1330 |
+
}
|
| 1331 |
+
|
| 1332 |
+
const sortedRows = sortHittersLiveApp(allRows);
|
| 1333 |
+
const sortedBoardRows = sortHittersLiveApp(boardRows);
|
| 1334 |
+
const filteredRows = sortedRows.filter((row) => rowMatchesTeamFilter(row.team, options.team));
|
| 1335 |
+
const filteredBoardRows = sortedBoardRows.filter((row) => rowMatchesTeamFilter(row.team, options.team));
|
| 1336 |
+
|
| 1337 |
return {
|
| 1338 |
source: 'hosted',
|
| 1339 |
resolvedDate,
|
| 1340 |
+
parityRanked: true,
|
| 1341 |
+
rows: filteredRows,
|
| 1342 |
+
boardRows: filteredBoardRows,
|
| 1343 |
+
};
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
prepareHostedTeamHitters({
|
| 1347 |
+
team,
|
| 1348 |
+
slateLookup,
|
| 1349 |
+
rosterLookup,
|
| 1350 |
+
rosterIdsByTeam,
|
| 1351 |
+
exclusionSet,
|
| 1352 |
+
rotowireLineups,
|
| 1353 |
+
hitterRows,
|
| 1354 |
+
opposingPitcherHand,
|
| 1355 |
+
split,
|
| 1356 |
+
recentWindow,
|
| 1357 |
+
weightedMode,
|
| 1358 |
+
likelyOnly,
|
| 1359 |
+
}) {
|
| 1360 |
+
const normalizedTeam = normalizeTeam(team);
|
| 1361 |
+
const effectiveSplit = split === DEFAULT_SPLIT_KEY && opposingPitcherHand === 'R'
|
| 1362 |
+
? 'vs_rhp'
|
| 1363 |
+
: split === DEFAULT_SPLIT_KEY && opposingPitcherHand === 'L'
|
| 1364 |
+
? 'vs_lhp'
|
| 1365 |
+
: split;
|
| 1366 |
+
const rosterPlayerIds = rosterIdsByTeam.get(normalizedTeam) ?? null;
|
| 1367 |
+
|
| 1368 |
+
const preparedRows = hitterRows
|
| 1369 |
+
.filter((row) => normalizeTeam(row.team) === normalizedTeam)
|
| 1370 |
+
.filter((row) => String(row.split_key ?? DEFAULT_SPLIT_KEY) === effectiveSplit)
|
| 1371 |
+
.filter((row) => String(row.recent_window ?? DEFAULT_RECENT_WINDOW) === recentWindow)
|
| 1372 |
+
.filter((row) => String(row.weighted_mode ?? DEFAULT_WEIGHTED_MODE) === weightedMode)
|
| 1373 |
+
.filter((row) => !rosterPlayerIds || rosterPlayerIds.has(String(row.batter ?? row.player_id ?? '').trim()))
|
| 1374 |
+
.filter((row) => !exclusionSet.has(String(row.batter ?? row.player_id ?? '').trim()))
|
| 1375 |
+
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }))
|
| 1376 |
+
.filter((row) => !likelyOnly || (numberOrNull(row.likely_starter_score) ?? 0) > 0);
|
| 1377 |
+
|
| 1378 |
+
return applyProjectedLineup(preparedRows, normalizedTeam, rotowireLineups);
|
| 1379 |
+
}
|
| 1380 |
+
|
| 1381 |
+
async getTopHitters(options = {}) {
|
| 1382 |
+
const result = await this.buildHostedHitterParityResult(options);
|
| 1383 |
+
return {
|
| 1384 |
+
source: result.source,
|
| 1385 |
+
resolvedDate: result.resolvedDate,
|
| 1386 |
+
parityRanked: true,
|
| 1387 |
+
rows: result.rows.slice(0, limitOrDefault(options.limit)),
|
| 1388 |
+
};
|
| 1389 |
}
|
| 1390 |
|
| 1391 |
async getTopPitchers(options = {}) {
|
|
|
|
| 1416 |
}
|
| 1417 |
|
| 1418 |
async getBestMatchups(options = {}) {
|
| 1419 |
+
const result = await this.buildHostedHitterParityResult(options);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1420 |
return {
|
| 1421 |
+
source: result.source,
|
| 1422 |
+
resolvedDate: result.resolvedDate,
|
| 1423 |
+
parityRanked: true,
|
| 1424 |
+
rows: result.boardRows.slice(0, limitOrDefault(options.limit ?? (options.team ? 3 : 12), 12)),
|
| 1425 |
};
|
| 1426 |
}
|
| 1427 |
|
|
|
|
| 1867 |
if (this.hosted?.isConfigured?.()) {
|
| 1868 |
try {
|
| 1869 |
const hostedResult = await this.hosted[methodName](options);
|
| 1870 |
+
if (methodName === 'getTopHitters' && !hostedResult?.parityRanked) {
|
| 1871 |
return await this.enrichHostedHitters(hostedResult, options, methodName);
|
| 1872 |
}
|
| 1873 |
return hostedResult;
|
|
|
|
| 1966 |
}
|
| 1967 |
}
|
| 1968 |
|
| 1969 |
+
async function defaultFetchText(url, targetDate) {
|
| 1970 |
+
const requestUrl = new URL(url);
|
| 1971 |
+
requestUrl.searchParams.set('date', parseDateOrToday(targetDate));
|
| 1972 |
+
const response = await fetch(requestUrl, {
|
| 1973 |
+
headers: {
|
| 1974 |
+
'User-Agent': 'KasperMLB/1.0',
|
| 1975 |
+
},
|
| 1976 |
+
});
|
| 1977 |
+
if (!response.ok) {
|
| 1978 |
+
throw new Error(`Rotowire request failed with status ${response.status}`);
|
| 1979 |
+
}
|
| 1980 |
+
return response.text();
|
| 1981 |
+
}
|
| 1982 |
+
|
| 1983 |
export async function readParquetFromUrl(url, columns) {
|
| 1984 |
const file = await asyncBufferFromUrl({ url });
|
| 1985 |
return parquetReadObjects({
|
test/matchups.test.js
CHANGED
|
@@ -11,6 +11,8 @@ test('hosted artifact source resolves latest available daily slate and caches pa
|
|
| 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',
|
|
@@ -20,20 +22,48 @@ test('hosted artifact source resolves latest available daily slate and caches pa
|
|
| 20 |
['https://example.test/daily/2026-04-06/daily_hitter_metrics.parquet', [
|
| 21 |
{
|
| 22 |
team: 'CHC',
|
| 23 |
-
hitter_name: '
|
| 24 |
batter: 100,
|
| 25 |
-
|
|
|
|
| 26 |
recent_window: 'season',
|
| 27 |
weighted_mode: 'weighted',
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
| 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(
|
|
@@ -50,6 +80,7 @@ test('hosted artifact source resolves latest available daily slate and caches pa
|
|
| 50 |
}
|
| 51 |
return responses.get(url);
|
| 52 |
},
|
|
|
|
| 53 |
logger: { debug() {}, warn() {} },
|
| 54 |
}
|
| 55 |
);
|
|
@@ -72,6 +103,8 @@ test('hosted artifact source matches common team aliases against team abbreviati
|
|
| 72 |
game_pk: 20,
|
| 73 |
away_team: 'ATH',
|
| 74 |
home_team: 'NYY',
|
|
|
|
|
|
|
| 75 |
away_probable_pitcher: 'JP Sears',
|
| 76 |
home_probable_pitcher: 'Carlos Rodon',
|
| 77 |
away_probable_hand: 'L',
|
|
@@ -96,6 +129,10 @@ test('hosted artifact source matches common team aliases against team abbreviati
|
|
| 96 |
avg_launch_angle: 14.2,
|
| 97 |
},
|
| 98 |
]],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
|
| 100 |
['https://example.test/daily/2026-04-07/rosters.parquet', [
|
| 101 |
{
|
|
@@ -121,6 +158,7 @@ test('hosted artifact source matches common team aliases against team abbreviati
|
|
| 121 |
}
|
| 122 |
return responses.get(url);
|
| 123 |
},
|
|
|
|
| 124 |
logger: { debug() {}, warn() {} },
|
| 125 |
}
|
| 126 |
);
|
|
@@ -186,6 +224,24 @@ test('hosted best matchups uses pitcher-hand split rows from hosted slate inputs
|
|
| 186 |
avg_launch_angle: 20,
|
| 187 |
},
|
| 188 |
]],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
|
| 190 |
['https://example.test/daily/2026-04-07/rosters.parquet', [
|
| 191 |
{ team: 'MIL', player_id: 123, player_name: 'Gary Sanchez' },
|
|
@@ -207,6 +263,7 @@ test('hosted best matchups uses pitcher-hand split rows from hosted slate inputs
|
|
| 207 |
}
|
| 208 |
return responses.get(url);
|
| 209 |
},
|
|
|
|
| 210 |
logger: { debug() {}, warn() {} },
|
| 211 |
}
|
| 212 |
);
|
|
@@ -286,6 +343,12 @@ test('hosted best matchups keeps only the top three hitters per game before rank
|
|
| 286 |
{ team: 'BOS', hitter_name: '23', batter: 23, stand: 'L', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.350, swstr_pct: 0.10, barrel_bbe_pct: 0.13, barrel_bip_pct: 0.11, pulled_barrel_pct: 0.07, sweet_spot_pct: 0.33, fb_pct: 0.24, hard_hit_pct: 0.41, avg_launch_angle: 16 },
|
| 287 |
{ team: 'BAL', hitter_name: '24', batter: 24, stand: 'L', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.330, swstr_pct: 0.11, barrel_bbe_pct: 0.11, barrel_bip_pct: 0.09, pulled_barrel_pct: 0.06, sweet_spot_pct: 0.31, fb_pct: 0.22, hard_hit_pct: 0.39, avg_launch_angle: 14 },
|
| 288 |
]],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
|
| 290 |
['https://example.test/daily/2026-04-07/rosters.parquet', [
|
| 291 |
{ team: 'NYY', player_id: 11, player_name: 'Game One A' },
|
|
@@ -314,6 +377,7 @@ test('hosted best matchups keeps only the top three hitters per game before rank
|
|
| 314 |
}
|
| 315 |
return responses.get(url);
|
| 316 |
},
|
|
|
|
| 317 |
logger: { debug() {}, warn() {} },
|
| 318 |
}
|
| 319 |
);
|
|
|
|
| 11 |
game_pk: 10,
|
| 12 |
away_team: 'CHC',
|
| 13 |
home_team: 'MIL',
|
| 14 |
+
away_probable_pitcher_id: 1001,
|
| 15 |
+
home_probable_pitcher_id: 1002,
|
| 16 |
away_probable_pitcher: 'Shota Imanaga',
|
| 17 |
home_probable_pitcher: 'Freddy Peralta',
|
| 18 |
away_probable_hand: 'L',
|
|
|
|
| 22 |
['https://example.test/daily/2026-04-06/daily_hitter_metrics.parquet', [
|
| 23 |
{
|
| 24 |
team: 'CHC',
|
| 25 |
+
hitter_name: '100',
|
| 26 |
batter: 100,
|
| 27 |
+
stand: 'R',
|
| 28 |
+
split_key: 'vs_rhp',
|
| 29 |
recent_window: 'season',
|
| 30 |
weighted_mode: 'weighted',
|
| 31 |
+
swstr_pct: 0.08,
|
| 32 |
+
barrel_bbe_pct: 0.16,
|
| 33 |
+
barrel_bip_pct: 0.14,
|
| 34 |
+
pulled_barrel_pct: 0.11,
|
| 35 |
+
sweet_spot_pct: 0.37,
|
| 36 |
likely_starter_score: 98.2,
|
| 37 |
xwoba: 0.398,
|
| 38 |
+
fb_pct: 0.29,
|
| 39 |
hard_hit_pct: 48.2,
|
| 40 |
+
avg_launch_angle: 17.2,
|
| 41 |
+
},
|
| 42 |
+
]],
|
| 43 |
+
['https://example.test/daily/2026-04-06/daily_pitcher_metrics.parquet', [
|
| 44 |
+
{
|
| 45 |
+
pitcher_id: 1001,
|
| 46 |
+
pitcher_name: 'Shota Imanaga',
|
| 47 |
+
p_throws: 'L',
|
| 48 |
+
split_key: 'overall',
|
| 49 |
+
recent_window: 'season',
|
| 50 |
+
weighted_mode: 'weighted',
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
pitcher_id: 1002,
|
| 54 |
+
pitcher_name: 'Freddy Peralta',
|
| 55 |
+
p_throws: 'R',
|
| 56 |
+
split_key: 'overall',
|
| 57 |
+
recent_window: 'season',
|
| 58 |
+
weighted_mode: 'weighted',
|
| 59 |
},
|
| 60 |
]],
|
| 61 |
['https://example.test/daily/2026-04-06/hitter_pitcher_exclusions.parquet', []],
|
| 62 |
+
['https://example.test/daily/2026-04-06/rosters.parquet', [
|
| 63 |
+
{ team: 'CHC', player_id: 100, player_name: 'Seiya Suzuki' },
|
| 64 |
+
]],
|
| 65 |
+
['https://example.test/daily/2026-04-06/daily_batter_zone_profiles.parquet', []],
|
| 66 |
+
['https://example.test/daily/2026-04-06/daily_pitcher_zone_profiles.parquet', []],
|
| 67 |
]);
|
| 68 |
const calls = [];
|
| 69 |
const source = new HostedArtifactSource(
|
|
|
|
| 80 |
}
|
| 81 |
return responses.get(url);
|
| 82 |
},
|
| 83 |
+
fetchTextImpl: async () => '',
|
| 84 |
logger: { debug() {}, warn() {} },
|
| 85 |
}
|
| 86 |
);
|
|
|
|
| 103 |
game_pk: 20,
|
| 104 |
away_team: 'ATH',
|
| 105 |
home_team: 'NYY',
|
| 106 |
+
away_probable_pitcher_id: 2001,
|
| 107 |
+
home_probable_pitcher_id: 2002,
|
| 108 |
away_probable_pitcher: 'JP Sears',
|
| 109 |
home_probable_pitcher: 'Carlos Rodon',
|
| 110 |
away_probable_hand: 'L',
|
|
|
|
| 129 |
avg_launch_angle: 14.2,
|
| 130 |
},
|
| 131 |
]],
|
| 132 |
+
['https://example.test/daily/2026-04-07/daily_pitcher_metrics.parquet', [
|
| 133 |
+
{ pitcher_id: 2001, pitcher_name: 'JP Sears', p_throws: 'L', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
|
| 134 |
+
{ pitcher_id: 2002, pitcher_name: 'Carlos Rodon', p_throws: 'L', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
|
| 135 |
+
]],
|
| 136 |
['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
|
| 137 |
['https://example.test/daily/2026-04-07/rosters.parquet', [
|
| 138 |
{
|
|
|
|
| 158 |
}
|
| 159 |
return responses.get(url);
|
| 160 |
},
|
| 161 |
+
fetchTextImpl: async () => '',
|
| 162 |
logger: { debug() {}, warn() {} },
|
| 163 |
}
|
| 164 |
);
|
|
|
|
| 224 |
avg_launch_angle: 20,
|
| 225 |
},
|
| 226 |
]],
|
| 227 |
+
['https://example.test/daily/2026-04-07/daily_pitcher_metrics.parquet', [
|
| 228 |
+
{
|
| 229 |
+
pitcher_id: 501,
|
| 230 |
+
pitcher_name: 'Garrett Crochet',
|
| 231 |
+
p_throws: 'L',
|
| 232 |
+
split_key: 'overall',
|
| 233 |
+
recent_window: 'season',
|
| 234 |
+
weighted_mode: 'weighted',
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
pitcher_id: 601,
|
| 238 |
+
pitcher_name: 'Freddy Peralta',
|
| 239 |
+
p_throws: 'R',
|
| 240 |
+
split_key: 'overall',
|
| 241 |
+
recent_window: 'season',
|
| 242 |
+
weighted_mode: 'weighted',
|
| 243 |
+
},
|
| 244 |
+
]],
|
| 245 |
['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
|
| 246 |
['https://example.test/daily/2026-04-07/rosters.parquet', [
|
| 247 |
{ team: 'MIL', player_id: 123, player_name: 'Gary Sanchez' },
|
|
|
|
| 263 |
}
|
| 264 |
return responses.get(url);
|
| 265 |
},
|
| 266 |
+
fetchTextImpl: async () => '',
|
| 267 |
logger: { debug() {}, warn() {} },
|
| 268 |
}
|
| 269 |
);
|
|
|
|
| 343 |
{ team: 'BOS', hitter_name: '23', batter: 23, stand: 'L', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.350, swstr_pct: 0.10, barrel_bbe_pct: 0.13, barrel_bip_pct: 0.11, pulled_barrel_pct: 0.07, sweet_spot_pct: 0.33, fb_pct: 0.24, hard_hit_pct: 0.41, avg_launch_angle: 16 },
|
| 344 |
{ team: 'BAL', hitter_name: '24', batter: 24, stand: 'L', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.330, swstr_pct: 0.11, barrel_bbe_pct: 0.11, barrel_bip_pct: 0.09, pulled_barrel_pct: 0.06, sweet_spot_pct: 0.31, fb_pct: 0.22, hard_hit_pct: 0.39, avg_launch_angle: 14 },
|
| 345 |
]],
|
| 346 |
+
['https://example.test/daily/2026-04-07/daily_pitcher_metrics.parquet', [
|
| 347 |
+
{ pitcher_id: 101, pitcher_name: 'Away One', p_throws: 'R', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
|
| 348 |
+
{ pitcher_id: 201, pitcher_name: 'Home One', p_throws: 'L', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
|
| 349 |
+
{ pitcher_id: 301, pitcher_name: 'Away Two', p_throws: 'R', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
|
| 350 |
+
{ pitcher_id: 401, pitcher_name: 'Home Two', p_throws: 'R', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
|
| 351 |
+
]],
|
| 352 |
['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
|
| 353 |
['https://example.test/daily/2026-04-07/rosters.parquet', [
|
| 354 |
{ team: 'NYY', player_id: 11, player_name: 'Game One A' },
|
|
|
|
| 377 |
}
|
| 378 |
return responses.get(url);
|
| 379 |
},
|
| 380 |
+
fetchTextImpl: async () => '',
|
| 381 |
logger: { debug() {}, warn() {} },
|
| 382 |
}
|
| 383 |
);
|