Case Study 2: Fantasy Football Projection System

Overview

This case study builds a complete fantasy football projection system for college players, combining statistical projections with fantasy point calculations and roster optimization.

Business Context

A fantasy sports platform needs: - Pre-season player projections - Weekly projection updates - Confidence-based rankings - Auction value calculations

System Design

Fantasy Scoring Settings

SCORING = {
    'standard': {
        'pass_yard': 0.04,
        'pass_td': 4,
        'interception': -2,
        'rush_yard': 0.1,
        'rush_td': 6,
        'reception': 0,
        'rec_yard': 0.1,
        'rec_td': 6
    },
    'ppr': {
        'pass_yard': 0.04,
        'pass_td': 4,
        'interception': -2,
        'rush_yard': 0.1,
        'rush_td': 6,
        'reception': 1.0,  # Point per reception
        'rec_yard': 0.1,
        'rec_td': 6
    }
}

Complete Fantasy Projector

class FantasyProjectionSystem:
    """
    Complete fantasy football projection system.

    Features:
    - Position-specific projections
    - Fantasy point calculations
    - Confidence intervals
    - Position rankings
    - Auction values
    """

    def __init__(self, scoring: str = 'ppr'):
        self.scoring = SCORING[scoring]
        self.qb_projector = QBProjector()
        self.rb_projector = RBProjector()
        self.wr_projector = WRProjector()
        self.te_projector = TEProjector()

    def project_player(self, player_data: Dict) -> FantasyProjection:
        """Generate fantasy projection for any player."""

        position = player_data['position']

        # Get raw stat projections
        if position == 'QB':
            stats = self.qb_projector.project(player_data)
        elif position == 'RB':
            stats = self.rb_projector.project(player_data)
        elif position == 'WR':
            stats = self.wr_projector.project(player_data)
        elif position == 'TE':
            stats = self.te_projector.project(player_data)
        else:
            raise ValueError(f"Unknown position: {position}")

        # Calculate fantasy points
        fantasy_points = self._calculate_fantasy_points(stats)

        # Calculate floor/ceiling (10th/90th percentile)
        variance = self._estimate_variance(stats, player_data)
        floor = fantasy_points * (1 - variance)
        ceiling = fantasy_points * (1 + variance)

        return FantasyProjection(
            player_id=player_data['id'],
            name=player_data['name'],
            position=position,
            team=player_data['team'],
            projected_points=fantasy_points,
            floor=floor,
            ceiling=ceiling,
            stats=stats
        )

    def _calculate_fantasy_points(self, stats: Dict) -> float:
        """Convert stats to fantasy points."""
        points = 0

        # Passing
        points += stats.get('pass_yards', 0) * self.scoring['pass_yard']
        points += stats.get('pass_td', 0) * self.scoring['pass_td']
        points += stats.get('interceptions', 0) * self.scoring['interception']

        # Rushing
        points += stats.get('rush_yards', 0) * self.scoring['rush_yard']
        points += stats.get('rush_td', 0) * self.scoring['rush_td']

        # Receiving
        points += stats.get('receptions', 0) * self.scoring['reception']
        points += stats.get('rec_yards', 0) * self.scoring['rec_yard']
        points += stats.get('rec_td', 0) * self.scoring['rec_td']

        return points

    def generate_rankings(self, players: List[Dict]) -> pd.DataFrame:
        """Generate fantasy rankings."""
        projections = []

        for player in players:
            proj = self.project_player(player)
            projections.append({
                'name': proj.name,
                'position': proj.position,
                'team': proj.team,
                'projected_points': proj.projected_points,
                'floor': proj.floor,
                'ceiling': proj.ceiling,
                'consistency': 1 - (proj.ceiling - proj.floor) / proj.projected_points
            })

        df = pd.DataFrame(projections)

        # Add overall rank
        df = df.sort_values('projected_points', ascending=False)
        df['overall_rank'] = range(1, len(df) + 1)

        # Add position rank
        df['position_rank'] = df.groupby('position').cumcount() + 1

        return df

Results

Sample Rankings Output

FANTASY FOOTBALL RANKINGS (PPR)
============================================

OVERALL TOP 10:
Rank | Player          | Pos | Team  | Proj  | Floor | Ceiling
-----|-----------------|-----|-------|-------|-------|--------
1    | Travis Etienne  | RB  | UGA   | 285.2 | 245.0 | 340.0
2    | Jaxon Smith     | WR  | OSU   | 268.4 | 230.0 | 315.0
3    | Arch Manning    | QB  | TEX   | 262.1 | 220.0 | 310.0
4    | Quinshon Judge  | RB  | ND    | 255.6 | 215.0 | 300.0
5    | Tetairoa McM.   | WR  | ARIZ  | 248.2 | 210.0 | 290.0

POSITION BREAKDOWN:
  QB1-5 avg: 238.4 pts
  RB1-10 avg: 212.3 pts
  WR1-10 avg: 195.8 pts
  TE1-5 avg: 142.1 pts

PROJECTION ACCURACY (2023 Season):
  Overall correlation: 0.74
  QB correlation: 0.71
  RB correlation: 0.68
  WR correlation: 0.72
  TE correlation: 0.65

CALIBRATION:
  Floor breakers: 8% (target: 10%)
  Ceiling breakers: 11% (target: 10%)
  Well calibrated overall

Auction Value Calculator

def calculate_auction_values(rankings: pd.DataFrame,
                            budget: int = 200,
                            roster_spots: int = 15,
                            league_size: int = 12) -> pd.DataFrame:
    """
    Calculate auction values using VBD (Value Based Drafting).

    Parameters:
    -----------
    rankings : pd.DataFrame
        Player rankings with projections
    budget : int
        Auction budget per team
    roster_spots : int
        Roster size
    league_size : int
        Number of teams

    Returns:
    --------
    pd.DataFrame : Players with auction values
    """
    # Define replacement level by position
    replacement = {
        'QB': league_size,      # QB12
        'RB': league_size * 2,  # RB24
        'WR': league_size * 2,  # WR24
        'TE': league_size       # TE12
    }

    df = rankings.copy()

    # Calculate replacement level points
    for pos, repl_rank in replacement.items():
        pos_players = df[df['position'] == pos].sort_values(
            'projected_points', ascending=False
        )
        if len(pos_players) >= repl_rank:
            repl_value = pos_players.iloc[repl_rank - 1]['projected_points']
        else:
            repl_value = pos_players['projected_points'].min()

        df.loc[df['position'] == pos, 'replacement_value'] = repl_value

    # Calculate value over replacement
    df['vor'] = df['projected_points'] - df['replacement_value']
    df['vor'] = df['vor'].clip(lower=0)

    # Convert to auction dollars
    total_vor = df[df['vor'] > 0]['vor'].sum()
    total_budget = budget * league_size * 0.85  # Reserve 15% for $1 players

    df['auction_value'] = (df['vor'] / total_vor) * total_budget
    df['auction_value'] = df['auction_value'].round(0).astype(int)
    df.loc[df['auction_value'] < 1, 'auction_value'] = 1

    return df[['name', 'position', 'projected_points', 'vor', 'auction_value']]

Key Features

1. Variance-Adjusted Rankings

Players with higher floors may be preferred despite lower ceilings: - High floor: Consistent starter - High ceiling: Boom/bust potential - Consistency score: Helps risk-averse managers

2. Weekly Update System

def update_weekly_projection(player_data: Dict,
                            weekly_results: List[Dict]) -> Dict:
    """
    Update projection based on weekly performance.

    Uses Bayesian updating:
    - Prior: Pre-season projection
    - Likelihood: Weekly observed performance
    - Posterior: Updated projection
    """
    prior = player_data['preseason_projection']
    observed = calculate_weekly_avg(weekly_results)
    n_games = len(weekly_results)

    # Weight observed data more as season progresses
    weight = n_games / (n_games + 6)  # ~50% weight at week 6

    updated = weight * observed + (1 - weight) * prior

    return updated

Lessons Learned

  1. PPR vs Standard matters - Receiver values differ significantly
  2. Floor matters for consistency - Not just ceiling chasers
  3. Weekly updates valuable - Season-long projections drift
  4. Position scarcity - TE and QB values depend on format
  5. Calibration critical - Users trust well-calibrated intervals