29 min read

> "Baseball is the only field of endeavor where a man can succeed three times out of ten and be considered a good performer."

Learning Objectives

  • Translate sabermetric statistics (wOBA, FIP, wRC+, WAR) into game-level probability estimates
  • Build pitcher projection models that account for handedness, pitch arsenal, and workload
  • Calculate and apply park factors with environmental adjustments for temperature, altitude, humidity, and wind
  • Construct run-scoring models using Poisson and negative binomial distributions for moneyline, run line, and totals markets
  • Identify and exploit MLB-specific market patterns including reverse line movement, umpire effects, and seasonal inefficiencies

Chapter 17: Modeling MLB

"Baseball is the only field of endeavor where a man can succeed three times out of ten and be considered a good performer." --- Ted Williams

Chapter Overview

Baseball occupies a unique position in the sports betting landscape. No other major sport offers the combination of a deep statistical tradition, massive sample sizes within a single season, and a game structure that isolates individual matchups so cleanly. The 162-game regular season produces an enormous volume of data. The pitcher-versus-batter confrontation --- a discrete, repeated event with measurable inputs and outputs --- provides the kind of structured randomness that statistical models thrive on. And the century-long tradition of sabermetrics means that the analytical tools for understanding baseball performance are more mature and more publicly available than for any other sport.

For the betting modeler, these properties create both opportunity and challenge. The opportunity is clear: rich data enables sophisticated models, and the sheer volume of games means that even small edges compound into meaningful profits over a full season. The challenge is that the market knows this too. MLB betting lines, particularly moneylines on games with known starting pitchers, are among the most efficient in all of sports. Finding edges requires going beyond the statistics that everyone can access on FanGraphs or Baseball Reference and building models that integrate information in ways the market has not fully priced.

This chapter provides the tools to do exactly that. We begin with the sabermetric foundations that every baseball bettor must understand --- not as an academic exercise, but as the raw inputs to prediction models. We then build pitcher-centric models that capture the dominant source of game-to-game variance. Park factors and environmental adjustments follow, because no MLB model is complete without accounting for the enormous differences between venues. We construct run-scoring distributions that translate team and pitcher quality into probabilities for moneyline, run line, and totals markets. Finally, we survey the market patterns specific to MLB betting that create exploitable inefficiencies.

In this chapter, you will learn to: - Use pybaseball to pull Statcast data and compute advanced metrics programmatically - Build a pitcher matchup model that integrates handedness, pitch arsenal, and recent workload - Calculate park-adjusted projections that account for weather, altitude, and wind - Fit Poisson and negative binomial models to run-scoring data for pricing run lines and totals - Identify reverse line movement, umpire effects, and seasonal patterns in MLB betting markets


17.1 Sabermetric Foundations for Bettors

Why Sabermetrics Matter for Betting

The sabermetric revolution, catalyzed by Bill James in the 1980s and brought to public consciousness by Michael Lewis's Moneyball (2003), fundamentally changed how baseball performance is measured. For bettors, the key insight is not that traditional statistics like batting average or ERA are worthless --- it is that they are noisy. They conflate skill with luck, individual performance with context, and process with outcome. Advanced metrics attempt to isolate the signal from the noise, and the metrics that do this best are the ones most useful for prediction.

The bettor's question is always forward-looking: What will happen in tonight's game? This is different from the historian's question (What happened last season?) or the fan's question (How is my team doing?). Predictive value is what separates useful metrics from interesting-but-irrelevant ones. We will evaluate every metric through this lens.

Weighted On-Base Average (wOBA)

Weighted on-base average (wOBA) is the single most important offensive metric for bettors. It measures a hitter's overall offensive contribution by assigning linear weights to each plate appearance outcome based on its average run value.

The formula for wOBA is:

$$\text{wOBA} = \frac{w_{BB} \cdot \text{BB} + w_{HBP} \cdot \text{HBP} + w_{1B} \cdot \text{1B} + w_{2B} \cdot \text{2B} + w_{3B} \cdot \text{3B} + w_{HR} \cdot \text{HR}}{\text{AB} + \text{BB} - \text{IBB} + \text{SF} + \text{HBP}}$$

where the weights $w$ are derived each season from linear weights analysis. Typical values (which shift slightly year to year) are approximately:

Event Approximate Weight
Walk (BB) 0.690
Hit by Pitch (HBP) 0.720
Single (1B) 0.880
Double (2B) 1.245
Triple (3B) 1.575
Home Run (HR) 2.015

The scale is calibrated to match on-base percentage (OBP), so league average wOBA is typically around .310-.320 in a given season. The advantage of wOBA over OBP or batting average is that it properly weights extra-base hits: a double is worth roughly 1.4 times a single, a home run roughly 2.3 times a single. Batting average treats all hits equally; OBP treats all times on base equally. Neither reflects the actual run value of offensive events.

Weighted Runs Created Plus (wRC+)

While wOBA is expressed on the OBP scale, wRC+ normalizes offensive production to a league-and-park-adjusted scale where 100 is league average. A hitter with a wRC+ of 130 produced 30% more runs per plate appearance than league average, adjusted for park.

$$\text{wRC+} = \left(\frac{\text{wRAA}/\text{PA} + \text{league runs per PA}}{1 + \frac{\text{league runs per PA} - \text{park factor} \cdot \text{league runs per PA}}{\text{league runs per PA}}}\right) \times 100$$

where wRAA (weighted Runs Above Average) is derived from wOBA:

$$\text{wRAA} = \frac{\text{wOBA} - \text{league wOBA}}{\text{wOBA scale}} \times \text{PA}$$

For bettors, wRC+ is invaluable because it allows direct comparison of hitters across different parks and eras. A lineup full of 110+ wRC+ hitters is expected to produce roughly 10% more runs than a lineup of league-average hitters, park-adjusted. This translates directly to game-level run expectation.

Fielding Independent Pitching (FIP)

FIP is the pitching analog of wOBA in the sense that it isolates pitcher skill from factors outside the pitcher's control --- primarily the quality of the defense behind him. FIP considers only the three true outcomes that a pitcher controls: strikeouts, walks, and home runs.

$$\text{FIP} = \frac{13 \times \text{HR} + 3 \times (\text{BB} + \text{HBP}) - 2 \times \text{K}}{\text{IP}} + C_{\text{FIP}}$$

where $C_{\text{FIP}}$ is a constant that scales FIP to match the league's ERA for that season. The constant is typically around 3.10-3.20, varying year to year.

FIP is more predictive of future ERA than ERA itself. This is a critical point for bettors: a pitcher with a 4.50 ERA and a 3.40 FIP has likely been unlucky (his defense has been poor, or he has had bad luck on balls in play). Betting markets sometimes anchor on ERA, creating value opportunities when FIP diverges significantly.

Expected FIP (xFIP) goes one step further by replacing actual home runs with expected home runs based on fly ball rate, assuming a league-average HR/FB rate:

$$\text{xFIP} = \frac{13 \times (\text{FB} \times \text{league HR/FB rate}) + 3 \times (\text{BB} + \text{HBP}) - 2 \times \text{K}}{\text{IP}} + C_{\text{FIP}}$$

SIERA (Skill-Interactive ERA) adds further refinements by accounting for the interaction between strikeout rate and ground ball rate --- high-strikeout pitchers who also generate ground balls are more effective than the sum of those skills would suggest.

Wins Above Replacement (WAR)

WAR is the most comprehensive single metric in baseball, estimating the total number of wins a player contributes above a replacement-level player (a freely available minor-league call-up). For position players, WAR combines offensive production, baserunning, defense, and positional adjustment. For pitchers, it combines run prevention with innings pitched.

For betting purposes, WAR is most useful at the team level. Total team WAR correlates strongly with actual wins:

$$\text{Expected Wins} \approx \text{Replacement Level Wins} + \sum_{i} \text{WAR}_i$$

where replacement level is approximately 47-48 wins over a 162-game season. A team with 40 WAR above replacement is projected for roughly 87-88 wins.

The Pythagorean expectation provides an alternative route from run production to wins:

$$\text{Win\%} = \frac{\text{RS}^{1.83}}{\text{RS}^{1.83} + \text{RA}^{1.83}}$$

where RS is runs scored and RA is runs allowed, and the exponent 1.83 (sometimes 2, sometimes refined further) is the empirically derived value for MLB.

Statcast and the Modern Data Revolution

Since 2015, MLB's Statcast system has measured every pitch, batted ball, and player movement with unprecedented precision. Key Statcast metrics for bettors include:

  • Exit velocity (EV): The speed of the ball off the bat, measured in mph. Higher EV correlates strongly with offensive production.
  • Launch angle (LA): The vertical angle at which the ball leaves the bat. Combined with EV, this predicts batted ball outcomes with high accuracy.
  • Expected wOBA (xwOBA): Derived from EV and LA, xwOBA estimates what a batter's wOBA "should" have been given the quality of contact.
  • Spin rate: RPM on pitched balls, which correlates with movement and deception.
  • Stuff+ and Pitching+: Newer composite metrics that evaluate pitch quality independent of outcome.

The gap between xwOBA and actual wOBA identifies hitters who have been lucky or unlucky on batted balls. This regression signal is directly useful for betting.

Python Code: Pulling Sabermetric Data with pybaseball

"""
MLB Sabermetric Data Pipeline

Uses the pybaseball library to pull advanced statistics from
FanGraphs and Statcast, then computes composite team-level
metrics for use in game prediction models.

Requirements:
    pip install pybaseball pandas numpy
"""

import pandas as pd
import numpy as np
from pybaseball import (
    batting_stats,
    pitching_stats,
    team_batting,
    team_pitching,
    statcast,
)
from datetime import datetime, timedelta


class MLBSabermetricPipeline:
    """
    Pulls and processes advanced MLB statistics for betting models.

    This pipeline combines FanGraphs leaderboard data with Statcast
    batted ball data to create team-level offensive and pitching
    profiles suitable for game-level prediction.

    Attributes:
        season: The MLB season year to analyze.
        batting_data: DataFrame of team batting statistics.
        pitching_data: DataFrame of team pitching statistics.
    """

    def __init__(self, season: int):
        """
        Initialize the pipeline for a given season.

        Args:
            season: The MLB season year (e.g., 2025).
        """
        self.season = season
        self.batting_data = None
        self.pitching_data = None
        self._team_map = self._build_team_abbreviation_map()

    @staticmethod
    def _build_team_abbreviation_map() -> dict[str, str]:
        """Map common team abbreviation variants to standard forms."""
        return {
            "ARI": "ARI", "ATL": "ATL", "BAL": "BAL", "BOS": "BOS",
            "CHC": "CHC", "CHW": "CHW", "CWS": "CHW", "CIN": "CIN",
            "CLE": "CLE", "COL": "COL", "DET": "DET", "HOU": "HOU",
            "KCR": "KCR", "KC": "KCR", "LAA": "LAA", "LAD": "LAD",
            "MIA": "MIA", "MIL": "MIL", "MIN": "MIN", "NYM": "NYM",
            "NYY": "NYY", "OAK": "OAK", "PHI": "PHI", "PIT": "PIT",
            "SDP": "SDP", "SD": "SDP", "SFG": "SFG", "SF": "SFG",
            "SEA": "SEA", "STL": "STL", "TBR": "TBR", "TB": "TBR",
            "TEX": "TEX", "TOR": "TOR", "WSN": "WSN", "WSH": "WSN",
        }

    def pull_team_batting(self) -> pd.DataFrame:
        """
        Pull team-level batting statistics from FanGraphs.

        Returns:
            DataFrame with columns including wOBA, wRC+, ISO,
            BB%, K%, and other offensive metrics for each team.
        """
        print(f"Pulling team batting data for {self.season}...")
        self.batting_data = team_batting(self.season)
        return self.batting_data

    def pull_team_pitching(self) -> pd.DataFrame:
        """
        Pull team-level pitching statistics from FanGraphs.

        Returns:
            DataFrame with columns including ERA, FIP, xFIP,
            SIERA, K/9, BB/9, and HR/9 for each team.
        """
        print(f"Pulling team pitching data for {self.season}...")
        self.pitching_data = team_pitching(self.season)
        return self.pitching_data

    def compute_team_offensive_rating(
        self, df: pd.DataFrame | None = None
    ) -> pd.DataFrame:
        """
        Compute a composite offensive rating for each team.

        The rating blends wOBA, isolated power (ISO), walk rate,
        and strikeout rate into a single index scaled to 100.

        Args:
            df: Optional pre-loaded batting DataFrame. If None,
                uses self.batting_data (must call pull_team_batting first).

        Returns:
            DataFrame with team abbreviations and offensive ratings.
        """
        if df is None:
            df = self.batting_data
        if df is None:
            raise ValueError("No batting data available. Call pull_team_batting first.")

        ratings = pd.DataFrame()
        ratings["Team"] = df["Team"]

        # Normalize each component to z-scores
        woba_z = (df["wOBA"] - df["wOBA"].mean()) / df["wOBA"].std()
        iso_z = (df["ISO"] - df["ISO"].mean()) / df["ISO"].std()
        bb_z = (df["BB%"] - df["BB%"].mean()) / df["BB%"].std()
        k_z = -(df["K%"] - df["K%"].mean()) / df["K%"].std()  # Negative: fewer K better

        # Weighted composite: wOBA dominates, supplemented by components
        composite = 0.50 * woba_z + 0.20 * iso_z + 0.15 * bb_z + 0.15 * k_z
        ratings["off_rating"] = 100 + 15 * composite  # Scale to mean=100, sd=15

        return ratings.sort_values("off_rating", ascending=False).reset_index(drop=True)

    def compute_team_pitching_rating(
        self, df: pd.DataFrame | None = None
    ) -> pd.DataFrame:
        """
        Compute a composite pitching rating for each team.

        Blends FIP, xFIP, K/9, and BB/9 into a single index.
        Lower is better for pitching, but the rating is scaled
        so that higher = better (inverted).

        Args:
            df: Optional pre-loaded pitching DataFrame.

        Returns:
            DataFrame with team abbreviations and pitching ratings.
        """
        if df is None:
            df = self.pitching_data
        if df is None:
            raise ValueError("No pitching data. Call pull_team_pitching first.")

        ratings = pd.DataFrame()
        ratings["Team"] = df["Team"]

        # For pitching, lower is better, so we negate
        fip_z = -(df["FIP"] - df["FIP"].mean()) / df["FIP"].std()
        xfip_z = -(df["xFIP"] - df["xFIP"].mean()) / df["xFIP"].std()
        k9_z = (df["K/9"] - df["K/9"].mean()) / df["K/9"].std()
        bb9_z = -(df["BB/9"] - df["BB/9"].mean()) / df["BB/9"].std()

        composite = 0.35 * fip_z + 0.30 * xfip_z + 0.20 * k9_z + 0.15 * bb9_z
        ratings["pitch_rating"] = 100 + 15 * composite

        return ratings.sort_values("pitch_rating", ascending=False).reset_index(drop=True)

    def compute_overall_team_power(self) -> pd.DataFrame:
        """
        Combine offensive and pitching ratings into overall team power.

        The overall rating is a weighted average: offense 45%,
        pitching 55%. Pitching is weighted slightly higher because
        it is more controllable and predictive game-to-game.

        Returns:
            DataFrame with Team, off_rating, pitch_rating, and
            overall_power columns.
        """
        off = self.compute_team_offensive_rating()
        pit = self.compute_team_pitching_rating()

        merged = off.merge(pit, on="Team", suffixes=("_off", "_pit"))
        merged["overall_power"] = (
            0.45 * merged["off_rating"] + 0.55 * merged["pitch_rating"]
        )
        return merged.sort_values("overall_power", ascending=False).reset_index(
            drop=True
        )


def pull_statcast_xwoba_leaders(
    start_date: str, end_date: str, min_pa: int = 50
) -> pd.DataFrame:
    """
    Pull Statcast data and compute xwOBA versus actual wOBA gaps.

    This function identifies hitters whose actual production
    diverges from their expected production based on contact
    quality, flagging regression candidates.

    Args:
        start_date: Start date in 'YYYY-MM-DD' format.
        end_date: End date in 'YYYY-MM-DD' format.
        min_pa: Minimum plate appearances to include.

    Returns:
        DataFrame with batter names, actual wOBA, xwOBA,
        and the gap between them, sorted by gap magnitude.
    """
    print(f"Pulling Statcast data from {start_date} to {end_date}...")
    data = statcast(start_dt=start_date, end_dt=end_date)

    # Filter to batted ball events with valid xwOBA
    batted = data[data["estimated_woba_using_speedangle"].notna()].copy()

    # Group by batter
    batter_stats = (
        batted.groupby(["batter", "player_name"])
        .agg(
            pa=("estimated_woba_using_speedangle", "count"),
            xwoba=("estimated_woba_using_speedangle", "mean"),
            actual_woba=("woba_value", "mean"),
        )
        .reset_index()
    )

    batter_stats = batter_stats[batter_stats["pa"] >= min_pa].copy()
    batter_stats["woba_gap"] = batter_stats["actual_woba"] - batter_stats["xwoba"]
    batter_stats = batter_stats.sort_values("woba_gap", key=abs, ascending=False)

    return batter_stats[["player_name", "pa", "actual_woba", "xwoba", "woba_gap"]]


# --- Example Usage ---
if __name__ == "__main__":
    pipeline = MLBSabermetricPipeline(season=2025)

    # Pull team-level data
    pipeline.pull_team_batting()
    pipeline.pull_team_pitching()

    # Compute power ratings
    power = pipeline.compute_overall_team_power()
    print("\n=== Team Power Rankings ===")
    print(power[["Team", "off_rating", "pitch_rating", "overall_power"]].to_string(
        index=False, float_format="{:.1f}".format
    ))

    # Find regression candidates from Statcast
    regression = pull_statcast_xwoba_leaders("2025-04-01", "2025-06-15", min_pa=100)
    print("\n=== Biggest xwOBA vs wOBA Gaps (Regression Candidates) ===")
    print(regression.head(15).to_string(index=False, float_format="{:.3f}".format))

Stabilization Rates and Predictive Horizons

Not all statistics stabilize at the same rate. Stabilization refers to the number of plate appearances or innings pitched at which a metric becomes more signal than noise (reliability exceeds 0.50). This matters enormously for in-season betting models, because it determines when you should trust a metric and when you should regress to preseason projections.

Metric Approximate Stabilization (PA/IP) Betting Implication
Strikeout rate (K%) ~60 PA / ~70 IP Trust early; reliable signal
Walk rate (BB%) ~120 PA / ~170 IP Trust by May for regulars
HR rate (HR/FB) ~300 PA / ~300 IP Slow; use xHR or Statcast data
BABIP ~800+ PA / ~2000+ IP Very slow; heavy regression needed
wOBA ~350 PA Moderate; blend with projections
FIP ~200 IP Moderate; useful by mid-season
Exit velocity ~50 batted balls Fast; usable quickly

Market Insight: Early in the season (April-May), batting average and ERA are dominated by noise. Betting markets often overreact to hot and cold starts. A hitter batting .180 in April with strong xwOBA and exit velocity metrics is a regression candidate whose team may be undervalued. Conversely, a pitcher with a 1.80 ERA but a 4.00 FIP is on borrowed time. The informed bettor trades on the underlying quality, not the noisy surface statistics.


17.2 Pitcher Modeling and Matchup Analysis

The Pitcher as the Primary Driver

In no other major team sport does a single player dominate game outcomes the way a starting pitcher does in baseball. A starting pitcher typically faces 20-27 batters and accounts for 5-7 innings --- 55-78% of the game. The difference between an ace (3.00 FIP) and a replacement-level pitcher (5.50 FIP) translates to roughly 2.5 runs over a full game, which shifts the expected run total and moneyline dramatically.

Betting markets reflect this: the moneyline on an MLB game can swing by 50 to 100 cents depending on which pitcher is announced. When a team scratches its ace and replaces him with a back-end starter, the line can move from -180 to -110 in minutes. No other sport-specific variable carries this much weight.

Building a Pitcher Quality Score

A pitcher quality score (PQS) reduces multiple pitching metrics to a single predictive number. We build ours from components that are both predictive and relatively quick to stabilize:

$$\text{PQS} = w_1 \cdot z_{\text{FIP}} + w_2 \cdot z_{\text{K\%}} + w_3 \cdot z_{\text{BB\%}} + w_4 \cdot z_{\text{GB\%}} + w_5 \cdot z_{\text{Stuff+}}$$

where each $z$ is the standardized (z-score) version of the metric, and the weights are calibrated to maximize correlation with future run prevention. Typical weights from empirical research:

$$w_1 = 0.30, \quad w_2 = 0.25, \quad w_3 = 0.20, \quad w_4 = 0.10, \quad w_5 = 0.15$$

Handedness and Platoon Splits

The platoon advantage --- the tendency for hitters to perform better against opposite-handed pitchers --- is one of the most robust effects in baseball. On average:

  • Left-handed hitters vs. right-handed pitchers: wOBA ~ .330
  • Left-handed hitters vs. left-handed pitchers: wOBA ~ .305
  • Right-handed hitters vs. left-handed pitchers: wOBA ~ .335
  • Right-handed hitters vs. right-handed pitchers: wOBA ~ .315

The difference of approximately 25-30 points of wOBA translates to roughly 0.3-0.4 runs per game at the lineup level. For betting purposes, the platoon composition of a lineup against a specific pitcher's handedness affects expected run production and thus the total and moneyline.

Bullpen Modeling

The bullpen accounts for 22-45% of innings in a typical game and an increasing share in the modern era. Modeling the bullpen requires:

  1. Identifying likely usage patterns: Managers typically deploy their best relievers in high-leverage situations. The concept of leverage index (LI) --- which measures the importance of a situation based on inning, score, and baserunners --- helps weight bullpen contributions appropriately.

  2. Rest and availability: Relievers who pitched the previous day (or on consecutive days) are typically less effective or unavailable entirely. Tracking recent bullpen usage is critical.

  3. Composite bullpen metrics: We compute a team bullpen rating as the innings-weighted average FIP of all relievers, with adjustments for leverage:

$$\text{Bullpen Rating} = \frac{\sum_{i} \text{IP}_i \times \text{LI}_i \times \text{FIP}_i}{\sum_{i} \text{IP}_i \times \text{LI}_i}$$

Pitch Arsenal Analysis

Modern pitchers throw multiple pitch types, and the effectiveness of each pitch varies against different hitter profiles. Statcast data allows us to evaluate each pitch independently:

  • Fastball velocity and induced vertical break (IVB): Higher velocity and more rise (IVB) correlate with more whiffs on fastballs.
  • Slider sweep and depth: Horizontal movement (sweep) generates more whiffs from same-side hitters.
  • Changeup velocity differential: The gap between fastball and changeup velocity creates deception.

A pitcher whose arsenal plays up against a particular lineup composition (e.g., a lefty with a devastating slider facing a lineup stacked with left-handed hitters) may be undervalued by simple FIP-based models.

Python Code: Pitcher Matchup Model

"""
MLB Pitcher Matchup Analysis Model

Builds pitcher quality scores, evaluates platoon matchups,
and estimates expected run production against specific lineups.

Requirements:
    pip install pybaseball pandas numpy scipy
"""

import pandas as pd
import numpy as np
from scipy import stats as sp_stats
from pybaseball import pitching_stats, playerid_lookup, statcast_pitcher


class PitcherProfile:
    """
    Comprehensive pitcher profile for matchup analysis.

    Combines FanGraphs-level statistics with Statcast pitch
    data to produce a multidimensional pitcher evaluation
    suitable for game-level prediction.

    Attributes:
        name: Pitcher's full name.
        hand: 'L' or 'R' for throwing hand.
        fip: Fielding Independent Pitching.
        k_pct: Strikeout percentage.
        bb_pct: Walk percentage.
        gb_pct: Ground ball percentage.
        hr_fb_pct: Home run per fly ball percentage.
        quality_score: Composite pitcher quality score.
    """

    def __init__(
        self,
        name: str,
        hand: str,
        fip: float,
        k_pct: float,
        bb_pct: float,
        gb_pct: float,
        hr_fb_pct: float,
        stuff_plus: float = 100.0,
        ip_season: float = 0.0,
    ):
        self.name = name
        self.hand = hand
        self.fip = fip
        self.k_pct = k_pct
        self.bb_pct = bb_pct
        self.gb_pct = gb_pct
        self.hr_fb_pct = hr_fb_pct
        self.stuff_plus = stuff_plus
        self.ip_season = ip_season
        self.quality_score = self._compute_quality_score()

    def _compute_quality_score(self) -> float:
        """
        Compute composite quality score from pitcher metrics.

        The score is constructed so that higher is better,
        centered at 100 (league average), with a standard
        deviation of approximately 15.

        Returns:
            Composite quality score as a float.
        """
        # League-average reference points (approximate)
        lg_fip, lg_fip_sd = 4.20, 0.70
        lg_k, lg_k_sd = 22.0, 5.0
        lg_bb, lg_bb_sd = 8.0, 2.0
        lg_gb, lg_gb_sd = 43.0, 5.0
        lg_stuff, lg_stuff_sd = 100.0, 15.0

        z_fip = -(self.fip - lg_fip) / lg_fip_sd  # Negative: lower FIP = better
        z_k = (self.k_pct - lg_k) / lg_k_sd
        z_bb = -(self.bb_pct - lg_bb) / lg_bb_sd  # Negative: lower BB = better
        z_gb = (self.gb_pct - lg_gb) / lg_gb_sd
        z_stuff = (self.stuff_plus - lg_stuff) / lg_stuff_sd

        composite = (
            0.30 * z_fip
            + 0.25 * z_k
            + 0.20 * z_bb
            + 0.10 * z_gb
            + 0.15 * z_stuff
        )
        return round(100 + 15 * composite, 1)

    def __repr__(self) -> str:
        return (
            f"PitcherProfile({self.name}, {self.hand}HP, "
            f"FIP={self.fip:.2f}, QS={self.quality_score:.1f})"
        )


class MatchupAnalyzer:
    """
    Analyzes the matchup between a starting pitcher and an
    opposing lineup, accounting for handedness platoon effects.

    Attributes:
        league_avg_woba: League average wOBA for the season.
    """

    # Platoon adjustment factors (multiplicative)
    # Relative to league average performance
    PLATOON_FACTORS = {
        ("L", "L"): 0.92,   # LHP vs LHB: hitters disadvantaged
        ("L", "R"): 1.06,   # LHP vs RHB: hitters advantaged
        ("R", "L"): 1.05,   # RHP vs LHB: hitters advantaged
        ("R", "R"): 0.96,   # RHP vs RHB: hitters disadvantaged
    }

    def __init__(self, league_avg_woba: float = 0.315):
        self.league_avg_woba = league_avg_woba

    def estimate_woba_against(
        self,
        pitcher: PitcherProfile,
        batter_hand: str,
        batter_woba: float,
    ) -> float:
        """
        Estimate a batter's expected wOBA against a specific pitcher.

        Combines the batter's overall skill (wOBA), the pitcher's
        quality score, and the platoon matchup factor.

        The logic follows the Odds Ratio method:
            expected = (batter * pitcher_factor * platoon) / league

        Args:
            pitcher: The pitcher profile.
            batter_hand: 'L', 'R', or 'S' (switch hitter).
            batter_woba: The batter's season wOBA.

        Returns:
            Estimated wOBA for this matchup.
        """
        # Convert pitcher quality score to a wOBA-against factor
        # A QS of 115 means the pitcher is ~1 SD above average
        # which translates to allowing ~85% of league-avg wOBA
        pitcher_factor = 1.0 - (pitcher.quality_score - 100) / 100 * 0.5

        # Determine batter's effective handedness against this pitcher
        if batter_hand == "S":
            # Switch hitters bat opposite to pitcher hand
            effective_hand = "R" if pitcher.hand == "L" else "L"
        else:
            effective_hand = batter_hand

        platoon = self.PLATOON_FACTORS.get(
            (pitcher.hand, effective_hand), 1.0
        )

        # Odds Ratio method for combining factors
        batter_ratio = batter_woba / self.league_avg_woba
        expected = self.league_avg_woba * batter_ratio * pitcher_factor * platoon

        return round(min(max(expected, 0.150), 0.550), 3)

    def estimate_lineup_woba(
        self,
        pitcher: PitcherProfile,
        lineup: list[dict],
    ) -> float:
        """
        Estimate the aggregate expected wOBA for an entire lineup.

        Args:
            pitcher: The opposing starting pitcher.
            lineup: List of dicts with 'name', 'hand', 'woba' keys.

        Returns:
            Weighted average expected wOBA across the lineup.
        """
        woba_estimates = []
        for batter in lineup:
            est = self.estimate_woba_against(
                pitcher, batter["hand"], batter["woba"]
            )
            woba_estimates.append(est)

        return round(np.mean(woba_estimates), 3)

    def woba_to_runs_per_game(
        self,
        lineup_woba: float,
        pa_per_game: float = 38.0,
    ) -> float:
        """
        Convert a lineup's expected wOBA to runs per game.

        Uses the approximate linear relationship between team
        wOBA and runs scored per plate appearance, scaled
        to a full game.

        The conversion factor is derived from the wOBA scale:
            runs per PA ~ (wOBA - 0.180) / 1.15

        Args:
            lineup_woba: The lineup's expected wOBA.
            pa_per_game: Average plate appearances per team per game.

        Returns:
            Expected runs scored per game.
        """
        runs_per_pa = (lineup_woba - 0.180) / 1.15
        return round(max(runs_per_pa * pa_per_game, 1.5), 2)


class BullpenModel:
    """
    Models bullpen contribution for estimating total game runs.

    Assumes the starting pitcher covers a specified number of
    innings, with the bullpen covering the remainder.
    """

    def __init__(
        self,
        team_bullpen_fip: float,
        recent_usage_innings: float = 0.0,
    ):
        """
        Args:
            team_bullpen_fip: Team bullpen's aggregate FIP.
            recent_usage_innings: Bullpen innings in last 2 days
                (for fatigue adjustment).
        """
        self.bullpen_fip = team_bullpen_fip
        self.recent_usage = recent_usage_innings

    def fatigue_adjustment(self) -> float:
        """
        Compute fatigue multiplier based on recent bullpen usage.

        Heavy recent usage (>6 innings in 2 days) degrades
        expected performance by up to 15%.

        Returns:
            Multiplicative adjustment factor (>= 1.0 means worse).
        """
        if self.recent_usage <= 4.0:
            return 1.00
        elif self.recent_usage <= 6.0:
            return 1.05
        elif self.recent_usage <= 8.0:
            return 1.10
        else:
            return 1.15

    def expected_bullpen_runs(
        self,
        innings: float,
    ) -> float:
        """
        Estimate runs allowed by the bullpen over given innings.

        Args:
            innings: Number of innings the bullpen is expected to cover.

        Returns:
            Expected runs allowed.
        """
        # FIP approximates earned runs per 9 innings
        runs_per_inning = (self.bullpen_fip * self.fatigue_adjustment()) / 9.0
        return round(runs_per_inning * innings, 2)


# --- Worked Example ---
if __name__ == "__main__":
    # Define a starting pitcher
    ace = PitcherProfile(
        name="Gerrit Cole",
        hand="R",
        fip=2.95,
        k_pct=30.5,
        bb_pct=5.2,
        gb_pct=40.0,
        hr_fb_pct=10.5,
        stuff_plus=118,
        ip_season=120.0,
    )
    print(f"Pitcher: {ace}")
    print(f"Quality Score: {ace.quality_score}")

    # Define an opposing lineup
    opposing_lineup = [
        {"name": "Mullins", "hand": "S", "woba": .320},
        {"name": "Rutschman", "hand": "S", "woba": .365},
        {"name": "Santander", "hand": "S", "woba": .355},
        {"name": "Henderson", "hand": "L", "woba": .375},
        {"name": "Mountcastle", "hand": "R", "woba": .330},
        {"name": "Holliday", "hand": "R", "woba": .310},
        {"name": "Westburg", "hand": "R", "woba": .325},
        {"name": "Cowser", "hand": "L", "woba": .305},
        {"name": "Mateo", "hand": "R", "woba": .280},
    ]

    analyzer = MatchupAnalyzer()
    lineup_woba = analyzer.estimate_lineup_woba(ace, opposing_lineup)
    runs_vs_starter = analyzer.woba_to_runs_per_game(lineup_woba, pa_per_game=25)

    print(f"\nLineup expected wOBA vs {ace.name}: {lineup_woba}")
    print(f"Expected runs vs starter (~6 IP): {runs_vs_starter}")

    # Bullpen contribution
    bp = BullpenModel(team_bullpen_fip=3.85, recent_usage_innings=5.0)
    bp_runs = bp.expected_bullpen_runs(innings=3.0)
    print(f"Expected bullpen runs (3 IP): {bp_runs}")
    print(f"Total expected runs allowed: {runs_vs_starter + bp_runs:.2f}")

Worked Example: Pitcher Quality Score in Action

Consider two pitchers with very different profiles:

Pitcher A: FIP 3.10, K% 28.0, BB% 6.5, GB% 48.0, Stuff+ 112 Pitcher B: FIP 4.40, K% 18.5, BB% 9.0, GB% 38.0, Stuff+ 92

Computing quality scores:

For Pitcher A: - $z_{\text{FIP}} = -(3.10 - 4.20)/0.70 = +1.57$ - $z_{\text{K\%}} = (28.0 - 22.0)/5.0 = +1.20$ - $z_{\text{BB\%}} = -(6.5 - 8.0)/2.0 = +0.75$ - $z_{\text{GB\%}} = (48.0 - 43.0)/5.0 = +1.00$ - $z_{\text{Stuff+}} = (112 - 100)/15 = +0.80$

$$\text{QS}_A = 100 + 15 \times (0.30 \times 1.57 + 0.25 \times 1.20 + 0.20 \times 0.75 + 0.10 \times 1.00 + 0.15 \times 0.80)$$ $$= 100 + 15 \times (0.471 + 0.300 + 0.150 + 0.100 + 0.120)$$ $$= 100 + 15 \times 1.141 = 117.1$$

For Pitcher B: - $z_{\text{FIP}} = -(4.40 - 4.20)/0.70 = -0.29$ - $z_{\text{K\%}} = (18.5 - 22.0)/5.0 = -0.70$ - $z_{\text{BB\%}} = -(9.0 - 8.0)/2.0 = -0.50$ - $z_{\text{GB\%}} = (38.0 - 43.0)/5.0 = -1.00$ - $z_{\text{Stuff+}} = (92 - 100)/15 = -0.53$

$$\text{QS}_B = 100 + 15 \times (0.30 \times (-0.29) + 0.25 \times (-0.70) + 0.20 \times (-0.50) + 0.10 \times (-1.00) + 0.15 \times (-0.53))$$ $$= 100 + 15 \times (-0.087 - 0.175 - 0.100 - 0.100 - 0.080)$$ $$= 100 + 15 \times (-0.542) = 91.9$$

The difference of 25.2 quality score points translates to approximately 1.8-2.2 expected runs over a full game, which is the difference between a strong favorite and a near pick-em on the moneyline.

Common Pitfall: Small-sample batter-vs-pitcher matchup history (e.g., "Hitter X is 7-for-12 lifetime against Pitcher Y") is largely noise. With an average outcome variance as high as batting results have, 12 plate appearances carry almost no predictive power. Use matchup-based reasoning only at the skill level (e.g., "this hitter struggles against high-velocity fastballs, and this pitcher throws 97 mph") rather than historical head-to-head records.


17.3 Park Factors and Environmental Adjustments

Why Parks Matter

MLB is unique among major sports in that its venues have non-standardized playing surfaces. Outfield dimensions, wall heights, foul territory area, and altitude vary enormously between parks. Coors Field in Denver (5,280 feet elevation) inflates offense by 30-40% compared to a neutral park. Oracle Park in San Francisco, with its cavernous right field and heavy marine air, suppresses home runs by 15-20%.

Park factors quantify these effects. A park factor of 1.10 for runs means that park produces 10% more runs than a neutral environment. For bettors, failing to account for park factors is one of the most common and costly modeling errors.

Calculating Park Factors

The basic park factor formula for runs is:

$$\text{PF}_{\text{runs}} = \frac{\text{Runs per game at home}}{\text{Runs per game on road}}$$

More precisely, to avoid biases from schedule strength:

$$\text{PF}_{\text{runs}} = \frac{\frac{\text{Home RS} + \text{Home RA}}{\text{Home Games}}}{\frac{\text{Road RS} + \text{Road RA}}{\text{Road Games}}}$$

This is typically calculated over 3-5 years to reduce noise. Single-season park factors are volatile and should be regressed toward 1.0 (neutral).

Multi-year regression-adjusted park factors (approximate, as of recent seasons):

Park Team Run PF HR PF
Coors Field COL 1.35 1.40
Great American Ball Park CIN 1.10 1.20
Fenway Park BOS 1.05 0.95
Globe Life Field TEX 1.02 1.05
Yankee Stadium NYY 1.00 1.15
T-Mobile Park SEA 0.93 0.85
Oracle Park SFG 0.92 0.80
Petco Park SDP 0.95 0.88
Tropicana Field TBR 0.97 0.95
Oakland Coliseum OAK 0.94 0.90

Altitude, Temperature, Humidity, and Wind

Beyond structural park differences, environmental conditions affect ball flight and pitcher grip:

Altitude: Higher altitude means thinner air, which reduces drag on the baseball. At Coors Field, balls travel approximately 5-9% farther than at sea level, all else equal. The effect on fly balls is dramatic: a fly ball that travels 380 feet at sea level travels approximately 400-410 feet in Denver.

The drag reduction can be modeled as:

$$d_{\text{adjusted}} = d_{\text{sea level}} \times \left(\frac{\rho_{\text{sea level}}}{\rho_{\text{altitude}}}\right)^{0.38}$$

where $\rho$ is air density and the exponent 0.38 is empirically derived for baseballs.

Temperature: Warmer air is less dense. A ball hit in 95-degree weather travels approximately 6-8 feet farther than the same contact in 55-degree weather. This effect is meaningful: the difference between a warning-track fly out and a home run is often just 5-10 feet.

Humidity: Contrary to popular belief, humid air is less dense than dry air (water vapor is lighter than nitrogen or oxygen). Higher humidity slightly increases ball flight. However, humidity also affects the baseball itself --- a more humid ball may be slightly heavier and deader. The net effect is small and debated.

Wind: Wind is the most impactful weather variable for a single game. A 15 mph wind blowing out to center field can add 20-30 feet to fly ball distance, dramatically increasing home run probability. Conversely, a strong wind blowing in suppresses home runs.

Python Code: Park-Adjusted Projections

"""
Park Factor and Environmental Adjustment Module

Computes park-adjusted run projections incorporating
venue characteristics, temperature, wind, and altitude.

Requirements:
    pip install pandas numpy requests
"""

import numpy as np
import pandas as pd
from dataclasses import dataclass


@dataclass
class ParkProfile:
    """
    Defines the static and environmental characteristics of an MLB park.

    Attributes:
        name: Park name.
        team: Home team abbreviation.
        base_run_factor: Multi-year park factor for runs.
        base_hr_factor: Multi-year park factor for home runs.
        altitude_ft: Elevation in feet above sea level.
        is_dome: Whether the park has a closed roof.
    """
    name: str
    team: str
    base_run_factor: float
    base_hr_factor: float
    altitude_ft: float
    is_dome: bool


# Reference park profiles
PARK_PROFILES = {
    "COL": ParkProfile("Coors Field", "COL", 1.35, 1.40, 5280, False),
    "CIN": ParkProfile("Great American Ball Park", "CIN", 1.10, 1.20, 480, False),
    "BOS": ParkProfile("Fenway Park", "BOS", 1.05, 0.95, 20, False),
    "NYY": ParkProfile("Yankee Stadium", "NYY", 1.00, 1.15, 55, False),
    "TEX": ParkProfile("Globe Life Field", "TEX", 1.02, 1.05, 500, True),
    "SEA": ParkProfile("T-Mobile Park", "SEA", 0.93, 0.85, 20, True),
    "SFG": ParkProfile("Oracle Park", "SFG", 0.92, 0.80, 5, False),
    "SDP": ParkProfile("Petco Park", "SDP", 0.95, 0.88, 15, False),
    "MIA": ParkProfile("loanDepot Park", "MIA", 0.94, 0.85, 10, True),
    "MIL": ParkProfile("American Family Field", "MIL", 1.02, 1.08, 600, True),
    "HOU": ParkProfile("Minute Maid Park", "HOU", 1.00, 1.02, 45, True),
    "PHI": ParkProfile("Citizens Bank Park", "PHI", 1.04, 1.12, 20, False),
    "CHC": ParkProfile("Wrigley Field", "CHC", 1.05, 1.10, 595, False),
    "ATL": ParkProfile("Truist Park", "ATL", 1.00, 1.02, 1050, False),
    "LAD": ParkProfile("Dodger Stadium", "LAD", 0.97, 0.92, 515, False),
}


class EnvironmentalAdjuster:
    """
    Adjusts park factors for real-time environmental conditions.

    Incorporates temperature, wind speed and direction, and
    humidity to produce a dynamic, game-specific park factor.
    """

    # Baseline conditions (approximately league average)
    BASELINE_TEMP_F = 72.0
    BASELINE_WIND_MPH = 5.0
    BASELINE_HUMIDITY_PCT = 50.0

    # Adjustment coefficients (per unit deviation from baseline)
    # Derived from empirical studies of batted ball distance
    TEMP_COEFF = 0.002      # +0.2% per degree F above baseline
    WIND_OUT_COEFF = 0.008  # +0.8% per mph wind blowing out
    WIND_IN_COEFF = -0.010  # -1.0% per mph wind blowing in
    HUMIDITY_COEFF = 0.0003 # +0.03% per % humidity above baseline

    def compute_adjustment(
        self,
        park: ParkProfile,
        temp_f: float,
        wind_mph: float,
        wind_direction: str,
        humidity_pct: float,
    ) -> float:
        """
        Compute the environmental adjustment multiplier.

        Args:
            park: The park profile for the venue.
            temp_f: Game-time temperature in Fahrenheit.
            wind_mph: Wind speed in mph.
            wind_direction: One of 'out', 'in', 'cross', 'calm'.
            humidity_pct: Relative humidity as a percentage.

        Returns:
            Multiplicative adjustment factor. A value of 1.05
            means conditions increase run scoring by 5% beyond
            the base park factor.
        """
        if park.is_dome:
            # Domed stadiums have controlled environments
            return 1.00

        adjustment = 0.0

        # Temperature effect
        temp_delta = temp_f - self.BASELINE_TEMP_F
        adjustment += temp_delta * self.TEMP_COEFF

        # Wind effect (depends on direction)
        if wind_direction == "out":
            adjustment += wind_mph * self.WIND_OUT_COEFF
        elif wind_direction == "in":
            adjustment += wind_mph * self.WIND_IN_COEFF
        elif wind_direction == "cross":
            adjustment += wind_mph * self.WIND_OUT_COEFF * 0.3  # Reduced effect
        # 'calm' adds nothing

        # Humidity effect (small)
        humidity_delta = humidity_pct - self.BASELINE_HUMIDITY_PCT
        adjustment += humidity_delta * self.HUMIDITY_COEFF

        return round(1.0 + adjustment, 4)


class ParkAdjustedProjection:
    """
    Produces park-and-weather-adjusted run projections.

    Combines a neutral-site run projection with the park's
    base factor and real-time environmental adjustments.
    """

    def __init__(self):
        self.env_adjuster = EnvironmentalAdjuster()

    def project_runs(
        self,
        neutral_runs: float,
        park_team: str,
        temp_f: float = 72.0,
        wind_mph: float = 5.0,
        wind_direction: str = "calm",
        humidity_pct: float = 50.0,
    ) -> dict[str, float]:
        """
        Project runs for a team in a specific park and weather.

        Args:
            neutral_runs: Expected runs at a neutral site.
            park_team: Team abbreviation for the venue.
            temp_f: Temperature at game time.
            wind_mph: Wind speed at game time.
            wind_direction: Wind direction ('out', 'in', 'cross', 'calm').
            humidity_pct: Relative humidity percentage.

        Returns:
            Dictionary with neutral, park-adjusted, and
            weather-adjusted run projections.
        """
        park = PARK_PROFILES.get(park_team)
        if park is None:
            # Unknown park: use neutral factors
            park = ParkProfile("Unknown", park_team, 1.00, 1.00, 0, False)

        # Apply base park factor
        park_adjusted = neutral_runs * park.base_run_factor

        # Apply environmental adjustment
        env_factor = self.env_adjuster.compute_adjustment(
            park, temp_f, wind_mph, wind_direction, humidity_pct
        )
        weather_adjusted = park_adjusted * env_factor

        return {
            "neutral_runs": round(neutral_runs, 2),
            "park_factor": park.base_run_factor,
            "env_factor": env_factor,
            "park_adjusted_runs": round(park_adjusted, 2),
            "weather_adjusted_runs": round(weather_adjusted, 2),
        }

    def project_game_total(
        self,
        home_neutral_runs: float,
        away_neutral_runs: float,
        park_team: str,
        temp_f: float = 72.0,
        wind_mph: float = 5.0,
        wind_direction: str = "calm",
        humidity_pct: float = 50.0,
    ) -> dict[str, float]:
        """
        Project the total runs for a complete game.

        Args:
            home_neutral_runs: Home team's neutral-site run expectation.
            away_neutral_runs: Away team's neutral-site run expectation.
            park_team: Home team abbreviation (determines venue).
            temp_f: Temperature at game time.
            wind_mph: Wind speed.
            wind_direction: Wind direction.
            humidity_pct: Relative humidity.

        Returns:
            Dictionary with projected total, home runs, and away runs.
        """
        home_proj = self.project_runs(
            home_neutral_runs, park_team, temp_f, wind_mph,
            wind_direction, humidity_pct
        )
        away_proj = self.project_runs(
            away_neutral_runs, park_team, temp_f, wind_mph,
            wind_direction, humidity_pct
        )

        total = home_proj["weather_adjusted_runs"] + away_proj["weather_adjusted_runs"]

        return {
            "home_runs": home_proj["weather_adjusted_runs"],
            "away_runs": away_proj["weather_adjusted_runs"],
            "projected_total": round(total, 2),
            "park_factor": home_proj["park_factor"],
            "env_factor": home_proj["env_factor"],
        }


# --- Worked Example ---
if __name__ == "__main__":
    projector = ParkAdjustedProjection()

    # Example 1: Game at Coors Field on a hot day with wind out
    print("=== Coors Field: Hot Day, Wind Blowing Out ===")
    result = projector.project_game_total(
        home_neutral_runs=4.5,
        away_neutral_runs=4.2,
        park_team="COL",
        temp_f=92,
        wind_mph=12,
        wind_direction="out",
        humidity_pct=25,
    )
    for key, val in result.items():
        print(f"  {key}: {val}")

    # Example 2: Game at Oracle Park on a cool, foggy evening
    print("\n=== Oracle Park: Cool Night, Wind Blowing In ===")
    result = projector.project_game_total(
        home_neutral_runs=4.5,
        away_neutral_runs=4.2,
        park_team="SFG",
        temp_f=58,
        wind_mph=15,
        wind_direction="in",
        humidity_pct=80,
    )
    for key, val in result.items():
        print(f"  {key}: {val}")

    # Example 3: Dome game (weather irrelevant)
    print("\n=== Minute Maid Park: Dome (Weather Irrelevant) ===")
    result = projector.project_game_total(
        home_neutral_runs=4.5,
        away_neutral_runs=4.2,
        park_team="HOU",
        temp_f=95,  # Irrelevant for dome
        wind_mph=20,  # Irrelevant for dome
        wind_direction="out",
        humidity_pct=90,
    )
    for key, val in result.items():
        print(f"  {key}: {val}")

The Coors Field Problem

Coors Field deserves special attention because its effects are so extreme. The thin air at 5,280 feet affects the game in multiple ways:

  1. Reduced air resistance: Batted balls travel farther and faster. Breaking pitches break less. Fastballs lose less velocity but also have less "life."

  2. Inflated statistics: Rockies hitters accumulate counting stats at home that do not travel. Rockies pitchers appear worse than they are. Naive models that use raw statistics will systematically overvalue Rockies hitters and undervalue Rockies pitchers.

  3. The "Coors hangover": Some evidence suggests that Rockies hitters perform slightly worse on the road than their true talent level, possibly because their timing calibration at altitude does not transfer perfectly to sea-level parks.

  4. Humidor effect: Since 2002, the Rockies have stored baseballs in a humidor to increase moisture content and reduce the ball's coefficient of restitution. This has modestly reduced the park's run-scoring extremes but has not eliminated them.

For betting totals, the key insight is that Coors Field totals are typically set at 11-13 (compared to 8-9 elsewhere), and the market generally prices the altitude effect accurately. The real value comes from daily weather deviations: a 95-degree day with wind blowing out at Coors can push expected scoring well above even the inflated posted total, while a 55-degree evening with wind blowing in can suppress scoring below the number.

Market Insight: Recreational bettors systematically overbet overs at Coors Field because the park's reputation for high scoring is so well known. The market has adjusted. In recent years, unders at Coors Field have been profitable more often than overs, precisely because the posted totals already account for (and sometimes overcorrect for) the altitude effect. Look for value by betting against the narrative, especially on cool evenings with favorable pitching matchups.


17.4 Run Line and Totals Models

Run-Scoring Distributions

The foundation of all MLB betting models is the distribution of runs scored per team per game. The two most common distributional assumptions are the Poisson distribution and the negative binomial distribution.

The Poisson Model

The Poisson distribution models the number of occurrences of a random event in a fixed interval. For baseball, we model runs scored per game as a Poisson random variable:

$$P(X = k) = \frac{\lambda^k e^{-\lambda}}{k!}$$

where $\lambda$ is the expected number of runs and $k$ is the actual number of runs scored.

The Poisson model has one parameter ($\lambda$) and assumes that: 1. Runs are scored independently of one another 2. The rate of scoring is constant throughout the game 3. Two runs cannot be scored at exactly the same instant

Assumption 1 is violated (runs tend to cluster in baseball due to multi-run innings), and assumption 2 is violated (run-scoring rates vary by inning and game state). However, the Poisson model remains a useful first approximation.

For a game where Team A has $\lambda_A = 4.5$ and Team B has $\lambda_B = 3.8$:

  • $P(\text{Team A scores exactly 4}) = \frac{4.5^4 \cdot e^{-4.5}}{4!} = \frac{410.06 \cdot 0.0111}{24} = 0.1898$
  • $P(\text{Team A scores 0}) = e^{-4.5} = 0.0111$
  • $P(\text{total} \geq 9) = 1 - P(\text{total} \leq 8)$, computed by convolving the two Poisson distributions

The Negative Binomial Model

The negative binomial distribution is a more flexible alternative that allows for overdispersion --- greater variance than the Poisson model predicts. In practice, MLB run distributions are slightly overdispersed because of run-clustering effects.

$$P(X = k) = \binom{k + r - 1}{k} p^r (1-p)^k$$

where $r$ is the dispersion parameter and $p$ is related to the mean. The mean is $\mu = r(1-p)/p$ and the variance is $\mu + \mu^2/r$. When $r \to \infty$, the negative binomial converges to the Poisson.

Empirically, the negative binomial with $r \approx 5$-$8$ fits MLB run distributions better than the Poisson, particularly in the tails (very high-scoring and shutout games).

Moneyline Pricing from Run Distributions

Once we have run distributions for each team, we can compute win probabilities directly:

$$P(\text{A wins}) = \sum_{a=1}^{\infty} \sum_{b=0}^{a-1} P(X_A = a) \cdot P(X_B = b)$$

$$P(\text{B wins}) = \sum_{b=1}^{\infty} \sum_{a=0}^{b-1} P(X_A = a) \cdot P(X_B = b)$$

$$P(\text{tie}) = \sum_{k=0}^{\infty} P(X_A = k) \cdot P(X_B = k)$$

In practice, ties are resolved by extra innings, so the moneyline probability requires an extra-innings model. A simple approach allocates tie probability proportionally to each team's win probability, which is roughly correct since extra innings are close to a coin flip with a slight edge for the team that performed better in regulation.

Run Line (+1.5) Modeling

The MLB run line is the equivalent of the point spread, standardized at +/- 1.5 runs. The favorite is listed at -1.5 (must win by 2 or more) and the underdog at +1.5 (must win outright or lose by exactly 1).

$$P(\text{favorite covers } -1.5) = \sum_{a=2}^{\infty} \sum_{b=0}^{a-2} P(X_A = a) \cdot P(X_B = b)$$

Alternatively:

$$P(\text{underdog covers } +1.5) = P(\text{underdog wins}) + P(\text{margin} = 1)$$

The run line is one of the most interesting markets in baseball because the relationship between moneyline probability and run line probability is non-linear. A -200 moneyline favorite (approximately 67% win probability) covers -1.5 only about 55% of the time, because many wins are by exactly 1 run. This means that heavy favorites on the run line often offer better value than the moneyline, and heavy underdogs on the +1.5 run line are frequently overpriced.

First Five Innings (F5) Bets

First five innings bets settle based on the score after 5 complete innings, eliminating bullpen variance. Since the starting pitcher typically covers most or all of the first 5 innings, F5 bets are purer tests of the starting pitcher matchup model.

F5 lines are attractive because: 1. They isolate the variable we model best (starting pitcher quality) 2. They reduce variance from bullpen performance, which is harder to predict 3. F5 totals are lower (typically 4-5 runs), where Poisson models are more accurate 4. Markets for F5 lines may be less efficient than full-game lines

The F5 run expectation can be estimated as approximately 55-60% of the full-game expectation, adjusted for the specific starters.

Python Code: Run Distribution Model

"""
MLB Run Distribution and Betting Market Model

Implements Poisson and negative binomial models for MLB
run scoring, then derives moneyline, run line, and totals
probabilities from the distributions.

Requirements:
    pip install numpy scipy pandas
"""

import numpy as np
from scipy.stats import poisson, nbinom
from scipy.optimize import minimize_scalar
from itertools import product


class RunDistributionModel:
    """
    Models MLB run scoring using Poisson or negative binomial
    distributions and derives betting market probabilities.

    Attributes:
        model_type: 'poisson' or 'negative_binomial'.
        nb_r: Dispersion parameter for negative binomial (if used).
        max_runs: Maximum runs to consider in calculations.
    """

    def __init__(
        self,
        model_type: str = "negative_binomial",
        nb_r: float = 6.0,
        max_runs: int = 20,
    ):
        """
        Args:
            model_type: 'poisson' or 'negative_binomial'.
            nb_r: Dispersion parameter for the negative binomial.
                Higher values make it more Poisson-like.
            max_runs: Upper bound for run calculations. Values
                above this threshold are treated as having zero
                probability (negligible for typical lambda values).
        """
        if model_type not in ("poisson", "negative_binomial"):
            raise ValueError("model_type must be 'poisson' or 'negative_binomial'")
        self.model_type = model_type
        self.nb_r = nb_r
        self.max_runs = max_runs

    def run_pmf(self, lam: float) -> np.ndarray:
        """
        Compute the probability mass function for runs scored.

        Args:
            lam: Expected (mean) runs for the team.

        Returns:
            Array of probabilities P(X=0), P(X=1), ..., P(X=max_runs).
        """
        k = np.arange(self.max_runs + 1)

        if self.model_type == "poisson":
            return poisson.pmf(k, lam)
        else:
            # Negative binomial parameterized by mean and dispersion
            # scipy's nbinom uses (n, p) where n=r, p=r/(r+mu)
            p = self.nb_r / (self.nb_r + lam)
            return nbinom.pmf(k, self.nb_r, p)

    def win_probability(
        self, lam_home: float, lam_away: float
    ) -> dict[str, float]:
        """
        Compute win probabilities for a game.

        Uses the joint distribution of runs scored by each team
        to calculate P(home win), P(away win), and P(tie in
        regulation).

        Args:
            lam_home: Expected runs for the home team.
            lam_away: Expected runs for the away team.

        Returns:
            Dictionary with 'home_win', 'away_win', and
            'regulation_tie' probabilities.
        """
        pmf_home = self.run_pmf(lam_home)
        pmf_away = self.run_pmf(lam_away)

        # Joint probability matrix: P(home=i, away=j)
        joint = np.outer(pmf_home, pmf_away)

        # Home wins: all cells where row > col
        home_win = np.sum(np.tril(joint, k=-1))
        # Away wins: all cells where col > row
        away_win = np.sum(np.triu(joint, k=1))
        # Tie: diagonal
        tie = np.sum(np.diag(joint))

        # Allocate tie probability proportionally (extra innings approximation)
        total_decided = home_win + away_win
        if total_decided > 0:
            home_final = home_win + tie * (home_win / total_decided)
            away_final = away_win + tie * (away_win / total_decided)
        else:
            home_final = 0.5
            away_final = 0.5

        return {
            "home_win": round(home_final, 4),
            "away_win": round(away_final, 4),
            "regulation_tie": round(tie, 4),
        }

    def run_line_probability(
        self, lam_favorite: float, lam_underdog: float, line: float = -1.5
    ) -> dict[str, float]:
        """
        Compute run line (spread) probabilities.

        Args:
            lam_favorite: Expected runs for the favorite.
            lam_underdog: Expected runs for the underdog.
            line: The run line for the favorite (typically -1.5).

        Returns:
            Dictionary with probabilities of the favorite covering
            and the underdog covering.
        """
        pmf_fav = self.run_pmf(lam_favorite)
        pmf_dog = self.run_pmf(lam_underdog)

        joint = np.outer(pmf_fav, pmf_dog)

        # Favorite covers -1.5: favorite wins by 2+
        fav_covers = 0.0
        for i in range(self.max_runs + 1):
            for j in range(self.max_runs + 1):
                if i + line > j:  # e.g., i - 1.5 > j means i >= j + 2
                    fav_covers += joint[i, j]

        dog_covers = 1.0 - fav_covers

        return {
            "favorite_covers": round(fav_covers, 4),
            "underdog_covers": round(dog_covers, 4),
            "line": line,
        }

    def totals_probability(
        self, lam_home: float, lam_away: float, total_line: float
    ) -> dict[str, float]:
        """
        Compute over/under probabilities for the game total.

        Args:
            lam_home: Expected runs for the home team.
            lam_away: Expected runs for the away team.
            total_line: The posted total (e.g., 8.5).

        Returns:
            Dictionary with over and under probabilities.
        """
        pmf_home = self.run_pmf(lam_home)
        pmf_away = self.run_pmf(lam_away)

        joint = np.outer(pmf_home, pmf_away)

        over_prob = 0.0
        under_prob = 0.0

        for i in range(self.max_runs + 1):
            for j in range(self.max_runs + 1):
                total = i + j
                if total > total_line:
                    over_prob += joint[i, j]
                elif total < total_line:
                    under_prob += joint[i, j]
                # Exactly on total_line with .5 is impossible

        return {
            "over": round(over_prob, 4),
            "under": round(under_prob, 4),
            "total_line": total_line,
        }

    def first_five_innings(
        self,
        lam_home: float,
        lam_away: float,
        starter_innings_pct: float = 0.58,
    ) -> dict[str, float]:
        """
        Estimate first-five-innings probabilities.

        Scales the full-game lambda by the estimated proportion
        of runs scored in the first 5 innings, then computes
        probabilities using the scaled lambdas.

        Args:
            lam_home: Full-game expected runs for home team.
            lam_away: Full-game expected runs for away team.
            starter_innings_pct: Fraction of total runs expected
                in the first 5 innings (typically 55-60%).

        Returns:
            Dictionary with F5 win probabilities, total, and
            expected F5 runs.
        """
        f5_home = lam_home * starter_innings_pct
        f5_away = lam_away * starter_innings_pct

        win_probs = self.win_probability(f5_home, f5_away)
        f5_total = f5_home + f5_away

        return {
            "f5_home_runs": round(f5_home, 2),
            "f5_away_runs": round(f5_away, 2),
            "f5_total": round(f5_total, 2),
            "f5_home_ml": win_probs["home_win"],
            "f5_away_ml": win_probs["away_win"],
        }

    def probability_to_american_odds(self, prob: float) -> int:
        """Convert a probability to American odds."""
        if prob <= 0 or prob >= 1:
            raise ValueError("Probability must be between 0 and 1.")
        if prob >= 0.5:
            return round(-100 * prob / (1 - prob))
        else:
            return round(100 * (1 - prob) / prob)

    def full_game_analysis(
        self,
        lam_home: float,
        lam_away: float,
        total_line: float,
        run_line: float = -1.5,
    ) -> dict:
        """
        Produce a complete analysis for a single game.

        Args:
            lam_home: Expected runs for the home team.
            lam_away: Expected runs for the away team.
            total_line: The posted total line.
            run_line: The run line for the favorite.

        Returns:
            Comprehensive dictionary with all derived probabilities
            and fair odds for each market.
        """
        win = self.win_probability(lam_home, lam_away)
        rl = self.run_line_probability(
            max(lam_home, lam_away),
            min(lam_home, lam_away),
            run_line,
        )
        totals = self.totals_probability(lam_home, lam_away, total_line)
        f5 = self.first_five_innings(lam_home, lam_away)

        return {
            "expected_total": round(lam_home + lam_away, 2),
            "moneyline": {
                "home_prob": win["home_win"],
                "away_prob": win["away_win"],
                "home_fair_odds": self.probability_to_american_odds(win["home_win"]),
                "away_fair_odds": self.probability_to_american_odds(win["away_win"]),
            },
            "run_line": rl,
            "totals": totals,
            "first_five": f5,
        }


# --- Worked Example ---
if __name__ == "__main__":
    model = RunDistributionModel(model_type="negative_binomial", nb_r=6.0)

    # Game: Yankees (home, strong pitching) vs Red Sox (away, strong offense)
    lam_home = 4.8  # Yankees expected runs
    lam_away = 4.2  # Red Sox expected runs
    posted_total = 9.0

    print("=== Full Game Analysis: Yankees vs Red Sox ===")
    print(f"Expected runs: NYY {lam_home}, BOS {lam_away}")
    print(f"Posted total: {posted_total}\n")

    analysis = model.full_game_analysis(lam_home, lam_away, posted_total)

    print("MONEYLINE:")
    ml = analysis["moneyline"]
    print(f"  Home win prob: {ml['home_prob']:.1%} (fair: {ml['home_fair_odds']})")
    print(f"  Away win prob: {ml['away_prob']:.1%} (fair: {ml['away_fair_odds']})")

    print(f"\nRUN LINE ({analysis['run_line']['line']}):")
    print(f"  Favorite covers: {analysis['run_line']['favorite_covers']:.1%}")
    print(f"  Underdog covers: {analysis['run_line']['underdog_covers']:.1%}")

    print(f"\nTOTALS ({analysis['totals']['total_line']}):")
    print(f"  Over:  {analysis['totals']['over']:.1%}")
    print(f"  Under: {analysis['totals']['under']:.1%}")

    print(f"\nFIRST FIVE INNINGS:")
    f5 = analysis["first_five"]
    print(f"  F5 expected total: {f5['f5_total']}")
    print(f"  F5 home ML prob: {f5['f5_home_ml']:.1%}")
    print(f"  F5 away ML prob: {f5['f5_away_ml']:.1%}")

    # Compare Poisson vs Negative Binomial
    print("\n\n=== Model Comparison: Poisson vs Negative Binomial ===")
    pois = RunDistributionModel(model_type="poisson")
    nb = RunDistributionModel(model_type="negative_binomial", nb_r=6.0)

    for total_line in [7.5, 8.5, 9.5, 10.5]:
        p_over = pois.totals_probability(4.8, 4.2, total_line)["over"]
        nb_over = nb.totals_probability(4.8, 4.2, total_line)["over"]
        print(f"  Total {total_line}: Poisson Over={p_over:.1%}, NB Over={nb_over:.1%}, Diff={nb_over-p_over:+.1%}")

Worked Example: Comparing Moneyline and Run Line Value

Consider a game where Team A (home) is projected at 5.0 runs and Team B (away) at 3.5 runs using our negative binomial model ($r = 6$).

Step 1: Compute moneyline probabilities.

Using the model, $P(\text{Home wins}) \approx 0.6320$ or 63.2%.

Fair American odds: Home $\approx -172$, Away $\approx +172$.

If the market offers Home at -165 (implied 62.3%), the home moneyline has $63.2\% - 62.3\% = +0.9\%$ edge.

Step 2: Compute run line probabilities.

$P(\text{Home covers } -1.5) \approx 0.4850$ or 48.5%.

If the market offers Home -1.5 at +130 (implied 43.5%), the run line has $48.5\% - 43.5\% = +5.0\%$ edge.

Step 3: Compare.

The run line offers substantially more edge (+5.0%) than the moneyline (+0.9%) in this scenario. This is a common pattern: when a team is a significant favorite, the run line often provides better relative value because the market tends to underprice the probability of winning by 2+ runs for dominant teams.

Common Pitfall: Bettors who only wager moneylines miss the most efficient market in baseball. The run line market is less liquid and attracts less sharp money than the moneyline, creating larger and more persistent mispricings. Always check run line and F5 odds alongside the moneyline.


17.5 MLB Betting Market Patterns

Reverse Line Movement in Baseball

Reverse line movement (RLM) occurs when a line moves in the opposite direction of the public betting percentages. For example, if 70% of bets are on the Yankees, but the line moves from Yankees -160 to Yankees -155 (indicating less confidence in the Yankees), this suggests sharp money is on the other side.

In MLB, RLM is particularly informative because: 1. The moneyline structure means sharp money is more easily camouflaged in the underdog price 2. The high volume of games (2,430 per regular season) provides ample opportunities 3. Starting pitcher changes can trigger RLM independent of sharp action

Tracking RLM requires access to betting percentage data (from services like Action Network, VegasInsider, or Pregame.com) alongside line movement data.

Steam on Totals

"Steam moves" --- rapid, coordinated line movements triggered by sharp action --- are particularly common in the MLB totals market. Totals in baseball are influenced by weather conditions that become clearer as game time approaches, creating late-breaking information edges.

A typical steam scenario: weather reports confirm 15 mph winds blowing out at Wrigley Field. Sharp bettors who monitor weather closely hit the over across multiple books simultaneously. The total moves from 8.5 to 9 to 9.5 within minutes.

The key for the retail bettor is speed: by the time a steam move is visible on public odds screens, the best prices are already gone. However, originating on the same side as steam (even at the moved line) has historically been profitable, as the initial move often underadjusts.

Umpire Effects on Totals

Home plate umpires have measurable effects on game outcomes through their strike zone tendencies. Umpires with large strike zones (more called strikes) suppress offense; umpires with small zones (fewer called strikes) inflate it.

The effect is not negligible. The difference between the most generous and most restrictive umpires can be 0.5 to 1.0 runs per game in total scoring. For a game with a posted total of 8.5, a shift of 0.5 runs changes the over/under probability by approximately 5-8 percentage points.

Key umpire metrics: - Runs per game above average (RPGAA): How many more or fewer runs games produce with this umpire behind the plate, compared to the league average. - Called strike rate: The percentage of taken pitches called strikes. Higher rates suppress offense. - Zone accuracy: How consistently the umpire calls the rulebook zone. Less accurate umpires introduce more variance.

Dog Money and Underdog Profitability

The "dog money" concept refers to the historical profitability of betting on MLB underdogs. Multiple academic and industry studies have found that, over long periods, underdogs in MLB have covered the expected win rate implied by their odds at a slightly higher rate than favorites. This effect is attributed to the public's preference for betting favorites, which pushes favorite prices to levels that may slightly exceed true probability.

The effect is small (1-3% ROI on underdogs over large samples) and has compressed as the market has become more efficient. However, it persists in specific situations:

  • Large underdogs (+150 and above): The least popular bets tend to offer the best relative value.
  • Road underdogs with quality starting pitching: When a team's moneyline is heavily discounted because of overall team quality, but the day's starting pitcher is better than the team's average.
  • April and September: Early in the season (when teams are most uncertain) and in September (when contending teams rest players against non-contenders).

Seasonal Patterns

MLB's 162-game season creates distinct seasonal patterns relevant to bettors:

April (Opening Month): Uncertainty is highest. Preseason projections are imprecise, small samples of current-year data are unreliable, and weather (cold, wet conditions in northern cities) introduces extra variance. Overs tend to be underpriced in April because the market anchors on warm-weather run-scoring rates.

May-July (Core Season): Statistics stabilize. Market efficiency increases. Edges compress. This is when the sharpest models earn their keep, as competition for edges is fiercest.

July-August (Trade Deadline): Deadline trades create sharp changes in team composition that the market may take 1-2 weeks to fully price. Acquiring a frontline starter or trading away key contributors can shift a team's projected runs per game by 0.2-0.5 in either direction.

September (Roster Expansion): Until 2020, September rosters expanded from 25 to 40, dramatically changing bullpen dynamics. Current rules allow expansion to 28. The introduction of unfamiliar pitchers increases variance and can create edges for bettors who track minor league stats.

Postseason: Shorter series introduce massive variance. Pricing playoff games is fundamentally different from regular season games because rest, pitching rotation order, and home field matter more. Models trained only on regular season data will underperform.

Python Code: MLB Market Pattern Analysis

"""
MLB Betting Market Pattern Analysis

Analyzes historical MLB betting data to identify
reverse line movement, umpire effects, underdog
profitability, and seasonal patterns.

Requirements:
    pip install pandas numpy matplotlib seaborn
"""

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from typing import Optional


class MLBMarketAnalyzer:
    """
    Analyzes MLB betting market patterns from historical data.

    Expects a DataFrame with columns:
        - date: Game date
        - home_team, away_team: Team abbreviations
        - home_starter, away_starter: Pitcher names
        - home_ml_open, away_ml_open: Opening moneyline odds
        - home_ml_close, away_ml_close: Closing moneyline odds
        - home_pct: Public betting percentage on home team
        - total_open, total_close: Opening and closing totals
        - home_score, away_score: Final scores
        - umpire: Home plate umpire name
        - month: Calendar month

    Attributes:
        data: The historical betting DataFrame.
    """

    def __init__(self, data: pd.DataFrame):
        self.data = data.copy()
        self._preprocess()

    def _preprocess(self) -> None:
        """Add derived columns for analysis."""
        df = self.data

        # Determine favorite/underdog
        df["home_is_fav"] = df["home_ml_close"] < df["away_ml_close"]

        # Actual results
        df["home_win"] = df["home_score"] > df["away_score"]
        df["total_runs"] = df["home_score"] + df["away_score"]

        # Line movement
        df["ml_moved_toward_home"] = df["home_ml_close"] < df["home_ml_open"]

        # Reverse line movement flag
        # RLM = public on home but line moves away from home (or vice versa)
        df["rlm_flag"] = (
            ((df["home_pct"] > 55) & ~df["ml_moved_toward_home"])
            | ((df["home_pct"] < 45) & df["ml_moved_toward_home"])
        )

        # Moneyline profit calculation (flat $100 bet)
        df["home_ml_profit"] = df.apply(
            lambda r: self._calc_profit(r["home_ml_close"], r["home_win"]),
            axis=1,
        )
        df["away_ml_profit"] = df.apply(
            lambda r: self._calc_profit(r["away_ml_close"], not r["home_win"]),
            axis=1,
        )

    @staticmethod
    def _calc_profit(odds: int, won: bool) -> float:
        """Calculate profit on a $100 flat bet."""
        if won:
            if odds > 0:
                return 100 * (odds / 100)
            else:
                return 100 * (100 / abs(odds))
        else:
            return -100.0

    def reverse_line_movement_analysis(self) -> pd.DataFrame:
        """
        Analyze the profitability of following reverse line movement.

        Returns:
            DataFrame summarizing RLM results by direction.
        """
        rlm = self.data[self.data["rlm_flag"]].copy()

        # When RLM occurs, bet against the public
        # If public is on home (home_pct > 55) but line moves away,
        # the sharp side is away
        rlm["sharp_side_won"] = np.where(
            rlm["home_pct"] > 55,
            ~rlm["home_win"],  # Sharp side is away
            rlm["home_win"],   # Sharp side is home
        )

        total_games = len(rlm)
        sharp_wins = rlm["sharp_side_won"].sum()
        win_rate = sharp_wins / total_games if total_games > 0 else 0

        summary = pd.DataFrame(
            {
                "metric": ["Total RLM games", "Sharp side wins", "Win rate"],
                "value": [total_games, sharp_wins, f"{win_rate:.1%}"],
            }
        )
        return summary

    def umpire_impact_analysis(self, min_games: int = 30) -> pd.DataFrame:
        """
        Analyze umpire effects on total runs scored.

        Args:
            min_games: Minimum games for an umpire to be included.

        Returns:
            DataFrame of umpires with their runs per game
            and deviation from league average.
        """
        league_avg = self.data["total_runs"].mean()

        ump_stats = (
            self.data.groupby("umpire")
            .agg(
                games=("total_runs", "count"),
                avg_total=("total_runs", "mean"),
                std_total=("total_runs", "std"),
            )
            .reset_index()
        )
        ump_stats = ump_stats[ump_stats["games"] >= min_games].copy()
        ump_stats["rpg_above_avg"] = ump_stats["avg_total"] - league_avg
        ump_stats = ump_stats.sort_values("rpg_above_avg", ascending=False)

        return ump_stats.round(2)

    def underdog_profitability(
        self, min_odds: int = 100, max_odds: int = 300
    ) -> pd.DataFrame:
        """
        Analyze profitability of betting all underdogs in a range.

        Args:
            min_odds: Minimum underdog odds to include (e.g., +100).
            max_odds: Maximum underdog odds to include (e.g., +300).

        Returns:
            DataFrame with profitability by odds bucket.
        """
        df = self.data.copy()

        # Identify underdogs (positive odds or higher odds)
        df["dog_odds"] = np.where(
            df["home_is_fav"], df["away_ml_close"], df["home_ml_close"]
        )
        df["dog_won"] = np.where(df["home_is_fav"], ~df["home_win"], df["home_win"])

        # Filter to range
        dogs = df[(df["dog_odds"] >= min_odds) & (df["dog_odds"] <= max_odds)].copy()

        # Bucket by odds range
        dogs["odds_bucket"] = pd.cut(
            dogs["dog_odds"],
            bins=[100, 120, 140, 160, 180, 200, 250, 300],
            labels=["+100-120", "+120-140", "+140-160", "+160-180",
                    "+180-200", "+200-250", "+250-300"],
        )

        # Calculate profit per bucket
        dogs["profit"] = dogs.apply(
            lambda r: self._calc_profit(int(r["dog_odds"]), r["dog_won"]),
            axis=1,
        )

        summary = (
            dogs.groupby("odds_bucket", observed=True)
            .agg(
                bets=("profit", "count"),
                wins=("dog_won", "sum"),
                total_profit=("profit", "sum"),
            )
            .reset_index()
        )
        summary["win_rate"] = (summary["wins"] / summary["bets"]).round(3)
        summary["roi"] = (summary["total_profit"] / (summary["bets"] * 100) * 100).round(2)

        return summary

    def seasonal_pattern_analysis(self) -> pd.DataFrame:
        """
        Analyze over/under profitability by month.

        Returns:
            DataFrame with monthly over/under results.
        """
        df = self.data.copy()
        df["over_won"] = df["total_runs"] > df["total_close"]
        df["under_won"] = df["total_runs"] < df["total_close"]

        monthly = (
            df.groupby("month")
            .agg(
                games=("total_runs", "count"),
                avg_total=("total_runs", "mean"),
                avg_line=("total_close", "mean"),
                over_pct=("over_won", "mean"),
                under_pct=("under_won", "mean"),
            )
            .reset_index()
        )
        monthly["over_edge"] = monthly["over_pct"] - 0.5
        return monthly.round(3)

    def plot_umpire_impact(self, ump_data: Optional[pd.DataFrame] = None) -> None:
        """
        Visualize umpire impact on game totals.

        Args:
            ump_data: Pre-computed umpire analysis DataFrame.
                If None, computes it fresh.
        """
        if ump_data is None:
            ump_data = self.umpire_impact_analysis()

        fig, ax = plt.subplots(figsize=(12, 6))
        colors = ["#c0392b" if x > 0 else "#2980b9" for x in ump_data["rpg_above_avg"]]

        ax.barh(ump_data["umpire"], ump_data["rpg_above_avg"], color=colors)
        ax.axvline(x=0, color="black", linewidth=0.8)
        ax.set_xlabel("Runs Per Game Above/Below Average")
        ax.set_title("Home Plate Umpire Impact on Game Totals")
        plt.tight_layout()
        plt.savefig("umpire_impact.png", dpi=150, bbox_inches="tight")
        plt.close()
        print("Saved umpire_impact.png")


# --- Example with Synthetic Data ---
if __name__ == "__main__":
    # Generate synthetic historical data for demonstration
    np.random.seed(42)
    n_games = 2000

    synthetic = pd.DataFrame({
        "date": pd.date_range("2024-04-01", periods=n_games, freq="D"),
        "home_team": np.random.choice(["NYY", "BOS", "LAD", "HOU", "ATL"], n_games),
        "away_team": np.random.choice(["NYM", "TBR", "SFG", "SEA", "MIA"], n_games),
        "home_starter": "Starter_H",
        "away_starter": "Starter_A",
        "home_ml_open": np.random.choice([-150, -130, -110, 100, 120, 140], n_games),
        "away_ml_open": np.random.choice([130, 110, -110, -100, -120, -140], n_games),
        "home_pct": np.random.uniform(35, 75, n_games),
        "total_open": np.random.choice([7.5, 8.0, 8.5, 9.0, 9.5], n_games),
        "home_score": np.random.poisson(4.3, n_games),
        "away_score": np.random.poisson(4.1, n_games),
        "umpire": np.random.choice([
            "Angel Hernandez", "Joe West", "CB Bucknor",
            "Jim Wolf", "Pat Hoberg", "Nic Lentz"
        ], n_games),
    })

    # Derive closing lines (slight movement from open)
    synthetic["home_ml_close"] = synthetic["home_ml_open"] + np.random.choice(
        [-5, 0, 5], n_games
    )
    synthetic["away_ml_close"] = -synthetic["home_ml_close"]  # Simplified
    synthetic["total_close"] = synthetic["total_open"] + np.random.choice(
        [-0.5, 0, 0.5], n_games
    )
    synthetic["month"] = synthetic["date"].dt.month

    analyzer = MLBMarketAnalyzer(synthetic)

    print("=== Reverse Line Movement Analysis ===")
    print(analyzer.reverse_line_movement_analysis().to_string(index=False))

    print("\n=== Umpire Impact Analysis ===")
    print(analyzer.umpire_impact_analysis(min_games=50).to_string(index=False))

    print("\n=== Underdog Profitability ===")
    print(analyzer.underdog_profitability().to_string(index=False))

    print("\n=== Seasonal Over/Under Patterns ===")
    print(analyzer.seasonal_pattern_analysis().to_string(index=False))

A Note on Market Efficiency in MLB

MLB betting markets have become significantly more efficient over the past decade. The proliferation of advanced statistics, the growth of the sharp betting market, and the professionalization of sportsbook operations have all compressed the edges available to public bettors. However, several structural features of baseball continue to create opportunities:

  1. Volume: With 2,430 regular season games, even small edges compound into large sample sizes.
  2. Late-breaking information: Weather, lineup announcements, and bullpen availability are finalized close to game time, creating windows of informational asymmetry.
  3. Derivative markets: Run lines, F5 lines, team totals, and props are all less efficient than the headline moneyline.
  4. Platoon and bullpen complexity: The interaction effects between pitcher handedness, lineup composition, and bullpen availability create combinatorial complexity that simple models struggle to capture fully.

Real-World Application: The most successful MLB bettors today combine multiple information sources: quantitative pitcher models, real-time weather data, lineup tracking, bullpen availability monitoring, and market data (line movement, betting percentages). No single approach dominates. The edge comes from integrating these sources faster and more accurately than the market does.


17.6 Chapter Summary

Key Concepts

  1. Sabermetric metrics (wOBA, FIP, wRC+, WAR) isolate skill from luck and are more predictive of future performance than traditional statistics (batting average, ERA, RBI). For betting, predictive power is the only standard that matters.

  2. Starting pitcher quality is the single largest driver of game-to-game variance in MLB outcomes. A model that accurately projects starting pitcher performance is the foundation of all MLB betting analysis.

  3. Platoon effects --- the handedness advantage of batters against opposite-handed pitchers --- are robust, persistent, and quantifiable. They should be incorporated into every lineup-level projection.

  4. Park factors vary enormously across MLB venues and must be included in all run-scoring projections. Multi-year regression-adjusted factors are more reliable than single-season calculations.

  5. Environmental conditions (temperature, wind, humidity, altitude) create game-specific deviations from base park factors. Real-time weather data is a legitimate informational edge in totals betting.

  6. The negative binomial distribution provides a better fit than the Poisson for MLB run scoring because it accounts for the overdispersion (run clustering) characteristic of baseball.

  7. Run line and F5 markets often offer better relative value than the headline moneyline because they attract less sharp action and create non-obvious mispricings.

  8. Reverse line movement in MLB is informative because sharp bettors exploit weather, lineup, and bullpen information that becomes available close to game time.

  9. Umpire effects on totals are measurable and persistent, with the most extreme umpires shifting expected scoring by 0.5-1.0 runs per game.

  10. MLB underdogs have historically offered slightly positive ROI as a group, particularly at higher odds (+150 and above), though this edge has compressed over time.

Key Formulas

Formula Description
$\text{wOBA} = \frac{\sum w_i \cdot \text{event}_i}{\text{AB} + \text{BB} - \text{IBB} + \text{SF} + \text{HBP}}$ Weighted on-base average
$\text{FIP} = \frac{13 \cdot \text{HR} + 3 \cdot (\text{BB}+\text{HBP}) - 2 \cdot \text{K}}{\text{IP}} + C$ Fielding Independent Pitching
$\text{Win\%} = \frac{\text{RS}^{1.83}}{\text{RS}^{1.83} + \text{RA}^{1.83}}$ Pythagorean expectation
$P(X=k) = \frac{\lambda^k e^{-\lambda}}{k!}$ Poisson PMF for run scoring
$\text{PF} = \frac{\text{runs per game at home}}{\text{runs per game on road}}$ Basic park factor
$d_{\text{adj}} = d_{\text{sea}} \cdot \left(\frac{\rho_{\text{sea}}}{\rho_{\text{alt}}}\right)^{0.38}$ Altitude adjustment for batted ball distance

Key Code Patterns

  1. Data pipeline with pybaseball (MLBSabermetricPipeline): Demonstrates how to pull FanGraphs and Statcast data, compute composite team ratings, and identify regression candidates from xwOBA gaps.

  2. Pitcher matchup model (MatchupAnalyzer): Uses the Odds Ratio method to combine pitcher quality, batter skill, and platoon effects into matchup-specific wOBA estimates, then converts to expected runs.

  3. Park-adjusted projection (ParkAdjustedProjection): Layers environmental adjustments on top of base park factors, with special handling for domed stadiums.

  4. Run distribution model (RunDistributionModel): Implements both Poisson and negative binomial distributions for run scoring, then derives moneyline, run line, totals, and F5 probabilities from the distributions.

  5. Market pattern analyzer (MLBMarketAnalyzer): Provides tools for studying reverse line movement, umpire effects, underdog profitability, and seasonal patterns from historical betting data.

Decision Framework: MLB Betting Checklist

START: An MLB game is on today's card.

1. Who is starting on the mound?
   - Compute pitcher quality scores for both starters.
   - Check recent workload and rest days.

2. What does the lineup look like?
   - Confirm announced lineups (available ~2-4 hours before first pitch).
   - Compute platoon-adjusted lineup wOBA against each starter.

3. What are the park and weather conditions?
   - Apply base park factor.
   - Check temperature, wind speed/direction, and humidity.
   - Compute weather-adjusted run projections.

4. What is the bullpen situation?
   - Check recent bullpen usage (last 2-3 days).
   - Apply fatigue adjustments to bullpen run expectations.

5. Run the model.
   - Compute team-level expected runs.
   - Generate moneyline, run line, totals, and F5 probabilities.

6. Compare to market.
   - Convert model probabilities to fair odds.
   - Identify markets where your edge exceeds the vig.
   - Check for reverse line movement or umpire effects.

7. Size and place bets.
   - Apply Kelly criterion or fractional Kelly sizing.
   - Record the bet with full rationale in your tracker.

What's Next

In Chapter 18: Modeling the NHL, we turn to hockey --- a sport whose low-scoring nature and chaotic gameplay present a fundamentally different modeling challenge. We will build expected goals (xG) models from shot-level data, evaluate goaltenders using Goals Saved Above Expected (GSAx), account for score effects and game state, and identify NHL-specific betting market patterns. Where baseball rewards deep statistical analysis of individual matchups, hockey rewards understanding the underlying process of shot generation, shot quality, and the large role of randomness --- concepts that create persistent market inefficiencies.


This chapter is part of The Sports Betting Textbook, a comprehensive guide to quantitative sports betting. All code examples use Python 3.11+ and are available in the companion repository.