3b065775f5
Phase 1: Data Engineering
- Refactored notebooks into src/{scraper,features,models,optimization,bot}
- FBref scraper with proxy rotation + Playwright Cloudflare bypass
- Fantacalcio.it integrated scraper (authenticated API + HTML fallback)
- api-football RapidAPI client for supplementary xG/xA/injuries
- RAG news pipeline: Gazzetta, Sky Sport, Di Marzio → injury/suspension/tactical extraction
- 26/27 season config: teams, scoring rules, name mappings, news sources
Phase 2: SOTA ML Architecture
- GBM Ensemble (LightGBM + CatBoost + XGBoost) with stacked blending
- Bootstrap ensemble for uncertainty quantification
- SinhArcsinh distribution head (ported from original TF Probability)
- Card classifiers (yellow/red), penalty model, goal probability (Poisson)
- Temporal GNN for player interaction modeling (crosses→goals, passes→assists)
- Optuna hyperparameter tuning with time-series CV
Phase 3: Operations Research
- Auction solver: MILP knapsack with PuLP (budget + role constraints)
- Grid Auction (Asta a Griglia): Minimax game theory bidding strategy
- Weekly lineup optimizer: MCTS maximizing win probability vs opponent
- Modificatore Difesa integration + captain selection
- Transfer market analyzer: buy-low/sell-high via xG regression to mean
- Opponent behavior modeling from historical lineage patterns
Phase 4: Agentic Workflow
- Telegram bot: auto-briefing (Friday + Sunday morning)
- Tactical briefing generator with start/sit recommendations
- GitHub Actions CI/CD: scheduled pipeline (scrape → predict → notify)
Infrastructure:
- 31 pytest unit tests (features, models, scraper, optimization)
- requirements.txt (lightgbm, catboost, xgboost, optuna, pulp, playwright, langchain)
- Makefile with install/test/lint/scrape/train/bot targets
- Jupyter notebook: 26_27_strategy.ipynb demonstrating auction + matchday 1 mockup
- Completely rewritten README.md with architecture diagram
137 lines
4.6 KiB
Python
137 lines
4.6 KiB
Python
"""Tests for the feature engineering pipeline."""
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from src.features.vote_processor import VoteProcessor
|
|
from src.features.advanced_metrics import FatigueIndex, PitchTilt, WeatherContext
|
|
from src.features.news_rag import NewsRAGPipeline
|
|
|
|
|
|
class TestVoteProcessor:
|
|
def test_fantavote_computation(self):
|
|
"""Verify fantavote = vote + goals*3 + assists - yellow*0.5 - red."""
|
|
processor = VoteProcessor()
|
|
df = pd.DataFrame({
|
|
"matchday": [1],
|
|
"player": ["Test Player"],
|
|
"team": ["Team A"],
|
|
"oppteam": ["Team B"],
|
|
"home": [1],
|
|
"vote": [6.5],
|
|
"goals": [1],
|
|
"assists": [1],
|
|
"cards_malus": [0.5],
|
|
"fantavote": [10.0],
|
|
})
|
|
|
|
# fantavote should be: 6.5 + 3 + 1 - 0.5 = 10.0
|
|
assert df["fantavote"].iloc[0] == 10.0
|
|
|
|
def test_own_goal_deduction(self):
|
|
processor = VoteProcessor()
|
|
scoring = processor.scoring
|
|
|
|
vote = 6.0
|
|
goals = 0 # field goal
|
|
own_goals = 1 # own goal
|
|
assists = 0
|
|
|
|
goals_net = goals - own_goals
|
|
goals_bonus = max(0, goals_net) * scoring["goal"]
|
|
own_goal_malus = max(0, own_goals) * abs(scoring["own_goal"])
|
|
cards_malus = 0
|
|
|
|
fantavote = vote + goals_bonus + 0 - cards_malus - own_goal_malus
|
|
assert fantavote == 6.0 - 2.0 # 6 - 2 for own goal
|
|
|
|
def test_compute_player_averages(self):
|
|
processor = VoteProcessor()
|
|
votes = pd.DataFrame({
|
|
"player": ["A", "A", "A", "A", "B", "B"],
|
|
"team": ["T1", "T1", "T1", "T1", "T2", "T2"],
|
|
"vote": [6.0, 7.0, 6.5, 7.5, 6.0, 6.0],
|
|
})
|
|
|
|
result = processor.compute_player_averages(votes, min_votes=2)
|
|
assert "vote_avg" in result.columns
|
|
assert result["n_matches"].iloc[0] == 4 # Player A has 4
|
|
|
|
|
|
class TestAdvancedMetrics:
|
|
def test_fatigue_rest_days(self):
|
|
fi = FatigueIndex()
|
|
match_dates = pd.Series(["2026-09-20", "2026-09-27", "2026-10-04"])
|
|
prev_dates = pd.Series(["2026-09-13", "2026-09-20", "2026-09-27"])
|
|
rest = fi.rest_days(match_dates, prev_dates)
|
|
assert all(rest == 7)
|
|
|
|
def test_pitch_tilt(self):
|
|
pt = PitchTilt()
|
|
own = np.array([50.0, 100.0])
|
|
opp = np.array([50.0, 50.0])
|
|
tilt = pt.pitch_tilt(own, opp)
|
|
assert abs(tilt[0] - 0.5) < 1e-6
|
|
assert abs(tilt[1] - 2.0 / 3.0) < 1e-6
|
|
|
|
def test_field_tilt(self):
|
|
pt = PitchTilt()
|
|
own_passes = np.array([30.0])
|
|
opp_passes = np.array([50.0])
|
|
tilt = pt.field_tilt(own_passes, opp_passes)
|
|
assert abs(tilt[0] - 30 / 80) < 1e-6
|
|
|
|
def test_pressure_regain(self):
|
|
pt = PitchTilt()
|
|
efficiency = pt.pressure_regain_efficiency(
|
|
np.array([10.0]), np.array([100.0])
|
|
)
|
|
assert efficiency[0] == 0.1
|
|
|
|
def test_weather_context(self):
|
|
ctx = WeatherContext.get_context("Milano", 1) # January
|
|
assert ctx[0] == "cold"
|
|
assert ctx[1] > 0
|
|
|
|
ctx = WeatherContext.get_context("Napoli", 12) # December
|
|
assert ctx[0] == "warm"
|
|
|
|
|
|
class TestNewsRAG:
|
|
def test_simple_extract_injury(self):
|
|
rag = NewsRAGPipeline()
|
|
entities = rag._simple_extract(
|
|
"Lautaro Martinez infortunio: salta la partita contro il Milan. "
|
|
"L'attaccante ha riportato uno stiramento muscolare."
|
|
)
|
|
injury_entities = [e for e in entities if e["type"] == "INJURY"]
|
|
assert len(injury_entities) > 0
|
|
|
|
def test_simple_extract_suspension(self):
|
|
rag = NewsRAGPipeline()
|
|
entities = rag._simple_extract(
|
|
"Barella squalificato per una giornata dopo l'ammonizione. "
|
|
"Salterà il prossimo turno."
|
|
)
|
|
suspension_entities = [e for e in entities if e["type"] == "SUSPENSION"]
|
|
assert len(suspension_entities) > 0
|
|
|
|
def test_simple_extract_tactical(self):
|
|
rag = NewsRAGPipeline()
|
|
entities = rag._simple_extract(
|
|
"La Juventus cambia modulo: passa al 3-5-2 contro l'Inter. "
|
|
"Cambiaso e Di Lorenzo sulle fasce."
|
|
)
|
|
tactical_entities = [e for e in entities if e["type"] == "TACTICAL_SHIFT"]
|
|
assert len(tactical_entities) > 0
|
|
|
|
def test_to_features(self):
|
|
rag = NewsRAGPipeline()
|
|
entities = [
|
|
{"type": "INJURY", "source_text": "Osimhen injured", "article_link": "", "source": ""},
|
|
]
|
|
features = rag.to_features(entities, ["Osimhen", "Kvaratskhelia"])
|
|
assert features["news_injury_flag"].iloc[0] == 1
|
|
assert features["news_injury_flag"].iloc[1] == 0
|