Spaces:
Sleeping
Sleeping
| 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] | |