28 Commits

Author SHA1 Message Date
fahricansecer 5b5f83c8cf fix(ai-engine): remove target leakage from training data extraction
Deploy Iddaai Backend / build-and-deploy (push) Successful in 6s
- goals_form now uses avg of last 5 historical matches instead of current match goals
- squad_quality removes current match goals/assists, uses only pre-match known data
- adds temporal filtering via match_id -> mst_utc mapping
2026-05-05 22:35:04 +03:00
fahricansecer bfddcaca7d gg
Deploy Iddaai Backend / build-and-deploy (push) Successful in 6s
2026-05-05 21:27:06 +03:00
fahricansecer 56d560af08 Update single_match_orchestrator.py
Deploy Iddaai Backend / build-and-deploy (push) Successful in 8s
2026-05-05 20:59:59 +03:00
fahricansecer 4bc51cfa99 fix(ai-engine): hoist ms_edge before score prediction branch to prevent UnboundLocalError
Deploy Iddaai Backend / build-and-deploy (push) Successful in 5s
2026-05-05 20:34:14 +03:00
fahricansecer fdb8a5d0f0 fix(ai-engine): sync FEATURE_COLS with trained models (82→102 features)
Deploy Iddaai Backend / build-and-deploy (push) Successful in 6s
- Load feature columns dynamically from feature_cols.json
- Add 20 missing odds_*_present boolean flags to fallback list
- Fixes LightGBM 'features in data (82) != training data (102)' crash
2026-05-05 20:29:55 +03:00
fahricansecer 22596e69f2 fix(predictions): circuit breaker resilience + graceful degradation
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
- Reset consecutiveFailures on cooldown expiry (half-open state)
  so a single retry failure doesn't immediately re-open the circuit
- Exclude AI Engine app-level 500s from circuit breaker count
  (only network/infra errors: timeout, 502, 503, 504, 429)
- Return null gracefully instead of throwing 503 when no cache exists
- Add DB fallback for non-cooldown AI Engine failures
- Remove blocking wait-and-retry that held requests for up to 20s
2026-05-05 20:19:25 +03:00
fahricansecer f32badbd8f fix(predictions): cooldown fallback cascade + circuit breaker tuning
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
- Add 4-level fallback when AI circuit breaker fires cooldown:
  1) In-memory cache (10min TTL)
  2) DB stored prediction (no TTL filter)
  3) DB cached prediction (with model version check)
  4) Wait out cooldown + retry once (max 20s wait)
- Raise circuit breaker threshold from 3 to 5 consecutive failures
- Reduce cooldown duration from 30s to 15s for faster recovery
- Add extractCooldownMs helper to parse remaining ms from error detail
2026-05-05 20:11:19 +03:00
fahricansecer 5645b38f20 main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 32s
2026-05-05 17:09:11 +03:00
fahricansecer 244d8f5366 feat(ai): expand training to 68K+ matches, add score model, backfill implied odds
Deploy Iddaai Backend / build-and-deploy (push) Successful in 6s
- extract_training_data.py: switch from top_leagues.json (23) to qualified_leagues.json (265)
- update_implied_odds.py: new script to backfill implied odds from real market data
- train_score_model.py: rewrite with v25 102-feature set + temporal split
- single_match_orchestrator.py: integrate ML score model with heuristic fallback
2026-05-05 16:04:00 +03:00
fahricansecer 9bb8f39bca gg
Deploy Iddaai Backend / build-and-deploy (push) Successful in 2m45s
2026-05-05 14:06:20 +03:00
fahricansecer 7a1cf14e2f Update matches.service.ts
Deploy Iddaai Backend / build-and-deploy (push) Successful in 28s
2026-05-05 10:47:00 +03:00
fahricansecer 62c797d299 Update matches.service.ts
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s
2026-05-05 10:13:23 +03:00
fahricansecer 34cc4a6cbb Update matches.service.ts
Deploy Iddaai Backend / build-and-deploy (push) Successful in 30s
2026-05-05 01:04:56 +03:00
fahricansecer 27e96da31d main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s
2026-05-04 18:00:40 +03:00
fahricansecer 145a8b336b fix(feeder): preserve pre-match odds when match goes live
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s
Live odds have missing selections (e.g. '1' key removed from Maç Sonucu
after kickoff), causing the AI model to produce wildly incorrect predictions
(e.g. 3.5% home win for Bristol City). Two guards added:

1. fetchOddsForMatches: Exclude live/finished matches from odds fetch query
2. processMatchOdds: Skip odds/lineups/sidelined overwrite if match already
   has pre-match odds and is live/finished
2026-05-02 16:32:42 +03:00
fahricansecer 7a8960edb8 chore: remove debug checkpoint logs and temp SQL files
Deploy Iddaai Backend / build-and-deploy (push) Successful in 37s
2026-04-26 17:09:22 +03:00
fahricansecer 691c52f610 perf: replace Prisma relation queries with raw SQL for getExistingMatchIds and getMissingScopes - fixes Pi hang
Deploy Iddaai Backend / build-and-deploy (push) Successful in 39s
2026-04-26 17:07:19 +03:00
fahricansecer bc461429f6 debug: add checkpoint timestamps to processDate for hang diagnosis
Deploy Iddaai Backend / build-and-deploy (push) Successful in 46s
2026-04-26 17:04:46 +03:00
fahricansecer a338d02244 main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 2m42s
2026-04-26 03:07:18 +03:00
fahricansecer 1623432039 fix: watchdog force-kill with SIGKILL fallback when process.exit is blocked 2026-04-26 02:27:51 +03:00
fahricansecer 4c7930e9d2 feat: add watchdog timer to detect and recover from hung API requests
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
2026-04-25 11:20:30 +03:00
fahricansecer ec463cb927 fix: make canvas import optional for ARM64 compatibility 2026-04-25 02:41:53 +03:00
fahricansecer eab95c4e5c Update feeder.service.ts
Deploy Iddaai Backend / build-and-deploy (push) Successful in 30s
2026-04-25 02:23:38 +03:00
fahricansecer 9027cc9900 v28
Deploy Iddaai Backend / build-and-deploy (push) Successful in 3m21s
2026-04-24 23:46:28 +03:00
fahricansecer 3875f2a512 Create v28-pro-max-architecture.md
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
2026-04-24 02:30:26 +03:00
fahricansecer 300dceeb4b Merge branch 'main' of https://gitea.bilgich.com/fahricansecer/iddaai-be
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
2026-04-24 02:10:48 +03:00
fahricansecer ad01976fb9 fix: lineup data normalization + tomorrow match sync + player field mapping 2026-04-24 02:09:58 +03:00
fahricansecer 6880eb92f5 Merge pull request 'v26-shadow' (#4) from v26-shadow into main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
Reviewed-on: #4
2026-04-24 01:15:54 +03:00
59 changed files with 8553 additions and 1210 deletions
+4 -2
View File
@@ -42,7 +42,9 @@ uploads/
public/uploads/
# Large Datasets and ML Models
ai-engine/models/
models/
ai-engine/models/*
!ai-engine/models/*.py
models/*
!models/*.py
colab_export/
+1 -1
View File
@@ -47,7 +47,7 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/i18n ./dist/i18n
# Copy league filter config files (critical: without these, feeder stores ALL matches)
COPY top_leagues.json basketball_top_leagues.json ./
COPY qualified_leagues.json top_leagues.json basketball_top_leagues.json ./
# Set environment
ENV NODE_ENV=production
+40 -13
View File
@@ -18,15 +18,20 @@ from features.sidelined_analyzer import get_sidelined_analyzer
@dataclass
class PlayerPrediction:
"""Player engine prediction output."""
home_squad_quality: float = 50.0 # 0-100
away_squad_quality: float = 50.0
squad_diff: float = 0.0 # -100 to +100
"""Player engine prediction output.
IMPORTANT: squad_quality uses the SAME composite formula as
extract_training_data.py so that inference values match the
distribution the model was trained on (~3-36 range).
"""
home_squad_quality: float = 12.0 # training-scale composite (~3-36)
away_squad_quality: float = 12.0
squad_diff: float = 0.0 # home - away (training scale)
home_key_players: int = 0
away_key_players: int = 0
home_missing_impact: float = 0.0 # 0-1, how much weaker due to missing players
home_missing_impact: float = 0.0 # 0-1, how much weaker due to missing players
away_missing_impact: float = 0.0
home_goals_form: int = 0 # Goals in last 5 matches
home_goals_form: int = 0 # Goals in last 5 matches
away_goals_form: int = 0
lineup_available: bool = False
confidence: float = 0.0
@@ -100,10 +105,12 @@ class PlayerPredictorEngine:
"home_goals_last_5": home_analysis.total_goals_last_5,
"home_assists_last_5": home_analysis.total_assists_last_5,
"home_key_players": home_analysis.key_players_count,
"home_forwards": home_analysis.forward_count or 2,
"away_starting_11": away_analysis.starting_count or 11,
"away_goals_last_5": away_analysis.total_goals_last_5,
"away_assists_last_5": away_analysis.total_assists_last_5,
"away_key_players": away_analysis.key_players_count,
"away_forwards": away_analysis.forward_count or 2,
}
elif match_id:
# Try to get from database
@@ -131,13 +138,31 @@ class PlayerPredictorEngine:
away_goals = features.get("away_goals_last_5", 0)
home_key = features.get("home_key_players", 0)
away_key = features.get("away_key_players", 0)
home_assists = features.get("home_assists_last_5", 0)
away_assists = features.get("away_assists_last_5", 0)
home_starting = features.get("home_starting_11", 11)
away_starting = features.get("away_starting_11", 11)
home_fwd = features.get("home_forwards", 2)
away_fwd = features.get("away_forwards", 2)
# Calculate squad quality (0-100)
# Based on: goals scored, key players, assists
home_quality = min(100, 50 + (home_goals * 3) + (home_key * 5) +
features.get("home_assists_last_5", 0) * 2)
away_quality = min(100, 50 + (away_goals * 3) + (away_key * 5) +
features.get("away_assists_last_5", 0) * 2)
# Calculate squad quality — MUST match extract_training_data.py formula
# Formula: starting_count * 0.3 + goals * 2.0 + assists * 1.0
# + key_players * 3.0 + fwd_count * 1.5
# Typical range: ~3 36 (model trained on this distribution)
home_quality = (
home_starting * 0.3 +
home_goals * 2.0 +
home_assists * 1.0 +
home_key * 3.0 +
home_fwd * 1.5
)
away_quality = (
away_starting * 0.3 +
away_goals * 2.0 +
away_assists * 1.0 +
away_key * 3.0 +
away_fwd * 1.5
)
# Squad difference
squad_diff = home_quality - away_quality
@@ -186,8 +211,10 @@ class PlayerPredictorEngine:
Calculate 1X2 probability modifiers based on squad analysis.
Returns modifiers to apply to base probabilities.
squad_diff is in training scale (~-33 to +33), normalize to -1..+1.
"""
diff = prediction.squad_diff / 100 # -1 to +1
diff = prediction.squad_diff / 33.0 # training-scale normalisation
diff = max(-1.0, min(1.0, diff)) # clamp
return {
"home_modifier": 1.0 + (diff * 0.3), # Up to +/-30%
+16 -16
View File
@@ -323,8 +323,8 @@ class OddsBandAnalyzer:
m.home_team_id,
m.away_team_id,
CASE
WHEN m.home_team_id = %(team_id)s THEN os_sel.odd_value
ELSE os_sel2.odd_value
WHEN m.home_team_id = %(team_id)s THEN os_sel.odd_value::numeric
ELSE os_sel2.odd_value::numeric
END AS team_odds
FROM matches m
JOIN odd_categories oc
@@ -344,7 +344,7 @@ class OddsBandAnalyzer:
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND COALESCE(os_sel.odd_value, os_sel2.odd_value)
AND COALESCE(os_sel.odd_value::numeric, os_sel2.odd_value::numeric)
BETWEEN %(band_low)s AND %(band_high)s
ORDER BY m.mst_utc DESC
LIMIT %(max_lookback)s
@@ -432,7 +432,7 @@ class OddsBandAnalyzer:
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND os_h.odd_value BETWEEN %(band_low)s AND %(band_high)s
AND os_h.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
ORDER BY m.mst_utc DESC
LIMIT %(max_lookback)s
)
@@ -508,7 +508,7 @@ class OddsBandAnalyzer:
f"İlk Yarı {line_str} Alt/Üst",
f"Ilk Yari {line_str} Alt/Ust",
]
score_expr = "COALESCE(m.score_ht_home, 0) + COALESCE(m.score_ht_away, 0)"
score_expr = "COALESCE(m.ht_score_home, 0) + COALESCE(m.ht_score_away, 0)"
else:
cat_names = [
f"{line_str} Alt/Üst",
@@ -535,7 +535,7 @@ class OddsBandAnalyzer:
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND os_over.odd_value BETWEEN %(band_low)s AND %(band_high)s
AND os_over.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
ORDER BY m.mst_utc DESC
LIMIT %(max_lookback)s
)
@@ -620,7 +620,7 @@ class OddsBandAnalyzer:
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND os_yes.odd_value BETWEEN %(band_low)s AND %(band_high)s
AND os_yes.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
ORDER BY m.mst_utc DESC
LIMIT %(max_lookback)s
)
@@ -696,7 +696,7 @@ class OddsBandAnalyzer:
AND m.sport = 'football' AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND os_sel.odd_value BETWEEN %(bl)s AND %(bh)s
AND os_sel.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
ORDER BY m.mst_utc DESC LIMIT %(ml)s
)
SELECT COUNT(*) AS ss,
@@ -748,7 +748,7 @@ class OddsBandAnalyzer:
try:
cur.execute("""
WITH ht_matches AS (
SELECT m.score_ht_home, m.score_ht_away,
SELECT m.ht_score_home, m.ht_score_away,
m.home_team_id, m.away_team_id
FROM matches m
JOIN odd_categories oc ON oc.match_id = m.id
@@ -761,18 +761,18 @@ class OddsBandAnalyzer:
AND os2.name = '2' AND m.away_team_id = %(tid)s
WHERE (m.home_team_id = %(tid)s OR m.away_team_id = %(tid)s)
AND m.sport = 'football' AND m.status = 'FT'
AND m.score_ht_home IS NOT NULL
AND m.ht_score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND COALESCE(os1.odd_value, os2.odd_value)
AND COALESCE(os1.odd_value::numeric, os2.odd_value::numeric)
BETWEEN %(bl)s AND %(bh)s
ORDER BY m.mst_utc DESC LIMIT %(ml)s
)
SELECT COUNT(*) AS ss,
COALESCE(AVG(CASE
WHEN (home_team_id = %(tid)s AND score_ht_home > score_ht_away)
OR (away_team_id = %(tid)s AND score_ht_away > score_ht_home)
WHEN (home_team_id = %(tid)s AND ht_score_home > ht_score_away)
OR (away_team_id = %(tid)s AND ht_score_away > ht_score_home)
THEN 1.0 ELSE 0.0 END), 0.33) AS win_rate,
COALESCE(AVG(CASE WHEN score_ht_home = score_ht_away
COALESCE(AVG(CASE WHEN ht_score_home = ht_score_away
THEN 1.0 ELSE 0.0 END), 0.40) AS draw_rate
FROM ht_matches
""", {
@@ -824,7 +824,7 @@ class OddsBandAnalyzer:
AND m.sport = 'football' AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND os_odd.odd_value BETWEEN %(bl)s AND %(bh)s
AND os_odd.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
ORDER BY m.mst_utc DESC LIMIT %(ml)s
)
SELECT COUNT(*) AS ss,
@@ -1185,7 +1185,7 @@ class OddsBandAnalyzer:
'IY/MS'
)
JOIN odd_selections os ON os.odd_category_db_id = oc.db_id
AND os.odd_value BETWEEN %(bl)s AND %(bh)s
AND os.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
WHERE m.sport = 'football'
AND m.status = 'FT'
AND m.score_home IS NOT NULL
+14 -8
View File
@@ -14,10 +14,13 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from models.basketball_v25 import get_basketball_v25_predictor
try:
from models.basketball_v25 import get_basketball_v25_predictor
HAS_BASKETBALL = True
except ImportError:
HAS_BASKETBALL = False
from services.single_match_orchestrator import get_single_match_orchestrator
from services.v26_shadow_engine import get_v26_shadow_engine
from data.database import dispose_engine
load_dotenv()
@@ -49,9 +52,6 @@ async def lifespan(_: FastAPI):
yield
# Cleanup async DB connections on shutdown
await dispose_engine()
app = FastAPI(
title="Suggest-Bet AI Engine",
@@ -123,9 +123,15 @@ def health_check() -> dict[str, Any]:
try:
orchestrator = get_single_match_orchestrator()
shadow_engine = get_v26_shadow_engine()
basketball_predictor = get_basketball_v25_predictor()
basketball_readiness = basketball_predictor.readiness_summary()
ready = bool(basketball_readiness["fully_loaded"])
if HAS_BASKETBALL:
basketball_predictor = get_basketball_v25_predictor()
basketball_readiness = basketball_predictor.readiness_summary()
ready = bool(basketball_readiness.get("fully_loaded", True))
else:
basketball_readiness = {"fully_loaded": False, "error": "Basketball module not found"}
ready = True
return {
"status": "healthy" if ready else "degraded",
"engine": "v28.main",
+413
View File
@@ -0,0 +1,413 @@
"""
Calibration Module for XGBoost Models
=====================================
Calibrates raw probabilities from XGBoost models using Isotonic Regression.
Ensures that a predicted probability of 70% actually corresponds to a 70% win rate.
Usage:
from ai_engine.models.calibration import Calibrator
calibrator = Calibrator()
calibrated_prob = calibrator.calibrate("ms", raw_prob)
# Training new calibration models:
calibrator.train_calibration(valid_df, market="ms")
"""
import os
import pickle
import json
import numpy as np
import pandas as pd
from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any
from sklearn.isotonic import IsotonicRegression
from sklearn.calibration import calibration_curve
from sklearn.metrics import brier_score_loss
AI_ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CALIBRATION_DIR = os.path.join(AI_ENGINE_DIR, "models", "calibration")
os.makedirs(CALIBRATION_DIR, exist_ok=True)
# Supported markets for calibration
SUPPORTED_MARKETS = [
"ms", # Match Result (1X2) - multi-class, calibrated per class
"ms_home", # Standard Home win probability
"ms_home_heavy_fav", # Context: home odds <= 1.40
"ms_home_fav", # Context: 1.40 < home odds <= 1.80
"ms_home_balanced", # Context: 1.80 < home odds <= 2.50
"ms_home_underdog", # Context: home odds > 2.50
"ms_draw", # Draw probability
"ms_away", # Away win probability
"ou15", # Over/Under 1.5
"ou25", # Over/Under 2.5
"ou35", # Over/Under 3.5
"btts", # Both Teams to Score
"ht_ft", # Half-Time/Full-Time
"dc", # Double Chance
"ht", # Half-Time Result
]
class CalibrationMetrics:
"""Stores calibration quality metrics for a market."""
def __init__(self):
self.brier_score: float = 0.0
self.calibration_error: float = 0.0
self.sample_count: int = 0
self.last_trained: str = ""
self.mean_predicted: float = 0.0
self.mean_actual: float = 0.0
def to_dict(self) -> Dict:
return {
"brier_score": round(self.brier_score, 4),
"calibration_error": round(self.calibration_error, 4),
"sample_count": self.sample_count,
"last_trained": self.last_trained,
"mean_predicted": round(self.mean_predicted, 4),
"mean_actual": round(self.mean_actual, 4),
}
class Calibrator:
"""
Probability calibration using Isotonic Regression.
Isotonic Regression is a non-parametric method that fits a piecewise
constant function that is monotonically increasing. It's ideal for
calibrating probabilities because:
1. It preserves ranking (if P(A) > P(B) before, P(A) > P(B) after)
2. It doesn't assume a specific distribution shape
3. It can correct systematic over/under-confidence
Example:
# Before calibration: model predicts 70% but actual win rate is 60%
# After calibration: model predicts 70% → calibrated to 60%
"""
def __init__(self):
self.calibrators: Dict[str, IsotonicRegression] = {}
self.metrics: Dict[str, CalibrationMetrics] = {}
self.heuristic_fallback: Dict[str, float] = {
"ms": 0.90,
"ms_home": 0.90,
"ms_home_heavy_fav": 0.95,
"ms_home_fav": 0.90,
"ms_home_balanced": 0.85,
"ms_home_underdog": 0.80,
"ms_draw": 0.90,
"ms_away": 0.90,
"ou15": 0.90,
"ou25": 0.90,
"ou35": 0.90,
"btts": 0.90,
"ht_ft": 0.85,
"dc": 0.93,
"ht": 0.85,
}
self._load_calibrators()
def _load_calibrators(self):
"""Load trained calibrators for each market from disk."""
for market in SUPPORTED_MARKETS:
model_path = os.path.join(CALIBRATION_DIR, f"{market}_calibrator.pkl")
metrics_path = os.path.join(CALIBRATION_DIR, f"{market}_metrics.json")
if os.path.exists(model_path):
try:
with open(model_path, "rb") as f:
self.calibrators[market] = pickle.load(f)
print(f"[Calibrator] Loaded calibration model for {market}")
except Exception as e:
print(f"[Calibrator] Warning: Failed to load {market}: {e}")
if os.path.exists(metrics_path):
try:
with open(metrics_path, "r") as f:
data = json.load(f)
metrics = CalibrationMetrics()
metrics.brier_score = data.get("brier_score", 0.0)
metrics.calibration_error = data.get("calibration_error", 0.0)
metrics.sample_count = data.get("sample_count", 0)
metrics.last_trained = data.get("last_trained", "")
metrics.mean_predicted = data.get("mean_predicted", 0.0)
metrics.mean_actual = data.get("mean_actual", 0.0)
self.metrics[market] = metrics
except Exception as e:
print(f"[Calibrator] Warning: Failed to load metrics for {market}: {e}")
def calibrate(self, market_type: str, raw_prob: float, odds_val: Optional[float] = None) -> float:
"""
Calibrate a raw probability using Isotonic Regression.
Args:
market_type (str): 'ms_home', 'ou25', 'btts', 'ht_ft', etc.
raw_prob (float): The raw probability from XGBoost (0.0 - 1.0)
odds_val (float, optional): The pre-match odds, used for context-aware bucket mapping
Returns:
float: Calibrated probability (0.0 - 1.0)
"""
# Normalize market type
market_key = market_type.lower().replace("-", "_")
# Route to bucket if ms_home and odds provided
if market_key == "ms_home" and odds_val is not None and odds_val > 1.0:
if odds_val <= 1.40:
bucket_key = "ms_home_heavy_fav"
elif odds_val <= 1.80:
bucket_key = "ms_home_fav"
elif odds_val <= 2.50:
bucket_key = "ms_home_balanced"
else:
bucket_key = "ms_home_underdog"
if bucket_key in self.calibrators:
market_key = bucket_key
# If we have a trained Isotonic Regression model, use it
if market_key in self.calibrators:
try:
calibrated = self.calibrators[market_key].predict([raw_prob])[0]
# Ensure output is valid probability
return float(np.clip(calibrated, 0.01, 0.99))
except Exception as e:
print(f"[Calibrator] Warning: Isotonic failed for {market_key}: {e}")
# Fall through to heuristic
# Fallback to heuristic calibration
return self._heuristic_calibrate(market_key, raw_prob)
def _heuristic_calibrate(self, market_type: str, raw_prob: float) -> float:
"""
Heuristic calibration fallback when no trained model exists.
This applies a conservative shrinkage towards the mean:
- Binary markets (OU, BTTS): shrink towards 0.5
- Multi-class (MS): shrink towards 0.33
- HT/FT: stronger shrinkage due to higher variance
"""
# Get shrinkage factor for this market
shrinkage = self.heuristic_fallback.get(market_type, 0.90)
if market_type in ["ms", "ms_home", "ms_home_heavy_fav", "ms_home_fav", "ms_home_balanced", "ms_home_underdog", "ms_draw", "ms_away"]:
# Pull towards 0.33 (uniform for 3-class)
return (raw_prob * shrinkage) + (0.33 * (1.0 - shrinkage))
elif market_type in ["ou15", "ou25", "ou35", "btts"]:
# Pull towards 0.5 (uniform for binary)
return (raw_prob * shrinkage) + (0.5 * (1.0 - shrinkage))
elif market_type in ["ht_ft", "ht"]:
# Stronger shrinkage for high-variance markets
return raw_prob * shrinkage
elif market_type == "dc":
# Double chance is more reliable
return (raw_prob * shrinkage) + (0.66 * (1.0 - shrinkage))
return raw_prob
def train_calibration(
self,
df: pd.DataFrame,
market: str,
prob_col: str,
actual_col: str,
min_samples: int = 100,
save: bool = True,
) -> CalibrationMetrics:
"""
Train an Isotonic Regression calibration model for a specific market.
Args:
df: DataFrame with predictions and actual outcomes
market: Market identifier (e.g., 'ms_home', 'ou25', 'btts')
prob_col: Column name for raw probabilities
actual_col: Column name for actual outcomes (0 or 1)
min_samples: Minimum samples required to train
save: Whether to save the model to disk
Returns:
CalibrationMetrics with quality metrics
"""
# Filter valid data
valid_df = df[[prob_col, actual_col]].dropna()
n_samples = len(valid_df)
if n_samples < min_samples:
print(f"[Calibrator] Warning: Only {n_samples} samples for {market}, "
f"need at least {min_samples}")
metrics = CalibrationMetrics()
metrics.sample_count = n_samples
return metrics
# Extract arrays
raw_probs = valid_df[prob_col].values
actuals = valid_df[actual_col].values
# Train Isotonic Regression
iso = IsotonicRegression(out_of_bounds="clip", increasing=True)
iso.fit(raw_probs, actuals)
# Calculate calibrated probabilities
calibrated_probs = iso.predict(raw_probs)
# Calculate metrics
metrics = CalibrationMetrics()
metrics.sample_count = n_samples
metrics.last_trained = datetime.utcnow().isoformat()
metrics.brier_score = brier_score_loss(actuals, calibrated_probs)
metrics.mean_predicted = np.mean(raw_probs)
metrics.mean_actual = np.mean(actuals)
# Calculate Expected Calibration Error (ECE)
metrics.calibration_error = self._calculate_ece(
calibrated_probs, actuals, n_bins=10
)
# Store in memory
self.calibrators[market] = iso
self.metrics[market] = metrics
# Save to disk
if save:
self._save_calibration(market, iso, metrics)
print(f"[Calibrator] Trained {market}: "
f"Brier={metrics.brier_score:.4f}, "
f"ECE={metrics.calibration_error:.4f}, "
f"n={n_samples}")
return metrics
def train_all_markets(
self,
df: pd.DataFrame,
market_config: Dict[str, Tuple[str, str]],
min_samples: int = 100,
) -> Dict[str, CalibrationMetrics]:
"""
Train calibration models for multiple markets at once.
Args:
df: DataFrame with all predictions and outcomes
market_config: Dict mapping market -> (prob_col, actual_col)
e.g., {'ou25': ('ou25_over_prob', 'ou25_over_actual')}
min_samples: Minimum samples per market
Returns:
Dict of market -> CalibrationMetrics
"""
results = {}
for market, (prob_col, actual_col) in market_config.items():
print(f"\n[Calibrator] Training {market}...")
try:
metrics = self.train_calibration(
df=df,
market=market,
prob_col=prob_col,
actual_col=actual_col,
min_samples=min_samples,
save=True,
)
results[market] = metrics
except Exception as e:
print(f"[Calibrator] Failed to train {market}: {e}")
return results
def _calculate_ece(
self,
probs: np.ndarray,
actuals: np.ndarray,
n_bins: int = 10
) -> float:
"""
Calculate Expected Calibration Error (ECE).
ECE = sum(|bin_accuracy - bin_confidence| * bin_weight)
Lower is better. Perfect calibration = 0.
"""
bin_boundaries = np.linspace(0, 1, n_bins + 1)
ece = 0.0
for i in range(n_bins):
in_bin = (probs >= bin_boundaries[i]) & (probs < bin_boundaries[i + 1])
prop_in_bin = np.mean(in_bin)
if prop_in_bin > 0:
accuracy_in_bin = np.mean(actuals[in_bin])
avg_confidence_in_bin = np.mean(probs[in_bin])
ece += np.abs(accuracy_in_bin - avg_confidence_in_bin) * prop_in_bin
return ece
def _save_calibration(
self,
market: str,
calibrator: IsotonicRegression,
metrics: CalibrationMetrics
):
"""Save calibration model and metrics to disk."""
# Save model
model_path = os.path.join(CALIBRATION_DIR, f"{market}_calibrator.pkl")
with open(model_path, "wb") as f:
pickle.dump(calibrator, f)
# Save metrics
metrics_path = os.path.join(CALIBRATION_DIR, f"{market}_metrics.json")
with open(metrics_path, "w") as f:
json.dump(metrics.to_dict(), f, indent=2)
print(f"[Calibrator] Saved {market} to {CALIBRATION_DIR}")
def get_calibration_report(self) -> Dict[str, Any]:
"""Generate a summary report of all calibration models."""
report = {
"trained_markets": list(self.calibrators.keys()),
"metrics": {},
"heuristic_only": [],
}
for market in SUPPORTED_MARKETS:
if market in self.metrics:
report["metrics"][market] = self.metrics[market].to_dict()
elif market not in self.calibrators:
report["heuristic_only"].append(market)
return report
def get_calibrated_probabilities(
self,
market: str,
raw_probs: np.ndarray
) -> np.ndarray:
"""
Batch calibration for array of probabilities.
Args:
market: Market type
raw_probs: Array of raw probabilities
Returns:
Array of calibrated probabilities
"""
return np.array([self.calibrate(market, p) for p in raw_probs])
# Singleton instance
_calibrator_instance: Optional[Calibrator] = None
def get_calibrator() -> Calibrator:
"""Get or create the global Calibrator instance."""
global _calibrator_instance
if _calibrator_instance is None:
_calibrator_instance = Calibrator()
return _calibrator_instance
File diff suppressed because it is too large Load Diff
+676
View File
@@ -0,0 +1,676 @@
"""
V25 Ensemble Predictor - NO TARGET LEAKAGE
===========================================
Multi-model ensemble for match prediction using XGBoost and LightGBM.
Features:
- 73 engineered features (NO target leakage)
- Market-specific models (MS, OU25, BTTS)
- Weighted ensemble predictions
- Value bet detection
"""
import os
import json
import numpy as np
import pandas as pd
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field
import xgboost as xgb
import lightgbm as lgb
# CatBoost is optional
try:
from catboost import CatBoostClassifier
CATBOOST_AVAILABLE = True
except ImportError:
CatBoostClassifier = None
CATBOOST_AVAILABLE = False
# Paths
MODELS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'v25')
@dataclass
class MarketPrediction:
"""Prediction for a single betting market."""
market_type: str
pick: str
probability: float
confidence: float
odds: float = 0.0
is_value_bet: bool = False
edge: float = 0.0
def to_dict(self) -> dict:
return {
'market_type': self.market_type,
'pick': self.pick,
'probability': round(self.probability * 100, 1),
'confidence': round(self.confidence, 1),
'odds': self.odds,
'is_value_bet': self.is_value_bet,
'edge': round(self.edge * 100, 1),
}
@dataclass
class ValueBet:
"""Detected value bet opportunity."""
market_type: str
pick: str
probability: float
odds: float
edge: float
confidence: float
def to_dict(self) -> dict:
return {
'market_type': self.market_type,
'pick': self.pick,
'probability': round(self.probability * 100, 1),
'odds': self.odds,
'edge': round(self.edge * 100, 1),
'confidence': round(self.confidence, 1),
}
@dataclass
class MatchPrediction:
"""Complete match prediction with all markets."""
match_id: str
home_team: str
away_team: str
# MS predictions
home_prob: float = 0.0
draw_prob: float = 0.0
away_prob: float = 0.0
ms_pick: str = ''
ms_confidence: float = 0.0
# OU25 predictions
over_prob: float = 0.0
under_prob: float = 0.0
ou25_pick: str = ''
ou25_confidence: float = 0.0
# BTTS predictions
btts_yes_prob: float = 0.0
btts_no_prob: float = 0.0
btts_pick: str = ''
btts_confidence: float = 0.0
# Value bets
value_bets: List[ValueBet] = field(default_factory=list)
def to_dict(self) -> dict:
return {
'match_id': self.match_id,
'home_team': self.home_team,
'away_team': self.away_team,
'ms': {
'home_prob': round(self.home_prob * 100, 1),
'draw_prob': round(self.draw_prob * 100, 1),
'away_prob': round(self.away_prob * 100, 1),
'pick': self.ms_pick,
'confidence': round(self.ms_confidence, 1),
},
'ou25': {
'over_prob': round(self.over_prob * 100, 1),
'under_prob': round(self.under_prob * 100, 1),
'pick': self.ou25_pick,
'confidence': round(self.ou25_confidence, 1),
},
'btts': {
'yes_prob': round(self.btts_yes_prob * 100, 1),
'no_prob': round(self.btts_no_prob * 100, 1),
'pick': self.btts_pick,
'confidence': round(self.btts_confidence, 1),
},
'value_bets': [vb.to_dict() for vb in self.value_bets],
}
class V25Predictor:
"""
V25 Ensemble Predictor - NO TARGET LEAKAGE
Uses market-specific XGBoost and LightGBM models.
Each market (MS, OU25, BTTS) has its own trained models.
"""
# Feature columns — loaded dynamically from feature_cols.json to stay
# in sync with the trained models. The hardcoded list below is only a
# fallback in case the JSON file is missing.
_FALLBACK_FEATURE_COLS = [
# ELO Features (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 Features (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 Features (6)
'h2h_total_matches', 'h2h_home_win_rate', 'h2h_draw_rate',
'h2h_avg_goals', 'h2h_btts_rate', 'h2h_over25_rate',
# Team Stats Features (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 Features (24)
'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 Presence Flags (20)
'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 Features (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 Engine (3)
'home_momentum_score', 'away_momentum_score', 'momentum_diff',
# Squad Features (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',
]
@staticmethod
def _load_feature_cols() -> list:
"""Load feature columns from feature_cols.json, falling back to hardcoded list."""
feature_json = os.path.join(MODELS_DIR, 'feature_cols.json')
try:
if os.path.exists(feature_json):
with open(feature_json, 'r', encoding='utf-8') as f:
cols = json.load(f)
if isinstance(cols, list) and len(cols) > 0:
print(f"[V25] Loaded {len(cols)} feature columns from feature_cols.json")
return cols
except Exception as e:
print(f"[V25] Warning: could not load feature_cols.json: {e}")
print(f"[V25] Using fallback feature columns ({len(V25Predictor._FALLBACK_FEATURE_COLS)} features)")
return V25Predictor._FALLBACK_FEATURE_COLS
FEATURE_COLS = _load_feature_cols.__func__()
# Model weights for ensemble
DEFAULT_WEIGHTS = {
'xgb': 0.50,
'lgb': 0.50,
}
def __init__(self, models_dir: str = None):
"""
Initialize V25 Predictor.
Args:
models_dir: Directory containing model files. Defaults to v25/ directory.
"""
self.models_dir = models_dir or MODELS_DIR
self.models = {} # market -> {'xgb': model, 'lgb': model}
self._loaded = False
# All trained market models available in V25
ALL_MARKETS = [
'ms', 'ou25', 'btts', # Core markets
'ou15', 'ou35', # Additional OU lines
'ht_result', 'ht_ou05', 'ht_ou15', # HT markets
'htft', # HT/FT combo
'cards_ou45', # Cards market
'handicap_ms', # Handicap
'odd_even', # Odd/Even goals
]
# Multi-class markets (output > 2 classes)
MULTICLASS_MARKETS = {'ms', 'ht_result', 'htft', 'handicap_ms'}
def load_models(self) -> bool:
"""Load all market-specific models from disk."""
try:
loaded_count = 0
for market in self.ALL_MARKETS:
self.models[market] = {}
# Load XGBoost (read content in Python to avoid non-ASCII path issues)
xgb_path = os.path.join(self.models_dir, f'xgb_v25_{market}.json')
if os.path.exists(xgb_path) and os.path.getsize(xgb_path) > 0:
with open(xgb_path, 'r', encoding='utf-8') as f:
xgb_content = f.read()
booster = xgb.Booster()
booster.load_model(bytearray(xgb_content, 'utf-8'))
self.models[market]['xgb'] = booster
loaded_count += 1
# Load LightGBM (read content in Python to avoid non-ASCII path issues)
lgb_path = os.path.join(self.models_dir, f'lgb_v25_{market}.txt')
if os.path.exists(lgb_path) and os.path.getsize(lgb_path) > 0:
with open(lgb_path, 'r', encoding='utf-8') as f:
model_str = f.read()
self.models[market]['lgb'] = lgb.Booster(model_str=model_str)
loaded_count += 1
# Remove empty entries
if not self.models[market]:
del self.models[market]
print(f"[V25] Loaded {loaded_count} model files across {len(self.models)} markets: {list(self.models.keys())}")
self._loaded = loaded_count > 0
return self._loaded
except Exception as e:
print(f"[ERROR] Error loading models: {e}")
import traceback
traceback.print_exc()
return False
def _ensure_loaded(self):
"""Ensure models are loaded before prediction."""
if not self._loaded:
if not self.load_models():
raise RuntimeError("Failed to load V25 models")
def _prepare_features(self, features: Dict[str, float]) -> pd.DataFrame:
"""Prepare feature vector for prediction."""
X = pd.DataFrame([{col: features.get(col, 0.0) for col in self.FEATURE_COLS}])
return X
def predict_ms(self, features: Dict[str, float]) -> tuple:
"""
Predict match result (1X2).
Returns:
(home_prob, draw_prob, away_prob)
"""
self._ensure_loaded()
X = self._prepare_features(features)
probs = []
# XGBoost
if 'xgb' in self.models.get('ms', {}):
dmat = xgb.DMatrix(X)
xgb_proba = self.models['ms']['xgb'].predict(dmat)
if len(xgb_proba.shape) == 1:
xgb_proba = np.array([xgb_proba])
probs.append(xgb_proba[0] * self.DEFAULT_WEIGHTS['xgb'])
# LightGBM
if 'lgb' in self.models.get('ms', {}):
lgb_proba = self.models['ms']['lgb'].predict(X)
if len(lgb_proba.shape) == 2:
probs.append(lgb_proba[0] * self.DEFAULT_WEIGHTS['lgb'])
if not probs:
return 0.33, 0.33, 0.33
ensemble_proba = np.sum(probs, axis=0)
ensemble_proba = ensemble_proba / ensemble_proba.sum()
return float(ensemble_proba[0]), float(ensemble_proba[1]), float(ensemble_proba[2])
def predict_ou25(self, features: Dict[str, float]) -> tuple:
"""
Predict Over/Under 2.5 goals.
Returns:
(over_prob, under_prob)
"""
self._ensure_loaded()
X = self._prepare_features(features)
probs = []
# XGBoost
if 'xgb' in self.models.get('ou25', {}):
dmat = xgb.DMatrix(X)
xgb_proba = self.models['ou25']['xgb'].predict(dmat)
if isinstance(xgb_proba, np.ndarray) and len(xgb_proba.shape) == 1:
probs.append(xgb_proba[0])
# LightGBM
if 'lgb' in self.models.get('ou25', {}):
lgb_proba = self.models['ou25']['lgb'].predict(X)
if isinstance(lgb_proba, np.ndarray):
probs.append(lgb_proba[0])
if not probs:
return 0.5, 0.5
# Average probability
avg_prob = np.mean(probs)
return float(avg_prob), float(1 - avg_prob)
def predict_btts(self, features: Dict[str, float]) -> tuple:
"""
Predict Both Teams To Score.
Returns:
(yes_prob, no_prob)
"""
self._ensure_loaded()
X = self._prepare_features(features)
probs = []
# XGBoost
if 'xgb' in self.models.get('btts', {}):
dmat = xgb.DMatrix(X)
xgb_proba = self.models['btts']['xgb'].predict(dmat)
if isinstance(xgb_proba, np.ndarray) and len(xgb_proba.shape) == 1:
probs.append(xgb_proba[0])
# LightGBM
if 'lgb' in self.models.get('btts', {}):
lgb_proba = self.models['btts']['lgb'].predict(X)
if isinstance(lgb_proba, np.ndarray):
probs.append(lgb_proba[0])
if not probs:
return 0.5, 0.5
# Average probability
avg_prob = np.mean(probs)
return float(avg_prob), float(1 - avg_prob)
def predict_market(self, market: str, features: Dict[str, float]) -> np.ndarray:
"""
Generic prediction for any loaded market.
Args:
market: Market key (e.g. 'ht_result', 'htft', 'cards_ou45')
features: Feature dictionary.
Returns:
numpy array of probabilities.
For binary markets: [positive_prob]
For multi-class markets: [class0_prob, class1_prob, ...]
"""
self._ensure_loaded()
if market not in self.models:
return None
X = self._prepare_features(features)
probs = []
weights = []
is_multiclass = market in self.MULTICLASS_MARKETS
# XGBoost
if 'xgb' in self.models[market]:
dmat = xgb.DMatrix(X)
xgb_proba = self.models[market]['xgb'].predict(dmat)
if isinstance(xgb_proba, np.ndarray):
if is_multiclass and len(xgb_proba.shape) == 2:
probs.append(xgb_proba[0])
elif is_multiclass and len(xgb_proba.shape) == 1:
probs.append(xgb_proba)
else:
probs.append(np.array([xgb_proba[0]]))
weights.append(self.DEFAULT_WEIGHTS['xgb'])
# LightGBM
if 'lgb' in self.models[market]:
lgb_proba = self.models[market]['lgb'].predict(X)
if isinstance(lgb_proba, np.ndarray):
if is_multiclass and len(lgb_proba.shape) == 2:
probs.append(lgb_proba[0])
elif is_multiclass and len(lgb_proba.shape) == 1:
probs.append(lgb_proba)
else:
probs.append(np.array([lgb_proba[0]]))
weights.append(self.DEFAULT_WEIGHTS['lgb'])
if not probs:
return None
# Weighted average
if len(probs) == 1:
return probs[0]
total_w = sum(weights[:len(probs)])
result = np.zeros_like(probs[0])
for p, w in zip(probs, weights):
result += p * (w / total_w)
# Normalize multi-class
if is_multiclass and result.sum() > 0:
result = result / result.sum()
return result
def has_market(self, market: str) -> bool:
"""Check if a specific market model is loaded."""
return market in self.models
def predict_match(
self,
match_id: str,
home_team: str,
away_team: str,
features: Dict[str, float],
odds: Optional[Dict[str, float]] = None,
) -> MatchPrediction:
"""
Predict all markets for a match.
Args:
match_id: Match identifier.
home_team: Home team name.
away_team: Away team name.
features: Feature dictionary.
odds: Optional odds dictionary for value bet detection.
Returns:
MatchPrediction object.
"""
# Get predictions for each market
home_prob, draw_prob, away_prob = self.predict_ms(features)
over_prob, under_prob = self.predict_ou25(features)
btts_yes_prob, btts_no_prob = self.predict_btts(features)
# Determine picks
ms_probs = {'1': home_prob, 'X': draw_prob, '2': away_prob}
ms_pick = max(ms_probs, key=ms_probs.get)
ms_confidence = ms_probs[ms_pick] * 100
ou25_probs = {'Over': over_prob, 'Under': under_prob}
ou25_pick = max(ou25_probs, key=ou25_probs.get)
ou25_confidence = ou25_probs[ou25_pick] * 100
btts_probs = {'Yes': btts_yes_prob, 'No': btts_no_prob}
btts_pick = max(btts_probs, key=btts_probs.get)
btts_confidence = btts_probs[btts_pick] * 100
# Create prediction
prediction = MatchPrediction(
match_id=match_id,
home_team=home_team,
away_team=away_team,
home_prob=home_prob,
draw_prob=draw_prob,
away_prob=away_prob,
ms_pick=ms_pick,
ms_confidence=ms_confidence,
over_prob=over_prob,
under_prob=under_prob,
ou25_pick=ou25_pick,
ou25_confidence=ou25_confidence,
btts_yes_prob=btts_yes_prob,
btts_no_prob=btts_no_prob,
btts_pick=btts_pick,
btts_confidence=btts_confidence,
)
# Detect value bets
if odds:
prediction.value_bets = self._detect_value_bets(
prediction, odds, home_prob, draw_prob, away_prob,
over_prob, under_prob, btts_yes_prob, btts_no_prob
)
return prediction
def _detect_value_bets(
self,
prediction: MatchPrediction,
odds: Dict[str, float],
home_prob: float,
draw_prob: float,
away_prob: float,
over_prob: float,
under_prob: float,
btts_yes_prob: float,
btts_no_prob: float,
) -> List[ValueBet]:
"""Detect value bets based on model vs market odds."""
value_bets = []
min_edge = 0.05 # 5% minimum edge
# MS value bets
if 'ms_h' in odds and odds['ms_h'] > 0:
implied = 1 / odds['ms_h']
edge = home_prob - implied
if edge > min_edge:
value_bets.append(ValueBet(
market_type='MS',
pick='1',
probability=home_prob,
odds=odds['ms_h'],
edge=edge,
confidence=home_prob * 100,
))
if 'ms_d' in odds and odds['ms_d'] > 0:
implied = 1 / odds['ms_d']
edge = draw_prob - implied
if edge > min_edge:
value_bets.append(ValueBet(
market_type='MS',
pick='X',
probability=draw_prob,
odds=odds['ms_d'],
edge=edge,
confidence=draw_prob * 100,
))
if 'ms_a' in odds and odds['ms_a'] > 0:
implied = 1 / odds['ms_a']
edge = away_prob - implied
if edge > min_edge:
value_bets.append(ValueBet(
market_type='MS',
pick='2',
probability=away_prob,
odds=odds['ms_a'],
edge=edge,
confidence=away_prob * 100,
))
# OU25 value bets
if 'ou25_o' in odds and odds['ou25_o'] > 0:
implied = 1 / odds['ou25_o']
edge = over_prob - implied
if edge > min_edge:
value_bets.append(ValueBet(
market_type='OU25',
pick='Over',
probability=over_prob,
odds=odds['ou25_o'],
edge=edge,
confidence=over_prob * 100,
))
if 'ou25_u' in odds and odds['ou25_u'] > 0:
implied = 1 / odds['ou25_u']
edge = under_prob - implied
if edge > min_edge:
value_bets.append(ValueBet(
market_type='OU25',
pick='Under',
probability=under_prob,
odds=odds['ou25_u'],
edge=edge,
confidence=under_prob * 100,
))
# BTTS value bets
if 'btts_y' in odds and odds['btts_y'] > 0:
implied = 1 / odds['btts_y']
edge = btts_yes_prob - implied
if edge > min_edge:
value_bets.append(ValueBet(
market_type='BTTS',
pick='Yes',
probability=btts_yes_prob,
odds=odds['btts_y'],
edge=edge,
confidence=btts_yes_prob * 100,
))
if 'btts_n' in odds and odds['btts_n'] > 0:
implied = 1 / odds['btts_n']
edge = btts_no_prob - implied
if edge > min_edge:
value_bets.append(ValueBet(
market_type='BTTS',
pick='No',
probability=btts_no_prob,
odds=odds['btts_n'],
edge=edge,
confidence=btts_no_prob * 100,
))
return value_bets
# Singleton instance
_v25_predictor: Optional[V25Predictor] = None
def get_v25_predictor() -> V25Predictor:
"""Get or create V25 predictor instance."""
global _v25_predictor
if _v25_predictor is None:
_v25_predictor = V25Predictor()
_v25_predictor.load_models()
return _v25_predictor
+291
View File
@@ -0,0 +1,291 @@
"""
V27 Pro Predictor — Odds-Free Fundamentals + Value Edge Detection
This module loads V27 ensemble models (XGBoost, LightGBM, CatBoost)
and produces market-independent probability estimates.
The key insight: V27 is trained WITHOUT odds features, so it produces
"true" probabilities unbiased by market pricing. The divergence between
V25 (odds-aware) and V27 (odds-free) predictions signals market mispricing.
"""
import json
import logging
import os
import pickle
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger(__name__)
V27_DIR = Path(__file__).parent / "v27"
class V27Predictor:
"""
Loads V27 ensemble models and provides predictions using the
82-feature odds-free vector.
"""
MARKETS = ["ms", "ou25"]
def __init__(self):
self.models: Dict[str, Dict[str, object]] = {}
self.feature_cols: List[str] = []
self._loaded = False
def load_models(self) -> bool:
"""Load all V27 ensemble models and feature column spec."""
if self._loaded:
return True
# Feature columns
cols_path = V27_DIR / "v27_feature_cols.json"
if not cols_path.exists():
logger.error("[V27] Feature columns file not found: %s", cols_path)
return False
try:
with open(cols_path, "r", encoding="utf-8") as f:
self.feature_cols = json.load(f)
logger.info("[V27] Loaded %d feature columns", len(self.feature_cols))
except Exception as e:
logger.error("[V27] Failed to load feature columns: %s", e)
return False
# Load models per market
model_types = {"xgb": "xgb", "lgb": "lgb", "cb": "cb"}
for market in self.MARKETS:
self.models[market] = {}
for short, label in model_types.items():
# Try market-specific file first: v27_ms_xgb.pkl
path = V27_DIR / f"v27_{market}_{short}.pkl"
if not path.exists():
# Fallback to generic: v27_xgboost.pkl (for MS only)
generic_names = {"xgb": "v27_xgboost.pkl", "lgb": "v27_lightgbm.pkl", "cb": "v27_catboost.pkl"}
path = V27_DIR / generic_names.get(short, "")
if not path.exists():
logger.warning("[V27] Model file not found for %s/%s", market, short)
continue
try:
with open(path, "rb") as f:
model = pickle.load(f)
self.models[market][label] = model
logger.info("[V27] ✓ Loaded %s/%s from %s", market, label, path.name)
except Exception as e:
logger.error("[V27] ✗ Failed to load %s/%s: %s", market, label, e)
loaded_count = sum(len(v) for v in self.models.values())
if loaded_count == 0:
logger.error("[V27] No models loaded!")
return False
self._loaded = True
logger.info("[V27] Total models loaded: %d across %d markets", loaded_count, len(self.models))
return True
def _build_feature_array(self, features: Dict[str, float]) -> np.ndarray:
"""
Build ordered feature array from the full feature dict.
V27 uses only its 82 features (odds-free subset).
"""
row = []
for col in self.feature_cols:
row.append(float(features.get(col, 0.0)))
return np.array([row])
def _predict_with_model(self, model, X: np.ndarray, label: str, expected_classes: int) -> Optional[np.ndarray]:
"""
Predict probabilities from a model, handling both sklearn wrappers
(predict_proba) and raw Booster objects (predict).
For raw XGBoost Boosters, DMatrix is created WITH feature_names
to match the training schema.
"""
import xgboost as xgb
import lightgbm as lgbm
import pandas as pd
# 1. Try sklearn-style predict_proba first
if hasattr(model, 'predict_proba'):
try:
proba = model.predict_proba(X)[0]
if len(proba) == expected_classes:
return proba
logger.warning("[V27] %s predict_proba returned %d classes, expected %d", label, len(proba), expected_classes)
except Exception:
pass # Fall through to raw predict
# 2. Raw xgboost.Booster — MUST pass feature_names
if isinstance(model, xgb.Booster):
try:
feature_names = self.feature_cols if self.feature_cols else None
dmat = xgb.DMatrix(X, feature_names=feature_names)
raw = model.predict(dmat)
if isinstance(raw, np.ndarray):
if raw.ndim == 2 and raw.shape[1] == expected_classes:
return raw[0]
elif raw.ndim == 1 and expected_classes == 2:
p = float(raw[0])
return np.array([1.0 - p, p])
elif raw.ndim == 1 and len(raw) == expected_classes:
return raw
except Exception as e:
logger.warning("[V27] %s xgb.Booster predict failed: %s", label, e)
return None
# 3. Raw lightgbm.Booster — pass as DataFrame with column names
if isinstance(model, lgbm.Booster):
try:
if self.feature_cols:
X_named = pd.DataFrame(X, columns=self.feature_cols)
raw = model.predict(X_named)
else:
raw = model.predict(X)
if isinstance(raw, np.ndarray):
if raw.ndim == 2 and raw.shape[1] == expected_classes:
return raw[0]
elif raw.ndim == 1 and expected_classes == 2:
p = float(raw[0])
return np.array([1.0 - p, p])
elif raw.ndim == 1 and len(raw) == expected_classes:
return raw
except Exception as e:
logger.warning("[V27] %s lgb.Booster predict failed: %s", label, e)
return None
# 4. Generic fallback (CatBoost, etc.)
try:
if hasattr(model, 'predict'):
raw = model.predict(X)
if isinstance(raw, np.ndarray):
if raw.ndim == 2 and raw.shape[1] == expected_classes:
return raw[0]
elif raw.ndim == 1 and expected_classes == 2:
p = float(raw[0])
return np.array([1.0 - p, p])
elif raw.ndim == 1 and len(raw) == expected_classes:
return raw
except Exception as e:
logger.warning("[V27] %s generic predict failed: %s", label, e)
return None
def predict_ms(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
"""
Predict Match Score probabilities (Home/Draw/Away).
Returns dict with keys: home, draw, away.
"""
if not self._loaded or "ms" not in self.models or not self.models["ms"]:
return None
X = self._build_feature_array(features)
probs_list = []
for label, model in self.models["ms"].items():
proba = self._predict_with_model(model, X, f"MS/{label}", expected_classes=3)
if proba is not None and len(proba) == 3:
probs_list.append(proba)
if not probs_list:
return None
# Ensemble average
avg = np.mean(probs_list, axis=0)
return {
"home": float(avg[0]),
"draw": float(avg[1]),
"away": float(avg[2]),
}
def predict_ou25(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
"""
Predict Over/Under 2.5 probabilities.
Returns dict with keys: under, over.
"""
if not self._loaded or "ou25" not in self.models or not self.models["ou25"]:
return None
X = self._build_feature_array(features)
probs_list = []
for label, model in self.models["ou25"].items():
proba = self._predict_with_model(model, X, f"OU25/{label}", expected_classes=2)
if proba is not None and len(proba) == 2:
probs_list.append(proba)
if not probs_list:
return None
avg = np.mean(probs_list, axis=0)
return {
"under": float(avg[0]),
"over": float(avg[1]),
}
def predict_all(self, features: Dict[str, float]) -> Dict[str, Optional[Dict[str, float]]]:
"""Run predictions for all supported markets."""
return {
"ms": self.predict_ms(features),
"ou25": self.predict_ou25(features),
}
def compute_divergence(
v25_probs: Dict[str, float],
v27_probs: Dict[str, float],
) -> Dict[str, float]:
"""
Compute the divergence signal between V25 (odds-aware) and V27 (odds-free).
Positive divergence = V27 thinks it's MORE likely than the market → VALUE BET
Negative divergence = V27 thinks it's LESS likely than the market → PASS
Returns per-outcome divergence values.
"""
divergence = {}
for key in v27_probs:
v25_val = v25_probs.get(key, 0.33)
v27_val = v27_probs.get(key, 0.33)
divergence[key] = round(v27_val - v25_val, 4)
return divergence
def compute_value_edge(
v25_probs: Dict[str, float],
v27_probs: Dict[str, float],
odds: Dict[str, float],
) -> Dict[str, Dict]:
"""
Detect value bets by combining V25/V27 divergence with odds.
A value bet exists when:
1. V27 (odds-free) probability > implied odds probability (model says it's underpriced)
2. V27 and V25 divergence is positive (V27 sees more signal than the market)
Returns per-outcome: { probability, implied_prob, edge, is_value }
"""
results = {}
for key in v27_probs:
v27_p = v27_probs[key]
v25_p = v25_probs.get(key, 0.33)
odds_val = odds.get(key, 0.0)
implied_p = (1.0 / odds_val) if odds_val > 1.01 else 0.0
divergence = v27_p - v25_p
edge = v27_p - implied_p if implied_p > 0 else 0.0
results[key] = {
"v27_prob": round(v27_p, 4),
"v25_prob": round(v25_p, 4),
"implied_prob": round(implied_p, 4),
"divergence": round(divergence, 4),
"edge": round(edge, 4),
"is_value": edge > 0.05 and divergence > 0.02, # 5% edge + 2% divergence
}
return results
+21 -7
View File
@@ -33,7 +33,7 @@ from features.upset_engine import get_upset_engine
from features.referee_engine import get_referee_engine
from features.momentum_engine import get_momentum_engine
TOP_LEAGUES_PATH = os.path.join(AI_ENGINE_DIR, "..", "top_leagues.json")
TOP_LEAGUES_PATH = os.path.join(AI_ENGINE_DIR, "..", "qualified_leagues.json")
OUTPUT_CSV = os.path.join(AI_ENGINE_DIR, "data", "training_data.csv")
# Ensure output dir exists
@@ -424,12 +424,18 @@ class BatchDataLoader:
for mid, tid, pid in self.cur.fetchall():
starting_players[(mid, tid)].append(pid)
# 5) Build combined cache
# 5) Build match_id → mst_utc mapping for temporal filtering
match_mst = {}
for m in self.matches:
match_mst[m[0]] = m[7] # m[0]=id, m[7]=mst_utc
# 6) Build combined cache — NO DATA LEAKAGE
# goals_form: avg goals from last 5 matches BEFORE this match (not this match!)
# squad_quality: only uses pre-match info (lineup, key players) — no current-match goals/assists
all_keys = set(participation.keys()) | set(events.keys())
for key in all_keys:
mid, tid = key
part = participation.get(key, {'starting_count': 0, 'total_squad': 0, 'fwd_count': 0})
evt = events.get(key, {'goals': 0, 'assists': 0, 'unique_scorers': 0})
# Count key players in starting XI
starters = starting_players.get(key, [])
@@ -437,22 +443,30 @@ class BatchDataLoader:
kp_total = len(key_players_by_team.get(tid, set()))
kp_missing = max(0, kp_total - kp_in_starting)
# Squad quality: composite score
# Squad quality: composite score — ONLY pre-match info (no current-match goals/assists!)
squad_quality = (
part['starting_count'] * 0.3 +
evt['goals'] * 2.0 +
evt['assists'] * 1.0 +
kp_in_starting * 3.0 +
part['fwd_count'] * 1.5
)
# Missing impact: how many key players are missing
missing_impact = min(kp_missing / max(kp_total, 1), 1.0)
# goals_form: avg goals from last 5 matches BEFORE this match
current_mst = match_mst.get(mid, 0)
team_history = self.team_matches.get(tid, [])
recent_goals = [
tm[2] # team_score
for tm in team_history
if tm[0] < current_mst # only matches BEFORE this one
][-5:] # last 5
goals_form = sum(recent_goals) / len(recent_goals) if recent_goals else 1.3
self.squad_cache[key] = {
'squad_quality': squad_quality,
'key_players': kp_in_starting,
'missing_impact': missing_impact,
'goals_form': evt['goals'],
'goals_form': round(goals_form, 2),
}
def _load_cards_data(self):
+225 -137
View File
@@ -1,183 +1,271 @@
"""
V25-Compatible Score Prediction Model Trainer
===============================================
Trains 4 independent XGBoost regression models for:
- FT Home Goals
- FT Away Goals
- HT Home Goals
- HT Away Goals
Uses the same 102-feature set as v25_ensemble for full compatibility.
Temporal train/test split (80/20) to avoid future leakage.
Usage:
python3 scripts/train_score_model.py
"""
import os
import sys
import pickle
import numpy as np
import pandas as pd
import xgboost as xgb
import pickle
import os
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error, r2_score
from datetime import datetime
from sklearn.metrics import mean_absolute_error, r2_score, mean_squared_error
# Paths
DATA_PATH = os.path.join(os.path.dirname(__file__), "../data/training_data.csv")
MODEL_PATH = os.path.join(os.path.dirname(__file__), "../models/xgb_score.pkl")
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Import unified 56-feature array from markets trainer
from train_xgboost_markets import FEATURES
# Config
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")
MODEL_PATH = os.path.join(AI_ENGINE_DIR, "models", "xgb_score.pkl")
# Import the EXACT same feature set as v25 market models
from train_v25_clean import FEATURES
TARGETS = ["score_home", "score_away", "ht_score_home", "ht_score_away"]
def train():
print("🚀 Training Score Prediction Model (XGBoost) - Full Time & Half Time")
print("=" * 60)
# Model hyperparameters (tuned for goal count regression)
XGB_PARAMS = {
"objective": "reg:squarederror",
"n_estimators": 1200,
"learning_rate": 0.02,
"max_depth": 6,
"subsample": 0.8,
"colsample_bytree": 0.7,
"min_child_weight": 5,
"reg_alpha": 0.1,
"reg_lambda": 1.0,
"n_jobs": -1,
"random_state": 42,
}
def load_data() -> pd.DataFrame:
"""Load and validate training data."""
if not os.path.exists(DATA_PATH):
print(f"❌ Data file not found: {DATA_PATH}")
return
print(" Run extract_training_data.py first")
sys.exit(1)
print(f"📦 Loading data from {DATA_PATH}...")
df = pd.read_csv(DATA_PATH)
# Preprocessing
# Drop rows where target is missing (should verify)
# Fill feature NaNs with 0 (same as v25 training)
for col in FEATURES:
if col in df.columns:
df[col] = df[col].fillna(0)
# Backward-compatible: add odds presence flags if missing
odds_base_columns = [
"odds_ms_h", "odds_ms_d", "odds_ms_a",
"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",
]
for base_col in odds_base_columns:
pres_col = f"{base_col}_present"
if pres_col not in df.columns and base_col in df.columns:
df[pres_col] = (df[base_col] > 1.0).astype(int)
# Drop rows where any target is missing
df = df.dropna(subset=TARGETS)
# Fill feature NaNs with median/mean or 0
print(f" Original rows: {len(df)}")
# Filter valid odds (at least ms_h > 1.0)
# Filter: at least MS odds must be present
df = df[df["odds_ms_h"] > 1.0].copy()
print(f" Rows with valid odds: {len(df)}")
X = df[FEATURES]
y_home = df["score_home"]
y_away = df["score_away"]
y_ht_home = df["ht_score_home"]
y_ht_away = df["ht_score_away"]
# Ensure all features exist
missing = [f for f in FEATURES if f not in df.columns]
if missing:
print(f"⚠️ Missing {len(missing)} features, filling with 0: {missing[:5]}...")
for f in missing:
df[f] = 0
# Train/Test Split
X_train, X_test, y_h_train, y_h_test, y_a_train, y_a_test, y_ht_h_train, y_ht_h_test, y_ht_a_train, y_ht_a_test = train_test_split(
X, y_home, y_away, y_ht_home, y_ht_away, test_size=0.2, random_state=42
return df
def temporal_split(df: pd.DataFrame, train_ratio: float = 0.80):
"""
Temporal train/test split by match date.
Ensures no future information leaks into training.
"""
if "match_date" in df.columns:
df = df.sort_values("match_date").reset_index(drop=True)
elif "round" in df.columns:
df = df.sort_values("round").reset_index(drop=True)
split_idx = int(len(df) * train_ratio)
return df.iloc[:split_idx].copy(), df.iloc[split_idx:].copy()
def train_single_model(X_train, y_train, X_test, y_test, name: str):
"""Train a single XGBoost regression model with early stopping."""
print(f"\n🏗️ Training {name} model...")
model = xgb.XGBRegressor(**XGB_PARAMS)
model.fit(
X_train, y_train,
eval_set=[(X_test, y_test)],
verbose=False,
)
print(f" Training set: {len(X_train)} matches")
print(f" Test set: {len(X_test)} matches")
preds = model.predict(X_test)
# --- HOME GOALS MODEL ---
print("\n🏠 Training Home Goals Model...")
xgb_home = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42,
early_stopping_rounds=50 # Configure here for newer XGBoost or remove if not supported in constructor (depends on version)
)
# Actually, to be safe across versions, let's remove early stopping for now or use validation set properly
# Using 'eval_set' without early_stopping_rounds just prints metrics
xgb_home = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42
)
xgb_home.fit(X_train, y_h_train, eval_set=[(X_test, y_h_test)], verbose=False)
mae = mean_absolute_error(y_test, preds)
rmse = np.sqrt(mean_squared_error(y_test, preds))
r2 = r2_score(y_test, preds)
home_preds = xgb_home.predict(X_test)
mae_home = mean_absolute_error(y_h_test, home_preds)
r2_home = r2_score(y_h_test, home_preds)
print(f" ✅ FT Home MAE: {mae_home:.4f} goals")
print(f" ✅ FT Home R2: {r2_home:.4f}")
print(f" MAE: {mae:.4f} goals")
print(f" RMSE: {rmse:.4f}")
print(f" R²: {r2:.4f}")
# --- AWAY GOALS MODEL ---
print("\n✈️ Training FT Away Goals Model...")
xgb_away = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42
)
xgb_away.fit(X_train, y_a_train, eval_set=[(X_test, y_a_test)], verbose=False)
return model, {"mae": mae, "rmse": rmse, "r2": r2}
away_preds = xgb_away.predict(X_test)
mae_away = mean_absolute_error(y_a_test, away_preds)
r2_away = r2_score(y_a_test, away_preds)
print(f" ✅ FT Away MAE: {mae_away:.4f} goals")
print(f" ✅ FT Away R2: {r2_away:.4f}")
# --- HT HOME GOALS MODEL ---
print("\n🏠 Training HT Home Goals Model...")
xgb_ht_home = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42
)
xgb_ht_home.fit(X_train, y_ht_h_train, eval_set=[(X_test, y_ht_h_test)], verbose=False)
def evaluate_combined(models: dict, X_test, y_test_dict: dict):
"""Evaluate combined score accuracy (FT and HT)."""
print("\n🎯 Combined Score Evaluation (Test Set):")
ht_home_preds = xgb_ht_home.predict(X_test)
mae_ht_home = mean_absolute_error(y_ht_h_test, ht_home_preds)
print(f" ✅ HT Home MAE: {mae_ht_home:.4f} goals")
# FT Score
ft_h_preds = models["ft_home"].predict(X_test)
ft_a_preds = models["ft_away"].predict(X_test)
# --- HT AWAY GOALS MODEL ---
print("\n✈️ Training HT Away Goals Model...")
xgb_ht_away = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42
)
xgb_ht_away.fit(X_train, y_ht_a_train, eval_set=[(X_test, y_ht_a_test)], verbose=False)
y_ft_h = y_test_dict["score_home"].values
y_ft_a = y_test_dict["score_away"].values
ht_away_preds = xgb_ht_away.predict(X_test)
mae_ht_away = mean_absolute_error(y_ht_a_test, ht_away_preds)
print(f" ✅ HT Away MAE: {mae_ht_away:.4f} goals")
exact = 0
close = 0
result_correct = 0
total = len(X_test)
# --- EVALUATE EXACT SCORE ACCURACY (ROUNDED) ---
print("\n🎯 Exact FT Score Accuracy (Test Set):")
correct = 0
close = 0 # Within 1 goal diff for both
for h_true, a_true, h_pred, a_pred in zip(y_ft_h, y_ft_a, ft_h_preds, ft_a_preds):
hp = max(0, round(h_pred))
ap = max(0, round(a_pred))
for h_true, a_true, h_pred, a_pred in zip(y_h_test, y_a_test, home_preds, away_preds):
h_p = round(h_pred)
a_p = round(a_pred)
if h_p == h_true and a_p == a_true:
correct += 1
if abs(h_p - h_true) <= 1 and abs(a_p - a_true) <= 1:
# Exact score
if hp == h_true and ap == a_true:
exact += 1
# Close (±1 each)
if abs(hp - h_true) <= 1 and abs(ap - a_true) <= 1:
close += 1
acc = correct / len(X_test) * 100
close_acc = close / len(X_test) * 100
print(f" Exact Match: {acc:.2f}%")
print(f" Close Match (+/- 1 goal): {close_acc:.2f}%")
# Result direction (1X2)
true_result = 1 if h_true > a_true else (0 if h_true == a_true else -1)
pred_result = 1 if hp > ap else (0 if hp == ap else -1)
if true_result == pred_result:
result_correct += 1
print(f" FT Exact Score: {exact / total * 100:.2f}% ({exact}/{total})")
print(f" FT Close (±1): {close / total * 100:.2f}% ({close}/{total})")
print(f" FT Result (1X2): {result_correct / total * 100:.2f}% ({result_correct}/{total})")
# HT Score
ht_h_preds = models["ht_home"].predict(X_test)
ht_a_preds = models["ht_away"].predict(X_test)
y_ht_h = y_test_dict["ht_score_home"].values
y_ht_a = y_test_dict["ht_score_away"].values
ht_exact = 0
ht_total = len(X_test)
for h_true, a_true, h_pred, a_pred in zip(y_ht_h, y_ht_a, ht_h_preds, ht_a_preds):
hp = max(0, round(h_pred))
ap = max(0, round(a_pred))
if hp == h_true and ap == a_true:
ht_exact += 1
print(f" HT Exact Score: {ht_exact / ht_total * 100:.2f}% ({ht_exact}/{ht_total})")
return {
"ft_exact_pct": exact / total * 100,
"ft_close_pct": close / total * 100,
"ft_result_pct": result_correct / total * 100,
"ht_exact_pct": ht_exact / ht_total * 100,
}
def train():
"""Main training pipeline."""
print("🚀 Score Prediction Model Trainer (V25-Compatible)")
print(f" Feature count: {len(FEATURES)}")
print("=" * 60)
# Load data
df = load_data()
print(f" Total valid rows: {len(df)}")
# Temporal split
train_df, test_df = temporal_split(df)
print(f" Training set: {len(train_df)} matches")
print(f" Test set: {len(test_df)} matches (temporally after training)")
X_train = train_df[FEATURES]
X_test = test_df[FEATURES]
# Train 4 models
models = {}
metrics = {}
for target_name, model_key in [
("score_home", "ft_home"),
("score_away", "ft_away"),
("ht_score_home", "ht_home"),
("ht_score_away", "ht_away"),
]:
model, metric = train_single_model(
X_train, train_df[target_name],
X_test, test_df[target_name],
model_key,
)
models[model_key] = model
metrics[model_key] = metric
# Combined evaluation
y_test_dict = {t: test_df[t] for t in TARGETS}
combined = evaluate_combined(models, X_test, y_test_dict)
# Save
print(f"\n💾 Saving models to {MODEL_PATH}...")
print(f"\n💾 Saving to {MODEL_PATH}...")
model_data = {
"home_model": xgb_home,
"away_model": xgb_away,
"ht_home_model": xgb_ht_home,
"ht_away_model": xgb_ht_away,
"home_model": models["ft_home"],
"away_model": models["ft_away"],
"ht_home_model": models["ht_home"],
"ht_away_model": models["ht_away"],
"features": FEATURES,
"meta": {
"mae_home": mae_home,
"mae_away": mae_away,
"mae_ht_home": mae_ht_home,
"mae_ht_away": mae_ht_away,
"acc": acc
}
**{f"{k}_{mk}": mv for k, m in metrics.items() for mk, mv in m.items()},
**combined,
"trained_at": datetime.now().isoformat(),
"feature_count": len(FEATURES),
"train_size": len(train_df),
"test_size": len(test_df),
},
}
with open(MODEL_PATH, "wb") as f:
pickle.dump(model_data, f)
print("✅ Done.")
print("\n✅ Score model training complete!")
print(f" Saved: {MODEL_PATH}")
if __name__ == "__main__":
train()
+307
View File
@@ -0,0 +1,307 @@
"""
Update Implied Odds in football_ai_features
=============================================
Populates implied_home, implied_draw, implied_away, implied_over25, implied_btts
from real odds data in odd_categories + odd_selections tables.
Also backfills form-based features (home_goals_avg_5, away_goals_avg_5, etc.)
from recent match history.
Usage:
python3 scripts/update_implied_odds.py
"""
import os
import sys
import time
import psycopg2
from dotenv import load_dotenv
load_dotenv()
def get_conn():
db_url = os.getenv("DATABASE_URL", "").split("?schema=")[0]
return psycopg2.connect(db_url)
def update_implied_odds(conn):
"""Update implied probabilities from real odds data."""
cur = conn.cursor()
print("📊 Phase 1: Updating implied odds from real market data...")
t0 = time.time()
# Step 1: Build odds lookup from odd_categories + odd_selections
print(" Loading odds data...")
cur.execute("""
SELECT oc.match_id, oc.name AS cat_name, os.name AS sel_name, os.odd_value
FROM odd_selections os
JOIN odd_categories oc ON os.odd_category_db_id = oc.db_id
WHERE os.odd_value IS NOT NULL
AND CAST(os.odd_value AS FLOAT) > 1.0
""")
odds_by_match = {}
row_count = 0
for match_id, cat_name, sel_name, odd_val in cur.fetchall():
try:
v = float(odd_val)
if v <= 1.0:
continue
except (ValueError, TypeError):
continue
if match_id not in odds_by_match:
odds_by_match[match_id] = {}
cat_lower = (cat_name or "").lower().strip()
sel_lower = (sel_name or "").lower().strip()
# Match Result (1X2)
if cat_lower == 'maç sonucu':
if sel_name == '1':
odds_by_match[match_id]['ms_h'] = v
elif sel_name in ('0', 'X'):
odds_by_match[match_id]['ms_d'] = v
elif sel_name == '2':
odds_by_match[match_id]['ms_a'] = v
# Over/Under 2.5
elif cat_lower == '2,5 alt/üst':
if 'üst' in sel_lower:
odds_by_match[match_id]['ou25_o'] = v
elif 'alt' in sel_lower:
odds_by_match[match_id]['ou25_u'] = v
# BTTS
elif cat_lower == 'karşılıklı gol':
if 'var' in sel_lower:
odds_by_match[match_id]['btts_y'] = v
elif 'yok' in sel_lower:
odds_by_match[match_id]['btts_n'] = v
row_count += 1
print(f" Loaded odds for {len(odds_by_match)} matches ({row_count} selections) in {time.time()-t0:.1f}s")
# Step 2: Calculate implied probabilities and update
print(" Calculating implied probabilities...")
# Get all match_ids in football_ai_features
cur.execute("SELECT match_id FROM football_ai_features")
feature_match_ids = {row[0] for row in cur.fetchall()}
updated = 0
batch_size = 500
updates = []
for match_id in feature_match_ids:
odds = odds_by_match.get(match_id, {})
if not odds:
continue
# Implied MS probabilities (vig-free normalization)
ms_h = odds.get('ms_h', 0)
ms_d = odds.get('ms_d', 0)
ms_a = odds.get('ms_a', 0)
implied_home = 0.33
implied_draw = 0.33
implied_away = 0.33
if ms_h > 1.0 and ms_d > 1.0 and ms_a > 1.0:
raw_sum = (1 / ms_h) + (1 / ms_d) + (1 / ms_a)
if raw_sum > 0:
implied_home = round((1 / ms_h) / raw_sum, 4)
implied_draw = round((1 / ms_d) / raw_sum, 4)
implied_away = round((1 / ms_a) / raw_sum, 4)
# Implied OU25
ou25_o = odds.get('ou25_o', 0)
ou25_u = odds.get('ou25_u', 0)
implied_over25 = 0.50
if ou25_o > 1.0 and ou25_u > 1.0:
raw_sum = (1 / ou25_o) + (1 / ou25_u)
if raw_sum > 0:
implied_over25 = round((1 / ou25_o) / raw_sum, 4)
# Implied BTTS
btts_y = odds.get('btts_y', 0)
btts_n = odds.get('btts_n', 0)
implied_btts = 0.50
if btts_y > 1.0 and btts_n > 1.0:
raw_sum = (1 / btts_y) + (1 / btts_n)
if raw_sum > 0:
implied_btts = round((1 / btts_y) / raw_sum, 4)
# Only update if we have real data (not all defaults)
has_real_data = (ms_h > 1.0 or ou25_o > 1.0 or btts_y > 1.0)
if not has_real_data:
continue
updates.append((
implied_home, implied_draw, implied_away,
implied_over25, implied_btts, match_id
))
if len(updates) >= batch_size:
cur.executemany("""
UPDATE football_ai_features
SET implied_home = %s,
implied_draw = %s,
implied_away = %s,
implied_over25 = %s,
implied_btts_yes = %s
WHERE match_id = %s
""", updates)
updated += len(updates)
updates = []
# Final batch
if updates:
cur.executemany("""
UPDATE football_ai_features
SET implied_home = %s,
implied_draw = %s,
implied_away = %s,
implied_over25 = %s,
implied_btts_yes = %s
WHERE match_id = %s
""", updates)
updated += len(updates)
conn.commit()
print(f" ✅ Updated implied odds for {updated} matches in {time.time()-t0:.1f}s")
return updated
def update_form_features(conn):
"""Backfill form-based features (goals avg, clean sheet rate) from match history."""
cur = conn.cursor()
print("\n📊 Phase 2: Updating form-based features...")
t0 = time.time()
# Load all finished football matches ordered by time
print(" Loading match history...")
cur.execute("""
SELECT id, home_team_id, away_team_id, score_home, score_away, mst_utc
FROM matches
WHERE status = 'FT'
AND score_home IS NOT NULL
AND sport = 'football'
ORDER BY mst_utc ASC
""")
matches = cur.fetchall()
print(f" Loaded {len(matches)} finished matches")
# Build team history incrementally
from collections import defaultdict
team_history = defaultdict(list) # team_id -> [(goals_scored, goals_conceded)]
# Get all feature match IDs
cur.execute("SELECT match_id FROM football_ai_features")
feature_match_ids = {row[0] for row in cur.fetchall()}
updated = 0
batch_size = 500
updates = []
for match_id, home_id, away_id, score_home, score_away, mst_utc in matches:
# Calculate features BEFORE updating history (pre-match features)
if match_id in feature_match_ids:
h_hist = team_history[home_id][-5:] # last 5
a_hist = team_history[away_id][-5:]
# Home team form
if h_hist:
h_goals_avg = sum(g for g, _ in h_hist) / len(h_hist)
h_conceded_avg = sum(c for _, c in h_hist) / len(h_hist)
h_cs_rate = sum(1 for _, c in h_hist if c == 0) / len(h_hist)
h_scoring_rate = sum(1 for g, _ in h_hist if g > 0) / len(h_hist)
else:
h_goals_avg, h_conceded_avg = 1.3, 1.2
h_cs_rate, h_scoring_rate = 0.25, 0.75
# Away team form
if a_hist:
a_goals_avg = sum(g for g, _ in a_hist) / len(a_hist)
a_conceded_avg = sum(c for _, c in a_hist) / len(a_hist)
a_cs_rate = sum(1 for _, c in a_hist if c == 0) / len(a_hist)
a_scoring_rate = sum(1 for g, _ in a_hist if g > 0) / len(a_hist)
else:
a_goals_avg, a_conceded_avg = 1.3, 1.2
a_cs_rate, a_scoring_rate = 0.25, 0.75
updates.append((
round(h_goals_avg, 3), round(h_conceded_avg, 3),
round(h_cs_rate, 3), round(h_scoring_rate, 3),
round(a_goals_avg, 3), round(a_conceded_avg, 3),
round(a_cs_rate, 3), round(a_scoring_rate, 3),
match_id
))
if len(updates) >= batch_size:
cur.executemany("""
UPDATE football_ai_features
SET home_goals_avg_5 = %s,
home_conceded_avg_5 = %s,
home_clean_sheet_rate = %s,
home_scoring_rate = %s,
away_goals_avg_5 = %s,
away_conceded_avg_5 = %s,
away_clean_sheet_rate = %s,
away_scoring_rate = %s
WHERE match_id = %s
""", updates)
updated += len(updates)
updates = []
# Update history AFTER feature extraction (maintains pre-match invariant)
team_history[home_id].append((score_home, score_away))
team_history[away_id].append((score_away, score_home))
# Final batch
if updates:
cur.executemany("""
UPDATE football_ai_features
SET home_goals_avg_5 = %s,
home_conceded_avg_5 = %s,
home_clean_sheet_rate = %s,
home_scoring_rate = %s,
away_goals_avg_5 = %s,
away_conceded_avg_5 = %s,
away_clean_sheet_rate = %s,
away_scoring_rate = %s
WHERE match_id = %s
""", updates)
updated += len(updates)
conn.commit()
print(f" ✅ Updated form features for {updated} matches in {time.time()-t0:.1f}s")
return updated
def main():
print("🚀 Football AI Features — Implied Odds & Form Backfill")
print("=" * 60)
conn = get_conn()
try:
odds_updated = update_implied_odds(conn)
form_updated = update_form_features(conn)
print(f"\n✅ DONE!")
print(f" Implied odds updated: {odds_updated} matches")
print(f" Form features updated: {form_updated} matches")
finally:
conn.close()
if __name__ == "__main__":
main()
+497
View File
@@ -0,0 +1,497 @@
"""
Deterministic betting judge for prediction packages.
The model layer estimates event probabilities. BettingBrain decides whether
those probabilities are trustworthy enough to risk money.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
class BettingBrain:
MIN_ODDS = 1.30
MIN_BET_SCORE = 72.0
MIN_WATCH_SCORE = 62.0
MIN_BAND_SAMPLE = 8
HARD_DIVERGENCE = 0.22
SOFT_DIVERGENCE = 0.14
EXTREME_MODEL_PROB = 0.85
EXTREME_GAP = 0.30
MARKET_PRIORS = {
"DC": 4.0,
"OU15": 3.0,
"OU25": 2.0,
"BTTS": 0.0,
"MS": -2.0,
"OU35": -2.0,
"HT": -6.0,
"HTFT": -12.0,
"CARDS": -5.0,
"OE": -8.0,
}
def judge(self, package: Dict[str, Any]) -> Dict[str, Any]:
v27_engine = package.get("v27_engine")
if not isinstance(v27_engine, dict):
return package
guarded = dict(package)
rows = self._collect_rows(guarded)
if not rows:
return guarded
judged_rows: Dict[str, Dict[str, Any]] = {}
decisions: List[Dict[str, Any]] = []
for row in rows:
key = self._row_key(row)
judged = self._judge_row(dict(row), guarded)
judged_rows[key] = judged
decisions.append(judged["betting_brain"])
approved = [
row for row in judged_rows.values()
if row.get("betting_brain", {}).get("action") == "BET"
]
watchlist = [
row for row in judged_rows.values()
if row.get("betting_brain", {}).get("action") == "WATCH"
]
approved.sort(key=self._candidate_sort_key, reverse=True)
watchlist.sort(key=self._candidate_sort_key, reverse=True)
original_main = guarded.get("main_pick") or {}
main_pick = None
decision = "NO_BET"
decision_reason = "No candidate passed the betting brain evidence gates."
if approved:
main_pick = dict(approved[0])
main_pick["is_guaranteed"] = bool(main_pick.get("betting_brain", {}).get("score", 0.0) >= 82.0)
main_pick["pick_reason"] = "betting_brain_approved"
decision = "BET"
decision_reason = main_pick.get("betting_brain", {}).get("summary", "Evidence is aligned.")
elif watchlist:
main_pick = dict(watchlist[0])
self._force_no_bet(main_pick, "betting_brain_watchlist")
decision = "WATCHLIST"
decision_reason = main_pick.get("betting_brain", {}).get("summary", "Interesting but not clean enough.")
elif original_main:
main_pick = dict(judged_rows.get(self._row_key(original_main), original_main))
self._force_no_bet(main_pick, "betting_brain_no_safe_pick")
main_key = self._row_key(main_pick) if main_pick else ""
supporting = [
dict(row)
for row in judged_rows.values()
if self._row_key(row) != main_key
]
supporting.sort(key=self._candidate_sort_key, reverse=True)
bet_summary = [
self._summary_item(row)
for row in sorted(judged_rows.values(), key=self._candidate_sort_key, reverse=True)
]
guarded["main_pick"] = main_pick
guarded["value_pick"] = self._pick_value_candidate(judged_rows, main_key)
guarded["supporting_picks"] = supporting[:6]
guarded["bet_summary"] = bet_summary
playable = decision == "BET" and bool(main_pick and main_pick.get("playable"))
advice = dict(guarded.get("bet_advice") or {})
advice["playable"] = playable
advice["suggested_stake_units"] = float(main_pick.get("stake_units", 0.0)) if playable else 0.0
advice["reason"] = "betting_brain_approved" if playable else "betting_brain_no_bet"
advice["decision"] = decision
advice["confidence_band"] = self._decision_band(main_pick)
guarded["bet_advice"] = advice
rejected = [d for d in decisions if d.get("action") == "REJECT"]
guarded["betting_brain"] = {
"version": "judge-v1",
"decision": decision,
"reason": decision_reason,
"main_pick_key": main_key or None,
"approved_count": len(approved),
"watchlist_count": len(watchlist),
"rejected_count": len(rejected),
"top_candidates": self._top_decisions(decisions),
"rules": {
"min_bet_score": self.MIN_BET_SCORE,
"min_watch_score": self.MIN_WATCH_SCORE,
"min_band_sample": self.MIN_BAND_SAMPLE,
"hard_divergence": self.HARD_DIVERGENCE,
"soft_divergence": self.SOFT_DIVERGENCE,
"extreme_model_probability": self.EXTREME_MODEL_PROB,
"extreme_model_market_gap": self.EXTREME_GAP,
},
}
guarded["upper_brain"] = guarded["betting_brain"]
guarded.setdefault("analysis_details", {})
guarded["analysis_details"]["betting_brain_applied"] = True
guarded["analysis_details"]["betting_brain_decision"] = decision
return guarded
def _judge_row(self, row: Dict[str, Any], package: Dict[str, Any]) -> Dict[str, Any]:
market = str(row.get("market") or "")
pick = str(row.get("pick") or "")
model_prob = self._market_probability(row, package)
odds = self._safe_float(row.get("odds"), 0.0) or 0.0
implied = (1.0 / odds) if odds > 1.0 else 0.0
model_gap = (model_prob - implied) if model_prob is not None and implied > 0 else None
calibrated_conf = self._safe_float(row.get("calibrated_confidence", row.get("confidence")), 0.0) or 0.0
play_score = self._safe_float(row.get("play_score"), 0.0) or 0.0
ev_edge = self._safe_float(row.get("ev_edge", row.get("edge")), 0.0) or 0.0
v27_prob = self._v27_probability(market, pick, package.get("v27_engine") or {})
divergence = abs(model_prob - v27_prob) if model_prob is not None and v27_prob is not None else None
triple_key = self._triple_key(market, pick)
triple = self._triple_value(package, triple_key)
band_sample = int(self._safe_float((triple or {}).get("band_sample"), 0.0) or 0.0)
triple_is_value = bool((triple or {}).get("is_value"))
consensus = str((package.get("v27_engine") or {}).get("consensus") or "").upper()
positives: List[str] = []
issues: List[str] = []
vetoes: List[str] = []
score = 0.0
if row.get("playable"):
score += 18.0
positives.append("base_model_playable")
else:
score -= 18.0
issues.append("base_model_not_playable")
score += max(0.0, min(20.0, calibrated_conf * 0.22))
score += max(-8.0, min(16.0, ev_edge * 45.0))
score += max(0.0, min(14.0, play_score * 0.12))
score += self.MARKET_PRIORS.get(market, -3.0)
data_quality = package.get("data_quality") or {}
quality_score = self._safe_float(data_quality.get("score"), 0.6) or 0.6
score += max(-8.0, min(6.0, (quality_score - 0.55) * 16.0))
risk = str((package.get("risk") or {}).get("level") or "MEDIUM").upper()
score += {"LOW": 5.0, "MEDIUM": 0.0, "HIGH": -12.0, "EXTREME": -22.0}.get(risk, -4.0)
if odds < self.MIN_ODDS:
vetoes.append("odds_below_minimum")
if calibrated_conf < 38.0:
vetoes.append("calibrated_confidence_too_low")
if play_score < 50.0:
vetoes.append("play_score_too_low")
if divergence is not None:
if divergence >= self.HARD_DIVERGENCE:
score -= 42.0
vetoes.append("v25_v27_hard_disagreement")
elif divergence >= self.SOFT_DIVERGENCE:
score -= 18.0
issues.append("v25_v27_soft_disagreement")
else:
score += 11.0
positives.append("v25_v27_aligned")
if isinstance(triple, dict):
if triple_is_value:
score += 18.0
positives.append("triple_value_confirmed")
elif market in {"DC", "MS", "OU25", "BTTS"}:
score -= 18.0
issues.append("triple_value_not_confirmed")
if band_sample >= 25:
score += 8.0
positives.append("strong_historical_sample")
elif band_sample >= self.MIN_BAND_SAMPLE:
score += 3.0
positives.append("usable_historical_sample")
else:
score -= 16.0
issues.append("historical_sample_too_low")
if market == "DC":
vetoes.append("dc_without_historical_sample")
elif market in {"MS", "DC", "OU25"}:
score -= 10.0
issues.append("missing_triple_value_evidence")
if consensus == "DISAGREE" and market in {"MS", "DC"}:
score -= 12.0
issues.append("engine_consensus_disagree")
if (
model_prob is not None
and model_gap is not None
and model_prob >= self.EXTREME_MODEL_PROB
and model_gap >= self.EXTREME_GAP
and not triple_is_value
):
score -= 24.0
vetoes.append("extreme_probability_without_evidence")
if market in {"HT", "HTFT", "OE"} and score < 86.0:
vetoes.append("volatile_market_requires_exceptional_evidence")
score = max(0.0, min(100.0, score))
action = "BET"
if vetoes:
action = "REJECT"
elif score < self.MIN_WATCH_SCORE:
action = "REJECT"
elif score < self.MIN_BET_SCORE:
action = "WATCH"
row["betting_brain"] = {
"action": action,
"score": round(score, 1),
"summary": self._summary(action, market, pick, positives, issues, vetoes),
"positives": positives[:5],
"issues": issues[:6],
"vetoes": vetoes[:6],
"model_prob": round(model_prob, 4) if model_prob is not None else None,
"implied_prob": round(implied, 4),
"model_market_gap": round(model_gap, 4) if model_gap is not None else None,
"v27_prob": round(v27_prob, 4) if v27_prob is not None else None,
"divergence": round(divergence, 4) if divergence is not None else None,
"triple_key": triple_key,
"triple_value": triple,
}
if action != "BET":
self._force_no_bet(row, f"betting_brain_{action.lower()}")
else:
row["is_guaranteed"] = bool(score >= 82.0)
row["pick_reason"] = "betting_brain_approved"
row["stake_units"] = self._brain_stake(row, score)
row["bet_grade"] = "A" if score >= 82.0 else "B"
row["playable"] = True
self._append_reason(row, f"betting_brain_{action.lower()}_{round(score)}")
return row
def _collect_rows(self, package: Dict[str, Any]) -> List[Dict[str, Any]]:
rows: Dict[str, Dict[str, Any]] = {}
for source in ("main_pick", "value_pick"):
item = package.get(source)
if isinstance(item, dict) and item.get("market"):
rows[self._row_key(item)] = dict(item)
for source in ("supporting_picks", "bet_summary"):
for item in package.get(source) or []:
if isinstance(item, dict) and item.get("market"):
key = self._row_key(item)
rows[key] = self._merge_row(rows.get(key), item)
return list(rows.values())
@staticmethod
def _merge_row(existing: Optional[Dict[str, Any]], incoming: Dict[str, Any]) -> Dict[str, Any]:
if existing is None:
return dict(incoming)
merged = dict(incoming)
merged.update({k: v for k, v in existing.items() if v is not None})
for key in ("decision_reasons", "reasons"):
reasons = list(existing.get(key) or []) + list(incoming.get(key) or [])
if reasons:
merged[key] = list(dict.fromkeys(reasons))
return merged
def _pick_value_candidate(self, rows: Dict[str, Dict[str, Any]], main_key: str) -> Optional[Dict[str, Any]]:
candidates = [
row for key, row in rows.items()
if key != main_key
and row.get("betting_brain", {}).get("action") in {"BET", "WATCH"}
and (self._safe_float(row.get("odds"), 0.0) or 0.0) >= 1.60
]
candidates.sort(key=self._candidate_sort_key, reverse=True)
return dict(candidates[0]) if candidates else None
def _summary_item(self, row: Dict[str, Any]) -> Dict[str, Any]:
reasons = list(row.get("decision_reasons") or row.get("reasons") or [])
return {
"market": row.get("market"),
"pick": row.get("pick"),
"raw_confidence": row.get("raw_confidence", row.get("confidence")),
"calibrated_confidence": row.get("calibrated_confidence", row.get("confidence")),
"bet_grade": row.get("bet_grade", "PASS"),
"playable": bool(row.get("playable")),
"stake_units": float(row.get("stake_units", 0.0) or 0.0),
"play_score": row.get("play_score", 0.0),
"ev_edge": row.get("ev_edge", row.get("edge", 0.0)),
"implied_prob": row.get("implied_prob", 0.0),
"odds_reliability": row.get("odds_reliability", 0.35),
"odds": row.get("odds", 0.0),
"reasons": reasons[:6],
"betting_brain": row.get("betting_brain"),
}
@staticmethod
def _candidate_sort_key(row: Dict[str, Any]) -> Tuple[float, float, float]:
brain = row.get("betting_brain") or {}
action_boost = {"BET": 2.0, "WATCH": 1.0, "REJECT": 0.0}.get(str(brain.get("action")), 0.0)
return (
action_boost,
float(brain.get("score", 0.0) or 0.0),
float(row.get("play_score", 0.0) or 0.0),
)
@staticmethod
def _row_key(row: Optional[Dict[str, Any]]) -> str:
if not isinstance(row, dict):
return ""
return f"{row.get('market')}:{row.get('pick')}"
def _force_no_bet(self, row: Dict[str, Any], reason: str) -> None:
row["playable"] = False
row["stake_units"] = 0.0
row["bet_grade"] = "PASS"
row["is_guaranteed"] = False
row["pick_reason"] = reason
if row.get("signal_tier") == "CORE":
row["signal_tier"] = "PASS"
self._append_reason(row, reason)
@staticmethod
def _append_reason(row: Dict[str, Any], reason: str) -> None:
key = "decision_reasons" if "decision_reasons" in row else "reasons"
reasons = list(row.get(key) or [])
if reason not in reasons:
reasons.append(reason)
row[key] = reasons[:6]
def _brain_stake(self, row: Dict[str, Any], score: float) -> float:
existing = self._safe_float(row.get("stake_units"), 0.0) or 0.0
odds = self._safe_float(row.get("odds"), 0.0) or 0.0
if odds <= 1.0:
return 0.0
cap = 2.0 if score >= 82.0 else 1.2
if score < 78.0:
cap = 0.8
return round(max(0.25, min(existing if existing > 0 else cap, cap)), 1)
@staticmethod
def _decision_band(main_pick: Optional[Dict[str, Any]]) -> str:
if not main_pick:
return "LOW"
score = float((main_pick.get("betting_brain") or {}).get("score", 0.0) or 0.0)
if score >= 82.0:
return "HIGH"
if score >= 72.0:
return "MEDIUM"
return "LOW"
@staticmethod
def _top_decisions(decisions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
ordered = sorted(decisions, key=lambda d: float(d.get("score", 0.0) or 0.0), reverse=True)
return [
{
"action": item.get("action"),
"score": item.get("score"),
"summary": item.get("summary"),
"vetoes": item.get("vetoes", []),
"issues": item.get("issues", []),
}
for item in ordered[:5]
]
@staticmethod
def _summary(action: str, market: str, pick: str, positives: List[str], issues: List[str], vetoes: List[str]) -> str:
if action == "BET":
return f"{market} {pick} approved: evidence is aligned enough for a controlled stake."
if action == "WATCH":
return f"{market} {pick} is interesting but not clean enough for stake."
if vetoes:
return f"{market} {pick} rejected: {', '.join(vetoes[:3])}."
if issues:
return f"{market} {pick} rejected: {', '.join(issues[:3])}."
return f"{market} {pick} rejected by evidence score."
def _market_probability(self, row: Dict[str, Any], package: Dict[str, Any]) -> Optional[float]:
direct = self._safe_float(row.get("probability"))
if direct is not None:
return direct
board = package.get("market_board") or {}
payload = board.get(str(row.get("market") or "")) if isinstance(board, dict) else None
probs = payload.get("probs") if isinstance(payload, dict) else None
if not isinstance(probs, dict):
return None
key = self._prob_key(str(row.get("market") or ""), str(row.get("pick") or ""))
return self._safe_float(probs.get(key)) if key else None
def _v27_probability(self, market: str, pick: str, v27_engine: Dict[str, Any]) -> Optional[float]:
predictions = v27_engine.get("predictions") or {}
ms = predictions.get("ms") or {}
ou25 = predictions.get("ou25") or {}
if market == "MS":
return self._safe_float(ms.get({"1": "home", "X": "draw", "2": "away"}.get(pick, "")))
if market == "DC":
home = self._safe_float(ms.get("home"), 0.0) or 0.0
draw = self._safe_float(ms.get("draw"), 0.0) or 0.0
away = self._safe_float(ms.get("away"), 0.0) or 0.0
return {"1X": home + draw, "X2": draw + away, "12": home + away}.get(pick)
if market == "OU25":
key = self._prob_key(market, pick)
return self._safe_float(ou25.get(key)) if key else None
return None
def _triple_value(self, package: Dict[str, Any], key: Optional[str]) -> Optional[Dict[str, Any]]:
if not key:
return None
value = ((package.get("v27_engine") or {}).get("triple_value") or {}).get(key)
return value if isinstance(value, dict) else None
def _triple_key(self, market: str, pick: str) -> Optional[str]:
prob_key = self._prob_key(market, pick)
if market == "MS":
return {"1": "home", "2": "away"}.get(pick)
if market == "DC" and pick.upper() in {"1X", "X2", "12"}:
return f"dc_{pick.lower()}"
if market in {"OU15", "OU25", "OU35"} and prob_key == "over":
return f"{market.lower()}_over"
if market == "BTTS" and prob_key == "yes":
return "btts_yes"
if market == "HT":
return {"1": "ht_home", "2": "ht_away"}.get(pick)
if market in {"HT_OU05", "HT_OU15"} and prob_key == "over":
return f"{market.lower()}_over"
if market == "OE" and prob_key == "odd":
return "oe_odd"
if market == "CARDS" and prob_key == "over":
return "cards_over"
if market == "HTFT" and "/" in pick:
return f"htft_{pick.replace('/', '').lower()}"
return None
@staticmethod
def _prob_key(market: str, pick: str) -> Optional[str]:
norm = str(pick or "").strip().casefold()
if market in {"MS", "HT", "HCAP"}:
return pick if pick in {"1", "X", "2"} else None
if market == "DC":
return pick.upper() if pick.upper() in {"1X", "X2", "12"} else None
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
if "over" in norm or "ust" in norm or "üst" in norm:
return "over"
if "under" in norm or "alt" in norm:
return "under"
if market == "BTTS":
if "yes" in norm or "var" in norm:
return "yes"
if "no" in norm or "yok" in norm:
return "no"
if market == "OE":
if "odd" in norm or "tek" in norm:
return "odd"
if "even" in norm or "cift" in norm or "çift" in norm:
return "even"
if market == "HTFT" and "/" in pick:
return pick
return None
@staticmethod
def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return default
File diff suppressed because it is too large Load Diff
+63 -50
View File
@@ -1,6 +1,6 @@
# Social Poster Modülü — Otomatik Sosyal Medya Paylaşım Sistemi
Son güncelleme: 1 Mart 2026
Son güncelleme: 5 Mayıs 2026
---
@@ -13,11 +13,11 @@ Top liglerdeki maçların AI tahminlerini **otomatik olarak görselleştirip** I
## 2. Mimari Akış
```
Cron (*/10 dk) → LiveMatch sorgusu (top_leagues.json filtresi)
Cron (*/15 dk) → LiveMatch sorgusu (top_leagues.json filtresi)
→ AI Engine V20+ POST /v20plus/analyze/{match_id}
→ PredictionCardDto oluştur
→ Node Canvas ile 1080x1920 PNG render
→ Gemini ile Türkçe caption üret
→ Node Canvas ile futbol/basketbol 1080x1080 JPEG render
Ollama/Gemini ile Türkçe SEO uyumlu caption üret
→ Twitter / Facebook / Instagram API'ye paylaş
```
@@ -44,41 +44,46 @@ src/modules/social-poster/
### 4.1 SocialPosterService
**Cron:** Her 10 dakikada bir çalışır. 2540 dakika içinde başlayacak maçları `top_leagues.json` filtresiyle bulur.
**Cron:** Her 15 dakikada bir çalışır. Varsayılan olarak 2545 dakika içinde başlayacak futbol ve basketbol maçlarını `top_leagues.json` filtresiyle bulur.
**Tekrar paylaşım koruması:** Başarılı platform paylaşımı alan maç ID'leri `storage/social-poster-posted.json` içinde son 500 kayıt olarak tutulur. Servis restart sonrası aynı maç tekrar paylaşılmaz.
**Pipeline:** `predictAndPost(match)` → Tahmin al → Görsel üret → Caption üret → Paylaş
**AI Engine İsteği:**
```typescript
// POST — GET değil! AI Engine v20plus POST bekler.
axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 })
axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, {
timeout: 30000,
});
```
**Veri Haritalandırma (V20+ → CardDto):**
| V20+ Response Alanı | CardDto Alanı |
|---|---|
| `score_prediction.ht` | `htScore` (ör: "1-1") |
| `score_prediction.ft` | `ftScore` (ör: "2-1") |
| `main_pick.confidence` | `scoreConfidence` (ör: 65) |
| V20+ Response Alanı | CardDto Alanı |
| ----------------------- | ---------------------------------------------- |
| `score_prediction.ht` | `htScore` (ör: "1-1") |
| `score_prediction.ft` | `ftScore` (ör: "2-1") |
| `main_pick.confidence` | `scoreConfidence` (ör: 65) |
| `bet_summary[]` (array) | `topPicks[]` (ilk 3, confidence'a göre sıralı) |
| `risk.level` | `riskLevel` (LOW/MEDIUM/HIGH/EXTREME) |
| `match_info.home_team` | `homeTeam` (fallback) |
| `risk.level` | `riskLevel` (LOW/MEDIUM/HIGH/EXTREME) |
| `match_info.home_team` | `homeTeam` (fallback) |
**Bet Summary Market Kodları:**
| Kod | Türkçe | English |
|---|---|---|
| MS | Maç Sonucu | Match Result |
| OU15 | Üst 1.5 Gol | Over 1.5 |
| OU25 | Üst 2.5 Gol | Over 2.5 |
| OU35 | Üst 3.5 Gol | Over 3.5 |
| BTTS | Karşılıklı Gol | Both Teams Score |
| DC | Çifte Şans | Double Chance |
| HT | İlk Yarı Sonucu | Half Time Result |
| HT_OU05 | İY 0.5 Üst/Alt | HT Over/Under 0.5 |
| OE | Tek/Çift | Odd/Even |
| HTFT | İY/MS | HT/FT |
| Kod | Türkçe | English |
| ------- | --------------- | ----------------- |
| MS | Maç Sonucu | Match Result |
| OU15 | Üst 1.5 Gol | Over 1.5 |
| OU25 | Üst 2.5 Gol | Over 2.5 |
| OU35 | Üst 3.5 Gol | Over 3.5 |
| BTTS | Karşılıklı Gol | Both Teams Score |
| DC | Çifte Şans | Double Chance |
| HT | İlk Yarı Sonucu | Half Time Result |
| HT_OU05 | İY 0.5 Üst/Alt | HT Over/Under 0.5 |
| OE | Tek/Çift | Odd/Even |
| HTFT | İY/MS | HT/FT |
### 4.2 ImageRendererService
@@ -89,6 +94,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
**Boyut:** 1080×1920 px (Instagram Story / Reels uyumlu)
**Özellikler:**
- Koyu gradient arka plan (#0a0e27#1a1040#0d1b2a)
- Lig adı + tarih başlık satırı
- Takım logoları (200×200px) — `public/uploads/teams/` altından okunur
@@ -100,6 +106,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
- Alt bilgi: "⚡ AI Powered by SuggestBet"
**Logo Çözümleme:**
```
1. Yerel dosya varsa → public/uploads/teams/xxx.png oku
2. URL http ile başlıyorsa → HTTP ile indir
@@ -118,10 +125,10 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
## 5. API Endpointleri
| Method | Path | Auth | Açıklama |
|---|---|---|---|
| GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) |
| POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş |
| Method | Path | Auth | Açıklama |
| ------ | ------------------------------------- | ------- | ---------------------------------------------------- |
| GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) |
| POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş |
> **Not:** Test endpointleri `@Public()` dekoratörüyle auth bypass edilmiştir. Production'da kaldırılmalı veya admin-only yapılmalıdır.
@@ -129,14 +136,20 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
## 6. Environment Değişkenleri
| Key | Zorunlu | Varsayılan | Açıklama |
|---|---|---|---|
| `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL |
| `APP_BASE_URL` | ✅ | `http://localhost:3000` | Logo URL çözümleme için |
| `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif |
| `GOOGLE_API_KEY` | ❌ | — | Gemini caption için |
| Twitter API keys | ❌ | — | Twitter paylaşım için |
| Meta API keys | ❌ | — | FB/IG paylaşım için |
| Key | Zorunlu | Varsayılan | Açıklama |
| --------------------------------------------- | ------- | ------------------------ | -------------------------------------------------------------------- |
| `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL |
| `APP_BASE_URL` | ✅ | `http://localhost:3000` | Meta'nın çekebileceği public görsel URL'i ve logo URL çözümleme için |
| `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif |
| `SOCIAL_POSTER_SPORTS` | ❌ | `football,basketball` | Otomatik paylaşılacak sporlar |
| `SOCIAL_POSTER_WINDOW_MIN` | ❌ | `25` | Başlama zaman penceresi alt sınırı (dakika) |
| `SOCIAL_POSTER_WINDOW_MAX` | ❌ | `45` | Başlama zaman penceresi üst sınırı (dakika) |
| `OLLAMA_BASE_URL` | ❌ | `http://localhost:11434` | Lokal LLM endpoint'i |
| `OLLAMA_MODEL` / `SOCIAL_POSTER_OLLAMA_MODEL` | ❌ | — | Caption üretiminde kullanılacak lokal model |
| `GOOGLE_API_KEY` | ❌ | — | Gemini caption için |
| Twitter API keys | ❌ | — | X medya upload + `/2/tweets` paylaşımı için OAuth 1.0a user context |
| `META_GRAPH_API_VERSION` | ❌ | `v25.0` | Meta Graph API sürümü |
| Meta API keys | ❌ | — | FB/IG paylaşım için |
---
@@ -144,9 +157,9 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
```json
{
"canvas": "^2.x", // Node Canvas — görsel üretimi
"axios": "^1.x", // HTTP istekleri (AI Engine + logo indirme)
"@nestjs/schedule": "*" // Cron job desteği
"canvas": "^2.x", // Node Canvas — görsel üretimi
"axios": "^1.x", // HTTP istekleri (AI Engine + logo indirme)
"@nestjs/schedule": "*" // Cron job desteği
}
```
@@ -165,10 +178,10 @@ RUN apk add --no-cache cairo-dev pango-dev jpeg-dev giflib-dev librsvg-dev
### Port Yönetimi
| Servis | Port |
|---|---|
| NestJS Backend | 3000 (production: 150X) |
| AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) |
| Servis | Port |
| -------------- | ------------------------------------------- |
| NestJS Backend | 3000 (production: 150X) |
| AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) |
### Dosya Sistemi
@@ -182,9 +195,9 @@ public/
## 9. Bilinen Sorunlar & Çözümler
| Sorun | Sebep | Çözüm |
|---|---|---|
| `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) |
| `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır |
| `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan |
| Logolar görünmüyor (lokal dev) | Logo dosyaları sunucuda, lokalde yok | Deploy'da çalışır, lokal'de graceful skip |
| Sorun | Sebep | Çözüm |
| --------------------------------------- | ------------------------------------ | ----------------------------------------- |
| `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) |
| `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır |
| `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan |
| Logolar görünmüyor (lokal dev) | Logo dosyaları sunucuda, lokalde yok | Deploy'da çalışır, lokal'de graceful skip |
File diff suppressed because one or more lines are too long
+370
View File
@@ -0,0 +1,370 @@
# V28-Pro-Max Model Architecture Documentation
> **Model Version:** `v28-pro-max`
> **Engine File:** `ai-engine/services/single_match_orchestrator.py` (4656 satır)
> **Son Güncelleme:** 2026-04-24
---
## 1. Genel Bakış
V28-Pro-Max, üç bağımsız tahmin katmanını (V25, V27, V28) tek bir orchestrator içinde birleştiren **üçlü hibrit AI tahmin motorudur**. Her maç için 13+ bahis pazarını analiz eder, olasılık hesaplar, risk değerlendirir ve "Value Bet" tespiti yapar.
```
┌─────────────────────────────────────────────────────┐
│ SingleMatchOrchestrator │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ V25 │ │ V27 │ │ V28 │ │
│ │ Ensemble │ │ Dual-Eng │ │ Odds-Band │ │
│ │ (XGB+LGB)│ │ Divergnce│ │ Historical │ │
│ └────┬─────┘ └────┬─────┘ └───────┬────────┘ │
│ │ │ │ │
│ └──────────────┼────────────────┘ │
│ ▼ │
│ FullMatchPrediction │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ Market Rows Risk Calc Triple Value │
│ │ │ │ │
│ └───────────┼───────────┘ │
│ ▼ │
│ _build_prediction_package() │
│ → JSON Response (v28-pro-max) │
└─────────────────────────────────────────────────────┘
```
---
## 2. Katman Detayları
### 2.1 V25 — Ensemble ML Katmanı
**Dosya:** `ai-engine/models/v25_ensemble.py`
- **Algoritmalar:** XGBoost + LightGBM ensemble
- **Girdi:** Pre-match feature vektörü (form, elo, odds, kadro, hakem vb.)
- **Çıktı:** Tüm pazarlar için olasılık dağılımları + confidence skorları
- **Özellik:** Odds-aware (bahis oranlarını feature olarak kullanır)
- **Target leakage koruması:** Maç sonucu bilgisi asla feature olarak kullanılmaz
```python
# V25 çağrılma noktası (orchestrator L310-315)
v25_signal = v25_predictor.predict(features)
# Çıktı: {MS: {home: 0.45, draw: 0.28, away: 0.27}, OU25: {...}, BTTS: {...}, ...}
```
### 2.2 V27 — Dual-Engine Divergence Katmanı
**Dosya:** `ai-engine/models/v27_predictor.py`
- **Amaç:** Odds-FREE temel olasılıkları hesaplar (sadece form/elo/kadro)
- **Mekanizma:** V25 (odds-aware) vs V27 (odds-free) karşılaştırması
- **Divergence Tespiti:** İki motor arasındaki fark → bahisçinin fiyatlandırma hatasını tespit eder
- **Çıktı:** `ms_divergence`, `ou25_divergence`, `is_value` sinyalleri
```python
# Divergence hesaplama (orchestrator L830-863)
ms_divergence = {
"home": v25_home_prob - v27_home_prob, # Pozitif = V25 bahisçiyle hemfikir
"away": v25_away_prob - v27_away_prob, # Negatif = Model bahisçiden farklı düşünüyor
}
ms_value = {
"home": {"is_value": v27_home > implied_home and abs(div) > 0.05},
"away": {"is_value": v27_away > implied_away and abs(div) > 0.05},
}
```
### 2.3 V28 — Odds-Band Historical Performance Katmanı
**Dosya:** `ai-engine/features/odds_band_analyzer.py`
- **Amaç:** "Bu oran bandında tarihsel olarak ne oldu?" sorusunu yanıtlar
- **Mekanizma:** Maçın mevcut oranını bir banda yerleştirir (ör: MS Home 1.70-1.90), ardından veritabanındaki aynı banddaki geçmiş maçları sorgular
- **Sorgu:** PostgreSQL üzerinden takım-spesifik tarihsel performans
```python
# OddsBandAnalyzer.compute_all() çıktısı — 18 pazar için band metrikleri:
{
"home_band_ms_win_rate": 0.62, # Ev sahibi bu oran bandında %62 kazanmış
"home_band_ms_sample": 34, # 34 maçlık örneklem
"band_ou25_over_rate": 0.58, # Bu banddaki maçların %58'i 2.5 üst
"band_btts_yes_rate": 0.51, # KG Var oranı
"band_htft_11_rate": 0.28, # İY/MS 1/1 oranı
"band_cards_referee_avg": 4.2, # Hakem kart ortalaması
# ... toplam 60+ feature
}
```
---
## 3. Analiz Edilen Bahis Pazarları (13+)
| # | Pazar | Kod | Olasılık Alanları | Odds Anahtarları |
|---|-------|-----|-------------------|------------------|
| 1 | Maç Sonucu | `MS` | home/draw/away | ms_h, ms_d, ms_a |
| 2 | Çifte Şans | `DC` | 1X/X2/12 | dc_1x, dc_x2, dc_12 |
| 3 | Üst/Alt 1.5 | `OU15` | over/under | ou15_o, ou15_u |
| 4 | Üst/Alt 2.5 | `OU25` | over/under | ou25_o, ou25_u |
| 5 | Üst/Alt 3.5 | `OU35` | over/under | ou35_o, ou35_u |
| 6 | Karşılıklı Gol | `BTTS` | yes/no | btts_y, btts_n |
| 7 | İlk Yarı Sonucu | `HT` | 1/X/2 | ht_h, ht_d, ht_a |
| 8 | İY/MS (9 kombo) | `HTFT` | 1/1, 1/X, 1/2, X/1, X/X, X/2, 2/1, 2/X, 2/2 | htft_11..htft_22 |
| 9 | Tek/Çift | `OE` | odd/even | oe_odd, oe_even |
| 10 | İY Üst/Alt 0.5 | `HT_OU05` | over/under | ht_ou05_o, ht_ou05_u |
| 11 | İY Üst/Alt 1.5 | `HT_OU15` | over/under | ht_ou15_o, ht_ou15_u |
| 12 | Kartlar | `CARDS` | over/under | cards_o, cards_u |
| 13 | Handikap | `HCAP` | 1/X/2 | hcap_h, hcap_d, hcap_a |
---
## 4. Triple Value Detection (V28 Ana Yeniliği)
V28'in en kritik özelliği: **3 bağımsız kaynağı çapraz kontrol ederek "gerçek değer" tespiti yapması.**
```
Triple Value = V27 Divergence + V28 Band Rate + Odds Implied Probability
Koşullar (hepsi sağlanmalı):
1. V27 olasılığı > bahisçi implied olasılığı (v27_confirms)
2. Band tarihsel oranı > implied olasılık (band_confirms)
3. Kombine edge > %5 (edge > 0.05)
4. Band örneklem >= 8 maç (band_sample >= 8)
→ Tüm koşullar sağlanırsa: is_value = True
```
**Örnek:**
```
Galatasaray vs Beşiktaş — MS Home (1.85 oran)
├── Implied Prob: 1/1.85 = 0.54 (%54)
├── V27 (odds-free): 0.61 (%61) → ✅ V27 confirms (0.61 > 0.54)
├── V28 Band Rate: 0.62 (%62, 34 maç) → ✅ Band confirms (0.62 > 0.54)
├── Combined Prob: (0.61 + 0.62) / 2 = 0.615
├── Edge: 0.615 - 0.54 = 0.075 (%7.5) → ✅ Edge > 5%
└── is_value = TRUE → "Bu bahis değerli!"
```
---
## 5. Market Row Dekorasyon Pipeline'ı
Her pazar aşağıdaki pipeline'dan geçer:
```
_build_market_rows() → Ham market row'ları oluştur (13 pazar)
_apply_market_consistency() → Pazarlar arası tutarlılık kontrolü
_decorate_market_row() → Her row'a playability, grading, staking ekle
Sort by (playable, play_score) → En iyi pick'ler başa gelir
```
### 5.1 Decorate Market Row — Quant Hybrid Sistemi
Her market row şu metriklerle dekore edilir:
| Metrik | Formül | Açıklama |
|--------|--------|----------|
| `calibrated_confidence` | `raw_conf × market_calibration` | Kalibre edilmiş güven |
| `ev_edge` | `(prob × odds) - 1.0` | Expected Value edge |
| `simple_edge` | `prob - (1/odds)` | Basit olasılık farkı |
| `play_score` | `cal_conf + (edge × 100 × edge_mult) - penalties` | Oynanabilirlik skoru |
| `stake_units` | Quarter-Kelly Criterion | Önerilen bahis miktarı |
| `bet_grade` | A/B/C/PASS | EV edge bazlı not |
### 5.2 Playability Gates (Güvenlik Kapıları)
Bir market row'un "playable" olması için tüm kapılardan geçmesi gerekir:
1. **Confidence Gate:** `calibrated_conf >= min_conf` (pazar bazlı eşik)
2. **Odds Gate:** Odds-required pazarlarda `odds > 1.01`
3. **Risk-Quality Gate:** HIGH/EXTREME risk + LOW kalite → BLOK
4. **Negative Edge Gate:** `simple_edge < neg_threshold` → BLOK
5. **EV Edge Gate:** `ev_edge < min_edge` → BLOK
6. **Play Score Gate:** `play_score < min_play_score` → BLOK
### 5.3 Kelly Criterion Staking
```python
# Quarter-Kelly (¼ Kelly, 10-unit bankroll)
f* = ((b × p) - q) / b # Full Kelly
stake = f* × 0.25 × 10 # Quarter Kelly × bankroll
stake = min(stake, 3.0) # Cap: max 3 unit
stake = max(stake, 0.25) # Floor: min 0.25 unit
```
---
## 6. Guaranteed Pick Logic (V32 Calibration-Aware)
Ana pick seçimi 4 öncelik sırasıyla yapılır:
```
Priority 1: HIGH_ACCURACY markets (DC, OU15, HT_OU05)
+ Odds >= 1.30 + Confidence >= 44%
→ is_guaranteed = True, reason = "high_accuracy_market"
Priority 2: Any playable + Odds >= 1.30 + Conf >= 44%
→ is_guaranteed = True, reason = "confidence_threshold_met"
Priority 3: Any playable + Odds >= 1.30
→ is_guaranteed = False, reason = "odds_only_fallback"
Priority 4: Best non-playable (last resort)
→ is_guaranteed = False, reason = "last_resort"
```
**Value Pick:** `main_pick`'ten farklı, odds >= 1.60, confidence >= %40 olan en iyi alternatif.
**Aggressive Pick:** HT/FT reversal senaryoları (1/2, 2/1, X/1, X/2) arasından en yüksek olasılıklı.
---
## 7. Risk Assessment Sistemi
```python
risk_score = 100 - max_market_conf + lineup_penalty + referee_penalty + parity_penalty
# Penalty'ler:
lineup_penalty = 12.0 (kadro yok) | 7.0 (probable_xi) | 0.0 (confirmed)
referee_penalty = 6.0 (hakem yok) | 0.0
parity_penalty = 8.0 (|ms_edge| < 0.08) | 0.0
# Risk seviyeleri:
EXTREME: score >= 78
HIGH: score >= 62
MEDIUM: score >= 40
LOW: score < 40
```
### Surprise Risk Tespiti
- `is_surprise_risk = True` → Risk HIGH/EXTREME VEYA draw_prob >= %30
- `surprise_type`: `balanced_match_risk` veya `draw_pressure`
---
## 8. xG ve Skor Tahmini
```python
base_home_xg = (home_goals_avg + away_xga) / 2
base_away_xg = (away_goals_avg + home_xga) / 2
# MS edge ve BTTS etkisiyle düzeltme:
home_xg = base_home_xg + (ms_edge × 0.55) + (btts_prob - 0.5) × 0.18
away_xg = base_away_xg - (ms_edge × 0.55) + (btts_prob - 0.5) × 0.18
# Liga ortalamasıyla ölçekleme:
total_target = league_avg_goals × 0.55 + team_avgs × 0.45 + ou25_signal × 1.15
scale = total_target / (home_xg + away_xg)
final_home_xg = home_xg × scale
final_away_xg = away_xg × scale
# Skor tahmini:
FT = round(home_xg) - round(away_xg)
HT = round(home_xg × 0.45) - round(away_xg × 0.45)
Top5 = Poisson dağılımı ile en olası 5 skor
```
---
## 9. Data Quality Skoru
```python
quality_score = odds_score × 0.35 + lineup_score × 0.35 + ref_score × 0.15 + form_score × 0.15
# Etiketleme:
HIGH: score >= 0.75
MEDIUM: score >= 0.45
LOW: score < 0.45
```
---
## 10. Çıktı JSON Kontratı
```json
{
"model_version": "v28-pro-max",
"match_info": { "match_id", "home_team", "away_team", "league", ... },
"data_quality": { "label", "score", "lineup_source", "flags" },
"risk": { "level", "score", "is_surprise_risk", "warnings" },
"engine_breakdown": { "team", "player", "odds", "referee" },
"main_pick": { "market", "pick", "confidence", "odds", "ev_edge", "bet_grade", "is_guaranteed" },
"value_pick": { ... },
"aggressive_pick": { "market": "HT/FT", "pick": "1/2", ... },
"bet_advice": { "playable", "suggested_stake_units", "reason" },
"bet_summary": [ { "market", "pick", "calibrated_confidence", "bet_grade", "ev_edge", ... } ],
"supporting_picks": [ ... ],
"score_prediction": { "ft", "ht", "xg_home", "xg_away", "xg_total" },
"scenario_top5": [ "1-0", "2-1", ... ],
"market_board": { "MS": {...}, "DC": {...}, "OU25": {...}, ... },
"v25_signal": { "available", "markets", "value_bets" },
"reasoning_factors": [ ... ]
}
```
---
## 11. League-Specific Odds Reliability (V31)
Bazı liglerin bahis oranları daha güvenilirdir. Bu bilgi `_decorate_market_row` içinde edge ağırlıklandırmasında kullanılır:
```python
odds_rel = league_reliability.get(league_id, 0.35) # 0.0 - 1.0
edge_multiplier = 0.60 + (odds_rel × 0.60) # 0.60 - 1.20
# Güvenilir lig → edge daha fazla ağırlık alır
# Güvenilsiz lig → model confidence'a daha çok güvenilir
```
---
## 12. Dosya Haritası
```
ai-engine/
├── services/
│ └── single_match_orchestrator.py ← Ana orchestrator (4656 satır)
├── models/
│ ├── v25_ensemble.py ← XGBoost + LightGBM ensemble
│ └── v27_predictor.py ← Odds-free fundamental predictor
├── features/
│ └── odds_band_analyzer.py ← V28 tarihsel band analizi
└── main.py ← FastAPI endpoint (/predict)
```
---
## 13. Akış Özeti
```
HTTP POST /predict {match_id}
SingleMatchOrchestrator.analyze_match(match_id)
├── _load_match_data() → DB'den maç + odds + kadro + form
├── V25: v25_predictor.predict(features)
│ → 13 pazar olasılık + confidence
├── V27: v27_predictor.predict(features)
│ → Odds-free MS/OU25 olasılıkları
│ → Divergence hesaplama
├── V28: odds_band_analyzer.compute_all()
│ → 18 pazar için tarihsel band metrikleri
├── Triple Value Detection
│ → V27 + V28 + Implied çapraz kontrol
├── _enrich_prediction() → xG, risk, skor tahmini
├── _build_market_rows() → 13+ ham market row
├── _apply_market_consistency()
├── _decorate_market_row() → EV, Kelly, grading
├── Guaranteed Pick Selection → main_pick, value_pick, aggressive_pick
└── _build_prediction_package() → Final JSON kontratı
```
+15 -39
View File
@@ -26,7 +26,7 @@
"@nestjs/swagger": "^11.2.4",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"@prisma/client": "5.22.0",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"bullmq": "^5.66.4",
@@ -46,7 +46,7 @@
"passport-jwt": "^4.0.1",
"pino": "^10.1.0",
"pino-http": "^11.0.0",
"prisma": "^5.22.0",
"prisma": "5.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"twitter-api-v2": "^1.29.0",
@@ -1145,7 +1145,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -3001,7 +3000,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz",
"integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"axios": "^1.3.1",
@@ -3095,7 +3093,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -3262,7 +3259,6 @@
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.11.tgz",
"integrity": "sha512-R/+A8XFqLgN8zNs2twhrOaE7dJbRQhdPX3g46am4RT/x8xGLqDphrXkUIno4cGUZHxbczChBAaAPTdPv73wDZA==",
"peer": true,
"dependencies": {
"file-type": "21.2.0",
"iterare": "1.2.1",
@@ -3308,7 +3304,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.11.tgz",
"integrity": "sha512-H9i+zT3RvHi7tDc+lCmWHJ3ustXveABCr+Vcpl96dNOxgmrx4elQSTC4W93Mlav2opfLV+p0UTHY6L+bpUA4zA==",
"hasInstallScript": true,
"peer": true,
"dependencies": {
"@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.1",
@@ -3388,7 +3383,6 @@
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.11.tgz",
"integrity": "sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==",
"peer": true,
"dependencies": {
"cors": "2.8.5",
"express": "5.2.1",
@@ -3409,7 +3403,6 @@
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.11.tgz",
"integrity": "sha512-0z6pLg9CuTXtz7q2lRZoPOU94DN28OTa39f4cQrlZysKA6QrKM7w7z6xqb4g32qjF+LQHFNRmMJtE/pLrxBaig==",
"peer": true,
"dependencies": {
"socket.io": "4.8.3",
"tslib": "2.8.1"
@@ -3784,7 +3777,6 @@
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"peer": true,
"engines": {
"node": ">=16.13"
},
@@ -3849,7 +3841,6 @@
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@@ -4755,7 +4746,6 @@
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@@ -4877,7 +4867,6 @@
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -5042,7 +5031,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz",
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.52.0",
"@typescript-eslint/types": "8.52.0",
@@ -5680,7 +5668,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5734,7 +5721,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5926,7 +5912,6 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
@@ -6240,7 +6225,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6313,7 +6297,6 @@
"version": "5.66.4",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.66.4.tgz",
"integrity": "sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==",
"peer": true,
"dependencies": {
"cron-parser": "4.9.0",
"ioredis": "5.8.2",
@@ -6387,7 +6370,6 @@
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz",
"integrity": "sha512-TKeeb9nSybk1e9E5yAiPVJ6YKdX9FYhwqqy8fBfVKAFVTJYZUNmeIvwjURW6+UikNsO6l2ta27thYgo/oumDsw==",
"peer": true,
"dependencies": {
"@cacheable/utils": "^2.3.2",
"keyv": "^5.5.4"
@@ -6601,7 +6583,6 @@
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -6651,14 +6632,12 @@
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"peer": true
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
},
"node_modules/class-validator": {
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@@ -7497,7 +7476,8 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
@@ -7555,7 +7535,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7615,7 +7594,6 @@
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7846,7 +7824,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9051,7 +9028,6 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz",
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -9895,7 +9871,6 @@
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz",
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
"peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -10688,6 +10663,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"peer": true,
"engines": {
"node": ">= 6"
}
@@ -10920,7 +10896,6 @@
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"peer": true,
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
@@ -11047,7 +11022,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
"peer": true,
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
@@ -11077,7 +11051,6 @@
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz",
"integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==",
"peer": true,
"dependencies": {
"get-caller-file": "^2.0.5",
"pino": "^10.0.0",
@@ -11286,7 +11259,6 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -11340,7 +11312,6 @@
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"hasInstallScript": true,
"peer": true,
"dependencies": {
"@prisma/engines": "5.22.0"
},
@@ -12479,7 +12450,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -12794,7 +12764,6 @@
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -12950,7 +12919,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13298,6 +13266,7 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@@ -13315,6 +13284,7 @@
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
@@ -13327,6 +13297,7 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@@ -13340,6 +13311,7 @@
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"peer": true,
"engines": {
"node": ">=4.0"
}
@@ -13348,13 +13320,15 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/webpack/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -13364,6 +13338,7 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -13376,6 +13351,7 @@
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
+5 -5
View File
@@ -22,14 +22,14 @@
"ai:backtest": "python ai-engine/scripts/backtest_v2_runtime.py",
"ai:train:vqwen": "python ai-engine/scripts/train_vqwen_v3.py",
"feeder:historical": "ts-node -r tsconfig-paths/register src/scripts/run-feeder.ts",
"feeder:previous-day": "ts-node -r tsconfig-paths/register src/scripts/run-feeder.ts",
"feeder:repair": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-repair.ts",
"feeder:previous-day": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-previous-day.ts",
"feeder:fill-gaps": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-filtered.ts",
"feeder:basketball": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-basketball.ts",
"feeder:live": "ts-node -r tsconfig-paths/register src/scripts/run-live-feeder.ts",
"cleanup:live": "ts-node -r tsconfig-paths/register src/scripts/cleanup-live-matches.ts",
"swagger:summary": "ts-node -r tsconfig-paths/register src/scripts/export-swagger-endpoints-summary.ts",
"postman:export": "ts-node -r tsconfig-paths/register src/scripts/export-postman-collection.ts"
,
"postman:export": "ts-node -r tsconfig-paths/register src/scripts/export-postman-collection.ts",
"ai:extract:v26": "python3 ai-engine/scripts/extract_training_data_v26.py",
"ai:train:v26": "python3 ai-engine/scripts/train_v26_shadow.py",
"ai:backtest:v26": "python3 ai-engine/scripts/backtest_v26_shadow.py",
@@ -55,7 +55,7 @@
"@nestjs/swagger": "^11.2.4",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"@prisma/client": "5.22.0",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"bullmq": "^5.66.4",
@@ -75,7 +75,7 @@
"passport-jwt": "^4.0.1",
"pino": "^10.1.0",
"pino-http": "^11.0.0",
"prisma": "^5.22.0",
"prisma": "5.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"twitter-api-v2": "^1.29.0",
+267
View File
@@ -0,0 +1,267 @@
[
"3iwftmprsznl6yribr11a8l9m",
"cegl2ivkc25blcatxp4jmk1ec",
"1zp1du9n4rj36p1ss9zbxtqfb",
"bockl24qpr7ryjl8b6obukga",
"byu00jvt1j6csyv4y1lkt2fm2",
"degxm4y6gmvp011ccyrev6z5p",
"c7b8o53flg36wbuevfzy3lb10",
"7ntvbsyq31jnzoqoa8850b9b8",
"581t4mywybx21wcpmpykhyzr3",
"3frp1zxrqulrlrnk503n6l4l",
"287tckirbfj9nb8ar2k9r60vn",
"bgen5kjer2ytfp7lo9949t72g",
"ac112osli9fvox1epcg4ld3t6",
"3is4bkgf3loxv9qfg3hm8zfqb",
"c1d9p6b2e9zr5tqlzx3ktjplg",
"5zr0b05eyx25km7z1k03ca9jx",
"5z8v4mj6cjs9ex6hdrpourjzh",
"scf9p4y91yjvqvg5jndxzhxj",
"3p81ltz6845appgkbgkzxueii",
"b5udgm9vakjqz8dcmy5b2g0xt",
"b1rveez5u792gess9w3e7v5le",
"2ty8ihceabty8yddmu31iuuej",
"8ey0ww2zsosdmwr8ehsorh6t7",
"2nttcoriwf5co73vmz1vr8frm",
"1r097lpxe0xn03ihb7wi98kao",
"2kwbbcootiqqgmrzs6o5inle5",
"907l7wtxdvugdo9i2249wcmr0",
"8o5tv5viv4hy1qg9jp94k7ayb",
"4nidzmunvpvxk1ir9b6m8mpay",
"dkarmrybx9vx10rg7cywumth0",
"a9vrdkelbgif0gtu3wxsr75xo",
"4w7x0s5gfs5abasphlha5de8k",
"8dn0w8zh7nbn2i904603eigwf",
"1gwajyt0pk2jm5fx5mu36v114",
"2o9svokc5s7diish3ycrzk7jm",
"7hl0svs2hg225i2zud0g3xzp2",
"89ovpy1rarewwzqvi30bfdr8b",
"2hsidwomhjsaaytdy9u5niyi4",
"34pl8szyvrbwcmfkuocjm3r6t",
"8r98daokeuzsamu5fmjtblqx5",
"akmkihra9ruad09ljapsm84b3",
"722fdbecxzcq9788l6jqclzlw",
"663a54fmymndjeev47qm7d3nf",
"4zwgbb66rif2spcoeeol2motx",
"9chuiarcjofld1dkj9kysehmb",
"5y0z0l2epprzbscvzsgldw8vu",
"2wolc27r8z03itcvwp43e38c5",
"alpfd99yd3lfv7bhjo0biuq7b",
"ea0h6cf3bhl698hkxhpulh2zz",
"8sdpk4aerruf515yh76ezo7vi",
"6by3h89i2eykc341oz7lv1ddd",
"7r1f93t6ddrsa5n8v1nq6qlzm",
"8yi6ejjd1zudcqtbn07haahg6",
"ein4fkggto3pdh5msp8huafiq",
"b60nisd3qn427jm0hrg9kvmab",
"1qd0wvt30rlswa4g6nu4na660",
"b73zounsynk9d3u1p9nvpu7i2",
"civf31q1inxohs4a03y8reetf",
"bu1l7ckihyr0errxw61p0m05",
"a7247po5qs29o3zsfmt222ydu",
"6lwpjhktjhl9g7x2w7njmzva6",
"4c1nfi2j1m731hcay25fcgndq",
"3ww12jab49q8q8mk9avdwjqgk",
"8y29fg2s85ppcb8uugm5ee8s4",
"82jkgccg7phfjpd0mltdl3pat",
"46b141eaqq9q7o4gz5gtdpikk",
"482ofyysbdbeoxauk19yg7tdt",
"4oogyu6o156iphvdvphwpck10",
"2y8bntiif3a9y6gtmauv30gt",
"e21cf135btr8t3upw0vl6n6x0",
"c0yqkbilbbg70ij2473xymmqv",
"5dycj9wdhxh3n33qubw18ohlk",
"1eruend45vd20g9hbrpiggs5u",
"e1kxdivp5g4cpldgpwvnzl1vv",
"ddyrh5latwfhesgfh4w401n92",
"af79lqrc0ntom74zq13ccjslo",
"3ab1uwtoyjopdj1y1fynyy9jg",
"c0r21rtokgnbtc0o2rldjmkxu",
"e0lck99w8meo9qoalfrxgo33o",
"yv73ms6v1995b5wny16jcfi3",
"5aw6uyw4pz2bpj24t5z8aacim",
"75i269i1ak43magshljadydrh",
"8k1xcsyvxapl4jlsluh3eomre",
"jznihqxle06xych9ygwiwnsa",
"6wubmo7di3kdpflluf6s8c7vs",
"7cwemnr3vi40znjq451zxkus6",
"6ifaeunfdelecgticvxanikzu",
"913mb508il6jzwtlj28fl892h",
"29actv1ohj8r10kd9hu0jnb0n",
"3btdfgw79qiz3jmyfudovtbu2",
"5cwsxtx37les6m10xj71htkgf",
"9nbpdi9q3ywcm4q0j5u0ekwcq",
"dm5ka0os1e3dxcp3vh05kmp33",
"beqqnubkv05mamuwvimeum015",
"57nu0wygurzkp6fuy5hhrtaa2",
"du6jsenbjql5e8f3yk880ox4g",
"cesdwwnxbc5fmajgroc0hqzy2",
"3w1hkk9k9gr8fwssyn4icvdfo",
"65ggsqdi6drpa4m8y3gkll25k",
"4yzidekywejmxxp77gqmdgopg",
"avs3xposm3t9x1x2vzsoxzcbu",
"75434tz9rc14xkkvudex742ui",
"aho73e5udydy96iun3tkzdzsi",
"4qehj8hfxmy6o2ohp4fxinnzo",
"ae1wva3zrzcp2zd15gpvsntg6",
"4d5d3sf6805n5u6jdoa0hdlog",
"3l29w00m506ex93t5bbh9cg2a",
"zs18qaehvhg3w1208874zvfa",
"4mbfidy8zum5u0aqjqo0vuqs2",
"8v97rcbthsxmzqk4ufxws9mug",
"c76z5d6j7dpi1e79tm8fpm39z",
"47s2kt0e8m444ftqvsrqa3bvq",
"9ikchyu9fb8bvx0s673jofj6s",
"6ihotpaocgiovlxw18e9r9prx",
"32n2r9bl6x90psj0wa7bfs6vq",
"zilopfej2h0n3vpan5tcynpo",
"7nmz249q89qg5ezcvzlheljji",
"ajxs0e0g6ryg5ol8qvw3evrcz",
"477yyajzheg2z8u7uick0e13e",
"8t2o4huu2e48ij23dxnl9w5qx",
"1wwro3z1eb3fl601dju6inlc6",
"4yngyfinzd6bb1k7anqtqs0wt",
"1b70m6qtxrp75b4vtk8hxh8c3",
"7af85xa75vozt2l4hzi6ryts7",
"117yqo02rs8dykkxpm274w3bd",
"725gd73msyt08xm76v7gkxj7u",
"f4jc2cc5nq7flaoptpi5ua4k4",
"xwnjb1az11zffwty3m6vn8y6",
"dr2xk7muj8aqcjdz2b3li1c0k",
"1mpjd0vbxbtu9zw89yj09xk3z",
"3428tckxcirwwh3o3jgc1m8ji",
"6sxm2iln2w45ux498pty9miw8",
"6321dlqv4ziuwqte4xpohijtw",
"5c96g1zm7vo5ons9c42uy2w3r",
"ili150pwfuf39f7yfdch9lhw",
"7swf4kpu3v38i2it4h94c5s9k",
"iu1vi94p4p28oozl1h9bvplr",
"5k620c7y6dlbmcm88dt3eb7t",
"f39uq10c8xhg5e6rwwcf6lhgc",
"6lkj3o21cr4g7bql6tb3fk222",
"9ynnnx1qmkizq1o3qr3v0nsuk",
"8usjlmziv3p2re0r2wwzezki9",
"4zwjlzdszduqmxzusysvzymms",
"7mxwwunvot2pi69pj1yr1kh8i",
"5taraea6mqjjldg9zxswo825y",
"9fuwphq8kvugrlc3ckm7k8wes",
"dvstmwnvw0mt5p38twn9yttyb",
"2xg0qvif1rh7du6wmk2eleku3",
"8x3sbh85gc8qir50utw39jl04",
"59tpnfrwnvhnhzmnvfyug68hj",
"1fedahp0rws09tj451onten8r",
"esrunz7rjb0td98mx9e5cedoy",
"2hj3286pqov1g1g59k2t2qcgm",
"55hcphd1ccc6eai1ms77460on",
"40yjcbx2sq6oq736iqqqczwt1",
"eog6knrkfei68si736fpquyzc",
"f47f3717z2vtpxfxrpdd4jl1x",
"3oa9e03e7w9nr8kqwqc3tlqz9",
"apdwh753fupxheygs8seahh7x",
"486rhdgz7yc0sygziht7hje65",
"erpufio3qaujd9gkszcqvb0bf",
"cu0rmpyff5692eo06ltddjo8a",
"eg6s9f1jj7jr6stmbosn0g6c8",
"9p3nnxhdjahfn8qswpzy8oyc3",
"cse5oqqt2pzfcy8uz6yz3tkbj",
"cfesxhzb83yl8b779uv3revz1",
"4rls982p5uzil6x30mhyhv9f3",
"eitf7hulqfv1clb7toewkil24",
"byhmntnl1b4lxw0zz21im3zkd",
"gfskxsdituog2kqp9yiu7bzi",
"ejunkmfhjz9weugd2bqrkgobb",
"bdtat25m14jy85y484z3e6lf",
"ax1yf4nlzqpcji4j8epdgx3zl",
"1j4ehtrbry9depwt6oghaq3lu",
"xaouuwuk8qyhv1libkeexwjh",
"1q4ab2bpg5e8jl1g2udnakrju",
"81txfenlgw75nq3u2nfdkj92o",
"19q13y6ruzo0o84ipblcuouzs",
"3n9mk5b2mxmq831wfmv6pu86i",
"3n5046abeu3x482ds3jwda238",
"2aso72utuctat2ecs6nahjss6",
"2bmwykmdlcc2u1c40ytoc39vy",
"bx57cmq1edfq53ckfk791supi",
"bly7ema5au6j40i0grhl0pnub",
"er5745q30wnr8jv9nr863omzg",
"by5nibd18nkt40t0j8a0j5yzx",
"1ncmha8yglhyyhg6gtaujymqf",
"agpweohvn9tugnyl6ry4rhivp",
"8ztsv3pzrsyq5w1r3a0nfk1y5",
"4davonpqws4a4ejl1awu98zdg",
"6vq8j5p3av14nr3iuyi4okhjt",
"bbajzna018c79opa1kl5kmkqo",
"eu2g5j36zzxiazpd729osx0wm",
"595nsvo7ykvoe690b1e4u5n56",
"1gxlzw2ezkyeykhcaa5x8ozkk",
"2z7257m7hj58zuxcjrsg4erzc",
"392slbmf1kdqlr6sd1ckt71rs",
"6g8hw3acenrw828la7gwx4mvs",
"d9eaigzyfnfiraqc3ius757tl",
"3aa4mumjl6zyetg6o9hwd5hhx",
"6hlw7rhrpe9garwmfoxu4lebc",
"e6vzdkz6l236s9p288mharefy",
"dvtl8sf1262pd2aqgu641qa7u",
"5pq4dbinkmt8ujoepyqzih7iw",
"6qitd9h242qkvjenaytfdnsf2",
"cbdbziaqczfuyuwqsylqi26zd",
"3ymqchdzk8tt6lfphf26xfvh0",
"2rdrisk4vlglfjxwu0precyqd",
"1cnx2c8g3hhp8ssxnwwli0mjb",
"65q4uwm6ol1rkf5dp89m8omny",
"8kt53kt3mfo29gldhkl05u25b",
"5jd0k2txwnq69frs79eulba8j",
"8x62utr2uti3i7kk14isbnip6",
"b3ufcd24wfnnd5j98ped6irfu",
"61fzfjogstjuukzcehighq7mu",
"50ap4sua1xyut3mpu7ehesp63",
"6694fff47wqxl10lrd9tb91f8",
"macko16888165594668885588",
"3e40pestup9xzagsu2o6c0i8u",
"9oqeqyj7swpnl86ytafjwavvo",
"1qt9bfl6dhydf4tpano6n1p7s",
"29lni33vxqrl1tqhadrnfid6t",
"2db0aw1duj2my9l5iey5gm6nq",
"1vyghvhuy6abu4htoemdi79bd",
"4vksk0d2q4c5w0itdl52lzek6",
"193wqkyb0v5jnsblhvd2ocmyo",
"a3egqgf45jqft6y0uoyvw3mbj",
"5liafywveaf56s2nod8hg9nca",
"3a0j0giz3c3ajw9h59evv7lqt",
"2mdmx668tyhy4u4z9zszwjv5v",
"19mr0xdp7li6nkz87oxh53xed",
"8u5w0g8jimye1cu5albkcb3qs",
"2kuyfkulm5lsgjxynrgh3vz70",
"8cit3whr514nnd4zkaovsnqn",
"9mr92dlx7ryaxhi07sgt90ish",
"1dajh9qrda3enawmlt7ogt05w",
"10x5pvhifwo4y7hs3fz9hf245",
"dc4k1xh2984zbypbnunk7ncic",
"e6rl4hongahbihxd3tpudespd",
"2r1hqz453bn9ljzt53kdr2lwb",
"86wrztni4x8tnvq9cr1cetvfu",
"5em08hhvd7komnfdsb1yagpas",
"326jpj7749ojwqhu3ap27zl77",
"bqvy41un7sf86rbse9tv810x7",
"93i7thp7zi0ympyt6l8aa1r2i",
"ahl3vljaignq9ebaos4uqkrvo",
"68zplepppndhl8bfdvgy9vgu1",
"df1o8phtfy4dwhv6n7mmeedvw",
"cj30195079sdep2imeyt7y47p",
"3z6xfyd3ovi5x09orlo4rmskx",
"1n990e5dpi9xwruwf6uslknkq",
"etta63x1t7tnkn4jheisjwk4p",
"2xv6qkye2rsnwram454x8i8f1",
"8c93rclta164ypkno054nkfyt",
"89v3ukjpui1gashsz3i1vphfa",
"8tddm56zbasf57jkkay4kbf11",
"dcgbs1vkp9y3y31li7s95i51f",
"dlf90uty1axvtr1vn2aaw9vqh",
"9gvvndi7vk9fzvpe65pv5x2ir",
"7siumtnmgqfap6nalpu8xcwb6",
"7zsbjmlmhzn0y7923lw4zquud",
"8dxsd8xnjm9n1ogo37yomgl3p",
"arrfx02rdlstdfwdyikwqtwgl",
"afp674ll89oqsbbrqt17xfxlh",
"22euhl6zy56cp651ipq99rooq"
]
+3
View File
@@ -50,6 +50,7 @@ import { LeaguesModule } from "./modules/leagues/leagues.module";
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";
// Services and Tasks
import { ServicesModule } from "./services/services.module";
@@ -76,6 +77,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
// Configuration
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [".env.local", ".env"],
validate: validateEnv,
load: [
appConfig,
@@ -201,6 +203,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
AnalysisModule,
CouponsModule,
SporTotoModule,
AiProxyModule,
// Services and Scheduled Tasks
ServicesModule,
+39 -7
View File
@@ -69,9 +69,8 @@ export class AiEngineClient {
this.defaultTimeoutMs = options.timeoutMs ?? 30000;
this.maxRetries = options.maxRetries ?? 2;
this.retryDelayMs = options.retryDelayMs ?? 750;
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3;
this.circuitBreakerCooldownMs =
options.circuitBreakerCooldownMs ?? 30000;
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 5;
this.circuitBreakerCooldownMs = options.circuitBreakerCooldownMs ?? 15000;
this.axiosClient = axios.create({
baseURL: options.baseUrl,
@@ -113,7 +112,9 @@ export class AiEngineClient {
};
}
private async request<T>(config: AiEngineRequestConfig): Promise<AxiosResponse<T>> {
private async request<T>(
config: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
this.ensureCircuitAvailable();
const retries = this.resolveRetryCount(config);
@@ -133,7 +134,13 @@ export class AiEngineClient {
const shouldRetry = attempt < retries && this.isRetriableError(error);
if (!shouldRetry) {
this.registerFailure(error);
// Only register circuit breaker failure for server/network errors, not client errors (4xx)
if (this.isServerError(error)) {
this.registerFailure(error);
} else {
// It's a successful contact with the engine (e.g. 404, 422), so reset failures
this.resetFailures();
}
throw this.toRequestError(error);
}
@@ -162,7 +169,8 @@ export class AiEngineClient {
}
const remainingCooldown =
this.circuitBreakerCooldownMs - (Date.now() - (this.circuitOpenedAt ?? 0));
this.circuitBreakerCooldownMs -
(Date.now() - (this.circuitOpenedAt ?? 0));
if (remainingCooldown > 0) {
throw new AiEngineRequestError("AI engine circuit breaker is open", {
@@ -175,8 +183,11 @@ export class AiEngineClient {
}
this.logger.warn(
`[${this.serviceName}] AI circuit breaker cooldown elapsed, allowing a recovery attempt`,
`[${this.serviceName}] AI circuit breaker cooldown elapsed, allowing a recovery attempt (resetting failures from ${this.consecutiveFailures})`,
);
// Half-open state: reset failures so a single retry failure doesn't
// immediately re-open the circuit at threshold+1
this.consecutiveFailures = 0;
this.circuitOpenedAt = null;
}
@@ -218,6 +229,27 @@ export class AiEngineClient {
return status >= 500 || status === 429 || error.code === "ECONNABORTED";
}
private isServerError(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return true; // Not an axios error, assume internal/network error
}
if (!error.response) {
return true; // Network error, timeout, etc.
}
// Only count infrastructure-level errors toward circuit breaker:
// - No response (network failure) → already handled above
// - Timeout (ECONNABORTED) → infrastructure
// - 429 (rate limit) → infrastructure
// - 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') {
return true;
}
const status = error.response.status;
return status === 429 || status === 502 || status === 503 || status === 504;
}
private toRequestError(error: unknown): AiEngineRequestError {
if (error instanceof AiEngineRequestError) {
return error;
+2
View File
@@ -81,6 +81,7 @@ export const LIVE_STATUS_VALUES_FOR_DB = [
"Playing",
"Half Time",
"liveGame",
"minutes",
];
export const LIVE_STATE_VALUES_FOR_DB = [
@@ -109,6 +110,7 @@ export const FINISHED_STATUS_VALUES_FOR_DB = [
"postGame",
"posted",
"Posted",
"state",
];
export const FINISHED_STATE_VALUES_FOR_DB = [
+1 -4
View File
@@ -14,10 +14,7 @@ function extractDateParts(date: Date, timeZone: string) {
return { year, month, day };
}
export function getDateStringInTimeZone(
date: Date,
timeZone: string,
): string {
export function getDateStringInTimeZone(date: Date, timeZone: string): string {
const { year, month, day } = extractDateParts(date, timeZone);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
+9 -1
View File
@@ -22,7 +22,7 @@ export const envSchema = z.object({
// Database
DATABASE_URL: z.string().url(),
// AI Engine
AI_ENGINE_URL: z.string().url().default("http://localhost:8000"),
AI_ENGINE_URL: z.string().url(),
AI_ENGINE_MODE: z.enum(["v28-pro-max", "dual"]).default("v28-pro-max"),
// JWT
@@ -56,13 +56,21 @@ export const envSchema = z.object({
.string()
.transform((val) => val === "true")
.default("false" as any),
SOCIAL_POSTER_SPORTS: z.string().default("football,basketball"),
SOCIAL_POSTER_WINDOW_MIN: z.coerce.number().default(25),
SOCIAL_POSTER_WINDOW_MAX: z.coerce.number().default(45),
SOCIAL_POSTER_OLLAMA_MODEL: z.string().optional(),
APP_BASE_URL: z.string().url().optional(),
TWITTER_API_KEY: z.string().optional(),
TWITTER_API_SECRET: z.string().optional(),
TWITTER_ACCESS_TOKEN: z.string().optional(),
TWITTER_ACCESS_SECRET: z.string().optional(),
META_GRAPH_API_VERSION: z.string().default("v25.0"),
META_PAGE_ACCESS_TOKEN: z.string().optional(),
META_PAGE_ID: z.string().optional(),
META_IG_USER_ID: z.string().optional(),
OLLAMA_BASE_URL: z.string().url().optional(),
OLLAMA_MODEL: z.string().optional(),
// Optional Features
ENABLE_MAIL: booleanString,
+32 -1
View File
@@ -18,7 +18,12 @@ import {
CACHE_MANAGER,
} from "@nestjs/cache-manager";
import * as cacheManager from "cache-manager";
import { ApiTags, ApiBearerAuth, ApiOperation } from "@nestjs/swagger";
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse as SwaggerResponse,
} from "@nestjs/swagger";
import { Roles } from "../../common/decorators";
import { PrismaService } from "../../database/prisma.service";
import { PaginationDto } from "../../common/dto/pagination.dto";
@@ -46,6 +51,7 @@ export class AdminController {
@Get("users")
@ApiOperation({ summary: "Get all users (admin)" })
@SwaggerResponse({ status: 200, type: [UserResponseDto] })
async getAllUsers(
@Query() pagination: PaginationDto,
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
@@ -75,6 +81,7 @@ export class AdminController {
@Get("users/:id")
@ApiOperation({ summary: "Get user by ID" })
@SwaggerResponse({ status: 200, type: UserResponseDto })
async getUserById(
@Param("id") id: string,
): Promise<ApiResponse<UserResponseDto>> {
@@ -98,6 +105,7 @@ export class AdminController {
@Put("users/:id/toggle-active")
@ApiOperation({ summary: "Toggle user active status" })
@SwaggerResponse({ status: 200, type: UserResponseDto })
async toggleUserActive(
@Param("id") id: string,
): Promise<ApiResponse<UserResponseDto>> {
@@ -120,6 +128,7 @@ export class AdminController {
@Put("users/:id/role")
@ApiOperation({ summary: "Update user role" })
@SwaggerResponse({ status: 200, type: UserResponseDto })
async updateUserRole(
@Param("id") id: string,
@Body() data: { role: UserRole },
@@ -137,6 +146,7 @@ export class AdminController {
@Put("users/:id/subscription")
@ApiOperation({ summary: "Update user subscription" })
@SwaggerResponse({ status: 200, type: UserResponseDto })
async updateUserSubscription(
@Param("id") id: string,
@Body()
@@ -160,6 +170,7 @@ export class AdminController {
@Delete("users/:id")
@ApiOperation({ summary: "Soft delete a user" })
@SwaggerResponse({ status: 200, description: "User deleted" })
async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
await this.prisma.user.update({
where: { id },
@@ -175,6 +186,10 @@ export class AdminController {
@CacheKey("app_settings")
@CacheTTL(60 * 1000)
@ApiOperation({ summary: "Get all app settings" })
@SwaggerResponse({
status: 200,
schema: { type: "object", additionalProperties: { type: "string" } },
})
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
const settings = await this.prisma.appSetting.findMany();
const settingsMap: Record<string, string> = {};
@@ -186,6 +201,13 @@ export class AdminController {
@Put("settings/:key")
@ApiOperation({ summary: "Update an app setting" })
@SwaggerResponse({
status: 200,
schema: {
type: "object",
properties: { key: { type: "string" }, value: { type: "string" } },
},
})
async updateSetting(
@Param("key") key: string,
@Body() data: { value: string },
@@ -206,6 +228,10 @@ export class AdminController {
@Get("usage-limits")
@ApiOperation({ summary: "Get all usage limits" })
@SwaggerResponse({
status: 200,
schema: { type: "array", items: { type: "object" } },
})
async getAllUsageLimits(@Query() pagination: PaginationDto) {
const { skip, take } = pagination;
@@ -233,6 +259,10 @@ export class AdminController {
@Post("usage-limits/reset-all")
@ApiOperation({ summary: "Reset all usage limits" })
@SwaggerResponse({
status: 200,
schema: { type: "object", properties: { count: { type: "number" } } },
})
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
const result = await this.prisma.usageLimit.updateMany({
data: {
@@ -252,6 +282,7 @@ export class AdminController {
@Get("analytics/overview")
@ApiOperation({ summary: "Get system analytics overview" })
@SwaggerResponse({ status: 200, schema: { type: "object" } })
async getAnalyticsOverview() {
const [
totalUsers,
@@ -0,0 +1,20 @@
import { All, Body, Controller, Req } from "@nestjs/common";
import type { Request } from "express";
import { AiProxyService } from "./ai-proxy.service";
@Controller("ai-engine")
export class AiProxyController {
constructor(private readonly aiProxyService: AiProxyService) {}
@All("*path")
proxy(@Req() request: Request, @Body() body: unknown) {
return this.aiProxyService.proxy({
method: request.method,
originalUrl: request.originalUrl,
query: request.query as Record<string, unknown>,
body,
acceptLanguage: request.headers["accept-language"],
});
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { AiProxyController } from "./ai-proxy.controller";
import { AiProxyService } from "./ai-proxy.service";
@Module({
imports: [
HttpModule.register({
timeout: 45000,
maxRedirects: 0,
}),
],
controllers: [AiProxyController],
providers: [AiProxyService],
})
export class AiProxyModule {}
+98
View File
@@ -0,0 +1,98 @@
import {
BadGatewayException,
ForbiddenException,
Injectable,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { AxiosError, Method } from "axios";
interface ProxyRequest {
method: string;
originalUrl: string;
query: Record<string, unknown>;
body: unknown;
acceptLanguage?: string | string[];
}
interface AllowedRoute {
method: Method;
pattern: RegExp;
}
const ALLOWED_AI_ROUTES: AllowedRoute[] = [
{ method: "GET", pattern: /^\/$/ },
{ method: "GET", pattern: /^\/health$/ },
{ method: "POST", pattern: /^\/v20plus\/analyze\/[^/]+$/ },
{ method: "GET", pattern: /^\/v20plus\/analyze-htms\/[^/]+$/ },
{ method: "GET", pattern: /^\/v20plus\/analyze-htft\/[^/]+$/ },
{ method: "POST", pattern: /^\/v20plus\/coupon$/ },
{ method: "GET", pattern: /^\/v20plus\/daily-banker$/ },
{ method: "GET", pattern: /^\/v20plus\/reversal-watchlist$/ },
{ method: "GET", pattern: /^\/v2\/health$/ },
{ method: "POST", pattern: /^\/v2\/analyze\/[^/]+$/ },
];
@Injectable()
export class AiProxyService {
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
async proxy(request: ProxyRequest) {
const path = this.extractProxyPath(request.originalUrl);
const method = request.method.toUpperCase() as Method;
if (!this.isAllowed(method, path)) {
throw new ForbiddenException("AI_PROXY_ROUTE_NOT_ALLOWED");
}
const baseUrl = this.configService.getOrThrow<string>("AI_ENGINE_URL");
const targetUrl = new URL(path, baseUrl);
try {
const response = await this.httpService.axiosRef.request({
url: targetUrl.toString(),
method,
params: request.query,
data: request.body,
headers: {
"content-type": "application/json",
"accept-language": Array.isArray(request.acceptLanguage)
? request.acceptLanguage[0]
: request.acceptLanguage,
},
timeout: 45000,
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 500,
});
return response.data;
} catch (error) {
const axiosError = error as AxiosError;
throw new BadGatewayException({
message: "AI_PROXY_UPSTREAM_FAILED",
status: axiosError.response?.status,
});
}
}
private extractProxyPath(originalUrl: string): string {
const withoutQuery = originalUrl.split("?")[0] || "";
const marker = "/ai-engine";
const markerIndex = withoutQuery.indexOf(marker);
if (markerIndex === -1) {
return "/";
}
const path = withoutQuery.slice(markerIndex + marker.length);
return path.length === 0 ? "/" : path;
}
private isAllowed(method: Method, path: string): boolean {
return ALLOWED_AI_ROUTES.some(
(route) => route.method === method && route.pattern.test(path),
);
}
}
+23 -2
View File
@@ -30,7 +30,18 @@ export class AnalysisController {
@Post("analyze-matches")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Analyze multiple matches for coupon" })
@ApiResponse({ status: 200, description: "Analysis successful" })
@ApiResponse({
status: 200,
description: "Analysis successful",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
@ApiResponse({ status: 400, description: "Invalid input" })
@ApiResponse({ status: 429, description: "Usage limit exceeded" })
async analyzeMatches(
@@ -92,7 +103,17 @@ export class AnalysisController {
*/
@Get("history")
@ApiOperation({ summary: "Get analysis history" })
@ApiResponse({ status: 200, description: "History retrieved" })
@ApiResponse({
status: 200,
description: "History retrieved",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "array", items: { type: "object" } },
},
},
})
async getHistory(@CurrentUser() user: any) {
const history = await this.analysisService.getAnalysisHistory(user.id);
return { success: true, data: history };
+11 -1
View File
@@ -67,7 +67,17 @@ export class AuthController {
@Post("logout")
@HttpCode(200)
@ApiOperation({ summary: "Logout and invalidate refresh token" })
@ApiOkResponse({ description: "Logout successful" })
@ApiOkResponse({
description: "Logout successful",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
data: { type: "null" },
},
},
})
async logout(
@Body() dto: RefreshTokenDto,
@I18n() i18n: I18nContext,
+2 -5
View File
@@ -94,11 +94,8 @@ export class RolesGuard implements CanActivate {
return false;
}
const normalizedUserRoles = (user.roles?.length
? user.roles
: user.role
? [user.role]
: []
const normalizedUserRoles = (
user.roles?.length ? user.roles : user.role ? [user.role] : []
).map((role) => normalizeRole(role));
const normalizedRequiredRoles = requiredRoles.map((role) =>
+72 -3
View File
@@ -53,7 +53,18 @@ export class CouponsController {
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Analyze single match with V20 model" })
@ApiResponse({ status: 200, description: "Match analysis" })
@ApiResponse({
status: 200,
description: "Match analysis",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
if (!analysis) {
@@ -99,6 +110,18 @@ export class CouponsController {
@ApiOperation({
summary: "Generate a high-confidence banko combo (2 matches)",
})
@ApiResponse({
status: 200,
description: "Daily banko coupon",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async getDailyBanko(@Body() dto: DailyBankoDto) {
// If no match IDs provided, fetch from system (top 50 upcoming)
let candidateMatches = dto.matchIds || [];
@@ -146,7 +169,18 @@ export class CouponsController {
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Suggest Smart Coupon" })
@ApiResponse({ status: 200, description: "Smart Coupon generated" })
@ApiResponse({
status: 200,
description: "Smart Coupon generated",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async suggestCoupon(@Body() dto: SuggestCouponDto) {
// If no match IDs provided, fetch from system (top 50 upcoming)
let candidateMatches = dto.matchIds || [];
@@ -237,6 +271,18 @@ export class CouponsController {
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Create and save a user coupon" })
@ApiResponse({
status: 201,
description: "Coupon created",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) {
// req.user is populated by JwtAuthGuard
const coupon = await this.userCouponService.createCoupon(req.user, dto);
@@ -251,6 +297,18 @@ export class CouponsController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Get user betting statistics" })
@ApiResponse({
status: 200,
description: "User statistics",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "object" },
message: { type: "string" },
},
},
})
async getUserStats(@Req() req: any) {
const stats = await this.userCouponService.getUserStatistics(req.user.id);
return { success: true, data: stats };
@@ -263,7 +321,18 @@ export class CouponsController {
@Get("history")
@ApiBearerAuth()
@ApiOperation({ summary: "Get coupon history" })
@ApiResponse({ status: 200, description: "History retrieved" })
@ApiResponse({
status: 200,
description: "History retrieved",
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: { type: "array", items: { type: "object" } },
message: { type: "string" },
},
},
})
async getHistory(@Query("limit") limit?: string) {
// eslint-disable-next-line @typescript-eslint/await-thenable
const results = await this.couponsService.getCouponHistory(
-1
View File
@@ -25,4 +25,3 @@ import { MatchesModule } from "../matches/matches.module";
],
})
export class CouponsModule {}
@@ -109,8 +109,7 @@ export class FrequencyCouponDto {
minSignal?: number;
@ApiPropertyOptional({
description:
"Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)",
description: "Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)",
example: ["OU2.5", "BTTS"],
})
@IsOptional()
@@ -108,8 +108,7 @@ export class FrequencyEngineService {
venue: "home" | "away",
oddsBand: string,
): Promise<TeamFrequencyRow | null> {
const venueColumn =
venue === "home" ? "m.home_team_id" : "m.away_team_id";
const venueColumn = venue === "home" ? "m.home_team_id" : "m.away_team_id";
const oddsSelection = venue === "home" ? "'1'" : "'2'";
const bandRange = this.parseBandRange(oddsBand);
@@ -191,7 +190,7 @@ export class FrequencyEngineService {
// OU 1.5 OVER
const ou15Combined = (homeFreq.ou15_rate + awayFreq.ou15_rate) / 2;
if (ou15Combined >= 0.80) {
if (ou15Combined >= 0.8) {
signals.push({
market: "OU1.5_OVER",
pick: "1.5 UST",
@@ -212,7 +211,7 @@ export class FrequencyEngineService {
// OU 2.5 OVER
const ou25Combined = (homeFreq.ou25_rate + awayFreq.ou25_rate) / 2;
if (ou25Combined >= 0.60) {
if (ou25Combined >= 0.6) {
signals.push({
market: "OU2.5_OVER",
pick: "2.5 UST",
@@ -233,7 +232,7 @@ export class FrequencyEngineService {
// OU 3.5 OVER
const ou35Combined = (homeFreq.ou35_rate + awayFreq.ou35_rate) / 2;
if (ou35Combined >= 0.50) {
if (ou35Combined >= 0.5) {
signals.push({
market: "OU3.5_OVER",
pick: "3.5 UST",
@@ -254,7 +253,7 @@ export class FrequencyEngineService {
// BTTS YES
const bttsCombined = (homeFreq.btts_rate + awayFreq.btts_rate) / 2;
if (bttsCombined >= 0.60) {
if (bttsCombined >= 0.6) {
signals.push({
market: "BTTS_YES",
pick: "KG VAR",
@@ -299,7 +298,7 @@ export class FrequencyEngineService {
const hwCombined = (homeFreq.win_rate + awayFreq.win_rate) / 2;
// awayFreq.win_rate aslında deplasman takımının KAYBETme oranı
// (away takımı o bandda maçları kazanma değil, kaybetme olarak bak)
if (hwCombined >= 0.70 && homeOdds > 1.10 && homeOdds < 3.50) {
if (hwCombined >= 0.7 && homeOdds > 1.1 && homeOdds < 3.5) {
signals.push({
market: "MS_HOME",
pick: "MS 1",
@@ -411,9 +410,7 @@ export class FrequencyEngineService {
/**
* Lig bazlı gol profili.
*/
async getLeagueProfile(
leagueId: string,
): Promise<LeagueProfileRow | null> {
async getLeagueProfile(leagueId: string): Promise<LeagueProfileRow | null> {
const rows = await this.prisma.$queryRawUnsafe<LeagueProfileRow[]>(
`
SELECT
@@ -521,9 +518,7 @@ export class FrequencyEngineService {
return "6.00+";
}
private parseBandRange(
band: string,
): { min: number; max: number } | null {
private parseBandRange(band: string): { min: number; max: number } | null {
const map: Record<string, { min: number; max: number }> = {
"1.00-1.30": { min: 1.0, max: 1.3 },
"1.30-1.50": { min: 1.3, max: 1.5 },
@@ -537,9 +532,7 @@ export class FrequencyEngineService {
return map[band] || null;
}
private calculateLeagueBonus(
profile: LeagueProfileRow | null,
): number {
private calculateLeagueBonus(profile: LeagueProfileRow | null): number {
if (!profile || profile.total_matches < 20) {
return 0;
}
@@ -154,9 +154,10 @@ export class SmartCouponService {
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
let prediction: SingleMatchPredictionPackage;
try {
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
`/v20plus/analyze/${matchId}`,
);
const response =
await this.aiEngineClient.post<SingleMatchPredictionPackage>(
`/v20plus/analyze/${matchId}`,
);
prediction = response.data;
} catch (error: unknown) {
if (error instanceof AiEngineRequestError) {
@@ -264,7 +265,7 @@ export class SmartCouponService {
markets?: string[];
}): Promise<FrequencyCouponResult> {
const maxMatches = options.maxMatches ?? 3;
const minSignal = options.minSignal ?? 0.70;
const minSignal = options.minSignal ?? 0.7;
const allowedMarkets = options.markets?.map((m) => m.toUpperCase()) || null;
this.logger.log(
@@ -167,7 +167,7 @@ export class FeederPersistenceService {
const leagueId = this.safeString(league.id);
if (leagueId) {
const logoUrl = `https://file.mackolikfeeds.com/areas/${leagueId}`;
const logoUrl = `https://file.mackolikfeeds.com/competitions/${leagueId}`;
const localPath = `public/uploads/competitions/${leagueId}.png`;
imageDownloads.push(
ImageUtils.downloadImage(logoUrl, localPath)
@@ -853,22 +853,89 @@ export class FeederPersistenceService {
}
async getExistingMatchIds(matchIds: string[]): Promise<string[]> {
const matches = await this.prisma.match.findMany({
where: {
id: { in: matchIds },
AND: [
{ oddCategories: { some: {} } },
{
OR: [
{ footballTeamStats: { some: {} } },
{ basketballTeamStats: { some: {} } },
],
},
],
},
select: { id: true },
});
return matches.map((m) => m.id);
if (matchIds.length === 0) return [];
// Use raw SQL for performance — Prisma's { some: {} } relation filters
// generate heavy correlated subqueries that hang on Raspberry Pi with
// large tables (15M+ odd_selections, 3M+ participations).
const result = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>(
`
SELECT m.id
FROM matches m
WHERE m.id = ANY($1::text[])
AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
AND (
(m.sport = 'football'
AND EXISTS (SELECT 1 FROM football_team_stats fts WHERE fts.match_id = m.id)
AND (SELECT count(*) FROM match_player_participation mpp
WHERE mpp.match_id = m.id AND mpp.is_starting = true) >= 18)
OR
(m.sport = 'basketball'
AND EXISTS (SELECT 1 FROM basketball_team_stats bts WHERE bts.match_id = m.id)
AND EXISTS (SELECT 1 FROM basketball_player_stats bps WHERE bps.match_id = m.id))
)
`,
matchIds,
);
return result.map((r) => r.id);
}
/**
* For a list of match IDs that ALREADY exist in DB,
* returns which data scopes are missing per match.
* Only checks completed (Ended) football/basketball matches.
*/
async getMissingScopes(matchIds: string[]): Promise<Map<string, string[]>> {
const result = new Map<string, string[]>();
if (matchIds.length === 0) return result;
// Use raw SQL for performance on Raspberry Pi.
// Note: state is 'postGame' in DB, not 'Ended'.
const rows = await this.prisma.$queryRawUnsafe<
Array<{
id: string;
sport: string;
fts_count: bigint;
pp_count: bigint;
bts_count: bigint;
bps_count: bigint;
oc_count: bigint;
}>
>(
`
SELECT m.id, m.sport::text,
(SELECT count(*) FROM football_team_stats fts WHERE fts.match_id = m.id) as fts_count,
(SELECT count(*) FROM match_player_participation mpp WHERE mpp.match_id = m.id) as pp_count,
(SELECT count(*) FROM basketball_team_stats bts WHERE bts.match_id = m.id) as bts_count,
(SELECT count(*) FROM basketball_player_stats bps WHERE bps.match_id = m.id) as bps_count,
(SELECT count(*) FROM odd_categories oc WHERE oc.match_id = m.id) as oc_count
FROM matches m
WHERE m.id = ANY($1::text[])
AND m.state = 'postGame'
`,
matchIds,
);
for (const m of rows) {
const missing: string[] = [];
if (m.sport === "football") {
if (Number(m.fts_count) === 0) missing.push("stats");
if (Number(m.pp_count) < 18) missing.push("lineups");
} else if (m.sport === "basketball") {
if (Number(m.bts_count) === 0) missing.push("stats");
if (Number(m.bps_count) === 0) missing.push("lineups");
}
if (Number(m.oc_count) === 0) missing.push("odds");
if (missing.length > 0) {
result.set(m.id, missing);
}
}
return result;
}
async hasOdds(matchId: string): Promise<boolean> {
+128 -44
View File
@@ -43,6 +43,14 @@ export class FeederService {
private readonly MAX_RETRIES = 50;
private readonly DAILY_SYNC_TIME_ZONE = "Europe/Istanbul";
/** Watchdog heartbeat updated on every match/date activity */
public lastActivityAt: number = Date.now();
/** Call this to bump the heartbeat */
private heartbeat(): void {
this.lastActivityAt = Date.now();
}
constructor(
private readonly scraperService: FeederScraperService,
private readonly transformerService: FeederTransformerService,
@@ -168,7 +176,7 @@ export class FeederService {
// writing to live_matches. Historical scan should only fill matches table.
endDate.setDate(endDate.getDate() - 2);
const stateKey = `historical_scan_state_${sports.join("_")}${targetLeagueIds.length > 0 ? "_filtered" : ""}_desc`;
const stateKey = `historical_full_data_v2_state_${sports.join("_")}${targetLeagueIds.length > 0 ? "_filtered" : ""}_desc`;
let currentDate: Date | null = null;
// Resume from saved state
@@ -259,6 +267,7 @@ export class FeederService {
): Promise<void> {
const { onlyCompletedMatches = false, refreshExistingMatches = false } =
options;
this.heartbeat();
this.logger.log(`[${sport}] 📅 Processing: ${dateString}`);
try {
@@ -310,9 +319,20 @@ export class FeederService {
const { startTs: targetDateStartTs, endTs: targetDateEndTs } =
this.getDayBoundsForTimeZone(dateString, this.DAILY_SYNC_TIME_ZONE);
// DEBUG: Log sample mstUtc values vs target bounds to diagnose filtering
if (allMatches.length > 0) {
const sample = allMatches.slice(0, 3);
this.logger.warn(
`[${sport}] [${dateString}] DEBUG: bounds=[${targetDateStartTs}, ${targetDateEndTs}] ` +
`(${new Date(targetDateStartTs * 1000).toISOString()} - ${new Date(targetDateEndTs * 1000).toISOString()}) | ` +
`sampleMstUtc=[${sample.map((m) => `${m.mstUtc} (asSec=${new Date(m.mstUtc * 1000).toISOString()}, asMs=${new Date(m.mstUtc).toISOString()})`).join(", ")}]`,
);
}
const dateFilteredMatches = allMatches.filter((m) => {
const matchTs = m.mstUtc;
return matchTs >= targetDateStartTs && matchTs <= targetDateEndTs;
// mstUtc is in milliseconds from API, bounds are in seconds
const matchTsSec = Math.floor(m.mstUtc / 1000);
return matchTsSec >= targetDateStartTs && matchTsSec <= targetDateEndTs;
});
const apiReturnedCount = allMatches.length;
@@ -365,21 +385,76 @@ export class FeederService {
return;
}
// 2. Filter out already existing matches to skip processing
// 2. Filter out already existing matches & patch incomplete ones
const allIds = matchesToProcess.map((m) => m.id);
const existingIds =
await this.persistenceService.getExistingMatchIds(allIds);
const totalCount = matchesToProcess.length;
// ── Patch incomplete existing matches ──────────────────────
// Find matches that ARE in DB but have missing data scopes
const allExistingInDb =
await this.persistenceService.getMissingScopes(allIds);
if (allExistingInDb.size > 0) {
this.logger.log(
`[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`,
);
for (const [matchId, missingScopes] of allExistingInDb) {
const matchSummary = matchesToProcess.find((m) => m.id === matchId);
if (!matchSummary) continue;
for (const scope of missingScopes) {
await this.delay(500);
try {
const patchScope: "all" | "lineups" | "odds" =
scope === "odds"
? "odds"
: scope === "lineups"
? "lineups"
: "all";
const result = await this.processSingleMatch(
matchSummary,
data.competitions,
sport,
true, // force
patchScope,
);
this.heartbeat();
if (result.success) {
this.logger.log(
`[${sport}] ✅ Patched [${scope}] for ${matchId} ${matchSummary.homeTeam.name} vs ${matchSummary.awayTeam.name}`,
);
} else {
this.logger.warn(
`[${sport}] ⚠️ Patch [${scope}] failed for ${matchId}`,
);
}
} catch (e: any) {
this.logger.warn(
`[${sport}] ❌ Patch [${scope}] exception for ${matchId}: ${e.message}`,
);
}
}
}
}
// ─────────────────────────────────────────────────────────────
// Now filter out COMPLETE existing matches (skip them)
if (!refreshExistingMatches && existingIds.length > 0) {
// Re-check after patching - which ones are now complete?
const updatedExistingIds =
await this.persistenceService.getExistingMatchIds(allIds);
matchesToProcess = matchesToProcess.filter(
(m) => !existingIds.includes(m.id),
(m) => !updatedExistingIds.includes(m.id),
);
}
if (matchesToProcess.length === 0) {
this.logger.log(
`[${sport}] [${dateString}] All ${totalCount} matches already exist. Skipping...`,
`[${sport}] [${dateString}] All ${totalCount} matches processed (${existingIds.length} existed, ${allExistingInDb.size} patched). Done.`,
);
return;
}
@@ -390,7 +465,7 @@ export class FeederService {
);
} else {
this.logger.log(
`[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} matches (Skipped ${existingIds.length} existing)`,
`[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} new matches (${existingIds.length} existing, ${allExistingInDb.size} patched)`,
);
}
@@ -420,6 +495,7 @@ export class FeederService {
refreshExistingMatches,
);
this.heartbeat();
if (result.success) {
this.logger.log(
`[${sport}] ✅ successful for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`,
@@ -432,6 +508,7 @@ export class FeederService {
failedMatches.push(match);
}
} catch (e: any) {
this.heartbeat();
this.logger.warn(
`[${sport}] Sequential error for ${match.id}: ${e.message}`,
);
@@ -452,7 +529,7 @@ export class FeederService {
match,
data.competitions,
sport,
refreshExistingMatches,
true, // FORCE: re-fetch incomplete data
);
if (result.success) {
successCount++;
@@ -753,14 +830,12 @@ export class FeederService {
}
// Starting Formation & Substitutes (Always for lineups or all)
// V20 OPTIMIZATION: Disabled to speed up feeder and reduce 502 errors.
// We only use Team Stats for V20 model.
/*
if (scope === 'all' || scope === 'lineups') {
if (scope === "all" || scope === "lineups") {
// Starting Formation
try {
const formationData =
await this.scraperService.fetchStartingFormation(matchId);
const formationData = await fetchResilient("Formation", () =>
this.scraperService.fetchStartingFormation(matchId),
);
if (formationData?.stats) {
this.transformerService.processLineup(
formationData.stats.home || [],
@@ -780,14 +855,15 @@ export class FeederService {
);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Formation failed: ${e.message}`);
}
// Substitutes
try {
const subsData =
await this.scraperService.fetchSubstitutions(matchId);
const subsData = await fetchResilient("Subs", () =>
this.scraperService.fetchSubstitutions(matchId),
);
if (subsData?.stats) {
this.transformerService.processLineup(
subsData.stats.home || [],
@@ -807,11 +883,10 @@ export class FeederService {
);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Subs failed: ${e.message}`);
}
}
*/
// Game Stats & Officials
if (scope === "all") {
@@ -869,7 +944,37 @@ export class FeederService {
}
}
// 4. Persist to Database
// ── Pre-save completeness gate ──────────────────────────────
// If a 502 caused missing data, do NOT save. The data exists on
// the API and will be available shortly. Skip and retry instead.
const completedMatch = isMatchCompleted({
state: headerData?.matchStatus ?? matchSummary.state,
status: matchSummary.status,
substate: matchSummary.substate,
statusBoxContent: matchSummary.statusBoxContent,
scoreHome: headerData?.scoreHome ?? matchSummary.score?.home,
scoreAway: headerData?.scoreAway ?? matchSummary.score?.away,
});
const missingParts: string[] = [];
if (scope === "all" && completedMatch) {
if (sport === "football" && !stats) missingParts.push("Stats");
if (sport === "football" && participationData.length < 18)
missingParts.push("Lineups");
if (sport === "basketball" && !basketballTeamStats)
missingParts.push("BoxScore");
if (oddsArray.length === 0) missingParts.push("Odds");
}
// 502 caused missing data → do NOT save, retry later
if (hasCriticalError && missingParts.length > 0) {
this.logger.warn(
`[${matchId}] ⛔ SKIPPED SAVE: 502 errors caused missing [${missingParts.join(", ")}]. Will retry for complete data.`,
);
return { success: false, retryable: true };
}
// 4. SAVE
let saved = false;
if (scope === "lineups") {
saved = await this.persistenceService.saveLineups(
@@ -923,32 +1028,11 @@ export class FeederService {
*/
// ==========================================
const completedMatch = isMatchCompleted({
state: headerData?.matchStatus ?? matchSummary.state,
status: matchSummary.status,
substate: matchSummary.substate,
statusBoxContent: matchSummary.statusBoxContent,
scoreHome: headerData?.scoreHome ?? matchSummary.score?.home,
scoreAway: headerData?.scoreAway ?? matchSummary.score?.away,
});
const missingParts: string[] = [];
if (scope === "all" && completedMatch) {
if (sport === "football" && !stats) missingParts.push("Stats");
if (sport === "basketball" && !basketballTeamStats)
missingParts.push("BoxScore");
if (oddsArray.length === 0) missingParts.push("Odds");
}
if (saved && (hasCriticalError || missingParts.length > 0)) {
const reason = hasCriticalError
? "missing data after upstream errors"
: "incomplete completed-match payload";
// No 502 but data genuinely missing → save anyway, log warning
if (saved && missingParts.length > 0) {
this.logger.warn(
`[${matchId}] Saved with ${reason}. Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
`[${matchId}] Saved but data genuinely missing (no 502): [${missingParts.join(", ")}]`,
);
return { success: false, retryable: true };
}
return { success: saved, retryable: !saved };
+14 -2
View File
@@ -1,5 +1,5 @@
import { Controller, Get, Res } from "@nestjs/common";
import { ApiTags, ApiOperation } from "@nestjs/swagger";
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import type { Response } from "express";
import { Public } from "../../common/decorators";
import { PrismaService } from "../../database/prisma.service";
@@ -52,6 +52,17 @@ export class HealthController {
@Get("live")
@Public()
@ApiOperation({ summary: "Liveness check" })
@ApiResponse({
status: 200,
description: "System liveness",
schema: {
type: "object",
properties: {
status: { type: "string" },
timestamp: { type: "string" },
},
},
})
liveness(@Res() response: Response) {
return response
.status(200)
@@ -83,7 +94,8 @@ export class HealthController {
} catch (error: unknown) {
return {
status: "down",
detail: error instanceof Error ? error.message : "Unknown database error",
detail:
error instanceof Error ? error.message : "Unknown database error",
};
}
}
+65 -5
View File
@@ -28,7 +28,11 @@ export class LeaguesController {
@Get("countries")
@Public()
@ApiOperation({ summary: "Get all countries" })
@ApiResponse({ status: 200, description: "List of countries" })
@ApiResponse({
status: 200,
description: "List of countries",
schema: { type: "array", items: { type: "object" } },
})
async getCountries() {
return this.leaguesService.findAllCountries();
}
@@ -40,6 +44,11 @@ export class LeaguesController {
@Get("countries/:id")
@Public()
@ApiOperation({ summary: "Get country by ID with leagues" })
@ApiResponse({
status: 200,
description: "Country details",
schema: { type: "object" },
})
@ApiParam({ name: "id", description: "Country ID" })
async getCountryById(@Param("id") id: string) {
const country = await this.leaguesService.findCountryById(id);
@@ -54,6 +63,11 @@ export class LeaguesController {
@Get()
@Public()
@ApiOperation({ summary: "Get all leagues" })
@ApiResponse({
status: 200,
description: "List of leagues",
schema: { type: "array", items: { type: "object" } },
})
@ApiQuery({
name: "sport",
required: false,
@@ -71,6 +85,11 @@ export class LeaguesController {
@Get("teams/h2h")
@Public()
@ApiOperation({ summary: "Get head-to-head matches between two teams" })
@ApiResponse({
status: 200,
description: "Head-to-head matches",
schema: { type: "array", items: { type: "object" } },
})
@ApiQuery({ name: "team1", required: true })
@ApiQuery({ name: "team2", required: true })
@ApiQuery({ name: "limit", required: false, type: Number })
@@ -93,6 +112,11 @@ export class LeaguesController {
@Get("teams/search")
@Public()
@ApiOperation({ summary: "Search teams by name" })
@ApiResponse({
status: 200,
description: "List of teams matching search",
schema: { type: "array", items: { type: "object" } },
})
@ApiQuery({ name: "q", required: true, description: "Search query" })
@ApiQuery({
name: "sport",
@@ -110,6 +134,11 @@ export class LeaguesController {
@Get("teams/:id")
@Public()
@ApiOperation({ summary: "Get team by ID" })
@ApiResponse({
status: 200,
description: "Team details",
schema: { type: "object" },
})
@ApiParam({ name: "id", description: "Team ID" })
async getTeamById(@Param("id") id: string) {
const team = await this.leaguesService.findTeamById(id);
@@ -124,10 +153,36 @@ export class LeaguesController {
@Get("teams/:id/matches")
@Public()
@ApiOperation({ summary: "Get team's recent matches (paginated)" })
@ApiResponse({
status: 200,
description: "Paginated list of matches",
schema: {
type: "object",
properties: {
data: { type: "array", items: { type: "object" } },
meta: { type: "object" },
},
},
})
@ApiParam({ name: "id", description: "Team ID" })
@ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" })
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" })
@ApiQuery({ name: "season", required: false, type: String, description: "Season (e.g. 2024-2025)" })
@ApiQuery({
name: "page",
required: false,
type: Number,
description: "Page number (default: 1)",
})
@ApiQuery({
name: "limit",
required: false,
type: Number,
description: "Items per page (default: 20)",
})
@ApiQuery({
name: "season",
required: false,
type: String,
description: "Season (e.g. 2024-2025)",
})
async getTeamMatches(
@Param("id") id: string,
@Query("page") page?: string,
@@ -138,7 +193,7 @@ export class LeaguesController {
id,
parseInt(page || "1", 10),
parseInt(limit || "20", 10),
season
season,
);
}
@@ -149,6 +204,11 @@ export class LeaguesController {
@Get(":id")
@Public()
@ApiOperation({ summary: "Get league by ID" })
@ApiResponse({
status: 200,
description: "League details",
schema: { type: "object" },
})
@ApiParam({ name: "id", description: "League ID" })
async getLeagueById(@Param("id") id: string) {
const league = await this.leaguesService.findLeagueById(id);
+8 -3
View File
@@ -105,7 +105,7 @@ export class LeaguesService {
teamId: string,
page: number = 1,
limit: number = 20,
season?: string
season?: string,
) {
const skip = (page - 1) * limit;
const where: any = {
@@ -123,7 +123,9 @@ export class LeaguesService {
// Season starts August 1st of startYear
const startDate = new Date(Date.UTC(startYear, 7, 1)).getTime();
// Season ends July 31st of endYear
const endDate = new Date(Date.UTC(endYear, 6, 31, 23, 59, 59, 999)).getTime();
const endDate = new Date(
Date.UTC(endYear, 6, 31, 23, 59, 59, 999),
).getTime();
where.mstUtc = {
gte: startDate,
@@ -186,7 +188,10 @@ export class LeaguesService {
{ homeTeamId: teamId1, awayTeamId: teamId2 },
{ homeTeamId: teamId2, awayTeamId: teamId1 },
],
state: "postGame", // Finished matches are stored as "postGame"
AND: [
{ scoreHome: { not: null } },
{ scoreAway: { not: null } },
],
},
include: {
homeTeam: true,
+12 -1
View File
@@ -71,7 +71,17 @@ export class MatchesController {
@ApiQuery({ name: "page", required: false, type: Number })
@ApiQuery({ name: "limit", required: false, type: Number })
@ApiQuery({ name: "sport", required: false, enum: Sport })
@ApiResponse({ status: 200, description: "Paginated list of matches" })
@ApiResponse({
status: 200,
description: "Paginated list of matches",
schema: {
type: "object",
properties: {
data: { type: "array", items: { type: "object" } },
meta: { type: "object" },
},
},
})
async listMatches(
@Query("page") page?: string,
@Query("limit") limit?: string,
@@ -112,6 +122,7 @@ export class MatchesController {
@ApiResponse({
status: 200,
description: "Match details with lineups, stats, odds, events",
schema: { type: "object" },
})
@ApiResponse({ status: 404, description: "Match not found" })
async getMatchDetails(@Param("id") id: string) {
+406 -43
View File
@@ -20,26 +20,55 @@ import {
@Injectable()
export class MatchesService {
private readonly logger = new Logger(MatchesService.name);
private qualifiedLeagueIds: string[] = [];
private topLeagueIds: string[] = [];
constructor(private readonly prisma: PrismaService) {
this.loadQualifiedLeagues();
this.loadTopLeagues();
}
private loadTopLeagues() {
try {
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (fs.existsSync(topLeaguesPath)) {
this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
this.logger.log(
`Loaded ${this.topLeagueIds.length} top leagues for filtering.`,
);
const filePath = path.join(process.cwd(), "top_leagues.json");
if (fs.existsSync(filePath)) {
this.topLeagueIds = JSON.parse(fs.readFileSync(filePath, "utf8"));
}
} catch (e) {
this.logger.warn(`Failed to load top_leagues.json: ${e.message}`);
}
}
private loadQualifiedLeagues() {
try {
const filePath = path.join(process.cwd(), "qualified_leagues.json");
if (fs.existsSync(filePath)) {
this.qualifiedLeagueIds = JSON.parse(fs.readFileSync(filePath, "utf8"));
this.logger.log(
`Loaded ${this.qualifiedLeagueIds.length} qualified leagues for filtering.`,
);
}
} catch (e) {
this.logger.warn(`Failed to load qualified_leagues.json: ${e.message}`);
}
}
/**
* Generate URL for the country flag served from Mackolik
*/
private getCountryFlagUrl(countryId?: string | null): string | undefined {
if (!countryId) return undefined;
return `https://file.mackolikfeeds.com/areas/${countryId}`;
}
/**
* Generate URL for the team logo served from local uploads
*/
private getTeamLogoUrl(teamId?: string | null): string | undefined {
if (!teamId) return undefined;
return `https://file.mackolikfeeds.com/teams/${teamId}`;
}
private getLiveFilter(): Prisma.LiveMatchWhereInput {
return {
OR: [
@@ -139,9 +168,9 @@ export class MatchesService {
if (leagueId) {
where.leagueId = leagueId;
} else if (status === "LIVE" && this.topLeagueIds.length > 0) {
// Filter live matches by top leagues by default if no leagueId is provided
where.leagueId = { in: this.topLeagueIds };
} else if (this.qualifiedLeagueIds.length > 0) {
// Only show matches from qualified leagues (leagues with historical data for AI analysis)
where.leagueId = { in: this.qualifiedLeagueIds };
}
if (status === "LIVE") {
@@ -298,7 +327,9 @@ export class MatchesService {
country: {
id: match.league?.country?.id || "",
name: match.league?.country?.name || "",
flagUrl: match.league?.country?.flagUrl || undefined,
flagUrl:
match.league?.country?.flagUrl ||
this.getCountryFlagUrl(match.league?.country?.id),
},
sport: sport,
matches: [],
@@ -353,11 +384,11 @@ export class MatchesService {
htScoreAway: undefined,
homeTeamName: match.homeTeam?.name || "Unknown",
homeTeamLogo: match.homeTeamId
? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}`
? this.getTeamLogoUrl(match.homeTeamId)
: undefined,
awayTeamName: match.awayTeam?.name || "Unknown",
awayTeamLogo: match.awayTeamId
? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}`
? this.getTeamLogoUrl(match.awayTeamId)
: undefined,
leagueName: match.league?.name,
countryName: match.league?.country?.name,
@@ -365,48 +396,62 @@ export class MatchesService {
});
}
return Array.from(leaguesMap.values());
return Array.from(leaguesMap.values()).sort((a, b) => {
const aIdx = this.topLeagueIds.indexOf(a.id);
const bIdx = this.topLeagueIds.indexOf(b.id);
const aPriority = aIdx === -1 ? 999 : aIdx;
const bPriority = bIdx === -1 ? 999 : bIdx;
if (aPriority !== bPriority) return aPriority - bPriority;
return (a.name || "").localeCompare(b.name || "");
});
}
/**
* Get active leagues with match counts
*/
async getActiveLeagues(sport: Sport): Promise<ActiveLeagueDto[]> {
// Start of today in UTC — same reference point as findMatches browse filter
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const todayMs = BigInt(today.getTime());
// Build finished statuses/states for exclusion (mirroring getBrowseFilter logic)
const finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB;
const finishedStates = FINISHED_STATE_VALUES_FOR_DB;
const liveStatuses = LIVE_STATUS_VALUES_FOR_DB;
const liveStates = LIVE_STATE_VALUES_FOR_DB;
// Use raw query for complex aggregation
// Filter: (mstUtc >= today AND NOT finished) OR is currently live
const leagues = await this.prisma.$queryRaw<any[]>`
SELECT
l.id, l.name, l.code,
c.id as country_id,
c.name as country_name,
c.flag_url as country_flag,
COUNT(lm.id)::int as match_count,
COUNT(CASE WHEN lm.status IN ('LIVE', '1H', '2H', 'HT', '1Q', '2Q', '3Q', '4Q', 'Playing', 'Half Time')
OR lm.state IN ('live', 'firsthalf', 'secondhalf') THEN 1 END)::int as live_count
COUNT(CASE WHEN lm.status IN (${Prisma.join(liveStatuses)})
OR lm.state IN (${Prisma.join(liveStates)}) THEN 1 END)::int as live_count
FROM live_matches lm
JOIN leagues l ON lm.league_id = l.id
LEFT JOIN countries c ON l.country_id = c.id
WHERE lm.sport = ${sport}
${this.topLeagueIds.length > 0 ? Prisma.sql`AND l.id IN (${Prisma.join(this.topLeagueIds)})` : Prisma.empty}
GROUP BY l.id, l.name, l.code, c.name, c.flag_url
${this.qualifiedLeagueIds.length > 0 ? Prisma.sql`AND l.id IN (${Prisma.join(this.qualifiedLeagueIds)})` : Prisma.empty}
AND (
(lm.mst_utc >= ${todayMs} AND lm.status NOT IN (${Prisma.join(finishedStatuses)}) AND COALESCE(lm.state, '') NOT IN (${Prisma.join(finishedStates)}))
OR lm.status IN (${Prisma.join(liveStatuses)})
OR lm.state IN (${Prisma.join(liveStates)})
)
GROUP BY l.id, l.name, l.code, c.id, c.name, c.flag_url
ORDER BY l.name ASC
`;
// Priority sorting (Mackolik style)
const PRIORITY = [
"Trendyol Süper Lig",
"Süper Lig",
"Trendyol 1. Lig",
"1. Lig",
"Premier Lig",
"LaLiga",
"Serie A",
"Bundesliga",
"Ligue 1",
];
return leagues
.filter((l) => l.match_count > 0)
.sort((a, b) => {
const aIdx = PRIORITY.findIndex((p) => a.name?.includes(p));
const bIdx = PRIORITY.findIndex((p) => b.name?.includes(p));
const aIdx = this.topLeagueIds.indexOf(a.id);
const bIdx = this.topLeagueIds.indexOf(b.id);
const aPriority = aIdx === -1 ? 999 : aIdx;
const bPriority = bIdx === -1 ? 999 : bIdx;
@@ -419,7 +464,7 @@ export class MatchesService {
name: l.name,
code: l.code,
countryName: l.country_name,
countryFlag: l.country_flag,
countryFlag: l.country_flag || this.getCountryFlagUrl(l.country_id),
matchCount: l.match_count,
liveCount: l.live_count,
}));
@@ -458,13 +503,9 @@ export class MatchesService {
scoreAway: m.scoreAway,
status: m.status,
homeTeamName: m.homeTeam?.name,
homeTeamLogo: m.homeTeamId
? `https://file.mackolikfeeds.com/teams/${m.homeTeamId}`
: null,
homeTeamLogo: m.homeTeamId ? this.getTeamLogoUrl(m.homeTeamId) : null,
awayTeamName: m.awayTeam?.name,
awayTeamLogo: m.awayTeamId
? `https://file.mackolikfeeds.com/teams/${m.awayTeamId}`
: null,
awayTeamLogo: m.awayTeamId ? this.getTeamLogoUrl(m.awayTeamId) : null,
leagueName: m.league?.name,
countryName: m.league?.country?.name,
})),
@@ -586,7 +627,59 @@ export class MatchesService {
date: new Date(Number(liveMatch.mstUtc)),
// Fill missing relations with empty arrays
teamStats: [],
playerParticipations: [],
playerParticipations: (() => {
const parsed: Array<{
teamId: string;
isStarting: boolean;
shirtNumber: string | number | null;
position: string | null;
player: { id: string; name: string };
}> = [];
const canTrustFeedLineups =
displayStatus === "LIVE" || displayStatus === "Finished";
if (!canTrustFeedLineups) {
return parsed;
}
if (liveMatch.lineups && typeof liveMatch.lineups === "object") {
const lu = liveMatch.lineups as Record<string, any>;
const addPlayers = (teamLu: any, teamId: string | null) => {
if (!teamLu || !teamId) return;
if (teamLu.xi && Array.isArray(teamLu.xi)) {
teamLu.xi.forEach((p: any) => {
parsed.push({
teamId,
isStarting: true,
shirtNumber: p.shirtNumber || p.number,
position: p.position || p.pos,
player: {
id: p.personId || p.id || p.playerId || "unknown",
name:
p.matchName || p.name || p.playerName || "Bilinmiyor",
},
});
});
}
if (teamLu.subs && Array.isArray(teamLu.subs)) {
teamLu.subs.forEach((p: any) => {
parsed.push({
teamId,
isStarting: false,
shirtNumber: p.shirtNumber || p.number,
position: p.position || p.pos,
player: {
id: p.personId || p.id || p.playerId || "unknown",
name:
p.matchName || p.name || p.playerName || "Bilinmiyor",
},
});
});
}
};
addPlayers(lu.home, liveMatch.homeTeamId);
addPlayers(lu.away, liveMatch.awayTeamId);
}
return parsed;
})(),
playerEvents: [],
oddCategories: [], // Will handle odds parsing below
officials: [],
@@ -597,6 +690,65 @@ export class MatchesService {
if (!match) return null;
const detailDisplayStatus = getDisplayMatchStatus({
state: match.state,
status: match.status,
substate: match.substate,
scoreHome: match.scoreHome,
scoreAway: match.scoreAway,
});
const canTrustStoredLineups =
this.canTrustStoredLineups(detailDisplayStatus);
if (Array.isArray(match.playerParticipations)) {
if (!canTrustStoredLineups) {
match.playerParticipations = [];
}
const hasHomeLineup = match.playerParticipations.some(
(p: any) => p.teamId === match.homeTeamId && p.isStarting,
);
const hasAwayLineup = match.playerParticipations.some(
(p: any) => p.teamId === match.awayTeamId && p.isStarting,
);
if (!hasHomeLineup || !hasAwayLineup) {
const sidelined =
match.sidelined && typeof match.sidelined === "object"
? (match.sidelined as Record<string, any>)
: {};
const matchDateMs = Number(match.mstUtc || Date.now());
const probableLineups: any[] = [];
if (!hasHomeLineup && match.homeTeamId) {
probableLineups.push(
...(await this.buildProbableLineupForTeam({
teamId: match.homeTeamId,
beforeDateMs: matchDateMs,
sidelinedTeamData: sidelined.homeTeam,
})),
);
}
if (!hasAwayLineup && match.awayTeamId) {
probableLineups.push(
...(await this.buildProbableLineupForTeam({
teamId: match.awayTeamId,
beforeDateMs: matchDateMs,
sidelinedTeamData: sidelined.awayTeam,
})),
);
}
if (probableLineups.length > 0) {
match.playerParticipations = canTrustStoredLineups
? [...match.playerParticipations, ...probableLineups]
: probableLineups;
match.lineupSource = "probable_xi";
}
}
}
// Structure odds
const odds: Record<
string,
@@ -650,10 +802,19 @@ export class MatchesService {
teamStats: normalizedTeamStats,
mstUtc: Number(match.mstUtc),
date: match.date || new Date(Number(match.mstUtc)),
// Ensure score is in expected format (nested object for frontend if needed, but frontend seems to use match.score.home in some places and match.scoreHome in others.
// The match-detail-content uses match.score.home. Match entity has scoreHome/scoreAway fields.
// Let's ensure compatibility.
score: match.score || { home: match.scoreHome, away: match.scoreAway },
homeTeam: {
...match.homeTeam,
logo: match.homeTeamId
? this.getTeamLogoUrl(match.homeTeamId)
: match.homeTeam?.logoUrl || null,
},
awayTeam: {
...match.awayTeam,
logo: match.awayTeamId
? this.getTeamLogoUrl(match.awayTeamId)
: match.awayTeam?.logoUrl || null,
},
stats: {
home: this.normalizeTeamStat(homeStat, match.sport),
away: this.normalizeTeamStat(awayStat, match.sport),
@@ -699,4 +860,206 @@ export class MatchesService {
return team?.id || null;
}
private async buildProbableLineupForTeam(params: {
teamId: string;
beforeDateMs: number;
sidelinedTeamData?: any;
matchLimit?: number;
lookbackDays?: number;
maxStalenessDays?: number;
}) {
const matchLimit = params.matchLimit ?? 5;
const lookbackDays = params.lookbackDays ?? 370;
const maxStalenessDays = params.maxStalenessDays ?? 120;
const beforeDateMs = params.beforeDateMs || Date.now();
const minDateMs = Math.max(
0,
beforeDateMs - lookbackDays * 24 * 60 * 60 * 1000,
);
const excluded = this.extractSidelinedPlayerIds(params.sidelinedTeamData);
const rows = await this.prisma.$queryRaw<any[]>`
SELECT
mpp.player_id AS "playerId",
p.name AS "playerName",
mpp.position AS "position",
mpp.shirt_number AS "shirtNumber",
m.id AS "matchId",
m.mst_utc AS "mstUtc"
FROM match_player_participation mpp
JOIN matches m ON m.id = mpp.match_id
JOIN players p ON p.id = mpp.player_id
WHERE mpp.team_id = ${params.teamId}
AND mpp.is_starting = true
AND NOT EXISTS (
SELECT 1
FROM match_player_participation later_mpp
JOIN matches later_m ON later_m.id = later_mpp.match_id
WHERE later_mpp.player_id = mpp.player_id
AND later_mpp.team_id <> ${params.teamId}
AND later_m.mst_utc > m.mst_utc
AND later_m.mst_utc < ${BigInt(beforeDateMs)}
AND (
later_m.status = 'FT'
OR later_m.state = 'postGame'
OR (later_m.score_home IS NOT NULL AND later_m.score_away IS NOT NULL)
)
)
AND m.id IN (
SELECT m2.id
FROM matches m2
JOIN match_player_participation recent_mpp
ON recent_mpp.match_id = m2.id
AND recent_mpp.team_id = ${params.teamId}
AND recent_mpp.is_starting = true
WHERE (m2.home_team_id = ${params.teamId} OR m2.away_team_id = ${params.teamId})
AND (
m2.status = 'FT'
OR m2.state = 'postGame'
OR (m2.score_home IS NOT NULL AND m2.score_away IS NOT NULL)
)
AND m2.mst_utc < ${BigInt(beforeDateMs)}
AND m2.mst_utc >= ${BigInt(minDateMs)}
GROUP BY m2.id
HAVING COUNT(recent_mpp.*) >= 9
ORDER BY MAX(m2.mst_utc) DESC
LIMIT ${matchLimit}
)
ORDER BY m.mst_utc DESC
`;
if (!rows.length) return [];
const latestMst = Math.max(...rows.map((row) => Number(row.mstUtc || 0)));
const ageDays =
latestMst > 0
? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000)
: Number.POSITIVE_INFINITY;
const staleProjection = ageDays > maxStalenessDays;
const matchOrder = new Map<string, number>();
for (const row of rows) {
const matchId = String(row.matchId);
if (!matchOrder.has(matchId)) {
matchOrder.set(matchId, matchOrder.size);
}
}
const playerMap = new Map<
string,
{
playerId: string;
playerName: string;
position: string | null;
shirtNumber: number | null;
score: number;
starts: number;
lastSeenRank: number;
}
>();
for (const row of rows) {
const playerId = String(row.playerId);
if (excluded.has(playerId)) continue;
const rank = matchOrder.get(String(row.matchId)) ?? matchLimit;
const recencyWeight = Math.max(1, matchLimit - rank);
const score = recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
const existing = playerMap.get(playerId);
if (!existing) {
playerMap.set(playerId, {
playerId,
playerName: row.playerName || "Bilinmiyor",
position: row.position ?? null,
shirtNumber:
row.shirtNumber === null || row.shirtNumber === undefined
? null
: Number(row.shirtNumber),
score,
starts: 1,
lastSeenRank: rank,
});
} else {
existing.score += score;
existing.starts += 1;
existing.lastSeenRank = Math.min(existing.lastSeenRank, rank);
existing.position = existing.position || row.position || null;
existing.shirtNumber =
existing.shirtNumber ??
(row.shirtNumber === null || row.shirtNumber === undefined
? null
: Number(row.shirtNumber));
}
}
const ranked = [...playerMap.values()]
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (b.starts !== a.starts) return b.starts - a.starts;
return a.lastSeenRank - b.lastSeenRank;
})
.slice(0, 11);
const coverage = Math.min(1, ranked.length / 11);
const historyScore = Math.min(1, matchOrder.size / matchLimit);
const stableCore = ranked.filter((p) => p.starts >= 2).length / 11;
const stalenessFactor = Math.max(
0.35,
Math.min(1, maxStalenessDays / Math.max(ageDays, 1)),
);
const confidence = Math.max(
0,
Math.min(
staleProjection ? 0.58 : 0.88,
(coverage * 0.45 + historyScore * 0.25 + stableCore * 0.3) *
stalenessFactor,
),
);
return ranked.map((p) => ({
teamId: params.teamId,
isStarting: true,
shirtNumber: p.shirtNumber,
position: p.position,
isProbable: true,
lineupSource: "probable_xi",
projectionConfidence: Number(confidence.toFixed(3)),
projectionAgeDays: Number(ageDays.toFixed(1)),
projectionStale: staleProjection,
projectionMatchLimit: matchLimit,
projectionLookbackDays: lookbackDays,
projectionMaxStalenessDays: maxStalenessDays,
player: {
id: p.playerId,
name: p.playerName,
},
}));
}
private extractSidelinedPlayerIds(teamData: any): Set<string> {
if (!teamData || typeof teamData !== "object") return new Set();
const players = Array.isArray(teamData.players) ? teamData.players : [];
return new Set(
players
.map((player: any) =>
String(
player?.playerId ??
player?.player_id ??
player?.id ??
player?.personId ??
"",
),
)
.filter(Boolean),
);
}
private canTrustStoredLineups(displayStatus?: string): boolean {
const normalized = String(displayStatus || "").toLowerCase();
return (
normalized === "live" || normalized === "finished" || normalized === "ft"
);
}
}
@@ -56,6 +56,11 @@ export class PredictionsController {
*/
@Get("test/:id")
@ApiOperation({ summary: "Refetch match data and get prediction" })
@ApiResponse({
status: 200,
description: "Prediction details",
schema: { type: "object" },
})
@ApiParam({ name: "id", description: "Match ID" })
async getTestPrediction(@Param("id") id: string) {
return this.predictionsService.testPrediction(id);
@@ -91,16 +96,20 @@ export class PredictionsController {
@Public()
@ApiOperation({ summary: "Get prediction for a specific match" })
@ApiParam({ name: "matchId", description: "Match ID" })
@ApiResponse({ status: 200, type: MatchPredictionDto })
@ApiResponse({
status: 200,
description: "Match prediction",
schema: { type: "object" },
type: MatchPredictionDto,
})
@ApiResponse({ status: 404, description: "Match not found" })
async getPrediction(
@Param("matchId") matchId: string,
): Promise<MatchPredictionDto> {
// Check cache first - DISABLED per user request to always fetch from scratch
// const cached = await this.predictionsService.getCachedPrediction(matchId);
// if (cached) {
// return cached;
// }
const cached = await this.predictionsService.getCachedPrediction(matchId);
if (cached) {
return cached;
}
// Get from AI Engine
const prediction = await this.predictionsService.getPredictionById(matchId);
@@ -146,6 +155,7 @@ export class PredictionsController {
@ApiResponse({
status: 200,
description: "Smart coupon generated successfully",
schema: { type: "object" },
})
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
const coupon = await this.predictionsService.getSmartCoupon(
+290 -71
View File
@@ -21,6 +21,10 @@ import {
} from "./dto";
import { Prisma } from "@prisma/client";
import { FeederService } from "../feeder/feeder.service";
import {
isMatchCompleted,
isMatchLive,
} from "../../common/utils/match-status.util";
import * as fs from "node:fs";
import * as path from "node:path";
import {
@@ -49,7 +53,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private queueEvents: QueueEvents | null = null;
private readonly aiEngineUrl: string;
private readonly aiEngineClient: AiEngineClient;
private readonly topLeagueIds = new Set<string>();
private readonly qualifiedLeagueIds = new Set<string>();
private readonly reasonTranslations: Record<string, string> = {
confidence_below_threshold: "Güven eşiğin altında",
confidence_interval_too_wide: "Güven aralığı çok geniş",
@@ -137,7 +141,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
maxRetries: 2,
retryDelayMs: 750,
});
this.topLeagueIds = this.loadTopLeagueIds();
this.qualifiedLeagueIds = this.loadQualifiedLeagueIds();
}
onModuleInit() {
@@ -155,6 +159,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
}
private predictionMemCache = new Map<
string,
{ timestamp: number; payload: MatchPredictionDto }
>();
async onModuleDestroy() {
if (this.queueEvents) {
await this.queueEvents.close();
@@ -177,8 +186,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
status: response.data?.status || "healthy",
modelLoaded: response.data?.model_loaded ?? true,
predictionServiceReady:
response.data?.prediction_service_ready ?? true,
predictionServiceReady: response.data?.prediction_service_ready ?? true,
aiEngineReachable: true,
circuitState: circuit.state,
consecutiveFailures: circuit.consecutiveFailures,
@@ -223,11 +231,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
`/v20plus/analyze/${matchId}`,
{ simulate: true, is_simulation: true, pre_match_only: true },
);
await this.recordPredictionRun(matchId, response.data);
return this.enrichPredictionResponse(
response.data as MatchPredictionDto,
const prediction = this.enrichPredictionResponse(
response.data,
matchContext,
);
await this.recordPredictionRun(matchId, response.data);
await this.cachePrediction(matchId, prediction);
return prediction;
} catch (e: unknown) {
const requestError =
e instanceof AiEngineRequestError
@@ -235,11 +245,69 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
: new AiEngineRequestError("AI Engine request failed");
const status = requestError.status;
const detail = requestError.detail || requestError.message;
// ── Cooldown fallback cascade: memCache → DB stored → DB cached → wait & retry ──
if (
status === HttpStatus.SERVICE_UNAVAILABLE &&
this.hasCooldown(detail)
) {
// 1) In-memory cache (10min TTL)
const memCached = this.predictionMemCache.get(matchId);
if (memCached && Date.now() - memCached.timestamp < 10 * 60 * 1000) {
this.logger.warn(
`AI Engine cooldown for ${matchId}; returning mem-cached prediction`,
);
return memCached.payload;
}
// 2) DB stored prediction (no TTL filter)
const storedPrediction = await this.getStoredPrediction(matchId);
if (storedPrediction) {
this.logger.warn(
`AI Engine cooldown for ${matchId}; returning stored prediction`,
);
return this.enrichPredictionResponse(storedPrediction, matchContext);
}
// 3) DB cached prediction (with model version check)
const cachedPrediction = await this.getCachedPrediction(matchId);
if (cachedPrediction) {
this.logger.warn(
`AI Engine cooldown for ${matchId}; returning cached prediction`,
);
return this.enrichPredictionResponse(cachedPrediction, matchContext);
}
// 4) No cached data at all — return null gracefully
this.logger.warn(
`AI Engine cooldown for ${matchId}; no cached data available — returning null gracefully`,
);
return null;
}
// ── Non-cooldown errors (e.g. AI Engine 500 for this match) ──
// Try DB fallback before giving up
const storedFallback = await this.getStoredPrediction(matchId);
if (storedFallback) {
this.logger.warn(
`AI Engine failed for ${matchId} (status=${status}); returning stored prediction as fallback`,
);
return this.enrichPredictionResponse(storedFallback, matchContext);
}
const cachedFallback = await this.getCachedPrediction(matchId);
if (cachedFallback) {
this.logger.warn(
`AI Engine failed for ${matchId} (status=${status}); returning cached prediction as fallback`,
);
return this.enrichPredictionResponse(cachedFallback, matchContext);
}
this.logger.error(
`Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`,
);
// Forward AI Engine's actual error
// Forward AI Engine's actual error for client-meaningful statuses
if (status === 404) {
throw new HttpException(
`Match not found in AI Engine: ${matchId}`,
@@ -252,10 +320,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
throw new HttpException(
`AI Engine error: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
status || HttpStatus.SERVICE_UNAVAILABLE,
// For server errors (500, 503 etc.) return null instead of throwing
// This prevents the user from seeing raw 503 errors
this.logger.warn(
`AI Engine server error for ${matchId}; returning null gracefully instead of ${status}`,
);
return null;
}
}
@@ -314,33 +385,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
match_date_ms: Number(p.match.mstUtc) * 1000,
league: p.match.league?.name || "",
league_id: p.match.leagueId,
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""),
is_top_league: this.qualifiedLeagueIds.has(p.match.leagueId ?? ""),
},
} as unknown as MatchPredictionDto;
}),
};
}
private loadTopLeagueIds(): Set<string> {
private loadQualifiedLeagueIds(): Set<string> {
try {
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (!fs.existsSync(topLeaguesPath)) {
const filePath = path.join(process.cwd(), "qualified_leagues.json");
if (!fs.existsSync(filePath)) {
this.logger.warn(
"qualified_leagues.json not found — all leagues allowed",
);
return new Set<string>();
}
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (!Array.isArray(raw)) {
return new Set<string>();
}
return new Set(
const ids = new Set(
raw
.map((value) => String(value).trim())
.filter((value) => value.length > 0),
);
this.logger.log(`Loaded ${ids.size} qualified league IDs`);
return ids;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to load top_leagues.json: ${message}`);
this.logger.warn(`Failed to load qualified_leagues.json: ${message}`);
return new Set<string>();
}
}
@@ -354,7 +430,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (match) {
return {
leagueId: match.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""),
isTopLeague: this.qualifiedLeagueIds.has(match.leagueId ?? ""),
};
}
@@ -365,7 +441,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
leagueId: liveMatch?.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""),
isTopLeague: this.qualifiedLeagueIds.has(liveMatch?.leagueId ?? ""),
};
}
@@ -674,6 +750,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
odds: this.normalizeDisplayOdds(odds, impliedProb),
implied_prob: impliedProb,
ev_edge: evEdge,
playable: Boolean(record.playable) && interval.threshold_met,
stake_units:
Boolean(record.playable) && interval.threshold_met
? this.asNumber(record.stake_units)
: 0,
reasons: Array.isArray(record.reasons)
? record.reasons.map((reason) => this.translateReason(String(reason)))
: [],
@@ -710,20 +791,20 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return this.reasonTranslations[normalized];
}
const evMatch = normalized.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/);
const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
if (evMatch) {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
}
const negativeEdgeMatch = normalized.match(
/^negative_model_edge_([+\-]?[\d.]+)$/,
/^negative_model_edge_([-+]?[\d.]+)$/,
);
if (negativeEdgeMatch) {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
}
const edgeThresholdMatch = normalized.match(
/^below_market_edge_threshold_([+\-]?[\d.]+)$/,
/^below_market_edge_threshold_([-+]?[\d.]+)$/,
);
if (edgeThresholdMatch) {
return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`;
@@ -919,15 +1000,39 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return 0;
}
const normalizedPick = pickName.toUpperCase();
const normalizedPick = this.normalizePickKey(pickName);
for (const [key, value] of Object.entries(probabilities)) {
if (key.toUpperCase() === normalizedPick) {
if (this.normalizePickKey(key) === normalizedPick) {
return this.asNumber(value);
}
}
return 0;
}
private normalizePickKey(value: string): string {
const normalized = value.trim().toUpperCase();
const aliases: Record<string, string> = {
ÜST: "OVER",
UST: "OVER",
OVER: "OVER",
ALT: "UNDER",
UNDER: "UNDER",
"KG VAR": "YES",
VAR: "YES",
YES: "YES",
"KG YOK": "NO",
YOK: "NO",
NO: "NO",
TEK: "ODD",
ODD: "ODD",
ÇİFT: "EVEN",
CIFT: "EVEN",
EVEN: "EVEN",
};
return aliases[normalized] ?? normalized;
}
private impliedProbabilityFromOdds(odds: number): number {
if (odds <= 1) {
return 0;
@@ -1026,10 +1131,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
// Direct HTTP mode
try {
const response = await this.aiEngineClient.post(
"/smart-coupon",
{ match_ids: matchIds, strategy, ...options },
);
const response = await this.aiEngineClient.post("/smart-coupon", {
match_ids: matchIds,
strategy,
...options,
});
return response.data;
} catch (error: unknown) {
const message =
@@ -1085,8 +1191,26 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
async cachePrediction(matchId: string, prediction: MatchPredictionDto) {
this.predictionMemCache.set(matchId, {
timestamp: Date.now(),
payload: prediction,
});
if (this.predictionMemCache.size > 500) {
const firstKey = this.predictionMemCache.keys().next().value;
if (firstKey) this.predictionMemCache.delete(firstKey);
}
const payload = prediction as unknown as Prisma.InputJsonObject;
try {
const existsInMatch = await this.prisma.match.findUnique({
where: { id: matchId },
select: { id: true },
});
if (!existsInMatch) {
return;
}
await this.prisma.prediction.upsert({
where: { matchId },
update: {
@@ -1106,6 +1230,16 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getCachedPrediction(
matchId: string,
): Promise<MatchPredictionDto | null> {
const memCached = this.predictionMemCache.get(matchId);
if (memCached) {
if (Date.now() - memCached.timestamp < 10 * 60 * 1000) {
// 10 mins TTL
return memCached.payload;
} else {
this.predictionMemCache.delete(matchId);
}
}
const prediction = await this.prisma.prediction.findUnique({
where: { matchId },
});
@@ -1132,6 +1266,43 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return prediction.predictionJson as unknown as MatchPredictionDto;
}
private async getStoredPrediction(
matchId: string,
): Promise<MatchPredictionDto | null> {
const prediction = await this.prisma.prediction.findUnique({
where: { matchId },
});
return prediction
? (prediction.predictionJson as unknown as MatchPredictionDto)
: null;
}
private hasCooldown(detail: unknown): boolean {
if (typeof detail === "string") {
return detail.includes("cooldownRemainingMs");
}
if (detail && typeof detail === "object") {
return "cooldownRemainingMs" in detail;
}
return false;
}
private extractCooldownMs(detail: unknown): number {
if (detail && typeof detail === "object" && "cooldownRemainingMs" in detail) {
return Number((detail as Record<string, unknown>).cooldownRemainingMs) || 0;
}
if (typeof detail === "string") {
const match = detail.match(/cooldownRemainingMs[":\s]+(\d+)/);
return match ? Number(match[1]) : 0;
}
return 0;
}
private async ensureSmartCouponDataReady(matchIds: string[]): Promise<void> {
const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))];
if (uniqueMatchIds.length === 0) {
@@ -1147,32 +1318,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
private async ensurePredictionDataReady(matchId: string): Promise<void> {
const [liveMatch, persistedMatch, oddCategoryCount] = await Promise.all([
this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: {
id: true,
odds: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
},
}),
this.prisma.match.findUnique({
where: { id: matchId },
select: {
id: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
},
}),
this.prisma.oddCategory.count({
where: { matchId },
}),
]);
const [liveMatch, persistedMatch, oddCategoryCount, lineupCount] =
await Promise.all([
this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: {
id: true,
odds: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
leagueId: true,
},
}),
this.prisma.match.findUnique({
where: { id: matchId },
select: {
id: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
leagueId: true,
},
}),
this.prisma.oddCategory.count({
where: { matchId },
}),
this.prisma.matchPlayerParticipation.count({
where: { matchId },
}),
]);
const hasLiveOdds =
!!liveMatch?.odds &&
@@ -1188,27 +1365,68 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
}
// League qualification gate: reject predictions for leagues without
// sufficient historical training data (odds + lineups + stats)
const leagueId = liveMatch?.leagueId || persistedMatch?.leagueId;
if (
this.qualifiedLeagueIds.size > 0 &&
(!leagueId || !this.qualifiedLeagueIds.has(leagueId))
) {
throw new HttpException(
`Bu lig için yeterli geçmiş veri bulunmuyor. Tahmin yapılamaz.`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
const state = liveMatch?.state || persistedMatch?.state;
const status = liveMatch?.status || persistedMatch?.status;
const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome;
const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway;
const hasScores =
scoreHome !== null &&
scoreHome !== undefined &&
scoreAway !== null &&
scoreAway !== undefined;
const isFinished =
hasScores ||
state === "MS" ||
state === "postGame" ||
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
status as string,
);
const isFinished = isMatchCompleted({
state: state ?? null,
status: status ?? null,
scoreHome,
scoreAway,
});
const isLive = isMatchLive({
state: state ?? null,
status: status ?? null,
});
const hasOdds = hasLiveOdds || oddCategoryCount > 0;
if (hasOdds || isFinished) {
if (hasOdds || isFinished || isLive) {
// ── Lineup guard: fetch lineups if missing before analysis ──
// A proper football lineup has at least 11 starting players (22 total
// with subs). If we have fewer than 11 participation records, the
// lineup data is likely missing — attempt to fetch it from source.
if (lineupCount < 11) {
this.logger.log(
`[${matchId}] ⚠️ Lineups missing (${lineupCount} players in DB). Fetching from source before analysis...`,
);
try {
const refreshResult = await this.feederService.refreshMatch(
matchId,
"lineups",
);
if (refreshResult.success) {
this.logger.log(
`[${matchId}] ✅ Lineups fetched successfully before analysis`,
);
} else {
this.logger.warn(
`[${matchId}] ⚠️ Lineup fetch returned failure — proceeding with existing data. Error: ${refreshResult.error ?? "unknown"}`,
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`[${matchId}] ⚠️ Lineup fetch exception — proceeding with existing data. ${message}`,
);
}
}
return;
}
@@ -1246,7 +1464,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Prediction run audit skipped for ${matchId}: ${message}`);
this.logger.warn(
`Prediction run audit skipped for ${matchId}: ${message}`,
);
}
}
@@ -1328,8 +1548,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
: null,
bet_advice: {
playable: payload.bet_advice?.playable ?? false,
suggested_stake_units:
payload.bet_advice?.suggested_stake_units ?? 0,
suggested_stake_units: payload.bet_advice?.suggested_stake_units ?? 0,
reason: payload.bet_advice?.reason ?? null,
},
top_summary: topSummary,
@@ -1,6 +1,8 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { GeminiService } from "../gemini/gemini.service";
import { PredictionCardDto } from "./dto/prediction-card.dto";
import axios from "axios";
const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin.
Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun.
@@ -11,6 +13,7 @@ KURALLAR:
- Emoji kullan ama abartma (2-4 emoji yeterli)
- Skor tahminini vurgula
- Güven yüzdesini belirt
- Lig, ülke, takım adları ve ana tahminleri SEO için doğal şekilde geçir
- İlgili hashtag'leri ekle (#PremierLeague, #SüperLig vb.)
- KESİNLİKLE "kesin kazanır", "garanti" gibi ifadeler KULLANMA
- "Tahminimiz", "Beklentimiz", "Analizimiz" gibi ifadeler kullan
@@ -20,13 +23,31 @@ KURALLAR:
@Injectable()
export class CaptionGeneratorService {
private readonly logger = new Logger(CaptionGeneratorService.name);
private readonly ollamaBaseUrl: string;
private readonly ollamaModel: string;
constructor(private readonly geminiService: GeminiService) {}
constructor(
private readonly geminiService: GeminiService,
private readonly configService: ConfigService,
) {
this.ollamaBaseUrl =
this.configService.get<string>("OLLAMA_BASE_URL") ||
"http://localhost:11434";
this.ollamaModel =
this.configService.get<string>("OLLAMA_MODEL") ||
this.configService.get<string>("SOCIAL_POSTER_OLLAMA_MODEL") ||
"";
}
/**
* Generate a social media caption for a match prediction using Gemini AI.
*/
async generateCaption(card: PredictionCardDto): Promise<string> {
if (this.ollamaModel) {
const caption = await this.generateWithOllama(card);
if (caption) return caption;
}
if (!this.geminiService.isAvailable()) {
this.logger.warn("Gemini not available, using template caption");
return this.generateFallbackCaption(card);
@@ -53,6 +74,39 @@ export class CaptionGeneratorService {
}
}
private async generateWithOllama(card: PredictionCardDto): Promise<string> {
const prompt = `${SYSTEM_PROMPT}
${this.buildPrompt(card)}`;
try {
const response = await axios.post(
`${this.ollamaBaseUrl.replace(/\/$/, "")}/api/generate`,
{
model: this.ollamaModel,
prompt,
stream: false,
options: {
temperature: 0.7,
num_predict: 260,
},
},
{ timeout: 20000 },
);
const text = String(response.data?.response || "").trim();
if (!text) return "";
this.logger.log(
`Ollama caption generated for ${card.homeTeam} vs ${card.awayTeam}`,
);
return this.ensureHashtags(text, card);
} catch (error) {
this.logger.warn(`Ollama caption generation failed: ${error.message}`);
return "";
}
}
private buildPrompt(card: PredictionCardDto): string {
const topPicksText = card.topPicks
.map(
@@ -64,9 +118,11 @@ export class CaptionGeneratorService {
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
MAÇ: ${card.homeTeam} vs ${card.awayTeam}
SPOR: ${card.sport === "basketball" ? "Basketbol" : "Futbol"}
LİG: ${card.leagueName}
ÜLKE/BÖLGE: ${card.countryName || "-"}
TARİH: ${card.matchDate}
İLK YARI SKOR TAHMİNİ: ${card.htScore}
${card.sport === "basketball" ? "İLK DEVRE" : "İLK YARI"} SKOR TAHMİNİ: ${card.htScore}
MAÇ SONU SKOR TAHMİNİ: ${card.ftScore}
SKOR GÜVEN: %${card.scoreConfidence}
RİSK SEVİYESİ: ${card.riskLevel}
@@ -85,7 +141,8 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
const homeTag = card.homeTeam.replace(/\s+/g, "");
const awayTag = card.awayTeam.replace(/\s+/g, "");
text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`;
const sportTag = card.sport === "basketball" ? "Basketbol" : "Futbol";
text += `\n\n#${leagueTag} #${homeTag} #${awayTag} #${sportTag}`;
}
return text.trim();
}
@@ -99,11 +156,14 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
.replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
return `${card.homeTeam} vs ${card.awayTeam}
🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore})
const sportLabel = card.sport === "basketball" ? "Basketbol" : "Futbol";
const halfLabel = card.sport === "basketball" ? "İD" : "İY";
return `${card.leagueName}${card.countryName ? ` (${card.countryName})` : ""}: ${card.homeTeam} vs ${card.awayTeam}
🎯 ${sportLabel} tahminimiz: ${card.ftScore} (${halfLabel}: ${card.htScore})
📊 Güven: %${card.scoreConfidence}
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
#${leagueTag} #SuggestBet #Bahis`.trim();
#${leagueTag} #${sportLabel} #MaçTahmini #iddaai`.trim();
}
}
@@ -21,12 +21,15 @@ export interface TopPick {
export interface PredictionCardDto {
// ─── Match Info ───
matchId: string;
sport: "football" | "basketball";
homeTeam: string;
awayTeam: string;
homeLogo: string;
awayLogo: string;
leagueName: string;
leagueLogo?: string;
countryName?: string;
countryFlag?: string;
/** Formatted date, e.g. "01 Mar 2026 - 21:00" */
matchDate: string;
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -10,13 +10,16 @@ export class MetaService {
private readonly pageId: string;
private readonly igUserId: string;
private readonly isEnabled: boolean;
private readonly graphApiBase = "https://graph.facebook.com/v21.0";
private readonly graphApiBase: string;
constructor(private readonly configService: ConfigService) {
this.pageAccessToken =
this.configService.get<string>("META_PAGE_ACCESS_TOKEN") || "";
this.pageId = this.configService.get<string>("META_PAGE_ID") || "";
this.igUserId = this.configService.get<string>("META_IG_USER_ID") || "";
const graphVersion =
this.configService.get<string>("META_GRAPH_API_VERSION") || "v25.0";
this.graphApiBase = `https://graph.facebook.com/${graphVersion}`;
this.isEnabled = !!(this.pageAccessToken && this.pageId);
@@ -63,11 +66,12 @@ export class MetaService {
{
url: imageUrl,
message,
published: true,
access_token: this.pageAccessToken,
},
);
const postId = response.data?.id;
const postId = response.data?.post_id || response.data?.id;
this.logger.log(`✅ Facebook post published: ${postId}`);
return postId || null;
} catch (error) {
@@ -109,6 +113,7 @@ export class MetaService {
{
image_url: imageUrl,
caption,
alt_text: this.buildAltText(caption),
access_token: this.pageAccessToken,
},
);
@@ -156,7 +161,7 @@ export class MetaService {
`${this.graphApiBase}/${containerId}`,
{
params: {
fields: "status_code",
fields: "status_code,status",
access_token: this.pageAccessToken,
},
},
@@ -177,4 +182,12 @@ export class MetaService {
this.logger.warn("Container wait timed out, attempting publish anyway");
}
private buildAltText(caption: string): string {
return caption
.replace(/#[^\s#]+/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 900);
}
}
@@ -19,6 +19,11 @@ import {
// Top leagues loaded once
const TOP_LEAGUES_PATH = path.join(process.cwd(), "top_leagues.json");
const POSTED_STATE_PATH = path.join(
process.cwd(),
"storage",
"social-poster-posted.json",
);
@Injectable()
export class SocialPosterService {
@@ -26,6 +31,9 @@ export class SocialPosterService {
private readonly aiEngineUrl: string;
private readonly appBaseUrl: string;
private readonly isEnabled: boolean;
private readonly sports: string[];
private readonly windowMinMinutes: number;
private readonly windowMaxMinutes: number;
private readonly postedMatchIds = new Set<string>();
private topLeagueIds: Set<string> = new Set();
@@ -44,8 +52,22 @@ export class SocialPosterService {
this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000";
this.isEnabled =
this.configService.get<string>("SOCIAL_POSTER_ENABLED") === "true";
this.sports = (
this.configService.get<string>("SOCIAL_POSTER_SPORTS") ||
"football,basketball"
)
.split(",")
.map((sport) => sport.trim())
.filter(Boolean);
this.windowMinMinutes = Number(
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MIN") || 25,
);
this.windowMaxMinutes = Number(
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MAX") || 45,
);
this.loadTopLeagues();
this.loadPostedState();
}
private loadTopLeagues() {
@@ -59,16 +81,45 @@ export class SocialPosterService {
}
}
private loadPostedState() {
try {
const data = fs.readFileSync(POSTED_STATE_PATH, "utf-8");
const ids = JSON.parse(data);
if (Array.isArray(ids)) {
ids.forEach((id) => this.postedMatchIds.add(String(id)));
}
this.logger.log(
`✅ Loaded ${this.postedMatchIds.size} posted social match IDs`,
);
} catch {
this.logger.warn("⚠️ No social poster state file found yet");
}
}
private savePostedState() {
const dir = path.dirname(POSTED_STATE_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(
POSTED_STATE_PATH,
JSON.stringify(Array.from(this.postedMatchIds).slice(-500), null, 2),
);
}
/**
* Cron: Every 10 minutes, check for upcoming matches.
* Cron: Every 15 minutes, check for upcoming matches.
* Posts predictions 30 minutes before kickoff.
*/
@Cron("*/10 * * * *")
@Cron("*/15 * * * *")
async checkAndPostUpcomingMatches() {
if (!this.isEnabled) return;
try {
const matches = await this.getUpcomingMatches(25, 40); // 25-40 min window
const matches = await this.getUpcomingMatches(
this.windowMinMinutes,
this.windowMaxMinutes,
);
this.logger.log(
`📅 Found ${matches.length} upcoming matches in the window`,
);
@@ -77,7 +128,19 @@ export class SocialPosterService {
if (this.postedMatchIds.has(match.id)) continue;
try {
await this.predictAndPost(match);
const result = await this.predictAndPost(match);
const posted =
result.twitterPostId ||
result.facebookPostId ||
result.instagramPostId;
if (!posted) {
this.logger.warn(
`No platform accepted post for match ${match.id}; it will be retried later`,
);
continue;
}
this.postedMatchIds.add(match.id);
// Cleanup: remove old IDs (keep last 500)
@@ -87,6 +150,7 @@ export class SocialPosterService {
.slice(0, arr.length - 500)
.forEach((id) => this.postedMatchIds.delete(id));
}
this.savePostedState();
} catch (error) {
this.logger.error(
`Failed to process match ${match.id}: ${error.message}`,
@@ -113,19 +177,33 @@ export class SocialPosterService {
const minTime = now + minMinutes * 60 * 1000;
const maxTime = now + maxMinutes * 60 * 1000;
const where: any = {
sport: { in: this.sports },
mstUtc: {
gte: minTime,
lte: maxTime,
},
};
if (this.topLeagueIds.size > 0) {
where.leagueId = { in: Array.from(this.topLeagueIds) };
}
const matches = await this.prisma.liveMatch.findMany({
where: {
sport: "football",
leagueId: { in: Array.from(this.topLeagueIds) },
mstUtc: {
gte: minTime,
lte: maxTime,
},
...where,
},
include: {
homeTeam: true,
awayTeam: true,
league: true,
league: {
include: {
country: true,
},
},
},
orderBy: {
mstUtc: "asc",
},
});
@@ -233,7 +311,10 @@ export class SocialPosterService {
const ftScore = score.ft || "1-1";
// Extract best bets from bet_summary array
const topPicks = this.extractTopPicks(prediction);
const sport = this.normalizeSport(
match.sport || prediction.match_info?.sport,
);
const topPicks = this.extractTopPicks(prediction, sport);
// Match date formatting
const matchDate = this.formatMatchDate(match.mstUtc);
@@ -246,6 +327,7 @@ export class SocialPosterService {
return {
matchId: match.id,
sport,
homeTeam:
match.homeTeam?.name || prediction.match_info?.home_team || "Home",
awayTeam:
@@ -253,6 +335,12 @@ export class SocialPosterService {
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""),
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""),
leagueName: match.league?.name || prediction.match_info?.league || "",
leagueLogo: this.resolveLogoUrl(match.league?.logoUrl || ""),
countryName:
match.league?.country?.name ||
prediction.match_info?.country ||
this.inferCountryName(match.league?.name || ""),
countryFlag: this.resolveLogoUrl(match.league?.country?.flagUrl || ""),
matchDate,
htScore,
ftScore,
@@ -266,11 +354,14 @@ export class SocialPosterService {
/**
* Extract top 3 picks sorted by confidence from the V20+ bet_summary array.
*/
private extractTopPicks(prediction: any): TopPick[] {
private extractTopPicks(
prediction: any,
sport: "football" | "basketball",
): TopPick[] {
const betSummary: any[] = prediction.bet_summary || [];
// Market code to Turkish/English label mapping
const marketLabels: Record<string, { tr: string; en: string }> = {
const footballLabels: Record<string, { tr: string; en: string }> = {
MS: { tr: "Maç Sonucu", en: "Match Result" },
OU15: { tr: "Üst 1.5 Gol", en: "Over 1.5" },
OU25: { tr: "Üst 2.5 Gol", en: "Over 2.5" },
@@ -282,6 +373,20 @@ export class SocialPosterService {
OE: { tr: "Tek/Çift", en: "Odd/Even" },
HTFT: { tr: "İY/MS", en: "HT/FT" },
};
const basketballLabels: Record<string, { tr: string; en: string }> = {
MS: { tr: "Maç Sonucu", en: "Match Result" },
ML: { tr: "Maç Sonucu", en: "Moneyline" },
WINNER: { tr: "Maç Sonucu", en: "Winner" },
TOTAL: { tr: "Toplam Sayı", en: "Total Points" },
OU: { tr: "Toplam Sayı", en: "Total Points" },
OVER_UNDER: { tr: "Toplam Sayı", en: "Total Points" },
HANDICAP: { tr: "Handikap", en: "Handicap" },
HND: { tr: "Handikap", en: "Handicap" },
SPREAD: { tr: "Handikap", en: "Spread" },
HT: { tr: "İlk Devre Sonucu", en: "First Half Result" },
};
const marketLabels =
sport === "basketball" ? basketballLabels : footballLabels;
const candidates: TopPick[] = betSummary.map((bet) => {
const labels = marketLabels[bet.market] || {
@@ -302,6 +407,32 @@ export class SocialPosterService {
return candidates.slice(0, 3);
}
private inferCountryName(leagueName: string): string {
const normalized = leagueName.toLocaleLowerCase("tr-TR");
if (normalized.includes("süper lig") || normalized.includes("türkiye")) {
return "Türkiye";
}
if (
normalized.includes("premier league") ||
normalized.includes("championship")
) {
return "İngiltere";
}
if (normalized.includes("la liga")) return "İspanya";
if (normalized.includes("serie a")) return "İtalya";
if (normalized.includes("bundesliga")) return "Almanya";
if (normalized.includes("ligue 1")) return "Fransa";
if (normalized.includes("euroleague")) return "Avrupa";
if (normalized.includes("nba")) return "ABD";
return "";
}
private normalizeSport(sport?: string): "football" | "basketball" {
return String(sport).toLowerCase() === "basketball"
? "basketball"
: "football";
}
/**
* Convert relative logo paths to full HTTP URLs.
* On the deployed server, logos exist at public/uploads/teams/...
@@ -351,7 +482,11 @@ export class SocialPosterService {
include: {
homeTeam: true,
awayTeam: true,
league: true,
league: {
include: {
country: true,
},
},
},
});
@@ -373,7 +508,11 @@ export class SocialPosterService {
include: {
homeTeam: true,
awayTeam: true,
league: true,
league: {
include: {
country: true,
},
},
},
});
+11 -3
View File
@@ -20,7 +20,7 @@ export class TwitterService {
void this.initClient(apiKey, apiSecret, accessToken, accessSecret);
} else {
this.logger.warn(
"⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET",
"⚠️ X/Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET",
);
}
}
@@ -64,10 +64,10 @@ export class TwitterService {
}
try {
// Step 1: Upload media via v1.1
// Step 1: Upload image media via the X media upload endpoint.
const mediaData = fs.readFileSync(imagePath);
const mediaId = await this.client.v1.uploadMedia(mediaData, {
mimeType: "image/png",
mimeType: this.getMimeType(imagePath),
});
// Step 2: Create tweet via v2
@@ -84,4 +84,12 @@ export class TwitterService {
return null;
}
}
private getMimeType(imagePath: string): string {
const ext = imagePath.toLowerCase().split(".").pop();
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
if (ext === "webp") return "image/webp";
if (ext === "png") return "image/png";
return "image/jpeg";
}
}
+26 -4
View File
@@ -54,6 +54,7 @@ export class SporTotoController {
@ApiResponse({
status: 200,
description: "Sync result with action (created/updated/unchanged)",
schema: { type: "object" },
})
async syncFromApi() {
const result = await this.sporTotoService.syncFromApi();
@@ -82,6 +83,7 @@ export class SporTotoController {
@ApiResponse({
status: 200,
description: "Array of bulletins with matches and results",
schema: { type: "object" },
})
async listBulletins(
@Query("status") status?: TotoBulletinStatus,
@@ -105,6 +107,7 @@ export class SporTotoController {
@ApiResponse({
status: 200,
description: "Bulletin with matches and results",
schema: { type: "object" },
})
@ApiResponse({ status: 404, description: "Bulletin not found" })
async getBulletin(@Param("id") id: string) {
@@ -123,7 +126,11 @@ export class SporTotoController {
"Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.",
})
@ApiBody({ type: CreateBulletinDto })
@ApiResponse({ status: 201, description: "Created bulletin with matches" })
@ApiResponse({
status: 201,
description: "Created bulletin with matches",
schema: { type: "object" },
})
@ApiResponse({
status: 409,
description: "Bulletin with this gameCycleNo already exists",
@@ -145,7 +152,11 @@ export class SporTotoController {
})
@ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiBody({ type: UpdateResultsDto })
@ApiResponse({ status: 200, description: "Updated bulletin with results" })
@ApiResponse({
status: 200,
description: "Updated bulletin with results",
schema: { type: "object" },
})
@ApiResponse({ status: 404, description: "Bulletin not found" })
async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) {
const bulletin = await this.sporTotoService.updateResults(id, dto);
@@ -162,7 +173,11 @@ export class SporTotoController {
"Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.",
})
@ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiResponse({ status: 200, description: "Pool distribution and EV stats" })
@ApiResponse({
status: 200,
description: "Pool distribution and EV stats",
schema: { type: "object" },
})
async getBulletinStats(@Param("id") id: string) {
const stats = await this.sporTotoService.getBulletinStats(id);
return { success: true, data: stats };
@@ -181,7 +196,11 @@ export class SporTotoController {
type: Number,
description: "Number of results (default: 20)",
})
@ApiResponse({ status: 200, description: "Rollover history with trend data" })
@ApiResponse({
status: 200,
description: "Rollover history with trend data",
schema: { type: "object" },
})
async getRolloverHistory(@Query("limit") limit?: string) {
const history = await this.sporTotoService.getRolloverHistory(
Number(limit) || 20,
@@ -204,6 +223,7 @@ export class SporTotoController {
@ApiResponse({
status: 200,
description: "Generated columns with strategy, cost, and column strings",
schema: { type: "object" },
})
async generateColumns(@Body() dto: GenerateColumnsDto) {
const result = await this.sporTotoService.generateColumns(dto);
@@ -223,6 +243,7 @@ export class SporTotoController {
@ApiResponse({
status: 200,
description: "Evaluation results with correct counts per column",
schema: { type: "object" },
})
async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
const result = await this.sporTotoService.evaluateColumns(
@@ -248,6 +269,7 @@ export class SporTotoController {
status: 200,
description:
"Prediction result with per-match analysis, system coupon, and EV report with play recommendation",
schema: { type: "object" },
})
async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) {
this.logger.log(
+39
View File
@@ -0,0 +1,39 @@
/**
* Run Previous-Day Completed Match Sync
* Usage: npm run feeder:previous-day
*/
import { NestFactory } from "@nestjs/core";
import { FeederService } from "../modules/feeder/feeder.service";
import { Logger } from "@nestjs/common";
async function bootstrap() {
process.env.FEEDER_MODE = "historical";
const logger = new Logger("FeederPreviousDayScript");
logger.log("🚀 Starting previous-day completed match sync...");
// Load AppModule after FEEDER_MODE is set so cron imports can be disabled.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AppModule } = require("../app.module");
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ["log", "error", "warn"],
});
try {
const feederService = app.get(FeederService);
await feederService.runPreviousDayCompletedMatchesScan();
logger.log("✅ Previous-day completed match sync completed successfully!");
} catch (error: any) {
logger.error(`❌ Feeder failed: ${error.message}`);
logger.error(error.stack);
process.exit(1);
} finally {
await app.close();
}
process.exit(0);
}
void bootstrap();
+123
View File
@@ -0,0 +1,123 @@
/**
* Repair Feeder - Fix incomplete matches
* Usage: npm run feeder:repair
*
* Finds matches in DB that are missing stats or lineups
* and re-fetches them from the API.
*/
import { NestFactory } from "@nestjs/core";
import { Logger } from "@nestjs/common";
import { PrismaService } from "../database/prisma.service";
import { FeederService } from "../modules/feeder/feeder.service";
async function bootstrap() {
process.env.FEEDER_MODE = "historical";
const logger = new Logger("FeederRepair");
logger.log("🔧 Starting feeder repair scan...");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AppModule } = require("../app.module");
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ["log", "error", "warn"],
});
const prisma = app.get(PrismaService);
const feederService = app.get(FeederService);
try {
// Find football matches missing stats (no football_team_stats rows)
const matchesMissingStats = await prisma.$queryRaw<
Array<{ id: string; match_name: string }>
>`
SELECT m.id, m.match_name
FROM matches m
LEFT JOIN football_team_stats fts ON fts.match_id = m.id
WHERE m.sport = 'football'
AND m.state = 'Ended'
AND fts.id IS NULL
ORDER BY m.mst_utc DESC
`;
// Find football matches missing lineups (< 18 participation rows)
const matchesMissingLineups = await prisma.$queryRaw<
Array<{ id: string; match_name: string; cnt: bigint }>
>`
SELECT m.id, m.match_name, COUNT(mpp.id) as cnt
FROM matches m
LEFT JOIN match_player_participation mpp ON mpp.match_id = m.id
WHERE m.sport = 'football'
AND m.state = 'Ended'
GROUP BY m.id, m.match_name
HAVING COUNT(mpp.id) < 18
ORDER BY m.mst_utc DESC
`;
// Combine unique match IDs
const repairSet = new Set<string>();
for (const m of matchesMissingStats) repairSet.add(m.id);
for (const m of matchesMissingLineups) repairSet.add(m.id);
logger.log(
`📊 Found ${repairSet.size} incomplete matches (${matchesMissingStats.length} missing stats, ${matchesMissingLineups.length} missing lineups)`,
);
if (repairSet.size === 0) {
logger.log("✅ No incomplete matches found. Everything is clean!");
await app.close();
process.exit(0);
}
let repaired = 0;
let failed = 0;
const matchIds = Array.from(repairSet);
for (let i = 0; i < matchIds.length; i++) {
const matchId = matchIds[i];
// Rate limiting
if (i > 0 && i % 10 === 0) {
logger.log(
`⏸️ Cooldown after 10 repairs... (${repaired} repaired, ${failed} failed, ${matchIds.length - i} remaining)`,
);
await new Promise((r) => setTimeout(r, 5000));
}
await new Promise((r) => setTimeout(r, 500));
try {
const result = await feederService.refreshMatch(matchId, "all");
if (result.success) {
repaired++;
if (repaired % 25 === 0) {
logger.log(`🔧 Progress: ${repaired}/${matchIds.length} repaired`);
}
} else {
failed++;
logger.warn(
`❌ [${matchId}] Repair failed: ${result.error || "unknown"}`,
);
}
} catch (e: any) {
failed++;
logger.error(`❌ [${matchId}] Repair exception: ${e.message}`);
}
}
logger.log(
`🎉 REPAIR COMPLETE: ${repaired} repaired, ${failed} failed out of ${matchIds.length} total`,
);
} catch (error: any) {
logger.error(`❌ Repair failed: ${error.message}`);
logger.error(error.stack);
process.exit(1);
} finally {
await app.close();
}
process.exit(0);
}
void bootstrap();
+52 -5
View File
@@ -1,18 +1,25 @@
/**
* Run Previous-Day Completed Match Sync
* Run Full Historical Feeder
* Usage: npm run feeder:historical
*
* Includes a watchdog that kills the process if no activity
* is detected for 5 minutes (stuck API request), letting PM2
* auto-restart and resume from DB.
*/
import { NestFactory } from "@nestjs/core";
import { FeederService } from "../modules/feeder/feeder.service";
import { Logger } from "@nestjs/common";
const WATCHDOG_INTERVAL_MS = 60_000; // Check every 1 minute
const WATCHDOG_TIMEOUT_MS = 3 * 60_000; // Kill if no activity for 3 minutes
async function bootstrap() {
process.env.FEEDER_MODE = "historical";
const logger = new Logger("FeederScript");
logger.log("🚀 Starting previous-day completed match sync...");
logger.log("🚀 Starting full historical feeder...");
// Load AppModule after FEEDER_MODE is set so cron imports can be disabled.
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -21,15 +28,55 @@ async function bootstrap() {
logger: ["log", "error", "warn"],
});
const feederService = app.get(FeederService);
// ── Watchdog Timer ──────────────────────────────────────────
// If the feeder hangs on an API call for 3+ minutes, force-kill
// so PM2 can restart and resume from where it left off in DB.
// NOTE: process.exit(1) alone can be blocked by open handles
// (DB connections, HTTP sockets). We use process.kill(SIGKILL)
// as an unconditional fallback.
const watchdog = setInterval(() => {
const idleMs = Date.now() - feederService.lastActivityAt;
if (idleMs > WATCHDOG_TIMEOUT_MS) {
logger.error(
`🐕 WATCHDOG: No activity for ${Math.round(idleMs / 1000)}s. Force-killing for PM2 restart...`,
);
// Try graceful exit first
try {
process.exit(1);
} catch {
// Ignored fallback below
}
// If process.exit didn't work (blocked by open handles),
// schedule an unconditional SIGKILL after 2 seconds
setTimeout(() => {
logger.error("🐕 WATCHDOG: process.exit blocked. Sending SIGKILL...");
process.kill(process.pid, "SIGKILL");
}, 2_000).unref();
}
}, WATCHDOG_INTERVAL_MS);
// Don't let the watchdog timer keep the process alive after scan finishes
watchdog.unref();
try {
const feederService = app.get(FeederService);
await feederService.runPreviousDayCompletedMatchesScan();
logger.log("✅ Previous-day completed match sync completed successfully!");
const startDate = process.env.FEEDER_START_DATE || "2023-06-01";
const sports = (process.env.FEEDER_SPORTS || "football,basketball")
.split(",")
.map((sport) => sport.trim())
.filter(Boolean) as Array<"football" | "basketball">;
await feederService.runHistoricalScan(sports, startDate);
logger.log("✅ Full historical feeder completed successfully!");
} catch (error: any) {
logger.error(`❌ Feeder failed: ${error.message}`);
logger.error(error.stack);
process.exit(1);
} finally {
clearInterval(watchdog);
await app.close();
}
+1 -3
View File
@@ -44,9 +44,7 @@ export class AiService {
private readonly pythonEngineUrl: string;
private readonly aiEngineClient: AiEngineClient;
constructor(
private readonly configService: ConfigService,
) {
constructor(private readonly configService: ConfigService) {
this.pythonEngineUrl =
this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000";
this.aiEngineClient = new AiEngineClient({
+95 -23
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { HttpService } from "@nestjs/axios";
import { PrismaService } from "../database/prisma.service";
@@ -161,7 +161,8 @@ export class DataFetcherTask {
`Pruned ${deleted.count} stale live matches. Starting full sync...`,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const message =
error instanceof Error ? error.message : String(error);
this.logger.error(`Stale live_match cleanup failed: ${message}`);
return;
}
@@ -182,7 +183,9 @@ export class DataFetcherTask {
this.logger.log("syncLiveMatches START");
const today = getDateStringInTimeZone(new Date(), this.timeZone);
const tomorrow = getShiftedDateStringInTimeZone(1, this.timeZone);
await this.syncMatchList(today);
await this.syncMatchList(tomorrow);
await this.updateLiveScores();
await this.fetchOddsForMatches();
await this.fillMissingLineups();
@@ -192,12 +195,12 @@ export class DataFetcherTask {
private async syncMatchList(date: string): Promise<void> {
// Football
const footballLeagues = this.loadLeagueFilterSet("top_leagues.json");
const footballLeagues = this.loadLeagueFilterSet("qualified_leagues.json");
if (footballLeagues && footballLeagues.size > 0) {
await this.fetchMatchesForSport("football", date, footballLeagues);
} else {
this.logger.warn(
"top_leagues.json is missing/empty — writing ALL football matches",
"qualified_leagues.json is missing/empty — writing ALL football matches",
);
await this.fetchMatchesForSport("football", date, new Set());
}
@@ -248,7 +251,7 @@ export class DataFetcherTask {
}
this.logger.log(
`📡 Updating scores for ${liveMatches.length} live matches`,
`LIVE Updating scores for ${liveMatches.length} live matches`,
);
for (const match of liveMatches) {
@@ -276,25 +279,25 @@ export class DataFetcherTask {
}
}
this.logger.log("📡 Live score update complete");
this.logger.log("LIVE Live score update complete");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Live score update failed: ${message}`);
}
}
// ────────────────────────────────────────────────────────────
// Phase 3: Odds + referee + lineups + sidelined
// ────────────────────────────────────────────────────────────
private async fetchOddsForMatches(): Promise<void> {
this.logger.log("💰 Fetching odds for live matches...");
this.logger.log("MONEY Fetching odds for live matches...");
try {
// Load both league filters
// Load both league filters (data-driven qualified leagues)
const topLeagueIds: string[] = [];
const footballLeagues = this.loadLeagueFilterSet("top_leagues.json");
const footballLeagues = this.loadLeagueFilterSet(
"qualified_leagues.json",
);
if (footballLeagues) topLeagueIds.push(...footballLeagues);
const basketballLeagues = this.loadLeagueFilterSet(
@@ -305,6 +308,9 @@ export class DataFetcherTask {
const allowedLeagueIds = Array.from(new Set(topLeagueIds));
// Get matches needing odds (from 12 hours ago onward)
// CRITICAL: Only fetch odds/lineups for NOT STARTED matches.
// Once a match goes live, odds selections disappear or change,
// corrupting the pre-match data the AI model relies on.
const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
const matchesToFetch = await this.prisma.liveMatch.findMany({
@@ -313,6 +319,15 @@ export class DataFetcherTask {
...(allowedLeagueIds.length > 0
? { leagueId: { in: allowedLeagueIds } }
: {}),
// Exclude live and finished matches — preserve their pre-match odds
NOT: {
OR: [
{ status: { in: FINISHED_STATUS_VALUES_FOR_DB } },
{ state: { in: FINISHED_STATE_VALUES_FOR_DB } },
{ status: { in: LIVE_STATUS_VALUES_FOR_DB } },
{ state: { in: LIVE_STATE_VALUES_FOR_DB } },
],
},
},
include: {
homeTeam: { select: { name: true } },
@@ -323,11 +338,13 @@ export class DataFetcherTask {
});
if (matchesToFetch.length === 0) {
this.logger.log("💰 No matches to fetch odds for");
this.logger.log("MONEY No matches to fetch odds for");
return;
}
this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`);
this.logger.log(
`MONEY Fetching odds for ${matchesToFetch.length} matches`,
);
let successCount = 0;
let errorCount = 0;
@@ -356,7 +373,7 @@ export class DataFetcherTask {
// Retry failed matches (502/Timeout)
if (failedMatches.length > 0) {
this.logger.warn(
`⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
`Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
);
for (const match of failedMatches) {
@@ -364,7 +381,7 @@ export class DataFetcherTask {
try {
await this.processMatchOdds(match);
successCount++;
this.logger.log(`✅ Retry successful for match ${match.id}`);
this.logger.log(`SUCCESS Retry successful for match ${match.id}`);
} catch (retryErr: unknown) {
const message =
retryErr instanceof Error ? retryErr.message : String(retryErr);
@@ -376,7 +393,7 @@ export class DataFetcherTask {
}
this.logger.log(
`💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
`MONEY Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
@@ -432,7 +449,10 @@ export class DataFetcherTask {
for (const match of toUpdate) {
try {
const formation = await this.scraper.fetchStartingFormation(match.id);
const [formation, substitutions] = await Promise.all([
this.scraper.fetchStartingFormation(match.id),
this.scraper.fetchSubstitutions(match.id),
]);
const sidelined = match.matchSlug
? await this.scraper.fetchSidelinedPlayers(
match.id,
@@ -440,11 +460,26 @@ export class DataFetcherTask {
)
: null;
// Normalize to same home.xi/away.xi format used by processMatchOdds
let normalizedLineups: Record<string, unknown> | null = null;
if (formation || substitutions) {
normalizedLineups = {
home: {
xi: formation?.stats?.home || [],
subs: substitutions?.stats?.home || [],
},
away: {
xi: formation?.stats?.away || [],
subs: substitutions?.stats?.away || [],
},
};
}
await this.prisma.liveMatch.update({
where: { id: match.id },
data: {
lineups: formation
? JSON.parse(JSON.stringify(formation))
lineups: normalizedLineups
? JSON.parse(JSON.stringify(normalizedLineups))
: Prisma.JsonNull,
sidelined: sidelined
? JSON.parse(JSON.stringify(sidelined))
@@ -810,8 +845,8 @@ export class DataFetcherTask {
const matchTime = Number(match.mstUtc);
const diffHours = (matchTime - now) / (1000 * 60 * 60);
// Fetch if between -3 hours (started) and +4 hours (upcoming)
if (diffHours < 4 && diffHours > -3) {
// Fetch if between -3 hours (started) and +24 hours (upcoming)
if (diffHours < 24 && diffHours > -3) {
// Lineups
try {
const [startingFormation, substitutions] = await Promise.all([
@@ -871,7 +906,44 @@ export class DataFetcherTask {
}
}
// ALWAYS update oddsUpdatedAt to ensure rotation
// Guard: If match already has pre-match odds and is now live/finished,
// do NOT overwrite odds/lineups/sidelined — the model needs stable pre-match data.
const matchState = match.state?.toLowerCase() ?? "";
const matchStatus = match.status?.toLowerCase() ?? "";
const liveStates = LIVE_STATE_VALUES_FOR_DB.map((s) => s.toLowerCase());
const liveStatuses = LIVE_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase());
const finishedStates = FINISHED_STATE_VALUES_FOR_DB.map((s) =>
s.toLowerCase(),
);
const finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB.map((s) =>
s.toLowerCase(),
);
const isLiveOrFinished =
liveStates.includes(matchState) ||
liveStatuses.includes(matchStatus) ||
finishedStates.includes(matchState) ||
finishedStatuses.includes(matchStatus);
const existingOdds = match.odds as Record<string, unknown> | null;
const hasExistingOdds =
!!existingOdds &&
typeof existingOdds === "object" &&
Object.keys(existingOdds).length > 0;
if (isLiveOrFinished && hasExistingOdds) {
// Match is live/finished and already has pre-match odds — skip data update
this.logger.debug(
`🛡️ Preserving pre-match data for ${match.matchName} (status: ${matchStatus}, state: ${matchState})`,
);
await this.prisma.liveMatch.update({
where: { id: match.id },
data: { oddsUpdatedAt: new Date() },
});
return;
}
// Update odds/lineups/sidelined for pre-match (NS) matches
await this.prisma.liveMatch.update({
where: { id: match.id },
data: {
@@ -892,7 +964,7 @@ export class DataFetcherTask {
sidelined.awayTeam.totalSidelined > 0))
) {
this.logger.log(
`✅ Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`,
`SUCCESS Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`,
);
} else {
this.logger.debug(
BIN
View File
Binary file not shown.