Roster Construction in Hockey
Beginner
10 min read
1 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.
Table of Contents
Related Topics
Quick Actions