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...
In This Chapter
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:
-
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.
-
Schedule analysis: The specific opponents each team faces, accounting for home/away splits, rest advantages, travel, and the strength of the opposition.
-
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:
-
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.
-
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.
-
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.
-
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:
-
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.
-
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.
-
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.
-
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:
- Bet the over on a team's win total at 48.5 before the season
- 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:
- P&L estimation: You can estimate the mark-to-market value of your portfolio by comparing current implied probabilities to your purchase prices.
- 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.
- 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:
- 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.
- Edge deterioration: When the edge on a position drops below a minimum threshold, consider closing or hedging.
- 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:
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.