Case Study 2: Dynamic Futures Hedging and Portfolio Management

Overview

This case study implements a dynamic hedging engine for futures portfolios. We simulate a bettor holding multiple futures positions throughout a season, making hedging decisions as team probabilities evolve with new information. The system demonstrates risk-free hedging, Kelly-optimal hedging, multi-stage hedging through playoff rounds, and portfolio-level risk management with correlation-aware position sizing.

Problem Statement

Futures bets lock up capital for weeks or months. As the season unfolds, a position that was originally a small-edge bet may become a large unrealized gain (if the team is performing well) or a likely loss (if the team has disappointed). The bettor faces ongoing decisions: when to hedge to lock in profit, how much to hedge, and how to manage a portfolio of correlated futures positions. Our goal is to build a system that answers these questions quantitatively using expected value, Kelly criterion, and portfolio optimization.

Implementation

"""
Case Study 2: Dynamic Futures Hedging and Portfolio Management

Models a futures portfolio through a season, making hedging decisions
at key milestones using risk-free, Kelly, and multi-stage strategies.
"""

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


@dataclass
class FuturesPosition:
    """An open futures bet.

    Attributes:
        team: Team name.
        bet_type: "championship", "win_total_over", or "win_total_under".
        stake: Original wager amount.
        odds: Decimal odds at time of bet.
        current_prob: Current estimated probability of winning.
        original_prob: Probability at time of bet.
    """

    team: str
    bet_type: str
    stake: float
    odds: float
    current_prob: float
    original_prob: float

    @property
    def potential_profit(self) -> float:
        """Maximum profit if the bet wins."""
        return self.stake * (self.odds - 1)

    @property
    def expected_value(self) -> float:
        """Current expected value of the position."""
        return self.current_prob * self.potential_profit - (
            1 - self.current_prob
        ) * self.stake

    @property
    def current_fair_odds(self) -> float:
        """Fair decimal odds at current probability."""
        if self.current_prob <= 0:
            return float("inf")
        return 1.0 / self.current_prob


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

    Args:
        bankroll: Current bankroll size.
        kelly_fraction: Fraction of Kelly to use (0.25 = quarter Kelly).
    """

    def __init__(
        self, bankroll: float = 10_000, kelly_fraction: float = 0.25
    ):
        self.bankroll = bankroll
        self.kelly_fraction = kelly_fraction

    def risk_free_hedge(
        self,
        position: FuturesPosition,
        hedge_odds: float,
    ) -> Dict:
        """Calculate the risk-free hedge that guarantees equal profit.

        Args:
            position: The futures position to hedge.
            hedge_odds: Decimal odds for the opposing side.

        Returns:
            Hedge details with stakes and guaranteed profit.
        """
        max_profit = position.potential_profit
        hedge_payout_per_unit = hedge_odds - 1.0

        if hedge_payout_per_unit <= 0:
            return {"error": "Hedge odds too low"}

        # If original wins: profit = max_profit - hedge_stake
        # If hedge wins: profit = hedge_stake * (hedge_odds - 1) - position.stake
        # Set equal: max_profit - H = H * (hedge_odds - 1) - stake
        # max_profit + stake = H * (1 + hedge_odds - 1) = H * hedge_odds
        hedge_stake = (max_profit + position.stake) / hedge_odds
        guaranteed_profit = max_profit - hedge_stake

        return {
            "hedge_stake": round(hedge_stake, 2),
            "guaranteed_profit": round(guaranteed_profit, 2),
            "roi_on_total_risk": round(
                guaranteed_profit / (position.stake + hedge_stake), 4
            ),
            "if_original_wins": round(max_profit - hedge_stake, 2),
            "if_hedge_wins": round(
                hedge_stake * hedge_payout_per_unit - position.stake, 2
            ),
        }

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

        The Kelly hedge maximizes expected log utility of the combined
        position (original + hedge).

        Args:
            position: The futures position to hedge.
            hedge_odds: Decimal odds for the opposing side.
            win_prob: Probability the original bet wins.

        Returns:
            Hedge details with Kelly-optimal stake.
        """
        max_profit = position.potential_profit
        b_hedge = hedge_odds - 1.0

        if b_hedge <= 0:
            return {"error": "Hedge odds too low"}

        # Kelly fraction for the hedge side
        q = win_prob  # prob that hedge loses (original wins)
        p = 1.0 - win_prob  # prob that hedge wins (original loses)

        # Effective bankroll includes the potential swing
        effective_bank = self.bankroll + max_profit

        kelly_full = (b_hedge * p - q) / b_hedge
        kelly_adjusted = max(kelly_full * self.kelly_fraction, 0)

        hedge_stake = kelly_adjusted * effective_bank

        # Cap hedge at risk-free level
        rf = self.risk_free_hedge(position, hedge_odds)
        if not isinstance(rf.get("hedge_stake"), str):
            hedge_stake = min(hedge_stake, rf["hedge_stake"])

        if_original_wins = max_profit - hedge_stake
        if_hedge_wins = hedge_stake * b_hedge - position.stake

        ev = win_prob * if_original_wins + (1 - win_prob) * if_hedge_wins

        return {
            "hedge_stake": round(hedge_stake, 2),
            "kelly_fraction_raw": round(kelly_full, 4),
            "expected_value": round(ev, 2),
            "if_original_wins": round(if_original_wins, 2),
            "if_hedge_wins": round(if_hedge_wins, 2),
        }

    def partial_hedge(
        self,
        position: FuturesPosition,
        hedge_odds: float,
        hedge_pct: float = 0.50,
    ) -> Dict:
        """Calculate a partial hedge at a specified percentage.

        Args:
            position: The futures position to hedge.
            hedge_odds: Decimal odds for the opposing side.
            hedge_pct: Fraction of the risk-free hedge to place.

        Returns:
            Hedge details.
        """
        rf = self.risk_free_hedge(position, hedge_odds)
        if "error" in rf:
            return rf

        hedge_stake = rf["hedge_stake"] * hedge_pct
        b = hedge_odds - 1.0

        if_original_wins = position.potential_profit - hedge_stake
        if_hedge_wins = hedge_stake * b - position.stake

        return {
            "hedge_pct": hedge_pct,
            "hedge_stake": round(hedge_stake, 2),
            "if_original_wins": round(if_original_wins, 2),
            "if_hedge_wins": round(if_hedge_wins, 2),
        }

    def middle_opportunity(
        self,
        position: FuturesPosition,
        current_line: float,
        original_line: float,
        hedge_odds: float,
    ) -> Dict:
        """Evaluate a middle opportunity on win total futures.

        Args:
            position: Original position (e.g., Over 47.5).
            current_line: Current live line (e.g., 52.5).
            original_line: Original line (e.g., 47.5).
            hedge_odds: Odds for the hedge side (e.g., Under 52.5).

        Returns:
            Middle analysis with probabilities and expected value.
        """
        # Middle range: wins between original_line and current_line
        middle_low = int(original_line) + 1  # First win count where original wins
        middle_high = int(current_line)  # Last win count where hedge also wins

        # Approximate middle probability using normal distribution
        from scipy.stats import norm
        mean_wins = (original_line + current_line) / 2 + 1
        std_wins = 4.5  # Typical remaining uncertainty

        middle_prob = float(
            norm.cdf(middle_high + 0.5, mean_wins, std_wins)
            - norm.cdf(middle_low - 0.5, mean_wins, std_wins)
        )

        rf = self.risk_free_hedge(position, hedge_odds)
        if "error" in rf:
            return rf

        # In a middle, both bets win
        middle_bonus = rf["hedge_stake"] * (hedge_odds - 1)

        return {
            "middle_range": f"{middle_low}-{middle_high} wins",
            "middle_prob": round(middle_prob, 4),
            "guaranteed_profit": rf["guaranteed_profit"],
            "middle_bonus": round(middle_bonus, 2),
            "expected_middle_bonus": round(middle_prob * middle_bonus, 2),
        }


class FuturesPortfolio:
    """Manages a portfolio of futures positions.

    Args:
        bankroll: Starting bankroll.
        max_exposure_pct: Maximum total capital at risk as fraction of bankroll.
        max_single_pct: Maximum single-bet exposure as fraction of bankroll.
    """

    def __init__(
        self,
        bankroll: float = 10_000,
        max_exposure_pct: float = 0.20,
        max_single_pct: float = 0.05,
    ):
        self.bankroll = bankroll
        self.max_exposure_pct = max_exposure_pct
        self.max_single_pct = max_single_pct
        self.positions: List[FuturesPosition] = []

    def add_position(self, position: FuturesPosition) -> bool:
        """Add a position if it passes exposure checks.

        Args:
            position: New position to add.

        Returns:
            True if added, False if rejected.
        """
        # Single bet exposure check
        if position.stake > self.bankroll * self.max_single_pct:
            return False

        # Total exposure check
        total_risk = sum(p.stake for p in self.positions) + position.stake
        if total_risk > self.bankroll * self.max_exposure_pct:
            return False

        self.positions.append(position)
        return True

    def portfolio_summary(self) -> Dict:
        """Compute portfolio-level metrics.

        Returns:
            Summary dictionary with risk and return metrics.
        """
        total_stake = sum(p.stake for p in self.positions)
        total_potential = sum(p.potential_profit for p in self.positions)
        total_ev = sum(p.expected_value for p in self.positions)

        # Portfolio variance (approximate, assuming some correlation)
        # Championship futures on different teams are negatively correlated
        individual_vars = []
        for p in self.positions:
            win_payoff = p.potential_profit
            lose_payoff = -p.stake
            var = (
                p.current_prob * (win_payoff - p.expected_value) ** 2
                + (1 - p.current_prob) * (lose_payoff - p.expected_value) ** 2
            )
            individual_vars.append(var)

        # Simple sum (conservative, ignores negative correlations)
        total_var = sum(individual_vars)
        total_std = float(np.sqrt(total_var))

        return {
            "n_positions": len(self.positions),
            "total_stake": round(total_stake, 2),
            "total_potential_profit": round(total_potential, 2),
            "total_ev": round(total_ev, 2),
            "portfolio_std": round(total_std, 2),
            "exposure_pct": round(total_stake / self.bankroll, 4),
            "ev_per_dollar_risk": round(
                total_ev / total_stake if total_stake > 0 else 0, 4
            ),
        }


def simulate_season_evolution(
    seed: int = 42,
) -> List[Dict]:
    """Simulate a season with evolving championship probabilities.

    Args:
        seed: Random seed.

    Returns:
        List of milestone snapshots showing probability evolution.
    """
    rng = np.random.RandomState(seed)

    team = "BOS"
    milestones = [
        "Preseason",
        "20 games in",
        "All-Star break",
        "Trade deadline",
        "Regular season end",
        "First round",
        "Conference semis",
        "Conference finals",
        "Finals",
    ]

    # Simulate probability evolution
    prob = 0.08  # Preseason
    snapshots = []

    for i, milestone in enumerate(milestones):
        # At each milestone, probability evolves
        if i > 0:
            drift = rng.normal(0.02, 0.03)  # Slight upward drift
            shock = 0.0
            if rng.random() < 0.2:
                shock = rng.normal(0, 0.05)
            prob = np.clip(prob + drift + shock, 0.01, 0.95)

        # For playoffs, bigger jumps
        if i >= 5:
            if rng.random() < 0.65:  # 65% chance of advancing
                prob = min(prob * 1.5 + rng.uniform(0.02, 0.08), 0.95)
            else:
                prob = 0.0  # Eliminated

        if prob <= 0:
            snapshots.append({
                "milestone": milestone,
                "prob": 0.0,
                "eliminated": True,
            })
            break

        snapshots.append({
            "milestone": milestone,
            "prob": round(prob, 4),
            "eliminated": False,
        })

    return snapshots


def main() -> None:
    """Run the hedging and portfolio management case study."""
    print("=" * 70)
    print("CASE STUDY 2: Dynamic Futures Hedging & Portfolio Management")
    print("=" * 70)

    # --- Part 1: Basic Hedging Strategies ---
    print("\n--- Part 1: Hedging Strategy Comparison ---\n")

    position = FuturesPosition(
        team="BOS",
        bet_type="championship",
        stake=100.0,
        odds=21.0,  # +2000
        current_prob=0.45,
        original_prob=0.08,
    )

    calc = FuturesHedgeCalculator(bankroll=10_000, kelly_fraction=0.25)

    print(f"  Original bet: ${position.stake} on {position.team} "
          f"at {position.odds:.1f} (implied {1/position.odds:.1%})")
    print(f"  Current prob: {position.current_prob:.1%}")
    print(f"  Potential profit: ${position.potential_profit:,.0f}")
    print(f"  Current EV: ${position.expected_value:,.0f}")

    # Opponent moneyline at -150 (1.667 decimal)
    hedge_odds = 1.667

    # Risk-free hedge
    rf = calc.risk_free_hedge(position, hedge_odds)
    print(f"\n  Risk-Free Hedge:")
    print(f"    Hedge stake: ${rf['hedge_stake']:,.2f}")
    print(f"    Guaranteed profit: ${rf['guaranteed_profit']:,.2f}")
    print(f"    If original wins: ${rf['if_original_wins']:,.2f}")
    print(f"    If hedge wins: ${rf['if_hedge_wins']:,.2f}")

    # Kelly hedge
    kelly = calc.kelly_hedge(position, hedge_odds, position.current_prob)
    print(f"\n  Kelly-Optimal Hedge:")
    print(f"    Hedge stake: ${kelly['hedge_stake']:,.2f}")
    print(f"    Expected value: ${kelly['expected_value']:,.2f}")
    print(f"    If original wins: ${kelly['if_original_wins']:,.2f}")
    print(f"    If hedge wins: ${kelly['if_hedge_wins']:,.2f}")

    # Partial hedges
    print(f"\n  Partial Hedge Comparison:")
    print(f"  {'Pct':>5} {'Stake':>10} {'If Win':>10} {'If Lose':>10}")
    print("  " + "-" * 38)
    for pct in [0.0, 0.25, 0.50, 0.75, 1.00]:
        if pct == 0:
            print(f"  {0:>4.0%} {'$0':>10} "
                  f"${position.potential_profit:>9,.0f} "
                  f"${-position.stake:>9,.0f}")
        else:
            ph = calc.partial_hedge(position, hedge_odds, pct)
            print(f"  {pct:>4.0%} ${ph['hedge_stake']:>9,.2f} "
                  f"${ph['if_original_wins']:>9,.2f} "
                  f"${ph['if_hedge_wins']:>9,.2f}")

    # --- Part 2: Middle Opportunity ---
    print("\n\n--- Part 2: Middle Opportunity ---\n")

    wt_position = FuturesPosition(
        team="BOS",
        bet_type="win_total_over",
        stake=200.0,
        odds=1.91,
        current_prob=0.92,
        original_prob=0.55,
    )

    middle = calc.middle_opportunity(
        wt_position,
        current_line=52.5,
        original_line=47.5,
        hedge_odds=1.91,
    )

    print(f"  Original bet: Over 47.5 wins at {wt_position.odds}")
    print(f"  Current live line: 52.5")
    print(f"  Middle range: {middle['middle_range']}")
    print(f"  Middle probability: {middle['middle_prob']:.1%}")
    print(f"  Guaranteed profit: ${middle['guaranteed_profit']:,.2f}")
    print(f"  Middle bonus: ${middle['middle_bonus']:,.2f}")
    print(f"  Expected middle bonus: ${middle['expected_middle_bonus']:,.2f}")

    # --- Part 3: Season Evolution ---
    print("\n\n--- Part 3: Season Evolution & Hedge Timing ---\n")

    snapshots = simulate_season_evolution(seed=42)

    print(f"  {'Milestone':>22} {'Prob':>7} {'EV':>8} {'Action':>20}")
    print("  " + "-" * 60)

    for snap in snapshots:
        if snap["eliminated"]:
            print(f"  {snap['milestone']:>22} {'0.0%':>7} "
                  f"{'$-100':>8} {'ELIMINATED':>20}")
            break

        pos = FuturesPosition(
            "BOS", "championship", 100, 21.0,
            snap["prob"], 0.08,
        )
        ev = pos.expected_value

        if snap["prob"] > 0.40:
            action = "HEDGE RECOMMENDED"
        elif snap["prob"] > 0.20:
            action = "Monitor / partial"
        elif snap["prob"] > 0.10:
            action = "Hold position"
        else:
            action = "Hold / small edge"

        print(f"  {snap['milestone']:>22} {snap['prob']:>7.1%} "
              f"${ev:>7,.0f} {action:>20}")

    # --- Part 4: Portfolio Management ---
    print("\n\n--- Part 4: Portfolio Management ---\n")

    portfolio = FuturesPortfolio(
        bankroll=10_000,
        max_exposure_pct=0.20,
        max_single_pct=0.05,
    )

    candidates = [
        FuturesPosition("BOS", "championship", 300, 13.0, 0.10, 0.06),
        FuturesPosition("MIL", "championship", 200, 17.0, 0.08, 0.05),
        FuturesPosition("DEN", "championship", 250, 11.0, 0.12, 0.08),
        FuturesPosition("BOS", "win_total_over", 500, 1.91, 0.58, 0.52),
        FuturesPosition("LAL", "win_total_under", 400, 1.91, 0.60, 0.54),
        FuturesPosition("GSW", "championship", 150, 21.0, 0.06, 0.04),
        FuturesPosition("PHX", "win_total_over", 800, 1.91, 0.55, 0.50),
    ]

    print(f"  Attempting to add {len(candidates)} positions "
          f"(bankroll: ${portfolio.bankroll:,.0f}):\n")

    for pos in candidates:
        added = portfolio.add_position(pos)
        status = "ADDED" if added else "REJECTED (exposure limit)"
        print(f"    {pos.team:>4} {pos.bet_type:>18} "
              f"${pos.stake:>6,.0f} => {status}")

    summary = portfolio.portfolio_summary()
    print(f"\n  Portfolio Summary:")
    print(f"    Positions: {summary['n_positions']}")
    print(f"    Total stake: ${summary['total_stake']:,.2f}")
    print(f"    Exposure: {summary['exposure_pct']:.1%} of bankroll")
    print(f"    Total potential profit: ${summary['total_potential_profit']:,.2f}")
    print(f"    Total EV: ${summary['total_ev']:,.2f}")
    print(f"    EV per dollar at risk: {summary['ev_per_dollar_risk']:+.1%}")
    print(f"    Portfolio std dev: ${summary['portfolio_std']:,.2f}")

    # --- Part 5: Multi-Round Hedging ---
    print("\n\n--- Part 5: Multi-Round Playoff Hedging ---\n")

    # Team in conference semifinals, needs to win 2 more rounds
    cfinal_prob = 0.55  # P(win conf finals)
    finals_prob = 0.48  # P(win finals | in finals)
    champ_prob = cfinal_prob * finals_prob

    original = FuturesPosition(
        "BOS", "championship", 100, 26.0,
        champ_prob, 0.04,
    )

    print(f"  Team in conf semis (already won round 1)")
    print(f"  P(win conf finals) = {cfinal_prob:.1%}")
    print(f"  P(win finals | in finals) = {finals_prob:.1%}")
    print(f"  P(championship) = {champ_prob:.1%}")
    print(f"  Original bet: ${original.stake} at {original.odds} "
          f"(potential: ${original.potential_profit:,.0f})")

    # Strategy A: No hedge
    ev_no_hedge = original.expected_value
    print(f"\n  Strategy A (No Hedge):")
    print(f"    EV = ${ev_no_hedge:,.0f}")

    # Strategy B: Hedge only if they make finals
    print(f"\n  Strategy B (Hedge only at Finals):")
    print(f"    If they make finals (p={cfinal_prob:.0%}): hedge vs Finals opponent")
    finals_hedge_odds = 1.80
    finals_pos = FuturesPosition(
        "BOS", "championship", 100, 26.0,
        finals_prob, 0.04,
    )
    rf_finals = calc.risk_free_hedge(finals_pos, finals_hedge_odds)
    print(f"    Risk-free hedge at Finals: ${rf_finals['hedge_stake']:,.2f}")
    print(f"    Guaranteed if in finals: ${rf_finals['guaranteed_profit']:,.2f}")
    # EV of strategy B
    ev_b = cfinal_prob * rf_finals["guaranteed_profit"] + (
        (1 - cfinal_prob) * (-original.stake)
    )
    print(f"    Overall EV = ${ev_b:,.0f}")

    # Strategy C: Hedge at each round
    print(f"\n  Strategy C (Hedge at Each Round):")
    # Hedge vs conf finals opponent now
    cf_hedge_odds = 2.10
    cf_hedge_stake = 80.0  # Partial hedge
    if_win_cf = original.potential_profit - cf_hedge_stake
    if_lose_cf = cf_hedge_stake * (cf_hedge_odds - 1) - original.stake
    ev_c = cfinal_prob * (finals_prob * if_win_cf + (1 - finals_prob) * (-original.stake + cf_hedge_stake * (cf_hedge_odds - 1) - cf_hedge_stake)) + (1 - cfinal_prob) * if_lose_cf
    print(f"    Conf finals hedge: ${cf_hedge_stake:.0f} at {cf_hedge_odds}")
    print(f"    If win conf finals then win championship: ${if_win_cf:,.0f}")
    print(f"    If lose conf finals: ${if_lose_cf:,.0f}")

    print("\n" + "=" * 70)


if __name__ == "__main__":
    main()

Analysis and Results

The hedging analysis reveals the fundamental tradeoff between locking in guaranteed profit and maintaining expected value. The risk-free hedge guarantees a positive return regardless of outcome but sacrifices significant upside. The Kelly-optimal hedge balances these concerns by hedging only enough to satisfy the bankroll management criterion, preserving more expected value while still reducing variance.

The middle opportunity on win total futures is a particularly attractive strategy. When a team exceeds expectations early in the season and the live win total moves significantly above the original line, the bettor can hedge with the under on the new line while retaining the original over bet. If the team finishes in the "middle" range, both bets win, creating a bonus payout.

The portfolio management analysis demonstrates why exposure limits are critical for futures betting. Even with positive-edge bets, concentrating too much capital in long-duration positions creates unacceptable risk. The maximum exposure of 20% of bankroll ensures that a worst-case scenario (all futures lose) does not devastate the bankroll.

The multi-round hedging comparison shows that hedging at each playoff round, rather than waiting until the final round, produces a more balanced risk-return profile. It locks in incremental profit at each stage while maintaining the possibility of the full payout.

Key Takeaways

Dynamic hedging is not about eliminating risk -- it is about managing the risk-return tradeoff as new information arrives. The optimal hedge depends on the bettor's bankroll, risk tolerance, and the current probability of the original bet winning. Risk-free hedging maximizes guaranteed profit but sacrifices expected value. Kelly hedging maximizes expected growth rate. Multi-stage hedging through playoff rounds provides a practical middle ground that locks in profit incrementally. Portfolio-level risk management with exposure caps prevents any single loss or scenario from being catastrophic.