Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,31 @@
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
},
"teammateMode": "in-process",
"permissions": {
"allow": [
"Bash(pytest:*)",
"Bash(python -m pytest:*)",
"Bash(.venv/Scripts/python -m pytest:*)",
"Bash(.venv\\Scripts\\python -m pytest:*)",
"Bash(backend/.venv/Scripts/python -m pytest:*)",
"Bash(backend\\.venv\\Scripts\\python -m pytest:*)",
"Bash(ruff:*)",
"Bash(python -m ruff:*)",
"Bash(.venv/Scripts/python -m ruff:*)",
"Bash(.venv\\Scripts\\python -m ruff:*)",
"Bash(backend/.venv/Scripts/python -m ruff:*)",
"Bash(backend\\.venv\\Scripts\\python -m ruff:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "prompt",
"if": "Bash(gh pr create*)",
"prompt": "A PR to master is about to be opened. If the code-review skill (/code-review) has NOT already been run against the latest changes in this conversation, respond {\"ok\": false, \"reason\": \"Run /code-review, address its findings, then retry opening the PR.\"}. Otherwise respond {\"ok\": true}."
"if": "Bash(gh pr create:*)",
"prompt": "Hook input: $ARGUMENTS\n\nFirst, read tool_input.command. If it does not invoke `gh pr create`, respond {\"ok\": true} immediately and do nothing else.\n\nOtherwise a PR to master is about to be opened. If the code-review skill (/code-review) has NOT already been run against the latest changes in this conversation, respond {\"ok\": false, \"reason\": \"Run /code-review, address its findings, then retry opening the PR.\"}. Otherwise respond {\"ok\": true}."
}
]
}
Expand All @@ -22,11 +38,11 @@
"hooks": [
{
"type": "prompt",
"if": "Bash(gh pr create*)",
"prompt": "A PR to master was just opened. Unless the technical-writer subagent has already checked docs against this diff earlier in this conversation, respond {\"ok\": false, \"reason\": \"Invoke the technical-writer subagent (.claude/agents/technical-writer.md), make sure you document all changes in the PR summary corrrecly, and the README.md + Mathematical_Specification.md files only if necessary.\"}. Otherwise respond {\"ok\": true}."
"if": "Bash(gh pr create:*)",
"prompt": "Hook input: $ARGUMENTS\n\nFirst, read tool_input.command. If it does not invoke `gh pr create`, respond {\"ok\": true} immediately and do nothing else.\n\nOtherwise a PR to master was just opened. Unless the technical-writer subagent has already checked docs against this diff earlier in this conversation, respond {\"ok\": false, \"reason\": \"Invoke the technical-writer subagent (.claude/agents/technical-writer.md), make sure you document all changes in the PR summary correctly, and the README.md + Mathematical_Specification.md files only if necessary.\"}. Otherwise respond {\"ok\": true}."
}
]
}
]
}
}
}
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
backend:
- 'backend/**'
- '.github/workflows/ci.yml'
# mirrored against METRIC_FIELDS by tests/domain/test_defensive_stats.py
- 'frontend/src/api/players.ts'
frontend:
- 'frontend/**'
- '.github/workflows/ci.yml'
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ flowchart LR
FE["React SPA\nVite :5173"]
subgraph Backend["FastAPI :8000"]
API["API Routers"]
SE["Scoring Engine\nS_final = (Off + Def + Tac) / (min/90)"]
SE["Scoring Engine\nS_final = raw/90 x starter x confidence + bonus"]
PA["Player Assembler\nbuild · merge · aggregate"]
SC["Stats Client\nScraperFC + Chrome"]
MR["Mongo Repository"]
Expand Down Expand Up @@ -65,8 +65,9 @@ flowchart LR

## Core Features

- **Fantasy Scoring** — composite score `S_final = (Offensive + Defensive + Tactical) / (minutes / 90)` with position-specific goal/assist weights; GK goals worth 10 pts, FW goals worth 4 pts
- **Fantasy Scoring** — composite score `S_final = raw_per90 x starter_bonus x confidence + playing_time_bonus`, where `raw_per90` is `(Offensive + Defensive + Tactical) / (minutes / 90)` with position-specific goal/assist weights (GK goals worth 10 pts, FW goals worth 4 pts). `starter_bonus` rewards regular starters and `confidence` discounts small appearance counts — see `Mathematical_Specification.md`
- **Rankings** — paginated player table sorted by `S_final`; filterable by position, team, nationality, and sleeper flag
- **Defensive Metrics** — tackles, interceptions, clearances, blocks, aerial duels, ball recoveries, and errors leading to a shot/goal are tracked per player and sortable/filterable in Rankings; not yet part of `S_final` scoring
- **Sleeper Detection** — `HIGH_VALUE` flags players where xG+xA significantly exceeds G+A; `OVERPERFORMING` flags the inverse; gated on `minutes > 450`
- **Player Detail** — per-competition stat breakdown and aggregated scores for any player, including those without a linked external ID
- **Head-to-Head Compare** — side-by-side comparison of exactly two players across all stat dimensions
Expand Down
14 changes: 14 additions & 0 deletions backend/app/api/modals/player_modals.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ class StatsOut(BaseModel):
headed_goals: int
left_foot_goals: int
right_foot_goals: int
# Defending
tackles: int = 0
tackles_won: int = 0
interceptions: int = 0
clearances: int = 0
blocks: int = 0
aerial_duels_won: int = 0
aerial_lost: int = 0
ball_recoveries: int = 0
dribbled_past: int = 0
errors_lead_to_goal: int = 0
errors_lead_to_shot: int = 0
tackles_won_pct: float = 0.0
aerial_duels_won_pct: float = 0.0


class ScoreOut(BaseModel):
Expand Down
49 changes: 49 additions & 0 deletions backend/app/domain/defensive_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Promote Sofascore defensive columns from raw_stats into the typed Stats model.

raw_stats is the single source for these, so a live fetch and the backfill migration
share one mapping. Rates are derived from counts, never read from Sofascore's own
percentage columns, so they stay correct once counts are summed across competitions.
"""

from app.domain.models import Stats

DEFENSIVE_RAW_MAP: dict[str, str] = {
"tackles": "tackles",
"tacklesWon": "tackles_won",
"interceptions": "interceptions",
"clearances": "clearances",
"outfielderBlocks": "blocks",
"aerialDuelsWon": "aerial_duels_won",
"aerialLost": "aerial_lost",
"ballRecovery": "ball_recoveries",
"dribbledPast": "dribbled_past",
"errorLeadToGoal": "errors_lead_to_goal",
"errorLeadToShot": "errors_lead_to_shot",
}


def apply_defensive_raw(stats: Stats, raw: dict | None) -> Stats:
"""Copy the defensive columns out of one entry's raw_stats onto stats, then derive rates."""
for raw_key, field in DEFENSIVE_RAW_MAP.items():
value = (raw or {}).get(raw_key)
if value is None:
continue
try:
setattr(stats, field, int(float(value)))
except (TypeError, ValueError):
continue
recompute_rates(stats)
return stats


def recompute_rates(stats: Stats) -> Stats:
"""Derive success rates from the current counts. Safe to call after aggregation."""
stats.tackles_won_pct = _pct(stats.tackles_won, stats.tackles)
stats.aerial_duels_won_pct = _pct(
stats.aerial_duels_won, stats.aerial_duels_won + stats.aerial_lost
)
return stats


def _pct(won: float, attempted: float) -> float:
return round(won / attempted * 100, 1) if attempted > 0 else 0.0
15 changes: 15 additions & 0 deletions backend/app/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ class Stats:
headed_goals: int = 0
left_foot_goals: int = 0
right_foot_goals: int = 0
# Defending (promoted from raw_stats; see defensive_stats.py)
tackles: int = 0
tackles_won: int = 0
interceptions: int = 0
clearances: int = 0
blocks: int = 0
aerial_duels_won: int = 0
aerial_lost: int = 0
ball_recoveries: int = 0
dribbled_past: int = 0
errors_lead_to_goal: int = 0
errors_lead_to_shot: int = 0
# Rates recomputed from summed counts, never averaged across competitions
tackles_won_pct: float = 0.0
aerial_duels_won_pct: float = 0.0


@dataclass
Expand Down
13 changes: 13 additions & 0 deletions backend/app/domain/player_assembler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import UTC, datetime

from app.domain.competitions import canonical_competition
from app.domain.defensive_stats import recompute_rates
from app.domain.models import AggregatedScores, CompetitionEntry, PlayerDTO, Stats
from app.domain.scoring_engine import ScoringEngine
from app.domain.sleeper_detector import SleeperDetector
Expand Down Expand Up @@ -48,7 +49,19 @@ def aggregate_stats(entries: list[CompetitionEntry]) -> Stats:
total.headed_goals += s.headed_goals
total.left_foot_goals += s.left_foot_goals
total.right_foot_goals += s.right_foot_goals
total.tackles += s.tackles
total.tackles_won += s.tackles_won
total.interceptions += s.interceptions
total.clearances += s.clearances
total.blocks += s.blocks
total.aerial_duels_won += s.aerial_duels_won
total.aerial_lost += s.aerial_lost
total.ball_recoveries += s.ball_recoveries
total.dribbled_past += s.dribbled_past
total.errors_lead_to_goal += s.errors_lead_to_goal
total.errors_lead_to_shot += s.errors_lead_to_shot
total.scoring_frequency = (total.minutes / total.goals) if total.goals > 0 else 0.0
recompute_rates(total)
return total


Expand Down
79 changes: 5 additions & 74 deletions backend/app/infrastructure/mongo_repository.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import asdict, fields
from datetime import UTC, datetime

from pymongo import ASCENDING, DESCENDING, MongoClient
Expand All @@ -21,83 +22,13 @@


def _stats_to_dict(stats: Stats) -> dict:
return {
"goals": stats.goals,
"assists": stats.assists,
"xg": stats.xg,
"xa": stats.xa,
"minutes": stats.minutes,
"clean_sheets": stats.clean_sheets,
"pk_saved": stats.pk_saved,
"pk_won": stats.pk_won,
"pk_scored": stats.pk_scored,
"pk_taken": stats.pk_taken,
"yellow_cards": stats.yellow_cards,
"red_cards": stats.red_cards,
"yellow_red_cards": stats.yellow_red_cards,
"direct_red_cards": stats.direct_red_cards,
"fouls_committed": stats.fouls_committed,
"rating": stats.rating,
"big_chances_created": stats.big_chances_created,
"key_passes": stats.key_passes,
"appearances": stats.appearances,
"matches_started": stats.matches_started,
"saves": stats.saves,
"saves_outside_box": stats.saves_outside_box,
"goals_conceded": stats.goals_conceded,
"goals_prevented": stats.goals_prevented,
"high_claims": stats.high_claims,
"penalty_conceded": stats.penalty_conceded,
"penalty_faced": stats.penalty_faced,
"total_shots": stats.total_shots,
"shots_on_target": stats.shots_on_target,
"shots_off_target": stats.shots_off_target,
"scoring_frequency": stats.scoring_frequency,
"penalty_miss": stats.penalty_miss,
"headed_goals": stats.headed_goals,
"left_foot_goals": stats.left_foot_goals,
"right_foot_goals": stats.right_foot_goals,
}
return asdict(stats)


def _stats_from_dict(d: dict) -> Stats:
return Stats(
goals=d.get("goals", 0),
assists=d.get("assists", 0),
xg=d.get("xg", 0.0),
xa=d.get("xa", 0.0),
minutes=d.get("minutes", 0),
clean_sheets=d.get("clean_sheets", 0),
pk_saved=d.get("pk_saved", 0),
pk_won=d.get("pk_won", 0),
pk_scored=d.get("pk_scored", 0),
pk_taken=d.get("pk_taken", 0),
yellow_cards=d.get("yellow_cards", 0),
red_cards=d.get("red_cards", 0),
yellow_red_cards=d.get("yellow_red_cards", 0),
direct_red_cards=d.get("direct_red_cards", 0),
fouls_committed=d.get("fouls_committed", 0.0),
rating=d.get("rating", 0.0),
big_chances_created=d.get("big_chances_created", 0),
key_passes=d.get("key_passes", 0),
appearances=d.get("appearances", 0),
matches_started=d.get("matches_started", 0),
saves=d.get("saves", 0),
saves_outside_box=d.get("saves_outside_box", 0),
goals_conceded=d.get("goals_conceded", 0),
goals_prevented=d.get("goals_prevented", 0.0),
high_claims=d.get("high_claims", 0),
penalty_conceded=d.get("penalty_conceded", 0),
penalty_faced=d.get("penalty_faced", 0),
total_shots=d.get("total_shots", 0),
shots_on_target=d.get("shots_on_target", 0),
shots_off_target=d.get("shots_off_target", 0),
scoring_frequency=d.get("scoring_frequency", 0.0),
penalty_miss=d.get("penalty_miss", 0),
headed_goals=d.get("headed_goals", 0),
left_foot_goals=d.get("left_foot_goals", 0),
right_foot_goals=d.get("right_foot_goals", 0),
)
"""Build Stats from a stored doc, ignoring keys the model no longer declares."""
valid = {f.name for f in fields(Stats)}
return Stats(**{k: v for k, v in d.items() if k in valid})


def _bio_doc(player: PlayerDTO) -> dict:
Expand Down
7 changes: 5 additions & 2 deletions backend/app/modes/fetch_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from app.config import settings
from app.domain.competitions import canonical_competition, classify_competition
from app.domain.defensive_stats import apply_defensive_raw
from app.domain.models import CompetitionEntry, Stats
from app.domain.player_assembler import build_player, merge
from app.infrastructure.sofascore_client import SofascoreClient
Expand Down Expand Up @@ -170,11 +171,13 @@ def _fetch_ss(task_idx: int, comp: str, positions: list[str]) -> None:
left_foot_goals=int(row.get("left_foot_goals", 0)),
right_foot_goals=int(row.get("right_foot_goals", 0)),
)
position = str(row.get("_position_group") or row.get("position", "MF"))
score = _scoring.calculate(stats, position)
raw_stats = row.get("_raw_stats") or {}
if not isinstance(raw_stats, dict):
raw_stats = {}
apply_defensive_raw(stats, raw_stats)

position = str(row.get("_position_group") or row.get("position", "MF"))
score = _scoring.calculate(stats, position)
canon = canonical_competition(comp)
entry = CompetitionEntry(
competition=canon,
Expand Down
Loading
Loading