Roster Construction in Hockey

Beginner 10 min read 0 views Nov 27, 2025

Building a Championship Roster

Successful roster construction balances star power, depth, positional needs, and salary cap constraints. Analytics can identify optimal resource allocation strategies and roster composition patterns from championship teams.

Roster Construction Principles

  • Star Allocation: Elite players provide disproportionate value
  • Depth Quality: Third and fourth lines must contribute
  • Positional Balance: Strength down the middle (centers)
  • Defense Pairs: Complementary skill sets on D-pairings
  • Goaltending: Elite goalie or strong tandem approach

Python: Roster Optimization

import pandas as pd
import numpy as np
from scipy.optimize import linprog

# Load available players and contracts
available_players = pd.read_csv('nhl_available_players.csv')

SALARY_CAP = 83_500_000
ROSTER_SIZE = 23

def build_optimal_roster(players_df, cap_limit=SALARY_CAP, roster_size=ROSTER_SIZE):
    """Build optimal roster within cap constraints"""

    # Required positions
    position_requirements = {
        'C': 4,   # 4 centers minimum
        'LW': 4,  # 4 left wings
        'RW': 4,  # 4 right wings
        'D': 7,   # 7 defensemen
        'G': 2    # 2 goalies
    }

    # Sort players by value (WAR per dollar)
    players_df['value_ratio'] = players_df['war'] / players_df['cap_hit']
    players_df = players_df.sort_values('value_ratio', ascending=False)

    selected_roster = []
    total_cap = 0
    position_counts = {pos: 0 for pos in position_requirements.keys()}

    # First, fill required positions
    for position, required in position_requirements.items():
        pos_players = players_df[
            (players_df['position'] == position) &
            (~players_df['player_id'].isin([p['player_id'] for p in selected_roster]))
        ]

        for _, player in pos_players.iterrows():
            if position_counts[position] < required:
                if total_cap + player['cap_hit'] <= cap_limit:
                    selected_roster.append(player.to_dict())
                    total_cap += player['cap_hit']
                    position_counts[position] += 1

    # Fill remaining roster spots with best available
    remaining_spots = roster_size - len(selected_roster)
    remaining_players = players_df[
        ~players_df['player_id'].isin([p['player_id'] for p in selected_roster])
    ]

    for _, player in remaining_players.iterrows():
        if len(selected_roster) < roster_size:
            if total_cap + player['cap_hit'] <= cap_limit:
                selected_roster.append(player.to_dict())
                total_cap += player['cap_hit']

    roster_df = pd.DataFrame(selected_roster)

    return {
        'roster': roster_df,
        'total_cap': total_cap,
        'cap_space': cap_limit - total_cap,
        'total_war': roster_df['war'].sum(),
        'roster_size': len(roster_df)
    }

# Build optimal roster
optimal = build_optimal_roster(available_players)

print("=== Optimal Roster Construction ===")
print(f"Total Cap Hit: ${optimal['total_cap']:,.0f}")
print(f"Remaining Cap Space: ${optimal['cap_space']:,.0f}")
print(f"Total Projected WAR: {optimal['total_war']:.1f}")
print(f"Roster Size: {optimal['roster_size']}")

print("\n=== Roster Breakdown by Position ===")
position_summary = optimal['roster'].groupby('position').agg({
    'cap_hit': ['sum', 'mean', 'count'],
    'war': ['sum', 'mean']
}).round(2)
print(position_summary)

# Line combination optimization
def create_line_combinations(forwards_df):
    """Create optimal forward line combinations"""

    centers = forwards_df[forwards_df['position'] == 'C'].nlargest(4, 'war')
    wingers = forwards_df[forwards_df['position'].isin(['LW', 'RW'])].nlargest(8, 'war')

    lines = []

    # Line 1: Best center + 2 best wingers
    line1 = {
        'line': 1,
        'center': centers.iloc[0]['player_name'],
        'lw': wingers.iloc[0]['player_name'],
        'rw': wingers.iloc[1]['player_name'],
        'total_war': (centers.iloc[0]['war'] +
                     wingers.iloc[0]['war'] +
                     wingers.iloc[1]['war']),
        'total_cap': (centers.iloc[0]['cap_hit'] +
                     wingers.iloc[0]['cap_hit'] +
                     wingers.iloc[1]['cap_hit'])
    }
    lines.append(line1)

    # Line 2: Second center + next 2 wingers
    line2 = {
        'line': 2,
        'center': centers.iloc[1]['player_name'],
        'lw': wingers.iloc[2]['player_name'],
        'rw': wingers.iloc[3]['player_name'],
        'total_war': (centers.iloc[1]['war'] +
                     wingers.iloc[2]['war'] +
                     wingers.iloc[3]['war']),
        'total_cap': (centers.iloc[1]['cap_hit'] +
                     wingers.iloc[2]['cap_hit'] +
                     wingers.iloc[3]['cap_hit'])
    }
    lines.append(line2)

    # Lines 3 & 4 similarly
    for line_num in [3, 4]:
        idx = line_num - 1
        line = {
            'line': line_num,
            'center': centers.iloc[idx]['player_name'],
            'lw': wingers.iloc[idx*2]['player_name'] if idx*2 < len(wingers) else 'N/A',
            'rw': wingers.iloc[idx*2+1]['player_name'] if idx*2+1 < len(wingers) else 'N/A',
            'total_war': (centers.iloc[idx]['war'] +
                         (wingers.iloc[idx*2]['war'] if idx*2 < len(wingers) else 0) +
                         (wingers.iloc[idx*2+1]['war'] if idx*2+1 < len(wingers) else 0)),
            'total_cap': (centers.iloc[idx]['cap_hit'] +
                         (wingers.iloc[idx*2]['cap_hit'] if idx*2 < len(wingers) else 0) +
                         (wingers.iloc[idx*2+1]['cap_hit'] if idx*2+1 < len(wingers) else 0))
        }
        lines.append(line)

    return pd.DataFrame(lines)

forwards = optimal['roster'][optimal['roster']['position'].isin(['C', 'LW', 'RW'])]
line_combos = create_line_combinations(forwards)

print("\n=== Optimal Line Combinations ===")
print(line_combos)

# Defense pairing optimization
def create_defense_pairs(defense_df):
    """Create optimal defensive pairings"""

    # Sort defensemen by WAR
    d_sorted = defense_df.sort_values('war', ascending=False)

    pairs = []

    # Pair 1: Best + complementary partner
    pairs.append({
        'pair': 1,
        'd1': d_sorted.iloc[0]['player_name'],
        'd2': d_sorted.iloc[1]['player_name'],
        'total_war': d_sorted.iloc[0]['war'] + d_sorted.iloc[1]['war'],
        'total_cap': d_sorted.iloc[0]['cap_hit'] + d_sorted.iloc[1]['cap_hit'],
        'avg_toi': (d_sorted.iloc[0]['toi'] + d_sorted.iloc[1]['toi']) / 2
    })

    # Pairs 2 & 3
    for pair_num in [2, 3]:
        idx = (pair_num - 1) * 2
        if idx + 1 < len(d_sorted):
            pairs.append({
                'pair': pair_num,
                'd1': d_sorted.iloc[idx]['player_name'],
                'd2': d_sorted.iloc[idx+1]['player_name'],
                'd1_war': d_sorted.iloc[idx]['war'],
                'd2_war': d_sorted.iloc[idx+1]['war'],
                'total_war': d_sorted.iloc[idx]['war'] + d_sorted.iloc[idx+1]['war'],
                'total_cap': d_sorted.iloc[idx]['cap_hit'] + d_sorted.iloc[idx+1]['cap_hit']
            })

    return pd.DataFrame(pairs)

defense = optimal['roster'][optimal['roster']['position'] == 'D']
d_pairs = create_defense_pairs(defense)

print("\n=== Optimal Defense Pairings ===")
print(d_pairs)

# Championship roster analysis
def analyze_championship_roster_patterns():
    """Analyze common patterns in championship rosters"""

    # Historical championship data (simplified)
    championship_patterns = {
        'star_allocation': {
            'top_3_players_cap_pct': 0.28,  # ~28% to top 3
            'top_6_players_cap_pct': 0.45,  # ~45% to top 6
        },
        'depth_quality': {
            'min_4th_line_war': 0.5,  # 4th line must contribute
            'min_3rd_pair_war': 1.0,  # 3rd D pair must be solid
        },
        'position_strength': {
            'min_centers_war': 8.0,  # Strong down the middle
            'min_defense_war': 10.0,  # Strong defense corps
            'min_goalie_war': 3.0,   # Above average goaltending
        }
    }

    return championship_patterns

champ_patterns = analyze_championship_roster_patterns()

# Evaluate current roster against championship patterns
def evaluate_roster_composition(roster_df, cap_limit=SALARY_CAP):
    """Evaluate roster against championship patterns"""

    roster_sorted = roster_df.nlargest(23, 'war')

    # Top player allocation
    top3_cap = roster_sorted.head(3)['cap_hit'].sum()
    top6_cap = roster_sorted.head(6)['cap_hit'].sum()

    top3_pct = (top3_cap / cap_limit) * 100
    top6_pct = (top6_cap / cap_limit) * 100

    # Position strength
    centers_war = roster_df[roster_df['position'] == 'C']['war'].sum()
    defense_war = roster_df[roster_df['position'] == 'D']['war'].sum()
    goalie_war = roster_df[roster_df['position'] == 'G']['war'].sum()

    print("\n=== Roster Composition Analysis ===")
    print(f"Top 3 Players Cap %: {top3_pct:.1f}% (Championship avg: 28%)")
    print(f"Top 6 Players Cap %: {top6_pct:.1f}% (Championship avg: 45%)")
    print(f"\nPosition Strength:")
    print(f"  Centers WAR: {centers_war:.1f} (Min recommended: 8.0)")
    print(f"  Defense WAR: {defense_war:.1f} (Min recommended: 10.0)")
    print(f"  Goalie WAR: {goalie_war:.1f} (Min recommended: 3.0)")

    # Overall grade
    checks = 0
    total_checks = 5

    if 25 <= top3_pct <= 32: checks += 1
    if 40 <= top6_pct <= 50: checks += 1
    if centers_war >= 8.0: checks += 1
    if defense_war >= 10.0: checks += 1
    if goalie_war >= 3.0: checks += 1

    grade = (checks / total_checks) * 100

    print(f"\nRoster Grade: {grade:.0f}/100")
    if grade >= 80:
        print("✓ Championship-caliber roster construction")
    elif grade >= 60:
        print("⚠ Competitive roster, some weaknesses")
    else:
        print("✗ Roster needs significant improvement")

    return {
        'top3_pct': top3_pct,
        'top6_pct': top6_pct,
        'centers_war': centers_war,
        'defense_war': defense_war,
        'goalie_war': goalie_war,
        'grade': grade
    }

evaluation = evaluate_roster_composition(optimal['roster'])

R: Roster Balance Analysis

library(tidyverse)
library(scales)

# Load roster data
roster <- read_csv("team_roster.csv")

SALARY_CAP <- 83500000

# Analyze roster balance
analyze_roster_balance <- function(roster_data, cap_limit = SALARY_CAP) {

  # Position distribution
  position_summary <- roster_data %>%
    group_by(position) %>%
    summarise(
      players = n(),
      total_cap = sum(cap_hit),
      avg_cap = mean(cap_hit),
      total_war = sum(war),
      avg_war = mean(war)
    ) %>%
    mutate(
      cap_pct = (total_cap / cap_limit) * 100
    )

  return(position_summary)
}

roster_balance <- analyze_roster_balance(roster)

cat("=== Roster Balance Analysis ===\n")
print(roster_balance)

# Visualize cap allocation by position
ggplot(roster_balance, aes(x = position, y = total_cap, fill = position)) +
  geom_col() +
  geom_text(aes(label = dollar(total_cap, scale = 1e-6, suffix = "M")),
            vjust = -0.5) +
  scale_y_continuous(labels = dollar_format(scale = 1e-6, suffix = "M")) +
  labs(title = "Cap Allocation by Position",
       x = "Position", y = "Total Cap Hit",
       fill = "Position") +
  theme_minimal()

# Line combination analysis
create_line_combinations <- function(forwards_data) {
  centers <- forwards_data %>%
    filter(position == "C") %>%
    arrange(desc(war)) %>%
    head(4)

  wingers <- forwards_data %>%
    filter(position %in% c("LW", "RW")) %>%
    arrange(desc(war)) %>%
    head(8)

  tibble(
    line = 1:4,
    center = centers$player_name[1:4],
    total_line_war = c(
      centers$war[1] + sum(wingers$war[1:2]),
      centers$war[2] + sum(wingers$war[3:4]),
      centers$war[3] + sum(wingers$war[5:6]),
      centers$war[4] + sum(wingers$war[7:8])
    ),
    total_line_cap = c(
      centers$cap_hit[1] + sum(wingers$cap_hit[1:2]),
      centers$cap_hit[2] + sum(wingers$cap_hit[3:4]),
      centers$cap_hit[3] + sum(wingers$cap_hit[5:6]),
      centers$cap_hit[4] + sum(wingers$cap_hit[7:8])
    )
  )
}

forwards <- roster %>% filter(position %in% c("C", "LW", "RW"))
line_combos <- create_line_combinations(forwards)

cat("\n=== Line Combinations ===\n")
print(line_combos)

# Championship roster patterns
evaluate_championship_patterns <- function(roster_data, cap_limit = SALARY_CAP) {

  roster_sorted <- roster_data %>% arrange(desc(war))

  top3_cap_pct <- (sum(roster_sorted$cap_hit[1:3]) / cap_limit) * 100
  top6_cap_pct <- (sum(roster_sorted$cap_hit[1:6]) / cap_limit) * 100

  centers_war <- sum(roster_data$war[roster_data$position == "C"])
  defense_war <- sum(roster_data$war[roster_data$position == "D"])
  goalie_war <- sum(roster_data$war[roster_data$position == "G"])

  cat("=== Championship Pattern Analysis ===\n")
  cat(sprintf("Top 3 Players Cap %%: %.1f%% (Target: 28%%)\n", top3_cap_pct))
  cat(sprintf("Top 6 Players Cap %%: %.1f%% (Target: 45%%)\n", top6_cap_pct))
  cat(sprintf("\nCenters WAR: %.1f (Min: 8.0)\n", centers_war))
  cat(sprintf("Defense WAR: %.1f (Min: 10.0)\n", defense_war))
  cat(sprintf("Goalie WAR: %.1f (Min: 3.0)\n", goalie_war))

  # Grading
  checks <- sum(c(
    top3_cap_pct >= 25 & top3_cap_pct <= 32,
    top6_cap_pct >= 40 & top6_cap_pct <= 50,
    centers_war >= 8.0,
    defense_war >= 10.0,
    goalie_war >= 3.0
  ))

  grade <- (checks / 5) * 100

  cat(sprintf("\nRoster Grade: %.0f/100\n", grade))

  if (grade >= 80) {
    cat("✓ Championship-caliber roster\n")
  } else if (grade >= 60) {
    cat("⚠ Competitive roster\n")
  } else {
    cat("✗ Needs improvement\n")
  }

  list(
    top3_cap_pct = top3_cap_pct,
    top6_cap_pct = top6_cap_pct,
    grade = grade
  )
}

champ_eval <- evaluate_championship_patterns(roster)

# Visualize WAR distribution
ggplot(roster %>% arrange(desc(war)) %>% mutate(rank = row_number()),
       aes(x = rank, y = war, color = position)) +
  geom_point(size = 3) +
  geom_line(alpha = 0.3) +
  labs(title = "Roster WAR Distribution",
       subtitle = "Player value across roster depth",
       x = "Roster Rank", y = "WAR",
       color = "Position") +
  theme_minimal()

Star Power vs Depth

The optimal balance between star players and depth depends on team context. Contenders often concentrate cap space in elite players, while developing teams spread resources more evenly to build sustainable depth.

Roster Construction Best Practices

  • Allocate 25-30% of cap to top 3 players for star power
  • Ensure strength down the middle with quality centers
  • Build complementary defense pairings (offensive + defensive)
  • Maintain 4th line and 3rd pair competitiveness
  • Balance between proven veterans and cost-controlled youth

Discussion

Have questions or feedback? Join our community discussion on Discord or GitHub Discussions.