Chapter 60

Capstone - Complete Analytics System

Intermediate 30 min read 5 sections 10 code examples
0 of 60 chapters completed (0%)
Learning Objectives
  • Conduct comprehensive season performance reviews
  • Analyze squad composition and identify gaps
  • Build player development assessments
  • Create data-driven transfer priorities
  • Plan for next season using historical performance data

The end of a football season marks both an ending and a beginning. Analytics teams must evaluate what happened, identify what needs to change, and build plans for improvement. This chapter covers the complete season review process that sets the foundation for future success.

The Season Review Framework

A comprehensive season review answers fundamental questions: Did we achieve our objectives? Where did we succeed and fail? What changes do we need to make? The framework below structures this analysis.

1. Results Analysis
  • League position vs target
  • Cup progress
  • Home vs away splits
  • Points by month
2. Performance Analysis
  • xG vs actual goals
  • Playing style evolution
  • Set piece effectiveness
  • Tactical patterns
3. Squad Analysis
  • Individual player reviews
  • Squad depth by position
  • Age profile analysis
  • Injury impact
4. Next Season Planning
  • Transfer priorities
  • Contract decisions
  • Youth integration
  • Tactical evolution
season_review.R / season_review.py
# Python: Season Review Framework
import pandas as pd
import numpy as np
from dataclasses import dataclass, field
from typing import Dict, List, Optional

@dataclass
class SeasonObjectives:
    """Season objectives and targets."""
    league_position: int
    cup_round: str  # e.g., "Quarter-finals"
    points_target: int
    achieved: Dict[str, bool] = field(default_factory=dict)

@dataclass
class SeasonResults:
    """Season results summary."""
    wins: int
    draws: int
    losses: int
    points: int
    goals_for: int
    goals_against: int
    xg_for: float
    xg_against: float
    league_position: int

class SeasonReview:
    """Comprehensive season review analysis."""

    def __init__(self, season: str, team: str,
                 matches: pd.DataFrame,
                 player_stats: pd.DataFrame,
                 objectives: Optional[SeasonObjectives] = None):
        self.season = season
        self.team = team
        self.matches = matches
        self.player_stats = player_stats
        self.objectives = objectives
        self.results = self._calculate_results()

    def _calculate_results(self) -> SeasonResults:
        """Calculate season results summary."""
        team_matches = self.matches[self.matches["team"] == self.team]

        return SeasonResults(
            wins=len(team_matches[team_matches["result"] == "W"]),
            draws=len(team_matches[team_matches["result"] == "D"]),
            losses=len(team_matches[team_matches["result"] == "L"]),
            points=(len(team_matches[team_matches["result"] == "W"]) * 3 +
                   len(team_matches[team_matches["result"] == "D"])),
            goals_for=team_matches["goals_for"].sum(),
            goals_against=team_matches["goals_against"].sum(),
            xg_for=team_matches["xg"].sum(),
            xg_against=team_matches["xg_against"].sum(),
            league_position=self._get_league_position()
        )

    def _get_league_position(self) -> int:
        """Get final league position."""
        # Would normally query from league table
        return 4  # Placeholder

    def assess_objectives(self) -> Dict:
        """Assess season against objectives."""
        assessment = {}

        if self.objectives:
            # League position
            assessment["league"] = {
                "target": self.objectives.league_position,
                "actual": self.results.league_position,
                "achieved": self.results.league_position <= self.objectives.league_position,
                "margin": self.objectives.league_position - self.results.league_position
            }

            # Points
            assessment["points"] = {
                "target": self.objectives.points_target,
                "actual": self.results.points,
                "achieved": self.results.points >= self.objectives.points_target
            }

        # xG analysis
        xg_diff = self.results.goals_for - self.results.xg_for
        assessment["xg_performance"] = {
            "xg_for": round(self.results.xg_for, 1),
            "actual_goals": self.results.goals_for,
            "difference": round(xg_diff, 1),
            "assessment": self._assess_xg_performance(xg_diff)
        }

        return assessment

    def _assess_xg_performance(self, diff: float) -> str:
        """Assess performance vs xG."""
        if diff > 5:
            return "Significantly overperformed xG"
        elif diff > 2:
            return "Moderately overperformed xG"
        elif diff < -5:
            return "Significantly underperformed xG"
        elif diff < -2:
            return "Moderately underperformed xG"
        return "Performed as expected"

    def monthly_analysis(self) -> pd.DataFrame:
        """Analyze performance by month."""
        team_matches = self.matches[self.matches["team"] == self.team].copy()
        team_matches["month"] = pd.to_datetime(team_matches["date"]).dt.month

        monthly = team_matches.groupby("month").agg({
            "result": lambda x: (x == "W").sum() * 3 + (x == "D").sum(),
            "goals_for": "sum",
            "goals_against": "sum",
            "xg": "sum"
        }).reset_index()

        monthly.columns = ["month", "points", "goals_for", "goals_against", "xg"]
        monthly["matches"] = team_matches.groupby("month").size().values
        monthly["ppg"] = monthly["points"] / monthly["matches"]

        return monthly

print("Season review framework ready")
# R: Season Review Framework
library(tidyverse)
library(R6)

# Season Review Class
SeasonReview <- R6Class("SeasonReview",
  public = list(
    season = NULL,
    team = NULL,
    matches = NULL,
    player_stats = NULL,

    # Results
    league_position = NULL,
    points = NULL,
    target_position = NULL,

    initialize = function(season, team, matches, player_stats,
                         target_position = NULL) {
      self$season <- season
      self$team <- team
      self$matches <- matches
      self$player_stats <- player_stats
      self$target_position <- target_position

      # Calculate basic results
      self$calculate_results()
    },

    calculate_results = function() {
      team_matches <- self$matches %>%
        filter(team == self$team)

      self$points <- team_matches %>%
        summarise(
          wins = sum(result == "W"),
          draws = sum(result == "D"),
          losses = sum(result == "L"),
          points = wins * 3 + draws,
          goals_for = sum(goals_for),
          goals_against = sum(goals_against),
          xg_for = sum(xg, na.rm = TRUE),
          xg_against = sum(xg_against, na.rm = TRUE)
        )
    },

    # Objective assessment
    assess_objectives = function() {
      assessment <- list()

      # League position
      if (!is.null(self$target_position) && !is.null(self$league_position)) {
        assessment$league <- list(
          target = self$target_position,
          actual = self$league_position,
          achieved = self$league_position <= self$target_position,
          margin = self$target_position - self$league_position
        )
      }

      # Points analysis
      assessment$points <- list(
        total = self$points$points,
        ppg = self$points$points / (self$points$wins + self$points$draws + self$points$losses),
        home_ppg = self$calculate_home_away_ppg()$home,
        away_ppg = self$calculate_home_away_ppg()$away
      )

      # xG performance
      assessment$xg <- list(
        total_xg = self$points$xg_for,
        total_xga = self$points$xg_against,
        xg_diff = self$points$xg_for - self$points$xg_against,
        goals_vs_xg = self$points$goals_for - self$points$xg_for,
        assessment = case_when(
          self$points$goals_for > self$points$xg_for + 5 ~ "Overperformed xG",
          self$points$goals_for < self$points$xg_for - 5 ~ "Underperformed xG",
          TRUE ~ "Performed as expected"
        )
      )

      return(assessment)
    },

    calculate_home_away_ppg = function() {
      team_matches <- self$matches %>%
        filter(team == self$team)

      home <- team_matches %>%
        filter(venue == "Home") %>%
        summarise(ppg = (sum(result == "W") * 3 + sum(result == "D")) / n())

      away <- team_matches %>%
        filter(venue == "Away") %>%
        summarise(ppg = (sum(result == "W") * 3 + sum(result == "D")) / n())

      list(home = home$ppg, away = away$ppg)
    }
  )
)

cat("Season review framework initialized\n")

Squad Analysis

Understanding squad composition, utilization, and gaps is crucial for planning. This analysis identifies strengths, weaknesses, and priorities for improvement.

squad_analysis.R / squad_analysis.py
# Python: Comprehensive squad analysis
import pandas as pd
import numpy as np
from typing import Dict, List, Optional
from dataclasses import dataclass
from datetime import date

@dataclass
class SquadGap:
    """Identified squad gap."""
    position: str
    severity: str  # High, Medium, Low
    current_depth: int
    recommendation: str

class SquadAnalyzer:
    """Analyze squad composition and identify gaps."""

    def __init__(self, player_stats: pd.DataFrame,
                 contracts: Optional[pd.DataFrame] = None):
        self.player_stats = player_stats
        self.contracts = contracts

    def analyze_utilization(self) -> pd.DataFrame:
        """Analyze playing time distribution."""
        return self.player_stats.groupby("position_group").agg({
            "minutes": ["sum", "mean", "max", "count"],
            "player": "nunique"
        }).reset_index()

    def analyze_age_profile(self) -> pd.DataFrame:
        """Analyze squad age profile."""
        # Filter to regular players
        regulars = self.player_stats[self.player_stats["minutes"] >= 450].copy()

        # Categorize by age
        def age_group(age):
            if age <= 21:
                return "U21"
            elif age <= 24:
                return "22-24"
            elif age <= 28:
                return "25-28"
            elif age <= 31:
                return "29-31"
            return "32+"

        regulars["age_group"] = regulars["age"].apply(age_group)

        profile = regulars.groupby("age_group").agg({
            "player": "count",
            "minutes": "sum",
            "season_rating": "mean"
        }).reset_index()

        profile.columns = ["age_group", "n_players", "total_minutes", "avg_rating"]
        profile["pct_minutes"] = (
            profile["total_minutes"] / profile["total_minutes"].sum() * 100
        )

        return profile

    def analyze_depth(self) -> pd.DataFrame:
        """Analyze squad depth by position."""
        depth = self.player_stats.groupby("position_group").apply(
            lambda x: pd.Series({
                "starters": (x["minutes"] >= 2000).sum(),
                "rotational": ((x["minutes"] >= 900) & (x["minutes"] < 2000)).sum(),
                "fringe": ((x["minutes"] >= 450) & (x["minutes"] < 900)).sum(),
                "minimal": (x["minutes"] < 450).sum(),
                "total_players": len(x)
            })
        ).reset_index()

        # Calculate depth score
        depth["depth_score"] = (
            depth["starters"] * 3 +
            depth["rotational"] * 2 +
            depth["fringe"]
        )

        depth["assessment"] = depth["depth_score"].apply(
            lambda x: "Strong" if x >= 8 else ("Adequate" if x >= 5 else "Weak")
        )

        return depth

    def analyze_contracts(self) -> Optional[pd.DataFrame]:
        """Analyze contract situations."""
        if self.contracts is None:
            return None

        df = self.contracts.copy()
        df["years_remaining"] = (
            (pd.to_datetime(df["contract_expiry"]) - pd.Timestamp.now()).dt.days / 365
        )

        df["situation"] = df["years_remaining"].apply(
            lambda x: "Critical" if x <= 1 else ("Attention" if x <= 2 else "Secure")
        )

        return df.groupby("situation").agg({
            "player": "count",
            "market_value": "sum"
        }).reset_index()

    def identify_gaps(self) -> List[SquadGap]:
        """Identify squad gaps and priorities."""
        gaps = []
        depth = self.analyze_depth()

        # Position gaps
        for _, row in depth.iterrows():
            if row["assessment"] == "Weak":
                gaps.append(SquadGap(
                    position=row["position_group"],
                    severity="High",
                    current_depth=row["starters"] + row["rotational"],
                    recommendation=f"Urgent need for {row['position_group']} reinforcement"
                ))

        # Age gaps
        age_profile = self.analyze_age_profile()
        senior_pct = age_profile[
            age_profile["age_group"].isin(["29-31", "32+"])
        ]["pct_minutes"].sum()

        if senior_pct > 40:
            gaps.append(SquadGap(
                position="Squad-wide",
                severity="Medium",
                current_depth=0,
                recommendation="Prioritize younger signings - aging squad"
            ))

        return gaps

    def generate_report(self) -> Dict:
        """Generate complete squad analysis report."""
        return {
            "utilization": self.analyze_utilization(),
            "age_profile": self.analyze_age_profile(),
            "depth": self.analyze_depth(),
            "contracts": self.analyze_contracts(),
            "gaps": self.identify_gaps()
        }

print("Squad analyzer ready")
# R: Comprehensive squad analysis
library(tidyverse)

# Squad Analyzer
analyze_squad <- function(player_stats, contracts) {

  analysis <- list()

  # Playing time distribution
  analysis$utilization <- player_stats %>%
    group_by(position_group) %>%
    summarise(
      total_minutes = sum(minutes),
      players_used = n_distinct(player),
      avg_minutes = mean(minutes),
      top_player_minutes = max(minutes),
      concentration = max(minutes) / sum(minutes) * 100,  # % held by top player
      .groups = "drop"
    )

  # Age profile
  analysis$age_profile <- player_stats %>%
    filter(minutes >= 450) %>%  # Regular players
    mutate(
      age_group = case_when(
        age <= 21 ~ "U21",
        age <= 24 ~ "22-24",
        age <= 28 ~ "25-28",
        age <= 31 ~ "29-31",
        TRUE ~ "32+"
      )
    ) %>%
    group_by(age_group) %>%
    summarise(
      n_players = n(),
      total_minutes = sum(minutes),
      avg_rating = mean(season_rating, na.rm = TRUE),
      .groups = "drop"
    ) %>%
    mutate(
      pct_minutes = total_minutes / sum(total_minutes) * 100
    )

  # Squad depth by position
  analysis$depth <- player_stats %>%
    group_by(position_group) %>%
    summarise(
      starters = sum(minutes >= 2000),
      rotational = sum(minutes >= 900 & minutes < 2000),
      fringe = sum(minutes >= 450 & minutes < 900),
      minimal = sum(minutes < 450),
      total_players = n(),
      .groups = "drop"
    ) %>%
    mutate(
      depth_score = starters * 3 + rotational * 2 + fringe,
      depth_assessment = case_when(
        depth_score >= 8 ~ "Strong",
        depth_score >= 5 ~ "Adequate",
        TRUE ~ "Weak"
      )
    )

  # Contract situations
  if (!is.null(contracts)) {
    analysis$contracts <- contracts %>%
      mutate(
        years_remaining = as.numeric(difftime(contract_expiry, Sys.Date(), units = "days")) / 365,
        situation = case_when(
          years_remaining <= 1 ~ "Critical",
          years_remaining <= 2 ~ "Attention",
          TRUE ~ "Secure"
        )
      ) %>%
      group_by(situation) %>%
      summarise(
        n_players = n(),
        total_value = sum(market_value, na.rm = TRUE),
        .groups = "drop"
      )
  }

  return(analysis)
}

# Identify squad gaps
identify_squad_gaps <- function(squad_analysis, requirements) {

  gaps <- list()

  # Check depth by position
  depth <- squad_analysis$depth

  for (pos in unique(depth$position_group)) {
    pos_depth <- depth %>% filter(position_group == pos)

    if (pos_depth$depth_assessment == "Weak") {
      gaps[[pos]] <- list(
        severity = "High",
        current_starters = pos_depth$starters,
        current_rotation = pos_depth$rotational,
        recommendation = sprintf("Need to add %s depth", pos)
      )
    }
  }

  # Age concerns
  age_profile <- squad_analysis$age_profile
  senior_minutes <- age_profile %>%
    filter(age_group %in% c("29-31", "32+")) %>%
    summarise(pct = sum(pct_minutes))

  if (senior_minutes$pct > 40) {
    gaps$age <- list(
      severity = "Medium",
      issue = "Squad aging - high reliance on 29+ players",
      recommendation = "Prioritize younger signings for long-term"
    )
  }

  return(gaps)
}

cat("Squad analysis functions ready\n")

Player Development Review

Individual player reviews assess season performance, development trajectory, and future potential. This is particularly important for young players and high-value assets.

player_development.R / player_development.py
# Python: Player development assessment
import pandas as pd
import numpy as np
from typing import Dict, Optional
from dataclasses import dataclass

@dataclass
class PlayerDevelopment:
    """Player development trajectory."""
    minutes_change: int
    rating_change: float
    key_metric_changes: Dict[str, float]
    trajectory: str  # Improving, Stable, Declining

class PlayerReviewGenerator:
    """Generate individual player season reviews."""

    def generate_report(self, player_name: str,
                        current_stats: pd.DataFrame,
                        previous_stats: Optional[pd.DataFrame] = None) -> Dict:
        """Generate comprehensive player report card."""

        current = current_stats[current_stats["player"] == player_name]
        if current.empty:
            raise ValueError(f"Player not found: {player_name}")

        player = current.iloc[0]

        report = {
            "info": {
                "player": player_name,
                "age": player["age"],
                "position": player["position"],
                "minutes": player["minutes"],
                "appearances": player.get("appearances", 0)
            },
            "performance": self._assess_performance(player),
            "development": self._assess_development(player_name, player, previous_stats),
            "rankings": self._get_percentile_rankings(player),
            "assessment": self._generate_assessment(player)
        }

        return report

    def _assess_performance(self, player: pd.Series) -> Dict:
        """Assess season performance."""
        return {
            "goals": player.get("goals", 0),
            "assists": player.get("assists", 0),
            "xg": round(player.get("xg", 0), 2),
            "xa": round(player.get("xa", 0), 2),
            "goals_vs_xg": round(player.get("goals", 0) - player.get("xg", 0), 2),
            "overall_rating": player.get("season_rating", 6.0)
        }

    def _assess_development(self, player_name: str,
                            current: pd.Series,
                            previous: Optional[pd.DataFrame]) -> Optional[Dict]:
        """Compare with previous season."""
        if previous is None or previous.empty:
            return None

        prev = previous[previous["player"] == player_name]
        if prev.empty:
            return None

        last_season = prev.iloc[0]

        rating_change = current.get("season_rating", 6) - last_season.get("season_rating", 6)

        return {
            "minutes_change": current["minutes"] - last_season["minutes"],
            "rating_change": round(rating_change, 2),
            "trajectory": self._classify_trajectory(rating_change)
        }

    def _classify_trajectory(self, rating_change: float) -> str:
        """Classify development trajectory."""
        if rating_change > 0.3:
            return "Strong improvement"
        elif rating_change > 0:
            return "Slight improvement"
        elif rating_change < -0.3:
            return "Decline"
        return "Stable"

    def _get_percentile_rankings(self, player: pd.Series) -> Dict:
        """Get percentile rankings vs position."""
        return {
            "xg_percentile": player.get("xg_percentile", 50),
            "xa_percentile": player.get("xa_percentile", 50),
            "progressive_percentile": player.get("progressive_percentile", 50),
            "defensive_percentile": player.get("defensive_percentile", 50)
        }

    def _generate_assessment(self, player: pd.Series) -> Dict:
        """Generate overall assessment and recommendation."""
        age = player["age"]
        rating = player.get("season_rating", 6.0)
        value = player.get("market_value", 0)

        # Determine recommendation
        if age <= 23 and rating >= 6.5:
            recommendation = "Key prospect - increase playing time"
        elif age <= 23 and rating < 6.0:
            recommendation = "Consider loan for development"
        elif age >= 30 and rating < 6.5:
            recommendation = "Consider sale - declining asset"
        elif rating >= 7.5:
            recommendation = "Core player - prioritize extension"
        else:
            recommendation = "Maintain current role"

        return {
            "current_value": value,
            "projected_value": self._project_value(age, rating, value),
            "recommendation": recommendation
        }

    def _project_value(self, age: int, rating: float, current_value: float) -> float:
        """Project future market value."""
        # Age curve
        if age <= 22:
            age_mult = 1.3
        elif age <= 25:
            age_mult = 1.15
        elif age <= 28:
            age_mult = 1.0
        elif age <= 30:
            age_mult = 0.85
        else:
            age_mult = 0.7

        # Performance multiplier
        perf_mult = 1 + (rating - 6) / 4

        return round(current_value * age_mult * perf_mult, 1)

print("Player review generator ready")
# R: Player development assessment
library(tidyverse)

# Generate player season report card
generate_player_report <- function(player_name, current_season_stats,
                                    previous_seasons, expected_development) {

  current <- current_season_stats %>%
    filter(player == player_name)

  if (nrow(current) == 0) {
    stop(paste("Player not found:", player_name))
  }

  report <- list()

  # Basic info
  report$info <- list(
    player = player_name,
    age = current$age,
    position = current$position,
    minutes = current$minutes,
    appearances = current$appearances
  )

  # Performance metrics
  report$performance <- list(
    goals = current$goals,
    assists = current$assists,
    xg = current$xg,
    xa = current$xa,
    goals_minus_xg = current$goals - current$xg,
    assists_minus_xa = current$assists - current$xa,
    overall_rating = current$season_rating
  )

  # Year-over-year development
  if (!is.null(previous_seasons)) {
    previous <- previous_seasons %>%
      filter(player == player_name) %>%
      arrange(desc(season))

    if (nrow(previous) > 0) {
      last_season <- previous[1, ]

      report$development <- list(
        minutes_change = current$minutes - last_season$minutes,
        rating_change = current$season_rating - last_season$season_rating,
        xg_per90_change = (current$xg / current$minutes * 90) -
                          (last_season$xg / last_season$minutes * 90),
        trajectory = case_when(
          current$season_rating > last_season$season_rating + 0.3 ~ "Strong improvement",
          current$season_rating > last_season$season_rating ~ "Slight improvement",
          current$season_rating < last_season$season_rating - 0.3 ~ "Decline",
          TRUE ~ "Stable"
        )
      )
    }
  }

  # Percentile rankings (vs position)
  report$rankings <- list(
    xg_percentile = current$xg_percentile,
    xa_percentile = current$xa_percentile,
    progressive_percentile = current$progressive_percentile,
    defensive_percentile = current$defensive_percentile
  )

  # Future assessment
  report$assessment <- list(
    current_value = current$market_value,
    potential_value = estimate_future_value(current),
    recommendation = generate_player_recommendation(report)
  )

  return(report)
}

# Estimate future value
estimate_future_value <- function(player_data) {
  age <- player_data$age
  current_value <- player_data$market_value
  rating <- player_data$season_rating

  # Age curve factor
  age_factor <- case_when(
    age <= 22 ~ 1.3,
    age <= 25 ~ 1.15,
    age <= 28 ~ 1.0,
    age <= 30 ~ 0.85,
    TRUE ~ 0.7
  )

  # Performance factor
  perf_factor <- (rating - 6) / 4 + 1  # 1.0 for 6.0 rating, up to 1.5 for 8.0

  current_value * age_factor * perf_factor
}

# Generate recommendation
generate_player_recommendation <- function(report) {
  age <- report$info$age
  rating <- report$performance$overall_rating

  if (age <= 23 && rating >= 6.5) {
    return("Key development prospect - increase playing time")
  }
  if (age <= 23 && rating < 6.0) {
    return("Consider loan to aid development")
  }
  if (age >= 30 && rating < 6.5) {
    return("Consider selling - declining asset")
  }
  if (rating >= 7.5) {
    return("Core player - prioritize contract extension")
  }

  return("Maintain current role")
}

Transfer Planning & Priorities

Season reviews directly inform transfer strategy. By combining squad gaps, budget constraints, and market opportunities, we can generate data-driven transfer priorities.

transfer_planning.R / transfer_planning.py
# Python: Transfer priority generator
import pandas as pd
import numpy as np
from typing import Dict, List
from dataclasses import dataclass

@dataclass
class TransferTarget:
    """Potential transfer target."""
    player: str
    position: str
    age: int
    current_club: str
    market_value: float
    score: float
    fit_rating: float

@dataclass
class PositionPriority:
    """Transfer priority for a position."""
    position: str
    rank: int
    severity: str
    budget_allocation: float
    targets: List[TransferTarget]

class TransferPlanner:
    """Generate data-driven transfer priorities."""

    def __init__(self, base_budget: float):
        self.base_budget = base_budget

    def generate_priorities(self, squad_gaps: List,
                            market_data: pd.DataFrame,
                            outgoing_revenue: float = 0) -> Dict:
        """Generate complete transfer priority plan."""

        total_budget = self.base_budget + outgoing_revenue

        # Score and rank gaps
        gap_rankings = self._rank_gaps(squad_gaps)

        # Generate position priorities
        position_priorities = []
        remaining_budget = total_budget

        for i, gap in enumerate(gap_rankings):
            # Allocate budget based on severity
            allocation = self._calculate_allocation(
                gap.severity, remaining_budget, len(gap_rankings) - i
            )

            # Find targets within budget
            targets = self._find_targets(
                gap.position, allocation, market_data
            )

            position_priorities.append(PositionPriority(
                position=gap.position,
                rank=i + 1,
                severity=gap.severity,
                budget_allocation=allocation,
                targets=targets
            ))

            remaining_budget -= allocation

        return {
            "total_budget": total_budget,
            "priorities": position_priorities,
            "remaining_reserve": remaining_budget
        }

    def _rank_gaps(self, gaps: List) -> List:
        """Rank gaps by severity."""
        severity_order = {"High": 3, "Medium": 2, "Low": 1}
        return sorted(gaps, key=lambda x: severity_order.get(x.severity, 0), reverse=True)

    def _calculate_allocation(self, severity: str,
                              remaining: float, positions_left: int) -> float:
        """Calculate budget allocation for position."""
        pct = {"High": 0.40, "Medium": 0.30, "Low": 0.20}.get(severity, 0.15)
        return min(remaining * pct, remaining / positions_left * 1.5)

    def _find_targets(self, position: str, budget: float,
                      market_data: pd.DataFrame) -> List[TransferTarget]:
        """Find suitable targets for position."""
        candidates = market_data[
            (market_data["position_group"] == position) &
            (market_data["market_value"] <= budget) &
            (market_data["age"] <= 28)
        ].sort_values("score", ascending=False).head(5)

        return [
            TransferTarget(
                player=row["player"],
                position=row["position"],
                age=row["age"],
                current_club=row["team"],
                market_value=row["market_value"],
                score=row["score"],
                fit_rating=row.get("fit_rating", 0)
            )
            for _, row in candidates.iterrows()
        ]

    def format_plan(self, plan: Dict) -> str:
        """Format transfer plan for presentation."""
        output = f"""
=== TRANSFER PRIORITY PLAN ===
Total Budget: €{plan["total_budget"]:.1f}M
Reserve: €{plan["remaining_reserve"]:.1f}M

POSITION PRIORITIES:
{"-" * 40}
"""
        for p in plan["priorities"]:
            output += f"""
{p.rank}. {p.position} [{p.severity}]
   Budget: €{p.budget_allocation:.1f}M
   Top Targets:
"""
            for t in p.targets[:3]:
                output += f"   - {t.player} ({t.current_club}, {t.age}) - €{t.market_value:.1f}M\n"

        return output

print("Transfer planner ready")
# R: Transfer priority generator
library(tidyverse)

# Generate transfer priorities
generate_transfer_priorities <- function(squad_gaps, budget, market_data,
                                          outgoing_decisions) {

  priorities <- list()

  # Calculate available budget
  outgoing_revenue <- outgoing_decisions %>%
    filter(decision %in% c("Sell", "Release")) %>%
    summarise(revenue = sum(expected_fee, na.rm = TRUE))

  total_budget <- budget + outgoing_revenue$revenue

  # Prioritize gaps by severity
  gap_priorities <- tibble()

  for (pos in names(squad_gaps)) {
    gap <- squad_gaps[[pos]]
    gap_priorities <- bind_rows(gap_priorities, tibble(
      position = pos,
      severity = gap$severity,
      priority_score = case_when(
        gap$severity == "High" ~ 3,
        gap$severity == "Medium" ~ 2,
        TRUE ~ 1
      )
    ))
  }

  gap_priorities <- gap_priorities %>%
    arrange(desc(priority_score))

  # For each priority, find market targets
  for (i in 1:nrow(gap_priorities)) {
    pos <- gap_priorities$position[i]
    severity <- gap_priorities$severity[i]

    # Budget allocation based on priority
    allocation_pct <- case_when(
      severity == "High" ~ 0.40,
      severity == "Medium" ~ 0.25,
      TRUE ~ 0.15
    )

    position_budget <- total_budget * allocation_pct

    # Find suitable targets
    targets <- market_data %>%
      filter(position_group == pos) %>%
      filter(market_value <= position_budget) %>%
      filter(age <= 28) %>%  # Reasonable age limit
      arrange(desc(score)) %>%
      head(5)

    priorities[[pos]] <- list(
      priority_rank = i,
      severity = severity,
      budget_allocation = position_budget,
      top_targets = targets
    )
  }

  return(list(
    total_budget = total_budget,
    gap_priorities = gap_priorities,
    position_priorities = priorities
  ))
}

# Format transfer plan
format_transfer_plan <- function(priorities) {
  cat(sprintf("\n=== TRANSFER PRIORITY PLAN ===\n"))
  cat(sprintf("Total Budget: €%.1fM\n\n", priorities$total_budget))

  cat("POSITION PRIORITIES:\n")
  cat(paste(rep("-", 40), collapse = ""), "\n")

  for (pos in names(priorities$position_priorities)) {
    p <- priorities$position_priorities[[pos]]
    cat(sprintf("\n%d. %s [%s]\n", p$priority_rank, pos, p$severity))
    cat(sprintf("   Budget: €%.1fM\n", p$budget_allocation))

    if (nrow(p$top_targets) > 0) {
      cat("   Top Targets:\n")
      for (j in 1:min(3, nrow(p$top_targets))) {
        t <- p$top_targets[j, ]
        cat(sprintf("   - %s (%s, %d) - €%.1fM\n",
                    t$player, t$team, t$age, t$market_value))
      }
    }
  }
}

Practice Exercises

Exercise 1: Complete Season Review

Using FBref data, conduct a complete season review for a top-5 league club. Include results analysis, xG performance, monthly trends, and comparison to pre-season objectives.

Exercise 2: Squad Gap Analysis

Analyze a club's squad depth, age profile, and contract situations. Identify the top 3 positions needing reinforcement and justify your prioritization with data.

Exercise 3: Transfer Window Simulation

Given a €50M budget and identified squad gaps, create a complete transfer plan. Identify 3-5 realistic targets per position, estimate feasibility, and build a prioritized shortlist with alternatives.

Summary

Key Takeaways
  • Structured framework: Season reviews cover results, performance, squad, and planning
  • xG analysis: Compare actual results to expected to assess luck vs skill
  • Squad depth: Quantify depth by position to identify vulnerability
  • Age profiling: Balance youth development with experience
  • Data-driven priorities: Let squad gaps and budget inform transfer targets
Season Review Timeline
Week 1-2 Results analysis, xG review, monthly breakdown
Week 2-3 Individual player reviews, development assessments
Week 3-4 Squad gap analysis, depth assessment
Week 4-5 Transfer priorities, target identification
Week 5-6 Board presentation, budget allocation