496 lines
20 KiB
Python
496 lines
20 KiB
Python
"""Coupon Mixin — multi-match coupon builder + daily bankers.
|
||
|
||
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 CouponMixin:
|
||
def _prefilter_match_ids(self, match_ids: List[str], limit: int = 15) -> List[str]:
|
||
"""
|
||
40+ maç gelirse hepsini analiz etmek çok yavaş.
|
||
DB'den hızlıca en kaliteli limit adet maçı seç:
|
||
- Odds verisi olan maçlar önce
|
||
- football_ai_features'da gerçek ELO'su olan maçlar
|
||
- Yüksek lig güvenilirliği
|
||
"""
|
||
if len(match_ids) <= limit:
|
||
return match_ids
|
||
|
||
try:
|
||
with psycopg2.connect(self.dsn) as conn:
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("""
|
||
SELECT
|
||
m.id,
|
||
COUNT(oc.db_id) AS odds_count,
|
||
COALESCE(f.home_elo, 1500) AS home_elo,
|
||
lr.reliability_score
|
||
FROM matches m
|
||
LEFT JOIN odd_categories oc ON oc.match_id = m.id
|
||
LEFT JOIN football_ai_features f ON f.match_id = m.id
|
||
LEFT JOIN team_elo_ratings ter_h ON ter_h.team_id = m.home_team_id
|
||
LEFT JOIN (
|
||
SELECT league_id, AVG(home_elo) AS reliability_score
|
||
FROM football_ai_features
|
||
GROUP BY league_id
|
||
) lr ON lr.league_id = m.league_id
|
||
WHERE m.id = ANY(%s)
|
||
GROUP BY m.id, f.home_elo, lr.reliability_score
|
||
ORDER BY
|
||
COUNT(oc.db_id) DESC,
|
||
COALESCE(f.home_elo, 1500) DESC
|
||
LIMIT %s
|
||
""", (match_ids, limit))
|
||
rows = cur.fetchall()
|
||
filtered = [r["id"] for r in rows]
|
||
# Eğer DB'den yeterli gelmediyse kalanları ekle
|
||
remaining = [m for m in match_ids if m not in filtered]
|
||
return filtered + remaining[:max(0, limit - len(filtered))]
|
||
except Exception as e:
|
||
print(f"⚠️ Prefilter failed, using original list: {e}")
|
||
return match_ids[:limit]
|
||
|
||
def build_coupon(
|
||
self,
|
||
match_ids: List[str],
|
||
strategy: str = "BALANCED",
|
||
max_matches: Optional[int] = None,
|
||
min_confidence: Optional[float] = None,
|
||
) -> Dict[str, Any]:
|
||
strategy_name = (strategy or "BALANCED").upper()
|
||
|
||
strategy_config = {
|
||
"SAFE": {"max_matches": 4, "min_conf": 66.0, "prefilter": 12},
|
||
"BALANCED": {"max_matches": 5, "min_conf": 58.0, "prefilter": 15},
|
||
"AGGRESSIVE": {"max_matches": 8, "min_conf": 52.0, "prefilter": 20},
|
||
"VALUE": {"max_matches": 8, "min_conf": 48.0, "prefilter": 20},
|
||
"MIRACLE": {"max_matches": 10, "min_conf": 44.0, "prefilter": 25},
|
||
}
|
||
cfg = strategy_config.get(strategy_name, strategy_config["BALANCED"])
|
||
max_allowed = max_matches if max_matches is not None else cfg["max_matches"]
|
||
min_conf = min_confidence if min_confidence is not None else cfg["min_conf"]
|
||
prefilter_limit = cfg["prefilter"]
|
||
|
||
# Çok fazla maç gelirse önce hızlı prefilter uygula
|
||
if len(match_ids) > prefilter_limit:
|
||
print(f"🔍 Prefiltering {len(match_ids)} → {prefilter_limit} matches for {strategy_name} coupon")
|
||
match_ids = self._prefilter_match_ids(match_ids, prefilter_limit)
|
||
|
||
candidates: List[Dict[str, Any]] = []
|
||
rejected: List[Dict[str, Any]] = []
|
||
|
||
for match_id in match_ids:
|
||
package = self.analyze_match(match_id)
|
||
if not package:
|
||
rejected.append({"match_id": match_id, "reason": "match_not_found"})
|
||
continue
|
||
|
||
risk_level = str(package.get("risk", {}).get("level", "MEDIUM")).upper()
|
||
data_quality = str(package.get("data_quality", {}).get("label", "MEDIUM")).upper()
|
||
match_candidates: List[Dict[str, Any]] = []
|
||
seen_keys: Set[Tuple[str, str]] = set()
|
||
bet_summary = package.get("bet_summary") or []
|
||
|
||
raw_picks = []
|
||
for candidate in [
|
||
package.get("main_pick"),
|
||
package.get("value_pick"),
|
||
*(package.get("supporting_picks") or []),
|
||
]:
|
||
if isinstance(candidate, dict):
|
||
raw_picks.append(candidate)
|
||
for candidate in bet_summary:
|
||
if isinstance(candidate, dict):
|
||
raw_picks.append(candidate)
|
||
|
||
for candidate in raw_picks:
|
||
market = str(candidate.get("market") or "")
|
||
pick = str(candidate.get("pick") or "")
|
||
if not market or not pick:
|
||
continue
|
||
|
||
dedupe_key = (market, pick)
|
||
if dedupe_key in seen_keys:
|
||
continue
|
||
seen_keys.add(dedupe_key)
|
||
|
||
calibrated_conf = float(
|
||
candidate.get("calibrated_confidence", candidate.get("confidence", 0.0))
|
||
or 0.0
|
||
)
|
||
odds = float(candidate.get("odds", 0.0) or 0.0)
|
||
probability = float(candidate.get("probability", 0.0) or 0.0)
|
||
play_score = float(candidate.get("play_score", 0.0) or 0.0)
|
||
ev_edge = float(
|
||
candidate.get("ev_edge", candidate.get("edge", 0.0)) or 0.0
|
||
)
|
||
playable = bool(candidate.get("playable"))
|
||
bet_grade = str(candidate.get("bet_grade", "PASS")).upper()
|
||
|
||
if odds <= 1.01:
|
||
continue
|
||
|
||
strict_candidate = (
|
||
playable
|
||
and calibrated_conf >= min_conf
|
||
and bet_grade != "PASS"
|
||
)
|
||
|
||
if strategy_name == "SAFE":
|
||
strict_pass = strict_candidate
|
||
if odds > 2.35 or play_score < 60.0 or risk_level in {"HIGH", "EXTREME"}:
|
||
strict_pass = False
|
||
if data_quality == "LOW" or ev_edge < 0.01 or bet_grade == "PASS":
|
||
strict_pass = False
|
||
strict_score = (
|
||
calibrated_conf * 1.10
|
||
+ play_score * 0.90
|
||
+ (ev_edge * 180.0)
|
||
- abs(odds - 1.55) * 12.0
|
||
)
|
||
soft_pass = (
|
||
calibrated_conf >= max(min_conf - 10.0, 56.0)
|
||
and odds <= 2.70
|
||
and play_score >= 50.0
|
||
and risk_level != "EXTREME"
|
||
and data_quality != "LOW"
|
||
and ev_edge >= -0.01
|
||
)
|
||
soft_score = (
|
||
calibrated_conf
|
||
+ play_score * 0.85
|
||
+ (ev_edge * 140.0)
|
||
- abs(odds - 1.65) * 9.0
|
||
)
|
||
elif strategy_name == "BALANCED":
|
||
strict_pass = strict_candidate
|
||
if odds > 3.40 or play_score < 52.0 or risk_level == "EXTREME":
|
||
strict_pass = False
|
||
if ev_edge < 0.0 or bet_grade == "PASS":
|
||
strict_pass = False
|
||
strict_score = (
|
||
calibrated_conf
|
||
+ play_score
|
||
+ (ev_edge * 220.0)
|
||
+ min(odds, 3.0) * 3.0
|
||
)
|
||
soft_pass = (
|
||
calibrated_conf >= max(min_conf - 10.0, 48.0)
|
||
and odds <= 4.20
|
||
and play_score >= 44.0
|
||
and risk_level != "EXTREME"
|
||
and ev_edge >= -0.015
|
||
)
|
||
soft_score = (
|
||
calibrated_conf * 0.95
|
||
+ play_score * 0.90
|
||
+ (ev_edge * 180.0)
|
||
+ min(odds, 3.5) * 3.5
|
||
)
|
||
elif strategy_name == "AGGRESSIVE":
|
||
strict_pass = strict_candidate
|
||
if odds < 1.35 or odds > 7.50 or play_score < 46.0:
|
||
strict_pass = False
|
||
if risk_level == "EXTREME" or bet_grade == "PASS":
|
||
strict_pass = False
|
||
strict_score = (
|
||
calibrated_conf * 0.85
|
||
+ play_score * 0.75
|
||
+ (ev_edge * 260.0)
|
||
+ min(odds, 6.0) * 7.0
|
||
)
|
||
soft_pass = (
|
||
calibrated_conf >= max(min_conf - 10.0, 42.0)
|
||
and 1.25 <= odds <= 8.50
|
||
and play_score >= 40.0
|
||
and risk_level != "EXTREME"
|
||
and ev_edge >= -0.02
|
||
)
|
||
soft_score = (
|
||
calibrated_conf * 0.80
|
||
+ play_score * 0.70
|
||
+ (ev_edge * 210.0)
|
||
+ min(odds, 7.0) * 7.5
|
||
)
|
||
elif strategy_name == "VALUE":
|
||
strict_pass = strict_candidate
|
||
if odds < 1.55 or play_score < 48.0 or ev_edge < 0.03:
|
||
strict_pass = False
|
||
if risk_level == "EXTREME" or data_quality == "LOW" or bet_grade == "PASS":
|
||
strict_pass = False
|
||
strict_score = (
|
||
calibrated_conf * 0.75
|
||
+ play_score * 0.85
|
||
+ (ev_edge * 320.0)
|
||
+ min(odds, 6.5) * 8.0
|
||
)
|
||
soft_pass = (
|
||
calibrated_conf >= max(min_conf - 10.0, 40.0)
|
||
and odds >= 1.35
|
||
and play_score >= 40.0
|
||
and risk_level != "EXTREME"
|
||
and data_quality != "LOW"
|
||
and ev_edge >= 0.0
|
||
)
|
||
soft_score = (
|
||
calibrated_conf * 0.70
|
||
+ play_score * 0.80
|
||
+ (ev_edge * 260.0)
|
||
+ min(odds, 7.0) * 7.0
|
||
)
|
||
else: # MIRACLE
|
||
strict_pass = strict_candidate
|
||
if odds < 2.10 or play_score < 40.0 or ev_edge < 0.01:
|
||
strict_pass = False
|
||
if risk_level == "EXTREME" or bet_grade == "PASS":
|
||
strict_pass = False
|
||
strict_score = (
|
||
calibrated_conf * 0.55
|
||
+ play_score * 0.60
|
||
+ (ev_edge * 260.0)
|
||
+ min(odds, 10.0) * 10.0
|
||
)
|
||
soft_pass = (
|
||
calibrated_conf >= max(min_conf - 10.0, 36.0)
|
||
and odds >= 1.60
|
||
and play_score >= 34.0
|
||
and risk_level != "EXTREME"
|
||
and ev_edge >= -0.02
|
||
)
|
||
soft_score = (
|
||
calibrated_conf * 0.50
|
||
+ play_score * 0.55
|
||
+ (ev_edge * 200.0)
|
||
+ min(odds, 10.0) * 9.0
|
||
)
|
||
|
||
fallback_pass = (
|
||
calibrated_conf >= max(min_conf - 14.0, 34.0)
|
||
and odds >= 1.20
|
||
and play_score >= 32.0
|
||
and risk_level != "EXTREME"
|
||
)
|
||
fallback_score = (
|
||
calibrated_conf * 0.60
|
||
+ play_score * 0.65
|
||
+ (ev_edge * 120.0)
|
||
+ min(odds, 6.0) * 4.0
|
||
)
|
||
|
||
strategy_score = strict_score
|
||
selection_mode = "strict"
|
||
if strict_pass:
|
||
pass
|
||
elif soft_pass:
|
||
strategy_score = soft_score
|
||
selection_mode = "soft"
|
||
elif fallback_pass:
|
||
strategy_score = fallback_score
|
||
selection_mode = "fallback"
|
||
else:
|
||
continue
|
||
|
||
match_candidates.append(
|
||
{
|
||
"match_id": package["match_info"]["match_id"],
|
||
"match_name": package["match_info"]["match_name"],
|
||
"market": market,
|
||
"pick": pick,
|
||
"probability": probability,
|
||
"confidence": calibrated_conf,
|
||
"odds": odds,
|
||
"risk_level": risk_level,
|
||
"data_quality": data_quality,
|
||
"bet_grade": bet_grade,
|
||
"playable": playable,
|
||
"play_score": round(play_score, 1),
|
||
"ev_edge": round(ev_edge, 4),
|
||
"selection_mode": selection_mode,
|
||
"strategy_score": round(strategy_score, 3),
|
||
}
|
||
)
|
||
|
||
if not match_candidates:
|
||
rejected.append(
|
||
{
|
||
"match_id": match_id,
|
||
"reason": "no_strategy_fit",
|
||
"threshold": min_conf,
|
||
}
|
||
)
|
||
continue
|
||
|
||
match_candidates.sort(
|
||
key=lambda item: (
|
||
float(item.get("strategy_score", 0.0)),
|
||
float(item.get("confidence", 0.0)),
|
||
float(item.get("ev_edge", 0.0)),
|
||
),
|
||
reverse=True,
|
||
)
|
||
candidates.append(match_candidates[0])
|
||
|
||
candidates.sort(
|
||
key=lambda item: (
|
||
float(item.get("strategy_score", 0.0)),
|
||
float(item.get("confidence", 0.0)),
|
||
float(item.get("ev_edge", 0.0)),
|
||
),
|
||
reverse=True,
|
||
)
|
||
selected = candidates[: max(1, max_allowed)]
|
||
|
||
total_odds = 1.0
|
||
win_probability = 1.0
|
||
for pick in selected:
|
||
odd = float(pick.get("odds") or 1.0)
|
||
prob = float(pick.get("probability") or 0.0)
|
||
total_odds *= odd if odd > 1.0 else 1.0
|
||
win_probability *= prob
|
||
|
||
return {
|
||
"strategy": strategy_name,
|
||
"generated_at": __import__("datetime").datetime.utcnow().isoformat() + "Z",
|
||
"match_count": len(selected),
|
||
"bets": selected,
|
||
"total_odds": round(total_odds, 2),
|
||
"expected_win_rate": round(win_probability, 4),
|
||
"rejected_matches": rejected,
|
||
}
|
||
|
||
def get_daily_bankers_live(self, count: int = 3) -> List[Dict[str, Any]]:
|
||
with psycopg2.connect(self.dsn) as conn:
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT id
|
||
FROM live_matches
|
||
WHERE mst_utc > EXTRACT(EPOCH FROM NOW()) * 1000
|
||
AND mst_utc < EXTRACT(EPOCH FROM NOW() + INTERVAL '24 hours') * 1000
|
||
ORDER BY mst_utc ASC
|
||
LIMIT 60
|
||
""",
|
||
)
|
||
ids = [row["id"] for row in cur.fetchall()]
|
||
|
||
if not ids:
|
||
return []
|
||
|
||
coupon = self.build_coupon(
|
||
match_ids=ids,
|
||
strategy="SAFE",
|
||
max_matches=max(1, count),
|
||
min_confidence=78.0,
|
||
)
|
||
return coupon.get("bets", [])[: max(1, count)]
|
||
|
||
def get_daily_bankers(self, count: int = 3) -> List[Dict[str, Any]]:
|
||
"""
|
||
Identifies the safest, highest value bets for the next 24 hours.
|
||
"""
|
||
now_ms = int(time.time() * 1000)
|
||
horizon_ms = now_ms + (24 * 60 * 60 * 1000)
|
||
|
||
with psycopg2.connect(self.dsn) as conn:
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("""
|
||
SELECT m.id, m.match_name, m.mst_utc
|
||
FROM matches m
|
||
WHERE m.mst_utc >= %s AND m.mst_utc <= %s
|
||
AND m.status = 'NS'
|
||
AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
|
||
ORDER BY m.mst_utc ASC
|
||
LIMIT 50
|
||
""", (now_ms, horizon_ms))
|
||
matches = cur.fetchall()
|
||
|
||
potential_bankers = []
|
||
print(f"🔍 Scanning {len(matches)} upcoming matches for Bankers...")
|
||
|
||
for match in matches:
|
||
try:
|
||
data = self._load_match_data(match['id'])
|
||
if data is None: continue
|
||
|
||
result = self.analyze_match(match['id'])
|
||
|
||
if result and 'main_pick' in result:
|
||
pick = result['main_pick']
|
||
conf = pick.get('calibrated_confidence', pick.get('confidence', 0))
|
||
odds = pick.get('odds', 0)
|
||
market = pick.get('market', '')
|
||
pick_name = pick.get('pick', '')
|
||
|
||
# Banker Criteria: High Confidence (>75%) AND Decent Odds (>1.30)
|
||
if conf >= 75.0 and odds >= 1.30:
|
||
score = conf * (odds - 1.0)
|
||
potential_bankers.append({
|
||
"match_id": match['id'],
|
||
"match_name": match['match_name'] or f"{data.home_team_name} vs {data.away_team_name}",
|
||
"league": data.league_name,
|
||
"pick": f"{market} - {pick_name}",
|
||
"confidence": conf,
|
||
"odds": odds,
|
||
"value_score": score
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
potential_bankers.sort(key=lambda x: x['value_score'], reverse=True)
|
||
return potential_bankers[:count]
|