15 min read

Futures markets represent some of the most intellectually challenging and potentially lucrative opportunities in sports betting. Unlike game-by-game wagering where each bet resolves within hours, futures bets play out over weeks, months, or an...

Chapter 35: Futures and Season-Long Markets

Futures markets represent some of the most intellectually challenging and potentially lucrative opportunities in sports betting. Unlike game-by-game wagering where each bet resolves within hours, futures bets play out over weeks, months, or an entire season. Win totals, division winners, conference champions, MVP awards, and championship futures all require the bettor to think in terms of probability distributions over long horizons, manage capital that is locked up for extended periods, and make dynamic decisions about hedging, doubling down, or letting positions ride as new information arrives.

For the quantitative bettor, futures markets combine several analytical disciplines: season simulation modeling, implied probability extraction from market prices, portfolio optimization under capital constraints, and dynamic hedging theory. The margins on futures bets are typically wider than on game-by-game markets (often 15-30% total overround across all selections), but the market is also less efficient because fewer participants have the modeling sophistication to properly evaluate long-horizon outcomes. This creates a setting where patient, well-capitalized bettors with good models can extract consistent value.

This chapter provides a comprehensive framework for futures betting, covering win total modeling, championship market analysis, hedging strategies, timing considerations, and season-long portfolio management.


35.1 Win Total Modeling

Preseason Win Total Projections

Win total futures -- over/under bets on how many games a team will win during the regular season -- are among the most popular and analytically tractable futures markets. The analytical challenge is clear: estimate the number of games a team will win and compare this to the posted total.

A robust win total projection requires three components:

  1. Team strength estimation: A rating for each team that captures their overall quality relative to the league. This can come from power ratings, Elo systems, statistical models, or market-derived estimates.

  2. Schedule analysis: The specific opponents each team faces, accounting for home/away splits, rest advantages, travel, and the strength of the opposition.

  3. Variance modeling: Understanding that the actual win total will differ from the expected win total due to randomness. A team projected for 50 wins might reasonably finish anywhere from 44 to 56 wins in a typical NBA season.

The basic projection formula for expected wins is:

$$E[\text{Wins}] = \sum_{i=1}^{N} P(\text{Win}_i)$$

Where $P(\text{Win}_i)$ is the probability of winning game $i$, and $N$ is the total number of games in the season.

Each game's win probability can be estimated using:

$$P(\text{Win}_i) = \frac{1}{1 + 10^{-(R_{\text{team}} - R_{\text{opp}_i} + \text{HCA}) / s}}$$

Where $R_{\text{team}}$ and $R_{\text{opp}_i}$ are rating values, HCA is the home court/field advantage, and $s$ is a scaling parameter (typically around 400 for Elo-style ratings).

Pythagorean Wins

The Pythagorean expectation, originally developed by Bill James for baseball, estimates a team's expected win percentage based on points scored and points allowed:

$$\text{Win\%} = \frac{\text{PF}^k}{\text{PF}^k + \text{PA}^k}$$

Where PF is points for (scored), PA is points against (allowed), and $k$ is a sport-specific exponent:

Sport Typical Exponent ($k$) Notes
MLB 1.83 Original James formula
NBA 13.91 Higher due to less randomness
NFL 2.37 Moderate randomness
NHL 2.05 Similar to NFL
Soccer 1.35 High randomness in low-scoring games

Teams that outperform their Pythagorean expectation (winning more than their point differential suggests) are often due for regression, and vice versa. This is a powerful input for win total projections.

For preseason projections, you can estimate a team's expected points scored and points allowed by combining: - Previous season's performance (regressed toward the mean) - Offseason roster changes (free agency, trades, draft) - Coaching changes - Age curves and development expectations

Monte Carlo Season Simulation

The most powerful approach to win total modeling is Monte Carlo simulation, where we simulate the entire season thousands of times to generate a distribution of possible outcomes.

The advantages of simulation over analytical formulas include: - Naturally handles schedule effects (strength of schedule, rest patterns) - Produces full probability distributions, not just point estimates - Can incorporate dependencies (e.g., division games are more important) - Easily extensible to division, conference, and championship probabilities - Handles tiebreaker scenarios

Python Code for Win Total Modeling

import numpy as np
from dataclasses import dataclass, field
from typing import Dict, List, Tuple, Optional
from scipy.stats import norm, binom
from collections import Counter


@dataclass
class Team:
    """Represents a team with its ratings and schedule."""
    name: str
    abbreviation: str
    conference: str
    division: str
    rating: float  # Power rating (higher = better)
    rating_std: float  # Uncertainty in rating
    home_advantage: float = 3.0  # Home court/field advantage in rating points

    # Previous season data (for Pythagorean regression)
    prev_points_for: float = 0.0
    prev_points_against: float = 0.0
    prev_wins: int = 0
    prev_games: int = 82


@dataclass
class ScheduleGame:
    """A single game on a team's schedule."""
    game_number: int
    opponent: str
    is_home: bool
    rest_days: int = 1
    is_division: bool = False
    is_conference: bool = False


class WinTotalSimulator:
    """
    Monte Carlo simulator for season win totals.

    Simulates entire seasons to produce:
    - Win total distributions for each team
    - Division/conference/championship probabilities
    - Correlated outcomes across teams
    """

    def __init__(
        self,
        teams: Dict[str, Team],
        schedules: Dict[str, List[ScheduleGame]],
        scale_factor: float = 10.0,
    ):
        """
        Args:
            teams: Dictionary of team abbreviation -> Team
            schedules: Dictionary of team abbreviation -> schedule
            scale_factor: Rating scale factor for win probability calculation
        """
        self.teams = teams
        self.schedules = schedules
        self.scale_factor = scale_factor

    def game_win_probability(
        self, team_rating: float, opp_rating: float,
        is_home: bool, home_advantage: float,
        rest_days_team: int = 1, rest_days_opp: int = 1,
    ) -> float:
        """Calculate win probability for a single game."""
        # Home advantage
        hca = home_advantage if is_home else -home_advantage

        # Rest advantage (back-to-back penalty)
        rest_adj = 0.0
        if rest_days_team == 0:
            rest_adj -= 1.5  # Back-to-back penalty
        if rest_days_opp == 0:
            rest_adj += 1.0  # Opponent on back-to-back is our advantage

        rating_diff = team_rating - opp_rating + hca + rest_adj

        # Logistic function
        prob = 1.0 / (1.0 + 10 ** (-rating_diff / self.scale_factor))
        return prob

    def pythagorean_wins(
        self, team: Team, exponent: float = 13.91
    ) -> float:
        """Calculate Pythagorean expected wins from previous season."""
        if team.prev_points_for <= 0 or team.prev_points_against <= 0:
            return team.prev_wins

        pyth_pct = (team.prev_points_for ** exponent /
                    (team.prev_points_for ** exponent +
                     team.prev_points_against ** exponent))
        return pyth_pct * team.prev_games

    def simulate_season(
        self, n_simulations: int = 10000,
        include_rating_uncertainty: bool = True,
    ) -> Dict:
        """
        Run a full Monte Carlo season simulation.

        Args:
            n_simulations: Number of seasons to simulate
            include_rating_uncertainty: Whether to sample team ratings
                from their uncertainty distribution each simulation

        Returns:
            Dictionary with simulation results for each team
        """
        team_names = list(self.teams.keys())
        n_teams = len(team_names)

        # Storage for results
        win_counts = {name: np.zeros(n_simulations) for name in team_names}
        division_winners = {name: 0 for name in team_names}
        conference_leaders = {name: 0 for name in team_names}

        for sim in range(n_simulations):
            # Sample team ratings (with uncertainty)
            if include_rating_uncertainty:
                sim_ratings = {}
                for name, team in self.teams.items():
                    sim_ratings[name] = np.random.normal(
                        team.rating, team.rating_std
                    )
            else:
                sim_ratings = {name: team.rating
                               for name, team in self.teams.items()}

            # Simulate each team's season
            season_wins = {}
            for team_name in team_names:
                team = self.teams[team_name]
                schedule = self.schedules.get(team_name, [])
                wins = 0

                for game in schedule:
                    opp_name = game.opponent
                    if opp_name not in sim_ratings:
                        continue

                    win_prob = self.game_win_probability(
                        team_rating=sim_ratings[team_name],
                        opp_rating=sim_ratings[opp_name],
                        is_home=game.is_home,
                        home_advantage=team.home_advantage,
                        rest_days_team=game.rest_days,
                    )

                    if np.random.random() < win_prob:
                        wins += 1

                win_counts[team_name][sim] = wins
                season_wins[team_name] = wins

            # Determine division and conference winners
            divisions = {}
            for name, team in self.teams.items():
                div = team.division
                if div not in divisions:
                    divisions[div] = []
                divisions[div].append((name, season_wins[name]))

            for div, teams_in_div in divisions.items():
                teams_in_div.sort(key=lambda x: x[1], reverse=True)
                winner = teams_in_div[0][0]
                division_winners[winner] += 1

            conferences = {}
            for name, team in self.teams.items():
                conf = team.conference
                if conf not in conferences:
                    conferences[conf] = []
                conferences[conf].append((name, season_wins[name]))

            for conf, teams_in_conf in conferences.items():
                teams_in_conf.sort(key=lambda x: x[1], reverse=True)
                leader = teams_in_conf[0][0]
                conference_leaders[leader] += 1

        # Compile results
        results = {}
        for name in team_names:
            wins = win_counts[name]
            results[name] = {
                'mean_wins': round(float(np.mean(wins)), 1),
                'median_wins': round(float(np.median(wins)), 1),
                'std_wins': round(float(np.std(wins)), 1),
                'p10_wins': round(float(np.percentile(wins, 10)), 1),
                'p25_wins': round(float(np.percentile(wins, 25)), 1),
                'p75_wins': round(float(np.percentile(wins, 75)), 1),
                'p90_wins': round(float(np.percentile(wins, 90)), 1),
                'win_distribution': dict(Counter(wins.astype(int))),
                'division_winner_prob': round(
                    division_winners[name] / n_simulations, 3
                ),
                'conference_best_prob': round(
                    conference_leaders[name] / n_simulations, 3
                ),
            }

        return results

    def evaluate_win_total(
        self,
        team_name: str,
        posted_total: float,
        over_odds: float,
        under_odds: float,
        simulation_results: Dict,
    ) -> Dict:
        """
        Evaluate a win total bet against simulation results.

        Args:
            team_name: Team abbreviation
            posted_total: The book's win total line
            over_odds: Decimal odds for over
            under_odds: Decimal odds for under
            simulation_results: Results from simulate_season()

        Returns:
            Evaluation with probabilities and edge
        """
        if team_name not in simulation_results:
            return {'error': f'Team {team_name} not in simulation results'}

        result = simulation_results[team_name]
        mean_wins = result['mean_wins']
        std_wins = result['std_wins']

        # Calculate over/under probabilities from simulation distribution
        dist = result['win_distribution']
        total_sims = sum(dist.values())

        over_wins = sum(
            count for wins, count in dist.items() if wins > posted_total
        )
        under_wins = sum(
            count for wins, count in dist.items() if wins < posted_total
        )
        push_count = sum(
            count for wins, count in dist.items()
            if abs(wins - posted_total) < 0.5
        )

        # For half-point totals, no push
        if posted_total != int(posted_total):
            over_prob = over_wins / total_sims
            under_prob = under_wins / total_sims
            push_prob = 0.0
        else:
            over_prob = over_wins / total_sims
            under_prob = under_wins / total_sims
            push_prob = push_count / total_sims

        # Edge calculation
        over_implied = 1.0 / over_odds
        under_implied = 1.0 / under_odds
        total_implied = over_implied + under_implied
        fair_over = over_implied / total_implied
        fair_under = under_implied / total_implied

        over_edge = over_prob - fair_over
        under_edge = under_prob - fair_under

        over_ev = over_prob * (over_odds - 1) - (1 - over_prob)
        under_ev = under_prob * (under_odds - 1) - (1 - under_prob)

        if over_edge > under_edge and over_edge > 0:
            recommendation = 'OVER'
            best_edge = over_edge
        elif under_edge > 0:
            recommendation = 'UNDER'
            best_edge = under_edge
        else:
            recommendation = 'PASS'
            best_edge = max(over_edge, under_edge)

        return {
            'team': team_name,
            'posted_total': posted_total,
            'projected_wins': mean_wins,
            'projection_std': std_wins,
            'over_prob': round(over_prob, 3),
            'under_prob': round(under_prob, 3),
            'push_prob': round(push_prob, 3),
            'over_edge': round(over_edge, 3),
            'under_edge': round(under_edge, 3),
            'over_ev': round(over_ev, 3),
            'under_ev': round(under_ev, 3),
            'recommendation': recommendation,
            'best_edge': round(best_edge, 3),
            'division_winner_prob': result['division_winner_prob'],
        }


# Demonstration
if __name__ == "__main__":
    np.random.seed(42)

    # Create a simplified 6-team league (2 divisions)
    teams = {
        'BOS': Team('Boston', 'BOS', 'East', 'Atlantic',
                     rating=8.5, rating_std=2.0, prev_points_for=116.0,
                     prev_points_against=108.5, prev_wins=60),
        'NYK': Team('New York', 'NYK', 'East', 'Atlantic',
                     rating=5.0, rating_std=2.5, prev_points_for=112.0,
                     prev_points_against=110.0, prev_wins=50),
        'PHI': Team('Philadelphia', 'PHI', 'East', 'Atlantic',
                     rating=3.5, rating_std=3.0, prev_points_for=110.0,
                     prev_points_against=111.0, prev_wins=47),
        'MIL': Team('Milwaukee', 'MIL', 'East', 'Central',
                     rating=6.0, rating_std=2.0, prev_points_for=114.0,
                     prev_points_against=109.5, prev_wins=54),
        'CLE': Team('Cleveland', 'CLE', 'East', 'Central',
                     rating=7.0, rating_std=2.0, prev_points_for=113.5,
                     prev_points_against=107.5, prev_wins=57),
        'CHI': Team('Chicago', 'CHI', 'East', 'Central',
                     rating=-1.0, rating_std=3.0, prev_points_for=108.0,
                     prev_points_against=113.0, prev_wins=35),
    }

    # Generate simplified schedules (40 games each)
    schedules = {}
    team_names = list(teams.keys())
    for team_name in team_names:
        schedule = []
        game_num = 0
        for opp in team_names:
            if opp == team_name:
                continue
            # Play each opponent 8 times (4 home, 4 away)
            for g in range(8):
                game_num += 1
                is_home = g < 4
                team_obj = teams[team_name]
                opp_obj = teams[opp]
                is_div = team_obj.division == opp_obj.division
                schedule.append(ScheduleGame(
                    game_number=game_num,
                    opponent=opp,
                    is_home=is_home,
                    rest_days=np.random.choice([0, 1, 1, 1, 2]),
                    is_division=is_div,
                    is_conference=True,
                ))
        schedules[team_name] = schedule

    # Run simulation
    simulator = WinTotalSimulator(teams, schedules, scale_factor=10.0)

    print("Running 10,000 season simulations...")
    results = simulator.simulate_season(n_simulations=10000)

    print("\nSeason Simulation Results:")
    print("=" * 75)
    print(f"{'Team':>5} {'Mean':>6} {'Std':>5} {'P10':>5} {'P25':>5} "
          f"{'P75':>5} {'P90':>5} {'DivWin%':>8} {'ConfBest%':>10}")
    print("-" * 75)

    for name in sorted(results.keys(),
                       key=lambda x: results[x]['mean_wins'], reverse=True):
        r = results[name]
        print(f"{name:>5} {r['mean_wins']:>6.1f} {r['std_wins']:>5.1f} "
              f"{r['p10_wins']:>5.1f} {r['p25_wins']:>5.1f} "
              f"{r['p75_wins']:>5.1f} {r['p90_wins']:>5.1f} "
              f"{r['division_winner_prob']:>8.1%} "
              f"{r['conference_best_prob']:>10.1%}")

    # Evaluate win total bets
    print("\n\nWin Total Evaluations:")
    print("=" * 65)

    win_totals = {
        'BOS': (27.5, 1.909, 1.909),
        'NYK': (23.5, 1.909, 1.909),
        'PHI': (22.5, 1.870, 1.952),
        'MIL': (25.5, 1.909, 1.909),
        'CLE': (26.5, 1.952, 1.870),
        'CHI': (17.5, 1.909, 1.909),
    }

    for team_name, (total, over_odds, under_odds) in win_totals.items():
        eval_result = simulator.evaluate_win_total(
            team_name, total, over_odds, under_odds, results
        )
        print(f"\n  {team_name}: Line {total}, "
              f"Projected {eval_result['projected_wins']:.1f} "
              f"+/- {eval_result['projection_std']:.1f}")
        print(f"    Over Prob: {eval_result['over_prob']:.1%}, "
              f"Under Prob: {eval_result['under_prob']:.1%}")
        print(f"    Over Edge: {eval_result['over_edge']:+.1%}, "
              f"Under Edge: {eval_result['under_edge']:+.1%}")
        print(f"    Recommendation: {eval_result['recommendation']} "
              f"(Edge: {eval_result['best_edge']:+.1%})")

This simulation framework produces not just point estimates but full distributions of outcomes, enabling us to calculate exact probabilities for any win total threshold. The inclusion of rating uncertainty (sampling team ratings from a distribution each simulation) captures the fundamental uncertainty in our preseason assessments.


35.2 Championship and Division Futures

Implied Probability Extraction

Championship and division futures markets list prices for each possible winner. Extracting fair implied probabilities from these prices requires removing the built-in overround (margin).

Given a market with $n$ selections and prices $p_1, p_2, \ldots, p_n$ (in decimal odds), the raw implied probabilities are:

$$q_i = \frac{1}{p_i}$$

The total overround is:

$$O = \sum_{i=1}^{n} q_i - 1$$

The most common method to remove the overround is proportional normalization:

$$\hat{q}_i = \frac{q_i}{\sum_{j=1}^{n} q_j}$$

However, this approach is known to be imprecise for longshots. The multiplicative (or "power") method is often more accurate:

$$\hat{q}_i = q_i^k$$

Where $k$ is chosen so that $\sum_i \hat{q}_i^k = 1$. This can be solved numerically.

The Shin method, based on insider trading theory, provides another approach:

$$z = \frac{\sqrt{O^2 + 4(1-O) \sum q_i^2} - O}{2(n - O)}$$

$$\hat{q}_i = \frac{\sqrt{z^2 + 4(1-z) q_i^2 / \sum q_j^2} - z}{2(1-z)}$$

Identifying Value in Championship Futures

Value in championship futures can come from several sources:

  1. Superior team strength estimates: If your power ratings are more accurate than the market's, you will identify teams whose true championship probability exceeds their implied probability.

  2. Path dependency: Championship probability depends not just on team strength but on the path through the playoffs (bracket structure, potential opponents). Your simulation can capture this while the market may not fully price it.

  3. Longshot bias: Markets historically overprice heavy favorites and underprice moderate longshots (not extreme longshots). A team at 15-to-1 might be a better bet than a team at 3-to-1 even if the favorite is objectively better.

  4. Roster changes not yet priced: Mid-season trades, injuries, and returns can create windows where the futures market has not fully adjusted.

Hedging Along the Way

One of the unique advantages of futures bets is the ability to hedge as the season progresses. If you bet a team at 25-to-1 before the season and they are now 5-to-1, you can lock in a profit regardless of outcome by betting against them. We will cover hedging in detail in Section 35.3.

Python Code for Championship Futures Analysis

import numpy as np
from scipy.optimize import brentq
from typing import Dict, List, Tuple
from dataclasses import dataclass


@dataclass
class FuturesSelection:
    """A single selection in a futures market."""
    team: str
    decimal_odds: float
    model_probability: float = 0.0  # Your model's estimate


class FuturesMarketAnalyzer:
    """
    Analyzes championship and division futures markets.

    Provides methods for:
    - Implied probability extraction (multiple methods)
    - Edge identification
    - Market efficiency assessment
    """

    def __init__(self, selections: List[FuturesSelection]):
        self.selections = selections

    def raw_implied_probabilities(self) -> Dict[str, float]:
        """Calculate raw (un-normalized) implied probabilities."""
        return {
            s.team: 1.0 / s.decimal_odds
            for s in self.selections
        }

    def total_overround(self) -> float:
        """Calculate the total market overround."""
        return sum(1.0 / s.decimal_odds for s in self.selections) - 1.0

    def proportional_normalization(self) -> Dict[str, float]:
        """Remove overround using simple proportional method."""
        raw = self.raw_implied_probabilities()
        total = sum(raw.values())
        return {team: prob / total for team, prob in raw.items()}

    def power_method(self) -> Dict[str, float]:
        """
        Remove overround using the multiplicative (power) method.

        Finds exponent k such that sum(q_i^k) = 1.
        This better handles longshot-favorite bias.
        """
        raw_probs = [1.0 / s.decimal_odds for s in self.selections]

        def objective(k):
            return sum(p ** k for p in raw_probs) - 1.0

        # Find k that makes probabilities sum to 1
        try:
            k = brentq(objective, 0.5, 2.0)
        except ValueError:
            k = 1.0  # Fall back to proportional

        fair_probs = {
            s.team: (1.0 / s.decimal_odds) ** k
            for s in self.selections
        }

        # Normalize to ensure exact sum to 1
        total = sum(fair_probs.values())
        return {team: prob / total for team, prob in fair_probs.items()}

    def shin_method(self) -> Dict[str, float]:
        """
        Remove overround using Shin's method.

        Based on the assumption that overround comes from
        the bookmaker's need to protect against informed bettors.
        """
        raw = [1.0 / s.decimal_odds for s in self.selections]
        n = len(raw)
        total_raw = sum(raw)
        sum_sq = sum(r ** 2 for r in raw)

        overround = total_raw - 1.0

        # Shin's z parameter
        z_num = np.sqrt(overround ** 2 + 4 * (1 - overround) * sum_sq / total_raw ** 2)
        z = (z_num - overround) / (2 * (1 - overround)) if overround < 1 else 0.01

        fair_probs = {}
        for s in self.selections:
            q = 1.0 / s.decimal_odds
            inner = z ** 2 + 4 * (1 - z) * (q / total_raw) ** 2
            fair = (np.sqrt(inner) - z) / (2 * (1 - z))
            fair_probs[s.team] = max(fair, 0.001)

        # Normalize
        total = sum(fair_probs.values())
        return {team: prob / total for team, prob in fair_probs.items()}

    def identify_value(
        self, method: str = 'power'
    ) -> List[Dict]:
        """
        Identify value bets by comparing model probabilities
        to fair implied probabilities.
        """
        if method == 'proportional':
            fair_probs = self.proportional_normalization()
        elif method == 'power':
            fair_probs = self.power_method()
        elif method == 'shin':
            fair_probs = self.shin_method()
        else:
            fair_probs = self.proportional_normalization()

        value_bets = []
        for s in self.selections:
            if s.model_probability <= 0:
                continue

            fair = fair_probs.get(s.team, 0)
            edge = s.model_probability - fair
            ev = s.model_probability * (s.decimal_odds - 1) - (1 - s.model_probability)

            # Kelly criterion for this bet
            b = s.decimal_odds - 1
            p = s.model_probability
            q = 1 - p
            kelly = (p * b - q) / b if b > 0 else 0
            kelly = max(0, kelly)

            value_bets.append({
                'team': s.team,
                'odds': s.decimal_odds,
                'raw_implied': round(1.0 / s.decimal_odds, 4),
                'fair_implied': round(fair, 4),
                'model_prob': round(s.model_probability, 4),
                'edge': round(edge, 4),
                'ev_per_dollar': round(ev, 4),
                'kelly_fraction': round(kelly, 4),
                'is_value': edge > 0.01,  # 1% minimum edge
            })

        value_bets.sort(key=lambda x: x['edge'], reverse=True)
        return value_bets


# Demonstration
if __name__ == "__main__":
    # NBA Championship futures market
    selections = [
        FuturesSelection("BOS", 4.50, 0.22),
        FuturesSelection("OKC", 5.00, 0.18),
        FuturesSelection("CLE", 7.00, 0.14),
        FuturesSelection("DEN", 9.00, 0.12),
        FuturesSelection("NYK", 12.00, 0.08),
        FuturesSelection("MIL", 15.00, 0.07),
        FuturesSelection("DAL", 18.00, 0.05),
        FuturesSelection("MIN", 20.00, 0.04),
        FuturesSelection("PHI", 25.00, 0.035),
        FuturesSelection("MIA", 30.00, 0.025),
        FuturesSelection("LAL", 35.00, 0.02),
        FuturesSelection("GSW", 40.00, 0.015),
        FuturesSelection("Field", 8.00, 0.085),
    ]

    analyzer = FuturesMarketAnalyzer(selections)

    print("Championship Futures Analysis")
    print("=" * 70)
    print(f"Total Overround: {analyzer.total_overround():.1%}")

    # Compare methods
    prop = analyzer.proportional_normalization()
    power = analyzer.power_method()
    shin = analyzer.shin_method()

    print(f"\n{'Team':>6} {'Odds':>6} {'Raw%':>6} {'Prop%':>6} "
          f"{'Power%':>7} {'Shin%':>6} {'Model%':>7}")
    print("-" * 55)
    for s in selections:
        raw = 1.0 / s.decimal_odds
        print(f"{s.team:>6} {s.decimal_odds:>6.1f} {raw:>6.1%} "
              f"{prop[s.team]:>6.1%} {power[s.team]:>7.1%} "
              f"{shin[s.team]:>6.1%} {s.model_probability:>7.1%}")

    # Identify value
    print("\n\nValue Identification (Power Method):")
    print("=" * 70)
    value_bets = analyzer.identify_value(method='power')
    for vb in value_bets:
        marker = " ***" if vb['is_value'] else ""
        print(f"  {vb['team']:>6}: Odds {vb['odds']:>5.1f} | "
              f"Fair: {vb['fair_implied']:.1%} | "
              f"Model: {vb['model_prob']:.1%} | "
              f"Edge: {vb['edge']:+.1%} | "
              f"EV: {vb['ev_per_dollar']:+.3f} | "
              f"Kelly: {vb['kelly_fraction']:.2%}{marker}")

35.3 Hedging Strategies

Hedging is the practice of placing additional bets to reduce risk on an existing position. In futures markets, hedging opportunities arise naturally as the season progresses and your initial positions gain or lose value.

When to Hedge Futures

The decision to hedge is fundamentally a tradeoff between expected value and risk reduction. Pure expected-value maximizers would never hedge a positive-EV position, but real bettors with finite bankrolls and risk aversion often benefit from strategic hedging.

Hedging makes sense when:

  1. The position has grown large relative to bankroll: If you bet $100 at 50-to-1 and the team is now 5-to-1, your effective position ($5,000 potential payout) may be too large for comfortable risk management.

  2. Your edge has evaporated: If you bet a team because your model gave them a higher probability than the market, but the market has now moved to match (or exceed) your model, the EV advantage of holding the full position is gone.

  3. Risk-free profit is available: In some cases, you can lock in a guaranteed profit by hedging at the right time, especially in playoff situations where the number of possible outcomes is small.

  4. Opportunity cost of capital: Futures tie up capital for months. If you can lock in a good return and redeploy the capital, the reinvestment value may exceed the expected value of continuing to hold.

Hedge Calculation

The basic hedge calculation for a two-outcome scenario (team wins championship or does not):

Existing position: Bet $B_1$ at odds $O_1$ on Team A to win. - If Team A wins: Profit = $B_1 \times (O_1 - 1)$ - If Team A loses: Loss = $B_1$

Hedge bet: Bet $B_2$ at odds $O_2$ on "Not Team A" (or the specific opponent). - If Team A wins: Lose hedge = $B_2$ - If Team A loses: Profit on hedge = $B_2 \times (O_2 - 1)$

For equal profit regardless of outcome (risk-free):

$$B_1 \times (O_1 - 1) - B_2 = B_2 \times (O_2 - 1) - B_1$$

Solving for $B_2$:

$$B_2 = \frac{B_1 \times O_1}{O_2}$$

This creates a guaranteed profit of:

$$\text{Guaranteed Profit} = B_1 \times (O_1 - 1) - B_2 = B_1 \times O_1 \times \left(1 - \frac{1}{O_2}\right) - B_1$$

Optimal Hedging

Pure risk-free hedging is not always optimal. A more sophisticated approach uses utility theory to find the hedge amount that maximizes expected utility:

$$\max_{B_2} \left[ P(\text{win}) \times U(\text{payoff if win}) + P(\text{lose}) \times U(\text{payoff if lose}) \right]$$

For a logarithmic utility function (which corresponds to the Kelly criterion), the optimal hedge amount can be derived analytically:

$$B_2^* = \frac{(1 - p) \times W - p \times B_1 \times (O_1 - 1)}{O_2 - 1 + 1}$$

Wait, let us be more precise. Let $W$ be total current wealth (bankroll), $p$ be the probability of Team A winning, $O_1$ the original odds, $O_2$ the current hedge odds, and $B_1$ the original stake.

If we hedge with amount $B_2$: - If Team A wins: Final wealth = $W + B_1(O_1 - 1) - B_2$ - If Team A loses: Final wealth = $W - B_1 + B_2(O_2 - 1)$

Maximizing expected log wealth:

$$\max_{B_2} \left[ p \ln(W + B_1(O_1-1) - B_2) + (1-p) \ln(W - B_1 + B_2(O_2-1)) \right]$$

Taking the derivative and setting to zero:

$$\frac{-p}{W + B_1(O_1-1) - B_2} + \frac{(1-p)(O_2-1)}{W - B_1 + B_2(O_2-1)} = 0$$

This yields:

$$B_2^* = \frac{(1-p)(W + B_1(O_1-1)) - p \cdot \frac{W - B_1}{O_2 - 1}}{1 + (1-p)}$$

In practice, it is simpler to solve numerically.

Python Code with Worked Examples

import numpy as np
from scipy.optimize import minimize_scalar
from typing import Dict, Optional, Tuple
from dataclasses import dataclass


@dataclass
class FuturesPosition:
    """An existing futures bet position."""
    team: str
    original_stake: float
    original_odds: float  # Decimal odds when bet was placed
    current_odds: float   # Current decimal odds for same outcome
    win_probability: float  # Current estimated probability of winning


class FuturesHedgeCalculator:
    """
    Calculates optimal hedging strategies for futures positions.

    Supports:
    - Risk-free (equal profit) hedging
    - Kelly-optimal hedging
    - Partial hedging with custom risk tolerance
    """

    def __init__(self, bankroll: float):
        self.bankroll = bankroll

    def risk_free_hedge(
        self, position: FuturesPosition, hedge_odds: float
    ) -> Dict:
        """
        Calculate the hedge bet that guarantees equal profit
        regardless of outcome.

        Args:
            position: The existing futures position
            hedge_odds: Decimal odds available for the hedge
                        (betting against the original team)

        Returns:
            Hedge details including stake and guaranteed profit
        """
        B1 = position.original_stake
        O1 = position.original_odds
        O2 = hedge_odds

        # Potential payout on original bet (gross, including stake return)
        total_payout = B1 * O1

        # Hedge stake for equal profit
        # If team wins: profit = B1*(O1-1) - B2
        # If team loses: profit = B2*(O2-1) - B1
        # Setting equal: B1*(O1-1) - B2 = B2*(O2-1) - B1
        # B1*O1 = B2*O2
        B2 = total_payout / O2

        # Profits
        profit_if_win = B1 * (O1 - 1) - B2
        profit_if_lose = B2 * (O2 - 1) - B1

        # Verify they're equal (should be within rounding)
        guaranteed_profit = profit_if_win  # = profit_if_lose

        # ROI calculation
        total_invested = B1 + B2
        roi = guaranteed_profit / total_invested

        return {
            'hedge_stake': round(B2, 2),
            'guaranteed_profit': round(guaranteed_profit, 2),
            'profit_if_team_wins': round(profit_if_win, 2),
            'profit_if_team_loses': round(profit_if_lose, 2),
            'total_invested': round(total_invested, 2),
            'roi': round(roi, 4),
            'original_potential_profit': round(B1 * (O1 - 1), 2),
            'hedged': True,
        }

    def kelly_optimal_hedge(
        self, position: FuturesPosition, hedge_odds: float
    ) -> Dict:
        """
        Calculate the Kelly-optimal hedge amount.

        Maximizes expected log(wealth) accounting for the existing position.
        """
        B1 = position.original_stake
        O1 = position.original_odds
        O2 = hedge_odds
        p = position.win_probability
        W = self.bankroll

        def neg_expected_log_wealth(B2):
            if B2 < 0:
                return 1e10  # Can't hedge negative amount

            # Wealth if team wins
            w_win = W + B1 * (O1 - 1) - B2
            # Wealth if team loses
            w_lose = W - B1 + B2 * (O2 - 1)

            if w_win <= 0 or w_lose <= 0:
                return 1e10  # Avoid ruin

            return -(p * np.log(w_win) + (1 - p) * np.log(w_lose))

        # Search for optimal B2
        max_hedge = W * 0.5  # Don't hedge more than half bankroll
        result = minimize_scalar(
            neg_expected_log_wealth,
            bounds=(0, max_hedge),
            method='bounded',
        )

        B2_optimal = max(0, result.x)

        # Calculate outcomes
        profit_win = B1 * (O1 - 1) - B2_optimal
        profit_lose = B2_optimal * (O2 - 1) - B1

        expected_profit = p * profit_win + (1 - p) * profit_lose

        return {
            'hedge_stake': round(B2_optimal, 2),
            'profit_if_team_wins': round(profit_win, 2),
            'profit_if_team_loses': round(profit_lose, 2),
            'expected_profit': round(expected_profit, 2),
            'win_probability': p,
            'total_invested': round(B1 + B2_optimal, 2),
            'expected_roi': round(
                expected_profit / (B1 + B2_optimal), 4
            ) if (B1 + B2_optimal) > 0 else 0,
        }

    def partial_hedge(
        self, position: FuturesPosition, hedge_odds: float,
        hedge_fraction: float = 0.5,
    ) -> Dict:
        """
        Calculate a partial hedge that locks in some profit
        while maintaining upside.

        Args:
            position: Existing position
            hedge_odds: Available hedge odds
            hedge_fraction: Fraction of the full risk-free hedge to place
                            (0 = no hedge, 1 = full hedge)
        """
        full_hedge = self.risk_free_hedge(position, hedge_odds)
        full_B2 = full_hedge['hedge_stake']

        B2 = full_B2 * hedge_fraction
        B1 = position.original_stake
        O1 = position.original_odds
        O2 = hedge_odds

        profit_win = B1 * (O1 - 1) - B2
        profit_lose = B2 * (O2 - 1) - B1

        p = position.win_probability
        expected_profit = p * profit_win + (1 - p) * profit_lose
        min_profit = min(profit_win, profit_lose)

        return {
            'hedge_fraction': hedge_fraction,
            'hedge_stake': round(B2, 2),
            'profit_if_team_wins': round(profit_win, 2),
            'profit_if_team_loses': round(profit_lose, 2),
            'expected_profit': round(expected_profit, 2),
            'minimum_profit': round(min_profit, 2),
            'upside_preserved': round(profit_win / full_hedge['original_potential_profit'], 3),
        }

    def multi_outcome_hedge(
        self,
        position: FuturesPosition,
        remaining_opponents: Dict[str, float],
    ) -> Dict:
        """
        Hedge a futures position when multiple opponents remain
        (e.g., during playoff rounds).

        Args:
            position: Your existing futures position
            remaining_opponents: Dict of opponent -> decimal odds for
                                 that opponent winning the championship

        Returns:
            Optimal hedge amounts for each opponent
        """
        B1 = position.original_stake
        O1 = position.original_odds
        W = self.bankroll
        p_team = position.win_probability

        # Target: equalize wealth across all outcomes
        total_payout_if_win = W + B1 * (O1 - 1)

        hedge_amounts = {}
        total_hedge = 0

        for opp, opp_odds in remaining_opponents.items():
            # For equal profit across all outcomes:
            # W + B1*(O1-1) - sum(hedges) = W - B1 + Bi*(Oi-1) - sum(other hedges)
            # This simplifies to: B1*O1 - Bi = Bi*(Oi-1)
            # => Bi*Oi = B1*O1 => Bi = B1*O1/Oi

            # But this only works for 2 outcomes; for multiple outcomes,
            # we need to solve a system. Simplify by targeting equal wealth:

            # If opponent i wins: wealth = W - B1 + Bi*(Oi-1) - sum(Bj for j!=i)
            # For all to be equal, we need proportional allocation
            pass

        # Simpler approach: allocate hedge budget proportionally
        # to opponent probabilities
        opp_probs = {}
        for opp, opp_odds in remaining_opponents.items():
            opp_probs[opp] = 1.0 / opp_odds

        total_opp_prob = sum(opp_probs.values())

        # Target guaranteed profit
        target_profit_pct = 0.5  # Lock in 50% of max profit

        for opp, opp_odds in remaining_opponents.items():
            # Proportion of non-team probability
            prob_share = opp_probs[opp] / total_opp_prob if total_opp_prob > 0 else 0

            # Risk-free hedge amount for this outcome
            risk_free_b = B1 * O1 / opp_odds
            hedge_b = risk_free_b * target_profit_pct

            hedge_amounts[opp] = {
                'odds': opp_odds,
                'hedge_stake': round(hedge_b, 2),
                'payout_if_wins': round(hedge_b * (opp_odds - 1), 2),
                'prob_share': round(prob_share, 3),
            }
            total_hedge += hedge_b

        # Calculate outcomes
        max_profit = B1 * (O1 - 1) - total_hedge
        total_invested = B1 + total_hedge

        return {
            'position': {
                'team': position.team,
                'original_stake': B1,
                'original_odds': O1,
                'potential_payout': round(B1 * O1, 2),
            },
            'hedge_amounts': hedge_amounts,
            'total_hedge_cost': round(total_hedge, 2),
            'profit_if_team_wins': round(max_profit, 2),
            'total_invested': round(total_invested, 2),
        }


# Worked Examples
if __name__ == "__main__":
    calc = FuturesHedgeCalculator(bankroll=10000)

    print("=" * 65)
    print("FUTURES HEDGING: WORKED EXAMPLES")
    print("=" * 65)

    # Example 1: Simple risk-free hedge
    print("\n--- Example 1: Risk-Free Hedge ---")
    print("You bet $200 on the Celtics to win the NBA Championship at 8.00 (7/1)")
    print("Celtics are now in the Finals at 2.20 (6/5)")
    print("Opponent (Thunder) available at 2.00 (1/1)")

    position = FuturesPosition(
        team="BOS", original_stake=200, original_odds=8.00,
        current_odds=2.20, win_probability=0.55,
    )

    risk_free = calc.risk_free_hedge(position, hedge_odds=2.00)
    print(f"\n  Hedge stake on Thunder: ${risk_free['hedge_stake']}")
    print(f"  If Celtics win: ${risk_free['profit_if_team_wins']}")
    print(f"  If Thunder win: ${risk_free['profit_if_team_loses']}")
    print(f"  Guaranteed profit: ${risk_free['guaranteed_profit']}")
    print(f"  Total invested: ${risk_free['total_invested']}")
    print(f"  ROI: {risk_free['roi']:.1%}")

    # Example 2: Kelly-optimal hedge
    print("\n--- Example 2: Kelly-Optimal Hedge ---")
    print("Same position, but optimizing for bankroll growth")

    kelly_hedge = calc.kelly_optimal_hedge(position, hedge_odds=2.00)
    print(f"\n  Kelly hedge stake: ${kelly_hedge['hedge_stake']}")
    print(f"  If Celtics win: ${kelly_hedge['profit_if_team_wins']}")
    print(f"  If Thunder win: ${kelly_hedge['profit_if_team_loses']}")
    print(f"  Expected profit: ${kelly_hedge['expected_profit']}")
    print(f"  Expected ROI: {kelly_hedge['expected_roi']:.1%}")

    # Example 3: Partial hedge spectrum
    print("\n--- Example 3: Partial Hedge Spectrum ---")
    print("Comparing different hedge levels:")
    print(f"{'Hedge%':>8} {'Stake':>8} {'Win Profit':>12} "
          f"{'Lose Profit':>13} {'E[Profit]':>10} {'Min Profit':>12}")
    print("-" * 65)

    for frac in [0, 0.25, 0.50, 0.75, 1.0]:
        partial = calc.partial_hedge(position, 2.00, frac)
        print(f"{frac:>8.0%} ${partial['hedge_stake']:>7.0f} "
              f"${partial['profit_if_team_wins']:>11.0f} "
              f"${partial['profit_if_team_loses']:>12.0f} "
              f"${partial['expected_profit']:>9.0f} "
              f"${partial['minimum_profit']:>11.0f}")

    # Example 4: Multi-opponent hedge (Conference Finals)
    print("\n--- Example 4: Multi-Outcome Hedge ---")
    print("You bet $100 on Celtics at 6.00 pre-season")
    print("Conference Finals: 3 other teams remain")

    position2 = FuturesPosition(
        team="BOS", original_stake=100, original_odds=6.00,
        current_odds=3.00, win_probability=0.35,
    )

    multi = calc.multi_outcome_hedge(
        position2,
        remaining_opponents={
            'OKC': 3.50,
            'DEN': 5.00,
            'CLE': 6.00,
        },
    )

    print(f"\n  Original position: ${multi['position']['original_stake']} "
          f"at {multi['position']['original_odds']}")
    print(f"  Potential payout: ${multi['position']['potential_payout']}")
    print(f"\n  Hedge allocations:")
    for opp, details in multi['hedge_amounts'].items():
        print(f"    {opp}: ${details['hedge_stake']:.0f} "
              f"at {details['odds']} "
              f"(payout: ${details['payout_if_wins']:.0f})")
    print(f"\n  Total hedge cost: ${multi['total_hedge_cost']:.0f}")
    print(f"  Profit if Celtics win: ${multi['profit_if_team_wins']:.0f}")
    print(f"  Total invested: ${multi['total_invested']:.0f}")

35.4 Optimal Entry and Exit Points

Timing Futures Bets

The timing of futures bets significantly affects their expected value. Prices evolve throughout the offseason and season in response to new information, and different periods offer systematically different value profiles.

Preseason (months before season): - Widest margins (highest overround) - Greatest uncertainty allows for the largest theoretical edges - Markets may not have fully incorporated offseason moves - Long capital lockup period

Just before season start: - Margins tighten as books take more action - All major roster moves are known - Good balance of edge availability and capital efficiency

Early season (first 10-20% of games): - Small sample of actual results; market often overreacts - Teams that start hot get overvalued; slow starters get undervalued - Excellent window for contrarian value on teams you projected well before the season

Mid-season: - Markets become more efficient as the sample grows - Trade deadline creates new information - Win total lines may be taken off the board or re-posted

Late season / playoffs: - Very efficient pricing on championship futures - But hedging and arbitrage opportunities arise - Reduced capital lockup period

Market Pricing Throughout the Season

The dynamics of futures pricing throughout a season follow predictable patterns:

Favorite compression: As the season progresses, the true championship contenders separate from the field. Favorites' odds shorten (lower payout), and the overround on the full market decreases because books are more confident in their assessments.

Longshot elimination: Long shots gradually drop out of contention, and their prices lengthen to extreme levels before being removed from the board entirely. Books profit enormously on longshot futures because they collect all the stakes from eliminated teams.

Information cascades: Significant events (star player injury, blockbuster trade) cause rapid repricing across the entire futures board, not just the directly affected team. These cascading adjustments create brief mispricing windows.

Middle Opportunities

A "middle" in futures occurs when you can take opposing positions at different times at prices that create a range of outcomes where both bets win. For example:

  1. Bet the over on a team's win total at 48.5 before the season
  2. Mid-season, after the team starts slowly, bet the under on the same team at 47.5

If the team finishes with exactly 48 wins, both bets win. If they finish with 49+, the over wins and the under loses (but at a smaller stake). If they finish with 47 or fewer, the under wins.

Python Analysis of Timing and Middle Opportunities

import numpy as np
from typing import Dict, List, Tuple
from dataclasses import dataclass, field


@dataclass
class FuturesPricePoint:
    """A snapshot of futures pricing at a point in time."""
    date: str
    team: str
    market: str  # 'win_total', 'championship', etc.
    line: float  # For win totals
    over_odds: float = 0.0
    under_odds: float = 0.0
    championship_odds: float = 0.0
    model_probability: float = 0.0
    games_played: int = 0
    current_record: str = ""


class FuturesTimingAnalyzer:
    """
    Analyzes optimal entry and exit points for futures positions.

    Tracks pricing evolution and identifies:
    - Optimal entry windows
    - Middle opportunities
    - Hedging trigger points
    """

    def __init__(self):
        self.price_history: Dict[str, List[FuturesPricePoint]] = {}
        self.positions: List[Dict] = []

    def add_price_point(self, point: FuturesPricePoint):
        """Record a new price observation."""
        key = f"{point.team}_{point.market}"
        if key not in self.price_history:
            self.price_history[key] = []
        self.price_history[key].append(point)

    def analyze_entry_timing(
        self,
        team: str,
        market: str = 'win_total',
    ) -> Dict:
        """
        Analyze historical pricing to identify the best entry window.
        """
        key = f"{team}_{market}"
        history = self.price_history.get(key, [])

        if len(history) < 2:
            return {'error': 'Insufficient price history'}

        # Track edge evolution over time
        edge_timeline = []
        for point in history:
            if market == 'win_total' and point.over_odds > 0:
                over_implied = 1.0 / point.over_odds
                under_implied = 1.0 / point.under_odds
                total_implied = over_implied + under_implied
                fair_over = over_implied / total_implied
                fair_under = under_implied / total_implied

                over_edge = point.model_probability - fair_over
                under_edge = (1 - point.model_probability) - fair_under
                best_edge = max(over_edge, under_edge)
                best_side = 'over' if over_edge > under_edge else 'under'

                vig = total_implied - 1.0

                edge_timeline.append({
                    'date': point.date,
                    'line': point.line,
                    'games_played': point.games_played,
                    'model_prob_over': round(point.model_probability, 3),
                    'fair_over': round(fair_over, 3),
                    'fair_under': round(fair_under, 3),
                    'over_edge': round(over_edge, 3),
                    'under_edge': round(under_edge, 3),
                    'best_edge': round(best_edge, 3),
                    'best_side': best_side,
                    'vig': round(vig, 3),
                    'record': point.current_record,
                })

        # Find optimal entry point
        if edge_timeline:
            best_entry = max(edge_timeline, key=lambda x: x['best_edge'])
            worst_entry = min(edge_timeline, key=lambda x: x['best_edge'])
        else:
            best_entry = worst_entry = None

        return {
            'team': team,
            'market': market,
            'timeline': edge_timeline,
            'best_entry': best_entry,
            'worst_entry': worst_entry,
            'n_observations': len(edge_timeline),
        }

    def find_middle_opportunities(
        self,
        team: str,
        market: str = 'win_total',
    ) -> List[Dict]:
        """
        Identify middle opportunities where two bets on opposite sides
        at different times create a profitable range.
        """
        key = f"{team}_{market}"
        history = self.price_history.get(key, [])

        if len(history) < 2:
            return []

        middles = []

        # Compare all pairs of price points
        for i in range(len(history)):
            for j in range(i + 1, len(history)):
                p1 = history[i]
                p2 = history[j]

                if market != 'win_total':
                    continue

                # Middle exists when lines differ
                line_diff = p2.line - p1.line

                if abs(line_diff) < 0.5:
                    continue  # No middle possible

                # Scenario 1: Bet OVER at earlier (lower) line,
                # UNDER at later (higher) line
                if p2.line > p1.line:
                    over_line = p1.line
                    over_odds = p1.over_odds
                    under_line = p2.line
                    under_odds = p2.under_odds

                    # Middle range: wins between over_line and under_line
                    middle_width = under_line - over_line

                    # Calculate profits
                    over_stake = 100
                    under_stake = 100  # Could optimize

                    # If result in middle range: both win
                    profit_middle = (over_stake * (over_odds - 1) +
                                     under_stake * (under_odds - 1))
                    # If result above under_line: over wins, under loses
                    profit_high = over_stake * (over_odds - 1) - under_stake
                    # If result below over_line: under wins, over loses
                    profit_low = under_stake * (under_odds - 1) - over_stake

                    middles.append({
                        'type': 'OVER_then_UNDER',
                        'over_date': p1.date,
                        'under_date': p2.date,
                        'over_line': over_line,
                        'under_line': under_line,
                        'middle_width': middle_width,
                        'over_odds': over_odds,
                        'under_odds': under_odds,
                        'profit_in_middle': round(profit_middle, 2),
                        'profit_above': round(profit_high, 2),
                        'profit_below': round(profit_low, 2),
                        'worst_case': round(min(profit_high, profit_low), 2),
                        'is_risk_free': min(profit_high, profit_low) > 0,
                    })

                # Scenario 2: Bet UNDER at earlier (higher) line,
                # OVER at later (lower) line
                elif p2.line < p1.line:
                    under_line = p1.line
                    under_odds = p1.under_odds
                    over_line = p2.line
                    over_odds = p2.over_odds

                    middle_width = under_line - over_line

                    over_stake = 100
                    under_stake = 100

                    profit_middle = (over_stake * (over_odds - 1) +
                                     under_stake * (under_odds - 1))
                    profit_high = over_stake * (over_odds - 1) - under_stake
                    profit_low = under_stake * (under_odds - 1) - over_stake

                    middles.append({
                        'type': 'UNDER_then_OVER',
                        'under_date': p1.date,
                        'over_date': p2.date,
                        'over_line': over_line,
                        'under_line': under_line,
                        'middle_width': middle_width,
                        'over_odds': over_odds,
                        'under_odds': under_odds,
                        'profit_in_middle': round(profit_middle, 2),
                        'profit_above': round(profit_high, 2),
                        'profit_below': round(profit_low, 2),
                        'worst_case': round(min(profit_high, profit_low), 2),
                        'is_risk_free': min(profit_high, profit_low) > 0,
                    })

        # Sort by potential value (prioritize risk-free, then by middle width)
        middles.sort(
            key=lambda x: (x['is_risk_free'], x['middle_width']),
            reverse=True,
        )
        return middles


# Demonstration
if __name__ == "__main__":
    np.random.seed(42)
    analyzer = FuturesTimingAnalyzer()

    # Simulate a team's win total pricing throughout a season
    # Team with true win total around 52 (NBA)
    true_strength = 52

    dates = [
        ("Pre-Season (Aug)", 0, ""),
        ("Season Start (Oct)", 0, "0-0"),
        ("Nov 1", 8, "5-3"),
        ("Dec 1", 22, "12-10"),
        ("Jan 1", 36, "20-16"),
        ("Feb 1", 50, "28-22"),
        ("Mar 1", 62, "36-26"),
        ("Apr 1", 75, "45-30"),
    ]

    for date_label, games, record in dates:
        # Simulate line movement
        if games == 0:
            line = 50.5  # Preseason line
            model_prob_over = 0.58  # Our model says over
        else:
            # Line adjusts based on team's actual record
            wins = int(record.split('-')[0])
            losses = int(record.split('-')[1])
            win_pct = wins / (wins + losses)
            projected_total = win_pct * 82
            line = round(projected_total - 0.5)  # Line tracks performance
            remaining = 82 - games
            expected_remaining = true_strength * remaining / 82
            projected_final = wins + expected_remaining
            model_prob_over = 1.0 - np.clip(
                (line - projected_final) / 5.0 + 0.5, 0.1, 0.9
            )

        # Simulate odds with vig
        vig = 0.05 if games == 0 else 0.045
        over_odds = 1 / (model_prob_over * (1 + vig / 2))
        under_odds = 1 / ((1 - model_prob_over) * (1 + vig / 2))

        analyzer.add_price_point(FuturesPricePoint(
            date=date_label,
            team="BOS",
            market="win_total",
            line=line,
            over_odds=round(over_odds, 3),
            under_odds=round(under_odds, 3),
            model_probability=round(model_prob_over, 3),
            games_played=games,
            current_record=record,
        ))

    # Analyze entry timing
    timing = analyzer.analyze_entry_timing("BOS", "win_total")
    print("WIN TOTAL ENTRY TIMING ANALYSIS: BOS")
    print("=" * 70)

    if timing.get('timeline'):
        print(f"{'Date':>25} {'Line':>5} {'Games':>6} {'Record':>8} "
              f"{'Edge':>7} {'Side':>6} {'Vig':>5}")
        print("-" * 70)
        for t in timing['timeline']:
            print(f"{t['date']:>25} {t['line']:>5.1f} {t['games_played']:>6} "
                  f"{t['record']:>8} {t['best_edge']:>+7.1%} "
                  f"{t['best_side']:>6} {t['vig']:>5.1%}")

    if timing.get('best_entry'):
        print(f"\nBest entry: {timing['best_entry']['date']} "
              f"({timing['best_entry']['best_side']} "
              f"at {timing['best_entry']['line']}, "
              f"edge: {timing['best_entry']['best_edge']:+.1%})")

    # Find middle opportunities
    middles = analyzer.find_middle_opportunities("BOS", "win_total")
    print(f"\n\nMIDDLE OPPORTUNITIES")
    print("=" * 70)
    for m in middles[:5]:
        print(f"\n  {m['type']}: Over {m['over_line']} / Under {m['under_line']}")
        print(f"  Middle width: {m['middle_width']} wins")
        print(f"  Profit if in middle: ${m['profit_in_middle']:.0f}")
        print(f"  Profit if above: ${m['profit_above']:.0f}")
        print(f"  Profit if below: ${m['profit_below']:.0f}")
        print(f"  Worst case: ${m['worst_case']:.0f}")
        print(f"  Risk-free: {'Yes' if m['is_risk_free'] else 'No'}")

35.5 Season-Long Portfolio Management

Managing a Futures Portfolio

A season-long futures portfolio typically contains dozens of positions across win totals, division futures, conference futures, and championship futures. Managing this portfolio requires tracking implied probability changes, monitoring edge deterioration, and making ongoing rebalancing decisions.

The key principles of futures portfolio management are:

Diversification: Spread capital across many uncorrelated positions. Do not concentrate on a single team or outcome. A well-diversified futures portfolio might include 15-25 positions across different teams, leagues, and market types.

Capital allocation: Futures tie up capital for extended periods. The opportunity cost of locked-up capital must be factored into position sizing. A Kelly-optimal bet that locks up capital for 8 months is worth less than the same Kelly-sized bet that resolves in a day.

Dynamic management: Unlike game bets that resolve quickly, futures positions must be actively managed. As new information arrives, you should continuously reassess whether each position still has positive expected value.

Correlation awareness: Futures positions can be correlated in non-obvious ways. For example, betting the over on one team's win total is implicitly a bet against their division rivals to some degree. Understanding these correlations prevents unintended concentration.

Tracking Implied Probability Changes

The implied probability of your futures positions changes daily as the market adjusts to new information. Tracking these changes serves several purposes:

  1. P&L estimation: You can estimate the mark-to-market value of your portfolio by comparing current implied probabilities to your purchase prices.
  2. Edge monitoring: If the market moves toward your model's price, your edge is shrinking and you may want to hedge or close the position.
  3. Opportunity identification: If the market moves away from your model's price (i.e., your edge is growing), you may want to add to the position.

Rebalancing

Rebalancing a futures portfolio involves adjusting position sizes to maintain desired risk exposure as probabilities change. The three main rebalancing triggers are:

  1. Probability threshold: When a team's championship probability crosses a predefined threshold (e.g., from 5% to 15%), the Kelly-optimal bet size changes significantly.
  2. Edge deterioration: When the edge on a position drops below a minimum threshold, consider closing or hedging.
  3. Correlation changes: When the correlation structure of the portfolio changes (e.g., two teams you are long both make the conference finals and must play each other), rebalancing is necessary.

Python Code for Season-Long Portfolio Management

import numpy as np
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import json


@dataclass
class FuturesPosition:
    """A single position in the futures portfolio."""
    position_id: str
    team: str
    market_type: str  # 'win_total', 'division', 'conference', 'championship'
    direction: str    # 'over', 'under', 'win'
    stake: float
    odds_at_entry: float
    implied_prob_at_entry: float
    model_prob_at_entry: float
    entry_date: str
    current_odds: float = 0.0
    current_model_prob: float = 0.0
    status: str = 'open'  # 'open', 'won', 'lost', 'hedged', 'closed'
    hedge_amount: float = 0.0
    notes: str = ""

    @property
    def potential_payout(self) -> float:
        return self.stake * self.odds_at_entry

    @property
    def current_implied_prob(self) -> float:
        if self.current_odds > 0:
            return 1.0 / self.current_odds
        return self.implied_prob_at_entry

    @property
    def mark_to_market(self) -> float:
        """Estimate current market value of position."""
        if self.status in ('won', 'lost', 'closed'):
            return 0.0
        # Value = current_prob / entry_prob * stake - stake
        # (what we could sell the position for minus what we paid)
        if self.implied_prob_at_entry > 0:
            value_ratio = self.current_implied_prob / self.implied_prob_at_entry
            return self.stake * (value_ratio - 1)
        return 0.0

    @property
    def current_edge(self) -> float:
        """Current model edge on this position."""
        return self.current_model_prob - self.current_implied_prob

    @property
    def edge_at_entry(self) -> float:
        return self.model_prob_at_entry - self.implied_prob_at_entry


class FuturesPortfolioManager:
    """
    Manages a portfolio of futures positions throughout a season.

    Tracks:
    - Position P&L and mark-to-market values
    - Edge evolution over time
    - Portfolio-level risk metrics
    - Rebalancing recommendations
    """

    def __init__(
        self,
        total_bankroll: float = 50000.0,
        max_futures_allocation: float = 0.15,  # 15% of bankroll
        min_edge_threshold: float = 0.02,      # 2% minimum edge
        kelly_fraction: float = 0.20,          # 20% Kelly
    ):
        self.total_bankroll = total_bankroll
        self.max_futures_allocation = max_futures_allocation
        self.min_edge = min_edge_threshold
        self.kelly_fraction = kelly_fraction

        self.positions: Dict[str, FuturesPosition] = {}
        self.history: List[Dict] = []
        self.position_counter = 0

    @property
    def total_stake(self) -> float:
        return sum(
            p.stake for p in self.positions.values()
            if p.status == 'open'
        )

    @property
    def total_mtm(self) -> float:
        return sum(
            p.mark_to_market for p in self.positions.values()
            if p.status == 'open'
        )

    @property
    def allocation_pct(self) -> float:
        return self.total_stake / self.total_bankroll

    def add_position(
        self,
        team: str,
        market_type: str,
        direction: str,
        odds: float,
        model_prob: float,
        stake: Optional[float] = None,
        entry_date: str = "",
        notes: str = "",
    ) -> Optional[str]:
        """
        Add a new futures position to the portfolio.

        If stake is not provided, calculates Kelly-optimal size.
        """
        implied_prob = 1.0 / odds
        edge = model_prob - implied_prob

        if edge < self.min_edge:
            print(f"Edge ({edge:.1%}) below minimum ({self.min_edge:.1%}). "
                  f"Position not added.")
            return None

        # Check allocation limits
        remaining_allocation = (
            self.total_bankroll * self.max_futures_allocation - self.total_stake
        )
        if remaining_allocation <= 0:
            print("Futures allocation limit reached. Position not added.")
            return None

        # Calculate Kelly-optimal stake if not provided
        if stake is None:
            b = odds - 1
            p = model_prob
            q = 1 - p
            kelly_full = (p * b - q) / b
            kelly_full = max(0, kelly_full)
            stake = self.total_bankroll * kelly_full * self.kelly_fraction

            # Apply constraints
            max_single = self.total_bankroll * 0.02  # 2% max single position
            stake = min(stake, max_single, remaining_allocation)

        if stake < 10:
            print("Calculated stake too small. Position not added.")
            return None

        self.position_counter += 1
        pos_id = f"FUT_{self.position_counter:04d}"

        position = FuturesPosition(
            position_id=pos_id,
            team=team,
            market_type=market_type,
            direction=direction,
            stake=round(stake, 2),
            odds_at_entry=odds,
            implied_prob_at_entry=implied_prob,
            model_prob_at_entry=model_prob,
            entry_date=entry_date or datetime.now().strftime("%Y-%m-%d"),
            current_odds=odds,
            current_model_prob=model_prob,
            notes=notes,
        )

        self.positions[pos_id] = position

        self.history.append({
            'action': 'OPEN',
            'position_id': pos_id,
            'team': team,
            'market': market_type,
            'stake': round(stake, 2),
            'odds': odds,
            'edge': round(edge, 3),
            'date': position.entry_date,
        })

        return pos_id

    def update_prices(self, updates: Dict[str, Dict]):
        """
        Update current prices for positions.

        Args:
            updates: Dict of position_id -> {current_odds, current_model_prob}
        """
        for pos_id, update in updates.items():
            if pos_id in self.positions:
                pos = self.positions[pos_id]
                if 'current_odds' in update:
                    pos.current_odds = update['current_odds']
                if 'current_model_prob' in update:
                    pos.current_model_prob = update['current_model_prob']

    def get_portfolio_summary(self) -> Dict:
        """Generate a comprehensive portfolio summary."""
        open_positions = [
            p for p in self.positions.values() if p.status == 'open'
        ]
        closed_positions = [
            p for p in self.positions.values() if p.status != 'open'
        ]

        # Position-level details
        position_details = []
        for p in open_positions:
            position_details.append({
                'id': p.position_id,
                'team': p.team,
                'market': p.market_type,
                'direction': p.direction,
                'stake': p.stake,
                'entry_odds': p.odds_at_entry,
                'current_odds': p.current_odds,
                'entry_edge': round(p.edge_at_entry, 3),
                'current_edge': round(p.current_edge, 3),
                'mtm_pnl': round(p.mark_to_market, 2),
                'mtm_pct': round(p.mark_to_market / p.stake * 100, 1)
                           if p.stake > 0 else 0,
            })

        # Sort by current edge
        position_details.sort(key=lambda x: x['current_edge'], reverse=True)

        # Portfolio metrics
        total_stake = sum(p.stake for p in open_positions)
        total_mtm = sum(p.mark_to_market for p in open_positions)
        avg_edge = (np.mean([p.current_edge for p in open_positions])
                    if open_positions else 0)

        # Concentration analysis
        team_exposure = {}
        for p in open_positions:
            team_exposure[p.team] = team_exposure.get(p.team, 0) + p.stake

        max_team_exposure = max(team_exposure.values()) if team_exposure else 0

        # Edge distribution
        positive_edge = sum(
            1 for p in open_positions if p.current_edge > 0
        )
        negative_edge = sum(
            1 for p in open_positions if p.current_edge <= 0
        )

        return {
            'summary': {
                'total_bankroll': self.total_bankroll,
                'total_stake': round(total_stake, 2),
                'allocation_pct': round(total_stake / self.total_bankroll * 100, 1),
                'total_mtm_pnl': round(total_mtm, 2),
                'mtm_roi': round(total_mtm / max(total_stake, 1) * 100, 1),
                'n_open_positions': len(open_positions),
                'n_closed_positions': len(closed_positions),
                'avg_current_edge': round(avg_edge, 3),
                'positions_with_edge': positive_edge,
                'positions_without_edge': negative_edge,
                'max_single_team_exposure': round(max_team_exposure, 2),
            },
            'positions': position_details,
            'team_exposure': {
                k: round(v, 2) for k, v in
                sorted(team_exposure.items(), key=lambda x: x[1], reverse=True)
            },
        }

    def get_rebalancing_recommendations(self) -> List[Dict]:
        """
        Generate rebalancing recommendations based on current
        portfolio state and edge evolution.
        """
        recommendations = []

        for pos_id, pos in self.positions.items():
            if pos.status != 'open':
                continue

            current_edge = pos.current_edge
            entry_edge = pos.edge_at_entry

            # Recommendation 1: Close positions where edge has disappeared
            if current_edge < -0.02:
                recommendations.append({
                    'action': 'CLOSE/HEDGE',
                    'position_id': pos_id,
                    'team': pos.team,
                    'market': pos.market_type,
                    'reason': f'Negative edge ({current_edge:+.1%})',
                    'urgency': 'HIGH',
                    'entry_edge': round(entry_edge, 3),
                    'current_edge': round(current_edge, 3),
                    'mtm_pnl': round(pos.mark_to_market, 2),
                })
            elif current_edge < self.min_edge / 2:
                recommendations.append({
                    'action': 'MONITOR',
                    'position_id': pos_id,
                    'team': pos.team,
                    'market': pos.market_type,
                    'reason': f'Edge eroding ({current_edge:+.1%})',
                    'urgency': 'MEDIUM',
                    'entry_edge': round(entry_edge, 3),
                    'current_edge': round(current_edge, 3),
                    'mtm_pnl': round(pos.mark_to_market, 2),
                })

            # Recommendation 2: Add to positions where edge has grown
            if current_edge > entry_edge * 1.5 and current_edge > 0.05:
                recommendations.append({
                    'action': 'ADD',
                    'position_id': pos_id,
                    'team': pos.team,
                    'market': pos.market_type,
                    'reason': (f'Edge expanded from {entry_edge:+.1%} '
                               f'to {current_edge:+.1%}'),
                    'urgency': 'MEDIUM',
                    'entry_edge': round(entry_edge, 3),
                    'current_edge': round(current_edge, 3),
                    'mtm_pnl': round(pos.mark_to_market, 2),
                })

            # Recommendation 3: Hedge profitable positions with large MTM
            if pos.mark_to_market > pos.stake * 2:
                recommendations.append({
                    'action': 'PARTIAL_HEDGE',
                    'position_id': pos_id,
                    'team': pos.team,
                    'market': pos.market_type,
                    'reason': (f'Large unrealized gain '
                               f'(${pos.mark_to_market:.0f}, '
                               f'{pos.mark_to_market/pos.stake:.0%} of stake)'),
                    'urgency': 'LOW',
                    'entry_edge': round(entry_edge, 3),
                    'current_edge': round(current_edge, 3),
                    'mtm_pnl': round(pos.mark_to_market, 2),
                })

        # Sort by urgency
        urgency_order = {'HIGH': 0, 'MEDIUM': 1, 'LOW': 2}
        recommendations.sort(key=lambda x: urgency_order.get(x['urgency'], 3))

        return recommendations


# Demonstration
if __name__ == "__main__":
    np.random.seed(42)

    pm = FuturesPortfolioManager(
        total_bankroll=50000,
        max_futures_allocation=0.15,
        min_edge_threshold=0.02,
        kelly_fraction=0.20,
    )

    # Add preseason positions
    print("ADDING PRESEASON POSITIONS")
    print("=" * 60)

    positions_to_add = [
        ("BOS", "championship", "win", 5.00, 0.24, "Strong roster, clear edge"),
        ("OKC", "championship", "win", 6.00, 0.20, "Young core, high ceiling"),
        ("BOS", "win_total", "over", 1.909, 0.58, "Over 56.5 wins"),
        ("CHI", "win_total", "under", 1.909, 0.62, "Under 32.5 wins"),
        ("MIL", "division", "win", 3.00, 0.38, "Central division"),
        ("NYK", "win_total", "over", 1.952, 0.56, "Over 49.5 wins"),
        ("DEN", "championship", "win", 10.00, 0.12, "Defending champs undervalued"),
        ("CLE", "win_total", "over", 1.870, 0.57, "Over 52.5 wins"),
    ]

    for team, market, direction, odds, model_prob, notes in positions_to_add:
        pos_id = pm.add_position(
            team=team, market_type=market, direction=direction,
            odds=odds, model_prob=model_prob,
            entry_date="2025-10-01", notes=notes,
        )
        if pos_id:
            pos = pm.positions[pos_id]
            print(f"  {pos_id}: {team} {market} {direction} "
                  f"${pos.stake:.0f} @ {odds} "
                  f"(edge: {pos.edge_at_entry:+.1%})")

    # Simulate mid-season price changes
    print("\n\nMID-SEASON UPDATE (January)")
    print("=" * 60)

    updates = {}
    for pos_id, pos in pm.positions.items():
        # Simulate price movement
        # Some positions improve, some deteriorate
        if pos.team == "BOS":
            new_odds = pos.odds_at_entry * 0.75  # BOS odds shortened
            new_model_prob = pos.model_prob_at_entry * 1.1
        elif pos.team == "CHI":
            new_odds = pos.odds_at_entry * 0.9  # CHI under looking good
            new_model_prob = pos.model_prob_at_entry * 1.05
        elif pos.team == "NYK":
            new_odds = pos.odds_at_entry * 1.3  # NYK struggling
            new_model_prob = pos.model_prob_at_entry * 0.85
        elif pos.team == "DEN":
            new_odds = pos.odds_at_entry * 1.5  # DEN injuries
            new_model_prob = pos.model_prob_at_entry * 0.7
        else:
            new_odds = pos.odds_at_entry * np.random.uniform(0.8, 1.2)
            new_model_prob = pos.model_prob_at_entry * np.random.uniform(0.9, 1.1)

        new_model_prob = min(new_model_prob, 0.95)
        updates[pos_id] = {
            'current_odds': round(new_odds, 2),
            'current_model_prob': round(new_model_prob, 3),
        }

    pm.update_prices(updates)

    # Portfolio summary
    summary = pm.get_portfolio_summary()
    print(f"\nPortfolio Summary:")
    for k, v in summary['summary'].items():
        print(f"  {k}: {v}")

    print(f"\nPositions:")
    print(f"{'ID':>10} {'Team':>5} {'Market':>12} {'Stake':>8} "
          f"{'EntryEdge':>10} {'CurEdge':>9} {'MTM P&L':>9} {'MTM%':>6}")
    print("-" * 75)
    for p in summary['positions']:
        print(f"{p['id']:>10} {p['team']:>5} {p['market']:>12} "
              f"${p['stake']:>7.0f} {p['entry_edge']:>+10.1%} "
              f"{p['current_edge']:>+9.1%} ${p['mtm_pnl']:>8.0f} "
              f"{p['mtm_pct']:>+5.0f}%")

    print(f"\nTeam Exposure:")
    for team, exposure in summary['team_exposure'].items():
        print(f"  {team}: ${exposure:.0f}")

    # Rebalancing recommendations
    print(f"\n\nREBALANCING RECOMMENDATIONS")
    print("=" * 60)
    recommendations = pm.get_rebalancing_recommendations()
    for rec in recommendations:
        print(f"\n  [{rec['urgency']}] {rec['action']}: "
              f"{rec['team']} {rec['market']} ({rec['position_id']})")
        print(f"    Reason: {rec['reason']}")
        print(f"    Entry edge: {rec['entry_edge']:+.1%} -> "
              f"Current: {rec['current_edge']:+.1%}")
        print(f"    MTM P&L: ${rec['mtm_pnl']:.0f}")

35.6 Chapter Summary

Futures markets reward patient, disciplined bettors who can combine quantitative modeling with sound portfolio management. The longer time horizon introduces unique challenges -- capital lockup, dynamic information, and the need for ongoing management -- but also creates opportunities that do not exist in the faster-paced game-by-game markets.

Key takeaways:

  1. Win total modeling is the gateway to futures analysis. Monte Carlo season simulation produces full distributions of outcomes, enabling precise probability calculations for any win total threshold. The Pythagorean expectation provides a powerful regression tool for identifying teams whose record over- or under-represents their true strength.

  2. Implied probability extraction requires care. The proportional normalization method is simple but biased against longshots. The power method and Shin method provide more accurate fair probabilities, particularly for markets with large overrounds and many selections. Understanding which method to use and when is essential for accurate edge identification.

  3. Hedging is a risk management tool, not a profit strategy. Pure risk-free hedges sacrifice expected value for certainty. Kelly-optimal hedging provides a principled framework for deciding how much to hedge based on your risk tolerance and the current probability landscape. The optimal hedge amount depends on bankroll size, probability estimates, and utility function.

  4. Timing matters in futures. The same bet at different points in the season can have dramatically different expected values. Preseason offers the widest edges but longest capital lockup. Early-season overreaction to small samples creates contrarian opportunities. Middle opportunities arise when lines move enough to create overlapping win ranges.

  5. Portfolio management is continuous. A futures portfolio is not a set-and-forget operation. Tracking implied probability changes, monitoring edge evolution, managing team-level concentration, and executing rebalancing trades are ongoing responsibilities. The portfolio manager who actively manages their book will consistently outperform the one who places bets and walks away.

  6. Correlation awareness prevents hidden concentration. Betting the over on one team's win total is inherently correlated with betting the under on their division rivals. Championship bets on multiple teams in the same conference create scenarios where positions must offset each other. Understanding and managing these correlations is essential for true portfolio diversification.

The combination of rigorous modeling, disciplined execution, and active portfolio management forms the foundation of a successful season-long futures operation. While individual futures bets are inherently high-variance propositions, a diversified portfolio of positive-expected-value positions, actively managed throughout the season, can produce reliable long-term returns.