v28
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user