539 lines
20 KiB
Python
539 lines
20 KiB
Python
"""Basketball Mixin — basketball-specific market construction.
|
|
|
|
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 BasketballMixin:
|
|
def _build_basketball_prediction_package(
|
|
self,
|
|
data: MatchData,
|
|
prediction: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
quality = self._compute_data_quality(data)
|
|
|
|
raw_market_rows = self._build_basketball_market_rows(data, prediction)
|
|
market_rows = [
|
|
self._decorate_basketball_market_row(data, prediction, quality, row)
|
|
for row in raw_market_rows
|
|
]
|
|
market_rows.sort(
|
|
key=lambda row: (
|
|
1 if row.get("playable") else 0,
|
|
float(row.get("play_score", 0.0)),
|
|
),
|
|
reverse=True,
|
|
)
|
|
|
|
playable_rows = [row for row in market_rows if row.get("playable")]
|
|
|
|
MIN_ODDS = 1.30
|
|
playable_with_odds = [
|
|
row for row in playable_rows
|
|
if float(row.get("odds", 0.0)) >= MIN_ODDS
|
|
]
|
|
|
|
if playable_with_odds:
|
|
playable_with_odds.sort(
|
|
key=lambda r: (
|
|
float(r.get("ev_edge", 0.0)),
|
|
float(r.get("play_score", 0.0)),
|
|
),
|
|
reverse=True,
|
|
)
|
|
main_pick = playable_with_odds[0]
|
|
main_pick["is_guaranteed"] = False
|
|
main_pick["pick_reason"] = "positive_ev_pick"
|
|
else:
|
|
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
|
|
fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
|
|
main_pick = fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)
|
|
if main_pick:
|
|
main_pick["is_guaranteed"] = False
|
|
main_pick["playable"] = False
|
|
main_pick["stake_units"] = 0.0
|
|
main_pick["bet_grade"] = "PASS"
|
|
main_pick["pick_reason"] = "no_playable_value_found"
|
|
|
|
supporting: List[Dict[str, Any]] = []
|
|
for row in market_rows:
|
|
if main_pick and row["market"] == main_pick["market"] and row["pick"] == main_pick["pick"]:
|
|
continue
|
|
supporting.append(row)
|
|
supporting = supporting[:5]
|
|
|
|
bet_summary = [self._to_bet_summary_item(row) for row in market_rows]
|
|
scenarios = self._build_basketball_scenarios(prediction)
|
|
reasons = self._build_basketball_reasoning_factors(data, prediction, quality)
|
|
|
|
aggressive_pick: Optional[Dict[str, Any]] = None
|
|
risk_level = prediction.get("risk_level", "MEDIUM")
|
|
risk_score = float(prediction.get("risk_score", 50.0) or 50.0)
|
|
|
|
# Build aggressive pick if available from Spreak in market_board
|
|
board = prediction.get("market_board", {})
|
|
if risk_level in ("LOW", "MEDIUM") and "Spread" in board:
|
|
spr_data = board["Spread"]
|
|
probs = list(spr_data.values())
|
|
keys = list(spr_data.keys())
|
|
if len(probs) >= 2:
|
|
prob_a = float(str(probs[0]).replace('%', '')) / 100.0
|
|
prob_h = float(str(probs[1]).replace('%', '')) / 100.0
|
|
max_prob = max(prob_a, prob_h)
|
|
|
|
spr_pick = "Home" if prob_h >= prob_a else "Away"
|
|
|
|
conf = 50.0
|
|
line_str = "Spread"
|
|
for b in prediction.get("bet_summary", []):
|
|
if b["market"] == "Spread":
|
|
conf = float(b["confidence"])
|
|
line_str = b["pick"]
|
|
|
|
aggressive_pick = {
|
|
"market": "SPREAD",
|
|
"pick": line_str,
|
|
"probability": round(max_prob, 4),
|
|
"confidence": round(conf, 1),
|
|
"odds": round(
|
|
float(
|
|
data.odds_data.get(
|
|
"spread_h" if spr_pick == "Home" else "spread_a", 0.0
|
|
)
|
|
),
|
|
2,
|
|
),
|
|
}
|
|
|
|
scores = prediction.get("score_prediction", {})
|
|
home_score = scores.get("home_expected", 80.0)
|
|
away_score = scores.get("away_expected", 80.0)
|
|
total_score = scores.get("total_expected", 160.0)
|
|
|
|
mb_out = {
|
|
"PLAYER_TOP": board.get("PLAYER_TOP", []),
|
|
}
|
|
|
|
if "ML" in board:
|
|
ml_data = board["ML"]
|
|
keys = list(ml_data.keys())
|
|
if len(keys) >= 2:
|
|
mb_out["ML"] = {
|
|
"pick": prediction.get("main_pick", ""),
|
|
"confidence": 60.0,
|
|
"probs": {
|
|
"1": round(float(str(ml_data[keys[0]]).replace('%', '')) / 100.0, 4),
|
|
"2": round(float(str(ml_data[keys[1]]).replace('%', '')) / 100.0, 4),
|
|
},
|
|
}
|
|
|
|
if "Totals" in board:
|
|
tot_data = board["Totals"]
|
|
keys = list(tot_data.keys())
|
|
if len(keys) >= 2:
|
|
mb_out["TOTAL"] = {
|
|
"line": 160.5,
|
|
"pick": prediction.get("main_pick", ""),
|
|
"confidence": 60.0,
|
|
"probs": {
|
|
"under": round(float(str(tot_data[keys[0]]).replace('%', '')) / 100.0, 4),
|
|
"over": round(float(str(tot_data[keys[1]]).replace('%', '')) / 100.0, 4),
|
|
},
|
|
}
|
|
|
|
if "Spread" in board:
|
|
spr_data = board["Spread"]
|
|
keys = list(spr_data.keys())
|
|
if len(keys) >= 2:
|
|
mb_out["SPREAD"] = {
|
|
"line_home": 0.0,
|
|
"pick": prediction.get("main_pick", ""),
|
|
"confidence": 60.0,
|
|
"probs": {
|
|
"away_cover": round(float(str(spr_data[keys[0]]).replace('%', '')) / 100.0, 4),
|
|
"home_cover": round(float(str(spr_data[keys[1]]).replace('%', '')) / 100.0, 4),
|
|
},
|
|
}
|
|
|
|
return {
|
|
"model_version": str(prediction.get("engine_version") or "v28.main.basketball"),
|
|
"match_info": {
|
|
"match_id": data.match_id,
|
|
"match_name": f"{data.home_team_name} vs {data.away_team_name}",
|
|
"home_team": data.home_team_name,
|
|
"away_team": data.away_team_name,
|
|
"league": data.league_name,
|
|
"match_date_ms": data.match_date_ms,
|
|
"sport": data.sport,
|
|
},
|
|
"data_quality": quality,
|
|
"risk": {
|
|
"level": risk_level,
|
|
"score": round(risk_score, 1),
|
|
"is_surprise_risk": False,
|
|
"surprise_type": "",
|
|
"warnings": [],
|
|
},
|
|
"engine_breakdown": prediction.get("engine_breakdown")
|
|
or {
|
|
"team": 60.0,
|
|
"player": 60.0,
|
|
"odds": 80.0,
|
|
"referee": 50.0,
|
|
},
|
|
"main_pick": main_pick,
|
|
"bet_advice": {
|
|
"playable": bool(main_pick and main_pick.get("playable")),
|
|
"suggested_stake_units": float(main_pick.get("stake_units", 0.0))
|
|
if (main_pick and main_pick.get("playable"))
|
|
else 0.0,
|
|
"reason": "playable_pick_found"
|
|
if (main_pick and main_pick.get("playable"))
|
|
else "no_bet_conditions_met",
|
|
},
|
|
"bet_summary": bet_summary,
|
|
"supporting_picks": supporting,
|
|
"aggressive_pick": aggressive_pick,
|
|
"scenario_top5": scenarios,
|
|
"score_prediction": {
|
|
"ft": f"{int(round(home_score))}-{int(round(away_score))}",
|
|
"ht": f"{int(round(home_score * 0.52))}-{int(round(away_score * 0.52))}",
|
|
"xg_home": round(float(home_score), 2),
|
|
"xg_away": round(float(away_score), 2),
|
|
"xg_total": round(float(total_score), 2),
|
|
},
|
|
"market_board": mb_out,
|
|
"reasoning_factors": reasons,
|
|
}
|
|
|
|
def _build_basketball_market_rows(
|
|
self,
|
|
data: MatchData,
|
|
pred: Dict[str, Any],
|
|
) -> List[Dict[str, Any]]:
|
|
odds = data.odds_data
|
|
|
|
market_board = pred.get("market_board", {})
|
|
|
|
# 1. Moneyline
|
|
ml_row = None
|
|
if "ML" in market_board:
|
|
ml_data = market_board["ML"]
|
|
# To get specific pick (MS 1 or MS 2), look at the probability values
|
|
probs = list(ml_data.values())
|
|
keys = list(ml_data.keys())
|
|
if len(probs) >= 2:
|
|
prob_1 = float(str(probs[0]).replace('%', '')) / 100.0
|
|
prob_2 = float(str(probs[1]).replace('%', '')) / 100.0
|
|
max_prob = max(prob_1, prob_2)
|
|
|
|
# Derive pick string
|
|
ml_pick_val = keys[0] if prob_1 >= prob_2 else keys[1]
|
|
ml_pick = "1" if "1" in ml_pick_val else "2"
|
|
ml_odd_key = "ml_h" if ml_pick == "1" else "ml_a"
|
|
|
|
# Find confidence from bet summary
|
|
conf = 50.0
|
|
for b in pred.get("bet_summary", []):
|
|
if b["market"] == "Moneyline": conf = float(b["confidence"])
|
|
|
|
ml_row = {
|
|
"market": "ML",
|
|
"pick": ml_pick,
|
|
"probability": round(max_prob, 4),
|
|
"confidence": round(conf, 1),
|
|
"odds": round(float(odds.get(ml_odd_key, 0.0)), 2),
|
|
}
|
|
|
|
# 2. Totals
|
|
tot_row = None
|
|
if "Totals" in market_board:
|
|
tot_data = market_board["Totals"]
|
|
probs = list(tot_data.values())
|
|
keys = list(tot_data.keys())
|
|
if len(probs) >= 2:
|
|
prob_u = float(str(probs[0]).replace('%', '')) / 100.0
|
|
prob_o = float(str(probs[1]).replace('%', '')) / 100.0
|
|
max_prob = max(prob_u, prob_o)
|
|
|
|
pick_str = keys[1] if prob_o >= prob_u else keys[0]
|
|
tot_pick = "Over" if "Over" in pick_str else "Under"
|
|
line_val = pick_str.replace("Over", "").replace("Under", "").strip()
|
|
|
|
conf = 50.0
|
|
for b in pred.get("bet_summary", []):
|
|
if b["market"] == "Totals": conf = float(b["confidence"])
|
|
|
|
tot_row = {
|
|
"market": "TOTAL",
|
|
"pick": f"{tot_pick} {line_val}",
|
|
"probability": round(max_prob, 4),
|
|
"confidence": round(conf, 1),
|
|
"odds": round(float(odds.get("tot_o" if tot_pick == "Over" else "tot_u", 0.0)), 2),
|
|
}
|
|
|
|
# 3. Spread
|
|
spr_row = None
|
|
if "Spread" in market_board:
|
|
spr_data = market_board["Spread"]
|
|
probs = list(spr_data.values())
|
|
keys = list(spr_data.keys())
|
|
if len(probs) >= 2:
|
|
prob_a = float(str(probs[0]).replace('%', '')) / 100.0
|
|
prob_h = float(str(probs[1]).replace('%', '')) / 100.0
|
|
max_prob = max(prob_a, prob_h)
|
|
|
|
spr_pick = "Home" if prob_h >= prob_a else "Away"
|
|
|
|
conf = 50.0
|
|
line_str = ""
|
|
for b in pred.get("bet_summary", []):
|
|
if b["market"] == "Spread":
|
|
conf = float(b["confidence"])
|
|
line_str = b["pick"]
|
|
|
|
spr_row = {
|
|
"market": "SPREAD",
|
|
"pick": spr_pick + " " + line_str,
|
|
"probability": round(max_prob, 4),
|
|
"confidence": round(conf, 1),
|
|
"odds": round(float(odds.get("spread_h" if spr_pick == "Home" else "spread_a", 0.0)), 2),
|
|
}
|
|
|
|
# Return valid rows
|
|
rows = []
|
|
if ml_row: rows.append(ml_row)
|
|
if tot_row: rows.append(tot_row)
|
|
if spr_row: rows.append(spr_row)
|
|
return rows
|
|
|
|
def _decorate_basketball_market_row(
|
|
self,
|
|
data: MatchData,
|
|
prediction: Dict[str, Any],
|
|
quality: Dict[str, Any],
|
|
row: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
market = str(row.get("market") or "")
|
|
raw_conf = float(row.get("confidence") or 0.0)
|
|
prob = float(row.get("probability") or 0.0)
|
|
odd = float(row.get("odds") or 0.0)
|
|
|
|
calibration = {"ML": 0.90, "TOTAL": 0.88, "SPREAD": 0.86}.get(market, 0.88)
|
|
min_conf = {"ML": 55.0, "TOTAL": 56.0, "SPREAD": 55.0}.get(market, 55.0)
|
|
|
|
calibrated_conf = max(1.0, min(99.0, raw_conf * calibration))
|
|
implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
|
|
edge = prob - implied_prob if implied_prob > 0 else 0.0
|
|
|
|
risk_level = str(prediction.get("risk_level", "MEDIUM")).upper()
|
|
risk_penalty = {"LOW": 0.0, "MEDIUM": 3.0, "HIGH": 8.0, "EXTREME": 12.0}.get(
|
|
risk_level,
|
|
4.0,
|
|
)
|
|
quality_label = str(quality.get("label") or "MEDIUM").upper()
|
|
quality_penalty = {"HIGH": 0.0, "MEDIUM": 2.0, "LOW": 6.0}.get(
|
|
quality_label,
|
|
4.0,
|
|
)
|
|
|
|
base_score = calibrated_conf + (edge * 100.0)
|
|
play_score = max(0.0, min(100.0, base_score - risk_penalty - quality_penalty))
|
|
|
|
reasons: List[str] = []
|
|
playable = True
|
|
|
|
min_play_score = self.market_min_play_score.get(market, 68.0)
|
|
min_edge = self.market_min_edge.get(market, 0.02)
|
|
|
|
if calibrated_conf < min_conf:
|
|
playable = False
|
|
reasons.append("below_calibrated_conf_threshold")
|
|
if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01:
|
|
playable = False
|
|
reasons.append("market_odds_missing")
|
|
if risk_level in ("HIGH", "EXTREME") and quality_label == "LOW":
|
|
playable = False
|
|
reasons.append("high_risk_low_data_quality")
|
|
if odd > 1.0 and edge < -0.05:
|
|
playable = False
|
|
reasons.append("negative_model_edge")
|
|
|
|
if not reasons:
|
|
reasons.append("market_passed_all_gates")
|
|
|
|
if not playable:
|
|
grade = "PASS"
|
|
stake_units = 0.0
|
|
elif play_score >= 72:
|
|
grade = "A"
|
|
stake_units = 1.0
|
|
elif play_score >= 61:
|
|
grade = "B"
|
|
stake_units = 0.5
|
|
else:
|
|
grade = "C"
|
|
stake_units = 0.25
|
|
|
|
out = dict(row)
|
|
out.update(
|
|
{
|
|
"raw_confidence": round(raw_conf, 1),
|
|
"calibrated_confidence": round(calibrated_conf, 1),
|
|
"min_required_confidence": round(min_conf, 1),
|
|
"edge": round(edge, 4),
|
|
"play_score": round(play_score, 1),
|
|
"playable": playable,
|
|
"bet_grade": grade,
|
|
"stake_units": stake_units,
|
|
"decision_reasons": reasons[:3],
|
|
},
|
|
)
|
|
return out
|
|
|
|
def _build_basketball_scenarios(
|
|
self,
|
|
prediction: Dict[str, Any],
|
|
) -> List[Dict[str, Any]]:
|
|
scores = prediction.get("score_prediction", {})
|
|
home = float(scores.get("home_expected", 80.0))
|
|
away = float(scores.get("away_expected", 80.0))
|
|
templates = [
|
|
(0.00, 0.23),
|
|
(+3.5, 0.20),
|
|
(-3.5, 0.19),
|
|
(+6.0, 0.16),
|
|
(-6.0, 0.14),
|
|
]
|
|
out: List[Dict[str, Any]] = []
|
|
for delta, prob in templates:
|
|
h = int(round(home + delta))
|
|
a = int(round(away - delta))
|
|
out.append({"score": f"{h}-{a}", "prob": prob})
|
|
return out
|
|
|
|
def _build_basketball_reasoning_factors(
|
|
self,
|
|
data: MatchData,
|
|
prediction: Dict[str, Any],
|
|
quality: Dict[str, Any],
|
|
) -> List[str]:
|
|
factors: List[str] = []
|
|
|
|
# XGBoost models are odds-aware, weight it heavily
|
|
factors.append("market_signal_dominant")
|
|
|
|
if quality.get("label") in ("HIGH", "MEDIUM"):
|
|
factors.append("player_form_signal_strong")
|
|
else:
|
|
factors.append("player_form_signal_limited")
|
|
|
|
if prediction.get("is_surprise_risk"):
|
|
factors.append("upset_risk_detected")
|
|
if quality.get("label") == "LOW":
|
|
factors.append("limited_data_confidence")
|
|
|
|
factors.append("basketball_points_model")
|
|
return factors
|
|
|
|
def _compute_basketball_data_quality(self, data: MatchData) -> Dict[str, Any]:
|
|
flags: List[str] = []
|
|
|
|
has_ml = float(data.odds_data.get("ml_h", 0.0)) > 1.0 and float(data.odds_data.get("ml_a", 0.0)) > 1.0
|
|
has_total = (
|
|
float(data.odds_data.get("tot_line", 0.0)) > 0.0
|
|
and float(data.odds_data.get("tot_o", 0.0)) > 1.0
|
|
and float(data.odds_data.get("tot_u", 0.0)) > 1.0
|
|
)
|
|
has_spread = (
|
|
"spread_home_line" in data.odds_data
|
|
and float(data.odds_data.get("spread_h", 0.0)) > 1.0
|
|
and float(data.odds_data.get("spread_a", 0.0)) > 1.0
|
|
)
|
|
|
|
odds_components = [has_ml, has_total, has_spread]
|
|
odds_score = sum(1.0 for x in odds_components if x) / 3.0
|
|
if not has_ml:
|
|
flags.append("missing_moneyline_odds")
|
|
if not has_total:
|
|
flags.append("missing_total_odds")
|
|
if not has_spread:
|
|
flags.append("missing_spread_odds")
|
|
|
|
# Basketball live lineup/referee coverage is structurally lower in this project.
|
|
# Keep neutral baseline and rely mostly on odds depth.
|
|
lineup_score = 0.7
|
|
ref_score = 0.7
|
|
|
|
total_score = (odds_score * 0.75) + (lineup_score * 0.15) + (ref_score * 0.10)
|
|
if total_score >= 0.75:
|
|
label = "HIGH"
|
|
elif total_score >= 0.52:
|
|
label = "MEDIUM"
|
|
else:
|
|
label = "LOW"
|
|
|
|
return {
|
|
"label": label,
|
|
"score": round(total_score, 3),
|
|
"home_lineup_count": len(data.home_lineup or []),
|
|
"away_lineup_count": len(data.away_lineup or []),
|
|
"lineup_source": data.lineup_source,
|
|
"flags": flags,
|
|
}
|