Spaces:
Running
Running
Commit ·
ea721f9
1
Parent(s): 626efb4
Handle unavailable seasons with validated fallback
Browse files- CHECKSUMS.sha256 +6 -6
- app/core/analyzer.py +2 -2
- app/core/competitions.py +3 -0
- app/pipeline.py +5 -0
- app/providers/football_data.py +64 -19
- tests/test_pipeline.py +7 -0
- tests/test_robustness.py +96 -0
CHECKSUMS.sha256
CHANGED
|
@@ -10,9 +10,9 @@ a68770f781b7acacfaaf56e5110d9846b7001e87d9936d433990b8393d5b86e5 TUTORIAL.md
|
|
| 10 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/__init__.py
|
| 11 |
c4891fc509f38fb94d9006be910c18b7573645c11da3532c38356302d99c7e55 app/config.py
|
| 12 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/core/__init__.py
|
| 13 |
-
|
| 14 |
48c155a14807a76713edb7579076fa877587e5cff9e35f8f7d0fef35d821396f app/core/calibration.py
|
| 15 |
-
|
| 16 |
0545a029d7488a10cc6d3dd8d2c69c38d5b6b3246ceb34b035ed0f2b77570e49 app/core/history.py
|
| 17 |
d2a9298d6d322fecb72184990c6c7bd720be5b3b12fb9245615f3687ee890aba app/core/market.py
|
| 18 |
70922a2b322aeb7512ba9147e391687cb8d50083523d03fa3fd6ae9441a94fc2 app/core/names.py
|
|
@@ -21,9 +21,9 @@ abf7d9982399348a65b3f1e1b14ccdbe03e0b37c984eff790ed59248738fa56c app/core/ticke
|
|
| 21 |
8c76f9b59d601719dee412f494a964ea588ff5e6393af49c203765524fe26bd2 app/logging_config.py
|
| 22 |
2c30ffa77b5c69a67ab90a257504af4372909de8e95c4f61e32b752352f112d9 app/main.py
|
| 23 |
34193815ff5356263a850c254ab48a2850b12f3b623831ea81b61708fced9519 app/models.py
|
| 24 |
-
|
| 25 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/providers/__init__.py
|
| 26 |
-
|
| 27 |
1ffb376b0856a863a512d06ae36a736003692a8bbf5e74cfe23704c2c98f29d9 app/providers/http_client.py
|
| 28 |
255c1050280cb7c1816c4c0c05aba96c4ac75c547415ada1e91dd8d17078fea2 app/providers/odds_api.py
|
| 29 |
adfcb95146b325af7cfddb67e1ab29f714f912736374c6d0d384f9ba93139659 app/static/app.js
|
|
@@ -36,5 +36,5 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 data/.gitkeep
|
|
| 36 |
8636f9ab1a075be9f3039e2a6471837259c4f36b625bcaf7a3d9a1edd2419c6d requirements-dev.txt
|
| 37 |
27d9fc069a1ce60bdf78cf32226af023d57a63c5ab4076797edb3d05258a5397 requirements.txt
|
| 38 |
66b37f1efcf7bc1f3b2c2437dc991f489a0413b3de9268565fd17eca882880b2 tests/test_core.py
|
| 39 |
-
|
| 40 |
-
|
|
|
|
| 10 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/__init__.py
|
| 11 |
c4891fc509f38fb94d9006be910c18b7573645c11da3532c38356302d99c7e55 app/config.py
|
| 12 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/core/__init__.py
|
| 13 |
+
9511f0e32638b1951f5e153d76e827bf67c54b250e6863c32fcadd76f01ab43a app/core/analyzer.py
|
| 14 |
48c155a14807a76713edb7579076fa877587e5cff9e35f8f7d0fef35d821396f app/core/calibration.py
|
| 15 |
+
ecc608094af12bc17a728263a7e963bde91ea26ac2a8715101e7493735f95453 app/core/competitions.py
|
| 16 |
0545a029d7488a10cc6d3dd8d2c69c38d5b6b3246ceb34b035ed0f2b77570e49 app/core/history.py
|
| 17 |
d2a9298d6d322fecb72184990c6c7bd720be5b3b12fb9245615f3687ee890aba app/core/market.py
|
| 18 |
70922a2b322aeb7512ba9147e391687cb8d50083523d03fa3fd6ae9441a94fc2 app/core/names.py
|
|
|
|
| 21 |
8c76f9b59d601719dee412f494a964ea588ff5e6393af49c203765524fe26bd2 app/logging_config.py
|
| 22 |
2c30ffa77b5c69a67ab90a257504af4372909de8e95c4f61e32b752352f112d9 app/main.py
|
| 23 |
34193815ff5356263a850c254ab48a2850b12f3b623831ea81b61708fced9519 app/models.py
|
| 24 |
+
87235eccba7b95cc05a9a5239d6c17ceeb8ddc11c1c2efebd6daea2182fbb92f app/pipeline.py
|
| 25 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/providers/__init__.py
|
| 26 |
+
d6f32051d88974c1816db0b83570698cdc35c83d9381ce99d769160905e656f1 app/providers/football_data.py
|
| 27 |
1ffb376b0856a863a512d06ae36a736003692a8bbf5e74cfe23704c2c98f29d9 app/providers/http_client.py
|
| 28 |
255c1050280cb7c1816c4c0c05aba96c4ac75c547415ada1e91dd8d17078fea2 app/providers/odds_api.py
|
| 29 |
adfcb95146b325af7cfddb67e1ab29f714f912736374c6d0d384f9ba93139659 app/static/app.js
|
|
|
|
| 36 |
8636f9ab1a075be9f3039e2a6471837259c4f36b625bcaf7a3d9a1edd2419c6d requirements-dev.txt
|
| 37 |
27d9fc069a1ce60bdf78cf32226af023d57a63c5ab4076797edb3d05258a5397 requirements.txt
|
| 38 |
66b37f1efcf7bc1f3b2c2437dc991f489a0413b3de9268565fd17eca882880b2 tests/test_core.py
|
| 39 |
+
75901c556c3e578d6e075d81a9fb686e6129731c997bbe6555611b85bcc67d56 tests/test_pipeline.py
|
| 40 |
+
2698e47665232af00cc3d951b3c673473eb7a889c364a4da57f01fc809801526 tests/test_robustness.py
|
app/core/analyzer.py
CHANGED
|
@@ -5,7 +5,7 @@ from statistics import pstdev
|
|
| 5 |
|
| 6 |
from app.config import MODEL_VERSION
|
| 7 |
from app.core.calibration import calibrate_probability
|
| 8 |
-
from app.core.competitions import competition_for_sport_key
|
| 9 |
from app.core.market import market_consensus
|
| 10 |
from app.core.names import build_team_catalog, resolve_event_pair
|
| 11 |
from app.core.stats import build_elo, predictive_models, tune_ensemble_weights
|
|
@@ -121,7 +121,7 @@ def analyze_events(
|
|
| 121 |
|
| 122 |
comp_code = spec.football_data_code
|
| 123 |
comp_matches = matches_by_comp.get(comp_code, [])
|
| 124 |
-
if len(comp_matches) <
|
| 125 |
rejected.append({
|
| 126 |
"event": event_name,
|
| 127 |
"reason": f"histórico insuficiente em {comp_code} ({len(comp_matches)} jogos)",
|
|
|
|
| 5 |
|
| 6 |
from app.config import MODEL_VERSION
|
| 7 |
from app.core.calibration import calibrate_probability
|
| 8 |
+
from app.core.competitions import MIN_COMPETITION_HISTORY, competition_for_sport_key
|
| 9 |
from app.core.market import market_consensus
|
| 10 |
from app.core.names import build_team_catalog, resolve_event_pair
|
| 11 |
from app.core.stats import build_elo, predictive_models, tune_ensemble_weights
|
|
|
|
| 121 |
|
| 122 |
comp_code = spec.football_data_code
|
| 123 |
comp_matches = matches_by_comp.get(comp_code, [])
|
| 124 |
+
if len(comp_matches) < MIN_COMPETITION_HISTORY:
|
| 125 |
rejected.append({
|
| 126 |
"event": event_name,
|
| 127 |
"reason": f"histórico insuficiente em {comp_code} ({len(comp_matches)} jogos)",
|
app/core/competitions.py
CHANGED
|
@@ -4,6 +4,9 @@ from dataclasses import dataclass
|
|
| 4 |
from datetime import date
|
| 5 |
|
| 6 |
|
|
|
|
|
|
|
|
|
|
| 7 |
@dataclass(frozen=True)
|
| 8 |
class CompetitionSpec:
|
| 9 |
sport_key: str
|
|
|
|
| 4 |
from datetime import date
|
| 5 |
|
| 6 |
|
| 7 |
+
MIN_COMPETITION_HISTORY = 40
|
| 8 |
+
|
| 9 |
+
|
| 10 |
@dataclass(frozen=True)
|
| 11 |
class CompetitionSpec:
|
| 12 |
sport_key: str
|
app/pipeline.py
CHANGED
|
@@ -165,6 +165,11 @@ class DailyPipeline:
|
|
| 165 |
f"{len(football_meta['errors'])} consulta(s) de histórico falharam; "
|
| 166 |
"o cache foi preservado quando disponível."
|
| 167 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
remaining = odds.quota.get("remaining")
|
| 170 |
if isinstance(remaining, int) and remaining < 50:
|
|
|
|
| 165 |
f"{len(football_meta['errors'])} consulta(s) de histórico falharam; "
|
| 166 |
"o cache foi preservado quando disponível."
|
| 167 |
)
|
| 168 |
+
if football_meta.get("fallbacks"):
|
| 169 |
+
warnings.append(
|
| 170 |
+
f"{len(football_meta['fallbacks'])} temporada(s) atual(is) ainda "
|
| 171 |
+
"não publicada(s); o histórico anterior foi carregado."
|
| 172 |
+
)
|
| 173 |
|
| 174 |
remaining = odds.quota.get("remaining")
|
| 175 |
if isinstance(remaining, int) and remaining < 50:
|
app/providers/football_data.py
CHANGED
|
@@ -6,7 +6,11 @@ from datetime import datetime, timedelta, timezone
|
|
| 6 |
import logging
|
| 7 |
import time
|
| 8 |
|
| 9 |
-
from app.core.competitions import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from app.models import FinishedMatch
|
| 11 |
from app.providers.http_client import ResilientHTTP, ProviderError
|
| 12 |
|
|
@@ -110,7 +114,11 @@ class FootballDataProvider:
|
|
| 110 |
away_aliases=cls._team_aliases(away),
|
| 111 |
)
|
| 112 |
|
| 113 |
-
async def _fetch_season(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
try:
|
| 115 |
payload = await self._get(
|
| 116 |
f"{self.BASE}/competitions/{competition_code}/matches",
|
|
@@ -123,15 +131,15 @@ class FootballDataProvider:
|
|
| 123 |
and "token" in message
|
| 124 |
and any(word in message for word in ("invalid", "missing", "invalido"))
|
| 125 |
)
|
| 126 |
-
if exc.status_code
|
| 127 |
raise
|
| 128 |
-
return [], f"{competition_code}/{season}: {exc}"
|
| 129 |
except Exception as exc:
|
| 130 |
-
return [], f"{competition_code}/{season}: {type(exc).__name__}: {exc}"
|
| 131 |
|
| 132 |
items = payload.get("matches")
|
| 133 |
if not isinstance(items, list):
|
| 134 |
-
return [], f"{competition_code}/{season}: resposta sem lista de partidas"
|
| 135 |
|
| 136 |
out: list[FinishedMatch] = []
|
| 137 |
for item in items:
|
|
@@ -139,8 +147,8 @@ class FootballDataProvider:
|
|
| 139 |
if match:
|
| 140 |
out.append(match)
|
| 141 |
if items and not out:
|
| 142 |
-
return [], f"{competition_code}/{season}: nenhuma partida válida na resposta"
|
| 143 |
-
return out, None
|
| 144 |
|
| 145 |
@staticmethod
|
| 146 |
def _merge(matches: list[FinishedMatch]) -> list[FinishedMatch]:
|
|
@@ -168,18 +176,24 @@ class FootballDataProvider:
|
|
| 168 |
m for m in cached_matches
|
| 169 |
if m.utc_date >= cutoff and any(m.competition == s.football_data_code for s in specs)
|
| 170 |
]
|
| 171 |
-
meta = {
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
for spec in specs:
|
| 175 |
current_season = season_start_year(spec, now.date())
|
| 176 |
comp_cache = [m for m in all_matches if m.competition == spec.football_data_code]
|
| 177 |
|
| 178 |
-
current, error = await self._fetch_season(
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
| 183 |
all_matches.extend(current)
|
| 184 |
|
| 185 |
recent_comp = [
|
|
@@ -188,19 +202,50 @@ class FootballDataProvider:
|
|
| 188 |
]
|
| 189 |
|
| 190 |
previous_loaded = False
|
|
|
|
| 191 |
# Early in a season the current-season sample is too shallow.
|
| 192 |
# Fetch the previous season only when the cache/current data does not
|
| 193 |
# already provide a reasonable modelling base.
|
| 194 |
if len(recent_comp) < 140:
|
| 195 |
-
previous, prev_error = await self._fetch_season(
|
| 196 |
spec.football_data_code,
|
| 197 |
current_season - 1,
|
| 198 |
)
|
| 199 |
if prev_error:
|
| 200 |
meta["errors"].append(prev_error)
|
| 201 |
else:
|
| 202 |
-
previous_loaded = True
|
| 203 |
all_matches.extend(previous)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
comp_final = [
|
| 206 |
m for m in self._merge(all_matches)
|
|
@@ -213,9 +258,9 @@ class FootballDataProvider:
|
|
| 213 |
"previous_loaded": previous_loaded,
|
| 214 |
}
|
| 215 |
|
| 216 |
-
if specs and
|
| 217 |
raise ProviderError(
|
| 218 |
-
"Todas as consultas
|
| 219 |
"o estado anterior foi preservado"
|
| 220 |
)
|
| 221 |
|
|
|
|
| 6 |
import logging
|
| 7 |
import time
|
| 8 |
|
| 9 |
+
from app.core.competitions import (
|
| 10 |
+
MIN_COMPETITION_HISTORY,
|
| 11 |
+
requested_competitions,
|
| 12 |
+
season_start_year,
|
| 13 |
+
)
|
| 14 |
from app.models import FinishedMatch
|
| 15 |
from app.providers.http_client import ResilientHTTP, ProviderError
|
| 16 |
|
|
|
|
| 114 |
away_aliases=cls._team_aliases(away),
|
| 115 |
)
|
| 116 |
|
| 117 |
+
async def _fetch_season(
|
| 118 |
+
self,
|
| 119 |
+
competition_code: str,
|
| 120 |
+
season: int,
|
| 121 |
+
) -> tuple[list[FinishedMatch], str | None, int | None]:
|
| 122 |
try:
|
| 123 |
payload = await self._get(
|
| 124 |
f"{self.BASE}/competitions/{competition_code}/matches",
|
|
|
|
| 131 |
and "token" in message
|
| 132 |
and any(word in message for word in ("invalid", "missing", "invalido"))
|
| 133 |
)
|
| 134 |
+
if exc.status_code in {401, 429} or invalid_token:
|
| 135 |
raise
|
| 136 |
+
return [], f"{competition_code}/{season}: {exc}", exc.status_code
|
| 137 |
except Exception as exc:
|
| 138 |
+
return [], f"{competition_code}/{season}: {type(exc).__name__}: {exc}", None
|
| 139 |
|
| 140 |
items = payload.get("matches")
|
| 141 |
if not isinstance(items, list):
|
| 142 |
+
return [], f"{competition_code}/{season}: resposta sem lista de partidas", 200
|
| 143 |
|
| 144 |
out: list[FinishedMatch] = []
|
| 145 |
for item in items:
|
|
|
|
| 147 |
if match:
|
| 148 |
out.append(match)
|
| 149 |
if items and not out:
|
| 150 |
+
return [], f"{competition_code}/{season}: nenhuma partida válida na resposta", 200
|
| 151 |
+
return out, None, None
|
| 152 |
|
| 153 |
@staticmethod
|
| 154 |
def _merge(matches: list[FinishedMatch]) -> list[FinishedMatch]:
|
|
|
|
| 176 |
m for m in cached_matches
|
| 177 |
if m.utc_date >= cutoff and any(m.competition == s.football_data_code for s in specs)
|
| 178 |
]
|
| 179 |
+
meta = {
|
| 180 |
+
"competitions": {},
|
| 181 |
+
"errors": [],
|
| 182 |
+
"fallbacks": [],
|
| 183 |
+
"cache_loaded": len(all_matches),
|
| 184 |
+
}
|
| 185 |
+
successful_sources = 0
|
| 186 |
|
| 187 |
for spec in specs:
|
| 188 |
current_season = season_start_year(spec, now.date())
|
| 189 |
comp_cache = [m for m in all_matches if m.competition == spec.football_data_code]
|
| 190 |
|
| 191 |
+
current, error, error_status = await self._fetch_season(
|
| 192 |
+
spec.football_data_code,
|
| 193 |
+
current_season,
|
| 194 |
+
)
|
| 195 |
+
if not error:
|
| 196 |
+
successful_sources += 1
|
| 197 |
all_matches.extend(current)
|
| 198 |
|
| 199 |
recent_comp = [
|
|
|
|
| 202 |
]
|
| 203 |
|
| 204 |
previous_loaded = False
|
| 205 |
+
previous_usable: list[FinishedMatch] = []
|
| 206 |
# Early in a season the current-season sample is too shallow.
|
| 207 |
# Fetch the previous season only when the cache/current data does not
|
| 208 |
# already provide a reasonable modelling base.
|
| 209 |
if len(recent_comp) < 140:
|
| 210 |
+
previous, prev_error, _ = await self._fetch_season(
|
| 211 |
spec.football_data_code,
|
| 212 |
current_season - 1,
|
| 213 |
)
|
| 214 |
if prev_error:
|
| 215 |
meta["errors"].append(prev_error)
|
| 216 |
else:
|
|
|
|
| 217 |
all_matches.extend(previous)
|
| 218 |
+
previous_usable = [
|
| 219 |
+
match for match in self._merge(previous)
|
| 220 |
+
if (
|
| 221 |
+
match.competition == spec.football_data_code
|
| 222 |
+
and cutoff <= match.utc_date < now
|
| 223 |
+
)
|
| 224 |
+
]
|
| 225 |
+
previous_loaded = bool(previous_usable)
|
| 226 |
+
|
| 227 |
+
# football-data.org may not publish the newly-started season for
|
| 228 |
+
# every competition at the same time. A 404 is an expected
|
| 229 |
+
# availability gap only when the previous season alone supplies
|
| 230 |
+
# the minimum sample consumed by the analyzer.
|
| 231 |
+
if error:
|
| 232 |
+
fallback_accepted = (
|
| 233 |
+
error_status == 404
|
| 234 |
+
and len(previous_usable) >= MIN_COMPETITION_HISTORY
|
| 235 |
+
)
|
| 236 |
+
if fallback_accepted:
|
| 237 |
+
successful_sources += 1
|
| 238 |
+
meta["fallbacks"].append(
|
| 239 |
+
{
|
| 240 |
+
"competition": spec.football_data_code,
|
| 241 |
+
"requested_season": current_season,
|
| 242 |
+
"fallback_season": current_season - 1,
|
| 243 |
+
"matches": len(previous_usable),
|
| 244 |
+
"reason": "temporada atual ainda indisponível (HTTP 404)",
|
| 245 |
+
}
|
| 246 |
+
)
|
| 247 |
+
else:
|
| 248 |
+
meta["errors"].append(error)
|
| 249 |
|
| 250 |
comp_final = [
|
| 251 |
m for m in self._merge(all_matches)
|
|
|
|
| 258 |
"previous_loaded": previous_loaded,
|
| 259 |
}
|
| 260 |
|
| 261 |
+
if specs and successful_sources == 0 and meta["errors"]:
|
| 262 |
raise ProviderError(
|
| 263 |
+
"Todas as consultas utilizáveis ao football-data.org falharam; "
|
| 264 |
"o estado anterior foi preservado"
|
| 265 |
)
|
| 266 |
|
tests/test_pipeline.py
CHANGED
|
@@ -79,6 +79,12 @@ def test_pipeline_happy_path_persists_complete_state_and_closes_http(monkeypatch
|
|
| 79 |
assert cached == []
|
| 80 |
return _matches(), {
|
| 81 |
"errors": [],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
"competitions": {"PL": {"matches": 45}},
|
| 83 |
}
|
| 84 |
|
|
@@ -116,6 +122,7 @@ def test_pipeline_happy_path_persists_complete_state_and_closes_http(monkeypatch
|
|
| 116 |
assert state["providers"]["football_data"]["ok"] is True
|
| 117 |
assert state["providers"]["odds_api"]["ok"] is True
|
| 118 |
assert state["providers"]["odds_api"]["estimated_cost_per_league"] == 1
|
|
|
|
| 119 |
assert store.load_state()["generated_at"] == state["generated_at"]
|
| 120 |
assert len(store.load_matches()) == 45
|
| 121 |
assert store.load_history() == []
|
|
|
|
| 79 |
assert cached == []
|
| 80 |
return _matches(), {
|
| 81 |
"errors": [],
|
| 82 |
+
"fallbacks": [{
|
| 83 |
+
"competition": "PL",
|
| 84 |
+
"requested_season": 2026,
|
| 85 |
+
"fallback_season": 2025,
|
| 86 |
+
"matches": 45,
|
| 87 |
+
}],
|
| 88 |
"competitions": {"PL": {"matches": 45}},
|
| 89 |
}
|
| 90 |
|
|
|
|
| 122 |
assert state["providers"]["football_data"]["ok"] is True
|
| 123 |
assert state["providers"]["odds_api"]["ok"] is True
|
| 124 |
assert state["providers"]["odds_api"]["estimated_cost_per_league"] == 1
|
| 125 |
+
assert any("histórico anterior" in warning for warning in state["warnings"])
|
| 126 |
assert store.load_state()["generated_at"] == state["generated_at"]
|
| 127 |
assert len(store.load_matches()) == 45
|
| 128 |
assert store.load_history() == []
|
tests/test_robustness.py
CHANGED
|
@@ -13,6 +13,7 @@ from app.core.history import performance_metrics, settle_history
|
|
| 13 |
from app.core.market import market_consensus
|
| 14 |
from app.models import FinishedMatch
|
| 15 |
from app.pipeline import DailyPipeline
|
|
|
|
| 16 |
from app.providers.http_client import ProviderError, ResilientHTTP
|
| 17 |
from app.providers.odds_api import OddsAPIProvider, _odds_api_timestamp
|
| 18 |
from app.storage import StateStore
|
|
@@ -140,6 +141,101 @@ def test_odds_timestamp_normalizes_timezone_and_rollover():
|
|
| 140 |
assert _odds_api_timestamp(local + timedelta(hours=6)) == "2027-01-01T02:59:59Z"
|
| 141 |
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
def test_pipeline_running_includes_direct_lock_holder():
|
| 144 |
pipeline = DailyPipeline(settings=None, store=None)
|
| 145 |
|
|
|
|
| 13 |
from app.core.market import market_consensus
|
| 14 |
from app.models import FinishedMatch
|
| 15 |
from app.pipeline import DailyPipeline
|
| 16 |
+
from app.providers.football_data import FootballDataProvider
|
| 17 |
from app.providers.http_client import ProviderError, ResilientHTTP
|
| 18 |
from app.providers.odds_api import OddsAPIProvider, _odds_api_timestamp
|
| 19 |
from app.storage import StateStore
|
|
|
|
| 141 |
assert _odds_api_timestamp(local + timedelta(hours=6)) == "2027-01-01T02:59:59Z"
|
| 142 |
|
| 143 |
|
| 144 |
+
def test_football_provider_treats_404_with_usable_previous_season_as_fallback(monkeypatch):
|
| 145 |
+
provider = FootballDataProvider("test-token", http=None)
|
| 146 |
+
calls = []
|
| 147 |
+
previous_matches = [
|
| 148 |
+
FinishedMatch(
|
| 149 |
+
match_id=f"previous-{index}",
|
| 150 |
+
competition="PL",
|
| 151 |
+
utc_date=datetime.now(timezone.utc) - timedelta(days=index + 1),
|
| 152 |
+
home="Alpha FC",
|
| 153 |
+
away="Beta FC",
|
| 154 |
+
home_goals=2,
|
| 155 |
+
away_goals=0,
|
| 156 |
+
)
|
| 157 |
+
for index in range(40)
|
| 158 |
+
]
|
| 159 |
+
|
| 160 |
+
async def fake_fetch(competition_code, season):
|
| 161 |
+
calls.append((competition_code, season))
|
| 162 |
+
if len(calls) == 1:
|
| 163 |
+
return [], f"{competition_code}/{season}: HTTP 404", 404
|
| 164 |
+
return previous_matches, None, None
|
| 165 |
+
|
| 166 |
+
monkeypatch.setattr(provider, "_fetch_season", fake_fetch)
|
| 167 |
+
matches, meta = asyncio.run(
|
| 168 |
+
provider.fetch_finished(240, ("soccer_epl",), cached_matches=[])
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
assert matches == list(reversed(previous_matches))
|
| 172 |
+
assert meta["errors"] == []
|
| 173 |
+
assert len(meta["fallbacks"]) == 1
|
| 174 |
+
assert meta["fallbacks"][0]["requested_season"] == calls[0][1]
|
| 175 |
+
assert meta["fallbacks"][0]["fallback_season"] == calls[1][1]
|
| 176 |
+
assert meta["fallbacks"][0]["matches"] == 40
|
| 177 |
+
assert meta["competitions"]["PL"]["previous_loaded"] is True
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@pytest.mark.parametrize(
|
| 181 |
+
("current_status", "previous_count"),
|
| 182 |
+
[(404, 39), (500, 40)],
|
| 183 |
+
)
|
| 184 |
+
def test_football_provider_does_not_mask_failed_or_insufficient_fallback(
|
| 185 |
+
monkeypatch,
|
| 186 |
+
current_status,
|
| 187 |
+
previous_count,
|
| 188 |
+
):
|
| 189 |
+
provider = FootballDataProvider("test-token", http=None)
|
| 190 |
+
calls = 0
|
| 191 |
+
|
| 192 |
+
async def fake_fetch(competition_code, season):
|
| 193 |
+
nonlocal calls
|
| 194 |
+
calls += 1
|
| 195 |
+
if calls == 1:
|
| 196 |
+
return [], f"{competition_code}/{season}: HTTP {current_status}", current_status
|
| 197 |
+
matches = [
|
| 198 |
+
FinishedMatch(
|
| 199 |
+
match_id=f"fallback-{index}",
|
| 200 |
+
competition="PL",
|
| 201 |
+
utc_date=datetime.now(timezone.utc) - timedelta(days=index + 1),
|
| 202 |
+
home="Alpha FC",
|
| 203 |
+
away="Beta FC",
|
| 204 |
+
home_goals=1,
|
| 205 |
+
away_goals=0,
|
| 206 |
+
)
|
| 207 |
+
for index in range(previous_count)
|
| 208 |
+
]
|
| 209 |
+
return matches, None, None
|
| 210 |
+
|
| 211 |
+
monkeypatch.setattr(provider, "_fetch_season", fake_fetch)
|
| 212 |
+
with pytest.raises(ProviderError, match="Todas as consultas utilizáveis"):
|
| 213 |
+
asyncio.run(provider.fetch_finished(240, ("soccer_epl",), cached_matches=[]))
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@pytest.mark.parametrize(
|
| 217 |
+
("status_code", "message"),
|
| 218 |
+
[
|
| 219 |
+
(401, "unauthorized"),
|
| 220 |
+
(429, "rate limit exceeded"),
|
| 221 |
+
(400, "Your API token is invalid"),
|
| 222 |
+
],
|
| 223 |
+
)
|
| 224 |
+
def test_football_provider_fails_fast_for_auth_or_rate_limit(
|
| 225 |
+
monkeypatch,
|
| 226 |
+
status_code,
|
| 227 |
+
message,
|
| 228 |
+
):
|
| 229 |
+
provider = FootballDataProvider("test-token", http=None)
|
| 230 |
+
|
| 231 |
+
async def failing_get(_url, _params):
|
| 232 |
+
raise ProviderError(message, status_code)
|
| 233 |
+
|
| 234 |
+
monkeypatch.setattr(provider, "_get", failing_get)
|
| 235 |
+
with pytest.raises(ProviderError, match=message):
|
| 236 |
+
asyncio.run(provider._fetch_season("PL", 2026))
|
| 237 |
+
|
| 238 |
+
|
| 239 |
def test_pipeline_running_includes_direct_lock_holder():
|
| 240 |
pipeline = DailyPipeline(settings=None, store=None)
|
| 241 |
|