""" V2 Betting Engine — FastAPI Router Async endpoint that orchestrates: DB → Features → Model → Quant → Response. Mounted as a sub-router on the existing main.py app, so both V20+ (legacy) and V2 endpoints coexist. """ from __future__ import annotations import logging import time from typing import Any from fastapi import APIRouter, HTTPException from core.quant import ( MarketPick, RiskResult, analyze_market, assess_risk, ) from data.database import get_session from features.extractor import MatchFeatures, extract_features from models.betting_engine import get_predictor from schemas.response import ( BetAdvice, BetSummaryRow, DataQuality, EngineBreakdown, MarketProbs, MatchInfo, PickDetail, PredictionResponse, RiskAssessment, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/v2", tags=["V2 Betting Engine"]) # ═══════════════════════════════════════════════════════════════════════════ # Endpoints # ═══════════════════════════════════════════════════════════════════════════ @router.post("/analyze/{match_id}", response_model=PredictionResponse) async def analyze_match_v2(match_id: str) -> PredictionResponse: """ Full single-match analysis pipeline: 1. Extract leakage-free features from PostgreSQL 2. Run calibrated ensemble predictions (MS, OU25, BTTS) 3. Calculate edges via implied probability comparison 4. Apply Fractional Kelly staking 5. Grade & rank picks 6. Assess risk 7. Return SingleMatchPredictionPackage """ started_at = time.perf_counter() # ─── Step 1: Feature extraction ─────────────────────────────────── async with get_session() as session: feats = await extract_features(session, match_id) if feats is None: raise HTTPException( status_code=404, detail=f"Match {match_id} not found or insufficient data.", ) # ─── Step 2: Model predictions ──────────────────────────────────── predictor = get_predictor() X = feats.to_model_array() all_probs = predictor.predict_all(X, feats) # ─── Step 3: Quantitative analysis per market ───────────────────── ms_odds_map = {"1": feats.odds_home, "X": feats.odds_draw, "2": feats.odds_away} ou25_odds_map = {"Under": feats.odds_under25, "Over": feats.odds_over25} btts_odds_map = {"No": feats.odds_btts_no, "Yes": feats.odds_btts_yes} ms_pick = analyze_market("MS", all_probs["MS"], ms_odds_map, feats.data_quality_score) ou25_pick = analyze_market("OU25", all_probs["OU25"], ou25_odds_map, feats.data_quality_score) btts_pick = analyze_market("BTTS", all_probs["BTTS"], btts_odds_map, feats.data_quality_score) all_picks = [ms_pick, ou25_pick, btts_pick] # ─── Step 4: Select main pick (highest play_score among playable) ─ playable_picks = [p for p in all_picks if p.playable] playable_picks.sort(key=lambda p: p.play_score, reverse=True) main_pick: MarketPick | None = playable_picks[0] if playable_picks else None supporting = playable_picks[1:] if len(playable_picks) > 1 else [] # Value pick: best playable with odds >= 1.60 value_candidates = [p for p in playable_picks if p.odds >= 1.60] value_pick: MarketPick | None = value_candidates[0] if value_candidates else None # If value_pick IS the main_pick, try the next candidate if value_pick and main_pick and value_pick.market == main_pick.market: value_pick = value_candidates[1] if len(value_candidates) > 1 else None # Aggressive pick: highest edge regardless of playability all_picks_by_edge = sorted(all_picks, key=lambda p: p.edge, reverse=True) aggressive = all_picks_by_edge[0] if all_picks_by_edge and all_picks_by_edge[0].edge > 0 else None # ─── Step 5: Risk assessment ────────────────────────────────────── implied_prob_fav = max(feats.implied_prob_home, feats.implied_prob_away) risk = assess_risk( missing_players_impact=feats.missing_players_impact, data_quality_score=feats.data_quality_score, elo_diff=feats.elo_diff, implied_prob_fav=implied_prob_fav, ) # ─── Step 6: Build response ─────────────────────────────────────── elapsed_ms = int((time.perf_counter() - started_at) * 1000) response = PredictionResponse( model_version="v2.betting_engine", match_info=MatchInfo( match_id=match_id, match_name=feats.match_name, home_team=feats.home_team_name, away_team=feats.away_team_name, league=feats.league_name, match_date_ms=feats.match_date_ms, ), data_quality=DataQuality( label=_quality_label(feats.data_quality_score), score=feats.data_quality_score, flags=feats.data_quality_flags, ), risk=RiskAssessment( level=risk.level, score=risk.score, is_surprise_risk=risk.is_surprise_risk, surprise_type=risk.surprise_type, warnings=risk.warnings, ), engine_breakdown=EngineBreakdown( team=round(feats.elo_diff / 100.0, 2), player=round(-feats.missing_players_impact, 2), odds=round(implied_prob_fav, 2), referee=0.0, ), main_pick=_pick_to_detail(main_pick, feats) if main_pick else None, value_pick=_pick_to_detail(value_pick, feats) if value_pick else None, bet_advice=BetAdvice( playable=main_pick is not None, suggested_stake_units=main_pick.stake_units if main_pick else 0.0, reason=( f"Best value: {main_pick.market} {main_pick.pick} " f"(edge {main_pick.edge:.1%}, grade {main_pick.bet_grade})" if main_pick else "no_playable_edge_found" ), ), bet_summary=[_pick_to_summary(p) for p in all_picks], supporting_picks=[_pick_to_detail(p, feats) for p in supporting], aggressive_pick=_pick_to_detail(aggressive, feats) if aggressive else None, market_board={ "MS": MarketProbs( pick=ms_pick.pick, confidence=round(ms_pick.probability * 100, 1), probs=all_probs["MS"], ).model_dump(), "OU25": MarketProbs( pick=ou25_pick.pick, confidence=round(ou25_pick.probability * 100, 1), probs=all_probs["OU25"], ).model_dump(), "BTTS": MarketProbs( pick=btts_pick.pick, confidence=round(btts_pick.probability * 100, 1), probs=all_probs["BTTS"], ).model_dump(), }, reasoning_factors=_build_reasoning(feats, main_pick, risk, elapsed_ms), ) logger.info( "V2 analyze %s → %s in %dms (main: %s %s, edge: %s)", match_id, response.bet_advice.reason, elapsed_ms, main_pick.market if main_pick else "NONE", main_pick.pick if main_pick else "", f"{main_pick.edge:.1%}" if main_pick else "N/A", ) return response @router.get("/health") async def v2_health(): predictor = get_predictor() return { "status": "healthy", "engine": "v2.betting_engine", "models_loaded": predictor.is_ready, } # ═══════════════════════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════════════════════ def _quality_label(score: float) -> str: if score >= 0.8: return "HIGH" if score >= 0.5: return "MEDIUM" return "LOW" def _pick_to_detail(pick: MarketPick, feats: MatchFeatures) -> PickDetail: implied = { "MS": {"1": feats.implied_prob_home, "X": feats.implied_prob_draw, "2": feats.implied_prob_away}, "OU25": {"Over": feats.implied_prob_over25, "Under": feats.implied_prob_under25}, "BTTS": {"Yes": feats.implied_prob_btts_yes, "No": feats.implied_prob_btts_no}, } raw_conf = pick.probability * 100.0 market_implied = implied.get(pick.market, {}).get(pick.pick, 0.33) return PickDetail( market=pick.market, pick=pick.pick, probability=pick.probability, confidence=round(raw_conf, 1), odds=pick.odds, raw_confidence=round(raw_conf, 1), calibrated_confidence=round(raw_conf, 1), min_required_confidence=round(market_implied * 100, 1), edge=pick.edge, play_score=pick.play_score, playable=pick.playable, bet_grade=pick.bet_grade, stake_units=pick.stake_units, decision_reasons=pick.decision_reasons, ) def _pick_to_summary(pick: MarketPick) -> BetSummaryRow: return BetSummaryRow( market=pick.market, pick=pick.pick, raw_confidence=round(pick.probability * 100, 1), calibrated_confidence=round(pick.probability * 100, 1), bet_grade=pick.bet_grade, playable=pick.playable, stake_units=pick.stake_units, play_score=pick.play_score, reasons=pick.decision_reasons, ) def _build_reasoning( feats: MatchFeatures, main_pick: MarketPick | None, risk: RiskResult, elapsed_ms: int, ) -> list[str]: reasons: list[str] = [] reasons.append(f"ELO: {feats.home_elo:.0f} vs {feats.away_elo:.0f} (diff: {feats.elo_diff:+.0f})") reasons.append( f"Form (last 5): Home {feats.home_avg_goals_scored:.1f}GF/{feats.home_avg_goals_conceded:.1f}GA " f"— Away {feats.away_avg_goals_scored:.1f}GF/{feats.away_avg_goals_conceded:.1f}GA" ) reasons.append( f"Implied probs: H={feats.implied_prob_home:.0%} D={feats.implied_prob_draw:.0%} " f"A={feats.implied_prob_away:.0%}" ) if feats.missing_players_impact > 0: reasons.append(f"Missing player impact: {feats.missing_players_impact:.2f}") if main_pick: reasons.append( f"Best edge: {main_pick.market} {main_pick.pick} " f"→ {main_pick.edge:+.1%} (grade {main_pick.bet_grade})" ) reasons.append(f"Risk: {risk.level} (score {risk.score:.2f})") reasons.append(f"Data quality: {feats.data_quality_score:.0%}") reasons.append(f"Inference time: {elapsed_ms}ms") return reasons