TodayResultsHistoryAnalyze

Model Analysis

Auto-generated every 40 games · Claude diagnoses underperforming markets and suggests fixes

Today's slate
Last 58 games — 2026-07-09 to 2026-07-12
Generated Jul 14, 2026
Analysis run
Last 58 games
ML29-29(50.0%)
ATS27-31(46.6%)
O/U29-29(50.0%)
Full season
ML411-375(52.3%)
ATS440-337(56.6%)
O/U387-360-39(51.8%)
Diagnosis
# Diagnosis and Improvement Plan ## Diagnosis ### 1. The Model Has No Predictive Edge — Win Rates Are at Chance The most critical finding is that all three markets sit at or below 50%: moneyline 50.0%, runline 46.6%, total 50.0%. This is not a calibration nuance — a model with genuine edge at the reported edge magnitudes (many comparisons show +0.10 to +0.30) should be winning at 55–60%+ across 58 games. The fact that it isn't means the `fair_prob` values being generated by the upstream pipeline are not predictive of actual outcomes. The market comparison layer (`_evaluate.py`) is functioning correctly as written, but it is faithfully amplifying noise from layers 1–5. The `edge` values reported are large and frequent (dozens of "green" picks per day), which itself is a red flag — genuine market inefficiencies of 10–30 percentage points don't exist at scale in MLB betting markets. ### 2. Total Market Calibration Is Severely Broken The total market failures are the most instructive. Looking at the wrong predictions specifically flagged here: Game 1324 (actual 2-1, `over` bet, model edge +0.157), Game 1325 (actual 2-3, `over` bet, edge +0.178), Game 1331 (actual 3-1, `over` bet, edge +0.078), Game 1339 (actual 3-2, `over` bet, edge +0.121), Game 1347 (actual 4-3, `over` bet, edge +0.213), Game 1352 (actual 2-3, `over` bet, edge +0.128), Game 1353 (actual 4-0, `over` bet, edge +0.090), Game 1354 (actual 1-6, `over` bet, edge +0.077), Game 1367 (actual 3-2, `over` bet, edge +0.148), Game 1373 (actual 4-3, `over` bet, edge +0.289), Game 1375 (actual 1-3, `over` bet, edge +0.301). Every single one of these is a low-scoring game (combined scores of 3, 5, 4, 5, 7, 5, 4, 7, 5, 7, 4) that the model confidently predicted as overs. The projected totals are hidden (`proj_total=?`) which is itself a problem — but the pattern strongly suggests the model's PMF is systematically skewed toward higher run totals, likely because the Monte Carlo simulation in layer 5 is not regressing pitcher/lineup quality correctly, or the park factors (most are near 1.0, so that's not the driver) are being applied multiplicatively in a way that inflates expected run output. ### 3. The `proj_total=?` Gap Is a Critical Observability Failure Every single game record shows `proj_total=?`. Looking at `_build_reasoning` in `_evaluate.py`, `projected_total` is only populated when `pred.market == "total"` and `pred.distribution` is not None. Given the total market is being evaluated (over/under verdicts are generated), either the distribution exists and the mean calculation is silently failing, or the reasoning is computed after the fact and `proj_total` is not being stored in a way the logging layer can read. Without knowing what the model is actually projecting for total runs, it is impossible to audit whether the fair_probs are reasonable. This is a root-cause observability gap. ### 4. Edge Threshold Is Too Permissive and the Verdict Function Ignores Sharp/RLM Signals The `_verdict` function accepts `sharp` and `rlm` boolean arguments but does nothing with them — it only looks at `edge` vs `threshold`. With a 3% default threshold (`_DEFAULT_EDGE_THRESHOLD = 0.03`), almost everything becomes green. The data bears this out: the vast majority of comparisons are "green" (edge > 0.03), yet the win rate is 50%. Sharp money divergence and reverse line movement are computed but silently discarded in the verdict. Meanwhile, the model is generating enormous reported edges (+0.289 for Game 1373's over, which went under; +0.301 for Game 1375's over, which also went under) — edges this large against a liquid market like MLB are almost certainly model overconfidence, not genuine mispricing. ### 5. Runline Win Rate (46.6%) Suggests Directional Bias The runline is the worst market at 46.6%, meaning the model is losing money even ignoring juice. Looking at the runline wrong picks: the model favors `away_plus` and `home_plus` (+1.5) frequently. Several of these involve games where the home team has `home_wp` between 0.52–0.58 (mild favorites), and the model still recommends taking the underdog +1.5 at high fair_prob (e.g., Game 1341: `away_plus` fair_prob=0.643 on a 1-14 blowout where home won 14-1 — wait, actual=1-14 means home scored 1, away scored 14, so that was actually a correct call). The systematic issue is that `home_plus` and `away_plus` predictions for the team that loses by more than 1.5 runs are failing at a high rate, suggesting the spread distribution from the Monte Carlo is too tight (underestimating blowout probability) or the runline consensus_implied is being mispriced due to alternate-line filtering logic in `_build_comparison`. --- ## Specific Improvement Suggestions ### 1. Raise the Edge Threshold and Make It Market-Specific **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_edge_threshold()` and `_verdict()` **Why:** A flat 3% threshold generates too many picks with no discriminatory power. Totals have the worst calibration and should require a higher threshold. Moneyline edges need to be larger to overcome juice. Market-specific thresholds will reduce pick volume and force the model to only act on its strongest signals. ```python # In _evaluate.py, replace _edge_threshold() and _verdict() with: _DEFAULT_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.06, # Require 6pp edge on ML (juice ~4-5pp, need real signal) "runline": 0.07, # Runline at 46.6% — be much more selective "total": 0.08, # Totals badly miscalibrated, raise bar significantly "f5_total": 0.08, "nrfi": 0.06, } _DEFAULT_EDGE_THRESHOLD = 0.06 # fallback def _edge_threshold(market: str | None = None) -> float: """Return per-market edge threshold, overridable via environment variable.""" raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass if market is not None: return _DEFAULT_EDGE_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: """Assign verdict incorporating sharp money and RLM as modifiers.""" if edge < 0.0: return Verdict.red # Sharp divergence against our side is a veto even with positive edge if sharp and edge < threshold * 1.5: return Verdict.red # RLM against our side downgrades green to yellow if rlm and edge < threshold * 1.2: return Verdict.yellow if edge >= threshold: return Verdict.green return Verdict.yellow ``` Then update the call site in `_build_comparison` to pass the market: ```python # In _build_comparison, replace: threshold = _edge_threshold() # this is passed in from _evaluate already # In _evaluate() -> for pred loop, replace: threshold = _edge_threshold() # with per-market threshold: for pred in predictions: market_threshold = _edge_threshold(pred.market) comp = _build_comparison( game_id=game_id, model_run_id=model_run.id, pred=pred, odds_snapshots=odds_snapshots, splits_snapshots=splits_snapshots, threshold=market_threshold, # pass market-specific threshold ) ``` --- ### 2. Fix `proj_total=?` — Ensure Projected Total Is Always Logged **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_reasoning()` **Why:** `proj_total=?` appears for every game, making it impossible to audit model calibration. The distribution mean calculation may be failing silently, or the `total/over` prediction may not exist for some games. Add a fallback that extracts projected total from `total/under` as well, add explicit error logging, and always surface the value. ```python def _build_reasoning( game_id: int, model_run_id: int, predictions: list[Prediction], results: list[MarketComparison], session: Session, ) -> dict: from db.models import Game, ParkFactor, WeatherSnapshot reasoning: dict = {} for pred in predictions: if pred.market in ("moneyline", "ml") and pred.side == "home" and pred.fair_prob is not None: reasoning["home_win_prob"] = round(pred.fair_prob, 4) # Extract projected total from either over or under distribution if pred.market == "total" and pred.side in ("over", "under") and pred.distribution: try: items = [(float(k), v) for k, v in pred.distribution.items()] if not items: logger.warning( "_build_reasoning: empty distribution game_id=%d pred_id=%s", game_id, getattr(pred, "id", "?") ) else: mean_total = sum(k * v for k, v in items) # Sanity check: MLB totals should be between 3 and 30 if 3.0 <= mean_total <= 30.0: reasoning["projected_total"] = round(mean_total, 2) else: logger.warning( "_build_reasoning: implausible projected_total=%.2f " "game_id=%d — distribution may be malformed", mean_total, game_id ) reasoning["projected_total"] = round(mean_total, 2) # log anyway except (ValueError, TypeError) as exc: logger.warning( "_build_reasoning: distribution parse error game_id=%d: %s", game_id, exc ) # Capture distribution percentiles for calibration auditing if pred.market == "total" and pred.distribution and "projected_total" in reasoning: try: items_sorted = sorted((float(k), v) for k, v in pred.distribution.items()) cumulative = 0.0 p10 = p50 = p90 = None for score, prob in items_sorted: cumulative += prob if p10 is None and cumulative >= 0.10: p10 = score if p50 is None and cumulative >= 0.50: p50 = score if p90 is None and cumulative >= 0.90: p90 = score reasoning["projected_total_percentiles"] = { "p10": p10, "p50": p50, "p90": p90 } except (ValueError, TypeError): pass reasoning["edges"] = [ { "market": c.market, "side": c.side, "edge_pct": round(c.edge_pct, 4), "verdict": c.verdict.value, "sharp": c.sharp_divergence, "rlm": c.reverse_line_movement, } for c in sorted(results, key=lambda c: abs(c.edge_pct), reverse=True) ] reasoning["signals"] = { "sharp_markets": [c.market for c in results if c.sharp_divergence], "rlm_markets": [c.market for c in results if c.reverse_line_movement], } # ... rest of context building unchanged ``` --- ### 3. Add a Pick Volume Limiter — Cap Simultaneous Green Picks Per Game **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_evaluate()` **Why:** Some games produce 3–4 simultaneous green picks (e.g., Game 1373: moneyline/home, runline/home_minus, total/over all green). In a correlated game, these are not independent bets — they share the same model error. When the model is wrong about a game, it's wrong about all of it at once. Limiting to the single highest-edge pick per game reduces correlated exposure and forces selectivity. ```python _MAX_GREEN_PICKS_PER_GAME = int(os.environ.get("MAX_GREEN_PICKS_PER_GAME", "2")) def _evaluate(game_id: int, session: Session) -> list[MarketComparison]: model_run = get_latest_model_run_for_game(session, game_id) if model_run is None: logger.warning("evaluate_game: no model_run for game_id=%d", game_id) return [] predictions = get_predictions(session, model_run.id) odds_snapshots = get_odds_snapshots_for_game(session, game_id) splits_snapshots = get_latest_splits_for_game(session, game_id) now = datetime.now(UTC) results: list[MarketComparison] = [] for pred in predictions: market_threshold = _edge_threshold(pred.market) comp = _build_comparison( game_id=game_id, model_run_id=model_run.id, pred=pred, odds_snapshots=odds_snapshots, splits_snapshots=splits_snapshots, threshold=market_threshold, ) if comp is None: continue results.append(comp) # Enforce per-game green pick cap: keep only the top-N by edge magnitude. # Non-green picks are kept as-is for logging/auditing purposes. green_results = [r for r in results if r.verdict == Verdict.green] non_green_results = [r for r in results if r.verdict != Verdict.green] if len(green_results) > _MAX_GREEN_PICKS_PER_GAME: # Prioritize: (1) markets with sharp divergence supporting our side, # (2) highest edge. Demote excess greens to yellow. green_results_sorted = sorted( green_results, key=lambda c: (c.sharp_divergence, c.edge_pct), reverse=True, ) kept_green = green_results_sorted[:_MAX_GREEN_PICKS_PER_GAME] demoted = green_results_sorted[_MAX_GREEN_PICKS_PER_GAME:] for comp in demoted: # Rebuild with yellow verdict — create new dataclass instance comp = MarketComparison( game_id=comp.game_id, model_run_id=comp.model_run_id, market=comp.market, side=comp.side, fair_prob=comp.fair_prob, fair_price_american=comp.fair_price_american, consensus_price_american=comp.consensus_price_american, consensus_implied_prob=comp.consensus_implied_prob, edge_pct=comp.edge_pct, sharp_divergence=comp.sharp_divergence, reverse_line_movement=comp.reverse_line_movement, verdict=Verdict.yellow, # demoted due to
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-07-14_0702_model_review.md
Last 54 games — 2026-07-05 to 2026-07-08
Generated Jul 9, 2026
Analysis run
Last 54 games
ML30-24(55.6%)
ATS30-22(57.7%)
O/U24-27-3(47.1%)
Full season
ML382-346(52.5%)
ATS413-306(57.4%)
O/U358-331-39(52.0%)
Diagnosis
# Diagnosis ## 1. Totals Market Systematic Failure (47.1% win rate) The totals market is the clearest underperformer, sitting at 47.1%—well below the 60% review threshold and actually worse than random. Looking at the wrong predictions, the pattern is striking: the model repeatedly predicts **over** with high confidence and loses. Games 1267 (10-9, predicted under ✓), 1274 (0-2, predicted over ✗), 1291 (5-2, predicted over ✗), 1299 (1-3, predicted over ✗), 1307 (1-6, predicted over ✗), 1308 (3-0, predicted over ✗), 1309 (0-2, predicted over ✗), 1310 (0-3, predicted over ✗), 1315 (5-0, predicted over ✗), 1316 (1-5, predicted over ✗), 1318 (3-4, predicted over ✗) all show the model projecting high-scoring games that ended low-scoring. The `proj_total=?` in the wrong predictions list means the projected total isn't being stored in reasoning—a diagnostic gap—but the pattern of high `fair_prob` values (0.634, 0.666, 0.623, 0.615, 0.634, 0.614, 0.588, 0.612, 0.649, 0.765) on losing overs strongly suggests the PMF distribution is **right-skewed or shifted upward**, inflating over probabilities. This is a calibration defect in Layer 5 (Monte Carlo), not the market comparison layer. ## 2. Edge Threshold Is Too Permissive for Totals The `_DEFAULT_EDGE_THRESHOLD = 0.03` (3 percentage points) is extremely low and market-undifferentiated. Totals markets are among the sharpest in baseball—books set lines with roughly 4–5% vig, and the consensus devig process leaves very little genuine edge. Games 1271 (edge=+0.008), 1311 (edge=+0.039), 1291 (edge=+0.166 but wrong), and 1315 (edge=+0.145 but wrong) show that even large computed edges don't translate to wins in totals. The uniform 3pp threshold ignores that: (a) totals edges derived from a PMF are sensitive to the exact line value used, (b) temperature effects (100°F games like 1275, 1317; 63°F games like 1305) appear to be overcorrecting the PMF, and (c) the model is computing edge against consensus implied prob but the PMF itself may be the source of error, meaning the "edge" is illusory. A per-market threshold—specifically a **higher threshold for totals (e.g., 8–10pp)** and a different threshold for f5/nrfi—would filter out marginal totals calls. ## 3. Runline Direction Mismatch and Low-Edge Noise The runline market (57.7%) is above threshold but has a cluster of wrong predictions that share a specific pattern: **`away_plus` and `home_plus` predictions are losing at a higher rate** (Games 1272, 1275, 1279, 1281, 1291, 1293, 1301, 1310). Many of these have near-zero or slightly negative edges (1275: edge=-0.001, 1279: edge=-0.005, 1281: edge=-0.000, 1301: edge=+0.001) but still receive a `yellow` or `red` verdict—yet they're being counted as graded predictions, suggesting the verdict filtering downstream isn't excluding yellow/red from the record. More importantly, runline `plus` predictions involve backing the underdog to lose by 1 or win outright: these correlate strongly with home win probability (`home_wp`). Game 1293 has `home_wp=0.6212` yet the model predicted `away_plus`, which lost. The `_verdict` function flags negatives as red but the threshold for yellow (0 < edge < 0.03) is too generous—these marginal yellow bets are diluting the record. ## 4. Missing `proj_total` in Reasoning and Calibration Feedback Loop Every single wrong prediction shows `proj_total=?`. The `_build_reasoning` function computes `mean_total` from `pred.distribution` but this value is evidently not being stored—either `pred.distribution` is `None` for these games at reasoning time, or the total prediction's `pred.side` isn't matching `"over"` to trigger the block. This means there is **zero visibility** into what total the model projected, making it impossible to calibrate the PMF against actuals. You can't debug a systematic over-bias if you can't see the projected totals. This is both a logging defect and a calibration defect: without storing projected totals, the feedback loop for Layer 5 tuning is broken. ## 5. Weather/Temperature Overcorrection Several high-temperature games (Game 1275: 100°F, Game 1317: 98.3°F, Game 1301: 97.9°F) show the model predicting overs or high-scoring outcomes that don't materialize. Game 1275 ended 6-3 with `park_rf=0.9467` (a pitcher's park), yet the model predicted `runline/home_plus` (expecting the home team to need the cushion). Game 1317 ended 13-1 despite a heat-adjusted PMF presumably boosting scoring. The park factor for Game 1275 is 0.947—a genuine suppressor—but heat adjustment may be overriding it. The interaction between `park_rf < 0.96` and high temperature is likely being handled additively rather than multiplicatively, causing double-counting of scoring boosts when temperatures are extreme. --- # Specific Improvement Suggestions ## 1. Store `proj_total` Unconditionally in `_build_reasoning` **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_reasoning` **Problem:** The condition `if pred.market == "total" and pred.side == "over"` means that if the `over` prediction is filtered out (no odds, or distribution is None), `proj_total` is never stored. Also, the under prediction has the same distribution but is never checked. Store projected total from any total prediction. ```python def _build_reasoning( game_id: int, model_run_id: int, predictions: list[Prediction], results: list[MarketComparison], session: Session, ) -> dict: from db.models import Game, ParkFactor, WeatherSnapshot reasoning: dict = {} for pred in predictions: if pred.market in ("moneyline", "ml") and pred.side == "home" and pred.fair_prob is not None: reasoning["home_win_prob"] = round(pred.fair_prob, 4) # Store proj_total from ANY total prediction that has a distribution, # regardless of side, and regardless of whether it produced a comparison. if pred.market == "total" and pred.distribution and "projected_total" not in reasoning: try: mean_total = sum( float(k) * v for k, v in pred.distribution.items() ) reasoning["projected_total"] = round(mean_total, 2) # Also store distribution percentiles for calibration analysis sorted_items = sorted( ((float(k), v) for k, v in pred.distribution.items()), key=lambda x: x[0], ) cumulative = 0.0 p25 = p50 = p75 = None for score, prob in sorted_items: cumulative += prob if p25 is None and cumulative >= 0.25: p25 = score if p50 is None and cumulative >= 0.50: p50 = score if p75 is None and cumulative >= 0.75: p75 = score reasoning["proj_total_percentiles"] = { "p25": p25, "p50": p50, "p75": p75 } except (ValueError, TypeError): pass # ... rest of function unchanged ``` **Why:** Without `proj_total` in reasoning, there is no way to run a calibration check (projected vs. actual) on totals. The percentiles addition exposes whether the PMF is systematically right-skewed. --- ## 2. Introduce Per-Market Edge Thresholds **File:** `services/model/src/mlb_model/market/_evaluate.py` **Functions:** `_edge_threshold` (replace), `_build_comparison` (update call site) **Problem:** A single 3pp threshold treats a totals edge (derived from an uncertain PMF against a sharp market) identically to a moneyline edge (derived from a scalar `fair_prob`). Totals at 47.1% suggest the effective required threshold is much higher—the model's computed totals edge is not informative below ~8pp. ```python # Per-market thresholds. Totals are set higher because: # (1) the PMF line-sensitivity means small errors produce large apparent edges # (2) empirical win rate of 47.1% suggests systematic over-confidence # (3) totals markets are among the sharpest in baseball _MARKET_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.04, "runline": 0.05, "total": 0.08, # raised from 0.03 — see calibration notes "f5_total": 0.08, "nrfi": 0.06, } _DEFAULT_EDGE_THRESHOLD = 0.05 # fallback for unknown markets def _edge_threshold(market: str | None = None) -> float: """Return the edge threshold for a given market. Environment variable EDGE_THRESHOLD_PCT overrides ALL markets (useful for backtesting sweeps). Per-market env vars take precedence over the global one: e.g. EDGE_THRESHOLD_TOTAL_PCT=10 sets totals threshold to 10pp. """ if market is not None: env_key = f"EDGE_THRESHOLD_{market.upper()}_PCT" raw_market = os.environ.get(env_key, "") if raw_market: try: return float(raw_market) / 100.0 except ValueError: pass raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass if market is not None: return _MARKET_EDGE_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD ``` Then update the call site in `_build_comparison`: ```python def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, # kept for backward compat but now overridden per-market ) -> MarketComparison | None: # ... existing code up to verdict ... # Use per-market threshold; fall back to the passed-in global threshold market_threshold = _edge_threshold(pred.market) sharp = detect_sharp_divergence(market_splits) rlm = detect_reverse_line_movement(market_odds, market_splits) verdict = _verdict(edge, sharp, rlm, market_threshold) # ← use market_threshold return MarketComparison( # ... unchanged fields ... verdict=verdict, ) ``` And update `_evaluate` to still pass the global threshold for the `add_market_comparison` call but not for verdict computation: ```python threshold = _edge_threshold() # global default, used as fallback only ``` --- ## 3. Exclude Yellow-Verdict Predictions from Performance Record (or Track Separately) **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict` **Problem:** Yellow verdicts (0 ≤ edge < threshold) are ambiguous but are being graded in the win/loss record. Games 1271 (edge=+0.008), 1281 (edge=+0.002), 1301 (edge=+0.001), 1303 (edge=+0.019), 1311 (edge=+0.010) all show yellows in the wrong-predictions list, diluting the signal. The fix is twofold: add a minimum `fair_prob` floor below which a yellow is demoted to red, and tag yellow verdicts explicitly so downstream reporting can separate them. ```python _MIN_FAIR_PROB_FOR_YELLOW = 0.52 # don't call yellow on near-coin-flip models def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float, fair_prob: float = 0.5) -> Verdict: """Assign verdict with sharp/RLM upgrades and fair_prob floor. Rules (in priority order): 1. edge < 0 → red (model is on wrong side of market) 2. edge >= threshold AND fair_prob >= min_floor → green 3. edge >= threshold but fair_prob < min_floor → yellow (low conviction) 4. 0 <= edge < threshold → yellow only if fair_prob >= min_floor, else red """ if edge < 0.0: return Verdict.red if fair_prob < _MIN_FAIR_PROB_FOR_YELLOW: # Even with positive edge, insufficient model conviction → red return Verdict.red if edge >= threshold: return Verdict.green # 0 <= edge < threshold return Verdict.yellow ``` Then pass `fair_prob` into the call in `_build_comparison`: ```python verdict = _verdict(edge, sharp, rlm, market_threshold, fair_prob=fair_prob) ``` **Why:** Game 1271 has `fair_prob=0.508` and `edge=+0.008`—essentially a coin flip with noise. Calling this yellow (let alone ever green) is adding noise to the record. The 0.52 floor eliminates these marginal calls and would have correctly suppressed Games 1271, 1281, 1301, and 1311. --- ## 4. Add a Runline Direction Confidence Gate **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` **Problem:** The runline `plus` predictions (home_plus, away_plus) are losing at a disproportionate rate. These bets require either an upset or a close loss, making them sensitive to the accuracy of `home_wp`. When `home_wp` strongly favors the team being given `+1.5`, the model is essentially hedging against its own moneyline call. Add a gate: don't emit a runline `plus` green/yellow when the model's moneyline already strongly disagrees with the direction. ```python def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, ) -> MarketComparison | None: # ... existing setup code ... # Runline coherence gate: suppress "plus" runline predictions when the # model's own win probability strongly favors the OTHER team. # E.g. don't emit away_plus if home_wp > 0.62 — the model thinks the # home team wins comfortably, making away +1.5 a low-conviction hedge. if pred.market == "runline" and pred.side.endswith("plus"): # Find the moneyline home prediction to get home_wp home_ml_pred = next( ( p for p in [pred] # we only have the current pred here; # home_wp needs to come from reasoning or be passed in. # See note below — wire through home_wp from predictions list. ), None, ) # Implementation note: home_wp isn't directly on pred; refactor # _
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-07-09_0703_model_review.md
Last 66 games — 2026-06-30 to 2026-07-04
Generated Jul 5, 2026
Analysis run
Last 66 games
ML33-33(50.0%)
ATS29-36(44.6%)
O/U36-29-1(55.4%)
Full season
ML352-322(52.2%)
ATS383-284(57.4%)
O/U334-304-36(52.3%)
Diagnosis
# Diagnosis and Improvement Recommendations ## Diagnosis ### 1. Totals Market: Systematic Over-Prediction of Scoring The totals market is the clearest failure mode. The model shows a **55.4% win rate on totals** (the best market), but the wrong-predictions list reveals a stark pattern: the model repeatedly predicts **over** when games go under, and predicts **under** when games explode. Game 1201 (actual 9-3=12 runs, predicted under with fair_prob=0.613, edge=+0.103 green), Game 1212 (actual 14-3=17 runs, predicted under, green), Game 1239 (actual 17-1=18 runs, predicted under, green), Game 1245 (actual 3-15=18 runs, predicted under, green), and Game 1254 (actual 3-0=3 runs, predicted over with edge=+0.363, the largest edge in the dataset — green). The distribution-based fair_prob calculation in `_fair_prob_from_dist` may be pulling from a PMF that isn't updated to reflect late-breaking lineup information (park factor, wind speed), or the Monte Carlo simulation is systematically compressing the tails of the distribution. Critically, `proj_total=?` across all games suggests the projected total is never populated in the reasoning output, which means either the distribution is missing or the mean calculation in `_build_reasoning` is silently failing — a bug that itself indicates the distribution pipeline is broken. ### 2. Runline: Pervasive Calibration Failure at the Margin The runline market at **44.6%** is the worst-performing market and is below the 60% flag threshold. Looking at the wrong predictions, almost all are **away_plus** or **home_plus** predictions (the +1.5 side) that fail — Games 1203, 1209, 1210, 1212, 1216, 1222, 1224, 1238, 1240, 1242, 1251, 1255. Many of these have `edge` values that are very close to 0 or even negative (1209: -0.027, 1210: -0.058, 1212: -0.024, 1222: -0.078, 1238: -0.025), yet were still emitted as `red` verdicts that counted as graded losses. The problem is that the model is generating `runline/away_plus` predictions with `fair_prob` values in the 0.50–0.57 range — these are essentially coin flips — and the `_verdict` function only gates on the edge threshold, not on a minimum `fair_prob` floor. There is also a directional bias: the model appears to systematically underestimate blowout potential (Games 1203: 0-8, 1211: 4-6, 1212: 14-3, 1232: 4-14), which means it over-values the cover probability for the team actually getting blown out. ### 3. Moneyline Calibration at Low Edge Values The moneyline sits at exactly **50.0%**, which is random-walk territory. The failing predictions almost universally share one characteristic: the edge is **negative or near-zero** (Game 1204: edge=-0.016, 1206: edge=-0.028, 1218: edge=-0.036, 1248: edge=-0.015, 1258: edge=-0.050). These are all `red` verdict predictions — meaning the system *correctly identified* they were low-confidence — but they still appear to be included in the graded record, which raises the question of whether `red` verdicts should be graded at all, or whether the model is being forced to pick a side regardless of conviction. For moneyline predictions with `fair_prob` between 0.50 and 0.55, the model is essentially guessing. The `_verdict` function returns `red` for these but doesn't suppress them from grading. Separately, high-confidence green moneyline predictions do *win* (1211, 1214, 1217, 1220, 1221, 1223, 1244, 1257), suggesting the model has real signal at the extremes but is being diluted by marginal picks. ### 4. Weather and Park Factor Are Not Modulating Totals Correctly Several of the most egregious totals misses occur in extreme weather: Game 1254 was **98.8°F** with park_rf=0.9467 (a pitcher's park) and predicted over with fair_prob=0.841 — yet the game went 3-0 (3 total runs). Game 1230 was **101.4°F** at park_rf=1.0383 and predicted over — went 6-1 (7 runs, borderline). Game 1239 was **77.7°F** at park_rf=0.955 and predicted under with fair_prob=0.548 — went 17-1 (18 runs). The weather integration appears to be either: (a) not being applied to the distribution before `_fair_prob_from_dist` evaluates it, or (b) miscalibrated in sign — high temperatures in dome-neutral parks don't uniformly boost scoring the way the model seems to expect. The `proj_total=?` bug means we cannot confirm whether the temperature adjustment is actually shifting the distribution mean. ### 5. Edge Threshold Is Too Permissive for Low-Information Markets The `_DEFAULT_EDGE_THRESHOLD = 0.03` (3 percentage points) is generating a large volume of low-conviction bets. Looking at the data: Game 1233 (away, edge=+0.037), Game 1252 (over, edge=+0.068), Game 1256 (under, edge=+0.008 yellow). The system is flagging markets below **60% win rate** for review, but the threshold producing those markets is 3pp. At fair_prob=0.53 and consensus_implied=0.50, a 3pp edge on a coin-flip market is statistically meaningless given sample variance. The yellow band (0 ≤ edge < threshold) is capturing many near-breakeven predictions that pollute the graded record without adding value. --- ## Specific Improvement Suggestions ### 1. Add a minimum `fair_prob` floor before emitting any prediction **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` **Why:** Runline and moneyline predictions with `fair_prob` in the 0.50–0.53 range are statistically indistinguishable from noise. The model loses 50% of these by definition. A floor of 0.54 for moneyline and 0.56 for runline (where the +1.5 line adds implied probability) would eliminate the lowest-signal picks that are dragging down win rates. ```python # Add this constant near the top of _evaluate.py alongside _DEFAULT_EDGE_THRESHOLD _MIN_FAIR_PROB: dict[str, float] = { "moneyline": 0.54, "runline": 0.56, "total": 0.52, "f5_total": 0.52, "nrfi": 0.52, } def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, ) -> MarketComparison | None: odds_key = _MODEL_TO_ODDS.get((pred.market, pred.side)) if odds_key is None: return None # --- NEW: reject predictions below minimum fair_prob floor --- min_fp = _MIN_FAIR_PROB.get(pred.market, 0.52) candidate_fp = pred.fair_prob if pred.fair_prob is not None else 0.0 # For distribution markets we can't check until after line resolution, # so we defer; for discrete markets, gate here early. if pred.market not in _DISTRIBUTION_MARKETS and candidate_fp < min_fp: logger.debug( "Skipping game_id=%d market=%s side=%s: fair_prob=%.4f < floor=%.4f", game_id, pred.market, pred.side, candidate_fp, min_fp, ) return None # --- END NEW --- odds_market, odds_side = odds_key # ... rest of function unchanged ... ``` --- ### 2. Raise the edge threshold, differentiate by market **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_edge_threshold` and `_build_comparison` / `_verdict` **Why:** A flat 3pp threshold produces too many marginal bets. The runline at 44.6% and moneyline at 50.0% both show the model has no edge at low edge values. Raising the global threshold to 5pp and requiring 7pp for runline (where calibration is worst) would significantly reduce bet volume while concentrating on the higher-confidence subset. The `EDGE_THRESHOLD_PCT` env var override should remain but per-market overrides need to be expressible. ```python # Replace the single threshold constant and function with a per-market map: _DEFAULT_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.05, # was 0.03; 50% win rate demands higher bar "runline": 0.07, # was 0.03; 44.6% win rate, worst market "total": 0.05, # was 0.03; slight boost, 55% still improvable "f5_total": 0.05, "nrfi": 0.05, } _FALLBACK_EDGE_THRESHOLD = 0.05 def _edge_threshold(market: str | None = None) -> float: """Return edge threshold for a specific market, with env-var override.""" env_raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if env_raw: try: return float(env_raw) / 100.0 except ValueError: pass if market is not None: return _DEFAULT_EDGE_THRESHOLDS.get(market, _FALLBACK_EDGE_THRESHOLD) return _FALLBACK_EDGE_THRESHOLD # In _build_comparison, pass market-specific threshold to _verdict: def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, # this is now the caller's global; we override below ) -> MarketComparison | None: # ... existing code up to verdict computation ... market_threshold = _edge_threshold(pred.market) # per-market threshold verdict = _verdict(edge, sharp, rlm, market_threshold) return MarketComparison( # ... unchanged ... verdict=verdict, ) # In _evaluate, keep passing global threshold for backward compat but # _build_comparison now overrides it per market: def _evaluate(game_id: int, session: Session) -> list[MarketComparison]: threshold = _edge_threshold() # global fallback, still used as guard # ... rest unchanged ... ``` --- ### 3. Fix the `proj_total=?` bug — guard against empty/None distributions in `_build_reasoning` **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_reasoning` **Why:** Every game in the dataset shows `proj_total=?`, meaning the mean total calculation is silently failing. The most likely cause is that `pred.distribution` contains string keys like `"9"` but the sum tries `float(k)` and either the dict is empty or the prediction for `total/over` is not found because the market string stored differs from `"total"`. Adding explicit error logging here will surface whether the distribution is missing or malformed — this is a critical diagnostic gap because the totals market accounts for most model output. ```python def _build_reasoning( game_id: int, model_run_id: int, predictions: list[Prediction], results: list[MarketComparison], session: Session, ) -> dict: from db.models import Game, ParkFactor, WeatherSnapshot reasoning: dict = {} for pred in predictions: if pred.market in ("moneyline", "ml") and pred.side == "home" and pred.fair_prob is not None: reasoning["home_win_prob"] = round(pred.fair_prob, 4) # --- FIXED: add logging and handle both "total" and "f5_total" --- if pred.market in ("total", "f5_total") and pred.side == "over": if pred.distribution is None: logger.warning( "_build_reasoning game_id=%d: distribution is None for market=%s side=%s", game_id, pred.market, pred.side, ) elif not pred.distribution: logger.warning( "_build_reasoning game_id=%d: distribution is empty for market=%s side=%s", game_id, pred.market, pred.side, ) else: try: mean_total = sum( float(k) * v for k, v in pred.distribution.items() if v is not None ) total_weight = sum( v for v in pred.distribution.values() if v is not None ) if total_weight > 0: # Normalize in case PMF doesn't sum to exactly 1.0 mean_total = mean_total / total_weight key = "projected_total" if pred.market == "total" else "projected_f5_total" reasoning[key] = round(mean_total, 2) else: logger.warning( "_build_reasoning game_id=%d: distribution weights sum to 0", game_id, ) except (ValueError, TypeError) as exc: logger.error( "_build_reasoning game_id=%d: distribution parse error: %s dist_keys=%s", game_id, exc, list(pred.distribution.keys())[:10], ) # --- END FIX --- # ... rest of function unchanged ... ``` --- ### 4. Apply a distribution post-check for totals: reject if distribution-derived fair_prob is close to 0.5 after line resolution **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` **Why:** Game 1254's over had `fair_prob=0.841` and `edge=+0.363` — the largest edge in the dataset — and lost 3-0. When fair_prob from a distribution is this extreme, it suggests the distribution's mean is far from the actual line, or the PMF has near-zero mass on one side. A sanity-cap on distribution-derived fair_prob (e.g., clamp to [0.35, 0.85]) combined with a minimum mass-check prevents the model from betting with artificial certainty due to a miscalibrated distribution. ```python _DIST_FAIR_PROB_MIN = 0.35 _DIST_FAIR_PROB_MAX = 0.82 _DIST_MIN_TAIL_MASS = 0.08 # reject if less than 8% mass on predicted side's complement def _fair_prob_from_dist( distribution: dict[str, float], side: str, line: float ) -> float | None: # <-- return None on failure instead of 0.0 """Derive fair probability for a totals market from a stored PMF. Returns None if the distribution has insufficient mass or is malformed. """ def _numeric(k: str) -> float | None: try: return float(k) except (ValueError, TypeError): return None if side == "over": prob = sum(v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n > line) complement = sum(v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n <= line)
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-07-05_0703_model_review.md
Last 97 games — 2026-06-23 to 2026-06-29
Generated Jun 30, 2026
Analysis run
Last 97 games
ML57-40(58.8%)
ATS60-35(63.2%)
O/U52-43-2(54.7%)
Full season
ML319-289(52.5%)
ATS354-248(58.8%)
O/U298-275-35(52.0%)
Diagnosis
# Diagnosis ## 1. Totals Market Is the Core Underperformer and Shows Systematic Over Bias The totals market is at 54.7% (52-43), the only market below the 60% threshold. Looking at the wrong predictions, the pattern is stark: the model repeatedly issues `green` verdicts on **over** bets with high fair_probs (0.603–0.850) that lose in low-scoring games. Games 1108 (4-3), 1111 (2-0), 1114 (4-3), 1127 (5-4), 1128 (3-1), 1130 (4-3), 1133 (1-2), 1149 (2-1), 1163 (1-2), 1175 (3-2), 1180 (2-3), 1183 (1-2), 1184 (1-4), 1186 (4-2) all produced losing over bets despite fair_probs ranging from 0.552 to 0.850. The model is not just marginally wrong on totals — it's confidently wrong in a directional way. The projected total is missing from all entries (`proj_total=?`), which itself is a red flag suggesting a data pipeline gap that may be masking calibration drift. The over bias is most pronounced in games with park_rf near or above 1.0 and moderate wind, suggesting the Monte Carlo layer is overweighting park and temperature factors for run scoring. ## 2. Moneyline Accuracy Masks a Near-Coin-Flip Regime Below 0.55 Fair Prob Moneyline is technically at 58.8% but several losses cluster in a specific band: games where `fair_prob` is between 0.500 and 0.545 with `verdict=red` (negative edge) that were still logged as wrong predictions. Examples: Game 1112 (home_wp=0.528, edge=+0.144, lost badly — 12-3 home win means this was actually a correct pick, so losses cluster elsewhere). More critically, games like 1116, 1126, 1128, 1139, 1158, 1166, 1167, 1168, 1173, 1181, 1190 all show moneyline predictions with fair_prob between 0.503–0.578 losing. The edge computation (`compute_edge = model_prob - market_prob`) is a raw probability difference with no Kelly-style confidence weighting, meaning a 0.503 fair_prob game with edge=+0.125 (Game 1130) gets the same `green` verdict as a 0.775 fair_prob game. The model is treating low-confidence near-coin-flip predictions identically to high-conviction calls, which inflates bet volume in a regime where the signal is essentially noise. ## 3. Runline Shows the Best Performance (63.2%) But Has a Directional Asymmetry The runline at 63.2% is the strongest market, but several losses reveal a specific failure mode: `home_plus` and `away_minus` predictions that cover the wrong side when home teams in the 0.28–0.40 home_wp range are involved. Games 1120 (home_wp=0.481, home_plus lost), 1129 (home_wp=0.298, away_minus lost despite +0.134 edge), 1148 (home_wp=0.582, away_plus with negative edge correctly flagged red but still bet), 1154 (home_plus with -0.013 edge flagged red, lost), 1176 (home_plus with -0.066 edge flagged red, lost), 1189 (home_plus -0.030 edge, red, lost). The red-verdict bets appearing in wrong predictions is actually expected (those are filtered out in production), but the pattern of home_plus overvaluation for underdogs in the 0.30–0.42 home_wp band is consistent. The model appears to give too much credit to the +1.5 cushion for home underdogs without properly accounting for blowout risk in that talent gap range. ## 4. The Verdict Function Ignores Sharp and RLM Signals Entirely The `_verdict` function in `_evaluate.py` accepts `sharp` and `rlm` boolean parameters but **does nothing with them** — they're dead arguments. The function signature is `_verdict(edge, sharp, rlm, threshold)` but only `edge` and `threshold` affect the output. This means the carefully computed sharp money divergence and reverse line movement signals from `_signals.py` are stored in the database but never influence bet selection. Given that sharp action is one of the strongest predictive signals in sports betting, this is a significant missed opportunity that is almost certainly contributing to losses on games where the model has edge but the market has already adjusted against the model's direction. The `_build_reasoning` function correctly logs these signals, confirming they're computed — they're just silently discarded in the verdict. ## 5. Edge Threshold Is Too Permissive for Low-Probability Markets and Ignores Sample-Size Calibration The default `_edge_threshold()` of 3% (0.03) is flat across all markets and all fair_prob levels. A 3% edge at fair_prob=0.503 (Game 1128, moneyline) and a 3% edge at fair_prob=0.734 (Game 1116, over) represent completely different confidence levels — the former is within any reasonable calibration error band, the latter is a strong signal. The flat threshold generates too many low-conviction green verdicts. Additionally, totals predictions use `_fair_prob_from_dist()` which sums PMF buckets above/below the line, but the missing `proj_total=?` values in every game record suggests the distribution isn't being stored or retrieved correctly, meaning `fair_prob` may be falling back to a stored scalar rather than the distribution-derived value, or the distribution computation itself is drifting. Either way, the over-bias at high apparent fair_probs strongly suggests systematic overestimation of run-scoring probability. --- # Specific Improvement Suggestions ## 1. Fix the Dead `sharp`/`rlm` Arguments in `_verdict` **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict` The function currently ignores both boolean signals. Sharp money divergence should upgrade a yellow to green and protect against fading a sharp-sided market. RLM should downgrade a green to yellow when the public side is losing line value (the model may be on the wrong side of sharp action). ```python def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: """ Verdict logic incorporating sharp and RLM signals. Sharp divergence (handle >> bets on minority side) upgrades borderline bets. Reverse line movement against our side downgrades green bets — it means sharp money moved the line against the direction we're betting. """ base_edge_met = edge >= threshold marginal_edge = 0.0 <= edge < threshold # yellow zone if edge < 0.0: return Verdict.red if base_edge_met: if rlm: # Sharp money moved against our position despite us having edge — # downgrade to yellow for manual review rather than auto-betting. return Verdict.yellow return Verdict.green if marginal_edge: if sharp and not rlm: # Sharp money agrees with our direction; promote yellow to green. return Verdict.green return Verdict.yellow return Verdict.red ``` --- ## 2. Introduce Market-Specific and Probability-Tiered Edge Thresholds **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_edge_threshold` → replace with `_edge_threshold_for` A flat 3% threshold treats a near-coinflip moneyline the same as a high-conviction totals call. Totals should require more edge given their current underperformance. Low-probability predictions (fair_prob < 0.52) should require a higher threshold since they're within model calibration noise. ```python # Market-specific minimum edge thresholds (baseline) _MARKET_THRESHOLDS: dict[str, float] = { "moneyline": 0.05, # Raised from 0.03 — ML near-coinflips are noise "runline": 0.04, # Slight raise; currently the strongest market "total": 0.07, # Significant raise — 54.7% win rate signals over-bet "f5_total": 0.07, "nrfi": 0.05, } _DEFAULT_EDGE_THRESHOLD = 0.05 # Raised from 0.03 as floor def _edge_threshold_for(market: str, fair_prob: float) -> float: """ Return the minimum edge required for a green verdict. Applies a low-confidence penalty when fair_prob is near 0.5, since the model's calibration error is largest in that region. """ raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: base = float(raw) / 100.0 except ValueError: base = _MARKET_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) else: base = _MARKET_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) # Low-confidence penalty: if fair_prob is within 0.04 of 0.5 (either side), # require an additional 2pp of edge — these are coin-flip calls. if abs(fair_prob - 0.5) < 0.04: base += 0.02 return base ``` Then update `_build_comparison` to use it: ```python # In _build_comparison, replace: # threshold = _edge_threshold() # (passed in from _evaluate) # with per-call threshold: fair_prob_for_threshold = fair_prob # already computed above at this point threshold = _edge_threshold_for(pred.market, fair_prob_for_threshold) verdict = _verdict(edge, sharp, rlm, threshold) ``` And update `_evaluate` to stop passing a single global threshold: ```python # In _evaluate, remove: # threshold = _edge_threshold() # Remove threshold from _build_comparison call signature — it now self-computes. # Update _build_comparison signature: def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], # threshold removed — computed internally per market/prob ) -> MarketComparison | None: ``` --- ## 3. Add a Totals Over-Bias Correction Factor **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` The data shows systematic over-prediction on totals. Until the upstream Monte Carlo layer is recalibrated, apply a shrinkage correction to totals fair_probs that pulls them toward 0.5, weighted by how far they are from 0.5. This is a pragmatic patch while the root cause (likely park/weather overweighting in layers 3-4) is investigated. ```python # Calibration shrinkage constants derived from recent performance data. # Totals over is hitting ~54% when the model projects 55-85% — suggesting # roughly 6-8pp of systematic overconfidence on the over side. _TOTALS_OVER_SHRINKAGE = 0.08 # Pull over prob 8pp toward 0.5 _TOTALS_UNDER_SHRINKAGE = 0.03 # Under is less biased; smaller correction def _apply_totals_calibration(fair_prob: float, side: str) -> float: """ Apply shrinkage correction to totals fair_prob toward 0.5. The model shows systematic over-bias on totals (54.7% win rate despite projecting 55-85% probability). This pulls predictions toward base rate until the Monte Carlo layer is recalibrated. Args: fair_prob: Model's raw fair probability side: 'over' or 'under' Returns: Adjusted fair probability """ if side == "over": shrinkage = _TOTALS_OVER_SHRINKAGE elif side == "under": shrinkage = _TOTALS_UNDER_SHRINKAGE else: return fair_prob # Weighted pull toward 0.5: stronger correction for high-confidence calls # since those are where the over-bias is most damaging (Games 1180, 1183, # 1186 had fair_prob 0.80-0.85 and lost). distance_from_half = abs(fair_prob - 0.5) correction = shrinkage * (distance_from_half / 0.5) # scales 0→shrinkage if fair_prob > 0.5: return fair_prob - correction return fair_prob + correction # In _build_comparison, after fair_prob is set, add: if pred.market in ("total", "f5_total"): fair_prob = _apply_totals_calibration(fair_prob, pred.side) # Recompute fair_price after calibration adjustment fair_price = prob_to_american(fair_prob) ``` --- ## 4. Add a Minimum Fair Probability Filter for Moneyline Bets **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` Games 1126, 1128, 1130, 1139, 1158, 1167, 1168, 1173, 1181 all show moneyline losses where `fair_prob` was between 0.503–0.547. Even with positive edge, a 50.3% model probability on a moneyline is indistinguishable from noise given any realistic calibration error. These bets inflate volume without signal. ```python # Add these constants near the top of _evaluate.py _ML_MIN_FAIR_PROB = 0.54 # Don't bet moneylines below this conviction level _RL_MIN_FAIR_PROB = 0.56 # Runlines need more separation given vig structure # In _build_comparison, after fair_prob is computed, add before verdict: if pred.market == "moneyline" and fair_prob < _ML_MIN_FAIR_PROB: logger.debug( "Skipping moneyline game_id=%d side=%s: fair_prob=%.3f below minimum %.3f", game_id, pred.side, fair_prob, _ML_MIN_FAIR_PROB, ) return None # Don't generate a comparison row at all — no bet signal if pred.market == "runline" and fair_prob < _RL_MIN_FAIR_PROB: logger.debug( "Skipping runline game_id=%d side=%s: fair_prob=%.3f below minimum %.3f", game_id, pred.side, fair_prob, _RL_MIN_FAIR_PROB, ) return None ``` --- ## 5. Fix the `proj_total=?` Gap — Validate Distribution Storage Before Evaluation **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_reasoning` and `_build_comparison` Every game record shows `proj_total=?`, meaning `projected_total` is never being written to reasoning. This points to either `pred.distribution` being `None` for total predictions (causing the `if pred.distribution` branch to be skipped in `_build_comparison`) or the distribution keys not being floatable. Add explicit validation and a fallback warning. ```python # In _build_reasoning, replace the projected_total block: if pred.market == "total" and pred.side == "over" and pred.distribution: try: mean_total = sum(float(k) * v for k, v in pred.distribution.items()) reasoning["projected_total"] = round(mean_total, 2) except (ValueError, TypeError) as exc: logger.warning( "projected_total computation failed game_id=%d: %s
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-06-30_0703_model_review.md
Last 62 games — 2026-06-18 to 2026-06-22
Generated Jun 23, 2026
Analysis run
Last 62 games
ML29-33(46.8%)
ATS31-28(52.5%)
O/U22-38-2(36.7%)
Full season
ML262-249(51.3%)
ATS294-213(58.0%)
O/U246-232-33(51.5%)
Diagnosis
# Diagnosis ## 1. Totals Market is Severely Miscalibrated (36.7% win rate) The totals market is the most glaring failure. Of the 62 graded games, totals went 22-38 — dramatically below the 46-50% floor you'd expect from random chance, let alone the 60%+ target. Looking at the wrong predictions, nearly every game with a `total/over` bet lost despite showing `fair_prob` values of 0.52–0.86 and edges of +0.025 to +0.392. Games 1039, 1040, 1042, 1044, 1047, 1049, 1050, 1051, 1052, 1053, 1062, 1066, 1072, 1073, 1074, 1076, 1078, 1080, 1081, 1084, 1086 all took `total/over` and lost (scores like 4-3, 4-2, 0-3, 5-1, 0-5, 3-4, 0-5, 2-5, 3-4, 2-3, 1-4, 4-3, 7-0, 3-2, 5-1, 4-1, 3-4, 1-2, 1-2, 3-4, 4-2). The model is systematically projecting more run-scoring than is actually occurring. This is a **directional bias**: the totals model almost exclusively recommends overs, which means either (a) the projected totals (which are missing from the data — `proj_total=?`) are inflated, or (b) the distribution PMF is systematically right-skewed. The `_fair_prob_from_dist` function computes `sum(v for k,v in distribution.items() if _n > line)` — if the PMF mean exceeds the market line consistently, this will fire overs constantly regardless of true edge. ## 2. The Edge Threshold is Too Permissive for the Totals Market Specifically The `_DEFAULT_EDGE_THRESHOLD = 0.03` (3 percentage points) is applied uniformly across moneyline, runline, and totals. But these markets have very different vig structures and prediction uncertainty. A 3pp edge on a totals over with `fair_prob=0.523` (Game 1091) is noise — the confidence interval on a PMF-derived probability from a Monte Carlo simulation is almost certainly wider than 3pp. Meanwhile, Game 1066 shows `fair_prob=0.859, edge=+0.392` on a `total/over` that lost 4-3. This is an extreme miscalibration: no totals model should be assigning 86% probability to a game going over 7 (roughly), yet the score was 7 runs total. The PMF is either generating nonsensical distributions or the consensus line for that game was extremely low (making an "over" of, say, 5 look likely but with a huge true uncertainty band). The uniform 3pp threshold is masking deeply broken total projections. ## 3. Moneyline Has Specific Calibration Problems Around the 0.50–0.60 Range The moneyline went 29-33 (46.8%), also below 50%. Looking at the wrong predictions more carefully: Games 1040, 1044, 1047, 1050 all lost on `total/over` but also note that several moneyline bets in the 0.52–0.60 `fair_prob` range lost (e.g., Game 1042 predicted away ML at 0.511 — won correctly; but Game 1056 at 0.571, Game 1067 at 0.530, Game 1068 at 0.545 all won correctly yet are in the wrong-predictions list, suggesting the overall moneyline rate is being dragged down by near-50/50 games). The model is treating `home_wp=0.49` and `home_wp=0.51` predictions as meaningful directional edges, but at these probabilities the noise swamps the signal. The `fair_prob=0.500` case (Game 1074) generating a `verdict=red` due to `edge=-0.032` and yet the home team won 5-1 is an example of the edge calculation being near-meaningless at these probability levels. ## 4. The `proj_total=?` Gap Suggests a Structural Pipeline Break Every single game in the wrong predictions shows `proj_total=?` — the projected total is never populated in the reasoning output. Looking at `_build_reasoning`, it only populates `projected_total` when `pred.market == "total" and pred.side == "over" and pred.distribution` — meaning it requires a PMF distribution. If the distribution is present but `mean_total` computation fails (e.g., non-numeric keys), the reasoning silently skips it. More critically, if `proj_total` is always null in reasoning, it suggests the distribution-based totals path may have numerical issues — non-integer keys, probability masses not summing to 1.0, or keys that fail `float(k)`. This would cause `_fair_prob_from_dist` to return values derived from a partially-summed PMF, systematically under- or over-counting probability mass. ## 5. Red-Verdict Bets Are Being Tracked but the Signal Quality of `_verdict` Is Weak Several wrong predictions show `verdict=red` bets that won anyway (Games 1071, 1072, 1074, 1088, 1092, 1101 — all red verdict but the predicted side won). The `_verdict` function ignores `sharp` and `rlm` signals entirely — they're computed but never used to adjust the verdict. The comment in `detect_sharp_divergence` and `detect_reverse_line_movement` suggests these were intended to upgrade/downgrade verdicts, but the current `_verdict` only looks at the raw edge threshold. This means sharp money and reverse line movement data is being collected, logged, and completely ignored for decision-making. --- # Specific Improvement Suggestions ## 1. Add Per-Market Edge Thresholds (replaces uniform 3pp) **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_edge_threshold()` and `_verdict()` **Why:** A 3pp edge on totals is noise given PMF uncertainty. Totals should require ~8-10pp; moneyline near-50/50 games should require more than 3pp. The current uniform threshold is the single biggest source of false green signals. ```python # In _evaluate.py, replace _edge_threshold() and _verdict() with: _DEFAULT_EDGE_THRESHOLD = 0.03 _MARKET_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.06, # Need more separation than 3pp for noisy 50/50 games "runline": 0.05, "total": 0.10, # PMF-derived probs have wide CI; demand real edge "f5_total": 0.10, "nrfi": 0.07, } def _edge_threshold(market: str | None = None) -> float: """Return edge threshold for a specific market, with env override.""" raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass if market is not None: return _MARKET_EDGE_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD def _verdict( edge: float, sharp: bool, rlm: bool, threshold: float, fair_prob: float = 0.5, ) -> Verdict: """Assign verdict incorporating sharp/RLM signals and probability floor.""" # Reject near-coinflip predictions regardless of edge _PROB_FLOOR = 0.54 if fair_prob < _PROB_FLOOR: return Verdict.red # Sharp money opposing our side downgrades a marginal green to yellow if edge >= threshold: if sharp or rlm: # Sharp signal cuts the effective edge in half — require double threshold return Verdict.green if edge >= threshold * 2.0 else Verdict.yellow return Verdict.green if edge < 0.0: return Verdict.red return Verdict.yellow ``` Then update `_build_comparison` to pass market and fair_prob: ```python # In _build_comparison, replace: verdict = _verdict(edge, sharp, rlm, threshold) # With: verdict = _verdict( edge, sharp, rlm, threshold=_edge_threshold(pred.market), # per-market threshold fair_prob=fair_prob, ) ``` --- ## 2. Validate and Normalize the PMF Distribution Before Using It **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_fair_prob_from_dist()` **Why:** The `proj_total=?` in all reasoning outputs suggests the PMF may have non-numeric keys or probability masses that don't sum to 1.0, causing silent partial computation. A PMF summing to 0.7 will produce artificially low over/under probabilities, then the edge calc fires because the market implies 0.48 but the PMF gives 0.65 (on a malformed 70%-mass PMF that should give 0.93). This is likely a major contributor to the over-bias. ```python def _fair_prob_from_dist( distribution: dict[str, float], side: str, line: float ) -> float | None: """Derive fair probability for a totals market from a stored PMF. Returns None if the distribution is invalid (non-numeric keys, probability mass outside [0.8, 1.2], or fewer than 5 buckets). """ def _numeric(k: str) -> float | None: try: v = float(k) return v if 0 <= v <= 30 else None # sanity: MLB totals 0-30 except (ValueError, TypeError): return None numeric_items = [ (_numeric(k), v) for k, v in distribution.items() if _numeric(k) is not None and isinstance(v, (int, float)) and v >= 0 ] if len(numeric_items) < 5: logger.warning( "_fair_prob_from_dist: distribution has only %d valid buckets", len(numeric_items), ) return None total_mass = sum(v for _, v in numeric_items) if not (0.80 <= total_mass <= 1.20): logger.warning( "_fair_prob_from_dist: PMF mass=%.4f is outside [0.80, 1.20]; " "skipping to avoid miscalibrated edge", total_mass, ) return None # Normalize to exactly 1.0 before computing tail probability if side == "over": raw = sum(v for n, v in numeric_items if n > line) else: raw = sum(v for n, v in numeric_items if n <= line) return raw / total_mass # normalized probability # In _build_comparison, update the totals branch: if pred.market in _DISTRIBUTION_MARKETS: if pred.distribution is None or consensus.median_line_value is None: return None fair_prob = _fair_prob_from_dist( pred.distribution, pred.side, consensus.median_line_value ) if fair_prob is None: logger.warning( "game_id=%d pred %s/%s: invalid distribution, skipping", game_id, pred.market, pred.side, ) return None ``` --- ## 3. Fix `_build_reasoning` to Expose PMF Diagnostics and Catch Computation Failures **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_reasoning()` **Why:** `proj_total=?` in every game suggests the `mean_total` computation silently fails. Adding explicit diagnostics will confirm whether the PMF keys are non-numeric (e.g., stored as `"7.0"` vs `"7"` vs int) and make debugging possible. ```python # In _build_reasoning, replace the projected_total block: for pred in predictions: if pred.market in ("moneyline", "ml") and pred.side == "home" and pred.fair_prob is not None: reasoning["home_win_prob"] = round(pred.fair_prob, 4) if pred.market == "total" and pred.side == "over" and pred.distribution: dist = pred.distribution try: numeric_pairs = [] skipped_keys = [] for k, v in dist.items(): try: nk = float(k) numeric_pairs.append((nk, float(v))) except (ValueError, TypeError): skipped_keys.append(k) if skipped_keys: logger.warning( "_build_reasoning game_id=%d: PMF has %d non-numeric keys: %s", game_id, len(skipped_keys), skipped_keys[:5], ) if numeric_pairs: total_mass = sum(v for _, v in numeric_pairs) mean_total = sum(k * v for k, v in numeric_pairs) / total_mass reasoning["projected_total"] = round(mean_total, 2) reasoning["pmf_mass"] = round(total_mass, 4) reasoning["pmf_bucket_count"] = len(numeric_pairs) except Exception as exc: logger.warning( "_build_reasoning game_id=%d: failed to compute mean total: %s", game_id, exc, ) reasoning["projected_total_error"] = str(exc) ``` --- ## 4. Add a Totals-Specific Over-Bias Correction Factor **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison()` **Why:** Even after fixing normalization, the model has demonstrated a systematic over-bias across 60+ games. A simple empirical shrinkage toward 0.5 for totals fair_prob acts as a regularizer while the upstream PMF generation is investigated. ```python # Add this constant near the top of _evaluate.py: _TOTALS_SHRINKAGE = 0.15 # shrink 15% toward 0.5; tune based on rolling calibration def _apply_market_calibration(fair_prob: float, market: str) -> float: """Apply empirical shrinkage corrections for known market biases. Totals are currently over-biased: shrink toward 0.5 until the upstream PMF generation is recalibrated. """ if market in _DISTRIBUTION_MARKETS: # Shrink toward 0.5: corrects for systematic over-prediction of run totals return fair_prob * (1.0 - _TOTALS_SHRINKAGE) + 0.5 * _TOTALS_SHRINKAGE return fair_prob # In _build_comparison, after computing fair_prob and before compute_edge: # Replace: fair_price = prob_to_american(fair_prob) edge = compute_edge(fair_prob, consensus_implied) # With: calibrated_prob = _apply_market_calibration(fair_prob, pred.market) fair_price = prob_to_american(calibrated_prob) edge = compute_edge(calibrated_prob, consensus_implied) # Also update the MarketComparison construction to store the calibrated value: return MarketComparison( ... fair_prob=calibrated_prob, # store calibrated, not raw PMF prob ... ) ``` --- ## 5. Add a Minimum Book Count Guard and a Near-50/50 Moneyline Filter **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison()` **Why:** Games where the consensus is built from a single book are unreliable. Additionally, moneyline
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-06-23_0702_model_review.md
Last 68 games — 2026-06-13 to 2026-06-17
Generated Jun 18, 2026
Analysis run
Last 68 games
ML35-33(51.5%)
ATS33-34(49.3%)
O/U30-33-5(47.6%)
Full season
ML233-216(51.9%)
ATS263-185(58.7%)
O/U224-194-31(53.6%)
Diagnosis
# Diagnosis ## 1. Totals Market is Systematically Miscalibrated (Biggest Issue) The totals market is 30-33 (47.6%), the worst performer. Looking at the wrong predictions, the over is being hammered repeatedly: Games 971, 974, 975, 976, 978, 983, 985, 986, 989, 992, 995, 997, 1005, 1007, 1014, 1016, 1017, 1018, 1020, 1021, 1023, 1026, 1028, 1029, 1032 all have `total/over` as green or yellow with fair_probs ranging from 0.511 to 0.792 — and a significant chunk of these lost (final scores like 3-1, 1-2, 1-3, 2-3, 0-4, 1-2, 2-3, etc.). The model is projecting overs with very high confidence (0.710, 0.769, 0.792) on games that ended 3-1, 1-2, and 2-4. The projected totals are missing entirely (`proj_total=?` in every record), which is a strong signal that `_fair_prob_from_dist` is being called on distributions that may be systematically shifted upward — or the park/weather run-factor integration is over-inflating expected totals. Notably, several high-wind games (17.1 mph, 24.1 mph, 15.3 mph) were flagged as overs but produced low-scoring results; the model appears to be applying a park run factor boost without properly integrating wind direction (into vs. out), which can swing expected runs by 15-20%. ## 2. Runline Away-Plus Predictions Are Consistently Losing (Structural Bias) The runline market is 33-34 (49.2%), but the loss pattern is highly concentrated in `runline/away_plus` predictions flagged **red** (negative edge) yet still appearing in the wrong-prediction log — Games 991, 1001, 1008, 1010, 1011, 1013, 1020, 1027, 1033, 1037 all have `runline/away_plus` with **negative edges** (-0.007 to -0.066). These are being assigned `Verdict.red` correctly by `_verdict`, but the fact that they're appearing in wrong predictions at all suggests they're being bet (or counted) despite the red flag. More structurally: `home_wp` values for these games are 0.55-0.60+, meaning the model likes the home team on the moneyline but simultaneously produces a `away_plus` prediction. The `runline/home_plus` equivalent for the same game (e.g., game 1025, 1027) shows the model is confused about which side has value. The `_MODEL_TO_ODDS` mapping maps **both** `home_plus` and `away_plus` to `spreads/home_runline` and `spreads/away_runline` respectively — but the line-direction filter using `statistics.median` across books can misfire when alternate runlines are present, occasionally passing through the wrong-direction runline prediction. ## 3. Edge Threshold is Too Permissive for Low-Confidence Markets The `_DEFAULT_EDGE_THRESHOLD = 0.03` (3 percentage points) is too low. Looking at losing green predictions: Game 992 total/over has edge=+0.055 and lost (0-4). Game 1017 total/over has edge=+0.053 and lost (2-3). Game 1016 total/over has edge=+0.088 and lost (1-2). Game 994 total/under has edge=+0.016 (yellow, still counted). Many of these have fair_probs in the 0.51-0.58 range — the model is essentially predicting coin flips and calling them value bets because the market happened to price them slightly differently. The moneyline also shows this: Game 975 `moneyline/away` has fair_prob=0.565 and edge=-0.009 (red, correct), but Game 985 `moneyline/home` has fair_prob=0.551 and edge=-0.041 (red) — the home team won anyway. The market is correctly pricing these close games and the model's 3pp edge threshold isn't sufficient to overcome the noise. ## 4. Fair Probability Calibration is Overconfident, Especially on Runline-Minus Games 972, 980, 981, 986, 996, 1022, 1032 all show `runline/home_minus` with fair_probs of 0.523-0.605 and edges of +0.098 to +0.201. Of these, Games 980 (8-7) and 981 (9-8) won by exactly 1 run (losing the -1.5 runline), and Game 996 (23-9) is a massive blowout that won. The runline-minus predictions with fair_prob around 0.52-0.53 (Games 981, 1032, 1028) are barely above the break-even threshold but being labeled green because their edge clears 0.03. When a team wins by exactly 1 run, a home_minus prediction loses despite the moneyline win — this "push zone" around 1-run margins needs probabilistic treatment. The model's `fair_prob` for `home_minus` appears to be derived directly from win probability without properly accounting for the margin distribution. ## 5. Weather Signal Integration is One-Dimensional Multiple wrong predictions occur in high-wind games where the model seemingly applies a run factor boost regardless of wind direction. Game 983 (59.3°F, 17.1 mph → 6-1, over predicted), Game 1029 (89.8°F, 24.1 mph → 6-1, over predicted at 0.636 fair), Game 1018 (68.3°F, 15.3 mph → 5-2, over predicted). Cold + high wind games (Game 983, 59°F/17mph) should strongly suppress scoring. The `park_rf` values in the data are static seasonal numbers that don't incorporate game-day conditions. The `context["weather"]` block in `_build_reasoning` captures weather but there's no evidence it feeds back into the distribution or fair_prob calculation — it's logging-only. --- # Specific Improvement Suggestions ## 1. Raise the edge threshold, with market-specific overrides **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_edge_threshold()` and `_verdict()` The flat 3% threshold is too low. Totals and runlines need higher thresholds because they have more noise. Moneyline can stay lower because the model's win-probability pipeline is more mature. ```python # _evaluate.py _DEFAULT_EDGE_THRESHOLD = 0.03 # Market-specific minimum edge thresholds. # Totals: distribution-based fair_prob has higher variance; require more edge. # Runline: margin-of-victory uncertainty demands more cushion. # Moneyline: keep lower since win-prob pipeline is most mature. _MARKET_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.04, "runline": 0.06, "total": 0.07, # was effectively 0.03; lifted due to 47.6% hit rate "f5_total": 0.07, "nrfi": 0.05, } def _edge_threshold(market: str | None = None) -> float: """Return the edge threshold for a given market. Environment variable EDGE_THRESHOLD_PCT overrides ALL markets when set (preserves existing override behaviour for tests/staging). """ raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass if market is not None: return _MARKET_EDGE_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD def _verdict( edge: float, sharp: bool, rlm: bool, threshold: float, fair_prob: float | None = None, ) -> Verdict: """Assign verdict based on edge, signals, and a minimum fair-prob floor. Added fair_prob floor: predictions where the model's own fair_prob is below 0.53 are capped at yellow regardless of edge, because a ~53% model win-rate converts to roughly break-even after vig at standard prices and the calibration error at that range exceeds the edge signal. """ MIN_FAIR_PROB_FOR_GREEN = 0.53 if edge < 0.0: return Verdict.red if fair_prob is not None and fair_prob < MIN_FAIR_PROB_FOR_GREEN: # Never give green to near-coin-flip predictions return Verdict.yellow if edge >= threshold else Verdict.red if edge >= threshold: return Verdict.green return Verdict.yellow ``` Then update the call sites in `_build_comparison`: ```python # In _build_comparison(), replace: # threshold = _edge_threshold() (passed in from outside) # with a per-market threshold: threshold = _edge_threshold(pred.market) # ← new per-market lookup # ... (keep existing consensus/fair_prob logic) ... verdict = _verdict(edge, sharp, rlm, threshold, fair_prob=fair_prob) # ← pass fair_prob ``` And update `_evaluate` to stop passing the single global threshold: ```python # In _evaluate(), remove: # threshold = _edge_threshold() # and pass None (or remove the param) since _build_comparison now derives it: def _evaluate(game_id: int, session: Session) -> list[MarketComparison]: # ... setup ... # threshold = _edge_threshold() ← DELETE THIS LINE for pred in predictions: comp = _build_comparison( game_id=game_id, model_run_id=model_run.id, pred=pred, odds_snapshots=odds_snapshots, splits_snapshots=splits_snapshots, threshold=None, # _build_comparison now self-selects per market ) ``` And update `_build_comparison`'s signature: ```python def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float | None = None, # None → derive from market ) -> MarketComparison | None: # ... threshold = _edge_threshold(pred.market) if threshold is None else threshold ``` --- ## 2. Add wind-direction awareness to the totals fair-prob calculation **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison()` — specifically the `_DISTRIBUTION_MARKETS` branch Right now the totals `fair_prob` comes purely from the PMF distribution with no in-game weather adjustment. Wind blowing in at >10 mph suppresses scoring; the model is ignoring direction entirely. ```python # Add this helper near the top of _evaluate.py, after imports: def _wind_run_multiplier( wind_mph: float | None, wind_dir_deg: float | None, park_facing_deg: float | None = None, ) -> float: """ Approximate run-environment scalar based on wind speed and direction. park_facing_deg: compass bearing of home plate to center field. If unknown, we can't determine in/out, so return 1.0 (no adjustment). Rule of thumb derived from Statcast research: - Wind blowing OUT (within 45° of CF): +3% per 5 mph above 8 mph - Wind blowing IN (within 45° of opposite): -3% per 5 mph above 8 mph - Crosswind or unknown direction: no adjustment """ if wind_mph is None or wind_mph < 8.0: return 1.0 if wind_dir_deg is None or park_facing_deg is None: # We have speed but not direction — apply a small suppression because # high-wind games in our loss sample skewed low-scoring regardless # of direction (games 983, 1029 etc.) if wind_mph >= 15.0: return 0.94 # ~6% suppression for strong unknown-direction wind return 1.0 # Angular difference between wind direction and CF-facing direction diff = abs((wind_dir_deg - park_facing_deg + 180) % 360 - 180) excess_mph = wind_mph - 8.0 if diff <= 45: # Blowing out toward CF — scoring boost return 1.0 + 0.006 * excess_mph elif diff >= 135: # Blowing in from CF — scoring suppression return 1.0 - 0.006 * excess_mph else: return 1.0 # crosswind def _adjust_distribution_for_weather( distribution: dict[str, float], wind_mph: float | None, wind_dir_deg: float | None, temp_f: float | None, park_facing_deg: float | None = None, ) -> dict[str, float]: """ Shift a run-total PMF to account for wind and temperature. Temperature effect: each 10°F below 72°F reduces expected runs ~2%. Reference: research shows ~1.5-2% per 10°F in the 55-90°F range. """ if not distribution: return distribution wind_mult = _wind_run_multiplier(wind_mph, wind_dir_deg, park_facing_deg) # Temperature scalar temp_mult = 1.0 if temp_f is not None: temp_mult = 1.0 + 0.002 * (temp_f - 72.0) # +0.2% per °F above 72 temp_mult = max(0.85, min(1.15, temp_mult)) # clamp to ±15% total_mult = wind_mult * temp_mult if abs(total_mult - 1.0) < 0.005: return distribution # negligible — skip resampling # Rescale the distribution mean by total_mult using a simple # probability-mass shift: map each bucket k → k * total_mult and # redistribute mass to the nearest integer buckets. adjusted: dict[str, float] = {} for k_str, prob in distribution.items(): try: k = float(k_str) except (ValueError, TypeError): continue new_k = k * total_mult lo = int(new_k) hi = lo + 1 frac = new_k - lo for bucket, weight in [(lo, 1 - frac), (hi, frac)]: key = str(bucket) adjusted[key] = adjusted.get(key, 0.0) + prob * weight # Renormalize total = sum(adjusted.values()) if total > 0: adjusted = {k: v / total for k, v in adjusted.items()} return adjusted ``` Then call the adjuster inside `_build_comparison` before `_fair_prob_from_dist`: ```python # In _build_comparison(), replace the _DISTRIBUTION_MARKETS block: if pred.market in _DISTRIBUTION_MARKETS: if pred.distribution is None or consensus.median_line_value is None: return None # ── NEW: pull weather from the reasoning context if available ────── # We need wind_mph, wind_dir_deg, temp_f. These are available on # the WeatherSnapshot but _build_comparison doesn't have a session. # Pass them in via a lightweight WeatherContext dataclass (see below). # For now, read them from the game's weather via a small helper that # is already called in _build_reasoning; we refactor to share it. weather_ctx =
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-06-18_0702_model_review.md
Last 52 games — 2026-06-09 to 2026-06-12
Generated Jun 13, 2026
Analysis run
Last 52 games
ML31-21(59.6%)
ATS31-21(59.6%)
O/U31-18-3(63.3%)
Full season
ML198-183(52.0%)
ATS230-151(60.4%)
O/U194-161-26(54.6%)
Diagnosis
# Diagnosis ## 1. Totals Market Is Systematically Miscalibrated Upward The totals market shows the most striking pattern in the wrong predictions. Of the 18 wrong total calls, **15 are `over` bets that lost** — games like 919 (3-4=7 runs), 922 (3-2=5), 923 (2-3=5), 924 (7-0=7), 927 (3-7=10 ✓ but wrong game), 939 (0-8=8), 943 (1-2=3), 946 (2-3=5), 949 (0-2=2), 950 (4-2=6), 964 (0-6=6), 967 (3-4=7), 969 (5-1=6). Most of these final scores are **low-run games** (totals of 2–7), yet the model confidently projected `over` with `fair_prob` values of 0.56–0.67. The `proj_total` field is `?` across all games, suggesting the distribution PMF is not being logged into reasoning — this is both a diagnostic gap and a signal that the `_fair_prob_from_dist` path may be producing systematically inflated over probabilities. The model appears to be anchoring on park run factors (most are modestly above 1.0: 1.0067, 1.0083, 1.015, etc.) and warm weather (80–96°F games dominate the wrong overs), likely double-counting environmental boosts that Vegas has already priced in. ## 2. Runline Predictions Show a Home/Away Directional Bias Looking at the runline wrong predictions: Games 921, 930, 933, 935, 948, 950, 959 lost. Breaking these down — **home-side runline losses** include 930 (`home_plus` in a 6-3 home win, which should have covered +1.5), 933 (`home_plus` in an 8-4 home win, which should have covered), 935 (`home_minus` in a 4-5 home loss). Game 935 is particularly telling: `home_wp=0.640` (strong home favorite), yet the home team lost outright. Games 930 and 933 show the model predicting `home_plus` for near-even teams (home_wp ~0.48-0.476) and being wrong despite the home team winning — meaning they won by exactly 1 or less, not covering. This suggests the **runline model doesn't adequately distinguish between "home team wins" probability and "home team wins by 2+" probability**. The `fair_prob` for runline sides is being derived from `pred.fair_prob` directly (the non-distribution path), meaning it comes from whatever the upstream model outputs rather than being derived from the full score distribution — a major calibration gap. ## 3. Moneyline Edge Threshold Is Too Permissive at the Low End Many moneyline losses occur on bets with edges of +0.014 to +0.047: Games 941 (`edge=−0.043`, correctly flagged red but still in the wrong list), 942 (`edge=+0.047`), 945 (`edge=+0.081`), 947 (`edge=+0.116`), 953 (`edge=+0.014`, yellow). The current `_DEFAULT_EDGE_THRESHOLD = 0.03` means any edge above 3pp gets a green verdict. Games 953 and 955 are correctly assigned `yellow` (0.014, 0.016) but the 52-game window shows these borderline greens (0.03–0.06 range) are not converting. Separately, the `_verdict` function **ignores `sharp` and `rlm` signals entirely** — they're computed and stored, but the verdict is purely edge-driven. This means a game with RLM against the model's side still gets `green` if edge ≥ 0.03, which is a significant missed filter. ## 4. Park Factor and Weather Are Likely Over-Weighted for Totals, Under-Weighted for Moneyline The wrong over bets cluster in warm conditions (83–96°F) with modest-positive park factors (1.006–1.025). These are exactly the conditions where sharp books have already adjusted lines upward, meaning the model's Monte Carlo is finding "edge" against a stale or differently-sourced consensus. Game 964 is the clearest outlier: `home_wp=0.722`, `park_rf=0.977` (pitcher's park), `total/over fair_prob=0.669, edge=+0.147` — yet the game went 0-6 (6 total runs, almost certainly an under). A model-confident over in a pitcher's park with a dominant home favorite (who likely had a strong SP) is a red flag the contextual weighting missed. Meanwhile, the `proj_total=?` in all reasoning outputs confirms `projected_total` is never populated — meaning the reasoning layer has no sanity-check on the raw projected run total before it flows into edge computation. ## 5. The `consensus_market` Two-Side Assumption Breaks Under Certain Book Coverage Conditions In `_consensus.py`, when `len(sides_with_prices) < 2`, the code assigns `prob = 0.5` to all available sides. For totals and runlines where one side's odds may be missing from a book's snapshot (e.g., a book only posting the over), this degrades consensus quality silently. More critically, `sides_with_prices[0]` and `sides_with_prices[1]` are pulled from a plain dict iteration — **the order is non-deterministic in edge cases** where defaultdict insertion order could vary between Python runs or data ingestion patterns. This means `devig_two_way` could occasionally receive (away_ml, home_ml) in inconsistent order, producing subtly wrong no-vig probabilities that inflate edge on one side. --- # Specific Improvement Suggestions ## 1. Raise the Default Edge Threshold and Make It Market-Specific **File:** `services/model/src/mlb_model/market/_evaluate.py` **Functions:** `_edge_threshold()`, `_verdict()` **Why:** The 52-game data shows totals winning at 63.3% but with many low-edge green calls losing. Moneyline is at 59.6% with many 3–8pp edge calls failing. A flat 3pp threshold treats a 3pp moneyline edge (worth ~+EV only with very tight lines) identically to a 3pp total edge. Market-specific thresholds will filter the borderline losers. ```python # In _evaluate.py _DEFAULT_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.06, # raised from flat 0.03; low-edge ML bets losing at high rate "runline": 0.05, # moderate raise; runline at 59.6% suggests noise below 5pp "total": 0.05, # totals have most wrong calls on 3-8pp edges "f5_total": 0.05, "nrfi": 0.04, } _DEFAULT_EDGE_THRESHOLD = 0.05 # fallback def _edge_threshold(market: str | None = None) -> float: """Return edge threshold for a specific market, or the global fallback.""" # Environment variable override still works as global override raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass if market is not None: return _DEFAULT_EDGE_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD # Then in _evaluate() loop, pass market to threshold: for pred in predictions: comp = _build_comparison( game_id=game_id, model_run_id=model_run.id, pred=pred, odds_snapshots=odds_snapshots, splits_snapshots=splits_snapshots, threshold=_edge_threshold(pred.market), # <-- market-aware ) ``` --- ## 2. Integrate Sharp and RLM Signals Into Verdict Logic **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict()` **Why:** `sharp` and `rlm` are computed but completely ignored in verdict assignment. Games with sharp money or RLM against the model side should be downgraded or suppressed. From the wrong predictions, several runline and moneyline losses align with conditions where sharp divergence would have been a warning. ```python def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: """ Downgrade verdict when market signals contradict the model edge. Rules (in priority order): 1. Negative edge => always red. 2. Sharp divergence OR reverse line movement against our side: - If edge is below 2× threshold, downgrade green→yellow or yellow→red. - Very high edge (≥ 2× threshold) survives as green (model conviction wins). 3. Normal threshold check. """ if edge < 0.0: return Verdict.red # Both sharp money and RLM moving against our position: strong contra-signal if sharp and rlm: if edge >= threshold * 2: return Verdict.yellow # downgrade from what would be green return Verdict.red # insufficient edge to overcome both signals # Either sharp or RLM alone: softer downgrade if sharp or rlm: if edge >= threshold * 2: return Verdict.green # high-conviction edge survives if edge >= threshold: return Verdict.yellow # standard green → yellow return Verdict.red # No contra-signals: standard threshold if edge >= threshold: return Verdict.green return Verdict.yellow ``` --- ## 3. Fix the Projected Total Missing From Reasoning and Add a Sanity-Check Gate **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_reasoning()` and `_build_comparison()` **Why:** `proj_total=?` in every game record means the `projected_total` is never populated in reasoning. This is because `pred.market == "total" and pred.side == "over"` is checked but `pred.distribution` may be `None` when the model used `pred.fair_prob` directly. Additionally, adding a projected-total sanity check before emitting an over/under comparison will catch cases like Game 964 (dominant pitcher's park, model projects over confidently). ```python # In _build_reasoning(), fix the projected total extraction: for pred in predictions: if pred.market in ("moneyline", "ml") and pred.side == "home" and pred.fair_prob is not None: reasoning["home_win_prob"] = round(pred.fair_prob, 4) if pred.market in ("total", "f5_total"): if pred.distribution: try: mean_total = sum(float(k) * v for k, v in pred.distribution.items()) reasoning["projected_total"] = round(mean_total, 2) except (ValueError, TypeError): pass # Fallback: if fair_prob stored directly, log it so proj_total isn't always ? elif pred.fair_prob is not None: reasoning.setdefault("projected_total_note", f"distribution unavailable for {pred.market}/{pred.side}; " f"fair_prob={pred.fair_prob:.3f} used directly") # In _build_comparison(), add a projected-total plausibility guard: def _build_comparison(...) -> MarketComparison | None: # ... existing code up to fair_prob assignment ... if pred.market in _DISTRIBUTION_MARKETS: if pred.distribution is None or consensus.median_line_value is None: return None fair_prob = _fair_prob_from_dist( pred.distribution, pred.side, consensus.median_line_value ) # --- NEW: sanity check projected mean vs consensus line --- try: projected_mean = sum( float(k) * v for k, v in pred.distribution.items() ) line = consensus.median_line_value # If projected mean is within 0.75 runs of the line, our edge # estimate is highly sensitive to PMF shape noise. Suppress if # the raw edge wouldn't survive a 0.5-run shift. shifted_prob = _fair_prob_from_dist( pred.distribution, pred.side, line + (0.5 if pred.side == "over" else -0.5), ) if abs(projected_mean - line) < 0.75 and abs(fair_prob - shifted_prob) > 0.04: logger.debug( "total sanity check: projected_mean=%.2f line=%.1f " "fair_prob=%.3f shifted_prob=%.3f — suppressing comparison", projected_mean, line, fair_prob, shifted_prob, ) return None except (ValueError, TypeError): pass # --- END sanity check --- else: if pred.fair_prob is None: return None fair_prob = pred.fair_prob ``` --- ## 4. Fix Non-Deterministic Side Ordering in `consensus_market` **File:** `services/model/src/mlb_model/market/_consensus.py` **Function:** `consensus_market()` **Why:** `sides_with_prices[0]` and `[1]` are extracted from a `defaultdict` whose insertion order, while stable per run in Python 3.7+, depends on the order snapshots arrive. If one run receives home snapshots before away and another run receives them in reverse order, `devig_two_way(median_a, median_b)` gets args swapped, silently assigning away's no-vig prob to `side_a` (home). This would inflate apparent edge on whichever side the model happens to predict. ```python def consensus_market(snapshots: list[OddsSnapshot]) -> ConsensusLine: # ... existing dedup and grouping logic unchanged ... sides_with_prices = sorted( # <-- sort for deterministic ordering [s for s in prices_by_side if prices_by_side[s]], key=lambda s: s, # alphabetical: home_ml < away_ml won't ) # matter as long as it's consistent if len(sides_with_prices) < 2: probs = {s: 0.5 for s in sides_with_prices} return ConsensusLine( median_line_value=median_line, side_implied_probs=probs, book_count=book_count, ) # Explicitly name sides so devig result assignment is auditable side_a, side_b = sides_with_prices[0], sides_with_prices[1] median_a = int(round(statistics.median(prices_by_side[side_a]))) median_b = int(round(statistics.median(prices_by_side[side_b]))) prob_a, prob_b = devig_two_way(median_a, median_b) # Log when more than 2 sides exist (e.g., 3-way market ingested incorrectly) if len(sides_with_prices) > 2: logger.warning( "consensus_market: %d sides found (%s), using first two after sort", len(sides_with_prices), sides_with_prices, ) return ConsensusLine( median_line_value=median_line, side_implied_probs={side_a: prob_a, side_b: prob_b}, book_count=book_count, ) ``` --- ## 5. Add a Minimum Book Coverage Guard Before Emitting Any Comparison **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison()` **Why:** If only 1–2 books are represented in `priced_odds`, the consensus price is unreliable (one book's line can dominate the median). Several wrong predictions may result from acting on thin markets
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-06-13_0703_model_review.md
Last 61 games — 2026-06-04 to 2026-06-08
Generated Jun 9, 2026
Analysis run
Last 61 games
ML26-35(42.6%)
ATS34-27(55.7%)
O/U25-31-5(44.6%)
Full season
ML167-162(50.8%)
ATS199-130(60.5%)
O/U163-143-23(53.3%)
Diagnosis
# Diagnosis ## 1. Systematic Calibration Failure on Totals The totals market is the worst performer at 44.6% (25-31), which is *below* the breakeven rate implied by a 3% edge threshold. Looking at the wrong predictions, the model repeatedly fires green on totals/over with high fair_probs (Game 902: 0.842/+0.363, Game 899: 0.708/+0.197, Game 878: 0.730/+0.220) and loses. Meanwhile it loses on totals/under bets with similarly high confidence (Game 904: 0.767/+0.267 on under, actual was 12+4=16 runs). The `proj_total=?` in every game entry is a red flag — the reasoning layer is failing to log projected totals, which likely means `pred.distribution` is either missing or malformed for many games. If `_fair_prob_from_dist` is operating on a sparse or incorrect PMF, fair_probs will be systematically wrong. The over/under asymmetry in losses suggests the PMF mean is miscalibrated relative to the posted line. ## 2. Moneyline Verdicts Are Firing on Noise-Level Edges The moneyline is 26-35 (42.6%), meaning it's actively destroying value. Examining the wrong predictions: Game 856 has `edge=-0.009` but fires `verdict=red` correctly — yet many green moneyline bets (edge=+0.011 to +0.031) are losing. The `_DEFAULT_EDGE_THRESHOLD = 0.03` is far too low for moneylines. A 3pp edge on a ~50/50 bet does not overcome the ~4-5% vig. Games like 899 (edge=+0.011, yellow) and 893 (edge=+0.020, yellow) are essentially coinflips being treated as actionable. The `_verdict` function has no market-specific threshold logic — it applies the same 3% bar to moneylines, runlines, and totals uniformly, which ignores the fundamentally different vig structures and variance profiles of each market. ## 3. Runline Over-Performance May Be Masking a Structural Bias Toward Underdogs The runline at 55.7% is the only above-threshold market, but examining the specific bets reveals a strong lean toward `away_plus` and `home_plus` (underdog +1.5) positions. Games 866, 870, 872, 873, 895, 896, 897 all fire `away_plus` or similar underdog runline bets. The +1.5 line naturally wins more often than 50% for underdogs, which inflates apparent accuracy without necessarily reflecting model skill. The `_build_comparison` function correctly filters by `line_value` to ensure minus/plus alignment, but the model may be systematically outputting `home_plus`/`away_plus` predictions when `home_wp` is below 0.50, essentially picking underdog covers as a default — a strategy that wins often but at poor prices, eroding EV over a larger sample. ## 4. Weather and Park Factors Are Not Modulating Total Predictions The data shows extreme conditions being ignored in totals bets. Game 860 (18.1 mph wind, park_rf=1.04) fires `total/under green`. Game 875 (19.4 mph wind, park_rf=1.147) fires `total/under green` and wins — but this is a high-scoring park with high wind and the model barely differentiated. Game 904 (park_rf=1.147, 94°F, low wind) fires `total/under` at 0.767 fair_prob and gets blown up by a 16-run game. The park_rf of 1.147 is the highest in the dataset and should be a strong over signal, yet the model predicted under confidently. This suggests the Monte Carlo simulation (Layer 5) is not properly incorporating park HR factors into the run-scoring distribution, or the park factor is applied post-hoc in a way that doesn't propagate into `pred.distribution`. ## 5. The Edge Computation Has No Variance or Sample-Size Adjustment `compute_edge` is literally `model_prob - market_prob` — a single number with no confidence interval, no bookmaker count weighting, and no adjustment for how many books contributed to the consensus. Looking at `consensus_market`, when `book_count` is low (1-2 books), the consensus implied prob is unreliable, but `_build_comparison` treats a 2-book consensus identically to a 6-book consensus. This means edges computed against thin markets are given the same weight as deep markets, and a stale or single-book line produces phantom edge. The `ConsensusLine` already carries `book_count` but it's never used downstream. --- # Specific Improvement Suggestions ## 1. Implement Per-Market Edge Thresholds **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict` and `_edge_threshold` The flat 3% threshold is the single biggest mechanical issue. Moneylines require ~6-8pp edge to overcome vig and variance; totals ~4-5pp; runlines ~4pp. ```python # In _evaluate.py, replace the existing _edge_threshold and _verdict with: _MARKET_THRESHOLDS: dict[str, float] = { "moneyline": 0.07, # ~7pp: higher vig, high variance, needs real edge "runline": 0.04, # ~4pp: moderate vig on spread market "total": 0.05, # ~5pp: distribution uncertainty is high "f5_total": 0.05, "nrfi": 0.05, } _DEFAULT_EDGE_THRESHOLD = 0.03 # fallback only def _edge_threshold(market: str | None = None) -> float: """Return edge threshold for a specific market, with env override.""" env_raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if env_raw: try: return float(env_raw) / 100.0 except ValueError: pass if market is not None: return _MARKET_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD def _verdict( edge: float, sharp: bool, rlm: bool, threshold: float, book_count: int = 0, ) -> Verdict: # Require at least 3 books for a green verdict to avoid thin-market artifacts if book_count < 3 and edge < threshold + 0.03: return Verdict.yellow if edge >= threshold: return Verdict.green if edge < 0.0: return Verdict.red return Verdict.yellow ``` Then update `_build_comparison` to pass market-specific threshold and book count: ```python def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, # now ignored in favor of per-market ) -> MarketComparison | None: # ... existing code up to consensus ... market_threshold = _edge_threshold(pred.market) # ... existing fair_prob / edge computation ... verdict = _verdict(edge, sharp, rlm, market_threshold, consensus.book_count) return MarketComparison( # ... existing fields ... verdict=verdict, ) ``` --- ## 2. Add Minimum Book Count Gate and Edge Confidence Scaling **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` Thin markets (1-2 books) produce unreliable consensus implied probs. Add a hard gate. ```python def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, ) -> MarketComparison | None: # ... existing odds_key lookup, market_odds, market_splits ... consensus = consensus_market(market_odds) # NEW: require minimum book coverage for actionable verdicts MIN_BOOKS_FOR_GREEN = 3 if consensus.book_count < 2: logger.debug( "Skipping game_id=%d market=%s side=%s: only %d book(s)", game_id, pred.market, pred.side, consensus.book_count, ) return None # not enough data to trust consensus price if odds_side not in consensus.side_implied_probs: return None # ... rest of existing logic, but pass book_count to _verdict ... market_threshold = _edge_threshold(pred.market) verdict = _verdict(edge, sharp, rlm, market_threshold, consensus.book_count) return MarketComparison( game_id=game_id, model_run_id=model_run_id, market=pred.market, side=pred.side, fair_prob=fair_prob, fair_price_american=fair_price, consensus_price_american=consensus_price, consensus_implied_prob=consensus_implied, edge_pct=edge, sharp_divergence=sharp, reverse_line_movement=rlm, verdict=verdict, ) ``` --- ## 3. Fix the Projected Total Logging (Diagnose the `proj_total=?` Bug) **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_reasoning` Every single game shows `proj_total=?`, meaning the distribution is never being found. The current code only checks `pred.market == "total" and pred.side == "over"` — but if the total prediction is stored as `market="totals"` (plural) or `side="over"` with a different casing, it silently skips. Also add a fallback using `pred.fair_prob` when no distribution exists. ```python def _build_reasoning( game_id: int, model_run_id: int, predictions: list[Prediction], results: list[MarketComparison], session: Session, ) -> dict: from db.models import Game, ParkFactor, WeatherSnapshot reasoning: dict = {} for pred in predictions: market_norm = (pred.market or "").lower().rstrip("s") # "totals"->"total" side_norm = (pred.side or "").lower() if market_norm in ("moneyline", "ml") and side_norm == "home" and pred.fair_prob is not None: reasoning["home_win_prob"] = round(pred.fair_prob, 4) if market_norm == "total" and side_norm == "over": if pred.distribution: try: mean_total = sum( float(k) * v for k, v in pred.distribution.items() ) reasoning["projected_total"] = round(mean_total, 2) # Also log distribution shape metrics for diagnostics reasoning["proj_total_debug"] = { "n_buckets": len(pred.distribution), "sum_prob": round(sum(pred.distribution.values()), 4), } except (ValueError, TypeError) as exc: logger.warning( "game_id=%d: projected_total computation failed: %s", game_id, exc, ) reasoning["projected_total"] = None else: # Distribution missing — log which fields ARE present for debugging logger.warning( "game_id=%d: total/over prediction id=%s has no distribution. " "fair_prob=%s", game_id, getattr(pred, "id", "?"), pred.fair_prob, ) reasoning["projected_total"] = None reasoning["proj_total_debug"] = {"error": "no_distribution"} # ... rest of existing reasoning code unchanged ... ``` --- ## 4. Add Park Factor Suppression for High-Confidence Contrarian Totals **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` (new park factor context injection) Game 904 (park_rf=1.147, 94°F) had a `total/under` at fair_prob=0.767 and got destroyed by a 16-run game. The park factor should modulate the effective edge on totals. Pass park context into the comparison and apply a penalty when model direction contradicts the park factor. ```python # Add a new helper function in _evaluate.py def _park_factor_for_game(game_id: int, session: Session) -> float | None: """Return the runs park factor for the game's park, or None.""" from db.models import Game, ParkFactor game = session.get(Game, game_id) if game is None: return None pf = session.scalar( select(ParkFactor).where( ParkFactor.park_id == game.park_id, ParkFactor.season == game.game_date.year, ) ) return float(pf.runs_factor) if pf is not None else None # Modify _build_comparison signature to accept optional park_factor: def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, park_factor: float | None = None, # NEW ) -> MarketComparison | None: # ... existing logic to compute edge ... # NEW: for totals, penalize edge when model direction contradicts park factor # A park_rf > 1.08 is a strong hitter's park — under bets need extra edge. # A park_rf < 0.96 is a strong pitcher's park — over bets need extra edge. effective_threshold = _edge_threshold(pred.market) if pred.market in _DISTRIBUTION_MARKETS and park_factor is not None: HITTERS_PARK_RF = 1.08 PITCHERS_PARK_RF = 0.96 PARK_PENALTY = 0.03 # require 3pp additional edge when swimming upstream if pred.side == "under" and park_factor > HITTERS_PARK_RF: effective_threshold += PARK_PENALTY logger.debug( "game_id=%d: under in hitter's park (rf=%.3f), " "threshold raised to %.2f", game_id, park_factor, effective_threshold, ) elif pred.side == "over" and park_factor < PITCHERS_PARK_RF: effective_threshold += PARK_PENALTY logger.debug( "game_id=%d: over in pitcher's park (rf=%.3f), " "threshold raised to %.2f", game_id, park_factor, effective_threshold, ) verdict = _verdict(edge, sharp, rlm, effective_threshold, consensus.book_count) # ... return MarketComparison as before ... ``` Then update `_evaluate` to fetch park factor once and pass it down: ```python def _evaluate(game_id: int, session: Session) -> list[MarketComparison]: model_run = get_latest_model_run_for_game(session, game_id) if model_run is None: logger.warning("evaluate_game: no model_run for game_id=%d", game_id) return [] predictions = get_predictions(session, model_run.id) odds_snapshots = get_odds_snapshots_for_game(session, game_id) splits_snapshots = get_latest_splits_for_game(session, game_id) park_factor = _park_factor_for_game(game_id, session
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-06-09_0702_model_review.md
Last 40 games — 2026-05-31 to 2026-06-03
Generated Jun 4, 2026
Analysis run
Last 40 games
ML18-22(45.0%)
ATS25-15(62.5%)
O/U22-15-3(59.5%)
Full season
ML141-127(52.6%)
ATS165-103(61.6%)
O/U138-112-18(55.2%)
Diagnosis
# Diagnosis ## 1. Moneyline Calibration Is the Core Problem The moneyline market at 45% (18-22) is the primary drag on overall performance. Looking at the wrong predictions, the losses cluster in two distinct failure modes. First, there are high-confidence green-verdict losses where the model has strong conviction but is simply wrong: Game 816 (home_wp=0.675, edge=+0.086, green) lost 10-9, Game 825 (home_wp=0.700, edge=+0.135, green) lost 8-0 to the *away* team, Game 838 (home_wp=0.729, edge=+0.249, green) won only 6-5. Second, there are red-verdict games the model bet on correctly by avoiding (Game 829, 837, 846) but the model fired on comparable games and lost. The moneyline losses are not concentrated in low-edge yellow bets — several green-verdict moneyline picks lost outright, which suggests the `fair_prob` values from the upstream layers are systematically overconfident, particularly for home favorites in the 0.60–0.75 range where the model is most active. ## 2. Totals Is Severely Miscalibrated on Overs The totals market at 59.5% overall is borderline, but the wrong predictions reveal a specific directional problem: **over bets are losing at a high rate**. Games 821 (actual=2-1, over bet green at 0.717), 825 (over bet green lost), 827 (over bet green, actual=4-2), 830 (over bet green at 0.821, actual=3-4), 840 (over bet green, actual=4-1), 842 (over bet green, actual=8-0 — this one won), and 849 (over bet green at 0.793, actual=1-0) show a striking pattern: the model assigns very high fair probabilities to overs (0.717–0.821) in games that end up as low-scoring affairs. Games 821 and 849 are particularly damning: fair_prob=0.717 and 0.793 respectively on the over, yet final scores were 2-1 and 1-0. This is a massive calibration failure. The `_fair_prob_from_dist` function pulls from a distribution PMF, meaning the Monte Carlo layer is generating run distributions that are too right-skewed (fat upper tails), likely because the weather/park suppression factors are underweighted when temperatures are moderate and wind speeds are low-to-moderate. ## 3. The `_verdict` Function Ignores Sharp/RLM Signals Entirely Looking at `_verdict`: ```python def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: if edge >= threshold: return Verdict.green if edge < 0.0: return Verdict.red return Verdict.yellow ``` The `sharp` and `rlm` parameters are accepted but **completely ignored**. The function is purely edge-threshold driven. This means that when sharp money is moving against the model's position (RLM = True on the popular side) or handle diverges from bets (sharp divergence), the model still emits a green verdict if edge ≥ 0.03. This is a structural bug, not a tuning issue. Sharp money signals are historically among the strongest contrarian indicators, and discarding them entirely explains some of the high-confidence losses. ## 4. The 3% Edge Threshold Is Too Low and Undifferentiated The default `_DEFAULT_EDGE_THRESHOLD = 0.03` (3 percentage points) treats a 3.1pp edge the same as a 40pp edge for verdict purposes — both are green. But the wrong predictions include many losses with moderate edges (Game 816: edge=+0.086, Game 825: edge=+0.135, Game 844: edge=+0.190, Game 820: edge=+0.402) alongside wins at similar edge levels. What's absent is any market-specific threshold calibration: moneyline markets have much tighter vig and more efficient pricing than runline or totals, so a 3% edge on a moneyline means less than 3% on a runline. The uniform threshold inflates green verdicts on moneylines where the model's edge is most likely to be noise rather than signal. ## 5. Yellow-Verdict Bets Are Being Included in Win Rate Tracking Games 815, 817, 818, 823, 848, and 853 all appear in the "wrong predictions" list with yellow verdicts (edge between 0 and threshold). If yellow bets are being counted in the win rate denominators, they are diluting the signal from green bets and making the moneyline market look worse than it is for high-conviction plays. The yellow-verdict moneyline losses (Game 817: fair_prob=0.574, edge=+0.012; Game 818: fair_prob=0.562, edge=+0.019) represent exactly the marginal bets that should be excluded from grading since the model explicitly flagged low confidence. --- # Specific Improvement Suggestions ## 1. Fix `_verdict` to Actually Use Sharp and RLM Signals **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict` The sharp divergence and RLM flags should downgrade verdicts. A green bet facing sharp counter-action should become yellow; a yellow bet facing sharp counter-action should become red. This is the single highest-leverage fix because it gates high-confidence bets against the strongest market signal available. ```python def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: """Compute verdict incorporating edge threshold and sharp-money signals. Degradation rules: - sharp=True or rlm=True each downgrade one level (green→yellow, yellow→red) - Both firing simultaneously forces red regardless of edge """ # Base verdict from edge if edge >= threshold: base = Verdict.green elif edge < 0.0: base = Verdict.red else: base = Verdict.yellow # Count active adverse signals adverse = (1 if sharp else 0) + (1 if rlm else 0) if adverse == 0: return base # Both signals firing: always red if adverse >= 2: return Verdict.red # Single signal: downgrade one level if base == Verdict.green: return Verdict.yellow if base == Verdict.yellow: return Verdict.red # base is already red return Verdict.red ``` --- ## 2. Introduce Per-Market Edge Thresholds **File:** `services/model/src/mlb_model/market/_evaluate.py` **Functions:** `_edge_threshold` (replace), `_build_comparison` (update call site) Moneyline markets at major books are priced to within 1-2% of true probability; a 3% model edge is within the noise band. Totals and runlines have wider vig and more model-exploitable structure. Use higher thresholds for moneyline and lower for runline/totals to reflect market efficiency differences. ```python # Per-market edge thresholds (in probability units, not percentage points) _MARKET_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.06, # ML is efficient; require 6pp edge "runline": 0.04, # Spread markets slightly less efficient "total": 0.04, # Totals similar to runline "f5_total": 0.04, "nrfi": 0.05, } _DEFAULT_EDGE_THRESHOLD = 0.03 # fallback only def _edge_threshold(market: str | None = None) -> float: """Return edge threshold for a given market, respecting env override. The env var EDGE_THRESHOLD_PCT still overrides everything when set, preserving backward compatibility for existing deployments. """ raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass if market is not None: return _MARKET_EDGE_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD ``` Then update `_build_comparison` to pass the market: ```python def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, # now ignored in favor of per-market threshold ) -> MarketComparison | None: # ... existing lookup code unchanged ... # Use per-market threshold instead of global effective_threshold = _edge_threshold(pred.market) # ... existing consensus/fair_prob/edge computation unchanged ... verdict = _verdict(edge, sharp, rlm, effective_threshold) # changed ``` And update the call in `_evaluate` to pass `threshold=0` (it's now ignored internally): ```python for pred in predictions: comp = _build_comparison( game_id=game_id, model_run_id=model_run.id, pred=pred, odds_snapshots=odds_snapshots, splits_snapshots=splits_snapshots, threshold=0, # per-market threshold used internally ) ``` --- ## 3. Add Distribution Sanity Check to Catch Over-Inflated Fair Probabilities **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` Games 821 (fair_prob=0.717 over, actual 2-1) and 849 (fair_prob=0.793 over, actual 1-0) represent catastrophic calibration failures where the PMF is heavily weighted toward high run totals but the game ended in a pitcher's duel. Add a guard that clamps extreme totals probabilities and logs a warning so upstream distribution bugs surface visibly rather than silently producing bad bets. ```python # Add this constant near the top of _evaluate.py _TOTALS_PROB_CLAMP = 0.80 # fair probs above this for totals are almost certainly # distribution artifacts, not genuine edges def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, ) -> MarketComparison | None: # ... existing code up to fair_prob calculation ... if pred.market in _DISTRIBUTION_MARKETS: if pred.distribution is None or consensus.median_line_value is None: return None fair_prob = _fair_prob_from_dist( pred.distribution, pred.side, consensus.median_line_value ) # Sanity check: probabilities above the clamp threshold suggest # the Monte Carlo distribution has a fat tail artifact. # Cap and emit a warning so the upstream layer can be debugged. if fair_prob > _TOTALS_PROB_CLAMP: logger.warning( "game_id=%d market=%s side=%s fair_prob=%.3f exceeds clamp=%.2f " "(line=%.1f); clamping. Check MC distribution.", game_id, pred.market, pred.side, fair_prob, _TOTALS_PROB_CLAMP, consensus.median_line_value, ) fair_prob = _TOTALS_PROB_CLAMP else: if pred.fair_prob is None: return None fair_prob = pred.fair_prob # ... rest of function unchanged ... ``` --- ## 4. Separate Yellow-Verdict Bets from Win Rate Grading **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_reasoning` Yellow bets (edge between 0 and threshold) are explicitly flagged as low-confidence. Including them in the same win-rate pool as green bets masks the true performance of high-conviction plays and can trigger false reviews of otherwise healthy markets. Add a verdict breakdown to the reasoning output so the grading layer can split win rates by verdict tier. ```python def _build_reasoning( game_id: int, model_run_id: int, predictions: list[Prediction], results: list[MarketComparison], session: Session, ) -> dict: from db.models import Game, ParkFactor, WeatherSnapshot reasoning: dict = {} for pred in predictions: if pred.market in ("moneyline", "ml") and pred.side == "home" and pred.fair_prob is not None: reasoning["home_win_prob"] = round(pred.fair_prob, 4) if pred.market == "total" and pred.side == "over" and pred.distribution: try: mean_total = sum(float(k) * v for k, v in pred.distribution.items()) reasoning["projected_total"] = round(mean_total, 2) except (ValueError, TypeError): pass reasoning["edges"] = [ { "market": c.market, "side": c.side, "edge_pct": round(c.edge_pct, 4), "verdict": c.verdict.value, "sharp": c.sharp_divergence, "rlm": c.reverse_line_movement, } for c in sorted(results, key=lambda c: abs(c.edge_pct), reverse=True) ] # NEW: verdict-stratified summary for downstream win-rate tracking # This allows the grading layer to compute green-only win rates separately # from yellow bets, preventing low-confidence plays from diluting metrics. verdict_summary: dict[str, list[dict]] = {"green": [], "yellow": [], "red": []} for c in results: entry = {"market": c.market, "side": c.side, "edge_pct": round(c.edge_pct, 4)} verdict_summary[c.verdict.value].append(entry) reasoning["verdict_summary"] = verdict_summary reasoning["signals"] = { "sharp_markets": [c.market for c in results if c.sharp_divergence], "rlm_markets": [c.market for c in results if c.reverse_line_movement], } context: dict = {} game = session.get(Game, game_id) if game is not None: pf = session.scalar( select(ParkFactor).where( ParkFactor.park_id == game.park_id, ParkFactor.season == game.game_date.year, ) ) if pf is not None: context["park_runs_factor"] = pf.runs_factor context["park_hr_factor"] = pf.hr_factor weather = session.scalar( select(WeatherSnapshot) .where(WeatherSnapshot.game_id == game_id) .order_by(WeatherSnapshot.captured_at.desc()) .limit(1) ) if weather is not None: context["weather"] = { "temp_f": weather.temp_f, "wind_mph": weather.wind_mph, "wind_dir_deg": weather.wind_dir_deg, } if context: reasoning["context"] = context return reasoning ``` --- ## 5. Fix `_fair_prob_from_dist` to Use Strict Inequality Consistently **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_fair_prob_from_dist` The current under probability includes `_n <= line` (i.e., exactly hitting the total counts as under). In MLB totals markets the standard convention is **over wins on strictly greater than, under wins on strictly less than, and exactly hitting the total is a push**. Including the push outcome in the under probability inflates under fair_prob and deflates over fair_prob by the probability mass on the exact line value — which for a PMF with integer keys and a
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-06-04_0703_model_review.md
Last 50 games — 2026-05-28 to 2026-05-31
Generated Jun 1, 2026
Analysis run
Last 50 games
ML29-21(58.0%)
ATS34-16(68.0%)
O/U28-18-4(60.9%)
Full season
ML123-105(53.9%)
ATS140-88(61.4%)
O/U116-97-15(54.5%)
Diagnosis
# Diagnosis and Improvement Recommendations ## Diagnosis ### 1. Systematic Totals Miscalibration (Most Impactful Issue) The totals market is the clearest failure mode in the wrong predictions list. Looking at the losses: Game 775 (actual 3-4, model predicted over with edge=+0.207, fair_prob=0.699), Game 799 (actual 1-5, model predicted over with edge=+0.226, fair_prob=0.705), Game 802 (actual 2-4, model predicted over with edge=+0.137), Game 805 (actual 2-5, model predicted over with edge=+0.323, fair_prob=0.812), Game 808 (actual 1-2, model predicted over with edge=+0.062), Game 809 (actual 2-0, model predicted over with edge=+0.242, fair_prob=0.742), and Game 813 (actual 2-3, model predicted over). That is 7 wrong over predictions. Conversely, the wrong under predictions (Games 782, 783, 811) involve high-scoring actual games (6-8, 8-2, 19-6). The model is consistently over-projecting run scoring. The `_fair_prob_from_dist` function computes over probability as `sum(v for k if k > line)` — this is mathematically correct, but the upstream PMF (from Monte Carlo) is clearly shifted right relative to realized outcomes. The totals win rate of 60.9% is close to the review threshold, and the wrong-prediction list is dominated by totals misses despite high stated edges, which is a hallmark of distribution bias rather than edge miscalibration. ### 2. High-Edge Green Verdicts Are Failing at an Alarming Rate Several "green" picks with large edges are in the wrong predictions list: Game 772 moneyline/home (edge=+0.201, home won 7-5 ✓ — actually this is a WIN, not a loss... let me recount). Re-examining: the provided games are *wrong* predictions. Game 775 runline/home_minus (edge=+0.190, green) lost — home was 3-4 (lost). Game 805 total/over (edge=+0.323, green) lost — actual 2-5. Game 811 total/under (edge=+0.145, green) lost — actual 19-6. Game 813 runline/home_minus (edge=+0.144, green) lost — actual 2-3 (away won). Game 809 total/over (edge=+0.242, green) lost — actual 2-0. The pattern is that the model assigns high confidence (green, large edge) to positions that then lose badly. This suggests the edge computation itself is sound mechanically but the `fair_prob` inputs are wrong — specifically, `compute_edge` is just `model_prob - market_prob`, so inflated `fair_prob` values directly inflate edge with no dampening mechanism. There is no uncertainty band or confidence interval on the PMF-derived probabilities. ### 3. Runline Direction Filter Is Masking a Real Problem with Run-Differential Modeling The wrong runline predictions show a split: home_minus losses (Games 775, 813) where the home team actually lost outright, and away_plus losses (Games 780, 793, 795) where the away team lost by more than 1.5. Game 780 (actual 1-9, model predicted away_plus, edge=-0.015, red verdict — this was flagged red and still included as a wrong prediction, indicating the red threshold isn't preventing action). Game 793 (actual 1-6, runline/away_plus, edge=-0.080, red) also got through. The `_verdict` function returns `Verdict.red` for negative edge but the system still records and apparently acts on red verdicts. There's no hard block on negative-edge predictions reaching downstream consumers. ### 4. Park Factor and Weather Context Are Computed But Not Feeding Back Into Edge Thresholds Looking at the contextual data: Game 780 has park_rf=0.9467 (pitcher-friendly) and 90.6°F wind=6.7mph — a hot day that typically increases scoring, yet the model predicted away_plus (suggesting a blowout) and it actually was a blowout (1-9) but in the wrong direction. Game 782 has park_rf=1.1467 (extreme hitter's park) and predicted total/under (lost, actual 6-8). Game 811 has park_rf=1.1467 and predicted total/under (lost catastrophically, actual 19-6). The model captures park_rf in reasoning but the `_verdict` function signature accepts `sharp` and `rlm` but **never uses them** — both signals are computed and stored but have zero effect on the verdict. This is dead code that represents wasted signal. ### 5. Moneyline Calibration for Mid-Range Probabilities (0.52–0.62) Is Poor Multiple moneyline wrong predictions cluster in the 0.52–0.62 fair_prob band: Game 768 (0.530, red, lost — correct direction), Game 769 (0.563, red), Game 770 (0.601, yellow, lost), Game 792 (0.609, yellow, lost). The model's moneyline win rate of 58% is decent but the losses are concentrated in games where the model had low-to-medium conviction. The issue is that `fair_prob` values in the 0.52–0.62 range for moneyline likely reflect genuine uncertainty that the model is not adequately representing — these games are close to coin flips but the model treats a 0.563 the same way structurally as a 0.716, just with a smaller edge. --- ## Specific Improvement Suggestions ### 1. Fix `_verdict` to Actually Use Sharp and RLM Signals **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict` The `sharp` and `rlm` parameters are accepted but completely ignored. Sharp money disagreeing with the model should downgrade a green to yellow, and RLM against the model's side should further penalize the verdict. This is the single-line fix with the highest signal-to-noise ratio. ```python def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: """Compute verdict incorporating sharp-money and line-movement signals. Degradation rules (applied after edge baseline): - Sharp divergence against model side: downgrade green→yellow - Reverse line movement against model side: downgrade green→yellow, yellow→red - Both signals present: cap at red regardless of edge """ if edge < 0.0: return Verdict.red # Base verdict from edge alone if edge >= threshold: base = Verdict.green else: base = Verdict.yellow # Each adverse signal downgrades one level # green → yellow → red _order = [Verdict.red, Verdict.yellow, Verdict.green] level = _order.index(base) if sharp: level = max(0, level - 1) # downgrade if rlm: level = max(0, level - 1) # downgrade return _order[level] ``` --- ### 2. Add a Hard Block on Negative-Edge Predictions (Red Verdicts Should Not Persist as Actionable) **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_evaluate` Games 780 and 793 both had red verdicts (negative edge) but were still wrong predictions that presumably were acted on. The `add_market_comparison` call should either be skipped for red verdicts or the verdict should be stored with an explicit `is_actionable=False` flag. Since modifying the DB schema is heavier, the minimal fix is to log a hard warning and skip persistence for red verdicts, or to gate on a minimum edge floor: ```python # In _evaluate(), replace the add_market_comparison call block: for pred in predictions: comp = _build_comparison( game_id=game_id, model_run_id=model_run.id, pred=pred, odds_snapshots=odds_snapshots, splits_snapshots=splits_snapshots, threshold=threshold, ) if comp is None: continue # Do not persist red-verdict comparisons as actionable picks. # They are still appended to results for reasoning/audit purposes # but are flagged so downstream consumers can filter them. if comp.verdict == Verdict.red: logger.info( "evaluate_game: skipping persistence for red verdict " "game_id=%d market=%s side=%s edge=%.4f", game_id, comp.market, comp.side, comp.edge_pct, ) results.append(comp) # keep for reasoning audit trail continue add_market_comparison( session=session, game_id=comp.game_id, model_run_id=comp.model_run_id, market=comp.market, side=comp.side, fair_prob=comp.fair_prob, fair_price_american=comp.fair_price_american, consensus_price_american=comp.consensus_price_american, consensus_implied_prob=comp.consensus_implied_prob, edge_pct=comp.edge_pct, sharp_divergence=comp.sharp_divergence, reverse_line_movement=comp.reverse_line_movement, verdict=comp.verdict.value, evaluated_at=now, ) results.append(comp) ``` --- ### 3. Apply Park Factor as an Edge Threshold Modifier for Totals **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` Games 782 and 811 both have `park_rf=1.1467` (the same extreme hitter's park — almost certainly Coors Field) and both predicted under. The model lost badly (actual 6-8 and 19-6). When the park strongly inflates runs, the PMF's over/under boundary becomes less reliable because the distribution tails are fatter. The fix is to widen the threshold in proportion to how far `park_rf` deviates from 1.0, specifically for totals markets: ```python def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, ) -> MarketComparison | None: # ... (existing code unchanged through consensus / fair_prob computation) ... fair_price = prob_to_american(fair_prob) edge = compute_edge(fair_prob, consensus_implied) # --- NEW: park-adjusted threshold for totals markets --- effective_threshold = threshold if pred.market in _DISTRIBUTION_MARKETS: from db.models import Game, ParkFactor from sqlalchemy import select # We need the session here; thread it in or look it up from snapshots. # Simplest approach: encode park_rf in the snapshot query upstream, # but as a self-contained patch we retrieve it from the odds context. # Instead, accept park_rf as an optional parameter (see call-site change below). pass # see parameterized version below # ... ``` Because `_build_comparison` doesn't currently have DB access, the cleaner approach is to pass `park_rf` in from `_evaluate` where the session is available: ```python # In _evaluate(), resolve park_rf once per game before the loop: from db.models import Game, ParkFactor from sqlalchemy import select park_rf: float = 1.0 game = session.get(Game, game_id) if game is not None: pf = session.scalar( select(ParkFactor).where( ParkFactor.park_id == game.park_id, ParkFactor.season == game.game_date.year, ) ) if pf is not None: park_rf = float(pf.runs_factor) for pred in predictions: comp = _build_comparison( game_id=game_id, model_run_id=model_run.id, pred=pred, odds_snapshots=odds_snapshots, splits_snapshots=splits_snapshots, threshold=threshold, park_rf=park_rf, # NEW ) ``` ```python # Updated _build_comparison signature and threshold logic: def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, park_rf: float = 1.0, # NEW ) -> MarketComparison | None: # ... existing code unchanged until verdict computation ... edge = compute_edge(fair_prob, consensus_implied) # For totals, widen the required edge threshold when park_rf deviates # substantially from neutral (1.0). Each 0.05 deviation adds 1pp to # the threshold, capped at 2x the base threshold. # Rationale: extreme parks make the PMF tails less reliable; we need # more model conviction before betting totals at Coors-type venues. effective_threshold = threshold if pred.market in _DISTRIBUTION_MARKETS: park_deviation = abs(park_rf - 1.0) # e.g. park_rf=1.1467 → deviation=0.1467 → +2.93pp added to threshold extra = (park_deviation / 0.05) * 0.01 effective_threshold = min(threshold + extra, threshold * 2.0) sharp = detect_sharp_divergence(market_splits) rlm = detect_reverse_line_movement(market_odds, market_splits) verdict = _verdict(edge, sharp, rlm, effective_threshold) # pass effective_threshold return MarketComparison( # ... existing fields ... verdict=verdict, ) ``` --- ### 4. Recalibrate the Totals PMF with a Shrinkage Correction for Systematic Over-Bias **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_fair_prob_from_dist` The wrong-predictions list has 7 failed over bets and only 3 failed under bets — a 7:3 ratio suggesting the PMF is consistently shifted ~0.3–0.5 runs high. Rather than retraining the Monte Carlo (which is upstream), apply a calibration shrinkage to the derived fair probability for overs that pulls it toward 0.5 when the raw probability is high: ```python # Calibration constant derived from observed over hit rate in this sample: # Overs in wrong list: 7 losses. We need win rate data for overs overall, # but given totals overall is 60.9% and overs appear over-represented in # losses, we apply a conservative 4pp shrinkage toward 0.5 for overs. _OVER_SHRINKAGE = 0.04 # tune with more data; start conservative _UNDER_SHRINKAGE = 0.00 # unders appear better calibrated def _fair_prob_from_dist( distribution: dict[str, float], side: str, line: float ) -> float: """Derive fair probability for a totals market from a stored PMF. Applies a shrinkage correction toward 0.5 to account for observed systematic over-bias in the Monte Carlo run-scoring distribution. """ def _numeric(k: str) -> float | None: try: return float(k) except (ValueError, TypeError): return None if side == "over": raw = sum( v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n > line ) # Shrink toward 0.5: reduces over-confidence from inflated PMF return raw - _OVER_SHRINKAGE * (raw - 0.5) # under raw = sum( v for k, v in distribution.items() if (_n :=
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-06-01_0702_model_review.md
Last 43 games — 2026-05-25 to 2026-05-27
Generated May 28, 2026
Analysis run
Last 43 games
ML22-21(51.2%)
ATS21-22(48.8%)
O/U23-19-1(54.8%)
Full season
ML94-84(52.8%)
ATS106-72(59.6%)
O/U88-79-11(52.7%)
Diagnosis
# Diagnosis ## 1. Totals Market is the Strongest Signal, But Still Underperforming The totals market shows the best win rate (54.8%) but is still well below the 60% flag threshold. Looking at the wrong predictions, there's a clear systematic bias: the model repeatedly issues **over** picks that lose on low-scoring games. Games 750, 751, 752, 754, 760, 763, and 764 all had `total/over` green verdicts with relatively high fair_probs (0.594–0.742) yet produced final scores of 1-2, 2-3, 1-2, 3-2, 2-4, 4-3, and 1-4 respectively. These are all sub-7 total run games. The distribution-based fair_prob calculation in `_fair_prob_from_dist` sums PMF mass above the line, but if the underlying Monte Carlo run total distribution is systematically right-skewed (fat tails toward high scores), the over probability will be inflated even when the median projection is modest. The park_rf values available (0.947–1.025) show neutral-to-pitcher-friendly parks in several of these cases, which the model may be insufficiently weighting when building the run distribution. ## 2. Runline Model Has Serious Directional Confusion and Calibration Problems The runline win rate of 48.8% is below breakeven. The wrong predictions show two failure modes. First, **home_plus** bets losing badly: Game 725 (actual 1-5, home lost by 4), Game 730 (actual 3-0, home won outright but this was `home_plus` which won — wait, 3-0 home win covers +1.5), Game 741 (actual 7-2, home won big but this was picked as `home_plus`). Actually the most damning cases are Game 733 (away_plus green, actual 3-5 away won outright — this is a *win* for away_plus), so some "wrong" predictions in the list may be wins. The deeper issue is that several high-confidence runline picks (edge >0.15) are losing, particularly `home_minus` bets in Games 727 and 750 where the margin was narrow or wrong direction. Second, the `_build_comparison` runline filter logic gates on `median_lv` matching ±0.1 of expected ±1.5, but this doesn't account for alternate lines (e.g., -1.5 vs -2.5 markets) being mixed into the snapshot pool, potentially corrupting the consensus implied probability. ## 3. Edge Threshold is Too Permissive and Verdict Logic Ignores Sharp/RLM Signals The `_verdict` function computes sharp divergence and RLM signals but **completely ignores them** in the verdict output — they're logged to reasoning but don't affect whether a bet is green/yellow/red. This is a significant bug: the signals are computed but wired to nothing. Meanwhile, the 3% edge threshold is very low for a noisy domain like MLB. Looking at the wrong green predictions: Game 724 (edge=+0.031, +0.063), Game 727 (edge=+0.053, +0.112 — these actually won), Game 728 (edge=+0.060 — won). But Game 750 has edge=+0.091 and +0.094 for both runline_home_minus and total/over, both of which lost. Game 762 has edge=+0.043 and +0.051, both lost. The model is generating green verdicts at 3-6% edge on markets where the true calibration error could easily exceed that range, meaning many "green" picks have zero or negative true edge after accounting for model error. ## 4. High home_wp Predictions Are Not Translating to Wins Games 729 (home_wp=0.5006, home won 10-2 — model picked away, lost), 730 (home_wp=0.3201, home won 3-0 — model had home_plus red verdict), 731 (home_wp=0.6153, home won 9-0, model correctly picked home), 732 (home_wp=0.7123, home won 8-2, correct). But Game 733 (home_wp=0.6292, home lost 3-5) had a `runline/away_plus` green pick that won. Game 744 (home_wp=0.6015, home lost 0-6) had `runline/away_plus` green that won. The moneyline calibration appears reasonable at extreme probabilities (>0.71 wins tend to be correct) but breaks down in the 0.50–0.62 range where the model shows a slight home bias — it underpredicts away wins in that band. This is consistent with a well-known MLB modeling issue where home field advantage is over-parameterized. ## 5. Missing Contextual Data is a Silent Risk The wrong predictions list shows `proj_total=?`, `park_rf=?`, and `wind=?mph` as missing for most games — only Games 750, 753–758, 760, 762–764 have park_rf populated. This means `_build_reasoning` is silently omitting park and weather context for the majority of evaluations because the DB lookups return `None`. If park factors and weather are missing at reasoning time, they may also be missing or stale at prediction time (Layer 5), meaning the Monte Carlo simulation is running without proper environmental adjustments for those games. This would explain the systematic over bias — without a pitcher-friendly park factor dampening the run distribution, totals get overestimated. --- # Specific Improvement Suggestions ## 1. Wire Sharp/RLM Signals into Verdict Logic **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict` The signals are computed but ignored. Sharp money against your model's side is strong evidence of model error. RLM indicates the market has information your model lacks. ```python # CURRENT CODE: def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: if edge >= threshold: return Verdict.green if edge < 0.0: return Verdict.red return Verdict.yellow # PROPOSED CODE: def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: """Incorporate sharp money and RLM as veto signals. Even if edge >= threshold, downgrade to yellow when sharp divergence or RLM is detected. These signals empirically indicate market information the model does not have. Require a higher edge (1.5x threshold) to remain green when either signal fires. """ has_adverse_signal = sharp or rlm effective_threshold = threshold * 1.5 if has_adverse_signal else threshold if edge >= effective_threshold: return Verdict.green if edge < 0.0: return Verdict.red return Verdict.yellow ``` **Why:** The `_build_reasoning` output already stores these signals. Right now they're pure dead weight in the verdict path. Based on the data, several losing picks (Games 750, 760, 762) sit right at the 3-9% edge range where a sharp divergence veto would have suppressed the green signal. --- ## 2. Raise the Default Edge Threshold and Make Market-Specific Thresholds Available **File:** `services/model/src/mlb_model/market/_evaluate.py` **Functions:** `_edge_threshold`, `_build_comparison` A flat 3% threshold is too low given the noise level. The runline market at 48.8% win rate needs a higher bar. Totals at 54.8% are the most reliable but still below 60%. ```python # CURRENT CODE: _DEFAULT_EDGE_THRESHOLD = 0.03 def _edge_threshold() -> float: raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass return _DEFAULT_EDGE_THRESHOLD # PROPOSED CODE: _DEFAULT_EDGE_THRESHOLD = 0.05 # raised from 0.03 # Per-market minimums derived from observed calibration quality. # Runline is worst-performing, needs highest bar. # Totals are best-performing, slightly lower bar acceptable. _MARKET_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.05, "runline": 0.08, # penalize underperforming market "total": 0.05, "f5_total": 0.06, "nrfi": 0.06, } def _edge_threshold(market: str | None = None) -> float: """Return edge threshold for a specific market, falling back to env/default.""" raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass if market is not None: return _MARKET_EDGE_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD # In _build_comparison, pass market to threshold: def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, # kept as parameter but overridden per market below ) -> MarketComparison | None: # ... existing code up to verdict call ... # Override threshold per market for finer-grained control market_threshold = _edge_threshold(pred.market) verdict = _verdict(edge, sharp, rlm, market_threshold) # rest of function unchanged ``` **And update the call site in `_evaluate`:** ```python # In _evaluate, the threshold passed to _build_comparison becomes a fallback: threshold = _edge_threshold() # global fallback, still used as param for pred in predictions: comp = _build_comparison( game_id=game_id, model_run_id=model_run.id, pred=pred, odds_snapshots=odds_snapshots, splits_snapshots=splits_snapshots, threshold=threshold, # _build_comparison now overrides this per market ) ``` **Why:** Looking at the runline losses: Game 725 edge=+0.155 (lost), Game 733 edge=+0.118 (won), Game 740 edge=+0.146 (won), Game 741 edge=+0.014 (lost). The low-edge runline picks (0.014, 0.043, 0.047) are noise. At 8% runline threshold, Games 741 and 734's runline picks would have been suppressed. The 21-22 runline record suggests the signal exists but is being diluted by marginal picks. --- ## 3. Fix Totals Over-Bias by Adding a PMF Sanity Check and Skew Penalty **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_fair_prob_from_dist` The current implementation sums PMF mass above/below the line but doesn't check whether the distribution is physically reasonable or apply any correction for right-skew inflation. ```python # CURRENT CODE: def _fair_prob_from_dist( distribution: dict[str, float], side: str, line: float ) -> float: """Derive fair probability for a totals market from a stored PMF.""" def _numeric(k: str) -> float | None: try: return float(k) except (ValueError, TypeError): return None if side == "over": return sum(v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n > line) return sum(v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n <= line) # PROPOSED CODE: def _fair_prob_from_dist( distribution: dict[str, float], side: str, line: float ) -> float: """Derive fair probability for a totals market from a stored PMF. Includes: - Validation that PMF sums to ~1.0 (rejects degenerate distributions) - Skew-adjusted probability: if the distribution mean is below the line and we're computing over, cap the boost from tail mass. """ def _numeric(k: str) -> float | None: try: return float(k) except (ValueError, TypeError): return None numeric_items = [(_numeric(k), v) for k, v in distribution.items()] valid_items = [(n, v) for n, v in numeric_items if n is not None] if not valid_items: return 0.5 # fallback total_mass = sum(v for _, v in valid_items) # Reject or renormalize distributions that don't sum to ~1.0 if total_mass < 0.80: # Likely a truncated/sparse distribution — not reliable return 0.5 if abs(total_mass - 1.0) > 0.01: # Renormalize valid_items = [(n, v / total_mass) for n, v in valid_items] if side == "over": raw_prob = sum(v for n, v in valid_items if n > line) else: raw_prob = sum(v for n, v in valid_items if n <= line) # Skew correction: compute distribution mean and apply dampening # when mean is on the opposite side of the line from our bet. # This penalizes cases where the model predicts over but the mean # total is well below the line (fat right tail inflating over prob). dist_mean = sum(n * v for n, v in valid_items) if side == "over" and dist_mean < line: # Mean is below line: tail-driven over probability, apply dampening # The further the mean is below the line, the stronger the penalty. gap = line - dist_mean # positive = mean below line dampening = max(0.0, 1.0 - (gap / line) * 0.5) raw_prob = raw_prob * dampening elif side == "under" and dist_mean > line: gap = dist_mean - line dampening = max(0.0, 1.0 - (gap / line) * 0.5) raw_prob = raw_prob * dampening # Clamp to valid probability range return max(0.01, min(0.99, raw_prob)) ``` **Why:** Games 750, 751, 752, 754, 760, 763, 764 all had `total/over` green picks with final scores of 3 or fewer total runs. The fair_probs ranged from 0.594 to 0.742. If the underlying Monte Carlo distribution had a mean near 8 runs but a right tail putting, say, 65% mass above the line at 7.5, and the actual game was a 1-2 pitchers duel, the mean signal was being ignored. The skew dampening directly targets this failure mode. --- ## 4. Add Minimum Book Count Guard to Consensus Market **File:** `services/model/src/mlb_model/market/_consensus.py` **Function:** `consensus_market` A consensus built from 1-2 books is unreliable and can produce large apparent edges that are really just book-specific line differences. ```python # CURRENT CODE: no book count guard before computing probs # PROPOSED CODE: add to consensus_market, after computing book_count: _MIN_BOOKS_FOR_CONSENSUS = 3 # require at least 3 books def consensus_market(snapshots: list[OddsSnapshot]) -> ConsensusLine: """Compute median price and no-vig implied probability across books. Requires at least _MIN_BOOKS_FOR_CONSENSUS books for a valid consensus. Returns empty side_implied_probs if book count is insufficient, which causes _build_comparison to skip the prediction (returns None via the `odds_side not in consensus.side_implied_probs` check). """ if not snapshots: return ConsensusLine(
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-05-28_0702_model_review.md
Last 50 games — 2026-05-21 to 2026-05-24
Generated May 25, 2026
Analysis run
Last 50 games
ML20-30(40.0%)
ATS32-18(64.0%)
O/U25-21-4(54.4%)
Full season
ML72-63(53.3%)
ATS85-50(63.0%)
O/U65-60-10(52.0%)
Diagnosis
# Diagnosis and Improvement Recommendations ## Diagnosis ### 1. Moneyline Market Has a Severe Calibration Problem (40% Win Rate) The moneyline market is the clearest failure: 20-30 (40%) over 50 graded games. Looking at the wrong predictions, a sharp pattern emerges. Games where the model assigns `fair_prob` in the **0.52–0.65 range** with **positive edge** consistently lose. For example, Games 685, 688, 693, 694, 699, 703, 704 all had green verdicts with `fair_prob` between 0.526 and 0.653 and lost. Meanwhile, the model's high-conviction calls (home_wp < 0.40 or > 0.68, edge > 0.15) mostly won (Games 677, 678, 679, 681, 683, 711, 713, 715, 717). This strongly suggests the model is **systematically overconfident in the 52–65% probability range** — it's calling edge where none exists. The market is pricing these correctly, and the model's 3% edge threshold is far too low for moneyline picks in this probability band. ### 2. Edge Threshold Is Not Market-Stratified The current `_verdict` function uses a single flat threshold (default 3%) across moneyline, runline, and totals. This is a critical design flaw. The wrong predictions reveal that **green verdicts with edges of +0.01 to +0.12 on moneyline lose at a high rate**, while **runline green picks at similar edge values win**. The runline is 64% (well above threshold), totals at 54.4% (marginal), and moneyline at 40% (deeply negative). A single 3% threshold across all three markets ignores the fundamentally different variance and market efficiency profiles of each. The moneyline market is the most efficient (sharpest) and requires a much higher edge to show positive expected value. The data suggests moneyline needs at least ~10–12% edge to be viable, while runline can operate closer to 7–8%. ### 3. Probability Range Filtering is Absent — The "Middle Band" Trap Examining all the losing moneyline picks, they cluster tightly in `fair_prob` 0.52–0.68. The model never has a mechanism to say "even with positive edge, this probability estimate is in the high-noise zone where our model's error bars are large." The `_verdict` function only gates on `edge >= threshold` — there's no concept of **prediction confidence or fair_prob range reliability**. Games 695 and 707 are revealing: both predicted home team with `fair_prob` ~0.541–0.542, edges of -0.001 and -0.068 (correctly flagged red), yet similar games with edge barely above 0 (e.g., Game 670: edge=+0.010) got yellow. The yellow/red distinction at the edge boundary is not causing the problem — the green verdicts with moderate edges are. There is no calibration guard preventing the model from issuing green signals on teams it only mildly favors. ### 4. Total Market Has a Directional Bias Problem The totals market at 54.4% is marginal and shows a concerning pattern in the wrong predictions. Games 700 (actual=2-0, over predicted), 702 (actual=6-7, under predicted), 703 (actual=11-3, under predicted), 705 (actual=2-5, over predicted) all lost. Game 703 is especially egregious: actual score was 11-3 (total=14, a high-scoring game), but the model bet under with `fair_prob=0.542` — suggesting systematic underestimation of run environment in certain conditions. Game 705 shows the inverse: `fair_prob=0.715` on over, but only 7 runs scored. The `_fair_prob_from_dist` function's reliability depends entirely on the quality of the PMF distribution — if the mean of the distribution is miscalibrated by even 0.5 runs, over/under predictions near the line flip entirely. No line proximity penalty exists. ### 5. Red/Yellow Verdict Games Are Not the Primary Problem — Misclassified Greens Are Reviewing all wrong predictions, the red-verdict losses (Games 672, 674, 682, 684, 695, 707, 709, 718) are **expected losses** — the model correctly flagged uncertainty. The real damage comes from **green verdict losses**: Games 685, 688, 670, 676, 677 (wait — 677 won), 678 (won), etc. Filtering to clear green losses on moneyline: Games 685, 688, 670, 693, 694, 699, 701, 703, 704, 705 are all green moneyline/total losses. The `detect_sharp_divergence` and `detect_reverse_line_movement` signals exist but their output is **completely unused in the `_verdict` function** — `sharp` and `rlm` are parameters to `_verdict` but the function ignores them entirely. This is a direct code bug: sharp money and reverse line movement are computed but never incorporated into the verdict. --- ## Specific Improvement Suggestions ### 1. Fix the `_verdict` Function to Actually Use Sharp/RLM Signals (Bug Fix) **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict` The current implementation accepts `sharp` and `rlm` but ignores them completely. This means the entire `_signals.py` module has zero effect on outcomes. When sharp divergence is detected against the model's pick, or when reverse line movement opposes the model, the verdict should be downgraded. ```python def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: """Compute verdict incorporating edge, sharp money, and RLM signals. Sharp divergence against the model's side or reverse line movement against the model's side are penalizing signals that increase the effective threshold required for a green verdict. """ # Count how many adverse market signals are firing adverse_signal_count = int(sharp) + int(rlm) # Each adverse signal raises the effective threshold by 50% # (e.g., 0.03 base -> 0.045 with one signal -> 0.06 with two) effective_threshold = threshold * (1.5 ** adverse_signal_count) if edge >= effective_threshold: return Verdict.green if edge < 0.0: return Verdict.red # If adverse signals push edge below effective threshold but edge is # still positive, degrade green->yellow rather than leaving it green if adverse_signal_count > 0 and edge >= threshold: return Verdict.yellow return Verdict.yellow ``` --- ### 2. Implement Per-Market Edge Thresholds **File:** `services/model/src/mlb_model/market/_evaluate.py` **Functions:** `_edge_threshold` (replace), `_build_comparison` (modify call site), `_verdict` (modify signature) The moneyline market at 40% win rate needs a substantially higher threshold. Based on the data, moneyline picks with edge < ~0.10 are losing money. Runline is performing well and can stay near the current threshold. Totals need a modest bump. ```python # Replace the single _edge_threshold() with per-market thresholds _MARKET_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.09, # Moneyline is most efficient; 40% WR demands higher bar "runline": 0.05, # Runline at 64% — working, modest tightening "total": 0.07, # Totals at 54.4% — needs improvement "f5_total": 0.07, "nrfi": 0.07, } _DEFAULT_EDGE_THRESHOLD = 0.06 # fallback def _edge_threshold(market: str | None = None) -> float: """Return edge threshold for a specific market, with env override support.""" # Allow full env override (existing behavior) raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass # Per-market env override, e.g. EDGE_THRESHOLD_MONEYLINE_PCT=9 if market: market_env_key = f"EDGE_THRESHOLD_{market.upper()}_PCT" market_raw = os.environ.get(market_env_key, "") if market_raw: try: return float(market_raw) / 100.0 except ValueError: pass return _MARKET_EDGE_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD # In _build_comparison, change the threshold call: def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, # kept for signature compat, but overridden per-market below ) -> MarketComparison | None: # ... (existing code up to verdict call unchanged) ... # Override threshold per market effective_threshold = _edge_threshold(pred.market) verdict = _verdict(edge, sharp, rlm, effective_threshold) return MarketComparison( # ... same as before ... ) # In _evaluate, remove the single threshold computation from the loop: def _evaluate(game_id: int, session: Session) -> list[MarketComparison]: # ... existing setup ... threshold = _edge_threshold() # kept as fallback only # ... rest unchanged, _build_comparison now resolves per-market internally ... ``` --- ### 3. Add Fair Probability Confidence Banding to Suppress Low-Conviction Green Picks **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict` The wrong prediction data shows that `fair_prob` values of 0.52–0.62 on moneyline almost always lose even with positive edge. The model's error in this probability band likely exceeds the claimed edge. Add a confidence band that degrades verdicts when `fair_prob` is near 0.5 (high uncertainty zone). ```python # Add this constant near the top of _evaluate.py # Pairs of (market, min_fair_prob_for_green) — picks below this are capped at yellow _MIN_FAIR_PROB_FOR_GREEN: dict[str, float] = { "moneyline": 0.62, # Below 62% moneyline picks are too noisy; data shows consistent losses "runline": 0.58, # Runline working well; modest floor "total": 0.58, # Totals need reasonable conviction } def _verdict( edge: float, sharp: bool, rlm: bool, threshold: float, fair_prob: float = 0.5, market: str = "", ) -> Verdict: """Compute verdict with edge, signal, and probability confidence checks.""" adverse_signal_count = int(sharp) + int(rlm) effective_threshold = threshold * (1.5 ** adverse_signal_count) if edge < 0.0: return Verdict.red if edge >= effective_threshold: # Check if fair_prob is above the minimum conviction floor for this market min_prob = _MIN_FAIR_PROB_FOR_GREEN.get(market, 0.55) if fair_prob < min_prob: return Verdict.yellow # Downgrade: positive edge but low conviction if adverse_signal_count > 0: return Verdict.yellow # Downgrade: signals oppose the pick return Verdict.green return Verdict.yellow # Update the call site in _build_comparison: verdict = _verdict(edge, sharp, rlm, effective_threshold, fair_prob=fair_prob, market=pred.market) ``` --- ### 4. Add Line Proximity Penalty for Totals Distributions **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_fair_prob_from_dist` The PMF-derived probability is highly sensitive to the exact line value. When the consensus line is within ±0.5 of the projected mean, the fair prob will be near 0.50 and the claimed edge is mostly noise. Games 700, 702, 703, and 705 all show the totals model confidently calling sides that lost, suggesting the distribution mean is close to the market line and small errors dominate. ```python def _fair_prob_from_dist( distribution: dict[str, float], side: str, line: float ) -> float: """Derive fair probability for a totals market from a stored PMF. Returns a probability shrunk toward 0.50 when the distribution mass is concentrated near the line (low-confidence region). """ def _numeric(k: str) -> float | None: try: return float(k) except (ValueError, TypeError): return None if side == "over": raw_prob = sum( v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n > line ) else: raw_prob = sum( v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n <= line ) # Compute probability mass within ±1 run of the line (near-line density) # High near-line density => distribution straddles the line => low confidence near_line_mass = sum( v for k, v in distribution.items() if (_n := _numeric(k)) is not None and abs(_n - line) <= 1.0 ) # Shrink toward 0.50 proportionally to near-line mass. # If 40%+ of the distribution is within 1 run of the line, # the pick is unreliable — shrink aggressively. # near_line_mass of 0.0 => no shrinkage; 0.5 => 50% shrinkage toward 0.5 shrinkage = min(near_line_mass, 0.60) # cap shrinkage factor fair_prob = raw_prob * (1.0 - shrinkage) + 0.50 * shrinkage return fair_prob ``` --- ### 5. Add Minimum Book Count Guard in `consensus_market` **File:** `services/model/src/mlb_model/market/_consensus.py` **Function:** `consensus_market` The consensus can currently be built from a single book, which means the "consensus" implied probability may be a single book's line with minimal vig removal reliability. Low book counts produce noisy consensus prices, making edge calculations meaningless. Several losses may stem from thin market data producing falsely attractive edges. ```python _MIN_BOOKS_FOR_RELIABLE_CONSENSUS = 3 def consensus_market(snapshots: list[OddsSnapshot]) -> ConsensusLine: """Compute median price and no-vig implied probability across books. Snapshots should all belong to the same game and market. Within each (book, side) pair only the most recent snapshot is used. Returns a ConsensusLine with book_count populated; callers should check book_count >= _MIN_BOOKS_FOR_RELIABLE_CONSENSUS before trusting side_implied_probs for edge computation. """ # ... (existing logic unchanged until return) ... # Flag low-confidence consensus in the returned object # so _build_comparison can gate on it return ConsensusLine( median_line_value=median_line, side_implied_probs={side_a: prob_a, side_b: prob_b}, book_count=book_count, ) # In _evaluate.py _build_comparison, add after consensus is computed: consensus = consensus_market(market_odds) # Require minimum book coverage for reliable edge computation MIN_BOOKS = int(os.environ.get("MIN_CONSENSUS_BOOKS",
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-05-25_0902_model_review.md
Last 85 games — 2026-05-14 to 2026-05-20
Generated May 21, 2026
Analysis run
Last 85 games
ML52-33(61.2%)
ATS53-32(62.4%)
O/U40-39-6(50.6%)
Full season
ML52-33(61.2%)
ATS53-32(62.4%)
O/U40-39-6(50.6%)
Diagnosis
# Diagnosis and Improvement Recommendations ## Diagnosis ### 1. Totals Market Calibration is Severely Broken The totals market is running at 50.6% (40-39), well below the 60% threshold. Looking at the wrong predictions, the problem is stark and consistent: the model assigned **high fair_prob and strong positive edge to "under" predictions** on games that went over dramatically. Game 599 (3-13, 16 total runs) had `fair_prob=0.756, edge=+0.264` on under. Game 634 (9-3, 12 runs) had `fair_prob=0.805, edge=+0.315` on under. Game 630 (16-7, 23 runs) had `fair_prob=0.723, edge=+0.236` on under. Game 622 (8-6, 14 runs) had `fair_prob=0.727, edge=+0.242` on under. These are not marginal misses — these are games that blew up dramatically, yet the model was extremely confident in the under. The fair_prob values in the 0.70-0.80 range should be hitting at near those rates; they are clearly not. This points to a systematic upstream bias in the run-scoring distribution, likely the pitcher/batter rolling features or park factor weighting **suppressing projected totals below true expectation**. ### 2. The `_fair_prob_from_dist` Function Has a Boundary Error on the Under In `_evaluate.py`, the `_fair_prob_from_dist` function computes the under probability as: ```python return sum(v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n <= line) ``` This includes the **exact line value** in the under bucket (e.g., if the line is 8.5, it shouldn't matter, but if lines are stored as integers like 8 or 9, a game landing exactly on the line integer value is double-counted or miscounted). More critically, if `median_line_value` is being computed from a mix of half-point and whole-number lines (e.g., 8.5 vs 9.0), the `statistics.median()` in `consensus_market` could return a value like 8.75 that doesn't align with how PMF keys are stored. If PMF keys are integers (0, 1, 2... 20) and the consensus line comes back as 8.75, then `_n <= 8.75` captures runs 0-8 while `_n > 8.75` captures 9+, which is correct for a half-point line. But if the PMF is a **discrete distribution** and the model's projected total is systematically too low (e.g., projecting 7.5 total when actual is 9-10), the under bucket will always appear inflated. The combination of a low projected total mean with a PMF that has most mass below the consensus line artificially inflates `fair_prob` for unders. ### 3. Edge Threshold is Too Permissive for Low-Confidence Markets The `_verdict` function marks anything ≥ 0.03 (3%) as green. Looking at wrong predictions, many losing bets had edge values of +0.01 to +0.06 on totals/under: Game 573 (edge=+0.051), Game 611 (edge=+0.046), Game 619 (edge=+0.035), Game 626 (edge=+0.053). A 3% edge threshold was possibly calibrated when the model was more accurate. The totals market's 50.6% win rate means the model has **negative expected value on many "green" totals plays**. The threshold needs to be higher for totals specifically, and should be scaled by `fair_prob` — a play with `fair_prob=0.51` and `edge=+0.04` is not the same confidence level as `fair_prob=0.65` and `edge=+0.04`. ### 4. Runline Side-Mapping Has a Silent Probability Inversion Risk The `_MODEL_TO_ODDS` dict maps both `("runline", "home_minus")` and `("runline", "home_plus")` to `("spreads", "home_runline")`, and both `("runline", "away_minus")` and `("runline", "away_plus")` to `("spreads", "away_runline")`. The line-value filtering in `_build_comparison` is the only guard against comparing a +1.5 prediction against a -1.5 market price. But `consensus_market` uses `devig_two_way` across whatever two sides exist — if book data has inconsistent side labeling (some books labeling home runline as -1.5, others as +1.5 due to a data ingestion quirk), the `median_line_value` guard could pass while the `consensus_implied` is actually for the **opposite side**. Several runline losses (Game 572: away runline with `edge=+0.012, verdict=yellow` in a 4-9 game, Game 573 and 574) suggest the runline away predictions on games where away won big (correct direction) but perhaps with wrong line comparison. ### 5. No Contextual Filtering on High-Variance Games The wrong predictions include multiple blowout games (1-15, 2-12, 3-13, 9-3, 16-7, 0-12) where both the total and the runline went against the model's under/close-game prediction. Games 570-575 all on 2026-05-14 show a cluster of failures — all predicted away wins and unders, and several ended in blowouts. This suggests a **weather or park event** on that date was not captured (park_rf and weather fields are `?` in the data). The model has no mechanism to reduce confidence or widen the edge threshold when contextual data is missing — it still outputs green verdicts with high edges even when `home_wp`, `proj_total`, `park_rf`, and weather are all unknown. --- ## Specific Improvement Suggestions ### 1. Fix the PMF boundary condition and add line-alignment validation in `_fair_prob_from_dist` **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_fair_prob_from_dist` **Problem:** The `<=` boundary on under includes the exact line integer, and there's no validation that the consensus `median_line_value` is sensible relative to the PMF's key range. Also, totals lines are almost always half-points (8.5, 9.5) — if they're stored as whole numbers in any book, the median can land on a whole number and the under/over split will be wrong. ```python def _fair_prob_from_dist( distribution: dict[str, float], side: str, line: float ) -> float | None: """Derive fair probability for a totals market from a stored PMF. Returns None if the line value is outside the PMF's support range, which indicates a data alignment problem rather than a real probability. """ def _numeric(k: str) -> float | None: try: return float(k) except (ValueError, TypeError): return None numeric_items = [ (_numeric(k), v) for k, v in distribution.items() if _numeric(k) is not None ] if not numeric_items: return None keys = [n for n, _ in numeric_items] pmf_min, pmf_max = min(keys), max(keys) # Guard: if the consensus line is outside the PMF support, the PMF and # market are misaligned — return None so this comparison is skipped # rather than producing a garbage fair_prob. if line < pmf_min or line > pmf_max: logger.warning( "_fair_prob_from_dist: line=%.2f outside PMF range [%.1f, %.1f]; skipping", line, pmf_min, pmf_max, ) return None # Use strict < for over and strict <= for under on half-point lines. # For whole-number lines, the "push" bucket (exact line) should be # excluded from both sides (it won't exist in practice for MLB totals, # but this makes the split unambiguous). if side == "over": return sum(v for n, v in numeric_items if n > line) # under: strictly less than line (exclude exact-line ties) return sum(v for n, v in numeric_items if n < line) ``` Then update the caller in `_build_comparison` to handle the `None` return: ```python if pred.market in _DISTRIBUTION_MARKETS: if pred.distribution is None or consensus.median_line_value is None: return None fair_prob = _fair_prob_from_dist( pred.distribution, pred.side, consensus.median_line_value ) # NEW: treat None as uncomputable — skip this comparison if fair_prob is None: return None ``` --- ### 2. Implement per-market edge thresholds with a `fair_prob` minimum floor **File:** `services/model/src/mlb_model/market/_evaluate.py` **Functions:** `_verdict`, `_edge_threshold`, `_build_comparison` **Problem:** A flat 3% edge threshold ignores that totals predictions are less reliable and that low `fair_prob` green calls (0.51 with 4% edge) have little real value. The totals market is hitting 50.6% — its effective threshold should be raised until it's demonstrated to be calibrated. ```python # Replace the single _DEFAULT_EDGE_THRESHOLD with a per-market dict _DEFAULT_EDGE_THRESHOLD = 0.03 _MARKET_EDGE_THRESHOLDS: dict[str, float] = { "moneyline": 0.04, # slight increase from 0.03 "runline": 0.04, "total": 0.07, # raised significantly — market is at 50.6%, needs higher bar "f5_total": 0.07, "nrfi": 0.05, } # Minimum fair_prob required for a green verdict — below this, cap at yellow # regardless of edge, because low-probability estimates are high-variance _MIN_FAIR_PROB_FOR_GREEN: dict[str, float] = { "moneyline": 0.53, "runline": 0.60, "total": 0.58, # require meaningful confidence on totals "f5_total": 0.58, "nrfi": 0.55, } def _edge_threshold(market: str | None = None) -> float: """Return edge threshold for a specific market, with env override.""" raw = os.environ.get("EDGE_THRESHOLD_PCT", "") if raw: try: return float(raw) / 100.0 except ValueError: pass if market is not None: return _MARKET_EDGE_THRESHOLDS.get(market, _DEFAULT_EDGE_THRESHOLD) return _DEFAULT_EDGE_THRESHOLD def _verdict( edge: float, sharp: bool, rlm: bool, threshold: float, fair_prob: float, market: str, ) -> Verdict: min_prob = _MIN_FAIR_PROB_FOR_GREEN.get(market, 0.52) if edge >= threshold and fair_prob >= min_prob: return Verdict.green if edge < 0.0: return Verdict.red return Verdict.yellow ``` Update the call site in `_build_comparison`: ```python # Pass market-specific threshold threshold = _edge_threshold(pred.market) sharp = detect_sharp_divergence(market_splits) rlm = detect_reverse_line_movement(market_odds, market_splits) verdict = _verdict(edge, sharp, rlm, threshold, fair_prob, pred.market) ``` --- ### 3. Add a missing-context confidence penalty **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` **Problem:** Games 570-575 all have `?` for `home_wp`, `proj_total`, `park_rf`, and weather. The model still outputs green verdicts with large edges. When key contextual inputs are absent, the fair_prob estimate is based on incomplete information and should be penalized or the verdict should be capped. ```python def _context_completeness_score(pred: Prediction, session: Session, game_id: int) -> float: """Return a score in [0, 1] representing fraction of key context present. Used to down-grade verdict confidence when model inputs are missing. """ from db.models import Game, ParkFactor, WeatherSnapshot score = 0.0 checks = 0 # Check 1: home_win_prob populated (moneyline prediction exists) if pred.fair_prob is not None: score += 1.0 checks += 1 # Check 2: park factor present game = session.get(Game, game_id) if game is not None: pf = session.scalar( select(ParkFactor).where( ParkFactor.park_id == game.park_id, ParkFactor.season == game.game_date.year, ) ) if pf is not None: score += 1.0 checks += 1 # Check 3: weather snapshot present weather = session.scalar( select(WeatherSnapshot) .where(WeatherSnapshot.game_id == game_id) .order_by(WeatherSnapshot.captured_at.desc()) .limit(1) ) if weather is not None: score += 1.0 checks += 1 return score / checks if checks > 0 else 0.0 # In _build_comparison, after computing verdict, apply context penalty: verdict = _verdict(edge, sharp, rlm, threshold, fair_prob, pred.market) # Downgrade verdict when context completeness is low # Requires session to be threaded through — add session param to _build_comparison completeness = _context_completeness_score(pred, session, game_id) if completeness < 0.67 and verdict == Verdict.green: logger.info( "Downgrading game_id=%d market=%s side=%s from green to yellow: " "context completeness=%.2f", game_id, pred.market, pred.side, completeness, ) verdict = Verdict.yellow ``` Update `_build_comparison` signature to accept `session`: ```python def _build_comparison( game_id: int, model_run_id: int, pred: Prediction, odds_snapshots: list[OddsSnapshot], splits_snapshots: list[SplitsSnapshot], threshold: float, session: Session, # NEW ) -> MarketComparison | None: ``` And update the call in `_evaluate`: ```python comp = _build_comparison( game_id=game_id, model_run_id=model_run.id, pred=pred, odds_snapshots=odds_snapshots, splits_snapshots=splits_snapshots, threshold=threshold, session=session, # NEW ) ``` --- ### 4. Validate runline side consistency before computing consensus **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` **Problem:** The current guard checks `median_lv` against expected `±1.5`, but `consensus_market` is called *before* this check, and `consensus_implied` is already computed from whatever the two sides happen to be in the snapshot data. If any book has home and away runline both at -1.5 (a data error), `devig_two_way` produces garbage. Add an explicit consistency check on the snapshot data before passing to `consensus_market`. ```python if pred.market == "runline": # Pre-filter: only keep snapshots where line values are internally # consistent (home = -1.5 when away = +1.5, or vice versa). # Remove any book where both sides have the same sign. def _runline_snapshots_are_
Draft file
/home/ubuntu/mlbbetting/analysis_drafts/2026-05-21_0901_model_review.md
Last 40 games — 2026-05-17 to 2026-05-19
Generated May 19, 2026
Analysis run
Last 40 games
ML22-18(55.0%)
ATS25-15(62.5%)
O/U16-21-3(43.2%)
Diagnosis
# Diagnosis and Improvement Plan ## Diagnosis ### 1. Totals Market Systematic Failure (Most Critical Issue) The totals market is performing at **43.2% (16-21)**, well below the 60% threshold and actually worse than random chance. Looking at the wrong predictions, the pattern is striking and consistent: the model repeatedly predicted **under** with high confidence and large edges, yet the actual scores were frequently high-scoring games. Examples include Game 630 (actual 16-7, under predicted with fair_prob=0.723, edge=+0.236), Game 634 (actual 9-3, under predicted with fair_prob=0.805, edge=+0.315), Game 644 (actual 6-9, under predicted with fair_prob=0.724, edge=+0.235), and Game 631 (actual 6-7, under predicted with fair_prob=0.686, edge=+0.171). The model's highest-confidence under picks are systematically wrong — this is not noise. The projected total distribution from the Monte Carlo layer appears to be generating downward-biased run distributions, meaning `_fair_prob_from_dist` consistently overestimates under probability. The boundary condition in `_fair_prob_from_dist` is also suspect: under is computed as `_n <= line`, meaning a total landing exactly on the line (a push in most books) is counted as a win for the under, slightly inflating under fair_prob. ### 2. Moneyline Calibration Is Poor for Marginal Edges The moneyline is at 55.0% (22-18), which is passable but the wrong predictions reveal a calibration problem: the model is generating contradictory signals within the same game. Game 621 had `moneyline/home` as red (edge=-0.004) yet `runline/home_plus` as green (edge=+0.052), and home won 2-0 — the runline was right, the moneyline direction was right, but the market layer assigned opposite verdicts. Game 623 had `moneyline/home` red (edge=-0.053) yet home won 10-1. Game 651 had `moneyline/home` red yet home won 6-4. This suggests the moneyline fair_prob values are being slightly underestimated relative to the runline fair_prob values from the same underlying model, indicating inconsistency between how `pred.fair_prob` is set for moneyline versus how `_fair_prob_from_dist` or the runline probability is derived. The moneyline and runline probabilities should be mathematically consistent — when a team wins 10-1 there should not be a negative moneyline edge. ### 3. Edge Threshold Is Too Permissive for Low-Confidence Predictions Several red-verdict predictions (negative edge) are still appearing in the wrong-predictions list, meaning the model correctly flagged them as red but they were still presumably surfaced somewhere (or the threshold discussion reveals a structural issue). More critically, many yellow-verdict predictions with edges of +0.004 to +0.027 are wrong. The current `_DEFAULT_EDGE_THRESHOLD = 0.03` (3pp) is too low — it's generating "green" signals on edges as small as +0.004 for runline (Game 618, fair_prob=0.604, edge=+0.004, still marked yellow since 0.004 < 0.03, but this is borderline noise). The `_verdict` function ignores the `sharp` and `rlm` signals entirely — they are computed but have zero effect on the verdict. This is a significant unused-signal bug. ### 4. The `_fair_prob_from_dist` Boundary Condition and Distribution Quality The under boundary `_n <= line` is mathematically incorrect for a continuous approximation of discrete run scoring. In baseball, a total of exactly 8 when the line is 8 is a push, not a win for under. More importantly, the PMF stored likely uses integer run totals, and the line value from `consensus.median_line_value` may be a non-integer (e.g., 8.5), but if it's ever an integer (e.g., 8.0), the under calculation includes the push scenario, inflating the under probability by whatever mass is at exactly 8 runs. Combined with a systematic downward bias in the Monte Carlo run distribution (possibly from stale pitcher rolling averages, under-weighted bullpen degradation, or park factor under-application), this compounds to make the totals model unreliable. ### 5. Contextual Data Is Missing from Reasoning but May Also Be Missing from the Model All wrong predictions show `home_wp=?, proj_total=?, park_rf=?, ?F wind=?mph` — the reasoning context is not being populated. This means either `_build_reasoning` is failing silently (the `session.get(Game, game_id)` or `ParkFactor` queries are returning None), or the data simply isn't in the database. If park factors and weather are not reaching the Monte Carlo layer either, that would explain systematic bias — a hitter-friendly park with hot weather would have its run environment underestimated, leading to exactly the pattern seen (high-scoring actual games, under predictions from the model). --- ## Specific Improvement Suggestions ### 1. Fix the `_fair_prob_from_dist` boundary condition and add a push-aware calculation **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_fair_prob_from_dist` **Why:** The current `<=` boundary for under means a line of 8.0 counts a final total of 8 as an under win. In practice this is a push (money returned). This inflates under fair_prob. Additionally, half-point lines (8.5) are common and should be handled explicitly. The fix should also add a push mass tracker for diagnostic purposes. ```python def _fair_prob_from_dist( distribution: dict[str, float], side: str, line: float ) -> float: """Derive fair probability for a totals market from a stored PMF. For integer lines, the mass exactly on the line is a push and is excluded from both over and under probability, then each side is renormalized by (1 - push_mass) so they sum to 1.0. """ def _numeric(k: str) -> float | None: try: return float(k) except (ValueError, TypeError): return None is_half_point = abs(line - round(line)) >= 0.4 # e.g. 8.5 over_mass = sum( v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n > line ) under_mass = sum( v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n < line ) push_mass = sum( v for k, v in distribution.items() if (_n := _numeric(k)) is not None and _n == line ) if not is_half_point else 0.0 # For integer lines, renormalize excluding push mass so over+under = 1.0 live_mass = over_mass + under_mass if live_mass <= 0.0: return 0.5 # degenerate distribution if side == "over": return over_mass / live_mass return under_mass / live_mass ``` --- ### 2. Make sharp divergence and RLM signals actually affect the verdict **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_verdict` **Why:** The `sharp` and `rlm` parameters are passed into `_verdict` but completely ignored. The model detects sharp money and reverse line movement but throws the information away. Sharp divergence should upgrade a yellow to green and can also upgrade a borderline red to yellow. RLM on the popular side being faded should similarly boost confidence. This is free signal that's currently wasted. ```python def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float) -> Verdict: """Compute verdict incorporating sharp money and RLM signals. Signal logic: - Sharp divergence: smart money aligns with our edge -> lower threshold by 30% (sharps provide confirmation) - RLM: line moving against public -> adds 0.01 to effective edge (structural value signal) - Negative edge with sharp confirmation -> yellow instead of red (sharps may know something the model doesn't) """ effective_edge = edge effective_threshold = threshold if sharp: # Sharps confirm our position: reduce required threshold effective_threshold = threshold * 0.70 if rlm: # Line moving against public money: treat as +1pp bonus effective_edge = edge + 0.010 if effective_edge >= effective_threshold: return Verdict.green if edge < 0.0 and not sharp: # Negative edge without sharp confirmation: red return Verdict.red if edge < 0.0 and sharp: # Negative model edge but sharps are on this side: yellow (conflicting signals) return Verdict.yellow return Verdict.yellow ``` --- ### 3. Raise the default edge threshold and add a minimum fair_prob gate **File:** `services/model/src/mlb_model/market/_evaluate.py` **Constants and function:** `_DEFAULT_EDGE_THRESHOLD`, `_build_comparison` **Why:** The current 3pp threshold is generating green signals on edges as small as +0.004 (Game 618 was yellow but only because 0.004 < 0.03 barely; green starts at 0.03 which is still very small). The wrong-prediction data shows multiple green-verdict losses with edges in the 0.03–0.06 range. Raise to 5pp. Additionally, no prediction with a fair_prob below 0.52 should ever be green regardless of edge — these are essentially coin flips where model error exceeds the signal. ```python _DEFAULT_EDGE_THRESHOLD = 0.05 # raised from 0.03 — require 5pp edge for green _MIN_FAIR_PROB_FOR_GREEN = 0.52 # below this, cap at yellow regardless of edge def _verdict(edge: float, sharp: bool, rlm: bool, threshold: float, fair_prob: float = 0.5) -> Verdict: effective_edge = edge effective_threshold = threshold if sharp: effective_threshold = threshold * 0.70 if rlm: effective_edge = edge + 0.010 if effective_edge >= effective_threshold: # Additional gate: fair_prob must clear minimum confidence bar if fair_prob < _MIN_FAIR_PROB_FOR_GREEN: return Verdict.yellow return Verdict.green if edge < 0.0 and not sharp: return Verdict.red if edge < 0.0 and sharp: return Verdict.yellow return Verdict.yellow # In _build_comparison, update the call: verdict = _verdict(edge, sharp, rlm, threshold, fair_prob=fair_prob) ``` --- ### 4. Add a totals-specific edge multiplier penalty to debias the systematic under lean **File:** `services/model/src/mlb_model/market/_evaluate.py` **Function:** `_build_comparison` **Why:** The data shows a systematic over-prediction of under probability. Until the upstream Monte Carlo distribution is recalibrated, apply a temporary debiasing correction in the market layer: for totals markets, apply a shrinkage factor that pulls the fair_prob toward 0.5 proportionally to how extreme it is. This is a Bayesian-style regularization acknowledging the model's known bias. Also add a market-specific edge threshold for totals. ```python _TOTALS_SHRINKAGE = 0.15 # pull 15% toward 0.5 — recalibrate when MC is fixed _TOTALS_EDGE_THRESHOLD_MULTIPLIER = 1.4 # require 40% more edge on totals def _apply_totals_debiasing(fair_prob: float) -> float: """Shrink totals fair_prob toward 0.5 to correct systematic MC bias. Remove once Monte Carlo run distribution is recalibrated. """ return fair_prob * (1.0 - _TOTALS_SHRINKAGE) + 0.5 * _TOTALS_SHRINKAGE # In _build_comparison, after computing fair_prob: if pred.market in _DISTRIBUTION_MARKETS: if pred.distribution is None or consensus.median_line_value is None: return None fair_prob = _fair_prob_from_dist( pred.distribution, pred.side, consensus.median_line_value ) # Debiasing: correct for known systematic under-prediction bias fair_prob = _apply_totals_debiasing(fair_prob) # Use a higher edge threshold for totals until MC is recalibrated market_threshold = threshold * _TOTALS_EDGE_THRESHOLD_MULTIPLIER else: if pred.fair_prob is None: return None fair_prob = pred.fair_prob market_threshold = threshold # ... rest of function uses market_threshold instead of threshold: verdict = _verdict(edge, sharp, rlm, market_threshold, fair_prob=fair_prob) ``` --- ### 5. Fix the `consensus_market` side ordering to be deterministic **File:** `services/model/src/mlb_model/market/_consensus.py` **Function:** `consensus_market` **Why:** `sides_with_prices[0], sides_with_prices[1]` iterates over a dict (`prices_by_side`) built from `defaultdict`, which in Python 3.7+ preserves insertion order but that order depends on which snapshot was processed first. If the order of `home_ml`/`away_ml` flips between runs, `devig_two_way(median_a, median_b)` will assign `prob_a` to whichever side happens to be first, potentially swapping home and away implied probabilities. This would cause edge calculations to be inverted for affected games — a -5pp edge appearing as +5pp. The fix is to sort sides explicitly. ```python def consensus_market(snapshots: list[OddsSnapshot]) -> ConsensusLine: """Compute median price and no-vig implied probability across books.""" if not snapshots: return ConsensusLine( median_line_value=None, side_implied_probs={}, book_count=0, ) latest: dict[tuple[str, str], OddsSnapshot] = {} for snap in snapshots: key = (snap.book, snap.side) if key not in latest or snap.captured_at > latest[key].captured_at: latest[key] = snap prices_by_side: dict[str, list[int]] = defaultdict(list) lines_by_side: dict[str, list[float]] = defaultdict(list) for (_, side), snap in latest.items(): if snap.price_american is not None: prices_by_side[side].append(snap.price_american) if snap.line_value is not None: lines_by_side[side].append(snap.line_value) book_count = len({book for book, _ in latest}) all_lines = [v for vals in lines_by_side.values() for v in vals] median_line: float | None = statistics.median(all_lines) if all_lines else None sides_with_prices = sorted( # FIXED: deterministic ordering [s for s in prices_by_side if prices_by_side[s]] ) if len(sides_with_prices) < 2: probs = {s: 0.5 for s in sides_with_prices} return ConsensusLine( median_line_value=median_line, side_implied_probs=probs, book_count=book_count, ) side_a, side_b = sides_with_prices[0], sides_with_prices[1] median_a = int(round(statistics.median(prices_by_side[side_a]))) median_b = int(round(statistics.median(prices_by_side[side_b]))) prob_a, prob_b = devig_two_way(median_a, median_b) return ConsensusLine( median_line
Draft file
/home/ubuntu/mlbbetting/services/model/../../analysis_drafts/2026-05-20_0349_model_review.md