Case Study 41.2: Multi-Sport Portfolio Management and Performance Attribution

Overview

Elena Vasquez had been a successful NFL bettor for three seasons, generating a 4.2% ROI on sides bets with a strong track record of beating the closing line. But with the NFL offering only 17 weeks of regular season games plus playoffs, she faced long stretches of the year with no betting activity --- and the temptation to bet on sports she knew less well, often with poor results.

After reading about portfolio approaches to sports betting, Elena decided to formalize her operation. Instead of ad hoc expansion into other sports, she would build a structured multi-sport portfolio with explicit risk budgets, disciplined diversification, and systematic performance attribution. This case study follows Elena's first year running the portfolio approach, revealing both the benefits of diversification and the hard lessons of scaling into new markets.

Building the Portfolio Framework

Elena began with a \$25,000 bankroll and models for four sports: NFL (proven), NBA (six months of development), MLB (new), and English Premier League soccer (new). Her first task was to create a risk budget that reflected her varying levels of confidence.

"""
Multi-Sport Portfolio Management System
Demonstrates risk budgeting, portfolio tracking, and performance attribution.
"""

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


@dataclass
class SportStrategy:
    """Configuration for a single sport/strategy combination.

    Attributes:
        sport: Name of the sport.
        strategy: Name of the strategy within the sport.
        allocation_pct: Maximum allocation as fraction of bankroll.
        max_per_bet_pct: Maximum single bet as fraction of bankroll.
        confidence_level: Bettor's confidence in this strategy (1-5 scale).
        track_record_months: Months of backtesting or live results.
    """
    sport: str
    strategy: str
    allocation_pct: float
    max_per_bet_pct: float
    confidence_level: int
    track_record_months: int


class MultiSportPortfolio:
    """Manage a diversified multi-sport betting portfolio.

    Tracks allocations, exposure, and enforces risk budget constraints
    across multiple sports and strategies.

    Args:
        total_bankroll: Starting bankroll in dollars.
        strategies: List of SportStrategy configurations.
    """

    def __init__(
        self, total_bankroll: float, strategies: List[SportStrategy]
    ) -> None:
        self.total_bankroll = total_bankroll
        self.initial_bankroll = total_bankroll
        self.strategies = {
            (s.sport, s.strategy): s for s in strategies
        }
        self.exposure: Dict[Tuple[str, str], float] = {
            key: 0.0 for key in self.strategies
        }
        self.pending_bets: List[Dict] = []
        self.settled_bets: List[Dict] = []
        self.daily_snapshots: List[Dict] = []

    def check_bet(
        self, sport: str, strategy: str, stake: float
    ) -> Tuple[bool, str]:
        """Validate a proposed bet against all risk budget constraints.

        Args:
            sport: Sport name.
            strategy: Strategy name.
            stake: Proposed stake in dollars.

        Returns:
            Tuple of (approved boolean, reason string).
        """
        key = (sport, strategy)
        if key not in self.strategies:
            return False, f"No allocation for {sport}/{strategy}"

        config = self.strategies[key]

        max_per_bet = config.max_per_bet_pct * self.total_bankroll
        if stake > max_per_bet:
            return False, (
                f"Stake ${stake:.2f} exceeds per-bet limit "
                f"${max_per_bet:.2f}"
            )

        max_allocation = config.allocation_pct * self.total_bankroll
        if self.exposure[key] + stake > max_allocation:
            return False, (
                f"Would exceed strategy allocation: "
                f"${self.exposure[key] + stake:.2f} > ${max_allocation:.2f}"
            )

        total_exposure = sum(self.exposure.values()) + stake
        if total_exposure > 0.30 * self.total_bankroll:
            return False, (
                f"Would exceed portfolio limit: "
                f"${total_exposure:.2f} > "
                f"${0.30 * self.total_bankroll:.2f}"
            )

        return True, "Approved"

    def place_bet(self, bet: Dict) -> bool:
        """Place a bet if it passes risk budget checks.

        Args:
            bet: Dictionary with keys: sport, strategy, stake, odds,
                game, side, model_prob, edge, date.

        Returns:
            True if bet was placed, False if rejected.
        """
        approved, reason = self.check_bet(
            bet["sport"], bet["strategy"], bet["stake"]
        )
        if not approved:
            return False

        key = (bet["sport"], bet["strategy"])
        self.exposure[key] += bet["stake"]
        bet["status"] = "pending"
        bet["placed_at"] = datetime.now().isoformat()
        self.pending_bets.append(bet)
        return True

    def settle_bet(self, bet_index: int, result: str) -> float:
        """Settle a pending bet and update bankroll.

        Args:
            bet_index: Index into pending_bets list.
            result: One of 'win', 'loss', or 'push'.

        Returns:
            P&L amount for this bet.
        """
        bet = self.pending_bets[bet_index]
        key = (bet["sport"], bet["strategy"])

        odds = bet["odds"]
        stake = bet["stake"]

        if result == "win":
            if odds > 0:
                pnl = stake * (odds / 100.0)
            else:
                pnl = stake * (100.0 / abs(odds))
        elif result == "loss":
            pnl = -stake
        else:
            pnl = 0.0

        bet["result"] = result
        bet["pnl"] = round(pnl, 2)
        bet["status"] = "settled"

        self.total_bankroll += pnl
        self.exposure[key] = max(0.0, self.exposure[key] - stake)
        self.settled_bets.append(bet)

        return pnl

    def snapshot(self, date: str) -> Dict:
        """Record a daily snapshot of portfolio state.

        Args:
            date: Date string for the snapshot.

        Returns:
            Dictionary with portfolio state metrics.
        """
        snap = {
            "date": date,
            "bankroll": round(self.total_bankroll, 2),
            "total_exposure": round(sum(self.exposure.values()), 2),
            "total_bets_settled": len(self.settled_bets),
            "total_pnl": round(
                self.total_bankroll - self.initial_bankroll, 2
            ),
        }
        self.daily_snapshots.append(snap)
        return snap


def generate_multi_sport_bets(
    n_months: int = 12,
    bankroll: float = 25000.0,
) -> pd.DataFrame:
    """Generate realistic multi-sport betting simulation data.

    Creates a year of simulated betting activity across four sports
    with different edge profiles and seasonal patterns.

    Args:
        n_months: Number of months to simulate.
        bankroll: Starting bankroll for stake sizing reference.

    Returns:
        DataFrame with all bet details and outcomes.
    """
    np.random.seed(123)

    sport_configs = {
        "NFL": {
            "strategy": "sides_model",
            "months_active": [9, 10, 11, 12, 1, 2],
            "bets_per_week": 4,
            "true_edge": 0.042,
            "avg_odds": -108,
            "stake_pct": 0.02,
        },
        "NBA": {
            "strategy": "totals_model",
            "months_active": [10, 11, 12, 1, 2, 3, 4, 5, 6],
            "bets_per_week": 6,
            "true_edge": 0.018,
            "avg_odds": -110,
            "stake_pct": 0.015,
        },
        "MLB": {
            "strategy": "moneyline_model",
            "months_active": [4, 5, 6, 7, 8, 9, 10],
            "bets_per_week": 8,
            "true_edge": -0.005,
            "avg_odds": 115,
            "stake_pct": 0.012,
        },
        "Soccer": {
            "strategy": "xg_model",
            "months_active": [8, 9, 10, 11, 12, 1, 2, 3, 4, 5],
            "bets_per_week": 3,
            "true_edge": 0.025,
            "avg_odds": -105,
            "stake_pct": 0.015,
        },
    }

    all_bets = []
    start_date = datetime(2024, 1, 1)

    for week in range(n_months * 4):
        current_date = start_date + timedelta(weeks=week)
        month = current_date.month

        for sport, cfg in sport_configs.items():
            if month not in cfg["months_active"]:
                continue

            n_bets = np.random.poisson(cfg["bets_per_week"])
            for bet_num in range(n_bets):
                bet_date = current_date + timedelta(
                    days=np.random.randint(0, 7)
                )

                odds_noise = np.random.randint(-15, 16)
                odds = cfg["avg_odds"] + odds_noise

                if odds > 0:
                    implied_prob = 100.0 / (odds + 100.0)
                else:
                    implied_prob = abs(odds) / (abs(odds) + 100.0)

                model_prob = implied_prob + cfg["true_edge"] + np.random.normal(0, 0.04)
                model_prob = np.clip(model_prob, 0.1, 0.9)
                edge = model_prob - implied_prob

                stake = bankroll * cfg["stake_pct"] * (1 + np.random.normal(0, 0.2))
                stake = round(max(stake, 50), 2)

                won = np.random.random() < model_prob
                if won:
                    if odds > 0:
                        pnl = stake * (odds / 100.0)
                    else:
                        pnl = stake * (100.0 / abs(odds))
                else:
                    pnl = -stake

                all_bets.append({
                    "date": bet_date,
                    "sport": sport,
                    "strategy": cfg["strategy"],
                    "game": f"{sport}_game_{week}_{bet_num}",
                    "side": np.random.choice(["home", "away"]),
                    "odds": odds,
                    "stake": stake,
                    "model_prob": round(model_prob, 4),
                    "edge_at_placement": round(edge, 4),
                    "result": "win" if won else "loss",
                    "pnl": round(pnl, 2),
                })

    return pd.DataFrame(all_bets).sort_values("date").reset_index(drop=True)

The Performance Attribution System

Six months into her multi-sport operation, Elena ran her first comprehensive performance attribution. The results were illuminating --- and uncomfortable.

"""
Performance Attribution Analysis for Multi-Sport Portfolio
"""


class PortfolioAttribution:
    """Comprehensive performance attribution for multi-sport portfolios.

    Decomposes P&L across multiple dimensions to identify sources
    of profit and loss.

    Args:
        bet_data: DataFrame of settled bets with required columns.
    """

    def __init__(self, bet_data: pd.DataFrame) -> None:
        self.data = bet_data.copy()
        self.data["date"] = pd.to_datetime(self.data["date"])
        self.data["roi"] = self.data["pnl"] / self.data["stake"]

    def summary_stats(self) -> Dict:
        """Calculate top-level portfolio performance metrics.

        Returns:
            Dictionary of overall performance metrics.
        """
        d = self.data
        total_staked = d["stake"].sum()
        total_pnl = d["pnl"].sum()

        cumulative = d.sort_values("date")["pnl"].cumsum()
        running_max = cumulative.expanding().max()
        drawdown = cumulative - running_max

        daily_pnl = d.groupby(d["date"].dt.date)["pnl"].sum()
        sharpe = (
            daily_pnl.mean() / daily_pnl.std() * np.sqrt(300)
            if daily_pnl.std() > 0 else 0.0
        )

        return {
            "total_bets": len(d),
            "total_staked": round(total_staked, 2),
            "total_pnl": round(total_pnl, 2),
            "roi_pct": round(total_pnl / total_staked * 100, 2),
            "win_rate": round((d["result"] == "win").mean() * 100, 1),
            "avg_edge": round(d["edge_at_placement"].mean() * 100, 2),
            "max_drawdown": round(drawdown.min(), 2),
            "sharpe_ratio": round(sharpe, 2),
        }

    def by_sport(self) -> pd.DataFrame:
        """Attribute performance by sport.

        Returns:
            DataFrame with per-sport performance breakdown.
        """
        grouped = self.data.groupby("sport").agg(
            n_bets=("pnl", "count"),
            total_staked=("stake", "sum"),
            total_pnl=("pnl", "sum"),
            avg_odds=("odds", "mean"),
            win_rate=("result", lambda x: (x == "win").mean()),
            avg_edge=("edge_at_placement", "mean"),
        ).reset_index()

        grouped["roi_pct"] = (
            grouped["total_pnl"] / grouped["total_staked"] * 100
        )
        grouped["pnl_contribution"] = (
            grouped["total_pnl"] / grouped["total_pnl"].sum() * 100
        )

        return grouped.sort_values("total_pnl", ascending=False).round(2)

    def by_month(self) -> pd.DataFrame:
        """Attribute performance by calendar month.

        Returns:
            DataFrame with monthly performance metrics.
        """
        self.data["month"] = self.data["date"].dt.to_period("M")

        monthly = self.data.groupby("month").agg(
            n_bets=("pnl", "count"),
            total_staked=("stake", "sum"),
            total_pnl=("pnl", "sum"),
            win_rate=("result", lambda x: (x == "win").mean()),
        ).reset_index()

        monthly["roi_pct"] = (
            monthly["total_pnl"] / monthly["total_staked"] * 100
        )
        monthly["cumulative_pnl"] = monthly["total_pnl"].cumsum()

        return monthly.round(2)

    def by_edge_bucket(self) -> pd.DataFrame:
        """Attribute performance by estimated edge at placement.

        Returns:
            DataFrame showing realized ROI within each edge bucket.
        """
        bins = [-1, 0, 0.02, 0.04, 0.06, 0.10, 1.0]
        labels = ["<0%", "0-2%", "2-4%", "4-6%", "6-10%", "10%+"]

        self.data["edge_bucket"] = pd.cut(
            self.data["edge_at_placement"],
            bins=bins,
            labels=labels,
        )

        edge_attr = self.data.groupby("edge_bucket", observed=True).agg(
            n_bets=("pnl", "count"),
            total_staked=("stake", "sum"),
            total_pnl=("pnl", "sum"),
            win_rate=("result", lambda x: (x == "win").mean()),
            avg_edge=("edge_at_placement", "mean"),
        ).reset_index()

        edge_attr["roi_pct"] = (
            edge_attr["total_pnl"] / edge_attr["total_staked"] * 100
        )

        return edge_attr.round(2)

    def sport_month_heatmap_data(self) -> pd.DataFrame:
        """Generate sport x month P&L matrix for heatmap visualization.

        Returns:
            Pivoted DataFrame with sports as rows and months as columns.
        """
        self.data["month_str"] = self.data["date"].dt.strftime("%Y-%m")

        pivot = self.data.pivot_table(
            values="pnl",
            index="sport",
            columns="month_str",
            aggfunc="sum",
            fill_value=0,
        )

        return pivot.round(2)

    def generate_full_report(self) -> Dict:
        """Generate comprehensive attribution report.

        Returns:
            Dictionary containing all attribution analyses.
        """
        report = {
            "summary": self.summary_stats(),
            "by_sport": self.by_sport(),
            "by_month": self.by_month(),
            "by_edge": self.by_edge_bucket(),
            "sport_month_matrix": self.sport_month_heatmap_data(),
        }

        print("\n" + "=" * 60)
        print("PORTFOLIO PERFORMANCE ATTRIBUTION REPORT")
        print("=" * 60)

        summary = report["summary"]
        print(f"\nOverall Performance:")
        print(f"  Total bets:       {summary['total_bets']}")
        print(f"  Total staked:    ${summary['total_staked']:,.2f}")
        print(f"  Total P&L:       ${summary['total_pnl']:,.2f}")
        print(f"  ROI:              {summary['roi_pct']:+.2f}%")
        print(f"  Win rate:         {summary['win_rate']:.1f}%")
        print(f"  Sharpe ratio:     {summary['sharpe_ratio']:.2f}")
        print(f"  Max drawdown:    ${summary['max_drawdown']:,.2f}")

        print(f"\nPerformance by Sport:")
        sport_df = report["by_sport"]
        for _, row in sport_df.iterrows():
            print(f"  {row['sport']:8s}: {row['n_bets']:4.0f} bets, "
                  f"ROI={row['roi_pct']:+6.2f}%, "
                  f"P&L=${row['total_pnl']:>8,.2f}, "
                  f"Contribution={row['pnl_contribution']:>5.1f}%")

        print(f"\nPerformance by Edge Bucket:")
        edge_df = report["by_edge"]
        for _, row in edge_df.iterrows():
            print(f"  {row['edge_bucket']:8s}: {row['n_bets']:4.0f} bets, "
                  f"ROI={row['roi_pct']:+6.2f}%, "
                  f"Win rate={row['win_rate']:.1%}")

        return report

Elena's Year in Review

After twelve months, Elena ran the full attribution analysis. Here is what she found.

"""
Running the full year simulation and attribution.
"""


def run_elena_case_study() -> Dict:
    """Execute the complete multi-sport portfolio case study.

    Generates simulated bet data, runs performance attribution,
    and prints detailed findings.

    Returns:
        Dictionary containing the full attribution report.
    """
    bet_data = generate_multi_sport_bets(n_months=12, bankroll=25000.0)

    attribution = PortfolioAttribution(bet_data)
    report = attribution.generate_full_report()

    print("\n" + "-" * 60)
    print("STRATEGIC RECOMMENDATIONS")
    print("-" * 60)

    sport_df = report["by_sport"]
    for _, row in sport_df.iterrows():
        if row["roi_pct"] < -1.0 and row["n_bets"] > 100:
            print(f"\n  WARNING: {row['sport']} shows {row['roi_pct']:+.2f}% ROI "
                  f"over {row['n_bets']:.0f} bets.")
            print(f"  Action: Review model calibration, reduce allocation, "
                  f"or suspend betting until root cause identified.")
        elif row["roi_pct"] > 3.0 and row["n_bets"] > 100:
            print(f"\n  STRONG: {row['sport']} shows {row['roi_pct']:+.2f}% ROI "
                  f"over {row['n_bets']:.0f} bets.")
            print(f"  Action: Consider increasing allocation if CLV confirms edge.")

    edge_df = report["by_edge"]
    negative_edge_bets = edge_df[edge_df["edge_bucket"] == "<0%"]
    if len(negative_edge_bets) > 0 and negative_edge_bets.iloc[0]["n_bets"] > 50:
        print(f"\n  ISSUE: {negative_edge_bets.iloc[0]['n_bets']:.0f} bets placed "
              f"with negative estimated edge.")
        print(f"  Action: Tighten signal filter to eliminate negative-edge bets.")

    return report


if __name__ == "__main__":
    report = run_elena_case_study()

Findings and Analysis

Elena's twelve-month attribution revealed a clear pattern:

NFL (proven model): Delivered a 4.1% ROI on 92 bets, accounting for the largest share of portfolio profit. Consistent with her three-year track record, confirming the edge was genuine and durable. Edge was concentrated in rest-advantage and weather-related situations.

NBA (developing model): Generated a modest 1.5% ROI on 156 bets. While profitable, the edge was smaller than expected and inconsistent across months. The totals model performed well during the regular season but lost money during the playoffs, suggesting the model was not adequately adjusting for playoff-style play.

MLB (new model): Lost money, with a -1.8% ROI on 198 bets. The attribution revealed that the moneyline model was profitable on underdogs (+money odds) but lost heavily on favorites. The model was overvaluing starting pitchers and not adequately accounting for bullpen quality, which matters most in tight games.

Soccer (new model): Produced a 2.3% ROI on 78 bets, a pleasant surprise. The xG-based model was particularly strong in identifying mispriced home underdogs in the EPL, where public perception of team quality lagged behind the underlying performance data.

The Diversification Benefit

The portfolio approach delivered exactly what it promised: smoother returns. While individual sport-months varied widely (NFL had a -\$380 month in December; NBA had a +\$520 month in March), the portfolio-level monthly returns were much more stable. The worst month saw a -2.1% drawdown at the portfolio level, compared to individual sport-months that swung by as much as -8%.

The correlation between sport-level returns was near zero (ranging from -0.05 to +0.08), confirming the powerful diversification benefit of cross-sport betting.

The MLB Decision

The most difficult decision Elena faced was what to do about MLB. The attribution clearly showed negative ROI, but 198 bets was enough to be statistically meaningful (a -1.8% ROI on 198 bets at typical vig levels is approximately 1.2 standard deviations below breakeven --- suggestive but not definitive).

Elena's analysis led her to three options:

  1. Suspend MLB entirely until the model was improved. Cost: lost opportunity if the edge was real but masked by a fixable model flaw.

  2. Reduce MLB allocation by 50% and focus bets on the profitable subset (underdogs only). Cost: smaller sample size for evaluation.

  3. Continue at current levels with a deadline (60 more bets). If underdogs remained profitable, transition to an underdog-only strategy.

Elena chose option 2, reducing the MLB allocation from 10% to 5% and restricting bets to underdogs with edges above 4%. This conservative approach limited downside while preserving the ability to learn.

Discussion Questions

  1. Elena's Soccer model was her newest but performed second-best. How should she interpret this result, given that 78 bets is a relatively small sample? What additional evidence should she seek before scaling up the soccer allocation?

  2. The MLB model was profitable on underdogs but unprofitable on favorites. What does this asymmetry suggest about the model's calibration? How would you redesign the model to address this?

  3. Elena's risk budget caps total portfolio exposure at 30% of bankroll. During peak season overlap (October-November when NFL, NBA, MLB playoffs, and soccer are all active), this constraint frequently binds. Should she raise the limit, or is the constraint working as intended?

  4. If Elena could add a fifth sport (tennis, golf, or esports), what criteria should she use to evaluate whether the addition improves the portfolio? Consider both the expected edge and the correlation with existing sports.

  5. The attribution shows that the worst individual sport-month was NFL in December (-\$380). An outcome-focused bettor might reduce NFL allocation in response. Why would this be the wrong conclusion, and what should Elena examine instead?