This commit is contained in:
2026-04-23 22:22:59 +03:00
parent df428ed1e8
commit 634204acf0
6 changed files with 2064 additions and 90 deletions
File diff suppressed because it is too large Load Diff
+240
View File
@@ -36,6 +36,11 @@ class FeatureEnrichmentService:
'avg_goals': 2.5,
'btts_rate': 0.5,
'over25_rate': 0.5,
# V27 expanded
'home_goals_avg': 1.3,
'away_goals_avg': 1.1,
'recent_trend': 0.0,
'venue_advantage': 0.0,
}
_DEFAULT_FORM = {
'clean_sheet_rate': 0.2,
@@ -53,6 +58,25 @@ class FeatureEnrichmentService:
_DEFAULT_LEAGUE = {
'avg_goals': 2.7,
'zero_goal_rate': 0.07,
# V27 expanded
'home_win_rate': 0.46,
'draw_rate': 0.26,
'btts_rate': 0.50,
'ou25_rate': 0.50,
'reliability_score': 0.0,
}
_DEFAULT_ROLLING = {
'rolling5_goals': 1.3,
'rolling5_conceded': 1.2,
'rolling10_goals': 1.3,
'rolling10_conceded': 1.2,
'rolling20_goals': 1.3,
'rolling20_conceded': 1.2,
'rolling5_cs': 0.2,
}
_DEFAULT_VENUE = {
'venue_goals': 1.4,
'venue_conceded': 1.1,
}
# ─── 1. Team Stats ──────────────────────────────────────────────
@@ -186,6 +210,13 @@ class FeatureEnrichmentService:
total_goals = 0
btts_count = 0
over25_count = 0
# V27 expanded trackers
home_team_goals_list = []
away_team_goals_list = []
home_team_venue_wins = 0
home_team_venue_total = 0
away_team_venue_wins = 0
away_team_venue_total = 0
for row in rows:
sh = int(row['score_home'])
@@ -195,14 +226,22 @@ class FeatureEnrichmentService:
# Normalise: who is "home team" in THIS prediction context
if str(row['home_team_id']) == home_team_id:
home_team_goals_list.append(sh)
away_team_goals_list.append(sa)
home_team_venue_total += 1
if sh > sa:
home_wins += 1
home_team_venue_wins += 1
elif sh == sa:
draws += 1
else:
# Reversed fixture: away_team was at home
home_team_goals_list.append(sa)
away_team_goals_list.append(sh)
away_team_venue_total += 1
if sa > sh:
home_wins += 1
away_team_venue_wins += 1
elif sh == sa:
draws += 1
@@ -211,6 +250,29 @@ class FeatureEnrichmentService:
if match_goals > 2:
over25_count += 1
# V27: recent_trend = last-5 home_win_rate - first-5 home_win_rate
recent_trend = 0.0
if total >= 6:
recent_5_wins = sum(
1 for r in rows[:5]
if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away']))
or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home']))
)
older_5_wins = sum(
1 for r in rows[-5:]
if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away']))
or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home']))
)
recent_trend = (recent_5_wins - older_5_wins) / 5.0
# V27: venue_advantage = home_win_rate_at_home - home_win_rate_away
venue_advantage = 0.0
if home_team_venue_total > 0 and away_team_venue_total > 0:
venue_advantage = (
home_team_venue_wins / home_team_venue_total
- away_team_venue_wins / away_team_venue_total
)
return {
'total_matches': total,
'home_win_rate': home_wins / total,
@@ -218,6 +280,11 @@ class FeatureEnrichmentService:
'avg_goals': total_goals / total,
'btts_rate': btts_count / total,
'over25_rate': over25_count / total,
# V27 expanded
'home_goals_avg': _safe_avg(home_team_goals_list, 1.3),
'away_goals_avg': _safe_avg(away_team_goals_list, 1.1),
'recent_trend': round(recent_trend, 4),
'venue_advantage': round(venue_advantage, 4),
}
# ─── 3. Form & Streaks ──────────────────────────────────────────
@@ -433,6 +500,10 @@ class FeatureEnrichmentService:
total = len(rows)
total_goals = 0
zero_goal_matches = 0
home_wins = 0
draw_count = 0
btts_count = 0
over25_count = 0
for row in rows:
sh = int(row['score_home'])
@@ -441,10 +512,24 @@ class FeatureEnrichmentService:
total_goals += match_goals
if match_goals == 0:
zero_goal_matches += 1
if sh > sa:
home_wins += 1
elif sh == sa:
draw_count += 1
if sh > 0 and sa > 0:
btts_count += 1
if match_goals > 2:
over25_count += 1
return {
'avg_goals': total_goals / total,
'zero_goal_rate': zero_goal_matches / total,
# V27 expanded
'home_win_rate': home_wins / total,
'draw_rate': draw_count / total,
'btts_rate': btts_count / total,
'ou25_rate': over25_count / total,
'reliability_score': min(total / 50.0, 1.0),
}
# ─── 6. Momentum ───────────────────────────────────────────────
@@ -514,6 +599,161 @@ class FeatureEnrichmentService:
return round(weighted_score / max_possible, 4)
# ─── 7. Rolling Stats (V27) ─────────────────────────────────────
def compute_rolling_stats(
self,
cur: RealDictCursor,
team_id: str,
before_date_ms: int,
) -> Dict[str, float]:
"""
Rolling goal averages and clean-sheet rates over the last 5/10/20 matches.
Single DB query, three windows computed programmatically.
"""
if not team_id:
return dict(self._DEFAULT_ROLLING)
try:
cur.execute(
"""
SELECT
m.home_team_id,
m.score_home,
m.score_away
FROM matches m
WHERE (m.home_team_id = %s OR m.away_team_id = %s)
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT 20
""",
(team_id, team_id, before_date_ms),
)
rows = cur.fetchall()
except Exception:
return dict(self._DEFAULT_ROLLING)
if not rows:
return dict(self._DEFAULT_ROLLING)
goals = []
conceded = []
clean_sheets = []
for row in rows:
is_home = str(row['home_team_id']) == team_id
gf = int(row['score_home'] if is_home else row['score_away'])
ga = int(row['score_away'] if is_home else row['score_home'])
goals.append(gf)
conceded.append(ga)
clean_sheets.append(1 if ga == 0 else 0)
n = len(goals)
return {
'rolling5_goals': _safe_avg(goals[:5], 1.3),
'rolling5_conceded': _safe_avg(conceded[:5], 1.2),
'rolling10_goals': _safe_avg(goals[:min(10, n)], 1.3),
'rolling10_conceded': _safe_avg(conceded[:min(10, n)], 1.2),
'rolling20_goals': _safe_avg(goals[:n], 1.3),
'rolling20_conceded': _safe_avg(conceded[:n], 1.2),
'rolling5_cs': _safe_avg(clean_sheets[:5], 0.2),
}
# ─── 8. Venue Stats (V27) ──────────────────────────────────────
def compute_venue_stats(
self,
cur: RealDictCursor,
team_id: str,
before_date_ms: int,
is_home: bool = True,
) -> Dict[str, float]:
"""
Team goals scored/conceded at specific venue (home or away only).
"""
if not team_id:
return dict(self._DEFAULT_VENUE)
venue_col = 'home_team_id' if is_home else 'away_team_id'
try:
cur.execute(
f"""
SELECT m.score_home, m.score_away
FROM matches m
WHERE m.{venue_col} = %s
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT 20
""",
(team_id, before_date_ms),
)
rows = cur.fetchall()
except Exception:
return dict(self._DEFAULT_VENUE)
if not rows:
return dict(self._DEFAULT_VENUE)
goals = []
conceded_list = []
for row in rows:
sh = int(row['score_home'])
sa = int(row['score_away'])
if is_home:
goals.append(sh)
conceded_list.append(sa)
else:
goals.append(sa)
conceded_list.append(sh)
return {
'venue_goals': _safe_avg(goals, 1.4),
'venue_conceded': _safe_avg(conceded_list, 1.1),
}
# ─── 9. Days Rest (V27) ────────────────────────────────────────
def compute_days_rest(
self,
cur: RealDictCursor,
team_id: str,
before_date_ms: int,
) -> float:
"""
Returns number of days since the team's last match.
Default: 7.0 (one-week rest).
"""
if not team_id:
return 7.0
try:
cur.execute(
"""
SELECT m.mst_utc
FROM matches m
WHERE (m.home_team_id = %s OR m.away_team_id = %s)
AND m.status = 'FT'
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT 1
""",
(team_id, team_id, before_date_ms),
)
row = cur.fetchone()
except Exception:
return 7.0
if not row or not row.get('mst_utc'):
return 7.0
last_match_ms = int(row['mst_utc'])
diff_days = (before_date_ms - last_match_ms) / (1000 * 86400)
return round(max(0.0, min(diff_days, 30.0)), 1)
# ─── Utility ────────────────────────────────────────────────────────
def _safe_avg(values: list, default: float) -> float:
+551 -71
View File
@@ -28,6 +28,8 @@ from psycopg2.extras import RealDictCursor
from data.db import get_clean_dsn
from models.v20_ensemble import FullMatchPrediction
from models.v25_ensemble import V25Predictor, get_v25_predictor
from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge
from features.odds_band_analyzer import OddsBandAnalyzer
from models.basketball_v25 import (
BasketballMatchPrediction,
get_basketball_v25_predictor,
@@ -146,74 +148,87 @@ class SingleMatchOrchestrator:
self.top_league_ids = load_top_league_ids()
self.league_reliability = load_league_reliability()
self.enrichment = FeatureEnrichmentService()
# Market calibration multipliers — V31 rebalance
# Previous values created mathematical impossibilities:
# BTTS: max reachable = 100×0.45 = 45, but min_conf was 55 → NEVER playable
# New approach: calibration = blend(backtest_accuracy, 0.80) to avoid crushing raw signal
self.odds_band_analyzer = OddsBandAnalyzer()
# ── V32 Calibration Rebalance ──────────────────────────────────
# RULE: max_reachable = 100 × calibration MUST be > min_conf + 8
# Previous values had 5 markets where this was IMPOSSIBLE:
# HT(0.42×100=42 < 45), HCAP(0.40×100=40 < 46), HTFT(0.28×100=28 < 32)
# HT_OU15(0.46×100=46 < 48), CARDS(0.45×100=45 < 48)
# These markets could NEVER become playable → all predictions were PASS.
#
# New calibration: conservative but mathematically achievable.
# Each market's calibration ensures high-confidence model outputs CAN pass.
self.market_calibration: Dict[str, float] = {
"MS": 0.48,
"DC": 0.82,
"OU15": 0.84,
"OU25": 0.54,
"OU35": 0.44,
"BTTS": 0.50,
"HT": 0.42,
"HT_OU05": 0.68,
"HT_OU15": 0.46,
"OE": 0.58,
"CARDS": 0.45,
"HCAP": 0.40,
"HTFT": 0.28,
"MS": 0.62, # max=62 vs min=42 ✓ (was 0.48→max=48 vs 44 ⚠️)
"DC": 0.82, # max=82 vs min=52 ✓ (unchanged, already good)
"OU15": 0.84, # max=84 vs min=55 ✓ (unchanged, already good)
"OU25": 0.68, # max=68 vs min=48 ✓ (was 0.54→max=54 vs 52 ⚠️)
"OU35": 0.60, # max=60 vs min=48 ✓ (was 0.44→max=44 vs 54 ❌)
"BTTS": 0.65, # max=65 vs min=46 ✓ (was 0.50→max=50 vs 50 ⚠️)
"HT": 0.58, # max=58 vs min=40 ✓ (was 0.42→max=42 vs 45 ❌)
"HT_OU05": 0.68, # max=68 vs min=50 ✓ (unchanged)
"HT_OU15": 0.60, # max=60 vs min=42 ✓ (was 0.46→max=46 vs 48 ❌)
"OE": 0.62, # max=62 vs min=46 ✓ (was 0.58→max=58 vs 50 ok)
"CARDS": 0.58, # max=58 vs min=42 ✓ (was 0.45→max=45 vs 48 ❌)
"HCAP": 0.56, # max=56 vs min=40 ✓ (was 0.40→max=40 vs 46 ❌)
"HTFT": 0.45, # max=45 vs min=28 ✓ (was 0.28→max=28 vs 32 ❌)
}
# Min confidence: lowered to be achievable (max_reachable - 16 to -20)
self.market_min_conf: Dict[str, float] = {
"MS": 44.0,
"DC": 55.0,
"OU15": 58.0,
"OU25": 52.0,
"OU35": 54.0,
"BTTS": 50.0,
"HT": 45.0,
"HT_OU05": 54.0,
"HT_OU15": 48.0,
"OE": 50.0,
"CARDS": 48.0,
"HCAP": 46.0,
"HTFT": 32.0,
"MS": 42.0, # was 44 — 3-way market, hard to get high conf
"DC": 52.0, # was 55 — double chance is easier
"OU15": 55.0, # was 58 — binary + usually high conf
"OU25": 48.0, # was 52 — core market, allow more through
"OU35": 48.0, # was 54 — lowered to let signals pass
"BTTS": 46.0, # was 50 — binary market
"HT": 40.0, # was 45 — was ❌ impossible, now achievable
"HT_OU05": 50.0, # was 54 — binary HT market
"HT_OU15": 42.0, # was 48 — was ❌ impossible, now achievable
"OE": 46.0, # was 50 — coin-flip market, lower bar
"CARDS": 42.0, # was 48 — was ❌ impossible, now achievable
"HCAP": 40.0, # was 46 — was ❌ impossible, now achievable
"HTFT": 28.0, # was 32 — was ❌ impossible, 9-way market
}
# Min play score: moderate reduction to allow more C-grade bets
self.market_min_play_score: Dict[str, float] = {
"MS": 72.0,
"DC": 62.0,
"OU15": 64.0,
"OU25": 70.0,
"OU35": 76.0,
"BTTS": 70.0,
"HT": 74.0,
"HT_OU05": 64.0,
"HT_OU15": 72.0,
"OE": 66.0,
"CARDS": 74.0,
"HCAP": 76.0,
"HTFT": 82.0,
"MS": 65.0, # was 72 — let more MS through for tracking
"DC": 58.0, # was 62 — DC is high accuracy
"OU15": 60.0, # was 64 — strong market per backtest
"OU25": 64.0, # was 70 — core market
"OU35": 68.0, # was 76 — riskier market
"BTTS": 64.0, # was 70 — allow more signals
"HT": 66.0, # was 74 — was never reachable anyway
"HT_OU05": 60.0, # was 64 — strong backtest market
"HT_OU15": 64.0, # was 72 — moderate
"OE": 60.0, # was 66 — low priority market
"CARDS": 66.0, # was 74 — niche market
"HCAP": 68.0, # was 76 — risky
"HTFT": 72.0, # was 82 — 9-way, very risky
}
self.market_min_edge: Dict[str, float] = {
"MS": 0.03,
"DC": 0.01,
"OU15": 0.01,
"OU25": 0.02,
"OU35": 0.04,
"BTTS": 0.03,
"HT": 0.04,
"HT_OU05": 0.01,
"HT_OU15": 0.03,
"OE": 0.02,
"CARDS": 0.03,
"HCAP": 0.04,
"HTFT": 0.06,
"MS": 0.02, # was 0.03 — slight relaxation
"DC": 0.01, # unchanged
"OU15": 0.01, # unchanged
"OU25": 0.02, # unchanged
"OU35": 0.03, # was 0.04
"BTTS": 0.02, # was 0.03
"HT": 0.03, # was 0.04
"HT_OU05": 0.01, # unchanged
"HT_OU15": 0.02, # was 0.03
"OE": 0.02, # unchanged
"CARDS": 0.02, # was 0.03
"HCAP": 0.03, # was 0.04
"HTFT": 0.05, # was 0.06
}
def _get_v25_predictor(self) -> V25Predictor:
if self.v25_predictor is None:
try:
self.v25_predictor = get_v25_predictor()
print(f"[V25] ✅ Predictor loaded: {len(self.v25_predictor.models)} market models")
except Exception as e:
print(f"[V25] ❌ PREDICTOR LOAD FAILED: {e}")
raise
return self.v25_predictor
def _get_v26_shadow_engine(self) -> V26ShadowEngine:
@@ -221,6 +236,21 @@ class SingleMatchOrchestrator:
self.v26_shadow_engine = get_v26_shadow_engine()
return self.v26_shadow_engine
def _get_v27_predictor(self) -> Optional[V27Predictor]:
"""Non-fatal V27 loader — returns None if models can't load."""
if getattr(self, "_v27", None) is not None:
return self._v27
try:
pred = V27Predictor()
if pred.load_models():
self._v27 = pred
print(f"[V27] ✅ Predictor loaded: {sum(len(v) for v in pred.models.values())} models")
return self._v27
except Exception as e:
print(f"[V27] ⚠ Load failed (non-fatal): {e}")
self._v27 = None
return None
def _build_v25_features(self, data: MatchData) -> Dict[str, float]:
"""
Build the single authoritative V25 pre-match feature vector.
@@ -281,6 +311,23 @@ class SingleMatchOrchestrator:
league = enr.compute_league_averages(cur, data.league_id, data.match_date_ms)
home_momentum = enr.compute_momentum(cur, data.home_team_id, data.match_date_ms)
away_momentum = enr.compute_momentum(cur, data.away_team_id, data.match_date_ms)
# V27 enrichment
home_rolling = enr.compute_rolling_stats(cur, data.home_team_id, data.match_date_ms)
away_rolling = enr.compute_rolling_stats(cur, data.away_team_id, data.match_date_ms)
home_venue = enr.compute_venue_stats(cur, data.home_team_id, data.match_date_ms, is_home=True)
away_venue = enr.compute_venue_stats(cur, data.away_team_id, data.match_date_ms, is_home=False)
home_rest = enr.compute_days_rest(cur, data.home_team_id, data.match_date_ms)
away_rest = enr.compute_days_rest(cur, data.away_team_id, data.match_date_ms)
# V28 Odds-Band Historical Performance
odds_band_features = self.odds_band_analyzer.compute_all(
cur=cur,
home_team_id=data.home_team_id,
away_team_id=data.away_team_id,
league_id=data.league_id,
odds=odds,
before_ts=data.match_date_ms,
referee_name=data.referee_name,
)
except Exception:
# Full fallback — use all defaults
home_stats = dict(enr._DEFAULT_TEAM_STATS)
@@ -292,6 +339,14 @@ class SingleMatchOrchestrator:
league = dict(enr._DEFAULT_LEAGUE)
home_momentum = 0.0
away_momentum = 0.0
# V27 fallbacks
home_rolling = dict(enr._DEFAULT_ROLLING)
away_rolling = dict(enr._DEFAULT_ROLLING)
home_venue = dict(enr._DEFAULT_VENUE)
away_venue = dict(enr._DEFAULT_VENUE)
home_rest = 7.0
away_rest = 7.0
odds_band_features = {} # V28 fallback
odds_presence = {
'odds_ms_h_present': 1.0 if ms_h > 1.01 else 0.0,
@@ -316,16 +371,38 @@ class SingleMatchOrchestrator:
'odds_btts_n_present': 1.0 if float(odds.get('btts_n', 0)) > 1.01 else 0.0,
}
# ── Calendar features (V27) ──
import datetime
match_dt = datetime.datetime.utcfromtimestamp(data.match_date_ms / 1000)
match_month = match_dt.month
is_season_start = 1.0 if match_month in (7, 8, 9) else 0.0
is_season_end = 1.0 if match_month in (5, 6) else 0.0
# ── Derived / Interaction features (V27) ──
elo_diff = home_elo - away_elo
form_elo_diff = home_form_elo_val - away_form_elo_val
attack_vs_defense_home = data.home_goals_avg - data.away_conceded_avg
attack_vs_defense_away = data.away_goals_avg - data.home_conceded_avg
xga_home = data.home_conceded_avg
xga_away = data.away_conceded_avg
xg_diff = xga_home - xga_away
mom_diff = home_momentum - away_momentum
form_momentum_interaction = mom_diff * form_elo_diff / 1000.0
elo_form_consistency = 1.0 - abs(elo_diff - form_elo_diff) / max(abs(elo_diff), 100.0)
upset_x_elo_gap = upset_potential * abs(elo_diff) / 500.0
return {
# META (1)
'mst_utc': float(data.match_date_ms),
# ELO (8)
'home_overall_elo': home_elo,
'away_overall_elo': away_elo,
'elo_diff': home_elo - away_elo,
'elo_diff': elo_diff,
'home_home_elo': home_venue_elo,
'away_away_elo': away_venue_elo,
'home_form_elo': home_form_elo_val,
'away_form_elo': away_form_elo_val,
'form_elo_diff': home_form_elo_val - away_form_elo_val,
'form_elo_diff': form_elo_diff,
# Form (12)
'home_goals_avg': data.home_goals_avg,
'home_conceded_avg': data.home_conceded_avg,
@@ -339,13 +416,17 @@ class SingleMatchOrchestrator:
'away_winning_streak': away_form['winning_streak'],
'home_unbeaten_streak': home_form['unbeaten_streak'],
'away_unbeaten_streak': away_form['unbeaten_streak'],
# H2H (6)
# H2H (10 — original 6 + V27 expanded 4)
'h2h_total_matches': h2h['total_matches'],
'h2h_home_win_rate': h2h['home_win_rate'],
'h2h_draw_rate': h2h['draw_rate'],
'h2h_avg_goals': h2h['avg_goals'],
'h2h_btts_rate': h2h['btts_rate'],
'h2h_over25_rate': h2h['over25_rate'],
'h2h_home_goals_avg': h2h['home_goals_avg'],
'h2h_away_goals_avg': h2h['away_goals_avg'],
'h2h_recent_trend': h2h['recent_trend'],
'h2h_venue_advantage': h2h['venue_advantage'],
# Stats (8)
'home_avg_possession': home_stats['avg_possession'],
'away_avg_possession': away_stats['avg_possession'],
@@ -380,11 +461,16 @@ class SingleMatchOrchestrator:
'odds_btts_y': float(odds.get('btts_y', 0)),
'odds_btts_n': float(odds.get('btts_n', 0)),
**odds_presence,
# League (4)
'home_xga': data.home_conceded_avg,
'away_xga': data.away_conceded_avg,
# League (9 — original 2 + V27 expanded 5 + xga 2)
'home_xga': xga_home,
'away_xga': xga_away,
'league_avg_goals': league['avg_goals'],
'league_zero_goal_rate': league['zero_goal_rate'],
'league_home_win_rate': league['home_win_rate'],
'league_draw_rate': league['draw_rate'],
'league_btts_rate': league['btts_rate'],
'league_ou25_rate': league['ou25_rate'],
'league_reliability_score': league['reliability_score'],
# Upset (4)
'upset_atmosphere': 0.0,
'upset_motivation': 0.0,
@@ -399,9 +485,45 @@ class SingleMatchOrchestrator:
# Momentum (3)
'home_momentum_score': home_momentum,
'away_momentum_score': away_momentum,
'momentum_diff': home_momentum - away_momentum,
'momentum_diff': mom_diff,
# ── V27 Rolling Stats (13) ──
'home_rolling5_goals': home_rolling['rolling5_goals'],
'home_rolling5_conceded': home_rolling['rolling5_conceded'],
'home_rolling10_goals': home_rolling['rolling10_goals'],
'home_rolling10_conceded': home_rolling['rolling10_conceded'],
'home_rolling20_goals': home_rolling['rolling20_goals'],
'home_rolling20_conceded': home_rolling['rolling20_conceded'],
'away_rolling5_goals': away_rolling['rolling5_goals'],
'away_rolling5_conceded': away_rolling['rolling5_conceded'],
'away_rolling10_goals': away_rolling['rolling10_goals'],
'away_rolling10_conceded': away_rolling['rolling10_conceded'],
'home_rolling5_cs': home_rolling['rolling5_cs'],
'away_rolling5_cs': away_rolling['rolling5_cs'],
# ── V27 Venue Stats (4) ──
'home_venue_goals': home_venue['venue_goals'],
'home_venue_conceded': home_venue['venue_conceded'],
'away_venue_goals': away_venue['venue_goals'],
'away_venue_conceded': away_venue['venue_conceded'],
# ── V27 Goal Trend (2) ──
'home_goal_trend': home_rolling['rolling5_goals'] - home_rolling['rolling10_goals'],
'away_goal_trend': away_rolling['rolling5_goals'] - away_rolling['rolling10_goals'],
# ── V27 Calendar (4) ──
'home_days_rest': home_rest,
'away_days_rest': away_rest,
'match_month': float(match_month),
'is_season_start': is_season_start,
'is_season_end': is_season_end,
# ── V27 Interaction (6) ──
'attack_vs_defense_home': attack_vs_defense_home,
'attack_vs_defense_away': attack_vs_defense_away,
'xg_diff': xg_diff,
'form_momentum_interaction': form_momentum_interaction,
'elo_form_consistency': elo_form_consistency,
'upset_x_elo_gap': upset_x_elo_gap,
# Squad Features (9) — PlayerPredictorEngine
**self._get_squad_features(data),
# V28 Odds-Band Historical Performance Features
**odds_band_features,
}
def _get_squad_features(self, data: MatchData) -> Dict[str, float]:
@@ -666,6 +788,17 @@ class SingleMatchOrchestrator:
if data is None:
return None
# ── Pre-Match Simulation Mode ────────────────────────────
# For finished (FT/postGame) matches, strip live scores so the
# entire pipeline treats them as if they haven't kicked off yet.
# _is_live_match already returns False for FT, but this adds
# defense-in-depth against any code path that reads scores directly.
_status_upper = str(data.status or "").upper()
_state_upper = str(data.state or "").upper()
if _status_upper in {"FT", "FINISHED"} or _state_upper in {"POSTGAME", "POST_GAME"}:
data.current_score_home = None
data.current_score_away = None
sport_key = str(data.sport or "football").lower()
if sport_key == "basketball":
prediction = self._get_basketball_predictor().predict(
@@ -687,6 +820,343 @@ class SingleMatchOrchestrator:
prediction = self._build_v25_prediction(data, features, v25_signal)
base_package = self._build_prediction_package(data, prediction, v25_signal)
# ── V27 Dual-Engine Divergence ──────────────────────────────
v27_predictor = self._get_v27_predictor()
if v27_predictor is not None:
try:
v27_preds = v27_predictor.predict_all(features)
# MS divergence
v27_ms = v27_preds.get("ms")
if v27_ms:
v25_ms_probs = {
"home": prediction.ms_home_prob,
"draw": prediction.ms_draw_prob,
"away": prediction.ms_away_prob,
}
ms_divergence = compute_divergence(v25_ms_probs, v27_ms)
ms_odds = {
"home": float((data.odds_data or {}).get("ms_h", 0)),
"draw": float((data.odds_data or {}).get("ms_d", 0)),
"away": float((data.odds_data or {}).get("ms_a", 0)),
}
ms_value = compute_value_edge(v25_ms_probs, v27_ms, ms_odds)
else:
ms_divergence = {}
ms_value = {}
# OU25 divergence
v27_ou25 = v27_preds.get("ou25")
if v27_ou25:
v25_ou25_probs = {
"under": prediction.under_25_prob,
"over": prediction.over_25_prob,
}
ou25_divergence = compute_divergence(v25_ou25_probs, v27_ou25)
ou25_odds = {
"under": float((data.odds_data or {}).get("ou25_u", 0)),
"over": float((data.odds_data or {}).get("ou25_o", 0)),
}
ou25_value = compute_value_edge(v25_ou25_probs, v27_ou25, ou25_odds)
else:
ou25_divergence = {}
ou25_value = {}
# ── V28 Odds-Band Historical Performance ─────────────
odds_band_ms_home = {
"win_rate": features.get("home_band_ms_win_rate", 0.33),
"draw_rate": features.get("home_band_ms_draw_rate", 0.33),
"loss_rate": features.get("home_band_ms_loss_rate", 0.34),
"sample": features.get("home_band_ms_sample", 0),
"avg_goals_scored": features.get("home_band_ms_avg_goals_scored", 1.3),
"avg_goals_conceded": features.get("home_band_ms_avg_goals_conceded", 1.1),
}
odds_band_ms_away = {
"win_rate": features.get("away_band_ms_win_rate", 0.33),
"draw_rate": features.get("away_band_ms_draw_rate", 0.33),
"loss_rate": features.get("away_band_ms_loss_rate", 0.34),
"sample": features.get("away_band_ms_sample", 0),
"avg_goals_scored": features.get("away_band_ms_avg_goals_scored", 1.3),
"avg_goals_conceded": features.get("away_band_ms_avg_goals_conceded", 1.1),
}
odds_band_ou25 = {
"over_rate": features.get("band_ou25_over_rate", 0.50),
"under_rate": features.get("band_ou25_under_rate", 0.50),
"avg_total_goals": features.get("band_ou25_avg_total_goals", 2.5),
"sample": features.get("band_ou25_sample", 0),
}
odds_band_ou15 = {
"over_rate": features.get("band_ou15_over_rate", 0.65),
"under_rate": features.get("band_ou15_under_rate", 0.35),
"avg_total_goals": features.get("band_ou15_avg_total_goals", 2.5),
"sample": features.get("band_ou15_sample", 0),
}
odds_band_ou35 = {
"over_rate": features.get("band_ou35_over_rate", 0.35),
"under_rate": features.get("band_ou35_under_rate", 0.65),
"avg_total_goals": features.get("band_ou35_avg_total_goals", 2.5),
"sample": features.get("band_ou35_sample", 0),
}
odds_band_btts = {
"yes_rate": features.get("band_btts_yes_rate", 0.50),
"no_rate": features.get("band_btts_no_rate", 0.50),
"sample": features.get("band_btts_sample", 0),
}
odds_band_dc = {
"1x_rate": features.get("band_dc_1x_rate", 0.60),
"x2_rate": features.get("band_dc_x2_rate", 0.60),
"12_rate": features.get("band_dc_12_rate", 0.67),
"1x_sample": features.get("band_dc_1x_sample", 0),
"x2_sample": features.get("band_dc_x2_sample", 0),
"12_sample": features.get("band_dc_12_sample", 0),
}
odds_band_ht_home = {
"win_rate": features.get("home_band_ht_win_rate", 0.33),
"draw_rate": features.get("home_band_ht_draw_rate", 0.40),
"loss_rate": features.get("home_band_ht_loss_rate", 0.27),
"sample": features.get("home_band_ht_sample", 0),
}
odds_band_ht_away = {
"win_rate": features.get("away_band_ht_win_rate", 0.33),
"draw_rate": features.get("away_band_ht_draw_rate", 0.40),
"loss_rate": features.get("away_band_ht_loss_rate", 0.27),
"sample": features.get("away_band_ht_sample", 0),
}
odds_band_ht_ou05 = {
"over_rate": features.get("band_ht_ou05_over_rate", 0.50),
"under_rate": features.get("band_ht_ou05_under_rate", 0.50),
"sample": features.get("band_ht_ou05_sample", 0),
}
odds_band_ht_ou15 = {
"over_rate": features.get("band_ht_ou15_over_rate", 0.35),
"under_rate": features.get("band_ht_ou15_under_rate", 0.65),
"sample": features.get("band_ht_ou15_sample", 0),
}
odds_band_oe = {
"odd_rate": features.get("band_oe_odd_rate", 0.50),
"even_rate": features.get("band_oe_even_rate", 0.50),
"sample": features.get("band_oe_sample", 0),
}
# Cards (Kart) band — hakem + takım profili
odds_band_cards = {
"referee_avg": features.get("band_cards_referee_avg", 0.0),
"referee_over_rate": features.get("band_cards_referee_over_rate", 0.50),
"referee_sample": features.get("band_cards_referee_sample", 0),
"team_avg": features.get("band_cards_team_avg", 0.0),
"team_over_rate": features.get("band_cards_team_over_rate", 0.50),
"team_sample": features.get("band_cards_team_sample", 0),
"combined_over_rate": features.get("band_cards_combined_over_rate", 0.50),
"sample": features.get("band_cards_sample", 0),
}
# HTFT (İY/MS) 9 combination rates
odds_band_htft = {}
for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"):
odds_band_htft[combo] = {
"rate": features.get(f"band_htft_{combo}_rate", 0.11),
"sample": features.get(f"band_htft_{combo}_sample", 0),
}
# ── Triple Value Detection ────────────────────────────
ms_odds = {
"home": float((data.odds_data or {}).get("ms_h", 0)),
"draw": float((data.odds_data or {}).get("ms_d", 0)),
"away": float((data.odds_data or {}).get("ms_a", 0)),
}
triple_value = {}
for outcome_key, band_key, odds_key in [
("home", "home", "home"),
("away", "away", "away"),
]:
v27_prob = (v27_ms or {}).get(outcome_key, 0)
band_rate = (odds_band_ms_home if band_key == "home"
else odds_band_ms_away)["win_rate"]
mkt_odds = ms_odds.get(odds_key, 0)
implied_prob = (1.0 / mkt_odds) if mkt_odds > 1.0 else 0.33
combined_prob = (v27_prob + band_rate) / 2.0 if v27_prob > 0 else band_rate
edge = combined_prob - implied_prob
band_sample = (odds_band_ms_home if band_key == "home"
else odds_band_ms_away)["sample"]
v27_confirms = v27_prob > implied_prob
band_confirms = band_rate > implied_prob
confirmation_count = sum([v27_confirms, band_confirms])
triple_value[outcome_key] = {
"v27_prob": round(v27_prob, 4),
"band_rate": round(band_rate, 4),
"implied_prob": round(implied_prob, 4),
"combined_prob": round(combined_prob, 4),
"edge": round(edge, 4),
"band_sample": band_sample,
"confirmations": confirmation_count,
"is_value": (
confirmation_count >= 2
and edge > 0.05
and band_sample >= 8
),
}
# OU25 triple value
ou25_over_odds = float((data.odds_data or {}).get("ou25_o", 0))
v27_ou25_over = (v27_ou25 or {}).get("over", 0) if v27_ou25 else 0
ou25_band_rate = odds_band_ou25["over_rate"]
ou25_implied = (1.0 / ou25_over_odds) if ou25_over_odds > 1.0 else 0.50
ou25_combined = (v27_ou25_over + ou25_band_rate) / 2.0 if v27_ou25_over > 0 else ou25_band_rate
ou25_edge = ou25_combined - ou25_implied
ou25_v27_confirms = v27_ou25_over > ou25_implied
ou25_band_confirms = ou25_band_rate > ou25_implied
ou25_conf_count = sum([ou25_v27_confirms, ou25_band_confirms])
triple_value["ou25_over"] = {
"v27_prob": round(v27_ou25_over, 4),
"band_rate": round(ou25_band_rate, 4),
"implied_prob": round(ou25_implied, 4),
"combined_prob": round(ou25_combined, 4),
"edge": round(ou25_edge, 4),
"band_sample": odds_band_ou25["sample"],
"confirmations": ou25_conf_count,
"is_value": (
ou25_conf_count >= 2
and ou25_edge > 0.05
and odds_band_ou25["sample"] >= 8
),
}
# BTTS triple value
btts_yes_odds = float((data.odds_data or {}).get("btts_y", 0))
btts_implied = (1.0 / btts_yes_odds) if btts_yes_odds > 1.0 else 0.50
btts_band_rate = odds_band_btts["yes_rate"]
btts_combined = btts_band_rate
btts_edge = btts_combined - btts_implied
btts_band_confirms = btts_band_rate > btts_implied
triple_value["btts_yes"] = {
"band_rate": round(btts_band_rate, 4),
"implied_prob": round(btts_implied, 4),
"combined_prob": round(btts_combined, 4),
"edge": round(btts_edge, 4),
"band_sample": odds_band_btts["sample"],
"confirmations": 1 if btts_band_confirms else 0,
"is_value": (
btts_band_confirms
and btts_edge > 0.05
and odds_band_btts["sample"] >= 8
),
}
# ── Band-only value for new markets ───────────────────
def _band_value(label, band_rate, odds_key, sample):
o = float((data.odds_data or {}).get(odds_key, 0))
imp = (1.0 / o) if o > 1.0 else 0.50
e = band_rate - imp
conf = band_rate > imp
return {
"band_rate": round(band_rate, 4),
"implied_prob": round(imp, 4),
"edge": round(e, 4),
"band_sample": sample,
"is_value": conf and e > 0.05 and sample >= 8,
}
triple_value["ou15_over"] = _band_value(
"ou15", odds_band_ou15["over_rate"], "ou15_o", odds_band_ou15["sample"])
triple_value["ou35_over"] = _band_value(
"ou35", odds_band_ou35["over_rate"], "ou35_o", odds_band_ou35["sample"])
triple_value["dc_1x"] = _band_value(
"dc1x", odds_band_dc["1x_rate"], "dc_1x", odds_band_dc["1x_sample"])
triple_value["dc_x2"] = _band_value(
"dcx2", odds_band_dc["x2_rate"], "dc_x2", odds_band_dc["x2_sample"])
triple_value["dc_12"] = _band_value(
"dc12", odds_band_dc["12_rate"], "dc_12", odds_band_dc["12_sample"])
triple_value["ht_home"] = _band_value(
"ht_h", odds_band_ht_home["win_rate"], "ht_h", odds_band_ht_home["sample"])
triple_value["ht_away"] = _band_value(
"ht_a", odds_band_ht_away["win_rate"], "ht_a", odds_band_ht_away["sample"])
triple_value["ht_ou05_over"] = _band_value(
"htou05", odds_band_ht_ou05["over_rate"], "ht_ou05_o", odds_band_ht_ou05["sample"])
triple_value["ht_ou15_over"] = _band_value(
"htou15", odds_band_ht_ou15["over_rate"], "ht_ou15_o", odds_band_ht_ou15["sample"])
triple_value["oe_odd"] = _band_value(
"oe", odds_band_oe["odd_rate"], "oe_odd", odds_band_oe["sample"])
# Cards triple value — composite (hakem + takım)
triple_value["cards_over"] = _band_value(
"cards", odds_band_cards["combined_over_rate"], "cards_o",
odds_band_cards["sample"])
# HTFT triple value — 9 combinations
for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"):
htft_combo_data = odds_band_htft.get(combo, {})
triple_value[f"htft_{combo}"] = _band_value(
f"htft_{combo}", htft_combo_data.get("rate", 0.11),
f"htft_{combo}", htft_combo_data.get("sample", 0))
# Attach to package
base_package["v27_engine"] = {
"version": "v28-pro-max",
"approach": "odds-free fundamentals + full odds-band analytics + cards + htft",
"predictions": {
"ms": v27_ms or {},
"ou25": v27_ou25 or {},
},
"divergence": {
"ms": ms_divergence,
"ou25": ou25_divergence,
},
"value_edge": {
"ms": ms_value,
"ou25": ou25_value,
},
"odds_band": {
"ms_home": odds_band_ms_home,
"ms_away": odds_band_ms_away,
"ou25": odds_band_ou25,
"ou15": odds_band_ou15,
"ou35": odds_band_ou35,
"btts": odds_band_btts,
"dc": odds_band_dc,
"ht_home": odds_band_ht_home,
"ht_away": odds_band_ht_away,
"ht_ou05": odds_band_ht_ou05,
"ht_ou15": odds_band_ht_ou15,
"oe": odds_band_oe,
"cards": odds_band_cards,
"htft": odds_band_htft,
},
"triple_value": triple_value,
}
# Boost confidence when V27 agrees with V25
if v27_ms:
v27_best = max(v27_ms, key=v27_ms.get)
v25_best_map = {"1": "home", "X": "draw", "2": "away"}
v25_best_mapped = v25_best_map.get(prediction.ms_pick, "")
if v27_best == v25_best_mapped:
# Engines agree → boost confidence by up to 5%
boost = min(5.0, abs(ms_divergence.get(v27_best, 0)) * 50)
# Additional boost if odds-band also confirms
band_val = triple_value.get(v25_best_mapped, {})
if band_val.get("is_value"):
boost = min(8.0, boost + 3.0) # Triple confirmation extra boost
prediction.ms_confidence = min(95.0, prediction.ms_confidence + boost)
base_package["prediction"]["ms_confidence"] = prediction.ms_confidence
base_package["v27_engine"]["consensus"] = "AGREE"
else:
base_package["v27_engine"]["consensus"] = "DISAGREE"
# Update analysis details
base_package.setdefault("analysis_details", {})
base_package["analysis_details"]["dual_engine"] = True
base_package["analysis_details"]["v27_loaded"] = True
base_package["analysis_details"]["odds_band_loaded"] = True
except Exception as e:
print(f"[V27] ⚠ Prediction failed (non-fatal): {e}")
base_package.setdefault("analysis_details", {})
base_package["analysis_details"]["v27_loaded"] = False
mode = str(getattr(self, "engine_mode", "v25") or "v25").lower()
if mode not in {"v25", "v26", "dual"}:
mode = "v25"
@@ -2463,17 +2933,17 @@ class SingleMatchOrchestrator:
playable_rows = [row for row in market_rows if row.get("playable")]
# GUARANTEED PICK LOGIC (Optimized based on backtest results):
# GUARANTEED PICK LOGIC (V32 - Calibration-aware):
# Runtime replay insights:
# - Trust only markets that remain robust after pre-match replay.
# - Current strongest football markets: DC, OU15, HT_OU05.
#
# Priority 1: High-accuracy market (DC/OU15/HT_OU05/OU25) + Odds >= 1.30 + Conf >= 40%
# Priority 2: Any playable + Odds >= 1.30 + Conf >= 40%
# Priority 1: High-accuracy market (DC/OU15/HT_OU05/OU25) + Odds >= 1.30 + Conf >= 44%
# Priority 2: Any playable + Odds >= 1.30 + Conf >= 44%
# Priority 3: Playable + Odds >= 1.30
# Priority 4: Best non-playable (fallback)
MIN_ODDS = 1.30
MIN_CONFIDENCE = 52.0
MIN_CONFIDENCE = 44.0 # V32: lowered from 52 to match new calibration
# High-accuracy markets from backtest (prioritize these)
HIGH_ACCURACY_MARKETS = {"DC", "OU15", "HT_OU05"}
@@ -2481,7 +2951,7 @@ class SingleMatchOrchestrator:
# Priority 1: High-accuracy markets with good odds and confidence
high_accuracy_picks = [
row for row in playable_rows
if row.get("market_type") in HIGH_ACCURACY_MARKETS
if row.get("market") in HIGH_ACCURACY_MARKETS
and float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
@@ -2686,8 +3156,14 @@ class SingleMatchOrchestrator:
if market in available_markets
}
# Determine simulation mode for the response
_resp_status = str(data.status or "").upper()
_resp_state = str(data.state or "").upper()
is_simulation = _resp_status in {"FT", "FINISHED"} or _resp_state in {"POSTGAME", "POST_GAME"}
return {
"model_version": "v25.main",
"simulation_mode": "pre_match" if is_simulation else None,
"match_info": {
"match_id": data.match_id,
"match_name": f"{data.home_team_name} vs {data.away_team_name}",
@@ -3816,11 +4292,15 @@ class SingleMatchOrchestrator:
playable = False
reasons.append("high_risk_low_data_quality")
if lineup_missing and lineup_sensitive:
playable = False
# V32: Don't hard-block, apply heavy penalty instead
# This allows high-confidence predictions to still surface
lineup_penalty += 8.0
reasons.append("lineup_insufficient_for_market")
if data.lineup_source == "probable_xi" and lineup_sensitive:
playable = False
reasons.append("lineup_not_confirmed")
# V32: Penalty instead of hard block
# Most pre-match predictions use probable_xi — blocking kills all output
lineup_penalty += 6.0
reasons.append("lineup_probable_xi_penalty")
# V31: negative edge threshold adapts to league reliability
# Reliable league: stricter (-0.03), unreliable: looser (-0.08)
neg_edge_threshold = -0.03 - (1.0 - odds_rel) * 0.05
+7 -4
View File
@@ -119,20 +119,23 @@ export class LeaguesController {
/**
* GET /leagues/teams/:id/matches
* Get team's recent matches
* Get team's recent matches (paginated)
*/
@Get("teams/:id/matches")
@Public()
@ApiOperation({ summary: "Get team's recent matches" })
@ApiOperation({ summary: "Get team's recent matches (paginated)" })
@ApiParam({ name: "id", description: "Team ID" })
@ApiQuery({ name: "limit", required: false, type: Number })
@ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" })
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" })
async getTeamMatches(
@Param("id") id: string,
@Query("page") page?: string,
@Query("limit") limit?: string,
) {
return this.leaguesService.getTeamRecentMatches(
id,
parseInt(limit || "10", 10),
parseInt(page || "1", 10),
parseInt(limit || "20", 10),
);
}
+25 -6
View File
@@ -99,21 +99,40 @@ export class LeaguesService {
}
/**
* Get team's matches (past + upcoming)
* Get team's matches (past + upcoming) with pagination
*/
async getTeamRecentMatches(teamId: string, limit: number = 50) {
return this.prisma.match.findMany({
where: {
async getTeamRecentMatches(
teamId: string,
page: number = 1,
limit: number = 20,
) {
const skip = (page - 1) * limit;
const where = {
OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }],
},
};
const [data, total] = await this.prisma.$transaction([
this.prisma.match.findMany({
where,
include: {
homeTeam: true,
awayTeam: true,
league: { include: { country: true } },
},
orderBy: { mstUtc: "desc" },
skip,
take: limit,
});
}),
this.prisma.match.count({ where }),
]);
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
+8
View File
@@ -418,6 +418,14 @@ export class MatchPredictionDto {
@ApiProperty({ type: Object, required: false })
surprise_hunter?: Record<string, unknown>;
@ApiProperty({
type: Object,
required: false,
description:
"V28 Odds-Band engine output: historical band analytics, triple-value detection, cards profiling, and HTFT 9-combo analysis",
})
v27_engine?: Record<string, unknown>;
}
export class ValueBetDto {