NBA win probability is a function of score margin, time remaining, pace, and team quality. The core model: WP = Phi(margin / (sigma * sqrt(possessions_remaining))). Market participants overreact to scoring runs that are statistically indistinguishable from noise — and that overreaction is where live betting agents find edge.

Why This Matters for Agents

Live betting is the fastest-growing segment of sports wagering, and NBA in-game markets are among the most liquid. A live betting agent needs a win probability model that updates every possession — fast enough to detect when sportsbook lines lag behind reality.

This is Layer 4 — Intelligence. The agent’s win probability engine ingests real-time game state (score, time, possession, lineup) from Layer 3 data feeds, computes its own probability estimate, compares it against live odds pulled from The Odds API via the Offshore Sportsbook API hub, and fires orders when edge exceeds its threshold. The entire loop — observe, compute, compare, execute — must complete in under a second. The math here is the compute step. For the EV decision framework that wraps this model, see Expected Value for Prediction Market Agents.

The Math

Pre-Game: The Log5 Method

Before the game starts, an agent needs a baseline win probability from team quality ratings. The log5 method, developed by Bill James, converts two teams’ win percentages into a head-to-head probability:

P(A beats B) = (pA - pA * pB) / (pA + pB - 2 * pA * pB)

Where pA is Team A’s win percentage and pB is Team B’s win percentage, both expressed as decimals.

The derivation assumes each team’s win percentage reflects performance against a league-average opponent. If Team A wins 70% against average and Team B wins 45% against average, the formula computes:

P(A beats B) = (0.70 - 0.70 * 0.45) / (0.70 + 0.45 - 2 * 0.70 * 0.45)
             = (0.70 - 0.315) / (1.15 - 0.63)
             = 0.385 / 0.52
             = 0.7404 → 74.0%

Home-court advantage adds approximately 2.5-3.0 percentage points in the NBA (down from ~3.5% pre-2020). Apply it as a prior adjustment before feeding into the in-game model.

In-Game: Score Differential Diffusion

The foundational insight: NBA scoring is approximately a random walk with drift. Each possession, the scoring margin changes by a small random amount. Over many possessions, the distribution of final margins is approximately normal.

The model rests on three observations:

  1. Score margin follows a diffusion process. The change in margin per possession has mean mu (the per-possession quality difference between teams) and standard deviation sigma (empirically ~2.0-2.2 across modern NBA seasons).

  2. Variance grows linearly with remaining possessions. If there are N possessions left, the final margin’s standard deviation is sigma * sqrt(N).

  3. Win probability = probability that final margin > 0 for the leading team. This is a standard normal CDF evaluation.

The core formula:

WP = Phi((margin + mu * N) / (sigma * sqrt(N)))

Where:

  • WP = win probability for the team with the lead
  • Phi = standard normal CDF
  • margin = current point differential (positive for leading team)
  • mu = per-possession scoring advantage (from team ratings)
  • N = estimated possessions remaining
  • sigma = per-possession scoring standard deviation (~2.0-2.2)

For two evenly matched teams (mu = 0), this simplifies to:

WP = Phi(margin / (sigma * sqrt(N)))

Estimating Possessions Remaining

Possessions remaining is the critical input. You need two things: pace and time.

Pace = possessions per 48 minutes for a team. The league average is roughly 100 possessions per team per game (2024-25 season). Individual teams range from ~95 (slow, grind-it-out teams) to ~105 (fast-paced teams).

Game pace combines both teams:

Game_Pace = (Pace_A + Pace_B) / 2

Possessions remaining:

N = Game_Pace * (minutes_remaining / 48)

A game with game pace of 100 and 6:00 remaining in the 4th quarter:

N = 100 * (6 / 48) = 12.5 possessions remaining

The Possession Estimate Formula

Possessions aren’t directly counted in box scores. The standard estimate:

Possessions ≈ FGA - OREB + TOV + 0.44 * FTA

Where FGA = field goal attempts, OREB = offensive rebounds, TOV = turnovers, FTA = free throw attempts. The 0.44 coefficient accounts for and-ones, technical free throws, and three-shot fouls that don’t end possessions.

Calibrating Sigma

The per-possession scoring standard deviation sigma is calibrated from historical play-by-play data. The procedure:

  1. For each game in your dataset, record the margin at every possession boundary.
  2. Compute the per-possession margin change: delta_margin(t) = margin(t) - margin(t-1).
  3. sigma = std(delta_margin) across all possessions in the dataset.

Empirically, sigma lands between 2.0 and 2.2 for NBA data from 2015-2025. The value is remarkably stable across seasons.

Win Probability Table (Evenly Matched Teams, sigma = 2.1)

Margin  |  24 min left  |  12 min left  |   6 min left  |   2 min left
--------+---------------+---------------+---------------+--------------
  +1    |     53.2%     |     54.5%     |     56.3%     |     62.2%
  +5    |     61.4%     |     68.1%     |     75.8%     |     89.1%
 +10    |     72.0%     |     82.1%     |     91.0%     |     98.4%
 +15    |     81.1%     |     91.3%     |     97.3%     |     99.8%
 +20    |     87.8%     |     96.1%     |     99.3%     |    ~100.0%

Key takeaway: a 10-point lead with 6 minutes left is a 91% win probability for evenly matched teams. With 2 minutes left, it’s 98.4%.

The Momentum Myth

Bettors and commentators constantly reference momentum — “the Celtics have all the momentum after that 12-0 run.” The data says otherwise.

Statistical tests on NBA play-by-play data (2015-2025) consistently show that scoring sequences are indistinguishable from independent Bernoulli trials. A team that just scored on three consecutive possessions is not meaningfully more likely to score on the next possession than their base rate predicts.

The evidence:

  • Serial correlation in possession outcomes: r ≈ 0.02-0.04. Statistically significant only because of massive sample sizes; practically meaningless.
  • Hot hand in shooting: The “hot hand” effect exists but is tiny — approximately +1.5-2.0 percentage points on the next shot after making 3+ in a row. This is far smaller than what bettors perceive.
  • Run frequency: The number of 8-0, 10-0, and 12-0 runs in actual NBA games matches the expected frequency from a random model with each team’s scoring rate. Runs happen because both teams are scoring at roughly 50% of possessions — streaks are inevitable in any Bernoulli sequence.

The implication for agents: when a sportsbook shifts its live line by 3-4 points after a visible scoring run, the model-based win probability has barely moved. That’s edge.

Player Impact and Lineup Adjustments

The base model assumes constant team quality, but NBA lineups change every few minutes. Three metrics quantify individual player impact:

BPM (Box Plus/Minus): Estimates a player’s per-100-possession contribution using box score stats. Range: -5 (replacement level) to +10 (MVP). Publicly available, easy to compute.

RAPTOR (FiveThirtyEight, now discontinued but methodology published): Combined box score and on/off data. More accurate than BPM for role players.

EPM (Estimated Plus-Minus): The current gold standard from Dunks & Threes. Uses regularized adjusted plus-minus with box score priors. Correlates most strongly with future team performance.

For lineup adjustments, an agent computes the net rating difference between the current lineup and the team’s average:

lineup_adjustment = (sum(EPM of current 5) - team_avg_EPM) / 5
mu_adjusted = mu_base + lineup_adjustment / possessions_per_minute

When a star sits with 4 fouls, the agent’s model detects the shift before the live line catches up.

Worked Examples

Example 1: Celtics vs. Lakers, 4th Quarter

Game state: Celtics lead 98-91, 8:24 remaining in Q4. Game pace: 101.2 possessions.

minutes_remaining = 8.4
N = 101.2 * (8.4 / 48) = 17.71 possessions

margin = 98 - 91 = 7

# Celtics are the stronger team; pre-game rating diff = +3.2 points/game
# Convert to per-possession: mu = 3.2 / 101.2 = 0.0316 per possession

sigma = 2.1

WP = Phi((7 + 0.0316 * 17.71) / (2.1 * sqrt(17.71)))
   = Phi((7 + 0.56) / (2.1 * 4.208))
   = Phi(7.56 / 8.837)
   = Phi(0.8556)
   = 0.8039 → 80.4%

The sportsbook live line shows Celtics moneyline at -280 (implied 73.7%). The agent’s model says 80.4%. That’s 6.7 percentage points of edge — well above a typical 3-5% threshold. The agent bets Celtics live moneyline.

Example 2: Suns vs. Nuggets — Post-Run Overreaction

Nuggets led 78-72 heading into a timeout. During a 2-minute stretch, the Suns go on a 10-0 run. Score is now Suns 82, Nuggets 78. 5:15 left in Q3.

The sportsbook panics: Suns live moneyline moves to -190 (implied 65.5%).

minutes_remaining = 5.25 + 12.0 = 17.25  (rest of Q3 + full Q4)
Game_Pace = 98.5
N = 98.5 * (17.25 / 48) = 35.41 possessions

margin = 4 (Suns lead)
mu = -0.015 (Nuggets slightly better, home court)
sigma = 2.1

WP_suns = Phi((4 + (-0.015) * 35.41) / (2.1 * sqrt(35.41)))
        = Phi((4 - 0.531) / (2.1 * 5.951))
        = Phi(3.469 / 12.497)
        = Phi(0.2776)
        = 0.6094 → 60.9%

The model says 60.9%. The book implies 65.5%. The book has overreacted to the run. A sharp agent takes Nuggets live moneyline at +160 (implied 38.5% vs. model’s 39.1%). The edge is marginal on the Nuggets side — this is a pass or a small play depending on the agent’s threshold.

Example 3: Totals Market — Pace-Adjusted

Pre-game total: 224.5 at BetOnline. Two fast teams: Pacers (Pace 104.8) and Hawks (Pace 103.2).

Expected_Pace = (104.8 + 103.2) / 2 = 104.0
League_Avg_Pace = 100.0

# Pacers ORtg: 115.2, DRtg: 113.8
# Hawks ORtg: 113.5, DRtg: 116.1

# Expected points for Pacers:
# ORtg_Pacers adjusted vs Hawks DRtg: (115.2 + 116.1) / 2 = 115.65 per 100 poss
# (Simple average of opponent defense and own offense against league avg)

Pacers_expected = 115.65 * (104.0 / 100) = 120.28
# Hawks: (113.5 + 113.8) / 2 = 113.65 per 100 poss
Hawks_expected = 113.65 * (104.0 / 100) = 118.20

Total_expected = 120.28 + 118.20 = 238.5

Model projects 238.5, line is 224.5. That’s a 14-point gap — likely because the simple model is missing defensive matchup specifics, but even with error bounds, the over looks strong. The agent takes Over 224.5 at -110 on BetOnline.

Implementation

import numpy as np
from scipy.stats import norm
from dataclasses import dataclass
from typing import Optional


@dataclass
class GameState:
    """Current NBA game state for win probability calculation."""
    home_score: int
    away_score: int
    period: int              # 1-4 (or 5+ for OT)
    minutes_remaining: float # in current period
    home_pace: float         # team pace rating (possessions per 48 min)
    away_pace: float
    home_ortg: float         # offensive rating per 100 possessions
    home_drtg: float         # defensive rating per 100 possessions
    away_ortg: float
    away_drtg: float
    home_lineup_adj: float = 0.0  # EPM adjustment for current lineup
    away_lineup_adj: float = 0.0


@dataclass
class WinProbResult:
    """Output of win probability calculation."""
    home_wp: float
    away_wp: float
    margin: int
    possessions_remaining: float
    mu: float
    sigma: float
    model_version: str = "diffusion-v1"


def total_minutes_remaining(period: int, minutes_in_period: float) -> float:
    """
    Calculate total game minutes remaining.

    NBA periods are 12 minutes each. Overtime periods are 5 minutes.
    """
    if period <= 4:
        remaining_full_periods = 4 - period
        return minutes_in_period + (remaining_full_periods * 12.0)
    else:
        # Overtime — only current period matters for estimation
        return minutes_in_period


def estimate_possessions_remaining(
    game_pace: float,
    minutes_remaining: float
) -> float:
    """
    Estimate possessions remaining in the game.

    game_pace: combined pace (possessions per 48 minutes per team)
    minutes_remaining: total minutes left in regulation
    """
    return game_pace * (minutes_remaining / 48.0)


def compute_mu(
    home_ortg: float,
    home_drtg: float,
    away_ortg: float,
    away_drtg: float,
    home_lineup_adj: float,
    away_lineup_adj: float,
    game_pace: float,
    home_court_advantage: float = 2.8
) -> float:
    """
    Compute per-possession scoring advantage (mu) for home team.

    Returns points per possession advantage. Positive = home favored.
    """
    # Net rating difference per 100 possessions
    home_net = home_ortg - home_drtg
    away_net = away_ortg - away_drtg

    # Expected margin per 100 possessions (home perspective)
    rating_diff = (home_net - away_net) / 2.0 + home_court_advantage

    # Add lineup adjustments
    rating_diff += (home_lineup_adj - away_lineup_adj)

    # Convert to per-possession
    mu = rating_diff / 100.0
    return mu


def nba_win_probability(
    state: GameState,
    sigma: float = 2.1
) -> WinProbResult:
    """
    Compute NBA win probability using score differential diffusion model.

    WP = Phi((margin + mu * N) / (sigma * sqrt(N)))

    Parameters:
        state: Current game state
        sigma: Per-possession scoring standard deviation (default 2.1)

    Returns:
        WinProbResult with home and away win probabilities
    """
    margin = state.home_score - state.away_score
    game_pace = (state.home_pace + state.away_pace) / 2.0

    mins_left = total_minutes_remaining(state.period, state.minutes_remaining)
    n_possessions = estimate_possessions_remaining(game_pace, mins_left)

    # Handle end-of-game
    if n_possessions < 0.5:
        home_wp = 1.0 if margin > 0 else (0.5 if margin == 0 else 0.0)
        return WinProbResult(
            home_wp=home_wp,
            away_wp=1.0 - home_wp,
            margin=margin,
            possessions_remaining=n_possessions,
            mu=0.0,
            sigma=sigma
        )

    mu = compute_mu(
        state.home_ortg, state.home_drtg,
        state.away_ortg, state.away_drtg,
        state.home_lineup_adj, state.away_lineup_adj,
        game_pace
    )

    z = (margin + mu * n_possessions) / (sigma * np.sqrt(n_possessions))
    home_wp = float(norm.cdf(z))

    return WinProbResult(
        home_wp=home_wp,
        away_wp=1.0 - home_wp,
        margin=margin,
        possessions_remaining=n_possessions,
        mu=mu,
        sigma=sigma
    )


def log5_pregame(win_pct_a: float, win_pct_b: float) -> float:
    """
    Log5 method for pre-game win probability.

    P(A beats B) = (pA - pA*pB) / (pA + pB - 2*pA*pB)
    """
    num = win_pct_a - win_pct_a * win_pct_b
    den = win_pct_a + win_pct_b - 2 * win_pct_a * win_pct_b
    if den == 0:
        return 0.5
    return num / den


def detect_live_edge(
    model_wp: float,
    book_odds: int,
    min_edge: float = 0.03
) -> dict:
    """
    Compare model win probability against sportsbook live odds.

    Parameters:
        model_wp: Model's win probability (0 to 1)
        book_odds: American odds from sportsbook (e.g., -150, +130)
        min_edge: Minimum edge threshold to signal a bet (default 3%)

    Returns:
        Dict with edge analysis
    """
    # Convert American odds to implied probability
    if book_odds < 0:
        implied = abs(book_odds) / (abs(book_odds) + 100)
    else:
        implied = 100 / (book_odds + 100)

    edge = model_wp - implied

    # Convert odds to decimal for EV calculation
    if book_odds < 0:
        decimal_odds = 1 + 100 / abs(book_odds)
    else:
        decimal_odds = 1 + book_odds / 100

    ev_per_dollar = model_wp * decimal_odds - 1.0

    return {
        "model_wp": round(model_wp, 4),
        "implied_wp": round(implied, 4),
        "edge": round(edge, 4),
        "ev_per_dollar": round(ev_per_dollar, 4),
        "signal": "BET" if edge >= min_edge else "PASS",
        "book_odds": book_odds,
        "decimal_odds": round(decimal_odds, 3)
    }


# --- Example usage ---

if __name__ == "__main__":
    # Pre-game: Celtics (70% season) vs Lakers (55% season)
    pregame = log5_pregame(0.70, 0.55)
    print(f"Pre-game: Celtics {pregame:.1%} vs Lakers {1-pregame:.1%}")
    print()

    # In-game: Celtics lead 98-91, 8:24 left in Q4
    state = GameState(
        home_score=98,
        away_score=91,
        period=4,
        minutes_remaining=8.4,
        home_pace=99.5,   # Celtics
        away_pace=102.3,  # Lakers
        home_ortg=118.2,
        home_drtg=110.5,
        away_ortg=114.8,
        away_drtg=112.3
    )

    result = nba_win_probability(state)
    print(f"In-game: Celtics {result.home_wp:.1%} vs Lakers {result.away_wp:.1%}")
    print(f"Margin: {result.margin}, Possessions left: {result.possessions_remaining:.1f}")
    print(f"Mu: {result.mu:.4f} per possession")
    print()

    # Edge detection: book has Celtics -280
    edge = detect_live_edge(result.home_wp, -280)
    print(f"Edge analysis:")
    for k, v in edge.items():
        print(f"  {k}: {v}")

Integration with The Odds API

An agent needs live odds to compare against. Here’s the live odds polling loop:

import requests
import time
from typing import Optional


ODDS_API_KEY = "YOUR_KEY"  # from https://the-odds-api.com
ODDS_API_BASE = "https://api.the-odds-api.com/v4"


def get_live_nba_odds(
    api_key: str = ODDS_API_KEY,
    bookmakers: str = "betonline,bovada,mybookieag",
    markets: str = "h2h"
) -> list[dict]:
    """
    Pull live NBA odds from The Odds API.

    Returns list of games with current live odds.
    """
    resp = requests.get(
        f"{ODDS_API_BASE}/sports/basketball_nba/odds",
        params={
            "apiKey": api_key,
            "regions": "us",
            "markets": markets,
            "bookmakers": bookmakers,
            "oddsFormat": "american"
        }
    )
    resp.raise_for_status()
    return resp.json()


def extract_moneyline(game: dict, team_name: str) -> Optional[int]:
    """Extract moneyline odds for a specific team from Odds API response."""
    for bookmaker in game.get("bookmakers", []):
        for market in bookmaker.get("markets", []):
            if market["key"] == "h2h":
                for outcome in market["outcomes"]:
                    if outcome["name"] == team_name:
                        return outcome["price"]
    return None


def live_betting_loop(
    game_state_fn,  # callable that returns current GameState
    interval_seconds: int = 30,
    min_edge: float = 0.03
):
    """
    Main live betting agent loop.

    game_state_fn: function returning current GameState from data feed
    interval_seconds: polling interval
    min_edge: minimum edge to trigger bet signal
    """
    while True:
        state = game_state_fn()
        if state is None:
            print("Game ended or no data.")
            break

        result = nba_win_probability(state)

        # Pull live odds
        games = get_live_nba_odds()
        # Match game and extract odds (simplified — production would match by team/date)

        print(f"[{state.period}Q {state.minutes_remaining:.1f}min] "
              f"Home {state.home_score}-{state.away_score} Away | "
              f"WP: {result.home_wp:.1%}")

        time.sleep(interval_seconds)

Limitations and Edge Cases

1. End-of-game non-linearity. The diffusion model assumes continuous scoring. In the final 2 minutes, intentional fouling, clock management, and free throw shooting fundamentally change the dynamics. A 3-point lead with 30 seconds left has a different WP than the model predicts because the trailing team will foul and shoot threes. Dedicated end-of-game models (like Inpredictable’s) use possession-by-possession simulation for the final 2 minutes.

2. Free throw shooting variance. Late-game WP depends heavily on free throw percentage. A team that shoots 82% from the line is in a fundamentally different position than one shooting 72% when protecting a lead via intentional fouls. The base model doesn’t account for team-specific FT%.

3. Foul trouble and ejections. If a star picks up their 5th foul in the 3rd quarter, the model’s lineup adjustment captures some of this, but it can’t fully anticipate the strategic constraint — the coach will play the star fewer minutes, and the team’s offense is worse even when the star is on the floor (playing conservatively to avoid the 6th foul).

4. Overtime uncertainty. The model assumes 48-minute regulation. Overtime requires a separate branch: if the game is tied with 0 possessions remaining, WP should reflect home-court advantage in OT (~52-53%) rather than 50%.

5. Sigma instability in blowouts. When one team leads by 25+ in the 4th quarter, both teams play bench lineups and the per-possession variance changes. The model’s sigma calibration comes from competitive minutes and breaks down in garbage time.

6. Live data latency. The model is only as good as its inputs. If the agent’s game state feed lags by 15 seconds and a basket was scored during that window, the WP estimate is stale. Production agents need sub-second data feeds from providers like Sportradar or direct arena feeds.

FAQ

How do you calculate NBA win probability from score margin and time remaining?

NBA win probability follows the score differential diffusion model: WP = Phi(margin / (sigma * sqrt(possessions_remaining))), where Phi is the standard normal CDF, margin is the current lead, sigma is approximately 2.0-2.2 (calibrated from play-by-play data), and possessions_remaining is estimated from pace and time left. A 10-point lead with 6 minutes left yields roughly 91% win probability for evenly matched teams.

What is the log5 method for predicting NBA game outcomes?

The log5 method estimates head-to-head win probability from team win percentages: P(A beats B) = (pA - pApB) / (pA + pB - 2pA*pB). A 70% team facing a 45% team has a 74.0% win probability. Bill James developed this method and it remains the gold standard for quick matchup estimation in basketball and baseball.

Does momentum exist in NBA games for betting purposes?

Statistical analysis consistently shows that NBA scoring runs are indistinguishable from random Bernoulli sequences. Momentum is a narrative construct, not a predictive signal. However, bettors and sportsbooks overreact to visible runs — a team hitting three straight threes will cause live lines to shift more than the math justifies, creating exploitable windows for model-driven agents.

How does pace affect NBA win probability modeling?

Pace determines possessions remaining, which controls variance. A game averaging 105 possessions has different win probability dynamics than one averaging 95. Higher pace means more possessions left, which increases variance and reduces the certainty of a given lead. Agents must estimate game-specific pace in real time, not use league averages.

How do NBA live betting agents detect mispriced lines?

A live betting agent compares its model’s win probability against the sportsbook’s implied probability from live odds. If the agent’s model says 72% and the book implies 65%, that’s a +EV opportunity. The agent pulls live odds via The Odds API, runs the win probability model on current game state, and executes when edge exceeds a configurable threshold (typically 3-5%). See the expected value guide for the EV calculation framework.

What’s Next

This model gives an agent real-time win probability for NBA games. The next steps connect it to the broader agent architecture:

  • Sizing the bet: Once the agent detects edge, it needs to know how much to wager. The Kelly Criterion provides the mathematically optimal bet size given the model’s edge estimate and bankroll state.
  • Evaluating performance: A live betting agent measures itself by closing line value (CLV) — does the line move in its direction after it bets? CLV is the single best predictor of long-term profitability in sharp betting.
  • Other sport models: The Expected Goals (xG) model applies similar possession-level thinking to soccer, and the NFL mathematical modeling guide covers football-specific win probability.
  • The full stack: See the Agent Betting Stack for how this Layer 4 intelligence module connects to data feeds (Layer 3), wallet infrastructure (Layer 2), and the overall agent framework.
  • Live odds infrastructure: The Offshore Sportsbook API hub documents which books offer live odds via API for automated agent access.