Capstone Project 3: Season-Long Betting Simulation Challenge


Project Overview

This capstone project is fundamentally different from the first two. Rather than building software, you will manage a simulated $10,000 bankroll through a complete historical sports season, making real betting decisions week by week without access to future results. The simulation framework will present you with that week's available odds, accept your bet slips, grade your bets against actual outcomes, and track your bankroll and performance metrics over time.

This project tests whether you can apply the full stack of knowledge from this textbook under realistic conditions: incomplete information, time pressure, psychological challenges, variance, and the need for consistent discipline across months of betting. Building a model is necessary but not sufficient. You must also demonstrate sound bankroll management (Chapters 4 and 14), value betting discipline (Chapter 13), emotional control through inevitable drawdowns (Chapter 36), systematic record-keeping (Chapter 37), and honest self-assessment (Chapter 41).

The simulation uses one complete historical season of real game results and actual closing odds. You will not know which season until the simulation begins, preventing any advantage from memory of outcomes. Your task is to apply your models and strategies as if you were betting in real time, submitting your bet slips before each week's results are revealed.

Learning Objectives

Upon completing this project, you will be able to:

  1. Develop and document a comprehensive pre-season betting strategy covering multiple sports and market types.
  2. Make disciplined betting decisions under uncertainty, balancing model output with market information.
  3. Manage a bankroll through extended winning streaks and drawdowns without deviating from strategy.
  4. Track, analyze, and attribute betting performance using professional-grade metrics.
  5. Conduct a rigorous mid-season review and make evidence-based strategy adjustments.
  6. Write a professional post-season retrospective that honestly evaluates both process and results.
  7. Distinguish between skill and variance in your own performance.

Rules and Constraints

Bankroll Rules

  1. Starting bankroll: $10,000 exactly. No additional deposits permitted.
  2. Minimum bet size: $25 per wager.
  3. Maximum bet size: $500 per wager (5% of initial bankroll). This hard cap applies regardless of current bankroll level.
  4. Maximum daily exposure: $2,000 in total open bets on any single day.
  5. Maximum weekly exposure: $4,000 in total bets placed during a single simulation week.
  6. If bankroll reaches $0: The simulation is over. You must document what happened and analyze why.
  7. If bankroll exceeds $25,000: Congratulations, but continue betting -- the full season must be completed for grading.

Betting Requirements

  1. Minimum total bets: 200 bets over the full season. This ensures you are actively participating, not sitting on the sideline waiting for one perfect spot.
  2. Multi-sport requirement: You must place bets on at least 3 different sports during the season. At least 20% of your total bets must come from a sport other than your primary sport.
  3. Market type diversity: You must bet on at least 3 different market types (e.g., spreads, moneylines, totals, props, futures). At least 10% of bets must come from a non-primary market.
  4. Bet types available: Moneylines, point spreads, totals (over/under), first-half lines, futures, and selected player props as provided in the data.
  5. No parlays or teasers unless you specifically model the correlation between legs (as described in Chapter 14, Section 14.3). If you include parlays, you must document your correlation methodology.
  6. Line shopping: You will receive odds from three simulated sportsbooks. You must place each bet at the book offering the best available odds for that selection.
  7. No retroactive bets: All bets must be submitted before the simulation reveals that week's results. The framework enforces this constraint programmatically.

What You Receive Each Week

The simulation framework provides:

  • Game schedule: All games for the upcoming week across NFL, NBA, MLB, NHL, and selected soccer leagues.
  • Odds from three sportsbooks: Moneylines, spreads, and totals for every game. Lines may differ between books by 0.5--2 points on spreads and 5--15 cents on moneylines, simulating real market variation.
  • Basic statistics: Team records, recent results (last 5 games), and season-level summary statistics for each team.
  • Injury reports: Key injuries listed with status (Out, Doubtful, Questionable).
  • Prior weeks' results: All results and your personal bet history are available for review.

You may use any external tools, models, or analysis to make your decisions, but you may not look up the actual historical results for the season being simulated. The honor system applies; additionally, the grading rubric rewards process quality and analysis depth, not raw results. A student who loses money but demonstrates excellent process, analysis, and self-awareness will outscore a student who profits through undocumented or incoherent methods.


Data Provided: Historical Season Package

The simulation framework generates a complete season of data drawn from actual historical results. The data package includes:

Provided Data Files

File Contents Format
schedule.csv Full season schedule across all sports game_id, date, sport, home_team, away_team, week_number
odds_book_a.csv Odds from Sportsbook A for all games game_id, home_ml, away_ml, spread, home_spread_odds, away_spread_odds, total, over_odds, under_odds
odds_book_b.csv Odds from Sportsbook B Same format as Book A
odds_book_c.csv Odds from Sportsbook C Same format as Book A
team_stats.csv Season-to-date team statistics Updated weekly; team, sport, wins, losses, points_for, points_against, recent_form
injuries.csv Weekly injury reports week, team, player, position, status
results.csv Game results (revealed week by week) game_id, home_score, away_score, winner

Data Generation Script

The following Python script generates the simulation data package from historical records. The instructor runs this script once to produce the sealed season data; students receive the data one week at a time through the simulation framework.

"""
simulation_data_generator.py
Generates a complete season simulation package from historical data.

The instructor runs this script to produce the season data.
Students do NOT run this script -- they interact with the simulation
framework (phase2_simulation.py) which reveals data week by week.
"""

import pandas as pd
import numpy as np
import sqlite3
import json
import hashlib
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from datetime import datetime
import random

class SeasonDataGenerator:
    """
    Generates a complete multi-sport season data package for simulation.

    Design:
    - Draws from actual historical results to ensure realism
    - Packages data into weekly chunks for sequential revelation
    - Adds simulated multi-book odds with realistic variation
    - Includes injury data and team statistics
    """

    def __init__(self, seed: int = None):
        """
        Parameters:
        - seed: Random seed for reproducibility. Different seeds produce
          different season selections.
        """
        self.rng = np.random.RandomState(seed)
        self.season_data = {}

    def generate_nfl_season(self, source_db: str,
                             season_year: int) -> pd.DataFrame:
        """
        Extract one NFL season from the nfl_data_py database.

        Returns DataFrame with: game_id, week, date, home_team, away_team,
        home_score, away_score, spread_line, total_line, home_ml, away_ml
        """
        import nfl_data_py as nfl

        schedules = nfl.import_schedules([season_year])
        games = schedules[schedules["game_type"] == "REG"][[
            "game_id", "week", "gameday", "home_team", "away_team",
            "home_score", "away_score", "spread_line", "total_line",
            "home_moneyline", "away_moneyline"
        ]].copy()

        games.rename(columns={
            "gameday": "date",
            "home_moneyline": "home_ml",
            "away_moneyline": "away_ml"
        }, inplace=True)

        games["sport"] = "NFL"
        games["home_margin"] = games["home_score"] - games["away_score"]
        games["total_score"] = games["home_score"] + games["away_score"]

        return games

    def generate_nba_season(self, season_year: int) -> pd.DataFrame:
        """
        Generate NBA season data. In production, this would pull from
        basketball-reference or the NBA API. For the simulation, we
        provide a pre-processed CSV.

        NBA seasons run October-April with ~1230 regular season games.
        """
        # Placeholder: load from pre-processed source
        # In the actual implementation, this reads from a curated database
        pass

    def generate_mlb_season(self, season_year: int) -> pd.DataFrame:
        """Generate MLB season data (April-September, ~2430 games)."""
        pass

    def generate_multi_book_odds(self, true_odds: pd.DataFrame,
                                   n_books: int = 3) -> Dict[str, pd.DataFrame]:
        """
        Generate simulated odds from multiple sportsbooks by adding
        realistic noise to the true closing lines.

        The variation between books follows the patterns described in
        Chapter 12 (Line Shopping):
        - Spreads vary by 0 to 1 point across books
        - Totals vary by 0 to 1 point
        - Moneylines vary by 5 to 20 cents
        - Juice varies from -105 to -115 on spreads

        This simulates real market conditions where different books
        have different positions and customer bases.
        """
        books = {}

        for i in range(n_books):
            book_name = f"book_{chr(65 + i)}"  # book_A, book_B, book_C
            book_odds = true_odds.copy()

            # Spread variation: +/- 0.5 with some probability
            spread_adjustment = self.rng.choice(
                [-0.5, 0, 0, 0, 0.5],
                size=len(book_odds)
            )
            book_odds["spread"] = book_odds["spread_line"] + spread_adjustment

            # Total variation
            total_adjustment = self.rng.choice(
                [-0.5, 0, 0, 0, 0.5],
                size=len(book_odds)
            )
            book_odds["total"] = book_odds["total_line"] + total_adjustment

            # Juice variation on spreads (-105 to -115)
            juice_home = self.rng.choice(
                [-105, -108, -110, -110, -110, -112, -115],
                size=len(book_odds)
            )
            juice_away = self._complementary_juice(juice_home)
            book_odds["home_spread_odds"] = juice_home
            book_odds["away_spread_odds"] = juice_away

            # Total juice variation
            book_odds["over_odds"] = self.rng.choice(
                [-105, -108, -110, -110, -110, -112, -115],
                size=len(book_odds)
            )
            book_odds["under_odds"] = self._complementary_juice(
                book_odds["over_odds"].values
            )

            # Moneyline variation (add noise proportional to odds magnitude)
            ml_noise = self.rng.randint(-10, 11, size=len(book_odds))
            book_odds["home_ml"] = book_odds["home_ml"] + ml_noise
            book_odds["away_ml"] = book_odds["away_ml"] - ml_noise

            # Ensure moneyline consistency (no +/+ or -/- for both sides)
            # and that moneylines have appropriate vig
            book_odds = self._fix_moneylines(book_odds)

            books[book_name] = book_odds[[
                "game_id", "home_ml", "away_ml", "spread",
                "home_spread_odds", "away_spread_odds",
                "total", "over_odds", "under_odds"
            ]]

        return books

    def _complementary_juice(self, juice_array: np.ndarray) -> np.ndarray:
        """
        Given juice on one side, compute the complementary juice
        so that the total vig is approximately 20 cents (the standard).
        If one side is -110, the other is -110 (20 cents total vig).
        If one side is -105, the other is approximately -115.
        """
        result = np.zeros_like(juice_array)
        for i, j in enumerate(juice_array):
            # Total implied probability should be approximately 1.04-1.05
            impl_this = abs(j) / (abs(j) + 100) if j < 0 else 100 / (j + 100)
            target_total = 1.04 + self.rng.uniform(0, 0.02)
            impl_other = target_total - impl_this
            # Convert back to American odds
            if impl_other > 0.5:
                result[i] = int(-100 * impl_other / (1 - impl_other))
            else:
                result[i] = int(100 * (1 - impl_other) / impl_other)
        return result.astype(int)

    def _fix_moneylines(self, df: pd.DataFrame) -> pd.DataFrame:
        """Ensure moneyline odds are valid and have appropriate vig."""
        for idx in df.index:
            home_ml = df.loc[idx, "home_ml"]
            away_ml = df.loc[idx, "away_ml"]

            # Both sides should not be positive (no arb by default)
            # At least one should be negative in most cases
            if home_ml > 0 and away_ml > 0:
                # Make the slight favorite negative
                if home_ml < away_ml:
                    df.loc[idx, "home_ml"] = -home_ml
                else:
                    df.loc[idx, "away_ml"] = -away_ml

            # Ensure minimum vig (total implied prob >= 1.02)
            h_prob = abs(home_ml) / (abs(home_ml) + 100) if home_ml < 0 else 100 / (home_ml + 100)
            a_prob = abs(away_ml) / (abs(away_ml) + 100) if away_ml < 0 else 100 / (away_ml + 100)

            if h_prob + a_prob < 1.02:
                # Add vig by moving both lines slightly
                df.loc[idx, "home_ml"] = int(df.loc[idx, "home_ml"] * 0.95)
                df.loc[idx, "away_ml"] = int(df.loc[idx, "away_ml"] * 0.95)

        return df

    def generate_weekly_team_stats(self, results: pd.DataFrame,
                                     week: int) -> pd.DataFrame:
        """
        Generate team statistics as they would be known through the
        specified week. Only includes results from completed weeks.

        This prevents any look-ahead information from leaking into
        the data (critical constraint, see Chapter 30 on backtesting).
        """
        completed = results[results["week"] <= week].copy()

        stats = []
        teams = set(completed["home_team"]).union(set(completed["away_team"]))

        for team in teams:
            home_games = completed[completed["home_team"] == team]
            away_games = completed[completed["away_team"] == team]

            wins = (
                (home_games["home_score"] > home_games["away_score"]).sum() +
                (away_games["away_score"] > away_games["home_score"]).sum()
            )
            losses = (
                (home_games["home_score"] < home_games["away_score"]).sum() +
                (away_games["away_score"] < away_games["home_score"]).sum()
            )
            ties = (
                (home_games["home_score"] == home_games["away_score"]).sum() +
                (away_games["away_score"] == away_games["home_score"]).sum()
            )

            points_for = (
                home_games["home_score"].sum() +
                away_games["away_score"].sum()
            )
            points_against = (
                home_games["away_score"].sum() +
                away_games["home_score"].sum()
            )

            games_played = wins + losses + ties

            # Last 5 games form
            all_team = pd.concat([
                home_games.assign(
                    team_score=home_games["home_score"],
                    opp_score=home_games["away_score"]
                ),
                away_games.assign(
                    team_score=away_games["away_score"],
                    opp_score=away_games["home_score"]
                )
            ]).sort_values("week").tail(5)

            recent_wins = (all_team["team_score"] > all_team["opp_score"]).sum()
            recent_form = f"{recent_wins}-{len(all_team) - recent_wins}"

            stats.append({
                "team": team,
                "sport": completed[
                    (completed["home_team"] == team) |
                    (completed["away_team"] == team)
                ]["sport"].iloc[0],
                "week": week,
                "wins": wins,
                "losses": losses,
                "ties": ties,
                "games_played": games_played,
                "points_for": points_for,
                "points_against": points_against,
                "ppg": round(points_for / max(games_played, 1), 1),
                "papg": round(points_against / max(games_played, 1), 1),
                "point_diff": points_for - points_against,
                "recent_form": recent_form,
            })

        return pd.DataFrame(stats)

    def package_season(self, output_dir: str = "simulation_data"):
        """
        Package all season data into the simulation directory structure.
        Creates weekly subdirectories with appropriate data files.
        """
        out = Path(output_dir)
        out.mkdir(parents=True, exist_ok=True)

        # Write full schedule (game times revealed, results hidden)
        # Write odds files for each book
        # Write initial team stats
        # Write weekly result files (sealed, revealed one at a time)

        print(f"Season package written to {out}")
        print(f"Total games: {len(self.season_data.get('schedule', []))}")
        print("Ready for simulation.")

Phase 1: Pre-Season Analysis and Strategy Development

Duration: Week 1 (of simulation, which maps to 1 week of real time)

Relevant chapters: Chapter 4 (Bankroll Management), Chapter 13 (Value Betting), Chapter 14 (Advanced Bankroll), Chapter 15--22 (Sport-Specific Modeling), Chapter 36 (Psychology)

Before the simulation begins, you must submit a written Pre-Season Strategy Document (3--5 pages) that establishes your approach. This document will be compared to your actual behavior during the simulation.

1.1 Required Strategy Document Contents

Section A: Overall Philosophy (1 page) - What is your betting philosophy? (e.g., model-driven, market-driven, hybrid) - Which sports and markets will you prioritize and why? - What is your edge hypothesis? Where do you believe inefficiencies exist? - How will you balance expected value against variance? - Reference the value betting framework from Chapter 13.

Section B: Bankroll Management Plan (1 page) - What staking method will you use? (Flat bet, percentage, Kelly, fractional Kelly) - Justify your choice using the analysis from Chapter 4 and Chapter 14. - What is your standard unit size? - What are your maximum bet size rules? - Under what conditions will you adjust your unit size? - What are your stop-loss rules? (Daily, weekly, monthly) - How will you handle a 20% drawdown? A 40% drawdown?

Section C: Model Description (1--2 pages) - What predictive model(s) will you use? - What data inputs drive your model? - How do you estimate probabilities? - How do you identify value relative to the market? - What are your minimum edge thresholds for placing a bet? - What is your process for each sport you plan to bet? - Reference specific techniques from Chapters 9, 15--22, 26, and 27 as appropriate.

Section D: Process and Discipline (0.5 page) - Describe your weekly workflow: how will you analyze games, select bets, and record decisions? - What safeguards do you have against tilt and emotional betting (Chapter 36)? - How will you track your performance (Chapter 37)?

1.2 Pre-Season Model Setup

Before the simulation begins, you should build or calibrate your predictive models. You may use any combination of the following approaches (reference the appropriate chapters):

Approach Description Chapter
Elo ratings Rating system updated through prior seasons Ch 26
Power ratings Regression-based team strength estimates Ch 9, 26
EPA/efficiency models Sport-specific efficiency metrics Ch 15--22
Machine learning XGBoost, neural nets, etc. Ch 27, 29
Market-based Use sharp lines as truth, exploit soft books Ch 11, 12
Hybrid Combine model probabilities with market information Ch 13, 41

You may also enter the simulation with no formal model and rely on qualitative analysis, but your documentation must explain your reasoning framework. Note that the grading rubric heavily weights process quality, so a well-documented qualitative approach can score well.


Phase 2: Monthly Simulation Rounds

Duration: Weeks 2--7 (the season unfolds over approximately 6 simulation rounds)

Relevant chapters: Chapter 3 (Expected Value), Chapter 12 (Line Shopping), Chapter 13 (Value Betting), Chapter 14 (Staking), Chapter 37 (Record-Keeping)

2.1 Weekly Betting Process

Each simulation week, you will:

  1. Receive the weekly data package: Schedule, odds from three books, team stats, and injury reports for upcoming games.
  2. Analyze the games: Apply your models, identify value opportunities, and compare to market odds.
  3. Submit your bet slip: A structured CSV file listing every bet you wish to place.
  4. Receive results: After submission, the framework reveals that week's outcomes and grades your bets.

2.2 Bet Slip Format

Each weekly bet slip is a CSV file with the following columns:

game_id,bet_type,selection,sportsbook,odds,stake,model_probability,reasoning
Column Description Example
game_id Unique identifier for the game NFL_2023_W05_KC_MIN
bet_type Market type "spread", "moneyline", "total"
selection What you are betting on "KC -3", "MIN +3", "Over 48.5", "MIN ML"
sportsbook Which book you are placing at "book_A", "book_B", "book_C"
odds American odds at that book -110, +155
stake Dollar amount wagered 100.00
model_probability Your estimated probability of winning 0.55
reasoning Brief explanation (required for every bet) "EPA model gives KC 3.8 edge; market at 3.0 is 0.8 pts of value"

2.3 Simulation Framework

The following code implements the simulation engine that manages the season simulation, accepts bet slips, grades bets, and tracks performance.

"""
phase2_simulation_framework.py
Season-long betting simulation engine.

This framework manages the simulation lifecycle:
1. Loads the sealed season data
2. Reveals data one week at a time
3. Accepts bet slips and validates them against rules
4. Grades bets against actual results
5. Tracks bankroll and performance metrics
6. Produces weekly and season-level reports
"""

import pandas as pd
import numpy as np
import json
import csv
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass, field, asdict
from datetime import datetime
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Configuration and Constants
# ---------------------------------------------------------------------------

INITIAL_BANKROLL = 10000.0
MIN_BET = 25.0
MAX_BET = 500.0
MAX_DAILY_EXPOSURE = 2000.0
MAX_WEEKLY_EXPOSURE = 4000.0
MIN_TOTAL_BETS = 200
MIN_SPORTS = 3
MIN_SECONDARY_SPORT_PCT = 0.20
MIN_MARKET_TYPES = 3
MIN_SECONDARY_MARKET_PCT = 0.10


@dataclass
class BetSlip:
    """A single bet submission."""
    game_id: str
    bet_type: str
    selection: str
    sportsbook: str
    odds: int
    stake: float
    model_probability: float
    reasoning: str


@dataclass
class GradedBet:
    """A bet after grading against actual results."""
    game_id: str
    bet_type: str
    selection: str
    sportsbook: str
    odds: int
    stake: float
    model_probability: float
    reasoning: str
    week: int
    sport: str
    result: str        # "win", "loss", "push"
    profit: float
    home_score: int
    away_score: int
    closing_line: float


@dataclass
class WeeklyReport:
    """Summary of one week's betting activity."""
    week: int
    bets_placed: int
    bets_won: int
    bets_lost: int
    bets_pushed: int
    total_staked: float
    total_profit: float
    weekly_roi: float
    bankroll_start: float
    bankroll_end: float
    best_bet: str
    worst_bet: str


@dataclass
class SimulationState:
    """Tracks the complete state of the simulation."""
    current_week: int = 0
    bankroll: float = INITIAL_BANKROLL
    total_bets: int = 0
    total_profit: float = 0.0
    all_bets: List[GradedBet] = field(default_factory=list)
    weekly_reports: List[WeeklyReport] = field(default_factory=list)
    peak_bankroll: float = INITIAL_BANKROLL
    max_drawdown: float = 0.0
    sports_bet_on: Dict[str, int] = field(default_factory=dict)
    markets_bet_on: Dict[str, int] = field(default_factory=dict)


# ---------------------------------------------------------------------------
# Simulation Engine
# ---------------------------------------------------------------------------

class SeasonSimulator:
    """
    Manages the season-long betting simulation.

    Lifecycle:
    1. Initialize with sealed season data
    2. For each week:
       a. Reveal that week's schedule, odds, stats, and injuries
       b. Accept bet slip from the student
       c. Validate bets against rules
       d. Grade bets against actual results
       e. Update bankroll and metrics
       f. Generate weekly report
    3. After final week, generate season summary
    """

    def __init__(self, data_dir: str = "simulation_data"):
        self.data_dir = Path(data_dir)
        self.state = SimulationState()

        # Load season data
        self.schedule = pd.read_csv(self.data_dir / "schedule.csv")
        self.results = pd.read_csv(self.data_dir / "results.csv")
        self.books = {
            "book_A": pd.read_csv(self.data_dir / "odds_book_a.csv"),
            "book_B": pd.read_csv(self.data_dir / "odds_book_b.csv"),
            "book_C": pd.read_csv(self.data_dir / "odds_book_c.csv"),
        }
        self.injuries = pd.read_csv(self.data_dir / "injuries.csv")

        self.total_weeks = self.schedule["week_number"].max()
        logger.info(f"Simulation loaded: {len(self.schedule)} games "
                     f"across {self.total_weeks} weeks")

    def get_week_data(self, week: int) -> Dict:
        """
        Reveal data for the specified week.
        Only returns information that would be available BEFORE
        the week's games are played.
        """
        if week > self.total_weeks:
            raise ValueError(f"Week {week} exceeds season length "
                              f"({self.total_weeks})")

        week_schedule = self.schedule[
            self.schedule["week_number"] == week
        ].copy()

        # Odds for this week's games
        week_game_ids = set(week_schedule["game_id"])
        week_odds = {}
        for book_name, book_df in self.books.items():
            week_odds[book_name] = book_df[
                book_df["game_id"].isin(week_game_ids)
            ].copy()

        # Injuries for this week
        week_injuries = self.injuries[
            self.injuries["week"] == week
        ].copy()

        # Team stats through PREVIOUS week (no current week info)
        # This is loaded from pre-computed stats
        stats_file = self.data_dir / f"team_stats_week_{week - 1}.csv"
        if stats_file.exists():
            team_stats = pd.read_csv(stats_file)
        else:
            team_stats = pd.DataFrame()

        # Prior results (all completed weeks)
        prior_results = self.results[
            self.results["game_id"].isin(
                self.schedule[self.schedule["week_number"] < week]["game_id"]
            )
        ].copy()

        return {
            "week": week,
            "schedule": week_schedule,
            "odds": week_odds,
            "injuries": week_injuries,
            "team_stats": team_stats,
            "prior_results": prior_results,
            "bankroll": self.state.bankroll,
            "season_bets_so_far": self.state.total_bets,
        }

    def validate_bet_slip(self, bets: List[BetSlip],
                           week: int) -> Tuple[List[BetSlip], List[str]]:
        """
        Validate a bet slip against the simulation rules.
        Returns (valid_bets, list_of_error_messages).
        """
        errors = []
        valid = []

        weekly_total = 0.0

        for i, bet in enumerate(bets):
            bet_errors = []

            # Check game exists this week
            week_games = set(self.schedule[
                self.schedule["week_number"] == week
            ]["game_id"])
            if bet.game_id not in week_games:
                bet_errors.append(
                    f"Bet {i+1}: game_id '{bet.game_id}' not in week {week}"
                )

            # Check bet size limits
            if bet.stake < MIN_BET:
                bet_errors.append(
                    f"Bet {i+1}: stake ${bet.stake:.2f} below minimum "
                    f"${MIN_BET:.2f}"
                )
            if bet.stake > MAX_BET:
                bet_errors.append(
                    f"Bet {i+1}: stake ${bet.stake:.2f} exceeds maximum "
                    f"${MAX_BET:.2f}"
                )

            # Check bankroll sufficiency
            if bet.stake > self.state.bankroll:
                bet_errors.append(
                    f"Bet {i+1}: stake ${bet.stake:.2f} exceeds bankroll "
                    f"${self.state.bankroll:.2f}"
                )

            # Check weekly exposure
            weekly_total += bet.stake
            if weekly_total > MAX_WEEKLY_EXPOSURE:
                bet_errors.append(
                    f"Bet {i+1}: cumulative weekly exposure "
                    f"${weekly_total:.2f} exceeds maximum "
                    f"${MAX_WEEKLY_EXPOSURE:.2f}"
                )

            # Check sportsbook exists
            if bet.sportsbook not in self.books:
                bet_errors.append(
                    f"Bet {i+1}: unknown sportsbook '{bet.sportsbook}'"
                )

            # Verify odds match the book
            if bet.sportsbook in self.books:
                book_df = self.books[bet.sportsbook]
                game_odds = book_df[book_df["game_id"] == bet.game_id]
                if not game_odds.empty:
                    # Verify the submitted odds match what the book offers
                    # (allow small rounding differences)
                    pass  # Detailed verification logic here

            # Check model probability is valid
            if not (0.0 < bet.model_probability < 1.0):
                bet_errors.append(
                    f"Bet {i+1}: model_probability must be between 0 and 1"
                )

            # Check reasoning is not empty
            if not bet.reasoning or len(bet.reasoning.strip()) < 10:
                bet_errors.append(
                    f"Bet {i+1}: reasoning must be at least 10 characters"
                )

            if bet_errors:
                errors.extend(bet_errors)
            else:
                valid.append(bet)

        return valid, errors

    def grade_bets(self, bets: List[BetSlip], week: int) -> List[GradedBet]:
        """
        Grade submitted bets against actual results.

        Grading logic by bet type:
        - Spread: home_margin + spread vs. 0
        - Moneyline: did the selected team win?
        - Total: home_score + away_score vs. total line
        """
        graded = []
        week_results = self.results[
            self.results["game_id"].isin(
                self.schedule[self.schedule["week_number"] == week]["game_id"]
            )
        ]

        for bet in bets:
            game_result = week_results[
                week_results["game_id"] == bet.game_id
            ]
            if game_result.empty:
                logger.warning(f"No result found for game {bet.game_id}")
                continue

            game = game_result.iloc[0]
            home_score = int(game["home_score"])
            away_score = int(game["away_score"])
            home_margin = home_score - away_score
            total_score = home_score + away_score

            # Get game sport
            game_info = self.schedule[
                self.schedule["game_id"] == bet.game_id
            ].iloc[0]
            sport = game_info["sport"]

            # Grade the bet
            result, profit = self._grade_single_bet(
                bet, home_margin, total_score
            )

            # Get closing line for CLV calculation
            # Use the average across all three books as the closing line
            closing_line = self._get_closing_line(bet)

            graded.append(GradedBet(
                game_id=bet.game_id,
                bet_type=bet.bet_type,
                selection=bet.selection,
                sportsbook=bet.sportsbook,
                odds=bet.odds,
                stake=bet.stake,
                model_probability=bet.model_probability,
                reasoning=bet.reasoning,
                week=week,
                sport=sport,
                result=result,
                profit=profit,
                home_score=home_score,
                away_score=away_score,
                closing_line=closing_line,
            ))

        return graded

    def _grade_single_bet(self, bet: BetSlip, home_margin: int,
                           total_score: int) -> Tuple[str, float]:
        """Grade a single bet and return (result, profit)."""
        odds = bet.odds
        stake = bet.stake

        if bet.bet_type == "spread":
            # Parse the spread from selection (e.g., "KC -3" or "MIN +3")
            parts = bet.selection.rsplit(" ", 1)
            spread = float(parts[-1])

            # Determine which side: if team name matches home, use spread as-is
            # If it matches away, the effective margin check is different
            # For simplicity: the selection states the team and their spread
            # Cover check: team's margin relative to their spread
            # "KC -3" covers if KC wins by more than 3
            # "MIN +3" covers if MIN loses by less than 3 or wins

            # Determine if this is a home or away bet from the game schedule
            game_info = self.schedule[
                self.schedule["game_id"] == bet.game_id
            ].iloc[0]

            team_name = parts[0].strip()
            if team_name == game_info["home_team"]:
                cover_margin = home_margin + spread
            else:
                cover_margin = -home_margin + spread

            if cover_margin > 0:
                result = "win"
            elif cover_margin == 0:
                result = "push"
            else:
                result = "loss"

        elif bet.bet_type == "moneyline":
            parts = bet.selection.split(" ML")
            team_name = parts[0].strip()

            game_info = self.schedule[
                self.schedule["game_id"] == bet.game_id
            ].iloc[0]

            if team_name == game_info["home_team"]:
                won = home_margin > 0
            else:
                won = home_margin < 0

            if home_margin == 0:
                result = "push"
            elif won:
                result = "win"
            else:
                result = "loss"

        elif bet.bet_type == "total":
            if "Over" in bet.selection:
                line = float(bet.selection.split("Over ")[1])
                if total_score > line:
                    result = "win"
                elif total_score == line:
                    result = "push"
                else:
                    result = "loss"
            else:  # Under
                line = float(bet.selection.split("Under ")[1])
                if total_score < line:
                    result = "win"
                elif total_score == line:
                    result = "push"
                else:
                    result = "loss"
        else:
            result = "loss"  # Unknown bet type

        # Calculate profit
        if result == "win":
            if odds > 0:
                profit = stake * (odds / 100.0)
            else:
                profit = stake * (100.0 / abs(odds))
        elif result == "push":
            profit = 0.0
        else:
            profit = -stake

        return result, round(profit, 2)

    def _get_closing_line(self, bet: BetSlip) -> float:
        """Get the consensus closing line for CLV calculation."""
        # Average the relevant line across all three books
        # This serves as a proxy for the closing line
        lines = []
        for book_name, book_df in self.books.items():
            game_row = book_df[book_df["game_id"] == bet.game_id]
            if game_row.empty:
                continue

            if bet.bet_type == "spread":
                lines.append(game_row.iloc[0]["spread"])
            elif bet.bet_type == "total":
                lines.append(game_row.iloc[0]["total"])
            elif bet.bet_type == "moneyline":
                if "home" in bet.selection.lower() or bet.selection.split(" ML")[0] == game_row.iloc[0].get("home_team", ""):
                    lines.append(game_row.iloc[0]["home_ml"])
                else:
                    lines.append(game_row.iloc[0]["away_ml"])

        return np.mean(lines) if lines else 0

    def process_week(self, week: int,
                      bet_slip_path: str) -> WeeklyReport:
        """
        Process a complete week: validate, grade, update state.
        """
        # Load bet slip
        bets = self._load_bet_slip(bet_slip_path)
        logger.info(f"Week {week}: Loaded {len(bets)} bets")

        # Validate
        valid_bets, errors = self.validate_bet_slip(bets, week)
        if errors:
            logger.warning(f"Validation errors:\n" +
                            "\n".join(f"  - {e}" for e in errors))

        logger.info(f"Week {week}: {len(valid_bets)} valid bets "
                     f"({len(errors)} errors)")

        # Grade
        graded = self.grade_bets(valid_bets, week)

        # Update state
        bankroll_start = self.state.bankroll
        week_staked = 0.0
        week_profit = 0.0
        wins = losses = pushes = 0

        for g in graded:
            self.state.bankroll += g.profit
            self.state.total_bets += 1
            self.state.total_profit += g.profit
            self.state.all_bets.append(g)
            week_staked += g.stake
            week_profit += g.profit

            if g.result == "win":
                wins += 1
            elif g.result == "loss":
                losses += 1
            else:
                pushes += 1

            # Track sports and markets
            self.state.sports_bet_on[g.sport] = (
                self.state.sports_bet_on.get(g.sport, 0) + 1
            )
            self.state.markets_bet_on[g.bet_type] = (
                self.state.markets_bet_on.get(g.bet_type, 0) + 1
            )

        # Update peak and drawdown
        if self.state.bankroll > self.state.peak_bankroll:
            self.state.peak_bankroll = self.state.bankroll
        current_drawdown = (
            (self.state.peak_bankroll - self.state.bankroll) /
            self.state.peak_bankroll * 100
        )
        if current_drawdown > self.state.max_drawdown:
            self.state.max_drawdown = current_drawdown

        # Build weekly report
        best_bet = max(graded, key=lambda g: g.profit).selection if graded else "N/A"
        worst_bet = min(graded, key=lambda g: g.profit).selection if graded else "N/A"

        report = WeeklyReport(
            week=week,
            bets_placed=len(graded),
            bets_won=wins,
            bets_lost=losses,
            bets_pushed=pushes,
            total_staked=week_staked,
            total_profit=week_profit,
            weekly_roi=(week_profit / week_staked * 100) if week_staked > 0 else 0,
            bankroll_start=bankroll_start,
            bankroll_end=self.state.bankroll,
            best_bet=best_bet,
            worst_bet=worst_bet,
        )

        self.state.weekly_reports.append(report)
        self._print_weekly_summary(report)

        return report

    def _load_bet_slip(self, path: str) -> List[BetSlip]:
        """Load a bet slip from CSV."""
        bets = []
        df = pd.read_csv(path)
        for _, row in df.iterrows():
            bets.append(BetSlip(
                game_id=str(row["game_id"]),
                bet_type=str(row["bet_type"]),
                selection=str(row["selection"]),
                sportsbook=str(row["sportsbook"]),
                odds=int(row["odds"]),
                stake=float(row["stake"]),
                model_probability=float(row["model_probability"]),
                reasoning=str(row["reasoning"]),
            ))
        return bets

    def _print_weekly_summary(self, report: WeeklyReport):
        """Print a formatted weekly summary."""
        print(f"\n{'=' * 60}")
        print(f"  WEEK {report.week} SUMMARY")
        print(f"{'=' * 60}")
        print(f"  Bets: {report.bets_placed} "
              f"({report.bets_won}W-{report.bets_lost}L-"
              f"{report.bets_pushed}P)")
        print(f"  Staked: ${report.total_staked:,.2f}")
        print(f"  Profit: ${report.total_profit:,.2f} "
              f"(ROI: {report.weekly_roi:+.1f}%)")
        print(f"  Bankroll: ${report.bankroll_start:,.2f} -> "
              f"${report.bankroll_end:,.2f}")
        print(f"  Best bet: {report.best_bet}")
        print(f"  Worst bet: {report.worst_bet}")
        print(f"{'=' * 60}\n")

    def generate_season_summary(self) -> Dict:
        """
        Generate the final season summary with all performance metrics.

        Metrics computed (from Chapters 3, 12, 14, 30, 37):
        - ROI: total profit / total staked
        - Sharpe ratio: risk-adjusted return measure (Chapter 14)
        - Max drawdown: worst peak-to-trough decline (Chapter 14)
        - CLV: average closing line value (Chapter 12)
        - Brier score: calibration of model probabilities (Chapter 30)
        - Win rate by sport, by market, by confidence level
        """
        bets_df = pd.DataFrame([asdict(b) for b in self.state.all_bets])

        if bets_df.empty:
            return {"error": "No bets placed"}

        total_staked = bets_df["stake"].sum()
        total_profit = bets_df["profit"].sum()
        roi = total_profit / total_staked * 100 if total_staked > 0 else 0

        # Sharpe ratio (Chapter 14, Section 14.2)
        bet_returns = bets_df["profit"] / bets_df["stake"]
        sharpe = (bet_returns.mean() / bet_returns.std() *
                  np.sqrt(len(bets_df))) if bet_returns.std() > 0 else 0

        # CLV (Chapter 12) -- requires closing line data
        # This would be computed from the closing_line field

        # Brier score (Chapter 30)
        bets_df["actual_win"] = (bets_df["result"] == "win").astype(float)
        non_push = bets_df[bets_df["result"] != "push"]
        if len(non_push) > 0:
            brier = np.mean(
                (non_push["model_probability"] - non_push["actual_win"]) ** 2
            )
        else:
            brier = None

        # Diversity checks
        sports_count = len(self.state.sports_bet_on)
        market_count = len(self.state.markets_bet_on)

        primary_sport = max(self.state.sports_bet_on,
                             key=self.state.sports_bet_on.get)
        secondary_pct = 1.0 - (
            self.state.sports_bet_on[primary_sport] /
            self.state.total_bets
        )

        # Monthly breakdown
        monthly_profits = []
        for report in self.state.weekly_reports:
            monthly_profits.append(report.total_profit)

        return {
            "total_bets": self.state.total_bets,
            "total_staked": round(total_staked, 2),
            "total_profit": round(total_profit, 2),
            "roi_pct": round(roi, 2),
            "final_bankroll": round(self.state.bankroll, 2),
            "peak_bankroll": round(self.state.peak_bankroll, 2),
            "max_drawdown_pct": round(self.state.max_drawdown, 2),
            "sharpe_ratio": round(sharpe, 3),
            "brier_score": round(brier, 4) if brier is not None else None,
            "win_rate": round(
                (bets_df["result"] == "win").sum() /
                (bets_df["result"] != "push").sum() * 100, 1
            ),
            "sports_bet_on": self.state.sports_bet_on,
            "markets_bet_on": self.state.markets_bet_on,
            "sports_count": sports_count,
            "market_count": market_count,
            "secondary_sport_pct": round(secondary_pct * 100, 1),
            "meets_min_bets": self.state.total_bets >= MIN_TOTAL_BETS,
            "meets_sport_diversity": sports_count >= MIN_SPORTS,
            "meets_market_diversity": market_count >= MIN_MARKET_TYPES,
        }

2.4 Weekly Bet Log Template

In addition to the CSV bet slip, you must maintain a weekly bet log journal (Chapter 37) that includes:

  1. Market observations: What did you notice about the lines this week? Any significant movements?
  2. Model output summary: What did your model say? Which games had the largest edges?
  3. Selection rationale: For each bet, why did you choose this bet and this stake size?
  4. Post-results reflection: After seeing results, what did you learn? Were any losses due to bad process vs. bad luck?
  5. Running metrics: Current bankroll, season-to-date ROI, win rate, and any concern flags.

Phase 3: Mid-Season Review and Adjustment

Duration: After Week 3--4 of the simulation (roughly mid-season)

Relevant chapters: Chapter 13 (Evaluating Your Edge), Chapter 30 (Model Evaluation), Chapter 37 (Performance Review), Chapter 41 (Performance Attribution)

At the midpoint of the simulation, you must submit a Mid-Season Review Document (3--5 pages).

3.1 Required Mid-Season Review Contents

Section A: Performance Summary (1 page) - Current bankroll and ROI - Win rate by sport and market type - Cumulative P&L chart - Comparison to pre-season expectations - Key performance metrics: CLV, Brier score, Sharpe ratio

Section B: Model Assessment (1 page) - Is your model performing as expected? - Calibration analysis: are your probability estimates accurate? (Chapter 30) - Which sports/markets have been profitable and which have not? - Feature importance or model diagnostics - Any evidence of model degradation or changing market conditions?

Section C: Strategy Adjustments (1 page) - Based on the data, what will you change for the second half? - Adjustments to staking (increase/decrease unit size, change Kelly fraction) - Adjustments to model (new features, recalibration, different thresholds) - Sports or markets you will add, drop, or reallocate capital toward - Justify every change with evidence, not feelings (Chapter 36)

Section D: Behavioral Self-Assessment (0.5--1 page) - Have you experienced tilt or emotional betting? When and how did you handle it? (Chapter 36) - Have you deviated from your pre-season strategy? If so, why? - What has been your psychological experience? Describe specific moments of doubt, overconfidence, or frustration. - Are your stop-loss and risk management rules being followed?


Phase 4: Final Results and Analysis

Duration: Week 7 (final simulation week)

Relevant chapters: Chapter 14 (Drawdown Analysis), Chapter 30 (Model Evaluation), Chapter 37 (Performance Analysis), Chapter 41 (Performance Attribution)

After the final simulation week, the season is complete. You now have the full record of every bet, every result, and your final bankroll.

4.1 Final Performance Metrics

Calculate and report the following metrics for your complete season:

Metric Definition Chapter What It Tells You
Total ROI Total profit / total staked Ch 3 Overall efficiency of your betting
Win Rate Wins / (wins + losses) Ch 3 Raw accuracy (context-dependent)
Yield per Bet Average profit per bet Ch 3 Average edge per wager
Sharpe Ratio Mean return / std dev of returns, annualized Ch 14 Risk-adjusted performance
Max Drawdown Largest peak-to-trough decline Ch 14 Worst case experienced
Max Drawdown Duration Longest period between equity highs Ch 14 Endurance of cold streaks
CLV (avg) Mean closing line value Ch 12 Are you consistently beating the market?
Brier Score Mean squared error of probability estimates Ch 30 Calibration of your model
Kelly Growth Rate Actual geometric growth vs. Kelly optimal Ch 14 Efficiency of your staking
Sport-Level ROI ROI broken down by sport Ch 37 Where your edge comes from
Market-Level ROI ROI broken down by market type Ch 37 Which markets you exploit best
Monthly ROI ROI broken down by simulation month Ch 37 Consistency over time

Phase 5: Write-Up

Duration: Week 8 (after simulation ends)

Relevant chapters: Chapter 37 (Continuous Improvement), Chapter 41 (Putting It Together)

5.1 Final Report Requirements (10--15 pages)

The final report is the most heavily weighted deliverable. It must demonstrate deep analytical thinking, honest self-assessment, and the ability to separate skill from luck.

Section 1: Executive Summary (1 page) - Final bankroll, ROI, total bets, win rate - One-paragraph summary of what happened - One-paragraph summary of what you learned

Section 2: Strategy Description (2 pages) - Detailed description of your betting approach - Models used and their theoretical basis - Staking methodology with justification - How your strategy evolved from pre-season to final form - Reference specific textbook concepts and chapters

Section 3: Performance Analysis (3--4 pages) - All metrics from the table above, with visualizations - Cumulative P&L chart with drawdown overlay - Calibration plot (predicted probability vs. actual win rate, 10 bins minimum) - Performance breakdown by sport, market type, month, and confidence level - CLV distribution plot - Comparison of actual results to a Monte Carlo simulation of expected results given your edge estimates (Chapter 24)

Section 4: Skill vs. Variance Analysis (2 pages) - Was your result (good or bad) primarily skill or variance? - Use the binomial test or bootstrap confidence interval (Chapter 8, Chapter 24) to assess whether your win rate is significantly different from breakeven (52.4% at -110) - Compare your actual ROI to the distribution of ROIs a random bettor would achieve - If profitable: what evidence supports that your edge is real and not just luck? - If unprofitable: what evidence suggests you had an edge that variance obscured, or alternatively, what evidence suggests your model was not working?

Section 5: Behavioral and Process Review (2 pages) - Honest assessment of your decision-making discipline - Specific examples of good process (even with bad outcomes) - Specific examples of bad process (even with good outcomes) - How did drawdowns affect your behavior? Did you deviate from strategy? - What cognitive biases (Chapter 36) did you observe in yourself? - What would you do differently in a second simulation?

Section 6: Lessons Learned (1--2 pages) - What are the three most important things you learned? - How has this simulation changed your understanding of sports betting? - What surprised you most? - What specific textbook concepts proved most valuable in practice? - What concepts seemed important in theory but were hard to apply in practice?


Scoring Criteria

Grading Rubric

Component Weight Description
Process Quality 40% Rigor, consistency, and discipline throughout the simulation
Model Quality 20% Statistical validity, calibration, and innovation of predictive approach
Results Analysis 25% Depth and honesty of self-evaluation, skill vs. variance analysis
Presentation 15% Clarity of writing, quality of visualizations, professionalism

Detailed Scoring Breakdown

Process Quality (40%)

Sub-Component Points Criteria
Pre-season strategy document 5 Complete, specific, and grounded in textbook methods
Bet reasoning quality 10 Every bet has documented reasoning; reasoning is analytical, not emotional
Staking discipline 8 Consistent staking methodology; rules followed even during drawdowns
Weekly bet log quality 7 Thorough, reflective, includes market observations
Mid-season review depth 5 Evidence-based adjustments with clear rationale
Rule compliance 5 All simulation rules followed; diversity requirements met

Model Quality (20%)

Sub-Component Points Criteria
Model methodology 8 Sound statistical approach with clear theoretical basis
Calibration 5 Probability estimates are well-calibrated (Brier score, calibration plot)
Feature engineering 4 Thoughtful feature selection grounded in domain knowledge
Innovation 3 Creative approaches, novel combinations, or unique insights

Results Analysis (25%)

Sub-Component Points Criteria
Performance metrics 5 All required metrics computed correctly
Visualizations 5 Clear, informative charts that support the narrative
Skill vs. variance analysis 8 Rigorous statistical assessment of whether results reflect edge
Behavioral self-assessment 7 Honest, specific, and reflective analysis of decision-making

Presentation (15%)

Sub-Component Points Criteria
Writing quality 5 Clear, professional, well-organized
Report completeness 5 All required sections included with sufficient depth
Visual presentation 5 Charts are labeled, well-formatted, and support the analysis

Important Grading Notes

  1. A profitable simulation does NOT guarantee a high grade. A student who profits through undocumented, undisciplined betting will score lower than a student who loses money through a rigorous, well-documented process.

  2. An unprofitable simulation does NOT guarantee a low grade. Variance is real. A well-executed strategy can lose money over one season due to sampling variability. What matters is whether the process was sound and whether the analysis honestly evaluates the result.

  3. Honesty is rewarded. Self-awareness about mistakes, biases, and limitations demonstrates mastery of Chapter 36 and Chapter 37 more effectively than claiming perfection.

  4. The skill vs. variance analysis is critical. This section separates students who truly understand the statistical nature of betting from those who merely participate. A 2,000-word analysis using bootstrap confidence intervals, Monte Carlo simulations, and CLV analysis will score much higher than a 200-word assertion that "I was just unlucky" or "my model works."


Suggested Timeline

Week Phase Key Activities Deliverable
1 Phase 1: Pre-Season Build/calibrate models; develop strategy; write strategy doc Pre-Season Strategy Document
2 Phase 2: Month 1 Weeks 1--4 of simulation; submit weekly bet slips and logs 4 weekly bet slips + bet logs
3 Phase 2: Month 2 Weeks 5--8 of simulation 4 weekly bet slips + bet logs
4 Phase 3: Mid-Season Mid-season review; strategy adjustments Mid-Season Review Document
5 Phase 2: Month 3 Weeks 9--12 of simulation 4 weekly bet slips + bet logs
6 Phase 2: Month 4 Weeks 13--16 of simulation 4 weekly bet slips + bet logs
7 Phase 4: Final weeks Weeks 17--18 of simulation; final grading Final 2 weekly bet slips + logs
8 Phase 5: Write-Up Compile analysis; produce final report Final Report (10--15 pages)

Chapter Reference Index

  • Chapter 2 (Probability and Odds): Interpreting odds across the three simulated sportsbooks
  • Chapter 3 (Expected Value): EV calculation as the basis for every bet decision
  • Chapter 4 (Bankroll Management): Foundational staking strategy and survival math
  • Chapter 5 (Data Literacy): Working with the provided data files, maintaining records
  • Chapter 8 (Hypothesis Testing): Testing whether your win rate is statistically significant
  • Chapter 9 (Regression Analysis): Building predictive models for game outcomes
  • Chapter 11 (Betting Markets): Understanding what the odds are telling you
  • Chapter 12 (Line Shopping): Exploiting differences between the three sportsbooks; CLV tracking
  • Chapter 13 (Value Betting): Identifying +EV opportunities systematically
  • Chapter 14 (Advanced Bankroll): Kelly sizing, drawdown management, Sharpe ratio
  • Chapter 15--22 (Sport-Specific Modeling): Building models for each sport you bet on
  • Chapter 24 (Monte Carlo Simulation): Simulating expected results given your estimated edge
  • Chapter 26 (Ratings and Rankings): Elo or similar rating systems as model inputs
  • Chapter 27 (Advanced Regression): Machine learning models, probability calibration
  • Chapter 30 (Model Evaluation): Brier score, calibration plots, walk-forward validation of your model
  • Chapter 36 (Psychology): Managing emotions, identifying biases, maintaining discipline
  • Chapter 37 (Discipline and Systems): Bet logging, performance tracking, review processes
  • Chapter 38 (Risk Management): Stop-loss rules, responsible bankroll management
  • Chapter 41 (Putting It Together): Integrating all components into a coherent operation

Tips for Success

  1. Start with a clear plan. The pre-season strategy document is not busywork. Students who write a thoughtful strategy document and follow it consistently outperform students who "wing it." The discipline of committing to a plan in writing creates accountability.

  2. Your bet reasoning is the most important thing you write. A one-sentence reasoning like "model likes this" will score poorly. A reasoning like "Elo model gives KC a 0.58 win probability vs. market implied 0.53 (after vig removal). 5% edge exceeds my 3% threshold. Taking KC at book_B which has the best line at -145 vs. -155 at book_A" demonstrates mastery.

  3. Embrace the losing streaks. You will have losing weeks. Probably multiple in a row. This is mathematically certain even for a skilled bettor. What matters is how you respond. Do you increase bet sizes to chase losses (bad)? Do you abandon your model after a 3-game losing streak (bad)? Or do you maintain discipline, log your bets carefully, and trust the process (good)?

  4. Track CLV religiously. Closing Line Value (Chapter 12) is your best real-time indicator of whether your model has edge. If you are consistently getting better prices than the closing line -- even during a losing streak -- you should maintain confidence in your process.

  5. The skill vs. variance section separates good students from great ones. Use the Monte Carlo simulation technique from Chapter 24 to generate 10,000 simulated seasons with your estimated edge, and see where your actual result falls in that distribution. Use the binomial test from Chapter 8 to test statistical significance. This is where the textbook knowledge converts into genuine understanding.

  6. Be honest about what you do not know. The strongest final reports acknowledge uncertainty. If your model was wrong about something, say so. If you cannot determine whether a loss was bad luck or a bad model, say that too. Intellectual honesty is a hallmark of sophisticated analytical thinking.


This capstone project integrates material from Chapters 2--5, 8--9, 11--14, 15--22, 24, 26--27, 30, 36--38, and 41 of The Sports Betting Textbook.