Merge pull request 'gg' (#6) from v28 into main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 39s
Deploy Iddaai Backend / build-and-deploy (push) Successful in 39s
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class EnsembleConfig:
|
||||
_instance: Optional['EnsembleConfig'] = None
|
||||
_config: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(EnsembleConfig, cls).__new__(cls)
|
||||
cls._instance._load_config()
|
||||
return cls._instance
|
||||
|
||||
|
||||
def _load_config(self):
|
||||
"""Load configuration from YAML file."""
|
||||
config_path = os.path.join(os.path.dirname(__file__), 'ensemble_config.yaml')
|
||||
@@ -22,12 +24,12 @@ class EnsembleConfig:
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load ensemble config: {e}")
|
||||
self._config = {}
|
||||
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value by key (supports dot notation for nested keys)."""
|
||||
keys = key.split('.')
|
||||
value = self._config
|
||||
|
||||
|
||||
try:
|
||||
for k in keys:
|
||||
value = value[k]
|
||||
@@ -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')}")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
"""
|
||||
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.calibration import CalibratedClassifierCV
|
||||
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 (83 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",
|
||||
]
|
||||
|
||||
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...")
|
||||
|
||||
# XGB calibration
|
||||
xgb_wrapper = XGBWrapper(xgb_params, num_boost_round=xgb_model.best_iteration)
|
||||
xgb_calibrated = CalibratedClassifierCV(xgb_wrapper, method="isotonic", cv="prefit")
|
||||
xgb_wrapper.fit(X_train, y_train)
|
||||
xgb_calibrated.fit(X_cal, y_cal)
|
||||
|
||||
# LGB calibration — use raw predictions approach
|
||||
lgb_cal_preds = lgb_model.predict(X_cal, num_iteration=lgb_model.best_iteration)
|
||||
if len(lgb_cal_preds.shape) == 1:
|
||||
lgb_cal_preds = np.column_stack([1 - lgb_cal_preds, lgb_cal_preds])
|
||||
|
||||
# ── 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
|
||||
xgb_cal_probs = xgb_calibrated.predict_proba(X_test)
|
||||
|
||||
# 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])
|
||||
|
||||
# Ensemble (raw)
|
||||
raw_ensemble = (xgb_raw_probs + lgb_raw_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_ensemble = _eval(raw_ensemble, "Ensemble Raw")
|
||||
|
||||
# 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}")
|
||||
|
||||
# Calibrated model
|
||||
cal_path = os.path.join(MODELS_DIR, f"cal_xgb_v25_{market_name.lower()}.pkl")
|
||||
with open(cal_path, "wb") as f:
|
||||
pickle.dump(xgb_calibrated, f)
|
||||
print(f"[SAVE] {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_ensemble_raw": m_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_raw", {})
|
||||
print(f" {name:12s} | Acc={ens.get('accuracy','?'):>6s} | LL={ens.get('logloss','?'):>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()
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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
@@ -24,7 +24,7 @@ async function main() {
|
||||
firstName: 'Super',
|
||||
lastName: 'Admin',
|
||||
role: UserRole.superadmin,
|
||||
subscriptionStatus: SubscriptionStatus.active,
|
||||
subscriptionStatus: SubscriptionStatus.free,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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ı"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user