@@ -0,0 +1,469 @@
|
||||
"""Reversal Mixin — HT/FT reversal watchlist and cycle metrics.
|
||||
|
||||
Auto-extracted mixin module — split from services/single_match_orchestrator.py.
|
||||
All methods here are composed into SingleMatchOrchestrator via inheritance.
|
||||
`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are
|
||||
initialised in the main __init__.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import math
|
||||
import os
|
||||
import pickle
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, overload
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from data.db import get_clean_dsn
|
||||
from schemas.prediction import FullMatchPrediction
|
||||
from schemas.match_data import MatchData
|
||||
from models.v25_ensemble import V25Predictor, get_v25_predictor
|
||||
try:
|
||||
from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge
|
||||
except ImportError:
|
||||
class V27Predictor: # type: ignore[no-redef]
|
||||
def __init__(self): self.models = {}
|
||||
def load_models(self): return False
|
||||
def predict_all(self, features): return {}
|
||||
def compute_divergence(*args, **kwargs):
|
||||
return {}
|
||||
def compute_value_edge(*args, **kwargs):
|
||||
return {}
|
||||
from features.odds_band_analyzer import OddsBandAnalyzer
|
||||
try:
|
||||
from models.basketball_v25 import (
|
||||
BasketballMatchPrediction,
|
||||
get_basketball_v25_predictor,
|
||||
)
|
||||
except ImportError:
|
||||
BasketballMatchPrediction = Any # type: ignore[misc]
|
||||
def get_basketball_v25_predictor() -> Any:
|
||||
raise ImportError("Basketball predictor is not available")
|
||||
from core.engines.player_predictor import PlayerPrediction, get_player_predictor
|
||||
from services.feature_enrichment import FeatureEnrichmentService
|
||||
from services.betting_brain import BettingBrain
|
||||
from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine
|
||||
from services.match_commentary import generate_match_commentary
|
||||
from utils.top_leagues import load_top_league_ids
|
||||
from utils.league_reliability import load_league_reliability
|
||||
from config.config_loader import build_threshold_dict, get_threshold_default
|
||||
from models.calibration import get_calibrator
|
||||
|
||||
|
||||
class ReversalMixin:
|
||||
def get_reversal_watchlist(
|
||||
self,
|
||||
count: int = 20,
|
||||
horizon_hours: int = 72,
|
||||
min_score: float = 45.0,
|
||||
top_leagues_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
safe_count = max(1, min(100, int(count)))
|
||||
safe_horizon = max(6, min(168, int(horizon_hours)))
|
||||
safe_min_score = max(0.0, min(100.0, float(min_score)))
|
||||
now_ms = int(time.time() * 1000)
|
||||
horizon_ms = now_ms + (safe_horizon * 60 * 60 * 1000)
|
||||
|
||||
with psycopg2.connect(self.dsn) as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
lm.id,
|
||||
lm.home_team_id,
|
||||
lm.away_team_id,
|
||||
lm.league_id,
|
||||
lm.mst_utc
|
||||
FROM live_matches lm
|
||||
WHERE lm.sport = 'football'
|
||||
AND lm.mst_utc >= %s
|
||||
AND lm.mst_utc <= %s
|
||||
ORDER BY lm.mst_utc ASC
|
||||
LIMIT 200
|
||||
""",
|
||||
(now_ms, horizon_ms),
|
||||
)
|
||||
raw_candidates = cur.fetchall()
|
||||
|
||||
candidates = [
|
||||
row
|
||||
for row in raw_candidates
|
||||
if row.get("home_team_id") and row.get("away_team_id")
|
||||
]
|
||||
if top_leagues_only:
|
||||
candidates = [
|
||||
row for row in candidates if self._is_top_league(row.get("league_id"))
|
||||
]
|
||||
|
||||
team_ids: Set[str] = set()
|
||||
pair_keys: Set[Tuple[str, str]] = set()
|
||||
for row in candidates:
|
||||
home_id = str(row["home_team_id"])
|
||||
away_id = str(row["away_team_id"])
|
||||
team_ids.add(home_id)
|
||||
team_ids.add(away_id)
|
||||
h, a = sorted((home_id, away_id))
|
||||
pair_keys.add((h, a))
|
||||
|
||||
team_cycle = self._fetch_team_reversal_cycle_metrics(cur, team_ids, now_ms)
|
||||
h2h_ctx = self._fetch_h2h_reversal_context(cur, pair_keys, now_ms)
|
||||
|
||||
watch_items_all: List[Dict[str, Any]] = []
|
||||
scanned = 0
|
||||
for row in candidates:
|
||||
match_id = str(row["id"])
|
||||
data = self._load_match_data(match_id)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
package = self.analyze_match(match_id)
|
||||
if not package:
|
||||
continue
|
||||
|
||||
scanned += 1
|
||||
htft_probs = package.get("market_board", {}).get("HTFT", {}).get("probs", {})
|
||||
prob_12 = float(htft_probs.get("1/2", 0.0))
|
||||
prob_21 = float(htft_probs.get("2/1", 0.0))
|
||||
if prob_12 <= 0.0 and prob_21 <= 0.0:
|
||||
continue
|
||||
overall_htft_pick = None
|
||||
overall_htft_prob = 0.0
|
||||
if htft_probs:
|
||||
overall_htft_pick, overall_htft_prob = max(
|
||||
htft_probs.items(),
|
||||
key=lambda item: float(item[1]),
|
||||
)
|
||||
|
||||
reversal_sum = prob_12 + prob_21
|
||||
reversal_max = max(prob_12, prob_21)
|
||||
top_pick = "2/1" if prob_21 >= prob_12 else "1/2"
|
||||
top_prob = prob_21 if top_pick == "2/1" else prob_12
|
||||
|
||||
ms_h = self._to_float(data.odds_data.get("ms_h"), 0.0)
|
||||
ms_a = self._to_float(data.odds_data.get("ms_a"), 0.0)
|
||||
gap = abs(ms_h - ms_a) if ms_h > 1.0 and ms_a > 1.0 else 0.0
|
||||
favorite_odd = min(ms_h, ms_a) if ms_h > 1.0 and ms_a > 1.0 else 0.0
|
||||
|
||||
# Reversal events are rare (~5% baseline), so convert raw probs to a more useful
|
||||
# watchlist scale where p in [0.02, 0.08] becomes meaningfully separable.
|
||||
base_score = (reversal_max * 100.0 * 8.0) + (reversal_sum * 100.0 * 4.0)
|
||||
|
||||
balance_bonus = 0.0
|
||||
if gap > 0.0:
|
||||
balance_bonus = max(0.0, (1.0 - min(gap, 1.2) / 1.2) * 7.0)
|
||||
elif ms_h > 1.0 and ms_a > 1.0:
|
||||
balance_bonus = 2.0
|
||||
|
||||
favorite_bonus = 0.0
|
||||
if favorite_odd > 0.0 and favorite_odd <= 1.70 and reversal_max >= 0.02:
|
||||
favorite_bonus = min(8.0, (1.70 - favorite_odd) * 12.0)
|
||||
|
||||
home_metrics = team_cycle.get(data.home_team_id, {})
|
||||
away_metrics = team_cycle.get(data.away_team_id, {})
|
||||
cycle_pressure = max(
|
||||
float(home_metrics.get("cycle_pressure", 0.0)),
|
||||
float(away_metrics.get("cycle_pressure", 0.0)),
|
||||
)
|
||||
cycle_bonus = cycle_pressure * 10.0
|
||||
|
||||
h, a = sorted((data.home_team_id, data.away_team_id))
|
||||
pair_key = (h, a)
|
||||
pair_ctx = h2h_ctx.get(pair_key, {})
|
||||
blowout_bonus = 0.0
|
||||
last_diff = int(pair_ctx.get("goal_diff", 0))
|
||||
if abs(last_diff) >= 3:
|
||||
blowout_bonus = 6.0
|
||||
if abs(last_diff) >= 5:
|
||||
blowout_bonus += 3.0
|
||||
|
||||
ou25_o = self._to_float(data.odds_data.get("ou25_o"), 0.0)
|
||||
tempo_bonus = 0.0
|
||||
if ou25_o > 1.0 and ou25_o <= 1.72:
|
||||
tempo_bonus = 2.5
|
||||
|
||||
watch_score = max(
|
||||
0.0,
|
||||
min(
|
||||
100.0,
|
||||
base_score + balance_bonus + favorite_bonus + cycle_bonus + blowout_bonus + tempo_bonus,
|
||||
),
|
||||
)
|
||||
reason_codes: List[str] = []
|
||||
if top_prob >= 0.045:
|
||||
reason_codes.append("reversal_prob_hot")
|
||||
elif top_prob >= 0.030:
|
||||
reason_codes.append("reversal_prob_warm")
|
||||
if gap > 0.0 and gap <= 0.80:
|
||||
reason_codes.append("balanced_matchup")
|
||||
if favorite_bonus > 0.0:
|
||||
reason_codes.append("strong_favorite_reversal_window")
|
||||
if cycle_pressure >= 0.55:
|
||||
reason_codes.append("team_reversal_cycle_pressure")
|
||||
if blowout_bonus > 0.0:
|
||||
reason_codes.append("h2h_blowout_rematch")
|
||||
if tempo_bonus > 0.0:
|
||||
reason_codes.append("high_tempo_profile")
|
||||
if not reason_codes:
|
||||
reason_codes.append("model_signal_only")
|
||||
|
||||
item = (
|
||||
{
|
||||
"match_id": data.match_id,
|
||||
"match_name": f"{data.home_team_name} vs {data.away_team_name}",
|
||||
"match_date_ms": data.match_date_ms,
|
||||
"league_id": data.league_id,
|
||||
"league": data.league_name,
|
||||
"risk_band": self._watchlist_risk_band(watch_score),
|
||||
"watch_score": round(watch_score, 2),
|
||||
"top_pick": top_pick,
|
||||
"top_pick_prob": round(top_prob, 4),
|
||||
"top_pick_scope": "reversal_only",
|
||||
"overall_htft_pick": overall_htft_pick,
|
||||
"overall_htft_pick_prob": round(float(overall_htft_prob), 4),
|
||||
"reversal_probs": {
|
||||
"1/2": round(prob_12, 4),
|
||||
"2/1": round(prob_21, 4),
|
||||
},
|
||||
"odds_snapshot": {
|
||||
"ms_h": round(ms_h, 2) if ms_h > 0 else None,
|
||||
"ms_a": round(ms_a, 2) if ms_a > 0 else None,
|
||||
"ms_gap": round(gap, 3),
|
||||
"favorite_odd": round(favorite_odd, 2) if favorite_odd > 0 else None,
|
||||
},
|
||||
"pattern_signals": {
|
||||
"home_cycle_pressure": round(float(home_metrics.get("cycle_pressure", 0.0)), 3),
|
||||
"away_cycle_pressure": round(float(away_metrics.get("cycle_pressure", 0.0)), 3),
|
||||
"home_matches_since_last_reversal": int(home_metrics.get("matches_since_last_reversal", 99)),
|
||||
"away_matches_since_last_reversal": int(away_metrics.get("matches_since_last_reversal", 99)),
|
||||
"h2h_last_goal_diff": last_diff if pair_ctx else None,
|
||||
"h2h_last_result": pair_ctx.get("result"),
|
||||
},
|
||||
"reason_codes": reason_codes,
|
||||
}
|
||||
)
|
||||
watch_items_all.append(item)
|
||||
|
||||
watch_items_all.sort(
|
||||
key=lambda item: (
|
||||
float(item.get("watch_score", 0.0)),
|
||||
float(item.get("top_pick_prob", 0.0)),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
selected = [
|
||||
item for item in watch_items_all if float(item.get("watch_score", 0.0)) >= safe_min_score
|
||||
][:safe_count]
|
||||
preview = watch_items_all[: min(5, len(watch_items_all))]
|
||||
return {
|
||||
"engine": "v28.main",
|
||||
"generated_at": __import__("datetime").datetime.utcnow().isoformat() + "Z",
|
||||
"horizon_hours": safe_horizon,
|
||||
"min_score": round(safe_min_score, 2),
|
||||
"top_leagues_only": bool(top_leagues_only),
|
||||
"scanned_matches": scanned,
|
||||
"candidate_matches": len(candidates),
|
||||
"listed_matches": len(selected),
|
||||
"watchlist": selected,
|
||||
"top_candidates_preview": preview,
|
||||
}
|
||||
|
||||
def _fetch_team_reversal_cycle_metrics(
|
||||
self,
|
||||
cur: RealDictCursor,
|
||||
team_ids: Set[str],
|
||||
now_ms: int,
|
||||
) -> Dict[str, Dict[str, float]]:
|
||||
if not team_ids:
|
||||
return {}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
WITH team_matches AS (
|
||||
SELECT
|
||||
m.home_team_id AS team_id,
|
||||
m.mst_utc,
|
||||
CASE
|
||||
WHEN m.ht_score_home > m.ht_score_away THEN 'L'
|
||||
WHEN m.ht_score_home < m.ht_score_away THEN 'T'
|
||||
ELSE 'D'
|
||||
END AS ht_state,
|
||||
CASE
|
||||
WHEN m.score_home > m.score_away THEN 'W'
|
||||
WHEN m.score_home < m.score_away THEN 'L'
|
||||
ELSE 'D'
|
||||
END AS ft_state
|
||||
FROM matches m
|
||||
WHERE m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.ht_score_home IS NOT NULL
|
||||
AND m.ht_score_away IS NOT NULL
|
||||
AND m.home_team_id = ANY(%s)
|
||||
AND m.mst_utc < %s
|
||||
UNION ALL
|
||||
SELECT
|
||||
m.away_team_id AS team_id,
|
||||
m.mst_utc,
|
||||
CASE
|
||||
WHEN m.ht_score_away > m.ht_score_home THEN 'L'
|
||||
WHEN m.ht_score_away < m.ht_score_home THEN 'T'
|
||||
ELSE 'D'
|
||||
END AS ht_state,
|
||||
CASE
|
||||
WHEN m.score_away > m.score_home THEN 'W'
|
||||
WHEN m.score_away < m.score_home THEN 'L'
|
||||
ELSE 'D'
|
||||
END AS ft_state
|
||||
FROM matches m
|
||||
WHERE m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.ht_score_home IS NOT NULL
|
||||
AND m.ht_score_away IS NOT NULL
|
||||
AND m.away_team_id = ANY(%s)
|
||||
AND m.mst_utc < %s
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
team_id,
|
||||
mst_utc,
|
||||
ht_state,
|
||||
ft_state,
|
||||
ROW_NUMBER() OVER (PARTITION BY team_id ORDER BY mst_utc DESC) AS rn
|
||||
FROM team_matches
|
||||
)
|
||||
SELECT team_id, mst_utc, ht_state, ft_state
|
||||
FROM ranked
|
||||
WHERE rn <= 80
|
||||
ORDER BY team_id ASC, mst_utc DESC
|
||||
""",
|
||||
(list(team_ids), now_ms, list(team_ids), now_ms),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
by_team: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for row in rows:
|
||||
by_team[str(row["team_id"])].append(row)
|
||||
|
||||
out: Dict[str, Dict[str, float]] = {}
|
||||
for team_id in team_ids:
|
||||
team_rows = by_team.get(str(team_id), [])
|
||||
if not team_rows:
|
||||
out[str(team_id)] = {
|
||||
"recent_reversal_rate": 0.0,
|
||||
"matches_since_last_reversal": 99.0,
|
||||
"avg_gap_matches": 12.0,
|
||||
"cycle_pressure": 0.0,
|
||||
}
|
||||
continue
|
||||
|
||||
reversal_indexes: List[int] = []
|
||||
recent_reversal = 0
|
||||
recent_n = min(15, len(team_rows))
|
||||
for idx, row in enumerate(team_rows, start=1):
|
||||
ht_state = str(row.get("ht_state") or "")
|
||||
ft_state = str(row.get("ft_state") or "")
|
||||
is_reversal = (ht_state == "L" and ft_state == "L") or (ht_state == "T" and ft_state == "W")
|
||||
if idx <= recent_n and is_reversal:
|
||||
recent_reversal += 1
|
||||
if is_reversal:
|
||||
reversal_indexes.append(idx)
|
||||
|
||||
recent_rate = (recent_reversal / recent_n) if recent_n > 0 else 0.0
|
||||
since_last = float(reversal_indexes[0]) if reversal_indexes else 99.0
|
||||
|
||||
gaps: List[float] = []
|
||||
if len(reversal_indexes) >= 2:
|
||||
for i in range(1, len(reversal_indexes)):
|
||||
gaps.append(float(reversal_indexes[i] - reversal_indexes[i - 1]))
|
||||
avg_gap = (sum(gaps) / len(gaps)) if gaps else 12.0
|
||||
if avg_gap <= 0:
|
||||
avg_gap = 12.0
|
||||
|
||||
cycle_pressure = 0.0
|
||||
if reversal_indexes:
|
||||
tolerance = max(3.0, avg_gap * 0.7)
|
||||
diff = abs(since_last - avg_gap)
|
||||
cycle_pressure = max(0.0, 1.0 - (diff / tolerance))
|
||||
|
||||
out[str(team_id)] = {
|
||||
"recent_reversal_rate": round(recent_rate, 4),
|
||||
"matches_since_last_reversal": round(since_last, 2),
|
||||
"avg_gap_matches": round(avg_gap, 2),
|
||||
"cycle_pressure": round(cycle_pressure, 4),
|
||||
}
|
||||
return out
|
||||
|
||||
def _fetch_h2h_reversal_context(
|
||||
self,
|
||||
cur: RealDictCursor,
|
||||
pair_keys: Set[Tuple[str, str]],
|
||||
now_ms: int,
|
||||
) -> Dict[Tuple[str, str], Dict[str, Any]]:
|
||||
if not pair_keys:
|
||||
return {}
|
||||
|
||||
team_ids = sorted({team_id for pair in pair_keys for team_id in pair})
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
m.home_team_id,
|
||||
m.away_team_id,
|
||||
m.score_home,
|
||||
m.score_away,
|
||||
m.ht_score_home,
|
||||
m.ht_score_away,
|
||||
m.mst_utc
|
||||
FROM matches m
|
||||
WHERE m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.home_team_id = ANY(%s)
|
||||
AND m.away_team_id = ANY(%s)
|
||||
AND m.mst_utc < %s
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT 4000
|
||||
""",
|
||||
(team_ids, team_ids, now_ms),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
out: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
home_id = str(row["home_team_id"])
|
||||
away_id = str(row["away_team_id"])
|
||||
h, a = sorted((home_id, away_id))
|
||||
key = (h, a)
|
||||
if key not in pair_keys or key in out:
|
||||
continue
|
||||
|
||||
score_home = int(row["score_home"])
|
||||
score_away = int(row["score_away"])
|
||||
goal_diff = score_home - score_away
|
||||
out[key] = {
|
||||
"goal_diff": goal_diff,
|
||||
"result": f"{score_home}-{score_away}",
|
||||
"match_date_ms": int(row["mst_utc"] or 0),
|
||||
}
|
||||
if len(out) >= len(pair_keys):
|
||||
break
|
||||
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _watchlist_risk_band(score: float) -> str:
|
||||
if score >= 68.0:
|
||||
return "HIGH"
|
||||
if score >= 54.0:
|
||||
return "MEDIUM"
|
||||
return "LOW"
|
||||
Reference in New Issue
Block a user