2 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
34 changed files with 3274 additions and 219 deletions
+6 -6
View File
@@ -25,11 +25,11 @@ jobs:
--network iddaai_iddaai-network \
-p 127.0.0.1:1810:3005 \
-e NODE_ENV=production \
-e DATABASE_URL='postgresql://iddaai_user:IddaA1_S4crET!@iddaai-postgres:5432/iddaai_db?schema=public' \
-e REDIS_HOST='iddaai-redis' \
-e REDIS_PORT='6379' \
-e REDIS_PASSWORD='IddaA1_Redis_Pass!' \
-e AI_ENGINE_URL='http://iddaai-ai-engine:8000' \
-e JWT_SECRET='b7V8jM2wP1L5mQxs2RdfFkAsLpI2oG!w' \
-e DATABASE_URL='${{ secrets.DATABASE_URL }}' \
-e REDIS_HOST='${{ secrets.REDIS_HOST }}' \
-e REDIS_PORT='${{ secrets.REDIS_PORT }}' \
-e REDIS_PASSWORD='${{ secrets.REDIS_PASSWORD }}' \
-e AI_ENGINE_URL='${{ secrets.AI_ENGINE_URL }}' \
-e JWT_SECRET='${{ secrets.JWT_SECRET }}' \
-e JWT_ACCESS_EXPIRATION='1d' \
iddaai-be:latest /bin/sh -c "npx prisma migrate deploy && node dist/src/main.js"
+69
View File
@@ -1,7 +1,9 @@
import os
import json
import yaml
from typing import Dict, Any, Optional
class EnsembleConfig:
_instance: Optional['EnsembleConfig'] = None
_config: Dict[str, Any] = {}
@@ -35,12 +37,79 @@ class EnsembleConfig:
except (KeyError, TypeError):
return default
# Singleton accessor
def get_config() -> 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__":
# Test
cfg = get_config()
print(f"Weights: {cfg.get('engine_weights')}")
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
}
}
@@ -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
}
}
}
}
+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'):
"""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:
return
@@ -70,19 +70,27 @@ def flush_features_batch(conn, rows, dry_run: bool, sport: str = 'football'):
f"""
INSERT INTO {table_name}
(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,
missing_players_impact, calculator_ver, updated_at)
VALUES %s
ON CONFLICT (match_id) DO UPDATE SET
home_elo = EXCLUDED.home_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,
away_form_score = EXCLUDED.away_form_score,
calculator_ver = EXCLUDED.calculator_ver,
updated_at = EXCLUDED.updated_at
""",
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,
)
conn.commit()
@@ -136,16 +144,24 @@ def backfill(sport: str, batch_size: int, dry_run: bool):
if not home_id or not away_id:
continue
# Snapshot PRE-match ELO
# Snapshot PRE-match ELO (all dimensions)
home_rating = elo.get_or_create_rating(home_id, h_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((
match_id,
round(home_rating.overall_elo, 2),
round(away_rating.overall_elo, 2),
round(form_to_score(home_rating.recent_form), 2),
round(form_to_score(away_rating.recent_form), 2),
h_overall, # home_elo
a_overall, # away_elo
round(home_rating.home_elo, 2), # home_home_elo
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,
))
+162 -10
View File
@@ -14,6 +14,7 @@ import json
import csv
import math
import time
import bisect
from datetime import datetime
from collections import defaultdict
@@ -120,6 +121,14 @@ FEATURE_COLS = [
"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",
# Labels
"score_home", "score_away", "total_goals",
"ht_score_home", "ht_score_away", "ht_total_goals",
@@ -336,7 +345,7 @@ class BatchDataLoader:
self.team_stats[tid].append((mst, poss, sot, tshots, corn, team_goals))
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))
# 1) Participation: starting XI count + position distribution per (match, team)
@@ -429,9 +438,90 @@ class BatchDataLoader:
for m in self.matches:
match_mst[m[0]] = m[7] # m[0]=id, m[7]=mst_utc
# 6) Build combined cache — NO DATA LEAKAGE
# goals_form: avg goals from last 5 matches BEFORE this match (not this match!)
# squad_quality: only uses pre-match info (lineup, key players) — no current-match goals/assists
# ─── NEW: Player Career Stats (prefix-sum for O(1) temporal lookup) ───
# 6a) Goals per player per match date
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())
for key in all_keys:
mid, tid = key
@@ -443,30 +533,78 @@ class BatchDataLoader:
kp_total = len(key_players_by_team.get(tid, set()))
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 = (
part['starting_count'] * 0.3 +
kp_in_starting * 3.0 +
part['fwd_count'] * 1.5
)
# Missing impact: how many key players are missing
missing_impact = min(kp_missing / max(kp_total, 1), 1.0)
# goals_form: avg goals from last 5 matches BEFORE this match
current_mst = match_mst.get(mid, 0)
team_history = self.team_matches.get(tid, [])
recent_goals = [
tm[2] # team_score
for tm in team_history
if tm[0] < current_mst # only matches BEFORE this one
][-5:] # last 5
tm[2] for tm in team_history if tm[0] < current_mst
][-5:]
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] = {
'squad_quality': squad_quality,
'key_players': kp_in_starting,
'missing_impact': missing_impact,
'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):
@@ -856,6 +994,20 @@ class FeatureExtractor:
"home_goals_form": home_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
"score_home": sh,
"score_away": sa,
+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()
+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}"
+62 -112
View File
@@ -51,8 +51,10 @@ from core.engines.player_predictor import PlayerPrediction, get_player_predictor
from services.feature_enrichment import FeatureEnrichmentService
from services.betting_brain import BettingBrain
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.league_reliability import load_league_reliability
from config.config_loader import build_threshold_dict, get_threshold_default
@dataclass
@@ -165,99 +167,15 @@ class SingleMatchOrchestrator:
self.league_reliability = load_league_reliability()
self.enrichment = FeatureEnrichmentService()
self.odds_band_analyzer = OddsBandAnalyzer()
# ── V32 Calibration Rebalance ──────────────────────────────────
# RULE: max_reachable = 100 × calibration MUST be > min_conf + 8
# Previous values had 5 markets where this was IMPOSSIBLE:
# HT(0.42×100=42 < 45), HCAP(0.40×100=40 < 46), HTFT(0.28×100=28 < 32)
# HT_OU15(0.46×100=46 < 48), CARDS(0.45×100=45 < 48)
# These markets could NEVER become playable → all predictions were PASS.
#
# New calibration: conservative but mathematically achievable.
# Each market's calibration ensures high-confidence model outputs CAN pass.
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": 20.0, # was 42 — drastically lowered to allow underdog/draw value bets
"DC": 40.0, # was 52
"OU15": 45.0, # was 55
"OU25": 30.0, # was 48
"OU35": 20.0, # was 48
"BTTS": 30.0, # was 46
"HT": 20.0, # was 40
"HT_OU05": 35.0, # was 50
"HT_OU15": 25.0, # was 42
"OE": 35.0, # was 46
"CARDS": 30.0, # was 42
"HCAP": 25.0, # was 40
"HTFT": 10.0, # was 28
}
# Min play score: Significantly reduced to stop blocking value bets on underdogs
self.market_min_play_score: Dict[str, float] = {
"MS": 30.0, # was 65
"DC": 55.0, # was 58
"OU15": 55.0, # was 60
"OU25": 45.0, # was 64
"OU35": 35.0, # was 68
"BTTS": 45.0, # was 64
"HT": 30.0, # was 66
"HT_OU05": 45.0, # was 60
"HT_OU15": 35.0, # was 64
"OE": 35.0, # was 60
"CARDS": 40.0, # was 66
"HCAP": 35.0, # was 68
"HTFT": 20.0, # was 72
}
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
}
self.odds_band_min_sample: Dict[str, float] = {
"MS": 8.0,
"DC": 8.0,
"OU15": 8.0,
"OU25": 8.0,
"OU35": 8.0,
"BTTS": 8.0,
"HT": 8.0,
"HT_OU05": 8.0,
"HT_OU15": 8.0,
}
self.odds_band_min_edge: Dict[str, float] = {
"MS": 0.015,
"DC": 0.012,
"OU15": 0.012,
"OU25": 0.015,
"OU35": 0.018,
"BTTS": 0.015,
"HT": 0.018,
"HT_OU05": 0.012,
"HT_OU15": 0.015,
}
# ── Market Thresholds (loaded from config/market_thresholds.json) ──
# All values are centralized in a single JSON file for easy tuning
# without code changes. See config/market_thresholds.json for details.
self.market_calibration: Dict[str, float] = build_threshold_dict("calibration")
self.market_min_conf: Dict[str, float] = build_threshold_dict("min_conf")
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")
self.odds_band_min_sample: Dict[str, float] = build_threshold_dict("odds_band_min_sample")
self.odds_band_min_edge: Dict[str, float] = build_threshold_dict("odds_band_min_edge")
def _get_v25_predictor(self) -> V25Predictor:
if self.v25_predictor is None:
@@ -720,7 +638,7 @@ class SingleMatchOrchestrator:
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.
@@ -729,19 +647,22 @@ class SingleMatchOrchestrator:
T=1.0 → no change, T>1 → softer probabilities.
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
eps = 1e-7 # numerical stability
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
if n <= 2:
T = max(temperature, 2.0)
T = max(temperature, 1.5) # was 2.0
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:
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
labels = list(probs_dict.keys())
@@ -767,8 +688,8 @@ class SingleMatchOrchestrator:
Applies temperature scaling to convert overconfident LightGBM outputs
into realistic, calibrated probabilities.
"""
# Apply temperature scaling to soften extreme probabilities
scaled_probs = _temperature_scale(probs_dict, temperature=2.5)
# V34: Apply temperature scaling — reduced from 2.5 to 1.5
scaled_probs = _temperature_scale(probs_dict, temperature=1.5)
best_label = max(scaled_probs, key=scaled_probs.get)
best_prob = float(scaled_probs[best_label])
@@ -1532,6 +1453,13 @@ class SingleMatchOrchestrator:
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()
if mode not in {"v25", "v26", "dual", "v28", "v28-pro-max"}:
mode = "v25"
@@ -1545,6 +1473,7 @@ class SingleMatchOrchestrator:
)
if mode == "v26":
shadow_package["match_commentary"] = base_package.get("match_commentary")
return shadow_package
if mode == "dual":
merged = dict(base_package)
@@ -5239,7 +5168,9 @@ class SingleMatchOrchestrator:
reasons: List[str] = []
playable = True
is_value_sniper = ev_edge >= 0.03
# 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 not is_value_sniper:
@@ -5261,29 +5192,48 @@ class SingleMatchOrchestrator:
# Most pre-match predictions use probable_xi — blocking kills all output
lineup_penalty += 6.0
reasons.append("lineup_probable_xi_penalty")
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier)
# V34: Added confidence bonus — high raw model probability gets a boost
# This prevents over-penalization when edge is near-zero but model is confident
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),
)
if bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")):
# 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"))
if bool(band_verdict.get("required")) and implied_prob > 0.0 and model_edge <= 0.0:
if not is_value_sniper:
playable = False
reasons.append(f"model_not_above_market_{model_edge:+.3f}")
# V31: negative edge threshold adapts to league reliability
# Reliable league: stricter (-0.03), unreliable: looser (-0.08)
neg_edge_threshold = -0.03 - (1.0 - odds_rel) * 0.05
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 not is_value_sniper:
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:
playable = False
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
if not is_value_sniper:
playable = False
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
if play_score < min_play_score:
if not is_value_sniper:
playable = False
+33 -2
View File
@@ -543,6 +543,7 @@ model User {
analyses Analysis[]
refreshTokens RefreshToken[]
usageLimit UsageLimit?
subscription Subscription?
coupons UserCoupon[]
totoCoupons TotoCoupon[]
@@ -551,6 +552,27 @@ model User {
@@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 {
id String @id @default(uuid())
token String @unique
@@ -569,6 +591,8 @@ model UsageLimit {
userId String @unique @map("user_id")
analysisCount Int @default(0) @map("analysis_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
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -765,8 +789,15 @@ enum UserRole {
enum SubscriptionStatus {
free
active
expired
plus
premium
past_due
cancelled
}
enum BillingInterval {
monthly
yearly
}
enum PlayerPosition {
+1 -1
View File
@@ -24,7 +24,7 @@ async function main() {
firstName: 'Super',
lastName: 'Admin',
role: UserRole.superadmin,
subscriptionStatus: SubscriptionStatus.active,
subscriptionStatus: SubscriptionStatus.free,
isActive: true,
},
});
+2
View File
@@ -51,6 +51,7 @@ import { AnalysisModule } from "./modules/analysis/analysis.module";
import { CouponsModule } from "./modules/coupons/coupons.module";
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
import { AiProxyModule } from "./modules/ai-proxy/ai-proxy.module";
import { SubscriptionsModule } from "./modules/subscriptions/subscriptions.module";
// Services and Tasks
import { ServicesModule } from "./services/services.module";
@@ -204,6 +205,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
CouponsModule,
SporTotoModule,
AiProxyModule,
SubscriptionsModule,
// Services and Scheduled Tasks
ServicesModule,
+1 -1
View File
@@ -243,7 +243,7 @@ export class AiEngineClient {
// - 502/503/504 (proxy/gateway errors) → infrastructure
// Do NOT count 500 (app-level crash in AI Engine) — it may be
// match-specific and shouldn't block all other matches.
if (error.code === 'ECONNABORTED') {
if (error.code === "ECONNABORTED") {
return true;
}
const status = error.response.status;
+10
View File
@@ -72,6 +72,16 @@ export const envSchema = z.object({
OLLAMA_BASE_URL: z.string().url().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
ENABLE_MAIL: booleanString,
ENABLE_S3: booleanString,
+8 -1
View File
@@ -9,5 +9,12 @@
"serverError": "An unexpected error occurred",
"unauthorized": "You are not authorized to perform this action",
"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",
"VALIDATION_FAILED": "Validation failed",
"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",
"unauthorized": "Bu işlemi yapmaya yetkiniz yok",
"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ı",
"VALIDATION_FAILED": "Doğrulama başarısız",
"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,
Inject,
NotFoundException,
BadRequestException,
} from "@nestjs/common";
import {
CacheInterceptor,
@@ -36,6 +37,8 @@ import {
import { plainToInstance } from "class-transformer";
import { UserResponseDto } from "../users/dto/user.dto";
import { UserRole } from "@prisma/client";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
import { PlanType } from "../subscriptions/dto/subscription.dto";
@ApiTags("Admin")
@ApiBearerAuth()
@@ -45,6 +48,7 @@ export class AdminController {
constructor(
private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
private readonly subscriptionsService: SubscriptionsService,
) {}
// ================== Users Management ==================
@@ -122,7 +126,7 @@ export class AdminController {
return createSuccessResponse(
plainToInstance(UserResponseDto, updated),
"User status updated",
"common.SUCCESS_USER_STATUS_UPDATED",
);
}
@@ -140,31 +144,7 @@ export class AdminController {
return createSuccessResponse(
plainToInstance(UserResponseDto, user),
"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",
"common.SUCCESS_USER_ROLE_UPDATED",
);
}
@@ -176,7 +156,7 @@ export class AdminController {
where: { id },
data: { deletedAt: new Date() },
});
return createSuccessResponse(null, "User deleted");
return createSuccessResponse(null, "common.SUCCESS_USER_DELETED");
}
// ================== App Settings ==================
@@ -220,7 +200,7 @@ export class AdminController {
await this.cacheManager.del("app_settings");
return createSuccessResponse(
{ key: setting.key, value: setting.value || "" },
"Setting updated",
"common.SUCCESS_SETTING_UPDATED",
);
}
@@ -274,7 +254,57 @@ export class AdminController {
return createSuccessResponse(
{ 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([
this.prisma.user.count(),
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.prediction.count(),
this.prisma.userCoupon.count(),
+2
View File
@@ -1,7 +1,9 @@
import { Module } from "@nestjs/common";
import { AdminController } from "./admin.controller";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
@Module({
imports: [SubscriptionsModule],
controllers: [AdminController],
})
export class AdminModule {}
+2 -2
View File
@@ -59,7 +59,7 @@ export class AnalysisController {
);
if (!canProceed) {
throw new ForbiddenException("You have exceeded your daily usage limit");
throw new ForbiddenException("USAGE_LIMIT_EXCEEDED");
}
// Run analysis
@@ -68,7 +68,7 @@ export class AnalysisController {
if (!result) {
return {
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(
userId: string,
@@ -96,24 +96,23 @@ export class AnalysisService {
});
if (!usageLimit) {
// Create default limit
// Create default limit with free-tier maxes
await this.prisma.usageLimit.create({
data: {
userId,
analysisCount: 0,
couponCount: 0,
maxAnalyses: 3,
maxCoupons: 1,
lastResetDate: new Date(),
},
});
return true;
}
// Check limits (default: 10 analyses, 3 coupons per day)
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const isPremium = user?.subscriptionStatus === "active";
const maxAnalyses = isPremium ? 50 : 10;
const maxCoupons = isPremium ? 10 : 3;
// Use plan-aware limits from DB (set by SubscriptionsService.syncLimitsWithPlan)
const maxAnalyses = usageLimit.maxAnalyses ?? 3;
const maxCoupons = usageLimit.maxCoupons ?? 1;
if (isCoupon) {
return usageLimit.couponCount < maxCoupons;
+1 -4
View File
@@ -188,10 +188,7 @@ export class LeaguesService {
{ homeTeamId: teamId1, awayTeamId: teamId2 },
{ homeTeamId: teamId2, awayTeamId: teamId1 },
],
AND: [
{ scoreHome: { not: null } },
{ scoreAway: { not: null } },
],
AND: [{ scoreHome: { not: null } }, { scoreAway: { not: null } }],
},
include: {
homeTeam: true,
@@ -21,12 +21,17 @@ import {
GeneratePredictionDto,
SmartCouponRequestDto,
} 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")
@Controller("predictions")
export class PredictionsController {
constructor(private readonly predictionsService: PredictionsService) {}
constructor(
private readonly predictionsService: PredictionsService,
private readonly analysisService: AnalysisService,
) {}
/**
* GET /predictions/health
@@ -93,7 +98,6 @@ export class PredictionsController {
* Get prediction for a specific match
*/
@Get(":matchId")
@Public()
@ApiOperation({ summary: "Get prediction for a specific match" })
@ApiParam({ name: "matchId", description: "Match ID" })
@ApiResponse({
@@ -103,11 +107,23 @@ export class PredictionsController {
type: MatchPredictionDto,
})
@ApiResponse({ status: 404, description: "Match not found" })
@ApiResponse({ status: 403, description: "Daily limit exceeded" })
async getPrediction(
@Param("matchId") matchId: string,
@CurrentUser() user: any,
): 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);
if (cached) {
await this.analysisService.recordUsage(user.id, false);
return cached;
}
@@ -115,9 +131,10 @@ export class PredictionsController {
const prediction = await this.predictionsService.getPredictionById(matchId);
if (!prediction) {
throw new NotFoundException(`Match not found: ${matchId}`);
throw new NotFoundException("MATCH_NOT_FOUND");
}
await this.analysisService.recordUsage(user.id, false);
return prediction;
}
@@ -129,17 +146,29 @@ export class PredictionsController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Generate prediction with provided match data" })
@ApiResponse({ status: 200, type: MatchPredictionDto })
@ApiResponse({ status: 403, description: "Daily limit exceeded" })
async generatePrediction(
@CurrentUser() user: any,
@Body() dto: GeneratePredictionDto,
): 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({
matchId: dto.matchId,
});
if (!prediction) {
throw new NotFoundException("Failed to generate prediction");
throw new NotFoundException("PREDICTION_GENERATION_FAILED");
}
await this.analysisService.recordUsage(user.id, false);
return prediction;
}
@@ -157,7 +186,20 @@ export class PredictionsController {
description: "Smart coupon generated successfully",
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(
dto.matchIds,
dto.strategy || "BALANCED",
@@ -168,9 +210,10 @@ export class PredictionsController {
);
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;
}
}
@@ -10,6 +10,7 @@ import { PredictionsQueue } from "./queues/predictions.queue";
import { PredictionsProcessor } from "./queues/predictions.processor";
import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
import { FeederModule } from "../feeder/feeder.module";
import { AnalysisModule } from "../analysis/analysis.module";
const redisEnabled = process.env.REDIS_ENABLED === "true";
@@ -25,6 +26,7 @@ const redisEnabled = process.env.REDIS_ENABLED === "true";
: []),
MatchesModule,
FeederModule,
AnalysisModule,
],
controllers: [PredictionsController],
providers: [
@@ -1354,8 +1354,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
private extractCooldownMs(detail: unknown): number {
if (detail && typeof detail === "object" && "cooldownRemainingMs" in detail) {
return Number((detail as Record<string, unknown>).cooldownRemainingMs) || 0;
if (
detail &&
typeof detail === "object" &&
"cooldownRemainingMs" in detail
) {
return (
Number((detail as Record<string, unknown>).cooldownRemainingMs) || 0
);
}
if (typeof detail === "string") {
@@ -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;
}
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()
export class UserResponseDto {
@@ -95,9 +113,16 @@ export class UserResponseDto {
@Expose()
isActive: boolean;
@Expose()
subscriptionStatus: string;
@Expose()
createdAt: Date;
@Expose()
updatedAt: Date;
@Expose()
@Type(() => UsageLimitDto)
usageLimit?: UsageLimitDto;
}
+15 -7
View File
@@ -340,7 +340,9 @@ export class DataFetcherTask {
for (const row of rows) {
const result = this.resolvePredictionRunSettlement(row);
if (!result) continue;
const closingOddsSnapshot = await this.getClosingOddsSnapshot(row.matchId);
const closingOddsSnapshot = await this.getClosingOddsSnapshot(
row.matchId,
);
const settlementSummary = {
settled_at: new Date().toISOString(),
model_version: row.engineVersion,
@@ -453,7 +455,13 @@ export class DataFetcherTask {
const playable = mainPick.playable === true;
const odds = Number(mainPick.odds || 0);
if (!market || !pick || !playable || !Number.isFinite(odds) || odds <= 1.01) {
if (
!market ||
!pick ||
!playable ||
!Number.isFinite(odds) ||
odds <= 1.01
) {
return { outcome: "NO_BET", unitProfit: 0 };
}
@@ -516,10 +524,9 @@ export class DataFetcherTask {
const goalLine = this.goalLineForMarket(market);
if (goalLine !== null) {
const total =
market.startsWith("HT_")
? this.nullableSum(input.htScoreHome, input.htScoreAway)
: scoreHome + scoreAway;
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;
@@ -537,7 +544,8 @@ export class DataFetcherTask {
if (market === "HTFT") {
const htHome = input.htScoreHome;
const htAway = input.htScoreAway;
if (htHome === null || htAway === null || !pick.includes("/")) return null;
if (htHome === null || htAway === null || !pick.includes("/"))
return null;
const [htPick, ftPick] = pick.split("/");
return (
this.isResultPickWon(htPick, htHome, htAway) === true &&
+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" })
async checkSubscriptions() {
@@ -155,21 +155,55 @@ export class LimitResetterTask {
try {
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: {
subscriptionStatus: "active",
subscriptionExpiresAt: { lt: now },
},
data: {
subscriptionStatus: "expired",
plan: "cancelled",
cancelEffectiveDate: { lt: now },
},
select: { id: true, userId: true },
});
if (result.count > 0) {
this.logger.log(`${result.count} subscriptions marked as expired`);
for (const sub of expiredSubs) {
// 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,