3 min read

The 2017 Philadelphia Eagles won Super Bowl LII with Carson Wentz going down to injury mid-season and Nick Foles stepping in. The 2022 Cincinnati Bengals reached the Super Bowl just two years after finishing 2-14. Meanwhile, the Dallas Cowboys have...

Chapter 17: Team Building and Roster Construction

Introduction

The 2017 Philadelphia Eagles won Super Bowl LII with Carson Wentz going down to injury mid-season and Nick Foles stepping in. The 2022 Cincinnati Bengals reached the Super Bowl just two years after finishing 2-14. Meanwhile, the Dallas Cowboys have had only three playoff wins in 28 years despite consistently high payrolls. What separates successful roster construction from the rest?

Building a winning NFL team involves a complex interplay of: - The salary cap - $224.8M in 2023, creating hard constraints - The draft - Primary source of cost-controlled talent - Free agency - Expensive but immediate talent acquisition - Contract structure - Guaranteed money, cap manipulation, and timing - Position value - Not all positions contribute equally to winning

This chapter explores the analytics of roster construction—how successful teams allocate resources across positions, manage the salary cap, and build sustainable competitive windows.


The Salary Cap Economy

Understanding the Cap

The NFL salary cap is a hard limit on total player salaries, designed to create competitive balance:

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

@dataclass
class SalaryCapInfo:
    """NFL salary cap context."""
    year: int
    cap_amount: float
    top_qb_pct: float  # Top QB % of cap
    avg_starter_pct: float
    roster_size: int = 53

# Historical cap growth
CAP_HISTORY = {
    2015: 143.28,
    2016: 155.27,
    2017: 167.00,
    2018: 177.20,
    2019: 188.20,
    2020: 198.20,
    2021: 182.50,  # COVID reduction
    2022: 208.20,
    2023: 224.80,
    2024: 255.40  # Projected
}

def analyze_cap_growth():
    """Analyze salary cap trends."""
    years = list(CAP_HISTORY.keys())
    caps = list(CAP_HISTORY.values())

    # Average annual growth (excluding COVID year)
    growth_rates = []
    for i in range(1, len(caps)):
        if years[i] != 2021:  # Skip COVID anomaly
            growth_rates.append((caps[i] - caps[i-1]) / caps[i-1])

    avg_growth = np.mean(growth_rates)

    return {
        'avg_annual_growth': avg_growth,
        'recent_cap': caps[-1],
        'five_year_growth': (caps[-1] - caps[-6]) / caps[-6] if len(caps) > 5 else 0
    }

Position Spending Benchmarks

Successful teams allocate cap space strategically:

@dataclass
class PositionBenchmark:
    """Cap allocation benchmarks by position."""
    position: str
    avg_pct_of_cap: float
    elite_pct_of_cap: float
    starter_count: int
    positional_value: float  # WAR-like metric

POSITION_BENCHMARKS = {
    'QB': PositionBenchmark('QB', 12.0, 18.0, 1, 10.0),
    'EDGE': PositionBenchmark('EDGE', 8.0, 12.0, 2, 7.5),
    'WR': PositionBenchmark('WR', 10.0, 14.0, 3, 6.0),
    'OT': PositionBenchmark('OT', 9.0, 12.0, 2, 7.0),
    'CB': PositionBenchmark('CB', 8.0, 11.0, 2, 6.5),
    'DT': PositionBenchmark('DT', 5.0, 8.0, 2, 5.0),
    'LB': PositionBenchmark('LB', 6.0, 9.0, 3, 5.5),
    'S': PositionBenchmark('S', 5.0, 7.0, 2, 4.5),
    'TE': PositionBenchmark('TE', 4.0, 6.0, 1, 4.0),
    'IOL': PositionBenchmark('IOL', 6.0, 8.0, 3, 5.5),
    'RB': PositionBenchmark('RB', 3.0, 5.0, 1, 3.0),
}

def calculate_cap_allocation(contracts: pd.DataFrame,
                             cap: float) -> pd.DataFrame:
    """
    Calculate cap allocation by position group.

    Args:
        contracts: DataFrame with player, position, cap_hit
        cap: Total salary cap

    Returns:
        Position-level spending analysis
    """
    position_spending = contracts.groupby('position').agg(
        total_cap_hit=('cap_hit', 'sum'),
        player_count=('player', 'count'),
        max_contract=('cap_hit', 'max')
    ).reset_index()

    position_spending['pct_of_cap'] = (
        position_spending['total_cap_hit'] / cap * 100
    )

    # Compare to benchmarks
    position_spending['benchmark_pct'] = position_spending['position'].map(
        lambda x: POSITION_BENCHMARKS.get(x, PositionBenchmark(x, 5.0, 8.0, 1, 5.0)).avg_pct_of_cap
    )

    position_spending['vs_benchmark'] = (
        position_spending['pct_of_cap'] - position_spending['benchmark_pct']
    )

    return position_spending.sort_values('pct_of_cap', ascending=False)

The Quarterback Dilemma

Elite QBs command 15-20% of the salary cap, creating roster-building tensions:

def analyze_qb_cap_impact(qb_cap_pct: float, cap: float) -> Dict:
    """
    Analyze how QB salary affects roster construction.

    Args:
        qb_cap_pct: QB's percentage of salary cap
        cap: Total salary cap
    """
    qb_cap_hit = cap * (qb_cap_pct / 100)
    remaining_cap = cap - qb_cap_hit
    remaining_roster = 52  # Excluding QB

    avg_per_player = remaining_cap / remaining_roster

    # Benchmarks
    elite_qb_pct = 18.0
    rookie_qb_pct = 2.0

    elite_remaining = cap * (1 - elite_qb_pct / 100) / remaining_roster
    rookie_remaining = cap * (1 - rookie_qb_pct / 100) / remaining_roster

    return {
        'qb_cap_hit': qb_cap_hit,
        'remaining_cap': remaining_cap,
        'avg_per_other_player': avg_per_player,
        'elite_qb_avg_per_player': elite_remaining,
        'rookie_qb_avg_per_player': rookie_remaining,
        'roster_advantage_vs_elite': (avg_per_player - elite_remaining) * remaining_roster
    }

# Example: $45M QB on $225M cap
result = analyze_qb_cap_impact(20.0, 225.0)
# Remaining $180M for 52 players = $3.46M average
# vs rookie QB: $220.5M for 52 = $4.24M average
# Difference: ~$40M more to spend on supporting cast

Draft Value Analysis

The Draft Pick Value Chart

Draft picks have quantifiable expected value:

# Jimmy Johnson draft value chart (traditional)
JIMMY_JOHNSON_VALUES = {
    1: 3000, 2: 2600, 3: 2200, 4: 1800, 5: 1700,
    6: 1600, 7: 1500, 8: 1400, 9: 1350, 10: 1300,
    11: 1250, 12: 1200, 13: 1150, 14: 1100, 15: 1050,
    16: 1000, 17: 950, 18: 900, 19: 875, 20: 850,
    32: 590, 33: 580, 64: 270, 65: 265,
    96: 116, 97: 114, 128: 54, 129: 53,
    160: 27, 192: 15, 224: 5, 256: 1
}

def get_jimmy_johnson_value(pick: int) -> float:
    """Get traditional draft value for a pick."""
    if pick in JIMMY_JOHNSON_VALUES:
        return JIMMY_JOHNSON_VALUES[pick]

    # Interpolate
    lower = max(k for k in JIMMY_JOHNSON_VALUES if k <= pick)
    upper = min(k for k in JIMMY_JOHNSON_VALUES if k >= pick)

    if lower == upper:
        return JIMMY_JOHNSON_VALUES[lower]

    lower_val = JIMMY_JOHNSON_VALUES[lower]
    upper_val = JIMMY_JOHNSON_VALUES[upper]

    # Linear interpolation
    ratio = (pick - lower) / (upper - lower)
    return lower_val - ratio * (lower_val - upper_val)


# Modern surplus value approach (AV-based)
def calculate_expected_surplus_value(pick: int, position: str) -> Dict:
    """
    Calculate expected surplus value of a draft pick.

    Surplus value = Expected performance value - Contract cost
    """
    # Expected career AV by draft position (approximation)
    # Based on historical data analysis
    expected_av = max(0, 40 - pick * 0.35 + np.random.normal(0, 5))

    # Rookie contract cost (4 years, scaled by pick)
    if pick <= 32:
        total_contract = 15 + (32 - pick) * 0.8  # First round premium
    elif pick <= 64:
        total_contract = 5 + (64 - pick) * 0.1
    else:
        total_contract = 3 + max(0, (100 - pick) * 0.02)

    # Market value for equivalent AV
    av_per_million = 0.8  # Rough conversion
    market_value = expected_av / av_per_million

    surplus_value = market_value - total_contract

    return {
        'pick': pick,
        'position': position,
        'expected_av': round(expected_av, 1),
        'contract_cost': round(total_contract, 1),
        'market_value': round(market_value, 1),
        'surplus_value': round(surplus_value, 1)
    }

Position Value in the Draft

Not all positions provide equal draft value:

# Position-specific hit rates and value
DRAFT_POSITION_VALUE = {
    # (hit_rate_rd1, avg_years_as_starter, positional_scarcity)
    'QB': (0.45, 8.0, 1.0),
    'EDGE': (0.55, 6.0, 0.8),
    'OT': (0.50, 7.0, 0.85),
    'CB': (0.45, 5.5, 0.75),
    'WR': (0.50, 5.0, 0.70),
    'DT': (0.45, 6.0, 0.65),
    'LB': (0.40, 5.0, 0.60),
    'S': (0.40, 5.0, 0.55),
    'IOL': (0.45, 6.0, 0.60),
    'TE': (0.35, 5.0, 0.50),
    'RB': (0.50, 3.5, 0.40),
}

def evaluate_draft_pick_value(pick: int, position: str,
                               player_grade: float) -> Dict:
    """
    Evaluate expected value of a draft pick.

    Args:
        pick: Draft pick number
        position: Position abbreviation
        player_grade: Pre-draft grade (0-100)
    """
    pos_data = DRAFT_POSITION_VALUE.get(position, (0.40, 5.0, 0.50))
    hit_rate, avg_years, scarcity = pos_data

    # Adjust hit rate for draft position
    position_bonus = max(0, (32 - pick) / 32) * 0.15
    adjusted_hit_rate = hit_rate + position_bonus

    # Grade adjustment
    grade_factor = (player_grade - 70) / 30  # Center around 70
    adjusted_hit_rate = min(0.8, adjusted_hit_rate + grade_factor * 0.1)

    # Expected value
    expected_starter_years = avg_years * adjusted_hit_rate
    expected_value = expected_starter_years * scarcity * 10

    # Surplus value (rookie contract advantage)
    rookie_contract_years = 4 if pick <= 32 else 4
    fifth_year_option = pick <= 32

    surplus_years = min(rookie_contract_years, expected_starter_years)
    surplus_value = surplus_years * scarcity * 5

    return {
        'pick': pick,
        'position': position,
        'player_grade': player_grade,
        'hit_probability': round(adjusted_hit_rate, 2),
        'expected_starter_years': round(expected_starter_years, 1),
        'expected_value': round(expected_value, 1),
        'surplus_value': round(surplus_value, 1),
        'fifth_year_option': fifth_year_option
    }

Draft Capital Management

def calculate_draft_capital(picks: List[Tuple[int, int]]) -> Dict:
    """
    Calculate total draft capital.

    Args:
        picks: List of (round, pick_in_round) tuples
    """
    total_value = 0
    round_values = {}

    for round_num, pick_in_round in picks:
        # Convert to overall pick
        overall = (round_num - 1) * 32 + pick_in_round

        value = get_jimmy_johnson_value(overall)
        total_value += value

        if round_num not in round_values:
            round_values[round_num] = 0
        round_values[round_num] += value

    return {
        'total_picks': len(picks),
        'total_value': total_value,
        'by_round': round_values,
        'equivalent_pick': find_equivalent_single_pick(total_value)
    }

def find_equivalent_single_pick(value: float) -> int:
    """Find single pick equivalent to given value."""
    for pick in range(1, 260):
        if get_jimmy_johnson_value(pick) <= value:
            return pick
    return 256

Free Agency Economics

Market Efficiency

Free agency is generally an inefficient market for buyers:

def analyze_free_agent_value(player_age: int, position: str,
                              aav: float, guaranteed: float,
                              performance_metric: float) -> Dict:
    """
    Analyze free agent contract value.

    Args:
        player_age: Age at signing
        position: Position
        aav: Average annual value ($M)
        guaranteed: Total guaranteed money ($M)
        performance_metric: Prior year performance (EPA, PFF grade, etc.)
    """
    pos_data = DRAFT_POSITION_VALUE.get(position, (0.40, 5.0, 0.50))
    _, avg_career_years, _ = pos_data

    # Expected remaining prime years
    prime_end_by_position = {
        'QB': 38, 'RB': 28, 'WR': 32, 'TE': 32,
        'OT': 34, 'IOL': 33, 'EDGE': 31, 'DT': 31,
        'LB': 30, 'CB': 30, 'S': 31
    }
    prime_end = prime_end_by_position.get(position, 30)
    remaining_prime = max(0, prime_end - player_age)

    # Age decline factor
    if player_age <= 27:
        age_factor = 1.0
    elif player_age <= 30:
        age_factor = 1.0 - (player_age - 27) * 0.05
    else:
        age_factor = 0.85 - (player_age - 30) * 0.1

    # Expected performance value
    expected_performance = performance_metric * age_factor

    # Contract risk assessment
    guaranteed_pct = guaranteed / (aav * 4) if aav > 0 else 0  # Assume 4-year deal
    injury_risk = 1 - (player_age - 25) * 0.02 if player_age > 25 else 1

    # Value assessment
    cost_per_expected_year = aav / remaining_prime if remaining_prime > 0 else float('inf')

    return {
        'player_age': player_age,
        'position': position,
        'aav': aav,
        'remaining_prime_years': remaining_prime,
        'age_factor': round(age_factor, 2),
        'expected_performance': round(expected_performance, 2),
        'guaranteed_pct': round(guaranteed_pct, 2),
        'cost_per_prime_year': round(cost_per_expected_year, 2),
        'value_rating': 'good' if cost_per_expected_year < 12 else
                        'fair' if cost_per_expected_year < 18 else 'poor'
    }

When Free Agency Makes Sense

def evaluate_fa_vs_draft(position: str, fa_aav: float,
                          fa_performance: float,
                          available_pick: int) -> Dict:
    """
    Compare free agent acquisition to draft alternative.

    Args:
        position: Target position
        fa_aav: Free agent AAV
        fa_performance: Free agent expected performance (0-100)
        available_pick: Best available draft pick
    """
    # Draft alternative
    draft_value = evaluate_draft_pick_value(available_pick, position, 75)

    # Free agent over 4 years
    fa_total_cost = fa_aav * 4
    fa_expected_value = fa_performance * 4  # Immediate impact

    # Draft over 4 years (including development time)
    draft_expected_value = draft_value['expected_value'] * 0.7  # Risk discount
    draft_cost = calculate_rookie_contract_cost(available_pick)

    # Surplus comparison
    fa_surplus = fa_expected_value - fa_total_cost / 3  # Performance - cost
    draft_surplus = draft_expected_value - draft_cost / 3

    return {
        'position': position,
        'fa_option': {
            'cost_4yr': fa_total_cost,
            'expected_value': fa_expected_value,
            'surplus': round(fa_surplus, 1)
        },
        'draft_option': {
            'pick': available_pick,
            'cost_4yr': draft_cost,
            'expected_value': round(draft_expected_value, 1),
            'surplus': round(draft_surplus, 1)
        },
        'recommendation': 'free_agent' if fa_surplus > draft_surplus else 'draft'
    }

def calculate_rookie_contract_cost(pick: int) -> float:
    """Estimate 4-year rookie contract cost."""
    if pick <= 10:
        return 35 + (10 - pick) * 2
    elif pick <= 32:
        return 15 + (32 - pick) * 0.6
    elif pick <= 64:
        return 5 + (64 - pick) * 0.1
    else:
        return 3.5

Roster Construction Strategies

The Competitive Window

Teams typically have 3-5 year windows to compete:

@dataclass
class CompetitiveWindow:
    """Analysis of team's competitive window."""
    team: str
    window_start: int
    window_end: int
    core_players: List[str]
    cap_flexibility: float
    draft_capital_rating: float
    championship_probability: float

def analyze_competitive_window(roster: pd.DataFrame,
                                contracts: pd.DataFrame,
                                draft_picks: List,
                                current_year: int) -> CompetitiveWindow:
    """
    Analyze team's competitive window.

    Args:
        roster: Current roster with age, position, performance
        contracts: Contract details with years remaining
        draft_picks: Future draft capital
        current_year: Current season
    """
    # Identify core players (top performers with years remaining)
    core = roster.merge(contracts, on='player')
    core = core[
        (core['performance_rating'] >= 80) &
        (core['years_remaining'] >= 2)
    ]

    core_players = core['player'].tolist()

    # Calculate window based on core player contracts
    if len(core) > 0:
        window_end = current_year + int(core['years_remaining'].median())
    else:
        window_end = current_year + 1

    # Cap flexibility
    total_committed = contracts[contracts['year'] == current_year + 1]['cap_hit'].sum()
    projected_cap = 235.0  # Next year projection
    cap_flexibility = (projected_cap - total_committed) / projected_cap

    # Draft capital
    draft_value = sum(get_jimmy_johnson_value(p[0] * 32 + p[1])
                     for p in draft_picks)
    draft_capital_rating = min(100, draft_value / 50)

    # Championship probability (simplified)
    roster_strength = roster['performance_rating'].mean() / 100
    qb_factor = roster[roster['position'] == 'QB']['performance_rating'].max() / 100
    championship_prob = roster_strength * 0.4 + qb_factor * 0.4 + cap_flexibility * 0.2

    return CompetitiveWindow(
        team=roster['team'].iloc[0] if 'team' in roster.columns else 'Unknown',
        window_start=current_year,
        window_end=window_end,
        core_players=core_players[:5],  # Top 5
        cap_flexibility=round(cap_flexibility, 2),
        draft_capital_rating=round(draft_capital_rating, 1),
        championship_probability=round(championship_prob, 2)
    )

Build vs Buy Decision Framework

def build_vs_buy_analysis(team_state: str, position_need: str,
                           cap_space: float, draft_capital: float) -> Dict:
    """
    Recommend build (draft) vs buy (FA) strategy.

    Args:
        team_state: 'rebuilding', 'competitive', 'contending'
        position_need: Position to address
        cap_space: Available cap space ($M)
        draft_capital: Total draft value available
    """
    recommendations = {
        'rebuilding': {
            'primary': 'draft',
            'rationale': 'Accumulate cheap talent, extend window',
            'fa_approach': 'short_term_prove_it_deals',
            'trade_approach': 'sell_veterans_for_picks'
        },
        'competitive': {
            'primary': 'balanced',
            'rationale': 'Mix of draft and targeted FA',
            'fa_approach': 'value_signings_under_30',
            'trade_approach': 'opportunistic_both_directions'
        },
        'contending': {
            'primary': 'buy',
            'rationale': 'Maximize current window',
            'fa_approach': 'premium_signings_for_key_positions',
            'trade_approach': 'trade_picks_for_players'
        }
    }

    base_rec = recommendations.get(team_state, recommendations['competitive'])

    # Adjust for cap space
    if cap_space < 20:
        base_rec['cap_constraint'] = 'Must rely on draft/trades'
    elif cap_space > 50:
        base_rec['cap_opportunity'] = 'Can pursue multiple FA targets'

    # Position-specific guidance
    if position_need == 'QB' and team_state != 'rebuilding':
        base_rec['position_note'] = 'QB via draft is preferred for cap efficiency'
    elif position_need in ['EDGE', 'OT'] and team_state == 'contending':
        base_rec['position_note'] = f'{position_need} worth premium FA investment'
    elif position_need == 'RB':
        base_rec['position_note'] = 'RB rarely worth significant FA investment'

    return base_rec

Position Value Rankings

Wins Above Replacement by Position

def calculate_positional_war(pbp: pd.DataFrame,
                              roster: pd.DataFrame) -> pd.DataFrame:
    """
    Calculate approximate WAR by position.

    Based on EPA contribution and replacement level.
    """
    # Position replacement levels (EPA/play)
    REPLACEMENT_LEVELS = {
        'QB': -0.10,
        'RB': -0.05,
        'WR': -0.02,
        'TE': -0.03,
        'OL': -0.04,  # Combined
        'EDGE': -0.03,
        'DT': -0.02,
        'LB': -0.02,
        'CB': -0.04,
        'S': -0.02
    }

    # Snap-weighted EPA contribution
    # This is a simplified model
    position_war = []

    for position in REPLACEMENT_LEVELS.keys():
        pos_players = roster[roster['position_group'] == position]

        avg_epa = pos_players['epa_per_play'].mean() if len(pos_players) > 0 else 0
        replacement = REPLACEMENT_LEVELS[position]

        epa_above_replacement = avg_epa - replacement

        # Snap weight (more snaps = more value)
        snap_weights = {
            'QB': 1.0, 'OL': 0.9, 'WR': 0.7, 'RB': 0.5,
            'TE': 0.5, 'EDGE': 0.6, 'DT': 0.5, 'LB': 0.6,
            'CB': 0.7, 'S': 0.6
        }

        war = epa_above_replacement * snap_weights.get(position, 0.5) * 100

        position_war.append({
            'position': position,
            'avg_epa': round(avg_epa, 3),
            'replacement_level': replacement,
            'epa_above_replacement': round(epa_above_replacement, 3),
            'war': round(war, 1)
        })

    return pd.DataFrame(position_war).sort_values('war', ascending=False)


def calculate_cost_per_war(contracts: pd.DataFrame,
                           war_values: pd.DataFrame) -> pd.DataFrame:
    """
    Calculate cost per WAR by position.

    Lower is better (more efficient spending).
    """
    merged = contracts.merge(war_values, on='position')

    merged['cost_per_war'] = merged['aav'] / merged['war'].replace(0, 0.1)

    efficiency = merged.groupby('position').agg(
        total_spend=('aav', 'sum'),
        total_war=('war', 'sum'),
        avg_cost_per_war=('cost_per_war', 'mean')
    ).reset_index()

    efficiency['efficiency_rank'] = efficiency['avg_cost_per_war'].rank()

    return efficiency.sort_values('efficiency_rank')

The Running Back Devaluation

def analyze_rb_value():
    """
    Demonstrate why RBs are devalued in modern NFL.

    Key factors:
    1. Short career spans
    2. High replacement level
    3. Committee approaches work
    4. Receiving backs vs pure rushers
    """
    analysis = {
        'avg_rb_career': 2.57,  # Years as primary back
        'avg_edge_career': 5.2,
        'avg_wr_career': 4.8,

        'replacement_gap': {
            'RB': 0.02,   # EPA difference starter vs backup
            'WR': 0.08,
            'EDGE': 0.12,
            'QB': 0.25
        },

        'draft_capital_roi': {
            'RB_round_1': 0.65,   # Value vs cost
            'RB_round_3': 0.85,
            'WR_round_1': 0.80,
            'EDGE_round_1': 0.90
        },

        'recommendation': 'Draft RBs in rounds 3-5, avoid premium FA deals',
        'exceptions': 'Elite receiving backs (Austin Ekeler type) have more value'
    }

    return analysis

Practical Implementation

Complete Roster Evaluator

from dataclasses import dataclass
from typing import Dict, List, Optional
import pandas as pd
import numpy as np

@dataclass
class RosterReport:
    """Comprehensive roster evaluation report."""
    team: str
    season: int

    # Overall
    total_cap_used: float
    cap_space: float
    dead_money: float

    # Position spending
    qb_pct: float
    offense_pct: float
    defense_pct: float

    # Roster composition
    avg_age: float
    players_under_26: int
    players_over_30: int

    # Contract health
    players_in_final_year: int
    total_guaranteed_remaining: float

    # Performance
    roster_war: float
    cost_efficiency: float

    # Window analysis
    competitive_window_years: int
    window_phase: str


class RosterAnalyzer:
    """
    Comprehensive roster construction analyzer.

    Example usage:
        roster = pd.DataFrame(...)  # Player data
        contracts = pd.DataFrame(...)  # Contract data
        analyzer = RosterAnalyzer(roster, contracts, 2023)

        report = analyzer.generate_report('KC')
        recommendations = analyzer.get_recommendations('KC')
    """

    def __init__(self, roster: pd.DataFrame, contracts: pd.DataFrame,
                 season: int, cap: float = 224.8):
        """
        Initialize analyzer.

        Args:
            roster: Player roster with position, age, performance metrics
            contracts: Contract details with cap_hit, years_remaining, guaranteed
            season: Current season
            cap: Salary cap amount
        """
        self.roster = roster
        self.contracts = contracts
        self.season = season
        self.cap = cap

        # Merge data
        self.full_data = roster.merge(contracts, on='player', how='left')

    def calculate_cap_allocation(self, team: str) -> Dict:
        """Calculate cap spending breakdown."""
        team_data = self.full_data[self.full_data['team'] == team]

        total_cap_hit = team_data['cap_hit'].sum()
        cap_space = self.cap - total_cap_hit
        dead_money = team_data[team_data['on_roster'] == False]['cap_hit'].sum() if 'on_roster' in team_data.columns else 0

        # Position groups
        offense_positions = ['QB', 'RB', 'WR', 'TE', 'OT', 'IOL', 'C']
        defense_positions = ['EDGE', 'DT', 'LB', 'CB', 'S']

        offense_cap = team_data[
            team_data['position'].isin(offense_positions)
        ]['cap_hit'].sum()

        defense_cap = team_data[
            team_data['position'].isin(defense_positions)
        ]['cap_hit'].sum()

        qb_cap = team_data[team_data['position'] == 'QB']['cap_hit'].sum()

        return {
            'total_cap_hit': total_cap_hit,
            'cap_space': cap_space,
            'dead_money': dead_money,
            'qb_pct': qb_cap / self.cap * 100,
            'offense_pct': offense_cap / self.cap * 100,
            'defense_pct': defense_cap / self.cap * 100
        }

    def analyze_roster_age(self, team: str) -> Dict:
        """Analyze roster age distribution."""
        team_data = self.full_data[self.full_data['team'] == team]

        return {
            'avg_age': team_data['age'].mean(),
            'median_age': team_data['age'].median(),
            'under_26': len(team_data[team_data['age'] < 26]),
            'over_30': len(team_data[team_data['age'] > 30]),
            'age_distribution': team_data['age'].describe().to_dict()
        }

    def analyze_contract_health(self, team: str) -> Dict:
        """Analyze contract situation."""
        team_data = self.full_data[self.full_data['team'] == team]

        return {
            'players_final_year': len(team_data[team_data['years_remaining'] == 1]),
            'total_guaranteed': team_data['guaranteed_remaining'].sum(),
            'avg_years_remaining': team_data['years_remaining'].mean(),
            'upcoming_fa': team_data[
                team_data['years_remaining'] == 1
            ][['player', 'position', 'cap_hit']].to_dict('records')
        }

    def calculate_roster_war(self, team: str) -> float:
        """Calculate total roster WAR."""
        team_data = self.full_data[self.full_data['team'] == team]

        # Simplified WAR calculation
        if 'war' in team_data.columns:
            return team_data['war'].sum()
        elif 'performance_rating' in team_data.columns:
            # Estimate from performance rating
            return (team_data['performance_rating'].sum() - 50 * len(team_data)) / 10
        else:
            return 0

    def estimate_competitive_window(self, team: str) -> Tuple[int, str]:
        """Estimate competitive window."""
        team_data = self.full_data[self.full_data['team'] == team]

        # Core player contracts
        core_players = team_data[
            team_data['performance_rating'] >= 80
        ] if 'performance_rating' in team_data.columns else team_data.head(10)

        if len(core_players) == 0:
            return 1, 'rebuilding'

        avg_years_remaining = core_players['years_remaining'].mean()

        # QB situation
        qb_data = team_data[team_data['position'] == 'QB']
        if len(qb_data) > 0:
            qb_age = qb_data['age'].iloc[0]
            qb_quality = qb_data['performance_rating'].iloc[0] if 'performance_rating' in qb_data.columns else 70
        else:
            qb_age = 30
            qb_quality = 50

        # Calculate window
        if qb_quality < 60 or qb_age > 37:
            window = min(2, int(avg_years_remaining))
            phase = 'rebuilding' if qb_quality < 50 else 'retooling'
        elif qb_quality >= 85 and avg_years_remaining >= 3:
            window = int(min(5, avg_years_remaining + 1))
            phase = 'contending'
        else:
            window = int(avg_years_remaining)
            phase = 'competitive'

        return window, phase

    def generate_report(self, team: str) -> RosterReport:
        """Generate comprehensive roster report."""
        cap = self.calculate_cap_allocation(team)
        age = self.analyze_roster_age(team)
        contracts = self.analyze_contract_health(team)
        war = self.calculate_roster_war(team)
        window, phase = self.estimate_competitive_window(team)

        # Cost efficiency
        if war > 0:
            efficiency = cap['total_cap_hit'] / war
        else:
            efficiency = float('inf')

        return RosterReport(
            team=team,
            season=self.season,
            total_cap_used=round(cap['total_cap_hit'], 1),
            cap_space=round(cap['cap_space'], 1),
            dead_money=round(cap['dead_money'], 1),
            qb_pct=round(cap['qb_pct'], 1),
            offense_pct=round(cap['offense_pct'], 1),
            defense_pct=round(cap['defense_pct'], 1),
            avg_age=round(age['avg_age'], 1),
            players_under_26=age['under_26'],
            players_over_30=age['over_30'],
            players_in_final_year=contracts['players_final_year'],
            total_guaranteed_remaining=round(contracts['total_guaranteed'], 1),
            roster_war=round(war, 1),
            cost_efficiency=round(efficiency, 1),
            competitive_window_years=window,
            window_phase=phase
        )

    def get_recommendations(self, team: str) -> List[str]:
        """Generate roster construction recommendations."""
        report = self.generate_report(team)
        recommendations = []

        # Cap recommendations
        if report.cap_space < 10:
            recommendations.append("Limited cap space - prioritize extensions over FA")
        elif report.cap_space > 50:
            recommendations.append("Significant cap space - consider strategic FA additions")

        # Age recommendations
        if report.avg_age > 27:
            recommendations.append("Roster skews old - focus on youth infusion via draft")
        if report.players_under_26 < 15:
            recommendations.append("Need more young talent - prioritize draft capital")

        # QB recommendations
        if report.qb_pct > 18:
            recommendations.append("High QB cap hit - must excel at value positions elsewhere")
        elif report.qb_pct < 5:
            recommendations.append("Rookie QB window - maximize spending elsewhere now")

        # Window recommendations
        if report.window_phase == 'contending':
            recommendations.append("Contending window - trade future picks for immediate help")
        elif report.window_phase == 'rebuilding':
            recommendations.append("Rebuilding - accumulate draft picks, avoid long-term FA deals")

        # Contract recommendations
        if report.players_in_final_year > 15:
            recommendations.append("Many pending FAs - prioritize extension decisions")

        return recommendations


def print_roster_report(report: RosterReport) -> None:
    """Pretty print roster report."""
    print(f"\n{'='*55}")
    print(f"Roster Report: {report.team}")
    print(f"Season: {report.season}")
    print(f"{'='*55}")

    print(f"\n--- Cap Situation ---")
    print(f"Total Cap Used:    ${report.total_cap_used}M")
    print(f"Cap Space:         ${report.cap_space}M")
    print(f"Dead Money:        ${report.dead_money}M")

    print(f"\n--- Cap Allocation ---")
    print(f"QB:                {report.qb_pct:.1f}%")
    print(f"Offense:           {report.offense_pct:.1f}%")
    print(f"Defense:           {report.defense_pct:.1f}%")

    print(f"\n--- Roster Composition ---")
    print(f"Average Age:       {report.avg_age}")
    print(f"Players Under 26:  {report.players_under_26}")
    print(f"Players Over 30:   {report.players_over_30}")

    print(f"\n--- Contract Health ---")
    print(f"Pending FAs:       {report.players_in_final_year}")
    print(f"Guaranteed $:      ${report.total_guaranteed_remaining}M")

    print(f"\n--- Performance ---")
    print(f"Roster WAR:        {report.roster_war}")
    print(f"Cost Efficiency:   ${report.cost_efficiency}M per WAR")

    print(f"\n--- Competitive Window ---")
    print(f"Window:            {report.competitive_window_years} years")
    print(f"Phase:             {report.window_phase}")

    print(f"\n{'='*55}\n")

Summary

Key Takeaways:

  1. The salary cap is a hard constraint - Elite roster construction works within ~$225M limit
  2. Draft picks have calculable surplus value - Rookie contracts provide cost-controlled talent
  3. Position value varies dramatically - QB and EDGE worth more than RB and S
  4. Free agency is generally inefficient - Buyers typically overpay for immediate help
  5. Competitive windows are real - Teams have 3-5 years to maximize chances

Roster Construction Principles:

  • Build through the draft for cost-controlled talent
  • Pay premium only for premium positions (QB, EDGE, OT, CB)
  • Avoid long-term RB contracts
  • Manage the cap to maintain flexibility
  • Understand your competitive window and act accordingly

Preview: Part 4

With Part 3 complete, we've covered team-level analytics including efficiency metrics, play calling, situational football, home field advantage, strength of schedule, and roster construction.

Part 4: Predictive Modeling will build on these foundations to create actual prediction models: - Chapter 18: Introduction to Prediction Models - Chapter 19: Elo and Power Ratings - Chapter 20: Machine Learning for NFL Prediction - Chapter 21: Game Simulation - Chapter 22: Betting Market Analysis


References

  1. Over The Cap. "NFL Salary Cap Data"
  2. Spotrac. "NFL Team Contracts"
  3. Football Outsiders. "Draft Value Analysis"
  4. The Athletic. "NFL Roster Construction Deep Dives"
  5. PFF. "WAR and Position Value Research"