Case Study 1: Building a Kelly-Optimal Portfolio for a Full NFL Sunday

Overview

A professional sports bettor faces a common weekly challenge: on a typical NFL Sunday, their model identifies 8-12 positive-expected-value opportunities across the slate of games. Each opportunity has a different estimated probability, different available odds, and varying degrees of correlation with other opportunities on the same slate. The bettor must allocate their bankroll across these opportunities to maximize long-term growth while managing the risk of a catastrophic single-day loss. This case study walks through the complete process of constructing a Kelly-optimal betting portfolio for a real-world NFL Sunday, from model output to final bet slip.

The Setup

Our bettor begins the week with a bankroll of $25,000 distributed across three sportsbook accounts. Their NFL model produces probability estimates for all games, and by Saturday evening they have identified 10 positive-EV opportunities for Sunday. The model's historical accuracy over three seasons shows a true edge of approximately 2.8% above the break-even point, with a standard error on individual game probabilities of approximately 0.03.

The 10 opportunities, with the best available odds from line shopping across three books, are presented in the table below. Each opportunity has been confirmed as having positive expected value after removing the vig from the closing line.

The Portfolio Construction Process

The first step is to calculate the individual Kelly fraction for each bet. The second step is to estimate the correlation structure across the 10 bets. The third step is to apply portfolio optimization to determine the final allocation. The fourth step is to map the allocations to specific sportsbook accounts.

Step 1: Individual Kelly Fractions

For each of the 10 bets, the bettor calculates the Kelly fraction using $f^* = (pb - q) / b$ where $p$ is the model probability, $b$ is the net decimal odds, and $q = 1 - p$.

The model identifies bets ranging from a slight edge (1.5% above break-even) to a strong edge (5.2% above break-even). The full Kelly fractions range from 1.6% to 10.5% of bankroll. At quarter-Kelly, these become 0.4% to 2.6%.

Step 2: Correlation Estimation

Not all 10 bets are independent. Three pairs of bets involve the same game (a spread bet and a total bet on the same game), which creates mild positive correlation. Two additional pairs involve teams in the same division, creating a weak schedule-based correlation. The bettor estimates the following correlation structure:

  • Same-game spread/total pairs: $\rho = 0.12$
  • Same-division teams: $\rho = 0.04$
  • All other pairs: $\rho = 0.01$ (near-zero residual correlation from league-wide factors)

Step 3: Portfolio Optimization

The bettor applies mean-variance optimization with a risk aversion parameter calibrated to quarter-Kelly. The optimization maximizes the utility function:

$$U = \mu_P - \frac{\lambda}{2} \sigma_P^2$$

subject to the constraints that each allocation is non-negative and the total allocation does not exceed 15% of bankroll.

Step 4: Account Mapping

The final allocations are mapped to specific sportsbook accounts based on which book offers the best line for each bet. The bettor also considers account balance constraints: no single account should have more than 60% of total bets by dollar volume to maintain account longevity.

Implementation

"""
NFL Sunday Kelly Portfolio Construction

Constructs an optimal betting portfolio for a slate of NFL games
using the Kelly criterion adapted through mean-variance optimization.
Accounts for inter-bet correlations and multi-account constraints.

Author: The Sports Betting Textbook
Chapter: 14 - Advanced Bankroll and Staking Strategies
"""

from __future__ import annotations

import numpy as np
from dataclasses import dataclass, field
from scipy.optimize import minimize


@dataclass
class BettingOpportunity:
    """A single betting opportunity with model estimate and market odds.

    Attributes:
        game_id: Unique game identifier.
        bet_description: Human-readable description of the bet.
        model_probability: Model's estimated win probability.
        best_odds_american: Best available American odds from line shopping.
        best_book: Sportsbook offering the best line.
        bet_type: Type of bet (spread, total, moneyline).
    """

    game_id: str
    bet_description: str
    model_probability: float
    best_odds_american: int
    best_book: str
    bet_type: str

    @property
    def net_decimal_odds(self) -> float:
        """Convert American odds to net decimal odds (payout per dollar)."""
        if self.best_odds_american > 0:
            return self.best_odds_american / 100.0
        return 100.0 / abs(self.best_odds_american)

    @property
    def implied_probability(self) -> float:
        """Market implied probability including vig."""
        if self.best_odds_american > 0:
            return 100.0 / (self.best_odds_american + 100.0)
        return abs(self.best_odds_american) / (abs(self.best_odds_american) + 100.0)

    @property
    def expected_return(self) -> float:
        """Expected return per dollar wagered."""
        b = self.net_decimal_odds
        p = self.model_probability
        return p * b - (1 - p)

    @property
    def kelly_fraction(self) -> float:
        """Full Kelly fraction."""
        b = self.net_decimal_odds
        p = self.model_probability
        q = 1 - p
        f = (p * b - q) / b
        return max(0.0, f)

    @property
    def return_variance(self) -> float:
        """Variance of the bet return."""
        b = self.net_decimal_odds
        p = self.model_probability
        return p * (1 - p) * (b + 1) ** 2


@dataclass
class PortfolioResult:
    """Results of portfolio optimization.

    Attributes:
        allocations: Mapping of bet description to allocation fraction.
        total_allocation: Sum of all allocations.
        portfolio_expected_return: Weighted expected return.
        portfolio_std: Portfolio standard deviation.
        portfolio_sharpe: Sharpe ratio of the portfolio.
        dollar_bets: Mapping of bet description to dollar amount.
    """

    allocations: dict[str, float]
    total_allocation: float
    portfolio_expected_return: float
    portfolio_std: float
    portfolio_sharpe: float
    dollar_bets: dict[str, float] = field(default_factory=dict)


def build_correlation_matrix(
    bets: list[BettingOpportunity],
    same_game_pairs: list[tuple[int, int]],
    same_division_pairs: list[tuple[int, int]],
    same_game_corr: float = 0.12,
    same_division_corr: float = 0.04,
    baseline_corr: float = 0.01,
) -> np.ndarray:
    """Build the correlation matrix for a set of betting opportunities.

    Args:
        bets: List of betting opportunities.
        same_game_pairs: Index pairs of bets on the same game.
        same_division_pairs: Index pairs of bets on same-division teams.
        same_game_corr: Correlation for same-game bet pairs.
        same_division_corr: Correlation for same-division pairs.
        baseline_corr: Baseline correlation for all other pairs.

    Returns:
        Correlation matrix as a numpy array.
    """
    n = len(bets)
    corr = np.full((n, n), baseline_corr)
    np.fill_diagonal(corr, 1.0)

    for i, j in same_game_pairs:
        corr[i, j] = same_game_corr
        corr[j, i] = same_game_corr

    for i, j in same_division_pairs:
        corr[i, j] = same_division_corr
        corr[j, i] = same_division_corr

    return corr


def build_covariance_matrix(
    bets: list[BettingOpportunity],
    correlation_matrix: np.ndarray,
) -> np.ndarray:
    """Build the covariance matrix from bet variances and correlations.

    Args:
        bets: List of betting opportunities.
        correlation_matrix: Pairwise correlation matrix.

    Returns:
        Covariance matrix as a numpy array.
    """
    stds = np.array([np.sqrt(b.return_variance) for b in bets])
    return np.outer(stds, stds) * correlation_matrix


def optimize_portfolio(
    bets: list[BettingOpportunity],
    covariance_matrix: np.ndarray,
    kelly_multiplier: float = 0.25,
    max_total_allocation: float = 0.15,
    max_single_allocation: float = 0.03,
    risk_aversion: float = 4.0,
) -> PortfolioResult:
    """Find the optimal portfolio allocation using mean-variance optimization.

    Args:
        bets: List of betting opportunities.
        covariance_matrix: Covariance matrix of bet returns.
        kelly_multiplier: Fraction of full Kelly to use as starting point.
        max_total_allocation: Maximum total bankroll fraction.
        max_single_allocation: Maximum fraction on any single bet.
        risk_aversion: Lambda parameter for the utility function.

    Returns:
        PortfolioResult with optimal allocations.
    """
    n = len(bets)
    mu = np.array([b.expected_return for b in bets])

    def neg_utility(f: np.ndarray) -> float:
        port_return = mu @ f
        port_variance = f @ covariance_matrix @ f
        return -(port_return - (risk_aversion / 2) * port_variance)

    constraints = [
        {"type": "ineq", "fun": lambda f: max_total_allocation - np.sum(f)},
    ]

    bounds = [(0.0, max_single_allocation) for _ in range(n)]

    # Initial guess: scaled Kelly fractions
    f0 = np.array([b.kelly_fraction * kelly_multiplier for b in bets])
    total_f0 = f0.sum()
    if total_f0 > max_total_allocation:
        f0 = f0 * (max_total_allocation / total_f0)
    f0 = np.clip(f0, 0.0, max_single_allocation)

    result = minimize(
        neg_utility,
        f0,
        method="SLSQP",
        bounds=bounds,
        constraints=constraints,
        options={"maxiter": 1000, "ftol": 1e-12},
    )

    optimal_f = np.maximum(result.x, 0.0)

    port_return = mu @ optimal_f
    port_variance = optimal_f @ covariance_matrix @ optimal_f
    port_std = np.sqrt(port_variance)
    sharpe = port_return / port_std if port_std > 0 else 0.0

    allocations = {
        bets[i].bet_description: round(optimal_f[i], 6) for i in range(n)
    }

    return PortfolioResult(
        allocations=allocations,
        total_allocation=round(optimal_f.sum(), 6),
        portfolio_expected_return=round(port_return, 6),
        portfolio_std=round(port_std, 6),
        portfolio_sharpe=round(sharpe, 4),
    )


def allocate_to_accounts(
    portfolio: PortfolioResult,
    bets: list[BettingOpportunity],
    bankroll: float,
    account_balances: dict[str, float],
    max_account_share: float = 0.60,
) -> dict[str, list[dict[str, float]]]:
    """Map portfolio allocations to specific sportsbook accounts.

    Args:
        portfolio: Optimized portfolio result.
        bets: List of betting opportunities.
        bankroll: Total bankroll.
        account_balances: Current balance at each sportsbook.
        max_account_share: Maximum share of total action at any single book.

    Returns:
        Dictionary mapping book names to lists of bets with dollar amounts.
    """
    account_bets: dict[str, list[dict[str, float]]] = {
        book: [] for book in account_balances
    }
    account_totals: dict[str, float] = {book: 0.0 for book in account_balances}
    total_action = 0.0

    for bet in bets:
        alloc = portfolio.allocations.get(bet.bet_description, 0.0)
        if alloc <= 0:
            continue

        dollar_amount = round(alloc * bankroll, 2)
        book = bet.best_book

        if account_balances.get(book, 0) >= dollar_amount:
            account_bets[book].append({
                "bet": bet.bet_description,
                "amount": dollar_amount,
                "odds": bet.best_odds_american,
            })
            account_totals[book] += dollar_amount
            total_action += dollar_amount

    portfolio.dollar_bets = {
        bet.bet_description: round(
            portfolio.allocations.get(bet.bet_description, 0.0) * bankroll, 2
        )
        for bet in bets
    }

    return account_bets


def simulate_sunday_outcomes(
    bets: list[BettingOpportunity],
    portfolio: PortfolioResult,
    bankroll: float,
    n_simulations: int = 50000,
    seed: int = 42,
) -> dict[str, float]:
    """Simulate outcomes for the Sunday slate.

    Args:
        bets: List of betting opportunities.
        portfolio: Optimized portfolio result.
        bankroll: Starting bankroll.
        n_simulations: Number of simulation runs.
        seed: Random seed.

    Returns:
        Dictionary of summary statistics.
    """
    rng = np.random.default_rng(seed)
    n = len(bets)

    alloc_array = np.array([
        portfolio.allocations.get(b.bet_description, 0.0) for b in bets
    ])
    odds_array = np.array([b.net_decimal_odds for b in bets])
    prob_array = np.array([b.model_probability for b in bets])

    profits = np.zeros(n_simulations)

    for sim in range(n_simulations):
        outcomes = rng.random(n) < prob_array
        bet_profits = np.where(
            outcomes,
            alloc_array * odds_array * bankroll,
            -alloc_array * bankroll,
        )
        profits[sim] = bet_profits.sum()

    ending_bankrolls = bankroll + profits

    return {
        "mean_profit": round(float(profits.mean()), 2),
        "median_profit": round(float(np.median(profits)), 2),
        "std_profit": round(float(profits.std()), 2),
        "prob_positive": round(float((profits > 0).mean()), 4),
        "prob_loss_5pct": round(float((profits < -0.05 * bankroll).mean()), 4),
        "worst_5pct": round(float(np.percentile(profits, 5)), 2),
        "best_5pct": round(float(np.percentile(profits, 95)), 2),
        "max_loss": round(float(profits.min()), 2),
        "max_win": round(float(profits.max()), 2),
    }


def main() -> None:
    """Run the full NFL Sunday portfolio construction."""
    print("=" * 70)
    print("NFL SUNDAY KELLY PORTFOLIO CONSTRUCTION")
    print("=" * 70)

    bankroll = 25000.0
    print(f"\nBankroll: ${bankroll:,.0f}")

    bets = [
        BettingOpportunity("KC-BUF", "KC -3 (spread)", 0.555, -108, "BookA", "spread"),
        BettingOpportunity("KC-BUF", "Over 48.5", 0.545, -105, "BookB", "total"),
        BettingOpportunity("SF-DAL", "SF -1.5 (spread)", 0.540, -110, "BookA", "spread"),
        BettingOpportunity("BAL-CIN", "BAL -6.5 (spread)", 0.560, -110, "BookC", "spread"),
        BettingOpportunity("BAL-CIN", "Under 47.5", 0.535, -108, "BookA", "total"),
        BettingOpportunity("DET-GB", "DET ML", 0.520, +105, "BookB", "moneyline"),
        BettingOpportunity("MIA-NYJ", "MIA -3 (spread)", 0.555, -105, "BookC", "spread"),
        BettingOpportunity("PHI-NYG", "PHI -7 (spread)", 0.570, -112, "BookA", "spread"),
        BettingOpportunity("HOU-JAX", "Under 44.5", 0.540, -110, "BookB", "total"),
        BettingOpportunity("LAR-SEA", "SEA +1.5 (spread)", 0.535, -106, "BookC", "spread"),
    ]

    print(f"\n--- INDIVIDUAL BET ANALYSIS ---")
    print(f"{'Bet':<25} {'Prob':>6} {'Odds':>6} {'Edge':>6} {'Kelly':>7} {'QK':>6}")
    print("-" * 62)
    for b in bets:
        edge = b.expected_return * 100
        kelly = b.kelly_fraction * 100
        qk = kelly / 4
        print(
            f"{b.bet_description:<25} {b.model_probability:>5.1%} "
            f"{b.best_odds_american:>+5d} {edge:>5.1f}% "
            f"{kelly:>6.2f}% {qk:>5.2f}%"
        )

    same_game_pairs = [(0, 1), (3, 4)]
    same_division_pairs = [(0, 5), (2, 7)]

    corr = build_correlation_matrix(
        bets, same_game_pairs, same_division_pairs
    )
    cov = build_covariance_matrix(bets, corr)

    print(f"\n--- PORTFOLIO OPTIMIZATION ---")
    portfolio = optimize_portfolio(
        bets, cov,
        kelly_multiplier=0.25,
        max_total_allocation=0.15,
        max_single_allocation=0.03,
        risk_aversion=4.0,
    )

    print(f"\n{'Bet':<25} {'Allocation':>11} {'Dollar Bet':>11}")
    print("-" * 50)
    for b in bets:
        alloc = portfolio.allocations.get(b.bet_description, 0.0)
        dollars = alloc * bankroll
        if alloc > 0.0001:
            print(f"{b.bet_description:<25} {alloc:>10.2%} ${dollars:>9,.0f}")

    print(f"\nTotal allocation: {portfolio.total_allocation:.2%} "
          f"(${portfolio.total_allocation * bankroll:,.0f})")
    print(f"Portfolio expected return: {portfolio.portfolio_expected_return:.4%}")
    print(f"Portfolio std deviation:   {portfolio.portfolio_std:.4%}")
    print(f"Portfolio Sharpe ratio:    {portfolio.portfolio_sharpe:.4f}")

    print(f"\n--- ACCOUNT ALLOCATION ---")
    account_balances = {"BookA": 10000, "BookB": 8000, "BookC": 7000}
    account_bets = allocate_to_accounts(
        portfolio, bets, bankroll, account_balances
    )

    for book, book_bets in account_bets.items():
        if book_bets:
            total = sum(b["amount"] for b in book_bets)
            print(f"\n  {book} (balance: ${account_balances[book]:,}):")
            for b in book_bets:
                print(f"    {b['bet']:<25} ${b['amount']:>7,.0f} ({b['odds']:>+4d})")
            print(f"    {'Total':<25} ${total:>7,.0f}")

    print(f"\n--- SIMULATION RESULTS ---")
    sim_results = simulate_sunday_outcomes(bets, portfolio, bankroll)
    print(f"  Expected profit:     ${sim_results['mean_profit']:>+8,.0f}")
    print(f"  Median profit:       ${sim_results['median_profit']:>+8,.0f}")
    print(f"  Std deviation:       ${sim_results['std_profit']:>8,.0f}")
    print(f"  P(profitable day):   {sim_results['prob_positive']:>7.1%}")
    print(f"  P(lose > 5%):        {sim_results['prob_loss_5pct']:>7.1%}")
    print(f"  Worst 5th pctl:      ${sim_results['worst_5pct']:>+8,.0f}")
    print(f"  Best 95th pctl:      ${sim_results['best_5pct']:>+8,.0f}")

    print("\n" + "=" * 70)
    print("Portfolio construction complete.")


if __name__ == "__main__":
    main()

Results

The portfolio optimization allocates approximately 12.5% of the $25,000 bankroll across the 10 bets, with individual allocations ranging from 0.4% to 2.8%. The total dollar amount at risk is approximately $3,125, spread across three sportsbook accounts.

The simulation shows that on a typical Sunday, the portfolio produces a mean profit of approximately $85-110 (about 0.35-0.45% of bankroll), with a standard deviation of approximately $900. The probability of a profitable Sunday is approximately 54-56%, and the probability of losing more than 5% of the bankroll in a single day is approximately 2-3%.

The correlation adjustments reduce the allocations to same-game bet pairs by approximately 8-12% compared to treating them as independent. This is a modest but meaningful effect: over a full season, ignoring correlations would lead to approximately 0.3% additional portfolio risk that is uncompensated by additional expected return.

Practical Application

The most important practical insight from this case study is the magnitude of the allocations. A $25,000 bettor with genuine 2.8% edge, using quarter-Kelly with portfolio constraints, bets $100-$700 per game. This is far smaller than what most recreational bettors wager, illustrating how mathematical rigor constrains bet sizing.

The second insight is the importance of line shopping. The 10 bets are spread across three books, each offering the best price for specific games. The average improvement from line shopping is approximately 0.3 points of spread or 3 cents of juice, which translates to roughly $800-$1,200 of additional expected profit over a full season.

Key Takeaway

Kelly-optimal portfolio construction is a disciplined, multi-step process that produces bet sizes smaller than intuition suggests. The mathematical framework ensures that the bettor maximizes long-term growth while surviving the inevitable losing days. The process of estimating correlations, optimizing across simultaneous opportunities, and mapping to accounts transforms betting from a series of isolated decisions into a coherent portfolio management exercise.