283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""
|
|
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
|