feat(ai-engine): value sniper thresholds and logic relaxed

This commit is contained in:
2026-05-06 17:44:45 +03:00
parent 5b5f83c8cf
commit 4f7090e2d9
13 changed files with 2040 additions and 382 deletions
+56 -4
View File
@@ -29,7 +29,7 @@ class V27Predictor:
82-feature odds-free vector. 82-feature odds-free vector.
""" """
MARKETS = ["ms", "ou25"] MARKETS = ['ms', 'ou25', 'btts']
def __init__(self): def __init__(self):
self.models: Dict[str, Dict[str, object]] = {} self.models: Dict[str, Dict[str, object]] = {}
@@ -56,7 +56,7 @@ class V27Predictor:
return False return False
# Load models per market # Load models per market
model_types = {"xgb": "xgb", "lgb": "lgb", "cb": "cb"} model_types = {"xgb": "xgb", "lgb": "lgb"}
for market in self.MARKETS: for market in self.MARKETS:
self.models[market] = {} self.models[market] = {}
@@ -227,11 +227,63 @@ class V27Predictor:
"over": float(avg[1]), "over": float(avg[1]),
} }
def predict_btts(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
"""
Predict Both Teams To Score probabilities.
Returns dict with keys: no, yes.
"""
if not self._loaded or 'btts' not in self.models or not self.models['btts']:
return None
X = self._build_feature_array(features)
probs_list = []
for label, model in self.models['btts'].items():
proba = self._predict_with_model(model, X, f'BTTS/{label}', expected_classes=2)
if proba is not None and len(proba) == 2:
probs_list.append(proba)
if not probs_list:
return None
avg = np.mean(probs_list, axis=0)
return {
'no': float(avg[0]),
'yes': float(avg[1]),
}
def predict_dc(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
"""
Predict Double Chance probabilities.
DC is algebraically derived from MS predictions:
1X = home + draw
X2 = draw + away
12 = home + away
This gives an odds-free DC estimate for divergence detection.
"""
ms_probs = self.predict_ms(features)
if not ms_probs:
return None
home = ms_probs['home']
draw = ms_probs['draw']
away = ms_probs['away']
return {
'1x': round(home + draw, 4),
'x2': round(draw + away, 4),
'12': round(home + away, 4),
}
def predict_all(self, features: Dict[str, float]) -> Dict[str, Optional[Dict[str, float]]]: def predict_all(self, features: Dict[str, float]) -> Dict[str, Optional[Dict[str, float]]]:
"""Run predictions for all supported markets.""" """Run predictions for all supported markets."""
return { return {
"ms": self.predict_ms(features), 'ms': self.predict_ms(features),
"ou25": self.predict_ou25(features), 'ou25': self.predict_ou25(features),
'btts': self.predict_btts(features),
'dc': self.predict_dc(features),
} }
@@ -1,8 +1,8 @@
{ {
"trained_at": "2026-04-14 17:20:03", "trained_at": "2026-05-06 15:53:36",
"market_results": { "market_results": {
"MS": { "MS": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -107,19 +107,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.8938, "xgb_accuracy": 0.5437,
"xgb_logloss": 0.2263, "xgb_logloss": 0.9429,
"lgb_accuracy": 0.8938, "lgb_accuracy": 0.5436,
"lgb_logloss": 0.2214, "lgb_logloss": 0.9423,
"ensemble_accuracy": 0.8945, "ensemble_accuracy": 0.5442,
"ensemble_logloss": 0.2226, "ensemble_logloss": 0.9418,
"class_count": 3 "class_count": 3
}, },
"OU15": { "OU15": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -224,19 +224,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.9088, "xgb_accuracy": 0.753,
"xgb_logloss": 0.1758, "xgb_logloss": 0.5256,
"lgb_accuracy": 0.9067, "lgb_accuracy": 0.7523,
"lgb_logloss": 0.1783, "lgb_logloss": 0.5262,
"ensemble_accuracy": 0.9108, "ensemble_accuracy": 0.7533,
"ensemble_logloss": 0.1753, "ensemble_logloss": 0.5254,
"class_count": 2 "class_count": 2
}, },
"OU25": { "OU25": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -341,19 +341,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.9204, "xgb_accuracy": 0.6253,
"xgb_logloss": 0.1535, "xgb_logloss": 0.635,
"lgb_accuracy": 0.9224, "lgb_accuracy": 0.6246,
"lgb_logloss": 0.1523, "lgb_logloss": 0.6347,
"ensemble_accuracy": 0.9217, "ensemble_accuracy": 0.6262,
"ensemble_logloss": 0.1518, "ensemble_logloss": 0.6343,
"class_count": 2 "class_count": 2
}, },
"OU35": { "OU35": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -458,19 +458,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.9578, "xgb_accuracy": 0.7283,
"xgb_logloss": 0.1171, "xgb_logloss": 0.5463,
"lgb_accuracy": 0.9564, "lgb_accuracy": 0.7304,
"lgb_logloss": 0.1144, "lgb_logloss": 0.546,
"ensemble_accuracy": 0.9571, "ensemble_accuracy": 0.7297,
"ensemble_logloss": 0.1149, "ensemble_logloss": 0.5456,
"class_count": 2 "class_count": 2
}, },
"BTTS": { "BTTS": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -575,19 +575,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.9238, "xgb_accuracy": 0.5894,
"xgb_logloss": 0.1439, "xgb_logloss": 0.6636,
"lgb_accuracy": 0.9265, "lgb_accuracy": 0.5928,
"lgb_logloss": 0.143, "lgb_logloss": 0.6633,
"ensemble_accuracy": 0.9265, "ensemble_accuracy": 0.5897,
"ensemble_logloss": 0.1424, "ensemble_logloss": 0.6628,
"class_count": 2 "class_count": 2
}, },
"HT_RESULT": { "HT_RESULT": {
"samples": 9786, "samples": 103208,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -692,19 +692,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6850, "train_samples": 72245,
"val_samples": 1468, "val_samples": 15481,
"test_samples": 1468, "test_samples": 15482,
"xgb_accuracy": 0.5627, "xgb_accuracy": 0.4695,
"xgb_logloss": 0.8712, "xgb_logloss": 1.0174,
"lgb_accuracy": 0.5715, "lgb_accuracy": 0.4677,
"lgb_logloss": 0.8649, "lgb_logloss": 1.0166,
"ensemble_accuracy": 0.5811, "ensemble_accuracy": 0.4688,
"ensemble_logloss": 0.8649, "ensemble_logloss": 1.0164,
"class_count": 3 "class_count": 3
}, },
"HT_OU05": { "HT_OU05": {
"samples": 9786, "samples": 103208,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -809,19 +809,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6850, "train_samples": 72245,
"val_samples": 1468, "val_samples": 15481,
"test_samples": 1468, "test_samples": 15482,
"xgb_accuracy": 0.7221, "xgb_accuracy": 0.7011,
"xgb_logloss": 0.5122, "xgb_logloss": 0.5939,
"lgb_accuracy": 0.7268, "lgb_accuracy": 0.7002,
"lgb_logloss": 0.5092, "lgb_logloss": 0.5936,
"ensemble_accuracy": 0.7275, "ensemble_accuracy": 0.7009,
"ensemble_logloss": 0.5084, "ensemble_logloss": 0.5932,
"class_count": 2 "class_count": 2
}, },
"HT_OU15": { "HT_OU15": {
"samples": 9786, "samples": 103208,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -926,19 +926,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6850, "train_samples": 72245,
"val_samples": 1468, "val_samples": 15481,
"test_samples": 1468, "test_samples": 15482,
"xgb_accuracy": 0.752, "xgb_accuracy": 0.6723,
"xgb_logloss": 0.5252, "xgb_logloss": 0.6126,
"lgb_accuracy": 0.7595, "lgb_accuracy": 0.6736,
"lgb_logloss": 0.5213, "lgb_logloss": 0.6118,
"ensemble_accuracy": 0.7595, "ensemble_accuracy": 0.6734,
"ensemble_logloss": 0.5192, "ensemble_logloss": 0.6117,
"class_count": 2 "class_count": 2
}, },
"HTFT": { "HTFT": {
"samples": 9786, "samples": 103208,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -1043,19 +1043,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6850, "train_samples": 72245,
"val_samples": 1468, "val_samples": 15481,
"test_samples": 1468, "test_samples": 15482,
"xgb_accuracy": 0.5136, "xgb_accuracy": 0.3337,
"xgb_logloss": 1.1384, "xgb_logloss": 1.8208,
"lgb_accuracy": 0.5184, "lgb_accuracy": 0.3332,
"lgb_logloss": 1.1469, "lgb_logloss": 1.8203,
"ensemble_accuracy": 0.5143, "ensemble_accuracy": 0.3358,
"ensemble_logloss": 1.1339, "ensemble_logloss": 1.8186,
"class_count": 9 "class_count": 9
}, },
"ODD_EVEN": { "ODD_EVEN": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -1160,19 +1160,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.8863, "xgb_accuracy": 0.5296,
"xgb_logloss": 0.3565, "xgb_logloss": 0.6841,
"lgb_accuracy": 0.8802, "lgb_accuracy": 0.5359,
"lgb_logloss": 0.3338, "lgb_logloss": 0.6822,
"ensemble_accuracy": 0.8863, "ensemble_accuracy": 0.531,
"ensemble_logloss": 0.3423, "ensemble_logloss": 0.6826,
"class_count": 2 "class_count": 2
}, },
"CARDS_OU45": { "CARDS_OU45": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -1277,19 +1277,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.6283, "xgb_accuracy": 0.6009,
"xgb_logloss": 0.6174, "xgb_logloss": 0.6489,
"lgb_accuracy": 0.6413, "lgb_accuracy": 0.5988,
"lgb_logloss": 0.615, "lgb_logloss": 0.6487,
"ensemble_accuracy": 0.6372, "ensemble_accuracy": 0.6024,
"ensemble_logloss": 0.6142, "ensemble_logloss": 0.6479,
"class_count": 2 "class_count": 2
}, },
"HANDICAP_MS": { "HANDICAP_MS": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -1394,15 +1394,15 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.936, "xgb_accuracy": 0.6058,
"xgb_logloss": 0.1903, "xgb_logloss": 0.8691,
"lgb_accuracy": 0.9346, "lgb_accuracy": 0.608,
"lgb_logloss": 0.1843, "lgb_logloss": 0.8677,
"ensemble_accuracy": 0.936, "ensemble_accuracy": 0.6068,
"ensemble_logloss": 0.1861, "ensemble_logloss": 0.8677,
"class_count": 3 "class_count": 3
} }
} }
+146
View File
@@ -0,0 +1,146 @@
import os
import sys
import psycopg2
from psycopg2.extras import RealDictCursor
# Path ayarları
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from services.single_match_orchestrator import SingleMatchOrchestrator
from services.feature_enrichment import FeatureEnrichmentService
DSN = "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
def run_backtest(target_date="2026-05-03"):
conn = psycopg2.connect(DSN)
cur = conn.cursor(cursor_factory=RealDictCursor)
# 1. Hedef tarihteki bitmiş maçları ve takım isimlerini getir
cur.execute("""
SELECT m.id, m.score_home, m.score_away, m.mst_utc,
t1.name as home_name, t2.name as away_name
FROM matches m
LEFT JOIN teams t1 ON m.home_team_id = t1.id
LEFT JOIN teams t2 ON m.away_team_id = t2.id
WHERE m.status IN ('FT', 'AET', 'PEN')
AND to_timestamp(m.mst_utc / 1000.0)::date = %s::date
AND m.score_home IS NOT NULL
ORDER BY m.mst_utc ASC
""", (target_date,))
matches = cur.fetchall()
if not matches:
print(f"{target_date} tarihinde bitmiş maç bulunamadı.")
return
print(f"🚀 {target_date} için Orkestratör Backtesti Başlatılıyor... ({len(matches)} maç bulundu)")
print("-" * 60)
orchestrator = SingleMatchOrchestrator()
bets_placed = 0
won = 0
lost = 0
total_odds_won = 0.0
for match in matches:
# 3. Üst Akıl (Orkestratör) analizi yapar
try:
package = orchestrator.analyze_match(match['id'])
except Exception as e:
print(f"Hata ({match['id']}): {e}")
continue
if not package:
continue
package_data = package
# 4. Üst akıl bu maça bahis yapmaya karar verdi mi?
bet_advice = package_data.get("bet_advice", {})
if bet_advice.get("playable") == True:
bets_placed += 1
main_pick = package_data.get("main_pick", {})
market = main_pick.get("market")
pick = main_pick.get("pick")
odds = float(main_pick.get("odds", 0.0) or 0.0)
# Skora göre kazanıp kazanmadığını kontrol et
is_won = False
h = match['score_home']
a = match['score_away']
if market == "MS":
if pick == "1" and h > a: is_won = True
elif pick in ("X", "0") and h == a: is_won = True
elif pick == "2" and a > h: is_won = True
elif market == "OU25":
if pick == "Üst" and (h+a) > 2.5: is_won = True
elif pick == "Alt" and (h+a) < 2.5: is_won = True
elif market == "OU15":
if pick == "Üst" and (h+a) > 1.5: is_won = True
elif pick == "Alt" and (h+a) < 1.5: is_won = True
elif market == "BTTS":
if pick == "KG Var" and h > 0 and a > 0: is_won = True
elif pick == "KG Yok" and (h == 0 or a == 0): is_won = True
elif market == "DC":
if pick == "1X" and h >= a: is_won = True
elif pick == "12" and h != a: is_won = True
elif pick == "X2" and h <= a: is_won = True
if is_won:
won += 1
total_odds_won += odds
res = "✅ KAZANDI"
else:
lost += 1
res = "❌ KAYBETTİ"
print(f"[{res}] {match['home_name']} {h}-{a} {match['away_name']} | Tahmin: {market} {pick} (Oran: {odds})")
else:
main_pick = package_data.get("main_pick", {})
reasons = main_pick.get("reasons", ["Bilinmeyen Neden"]) if main_pick else ["No main pick"]
reason = " | ".join(reasons) if isinstance(reasons, list) else str(reasons)
market_board = package_data.get("market_board", {})
main_pick_market = main_pick.get('market', 'N/A') if main_pick else 'N/A'
main_pick_pick = main_pick.get('pick', 'N/A') if main_pick else 'N/A'
print(f"[PAS] {match['home_name']} {match['score_home']}-{match['score_away']} {match['away_name']} | Reddedilen: {main_pick_market} {main_pick_pick} -> Neden: {reason}")
if "market_passed_all_gates" in reason:
print(f" DEBUG: bet_advice = {bet_advice}")
v25_ms = market_board.get("MS", {}).get("probs", {})
v27_ms = {} # V27 is merged into V25 probabilities in market_board, or we don't have separate V27 access here
# Skora göre ms kontrolü
h = match['score_home']
a = match['score_away']
actual_ms = "1" if h > a else ("X" if h == a else "2")
v25_top = max(v25_ms, key=v25_ms.get) if v25_ms else "N/A"
v27_top = "N/A"
rejected_market = main_pick.get("market", "N/A") if main_pick else "N/A"
rejected_pick = main_pick.get("pick", "N/A") if main_pick else "N/A"
print(f"[PAS] {match['home_name']} {h}-{a} {match['away_name']} | Reddedilen: {rejected_market} {rejected_pick} -> Neden: {reason}")
print(f" [V25 MS Raw: {v25_top}] [Gerçek MS: {actual_ms}]")
# Sonuç Raporu
print("\n" + "=" * 60)
print(f"📊 BACKTEST SONUÇLARI ({target_date})")
print("=" * 60)
print(f"Toplam Maç Sayısı : {len(matches)}")
print(f"Oynanan Bahis Sayısı: {bets_placed} (Oynama Oranı: %{bets_placed/len(matches)*100:.1f})")
print(f"Riskli Bulunup Pas Geçilen: {len(matches) - bets_placed}")
if bets_placed > 0:
win_rate = won / bets_placed * 100
roi = ((total_odds_won - bets_placed) / bets_placed) * 100
print(f"Kazanılan : {won}")
print(f"Kaybedilen : {lost}")
print(f"İsabet Oranı : %{win_rate:.1f}")
print(f"Net Kar (ROI) : %{roi:.1f} {'📈' if roi > 0 else '📉'}")
if __name__ == "__main__":
run_backtest("2026-05-03")
+459
View File
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
"""
AI Features Full Enrichment Script
====================================
Fills empty/default columns in football_ai_features that were not populated
by the original elo_backfill_v1 script.
Enriches: H2H, referee, team_stats, league_averages, form_streaks,
rolling_goals, implied_odds, and clean_sheet/scoring rates.
Usage:
python scripts/enrich_ai_features.py # enrich all
python scripts/enrich_ai_features.py --batch-size 500 # smaller batches
python scripts/enrich_ai_features.py --dry-run # preview only
python scripts/enrich_ai_features.py --force # re-enrich all rows
python scripts/enrich_ai_features.py --limit 1000 # process N rows max
Designed to be idempotent: uses ON CONFLICT upserts, skips already-enriched rows.
"""
from __future__ import annotations
import os
import sys
import time
import argparse
from typing import Any, Dict, List, Optional, Tuple
# Add ai-engine root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import psycopg2
from psycopg2.extras import RealDictCursor, execute_values
from data.db import get_clean_dsn
from services.feature_enrichment import FeatureEnrichmentService
# ────────────────────────── constants ──────────────────────────
CALCULATOR_VER = 'enrichment_v2.0'
DEFAULT_BATCH_SIZE = 200
# ────────────────────────── helpers ────────────────────────────
def fetch_unenriched_matches(
conn: psycopg2.extensions.connection,
force: bool = False,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""
Fetch matches from football_ai_features that still have default values
in the enrichment columns (h2h_total=0 AND referee_avg_cards=0).
If force=True, fetches ALL rows regardless of current state.
"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
where_clause = "WHERE 1=1" if force else (
"WHERE (faf.h2h_total = 0 AND faf.referee_avg_cards = 0)"
)
limit_clause = f"LIMIT {limit}" if limit else ""
cur.execute(f"""
SELECT
faf.match_id,
m.home_team_id,
m.away_team_id,
m.mst_utc,
m.league_id,
m.score_home,
m.score_away
FROM football_ai_features faf
JOIN matches m ON m.id = faf.match_id
WHERE m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.sport = 'football'
AND ({where_clause.replace('WHERE ', '')})
ORDER BY m.mst_utc ASC
{limit_clause}
""")
return cur.fetchall()
def fetch_referee_for_match(
cur: RealDictCursor,
match_id: str,
) -> Optional[str]:
"""Get the head referee name for a match from match_officials."""
try:
cur.execute("""
SELECT mo.name
FROM match_officials mo
WHERE mo.match_id = %s
AND mo.role_id = 1
LIMIT 1
""", (match_id,))
row = cur.fetchone()
return row['name'] if row else None
except Exception:
return None
def fetch_implied_odds(
cur: RealDictCursor,
match_id: str,
) -> Dict[str, float]:
"""Get implied probabilities from odd_categories + odd_selections."""
defaults = {
'implied_home': 0.33,
'implied_draw': 0.33,
'implied_away': 0.33,
'implied_over25': 0.50,
'implied_btts_yes': 0.50,
'odds_overround': 0.0,
}
try:
cur.execute("""
SELECT oc.name AS cat_name, os.name AS sel_name, os.odd_value
FROM odd_selections os
JOIN odd_categories oc ON os.odd_category_db_id = oc.db_id
WHERE oc.match_id = %s
""", (match_id,))
rows = cur.fetchall()
except Exception:
return defaults
odds: Dict[str, float] = {}
for row in rows:
try:
cat = (row.get('cat_name') or '').lower().strip()
sel = (row.get('sel_name') or '').strip()
val = float(row.get('odd_value', 0))
if val <= 0:
continue
if cat == 'maç sonucu':
if sel == '1':
odds['ms_h'] = val
elif sel in ('0', 'X'):
odds['ms_d'] = val
elif sel == '2':
odds['ms_a'] = val
elif cat == '2,5 alt/üst':
if 'üst' in sel.lower():
odds['ou25_o'] = val
elif 'alt' in sel.lower():
odds['ou25_u'] = val
elif cat == 'karşılıklı gol':
if 'var' in sel.lower():
odds['btts_y'] = val
elif 'yok' in sel.lower():
odds['btts_n'] = val
except (ValueError, TypeError):
continue
# Compute implied probabilities
ms_h = odds.get('ms_h', 0)
ms_d = odds.get('ms_d', 0)
ms_a = odds.get('ms_a', 0)
if ms_h > 1.0 and ms_d > 1.0 and ms_a > 1.0:
raw_sum = 1 / ms_h + 1 / ms_d + 1 / ms_a
overround = raw_sum - 1.0
defaults['implied_home'] = round((1 / ms_h) / raw_sum, 4)
defaults['implied_draw'] = round((1 / ms_d) / raw_sum, 4)
defaults['implied_away'] = round((1 / ms_a) / raw_sum, 4)
defaults['odds_overround'] = round(overround, 4)
ou25_o = odds.get('ou25_o', 0)
ou25_u = odds.get('ou25_u', 0)
if ou25_o > 1.0 and ou25_u > 1.0:
raw_sum = 1 / ou25_o + 1 / ou25_u
defaults['implied_over25'] = round((1 / ou25_o) / raw_sum, 4)
btts_y = odds.get('btts_y', 0)
btts_n = odds.get('btts_n', 0)
if btts_y > 1.0 and btts_n > 1.0:
raw_sum = 1 / btts_y + 1 / btts_n
defaults['implied_btts_yes'] = round((1 / btts_y) / raw_sum, 4)
return defaults
def enrich_single_match(
enrichment: FeatureEnrichmentService,
cur: RealDictCursor,
match: Dict[str, Any],
) -> Dict[str, Any]:
"""
Compute all enrichment features for a single match and return
a dict ready for DB upsert.
"""
match_id = match['match_id']
home_id = str(match['home_team_id'])
away_id = str(match['away_team_id'])
mst_utc = int(match['mst_utc']) if match['mst_utc'] else 0
league_id = str(match['league_id']) if match['league_id'] else None
# 1. Team stats
home_stats = enrichment.compute_team_stats(cur, home_id, mst_utc)
away_stats = enrichment.compute_team_stats(cur, away_id, mst_utc)
# 2. H2H
h2h = enrichment.compute_h2h(cur, home_id, away_id, mst_utc)
# 3. Form & streaks
home_form = enrichment.compute_form_streaks(cur, home_id, mst_utc)
away_form = enrichment.compute_form_streaks(cur, away_id, mst_utc)
# 4. Referee
referee_name = fetch_referee_for_match(cur, match_id)
referee = enrichment.compute_referee_stats(cur, referee_name, mst_utc)
# 5. League averages
league = enrichment.compute_league_averages(cur, league_id, mst_utc)
# 6. Rolling stats (for goals avg)
home_rolling = enrichment.compute_rolling_stats(cur, home_id, mst_utc)
away_rolling = enrichment.compute_rolling_stats(cur, away_id, mst_utc)
# 7. Implied odds
implied = fetch_implied_odds(cur, match_id)
return {
'match_id': match_id,
# Team stats
'home_avg_possession': round(home_stats['avg_possession'], 2),
'away_avg_possession': round(away_stats['avg_possession'], 2),
'home_avg_shots_on_target': round(home_stats['avg_shots_on_target'], 2),
'away_avg_shots_on_target': round(away_stats['avg_shots_on_target'], 2),
'home_shot_conversion': round(home_stats['shot_conversion'], 4),
'away_shot_conversion': round(away_stats['shot_conversion'], 4),
'home_avg_corners': round(home_stats['avg_corners'], 2),
'away_avg_corners': round(away_stats['avg_corners'], 2),
# H2H
'h2h_total': h2h['total_matches'],
'h2h_home_win_rate': round(h2h['home_win_rate'], 4),
'h2h_avg_goals': round(h2h['avg_goals'], 2),
'h2h_over25_rate': round(h2h['over25_rate'], 4),
'h2h_btts_rate': round(h2h['btts_rate'], 4),
# Form
'home_clean_sheet_rate': round(home_form['clean_sheet_rate'], 4),
'away_clean_sheet_rate': round(away_form['clean_sheet_rate'], 4),
'home_scoring_rate': round(home_form['scoring_rate'], 4),
'away_scoring_rate': round(away_form['scoring_rate'], 4),
'home_win_streak': home_form['winning_streak'],
'away_win_streak': away_form['winning_streak'],
# Rolling goals
'home_goals_avg_5': round(home_rolling['rolling5_goals'], 2),
'away_goals_avg_5': round(away_rolling['rolling5_goals'], 2),
'home_conceded_avg_5': round(home_rolling['rolling5_conceded'], 2),
'away_conceded_avg_5': round(away_rolling['rolling5_conceded'], 2),
# Referee
'referee_avg_cards': round(referee['cards_total'], 2),
'referee_home_bias': round(referee['home_bias'], 4),
'referee_avg_goals': round(referee['avg_goals'], 2),
# League
'league_avg_goals': round(league['avg_goals'], 2),
'league_home_win_pct': round(league['home_win_rate'], 4),
'league_over25_pct': round(league['ou25_rate'], 4),
# Implied odds
'implied_home': implied['implied_home'],
'implied_draw': implied['implied_draw'],
'implied_away': implied['implied_away'],
'implied_over25': implied['implied_over25'],
'implied_btts_yes': implied['implied_btts_yes'],
'odds_overround': implied['odds_overround'],
# Missing players impact — default (no lineup data for historical)
'missing_players_impact': 0.0,
# Version
'calculator_ver': CALCULATOR_VER,
}
def flush_enrichment_batch(
conn: psycopg2.extensions.connection,
rows: List[Dict[str, Any]],
dry_run: bool,
) -> int:
"""Bulk upsert enriched features into football_ai_features."""
if not rows or dry_run:
return 0
columns = [
'match_id',
'home_avg_possession', 'away_avg_possession',
'home_avg_shots_on_target', 'away_avg_shots_on_target',
'home_shot_conversion', 'away_shot_conversion',
'home_avg_corners', 'away_avg_corners',
'h2h_total', 'h2h_home_win_rate', 'h2h_avg_goals',
'h2h_over25_rate', 'h2h_btts_rate',
'home_clean_sheet_rate', 'away_clean_sheet_rate',
'home_scoring_rate', 'away_scoring_rate',
'home_win_streak', 'away_win_streak',
'home_goals_avg_5', 'away_goals_avg_5',
'home_conceded_avg_5', 'away_conceded_avg_5',
'referee_avg_cards', 'referee_home_bias', 'referee_avg_goals',
'league_avg_goals', 'league_home_win_pct', 'league_over25_pct',
'implied_home', 'implied_draw', 'implied_away',
'implied_over25', 'implied_btts_yes', 'odds_overround',
'missing_players_impact', 'calculator_ver',
]
# Build update SET clause (skip match_id)
update_cols = [c for c in columns if c != 'match_id']
set_clause = ', '.join(f'{c} = EXCLUDED.{c}' for c in update_cols)
placeholders = ', '.join(['%s'] * len(columns))
values = [
tuple(row[c] for c in columns)
for row in rows
]
with conn.cursor() as cur:
execute_values(
cur,
f"""
INSERT INTO football_ai_features ({', '.join(columns)})
VALUES %s
ON CONFLICT (match_id) DO UPDATE SET
{set_clause},
updated_at = NOW()
""",
values,
template=f"({placeholders})",
page_size=200,
)
conn.commit()
return len(rows)
# ────────────────────────── main ───────────────────────────────
def run_enrichment(
batch_size: int,
dry_run: bool,
force: bool,
limit: Optional[int],
) -> None:
"""Core enrichment loop."""
dsn = get_clean_dsn()
conn = psycopg2.connect(dsn)
print(f"\n{'=' * 60}")
print(f"🧠 AI Features Full Enrichment — {CALCULATOR_VER}")
print(f" batch_size={batch_size} dry_run={dry_run} force={force}")
print(f"{'=' * 60}")
# 1. Fetch unenriched matches
t0 = time.time()
matches = fetch_unenriched_matches(conn, force=force, limit=limit)
print(f"\n📊 {len(matches):,} matches to enrich ({time.time() - t0:.1f}s)")
if not matches:
print("✅ Nothing to enrich — all rows already populated.")
conn.close()
return
# 2. Initialize enrichment service
enrichment = FeatureEnrichmentService()
# 3. Process in batches
total = len(matches)
processed = 0
written = 0
errors = 0
batch_buf: List[Dict[str, Any]] = []
t_start = time.time()
# Use a dedicated cursor with RealDictCursor for all enrichment queries
enrich_cur = conn.cursor(cursor_factory=RealDictCursor)
for idx, match in enumerate(matches):
try:
enriched = enrich_single_match(enrichment, enrich_cur, match)
batch_buf.append(enriched)
except Exception as e:
errors += 1
if errors <= 10:
print(f" ⚠️ Error enriching {match.get('match_id', '?')}: {e}")
processed += 1
# Flush batch
if len(batch_buf) >= batch_size:
flushed = flush_enrichment_batch(conn, batch_buf, dry_run)
written += flushed
batch_buf.clear()
# Progress reporting
if processed % 500 == 0:
elapsed = time.time() - t_start
rate = processed / elapsed if elapsed > 0 else 0
remaining = (total - processed) / rate if rate > 0 else 0
pct = processed / total * 100
print(
f" [{processed:>8,} / {total:,}] "
f"({pct:.1f}%) | {rate:.0f} matches/s | "
f"ETA: {remaining / 60:.1f} min | "
f"errors: {errors}"
)
# Flush remaining
if batch_buf:
flushed = flush_enrichment_batch(conn, batch_buf, dry_run)
written += flushed
enrich_cur.close()
elapsed = time.time() - t_start
print(f"\n{'=' * 60}")
print(f"✅ Enrichment complete:")
print(f" Processed: {processed:,} matches in {elapsed:.1f}s")
print(f" Written: {written:,} rows")
print(f" Errors: {errors:,}")
print(f" Rate: {processed / elapsed:.0f} matches/s")
print(f"{'=' * 60}")
conn.close()
def main() -> None:
parser = argparse.ArgumentParser(
description="Enrich football_ai_features with H2H, referee, stats, and odds data"
)
parser.add_argument(
'--batch-size',
type=int,
default=DEFAULT_BATCH_SIZE,
help=f'DB insert batch size (default: {DEFAULT_BATCH_SIZE})',
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Compute features but do not write to DB',
)
parser.add_argument(
'--force',
action='store_true',
help='Re-enrich ALL rows, not just empty ones',
)
parser.add_argument(
'--limit',
type=int,
default=None,
help='Max number of matches to process',
)
args = parser.parse_args()
run_enrichment(
batch_size=args.batch_size,
dry_run=args.dry_run,
force=args.force,
limit=args.limit,
)
if __name__ == '__main__':
main()
+79 -15
View File
@@ -510,12 +510,20 @@ class FeatureExtractor:
self.referee_engine = get_referee_engine() self.referee_engine = get_referee_engine()
self.momentum_engine = get_momentum_engine() self.momentum_engine = get_momentum_engine()
# ── Data Quality Thresholds ──
# Matches below these thresholds produce default-only features that
# teach the model noise rather than signal.
DQ_MIN_FORM_MATCHES = 3 # team must have ≥3 prior matches
DQ_MIN_FEATURE_COVERAGE = 0.30 # ≥30% of key features must be non-default
def extract_all(self) -> list: def extract_all(self) -> list:
"""Extract features for all matches, yield row dicts.""" """Extract features for all matches with data quality validation."""
matches = self.loader.matches matches = self.loader.matches
total = len(matches) total = len(matches)
rows = [] rows = []
skipped = 0 skipped = 0
dq_rejected = 0
dq_reasons: dict = defaultdict(int)
t_start = time.time() t_start = time.time()
print(f"\n🔄 Extracting features for {total} matches...", flush=True) print(f"\n🔄 Extracting features for {total} matches...", flush=True)
@@ -542,32 +550,37 @@ class FeatureExtractor:
rate = i / elapsed # matches per second rate = i / elapsed # matches per second
remaining = (total - i) / rate if rate > 0 else 0 remaining = (total - i) / rate if rate > 0 else 0
pct = i / total * 100 pct = i / total * 100
print(f" [{i}/{total}] ({pct:.0f}%) | {rate:.1f} maç/s | ETA: {remaining/60:.1f} dk | skipped: {skipped}", flush=True) print(
f" [{i}/{total}] ({pct:.0f}%) | {rate:.1f} maç/s | "
f"ETA: {remaining/60:.1f} dk | skipped: {skipped} | "
f"dq_rejected: {dq_rejected}",
flush=True,
)
row = self._extract_one( row = self._extract_one(
mid, mid, hid, aid, sh, sa, hth, hta, mst, lid,
hid, home_name, away_name, league_name,
aid,
sh,
sa,
hth,
hta,
mst,
lid,
home_name,
away_name,
league_name,
) )
if row: if row:
# ── Data Quality Gate ──
dq_pass, reason = self._validate_row_quality(row, hid, aid, mst)
if dq_pass:
rows.append(row) rows.append(row)
else:
dq_rejected += 1
dq_reasons[reason] += 1
else: else:
skipped += 1 skipped += 1
# Update ELO after processing (so ELO is calculated BEFORE the match) # Update ELO after processing (so ELO is calculated BEFORE the match)
self._update_elo(hid, aid, sh, sa) self._update_elo(hid, aid, sh, sa)
print(f" ✅ Extracted {len(rows)} rows, skipped {skipped}", flush=True) print(f" ✅ Extracted {len(rows)} rows, skipped {skipped}, DQ rejected {dq_rejected}", flush=True)
if dq_reasons:
print(f" 📊 DQ Rejection reasons:")
for reason, count in sorted(dq_reasons.items(), key=lambda x: -x[1]):
print(f" {reason}: {count}")
return rows return rows
def _extract_one( def _extract_one(
@@ -868,6 +881,57 @@ class FeatureExtractor:
return row return row
def _validate_row_quality(
self,
row: dict,
home_id: str,
away_id: str,
before_date: int,
) -> tuple:
"""
Data quality gate for training rows.
Ensures the feature vector has enough real signal to be useful for
training. Rejects rows where critical features are all at their
default/fallback values — these teach the model noise, not patterns.
Returns (pass: bool, reason: str | None).
"""
# 1. Minimum form history: both teams must have enough prior matches
home_history = self.loader.team_matches.get(home_id, [])
away_history = self.loader.team_matches.get(away_id, [])
home_prior = sum(1 for m in home_history if m[0] < before_date)
away_prior = sum(1 for m in away_history if m[0] < before_date)
if home_prior < self.DQ_MIN_FORM_MATCHES:
return False, 'home_insufficient_history'
if away_prior < self.DQ_MIN_FORM_MATCHES:
return False, 'away_insufficient_history'
# 2. Feature coverage check: count how many key features are non-default
key_features = [
('home_goals_avg', 1.3),
('away_goals_avg', 1.3),
('home_clean_sheet_rate', 0.25),
('away_clean_sheet_rate', 0.25),
('home_avg_possession', 0.50),
('away_avg_possession', 0.50),
('home_avg_shots_on_target', 3.5),
('away_avg_shots_on_target', 3.5),
('h2h_total_matches', 0),
('odds_ms_h', 0.0),
]
non_default = sum(
1 for feat_name, default_val in key_features
if abs(float(row.get(feat_name, default_val)) - default_val) > 0.01
)
coverage = non_default / len(key_features)
if coverage < self.DQ_MIN_FEATURE_COVERAGE:
return False, f'low_feature_coverage_{coverage:.0%}'
return True, None
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# ELO (simplified inline version — doesn't need DB, grows incrementally) # ELO (simplified inline version — doesn't need DB, grows incrementally)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+118 -21
View File
@@ -20,7 +20,7 @@ from sklearn.isotonic import IsotonicRegression
warnings.filterwarnings("ignore") warnings.filterwarnings("ignore")
AI_DIR = Path(__file__).resolve().parent.parent AI_DIR = Path(__file__).resolve().parent.parent
DATA_CSV = AI_DIR / "data" / "training_data_v27.csv" DATA_CSV = AI_DIR / "data" / "training_data.csv"
MODELS_DIR = AI_DIR / "models" / "v27" MODELS_DIR = AI_DIR / "models" / "v27"
MODELS_DIR.mkdir(parents=True, exist_ok=True) MODELS_DIR.mkdir(parents=True, exist_ok=True)
@@ -373,15 +373,52 @@ def main():
print("\n" + ""*65) print("\n" + ""*65)
print(" STAGE A.2: Fundamentals-Only O/U 2.5 Model") print(" STAGE A.2: Fundamentals-Only O/U 2.5 Model")
print(""*65) print(""*65)
y_tr_ou = tr["label_ou25"].values y_tr_ou = tr['label_ou25'].values
y_va_ou = va["label_ou25"].values y_va_ou = va['label_ou25'].values
mask_tr = ~np.isnan(y_tr_ou) mask_tr = ~np.isnan(y_tr_ou)
mask_va = ~np.isnan(y_va_ou) mask_va = ~np.isnan(y_va_ou)
if mask_tr.sum() > 1000: if mask_tr.sum() > 1000:
ou_models = train_fundamentals_model( ou_models = train_fundamentals_model(
X_tr[mask_tr], y_tr_ou[mask_tr].astype(int), X_tr[mask_tr], y_tr_ou[mask_tr].astype(int),
X_va[mask_va], y_va_ou[mask_va].astype(int), X_va[mask_va], y_va_ou[mask_va].astype(int),
clean_feats, "ou25") clean_feats, 'ou25')
# ── STAGE A.3: BTTS Model ──
btts_models = None
if 'label_btts' in tr.columns:
print('\n' + '' * 65)
print(' STAGE A.3: Fundamentals-Only BTTS Model')
print('' * 65)
y_tr_btts = tr['label_btts'].values
y_va_btts = va['label_btts'].values
mask_tr_btts = ~np.isnan(y_tr_btts)
mask_va_btts = ~np.isnan(y_va_btts)
if mask_tr_btts.sum() > 1000:
btts_models = train_fundamentals_model(
X_tr[mask_tr_btts], y_tr_btts[mask_tr_btts].astype(int),
X_va[mask_va_btts], y_va_btts[mask_va_btts].astype(int),
clean_feats, 'btts')
# Quick val accuracy
btts_probs = ensemble_predict(
btts_models,
X_va[mask_va_btts],
clean_feats,
n_class=2,
)
btts_acc = accuracy_score(
y_va_btts[mask_va_btts].astype(int),
btts_probs.argmax(1),
)
btts_ll = log_loss(
y_va_btts[mask_va_btts].astype(int),
btts_probs,
)
print(f'\n BTTS Ensemble Val: acc={btts_acc:.4f}, logloss={btts_ll:.4f}')
# Compare with naive baseline (always predict majority class)
btts_majority = y_va_btts[mask_va_btts].astype(int).mean()
print(f' BTTS baseline: {max(btts_majority, 1-btts_majority):.4f} (majority class)')
print(f' Model vs baseline: {btts_acc - max(btts_majority, 1-btts_majority):+.4f}')
# ── STAGE C: Backtest ── # ── STAGE C: Backtest ──
print("\n" + ""*65) print("\n" + ""*65)
@@ -422,13 +459,58 @@ def main():
# OU25 backtest # OU25 backtest
if ou_models: if ou_models:
print("\n --- O/U 2.5 Backtest ---") print('\n --- O/U 2.5 Backtest ---')
for edge in [0.05, 0.07, 0.10]: for edge in [0.05, 0.07, 0.10]:
r = backtest_value(ou_models, te, clean_feats, "ou25", r = backtest_value(ou_models, te, clean_feats, 'ou25',
min_edge=edge, min_odds=1.50, max_odds=3.0, min_edge=edge, min_odds=1.50, max_odds=3.0,
use_kelly=True) use_kelly=True)
if r.get("total", 0) > 0: if r.get('total', 0) > 0:
print_backtest(r, f"OU25 edge>{edge}") print_backtest(r, f'OU25 edge>{edge}')
# BTTS backtest
if btts_models and 'label_btts' in te.columns:
print('\n --- BTTS Backtest ---')
# Build BTTS odds for backtest
if 'odds_btts_y' in te.columns and 'odds_btts_n' in te.columns:
te_btts = te.copy()
te_btts['odds_btts_y'] = pd.to_numeric(
te_btts['odds_btts_y'], errors='coerce',
).fillna(1.85)
te_btts['odds_btts_n'] = pd.to_numeric(
te_btts['odds_btts_n'], errors='coerce',
).fillna(1.85)
for edge in [0.05, 0.07, 0.10]:
X_test = te_btts[clean_feats].values
probs = ensemble_predict(btts_models, X_test, clean_feats, 2)
y_btts = te_btts['label_btts'].values.astype(int)
odds_arr = te_btts[['odds_btts_n', 'odds_btts_y']].values
m_arr = 1 / odds_arr
impl = m_arr / m_arr.sum(axis=1, keepdims=True)
total_bets = 0
wins = 0
pnl = 0.0
for i in range(len(y_btts)):
for cls in range(2):
e = probs[i, cls] - impl[i, cls]
o = odds_arr[i, cls]
if e < edge or o < 1.50 or o > 3.0:
continue
total_bets += 1
won = (y_btts[i] == cls)
if won:
wins += 1
pnl += 10 * (o - 1)
else:
pnl -= 10
if total_bets > 0:
roi = pnl / (total_bets * 10) * 100
hit = wins / total_bets * 100
print(
f' Edge>{edge:.2f}: {total_bets} bets, '
f'hit={hit:.1f}%, ROI={roi:+.1f}%'
)
# ── Feature importance ── # ── Feature importance ──
if "lgb" in ms_models: if "lgb" in ms_models:
@@ -452,25 +534,40 @@ def main():
if ou_models: if ou_models:
for name, m in ou_models.items(): for name, m in ou_models.items():
p = MODELS_DIR / f"v27_ou25_{name}.pkl" p = MODELS_DIR / f'v27_ou25_{name}.pkl'
with open(p, "wb") as f: with open(p, 'wb') as f:
pickle.dump(m, f) pickle.dump(m, f)
print(f"{p.name}") print(f'{p.name}')
if btts_models:
for name, m in btts_models.items():
p = MODELS_DIR / f'v27_btts_{name}.pkl'
with open(p, 'wb') as f:
pickle.dump(m, f)
print(f'{p.name}')
meta = { meta = {
"version": "v27-pro", "trained_at": time.strftime("%Y-%m-%d %H:%M:%S"), 'version': 'v27-pro',
"approach": "odds-free fundamentals + value edge detection", 'trained_at': time.strftime('%Y-%m-%d %H:%M:%S'),
"feature_count": len(clean_feats), 'approach': 'odds-free fundamentals + value edge detection',
"total_samples": len(df), 'feature_count': len(clean_feats),
"val_acc": round(val_acc, 4), "val_ll": round(val_ll, 4), 'total_samples': len(df),
"best_config": {k: v for k, v in best_cfg.items() if k != "result"} if best_cfg else {}, 'val_acc': round(val_acc, 4),
"markets": ["ms"] + (["ou25"] if ou_models else []), 'val_ll': round(val_ll, 4),
'best_config': {
k: v for k, v in best_cfg.items() if k != 'result'
} if best_cfg else {},
'markets': (
['ms']
+ (['ou25'] if ou_models else [])
+ (['btts'] if btts_models else [])
),
} }
with open(MODELS_DIR / "v27_metadata.json", "w") as f: with open(MODELS_DIR / 'v27_metadata.json', 'w') as f:
json.dump(meta, f, indent=2, default=str) json.dump(meta, f, indent=2, default=str)
with open(MODELS_DIR / "v27_feature_cols.json", "w") as f: with open(MODELS_DIR / 'v27_feature_cols.json', 'w') as f:
json.dump(clean_feats, f, indent=2) json.dump(clean_feats, f, indent=2)
print(f" ✓ metadata + feature_cols") print(f' ✓ metadata + feature_cols')
print(f"\n Total time: {(time.time()-t0)/60:.1f} min") print(f"\n Total time: {(time.time()-t0)/60:.1f} min")
print(" DONE!") print(" DONE!")
+15 -7
View File
@@ -165,6 +165,11 @@ class BettingBrain:
score -= 18.0 score -= 18.0
issues.append("base_model_not_playable") issues.append("base_model_not_playable")
is_value_sniper = bool(row.get("is_value_sniper"))
if is_value_sniper:
score += 35.0
positives.append("value_sniper_override")
score += max(0.0, min(20.0, calibrated_conf * 0.22)) score += max(0.0, min(20.0, calibrated_conf * 0.22))
score += max(-8.0, min(16.0, ev_edge * 45.0)) score += max(-8.0, min(16.0, ev_edge * 45.0))
score += max(0.0, min(14.0, play_score * 0.12)) score += max(0.0, min(14.0, play_score * 0.12))
@@ -178,13 +183,13 @@ class BettingBrain:
if odds < self.MIN_ODDS: if odds < self.MIN_ODDS:
vetoes.append("odds_below_minimum") vetoes.append("odds_below_minimum")
if calibrated_conf < 38.0: if calibrated_conf < 38.0 and not is_value_sniper:
vetoes.append("calibrated_confidence_too_low") vetoes.append("calibrated_confidence_too_low")
if play_score < 50.0: if play_score < 50.0 and not is_value_sniper:
vetoes.append("play_score_too_low") vetoes.append("play_score_too_low")
if divergence is not None: if divergence is not None:
if divergence >= self.HARD_DIVERGENCE: if divergence >= self.HARD_DIVERGENCE and not is_value_sniper:
score -= 42.0 score -= 42.0
vetoes.append("v25_v27_hard_disagreement") vetoes.append("v25_v27_hard_disagreement")
elif divergence >= self.SOFT_DIVERGENCE: elif divergence >= self.SOFT_DIVERGENCE:
@@ -211,7 +216,7 @@ class BettingBrain:
else: else:
score -= 16.0 score -= 16.0
issues.append("historical_sample_too_low") issues.append("historical_sample_too_low")
if market == "DC": if market == "DC" and not is_value_sniper:
vetoes.append("dc_without_historical_sample") vetoes.append("dc_without_historical_sample")
elif market in {"MS", "DC", "OU25"}: elif market in {"MS", "DC", "OU25"}:
score -= 10.0 score -= 10.0
@@ -227,20 +232,21 @@ class BettingBrain:
and model_prob >= self.EXTREME_MODEL_PROB and model_prob >= self.EXTREME_MODEL_PROB
and model_gap >= self.EXTREME_GAP and model_gap >= self.EXTREME_GAP
and not triple_is_value and not triple_is_value
and not is_value_sniper
): ):
score -= 24.0 score -= 24.0
vetoes.append("extreme_probability_without_evidence") vetoes.append("extreme_probability_without_evidence")
if market in {"HT", "HTFT", "OE"} and score < 86.0: if market in {"HT", "HTFT", "OE"} and score < 86.0 and not is_value_sniper:
vetoes.append("volatile_market_requires_exceptional_evidence") vetoes.append("volatile_market_requires_exceptional_evidence")
score = max(0.0, min(100.0, score)) score = max(0.0, min(100.0, score))
action = "BET" action = "BET"
if vetoes: if vetoes:
action = "REJECT" action = "REJECT"
elif score < self.MIN_WATCH_SCORE: elif score < self.MIN_WATCH_SCORE and not is_value_sniper:
action = "REJECT" action = "REJECT"
elif score < self.MIN_BET_SCORE: elif score < self.MIN_BET_SCORE and not is_value_sniper:
action = "WATCH" action = "WATCH"
row["betting_brain"] = { row["betting_brain"] = {
@@ -276,6 +282,7 @@ class BettingBrain:
for source in ("main_pick", "value_pick"): for source in ("main_pick", "value_pick"):
item = package.get(source) item = package.get(source)
if isinstance(item, dict) and item.get("market"): if isinstance(item, dict) and item.get("market"):
# print(f"DEBUG: {source} is_value_sniper: {item.get('is_value_sniper')}")
rows[self._row_key(item)] = dict(item) rows[self._row_key(item)] = dict(item)
for source in ("supporting_picks", "bet_summary"): for source in ("supporting_picks", "bet_summary"):
@@ -283,6 +290,7 @@ class BettingBrain:
if isinstance(item, dict) and item.get("market"): if isinstance(item, dict) and item.get("market"):
key = self._row_key(item) key = self._row_key(item)
rows[key] = self._merge_row(rows.get(key), item) rows[key] = self._merge_row(rows.get(key), item)
return list(rows.values()) return list(rows.values())
@staticmethod @staticmethod
+151 -24
View File
@@ -14,11 +14,40 @@ is missing or queries fail.
from __future__ import annotations from __future__ import annotations
import unicodedata
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
# ─── Turkish Name Normalization ──────────────────────────────────
_TR_CHAR_MAP = str.maketrans(
'çÇğĞıİöÖşŞüÜâÂîÎûÛ',
'cCgGiIoOsSuUaAiIuU',
)
def _normalize_name(name: str) -> str:
"""
Normalize a Turkish referee name for fuzzy matching.
Strips accents, lowercases, removes extra whitespace, and maps
Turkish-specific characters to their ASCII equivalents.
"""
if not name:
return ''
# 1. Turkish-specific character mapping
normalized = name.translate(_TR_CHAR_MAP)
# 2. Unicode NFKD decomposition → strip combining marks
normalized = unicodedata.normalize('NFKD', normalized)
normalized = ''.join(
c for c in normalized if not unicodedata.combining(c)
)
# 3. Lowercase + collapse whitespace
return ' '.join(normalized.lower().split())
class FeatureEnrichmentService: class FeatureEnrichmentService:
"""Stateless service — all state comes from DB via cursor.""" """Stateless service — all state comes from DB via cursor."""
@@ -380,34 +409,20 @@ class FeatureEnrichmentService:
""" """
Referee tendencies: home win bias, avg goals, card rates. Referee tendencies: home win bias, avg goals, card rates.
Matches referee by name in match_officials (role_id=1 = Orta Hakem). Matches referee by name in match_officials (role_id=1 = Orta Hakem).
Uses Turkish-aware fuzzy matching as a fallback when exact name
lookup returns zero results.
""" """
if not referee_name: if not referee_name:
return dict(self._DEFAULT_REFEREE) return dict(self._DEFAULT_REFEREE)
try:
# Get match IDs officiated by this referee rows = self._query_referee_matches(cur, referee_name, before_date_ms, limit)
cur.execute(
""" # Fuzzy fallback: if exact match fails, try normalized name search
SELECT if not rows:
m.home_team_id, rows = self._fuzzy_referee_lookup(
m.score_home, cur, referee_name, before_date_ms, limit,
m.score_away,
m.id AS match_id
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.name = %s
AND mo.role_id = 1
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 %s
""",
(referee_name, before_date_ms, limit),
) )
rows = cur.fetchall()
except Exception:
return dict(self._DEFAULT_REFEREE)
if not rows: if not rows:
return dict(self._DEFAULT_REFEREE) return dict(self._DEFAULT_REFEREE)
@@ -459,6 +474,118 @@ class FeatureEnrichmentService:
'experience': total, 'experience': total,
} }
def _query_referee_matches(
self,
cur: RealDictCursor,
referee_name: str,
before_date_ms: int,
limit: int,
) -> list:
"""Exact-match referee lookup in match_officials."""
try:
cur.execute(
"""
SELECT
m.home_team_id,
m.score_home,
m.score_away,
m.id AS match_id
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.name = %s
AND mo.role_id = 1
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 %s
""",
(referee_name, before_date_ms, limit),
)
return cur.fetchall()
except Exception:
return []
def _fuzzy_referee_lookup(
self,
cur: RealDictCursor,
referee_name: str,
before_date_ms: int,
limit: int,
) -> list:
"""
Fuzzy referee lookup using Turkish name normalization.
Strategy: fetch recent distinct referee names from match_officials,
normalize both the query name and each candidate, and pick the
best match. This handles common mismatches like:
- 'Hüseyin Göçek' vs 'Huseyin Gocek'
- 'Ali Palabıyık' vs 'Ali Palabiyik'
- Extra/missing middle initials
"""
normalized_query = _normalize_name(referee_name)
if not normalized_query:
return []
try:
# Fetch candidate referee names (distinct, recent, role=1)
cur.execute(
"""
SELECT DISTINCT mo.name
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.role_id = 1
AND m.status = 'FT'
AND m.mst_utc < %s
ORDER BY mo.name
LIMIT 2000
""",
(before_date_ms,),
)
candidates = cur.fetchall()
except Exception:
return []
if not candidates:
return []
# Find best match by normalized name comparison
best_match: Optional[str] = None
best_score = 0.0
for cand_row in candidates:
cand_name = cand_row.get('name', '')
if not cand_name:
continue
normalized_cand = _normalize_name(cand_name)
# Exact normalized match
if normalized_cand == normalized_query:
best_match = cand_name
best_score = 1.0
break
# Substring containment (handles "First Last" vs "First M. Last")
if (
normalized_query in normalized_cand
or normalized_cand in normalized_query
):
containment_score = min(
len(normalized_query), len(normalized_cand)
) / max(len(normalized_query), len(normalized_cand))
if containment_score > best_score and containment_score > 0.6:
best_match = cand_name
best_score = containment_score
if not best_match:
return []
# Re-query with the resolved name
return self._query_referee_matches(
cur, best_match, before_date_ms, limit,
)
# ─── 5. League Averages ───────────────────────────────────────── # ─── 5. League Averages ─────────────────────────────────────────
def compute_league_averages( def compute_league_averages(
+408 -143
View File
@@ -84,6 +84,7 @@ class MatchData:
current_score_home: Optional[int] = None current_score_home: Optional[int] = None
current_score_away: Optional[int] = None current_score_away: Optional[int] = None
lineup_confidence: float = 0.0 lineup_confidence: float = 0.0
source_table: str = "matches"
class SingleMatchOrchestrator: class SingleMatchOrchestrator:
@@ -190,35 +191,35 @@ class SingleMatchOrchestrator:
} }
# Min confidence: lowered to be achievable (max_reachable - 16 to -20) # Min confidence: lowered to be achievable (max_reachable - 16 to -20)
self.market_min_conf: Dict[str, float] = { self.market_min_conf: Dict[str, float] = {
"MS": 42.0, # was 443-way market, hard to get high conf "MS": 20.0, # was 42drastically lowered to allow underdog/draw value bets
"DC": 52.0, # was 55 — double chance is easier "DC": 40.0, # was 52
"OU15": 55.0, # was 58 — binary + usually high conf "OU15": 45.0, # was 55
"OU25": 48.0, # was 52 — core market, allow more through "OU25": 30.0, # was 48
"OU35": 48.0, # was 54 — lowered to let signals pass "OU35": 20.0, # was 48
"BTTS": 46.0, # was 50 — binary market "BTTS": 30.0, # was 46
"HT": 40.0, # was 45 — was ❌ impossible, now achievable "HT": 20.0, # was 40
"HT_OU05": 50.0, # was 54 — binary HT market "HT_OU05": 35.0, # was 50
"HT_OU15": 42.0, # was 48 — was ❌ impossible, now achievable "HT_OU15": 25.0, # was 42
"OE": 46.0, # was 50 — coin-flip market, lower bar "OE": 35.0, # was 46
"CARDS": 42.0, # was 48 — was ❌ impossible, now achievable "CARDS": 30.0, # was 42
"HCAP": 40.0, # was 46 — was ❌ impossible, now achievable "HCAP": 25.0, # was 40
"HTFT": 28.0, # was 32 — was ❌ impossible, 9-way market "HTFT": 10.0, # was 28
} }
# Min play score: moderate reduction to allow more C-grade bets # Min play score: Significantly reduced to stop blocking value bets on underdogs
self.market_min_play_score: Dict[str, float] = { self.market_min_play_score: Dict[str, float] = {
"MS": 65.0, # was 72 — let more MS through for tracking "MS": 30.0, # was 65
"DC": 58.0, # was 62 — DC is high accuracy "DC": 55.0, # was 58
"OU15": 60.0, # was 64 — strong market per backtest "OU15": 55.0, # was 60
"OU25": 64.0, # was 70 — core market "OU25": 45.0, # was 64
"OU35": 68.0, # was 76 — riskier market "OU35": 35.0, # was 68
"BTTS": 64.0, # was 70 — allow more signals "BTTS": 45.0, # was 64
"HT": 66.0, # was 74 — was never reachable anyway "HT": 30.0, # was 66
"HT_OU05": 60.0, # was 64 — strong backtest market "HT_OU05": 45.0, # was 60
"HT_OU15": 64.0, # was 72 — moderate "HT_OU15": 35.0, # was 64
"OE": 60.0, # was 66 — low priority market "OE": 35.0, # was 60
"CARDS": 66.0, # was 74 — niche market "CARDS": 40.0, # was 66
"HCAP": 68.0, # was 76 — risky "HCAP": 35.0, # was 68
"HTFT": 72.0, # was 82 — 9-way, very risky "HTFT": 20.0, # was 72
} }
self.market_min_edge: Dict[str, float] = { self.market_min_edge: Dict[str, float] = {
"MS": 0.02, # was 0.03 — slight relaxation "MS": 0.02, # was 0.03 — slight relaxation
@@ -235,6 +236,28 @@ class SingleMatchOrchestrator:
"HCAP": 0.03, # was 0.04 "HCAP": 0.03, # was 0.04
"HTFT": 0.05, # was 0.06 "HTFT": 0.05, # was 0.06
} }
self.odds_band_min_sample: Dict[str, float] = {
"MS": 8.0,
"DC": 8.0,
"OU15": 8.0,
"OU25": 8.0,
"OU35": 8.0,
"BTTS": 8.0,
"HT": 8.0,
"HT_OU05": 8.0,
"HT_OU15": 8.0,
}
self.odds_band_min_edge: Dict[str, float] = {
"MS": 0.015,
"DC": 0.012,
"OU15": 0.012,
"OU25": 0.015,
"OU35": 0.018,
"BTTS": 0.015,
"HT": 0.018,
"HT_OU05": 0.012,
"HT_OU15": 0.015,
}
def _get_v25_predictor(self) -> V25Predictor: def _get_v25_predictor(self) -> V25Predictor:
if self.v25_predictor is None: if self.v25_predictor is None:
@@ -362,6 +385,32 @@ class SingleMatchOrchestrator:
away_venue_elo = float(elo_row.get('away_away_elo') or away_elo) away_venue_elo = float(elo_row.get('away_away_elo') or away_elo)
home_form_elo_val = float(elo_row.get('home_form_elo') or home_elo) home_form_elo_val = float(elo_row.get('home_form_elo') or home_elo)
away_form_elo_val = float(elo_row.get('away_form_elo') or away_elo) away_form_elo_val = float(elo_row.get('away_form_elo') or away_elo)
else:
cur.execute(
"""
SELECT
team_id,
overall_elo,
home_elo,
away_elo,
form_elo
FROM team_elo_ratings
WHERE team_id IN (%s, %s)
""",
(data.home_team_id, data.away_team_id),
)
elo_rows = cur.fetchall()
by_team = {str(r.get("team_id")): r for r in elo_rows}
home_row = by_team.get(str(data.home_team_id))
away_row = by_team.get(str(data.away_team_id))
if home_row:
home_elo = float(home_row.get("overall_elo") or 1500.0)
home_venue_elo = float(home_row.get("home_elo") or home_elo)
home_form_elo_val = float(home_row.get("form_elo") or home_elo)
if away_row:
away_elo = float(away_row.get("overall_elo") or 1500.0)
away_venue_elo = float(away_row.get("away_elo") or away_elo)
away_form_elo_val = float(away_row.get("form_elo") or away_elo)
# Enrichment queries # Enrichment queries
home_stats = enr.compute_team_stats(cur, data.home_team_id, data.match_date_ms) home_stats = enr.compute_team_stats(cur, data.home_team_id, data.match_date_ms)
@@ -390,6 +439,8 @@ class SingleMatchOrchestrator:
before_ts=data.match_date_ms, before_ts=data.match_date_ms,
referee_name=data.referee_name, referee_name=data.referee_name,
) )
setattr(data, "odds_band_features", odds_band_features)
setattr(data, "feature_source", "football_ai_features" if elo_row else "live_prematch_enrichment")
except Exception: except Exception:
# Full fallback — use all defaults # Full fallback — use all defaults
home_stats = dict(enr._DEFAULT_TEAM_STATS) home_stats = dict(enr._DEFAULT_TEAM_STATS)
@@ -409,6 +460,8 @@ class SingleMatchOrchestrator:
home_rest = 7.0 home_rest = 7.0
away_rest = 7.0 away_rest = 7.0
odds_band_features = {} # V28 fallback odds_band_features = {} # V28 fallback
setattr(data, "odds_band_features", odds_band_features)
setattr(data, "feature_source", "fallback_defaults")
odds_presence = { odds_presence = {
'odds_ms_h_present': 1.0 if ms_h > 1.01 else 0.0, 'odds_ms_h_present': 1.0 if ms_h > 1.01 else 0.0,
@@ -1290,25 +1343,72 @@ class SingleMatchOrchestrator:
), ),
} }
# BTTS triple value # BTTS triple value — now with V27 BTTS model
btts_yes_odds = float((data.odds_data or {}).get("btts_y", 0)) 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_implied = (1.0 / btts_yes_odds) if btts_yes_odds > 1.0 else 0.50
btts_band_rate = odds_band_btts["yes_rate"] btts_band_rate = odds_band_btts['yes_rate']
# V27 BTTS model prediction (if available)
v27_btts = v27_preds.get('btts')
v27_btts_yes = (v27_btts or {}).get('yes', 0) if v27_btts else 0
if v27_btts_yes > 0:
btts_combined = (v27_btts_yes + btts_band_rate) / 2.0
else:
btts_combined = btts_band_rate btts_combined = btts_band_rate
btts_edge = btts_combined - btts_implied btts_edge = btts_combined - btts_implied
btts_band_confirms = btts_band_rate > btts_implied btts_band_confirms = btts_band_rate > btts_implied
btts_v27_confirms = v27_btts_yes > btts_implied if v27_btts_yes > 0 else False
btts_conf_count = sum([btts_v27_confirms, btts_band_confirms])
triple_value["btts_yes"] = { # BTTS divergence (V25 vs V27)
"band_rate": round(btts_band_rate, 4), v25_btts_probs = {
"implied_prob": round(btts_implied, 4), 'no': 1.0 - prediction.btts_yes_prob,
"combined_prob": round(btts_combined, 4), 'yes': prediction.btts_yes_prob,
"edge": round(btts_edge, 4), }
"band_sample": odds_band_btts["sample"], btts_divergence = compute_divergence(v25_btts_probs, v27_btts) if v27_btts else {}
"confirmations": 1 if btts_band_confirms else 0, btts_odds = {
"is_value": ( 'yes': float((data.odds_data or {}).get('btts_y', 0)),
'no': float((data.odds_data or {}).get('btts_n', 0)),
}
btts_value_edge = compute_value_edge(
v25_btts_probs, v27_btts, btts_odds,
) if v27_btts else {}
# DC divergence (derived from V27 MS probs)
v27_dc = v27_preds.get('dc')
dc_divergence = {}
dc_value_edge = {}
if v27_dc:
v25_dc_probs = {
'1x': prediction.ms_home_prob + prediction.ms_draw_prob,
'x2': prediction.ms_draw_prob + prediction.ms_away_prob,
'12': prediction.ms_home_prob + prediction.ms_away_prob,
}
dc_divergence = compute_divergence(v25_dc_probs, v27_dc)
dc_odds = {
'1x': float((data.odds_data or {}).get('dc_1x', 0)),
'x2': float((data.odds_data or {}).get('dc_x2', 0)),
'12': float((data.odds_data or {}).get('dc_12', 0)),
}
dc_value_edge = compute_value_edge(v25_dc_probs, v27_dc, dc_odds)
triple_value['btts_yes'] = {
'v27_prob': round(v27_btts_yes, 4),
'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': btts_conf_count,
'is_value': (
btts_conf_count >= 2
and btts_edge > 0.05
and odds_band_btts['sample'] >= 8
) if v27_btts_yes > 0 else (
btts_band_confirms btts_band_confirms
and btts_edge > 0.05 and btts_edge > 0.05
and odds_band_btts["sample"] >= 8 and odds_band_btts['sample'] >= 8
), ),
} }
@@ -1366,14 +1466,20 @@ class SingleMatchOrchestrator:
"predictions": { "predictions": {
"ms": v27_ms or {}, "ms": v27_ms or {},
"ou25": v27_ou25 or {}, "ou25": v27_ou25 or {},
"btts": v27_btts or {},
"dc": v27_dc or {},
}, },
"divergence": { "divergence": {
"ms": ms_divergence, "ms": ms_divergence,
"ou25": ou25_divergence, "ou25": ou25_divergence,
"btts": btts_divergence,
"dc": dc_divergence,
}, },
"value_edge": { "value_edge": {
"ms": ms_value, "ms": ms_value,
"ou25": ou25_value, "ou25": ou25_value,
"btts": btts_value_edge,
"dc": dc_value_edge,
}, },
"odds_band": { "odds_band": {
"ms_home": odds_band_ms_home, "ms_home": odds_band_ms_home,
@@ -2670,6 +2776,13 @@ class SingleMatchOrchestrator:
# Hard gate: predictions with unknown teams are noisy and misleading. # Hard gate: predictions with unknown teams are noisy and misleading.
return None return None
status, state, substate = self._normalize_match_status(
row.get("status"),
row.get("state"),
row.get("substate"),
row.get("score_home"),
row.get("score_away"),
)
odds_data = self._extract_odds(cur, row) odds_data = self._extract_odds(cur, row)
home_lineup, away_lineup, lineup_source, lineup_confidence = self._extract_lineups(cur, row) home_lineup, away_lineup, lineup_source, lineup_confidence = self._extract_lineups(cur, row)
sidelined = self._parse_json_dict(row.get("sidelined")) sidelined = self._parse_json_dict(row.get("sidelined"))
@@ -2723,10 +2836,11 @@ class SingleMatchOrchestrator:
home_position=home_position, home_position=home_position,
away_position=away_position, away_position=away_position,
lineup_source=lineup_source, lineup_source=lineup_source,
status=str(row.get("status") or ""), status=status,
state=row.get("state"), state=state,
substate=row.get("substate"), substate=substate,
lineup_confidence=lineup_confidence, lineup_confidence=lineup_confidence,
source_table=str(row.get("source_table") or "matches"),
current_score_home=( current_score_home=(
int(row.get("score_home")) int(row.get("score_home"))
if row.get("score_home") is not None if row.get("score_home") is not None
@@ -2760,7 +2874,8 @@ class SingleMatchOrchestrator:
lm.referee_name, lm.referee_name,
ht.name as home_team_name, ht.name as home_team_name,
at.name as away_team_name, at.name as away_team_name,
l.name as league_name l.name as league_name,
'live_matches'::text as source_table
FROM live_matches lm FROM live_matches lm
LEFT JOIN teams ht ON ht.id = lm.home_team_id LEFT JOIN teams ht ON ht.id = lm.home_team_id
LEFT JOIN teams at ON at.id = lm.away_team_id LEFT JOIN teams at ON at.id = lm.away_team_id
@@ -2772,6 +2887,37 @@ class SingleMatchOrchestrator:
) )
return cur.fetchone() return cur.fetchone()
@staticmethod
def _normalize_match_status(
status: Any,
state: Any,
substate: Any,
score_home: Any,
score_away: Any,
) -> Tuple[str, Optional[str], Optional[str]]:
state_text = str(state or "").strip()
status_text = str(status or "").strip()
substate_text = str(substate or "").strip()
state_key = state_text.lower().replace("_", "").replace(" ", "")
status_key = status_text.lower().replace("_", "").replace(" ", "")
substate_key = substate_text.lower().replace("_", "").replace(" ", "")
live_tokens = {"live", "livegame", "firsthalf", "secondhalf", "halftime", "1h", "2h", "ht", "1q", "2q", "3q", "4q"}
finished_tokens = {"post", "postgame", "finished", "played", "ft", "ended", "aet", "pen", "penalties", "afterpenalties"}
pre_tokens = {"pre", "pregame", "scheduled", "ns", "notstarted", "timestamp"}
if state_key in live_tokens or status_key in live_tokens or substate_key in live_tokens:
return "LIVE", state_text or "live", substate_text or None
if state_key in finished_tokens or status_key in finished_tokens or substate_key in finished_tokens:
return "FT", state_text or "post", substate_text or None
if score_home is not None and score_away is not None and status_key not in pre_tokens:
return "FT", state_text or "post", substate_text or None
if state_key in pre_tokens or status_key in pre_tokens or substate_key in pre_tokens:
return "NS", state_text or "pre", substate_text or None
return status_text or "NS", state_text or None, substate_text or None
def _fetch_hist_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]: def _fetch_hist_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]:
cur.execute( cur.execute(
""" """
@@ -2793,7 +2939,8 @@ class SingleMatchOrchestrator:
ref.name as referee_name, ref.name as referee_name,
ht.name as home_team_name, ht.name as home_team_name,
at.name as away_team_name, at.name as away_team_name,
l.name as league_name l.name as league_name,
'matches'::text as source_table
FROM matches m FROM matches m
LEFT JOIN teams ht ON ht.id = m.home_team_id LEFT JOIN teams ht ON ht.id = m.home_team_id
LEFT JOIN teams at ON at.id = m.away_team_id LEFT JOIN teams at ON at.id = m.away_team_id
@@ -3668,66 +3815,33 @@ class SingleMatchOrchestrator:
playable_rows = [row for row in market_rows if row.get("playable")] playable_rows = [row for row in market_rows if row.get("playable")]
# 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 >= 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_ODDS = 1.30
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"}
# Priority 1: High-accuracy markets with good odds and confidence
high_accuracy_picks = [
row for row in playable_rows
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
]
if high_accuracy_picks:
# Sort by play_score, pick the best
high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = high_accuracy_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "high_accuracy_market"
else:
# Priority 2: Any playable with odds >= 1.30 and confidence >= 40%
guaranteed_picks = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
if guaranteed_picks:
guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = guaranteed_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "confidence_threshold_met"
else:
# Priority 3: Fallback - playable with odds >= 1.30
playable_with_odds = [ playable_with_odds = [
row for row in playable_rows row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS if float(row.get("odds", 0.0)) >= MIN_ODDS
] ]
if playable_with_odds: if playable_with_odds:
playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) 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 = playable_with_odds[0]
main_pick["is_guaranteed"] = False main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "odds_only_fallback" main_pick["pick_reason"] = "positive_ev_after_odds_band_gate"
else: else:
# Priority 4: Last resort - any playable or first market WITH ODDS > 0
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0] fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)) 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: if main_pick:
main_pick["is_guaranteed"] = False main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "last_resort" main_pick["playable"] = False
main_pick["stake_units"] = 0.0
main_pick["bet_grade"] = "PASS"
main_pick["pick_reason"] = "no_playable_value_after_odds_band_gate"
aggressive_pick = None aggressive_pick = None
htft_probs = prediction.ht_ft_probs or {} htft_probs = prediction.ht_ft_probs or {}
@@ -3756,11 +3870,13 @@ class SingleMatchOrchestrator:
value_candidates = [ value_candidates = [
row for row in playable_rows row for row in playable_rows
if float(row.get("odds", 0.0)) >= 1.60 if float(row.get("odds", 0.0)) >= 1.60
and float(row.get("calibrated_confidence", 0.0)) >= 40.0 # V34: Lowered min calibrated_confidence for value candidates from 40.0 to 25.0
# to allow high-odds value bets (which naturally have lower probabilities).
and float(row.get("calibrated_confidence", 0.0)) >= 25.0
] ]
if value_candidates: if value_candidates:
# Score them by (play_score * odds) to reward higher odds # Score them by (ev_edge) to reward actual mathematical value
value_candidates.sort(key=lambda r: float(r.get("play_score", 0.0)) * float(r.get("odds", 1.0)), reverse=True) value_candidates.sort(key=lambda r: float(r.get("ev_edge", 0.0)), reverse=True)
for v_cand in value_candidates: for v_cand in value_candidates:
if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]): if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]):
value_pick = v_cand value_pick = v_cand
@@ -3982,51 +4098,33 @@ class SingleMatchOrchestrator:
playable_rows = [row for row in market_rows if row.get("playable")] playable_rows = [row for row in market_rows if row.get("playable")]
# GUARANTEED PICK LOGIC (Optimized - same as football)
MIN_ODDS = 1.30 MIN_ODDS = 1.30
MIN_CONFIDENCE = 40.0
HIGH_ACCURACY_MARKETS = {"ML", "TOT", "SPREAD"}
high_accuracy_picks = [
row for row in playable_rows
if row.get("market_type") in HIGH_ACCURACY_MARKETS
and float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
if high_accuracy_picks:
high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = high_accuracy_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "high_accuracy_market"
else:
guaranteed_picks = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
if guaranteed_picks:
guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = guaranteed_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "confidence_threshold_met"
else:
playable_with_odds = [ playable_with_odds = [
row for row in playable_rows row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS if float(row.get("odds", 0.0)) >= MIN_ODDS
] ]
if playable_with_odds: if playable_with_odds:
playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) 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 = playable_with_odds[0]
main_pick["is_guaranteed"] = False main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "odds_only_fallback" main_pick["pick_reason"] = "positive_ev_pick"
else: else:
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0] fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)) 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: if main_pick:
main_pick["is_guaranteed"] = False main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "last_resort" 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]] = [] supporting: List[Dict[str, Any]] = []
for row in market_rows: for row in market_rows:
@@ -4518,6 +4616,121 @@ class SingleMatchOrchestrator:
return True return True
return self._v25_market_odds(odds, market, pick) > 1.01 return self._v25_market_odds(odds, market, pick) > 1.01
def _odds_band_verdict(
self,
data: MatchData,
market: str,
pick: str,
implied_prob: float,
) -> Dict[str, Any]:
features = getattr(data, "odds_band_features", {}) or {}
market_key = str(market or "").upper()
if not isinstance(features, dict) or implied_prob <= 0.0:
return {
"required": market_key in self.odds_band_min_sample,
"available": False,
"band_prob": 0.0,
"band_sample": 0.0,
"band_edge": 0.0,
"aligned": False,
"reason": "odds_band_unavailable",
}
pick_key = self._normalize_pick_token(pick)
band_prob = 0.0
sample = 0.0
if market_key == "MS":
if pick_key == "1":
band_prob = float(features.get("home_band_ms_win_rate", 0.0) or 0.0)
sample = float(features.get("home_band_ms_sample", 0.0) or 0.0)
elif pick_key == "2":
band_prob = float(features.get("away_band_ms_win_rate", 0.0) or 0.0)
sample = float(features.get("away_band_ms_sample", 0.0) or 0.0)
elif pick_key in {"X", "0"}:
home_draw = float(features.get("home_band_ms_draw_rate", 0.0) or 0.0)
away_draw = float(features.get("away_band_ms_draw_rate", 0.0) or 0.0)
band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
sample = max(
float(features.get("home_band_ms_sample", 0.0) or 0.0),
float(features.get("away_band_ms_sample", 0.0) or 0.0),
)
elif market_key == "DC":
dc_key = pick_key.replace("-", "").lower()
band_prob = float(features.get(f"band_dc_{dc_key}_rate", 0.0) or 0.0)
sample = float(features.get(f"band_dc_{dc_key}_sample", 0.0) or 0.0)
elif market_key in {"OU15", "OU25", "OU35"}:
suffix = {"OU15": "ou15", "OU25": "ou25", "OU35": "ou35"}[market_key]
rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)
elif market_key == "BTTS":
is_yes = "VAR" in pick_key or "YES" in pick_key or pick_key == "Y"
band_prob = float(features.get(f"band_btts_{'yes' if is_yes else 'no'}_rate", 0.0) or 0.0)
sample = float(features.get("band_btts_sample", 0.0) or 0.0)
elif market_key == "HT":
if pick_key == "1":
band_prob = float(features.get("home_band_ht_win_rate", 0.0) or 0.0)
sample = float(features.get("home_band_ht_sample", 0.0) or 0.0)
elif pick_key == "2":
band_prob = float(features.get("away_band_ht_win_rate", 0.0) or 0.0)
sample = float(features.get("away_band_ht_sample", 0.0) or 0.0)
elif pick_key in {"X", "0"}:
home_draw = float(features.get("home_band_ht_draw_rate", 0.0) or 0.0)
away_draw = float(features.get("away_band_ht_draw_rate", 0.0) or 0.0)
band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
sample = max(
float(features.get("home_band_ht_sample", 0.0) or 0.0),
float(features.get("away_band_ht_sample", 0.0) or 0.0),
)
elif market_key in {"HT_OU05", "HT_OU15"}:
suffix = "ht_ou05" if market_key == "HT_OU05" else "ht_ou15"
rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)
band_edge = band_prob - implied_prob if band_prob > 0.0 else 0.0
required_sample = float(self.odds_band_min_sample.get(market_key, 0.0))
required_edge = float(self.odds_band_min_edge.get(market_key, 0.0))
available = band_prob > 0.0 and sample >= required_sample
aligned = available and band_edge >= required_edge
reason = "odds_band_confirms_value"
if required_sample > 0.0 and sample < required_sample:
reason = "odds_band_sample_too_low"
elif band_prob <= 0.0:
reason = "odds_band_missing_probability"
elif band_edge < required_edge:
reason = f"odds_band_no_value_{band_edge:+.3f}"
return {
"required": market_key in self.odds_band_min_sample,
"available": available,
"band_prob": band_prob,
"band_sample": sample,
"band_edge": band_edge,
"aligned": aligned,
"reason": reason,
}
@staticmethod
def _normalize_pick_token(pick: str) -> str:
return (
str(pick or "")
.strip()
.upper()
.replace("İ", "I")
.replace("Ü", "U")
.replace("Ş", "S")
.replace("Ğ", "G")
.replace("Ö", "O")
.replace("Ç", "C")
)
@staticmethod
def _pick_is_over(pick_key: str) -> bool:
return "UST" in pick_key or "OVER" in pick_key
@staticmethod @staticmethod
def _goal_line_for_market(market: str) -> Optional[float]: def _goal_line_for_market(market: str) -> Optional[float]:
return { return {
@@ -4968,12 +5181,8 @@ class SingleMatchOrchestrator:
calibrated_conf = max(1.0, min(99.0, raw_conf * calibration)) calibrated_conf = max(1.0, min(99.0, raw_conf * calibration))
min_conf = self.market_min_conf.get(market, 55.0) min_conf = self.market_min_conf.get(market, 55.0)
# ── V2 Quant: EV Edge formula ──────────────────────────────────
# Old: edge = prob - (1/odd) ← simple probability difference
# New: edge = (prob × odd) - 1 ← Expected Value (what a quant uses)
implied_prob = (1.0 / odd) if odd > 1.0 else 0.0 implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
ev_edge = (prob * odd) - 1.0 if odd > 1.0 else 0.0 band_verdict = self._odds_band_verdict(data, market, str(row.get("pick") or ""), implied_prob)
simple_edge = prob - implied_prob if implied_prob > 0 else 0.0
# ── V31: League-specific odds reliability ────────────────────── # ── V31: League-specific odds reliability ──────────────────────
# Higher reliability → trust odds-based edge more in play_score # Higher reliability → trust odds-based edge more in play_score
@@ -4995,6 +5204,25 @@ class SingleMatchOrchestrator:
quality_label, quality_label,
5.0, 5.0,
) )
# V33: Removed probability deflation. Deflating probability breaks normalization
# (probs no longer sum to 1) and mathematically guarantees negative EV edge.
# Data quality and confidence penalties are already applied to play_score.
model_calibrated_prob = prob
band_prob = float(band_verdict.get("band_prob", 0.0) or 0.0)
if bool(band_verdict.get("available")):
calibrated_probability = (
(model_calibrated_prob * 0.45)
+ (band_prob * 0.35)
+ (implied_prob * 0.20)
)
elif implied_prob > 0.0:
calibrated_probability = (model_calibrated_prob * 0.65) + (implied_prob * 0.35)
else:
calibrated_probability = model_calibrated_prob
calibrated_probability = max(0.0, min(0.99, calibrated_probability))
model_edge = model_calibrated_prob - implied_prob if implied_prob > 0 else 0.0
ev_edge = (calibrated_probability * odd) - 1.0 if odd > 1.0 else 0.0
simple_edge = calibrated_probability - implied_prob if implied_prob > 0 else 0.0
home_n = len(data.home_lineup or []) home_n = len(data.home_lineup or [])
away_n = len(data.away_lineup or []) away_n = len(data.away_lineup or [])
@@ -5005,20 +5233,16 @@ class SingleMatchOrchestrator:
lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0))) lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
lineup_penalty += max(1.0, (1.0 - lineup_conf) * 5.0) lineup_penalty += max(1.0, (1.0 - lineup_conf) * 5.0)
# V31: edge contribution weighted by league odds reliability
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier)
play_score = max(
0.0,
min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
)
# ── V20+ Safety gates (PRESERVED) ───────────────────────────── # ── V20+ Safety gates (PRESERVED) ─────────────────────────────
min_play_score = self.market_min_play_score.get(market, 68.0) min_play_score = self.market_min_play_score.get(market, 68.0)
min_edge = self.market_min_edge.get(market, 0.02) min_edge = self.market_min_edge.get(market, 0.02)
reasons: List[str] = [] reasons: List[str] = []
playable = True playable = True
is_value_sniper = ev_edge >= 0.03
if calibrated_conf < min_conf: if calibrated_conf < min_conf:
if not is_value_sniper:
playable = False playable = False
reasons.append("below_calibrated_conf_threshold") reasons.append("below_calibrated_conf_threshold")
if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01: if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01:
@@ -5037,16 +5261,31 @@ class SingleMatchOrchestrator:
# Most pre-match predictions use probable_xi — blocking kills all output # Most pre-match predictions use probable_xi — blocking kills all output
lineup_penalty += 6.0 lineup_penalty += 6.0
reasons.append("lineup_probable_xi_penalty") reasons.append("lineup_probable_xi_penalty")
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier)
play_score = max(
0.0,
min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
)
if bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")):
if not is_value_sniper:
playable = False
reasons.append(str(band_verdict.get("reason") or "odds_band_not_aligned"))
if bool(band_verdict.get("required")) and implied_prob > 0.0 and model_edge <= 0.0:
if not is_value_sniper:
playable = False
reasons.append(f"model_not_above_market_{model_edge:+.3f}")
# V31: negative edge threshold adapts to league reliability # V31: negative edge threshold adapts to league reliability
# Reliable league: stricter (-0.03), unreliable: looser (-0.08) # Reliable league: stricter (-0.03), unreliable: looser (-0.08)
neg_edge_threshold = -0.03 - (1.0 - odds_rel) * 0.05 neg_edge_threshold = -0.03 - (1.0 - odds_rel) * 0.05
if odd > 1.0 and simple_edge < neg_edge_threshold: if odd > 1.0 and simple_edge < neg_edge_threshold:
if not is_value_sniper:
playable = False playable = False
reasons.append(f"negative_model_edge_{simple_edge:+.3f}") reasons.append(f"negative_model_edge_{simple_edge:+.3f}")
if odd > 1.0 and ev_edge < min_edge: if odd > 1.0 and ev_edge < min_edge:
playable = False playable = False
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}") reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
if play_score < min_play_score: if play_score < min_play_score:
if not is_value_sniper:
playable = False playable = False
reasons.append("insufficient_play_score") reasons.append("insufficient_play_score")
@@ -5068,15 +5307,15 @@ class SingleMatchOrchestrator:
elif ev_edge > 0.10: elif ev_edge > 0.10:
grade = "A" grade = "A"
# V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll) # V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll)
stake_units = self._kelly_stake(prob, odd) stake_units = self._kelly_stake(calibrated_probability, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A") reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A")
elif ev_edge > 0.05: elif ev_edge > 0.05:
grade = "B" grade = "B"
stake_units = self._kelly_stake(prob, odd) stake_units = self._kelly_stake(calibrated_probability, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B") reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B")
elif ev_edge > 0.02: elif ev_edge > 0.02:
grade = "C" grade = "C"
stake_units = self._kelly_stake(prob, odd) stake_units = self._kelly_stake(calibrated_probability, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C") reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C")
else: else:
# Passes all V20+ gates but no mathematical edge over bookie # Passes all V20+ gates but no mathematical edge over bookie
@@ -5093,8 +5332,16 @@ class SingleMatchOrchestrator:
"min_required_play_score": round(min_play_score, 1), "min_required_play_score": round(min_play_score, 1),
"min_required_edge": round(min_edge, 4), "min_required_edge": round(min_edge, 4),
"edge": round(ev_edge, 4), "edge": round(ev_edge, 4),
"model_probability": round(prob, 4),
"model_edge": round(model_edge, 4),
"calibrated_probability": round(calibrated_probability, 4),
"implied_prob": round(implied_prob, 4), "implied_prob": round(implied_prob, 4),
"ev_edge": round(ev_edge, 4), "ev_edge": round(ev_edge, 4),
"is_value_sniper": is_value_sniper,
"odds_band_probability": round(float(band_verdict.get("band_prob", 0.0) or 0.0), 4),
"odds_band_sample": round(float(band_verdict.get("band_sample", 0.0) or 0.0), 1),
"odds_band_edge": round(float(band_verdict.get("band_edge", 0.0) or 0.0), 4),
"odds_band_aligned": bool(band_verdict.get("aligned")),
"odds_reliability": round(odds_rel, 4), "odds_reliability": round(odds_rel, 4),
"play_score": round(play_score, 1), "play_score": round(play_score, 1),
"playable": playable, "playable": playable,
@@ -5145,7 +5392,15 @@ class SingleMatchOrchestrator:
"stake_units": float(row.get("stake_units", 0.0)), "stake_units": float(row.get("stake_units", 0.0)),
"play_score": row.get("play_score", 0.0), "play_score": row.get("play_score", 0.0),
"ev_edge": row.get("ev_edge", row.get("edge", 0.0)), "ev_edge": row.get("ev_edge", row.get("edge", 0.0)),
"is_value_sniper": bool(row.get("is_value_sniper")),
"model_probability": row.get("model_probability", row.get("probability", 0.0)),
"model_edge": row.get("model_edge", 0.0),
"calibrated_probability": row.get("calibrated_probability", row.get("probability", 0.0)),
"implied_prob": row.get("implied_prob", 0.0), "implied_prob": row.get("implied_prob", 0.0),
"odds_band_probability": row.get("odds_band_probability", 0.0),
"odds_band_sample": row.get("odds_band_sample", 0.0),
"odds_band_edge": row.get("odds_band_edge", 0.0),
"odds_band_aligned": bool(row.get("odds_band_aligned")),
"odds_reliability": row.get("odds_reliability", 0.35), "odds_reliability": row.get("odds_reliability", 0.35),
"odds": row.get("odds", 0.0), "odds": row.get("odds", 0.0),
"reasons": row.get("decision_reasons", []), "reasons": row.get("decision_reasons", []),
@@ -5187,6 +5442,11 @@ class SingleMatchOrchestrator:
ref_score = 1.0 if data.referee_name else 0.6 ref_score = 1.0 if data.referee_name else 0.6
if not data.referee_name: if not data.referee_name:
flags.append("missing_referee") flags.append("missing_referee")
if data.source_table == "live_matches":
flags.append("live_match_pre_match_features")
feature_source = str(getattr(data, "feature_source", "") or "")
if feature_source == "live_prematch_enrichment":
flags.append("ai_features_inferred_from_history")
total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10) total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10)
@@ -5196,6 +5456,10 @@ class SingleMatchOrchestrator:
label = "MEDIUM" label = "MEDIUM"
else: else:
label = "LOW" label = "LOW"
if label == "HIGH" and (
data.lineup_source == "probable_xi" or not data.referee_name
):
label = "MEDIUM"
return { return {
"label": label, "label": label,
@@ -5204,6 +5468,7 @@ class SingleMatchOrchestrator:
"away_lineup_count": away_n, "away_lineup_count": away_n,
"lineup_source": data.lineup_source, "lineup_source": data.lineup_source,
"lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3), "lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3),
"feature_source": feature_source or "unknown",
"flags": flags, "flags": flags,
} }
-2
View File
@@ -81,7 +81,6 @@ export const LIVE_STATUS_VALUES_FOR_DB = [
"Playing", "Playing",
"Half Time", "Half Time",
"liveGame", "liveGame",
"minutes",
]; ];
export const LIVE_STATE_VALUES_FOR_DB = [ export const LIVE_STATE_VALUES_FOR_DB = [
@@ -110,7 +109,6 @@ export const FINISHED_STATUS_VALUES_FOR_DB = [
"postGame", "postGame",
"posted", "posted",
"Posted", "Posted",
"state",
]; ];
export const FINISHED_STATE_VALUES_FOR_DB = [ export const FINISHED_STATE_VALUES_FOR_DB = [
+45
View File
@@ -148,6 +148,27 @@ export class MatchPickDto {
@ApiProperty({ required: false, default: 0 }) @ApiProperty({ required: false, default: 0 })
implied_prob?: number; implied_prob?: number;
@ApiProperty({ required: false, default: 0 })
model_probability?: number;
@ApiProperty({ required: false, default: 0 })
model_edge?: number;
@ApiProperty({ required: false, default: 0 })
calibrated_probability?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_probability?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_sample?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_edge?: number;
@ApiProperty({ required: false, default: false })
odds_band_aligned?: boolean;
@ApiProperty() @ApiProperty()
play_score: number; play_score: number;
@@ -171,6 +192,9 @@ export class MatchPickDto {
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"], enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
}) })
signal_tier?: SignalTier; signal_tier?: SignalTier;
@ApiProperty({ required: false, default: false })
is_guaranteed?: boolean;
} }
export class MatchBetAdviceDto { export class MatchBetAdviceDto {
@@ -227,6 +251,27 @@ export class MatchBetSummaryItemDto {
@ApiProperty({ required: false, default: 0 }) @ApiProperty({ required: false, default: 0 })
implied_prob?: number; implied_prob?: number;
@ApiProperty({ required: false, default: 0 })
model_probability?: number;
@ApiProperty({ required: false, default: 0 })
model_edge?: number;
@ApiProperty({ required: false, default: 0 })
calibrated_probability?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_probability?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_sample?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_edge?: number;
@ApiProperty({ required: false, default: false })
odds_band_aligned?: boolean;
@ApiProperty({ required: false, default: 0 }) @ApiProperty({ required: false, default: 0 })
odds?: number; odds?: number;
+86 -7
View File
@@ -60,7 +60,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
confidence_interval_too_wide_for_main_pick: confidence_interval_too_wide_for_main_pick:
"Ana seçim için güven aralığı çok geniş", "Ana seçim için güven aralığı çok geniş",
confidence_band_low: "Güven bandı düşük", confidence_band_low: "Güven bandı düşük",
playable_edge_found: "Oynanabilir avantaj bulundu", playable_edge_found: "Model avantaj sinyali bulundu",
market_signal_dominant: "Piyasa sinyali baskın", market_signal_dominant: "Piyasa sinyali baskın",
team_form_signal_dominant: "Takım formuna dayalı sinyaller çok baskın", team_form_signal_dominant: "Takım formuna dayalı sinyaller çok baskın",
lineup_signal_strong: "İlk on bir sinyali güçlü", lineup_signal_strong: "İlk on bir sinyali güçlü",
@@ -77,7 +77,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı", limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı",
data_quality_issue: "Veri kalitesi sorunu var", data_quality_issue: "Veri kalitesi sorunu var",
high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük", high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük",
insufficient_play_score: "Oynanabilirlik puanı yetersiz", insufficient_play_score: "Model sinyali yetersiz",
odds_band_confirms_value: "Tarihsel oran bandı değeri doğruluyor",
odds_band_sample_too_low: "Tarihsel oran bandı örneklemi yetersiz",
odds_band_missing_probability: "Tarihsel oran bandı olasılığı yok",
odds_band_unavailable: "Tarihsel oran bandı kullanılamıyor",
odds_band_not_aligned: "Model ve tarihsel oran bandı aynı yönde değil",
no_bet_conditions_met: "Bahis koşulları oluşmadı", no_bet_conditions_met: "Bahis koşulları oluşmadı",
market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti", market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti",
no_ev_edge_minimum_stake: no_ev_edge_minimum_stake:
@@ -129,10 +134,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private readonly feederService: FeederService, private readonly feederService: FeederService,
@Optional() private readonly predictionsQueue?: PredictionsQueue, @Optional() private readonly predictionsQueue?: PredictionsQueue,
) { ) {
this.aiEngineUrl = this.configService.get( this.aiEngineUrl = this.resolveAiEngineUrl();
"AI_ENGINE_URL",
"http://localhost:8000",
);
this.aiEngineClient = new AiEngineClient({ this.aiEngineClient = new AiEngineClient({
baseUrl: this.aiEngineUrl, baseUrl: this.aiEngineUrl,
logger: this.logger, logger: this.logger,
@@ -421,6 +423,59 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
} }
} }
private resolveAiEngineUrl(): string {
const configuredUrl = this.configService.get(
"AI_ENGINE_URL",
"http://localhost:8000",
);
const localEnvUrl = this.readLocalEnvValue("AI_ENGINE_URL");
if (
process.env.NODE_ENV !== "production" &&
localEnvUrl &&
localEnvUrl !== configuredUrl &&
this.isLocalhostUrl(configuredUrl) &&
this.isLocalhostUrl(localEnvUrl)
) {
this.logger.warn(
`AI_ENGINE_URL inherited from parent process (${configuredUrl}) differs from .env.local (${localEnvUrl}); using .env.local for local development`,
);
return localEnvUrl;
}
return configuredUrl;
}
private readLocalEnvValue(key: string): string | null {
const filePath = path.join(process.cwd(), ".env.local");
if (!fs.existsSync(filePath)) {
return null;
}
const line = fs
.readFileSync(filePath, "utf8")
.split(/\r?\n/u)
.find((entry) => entry.trim().startsWith(`${key}=`));
if (!line) {
return null;
}
return line
.slice(line.indexOf("=") + 1)
.trim()
.replace(/^['"]|['"]$/gu, "");
}
private isLocalhostUrl(value: string): boolean {
try {
const url = new URL(value);
return ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
} catch {
return false;
}
}
private async getMatchContext(matchId: string): Promise<MatchContext> { private async getMatchContext(matchId: string): Promise<MatchContext> {
const match = await this.prisma.match.findUnique({ const match = await this.prisma.match.findUnique({
where: { id: matchId }, where: { id: matchId },
@@ -705,6 +760,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
), ),
confidence_interval: interval, confidence_interval: interval,
signal_tier: this.classifySignalTier(record, interval), signal_tier: this.classifySignalTier(record, interval),
is_guaranteed: false,
}; };
} }
@@ -793,7 +849,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/); const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
if (evMatch) { if (evMatch) {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`; return `Teorik avantaj sinyali: Not ${evMatch[2]}`;
} }
const negativeEdgeMatch = normalized.match( const negativeEdgeMatch = normalized.match(
@@ -803,6 +859,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`; return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
} }
const bandNoValueMatch = normalized.match(
/^odds_band_no_value_([-+]?[\d.]+)$/,
);
if (bandNoValueMatch) {
return `Tarihsel oran bandı değeri doğrulamadı (${bandNoValueMatch[1]})`;
}
const edgeThresholdMatch = normalized.match( const edgeThresholdMatch = normalized.match(
/^below_market_edge_threshold_([-+]?[\d.]+)$/, /^below_market_edge_threshold_([-+]?[\d.]+)$/,
); );
@@ -1514,8 +1577,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
pick: item.pick, pick: item.pick,
playable: item.playable, playable: item.playable,
bet_grade: item.bet_grade, bet_grade: item.bet_grade,
odds: item.odds,
model_edge: item.model_edge,
calibrated_probability: item.calibrated_probability,
calibrated_confidence: item.calibrated_confidence, calibrated_confidence: item.calibrated_confidence,
ev_edge: item.ev_edge ?? 0, ev_edge: item.ev_edge ?? 0,
odds_band_probability: item.odds_band_probability,
odds_band_sample: item.odds_band_sample,
odds_band_edge: item.odds_band_edge,
odds_band_aligned: item.odds_band_aligned,
stake_units: item.stake_units, stake_units: item.stake_units,
})) }))
: []; : [];
@@ -1531,8 +1601,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
pick: payload.main_pick.pick, pick: payload.main_pick.pick,
playable: payload.main_pick.playable, playable: payload.main_pick.playable,
bet_grade: payload.main_pick.bet_grade, bet_grade: payload.main_pick.bet_grade,
odds: payload.main_pick.odds,
model_edge: payload.main_pick.model_edge,
calibrated_probability: payload.main_pick.calibrated_probability,
calibrated_confidence: payload.main_pick.calibrated_confidence, calibrated_confidence: payload.main_pick.calibrated_confidence,
ev_edge: payload.main_pick.ev_edge ?? 0, ev_edge: payload.main_pick.ev_edge ?? 0,
odds_band_probability: payload.main_pick.odds_band_probability,
odds_band_sample: payload.main_pick.odds_band_sample,
odds_band_edge: payload.main_pick.odds_band_edge,
odds_band_aligned: payload.main_pick.odds_band_aligned,
stake_units: payload.main_pick.stake_units, stake_units: payload.main_pick.stake_units,
} }
: null, : null,
@@ -1542,6 +1619,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
pick: payload.value_pick.pick, pick: payload.value_pick.pick,
playable: payload.value_pick.playable, playable: payload.value_pick.playable,
bet_grade: payload.value_pick.bet_grade, bet_grade: payload.value_pick.bet_grade,
odds: payload.value_pick.odds,
model_edge: payload.value_pick.model_edge,
calibrated_confidence: payload.value_pick.calibrated_confidence, calibrated_confidence: payload.value_pick.calibrated_confidence,
ev_edge: payload.value_pick.ev_edge ?? 0, ev_edge: payload.value_pick.ev_edge ?? 0,
} }
+324 -6
View File
@@ -9,6 +9,7 @@ import * as path from "path";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { SidelinedResponse } from "../modules/feeder/feeder.types"; import { SidelinedResponse } from "../modules/feeder/feeder.types";
import { import {
deriveStoredMatchStatus,
FINISHED_STATE_VALUES_FOR_DB, FINISHED_STATE_VALUES_FOR_DB,
FINISHED_STATUS_VALUES_FOR_DB, FINISHED_STATUS_VALUES_FOR_DB,
LIVE_STATE_VALUES_FOR_DB, LIVE_STATE_VALUES_FOR_DB,
@@ -74,6 +75,17 @@ interface LiveLineupsJson {
away: { xi: unknown[]; subs: unknown[] }; away: { xi: unknown[]; subs: unknown[] };
} }
interface PendingPredictionRunForSettlement {
id: bigint;
matchId: string;
engineVersion: string;
payloadSummary: unknown;
scoreHome: number | null;
scoreAway: number | null;
htScoreHome: number | null;
htScoreAway: number | null;
}
type SportType = "football" | "basketball"; type SportType = "football" | "basketball";
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
@@ -187,6 +199,7 @@ export class DataFetcherTask {
await this.syncMatchList(today); await this.syncMatchList(today);
await this.syncMatchList(tomorrow); await this.syncMatchList(tomorrow);
await this.updateLiveScores(); await this.updateLiveScores();
await this.settlePredictionRuns();
await this.fetchOddsForMatches(); await this.fetchOddsForMatches();
await this.fillMissingLineups(); await this.fillMissingLineups();
@@ -263,13 +276,23 @@ export class DataFetcherTask {
if (response.data?.data) { if (response.data?.data) {
const matchData = response.data.data; const matchData = response.data.data;
const scoreHome = matchData.homeScore ?? null;
const scoreAway = matchData.awayScore ?? null;
const storedStatus = deriveStoredMatchStatus({
state: matchData.state,
status: matchData.status,
substate: matchData.substate,
scoreHome,
scoreAway,
});
await this.prisma.liveMatch.update({ await this.prisma.liveMatch.update({
where: { id: match.id }, where: { id: match.id },
data: { data: {
scoreHome: matchData.homeScore ?? null, scoreHome,
scoreAway: matchData.awayScore ?? null, scoreAway,
state: matchData.state || matchData.status, state: matchData.state || null,
status: matchData.status, substate: matchData.substate || null,
status: storedStatus,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
@@ -286,6 +309,292 @@ export class DataFetcherTask {
} }
} }
private async settlePredictionRuns(): Promise<void> {
try {
const rows = await this.prisma.$queryRawUnsafe<
PendingPredictionRunForSettlement[]
>(`
SELECT
pr.id,
pr.match_id AS "matchId",
pr.engine_version AS "engineVersion",
pr.payload_summary AS "payloadSummary",
m.score_home AS "scoreHome",
m.score_away AS "scoreAway",
m.ht_score_home AS "htScoreHome",
m.ht_score_away AS "htScoreAway"
FROM prediction_runs pr
JOIN matches m ON m.id = pr.match_id
WHERE pr.eventual_outcome IS NULL
AND m.sport = 'football'
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
ORDER BY pr.generated_at ASC
LIMIT 500
`);
if (rows.length === 0) return;
let settled = 0;
for (const row of rows) {
const result = this.resolvePredictionRunSettlement(row);
if (!result) continue;
const closingOddsSnapshot = await this.getClosingOddsSnapshot(row.matchId);
const settlementSummary = {
settled_at: new Date().toISOString(),
model_version: row.engineVersion,
outcome: result.outcome,
unit_profit: result.unitProfit,
final_score: {
home: row.scoreHome,
away: row.scoreAway,
},
halftime_score: {
home: row.htScoreHome,
away: row.htScoreAway,
},
closing_odds_snapshot: closingOddsSnapshot,
};
await this.prisma.$executeRawUnsafe(
`
UPDATE prediction_runs
SET eventual_outcome = $1,
unit_profit = $2,
payload_summary = payload_summary || jsonb_build_object('settlement', $3::jsonb)
WHERE id = $4
`,
result.outcome,
result.unitProfit,
JSON.stringify(settlementSummary),
row.id,
);
settled++;
}
if (settled > 0) {
this.logger.log(`Settled ${settled} prediction run(s)`);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Prediction run settlement skipped: ${message}`);
}
}
private async getClosingOddsSnapshot(
matchId: string,
): Promise<Record<string, unknown>> {
const liveMatch = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: {
odds: true,
oddsUpdatedAt: true,
status: true,
state: true,
scoreHome: true,
scoreAway: true,
},
});
if (liveMatch?.odds) {
return {
source: "live_match",
odds: liveMatch.odds,
odds_updated_at: liveMatch.oddsUpdatedAt?.toISOString() ?? null,
status: liveMatch.status ?? null,
state: liveMatch.state ?? null,
score_home: liveMatch.scoreHome,
score_away: liveMatch.scoreAway,
};
}
const categories = await this.prisma.oddCategory.findMany({
where: { matchId },
select: {
name: true,
selections: {
select: {
name: true,
oddValue: true,
position: true,
updatedAt: true,
},
orderBy: { position: "asc" },
take: 12,
},
},
orderBy: { name: "asc" },
take: 24,
});
return {
source: "odd_selections",
category_count: categories.length,
categories: categories.map((category) => ({
name: category.name,
selections: category.selections.map((selection) => ({
name: selection.name,
odd_value: selection.oddValue,
position: selection.position,
updated_at: selection.updatedAt?.toISOString() ?? null,
})),
})),
};
}
private resolvePredictionRunSettlement(
row: PendingPredictionRunForSettlement,
): { outcome: string; unitProfit: number } | null {
const summary = this.asRecord(row.payloadSummary);
const mainPick = this.asRecord(summary.main_pick);
const market = String(mainPick.market || "");
const pick = String(mainPick.pick || "");
const playable = mainPick.playable === true;
const odds = Number(mainPick.odds || 0);
if (!market || !pick || !playable || !Number.isFinite(odds) || odds <= 1.01) {
return { outcome: "NO_BET", unitProfit: 0 };
}
const won = this.isPredictionPickWon({
market,
pick,
scoreHome: row.scoreHome,
scoreAway: row.scoreAway,
htScoreHome: row.htScoreHome,
htScoreAway: row.htScoreAway,
});
if (won === null) return null;
return {
outcome: `${won ? "WON" : "LOST"}:${market}:${pick}`,
unitProfit: Number((won ? odds - 1 : -1).toFixed(4)),
};
}
private isPredictionPickWon(input: {
market: string;
pick: string;
scoreHome: number | null;
scoreAway: number | null;
htScoreHome: number | null;
htScoreAway: number | null;
}): boolean | null {
const market = input.market.toUpperCase();
const pick = this.normalizePick(input.pick);
const scoreHome = input.scoreHome;
const scoreAway = input.scoreAway;
if (scoreHome === null || scoreAway === null) return null;
if (market === "MS") {
if (pick === "1") return scoreHome > scoreAway;
if (pick === "X" || pick === "0") return scoreHome === scoreAway;
if (pick === "2") return scoreAway > scoreHome;
return null;
}
if (market === "DC") {
const normalized = pick.replace("-", "");
if (normalized === "1X") return scoreHome >= scoreAway;
if (normalized === "X2") return scoreAway >= scoreHome;
if (normalized === "12") return scoreHome !== scoreAway;
return null;
}
if (market === "BTTS") {
const bothScored = scoreHome > 0 && scoreAway > 0;
if (pick.includes("VAR") || pick.includes("YES") || pick === "Y") {
return bothScored;
}
if (pick.includes("YOK") || pick.includes("NO") || pick === "N") {
return !bothScored;
}
return null;
}
const goalLine = this.goalLineForMarket(market);
if (goalLine !== null) {
const total =
market.startsWith("HT_")
? this.nullableSum(input.htScoreHome, input.htScoreAway)
: scoreHome + scoreAway;
if (total === null) return null;
if (this.isOverPick(pick)) return total > goalLine;
return total < goalLine;
}
if (market === "HT") {
const htHome = input.htScoreHome;
const htAway = input.htScoreAway;
if (htHome === null || htAway === null) return null;
if (pick === "1") return htHome > htAway;
if (pick === "X" || pick === "0") return htHome === htAway;
if (pick === "2") return htAway > htHome;
}
if (market === "HTFT") {
const htHome = input.htScoreHome;
const htAway = input.htScoreAway;
if (htHome === null || htAway === null || !pick.includes("/")) return null;
const [htPick, ftPick] = pick.split("/");
return (
this.isResultPickWon(htPick, htHome, htAway) === true &&
this.isResultPickWon(ftPick, scoreHome, scoreAway) === true
);
}
return null;
}
private isResultPickWon(
pick: string,
homeScore: number,
awayScore: number,
): boolean | null {
if (pick === "1") return homeScore > awayScore;
if (pick === "X" || pick === "0") return homeScore === awayScore;
if (pick === "2") return awayScore > homeScore;
return null;
}
private goalLineForMarket(market: string): number | null {
if (market === "OU15") return 1.5;
if (market === "OU25") return 2.5;
if (market === "OU35") return 3.5;
if (market === "HT_OU05") return 0.5;
if (market === "HT_OU15") return 1.5;
return null;
}
private nullableSum(a: number | null, b: number | null): number | null {
if (a === null || b === null) return null;
return a + b;
}
private normalizePick(value: string): string {
return value
.trim()
.toUpperCase()
.replace(/İ/g, "I")
.replace(/Ü/g, "U")
.replace(/Ş/g, "S")
.replace(/Ğ/g, "G")
.replace(/Ö/g, "O")
.replace(/Ç/g, "C");
}
private isOverPick(pick: string): boolean {
return pick.includes("UST") || pick.includes("OVER");
}
private asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
// Phase 3: Odds + referee + lineups + sidelined // Phase 3: Odds + referee + lineups + sidelined
private async fetchOddsForMatches(): Promise<void> { private async fetchOddsForMatches(): Promise<void> {
@@ -705,6 +1014,15 @@ export class DataFetcherTask {
// Safe score parsing // Safe score parsing
const sHome = this.asInt(match.homeScore ?? match.score?.home); const sHome = this.asInt(match.homeScore ?? match.score?.home);
const sAway = this.asInt(match.awayScore ?? match.score?.away); const sAway = this.asInt(match.awayScore ?? match.score?.away);
const storedStatus = deriveStoredMatchStatus({
state: match.state,
status: match.status,
substate: match.substate,
statusBoxContent: match.statusBoxContent,
scoreHome: sHome,
scoreAway: sAway,
score: match.score,
});
// Handle postponed matches (ERT = Erteledendi) // Handle postponed matches (ERT = Erteledendi)
if (match.statusBoxContent === "ERT") { if (match.statusBoxContent === "ERT") {
@@ -733,7 +1051,7 @@ export class DataFetcherTask {
leagueId: leagueId, leagueId: leagueId,
state: match.state || null, state: match.state || null,
substate: match.substate || null, substate: match.substate || null,
status: match.status || match.state || "NS", status: storedStatus,
scoreHome: sHome, scoreHome: sHome,
scoreAway: sAway, scoreAway: sAway,
homeTeamId: homeTeamId, homeTeamId: homeTeamId,
@@ -748,7 +1066,7 @@ export class DataFetcherTask {
leagueId: leagueId, leagueId: leagueId,
state: match.state || null, state: match.state || null,
substate: match.substate || null, substate: match.substate || null,
status: match.status || match.state || "NS", status: storedStatus,
mstUtc: BigInt(match.mstUtc || Date.now()), mstUtc: BigInt(match.mstUtc || Date.now()),
scoreHome: sHome, scoreHome: sHome,
scoreAway: sAway, scoreAway: sAway,