Baseball is the cleanest Markov chain in sports — 24 base-out states, a computable transition matrix, and a closed-form solution for run expectancy: RE = (I - Q)^{-1} * R. From this single framework, you derive wOBA coefficients, FIP weights, win expectancy, leverage index, and pitcher-specific F5 projections that beat sportsbook lines.

Why This Matters for Agents

Baseball is the only major sport where game state is fully described by a small, discrete set of states with memoryless transitions. A plate appearance outcome depends on the batter, pitcher, and base-out state — not on what happened three batters ago. This makes baseball a textbook absorbing Markov chain, and it makes MLB the most mathematically tractable sport for an autonomous betting agent to model.

This is Layer 4 — Intelligence. An agent’s MLB pipeline works like this: pull odds from The Odds API, build pitcher-specific transition matrices from Retrosheet play-by-play data, solve for run expectancy, feed those projections into a Poisson model for game totals, and compare against sportsbook lines. The Markov chain is the core engine. Everything else — wOBA, FIP, win expectancy, leverage — falls out of the same transition matrix. The Agent Betting Stack places this squarely in the intelligence layer, where raw data becomes actionable projections.

The Math

The 24 Base-Out States

A half-inning in baseball is fully described by two variables: which bases are occupied, and how many outs there are.

Bases have 8 configurations (each of 3 bases is either occupied or empty): 2^3 = 8. Outs have 3 values: 0, 1, 2. That gives 8 x 3 = 24 transient states, plus one absorbing state (3 outs = inning over).

State Encoding — 24 Transient States + 1 Absorbing State

Bases    Notation   0 outs   1 out    2 outs
───────  ────────   ──────   ─────    ──────
Empty    ---        S0       S8       S16
1B       1--        S1       S9       S17
2B       -2-        S2       S10      S18
3B       --3        S3       S11      S19
1B,2B    12-        S4       S12      S20
1B,3B    1-3        S5       S13      S21
2B,3B    -23        S6       S14      S22
Loaded   123        S7       S15      S23

Absorbing: S24 = 3 outs (inning ends)

Each plate appearance starts in one of these 24 states and transitions to another state (possibly the same out count with different bases, or one more out, or the absorbing state). Runs may score during the transition.

The Transition Matrix

Define a 25 x 25 matrix T where T[i][j] = P(next state is j | current state is i), for i in {S0, …, S24} and j in {S0, …, S24}.

The absorbing state S24 is a sink: T[24][24] = 1 and T[24][j] = 0 for all j != 24.

We also need a runs matrix R[i][j] = expected runs scored when transitioning from state i to state j. A home run with bases loaded transitions from S7 (loaded, k outs) to S_base_empty (same outs) and scores 4 runs.

From Retrosheet play-by-play data, we estimate T and R empirically:

T[i][j] = (count of transitions from i to j) / (count of plate appearances starting in state i)
R[i][j] = (total runs scored on transitions from i to j) / (count of transitions from i to j)

Solving for Run Expectancy

This is an absorbing Markov chain. Partition the transition matrix into transient states (S0-S23) and the absorbing state (S24):

T = | Q   r |
    | 0   1 |

Q = 24x24 matrix of transient-to-transient transition probabilities
r = 24x1 vector of transient-to-absorbing transition probabilities

The fundamental matrix is N = (I - Q)^{-1}, where N[i][j] = expected number of times the chain visits state j starting from state i before absorption.

Run expectancy for each state is:

RE = N * g

where g[j] = Σ_k T[j][k] * R[j][k]  (expected immediate runs scored per visit to state j)

In plain English: g[j] is the average runs scored on any single plate appearance that starts in state j. N tells you how many plate appearances happen in each state before the inning ends. Multiply and sum to get total expected runs.

Empirical Run Expectancy Table (2019-2024 MLB)

These values come from solving the absorbing Markov chain on ~1.1 million half-innings of Retrosheet play-by-play data:

Run Expectancy Matrix — 2019-2024 MLB Averages

Bases        0 outs    1 out    2 outs
─────────    ──────    ─────    ──────
---           0.481    0.254     0.098
1--           0.859    0.509     0.222
-2-           1.100    0.664     0.319
--3           1.353    0.950     0.353
12-           1.437    0.884     0.429
1-3           1.784    1.130     0.478
-23           1.960    1.376     0.580
123           2.282    1.541     0.689

Key observations: bases loaded with zero outs produces 2.282 expected runs — not even 2.5. Baseball is a low-scoring game. Runner on first with one out (0.509) is worth about half a run. Bases empty with two outs (0.098) is nearly dead.

Win Expectancy

Run expectancy covers a single half-inning. Win expectancy extends to the full game:

WE = P(home team wins | inning, half, outs, bases, score differential)

This is a function of five variables. In practice, you discretize the score differential (say, -10 to +10) and compute WE for every combination:

  • 9 innings x 2 halves = 18 half-innings (plus extras)
  • 24 base-out states per half-inning
  • ~21 score differentials (-10 to +10)
  • Total: 18 x 24 x 21 = 9,072 unique game states

WE is computed by simulation or backward induction. Starting from the final game state (bottom of 9th, 3 outs, score differential known), work backward through every possible state, using the run expectancy distribution (not just the mean) to compute the probability of each scoring outcome.

Leverage Index

Leverage Index (LI) quantifies how much a plate appearance matters for the game outcome:

LI = |WE(best PA outcome) - WE(worst PA outcome)| / average_WE_swing

A league-average plate appearance has LI = 1.0. A bases-loaded, tie game, bottom-of-the-9th plate appearance might have LI = 5.0+. This is why closers face the highest-leverage at-bats — not because the 9th inning is inherently special, but because the game states they encounter produce the largest WE swings.

For betting, LI identifies when live line movements should be largest. An agent monitoring live odds can detect when a sportsbook is slow to adjust after a high-LI plate appearance.

Linear Weights: wOBA and FIP

The run expectancy matrix directly produces the coefficient weights used in wOBA (weighted on-base average) and FIP (fielding independent pitching).

wOBA coefficients are the average run value of each event type. For a single:

RunValue(single) = Σ over all base-out states s:
    P(PA occurs in state s) × [RE(state after single from s) - RE(s) + runs scored on the play]

Using 2019-2024 data, the standard wOBA weights are:

Event          wOBA Weight    Run Value (RE24)
─────────      ───────────    ────────────────
Unintentional BB    0.690         +0.300
Hit by Pitch       0.722         +0.338
Single             0.880         +0.457
Double             1.240         +0.762
Triple             1.560         +1.028
Home Run           2.010         +1.377

wOBA = (0.690uBB + 0.722HBP + 0.8801B + 1.2402B + 1.5603B + 2.010HR) / (AB + BB - IBB + SF + HBP)

FIP isolates pitcher-controlled events weighted by run value:

FIP = (13 * HR + 3 * (BB + HBP) - 2 * K) / IP + C_FIP

where C_FIP is a constant that scales FIP to the league ERA (~3.10 for recent seasons)

The 13/3/2 coefficients come from the Markov chain run values: a home run is worth ~13x a walk in run expectancy terms when you account for the base-out state transitions each event creates.

Worked Examples

Example 1: Run Expectancy Shift on a Key Play

Game situation: Yankees vs. Red Sox at Fenway, bottom of 6th. Runner on first, one out. Score tied 3-3.

Current state: S9 (1B occupied, 1 out). RE = 0.509.

Batter hits a double, runner advances to third. No run scores.

New state: S14 (2B+3B occupied, 1 out). RE = 1.376.

RE change = 1.376 - 0.509 = +0.867 runs

That double was worth 0.867 runs of expected value to the Red Sox offense. The win expectancy shifted from approximately 52.1% (home team, tied, mid-game) to approximately 61.8% — a leverage-weighted swing of ~10 percentage points.

A live betting agent watching this game on BetOnline should expect the Red Sox moneyline to move from roughly -108 to approximately -162 within seconds of the play.

Example 2: F5 Betting with Pitcher-Specific Run Projections

Matchup: Gerrit Cole (NYY) vs. Corbin Burnes (BAL) on a Tuesday night. BetOnline F5 line: NYY -0.5 at -120, total 3.5.

Step 1 — Build pitcher-specific transition matrices using each pitcher’s Retrosheet game logs from the current season (50+ innings each).

Cole’s transition matrix produces a run expectancy of 0.388 per inning (lower than league average 0.481 because Cole suppresses baserunners). Over 5 innings: 5 x 0.388 = 1.94 expected runs allowed.

Burnes: 0.412 per inning. Over 5 innings: 5 x 0.412 = 2.06 expected runs allowed.

Step 2 — Model F5 team runs as Poisson with lambda = pitcher’s expected runs allowed:

NYY F5 runs ~ Poisson(lambda=2.06)  (facing Burnes)
BAL F5 runs ~ Poisson(lambda=1.94)  (facing Cole)

Expected F5 total: 2.06 + 1.94 = 4.00

Step 3 — Compare against the line. BetOnline has F5 total 3.5. The agent’s model says the expected total is 4.00. Using the Poisson distribution:

P(F5 total > 3.5) = 1 - P(combined Poisson <= 3)
                   = 1 - Σ P(NYY=i)*P(BAL=j) for all i+j <= 3
                   ≈ 0.567

At -110, the breakeven probability is 52.4%. The agent’s model implies 56.7% for the over. That is a 4.3 percentage point edge — worth a bet at quarter-Kelly sizing.

Example 3: Leverage-Based Live Betting

Bottom of the 8th, score tied 2-2, runner on third, one out. State S11: RE = 0.950.

The leverage index here is approximately 3.2 — this plate appearance is 3.2x more consequential than average. A sacrifice fly (run scores, third out recorded) shifts WE from ~50% to ~65% for the home team. A strikeout (no run, 2 outs) shifts WE down to ~43%.

An agent with a faster WE model than the sportsbook can submit live bets in the seconds between the pitch result and the book’s line update. This is the sharp betting edge that algorithms exploit.

Implementation

import numpy as np
import pandas as pd
from scipy.linalg import inv
from typing import Optional


# --- State encoding ---

BASE_CONFIGS = [
    (False, False, False),  # ---  (empty)
    (True,  False, False),  # 1--
    (False, True,  False),  # -2-
    (False, False, True),   # --3
    (True,  True,  False),  # 12-
    (True,  False, True),   # 1-3
    (False, True,  True),   # -23
    (True,  True,  True),   # 123
]

NUM_BASE_CONFIGS = 8
NUM_OUT_COUNTS = 3
NUM_TRANSIENT = NUM_BASE_CONFIGS * NUM_OUT_COUNTS  # 24
ABSORBING = NUM_TRANSIENT  # state index 24 = 3 outs


def state_index(base_config: int, outs: int) -> int:
    """Convert (base_config, outs) to state index 0-23.

    Args:
        base_config: 0-7 index into BASE_CONFIGS
        outs: 0, 1, or 2

    Returns:
        State index from 0 to 23.
    """
    return base_config + outs * NUM_BASE_CONFIGS


def decode_state(state: int) -> tuple[int, int]:
    """Convert state index 0-23 to (base_config, outs)."""
    outs = state // NUM_BASE_CONFIGS
    base_config = state % NUM_BASE_CONFIGS
    return base_config, outs


def bases_to_config(first: bool, second: bool, third: bool) -> int:
    """Map base occupancy to config index 0-7."""
    for i, (f, s, t) in enumerate(BASE_CONFIGS):
        if (f, s, t) == (first, second, third):
            return i
    raise ValueError(f"Invalid base config: {first}, {second}, {third}")


# --- Transition matrix from play-by-play data ---

def build_transition_matrix(
    pbp: pd.DataFrame,
) -> tuple[np.ndarray, np.ndarray]:
    """Build transition and runs matrices from Retrosheet play-by-play data.

    Expects a DataFrame with columns:
        - first_base: bool (runner on first)
        - second_base: bool (runner on second)
        - third_base: bool (runner on third)
        - outs_before: int (0, 1, 2)
        - first_base_after: bool
        - second_base_after: bool
        - third_base_after: bool
        - outs_after: int (0, 1, 2, 3)
        - runs_scored: int (runs scoring on this play)

    Returns:
        T: (25, 25) transition probability matrix
        R: (25, 25) expected runs scored per transition
    """
    n = NUM_TRANSIENT + 1  # 25 states
    counts = np.zeros((n, n), dtype=np.float64)
    runs_total = np.zeros((n, n), dtype=np.float64)

    for _, row in pbp.iterrows():
        base_before = bases_to_config(
            row["first_base"], row["second_base"], row["third_base"]
        )
        outs_before = int(row["outs_before"])
        s_from = state_index(base_before, outs_before)

        outs_after = int(row["outs_after"])
        if outs_after >= 3:
            s_to = ABSORBING
        else:
            base_after = bases_to_config(
                row["first_base_after"],
                row["second_base_after"],
                row["third_base_after"],
            )
            s_to = state_index(base_after, outs_after)

        counts[s_from][s_to] += 1
        runs_total[s_from][s_to] += row["runs_scored"]

    # Normalize rows to get probabilities
    T = np.zeros((n, n), dtype=np.float64)
    R = np.zeros((n, n), dtype=np.float64)

    for i in range(NUM_TRANSIENT):
        row_sum = counts[i].sum()
        if row_sum > 0:
            T[i] = counts[i] / row_sum
            for j in range(n):
                if counts[i][j] > 0:
                    R[i][j] = runs_total[i][j] / counts[i][j]

    # Absorbing state
    T[ABSORBING][ABSORBING] = 1.0

    return T, R


# --- Run expectancy solver ---

def solve_run_expectancy(
    T: np.ndarray, R: np.ndarray
) -> np.ndarray:
    """Solve absorbing Markov chain for run expectancy per state.

    RE = N * g, where:
        N = (I - Q)^{-1}  (fundamental matrix)
        g[j] = sum_k T[j][k] * R[j][k]  (expected immediate runs from state j)

    Args:
        T: (25, 25) transition matrix
        R: (25, 25) runs-per-transition matrix

    Returns:
        re: (24,) array of run expectancy for each transient state
    """
    Q = T[:NUM_TRANSIENT, :NUM_TRANSIENT]  # 24x24 transient block
    I = np.eye(NUM_TRANSIENT)
    N = inv(I - Q)  # fundamental matrix

    # Expected immediate runs per visit to each state
    g = np.zeros(NUM_TRANSIENT)
    for j in range(NUM_TRANSIENT):
        g[j] = sum(T[j][k] * R[j][k] for k in range(NUM_TRANSIENT + 1))

    re = N @ g
    return re


def print_re_table(re: np.ndarray) -> None:
    """Print a formatted run expectancy table."""
    labels = ["---", "1--", "-2-", "--3", "12-", "1-3", "-23", "123"]
    print(f"{'Bases':<8} {'0 outs':>8} {'1 out':>8} {'2 outs':>8}")
    print("-" * 34)
    for b in range(NUM_BASE_CONFIGS):
        vals = [re[state_index(b, o)] for o in range(3)]
        print(f"{labels[b]:<8} {vals[0]:>8.3f} {vals[1]:>8.3f} {vals[2]:>8.3f}")


# --- Win expectancy via simulation ---

def simulate_half_inning(
    T: np.ndarray, R: np.ndarray, start_state: int = 0
) -> int:
    """Simulate one half-inning from a given state.

    Returns total runs scored.
    """
    state = start_state
    runs = 0
    while state != ABSORBING:
        probs = T[state]
        next_state = np.random.choice(NUM_TRANSIENT + 1, p=probs)
        runs += R[state][next_state]
        state = next_state
    return int(round(runs))


def simulate_game(
    T_home: np.ndarray, R_home: np.ndarray,
    T_away: np.ndarray, R_away: np.ndarray,
    n_innings: int = 9,
) -> tuple[int, int]:
    """Simulate a full game. Returns (away_runs, home_runs)."""
    away_runs = 0
    home_runs = 0

    for inning in range(1, n_innings + 1):
        # Top of inning: away bats, home pitches
        away_runs += simulate_half_inning(T_home, R_home, start_state=0)

        # Bottom of inning: home bats, away pitches
        # Walk-off check in 9th+
        home_runs += simulate_half_inning(T_away, R_away, start_state=0)
        if inning >= n_innings and home_runs > away_runs:
            break

    # Extra innings (simplified: keep playing until someone leads)
    while away_runs == home_runs:
        away_runs += simulate_half_inning(T_home, R_home, start_state=0)
        home_runs += simulate_half_inning(T_away, R_away, start_state=0)

    return away_runs, home_runs


def compute_win_expectancy(
    T_home: np.ndarray, R_home: np.ndarray,
    T_away: np.ndarray, R_away: np.ndarray,
    n_simulations: int = 50_000,
) -> float:
    """Estimate home team win probability via Monte Carlo.

    Returns P(home wins).
    """
    home_wins = 0
    for _ in range(n_simulations):
        away, home = simulate_game(T_home, R_home, T_away, R_away)
        if home > away:
            home_wins += 1
    return home_wins / n_simulations


# --- F5 (first 5 innings) Poisson model ---

def pitcher_run_expectancy_per_inning(
    T: np.ndarray, R: np.ndarray
) -> float:
    """Calculate expected runs per inning for a specific pitcher's
    transition matrix.

    Uses the run expectancy of the bases-empty, 0-outs state (S0).
    """
    re = solve_run_expectancy(T, R)
    return re[0]  # S0: start of a clean inning


def f5_poisson_probability(
    lambda_away: float,
    lambda_home: float,
    threshold: float = 3.5,
    over: bool = True,
) -> float:
    """Calculate P(F5 total over/under threshold) using Poisson model.

    Args:
        lambda_away: expected F5 runs for away team
        lambda_home: expected F5 runs for home team
        threshold: the total line (e.g., 3.5)
        over: if True, compute P(total > threshold); else P(total < threshold)

    Returns:
        Probability of over (or under).
    """
    from scipy.stats import poisson

    max_runs = 20  # truncate at 20 runs per team
    total_prob_under = 0.0
    cutoff = int(threshold)  # for half-integer lines, under means <= floor

    for away_r in range(max_runs + 1):
        for home_r in range(max_runs + 1):
            if away_r + home_r <= cutoff:
                p = poisson.pmf(away_r, lambda_away) * poisson.pmf(
                    home_r, lambda_home
                )
                total_prob_under += p

    if over:
        return 1.0 - total_prob_under
    return total_prob_under


# --- Linear weights derivation ---

def compute_linear_weights(
    re: np.ndarray, pbp: pd.DataFrame
) -> dict[str, float]:
    """Derive linear weights (run values) for each event type
    from the run expectancy matrix.

    For each event, run_value = RE(state_after) - RE(state_before) + runs_scored.
    Averaged across all base-out states weighted by frequency.

    Args:
        re: (24,) run expectancy array
        pbp: play-by-play DataFrame with 'event_type' column

    Returns:
        dict mapping event type to average run value
    """
    event_types = ["single", "double", "triple", "home_run", "walk",
                   "hit_by_pitch", "strikeout", "groundout", "flyout"]

    weights = {}
    for event in event_types:
        event_plays = pbp[pbp["event_type"] == event]
        if len(event_plays) == 0:
            continue

        run_values = []
        for _, row in event_plays.iterrows():
            base_before = bases_to_config(
                row["first_base"], row["second_base"], row["third_base"]
            )
            outs_before = int(row["outs_before"])
            s_from = state_index(base_before, outs_before)
            re_before = re[s_from]

            outs_after = int(row["outs_after"])
            if outs_after >= 3:
                re_after = 0.0
            else:
                base_after = bases_to_config(
                    row["first_base_after"],
                    row["second_base_after"],
                    row["third_base_after"],
                )
                s_after = state_index(base_after, outs_after)
                re_after = re[s_after]

            rv = re_after - re_before + row["runs_scored"]
            run_values.append(rv)

        weights[event] = np.mean(run_values)

    return weights


def calculate_woba(
    pa_data: dict[str, int], weights: Optional[dict[str, float]] = None
) -> float:
    """Calculate wOBA for a batter.

    Args:
        pa_data: dict with keys 'ubb', 'hbp', 'single', 'double',
                 'triple', 'home_run', 'ab', 'bb', 'ibb', 'sf'
        weights: custom wOBA weights; uses 2019-2024 defaults if None

    Returns:
        wOBA value (league average ~0.310-0.320)
    """
    if weights is None:
        weights = {
            "ubb": 0.690, "hbp": 0.722, "single": 0.880,
            "double": 1.240, "triple": 1.560, "home_run": 2.010,
        }

    numerator = (
        weights["ubb"] * pa_data["ubb"]
        + weights["hbp"] * pa_data["hbp"]
        + weights["single"] * pa_data["single"]
        + weights["double"] * pa_data["double"]
        + weights["triple"] * pa_data["triple"]
        + weights["home_run"] * pa_data["home_run"]
    )
    denominator = (
        pa_data["ab"] + pa_data["bb"] - pa_data["ibb"]
        + pa_data["sf"] + pa_data["hbp"]
    )

    if denominator == 0:
        return 0.0
    return numerator / denominator


def calculate_fip(
    hr: int, bb: int, hbp: int, k: int, ip: float, c_fip: float = 3.10
) -> float:
    """Calculate Fielding Independent Pitching.

    FIP = (13*HR + 3*(BB+HBP) - 2*K) / IP + C_FIP

    Args:
        hr: home runs allowed
        bb: walks allowed
        hbp: hit batters
        k: strikeouts
        ip: innings pitched (use decimal, e.g. 6.1 = 6 + 1/3)
        c_fip: league constant (~3.10 for 2019-2024)

    Returns:
        FIP value (scale matches ERA)
    """
    # Convert IP notation: 6.1 means 6 and 1/3 innings
    full_innings = int(ip)
    partial = ip - full_innings
    true_ip = full_innings + (partial * 10 / 3)

    if true_ip == 0:
        return float("inf")

    return (13 * hr + 3 * (bb + hbp) - 2 * k) / true_ip + c_fip


# --- Example usage with synthetic data ---

if __name__ == "__main__":
    # Generate a realistic run expectancy table using league-average
    # transition probabilities (hardcoded from 2019-2024 Retrosheet data)

    LEAGUE_RE = np.array([
        0.481, 0.859, 1.100, 1.353, 1.437, 1.784, 1.960, 2.282,  # 0 outs
        0.254, 0.509, 0.664, 0.950, 0.884, 1.130, 1.376, 1.541,  # 1 out
        0.098, 0.222, 0.319, 0.353, 0.429, 0.478, 0.580, 0.689,  # 2 outs
    ])

    print("=== 2019-2024 MLB Run Expectancy Table ===\n")
    print_re_table(LEAGUE_RE)

    print("\n\n=== wOBA Calculation Example ===")
    print("Juan Soto 2024 season (approximate):")
    soto_stats = {
        "ubb": 129, "hbp": 3, "single": 103, "double": 31,
        "triple": 1, "home_run": 41, "ab": 537, "bb": 129,
        "ibb": 17, "sf": 4,
    }
    woba = calculate_woba(soto_stats)
    print(f"wOBA = {woba:.3f}")

    print("\n\n=== FIP Calculation Example ===")
    print("Gerrit Cole 2024 season (approximate):")
    fip = calculate_fip(hr=23, bb=36, hbp=6, k=199, ip=95.1, c_fip=3.10)
    print(f"FIP = {fip:.2f}")

    print("\n\n=== F5 Over/Under Example ===")
    lambda_nyy = 2.06  # NYY expected F5 runs (facing Burnes)
    lambda_bal = 1.94  # BAL expected F5 runs (facing Cole)
    p_over = f5_poisson_probability(lambda_nyy, lambda_bal, threshold=3.5)
    print(f"NYY @ BAL — F5 total 3.5")
    print(f"P(Over 3.5) = {p_over:.3f}")
    print(f"P(Under 3.5) = {1 - p_over:.3f}")

    breakeven = 110 / (110 + 100)  # -110 juice
    edge = p_over - breakeven
    print(f"Edge vs -110 line: {edge:.1%}")

Limitations and Edge Cases

Stationarity assumption. The Markov chain assumes transition probabilities are constant throughout an inning. In reality, pitchers tire as pitch count increases. A starter at 95 pitches in the 6th inning has different transition probabilities than the same starter at 15 pitches in the 1st. Sophisticated agents use pitch-count-adjusted transition matrices, splitting each pitcher’s data into bins (pitches 1-30, 31-60, 61-90, 90+).

Platoon splits. The transition matrix changes based on batter-pitcher handedness matchup. A left-handed batter facing a right-handed pitcher produces different transition probabilities than an L-L matchup. Agents that build platoon-split transition matrices gain precision but need 4x the data to populate them reliably.

Park effects. Coors Field (Colorado) inflates run expectancy by approximately 15-20% relative to league average. An agent using a single transition matrix for all parks misestimates run expectancy at extreme venues. The fix: park-factor-adjusted transition matrices or a multiplicative park factor applied to the final run expectancy output.

Small sample sizes for pitcher-specific matrices. A starting pitcher throws approximately 800-1000 plate appearances per season. Populating a full 25x25 transition matrix from one season of data leaves many cells with fewer than 10 observations. Bayesian shrinkage toward league averages is necessary — weight the pitcher’s empirical matrix toward the league matrix using a sample-size-based blending factor.

Baserunning and defense. The basic Markov model treats all runners identically. A runner on first with 70th-percentile sprint speed has a different transition distribution (more stolen base attempts, more first-to-third on singles) than a slow catcher on first. Incorporating runner speed requires state augmentation or post-hoc adjustments to the transition matrix.

Extra innings and walk-off dynamics. The standard win expectancy model handles 9 innings cleanly but struggles with the MLB runner-on-second extra innings rule (introduced 2020). This rule changes the starting state of extra-inning half-innings from S0 (bases empty, 0 outs) to S2 (-2-, 0 outs), which has RE = 1.100 instead of 0.481. Agents must account for this rule when computing late-game WE.

FAQ

What are the 24 base-out states in baseball?

The 24 base-out states are every combination of 8 base configurations (empty, 1B, 2B, 3B, 1B+2B, 1B+3B, 2B+3B, loaded) and 3 out counts (0, 1, 2 outs). Each state fully describes the situation in a half-inning. A 25th absorbing state (3 outs) ends the inning. Run expectancy is calculated for each of these 24 states using the absorbing Markov chain solution.

How do you calculate run expectancy using a Markov chain?

Build a 25x25 transition matrix from play-by-play data where T[i][j] is the probability of moving from state i to state j. The 3-outs state is absorbing. Use the absorbing Markov chain formula RE = (I - Q)^{-1} * R, where Q is the 24x24 transient submatrix and R is the expected immediate runs scored per transition. This gives the expected runs from each state through the end of the inning.

What is win expectancy in baseball betting?

Win expectancy is the probability of winning the game given the current game state: inning, score differential, base-out state, and home/away status. It extends run expectancy by simulating full games using the Markov chain framework. An MLB betting agent uses win expectancy to identify mispriced live betting lines — if the agent’s WE model says 62% and the sportsbook implies 55%, that is a +EV opportunity worth sizing with the Kelly Criterion.

How does the Markov chain model connect to wOBA and FIP?

Linear weights like wOBA coefficients are derived directly from the run expectancy matrix. A single’s run value equals RE(post-single state) - RE(pre-single state) + runs scored on the play, averaged across all base-out states weighted by frequency. FIP uses the same principle: isolating pitcher-controlled events (strikeouts, walks, home runs) and weighting them by their run values. The 13/3/2 FIP coefficients trace back to the Markov chain run values.

What is F5 betting and why does the Markov chain model improve it?

F5 (first five innings) betting isolates starting pitcher performance by removing bullpen variance. The Markov chain model generates pitcher-specific run expectancy using individual pitcher transition matrices rather than league averages. An agent projects F5 runs for each starter, converts to Poisson probabilities, and compares against F5 lines on BetOnline or Bovada to find +EV spots.

What’s Next

The Markov chain framework makes MLB the most modelable major sport for autonomous agents. The next steps are combining this run expectancy engine with portfolio-level risk management and live betting execution.

  • Bankroll management for MLB models: Drawdown Math and Variance in Betting covers how to size your MLB bets when the edge is small but consistent over a 162-game season.
  • The Poisson foundation: This guide uses Poisson for F5 totals — see Poisson Distribution for Sports Modeling for the full derivation and extensions to other sports.
  • NFL companion model: NFL Mathematical Modeling applies a different modeling paradigm (regression-based) to football — compare the Markov chain approach to regression-based sport modeling.
  • Live betting execution: Once your WE model identifies mispriced live lines, you need infrastructure to execute. The Agent Betting Stack maps the full pipeline from model output to trade execution.
  • Feature engineering for production: Feature Engineering for Sports Prediction covers how to transform raw Retrosheet data into model-ready features at scale.