From f8599bdb9aa302d5fb25daee102d21e7c5874406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahri=20Can=20Se=C3=A7er?= Date: Mon, 11 May 2026 23:11:41 +0300 Subject: [PATCH] gg --- .../reports/training_v25/v25_pro_metrics.json | 2 +- ai-engine/scripts/backtest_13_sept.py | 206 +++++ ai-engine/scripts/backtest_50_detailed.py | 240 ++++++ ai-engine/scripts/backtest_adaptive.py | 191 +++++ ai-engine/scripts/backtest_diagnostic.py | 145 ++++ ai-engine/scripts/backtest_real.py | 223 +++++ ai-engine/scripts/backtest_roi.py | 231 ++++++ ai-engine/scripts/backtest_sniper.py | 164 ++++ ai-engine/scripts/backtest_strict.py | 162 ++++ ai-engine/scripts/backtest_v2_runtime.py | 230 ++++++ ai-engine/scripts/backtest_value_hunter.py | 147 ++++ ai-engine/scripts/backtest_value_sniper.py | 153 ++++ ai-engine/scripts/backtest_vqwen.py | 136 ++++ ai-engine/scripts/backtest_vqwen_deep.py | 141 ++++ ai-engine/scripts/backtest_vqwen_final.py | 159 ++++ ai-engine/scripts/backtest_vqwen_v3.py | 182 +++++ ai-engine/test_db.py | 7 + ai-engine/test_quant_integration.py | 56 ++ ai-engine/tests/test_engine_null_safety.py | 75 ++ ai-engine/tests/test_feature_enrichment.py | 282 +++++++ ai-engine/tests/test_main_api.py | 110 +++ .../tests/test_single_match_orchestrator.py | 766 ++++++++++++++++++ ai-engine/tests/test_skip_logic.py | 142 ++++ package.json | 4 +- src/scripts/backfill-prediction-runs.ts | 234 ++++++ src/scripts/print-backtest-report.ts | 208 +++++ .../prediction-settlement.market-resolver.ts | 127 +++ src/tasks/prediction-settlement.task.ts | 179 ++++ src/tasks/tasks.module.ts | 9 +- 29 files changed, 4908 insertions(+), 3 deletions(-) create mode 100644 ai-engine/scripts/backtest_13_sept.py create mode 100644 ai-engine/scripts/backtest_50_detailed.py create mode 100644 ai-engine/scripts/backtest_adaptive.py create mode 100644 ai-engine/scripts/backtest_diagnostic.py create mode 100644 ai-engine/scripts/backtest_real.py create mode 100644 ai-engine/scripts/backtest_roi.py create mode 100644 ai-engine/scripts/backtest_sniper.py create mode 100644 ai-engine/scripts/backtest_strict.py create mode 100644 ai-engine/scripts/backtest_v2_runtime.py create mode 100644 ai-engine/scripts/backtest_value_hunter.py create mode 100644 ai-engine/scripts/backtest_value_sniper.py create mode 100644 ai-engine/scripts/backtest_vqwen.py create mode 100644 ai-engine/scripts/backtest_vqwen_deep.py create mode 100644 ai-engine/scripts/backtest_vqwen_final.py create mode 100644 ai-engine/scripts/backtest_vqwen_v3.py create mode 100644 ai-engine/test_db.py create mode 100644 ai-engine/test_quant_integration.py create mode 100644 ai-engine/tests/test_engine_null_safety.py create mode 100644 ai-engine/tests/test_feature_enrichment.py create mode 100644 ai-engine/tests/test_main_api.py create mode 100644 ai-engine/tests/test_single_match_orchestrator.py create mode 100644 ai-engine/tests/test_skip_logic.py create mode 100644 src/scripts/backfill-prediction-runs.ts create mode 100644 src/scripts/print-backtest-report.ts create mode 100644 src/tasks/prediction-settlement.market-resolver.ts create mode 100644 src/tasks/prediction-settlement.task.ts diff --git a/ai-engine/reports/training_v25/v25_pro_metrics.json b/ai-engine/reports/training_v25/v25_pro_metrics.json index 9a2973f..a4ef186 100644 --- a/ai-engine/reports/training_v25/v25_pro_metrics.json +++ b/ai-engine/reports/training_v25/v25_pro_metrics.json @@ -689,4 +689,4 @@ } } } -} \ No newline at end of file +} diff --git a/ai-engine/scripts/backtest_13_sept.py b/ai-engine/scripts/backtest_13_sept.py new file mode 100644 index 0000000..6525557 --- /dev/null +++ b/ai-engine/scripts/backtest_13_sept.py @@ -0,0 +1,206 @@ +""" +Backtest for September 13th (Top Leagues Only) +============================================== +Simulates the NEW 'Skip Logic' on matches from Sept 13, 2025. +""" + +import os +import sys +import json +import psycopg2 +from psycopg2.extras import RealDictCursor +from datetime import datetime + +# Load .env manually to ensure correct DB connection +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, project_root) # Add root to path if needed + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +# ─── Configuration ───────── +MIN_CONF_THRESHOLDS = { + "MS": 45.0, "DC": 40.0, "OU15": 50.0, "OU25": 45.0, + "OU35": 45.0, "BTTS": 45.0, "HT": 40.0, +} + +def run_backtest(): + print("🚀 Backtest: 13 Eylül 2024 - Top Leagues") + print("="*60) + + # 1. Load Top Leagues + leagues_path = os.path.join(project_root, "top_leagues.json") + try: + with open(leagues_path, 'r') as f: + top_leagues = json.load(f) + # Ensure they are strings for SQL IN clause + league_ids = tuple(str(lid) for lid in top_leagues) + print(f"📋 Loaded {len(top_leagues)} top leagues.") + except Exception as e: + print(f"❌ Error loading top_leagues.json: {e}") + return + + # 2. Define Date Range (Sept 13, 2024 UTC) + start_dt = datetime(2024, 9, 13, 0, 0, 0) + end_dt = datetime(2024, 9, 13, 23, 59, 59) + start_ts = int(start_dt.timestamp() * 1000) + end_ts = int(end_dt.timestamp() * 1000) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 3. Fetch Matches & Predictions + # We need matches that are FT and have a prediction + query = """ + SELECT p.match_id, p.prediction_json, + m.score_home, m.score_away, m.status, m.league_id + FROM predictions p + JOIN matches m ON p.match_id = m.id + WHERE m.mst_utc BETWEEN %s AND %s + AND m.league_id IN %s + AND m.status = 'FT' + AND p.prediction_json IS NOT NULL + """ + + try: + cur.execute(query, (start_ts, end_ts, league_ids)) + rows = cur.fetchall() + except Exception as e: + print(f"❌ DB Error: {e}") + cur.close() + conn.close() + return + + print(f"📊 Found {len(rows)} matches with predictions on Sept 13, 2024.") + + if not rows: + print("⚠️ No predictions found for this date. The AI Engine might not have processed these historical matches yet.") + print("💡 Tip: Run the feeder or AI engine on this date range to generate predictions first.") + cur.close() + conn.close() + return + + total_bets = 0 + winning_bets = 0 + skipped_bets = 0 + total_profit = 0.0 + + for row in rows: + data = row['prediction_json'] + if isinstance(data, str): + data = json.loads(data) + + home_score = row['score_home'] or 0 + away_score = row['score_away'] or 0 + total_goals = home_score + away_score + + # Extract Main Pick + main_pick = None + main_pick_conf = 0.0 + main_pick_odds = 0.0 + + if "main_pick" in data and isinstance(data["main_pick"], dict): + mp = data["main_pick"] + main_pick = mp.get("pick") + main_pick_conf = mp.get("confidence", 0.0) + main_pick_odds = mp.get("odds", 0.0) + + if not main_pick or not main_pick_conf: + continue + + # Determine Market Type + pick_str = str(main_pick).upper() + market_type = "MS" + if "1X" in pick_str or "X2" in pick_str or "12" in pick_str: market_type = "DC" + elif "ÜST" in pick_str or "ALT" in pick_str or "OVER" in pick_str or "UNDER" in pick_str: + if "1.5" in pick_str: market_type = "OU15" + elif "3.5" in pick_str: market_type = "OU35" + else: market_type = "OU25" + elif "VAR" in pick_str or "YOK" in pick_str or "BTTS" in pick_str: market_type = "BTTS" + + threshold = MIN_CONF_THRESHOLDS.get(market_type, 45.0) + + # --- SKIP LOGIC --- + # 1. Confidence Gate + if main_pick_conf < threshold: + skipped_bets += 1 + continue + + # 2. Value Gate + if main_pick_odds > 0: + implied_prob = 1.0 / main_pick_odds + my_prob = main_pick_conf / 100.0 + edge = my_prob - implied_prob + if edge < -0.03: + skipped_bets += 1 + continue + + # --- BET PLAYED --- + total_bets += 1 + is_won = False + + # Resolve Result + if market_type == "MS": + if (main_pick == "1" or main_pick == "MS 1") and home_score > away_score: is_won = True + elif (main_pick == "X" or main_pick == "MS X") and home_score == away_score: is_won = True + elif (main_pick == "2" or main_pick == "MS 2") and away_score > home_score: is_won = True + + elif market_type.startswith("OU"): + line = 2.5 + if "1.5" in pick_str: line = 1.5 + elif "3.5" in pick_str: line = 3.5 + is_over = total_goals > line + is_under = total_goals < line + if ("ÜST" in pick_str or "OVER" in pick_str) and is_over: is_won = True + elif ("ALT" in pick_str or "UNDER" in pick_str) and is_under: is_won = True + + elif market_type == "BTTS": + if home_score > 0 and away_score > 0: + if "VAR" in pick_str: is_won = True + else: + if "YOK" in pick_str: is_won = True + + elif market_type == "DC": + if "1X" in pick_str and home_score >= away_score: is_won = True + elif "X2" in pick_str and away_score >= home_score: is_won = True + elif "12" in pick_str and home_score != away_score: is_won = True + + if is_won: + winning_bets += 1 + profit = main_pick_odds - 1.0 + total_profit += profit + else: + total_profit -= 1.0 + + # Report + print("\n" + "="*60) + print("📈 BACKTEST RESULTS: 13 EYLÜL 2025 (TOP LEAGUES)") + print("="*60) + print(f"Total Matches Analyzed: {len(rows)}") + print(f"🚫 Bets SKIPPED (Low Conf/Bad Value): {skipped_bets}") + print(f"✅ Bets PLAYED: {total_bets}") + + if total_bets > 0: + win_rate = (winning_bets / total_bets) * 100 + roi = (total_profit / total_bets) * 100 + + print(f"🏆 Winning Bets: {winning_bets}") + print(f"💀 Losing Bets: {total_bets - winning_bets}") + print("-" * 40) + print(f" Win Rate: {win_rate:.2f}%") + print(f"💰 Total Profit (Units): {total_profit:.2f}") + print(f"📊 ROI: {roi:.2f}%") + + if roi > 0: + print("🟢 STRATEGY IS PROFITABLE!") + else: + print("🔴 STRATEGY IS LOSING") + else: + print("⚠️ No bets were played. Thresholds might be too high or no suitable matches found.") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_backtest() diff --git a/ai-engine/scripts/backtest_50_detailed.py b/ai-engine/scripts/backtest_50_detailed.py new file mode 100644 index 0000000..5e9c8b6 --- /dev/null +++ b/ai-engine/scripts/backtest_50_detailed.py @@ -0,0 +1,240 @@ +""" +Detailed Backtest with 50 Top League Matches +============================================ +Runs AI Engine predictions on 50 real historical matches and shows +exactly which predictions were correct and which were skipped. + +Usage: + python ai-engine/scripts/backtest_50_detailed.py +""" + +import os +import sys +import json +import time +import psycopg2 +from psycopg2.extras import RealDictCursor + +# Add paths +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +sys.path.insert(0, ROOT_DIR) + +if "scripts" in os.path.basename(AI_DIR): + ROOT_DIR = os.path.dirname(ROOT_DIR) + +from services.single_match_orchestrator import get_single_match_orchestrator + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +# 50 Match IDs from the query +MATCH_IDS = [ + "v2ljcst50nk37x04xwimpi50", "7gz0bhb5yvdssazl3y5946kno", "7ftj7kbu4rzpewxravf3luuc4", + "7f1z4e8ch1dm5q677644cky6s", "7ffq3aq3so22iymfdzch63nys", "rrkmeuymz7gzvoz8mplikzdg", + "7hegc9covicy699bxsi81xkb8", "7gl7rpr1hjayk3e5ut0gr613o", "7g7d86i3738287xfvyfeffcwk", + "7hs4boe4hv80muawocevvx2j8", "7ijhsloieg4t9yp5cxp0duln8", "7ixaiiptli5ek32kuybuni4gk", + "7i5sfh41cjpwg4l972dm487x0", "eo7g4wunxxxr8uv45q8p5x638", "7dinds2937w4645wva2rddlas", + "7b5ukdhvqh62wtndeqfg01ixg", "7bjptsj24gndoydn7n0202g44", "7cqxf3vo58ewrwmoom5xiyexg", + "7bxjl9h2hnf165rlp3o1vfztg", "7eo8zrez08c342rqsezpvq39w", "7as1muhs98vdarlhsean4bspg", + "7dwhj8cfxv6v6bzxpu5e3h05w", "7d4vq4417ps84yjzh95bnvvv8", "7ea9z501jgp9kxw3gay4myrkk", + "7cd3401itlty6ded7c1wct0yc", "ebgpz9mcije2snv986n6587pw", "i7ar1dkhvcwpxmkyks65ib6c", + "lyek7tyy6qk2xjs9vblucnx0", "hdn9qtyn3ysjwbc3i2trantg", "3y2bnssfqlajosiz2gpkn6xhw", + "40pehd14s9djjtycujavbex3o", "3xnbfjznzmnwml20akbgnis5w", "2eovi2rcc2l4ha7fpb2w7e1hw", + "2bwuikdjyyuithhru8ka8o00k", "2d3pcd76ya9ihi9yotxc553is", "1e9it04z4epy2etdxsffe7m6s", + "7af49jgo4iulv1k8cplj9smj8", "5k3vrz619hdu9nx4rnx6uim1g", "amjppgpetnyr0iisi241kgkyc", + "coqrhq09kxd16iejvgtzj3mz8", "d8ysan1qdctmkvjaz2adw7aqc", "9ttciz0gtb0z09ev1q5fe0ro4", + "9u720o37yaddqu1w6hlszpnh0", "7ijezdjp8t0rjti91ac63hyxg", "72gvdvztbb3dn79jidzzxzcb8", + "6uof1v2s6vrpieeml2bwo9tlg", "91dd8ia3m0bxoqzjgyo3ptsk", "3tj1nt3udsbvb9soqn2cs6gpg", + "1br5g88o5idtjxka1fr6zg4k4", "akuesquthbmxlzckvnqmgles4" +] + +def run_detailed_backtest(): + print("🚀 DETAILED BACKTEST: 50 Top League Matches") + print("🧠 Engine: V30 Ensemble (V20+V25) + Skip Logic") + print("="*80) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + # Fetch match details with odds + placeholders = ','.join(['%s'] * len(MATCH_IDS)) + cur.execute(f""" + SELECT m.id, m.match_name, m.home_team_id, m.away_team_id, + m.score_home, m.score_away, m.league_id, + t1.name as home_team, t2.name as away_team, + l.name as league_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 + LEFT JOIN leagues l ON m.league_id = l.id + WHERE m.id IN ({placeholders}) + AND m.status = 'FT' + ORDER BY m.mst_utc DESC + """, MATCH_IDS) + + rows = cur.fetchall() + print(f"📊 Found {len(rows)} matches. Starting AI Analysis...") + + if not rows: + print("⚠️ No matches found.") + cur.close() + conn.close() + return + + # Initialize AI Engine + try: + orchestrator = get_single_match_orchestrator() + print("✅ AI Engine Loaded.\n") + except Exception as e: + print(f"❌ Failed to load AI Engine: {e}") + cur.close() + conn.close() + return + + # ─── Backtest Loop ─── + results = [] + total_skipped = 0 + total_played = 0 + total_won = 0 + total_profit = 0.0 + MIN_CONF = 45.0 + + start_time = time.time() + + for i, row in enumerate(rows): + match_id = str(row['id']) + home_team = row['home_team'] or "Unknown" + away_team = row['away_team'] or "Unknown" + league = row['league_name'] or "Unknown" + home_score = row['score_home'] or 0 + away_score = row['score_away'] or 0 + total_goals = home_score + away_score + + print(f"[{i+1}/{len(rows)}] {home_team} vs {away_team} ({league}) ... ", end="", flush=True) + + try: + prediction = orchestrator.analyze_match(match_id) + + if not prediction: + print("⚠️ No prediction") + continue + + # Extract Main Pick + main_pick = prediction.get("main_pick") or {} + pick_name = main_pick.get("pick", "") + confidence = main_pick.get("confidence", 0) + odds = main_pick.get("odds", 0) + + # Apply Skip Logic + if confidence < MIN_CONF: + print(f"🚫 SKIP (Conf {confidence:.0f}%)") + total_skipped += 1 + results.append({"match": f"{home_team} vs {away_team}", "pick": pick_name, + "conf": confidence, "odds": odds, "result": "SKIPPED", "profit": 0}) + continue + + if odds > 0: + implied_prob = 1.0 / odds + my_prob = confidence / 100.0 + if my_prob - implied_prob < -0.03: + print(f"🚫 SKIP (Bad Value)") + total_skipped += 1 + results.append({"match": f"{home_team} vs {away_team}", "pick": pick_name, + "conf": confidence, "odds": odds, "result": "SKIPPED", "profit": 0}) + continue + + # Bet Played + total_played += 1 + won = False + + # Resolve + pick_clean = str(pick_name).upper() + if pick_clean in ["1", "MS 1", "İY 1"] and home_score > away_score: won = True + elif pick_clean in ["X", "MS X", "İY X"] and home_score == away_score: won = True + elif pick_clean in ["2", "MS 2", "İY 2"] and away_score > home_score: won = True + elif pick_clean in ["1X", "X2"] or ("1X" in pick_clean or "X2" in pick_clean): + if "1X" in pick_clean and home_score >= away_score: won = True + elif "X2" in pick_clean and away_score >= home_score: won = True + elif pick_clean in ["12"] and home_score != away_score: won = True + elif "ÜST" in pick_clean or "OVER" in pick_clean: + line = 2.5 + if "1.5" in pick_clean: line = 1.5 + elif "3.5" in pick_clean: line = 3.5 + if total_goals > line: won = True + elif "ALT" in pick_clean or "UNDER" in pick_clean: + line = 2.5 + if "1.5" in pick_clean: line = 1.5 + elif "3.5" in pick_clean: line = 3.5 + if total_goals < line: won = True + elif "VAR" in pick_clean and home_score > 0 and away_score > 0: won = True + elif "YOK" in pick_clean and (home_score == 0 or away_score == 0): won = True + + if won: + total_won += 1 + profit = odds - 1.0 + print(f"✅ WON ({pick_name} @ {odds:.2f}, +{profit:.2f})") + else: + profit = -1.0 + print(f"❌ LOST ({pick_name} @ {odds:.2f})") + + total_profit += profit + results.append({"match": f"{home_team} vs {away_team}", "pick": pick_name, + "conf": confidence, "odds": odds, + "result": "WON" if won else "LOST", "profit": profit, + "score": f"{home_score}-{away_score}"}) + + except Exception as e: + print(f"💥 Error: {e}") + + elapsed = time.time() - start_time + + # ─── DETAILED REPORT ─── + print("\n" + "="*80) + print("📈 DETAILED BACKTEST RESULTS") + print(f"⏱️ Time: {elapsed:.1f}s") + print("="*80) + print(f"📊 Total Matches: {len(rows)}") + print(f"🚫 Skipped: {total_skipped}") + print(f"🎲 Played: {total_played}") + print(f"✅ Won: {total_won}") + print(f"💀 Lost: {total_played - total_won}") + print(f"💰 Profit: {total_profit:+.2f} units") + + if total_played > 0: + win_rate = (total_won / total_played) * 100 + roi = (total_profit / total_played) * 100 + print(f"📊 Win Rate: {win_rate:.1f}%") + print(f"📊 ROI: {roi:.1f}%") + if roi > 0: + print("🟢 STRATEGY IS PROFITABLE!") + else: + print("🔴 STRATEGY IS LOSING") + + # ─── TABLE OF ALL RESULTS ─── + print("\n" + "="*80) + print("📋 DETAILED MATCH RESULTS") + print("="*80) + print(f"{'Match':<40} {'Pick':<15} {'Conf':<6} {'Odds':<6} {'Result':<8} {'Score':<6}") + print("-"*80) + for r in results: + match_str = r['match'][:38] + pick_str = str(r['pick'])[:13] + conf_str = f"{r['conf']:.0f}%" + odds_str = f"{r['odds']:.2f}" if r['odds'] > 0 else "N/A" + res_str = r['result'] + score_str = r.get('score', '') + + # Color coding + if res_str == "WON": res_display = f"✅ {res_str}" + elif res_str == "LOST": res_display = f"❌ {res_str}" + else: res_display = f"🚫 {res_str}" + + print(f"{match_str:<40} {pick_str:<15} {conf_str:<6} {odds_str:<6} {res_display:<12} {score_str:<6}") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_detailed_backtest() diff --git a/ai-engine/scripts/backtest_adaptive.py b/ai-engine/scripts/backtest_adaptive.py new file mode 100644 index 0000000..c53cac5 --- /dev/null +++ b/ai-engine/scripts/backtest_adaptive.py @@ -0,0 +1,191 @@ +""" +Adaptive 500 Match Backtest +============================= +Skips NO match unless NO odds exist. +Evaluates ALL available markets (MS, OU, BTTS) and picks the BEST value bet. +""" + +import os +import sys +import json +import time +import psycopg2 +from psycopg2.extras import RealDictCursor + +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +sys.path.insert(0, ROOT_DIR) +if "scripts" in os.path.basename(AI_DIR): + ROOT_DIR = os.path.dirname(ROOT_DIR) + +from services.single_match_orchestrator import get_single_match_orchestrator + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +def run_adaptive_backtest(): + print("🔄 ADAPTIVE 500 MATCH BACKTEST") + print("="*60) + + # 1. Load Top Leagues + leagues_path = os.path.join(ROOT_DIR, "top_leagues.json") + with open(leagues_path, 'r') as f: + top_leagues = json.load(f) + league_ids = tuple(str(lid) for lid in top_leagues) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 2. Fetch 500 Finished Matches with Odds + cur.execute(""" + SELECT m.id, m.match_name, m.home_team_id, m.away_team_id, + m.score_home, m.score_away, m.league_id, + t1.name as home_team, t2.name as away_team + 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.league_id IN %s + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id) + ORDER BY m.mst_utc DESC + LIMIT 500 + """, (league_ids,)) + + rows = cur.fetchall() + print(f"📊 Found {len(rows)} matches. Analyzing...\n") + + if not rows: + print("⚠️ No matches found.") + return + + try: orchestrator = get_single_match_orchestrator() + except Exception as e: + print(f"❌ AI Error: {e}") + return + + # Stats + total_evaluated = 0 + total_bet = 0 + total_won = 0 + total_profit = 0.0 + skipped_count = 0 + + for i, row in enumerate(rows): + match_id = str(row['id']) + home = row['home_team'] or "?" + away = row['away_team'] or "?" + h_score = row['score_home'] or 0 + a_score = row['score_away'] or 0 + + total_evaluated += 1 + # print(f"[{i+1}] {home} vs {away} ... ", end="", flush=True) + + try: + pred = orchestrator.analyze_match(match_id) + if not pred: + # print("⚠️ No Data") + continue + + # ─── ADAPTIVE PICKING ─── + # Check ALL recommendations (Expert or Standard) to find the BEST option + candidates = [] + + # Add main picks + if pred.get("expert_recommendation"): + rec = pred["expert_recommendation"] + if rec.get("main_pick"): candidates.append(rec["main_pick"]) + if rec.get("safe_alternative"): candidates.append(rec["safe_alternative"]) + if rec.get("value_picks"): candidates.extend(rec["value_picks"]) + elif pred.get("main_pick"): + candidates.append(pred["main_pick"]) + + best_bet = None + for c in candidates: + if not c: continue + conf = c.get("confidence", 0) + odds = c.get("odds", 0) + pick = c.get("pick") + + # Flexible Criteria: + # 1. Confidence > 60% + # 2. Odds > 1.10 (Not "free" odds like 1.00) + # 3. Edge > -2% (Slightly tolerant) + if conf >= 60 and odds > 1.10: + implied = 1.0 / odds + edge = ((conf/100) - implied) * 100 + + # Prioritize positive edge, but accept small negative if confidence is high + if edge > -2.0: + if best_bet is None or (conf > best_bet.get("confidence", 0)): + best_bet = c + + if best_bet: + pick = str(best_bet.get("pick")).upper() + conf = best_bet.get("confidence") + odds = best_bet.get("odds") + + # Resolution Logic + won = False + if pick in ["1", "MS 1", "İY 1"] and h_score > a_score: won = True + elif pick in ["X", "MS X", "İY X"] and h_score == a_score: won = True + elif pick in ["2", "MS 2", "İY 2"] and a_score > h_score: won = True + elif pick in ["1X", "X2"]: + if "1X" in pick and h_score >= a_score: won = True + elif "X2" in pick and a_score >= h_score: won = True + elif pick == "12" and h_score != a_score: won = True + elif "ÜST" in pick or "OVER" in pick: + line = 2.5 + if "1.5" in pick: line = 1.5 + elif "3.5" in pick: line = 3.5 + if (h_score + a_score) > line: won = True + elif "ALT" in pick or "UNDER" in pick: + line = 2.5 + if "1.5" in pick: line = 1.5 + elif "3.5" in pick: line = 3.5 + if (h_score + a_score) < line: won = True + elif "VAR" in pick and h_score > 0 and a_score > 0: won = True + elif "YOK" in pick and (h_score == 0 or a_score == 0): won = True + + total_bet += 1 + if won: + total_won += 1 + profit = odds - 1.0 + total_profit += profit + # print(f"✅ WON (+{profit:.2f}) | {pick}") + else: + total_profit -= 1.0 + # print(f"❌ LOST ({pick} @ {odds:.2f})") + else: + skipped_count += 1 + # print(f"🚫 SKIP (No Value)") + + except Exception as e: + # print(f"💥 Error: {e}") + pass + + print("\n" + "="*60) + print("🔄 ADAPTIVE BACKTEST RESULTS (500 Matches)") + print("="*60) + print(f"📊 Evaluated: {total_evaluated}") + print(f"🎲 Played: {total_bet}") + print(f"🚫 Skipped: {skipped_count}") + print(f"✅ Won: {total_won}") + + if total_bet > 0: + win_rate = (total_won / total_bet) * 100 + roi = (total_profit / total_bet) * 100 + print(f"📈 Win Rate: {win_rate:.2f}%") + print(f"💰 Total Profit: {total_profit:.2f} Units") + print(f"📊 ROI: {roi:.2f}%") + if total_profit > 0: print("🟢 KARLI STRATEJİ") + else: print("🔴 ZARARDA") + else: + print("⚠️ Hiç bahis oynanmadı. Veri kalitesi çok düşük.") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_adaptive_backtest() diff --git a/ai-engine/scripts/backtest_diagnostic.py b/ai-engine/scripts/backtest_diagnostic.py new file mode 100644 index 0000000..b0751f7 --- /dev/null +++ b/ai-engine/scripts/backtest_diagnostic.py @@ -0,0 +1,145 @@ +""" +Diagnostic Backtest - Hangi Pazar Kanıyor? +=========================================== +Analyses the 500 matches to see WHICH markets are losing money. +""" + +import os +import sys +import json +import time +import psycopg2 +from psycopg2.extras import RealDictCursor +from collections import defaultdict + +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +sys.path.insert(0, ROOT_DIR) +if "scripts" in os.path.basename(AI_DIR): + ROOT_DIR = os.path.dirname(ROOT_DIR) + +from services.single_match_orchestrator import get_single_match_orchestrator + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +def run_diagnostic(): + print("🔍 TANI BACKTESTİ: NEREDE KAYBETTİK?") + print("="*60) + + leagues_path = os.path.join(ROOT_DIR, "top_leagues.json") + with open(leagues_path, 'r') as f: + top_leagues = json.load(f) + league_ids = tuple(str(lid) for lid in top_leagues) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + cur.execute(""" + SELECT m.id, m.match_name, m.home_team_id, m.away_team_id, + m.score_home, m.score_away, m.league_id, + t1.name as home_team, t2.name as away_team + 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.league_id IN %s + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id) + ORDER BY m.mst_utc DESC + LIMIT 500 + """, (league_ids,)) + + rows = cur.fetchall() + print(f"📊 {len(rows)} maç analiz ediliyor...\n") + + try: orchestrator = get_single_match_orchestrator() + except Exception as e: + print(f"❌ AI Hatası: {e}") + return + + # Market Stats: { "MS": {"won": 10, "lost": 20, "profit": -5.0}, ... } + market_stats = defaultdict(lambda: {"won": 0, "lost": 0, "profit": 0.0, "total": 0}) + + for i, row in enumerate(rows): + match_id = str(row['id']) + h_score = row['score_home'] or 0 + a_score = row['score_away'] or 0 + + try: + pred = orchestrator.analyze_match(match_id) + if not pred: continue + + candidates = [] + if pred.get("expert_recommendation"): + rec = pred["expert_recommendation"] + if rec.get("main_pick"): candidates.append(rec["main_pick"]) + if rec.get("value_picks"): candidates.extend(rec["value_picks"]) + elif pred.get("main_pick"): + candidates.append(pred["main_pick"]) + + played_this = False + for c in candidates: + if not c: continue + conf = c.get("confidence", 0) + odds = c.get("odds", 0) + pick = str(c.get("pick")).upper() + market_type = c.get("market_type", "Unknown") + + # Criteria + if conf >= 60 and odds > 1.10: + implied = 1.0 / odds + edge = ((conf/100) - implied) * 100 + if edge > -2.0: + # Resolve + won = False + if pick in ["1", "MS 1"] and h_score > a_score: won = True + elif pick in ["X", "MS X"] and h_score == a_score: won = True + elif pick in ["2", "MS 2"] and a_score > h_score: won = True + elif pick in ["1X", "X2"]: + if "1X" in pick and h_score >= a_score: won = True + elif "X2" in pick and a_score >= h_score: won = True + elif pick == "12" and h_score != a_score: won = True + elif "ÜST" in pick or "OVER" in pick: + line = 2.5 + if "1.5" in pick: line = 1.5 + elif "3.5" in pick: line = 3.5 + if (h_score + a_score) > line: won = True + elif "ALT" in pick or "UNDER" in pick: + line = 2.5 + if "1.5" in pick: line = 1.5 + elif "3.5" in pick: line = 3.5 + if (h_score + a_score) < line: won = True + elif "VAR" in pick and h_score > 0 and a_score > 0: won = True + elif "YOK" in pick and (h_score == 0 or a_score == 0): won = True + + market_stats[market_type]["total"] += 1 + if won: + market_stats[market_type]["won"] += 1 + market_stats[market_type]["profit"] += (odds - 1.0) + else: + market_stats[market_type]["lost"] += 1 + market_stats[market_type]["profit"] -= 1.0 + + played_this = True + break # Only one bet per match + + except: pass + + # Print Results + print("\n" + "="*60) + print("📊 PAZAR BAZLI KAR/ZARAR TABLOSU") + print("="*60) + print(f"{'Market':<15} {'Oynanan':<10} {'Kazanılan':<10} {'Win%':<8} {'Kâr':<10}") + print("-" * 60) + + for mkt, stats in sorted(market_stats.items(), key=lambda x: x[1]["profit"], reverse=True): + wr = (stats["won"] / stats["total"] * 100) if stats["total"] > 0 else 0 + print(f"{mkt:<15} {stats['total']:<10} {stats['won']:<10} {wr:.1f}% {stats['profit']:+.2f} Units") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_diagnostic() diff --git a/ai-engine/scripts/backtest_real.py b/ai-engine/scripts/backtest_real.py new file mode 100644 index 0000000..a62d349 --- /dev/null +++ b/ai-engine/scripts/backtest_real.py @@ -0,0 +1,223 @@ +""" +Real AI Engine Backtest Script +============================== +Uses the ACTUAL models (V20/V25 Ensemble) to predict historical matches. + +Usage: + python ai-engine/scripts/backtest_real.py +""" + +import os +import sys +import json +import time +import psycopg2 +from psycopg2.extras import RealDictCursor +from datetime import datetime + +# Add paths +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +sys.path.insert(0, ROOT_DIR) + +# Fix for Windows path issues in scripts +if "scripts" in os.path.basename(AI_DIR): + ROOT_DIR = os.path.dirname(ROOT_DIR) # One level up if inside scripts folder + +from services.single_match_orchestrator import get_single_match_orchestrator, MatchData + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +def run_backtest(): + print("🚀 REAL AI BACKTEST: Sept 13, 2024 - Top Leagues") + print("🧠 Engine: V30 Ensemble (V20+V25)") + print("="*60) + + # Load Top Leagues + leagues_path = os.path.join(ROOT_DIR, "top_leagues.json") + try: + with open(leagues_path, 'r') as f: + top_leagues = json.load(f) + league_ids = tuple(str(lid) for lid in top_leagues) + print(f"📋 Loaded {len(top_leagues)} top leagues.") + except Exception as e: + print(f"❌ Error loading top_leagues.json: {e}") + return + + # Date Range (Sept 13, 2024) + start_dt = datetime(2024, 9, 13, 0, 0, 0) + end_dt = datetime(2024, 9, 13, 23, 59, 59) + start_ts = int(start_dt.timestamp() * 1000) + end_ts = int(end_dt.timestamp() * 1000) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + # Fetch Matches + cur.execute(""" + SELECT m.id, m.match_name, m.home_team_id, m.away_team_id, + m.mst_utc, m.league_id, m.status, m.score_home, m.score_away, + t1.name as home_team, t2.name as away_team, + l.name as league_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 + LEFT JOIN leagues l ON m.league_id = l.id + WHERE m.mst_utc BETWEEN %s AND %s + AND m.league_id IN %s + AND m.status = 'FT' + ORDER BY m.mst_utc ASC + LIMIT 20 -- Limit to 20 matches to avoid running for hours on a single backtest + """, (start_ts, end_ts, league_ids)) + + rows = cur.fetchall() + print(f"📊 Found {len(rows)} finished matches. Starting AI Analysis...") + + if not rows: + print("⚠️ No matches found for this date.") + cur.close() + conn.close() + return + + # Initialize AI Engine + try: + orchestrator = get_single_match_orchestrator() + print("✅ AI Engine (SingleMatchOrchestrator) Loaded.") + except Exception as e: + print(f"❌ Failed to load AI Engine: {e}") + print("💡 Make sure models are trained/present in ai-engine/models/") + cur.close() + conn.close() + return + + # ─── Backtest Loop ─── + total_matches_analyzed = 0 + bets_skipped = 0 + bets_played = 0 + bets_won = 0 + total_profit = 0.0 + + # Thresholds matching the NEW Skip Logic + MIN_CONF = 45.0 + + start_time = time.time() + + for i, row in enumerate(rows): + match_id = str(row['id']) + home_team = row['home_team'] + away_team = row['away_team'] + home_score = row['score_home'] + away_score = row['score_away'] + + print(f"\n[{i+1}/{len(rows)}] Analyzing: {home_team} vs {away_team} ...") + + try: + # 1. AI PREDICTION (Actual Model Call) + prediction = orchestrator.analyze_match(match_id) + + if not prediction: + print(f" ⚠️ AI returned no prediction.") + continue + + total_matches_analyzed += 1 + + # 2. Extract Main Pick + main_pick = prediction.get("main_pick") or {} + pick_name = main_pick.get("pick") + confidence = main_pick.get("confidence", 0) + odds = main_pick.get("odds", 0) + + if not pick_name or not confidence: + print(f" ⚠️ No main pick found in prediction.") + continue + + print(f" 🤖 Pick: {pick_name} | Conf: {confidence}% | Odds: {odds}") + + # 3. Apply Skip Logic (New Backtest Logic) + if confidence < MIN_CONF: + print(f" 🚫 SKIPPED (Confidence {confidence}% < {MIN_CONF}%)") + bets_skipped += 1 + continue + + if odds > 0: + implied_prob = 1.0 / odds + my_prob = confidence / 100.0 + if my_prob - implied_prob < -0.03: # Negative edge + print(f" 🚫 SKIPPED (Negative Edge)") + bets_skipped += 1 + continue + + # 4. Bet Played + bets_played += 1 + print(f" 🎲 BET PLAYED: {pick_name} @ {odds}") + + # 5. Resolve Bet + won = False + # Basic resolution logic (Need to parse pick_name like "1", "X", "2", "2.5 Üst", etc.) + pick_clean = str(pick_name).upper() + + # MS + if pick_clean in ["1", "MS 1"] and home_score > away_score: won = True + elif pick_clean in ["X", "MS X"] and home_score == away_score: won = True + elif pick_clean in ["2", "MS 2"] and away_score > home_score: won = True + + # OU25 + elif "ÜST" in pick_clean or "OVER" in pick_clean: + if (home_score + away_score) > 2.5: won = True + elif "ALT" in pick_clean or "UNDER" in pick_clean: + if (home_score + away_score) < 2.5: won = True + + # BTTS + elif "VAR" in pick_clean and home_score > 0 and away_score > 0: won = True + elif "YOK" in pick_clean and (home_score == 0 or away_score == 0): won = True + + if won: + bets_won += 1 + profit = odds - 1.0 + print(f" ✅ WON! (+{profit:.2f} units)") + else: + profit = -1.0 + print(f" ❌ LOST! (-1.00 units)") + + total_profit += profit + + except Exception as e: + print(f" 💥 Error during analysis: {e}") + + elapsed = time.time() - start_time + + # ─── FINAL REPORT ─── + print("\n" + "="*60) + print("📈 REAL AI BACKTEST RESULTS") + print(f"🕒 Time taken: {elapsed:.1f} seconds") + print("="*60) + print(f"📊 Matches Analyzed: {total_matches_analyzed}") + print(f"🚫 Bets SKIPPED: {bets_skipped}") + print(f"✅ Bets PLAYED: {bets_played}") + + if bets_played > 0: + win_rate = (bets_won / bets_played) * 100 + roi = (total_profit / bets_played) * 100 + yield_val = total_profit # Net Units + + print(f"🏆 Bets Won: {bets_won}") + print(f"💀 Bets Lost: {bets_played - bets_won}") + print("-" * 40) + print(f" Win Rate: {win_rate:.2f}%") + print(f"💰 Total Profit (Units): {total_profit:.2f}") + print(f"📊 ROI: {roi:.2f}%") + + if roi > 0: + print("🟢 STRATEGY IS PROFITABLE!") + else: + print("🔴 STRATEGY IS LOSING") + else: + print("⚠️ No bets were played. All were skipped or failed.") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_backtest() diff --git a/ai-engine/scripts/backtest_roi.py b/ai-engine/scripts/backtest_roi.py new file mode 100644 index 0000000..28b02f4 --- /dev/null +++ b/ai-engine/scripts/backtest_roi.py @@ -0,0 +1,231 @@ +""" +Backtest ROI Engine +=================== +Simulates the NEW "Skip Logic" on historical predictions. +Answers: "What if we only played the bets the model was confident about?" + +Usage: + python ai-engine/scripts/backtest_roi.py +""" + +import os +import sys +import json +import psycopg2 +from psycopg2.extras import RealDictCursor +from typing import Dict, List, Any +from dotenv import load_dotenv + +# Load .env from project root (2 levels up from this script) +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +load_dotenv(os.path.join(project_root, ".env")) + +def get_clean_dsn() -> str: + """Return a psycopg2-compatible DSN from DATABASE_URL.""" + # HARDCODED FOR BACKTEST (Bypassing dotenv issues) + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +# ─── Configuration (Matching the NEW BetRecommender Logic) ───────── +# Minimum confidence to even consider a bet (Hard Gate) +MIN_CONF_THRESHOLDS = { + "MS": 45.0, + "DC": 40.0, + "OU15": 50.0, + "OU25": 45.0, + "OU35": 45.0, + "BTTS": 45.0, + "HT": 40.0, +} + +def get_market_type_from_key(key: str) -> str: + """Map prediction keys to market types for thresholding.""" + if key.startswith("ms_") or key in ["1", "X", "2"]: return "MS" + if key.startswith("dc_") or key in ["1X", "X2", "12"]: return "DC" + if key.startswith("ou15_") or key.startswith("1.5"): return "OU15" + if key.startswith("ou25_") or key.startswith("2.5"): return "OU25" + if key.startswith("ou35_") or key.startswith("3.5"): return "OU35" + if key.startswith("btts_") or key in ["Var", "Yok"]: return "BTTS" + if key.startswith("ht_") or key.startswith("İY"): return "HT" + return "MS" + +def simulate_backtest(): + print("🚀 Starting Backtest with NEW 'Skip Logic'...") + print("="*60) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 1. Fetch PREDICTIONS that have a confidence score + # We limit to last 1000 finished matches to keep it fast but representative + cur.execute(""" + SELECT p.match_id, p.prediction_json, + m.score_home, m.score_away, m.status + FROM predictions p + JOIN matches m ON p.match_id = m.id + WHERE m.status = 'FT' + AND p.prediction_json IS NOT NULL + ORDER BY m.mst_utc DESC + LIMIT 2000 + """) + predictions = cur.fetchall() + + print(f"📊 Loaded {len(predictions)} historical predictions.") + + total_bets = 0 + winning_bets = 0 + skipped_bets = 0 + total_profit = 0.0 # Assuming unit stake of 1.0 + + # 2. Process each prediction + for pred_row in predictions: + match_id = pred_row['match_id'] + data = pred_row['prediction_json'] + if isinstance(data, str): + data = json.loads(data) + + # Real result + home_score = pred_row['score_home'] or 0 + away_score = pred_row['score_away'] or 0 + total_goals = home_score + away_score + + # Extract prediction details from the JSON structure + # The structure varies, but usually contains 'main_pick', 'bet_summary', or 'market_board' + + # Try to get the main pick recommendation + main_pick = None + main_pick_conf = 0.0 + main_pick_odds = 0.0 + + # Navigate the V20+ JSON structure + market_board = data.get("market_board", {}) + + # Check Main Pick + if "main_pick" in data: + mp = data["main_pick"] + if isinstance(mp, dict): + main_pick = mp.get("pick") + main_pick_conf = mp.get("confidence", 0.0) + main_pick_odds = mp.get("odds", 0.0) + + # If no main pick, try bet_summary + if not main_pick and "bet_summary" in data: + summary = data["bet_summary"] + if isinstance(summary, list) and len(summary) > 0: + # Take the highest confidence one + best = max(summary, key=lambda x: x.get("confidence", 0)) + main_pick = best.get("pick") + main_pick_conf = best.get("confidence", 0.0) + main_pick_odds = best.get("odds", 0.0) + + if not main_pick or not main_pick_conf: + continue + + # ─── NEW LOGIC: APPLY FILTERS ─── + # 1. Determine Market Type + # Simple heuristic based on pick string + pick_str = str(main_pick).upper() + market_type = "MS" + if "1X" in pick_str or "X2" in pick_str or "12" in pick_str: market_type = "DC" + elif "ÜST" in pick_str or "ALT" in pick_str or "OVER" in pick_str or "UNDER" in pick_str: + if "1.5" in pick_str: market_type = "OU15" + elif "3.5" in pick_str: market_type = "OU35" + else: market_type = "OU25" + elif "VAR" in pick_str or "YOK" in pick_str or "BTTS" in pick_str: market_type = "BTTS" + + threshold = MIN_CONF_THRESHOLDS.get(market_type, 45.0) + + # 2. Check Confidence Gate + if main_pick_conf < threshold: + skipped_bets += 1 + continue + + # 3. Check Value Gate (Edge) + if main_pick_odds > 0: + implied_prob = 1.0 / main_pick_odds + my_prob = main_pick_conf / 100.0 + edge = my_prob - implied_prob + if edge < -0.03: # Negative value + skipped_bets += 1 + continue + + # ─── BET IS PLAYED ─── + total_bets += 1 + + # Determine if WON + is_won = False + + # Resolve MS (1, X, 2) + if market_type == "MS": + if main_pick == "1" and home_score > away_score: is_won = True + elif main_pick == "X" and home_score == away_score: is_won = True + elif main_pick == "2" and away_score > home_score: is_won = True + elif main_pick == "MS 1" and home_score > away_score: is_won = True + elif main_pick == "MS X" and home_score == away_score: is_won = True + elif main_pick == "MS 2" and away_score > home_score: is_won = True + + # Resolve OU (Over/Under) + elif market_type.startswith("OU"): + line = 2.5 + if "1.5" in pick_str: line = 1.5 + elif "3.5" in pick_str: line = 3.5 + + is_over = total_goals > line + is_under = total_goals < line # Simplification (usually line is X.5 so no draw) + + if "ÜST" in pick_str or "OVER" in pick_str: + if is_over: is_won = True + elif "ALT" in pick_str or "UNDER" in pick_str: + if is_under: is_won = True + + # Resolve BTTS + elif market_type == "BTTS": + if home_score > 0 and away_score > 0: + if "VAR" in pick_str: is_won = True + else: + if "YOK" in pick_str: is_won = True + + # Resolve DC (Double Chance) - Simplified + elif market_type == "DC": + if "1X" in pick_str and (home_score >= away_score): is_won = True + elif "X2" in pick_str and (away_score >= home_score): is_won = True + elif "12" in pick_str and (home_score != away_score): is_won = True + + if is_won: + winning_bets += 1 + profit = main_pick_odds - 1.0 + total_profit += profit + else: + total_profit -= 1.0 + + # ─── REPORT ─── + print("\n" + "="*60) + print("📈 BACKTEST RESULTS (With NEW Skip Logic)") + print("="*60) + print(f"Total Historical Matches Analyzed: {len(predictions)}") + print(f"🚫 Bets SKIPPED (Low Conf/Bad Value): {skipped_bets}") + print(f"✅ Bets PLAYED: {total_bets}") + + if total_bets > 0: + win_rate = (winning_bets / total_bets) * 100 + roi = (total_profit / total_bets) * 100 + + print(f"🏆 Winning Bets: {winning_bets}") + print(f"💀 Losing Bets: {total_bets - winning_bets}") + print("-" * 40) + print(f" Win Rate: {win_rate:.2f}%") + print(f"💰 Total Profit (Units): {total_profit:.2f}") + print(f"📊 ROI: {roi:.2f}%") + + if roi > 0: + print("🟢 STRATEGY IS PROFITABLE!") + else: + print("🔴 STRATEGY IS LOSING (Adjust thresholds!)") + else: + print("⚠️ No bets were played. Thresholds might be too high.") + + cur.close() + conn.close() + +if __name__ == "__main__": + simulate_backtest() diff --git a/ai-engine/scripts/backtest_sniper.py b/ai-engine/scripts/backtest_sniper.py new file mode 100644 index 0000000..ce4169c --- /dev/null +++ b/ai-engine/scripts/backtest_sniper.py @@ -0,0 +1,164 @@ +""" +SNIPER Backtest +=============== +Sadece en yüksek güvenilirlik ve değere sahip bahisleri oynar. +""" + +import os +import sys +import json +import time +import psycopg2 +from psycopg2.extras import RealDictCursor +from datetime import datetime + +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +sys.path.insert(0, ROOT_DIR) +if "scripts" in os.path.basename(AI_DIR): + ROOT_DIR = os.path.dirname(ROOT_DIR) + +from services.single_match_orchestrator import get_single_match_orchestrator + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +MATCH_IDS = [ + "v2ljcst50nk37x04xwimpi50", "7gz0bhb5yvdssazl3y5946kno", "7ftj7kbu4rzpewxravf3luuc4", + "7f1z4e8ch1dm5q677644cky6s", "7ffq3aq3so22iymfdzch63nys", "rrkmeuymz7gzvoz8mplikzdg", + "7hegc9covicy699bxsi81xkb8", "7gl7rpr1hjayk3e5ut0gr613o", "7g7d86i3738287xfvyfeffcwk", + "7hs4boe4hv80muawocevvx2j8", "7ijhsloieg4t9yp5cxp0duln8", "7ixaiiptli5ek32kuybuni4gk", + "7i5sfh41cjpwg4l972dm487x0", "eo7g4wunxxxr8uv45q8p5x638", "7dinds2937w4645wva2rddlas", + "7b5ukdhvqh62wtndeqfg01ixg", "7bjptsj24gndoydn7n0202g44", "7cqxf3vo58ewrwmoom5xiyexg", + "7bxjl9h2hnf165rlp3o1vfztg", "7eo8zrez08c342rqsezpvq39w", "7as1muhs98vdarlhsean4bspg", + "7dwhj8cfxv6v6bzxpu5e3h05w", "7d4vq4417ps84yjzh95bnvvv8", "7ea9z501jgp9kxw3gay4myrkk", + "7cd3401itlty6ded7c1wct0yc", "ebgpz9mcije2snv986n6587pw", "i7ar1dkhvcwpxmkyks65ib6c", + "lyek7tyy6qk2xjs9vblucnx0", "hdn9qtyn3ysjwbc3i2trantg", "3y2bnssfqlajosiz2gpkn6xhw", + "40pehd14s9djjtycujavbex3o", "3xnbfjznzmnwml20akbgnis5w", "2eovi2rcc2l4ha7fpb2w7e1hw", + "2bwuikdjyyuithhru8ka8o00k", "2d3pcd76ya9ihi9yotxc553is", "1e9it04z4epy2etdxsffe7m6s", + "7af49jgo4iulv1k8cplj9smj8", "5k3vrz619hdu9nx4rnx6uim1g", "amjppgpetnyr0iisi241kgkyc", + "coqrhq09kxd16iejvgtzj3mz8", "d8ysan1qdctmkvjaz2adw7aqc", "9ttciz0gtb0z09ev1q5fe0ro4", + "9u720o37yaddqu1w6hlszpnh0", "7ijezdjp8t0rjti91ac63hyxg", "72gvdvztbb3dn79jidzzxzcb8", + "6uof1v2s6vrpieeml2bwo9tlg", "91dd8ia3m0bxoqzjgyo3ptsk", "3tj1nt3udsbvb9soqn2cs6gpg", + "1br5g88o5idtjxka1fr6zg4k4", "akuesquthbmxlzckvnqmgles4" +] + +def run_sniper_backtest(): + print("🎯 SNIPER BACKTEST: SADECE NET OLANLAR") + print("="*60) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + placeholders = ','.join(['%s'] * len(MATCH_IDS)) + cur.execute(f""" + SELECT m.id, m.match_name, m.home_team_id, m.away_team_id, + m.score_home, m.score_away, + t1.name as home_team, t2.name as away_team, + l.name as league_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 + LEFT JOIN leagues l ON m.league_id = l.id + WHERE m.id IN ({placeholders}) AND m.status = 'FT' + """, MATCH_IDS) + + rows = cur.fetchall() + print(f"📊 Analiz edilecek {len(rows)} maç var.\n") + + try: + orchestrator = get_single_match_orchestrator() + except Exception as e: + print(f"❌ AI Hatası: {e}") + return + + total_bet = 0 + total_won = 0 + total_profit = 0.0 + + for i, row in enumerate(rows): + match_id = str(row['id']) + home = row['home_team'] or "?" + away = row['away_team'] or "?" + h_score = row['score_home'] or 0 + a_score = row['score_away'] or 0 + + print(f"[{i+1}/{len(rows)}] {home} vs {away} ... ", end="", flush=True) + + try: + pred = orchestrator.analyze_match(match_id) + if not pred: + print("⚠️ Veri Yok") + continue + + pick_data = pred.get("expert_recommendation", {}).get("main_pick") or pred.get("main_pick", {}) + pick = pick_data.get("pick") or pick_data.get("market_type") + conf = pick_data.get("confidence", 0) + odds = pick_data.get("odds", 0) + + # SNIPER FİLTRELERİ + if conf < 75: + print(f"🚫 PASS (Conf: {conf:.0f}%)") + continue + if odds < 1.35: + print(f"🚫 PASS (Odds: {odds:.2f} çok düşük)") + continue + + # Value Control + implied = 1.0 / odds + if (conf/100) < implied: + print(f"🚫 PASS (Negatif Value)") + continue + + # OYNA + total_bet += 1 + won = False + pick_clean = str(pick).upper() + + if pick_clean in ["1", "MS 1"] and h_score > a_score: won = True + elif pick_clean in ["X", "MS X"] and h_score == a_score: won = True + elif pick_clean in ["2", "MS 2"] and a_score > h_score: won = True + elif "ÜST" in pick_clean or "OVER" in pick_clean: + line = 2.5 + if "1.5" in pick_clean: line = 1.5 + elif "3.5" in pick_clean: line = 3.5 + if (h_score + a_score) > line: won = True + elif "ALT" in pick_clean or "UNDER" in pick_clean: + line = 2.5 + if "1.5" in pick_clean: line = 1.5 + elif "3.5" in pick_clean: line = 3.5 + if (h_score + a_score) < line: won = True + elif "VAR" in pick_clean and h_score > 0 and a_score > 0: won = True + elif "YOK" in pick_clean and (h_score == 0 or a_score == 0): won = True + + if won: + total_won += 1 + profit = odds - 1.0 + total_profit += profit + print(f"✅ WON! (+{profit:.2f})") + else: + total_profit -= 1.0 + print(f"❌ LOST! ({pick} @ {odds:.2f})") + + except Exception as e: + print(f"💥 Hata: {e}") + + print("\n" + "="*60) + print("🎯 SNIPER SONUÇLARI") + print("="*60) + print(f"Oynanan: {total_bet}") + print(f"Kazanılan: {total_won}") + print(f"Kazanma Oranı: %{(total_won/total_bet)*100:.1f}" if total_bet > 0 else "Kazanma Oranı: N/A") + print(f"Toplam Kâr: {total_profit:.2f} Units") + + if total_profit > 0: + print("🟢 PARA KAZANDIK!") + else: + print("🔴 PARA KAYBETTİK!") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_sniper_backtest() diff --git a/ai-engine/scripts/backtest_strict.py b/ai-engine/scripts/backtest_strict.py new file mode 100644 index 0000000..3985028 --- /dev/null +++ b/ai-engine/scripts/backtest_strict.py @@ -0,0 +1,162 @@ +""" +Strict Sniper Backtest (Calibrated) +=================================== +Sadece Güven > %75 ve Oran > 1.30 olan bahisleri oynar. +Modelin şişirilmiş özgüvenini elemek için yapıldı. +""" + +import os +import sys +import json +import time +import psycopg2 +from psycopg2.extras import RealDictCursor + +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +sys.path.insert(0, ROOT_DIR) +if "scripts" in os.path.basename(AI_DIR): + ROOT_DIR = os.path.dirname(ROOT_DIR) + +from services.single_match_orchestrator import get_single_match_orchestrator + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +def run_strict_backtest(): + print("🎯 STRICT SNIPER BACKTEST (Conf > 75%)") + print("="*60) + + leagues_path = os.path.join(ROOT_DIR, "top_leagues.json") + with open(leagues_path, 'r') as f: + top_leagues = json.load(f) + league_ids = tuple(str(lid) for lid in top_leagues) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + cur.execute(""" + SELECT m.id, m.match_name, m.home_team_id, m.away_team_id, + m.score_home, m.score_away, + t1.name as home_team, t2.name as away_team + 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.league_id IN %s + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id) + ORDER BY m.mst_utc DESC + LIMIT 500 + """, (league_ids,)) + + rows = cur.fetchall() + print(f"📊 {len(rows)} maç taranıyor. Sadece NET OLANLAR oynanacak...\n") + + try: orchestrator = get_single_match_orchestrator() + except Exception as e: + print(f"❌ AI Hatası: {e}") + return + + total_bet = 0 + total_won = 0 + total_profit = 0.0 + + for i, row in enumerate(rows): + match_id = str(row['id']) + home = row['home_team'] or "?" + away = row['away_team'] or "?" + h_score = row['score_home'] or 0 + a_score = row['score_away'] or 0 + + try: + pred = orchestrator.analyze_match(match_id) + if not pred: continue + + # Check all picks for a HIGH CONFIDENCE bet + candidates = [] + if pred.get("expert_recommendation"): + rec = pred["expert_recommendation"] + if rec.get("main_pick"): candidates.append(rec["main_pick"]) + if rec.get("value_picks"): candidates.extend(rec["value_picks"]) + elif pred.get("main_pick"): + candidates.append(pred["main_pick"]) + + best_bet = None + for c in candidates: + if not c: continue + # Access attributes safely (Dict or Object) + conf = c.get("confidence", 0) if isinstance(c, dict) else getattr(c, 'confidence', 0) + odds = c.get("odds", 0) if isinstance(c, dict) else getattr(c, 'odds', 0) + pick = c.get("pick", "") if isinstance(c, dict) else getattr(c, 'pick', "") + + # STRICT CRITERIA + if conf >= 75.0 and odds >= 1.30: + # Check Value (Edge) + implied = 1.0 / odds + edge = ((conf/100) - implied) * 100 + if edge > -5.0: # Tolerant edge + if best_bet is None or (conf > (best_bet.get("confidence", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'confidence', 0))): + best_bet = c + + if best_bet: + pick = str(best_bet.get("pick") if isinstance(best_bet, dict) else getattr(best_bet, 'pick', "")).upper() + conf = best_bet.get("confidence", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'confidence', 0) + odds = best_bet.get("odds", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'odds', 0) + + # Resolution + won = False + if pick in ["1", "MS 1"] and h_score > a_score: won = True + elif pick in ["X", "MS X"] and h_score == a_score: won = True + elif pick in ["2", "MS 2"] and a_score > h_score: won = True + elif pick in ["1X", "X2"]: + if "1X" in pick and h_score >= a_score: won = True + elif "X2" in pick and a_score >= h_score: won = True + elif "ÜST" in pick or "OVER" in pick: + line = 2.5 + if "1.5" in pick: line = 1.5 + elif "3.5" in pick: line = 3.5 + if (h_score + a_score) > line: won = True + elif "ALT" in pick or "UNDER" in pick: + line = 2.5 + if "1.5" in pick: line = 1.5 + elif "3.5" in pick: line = 3.5 + if (h_score + a_score) < line: won = True + elif "VAR" in pick and h_score > 0 and a_score > 0: won = True + elif "YOK" in pick and (h_score == 0 or a_score == 0): won = True + + total_bet += 1 + if won: + total_won += 1 + profit = odds - 1.0 + total_profit += profit + print(f"[{i+1}] ✅ {home} vs {away} | {pick} ({conf:.0f}%) -> WON (+{profit:.2f})") + else: + total_profit -= 1.0 + print(f"[{i+1}] ❌ {home} vs {away} | {pick} ({conf:.0f}%) -> LOST") + + except Exception as e: + pass + + print("\n" + "="*60) + print("🎯 STRICT SNIPER SONUÇLARI") + print("="*60) + print(f"Oynanan Bahis: {total_bet}") + print(f"Kazanılan: {total_won}") + + if total_bet > 0: + win_rate = (total_won / total_bet) * 100 + roi = (total_profit / total_bet) * 100 + print(f"Kazanma Oranı: %{win_rate:.2f}") + print(f"Toplam Kâr: {total_profit:.2f} Units") + if total_profit > 0: print("🟢 PARA KAZANDIK!") + else: print("🔴 PARA KAYBETTİK!") + else: + print("⚠️ Yeteri kadar NET maç bulunamadı.") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_strict_backtest() diff --git a/ai-engine/scripts/backtest_v2_runtime.py b/ai-engine/scripts/backtest_v2_runtime.py new file mode 100644 index 0000000..0b54e53 --- /dev/null +++ b/ai-engine/scripts/backtest_v2_runtime.py @@ -0,0 +1,230 @@ +""" +Backtest the live V2 predictor stack against recent finished football matches. + +This script uses the same path as production: +database -> feature extractor -> betting predictor -> quant ranking. +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from dataclasses import dataclass +from pathlib import Path + +from sqlalchemy import text + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from core.quant import MarketPick, analyze_market +from data.database import dispose_engine, get_session +from features.extractor import extract_features +from models.betting_engine import get_predictor + + +@dataclass +class BacktestStats: + sampled_matches: int = 0 + analyzed_matches: int = 0 + skipped_matches: int = 0 + ms_correct: int = 0 + ou25_correct: int = 0 + btts_correct: int = 0 + main_pick_count: int = 0 + main_pick_correct: int = 0 + playable_pick_count: int = 0 + playable_pick_correct: int = 0 + playable_units_staked: float = 0.0 + playable_units_profit: float = 0.0 + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--limit", type=int, default=50) + parser.add_argument("--days", type=int, default=45) + return parser.parse_args() + + +def _actual_ms(score_home: int, score_away: int) -> str: + if score_home > score_away: + return "1" + if score_home < score_away: + return "2" + return "X" + + +def _actual_ou25(score_home: int, score_away: int) -> str: + return "Over" if (score_home + score_away) > 2 else "Under" + + +def _actual_btts(score_home: int, score_away: int) -> str: + return "Yes" if score_home > 0 and score_away > 0 else "No" + + +def _odds_map_from_features(feats) -> dict[str, dict[str, float]]: + return { + "MS": {"1": feats.odds_home, "X": feats.odds_draw, "2": feats.odds_away}, + "OU25": {"Under": feats.odds_under25, "Over": feats.odds_over25}, + "BTTS": {"No": feats.odds_btts_no, "Yes": feats.odds_btts_yes}, + } + + +def _best_pick(feats, all_probs: dict[str, dict[str, float]]) -> MarketPick | None: + odds_map = _odds_map_from_features(feats) + picks = [ + analyze_market("MS", all_probs["MS"], odds_map["MS"], feats.data_quality_score), + analyze_market("OU25", all_probs["OU25"], odds_map["OU25"], feats.data_quality_score), + analyze_market("BTTS", all_probs["BTTS"], odds_map["BTTS"], feats.data_quality_score), + ] + ranked = sorted( + [pick for pick in picks if pick.pick], + key=lambda pick: pick.play_score, + reverse=True, + ) + return ranked[0] if ranked else None + + +def _pick_won(pick: MarketPick, actuals: dict[str, str]) -> bool: + return actuals.get(pick.market) == pick.pick + + +async def _load_match_rows(limit: int, days: int) -> list[dict[str, object]]: + min_mst_utc = days * 86400000 + query = text(""" + SELECT + m.id, + m.match_name, + m.score_home, + m.score_away, + m.mst_utc + FROM matches m + WHERE m.sport = 'football' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + AND m.mst_utc >= ( + EXTRACT(EPOCH FROM NOW()) * 1000 - :min_mst_utc + ) + AND EXISTS ( + SELECT 1 + FROM odd_categories oc + WHERE oc.match_id = m.id + AND oc.name IN ('Maç Sonucu', '2,5 Alt/Üst', 'Karşılıklı Gol') + ) + ORDER BY m.mst_utc DESC + LIMIT :limit + """) + async with get_session() as session: + result = await session.execute( + query, + {"limit": limit, "min_mst_utc": min_mst_utc}, + ) + rows = result.mappings().all() + return [dict(row) for row in rows] + + +async def _run(limit: int, days: int) -> BacktestStats: + stats = BacktestStats() + predictor = get_predictor() + rows = await _load_match_rows(limit, days) + stats.sampled_matches = len(rows) + + async with get_session() as session: + for row in rows: + match_id = str(row["id"]) + score_home = int(row["score_home"]) + score_away = int(row["score_away"]) + feats = await extract_features(session, match_id) + + if feats is None: + stats.skipped_matches += 1 + continue + + if feats.data_quality_score <= 0.0: + stats.skipped_matches += 1 + continue + + all_probs = predictor.predict_all(feats.to_model_array(), feats) + stats.analyzed_matches += 1 + + actuals = { + "MS": _actual_ms(score_home, score_away), + "OU25": _actual_ou25(score_home, score_away), + "BTTS": _actual_btts(score_home, score_away), + } + + if max(all_probs["MS"], key=all_probs["MS"].get) == actuals["MS"]: + stats.ms_correct += 1 + if max(all_probs["OU25"], key=all_probs["OU25"].get) == actuals["OU25"]: + stats.ou25_correct += 1 + if max(all_probs["BTTS"], key=all_probs["BTTS"].get) == actuals["BTTS"]: + stats.btts_correct += 1 + + best_pick = _best_pick(feats, all_probs) + if best_pick is None: + continue + + stats.main_pick_count += 1 + if _pick_won(best_pick, actuals): + stats.main_pick_correct += 1 + + if best_pick.playable: + stats.playable_pick_count += 1 + stats.playable_units_staked += best_pick.stake_units + if _pick_won(best_pick, actuals): + stats.playable_pick_correct += 1 + stats.playable_units_profit += best_pick.stake_units * (best_pick.odds - 1.0) + else: + stats.playable_units_profit -= best_pick.stake_units + + return stats + + +def _pct(numerator: int, denominator: int) -> float: + if denominator <= 0: + return 0.0 + return round((numerator / denominator) * 100.0, 2) + + +def _roi(profit: float, staked: float) -> float: + if staked <= 0: + return 0.0 + return round((profit / staked) * 100.0, 2) + + +def _print_summary(stats: BacktestStats) -> None: + print("=== V2 Runtime Backtest ===") + print(f"Sampled matches : {stats.sampled_matches}") + print(f"Analyzed matches : {stats.analyzed_matches}") + print(f"Skipped matches : {stats.skipped_matches}") + print(f"MS accuracy : {_pct(stats.ms_correct, stats.analyzed_matches)}%") + print(f"OU2.5 accuracy : {_pct(stats.ou25_correct, stats.analyzed_matches)}%") + print(f"BTTS accuracy : {_pct(stats.btts_correct, stats.analyzed_matches)}%") + print( + "Main pick accuracy : " + f"{_pct(stats.main_pick_correct, stats.main_pick_count)}% " + f"({stats.main_pick_correct}/{stats.main_pick_count})" + ) + print( + "Playable accuracy : " + f"{_pct(stats.playable_pick_correct, stats.playable_pick_count)}% " + f"({stats.playable_pick_correct}/{stats.playable_pick_count})" + ) + print(f"Units staked : {stats.playable_units_staked:.2f}") + print(f"Units profit : {stats.playable_units_profit:.2f}") + print(f"ROI : {_roi(stats.playable_units_profit, stats.playable_units_staked)}%") + + +async def _main() -> None: + args = _parse_args() + try: + stats = await _run(args.limit, args.days) + _print_summary(stats) + finally: + await dispose_engine() + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/ai-engine/scripts/backtest_value_hunter.py b/ai-engine/scripts/backtest_value_hunter.py new file mode 100644 index 0000000..3f40c66 --- /dev/null +++ b/ai-engine/scripts/backtest_value_hunter.py @@ -0,0 +1,147 @@ +""" +Value Hunter Backtest +===================== +Sadece modelin büroyu yendiği (Pozitif Edge) maçları oynar. +""" + +import os, sys, json, time, psycopg2 +from psycopg2.extras import RealDictCursor + +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +sys.path.insert(0, ROOT_DIR) +if "scripts" in os.path.basename(AI_DIR): ROOT_DIR = os.path.dirname(ROOT_DIR) +from services.single_match_orchestrator import get_single_match_orchestrator + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +MATCH_IDS = [ + "v2ljcst50nk37x04xwimpi50", "7gz0bhb5yvdssazl3y5946kno", "7ftj7kbu4rzpewxravf3luuc4", + "7f1z4e8ch1dm5q677644cky6s", "7ffq3aq3so22iymfdzch63nys", "rrkmeuymz7gzvoz8mplikzdg", + "7hegc9covicy699bxsi81xkb8", "7gl7rpr1hjayk3e5ut0gr613o", "7g7d86i3738287xfvyfeffcwk", + "7hs4boe4hv80muawocevvx2j8", "7ijhsloieg4t9yp5cxp0duln8", "7ixaiiptli5ek32kuybuni4gk", + "7i5sfh41cjpwg4l972dm487x0", "eo7g4wunxxxr8uv45q8p5x638", "7dinds2937w4645wva2rddlas", + "7b5ukdhvqh62wtndeqfg01ixg", "7bjptsj24gndoydn7n0202g44", "7cqxf3vo58ewrwmoom5xiyexg", + "7bxjl9h2hnf165rlp3o1vfztg", "7eo8zrez08c342rqsezpvq39w", "7as1muhs98vdarlhsean4bspg", + "7dwhj8cfxv6v6bzxpu5e3h05w", "7d4vq4417ps84yjzh95bnvvv8", "7ea9z501jgp9kxw3gay4myrkk", + "7cd3401itlty6ded7c1wct0yc", "ebgpz9mcije2snv986n6587pw", "i7ar1dkhvcwpxmkyks65ib6c", + "lyek7tyy6qk2xjs9vblucnx0", "hdn9qtyn3ysjwbc3i2trantg", "3y2bnssfqlajosiz2gpkn6xhw", + "40pehd14s9djjtycujavbex3o", "3xnbfjznzmnwml20akbgnis5w", "2eovi2rcc2l4ha7fpb2w7e1hw", + "2bwuikdjyyuithhru8ka8o00k", "2d3pcd76ya9ihi9yotxc553is", "1e9it04z4epy2etdxsffe7m6s", + "7af49jgo4iulv1k8cplj9smj8", "5k3vrz619hdu9nx4rnx6uim1g", "amjppgpetnyr0iisi241kgkyc", + "coqrhq09kxd16iejvgtzj3mz8", "d8ysan1qdctmkvjaz2adw7aqc", "9ttciz0gtb0z09ev1q5fe0ro4", + "9u720o37yaddqu1w6hlszpnh0", "7ijezdjp8t0rjti91ac63hyxg", "72gvdvztbb3dn79jidzzxzcb8", + "6uof1v2s6vrpieeml2bwo9tlg", "91dd8ia3m0bxoqzjgyo3ptsk", "3tj1nt3udsbvb9soqn2cs6gpg", + "1br5g88o5idtjxka1fr6zg4k4", "akuesquthbmxlzckvnqmgles4" +] + +def run_value_hunter(): + print("💎 VALUE HUNTER: SADECE HATALI ORANLARI YAKALA") + print("="*60) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + placeholders = ','.join(['%s'] * len(MATCH_IDS)) + cur.execute(f""" + SELECT m.id, m.match_name, m.home_team_id, m.away_team_id, + m.score_home, m.score_away, + t1.name as home_team, t2.name as away_team + 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.id IN ({placeholders}) AND m.status = 'FT' + """, MATCH_IDS) + + rows = cur.fetchall() + print(f"📊 {len(rows)} maç taranıyor...\n") + + try: orchestrator = get_single_match_orchestrator() + except Exception as e: + print(f"❌ AI Hatası: {e}") + return + + total_bet = 0 + total_won = 0 + total_profit = 0.0 + total_edge_found = 0 + + for i, row in enumerate(rows): + match_id = str(row['id']) + home = row['home_team'] or "?" + away = row['away_team'] or "?" + h_score = row['score_home'] or 0 + a_score = row['score_away'] or 0 + + try: + pred = orchestrator.analyze_match(match_id) + if not pred: continue + + # Tüm önerileri kontrol et + picks = pred.get("expert_recommendation", {}).get("value_picks", []) + if not picks: picks = [pred.get("expert_recommendation", {}).get("main_pick")] + + played_this_match = False + + for pick_data in picks: + if not pick_data: continue + pick = pick_data.get("pick") + conf = pick_data.get("confidence", 0) + odds = pick_data.get("odds", 0) + edge = pick_data.get("edge", 0) + + # VALUE KURALI: Model bürodan en az %10 daha iyi olmalı + if edge < 10: continue + if odds < 1.20: continue + + total_bet += 1 + total_edge_found += edge + won = False + pick_clean = str(pick).upper() + + if pick_clean in ["1", "MS 1"] and h_score > a_score: won = True + elif pick_clean in ["X", "MS X"] and h_score == a_score: won = True + elif pick_clean in ["2", "MS 2"] and a_score > h_score: won = True + elif "ÜST" in pick_clean or "OVER" in pick_clean: + line = 2.5 + if "1.5" in pick_clean: line = 1.5 + if (h_score + a_score) > line: won = True + elif "ALT" in pick_clean or "UNDER" in pick_clean: + line = 2.5 + if "1.5" in pick_clean: line = 1.5 + if (h_score + a_score) < line: won = True + elif "VAR" in pick_clean and h_score > 0 and a_score > 0: won = True + elif "YOK" in pick_clean and (h_score == 0 or a_score == 0): won = True + + if won: + total_won += 1 + profit = odds - 1.0 + total_profit += profit + print(f"[{i+1}] ✅ {home} vs {away} | {pick} ({edge:.0f}% Edge) -> WON! (+{profit:.2f})") + else: + total_profit -= 1.0 + print(f"[{i+1}] ❌ {home} vs {away} | {pick} ({edge:.0f}% Edge) -> LOST") + + played_this_match = True + break # Maç başına tek bahis + + except Exception: pass + + print("\n" + "="*60) + print("💎 VALUE HUNTER SONUÇLARI") + print("="*60) + print(f"Toplam Value Bulunan Bahis: {total_bet}") + print(f"Ortalama Edge: {total_edge_found/total_bet:.1f}%" if total_bet > 0 else "N/A") + print(f"Kazanılan: {total_won}") + print(f"Toplam Kâr: {total_profit:.2f} Units") + + if total_profit > 0: print("🟢 PARA KAZANDIK!") + else: print("🔴 PARA KAYBETTİK!") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_value_hunter() diff --git a/ai-engine/scripts/backtest_value_sniper.py b/ai-engine/scripts/backtest_value_sniper.py new file mode 100644 index 0000000..9c65b45 --- /dev/null +++ b/ai-engine/scripts/backtest_value_sniper.py @@ -0,0 +1,153 @@ +""" +Value Sniper Backtest (High Odds) +================================= +Sadece Oran > 1.50 ve Güven > %70 olan bahisleri oynar. +""" + +import os +import sys +import json +import time +import psycopg2 +from psycopg2.extras import RealDictCursor + +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +sys.path.insert(0, ROOT_DIR) +if "scripts" in os.path.basename(AI_DIR): + ROOT_DIR = os.path.dirname(ROOT_DIR) + +from services.single_match_orchestrator import get_single_match_orchestrator + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +def run_value_sniper(): + print("💰 VALUE SNIPER BACKTEST (Odds > 1.50)") + print("="*60) + + leagues_path = os.path.join(ROOT_DIR, "top_leagues.json") + with open(leagues_path, 'r') as f: + top_leagues = json.load(f) + league_ids = tuple(str(lid) for lid in top_leagues) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + cur.execute(""" + SELECT m.id, m.match_name, m.home_team_id, m.away_team_id, + m.score_home, m.score_away, + t1.name as home_team, t2.name as away_team + 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.league_id IN %s + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id) + ORDER BY m.mst_utc DESC + LIMIT 500 + """, (league_ids,)) + + rows = cur.fetchall() + print(f"📊 {len(rows)} maç taranıyor...\n") + + try: orchestrator = get_single_match_orchestrator() + except Exception as e: + print(f"❌ AI Hatası: {e}") + return + + total_bet = 0 + total_won = 0 + total_profit = 0.0 + + for i, row in enumerate(rows): + match_id = str(row['id']) + home = row['home_team'] or "?" + away = row['away_team'] or "?" + h_score = row['score_home'] or 0 + a_score = row['score_away'] or 0 + + try: + pred = orchestrator.analyze_match(match_id) + if not pred: continue + + candidates = [] + if pred.get("expert_recommendation"): + rec = pred["expert_recommendation"] + if rec.get("main_pick"): candidates.append(rec["main_pick"]) + if rec.get("value_picks"): candidates.extend(rec["value_picks"]) + elif pred.get("main_pick"): + candidates.append(pred["main_pick"]) + + best_bet = None + for c in candidates: + if not c: continue + conf = c.get("confidence", 0) if isinstance(c, dict) else getattr(c, 'confidence', 0) + odds = c.get("odds", 0) if isinstance(c, dict) else getattr(c, 'odds', 0) + + # VALUE CRITERIA: Odds > 1.50 AND Conf > 70% + if conf >= 70.0 and odds >= 1.50: + # Check Edge + implied = 1.0 / odds + edge = ((conf/100) - implied) * 100 + if edge > 0: # Must be positive value + if best_bet is None or (conf > (best_bet.get("confidence", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'confidence', 0))): + best_bet = c + + if best_bet: + pick = str(best_bet.get("pick") if isinstance(best_bet, dict) else getattr(best_bet, 'pick', "")).upper() + conf = best_bet.get("confidence", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'confidence', 0) + odds = best_bet.get("odds", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'odds', 0) + + won = False + if pick in ["1", "MS 1"] and h_score > a_score: won = True + elif pick in ["X", "MS X"] and h_score == a_score: won = True + elif pick in ["2", "MS 2"] and a_score > h_score: won = True + elif "ÜST" in pick or "OVER" in pick: + line = 2.5 + if "1.5" in pick: line = 1.5 + elif "3.5" in pick: line = 3.5 + if (h_score + a_score) > line: won = True + elif "ALT" in pick or "UNDER" in pick: + line = 2.5 + if "1.5" in pick: line = 1.5 + elif "3.5" in pick: line = 3.5 + if (h_score + a_score) < line: won = True + elif "VAR" in pick and h_score > 0 and a_score > 0: won = True + elif "YOK" in pick and (h_score == 0 or a_score == 0): won = True + + total_bet += 1 + if won: + total_won += 1 + profit = odds - 1.0 + total_profit += profit + print(f"[{i+1}] ✅ {home} vs {away} | {pick} ({odds:.2f}) -> WON (+{profit:.2f})") + else: + total_profit -= 1.0 + print(f"[{i+1}] ❌ {home} vs {away} | {pick} ({odds:.2f}) -> LOST") + + except: pass + + print("\n" + "="*60) + print("💰 VALUE SNIPER SONUÇLARI") + print("="*60) + print(f"Oynanan Bahis: {total_bet}") + print(f"Kazanılan: {total_won}") + + if total_bet > 0: + win_rate = (total_won / total_bet) * 100 + roi = (total_profit / total_bet) * 100 + print(f"Kazanma Oranı: %{win_rate:.2f}") + print(f"Toplam Kâr: {total_profit:.2f} Units") + if total_profit > 0: print("🟢 PARA KAZANDIK!") + else: print("🔴 PARA KAYBETTİK!") + else: + print("⚠️ Yeterli VALUE bulunamadı.") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_value_sniper() diff --git a/ai-engine/scripts/backtest_vqwen.py b/ai-engine/scripts/backtest_vqwen.py new file mode 100644 index 0000000..e9f362c --- /dev/null +++ b/ai-engine/scripts/backtest_vqwen.py @@ -0,0 +1,136 @@ +""" +VQWEN Full Backtest +=================== +Tests all 3 VQWEN models (MS, OU25, BTTS) on 1000 historical matches. +""" + +import os +import sys +import json +import pickle +import pandas as pd +import numpy as np +import psycopg2 +from psycopg2.extras import RealDictCursor + +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +PROJECT_ROOT = os.path.dirname(ROOT_DIR) + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +def run_vqwen_backtest(): + print("🧠 VQWEN FULL BACKTEST") + print("="*60) + + # Load Models + mdir = os.path.join(ROOT_DIR, 'models', 'vqwen') + try: + with open(os.path.join(mdir, 'vqwen_ms.pkl'), 'rb') as f: model_ms = pickle.load(f) + with open(os.path.join(mdir, 'vqwen_ou25.pkl'), 'rb') as f: model_ou = pickle.load(f) + with open(os.path.join(mdir, 'vqwen_btts.pkl'), 'rb') as f: model_btts = pickle.load(f) + print("✅ VQWEN MS, OU25, BTTS modelleri yüklendi.") + except Exception as e: + print(f"❌ Model hatası: {e}") + return + + with open(os.path.join(PROJECT_ROOT, "top_leagues.json"), 'r') as f: + league_ids = tuple(str(lid) for lid in json.load(f)) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + cur.execute(""" + SELECT m.id, m.home_team_id, m.away_team_id, m.score_home, m.score_away, + t1.name as home_team, t2.name as away_team, + (SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '1' LIMIT 1) as oh, + (SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = 'X' LIMIT 1) as od, + (SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '2' LIMIT 1) as oa, + COALESCE((SELECT AVG(CASE WHEN m2.home_team_id = m.home_team_id AND m2.score_home > m2.score_away THEN 3 WHEN m2.home_team_id = m.home_team_id AND m2.score_home = m2.score_away THEN 1 ELSE 0 END) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc LIMIT 5), 0) as h_form, + COALESCE((SELECT AVG(CASE WHEN m2.away_team_id = m.away_team_id AND m2.score_away > m2.score_home THEN 3 WHEN m2.away_team_id = m.away_team_id AND m2.score_away = m2.score_home THEN 1 ELSE 0 END) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc LIMIT 5), 0) as a_form, + COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as h_sc, + COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.home_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as h_co, + COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as a_sc, + COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.away_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as a_co + 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.league_id IN %s AND m.status = 'FT' AND m.score_home IS NOT NULL + ORDER BY m.mst_utc DESC + LIMIT 1000 + """, (league_ids,)) + + rows = cur.fetchall() + print(f"📊 {len(rows)} maç analiz ediliyor...") + + results = {'ms': {'bet': 0, 'won': 0, 'profit': 0}, 'ou25': {'bet': 0, 'won': 0, 'profit': 0}, 'btts': {'bet': 0, 'won': 0, 'profit': 0}} + + for row in rows: + oh, od, oa = float(row['oh'] or 0), float(row['od'] or 0), float(row['oa'] or 0) + if oh <= 1.0 or od <= 1.0 or oa <= 1.0: continue + + h_xg = (float(row['h_sc'] or 1.2) + float(row['a_co'] or 1.2)) / 2 + a_xg = (float(row['a_sc'] or 1.2) + float(row['h_co'] or 1.2)) / 2 + h_p = (float(row['h_form'] or 0)*10) + (float(row['h_sc'] or 1.2)*5) - (float(row['h_co'] or 1.2)*5) + a_p = (float(row['a_form'] or 0)*10) + (float(row['a_sc'] or 1.2)*5) - (float(row['a_co'] or 1.2)*5) + + margin = (1/oh) + (1/od) + (1/oa) + + # MS Prediction + f_ms = pd.DataFrame([{'h_form': float(row['h_form']), 'a_form': float(row['a_form']), 'h_xg': h_xg, 'a_xg': a_xg, + 'pow_diff': h_p - a_p, 'imp_h': (1/oh)/margin, 'imp_d': (1/od)/margin, 'imp_a': (1/oa)/margin, + 'h_sot': 4.0, 'a_sot': 3.0}]) + ms_probs = model_ms.predict(f_ms)[0] + + # MS Value Bet + for i, (pick, prob, odd) in enumerate(zip(['1', 'X', '2'], ms_probs, [oh, od, oa])): + if odd <= 1.0: continue + edge = prob - (1/odd) + if edge > 0.05 and prob > 0.50: # Value ve Güven + results['ms']['bet'] += 1 + h, a = row['score_home'], row['score_away'] + w = (pick=='1' and h>a) or (pick=='X' and h==a) or (pick=='2' and a>h) + if w: results['ms']['won'] += 1; results['ms']['profit'] += (odd - 1.0) + else: results['ms']['profit'] -= 1.0 + break + + # OU2.5 Prediction + f_ou = pd.DataFrame([{'h_xg': h_xg, 'a_xg': a_xg, 'total_xg': h_xg+a_xg, 'h_sot': 4.0, 'a_sot': 3.0}]) + p_over = model_ou.predict(f_ou)[0] + + # OU2.5 Value Bet + if p_over > 0.55 and oh > 1.0: # Sadece örnek olarak over > %55 ise + results['ou25']['bet'] += 1 + if (row['score_home'] + row['score_away']) > 2.5: results['ou25']['won'] += 1; results['ou25']['profit'] += 0.85 # Ortalama oran + else: results['ou25']['profit'] -= 1.0 + + # BTTS Prediction + f_btts = pd.DataFrame([{'h_xg': h_xg, 'a_xg': a_xg, 'h_sc': float(row['h_sc']), 'a_sc': float(row['a_sc'])}]) + p_btts = model_btts.predict(f_btts)[0] + + # BTTS Value Bet + if p_btts > 0.55: + results['btts']['bet'] += 1 + if row['score_home'] > 0 and row['score_away'] > 0: results['btts']['won'] += 1; results['btts']['profit'] += 0.85 + else: results['btts']['profit'] -= 1.0 + + print("\n" + "="*60) + print("📊 VQWEN PAZAR BAZLI SONUÇLAR") + print("="*60) + for mkt in ['ms', 'ou25', 'btts']: + r = results[mkt] + wr = (r['won'] / r['bet'] * 100) if r['bet'] > 0 else 0 + print(f"{mkt.upper():<10} Oynanan: {r['bet']:<5} Kazanılan: {r['won']:<5} WR: {wr:.1f}% Kâr: {r['profit']:+.2f} Units") + + total_profit = sum(r['profit'] for r in results.values()) + print(f"\n💰 TOPLAM KÂR: {total_profit:+.2f} Units") + if total_profit > 0: print("🟢 PARA KAZANDIK!") + else: print("🔴 ZARARDA") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_vqwen_backtest() diff --git a/ai-engine/scripts/backtest_vqwen_deep.py b/ai-engine/scripts/backtest_vqwen_deep.py new file mode 100644 index 0000000..070ee1b --- /dev/null +++ b/ai-engine/scripts/backtest_vqwen_deep.py @@ -0,0 +1,141 @@ +""" +VQWEN Deep Backtest +=================== +Tests the NEW Deep model with player & card data. +""" + +import os +import sys +import json +import pickle +import pandas as pd +import numpy as np +import psycopg2 +from psycopg2.extras import RealDictCursor + +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +PROJECT_ROOT = os.path.dirname(ROOT_DIR) + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +def run_vqwen_deep_backtest(): + print("🧠 VQWEN DEEP BACKTEST") + print("="*60) + + # Load Models + mdir = os.path.join(ROOT_DIR, 'models', 'vqwen') + try: + with open(os.path.join(mdir, 'vqwen_ms.pkl'), 'rb') as f: model_ms = pickle.load(f) + with open(os.path.join(mdir, 'vqwen_ou25.pkl'), 'rb') as f: model_ou = pickle.load(f) + with open(os.path.join(mdir, 'vqwen_btts.pkl'), 'rb') as f: model_btts = pickle.load(f) + print("✅ VQWEN Deep modelleri yüklendi.") + except Exception as e: + print(f"❌ Model hatası: {e}") + return + + with open(os.path.join(PROJECT_ROOT, "top_leagues.json"), 'r') as f: + league_ids = tuple(str(lid) for lid in json.load(f)) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + cur.execute(""" + SELECT m.id, m.home_team_id, m.away_team_id, m.score_home, m.score_away, + t1.name as home_team, t2.name as away_team, + (SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '1' LIMIT 1) as oh, + (SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = 'X' LIMIT 1) as od, + (SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '2' LIMIT 1) as oa, + COALESCE((SELECT AVG(CASE WHEN m2.home_team_id = m.home_team_id AND m2.score_home > m2.score_away THEN 3 WHEN m2.home_team_id = m.home_team_id AND m2.score_home = m2.score_away THEN 1 ELSE 0 END) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc LIMIT 5), 0) as h_form, + COALESCE((SELECT AVG(CASE WHEN m2.away_team_id = m.away_team_id AND m2.score_away > m2.score_home THEN 3 WHEN m2.away_team_id = m.away_team_id AND m2.score_away = m2.score_home THEN 1 ELSE 0 END) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc LIMIT 5), 0) as a_form, + COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as h_sc, + COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.home_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as h_co, + COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as a_sc, + COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.away_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as a_co, + COALESCE((SELECT COUNT(*) FROM match_player_participation mp WHERE mp.match_id = m.id AND mp.team_id = m.home_team_id AND mp.is_starting = true), 0) as h_xi, + COALESCE((SELECT COUNT(*) FROM match_player_participation mp WHERE mp.match_id = m.id AND mp.team_id = m.away_team_id AND mp.is_starting = true), 0) as a_xi, + COALESCE((SELECT COUNT(*) FROM match_player_events mpe WHERE mpe.match_id = m.id AND mpe.event_type = 'card'), 0) as cards + 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.league_id IN %s AND m.status = 'FT' AND m.score_home IS NOT NULL + ORDER BY m.mst_utc DESC + LIMIT 1000 + """, (league_ids,)) + + rows = cur.fetchall() + print(f"📊 {len(rows)} maç analiz ediliyor...") + + results = {'ms': {'bet': 0, 'won': 0, 'profit': 0}, 'ou25': {'bet': 0, 'won': 0, 'profit': 0}, 'btts': {'bet': 0, 'won': 0, 'profit': 0}} + + for row in rows: + oh = float(row['oh'] or 0) + od = float(row['od'] or 0) + oa = float(row['oa'] or 0) + if oh <= 1.0 or od <= 1.0 or oa <= 1.0: continue + + h_xg = (float(row['h_sc'] or 1.2) + float(row['a_co'] or 1.2)) / 2 + a_xg = (float(row['a_sc'] or 1.2) + float(row['h_co'] or 1.2)) / 2 + h_p = (float(row['h_form'] or 0)*10) + (float(row['h_sc'] or 1.2)*5) - (float(row['h_co'] or 1.2)*5) + a_p = (float(row['a_form'] or 0)*10) + (float(row['a_sc'] or 1.2)*5) - (float(row['a_co'] or 1.2)*5) + + margin = (1/oh) + (1/od) + (1/oa) + h_sot, a_sot = 4.0, 3.0 + + # Features + f = pd.DataFrame([{ + 'h_form': float(row['h_form']), 'a_form': float(row['a_form']), + 'h_xg': h_xg, 'a_xg': a_xg, 'pow_diff': h_p - a_p, + 'imp_h': (1/oh)/margin, 'imp_d': (1/od)/margin, 'imp_a': (1/oa)/margin, + 'h_sot': h_sot, 'a_sot': a_sot, + 'h_xi': float(row['h_xi']), 'a_xi': float(row['a_xi']), + 'xi_diff': float(row['h_xi'] - row['a_xi']), + 'cards': float(row['cards']) + }]) + + # MS + ms_probs = model_ms.predict(f)[0] + for i, (pick, prob, odd) in enumerate(zip(['1', 'X', '2'], ms_probs, [oh, od, oa])): + if odd <= 1.0: continue + edge = prob - (1/odd) + if edge > 0.05 and prob > 0.50: + results['ms']['bet'] += 1 + h, a = row['score_home'], row['score_away'] + w = (pick=='1' and h>a) or (pick=='X' and h==a) or (pick=='2' and a>h) + if w: results['ms']['won'] += 1; results['ms']['profit'] += (odd - 1.0) + else: results['ms']['profit'] -= 1.0 + break + + # OU2.5 + p_over = float(model_ou.predict(f)[0]) + if p_over > 0.55: + results['ou25']['bet'] += 1 + if (row['score_home'] + row['score_away']) > 2.5: results['ou25']['won'] += 1; results['ou25']['profit'] += 0.85 + else: results['ou25']['profit'] -= 1.0 + + # BTTS + p_btts = float(model_btts.predict(f)[0]) + if p_btts > 0.55: + results['btts']['bet'] += 1 + if row['score_home'] > 0 and row['score_away'] > 0: results['btts']['won'] += 1; results['btts']['profit'] += 0.85 + else: results['btts']['profit'] -= 1.0 + + print("\n" + "="*60) + print("📊 VQWEN DEEP SONUÇLAR") + print("="*60) + for mkt in ['ms', 'ou25', 'btts']: + r = results[mkt] + wr = (r['won'] / r['bet'] * 100) if r['bet'] > 0 else 0 + print(f"{mkt.upper():<10} Oyn: {r['bet']:<5} Kaz: {r['won']:<5} WR: {wr:.1f}% Kâr: {r['profit']:+.2f}") + + total = sum(r['profit'] for r in results.values()) + print(f"\n💰 TOPLAM: {total:+.2f} Units") + print("🟢 PARA KAZANDIK!" if total > 0 else "🔴 ZARARDA") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_vqwen_deep_backtest() diff --git a/ai-engine/scripts/backtest_vqwen_final.py b/ai-engine/scripts/backtest_vqwen_final.py new file mode 100644 index 0000000..262071b --- /dev/null +++ b/ai-engine/scripts/backtest_vqwen_final.py @@ -0,0 +1,159 @@ +""" +VQWEN Final Backtest +==================== +Tests the Final Model (ELO + Rest + Context). +""" + +import os +import sys +import json +import pickle +import pandas as pd +import numpy as np +import psycopg2 +from psycopg2.extras import RealDictCursor + +AI_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(AI_DIR) +PROJECT_ROOT = os.path.dirname(ROOT_DIR) + +def get_clean_dsn() -> str: + return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db" + +def run_final_backtest(): + print("🧠 VQWEN FINAL BACKTEST (ELO + REST)") + print("="*60) + + # Load Models + mdir = os.path.join(ROOT_DIR, 'models', 'vqwen') + try: + with open(os.path.join(mdir, 'vqwen_ms.pkl'), 'rb') as f: model_ms = pickle.load(f) + with open(os.path.join(mdir, 'vqwen_ou25.pkl'), 'rb') as f: model_ou = pickle.load(f) + with open(os.path.join(mdir, 'vqwen_btts.pkl'), 'rb') as f: model_btts = pickle.load(f) + print("✅ VQWEN Final modelleri yüklendi.") + except Exception as e: + print(f"❌ Model hatası: {e}") + return + + with open(os.path.join(PROJECT_ROOT, "top_leagues.json"), 'r') as f: + league_ids = tuple(str(lid) for lid in json.load(f)) + + dsn = get_clean_dsn() + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + cur.execute(""" + SELECT m.id, m.home_team_id, m.away_team_id, m.score_home, m.score_away, + m.mst_utc, + t1.name as home_team, t2.name as away_team, + maf.home_elo, maf.away_elo, + COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc), 1.2) as h_home_goals, + COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc), 1.2) as a_away_goals, + COALESCE(EXTRACT(EPOCH FROM (to_timestamp(m.mst_utc/1000) - (SELECT MAX(to_timestamp(m2.mst_utc/1000)) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc)) / 86400), 7) as h_rest, + COALESCE(EXTRACT(EPOCH FROM (to_timestamp(m.mst_utc/1000) - (SELECT MAX(to_timestamp(m2.mst_utc/1000)) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc)) / 86400), 7) as a_rest, + COALESCE((SELECT COUNT(*) FROM match_player_participation mp WHERE mp.match_id = m.id AND mp.team_id = m.home_team_id AND mp.is_starting = true), 11) as h_xi, + COALESCE((SELECT COUNT(*) FROM match_player_participation mp WHERE mp.match_id = m.id AND mp.team_id = m.away_team_id AND mp.is_starting = true), 11) as a_xi, + COALESCE((SELECT COUNT(*) FROM match_player_events mpe WHERE mpe.match_id = m.id AND mpe.event_type = 'card'), 4) as cards, + (SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '1' LIMIT 1) as oh, + (SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = 'X' LIMIT 1) as od, + (SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '2' LIMIT 1) as oa + 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 + LEFT JOIN football_ai_features maf ON maf.match_id = m.id + WHERE m.league_id IN %s AND m.status = 'FT' AND m.score_home IS NOT NULL + ORDER BY m.mst_utc DESC + LIMIT 1000 + """, (league_ids,)) + + rows = cur.fetchall() + print(f"📊 {len(rows)} maç analiz ediliyor...") + + results = {'ms': {'bet': 0, 'won': 0, 'profit': 0}, 'ou25': {'bet': 0, 'won': 0, 'profit': 0}, 'btts': {'bet': 0, 'won': 0, 'profit': 0}} + + for row in rows: + oh = float(row['oh'] or 0) + od = float(row['od'] or 0) + oa = float(row['oa'] or 0) + if oh <= 1.0 or od <= 1.0 or oa <= 1.0: continue + + # Features + h_elo = float(row['home_elo'] or 1500) + a_elo = float(row['away_elo'] or 1500) + h_home_goals = float(row['h_home_goals'] or 1.2) + a_away_goals = float(row['a_away_goals'] or 1.2) + h_rest = float(row['h_rest'] or 7) + a_rest = float(row['a_rest'] or 7) + h_xi = float(row['h_xi'] or 11) + a_xi = float(row['a_xi'] or 11) + cards = float(row['cards'] or 4) + + def fatigue(rest): + if rest < 3: return 0.85 + if rest < 5: return 0.95 + return 1.0 + + h_fat = fatigue(h_rest) + a_fat = fatigue(a_rest) + + h_xg = h_home_goals * h_fat + a_xg = a_away_goals * a_fat + total_xg = h_xg + a_xg + + margin = (1/oh) + (1/od) + (1/oa) + f = pd.DataFrame([{ + 'elo_diff': h_elo - a_elo, + 'h_xg': h_xg, 'a_xg': a_xg, + 'total_xg': total_xg, + 'pow_diff': (h_elo/100)*h_fat - (a_elo/100)*a_fat, + 'rest_diff': h_rest - a_rest, + 'h_fatigue': h_fat, 'a_fatigue': a_fat, + 'imp_h': (1/oh)/margin, 'imp_d': (1/od)/margin, 'imp_a': (1/oa)/margin, + 'h_xi': h_xi, 'a_xi': a_xi, + 'cards': cards + }]) + + # MS + ms_probs = model_ms.predict(f)[0] + for i, (pick, prob, odd) in enumerate(zip(['1', 'X', '2'], ms_probs, [oh, od, oa])): + if odd <= 1.0: continue + edge = prob - (1/odd) + if edge > 0.05 and prob > 0.45: + results['ms']['bet'] += 1 + h, a = row['score_home'], row['score_away'] + w = (pick=='1' and h>a) or (pick=='X' and h==a) or (pick=='2' and a>h) + if w: results['ms']['won'] += 1; results['ms']['profit'] += (odd - 1.0) + else: results['ms']['profit'] -= 1.0 + break + + # OU2.5 + p_over = float(model_ou.predict(f)[0]) + if p_over > 0.55: + results['ou25']['bet'] += 1 + if (row['score_home'] + row['score_away']) > 2.5: results['ou25']['won'] += 1; results['ou25']['profit'] += 0.85 + else: results['ou25']['profit'] -= 1.0 + + # BTTS + p_btts = float(model_btts.predict(f)[0]) + if p_btts > 0.55: + results['btts']['bet'] += 1 + if row['score_home'] > 0 and row['score_away'] > 0: results['btts']['won'] += 1; results['btts']['profit'] += 0.85 + else: results['btts']['profit'] -= 1.0 + + print("\n" + "="*60) + print("📊 VQWEN FINAL SONUÇLAR") + print("="*60) + for mkt in ['ms', 'ou25', 'btts']: + r = results[mkt] + wr = (r['won'] / r['bet'] * 100) if r['bet'] > 0 else 0 + print(f"{mkt.upper():<10} Oyn: {r['bet']:<5} Kaz: {r['won']:<5} WR: {wr:.1f}% Kâr: {r['profit']:+.2f}") + + total = sum(r['profit'] for r in results.values()) + print(f"\n💰 TOPLAM: {total:+.2f} Units") + print("🟢 PARA KAZANDIK!" if total > 0 else "🔴 ZARARDA") + + cur.close() + conn.close() + +if __name__ == "__main__": + run_final_backtest() diff --git a/ai-engine/scripts/backtest_vqwen_v3.py b/ai-engine/scripts/backtest_vqwen_v3.py new file mode 100644 index 0000000..ac07616 --- /dev/null +++ b/ai-engine/scripts/backtest_vqwen_v3.py @@ -0,0 +1,182 @@ +""" +VQWEN v3 Shared-Contract Backtest +================================= + +Evaluates the retrained VQWEN models on the temporal validation slice using +the exact same pre-match feature contract as training/runtime. +""" + +from __future__ import annotations + +import json +import os +import pickle +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import psycopg2 +from dotenv import load_dotenv + +AI_DIR = Path(__file__).resolve().parent +ENGINE_DIR = AI_DIR.parent +REPO_DIR = ENGINE_DIR.parent +MODELS_DIR = ENGINE_DIR / "models" / "vqwen" + +if str(ENGINE_DIR) not in sys.path: + sys.path.insert(0, str(ENGINE_DIR)) + +from features.vqwen_contract import FEATURE_COLUMNS # noqa: E402 +from train_vqwen_v3 import ( # noqa: E402 + _enrich_pre_match_context, + _fetch_dataframe, + _prepare_features, + _temporal_split, + load_top_league_ids, +) + + +def _load_env() -> None: + load_dotenv(REPO_DIR / ".env", override=False) + load_dotenv(ENGINE_DIR / ".env", override=False) + + +def get_clean_dsn() -> str: + _load_env() + raw = os.getenv("DATABASE_URL", "").strip().strip('"').strip("'") + if not raw: + raise RuntimeError("DATABASE_URL is missing.") + return raw.split("?", 1)[0] + + +def _accuracy(y_true: np.ndarray, y_pred: np.ndarray) -> float: + if len(y_true) == 0: + return 0.0 + return float((y_true == y_pred).mean()) + + +def _binary_metrics(prob: np.ndarray, y_true: np.ndarray) -> tuple[float, float]: + pred = (prob >= 0.5).astype(int) + acc = _accuracy(y_true, pred) + brier = float(np.mean((prob - y_true) ** 2)) if len(y_true) else 1.0 + return acc, brier + + +def _multiclass_brier(prob: np.ndarray, y_true: np.ndarray, n_classes: int = 3) -> float: + if len(y_true) == 0: + return 1.0 + target = np.zeros((len(y_true), n_classes), dtype=np.float64) + target[np.arange(len(y_true)), y_true.astype(int)] = 1.0 + return float(np.mean(np.sum((prob - target) ** 2, axis=1))) + + +def _band_label(probability: float) -> str: + if probability >= 0.70: + return "HIGH" + if probability >= 0.60: + return "MEDIUM" + if probability >= 0.50: + return "LOW" + return "NO_BET" + + +def _summarize_bands( + name: str, + confidence: np.ndarray, + is_correct: np.ndarray, +) -> list[str]: + lines: list[str] = [] + for band in ("HIGH", "MEDIUM", "LOW"): + mask = np.array([_band_label(float(p)) == band for p in confidence], dtype=bool) + count = int(mask.sum()) + accuracy = float(is_correct[mask].mean()) if count else 0.0 + avg_conf = float(confidence[mask].mean()) if count else 0.0 + lines.append( + f"{name} {band:<6} count={count:<4} accuracy={accuracy*100:5.1f}% avg_conf={avg_conf*100:5.1f}%" + ) + return lines + + +def run_v3_backtest() -> None: + print("VQWEN v3 SHARED-CONTRACT BACKTEST") + print("=" * 60) + + league_ids = load_top_league_ids() + dsn = get_clean_dsn() + + with psycopg2.connect(dsn) as conn: + with conn.cursor() as cur: + df = _fetch_dataframe(cur, league_ids) + df = _enrich_pre_match_context(cur, df) + df = _prepare_features(df) + + train_df, valid_df = _temporal_split(df) + print(f"Toplam ornek: {len(df)} | Train: {len(train_df)} | Valid: {len(valid_df)}") + + with (MODELS_DIR / "vqwen_ms.pkl").open("rb") as handle: + model_ms = pickle.load(handle) + with (MODELS_DIR / "vqwen_ou25.pkl").open("rb") as handle: + model_ou25 = pickle.load(handle) + with (MODELS_DIR / "vqwen_btts.pkl").open("rb") as handle: + model_btts = pickle.load(handle) + + X_valid = valid_df[FEATURE_COLUMNS] + y_ms = valid_df["t_ms"].to_numpy(dtype=np.int64) + y_ou25 = valid_df["t_ou"].to_numpy(dtype=np.int64) + y_btts = valid_df["t_btts"].to_numpy(dtype=np.int64) + + ms_prob = np.asarray(model_ms.predict(X_valid), dtype=np.float64) + ou25_prob = np.asarray(model_ou25.predict(X_valid), dtype=np.float64).reshape(-1) + btts_prob = np.asarray(model_btts.predict(X_valid), dtype=np.float64).reshape(-1) + + ms_pred = np.argmax(ms_prob, axis=1) + ms_conf = np.max(ms_prob, axis=1) + ms_correct = (ms_pred == y_ms).astype(np.int64) + + ou25_pred = (ou25_prob >= 0.5).astype(np.int64) + ou25_conf = np.where(ou25_prob >= 0.5, ou25_prob, 1.0 - ou25_prob) + ou25_correct = (ou25_pred == y_ou25).astype(np.int64) + + btts_pred = (btts_prob >= 0.5).astype(np.int64) + btts_conf = np.where(btts_prob >= 0.5, btts_prob, 1.0 - btts_prob) + btts_correct = (btts_pred == y_btts).astype(np.int64) + + ms_acc = _accuracy(y_ms, ms_pred) + ou25_acc, ou25_brier = _binary_metrics(ou25_prob, y_ou25) + btts_acc, btts_brier = _binary_metrics(btts_prob, y_btts) + ms_brier = _multiclass_brier(ms_prob, y_ms) + + print("\nGenel metrikler") + print(f"MS accuracy : {ms_acc*100:.2f}% | multiclass_brier={ms_brier:.4f}") + print(f"OU25 accuracy : {ou25_acc*100:.2f}% | brier={ou25_brier:.4f}") + print(f"BTTS accuracy : {btts_acc*100:.2f}% | brier={btts_brier:.4f}") + + print("\nConfidence band") + for line in _summarize_bands("MS", ms_conf, ms_correct): + print(line) + for line in _summarize_bands("OU25", ou25_conf, ou25_correct): + print(line) + for line in _summarize_bands("BTTS", btts_conf, btts_correct): + print(line) + + summary = { + "validation_samples": int(len(valid_df)), + "metrics": { + "ms_accuracy": round(ms_acc, 4), + "ms_brier": round(ms_brier, 4), + "ou25_accuracy": round(ou25_acc, 4), + "ou25_brier": round(ou25_brier, 4), + "btts_accuracy": round(btts_acc, 4), + "btts_brier": round(btts_brier, 4), + }, + } + (MODELS_DIR / "vqwen_backtest_v3_summary.json").write_text( + json.dumps(summary, indent=2), + encoding="utf-8", + ) + print("\nKaydedildi: vqwen_backtest_v3_summary.json") + + +if __name__ == "__main__": + run_v3_backtest() diff --git a/ai-engine/test_db.py b/ai-engine/test_db.py new file mode 100644 index 0000000..4e7c66c --- /dev/null +++ b/ai-engine/test_db.py @@ -0,0 +1,7 @@ +import os, psycopg2 +from dotenv import load_dotenv +load_dotenv('/Users/piton/Documents/Suggest-Bet-BE/.env') +conn = psycopg2.connect(os.getenv('DATABASE_URL').split('?')[0]) +cur = conn.cursor() +cur.execute('SELECT mpe.match_id, SUM(CASE WHEN event_type::text LIKE \'%yellow_card%\' THEN 1 WHEN event_type::text LIKE \'%red_card%\' THEN 2 ELSE 1 END) as cards FROM match_player_events mpe WHERE event_type::text LIKE \'%card%\' GROUP BY mpe.match_id LIMIT 5') +print(cur.fetchall()) diff --git a/ai-engine/test_quant_integration.py b/ai-engine/test_quant_integration.py new file mode 100644 index 0000000..d05641b --- /dev/null +++ b/ai-engine/test_quant_integration.py @@ -0,0 +1,56 @@ +"""Quick test: V20+Quant integration — EV Edge, Kelly staking, edge-based grading.""" +import json +from services.single_match_orchestrator import SingleMatchOrchestrator + +MATCH_IDS = [ + "er7n8hqndkhvdsg6an72r7h90", # Def. Justicia vs Atl Lanus + "etpay8k4qr3gts3jjidfebaxg", # CA Tigre vs Gymnasia +] + +o = SingleMatchOrchestrator() + +for mid in MATCH_IDS: + print(f"\n{'='*60}") + print(f"MATCH: {mid}") + print(f"{'='*60}") + r = o.analyze_match(mid) + if not r: + print(" Match not found") + continue + + info = r.get("match_info", {}) + print(f" {info.get('match_name', '?')} | {info.get('league', '?')}") + + mp = r.get("main_pick", {}) + print(f"\n MAIN PICK: {mp.get('market')} {mp.get('pick')}") + print(f" probability: {mp.get('probability', 0):.4f}") + print(f" odds: {mp.get('odds', 0):.2f}") + print(f" ev_edge: {mp.get('ev_edge', mp.get('edge', 0)):+.4f}") + print(f" implied_prob: {mp.get('implied_prob', 0):.4f}") + print(f" bet_grade: {mp.get('bet_grade', 'N/A')}") + print(f" stake_units: {mp.get('stake_units', 0)}") + print(f" playable: {mp.get('playable', False)}") + print(f" reasons: {mp.get('decision_reasons', [])}") + + print(f"\n ALL MARKETS (with EV Edge + Kelly):") + for b in r.get("bet_summary", []): + ev = b.get("ev_edge", 0) + imp = b.get("implied_prob", 0) + flag = ">>>" if b.get("playable") else " " + mkt = b["market"] + pick = b["pick"] + odds = b.get("odds", 0) + grade = b["bet_grade"] + stake = b["stake_units"] + conf = b.get("calibrated_confidence", 0) + print( + f" {flag} {mkt:8s} {pick:12s} " + f"ev_edge={ev:+.3f} " + f"odds={odds:.2f} " + f"stake={stake:.1f} " + f"grade={grade:4s} " + f"conf={conf:.1f}% " + f"implied={imp:.3f}" + ) + + print() diff --git a/ai-engine/tests/test_engine_null_safety.py b/ai-engine/tests/test_engine_null_safety.py new file mode 100644 index 0000000..828b122 --- /dev/null +++ b/ai-engine/tests/test_engine_null_safety.py @@ -0,0 +1,75 @@ +import sys +import unittest +from decimal import Decimal +from pathlib import Path +from unittest.mock import MagicMock + +AI_ENGINE_ROOT = Path(__file__).resolve().parents[1] +if str(AI_ENGINE_ROOT) not in sys.path: + sys.path.insert(0, str(AI_ENGINE_ROOT)) + +from core.engines.odds_predictor import OddsPredictorEngine +from features.sidelined_analyzer import SidelinedAnalyzer + + +class EngineNullSafetyTests(unittest.TestCase): + def test_odds_predictor_accepts_decimal_inputs_without_crashing(self): + engine = OddsPredictorEngine() + + prediction = engine.predict( + odds_data={ + "ms_h": Decimal("2.10"), + "ms_d": Decimal("3.25"), + "ms_a": Decimal("3.60"), + "ou25_o": Decimal("1.90"), + }, + ) + + self.assertGreater(prediction.market_home_prob, 0.0) + self.assertGreater(prediction.market_draw_prob, 0.0) + self.assertGreater(prediction.market_away_prob, 0.0) + + def test_sidelined_analyzer_handles_non_numeric_fields(self): + analyzer = SidelinedAnalyzer.__new__(SidelinedAnalyzer) + analyzer.position_weights = {"K": 0.35, "D": 0.20, "O": 0.25, "F": 0.30} + analyzer.max_rating = 10 + analyzer.adaptation_threshold = 10 + analyzer.adaptation_discount = 0.5 + analyzer.goalkeeper_penalty = 0.15 + analyzer.confidence_boost = 10 + analyzer.max_impact = 0.85 + analyzer.key_player_threshold = 3 + analyzer.recent_matches_lookback = 15 + analyzer._fetch_player_stats = MagicMock(return_value={}) + + result = analyzer.analyze( + { + "totalSidelined": 2, + "players": [ + { + "playerId": "p1", + "playerName": "Player One", + "positionShort": "O", + "matchesMissed": "N/A", + "average": "?", + "type": "injury", + }, + { + "playerId": "p2", + "playerName": "Player Two", + "positionShort": "K", + "matchesMissed": "12", + "average": "6.7", + "type": "suspension", + }, + ], + }, + ) + + self.assertEqual(result.total_sidelined, 2) + self.assertGreaterEqual(result.impact_score, 0.0) + self.assertTrue(len(result.player_details) >= 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/ai-engine/tests/test_feature_enrichment.py b/ai-engine/tests/test_feature_enrichment.py new file mode 100644 index 0000000..f1f1ab8 --- /dev/null +++ b/ai-engine/tests/test_feature_enrichment.py @@ -0,0 +1,282 @@ +""" +Unit tests for FeatureEnrichmentService +======================================== +Tests all 6 enrichment methods with mocked DB cursor: + 1. compute_team_stats + 2. compute_h2h + 3. compute_form_streaks + 4. compute_referee_stats + 5. compute_league_averages + 6. compute_momentum +""" + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +AI_ENGINE_ROOT = Path(__file__).resolve().parents[1] +if str(AI_ENGINE_ROOT) not in sys.path: + sys.path.insert(0, str(AI_ENGINE_ROOT)) + +from services.feature_enrichment import FeatureEnrichmentService, _safe_avg + + +def _make_cursor(rows=None, side_effect=None): + """Create a mock RealDictCursor.""" + cur = MagicMock() + if side_effect: + cur.execute.side_effect = side_effect + else: + cur.fetchall.return_value = rows or [] + cur.fetchone.return_value = rows[0] if rows else None + return cur + + +class TestSafeAvg(unittest.TestCase): + def test_returns_average(self): + self.assertAlmostEqual(_safe_avg([2.0, 4.0, 6.0], 0.0), 4.0) + + def test_returns_default_on_empty(self): + self.assertEqual(_safe_avg([], 99.0), 99.0) + + def test_single_value(self): + self.assertAlmostEqual(_safe_avg([7.5], 0.0), 7.5) + + +class TestComputeTeamStats(unittest.TestCase): + def setUp(self): + self.svc = FeatureEnrichmentService() + self.ts = 1700000000000 + + def test_returns_defaults_when_no_team_id(self): + result = self.svc.compute_team_stats(MagicMock(), '', self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_TEAM_STATS) + + def test_returns_defaults_when_no_rows(self): + cur = _make_cursor(rows=[]) + result = self.svc.compute_team_stats(cur, 'team1', self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_TEAM_STATS) + + def test_returns_defaults_on_db_error(self): + cur = _make_cursor(side_effect=Exception('DB down')) + result = self.svc.compute_team_stats(cur, 'team1', self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_TEAM_STATS) + + def test_calculates_averages_correctly(self): + rows = [ + {'possession_percentage': 60.0, 'shots_on_target': 5, 'total_shots': 10, 'corners': 7}, + {'possession_percentage': 40.0, 'shots_on_target': 3, 'total_shots': 12, 'corners': 3}, + ] + cur = _make_cursor(rows) + result = self.svc.compute_team_stats(cur, 'team1', self.ts) + + self.assertAlmostEqual(result['avg_possession'], 50.0) + self.assertAlmostEqual(result['avg_shots_on_target'], 4.0) + self.assertAlmostEqual(result['shot_conversion'], (5 / 10 + 3 / 12) / 2, places=4) + self.assertAlmostEqual(result['avg_corners'], 5.0) + + def test_handles_none_subfields_gracefully(self): + """Rows with None values should be skipped, not crash.""" + rows = [ + {'possession_percentage': 55.0, 'shots_on_target': None, 'total_shots': None, 'corners': 4}, + {'possession_percentage': None, 'shots_on_target': 2, 'total_shots': 8, 'corners': None}, + ] + cur = _make_cursor(rows) + result = self.svc.compute_team_stats(cur, 'team1', self.ts) + + self.assertAlmostEqual(result['avg_possession'], 55.0) + self.assertAlmostEqual(result['avg_shots_on_target'], 2.0) + self.assertAlmostEqual(result['avg_corners'], 4.0) + + +class TestComputeH2H(unittest.TestCase): + def setUp(self): + self.svc = FeatureEnrichmentService() + self.ts = 1700000000000 + + def test_returns_defaults_when_no_ids(self): + result = self.svc.compute_h2h(MagicMock(), '', 'away1', self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_H2H) + + def test_returns_defaults_when_no_rows(self): + cur = _make_cursor(rows=[]) + result = self.svc.compute_h2h(cur, 'home1', 'away1', self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_H2H) + + def test_calculates_h2h_stats(self): + rows = [ + {'home_team_id': 'home1', 'away_team_id': 'away1', 'score_home': 2, 'score_away': 1}, # home win, btts, over25 + {'home_team_id': 'home1', 'away_team_id': 'away1', 'score_home': 0, 'score_away': 0}, # draw, no btts, no over25 + {'home_team_id': 'away1', 'away_team_id': 'home1', 'score_home': 1, 'score_away': 3}, # reversed: home wins again, btts, over25 + {'home_team_id': 'away1', 'away_team_id': 'home1', 'score_home': 2, 'score_away': 0}, # reversed: away(=home1) lost + ] + cur = _make_cursor(rows) + result = self.svc.compute_h2h(cur, 'home1', 'away1', self.ts) + + self.assertEqual(result['total_matches'], 4) + self.assertAlmostEqual(result['home_win_rate'], 2 / 4) + self.assertAlmostEqual(result['draw_rate'], 1 / 4) + self.assertAlmostEqual(result['btts_rate'], 2 / 4) + self.assertAlmostEqual(result['over25_rate'], 2 / 4) + + def test_returns_defaults_on_db_error(self): + cur = _make_cursor(side_effect=Exception('connection lost')) + result = self.svc.compute_h2h(cur, 'home1', 'away1', self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_H2H) + + +class TestComputeFormStreaks(unittest.TestCase): + def setUp(self): + self.svc = FeatureEnrichmentService() + self.ts = 1700000000000 + + def test_returns_defaults_when_no_team_id(self): + result = self.svc.compute_form_streaks(MagicMock(), '', self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_FORM) + + def test_calculates_streaks_correctly(self): + """Most recent first: W, W, D, L → winning_streak=2, unbeaten_streak=3.""" + rows = [ + {'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 2, 'score_away': 0}, # W (clean sheet, scored) + {'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 1, 'score_away': 0}, # W (clean sheet, scored) + {'home_team_id': 'x', 'away_team_id': 'team1', 'score_home': 1, 'score_away': 1}, # D (scored, conceded) + {'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 0, 'score_away': 2}, # L (not scored, conceded) + ] + cur = _make_cursor(rows) + result = self.svc.compute_form_streaks(cur, 'team1', self.ts) + + self.assertEqual(result['winning_streak'], 2) + self.assertEqual(result['unbeaten_streak'], 3) + self.assertAlmostEqual(result['clean_sheet_rate'], 2 / 4) + self.assertAlmostEqual(result['scoring_rate'], 3 / 4) + + def test_all_losses(self): + rows = [ + {'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 0, 'score_away': 1}, + {'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 0, 'score_away': 3}, + ] + cur = _make_cursor(rows) + result = self.svc.compute_form_streaks(cur, 'team1', self.ts) + + self.assertEqual(result['winning_streak'], 0) + self.assertEqual(result['unbeaten_streak'], 0) + self.assertAlmostEqual(result['scoring_rate'], 0.0) + + +class TestComputeRefereeStats(unittest.TestCase): + def setUp(self): + self.svc = FeatureEnrichmentService() + self.ts = 1700000000000 + + def test_returns_defaults_when_no_name(self): + result = self.svc.compute_referee_stats(MagicMock(), None, self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_REFEREE) + + def test_calculates_referee_tendencies(self): + match_rows = [ + {'home_team_id': 'h1', 'score_home': 2, 'score_away': 0, 'match_id': 'm1'}, # home win + {'home_team_id': 'h2', 'score_home': 1, 'score_away': 1, 'match_id': 'm2'}, # draw + ] + card_row = {'yellows': 6, 'total_cards': 8} + + cur = MagicMock() + # First execute (match query) → match_rows + # Second execute (card query) → card_row + cur.fetchall.return_value = match_rows + cur.fetchone.return_value = card_row + + result = self.svc.compute_referee_stats(cur, 'Ref Name', self.ts) + + self.assertEqual(result['experience'], 2) + self.assertAlmostEqual(result['avg_goals'], (2 + 0 + 1 + 1) / 2) + # home_bias = (1/2) - 0.46 = 0.04 + self.assertAlmostEqual(result['home_bias'], 0.04, places=4) + self.assertAlmostEqual(result['avg_yellow'], 6 / 2) + self.assertAlmostEqual(result['cards_total'], 8 / 2) + + def test_returns_defaults_on_db_error(self): + cur = _make_cursor(side_effect=Exception('timeout')) + result = self.svc.compute_referee_stats(cur, 'Some Ref', self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_REFEREE) + + +class TestComputeLeagueAverages(unittest.TestCase): + def setUp(self): + self.svc = FeatureEnrichmentService() + self.ts = 1700000000000 + + def test_returns_defaults_when_no_league_id(self): + result = self.svc.compute_league_averages(MagicMock(), None, self.ts) + self.assertEqual(result, FeatureEnrichmentService._DEFAULT_LEAGUE) + + def test_calculates_league_averages(self): + rows = [ + {'score_home': 1, 'score_away': 1}, # 2 goals + {'score_home': 0, 'score_away': 0}, # 0 goals (zero-goal match) + {'score_home': 3, 'score_away': 2}, # 5 goals + ] + cur = _make_cursor(rows) + result = self.svc.compute_league_averages(cur, 'league1', self.ts) + + self.assertAlmostEqual(result['avg_goals'], 7 / 3, places=4) + self.assertAlmostEqual(result['zero_goal_rate'], 1 / 3, places=4) + + +class TestComputeMomentum(unittest.TestCase): + def setUp(self): + self.svc = FeatureEnrichmentService() + self.ts = 1700000000000 + + def test_returns_zero_when_no_team_id(self): + result = self.svc.compute_momentum(MagicMock(), '', self.ts) + self.assertEqual(result, 0.0) + + def test_returns_zero_when_no_rows(self): + cur = _make_cursor(rows=[]) + result = self.svc.compute_momentum(cur, 'team1', self.ts) + self.assertEqual(result, 0.0) + + def test_all_wins_returns_one(self): + """All wins → momentum = 1.0 (max possible).""" + rows = [ + {'home_team_id': 'team1', 'score_home': 3, 'score_away': 0}, + {'home_team_id': 'team1', 'score_home': 2, 'score_away': 1}, + ] + cur = _make_cursor(rows) + result = self.svc.compute_momentum(cur, 'team1', self.ts) + self.assertAlmostEqual(result, 1.0, places=4) + + def test_all_losses_returns_negative(self): + """All losses → negative momentum.""" + rows = [ + {'home_team_id': 'team1', 'score_home': 0, 'score_away': 2}, + {'home_team_id': 'team1', 'score_home': 1, 'score_away': 3}, + ] + cur = _make_cursor(rows) + result = self.svc.compute_momentum(cur, 'team1', self.ts) + self.assertLess(result, 0.0) + + def test_mixed_results(self): + """W, D, L → weighted score between -1 and 1.""" + rows = [ + {'home_team_id': 'team1', 'score_home': 1, 'score_away': 0}, # W (weight=3) + {'home_team_id': 'x', 'away_team_id': 'team1', 'score_home': 0, 'score_away': 0}, # D (weight=2) + {'home_team_id': 'team1', 'score_home': 0, 'score_away': 1}, # L (weight=1) + ] + cur = _make_cursor(rows) + result = self.svc.compute_momentum(cur, 'team1', self.ts) + + # weighted = 3*3 + 1*2 + (-1)*1 = 9+2-1 = 10 + # max_possible = 3*3 + 3*2 + 3*1 = 18 + # normalised = 10/18 ≈ 0.5556 + self.assertAlmostEqual(result, round(10 / 18, 4), places=4) + + def test_returns_zero_on_db_error(self): + cur = _make_cursor(side_effect=Exception('broken pipe')) + result = self.svc.compute_momentum(cur, 'team1', self.ts) + self.assertEqual(result, 0.0) + + +if __name__ == '__main__': + unittest.main() diff --git a/ai-engine/tests/test_main_api.py b/ai-engine/tests/test_main_api.py new file mode 100644 index 0000000..44b9664 --- /dev/null +++ b/ai-engine/tests/test_main_api.py @@ -0,0 +1,110 @@ +import asyncio +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from fastapi import HTTPException + +AI_ENGINE_ROOT = Path(__file__).resolve().parents[1] +if str(AI_ENGINE_ROOT) not in sys.path: + sys.path.insert(0, str(AI_ENGINE_ROOT)) + +import main as ai_main + + +def _run(coro): + return asyncio.run(coro) + + +class MainApiFunctionTests(unittest.TestCase): + def test_analyze_match_v20plus_returns_payload(self): + orchestrator = MagicMock() + orchestrator.analyze_match.return_value = {"match_info": {"match_id": "m1"}} + + with patch("main.get_single_match_orchestrator", return_value=orchestrator): + result = _run(ai_main.analyze_match_v20plus("m1")) + + self.assertEqual(result["match_info"]["match_id"], "m1") + + def test_analyze_match_v20plus_raises_404(self): + orchestrator = MagicMock() + orchestrator.analyze_match.return_value = None + + with patch("main.get_single_match_orchestrator", return_value=orchestrator): + with self.assertRaises(HTTPException) as ctx: + _run(ai_main.analyze_match_v20plus("missing")) + + self.assertEqual(ctx.exception.status_code, 404) + + def test_analyze_match_htms_v20plus_returns_payload(self): + orchestrator = MagicMock() + orchestrator.analyze_match_htms.return_value = { + "status": "ok", + "engine_used": "v20plus_top_htms", + } + + with patch("main.get_single_match_orchestrator", return_value=orchestrator): + result = _run(ai_main.analyze_match_htms_v20plus("m1")) + + self.assertEqual(result["status"], "ok") + self.assertEqual(result["engine_used"], "v20plus_top_htms") + + def test_analyze_match_htft_timeout_validation(self): + with self.assertRaises(HTTPException) as ctx: + _run(ai_main.analyze_match_htft_v20plus("m1", timeout_sec=2)) + + self.assertEqual(ctx.exception.status_code, 400) + + def test_generate_coupon_v20plus_forwards_payload(self): + orchestrator = MagicMock() + orchestrator.build_coupon.return_value = {"bets": []} + + request = ai_main.CouponRequest( + match_ids=["m1", "m2"], + strategy="SAFE", + max_matches=3, + min_confidence=70, + ) + + with patch("main.get_single_match_orchestrator", return_value=orchestrator): + result = _run(ai_main.generate_coupon_v20plus(request)) + + self.assertEqual(result, {"bets": []}) + orchestrator.build_coupon.assert_called_once_with( + match_ids=["m1", "m2"], + strategy="SAFE", + max_matches=3, + min_confidence=70.0, + ) + + def test_reversal_watchlist_validation(self): + with self.assertRaises(HTTPException) as ctx: + _run(ai_main.get_reversal_watchlist_v20plus(count=0)) + self.assertEqual(ctx.exception.status_code, 400) + + def test_reversal_watchlist_forwards_payload(self): + orchestrator = MagicMock() + orchestrator.get_reversal_watchlist.return_value = {"watchlist": []} + + with patch("main.get_single_match_orchestrator", return_value=orchestrator): + result = _run( + ai_main.get_reversal_watchlist_v20plus( + count=12, + horizon_hours=48, + min_score=50.5, + top_leagues_only=True, + ), + ) + + self.assertEqual(result, {"watchlist": []}) + orchestrator.get_reversal_watchlist.assert_called_once_with( + count=12, + horizon_hours=48, + min_score=50.5, + top_leagues_only=True, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ai-engine/tests/test_single_match_orchestrator.py b/ai-engine/tests/test_single_match_orchestrator.py new file mode 100644 index 0000000..86eadc5 --- /dev/null +++ b/ai-engine/tests/test_single_match_orchestrator.py @@ -0,0 +1,766 @@ +import json +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +AI_ENGINE_ROOT = Path(__file__).resolve().parents[1] +if str(AI_ENGINE_ROOT) not in sys.path: + sys.path.insert(0, str(AI_ENGINE_ROOT)) + +from models.v20_ensemble import FullMatchPrediction +from models.basketball_v25 import BasketballMatchPrediction +from services.single_match_orchestrator import MatchData, SingleMatchOrchestrator + + +class _CursorContext: + def __init__(self, cursor): + self._cursor = cursor + + def __enter__(self): + return self._cursor + + def __exit__(self, exc_type, exc, tb): + return False + + +class _ConnContext: + def __init__(self, cursor): + self._cursor = cursor + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def cursor(self, cursor_factory=None): + return _CursorContext(self._cursor) + + +class _StaticFetchAllCursor: + def __init__(self, rows): + self.rows = rows + self.executed = [] + + def execute(self, query, params=None): + self.executed.append((query, params)) + + def fetchall(self): + return list(self.rows) + + +class _RouterCursor: + def __init__( + self, + *, + live_row=None, + hist_row=None, + relational_rows=None, + participation_rows=None, + probable_rows=None, + ): + self.live_row = live_row + self.hist_row = hist_row + self.relational_rows = relational_rows or [] + self.participation_rows = participation_rows or [] + self.probable_rows = probable_rows or [] + self.last_query = "" + + def execute(self, query, params=None): + self.last_query = query + + def fetchone(self): + if "FROM live_matches" in self.last_query: + return self.live_row + if "FROM matches m" in self.last_query: + return self.hist_row + return None + + def fetchall(self): + if "FROM odd_categories" in self.last_query: + return list(self.relational_rows) + if "FROM match_player_participation" in self.last_query and "GROUP BY" not in self.last_query: + return list(self.participation_rows) + if "GROUP BY mpp.player_id" in self.last_query: + return list(self.probable_rows) + return [] + + +def _build_orchestrator() -> SingleMatchOrchestrator: + orchestrator = SingleMatchOrchestrator.__new__(SingleMatchOrchestrator) + orchestrator.v25_predictor = MagicMock() + orchestrator.basketball_predictor = MagicMock() + orchestrator.dsn = "postgresql://unit-test" + orchestrator.league_reliability = {} + orchestrator.market_calibration = { + "MS": 0.82, + "DC": 0.93, + "OU15": 0.90, + "OU25": 0.85, + "OU35": 0.88, + "BTTS": 0.83, + "HT": 0.80, + "HT_OU05": 0.88, + } + orchestrator.market_min_conf = { + "MS": 52.0, + "DC": 56.0, + "OU15": 60.0, + "OU25": 58.0, + "OU35": 54.0, + "BTTS": 57.0, + "HT": 53.0, + "HT_OU05": 55.0, + } + orchestrator.market_min_play_score = { + "MS": 72.0, + "DC": 62.0, + "OU15": 64.0, + "OU25": 70.0, + "OU35": 76.0, + "BTTS": 70.0, + "HT": 74.0, + "HT_OU05": 64.0, + } + orchestrator.market_min_edge = { + "MS": 0.03, + "DC": 0.01, + "OU15": 0.01, + "OU25": 0.02, + "OU35": 0.04, + "BTTS": 0.03, + "HT": 0.04, + "HT_OU05": 0.01, + } + return orchestrator + + +class SingleMatchOrchestratorTests(unittest.TestCase): + def setUp(self): + self.orchestrator = _build_orchestrator() + + def test_parse_odds_json_uses_exact_market_match_and_ignores_collisions(self): + odds_json = { + "Maç Sonucu": {"1": "2.15", "X": "3.20", "2": "3.30"}, + "İlk Yarı/Maç Sonucu": {"1/1": "4.30"}, + "2,5 Alt/Üst": {"Üst": "1.85", "Alt": "1.95"}, + "İY 0,5 Alt/Üst": {"Üst": "1.49", "Alt": "2.20"}, + "1. Yarı Ev Sahibi 0,5 Alt/Üst": {"Üst": "1.99", "Alt": "1.45"}, + "2,5 Kart Puanı Alt/Üst": {"Üst": "1.33", "Alt": "2.95"}, + "Karşılıklı Gol": {"Var": "1.75", "Yok": "2.05"}, + "1. Yarı Karşılıklı Gol": {"Var": "2.10", "Yok": "1.60"}, + "Çifte Şans": {"1-X": "1.33", "X-2": "1.62", "1-2": "1.30"}, + "1. Yarı Sonucu": {"1": "2.45", "X": "2.00", "2": "3.80"}, + } + + parsed = self.orchestrator._parse_odds_json(odds_json) + + self.assertEqual(parsed["ms_h"], 2.15) + self.assertEqual(parsed["ms_d"], 3.20) + self.assertEqual(parsed["ms_a"], 3.30) + self.assertEqual(parsed["ou25_o"], 1.85) + self.assertEqual(parsed["ou25_u"], 1.95) + self.assertEqual(parsed["btts_y"], 1.75) + self.assertEqual(parsed["btts_n"], 2.05) + self.assertEqual(parsed["dc_1x"], 1.33) + self.assertEqual(parsed["dc_x2"], 1.62) + self.assertEqual(parsed["dc_12"], 1.30) + self.assertEqual(parsed["ht_h"], 2.45) + self.assertEqual(parsed["ht_d"], 2.00) + self.assertEqual(parsed["ht_a"], 3.80) + self.assertEqual(parsed["ht_ou05_o"], 1.49) + self.assertEqual(parsed["ht_ou05_u"], 2.20) + self.assertEqual(parsed["htft_11"], 4.30) + + def test_parse_odds_json_accepts_selection_variants(self): + odds_json = { + "2,5 Alt/Üst": {"2,5 Üst": "1.91", "2,5 Alt": "1.86"}, + "Karşılıklı Gol": {"YES": "1.82", "NO": "1.96"}, + "Çifte Şans": {"1X": "1.28", "X2": "1.44", "12": "1.32"}, + } + + parsed = self.orchestrator._parse_odds_json(odds_json) + + self.assertEqual(parsed["ou25_o"], 1.91) + self.assertEqual(parsed["ou25_u"], 1.86) + self.assertEqual(parsed["btts_y"], 1.82) + self.assertEqual(parsed["btts_n"], 1.96) + self.assertEqual(parsed["dc_1x"], 1.28) + self.assertEqual(parsed["dc_x2"], 1.44) + self.assertEqual(parsed["dc_12"], 1.32) + + def test_parse_odds_json_maps_all_football_markets_with_noise(self): + odds_json = { + "Maç Sonucu": {"1": "2.31", "X": "3.22", "2": "3.05"}, + "Çifte Şans": {"1-X": "1.34", "X-2": "1.52", "1-2": "1.28"}, + "1,5 Alt/Üst": {"Üst": "1.29", "Alt": "3.45"}, + "2,5 Alt/Üst": {"Üst": "1.71", "Alt": "2.05"}, + "3,5 Alt/Üst": {"Üst": "2.62", "Alt": "1.41"}, + "Karşılıklı Gol": {"Var": "1.66", "Yok": "2.11"}, + "1. Yarı Sonucu": {"1": "3.10", "X": "1.95", "2": "4.60"}, + "1. Yarı 0,5 Alt/Üst": {"Üst": "1.21", "Alt": "2.72"}, + # noise categories that must not overwrite football main markets + "1. Yarı Ev Sahibi 0,5 Alt/Üst": {"Üst": "1.99", "Alt": "1.45"}, + "1. Yarı Deplasman 0,5 Alt/Üst": {"Üst": "1.73", "Alt": "1.63"}, + "1.Yarı 3,5 Korner Alt/Üst": {"Üst": "1.26", "Alt": "2.30"}, + "2,5 Kart Puanı Alt/Üst": {"Üst": "1.40", "Alt": "2.60"}, + } + + parsed = self.orchestrator._parse_odds_json(odds_json) + + self.assertEqual(parsed["ms_h"], 2.31) + self.assertEqual(parsed["ms_d"], 3.22) + self.assertEqual(parsed["ms_a"], 3.05) + self.assertEqual(parsed["dc_1x"], 1.34) + self.assertEqual(parsed["dc_x2"], 1.52) + self.assertEqual(parsed["dc_12"], 1.28) + self.assertEqual(parsed["ou15_o"], 1.29) + self.assertEqual(parsed["ou15_u"], 3.45) + self.assertEqual(parsed["ou25_o"], 1.71) + self.assertEqual(parsed["ou25_u"], 2.05) + self.assertEqual(parsed["ou35_o"], 2.62) + self.assertEqual(parsed["ou35_u"], 1.41) + self.assertEqual(parsed["btts_y"], 1.66) + self.assertEqual(parsed["btts_n"], 2.11) + self.assertEqual(parsed["ht_h"], 3.10) + self.assertEqual(parsed["ht_d"], 1.95) + self.assertEqual(parsed["ht_a"], 4.60) + self.assertEqual(parsed["ht_ou05_o"], 1.21) + self.assertEqual(parsed["ht_ou05_u"], 2.72) + + def test_v25_market_odds_ignores_synthetic_default_when_selection_missing(self): + odds_json = { + "1,5 Alt/Üst": {"Alt": 5.70}, + "Çifte Şans": {"1-X": 1.30, "X-2": 1.38, "1-2": 1.09}, + } + + parsed = self.orchestrator._parse_odds_json(odds_json) + + self.assertEqual(parsed["ou15_o"], 0.0) + self.assertEqual( + self.orchestrator._v25_market_odds(parsed, "OU15", "Over"), + 1.0, + ) + self.assertEqual( + self.orchestrator._v25_market_odds(parsed, "OU15", "Under"), + 5.7, + ) + self.assertEqual( + self.orchestrator._v25_market_odds(parsed, "DC", "X2"), + 1.38, + ) + + def test_parse_odds_json_extracts_basketball_ml_total_spread(self): + odds_json = { + "Maç Sonucu (Uzt. Dahil)": {"1": "1.74", "2": "2.08"}, + "Alt/Üst (163,5)": {"Üst": "1.86", "Alt": "1.94"}, + "1. Yarı Alt/Üst (81,5)": {"Üst": "1.89", "Alt": "1.91"}, + "1. Yarı Alt/Üst (100,5)": {"Üst": "1.83", "Alt": "1.97"}, + "Hnd. MS (0:5,5)": {"1": "1.91", "+5.5h": "1.87"}, + } + + parsed = self.orchestrator._parse_odds_json(odds_json) + + self.assertEqual(parsed["ml_h"], 1.74) + self.assertEqual(parsed["ml_a"], 2.08) + self.assertEqual(parsed["tot_line"], 163.5) + self.assertEqual(parsed["tot_o"], 1.86) + self.assertEqual(parsed["tot_u"], 1.94) + self.assertEqual(parsed["spread_home_line"], -5.5) + self.assertEqual(parsed["spread_h"], 1.91) + self.assertEqual(parsed["spread_a"], 1.87) + self.assertNotIn("ht_ou05_o", parsed) + self.assertNotIn("ht_ou05_u", parsed) + + def test_extract_odds_merges_relational_when_live_json_is_incomplete(self): + row = { + "match_id": "m-1", + "odds": {"Maç Sonucu": {"1": 2.10, "X": 3.20, "2": 3.35}}, + } + relational_rows = [ + {"category_name": "Çifte Şans", "selection_name": "1-X", "odd_value": 1.28}, + {"category_name": "Çifte Şans", "selection_name": "X-2", "odd_value": 1.44}, + {"category_name": "Çifte Şans", "selection_name": "1-2", "odd_value": 1.31}, + {"category_name": "2,5 Alt/Üst", "selection_name": "Üst", "odd_value": 1.89}, + {"category_name": "2,5 Alt/Üst", "selection_name": "Alt", "odd_value": 1.94}, + {"category_name": "Karşılıklı Gol", "selection_name": "Var", "odd_value": 1.77}, + {"category_name": "Karşılıklı Gol", "selection_name": "Yok", "odd_value": 2.02}, + {"category_name": "1. Yarı Sonucu", "selection_name": "1", "odd_value": 2.55}, + {"category_name": "1. Yarı Sonucu", "selection_name": "X", "odd_value": 1.98}, + {"category_name": "1. Yarı Sonucu", "selection_name": "2", "odd_value": 3.40}, + ] + cur = _StaticFetchAllCursor(relational_rows) + + odds = self.orchestrator._extract_odds(cur, row) + + self.assertEqual(odds["ms_h"], 2.10) + self.assertEqual(odds["ms_d"], 3.20) + self.assertEqual(odds["ms_a"], 3.35) + self.assertEqual(odds["dc_x2"], 1.44) + self.assertEqual(odds["ou25_o"], 1.89) + self.assertEqual(odds["btts_y"], 1.77) + self.assertEqual(odds["ht_d"], 1.98) + self.assertEqual(len(cur.executed), 1) + + def test_extract_odds_fills_default_ms_when_no_source_available(self): + row = {"match_id": "m-2", "odds": None} + cur = _StaticFetchAllCursor([]) + + odds = self.orchestrator._extract_odds(cur, row) + + self.assertEqual(odds["ms_h"], SingleMatchOrchestrator.DEFAULT_MS_H) + self.assertEqual(odds["ms_d"], SingleMatchOrchestrator.DEFAULT_MS_D) + self.assertEqual(odds["ms_a"], SingleMatchOrchestrator.DEFAULT_MS_A) + + def test_parse_lineups_json_supports_id_playerid_personid(self): + lineups = { + "home": { + "xi": [ + {"id": "11"}, + {"playerId": "12"}, + ], + }, + "away": { + "starting": [ + {"personId": "21"}, + "22", + ], + }, + } + + home, away = self.orchestrator._parse_lineups_json(lineups) + + self.assertEqual(home, ["11", "12"]) + self.assertEqual(away, ["21", "22"]) + + def test_extract_lineups_uses_participation_and_probable_xi_fallbacks(self): + row = { + "match_id": "m-3", + "home_team_id": "h1", + "away_team_id": "a1", + "match_date_ms": 1700000000000, + "lineups": { + "home": {"xi": [{"personId": "h-live-1"}]}, + "away": {}, + }, + } + participation = [ + {"team_id": "a1", "player_id": "a-db-1"}, + {"team_id": "a1", "player_id": "a-db-2"}, + ] + cur = _StaticFetchAllCursor(participation) + + with patch.object( + self.orchestrator, + "_build_probable_xi", + side_effect=[["h-prob-1"], ["a-prob-1"]], + ) as probable_xi: + home, away, source = self.orchestrator._extract_lineups(cur, row) + + self.assertEqual(home, ["h-live-1"]) + self.assertEqual(away, ["a-db-1", "a-db-2"]) + self.assertEqual(source, "none") + probable_xi.assert_not_called() + + def test_extract_lineups_falls_back_to_probable_xi_when_live_and_participation_missing(self): + row = { + "match_id": "m-4", + "home_team_id": "h2", + "away_team_id": "a2", + "match_date_ms": 1700000000000, + "lineups": None, + } + cur = _StaticFetchAllCursor([]) + + with patch.object( + self.orchestrator, + "_build_probable_xi", + side_effect=[["h-prob-1", "h-prob-2"], ["a-prob-1"]], + ) as probable_xi: + home, away, source = self.orchestrator._extract_lineups(cur, row) + + self.assertEqual(home, ["h-prob-1", "h-prob-2"]) + self.assertEqual(away, ["a-prob-1"]) + self.assertEqual(source, "probable_xi") + self.assertEqual(probable_xi.call_count, 2) + + def test_load_match_data_parses_live_row_json_and_sidelined(self): + odds_payload = { + "Maç Sonucu": {"1": 2.10, "X": 3.30, "2": 3.50}, + "Çifte Şans": {"1-X": 1.30, "X-2": 1.52, "1-2": 1.34}, + "1,5 Alt/Üst": {"Üst": 1.33, "Alt": 2.90}, + "2,5 Alt/Üst": {"Üst": 1.91, "Alt": 1.85}, + "3,5 Alt/Üst": {"Üst": 2.95, "Alt": 1.38}, + "Karşılıklı Gol": {"Var": 1.84, "Yok": 1.92}, + "1. Yarı Sonucu": {"1": 2.55, "X": 1.97, "2": 3.45}, + } + lineups_payload = { + "home": {"xi": [{"personId": "101"}, {"personId": "102"}]}, + "away": {"xi": [{"personId": "201"}, {"personId": "202"}]}, + } + live_row = { + "match_id": "live-101", + "home_team_id": "h-101", + "away_team_id": "a-101", + "league_id": "l-101", + "sport": "FOOTBALL", + "match_date_ms": 1760000000000, + "odds": json.dumps(odds_payload), + "lineups": json.dumps(lineups_payload), + "sidelined": json.dumps( + { + "homeTeam": {"totalSidelined": 1, "players": []}, + "awayTeam": {"totalSidelined": 0, "players": []}, + } + ), + "referee_name": "John Ref", + "home_team_name": "Home FC", + "away_team_name": "Away FC", + "league_name": "League Name", + } + cursor = _RouterCursor(live_row=live_row) + + with patch("services.single_match_orchestrator.psycopg2.connect", return_value=_ConnContext(cursor)): + data = self.orchestrator._load_match_data("live-101") + + self.assertIsNotNone(data) + self.assertEqual(data.match_id, "live-101") + self.assertEqual(data.home_team_id, "h-101") + self.assertEqual(data.away_team_id, "a-101") + self.assertEqual(data.sport, "football") + self.assertEqual(data.referee_name, "John Ref") + self.assertEqual(data.home_lineup, ["101", "102"]) + self.assertEqual(data.away_lineup, ["201", "202"]) + self.assertEqual(data.lineup_source, "none") + self.assertEqual(data.sidelined_data["homeTeam"]["totalSidelined"], 1) + self.assertEqual(data.odds_data["dc_x2"], 1.52) + self.assertEqual(data.odds_data["ht_h"], 2.55) + + def test_analyze_match_forwards_all_core_fields_to_predictor(self): + match_data = MatchData( + match_id="live-55", + home_team_id="home-55", + away_team_id="away-55", + home_team_name="Home 55", + away_team_name="Away 55", + match_date_ms=1760000000000, + sport="football", + league_id="league-55", + league_name="League 55", + referee_name="Ref 55", + odds_data={"ms_h": 2.4, "ms_d": 3.1, "ms_a": 2.9}, + home_lineup=["h1", "h2"], + away_lineup=["a1", "a2"], + sidelined_data={ + "homeTeam": {"totalSidelined": 2, "players": []}, + "awayTeam": {"totalSidelined": 1, "players": []}, + }, + home_goals_avg=1.6, + home_conceded_avg=1.1, + away_goals_avg=1.2, + away_conceded_avg=1.4, + home_position=5, + away_position=8, + lineup_source="confirmed_live", + ) + prediction = FullMatchPrediction(match_id="live-55", home_team="Home 55", away_team="Away 55") + + self.orchestrator._load_match_data = MagicMock(return_value=match_data) + self.orchestrator.v25_predictor.predict_market_bundle = MagicMock(return_value={"MS": {"pick": "1"}}) + self.orchestrator._build_v25_features = MagicMock(return_value={}) + self.orchestrator._get_v25_signal = MagicMock(return_value={"MS": {"pick": "1"}}) + self.orchestrator._build_v25_prediction = MagicMock(return_value=prediction) + self.orchestrator._build_prediction_package = MagicMock(return_value={"ok": True}) + + result = self.orchestrator.analyze_match("live-55") + + self.assertEqual(result, {"ok": True}) + self.orchestrator._build_v25_features.assert_called_once_with(match_data) + self.orchestrator._get_v25_signal.assert_called_once_with(match_data, {}) + self.orchestrator._build_v25_prediction.assert_called_once_with( + match_data, + {}, + {"MS": {"pick": "1"}}, + ) + + def test_analyze_match_routes_basketball_to_basketball_predictor(self): + match_data = MatchData( + match_id="b-live-1", + home_team_id="bh", + away_team_id="ba", + home_team_name="Home B", + away_team_name="Away B", + match_date_ms=1760000000000, + sport="basketball", + league_id="bleague", + league_name="B League", + referee_name=None, + odds_data={"ml_h": 1.75, "ml_a": 2.05, "tot_line": 161.5, "tot_o": 1.88, "tot_u": 1.92}, + home_lineup=None, + away_lineup=None, + sidelined_data={"homeTeam": {"totalSidelined": 1}, "awayTeam": {"totalSidelined": 0}}, + home_goals_avg=85.0, + home_conceded_avg=79.0, + away_goals_avg=82.0, + away_conceded_avg=81.0, + home_position=4, + away_position=7, + lineup_source="none", + ) + prediction = BasketballMatchPrediction( + match_id="b-live-1", + home_team_name="Home B", + away_team_name="Away B", + league_name="B League", + ) + + self.orchestrator._load_match_data = MagicMock(return_value=match_data) + self.orchestrator.basketball_predictor.predict = MagicMock(return_value=prediction) + self.orchestrator._build_basketball_prediction_package = MagicMock( + return_value={"sport": "basketball", "ok": True} + ) + + result = self.orchestrator.analyze_match("b-live-1") + + self.assertEqual(result, {"sport": "basketball", "ok": True}) + self.orchestrator.basketball_predictor.predict.assert_called_once() + kwargs = self.orchestrator.basketball_predictor.predict.call_args.kwargs + self.assertEqual(kwargs["match_id"], "b-live-1") + self.assertEqual(kwargs["home_team_id"], "bh") + self.assertEqual(kwargs["away_team_id"], "ba") + self.assertEqual(kwargs["league_id"], "bleague") + self.assertEqual(kwargs["odds_data"]["ml_h"], 1.75) + self.orchestrator.v25_predictor.predict_market_bundle.assert_not_called() + + def test_build_market_rows_maps_odds_keys_correctly(self): + data = MatchData( + match_id="m-rows", + home_team_id="h", + away_team_id="a", + home_team_name="Home", + away_team_name="Away", + match_date_ms=1760000000000, + sport="football", + league_id=None, + league_name="", + referee_name=None, + odds_data={ + "ms_h": 2.3, + "ms_d": 3.2, + "ms_a": 3.1, + "dc_x2": 1.45, + "ou15_o": 1.36, + "ou25_u": 1.92, + "ou35_o": 2.85, + "btts_y": 1.88, + "ht_h": 2.55, + "ht_ou05_o": 1.47, + }, + home_lineup=None, + away_lineup=None, + sidelined_data=None, + home_goals_avg=1.5, + home_conceded_avg=1.2, + away_goals_avg=1.2, + away_conceded_avg=1.4, + home_position=10, + away_position=10, + lineup_source="none", + ) + pred = FullMatchPrediction( + match_id="m-rows", + home_team="Home", + away_team="Away", + ms_home_prob=0.25, + ms_draw_prob=0.30, + ms_away_prob=0.45, + ms_pick="2", + ms_confidence=69.0, + dc_1x_prob=0.60, + dc_x2_prob=0.72, + dc_12_prob=0.68, + dc_pick="X2", + dc_confidence=67.0, + over_15_prob=0.74, + under_15_prob=0.26, + ou15_pick="1.5 Üst", + ou15_confidence=72.0, + over_25_prob=0.44, + under_25_prob=0.56, + ou25_pick="2.5 Alt", + ou25_confidence=61.0, + over_35_prob=0.39, + under_35_prob=0.61, + ou35_pick="3.5 Over", + ou35_confidence=58.0, + btts_yes_prob=0.57, + btts_no_prob=0.43, + btts_pick="Yes", + btts_confidence=63.0, + ht_home_prob=0.41, + ht_draw_prob=0.39, + ht_away_prob=0.20, + ht_pick="1", + ht_confidence=60.0, + ht_over_05_prob=0.64, + ht_under_05_prob=0.36, + ht_ou_pick="Over 0.5", + ) + + rows = self.orchestrator._build_market_rows(data, pred) + by_market = {row["market"]: row for row in rows} + + self.assertEqual(by_market["MS"]["odds"], 3.1) + self.assertEqual(by_market["DC"]["odds"], 1.45) + self.assertEqual(by_market["OU15"]["odds"], 1.36) + self.assertEqual(by_market["OU25"]["odds"], 1.92) + self.assertEqual(by_market["OU35"]["odds"], 2.85) + self.assertEqual(by_market["BTTS"]["odds"], 1.88) + self.assertEqual(by_market["HT"]["odds"], 2.55) + self.assertEqual(by_market["HT_OU05"]["odds"], 1.47) + + def test_build_basketball_market_rows_maps_odds_keys_correctly(self): + data = MatchData( + match_id="b-rows", + home_team_id="bh", + away_team_id="ba", + home_team_name="Home B", + away_team_name="Away B", + match_date_ms=1760000000000, + sport="basketball", + league_id="bl", + league_name="Basketball League", + referee_name=None, + odds_data={ + "ml_h": 1.73, + "ml_a": 2.10, + "tot_line": 162.5, + "tot_o": 1.89, + "tot_u": 1.93, + "spread_home_line": -4.5, + "spread_h": 1.91, + "spread_a": 1.88, + }, + home_lineup=None, + away_lineup=None, + sidelined_data=None, + home_goals_avg=84.0, + home_conceded_avg=80.0, + away_goals_avg=82.0, + away_conceded_avg=81.0, + home_position=5, + away_position=8, + lineup_source="none", + ) + pred = { + "match_id": "b-rows", + "market_board": { + "ML": {"1": "62%", "2": "38%"}, + "Totals": {"Under 162.5": "43%", "Over 162.5": "57%"}, + "Spread": {"Away +4.5": "46%", "Home -4.5": "54%"} + } + } + + rows = self.orchestrator._build_basketball_market_rows(data, pred) + by_market = {row["market"]: row for row in rows} + + self.assertEqual(by_market["ML"]["odds"], 1.73) + self.assertEqual(by_market["TOTAL"]["odds"], 1.89) + self.assertEqual(by_market["SPREAD"]["odds"], 1.91) + + def test_compute_data_quality_flags_missing_referee_and_lineup(self): + data = MatchData( + match_id="dq-1", + home_team_id="h", + away_team_id="a", + home_team_name="Home", + away_team_name="Away", + match_date_ms=1760000000000, + sport="football", + league_id=None, + league_name="", + referee_name=None, + odds_data={"ms_h": 2.5, "ms_d": 3.2, "ms_a": 2.9}, + home_lineup=["h1", "h2"], + away_lineup=["a1"], + sidelined_data=None, + home_goals_avg=1.5, + home_conceded_avg=1.2, + away_goals_avg=1.2, + away_conceded_avg=1.4, + home_position=10, + away_position=10, + lineup_source="none", + ) + + quality = self.orchestrator._compute_data_quality(data) + + self.assertIn("lineup_incomplete", quality["flags"]) + self.assertIn("missing_referee", quality["flags"]) + self.assertEqual(quality["label"], "MEDIUM") + + def test_load_match_data_returns_none_when_team_ids_missing(self): + live_row = { + "match_id": "live-missing-ids", + "home_team_id": None, + "away_team_id": None, + "league_id": "l-1", + "sport": "football", + "match_date_ms": 1760000000000, + "odds": None, + "lineups": None, + "sidelined": None, + "referee_name": None, + "home_team_name": "Home", + "away_team_name": "Away", + "league_name": "League", + } + cursor = _RouterCursor(live_row=live_row) + + with patch("services.single_match_orchestrator.psycopg2.connect", return_value=_ConnContext(cursor)): + data = self.orchestrator._load_match_data("live-missing-ids") + + self.assertIsNone(data) + + def test_decorate_market_row_blocks_required_market_when_odds_missing(self): + data = MatchData( + match_id="dq-odds", + home_team_id="h", + away_team_id="a", + home_team_name="Home", + away_team_name="Away", + match_date_ms=1760000000000, + sport="football", + league_id="l1", + league_name="League", + referee_name="Ref", + odds_data={"ms_h": 2.2, "ms_d": 3.2, "ms_a": 3.0}, + home_lineup=["h"] * 11, + away_lineup=["a"] * 11, + sidelined_data=None, + home_goals_avg=1.5, + home_conceded_avg=1.2, + away_goals_avg=1.2, + away_conceded_avg=1.4, + home_position=7, + away_position=9, + lineup_source="confirmed_live", + ) + prediction = FullMatchPrediction(match_id="dq-odds", home_team="Home", away_team="Away") + quality = self.orchestrator._compute_data_quality(data) + row = { + "market": "HT_OU05", + "pick": "İY 0.5 Üst", + "probability": 0.65, + "confidence": 66.0, + "odds": 0.0, + } + + out = self.orchestrator._decorate_market_row(data, prediction, quality, row) + self.assertFalse(out["playable"]) + self.assertIn("market_odds_missing", out["decision_reasons"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ai-engine/tests/test_skip_logic.py b/ai-engine/tests/test_skip_logic.py new file mode 100644 index 0000000..eebb67f --- /dev/null +++ b/ai-engine/tests/test_skip_logic.py @@ -0,0 +1,142 @@ +""" +Unit Test for NEW Skip Logic in BetRecommender +============================================== +Run with: python ai-engine/tests/test_skip_logic.py +""" + +import os +import sys +import unittest +from dataclasses import dataclass +from typing import Optional + +# Add paths +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) + +from core.calculators.bet_recommender import BetRecommender, RecommendationResult, MarketPredictionDTO +from core.calculators.risk_assessor import RiskAnalysis +from core.calculators.match_result_calculator import MatchResultPrediction +from core.calculators.over_under_calculator import OverUnderPrediction +from config.config_loader import get_config + +@dataclass +class DummyContext: + """Minimal mock for CalculationContext""" + odds_data: dict + +class TestSkipLogic(unittest.TestCase): + + def setUp(self): + # Mock config to pass into BetRecommender + self.mock_config = { + "recommendations.market_weights": {"MS": 1.0, "ÇŞ": 0.9, "BTTS": 0.9, "2.5 Üst/Alt": 0.9}, + "recommendations.safe_markets": ["ÇŞ", "1.5 Üst/Alt"], + "recommendations.market_accuracy": {"MS": 65, "ÇŞ": 75, "BTTS": 60, "2.5 Üst/Alt": 65}, + "recommendations.baseline_accuracy": 65.0, + "recommendations.confidence_threshold": 60, + "recommendations.value_confidence_min": 45, + "recommendations.value_confidence_max": 60, + "recommendations.value_edge_margin": 0.03, + "recommendations.value_upgrade_edge": 5.0, + "recommendations.risk_safe_boost": 1.2, + "recommendations.risk_ms_penalty_high": 0.5, + "recommendations.risk_other_penalty": 0.7, + "recommendations.risk_ms_penalty_medium": 0.8, + } + self.recommender = BetRecommender(self.mock_config) + + def _make_risk(self, level="MEDIUM", is_surprise=False): + return RiskAnalysis(risk_level=level, is_surprise_risk=is_surprise, risk_score=0.5) + + def _make_ms_pred(self, pick, conf): + # pick: "1", "X", "2" + probs = {"1": {"ms_home_prob": 0.5, "ms_draw_prob": 0.3, "ms_away_prob": 0.2}, + "X": {"ms_home_prob": 0.2, "ms_draw_prob": 0.5, "ms_away_prob": 0.3}, + "2": {"ms_home_prob": 0.2, "ms_draw_prob": 0.3, "ms_away_prob": 0.5}} + p = probs.get(pick, probs["1"]) + return MatchResultPrediction( + ms_pick=pick, ms_confidence=conf, + dc_pick="1X", dc_confidence=0, + dc_1x_prob=0.7, dc_x2_prob=0.7, dc_12_prob=0.7, + **p + ) + + def _make_ou_pred(self): + return OverUnderPrediction( + ou25_pick="2.5 Üst", ou25_confidence=50.0, + over_25_prob=0.55, under_25_prob=0.45, + + btts_pick="Var", btts_confidence=50.0, + btts_yes_prob=0.55, btts_no_prob=0.45, + + ou15_pick="1.5 Üst", ou15_confidence=60.0, over_15_prob=0.7, under_15_prob=0.3, + ou35_pick="3.5 Alt", ou35_confidence=50.0, over_35_prob=0.3, under_35_prob=0.7 + ) + + def test_low_confidence_should_skip(self): + """Confidence < 45% should be SKIPPED""" + ms_pred = self._make_ms_pred(pick="2", conf=40.0) + ou_pred = self._make_ou_pred() + risk = self._make_risk("MEDIUM") + ctx = DummyContext(odds_data={"ms_2": 2.5}) + + res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk) + + # Check if MS bet is skipped + ms_bet = next((b for b in res.skipped_bets if b.market_type == "MS"), None) + self.assertIsNotNone(ms_bet, "MS bet with 40% conf should be skipped!") + self.assertTrue(ms_bet.is_skip) + + def test_good_confidence_should_recommend(self): + """Confidence > 60% and Good Odds should be RECOMMENDED""" + ms_pred = self._make_ms_pred(pick="1", conf=70.0) + ou_pred = self._make_ou_pred() + risk = self._make_risk("MEDIUM") + # Odds 1.80 for 70% prob = Good Value (Need real odds for MS to pass) + ctx = DummyContext(odds_data={"ms_1": 1.80, "ou15_o": 1.50}) # Added ou15 odds + + res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk) + + # Check if ANY bet is recommended (doesn't have to be MS, but usually is) + self.assertGreater(len(res.recommended_bets), 0, "At least one bet should be recommended!") + # Check that MS bet is NOT skipped + ms_bet = next((b for b in res.recommended_bets if b.market_type == "MS"), None) + if ms_bet: + self.assertFalse(ms_bet.is_skip) + + def test_negative_edge_should_skip(self): + """Even with high confidence, if Odds are too low (Bad Value), SKIP""" + ms_pred = self._make_ms_pred(pick="1", conf=70.0) # 70% prob + ou_pred = self._make_ou_pred() + risk = self._make_risk("MEDIUM") + # Odds 1.10 -> Implied 90%. Our prob is 70%. Edge is -20% -> SKIP + ctx = DummyContext(odds_data={"ms_1": 1.10}) + + res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk) + + ms_bet = next((b for b in res.skipped_bets if b.market_type == "MS"), None) + self.assertIsNotNone(ms_bet, "MS bet with terrible odds (Negative Edge) should be skipped!") + self.assertTrue(ms_bet.is_skip) + + def test_no_bets_recommendation(self): + """If all bets are low confidence, best_bet should be None""" + ms_pred = self._make_ms_pred(pick="1", conf=30.0) # Very low conf + ou_pred = self._make_ou_pred() + # Reset ALL OU confs to low + ou_pred.ou25_confidence = 30.0 + ou_pred.btts_confidence = 30.0 + ou_pred.ou15_confidence = 30.0 # This was 60 in setUp, causing the fail! + ou_pred.ou35_confidence = 30.0 + + risk = self._make_risk("MEDIUM") + ctx = DummyContext(odds_data={"ms_1": 2.0}) + + res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk) + + self.assertIsNone(res.best_bet, "If everything is skipped, there should be no best_bet.") + self.assertEqual(len(res.recommended_bets), 0, "No bets should be recommended!") + +if __name__ == '__main__': + print("🧪 Running Skip Logic Unit Tests...") + print("="*50) + unittest.main(verbosity=2) diff --git a/package.json b/package.json index 30a0416..53c4797 100755 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "cleanup:live": "ts-node -r tsconfig-paths/register src/scripts/cleanup-live-matches.ts", "swagger:summary": "ts-node -r tsconfig-paths/register src/scripts/export-swagger-endpoints-summary.ts", "postman:export": "ts-node -r tsconfig-paths/register src/scripts/export-postman-collection.ts", + "predictions:backfill": "ts-node --transpile-only -r tsconfig-paths/register src/scripts/backfill-prediction-runs.ts", + "predictions:report": "ts-node --transpile-only -r tsconfig-paths/register src/scripts/print-backtest-report.ts", "ai:extract:v26": "python3 ai-engine/scripts/extract_training_data_v26.py", "ai:train:v26": "python3 ai-engine/scripts/train_v26_shadow.py", "ai:backtest:v26": "python3 ai-engine/scripts/backtest_v26_shadow.py", @@ -130,4 +132,4 @@ "prisma": { "seed": "ts-node prisma/seed.ts" } -} +} \ No newline at end of file diff --git a/src/scripts/backfill-prediction-runs.ts b/src/scripts/backfill-prediction-runs.ts new file mode 100644 index 0000000..7426f27 --- /dev/null +++ b/src/scripts/backfill-prediction-runs.ts @@ -0,0 +1,234 @@ +/** + * =================================================== + * HISTORICAL PREDICTION BACKFILL + * =================================================== + * Replays v28-pro-max (or any engine) against historical + * finished matches to populate prediction_runs with + * payload_summary + odds_snapshot + eventual_outcome + + * unit_profit, so we can compute true ROI / calibration. + * + * Requires ENABLE_BACKFILL=true to prevent accidental run. + * + * Usage: + * ENABLE_BACKFILL=true npx ts-node --transpile-only \ + * -r tsconfig-paths/register src/scripts/backfill-prediction-runs.ts \ + * --from 2023-05-01 --to 2025-12-31 --limit 50000 --sport football + */ + +import { PrismaClient } from "@prisma/client"; +import axios from "axios"; +import { + resolveOutcomeForPick, + computeUnitProfit, + type MatchResult, +} from "../tasks/prediction-settlement.market-resolver"; + +const prisma = new PrismaClient(); +const AI_ENGINE_URL = process.env.AI_ENGINE_URL || "http://127.0.0.1:3005"; +const REQUEST_DELAY_MS = Number(process.env.BACKFILL_DELAY_MS ?? 300); +const CONCURRENCY = Number(process.env.BACKFILL_CONCURRENCY ?? 2); + +function parseArgs() { + const args = process.argv.slice(2); + const opts: Record = {}; + for (let i = 0; i < args.length; i += 2) { + opts[args[i].replace(/^--/, "")] = args[i + 1]; + } + return { + from: opts.from ?? "2023-05-01", + to: opts.to ?? new Date().toISOString().slice(0, 10), + limit: Number(opts.limit ?? 50000), + sport: (opts.sport ?? "football") as "football" | "basketball", + engine: opts.engine ?? "v28-pro-max", + }; +} + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function runOne(matchId: string, engine: string): Promise { + try { + const resp = await axios.post( + `${AI_ENGINE_URL}/v20plus/analyze/${matchId}`, + { engine_version: engine }, + { timeout: 25000 }, + ); + return resp.data; + } catch (err: any) { + console.error( + ` ✗ ${matchId} ${err?.response?.status ?? ""} ${err?.message ?? ""}`, + ); + return null; + } +} + +function buildPayloadSummary(payload: any): Record { + const main = payload?.main_pick ?? null; + const value = payload?.value_pick ?? null; + const advice = payload?.bet_advice ?? {}; + return { + model_version: payload?.model_version, + decision_trace_id: payload?.decision_trace_id ?? null, + main_pick: main + ? { + market: main.market, + pick: main.pick, + playable: main.playable, + bet_grade: main.bet_grade, + calibrated_confidence: main.calibrated_confidence, + ev_edge: main.ev_edge ?? 0, + stake_units: main.stake_units, + odds: main.odds ?? null, + } + : null, + value_pick: value + ? { + market: value.market, + pick: value.pick, + playable: value.playable, + calibrated_confidence: value.calibrated_confidence, + ev_edge: value.ev_edge ?? 0, + odds: value.odds ?? null, + } + : null, + bet_advice: { + playable: advice.playable ?? false, + suggested_stake_units: advice.suggested_stake_units ?? 0, + }, + }; +} + +function settleFromPayload( + payload: any, + result: MatchResult, +): { outcome: string; unitProfit: number } { + const advice = payload?.bet_advice ?? {}; + if (advice.playable !== true) return { outcome: "NO_BET", unitProfit: 0 }; + + const main = payload?.main_pick; + if (!main || !main.playable) return { outcome: "NO_BET", unitProfit: 0 }; + + const pickRef = { + market: String(main.market), + pick: String(main.pick), + stake_units: Number(main.stake_units ?? advice.suggested_stake_units ?? 1), + odds: Number(main.odds ?? 0) || null, + }; + const won = resolveOutcomeForPick(pickRef, result); + if (won === null) { + return { outcome: "NO_BET", unitProfit: 0 }; + } + return { + outcome: `${won ? "WON" : "LOST"}:${pickRef.market}:${pickRef.pick}`, + unitProfit: computeUnitProfit(won, pickRef.stake_units, pickRef.odds), + }; +} + +async function main() { + if (process.env.ENABLE_BACKFILL !== "true") { + console.error( + "✗ Backfill is gated. Re-run with ENABLE_BACKFILL=true to proceed.", + ); + process.exit(1); + } + + const opts = parseArgs(); + console.log("📦 Backfill prediction_runs"); + console.log(` Range: ${opts.from} → ${opts.to}`); + console.log(` Sport: ${opts.sport}`); + console.log(` Engine: ${opts.engine}`); + console.log(` Limit: ${opts.limit}`); + console.log(` AI engine: ${AI_ENGINE_URL}`); + + const fromMs = BigInt(new Date(opts.from).getTime()); + const toMs = BigInt(new Date(opts.to).getTime()); + + const matches = await prisma.match.findMany({ + where: { + sport: opts.sport as any, + status: "FT", + scoreHome: { not: null }, + scoreAway: { not: null }, + mstUtc: { gte: fromMs, lte: toMs }, + }, + orderBy: { mstUtc: "asc" }, + take: opts.limit, + select: { + id: true, + mstUtc: true, + scoreHome: true, + scoreAway: true, + htScoreHome: true, + htScoreAway: true, + }, + }); + + console.log(`\n ${matches.length} finished matches in range\n`); + + let predicted = 0; + let written = 0; + let skipped = 0; + + for (let i = 0; i < matches.length; i += CONCURRENCY) { + const batch = matches.slice(i, i + CONCURRENCY); + await Promise.all( + batch.map(async (m) => { + const existing = await prisma.predictionRun.findFirst({ + where: { matchId: m.id, engineVersion: opts.engine }, + select: { id: true }, + }); + if (existing) { + skipped += 1; + return; + } + + const payload = await runOne(m.id, opts.engine); + if (!payload) return; + predicted += 1; + + const summary = buildPayloadSummary(payload); + const result: MatchResult = { + scoreHome: m.scoreHome!, + scoreAway: m.scoreAway!, + htScoreHome: m.htScoreHome, + htScoreAway: m.htScoreAway, + }; + const settled = settleFromPayload(payload, result); + + await prisma.predictionRun.create({ + data: { + matchId: m.id, + engineVersion: payload?.model_version ?? opts.engine, + decisionTraceId: payload?.decision_trace_id ?? null, + oddsSnapshot: payload?.odds_snapshot ?? null, + payloadSummary: summary, + eventualOutcome: settled.outcome, + unitProfit: settled.unitProfit, + }, + }); + written += 1; + }), + ); + + if ((i / CONCURRENCY) % 25 === 0) { + console.log( + ` [${i + batch.length}/${matches.length}] predicted=${predicted} written=${written} skipped=${skipped}`, + ); + } + await sleep(REQUEST_DELAY_MS); + } + + console.log("\n✅ Backfill complete"); + console.log(` Predicted: ${predicted}`); + console.log(` Written: ${written}`); + console.log(` Skipped: ${skipped}`); + + await prisma.$disconnect(); +} + +main().catch(async (err) => { + console.error(err); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/src/scripts/print-backtest-report.ts b/src/scripts/print-backtest-report.ts new file mode 100644 index 0000000..2cd76e4 --- /dev/null +++ b/src/scripts/print-backtest-report.ts @@ -0,0 +1,208 @@ +/** + * =================================================== + * BACKTEST REPORT + * =================================================== + * Reads prediction_runs (with eventual_outcome + unit_profit + * already filled by settlement / backfill) and prints + * per-engine x per-market ROI tables. Also writes JSON. + * + * Usage: + * npx ts-node --transpile-only -r tsconfig-paths/register \ + * src/scripts/print-backtest-report.ts [--engine v28-pro-max] + */ + +import { PrismaClient, Prisma } from "@prisma/client"; +import * as fs from "fs"; +import * as path from "path"; + +const prisma = new PrismaClient(); + +interface RawRow { + engine_version: string; + market: string | null; + bet_grade: string | null; + outcome_kind: "WON" | "LOST" | "NO_BET"; + unit_profit: number | null; + count: bigint; +} + +interface AggregatedBucket { + engine: string; + market: string; + betGrade: string; + totalBets: number; + wins: number; + losses: number; + noBets: number; + totalProfit: number; + roi: number; + winRate: number; +} + +function parseArgs() { + const args = process.argv.slice(2); + const opts: Record = {}; + for (let i = 0; i < args.length; i += 2) { + opts[args[i].replace(/^--/, "")] = args[i + 1]; + } + return { engineFilter: opts.engine ?? null }; +} + +async function loadRows(engineFilter: string | null): Promise { + const engineClause = engineFilter + ? Prisma.sql`AND pr.engine_version = ${engineFilter}` + : Prisma.sql``; + + return prisma.$queryRaw(Prisma.sql` + SELECT pr.engine_version, + pr.payload_summary->'main_pick'->>'market' AS market, + pr.payload_summary->'main_pick'->>'bet_grade' AS bet_grade, + CASE + WHEN pr.eventual_outcome LIKE 'WON:%' THEN 'WON' + WHEN pr.eventual_outcome LIKE 'LOST:%' THEN 'LOST' + ELSE 'NO_BET' + END AS outcome_kind, + pr.unit_profit, + 1::bigint AS count + FROM prediction_runs pr + WHERE pr.eventual_outcome IS NOT NULL + ${engineClause} + `); +} + +function aggregate(rows: RawRow[]): AggregatedBucket[] { + const map = new Map(); + for (const r of rows) { + const market = r.market ?? "(none)"; + const betGrade = r.bet_grade ?? "(none)"; + const key = `${r.engine_version}|${market}|${betGrade}`; + if (!map.has(key)) { + map.set(key, { + engine: r.engine_version, + market, + betGrade, + totalBets: 0, + wins: 0, + losses: 0, + noBets: 0, + totalProfit: 0, + roi: 0, + winRate: 0, + }); + } + const b = map.get(key)!; + if (r.outcome_kind === "WON") b.wins += 1; + else if (r.outcome_kind === "LOST") b.losses += 1; + else b.noBets += 1; + if (r.outcome_kind !== "NO_BET") b.totalBets += 1; + b.totalProfit += Number(r.unit_profit ?? 0); + } + + const out = Array.from(map.values()); + for (const b of out) { + b.roi = b.totalBets > 0 ? b.totalProfit / b.totalBets : 0; + b.winRate = b.totalBets > 0 ? b.wins / b.totalBets : 0; + } + return out.sort((a, b) => b.totalBets - a.totalBets); +} + +function printTable(buckets: AggregatedBucket[]) { + const header = [ + "engine".padEnd(20), + "market".padEnd(10), + "grade".padEnd(6), + "bets".padStart(7), + "wins".padStart(7), + "losses".padStart(7), + "noBet".padStart(7), + "winRate".padStart(8), + "profit".padStart(10), + "ROI".padStart(8), + ].join(" │ "); + console.log(header); + console.log("─".repeat(header.length)); + for (const b of buckets) { + console.log( + [ + b.engine.padEnd(20), + b.market.padEnd(10), + b.betGrade.padEnd(6), + String(b.totalBets).padStart(7), + String(b.wins).padStart(7), + String(b.losses).padStart(7), + String(b.noBets).padStart(7), + `${(b.winRate * 100).toFixed(1)}%`.padStart(8), + b.totalProfit.toFixed(2).padStart(10), + `${(b.roi * 100).toFixed(2)}%`.padStart(8), + ].join(" │ "), + ); + } +} + +function engineSummary(buckets: AggregatedBucket[]) { + const byEngine = new Map(); + for (const b of buckets) { + if (!byEngine.has(b.engine)) { + byEngine.set(b.engine, { + engine: b.engine, + market: "ALL", + betGrade: "ALL", + totalBets: 0, + wins: 0, + losses: 0, + noBets: 0, + totalProfit: 0, + roi: 0, + winRate: 0, + }); + } + const acc = byEngine.get(b.engine)!; + acc.totalBets += b.totalBets; + acc.wins += b.wins; + acc.losses += b.losses; + acc.noBets += b.noBets; + acc.totalProfit += b.totalProfit; + } + const out = Array.from(byEngine.values()); + for (const e of out) { + e.roi = e.totalBets > 0 ? e.totalProfit / e.totalBets : 0; + e.winRate = e.totalBets > 0 ? e.wins / e.totalBets : 0; + } + return out; +} + +async function main() { + const { engineFilter } = parseArgs(); + console.log("📊 Backtest report"); + if (engineFilter) console.log(` filter engine = ${engineFilter}`); + + const rows = await loadRows(engineFilter); + console.log(` ${rows.length} resolved prediction_runs loaded\n`); + + const buckets = aggregate(rows); + const enginesAll = engineSummary(buckets); + + console.log("=== Per engine (all markets) ==="); + printTable(enginesAll); + console.log("\n=== Per engine × market × bet_grade ==="); + printTable(buckets); + + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const outDir = path.join(process.cwd(), "reports"); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir); + const outFile = path.join(outDir, `backtest-${ts}.json`); + fs.writeFileSync( + outFile, + JSON.stringify({ engines: enginesAll, buckets }, null, 2), + "utf8", + ); + console.log(`\n💾 Saved ${outFile}`); + + await prisma.$disconnect(); +} + +main().catch(async (err) => { + console.error(err); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/src/tasks/prediction-settlement.market-resolver.ts b/src/tasks/prediction-settlement.market-resolver.ts new file mode 100644 index 0000000..56ec40f --- /dev/null +++ b/src/tasks/prediction-settlement.market-resolver.ts @@ -0,0 +1,127 @@ +export interface MatchResult { + scoreHome: number; + scoreAway: number; + htScoreHome: number | null; + htScoreAway: number | null; +} + +export interface PickRef { + market: string; + pick: string; + stake_units: number; + odds: number | null; +} + +type Resolver = (pick: string, r: MatchResult) => boolean | null; + +const ms1x2: Resolver = (pick, r) => { + const outcome = r.scoreHome > r.scoreAway ? "1" : r.scoreHome < r.scoreAway ? "2" : "X"; + return pick === outcome; +}; + +const ht1x2: Resolver = (pick, r) => { + if (r.htScoreHome === null || r.htScoreAway === null) return null; + const outcome = + r.htScoreHome > r.htScoreAway ? "1" : r.htScoreHome < r.htScoreAway ? "2" : "X"; + return pick === outcome; +}; + +const overUnder = (line: number): Resolver => (pick, r) => { + const total = r.scoreHome + r.scoreAway; + if (total === line) return null; + const isOver = total > line; + if (pick === "Üst" || pick === "Ust" || pick.toLowerCase() === "over") return isOver; + if (pick === "Alt" || pick.toLowerCase() === "under") return !isOver; + return null; +}; + +const overUnderHt = (line: number): Resolver => (pick, r) => { + if (r.htScoreHome === null || r.htScoreAway === null) return null; + const total = r.htScoreHome + r.htScoreAway; + if (total === line) return null; + const isOver = total > line; + if (pick === "Üst" || pick === "Ust" || pick.toLowerCase() === "over") return isOver; + if (pick === "Alt" || pick.toLowerCase() === "under") return !isOver; + return null; +}; + +const btts: Resolver = (pick, r) => { + const both = r.scoreHome > 0 && r.scoreAway > 0; + if (pick === "Var" || pick === "KG Var" || pick.toLowerCase() === "yes") return both; + if (pick === "Yok" || pick === "KG Yok" || pick.toLowerCase() === "no") return !both; + return null; +}; + +const htft: Resolver = (pick, r) => { + if (r.htScoreHome === null || r.htScoreAway === null) return null; + const ht = + r.htScoreHome > r.htScoreAway ? "1" : r.htScoreHome < r.htScoreAway ? "2" : "X"; + const ft = r.scoreHome > r.scoreAway ? "1" : r.scoreHome < r.scoreAway ? "2" : "X"; + const normalized = pick.replace(/\s/g, "").toUpperCase(); + return normalized === `${ht}/${ft}`; +}; + +const doubleChance: Resolver = (pick, r) => { + const ft = r.scoreHome > r.scoreAway ? "1" : r.scoreHome < r.scoreAway ? "2" : "X"; + const normalized = pick.replace(/\s/g, "").toUpperCase().split(/[\/\-]/); + if (normalized.length !== 2) return null; + return normalized.includes(ft); +}; + +const oddEven: Resolver = (pick, r) => { + const total = r.scoreHome + r.scoreAway; + const isOdd = total % 2 === 1; + if (pick === "Tek" || pick.toLowerCase() === "odd") return isOdd; + if (pick === "Çift" || pick === "Cift" || pick.toLowerCase() === "even") return !isOdd; + return null; +}; + +const resolvers: Record = { + MS: ms1x2, + ML: ms1x2, + "1X2": ms1x2, + HT: ht1x2, + IY: ht1x2, + OU05: overUnder(0.5), + OU15: overUnder(1.5), + OU25: overUnder(2.5), + OU35: overUnder(3.5), + OU45: overUnder(4.5), + TOTAL: overUnder(2.5), + OU05_HT: overUnderHt(0.5), + OU15_HT: overUnderHt(1.5), + OU25_HT: overUnderHt(2.5), + BTTS: btts, + KG: btts, + HTFT: htft, + IYMS: htft, + DC: doubleChance, + CIFTE_SANS: doubleChance, + OE: oddEven, + TEKCIFT: oddEven, +}; + +export function resolveOutcomeForPick( + pick: PickRef, + result: MatchResult, +): boolean | null { + const market = pick.market.toUpperCase().replace(/[\s\-]/g, "_"); + const resolver = resolvers[market] ?? resolvers[pick.market]; + if (!resolver) return null; + try { + return resolver(pick.pick, result); + } catch { + return null; + } +} + +export function computeUnitProfit( + won: boolean, + stakeUnits: number, + odds: number | null, +): number { + const stake = Number.isFinite(stakeUnits) && stakeUnits > 0 ? stakeUnits : 1; + if (!won) return -stake; + if (!odds || odds <= 1) return 0; + return Number((stake * (odds - 1)).toFixed(4)); +} diff --git a/src/tasks/prediction-settlement.task.ts b/src/tasks/prediction-settlement.task.ts new file mode 100644 index 0000000..d65ab17 --- /dev/null +++ b/src/tasks/prediction-settlement.task.ts @@ -0,0 +1,179 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Cron } from "@nestjs/schedule"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../database/prisma.service"; +import { TaskLockService } from "./task-lock.service"; +import { + resolveOutcomeForPick, + computeUnitProfit, + type MatchResult, + type PickRef, +} from "./prediction-settlement.market-resolver"; + +interface UnresolvedRow { + id: bigint; + match_id: string; + payload_summary: Prisma.JsonValue; + odds_snapshot: Prisma.JsonValue; + score_home: number | null; + score_away: number | null; + ht_score_home: number | null; + ht_score_away: number | null; + status: string | null; +} + +const BATCH_SIZE = 500; + +@Injectable() +export class PredictionSettlementTask { + private readonly logger = new Logger(PredictionSettlementTask.name); + + constructor( + private readonly prisma: PrismaService, + private readonly taskLock: TaskLockService, + ) {} + + /** + * Runs after the previous-day match sync. Reconciles prediction_runs against actual results. + */ + @Cron("30 8 * * *", { timeZone: "Europe/Istanbul" }) + async settleCompletedPredictions() { + if (process.env.FEEDER_MODE === "historical") { + this.logger.debug("Skipping settlement in historical feeder mode"); + return; + } + await this.taskLock.runWithLease( + "settleCompletedPredictions", + 2 * 60 * 60 * 1000, + () => this.runSettlement(), + this.logger, + ); + } + + async runSettlement(): Promise<{ scanned: number; updated: number }> { + let scanned = 0; + let updated = 0; + let cursor = 0n; + + for (;;) { + const rows = await this.prisma.$queryRaw(Prisma.sql` + SELECT pr.id, + pr.match_id, + pr.payload_summary, + pr.odds_snapshot, + m.score_home, + m.score_away, + m.ht_score_home, + m.ht_score_away, + m.status + FROM prediction_runs pr + JOIN matches m ON m.id = pr.match_id + WHERE pr.eventual_outcome IS NULL + AND pr.id > ${cursor} + AND m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + ORDER BY pr.id ASC + LIMIT ${BATCH_SIZE} + `); + + if (rows.length === 0) break; + + for (const row of rows) { + scanned += 1; + const settled = this.settleRow(row); + if (settled === null) continue; + + await this.prisma.$executeRaw(Prisma.sql` + UPDATE prediction_runs + SET eventual_outcome = ${settled.outcome}, + unit_profit = ${settled.unitProfit} + WHERE id = ${row.id} + `); + updated += 1; + } + + cursor = rows[rows.length - 1].id; + } + + this.logger.log( + `Settlement finished: scanned=${scanned} updated=${updated}`, + ); + return { scanned, updated }; + } + + private settleRow( + row: UnresolvedRow, + ): { outcome: string; unitProfit: number } | null { + const summary = (row.payload_summary ?? {}) as Record; + const pick = this.pickToEvaluate(summary); + if (!pick) { + return { outcome: "NO_BET", unitProfit: 0 }; + } + + const result: MatchResult = { + scoreHome: row.score_home!, + scoreAway: row.score_away!, + htScoreHome: row.ht_score_home, + htScoreAway: row.ht_score_away, + }; + + const won = resolveOutcomeForPick(pick, result); + if (won === null) { + this.logger.debug( + `Cannot resolve market=${pick.market} pick=${pick.pick} for run=${row.id}`, + ); + return null; + } + + const odds = this.extractOdds(row.odds_snapshot, pick); + const stake = pick.stake_units ?? 1; + const profit = computeUnitProfit(won, stake, odds); + + return { + outcome: `${won ? "WON" : "LOST"}:${pick.market}:${pick.pick}`, + unitProfit: profit, + }; + } + + private pickToEvaluate(summary: Record): PickRef | null { + const advice = summary.bet_advice ?? {}; + if (advice.playable !== true) return null; + + const main = summary.main_pick; + if (main && main.playable && main.market && main.pick) { + return { + market: String(main.market), + pick: String(main.pick), + stake_units: Number(main.stake_units ?? advice.suggested_stake_units ?? 1), + odds: Number(main.odds ?? 0) || null, + }; + } + + const fallback = summary.value_pick; + if (fallback && fallback.playable && fallback.market && fallback.pick) { + return { + market: String(fallback.market), + pick: String(fallback.pick), + stake_units: Number(advice.suggested_stake_units ?? 1), + odds: Number(fallback.odds ?? 0) || null, + }; + } + return null; + } + + private extractOdds( + oddsSnapshot: Prisma.JsonValue, + pick: PickRef, + ): number | null { + if (pick.odds && pick.odds > 1) return pick.odds; + if (!oddsSnapshot || typeof oddsSnapshot !== "object") return null; + const snap = oddsSnapshot as Record; + const odds = snap.odds; + if (!odds || typeof odds !== "object") return null; + const category = odds[pick.market]; + if (!category || typeof category !== "object") return null; + const value = Number(category[pick.pick]); + return Number.isFinite(value) && value > 1 ? value : null; + } +} diff --git a/src/tasks/tasks.module.ts b/src/tasks/tasks.module.ts index 281bd2d..d522e16 100755 --- a/src/tasks/tasks.module.ts +++ b/src/tasks/tasks.module.ts @@ -3,6 +3,7 @@ import { HttpModule } from "@nestjs/axios"; import { DataFetcherTask } from "./data-fetcher.task"; import { HistoricalResultsSyncTask } from "./historical-results-sync.task"; import { LimitResetterTask } from "./limit-resetter.task"; +import { PredictionSettlementTask } from "./prediction-settlement.task"; import { TaskLockService } from "./task-lock.service"; import { DatabaseModule } from "../database/database.module"; import { FeederModule } from "../modules/feeder/feeder.module"; @@ -24,7 +25,13 @@ import { FeederModule } from "../modules/feeder/feeder.module"; DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask, + PredictionSettlementTask, + ], + exports: [ + DataFetcherTask, + HistoricalResultsSyncTask, + LimitResetterTask, + PredictionSettlementTask, ], - exports: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask], }) export class TasksModule {}