Vscode / app /core /analyzer.py
Erinaldorodrigues's picture
Use explicit risk gates for SAFE approval
5e23899
Raw
History Blame Contribute Delete
20.1 kB
from __future__ import annotations
from datetime import datetime, timezone
from statistics import pstdev
from app.config import MODEL_VERSION
from app.core.calibration import calibrate_probability
from app.core.competitions import MIN_COMPETITION_HISTORY, competition_for_sport_key
from app.core.market import market_consensus
from app.core.names import build_team_catalog, resolve_event_pair
from app.core.stats import build_elo, predictive_models, tune_ensemble_weights
from app.models import FinishedMatch, Pick
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
def _renormalize(values: tuple[float, float, float]) -> tuple[float, float, float]:
total = sum(values)
if total <= 0:
return 1 / 3, 1 / 3, 1 / 3
return tuple(v / total for v in values) # type: ignore[return-value]
def _market_quality(bookmakers: int, dispersion: float, stale: int) -> float:
depth = _clamp((bookmakers - 1) / 5.0)
stability = _clamp(1.0 - dispersion / 0.075)
freshness = _clamp(1.0 - stale / max(1.0, bookmakers + stale))
return 0.48 * depth + 0.38 * stability + 0.14 * freshness
def _safe_score(
probability: float,
conservative: float,
data_quality: float,
reliability: float,
agreement: float,
market_quality: float,
edge: float,
) -> float:
probability_component = _clamp((probability - 0.58) / 0.25)
conservative_component = _clamp((conservative - 0.53) / 0.20)
value_component = _clamp((edge + 0.015) / 0.075)
return 100.0 * (
0.31 * probability_component
+ 0.25 * conservative_component
+ 0.13 * data_quality
+ 0.12 * reliability
+ 0.08 * agreement
+ 0.07 * market_quality
+ 0.04 * value_component
)
def _kickoff(event: dict) -> datetime | None:
raw = event.get("commence_time")
if not raw:
return None
try:
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except Exception:
return None
def analyze_events(
events: list[dict],
matches: list[FinishedMatch],
min_safe_score: float,
limit: int,
*,
calibration_history: list[dict] | None = None,
previous_picks: list[dict] | None = None,
min_probability: float = 0.64,
min_conservative_probability: float = 0.57,
min_bookmakers: int = 3,
min_name_score: float = 82.0,
) -> tuple[list[Pick], list[dict]]:
if not matches:
return [], [{"reason": "sem histórico"}]
calibration_history = calibration_history or []
previous_by_event = {
str(p.get("event_id")): p
for p in (previous_picks or [])
if p.get("event_id")
}
competitions = sorted({m.competition for m in matches})
matches_by_comp = {
code: [m for m in matches if m.competition == code]
for code in competitions
}
catalogs = {
code: build_team_catalog(comp_matches, code)
for code, comp_matches in matches_by_comp.items()
}
elo_by_comp = {
code: build_elo(comp_matches)
for code, comp_matches in matches_by_comp.items()
}
tuning_by_comp = {
code: tune_ensemble_weights(comp_matches, code)
for code, comp_matches in matches_by_comp.items()
}
picks: list[Pick] = []
rejected: list[dict] = []
for event in events:
home_api = str(event.get("home_team") or "")
away_api = str(event.get("away_team") or "")
sport_key = str(event.get("_sport_key") or "")
spec = competition_for_sport_key(sport_key)
event_name = f"{home_api} x {away_api}"
if not spec:
rejected.append({"event": event_name, "reason": "competição sem mapeamento seguro"})
continue
comp_code = spec.football_data_code
comp_matches = matches_by_comp.get(comp_code, [])
if len(comp_matches) < MIN_COMPETITION_HISTORY:
rejected.append({
"event": event_name,
"reason": f"histórico insuficiente em {comp_code} ({len(comp_matches)} jogos)",
})
continue
kickoff = _kickoff(event)
if kickoff is None:
rejected.append({"event": event_name, "reason": "horário inválido"})
continue
if kickoff <= datetime.now(timezone.utc):
rejected.append({"event": event_name, "reason": "evento já iniciado"})
continue
market = market_consensus(event)
if market.bookmakers < 1 or market.home_prob is None or market.away_prob is None or market.draw_prob is None:
rejected.append({"event": event_name, "reason": "sem consenso H2H utilizável"})
continue
catalog = catalogs.get(comp_code, [])
home_identity, away_identity, name_confidence, name_detail = resolve_event_pair(
home_api,
away_api,
catalog,
minimum=min_name_score,
)
if not home_identity or not away_identity:
rejected.append({
"event": event_name,
"reason": (
"matching de times ambíguo "
f"(casa {name_detail['home_score']:.0f}, fora {name_detail['away_score']:.0f})"
),
})
continue
model = predictive_models(
home_identity.key,
away_identity.key,
comp_matches,
elo_by_comp.get(comp_code, {}),
competition=comp_code,
as_of=kickoff,
ensemble_weights=tuple(tuning_by_comp[comp_code]["weights"]),
)
poisson = tuple(float(v) for v in model["poisson"])
elo_p = tuple(float(v) for v in model["elo"])
form = tuple(float(v) for v in model["form"])
internal = tuple(float(v) for v in model["ensemble"])
data_quality = float(model["quality"])
market_vector = (
float(market.home_prob),
float(market.draw_prob),
float(market.away_prob),
)
market_q = _market_quality(
market.bookmakers,
market.dispersion,
market.stale_bookmakers,
)
tuning = tuning_by_comp[comp_code]
tuning_total_samples = float(tuning["samples"])
tuning_samples = float(tuning.get("validation_samples", tuning_total_samples))
tuning_skill = float(tuning.get("brier_skill", 0.0))
if tuning_samples >= 10:
sample_validation = _clamp((tuning_samples - 10.0) / 12.0)
skill_validation = _clamp((tuning_skill + 0.03) / 0.12)
model_validation = 0.35 * sample_validation + 0.65 * skill_validation
else:
# Unknown is not the same as bad. Keep the model usable, but make the
# current market prior more influential until walk-forward evidence grows.
model_validation = 0.45
overall_disagreement = max(
pstdev([poisson[i], elo_p[i], form[i]])
for i in range(3)
)
agreement = _clamp(1.0 - overall_disagreement / 0.11)
# The betting market is treated as a strong prior, not as a model feature.
# Good internal data earns more weight; weak/unstable data is shrunk harder
# toward the de-vig market consensus.
base_internal_weight = _clamp(
0.36
+ 0.20 * data_quality
+ 0.08 * agreement
+ 0.05 * (1.0 - market_q),
0.36,
0.67,
)
# Out-of-sample validation acts as a trust regulator. A model that has not
# demonstrated skill does not get to overpower a deep current market simply
# because its internal components happen to agree.
internal_weight = _clamp(
base_internal_weight * (0.82 + 0.18 * model_validation),
0.32,
0.65,
)
posterior_vector = _renormalize(tuple(
internal_weight * internal[i] + (1.0 - internal_weight) * market_vector[i]
for i in range(3)
))
candidate_rows = [
("home", home_api, 0, market.home_odd, market.home_prob),
("away", away_api, 2, market.away_odd, market.away_prob),
]
best = None
best_rejected = None
previous = previous_by_event.get(str(event.get("id") or ""))
for side, selection, idx, odd, mprob in candidate_rows:
if odd is None or mprob is None:
continue
side_market_dispersion = (
market.home_dispersion if side == "home" else market.away_dispersion
)
side_disagreement = pstdev([poisson[idx], elo_p[idx], form[idx]])
side_agreement = _clamp(1.0 - side_disagreement / 0.11)
raw_p = float(internal[idx])
anchored_p = float(posterior_vector[idx])
core_model_floor = min(float(poisson[idx]), float(elo_p[idx]))
calibrated_p, calibration_meta = calibrate_probability(
anchored_p,
calibration_history,
model_version=MODEL_VERSION,
competition_code=comp_code,
)
reliability = _clamp(
0.30 * data_quality
+ 0.22 * side_agreement
+ 0.18 * market_q
+ 0.20 * name_confidence
+ 0.10 * model_validation
)
# This is deliberately a reliability shrinkage, not a claimed
# frequentist confidence interval.
conservative = 0.5 + max(0.0, calibrated_p - 0.5) * reliability
edge = calibrated_p - float(mprob)
ev = calibrated_p * float(odd) - 1.0
score = _safe_score(
calibrated_p,
conservative,
data_quality,
reliability,
side_agreement,
market_q,
edge,
)
market_move = 0.0
selection_changed = False
version_conflict = bool(
previous and previous.get("model_version") != MODEL_VERSION
)
if previous and previous.get("model_version") == MODEL_VERSION:
if previous.get("side") == side and isinstance(previous.get("market_probability"), (int, float)):
market_move = float(mprob) - float(previous["market_probability"])
elif previous.get("side") and previous.get("side") != side:
selection_changed = True
reasons: list[str] = []
if name_confidence < min_name_score / 100.0:
reasons.append("matching de time abaixo do mínimo")
if market.bookmakers < min_bookmakers:
reasons.append(f"poucas casas no consenso ({market.bookmakers})")
if side_market_dispersion > 0.060:
reasons.append("mercado muito disperso para a seleção")
if data_quality < 0.52:
reasons.append("qualidade de dados insuficiente")
if reliability < 0.60:
reasons.append("confiabilidade combinada abaixo do mínimo")
if calibrated_p < min_probability:
reasons.append("probabilidade abaixo do filtro")
if conservative < min_conservative_probability:
reasons.append("probabilidade conservadora baixa")
if side_disagreement > 0.095:
reasons.append("modelos divergentes")
if core_model_floor < 0.50 and calibrated_p < 0.74:
reasons.append("Poisson/Elo não sustentam o favorito com segurança")
if abs(raw_p - float(mprob)) > 0.17:
reasons.append("modelo interno diverge demais do mercado")
if not 1.15 <= float(odd) <= 2.15:
reasons.append("odd de referência fora da faixa SAFE")
if ev < 0.0:
reasons.append("retorno esperado negativo no preço de referência")
if market_move < -0.04:
reasons.append("movimento de mercado relevante contra a seleção")
if selection_changed:
reasons.append("seleção mudou desde o último scan")
if version_conflict:
reasons.append("evento já rastreado por uma versão anterior")
# SafeScore is a transparent ranking/label. Approval is controlled by
# the explicit safety gates above, avoiding a second, opaque veto over
# candidates that already satisfy every measurable requirement.
row = {
"side": side,
"selection": selection,
"idx": idx,
"odd": float(odd),
"mprob": float(mprob),
"raw_p": raw_p,
"p": calibrated_p,
"conservative": conservative,
"edge": edge,
"ev": ev,
"score": score,
"reliability": reliability,
"disagreement": side_disagreement,
"market_move": market_move,
"market_dispersion": side_market_dispersion,
"core_model_floor": core_model_floor,
"model_validation": model_validation,
"calibration_delta": float(calibration_meta["delta"]),
"calibration_samples": float(calibration_meta["effective_samples"]),
"reasons": reasons,
"models": {
"poisson": poisson[idx],
"elo": elo_p[idx],
"form": form[idx],
"internal": raw_p,
"market": float(mprob),
"posterior_pre_calibration": anchored_p,
"lambda_home": float(model["lambda_home"]),
"lambda_away": float(model["lambda_away"]),
"rho": float(model["rho"]),
"league_draw_rate": float(model["league_draw_rate"]),
"league_sample": float(model["league_sample"]),
"internal_weight": internal_weight,
"base_internal_weight": base_internal_weight,
"model_validation": model_validation,
"core_model_floor": core_model_floor,
"calibration_samples": float(calibration_meta["effective_samples"]),
"weight_poisson": float(model["weight_poisson"]),
"weight_elo": float(model["weight_elo"]),
"weight_form": float(model["weight_form"]),
"tuning_samples": tuning_samples,
"tuning_total_samples": tuning_total_samples,
"tuning_brier": float(tuning["brier"]),
"tuning_climatology_brier": float(tuning.get("climatology_brier", 0.0)),
"tuning_brier_skill": tuning_skill,
"tuning_gain": float(tuning["gain"]),
},
}
rank = (row["conservative"], row["score"], row["ev"])
if row["reasons"]:
if best_rejected is None or rank > (
best_rejected["conservative"],
best_rejected["score"],
best_rejected["ev"],
):
best_rejected = row
elif best is None or rank > (
best["conservative"],
best["score"],
best["ev"],
):
best = row
if best is None and best_rejected is None:
rejected.append({"event": event_name, "reason": "mercado incompleto"})
continue
if best is None:
assert best_rejected is not None
blockers = list(best_rejected["reasons"])
rejected.append({
"approved": False,
"event_id": str(event.get("id") or f"{home_api}-{away_api}-{kickoff.isoformat()}"),
"event": event_name,
"kickoff": kickoff.isoformat(),
"competition": spec.label,
"competition_code": comp_code,
"home": home_api,
"away": away_api,
"selection": best_rejected["selection"],
"side": best_rejected["side"],
"odd": round(best_rejected["odd"], 3),
"fair_odd": round(1.0 / max(best_rejected["p"], 1e-9), 3),
"market_probability": round(best_rejected["mprob"], 4),
"model_ev": round(best_rejected["ev"], 4),
"edge": round(best_rejected["edge"], 4),
"quality": round(data_quality, 4),
"reliability": round(best_rejected["reliability"], 4),
"market_bookmakers": market.bookmakers,
"label": "EM OBSERVAÇÃO",
"blockers": blockers,
"reason": "; ".join(blockers),
"safe_score": round(best_rejected["score"], 1),
"probability": round(best_rejected["p"], 4),
"conservative_probability": round(best_rejected["conservative"], 4),
})
continue
score = float(best["score"])
label = "ULTRA SELECTIVO" if score >= 89 else "SAFE" if score >= 82 else "SELECTIVO"
why: list[str] = []
if data_quality >= 0.82:
why.append("amostra forte")
else:
why.append("amostra aprovada")
if best["disagreement"] <= 0.035:
why.append("modelos muito alinhados")
elif best["disagreement"] <= 0.065:
why.append("modelos alinhados")
if market.bookmakers >= 5:
why.append(f"consenso de {market.bookmakers} casas")
else:
why.append(f"consenso de {market.bookmakers} casas")
if best["conservative"] >= 0.65:
why.append("forte margem conservadora")
if best["market_move"] > 0.025:
why.append("mercado moveu a favor")
if best["calibration_samples"] >= 12:
why.append("calibração forward ativa")
why.append("Risk Gate aprovado")
picks.append(Pick(
event_id=str(event.get("id") or f"{home_api}-{away_api}-{kickoff.isoformat()}"),
kickoff=kickoff.isoformat(),
competition=spec.label,
competition_code=comp_code,
home=home_api,
away=away_api,
resolved_home_key=home_identity.key,
resolved_away_key=away_identity.key,
selection=best["selection"],
side=best["side"],
odd=round(best["odd"], 3),
probability=round(best["p"], 4),
raw_model_probability=round(best["raw_p"], 4),
conservative_probability=round(best["conservative"], 4),
market_probability=round(best["mprob"], 4),
fair_odd=round(1.0 / max(best["p"], 1e-9), 3),
model_ev=round(best["ev"], 4),
edge=round(best["edge"], 4),
safe_score=round(score, 1),
quality=round(data_quality, 4),
reliability=round(best["reliability"], 4),
disagreement=round(best["disagreement"], 4),
market_dispersion=round(best["market_dispersion"], 4),
market_bookmakers=market.bookmakers,
name_confidence=round(name_confidence, 4),
calibration_delta=round(best["calibration_delta"], 4),
market_move=round(best["market_move"], 4),
label=label,
reasons=why,
model_detail={k: round(v, 4) for k, v in best["models"].items()},
model_version=MODEL_VERSION,
))
picks.sort(
key=lambda p: (
p.conservative_probability,
p.safe_score,
p.reliability,
p.model_ev,
),
reverse=True,
)
return picks[:limit], rejected