3 Commits

Author SHA1 Message Date
fahricansecer f3362f266c gg 2026-05-10 22:52:05 +03:00
fahricansecer c525b12dfd gg 2026-05-10 10:37:45 +03:00
fahricansecer 4f7090e2d9 feat(ai-engine): value sniper thresholds and logic relaxed 2026-05-06 17:44:45 +03:00
43 changed files with 5251 additions and 538 deletions
+6 -6
View File
@@ -25,11 +25,11 @@ jobs:
--network iddaai_iddaai-network \ --network iddaai_iddaai-network \
-p 127.0.0.1:1810:3005 \ -p 127.0.0.1:1810:3005 \
-e NODE_ENV=production \ -e NODE_ENV=production \
-e DATABASE_URL='postgresql://iddaai_user:IddaA1_S4crET!@iddaai-postgres:5432/iddaai_db?schema=public' \ -e DATABASE_URL='${{ secrets.DATABASE_URL }}' \
-e REDIS_HOST='iddaai-redis' \ -e REDIS_HOST='${{ secrets.REDIS_HOST }}' \
-e REDIS_PORT='6379' \ -e REDIS_PORT='${{ secrets.REDIS_PORT }}' \
-e REDIS_PASSWORD='IddaA1_Redis_Pass!' \ -e REDIS_PASSWORD='${{ secrets.REDIS_PASSWORD }}' \
-e AI_ENGINE_URL='http://iddaai-ai-engine:8000' \ -e AI_ENGINE_URL='${{ secrets.AI_ENGINE_URL }}' \
-e JWT_SECRET='b7V8jM2wP1L5mQxs2RdfFkAsLpI2oG!w' \ -e JWT_SECRET='${{ secrets.JWT_SECRET }}' \
-e JWT_ACCESS_EXPIRATION='1d' \ -e JWT_ACCESS_EXPIRATION='1d' \
iddaai-be:latest /bin/sh -c "npx prisma migrate deploy && node dist/src/main.js" iddaai-be:latest /bin/sh -c "npx prisma migrate deploy && node dist/src/main.js"
+73 -4
View File
@@ -1,17 +1,19 @@
import os import os
import json
import yaml import yaml
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
class EnsembleConfig: class EnsembleConfig:
_instance: Optional['EnsembleConfig'] = None _instance: Optional['EnsembleConfig'] = None
_config: Dict[str, Any] = {} _config: Dict[str, Any] = {}
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = super(EnsembleConfig, cls).__new__(cls) cls._instance = super(EnsembleConfig, cls).__new__(cls)
cls._instance._load_config() cls._instance._load_config()
return cls._instance return cls._instance
def _load_config(self): def _load_config(self):
"""Load configuration from YAML file.""" """Load configuration from YAML file."""
config_path = os.path.join(os.path.dirname(__file__), 'ensemble_config.yaml') config_path = os.path.join(os.path.dirname(__file__), 'ensemble_config.yaml')
@@ -22,12 +24,12 @@ class EnsembleConfig:
except Exception as e: except Exception as e:
print(f"❌ Failed to load ensemble config: {e}") print(f"❌ Failed to load ensemble config: {e}")
self._config = {} self._config = {}
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
"""Get configuration value by key (supports dot notation for nested keys).""" """Get configuration value by key (supports dot notation for nested keys)."""
keys = key.split('.') keys = key.split('.')
value = self._config value = self._config
try: try:
for k in keys: for k in keys:
value = value[k] value = value[k]
@@ -35,12 +37,79 @@ class EnsembleConfig:
except (KeyError, TypeError): except (KeyError, TypeError):
return default return default
# Singleton accessor # Singleton accessor
def get_config() -> EnsembleConfig: def get_config() -> EnsembleConfig:
return EnsembleConfig() return EnsembleConfig()
# ── Market Thresholds Loader ────────────────────────────────────────────
_market_thresholds_cache: Optional[Dict[str, Any]] = None
def load_market_thresholds() -> Dict[str, Any]:
"""
Load market thresholds from JSON config file.
Returns the full config dict with 'markets' and 'defaults' keys.
Caches after first load for performance.
"""
global _market_thresholds_cache
if _market_thresholds_cache is not None:
return _market_thresholds_cache
config_path = os.path.join(os.path.dirname(__file__), 'market_thresholds.json')
try:
with open(config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
_market_thresholds_cache = data
print(f"✅ Market thresholds loaded: {len(data.get('markets', {}))} markets (v={data.get('_meta', {}).get('version', '?')})")
return data
except Exception as e:
print(f"❌ Failed to load market thresholds: {e} — using built-in defaults")
_market_thresholds_cache = {"markets": {}, "defaults": {
"calibration": 0.55,
"min_conf": 55.0,
"min_play_score": 68.0,
"min_edge": 0.02,
"odds_band_min_sample": 0.0,
"odds_band_min_edge": 0.0,
}}
return _market_thresholds_cache
def build_threshold_dict(field: str) -> Dict[str, float]:
"""
Build a flat {market: value} dict for a specific threshold field.
Usage:
calibration_map = build_threshold_dict("calibration")
# → {"MS": 0.62, "DC": 0.82, ...}
"""
data = load_market_thresholds()
markets = data.get("markets", {})
result: Dict[str, float] = {}
for market, cfg in markets.items():
if field in cfg:
result[market] = float(cfg[field])
return result
def get_threshold_default(field: str) -> float:
"""Get the default fallback value for a threshold field."""
data = load_market_thresholds()
defaults = data.get("defaults", {})
return float(defaults.get(field, 0.0))
if __name__ == "__main__": if __name__ == "__main__":
# Test # Test
cfg = get_config() cfg = get_config()
print(f"Weights: {cfg.get('engine_weights')}") print(f"Weights: {cfg.get('engine_weights')}")
print(f"Team Weight: {cfg.get('engine_weights.team')}") print(f"Team Weight: {cfg.get('engine_weights.team')}")
print()
print("--- Market Thresholds ---")
for field in ["calibration", "min_conf", "min_play_score", "min_edge"]:
d = build_threshold_dict(field)
print(f"{field}: {d}")
print(f"Default calibration: {get_threshold_default('calibration')}")
+115
View File
@@ -0,0 +1,115 @@
{
"_meta": {
"version": "v34",
"description": "Market-specific thresholds for the betting engine pipeline — V34 odds-aware gate fix",
"rule": "max_reachable (100 × calibration) MUST be > min_conf + 8",
"updated_at": "2026-05-10",
"changelog": "V34: Reduced min_edge to realistic levels for odds-aware V25 model. Model output ≈ market-implied, so large EV edges are mathematically impossible."
},
"markets": {
"MS": {
"calibration": 0.62,
"min_conf": 20.0,
"min_play_score": 28.0,
"min_edge": 0.005,
"odds_band_min_sample": 8.0,
"odds_band_min_edge": 0.005
},
"DC": {
"calibration": 0.82,
"min_conf": 40.0,
"min_play_score": 50.0,
"min_edge": 0.003,
"odds_band_min_sample": 8.0,
"odds_band_min_edge": 0.005
},
"OU15": {
"calibration": 0.84,
"min_conf": 45.0,
"min_play_score": 50.0,
"min_edge": 0.003,
"odds_band_min_sample": 8.0,
"odds_band_min_edge": 0.005
},
"OU25": {
"calibration": 0.68,
"min_conf": 30.0,
"min_play_score": 40.0,
"min_edge": 0.005,
"odds_band_min_sample": 8.0,
"odds_band_min_edge": 0.005
},
"OU35": {
"calibration": 0.60,
"min_conf": 20.0,
"min_play_score": 30.0,
"min_edge": 0.008,
"odds_band_min_sample": 8.0,
"odds_band_min_edge": 0.008
},
"BTTS": {
"calibration": 0.65,
"min_conf": 30.0,
"min_play_score": 40.0,
"min_edge": 0.005,
"odds_band_min_sample": 8.0,
"odds_band_min_edge": 0.005
},
"HT": {
"calibration": 0.58,
"min_conf": 20.0,
"min_play_score": 28.0,
"min_edge": 0.01,
"odds_band_min_sample": 8.0,
"odds_band_min_edge": 0.008
},
"HT_OU05": {
"calibration": 0.68,
"min_conf": 35.0,
"min_play_score": 42.0,
"min_edge": 0.005,
"odds_band_min_sample": 8.0,
"odds_band_min_edge": 0.005
},
"HT_OU15": {
"calibration": 0.60,
"min_conf": 25.0,
"min_play_score": 32.0,
"min_edge": 0.008,
"odds_band_min_sample": 8.0,
"odds_band_min_edge": 0.008
},
"OE": {
"calibration": 0.62,
"min_conf": 35.0,
"min_play_score": 32.0,
"min_edge": 0.005
},
"CARDS": {
"calibration": 0.58,
"min_conf": 30.0,
"min_play_score": 35.0,
"min_edge": 0.008
},
"HCAP": {
"calibration": 0.56,
"min_conf": 25.0,
"min_play_score": 30.0,
"min_edge": 0.015
},
"HTFT": {
"calibration": 0.45,
"min_conf": 10.0,
"min_play_score": 18.0,
"min_edge": 0.02
}
},
"defaults": {
"calibration": 0.55,
"min_conf": 55.0,
"min_play_score": 60.0,
"min_edge": 0.008,
"odds_band_min_sample": 0.0,
"odds_band_min_edge": 0.0
}
}
+56 -4
View File
@@ -29,7 +29,7 @@ class V27Predictor:
82-feature odds-free vector. 82-feature odds-free vector.
""" """
MARKETS = ["ms", "ou25"] MARKETS = ['ms', 'ou25', 'btts']
def __init__(self): def __init__(self):
self.models: Dict[str, Dict[str, object]] = {} self.models: Dict[str, Dict[str, object]] = {}
@@ -56,7 +56,7 @@ class V27Predictor:
return False return False
# Load models per market # Load models per market
model_types = {"xgb": "xgb", "lgb": "lgb", "cb": "cb"} model_types = {"xgb": "xgb", "lgb": "lgb"}
for market in self.MARKETS: for market in self.MARKETS:
self.models[market] = {} self.models[market] = {}
@@ -227,11 +227,63 @@ class V27Predictor:
"over": float(avg[1]), "over": float(avg[1]),
} }
def predict_btts(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
"""
Predict Both Teams To Score probabilities.
Returns dict with keys: no, yes.
"""
if not self._loaded or 'btts' not in self.models or not self.models['btts']:
return None
X = self._build_feature_array(features)
probs_list = []
for label, model in self.models['btts'].items():
proba = self._predict_with_model(model, X, f'BTTS/{label}', expected_classes=2)
if proba is not None and len(proba) == 2:
probs_list.append(proba)
if not probs_list:
return None
avg = np.mean(probs_list, axis=0)
return {
'no': float(avg[0]),
'yes': float(avg[1]),
}
def predict_dc(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
"""
Predict Double Chance probabilities.
DC is algebraically derived from MS predictions:
1X = home + draw
X2 = draw + away
12 = home + away
This gives an odds-free DC estimate for divergence detection.
"""
ms_probs = self.predict_ms(features)
if not ms_probs:
return None
home = ms_probs['home']
draw = ms_probs['draw']
away = ms_probs['away']
return {
'1x': round(home + draw, 4),
'x2': round(draw + away, 4),
'12': round(home + away, 4),
}
def predict_all(self, features: Dict[str, float]) -> Dict[str, Optional[Dict[str, float]]]: def predict_all(self, features: Dict[str, float]) -> Dict[str, Optional[Dict[str, float]]]:
"""Run predictions for all supported markets.""" """Run predictions for all supported markets."""
return { return {
"ms": self.predict_ms(features), 'ms': self.predict_ms(features),
"ou25": self.predict_ou25(features), 'ou25': self.predict_ou25(features),
'btts': self.predict_btts(features),
'dc': self.predict_dc(features),
} }
@@ -1,8 +1,8 @@
{ {
"trained_at": "2026-04-14 17:20:03", "trained_at": "2026-05-06 15:53:36",
"market_results": { "market_results": {
"MS": { "MS": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -107,19 +107,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.8938, "xgb_accuracy": 0.5437,
"xgb_logloss": 0.2263, "xgb_logloss": 0.9429,
"lgb_accuracy": 0.8938, "lgb_accuracy": 0.5436,
"lgb_logloss": 0.2214, "lgb_logloss": 0.9423,
"ensemble_accuracy": 0.8945, "ensemble_accuracy": 0.5442,
"ensemble_logloss": 0.2226, "ensemble_logloss": 0.9418,
"class_count": 3 "class_count": 3
}, },
"OU15": { "OU15": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -224,19 +224,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.9088, "xgb_accuracy": 0.753,
"xgb_logloss": 0.1758, "xgb_logloss": 0.5256,
"lgb_accuracy": 0.9067, "lgb_accuracy": 0.7523,
"lgb_logloss": 0.1783, "lgb_logloss": 0.5262,
"ensemble_accuracy": 0.9108, "ensemble_accuracy": 0.7533,
"ensemble_logloss": 0.1753, "ensemble_logloss": 0.5254,
"class_count": 2 "class_count": 2
}, },
"OU25": { "OU25": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -341,19 +341,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.9204, "xgb_accuracy": 0.6253,
"xgb_logloss": 0.1535, "xgb_logloss": 0.635,
"lgb_accuracy": 0.9224, "lgb_accuracy": 0.6246,
"lgb_logloss": 0.1523, "lgb_logloss": 0.6347,
"ensemble_accuracy": 0.9217, "ensemble_accuracy": 0.6262,
"ensemble_logloss": 0.1518, "ensemble_logloss": 0.6343,
"class_count": 2 "class_count": 2
}, },
"OU35": { "OU35": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -458,19 +458,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.9578, "xgb_accuracy": 0.7283,
"xgb_logloss": 0.1171, "xgb_logloss": 0.5463,
"lgb_accuracy": 0.9564, "lgb_accuracy": 0.7304,
"lgb_logloss": 0.1144, "lgb_logloss": 0.546,
"ensemble_accuracy": 0.9571, "ensemble_accuracy": 0.7297,
"ensemble_logloss": 0.1149, "ensemble_logloss": 0.5456,
"class_count": 2 "class_count": 2
}, },
"BTTS": { "BTTS": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -575,19 +575,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.9238, "xgb_accuracy": 0.5894,
"xgb_logloss": 0.1439, "xgb_logloss": 0.6636,
"lgb_accuracy": 0.9265, "lgb_accuracy": 0.5928,
"lgb_logloss": 0.143, "lgb_logloss": 0.6633,
"ensemble_accuracy": 0.9265, "ensemble_accuracy": 0.5897,
"ensemble_logloss": 0.1424, "ensemble_logloss": 0.6628,
"class_count": 2 "class_count": 2
}, },
"HT_RESULT": { "HT_RESULT": {
"samples": 9786, "samples": 103208,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -692,19 +692,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6850, "train_samples": 72245,
"val_samples": 1468, "val_samples": 15481,
"test_samples": 1468, "test_samples": 15482,
"xgb_accuracy": 0.5627, "xgb_accuracy": 0.4695,
"xgb_logloss": 0.8712, "xgb_logloss": 1.0174,
"lgb_accuracy": 0.5715, "lgb_accuracy": 0.4677,
"lgb_logloss": 0.8649, "lgb_logloss": 1.0166,
"ensemble_accuracy": 0.5811, "ensemble_accuracy": 0.4688,
"ensemble_logloss": 0.8649, "ensemble_logloss": 1.0164,
"class_count": 3 "class_count": 3
}, },
"HT_OU05": { "HT_OU05": {
"samples": 9786, "samples": 103208,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -809,19 +809,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6850, "train_samples": 72245,
"val_samples": 1468, "val_samples": 15481,
"test_samples": 1468, "test_samples": 15482,
"xgb_accuracy": 0.7221, "xgb_accuracy": 0.7011,
"xgb_logloss": 0.5122, "xgb_logloss": 0.5939,
"lgb_accuracy": 0.7268, "lgb_accuracy": 0.7002,
"lgb_logloss": 0.5092, "lgb_logloss": 0.5936,
"ensemble_accuracy": 0.7275, "ensemble_accuracy": 0.7009,
"ensemble_logloss": 0.5084, "ensemble_logloss": 0.5932,
"class_count": 2 "class_count": 2
}, },
"HT_OU15": { "HT_OU15": {
"samples": 9786, "samples": 103208,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -926,19 +926,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6850, "train_samples": 72245,
"val_samples": 1468, "val_samples": 15481,
"test_samples": 1468, "test_samples": 15482,
"xgb_accuracy": 0.752, "xgb_accuracy": 0.6723,
"xgb_logloss": 0.5252, "xgb_logloss": 0.6126,
"lgb_accuracy": 0.7595, "lgb_accuracy": 0.6736,
"lgb_logloss": 0.5213, "lgb_logloss": 0.6118,
"ensemble_accuracy": 0.7595, "ensemble_accuracy": 0.6734,
"ensemble_logloss": 0.5192, "ensemble_logloss": 0.6117,
"class_count": 2 "class_count": 2
}, },
"HTFT": { "HTFT": {
"samples": 9786, "samples": 103208,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -1043,19 +1043,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6850, "train_samples": 72245,
"val_samples": 1468, "val_samples": 15481,
"test_samples": 1468, "test_samples": 15482,
"xgb_accuracy": 0.5136, "xgb_accuracy": 0.3337,
"xgb_logloss": 1.1384, "xgb_logloss": 1.8208,
"lgb_accuracy": 0.5184, "lgb_accuracy": 0.3332,
"lgb_logloss": 1.1469, "lgb_logloss": 1.8203,
"ensemble_accuracy": 0.5143, "ensemble_accuracy": 0.3358,
"ensemble_logloss": 1.1339, "ensemble_logloss": 1.8186,
"class_count": 9 "class_count": 9
}, },
"ODD_EVEN": { "ODD_EVEN": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -1160,19 +1160,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.8863, "xgb_accuracy": 0.5296,
"xgb_logloss": 0.3565, "xgb_logloss": 0.6841,
"lgb_accuracy": 0.8802, "lgb_accuracy": 0.5359,
"lgb_logloss": 0.3338, "lgb_logloss": 0.6822,
"ensemble_accuracy": 0.8863, "ensemble_accuracy": 0.531,
"ensemble_logloss": 0.3423, "ensemble_logloss": 0.6826,
"class_count": 2 "class_count": 2
}, },
"CARDS_OU45": { "CARDS_OU45": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -1277,19 +1277,19 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.6283, "xgb_accuracy": 0.6009,
"xgb_logloss": 0.6174, "xgb_logloss": 0.6489,
"lgb_accuracy": 0.6413, "lgb_accuracy": 0.5988,
"lgb_logloss": 0.615, "lgb_logloss": 0.6487,
"ensemble_accuracy": 0.6372, "ensemble_accuracy": 0.6024,
"ensemble_logloss": 0.6142, "ensemble_logloss": 0.6479,
"class_count": 2 "class_count": 2
}, },
"HANDICAP_MS": { "HANDICAP_MS": {
"samples": 9791, "samples": 106428,
"features_used": [ "features_used": [
"home_overall_elo", "home_overall_elo",
"away_overall_elo", "away_overall_elo",
@@ -1394,15 +1394,15 @@
"home_goals_form", "home_goals_form",
"away_goals_form" "away_goals_form"
], ],
"train_samples": 6853, "train_samples": 74499,
"val_samples": 1469, "val_samples": 15964,
"test_samples": 1469, "test_samples": 15965,
"xgb_accuracy": 0.936, "xgb_accuracy": 0.6058,
"xgb_logloss": 0.1903, "xgb_logloss": 0.8691,
"lgb_accuracy": 0.9346, "lgb_accuracy": 0.608,
"lgb_logloss": 0.1843, "lgb_logloss": 0.8677,
"ensemble_accuracy": 0.936, "ensemble_accuracy": 0.6068,
"ensemble_logloss": 0.1861, "ensemble_logloss": 0.8677,
"class_count": 3 "class_count": 3
} }
} }
@@ -0,0 +1,692 @@
{
"trained_at": "2026-05-10 19:48:06",
"trainer": "v25_pro",
"optuna_trials": 50,
"total_features": 114,
"markets": {
"MS": {
"market": "MS",
"samples": 106861,
"train": 64116,
"val": 16029,
"cal": 10686,
"test": 16030,
"features_used": 114,
"xgb_best_params": {
"max_depth": 4,
"eta": 0.022329400652878233,
"subsample": 0.6690795757813364,
"colsample_bytree": 0.5042256538541441,
"min_child_weight": 6,
"gamma": 9.960129417155444e-05,
"reg_lambda": 0.5132295377582388,
"reg_alpha": 6.804503659726287e-08
},
"lgb_best_params": {
"max_depth": 4,
"learning_rate": 0.023142410802706542,
"feature_fraction": 0.5728681432360808,
"bagging_fraction": 0.6781774410065095,
"bagging_freq": 2,
"min_child_samples": 26,
"lambda_l1": 3.25216937188593e-05,
"lambda_l2": 4.8081236902660474e-08
},
"xgb_best_iteration": 643,
"lgb_best_iteration": 441,
"xgb_optuna_best_logloss": 0.9155,
"lgb_optuna_best_logloss": 0.9146,
"test_xgb_raw": {
"accuracy": 0.5442,
"logloss": 0.943
},
"test_xgb_calibrated": {
"accuracy": 0.5404,
"logloss": 0.9438
},
"test_lgb_raw": {
"accuracy": 0.5427,
"logloss": 0.943
},
"test_lgb_calibrated": {
"accuracy": 0.5417,
"logloss": 0.9447
},
"test_ensemble_raw": {
"accuracy": 0.5437,
"logloss": 0.9426
},
"test_ensemble_calibrated": {
"accuracy": 0.5418,
"logloss": 0.9435
}
},
"OU15": {
"market": "OU15",
"samples": 106861,
"train": 64116,
"val": 16029,
"cal": 10686,
"test": 16030,
"features_used": 114,
"xgb_best_params": {
"max_depth": 5,
"eta": 0.020779487257177966,
"subsample": 0.8109935286948485,
"colsample_bytree": 0.9525413847213635,
"min_child_weight": 6,
"gamma": 0.35330347775044696,
"reg_lambda": 5.373541021746059e-07,
"reg_alpha": 0.2959430087754284
},
"lgb_best_params": {
"max_depth": 6,
"learning_rate": 0.013402310027682367,
"feature_fraction": 0.7404728146233901,
"bagging_fraction": 0.9712026511549247,
"bagging_freq": 6,
"min_child_samples": 39,
"lambda_l1": 0.39893027986899576,
"lambda_l2": 0.0626443611997599
},
"xgb_best_iteration": 353,
"lgb_best_iteration": 370,
"xgb_optuna_best_logloss": 0.499,
"lgb_optuna_best_logloss": 0.4989,
"test_xgb_raw": {
"accuracy": 0.7521,
"logloss": 0.5267
},
"test_xgb_calibrated": {
"accuracy": 0.7521,
"logloss": 0.5344
},
"test_lgb_raw": {
"accuracy": 0.7528,
"logloss": 0.5261
},
"test_lgb_calibrated": {
"accuracy": 0.7505,
"logloss": 0.5362
},
"test_ensemble_raw": {
"accuracy": 0.7518,
"logloss": 0.5261
},
"test_ensemble_calibrated": {
"accuracy": 0.7522,
"logloss": 0.5364
}
},
"OU25": {
"market": "OU25",
"samples": 106861,
"train": 64116,
"val": 16029,
"cal": 10686,
"test": 16030,
"features_used": 114,
"xgb_best_params": {
"max_depth": 5,
"eta": 0.01274409160014454,
"subsample": 0.8300258899365814,
"colsample_bytree": 0.7336425662264429,
"min_child_weight": 9,
"gamma": 2.5382243933649716e-06,
"reg_lambda": 5.096723080351853e-05,
"reg_alpha": 0.00040919711449493223
},
"lgb_best_params": {
"max_depth": 6,
"learning_rate": 0.02301514680733822,
"feature_fraction": 0.9569492061944688,
"bagging_fraction": 0.7249143523144639,
"bagging_freq": 1,
"min_child_samples": 40,
"lambda_l1": 9.954995248644963e-08,
"lambda_l2": 3.82413187126927e-06
},
"xgb_best_iteration": 475,
"lgb_best_iteration": 235,
"xgb_optuna_best_logloss": 0.6202,
"lgb_optuna_best_logloss": 0.62,
"test_xgb_raw": {
"accuracy": 0.6221,
"logloss": 0.6352
},
"test_xgb_calibrated": {
"accuracy": 0.6226,
"logloss": 0.6344
},
"test_lgb_raw": {
"accuracy": 0.6236,
"logloss": 0.6348
},
"test_lgb_calibrated": {
"accuracy": 0.6231,
"logloss": 0.6343
},
"test_ensemble_raw": {
"accuracy": 0.6239,
"logloss": 0.6349
},
"test_ensemble_calibrated": {
"accuracy": 0.6236,
"logloss": 0.6338
}
},
"OU35": {
"market": "OU35",
"samples": 106861,
"train": 64116,
"val": 16029,
"cal": 10686,
"test": 16030,
"features_used": 114,
"xgb_best_params": {
"max_depth": 4,
"eta": 0.012538827444713596,
"subsample": 0.7947923612828379,
"colsample_bytree": 0.9717654601553765,
"min_child_weight": 6,
"gamma": 0.011265216242399128,
"reg_lambda": 0.12152579364613436,
"reg_alpha": 0.013995120492957489
},
"lgb_best_params": {
"max_depth": 6,
"learning_rate": 0.013456307557939324,
"feature_fraction": 0.8208768633332759,
"bagging_fraction": 0.929472334516626,
"bagging_freq": 6,
"min_child_samples": 35,
"lambda_l1": 0.05522724221034949,
"lambda_l2": 0.21689047644122147
},
"xgb_best_iteration": 696,
"lgb_best_iteration": 412,
"xgb_optuna_best_logloss": 0.552,
"lgb_optuna_best_logloss": 0.5515,
"test_xgb_raw": {
"accuracy": 0.7314,
"logloss": 0.5466
},
"test_xgb_calibrated": {
"accuracy": 0.7293,
"logloss": 0.5482
},
"test_lgb_raw": {
"accuracy": 0.73,
"logloss": 0.5462
},
"test_lgb_calibrated": {
"accuracy": 0.7298,
"logloss": 0.5485
},
"test_ensemble_raw": {
"accuracy": 0.7312,
"logloss": 0.5462
},
"test_ensemble_calibrated": {
"accuracy": 0.7301,
"logloss": 0.5478
}
},
"BTTS": {
"market": "BTTS",
"samples": 106861,
"train": 64116,
"val": 16029,
"cal": 10686,
"test": 16030,
"features_used": 114,
"xgb_best_params": {
"max_depth": 4,
"eta": 0.023533647209064805,
"subsample": 0.7469060816054074,
"colsample_bytree": 0.8445418254808608,
"min_child_weight": 8,
"gamma": 1.0503733400514561e-08,
"reg_lambda": 2.0919595769527735e-06,
"reg_alpha": 0.027277017326535417
},
"lgb_best_params": {
"max_depth": 4,
"learning_rate": 0.03900730648793646,
"feature_fraction": 0.6968255358438369,
"bagging_fraction": 0.7078349435778689,
"bagging_freq": 1,
"min_child_samples": 46,
"lambda_l1": 1.1796591413903922e-05,
"lambda_l2": 1.574367227995052e-08
},
"xgb_best_iteration": 462,
"lgb_best_iteration": 339,
"xgb_optuna_best_logloss": 0.6557,
"lgb_optuna_best_logloss": 0.6554,
"test_xgb_raw": {
"accuracy": 0.5908,
"logloss": 0.6637
},
"test_xgb_calibrated": {
"accuracy": 0.5885,
"logloss": 0.6647
},
"test_lgb_raw": {
"accuracy": 0.5891,
"logloss": 0.6638
},
"test_lgb_calibrated": {
"accuracy": 0.5891,
"logloss": 0.6702
},
"test_ensemble_raw": {
"accuracy": 0.5892,
"logloss": 0.6635
},
"test_ensemble_calibrated": {
"accuracy": 0.5885,
"logloss": 0.6655
}
},
"HT_RESULT": {
"market": "HT_RESULT",
"samples": 103641,
"train": 62184,
"val": 15546,
"cal": 10364,
"test": 15547,
"features_used": 114,
"xgb_best_params": {
"max_depth": 4,
"eta": 0.01736265891311687,
"subsample": 0.8370935625192159,
"colsample_bytree": 0.8091927356001175,
"min_child_weight": 9,
"gamma": 0.0006570311316367184,
"reg_lambda": 0.5206211670360164,
"reg_alpha": 0.0004530536252850605
},
"lgb_best_params": {
"max_depth": 4,
"learning_rate": 0.04842652289664568,
"feature_fraction": 0.6277272818879166,
"bagging_fraction": 0.9526964840164693,
"bagging_freq": 3,
"min_child_samples": 23,
"lambda_l1": 0.09429192580834124,
"lambda_l2": 5.5433175427148124e-08
},
"xgb_best_iteration": 516,
"lgb_best_iteration": 136,
"xgb_optuna_best_logloss": 1.0128,
"lgb_optuna_best_logloss": 1.0126,
"test_xgb_raw": {
"accuracy": 0.4689,
"logloss": 1.0174
},
"test_xgb_calibrated": {
"accuracy": 0.4685,
"logloss": 1.0193
},
"test_lgb_raw": {
"accuracy": 0.4696,
"logloss": 1.018
},
"test_lgb_calibrated": {
"accuracy": 0.4685,
"logloss": 1.0248
},
"test_ensemble_raw": {
"accuracy": 0.4699,
"logloss": 1.0172
},
"test_ensemble_calibrated": {
"accuracy": 0.4693,
"logloss": 1.0195
}
},
"HT_OU05": {
"market": "HT_OU05",
"samples": 103641,
"train": 62184,
"val": 15546,
"cal": 10364,
"test": 15547,
"features_used": 114,
"xgb_best_params": {
"max_depth": 4,
"eta": 0.02440515089624656,
"subsample": 0.7173767988211683,
"colsample_bytree": 0.5705266148307722,
"min_child_weight": 10,
"gamma": 0.00010295747493868653,
"reg_lambda": 0.00048367003442154754,
"reg_alpha": 0.00018303274057896783
},
"lgb_best_params": {
"max_depth": 4,
"learning_rate": 0.043477055106943,
"feature_fraction": 0.5704621124873813,
"bagging_fraction": 0.9208787923016158,
"bagging_freq": 1,
"min_child_samples": 50,
"lambda_l1": 0.015064619068942013,
"lambda_l2": 6.143857495033091e-07
},
"xgb_best_iteration": 315,
"lgb_best_iteration": 133,
"xgb_optuna_best_logloss": 0.5756,
"lgb_optuna_best_logloss": 0.5757,
"test_xgb_raw": {
"accuracy": 0.7021,
"logloss": 0.5949
},
"test_xgb_calibrated": {
"accuracy": 0.7011,
"logloss": 0.5976
},
"test_lgb_raw": {
"accuracy": 0.7009,
"logloss": 0.5954
},
"test_lgb_calibrated": {
"accuracy": 0.7019,
"logloss": 0.6002
},
"test_ensemble_raw": {
"accuracy": 0.7012,
"logloss": 0.5947
},
"test_ensemble_calibrated": {
"accuracy": 0.7016,
"logloss": 0.5994
}
},
"HT_OU15": {
"market": "HT_OU15",
"samples": 103641,
"train": 62184,
"val": 15546,
"cal": 10364,
"test": 15547,
"features_used": 114,
"xgb_best_params": {
"max_depth": 4,
"eta": 0.032235943414662994,
"subsample": 0.9298749893021518,
"colsample_bytree": 0.8077813949235508,
"min_child_weight": 8,
"gamma": 0.00020929324388600622,
"reg_lambda": 3.2154973975232725e-05,
"reg_alpha": 1.5945155621686738e-08
},
"lgb_best_params": {
"max_depth": 5,
"learning_rate": 0.013909897616748226,
"feature_fraction": 0.5585477334219859,
"bagging_fraction": 0.9398770580467641,
"bagging_freq": 2,
"min_child_samples": 22,
"lambda_l1": 0.001865897980802303,
"lambda_l2": 2.6934572591055333e-06
},
"xgb_best_iteration": 188,
"lgb_best_iteration": 387,
"xgb_optuna_best_logloss": 0.616,
"lgb_optuna_best_logloss": 0.6159,
"test_xgb_raw": {
"accuracy": 0.6749,
"logloss": 0.6109
},
"test_xgb_calibrated": {
"accuracy": 0.6747,
"logloss": 0.6137
},
"test_lgb_raw": {
"accuracy": 0.6745,
"logloss": 0.6112
},
"test_lgb_calibrated": {
"accuracy": 0.6745,
"logloss": 0.6201
},
"test_ensemble_raw": {
"accuracy": 0.674,
"logloss": 0.6109
},
"test_ensemble_calibrated": {
"accuracy": 0.6744,
"logloss": 0.6174
}
},
"HTFT": {
"market": "HTFT",
"samples": 103641,
"train": 62184,
"val": 15546,
"cal": 10364,
"test": 15547,
"features_used": 114,
"xgb_best_params": {
"max_depth": 4,
"eta": 0.015239309183459821,
"subsample": 0.7923828997985648,
"colsample_bytree": 0.686316507387916,
"min_child_weight": 6,
"gamma": 0.005249577944740401,
"reg_lambda": 2.1813455810361064e-08,
"reg_alpha": 3.454483107951557e-06
},
"lgb_best_params": {
"max_depth": 4,
"learning_rate": 0.010347899501864056,
"feature_fraction": 0.9585697341293057,
"bagging_fraction": 0.9413628962257758,
"bagging_freq": 2,
"min_child_samples": 36,
"lambda_l1": 0.0015332771659626943,
"lambda_l2": 7.3640280079715765
},
"xgb_best_iteration": 714,
"lgb_best_iteration": 602,
"xgb_optuna_best_logloss": 1.7863,
"lgb_optuna_best_logloss": 1.7862,
"test_xgb_raw": {
"accuracy": 0.3349,
"logloss": 1.8179
},
"test_xgb_calibrated": {
"accuracy": 0.3332,
"logloss": 1.824
},
"test_lgb_raw": {
"accuracy": 0.3367,
"logloss": 1.8187
},
"test_lgb_calibrated": {
"accuracy": 0.335,
"logloss": 1.8338
},
"test_ensemble_raw": {
"accuracy": 0.3363,
"logloss": 1.8176
},
"test_ensemble_calibrated": {
"accuracy": 0.3338,
"logloss": 1.828
}
},
"ODD_EVEN": {
"market": "ODD_EVEN",
"samples": 106861,
"train": 64116,
"val": 16029,
"cal": 10686,
"test": 16030,
"features_used": 114,
"xgb_best_params": {
"max_depth": 8,
"eta": 0.01010929937405026,
"subsample": 0.9492996501687384,
"colsample_bytree": 0.9061960005014683,
"min_child_weight": 7,
"gamma": 2.664416507237002e-08,
"reg_lambda": 0.0003748192960525308,
"reg_alpha": 0.005287068300306146
},
"lgb_best_params": {
"max_depth": 8,
"learning_rate": 0.0634879805509945,
"feature_fraction": 0.9993568368122896,
"bagging_fraction": 0.9246236397710591,
"bagging_freq": 3,
"min_child_samples": 16,
"lambda_l1": 0.0016414429853061781,
"lambda_l2": 6.112007631403553e-05
},
"xgb_best_iteration": 322,
"lgb_best_iteration": 55,
"xgb_optuna_best_logloss": 0.6777,
"lgb_optuna_best_logloss": 0.6762,
"test_xgb_raw": {
"accuracy": 0.5216,
"logloss": 0.684
},
"test_xgb_calibrated": {
"accuracy": 0.5236,
"logloss": 0.6834
},
"test_lgb_raw": {
"accuracy": 0.5279,
"logloss": 0.6826
},
"test_lgb_calibrated": {
"accuracy": 0.5274,
"logloss": 0.6861
},
"test_ensemble_raw": {
"accuracy": 0.5239,
"logloss": 0.6828
},
"test_ensemble_calibrated": {
"accuracy": 0.5236,
"logloss": 0.6861
}
},
"CARDS_OU45": {
"market": "CARDS_OU45",
"samples": 106861,
"train": 64116,
"val": 16029,
"cal": 10686,
"test": 16030,
"features_used": 114,
"xgb_best_params": {
"max_depth": 8,
"eta": 0.010098671964329344,
"subsample": 0.9969616653360747,
"colsample_bytree": 0.5085930751344795,
"min_child_weight": 10,
"gamma": 0.8600893137103568,
"reg_lambda": 7.556243125116086,
"reg_alpha": 0.5596869360839299
},
"lgb_best_params": {
"max_depth": 8,
"learning_rate": 0.0183440412249233,
"feature_fraction": 0.5416111323291537,
"bagging_fraction": 0.9754210612419695,
"bagging_freq": 2,
"min_child_samples": 5,
"lambda_l1": 0.09157782079463243,
"lambda_l2": 2.559000594641019
},
"xgb_best_iteration": 973,
"lgb_best_iteration": 503,
"xgb_optuna_best_logloss": 0.6408,
"lgb_optuna_best_logloss": 0.6407,
"test_xgb_raw": {
"accuracy": 0.597,
"logloss": 0.6501
},
"test_xgb_calibrated": {
"accuracy": 0.6019,
"logloss": 0.6471
},
"test_lgb_raw": {
"accuracy": 0.5977,
"logloss": 0.6486
},
"test_lgb_calibrated": {
"accuracy": 0.6019,
"logloss": 0.6498
},
"test_ensemble_raw": {
"accuracy": 0.5964,
"logloss": 0.6487
},
"test_ensemble_calibrated": {
"accuracy": 0.6034,
"logloss": 0.6467
}
},
"HANDICAP_MS": {
"market": "HANDICAP_MS",
"samples": 106861,
"train": 64116,
"val": 16029,
"cal": 10686,
"test": 16030,
"features_used": 114,
"xgb_best_params": {
"max_depth": 4,
"eta": 0.01475719431584365,
"subsample": 0.867899230696633,
"colsample_bytree": 0.6518567347674479,
"min_child_weight": 9,
"gamma": 0.34932767754310273,
"reg_lambda": 3.3257801082201637e-07,
"reg_alpha": 4.6977721450875555e-06
},
"lgb_best_params": {
"max_depth": 7,
"learning_rate": 0.019649745228555244,
"feature_fraction": 0.7903699430858344,
"bagging_fraction": 0.7932436899357213,
"bagging_freq": 3,
"min_child_samples": 30,
"lambda_l1": 9.496143774926949e-08,
"lambda_l2": 0.0049885051588706136
},
"xgb_best_iteration": 1016,
"lgb_best_iteration": 364,
"xgb_optuna_best_logloss": 0.8328,
"lgb_optuna_best_logloss": 0.8322,
"test_xgb_raw": {
"accuracy": 0.6062,
"logloss": 0.871
},
"test_xgb_calibrated": {
"accuracy": 0.6039,
"logloss": 0.8729
},
"test_lgb_raw": {
"accuracy": 0.6079,
"logloss": 0.8713
},
"test_lgb_calibrated": {
"accuracy": 0.6067,
"logloss": 0.8736
},
"test_ensemble_raw": {
"accuracy": 0.6072,
"logloss": 0.8707
},
"test_ensemble_calibrated": {
"accuracy": 0.6066,
"logloss": 0.8728
}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
import os
import sys
import psycopg2
from psycopg2.extras import RealDictCursor
# Path ayarları
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from services.single_match_orchestrator import SingleMatchOrchestrator
from services.feature_enrichment import FeatureEnrichmentService
DSN = "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
def run_backtest(target_date="2026-05-03"):
conn = psycopg2.connect(DSN)
cur = conn.cursor(cursor_factory=RealDictCursor)
# 1. Hedef tarihteki bitmiş maçları ve takım isimlerini getir
cur.execute("""
SELECT m.id, m.score_home, m.score_away, m.mst_utc,
t1.name as home_name, t2.name as away_name
FROM matches m
LEFT JOIN teams t1 ON m.home_team_id = t1.id
LEFT JOIN teams t2 ON m.away_team_id = t2.id
WHERE m.status IN ('FT', 'AET', 'PEN')
AND to_timestamp(m.mst_utc / 1000.0)::date = %s::date
AND m.score_home IS NOT NULL
ORDER BY m.mst_utc ASC
""", (target_date,))
matches = cur.fetchall()
if not matches:
print(f"{target_date} tarihinde bitmiş maç bulunamadı.")
return
print(f"🚀 {target_date} için Orkestratör Backtesti Başlatılıyor... ({len(matches)} maç bulundu)")
print("-" * 60)
orchestrator = SingleMatchOrchestrator()
bets_placed = 0
won = 0
lost = 0
total_odds_won = 0.0
for match in matches:
# 3. Üst Akıl (Orkestratör) analizi yapar
try:
package = orchestrator.analyze_match(match['id'])
except Exception as e:
print(f"Hata ({match['id']}): {e}")
continue
if not package:
continue
package_data = package
# 4. Üst akıl bu maça bahis yapmaya karar verdi mi?
bet_advice = package_data.get("bet_advice", {})
if bet_advice.get("playable") == True:
bets_placed += 1
main_pick = package_data.get("main_pick", {})
market = main_pick.get("market")
pick = main_pick.get("pick")
odds = float(main_pick.get("odds", 0.0) or 0.0)
# Skora göre kazanıp kazanmadığını kontrol et
is_won = False
h = match['score_home']
a = match['score_away']
if market == "MS":
if pick == "1" and h > a: is_won = True
elif pick in ("X", "0") and h == a: is_won = True
elif pick == "2" and a > h: is_won = True
elif market == "OU25":
if pick == "Üst" and (h+a) > 2.5: is_won = True
elif pick == "Alt" and (h+a) < 2.5: is_won = True
elif market == "OU15":
if pick == "Üst" and (h+a) > 1.5: is_won = True
elif pick == "Alt" and (h+a) < 1.5: is_won = True
elif market == "BTTS":
if pick == "KG Var" and h > 0 and a > 0: is_won = True
elif pick == "KG Yok" and (h == 0 or a == 0): is_won = True
elif market == "DC":
if pick == "1X" and h >= a: is_won = True
elif pick == "12" and h != a: is_won = True
elif pick == "X2" and h <= a: is_won = True
if is_won:
won += 1
total_odds_won += odds
res = "✅ KAZANDI"
else:
lost += 1
res = "❌ KAYBETTİ"
print(f"[{res}] {match['home_name']} {h}-{a} {match['away_name']} | Tahmin: {market} {pick} (Oran: {odds})")
else:
main_pick = package_data.get("main_pick", {})
reasons = main_pick.get("reasons", ["Bilinmeyen Neden"]) if main_pick else ["No main pick"]
reason = " | ".join(reasons) if isinstance(reasons, list) else str(reasons)
market_board = package_data.get("market_board", {})
main_pick_market = main_pick.get('market', 'N/A') if main_pick else 'N/A'
main_pick_pick = main_pick.get('pick', 'N/A') if main_pick else 'N/A'
print(f"[PAS] {match['home_name']} {match['score_home']}-{match['score_away']} {match['away_name']} | Reddedilen: {main_pick_market} {main_pick_pick} -> Neden: {reason}")
if "market_passed_all_gates" in reason:
print(f" DEBUG: bet_advice = {bet_advice}")
v25_ms = market_board.get("MS", {}).get("probs", {})
v27_ms = {} # V27 is merged into V25 probabilities in market_board, or we don't have separate V27 access here
# Skora göre ms kontrolü
h = match['score_home']
a = match['score_away']
actual_ms = "1" if h > a else ("X" if h == a else "2")
v25_top = max(v25_ms, key=v25_ms.get) if v25_ms else "N/A"
v27_top = "N/A"
rejected_market = main_pick.get("market", "N/A") if main_pick else "N/A"
rejected_pick = main_pick.get("pick", "N/A") if main_pick else "N/A"
print(f"[PAS] {match['home_name']} {h}-{a} {match['away_name']} | Reddedilen: {rejected_market} {rejected_pick} -> Neden: {reason}")
print(f" [V25 MS Raw: {v25_top}] [Gerçek MS: {actual_ms}]")
# Sonuç Raporu
print("\n" + "=" * 60)
print(f"📊 BACKTEST SONUÇLARI ({target_date})")
print("=" * 60)
print(f"Toplam Maç Sayısı : {len(matches)}")
print(f"Oynanan Bahis Sayısı: {bets_placed} (Oynama Oranı: %{bets_placed/len(matches)*100:.1f})")
print(f"Riskli Bulunup Pas Geçilen: {len(matches) - bets_placed}")
if bets_placed > 0:
win_rate = won / bets_placed * 100
roi = ((total_odds_won - bets_placed) / bets_placed) * 100
print(f"Kazanılan : {won}")
print(f"Kaybedilen : {lost}")
print(f"İsabet Oranı : %{win_rate:.1f}")
print(f"Net Kar (ROI) : %{roi:.1f} {'📈' if roi > 0 else '📉'}")
if __name__ == "__main__":
run_backtest("2026-05-03")
+23 -7
View File
@@ -59,7 +59,7 @@ def fetch_matches(conn, sport: str):
def flush_features_batch(conn, rows, dry_run: bool, sport: str = 'football'): def flush_features_batch(conn, rows, dry_run: bool, sport: str = 'football'):
"""Bulk upsert a batch of (match_id, home_elo, away_elo) into sport-partitioned ai_features table.""" """Bulk upsert ELO features into sport-partitioned ai_features table."""
if not rows or dry_run: if not rows or dry_run:
return return
@@ -70,19 +70,27 @@ def flush_features_batch(conn, rows, dry_run: bool, sport: str = 'football'):
f""" f"""
INSERT INTO {table_name} INSERT INTO {table_name}
(match_id, home_elo, away_elo, (match_id, home_elo, away_elo,
home_home_elo, away_away_elo,
home_form_elo, away_form_elo,
elo_diff,
home_form_score, away_form_score, home_form_score, away_form_score,
missing_players_impact, calculator_ver, updated_at) missing_players_impact, calculator_ver, updated_at)
VALUES %s VALUES %s
ON CONFLICT (match_id) DO UPDATE SET ON CONFLICT (match_id) DO UPDATE SET
home_elo = EXCLUDED.home_elo, home_elo = EXCLUDED.home_elo,
away_elo = EXCLUDED.away_elo, away_elo = EXCLUDED.away_elo,
home_home_elo = EXCLUDED.home_home_elo,
away_away_elo = EXCLUDED.away_away_elo,
home_form_elo = EXCLUDED.home_form_elo,
away_form_elo = EXCLUDED.away_form_elo,
elo_diff = EXCLUDED.elo_diff,
home_form_score = EXCLUDED.home_form_score, home_form_score = EXCLUDED.home_form_score,
away_form_score = EXCLUDED.away_form_score, away_form_score = EXCLUDED.away_form_score,
calculator_ver = EXCLUDED.calculator_ver, calculator_ver = EXCLUDED.calculator_ver,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
""", """,
rows, rows,
template="(%s, %s, %s, %s, %s, 0.0, %s, NOW())", template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0.0, %s, NOW())",
page_size=500, page_size=500,
) )
conn.commit() conn.commit()
@@ -136,16 +144,24 @@ def backfill(sport: str, batch_size: int, dry_run: bool):
if not home_id or not away_id: if not home_id or not away_id:
continue continue
# Snapshot PRE-match ELO # Snapshot PRE-match ELO (all dimensions)
home_rating = elo.get_or_create_rating(home_id, h_name or "") home_rating = elo.get_or_create_rating(home_id, h_name or "")
away_rating = elo.get_or_create_rating(away_id, a_name or "") away_rating = elo.get_or_create_rating(away_id, a_name or "")
h_overall = round(home_rating.overall_elo, 2)
a_overall = round(away_rating.overall_elo, 2)
feature_buf.append(( feature_buf.append((
match_id, match_id,
round(home_rating.overall_elo, 2), h_overall, # home_elo
round(away_rating.overall_elo, 2), a_overall, # away_elo
round(form_to_score(home_rating.recent_form), 2), round(home_rating.home_elo, 2), # home_home_elo
round(form_to_score(away_rating.recent_form), 2), round(away_rating.away_elo, 2), # away_away_elo
round(home_rating.form_elo, 2), # home_form_elo
round(away_rating.form_elo, 2), # away_form_elo
round(h_overall - a_overall, 2), # elo_diff
round(form_to_score(home_rating.recent_form), 2), # home_form_score
round(form_to_score(away_rating.recent_form), 2), # away_form_score
CALCULATOR_VER, CALCULATOR_VER,
)) ))
+459
View File
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
"""
AI Features Full Enrichment Script
====================================
Fills empty/default columns in football_ai_features that were not populated
by the original elo_backfill_v1 script.
Enriches: H2H, referee, team_stats, league_averages, form_streaks,
rolling_goals, implied_odds, and clean_sheet/scoring rates.
Usage:
python scripts/enrich_ai_features.py # enrich all
python scripts/enrich_ai_features.py --batch-size 500 # smaller batches
python scripts/enrich_ai_features.py --dry-run # preview only
python scripts/enrich_ai_features.py --force # re-enrich all rows
python scripts/enrich_ai_features.py --limit 1000 # process N rows max
Designed to be idempotent: uses ON CONFLICT upserts, skips already-enriched rows.
"""
from __future__ import annotations
import os
import sys
import time
import argparse
from typing import Any, Dict, List, Optional, Tuple
# Add ai-engine root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import psycopg2
from psycopg2.extras import RealDictCursor, execute_values
from data.db import get_clean_dsn
from services.feature_enrichment import FeatureEnrichmentService
# ────────────────────────── constants ──────────────────────────
CALCULATOR_VER = 'enrichment_v2.0'
DEFAULT_BATCH_SIZE = 200
# ────────────────────────── helpers ────────────────────────────
def fetch_unenriched_matches(
conn: psycopg2.extensions.connection,
force: bool = False,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""
Fetch matches from football_ai_features that still have default values
in the enrichment columns (h2h_total=0 AND referee_avg_cards=0).
If force=True, fetches ALL rows regardless of current state.
"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
where_clause = "WHERE 1=1" if force else (
"WHERE (faf.h2h_total = 0 AND faf.referee_avg_cards = 0)"
)
limit_clause = f"LIMIT {limit}" if limit else ""
cur.execute(f"""
SELECT
faf.match_id,
m.home_team_id,
m.away_team_id,
m.mst_utc,
m.league_id,
m.score_home,
m.score_away
FROM football_ai_features faf
JOIN matches m ON m.id = faf.match_id
WHERE m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.sport = 'football'
AND ({where_clause.replace('WHERE ', '')})
ORDER BY m.mst_utc ASC
{limit_clause}
""")
return cur.fetchall()
def fetch_referee_for_match(
cur: RealDictCursor,
match_id: str,
) -> Optional[str]:
"""Get the head referee name for a match from match_officials."""
try:
cur.execute("""
SELECT mo.name
FROM match_officials mo
WHERE mo.match_id = %s
AND mo.role_id = 1
LIMIT 1
""", (match_id,))
row = cur.fetchone()
return row['name'] if row else None
except Exception:
return None
def fetch_implied_odds(
cur: RealDictCursor,
match_id: str,
) -> Dict[str, float]:
"""Get implied probabilities from odd_categories + odd_selections."""
defaults = {
'implied_home': 0.33,
'implied_draw': 0.33,
'implied_away': 0.33,
'implied_over25': 0.50,
'implied_btts_yes': 0.50,
'odds_overround': 0.0,
}
try:
cur.execute("""
SELECT oc.name AS cat_name, os.name AS sel_name, os.odd_value
FROM odd_selections os
JOIN odd_categories oc ON os.odd_category_db_id = oc.db_id
WHERE oc.match_id = %s
""", (match_id,))
rows = cur.fetchall()
except Exception:
return defaults
odds: Dict[str, float] = {}
for row in rows:
try:
cat = (row.get('cat_name') or '').lower().strip()
sel = (row.get('sel_name') or '').strip()
val = float(row.get('odd_value', 0))
if val <= 0:
continue
if cat == 'maç sonucu':
if sel == '1':
odds['ms_h'] = val
elif sel in ('0', 'X'):
odds['ms_d'] = val
elif sel == '2':
odds['ms_a'] = val
elif cat == '2,5 alt/üst':
if 'üst' in sel.lower():
odds['ou25_o'] = val
elif 'alt' in sel.lower():
odds['ou25_u'] = val
elif cat == 'karşılıklı gol':
if 'var' in sel.lower():
odds['btts_y'] = val
elif 'yok' in sel.lower():
odds['btts_n'] = val
except (ValueError, TypeError):
continue
# Compute implied probabilities
ms_h = odds.get('ms_h', 0)
ms_d = odds.get('ms_d', 0)
ms_a = odds.get('ms_a', 0)
if ms_h > 1.0 and ms_d > 1.0 and ms_a > 1.0:
raw_sum = 1 / ms_h + 1 / ms_d + 1 / ms_a
overround = raw_sum - 1.0
defaults['implied_home'] = round((1 / ms_h) / raw_sum, 4)
defaults['implied_draw'] = round((1 / ms_d) / raw_sum, 4)
defaults['implied_away'] = round((1 / ms_a) / raw_sum, 4)
defaults['odds_overround'] = round(overround, 4)
ou25_o = odds.get('ou25_o', 0)
ou25_u = odds.get('ou25_u', 0)
if ou25_o > 1.0 and ou25_u > 1.0:
raw_sum = 1 / ou25_o + 1 / ou25_u
defaults['implied_over25'] = round((1 / ou25_o) / raw_sum, 4)
btts_y = odds.get('btts_y', 0)
btts_n = odds.get('btts_n', 0)
if btts_y > 1.0 and btts_n > 1.0:
raw_sum = 1 / btts_y + 1 / btts_n
defaults['implied_btts_yes'] = round((1 / btts_y) / raw_sum, 4)
return defaults
def enrich_single_match(
enrichment: FeatureEnrichmentService,
cur: RealDictCursor,
match: Dict[str, Any],
) -> Dict[str, Any]:
"""
Compute all enrichment features for a single match and return
a dict ready for DB upsert.
"""
match_id = match['match_id']
home_id = str(match['home_team_id'])
away_id = str(match['away_team_id'])
mst_utc = int(match['mst_utc']) if match['mst_utc'] else 0
league_id = str(match['league_id']) if match['league_id'] else None
# 1. Team stats
home_stats = enrichment.compute_team_stats(cur, home_id, mst_utc)
away_stats = enrichment.compute_team_stats(cur, away_id, mst_utc)
# 2. H2H
h2h = enrichment.compute_h2h(cur, home_id, away_id, mst_utc)
# 3. Form & streaks
home_form = enrichment.compute_form_streaks(cur, home_id, mst_utc)
away_form = enrichment.compute_form_streaks(cur, away_id, mst_utc)
# 4. Referee
referee_name = fetch_referee_for_match(cur, match_id)
referee = enrichment.compute_referee_stats(cur, referee_name, mst_utc)
# 5. League averages
league = enrichment.compute_league_averages(cur, league_id, mst_utc)
# 6. Rolling stats (for goals avg)
home_rolling = enrichment.compute_rolling_stats(cur, home_id, mst_utc)
away_rolling = enrichment.compute_rolling_stats(cur, away_id, mst_utc)
# 7. Implied odds
implied = fetch_implied_odds(cur, match_id)
return {
'match_id': match_id,
# Team stats
'home_avg_possession': round(home_stats['avg_possession'], 2),
'away_avg_possession': round(away_stats['avg_possession'], 2),
'home_avg_shots_on_target': round(home_stats['avg_shots_on_target'], 2),
'away_avg_shots_on_target': round(away_stats['avg_shots_on_target'], 2),
'home_shot_conversion': round(home_stats['shot_conversion'], 4),
'away_shot_conversion': round(away_stats['shot_conversion'], 4),
'home_avg_corners': round(home_stats['avg_corners'], 2),
'away_avg_corners': round(away_stats['avg_corners'], 2),
# H2H
'h2h_total': h2h['total_matches'],
'h2h_home_win_rate': round(h2h['home_win_rate'], 4),
'h2h_avg_goals': round(h2h['avg_goals'], 2),
'h2h_over25_rate': round(h2h['over25_rate'], 4),
'h2h_btts_rate': round(h2h['btts_rate'], 4),
# Form
'home_clean_sheet_rate': round(home_form['clean_sheet_rate'], 4),
'away_clean_sheet_rate': round(away_form['clean_sheet_rate'], 4),
'home_scoring_rate': round(home_form['scoring_rate'], 4),
'away_scoring_rate': round(away_form['scoring_rate'], 4),
'home_win_streak': home_form['winning_streak'],
'away_win_streak': away_form['winning_streak'],
# Rolling goals
'home_goals_avg_5': round(home_rolling['rolling5_goals'], 2),
'away_goals_avg_5': round(away_rolling['rolling5_goals'], 2),
'home_conceded_avg_5': round(home_rolling['rolling5_conceded'], 2),
'away_conceded_avg_5': round(away_rolling['rolling5_conceded'], 2),
# Referee
'referee_avg_cards': round(referee['cards_total'], 2),
'referee_home_bias': round(referee['home_bias'], 4),
'referee_avg_goals': round(referee['avg_goals'], 2),
# League
'league_avg_goals': round(league['avg_goals'], 2),
'league_home_win_pct': round(league['home_win_rate'], 4),
'league_over25_pct': round(league['ou25_rate'], 4),
# Implied odds
'implied_home': implied['implied_home'],
'implied_draw': implied['implied_draw'],
'implied_away': implied['implied_away'],
'implied_over25': implied['implied_over25'],
'implied_btts_yes': implied['implied_btts_yes'],
'odds_overround': implied['odds_overround'],
# Missing players impact — default (no lineup data for historical)
'missing_players_impact': 0.0,
# Version
'calculator_ver': CALCULATOR_VER,
}
def flush_enrichment_batch(
conn: psycopg2.extensions.connection,
rows: List[Dict[str, Any]],
dry_run: bool,
) -> int:
"""Bulk upsert enriched features into football_ai_features."""
if not rows or dry_run:
return 0
columns = [
'match_id',
'home_avg_possession', 'away_avg_possession',
'home_avg_shots_on_target', 'away_avg_shots_on_target',
'home_shot_conversion', 'away_shot_conversion',
'home_avg_corners', 'away_avg_corners',
'h2h_total', 'h2h_home_win_rate', 'h2h_avg_goals',
'h2h_over25_rate', 'h2h_btts_rate',
'home_clean_sheet_rate', 'away_clean_sheet_rate',
'home_scoring_rate', 'away_scoring_rate',
'home_win_streak', 'away_win_streak',
'home_goals_avg_5', 'away_goals_avg_5',
'home_conceded_avg_5', 'away_conceded_avg_5',
'referee_avg_cards', 'referee_home_bias', 'referee_avg_goals',
'league_avg_goals', 'league_home_win_pct', 'league_over25_pct',
'implied_home', 'implied_draw', 'implied_away',
'implied_over25', 'implied_btts_yes', 'odds_overround',
'missing_players_impact', 'calculator_ver',
]
# Build update SET clause (skip match_id)
update_cols = [c for c in columns if c != 'match_id']
set_clause = ', '.join(f'{c} = EXCLUDED.{c}' for c in update_cols)
placeholders = ', '.join(['%s'] * len(columns))
values = [
tuple(row[c] for c in columns)
for row in rows
]
with conn.cursor() as cur:
execute_values(
cur,
f"""
INSERT INTO football_ai_features ({', '.join(columns)})
VALUES %s
ON CONFLICT (match_id) DO UPDATE SET
{set_clause},
updated_at = NOW()
""",
values,
template=f"({placeholders})",
page_size=200,
)
conn.commit()
return len(rows)
# ────────────────────────── main ───────────────────────────────
def run_enrichment(
batch_size: int,
dry_run: bool,
force: bool,
limit: Optional[int],
) -> None:
"""Core enrichment loop."""
dsn = get_clean_dsn()
conn = psycopg2.connect(dsn)
print(f"\n{'=' * 60}")
print(f"🧠 AI Features Full Enrichment — {CALCULATOR_VER}")
print(f" batch_size={batch_size} dry_run={dry_run} force={force}")
print(f"{'=' * 60}")
# 1. Fetch unenriched matches
t0 = time.time()
matches = fetch_unenriched_matches(conn, force=force, limit=limit)
print(f"\n📊 {len(matches):,} matches to enrich ({time.time() - t0:.1f}s)")
if not matches:
print("✅ Nothing to enrich — all rows already populated.")
conn.close()
return
# 2. Initialize enrichment service
enrichment = FeatureEnrichmentService()
# 3. Process in batches
total = len(matches)
processed = 0
written = 0
errors = 0
batch_buf: List[Dict[str, Any]] = []
t_start = time.time()
# Use a dedicated cursor with RealDictCursor for all enrichment queries
enrich_cur = conn.cursor(cursor_factory=RealDictCursor)
for idx, match in enumerate(matches):
try:
enriched = enrich_single_match(enrichment, enrich_cur, match)
batch_buf.append(enriched)
except Exception as e:
errors += 1
if errors <= 10:
print(f" ⚠️ Error enriching {match.get('match_id', '?')}: {e}")
processed += 1
# Flush batch
if len(batch_buf) >= batch_size:
flushed = flush_enrichment_batch(conn, batch_buf, dry_run)
written += flushed
batch_buf.clear()
# Progress reporting
if processed % 500 == 0:
elapsed = time.time() - t_start
rate = processed / elapsed if elapsed > 0 else 0
remaining = (total - processed) / rate if rate > 0 else 0
pct = processed / total * 100
print(
f" [{processed:>8,} / {total:,}] "
f"({pct:.1f}%) | {rate:.0f} matches/s | "
f"ETA: {remaining / 60:.1f} min | "
f"errors: {errors}"
)
# Flush remaining
if batch_buf:
flushed = flush_enrichment_batch(conn, batch_buf, dry_run)
written += flushed
enrich_cur.close()
elapsed = time.time() - t_start
print(f"\n{'=' * 60}")
print(f"✅ Enrichment complete:")
print(f" Processed: {processed:,} matches in {elapsed:.1f}s")
print(f" Written: {written:,} rows")
print(f" Errors: {errors:,}")
print(f" Rate: {processed / elapsed:.0f} matches/s")
print(f"{'=' * 60}")
conn.close()
def main() -> None:
parser = argparse.ArgumentParser(
description="Enrich football_ai_features with H2H, referee, stats, and odds data"
)
parser.add_argument(
'--batch-size',
type=int,
default=DEFAULT_BATCH_SIZE,
help=f'DB insert batch size (default: {DEFAULT_BATCH_SIZE})',
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Compute features but do not write to DB',
)
parser.add_argument(
'--force',
action='store_true',
help='Re-enrich ALL rows, not just empty ones',
)
parser.add_argument(
'--limit',
type=int,
default=None,
help='Max number of matches to process',
)
args = parser.parse_args()
run_enrichment(
batch_size=args.batch_size,
dry_run=args.dry_run,
force=args.force,
limit=args.limit,
)
if __name__ == '__main__':
main()
+250 -34
View File
@@ -14,6 +14,7 @@ import json
import csv import csv
import math import math
import time import time
import bisect
from datetime import datetime from datetime import datetime
from collections import defaultdict from collections import defaultdict
@@ -119,6 +120,14 @@ FEATURE_COLS = [
"home_key_players", "away_key_players", "home_key_players", "away_key_players",
"home_missing_impact", "away_missing_impact", "home_missing_impact", "away_missing_impact",
"home_goals_form", "away_goals_form", "home_goals_form", "away_goals_form",
# Player-Level Features (12)
"home_lineup_goals_per90", "away_lineup_goals_per90",
"home_lineup_assists_per90", "away_lineup_assists_per90",
"home_squad_continuity", "away_squad_continuity",
"home_top_scorer_form", "away_top_scorer_form",
"home_avg_player_exp", "away_avg_player_exp",
"home_goals_diversity", "away_goals_diversity",
# Labels # Labels
"score_home", "score_away", "total_goals", "score_home", "score_away", "total_goals",
@@ -336,7 +345,7 @@ class BatchDataLoader:
self.team_stats[tid].append((mst, poss, sot, tshots, corn, team_goals)) self.team_stats[tid].append((mst, poss, sot, tshots, corn, team_goals))
def _load_squad_data(self): def _load_squad_data(self):
"""Bulk load squad participation + player events for squad features.""" """Bulk load squad participation + player events + player career for squad features."""
ph = ",".join(["%s"] * len(self.top_league_ids)) ph = ",".join(["%s"] * len(self.top_league_ids))
# 1) Participation: starting XI count + position distribution per (match, team) # 1) Participation: starting XI count + position distribution per (match, team)
@@ -429,9 +438,90 @@ class BatchDataLoader:
for m in self.matches: for m in self.matches:
match_mst[m[0]] = m[7] # m[0]=id, m[7]=mst_utc match_mst[m[0]] = m[7] # m[0]=id, m[7]=mst_utc
# 6) Build combined cache — NO DATA LEAKAGE # ─── NEW: Player Career Stats (prefix-sum for O(1) temporal lookup) ───
# goals_form: avg goals from last 5 matches BEFORE this match (not this match!) # 6a) Goals per player per match date
# squad_quality: only uses pre-match info (lineup, key players) — no current-match goals/assists self.cur.execute(f"""
SELECT mpe.player_id, m.mst_utc,
SUM(CASE WHEN mpe.event_type = 'goal'
AND COALESCE(mpe.event_subtype, '') NOT ILIKE '%%penaltı kaçırma%%'
THEN 1 ELSE 0 END) AS goals
FROM match_player_events mpe
JOIN matches m ON mpe.match_id = m.id
WHERE m.status = 'FT' AND m.sport = 'football' AND m.league_id IN ({ph})
GROUP BY mpe.player_id, m.mst_utc
""", self.top_league_ids)
player_goals_raw = defaultdict(dict)
for pid, mst, goals in self.cur.fetchall():
player_goals_raw[pid][mst] = (player_goals_raw[pid].get(mst, 0)) + (goals or 0)
# 6b) Assists per player per match date
self.cur.execute(f"""
SELECT mpe.assist_player_id, m.mst_utc, COUNT(*) AS assists
FROM match_player_events mpe
JOIN matches m ON mpe.match_id = m.id
WHERE m.status = 'FT' AND m.sport = 'football' AND m.league_id IN ({ph})
AND mpe.event_type = 'goal' AND mpe.assist_player_id IS NOT NULL
GROUP BY mpe.assist_player_id, m.mst_utc
""", self.top_league_ids)
player_assists_raw = defaultdict(dict)
for pid, mst, assists in self.cur.fetchall():
player_assists_raw[pid][mst] = (player_assists_raw[pid].get(mst, 0)) + (assists or 0)
# 6c) Player participation dates (starts only)
self.cur.execute(f"""
SELECT mpp.player_id, m.mst_utc
FROM match_player_participation mpp
JOIN matches m ON mpp.match_id = m.id
WHERE mpp.is_starting = true
AND m.status = 'FT' AND m.sport = 'football' AND m.league_id IN ({ph})
ORDER BY mpp.player_id, m.mst_utc
""", self.top_league_ids)
player_starts_raw = defaultdict(list)
for pid, mst in self.cur.fetchall():
player_starts_raw[pid].append(mst)
# 6d) Build prefix sums per player (goals_prefix[i] = total goals up to start i)
player_career = {}
all_pids = set(player_starts_raw.keys()) | set(player_goals_raw.keys()) | set(player_assists_raw.keys())
for pid in all_pids:
starts = sorted(set(player_starts_raw.get(pid, [])))
if not starts:
continue
g_map = player_goals_raw.get(pid, {})
a_map = player_assists_raw.get(pid, {})
cum_g, cum_a = 0, 0
goals_pf, assists_pf = [], []
for mst in starts:
cum_g += g_map.get(mst, 0)
cum_a += a_map.get(mst, 0)
goals_pf.append(cum_g)
assists_pf.append(cum_a)
player_career[pid] = {'msts': starts, 'gp': goals_pf, 'ap': assists_pf}
# Free raw dicts
del player_goals_raw, player_assists_raw, player_starts_raw
print(f" 📊 Player careers built: {len(player_career)} players", flush=True)
# ─── NEW: Team Lineup History (for squad continuity) ───
# 7) Per-team sorted lineups: [(mst, frozenset(player_ids))]
team_lineup_map = defaultdict(list)
for (mid, tid), pids in starting_players.items():
mst = match_mst.get(mid, 0)
if mst > 0 and pids:
team_lineup_map[tid].append((mst, frozenset(pids)))
team_lineup_history = {}
team_lineup_msts = {}
for tid, ll in team_lineup_map.items():
ll.sort(key=lambda x: x[0])
team_lineup_history[tid] = ll
team_lineup_msts[tid] = [x[0] for x in ll]
del team_lineup_map
# ─── 8) Build combined cache — NO DATA LEAKAGE ───
all_keys = set(participation.keys()) | set(events.keys()) all_keys = set(participation.keys()) | set(events.keys())
for key in all_keys: for key in all_keys:
mid, tid = key mid, tid = key
@@ -443,30 +533,78 @@ class BatchDataLoader:
kp_total = len(key_players_by_team.get(tid, set())) kp_total = len(key_players_by_team.get(tid, set()))
kp_missing = max(0, kp_total - kp_in_starting) kp_missing = max(0, kp_total - kp_in_starting)
# Squad quality: composite score — ONLY pre-match info (no current-match goals/assists!) # Squad quality: composite score — ONLY pre-match info
squad_quality = ( squad_quality = (
part['starting_count'] * 0.3 + part['starting_count'] * 0.3 +
kp_in_starting * 3.0 + kp_in_starting * 3.0 +
part['fwd_count'] * 1.5 part['fwd_count'] * 1.5
) )
# Missing impact: how many key players are missing
missing_impact = min(kp_missing / max(kp_total, 1), 1.0) missing_impact = min(kp_missing / max(kp_total, 1), 1.0)
# goals_form: avg goals from last 5 matches BEFORE this match # goals_form: avg goals from last 5 matches BEFORE this match
current_mst = match_mst.get(mid, 0) current_mst = match_mst.get(mid, 0)
team_history = self.team_matches.get(tid, []) team_history = self.team_matches.get(tid, [])
recent_goals = [ recent_goals = [
tm[2] # team_score tm[2] for tm in team_history if tm[0] < current_mst
for tm in team_history ][-5:]
if tm[0] < current_mst # only matches BEFORE this one
][-5:] # last 5
goals_form = sum(recent_goals) / len(recent_goals) if recent_goals else 1.3 goals_form = sum(recent_goals) / len(recent_goals) if recent_goals else 1.3
# ─── NEW: Player-level aggregation for starting XI ───
lineup_g90, lineup_a90, total_exp = 0.0, 0.0, 0
best_scorer_total, best_scorer_id = 0, None
scorers_in_lineup = 0
for pid in starters:
pc = player_career.get(pid)
if not pc:
continue
idx = bisect.bisect_left(pc['msts'], current_mst)
if idx == 0:
continue # no prior matches for this player
prior_starts = idx
prior_goals = pc['gp'][idx - 1]
prior_assists = pc['ap'][idx - 1]
lineup_g90 += prior_goals / prior_starts
lineup_a90 += prior_assists / prior_starts
total_exp += prior_starts
if prior_goals > 0:
scorers_in_lineup += 1
if prior_goals > best_scorer_total:
best_scorer_total = prior_goals
best_scorer_id = pid
n_st = len(starters) or 1
# Top scorer recent form (goals in last 5 starts)
top_scorer_form = 0
if best_scorer_id:
pc = player_career.get(best_scorer_id)
if pc:
idx = bisect.bisect_left(pc['msts'], current_mst)
if idx > 0:
s5 = max(0, idx - 5)
top_scorer_form = pc['gp'][idx - 1] - (pc['gp'][s5 - 1] if s5 > 0 else 0)
# Squad continuity (overlap with previous match lineup)
squad_continuity = 0.5
msts_list = team_lineup_msts.get(tid)
if msts_list:
li = bisect.bisect_left(msts_list, current_mst)
if li > 0:
prev_lineup = team_lineup_history[tid][li - 1][1]
squad_continuity = len(frozenset(starters) & prev_lineup) / n_st
self.squad_cache[key] = { self.squad_cache[key] = {
'squad_quality': squad_quality, 'squad_quality': squad_quality,
'key_players': kp_in_starting, 'key_players': kp_in_starting,
'missing_impact': missing_impact, 'missing_impact': missing_impact,
'goals_form': round(goals_form, 2), 'goals_form': round(goals_form, 2),
'lineup_goals_per90': round(lineup_g90, 3),
'lineup_assists_per90': round(lineup_a90, 3),
'squad_continuity': round(squad_continuity, 3),
'top_scorer_form': top_scorer_form,
'avg_player_exp': round(total_exp / n_st, 1),
'goals_diversity': round(scorers_in_lineup / n_st, 3),
} }
def _load_cards_data(self): def _load_cards_data(self):
@@ -510,16 +648,24 @@ class FeatureExtractor:
self.referee_engine = get_referee_engine() self.referee_engine = get_referee_engine()
self.momentum_engine = get_momentum_engine() self.momentum_engine = get_momentum_engine()
# ── Data Quality Thresholds ──
# Matches below these thresholds produce default-only features that
# teach the model noise rather than signal.
DQ_MIN_FORM_MATCHES = 3 # team must have ≥3 prior matches
DQ_MIN_FEATURE_COVERAGE = 0.30 # ≥30% of key features must be non-default
def extract_all(self) -> list: def extract_all(self) -> list:
"""Extract features for all matches, yield row dicts.""" """Extract features for all matches with data quality validation."""
matches = self.loader.matches matches = self.loader.matches
total = len(matches) total = len(matches)
rows = [] rows = []
skipped = 0 skipped = 0
dq_rejected = 0
dq_reasons: dict = defaultdict(int)
t_start = time.time() t_start = time.time()
print(f"\n🔄 Extracting features for {total} matches...", flush=True) print(f"\n🔄 Extracting features for {total} matches...", flush=True)
# Process chronologically — ELO grows as we go # Process chronologically — ELO grows as we go
for i, m in enumerate(matches): for i, m in enumerate(matches):
( (
@@ -536,38 +682,43 @@ class FeatureExtractor:
away_name, away_name,
league_name, league_name,
) = m ) = m
if i % 100 == 0 and i > 0: if i % 100 == 0 and i > 0:
elapsed = time.time() - t_start elapsed = time.time() - t_start
rate = i / elapsed # matches per second rate = i / elapsed # matches per second
remaining = (total - i) / rate if rate > 0 else 0 remaining = (total - i) / rate if rate > 0 else 0
pct = i / total * 100 pct = i / total * 100
print(f" [{i}/{total}] ({pct:.0f}%) | {rate:.1f} maç/s | ETA: {remaining/60:.1f} dk | skipped: {skipped}", flush=True) print(
f" [{i}/{total}] ({pct:.0f}%) | {rate:.1f} maç/s | "
f"ETA: {remaining/60:.1f} dk | skipped: {skipped} | "
f"dq_rejected: {dq_rejected}",
flush=True,
)
row = self._extract_one( row = self._extract_one(
mid, mid, hid, aid, sh, sa, hth, hta, mst, lid,
hid, home_name, away_name, league_name,
aid,
sh,
sa,
hth,
hta,
mst,
lid,
home_name,
away_name,
league_name,
) )
if row: if row:
rows.append(row) # ── Data Quality Gate ──
dq_pass, reason = self._validate_row_quality(row, hid, aid, mst)
if dq_pass:
rows.append(row)
else:
dq_rejected += 1
dq_reasons[reason] += 1
else: else:
skipped += 1 skipped += 1
# Update ELO after processing (so ELO is calculated BEFORE the match) # Update ELO after processing (so ELO is calculated BEFORE the match)
self._update_elo(hid, aid, sh, sa) self._update_elo(hid, aid, sh, sa)
print(f" ✅ Extracted {len(rows)} rows, skipped {skipped}", flush=True) print(f" ✅ Extracted {len(rows)} rows, skipped {skipped}, DQ rejected {dq_rejected}", flush=True)
if dq_reasons:
print(f" 📊 DQ Rejection reasons:")
for reason, count in sorted(dq_reasons.items(), key=lambda x: -x[1]):
print(f" {reason}: {count}")
return rows return rows
def _extract_one( def _extract_one(
@@ -842,6 +993,20 @@ class FeatureExtractor:
"away_missing_impact": away_missing_impact, "away_missing_impact": away_missing_impact,
"home_goals_form": home_goals_form, "home_goals_form": home_goals_form,
"away_goals_form": away_goals_form, "away_goals_form": away_goals_form,
# Player-Level Features
"home_lineup_goals_per90": home_sq.get('lineup_goals_per90', 0.0),
"away_lineup_goals_per90": away_sq.get('lineup_goals_per90', 0.0),
"home_lineup_assists_per90": home_sq.get('lineup_assists_per90', 0.0),
"away_lineup_assists_per90": away_sq.get('lineup_assists_per90', 0.0),
"home_squad_continuity": home_sq.get('squad_continuity', 0.5),
"away_squad_continuity": away_sq.get('squad_continuity', 0.5),
"home_top_scorer_form": home_sq.get('top_scorer_form', 0),
"away_top_scorer_form": away_sq.get('top_scorer_form', 0),
"home_avg_player_exp": home_sq.get('avg_player_exp', 0.0),
"away_avg_player_exp": away_sq.get('avg_player_exp', 0.0),
"home_goals_diversity": home_sq.get('goals_diversity', 0.0),
"away_goals_diversity": away_sq.get('goals_diversity', 0.0),
# Labels # Labels
"score_home": sh, "score_home": sh,
@@ -867,7 +1032,58 @@ class FeatureExtractor:
} }
return row return row
def _validate_row_quality(
self,
row: dict,
home_id: str,
away_id: str,
before_date: int,
) -> tuple:
"""
Data quality gate for training rows.
Ensures the feature vector has enough real signal to be useful for
training. Rejects rows where critical features are all at their
default/fallback values — these teach the model noise, not patterns.
Returns (pass: bool, reason: str | None).
"""
# 1. Minimum form history: both teams must have enough prior matches
home_history = self.loader.team_matches.get(home_id, [])
away_history = self.loader.team_matches.get(away_id, [])
home_prior = sum(1 for m in home_history if m[0] < before_date)
away_prior = sum(1 for m in away_history if m[0] < before_date)
if home_prior < self.DQ_MIN_FORM_MATCHES:
return False, 'home_insufficient_history'
if away_prior < self.DQ_MIN_FORM_MATCHES:
return False, 'away_insufficient_history'
# 2. Feature coverage check: count how many key features are non-default
key_features = [
('home_goals_avg', 1.3),
('away_goals_avg', 1.3),
('home_clean_sheet_rate', 0.25),
('away_clean_sheet_rate', 0.25),
('home_avg_possession', 0.50),
('away_avg_possession', 0.50),
('home_avg_shots_on_target', 3.5),
('away_avg_shots_on_target', 3.5),
('h2h_total_matches', 0),
('odds_ms_h', 0.0),
]
non_default = sum(
1 for feat_name, default_val in key_features
if abs(float(row.get(feat_name, default_val)) - default_val) > 0.01
)
coverage = non_default / len(key_features)
if coverage < self.DQ_MIN_FEATURE_COVERAGE:
return False, f'low_feature_coverage_{coverage:.0%}'
return True, None
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# ELO (simplified inline version — doesn't need DB, grows incrementally) # ELO (simplified inline version — doesn't need DB, grows incrementally)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+553
View File
@@ -0,0 +1,553 @@
"""
V25 Pro Model Trainer — Optuna + Isotonic Calibration
=====================================================
Combines V25's 83 features + 12 markets + temporal split
with Optuna hyperparameter tuning and Isotonic Regression calibration.
Usage:
python scripts/train_v25_pro.py
python scripts/train_v25_pro.py --markets MS,OU25,BTTS # specific markets
python scripts/train_v25_pro.py --trials 30 # fewer trials
"""
import os
import sys
import json
import pickle
import argparse
import numpy as np
import pandas as pd
import xgboost as xgb
import lightgbm as lgb
import optuna
from optuna.samplers import TPESampler
from datetime import datetime
from sklearn.metrics import accuracy_score, log_loss, classification_report
from sklearn.isotonic import IsotonicRegression
from sklearn.base import BaseEstimator, ClassifierMixin
optuna.logging.set_verbosity(optuna.logging.WARNING)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
AI_ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_PATH = os.path.join(AI_ENGINE_DIR, "data", "training_data.csv")
MODELS_DIR = os.path.join(AI_ENGINE_DIR, "models", "v25")
REPORTS_DIR = os.path.join(AI_ENGINE_DIR, "reports", "training_v25")
os.makedirs(MODELS_DIR, exist_ok=True)
os.makedirs(REPORTS_DIR, exist_ok=True)
# ─── Feature Columns (95 features, NO target leakage) ───────────────
FEATURES = [
# ELO (8)
"home_overall_elo", "away_overall_elo", "elo_diff",
"home_home_elo", "away_away_elo",
"home_form_elo", "away_form_elo", "form_elo_diff",
# Form (12)
"home_goals_avg", "home_conceded_avg",
"away_goals_avg", "away_conceded_avg",
"home_clean_sheet_rate", "away_clean_sheet_rate",
"home_scoring_rate", "away_scoring_rate",
"home_winning_streak", "away_winning_streak",
"home_unbeaten_streak", "away_unbeaten_streak",
# H2H (6)
"h2h_total_matches", "h2h_home_win_rate", "h2h_draw_rate",
"h2h_avg_goals", "h2h_btts_rate", "h2h_over25_rate",
# Team Stats (8)
"home_avg_possession", "away_avg_possession",
"home_avg_shots_on_target", "away_avg_shots_on_target",
"home_shot_conversion", "away_shot_conversion",
"home_avg_corners", "away_avg_corners",
# Odds (24 + 20 presence flags)
"odds_ms_h", "odds_ms_d", "odds_ms_a",
"implied_home", "implied_draw", "implied_away",
"odds_ht_ms_h", "odds_ht_ms_d", "odds_ht_ms_a",
"odds_ou05_o", "odds_ou05_u",
"odds_ou15_o", "odds_ou15_u",
"odds_ou25_o", "odds_ou25_u",
"odds_ou35_o", "odds_ou35_u",
"odds_ht_ou05_o", "odds_ht_ou05_u",
"odds_ht_ou15_o", "odds_ht_ou15_u",
"odds_btts_y", "odds_btts_n",
"odds_ms_h_present", "odds_ms_d_present", "odds_ms_a_present",
"odds_ht_ms_h_present", "odds_ht_ms_d_present", "odds_ht_ms_a_present",
"odds_ou05_o_present", "odds_ou05_u_present",
"odds_ou15_o_present", "odds_ou15_u_present",
"odds_ou25_o_present", "odds_ou25_u_present",
"odds_ou35_o_present", "odds_ou35_u_present",
"odds_ht_ou05_o_present", "odds_ht_ou05_u_present",
"odds_ht_ou15_o_present", "odds_ht_ou15_u_present",
"odds_btts_y_present", "odds_btts_n_present",
# League (4)
"home_xga", "away_xga",
"league_avg_goals", "league_zero_goal_rate",
# Upset Engine (4)
"upset_atmosphere", "upset_motivation", "upset_fatigue", "upset_potential",
# Referee Engine (5)
"referee_home_bias", "referee_avg_goals", "referee_cards_total",
"referee_avg_yellow", "referee_experience",
# Momentum (3)
"home_momentum_score", "away_momentum_score", "momentum_diff",
# Squad (9)
"home_squad_quality", "away_squad_quality", "squad_diff",
"home_key_players", "away_key_players",
"home_missing_impact", "away_missing_impact",
"home_goals_form", "away_goals_form",
# Player-Level Features (12)
"home_lineup_goals_per90", "away_lineup_goals_per90",
"home_lineup_assists_per90", "away_lineup_assists_per90",
"home_squad_continuity", "away_squad_continuity",
"home_top_scorer_form", "away_top_scorer_form",
"home_avg_player_exp", "away_avg_player_exp",
"home_goals_diversity", "away_goals_diversity",
]
MARKET_CONFIGS = [
{"target": "label_ms", "name": "MS", "num_class": 3},
{"target": "label_ou15", "name": "OU15", "num_class": 2},
{"target": "label_ou25", "name": "OU25", "num_class": 2},
{"target": "label_ou35", "name": "OU35", "num_class": 2},
{"target": "label_btts", "name": "BTTS", "num_class": 2},
{"target": "label_ht_result", "name": "HT_RESULT", "num_class": 3},
{"target": "label_ht_ou05", "name": "HT_OU05", "num_class": 2},
{"target": "label_ht_ou15", "name": "HT_OU15", "num_class": 2},
{"target": "label_ht_ft", "name": "HTFT", "num_class": 9},
{"target": "label_odd_even", "name": "ODD_EVEN", "num_class": 2},
{"target": "label_cards_ou45", "name": "CARDS_OU45", "num_class": 2},
{"target": "label_handicap_ms", "name": "HANDICAP_MS", "num_class": 3},
]
def load_data():
"""Load and prepare training data."""
if not os.path.exists(DATA_PATH):
print(f"[ERROR] Data not found: {DATA_PATH}")
sys.exit(1)
print(f"[INFO] Loading {DATA_PATH}...")
df = pd.read_csv(DATA_PATH)
for col in FEATURES:
if col in df.columns:
df[col] = df[col].fillna(0)
# Derive odds presence flags for older CSVs
odds_flag_sources = {
"odds_ms_h_present": "odds_ms_h", "odds_ms_d_present": "odds_ms_d",
"odds_ms_a_present": "odds_ms_a", "odds_ht_ms_h_present": "odds_ht_ms_h",
"odds_ht_ms_d_present": "odds_ht_ms_d", "odds_ht_ms_a_present": "odds_ht_ms_a",
"odds_ou05_o_present": "odds_ou05_o", "odds_ou05_u_present": "odds_ou05_u",
"odds_ou15_o_present": "odds_ou15_o", "odds_ou15_u_present": "odds_ou15_u",
"odds_ou25_o_present": "odds_ou25_o", "odds_ou25_u_present": "odds_ou25_u",
"odds_ou35_o_present": "odds_ou35_o", "odds_ou35_u_present": "odds_ou35_u",
"odds_ht_ou05_o_present": "odds_ht_ou05_o", "odds_ht_ou05_u_present": "odds_ht_ou05_u",
"odds_ht_ou15_o_present": "odds_ht_ou15_o", "odds_ht_ou15_u_present": "odds_ht_ou15_u",
"odds_btts_y_present": "odds_btts_y", "odds_btts_n_present": "odds_btts_n",
}
for flag_col, odds_col in odds_flag_sources.items():
if flag_col not in df.columns:
df[flag_col] = (
pd.to_numeric(df.get(odds_col, 0), errors="coerce").fillna(0) > 1.01
).astype(float)
print(f"[INFO] Shape: {df.shape}, Features: {len(FEATURES)}")
return df
def temporal_split_4way(valid_df: pd.DataFrame):
"""Chronological 60/15/10/15 split: train/val/cal/test."""
ordered = valid_df.sort_values("mst_utc").reset_index(drop=True)
n = len(ordered)
i1 = int(n * 0.60)
i2 = int(n * 0.75)
i3 = int(n * 0.85)
train = ordered.iloc[:i1].copy()
val = ordered.iloc[i1:i2].copy()
cal = ordered.iloc[i2:i3].copy()
test = ordered.iloc[i3:].copy()
return train, val, cal, test
# ─── XGBoost Wrapper for sklearn CalibratedClassifierCV ─────────────
class XGBWrapper(BaseEstimator, ClassifierMixin):
"""Thin sklearn-compatible wrapper around xgb.train for Isotonic calibration."""
def __init__(self, params, num_boost_round=500):
self.params = params
self.num_boost_round = num_boost_round
self.model_ = None
self.classes_ = None
def fit(self, X, y, **kwargs):
self.classes_ = np.unique(y)
dtrain = xgb.DMatrix(X, label=y)
self.model_ = xgb.train(self.params, dtrain, num_boost_round=self.num_boost_round)
return self
def predict_proba(self, X):
dm = xgb.DMatrix(X)
probs = self.model_.predict(dm)
if len(probs.shape) == 1:
probs = np.column_stack([1 - probs, probs])
return probs
def predict(self, X):
return np.argmax(self.predict_proba(X), axis=1)
# ─── Optuna Objectives ──────────────────────────────────────────────
def xgb_objective(trial, X_train, y_train, X_val, y_val, num_class):
params = {
"objective": "multi:softprob" if num_class > 2 else "binary:logistic",
"eval_metric": "mlogloss" if num_class > 2 else "logloss",
"max_depth": trial.suggest_int("max_depth", 3, 8),
"eta": trial.suggest_float("eta", 0.01, 0.15, log=True),
"subsample": trial.suggest_float("subsample", 0.6, 1.0),
"colsample_bytree": trial.suggest_float("colsample_bytree", 0.5, 1.0),
"min_child_weight": trial.suggest_int("min_child_weight", 1, 10),
"gamma": trial.suggest_float("gamma", 1e-8, 1.0, log=True),
"reg_lambda": trial.suggest_float("reg_lambda", 1e-8, 10.0, log=True),
"reg_alpha": trial.suggest_float("reg_alpha", 1e-8, 1.0, log=True),
"n_jobs": 4,
"random_state": 42,
}
if num_class > 2:
params["num_class"] = num_class
dtrain = xgb.DMatrix(X_train, label=y_train)
dval = xgb.DMatrix(X_val, label=y_val)
model = xgb.train(
params, dtrain, num_boost_round=1000,
evals=[(dval, "val")], early_stopping_rounds=50, verbose_eval=False,
)
preds = model.predict(dval)
if len(preds.shape) == 1:
preds = np.column_stack([1 - preds, preds])
return log_loss(y_val, preds)
def lgb_objective(trial, X_train, y_train, X_val, y_val, num_class):
params = {
"objective": "multiclass" if num_class > 2 else "binary",
"metric": "multi_logloss" if num_class > 2 else "binary_logloss",
"max_depth": trial.suggest_int("max_depth", 3, 8),
"learning_rate": trial.suggest_float("learning_rate", 0.01, 0.15, log=True),
"feature_fraction": trial.suggest_float("feature_fraction", 0.5, 1.0),
"bagging_fraction": trial.suggest_float("bagging_fraction", 0.6, 1.0),
"bagging_freq": trial.suggest_int("bagging_freq", 1, 7),
"min_child_samples": trial.suggest_int("min_child_samples", 5, 50),
"lambda_l1": trial.suggest_float("lambda_l1", 1e-8, 1.0, log=True),
"lambda_l2": trial.suggest_float("lambda_l2", 1e-8, 10.0, log=True),
"n_jobs": 4, "random_state": 42, "verbose": -1,
}
if num_class > 2:
params["num_class"] = num_class
train_data = lgb.Dataset(X_train, label=y_train)
val_data = lgb.Dataset(X_val, label=y_val, reference=train_data)
model = lgb.train(
params, train_data, num_boost_round=1000,
valid_sets=[val_data], valid_names=["val"],
callbacks=[lgb.early_stopping(50), lgb.log_evaluation(0)],
)
preds = model.predict(X_val, num_iteration=model.best_iteration)
if len(preds.shape) == 1:
preds = np.column_stack([1 - preds, preds])
return log_loss(y_val, preds)
# ─── Main Training Pipeline ─────────────────────────────────────────
def train_market(df, target_col, market_name, num_class, n_trials):
"""Full pipeline for one market: Optuna → Train → Calibrate → Evaluate."""
print(f"\n{'='*60}")
print(f"[MARKET] {market_name} (classes={num_class})")
print(f"{'='*60}")
valid_df = df[df[target_col].notna()].copy()
valid_df = valid_df[valid_df[target_col].astype(str) != ""].copy()
print(f"[INFO] Valid samples: {len(valid_df)}")
if len(valid_df) < 500:
print(f"[SKIP] Not enough data for {market_name}")
return None
available_features = [f for f in FEATURES if f in valid_df.columns]
print(f"[INFO] Features: {len(available_features)}/{len(FEATURES)}")
train_df, val_df, cal_df, test_df = temporal_split_4way(valid_df)
X_train = train_df[available_features].values
X_val = val_df[available_features].values
X_cal = cal_df[available_features].values
X_test = test_df[available_features].values
y_train = train_df[target_col].astype(int).values
y_val = val_df[target_col].astype(int).values
y_cal = cal_df[target_col].astype(int).values
y_test = test_df[target_col].astype(int).values
print(f"[INFO] Split: train={len(X_train)} val={len(X_val)} cal={len(X_cal)} test={len(X_test)}")
# ── Phase 1: Optuna XGBoost ──────────────────────────────────
print(f"\n[OPTUNA] XGBoost tuning ({n_trials} trials)...")
xgb_study = optuna.create_study(direction="minimize", sampler=TPESampler(seed=42))
xgb_study.optimize(
lambda trial: xgb_objective(trial, X_train, y_train, X_val, y_val, num_class),
n_trials=n_trials,
)
xgb_best = xgb_study.best_params
print(f"[OK] XGB best logloss: {xgb_study.best_value:.4f}")
# ── Phase 2: Optuna LightGBM ─────────────────────────────────
print(f"[OPTUNA] LightGBM tuning ({n_trials} trials)...")
lgb_study = optuna.create_study(direction="minimize", sampler=TPESampler(seed=42))
lgb_study.optimize(
lambda trial: lgb_objective(trial, X_train, y_train, X_val, y_val, num_class),
n_trials=n_trials,
)
lgb_best = lgb_study.best_params
print(f"[OK] LGB best logloss: {lgb_study.best_value:.4f}")
# ── Phase 3: Train final models with best params ─────────────
# XGBoost final
xgb_params = {
"objective": "multi:softprob" if num_class > 2 else "binary:logistic",
"eval_metric": "mlogloss" if num_class > 2 else "logloss",
"n_jobs": 4, "random_state": 42,
**{k: v for k, v in xgb_best.items()},
}
if num_class > 2:
xgb_params["num_class"] = num_class
dtrain = xgb.DMatrix(X_train, label=y_train)
dval = xgb.DMatrix(X_val, label=y_val)
xgb_model = xgb.train(
xgb_params, dtrain, num_boost_round=1500,
evals=[(dtrain, "train"), (dval, "val")],
early_stopping_rounds=80, verbose_eval=200,
)
print(f"[OK] XGB final: iter={xgb_model.best_iteration}, score={xgb_model.best_score:.4f}")
# LightGBM final
lgb_params = {
"objective": "multiclass" if num_class > 2 else "binary",
"metric": "multi_logloss" if num_class > 2 else "binary_logloss",
"n_jobs": 4, "random_state": 42, "verbose": -1,
**{k: v for k, v in lgb_best.items()},
}
if num_class > 2:
lgb_params["num_class"] = num_class
lgb_train_data = lgb.Dataset(X_train, label=y_train)
lgb_val_data = lgb.Dataset(X_val, label=y_val, reference=lgb_train_data)
lgb_model = lgb.train(
lgb_params, lgb_train_data, num_boost_round=1500,
valid_sets=[lgb_train_data, lgb_val_data],
valid_names=["train", "val"],
callbacks=[lgb.early_stopping(80), lgb.log_evaluation(200)],
)
print(f"[OK] LGB final: iter={lgb_model.best_iteration}")
# ── Phase 4: Isotonic Calibration on cal set ─────────────────
print("[CAL] Fitting Isotonic Regression (per-class)...")
# XGB calibration — manual IsotonicRegression per class
dcal = xgb.DMatrix(X_cal)
xgb_cal_raw = xgb_model.predict(dcal)
if len(xgb_cal_raw.shape) == 1:
xgb_cal_raw = np.column_stack([1 - xgb_cal_raw, xgb_cal_raw])
xgb_iso_calibrators = []
for cls_idx in range(num_class):
ir = IsotonicRegression(out_of_bounds="clip")
y_binary = (y_cal == cls_idx).astype(float)
ir.fit(xgb_cal_raw[:, cls_idx], y_binary)
xgb_iso_calibrators.append(ir)
print(f"[OK] XGB Isotonic calibrators fitted: {num_class} classes")
# LGB calibration — manual IsotonicRegression per class
lgb_cal_raw = lgb_model.predict(X_cal, num_iteration=lgb_model.best_iteration)
if len(lgb_cal_raw.shape) == 1:
lgb_cal_raw = np.column_stack([1 - lgb_cal_raw, lgb_cal_raw])
lgb_iso_calibrators = []
for cls_idx in range(num_class):
ir = IsotonicRegression(out_of_bounds="clip")
y_binary = (y_cal == cls_idx).astype(float)
ir.fit(lgb_cal_raw[:, cls_idx], y_binary)
lgb_iso_calibrators.append(ir)
print(f"[OK] LGB Isotonic calibrators fitted: {num_class} classes")
# ── Phase 5: Evaluate on test set ────────────────────────────
print("\n[EVAL] Test set evaluation...")
dtest = xgb.DMatrix(X_test)
# Raw XGB
xgb_raw_probs = xgb_model.predict(dtest)
if len(xgb_raw_probs.shape) == 1:
xgb_raw_probs = np.column_stack([1 - xgb_raw_probs, xgb_raw_probs])
# Calibrated XGB — apply isotonic per class + renormalize
xgb_cal_probs = np.column_stack([
xgb_iso_calibrators[i].predict(xgb_raw_probs[:, i]) for i in range(num_class)
])
xgb_cal_probs = xgb_cal_probs / xgb_cal_probs.sum(axis=1, keepdims=True)
# Raw LGB
lgb_raw_probs = lgb_model.predict(X_test, num_iteration=lgb_model.best_iteration)
if len(lgb_raw_probs.shape) == 1:
lgb_raw_probs = np.column_stack([1 - lgb_raw_probs, lgb_raw_probs])
# Calibrated LGB — apply isotonic per class + renormalize
lgb_cal_probs = np.column_stack([
lgb_iso_calibrators[i].predict(lgb_raw_probs[:, i]) for i in range(num_class)
])
lgb_cal_probs = lgb_cal_probs / lgb_cal_probs.sum(axis=1, keepdims=True)
# Ensembles
raw_ensemble = (xgb_raw_probs + lgb_raw_probs) / 2
cal_ensemble = (xgb_cal_probs + lgb_cal_probs) / 2
def _eval(probs, label):
preds = np.argmax(probs, axis=1)
acc = accuracy_score(y_test, preds)
ll = log_loss(y_test, probs)
print(f" {label}: Acc={acc:.4f} LogLoss={ll:.4f}")
return {"accuracy": round(float(acc), 4), "logloss": round(float(ll), 4)}
m_xgb_raw = _eval(xgb_raw_probs, "XGB Raw")
m_xgb_cal = _eval(xgb_cal_probs, "XGB Calibrated")
m_lgb_raw = _eval(lgb_raw_probs, "LGB Raw")
m_lgb_cal = _eval(lgb_cal_probs, "LGB Calibrated")
m_ensemble = _eval(raw_ensemble, "Ensemble Raw")
m_cal_ensemble = _eval(cal_ensemble, "Ensemble Calibrated")
# Classification report for ensemble
ens_preds = np.argmax(raw_ensemble, axis=1)
print(f"\n[REPORT] Ensemble Classification Report:")
print(classification_report(y_test, ens_preds))
# ── Phase 6: Save models ─────────────────────────────────────
# Raw models (orchestrator compatible)
xgb_path = os.path.join(MODELS_DIR, f"xgb_v25_{market_name.lower()}.json")
xgb_model.save_model(xgb_path)
print(f"[SAVE] {xgb_path}")
lgb_path = os.path.join(MODELS_DIR, f"lgb_v25_{market_name.lower()}.txt")
lgb_model.save_model(lgb_path)
print(f"[SAVE] {lgb_path}")
# Isotonic calibrators (XGB + LGB)
xgb_cal_path = os.path.join(MODELS_DIR, f"iso_xgb_v25_{market_name.lower()}.pkl")
with open(xgb_cal_path, "wb") as f:
pickle.dump(xgb_iso_calibrators, f)
print(f"[SAVE] {xgb_cal_path}")
lgb_cal_path = os.path.join(MODELS_DIR, f"iso_lgb_v25_{market_name.lower()}.pkl")
with open(lgb_cal_path, "wb") as f:
pickle.dump(lgb_iso_calibrators, f)
print(f"[SAVE] {lgb_cal_path}")
return {
"market": market_name,
"samples": int(len(valid_df)),
"train": int(len(X_train)),
"val": int(len(X_val)),
"cal": int(len(X_cal)),
"test": int(len(X_test)),
"features_used": len(available_features),
"xgb_best_params": xgb_best,
"lgb_best_params": lgb_best,
"xgb_best_iteration": int(xgb_model.best_iteration),
"lgb_best_iteration": int(lgb_model.best_iteration),
"xgb_optuna_best_logloss": round(float(xgb_study.best_value), 4),
"lgb_optuna_best_logloss": round(float(lgb_study.best_value), 4),
"test_xgb_raw": m_xgb_raw,
"test_xgb_calibrated": m_xgb_cal,
"test_lgb_raw": m_lgb_raw,
"test_lgb_calibrated": m_lgb_cal,
"test_ensemble_raw": m_ensemble,
"test_ensemble_calibrated": m_cal_ensemble,
}
def main():
parser = argparse.ArgumentParser(description="V25 Pro Trainer")
parser.add_argument("--markets", type=str, default=None,
help="Comma-separated market names (e.g., MS,OU25,BTTS)")
parser.add_argument("--trials", type=int, default=50,
help="Optuna trials per model per market")
args = parser.parse_args()
print("=" * 60)
print("V25 PRO — Optuna + Isotonic Calibration")
print("=" * 60)
print(f"[INFO] Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"[INFO] Trials per model: {args.trials}")
print(f"[INFO] Total features: {len(FEATURES)}")
df = load_data()
configs = MARKET_CONFIGS
if args.markets:
selected = [m.strip().upper() for m in args.markets.split(",")]
configs = [c for c in configs if c["name"] in selected]
print(f"[INFO] Selected markets: {[c['name'] for c in configs]}")
all_metrics = {
"trained_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"trainer": "v25_pro",
"optuna_trials": args.trials,
"total_features": len(FEATURES),
"markets": {},
}
for config in configs:
target = config["target"]
if target not in df.columns:
print(f"[SKIP] {config['name']}: missing target {target}")
continue
metrics = train_market(
df, target, config["name"], config["num_class"], args.trials,
)
if metrics:
all_metrics["markets"][config["name"]] = metrics
# Save feature list
feature_path = os.path.join(MODELS_DIR, "feature_cols.json")
with open(feature_path, "w") as f:
json.dump(FEATURES, f, indent=2)
# Save full report
report_path = os.path.join(REPORTS_DIR, "v25_pro_metrics.json")
with open(report_path, "w") as f:
json.dump(all_metrics, f, indent=2, default=str)
print(f"\n[SAVE] Report: {report_path}")
# Summary
print("\n" + "=" * 60)
print("[SUMMARY]")
print("=" * 60)
for name, m in all_metrics["markets"].items():
ens = m.get("test_ensemble_calibrated", m.get("test_ensemble_raw", {}))
acc = ens.get('accuracy', '?')
ll = ens.get('logloss', '?')
acc_s = f"{acc:.4f}" if isinstance(acc, float) else str(acc)
ll_s = f"{ll:.4f}" if isinstance(ll, float) else str(ll)
print(f" {name:12s} | Acc={acc_s:>6s} | LL={ll_s:>6s} | "
f"XGB_iter={m.get('xgb_best_iteration','?')} LGB_iter={m.get('lgb_best_iteration','?')}")
print(f"\n[INFO] Completed: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("[OK] V25 PRO Training Complete!")
if __name__ == "__main__":
main()
+118 -21
View File
@@ -20,7 +20,7 @@ from sklearn.isotonic import IsotonicRegression
warnings.filterwarnings("ignore") warnings.filterwarnings("ignore")
AI_DIR = Path(__file__).resolve().parent.parent AI_DIR = Path(__file__).resolve().parent.parent
DATA_CSV = AI_DIR / "data" / "training_data_v27.csv" DATA_CSV = AI_DIR / "data" / "training_data.csv"
MODELS_DIR = AI_DIR / "models" / "v27" MODELS_DIR = AI_DIR / "models" / "v27"
MODELS_DIR.mkdir(parents=True, exist_ok=True) MODELS_DIR.mkdir(parents=True, exist_ok=True)
@@ -373,15 +373,52 @@ def main():
print("\n" + ""*65) print("\n" + ""*65)
print(" STAGE A.2: Fundamentals-Only O/U 2.5 Model") print(" STAGE A.2: Fundamentals-Only O/U 2.5 Model")
print(""*65) print(""*65)
y_tr_ou = tr["label_ou25"].values y_tr_ou = tr['label_ou25'].values
y_va_ou = va["label_ou25"].values y_va_ou = va['label_ou25'].values
mask_tr = ~np.isnan(y_tr_ou) mask_tr = ~np.isnan(y_tr_ou)
mask_va = ~np.isnan(y_va_ou) mask_va = ~np.isnan(y_va_ou)
if mask_tr.sum() > 1000: if mask_tr.sum() > 1000:
ou_models = train_fundamentals_model( ou_models = train_fundamentals_model(
X_tr[mask_tr], y_tr_ou[mask_tr].astype(int), X_tr[mask_tr], y_tr_ou[mask_tr].astype(int),
X_va[mask_va], y_va_ou[mask_va].astype(int), X_va[mask_va], y_va_ou[mask_va].astype(int),
clean_feats, "ou25") clean_feats, 'ou25')
# ── STAGE A.3: BTTS Model ──
btts_models = None
if 'label_btts' in tr.columns:
print('\n' + '' * 65)
print(' STAGE A.3: Fundamentals-Only BTTS Model')
print('' * 65)
y_tr_btts = tr['label_btts'].values
y_va_btts = va['label_btts'].values
mask_tr_btts = ~np.isnan(y_tr_btts)
mask_va_btts = ~np.isnan(y_va_btts)
if mask_tr_btts.sum() > 1000:
btts_models = train_fundamentals_model(
X_tr[mask_tr_btts], y_tr_btts[mask_tr_btts].astype(int),
X_va[mask_va_btts], y_va_btts[mask_va_btts].astype(int),
clean_feats, 'btts')
# Quick val accuracy
btts_probs = ensemble_predict(
btts_models,
X_va[mask_va_btts],
clean_feats,
n_class=2,
)
btts_acc = accuracy_score(
y_va_btts[mask_va_btts].astype(int),
btts_probs.argmax(1),
)
btts_ll = log_loss(
y_va_btts[mask_va_btts].astype(int),
btts_probs,
)
print(f'\n BTTS Ensemble Val: acc={btts_acc:.4f}, logloss={btts_ll:.4f}')
# Compare with naive baseline (always predict majority class)
btts_majority = y_va_btts[mask_va_btts].astype(int).mean()
print(f' BTTS baseline: {max(btts_majority, 1-btts_majority):.4f} (majority class)')
print(f' Model vs baseline: {btts_acc - max(btts_majority, 1-btts_majority):+.4f}')
# ── STAGE C: Backtest ── # ── STAGE C: Backtest ──
print("\n" + ""*65) print("\n" + ""*65)
@@ -422,13 +459,58 @@ def main():
# OU25 backtest # OU25 backtest
if ou_models: if ou_models:
print("\n --- O/U 2.5 Backtest ---") print('\n --- O/U 2.5 Backtest ---')
for edge in [0.05, 0.07, 0.10]: for edge in [0.05, 0.07, 0.10]:
r = backtest_value(ou_models, te, clean_feats, "ou25", r = backtest_value(ou_models, te, clean_feats, 'ou25',
min_edge=edge, min_odds=1.50, max_odds=3.0, min_edge=edge, min_odds=1.50, max_odds=3.0,
use_kelly=True) use_kelly=True)
if r.get("total", 0) > 0: if r.get('total', 0) > 0:
print_backtest(r, f"OU25 edge>{edge}") print_backtest(r, f'OU25 edge>{edge}')
# BTTS backtest
if btts_models and 'label_btts' in te.columns:
print('\n --- BTTS Backtest ---')
# Build BTTS odds for backtest
if 'odds_btts_y' in te.columns and 'odds_btts_n' in te.columns:
te_btts = te.copy()
te_btts['odds_btts_y'] = pd.to_numeric(
te_btts['odds_btts_y'], errors='coerce',
).fillna(1.85)
te_btts['odds_btts_n'] = pd.to_numeric(
te_btts['odds_btts_n'], errors='coerce',
).fillna(1.85)
for edge in [0.05, 0.07, 0.10]:
X_test = te_btts[clean_feats].values
probs = ensemble_predict(btts_models, X_test, clean_feats, 2)
y_btts = te_btts['label_btts'].values.astype(int)
odds_arr = te_btts[['odds_btts_n', 'odds_btts_y']].values
m_arr = 1 / odds_arr
impl = m_arr / m_arr.sum(axis=1, keepdims=True)
total_bets = 0
wins = 0
pnl = 0.0
for i in range(len(y_btts)):
for cls in range(2):
e = probs[i, cls] - impl[i, cls]
o = odds_arr[i, cls]
if e < edge or o < 1.50 or o > 3.0:
continue
total_bets += 1
won = (y_btts[i] == cls)
if won:
wins += 1
pnl += 10 * (o - 1)
else:
pnl -= 10
if total_bets > 0:
roi = pnl / (total_bets * 10) * 100
hit = wins / total_bets * 100
print(
f' Edge>{edge:.2f}: {total_bets} bets, '
f'hit={hit:.1f}%, ROI={roi:+.1f}%'
)
# ── Feature importance ── # ── Feature importance ──
if "lgb" in ms_models: if "lgb" in ms_models:
@@ -452,25 +534,40 @@ def main():
if ou_models: if ou_models:
for name, m in ou_models.items(): for name, m in ou_models.items():
p = MODELS_DIR / f"v27_ou25_{name}.pkl" p = MODELS_DIR / f'v27_ou25_{name}.pkl'
with open(p, "wb") as f: with open(p, 'wb') as f:
pickle.dump(m, f) pickle.dump(m, f)
print(f"{p.name}") print(f'{p.name}')
if btts_models:
for name, m in btts_models.items():
p = MODELS_DIR / f'v27_btts_{name}.pkl'
with open(p, 'wb') as f:
pickle.dump(m, f)
print(f'{p.name}')
meta = { meta = {
"version": "v27-pro", "trained_at": time.strftime("%Y-%m-%d %H:%M:%S"), 'version': 'v27-pro',
"approach": "odds-free fundamentals + value edge detection", 'trained_at': time.strftime('%Y-%m-%d %H:%M:%S'),
"feature_count": len(clean_feats), 'approach': 'odds-free fundamentals + value edge detection',
"total_samples": len(df), 'feature_count': len(clean_feats),
"val_acc": round(val_acc, 4), "val_ll": round(val_ll, 4), 'total_samples': len(df),
"best_config": {k: v for k, v in best_cfg.items() if k != "result"} if best_cfg else {}, 'val_acc': round(val_acc, 4),
"markets": ["ms"] + (["ou25"] if ou_models else []), 'val_ll': round(val_ll, 4),
'best_config': {
k: v for k, v in best_cfg.items() if k != 'result'
} if best_cfg else {},
'markets': (
['ms']
+ (['ou25'] if ou_models else [])
+ (['btts'] if btts_models else [])
),
} }
with open(MODELS_DIR / "v27_metadata.json", "w") as f: with open(MODELS_DIR / 'v27_metadata.json', 'w') as f:
json.dump(meta, f, indent=2, default=str) json.dump(meta, f, indent=2, default=str)
with open(MODELS_DIR / "v27_feature_cols.json", "w") as f: with open(MODELS_DIR / 'v27_feature_cols.json', 'w') as f:
json.dump(clean_feats, f, indent=2) json.dump(clean_feats, f, indent=2)
print(f" ✓ metadata + feature_cols") print(f' ✓ metadata + feature_cols')
print(f"\n Total time: {(time.time()-t0)/60:.1f} min") print(f"\n Total time: {(time.time()-t0)/60:.1f} min")
print(" DONE!") print(" DONE!")
+15 -7
View File
@@ -165,6 +165,11 @@ class BettingBrain:
score -= 18.0 score -= 18.0
issues.append("base_model_not_playable") issues.append("base_model_not_playable")
is_value_sniper = bool(row.get("is_value_sniper"))
if is_value_sniper:
score += 35.0
positives.append("value_sniper_override")
score += max(0.0, min(20.0, calibrated_conf * 0.22)) score += max(0.0, min(20.0, calibrated_conf * 0.22))
score += max(-8.0, min(16.0, ev_edge * 45.0)) score += max(-8.0, min(16.0, ev_edge * 45.0))
score += max(0.0, min(14.0, play_score * 0.12)) score += max(0.0, min(14.0, play_score * 0.12))
@@ -178,13 +183,13 @@ class BettingBrain:
if odds < self.MIN_ODDS: if odds < self.MIN_ODDS:
vetoes.append("odds_below_minimum") vetoes.append("odds_below_minimum")
if calibrated_conf < 38.0: if calibrated_conf < 38.0 and not is_value_sniper:
vetoes.append("calibrated_confidence_too_low") vetoes.append("calibrated_confidence_too_low")
if play_score < 50.0: if play_score < 50.0 and not is_value_sniper:
vetoes.append("play_score_too_low") vetoes.append("play_score_too_low")
if divergence is not None: if divergence is not None:
if divergence >= self.HARD_DIVERGENCE: if divergence >= self.HARD_DIVERGENCE and not is_value_sniper:
score -= 42.0 score -= 42.0
vetoes.append("v25_v27_hard_disagreement") vetoes.append("v25_v27_hard_disagreement")
elif divergence >= self.SOFT_DIVERGENCE: elif divergence >= self.SOFT_DIVERGENCE:
@@ -211,7 +216,7 @@ class BettingBrain:
else: else:
score -= 16.0 score -= 16.0
issues.append("historical_sample_too_low") issues.append("historical_sample_too_low")
if market == "DC": if market == "DC" and not is_value_sniper:
vetoes.append("dc_without_historical_sample") vetoes.append("dc_without_historical_sample")
elif market in {"MS", "DC", "OU25"}: elif market in {"MS", "DC", "OU25"}:
score -= 10.0 score -= 10.0
@@ -227,20 +232,21 @@ class BettingBrain:
and model_prob >= self.EXTREME_MODEL_PROB and model_prob >= self.EXTREME_MODEL_PROB
and model_gap >= self.EXTREME_GAP and model_gap >= self.EXTREME_GAP
and not triple_is_value and not triple_is_value
and not is_value_sniper
): ):
score -= 24.0 score -= 24.0
vetoes.append("extreme_probability_without_evidence") vetoes.append("extreme_probability_without_evidence")
if market in {"HT", "HTFT", "OE"} and score < 86.0: if market in {"HT", "HTFT", "OE"} and score < 86.0 and not is_value_sniper:
vetoes.append("volatile_market_requires_exceptional_evidence") vetoes.append("volatile_market_requires_exceptional_evidence")
score = max(0.0, min(100.0, score)) score = max(0.0, min(100.0, score))
action = "BET" action = "BET"
if vetoes: if vetoes:
action = "REJECT" action = "REJECT"
elif score < self.MIN_WATCH_SCORE: elif score < self.MIN_WATCH_SCORE and not is_value_sniper:
action = "REJECT" action = "REJECT"
elif score < self.MIN_BET_SCORE: elif score < self.MIN_BET_SCORE and not is_value_sniper:
action = "WATCH" action = "WATCH"
row["betting_brain"] = { row["betting_brain"] = {
@@ -276,6 +282,7 @@ class BettingBrain:
for source in ("main_pick", "value_pick"): for source in ("main_pick", "value_pick"):
item = package.get(source) item = package.get(source)
if isinstance(item, dict) and item.get("market"): if isinstance(item, dict) and item.get("market"):
# print(f"DEBUG: {source} is_value_sniper: {item.get('is_value_sniper')}")
rows[self._row_key(item)] = dict(item) rows[self._row_key(item)] = dict(item)
for source in ("supporting_picks", "bet_summary"): for source in ("supporting_picks", "bet_summary"):
@@ -283,6 +290,7 @@ class BettingBrain:
if isinstance(item, dict) and item.get("market"): if isinstance(item, dict) and item.get("market"):
key = self._row_key(item) key = self._row_key(item)
rows[key] = self._merge_row(rows.get(key), item) rows[key] = self._merge_row(rows.get(key), item)
return list(rows.values()) return list(rows.values())
@staticmethod @staticmethod
+151 -24
View File
@@ -14,11 +14,40 @@ is missing or queries fail.
from __future__ import annotations from __future__ import annotations
import unicodedata
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
# ─── Turkish Name Normalization ──────────────────────────────────
_TR_CHAR_MAP = str.maketrans(
'çÇğĞıİöÖşŞüÜâÂîÎûÛ',
'cCgGiIoOsSuUaAiIuU',
)
def _normalize_name(name: str) -> str:
"""
Normalize a Turkish referee name for fuzzy matching.
Strips accents, lowercases, removes extra whitespace, and maps
Turkish-specific characters to their ASCII equivalents.
"""
if not name:
return ''
# 1. Turkish-specific character mapping
normalized = name.translate(_TR_CHAR_MAP)
# 2. Unicode NFKD decomposition → strip combining marks
normalized = unicodedata.normalize('NFKD', normalized)
normalized = ''.join(
c for c in normalized if not unicodedata.combining(c)
)
# 3. Lowercase + collapse whitespace
return ' '.join(normalized.lower().split())
class FeatureEnrichmentService: class FeatureEnrichmentService:
"""Stateless service — all state comes from DB via cursor.""" """Stateless service — all state comes from DB via cursor."""
@@ -380,34 +409,20 @@ class FeatureEnrichmentService:
""" """
Referee tendencies: home win bias, avg goals, card rates. Referee tendencies: home win bias, avg goals, card rates.
Matches referee by name in match_officials (role_id=1 = Orta Hakem). Matches referee by name in match_officials (role_id=1 = Orta Hakem).
Uses Turkish-aware fuzzy matching as a fallback when exact name
lookup returns zero results.
""" """
if not referee_name: if not referee_name:
return dict(self._DEFAULT_REFEREE) return dict(self._DEFAULT_REFEREE)
try:
# Get match IDs officiated by this referee rows = self._query_referee_matches(cur, referee_name, before_date_ms, limit)
cur.execute(
""" # Fuzzy fallback: if exact match fails, try normalized name search
SELECT if not rows:
m.home_team_id, rows = self._fuzzy_referee_lookup(
m.score_home, cur, referee_name, before_date_ms, limit,
m.score_away,
m.id AS match_id
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.name = %s
AND mo.role_id = 1
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT %s
""",
(referee_name, before_date_ms, limit),
) )
rows = cur.fetchall()
except Exception:
return dict(self._DEFAULT_REFEREE)
if not rows: if not rows:
return dict(self._DEFAULT_REFEREE) return dict(self._DEFAULT_REFEREE)
@@ -459,6 +474,118 @@ class FeatureEnrichmentService:
'experience': total, 'experience': total,
} }
def _query_referee_matches(
self,
cur: RealDictCursor,
referee_name: str,
before_date_ms: int,
limit: int,
) -> list:
"""Exact-match referee lookup in match_officials."""
try:
cur.execute(
"""
SELECT
m.home_team_id,
m.score_home,
m.score_away,
m.id AS match_id
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.name = %s
AND mo.role_id = 1
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT %s
""",
(referee_name, before_date_ms, limit),
)
return cur.fetchall()
except Exception:
return []
def _fuzzy_referee_lookup(
self,
cur: RealDictCursor,
referee_name: str,
before_date_ms: int,
limit: int,
) -> list:
"""
Fuzzy referee lookup using Turkish name normalization.
Strategy: fetch recent distinct referee names from match_officials,
normalize both the query name and each candidate, and pick the
best match. This handles common mismatches like:
- 'Hüseyin Göçek' vs 'Huseyin Gocek'
- 'Ali Palabıyık' vs 'Ali Palabiyik'
- Extra/missing middle initials
"""
normalized_query = _normalize_name(referee_name)
if not normalized_query:
return []
try:
# Fetch candidate referee names (distinct, recent, role=1)
cur.execute(
"""
SELECT DISTINCT mo.name
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.role_id = 1
AND m.status = 'FT'
AND m.mst_utc < %s
ORDER BY mo.name
LIMIT 2000
""",
(before_date_ms,),
)
candidates = cur.fetchall()
except Exception:
return []
if not candidates:
return []
# Find best match by normalized name comparison
best_match: Optional[str] = None
best_score = 0.0
for cand_row in candidates:
cand_name = cand_row.get('name', '')
if not cand_name:
continue
normalized_cand = _normalize_name(cand_name)
# Exact normalized match
if normalized_cand == normalized_query:
best_match = cand_name
best_score = 1.0
break
# Substring containment (handles "First Last" vs "First M. Last")
if (
normalized_query in normalized_cand
or normalized_cand in normalized_query
):
containment_score = min(
len(normalized_query), len(normalized_cand)
) / max(len(normalized_query), len(normalized_cand))
if containment_score > best_score and containment_score > 0.6:
best_match = cand_name
best_score = containment_score
if not best_match:
return []
# Re-query with the resolved name
return self._query_referee_matches(
cur, best_match, before_date_ms, limit,
)
# ─── 5. League Averages ───────────────────────────────────────── # ─── 5. League Averages ─────────────────────────────────────────
def compute_league_averages( def compute_league_averages(
+367
View File
@@ -0,0 +1,367 @@
"""
Match Commentary Generator
===========================
Generates human-readable Turkish commentary from the analysis package.
Reads all engine signals (model, odds band, betting brain, triple value)
and produces a clear, actionable summary for end users.
No LLM required — fully template-based.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def generate_match_commentary(package: Dict[str, Any]) -> Dict[str, Any]:
"""
Main entry point. Takes a full analysis package and returns a commentary dict.
Returns:
{
"action": "BET" | "WATCH" | "SKIP",
"headline": "...",
"summary": "...",
"notes": ["...", "..."],
"contradictions": ["...", "..."],
"confidence_label": "YÜKSEK" | "ORTA" | "DÜŞÜK" | "ÇOK DÜŞÜK"
}
"""
match_info = package.get("match_info") or {}
home = match_info.get("home_team", "Ev Sahibi")
away = match_info.get("away_team", "Deplasman")
main_pick = package.get("main_pick") or {}
betting_brain = package.get("betting_brain") or {}
v27_engine = package.get("v27_engine") or {}
market_board = package.get("market_board") or {}
score_pred = package.get("score_prediction") or {}
risk = package.get("risk") or {}
data_quality = package.get("data_quality") or {}
# ── Determine action ──────────────────────────────────────────
brain_decision = str(betting_brain.get("decision") or "NO_BET").upper()
main_playable = bool(main_pick.get("playable"))
main_vetoed = bool((main_pick.get("upper_brain") or {}).get("veto"))
approved_count = int(betting_brain.get("approved_count", 0) or 0)
if main_playable and not main_vetoed and approved_count > 0:
action = "BET"
elif approved_count == 0 and brain_decision == "NO_BET":
action = "SKIP"
else:
action = "WATCH"
# ── Headline ──────────────────────────────────────────────────
headline = _build_headline(action, main_pick, home, away)
# ── Summary paragraph ─────────────────────────────────────────
summary = _build_summary(
action, main_pick, market_board, v27_engine,
score_pred, risk, data_quality, home, away,
)
# ── Quick notes ───────────────────────────────────────────────
notes = _build_notes(market_board, v27_engine, score_pred, risk, home, away)
# ── Contradiction detection ───────────────────────────────────
contradictions = _detect_contradictions(market_board, v27_engine, package)
# ── Overall confidence label ──────────────────────────────────
confidence_label = _overall_confidence_label(main_pick, data_quality)
return {
"action": action,
"headline": headline,
"summary": summary,
"notes": notes[:6],
"contradictions": contradictions[:4],
"confidence_label": confidence_label,
}
# ═══════════════════════════════════════════════════════════════════════
# Headline
# ═══════════════════════════════════════════════════════════════════════
def _build_headline(
action: str,
main_pick: Dict[str, Any],
home: str,
away: str,
) -> str:
if action == "BET":
market = main_pick.get("market", "")
pick = main_pick.get("pick", "")
odds = main_pick.get("odds", 0.0)
conf = main_pick.get("calibrated_confidence", main_pick.get("confidence", 0))
market_tr = _market_to_turkish(market, pick)
return f"🎯 {market_tr} önerisi — Oran: {odds}, Güven: %{conf:.0f}"
if action == "WATCH":
return f"👀 {home} vs {away} — İzlemeye değer sinyaller var"
return f"⚠️ {home} vs {away} — Şu an net bir fırsat görülmüyor"
# ═══════════════════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════════════════
def _build_summary(
action: str,
main_pick: Dict[str, Any],
market_board: Dict[str, Any],
v27_engine: Dict[str, Any],
score_pred: Dict[str, Any],
risk: Dict[str, Any],
data_quality: Dict[str, Any],
home: str,
away: str,
) -> str:
parts: List[str] = []
# Who is the favourite?
ms_board = market_board.get("MS") or {}
ms_pick = ms_board.get("pick", "")
ms_conf = float(ms_board.get("confidence", 50) or 50)
if ms_pick == "1" and ms_conf > 45:
parts.append(f"{home} hafif favori görünüyor")
elif ms_pick == "1" and ms_conf > 55:
parts.append(f"{home} net favori")
elif ms_pick == "2" and ms_conf > 45:
parts.append(f"{away} hafif favori görünüyor")
elif ms_pick == "2" and ms_conf > 55:
parts.append(f"{away} net favori")
else:
parts.append("İki takım da birbirine yakın güçte")
# xG expectation
xg_home = float(score_pred.get("xg_home", 0) or 0)
xg_away = float(score_pred.get("xg_away", 0) or 0)
xg_total = xg_home + xg_away
if xg_total > 3.0:
parts.append(f"Gol beklentisi yüksek (toplam xG: {xg_total:.1f})")
elif xg_total < 2.0:
parts.append(f"Düşük gol beklentisi (toplam xG: {xg_total:.1f})")
# Consensus check
consensus = str(v27_engine.get("consensus") or "").upper()
if consensus == "AGREE":
parts.append("Model motorları aynı fikirde")
elif consensus == "DISAGREE":
parts.append("Model motorları farklı sonuçlara ulaşıyor — belirsizlik var")
# Action-specific
if action == "BET":
market_tr = _market_to_turkish(
main_pick.get("market", ""), main_pick.get("pick", "")
)
edge = float(main_pick.get("ev_edge", 0) or 0)
parts.append(
f"{market_tr} yönünde değer tespit edildi (EV edge: {edge:+.1%})"
)
elif action == "SKIP":
parts.append(
"Hiçbir markette piyasanın fiyatlamadığı bir avantaj görülmüyor"
)
# Risk
risk_level = str(risk.get("level") or "MEDIUM").upper()
if risk_level == "HIGH":
parts.append("⚠️ Risk seviyesi yüksek")
elif risk_level == "EXTREME":
parts.append("🔴 Çok yüksek risk — dikkatli olun")
# Data quality
quality_label = str(data_quality.get("label") or "MEDIUM").upper()
if quality_label == "LOW":
parts.append("Veri kalitesi düşük — tahminler daha az güvenilir")
return ". ".join(parts) + "."
# ═══════════════════════════════════════════════════════════════════════
# Quick Notes
# ═══════════════════════════════════════════════════════════════════════
def _build_notes(
market_board: Dict[str, Any],
v27_engine: Dict[str, Any],
score_pred: Dict[str, Any],
risk: Dict[str, Any],
home: str,
away: str,
) -> List[str]:
notes: List[str] = []
triple_value = v27_engine.get("triple_value") or {}
odds_band = v27_engine.get("odds_band") or {}
# MS note
ms = market_board.get("MS") or {}
ms_conf = float(ms.get("confidence", 0) or 0)
if ms_conf < 45:
notes.append("Maç sonucu belirsiz, net favori yok")
elif ms.get("pick") == "1":
notes.append(f"{home} favori ama oran değerli mi kontrol et")
elif ms.get("pick") == "2":
notes.append(f"{away} favori ama oran değerli mi kontrol et")
# OU25 note
ou25 = market_board.get("OU25") or {}
ou25_probs = ou25.get("probs") or {}
over_prob = float(ou25_probs.get("over", 0.5) or 0.5)
if over_prob > 0.58:
notes.append("2.5 Üst yönünde eğilim var")
elif over_prob < 0.42:
notes.append("2.5 Alt yönünde eğilim var")
else:
notes.append("2.5 Üst/Alt dengeli — kesin sinyal yok")
# BTTS note
btts = market_board.get("BTTS") or {}
btts_probs = btts.get("probs") or {}
btts_yes = float(btts_probs.get("yes", 0.5) or 0.5)
if btts_yes > 0.58:
notes.append("Her iki takımın da gol atması bekleniyor")
elif btts_yes < 0.42:
notes.append("KG olasılığı düşük")
# HT note
ht = market_board.get("HT") or {}
ht_pick = ht.get("pick", "")
ht_conf = float(ht.get("confidence", 0) or 0)
if ht_conf > 40 and ht_pick:
ht_label = {"1": f"İY {home}", "2": f"İY {away}", "X": "İY beraberlik"}.get(
ht_pick, f"İY {ht_pick}"
)
notes.append(f"{ht_label} yönünde hafif sinyal (%{ht_conf:.0f})")
# Risk warnings
warnings = risk.get("warnings") or []
for w in warnings[:2]:
notes.append(f"⚠️ {w}")
return notes
# ═══════════════════════════════════════════════════════════════════════
# Contradiction Detection
# ═══════════════════════════════════════════════════════════════════════
def _detect_contradictions(
market_board: Dict[str, Any],
v27_engine: Dict[str, Any],
package: Dict[str, Any],
) -> List[str]:
"""
Detect cases where model prediction and odds band/triple value
point in opposite directions — the user's main complaint.
"""
contradictions: List[str] = []
triple_value = v27_engine.get("triple_value") or {}
predictions = v27_engine.get("predictions") or {}
# MS contradiction: model says home but triple_value says away has value
ms_preds = predictions.get("ms") or {}
ms_home = float(ms_preds.get("home", 0) or 0)
ms_away = float(ms_preds.get("away", 0) or 0)
home_triple = triple_value.get("home") or {}
away_triple = triple_value.get("away") or {}
model_favours_home = ms_home > ms_away
away_is_value = bool(away_triple.get("is_value"))
home_is_value = bool(home_triple.get("is_value"))
if model_favours_home and away_is_value:
contradictions.append(
"Model ev sahibini favori görüyor ama oran bandı deplasmanda değer buluyor — "
"bu çelişki nedeniyle MS tahminine dikkatli yaklaş"
)
elif not model_favours_home and home_is_value:
contradictions.append(
"Model deplasmanı favori görüyor ama oran bandı ev sahibinde değer buluyor — "
"bu çelişki nedeniyle MS tahminine dikkatli yaklaş"
)
# HT contradiction
ht_board = market_board.get("HT") or {}
ht_pick = ht_board.get("pick", "")
ht_home_triple = triple_value.get("ht_home") or {}
ht_away_triple = triple_value.get("ht_away") or {}
if ht_pick == "1" and bool(ht_away_triple.get("is_value")):
contradictions.append(
"Model İY ev sahibi diyor ama oran bandı İY deplasmanda değer buluyor — "
"İY tahmini güvenilir değil"
)
elif ht_pick == "2" and bool(ht_home_triple.get("is_value")):
contradictions.append(
"Model İY deplasman diyor ama oran bandı İY ev sahibinde değer buluyor — "
"İY tahmini güvenilir değil"
)
# OU25 contradiction
ou25_board = market_board.get("OU25") or {}
ou25_pick = ou25_board.get("pick", "")
ou25_over_triple = triple_value.get("ou25_over") or {}
ou25_under_triple = triple_value.get("ou25_under") or {}
if ou25_pick == "Üst" and bool(ou25_under_triple.get("is_value")):
contradictions.append(
"Model 2.5 Üst diyor ama oran bandı 2.5 Alt'ta değer buluyor — çelişki var"
)
elif ou25_pick == "Alt" and bool(ou25_over_triple.get("is_value")):
contradictions.append(
"Model 2.5 Alt diyor ama oran bandı 2.5 Üst'te değer buluyor — çelişki var"
)
return contradictions
# ═══════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════
def _overall_confidence_label(
main_pick: Dict[str, Any],
data_quality: Dict[str, Any],
) -> str:
"""Overall confidence label for the entire analysis."""
quality_score = float(data_quality.get("score", 0.5) or 0.5)
main_conf = float(
main_pick.get("calibrated_confidence", main_pick.get("confidence", 0)) or 0
)
main_playable = bool(main_pick.get("playable"))
if main_playable and main_conf >= 60 and quality_score >= 0.8:
return "YÜKSEK"
if main_playable and main_conf >= 45:
return "ORTA"
if main_conf >= 30:
return "DÜŞÜK"
return "ÇOK DÜŞÜK"
_MARKET_TR_MAP = {
"MS": {"1": "Maç Sonucu Ev Sahibi", "2": "Maç Sonucu Deplasman", "X": "Beraberlik"},
"DC": {"1X": "Çifte Şans 1X", "X2": "Çifte Şans X2", "12": "Çifte Şans 12"},
"OU25": {"Üst": "2.5 Üst", "Alt": "2.5 Alt", "Over": "2.5 Üst", "Under": "2.5 Alt"},
"OU15": {"Üst": "1.5 Üst", "Alt": "1.5 Alt", "Over": "1.5 Üst", "Under": "1.5 Alt"},
"OU35": {"Üst": "3.5 Üst", "Alt": "3.5 Alt", "Over": "3.5 Üst", "Under": "3.5 Alt"},
"BTTS": {"KG Var": "Karşılıklı Gol Var", "KG Yok": "Karşılıklı Gol Yok",
"Yes": "Karşılıklı Gol Var", "No": "Karşılıklı Gol Yok"},
"HT": {"1": "İlk Yarı Ev Sahibi", "2": "İlk Yarı Deplasman", "X": "İlk Yarı Beraberlik"},
"HT_OU05": {"Üst": "İY 0.5 Üst", "Alt": "İY 0.5 Alt"},
"HT_OU15": {"Üst": "İY 1.5 Üst", "Alt": "İY 1.5 Alt"},
"OE": {"Tek": "Tek", "Çift": "Çift", "Odd": "Tek", "Even": "Çift"},
"CARDS": {"Üst": "Kart Üst", "Alt": "Kart Alt"},
}
def _market_to_turkish(market: str, pick: str) -> str:
market_map = _MARKET_TR_MAP.get(market, {})
result = market_map.get(pick)
if result:
return result
return f"{market} {pick}"
+437 -222
View File
@@ -51,8 +51,10 @@ from core.engines.player_predictor import PlayerPrediction, get_player_predictor
from services.feature_enrichment import FeatureEnrichmentService from services.feature_enrichment import FeatureEnrichmentService
from services.betting_brain import BettingBrain from services.betting_brain import BettingBrain
from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine
from services.match_commentary import generate_match_commentary
from utils.top_leagues import load_top_league_ids from utils.top_leagues import load_top_league_ids
from utils.league_reliability import load_league_reliability from utils.league_reliability import load_league_reliability
from config.config_loader import build_threshold_dict, get_threshold_default
@dataclass @dataclass
@@ -84,6 +86,7 @@ class MatchData:
current_score_home: Optional[int] = None current_score_home: Optional[int] = None
current_score_away: Optional[int] = None current_score_away: Optional[int] = None
lineup_confidence: float = 0.0 lineup_confidence: float = 0.0
source_table: str = "matches"
class SingleMatchOrchestrator: class SingleMatchOrchestrator:
@@ -164,77 +167,15 @@ class SingleMatchOrchestrator:
self.league_reliability = load_league_reliability() self.league_reliability = load_league_reliability()
self.enrichment = FeatureEnrichmentService() self.enrichment = FeatureEnrichmentService()
self.odds_band_analyzer = OddsBandAnalyzer() self.odds_band_analyzer = OddsBandAnalyzer()
# ── V32 Calibration Rebalance ────────────────────────────────── # ── Market Thresholds (loaded from config/market_thresholds.json) ──
# RULE: max_reachable = 100 × calibration MUST be > min_conf + 8 # All values are centralized in a single JSON file for easy tuning
# Previous values had 5 markets where this was IMPOSSIBLE: # without code changes. See config/market_thresholds.json for details.
# HT(0.42×100=42 < 45), HCAP(0.40×100=40 < 46), HTFT(0.28×100=28 < 32) self.market_calibration: Dict[str, float] = build_threshold_dict("calibration")
# HT_OU15(0.46×100=46 < 48), CARDS(0.45×100=45 < 48) self.market_min_conf: Dict[str, float] = build_threshold_dict("min_conf")
# These markets could NEVER become playable → all predictions were PASS. self.market_min_play_score: Dict[str, float] = build_threshold_dict("min_play_score")
# self.market_min_edge: Dict[str, float] = build_threshold_dict("min_edge")
# New calibration: conservative but mathematically achievable. self.odds_band_min_sample: Dict[str, float] = build_threshold_dict("odds_band_min_sample")
# Each market's calibration ensures high-confidence model outputs CAN pass. self.odds_band_min_edge: Dict[str, float] = build_threshold_dict("odds_band_min_edge")
self.market_calibration: Dict[str, float] = {
"MS": 0.62, # max=62 vs min=42 ✓ (was 0.48→max=48 vs 44 ⚠️)
"DC": 0.82, # max=82 vs min=52 ✓ (unchanged, already good)
"OU15": 0.84, # max=84 vs min=55 ✓ (unchanged, already good)
"OU25": 0.68, # max=68 vs min=48 ✓ (was 0.54→max=54 vs 52 ⚠️)
"OU35": 0.60, # max=60 vs min=48 ✓ (was 0.44→max=44 vs 54 ❌)
"BTTS": 0.65, # max=65 vs min=46 ✓ (was 0.50→max=50 vs 50 ⚠️)
"HT": 0.58, # max=58 vs min=40 ✓ (was 0.42→max=42 vs 45 ❌)
"HT_OU05": 0.68, # max=68 vs min=50 ✓ (unchanged)
"HT_OU15": 0.60, # max=60 vs min=42 ✓ (was 0.46→max=46 vs 48 ❌)
"OE": 0.62, # max=62 vs min=46 ✓ (was 0.58→max=58 vs 50 ok)
"CARDS": 0.58, # max=58 vs min=42 ✓ (was 0.45→max=45 vs 48 ❌)
"HCAP": 0.56, # max=56 vs min=40 ✓ (was 0.40→max=40 vs 46 ❌)
"HTFT": 0.45, # max=45 vs min=28 ✓ (was 0.28→max=28 vs 32 ❌)
}
# Min confidence: lowered to be achievable (max_reachable - 16 to -20)
self.market_min_conf: Dict[str, float] = {
"MS": 42.0, # was 44 — 3-way market, hard to get high conf
"DC": 52.0, # was 55 — double chance is easier
"OU15": 55.0, # was 58 — binary + usually high conf
"OU25": 48.0, # was 52 — core market, allow more through
"OU35": 48.0, # was 54 — lowered to let signals pass
"BTTS": 46.0, # was 50 — binary market
"HT": 40.0, # was 45 — was ❌ impossible, now achievable
"HT_OU05": 50.0, # was 54 — binary HT market
"HT_OU15": 42.0, # was 48 — was ❌ impossible, now achievable
"OE": 46.0, # was 50 — coin-flip market, lower bar
"CARDS": 42.0, # was 48 — was ❌ impossible, now achievable
"HCAP": 40.0, # was 46 — was ❌ impossible, now achievable
"HTFT": 28.0, # was 32 — was ❌ impossible, 9-way market
}
# Min play score: moderate reduction to allow more C-grade bets
self.market_min_play_score: Dict[str, float] = {
"MS": 65.0, # was 72 — let more MS through for tracking
"DC": 58.0, # was 62 — DC is high accuracy
"OU15": 60.0, # was 64 — strong market per backtest
"OU25": 64.0, # was 70 — core market
"OU35": 68.0, # was 76 — riskier market
"BTTS": 64.0, # was 70 — allow more signals
"HT": 66.0, # was 74 — was never reachable anyway
"HT_OU05": 60.0, # was 64 — strong backtest market
"HT_OU15": 64.0, # was 72 — moderate
"OE": 60.0, # was 66 — low priority market
"CARDS": 66.0, # was 74 — niche market
"HCAP": 68.0, # was 76 — risky
"HTFT": 72.0, # was 82 — 9-way, very risky
}
self.market_min_edge: Dict[str, float] = {
"MS": 0.02, # was 0.03 — slight relaxation
"DC": 0.01, # unchanged
"OU15": 0.01, # unchanged
"OU25": 0.02, # unchanged
"OU35": 0.03, # was 0.04
"BTTS": 0.02, # was 0.03
"HT": 0.03, # was 0.04
"HT_OU05": 0.01, # unchanged
"HT_OU15": 0.02, # was 0.03
"OE": 0.02, # unchanged
"CARDS": 0.02, # was 0.03
"HCAP": 0.03, # was 0.04
"HTFT": 0.05, # was 0.06
}
def _get_v25_predictor(self) -> V25Predictor: def _get_v25_predictor(self) -> V25Predictor:
if self.v25_predictor is None: if self.v25_predictor is None:
@@ -362,6 +303,32 @@ class SingleMatchOrchestrator:
away_venue_elo = float(elo_row.get('away_away_elo') or away_elo) away_venue_elo = float(elo_row.get('away_away_elo') or away_elo)
home_form_elo_val = float(elo_row.get('home_form_elo') or home_elo) home_form_elo_val = float(elo_row.get('home_form_elo') or home_elo)
away_form_elo_val = float(elo_row.get('away_form_elo') or away_elo) away_form_elo_val = float(elo_row.get('away_form_elo') or away_elo)
else:
cur.execute(
"""
SELECT
team_id,
overall_elo,
home_elo,
away_elo,
form_elo
FROM team_elo_ratings
WHERE team_id IN (%s, %s)
""",
(data.home_team_id, data.away_team_id),
)
elo_rows = cur.fetchall()
by_team = {str(r.get("team_id")): r for r in elo_rows}
home_row = by_team.get(str(data.home_team_id))
away_row = by_team.get(str(data.away_team_id))
if home_row:
home_elo = float(home_row.get("overall_elo") or 1500.0)
home_venue_elo = float(home_row.get("home_elo") or home_elo)
home_form_elo_val = float(home_row.get("form_elo") or home_elo)
if away_row:
away_elo = float(away_row.get("overall_elo") or 1500.0)
away_venue_elo = float(away_row.get("away_elo") or away_elo)
away_form_elo_val = float(away_row.get("form_elo") or away_elo)
# Enrichment queries # Enrichment queries
home_stats = enr.compute_team_stats(cur, data.home_team_id, data.match_date_ms) home_stats = enr.compute_team_stats(cur, data.home_team_id, data.match_date_ms)
@@ -390,6 +357,8 @@ class SingleMatchOrchestrator:
before_ts=data.match_date_ms, before_ts=data.match_date_ms,
referee_name=data.referee_name, referee_name=data.referee_name,
) )
setattr(data, "odds_band_features", odds_band_features)
setattr(data, "feature_source", "football_ai_features" if elo_row else "live_prematch_enrichment")
except Exception: except Exception:
# Full fallback — use all defaults # Full fallback — use all defaults
home_stats = dict(enr._DEFAULT_TEAM_STATS) home_stats = dict(enr._DEFAULT_TEAM_STATS)
@@ -409,6 +378,8 @@ class SingleMatchOrchestrator:
home_rest = 7.0 home_rest = 7.0
away_rest = 7.0 away_rest = 7.0
odds_band_features = {} # V28 fallback odds_band_features = {} # V28 fallback
setattr(data, "odds_band_features", odds_band_features)
setattr(data, "feature_source", "fallback_defaults")
odds_presence = { odds_presence = {
'odds_ms_h_present': 1.0 if ms_h > 1.01 else 0.0, 'odds_ms_h_present': 1.0 if ms_h > 1.01 else 0.0,
@@ -667,7 +638,7 @@ class SingleMatchOrchestrator:
signal: Dict[str, Any] = {} signal: Dict[str, Any] = {}
def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 2.5) -> Dict[str, float]: def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 1.5) -> Dict[str, float]:
""" """
Apply temperature scaling to soften overconfident model outputs. Apply temperature scaling to soften overconfident model outputs.
@@ -676,19 +647,22 @@ class SingleMatchOrchestrator:
T=1.0 → no change, T>1 → softer probabilities. T=1.0 → no change, T>1 → softer probabilities.
Standard approach for post-hoc model calibration (Guo et al., 2017). Standard approach for post-hoc model calibration (Guo et al., 2017).
V34: Reduced from 2.5 to 1.5 — V25 model is already calibrated via
odds-aware training. Excessive flattening was destroying signal.
""" """
import math import math
eps = 1e-7 # numerical stability eps = 1e-7 # numerical stability
n = len(probs_dict) n = len(probs_dict)
# Determine appropriate temperature based on market type # V34: Reduced temperature — odds-aware model is already calibrated
# Binary markets (2-class) tend to be more overconfident in LGB # Binary markets (2-class) tend to be more overconfident in LGB
if n <= 2: if n <= 2:
T = max(temperature, 2.0) T = max(temperature, 1.5) # was 2.0
elif n == 3: elif n == 3:
T = max(temperature * 0.8, 1.5) # 3-way slightly less aggressive T = max(temperature * 0.8, 1.2) # was 1.5 — 3-way slightly less aggressive
else: else:
T = max(temperature * 0.6, 1.3) # 9-way (HTFT) already spread T = max(temperature * 0.6, 1.0) # was 1.3 — 9-way (HTFT) already spread
# Convert to log-odds and apply temperature # Convert to log-odds and apply temperature
labels = list(probs_dict.keys()) labels = list(probs_dict.keys())
@@ -714,8 +688,8 @@ class SingleMatchOrchestrator:
Applies temperature scaling to convert overconfident LightGBM outputs Applies temperature scaling to convert overconfident LightGBM outputs
into realistic, calibrated probabilities. into realistic, calibrated probabilities.
""" """
# Apply temperature scaling to soften extreme probabilities # V34: Apply temperature scaling — reduced from 2.5 to 1.5
scaled_probs = _temperature_scale(probs_dict, temperature=2.5) scaled_probs = _temperature_scale(probs_dict, temperature=1.5)
best_label = max(scaled_probs, key=scaled_probs.get) best_label = max(scaled_probs, key=scaled_probs.get)
best_prob = float(scaled_probs[best_label]) best_prob = float(scaled_probs[best_label])
@@ -1290,25 +1264,72 @@ class SingleMatchOrchestrator:
), ),
} }
# BTTS triple value # BTTS triple value — now with V27 BTTS model
btts_yes_odds = float((data.odds_data or {}).get("btts_y", 0)) btts_yes_odds = float((data.odds_data or {}).get('btts_y', 0))
btts_implied = (1.0 / btts_yes_odds) if btts_yes_odds > 1.0 else 0.50 btts_implied = (1.0 / btts_yes_odds) if btts_yes_odds > 1.0 else 0.50
btts_band_rate = odds_band_btts["yes_rate"] btts_band_rate = odds_band_btts['yes_rate']
btts_combined = btts_band_rate
# V27 BTTS model prediction (if available)
v27_btts = v27_preds.get('btts')
v27_btts_yes = (v27_btts or {}).get('yes', 0) if v27_btts else 0
if v27_btts_yes > 0:
btts_combined = (v27_btts_yes + btts_band_rate) / 2.0
else:
btts_combined = btts_band_rate
btts_edge = btts_combined - btts_implied btts_edge = btts_combined - btts_implied
btts_band_confirms = btts_band_rate > btts_implied btts_band_confirms = btts_band_rate > btts_implied
btts_v27_confirms = v27_btts_yes > btts_implied if v27_btts_yes > 0 else False
btts_conf_count = sum([btts_v27_confirms, btts_band_confirms])
triple_value["btts_yes"] = { # BTTS divergence (V25 vs V27)
"band_rate": round(btts_band_rate, 4), v25_btts_probs = {
"implied_prob": round(btts_implied, 4), 'no': 1.0 - prediction.btts_yes_prob,
"combined_prob": round(btts_combined, 4), 'yes': prediction.btts_yes_prob,
"edge": round(btts_edge, 4), }
"band_sample": odds_band_btts["sample"], btts_divergence = compute_divergence(v25_btts_probs, v27_btts) if v27_btts else {}
"confirmations": 1 if btts_band_confirms else 0, btts_odds = {
"is_value": ( 'yes': float((data.odds_data or {}).get('btts_y', 0)),
'no': float((data.odds_data or {}).get('btts_n', 0)),
}
btts_value_edge = compute_value_edge(
v25_btts_probs, v27_btts, btts_odds,
) if v27_btts else {}
# DC divergence (derived from V27 MS probs)
v27_dc = v27_preds.get('dc')
dc_divergence = {}
dc_value_edge = {}
if v27_dc:
v25_dc_probs = {
'1x': prediction.ms_home_prob + prediction.ms_draw_prob,
'x2': prediction.ms_draw_prob + prediction.ms_away_prob,
'12': prediction.ms_home_prob + prediction.ms_away_prob,
}
dc_divergence = compute_divergence(v25_dc_probs, v27_dc)
dc_odds = {
'1x': float((data.odds_data or {}).get('dc_1x', 0)),
'x2': float((data.odds_data or {}).get('dc_x2', 0)),
'12': float((data.odds_data or {}).get('dc_12', 0)),
}
dc_value_edge = compute_value_edge(v25_dc_probs, v27_dc, dc_odds)
triple_value['btts_yes'] = {
'v27_prob': round(v27_btts_yes, 4),
'band_rate': round(btts_band_rate, 4),
'implied_prob': round(btts_implied, 4),
'combined_prob': round(btts_combined, 4),
'edge': round(btts_edge, 4),
'band_sample': odds_band_btts['sample'],
'confirmations': btts_conf_count,
'is_value': (
btts_conf_count >= 2
and btts_edge > 0.05
and odds_band_btts['sample'] >= 8
) if v27_btts_yes > 0 else (
btts_band_confirms btts_band_confirms
and btts_edge > 0.05 and btts_edge > 0.05
and odds_band_btts["sample"] >= 8 and odds_band_btts['sample'] >= 8
), ),
} }
@@ -1366,14 +1387,20 @@ class SingleMatchOrchestrator:
"predictions": { "predictions": {
"ms": v27_ms or {}, "ms": v27_ms or {},
"ou25": v27_ou25 or {}, "ou25": v27_ou25 or {},
"btts": v27_btts or {},
"dc": v27_dc or {},
}, },
"divergence": { "divergence": {
"ms": ms_divergence, "ms": ms_divergence,
"ou25": ou25_divergence, "ou25": ou25_divergence,
"btts": btts_divergence,
"dc": dc_divergence,
}, },
"value_edge": { "value_edge": {
"ms": ms_value, "ms": ms_value,
"ou25": ou25_value, "ou25": ou25_value,
"btts": btts_value_edge,
"dc": dc_value_edge,
}, },
"odds_band": { "odds_band": {
"ms_home": odds_band_ms_home, "ms_home": odds_band_ms_home,
@@ -1426,6 +1453,13 @@ class SingleMatchOrchestrator:
base_package = self._apply_upper_brain_guards(base_package) base_package = self._apply_upper_brain_guards(base_package)
# ── Match Commentary: human-readable summary ──────────────
try:
base_package["match_commentary"] = generate_match_commentary(base_package)
except Exception as e:
print(f"[Commentary] ⚠ Generation failed (non-fatal): {e}")
base_package["match_commentary"] = None
mode = str(getattr(self, "engine_mode", "v28-pro-max") or "v28-pro-max").lower() mode = str(getattr(self, "engine_mode", "v28-pro-max") or "v28-pro-max").lower()
if mode not in {"v25", "v26", "dual", "v28", "v28-pro-max"}: if mode not in {"v25", "v26", "dual", "v28", "v28-pro-max"}:
mode = "v25" mode = "v25"
@@ -1439,6 +1473,7 @@ class SingleMatchOrchestrator:
) )
if mode == "v26": if mode == "v26":
shadow_package["match_commentary"] = base_package.get("match_commentary")
return shadow_package return shadow_package
if mode == "dual": if mode == "dual":
merged = dict(base_package) merged = dict(base_package)
@@ -2670,6 +2705,13 @@ class SingleMatchOrchestrator:
# Hard gate: predictions with unknown teams are noisy and misleading. # Hard gate: predictions with unknown teams are noisy and misleading.
return None return None
status, state, substate = self._normalize_match_status(
row.get("status"),
row.get("state"),
row.get("substate"),
row.get("score_home"),
row.get("score_away"),
)
odds_data = self._extract_odds(cur, row) odds_data = self._extract_odds(cur, row)
home_lineup, away_lineup, lineup_source, lineup_confidence = self._extract_lineups(cur, row) home_lineup, away_lineup, lineup_source, lineup_confidence = self._extract_lineups(cur, row)
sidelined = self._parse_json_dict(row.get("sidelined")) sidelined = self._parse_json_dict(row.get("sidelined"))
@@ -2723,10 +2765,11 @@ class SingleMatchOrchestrator:
home_position=home_position, home_position=home_position,
away_position=away_position, away_position=away_position,
lineup_source=lineup_source, lineup_source=lineup_source,
status=str(row.get("status") or ""), status=status,
state=row.get("state"), state=state,
substate=row.get("substate"), substate=substate,
lineup_confidence=lineup_confidence, lineup_confidence=lineup_confidence,
source_table=str(row.get("source_table") or "matches"),
current_score_home=( current_score_home=(
int(row.get("score_home")) int(row.get("score_home"))
if row.get("score_home") is not None if row.get("score_home") is not None
@@ -2760,7 +2803,8 @@ class SingleMatchOrchestrator:
lm.referee_name, lm.referee_name,
ht.name as home_team_name, ht.name as home_team_name,
at.name as away_team_name, at.name as away_team_name,
l.name as league_name l.name as league_name,
'live_matches'::text as source_table
FROM live_matches lm FROM live_matches lm
LEFT JOIN teams ht ON ht.id = lm.home_team_id LEFT JOIN teams ht ON ht.id = lm.home_team_id
LEFT JOIN teams at ON at.id = lm.away_team_id LEFT JOIN teams at ON at.id = lm.away_team_id
@@ -2772,6 +2816,37 @@ class SingleMatchOrchestrator:
) )
return cur.fetchone() return cur.fetchone()
@staticmethod
def _normalize_match_status(
status: Any,
state: Any,
substate: Any,
score_home: Any,
score_away: Any,
) -> Tuple[str, Optional[str], Optional[str]]:
state_text = str(state or "").strip()
status_text = str(status or "").strip()
substate_text = str(substate or "").strip()
state_key = state_text.lower().replace("_", "").replace(" ", "")
status_key = status_text.lower().replace("_", "").replace(" ", "")
substate_key = substate_text.lower().replace("_", "").replace(" ", "")
live_tokens = {"live", "livegame", "firsthalf", "secondhalf", "halftime", "1h", "2h", "ht", "1q", "2q", "3q", "4q"}
finished_tokens = {"post", "postgame", "finished", "played", "ft", "ended", "aet", "pen", "penalties", "afterpenalties"}
pre_tokens = {"pre", "pregame", "scheduled", "ns", "notstarted", "timestamp"}
if state_key in live_tokens or status_key in live_tokens or substate_key in live_tokens:
return "LIVE", state_text or "live", substate_text or None
if state_key in finished_tokens or status_key in finished_tokens or substate_key in finished_tokens:
return "FT", state_text or "post", substate_text or None
if score_home is not None and score_away is not None and status_key not in pre_tokens:
return "FT", state_text or "post", substate_text or None
if state_key in pre_tokens or status_key in pre_tokens or substate_key in pre_tokens:
return "NS", state_text or "pre", substate_text or None
return status_text or "NS", state_text or None, substate_text or None
def _fetch_hist_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]: def _fetch_hist_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]:
cur.execute( cur.execute(
""" """
@@ -2793,7 +2868,8 @@ class SingleMatchOrchestrator:
ref.name as referee_name, ref.name as referee_name,
ht.name as home_team_name, ht.name as home_team_name,
at.name as away_team_name, at.name as away_team_name,
l.name as league_name l.name as league_name,
'matches'::text as source_table
FROM matches m FROM matches m
LEFT JOIN teams ht ON ht.id = m.home_team_id LEFT JOIN teams ht ON ht.id = m.home_team_id
LEFT JOIN teams at ON at.id = m.away_team_id LEFT JOIN teams at ON at.id = m.away_team_id
@@ -3668,66 +3744,33 @@ class SingleMatchOrchestrator:
playable_rows = [row for row in market_rows if row.get("playable")] playable_rows = [row for row in market_rows if row.get("playable")]
# GUARANTEED PICK LOGIC (V32 - Calibration-aware):
# Runtime replay insights:
# - Trust only markets that remain robust after pre-match replay.
# - Current strongest football markets: DC, OU15, HT_OU05.
#
# Priority 1: High-accuracy market (DC/OU15/HT_OU05/OU25) + Odds >= 1.30 + Conf >= 44%
# Priority 2: Any playable + Odds >= 1.30 + Conf >= 44%
# Priority 3: Playable + Odds >= 1.30
# Priority 4: Best non-playable (fallback)
MIN_ODDS = 1.30 MIN_ODDS = 1.30
MIN_CONFIDENCE = 44.0 # V32: lowered from 52 to match new calibration playable_with_odds = [
# High-accuracy markets from backtest (prioritize these)
HIGH_ACCURACY_MARKETS = {"DC", "OU15", "HT_OU05"}
# Priority 1: High-accuracy markets with good odds and confidence
high_accuracy_picks = [
row for row in playable_rows row for row in playable_rows
if row.get("market") in HIGH_ACCURACY_MARKETS if float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
] ]
if high_accuracy_picks: if playable_with_odds:
# Sort by play_score, pick the best playable_with_odds.sort(
high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) key=lambda r: (
main_pick = high_accuracy_picks[0] float(r.get("ev_edge", 0.0)),
main_pick["is_guaranteed"] = True float(r.get("play_score", 0.0)),
main_pick["pick_reason"] = "high_accuracy_market" ),
reverse=True,
)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "positive_ev_after_odds_band_gate"
else: else:
# Priority 2: Any playable with odds >= 1.30 and confidence >= 40% fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
guaranteed_picks = [ fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
row for row in playable_rows main_pick = fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)
if float(row.get("odds", 0.0)) >= MIN_ODDS if main_pick:
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE main_pick["is_guaranteed"] = False
] main_pick["playable"] = False
main_pick["stake_units"] = 0.0
if guaranteed_picks: main_pick["bet_grade"] = "PASS"
guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) main_pick["pick_reason"] = "no_playable_value_after_odds_band_gate"
main_pick = guaranteed_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "confidence_threshold_met"
else:
# Priority 3: Fallback - playable with odds >= 1.30
playable_with_odds = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
]
if playable_with_odds:
playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "odds_only_fallback"
else:
# Priority 4: Last resort - any playable or first market WITH ODDS > 0
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None))
if main_pick:
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "last_resort"
aggressive_pick = None aggressive_pick = None
htft_probs = prediction.ht_ft_probs or {} htft_probs = prediction.ht_ft_probs or {}
@@ -3756,11 +3799,13 @@ class SingleMatchOrchestrator:
value_candidates = [ value_candidates = [
row for row in playable_rows row for row in playable_rows
if float(row.get("odds", 0.0)) >= 1.60 if float(row.get("odds", 0.0)) >= 1.60
and float(row.get("calibrated_confidence", 0.0)) >= 40.0 # V34: Lowered min calibrated_confidence for value candidates from 40.0 to 25.0
# to allow high-odds value bets (which naturally have lower probabilities).
and float(row.get("calibrated_confidence", 0.0)) >= 25.0
] ]
if value_candidates: if value_candidates:
# Score them by (play_score * odds) to reward higher odds # Score them by (ev_edge) to reward actual mathematical value
value_candidates.sort(key=lambda r: float(r.get("play_score", 0.0)) * float(r.get("odds", 1.0)), reverse=True) value_candidates.sort(key=lambda r: float(r.get("ev_edge", 0.0)), reverse=True)
for v_cand in value_candidates: for v_cand in value_candidates:
if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]): if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]):
value_pick = v_cand value_pick = v_cand
@@ -3982,51 +4027,33 @@ class SingleMatchOrchestrator:
playable_rows = [row for row in market_rows if row.get("playable")] playable_rows = [row for row in market_rows if row.get("playable")]
# GUARANTEED PICK LOGIC (Optimized - same as football)
MIN_ODDS = 1.30 MIN_ODDS = 1.30
MIN_CONFIDENCE = 40.0 playable_with_odds = [
HIGH_ACCURACY_MARKETS = {"ML", "TOT", "SPREAD"}
high_accuracy_picks = [
row for row in playable_rows row for row in playable_rows
if row.get("market_type") in HIGH_ACCURACY_MARKETS if float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
] ]
if high_accuracy_picks: if playable_with_odds:
high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) playable_with_odds.sort(
main_pick = high_accuracy_picks[0] key=lambda r: (
main_pick["is_guaranteed"] = True float(r.get("ev_edge", 0.0)),
main_pick["pick_reason"] = "high_accuracy_market" float(r.get("play_score", 0.0)),
),
reverse=True,
)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "positive_ev_pick"
else: else:
guaranteed_picks = [ fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
row for row in playable_rows fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
if float(row.get("odds", 0.0)) >= MIN_ODDS main_pick = fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE if main_pick:
] main_pick["is_guaranteed"] = False
main_pick["playable"] = False
if guaranteed_picks: main_pick["stake_units"] = 0.0
guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True) main_pick["bet_grade"] = "PASS"
main_pick = guaranteed_picks[0] main_pick["pick_reason"] = "no_playable_value_found"
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "confidence_threshold_met"
else:
playable_with_odds = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
]
if playable_with_odds:
playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "odds_only_fallback"
else:
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None))
if main_pick:
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "last_resort"
supporting: List[Dict[str, Any]] = [] supporting: List[Dict[str, Any]] = []
for row in market_rows: for row in market_rows:
@@ -4518,6 +4545,121 @@ class SingleMatchOrchestrator:
return True return True
return self._v25_market_odds(odds, market, pick) > 1.01 return self._v25_market_odds(odds, market, pick) > 1.01
def _odds_band_verdict(
self,
data: MatchData,
market: str,
pick: str,
implied_prob: float,
) -> Dict[str, Any]:
features = getattr(data, "odds_band_features", {}) or {}
market_key = str(market or "").upper()
if not isinstance(features, dict) or implied_prob <= 0.0:
return {
"required": market_key in self.odds_band_min_sample,
"available": False,
"band_prob": 0.0,
"band_sample": 0.0,
"band_edge": 0.0,
"aligned": False,
"reason": "odds_band_unavailable",
}
pick_key = self._normalize_pick_token(pick)
band_prob = 0.0
sample = 0.0
if market_key == "MS":
if pick_key == "1":
band_prob = float(features.get("home_band_ms_win_rate", 0.0) or 0.0)
sample = float(features.get("home_band_ms_sample", 0.0) or 0.0)
elif pick_key == "2":
band_prob = float(features.get("away_band_ms_win_rate", 0.0) or 0.0)
sample = float(features.get("away_band_ms_sample", 0.0) or 0.0)
elif pick_key in {"X", "0"}:
home_draw = float(features.get("home_band_ms_draw_rate", 0.0) or 0.0)
away_draw = float(features.get("away_band_ms_draw_rate", 0.0) or 0.0)
band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
sample = max(
float(features.get("home_band_ms_sample", 0.0) or 0.0),
float(features.get("away_band_ms_sample", 0.0) or 0.0),
)
elif market_key == "DC":
dc_key = pick_key.replace("-", "").lower()
band_prob = float(features.get(f"band_dc_{dc_key}_rate", 0.0) or 0.0)
sample = float(features.get(f"band_dc_{dc_key}_sample", 0.0) or 0.0)
elif market_key in {"OU15", "OU25", "OU35"}:
suffix = {"OU15": "ou15", "OU25": "ou25", "OU35": "ou35"}[market_key]
rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)
elif market_key == "BTTS":
is_yes = "VAR" in pick_key or "YES" in pick_key or pick_key == "Y"
band_prob = float(features.get(f"band_btts_{'yes' if is_yes else 'no'}_rate", 0.0) or 0.0)
sample = float(features.get("band_btts_sample", 0.0) or 0.0)
elif market_key == "HT":
if pick_key == "1":
band_prob = float(features.get("home_band_ht_win_rate", 0.0) or 0.0)
sample = float(features.get("home_band_ht_sample", 0.0) or 0.0)
elif pick_key == "2":
band_prob = float(features.get("away_band_ht_win_rate", 0.0) or 0.0)
sample = float(features.get("away_band_ht_sample", 0.0) or 0.0)
elif pick_key in {"X", "0"}:
home_draw = float(features.get("home_band_ht_draw_rate", 0.0) or 0.0)
away_draw = float(features.get("away_band_ht_draw_rate", 0.0) or 0.0)
band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
sample = max(
float(features.get("home_band_ht_sample", 0.0) or 0.0),
float(features.get("away_band_ht_sample", 0.0) or 0.0),
)
elif market_key in {"HT_OU05", "HT_OU15"}:
suffix = "ht_ou05" if market_key == "HT_OU05" else "ht_ou15"
rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)
band_edge = band_prob - implied_prob if band_prob > 0.0 else 0.0
required_sample = float(self.odds_band_min_sample.get(market_key, 0.0))
required_edge = float(self.odds_band_min_edge.get(market_key, 0.0))
available = band_prob > 0.0 and sample >= required_sample
aligned = available and band_edge >= required_edge
reason = "odds_band_confirms_value"
if required_sample > 0.0 and sample < required_sample:
reason = "odds_band_sample_too_low"
elif band_prob <= 0.0:
reason = "odds_band_missing_probability"
elif band_edge < required_edge:
reason = f"odds_band_no_value_{band_edge:+.3f}"
return {
"required": market_key in self.odds_band_min_sample,
"available": available,
"band_prob": band_prob,
"band_sample": sample,
"band_edge": band_edge,
"aligned": aligned,
"reason": reason,
}
@staticmethod
def _normalize_pick_token(pick: str) -> str:
return (
str(pick or "")
.strip()
.upper()
.replace("İ", "I")
.replace("Ü", "U")
.replace("Ş", "S")
.replace("Ğ", "G")
.replace("Ö", "O")
.replace("Ç", "C")
)
@staticmethod
def _pick_is_over(pick_key: str) -> bool:
return "UST" in pick_key or "OVER" in pick_key
@staticmethod @staticmethod
def _goal_line_for_market(market: str) -> Optional[float]: def _goal_line_for_market(market: str) -> Optional[float]:
return { return {
@@ -4968,12 +5110,8 @@ class SingleMatchOrchestrator:
calibrated_conf = max(1.0, min(99.0, raw_conf * calibration)) calibrated_conf = max(1.0, min(99.0, raw_conf * calibration))
min_conf = self.market_min_conf.get(market, 55.0) min_conf = self.market_min_conf.get(market, 55.0)
# ── V2 Quant: EV Edge formula ──────────────────────────────────
# Old: edge = prob - (1/odd) ← simple probability difference
# New: edge = (prob × odd) - 1 ← Expected Value (what a quant uses)
implied_prob = (1.0 / odd) if odd > 1.0 else 0.0 implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
ev_edge = (prob * odd) - 1.0 if odd > 1.0 else 0.0 band_verdict = self._odds_band_verdict(data, market, str(row.get("pick") or ""), implied_prob)
simple_edge = prob - implied_prob if implied_prob > 0 else 0.0
# ── V31: League-specific odds reliability ────────────────────── # ── V31: League-specific odds reliability ──────────────────────
# Higher reliability → trust odds-based edge more in play_score # Higher reliability → trust odds-based edge more in play_score
@@ -4995,6 +5133,25 @@ class SingleMatchOrchestrator:
quality_label, quality_label,
5.0, 5.0,
) )
# V33: Removed probability deflation. Deflating probability breaks normalization
# (probs no longer sum to 1) and mathematically guarantees negative EV edge.
# Data quality and confidence penalties are already applied to play_score.
model_calibrated_prob = prob
band_prob = float(band_verdict.get("band_prob", 0.0) or 0.0)
if bool(band_verdict.get("available")):
calibrated_probability = (
(model_calibrated_prob * 0.45)
+ (band_prob * 0.35)
+ (implied_prob * 0.20)
)
elif implied_prob > 0.0:
calibrated_probability = (model_calibrated_prob * 0.65) + (implied_prob * 0.35)
else:
calibrated_probability = model_calibrated_prob
calibrated_probability = max(0.0, min(0.99, calibrated_probability))
model_edge = model_calibrated_prob - implied_prob if implied_prob > 0 else 0.0
ev_edge = (calibrated_probability * odd) - 1.0 if odd > 1.0 else 0.0
simple_edge = calibrated_probability - implied_prob if implied_prob > 0 else 0.0
home_n = len(data.home_lineup or []) home_n = len(data.home_lineup or [])
away_n = len(data.away_lineup or []) away_n = len(data.away_lineup or [])
@@ -5005,22 +5162,20 @@ class SingleMatchOrchestrator:
lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0))) lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
lineup_penalty += max(1.0, (1.0 - lineup_conf) * 5.0) lineup_penalty += max(1.0, (1.0 - lineup_conf) * 5.0)
# V31: edge contribution weighted by league odds reliability
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier)
play_score = max(
0.0,
min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
)
# ── V20+ Safety gates (PRESERVED) ───────────────────────────── # ── V20+ Safety gates (PRESERVED) ─────────────────────────────
min_play_score = self.market_min_play_score.get(market, 68.0) min_play_score = self.market_min_play_score.get(market, 68.0)
min_edge = self.market_min_edge.get(market, 0.02) min_edge = self.market_min_edge.get(market, 0.02)
reasons: List[str] = [] reasons: List[str] = []
playable = True playable = True
# V34: Broadened value_sniper bypass — odds-aware model rarely shows 3% EV edge
# Allow high-confidence predictions OR modest positive EV to bypass secondary gates
is_value_sniper = ev_edge >= 0.008 or calibrated_conf >= 55.0
if calibrated_conf < min_conf: if calibrated_conf < min_conf:
playable = False if not is_value_sniper:
reasons.append("below_calibrated_conf_threshold") playable = False
reasons.append("below_calibrated_conf_threshold")
if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01: if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01:
playable = False playable = False
reasons.append("market_odds_missing") reasons.append("market_odds_missing")
@@ -5037,18 +5192,52 @@ class SingleMatchOrchestrator:
# Most pre-match predictions use probable_xi — blocking kills all output # Most pre-match predictions use probable_xi — blocking kills all output
lineup_penalty += 6.0 lineup_penalty += 6.0
reasons.append("lineup_probable_xi_penalty") reasons.append("lineup_probable_xi_penalty")
# V31: negative edge threshold adapts to league reliability # V34: Added confidence bonus — high raw model probability gets a boost
# Reliable league: stricter (-0.03), unreliable: looser (-0.08) # This prevents over-penalization when edge is near-zero but model is confident
neg_edge_threshold = -0.03 - (1.0 - odds_rel) * 0.05 raw_top_prob = float(row.get("probability", 0.0))
confidence_bonus = 0.0
if raw_top_prob >= 0.65:
confidence_bonus = 15.0
elif raw_top_prob >= 0.55:
confidence_bonus = 10.0
elif raw_top_prob >= 0.45:
confidence_bonus = 5.0
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier) + confidence_bonus
play_score = max(
0.0,
min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
)
# V34: odds_band gate — only hard-block when band data is AVAILABLE and aligned=False
# When band data is sparse (available=False), skip alignment check entirely
band_available = bool(band_verdict.get("available", False))
if band_available and bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")):
if not is_value_sniper:
playable = False
reasons.append(str(band_verdict.get("reason") or "odds_band_not_aligned"))
elif not band_available and bool(band_verdict.get("required")):
# Sparse data — log but don't block
reasons.append("odds_band_data_sparse_skipped")
# V34: REMOVED model_not_above_market gate entirely
# V25 model is odds-informed BY DESIGN → model output ≈ market-implied probability
# Requiring model > market is mathematically impossible with this architecture
# The negative_model_edge gate below still catches truly anti-value picks
# V34: negative edge threshold relaxed — odds-aware model's edge is naturally near zero
# Reliable league: -0.08, unreliable: up to -0.15
# Only blocks truly anti-value picks (model significantly below market)
neg_edge_threshold = -0.08 - (1.0 - odds_rel) * 0.07
if odd > 1.0 and simple_edge < neg_edge_threshold: if odd > 1.0 and simple_edge < neg_edge_threshold:
playable = False if not is_value_sniper:
reasons.append(f"negative_model_edge_{simple_edge:+.3f}") playable = False
reasons.append(f"negative_model_edge_{simple_edge:+.3f}")
# V34: Added value_sniper bypass — was missing before, causing hard blocks
if odd > 1.0 and ev_edge < min_edge: if odd > 1.0 and ev_edge < min_edge:
playable = False if not is_value_sniper:
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}") playable = False
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
if play_score < min_play_score: if play_score < min_play_score:
playable = False if not is_value_sniper:
reasons.append("insufficient_play_score") playable = False
reasons.append("insufficient_play_score")
if not reasons: if not reasons:
reasons.append("market_passed_all_gates") reasons.append("market_passed_all_gates")
@@ -5068,15 +5257,15 @@ class SingleMatchOrchestrator:
elif ev_edge > 0.10: elif ev_edge > 0.10:
grade = "A" grade = "A"
# V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll) # V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll)
stake_units = self._kelly_stake(prob, odd) stake_units = self._kelly_stake(calibrated_probability, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A") reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A")
elif ev_edge > 0.05: elif ev_edge > 0.05:
grade = "B" grade = "B"
stake_units = self._kelly_stake(prob, odd) stake_units = self._kelly_stake(calibrated_probability, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B") reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B")
elif ev_edge > 0.02: elif ev_edge > 0.02:
grade = "C" grade = "C"
stake_units = self._kelly_stake(prob, odd) stake_units = self._kelly_stake(calibrated_probability, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C") reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C")
else: else:
# Passes all V20+ gates but no mathematical edge over bookie # Passes all V20+ gates but no mathematical edge over bookie
@@ -5093,8 +5282,16 @@ class SingleMatchOrchestrator:
"min_required_play_score": round(min_play_score, 1), "min_required_play_score": round(min_play_score, 1),
"min_required_edge": round(min_edge, 4), "min_required_edge": round(min_edge, 4),
"edge": round(ev_edge, 4), "edge": round(ev_edge, 4),
"model_probability": round(prob, 4),
"model_edge": round(model_edge, 4),
"calibrated_probability": round(calibrated_probability, 4),
"implied_prob": round(implied_prob, 4), "implied_prob": round(implied_prob, 4),
"ev_edge": round(ev_edge, 4), "ev_edge": round(ev_edge, 4),
"is_value_sniper": is_value_sniper,
"odds_band_probability": round(float(band_verdict.get("band_prob", 0.0) or 0.0), 4),
"odds_band_sample": round(float(band_verdict.get("band_sample", 0.0) or 0.0), 1),
"odds_band_edge": round(float(band_verdict.get("band_edge", 0.0) or 0.0), 4),
"odds_band_aligned": bool(band_verdict.get("aligned")),
"odds_reliability": round(odds_rel, 4), "odds_reliability": round(odds_rel, 4),
"play_score": round(play_score, 1), "play_score": round(play_score, 1),
"playable": playable, "playable": playable,
@@ -5145,7 +5342,15 @@ class SingleMatchOrchestrator:
"stake_units": float(row.get("stake_units", 0.0)), "stake_units": float(row.get("stake_units", 0.0)),
"play_score": row.get("play_score", 0.0), "play_score": row.get("play_score", 0.0),
"ev_edge": row.get("ev_edge", row.get("edge", 0.0)), "ev_edge": row.get("ev_edge", row.get("edge", 0.0)),
"is_value_sniper": bool(row.get("is_value_sniper")),
"model_probability": row.get("model_probability", row.get("probability", 0.0)),
"model_edge": row.get("model_edge", 0.0),
"calibrated_probability": row.get("calibrated_probability", row.get("probability", 0.0)),
"implied_prob": row.get("implied_prob", 0.0), "implied_prob": row.get("implied_prob", 0.0),
"odds_band_probability": row.get("odds_band_probability", 0.0),
"odds_band_sample": row.get("odds_band_sample", 0.0),
"odds_band_edge": row.get("odds_band_edge", 0.0),
"odds_band_aligned": bool(row.get("odds_band_aligned")),
"odds_reliability": row.get("odds_reliability", 0.35), "odds_reliability": row.get("odds_reliability", 0.35),
"odds": row.get("odds", 0.0), "odds": row.get("odds", 0.0),
"reasons": row.get("decision_reasons", []), "reasons": row.get("decision_reasons", []),
@@ -5187,6 +5392,11 @@ class SingleMatchOrchestrator:
ref_score = 1.0 if data.referee_name else 0.6 ref_score = 1.0 if data.referee_name else 0.6
if not data.referee_name: if not data.referee_name:
flags.append("missing_referee") flags.append("missing_referee")
if data.source_table == "live_matches":
flags.append("live_match_pre_match_features")
feature_source = str(getattr(data, "feature_source", "") or "")
if feature_source == "live_prematch_enrichment":
flags.append("ai_features_inferred_from_history")
total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10) total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10)
@@ -5196,6 +5406,10 @@ class SingleMatchOrchestrator:
label = "MEDIUM" label = "MEDIUM"
else: else:
label = "LOW" label = "LOW"
if label == "HIGH" and (
data.lineup_source == "probable_xi" or not data.referee_name
):
label = "MEDIUM"
return { return {
"label": label, "label": label,
@@ -5204,6 +5418,7 @@ class SingleMatchOrchestrator:
"away_lineup_count": away_n, "away_lineup_count": away_n,
"lineup_source": data.lineup_source, "lineup_source": data.lineup_source,
"lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3), "lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3),
"feature_source": feature_source or "unknown",
"flags": flags, "flags": flags,
} }
+33 -2
View File
@@ -543,6 +543,7 @@ model User {
analyses Analysis[] analyses Analysis[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
usageLimit UsageLimit? usageLimit UsageLimit?
subscription Subscription?
coupons UserCoupon[] coupons UserCoupon[]
totoCoupons TotoCoupon[] totoCoupons TotoCoupon[]
@@ -551,6 +552,27 @@ model User {
@@map("users") @@map("users")
} }
model Subscription {
id String @id @default(uuid())
userId String @unique @map("user_id")
paddleSubscriptionId String? @unique @map("paddle_subscription_id")
paddleCustomerId String? @map("paddle_customer_id")
plan SubscriptionStatus @default(free)
billingInterval BillingInterval? @map("billing_interval")
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
cancelledAt DateTime? @map("cancelled_at")
cancelEffectiveDate DateTime? @map("cancel_effective_date")
paddlePriceId String? @map("paddle_price_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([paddleSubscriptionId])
@@index([paddleCustomerId])
@@map("subscriptions")
}
model RefreshToken { model RefreshToken {
id String @id @default(uuid()) id String @id @default(uuid())
token String @unique token String @unique
@@ -569,6 +591,8 @@ model UsageLimit {
userId String @unique @map("user_id") userId String @unique @map("user_id")
analysisCount Int @default(0) @map("analysis_count") analysisCount Int @default(0) @map("analysis_count")
couponCount Int @default(0) @map("coupon_count") couponCount Int @default(0) @map("coupon_count")
maxAnalyses Int @default(3) @map("max_analyses")
maxCoupons Int @default(1) @map("max_coupons")
lastResetDate DateTime @map("last_reset_date") @db.Date lastResetDate DateTime @map("last_reset_date") @db.Date
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -765,8 +789,15 @@ enum UserRole {
enum SubscriptionStatus { enum SubscriptionStatus {
free free
active plus
expired premium
past_due
cancelled
}
enum BillingInterval {
monthly
yearly
} }
enum PlayerPosition { enum PlayerPosition {
+1 -1
View File
@@ -24,7 +24,7 @@ async function main() {
firstName: 'Super', firstName: 'Super',
lastName: 'Admin', lastName: 'Admin',
role: UserRole.superadmin, role: UserRole.superadmin,
subscriptionStatus: SubscriptionStatus.active, subscriptionStatus: SubscriptionStatus.free,
isActive: true, isActive: true,
}, },
}); });
+2
View File
@@ -51,6 +51,7 @@ import { AnalysisModule } from "./modules/analysis/analysis.module";
import { CouponsModule } from "./modules/coupons/coupons.module"; import { CouponsModule } from "./modules/coupons/coupons.module";
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module"; import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
import { AiProxyModule } from "./modules/ai-proxy/ai-proxy.module"; import { AiProxyModule } from "./modules/ai-proxy/ai-proxy.module";
import { SubscriptionsModule } from "./modules/subscriptions/subscriptions.module";
// Services and Tasks // Services and Tasks
import { ServicesModule } from "./services/services.module"; import { ServicesModule } from "./services/services.module";
@@ -204,6 +205,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
CouponsModule, CouponsModule,
SporTotoModule, SporTotoModule,
AiProxyModule, AiProxyModule,
SubscriptionsModule,
// Services and Scheduled Tasks // Services and Scheduled Tasks
ServicesModule, ServicesModule,
+1 -1
View File
@@ -243,7 +243,7 @@ export class AiEngineClient {
// - 502/503/504 (proxy/gateway errors) → infrastructure // - 502/503/504 (proxy/gateway errors) → infrastructure
// Do NOT count 500 (app-level crash in AI Engine) — it may be // Do NOT count 500 (app-level crash in AI Engine) — it may be
// match-specific and shouldn't block all other matches. // match-specific and shouldn't block all other matches.
if (error.code === 'ECONNABORTED') { if (error.code === "ECONNABORTED") {
return true; return true;
} }
const status = error.response.status; const status = error.response.status;
-2
View File
@@ -81,7 +81,6 @@ export const LIVE_STATUS_VALUES_FOR_DB = [
"Playing", "Playing",
"Half Time", "Half Time",
"liveGame", "liveGame",
"minutes",
]; ];
export const LIVE_STATE_VALUES_FOR_DB = [ export const LIVE_STATE_VALUES_FOR_DB = [
@@ -110,7 +109,6 @@ export const FINISHED_STATUS_VALUES_FOR_DB = [
"postGame", "postGame",
"posted", "posted",
"Posted", "Posted",
"state",
]; ];
export const FINISHED_STATE_VALUES_FOR_DB = [ export const FINISHED_STATE_VALUES_FOR_DB = [
+10
View File
@@ -72,6 +72,16 @@ export const envSchema = z.object({
OLLAMA_BASE_URL: z.string().url().optional(), OLLAMA_BASE_URL: z.string().url().optional(),
OLLAMA_MODEL: z.string().optional(), OLLAMA_MODEL: z.string().optional(),
// Paddle (Subscription Billing)
PADDLE_API_KEY: z.string().optional(),
PADDLE_WEBHOOK_SECRET: z.string().optional(),
PADDLE_CLIENT_TOKEN: z.string().optional(),
PADDLE_ENVIRONMENT: z.enum(["sandbox", "production"]).default("sandbox"),
PADDLE_PLUS_MONTHLY_PRICE_ID: z.string().optional(),
PADDLE_PLUS_YEARLY_PRICE_ID: z.string().optional(),
PADDLE_PREMIUM_MONTHLY_PRICE_ID: z.string().optional(),
PADDLE_PREMIUM_YEARLY_PRICE_ID: z.string().optional(),
// Optional Features // Optional Features
ENABLE_MAIL: booleanString, ENABLE_MAIL: booleanString,
ENABLE_S3: booleanString, ENABLE_S3: booleanString,
+8 -1
View File
@@ -9,5 +9,12 @@
"serverError": "An unexpected error occurred", "serverError": "An unexpected error occurred",
"unauthorized": "You are not authorized to perform this action", "unauthorized": "You are not authorized to perform this action",
"forbidden": "Access denied", "forbidden": "Access denied",
"badRequest": "Invalid request" "badRequest": "Bad request",
"SUCCESS_USER_ROLE_UPDATED": "User role updated",
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "User subscription updated",
"SUCCESS_USER_DELETED": "User deleted",
"SUCCESS_USER_STATUS_UPDATED": "User status updated",
"SUCCESS_SETTING_UPDATED": "Setting updated",
"SUCCESS_ALL_LIMITS_RESET": "All usage limits reset",
"SUCCESS_USER_LIMITS_RESET": "User usage limits reset"
} }
+9 -1
View File
@@ -10,5 +10,13 @@
"TENANT_NOT_FOUND": "Tenant not found", "TENANT_NOT_FOUND": "Tenant not found",
"VALIDATION_FAILED": "Validation failed", "VALIDATION_FAILED": "Validation failed",
"INTERNAL_ERROR": "An internal error occurred, please try again later", "INTERNAL_ERROR": "An internal error occurred, please try again later",
"AUTH_REQUIRED": "Authentication required, please provide a valid token" "AUTH_REQUIRED": "Authentication required, please provide a valid token",
"USAGE_LIMIT_EXCEEDED": "You have exceeded your daily usage limit. Please upgrade your plan.",
"ANALYSIS_LIMIT_EXCEEDED": "You have exceeded your daily analysis limit. Please upgrade your plan.",
"COUPON_LIMIT_EXCEEDED": "You have exceeded your daily coupon limit. Please upgrade your plan.",
"INVALID_PLAN_TYPE": "Invalid plan type. Must be free, plus, or premium.",
"MATCH_NOT_FOUND": "Match not found",
"PREDICTION_GENERATION_FAILED": "Failed to generate prediction",
"SMART_COUPON_GENERATION_FAILED": "Failed to generate Smart Coupon",
"ANALYSIS_FAILED": "None of the provided matches could be analyzed successfully"
} }
+8 -1
View File
@@ -9,5 +9,12 @@
"serverError": "Beklenmeyen bir hata oluştu", "serverError": "Beklenmeyen bir hata oluştu",
"unauthorized": "Bu işlemi yapmaya yetkiniz yok", "unauthorized": "Bu işlemi yapmaya yetkiniz yok",
"forbidden": "Erişim reddedildi", "forbidden": "Erişim reddedildi",
"badRequest": "Geçersiz istek" "badRequest": "Geçersiz istek",
"SUCCESS_USER_ROLE_UPDATED": "Kullanıcı rolü güncellendi",
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "Kullanıcı aboneliği güncellendi",
"SUCCESS_USER_DELETED": "Kullanıcı başarıyla silindi",
"SUCCESS_USER_STATUS_UPDATED": "Kullanıcı durumu güncellendi",
"SUCCESS_SETTING_UPDATED": "Ayar güncellendi",
"SUCCESS_ALL_LIMITS_RESET": "Tüm kullanıcı limitleri sıfırlandı",
"SUCCESS_USER_LIMITS_RESET": "Kullanıcı limitleri sıfırlandı"
} }
+9 -1
View File
@@ -10,5 +10,13 @@
"TENANT_NOT_FOUND": "Kiracı bulunamadı", "TENANT_NOT_FOUND": "Kiracı bulunamadı",
"VALIDATION_FAILED": "Doğrulama başarısız", "VALIDATION_FAILED": "Doğrulama başarısız",
"INTERNAL_ERROR": "Bir iç hata oluştu, lütfen daha sonra tekrar deneyin", "INTERNAL_ERROR": "Bir iç hata oluştu, lütfen daha sonra tekrar deneyin",
"AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın" "AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın",
"USAGE_LIMIT_EXCEEDED": "Günlük kullanım limitinizi doldurdunuz. Lütfen paketinizi yükseltin.",
"ANALYSIS_LIMIT_EXCEEDED": "Günlük analiz limitinizi doldurdunuz. Lütfen paketinizi yükseltin.",
"COUPON_LIMIT_EXCEEDED": "Günlük kupon limitinizi doldurdunuz. Lütfen paketinizi yükseltin.",
"INVALID_PLAN_TYPE": "Geçersiz paket tipi. (free, plus, premium olmalıdır)",
"MATCH_NOT_FOUND": "Maç bulunamadı",
"PREDICTION_GENERATION_FAILED": "Tahmin oluşturulamadı",
"SMART_COUPON_GENERATION_FAILED": "Akıllı kupon oluşturulamadı",
"ANALYSIS_FAILED": "Sağlanan maçların hiçbiri başarıyla analiz edilemedi"
} }
+62 -30
View File
@@ -10,6 +10,7 @@ import {
UseInterceptors, UseInterceptors,
Inject, Inject,
NotFoundException, NotFoundException,
BadRequestException,
} from "@nestjs/common"; } from "@nestjs/common";
import { import {
CacheInterceptor, CacheInterceptor,
@@ -36,6 +37,8 @@ import {
import { plainToInstance } from "class-transformer"; import { plainToInstance } from "class-transformer";
import { UserResponseDto } from "../users/dto/user.dto"; import { UserResponseDto } from "../users/dto/user.dto";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
import { PlanType } from "../subscriptions/dto/subscription.dto";
@ApiTags("Admin") @ApiTags("Admin")
@ApiBearerAuth() @ApiBearerAuth()
@@ -45,6 +48,7 @@ export class AdminController {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache, @Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
private readonly subscriptionsService: SubscriptionsService,
) {} ) {}
// ================== Users Management ================== // ================== Users Management ==================
@@ -122,7 +126,7 @@ export class AdminController {
return createSuccessResponse( return createSuccessResponse(
plainToInstance(UserResponseDto, updated), plainToInstance(UserResponseDto, updated),
"User status updated", "common.SUCCESS_USER_STATUS_UPDATED",
); );
} }
@@ -140,31 +144,7 @@ export class AdminController {
return createSuccessResponse( return createSuccessResponse(
plainToInstance(UserResponseDto, user), plainToInstance(UserResponseDto, user),
"User role updated", "common.SUCCESS_USER_ROLE_UPDATED",
);
}
@Put("users/:id/subscription")
@ApiOperation({ summary: "Update user subscription" })
@SwaggerResponse({ status: 200, type: UserResponseDto })
async updateUserSubscription(
@Param("id") id: string,
@Body()
data: { subscriptionStatus: string; subscriptionExpiresAt?: string },
): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.update({
where: { id },
data: {
subscriptionStatus: data.subscriptionStatus as any,
subscriptionExpiresAt: data.subscriptionExpiresAt
? new Date(data.subscriptionExpiresAt)
: null,
},
});
return createSuccessResponse(
plainToInstance(UserResponseDto, user),
"User subscription updated",
); );
} }
@@ -176,7 +156,7 @@ export class AdminController {
where: { id }, where: { id },
data: { deletedAt: new Date() }, data: { deletedAt: new Date() },
}); });
return createSuccessResponse(null, "User deleted"); return createSuccessResponse(null, "common.SUCCESS_USER_DELETED");
} }
// ================== App Settings ================== // ================== App Settings ==================
@@ -220,7 +200,7 @@ export class AdminController {
await this.cacheManager.del("app_settings"); await this.cacheManager.del("app_settings");
return createSuccessResponse( return createSuccessResponse(
{ key: setting.key, value: setting.value || "" }, { key: setting.key, value: setting.value || "" },
"Setting updated", "common.SUCCESS_SETTING_UPDATED",
); );
} }
@@ -274,7 +254,57 @@ export class AdminController {
return createSuccessResponse( return createSuccessResponse(
{ count: result.count }, { count: result.count },
"All usage limits reset", "common.SUCCESS_ALL_LIMITS_RESET",
);
}
@Post("usage-limits/reset/:userId")
@ApiOperation({ summary: "Reset usage limits for a single user" })
@SwaggerResponse({ status: 200 })
async resetUserUsageLimits(
@Param("userId") userId: string,
): Promise<ApiResponse<null>> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException("USER_NOT_FOUND");
await this.prisma.usageLimit.update({
where: { userId },
data: {
analysisCount: 0,
couponCount: 0,
lastResetDate: new Date(),
},
});
return createSuccessResponse(null, "common.SUCCESS_USER_LIMITS_RESET");
}
@Put("users/:userId/subscription")
@ApiOperation({ summary: "Update a user's subscription tier" })
@SwaggerResponse({ status: 200 })
async updateUserSubscription(
@Param("userId") userId: string,
@Body() data: { plan: string },
): Promise<ApiResponse<null>> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException("USER_NOT_FOUND");
const validPlans = [PlanType.FREE, PlanType.PLUS, PlanType.PREMIUM];
const newPlan = data.plan as PlanType;
if (!validPlans.includes(newPlan)) {
throw new BadRequestException("INVALID_PLAN_TYPE");
}
await this.prisma.user.update({
where: { id: userId },
data: { subscriptionStatus: newPlan },
});
await this.subscriptionsService.syncLimitsWithPlan(userId, newPlan);
return createSuccessResponse(
null,
"common.SUCCESS_USER_SUBSCRIPTION_UPDATED",
); );
} }
@@ -294,7 +324,9 @@ export class AdminController {
] = await Promise.all([ ] = await Promise.all([
this.prisma.user.count(), this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }), this.prisma.user.count({ where: { isActive: true } }),
this.prisma.user.count({ where: { subscriptionStatus: "active" } }), this.prisma.user.count({
where: { subscriptionStatus: { in: ["plus", "premium"] } },
}),
this.prisma.match.count(), this.prisma.match.count(),
this.prisma.prediction.count(), this.prisma.prediction.count(),
this.prisma.userCoupon.count(), this.prisma.userCoupon.count(),
+2
View File
@@ -1,7 +1,9 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { AdminController } from "./admin.controller"; import { AdminController } from "./admin.controller";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
@Module({ @Module({
imports: [SubscriptionsModule],
controllers: [AdminController], controllers: [AdminController],
}) })
export class AdminModule {} export class AdminModule {}
+2 -2
View File
@@ -59,7 +59,7 @@ export class AnalysisController {
); );
if (!canProceed) { if (!canProceed) {
throw new ForbiddenException("You have exceeded your daily usage limit"); throw new ForbiddenException("USAGE_LIMIT_EXCEEDED");
} }
// Run analysis // Run analysis
@@ -68,7 +68,7 @@ export class AnalysisController {
if (!result) { if (!result) {
return { return {
success: false, success: false,
message: "None of the provided matches could be analyzed successfully", message: "ANALYSIS_FAILED",
}; };
} }
+7 -8
View File
@@ -84,7 +84,7 @@ export class AnalysisService {
} }
/** /**
* Check user usage limit * Check user usage limit (plan-aware via UsageLimit table)
*/ */
async checkUsageLimit( async checkUsageLimit(
userId: string, userId: string,
@@ -96,24 +96,23 @@ export class AnalysisService {
}); });
if (!usageLimit) { if (!usageLimit) {
// Create default limit // Create default limit with free-tier maxes
await this.prisma.usageLimit.create({ await this.prisma.usageLimit.create({
data: { data: {
userId, userId,
analysisCount: 0, analysisCount: 0,
couponCount: 0, couponCount: 0,
maxAnalyses: 3,
maxCoupons: 1,
lastResetDate: new Date(), lastResetDate: new Date(),
}, },
}); });
return true; return true;
} }
// Check limits (default: 10 analyses, 3 coupons per day) // Use plan-aware limits from DB (set by SubscriptionsService.syncLimitsWithPlan)
const user = await this.prisma.user.findUnique({ where: { id: userId } }); const maxAnalyses = usageLimit.maxAnalyses ?? 3;
const isPremium = user?.subscriptionStatus === "active"; const maxCoupons = usageLimit.maxCoupons ?? 1;
const maxAnalyses = isPremium ? 50 : 10;
const maxCoupons = isPremium ? 10 : 3;
if (isCoupon) { if (isCoupon) {
return usageLimit.couponCount < maxCoupons; return usageLimit.couponCount < maxCoupons;
+1 -4
View File
@@ -188,10 +188,7 @@ export class LeaguesService {
{ homeTeamId: teamId1, awayTeamId: teamId2 }, { homeTeamId: teamId1, awayTeamId: teamId2 },
{ homeTeamId: teamId2, awayTeamId: teamId1 }, { homeTeamId: teamId2, awayTeamId: teamId1 },
], ],
AND: [ AND: [{ scoreHome: { not: null } }, { scoreAway: { not: null } }],
{ scoreHome: { not: null } },
{ scoreAway: { not: null } },
],
}, },
include: { include: {
homeTeam: true, homeTeam: true,
+45
View File
@@ -148,6 +148,27 @@ export class MatchPickDto {
@ApiProperty({ required: false, default: 0 }) @ApiProperty({ required: false, default: 0 })
implied_prob?: number; implied_prob?: number;
@ApiProperty({ required: false, default: 0 })
model_probability?: number;
@ApiProperty({ required: false, default: 0 })
model_edge?: number;
@ApiProperty({ required: false, default: 0 })
calibrated_probability?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_probability?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_sample?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_edge?: number;
@ApiProperty({ required: false, default: false })
odds_band_aligned?: boolean;
@ApiProperty() @ApiProperty()
play_score: number; play_score: number;
@@ -171,6 +192,9 @@ export class MatchPickDto {
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"], enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
}) })
signal_tier?: SignalTier; signal_tier?: SignalTier;
@ApiProperty({ required: false, default: false })
is_guaranteed?: boolean;
} }
export class MatchBetAdviceDto { export class MatchBetAdviceDto {
@@ -227,6 +251,27 @@ export class MatchBetSummaryItemDto {
@ApiProperty({ required: false, default: 0 }) @ApiProperty({ required: false, default: 0 })
implied_prob?: number; implied_prob?: number;
@ApiProperty({ required: false, default: 0 })
model_probability?: number;
@ApiProperty({ required: false, default: 0 })
model_edge?: number;
@ApiProperty({ required: false, default: 0 })
calibrated_probability?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_probability?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_sample?: number;
@ApiProperty({ required: false, default: 0 })
odds_band_edge?: number;
@ApiProperty({ required: false, default: false })
odds_band_aligned?: boolean;
@ApiProperty({ required: false, default: 0 }) @ApiProperty({ required: false, default: 0 })
odds?: number; odds?: number;
@@ -21,12 +21,17 @@ import {
GeneratePredictionDto, GeneratePredictionDto,
SmartCouponRequestDto, SmartCouponRequestDto,
} from "./dto/predictions-request.dto"; } from "./dto/predictions-request.dto";
import { Public } from "src/common/decorators"; import { CurrentUser } from "src/common/decorators";
import { AnalysisService } from "../analysis/analysis.service";
import { ForbiddenException } from "@nestjs/common";
@ApiTags("Predictions") @ApiTags("Predictions")
@Controller("predictions") @Controller("predictions")
export class PredictionsController { export class PredictionsController {
constructor(private readonly predictionsService: PredictionsService) {} constructor(
private readonly predictionsService: PredictionsService,
private readonly analysisService: AnalysisService,
) {}
/** /**
* GET /predictions/health * GET /predictions/health
@@ -93,7 +98,6 @@ export class PredictionsController {
* Get prediction for a specific match * Get prediction for a specific match
*/ */
@Get(":matchId") @Get(":matchId")
@Public()
@ApiOperation({ summary: "Get prediction for a specific match" }) @ApiOperation({ summary: "Get prediction for a specific match" })
@ApiParam({ name: "matchId", description: "Match ID" }) @ApiParam({ name: "matchId", description: "Match ID" })
@ApiResponse({ @ApiResponse({
@@ -103,11 +107,23 @@ export class PredictionsController {
type: MatchPredictionDto, type: MatchPredictionDto,
}) })
@ApiResponse({ status: 404, description: "Match not found" }) @ApiResponse({ status: 404, description: "Match not found" })
@ApiResponse({ status: 403, description: "Daily limit exceeded" })
async getPrediction( async getPrediction(
@Param("matchId") matchId: string, @Param("matchId") matchId: string,
@CurrentUser() user: any,
): Promise<MatchPredictionDto> { ): Promise<MatchPredictionDto> {
const canProceed = await this.analysisService.checkUsageLimit(
user.id,
false,
1,
);
if (!canProceed) {
throw new ForbiddenException("ANALYSIS_LIMIT_EXCEEDED");
}
const cached = await this.predictionsService.getCachedPrediction(matchId); const cached = await this.predictionsService.getCachedPrediction(matchId);
if (cached) { if (cached) {
await this.analysisService.recordUsage(user.id, false);
return cached; return cached;
} }
@@ -115,9 +131,10 @@ export class PredictionsController {
const prediction = await this.predictionsService.getPredictionById(matchId); const prediction = await this.predictionsService.getPredictionById(matchId);
if (!prediction) { if (!prediction) {
throw new NotFoundException(`Match not found: ${matchId}`); throw new NotFoundException("MATCH_NOT_FOUND");
} }
await this.analysisService.recordUsage(user.id, false);
return prediction; return prediction;
} }
@@ -129,17 +146,29 @@ export class PredictionsController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Generate prediction with provided match data" }) @ApiOperation({ summary: "Generate prediction with provided match data" })
@ApiResponse({ status: 200, type: MatchPredictionDto }) @ApiResponse({ status: 200, type: MatchPredictionDto })
@ApiResponse({ status: 403, description: "Daily limit exceeded" })
async generatePrediction( async generatePrediction(
@CurrentUser() user: any,
@Body() dto: GeneratePredictionDto, @Body() dto: GeneratePredictionDto,
): Promise<MatchPredictionDto> { ): Promise<MatchPredictionDto> {
const canProceed = await this.analysisService.checkUsageLimit(
user.id,
false,
1,
);
if (!canProceed) {
throw new ForbiddenException("ANALYSIS_LIMIT_EXCEEDED");
}
const prediction = await this.predictionsService.getPredictionWithData({ const prediction = await this.predictionsService.getPredictionWithData({
matchId: dto.matchId, matchId: dto.matchId,
}); });
if (!prediction) { if (!prediction) {
throw new NotFoundException("Failed to generate prediction"); throw new NotFoundException("PREDICTION_GENERATION_FAILED");
} }
await this.analysisService.recordUsage(user.id, false);
return prediction; return prediction;
} }
@@ -157,7 +186,20 @@ export class PredictionsController {
description: "Smart coupon generated successfully", description: "Smart coupon generated successfully",
schema: { type: "object" }, schema: { type: "object" },
}) })
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> { @ApiResponse({ status: 403, description: "Daily limit exceeded" })
async generateSmartCoupon(
@CurrentUser() user: any,
@Body() dto: SmartCouponRequestDto,
): Promise<any> {
const canProceed = await this.analysisService.checkUsageLimit(
user.id,
true,
dto.matchIds?.length || 1,
);
if (!canProceed) {
throw new ForbiddenException("COUPON_LIMIT_EXCEEDED");
}
const coupon = await this.predictionsService.getSmartCoupon( const coupon = await this.predictionsService.getSmartCoupon(
dto.matchIds, dto.matchIds,
dto.strategy || "BALANCED", dto.strategy || "BALANCED",
@@ -168,9 +210,10 @@ export class PredictionsController {
); );
if (!coupon) { if (!coupon) {
throw new NotFoundException("Failed to generate Smart Coupon"); throw new NotFoundException("SMART_COUPON_GENERATION_FAILED");
} }
await this.analysisService.recordUsage(user.id, true);
return coupon; return coupon;
} }
} }
@@ -10,6 +10,7 @@ import { PredictionsQueue } from "./queues/predictions.queue";
import { PredictionsProcessor } from "./queues/predictions.processor"; import { PredictionsProcessor } from "./queues/predictions.processor";
import { PREDICTIONS_QUEUE } from "./queues/predictions.types"; import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
import { FeederModule } from "../feeder/feeder.module"; import { FeederModule } from "../feeder/feeder.module";
import { AnalysisModule } from "../analysis/analysis.module";
const redisEnabled = process.env.REDIS_ENABLED === "true"; const redisEnabled = process.env.REDIS_ENABLED === "true";
@@ -25,6 +26,7 @@ const redisEnabled = process.env.REDIS_ENABLED === "true";
: []), : []),
MatchesModule, MatchesModule,
FeederModule, FeederModule,
AnalysisModule,
], ],
controllers: [PredictionsController], controllers: [PredictionsController],
providers: [ providers: [
+94 -9
View File
@@ -60,7 +60,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
confidence_interval_too_wide_for_main_pick: confidence_interval_too_wide_for_main_pick:
"Ana seçim için güven aralığı çok geniş", "Ana seçim için güven aralığı çok geniş",
confidence_band_low: "Güven bandı düşük", confidence_band_low: "Güven bandı düşük",
playable_edge_found: "Oynanabilir avantaj bulundu", playable_edge_found: "Model avantaj sinyali bulundu",
market_signal_dominant: "Piyasa sinyali baskın", market_signal_dominant: "Piyasa sinyali baskın",
team_form_signal_dominant: "Takım formuna dayalı sinyaller çok baskın", team_form_signal_dominant: "Takım formuna dayalı sinyaller çok baskın",
lineup_signal_strong: "İlk on bir sinyali güçlü", lineup_signal_strong: "İlk on bir sinyali güçlü",
@@ -77,7 +77,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı", limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı",
data_quality_issue: "Veri kalitesi sorunu var", data_quality_issue: "Veri kalitesi sorunu var",
high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük", high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük",
insufficient_play_score: "Oynanabilirlik puanı yetersiz", insufficient_play_score: "Model sinyali yetersiz",
odds_band_confirms_value: "Tarihsel oran bandı değeri doğruluyor",
odds_band_sample_too_low: "Tarihsel oran bandı örneklemi yetersiz",
odds_band_missing_probability: "Tarihsel oran bandı olasılığı yok",
odds_band_unavailable: "Tarihsel oran bandı kullanılamıyor",
odds_band_not_aligned: "Model ve tarihsel oran bandı aynı yönde değil",
no_bet_conditions_met: "Bahis koşulları oluşmadı", no_bet_conditions_met: "Bahis koşulları oluşmadı",
market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti", market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti",
no_ev_edge_minimum_stake: no_ev_edge_minimum_stake:
@@ -129,10 +134,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private readonly feederService: FeederService, private readonly feederService: FeederService,
@Optional() private readonly predictionsQueue?: PredictionsQueue, @Optional() private readonly predictionsQueue?: PredictionsQueue,
) { ) {
this.aiEngineUrl = this.configService.get( this.aiEngineUrl = this.resolveAiEngineUrl();
"AI_ENGINE_URL",
"http://localhost:8000",
);
this.aiEngineClient = new AiEngineClient({ this.aiEngineClient = new AiEngineClient({
baseUrl: this.aiEngineUrl, baseUrl: this.aiEngineUrl,
logger: this.logger, logger: this.logger,
@@ -421,6 +423,59 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
} }
} }
private resolveAiEngineUrl(): string {
const configuredUrl = this.configService.get(
"AI_ENGINE_URL",
"http://localhost:8000",
);
const localEnvUrl = this.readLocalEnvValue("AI_ENGINE_URL");
if (
process.env.NODE_ENV !== "production" &&
localEnvUrl &&
localEnvUrl !== configuredUrl &&
this.isLocalhostUrl(configuredUrl) &&
this.isLocalhostUrl(localEnvUrl)
) {
this.logger.warn(
`AI_ENGINE_URL inherited from parent process (${configuredUrl}) differs from .env.local (${localEnvUrl}); using .env.local for local development`,
);
return localEnvUrl;
}
return configuredUrl;
}
private readLocalEnvValue(key: string): string | null {
const filePath = path.join(process.cwd(), ".env.local");
if (!fs.existsSync(filePath)) {
return null;
}
const line = fs
.readFileSync(filePath, "utf8")
.split(/\r?\n/u)
.find((entry) => entry.trim().startsWith(`${key}=`));
if (!line) {
return null;
}
return line
.slice(line.indexOf("=") + 1)
.trim()
.replace(/^['"]|['"]$/gu, "");
}
private isLocalhostUrl(value: string): boolean {
try {
const url = new URL(value);
return ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
} catch {
return false;
}
}
private async getMatchContext(matchId: string): Promise<MatchContext> { private async getMatchContext(matchId: string): Promise<MatchContext> {
const match = await this.prisma.match.findUnique({ const match = await this.prisma.match.findUnique({
where: { id: matchId }, where: { id: matchId },
@@ -705,6 +760,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
), ),
confidence_interval: interval, confidence_interval: interval,
signal_tier: this.classifySignalTier(record, interval), signal_tier: this.classifySignalTier(record, interval),
is_guaranteed: false,
}; };
} }
@@ -793,7 +849,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/); const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
if (evMatch) { if (evMatch) {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`; return `Teorik avantaj sinyali: Not ${evMatch[2]}`;
} }
const negativeEdgeMatch = normalized.match( const negativeEdgeMatch = normalized.match(
@@ -803,6 +859,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`; return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
} }
const bandNoValueMatch = normalized.match(
/^odds_band_no_value_([-+]?[\d.]+)$/,
);
if (bandNoValueMatch) {
return `Tarihsel oran bandı değeri doğrulamadı (${bandNoValueMatch[1]})`;
}
const edgeThresholdMatch = normalized.match( const edgeThresholdMatch = normalized.match(
/^below_market_edge_threshold_([-+]?[\d.]+)$/, /^below_market_edge_threshold_([-+]?[\d.]+)$/,
); );
@@ -1291,8 +1354,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
} }
private extractCooldownMs(detail: unknown): number { private extractCooldownMs(detail: unknown): number {
if (detail && typeof detail === "object" && "cooldownRemainingMs" in detail) { if (
return Number((detail as Record<string, unknown>).cooldownRemainingMs) || 0; detail &&
typeof detail === "object" &&
"cooldownRemainingMs" in detail
) {
return (
Number((detail as Record<string, unknown>).cooldownRemainingMs) || 0
);
} }
if (typeof detail === "string") { if (typeof detail === "string") {
@@ -1514,8 +1583,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
pick: item.pick, pick: item.pick,
playable: item.playable, playable: item.playable,
bet_grade: item.bet_grade, bet_grade: item.bet_grade,
odds: item.odds,
model_edge: item.model_edge,
calibrated_probability: item.calibrated_probability,
calibrated_confidence: item.calibrated_confidence, calibrated_confidence: item.calibrated_confidence,
ev_edge: item.ev_edge ?? 0, ev_edge: item.ev_edge ?? 0,
odds_band_probability: item.odds_band_probability,
odds_band_sample: item.odds_band_sample,
odds_band_edge: item.odds_band_edge,
odds_band_aligned: item.odds_band_aligned,
stake_units: item.stake_units, stake_units: item.stake_units,
})) }))
: []; : [];
@@ -1531,8 +1607,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
pick: payload.main_pick.pick, pick: payload.main_pick.pick,
playable: payload.main_pick.playable, playable: payload.main_pick.playable,
bet_grade: payload.main_pick.bet_grade, bet_grade: payload.main_pick.bet_grade,
odds: payload.main_pick.odds,
model_edge: payload.main_pick.model_edge,
calibrated_probability: payload.main_pick.calibrated_probability,
calibrated_confidence: payload.main_pick.calibrated_confidence, calibrated_confidence: payload.main_pick.calibrated_confidence,
ev_edge: payload.main_pick.ev_edge ?? 0, ev_edge: payload.main_pick.ev_edge ?? 0,
odds_band_probability: payload.main_pick.odds_band_probability,
odds_band_sample: payload.main_pick.odds_band_sample,
odds_band_edge: payload.main_pick.odds_band_edge,
odds_band_aligned: payload.main_pick.odds_band_aligned,
stake_units: payload.main_pick.stake_units, stake_units: payload.main_pick.stake_units,
} }
: null, : null,
@@ -1542,6 +1625,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
pick: payload.value_pick.pick, pick: payload.value_pick.pick,
playable: payload.value_pick.playable, playable: payload.value_pick.playable,
bet_grade: payload.value_pick.bet_grade, bet_grade: payload.value_pick.bet_grade,
odds: payload.value_pick.odds,
model_edge: payload.value_pick.model_edge,
calibrated_confidence: payload.value_pick.calibrated_confidence, calibrated_confidence: payload.value_pick.calibrated_confidence,
ev_edge: payload.value_pick.ev_edge ?? 0, ev_edge: payload.value_pick.ev_edge ?? 0,
} }
@@ -0,0 +1,178 @@
import {
IsString,
IsOptional,
IsEnum,
IsDateString,
IsInt,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Exclude, Expose, Type } from "class-transformer";
export enum PlanType {
FREE = "free",
PLUS = "plus",
PREMIUM = "premium",
}
export enum BillingIntervalType {
MONTHLY = "monthly",
YEARLY = "yearly",
}
/**
* Plan feature limits configuration
*/
export const PLAN_LIMITS: Record<
PlanType,
{ maxAnalyses: number; maxCoupons: number }
> = {
[PlanType.FREE]: { maxAnalyses: 3, maxCoupons: 1 },
[PlanType.PLUS]: { maxAnalyses: 25, maxCoupons: 5 },
[PlanType.PREMIUM]: { maxAnalyses: 999, maxCoupons: 999 },
};
/**
* Plan display information
*/
export interface PlanInfo {
id: PlanType;
name: string;
description: string;
monthlyPrice: number;
yearlyPrice: number;
currency: string;
features: string[];
limits: { maxAnalyses: number; maxCoupons: number };
highlighted: boolean;
}
export const PLANS: readonly PlanInfo[] = [
{
id: PlanType.FREE,
name: "Free",
description: "Temel analiz özellikleri",
monthlyPrice: 0,
yearlyPrice: 0,
currency: "TRY",
features: ["Günlük 3 analiz", "Günlük 1 kupon", "Temel maç istatistikleri"],
limits: PLAN_LIMITS[PlanType.FREE],
highlighted: false,
},
{
id: PlanType.PLUS,
name: "Plus",
description: "Detaylı analiz ve daha fazla kupon",
monthlyPrice: 99,
yearlyPrice: 999,
currency: "TRY",
features: [
"Günlük 25 analiz",
"Günlük 5 kupon",
"AI detaylı analiz",
"H2H karşılaştırma",
"Reklamsız deneyim",
],
limits: PLAN_LIMITS[PlanType.PLUS],
highlighted: true,
},
{
id: PlanType.PREMIUM,
name: "Premium",
description: "Sınırsız erişim ve özel özellikler",
monthlyPrice: 249,
yearlyPrice: 2499,
currency: "TRY",
features: [
"Sınırsız analiz",
"Sınırsız kupon",
"AI detaylı analiz",
"H2H karşılaştırma",
"Kupon Builder",
"Spor Toto analiz",
"Reklamsız deneyim",
"Öncelikli destek",
],
limits: PLAN_LIMITS[PlanType.PREMIUM],
highlighted: false,
},
] as const;
// ── Response DTOs ──
@Exclude()
export class UsageLimitResponseDto {
@Expose()
analysisCount: number;
@Expose()
couponCount: number;
@Expose()
maxAnalyses: number;
@Expose()
maxCoupons: number;
}
@Exclude()
export class SubscriptionResponseDto {
@Expose()
id: string;
@Expose()
plan: string;
@Expose()
billingInterval: string | null;
@Expose()
currentPeriodStart: Date | null;
@Expose()
currentPeriodEnd: Date | null;
@Expose()
cancelledAt: Date | null;
@Expose()
cancelEffectiveDate: Date | null;
@Expose()
paddlePriceId: string | null;
@Expose()
createdAt: Date;
@Expose()
updatedAt: Date;
}
// ── Request DTOs ──
export class CreateCheckoutDto {
@ApiProperty({
enum: PlanType,
example: PlanType.PLUS,
description: "Target plan",
})
@IsEnum(PlanType)
plan: PlanType;
@ApiProperty({
enum: BillingIntervalType,
example: BillingIntervalType.MONTHLY,
description: "Billing interval",
})
@IsEnum(BillingIntervalType)
billingInterval: BillingIntervalType;
}
export class CancelSubscriptionDto {
@ApiPropertyOptional({
description: "Reason for cancellation",
example: "Too expensive",
})
@IsOptional()
@IsString()
reason?: string;
}
+209
View File
@@ -0,0 +1,209 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as crypto from "crypto";
export interface PaddleWebhookEvent {
event_id: string;
event_type: string;
occurred_at: string;
notification_id: string;
data: Record<string, unknown>;
}
interface PaddleTransactionResponse {
data: {
id: string;
customer_id: string;
status: string;
};
}
@Injectable()
export class PaddleService {
private readonly logger = new Logger(PaddleService.name);
private readonly apiKey: string;
private readonly webhookSecret: string;
private readonly environment: "sandbox" | "production";
private readonly baseUrl: string;
constructor(private readonly config: ConfigService) {
this.apiKey = this.config.get<string>("PADDLE_API_KEY", "");
this.webhookSecret = this.config.get<string>("PADDLE_WEBHOOK_SECRET", "");
this.environment = this.config.get<"sandbox" | "production">(
"PADDLE_ENVIRONMENT",
"sandbox",
);
this.baseUrl =
this.environment === "production"
? "https://api.paddle.com"
: "https://sandbox-api.paddle.com";
}
/**
* Verify Paddle webhook signature (Paddle Billing v2)
*/
verifyWebhookSignature(rawBody: string, signatureHeader: string): boolean {
if (!this.webhookSecret) {
this.logger.warn(
"PADDLE_WEBHOOK_SECRET not configured, skipping verification",
);
return false;
}
try {
// Paddle signature format: ts=TIMESTAMP;h1=HASH
const parts = signatureHeader.split(";");
const tsValue = parts
.find((p) => p.startsWith("ts="))
?.replace("ts=", "");
const h1Value = parts
.find((p) => p.startsWith("h1="))
?.replace("h1=", "");
if (!tsValue || !h1Value) {
this.logger.warn("Invalid Paddle signature format");
return false;
}
// Compute expected signature: HMAC-SHA256(ts + ':' + rawBody)
const signedPayload = `${tsValue}:${rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", this.webhookSecret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(h1Value),
Buffer.from(expectedSignature),
);
} catch (error: unknown) {
const err = error as Error;
this.logger.error(
`Webhook signature verification failed: ${err.message}`,
);
return false;
}
}
/**
* Cancel a Paddle subscription
*/
async cancelSubscription(
paddleSubscriptionId: string,
effectiveFrom:
| "immediately"
| "next_billing_period" = "next_billing_period",
): Promise<void> {
const url = `${this.baseUrl}/subscriptions/${paddleSubscriptionId}/cancel`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ effective_from: effectiveFrom }),
});
if (!response.ok) {
const body = await response.text();
this.logger.error(`Paddle cancel failed: ${response.status} ${body}`);
throw new Error(
`Failed to cancel Paddle subscription: ${response.status}`,
);
}
this.logger.log(
`Paddle subscription ${paddleSubscriptionId} cancelled (effective: ${effectiveFrom})`,
);
}
/**
* Get subscription details from Paddle
*/
async getSubscription(
paddleSubscriptionId: string,
): Promise<Record<string, unknown>> {
const url = `${this.baseUrl}/subscriptions/${paddleSubscriptionId}`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Failed to get Paddle subscription: ${response.status}`);
}
const data = (await response.json()) as { data: Record<string, unknown> };
return data.data;
}
/**
* Map Paddle price ID to our internal plan
*/
mapPriceIdToPlan(priceId: string): {
plan: "plus" | "premium";
interval: "monthly" | "yearly";
} | null {
const mapping: Record<
string,
{ plan: "plus" | "premium"; interval: "monthly" | "yearly" }
> = {
[this.config.get<string>("PADDLE_PLUS_MONTHLY_PRICE_ID", "")]: {
plan: "plus",
interval: "monthly",
},
[this.config.get<string>("PADDLE_PLUS_YEARLY_PRICE_ID", "")]: {
plan: "plus",
interval: "yearly",
},
[this.config.get<string>("PADDLE_PREMIUM_MONTHLY_PRICE_ID", "")]: {
plan: "premium",
interval: "monthly",
},
[this.config.get<string>("PADDLE_PREMIUM_YEARLY_PRICE_ID", "")]: {
plan: "premium",
interval: "yearly",
},
};
// Remove empty key (from missing env vars)
delete mapping[""];
return mapping[priceId] ?? null;
}
/**
* Get the Paddle price ID for a given plan and interval
*/
getPriceId(plan: "plus" | "premium", interval: "monthly" | "yearly"): string {
const key = `PADDLE_${plan.toUpperCase()}_${interval.toUpperCase()}_PRICE_ID`;
const priceId = this.config.get<string>(key, "");
if (!priceId) {
throw new Error(
`Price ID not configured for ${plan} ${interval} (env: ${key})`,
);
}
return priceId;
}
/**
* Get the client-side token for Paddle.js
*/
getClientToken(): string {
return this.config.get<string>("PADDLE_CLIENT_TOKEN", "");
}
/**
* Get the Paddle environment
*/
getEnvironment(): "sandbox" | "production" {
return this.environment;
}
}
@@ -0,0 +1,186 @@
import {
Controller,
Get,
Post,
Body,
Req,
Res,
HttpCode,
HttpStatus,
Logger,
ForbiddenException,
} from "@nestjs/common";
import type { RawBodyRequest } from "@nestjs/common";
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiOkResponse,
} from "@nestjs/swagger";
import type { Request, Response } from "express";
import { CurrentUser, Public } from "../../common/decorators";
import type { ApiResponse } from "../../common/types/api-response.type";
import {
createSuccessResponse,
createErrorResponse,
} from "../../common/types/api-response.type";
import { SubscriptionsService } from "./subscriptions.service";
import { PaddleService, PaddleWebhookEvent } from "./paddle.service";
import {
CreateCheckoutDto,
CancelSubscriptionDto,
SubscriptionResponseDto,
PlanInfo,
PlanType,
} from "./dto/subscription.dto";
interface AuthenticatedUser {
id: string;
email: string;
role: string;
}
@ApiTags("Subscriptions")
@Controller("subscriptions")
export class SubscriptionsController {
private readonly logger = new Logger(SubscriptionsController.name);
constructor(
private readonly subscriptionsService: SubscriptionsService,
private readonly paddleService: PaddleService,
) {}
/**
* GET /subscriptions/plans Get all available plans (public)
*/
@Public()
@Get("plans")
@ApiOperation({ summary: "Get all available subscription plans" })
@ApiOkResponse({ description: "List of available plans" })
getPlans(): ApiResponse<readonly PlanInfo[]> {
const plans = this.subscriptionsService.getPlans();
return createSuccessResponse(plans, "Plans retrieved");
}
/**
* GET /subscriptions/me Get current user subscription
*/
@ApiBearerAuth()
@Get("me")
@ApiOperation({ summary: "Get current user subscription status" })
async getMySubscription(
@CurrentUser() user: AuthenticatedUser,
): Promise<ApiResponse<SubscriptionResponseDto | null>> {
const subscription = await this.subscriptionsService.getCurrentSubscription(
user.id,
);
return createSuccessResponse(subscription, "Subscription retrieved");
}
/**
* POST /subscriptions/checkout Get checkout config for Paddle.js
*/
@ApiBearerAuth()
@Post("checkout")
@ApiOperation({
summary: "Get Paddle checkout configuration for a plan",
})
async getCheckoutConfig(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: CreateCheckoutDto,
): Promise<
ApiResponse<{
priceId: string;
clientToken: string;
environment: string;
userId: string;
}>
> {
if (dto.plan === PlanType.FREE) {
throw new ForbiddenException("Cannot checkout for free plan");
}
const config = this.subscriptionsService.getCheckoutConfig(
dto.plan,
dto.billingInterval,
);
return createSuccessResponse(
{
...config,
userId: user.id,
},
"Checkout config ready",
);
}
/**
* POST /subscriptions/cancel Cancel current subscription
*/
@ApiBearerAuth()
@Post("cancel")
@ApiOperation({ summary: "Cancel the current subscription" })
async cancelSubscription(
@CurrentUser() user: AuthenticatedUser,
@Body() _dto: CancelSubscriptionDto,
): Promise<ApiResponse<null>> {
await this.subscriptionsService.cancelSubscription(user.id);
return createSuccessResponse(null, "Subscription cancellation requested");
}
/**
* POST /subscriptions/webhook/paddle Paddle webhook receiver
*
* This endpoint is PUBLIC (no JWT required) Paddle calls it directly.
* Authentication is done via HMAC signature verification.
*/
@Public()
@Post("webhook/paddle")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Paddle webhook receiver (internal)" })
async handlePaddleWebhook(
@Req() req: RawBodyRequest<Request>,
@Res() res: Response,
): Promise<void> {
const signature = req.headers["paddle-signature"] as string | undefined;
if (!signature) {
this.logger.warn("Paddle webhook received without signature");
res.status(HttpStatus.BAD_REQUEST).json({ error: "Missing signature" });
return;
}
// Get raw body for signature verification
const rawBody = req.rawBody?.toString("utf8");
if (!rawBody) {
this.logger.warn("Paddle webhook received without raw body");
res.status(HttpStatus.BAD_REQUEST).json({ error: "Missing body" });
return;
}
// Verify signature
const isValid = this.paddleService.verifyWebhookSignature(
rawBody,
signature,
);
if (!isValid) {
this.logger.warn("Paddle webhook signature verification failed");
res.status(HttpStatus.UNAUTHORIZED).json({ error: "Invalid signature" });
return;
}
// Parse and process
try {
const event = JSON.parse(rawBody) as PaddleWebhookEvent;
await this.subscriptionsService.handleWebhookEvent(event);
res.status(HttpStatus.OK).json({ received: true });
} catch (error: unknown) {
const err = error as Error;
this.logger.error(`Webhook processing failed: ${err.message}`);
res
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.json({ error: "Processing failed" });
}
}
}
@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { SubscriptionsController } from "./subscriptions.controller";
import { SubscriptionsService } from "./subscriptions.service";
import { PaddleService } from "./paddle.service";
import { DatabaseModule } from "../../database/database.module";
@Module({
imports: [DatabaseModule],
controllers: [SubscriptionsController],
providers: [SubscriptionsService, PaddleService],
exports: [SubscriptionsService, PaddleService],
})
export class SubscriptionsModule {}
@@ -0,0 +1,334 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { PaddleService, PaddleWebhookEvent } from "./paddle.service";
import {
PlanType,
BillingIntervalType,
PLAN_LIMITS,
PLANS,
SubscriptionResponseDto,
} from "./dto/subscription.dto";
import { plainToInstance } from "class-transformer";
@Injectable()
export class SubscriptionsService {
private readonly logger = new Logger(SubscriptionsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly paddleService: PaddleService,
) {}
/**
* Get current subscription for user
*/
async getCurrentSubscription(
userId: string,
): Promise<SubscriptionResponseDto | null> {
const subscription = await this.prisma.subscription.findUnique({
where: { userId },
});
if (!subscription) {
return null;
}
return plainToInstance(SubscriptionResponseDto, subscription);
}
/**
* Get or create subscription record for user
*/
async getOrCreateSubscription(userId: string) {
let subscription = await this.prisma.subscription.findUnique({
where: { userId },
});
if (!subscription) {
subscription = await this.prisma.subscription.create({
data: {
userId,
plan: "free",
},
});
}
return subscription;
}
/**
* Get all available plans
*/
getPlans() {
return PLANS;
}
/**
* Get checkout configuration (client-side token + price ID)
*/
getCheckoutConfig(
plan: PlanType,
billingInterval: BillingIntervalType,
): { priceId: string; clientToken: string; environment: string } {
if (plan === PlanType.FREE) {
throw new Error("Cannot checkout for free plan");
}
const paddlePlan = plan as "plus" | "premium";
const paddleInterval = billingInterval as "monthly" | "yearly";
const priceId = this.paddleService.getPriceId(paddlePlan, paddleInterval);
const clientToken = this.paddleService.getClientToken();
const environment = this.paddleService.getEnvironment();
return { priceId, clientToken, environment };
}
/**
* Handle incoming Paddle webhook event
*/
async handleWebhookEvent(event: PaddleWebhookEvent): Promise<void> {
const eventType = event.event_type;
const data = event.data;
this.logger.log(`Processing Paddle webhook: ${eventType}`);
switch (eventType) {
case "subscription.created":
case "subscription.updated":
await this.handleSubscriptionUpdate(data);
break;
case "subscription.canceled":
await this.handleSubscriptionCancelled(data);
break;
case "subscription.past_due":
await this.handleSubscriptionPastDue(data);
break;
case "subscription.resumed":
await this.handleSubscriptionResumed(data);
break;
case "transaction.completed":
this.logger.log(
`Transaction completed: ${(data as Record<string, unknown>).id}`,
);
break;
case "transaction.payment_failed":
this.logger.warn(
`Payment failed for transaction: ${(data as Record<string, unknown>).id}`,
);
break;
default:
this.logger.debug(`Unhandled Paddle event: ${eventType}`);
}
}
/**
* Cancel subscription for user
*/
async cancelSubscription(userId: string): Promise<void> {
const subscription = await this.prisma.subscription.findUnique({
where: { userId },
});
if (!subscription?.paddleSubscriptionId) {
throw new NotFoundException("No active subscription found");
}
await this.paddleService.cancelSubscription(
subscription.paddleSubscriptionId,
"next_billing_period",
);
this.logger.log(`Cancellation requested for user ${userId}`);
}
// ── Private Handlers ──
private async handleSubscriptionUpdate(
data: Record<string, unknown>,
): Promise<void> {
const paddleSubId = data.id as string;
const customerId = data.customer_id as string;
const status = data.status as string;
const customData = data.custom_data as { userId?: string } | undefined;
const items = data.items as Array<{ price: { id: string } }> | undefined;
const currentBillingPeriod = data.current_billing_period as
| { starts_at: string; ends_at: string }
| undefined;
const userId = customData?.userId;
if (!userId) {
this.logger.warn(
`No userId in custom_data for subscription ${paddleSubId}`,
);
return;
}
// Determine plan from price ID
const priceId = items?.[0]?.price?.id;
let plan: PlanType = PlanType.FREE;
let interval: BillingIntervalType = BillingIntervalType.MONTHLY;
if (priceId) {
const mapped = this.paddleService.mapPriceIdToPlan(priceId);
if (mapped) {
plan = mapped.plan as PlanType;
interval = mapped.interval as BillingIntervalType;
}
}
// Determine effective plan based on Paddle status
const effectivePlan =
status === "active" || status === "trialing" ? plan : PlanType.FREE;
// Upsert subscription record
await this.prisma.subscription.upsert({
where: { userId },
update: {
paddleSubscriptionId: paddleSubId,
paddleCustomerId: customerId,
plan: effectivePlan,
billingInterval: interval,
paddlePriceId: priceId ?? null,
currentPeriodStart: currentBillingPeriod?.starts_at
? new Date(currentBillingPeriod.starts_at)
: null,
currentPeriodEnd: currentBillingPeriod?.ends_at
? new Date(currentBillingPeriod.ends_at)
: null,
cancelledAt: null,
cancelEffectiveDate: null,
},
create: {
userId,
paddleSubscriptionId: paddleSubId,
paddleCustomerId: customerId,
plan: effectivePlan,
billingInterval: interval,
paddlePriceId: priceId ?? null,
currentPeriodStart: currentBillingPeriod?.starts_at
? new Date(currentBillingPeriod.starts_at)
: null,
currentPeriodEnd: currentBillingPeriod?.ends_at
? new Date(currentBillingPeriod.ends_at)
: null,
},
});
// Sync user subscription status
await this.prisma.user.update({
where: { id: userId },
data: { subscriptionStatus: effectivePlan },
});
// Sync usage limits with plan
await this.syncLimitsWithPlan(userId, effectivePlan);
this.logger.log(
`Subscription updated: user=${userId}, plan=${effectivePlan}, interval=${interval}`,
);
}
private async handleSubscriptionCancelled(
data: Record<string, unknown>,
): Promise<void> {
const paddleSubId = data.id as string;
const canceledAt = data.canceled_at as string | undefined;
const currentBillingPeriod = data.current_billing_period as
| { ends_at: string }
| undefined;
const subscription = await this.prisma.subscription.findUnique({
where: { paddleSubscriptionId: paddleSubId },
});
if (!subscription) {
this.logger.warn(`Subscription not found for cancel: ${paddleSubId}`);
return;
}
const effectiveDate = currentBillingPeriod?.ends_at
? new Date(currentBillingPeriod.ends_at)
: new Date();
await this.prisma.subscription.update({
where: { id: subscription.id },
data: {
plan: "cancelled",
cancelledAt: canceledAt ? new Date(canceledAt) : new Date(),
cancelEffectiveDate: effectiveDate,
},
});
// Downgrade user to free
await this.prisma.user.update({
where: { id: subscription.userId },
data: { subscriptionStatus: "free" },
});
await this.syncLimitsWithPlan(subscription.userId, PlanType.FREE);
this.logger.log(
`Subscription cancelled: user=${subscription.userId}, effective=${effectiveDate.toISOString()}`,
);
}
private async handleSubscriptionPastDue(
data: Record<string, unknown>,
): Promise<void> {
const paddleSubId = data.id as string;
const subscription = await this.prisma.subscription.findUnique({
where: { paddleSubscriptionId: paddleSubId },
});
if (!subscription) {
return;
}
await this.prisma.subscription.update({
where: { id: subscription.id },
data: { plan: "past_due" },
});
await this.prisma.user.update({
where: { id: subscription.userId },
data: { subscriptionStatus: "past_due" },
});
this.logger.warn(`Subscription past due: user=${subscription.userId}`);
}
private async handleSubscriptionResumed(
data: Record<string, unknown>,
): Promise<void> {
// Re-process as an update to restore the plan
await this.handleSubscriptionUpdate(data);
}
/**
* Sync usage limits with plan tier
*/
public async syncLimitsWithPlan(
userId: string,
plan: PlanType,
): Promise<void> {
const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS[PlanType.FREE];
await this.prisma.usageLimit.upsert({
where: { userId },
update: {
maxAnalyses: limits.maxAnalyses,
maxCoupons: limits.maxCoupons,
},
create: {
userId,
analysisCount: 0,
couponCount: 0,
maxAnalyses: limits.maxAnalyses,
maxCoupons: limits.maxCoupons,
lastResetDate: new Date(),
},
});
}
}
+26 -1
View File
@@ -73,7 +73,25 @@ export class ChangePasswordDto {
newPassword: string; newPassword: string;
} }
import { Exclude, Expose } from "class-transformer"; import { Exclude, Expose, Type } from "class-transformer";
@Exclude()
export class UsageLimitDto {
@Expose()
analysisCount: number;
@Expose()
couponCount: number;
@Expose()
maxAnalyses: number;
@Expose()
maxCoupons: number;
@Expose()
lastResetDate: Date;
}
@Exclude() @Exclude()
export class UserResponseDto { export class UserResponseDto {
@@ -95,9 +113,16 @@ export class UserResponseDto {
@Expose() @Expose()
isActive: boolean; isActive: boolean;
@Expose()
subscriptionStatus: string;
@Expose() @Expose()
createdAt: Date; createdAt: Date;
@Expose() @Expose()
updatedAt: Date; updatedAt: Date;
@Expose()
@Type(() => UsageLimitDto)
usageLimit?: UsageLimitDto;
} }
+332 -6
View File
@@ -9,6 +9,7 @@ import * as path from "path";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { SidelinedResponse } from "../modules/feeder/feeder.types"; import { SidelinedResponse } from "../modules/feeder/feeder.types";
import { import {
deriveStoredMatchStatus,
FINISHED_STATE_VALUES_FOR_DB, FINISHED_STATE_VALUES_FOR_DB,
FINISHED_STATUS_VALUES_FOR_DB, FINISHED_STATUS_VALUES_FOR_DB,
LIVE_STATE_VALUES_FOR_DB, LIVE_STATE_VALUES_FOR_DB,
@@ -74,6 +75,17 @@ interface LiveLineupsJson {
away: { xi: unknown[]; subs: unknown[] }; away: { xi: unknown[]; subs: unknown[] };
} }
interface PendingPredictionRunForSettlement {
id: bigint;
matchId: string;
engineVersion: string;
payloadSummary: unknown;
scoreHome: number | null;
scoreAway: number | null;
htScoreHome: number | null;
htScoreAway: number | null;
}
type SportType = "football" | "basketball"; type SportType = "football" | "basketball";
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
@@ -187,6 +199,7 @@ export class DataFetcherTask {
await this.syncMatchList(today); await this.syncMatchList(today);
await this.syncMatchList(tomorrow); await this.syncMatchList(tomorrow);
await this.updateLiveScores(); await this.updateLiveScores();
await this.settlePredictionRuns();
await this.fetchOddsForMatches(); await this.fetchOddsForMatches();
await this.fillMissingLineups(); await this.fillMissingLineups();
@@ -263,13 +276,23 @@ export class DataFetcherTask {
if (response.data?.data) { if (response.data?.data) {
const matchData = response.data.data; const matchData = response.data.data;
const scoreHome = matchData.homeScore ?? null;
const scoreAway = matchData.awayScore ?? null;
const storedStatus = deriveStoredMatchStatus({
state: matchData.state,
status: matchData.status,
substate: matchData.substate,
scoreHome,
scoreAway,
});
await this.prisma.liveMatch.update({ await this.prisma.liveMatch.update({
where: { id: match.id }, where: { id: match.id },
data: { data: {
scoreHome: matchData.homeScore ?? null, scoreHome,
scoreAway: matchData.awayScore ?? null, scoreAway,
state: matchData.state || matchData.status, state: matchData.state || null,
status: matchData.status, substate: matchData.substate || null,
status: storedStatus,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
@@ -286,6 +309,300 @@ export class DataFetcherTask {
} }
} }
private async settlePredictionRuns(): Promise<void> {
try {
const rows = await this.prisma.$queryRawUnsafe<
PendingPredictionRunForSettlement[]
>(`
SELECT
pr.id,
pr.match_id AS "matchId",
pr.engine_version AS "engineVersion",
pr.payload_summary AS "payloadSummary",
m.score_home AS "scoreHome",
m.score_away AS "scoreAway",
m.ht_score_home AS "htScoreHome",
m.ht_score_away AS "htScoreAway"
FROM prediction_runs pr
JOIN matches m ON m.id = pr.match_id
WHERE pr.eventual_outcome IS NULL
AND m.sport = 'football'
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
ORDER BY pr.generated_at ASC
LIMIT 500
`);
if (rows.length === 0) return;
let settled = 0;
for (const row of rows) {
const result = this.resolvePredictionRunSettlement(row);
if (!result) continue;
const closingOddsSnapshot = await this.getClosingOddsSnapshot(
row.matchId,
);
const settlementSummary = {
settled_at: new Date().toISOString(),
model_version: row.engineVersion,
outcome: result.outcome,
unit_profit: result.unitProfit,
final_score: {
home: row.scoreHome,
away: row.scoreAway,
},
halftime_score: {
home: row.htScoreHome,
away: row.htScoreAway,
},
closing_odds_snapshot: closingOddsSnapshot,
};
await this.prisma.$executeRawUnsafe(
`
UPDATE prediction_runs
SET eventual_outcome = $1,
unit_profit = $2,
payload_summary = payload_summary || jsonb_build_object('settlement', $3::jsonb)
WHERE id = $4
`,
result.outcome,
result.unitProfit,
JSON.stringify(settlementSummary),
row.id,
);
settled++;
}
if (settled > 0) {
this.logger.log(`Settled ${settled} prediction run(s)`);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Prediction run settlement skipped: ${message}`);
}
}
private async getClosingOddsSnapshot(
matchId: string,
): Promise<Record<string, unknown>> {
const liveMatch = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: {
odds: true,
oddsUpdatedAt: true,
status: true,
state: true,
scoreHome: true,
scoreAway: true,
},
});
if (liveMatch?.odds) {
return {
source: "live_match",
odds: liveMatch.odds,
odds_updated_at: liveMatch.oddsUpdatedAt?.toISOString() ?? null,
status: liveMatch.status ?? null,
state: liveMatch.state ?? null,
score_home: liveMatch.scoreHome,
score_away: liveMatch.scoreAway,
};
}
const categories = await this.prisma.oddCategory.findMany({
where: { matchId },
select: {
name: true,
selections: {
select: {
name: true,
oddValue: true,
position: true,
updatedAt: true,
},
orderBy: { position: "asc" },
take: 12,
},
},
orderBy: { name: "asc" },
take: 24,
});
return {
source: "odd_selections",
category_count: categories.length,
categories: categories.map((category) => ({
name: category.name,
selections: category.selections.map((selection) => ({
name: selection.name,
odd_value: selection.oddValue,
position: selection.position,
updated_at: selection.updatedAt?.toISOString() ?? null,
})),
})),
};
}
private resolvePredictionRunSettlement(
row: PendingPredictionRunForSettlement,
): { outcome: string; unitProfit: number } | null {
const summary = this.asRecord(row.payloadSummary);
const mainPick = this.asRecord(summary.main_pick);
const market = String(mainPick.market || "");
const pick = String(mainPick.pick || "");
const playable = mainPick.playable === true;
const odds = Number(mainPick.odds || 0);
if (
!market ||
!pick ||
!playable ||
!Number.isFinite(odds) ||
odds <= 1.01
) {
return { outcome: "NO_BET", unitProfit: 0 };
}
const won = this.isPredictionPickWon({
market,
pick,
scoreHome: row.scoreHome,
scoreAway: row.scoreAway,
htScoreHome: row.htScoreHome,
htScoreAway: row.htScoreAway,
});
if (won === null) return null;
return {
outcome: `${won ? "WON" : "LOST"}:${market}:${pick}`,
unitProfit: Number((won ? odds - 1 : -1).toFixed(4)),
};
}
private isPredictionPickWon(input: {
market: string;
pick: string;
scoreHome: number | null;
scoreAway: number | null;
htScoreHome: number | null;
htScoreAway: number | null;
}): boolean | null {
const market = input.market.toUpperCase();
const pick = this.normalizePick(input.pick);
const scoreHome = input.scoreHome;
const scoreAway = input.scoreAway;
if (scoreHome === null || scoreAway === null) return null;
if (market === "MS") {
if (pick === "1") return scoreHome > scoreAway;
if (pick === "X" || pick === "0") return scoreHome === scoreAway;
if (pick === "2") return scoreAway > scoreHome;
return null;
}
if (market === "DC") {
const normalized = pick.replace("-", "");
if (normalized === "1X") return scoreHome >= scoreAway;
if (normalized === "X2") return scoreAway >= scoreHome;
if (normalized === "12") return scoreHome !== scoreAway;
return null;
}
if (market === "BTTS") {
const bothScored = scoreHome > 0 && scoreAway > 0;
if (pick.includes("VAR") || pick.includes("YES") || pick === "Y") {
return bothScored;
}
if (pick.includes("YOK") || pick.includes("NO") || pick === "N") {
return !bothScored;
}
return null;
}
const goalLine = this.goalLineForMarket(market);
if (goalLine !== null) {
const total = market.startsWith("HT_")
? this.nullableSum(input.htScoreHome, input.htScoreAway)
: scoreHome + scoreAway;
if (total === null) return null;
if (this.isOverPick(pick)) return total > goalLine;
return total < goalLine;
}
if (market === "HT") {
const htHome = input.htScoreHome;
const htAway = input.htScoreAway;
if (htHome === null || htAway === null) return null;
if (pick === "1") return htHome > htAway;
if (pick === "X" || pick === "0") return htHome === htAway;
if (pick === "2") return htAway > htHome;
}
if (market === "HTFT") {
const htHome = input.htScoreHome;
const htAway = input.htScoreAway;
if (htHome === null || htAway === null || !pick.includes("/"))
return null;
const [htPick, ftPick] = pick.split("/");
return (
this.isResultPickWon(htPick, htHome, htAway) === true &&
this.isResultPickWon(ftPick, scoreHome, scoreAway) === true
);
}
return null;
}
private isResultPickWon(
pick: string,
homeScore: number,
awayScore: number,
): boolean | null {
if (pick === "1") return homeScore > awayScore;
if (pick === "X" || pick === "0") return homeScore === awayScore;
if (pick === "2") return awayScore > homeScore;
return null;
}
private goalLineForMarket(market: string): number | null {
if (market === "OU15") return 1.5;
if (market === "OU25") return 2.5;
if (market === "OU35") return 3.5;
if (market === "HT_OU05") return 0.5;
if (market === "HT_OU15") return 1.5;
return null;
}
private nullableSum(a: number | null, b: number | null): number | null {
if (a === null || b === null) return null;
return a + b;
}
private normalizePick(value: string): string {
return value
.trim()
.toUpperCase()
.replace(/İ/g, "I")
.replace(/Ü/g, "U")
.replace(/Ş/g, "S")
.replace(/Ğ/g, "G")
.replace(/Ö/g, "O")
.replace(/Ç/g, "C");
}
private isOverPick(pick: string): boolean {
return pick.includes("UST") || pick.includes("OVER");
}
private asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
// Phase 3: Odds + referee + lineups + sidelined // Phase 3: Odds + referee + lineups + sidelined
private async fetchOddsForMatches(): Promise<void> { private async fetchOddsForMatches(): Promise<void> {
@@ -705,6 +1022,15 @@ export class DataFetcherTask {
// Safe score parsing // Safe score parsing
const sHome = this.asInt(match.homeScore ?? match.score?.home); const sHome = this.asInt(match.homeScore ?? match.score?.home);
const sAway = this.asInt(match.awayScore ?? match.score?.away); const sAway = this.asInt(match.awayScore ?? match.score?.away);
const storedStatus = deriveStoredMatchStatus({
state: match.state,
status: match.status,
substate: match.substate,
statusBoxContent: match.statusBoxContent,
scoreHome: sHome,
scoreAway: sAway,
score: match.score,
});
// Handle postponed matches (ERT = Erteledendi) // Handle postponed matches (ERT = Erteledendi)
if (match.statusBoxContent === "ERT") { if (match.statusBoxContent === "ERT") {
@@ -733,7 +1059,7 @@ export class DataFetcherTask {
leagueId: leagueId, leagueId: leagueId,
state: match.state || null, state: match.state || null,
substate: match.substate || null, substate: match.substate || null,
status: match.status || match.state || "NS", status: storedStatus,
scoreHome: sHome, scoreHome: sHome,
scoreAway: sAway, scoreAway: sAway,
homeTeamId: homeTeamId, homeTeamId: homeTeamId,
@@ -748,7 +1074,7 @@ export class DataFetcherTask {
leagueId: leagueId, leagueId: leagueId,
state: match.state || null, state: match.state || null,
substate: match.substate || null, substate: match.substate || null,
status: match.status || match.state || "NS", status: storedStatus,
mstUtc: BigInt(match.mstUtc || Date.now()), mstUtc: BigInt(match.mstUtc || Date.now()),
scoreHome: sHome, scoreHome: sHome,
scoreAway: sAway, scoreAway: sAway,
+45 -11
View File
@@ -141,7 +141,7 @@ export class LimitResetterTask {
} }
/** /**
* Reset subscription status for expired users * Downgrade cancelled subscriptions that have passed their cancel effective date
*/ */
@Cron("0 0 * * *", { timeZone: "Europe/Istanbul" }) @Cron("0 0 * * *", { timeZone: "Europe/Istanbul" })
async checkSubscriptions() { async checkSubscriptions() {
@@ -155,21 +155,55 @@ export class LimitResetterTask {
try { try {
const now = new Date(); const now = new Date();
const result = await this.prisma.user.updateMany({ // Find subscriptions with passed cancel effective date
const expiredSubs = await this.prisma.subscription.findMany({
where: { where: {
subscriptionStatus: "active", plan: "cancelled",
subscriptionExpiresAt: { lt: now }, cancelEffectiveDate: { lt: now },
},
data: {
subscriptionStatus: "expired",
}, },
select: { id: true, userId: true },
}); });
if (result.count > 0) { for (const sub of expiredSubs) {
this.logger.log(`${result.count} subscriptions marked as expired`); // Downgrade to free
await this.prisma.user.update({
where: { id: sub.userId },
data: { subscriptionStatus: "free" },
});
// Sync limits to free tier
await this.prisma.usageLimit.upsert({
where: { userId: sub.userId },
update: { maxAnalyses: 3, maxCoupons: 1 },
create: {
userId: sub.userId,
analysisCount: 0,
couponCount: 0,
maxAnalyses: 3,
maxCoupons: 1,
lastResetDate: new Date(),
},
});
// Reset subscription to free
await this.prisma.subscription.update({
where: { id: sub.id },
data: {
plan: "free",
cancelledAt: null,
cancelEffectiveDate: null,
},
});
} }
} catch (error: any) {
this.logger.error(`Subscription check failed: ${error.message}`); if (expiredSubs.length > 0) {
this.logger.log(
`${expiredSubs.length} cancelled subscriptions downgraded to free`,
);
}
} catch (error: unknown) {
const err = error as Error;
this.logger.error(`Subscription check failed: ${err.message}`);
} }
}, },
this.logger, this.logger,