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
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
'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 : 0–1
|
||||
* 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` (0–1), 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,
|
||||
};
|
||||
Reference in New Issue
Block a user