7 min read

Fantasy football represents one of the largest applications of NFL analytics, with over 60 million Americans participating annually. While the casual player relies on expert rankings and gut instinct, analytical approaches provide significant...

Chapter 27: Fantasy Football Analytics

Introduction

Fantasy football represents one of the largest applications of NFL analytics, with over 60 million Americans participating annually. While the casual player relies on expert rankings and gut instinct, analytical approaches provide significant competitive advantages. This chapter explores the statistical frameworks that transform raw NFL data into actionable fantasy insights.

Fantasy football combines prediction, optimization, and game theory. You must project player performance, understand positional value, manage variance, and anticipate competitor behavior. Whether competing in season-long leagues or daily fantasy sports (DFS), analytical thinking separates consistent winners from the masses.


Section 1: Understanding Fantasy Scoring Systems

1.1 Standard Scoring

The foundation of fantasy football is the scoring system that converts on-field performance to fantasy points.

Standard Scoring (Most Common):

Category Points
Passing TD 4
Passing Yard 0.04 (25 yards = 1 point)
Interception -2
Rushing/Receiving TD 6
Rushing/Receiving Yard 0.1 (10 yards = 1 point)
Reception 0 (standard) or 1 (PPR)
Fumble Lost -2
2-Point Conversion 2

PPR (Point Per Reception) Scoring:

PPR adds 1 point per reception, dramatically changing player values:

def calculate_fantasy_points(stats: dict, scoring: str = 'standard') -> float:
    """
    Calculate fantasy points from player statistics.

    Args:
        stats: Dictionary with player statistics
        scoring: 'standard', 'ppr', or 'half_ppr'

    Returns:
        Total fantasy points
    """
    points = 0.0

    # Passing
    points += stats.get('pass_yards', 0) * 0.04
    points += stats.get('pass_td', 0) * 4
    points += stats.get('interceptions', 0) * -2

    # Rushing
    points += stats.get('rush_yards', 0) * 0.1
    points += stats.get('rush_td', 0) * 6

    # Receiving
    points += stats.get('rec_yards', 0) * 0.1
    points += stats.get('rec_td', 0) * 6

    # Receptions (scoring dependent)
    if scoring == 'ppr':
        points += stats.get('receptions', 0) * 1.0
    elif scoring == 'half_ppr':
        points += stats.get('receptions', 0) * 0.5

    # Turnovers
    points += stats.get('fumbles_lost', 0) * -2

    return points

1.2 Scoring System Impact

Different scoring systems create different optimal strategies:

Position Standard Rank PPR Rank Change
Elite RB 1-3 2-5
Volume WR 15-20 8-12 ↑↑
Pass-catching RB 10-15 3-8 ↑↑
Touchdown-dependent WR 10-15 18-25 ↓↓

Key Insight: Understanding your league's scoring system is the first analytical decision. PPR heavily favors volume receivers and pass-catching running backs, while standard scoring rewards touchdown production and efficiency.


Section 2: Value Over Replacement Player (VORP)

2.1 The Replacement Level Concept

Raw fantasy points are misleading because they ignore positional scarcity. VORP measures a player's value compared to the best freely available alternative.

Replacement Level Definition:

The replacement level player is the best player typically available on waivers—roughly the player ranked just outside starter-quality at each position.

For a 12-team league with typical starting requirements:

Position Starters Total Started Replacement Level
QB 1 12 QB13
RB 2 24 RB25
WR 2 24 WR25
TE 1 12 TE13
FLEX 1 12 (varies) RB37/WR37

2.2 VORP Calculation

class VORPCalculator:
    """Calculate Value Over Replacement Player."""

    def __init__(self, league_size: int = 12, roster_spots: dict = None):
        """
        Initialize VORP calculator.

        Args:
            league_size: Number of teams
            roster_spots: Dict of position -> starter count
        """
        self.league_size = league_size
        self.roster_spots = roster_spots or {
            'QB': 1, 'RB': 2, 'WR': 2, 'TE': 1, 'FLEX': 1
        }

        # Calculate replacement levels
        self.replacement_ranks = self._calculate_replacement_ranks()

    def _calculate_replacement_ranks(self) -> dict:
        """Determine replacement player rank for each position."""
        replacement = {}

        for pos, spots in self.roster_spots.items():
            if pos == 'FLEX':
                continue  # FLEX affects RB/WR replacement
            replacement[pos] = self.league_size * spots + 1

        # Adjust for FLEX (typically RB/WR)
        flex_spots = self.roster_spots.get('FLEX', 0)
        # FLEX effectively adds to RB/WR pool
        replacement['RB'] += flex_spots * self.league_size // 2
        replacement['WR'] += flex_spots * self.league_size // 2

        return replacement

    def calculate_vorp(self, player_points: float, position: str,
                      position_baseline: dict) -> float:
        """
        Calculate VORP for a player.

        Args:
            player_points: Projected season fantasy points
            position: Player position
            position_baseline: Dict of position -> replacement level points

        Returns:
            VORP score
        """
        replacement_points = position_baseline.get(position, 0)
        return player_points - replacement_points

2.3 VORP-Based Rankings

Traditional rankings sort by raw points. VORP rankings reveal true value:

Player Position Points VORP Raw Rank VORP Rank
Josh Allen QB 380 120 3 8
Christian McCaffrey RB 340 180 5 1
Tyreek Hill WR 310 150 7 3
Travis Kelce TE 260 140 12 4
Generic QB12 QB 260 0 20 35

Key Insight: A 380-point QB provides less draft value than a 340-point RB because replacement QBs score much closer to elite QBs than replacement RBs score to elite RBs.


Section 3: Positional Scarcity Analysis

3.1 The Scarcity Principle

Positional scarcity measures the dropoff in points between tiers of players. Positions with steep dropoffs (high scarcity) are more valuable early in drafts.

Measuring Scarcity:

def calculate_scarcity(projections: List[float], positions: int = 24) -> dict:
    """
    Calculate scarcity metrics for a position.

    Args:
        projections: List of projected points, sorted descending
        positions: Number of "startable" players

    Returns:
        Dict with scarcity metrics
    """
    sorted_proj = sorted(projections, reverse=True)

    # Tier 1 to Tier 2 dropoff
    tier1_avg = np.mean(sorted_proj[:6])
    tier2_avg = np.mean(sorted_proj[6:12])
    tier1_dropoff = tier1_avg - tier2_avg

    # Starter to replacement dropoff
    starter_avg = np.mean(sorted_proj[:positions])
    replacement = sorted_proj[positions] if len(sorted_proj) > positions else 0
    replacement_dropoff = starter_avg - replacement

    # Coefficient of variation (spread)
    cv = np.std(sorted_proj[:positions]) / np.mean(sorted_proj[:positions])

    return {
        'tier1_dropoff': tier1_dropoff,
        'replacement_dropoff': replacement_dropoff,
        'cv': cv,
        'scarcity_score': tier1_dropoff * cv  # Combined metric
    }

3.2 Historical Scarcity Patterns

Typical scarcity rankings (highest to lowest):

  1. Tight End - Massive dropoff after top 3-5
  2. Running Back - Significant dropoff, especially for elite workloads
  3. Wide Receiver - Moderate dropoff, deep position
  4. Quarterback - Minimal scarcity, streaming viable

2023 Example Scarcity:

Position Top-5 Avg 13-17 Avg Dropoff Scarcity Rank
TE 240 120 120 pts 1 (highest)
RB 280 180 100 pts 2
WR 270 200 70 pts 3
QB 360 300 60 pts 4 (lowest)

3.3 Draft Strategy Implications

Scarcity analysis informs the "Zero RB" vs "Robust RB" debate:

Zero RB Philosophy: - Targets elite WRs/TEs early - Banks on RB variance and waiver pickups - Works when: WR scarcity high, RB injury rates high

Robust RB Philosophy: - Locks up elite RB workloads early - Accepts WR depth as adequate - Works when: Elite RBs healthy, WR production distributed

Analytical Approach:

def recommend_draft_strategy(scarcity_data: dict,
                            injury_risk: dict,
                            league_settings: dict) -> str:
    """
    Recommend draft strategy based on current landscape.

    Args:
        scarcity_data: Position scarcity metrics
        injury_risk: Position injury risk data
        league_settings: League scoring and roster rules

    Returns:
        Strategy recommendation
    """
    rb_scarcity = scarcity_data['RB']['scarcity_score']
    wr_scarcity = scarcity_data['WR']['scarcity_score']
    te_scarcity = scarcity_data['TE']['scarcity_score']

    rb_injury_risk = injury_risk.get('RB', 0.15)

    ppr_factor = 1.0 if league_settings.get('ppr', False) else 0.0

    # Decision logic
    if te_scarcity > rb_scarcity * 1.5:
        return "Early TE strategy - lock up Kelce-tier player"
    elif rb_scarcity > wr_scarcity * 1.3 and rb_injury_risk < 0.12:
        return "Robust RB - target elite workloads rounds 1-3"
    elif ppr_factor > 0 and wr_scarcity > rb_scarcity:
        return "Zero RB - PPR favors WR volume, RBs replaceable"
    else:
        return "Balanced approach - best player available with positional awareness"

Section 4: Player Projection Methodology

4.1 Projection Components

Accurate projections combine multiple factors:

  1. Historical Performance - Weighted recent seasons
  2. Opportunity Metrics - Targets, carries, snap share
  3. Efficiency Metrics - Yards per attempt, TD rate
  4. Contextual Factors - Team offense, schedule, coaching
class PlayerProjector:
    """Project player fantasy points."""

    def __init__(self, weights: dict = None):
        """
        Initialize projector with season weights.

        Args:
            weights: Dict of season -> weight (most recent highest)
        """
        self.weights = weights or {
            'current_minus_1': 0.50,  # Last season
            'current_minus_2': 0.30,  # Two seasons ago
            'current_minus_3': 0.20,  # Three seasons ago
        }

    def project_volume(self, historical_data: dict,
                      opportunity_changes: dict) -> dict:
        """
        Project touches/targets based on historical data and changes.

        Args:
            historical_data: Past season opportunity metrics
            opportunity_changes: Expected changes (new team, etc.)

        Returns:
            Projected opportunity metrics
        """
        # Weighted average of historical
        weighted_targets = 0
        weighted_carries = 0
        total_weight = 0

        for season, weight in self.weights.items():
            if season in historical_data:
                weighted_targets += historical_data[season].get('targets', 0) * weight
                weighted_carries += historical_data[season].get('carries', 0) * weight
                total_weight += weight

        base_targets = weighted_targets / total_weight if total_weight > 0 else 0
        base_carries = weighted_carries / total_weight if total_weight > 0 else 0

        # Apply opportunity changes
        target_change = opportunity_changes.get('target_share_change', 0)
        carry_change = opportunity_changes.get('carry_share_change', 0)

        return {
            'projected_targets': base_targets * (1 + target_change),
            'projected_carries': base_carries * (1 + carry_change)
        }

    def project_efficiency(self, historical_data: dict,
                          age: int, position: str) -> dict:
        """
        Project per-touch efficiency with aging curve.

        Args:
            historical_data: Past efficiency metrics
            age: Player age for coming season
            position: Player position

        Returns:
            Projected efficiency metrics
        """
        # Base efficiency from recent data
        recent = historical_data.get('current_minus_1', {})
        yards_per_target = recent.get('yards_per_target', 8.0)
        yards_per_carry = recent.get('yards_per_carry', 4.2)
        td_rate = recent.get('td_rate', 0.04)

        # Apply aging curve
        age_factor = self._get_age_factor(age, position)

        return {
            'yards_per_target': yards_per_target * age_factor,
            'yards_per_carry': yards_per_carry * age_factor,
            'td_rate': td_rate * age_factor
        }

    def _get_age_factor(self, age: int, position: str) -> float:
        """Get aging factor for position."""
        # Peak ages vary by position
        peak_ages = {'RB': 25, 'WR': 27, 'TE': 28, 'QB': 30}
        peak = peak_ages.get(position, 27)

        if age <= peak:
            return 1.0 + (peak - age) * 0.01  # Slight improvement
        else:
            # Decline rates by position
            decline_rates = {'RB': 0.05, 'WR': 0.03, 'TE': 0.02, 'QB': 0.02}
            rate = decline_rates.get(position, 0.03)
            return 1.0 - (age - peak) * rate

4.2 Uncertainty and Ranges

Point projections are incomplete without uncertainty estimates:

def project_with_confidence(base_projection: float,
                           historical_variance: float,
                           injury_risk: float) -> dict:
    """
    Create projection with confidence interval.

    Args:
        base_projection: Expected fantasy points
        historical_variance: Standard deviation of past performance
        injury_risk: Probability of significant injury

    Returns:
        Dict with projection and confidence range
    """
    # Adjust variance for injury risk
    adjusted_variance = historical_variance * (1 + injury_risk)

    # 80% confidence interval
    lower_80 = base_projection - 1.28 * adjusted_variance
    upper_80 = base_projection + 1.28 * adjusted_variance

    # Floor and ceiling scenarios
    floor = max(0, base_projection - 2 * adjusted_variance)
    ceiling = base_projection + 2 * adjusted_variance

    return {
        'projection': base_projection,
        'lower_80': lower_80,
        'upper_80': upper_80,
        'floor': floor,
        'ceiling': ceiling,
        'variance': adjusted_variance
    }

4.3 Regression to the Mean

Fantasy relevant stats regress at different rates:

Statistic Regression Rate Sample Size Needed
TD Rate 75% 400+ touches
INT Rate 60% 300+ attempts
YAC/Reception 50% 100+ receptions
Catch Rate 40% 80+ targets
Target Share 30% Full season
Volume (carries) 20% Full season

Application: A receiver with 15% TD rate (vs 4% league average) should be regressed:

Regressed TD Rate = 0.04 + (0.15 - 0.04) × (1 - 0.75) = 0.0675

This 6.75% TD rate is more predictive than the observed 15%.


Section 5: Variance and Risk Management

5.1 Understanding Fantasy Variance

High-variance players are boom-or-bust; low-variance players are consistent. Both have strategic value depending on context.

Measuring Variance:

def calculate_variance_metrics(weekly_scores: List[float]) -> dict:
    """
    Calculate variance metrics for a player.

    Args:
        weekly_scores: List of weekly fantasy scores

    Returns:
        Dict with variance metrics
    """
    scores = np.array(weekly_scores)

    mean_score = np.mean(scores)
    std_dev = np.std(scores)
    cv = std_dev / mean_score  # Coefficient of variation

    # Boom/bust metrics
    boom_threshold = mean_score * 1.5
    bust_threshold = mean_score * 0.5

    boom_rate = np.mean(scores >= boom_threshold)
    bust_rate = np.mean(scores <= bust_threshold)

    # Consistency score (inverse of CV, scaled)
    consistency = 1 / (1 + cv)

    return {
        'mean': mean_score,
        'std_dev': std_dev,
        'cv': cv,
        'boom_rate': boom_rate,
        'bust_rate': bust_rate,
        'consistency': consistency
    }

5.2 Variance by Position

Position Avg CV Boom Rate Bust Rate Archetype
QB 0.35 25% 15% Most consistent
RB1 0.45 30% 20% Volume smooths
RB2 0.65 25% 35% TD dependent
WR1 0.50 35% 20% Boom potential
WR3 0.75 20% 45% High variance
TE 0.70 20% 40% Outside Kelce

5.3 Strategic Variance Management

When to Target High Variance: - Underdog in weekly matchup - Need to differentiate in DFS tournaments - Streaming in bad matchups

When to Target Low Variance: - Favorite in weekly matchup - Building floor for playoffs - Cash games in DFS

def recommend_variance_strategy(matchup_projection: float,
                               opponent_projection: float,
                               game_type: str) -> str:
    """
    Recommend variance strategy based on context.

    Args:
        matchup_projection: Your projected score
        opponent_projection: Opponent projected score
        game_type: 'h2h', 'cash', or 'tournament'

    Returns:
        Strategy recommendation
    """
    expected_margin = matchup_projection - opponent_projection

    if game_type == 'tournament':
        return "High variance - need ceiling to win large field"

    if game_type == 'cash':
        return "Low variance - protect floor to cash"

    # Head-to-head
    if expected_margin > 15:
        return "Low variance - protect large lead, avoid busts"
    elif expected_margin < -15:
        return "High variance - need boom games to overcome deficit"
    else:
        return "Balanced - moderate variance with solid floor"

Section 6: Daily Fantasy Sports (DFS) Optimization

6.1 DFS Fundamentals

DFS requires selecting a lineup under salary constraints to maximize projected points.

Typical Constraints: - Salary cap: $50,000 - Roster: 1 QB, 2 RB, 3 WR, 1 TE, 1 FLEX, 1 DEF - Ownership caps (sometimes)

6.2 Linear Optimization

from scipy.optimize import linprog

class DFSOptimizer:
    """Optimize DFS lineups using linear programming."""

    def __init__(self, salary_cap: int = 50000):
        self.salary_cap = salary_cap
        self.positions = ['QB', 'RB', 'WR', 'TE', 'FLEX', 'DEF']

    def optimize_lineup(self, players: pd.DataFrame) -> dict:
        """
        Find optimal lineup given projections and salaries.

        Args:
            players: DataFrame with columns [name, position, salary, projection]

        Returns:
            Dict with optimal lineup and total points
        """
        n_players = len(players)

        # Objective: maximize projections (negative for minimization)
        c = -players['projection'].values

        # Constraints
        A_eq = []
        b_eq = []

        # Position constraints
        for pos in ['QB', 'RB', 'WR', 'TE', 'DEF']:
            constraint = (players['position'] == pos).astype(int).values
            if pos == 'QB' or pos == 'TE' or pos == 'DEF':
                A_eq.append(constraint)
                b_eq.append(1)
            elif pos == 'RB':
                A_eq.append(constraint)
                b_eq.append(2)  # Minimum 2 RBs
            elif pos == 'WR':
                A_eq.append(constraint)
                b_eq.append(3)  # Minimum 3 WRs

        # Salary constraint (inequality)
        A_ub = [players['salary'].values]
        b_ub = [self.salary_cap]

        # Total players constraint
        A_eq.append(np.ones(n_players))
        b_eq.append(9)  # 9 players total

        # Solve
        result = linprog(c, A_ub=A_ub, b_ub=b_ub,
                        A_eq=np.array(A_eq), b_eq=b_eq,
                        bounds=(0, 1), method='highs')

        if result.success:
            selected = result.x > 0.5
            lineup = players[selected]
            return {
                'lineup': lineup,
                'total_projection': lineup['projection'].sum(),
                'total_salary': lineup['salary'].sum()
            }
        return None

6.3 Ownership and Game Theory

In tournaments, unique lineups win. Ownership percentage matters:

Leverage Calculation:

def calculate_leverage(player_projection: float,
                      player_ownership: float,
                      field_size: int) -> float:
    """
    Calculate leverage score for tournament play.

    Low ownership + high projection = high leverage.

    Args:
        player_projection: Expected fantasy points
        player_ownership: Projected ownership percentage
        field_size: Number of entries in tournament

    Returns:
        Leverage score (higher = better tournament play)
    """
    # Value per ownership point
    ownership_decimal = player_ownership / 100

    if ownership_decimal < 0.01:
        ownership_decimal = 0.01  # Floor

    leverage = player_projection / (ownership_decimal * 100)

    return leverage

6.4 Correlation and Stacking

DFS tournaments benefit from correlated lineups:

Game Stacks: - QB + WR from same team - QB + WR + opposing WR (shootout stack) - RB + DEF opposing (blowout stack)

def build_correlation_matrix(players: pd.DataFrame,
                            games: dict) -> np.ndarray:
    """
    Build correlation matrix for lineup optimization.

    Args:
        players: Player DataFrame
        games: Dict mapping teams to opponent

    Returns:
        Correlation matrix
    """
    n = len(players)
    corr = np.eye(n)  # Start with identity

    for i, player_i in players.iterrows():
        for j, player_j in players.iterrows():
            if i >= j:
                continue

            # Same team correlation
            if player_i['team'] == player_j['team']:
                if player_i['position'] == 'QB':
                    if player_j['position'] in ['WR', 'TE']:
                        corr[i, j] = corr[j, i] = 0.3  # Positive correlation
                elif player_i['position'] == player_j['position']:
                    corr[i, j] = corr[j, i] = -0.2  # Compete for touches

            # Opposing team (game environment)
            elif games.get(player_i['team']) == player_j['team']:
                # Game script correlation
                corr[i, j] = corr[j, i] = 0.1

    return corr

Section 7: Season-Long Strategy

7.1 Draft Capital Allocation

How you allocate picks across positions determines season-long success:

Value-Based Drafting (VBD):

def value_based_draft(players: pd.DataFrame,
                     roster_needs: dict,
                     current_pick: int) -> str:
    """
    Recommend draft pick using VBD.

    Args:
        players: Available players with projections
        roster_needs: Remaining roster spots by position
        current_pick: Current draft position

    Returns:
        Recommended player name
    """
    # Calculate VORP for available players
    available = players[players['drafted'] == False].copy()

    for pos in roster_needs.keys():
        if roster_needs[pos] > 0:
            pos_players = available[available['position'] == pos]
            if len(pos_players) > 0:
                # Calculate VORP vs replacement
                replacement = pos_players.iloc[roster_needs[pos] * 3]['projection']
                available.loc[available['position'] == pos, 'vorp'] = \
                    available.loc[available['position'] == pos, 'projection'] - replacement

    # Pick highest VORP
    best_pick = available.loc[available['vorp'].idxmax()]
    return best_pick['name']

7.2 In-Season Management

Waiver Wire Strategy:

def evaluate_waiver_add(current_roster: List[dict],
                       waiver_player: dict,
                       weeks_remaining: int) -> dict:
    """
    Evaluate waiver wire addition.

    Args:
        current_roster: Current roster players
        waiver_player: Potential addition
        weeks_remaining: Weeks left in season

    Returns:
        Evaluation with recommendation
    """
    position = waiver_player['position']

    # Find worst player at position
    pos_players = [p for p in current_roster if p['position'] == position]
    if not pos_players:
        return {'action': 'add', 'reason': 'Fills roster need'}

    worst_current = min(pos_players, key=lambda x: x['ros_projection'])

    # Compare ROS projections
    improvement = waiver_player['ros_projection'] - worst_current['ros_projection']
    season_value = improvement * weeks_remaining

    if improvement > 0:
        return {
            'action': 'add',
            'drop': worst_current['name'],
            'weekly_improvement': improvement,
            'season_value': season_value,
            'reason': f"+{improvement:.1f} PPG over {worst_current['name']}"
        }
    else:
        return {
            'action': 'hold',
            'reason': f"No improvement over {worst_current['name']}"
        }

7.3 Trade Evaluation

def evaluate_trade(giving: List[dict],
                  receiving: List[dict],
                  roster: List[dict],
                  weeks_remaining: int) -> dict:
    """
    Evaluate trade offer.

    Args:
        giving: Players being traded away
        receiving: Players being received
        roster: Current full roster
        weeks_remaining: Weeks left

    Returns:
        Trade evaluation
    """
    # Calculate VORP impact
    giving_value = sum(p['ros_projection'] * weeks_remaining for p in giving)
    receiving_value = sum(p['ros_projection'] * weeks_remaining for p in receiving)

    raw_difference = receiving_value - giving_value

    # Roster fit adjustment
    # Does the received player fill a need?
    roster_positions = [p['position'] for p in roster]
    receiving_positions = [p['position'] for p in receiving]

    fit_bonus = 0
    for pos in receiving_positions:
        pos_depth = roster_positions.count(pos)
        if pos_depth < 2:  # Thin at position
            fit_bonus += 0.1 * receiving_value

    adjusted_value = raw_difference + fit_bonus

    return {
        'giving_value': giving_value,
        'receiving_value': receiving_value,
        'raw_difference': raw_difference,
        'fit_bonus': fit_bonus,
        'adjusted_value': adjusted_value,
        'recommendation': 'Accept' if adjusted_value > 0 else 'Decline'
    }

Section 8: Matchup Analysis

8.1 Defense vs Position

Matchup analysis projects player performance against specific defenses:

class MatchupAnalyzer:
    """Analyze player matchups against defenses."""

    def __init__(self, defense_stats: pd.DataFrame):
        """
        Initialize with defensive statistics.

        Args:
            defense_stats: DataFrame with defensive performance by position
        """
        self.defense_stats = defense_stats

    def get_matchup_adjustment(self, player: dict, opponent: str) -> float:
        """
        Calculate matchup adjustment factor.

        Args:
            player: Player dictionary with position
            opponent: Opponent team code

        Returns:
            Multiplier for player projection (1.0 = neutral)
        """
        position = player['position']

        # Get opponent's performance vs position
        opp_stats = self.defense_stats[
            (self.defense_stats['team'] == opponent) &
            (self.defense_stats['position'] == position)
        ]

        if opp_stats.empty:
            return 1.0

        # Calculate adjustment based on points allowed vs average
        league_avg = self.defense_stats[
            self.defense_stats['position'] == position
        ]['points_allowed'].mean()

        opp_allowed = opp_stats['points_allowed'].values[0]

        # Convert to multiplier (10% above average = 1.10)
        adjustment = opp_allowed / league_avg

        return adjustment

    def rank_matchups(self, players: List[dict], week: int) -> pd.DataFrame:
        """
        Rank players by matchup quality.

        Args:
            players: List of players with opponents
            week: Game week

        Returns:
            DataFrame with matchup rankings
        """
        results = []

        for player in players:
            adjustment = self.get_matchup_adjustment(
                player, player['opponent']
            )
            adjusted_proj = player['base_projection'] * adjustment

            results.append({
                'name': player['name'],
                'position': player['position'],
                'opponent': player['opponent'],
                'base_projection': player['base_projection'],
                'matchup_adjustment': adjustment,
                'adjusted_projection': adjusted_proj,
                'matchup_quality': 'Favorable' if adjustment > 1.05
                                  else 'Unfavorable' if adjustment < 0.95
                                  else 'Neutral'
            })

        return pd.DataFrame(results).sort_values('adjusted_projection', ascending=False)

8.2 Vegas Totals Integration

Game totals and spreads inform fantasy projections:

def adjust_for_vegas(base_projection: float,
                    position: str,
                    team_implied_total: float,
                    spread: float) -> float:
    """
    Adjust projection based on Vegas lines.

    Args:
        base_projection: Base fantasy projection
        position: Player position
        team_implied_total: Team's implied point total
        spread: Point spread (negative = favorite)

    Returns:
        Vegas-adjusted projection
    """
    # Average implied total is ~23 points
    total_factor = team_implied_total / 23.0

    # Position-specific sensitivity to game script
    sensitivities = {
        'QB': 0.8,   # Highly correlated with scoring
        'RB': 0.5,   # Moderate (favorites run more)
        'WR': 0.6,   # Moderate-high
        'TE': 0.5,   # Moderate
    }

    sensitivity = sensitivities.get(position, 0.5)

    # Calculate adjustment
    adjustment = 1 + (total_factor - 1) * sensitivity

    # Game script adjustment for RBs
    if position == 'RB' and spread < -7:
        adjustment *= 1.05  # Favorites run more in blowouts
    elif position == 'RB' and spread > 7:
        adjustment *= 0.95  # Underdogs abandon run

    return base_projection * adjustment

Section 9: Bankroll and Contest Management

9.1 DFS Bankroll Strategy

def calculate_optimal_entry_size(bankroll: float,
                                contest_type: str,
                                edge_estimate: float) -> dict:
    """
    Calculate optimal DFS entry size using Kelly Criterion.

    Args:
        bankroll: Total DFS bankroll
        contest_type: 'cash' or 'tournament'
        edge_estimate: Estimated edge (e.g., 0.05 = 5%)

    Returns:
        Recommended entry allocation
    """
    if contest_type == 'cash':
        # Cash games: higher edge, lower variance
        # Use fractional Kelly (1/4)
        kelly_fraction = 0.25
        win_prob = 0.52 + edge_estimate  # ~52% base + edge
        odds = 0.9  # Account for rake
    else:
        # Tournaments: lower win prob, higher payout
        kelly_fraction = 0.1  # More conservative
        win_prob = 0.15 + edge_estimate
        odds = 5.0  # Average tournament multiplier

    # Kelly formula: f* = (bp - q) / b
    # where b = odds, p = win prob, q = 1 - p
    kelly = (odds * win_prob - (1 - win_prob)) / odds

    # Apply fraction and bankroll
    entry_size = bankroll * kelly * kelly_fraction

    return {
        'recommended_entry': entry_size,
        'kelly_fraction': kelly_fraction,
        'max_entry': bankroll * 0.1,  # Never more than 10%
        'min_entry': 1.0,  # Platform minimum
        'actual_entry': max(1.0, min(entry_size, bankroll * 0.1))
    }

9.2 Contest Selection

def evaluate_contest(entry_fee: float,
                    prize_pool: float,
                    entries: int,
                    payout_structure: str) -> dict:
    """
    Evaluate DFS contest value.

    Args:
        entry_fee: Cost to enter
        prize_pool: Total prize pool
        entries: Number of entries
        payout_structure: 'top_heavy' or 'flat'

    Returns:
        Contest evaluation metrics
    """
    # Calculate rake
    total_entries_value = entry_fee * entries
    rake = (total_entries_value - prize_pool) / total_entries_value

    # Expected payout position
    if payout_structure == 'top_heavy':
        # Only top 15-20% cash
        cash_line = int(entries * 0.18)
        min_cash = prize_pool * 0.01  # Typical min cash
    else:
        # 50/50 or double-up
        cash_line = int(entries * 0.5)
        min_cash = entry_fee * 1.8

    return {
        'rake_percentage': rake * 100,
        'cash_line': cash_line,
        'min_cash': min_cash,
        'roi_needed_to_profit': rake * 100,
        'recommendation': 'Play' if rake < 0.15 else 'Avoid high rake'
    }

Section 10: Advanced Topics

10.1 Player Correlation in Season-Long

Avoid negatively correlated players:

  • Don't roster two RBs from same team
  • Be cautious with QB/RB from same team (TD competition)
  • WR stacking can work if high passing volume expected

10.2 Late-Round Fliers

Mathematical approach to late-round upside:

def evaluate_late_round_value(player: dict,
                             adp: int,
                             your_pick: int) -> dict:
    """
    Evaluate late-round flier potential.

    Args:
        player: Player with projection and ceiling
        adp: Average draft position
        your_pick: Your current pick number

    Returns:
        Evaluation of late-round value
    """
    # Value is in ceiling relative to draft cost
    ceiling = player['ceiling']
    floor = player['floor']
    projection = player['projection']

    # Compare to expected value at ADP
    expected_at_adp = 200 - adp * 1.5  # Rough expected points by ADP

    # Upside ratio
    upside_ratio = ceiling / projection

    # Hit rate needed
    # If ceiling is 250 and projection is 100, need 40% hit rate to be worthwhile
    hit_rate_needed = expected_at_adp / ceiling

    return {
        'ceiling': ceiling,
        'floor': floor,
        'upside_ratio': upside_ratio,
        'hit_rate_needed': hit_rate_needed,
        'recommendation': 'Take flier' if hit_rate_needed < 0.35 and upside_ratio > 2.0
                         else 'Pass'
    }

10.3 Playoff Scheduling

For season-long leagues, playoff schedule matters:

def evaluate_playoff_schedule(player: dict,
                             playoff_opponents: List[str],
                             defense_rankings: dict) -> dict:
    """
    Evaluate player's playoff schedule.

    Args:
        player: Player dictionary
        playoff_opponents: List of week 15-17 opponents
        defense_rankings: Defense vs position rankings

    Returns:
        Playoff schedule evaluation
    """
    position = player['position']

    matchup_scores = []
    for opp in playoff_opponents:
        # Higher rank = easier matchup
        rank = defense_rankings.get((opp, position), 16)
        matchup_scores.append(rank)

    avg_matchup = np.mean(matchup_scores)

    return {
        'playoff_opponents': playoff_opponents,
        'matchup_ranks': matchup_scores,
        'average_matchup_rank': avg_matchup,
        'schedule_grade': 'A' if avg_matchup > 20
                         else 'B' if avg_matchup > 14
                         else 'C' if avg_matchup > 8
                         else 'D'
    }

Chapter Summary

Fantasy football analytics provides a structured approach to a game often dominated by emotion and heuristics. The key principles covered:

  1. Scoring Systems shape strategy—understand your league's rules
  2. VORP reveals true player value relative to replacement
  3. Positional Scarcity drives draft strategy
  4. Projections require volume, efficiency, and regression analysis
  5. Variance Management depends on matchup context
  6. DFS Optimization combines projection with game theory
  7. Season-Long Strategy balances draft capital, waivers, and trades
  8. Matchup Analysis adjusts projections for opponent quality
  9. Bankroll Management protects against variance

The analytical fantasy player doesn't just follow rankings—they understand why players are valued and identify where markets misprice talent.


Preview: Chapter 28

The final content chapter covers Draft Analysis, examining how to evaluate NFL Draft prospects using college statistics, combine data, and projection models. We'll explore the analytics behind prospect evaluation and how teams assess future value.


Key Equations Reference

Fantasy Points (PPR):

FP = pass_yards × 0.04 + pass_td × 4 + int × (-2)
   + rush_yards × 0.1 + rush_td × 6
   + rec_yards × 0.1 + rec_td × 6 + receptions × 1

VORP:

VORP = Player_Projection - Replacement_Level_Projection

Leverage (DFS):

Leverage = Projection / (Ownership% × 100)

Kelly Criterion (Bankroll):

f* = (bp - q) / b
where b = odds, p = win probability, q = 1 - p