@@ -689,4 +689,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
+3
-1
@@ -30,6 +30,8 @@
|
|||||||
"cleanup:live": "ts-node -r tsconfig-paths/register src/scripts/cleanup-live-matches.ts",
|
"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",
|
"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",
|
"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:extract:v26": "python3 ai-engine/scripts/extract_training_data_v26.py",
|
||||||
"ai:train:v26": "python3 ai-engine/scripts/train_v26_shadow.py",
|
"ai:train:v26": "python3 ai-engine/scripts/train_v26_shadow.py",
|
||||||
"ai:backtest:v26": "python3 ai-engine/scripts/backtest_v26_shadow.py",
|
"ai:backtest:v26": "python3 ai-engine/scripts/backtest_v26_shadow.py",
|
||||||
@@ -130,4 +132,4 @@
|
|||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed.ts"
|
"seed": "ts-node prisma/seed.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<string, string> = {};
|
||||||
|
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<any | null> {
|
||||||
|
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<string, any> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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<string, string> = {};
|
||||||
|
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<RawRow[]> {
|
||||||
|
const engineClause = engineFilter
|
||||||
|
? Prisma.sql`AND pr.engine_version = ${engineFilter}`
|
||||||
|
: Prisma.sql``;
|
||||||
|
|
||||||
|
return prisma.$queryRaw<RawRow[]>(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<string, AggregatedBucket>();
|
||||||
|
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<string, AggregatedBucket>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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<string, Resolver> = {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -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<UnresolvedRow[]>(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<string, any>;
|
||||||
|
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<string, any>): 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<string, any>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { HttpModule } from "@nestjs/axios";
|
|||||||
import { DataFetcherTask } from "./data-fetcher.task";
|
import { DataFetcherTask } from "./data-fetcher.task";
|
||||||
import { HistoricalResultsSyncTask } from "./historical-results-sync.task";
|
import { HistoricalResultsSyncTask } from "./historical-results-sync.task";
|
||||||
import { LimitResetterTask } from "./limit-resetter.task";
|
import { LimitResetterTask } from "./limit-resetter.task";
|
||||||
|
import { PredictionSettlementTask } from "./prediction-settlement.task";
|
||||||
import { TaskLockService } from "./task-lock.service";
|
import { TaskLockService } from "./task-lock.service";
|
||||||
import { DatabaseModule } from "../database/database.module";
|
import { DatabaseModule } from "../database/database.module";
|
||||||
import { FeederModule } from "../modules/feeder/feeder.module";
|
import { FeederModule } from "../modules/feeder/feeder.module";
|
||||||
@@ -24,7 +25,13 @@ import { FeederModule } from "../modules/feeder/feeder.module";
|
|||||||
DataFetcherTask,
|
DataFetcherTask,
|
||||||
HistoricalResultsSyncTask,
|
HistoricalResultsSyncTask,
|
||||||
LimitResetterTask,
|
LimitResetterTask,
|
||||||
|
PredictionSettlementTask,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
DataFetcherTask,
|
||||||
|
HistoricalResultsSyncTask,
|
||||||
|
LimitResetterTask,
|
||||||
|
PredictionSettlementTask,
|
||||||
],
|
],
|
||||||
exports: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask],
|
|
||||||
})
|
})
|
||||||
export class TasksModule {}
|
export class TasksModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user