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...
In This Chapter
- Introduction
- Section 1: Understanding Fantasy Scoring Systems
- Section 2: Value Over Replacement Player (VORP)
- Section 3: Positional Scarcity Analysis
- Section 4: Player Projection Methodology
- Section 5: Variance and Risk Management
- Section 6: Daily Fantasy Sports (DFS) Optimization
- Section 7: Season-Long Strategy
- Section 8: Matchup Analysis
- Section 9: Bankroll and Contest Management
- Section 10: Advanced Topics
- Chapter Summary
- Preview: Chapter 28
- Key Equations Reference
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):
- Tight End - Massive dropoff after top 3-5
- Running Back - Significant dropoff, especially for elite workloads
- Wide Receiver - Moderate dropoff, deep position
- 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:
- Historical Performance - Weighted recent seasons
- Opportunity Metrics - Targets, carries, snap share
- Efficiency Metrics - Yards per attempt, TD rate
- 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:
- Scoring Systems shape strategy—understand your league's rules
- VORP reveals true player value relative to replacement
- Positional Scarcity drives draft strategy
- Projections require volume, efficiency, and regression analysis
- Variance Management depends on matchup context
- DFS Optimization combines projection with game theory
- Season-Long Strategy balances draft capital, waivers, and trades
- Matchup Analysis adjusts projections for opponent quality
- 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