diff --git a/README.md b/README.md index eb417cf..c834b52 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ player data from SofaScore, FotMob, Transfermarkt, KBStats, and Analyst. KBStats last-five-match-slot analysis. - `notebooks/05_predicted_lineups`: live predicted and confirmed lineup collectors. +- `notebooks/07_squad_optimisation`: optimizer notebooks for Bundesliga Arena + and KickbaseKIS Arena, plus `manually_create_lineup.ipynb` for manual entry. - `notebooks/tests`: diagnostic notebooks that are not production pipeline steps. - `data/reference/kickbase`: curated Kickbase scoring and event-frequency rules. - `outputs/sofascore`: SofaScore match IDs, odds, team reference, form, team @@ -32,9 +34,15 @@ player data from SofaScore, FotMob, Transfermarkt, KBStats, and Analyst. and are not resolved to canonical identities. - `outputs/kbstats`: KBStats player snapshots. - `outputs/derived`: multi-source normalized outputs. +- `outputs/optimized_squad`: timestamped optimizer exports. +- `outputs/selected_lineups`: canonical per-arena selected-lineup JSON snapshots. + A newer confirmed selection replaces only that arena's file. - `archive`: superseded notebooks, historical data, and any unique recovered Jupyter checkpoints. - `project_paths.py`: the authoritative filesystem interface used by notebooks. +- `selected_lineups.py`: shared selected-lineup persistence and replacement prompts. +- `manual_lineup_helpers.py`: shared non-solver player-pool, rule, and validation + logic used by the manual lineup notebook. Generated JSON, CSV, and debug HTML must be written through `project_paths.py`; do not write generated files beside a notebook or into the project root. @@ -49,6 +57,12 @@ python -m pip install -r requirements.txt jupyter lab ``` +Run the automated checks with: + +```powershell +python -m pytest +``` + The existing `.venv` stays at the root because moving a virtual environment can invalidate absolute paths stored inside it. The scraping notebooks expect a compatible Chrome installation; their current configuration targets Chrome 150. @@ -61,7 +75,9 @@ are intentionally excluded because they are machine-specific or reproducible. Do not commit credentials, API tokens, browser profiles, cookies, screenshots, or other sensitive local material. Keep each code change and its related output -refresh in a small, descriptive commit. +refresh in a small, descriptive commit. A selected-lineup file represents the +current canonical choice for one arena; replacing it is intentional rather than +creating a timestamped history. ## Recommended pipeline @@ -75,6 +91,10 @@ refresh in a small, descriptive commit. the newest valid timestamped file in `outputs/sofascore/team_form`. 6. Run `notebooks/05_predicted_lineups/01_rotowire_lineups.ipynb` whenever a fresh RotoWire Bundesliga lineup snapshot is needed. +7. Create the required expected-points score file in `notebooks/06_score_creation`. +8. Run an optimizer in `notebooks/07_squad_optimisation`, or run + `manually_create_lineup.ipynb` to enter a formation, players, and captain + interactively. A confirmed selection is stored in `outputs/selected_lineups`. The Transfermarkt, KBStats, and FotMob collectors can run independently of the SofaScore team-reference pipeline when their own inputs are available. @@ -97,6 +117,8 @@ SofaScore team-reference pipeline when their own inputs are available. | High-rated players | Latest team-form snapshot | `outputs/sofascore/high_rated_players` | | KBStats high-average players | Latest KBStats player snapshot | `outputs/derived/kbstats_high_average_players` | | KBStats last-five high-average players | Latest KBStats player snapshot | `outputs/derived/kbstats_last_5_high_average_players` | +| Squad optimizers | Latest expected-points CSV and matchday matches | Timestamped optimizer CSV and optional selected-lineup JSON | +| Manual lineup | Latest expected-points CSV, matchday matches, arena, formation, and player choices | Optional canonical JSON in `outputs/selected_lineups` | Missing required inputs raise errors that include the exact expected path. Start Jupyter from the project root for the simplest path discovery; VS Code notebook diff --git a/manual_lineup_helpers.py b/manual_lineup_helpers.py new file mode 100644 index 0000000..a122612 --- /dev/null +++ b/manual_lineup_helpers.py @@ -0,0 +1,1184 @@ +# Import the libraries required by this notebook step. +from __future__ import annotations + +import json +import math +import re +import unicodedata +import warnings +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any + +import pandas as pd +from IPython.display import display + +# Set workflow configuration value: PROJECT_ROOT. +PROJECT_ROOT = Path(__file__).resolve().parent +# Set workflow configuration value: EXPECTED_POINTS_DIR. +EXPECTED_POINTS_DIR = PROJECT_ROOT / 'outputs' / 'expected_points' +# Set workflow configuration value: SOFASCORE_MATCH_DIR. +SOFASCORE_MATCH_DIR = PROJECT_ROOT / 'outputs' / 'sofascore' / 'match_ids' +# Set workflow configuration value: FOTMOB_MATCH_DIR. +FOTMOB_MATCH_DIR = PROJECT_ROOT / 'outputs' / 'fotmob' / 'match_ids' +# Set workflow configuration value: OPTIMIZED_SQUAD_DIR. +OPTIMIZED_SQUAD_DIR = PROJECT_ROOT / 'outputs' / 'optimized_squad' +# Set workflow configuration value: BUDGET_EUR. +BUDGET_EUR = 250_000_000 +# Set workflow configuration value: MIN_PLAUSIBLE_PLAYER_VALUE_EUR. +MIN_PLAUSIBLE_PLAYER_VALUE_EUR = 100_000 +# Set workflow configuration value: MAX_PLAUSIBLE_PLAYER_VALUE_EUR. +MAX_PLAUSIBLE_PLAYER_VALUE_EUR = 500_000_000 + +# Set workflow configuration value: ALLOWED_FORMATIONS. +ALLOWED_FORMATIONS = { + '4-4-2': {'DEF': 4, 'MID': 4, 'FOR': 2}, + '4-2-4': {'DEF': 4, 'MID': 2, 'FOR': 4}, + '3-4-3': {'DEF': 3, 'MID': 4, 'FOR': 3}, + '4-3-3': {'DEF': 4, 'MID': 3, 'FOR': 3}, + '5-3-2': {'DEF': 5, 'MID': 3, 'FOR': 2}, + '3-5-2': {'DEF': 3, 'MID': 5, 'FOR': 2}, + '5-4-1': {'DEF': 5, 'MID': 4, 'FOR': 1}, + '4-5-1': {'DEF': 4, 'MID': 5, 'FOR': 1}, + '3-6-1': {'DEF': 3, 'MID': 6, 'FOR': 1}, + '5-2-3': {'DEF': 5, 'MID': 2, 'FOR': 3}, +} + +# Set workflow configuration value: COLUMN_ALIASES. +COLUMN_ALIASES = { + 'player_id': {'id', 'player_id', 'playerId'}, + 'player_name': {'name', 'player_name', 'full_name', 'fullName'}, + 'score': {'score'}, + 'market_value': {'marketValue', 'market_value', 'ingame_value', 'in_game_value'}, + 'club': {'teamId', 'team_id', 'club_id', 'club', 'team'}, + 'position': {'position', 'player_position', 'ingame_position', 'kbstats_position'}, +} + +# Set workflow configuration value: POSITION_ALIASES. +POSITION_ALIASES = { + '1': 'GK', 'gk': 'GK', 'goalkeeper': 'GK', 'keeper': 'GK', + '2': 'DEF', 'def': 'DEF', 'defender': 'DEF', 'defence': 'DEF', 'defense': 'DEF', + '3': 'MID', 'mid': 'MID', 'midfielder': 'MID', 'midfield': 'MID', + '4': 'FOR', 'for': 'FOR', 'fwd': 'FOR', 'fw': 'FOR', 'forward': 'FOR', + 'striker': 'FOR', 'attacker': 'FOR', +} + +# Set workflow configuration value: KB_TEAM_ID_TO_KEY. +KB_TEAM_ID_TO_KEY = { + 2: 'bayern', 3: 'dortmund', 4: 'frankfurt', 5: 'freiburg', + 6: 'hamburg', 7: 'leverkusen', 8: 'schalke', 9: 'stuttgart', + 10: 'bremen', 13: 'augsburg', 14: 'hoffenheim', 15: 'gladbach', + 18: 'mainz', 28: 'koeln', 29: 'paderborn', 40: 'union', + 43: 'leipzig', 77: 'elversberg', +} + +# Set workflow configuration value: TEAM_DISPLAY_NAMES. +TEAM_DISPLAY_NAMES = { + 'bayern': 'FC Bayern München', + 'stuttgart': 'VfB Stuttgart', + 'koeln': '1. FC Köln', + 'hoffenheim': 'TSG Hoffenheim', + 'union': '1. FC Union Berlin', + 'frankfurt': 'Eintracht Frankfurt', + 'mainz': '1. FSV Mainz 05', + 'paderborn': 'SC Paderborn 07', + 'dortmund': 'Borussia Dortmund', + 'hamburg': 'Hamburger SV', + 'leipzig': 'RB Leipzig', + 'gladbach': 'Borussia Mönchengladbach', + 'freiburg': 'SC Freiburg', + 'bremen': 'SV Werder Bremen', + 'elversberg': 'SV 07 Elversberg', + 'leverkusen': 'Bayer 04 Leverkusen', + 'augsburg': 'FC Augsburg', + 'schalke': 'FC Schalke 04', +} + +# Set workflow configuration value: TEAM_ALIASES. +TEAM_ALIASES = { + 'bayern': {'FC Bayern München', 'Bayern München', 'Bayern Munich'}, + 'stuttgart': {'VfB Stuttgart'}, + 'koeln': {'1. FC Köln', 'FC Köln', '1. FC Cologne', 'FC Cologne'}, + 'hoffenheim': {'TSG Hoffenheim', 'Hoffenheim'}, + 'union': {'1. FC Union Berlin', 'Union Berlin'}, + 'frankfurt': {'Eintracht Frankfurt'}, + 'mainz': {'1. FSV Mainz 05', 'Mainz 05'}, + 'paderborn': {'SC Paderborn 07', 'SC Paderborn', 'Paderborn'}, + 'dortmund': {'Borussia Dortmund'}, + 'hamburg': {'Hamburger SV', 'Hamburg'}, + 'leipzig': {'RB Leipzig'}, + 'gladbach': {"Borussia M'gladbach", 'Borussia Mönchengladbach', 'Mönchengladbach'}, + 'freiburg': {'SC Freiburg', 'Freiburg'}, + 'bremen': {'SV Werder Bremen', 'Werder Bremen'}, + 'elversberg': {'SV 07 Elversberg', 'SV Elversberg', 'Elversberg'}, + 'leverkusen': {'Bayer 04 Leverkusen', 'Bayer Leverkusen'}, + 'augsburg': {'FC Augsburg', 'Augsburg'}, + 'schalke': {'FC Schalke 04', 'Schalke 04'}, +} + +# Set workflow configuration value: TIMESTAMP_PATTERN. +TIMESTAMP_PATTERN = r'\d{8}_\d{6}_[+-]\d{4}' +# Set workflow configuration value: EXPECTED_POINTS_FILENAME_RE. +EXPECTED_POINTS_FILENAME_RE = re.compile( + rf'^expected_points_(?P{TIMESTAMP_PATTERN})_' + rf'(?P.+)_(?P{TIMESTAMP_PATTERN})\.csv$' +) +# Set workflow configuration value: TIMESTAMP_FORMAT. +TIMESTAMP_FORMAT = '%Y%m%d_%H%M%S_%z' + +# Process each available item while preserving the current workflow state. +for formation_name, counts in ALLOWED_FORMATIONS.items(): + # Validate the input before continuing with later processing. + if sum(counts.values()) != 10: + raise ValueError(f'Formation {formation_name} does not contain 10 outfield players.') + +@dataclass(frozen=True) +# Define Score Metadata to keep related behaviour explicit. +class ScoreMetadata: + path: Path + retrieval_timestamp: str + method: str + metric_creation_timestamp: str + retrieval_datetime: datetime + metric_creation_datetime: datetime + +@dataclass(frozen=True) +# Define Match Record to keep related behaviour explicit. +class MatchRecord: + match_id: int + home_team_id: int + home_team_name: str + away_team_id: int + away_team_name: str + +@dataclass(frozen=True) +# Define Mapped Match to keep related behaviour explicit. +class MappedMatch: + record: MatchRecord + home_key: str + away_key: str + +@dataclass +# Define Prepared Data to keep related behaviour explicit. +class PreparedData: + df: pd.DataFrame + original_columns: list[str] + columns: dict[str, str] + positions: pd.Series + score_numeric: pd.Series + score_units: pd.Series + score_scale: int + value_numeric: pd.Series + value_eur: pd.Series + value_unit: str + team_keys: pd.Series + team_raw_to_key: dict[str, str] + +@dataclass(frozen=True) +# Define Formation Result to keep related behaviour explicit. +class FormationResult: + formation: str + status: str + chosen_indices: tuple[int, ...] = () + captain_index: int | None = None + total_score_units: int | None = None + total_value_eur: int | None = None + +# Parse and validate score filename for reuse in the workflow. +def parse_score_filename(path: Path) -> ScoreMetadata: + match = EXPECTED_POINTS_FILENAME_RE.fullmatch(path.name) + # Validate the input before continuing with later processing. + if match is None: + raise ValueError('filename does not match the required timestamp structure') + + retrieval_timestamp = match.group('retrieval') + method = match.group('method') + metric_timestamp = match.group('metric') + # Validate the input before continuing with later processing. + if not method.strip(): + raise ValueError('method is empty') + + # Handle expected failures with a clear, actionable message. + try: + retrieval_datetime = datetime.strptime(retrieval_timestamp, TIMESTAMP_FORMAT) + metric_datetime = datetime.strptime(metric_timestamp, TIMESTAMP_FORMAT) + except ValueError as exc: + raise ValueError(f'unparseable filename timestamp: {exc}') from exc + + return ScoreMetadata( + path=path, + retrieval_timestamp=retrieval_timestamp, + method=method, + metric_creation_timestamp=metric_timestamp, + retrieval_datetime=retrieval_datetime, + metric_creation_datetime=metric_datetime, + ) + + +# Find the latest score input for reuse in the workflow. +def discover_latest_score(directory: Path) -> ScoreMetadata: + # Validate the input before continuing with later processing. + if not directory.is_dir(): + raise FileNotFoundError(f'Score-input directory does not exist: {directory}') + + candidates = sorted(directory.glob('expected_points_*.csv')) + # Validate the input before continuing with later processing. + if not candidates: + raise FileNotFoundError(f'No score-input files (expected_points_*.csv) found in: {directory}') + + valid: list[ScoreMetadata] = [] + # Process each available item while preserving the current workflow state. + for path in candidates: + # Handle expected failures with a clear, actionable message. + try: + valid.append(parse_score_filename(path)) + except ValueError as exc: + warnings.warn(f'Ignoring malformed score-input file {path.name!r}: {exc}') + + # Validate the input before continuing with later processing. + if not valid: + raise FileNotFoundError( + f'No valid score-input CSV remains in {directory}; check filename timestamps.' + ) + + latest_datetime = max(item.metric_creation_datetime for item in valid) + newest = [item for item in valid if item.metric_creation_datetime == latest_datetime] + # Validate the input before continuing with later processing. + if len(newest) != 1: + names = ', '.join(item.path.name for item in newest) + raise ValueError( + 'Score-input selection is ambiguous: multiple files have the latest ' + f'metric-creation timestamp {latest_datetime.isoformat()}: {names}' + ) + return newest[0] + + +# Normalize column name for reuse in the workflow. +def normalize_column_name(value: str) -> str: + return re.sub(r'[^a-z0-9]+', '', str(value).casefold()) + + +# Handle required columns for reuse in the workflow. +def identify_required_columns(columns: list[str]) -> dict[str, str]: + normalized_actual: dict[str, list[str]] = {} + # Process each available item while preserving the current workflow state. + for column in columns: + normalized_actual.setdefault(normalize_column_name(column), []).append(column) + + resolved: dict[str, str] = {} + # Process each available item while preserving the current workflow state. + for logical_name, aliases in COLUMN_ALIASES.items(): + normalized_aliases = {normalize_column_name(alias) for alias in aliases} + matches = [ + column + for normalized, actual_columns in normalized_actual.items() + if normalized in normalized_aliases + for column in actual_columns + ] + # Validate the input before continuing with later processing. + if len(matches) != 1: + available = ', '.join(repr(column) for column in columns) + raise ValueError( + f'Could not identify exactly one {logical_name!r} column. ' + f'Matches={matches}; available columns=[{available}]' + ) + resolved[logical_name] = matches[0] + return resolved + + +# Handle row numbers for reuse in the workflow. +def source_row_numbers(indices: list[int], limit: int = 10) -> str: + rows = [str(index + 2) for index in indices[:limit]] + suffix = ' ...' if len(indices) > limit else '' + return ', '.join(rows) + suffix + + +# Parse and validate decimal series for reuse in the workflow. +def parse_decimal_series(series: pd.Series, label: str) -> tuple[list[Decimal], pd.Series]: + decimals: list[Decimal] = [] + missing: list[int] = [] + invalid: list[int] = [] + + # Process each available item while preserving the current workflow state. + for index, raw_value in series.items(): + text = str(raw_value).strip() + if not text: + missing.append(int(index)) + decimals.append(Decimal('NaN')) + continue + # Handle expected failures with a clear, actionable message. + try: + parsed = Decimal(text) + except InvalidOperation: + invalid.append(int(index)) + decimals.append(Decimal('NaN')) + continue + if not parsed.is_finite(): + invalid.append(int(index)) + decimals.append(parsed) + + # Validate the input before continuing with later processing. + if missing: + raise ValueError( + f'{label} contains missing values at source CSV row(s): ' + f'{source_row_numbers(missing)}' + ) + # Validate the input before continuing with later processing. + if invalid: + samples = [repr(series.loc[index]) for index in invalid[:5]] + raise ValueError( + f'{label} contains non-numeric or non-finite values at source CSV row(s) ' + f'{source_row_numbers(invalid)}; sample values={samples}' + ) + + numeric = pd.Series([float(item) for item in decimals], index=series.index, dtype='float64') + return decimals, numeric + + +# Integerize score values for reuse in the workflow. +def integerize_scores(decimals: list[Decimal], index: pd.Index) -> tuple[pd.Series, int]: + normalized = [item.normalize() if item != 0 else Decimal(0) for item in decimals] + decimal_places = max(max(0, -item.as_tuple().exponent) for item in normalized) + scale = 10 ** decimal_places + units: list[int] = [] + # Process each available item while preserving the current workflow state. + for item in decimals: + scaled = item * scale + # Validate the input before continuing with later processing. + if scaled != scaled.to_integral_value(): + raise ValueError(f'Could not integerize score value {item!r} exactly.') + units.append(int(scaled)) + return pd.Series(units, index=index, dtype=object), scale + + +# Normalize market values to euros for reuse in the workflow. +def normalize_market_values_to_euros( + decimals: list[Decimal], index: pd.Index +) -> tuple[pd.Series, str]: + # Validate the input before continuing with later processing. + if any(item <= 0 for item in decimals): + bad = [position for position, item in enumerate(decimals) if item <= 0] + raise ValueError( + 'Market values must be positive; invalid source CSV row(s): ' + f'{source_row_numbers(bad)}' + ) + + interpretations = ( + ('euros', Decimal(1)), + ('thousands of euros', Decimal(1_000)), + ('millions of euros', Decimal(1_000_000)), + ) + plausible: list[tuple[str, list[int]]] = [] + diagnostics: list[str] = [] + + # Process each available item while preserving the current workflow state. + for unit_name, factor in interpretations: + scaled = [item * factor for item in decimals] + if not all(item == item.to_integral_value() for item in scaled): + diagnostics.append(f'{unit_name}: would produce fractional euros') + continue + integer_values = [int(item) for item in scaled] + minimum = min(integer_values) + maximum = max(integer_values) + # Choose the appropriate path for the current data state. + if ( + minimum >= MIN_PLAUSIBLE_PLAYER_VALUE_EUR + and maximum <= MAX_PLAUSIBLE_PLAYER_VALUE_EUR + ): + plausible.append((unit_name, integer_values)) + else: + diagnostics.append( + f'{unit_name}: interpreted range €{minimum:,} to €{maximum:,} is outside ' + f'€{MIN_PLAUSIBLE_PLAYER_VALUE_EUR:,} to ' + f'€{MAX_PLAUSIBLE_PLAYER_VALUE_EUR:,}' + ) + + # Validate the input before continuing with later processing. + if len(plausible) != 1: + raw_min = min(decimals) + raw_max = max(decimals) + raise ValueError( + 'Market-value unit is ambiguous or inconsistent. Expected exactly one plausible ' + f'interpretation for raw range {raw_min} to {raw_max}; candidates=' + f'{[item[0] for item in plausible]}; diagnostics={diagnostics}' + ) + + unit_name, integer_values = plausible[0] + return pd.Series(integer_values, index=index, dtype=object), unit_name + + +# Normalize position for reuse in the workflow. +def normalize_position(raw_value: Any) -> str: + text = str(raw_value).strip().casefold() + # Handle expected failures with a clear, actionable message. + try: + numeric = Decimal(text) + except InvalidOperation: + numeric = None + if numeric is not None and numeric.is_finite() and numeric == numeric.to_integral_value(): + text = str(int(numeric)) + # Validate the input before continuing with later processing. + if text not in POSITION_ALIASES: + raise ValueError(f'unsupported KBStats position value {raw_value!r}') + return POSITION_ALIASES[text] + + +# Load and validate player data for reuse in the workflow. +def load_and_validate_player_data(path: Path) -> dict[str, Any]: + # Handle expected failures with a clear, actionable message. + try: + df = pd.read_csv(path, dtype=str, keep_default_na=False, encoding='utf-8-sig') + except pd.errors.EmptyDataError as exc: + raise ValueError(f'Score CSV is empty: {path}') from exc + except (OSError, UnicodeError, pd.errors.ParserError) as exc: + raise ValueError(f'Could not read score CSV {path}: {exc}') from exc + + # Validate the input before continuing with later processing. + if df.empty: + raise ValueError(f'Score CSV contains no player rows: {path}') + df = df.reset_index(drop=True) + original_columns = list(df.columns) + columns = identify_required_columns(original_columns) + + id_values = df[columns['player_id']].astype(str).str.strip() + missing_ids = id_values.index[id_values.eq('')].tolist() + # Validate the input before continuing with later processing. + if missing_ids: + raise ValueError(f'Missing player IDs at source CSV row(s): {source_row_numbers(missing_ids)}') + duplicate_ids = id_values[id_values.duplicated(keep=False)] + # Validate the input before continuing with later processing. + if not duplicate_ids.empty: + raise ValueError( + f'Player IDs must be unique; duplicates={sorted(duplicate_ids.unique().tolist())}' + ) + + names = df[columns['player_name']].astype(str).str.strip() + missing_names = names.index[names.eq('')].tolist() + # Validate the input before continuing with later processing. + if missing_names: + raise ValueError( + f'Missing player full names at source CSV row(s): {source_row_numbers(missing_names)}' + ) + + raw_clubs = df[columns['club']].astype(str).str.strip() + missing_clubs = raw_clubs.index[raw_clubs.eq('')].tolist() + # Validate the input before continuing with later processing. + if missing_clubs: + raise ValueError( + f'Missing club/team values at source CSV row(s): {source_row_numbers(missing_clubs)}' + ) + + positions: list[str] = [] + position_errors: list[str] = [] + # Process each available item while preserving the current workflow state. + for index, raw_value in df[columns['position']].items(): + # Handle expected failures with a clear, actionable message. + try: + positions.append(normalize_position(raw_value)) + except ValueError as exc: + position_errors.append(f'row {index + 2}: {exc}') + positions.append('') + # Validate the input before continuing with later processing. + if position_errors: + raise ValueError('Unsupported positions: ' + '; '.join(position_errors[:10])) + position_series = pd.Series(positions, index=df.index, dtype='string') + + score_decimals, score_numeric = parse_decimal_series( + df[columns['score']], 'Score' + ) + score_units, score_scale = integerize_scores(score_decimals, df.index) + value_decimals, value_numeric = parse_decimal_series( + df[columns['market_value']], 'Market values' + ) + value_eur, value_unit = normalize_market_values_to_euros(value_decimals, df.index) + + position_counts = position_series.value_counts().to_dict() + constructible = [ + name + for name, counts in ALLOWED_FORMATIONS.items() + if position_counts.get('GK', 0) >= 1 + and all(position_counts.get(position, 0) >= required for position, required in counts.items()) + ] + # Validate the input before continuing with later processing. + if len(df) < 11 or not constructible: + raise ValueError( + 'Insufficient eligible players to construct any permitted formation. ' + f'Rows={len(df)}; position counts={position_counts}' + ) + + return { + 'df': df, + 'original_columns': original_columns, + 'columns': columns, + 'positions': position_series, + 'score_numeric': score_numeric, + 'score_units': score_units, + 'score_scale': score_scale, + 'value_numeric': value_numeric, + 'value_eur': value_eur, + 'value_unit': value_unit, + } + +# Handle matchday for reuse in the workflow. +def request_matchday() -> int: + # Handle expected failures with a clear, actionable message. + try: + matchday = int(input('Enter the matchday to optimise the squad for: ')) + except ValueError as exc: + raise ValueError('Matchday must be entered as a positive integer.') from exc + # Validate the input before continuing with later processing. + if matchday < 1: + raise ValueError(f'Matchday must be a positive integer; received {matchday}.') + return matchday + + +# Handle positive integer for reuse in the workflow. +def require_positive_integer(value_to_check: Any, label: str) -> int: + # Validate the input before continuing with later processing. + if isinstance(value_to_check, bool): + raise ValueError(f'{label} must be a positive integer, not boolean.') + # Validate the input before continuing with later processing. + if isinstance(value_to_check, int): + parsed = value_to_check + # Validate the input before continuing with later processing. + elif isinstance(value_to_check, str) and value_to_check.strip().isdigit(): + parsed = int(value_to_check.strip()) + # Validate the input before continuing with later processing. + elif isinstance(value_to_check, float) and value_to_check.is_integer(): + parsed = int(value_to_check) + else: + raise ValueError(f'{label} must be a positive integer; received {value_to_check!r}.') + # Validate the input before continuing with later processing. + if parsed < 1: + raise ValueError(f'{label} must be greater than zero; received {parsed}.') + return parsed + + +# Handle field for reuse in the workflow. +def unique_field(record: dict[str, Any], aliases: tuple[str, ...], label: str) -> Any: + present = [(key, record[key]) for key in aliases if key in record and record[key] is not None] + # Validate the input before continuing with later processing. + if not present: + raise ValueError(f'Missing {label}; accepted fields={aliases}.') + first_value = present[0][1] + # Validate the input before continuing with later processing. + if any(candidate != first_value for _, candidate in present[1:]): + raise ValueError(f'Conflicting {label} fields: {present}.') + return first_value + + +# Extract match list for reuse in the workflow. +def extract_match_list(payload: Any) -> list[Any]: + if isinstance(payload, list): + return payload + # Validate the input before continuing with later processing. + if not isinstance(payload, dict): + raise ValueError('Match JSON must contain a top-level list or object wrapper.') + list_fields = [(key, payload[key]) for key in ('matches', 'fixtures', 'events') if isinstance(payload.get(key), list)] + # Validate the input before continuing with later processing. + if len(list_fields) != 1: + raise ValueError( + 'Match JSON object must contain exactly one list field named matches, fixtures, ' + f'or events; found {[key for key, _ in list_fields]}.' + ) + return list_fields[0][1] + + +# Extract team side for reuse in the workflow. +def extract_team_side(record: dict[str, Any], side: str, match_number: int) -> tuple[int, str]: + nested_candidates = [ + record[key] + for key in (side, f'{side}Team', f'{side}_team') + if isinstance(record.get(key), dict) + ] + # Validate the input before continuing with later processing. + if len(nested_candidates) > 1 and any(item != nested_candidates[0] for item in nested_candidates[1:]): + raise ValueError(f'Match {match_number} has conflicting nested {side}-team objects.') + + # Choose the appropriate path for the current data state. + if nested_candidates: + team_object = nested_candidates[0] + name = unique_field(team_object, ('name', 'team', 'team_name', 'teamName'), f'{side} team name') + team_id = unique_field(team_object, ('id', 'team_id', 'teamId'), f'{side} team ID') + else: + name = unique_field( + record, + (f'{side}_team', f'{side}Team', f'{side}_team_name', f'{side}TeamName'), + f'{side} team name', + ) + team_id = unique_field( + record, + (f'{side}_team_id', f'{side}TeamId', f'{side}TeamID'), + f'{side} team ID', + ) + + # Validate the input before continuing with later processing. + if not isinstance(name, str) or not name.strip(): + raise ValueError(f'Match {match_number} {side} team name is empty or non-text.') + return require_positive_integer(team_id, f'Match {match_number} {side} team ID'), name.strip() + + +# Parse and validate match records for reuse in the workflow. +def parse_match_records(payload: Any) -> list[MatchRecord]: + raw_matches = extract_match_list(payload) + # Validate the input before continuing with later processing. + if not raw_matches: + raise ValueError('Match JSON contains no matches.') + + matches: list[MatchRecord] = [] + seen_match_ids: set[int] = set() + provider_team_names: dict[int, str] = {} + + # Process each available item while preserving the current workflow state. + for match_number, raw_match in enumerate(raw_matches, start=1): + # Validate the input before continuing with later processing. + if not isinstance(raw_match, dict): + raise ValueError(f'Match {match_number} is not a JSON object.') + match_id = require_positive_integer( + unique_field(raw_match, ('match_id', 'matchId', 'id'), 'match ID'), + f'Match {match_number} match ID', + ) + # Validate the input before continuing with later processing. + if match_id in seen_match_ids: + raise ValueError(f'Duplicate match ID in match JSON: {match_id}.') + seen_match_ids.add(match_id) + + home_id, home_name = extract_team_side(raw_match, 'home', match_number) + away_id, away_name = extract_team_side(raw_match, 'away', match_number) + # Validate the input before continuing with later processing. + if home_id == away_id: + raise ValueError(f'Match {match_id} uses the same provider team ID for both sides.') + + # Process each available item while preserving the current workflow state. + for team_id, team_name in ((home_id, home_name), (away_id, away_name)): + normalized = normalize_team_name(team_name) + previous = provider_team_names.get(team_id) + # Validate the input before continuing with later processing. + if previous is not None and previous != normalized: + raise ValueError( + f'Provider team ID {team_id} has conflicting names in the match JSON.' + ) + provider_team_names[team_id] = normalized + + matches.append(MatchRecord(match_id, home_id, home_name, away_id, away_name)) + return matches + + +# Load matchday matches for reuse in the workflow. +def load_matchday_matches(matchday: int) -> tuple[list[MatchRecord], str, Path]: + attempts = ( + ('SofaScore', SOFASCORE_MATCH_DIR / f'match_ids_{matchday}.json'), + ('FotMob', FOTMOB_MATCH_DIR / f'match_ids_{matchday}_fotmob.json'), + ) + errors: list[str] = [] + + # Process each available item while preserving the current workflow state. + for source_name, path in attempts: + # Handle expected failures with a clear, actionable message. + try: + payload = json.loads(path.read_text(encoding='utf-8-sig')) + matches = parse_match_records(payload) + except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError) as exc: + errors.append(f'{source_name}: {path} -> {type(exc).__name__}: {exc}') + if source_name == 'SofaScore': + print(f'Warning: SofaScore match file could not be used ({exc}); trying FotMob.') + continue + print(f'Match source used: {source_name} ({path})') + return matches, source_name, path + + attempted_paths = '\n'.join(f' - {path}' for _, path in attempts) + error_text = '\n'.join(f' - {item}' for item in errors) + raise RuntimeError( + f'Could not load matchday {matchday} from either local source.\n' + f'Attempted paths:\n{attempted_paths}\nErrors:\n{error_text}' + ) + +# Normalize team name for reuse in the workflow. +def normalize_team_name(value_to_normalize: str) -> str: + normalized = unicodedata.normalize('NFKC', value_to_normalize).casefold().strip() + normalized = normalized.replace('’', "'").replace('`', "'") + normalized = re.sub(r'[^\w]+', ' ', normalized, flags=re.UNICODE) + return ' '.join(normalized.split()) + + +# Build team alias registry for reuse in the workflow. +def build_team_alias_registry() -> dict[str, str]: + # Validate the input before continuing with later processing. + if set(KB_TEAM_ID_TO_KEY.values()) != set(TEAM_DISPLAY_NAMES): + raise ValueError('Embedded Kickbase team map and display-name map are inconsistent.') + # Validate the input before continuing with later processing. + if set(TEAM_ALIASES) != set(TEAM_DISPLAY_NAMES): + raise ValueError('Embedded team aliases and display-name map are inconsistent.') + + registry: dict[str, str] = {} + # Process each available item while preserving the current workflow state. + for team_key, aliases in TEAM_ALIASES.items(): + # Process each available item while preserving the current workflow state. + for alias in set(aliases) | {TEAM_DISPLAY_NAMES[team_key]}: + normalized = normalize_team_name(alias) + previous = registry.get(normalized) + # Validate the input before continuing with later processing. + if previous is not None and previous != team_key: + raise ValueError( + f'Team alias {alias!r} is ambiguous between {previous!r} and {team_key!r}.' + ) + registry[normalized] = team_key + return registry + + +# Set workflow configuration value: TEAM_ALIAS_TO_KEY. +TEAM_ALIAS_TO_KEY = build_team_alias_registry() + + +# Resolve team name for reuse in the workflow. +def resolve_team_name(name: str) -> str: + normalized = normalize_team_name(name) + # Validate the input before continuing with later processing. + if normalized not in TEAM_ALIAS_TO_KEY: + raise ValueError( + f'Unrecognized team name {name!r} after exact normalization to {normalized!r}.' + ) + return TEAM_ALIAS_TO_KEY[normalized] + + +# Handle like for reuse in the workflow. +def integer_like(value_to_parse: Any) -> int | None: + # Handle expected failures with a clear, actionable message. + try: + parsed = Decimal(str(value_to_parse).strip()) + except InvalidOperation: + return None + if not parsed.is_finite() or parsed != parsed.to_integral_value() or parsed < 1: + return None + return int(parsed) + + +# Map clubs to matches for reuse in the workflow. +def map_clubs_to_matches( + df: pd.DataFrame, club_column: str, matches: list[MatchRecord] +) -> tuple[pd.Series, dict[str, str], list[MappedMatch], pd.DataFrame]: + provider_id_to_key: dict[int, str] = {} + mapped_matches: list[MappedMatch] = [] + + # Process each available item while preserving the current workflow state. + for record in matches: + home_key = resolve_team_name(record.home_team_name) + away_key = resolve_team_name(record.away_team_name) + # Validate the input before continuing with later processing. + if home_key == away_key: + raise ValueError(f'Match {record.match_id} maps both sides to {home_key!r}.') + # Process each available item while preserving the current workflow state. + for provider_id, team_key in ( + (record.home_team_id, home_key), + (record.away_team_id, away_key), + ): + previous = provider_id_to_key.get(provider_id) + # Validate the input before continuing with later processing. + if previous is not None and previous != team_key: + raise ValueError( + f'Provider team ID {provider_id} maps to both {previous!r} and {team_key!r}.' + ) + provider_id_to_key[provider_id] = team_key + mapped_matches.append(MappedMatch(record, home_key, away_key)) + + raw_series = df[club_column].astype(str).str.strip() + unique_raw = list(dict.fromkeys(raw_series.tolist())) + parsed_ids = {raw: integer_like(raw) for raw in unique_raw} + all_numeric = all(parsed is not None for parsed in parsed_ids.values()) + raw_to_key: dict[str, str] = {} + + # Validate the input before continuing with later processing. + if all_numeric and {int(value) for value in parsed_ids.values()} <= set(provider_id_to_key): + mapping_mode = 'compatible provider team IDs' + raw_to_key = {raw: provider_id_to_key[int(parsed_ids[raw])] for raw in unique_raw} + # Validate the input before continuing with later processing. + elif all_numeric and {int(value) for value in parsed_ids.values()} <= set(KB_TEAM_ID_TO_KEY): + mapping_mode = 'embedded Kickbase team-ID bridge' + raw_to_key = {raw: KB_TEAM_ID_TO_KEY[int(parsed_ids[raw])] for raw in unique_raw} + else: + mapping_mode = 'exact normalized team names' + errors: list[str] = [] + # Process each available item while preserving the current workflow state. + for raw in unique_raw: + # Handle expected failures with a clear, actionable message. + try: + raw_to_key[raw] = resolve_team_name(raw) + except ValueError as exc: + errors.append(str(exc)) + # Validate the input before continuing with later processing. + if errors: + raise ValueError( + 'Club/team mapping failed. CSV identifiers are neither a compatible provider ' + 'ID set, the known Kickbase ID set, nor recognized exact team names: ' + + '; '.join(errors) + ) + + team_keys = raw_series.map(raw_to_key) + # Validate the input before continuing with later processing. + if team_keys.isna().any(): + raise ValueError('Internal club mapping error left one or more player rows unmapped.') + + match_counts: dict[str, int] = {} + # Process each available item while preserving the current workflow state. + for match in mapped_matches: + # Process each available item while preserving the current workflow state. + for team_key in (match.home_key, match.away_key): + match_counts[team_key] = match_counts.get(team_key, 0) + 1 + bad_counts = {team: count for team, count in match_counts.items() if count != 1} + # Validate the input before continuing with later processing. + if bad_counts: + raise ValueError(f'Clubs mapped to an unexpected number of matches: {bad_counts}') + + dataset_keys = set(team_keys.tolist()) + match_keys = set(match_counts) + # Validate the input before continuing with later processing. + if dataset_keys != match_keys: + missing_from_csv = sorted(match_keys - dataset_keys) + missing_from_matches = sorted(dataset_keys - match_keys) + raise ValueError( + 'CSV clubs and matchday clubs do not form a one-to-one matchday mapping. ' + f'Match clubs absent from CSV={missing_from_csv}; ' + f'CSV clubs absent from matches={missing_from_matches}.' + ) + + key_to_raw_values: dict[str, set[str]] = {} + # Process each available item while preserving the current workflow state. + for raw, team_key in raw_to_key.items(): + key_to_raw_values.setdefault(team_key, set()).add(raw) + ambiguous_raw = {key: sorted(values) for key, values in key_to_raw_values.items() if len(values) != 1} + # Validate the input before continuing with later processing. + if ambiguous_raw: + raise ValueError(f'Canonical clubs map to multiple CSV club values: {ambiguous_raw}') + key_to_raw = {key: next(iter(values)) for key, values in key_to_raw_values.items()} + + diagnostic_rows: list[dict[str, str | int]] = [] + # Process each available item while preserving the current workflow state. + for match in mapped_matches: + diagnostic_rows.append( + { + 'Match ID': match.record.match_id, + 'JSON Home Team': match.record.home_team_name, + 'CSV Home Club': f'{TEAM_DISPLAY_NAMES[match.home_key]} ({key_to_raw[match.home_key]})', + 'JSON Away Team': match.record.away_team_name, + 'CSV Away Club': f'{TEAM_DISPLAY_NAMES[match.away_key]} ({key_to_raw[match.away_key]})', + } + ) + mapping_df = pd.DataFrame(diagnostic_rows) + print(f'Club mapping mode: {mapping_mode}') + return team_keys.astype('string'), raw_to_key, mapped_matches, mapping_df + + +# Prepare optimization data for reuse in the workflow. +def prepare_optimization_data( + path: Path, matches: list[MatchRecord] +) -> tuple[PreparedData, list[MappedMatch], pd.DataFrame]: + base = load_and_validate_player_data(path) + team_keys, raw_to_key, mapped_matches, mapping_df = map_clubs_to_matches( + base['df'], base['columns']['club'], matches + ) + prepared = PreparedData( + df=base['df'], + original_columns=base['original_columns'], + columns=base['columns'], + positions=base['positions'], + score_numeric=base['score_numeric'], + score_units=base['score_units'], + score_scale=base['score_scale'], + value_numeric=base['value_numeric'], + value_eur=base['value_eur'], + value_unit=base['value_unit'], + team_keys=team_keys, + team_raw_to_key=raw_to_key, + ) + return prepared, mapped_matches, mapping_df + +# Manual-only arena, formation, name-resolution, validation, and display helpers. +from dataclasses import dataclass +from difflib import SequenceMatcher +from typing import Iterable + +from sofascore_average_rating_score import FUZZY_MATCH_THRESHOLD, MAX_PROMPT_CANDIDATES, normalize_name + + +@dataclass(frozen=True) +class ArenaRules: + name: str + budget_eur: int + max_players_per_club: int + max_players_per_match: int = 4 + + +ARENA_RULES = { + "Bundesliga Arena": ArenaRules("Bundesliga Arena", 250_000_000, 3), + "KickbaseKIS Arena": ArenaRules("KickbaseKIS Arena", 150_000_000, 2), +} +ARENA_ALIASES = { + "bundesligaarena": "Bundesliga Arena", + "kickbasekisarena": "KickbaseKIS Arena", + "kickbasekisarena": "KickbaseKIS Arena", +} + + +def canonical_arena(value: object) -> ArenaRules: + """Resolve supported arena names and historical spelling variants.""" + key = normalize_name(value) + canonical = ARENA_ALIASES.get(key) + if canonical is None: + choices = ", ".join(ARENA_RULES) + raise ValueError(f"Unknown arena {value!r}. Choose one of: {choices}.") + return ARENA_RULES[canonical] + + +def request_arena() -> ArenaRules: + """Prompt until the user chooses a supported arena by number or name.""" + options = list(ARENA_RULES.values()) + while True: + print("Choose arena:") + for number, rules in enumerate(options, start=1): + print( + f" {number}. {rules.name} — budget €{rules.budget_eur:,}, " + f"max {rules.max_players_per_club} per club, " + f"max {rules.max_players_per_match} per match" + ) + answer = input("Arena number or name: ").strip() + if answer.isdigit() and 1 <= int(answer) <= len(options): + return options[int(answer) - 1] + try: + return canonical_arena(answer) + except ValueError as exc: + print(exc) + + +def normalize_formation(value: object) -> str: + text = str(value).strip().replace("–", "-").replace("—", "-") + return re.sub(r"\s+", "", text) + + +def request_formation() -> tuple[str, dict[str, int]]: + """Prompt until a project-supported eleven-player formation is entered.""" + while True: + answer = normalize_formation(input(f"Formation ({', '.join(ALLOWED_FORMATIONS)}): ")) + counts = ALLOWED_FORMATIONS.get(answer) + if counts is not None and sum(counts.values()) + 1 == 11: + return answer, dict(counts) + print( + f"{answer!r} is not a supported formation. " + f"Choose one of: {', '.join(ALLOWED_FORMATIONS)}." + ) + + +def _candidate_indices( + prepared: PreparedData, entered_name: str, allowed_indices: Iterable[int] | None = None +) -> tuple[str, list[int]]: + """Return exact candidates or established-policy fuzzy candidates for a name.""" + normalized = normalize_name(entered_name) + if not normalized: + return "none", [] + indices = list(prepared.df.index if allowed_indices is None else allowed_indices) + exact = [ + int(index) + for index in indices + if normalize_name(prepared.df.loc[index, prepared.columns["player_name"]]) == normalized + ] + if exact: + return "exact", sorted(exact) + ranked = sorted( + ( + ( + SequenceMatcher( + None, + normalized, + normalize_name(prepared.df.loc[index, prepared.columns["player_name"]]), + ).ratio(), + int(index), + ) + for index in indices + ), + key=lambda item: ( + -item[0], + str(prepared.df.loc[item[1], prepared.columns["player_name"]]).casefold(), + str(prepared.df.loc[item[1], prepared.columns["player_id"]]), + ), + ) + fuzzy = [index for similarity, index in ranked if similarity >= FUZZY_MATCH_THRESHOLD] + return "fuzzy", fuzzy[:MAX_PROMPT_CANDIDATES] + + +def _print_candidates(prepared: PreparedData, indices: list[int], kind: str) -> None: + print(f"{kind.title()} player candidates:") + for number, index in enumerate(indices, start=1): + print( + f" {number}. {prepared.df.loc[index, prepared.columns['player_name']]} " + f"(ID {prepared.df.loc[index, prepared.columns['player_id']]}; " + f"{prepared.positions.loc[index]}; " + f"{TEAM_DISPLAY_NAMES[prepared.team_keys.loc[index]]}; " + f"value €{int(prepared.value_eur.loc[index]):,}; " + f"score {prepared.df.loc[index, prepared.columns['score']]})" + ) + + +def _choose_candidate(prepared: PreparedData, entered_name: str, allowed_indices: Iterable[int] | None = None) -> int | None: + kind, candidates = _candidate_indices(prepared, entered_name, allowed_indices) + if not candidates: + print(f"No exact or plausible player match was found for {entered_name!r}. Try again.") + return None + if kind == "exact" and len(candidates) == 1: + return candidates[0] + _print_candidates(prepared, candidates, kind) + while True: + answer = input("Choose a candidate number, or press Enter to try another name: ").strip() + if not answer: + return None + if answer.isdigit() and 1 <= int(answer) <= len(candidates): + return candidates[int(answer) - 1] + print(f"Enter a number from 1 to {len(candidates)}, or press Enter to retry.") + + +def request_players_by_position( + prepared: PreparedData, formation_counts: dict[str, int] +) -> list[int]: + """Collect a unique manually chosen player for every formation slot.""" + selected: list[int] = [] + requested_counts = {"GK": 1, **formation_counts} + for position in ("GK", "DEF", "MID", "FOR"): + for slot in range(1, requested_counts.get(position, 0) + 1): + while True: + entered_name = input(f"{position} {slot} of {requested_counts[position]}: ").strip() + index = _choose_candidate(prepared, entered_name) + if index is None: + continue + actual_position = str(prepared.positions.loc[index]) + if actual_position != position: + print( + f"{prepared.df.loc[index, prepared.columns['player_name']]} is {actual_position}, " + f"not the requested {position}. Choose another player." + ) + continue + if index in selected: + print("That player is already selected. Choose another player.") + continue + selected.append(index) + print(f"Added {prepared.df.loc[index, prepared.columns['player_name']]}.\n") + break + return selected + + +def request_captain(prepared: PreparedData, selected_indices: list[int]) -> int: + """Require a captain from the completed manual lineup.""" + while True: + entered_name = input("Captain name: ").strip() + index = _choose_candidate(prepared, entered_name, selected_indices) + if index is None: + continue + if index not in selected_indices: + print("Captain must be one of the selected players.") + continue + return index + + +def format_score(units: int, scale: int) -> str: + return format(Decimal(units) / Decimal(scale), "f") + + +def _player_names(prepared: PreparedData, indices: list[int]) -> str: + return ", ".join(str(prepared.df.loc[index, prepared.columns["player_name"]]) for index in indices) + + +def validate_manual_lineup( + prepared: PreparedData, + selected_indices: list[int], + formation: str, + arena: ArenaRules, + mapped_matches: list[MappedMatch], + captain_index: int, +) -> dict[str, object]: + """Validate every optimizer rule without running the optimizer.""" + checks: list[dict[str, object]] = [] + expected = {"GK": 1, **ALLOWED_FORMATIONS[formation]} + actual_positions = prepared.positions.loc[selected_indices].value_counts().to_dict() + formation_ok = len(selected_indices) == 11 and all( + actual_positions.get(position, 0) == count for position, count in expected.items() + ) + checks.append({ + "rule": "Formation and squad size", + "passed": formation_ok, + "details": f"actual={actual_positions}; expected={expected}; players={len(selected_indices)}/11", + "budget": False, + }) + ids = prepared.df.loc[selected_indices, prepared.columns["player_id"]].astype(str).str.strip() + unique_ok = ids.nunique() == len(selected_indices) + checks.append({"rule": "Unique players", "passed": unique_ok, "details": "all player IDs are unique" if unique_ok else "duplicate player IDs detected", "budget": False}) + captain_ok = captain_index in selected_indices + checks.append({"rule": "Captain", "passed": captain_ok, "details": str(prepared.df.loc[captain_index, prepared.columns["player_name"]]) if captain_ok else "captain is not in lineup", "budget": False}) + total_value = sum(int(prepared.value_eur.loc[index]) for index in selected_indices) + budget_ok = total_value <= arena.budget_eur + excess = max(0, total_value - arena.budget_eur) + checks.append({"rule": "Budget", "passed": budget_ok, "details": f"€{total_value:,} / €{arena.budget_eur:,}" + (f" (exceeds by €{excess:,})" if excess else ""), "budget": True}) + club_counts = prepared.team_keys.loc[selected_indices].value_counts().to_dict() + club_violations = {club: count for club, count in club_counts.items() if count > arena.max_players_per_club} + club_details = "within limit" if not club_violations else "; ".join( + f"{TEAM_DISPLAY_NAMES[club]}: {count}/{arena.max_players_per_club} ({_player_names(prepared, [index for index in selected_indices if prepared.team_keys.loc[index] == club])})" + for club, count in club_violations.items() + ) + checks.append({"rule": "Players per club", "passed": not club_violations, "details": club_details, "budget": False}) + match_violations = [] + for match in mapped_matches: + match_players = [index for index in selected_indices if prepared.team_keys.loc[index] in {match.home_key, match.away_key}] + if len(match_players) > arena.max_players_per_match: + match_violations.append( + f"{match.record.home_team_name} vs {match.record.away_team_name}: " + f"{len(match_players)}/{arena.max_players_per_match} ({_player_names(prepared, match_players)})" + ) + checks.append({"rule": "Players per match", "passed": not match_violations, "details": "within limit" if not match_violations else "; ".join(match_violations), "budget": False}) + total_score_units = sum(int(prepared.score_units.loc[index]) for index in selected_indices) + int(prepared.score_units.loc[captain_index]) + return { + "checks": checks, + "total_value_eur": total_value, + "total_score_units": total_score_units, + "budget_excess_eur": excess, + "non_budget_valid": all(bool(check["passed"]) for check in checks if not check["budget"]), + "budget_valid": budget_ok, + } + + +def sorted_lineup_indices(prepared: PreparedData, selected_indices: list[int]) -> list[int]: + order = {"GK": 0, "DEF": 1, "MID": 2, "FOR": 3} + return sorted( + selected_indices, + key=lambda index: ( + order[prepared.positions.loc[index]], + -float(prepared.score_numeric.loc[index]), + str(prepared.df.loc[index, prepared.columns["player_name"]]).casefold(), + str(prepared.df.loc[index, prepared.columns["player_id"]]), + ), + ) + + +def lineup_summary_table(prepared: PreparedData, sorted_indices: list[int], captain_index: int) -> pd.DataFrame: + return pd.DataFrame({ + "Player": prepared.df.loc[sorted_indices, prepared.columns["player_name"]].tolist(), + "ID": prepared.df.loc[sorted_indices, prepared.columns["player_id"]].tolist(), + "Position": prepared.positions.loc[sorted_indices].tolist(), + "Club": [TEAM_DISPLAY_NAMES[key] for key in prepared.team_keys.loc[sorted_indices]], + "Price": [f"€{int(prepared.value_eur.loc[index]):,}" for index in sorted_indices], + "Expected points": prepared.df.loc[sorted_indices, prepared.columns["score"]].tolist(), + "Captain": ["Yes" if index == captain_index else "" for index in sorted_indices], + }) + + +def snapshot_players(prepared: PreparedData, sorted_indices: list[int], captain_index: int) -> list[dict[str, object]]: + return [ + { + "id": prepared.df.loc[index, prepared.columns["player_id"]], + "name": prepared.df.loc[index, prepared.columns["player_name"]], + "position": prepared.positions.loc[index], + "market_value": prepared.df.loc[index, prepared.columns["market_value"]], + "market_value_eur": int(prepared.value_eur.loc[index]), + "club": TEAM_DISPLAY_NAMES[prepared.team_keys.loc[index]], + "expected_points": prepared.df.loc[index, prepared.columns["score"]], + "captain": index == captain_index, + } + for index in sorted_indices + ] diff --git a/notebooks/07_squad_optimisation/manually_create_lineup.ipynb b/notebooks/07_squad_optimisation/manually_create_lineup.ipynb new file mode 100644 index 0000000..97535c1 --- /dev/null +++ b/notebooks/07_squad_optimisation/manually_create_lineup.ipynb @@ -0,0 +1,890 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "12d0e27a", + "metadata": {}, + "source": [ + "# Manually create a Kickbase lineup\n", + "\n", + "Build, validate, review, and select one manual lineup without running an optimizer.\n" + ] + }, + { + "cell_type": "markdown", + "id": "1336a6a5", + "metadata": {}, + "source": [ + "## 1. Imports and shared project logic\n", + "\n", + "The helper module below extracts the optimizer's non-solver data, mapping, and validation conventions.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "4f023203", + "metadata": {}, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "from IPython.display import display\n", + "\n", + "\n", + "def locate_project_root() -> Path:\n", + " starts = []\n", + " notebook_path = globals().get('__vsc_ipynb_file__')\n", + " if isinstance(notebook_path, str) and notebook_path.strip():\n", + " starts.append(Path(notebook_path).expanduser().resolve().parent)\n", + " starts.append(Path.cwd().resolve())\n", + " for start in starts:\n", + " for candidate in (start, *start.parents):\n", + " if (candidate / 'project_paths.py').is_file():\n", + " return candidate\n", + " raise FileNotFoundError('Could not locate project_paths.py. Start Jupyter from the project root.')\n", + "\n", + "\n", + "PROJECT_ROOT = locate_project_root()\n", + "if str(PROJECT_ROOT) not in sys.path:\n", + " sys.path.insert(0, str(PROJECT_ROOT))\n", + "\n", + "from manual_lineup_helpers import (\n", + " EXPECTED_POINTS_DIR,\n", + " canonical_arena,\n", + " discover_latest_score,\n", + " format_score,\n", + " lineup_summary_table,\n", + " load_matchday_matches,\n", + " prepare_optimization_data,\n", + " request_arena,\n", + " request_captain,\n", + " request_formation,\n", + " request_matchday,\n", + " request_players_by_position,\n", + " snapshot_players,\n", + " sorted_lineup_indices,\n", + " validate_manual_lineup,\n", + ")\n", + "from selected_lineups import make_selected_lineup, select_lineup_interactively\n" + ] + }, + { + "cell_type": "markdown", + "id": "e1a18049", + "metadata": {}, + "source": [ + "## 2. Player pool and expected-points input\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b0bc8ea7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Selected expected-points input:\n", + " File: C:\\kickbase project\\outputs\\expected_points\\expected_points_20260823_004913_+0200_sofascore_overall_rating_odds_lineup_20260823_005325_+0200.csv\n", + " Method: sofascore_overall_rating_odds_lineup\n", + " Metric creation: 20260823_005325_+0200\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Enter the matchday to optimise the squad for: 1\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Match source used: SofaScore (C:\\kickbase project\\outputs\\sofascore\\match_ids\\match_ids_1.json)\n", + "Club mapping mode: embedded Kickbase team-ID bridge\n", + "\n", + "Validated player pool: 469 players\n", + "Market-value unit: euros; validation uses euros.\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Match IDJSON Home TeamCSV Home ClubJSON Away TeamCSV Away Club
016434087FC Bayern MünchenFC Bayern München (2)VfB StuttgartVfB Stuttgart (9)
1164340221. FC Köln1. FC Köln (28)TSG HoffenheimTSG Hoffenheim (14)
2164340261. FC Union Berlin1. FC Union Berlin (40)Eintracht FrankfurtEintracht Frankfurt (4)
3164340441. FSV Mainz 051. FSV Mainz 05 (18)SC Paderborn 07SC Paderborn 07 (29)
416434023RB LeipzigRB Leipzig (43)Borussia M'gladbachBorussia Mönchengladbach (15)
516434020SV 07 ElversbergSV 07 Elversberg (77)Bayer 04 LeverkusenBayer 04 Leverkusen (7)
616434025Borussia DortmundBorussia Dortmund (3)Hamburger SVHamburger SV (6)
716434029SC FreiburgSC Freiburg (5)SV Werder BremenSV Werder Bremen (10)
816434039FC AugsburgFC Augsburg (13)FC Schalke 04FC Schalke 04 (8)
\n", + "
" + ], + "text/plain": [ + " Match ID JSON Home Team CSV Home Club JSON Away Team \\\n", + "0 16434087 FC Bayern München FC Bayern München (2) VfB Stuttgart \n", + "1 16434022 1. FC Köln 1. FC Köln (28) TSG Hoffenheim \n", + "2 16434026 1. FC Union Berlin 1. FC Union Berlin (40) Eintracht Frankfurt \n", + "3 16434044 1. FSV Mainz 05 1. FSV Mainz 05 (18) SC Paderborn 07 \n", + "4 16434023 RB Leipzig RB Leipzig (43) Borussia M'gladbach \n", + "5 16434020 SV 07 Elversberg SV 07 Elversberg (77) Bayer 04 Leverkusen \n", + "6 16434025 Borussia Dortmund Borussia Dortmund (3) Hamburger SV \n", + "7 16434029 SC Freiburg SC Freiburg (5) SV Werder Bremen \n", + "8 16434039 FC Augsburg FC Augsburg (13) FC Schalke 04 \n", + "\n", + " CSV Away Club \n", + "0 VfB Stuttgart (9) \n", + "1 TSG Hoffenheim (14) \n", + "2 Eintracht Frankfurt (4) \n", + "3 SC Paderborn 07 (29) \n", + "4 Borussia Mönchengladbach (15) \n", + "5 Bayer 04 Leverkusen (7) \n", + "6 Hamburger SV (6) \n", + "7 SV Werder Bremen (10) \n", + "8 FC Schalke 04 (8) " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "metadata = discover_latest_score(EXPECTED_POINTS_DIR)\n", + "print('Selected expected-points input:')\n", + "print(f' File: {metadata.path}')\n", + "print(f' Method: {metadata.method}')\n", + "print(f' Metric creation: {metadata.metric_creation_timestamp}')\n", + "\n", + "matchday = request_matchday()\n", + "matches, match_source, match_path = load_matchday_matches(matchday)\n", + "prepared, mapped_matches, mapping_table = prepare_optimization_data(metadata.path, matches)\n", + "print(f'\\nValidated player pool: {len(prepared.df):,} players')\n", + "print(f'Market-value unit: {prepared.value_unit}; validation uses euros.')\n", + "display(mapping_table)\n" + ] + }, + { + "cell_type": "markdown", + "id": "1e963091", + "metadata": {}, + "source": [ + "## 3. Arena and formation\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "3aca3288", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Choose arena:\n", + " 1. Bundesliga Arena — budget €250,000,000, max 3 per club, max 4 per match\n", + " 2. KickbaseKIS Arena — budget €150,000,000, max 2 per club, max 4 per match\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Arena number or name: 1\n", + "Formation (4-4-2, 4-2-4, 3-4-3, 4-3-3, 5-3-2, 3-5-2, 5-4-1, 4-5-1, 3-6-1, 5-2-3): 5-4-1\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using Bundesliga Arena: €250,000,000 budget, max 3 players per club.\n", + "Formation: 5-4-1\n" + ] + } + ], + "source": [ + "arena = request_arena()\n", + "formation, formation_counts = request_formation()\n", + "print(f'Using {arena.name}: €{arena.budget_eur:,} budget, max {arena.max_players_per_club} players per club.')\n", + "print(f'Formation: {formation}')\n" + ] + }, + { + "cell_type": "markdown", + "id": "d0fac223", + "metadata": {}, + "source": [ + "## 4. Choose players by position and select a captain\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "ec60cb1b", + "metadata": {}, + "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "GK 1 of 1: Nahuel Noll\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Nahuel Noll.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "DEF 1 of 5: Julian Ryerson\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Julian Ryerson.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "DEF 2 of 5: Ramy Bensebaini\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Ramy Bensebaini.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "DEF 3 of 5: Vladimir Coufal\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Vladimír Coufal.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "DEF 4 of 5: Anthony Caci\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Anthony Caci.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "DEF 5 of 5: Miguel Gutierrez\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Miguel Gutiérrez.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "MID 1 of 4: Michael Olise\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Michael Olise.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "MID 2 of 4: Nadiem Amiri\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Nadiem Amiri.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "MID 3 of 4: Konstantinos Karetsas\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Konstantinos Karetsas.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "MID 4 of 4: Ezechiel Banzuzi\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Ezechiel Banzuzi.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "FOR 1 of 1: Johan Bakayoko\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added Johan Bakayoko.\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Captain name: Michael Olise\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Captain: Michael Olise\n" + ] + } + ], + "source": [ + "selected_indices = request_players_by_position(prepared, formation_counts)\n", + "captain_index = request_captain(prepared, selected_indices)\n", + "print(f\"Captain: {prepared.df.loc[captain_index, prepared.columns['player_name']]}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "e5824407", + "metadata": {}, + "source": [ + "## 5. Validate arena rules and handle a budget-only override\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "6354eb1d", + "metadata": {}, + "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "The squad costs €257,189,203; the Bundesliga Arena budget is €250,000,000; it exceeds the budget by €7,189,203. Does this squad work in your actual game? [y/n]: y\n" + ] + } + ], + "source": [ + "validation = validate_manual_lineup(\n", + " prepared, selected_indices, formation, arena, mapped_matches, captain_index\n", + ")\n", + "budget_override_used = False\n", + "if not validation['budget_valid']:\n", + " total_value = int(validation['total_value_eur'])\n", + " excess = int(validation['budget_excess_eur'])\n", + " while True:\n", + " answer = input(\n", + " f'The squad costs €{total_value:,}; the {arena.name} budget is €{arena.budget_eur:,}; '\n", + " f'it exceeds the budget by €{excess:,}. Does this squad work in your actual game? [y/n]: '\n", + " ).strip().casefold()\n", + " if answer in {'y', 'yes'}:\n", + " budget_override_used = True\n", + " break\n", + " if answer in {'n', 'no'}:\n", + " break\n", + " print('Please answer yes or no.')\n", + "\n", + "lineup_valid = bool(validation['non_budget_valid']) and (\n", + " bool(validation['budget_valid']) or budget_override_used\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "93d1f400", + "metadata": {}, + "source": [ + "## 6. Final validation summary\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "4afcc062", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "=== Manual lineup summary ===\n", + "Arena: Bundesliga Arena\n", + "Formation: 5-4-1\n", + "Matchday: 1\n", + "Captain: Michael Olise\n", + "Total squad cost: €257,189,203\n", + "Total expected points (captain doubled): 134.499754\n", + "Budget override used: Yes\n", + "\n", + "Validation checks:\n", + " [PASS] Formation and squad size: actual={'DEF': 5, 'MID': 4, 'GK': 1, 'FOR': 1}; expected={'GK': 1, 'DEF': 5, 'MID': 4, 'FOR': 1}; players=11/11\n", + " [PASS] Unique players: all player IDs are unique\n", + " [PASS] Captain: Michael Olise\n", + " [PASS] Budget: €257,189,203 / €250,000,000 (exceeds by €7,189,203) (manual override)\n", + " [PASS] Players per club: within limit\n", + " [PASS] Players per match: within limit\n", + "\n", + "Overall result: VALID\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
PlayerIDPositionClubPriceExpected pointsCaptain
0Nahuel Noll2977GKSC Paderborn 07€12,680,2670.0
1Julian Ryerson2395DEFBorussia Dortmund€25,926,77517.286749
2Ramy Bensebaini2736DEFBorussia Dortmund€21,726,97215.993183
3Miguel Gutiérrez3759DEFBayer 04 Leverkusen€20,385,78713.776144
4Vladimír Coufal7745DEFTSG Hoffenheim€25,768,12310.378417
5Anthony Caci3470DEF1. FSV Mainz 05€10,416,0960.0
6Michael Olise8329MIDFC Bayern München€64,765,58118.580415Yes
7Konstantinos Karetsas16378MIDBorussia Dortmund€23,538,07217.098594
8Ezechiel Banzuzi11008MIDRB Leipzig€7,697,90910.999312
9Nadiem Amiri1639MID1. FSV Mainz 05€33,544,9000.0
10Johan Bakayoko8061FORRB Leipzig€10,738,72111.806525
\n", + "
" + ], + "text/plain": [ + " Player ID Position Club Price \\\n", + "0 Nahuel Noll 2977 GK SC Paderborn 07 €12,680,267 \n", + "1 Julian Ryerson 2395 DEF Borussia Dortmund €25,926,775 \n", + "2 Ramy Bensebaini 2736 DEF Borussia Dortmund €21,726,972 \n", + "3 Miguel Gutiérrez 3759 DEF Bayer 04 Leverkusen €20,385,787 \n", + "4 Vladimír Coufal 7745 DEF TSG Hoffenheim €25,768,123 \n", + "5 Anthony Caci 3470 DEF 1. FSV Mainz 05 €10,416,096 \n", + "6 Michael Olise 8329 MID FC Bayern München €64,765,581 \n", + "7 Konstantinos Karetsas 16378 MID Borussia Dortmund €23,538,072 \n", + "8 Ezechiel Banzuzi 11008 MID RB Leipzig €7,697,909 \n", + "9 Nadiem Amiri 1639 MID 1. FSV Mainz 05 €33,544,900 \n", + "10 Johan Bakayoko 8061 FOR RB Leipzig €10,738,721 \n", + "\n", + " Expected points Captain \n", + "0 0.0 \n", + "1 17.286749 \n", + "2 15.993183 \n", + "3 13.776144 \n", + "4 10.378417 \n", + "5 0.0 \n", + "6 18.580415 Yes \n", + "7 17.098594 \n", + "8 10.999312 \n", + "9 0.0 \n", + "10 11.806525 " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "sorted_indices = sorted_lineup_indices(prepared, selected_indices)\n", + "summary_table = lineup_summary_table(prepared, sorted_indices, captain_index)\n", + "print('=== Manual lineup summary ===')\n", + "print(f'Arena: {arena.name}')\n", + "print(f'Formation: {formation}')\n", + "print(f'Matchday: {matchday}')\n", + "print(f'Captain: {prepared.df.loc[captain_index, prepared.columns[\"player_name\"]]}')\n", + "print(f\"Total squad cost: €{int(validation['total_value_eur']):,}\")\n", + "print(f\"Total expected points (captain doubled): {format_score(int(validation['total_score_units']), prepared.score_scale)}\")\n", + "print(f\"Budget override used: {'Yes' if budget_override_used else 'No'}\")\n", + "print('\\nValidation checks:')\n", + "for check in validation['checks']:\n", + " passed = bool(check['passed']) or (bool(check['budget']) and budget_override_used)\n", + " status = 'PASS' if passed else 'FAIL'\n", + " suffix = ' (manual override)' if bool(check['budget']) and budget_override_used else ''\n", + " print(f\" [{status}] {check['rule']}: {check['details']}{suffix}\")\n", + "print(f\"\\nOverall result: {'VALID' if lineup_valid else 'INVALID'}\")\n", + "display(summary_table)\n" + ] + }, + { + "cell_type": "markdown", + "id": "39e3f9c8", + "metadata": {}, + "source": [ + "## 7. Select and save the valid lineup\n", + "\n", + "Saving replaces only this arena's canonical selected-lineup JSON after confirmation.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "fef08d7c", + "metadata": {}, + "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Select this lineup for Bundesliga Arena? [y/n]: y\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Current selected lineup for Bundesliga Arena: expected points=187.746417, players=[Manuel Neuer, Julian Ryerson, Willi Orban, Ramy Bensebaini, Nathaniel Brown, Konstantinos Karetsas, Aleksandar Pavlović, Brajan Gruda, Nicolas Seiwald, Yannik Engelhardt, Afonso Moreira]\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Replace the current selected lineup? [y/n]: y\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Selected lineup saved to: C:\\kickbase project\\outputs\\selected_lineups\\bundesliga-arena.json\n" + ] + } + ], + "source": [ + "if not lineup_valid:\n", + " print('Lineup was not saved because one or more non-overridable arena rules failed.')\n", + " selected_lineup_path = None\n", + "else:\n", + " snapshot = make_selected_lineup(\n", + " league=arena.name,\n", + " players=snapshot_players(prepared, sorted_indices, captain_index),\n", + " expected_points={\n", + " 'value': format_score(int(validation['total_score_units']), prepared.score_scale),\n", + " 'label': 'Total expected points (captain doubled)',\n", + " 'includes_captain_bonus': True,\n", + " },\n", + " source='manual',\n", + " metadata={\n", + " 'score_input_file': str(metadata.path),\n", + " 'score_method': metadata.method,\n", + " 'metric_creation_timestamp': metadata.metric_creation_timestamp,\n", + " 'matchday': matchday,\n", + " 'formation': formation,\n", + " 'budget_override_used': budget_override_used,\n", + " 'nominal_budget_eur': arena.budget_eur,\n", + " 'total_value_eur': int(validation['total_value_eur']),\n", + " },\n", + " )\n", + " selected_lineup_path = select_lineup_interactively(snapshot)\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/07_squad_optimisation/optimize_squad_max_2_per_team.ipynb b/notebooks/07_squad_optimisation/optimize_squad_max_2_per_team.ipynb index 5367474..2688282 100644 --- a/notebooks/07_squad_optimisation/optimize_squad_max_2_per_team.ipynb +++ b/notebooks/07_squad_optimisation/optimize_squad_max_2_per_team.ipynb @@ -1607,7 +1607,7 @@ " Anton Kade FOR FC Augsburg 12239474.0 11.020506 \n", "\n", "=== Output ===\n", - "Optimized squad CSV: C:\\kickbase project\\outputs\\optimized_squad\\optimized_squad_sofascore_overall_rating_odds_lineup_20260823_004913_+0200_20260823_005325_+0200_20260823_133038_+0200.csv\n" + "Optimized squad CSV: C:\\kickbase project\\outputs\\optimized_squad\\optimized_squad_sofascore_overall_rating_odds_lineup_20260823_004913_+0200_20260823_005325_+0200_20260823_162027_+0200.csv\n" ] } ], @@ -1674,13 +1674,116 @@ "print(f'Optimized squad CSV: {output_path}')" ] }, + { + "cell_type": "markdown", + "id": "981dbc7f", + "metadata": {}, + "source": [ + "## 8. Select this lineup\n", + "\n", + "Select a verified result only when it should become this league's canonical lineup.\n" + ] + }, { "cell_type": "code", - "execution_count": null, - "id": "a093bb20-eb9c-4365-9175-2c12be0c869a", + "execution_count": 8, + "id": "9e6563fe", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "# Selected-lineup persistence is shared by every squad workflow.\n", + "import sys\n", + "\n", + "if str(PROJECT_ROOT) not in sys.path:\n", + " sys.path.insert(0, str(PROJECT_ROOT))\n", + "\n", + "from selected_lineups import make_selected_lineup, select_lineup_interactively\n", + "\n", + "\n", + "def build_selected_lineup_snapshot(\n", + " league: str,\n", + " source: str,\n", + " prepared: PreparedData,\n", + " sorted_indices: list[int],\n", + " captain_index: int,\n", + " total_score_units: int,\n", + " metadata: ScoreMetadata,\n", + " matchday: int,\n", + " formation: str,\n", + ") -> dict[str, Any]:\n", + " \"\"\"Create a portable selected-lineup snapshot from verified notebook data.\"\"\"\n", + " players = []\n", + " for index in sorted_indices:\n", + " team_key = prepared.team_keys.loc[index]\n", + " players.append(\n", + " {\n", + " 'id': prepared.df.loc[index, prepared.columns['player_id']],\n", + " 'name': prepared.df.loc[index, prepared.columns['player_name']],\n", + " 'position': prepared.positions.loc[index],\n", + " 'market_value': prepared.df.loc[index, prepared.columns['market_value']],\n", + " 'market_value_eur': int(prepared.value_eur.loc[index]),\n", + " 'club': TEAM_DISPLAY_NAMES[team_key],\n", + " 'expected_points': prepared.df.loc[index, prepared.columns['score']],\n", + " 'captain': index == captain_index,\n", + " }\n", + " )\n", + " return make_selected_lineup(\n", + " league=league,\n", + " players=players,\n", + " expected_points={\n", + " 'value': format_score(total_score_units, prepared.score_scale),\n", + " 'label': 'Total expected points (captain doubled)',\n", + " 'includes_captain_bonus': True,\n", + " },\n", + " source=source,\n", + " metadata={\n", + " 'score_input_file': str(metadata.path),\n", + " 'score_method': metadata.method,\n", + " 'metric_creation_timestamp': metadata.metric_creation_timestamp,\n", + " 'matchday': matchday,\n", + " 'formation': formation,\n", + " },\n", + " )\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a093bb20-eb9c-4365-9175-2c12be0c869a", + "metadata": {}, + "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Select this lineup for Kickbasekis Arena? [y/n]: y\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Selected lineup saved to: C:\\kickbase project\\outputs\\selected_lineups\\kickbasekis-arena.json\n" + ] + } + ], + "source": [ + "\n", + "\n", + "# Selection is intentionally separate from the timestamped optimizer export.\n", + "selected_lineup_snapshot = build_selected_lineup_snapshot(\n", + " league='KickbaseKIS Arena',\n", + " source='optimizer_v2_per_match',\n", + " prepared=prepared,\n", + " sorted_indices=sorted_indices,\n", + " captain_index=int(winner.captain_index),\n", + " total_score_units=int(winner.total_score_units),\n", + " metadata=metadata,\n", + " matchday=matchday,\n", + " formation=winner.formation,\n", + ")\n", + "selected_lineup_path = select_lineup_interactively(selected_lineup_snapshot)\n" + ] } ], "metadata": { diff --git a/notebooks/07_squad_optimisation/optimize_squad_v1.ipynb b/notebooks/07_squad_optimisation/optimize_squad_v1.ipynb index ed68cb1..8261c22 100644 --- a/notebooks/07_squad_optimisation/optimize_squad_v1.ipynb +++ b/notebooks/07_squad_optimisation/optimize_squad_v1.ipynb @@ -41,7 +41,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 10, "id": "5e3ef4c5", "metadata": {}, "outputs": [], @@ -267,7 +267,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 11, "id": "9b3c19b4", "metadata": {}, "outputs": [], @@ -620,7 +620,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 12, "id": "eb6fdda4", "metadata": {}, "outputs": [], @@ -812,7 +812,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 13, "id": "39379a72", "metadata": {}, "outputs": [], @@ -1029,7 +1029,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 14, "id": "042c1a42", "metadata": {}, "outputs": [], @@ -1226,7 +1226,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 15, "id": "063f64a6", "metadata": {}, "outputs": [], @@ -1383,7 +1383,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 16, "id": "eedaa4d7", "metadata": {}, "outputs": [ @@ -1608,7 +1608,7 @@ " Afonso Moreira FOR Bayer 04 Leverkusen 23738581.0 13.470008 \n", "\n", "=== Output ===\n", - "Optimized squad CSV: C:\\kickbase project\\outputs\\optimized_squad\\optimized_squad_sofascore_overall_rating_odds_lineup_20260823_004913_+0200_20260823_005325_+0200_20260823_005626_+0200.csv\n" + "Optimized squad CSV: C:\\kickbase project\\outputs\\optimized_squad\\optimized_squad_sofascore_overall_rating_odds_lineup_20260823_004913_+0200_20260823_005325_+0200_20260823_162616_+0200.csv\n" ] } ], @@ -1675,13 +1675,116 @@ "print(f'Optimized squad CSV: {output_path}')" ] }, + { + "cell_type": "markdown", + "id": "e57e6d36", + "metadata": {}, + "source": [ + "## 8. Select this lineup\n", + "\n", + "Select a verified result only when it should become this league's canonical lineup.\n" + ] + }, { "cell_type": "code", - "execution_count": null, - "id": "a093bb20-eb9c-4365-9175-2c12be0c869a", + "execution_count": 17, + "id": "d8ddd441", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "# Selected-lineup persistence is shared by every squad workflow.\n", + "import sys\n", + "\n", + "if str(PROJECT_ROOT) not in sys.path:\n", + " sys.path.insert(0, str(PROJECT_ROOT))\n", + "\n", + "from selected_lineups import make_selected_lineup, select_lineup_interactively\n", + "\n", + "\n", + "def build_selected_lineup_snapshot(\n", + " league: str,\n", + " source: str,\n", + " prepared: PreparedData,\n", + " sorted_indices: list[int],\n", + " captain_index: int,\n", + " total_score_units: int,\n", + " metadata: ScoreMetadata,\n", + " matchday: int,\n", + " formation: str,\n", + ") -> dict[str, Any]:\n", + " \"\"\"Create a portable selected-lineup snapshot from verified notebook data.\"\"\"\n", + " players = []\n", + " for index in sorted_indices:\n", + " team_key = prepared.team_keys.loc[index]\n", + " players.append(\n", + " {\n", + " 'id': prepared.df.loc[index, prepared.columns['player_id']],\n", + " 'name': prepared.df.loc[index, prepared.columns['player_name']],\n", + " 'position': prepared.positions.loc[index],\n", + " 'market_value': prepared.df.loc[index, prepared.columns['market_value']],\n", + " 'market_value_eur': int(prepared.value_eur.loc[index]),\n", + " 'club': TEAM_DISPLAY_NAMES[team_key],\n", + " 'expected_points': prepared.df.loc[index, prepared.columns['score']],\n", + " 'captain': index == captain_index,\n", + " }\n", + " )\n", + " return make_selected_lineup(\n", + " league=league,\n", + " players=players,\n", + " expected_points={\n", + " 'value': format_score(total_score_units, prepared.score_scale),\n", + " 'label': 'Total expected points (captain doubled)',\n", + " 'includes_captain_bonus': True,\n", + " },\n", + " source=source,\n", + " metadata={\n", + " 'score_input_file': str(metadata.path),\n", + " 'score_method': metadata.method,\n", + " 'metric_creation_timestamp': metadata.metric_creation_timestamp,\n", + " 'matchday': matchday,\n", + " 'formation': formation,\n", + " },\n", + " )\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "a093bb20-eb9c-4365-9175-2c12be0c869a", + "metadata": {}, + "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "Select this lineup for Bundesliga Arena? [y/n]: y\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Selected lineup saved to: C:\\kickbase project\\outputs\\selected_lineups\\bundesliga-arena.json\n" + ] + } + ], + "source": [ + "\n", + "\n", + "# Selection is intentionally separate from the timestamped optimizer export.\n", + "selected_lineup_snapshot = build_selected_lineup_snapshot(\n", + " league='Bundesliga Arena',\n", + " source='optimizer_v1',\n", + " prepared=prepared,\n", + " sorted_indices=sorted_indices,\n", + " captain_index=int(winner.captain_index),\n", + " total_score_units=int(winner.total_score_units),\n", + " metadata=metadata,\n", + " matchday=matchday,\n", + " formation=winner.formation,\n", + ")\n", + "selected_lineup_path = select_lineup_interactively(selected_lineup_snapshot)\n" + ] } ], "metadata": { diff --git a/project_paths.py b/project_paths.py index acd7b42..1156af0 100644 --- a/project_paths.py +++ b/project_paths.py @@ -41,6 +41,9 @@ KBSTATS_PLAYERS_DIR = OUTPUTS_DIR / "kbstats" / "players" EXPECTED_POINTS_DIR = OUTPUTS_DIR / "expected_points" +OPTIMIZED_SQUAD_DIR = OUTPUTS_DIR / "optimized_squad" +# Canonical, per-arena selected lineup snapshots. Each arena owns one JSON file. +SELECTED_LINEUPS_DIR = OUTPUTS_DIR / "selected_lineups" DERIVED_BUNDESLIGA_SNAPSHOTS_DIR = ( OUTPUTS_DIR / "derived" / "bundesliga_snapshots" ) diff --git a/requirements.txt b/requirements.txt index 80083dc..99180c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ ipython==9.16.1 jupyterlab==4.6.2 pandas==3.0.5 pulp==3.3.2 +pytest==9.0.2 requests==2.34.2 selenium==4.46.0 undetected-chromedriver==3.5.5 diff --git a/selected_lineups.py b/selected_lineups.py new file mode 100644 index 0000000..3e2fba3 --- /dev/null +++ b/selected_lineups.py @@ -0,0 +1,167 @@ +"""Persistence and interactive selection helpers for canonical Kickbase lineups.""" + +from __future__ import annotations + +import json +import re +import tempfile +import unicodedata +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping + +from project_paths import SELECTED_LINEUPS_DIR + + +SCHEMA_VERSION = 1 +REQUIRED_PLAYER_FIELDS = ("id", "name", "position", "market_value") + + +def league_slug(league: str) -> str: + """Return a stable, filesystem-safe name for a non-empty league name.""" + normalized = unicodedata.normalize("NFKD", str(league)).encode("ascii", "ignore").decode() + slug = re.sub(r"[^a-z0-9]+", "-", normalized.casefold()).strip("-") + if not slug: + raise ValueError("League name must contain at least one letter or number.") + return slug + + +def selected_lineup_path(league: str, directory: Path = SELECTED_LINEUPS_DIR) -> Path: + """Return the canonical JSON location for a league's sole selected lineup.""" + return Path(directory) / f"{league_slug(league)}.json" + + +def _json_value(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if hasattr(value, "item"): + try: + return _json_value(value.item()) + except (TypeError, ValueError): + pass + if hasattr(value, "isoformat"): + return value.isoformat() + return str(value) + + +def _validated_players(players: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: + normalized = [] + for number, player in enumerate(players, start=1): + record = {str(key): _json_value(value) for key, value in dict(player).items()} + missing = [field for field in REQUIRED_PLAYER_FIELDS if not str(record.get(field, "")).strip()] + if missing: + raise ValueError(f"Player {number} is missing required field(s): {', '.join(missing)}.") + normalized.append(record) + if len(normalized) != 11: + raise ValueError(f"A selected lineup must contain exactly 11 players, found {len(normalized)}.") + ids = [str(player["id"]).strip() for player in normalized] + if len(ids) != len(set(ids)): + raise ValueError("A selected lineup cannot contain duplicate player IDs.") + return normalized + + +def make_selected_lineup( + league: str, + players: Iterable[Mapping[str, Any]], + expected_points: Mapping[str, Any], + source: str, + metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Build a validated, serializable selected-lineup snapshot.""" + league, source = str(league).strip(), str(source).strip() + if not league or not source: + raise ValueError("League name and lineup source cannot be empty.") + metric = {str(key): _json_value(value) for key, value in dict(expected_points).items()} + if "value" not in metric: + raise ValueError("Expected-points metadata must include a 'value'.") + return { + "schema_version": SCHEMA_VERSION, + "league": league, + "selected_at": datetime.now(timezone.utc).isoformat(), + "source": source, + "expected_points": metric, + "players": _validated_players(players), + "metadata": {str(key): _json_value(value) for key, value in dict(metadata or {}).items()}, + } + + +def load_selected_lineup(league: str, directory: Path = SELECTED_LINEUPS_DIR) -> dict[str, Any] | None: + """Load a league's selected lineup, or ``None`` if it has not been selected.""" + path = selected_lineup_path(league, directory) + if not path.is_file(): + return None + try: + with path.open("r", encoding="utf-8") as handle: + snapshot = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Could not read selected lineup {path}: {exc}") from exc + if not isinstance(snapshot, dict) or str(snapshot.get("league", "")).casefold() != str(league).strip().casefold(): + raise ValueError(f"Selected lineup {path} is malformed or belongs to another league.") + _validated_players(snapshot.get("players", [])) + return snapshot + + +def save_selected_lineup(lineup: Mapping[str, Any], directory: Path = SELECTED_LINEUPS_DIR) -> Path: + """Atomically write a selected-lineup snapshot to its league's sole JSON file.""" + required = ("league", "source", "expected_points", "players") + missing = [field for field in required if field not in lineup] + if missing: + raise ValueError(f"Lineup is missing required field(s): {', '.join(missing)}.") + snapshot = make_selected_lineup( + lineup["league"], lineup["players"], lineup["expected_points"], lineup["source"], lineup.get("metadata") + ) + if lineup.get("selected_at"): + snapshot["selected_at"] = str(lineup["selected_at"]) + path = selected_lineup_path(snapshot["league"], directory) + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = None + try: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as handle: + json.dump(snapshot, handle, ensure_ascii=False, indent=2) + handle.write("\n") + temporary_path = Path(handle.name) + temporary_path.replace(path) + except OSError as exc: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + raise OSError(f"Could not save selected lineup to {path}: {exc}") from exc + return path.resolve() + + +def _yes_no(prompt: str, input_func: Callable[[str], str], output_func: Callable[[str], None]) -> bool: + while True: + answer = input_func(prompt).strip().casefold() + if answer in {"y", "yes"}: + return True + if answer in {"n", "no"}: + return False + output_func("Please answer yes or no.") + + +def select_lineup_interactively( + lineup: Mapping[str, Any], + directory: Path = SELECTED_LINEUPS_DIR, + input_func: Callable[[str], str] = input, + output_func: Callable[[str], None] = print, + skip_selection_confirmation: bool = False, +) -> Path | None: + """Ask whether to select a lineup and explicitly confirm any replacement.""" + league = str(lineup.get("league", "")).strip() + if not league: + raise ValueError("Lineup must include a league before it can be selected.") + if not skip_selection_confirmation and not _yes_no( + f"Select this lineup for {league}? [y/n]: ", input_func, output_func + ): + output_func("Lineup was not selected; the existing selection is unchanged.") + return None + existing = load_selected_lineup(league, directory) + if existing is not None: + metric = existing.get("expected_points", {}).get("value", "unknown") + names = ", ".join(str(player.get("name", "?")) for player in existing.get("players", [])) + output_func(f"Current selected lineup for {league}: expected points={metric}, players=[{names}]") + if not _yes_no("Replace the current selected lineup? [y/n]: ", input_func, output_func): + output_func("Existing selected lineup was kept.") + return None + path = save_selected_lineup(lineup, directory) + output_func(f"Selected lineup saved to: {path}") + return path diff --git a/tests/test_manual_lineup_helpers.py b/tests/test_manual_lineup_helpers.py new file mode 100644 index 0000000..eb6488c --- /dev/null +++ b/tests/test_manual_lineup_helpers.py @@ -0,0 +1,73 @@ +import unittest + +import pandas as pd + +from manual_lineup_helpers import ( + PreparedData, + _candidate_indices, + canonical_arena, + normalize_formation, + validate_manual_lineup, +) + + +class ManualLineupHelpersTests(unittest.TestCase): + def setUp(self): + positions = ["GK"] + ["DEF"] * 4 + ["MID"] * 4 + ["FOR"] * 2 + teams = [ + "bayern", "dortmund", "frankfurt", "freiburg", "hamburg", "leverkusen", + "schalke", "stuttgart", "bremen", "augsburg", "hoffenheim", + ] + frame = pd.DataFrame({ + "id": list(range(1, 12)), + "name": [f"Manual Player {index}" for index in range(1, 12)], + "score": [float(index) for index in range(1, 12)], + "marketValue": [10_000_000] * 11, + }) + self.prepared = PreparedData( + df=frame, + original_columns=list(frame.columns), + columns={ + "player_id": "id", "player_name": "name", "score": "score", + "market_value": "marketValue", "club": "club", "position": "position", + }, + positions=pd.Series(positions), + score_numeric=pd.Series([float(index) for index in range(1, 12)]), + score_units=pd.Series(list(range(1, 12)), dtype=object), + score_scale=1, + value_numeric=pd.Series([10_000_000.0] * 11), + value_eur=pd.Series([10_000_000] * 11, dtype=object), + value_unit="euros", + team_keys=pd.Series(teams, dtype="string"), + team_raw_to_key={}, + ) + + def test_arena_alias_and_formation_normalization(self): + self.assertEqual(canonical_arena("Kickbasekis Arena").budget_eur, 150_000_000) + self.assertEqual(canonical_arena("Bundesliga Arena").max_players_per_club, 3) + self.assertEqual(normalize_formation(" 4 – 4 – 2 "), "4-4-2") + + def test_player_candidates_use_exact_then_fuzzy_matching(self): + kind, candidates = _candidate_indices(self.prepared, "Manual Player 1") + self.assertEqual((kind, candidates), ("exact", [0])) + kind, candidates = _candidate_indices(self.prepared, "Manual Plaeyr 1") + self.assertEqual(kind, "fuzzy") + self.assertEqual(candidates[0], 0) + + def test_budget_is_separate_from_other_rule_validity(self): + valid = validate_manual_lineup( + self.prepared, list(range(11)), "4-4-2", canonical_arena("Bundesliga Arena"), [], 0 + ) + self.assertTrue(valid["non_budget_valid"]) + self.assertTrue(valid["budget_valid"]) + self.prepared.value_eur[:] = 20_000_000 + over_budget = validate_manual_lineup( + self.prepared, list(range(11)), "4-4-2", canonical_arena("KickbaseKIS Arena"), [], 0 + ) + self.assertTrue(over_budget["non_budget_valid"]) + self.assertFalse(over_budget["budget_valid"]) + self.assertEqual(over_budget["budget_excess_eur"], 70_000_000) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_selected_lineups.py b/tests/test_selected_lineups.py new file mode 100644 index 0000000..f8368d3 --- /dev/null +++ b/tests/test_selected_lineups.py @@ -0,0 +1,44 @@ +from selected_lineups import ( + load_selected_lineup, + make_selected_lineup, + select_lineup_interactively, + selected_lineup_path, +) + + +def _players(): + return [ + { + "id": str(index), + "name": f"Player {index}", + "position": "GK" if index == 1 else "MID", + "market_value": 1_000_000, + } + for index in range(1, 12) + ] + + +def _lineup(value=100): + return make_selected_lineup( + "Bundesliga Arena", _players(), {"value": value}, "manual" + ) + + +def test_save_and_load_selected_lineup(tmp_path): + path = select_lineup_interactively(_lineup(), tmp_path, input_func=lambda _: "yes") + + assert path == selected_lineup_path("Bundesliga Arena", tmp_path).resolve() + assert load_selected_lineup("Bundesliga Arena", tmp_path)["expected_points"]["value"] == 100 + + +def test_declining_selection_does_not_write(tmp_path): + assert select_lineup_interactively(_lineup(), tmp_path, input_func=lambda _: "no") is None + assert load_selected_lineup("Bundesliga Arena", tmp_path) is None + + +def test_existing_selection_requires_replacement_confirmation(tmp_path): + select_lineup_interactively(_lineup(100), tmp_path, input_func=lambda _: "yes") + answers = iter(("yes", "no")) + + assert select_lineup_interactively(_lineup(200), tmp_path, input_func=lambda _: next(answers)) is None + assert load_selected_lineup("Bundesliga Arena", tmp_path)["expected_points"]["value"] == 100