Vscode / tests /test_liveness.py
Erinaldorodrigues's picture
Add transparent prediction radar
7293dcc
Raw
History Blame Contribute Delete
9.88 kB
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]