Files
vyndr/src/services/opponentStrength.js
T
builtbykev 55157b3288 Close-capture retry (lock-walled) + MLB opp_rank_stat derivation
PHASE 1 — CLOSE-CAPTURE RETRY, test-first. The closing capture gets a
retry the snapshot path deliberately does not: a snapshot re-runs at the
next slot, but a MISSED CLOSE IS PERMANENT, and the feed flaked once on a
dry induce. Three hard rules, each driven by a test written before the
logic:
  - BOUNDED attempts (default 3) with short backoff so every attempt fits
    inside the window. Never infinite.
  - HARD LOCK-WALL: inside lockWallMinutes of first pitch (or past it) it
    stops and records missed_close. A price captured AT or AFTER lock is
    NOT a close; storing one would fabricate the CLV baseline.
  - NO BOUND LOCK TIME -> refuse immediately, never burn retries on a prop
    whose close cannot be timed.
On exhaustion it records missed_close with NO price — never a stale,
mid-day or post-lock line.

PHASE 3 — MLB opp_rank_stat DERIVED, contract-locked. MLB previously had
no opponent metric at all (ESPN's MLB team endpoint carries none), so
engine1's +/-1.0 opponent factor never fired for the sport carrying most
of our volume. Derived from data we already ingest: statsapi team pitching
splits, all 30 teams in ONE free unauthenticated call.

THE SHARED CONTRACT is documented and TESTED, not assumed: 0-1 scale,
HIGH (>=0.70) = WEAK opponent, LOW (<=0.30) = TOUGH — identical to WNBA's
live semantics. Polarity is the highest-risk part: backwards polarity does
not fail loudly, it silently adjusts every MLB grade the wrong way. A test
asserts MLB polarity EQUALS WNBA polarity using engine1's own thresholds.

PROVEN AGAINST THE LIVE FEED:
  Colorado Rockies  BAA .286 -> opp_rank 0.983  (weak, fires weak_opponent)
  LA Dodgers        BAA .215 -> opp_rank 0.017  (tough, fires top_opponent)
  POLARITY HOLDS: true

HONEST NULLS, tested: thin league baseline, thin opponent sample, unmapped
stat, unknown opponent, or a missing field all return NULL with a reason —
we are FIXING a silent null, so it is never replaced by a confident guess
off three games. opponentStrengthHealth pages on an empty source AND on
derived-null-for-a-sport-we-expect-to-derive.

Suite 285/3435 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 12:27:47 -04:00

151 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* OPPONENT STRENGTH — `opp_rank_stat` (Session 64, Phase 3).
*
* ONE COLUMN, ONE MEANING, ACROSS SPORTS. This is the shared contract, taken
* from WNBA's live behaviour, which is the reference implementation:
*
* scale : 01
* polarity : HIGH (>= 0.70) = WEAK opponent defence → favourable to the
* hitter/scorer, lifts an OVER
* LOW (<= 0.30) = TOUGH opponent defence → fades an OVER
*
* MLB previously had NO opp_rank_stat at all (ESPN's MLB team endpoint carries
* no defensive metric), so engine1's ±1.0 opponent factor never fired for the
* sport carrying most of our volume. This derives it from data we ALREADY
* ingest, free and unauthenticated: statsapi's team pitching splits — one call
* returns all 30 teams with avg (opponent batting average against), slg, ops,
* homeRuns, strikeOuts, era, whip.
*
* POLARITY IS THE HIGHEST-RISK PART. A batting average AGAINST is a
* "higher = worse pitching" stat, so it maps DIRECTLY to our scale: a team that
* allows a high BAA is a weak opponent → high opp_rank_stat. Getting this
* backwards would silently mis-adjust every MLB grade in the wrong direction,
* which is far worse than having no value — so a test asserts MLB polarity
* equals WNBA polarity, not merely that a number exists.
*
* HONEST NULLS: below the sample floor — either the opponent's own games or the
* league baseline being too thin — this returns NULL. We are FIXING a silent
* null here; we do not get to replace it with a confident guess off three games.
*/
// Which pitching field expresses "how easy is this opponent for THIS stat".
// Each is a higher = weaker-opponent measure, matching the shared polarity.
const MLB_STAT_FIELD = {
hits: 'avg', // opponent batting average against
total_bases: 'slg', // slugging against
home_runs: 'homeRuns', // HR allowed
runs: 'runs',
rbi: 'runs',
doubles: 'slg',
triples: 'slg',
walks: 'baseOnBalls',
// Strikeouts are INVERTED: a staff that strikes out MORE batters is a TOUGHER
// opponent for a batter's hits/TB props, but for a BATTER-strikeouts prop a
// high-K staff makes the over EASIER. Handled by `invert` below.
strikeouts: 'strikeOuts',
};
// Fields where a HIGHER raw value means a TOUGHER opponent for the graded side,
// so the percentile must be flipped to preserve "high = weak".
const INVERTED_FOR_BATTER = new Set([]);
// For a batter's own strikeout prop, a high-K staff HELPS the over — so it is
// NOT inverted. Listed explicitly so the intent is readable rather than implied.
const NOT_INVERTED = new Set(['strikeouts']);
const MIN_OPPONENT_GAMES = Number(process.env.OPP_RANK_MIN_GAMES || 20);
const MIN_LEAGUE_TEAMS = Number(process.env.OPP_RANK_MIN_TEAMS || 20);
function num(v) {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/**
* Percentile of `value` within `all` (01), where a HIGHER raw value yields a
* HIGHER percentile. Ties share the midpoint.
*/
function percentile(value, all) {
const xs = all.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
if (xs.length < 2) return null;
let below = 0;
let equal = 0;
for (const x of xs) {
if (x < value) below += 1;
else if (x === value) equal += 1;
}
return (below + equal / 2) / xs.length;
}
/**
* Derive MLB opp_rank_stat.
*
* @param {Array} teams [{ teamId, stat: { avg, slg, gamesPlayed, ... } }] — every team
* @param {string|number} opponentTeamId
* @param {string} statType graded stat (hits, total_bases, ...)
* @returns {{value:number|null, reason:string|null, field:string|null, raw:number|null}}
*/
function deriveMlbOppRank(teams, opponentTeamId, statType, opts = {}) {
const field = MLB_STAT_FIELD[String(statType || '').toLowerCase()];
if (!field) return { value: null, reason: 'stat_not_mapped', field: null, raw: null };
const list = Array.isArray(teams) ? teams : [];
// The league baseline must itself be real. Early season / partial feeds
// produce a baseline that cannot rank anything honestly.
if (list.length < (opts.minTeams ?? MIN_LEAGUE_TEAMS)) {
return { value: null, reason: 'league_baseline_too_thin', field, raw: null };
}
const opp = list.find((t) => String(t.teamId) === String(opponentTeamId));
if (!opp || !opp.stat) return { value: null, reason: 'opponent_not_found', field, raw: null };
const games = num(opp.stat.gamesPlayed);
if (games != null && games < (opts.minGames ?? MIN_OPPONENT_GAMES)) {
return { value: null, reason: 'opponent_sample_too_thin', field, raw: null };
}
const raw = num(opp.stat[field]);
if (raw == null) return { value: null, reason: 'field_absent', field, raw: null };
const all = list
.filter((t) => {
const g = num(t.stat && t.stat.gamesPlayed);
return g == null || g >= (opts.minGames ?? MIN_OPPONENT_GAMES);
})
.map((t) => num(t.stat && t.stat[field]))
.filter((v) => v != null);
if (all.length < (opts.minTeams ?? MIN_LEAGUE_TEAMS)) {
return { value: null, reason: 'league_baseline_too_thin', field, raw };
}
let p = percentile(raw, all);
if (p == null) return { value: null, reason: 'percentile_undefined', field, raw };
// Preserve the shared polarity: HIGH = weak opponent.
const invert = INVERTED_FOR_BATTER.has(String(statType).toLowerCase())
&& !NOT_INVERTED.has(String(statType).toLowerCase());
if (invert) p = 1 - p;
return { value: Math.round(p * 1000) / 1000, reason: null, field, raw };
}
/**
* Health check. We are fixing a SILENT null — so an empty source AND an
* unexpected null for a sport we expect to derive both page. A derived metric
* that quietly stops deriving is the failure mode this whole session has been
* about.
*/
function opponentStrengthHealth({ sport, teamsLoaded = 0, derived = 0, attempted = 0 } = {}) {
if (teamsLoaded === 0) {
return { alarm: true, reason: `${String(sport).toUpperCase()} opponent-strength source returned NO teams — opp_rank_stat cannot be derived` };
}
if (attempted > 0 && derived === 0) {
return { alarm: true, reason: `${String(sport).toUpperCase()} opponent-strength derived NULL for all ${attempted} props despite ${teamsLoaded} teams loaded` };
}
return { alarm: false, reason: null };
}
module.exports = {
deriveMlbOppRank, percentile, opponentStrengthHealth,
MLB_STAT_FIELD, MIN_OPPONENT_GAMES, MIN_LEAGUE_TEAMS,
};