diff --git a/ai-engine/data/national_leagues.json b/ai-engine/data/national_leagues.json new file mode 100644 index 0000000..410b3f5 --- /dev/null +++ b/ai-engine/data/national_leagues.json @@ -0,0 +1,31 @@ +{ + "_meta": { + "purpose": "A-milli erkek futbol ligleri. betting_brain milli-maç gate'i bu listeyle tetiklenir.", + "strategy": "Milli maçta SADECE MS, oran 4.0-7.0, Hazırlık+Eleme oynanabilir. Turnuva/diğer market analiz-only. Backtest: +17% ROI, kararlılık-test geçti (eski/yeni yarı +22/+24%).", + "source": "2300-maç milli backtest (multi_backtest_20260602) segment+grid+stability analizi", + "competition_type_rule": "lig adı: 'hazırlık'->HAZIRLIK | 'eleme'/'play-off'->ELEME | diğer->TURNUVA" + }, + "league_ids": [ + "cesdwwnxbc5fmajgroc0hqzy2", + "3aa4mumjl6zyetg6o9hwd5hhx", + "40yjcbx2sq6oq736iqqqczwt1", + "39q1hq42hxjfylxb7xpe9bvf9", + "cu0rmpyff5692eo06ltddjo8a", + "ax1yf4nlzqpcji4j8epdgx3zl", + "1gxlzw2ezkyeykhcaa5x8ozkk", + "gfskxsdituog2kqp9yiu7bzi", + "595nsvo7ykvoe690b1e4u5n56", + "68zplepppndhl8bfdvgy9vgu1", + "3a0j0giz3c3ajw9h59evv7lqt", + "emy1ibc8fu2l0fukh4vlu5xl5", + "2db0aw1duj2my9l5iey5gm6nq", + "cc5tzz23tryrfqbm2pbv0jill", + "8tddm56zbasf57jkkay4kbf11", + "2r1hqz453bn9ljzt53kdr2lwb", + "93i7thp7zi0ympyt6l8aa1r2i", + "45db8orh1qttbsqq9hqapmbit", + "ude9t6yj60lebbn356qzg4k4", + "9qzn8cs96sgtqmesa9gpfti23", + "ad8y7vdjhinfqv4wo8rod6dck" + ] +} diff --git a/ai-engine/services/betting_brain.py b/ai-engine/services/betting_brain.py index 4785af0..df187ff 100644 --- a/ai-engine/services/betting_brain.py +++ b/ai-engine/services/betting_brain.py @@ -41,6 +41,23 @@ class BettingBrain: VALUE_TIER_STAKE_UNITS = 0.5 TRAP_MARKET_GAP = 0.10 + # ── V31f: NATIONAL-TEAM REGIME ─────────────────────────────────────── + # National matches behave nothing like clubs (2300-match backtest): + # * Only MS carries edge — OU/BTTS/HT/DC/OE all -12%..-21% → hard mute. + # * MS edge lives in the 4.0–7.0 odds band for HAZIRLIK/ELEME fixtures + # (+17% ROI, stable across older/newer halves: +22%/+24%). + # * Favorites (odds<3) lose (-10..-18%); TURNUVA inverts the pattern + # (4-7 band is -9% there) → tournaments get NO bet (analysis only). + # Calibration is fine; this is a *bet-selection* gate, applied only when + # match_info.is_national is True. Clubs are completely unaffected. + # See mds/national-team-strategy.md. + NATIONAL_BET_MARKET = "MS" + NATIONAL_MIN_ODDS = 4.0 + NATIONAL_MAX_ODDS = 7.0 + NATIONAL_ALLOWED_COMPETITIONS = {"HAZIRLIK", "ELEME"} + NATIONAL_BASE_SCORE = 66.0 # clears MIN_BET_SCORE(62) when gate passes + NATIONAL_STAKE_UNITS = 0.5 # flat, high-variance band (~24% hit) + MARKET_MIN_CONFIDENCE = { "MS": 45.0, "DC": 55.0, @@ -366,7 +383,7 @@ class BettingBrain: rejected = [d for d in decisions if d.get("action") == "REJECT"] guarded["betting_brain"] = { - "version": "judge-v31d-evidence-tiers", + "version": "judge-v31f-national-regime", "decision": decision, "reason": decision_reason, "main_pick_key": main_key or None, @@ -396,6 +413,10 @@ class BettingBrain: pick = str(row.get("pick") or "") model_prob = self._market_probability(row, package) odds = self._safe_float(row.get("odds"), 0.0) or 0.0 + # V31f: national-team match flags (set by orchestrator in match_info). + _mi = package.get("match_info") or {} + is_national = bool(_mi.get("is_national")) + competition_type = str(_mi.get("competition_type") or "") implied = (1.0 / odds) if odds > 1.0 else 0.0 model_gap = (model_prob - implied) if model_prob is not None and implied > 0 else None calibrated_conf = self._safe_float(row.get("calibrated_confidence", row.get("confidence")), 0.0) or 0.0 @@ -713,6 +734,85 @@ class BettingBrain: score = value_score positives.append(f"value_tier_{tier_value}") + # ── V31f: NATIONAL-TEAM REGIME (overrides club logic) ───────────── + # For national matches the validated strategy is a narrow, mechanical + # value gate (MS / odds 4-7 / Hazırlık+Eleme). We REPLACE the club + # verdict so club-tuned vetoes/scores don't distort it. All the rich + # analysis (probs, model_gap, divergence, triple) above is preserved + # in the payload below — only action/score/stake are decided here. + national_gate_passed = False + if is_national: + in_band = self.NATIONAL_MIN_ODDS <= odds <= self.NATIONAL_MAX_ODDS + is_bet_market = market == self.NATIONAL_BET_MARKET + comp_ok = competition_type in self.NATIONAL_ALLOWED_COMPETITIONS + # Genuine safety vetoes still kill the bet even for national matches. + hard_unsafe = { + "low_reliability_league_hard_block", + "v25_v27_hard_disagreement", + "extreme_negative_ev", + "htft_reversal_risk_high", + } + has_hard_unsafe = any(v in hard_unsafe for v in vetoes) + + national_vetoes: List[str] = [] + if not is_bet_market: + national_vetoes.append("national_non_ms_market_muted") + if not comp_ok: + national_vetoes.append("national_tournament_no_bet") + if not in_band: + national_vetoes.append("national_odds_outside_value_band") + if has_hard_unsafe: + national_vetoes.append("national_hard_safety_veto") + + if national_vetoes: + vetoes = national_vetoes + action = "REJECT" + score = min(score, 40.0) + issues.append(f"national_gate:{competition_type or 'unknown'}") + else: + vetoes = [] + national_gate_passed = True + score = self.NATIONAL_BASE_SCORE + max(-4.0, min(8.0, ev_edge * 30.0)) + score = max(0.0, min(100.0, score)) + action = "BET" + positives.append("national_value_gate_passed") + issues.append(f"national_gate:{competition_type}") + # skip the club action logic below + row["betting_brain"] = { + "action": action, + "score": round(score, 1), + "summary": self._summary(action, market, pick, positives, issues, vetoes), + "positives": positives[:5], + "issues": issues[:6], + "vetoes": vetoes[:6], + "sniper_bypassed": sniper_bypassed, + "value_tier_bypassed": [], + "is_value_tier": False, + "is_national": True, # V31f + "competition_type": competition_type, + "trap_market_flag": trap_market_flag, + "trap_market_gap": trap_market_gap, + "tier_label": "national_value" if national_gate_passed else None, + "value_tier": None, + "model_prob": round(model_prob, 4) if model_prob is not None else None, + "implied_prob": round(implied, 4), + "model_market_gap": round(model_gap, 4) if model_gap is not None else None, + "v27_prob": round(v27_prob, 4) if v27_prob is not None else None, + "divergence": round(divergence, 4) if divergence is not None else None, + "triple_key": triple_key, + "triple_value": triple, + } + if national_gate_passed: + row["is_guaranteed"] = False # high-variance band, never "guaranteed" + row["pick_reason"] = "national_value_gate" + row["stake_units"] = self.NATIONAL_STAKE_UNITS + row["bet_grade"] = "B" + row["playable"] = True + else: + self._force_no_bet(row, f"betting_brain_{action.lower()}") + self._append_reason(row, f"betting_brain_national_{action.lower()}_{round(score)}") + return row + score = max(0.0, min(100.0, score)) action = "BET" if vetoes: diff --git a/ai-engine/services/orchestrator/market_board.py b/ai-engine/services/orchestrator/market_board.py index 2c021c7..926f496 100644 --- a/ai-engine/services/orchestrator/market_board.py +++ b/ai-engine/services/orchestrator/market_board.py @@ -108,6 +108,26 @@ class MarketBoardMixin: "hit": info.get("hit"), } + def _is_national_match(self, league_id: Optional[str]) -> bool: + """True if this league is an A-milli (senior men's) national competition.""" + if not league_id: + return False + natl = getattr(self, "national_leagues", None) or set() + return str(league_id) in natl + + def _competition_type_for( + self, league_id: Optional[str], league_name: Optional[str] + ) -> Optional[str]: + """For national matches, classify HAZIRLIK/ELEME/TURNUVA from the league + name. None for non-national leagues (clubs don't use this).""" + if not self._is_national_match(league_id): + return None + try: + from utils.national_leagues import classify_competition + return classify_competition(league_name or "") + except Exception: + return None + def _build_prediction_package( self, data: MatchData, @@ -346,6 +366,9 @@ class MarketBoardMixin: # Backtest-derived per-league confidence (ROI + sample size). # None when the league has too little data to judge → FE shows no badge. "league_confidence": self._league_confidence_for(data.league_id), + # National-team match flags (drive betting_brain's national gate). + "is_national": self._is_national_match(data.league_id), + "competition_type": self._competition_type_for(data.league_id, data.league_name), "match_date_ms": data.match_date_ms, "sport": data.sport, # Live snapshot — match_commentary uses this to detect upset-in-progress diff --git a/ai-engine/services/single_match_orchestrator.py b/ai-engine/services/single_match_orchestrator.py index 9c04045..f2c0b44 100755 --- a/ai-engine/services/single_match_orchestrator.py +++ b/ai-engine/services/single_match_orchestrator.py @@ -58,6 +58,7 @@ from services.match_commentary import generate_match_commentary from utils.top_leagues import load_top_league_ids from utils.league_reliability import load_league_reliability from utils.league_confidence import load_league_confidence +from utils.national_leagues import load_national_leagues from config.config_loader import build_threshold_dict, get_threshold_default, get_config from models.calibration import get_calibrator @@ -173,6 +174,7 @@ class SingleMatchOrchestrator( self.top_league_ids = load_top_league_ids() self.league_reliability = load_league_reliability() self.league_confidence = load_league_confidence() + self.national_leagues = load_national_leagues() self.enrichment = FeatureEnrichmentService() self.odds_band_analyzer = OddsBandAnalyzer() # ── Market Thresholds (loaded from config/market_thresholds.json) ── diff --git a/ai-engine/utils/national_leagues.py b/ai-engine/utils/national_leagues.py new file mode 100644 index 0000000..4b5c2ef --- /dev/null +++ b/ai-engine/utils/national_leagues.py @@ -0,0 +1,67 @@ +""" +National-Team League Loader + Competition-Type Classifier +========================================================= +Loads the A-milli (senior men's) football league IDs from +data/national_leagues.json and classifies a league name into a +competition type. Powers the betting_brain national-match gate. + +Why this exists: + Backtest (2300 national matches) showed national matches behave very + differently from clubs — only the MS market carries edge, and only in + the 4.0–7.0 odds band for Hazırlık/Eleme fixtures (tournaments behave + inversely). Calibration is fine; the issue is *which* bets to allow. + See mds/national-team-strategy.md. + +Usage: + from utils.national_leagues import load_national_leagues, classify_competition + natl = load_national_leagues() # set[str] of league_ids + ctype = classify_competition(name) # "HAZIRLIK" | "ELEME" | "TURNUVA" +""" + +from __future__ import annotations + +import json +import os +from typing import Set + + +_DATA_FILE = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", + "data", + "national_leagues.json", +) + + +def load_national_leagues() -> Set[str]: + """Return the set of A-milli football league IDs (empty on any failure).""" + if not os.path.isfile(_DATA_FILE): + print( + f"⚠️ national_leagues.json not found at {_DATA_FILE}. " + "National-match gate disabled (no league treated as national)." + ) + return set() + try: + with open(_DATA_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + ids = set(str(x) for x in (data.get("league_ids") or [])) + print(f"✅ Loaded {len(ids)} national-team league IDs") + return ids + except (json.JSONDecodeError, KeyError, TypeError) as exc: + print(f"⚠️ Failed to parse national_leagues.json: {exc}") + return set() + + +def classify_competition(league_name: str) -> str: + """Map a league name to a competition type. + + HAZIRLIK = friendlies, ELEME = qualifiers/play-offs, TURNUVA = finals/cups. + The backtest edge lives in HAZIRLIK+ELEME (MS, odds 4-7); TURNUVA is + handled conservatively (no bet) by the gate. + """ + n = (league_name or "").lower() + if "hazırlık" in n or "hazirlik" in n or "friendl" in n: + return "HAZIRLIK" + if "eleme" in n or "play-off" in n or "playoff" in n or "qualif" in n: + return "ELEME" + return "TURNUVA" diff --git a/mds/national-team-strategy.md b/mds/national-team-strategy.md new file mode 100644 index 0000000..35eecd2 --- /dev/null +++ b/mds/national-team-strategy.md @@ -0,0 +1,70 @@ +# Milli Takım / Dünya Kupası — Bahis Stratejisi (Veri-Temelli) + +> Kaynak: 2.300 maçlık milli backtest (multi_backtest_20260602, /tmp/bt_natl.csv). +> Tüm rakamlar offline simülasyon (production'a dokunulmadan, aynı veride kural testi). +> Tarih: 2026-06. + +## Temel Bulgular (kanıtlanmış) +1. **Kalibrasyon İYİ** (MS ECE 1.6, OU15 2.2) — model olasılıkları milli maçta da doğru. + Sorun kalibrasyon değil EDGE. Yani piyasa oranları da keskin; avantaj sadece + belirli segmentlerde var. +2. **Sadece MS market'inde edge var.** OU/BTTS/HT/DC/OE hepsi "bet-all" ROI + −12%..−21% — milli maçta gol/skor marketleri güvenilmez, KAPATILMALI. +3. **MS'te edge oran bandına + rekabet türüne bağlı:** + - Favori (oran<3): zararlı (−10..−18%). Milli favoriler takılır (rotasyon/motivasyon). + - Denk-üstü (oran 4-7): ELEME/HAZIRLIK'ta kârlı, TURNUVA'da zararlı. +4. **Rekabet türü kritik faktör** (DB'de feature YOK, lig adından türetilir): + HAZIRLIK / ELEME / TURNUVA çok farklı davranır. + +## Grid + Kararlılık Testi (overfit'e karşı) +En iyi kombolar (N>=150, MS market): +| kural | N | hit% | ROI | +|---|---|---|---| +| 4.0-7.0 sadece ELEME | 585 | 25% | +23.1% | +| 3.5-6.0 HAZ+ELE | 1021 | 25% | +14.5% | +| **4.0-7.0 HAZ+ELE (SEÇİLEN)** | **865** | **24%** | **+17.1%** | +| 3.0-6.0 HAZ+ELE | 1381 | 25% | +10.1% | + +**Kararlılık (en güçlü kanıt):** "4-7 sadece ELEME" eski yarı +22.1% / yeni yarı +24.0% +→ iki bağımsız zaman diliminde de pozitif = overfit DEĞİL, sahada tutar. + +## TURNUVA/FİNAL farkı (Dünya Kupası finalleri için kritik) +Turnuva (Avrupa Şamp, Copa America, Uluslar Ligi, Gold Cup, Asya/Afrika Kupası): +- 4-7 bandı turnuvada ZARARLI (−8.9%) — elemenin tersi. +- Sadece underdog 5+ kârlı (+51% ama n=274, oynak, şans payı yüksek). +- Sebep: büyük turnuva finallerinde favoriler tutarlı, sürpriz az. + +## SEÇİLEN STRATEJİ (kullanıcı kararı) +**Milli-maç gate kuralı:** +- Market: SADECE MS (diğer tüm marketler milli maçta kapalı) +- Oran bandı: 4.0 ≤ odds < 7.0 +- Rekabet türü: SADECE Hazırlık + Eleme +- TURNUVA/FİNAL: bahis ÖNERME (sadece analiz/olasılık göster). Underdog +51% + cazip ama oynak/az-örneklem → gerçek paraya bağlanmadı (kullanıcı kararı). +Beklenen: +17% ROI, ~865 bahis/2300 maç. Mevcut gate +0.9% idi → ~19x iyileşme. + +## Mimari Notu (uygulama için) +- Sorun model değil → ayrı ML modeli GEREKSİZ (1898 maç zaten overfit riski; karar verildi: kurma). +- Çözüm = betting brain'de milli-maça özel GATE (eğitim-sonrası kural katmanı). +- Rekabet türü lig adından türetilir: 'hazırlık'→HAZIRLIK, 'eleme/play-off'→ELEME, + diğer→TURNUVA. Milli lig tespiti: qualified_leagues.json'a eklenen 21 milli lig. +- Kalıcı feature olarak rekabet türü eklenebilir (daha temiz) ama gate hardcode de yeter. + +## Durum: UYGULANDI + DOĞRULANDI (betting_brain v31f-national-regime). +Kod: +- utils/national_leagues.py — loader (data/national_leagues.json, 21 lig) + classify_competition +- single_match_orchestrator.py — self.national_leagues yüklenir +- orchestrator/market_board.py — match_info.is_national + competition_type; _is_national_match/_competition_type_for helpers +- betting_brain.py _judge_row — national regime bloğu: is_national ise club mantığını override eder, + SADECE MS + 4.0-7.0 + (HAZIRLIK|ELEME) → BET (NATIONAL_BASE_SCORE 66, stake 0.5u, grade B), + diğer her şey REJECT. Hard-safety vetoları (low_reliability_hard, v25_v27_hard, htft_reversal) + national'da da geçerli. Rich analiz payload korunur. + +DOĞRULAMA (V2 backtest, yeni gate aktif, 1829 maç, /tmp/bt_natl_v2.csv): + BET=784 → TAMAMI MS, oran 4.00-6.99 (bant dışı 0 bahis), hit %23.7, ROI +16.0%, +125.7u. + Simülasyondaki +17% ile birebir. OU/BTTS/HT/turnuva artık 0 BET. + +NOT: ai-engine ~10:10'da restart oldu (compose) → national-gate + V31e recal + league_confidence + kodu CANLI API'de aktif. Ama bunlar docker cp ile deploy edildi; kalıcılık için repo commit + + image rebuild gerekir (yeni container build'inde kaybolur). +## İlgili: 422 lig-gate düzeltmesi CANLIDA (qualified_leagues 48→69, milli ligler açıldı).