Hockey Age Curves and Peak Performance
Beginner
10 min read
0 views
Nov 27, 2025
When Do Hockey Players Peak?
Understanding age curves—how player performance changes with age—is crucial for contract negotiations, roster planning, and long-term team building. Different positions peak at different ages, and modern analytics can quantify these patterns precisely.
Typical Peak Ages by Position
- Forwards: Age 24-27 (offensive peak around 25)
- Defensemen: Age 26-29 (longer prime window)
- Goalies: Age 28-31 (latest peak)
- Decline Phase: Begins around age 30-32 for most players
Python: Calculate Age Curves
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import UnivariateSpline
# Load historical player data
player_careers = pd.read_csv('nhl_player_career_stats.csv')
# Calculate age curves by position
positions = ['C', 'LW', 'RW', 'D']
def calculate_age_curve(position_data):
"""Calculate average performance by age"""
age_performance = position_data.groupby('age').agg({
'points_per_game': 'mean',
'goals_per_game': 'mean',
'assists_per_game': 'mean',
'plus_minus_per_game': 'mean'
}).reset_index()
return age_performance
age_curves = {}
for position in positions:
pos_data = player_careers[player_careers['position'] == position]
age_curves[position] = calculate_age_curve(pos_data)
# Identify peak age for each position
print("=== Peak Performance Age by Position ===")
for position, curve_data in age_curves.items():
peak_age = curve_data.loc[
curve_data['points_per_game'].idxmax(), 'age'
]
peak_ppg = curve_data['points_per_game'].max()
print(f"{position}: Age {peak_age:.0f} ({peak_ppg:.3f} PPG)")
# Calculate aging factors (year-over-year decline)
def calculate_aging_factor(position_data):
"""Calculate expected performance change by age"""
aging_factors = []
for age in range(20, 38):
current_age = position_data[position_data['age'] == age]
next_age = position_data[position_data['age'] == age + 1]
if len(current_age) > 0 and len(next_age) > 0:
current_ppg = current_age['points_per_game'].mean()
next_ppg = next_age['points_per_game'].mean()
factor = (next_ppg / current_ppg) - 1
aging_factors.append({
'age': age,
'aging_factor': factor
})
return pd.DataFrame(aging_factors)
print("\n=== Aging Factors (Year-over-Year Change) ===")
for position in positions:
pos_data = player_careers[player_careers['position'] == position]
factors = calculate_aging_factor(pos_data)
print(f"\n{position}:")
print(factors)
# Project future performance for a player
def project_performance(current_age, current_ppg, position, years_ahead=5):
"""Project future performance based on age curve"""
pos_data = player_careers[player_careers['position'] == position]
factors = calculate_aging_factor(pos_data)
projections = [current_ppg]
for year in range(1, years_ahead + 1):
age = current_age + year
factor_row = factors[factors['age'] == age]
if len(factor_row) > 0:
aging_factor = factor_row['aging_factor'].values[0]
next_ppg = projections[-1] * (1 + aging_factor)
projections.append(max(0, next_ppg)) # Can't be negative
else:
# Default decline for ages beyond data
projections.append(projections[-1] * 0.95)
return projections
# Example: Project a 27-year-old center's performance
player_projection = project_performance(
current_age=27,
current_ppg=0.95,
position='C',
years_ahead=5
)
print("\n=== Player Projection (27-year-old Center, 0.95 PPG) ===")
for i, ppg in enumerate(player_projection):
age = 27 + i
print(f"Age {age}: {ppg:.3f} PPG")
# Contract value analysis based on age
def calculate_expected_war(age, current_ppg, position, contract_years):
"""Calculate expected Wins Above Replacement over contract"""
projections = project_performance(age, current_ppg, position, contract_years)
# Rough WAR estimation: 1 PPG ≈ 3 WAR (simplified)
war_values = [ppg * 3 for ppg in projections[1:]] # Exclude current year
total_war = sum(war_values)
return total_war, war_values
# Example contract analysis
age, ppg, position, years = 28, 0.85, 'D', 6
total_war, war_by_year = calculate_expected_war(age, ppg, position, years)
print(f"\n=== Contract Analysis: {years}-year deal for {age}-year-old {position} ===")
print(f"Current Production: {ppg:.3f} PPG")
print(f"Expected Total WAR: {total_war:.1f}")
print("WAR by Contract Year:", [f"{w:.2f}" for w in war_by_year])
R: Age Curve Visualization
library(tidyverse)
library(splines)
library(scales)
# Load career statistics
player_careers <- read_csv("nhl_player_career_stats.csv")
# Calculate age curves by position
positions <- c("C", "LW", "RW", "D")
age_curves <- positions %>%
map_dfr(function(pos) {
player_careers %>%
filter(position == pos) %>%
group_by(age) %>%
summarise(
avg_ppg = mean(points_per_game, na.rm = TRUE),
avg_goals = mean(goals_per_game, na.rm = TRUE),
avg_assists = mean(assists_per_game, na.rm = TRUE),
n_seasons = n(),
.groups = "drop"
) %>%
mutate(position = pos)
})
# Identify peak age for each position
peak_ages <- age_curves %>%
group_by(position) %>%
slice_max(avg_ppg, n = 1) %>%
select(position, peak_age = age, peak_ppg = avg_ppg)
cat("=== Peak Performance Age by Position ===\n")
print(peak_ages)
# Calculate aging factors
calculate_aging_factors <- function(position_data) {
position_data %>%
arrange(age) %>%
mutate(
next_ppg = lead(avg_ppg),
aging_factor = (next_ppg / avg_ppg) - 1
) %>%
filter(!is.na(aging_factor))
}
aging_factors <- age_curves %>%
group_by(position) %>%
group_modify(~calculate_aging_factors(.x)) %>%
ungroup()
cat("\n=== Aging Factors by Position ===\n")
print(aging_factors %>%
filter(age >= 20, age <= 35) %>%
select(position, age, avg_ppg, aging_factor))
# Function to project future performance
project_performance <- function(current_age, current_ppg, position,
years_ahead = 5, factors_df) {
projections <- numeric(years_ahead + 1)
projections[1] <- current_ppg
for (i in 1:years_ahead) {
age <- current_age + i
factor <- factors_df %>%
filter(position == !!position, age == !!age) %>%
pull(aging_factor)
if (length(factor) > 0) {
projections[i + 1] <- projections[i] * (1 + factor)
} else {
# Default decline
projections[i + 1] <- projections[i] * 0.95
}
projections[i + 1] <- max(0, projections[i + 1])
}
return(projections)
}
# Example projection
player_projection <- tibble(
age = 27:32,
projected_ppg = project_performance(27, 0.95, "C", 5, aging_factors)
)
cat("\n=== Player Projection (27-year-old Center, 0.95 PPG) ===\n")
print(player_projection)
# Visualize age curves
ggplot(age_curves %>% filter(age >= 18, age <= 40),
aes(x = age, y = avg_ppg, color = position)) +
geom_line(size = 1.2) +
geom_point(size = 2) +
geom_vline(data = peak_ages, aes(xintercept = peak_age, color = position),
linetype = "dashed", alpha = 0.5) +
scale_y_continuous(labels = number_format(accuracy = 0.01)) +
labs(title = "Hockey Age Curves by Position",
subtitle = "Average points per game across career ages",
x = "Age", y = "Points Per Game",
color = "Position") +
theme_minimal() +
theme(legend.position = "bottom")
# Contract value analysis
calculate_expected_war <- function(age, current_ppg, position,
contract_years, factors_df) {
projections <- project_performance(age, current_ppg, position,
contract_years, factors_df)
# PPG to WAR conversion (simplified: 1 PPG ≈ 3 WAR)
war_values <- projections[-1] * 3
tibble(
contract_year = 1:contract_years,
age = (age + 1):(age + contract_years),
projected_ppg = projections[-1],
projected_war = war_values
)
}
# Example contract analysis
contract_analysis <- calculate_expected_war(28, 0.85, "D", 6, aging_factors)
cat("\n=== Contract Analysis: 6-year deal for 28-year-old D ===\n")
print(contract_analysis)
cat(sprintf("\nTotal Expected WAR: %.1f\n", sum(contract_analysis$projected_war)))
Application to Contract Decisions
Age curves are critical for long-term contract negotiations. A 6-year deal for a 28-year-old means paying for ages 29-34, which typically includes both prime years and decline years. Teams must weigh current performance against projected aging when determining contract length and value.
Contract Considerations
- Long-term deals for players 30+ carry significant risk of decline
- Defensemen age more gracefully than forwards
- Power play specialists may see sharper declines than even-strength players
- Individual variation exists—some players defy typical age curves
Discussion
Have questions or feedback? Join our community discussion on
Discord or
GitHub Discussions.
Table of Contents
Related Topics
Quick Actions