import asyncio from collections import Counter from datetime import datetime, timedelta, timezone import math import random from types import SimpleNamespace import pytest import app.pipeline as pipeline_module from app.models import FinishedMatch from app.pipeline import DailyPipeline from app.storage import StateStore PRODUCTION_GATES = { "min_safe_score": 76.0, "min_probability": 0.64, "min_conservative_probability": 0.57, "min_bookmakers": 3, "min_name_score": 82.0, } def _settings(): return SimpleNamespace( football_data_token="football-token", odds_api_key="odds-key", odds_regions="eu", sport_keys=("soccer_epl",), history_days=240, scan_horizon_hours=36, top_picks_limit=10, **PRODUCTION_GATES, ) def _poisson_sample(rng: random.Random, rate: float) -> int: threshold = math.exp(-rate) product = 1.0 goals = 0 while product > threshold: goals += 1 product *= rng.random() return goals - 1 def _round_robin(teams: list[str]) -> list[list[tuple[str, str]]]: rotation = list(teams) first_leg: list[list[tuple[str, str]]] = [] for round_index in range(len(rotation) - 1): games: list[tuple[str, str]] = [] for index in range(len(rotation) // 2): home, away = rotation[index], rotation[-1 - index] if (round_index + index) % 2: home, away = away, home games.append((home, away)) first_leg.append(games) rotation = [rotation[0], rotation[-1], *rotation[1:-1]] second_leg = [[(away, home) for home, away in games] for games in first_leg] return first_leg + second_leg def _plausible_history(now: datetime) -> list[FinishedMatch]: teams = [ "Northbridge City Football Club", *[f"Midlands Team {index:02d} FC" for index in range(2, 20)], "Riverside United Football Club", ] ids = {team: str(100 + index) for index, team in enumerate(teams)} ratings = { team: 1.8 - 3.6 * index / (len(teams) - 1) for index, team in enumerate(teams) } rng = random.Random(9221) matches: list[FinishedMatch] = [] # Twenty teams playing 28 weekly rounds gives each club 28 observations over # a realistic 210-day season window, including home and away fixtures. for round_index, games in enumerate(_round_robin(teams)[:28]): match_date = now - timedelta(days=210 - 7 * round_index) for home, away in games: strength_delta = ratings[home] - ratings[away] home_rate = max(0.30, min(3.40, 1.42 * math.exp(0.34 * strength_delta))) away_rate = max(0.20, min(2.80, 1.05 * math.exp(-0.34 * strength_delta))) home_alias = home.replace(" Football Club", "").replace(" FC", "") away_alias = away.replace(" Football Club", "").replace(" FC", "") matches.append(FinishedMatch( match_id=str(len(matches)), competition="PL", utc_date=match_date, home=home, away=away, home_goals=_poisson_sample(rng, home_rate), away_goals=_poisson_sample(rng, away_rate), home_id=ids[home], away_id=ids[away], home_aliases=(home, home_alias), away_aliases=(away, away_alias), )) return matches def _plausible_event(now: datetime) -> dict: prices = [ (1.42, 4.70, 9.50), (1.43, 4.75, 9.75), (1.44, 4.80, 10.00), (1.45, 4.75, 9.75), (1.46, 4.70, 9.50), ] bookmakers = [] for index, (home_odd, draw_odd, away_odd) in enumerate(prices): bookmakers.append({ "key": f"book-{index}", "title": f"Book {index}", "last_update": now.isoformat(), "markets": [{ "key": "h2h", "outcomes": [ {"name": "Northbridge City", "price": home_odd}, {"name": "Draw", "price": draw_odd}, {"name": "Riverside United", "price": away_odd}, ], }], }) return { "id": "plausible-epl-event", "sport_key": "soccer_epl", "_sport_key": "soccer_epl", "home_team": "Northbridge City", "away_team": "Riverside United", "commence_time": (now + timedelta(hours=12)).isoformat(), "bookmakers": bookmakers, } class RecordingStore(StateStore): def __init__(self, data_dir): super().__init__(data_dir) self.saved_statuses: list[str] = [] self.backups = 0 def save_state(self, state): self.saved_statuses.append(state.get("status")) super().save_state(state) def backup_to_hub(self): self.backups += 1 def test_pipeline_approves_and_persists_one_plausible_positive_ev_pick( monkeypatch, tmp_path, ): now = datetime.now(timezone.utc) matches = _plausible_history(now) event = _plausible_event(now) appearances = Counter( team for match in matches for team in (match.home, match.away) ) draw_rate = sum(match.home_goals == match.away_goals for match in matches) / len(matches) assert len(matches) == 280 assert set(appearances.values()) == {28} assert 0.18 <= draw_rate <= 0.28 assert all(0.96 <= sum(1.0 / odd for odd in prices) <= 1.30 for prices in ( (1.42, 4.70, 9.50), (1.43, 4.75, 9.75), (1.44, 4.80, 10.00), (1.45, 4.75, 9.75), (1.46, 4.70, 9.50), )) closed = [] class FakeHTTP: def __init__(self, *args, **kwargs): pass async def aclose(self): closed.append(True) class FakeFootball: def __init__(self, token, http): assert token == "football-token" async def fetch_finished(self, history_days, sport_keys, cached): assert history_days == 240 assert sport_keys == ("soccer_epl",) assert cached == [] return matches, { "errors": [], "fallbacks": [], "competitions": {"PL": {"matches": len(matches)}}, } class FakeOdds: def __init__(self, api_key, http, regions): assert api_key == "odds-key" assert regions == "eu" self.region_count = 1 self.quota = {"remaining": 499, "used": 1, "last": 1} self.inactive_keys = [] self.queried_keys = ["soccer_epl"] self.errors = [] async def fetch_events(self, sport_keys, horizon_hours): assert sport_keys == ("soccer_epl",) assert horizon_hours == 36 return [event] monkeypatch.setattr(pipeline_module, "ResilientHTTP", FakeHTTP) monkeypatch.setattr(pipeline_module, "FootballDataProvider", FakeFootball) monkeypatch.setattr(pipeline_module, "OddsAPIProvider", FakeOdds) store = RecordingStore(tmp_path) state = asyncio.run(DailyPipeline(_settings(), store).scan()) assert store.saved_statuses == ["scanning", "ok"] assert state["status"] == "ok" assert state["summary"]["events"] == 1 assert state["summary"]["historical_matches"] == 280 assert state["summary"]["approved"] == 1 assert state["summary"]["rejected"] == 0 assert state["summary"]["radar"] == 0 assert state["rejected_preview"] == [] assert state["radar"] == [] assert len(state["picks"]) == 1 pick = state["picks"][0] assert pick["event_id"] == "plausible-epl-event" assert pick["selection"] == "Northbridge City" assert pick["side"] == "home" # The odds provider names are exact, unambiguous aliases of the canonical # football-data names, so identity resolution must retain the stable IDs. assert pick["resolved_home_key"] == "id:100" assert pick["resolved_away_key"] == "id:119" assert pick["name_confidence"] >= PRODUCTION_GATES["min_name_score"] / 100.0 assert pick["safe_score"] >= PRODUCTION_GATES["min_safe_score"] assert pick["probability"] >= PRODUCTION_GATES["min_probability"] assert pick["conservative_probability"] >= PRODUCTION_GATES["min_conservative_probability"] assert pick["conservative_probability"] <= pick["probability"] assert pick["market_bookmakers"] >= PRODUCTION_GATES["min_bookmakers"] assert pick["market_dispersion"] <= 0.060 assert pick["quality"] >= 0.52 assert pick["disagreement"] <= 0.095 assert pick["model_detail"]["core_model_floor"] >= 0.50 or pick["probability"] >= 0.74 assert abs(pick["raw_model_probability"] - pick["market_probability"]) <= 0.17 assert 1.15 <= pick["odd"] <= 2.15 assert pick["model_ev"] >= 0.0 assert pick["market_move"] >= -0.04 assert "Risk Gate aprovado" in pick["reasons"] assert pick["model_ev"] == pytest.approx( pick["probability"] * pick["odd"] - 1.0, abs=0.002, ) assert pick["edge"] == pytest.approx( pick["probability"] - pick["market_probability"], abs=0.002, ) assert pick["fair_odd"] == pytest.approx(1.0 / pick["probability"], abs=0.002) persisted = store.load_state() assert persisted["generated_at"] == state["generated_at"] assert persisted["picks"] == state["picks"] assert len(store.load_matches()) == 280 history = store.load_history() assert len(history) == 1 assert history[0]["event_id"] == pick["event_id"] assert history[0]["selection"] == pick["selection"] assert history[0]["probability"] == pick["probability"] assert history[0]["conservative_probability"] == pick["conservative_probability"] assert history[0]["result"] is None assert store.backups == 1 assert closed == [True]