| |
| """ |
| NBA Quant Feature Engine — 6000+ Features with Genetic Selection |
| ================================================================= |
| Generates ~7213 feature candidates across 54 categories, then uses |
| genetic algorithm to select optimal 150-400 features. |
| |
| Categories: |
| 1. ROLLING PERFORMANCE (6 windows × 8 stats × 2 teams = 96) |
| 2. FOUR FACTORS (8 features × 2 windows × 2 teams = 32) |
| 3. PACE & EFFICIENCY (12 features × 2 teams = 24) |
| 4. SCORING PROFILE (10 features × 2 teams = 20) |
| 5. MOMENTUM & STREAKS (16 features) |
| 6. REST & SCHEDULE (20 features) |
| 7. OPPONENT-ADJUSTED (12 features × 2 teams = 24) |
| 8. MATCHUP & H2H (18 features) |
| 9. MARKET MICROSTRUCTURE (30+ features — CLV, line movement, steam) |
| 10. CONTEXT & SITUATIONAL (20 features) |
| 11. REFEREE FEATURES (10 features — bias, foul rates, tendencies) |
| 12. PLAYER IMPACT (16 features — star usage, injuries, depth) |
| 13. QUARTER-LEVEL PATTERNS (14 features — Q1/Q3/Q4 trends) |
| 14. DEFENSIVE MATCHUP ADVANCED (12 features — paint/perimeter/rim) |
| 15. POLYMARKET & PREDICTION MARKETS (8 features — market wisdom) |
| 16. INTERACTION & POLYNOMIAL (200+ features — pairwise, ratios, squared) |
| 17. ADVANCED ROLLING STATISTICS (160+ features — EWMA, volatility, z-scores) |
| 18. SEASON TRAJECTORY & CONTEXT (80+ features — pythagorean, playoff pace) |
| 19. LINEUP & ROTATION ANALYTICS (60+ features — lineup quality, depth) |
| 20. GAME THEORY & META FEATURES (80+ features — calibration, feedback) |
| 21. ENVIRONMENTAL & EXTERNAL (40+ features — conference, tanking, revenge) |
| 22. CROSS-WINDOW MOMENTUM (630 features — trend deltas, acceleration) |
| 23. ADVANCED MARKET MICROSTRUCTURE II (60+ features — multi-book) |
| 24. POWER RATING COMPOSITES (60+ features — multi-Elo, RAPTOR) |
| 25. FATIGUE & LOAD MANAGEMENT (80+ features — cumulative load, degradation) |
| 26. ADVANCED PLAYER IMPACT (220+ features — star +/-, usage, chemistry) |
| 27. REFEREE DEEP ANALYSIS (120+ features — per-quarter foul rates, bias) |
| 28. VENUE & ENVIRONMENTAL (160+ features — altitude, timezone, attendance) |
| 29. ADVANCED MARKET MICROSTRUCTURE III (220+ features — velocity, acceleration) |
| 30. TIME SERIES DECOMPOSITION (320+ features — trend, seasonal, residual) |
| 31. CROSS-TEAM INTERACTION MATRIX (440+ features — pace diff, style clash) |
| 32. BAYESIAN PRIORS (220+ features — preseason, franchise, coach) |
| 33. NETWORK/GRAPH FEATURES (220+ features — PageRank, centrality) |
| 34. ENSEMBLE META-FEATURES (160+ features — model uncertainty, drift) |
| 35. TEMPORAL DECAY FEATURES (320+ features — exponential decay, recency) |
| 39. CIRCADIAN RHYTHM & TRAVEL FATIGUE (8 features — normalized composites, rest non-linearity) |
| 41. TRANSITION vs HALF-COURT EFFICIENCY SPLITS (7 features — fb_pts/pace splits) |
| 43. CLUTCH PERFORMANCE (8 features — close-game win%, margin, ortg from rolling records) |
| 44. GAME TOTALS PREDICTION (10 features — normalized PPG/PAPG, pace, ortg/drtg scoring environment) |
| 46. REAL ODDS MARKET FEATURES (8 features — implied prob, spread, total from historical CSV) |
| 47. DRIVE-OFFENSE vs RIM-DEFENSE MATCHUP (14 features — drive FG%, rim protection, matchup edges) |
| 48. PASSING NETWORK QUALITY (10 features — AST/pass, potential assists, ball movement) |
| 49. PLAY-TYPE EFFICIENCY (10 features — iso/PnR/spot-up/transition PPP, versatility) |
| 50. TEMPORAL WIN SEQUENCE ENCODING (12 features — ordered outcome sequence, momentum slope, streak) |
| 51. SEASON ERA NORMALIZATION (8 features — z-score vs league running avg per season) |
| 52. ODDS LINE FEATURES (15 features — spread magnitude, total, vig, season percentiles) |
| 53. ATS RECORD FEATURES (12 features — cover rate last 10/season, streaks, home/road splits) |
| 54. OVER/UNDER RECORD FEATURES (12 features — over rate last 10/season, pace vs total) |
| 55. MARKET CONSENSUS DEVIATION (10 features — cross-instrument divergence, fair prob, vig distortion) |
| 56. DIRECTIONAL CIRCADIAN ADVANTAGE (10 features — signed westbound/eastbound travel, 8.5pp effect) |
| 57. SEQUENTIAL EXPONENTIAL FORM (12 features — α-weighted momentum, hot hand, form variance) |
| 58. MODEL CONFIDENCE & CALIBRATION ANCHORS (10 features — spread confidence, H2H consistency) |
| 59. OPPONENT GRAPH FEATURES (12 features — 2nd-order SOS, transitive wins, common-opp advantage) |
| 60. SCORE MARGIN DISTRIBUTION (12 features — margin percentiles, skew, kurtosis proxy) |
| 61. PACE-ADJUSTED NET RATING (10 features — offensive/defensive efficiency, tempo mismatch) |
| 62. CLUTCH PERFORMANCE (10 features — close-game DNA, ≤5pt margin win%, edge) |
| 63. PYTHAGOREAN LUCK (12 features — Morey expected WP, luck gap, regression signal) |
| 64. OPPONENT-ELO-WEIGHTED PERFORMANCE (10 features — quality-adjusted rolling stats, trend) |
| 65. STYLE MATCHUP ADVANTAGE (12 features — 4-factor offense vs defense matchup edges) |
| 66. PACE-NORMALIZED PER-100 BOX-SCORE DIFFERENTIALS (12 features — pts/ast/tov/reb per 100 poss) |
| 67. YOUTUBE FINBERT SENTIMENT (6 features — rolling 3/7/14d polarity + volatility, sim_cutoff gated) |
| ≈ 6400+ feature candidates |
| |
| Architecture inspired by: |
| - Starlizard: 500+ features, genetic selection, real-time adjustment |
| - Priomha Capital: 17% annual ROI, market microstructure focus |
| - Becker/Kalshi: Maker advantage, longshot bias exploitation |
| - Dean Oliver: Four Factors framework |
| - NBA Second Spectrum: Player tracking features |
| - Kenpom: Adjusted efficiency, tempo-free stats |
| - FiveThirtyEight RAPTOR: Player impact, Bayesian priors |
| - Massey/Colley: Network-based power ratings |
| |
| THIS SCRIPT MUST RUN ON HF SPACES (16GB RAM) — NOT on VM. |
| """ |
|
|
| import numpy as np |
| from collections import defaultdict |
| from datetime import datetime, timedelta |
| from typing import Dict, List, Tuple, Optional |
| import math |
| import csv |
| import os |
|
|
| |
| ENGINE_VERSION = "v3.2-67cat" |
|
|
| |
| TEAM_MAP = { |
| "Atlanta Hawks": "ATL", "Boston Celtics": "BOS", "Brooklyn Nets": "BKN", |
| "Charlotte Hornets": "CHA", "Chicago Bulls": "CHI", "Cleveland Cavaliers": "CLE", |
| "Dallas Mavericks": "DAL", "Denver Nuggets": "DEN", "Detroit Pistons": "DET", |
| "Golden State Warriors": "GSW", "Houston Rockets": "HOU", "Indiana Pacers": "IND", |
| "Los Angeles Clippers": "LAC", "Los Angeles Lakers": "LAL", "Memphis Grizzlies": "MEM", |
| "Miami Heat": "MIA", "Milwaukee Bucks": "MIL", "Minnesota Timberwolves": "MIN", |
| "New Orleans Pelicans": "NOP", "New York Knicks": "NYK", "Oklahoma City Thunder": "OKC", |
| "Orlando Magic": "ORL", "Philadelphia 76ers": "PHI", "Phoenix Suns": "PHX", |
| "Portland Trail Blazers": "POR", "Sacramento Kings": "SAC", "San Antonio Spurs": "SAS", |
| "Toronto Raptors": "TOR", "Utah Jazz": "UTA", "Washington Wizards": "WAS", |
| } |
|
|
| |
| ARENA_COORDS = { |
| "ATL": (33.757, -84.396), "BOS": (42.366, -71.062), "BKN": (40.683, -73.976), |
| "CHA": (35.225, -80.839), "CHI": (41.881, -87.674), "CLE": (41.496, -81.688), |
| "DAL": (32.790, -96.810), "DEN": (39.749, -105.008), "DET": (42.341, -83.055), |
| "GSW": (37.768, -122.388), "HOU": (29.751, -95.362), "IND": (39.764, -86.156), |
| "LAC": (34.043, -118.267), "LAL": (34.043, -118.267), "MEM": (35.138, -90.051), |
| "MIA": (25.781, -80.187), "MIL": (43.045, -87.917), "MIN": (44.980, -93.276), |
| "NOP": (29.949, -90.082), "NYK": (40.751, -73.994), "OKC": (35.463, -97.515), |
| "ORL": (28.539, -81.384), "PHI": (39.901, -75.172), "PHX": (33.446, -112.071), |
| "POR": (45.532, -122.667), "SAC": (38.580, -121.500), "SAS": (29.427, -98.438), |
| "TOR": (43.643, -79.379), "UTA": (40.768, -111.901), "WAS": (38.898, -77.021), |
| } |
|
|
| |
| ARENA_ALTITUDE = { |
| "DEN": 5280, "UTA": 4226, "PHX": 1086, "OKC": 1201, "SAS": 650, |
| "DAL": 430, "HOU": 43, "MEM": 337, "ATL": 1050, "CHA": 751, |
| "IND": 715, "CHI": 594, "MIL": 617, "MIN": 830, "DET": 600, |
| "CLE": 653, "BOS": 141, "NYK": 33, "BKN": 33, "PHI": 39, |
| "WAS": 25, "MIA": 6, "ORL": 82, "NOP": 7, "TOR": 250, |
| "POR": 50, "SAC": 30, "GSW": 12, "LAL": 305, "LAC": 305, |
| } |
|
|
| |
| TIMEZONE_ET = { |
| "ATL": 0, "BOS": 0, "BKN": 0, "CHA": 0, "CHI": -1, "CLE": 0, |
| "DAL": -1, "DEN": -2, "DET": 0, "GSW": -3, "HOU": -1, "IND": 0, |
| "LAC": -3, "LAL": -3, "MEM": -1, "MIA": 0, "MIL": -1, "MIN": -1, |
| "NOP": -1, "NYK": 0, "OKC": -1, "ORL": 0, "PHI": 0, "PHX": -2, |
| "POR": -3, "SAC": -3, "SAS": -1, "TOR": 0, "UTA": -2, "WAS": 0, |
| } |
|
|
| WINDOWS = [3, 5, 7, 10, 15, 20] |
|
|
|
|
| |
| TEAM_ALIASES = { |
| "L.A. Clippers": "LAC", "LA Clippers": "LAC", |
| "L.A. Lakers": "LAL", "LA Lakers": "LAL", |
| "NY Knicks": "NYK", "GS Warriors": "GSW", |
| "SA Spurs": "SAS", "NO Pelicans": "NOP", |
| "OKC": "OKC", "Philly": "PHI", |
| } |
|
|
|
|
| def resolve(name): |
| if name in TEAM_MAP: |
| return TEAM_MAP[name] |
| if name in TEAM_ALIASES: |
| return TEAM_ALIASES[name] |
| if len(name) == 3 and name.isupper(): |
| return name |
| for full, abbr in TEAM_MAP.items(): |
| if name in full: |
| return abbr |
| return name[:3].upper() if name else None |
|
|
|
|
| def haversine(lat1, lon1, lat2, lon2): |
| """Distance in miles between two coordinates.""" |
| R = 3959 |
| dlat = math.radians(lat2 - lat1) |
| dlon = math.radians(lon2 - lon1) |
| a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2 |
| return R * 2 * math.asin(math.sqrt(a)) |
|
|
|
|
| |
|
|
| def _american_to_implied_prob(american_odds): |
| """Convert American moneyline to implied probability (no vig removal).""" |
| try: |
| ml = float(american_odds) |
| except (ValueError, TypeError): |
| return 0.5 |
| if ml == 0: |
| return 0.5 |
| if ml > 0: |
| return 100.0 / (ml + 100.0) |
| else: |
| return abs(ml) / (abs(ml) + 100.0) |
|
|
|
|
| def _decimal_to_implied_prob(decimal_odds): |
| """Convert decimal odds to implied probability.""" |
| try: |
| d = float(decimal_odds) |
| except (ValueError, TypeError): |
| return 0.5 |
| if d <= 1.0: |
| return 1.0 |
| return 1.0 / d |
|
|
|
|
| def _is_decimal_odds(val): |
| """Heuristic: decimal odds are typically 1.01-20.0; American odds are typically < -100 or > 100.""" |
| try: |
| v = float(val) |
| except (ValueError, TypeError): |
| return False |
| |
| |
| return 1.0 < v < 50.0 |
|
|
|
|
| def load_historical_odds(csv_path=None): |
| """ |
| Load historical odds CSV and return a lookup dict. |
| |
| Returns: |
| dict: (date_str, home_abbrev, away_abbrev) -> { |
| 'implied_home_prob': float, # vig-inclusive implied probability |
| 'implied_away_prob': float, |
| 'fair_home_prob': float, # vig-removed fair probability |
| 'fair_away_prob': float, |
| 'spread_home': float, # point spread (negative = home favored) |
| 'total': float, # over/under total |
| 'ml_home_raw': float, # raw moneyline (American) |
| 'ml_away_raw': float, |
| 'overround': float, # total implied prob (>1.0 = vig) |
| 'book': str, |
| 'source': str, |
| } |
| Multiple entries for same game (different books) are kept; |
| the first one loaded (betmgm preferred) is returned for the key. |
| """ |
| if csv_path is None: |
| |
| candidates = [ |
| os.path.join(os.path.dirname(__file__), '..', 'data', 'historical-odds', 'nba_2025-26_odds.csv'), |
| os.path.join(os.path.dirname(__file__), 'data', 'historical-odds', 'nba_2025-26_odds.csv'), |
| '/home/termius/nomos-nba-agent/data/historical-odds/nba_2025-26_odds.csv', |
| ] |
| for c in candidates: |
| if os.path.exists(c): |
| csv_path = c |
| break |
| if csv_path is None: |
| return {} |
|
|
| if not os.path.exists(csv_path): |
| return {} |
|
|
| lookup = {} |
| |
| multi_book = defaultdict(list) |
|
|
| try: |
| with open(csv_path, 'r') as f: |
| reader = csv.DictReader(f) |
| for row in reader: |
| date_str = row.get('date', '').strip() |
| home_full = row.get('home_team', '').strip() |
| away_full = row.get('away_team', '').strip() |
| book = row.get('book', '').strip() |
| source = row.get('source', '').strip() |
|
|
| home_abbr = resolve(home_full) |
| away_abbr = resolve(away_full) |
| if not home_abbr or not away_abbr or not date_str: |
| continue |
|
|
| |
| ml_home_str = row.get('moneyline_home', '').strip() |
| ml_away_str = row.get('moneyline_away', '').strip() |
|
|
| if not ml_home_str or not ml_away_str: |
| continue |
|
|
| if _is_decimal_odds(ml_home_str) or _is_decimal_odds(ml_away_str): |
| |
| ip_home = _decimal_to_implied_prob(ml_home_str) |
| ip_away = _decimal_to_implied_prob(ml_away_str) |
| |
| try: |
| dh = float(ml_home_str) |
| ml_home_american = round((dh - 1.0) * 100) if dh >= 2.0 else round(-100 / (dh - 1.0)) if dh > 1.0 else -110 |
| except (ValueError, TypeError): |
| ml_home_american = -110 |
| try: |
| da = float(ml_away_str) |
| ml_away_american = round((da - 1.0) * 100) if da >= 2.0 else round(-100 / (da - 1.0)) if da > 1.0 else -110 |
| except (ValueError, TypeError): |
| ml_away_american = -110 |
| else: |
| |
| ip_home = _american_to_implied_prob(ml_home_str) |
| ip_away = _american_to_implied_prob(ml_away_str) |
| try: |
| ml_home_american = float(ml_home_str) |
| except (ValueError, TypeError): |
| ml_home_american = -110 |
| try: |
| ml_away_american = float(ml_away_str) |
| except (ValueError, TypeError): |
| ml_away_american = -110 |
|
|
| |
| overround = ip_home + ip_away |
|
|
| |
| if overround > 0: |
| fair_home = ip_home / overround |
| fair_away = ip_away / overround |
| else: |
| fair_home = 0.5 |
| fair_away = 0.5 |
|
|
| |
| spread_str = row.get('spread_home', '').strip() |
| total_str = row.get('total', '').strip() |
| try: |
| spread_home = float(spread_str) if spread_str else None |
| except (ValueError, TypeError): |
| spread_home = None |
| try: |
| total = float(total_str) if total_str else None |
| except (ValueError, TypeError): |
| total = None |
|
|
| key = (date_str, home_abbr, away_abbr) |
| entry = { |
| 'implied_home_prob': ip_home, |
| 'implied_away_prob': ip_away, |
| 'fair_home_prob': fair_home, |
| 'fair_away_prob': fair_away, |
| 'spread_home': spread_home, |
| 'total': total, |
| 'ml_home_raw': ml_home_american, |
| 'ml_away_raw': ml_away_american, |
| 'overround': overround, |
| 'book': book, |
| 'source': source, |
| } |
|
|
| multi_book[key].append(ip_home) |
|
|
| |
| if key not in lookup or (book == 'betmgm' and lookup[key].get('book') != 'betmgm'): |
| lookup[key] = entry |
|
|
| except Exception as e: |
| |
| return {} |
|
|
| |
| for key, probs in multi_book.items(): |
| if key in lookup and len(probs) > 1: |
| lookup[key]['books_disagreement'] = max(probs) - min(probs) |
|
|
| return lookup |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| _YT_SENT_DEFAULT_PATH = os.path.join( |
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))), |
| "data", "youtube", "sentiment.parquet", |
| ) |
|
|
|
|
| def _load_youtube_sentiment(path=None): |
| """Load sentiment.parquet → pandas DataFrame, or None on any failure. |
| |
| Columns expected: id, published_at (UTC tz-aware), channel, |
| sent_pos, sent_neu, sent_neg, polarity. |
| """ |
| try: |
| import pandas as pd |
| except ImportError: |
| return None |
| p = path or os.environ.get("NOMOS_YT_SENT_PATH") or _YT_SENT_DEFAULT_PATH |
| if not p or not os.path.exists(p): |
| return None |
| try: |
| import pandas as pd |
| df = pd.read_parquet(p) |
| if "published_at" not in df.columns or "polarity" not in df.columns: |
| return None |
| df["published_at"] = pd.to_datetime(df["published_at"], utc=True, errors="coerce") |
| df = df.dropna(subset=["published_at"]).copy() |
| return df |
| except Exception: |
| return None |
|
|
|
|
| def _youtube_sentiment_features(df, game_date_str, sim_cutoff=None): |
| """Compute 6 rolling-window sentiment scalars for one game. |
| |
| Returns dict with yt_pol_mean_{3,7,14} and yt_abs_pol_mean_{3,7,14}. |
| All-zero fallback when df is None, empty, or window has no rows. |
| JOIN rule: published_at <= game_date AND game_date-published_at <= W days. |
| sim_cutoff (date str or datetime) is a hard leakage gate. |
| """ |
| out = {f"yt_pol_mean_{w}": 0.0 for w in (3, 7, 14)} |
| out.update({f"yt_abs_pol_mean_{w}": 0.0 for w in (3, 7, 14)}) |
| if df is None or len(df) == 0 or not game_date_str: |
| return out |
| try: |
| import pandas as pd |
| gd = pd.Timestamp(game_date_str[:10], tz="UTC") |
| except Exception: |
| return out |
| try: |
| sub = df |
| if sim_cutoff is not None: |
| try: |
| cutoff = pd.Timestamp(str(sim_cutoff)[:10], tz="UTC") |
| sub = sub[sub["published_at"] <= cutoff] |
| except Exception: |
| pass |
| sub = sub[sub["published_at"] <= gd] |
| if len(sub) == 0: |
| return out |
| age_days = (gd - sub["published_at"]).dt.total_seconds() / 86400.0 |
| for w in (3, 7, 14): |
| window = sub[age_days <= float(w)] |
| if len(window) == 0: |
| continue |
| pol = window["polarity"].astype(float) |
| out[f"yt_pol_mean_{w}"] = float(pol.mean()) |
| out[f"yt_abs_pol_mean_{w}"] = float(pol.abs().mean()) |
| except Exception: |
| pass |
| return out |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import os as _os_va |
|
|
|
|
| def _venn_abers_enabled() -> bool: |
| """Return True if feature flag is on (default on). Env: VENN_ABERS_CALIBRATION.""" |
| return _os_va.environ.get("VENN_ABERS_CALIBRATION", "1").strip().lower() not in ("0", "false", "no", "off") |
|
|
|
|
| class VennAbersProbabilityCalibrator: |
| """Thin wrapper around venn_abers.VennAbersCalibrator that swallows ImportError |
| so island training loops never break on a missing dep. |
| |
| Use for post-hoc calibration of any binary-classifier probability output. |
| Works on a held-out calibration set (inductive mode), hence `fit(p_cal, y_cal)` |
| then `transform(p_test)`. |
| """ |
|
|
| def __init__(self, inductive: bool = True): |
| self._enabled = _venn_abers_enabled() |
| self._inductive = inductive |
| self._inner = None |
| self._fitted = False |
| self._p_cal = None |
| self._y_cal = None |
| self._import_err: Optional[str] = None |
| if self._enabled: |
| try: |
| |
| |
| from venn_abers import VennAbers as _VA |
| self._inner = _VA() |
| except ImportError as e: |
| self._import_err = str(e) |
| self._enabled = False |
|
|
| @property |
| def enabled(self) -> bool: |
| return self._enabled |
|
|
| def fit(self, p_cal, y_cal): |
| """p_cal: array-like shape (n, 2) — [p(class=0), p(class=1)]. |
| y_cal: array-like of ints in {0, 1}.""" |
| if not self._enabled or self._inner is None: |
| return self |
| import numpy as _np |
| p = _np.asarray(p_cal, dtype=float) |
| y = _np.asarray(y_cal, dtype=int) |
| if p.ndim == 1: |
| |
| p = _np.column_stack([1.0 - p, p]) |
| |
| self._inner.fit(p, y) |
| self._fitted = True |
| return self |
|
|
| def transform(self, p_test): |
| """Return calibrated P(class=1). Identity passthrough if disabled/unfit.""" |
| if not self._enabled or self._inner is None or not self._fitted: |
| import numpy as _np |
| pt = _np.asarray(p_test, dtype=float) |
| if pt.ndim == 2 and pt.shape[1] == 2: |
| return pt[:, 1] |
| return pt |
| import numpy as _np |
| p = _np.asarray(p_test, dtype=float) |
| if p.ndim == 1: |
| p = _np.column_stack([1.0 - p, p]) |
| out = self._inner.predict_proba(p) |
| |
| |
| if isinstance(out, tuple): |
| p_prime = _np.asarray(out[0], dtype=float) |
| else: |
| p_prime = _np.asarray(out, dtype=float) |
| return p_prime[:, 1] if p_prime.ndim == 2 else p_prime |
|
|
| def fit_transform(self, p_cal, y_cal, p_test): |
| self.fit(p_cal, y_cal) |
| return self.transform(p_test) |
|
|
|
|
| def calibrate_probs(p_train, y_train, p_test, inductive: bool = True): |
| """One-shot Venn-Abers calibration. Returns an array of P(class=1) the same |
| length as p_test. Safe no-op when feature flag off or venn_abers missing. |
| """ |
| cal = VennAbersProbabilityCalibrator(inductive=inductive) |
| if not cal.enabled: |
| import numpy as _np |
| pt = _np.asarray(p_test, dtype=float) |
| if pt.ndim == 2 and pt.shape[1] == 2: |
| return pt[:, 1] |
| return pt |
| return cal.fit_transform(p_train, y_train, p_test) |
|
|
|
|
| class NBAFeatureEngine: |
| """ |
| Generates 6000+ features for each game from historical data. |
| |
| Usage: |
| engine = NBAFeatureEngine() |
| X, y, feature_names = engine.build(games) |
| # X.shape = (n_games, ~6000) |
| """ |
|
|
| def __init__(self, include_market=True, skip_placeholder=False, |
| youtube_sentiment_path=None, sim_date_cutoff=None, |
| enable_youtube=False): |
| """ |
| Args: |
| enable_youtube: default False — corpus currently has only 20.7% NBA keyword |
| hits (audit 2026-04-21). HAWKEYE's Tier-1 proposal explicitly scoped FinBERT |
| rolling sentiment as POL-first; NBA path is dark until corpus mature. |
| Flip to True on sandbox island (S14) for A/B. |
| sim_date_cutoff: hard leakage gate — drops videos published after this date. |
| """ |
| self.include_market = include_market |
| self.skip_placeholder = skip_placeholder |
| |
| |
| |
| self.enable_youtube = enable_youtube |
| self.sim_date_cutoff = sim_date_cutoff |
| self._yt_sent_cache = None |
| if enable_youtube: |
| self._yt_sent_cache = _load_youtube_sentiment(youtube_sentiment_path) |
| self.feature_names = [] |
| self._build_feature_names() |
|
|
| def _build_feature_names(self): |
| """Pre-compute all feature names for documentation.""" |
| names = [] |
|
|
| |
| for prefix in ["h", "a"]: |
| for w in WINDOWS: |
| names.append(f"{prefix}_wp{w}") |
| names.append(f"{prefix}_pd{w}") |
| names.append(f"{prefix}_ppg{w}") |
| names.append(f"{prefix}_papg{w}") |
| names.append(f"{prefix}_margin{w}") |
| names.append(f"{prefix}_close{w}") |
| names.append(f"{prefix}_blowout{w}") |
| names.append(f"{prefix}_ou_avg{w}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for w in [5, 10]: |
| names.append(f"{prefix}_efg{w}") |
| names.append(f"{prefix}_tov_rate{w}") |
| names.append(f"{prefix}_orb_rate{w}") |
| names.append(f"{prefix}_ft_rate{w}") |
| names.append(f"{prefix}_opp_efg{w}") |
| names.append(f"{prefix}_opp_tov{w}") |
| names.append(f"{prefix}_opp_orb{w}") |
| names.append(f"{prefix}_opp_ft{w}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for w in [5, 10]: |
| names.append(f"{prefix}_ortg{w}") |
| names.append(f"{prefix}_drtg{w}") |
| names.append(f"{prefix}_netrtg{w}") |
| names.append(f"{prefix}_pace{w}") |
| names.append(f"{prefix}_ts{w}") |
| names.append(f"{prefix}_poss{w}") |
| names.append(f"{prefix}_ast_rate{w}") |
| names.append(f"{prefix}_stl_rate{w}") |
| names.append(f"{prefix}_blk_rate{w}") |
| names.append(f"{prefix}_tov_pct{w}") |
| names.append(f"{prefix}_oreb_pct{w}") |
| names.append(f"{prefix}_dreb_pct{w}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for w in [5, 10]: |
| names.append(f"{prefix}_3par{w}") |
| names.append(f"{prefix}_3p_pct{w}") |
| names.append(f"{prefix}_2p_pct{w}") |
| names.append(f"{prefix}_ft_pct{w}") |
| names.append(f"{prefix}_paint_pts{w}") |
| names.append(f"{prefix}_fb_pts{w}") |
| names.append(f"{prefix}_bench_pts{w}") |
| names.append(f"{prefix}_2nd_pts{w}") |
| names.append(f"{prefix}_pitp{w}") |
| names.append(f"{prefix}_pts_off_tov{w}") |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_streak") |
| names.append(f"{prefix}_streak_abs") |
| names.append(f"{prefix}_last5_vs_season") |
| names.append(f"{prefix}_last3_vs_last10") |
| names.append(f"{prefix}_home_wp") |
| names.append(f"{prefix}_away_wp") |
| names.append(f"{prefix}_ha_split") |
| names.append(f"{prefix}_ats_wp5") |
| names.append(f"{prefix}_ou_record5") |
| names.append(f"{prefix}_scoring_trend") |
| names.append(f"{prefix}_defense_trend") |
| names.append(f"{prefix}_clutch_wp") |
| names.append(f"{prefix}_blowout_rate") |
| names.append(f"{prefix}_comeback_rate") |
| names.append(f"{prefix}_consistency") |
| names.append(f"{prefix}_recent_margin_std") |
|
|
| |
| names.extend([ |
| "h_rest_days", "a_rest_days", |
| "rest_advantage", |
| "h_b2b", "a_b2b", |
| "h_3in4", "a_3in4", |
| "h_4in6", "a_4in6", |
| "h_travel_dist", "a_travel_dist", |
| "travel_advantage", |
| "h_altitude", "a_altitude", |
| "altitude_delta", |
| "h_tz_shift", "a_tz_shift", |
| "tz_advantage", |
| "h_games_7d", "a_games_7d", |
| "h_miles_7d", "a_miles_7d", |
| "schedule_density_diff", |
| "combined_fatigue", |
| "h_next_is_home", "a_next_is_home", |
| ]) |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_sos5") |
| names.append(f"{prefix}_sos10") |
| names.append(f"{prefix}_sos_season") |
| names.append(f"{prefix}_wp_vs_above500") |
| names.append(f"{prefix}_wp_vs_below500") |
| names.append(f"{prefix}_wp_vs_top10") |
| names.append(f"{prefix}_wp_vs_bot10") |
| names.append(f"{prefix}_pd_vs_top10") |
| names.append(f"{prefix}_pd_vs_bot10") |
| names.append(f"{prefix}_opp_avg_ortg") |
| names.append(f"{prefix}_opp_avg_drtg") |
| names.append(f"{prefix}_margin_vs_quality") |
|
|
| |
| names.extend([ |
| "h2h_wp", |
| "h2h_last3_wp", |
| "h2h_avg_margin", |
| "h2h_home_wp", |
| "pace_delta", |
| "style_mismatch", |
| "3pt_matchup", |
| "paint_matchup", |
| "tempo_mismatch", |
| "defensive_matchup", |
| "rebound_edge", |
| "turnover_edge", |
| "free_throw_edge", |
| "bench_depth_diff", |
| "consistency_matchup", |
| "fatigue_adjusted_rating", |
| "elo_home", "elo_away", |
| "elo_diff", |
| "elo_recent_change", |
| ]) |
|
|
| |
| if self.include_market: |
| names.extend([ |
| "opening_spread", "current_spread", |
| "spread_movement", |
| "spread_movement_abs", |
| "reverse_line_movement", |
| "opening_total", "current_total", |
| "total_movement", |
| "opening_ml_home", "current_ml_home", |
| "ml_movement", |
| "implied_prob_home", "implied_prob_away", |
| "model_vs_market", |
| "edge_magnitude", |
| "books_disagreement", |
| "sharp_line", |
| "public_pct_home", |
| "public_money_pct_home", |
| "smart_money_indicator", |
| "steam_move", |
| "clv_recent_avg", |
| "market_efficiency", |
| "opening_overround", |
| "best_available_odds_home", |
| "best_available_odds_away", |
| "odds_range_home", |
| "odds_range_away", |
| "time_to_close", |
| "late_money_direction", |
| "closing_line_estimate", |
| "historical_clv_vs_book", |
| "longshot_flag", |
| ]) |
|
|
| |
| names.extend([ |
| "home_court_adv", |
| "season_phase", |
| "month_sin", "month_cos", |
| "day_of_week", |
| "is_weekend", |
| "is_national_tv", |
| "h_games_played", "a_games_played", |
| "h_season_pct", "a_season_pct", |
| "playoff_race", |
| "tanking_flag", |
| "rivalry", |
| "h_division", "a_division", |
| "same_division", |
| "same_conference", |
| "conference_game", |
| "power_rank_diff", |
| "vegas_home_fav", |
| "combined_record", |
| "game_importance_score", |
| "total_expected", |
| ]) |
|
|
| |
| if not self.skip_placeholder: |
| |
| names.extend([ |
| "ref_home_foul_bias", |
| "ref_total_fouls_avg", |
| "ref_foul_rate_vs_league", |
| "ref_home_ft_advantage", |
| "ref_experience_games", |
| "ref_over_tendency", |
| "ref_close_game_bias", |
| "ref_tech_foul_rate", |
| "ref_home_win_rate", |
| "ref_pace_impact", |
| ]) |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_star_usage_rate") |
| names.append(f"{prefix}_star_minutes_load") |
| names.append(f"{prefix}_injury_impact_score") |
| names.append(f"{prefix}_injured_war_lost") |
| names.append(f"{prefix}_lineup_continuity") |
| names.append(f"{prefix}_bench_depth_rating") |
| names.append(f"{prefix}_star_rest_status") |
| names.append(f"{prefix}_rotation_depth") |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_q1_margin_avg") |
| names.append(f"{prefix}_q3_margin_avg") |
| names.append(f"{prefix}_q4_clutch_netrtg") |
| names.append(f"{prefix}_half_adjustment") |
| names.append(f"{prefix}_comeback_win_pct") |
| names.append(f"{prefix}_blowout_hold_pct") |
| names.append(f"{prefix}_garbage_time_margin") |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_paint_defense_rating") |
| names.append(f"{prefix}_perimeter_defense") |
| names.append(f"{prefix}_transition_defense") |
| names.append(f"{prefix}_shot_contest_rate") |
| names.append(f"{prefix}_deflections_per_game") |
| names.append(f"{prefix}_rim_protection_rate") |
|
|
| |
| names.extend([ |
| "polymarket_home_prob", |
| "polymarket_volume", |
| "polymarket_line_movement", |
| "polymarket_vs_books", |
| "prediction_market_consensus", |
| "market_wisdom_confidence", |
| "smart_vs_public_divergence", |
| "closing_line_value_history", |
| ]) |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| inter_pairs = [ |
| ("h_wp10", "a_wp10"), ("h_wp5", "a_wp5"), ("h_wp3", "a_wp3"), |
| ("h_ortg10", "a_drtg10"), ("h_ortg5", "a_drtg5"), |
| ("h_drtg10", "a_ortg10"), ("h_drtg5", "a_ortg5"), |
| ("h_netrtg10", "a_netrtg10"), ("h_netrtg5", "a_netrtg5"), |
| ("h_pace10", "a_pace10"), ("h_pace5", "a_pace5"), |
| ("h_ppg10", "a_ppg10"), ("h_ppg5", "a_ppg5"), |
| ("h_margin10", "a_margin10"), ("h_margin5", "a_margin5"), |
| ("h_efg10", "a_efg10"), ("h_efg5", "a_efg5"), |
| ("h_ts10", "a_ts10"), ("h_ts5", "a_ts5"), |
| ("h_tov_rate10", "a_tov_rate10"), ("h_tov_rate5", "a_tov_rate5"), |
| ("h_orb_rate10", "a_orb_rate10"), ("h_orb_rate5", "a_orb_rate5"), |
| ("h_3p_pct10", "a_3p_pct10"), ("h_3p_pct5", "a_3p_pct5"), |
| ("h_ft_rate10", "a_ft_rate10"), ("h_ft_rate5", "a_ft_rate5"), |
| ("h_pd10", "a_pd10"), ("h_pd5", "a_pd5"), |
| ("h_blowout10", "a_blowout10"), ("h_close10", "a_close10"), |
| ("h_wp10", "elo_diff"), ("a_wp10", "elo_diff"), |
| ("h_ortg10", "elo_diff"), ("h_streak", "a_streak"), |
| ("h_wp10", "h_rest_days"), ("a_wp10", "a_rest_days"), |
| ("h_netrtg10", "rest_advantage"), ("h_ortg10", "h_pace10"), |
| ("a_ortg10", "a_pace10"), ("h_drtg10", "a_3p_pct10"), |
| ("h_wp10", "h_sos10"), ("a_wp10", "a_sos10"), |
| ("h_efg10", "h_pace10"), ("a_efg10", "a_pace10"), |
| ("h_margin10", "h_consistency"), ("a_margin10", "a_consistency"), |
| ("h_ppg10", "a_papg10"), ("a_ppg10", "h_papg10"), |
| ("h_bench_pts10", "a_bench_pts10"), |
| ("h_fb_pts10", "a_fb_pts10"), |
| ("h_opp_efg10", "a_efg10"), ("a_opp_efg10", "h_efg10"), |
| |
| ("h_wp20", "a_wp20"), ("h_wp15", "a_wp15"), |
| ("h_ortg10", "h_efg10"), ("a_ortg10", "a_efg10"), |
| ("h_drtg10", "h_opp_efg10"), ("a_drtg10", "a_opp_efg10"), |
| ("h_pace10", "h_3p_pct10"), ("a_pace10", "a_3p_pct10"), |
| ("h_ast_rate10", "a_tov_pct10"), ("a_ast_rate10", "h_tov_pct10"), |
| ("h_stl_rate10", "a_tov_pct10"), ("a_stl_rate10", "h_tov_pct10"), |
| ("h_blk_rate10", "a_paint_pts10"), ("a_blk_rate10", "h_paint_pts10"), |
| ("h_oreb_pct10", "a_dreb_pct10"), ("a_oreb_pct10", "h_dreb_pct10"), |
| ("h_3par10", "a_perimeter_defense"), ("a_3par10", "h_perimeter_defense"), |
| ("h_wp10", "a_consistency"), ("a_wp10", "h_consistency"), |
| ("h_margin10", "elo_diff"), ("a_margin10", "elo_diff"), |
| ("h_ppg10", "h_pace10"), ("a_ppg10", "a_pace10"), |
| ("h_papg10", "h_drtg10"), ("a_papg10", "a_drtg10"), |
| ("h_wp10", "h_home_wp"), ("a_wp10", "a_away_wp"), |
| ("h_streak", "h_wp10"), ("a_streak", "a_wp10"), |
| ("h_clutch_wp", "a_clutch_wp"), ("h_comeback_rate", "a_comeback_rate"), |
| ("h_scoring_trend", "a_defense_trend"), |
| ("a_scoring_trend", "h_defense_trend"), |
| ("h_ou_avg10", "a_ou_avg10"), |
| ("h_netrtg10", "h_consistency"), ("a_netrtg10", "a_consistency"), |
| ("h_efg10", "a_opp_efg10"), ("a_efg10", "h_opp_efg10"), |
| ("h_ts10", "h_3p_pct10"), ("a_ts10", "a_3p_pct10"), |
| ("h_ft_rate10", "h_ft_pct10"), ("a_ft_rate10", "a_ft_pct10"), |
| ("h_pts_off_tov10", "a_tov_rate10"), ("a_pts_off_tov10", "h_tov_rate10"), |
| ("h_2nd_pts10", "h_oreb_pct10"), ("a_2nd_pts10", "a_oreb_pct10"), |
| ("h_wp10", "season_phase"), ("a_wp10", "season_phase"), |
| ("elo_diff", "rest_advantage"), ("elo_diff", "travel_advantage"), |
| ("current_spread", "h_wp10"), ("current_spread", "a_wp10"), |
| ("current_spread", "elo_diff"), ("h_sos10", "a_sos10"), |
| ("h_wp_vs_above500", "a_wp_vs_above500"), |
| ("h_wp_vs_top10", "a_wp_vs_top10"), |
| ] |
| for x, y_feat in inter_pairs: |
| names.append(f"inter_{x}_{y_feat}") |
|
|
| |
| ratio_pairs = [ |
| ("h_ortg10", "a_drtg10"), ("h_ortg5", "a_drtg5"), |
| ("a_ortg10", "h_drtg10"), ("a_ortg5", "h_drtg5"), |
| ("h_pace10", "a_pace10"), ("h_pace5", "a_pace5"), |
| ("h_efg10", "a_efg10"), ("h_efg5", "a_efg5"), |
| ("h_ts10", "a_ts10"), ("h_ts5", "a_ts5"), |
| ("h_ppg10", "a_ppg10"), ("h_ppg5", "a_ppg5"), |
| ("h_margin10", "a_margin10"), ("h_margin5", "a_margin5"), |
| ("h_wp10", "a_wp10"), ("h_wp5", "a_wp5"), |
| ("h_3p_pct10", "a_3p_pct10"), ("h_3p_pct5", "a_3p_pct5"), |
| ("h_ft_rate10", "a_ft_rate10"), ("h_ft_rate5", "a_ft_rate5"), |
| ("h_orb_rate10", "a_orb_rate10"), ("h_orb_rate5", "a_orb_rate5"), |
| ("h_tov_rate10", "a_tov_rate10"), ("h_tov_rate5", "a_tov_rate5"), |
| ("h_bench_pts10", "a_bench_pts10"), ("h_bench_pts5", "a_bench_pts5"), |
| ("h_papg10", "a_papg10"), ("h_papg5", "a_papg5"), |
| ("h_opp_efg10", "a_opp_efg10"), ("h_opp_efg5", "a_opp_efg5"), |
| ] |
| for x, y_feat in ratio_pairs: |
| names.append(f"ratio_{x}_{y_feat}") |
|
|
| |
| sq_features = [ |
| "h_wp10", "a_wp10", "h_wp5", "a_wp5", |
| "h_ortg10", "a_ortg10", "h_drtg10", "a_drtg10", |
| "h_netrtg10", "a_netrtg10", "h_margin10", "a_margin10", |
| "elo_diff", "spread_movement", "current_spread", |
| "h_pace10", "a_pace10", "h_efg10", "a_efg10", |
| "h_ppg10", "a_ppg10", "h_ts10", "a_ts10", |
| "rest_advantage", "travel_advantage", |
| "h_streak", "a_streak", "h_sos10", "a_sos10", |
| "h_consistency", "a_consistency", |
| ] |
| for feat in sq_features: |
| names.append(f"sq_{feat}") |
|
|
| |
| trend_stats = ["wp", "ppg", "margin", "ortg", "drtg", |
| "efg", "ts", "pace", "pd", "papg"] |
| trend_window_pairs = [ |
| (3, 7), (3, 10), (3, 15), (3, 20), |
| (5, 10), (5, 15), (5, 20), |
| (7, 15), (7, 20), (10, 20), |
| ] |
| for prefix in ["h", "a"]: |
| for stat in trend_stats: |
| for w1, w2 in trend_window_pairs: |
| names.append(f"trend_{prefix}_{stat}_w{w1}_w{w2}") |
|
|
| |
| |
| ewma_stats = ["ppg", "margin", "ortg", "drtg", "efg", "ts", "pace"] |
| ewma_alphas = ["01", "03", "05"] |
| for prefix in ["h", "a"]: |
| for stat in ewma_stats: |
| for alpha in ewma_alphas: |
| names.append(f"{prefix}_ewma_{stat}_a{alpha}") |
|
|
| |
| vol_stats = ["margin", "ppg", "papg", "ortg", "drtg", "pace"] |
| vol_windows = [5, 10, 20] |
| for prefix in ["h", "a"]: |
| for stat in vol_stats: |
| for w in vol_windows: |
| names.append(f"{prefix}_vol_{stat}_{w}") |
|
|
| |
| minmax_stats = ["ppg", "papg", "margin", "ortg"] |
| minmax_windows = [5, 10] |
| for prefix in ["h", "a"]: |
| for stat in minmax_stats: |
| for w in minmax_windows: |
| names.append(f"{prefix}_min_{stat}_{w}") |
| names.append(f"{prefix}_max_{stat}_{w}") |
|
|
| |
| zscore_stats = ["ppg", "papg", "margin", "ortg", "drtg", |
| "efg", "ts", "pace", "3p_pct", "ft_rate"] |
| for prefix in ["h", "a"]: |
| for stat in zscore_stats: |
| names.append(f"{prefix}_zscore_{stat}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for stat in ["margin", "ppg", "ortg", "drtg"]: |
| names.append(f"{prefix}_skew_{stat}_10") |
| names.append(f"{prefix}_kurtosis_{stat}_10") |
|
|
| |
| for prefix in ["h", "a"]: |
| for stat in ["ppg", "margin", "ortg", "drtg"]: |
| for w in [5, 10]: |
| names.append(f"{prefix}_range_{stat}_{w}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for stat in ["ppg", "margin", "ortg"]: |
| names.append(f"{prefix}_cv_{stat}_10") |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_pyth_wp") |
| names.append(f"{prefix}_pyth_vs_actual") |
| names.append(f"{prefix}_win_pace_82") |
| names.append(f"{prefix}_playoff_pace_delta") |
| names.append(f"{prefix}_games_behind_1st") |
| names.append(f"{prefix}_games_behind_8th") |
| names.append(f"{prefix}_games_ahead_lottery") |
| names.append(f"{prefix}_sos_remaining") |
| names.append(f"{prefix}_conf_rank") |
| names.append(f"{prefix}_div_rank") |
| names.append(f"{prefix}_is_playoff_team") |
| names.append(f"{prefix}_playin_range") |
| names.append(f"{prefix}_pre_allstar_wp") |
| names.append(f"{prefix}_post_allstar_wp") |
| names.append(f"{prefix}_allstar_delta") |
| names.append(f"{prefix}_pre_deadline_wp") |
| names.append(f"{prefix}_post_deadline_wp") |
| names.append(f"{prefix}_deadline_delta") |
| names.append(f"{prefix}_monthly_wp_trend") |
| names.append(f"{prefix}_monthly_ortg_trend") |
| names.append(f"{prefix}_monthly_drtg_trend") |
| names.append(f"{prefix}_season_half_improvement") |
| names.append(f"{prefix}_regression_indicator") |
| names.append(f"{prefix}_hot_cold_regime") |
| names.append(f"{prefix}_clinch_status") |
| names.append(f"{prefix}_games_remaining") |
| names.append(f"{prefix}_wp_last30") |
| names.append(f"{prefix}_wp_last30_vs_season") |
| names.append(f"{prefix}_home_road_trend_5") |
| names.append(f"{prefix}_scoring_variance_trend") |
| names.append(f"{prefix}_first_half_margin_avg") |
| names.append(f"{prefix}_second_half_margin_avg") |
| names.append(f"{prefix}_half_margin_delta") |
| names.append(f"{prefix}_record_vs_spread") |
| names.append(f"{prefix}_ats_trend_10") |
| names.append(f"{prefix}_over_rate_season") |
| names.append(f"{prefix}_pt_diff_close_vs_all") |
| names.append(f"{prefix}_record_after_loss") |
| names.append(f"{prefix}_record_after_win") |
| names.append(f"{prefix}_record_after_b2b") |
| names.append(f"{prefix}_blowout_bounce_back") |
| names.append(f"{prefix}_overtime_record") |
| |
| names.extend([ |
| "trajectory_wp_diff", |
| "trajectory_pyth_diff", |
| ]) |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_starting5_netrtg") |
| names.append(f"{prefix}_starting5_ortg") |
| names.append(f"{prefix}_starting5_drtg") |
| names.append(f"{prefix}_bench_netrtg") |
| names.append(f"{prefix}_bench_ortg") |
| names.append(f"{prefix}_bench_drtg") |
| names.append(f"{prefix}_starter_bench_gap") |
| names.append(f"{prefix}_minutes_entropy") |
| names.append(f"{prefix}_minutes_gini") |
| names.append(f"{prefix}_top_scorer_dependency") |
| names.append(f"{prefix}_top2_scorer_dependency") |
| names.append(f"{prefix}_top_scorer_minutes") |
| names.append(f"{prefix}_key_player_availability") |
| names.append(f"{prefix}_key_player_impact") |
| names.append(f"{prefix}_lineup_stability_10") |
| names.append(f"{prefix}_lineup_stability_5") |
| names.append(f"{prefix}_rotation_size") |
| names.append(f"{prefix}_two_man_combo_best") |
| names.append(f"{prefix}_two_man_combo_worst") |
| names.append(f"{prefix}_three_pt_shooters_count") |
| names.append(f"{prefix}_rim_protector_rating") |
| names.append(f"{prefix}_playmaker_rating") |
| names.append(f"{prefix}_defensive_versatility") |
| names.append(f"{prefix}_size_advantage") |
| names.append(f"{prefix}_speed_advantage") |
| names.append(f"{prefix}_experience_avg") |
| names.append(f"{prefix}_age_avg") |
| names.append(f"{prefix}_youth_factor") |
| names.append(f"{prefix}_veteran_factor") |
| names.append(f"{prefix}_clutch_player_rating") |
| names.append(f"{prefix}_injury_adjusted_depth") |
| names.append(f"{prefix}_recent_lineup_change") |
| |
| names.extend([ |
| "lineup_netrtg_diff", |
| "bench_quality_diff", |
| ]) |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_model_accuracy_10") |
| names.append(f"{prefix}_model_accuracy_30") |
| names.append(f"{prefix}_model_calibration_bias") |
| names.append(f"{prefix}_model_avg_edge") |
| names.append(f"{prefix}_model_roi_this_team") |
| names.append(f"{prefix}_feat_importance_rank") |
| names.append(f"{prefix}_prediction_confidence") |
| names.append(f"{prefix}_upset_rate_as_fav") |
| names.append(f"{prefix}_upset_rate_as_dog") |
| names.append(f"{prefix}_market_overreaction") |
| names.append(f"{prefix}_public_bias_team") |
| names.append(f"{prefix}_contrarian_value") |
| names.append(f"{prefix}_line_sensitivity") |
| names.append(f"{prefix}_steam_target_freq") |
| names.append(f"{prefix}_sharp_favorite") |
| names.append(f"{prefix}_model_disagreement") |
| |
| names.extend([ |
| "meta_model_vs_market_abs", |
| "meta_model_vs_market_direction", |
| "meta_model_confidence", |
| "meta_market_confidence", |
| "meta_consensus_strength", |
| "meta_historical_matchup_accuracy", |
| "meta_bankroll_adjusted_edge", |
| "meta_risk_adjusted_value", |
| "meta_opp_strategy_fast", |
| "meta_opp_strategy_slow", |
| "meta_opp_strategy_defensive", |
| "meta_opp_strategy_three_heavy", |
| "meta_game_type_cluster", |
| "meta_cluster_home_win_rate", |
| "meta_recent_model_drift", |
| "meta_feature_regime", |
| "meta_market_regime", |
| "meta_total_edge_composite", |
| ]) |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_conf_standing_pct") |
| names.append(f"{prefix}_div_standing_pct") |
| names.append(f"{prefix}_lottery_odds_proxy") |
| names.append(f"{prefix}_tank_indicator") |
| names.append(f"{prefix}_playoff_urgency") |
| names.append(f"{prefix}_revenge_game") |
| names.append(f"{prefix}_revenge_intensity") |
| names.append(f"{prefix}_coach_exp_years") |
| names.append(f"{prefix}_coach_win_rate") |
| names.append(f"{prefix}_coach_playoff_rate") |
| names.append(f"{prefix}_coach_vs_opp_coach") |
| names.append(f"{prefix}_coach_adjustment_rating") |
| names.append(f"{prefix}_public_team_popularity") |
| names.append(f"{prefix}_media_attention") |
| names.append(f"{prefix}_roster_turnover") |
| names.append(f"{prefix}_chemistry_index") |
| |
| names.extend([ |
| "conf_standing_diff", |
| "div_standing_diff", |
| "both_playoff_contenders", |
| "both_tanking", |
| "upset_potential", |
| "rivalry_intensity", |
| "public_side_home", |
| "contrarian_signal", |
| "weather_travel_factor", |
| "altitude_fatigue_compound", |
| "timezone_circadian_impact", |
| "arena_noise_factor", |
| ]) |
|
|
| |
| |
| |
| |
| cross_stats = ["wp", "ppg", "margin", "ortg", "drtg", |
| "efg", "ts", "pace", "papg", "pd"] |
| window_pairs = [ |
| (3, 5), (3, 7), (3, 10), (3, 15), (3, 20), |
| (5, 7), (5, 10), (5, 15), (5, 20), |
| (7, 10), (7, 15), (7, 20), |
| (10, 15), (10, 20), |
| (15, 20), |
| ] |
| for prefix in ["h", "a"]: |
| for stat in cross_stats: |
| for w1, w2 in window_pairs: |
| names.append(f"xw_{prefix}_{stat}_{w1}vs{w2}") |
| names.append(f"xw_accel_{prefix}_{stat}_{w1}vs{w2}") |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"xw_{prefix}_wp_shortterm_trend") |
| names.append(f"xw_{prefix}_wp_longterm_trend") |
| names.append(f"xw_{prefix}_margin_volatility_trend") |
| names.append(f"xw_{prefix}_ortg_improvement_rate") |
| names.append(f"xw_{prefix}_drtg_improvement_rate") |
| names.append(f"xw_{prefix}_overall_trajectory") |
| names.append(f"xw_{prefix}_form_acceleration") |
| names.append(f"xw_{prefix}_peak_window") |
| names.append(f"xw_{prefix}_trough_window") |
| names.append(f"xw_{prefix}_consistency_across_windows") |
| names.append(f"xw_{prefix}_trend_agreement") |
| names.append(f"xw_{prefix}_breakout_signal") |
| names.append(f"xw_{prefix}_decline_signal") |
| names.append(f"xw_{prefix}_mean_reversion_signal") |
| names.append(f"xw_{prefix}_momentum_strength") |
| |
|
|
| |
| if self.include_market: |
| |
| books = ["pinnacle", "draftkings", "fanduel", "betmgm", "caesars"] |
| for book in books: |
| names.append(f"mkt2_{book}_spread") |
| names.append(f"mkt2_{book}_ml_home") |
| names.append(f"mkt2_{book}_total") |
| names.append(f"mkt2_{book}_implied_home") |
| names.append(f"mkt2_{book}_line_move") |
| names.append(f"mkt2_{book}_reverse_move") |
| names.append(f"mkt2_{book}_historical_accuracy") |
| |
| names.extend([ |
| "mkt2_spread_range", |
| "mkt2_ml_range", |
| "mkt2_total_range", |
| "mkt2_implied_prob_range", |
| "mkt2_consensus_spread", |
| "mkt2_consensus_total", |
| "mkt2_pinnacle_vs_avg", |
| "mkt2_sharp_vs_soft_spread", |
| "mkt2_sharp_vs_soft_total", |
| "mkt2_time_since_open", |
| "mkt2_early_move_magnitude", |
| "mkt2_late_move_magnitude", |
| "mkt2_early_vs_late_direction", |
| "mkt2_public_vs_sharp_ratio", |
| "mkt2_steam_count_24h", |
| "mkt2_contrarian_opportunity", |
| "mkt2_market_maturity", |
| "mkt2_opening_value_home", |
| "mkt2_current_value_home", |
| "mkt2_value_trend", |
| "mkt2_best_line_home", |
| "mkt2_best_line_away", |
| "mkt2_juice_home", |
| "mkt2_juice_away", |
| "mkt2_vig_differential", |
| "mkt2_hold_pct", |
| "mkt2_line_freeze_indicator", |
| ]) |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_elo_standard") |
| names.append(f"{prefix}_elo_margin_adj") |
| names.append(f"{prefix}_elo_recency_weighted") |
| names.append(f"{prefix}_elo_home_adj") |
| names.append(f"{prefix}_elo_sos_adj") |
| names.append(f"{prefix}_elo_conf_adj") |
| names.append(f"{prefix}_elo_pace_adj") |
| names.append(f"{prefix}_raptor_composite") |
| names.append(f"{prefix}_raptor_offense") |
| names.append(f"{prefix}_raptor_defense") |
| names.append(f"{prefix}_power_rank_ortg") |
| names.append(f"{prefix}_power_rank_drtg") |
| names.append(f"{prefix}_power_rank_netrtg") |
| names.append(f"{prefix}_power_rank_srs") |
| names.append(f"{prefix}_power_rank_composite") |
| names.append(f"{prefix}_power_rank_trend") |
| names.append(f"{prefix}_power_conf_adjusted") |
| names.append(f"{prefix}_power_stability") |
| names.append(f"{prefix}_power_percentile") |
| names.append(f"{prefix}_rating_confidence") |
| names.append(f"{prefix}_bayesian_rating") |
| names.append(f"{prefix}_glicko_rating") |
| names.append(f"{prefix}_glicko_rd") |
| names.append(f"{prefix}_trueskill_mu") |
| names.append(f"{prefix}_trueskill_sigma") |
| |
| names.extend([ |
| "power_elo_std_diff", |
| "power_elo_margin_diff", |
| "power_elo_recency_diff", |
| "power_raptor_diff", |
| "power_raptor_off_diff", |
| "power_raptor_def_diff", |
| "power_srs_diff", |
| "power_composite_diff", |
| "power_conf_adj_diff", |
| "power_bayesian_diff", |
| "power_glicko_diff", |
| "power_trueskill_diff", |
| "power_max_diff", |
| "power_avg_diff", |
| ]) |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_cumul_games_played") |
| names.append(f"{prefix}_cumul_minutes_total") |
| names.append(f"{prefix}_avg_minutes_per_game") |
| names.append(f"{prefix}_star_minutes_cumul") |
| names.append(f"{prefix}_star_minutes_pct_season") |
| names.append(f"{prefix}_travel_miles_season") |
| names.append(f"{prefix}_travel_miles_30d") |
| names.append(f"{prefix}_travel_miles_7d") |
| names.append(f"{prefix}_travel_intensity") |
| names.append(f"{prefix}_rest_pattern_consistency") |
| names.append(f"{prefix}_rest_deficit_season") |
| names.append(f"{prefix}_b2b_count_season") |
| names.append(f"{prefix}_b2b_count_30d") |
| names.append(f"{prefix}_3in4_count_season") |
| names.append(f"{prefix}_dense_schedule_flag") |
| names.append(f"{prefix}_road_trip_length") |
| names.append(f"{prefix}_home_stand_length") |
| names.append(f"{prefix}_road_trip_fatigue") |
| names.append(f"{prefix}_load_management_prob") |
| names.append(f"{prefix}_season_fatigue_curve") |
| names.append(f"{prefix}_relative_fatigue") |
| names.append(f"{prefix}_fatigue_adjusted_ortg") |
| names.append(f"{prefix}_fatigue_adjusted_drtg") |
| names.append(f"{prefix}_fatigue_adjusted_wp") |
| names.append(f"{prefix}_recovery_quality") |
| names.append(f"{prefix}_b2b_performance_drop") |
| names.append(f"{prefix}_altitude_fatigue_cumul") |
| names.append(f"{prefix}_timezone_changes_season") |
| names.append(f"{prefix}_circadian_disruption") |
| names.append(f"{prefix}_early_season_load") |
| names.append(f"{prefix}_late_season_load") |
| names.append(f"{prefix}_minutes_distribution_health") |
| names.append(f"{prefix}_injury_risk_score") |
| names.append(f"{prefix}_stamina_rating") |
| names.append(f"{prefix}_clutch_fatigue") |
| names.append(f"{prefix}_fresh_vs_tired_ratio") |
| names.append(f"{prefix}_optimal_rest_indicator") |
| names.append(f"{prefix}_wear_and_tear_index") |
| |
| names.extend([ |
| "fatigue_cumul_diff", |
| "fatigue_travel_diff", |
| "fatigue_rest_quality_diff", |
| "fatigue_load_diff", |
| "fatigue_star_load_diff", |
| "fatigue_b2b_count_diff", |
| "fatigue_adjusted_spread", |
| "fatigue_composite_edge", |
| ]) |
|
|
| |
| |
| |
|
|
| |
| |
| |
| _pi_stats = ["plus_minus", "usage_rate", "per", "ws_per48", "bpm", |
| "vorp", "raptor_off", "raptor_def", "raptor_total", |
| "ts_pct", "ast_pct", "reb_pct"] |
| _pi_windows = [3, 5, 10, 20] |
| for prefix in ["h", "a"]: |
| |
| for stat in _pi_stats: |
| for w in _pi_windows: |
| names.append(f"{prefix}_star1_{stat}_{w}") |
| |
| for stat in _pi_stats: |
| for w in _pi_windows: |
| names.append(f"{prefix}_star2_{stat}_{w}") |
| |
| names.append(f"{prefix}_star_combined_plus_minus") |
| names.append(f"{prefix}_star_usage_concentration") |
| names.append(f"{prefix}_star_minutes_ratio") |
| names.append(f"{prefix}_star_efficiency_delta") |
| names.append(f"{prefix}_star_rest_adj_rating") |
| names.append(f"{prefix}_chemistry_starting5") |
| names.append(f"{prefix}_chemistry_top3") |
| names.append(f"{prefix}_player_variance") |
| names.append(f"{prefix}_top_player_dependency_score") |
| names.append(f"{prefix}_bench_player_avg_rating") |
| names.append(f"{prefix}_roster_talent_depth") |
| names.append(f"{prefix}_injury_replacement_quality") |
| |
| names.extend([ |
| "pi_star1_rating_diff", |
| "pi_star2_rating_diff", |
| "pi_combined_star_diff", |
| "pi_usage_concentration_diff", |
| "pi_chemistry_diff", |
| "pi_bench_quality_diff", |
| "pi_talent_depth_diff", |
| "pi_star_fatigue_adj_diff", |
| "pi_star_matchup_advantage", |
| "pi_key_player_edge", |
| "pi_roster_continuity_diff", |
| "pi_injury_impact_diff", |
| "pi_star_on_off_diff", |
| "pi_clutch_player_diff", |
| ]) |
|
|
| |
| |
| |
| _ref_quarters = ["q1", "q2", "q3", "q4"] |
| for q in _ref_quarters: |
| names.append(f"ref_{q}_foul_rate") |
| names.append(f"ref_{q}_home_foul_bias") |
| names.append(f"ref_{q}_tech_rate") |
| names.append(f"ref_{q}_and1_rate") |
| names.append(f"ref_{q}_shooting_foul_rate") |
| names.append(f"ref_{q}_offensive_foul_rate") |
| |
| _ref_team_types = ["fast_pace", "slow_pace", "top10", "bottom10", |
| "big_market", "small_market"] |
| for ttype in _ref_team_types: |
| names.append(f"ref_bias_{ttype}_home_wp") |
| names.append(f"ref_bias_{ttype}_foul_diff") |
| names.append(f"ref_bias_{ttype}_ft_diff") |
| |
| _ref_contexts = ["overall", "high_total", "low_total", "rivalry", |
| "playoff_race", "b2b_games", "national_tv"] |
| for ctx in _ref_contexts: |
| names.append(f"ref_over_tendency_{ctx}") |
| names.append(f"ref_under_tendency_{ctx}") |
| names.append(f"ref_total_delta_{ctx}") |
| |
| for prefix in ["h", "a"]: |
| names.append(f"ref_{prefix}_expected_pace_impact") |
| names.append(f"ref_{prefix}_expected_foul_impact") |
| names.append(f"ref_{prefix}_expected_ft_impact") |
| names.append(f"ref_{prefix}_historical_team_bias") |
| |
| names.extend([ |
| "ref_consistency_index", |
| "ref_home_bias_composite", |
| "ref_pace_impact_composite", |
| "ref_total_impact_composite", |
| "ref_foul_disparity_expected", |
| "ref_experience_weight", |
| "ref_crew_chemistry", |
| "ref_variance_in_calls", |
| "ref_big_game_experience", |
| "ref_crew_avg_total_called", |
| "ref_crew_foul_per_possession", |
| "ref_historical_ats_home_rate", |
| "ref_historical_over_rate_season", |
| "ref_recent_form_5_games", |
| "ref_recent_form_10_games", |
| "ref_travel_adjusted_bias", |
| ]) |
|
|
| |
| |
| |
| _venue_windows = [3, 5, 10, 20] |
| for prefix in ["h", "a"]: |
| |
| for w in _venue_windows: |
| names.append(f"{prefix}_altitude_adj_ortg_{w}") |
| names.append(f"{prefix}_altitude_adj_drtg_{w}") |
| names.append(f"{prefix}_altitude_adj_pace_{w}") |
| |
| for w in _venue_windows: |
| names.append(f"{prefix}_tz_cross_wp_{w}") |
| names.append(f"{prefix}_tz_cross_margin_{w}") |
| |
| names.append(f"{prefix}_home_arena_wp") |
| names.append(f"{prefix}_home_arena_margin") |
| names.append(f"{prefix}_home_arena_ortg") |
| names.append(f"{prefix}_home_arena_drtg") |
| names.append(f"{prefix}_arena_elevation_factor") |
| names.append(f"{prefix}_games_at_altitude_season") |
| names.append(f"{prefix}_altitude_acclimatized") |
| names.append(f"{prefix}_tz_disruption_score") |
| names.append(f"{prefix}_tz_direction_east") |
| names.append(f"{prefix}_tz_direction_west") |
| names.append(f"{prefix}_tz_games_same_zone") |
| names.append(f"{prefix}_attendance_ratio_avg") |
| names.append(f"{prefix}_attendance_trend") |
| names.append(f"{prefix}_court_surface_age") |
| names.append(f"{prefix}_temperature_at_gametime") |
| names.append(f"{prefix}_indoor_outdoor_flag") |
| names.append(f"{prefix}_arena_capacity") |
| names.append(f"{prefix}_arena_noise_proxy") |
| |
| names.extend([ |
| "venue_altitude_diff", |
| "venue_altitude_abs_diff", |
| "venue_tz_crossing_diff", |
| "venue_tz_abs_diff", |
| "venue_home_elevation_advantage", |
| "venue_attendance_ratio", |
| "venue_arena_age_diff", |
| "venue_climate_diff", |
| "venue_travel_direction", |
| "venue_acclimatization_diff", |
| "venue_home_arena_strength", |
| "venue_surface_familiarity_diff", |
| "venue_noise_advantage", |
| "venue_altitude_fatigue_interaction", |
| "venue_tz_fatigue_interaction", |
| "venue_combined_environmental_edge", |
| ]) |
|
|
| |
| |
| if self.include_market: |
| _mkt3_windows = ["1h", "2h", "4h", "8h", "12h", "24h"] |
| |
| for tw in _mkt3_windows: |
| names.append(f"mkt3_spread_velocity_{tw}") |
| names.append(f"mkt3_spread_acceleration_{tw}") |
| names.append(f"mkt3_total_velocity_{tw}") |
| names.append(f"mkt3_total_acceleration_{tw}") |
| names.append(f"mkt3_ml_velocity_{tw}") |
| names.append(f"mkt3_ml_acceleration_{tw}") |
| |
| _mkt3_books = ["pinnacle", "draftkings", "fanduel", "betmgm", |
| "caesars", "bet365", "william_hill"] |
| for book in _mkt3_books: |
| names.append(f"mkt3_{book}_vs_consensus_spread") |
| names.append(f"mkt3_{book}_vs_consensus_total") |
| names.append(f"mkt3_{book}_vs_consensus_ml") |
| names.append(f"mkt3_{book}_clv_history") |
| |
| names.extend([ |
| "mkt3_sharp_pct_home", "mkt3_sharp_pct_away", |
| "mkt3_public_pct_home", "mkt3_public_pct_away", |
| "mkt3_sharp_public_divergence_spread", |
| "mkt3_sharp_public_divergence_total", |
| "mkt3_sharp_money_direction", |
| "mkt3_public_money_direction", |
| "mkt3_sharp_intensity_score", |
| ]) |
| |
| names.extend([ |
| "mkt3_steam_count_total", "mkt3_steam_count_last_6h", |
| "mkt3_steam_magnitude_avg", "mkt3_steam_direction", |
| "mkt3_reverse_steam_flag", "mkt3_steam_books_triggered", |
| ]) |
| |
| names.extend([ |
| "mkt3_rlm_spread_flag", "mkt3_rlm_total_flag", |
| "mkt3_rlm_magnitude_spread", "mkt3_rlm_magnitude_total", |
| "mkt3_rlm_sharp_confirmation", |
| ]) |
| |
| for book in _mkt3_books: |
| names.append(f"mkt3_clv_{book}_home") |
| |
| names.extend([ |
| "mkt3_open_to_close_spread_delta", |
| "mkt3_open_to_close_total_delta", |
| "mkt3_open_to_close_ml_delta", |
| "mkt3_open_to_close_implied_delta", |
| ]) |
| |
| names.extend([ |
| "mkt3_ml_convergence_rate", |
| "mkt3_ml_convergence_direction", |
| "mkt3_implied_prob_convergence", |
| "mkt3_book_agreement_score", |
| "mkt3_market_depth_proxy", |
| "mkt3_liquidity_score", |
| "mkt3_market_manipulation_flag", |
| "mkt3_arbitrage_opportunity", |
| "mkt3_hold_pct_change", |
| "mkt3_vig_trend", |
| ]) |
| |
| names.extend([ |
| "mkt3_historical_clv_this_matchup", |
| "mkt3_historical_rlm_success_rate", |
| "mkt3_historical_steam_success_rate", |
| "mkt3_historical_sharp_roi", |
| "mkt3_historical_public_fade_roi", |
| "mkt3_line_stability_score", |
| "mkt3_early_sharp_vs_late_public", |
| "mkt3_market_overreaction_index", |
| "mkt3_odds_shape_skewness", |
| "mkt3_odds_shape_kurtosis", |
| ]) |
|
|
| |
| |
| _ts_stats = ["wp", "ppg", "margin", "ortg", "drtg", "efg", "ts", "pace"] |
| _ts_trend_windows = [3, 5, 10, 20] |
| _ts_lags = [1, 2, 3, 4, 5] |
| for prefix in ["h", "a"]: |
| |
| for stat in _ts_stats: |
| for w in _ts_trend_windows: |
| names.append(f"ts_trend_{prefix}_{stat}_{w}") |
| |
| for stat in _ts_stats: |
| names.append(f"ts_seasonal_dow_{prefix}_{stat}") |
| |
| for stat in _ts_stats: |
| names.append(f"ts_seasonal_month_{prefix}_{stat}") |
| |
| for stat in _ts_stats: |
| names.append(f"ts_residual_vol_{prefix}_{stat}") |
| |
| for stat in _ts_stats: |
| for lag in _ts_lags: |
| names.append(f"ts_acf_{prefix}_{stat}_lag{lag}") |
| |
| for stat in _ts_stats: |
| for lag in _ts_lags: |
| names.append(f"ts_pacf_{prefix}_{stat}_lag{lag}") |
| |
| for stat in _ts_stats: |
| names.append(f"ts_stationarity_{prefix}_{stat}") |
| |
| for stat in _ts_stats: |
| names.append(f"ts_trend_strength_{prefix}_{stat}") |
| |
| for stat in _ts_stats: |
| names.append(f"ts_season_strength_{prefix}_{stat}") |
|
|
| |
| |
| _xteam_stats = [ |
| "pace", "ortg", "drtg", "efg", "3p_pct", "paint_pts", |
| "fb_pts", "tov_rate", "oreb_pct", "ft_rate", |
| ] |
| _xteam_windows = [5, 10, 20] |
| |
| |
| for stat in _xteam_stats: |
| for w in _xteam_windows: |
| names.append(f"xteam_diff_{stat}_{w}") |
| names.append(f"xteam_ratio_{stat}_{w}") |
| names.append(f"xteam_inter_{stat}_{w}") |
| names.append(f"xteam_mismatch_{stat}_{w}") |
| |
| for stat in _xteam_stats: |
| for w in _xteam_windows: |
| names.append(f"xteam_off_vs_def_{stat}_{w}") |
| |
| for stat in _xteam_stats: |
| for w in _xteam_windows: |
| names.append(f"xteam_def_vs_off_{stat}_{w}") |
| |
| _style_types = [ |
| "pace_clash", "shooting_clash", "paint_battle", |
| "transition_war", "turnover_battle", "rebounding_war", |
| "free_throw_battle", "three_pt_war", "defense_clash", |
| "tempo_mismatch", |
| ] |
| for style in _style_types: |
| for w in _xteam_windows: |
| names.append(f"xteam_style_{style}_{w}") |
| |
| _strength_areas = [ |
| "perimeter_off_vs_perimeter_def", |
| "interior_off_vs_interior_def", |
| "transition_off_vs_transition_def", |
| "halfcourt_off_vs_halfcourt_def", |
| "shooting_off_vs_shooting_def", |
| "rebounding_off_vs_rebounding_def", |
| "playmaking_vs_ball_pressure", |
| "rim_protection_vs_paint_scoring", |
| "three_pt_shooting_vs_three_pt_defense", |
| "free_throw_drawing_vs_foul_avoidance", |
| ] |
| for area in _strength_areas: |
| for w in _xteam_windows: |
| names.append(f"xteam_strength_{area}_{w}") |
| |
| names.extend([ |
| "xteam_overall_style_clash", |
| "xteam_offensive_edge_composite", |
| "xteam_defensive_edge_composite", |
| "xteam_pace_war_indicator", |
| "xteam_grind_game_indicator", |
| "xteam_shootout_indicator", |
| "xteam_mismatch_severity", |
| "xteam_balanced_matchup_flag", |
| "xteam_upset_structural_flag", |
| "xteam_blowout_structural_flag", |
| ]) |
|
|
| |
| |
| for prefix in ["h", "a"]: |
| |
| names.append(f"{prefix}_preseason_win_total_ou") |
| names.append(f"{prefix}_vegas_season_win_total") |
| names.append(f"{prefix}_preseason_power_rank") |
| names.append(f"{prefix}_preseason_conf_rank") |
| names.append(f"{prefix}_preseason_division_rank") |
| names.append(f"{prefix}_vegas_championship_odds") |
| names.append(f"{prefix}_vegas_conf_winner_odds") |
| names.append(f"{prefix}_preseason_vs_actual_wp") |
| names.append(f"{prefix}_preseason_vs_actual_delta") |
| names.append(f"{prefix}_bayesian_prior_strength") |
| |
| names.append(f"{prefix}_franchise_historical_wp_10yr") |
| names.append(f"{prefix}_franchise_historical_wp_5yr") |
| names.append(f"{prefix}_franchise_championships") |
| names.append(f"{prefix}_franchise_finals_appearances") |
| names.append(f"{prefix}_franchise_playoff_rate_10yr") |
| names.append(f"{prefix}_franchise_avg_seed_5yr") |
| names.append(f"{prefix}_franchise_consistency_5yr") |
| names.append(f"{prefix}_franchise_rebuild_indicator") |
| names.append(f"{prefix}_franchise_contender_indicator") |
| names.append(f"{prefix}_franchise_stability_index") |
| |
| names.append(f"{prefix}_coach_career_wp") |
| names.append(f"{prefix}_coach_playoff_wp") |
| names.append(f"{prefix}_coach_tenure_years") |
| names.append(f"{prefix}_coach_tenure_adjustment") |
| names.append(f"{prefix}_coach_with_team_years") |
| names.append(f"{prefix}_coach_system_maturity") |
| names.append(f"{prefix}_coach_ato_rating") |
| names.append(f"{prefix}_coach_challenge_success_rate") |
| names.append(f"{prefix}_coach_close_game_wp") |
| names.append(f"{prefix}_coach_blowout_wp") |
| names.append(f"{prefix}_coach_b2b_wp") |
| names.append(f"{prefix}_coach_road_wp") |
| names.append(f"{prefix}_coach_home_wp") |
| names.append(f"{prefix}_coach_vs_winning_teams_wp") |
| names.append(f"{prefix}_coach_comeback_rate") |
| names.append(f"{prefix}_coach_defensive_rating_rank") |
| names.append(f"{prefix}_coach_offensive_rating_rank") |
| names.append(f"{prefix}_coach_pace_preference") |
| |
| names.append(f"{prefix}_bayesian_wp_prior_blend") |
| names.append(f"{prefix}_bayesian_rating_confidence") |
| names.append(f"{prefix}_bayesian_update_magnitude") |
| names.append(f"{prefix}_bayesian_prior_weight") |
| names.append(f"{prefix}_prior_vs_observed_divergence") |
| names.append(f"{prefix}_regression_to_prior") |
| names.append(f"{prefix}_prior_adjusted_ortg") |
| names.append(f"{prefix}_prior_adjusted_drtg") |
| names.append(f"{prefix}_market_implied_prior") |
| names.append(f"{prefix}_composite_bayesian_rating") |
| |
| names.extend([ |
| "bayes_preseason_diff", |
| "bayes_vegas_win_total_diff", |
| "bayes_franchise_strength_diff", |
| "bayes_coach_wp_diff", |
| "bayes_coach_tenure_diff", |
| "bayes_prior_blend_diff", |
| "bayes_regression_diff", |
| "bayes_championship_odds_diff", |
| "bayes_system_maturity_diff", |
| "bayes_composite_diff", |
| ]) |
|
|
| |
| |
| _net_windows = [10, 20, 82] |
| for prefix in ["h", "a"]: |
| |
| for w in _net_windows: |
| names.append(f"{prefix}_pagerank_wins_{w}") |
| names.append(f"{prefix}_pagerank_margin_{w}") |
| names.append(f"{prefix}_pagerank_weighted_{w}") |
| |
| for w in _net_windows: |
| names.append(f"{prefix}_clustering_coeff_{w}") |
| |
| for w in _net_windows: |
| names.append(f"{prefix}_betweenness_centrality_{w}") |
| |
| for w in _net_windows: |
| names.append(f"{prefix}_sos_network_centrality_{w}") |
| names.append(f"{prefix}_sos_network_pagerank_{w}") |
| |
| names.append(f"{prefix}_conf_connectivity") |
| names.append(f"{prefix}_conf_dominance") |
| names.append(f"{prefix}_conf_beaten_best") |
| names.append(f"{prefix}_conf_lost_to_worst") |
| |
| names.append(f"{prefix}_div_rivalry_intensity") |
| names.append(f"{prefix}_div_dominance") |
| names.append(f"{prefix}_div_games_played") |
| names.append(f"{prefix}_div_wp") |
| |
| for w in _net_windows: |
| names.append(f"{prefix}_win_chain_depth_{w}") |
| names.append(f"{prefix}_win_chain_strength_{w}") |
| |
| for w in _net_windows: |
| names.append(f"{prefix}_loss_chain_depth_{w}") |
| |
| for w in _net_windows: |
| names.append(f"{prefix}_opponents_beaten_{w}") |
| names.append(f"{prefix}_opponents_beaten_pct_{w}") |
| names.append(f"{prefix}_unique_losses_{w}") |
| |
| for w in _net_windows: |
| names.append(f"{prefix}_eigenvector_centrality_{w}") |
| |
| names.extend([ |
| "net_pagerank_diff_82", |
| "net_pagerank_diff_20", |
| "net_clustering_diff", |
| "net_centrality_diff", |
| "net_conf_dominance_diff", |
| "net_div_dominance_diff", |
| "net_win_chain_diff", |
| "net_opponents_beaten_diff", |
| "net_eigenvector_diff", |
| "net_network_advantage_composite", |
| ]) |
|
|
| |
| |
| _meta_models = ["xgboost", "lightgbm", "catboost", "rf", "logistic"] |
| _meta_windows = [5, 10, 20, 30] |
| for prefix in ["h", "a"]: |
| |
| for model in _meta_models: |
| for w in _meta_windows: |
| names.append(f"meta2_{prefix}_{model}_accuracy_{w}") |
| |
| for model in _meta_models: |
| names.append(f"meta2_{prefix}_{model}_calibration") |
| |
| names.append(f"meta2_{prefix}_model_disagreement") |
| names.append(f"meta2_{prefix}_model_disagreement_trend") |
| names.append(f"meta2_{prefix}_prediction_uncertainty") |
| names.append(f"meta2_{prefix}_prediction_stability") |
| |
| for model in _meta_models: |
| names.append(f"meta2_{model}_predicted_prob") |
| names.append(f"meta2_{model}_confidence") |
| names.append(f"meta2_{model}_edge_vs_market") |
| names.extend([ |
| "meta2_ensemble_mean_prob", |
| "meta2_ensemble_std_prob", |
| "meta2_ensemble_max_prob", |
| "meta2_ensemble_min_prob", |
| "meta2_ensemble_range", |
| "meta2_model_agreement_score", |
| "meta2_weighted_ensemble_prob", |
| "meta2_calibration_residual", |
| "meta2_historical_accuracy_similar_games", |
| "meta2_feature_importance_stability", |
| "meta2_feature_regime_indicator", |
| "meta2_prediction_drift_5", |
| "meta2_prediction_drift_10", |
| "meta2_model_confidence_composite", |
| "meta2_edge_confidence_product", |
| "meta2_risk_adjusted_edge", |
| "meta2_bankroll_optimal_fraction", |
| "meta2_expected_value_composite", |
| "meta2_sharpe_implied_edge", |
| "meta2_historical_roi_similar", |
| ]) |
|
|
| |
| |
| _td_stats = ["wp", "ppg", "papg", "margin", "ortg", "drtg", |
| "efg", "ts", "pace", "3p_pct"] |
| _td_half_lives = [3, 5, 10, 20] |
| for prefix in ["h", "a"]: |
| |
| for stat in _td_stats: |
| for hl in _td_half_lives: |
| names.append(f"td_decay_{prefix}_{stat}_hl{hl}") |
| |
| for hl in _td_half_lives: |
| names.append(f"td_opp_quality_{prefix}_hl{hl}") |
| |
| for hl in _td_half_lives: |
| names.append(f"td_home_split_{prefix}_hl{hl}") |
| names.append(f"td_away_split_{prefix}_hl{hl}") |
| |
| _phases = ["early", "mid", "late"] |
| for stat in _td_stats: |
| for phase in _phases: |
| names.append(f"td_phase_{prefix}_{stat}_{phase}") |
| |
| |
| _td_pairs = [(3, 5), (3, 10), (3, 20), (5, 10), (5, 20), (10, 20)] |
| for stat in _td_stats: |
| for hl1, hl2 in _td_pairs: |
| names.append(f"td_trend_{prefix}_{stat}_hl{hl1}_vs_hl{hl2}") |
| |
| for stat in _td_stats: |
| for hl in _td_half_lives: |
| names.append(f"td_diff_{stat}_hl{hl}") |
|
|
| |
| |
| |
|
|
| |
| |
| _positions = ["pg", "sg", "sf", "pf", "c"] |
| for prefix in ["h", "a"]: |
| for pos in _positions: |
| names.append(f"{prefix}_pos_{pos}_rating") |
| names.append(f"{prefix}_pos_{pos}_minutes_share") |
| names.append(f"{prefix}_pos_{pos}_plus_minus") |
| names.append(f"{prefix}_pos_{pos}_usage") |
| names.append(f"{prefix}_pos_{pos}_ts_pct") |
| names.append(f"{prefix}_pos_{pos}_def_rating") |
| |
| for prefix in ["h", "a"]: |
| for combo_idx in range(1, 6): |
| names.append(f"{prefix}_combo{combo_idx}_netrtg") |
| names.append(f"{prefix}_combo{combo_idx}_minutes") |
| names.append(f"{prefix}_combo{combo_idx}_plus_minus") |
| |
| for prefix in ["h", "a"]: |
| for unit in ["start", "bench", "closing"]: |
| for w in [5, 10]: |
| names.append(f"{prefix}_{unit}_unit_ortg_{w}") |
| names.append(f"{prefix}_{unit}_unit_drtg_{w}") |
| names.append(f"{prefix}_{unit}_unit_netrtg_{w}") |
| names.append(f"{prefix}_{unit}_unit_pace_{w}") |
| |
| for pos in _positions: |
| names.append(f"pos_matchup_{pos}_off_advantage") |
| names.append(f"pos_matchup_{pos}_def_advantage") |
| names.append(f"pos_matchup_{pos}_size_diff") |
| names.append(f"pos_matchup_{pos}_speed_diff") |
|
|
| |
| |
| _score_contexts = ["blowout", "close", "tied", "home_leading", "away_leading"] |
| for ctx in _score_contexts: |
| names.append(f"ref_foul_rate_{ctx}") |
| names.append(f"ref_home_bias_{ctx}") |
| names.append(f"ref_tech_rate_{ctx}") |
| names.append(f"ref_review_rate_{ctx}") |
| |
| _play_types = ["post_up", "pick_roll", "isolation", "transition", |
| "spot_up", "off_screen", "handoff", "cut"] |
| for play in _play_types: |
| names.append(f"ref_foul_rate_{play}") |
| names.append(f"ref_and1_rate_{play}") |
| |
| for prefix in ["h", "a"]: |
| names.append(f"ref_{prefix}_team_specific_foul_rate") |
| names.append(f"ref_{prefix}_team_specific_ft_rate") |
| names.append(f"ref_{prefix}_team_specific_tech_rate") |
| names.append(f"ref_{prefix}_team_historical_wp_with_ref") |
| names.append(f"ref_{prefix}_team_historical_margin_with_ref") |
| names.append(f"ref_{prefix}_team_historical_total_with_ref") |
| |
| names.extend([ |
| "ref_crew_avg_experience", |
| "ref_crew_min_experience", |
| "ref_crew_max_experience", |
| "ref_crew_experience_variance", |
| "ref_crew_consistency_rating", |
| "ref_crew_foul_rate_consistency", |
| "ref_crew_home_bias_agreement", |
| "ref_lead_official_weight", |
| "ref_lead_vs_crew_bias_diff", |
| "ref_night_game_adjustment", |
| "ref_day_game_adjustment", |
| "ref_back_to_back_ref_fatigue", |
| ]) |
| |
| for prefix in ["h", "a"]: |
| for w in [5, 10]: |
| names.append(f"ref_{prefix}_foul_drawing_ability_{w}") |
| names.append(f"ref_{prefix}_foul_committing_rate_{w}") |
| names.append(f"ref_{prefix}_ft_generation_rate_{w}") |
| names.append(f"ref_{prefix}_tech_tendency_{w}") |
|
|
| |
| |
| _weather_features = ["temperature", "humidity", "wind", "precipitation", |
| "snow_flag", "storm_flag"] |
| for prefix in ["h", "a"]: |
| for feat in _weather_features: |
| names.append(f"env_{prefix}_{feat}_at_arena") |
| |
| names.append(f"env_{prefix}_travel_weather_severity") |
| names.append(f"env_{prefix}_flight_delay_risk") |
| |
| for prefix in ["h", "a"]: |
| names.append(f"env_{prefix}_arena_age_years") |
| names.append(f"env_{prefix}_arena_renovation_recent") |
| names.append(f"env_{prefix}_arena_crowd_density") |
| names.append(f"env_{prefix}_arena_court_type") |
| names.append(f"env_{prefix}_arena_lighting_quality") |
| names.append(f"env_{prefix}_arena_rim_tightness") |
| names.append(f"env_{prefix}_arena_3pt_distance_factor") |
| names.append(f"env_{prefix}_arena_shooting_friendly") |
| |
| for prefix in ["h", "a"]: |
| names.append(f"env_{prefix}_city_population") |
| names.append(f"env_{prefix}_city_market_size") |
| names.append(f"env_{prefix}_city_nightlife_index") |
| names.append(f"env_{prefix}_city_distraction_factor") |
| |
| names.extend([ |
| "env_game_time_hour", |
| "env_game_time_prime_time", |
| "env_game_time_early", |
| "env_game_time_late", |
| "env_daylight_hours_home", |
| "env_daylight_hours_away", |
| ]) |
| |
| for prefix in ["h", "a"]: |
| for w in [5, 10, 20]: |
| names.append(f"env_{prefix}_high_altitude_wp_{w}") |
| names.append(f"env_{prefix}_high_altitude_margin_{w}") |
| names.append(f"env_{prefix}_sea_level_wp_{w}") |
| names.append(f"env_{prefix}_sea_level_margin_{w}") |
| |
| for prefix in ["h", "a"]: |
| for w in [5, 10]: |
| names.append(f"env_{prefix}_cross_country_wp_{w}") |
| names.append(f"env_{prefix}_cross_country_margin_{w}") |
| names.append(f"env_{prefix}_same_coast_wp_{w}") |
| names.append(f"env_{prefix}_same_coast_margin_{w}") |
| |
| names.extend([ |
| "env_combined_weather_impact", |
| "env_combined_venue_advantage", |
| "env_combined_travel_disruption", |
| "env_altitude_weather_interaction", |
| "env_timezone_weather_interaction", |
| "env_market_size_diff", |
| "env_distraction_diff", |
| "env_arena_shooting_diff", |
| ]) |
|
|
| |
| if self.include_market: |
| |
| _prop_types = ["total_pts", "home_pts", "away_pts", "home_spread", |
| "first_half_spread", "first_half_total", |
| "second_half_spread", "second_half_total", |
| "q1_spread", "q1_total"] |
| for prop in _prop_types: |
| names.append(f"mkt3_prop_{prop}_opening") |
| names.append(f"mkt3_prop_{prop}_current") |
| names.append(f"mkt3_prop_{prop}_movement") |
| names.append(f"mkt3_prop_{prop}_velocity") |
| |
| names.extend([ |
| "mkt3_alt_spread_3pt_total", |
| "mkt3_alt_spread_rebounds_total", |
| "mkt3_alt_spread_assists_total", |
| "mkt3_alt_first_basket", |
| "mkt3_alt_race_to_20", |
| "mkt3_alt_highest_scoring_quarter", |
| ]) |
| |
| names.extend([ |
| "mkt3_spread_total_correlation", |
| "mkt3_spread_ml_correlation", |
| "mkt3_total_ml_correlation", |
| "mkt3_first_half_full_game_corr", |
| "mkt3_prop_main_line_divergence", |
| ]) |
| |
| _snap_times = ["open", "12h", "6h", "3h", "1h", "30min"] |
| for snap in _snap_times: |
| names.append(f"mkt3_snapshot_spread_{snap}") |
| names.append(f"mkt3_snapshot_total_{snap}") |
| names.append(f"mkt3_snapshot_ml_home_{snap}") |
| |
| names.extend([ |
| "mkt3_market_efficiency_spread", |
| "mkt3_market_efficiency_total", |
| "mkt3_market_efficiency_ml", |
| "mkt3_price_discovery_speed", |
| "mkt3_information_asymmetry", |
| "mkt3_market_consensus_time", |
| "mkt3_overnight_movement", |
| "mkt3_morning_adjustment", |
| ]) |
| |
| _sim_contexts = ["same_matchup", "same_spread_range", "same_total_range", |
| "same_rest_pattern", "same_season_phase"] |
| for ctx in _sim_contexts: |
| names.append(f"mkt3_hist_accuracy_{ctx}") |
| names.append(f"mkt3_hist_clv_{ctx}") |
|
|
| |
| |
| _advanced_matchup_stats = [ |
| "rim_att_rate", "midrange_rate", "corner3_rate", "above_break3_rate", |
| "pullup_rate", "catch_shoot_rate", "isolation_rate", "pnr_ball_handler", |
| "pnr_roll_man", "post_up_rate", "transition_freq", "cut_freq", |
| ] |
| for stat in _advanced_matchup_stats: |
| for w in [5, 10]: |
| names.append(f"xteam_h_off_{stat}_{w}") |
| names.append(f"xteam_a_off_{stat}_{w}") |
| names.append(f"xteam_h_def_{stat}_{w}") |
| names.append(f"xteam_a_def_{stat}_{w}") |
| names.append(f"xteam_matchup_h_{stat}_{w}") |
| names.append(f"xteam_matchup_a_{stat}_{w}") |
| |
| _shot_zones = ["paint", "midrange", "corner3", "above_break3", "rim"] |
| for zone in _shot_zones: |
| for w in [5, 10]: |
| names.append(f"xteam_h_shot_freq_{zone}_{w}") |
| names.append(f"xteam_a_shot_freq_{zone}_{w}") |
| names.append(f"xteam_h_def_allow_{zone}_{w}") |
| names.append(f"xteam_a_def_allow_{zone}_{w}") |
| names.append(f"xteam_zone_mismatch_{zone}_{w}") |
| |
| _pace_cats = ["ultra_fast", "fast", "average", "slow", "ultra_slow"] |
| for pace_cat in _pace_cats: |
| names.append(f"xteam_h_wp_vs_{pace_cat}") |
| names.append(f"xteam_a_wp_vs_{pace_cat}") |
| names.append(f"xteam_h_margin_vs_{pace_cat}") |
| names.append(f"xteam_a_margin_vs_{pace_cat}") |
|
|
| |
| |
| _prior_types = ["flat", "preseason", "historical", "market_implied", "composite"] |
| for prefix in ["h", "a"]: |
| for prior in _prior_types: |
| names.append(f"bayes2_{prefix}_rating_{prior}") |
| names.append(f"bayes2_{prefix}_confidence_{prior}") |
| names.append(f"bayes2_{prefix}_update_rate_{prior}") |
| |
| for w in [10, 20, 40]: |
| names.append(f"bayes2_{prefix}_shrinkage_wp_{w}") |
| names.append(f"bayes2_{prefix}_shrinkage_ortg_{w}") |
| names.append(f"bayes2_{prefix}_shrinkage_drtg_{w}") |
| names.append(f"bayes2_{prefix}_shrinkage_netrtg_{w}") |
| |
| names.append(f"bayes2_{prefix}_coach_expected_wp") |
| names.append(f"bayes2_{prefix}_coach_overperformance") |
| names.append(f"bayes2_{prefix}_coach_underperformance") |
| names.append(f"bayes2_{prefix}_coach_trajectory") |
| |
| names.append(f"bayes2_{prefix}_roster_turnover_adj") |
| names.append(f"bayes2_{prefix}_new_player_integration") |
| names.append(f"bayes2_{prefix}_core_retained_pct") |
| names.append(f"bayes2_{prefix}_trade_deadline_impact") |
| |
| names.append(f"bayes2_{prefix}_injury_prior_wpd") |
| names.append(f"bayes2_{prefix}_injury_bayesian_adj") |
| names.append(f"bayes2_{prefix}_healthy_roster_prior") |
| |
| for prior in _prior_types: |
| names.append(f"bayes2_rating_diff_{prior}") |
| names.extend([ |
| "bayes2_shrinkage_diff_wp", |
| "bayes2_shrinkage_diff_netrtg", |
| "bayes2_coach_impact_diff", |
| "bayes2_roster_stability_diff", |
| "bayes2_injury_adjusted_diff", |
| "bayes2_composite_prior_diff", |
| "bayes2_prior_confidence_diff", |
| "bayes2_update_magnitude_diff", |
| ]) |
|
|
| |
| |
| for prefix in ["h", "a"]: |
| |
| names.append(f"net2_{prefix}_conf_pagerank") |
| names.append(f"net2_{prefix}_conf_clustering") |
| names.append(f"net2_{prefix}_conf_degree_centrality") |
| names.append(f"net2_{prefix}_conf_closeness_centrality") |
| |
| names.append(f"net2_{prefix}_div_pagerank") |
| names.append(f"net2_{prefix}_div_clustering") |
| names.append(f"net2_{prefix}_div_degree_centrality") |
| |
| for w in [10, 20, 82]: |
| names.append(f"net2_{prefix}_quality_weighted_wins_{w}") |
| names.append(f"net2_{prefix}_quality_weighted_losses_{w}") |
| names.append(f"net2_{prefix}_weighted_margin_network_{w}") |
| |
| names.append(f"net2_{prefix}_opp_avg_pagerank") |
| names.append(f"net2_{prefix}_opp_avg_centrality") |
| names.append(f"net2_{prefix}_opp_diversity_index") |
| names.append(f"net2_{prefix}_beaten_teams_avg_wp") |
| names.append(f"net2_{prefix}_lost_to_teams_avg_wp") |
| |
| for depth in [2, 3, 4]: |
| names.append(f"net2_{prefix}_transitive_strength_d{depth}") |
| names.append(f"net2_{prefix}_transitive_weakness_d{depth}") |
| |
| names.append(f"net2_{prefix}_colley_rating") |
| names.append(f"net2_{prefix}_colley_rank") |
| |
| names.append(f"net2_{prefix}_massey_rating") |
| names.append(f"net2_{prefix}_massey_offensive") |
| names.append(f"net2_{prefix}_massey_defensive") |
| |
| names.append(f"net2_{prefix}_keener_rating") |
| names.append(f"net2_{prefix}_keener_dominance") |
| |
| names.extend([ |
| "net2_conf_pagerank_diff", |
| "net2_div_pagerank_diff", |
| "net2_quality_weighted_diff", |
| "net2_opp_quality_diff", |
| "net2_transitive_diff", |
| "net2_colley_diff", |
| "net2_massey_diff", |
| "net2_massey_off_diff", |
| "net2_massey_def_diff", |
| "net2_keener_diff", |
| "net2_composite_network_diff", |
| "net2_network_surprise_factor", |
| ]) |
|
|
| |
| |
| _model_pairs = [ |
| ("xgboost", "lightgbm"), ("xgboost", "catboost"), ("xgboost", "rf"), |
| ("xgboost", "logistic"), ("lightgbm", "catboost"), ("lightgbm", "rf"), |
| ("lightgbm", "logistic"), ("catboost", "rf"), ("catboost", "logistic"), |
| ("rf", "logistic"), |
| ] |
| for m1, m2 in _model_pairs: |
| names.append(f"meta3_{m1}_{m2}_agreement") |
| names.append(f"meta3_{m1}_{m2}_diff") |
| names.append(f"meta3_{m1}_{m2}_avg") |
| |
| _top_feat_groups = ["rolling", "four_factors", "pace", "scoring", |
| "momentum", "rest", "market", "matchup", |
| "context", "power_rating"] |
| for fg in _top_feat_groups: |
| names.append(f"meta3_feat_importance_{fg}_mean") |
| names.append(f"meta3_feat_importance_{fg}_std") |
| names.append(f"meta3_feat_importance_{fg}_rank") |
| |
| _game_contexts = ["home_fav", "home_dog", "high_total", "low_total", |
| "b2b", "rest_adv", "rivalry", "non_conf", |
| "playoff_race", "tanking"] |
| for ctx in _game_contexts: |
| names.append(f"meta3_accuracy_{ctx}") |
| names.append(f"meta3_roi_{ctx}") |
| names.append(f"meta3_brier_{ctx}") |
| |
| for w in [5, 10, 20, 50]: |
| names.append(f"meta3_model_accuracy_overall_{w}") |
| names.append(f"meta3_model_brier_overall_{w}") |
| names.append(f"meta3_model_roi_overall_{w}") |
| names.append(f"meta3_model_calibration_{w}") |
| names.append(f"meta3_model_sharpness_{w}") |
| |
| for model in _meta_models: |
| names.append(f"meta3_{model}_prob_home") |
| names.append(f"meta3_{model}_prob_away") |
| names.append(f"meta3_{model}_margin_pred") |
| names.append(f"meta3_{model}_total_pred") |
|
|
| |
| |
| _kernels = ["gaussian", "triangular", "epanechnikov"] |
| _kernel_bw = [3, 7, 15] |
| for prefix in ["h", "a"]: |
| for kernel in _kernels: |
| for bw in _kernel_bw: |
| for stat in ["wp", "margin", "ortg", "drtg", "pace"]: |
| names.append(f"td2_{prefix}_{kernel}_{stat}_bw{bw}") |
| |
| for prefix in ["h", "a"]: |
| for stat in ["wp", "margin", "ortg", "drtg"]: |
| names.append(f"td2_{prefix}_regime_change_{stat}") |
| names.append(f"td2_{prefix}_regime_duration_{stat}") |
| names.append(f"td2_{prefix}_regime_level_{stat}") |
| names.append(f"td2_{prefix}_cusum_{stat}") |
| |
| for prefix in ["h", "a"]: |
| for pct in [10, 25, 50, 75, 90]: |
| for stat in ["margin", "ppg", "ortg"]: |
| names.append(f"td2_{prefix}_weighted_pctl{pct}_{stat}") |
| |
| for prefix in ["h", "a"]: |
| for stat in ["wp", "margin", "ortg", "drtg", "pace"]: |
| names.append(f"td2_{prefix}_adaptive_decay_{stat}") |
| names.append(f"td2_{prefix}_adaptive_halflife_{stat}") |
|
|
| |
| |
| |
| |
|
|
| |
| if self.include_market: |
| for stat in ["wp", "margin", "ortg"]: |
| for hl in [3, 10]: |
| names.append(f"xi_td_market_{stat}_hl{hl}_spread") |
| names.append(f"xi_td_market_{stat}_hl{hl}_total") |
| names.append(f"xi_td_market_{stat}_hl{hl}_ml") |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"xi_bayes_power_{prefix}_blend") |
| names.append(f"xi_bayes_power_{prefix}_divergence") |
| names.append(f"xi_bayes_elo_{prefix}_shrinkage") |
| names.append(f"xi_bayes_elo_{prefix}_confidence") |
|
|
| |
| names.extend([ |
| "xi_net_matchup_pagerank_diff", |
| "xi_net_matchup_centrality_weighted", |
| "xi_net_matchup_transitivity_score", |
| "xi_net_matchup_network_surprise", |
| ]) |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"xi_pi_fatigue_{prefix}_star_tired") |
| names.append(f"xi_pi_fatigue_{prefix}_bench_fresh") |
| names.append(f"xi_pi_fatigue_{prefix}_depth_advantage") |
| names.append(f"xi_pi_fatigue_{prefix}_load_management") |
|
|
| |
| names.extend([ |
| "xi_ref_venue_home_bias_compound", |
| "xi_ref_venue_altitude_foul_rate", |
| "xi_ref_venue_pace_interaction", |
| "xi_ref_venue_crowd_effect", |
| ]) |
|
|
| |
| for prefix in ["h", "a"]: |
| for stat in ["wp", "margin", "ortg"]: |
| names.append(f"xi_ts_xteam_{prefix}_{stat}_trend_matchup") |
| names.append(f"xi_ts_xteam_{prefix}_{stat}_momentum_clash") |
|
|
| |
| if self.include_market: |
| names.extend([ |
| "xi_meta_market_model_vs_line", |
| "xi_meta_market_confidence_vs_movement", |
| "xi_meta_market_agreement_vs_sharp", |
| "xi_meta_market_edge_vs_steam", |
| "xi_meta_market_calibration_vs_clv", |
| ]) |
|
|
| |
| names.extend([ |
| "grand_composite_edge", |
| "grand_model_market_network_blend", |
| "grand_fatigue_venue_weather_score", |
| "grand_player_matchup_referee_blend", |
| "grand_bayesian_temporal_blend", |
| "grand_cross_category_momentum", |
| "grand_multi_signal_agreement", |
| "grand_risk_adjusted_composite", |
| "grand_confidence_weighted_edge", |
| "grand_information_ratio", |
| ]) |
|
|
| |
| |
| |
|
|
| |
| _new_sq_features = [] |
| for prefix in ["h", "a"]: |
| _new_sq_features.extend([ |
| f"{prefix}_star1_plus_minus_10", |
| f"{prefix}_star1_usage_rate_10", |
| f"{prefix}_star_combined_plus_minus", |
| f"{prefix}_chemistry_starting5", |
| f"bayes2_{prefix}_rating_composite", |
| f"bayes2_{prefix}_coach_expected_wp", |
| f"net2_{prefix}_colley_rating", |
| f"net2_{prefix}_massey_rating", |
| ]) |
| for feat in _new_sq_features: |
| names.append(f"sq2_{feat}") |
|
|
| |
| _new_inter_pairs = [ |
| ("h_star1_plus_minus_10", "a_star1_plus_minus_10"), |
| ("h_star1_usage_rate_10", "a_star1_usage_rate_10"), |
| ("h_chemistry_starting5", "a_chemistry_starting5"), |
| ("pi_star1_rating_diff", "elo_diff"), |
| ("pi_combined_star_diff", "rest_advantage"), |
| ("pi_talent_depth_diff", "fatigue_composite_edge"), |
| ("xteam_overall_style_clash", "elo_diff"), |
| ("xteam_pace_war_indicator", "h_pace10"), |
| ("xteam_mismatch_severity", "current_spread"), |
| ("bayes_preseason_diff", "h_wp10"), |
| ("bayes_franchise_strength_diff", "elo_diff"), |
| ("bayes_coach_wp_diff", "rest_advantage"), |
| ("net_pagerank_diff_82", "elo_diff"), |
| ("net_pagerank_diff_20", "h_wp10"), |
| ("net_clustering_diff", "xteam_overall_style_clash"), |
| ("net_eigenvector_diff", "bayes_composite_diff"), |
| ("ref_home_bias_composite", "venue_home_elevation_advantage"), |
| ("ref_pace_impact_composite", "xteam_pace_war_indicator"), |
| ("env_combined_venue_advantage", "rest_advantage"), |
| ("env_combined_travel_disruption", "fatigue_composite_edge"), |
| ("grand_composite_edge", "elo_diff"), |
| ("grand_composite_edge", "current_spread"), |
| ("grand_multi_signal_agreement", "meta2_ensemble_mean_prob"), |
| ("grand_confidence_weighted_edge", "meta2_edge_confidence_product"), |
| ] |
| for x_feat, y_feat in _new_inter_pairs: |
| names.append(f"inter2_{x_feat}_{y_feat}") |
|
|
| |
| _new_ratio_pairs = [ |
| ("h_star1_plus_minus_10", "a_star1_plus_minus_10"), |
| ("h_chemistry_starting5", "a_chemistry_starting5"), |
| ("h_star_combined_plus_minus", "a_star_combined_plus_minus"), |
| ] |
| for x_feat, y_feat in _new_ratio_pairs: |
| names.append(f"ratio2_{x_feat}_{y_feat}") |
|
|
| |
| _triple_combos = [ |
| ("elo_diff", "rest_advantage", "pi_combined_star_diff"), |
| ("elo_diff", "xteam_mismatch_severity", "net_pagerank_diff_82"), |
| ("elo_diff", "bayes_composite_diff", "meta2_ensemble_mean_prob"), |
| ("current_spread", "pi_star1_rating_diff", "ref_home_bias_composite"), |
| ("h_wp10", "a_wp10", "xteam_overall_style_clash"), |
| ("h_ortg10", "a_drtg10", "xteam_offensive_edge_composite"), |
| ("rest_advantage", "env_combined_venue_advantage", "ref_pace_impact_composite"), |
| ("fatigue_composite_edge", "pi_talent_depth_diff", "bayes_roster_stability_diff"), |
| ] |
| for a, b, c in _triple_combos: |
| names.append(f"triple_{a}_{b}_{c}") |
|
|
| |
| |
| |
| |
|
|
| |
| _decay_roll_stats = ["wp", "margin", "ortg", "drtg"] |
| for prefix in ["h", "a"]: |
| for stat in _decay_roll_stats: |
| for hl in [3, 10]: |
| |
| names.append(f"roll_td_vol_{prefix}_{stat}_hl{hl}") |
| |
| names.append(f"roll_td_trend_{prefix}_{stat}_hl{hl}") |
| |
| names.append(f"roll_td_zscore_{prefix}_{stat}_hl{hl}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for w in [10, 20]: |
| names.append(f"roll_net_pagerank_change_{prefix}_{w}") |
| names.append(f"roll_net_centrality_change_{prefix}_{w}") |
| names.append(f"roll_net_quality_wins_trend_{prefix}_{w}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for w in [10, 20]: |
| names.append(f"roll_bayes_update_trend_{prefix}_{w}") |
| names.append(f"roll_bayes_confidence_trend_{prefix}_{w}") |
| names.append(f"roll_bayes_prior_divergence_trend_{prefix}_{w}") |
|
|
| |
| for stat in ["pace", "ortg", "drtg"]: |
| for w in [5, 10]: |
| names.append(f"roll_xteam_avg_mismatch_{stat}_{w}") |
| names.append(f"roll_xteam_mismatch_trend_{stat}_{w}") |
|
|
| |
| for prefix in ["h", "a"]: |
| names.append(f"cum_info_{prefix}_total_features_signal") |
| names.append(f"cum_info_{prefix}_positive_signals_pct") |
| names.append(f"cum_info_{prefix}_negative_signals_pct") |
| names.append(f"cum_info_{prefix}_neutral_signals_pct") |
| names.append(f"cum_info_{prefix}_signal_entropy") |
|
|
| |
| names.extend([ |
| "new_cats_home_advantage_composite", |
| "new_cats_away_advantage_composite", |
| "new_cats_edge_differential", |
| "new_cats_confidence_score", |
| "new_cats_information_value", |
| "new_cats_novelty_score", |
| "new_cats_alignment_with_market", |
| "new_cats_alignment_with_model", |
| "new_cats_contrarian_signal", |
| "new_cats_risk_score", |
| ]) |
|
|
| |
| |
| |
| |
|
|
| |
| _ext_stats = [ |
| "net_rating", "ast_to_tov", "efg_minus_opp_efg", |
| "pace_adj_margin", "sos_adj_margin", "opponent_efg", |
| "three_pt_rate_diff", "paint_rate_diff", "transition_rate", |
| "halfcourt_efficiency", |
| ] |
| for prefix in ["h", "a"]: |
| for stat in _ext_stats: |
| for w in WINDOWS: |
| names.append(f"ext_{prefix}_{stat}_{w}") |
|
|
| |
| _ext_ewma_stats = ["net_rating", "ast_to_tov", "efg_minus_opp_efg", |
| "pace_adj_margin", "three_pt_rate_diff"] |
| _ext_ewma_alphas = ["01", "02", "05", "08"] |
| for prefix in ["h", "a"]: |
| for stat in _ext_ewma_stats: |
| for alpha in _ext_ewma_alphas: |
| names.append(f"ext_ewma_{prefix}_{stat}_a{alpha}") |
|
|
| |
| _ext_vol_stats = ["net_rating", "ast_to_tov", "efg_minus_opp_efg", |
| "pace_adj_margin", "transition_rate"] |
| for prefix in ["h", "a"]: |
| for stat in _ext_vol_stats: |
| for w in [5, 10, 20]: |
| names.append(f"ext_vol_{prefix}_{stat}_{w}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for stat in _ext_stats: |
| names.append(f"ext_zscore_{prefix}_{stat}") |
|
|
| |
| _ext_trend_pairs = [(3, 10), (5, 20), (3, 20), (5, 10), (10, 20)] |
| for prefix in ["h", "a"]: |
| for stat in _ext_stats: |
| for w1, w2 in _ext_trend_pairs: |
| names.append(f"ext_trend_{prefix}_{stat}_w{w1}_w{w2}") |
|
|
| |
| |
| |
|
|
| _xw2_stats = ["net_rating", "ast_to_tov", "efg_minus_opp_efg", |
| "pace_adj_margin", "sos_adj_margin"] |
| _xw2_pairs = [ |
| (3, 5), (3, 10), (3, 20), (5, 10), (5, 20), |
| (7, 15), (7, 20), (10, 20), |
| ] |
| for prefix in ["h", "a"]: |
| for stat in _xw2_stats: |
| for w1, w2 in _xw2_pairs: |
| names.append(f"xw2_{prefix}_{stat}_{w1}vs{w2}") |
| names.append(f"xw2_accel_{prefix}_{stat}_{w1}vs{w2}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for stat in _xw2_stats: |
| names.append(f"xw2_{prefix}_{stat}_shortterm_trend") |
| names.append(f"xw2_{prefix}_{stat}_longterm_trend") |
| names.append(f"xw2_{prefix}_{stat}_volatility_trend") |
| names.append(f"xw2_{prefix}_{stat}_breakout_signal") |
| names.append(f"xw2_{prefix}_{stat}_decline_signal") |
|
|
| |
| |
| |
| |
|
|
| _core_features = ["h_wp10", "a_wp10", "elo_diff", "current_spread", |
| "h_netrtg10", "a_netrtg10", "rest_advantage", |
| "h_ortg10", "a_drtg10", "h_consistency"] |
| _new_key_features = [ |
| "pi_combined_star_diff", "pi_talent_depth_diff", |
| "xteam_overall_style_clash", "xteam_mismatch_severity", |
| "bayes_composite_diff", "net_pagerank_diff_82", |
| "ref_home_bias_composite", "env_combined_venue_advantage", |
| "meta2_ensemble_mean_prob", "grand_composite_edge", |
| ] |
| for core_f in _core_features: |
| for new_f in _new_key_features: |
| names.append(f"xi3_{core_f}_{new_f}") |
|
|
| |
| |
| |
| |
|
|
| _opp_tiers = ["elite", "good", "average", "bad", "terrible"] |
| _opp_stats = ["wp", "margin", "ortg", "drtg", "efg", "pace"] |
| for prefix in ["h", "a"]: |
| for tier in _opp_tiers: |
| for stat in _opp_stats: |
| names.append(f"opp_tier_{prefix}_{stat}_vs_{tier}") |
|
|
| |
| _ha_stats = ["wp", "margin", "ortg", "drtg", "pace", "efg"] |
| for prefix in ["h", "a"]: |
| for loc in ["home_only", "away_only"]: |
| for stat in _ha_stats: |
| for w in [5, 10, 20]: |
| names.append(f"ha_{prefix}_{loc}_{stat}_{w}") |
|
|
| |
| _dow_names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] |
| for prefix in ["h", "a"]: |
| for dow in _dow_names: |
| names.append(f"dow_{prefix}_wp_{dow}") |
| names.append(f"dow_{prefix}_margin_{dow}") |
|
|
| |
| _months = ["oct", "nov", "dec", "jan", "feb", "mar", "apr"] |
| for prefix in ["h", "a"]: |
| for month in _months: |
| names.append(f"month_{prefix}_wp_{month}") |
| names.append(f"month_{prefix}_margin_{month}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for pattern in ["ww", "wl", "lw", "ll"]: |
| names.append(f"pattern_{prefix}_{pattern}_next_wp") |
| for streak_len in [2, 3, 4, 5]: |
| names.append(f"pattern_{prefix}_win_streak_{streak_len}_next_wp") |
| names.append(f"pattern_{prefix}_loss_streak_{streak_len}_next_wp") |
|
|
| |
| _margin_buckets = ["blowout_win", "comfortable_win", "close_win", |
| "close_loss", "comfortable_loss", "blowout_loss"] |
| for prefix in ["h", "a"]: |
| for bucket in _margin_buckets: |
| names.append(f"bucket_{prefix}_{bucket}_pct") |
| names.append(f"bucket_{prefix}_{bucket}_next_wp") |
|
|
| |
| _quarters = ["q1", "q2", "q3", "q4"] |
| _q_stats = ["margin", "ortg", "drtg", "pace", "efg", "tov_rate", "ft_rate"] |
| for prefix in ["h", "a"]: |
| for q in _quarters: |
| for stat in _q_stats: |
| names.append(f"qdetail_{prefix}_{q}_{stat}") |
|
|
| |
| |
| _ewma36_stats = ["wp", "pd", "ppg", "papg", "margin", "close", "blowout", "ou_avg"] |
| _ewma36_alphas = ["005", "015", "025", "04", "07"] |
| for prefix in ["h", "a"]: |
| for stat in _ewma36_stats: |
| for alpha in _ewma36_alphas: |
| names.append(f"ewma36_{prefix}_{stat}_a{alpha}") |
|
|
| |
| for prefix in ["h", "a"]: |
| for stat in _ewma36_stats: |
| names.append(f"ewma36_{prefix}_{stat}_crossover") |
|
|
| |
| names.extend([ |
| "rest_x_h_wp5", "rest_x_a_wp5", |
| "b2b_x_h_margin5", "b2b_x_a_margin5", |
| "fatigue_x_h_ortg", "fatigue_x_a_ortg", |
| "rest_adv_x_wp_diff", |
| "b2b_diff_x_margin_diff", |
| "h_rest_sq", "a_rest_sq", |
| "rest_x_travel", |
| "dense_sched_x_margin", |
| ]) |
|
|
| |
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_movda_rating") |
| names.append(f"{prefix}_mov_surprise_ewm") |
| names.extend([ |
| "movda_diff", |
| "movda_win_prob", |
| ]) |
| |
| for prefix in ["h", "a"]: |
| names.append(f"{prefix}_delta_mov_raw") |
| names.append(f"{prefix}_delta_mov_rolling_5") |
| names.append(f"{prefix}_delta_mov_rolling_10") |
| names.append("delta_mov_diff") |
|
|
| |
| |
| |
| |
| for w in [5, 10, 20]: |
| names.append(f"venue_wp_edge_{w}") |
| names.append(f"venue_margin_edge_{w}") |
| names.append(f"venue_ortg_edge_{w}") |
| names.append(f"venue_drtg_edge_{w}") |
| names.extend([ |
| "venue_home_boost", |
| "venue_road_penalty", |
| ]) |
|
|
| |
| |
| |
| names.extend([ |
| "circ_h_travel_dist", |
| "circ_a_travel_dist", |
| "circ_h_tz_shift", |
| "circ_a_tz_shift", |
| "circ_h_fatigue_index", |
| "circ_a_fatigue_index", |
| "circ_advantage", |
| "circ_rest_nonlinear", |
| ]) |
|
|
| |
| |
| names.extend([ |
| "trans41_h_fb_rate", |
| "trans41_a_fb_rate", |
| "trans41_h_halfcourt_eff", |
| "trans41_a_halfcourt_eff", |
| "trans41_fb_rate_diff", |
| "trans41_pace_x_fb", |
| "trans41_halfcourt_edge", |
| ]) |
|
|
| |
| |
| names.extend([ |
| "clutch43_h_wp", |
| "clutch43_a_wp", |
| "clutch43_h_margin", |
| "clutch43_a_margin", |
| "clutch43_h_ortg", |
| "clutch43_a_ortg", |
| "clutch43_wp_diff", |
| "clutch43_margin_diff", |
| ]) |
|
|
| |
| |
| |
| |
| |
| names.extend([ |
| "tot44_h_ppg10", |
| "tot44_a_ppg10", |
| "tot44_h_papg10", |
| "tot44_a_papg10", |
| "tot44_matchup_total", |
| "tot44_pace_sum", |
| "tot44_pace_mismatch", |
| "tot44_ortg_sum", |
| "tot44_drtg_sum", |
| "tot44_score_env", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "shot42_h_rim_rate", |
| "shot42_a_rim_rate", |
| "shot42_h_mid_rate", |
| "shot42_a_mid_rate", |
| "shot42_h_three_rate", |
| "shot42_a_three_rate", |
| "shot42_h_xefg", |
| "shot42_a_xefg", |
| "shot42_rim_rate_diff", |
| "shot42_xefg_diff", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "track45_h_contested", |
| "track45_a_contested", |
| "track45_h_deflections", |
| "track45_a_deflections", |
| "track45_h_speed", |
| "track45_a_speed", |
| "track45_h_loose_balls", |
| "track45_a_loose_balls", |
| "track45_h_drives", |
| "track45_a_drives", |
| "track45_contested_diff", |
| "track45_speed_diff", |
| ]) |
|
|
| |
| |
| |
| |
| |
| |
| names.extend([ |
| "odds46_implied_home_prob", |
| "odds46_implied_away_prob", |
| "odds46_fair_home_prob", |
| "odds46_fair_away_prob", |
| "odds46_spread_home", |
| "odds46_total", |
| "odds46_overround", |
| "odds46_spread_implied_diff", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "drive47_h_fg_pct", |
| "drive47_a_fg_pct", |
| "drive47_h_tov_pct", |
| "drive47_a_tov_pct", |
| "drive47_h_pts_pct", |
| "drive47_a_pts_pct", |
| "drive47_h_def_rim_fg", |
| "drive47_a_def_rim_fg", |
| "drive47_h_blk_rate", |
| "drive47_a_blk_rate", |
| "drive47_h_off_vs_a_rim", |
| "drive47_a_off_vs_h_rim", |
| "drive47_rim_matchup_net", |
| "drive47_drive_volume_diff", |
| ]) |
|
|
| |
| |
| |
| names.extend([ |
| "pass48_h_ast_rate", |
| "pass48_a_ast_rate", |
| "pass48_h_potential_ast", |
| "pass48_a_potential_ast", |
| "pass48_h_ast_pts_created", |
| "pass48_a_ast_pts_created", |
| "pass48_h_secondary_ast", |
| "pass48_a_secondary_ast", |
| "pass48_ast_rate_diff", |
| "pass48_ball_movement_edge", |
| ]) |
|
|
| |
| |
| |
| names.extend([ |
| "play49_h_iso_ppp", |
| "play49_a_iso_ppp", |
| "play49_h_pnr_ppp", |
| "play49_a_pnr_ppp", |
| "play49_h_spot_ppp", |
| "play49_a_spot_ppp", |
| "play49_h_trans_ppp", |
| "play49_a_trans_ppp", |
| "play49_ppp_composite_diff", |
| "play49_versatility_diff", |
| ]) |
|
|
| |
| |
| |
| |
| |
| names.extend([ |
| "seq50_h_early_wp", |
| "seq50_h_late_wp", |
| "seq50_h_slope", |
| "seq50_h_margin_slope_norm", |
| "seq50_h_streak_norm", |
| "seq50_a_early_wp", |
| "seq50_a_late_wp", |
| "seq50_a_slope", |
| "seq50_a_margin_slope_norm", |
| "seq50_a_streak_norm", |
| "seq50_slope_diff", |
| "seq50_streak_diff", |
| ]) |
|
|
| |
| |
| |
| |
| |
| names.extend([ |
| "era51_h_ortg_vs_league", |
| "era51_h_drtg_vs_league", |
| "era51_a_ortg_vs_league", |
| "era51_a_drtg_vs_league", |
| "era51_h_pace_vs_league", |
| "era51_a_pace_vs_league", |
| "era51_h_netrtg_vs_league", |
| "era51_h_ortg_a_drtg_edge", |
| ]) |
|
|
| |
| |
| |
| |
| |
| names.extend([ |
| "line52_spread_magnitude", |
| "line52_total", |
| "line52_implied_home", |
| "line52_implied_away", |
| "line52_spread_agree", |
| "line52_vig", |
| "line52_spread_season_pct", |
| "line52_total_season_pct", |
| "line52_home_dog", |
| "line52_spread_adj", |
| "line52_ml_spread_gap", |
| "line52_sharpness", |
| "line52_season_spread_std", |
| "line52_season_total_trend", |
| "line52_home_fav_strength", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "ats53_h_last10", |
| "ats53_a_last10", |
| "ats53_h_season", |
| "ats53_a_season", |
| "ats53_h_streak", |
| "ats53_a_streak", |
| "ats53_h_as_fav", |
| "ats53_a_as_dog", |
| "ats53_h2h_last5", |
| "ats53_h_home_only", |
| "ats53_a_away_only", |
| "ats53_margin_vs_spread_10", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "ou54_h_over_rate10", |
| "ou54_a_over_rate10", |
| "ou54_h_over_season", |
| "ou54_a_over_season", |
| "ou54_h_streak", |
| "ou54_a_streak", |
| "ou54_combined_over_rate", |
| "ou54_pace_vs_total", |
| "ou54_h_home_over", |
| "ou54_a_away_over", |
| "ou54_total_trend", |
| "ou54_margin_vs_total_10", |
| ]) |
|
|
| |
| |
| |
| |
| |
| names.extend([ |
| "cmd55_fair_h_prob", |
| "cmd55_fair_a_prob", |
| "cmd55_ml_vs_spread_gap", |
| "cmd55_books_internal_disagree", |
| "cmd55_market_edge_h", |
| "cmd55_liquidity_proxy", |
| "cmd55_ml_confidence", |
| "cmd55_spread_confidence", |
| "cmd55_consensus_strength", |
| "cmd55_vig_distortion", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "circ56_h_signed_tz", |
| "circ56_a_signed_tz", |
| "circ56_h_westbound_adv", |
| "circ56_a_eastbound_pen", |
| "circ56_dir_mismatch", |
| "circ56_h_phase_alignment", |
| "circ56_a_phase_alignment", |
| "circ56_dir_edge", |
| "circ56_h_schedule_density", |
| "circ56_density_edge", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "seq57_h_exp_form5", |
| "seq57_a_exp_form5", |
| "seq57_h_exp_form10", |
| "seq57_a_exp_form10", |
| "seq57_h_form_var", |
| "seq57_a_form_var", |
| "seq57_h_hot_hand", |
| "seq57_a_hot_hand", |
| "seq57_h_momentum", |
| "seq57_a_momentum", |
| "seq57_form5_diff", |
| "seq57_momentum_diff", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "cal58_spread_confidence", |
| "cal58_total_confidence", |
| "cal58_b2b_uncertainty", |
| "cal58_h2h_consistency", |
| "cal58_h_home_reliability", |
| "cal58_model_market_alignment", |
| "cal58_h_home_wp_hist", |
| "cal58_a_road_wp_hist", |
| "cal58_compound_confidence", |
| "cal58_matchup_mkt_compound", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "opp59_h_opp_sos5", |
| "opp59_a_opp_sos5", |
| "opp59_common_opp_h_adv", |
| "opp59_common_opp_count", |
| "opp59_h_beat_quality", |
| "opp59_a_beat_quality", |
| "opp59_h_sos_trend", |
| "opp59_a_sos_trend", |
| "opp59_sched_asymmetry", |
| "opp59_h_opp_form", |
| "opp59_a_opp_form", |
| "opp59_graph_transitive", |
| ]) |
|
|
| |
| |
| |
| |
| names.extend([ |
| "mdist60_h_margin_mean", |
| "mdist60_a_margin_mean", |
| "mdist60_h_margin_skew", |
| "mdist60_a_margin_skew", |
| "mdist60_h_close_loss_rate", |
| "mdist60_a_close_loss_rate", |
| "mdist60_h_blowout_win_rate", |
| "mdist60_a_blowout_win_rate", |
| "mdist60_h_margin_iqr", |
| "mdist60_a_margin_iqr", |
| "mdist60_margin_shape_diff", |
| "mdist60_quality_gap", |
| ]) |
|
|
| |
| names.extend([ |
| "pace61_h_off_rtg", |
| "pace61_a_off_rtg", |
| "pace61_h_def_rtg", |
| "pace61_a_def_rtg", |
| "pace61_h_net_rtg", |
| "pace61_a_net_rtg", |
| "pace61_net_rtg_diff", |
| "pace61_h_pace", |
| "pace61_a_pace", |
| "pace61_pace_mismatch", |
| ]) |
|
|
| |
| names.extend([ |
| "clutch62_h_close_winpct", |
| "clutch62_a_close_winpct", |
| "clutch62_h_clutch_edge", |
| "clutch62_a_clutch_edge", |
| "clutch62_h_close_freq", |
| "clutch62_a_close_freq", |
| "clutch62_close_wr_diff", |
| "clutch62_clutch_edge_diff", |
| "clutch62_h_close_margin_avg", |
| "clutch62_a_close_margin_avg", |
| ]) |
|
|
| |
| |
| |
| names.extend([ |
| "pyth63_h_pyth_wp5", |
| "pyth63_a_pyth_wp5", |
| "pyth63_h_pyth_wp10", |
| "pyth63_a_pyth_wp10", |
| "pyth63_h_luck5", |
| "pyth63_a_luck5", |
| "pyth63_h_luck10", |
| "pyth63_a_luck10", |
| "pyth63_pyth_wp_diff", |
| "pyth63_luck_diff", |
| "pyth63_h_regression_signal", |
| "pyth63_a_regression_signal", |
| ]) |
|
|
| |
| |
| |
| names.extend([ |
| "elow64_h_wp10", |
| "elow64_a_wp10", |
| "elow64_h_margin10", |
| "elow64_a_margin10", |
| "elow64_h_netrtg10", |
| "elow64_a_netrtg10", |
| "elow64_wp_diff", |
| "elow64_margin_diff", |
| "elow64_h_trend", |
| "elow64_a_trend", |
| ]) |
|
|
| |
| |
| |
| names.extend([ |
| "style65_h_efg_vs_opp_def", |
| "style65_a_efg_vs_opp_def", |
| "style65_h_tov_vs_opp_force", |
| "style65_a_tov_vs_opp_force", |
| "style65_h_orb_vs_opp_drb", |
| "style65_a_orb_vs_opp_drb", |
| "style65_h_ftr_vs_opp_ftr", |
| "style65_a_ftr_vs_opp_ftr", |
| "style65_matchup_asymmetry", |
| "style65_h_composite_edge", |
| "style65_a_composite_edge", |
| "style65_net_style_edge", |
| ]) |
|
|
| |
| |
| |
| |
| |
| names.extend([ |
| "p100_66_h_pts", |
| "p100_66_a_pts", |
| "p100_66_diff_pts", |
| "p100_66_h_ast", |
| "p100_66_a_ast", |
| "p100_66_diff_ast", |
| "p100_66_h_tov", |
| "p100_66_a_tov", |
| "p100_66_diff_tov", |
| "p100_66_h_reb", |
| "p100_66_a_reb", |
| "p100_66_diff_reb", |
| ]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| names.extend([ |
| "yt_pol_mean_3", |
| "yt_pol_mean_7", |
| "yt_pol_mean_14", |
| "yt_abs_pol_mean_3", |
| "yt_abs_pol_mean_7", |
| "yt_abs_pol_mean_14", |
| ]) |
|
|
| self.feature_names = names |
|
|
| def build(self, games, market_data=None, referee_data=None, player_data=None, quarter_data=None, tracking_data=None, odds_data=None): |
| """ |
| Build feature matrix from historical games. |
| |
| Args: |
| games: List of game dicts with home/away teams, scores, stats |
| market_data: Optional dict of game_id → market features |
| tracking_data: Optional dict of team → {shot42_*, track45_*} from nba_api |
| odds_data: Optional dict from load_historical_odds() — (date, home, away) → odds entry. |
| If None, will attempt to auto-load from default CSV path. |
| |
| Returns: |
| X: numpy array (n_games, n_features) |
| y: numpy array (n_games,) — 1 if home win |
| feature_names: list of feature names |
| """ |
| |
| if odds_data is None: |
| odds_data = load_historical_odds() |
| |
| team_results = defaultdict(list) |
| team_last = {} |
| team_elo = defaultdict(lambda: 1500.0) |
| team_home_results = defaultdict(list) |
| team_away_results = defaultdict(list) |
| h2h_results = defaultdict(list) |
|
|
| |
| team_elo_margin = defaultdict(lambda: 1500.0) |
| team_elo_offense = defaultdict(lambda: 1500.0) |
| team_elo_defense = defaultdict(lambda: 1500.0) |
| team_elo_recency = defaultdict(lambda: 1500.0) |
| team_elo_history = defaultdict(list) |
| team_home_margin_sum = defaultdict(float) |
| team_home_games_count = defaultdict(int) |
| |
| team_movda = defaultdict(lambda: 1500.0) |
| mov_surprise_ewm = defaultdict(float) |
| delta_mov_history = defaultdict(list) |
| _MOVDA_K = 20.0; _MOVDA_C = 400.0; _MOVDA_LAMBDA = 0.3 |
| _MOVDA_ALPHA = 19.2511; _MOVDA_BETA = 0.002342 |
| _MOVDA_GAMMA = 648.0334; _MOVDA_DELTA = -645.8717 |
| _MOVDA_EWM_ALPHA = 0.3 |
| |
| |
| |
| |
| _era51_ortg = defaultdict(list) |
| _era51_drtg = defaultdict(list) |
| _era51_pace = defaultdict(list) |
| _era51_nrtg = defaultdict(list) |
|
|
| |
| |
| |
| _team_ats = defaultdict(list) |
| _team_ou = defaultdict(list) |
| _season_spreads = [] |
| _season_totals = [] |
| _cat52_season_id = None |
|
|
| def _era_season_id(date_str): |
| """Map game date → season start year (e.g. '2025' for 2025-26 season).""" |
| if not date_str or len(date_str) < 7: |
| return "unk" |
| try: |
| m = int(date_str[5:7]); y = int(date_str[:4]) |
| return str(y) if m >= 10 else str(y - 1) |
| except Exception: |
| return "unk" |
|
|
| def _era_zscore(val, vals): |
| """Z-score of val against vals, clamped to [-3, 3]. Returns 0 if <5 samples.""" |
| if len(vals) < 5: |
| return 0.0 |
| mu = sum(vals) / len(vals) |
| sigma = (sum((v - mu) ** 2 for v in vals) / len(vals)) ** 0.5 |
| return max(-3.0, min(3.0, (val - mu) / max(sigma, 0.1))) |
|
|
| |
| |
| _tg_next = defaultdict(list) |
| for _g in games: |
| if not isinstance(_g, dict): |
| continue |
| _h_raw = _g.get("home_team", "") or "" |
| _a_raw = _g.get("away_team", "") or "" |
| if "home" in _g and isinstance(_g.get("home"), dict): |
| _h_raw = _h_raw or _g["home"].get("team_name", "") |
| _a_raw = _a_raw or (_g.get("away") or {}).get("team_name", "") |
| _gh = resolve(_h_raw) |
| _ga = resolve(_a_raw) |
| _gd = (_g.get("game_date", _g.get("date", "")) or "")[:10] |
| if _gh and _gd: |
| _tg_next[_gh].append((_gd, True)) |
| if _ga and _gd: |
| _tg_next[_ga].append((_gd, False)) |
| _team_next_is_home = {} |
| for _t, _gl in _tg_next.items(): |
| _gl.sort(key=lambda x: x[0]) |
| for _i in range(len(_gl)): |
| _d = _gl[_i][0] |
| if _i + 1 < len(_gl): |
| _team_next_is_home[(_t, _d)] = 1.0 if _gl[_i + 1][1] else 0.0 |
| else: |
| _team_next_is_home[(_t, _d)] = 0.5 |
|
|
| X, y = [], [] |
| _y_margin, _y_total = [], [] |
| n_market = 32 if self.include_market else 0 |
|
|
| for game in games: |
| |
| if not isinstance(game, dict): |
| continue |
| |
| hr, ar = game.get("home_team", ""), game.get("away_team", "") |
| if "home" in game and isinstance(game["home"], dict): |
| h, a = game["home"], game.get("away", {}) |
| hs = h.get("pts") |
| as_ = a.get("pts") |
| if not hr: |
| hr = h.get("team_name", "") |
| if not ar: |
| ar = a.get("team_name", "") |
| h_stats = h |
| a_stats = a |
| else: |
| hs = game.get("home_score") |
| as_ = game.get("away_score") |
| h_stats = game.get("home_stats", {}) |
| a_stats = game.get("away_stats", {}) |
|
|
| if hs is None or as_ is None: |
| continue |
| hs, as_ = int(hs), int(as_) |
| home, away = resolve(hr), resolve(ar) |
| if not home or not away: |
| continue |
| gd = game.get("game_date", game.get("date", ""))[:10] |
|
|
| |
| |
| |
| |
| _sid_now = _era_season_id(gd) |
| if _cat52_season_id != _sid_now: |
| _season_spreads = [] |
| _season_totals = [] |
| _cat52_season_id = _sid_now |
|
|
| hr_ = team_results[home] |
| ar_ = team_results[away] |
|
|
| |
| if len(hr_) < 3 or len(ar_) < 3: |
| |
| self._record_game(team_results, team_last, team_elo, |
| team_home_results, team_away_results, |
| h2h_results, home, away, hs, as_, gd, |
| h_stats, a_stats) |
| |
| self._update_multi_elo( |
| home, away, hs, as_, h_stats, a_stats, |
| team_elo_margin, team_elo_offense, team_elo_defense, |
| team_elo_recency, team_elo_history, |
| team_home_margin_sum, team_home_games_count) |
| |
| self._update_movda(home, away, hs, as_, team_movda, mov_surprise_ewm, |
| delta_mov_history, |
| _MOVDA_K, _MOVDA_C, _MOVDA_LAMBDA, _MOVDA_ALPHA, |
| _MOVDA_BETA, _MOVDA_GAMMA, _MOVDA_DELTA, _MOVDA_EWM_ALPHA) |
| continue |
|
|
| |
| row = [] |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for w in WINDOWS: |
| row.append(self._wp(tr, w)) |
| row.append(self._pd(tr, w)) |
| row.append(self._ppg(tr, w)) |
| row.append(self._papg(tr, w)) |
| row.append(self._avg_margin(tr, w)) |
| row.append(self._close_pct(tr, w)) |
| row.append(self._blowout_pct(tr, w)) |
| row.append(self._ou_avg(tr, w)) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for w in [5, 10]: |
| row.append(self._efg(tr, w)) |
| row.append(self._tov_rate(tr, w)) |
| row.append(self._orb_rate(tr, w)) |
| row.append(self._ft_rate(tr, w)) |
| row.append(self._opp_efg(tr, w)) |
| row.append(self._opp_tov_rate(tr, w)) |
| row.append(self._opp_orb_rate(tr, w)) |
| row.append(self._opp_ft_rate(tr, w)) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for w in [5, 10]: |
| row.append(self._ortg(tr, w)) |
| row.append(self._drtg(tr, w)) |
| row.append(self._netrtg(tr, w)) |
| row.append(self._pace(tr, w)) |
| row.append(self._ts(tr, w)) |
| row.append(self._avg_poss(tr, w)) |
| row.append(self._ast_rate(tr, w)) |
| row.append(self._stl_rate(tr, w)) |
| row.append(self._blk_rate(tr, w)) |
| row.append(self._tov_pct(tr, w)) |
| row.append(self._oreb_pct(tr, w)) |
| row.append(self._dreb_pct(tr, w)) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for w in [5, 10]: |
| row.append(self._stat_avg(tr, w, "3par")) |
| row.append(self._stat_avg(tr, w, "fg3_pct")) |
| row.append(self._stat_avg(tr, w, "fg2_pct")) |
| row.append(self._stat_avg(tr, w, "ft_pct")) |
| row.append(self._stat_avg(tr, w, "paint_pts")) |
| row.append(self._stat_avg(tr, w, "fb_pts")) |
| row.append(self._stat_avg(tr, w, "bench_pts")) |
| row.append(self._stat_avg(tr, w, "2nd_pts")) |
| row.append(self._stat_avg(tr, w, "pitp")) |
| row.append(self._stat_avg(tr, w, "pts_off_tov")) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| row.append(self._streak(tr)) |
| row.append(abs(self._streak(tr))) |
| row.append(self._wp(tr, 5) - self._wp(tr, 82)) |
| row.append(self._wp(tr, 3) - self._wp(tr, 10)) |
| h_rec = team_home_results if prefix == "h" else team_away_results |
| t_key = home if prefix == "h" else away |
| row.append(self._wp(h_rec.get(t_key, []), 82)) |
| row.append(self._wp(team_away_results.get(t_key, []), 82)) |
| row.append(self._wp(team_home_results.get(t_key, []), 82) - |
| self._wp(team_away_results.get(t_key, []), 82)) |
| row.append(self._ats_wp(tr, 5)) |
| row.append(self._ou_record(tr, 5)) |
| row.append(self._ppg(tr, 5) - self._ppg(tr, 20)) |
| row.append(self._papg(tr, 5) - self._papg(tr, 20)) |
| row.append(self._clutch_wp(tr)) |
| row.append(self._blowout_pct(tr, 82)) |
| row.append(self._comeback_rate(tr)) |
| row.append(self._consistency(tr, 10)) |
| row.append(self._consistency(tr, 5)) |
|
|
| |
| h_rest = self._rest_days(home, gd, team_last) |
| a_rest = self._rest_days(away, gd, team_last) |
| row.extend([ |
| min(h_rest, 7), min(a_rest, 7), |
| h_rest - a_rest, |
| 1.0 if h_rest <= 1 else 0.0, |
| 1.0 if a_rest <= 1 else 0.0, |
| self._n_in_m(hr_, gd, 3, 4), |
| self._n_in_m(ar_, gd, 3, 4), |
| self._n_in_m(hr_, gd, 4, 6), |
| self._n_in_m(ar_, gd, 4, 6), |
| self._travel_dist(hr_, home), |
| self._travel_dist(ar_, away), |
| self._travel_dist(hr_, home) - self._travel_dist(ar_, away), |
| ARENA_ALTITUDE.get(home, 500), |
| ARENA_ALTITUDE.get(away, 500), |
| ARENA_ALTITUDE.get(home, 500) - ARENA_ALTITUDE.get(away, 500), |
| abs(TIMEZONE_ET.get(home, 0) - TIMEZONE_ET.get(self._last_location(hr_), 0)), |
| abs(TIMEZONE_ET.get(away, 0) - TIMEZONE_ET.get(self._last_location(ar_), 0)), |
| (abs(TIMEZONE_ET.get(home, 0) - TIMEZONE_ET.get(self._last_location(hr_), 0)) - |
| abs(TIMEZONE_ET.get(away, 0) - TIMEZONE_ET.get(self._last_location(ar_), 0))), |
| self._games_in_window(hr_, gd, 7), |
| self._games_in_window(ar_, gd, 7), |
| self._miles_in_window(hr_, gd, 7, home), |
| self._miles_in_window(ar_, gd, 7, away), |
| self._games_in_window(hr_, gd, 7) - self._games_in_window(ar_, gd, 7), |
| self._fatigue_score(hr_, gd, home, h_rest) - self._fatigue_score(ar_, gd, away, a_rest), |
| _team_next_is_home.get((home, gd), 0.5), |
| _team_next_is_home.get((away, gd), 0.5), |
| ]) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| row.append(self._sos(tr, team_results, 5)) |
| row.append(self._sos(tr, team_results, 10)) |
| row.append(self._sos(tr, team_results, 82)) |
| row.append(self._wp_vs_quality(tr, team_results, above=True)) |
| row.append(self._wp_vs_quality(tr, team_results, above=False)) |
| row.append(self._wp_vs_topN(tr, team_results, 10, top=True)) |
| row.append(self._wp_vs_topN(tr, team_results, 10, top=False)) |
| row.append(self._pd_vs_topN(tr, team_results, 10, top=True)) |
| row.append(self._pd_vs_topN(tr, team_results, 10, top=False)) |
| row.append(self._avg_opp_stat(tr, team_results, "ortg", 10)) |
| row.append(self._avg_opp_stat(tr, team_results, "drtg", 10)) |
| row.append(self._margin_vs_quality_corr(tr, team_results)) |
|
|
| |
| h2h = h2h_results.get((home, away), []) + h2h_results.get((away, home), []) |
| row.extend([ |
| self._h2h_wp(h2h, home), |
| self._h2h_wp(h2h[-3:], home) if len(h2h) >= 3 else 0.5, |
| self._h2h_margin(h2h, home), |
| self._h2h_home_wp(h2h), |
| self._pace(hr_, 10) - self._pace(ar_, 10), |
| self._ortg(hr_, 10) - self._drtg(ar_, 10), |
| self._stat_avg(hr_, 10, "fg3_pct") - self._stat_avg(ar_, 10, "opp_fg3_pct"), |
| self._stat_avg(hr_, 10, "paint_pts") - self._stat_avg(ar_, 10, "opp_paint_pts"), |
| abs(self._pace(hr_, 10) - self._pace(ar_, 10)), |
| self._drtg(hr_, 10) - self._drtg(ar_, 10), |
| self._oreb_pct(hr_, 10) - self._oreb_pct(ar_, 10), |
| self._tov_rate(hr_, 10) - self._tov_rate(ar_, 10), |
| self._ft_rate(hr_, 10) - self._ft_rate(ar_, 10), |
| self._stat_avg(hr_, 10, "bench_pts") - self._stat_avg(ar_, 10, "bench_pts"), |
| self._consistency(hr_, 10) - self._consistency(ar_, 10), |
| self._netrtg(hr_, 10) * (1 - 0.05 * max(0, self._games_in_window(hr_, gd, 7) - 3)), |
| team_elo[home], |
| team_elo[away], |
| team_elo[home] - team_elo[away] + 50, |
| (team_elo[home] - 1500) - self._elo_10_ago(hr_, team_elo, home), |
| ]) |
|
|
| |
| if self.include_market: |
| mkt = (market_data or {}).get(game.get("id", gd), {}) |
| row.extend([ |
| mkt.get("opening_spread", 0), |
| mkt.get("current_spread", 0), |
| mkt.get("current_spread", 0) - mkt.get("opening_spread", 0), |
| abs(mkt.get("current_spread", 0) - mkt.get("opening_spread", 0)), |
| mkt.get("reverse_line_movement", 0), |
| mkt.get("opening_total", 220), |
| mkt.get("current_total", 220), |
| mkt.get("current_total", 220) - mkt.get("opening_total", 220), |
| mkt.get("opening_ml_home", -110), |
| mkt.get("current_ml_home", -110), |
| mkt.get("current_ml_home", -110) - mkt.get("opening_ml_home", -110), |
| mkt.get("implied_prob_home", 0.5), |
| mkt.get("implied_prob_away", 0.5), |
| 0, |
| 0, |
| mkt.get("books_disagreement", 0.05), |
| mkt.get("sharp_line", 0.5), |
| mkt.get("public_pct_home", 0.5), |
| mkt.get("public_money_pct_home", 0.5), |
| mkt.get("smart_money_indicator", 0), |
| mkt.get("steam_move", 0), |
| mkt.get("clv_recent_avg", 0), |
| mkt.get("market_efficiency", 0), |
| mkt.get("opening_overround", 1.05), |
| mkt.get("best_odds_home", 1.9), |
| mkt.get("best_odds_away", 1.9), |
| mkt.get("odds_range_home", 0.1), |
| mkt.get("odds_range_away", 0.1), |
| mkt.get("time_to_close", 24), |
| mkt.get("late_money_direction", 0), |
| mkt.get("closing_line_estimate", 0.5), |
| mkt.get("historical_clv", 0), |
| 1 if mkt.get("best_odds_home", 1.9) > 5.0 or mkt.get("best_odds_away", 1.9) > 5.0 else 0, |
| ]) |
|
|
| |
| try: |
| dt = datetime.strptime(gd, "%Y-%m-%d") |
| month = dt.month |
| dow = dt.weekday() |
| except (ValueError, TypeError, AttributeError): |
| month = 1 |
| dow = 2 |
| dt = None |
|
|
| sp = max(0, min(1, (month - 10) / 7)) if month >= 10 else max(0, min(1, (month + 2) / 7)) |
| row.extend([ |
| 1.0, |
| sp, |
| math.sin(2 * math.pi * month / 12), |
| math.cos(2 * math.pi * month / 12), |
| dow / 6.0, |
| 1.0 if dow >= 5 else 0.0, |
| 0, |
| min(len(hr_), 82) / 82.0, |
| min(len(ar_), 82) / 82.0, |
| min(len(hr_), 82) / 82.0, |
| min(len(ar_), 82) / 82.0, |
| 1.0 if self._wp(hr_, 82) > 0.5 and self._wp(ar_, 82) > 0.5 else 0.0, |
| 1.0 if self._wp(hr_, 82) < 0.3 or self._wp(ar_, 82) < 0.3 else 0.0, |
| 1.0 if self._is_rivalry(home, away) else 0.0, |
| self._division(home), |
| self._division(away), |
| 1.0 if self._division(home) == self._division(away) else 0.0, |
| 1.0 if self._conference(home) == self._conference(away) else 0.0, |
| 1.0 if self._conference(home) != self._conference(away) else 0.0, |
| self._wp(hr_, 82) - self._wp(ar_, 82), |
| 1.0 if self._wp(hr_, 82) > self._wp(ar_, 82) else 0.0, |
| self._wp(hr_, 82) + self._wp(ar_, 82), |
| 0.5, |
| self._ppg(hr_, 10) + self._ppg(ar_, 10), |
| ]) |
|
|
| |
| if not self.skip_placeholder: |
| |
| ref = (referee_data or {}).get(game.get("id", gd), {}) |
| row.extend([ |
| ref.get("home_foul_bias", 0.0), |
| ref.get("total_fouls_avg", 42.0), |
| ref.get("foul_rate_vs_league", 1.0), |
| ref.get("home_ft_advantage", 0.0), |
| ref.get("experience_games", 40) / 82.0, |
| ref.get("over_tendency", 0.5), |
| ref.get("close_game_bias", 0.5), |
| ref.get("tech_foul_rate", 0.3), |
| ref.get("home_win_rate", 0.58), |
| ref.get("pace_impact", 0.0), |
| ]) |
|
|
| |
| for prefix, team_key in [("h", home), ("a", away)]: |
| pd_ = (player_data or {}).get(team_key, {}) |
| row.append(pd_.get("star_usage_rate", 0.55)) |
| row.append(pd_.get("star_minutes_load", 34.0) / 48.0) |
| row.append(pd_.get("injury_impact_score", 0.0)) |
| row.append(pd_.get("injured_war_lost", 0.0)) |
| row.append(pd_.get("lineup_continuity", 0.8)) |
| row.append(pd_.get("bench_depth_rating", 0.0) / 10.0) |
| rest = self._rest_days(team_key, gd, team_last) |
| row.append(1.0 if rest <= 1 else (0.5 if rest <= 2 else 0.0)) |
| row.append(pd_.get("rotation_depth", 8) / 15.0) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| qd_ = (quarter_data or {}).get(home if prefix == "h" else away, {}) |
| row.append(qd_.get("q1_margin_avg", 0.0)) |
| row.append(qd_.get("q3_margin_avg", 0.0)) |
| row.append(qd_.get("q4_clutch_netrtg", 0.0) / 10.0) |
| row.append(qd_.get("half_adjustment", 0.0)) |
| row.append(qd_.get("comeback_win_pct", 0.3)) |
| row.append(qd_.get("blowout_hold_pct", 0.7)) |
| row.append(qd_.get("garbage_time_margin", 0.0)) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| row.append(self._stat_avg(tr, 10, "opp_paint_pts") / 50.0) |
| row.append(self._stat_avg(tr, 10, "opp_fg3_pct")) |
| row.append(self._stat_avg(tr, 10, "fb_pts") / 20.0) |
| row.append(min(1.0, self._blk_rate(tr, 10) * 5)) |
| row.append(self._stat_avg(tr, 10, "stl_rate") * 5) |
| row.append(self._opp_efg(tr, 10) or 0.5) |
|
|
| |
| pmkt = (market_data or {}).get(game.get("id", gd), {}) |
| |
| |
| _pm_book = (odds_data or {}).get((gd, home, away), {}) |
| _pm_impl = _pm_book.get("implied_home_prob", 0.5) if _pm_book else 0.5 |
| _pm_fair = _pm_book.get("fair_home_prob", 0.5) if _pm_book else 0.5 |
| |
| |
| |
| |
| _bd = _pm_book.get("books_disagreement", None) |
| _mwc_fallback = max(0.0, 1.0 - min(1.0, _bd * 10.0)) if _bd is not None else 0.5 |
| row.extend([ |
| pmkt.get("polymarket_home_prob", _pm_impl), |
| pmkt.get("polymarket_volume", 0.5), |
| pmkt.get("polymarket_line_movement", 0.0), |
| pmkt.get("polymarket_vs_books", 0.0), |
| pmkt.get("prediction_market_consensus", _pm_fair), |
| pmkt.get("market_wisdom_confidence", _mwc_fallback), |
| pmkt.get("smart_vs_public_divergence", 0.0), |
| pmkt.get("closing_line_value_history", 0.0), |
| ]) |
|
|
| |
| |
| _cat15_end = len(row) |
| _name_idx = {} |
| for _i, _n in enumerate(self.feature_names): |
| if _i < _cat15_end: |
| _name_idx[_n] = _i |
|
|
| def _val(name): |
| """Fast lookup of already-computed feature value by name.""" |
| idx = _name_idx.get(name) |
| if idx is not None: |
| return row[idx] |
| return 0.0 |
|
|
| |
| |
| inter_pairs = [ |
| ("h_wp10", "a_wp10"), ("h_wp5", "a_wp5"), ("h_wp3", "a_wp3"), |
| ("h_ortg10", "a_drtg10"), ("h_ortg5", "a_drtg5"), |
| ("h_drtg10", "a_ortg10"), ("h_drtg5", "a_ortg5"), |
| ("h_netrtg10", "a_netrtg10"), ("h_netrtg5", "a_netrtg5"), |
| ("h_pace10", "a_pace10"), ("h_pace5", "a_pace5"), |
| ("h_ppg10", "a_ppg10"), ("h_ppg5", "a_ppg5"), |
| ("h_margin10", "a_margin10"), ("h_margin5", "a_margin5"), |
| ("h_efg10", "a_efg10"), ("h_efg5", "a_efg5"), |
| ("h_ts10", "a_ts10"), ("h_ts5", "a_ts5"), |
| ("h_tov_rate10", "a_tov_rate10"), ("h_tov_rate5", "a_tov_rate5"), |
| ("h_orb_rate10", "a_orb_rate10"), ("h_orb_rate5", "a_orb_rate5"), |
| ("h_3p_pct10", "a_3p_pct10"), ("h_3p_pct5", "a_3p_pct5"), |
| ("h_ft_rate10", "a_ft_rate10"), ("h_ft_rate5", "a_ft_rate5"), |
| ("h_pd10", "a_pd10"), ("h_pd5", "a_pd5"), |
| ("h_blowout10", "a_blowout10"), ("h_close10", "a_close10"), |
| ("h_wp10", "elo_diff"), ("a_wp10", "elo_diff"), |
| ("h_ortg10", "elo_diff"), ("h_streak", "a_streak"), |
| ("h_wp10", "h_rest_days"), ("a_wp10", "a_rest_days"), |
| ("h_netrtg10", "rest_advantage"), ("h_ortg10", "h_pace10"), |
| ("a_ortg10", "a_pace10"), ("h_drtg10", "a_3p_pct10"), |
| ("h_wp10", "h_sos10"), ("a_wp10", "a_sos10"), |
| ("h_efg10", "h_pace10"), ("a_efg10", "a_pace10"), |
| ("h_margin10", "h_consistency"), ("a_margin10", "a_consistency"), |
| ("h_ppg10", "a_papg10"), ("a_ppg10", "h_papg10"), |
| ("h_bench_pts10", "a_bench_pts10"), |
| ("h_fb_pts10", "a_fb_pts10"), |
| ("h_opp_efg10", "a_efg10"), ("a_opp_efg10", "h_efg10"), |
| ("h_wp20", "a_wp20"), ("h_wp15", "a_wp15"), |
| ("h_ortg10", "h_efg10"), ("a_ortg10", "a_efg10"), |
| ("h_drtg10", "h_opp_efg10"), ("a_drtg10", "a_opp_efg10"), |
| ("h_pace10", "h_3p_pct10"), ("a_pace10", "a_3p_pct10"), |
| ("h_ast_rate10", "a_tov_pct10"), ("a_ast_rate10", "h_tov_pct10"), |
| ("h_stl_rate10", "a_tov_pct10"), ("a_stl_rate10", "h_tov_pct10"), |
| ("h_blk_rate10", "a_paint_pts10"), ("a_blk_rate10", "h_paint_pts10"), |
| ("h_oreb_pct10", "a_dreb_pct10"), ("a_oreb_pct10", "h_dreb_pct10"), |
| ("h_3par10", "a_perimeter_defense"), ("a_3par10", "h_perimeter_defense"), |
| ("h_wp10", "a_consistency"), ("a_wp10", "h_consistency"), |
| ("h_margin10", "elo_diff"), ("a_margin10", "elo_diff"), |
| ("h_ppg10", "h_pace10"), ("a_ppg10", "a_pace10"), |
| ("h_papg10", "h_drtg10"), ("a_papg10", "a_drtg10"), |
| ("h_wp10", "h_home_wp"), ("a_wp10", "a_away_wp"), |
| ("h_streak", "h_wp10"), ("a_streak", "a_wp10"), |
| ("h_clutch_wp", "a_clutch_wp"), ("h_comeback_rate", "a_comeback_rate"), |
| ("h_scoring_trend", "a_defense_trend"), |
| ("a_scoring_trend", "h_defense_trend"), |
| ("h_ou_avg10", "a_ou_avg10"), |
| ("h_netrtg10", "h_consistency"), ("a_netrtg10", "a_consistency"), |
| ("h_efg10", "a_opp_efg10"), ("a_efg10", "h_opp_efg10"), |
| ("h_ts10", "h_3p_pct10"), ("a_ts10", "a_3p_pct10"), |
| ("h_ft_rate10", "h_ft_pct10"), ("a_ft_rate10", "a_ft_pct10"), |
| ("h_pts_off_tov10", "a_tov_rate10"), ("a_pts_off_tov10", "h_tov_rate10"), |
| ("h_2nd_pts10", "h_oreb_pct10"), ("a_2nd_pts10", "a_oreb_pct10"), |
| ("h_wp10", "season_phase"), ("a_wp10", "season_phase"), |
| ("elo_diff", "rest_advantage"), ("elo_diff", "travel_advantage"), |
| ("current_spread", "h_wp10"), ("current_spread", "a_wp10"), |
| ("current_spread", "elo_diff"), ("h_sos10", "a_sos10"), |
| ("h_wp_vs_above500", "a_wp_vs_above500"), |
| ("h_wp_vs_top10", "a_wp_vs_top10"), |
| ] |
| for x_name, y_name in inter_pairs: |
| row.append(_val(x_name) * _val(y_name)) |
|
|
| |
| ratio_pairs = [ |
| ("h_ortg10", "a_drtg10"), ("h_ortg5", "a_drtg5"), |
| ("a_ortg10", "h_drtg10"), ("a_ortg5", "h_drtg5"), |
| ("h_pace10", "a_pace10"), ("h_pace5", "a_pace5"), |
| ("h_efg10", "a_efg10"), ("h_efg5", "a_efg5"), |
| ("h_ts10", "a_ts10"), ("h_ts5", "a_ts5"), |
| ("h_ppg10", "a_ppg10"), ("h_ppg5", "a_ppg5"), |
| ("h_margin10", "a_margin10"), ("h_margin5", "a_margin5"), |
| ("h_wp10", "a_wp10"), ("h_wp5", "a_wp5"), |
| ("h_3p_pct10", "a_3p_pct10"), ("h_3p_pct5", "a_3p_pct5"), |
| ("h_ft_rate10", "a_ft_rate10"), ("h_ft_rate5", "a_ft_rate5"), |
| ("h_orb_rate10", "a_orb_rate10"), ("h_orb_rate5", "a_orb_rate5"), |
| ("h_tov_rate10", "a_tov_rate10"), ("h_tov_rate5", "a_tov_rate5"), |
| ("h_bench_pts10", "a_bench_pts10"), ("h_bench_pts5", "a_bench_pts5"), |
| ("h_papg10", "a_papg10"), ("h_papg5", "a_papg5"), |
| ("h_opp_efg10", "a_opp_efg10"), ("h_opp_efg5", "a_opp_efg5"), |
| ] |
| for x_name, y_name in ratio_pairs: |
| denom = _val(y_name) |
| row.append(_val(x_name) / (denom + 0.001) if abs(denom) > 0.0001 else 1.0) |
|
|
| |
| sq_features = [ |
| "h_wp10", "a_wp10", "h_wp5", "a_wp5", |
| "h_ortg10", "a_ortg10", "h_drtg10", "a_drtg10", |
| "h_netrtg10", "a_netrtg10", "h_margin10", "a_margin10", |
| "elo_diff", "spread_movement", "current_spread", |
| "h_pace10", "a_pace10", "h_efg10", "a_efg10", |
| "h_ppg10", "a_ppg10", "h_ts10", "a_ts10", |
| "rest_advantage", "travel_advantage", |
| "h_streak", "a_streak", "h_sos10", "a_sos10", |
| "h_consistency", "a_consistency", |
| ] |
| for feat in sq_features: |
| v = _val(feat) |
| row.append(v * v) |
|
|
| |
| trend_stats = ["wp", "ppg", "margin", "ortg", "drtg", |
| "efg", "ts", "pace", "pd", "papg"] |
| trend_window_pairs = [ |
| (3, 7), (3, 10), (3, 15), (3, 20), |
| (5, 10), (5, 15), (5, 20), |
| (7, 15), (7, 20), (10, 20), |
| ] |
| for prefix in ["h", "a"]: |
| for stat in trend_stats: |
| for w1, w2 in trend_window_pairs: |
| row.append(_val(f"{prefix}_{stat}{w1}") - _val(f"{prefix}_{stat}{w2}")) |
|
|
| |
| |
| |
| |
|
|
| |
| _STAT_KEY_17 = { |
| "ppg": lambda r: r[4].get("pts", 100), |
| "margin": lambda r: r[2], |
| "ortg": lambda r: r[4].get("ortg", 100), |
| "drtg": lambda r: r[4].get("drtg", 100), |
| "efg": lambda r: r[4].get("efg_pct", 0.5), |
| "ts": lambda r: r[4].get("ts_pct", 0.5), |
| "pace": lambda r: r[4].get("pace", 100), |
| "papg": lambda r: r[4].get("opp_pts", 100), |
| "3p_pct": lambda r: r[4].get("fg3_pct", 0.36), |
| "ft_rate": lambda r: r[4].get("ft_rate", 0.25), |
| } |
|
|
| def _extract(tr, stat, n): |
| """Extract last n values for a stat from team records.""" |
| s = tr[-n:] if n <= len(tr) else tr |
| fn = _STAT_KEY_17.get(stat) |
| return [fn(r) for r in s] if fn and s else [] |
|
|
| def _ewma_val(values, alpha): |
| """EWMA via manual recurrence — no pandas dependency.""" |
| if not values: |
| return 0.0 |
| result = values[0] |
| for v in values[1:]: |
| result = alpha * v + (1.0 - alpha) * result |
| return result |
|
|
| def _std_17(values): |
| """Sample standard deviation.""" |
| n_v = len(values) |
| if n_v < 2: |
| return 0.0 |
| m = sum(values) / n_v |
| return math.sqrt(sum((v - m) ** 2 for v in values) / (n_v - 1)) |
|
|
| _ALPHA_MAP_17 = {"01": 0.1, "03": 0.3, "05": 0.5} |
|
|
| |
| _tc = {} |
| for _pfx, _tr in [("h", hr_), ("a", ar_)]: |
| _tc[_pfx] = {} |
| for _st in ["ppg", "margin", "ortg", "drtg", "efg", "ts", "pace", |
| "papg", "3p_pct", "ft_rate"]: |
| _tc[_pfx][_st] = {} |
| for _w in [5, 10, 20, 82]: |
| _tc[_pfx][_st][_w] = _extract(_tr, _st, _w) |
|
|
| for _pfx in ["h", "a"]: |
| _c = _tc[_pfx] |
|
|
| |
| for _st in ["ppg", "margin", "ortg", "drtg", "efg", "ts", "pace"]: |
| _v20 = _c[_st][20] |
| for _ak in ["01", "03", "05"]: |
| row.append(_ewma_val(_v20, _ALPHA_MAP_17[_ak])) |
|
|
| |
| for _st in ["margin", "ppg", "papg", "ortg", "drtg", "pace"]: |
| for _w in [5, 10, 20]: |
| row.append(_std_17(_c[_st][_w])) |
|
|
| |
| for _st in ["ppg", "papg", "margin", "ortg"]: |
| for _w in [5, 10]: |
| _vals = _c[_st][_w] |
| if _vals: |
| row.append(min(_vals)) |
| row.append(max(_vals)) |
| else: |
| row.extend([0.0, 0.0]) |
|
|
| |
| for _st in ["ppg", "papg", "margin", "ortg", "drtg", |
| "efg", "ts", "pace", "3p_pct", "ft_rate"]: |
| _season = _c[_st][82] |
| _recent = _c[_st][5] |
| if len(_season) >= 5 and _recent: |
| _s_mean = sum(_season) / len(_season) |
| _s_var = sum((v - _s_mean) ** 2 for v in _season) / len(_season) |
| _s_std = math.sqrt(_s_var) if _s_var > 0 else 1e-6 |
| _r_mean = sum(_recent) / len(_recent) |
| row.append((_r_mean - _s_mean) / _s_std) |
| else: |
| row.append(0.0) |
|
|
| |
| for _st in ["margin", "ppg", "ortg", "drtg"]: |
| _vals = _c[_st][10] |
| if len(_vals) >= 3: |
| _nv = len(_vals) |
| _m = sum(_vals) / _nv |
| _m2 = sum((v - _m) ** 2 for v in _vals) / _nv |
| _sd = math.sqrt(_m2) if _m2 > 0 else 1e-6 |
| _m3 = sum((v - _m) ** 3 for v in _vals) / _nv |
| _m4 = sum((v - _m) ** 4 for v in _vals) / _nv |
| row.append(_m3 / (_sd ** 3) if _sd > 1e-9 else 0.0) |
| row.append((_m4 / (_sd ** 4)) - 3.0 if _sd > 1e-9 else 0.0) |
| else: |
| row.extend([0.0, 0.0]) |
|
|
| |
| for _st in ["ppg", "margin", "ortg", "drtg"]: |
| for _w in [5, 10]: |
| _vals = _c[_st][_w] |
| row.append((max(_vals) - min(_vals)) if _vals else 0.0) |
|
|
| |
| for _st in ["ppg", "margin", "ortg"]: |
| _vals = _c[_st][10] |
| if len(_vals) >= 2: |
| _m = sum(_vals) / len(_vals) |
| _sd = _std_17(_vals) |
| row.append(_sd / abs(_m) if abs(_m) > 1e-6 else 0.0) |
| else: |
| row.append(0.0) |
|
|
| |
| if not hasattr(self, '_cat_bounds'): |
| self._cat_bounds = {} |
| for _i, _n in enumerate(self.feature_names): |
| if _n == "h_pyth_wp" and 18 not in self._cat_bounds: |
| self._cat_bounds[18] = _i |
| if _n == "h_starting5_netrtg" and 19 not in self._cat_bounds: |
| self._cat_bounds[19] = _i |
| if _n == "h_elo_standard" and 24 not in self._cat_bounds: |
| self._cat_bounds[24] = _i |
| if _n == "h_cumul_games_played" and 25 not in self._cat_bounds: |
| self._cat_bounds[25] = _i |
|
|
| _cat25_start = self._cat_bounds.get(25, len(self.feature_names)) |
|
|
| |
| |
| |
| for prefix, tr, team_key in [("h", hr_, home), ("a", ar_, away)]: |
| n_games = len(tr) |
| home_tr = team_home_results.get(team_key, []) |
| away_tr = team_away_results.get(team_key, []) |
|
|
| |
| total_pts = sum(r[4].get("pts", 100) for r in tr) |
| total_opp = sum(r[4].get("opp_pts", 100) for r in tr) |
| _e = 13.91 |
| pts_exp = total_pts ** _e if total_pts > 0 else 1.0 |
| opp_exp = total_opp ** _e if total_opp > 0 else 1.0 |
| pyth_wp = pts_exp / (pts_exp + opp_exp) if (pts_exp + opp_exp) > 0 else 0.5 |
| row.append(pyth_wp) |
|
|
| |
| actual_wp = self._wp(tr, n_games) |
| row.append(pyth_wp - actual_wp) |
|
|
| |
| win_pace = actual_wp * 82.0 |
| row.append(win_pace / 82.0) |
|
|
| |
| row.append((win_pace - 42.0) / 82.0) |
|
|
| |
| conf = self._conference(team_key) |
| conf_wps = [] |
| for t, recs in team_results.items(): |
| if recs and self._conference(t) == conf: |
| conf_wps.append((t, self._wp(recs, len(recs)))) |
| conf_wps.sort(key=lambda x: x[1], reverse=True) |
| best_wp = conf_wps[0][1] if conf_wps else 0.5 |
| row.append(max(0, (best_wp - actual_wp) * n_games) / 82.0) |
|
|
| |
| eighth_wp = conf_wps[7][1] if len(conf_wps) >= 8 else 0.5 |
| row.append((eighth_wp - actual_wp) * n_games / 82.0) |
|
|
| |
| worst_wp = conf_wps[-1][1] if conf_wps else 0.5 |
| row.append((actual_wp - worst_wp) * n_games / 82.0) |
|
|
| |
| row.append(self._sos(tr, team_results, min(n_games, 20))) |
|
|
| |
| team_rank = 15 |
| for idx_r, (t, _) in enumerate(conf_wps): |
| if t == team_key: |
| team_rank = idx_r + 1 |
| break |
| row.append(team_rank / 15.0) |
|
|
| |
| div_code = self._division(team_key) |
| div_wps = [(t, w) for t, w in conf_wps if self._division(t) == div_code] |
| div_rank = 5 |
| for idx_r, (t, _) in enumerate(div_wps): |
| if t == team_key: |
| div_rank = idx_r + 1 |
| break |
| row.append(div_rank / 5.0) |
|
|
| |
| row.append(1.0 if team_rank <= 10 else 0.0) |
|
|
| |
| row.append(1.0 if 7 <= team_rank <= 12 else 0.0) |
|
|
| |
| pre_asg = tr[:min(55, n_games)] |
| row.append(self._wp(pre_asg, len(pre_asg)) if pre_asg else 0.5) |
|
|
| |
| post_asg = tr[55:] if n_games > 55 else [] |
| row.append(self._wp(post_asg, len(post_asg)) if post_asg else 0.5) |
|
|
| |
| pre_wp = self._wp(pre_asg, len(pre_asg)) if pre_asg else 0.5 |
| post_wp = self._wp(post_asg, len(post_asg)) if post_asg else 0.5 |
| row.append(post_wp - pre_wp) |
|
|
| |
| pre_dl = tr[:min(45, n_games)] |
| row.append(self._wp(pre_dl, len(pre_dl)) if pre_dl else 0.5) |
|
|
| |
| post_dl = tr[45:] if n_games > 45 else [] |
| row.append(self._wp(post_dl, len(post_dl)) if post_dl else 0.5) |
|
|
| |
| pre_dl_wp = self._wp(pre_dl, len(pre_dl)) if pre_dl else 0.5 |
| post_dl_wp = self._wp(post_dl, len(post_dl)) if post_dl else 0.5 |
| row.append(post_dl_wp - pre_dl_wp) |
|
|
| |
| if n_games >= 30: |
| row.append(self._wp(tr[-15:], 15) - self._wp(tr[-30:-15], 15)) |
| else: |
| row.append(0.0) |
|
|
| |
| if n_games >= 30: |
| row.append(self._ortg(tr, 15) - self._stat_avg(tr[-30:-15], 15, "ortg")) |
| else: |
| row.append(0.0) |
|
|
| |
| if n_games >= 30: |
| row.append(self._drtg(tr, 15) - self._stat_avg(tr[-30:-15], 15, "drtg")) |
| else: |
| row.append(0.0) |
|
|
| |
| half = n_games // 2 |
| if half >= 5: |
| row.append(self._wp(tr[half:], n_games - half) - self._wp(tr[:half], half)) |
| else: |
| row.append(0.0) |
|
|
| |
| row.append(actual_wp - 0.5) |
|
|
| |
| last15_wp = self._wp(tr, 15) |
| regime = 1.0 if last15_wp >= 0.667 else (-1.0 if last15_wp <= 0.333 else 0.0) |
| row.append(regime) |
|
|
| |
| games_rem = max(0, 82 - n_games) |
| max_wins = actual_wp * n_games + games_rem |
| if max_wins / 82.0 < eighth_wp and eighth_wp > 0.3: |
| clinch = 0.0 |
| elif actual_wp * n_games > best_wp * 82: |
| clinch = 2.0 |
| else: |
| clinch = 1.0 |
| row.append(clinch / 2.0) |
|
|
| |
| row.append(games_rem / 82.0) |
|
|
| |
| wp_last30 = self._wp(tr, 30) |
| row.append(wp_last30) |
|
|
| |
| row.append(wp_last30 - actual_wp) |
|
|
| |
| rec_h = home_tr[-5:] if len(home_tr) >= 5 else home_tr |
| rec_a = away_tr[-5:] if len(away_tr) >= 5 else away_tr |
| h_wp_5 = self._wp(rec_h, len(rec_h)) if rec_h else 0.5 |
| a_wp_5 = self._wp(rec_a, len(rec_a)) if rec_a else 0.5 |
| row.append(h_wp_5 - a_wp_5) |
|
|
| |
| if n_games >= 20: |
| row.append((self._consistency(tr[-20:-10], 10) - self._consistency(tr, 10)) / 15.0) |
| else: |
| row.append(0.0) |
|
|
| |
| last10 = tr[-10:] |
| avg_m10 = sum(r[2] for r in last10) / max(len(last10), 1) |
| row.append(avg_m10 * 0.5 / 15.0) |
|
|
| |
| row.append(avg_m10 * 0.5 / 15.0) |
|
|
| |
| row.append(0.0) |
|
|
| |
| row.append(0.5) |
|
|
| |
| row.append(0.5) |
|
|
| |
| row.append(0.5) |
|
|
| |
| close_games = [r for r in tr if abs(r[2]) <= 5] |
| close_pd = sum(r[2] for r in close_games) / max(len(close_games), 1) |
| all_pd = sum(r[2] for r in tr) / max(n_games, 1) |
| row.append((close_pd - all_pd) / 15.0) |
|
|
| |
| after_loss = [tr[i] for i in range(1, len(tr)) if not tr[i - 1][1]] |
| row.append(self._wp(after_loss, len(after_loss)) if after_loss else 0.5) |
|
|
| |
| after_win = [tr[i] for i in range(1, len(tr)) if tr[i - 1][1]] |
| row.append(self._wp(after_win, len(after_win)) if after_win else 0.5) |
|
|
| |
| after_b2b = [tr[i] for i in range(1, len(tr)) if self._game_rest(tr[i], tr[:i + 1]) <= 1] |
| row.append(self._wp(after_b2b, len(after_b2b)) if after_b2b else 0.5) |
|
|
| |
| after_blow = [tr[i] for i in range(1, len(tr)) if not tr[i - 1][1] and tr[i - 1][2] <= -15] |
| row.append(self._wp(after_blow, len(after_blow)) if after_blow else 0.5) |
|
|
| |
| ot_proxy = [r for r in tr if abs(r[2]) <= 3] |
| row.append(self._wp(ot_proxy, len(ot_proxy)) if ot_proxy else 0.5) |
|
|
| |
| h_wp30 = self._wp(hr_, 30) |
| a_wp30 = self._wp(ar_, 30) |
| row.append(h_wp30 - a_wp30) |
|
|
| h_pts_s = sum(r[4].get("pts", 100) for r in hr_) |
| h_opp_s = sum(r[4].get("opp_pts", 100) for r in hr_) |
| a_pts_s = sum(r[4].get("pts", 100) for r in ar_) |
| a_opp_s = sum(r[4].get("opp_pts", 100) for r in ar_) |
| _e = 13.91 |
| h_pyth = (h_pts_s ** _e) / ((h_pts_s ** _e) + (h_opp_s ** _e)) if h_pts_s > 0 else 0.5 |
| a_pyth = (a_pts_s ** _e) / ((a_pts_s ** _e) + (a_opp_s ** _e)) if a_pts_s > 0 else 0.5 |
| row.append(h_pyth - a_pyth) |
|
|
| |
| _n_cats_19_23 = self._cat_bounds.get(24, _cat25_start) - self._cat_bounds.get(19, _cat25_start) |
| row.extend([0.0] * _n_cats_19_23) |
|
|
| |
| |
| |
| for prefix, team_key in [("h", home), ("a", away)]: |
| tr = team_results[team_key] |
| n_gp = len(tr) |
|
|
| |
| row.append((team_elo[team_key] - 1500.0) / 400.0) |
|
|
| |
| row.append((team_elo_margin[team_key] - 1500.0) / 400.0) |
|
|
| |
| row.append((team_elo_recency[team_key] - 1500.0) / 400.0) |
|
|
| |
| hca_bonus = 0.0 |
| if team_home_games_count[team_key] > 0: |
| hca_bonus = team_home_margin_sum[team_key] / team_home_games_count[team_key] |
| row.append((team_elo[team_key] + hca_bonus * 2.0 - 1500.0) / 400.0) |
|
|
| |
| sos_val = self._sos(tr, team_results, min(n_gp, 20)) |
| row.append((team_elo[team_key] * (0.5 + sos_val) - 1500.0) / 400.0) |
|
|
| |
| conf_elos = [team_elo[t] for t in team_results |
| if team_results[t] and self._conference(t) == self._conference(team_key)] |
| conf_avg_elo = sum(conf_elos) / max(len(conf_elos), 1) |
| row.append((team_elo[team_key] - conf_avg_elo) / 200.0) |
|
|
| |
| pace_val = self._pace(tr, 10) if tr else 100.0 |
| row.append((team_elo[team_key] - 1500.0) * (pace_val / 100.0) / 400.0) |
|
|
| |
| off_elo = team_elo_offense[team_key] |
| def_elo = team_elo_defense[team_key] |
| raptor_comp = (off_elo + def_elo) / 2.0 |
| row.append((raptor_comp - 1500.0) / 400.0) |
|
|
| |
| row.append((off_elo - 1500.0) / 400.0) |
|
|
| |
| row.append((def_elo - 1500.0) / 400.0) |
|
|
| |
| ortg_s = self._ortg(tr, n_gp) if tr else 100.0 |
| row.append(ortg_s / 120.0) |
|
|
| |
| drtg_s = self._drtg(tr, n_gp) if tr else 110.0 |
| row.append(1.0 - drtg_s / 120.0) |
|
|
| |
| netrtg_s = ortg_s - drtg_s |
| row.append(netrtg_s / 20.0) |
|
|
| |
| avg_m = self._pd(tr, n_gp) if tr else 0.0 |
| row.append((avg_m + (sos_val - 0.5) * 10) / 20.0) |
|
|
| |
| composite = ( |
| (team_elo[team_key] - 1500) / 400 * 0.3 + |
| netrtg_s / 20 * 0.3 + |
| (raptor_comp - 1500) / 400 * 0.2 + |
| (avg_m + (sos_val - 0.5) * 10) / 20 * 0.2 |
| ) |
| row.append(composite) |
|
|
| |
| elo_hist = team_elo_history.get(team_key, []) |
| if len(elo_hist) >= 10: |
| row.append((elo_hist[-1] - elo_hist[-10]) / 100.0) |
| elif len(elo_hist) >= 2: |
| row.append((elo_hist[-1] - elo_hist[0]) / 100.0) |
| else: |
| row.append(0.0) |
|
|
| |
| row.append((team_elo[team_key] - conf_avg_elo + netrtg_s) / 30.0) |
|
|
| |
| if len(elo_hist) >= 5: |
| recent_elo = elo_hist[-20:] |
| elo_m = sum(recent_elo) / len(recent_elo) |
| elo_std = (sum((e - elo_m) ** 2 for e in recent_elo) / len(recent_elo)) ** 0.5 |
| row.append(elo_std / 100.0) |
| else: |
| row.append(0.5) |
|
|
| |
| all_elos = [team_elo[t] for t in team_results if team_results[t]] |
| if all_elos: |
| row.append(sum(1 for e in all_elos if e <= team_elo[team_key]) / len(all_elos)) |
| else: |
| row.append(0.5) |
|
|
| |
| row.append(min(1.0, n_gp / 30.0)) |
|
|
| |
| prior_w = 10.0 |
| bayesian = (prior_w * 1500.0 + n_gp * team_elo[team_key]) / (prior_w + max(n_gp, 1)) |
| row.append((bayesian - 1500.0) / 400.0) |
|
|
| |
| row.append((team_elo[team_key] - 1500.0) / 400.0) |
|
|
| |
| glicko_rd = max(30.0, 350.0 - max(n_gp, 1) * 5.0) |
| row.append(glicko_rd / 350.0) |
|
|
| |
| row.append((team_elo[team_key] - 1500.0) / 400.0) |
|
|
| |
| row.append(max(0.1, 1.0 - max(n_gp, 1) / 82.0)) |
|
|
| |
| h_elo_s = team_elo[home] |
| a_elo_s = team_elo[away] |
| row.append((h_elo_s - a_elo_s) / 400.0) |
| row.append((team_elo_margin[home] - team_elo_margin[away]) / 400.0) |
| row.append((team_elo_recency[home] - team_elo_recency[away]) / 400.0) |
| h_rap = (team_elo_offense[home] + team_elo_defense[home]) / 2.0 |
| a_rap = (team_elo_offense[away] + team_elo_defense[away]) / 2.0 |
| row.append((h_rap - a_rap) / 400.0) |
| row.append((team_elo_offense[home] - team_elo_offense[away]) / 400.0) |
| row.append((team_elo_defense[home] - team_elo_defense[away]) / 400.0) |
| h_srs = self._pd(hr_, len(hr_)) + (self._sos(hr_, team_results, 20) - 0.5) * 10 |
| a_srs = self._pd(ar_, len(ar_)) + (self._sos(ar_, team_results, 20) - 0.5) * 10 |
| row.append((h_srs - a_srs) / 20.0) |
| h_comp = ((h_elo_s - 1500) / 400 * 0.3 + self._netrtg(hr_, len(hr_)) / 20 * 0.3 + |
| (h_rap - 1500) / 400 * 0.2 + h_srs / 20 * 0.2) |
| a_comp = ((a_elo_s - 1500) / 400 * 0.3 + self._netrtg(ar_, len(ar_)) / 20 * 0.3 + |
| (a_rap - 1500) / 400 * 0.2 + a_srs / 20 * 0.2) |
| row.append(h_comp - a_comp) |
| h_ca = [team_elo[t] for t in team_results |
| if team_results[t] and self._conference(t) == self._conference(home)] |
| a_ca = [team_elo[t] for t in team_results |
| if team_results[t] and self._conference(t) == self._conference(away)] |
| h_ca_avg = sum(h_ca) / max(len(h_ca), 1) |
| a_ca_avg = sum(a_ca) / max(len(a_ca), 1) |
| row.append(((h_elo_s - h_ca_avg) - (a_elo_s - a_ca_avg)) / 200.0) |
| h_gp_d = max(len(hr_), 1) |
| a_gp_d = max(len(ar_), 1) |
| h_bay = (10 * 1500 + h_gp_d * h_elo_s) / (10 + h_gp_d) |
| a_bay = (10 * 1500 + a_gp_d * a_elo_s) / (10 + a_gp_d) |
| row.append((h_bay - a_bay) / 400.0) |
| row.append((h_elo_s - a_elo_s) / 400.0) |
| row.append((h_elo_s - a_elo_s) / 400.0) |
| _all_diffs = [ |
| (h_elo_s - a_elo_s) / 400.0, |
| (team_elo_margin[home] - team_elo_margin[away]) / 400.0, |
| (team_elo_recency[home] - team_elo_recency[away]) / 400.0, |
| (h_rap - a_rap) / 400.0, |
| ] |
| row.append(max(_all_diffs, key=abs)) |
| row.append(sum(_all_diffs) / len(_all_diffs)) |
|
|
| |
| for prefix, tr, team_key in [("h", hr_, home), ("a", ar_, away)]: |
| |
| cumul_gp = len(tr) |
| row.append(cumul_gp / 82.0) |
|
|
| |
| row.append(cumul_gp * 48.0 / (82.0 * 48.0)) |
|
|
| |
| row.append(48.0 / 48.0) |
|
|
| |
| pd_ = (player_data or {}).get(team_key, {}) |
| star_min = pd_.get("star_minutes_load", 34.0) * cumul_gp |
| row.append(star_min / (82.0 * 40.0)) |
|
|
| |
| row.append(star_min / max(82.0 * 40.0, 1)) |
|
|
| |
| season_miles = self._total_miles_season(tr, team_key) |
| row.append(season_miles / 50000.0) |
|
|
| |
| row.append(self._miles_in_window(tr, gd, 30, team_key) / 15000.0) |
|
|
| |
| row.append(self._miles_in_window(tr, gd, 7, team_key) / 5000.0) |
|
|
| |
| miles_10g = self._miles_last_n_games(tr, 10, team_key) |
| games_10 = min(len(tr), 10) |
| row.append((miles_10g / max(games_10, 1)) / 1000.0) |
|
|
| |
| rest_days_list = self._recent_rest_days(tr, 10) |
| if len(rest_days_list) >= 2: |
| rm = sum(rest_days_list) / len(rest_days_list) |
| rest_std = (sum((r - rm) ** 2 for r in rest_days_list) / len(rest_days_list)) ** 0.5 |
| else: |
| rest_std = 1.0 |
| row.append(rest_std / 3.0) |
|
|
| |
| avg_rest = sum(rest_days_list) / len(rest_days_list) if rest_days_list else 1.5 |
| row.append((avg_rest - 1.2) / 2.0) |
|
|
| |
| b2b_season = self._count_b2b_in_window(tr, gd, 300) |
| row.append(b2b_season / 20.0) |
|
|
| |
| b2b_30d = self._count_b2b_in_window(tr, gd, 30) |
| row.append(b2b_30d / 5.0) |
|
|
| |
| three_in_4 = self._count_dense_stretches(tr, gd, 300, 3, 4) |
| row.append(three_in_4 / 15.0) |
|
|
| |
| g7 = self._games_in_window(tr, gd, 7) |
| row.append(1.0 if g7 >= 4 else 0.0) |
|
|
| |
| road_len = self._consecutive_away(tr) |
| row.append(road_len / 7.0) |
|
|
| |
| home_len = self._consecutive_home(tr) |
| row.append(home_len / 7.0) |
|
|
| |
| row.append(road_len * (miles_10g / max(games_10, 1)) / 5000.0) |
|
|
| |
| this_rest = self._rest_days(team_key, gd, team_last) |
| load_mgmt = 0.0 |
| if this_rest <= 1 and star_min / max(cumul_gp, 1) > 36: |
| load_mgmt = 0.7 |
| elif g7 >= 4: |
| load_mgmt = 0.4 |
| elif cumul_gp > 70: |
| load_mgmt = 0.3 |
| row.append(load_mgmt) |
|
|
| |
| row.append(max(0.0, (cumul_gp - 50) / 82.0)) |
|
|
| |
| |
| row.append((cumul_gp - 41 * sp) / 20.0 if sp > 0 else 0.0) |
|
|
| |
| fatigue_penalty = 0.02 * max(0, g7 - 3) + 0.01 * (1 if this_rest <= 1 else 0) |
| ortg_val = self._ortg(tr, 10) |
| row.append(ortg_val * (1.0 - fatigue_penalty) / 110.0) |
|
|
| |
| drtg_val = self._drtg(tr, 10) |
| row.append(drtg_val * (1.0 + fatigue_penalty * 0.5) / 110.0) |
|
|
| |
| wp_val = self._wp(tr, 10) |
| row.append(wp_val * (1.0 - fatigue_penalty)) |
|
|
| |
| rested_games = [r for r in tr[-30:] if self._game_rest(r, tr) >= 2] |
| row.append(self._wp(rested_games, len(rested_games)) if rested_games else 0.5) |
|
|
| |
| b2b_games = [r for r in tr[-30:] if self._game_rest(r, tr) <= 1] |
| non_b2b = [r for r in tr[-30:] if self._game_rest(r, tr) > 1] |
| b2b_margin = self._pd(b2b_games, len(b2b_games)) if b2b_games else 0.0 |
| non_b2b_margin = self._pd(non_b2b, len(non_b2b)) if non_b2b else 0.0 |
| row.append((b2b_margin - non_b2b_margin) / 10.0) |
|
|
| |
| high_alt_games = sum(1 for r in tr[-10:] |
| if ARENA_ALTITUDE.get(r[3], 500) > 3000) |
| row.append(high_alt_games / 10.0) |
|
|
| |
| tz_changes = self._count_tz_changes(tr) |
| row.append(tz_changes / 40.0) |
|
|
| |
| row.append(abs(TIMEZONE_ET.get(team_key, 0) - |
| TIMEZONE_ET.get(self._last_location(tr), 0)) / 3.0) |
|
|
| |
| early_games = [r for r in tr[:30]] |
| early_b2b = sum(1 for i in range(1, len(early_games)) |
| if self._days_between(early_games[i-1][0], early_games[i][0]) <= 1) |
| row.append(early_b2b / 10.0 if len(early_games) >= 20 else 0.0) |
|
|
| |
| late_games = tr[60:] if len(tr) > 60 else [] |
| late_density = len(late_games) / max(1, (cumul_gp - 60)) if cumul_gp > 60 else 0.0 |
| row.append(late_density) |
|
|
| |
| scoring_std = self._consistency(tr, 10) / 15.0 |
| row.append(max(0, 1.0 - scoring_std)) |
|
|
| |
| injury_risk = (g7 / 5.0) * 0.3 + (1 if this_rest <= 1 else 0) * 0.3 + \ |
| (cumul_gp / 82.0) * 0.2 + (star_min / max(cumul_gp * 40, 1)) * 0.2 |
| row.append(min(1.0, injury_risk)) |
|
|
| |
| row.append(self._wp(tr[-5:], 5) - self._wp(tr[-15:], 15) + 0.5) |
|
|
| |
| recent_close = [r for r in tr[-10:] if abs(r[2]) <= 5] |
| row.append(self._wp(recent_close, len(recent_close)) if recent_close else 0.5) |
|
|
| |
| rested_wp = self._wp(rested_games, len(rested_games)) if rested_games else 0.5 |
| tired_wp = self._wp(b2b_games, len(b2b_games)) if b2b_games else 0.5 |
| row.append(rested_wp / max(tired_wp, 0.1)) |
|
|
| |
| row.append(1.0 if 2 <= this_rest <= 3 and g7 <= 3 else 0.0) |
|
|
| |
| wear = (cumul_gp / 82.0) * 0.25 + \ |
| (b2b_30d / 5.0) * 0.2 + \ |
| (g7 / 5.0) * 0.2 + \ |
| (season_miles / 50000.0) * 0.15 + \ |
| (1 if this_rest <= 1 else 0) * 0.2 |
| row.append(min(1.0, wear)) |
|
|
| |
| |
| |
| _h25 = _cat25_start |
| _a25 = _cat25_start + 38 |
| row.append(row[_h25] - row[_a25]) |
| row.append(row[_h25 + 5] - row[_a25 + 5]) |
| row.append(row[_h25 + 9] - row[_a25 + 9]) |
| row.append(row[_h25 + 37] - row[_a25 + 37]) |
| row.append(row[_h25 + 4] - row[_a25 + 4]) |
| row.append(row[_h25 + 12] - row[_a25 + 12]) |
| |
| mkt_spread = _val("current_spread") |
| h_wear = row[_h25 + 37] |
| a_wear = row[_a25 + 37] |
| row.append(mkt_spread + (h_wear - a_wear) * 2.0) |
| |
| row.append((a_wear - h_wear) * 0.5 + (row[_a25 + 9] - row[_h25 + 9]) * 0.3 + |
| (row[_a25 + 12] - row[_h25 + 12]) * 0.2) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| _cat25_end = len(row) |
| _name_idx2 = {} |
| for _i, _n in enumerate(self.feature_names): |
| if _i < _cat25_end: |
| _name_idx2[_n] = _i |
|
|
| def _val2(name): |
| idx = _name_idx2.get(name) |
| if idx is not None and idx < len(row): |
| return row[idx] |
| return 0.0 |
|
|
| |
| |
| _pi_stats = ["plus_minus", "usage_rate", "per", "ws_per48", "bpm", |
| "vorp", "raptor_off", "raptor_def", "raptor_total", |
| "ts_pct", "ast_pct", "reb_pct"] |
| _pi_windows = [3, 5, 10, 20] |
| _pi_defaults = { |
| "plus_minus": 0.0, "usage_rate": 0.25, "per": 15.0, "ws_per48": 0.1, |
| "bpm": 0.0, "vorp": 1.0, "raptor_off": 0.0, "raptor_def": 0.0, |
| "raptor_total": 0.0, "ts_pct": 0.55, "ast_pct": 0.15, "reb_pct": 0.10, |
| } |
| for prefix, team_key in [("h", home), ("a", away)]: |
| pd_ = (player_data or {}).get(team_key, {}) |
| |
| for stat in _pi_stats: |
| base = pd_.get(f"star1_{stat}", _pi_defaults.get(stat, 0.0)) |
| for w in _pi_windows: |
| |
| regression = min(1.0, w / 20.0) |
| row.append(base * regression + _pi_defaults[stat] * (1 - regression)) |
| |
| for stat in _pi_stats: |
| base = pd_.get(f"star2_{stat}", _pi_defaults.get(stat, 0.0) * 0.85) |
| for w in _pi_windows: |
| regression = min(1.0, w / 20.0) |
| row.append(base * regression + _pi_defaults[stat] * 0.85 * (1 - regression)) |
| |
| tr = hr_ if prefix == "h" else ar_ |
| avg_margin_10 = self._pd(tr, 10) |
| row.append(pd_.get("star_combined_pm", avg_margin_10 * 1.5)) |
| star_usage = pd_.get("star_usage_concentration", 0.5) |
| row.append(star_usage) |
| row.append(pd_.get("star_minutes_ratio", 0.6)) |
| row.append(pd_.get("star_efficiency_delta", avg_margin_10 / 10.0)) |
| rest_here = self._rest_days(team_key, gd, team_last) |
| rest_adj = 1.0 - 0.05 * max(0, 2 - rest_here) |
| row.append(pd_.get("star_rest_adj_rating", avg_margin_10 / 10.0 * rest_adj)) |
| row.append(pd_.get("chemistry_starting5", 0.7)) |
| row.append(pd_.get("chemistry_top3", 0.6)) |
| row.append(self._consistency(tr, 10) / 15.0) |
| row.append(star_usage) |
| row.append(pd_.get("bench_player_avg_rating", 0.0)) |
| row.append(pd_.get("roster_talent_depth", 0.5)) |
| row.append(pd_.get("injury_replacement_quality", 0.4)) |
|
|
| |
| h_pd = (player_data or {}).get(home, {}) |
| a_pd = (player_data or {}).get(away, {}) |
| h_star1 = h_pd.get("star1_raptor_total", 0.0) |
| a_star1 = a_pd.get("star1_raptor_total", 0.0) |
| h_star2 = h_pd.get("star2_raptor_total", 0.0) |
| a_star2 = a_pd.get("star2_raptor_total", 0.0) |
| h_chem = h_pd.get("chemistry_starting5", 0.7) |
| a_chem = a_pd.get("chemistry_starting5", 0.7) |
| h_depth = h_pd.get("roster_talent_depth", 0.5) |
| a_depth = a_pd.get("roster_talent_depth", 0.5) |
| row.append(h_star1 - a_star1) |
| row.append(h_star2 - a_star2) |
| row.append((h_star1 + h_star2) - (a_star1 + a_star2)) |
| row.append(h_pd.get("star_usage_concentration", 0.5) - a_pd.get("star_usage_concentration", 0.5)) |
| row.append(h_chem - a_chem) |
| row.append(h_pd.get("bench_player_avg_rating", 0.0) - a_pd.get("bench_player_avg_rating", 0.0)) |
| row.append(h_depth - a_depth) |
| row.append((h_star1 * rest_adj) - (a_star1 * rest_adj)) |
| row.append(h_star1 - a_star1 + (h_star2 - a_star2) * 0.5) |
| row.append((h_star1 + h_star2) / 2 - (a_star1 + a_star2) / 2) |
| row.append(h_pd.get("roster_continuity", 0.7) - a_pd.get("roster_continuity", 0.7)) |
| row.append(h_pd.get("injury_impact_score", 0.0) - a_pd.get("injury_impact_score", 0.0)) |
| row.append(h_pd.get("star_on_off_diff", 5.0) - a_pd.get("star_on_off_diff", 5.0)) |
| row.append(h_pd.get("clutch_player_rating", 0.0) - a_pd.get("clutch_player_rating", 0.0)) |
|
|
| |
| ref = (referee_data or {}).get(game.get("id", gd), {}) |
| |
| for q in ["q1", "q2", "q3", "q4"]: |
| row.append(ref.get(f"{q}_foul_rate", 5.0) / 10.0) |
| row.append(ref.get(f"{q}_home_foul_bias", 0.0)) |
| row.append(ref.get(f"{q}_tech_rate", 0.05)) |
| row.append(ref.get(f"{q}_and1_rate", 0.03)) |
| row.append(ref.get(f"{q}_shooting_foul_rate", 0.15)) |
| row.append(ref.get(f"{q}_offensive_foul_rate", 0.05)) |
| |
| for ttype in ["fast_pace", "slow_pace", "top10", "bottom10", "big_market", "small_market"]: |
| row.append(ref.get(f"bias_{ttype}_home_wp", 0.58)) |
| row.append(ref.get(f"bias_{ttype}_foul_diff", 0.0)) |
| row.append(ref.get(f"bias_{ttype}_ft_diff", 0.0)) |
| |
| for ctx in ["overall", "high_total", "low_total", "rivalry", |
| "playoff_race", "b2b_games", "national_tv"]: |
| row.append(ref.get(f"over_tendency_{ctx}", 0.5)) |
| row.append(ref.get(f"under_tendency_{ctx}", 0.5)) |
| row.append(ref.get(f"total_delta_{ctx}", 0.0)) |
| |
| for prefix, team_key in [("h", home), ("a", away)]: |
| row.append(ref.get(f"{prefix}_expected_pace_impact", 0.0)) |
| row.append(ref.get(f"{prefix}_expected_foul_impact", 0.0)) |
| row.append(ref.get(f"{prefix}_expected_ft_impact", 0.0)) |
| row.append(ref.get(f"{prefix}_historical_team_bias", 0.0)) |
| |
| row.append(ref.get("consistency_index", 0.5)) |
| row.append(ref.get("home_bias_composite", 0.0)) |
| row.append(ref.get("pace_impact_composite", 0.0)) |
| row.append(ref.get("total_impact_composite", 0.0)) |
| row.append(ref.get("foul_disparity_expected", 0.0)) |
| row.append(ref.get("experience_weight", 0.5)) |
| row.append(ref.get("crew_chemistry", 0.5)) |
| row.append(ref.get("variance_in_calls", 0.3)) |
| row.append(ref.get("big_game_experience", 0.5)) |
| row.append(ref.get("crew_avg_total_called", 42.0) / 50.0) |
| row.append(ref.get("crew_foul_per_possession", 0.2)) |
| row.append(ref.get("historical_ats_home_rate", 0.5)) |
| row.append(ref.get("historical_over_rate_season", 0.5)) |
| row.append(ref.get("recent_form_5_games", 0.5)) |
| row.append(ref.get("recent_form_10_games", 0.5)) |
| row.append(ref.get("travel_adjusted_bias", 0.0)) |
|
|
| |
| h_alt = ARENA_ALTITUDE.get(home, 500) |
| a_alt = ARENA_ALTITUDE.get(away, 500) |
| h_tz = TIMEZONE_ET.get(home, 0) |
| a_tz = TIMEZONE_ET.get(away, 0) |
| for prefix, tr, team_key in [("h", hr_, home), ("a", ar_, away)]: |
| t_alt = ARENA_ALTITUDE.get(team_key, 500) |
| t_tz = TIMEZONE_ET.get(team_key, 0) |
| |
| alt_factor = 1.0 + (t_alt - 500) / 50000.0 |
| for w in [3, 5, 10, 20]: |
| row.append(self._ortg(tr, w) * alt_factor / 110.0) |
| row.append(self._drtg(tr, w) * alt_factor / 110.0) |
| row.append(self._pace(tr, w) * alt_factor / 100.0) |
| |
| for w in [3, 5, 10, 20]: |
| tz_games = [r for r in tr[-w:] if abs(TIMEZONE_ET.get(r[3], 0) - t_tz) >= 1] |
| if tz_games: |
| row.append(self._wp(tz_games, len(tz_games))) |
| row.append(self._pd(tz_games, len(tz_games)) / 15.0) |
| else: |
| row.extend([0.5, 0.0]) |
| |
| row.append(self._wp(team_home_results.get(team_key, []), 82)) |
| row.append(self._pd(team_home_results.get(team_key, []), 82) / 15.0) |
| row.append(self._ortg(team_home_results.get(team_key, []), 10) / 110.0) |
| row.append(self._drtg(team_home_results.get(team_key, []), 10) / 110.0) |
| row.append(t_alt / 5280.0) |
| high_alt_g = sum(1 for r in tr if ARENA_ALTITUDE.get(r[3], 500) > 3000) |
| row.append(high_alt_g / max(len(tr), 1)) |
| row.append(1.0 if high_alt_g > 5 else high_alt_g / 5.0) |
| |
| last_loc = self._last_location(tr) |
| tz_shift = abs(t_tz - TIMEZONE_ET.get(last_loc, 0)) |
| row.append(tz_shift / 3.0) |
| row.append(1.0 if t_tz < TIMEZONE_ET.get(last_loc, 0) else 0.0) |
| row.append(1.0 if t_tz > TIMEZONE_ET.get(last_loc, 0) else 0.0) |
| same_tz_g = sum(1 for r in tr[-10:] if TIMEZONE_ET.get(r[3], 0) == t_tz) |
| row.append(same_tz_g / 10.0) |
| |
| row.append(0.85) |
| row.append(0.0) |
| row.append(0.5) |
| row.append(0.5) |
| row.append(1.0) |
| row.append(0.5) |
| |
| row.append(self._wp(team_home_results.get(team_key, []), 20)) |
|
|
| |
| row.append((h_alt - a_alt) / 5280.0) |
| row.append(abs(h_alt - a_alt) / 5280.0) |
| row.append((abs(h_tz - TIMEZONE_ET.get(self._last_location(hr_), 0)) - |
| abs(a_tz - TIMEZONE_ET.get(self._last_location(ar_), 0))) / 3.0) |
| row.append(abs(h_tz - a_tz) / 3.0) |
| row.append(h_alt / 5280.0) |
| row.append(0.85) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(1.0 if h_tz < a_tz else (-1.0 if h_tz > a_tz else 0.0)) |
| row.append(0.0) |
| row.append(self._wp(team_home_results.get(home, []), 20)) |
| row.append(0.0) |
| row.append(self._wp(team_home_results.get(home, []), 20) - |
| self._wp(team_away_results.get(away, []), 20)) |
| row.append(abs(h_alt - a_alt) / 5280.0 * self._travel_dist(ar_, away) / 3000.0) |
| row.append(abs(h_tz - a_tz) / 3.0 * abs(self._fatigue_score(ar_, gd, away, a_rest))) |
| row.append(0.0) |
|
|
| |
| if self.include_market: |
| mkt = (market_data or {}).get(game.get("id", gd), {}) |
| |
| for tw in ["1h", "2h", "4h", "8h", "12h", "24h"]: |
| row.append(mkt.get(f"spread_velocity_{tw}", 0.0)) |
| row.append(mkt.get(f"spread_acceleration_{tw}", 0.0)) |
| row.append(mkt.get(f"total_velocity_{tw}", 0.0)) |
| row.append(mkt.get(f"total_acceleration_{tw}", 0.0)) |
| row.append(mkt.get(f"ml_velocity_{tw}", 0.0)) |
| row.append(mkt.get(f"ml_acceleration_{tw}", 0.0)) |
| |
| for book in ["pinnacle", "draftkings", "fanduel", "betmgm", |
| "caesars", "bet365", "william_hill"]: |
| row.append(mkt.get(f"{book}_vs_consensus_spread", 0.0)) |
| row.append(mkt.get(f"{book}_vs_consensus_total", 0.0)) |
| row.append(mkt.get(f"{book}_vs_consensus_ml", 0.0)) |
| row.append(mkt.get(f"{book}_clv_history", 0.0)) |
| |
| row.append(mkt.get("sharp_pct_home", 0.5)) |
| row.append(mkt.get("sharp_pct_away", 0.5)) |
| row.append(mkt.get("public_pct_home_mkt3", 0.5)) |
| row.append(mkt.get("public_pct_away_mkt3", 0.5)) |
| row.append(mkt.get("sharp_public_div_spread", 0.0)) |
| row.append(mkt.get("sharp_public_div_total", 0.0)) |
| row.append(mkt.get("sharp_money_direction", 0.0)) |
| row.append(mkt.get("public_money_direction", 0.0)) |
| row.append(mkt.get("sharp_intensity_score", 0.0)) |
| |
| row.append(mkt.get("steam_count_total", 0)) |
| row.append(mkt.get("steam_count_last_6h", 0)) |
| row.append(mkt.get("steam_magnitude_avg", 0.0)) |
| row.append(mkt.get("steam_direction", 0.0)) |
| row.append(mkt.get("reverse_steam_flag", 0)) |
| row.append(mkt.get("steam_books_triggered", 0)) |
| |
| row.append(mkt.get("rlm_spread_flag", 0)) |
| row.append(mkt.get("rlm_total_flag", 0)) |
| row.append(mkt.get("rlm_magnitude_spread", 0.0)) |
| row.append(mkt.get("rlm_magnitude_total", 0.0)) |
| row.append(mkt.get("rlm_sharp_confirmation", 0)) |
| |
| for book in ["pinnacle", "draftkings", "fanduel", "betmgm", |
| "caesars", "bet365", "william_hill"]: |
| row.append(mkt.get(f"clv_{book}_home", 0.0)) |
| |
| curr_sp = mkt.get("current_spread", 0) |
| open_sp = mkt.get("opening_spread", 0) |
| row.append(curr_sp - open_sp) |
| row.append(mkt.get("current_total", 220) - mkt.get("opening_total", 220)) |
| row.append(mkt.get("current_ml_home", -110) - mkt.get("opening_ml_home", -110)) |
| row.append(mkt.get("implied_prob_home", 0.5) - mkt.get("opening_implied_home", 0.5)) |
| |
| row.append(mkt.get("ml_convergence_rate", 0.0)) |
| row.append(mkt.get("ml_convergence_direction", 0.0)) |
| row.append(mkt.get("implied_prob_convergence", 0.0)) |
| row.append(mkt.get("book_agreement_score", 0.5)) |
| row.append(mkt.get("market_depth_proxy", 0.5)) |
| row.append(mkt.get("liquidity_score", 0.5)) |
| row.append(mkt.get("market_manipulation_flag", 0)) |
| row.append(mkt.get("arbitrage_opportunity", 0)) |
| row.append(mkt.get("hold_pct_change", 0.0)) |
| row.append(mkt.get("vig_trend", 0.0)) |
| |
| row.append(mkt.get("historical_clv_this_matchup", 0.0)) |
| row.append(mkt.get("historical_rlm_success_rate", 0.5)) |
| row.append(mkt.get("historical_steam_success_rate", 0.5)) |
| row.append(mkt.get("historical_sharp_roi", 0.0)) |
| row.append(mkt.get("historical_public_fade_roi", 0.0)) |
| row.append(mkt.get("line_stability_score", 0.5)) |
| row.append(mkt.get("early_sharp_vs_late_public", 0.0)) |
| row.append(mkt.get("market_overreaction_index", 0.0)) |
| row.append(mkt.get("odds_shape_skewness", 0.0)) |
| row.append(mkt.get("odds_shape_kurtosis", 0.0)) |
|
|
| |
| _ts_stats_list = ["wp", "ppg", "margin", "ortg", "drtg", "efg", "ts", "pace"] |
| _ts_stat_fn = { |
| "wp": lambda tr, w: self._wp(tr, w), |
| "ppg": lambda tr, w: self._ppg(tr, w), |
| "margin": lambda tr, w: self._pd(tr, w), |
| "ortg": lambda tr, w: self._ortg(tr, w), |
| "drtg": lambda tr, w: self._drtg(tr, w), |
| "efg": lambda tr, w: self._efg(tr, w), |
| "ts": lambda tr, w: self._ts(tr, w), |
| "pace": lambda tr, w: self._pace(tr, w), |
| } |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| |
| for stat in _ts_stats_list: |
| fn = _ts_stat_fn[stat] |
| for w in [3, 5, 10, 20]: |
| |
| season_val = fn(tr, len(tr)) if len(tr) >= 10 else fn(tr, max(len(tr), 3)) |
| window_val = fn(tr, w) |
| row.append(window_val - season_val) |
|
|
| |
| for stat in _ts_stats_list: |
| if dt is not None: |
| dow_games = [r for r in tr if self._get_dow(r[0]) == dow] |
| if dow_games and stat in _ts_stat_fn: |
| row.append(_ts_stat_fn[stat](dow_games, len(dow_games))) |
| else: |
| row.append(0.0) |
| else: |
| row.append(0.0) |
|
|
| |
| for stat in _ts_stats_list: |
| if dt is not None: |
| month_games = [r for r in tr if self._get_month(r[0]) == month] |
| if month_games and stat in _ts_stat_fn: |
| row.append(_ts_stat_fn[stat](month_games, len(month_games))) |
| else: |
| row.append(0.0) |
| else: |
| row.append(0.0) |
|
|
| |
| for stat in _ts_stats_list: |
| key_fn = _STAT_KEY_17.get(stat) |
| if key_fn and len(tr) >= 10: |
| vals = [key_fn(r) for r in tr[-20:]] |
| if len(vals) >= 5: |
| mean_v = sum(vals) / len(vals) |
| |
| n_v = len(vals) |
| x_mean = (n_v - 1) / 2.0 |
| slope_num = sum((i - x_mean) * (v - mean_v) for i, v in enumerate(vals)) |
| slope_den = sum((i - x_mean) ** 2 for i in range(n_v)) |
| slope = slope_num / slope_den if slope_den > 0 else 0 |
| residuals = [v - (mean_v + slope * (i - x_mean)) for i, v in enumerate(vals)] |
| res_std = (sum(r ** 2 for r in residuals) / len(residuals)) ** 0.5 |
| row.append(res_std) |
| else: |
| row.append(0.0) |
| else: |
| row.append(0.0) |
|
|
| |
| for stat in _ts_stats_list: |
| key_fn = _STAT_KEY_17.get(stat) |
| vals = [key_fn(r) for r in tr[-25:]] if key_fn and len(tr) >= 10 else [] |
| for lag in [1, 2, 3, 4, 5]: |
| if len(vals) > lag + 2: |
| n_v = len(vals) |
| mean_v = sum(vals) / n_v |
| var_v = sum((v - mean_v) ** 2 for v in vals) / n_v |
| if var_v > 1e-9: |
| acf = sum((vals[i] - mean_v) * (vals[i - lag] - mean_v) |
| for i in range(lag, n_v)) / (n_v * var_v) |
| row.append(acf) |
| else: |
| row.append(0.0) |
| else: |
| row.append(0.0) |
|
|
| |
| for stat in _ts_stats_list: |
| key_fn = _STAT_KEY_17.get(stat) |
| vals = [key_fn(r) for r in tr[-25:]] if key_fn and len(tr) >= 10 else [] |
| for lag in [1, 2, 3, 4, 5]: |
| if len(vals) > lag + 2: |
| n_v = len(vals) |
| mean_v = sum(vals) / n_v |
| var_v = sum((v - mean_v) ** 2 for v in vals) / n_v |
| if var_v > 1e-9: |
| |
| acf = sum((vals[i] - mean_v) * (vals[i - lag] - mean_v) |
| for i in range(lag, n_v)) / (n_v * var_v) |
| row.append(acf / (1 + lag * 0.1)) |
| else: |
| row.append(0.0) |
| else: |
| row.append(0.0) |
|
|
| |
| for stat in _ts_stats_list: |
| key_fn = _STAT_KEY_17.get(stat) |
| if key_fn and len(tr) >= 20: |
| vals = [key_fn(r) for r in tr[-20:]] |
| first_half = vals[:10] |
| second_half = vals[10:] |
| m1 = sum(first_half) / len(first_half) if first_half else 0 |
| m2 = sum(second_half) / len(second_half) if second_half else 0 |
| row.append(abs(m1 - m2) / max(abs(m1 + m2) / 2, 1e-6)) |
| else: |
| row.append(0.0) |
|
|
| |
| for stat in _ts_stats_list: |
| key_fn = _STAT_KEY_17.get(stat) |
| if key_fn and len(tr) >= 10: |
| vals = [key_fn(r) for r in tr[-20:]] |
| if len(vals) >= 5: |
| n_v = len(vals) |
| x_mean = (n_v - 1) / 2.0 |
| y_mean = sum(vals) / n_v |
| slope_num = sum((i - x_mean) * (v - y_mean) for i, v in enumerate(vals)) |
| slope_den = sum((i - x_mean) ** 2 for i in range(n_v)) |
| slope = slope_num / slope_den if slope_den > 0 else 0 |
| y_std = (sum((v - y_mean) ** 2 for v in vals) / n_v) ** 0.5 |
| row.append(slope * n_v / max(y_std, 1e-6)) |
| else: |
| row.append(0.0) |
| else: |
| row.append(0.0) |
|
|
| |
| for stat in _ts_stats_list: |
| key_fn = _STAT_KEY_17.get(stat) |
| if key_fn and len(tr) >= 10 and dt is not None: |
| dow_vals = {} |
| for r in tr[-30:]: |
| d = self._get_dow(r[0]) |
| if d not in dow_vals: |
| dow_vals[d] = [] |
| dow_vals[d].append(key_fn(r)) |
| if len(dow_vals) >= 3: |
| dow_means = [sum(v) / len(v) for v in dow_vals.values()] |
| grand_mean = sum(dow_means) / len(dow_means) |
| row.append(_std_17(dow_means) / max(abs(grand_mean), 1e-6)) |
| else: |
| row.append(0.0) |
| else: |
| row.append(0.0) |
|
|
| |
| _xteam_stats_list = ["pace", "ortg", "drtg", "efg", "3p_pct", "paint_pts", |
| "fb_pts", "tov_rate", "oreb_pct", "ft_rate"] |
| _xteam_stat_fn = { |
| "pace": lambda t, w: self._pace(t, w), |
| "ortg": lambda t, w: self._ortg(t, w), |
| "drtg": lambda t, w: self._drtg(t, w), |
| "efg": lambda t, w: self._efg(t, w), |
| "3p_pct": lambda t, w: self._stat_avg(t, w, "fg3_pct"), |
| "paint_pts": lambda t, w: self._stat_avg(t, w, "paint_pts"), |
| "fb_pts": lambda t, w: self._stat_avg(t, w, "fb_pts"), |
| "tov_rate": lambda t, w: self._tov_rate(t, w), |
| "oreb_pct": lambda t, w: self._oreb_pct(t, w), |
| "ft_rate": lambda t, w: self._ft_rate(t, w), |
| } |
| |
| for stat in _xteam_stats_list: |
| fn = _xteam_stat_fn[stat] |
| for w in [5, 10, 20]: |
| h_v = fn(hr_, w) |
| a_v = fn(ar_, w) |
| row.append(h_v - a_v) |
| row.append(h_v / max(abs(a_v), 0.001)) |
| row.append(h_v * a_v) |
| row.append(abs(h_v - a_v)) |
| |
| for stat in _xteam_stats_list: |
| fn = _xteam_stat_fn[stat] |
| for w in [5, 10, 20]: |
| h_off = fn(hr_, w) |
| a_def_stat = self._stat_avg(ar_, w, f"opp_{stat}") if stat not in ("pace", "ortg", "drtg") else fn(ar_, w) |
| row.append(h_off - a_def_stat) |
| |
| for stat in _xteam_stats_list: |
| fn = _xteam_stat_fn[stat] |
| for w in [5, 10, 20]: |
| a_off = fn(ar_, w) |
| h_def_stat = self._stat_avg(hr_, w, f"opp_{stat}") if stat not in ("pace", "ortg", "drtg") else fn(hr_, w) |
| row.append(a_off - h_def_stat) |
| |
| for style_idx in range(10): |
| for w in [5, 10, 20]: |
| |
| row.append(abs(self._pace(hr_, w) - self._pace(ar_, w)) / 10.0 + |
| abs(self._ortg(hr_, w) - self._drtg(ar_, w)) / 20.0) |
| |
| for area_idx in range(10): |
| for w in [5, 10, 20]: |
| |
| h_str = self._ortg(hr_, w) / 110.0 |
| a_wk = self._drtg(ar_, w) / 110.0 |
| row.append(h_str - a_wk) |
| |
| overall_pace_diff = abs(self._pace(hr_, 10) - self._pace(ar_, 10)) |
| overall_style = (self._ortg(hr_, 10) - self._drtg(ar_, 10)) - (self._ortg(ar_, 10) - self._drtg(hr_, 10)) |
| row.append(overall_pace_diff / 10.0) |
| row.append(overall_style / 20.0) |
| row.append(-overall_style / 20.0) |
| row.append(1.0 if overall_pace_diff > 5 else 0.0) |
| row.append(1.0 if overall_pace_diff < 2 else 0.0) |
| row.append(1.0 if self._ppg(hr_, 10) + self._ppg(ar_, 10) > 225 else 0.0) |
| row.append(abs(overall_style) / 10.0) |
| row.append(1.0 if abs(overall_style) < 3 else 0.0) |
| row.append(1.0 if self._wp(hr_, 10) < 0.4 and self._wp(ar_, 10) > 0.6 else 0.0) |
| row.append(1.0 if abs(self._netrtg(hr_, 10) - self._netrtg(ar_, 10)) > 10 else 0.0) |
|
|
| |
| for prefix, team_key, tr in [("h", home, hr_), ("a", away, ar_)]: |
| pd_ = (player_data or {}).get(team_key, {}) |
| n_gp = len(tr) |
| actual_wp = self._wp(tr, n_gp) |
| |
| preseason_ou = pd_.get("preseason_win_total_ou", 41.0) |
| row.append(preseason_ou / 82.0) |
| row.append(pd_.get("vegas_season_win_total", 41.0) / 82.0) |
| row.append(pd_.get("preseason_power_rank", 15) / 30.0) |
| row.append(pd_.get("preseason_conf_rank", 8) / 15.0) |
| row.append(pd_.get("preseason_division_rank", 3) / 5.0) |
| row.append(pd_.get("vegas_championship_odds", 0.03)) |
| row.append(pd_.get("vegas_conf_winner_odds", 0.06)) |
| preseason_wp = preseason_ou / 82.0 |
| row.append(preseason_wp - actual_wp) |
| row.append(abs(preseason_wp - actual_wp)) |
| prior_weight = max(0.1, 1.0 - n_gp / 82.0) |
| row.append(prior_weight) |
| |
| row.append(pd_.get("franchise_wp_10yr", 0.5)) |
| row.append(pd_.get("franchise_wp_5yr", 0.5)) |
| row.append(pd_.get("franchise_championships", 0) / 17.0) |
| row.append(pd_.get("franchise_finals", 0) / 30.0) |
| row.append(pd_.get("franchise_playoff_rate_10yr", 0.5)) |
| row.append(pd_.get("franchise_avg_seed_5yr", 8) / 15.0) |
| row.append(pd_.get("franchise_consistency_5yr", 0.5)) |
| row.append(1.0 if actual_wp < 0.35 and n_gp > 40 else 0.0) |
| row.append(1.0 if actual_wp > 0.6 else 0.0) |
| row.append(pd_.get("franchise_stability_index", 0.5)) |
| |
| row.append(pd_.get("coach_career_wp", 0.5)) |
| row.append(pd_.get("coach_playoff_wp", 0.5)) |
| coach_tenure = pd_.get("coach_tenure_years", 2.0) |
| row.append(coach_tenure / 10.0) |
| row.append(min(1.0, coach_tenure / 5.0)) |
| row.append(pd_.get("coach_with_team_years", 2.0) / 10.0) |
| row.append(min(1.0, pd_.get("coach_with_team_years", 2.0) / 4.0)) |
| row.append(pd_.get("coach_ato_rating", 0.5)) |
| row.append(pd_.get("coach_challenge_success_rate", 0.4)) |
| row.append(pd_.get("coach_close_game_wp", 0.5)) |
| row.append(pd_.get("coach_blowout_wp", 0.5)) |
| row.append(pd_.get("coach_b2b_wp", 0.45)) |
| row.append(pd_.get("coach_road_wp", 0.4)) |
| row.append(pd_.get("coach_home_wp", 0.6)) |
| row.append(pd_.get("coach_vs_winning_teams_wp", 0.45)) |
| row.append(pd_.get("coach_comeback_rate", 0.3)) |
| row.append(pd_.get("coach_defensive_rating_rank", 15) / 30.0) |
| row.append(pd_.get("coach_offensive_rating_rank", 15) / 30.0) |
| row.append(pd_.get("coach_pace_preference", 100.0) / 110.0) |
| |
| bayesian_blend = prior_weight * preseason_wp + (1 - prior_weight) * actual_wp |
| row.append(bayesian_blend) |
| row.append(1.0 - prior_weight) |
| row.append(abs(actual_wp - preseason_wp) * (1 - prior_weight)) |
| row.append(prior_weight) |
| row.append(actual_wp - preseason_wp) |
| regression_target = 0.5 * prior_weight + actual_wp * (1 - prior_weight) |
| row.append(regression_target - actual_wp) |
| row.append(bayesian_blend * self._ortg(tr, 10) / 110.0) |
| row.append(bayesian_blend * self._drtg(tr, 10) / 110.0) |
| row.append(pd_.get("market_implied_prior", 0.5)) |
| composite_bay = (bayesian_blend * 0.4 + |
| (team_elo[team_key] - 1500) / 400 * 0.3 + |
| self._netrtg(tr, 10) / 20 * 0.3) |
| row.append(composite_bay) |
| |
| h_bay_wp = _val2(f"h_preseason_win_total_ou") if "h_preseason_win_total_ou" in _name_idx2 else 0.5 |
| a_bay_wp = _val2(f"a_preseason_win_total_ou") if "a_preseason_win_total_ou" in _name_idx2 else 0.5 |
| |
| row.append(h_bay_wp - a_bay_wp) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
|
|
| |
| |
| for prefix, team_key, tr in [("h", home, hr_), ("a", away, ar_)]: |
| n_gp = len(tr) |
| |
| for w in [10, 20, 82]: |
| recent = tr[-w:] |
| if recent: |
| wins_vs = [r for r in recent if r[1]] |
| opp_wps = [self._wp(team_results[r[3]], 82) for r in wins_vs if team_results[r[3]]] |
| |
| pagerank_wins = sum(opp_wps) / max(len(opp_wps), 1) |
| row.append(pagerank_wins) |
| |
| margin_pr = sum(r[2] * self._wp(team_results[r[3]], 82) |
| for r in recent if team_results[r[3]]) / max(len(recent), 1) / 15.0 |
| row.append(margin_pr) |
| |
| row.append((pagerank_wins + margin_pr) / 2.0) |
| else: |
| row.extend([0.5, 0.0, 0.25]) |
| |
| for w in [10, 20, 82]: |
| recent = tr[-w:] |
| opps = set(r[3] for r in recent) |
| if len(opps) >= 3: |
| |
| mutual = 0 |
| total_possible = 0 |
| for opp in opps: |
| opp_recent = team_results[opp][-w:] |
| opp_opps = set(r[3] for r in opp_recent) |
| shared = opps.intersection(opp_opps) - {team_key, opp} |
| mutual += len(shared) |
| total_possible += len(opps) - 2 |
| row.append(mutual / max(total_possible, 1)) |
| else: |
| row.append(0.5) |
| |
| for w in [10, 20, 82]: |
| recent = tr[-w:] |
| |
| opps = set(r[3] for r in recent) |
| row.append(len(opps) / 29.0) |
| |
| for w in [10, 20, 82]: |
| recent = tr[-w:] |
| if recent: |
| opp_sos = [self._sos(team_results[r[3]], team_results, 10) for r in recent |
| if team_results[r[3]]] |
| row.append(sum(opp_sos) / max(len(opp_sos), 1)) |
| row.append(sum(opp_sos) / max(len(opp_sos), 1) * self._wp(tr, w)) |
| else: |
| row.extend([0.5, 0.25]) |
| |
| conf = self._conference(team_key) |
| conf_games = [r for r in tr if self._conference(r[3]) == conf] |
| row.append(len(conf_games) / max(n_gp, 1)) |
| row.append(self._wp(conf_games, len(conf_games)) if conf_games else 0.5) |
| conf_best = [r for r in conf_games if self._wp(team_results[r[3]], 82) > 0.6] |
| row.append(self._wp(conf_best, len(conf_best)) if conf_best else 0.5) |
| conf_worst = [r for r in conf_games if self._wp(team_results[r[3]], 82) < 0.4] |
| row.append(self._wp(conf_worst, len(conf_worst)) if conf_worst else 0.5) |
| |
| div_code = self._division(team_key) |
| div_games = [r for r in tr if self._division(r[3]) == div_code] |
| row.append(len(div_games) / max(n_gp, 1)) |
| row.append(self._wp(div_games, len(div_games)) if div_games else 0.5) |
| row.append(len(div_games)) |
| row.append(self._wp(div_games, len(div_games)) if div_games else 0.5) |
| |
| for w in [10, 20, 82]: |
| recent = tr[-w:] |
| beaten = set(r[3] for r in recent if r[1]) |
| |
| chain_depth = 0 |
| for opp in beaten: |
| opp_beaten = set(r[3] for r in team_results[opp][-w:] if r[1]) |
| chain_depth += len(opp_beaten) |
| row.append(chain_depth / max(len(beaten) * 10, 1)) |
| row.append(chain_depth / max(len(beaten) * 10, 1) * self._wp(tr, w)) |
| |
| for w in [10, 20, 82]: |
| recent = tr[-w:] |
| lost_to = set(r[3] for r in recent if not r[1]) |
| chain_depth = 0 |
| for opp in lost_to: |
| opp_lost = set(r[3] for r in team_results[opp][-w:] if not r[1]) |
| chain_depth += len(opp_lost) |
| row.append(chain_depth / max(len(lost_to) * 10, 1)) |
| |
| for w in [10, 20, 82]: |
| recent = tr[-w:] |
| beaten = set(r[3] for r in recent if r[1]) |
| row.append(len(beaten) / 29.0) |
| row.append(len(beaten) / max(len(recent), 1)) |
| lost_to = set(r[3] for r in recent if not r[1]) |
| row.append(len(lost_to) / 29.0) |
| |
| for w in [10, 20, 82]: |
| recent = tr[-w:] |
| if recent: |
| opp_wps = [self._wp(team_results[r[3]], 82) for r in recent if team_results[r[3]]] |
| if opp_wps: |
| row.append(self._wp(tr, w) * (sum(opp_wps) / len(opp_wps))) |
| else: |
| row.append(0.25) |
| else: |
| row.append(0.25) |
| |
| row.extend([0.0] * 10) |
|
|
| |
| |
| _meta_models = ["xgboost", "lightgbm", "catboost", "rf", "logistic"] |
| for prefix in ["h", "a"]: |
| for model in _meta_models: |
| for w in [5, 10, 20, 30]: |
| row.append(0.5) |
| for model in _meta_models: |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.5) |
| row.append(0.5) |
| for model in _meta_models: |
| row.append(0.5) |
| row.append(0.5) |
| row.append(0.0) |
| |
| row.extend([ |
| 0.5, 0.1, 0.5, 0.5, 0.0, |
| 0.5, 0.5, 0.0, 0.5, 0.5, |
| 0.0, 0.0, 0.0, 0.5, 0.0, |
| 0.0, 0.5, 0.0, 0.5, 0.0, |
| ]) |
|
|
| |
| _td_stats_list = ["wp", "ppg", "papg", "margin", "ortg", "drtg", |
| "efg", "ts", "pace", "3p_pct"] |
| _td_stat_fn = { |
| "wp": lambda r: 1.0 if r[1] else 0.0, |
| "ppg": lambda r: r[4].get("pts", 100), |
| "papg": lambda r: r[4].get("opp_pts", 100), |
| "margin": lambda r: r[2], |
| "ortg": lambda r: r[4].get("ortg", 100), |
| "drtg": lambda r: r[4].get("drtg", 100), |
| "efg": lambda r: r[4].get("efg_pct", 0.5), |
| "ts": lambda r: r[4].get("ts_pct", 0.5), |
| "pace": lambda r: r[4].get("pace", 100), |
| "3p_pct": lambda r: r[4].get("fg3_pct", 0.36), |
| } |
|
|
| def _decay_weighted(records, stat_fn, half_life): |
| """Compute exponential decay weighted average.""" |
| if not records: |
| return 0.0 |
| decay = math.log(2) / max(half_life, 1) |
| n = len(records) |
| total_w = 0.0 |
| total_v = 0.0 |
| for i, r in enumerate(records): |
| w = math.exp(-decay * (n - 1 - i)) |
| total_w += w |
| total_v += w * stat_fn(r) |
| return total_v / max(total_w, 1e-9) |
|
|
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| recent = tr[-30:] |
| |
| for stat in _td_stats_list: |
| fn = _td_stat_fn[stat] |
| for hl in [3, 5, 10, 20]: |
| row.append(_decay_weighted(recent, fn, hl)) |
| |
| for hl in [3, 5, 10, 20]: |
| decay = math.log(2) / max(hl, 1) |
| n = len(recent) |
| total_w = 0.0 |
| total_v = 0.0 |
| for i, r in enumerate(recent): |
| w = math.exp(-decay * (n - 1 - i)) |
| opp_wp = self._wp(team_results[r[3]], 82) if team_results[r[3]] else 0.5 |
| total_w += w |
| total_v += w * opp_wp |
| row.append(total_v / max(total_w, 1e-9)) |
| |
| home_g = [r for r in recent if r[4].get("is_home", False)] |
| away_g = [r for r in recent if not r[4].get("is_home", False)] |
| for hl in [3, 5, 10, 20]: |
| row.append(_decay_weighted(home_g, lambda r: 1.0 if r[1] else 0.0, hl)) |
| row.append(_decay_weighted(away_g, lambda r: 1.0 if r[1] else 0.0, hl)) |
| |
| n_gp = len(tr) |
| for stat in _td_stats_list: |
| fn = _td_stat_fn[stat] |
| |
| early = tr[:min(27, n_gp)] |
| row.append(sum(fn(r) for r in early) / max(len(early), 1) if early else 0.0) |
| |
| mid = tr[27:55] if n_gp > 27 else [] |
| row.append(sum(fn(r) for r in mid) / max(len(mid), 1) if mid else 0.0) |
| |
| late = tr[55:] if n_gp > 55 else [] |
| row.append(sum(fn(r) for r in late) / max(len(late), 1) if late else 0.0) |
| |
| _td_pairs = [(3, 5), (3, 10), (3, 20), (5, 10), (5, 20), (10, 20)] |
| for stat in _td_stats_list: |
| fn = _td_stat_fn[stat] |
| for hl1, hl2 in _td_pairs: |
| fast = _decay_weighted(recent, fn, hl1) |
| slow = _decay_weighted(recent, fn, hl2) |
| row.append(fast - slow) |
|
|
| |
| for stat in _td_stats_list: |
| fn = _td_stat_fn[stat] |
| for hl in [3, 5, 10, 20]: |
| h_val = _decay_weighted(hr_[-30:], fn, hl) |
| a_val = _decay_weighted(ar_[-30:], fn, hl) |
| row.append(h_val - a_val) |
|
|
| |
| |
| |
|
|
| |
| _positions = ["pg", "sg", "sf", "pf", "c"] |
| for prefix, team_key in [("h", home), ("a", away)]: |
| pd_ = (player_data or {}).get(team_key, {}) |
| for pos in _positions: |
| row.append(pd_.get(f"pos_{pos}_rating", 0.0)) |
| row.append(pd_.get(f"pos_{pos}_minutes_share", 0.2)) |
| row.append(pd_.get(f"pos_{pos}_plus_minus", 0.0)) |
| row.append(pd_.get(f"pos_{pos}_usage", 0.2)) |
| row.append(pd_.get(f"pos_{pos}_ts_pct", 0.55)) |
| row.append(pd_.get(f"pos_{pos}_def_rating", 110.0) / 120.0) |
| |
| for prefix, team_key in [("h", home), ("a", away)]: |
| pd_ = (player_data or {}).get(team_key, {}) |
| for combo_idx in range(1, 6): |
| row.append(pd_.get(f"combo{combo_idx}_netrtg", 0.0) / 10.0) |
| row.append(pd_.get(f"combo{combo_idx}_minutes", 10.0) / 48.0) |
| row.append(pd_.get(f"combo{combo_idx}_plus_minus", 0.0) / 10.0) |
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for unit in ["start", "bench", "closing"]: |
| for w in [5, 10]: |
| row.append(self._ortg(tr, w) / 110.0) |
| row.append(self._drtg(tr, w) / 110.0) |
| row.append(self._netrtg(tr, w) / 20.0) |
| row.append(self._pace(tr, w) / 100.0) |
| |
| for pos in _positions: |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
|
|
| |
| for ctx in ["blowout", "close", "tied", "home_leading", "away_leading"]: |
| row.extend([ref.get(f"foul_rate_{ctx}", 0.5), |
| ref.get(f"home_bias_{ctx}", 0.0), |
| ref.get(f"tech_rate_{ctx}", 0.05), |
| ref.get(f"review_rate_{ctx}", 0.05)]) |
| for play in ["post_up", "pick_roll", "isolation", "transition", |
| "spot_up", "off_screen", "handoff", "cut"]: |
| row.append(ref.get(f"foul_rate_{play}", 0.15)) |
| row.append(ref.get(f"and1_rate_{play}", 0.03)) |
| for prefix, team_key in [("h", home), ("a", away)]: |
| row.append(ref.get(f"{prefix}_team_specific_foul_rate", 0.5)) |
| row.append(ref.get(f"{prefix}_team_specific_ft_rate", 0.5)) |
| row.append(ref.get(f"{prefix}_team_specific_tech_rate", 0.05)) |
| row.append(ref.get(f"{prefix}_team_historical_wp_with_ref", 0.5)) |
| row.append(ref.get(f"{prefix}_team_historical_margin_with_ref", 0.0)) |
| row.append(ref.get(f"{prefix}_team_historical_total_with_ref", 0.5)) |
| row.extend([0.5] * 12) |
| for prefix in ["h", "a"]: |
| for w in [5, 10]: |
| row.extend([0.5, 0.5, 0.5, 0.5]) |
|
|
| |
| for prefix in ["h", "a"]: |
| row.extend([0.5, 0.5, 0.0, 0.0, 0.0, 0.0]) |
| row.extend([0.0, 0.0]) |
| for prefix in ["h", "a"]: |
| row.extend([0.5, 0.0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]) |
| for prefix in ["h", "a"]: |
| row.extend([0.5, 0.5, 0.5, 0.5]) |
| row.extend([0.5, 0.0, 0.0, 0.0, 0.5, 0.5]) |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for w in [5, 10, 20]: |
| row.extend([0.5, 0.0, 0.5, 0.0]) |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for w in [5, 10]: |
| row.extend([0.5, 0.0, 0.5, 0.0]) |
| row.extend([0.0] * 8) |
|
|
| |
| if self.include_market: |
| mkt = (market_data or {}).get(game.get("id", gd), {}) |
| |
| for prop in ["total_pts", "home_pts", "away_pts", "home_spread", |
| "first_half_spread", "first_half_total", |
| "second_half_spread", "second_half_total", |
| "q1_spread", "q1_total"]: |
| row.append(mkt.get(f"prop_{prop}_opening", 0.0)) |
| row.append(mkt.get(f"prop_{prop}_current", 0.0)) |
| row.append(mkt.get(f"prop_{prop}_movement", 0.0)) |
| row.append(mkt.get(f"prop_{prop}_velocity", 0.0)) |
| |
| row.extend([0.0] * 6) |
| |
| row.extend([0.0] * 5) |
| |
| for snap in ["open", "12h", "6h", "3h", "1h", "30min"]: |
| row.append(mkt.get(f"snapshot_spread_{snap}", 0.0)) |
| row.append(mkt.get(f"snapshot_total_{snap}", 220.0)) |
| row.append(mkt.get(f"snapshot_ml_home_{snap}", -110.0)) |
| |
| row.extend([0.5, 0.5, 0.5, 0.5, 0.0, 0.5, 0.0, 0.0]) |
| |
| for ctx in ["same_matchup", "same_spread_range", "same_total_range", |
| "same_rest_pattern", "same_season_phase"]: |
| row.append(0.5) |
| row.append(0.0) |
|
|
| |
| |
| for stat in ["rim_att_rate", "midrange_rate", "corner3_rate", "above_break3_rate", |
| "pullup_rate", "catch_shoot_rate", "isolation_rate", "pnr_ball_handler", |
| "pnr_roll_man", "post_up_rate", "transition_freq", "cut_freq"]: |
| for w in [5, 10]: |
| row.extend([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) |
| |
| for zone in ["paint", "midrange", "corner3", "above_break3", "rim"]: |
| for w in [5, 10]: |
| row.extend([0.3, 0.3, 0.3, 0.3, 0.0]) |
| |
| for pace_cat in ["ultra_fast", "fast", "average", "slow", "ultra_slow"]: |
| row.extend([0.5, 0.5, 0.0, 0.0]) |
|
|
| |
| for prefix, team_key, tr in [("h", home, hr_), ("a", away, ar_)]: |
| for prior in ["flat", "preseason", "historical", "market_implied", "composite"]: |
| row.extend([0.5, 0.5, 0.0]) |
| for w in [10, 20, 40]: |
| row.extend([0.5, 0.5, 0.5, 0.0]) |
| row.extend([0.5, 0.0, 0.0, 0.0]) |
| row.extend([0.0, 0.5, 0.7, 0.0]) |
| row.extend([0.0, 0.0, 0.5]) |
| for prior in ["flat", "preseason", "historical", "market_implied", "composite"]: |
| row.append(0.0) |
| row.extend([0.0] * 8) |
|
|
| |
| for prefix, team_key, tr in [("h", home, hr_), ("a", away, ar_)]: |
| |
| row.extend([0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]) |
| |
| for w in [10, 20, 82]: |
| row.extend([0.0, 0.0, 0.0]) |
| |
| row.extend([0.5, 0.5, 0.5, 0.5, 0.5]) |
| |
| for depth in [2, 3, 4]: |
| row.extend([0.0, 0.0]) |
| |
| row.extend([0.5, 0.5]) |
| |
| row.extend([0.5, 0.5, 0.5]) |
| |
| row.extend([0.5, 0.5]) |
| row.extend([0.0] * 12) |
|
|
| |
| for m1, m2 in [("xgboost", "lightgbm"), ("xgboost", "catboost"), ("xgboost", "rf"), |
| ("xgboost", "logistic"), ("lightgbm", "catboost"), ("lightgbm", "rf"), |
| ("lightgbm", "logistic"), ("catboost", "rf"), ("catboost", "logistic"), |
| ("rf", "logistic")]: |
| row.extend([0.5, 0.0, 0.5]) |
| for fg in ["rolling", "four_factors", "pace", "scoring", "momentum", |
| "rest", "market", "matchup", "context", "power_rating"]: |
| row.extend([0.0, 0.0, 0.0]) |
| for ctx in ["home_fav", "home_dog", "high_total", "low_total", "b2b", |
| "rest_adv", "rivalry", "non_conf", "playoff_race", "tanking"]: |
| row.extend([0.5, 0.0, 0.25]) |
| for w in [5, 10, 20, 50]: |
| row.extend([0.5, 0.25, 0.0, 0.0, 0.5]) |
| for model in _meta_models: |
| row.extend([0.5, 0.5, 0.0, 220.0 / 250.0]) |
|
|
| |
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| recent = tr[-30:] |
| for kernel in ["gaussian", "triangular", "epanechnikov"]: |
| for bw in [3, 7, 15]: |
| for stat in ["wp", "margin", "ortg", "drtg", "pace"]: |
| fn = _td_stat_fn.get(stat, lambda r: 0.0) |
| |
| if kernel == "gaussian": |
| hl = bw * 0.7 |
| elif kernel == "triangular": |
| hl = bw * 0.5 |
| else: |
| hl = bw * 0.6 |
| row.append(_decay_weighted(recent, fn, hl)) |
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for stat in ["wp", "margin", "ortg", "drtg"]: |
| key_fn = _STAT_KEY_17.get(stat) |
| if key_fn and len(tr) >= 20: |
| vals = [key_fn(r) for r in tr[-20:]] |
| first_10 = vals[:10] |
| last_10 = vals[10:] |
| m1 = sum(first_10) / 10 |
| m2 = sum(last_10) / 10 |
| row.append(abs(m2 - m1)) |
| row.append(10.0) |
| row.append(m2) |
| |
| cusum = 0.0 |
| grand_mean = sum(vals) / len(vals) |
| for v in vals: |
| cusum = max(0, cusum + (v - grand_mean)) |
| row.append(cusum / max(len(vals), 1)) |
| else: |
| row.extend([0.0, 10.0, 0.0, 0.0]) |
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for pct in [10, 25, 50, 75, 90]: |
| for stat in ["margin", "ppg", "ortg"]: |
| key_fn = _STAT_KEY_17.get(stat) |
| if key_fn and len(tr) >= 10: |
| vals = sorted([key_fn(r) for r in tr[-20:]]) |
| idx = int(len(vals) * pct / 100.0) |
| idx = min(idx, len(vals) - 1) |
| row.append(vals[idx]) |
| else: |
| row.append(0.0) |
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| recent = tr[-30:] |
| for stat in ["wp", "margin", "ortg", "drtg", "pace"]: |
| fn = _td_stat_fn.get(stat, lambda r: 0.0) |
| if len(recent) >= 10: |
| vals = [fn(r) for r in recent] |
| vol = _std_17(vals) |
| adaptive_hl = max(3, min(20, 10 / max(vol, 0.01))) |
| row.append(_decay_weighted(recent, fn, adaptive_hl)) |
| row.append(adaptive_hl / 20.0) |
| else: |
| row.extend([0.0, 0.5]) |
|
|
| |
| if self.include_market: |
| for stat in ["wp", "margin", "ortg"]: |
| for hl in [3, 10]: |
| h_d = _decay_weighted(hr_[-30:], _td_stat_fn.get(stat, lambda r: 0.0), hl) |
| mkt_val = _val2("current_spread") |
| row.append(h_d * mkt_val) |
| row.append(h_d * _val2("current_total")) |
| row.append(h_d * _val2("current_ml_home")) |
|
|
| |
| for prefix, team_key in [("h", home), ("a", away)]: |
| bay_wp = self._wp(team_results[team_key], len(team_results[team_key])) |
| elo_v = (team_elo[team_key] - 1500) / 400.0 |
| row.append((bay_wp + elo_v) / 2.0) |
| row.append(bay_wp - elo_v) |
| row.append(bay_wp * 0.7 + elo_v * 0.3) |
| row.append(min(1.0, len(team_results[team_key]) / 30.0)) |
|
|
| |
| row.extend([0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| for prefix, team_key in [("h", home), ("a", away)]: |
| tr = hr_ if prefix == "h" else ar_ |
| rest_here = self._rest_days(team_key, gd, team_last) |
| row.append(1.0 if rest_here <= 1 else 0.0) |
| row.append(1.0 if rest_here >= 3 else 0.0) |
| row.append(h_depth if prefix == "h" else a_depth) |
| row.append(1.0 if rest_here <= 1 and len(tr) > 70 else 0.0) |
|
|
| |
| row.extend([0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for stat in ["wp", "margin", "ortg"]: |
| fn = _ts_stat_fn[stat] |
| trend = fn(tr, 5) - fn(tr, 20) if len(tr) >= 20 else 0.0 |
| row.append(trend) |
| row.append(trend * abs(self._pace(hr_, 10) - self._pace(ar_, 10)) / 10.0) |
|
|
| |
| if self.include_market: |
| row.extend([0.0, 0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| h_edge = (_val2("elo_diff") + _val2("rest_advantage") * 0.5) / 2.0 |
| row.append(h_edge) |
| row.append(h_edge * 0.5) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.5) |
| row.append(0.0) |
| row.append(h_edge * 0.3) |
| row.append(0.0) |
|
|
| |
| _new_sq_features = [] |
| for prefix in ["h", "a"]: |
| _new_sq_features.extend([ |
| f"{prefix}_star1_plus_minus_10", |
| f"{prefix}_star1_usage_rate_10", |
| f"{prefix}_star_combined_plus_minus", |
| f"{prefix}_chemistry_starting5", |
| f"bayes2_{prefix}_rating_composite", |
| f"bayes2_{prefix}_coach_expected_wp", |
| f"net2_{prefix}_colley_rating", |
| f"net2_{prefix}_massey_rating", |
| ]) |
| for feat in _new_sq_features: |
| v = _val2(feat) |
| row.append(v * v) |
|
|
| |
| _new_inter_pairs = [ |
| ("h_star1_plus_minus_10", "a_star1_plus_minus_10"), |
| ("h_star1_usage_rate_10", "a_star1_usage_rate_10"), |
| ("h_chemistry_starting5", "a_chemistry_starting5"), |
| ("pi_star1_rating_diff", "elo_diff"), |
| ("pi_combined_star_diff", "rest_advantage"), |
| ("pi_talent_depth_diff", "fatigue_composite_edge"), |
| ("xteam_overall_style_clash", "elo_diff"), |
| ("xteam_pace_war_indicator", "h_pace10"), |
| ("xteam_mismatch_severity", "current_spread"), |
| ("bayes_preseason_diff", "h_wp10"), |
| ("bayes_franchise_strength_diff", "elo_diff"), |
| ("bayes_coach_wp_diff", "rest_advantage"), |
| ("net_pagerank_diff_82", "elo_diff"), |
| ("net_pagerank_diff_20", "h_wp10"), |
| ("net_clustering_diff", "xteam_overall_style_clash"), |
| ("net_eigenvector_diff", "bayes_composite_diff"), |
| ("ref_home_bias_composite", "venue_home_elevation_advantage"), |
| ("ref_pace_impact_composite", "xteam_pace_war_indicator"), |
| ("env_combined_venue_advantage", "rest_advantage"), |
| ("env_combined_travel_disruption", "fatigue_composite_edge"), |
| ("grand_composite_edge", "elo_diff"), |
| ("grand_composite_edge", "current_spread"), |
| ("grand_multi_signal_agreement", "meta2_ensemble_mean_prob"), |
| ("grand_confidence_weighted_edge", "meta2_edge_confidence_product"), |
| ] |
| for x_feat, y_feat in _new_inter_pairs: |
| row.append(_val2(x_feat) * _val2(y_feat)) |
|
|
| |
| for x_feat, y_feat in [ |
| ("h_star1_plus_minus_10", "a_star1_plus_minus_10"), |
| ("h_chemistry_starting5", "a_chemistry_starting5"), |
| ("h_star_combined_plus_minus", "a_star_combined_plus_minus"), |
| ]: |
| denom = _val2(y_feat) |
| row.append(_val2(x_feat) / (denom + 0.001) if abs(denom) > 0.0001 else 1.0) |
|
|
| |
| _triple_combos = [ |
| ("elo_diff", "rest_advantage", "pi_combined_star_diff"), |
| ("elo_diff", "xteam_mismatch_severity", "net_pagerank_diff_82"), |
| ("elo_diff", "bayes_composite_diff", "meta2_ensemble_mean_prob"), |
| ("current_spread", "pi_star1_rating_diff", "ref_home_bias_composite"), |
| ("h_wp10", "a_wp10", "xteam_overall_style_clash"), |
| ("h_ortg10", "a_drtg10", "xteam_offensive_edge_composite"), |
| ("rest_advantage", "env_combined_venue_advantage", "ref_pace_impact_composite"), |
| ("fatigue_composite_edge", "pi_talent_depth_diff", "bayes_roster_stability_diff"), |
| ] |
| for a_f, b_f, c_f in _triple_combos: |
| row.append(_val2(a_f) * _val2(b_f) * _val2(c_f)) |
|
|
| |
| _decay_roll_stats_list = ["wp", "margin", "ortg", "drtg"] |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| recent = tr[-30:] |
| for stat in _decay_roll_stats_list: |
| fn = _td_stat_fn.get(stat, lambda r: 0.0) |
| for hl in [3, 10]: |
| dw = _decay_weighted(recent, fn, hl) |
| season_avg = sum(fn(r) for r in tr) / max(len(tr), 1) if tr else 0.0 |
| vol = _std_17([fn(r) for r in recent]) if len(recent) >= 3 else 0.0 |
| row.append(vol) |
| row.append(dw - season_avg) |
| z = (dw - season_avg) / max(vol, 1e-6) |
| row.append(z) |
|
|
| |
| for prefix, team_key in [("h", home), ("a", away)]: |
| tr = team_results[team_key] |
| for w in [10, 20]: |
| row.append(0.0) |
| row.append(0.0) |
| row.append(0.0) |
|
|
| |
| for prefix in ["h", "a"]: |
| for w in [10, 20]: |
| row.extend([0.0, 0.0, 0.0]) |
|
|
| |
| for stat in ["pace", "ortg", "drtg"]: |
| fn = _xteam_stat_fn[stat] |
| for w in [5, 10]: |
| row.append(abs(fn(hr_, w) - fn(ar_, w)) / 10.0) |
| h_trend = fn(hr_, 5) - fn(hr_, 20) if len(hr_) >= 20 else 0.0 |
| a_trend = fn(ar_, 5) - fn(ar_, 20) if len(ar_) >= 20 else 0.0 |
| row.append(h_trend - a_trend) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| wp = self._wp(tr, 10) |
| row.append(wp) |
| row.append(wp) |
| row.append(1.0 - wp) |
| row.append(0.5) |
| row.append(0.5) |
|
|
| |
| row.extend([0.0] * 10) |
|
|
| |
| _ext_stats = ["net_rating", "ast_to_tov", "efg_minus_opp_efg", |
| "pace_adj_margin", "sos_adj_margin", "opponent_efg", |
| "three_pt_rate_diff", "paint_rate_diff", "transition_rate", |
| "halfcourt_efficiency"] |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| ortg_v = self._ortg(tr, 10) |
| drtg_v = self._drtg(tr, 10) |
| for stat_idx, stat in enumerate(_ext_stats): |
| |
| if stat == "net_rating": |
| base = ortg_v - drtg_v |
| elif stat == "ast_to_tov": |
| base = self._ast_rate(tr, 10) / max(self._tov_rate(tr, 10), 0.01) |
| elif stat == "efg_minus_opp_efg": |
| base = self._efg(tr, 10) - self._opp_efg(tr, 10) |
| elif stat == "pace_adj_margin": |
| base = (ortg_v - drtg_v) * self._pace(tr, 10) / 100.0 |
| elif stat == "sos_adj_margin": |
| base = (ortg_v - drtg_v) * self._sos(tr, team_results, 10) |
| elif stat == "opponent_efg": |
| base = self._opp_efg(tr, 10) |
| elif stat == "three_pt_rate_diff": |
| base = self._stat_avg(tr, 10, "fg3_pct") - self._stat_avg(tr, 10, "opp_fg3_pct") |
| elif stat == "paint_rate_diff": |
| base = self._stat_avg(tr, 10, "paint_pts") - self._stat_avg(tr, 10, "opp_paint_pts") |
| elif stat == "transition_rate": |
| base = self._stat_avg(tr, 10, "fb_pts") / max(self._ppg(tr, 10), 1) |
| else: |
| base = ortg_v / 110.0 |
| for w in WINDOWS: |
| |
| factor = min(1.0, w / 10.0) |
| row.append(base * factor) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for stat in ["net_rating", "ast_to_tov", "efg_minus_opp_efg", |
| "pace_adj_margin", "three_pt_rate_diff"]: |
| for alpha_str in ["01", "02", "05", "08"]: |
| alpha = {"01": 0.1, "02": 0.2, "05": 0.5, "08": 0.8}[alpha_str] |
| vals = [] |
| for r in tr[-20:]: |
| o = r[4].get("ortg", 100) |
| d = r[4].get("drtg", 100) |
| if stat == "net_rating": |
| vals.append(o - d) |
| elif stat == "ast_to_tov": |
| vals.append(r[4].get("ast_rate", 0.6) / max(r[4].get("tov_rate", 0.14), 0.01)) |
| elif stat == "efg_minus_opp_efg": |
| vals.append(r[4].get("efg_pct", 0.5) - r[4].get("opp_efg_pct", 0.5)) |
| elif stat == "pace_adj_margin": |
| vals.append((o - d) * r[4].get("pace", 100) / 100.0) |
| else: |
| vals.append(r[4].get("fg3_pct", 0.36) - r[4].get("opp_fg3_pct", 0.36)) |
| row.append(_ewma_val(vals, alpha) if vals else 0.0) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for stat in ["net_rating", "ast_to_tov", "efg_minus_opp_efg", |
| "pace_adj_margin", "transition_rate"]: |
| for w in [5, 10, 20]: |
| recent = tr[-w:] |
| if len(recent) >= 3: |
| vals = [] |
| for r in recent: |
| o = r[4].get("ortg", 100) |
| d = r[4].get("drtg", 100) |
| if stat == "net_rating": |
| vals.append(o - d) |
| elif stat == "ast_to_tov": |
| vals.append(r[4].get("ast_rate", 0.6) / max(r[4].get("tov_rate", 0.14), 0.01)) |
| elif stat == "efg_minus_opp_efg": |
| vals.append(r[4].get("efg_pct", 0.5) - r[4].get("opp_efg_pct", 0.5)) |
| elif stat == "pace_adj_margin": |
| vals.append((o - d) * r[4].get("pace", 100) / 100.0) |
| else: |
| vals.append(r[4].get("fb_pts", 10) / max(r[4].get("pts", 100), 1)) |
| row.append(_std_17(vals)) |
| else: |
| row.append(0.0) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for stat in _ext_stats: |
| vals = [] |
| for r in tr: |
| o = r[4].get("ortg", 100) |
| d = r[4].get("drtg", 100) |
| if stat == "net_rating": |
| vals.append(o - d) |
| elif stat == "ast_to_tov": |
| vals.append(r[4].get("ast_rate", 0.6) / max(r[4].get("tov_rate", 0.14), 0.01)) |
| elif stat == "efg_minus_opp_efg": |
| vals.append(r[4].get("efg_pct", 0.5) - r[4].get("opp_efg_pct", 0.5)) |
| elif stat == "pace_adj_margin": |
| vals.append((o - d) * r[4].get("pace", 100) / 100.0) |
| elif stat == "sos_adj_margin": |
| vals.append(o - d) |
| elif stat == "opponent_efg": |
| vals.append(r[4].get("opp_efg_pct", 0.5)) |
| elif stat == "three_pt_rate_diff": |
| vals.append(r[4].get("fg3_pct", 0.36) - r[4].get("opp_fg3_pct", 0.36)) |
| elif stat == "paint_rate_diff": |
| vals.append(r[4].get("paint_pts", 40) - r[4].get("opp_paint_pts", 40)) |
| elif stat == "transition_rate": |
| vals.append(r[4].get("fb_pts", 10) / max(r[4].get("pts", 100), 1)) |
| else: |
| vals.append(o / 110.0) |
| if len(vals) >= 10: |
| s_mean = sum(vals) / len(vals) |
| s_std = _std_17(vals) |
| recent_vals = vals[-5:] |
| r_mean = sum(recent_vals) / len(recent_vals) |
| row.append((r_mean - s_mean) / max(s_std, 1e-6)) |
| else: |
| row.append(0.0) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for stat in _ext_stats: |
| base_fn = _xteam_stat_fn.get("pace", lambda t, w: 0.0) |
| for w1, w2 in [(3, 10), (5, 20), (3, 20), (5, 10), (10, 20)]: |
| if stat == "net_rating": |
| v1 = self._netrtg(tr, w1) |
| v2 = self._netrtg(tr, w2) |
| elif stat == "pace_adj_margin": |
| v1 = self._netrtg(tr, w1) * self._pace(tr, w1) / 100.0 |
| v2 = self._netrtg(tr, w2) * self._pace(tr, w2) / 100.0 |
| else: |
| v1 = self._ortg(tr, w1) - self._drtg(tr, w1) |
| v2 = self._ortg(tr, w2) - self._drtg(tr, w2) |
| row.append(v1 - v2) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for stat in ["net_rating", "ast_to_tov", "efg_minus_opp_efg", |
| "pace_adj_margin", "sos_adj_margin"]: |
| for w1, w2 in [(3, 5), (3, 10), (3, 20), (5, 10), (5, 20), |
| (7, 15), (7, 20), (10, 20)]: |
| v1 = self._netrtg(tr, w1) |
| v2 = self._netrtg(tr, w2) |
| row.append(v1 - v2) |
| v1_prev = self._netrtg(tr[:-1], w1) if len(tr) > w1 + 1 else v1 |
| v2_prev = self._netrtg(tr[:-1], w2) if len(tr) > w2 + 1 else v2 |
| row.append((v1 - v2) - (v1_prev - v2_prev)) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for stat in ["net_rating", "ast_to_tov", "efg_minus_opp_efg", |
| "pace_adj_margin", "sos_adj_margin"]: |
| short = self._netrtg(tr, 3) |
| long = self._netrtg(tr, 20) if len(tr) >= 20 else self._netrtg(tr, max(len(tr), 3)) |
| mid = self._netrtg(tr, 10) if len(tr) >= 10 else self._netrtg(tr, max(len(tr), 3)) |
| row.append(short - mid) |
| row.append(long - mid) |
| vol_s = _std_17([self._netrtg(tr, w) for w in [3, 5, 10, 20] if len(tr) >= w]) |
| row.append(vol_s if vol_s else 0.0) |
| row.append(1.0 if short > long + 3 else 0.0) |
| row.append(1.0 if short < long - 3 else 0.0) |
|
|
| |
| _core_features = ["h_wp10", "a_wp10", "elo_diff", "current_spread", |
| "h_netrtg10", "a_netrtg10", "rest_advantage", |
| "h_ortg10", "a_drtg10", "h_consistency"] |
| _new_key_features = [ |
| "pi_combined_star_diff", "pi_talent_depth_diff", |
| "xteam_overall_style_clash", "xteam_mismatch_severity", |
| "bayes_composite_diff", "net_pagerank_diff_82", |
| "ref_home_bias_composite", "env_combined_venue_advantage", |
| "meta2_ensemble_mean_prob", "grand_composite_edge", |
| ] |
| for core_f in _core_features: |
| for new_f in _new_key_features: |
| row.append(_val2(core_f) * _val2(new_f)) |
|
|
| |
| _opp_tiers = ["elite", "good", "average", "bad", "terrible"] |
| _tier_bounds = [(0.65, 1.0), (0.55, 0.65), (0.45, 0.55), (0.35, 0.45), (0.0, 0.35)] |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for tier, (lo, hi) in zip(_opp_tiers, _tier_bounds): |
| tier_games = [r for r in tr if lo <= self._wp(team_results[r[3]], 82) < hi] |
| for stat_name, stat_fn in [ |
| ("wp", lambda t, n: self._wp(t, n)), |
| ("margin", lambda t, n: self._pd(t, n)), |
| ("ortg", lambda t, n: self._ortg(t, n)), |
| ("drtg", lambda t, n: self._drtg(t, n)), |
| ("efg", lambda t, n: self._efg(t, n)), |
| ("pace", lambda t, n: self._pace(t, n)), |
| ]: |
| if tier_games: |
| row.append(stat_fn(tier_games, len(tier_games))) |
| else: |
| row.append(0.5 if stat_name == "wp" else 0.0) |
|
|
| |
| for prefix, team_key in [("h", home), ("a", away)]: |
| for loc, loc_results in [("home_only", team_home_results.get(team_key, [])), |
| ("away_only", team_away_results.get(team_key, []))]: |
| for stat_name, stat_fn in [ |
| ("wp", lambda t, n: self._wp(t, n)), |
| ("margin", lambda t, n: self._pd(t, n)), |
| ("ortg", lambda t, n: self._ortg(t, n)), |
| ("drtg", lambda t, n: self._drtg(t, n)), |
| ("pace", lambda t, n: self._pace(t, n)), |
| ("efg", lambda t, n: self._efg(t, n)), |
| ]: |
| for w in [5, 10, 20]: |
| if loc_results: |
| row.append(stat_fn(loc_results, w)) |
| else: |
| row.append(0.5 if stat_name == "wp" else 0.0) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for d_idx in range(7): |
| d_games = [r for r in tr if self._get_dow(r[0]) == d_idx] |
| if d_games: |
| row.append(self._wp(d_games, len(d_games))) |
| row.append(self._pd(d_games, len(d_games)) / 15.0) |
| else: |
| row.extend([0.5, 0.0]) |
|
|
| |
| _month_map = {"oct": 10, "nov": 11, "dec": 12, "jan": 1, "feb": 2, "mar": 3, "apr": 4} |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for m_name, m_num in _month_map.items(): |
| m_games = [r for r in tr if self._get_month(r[0]) == m_num] |
| if m_games: |
| row.append(self._wp(m_games, len(m_games))) |
| row.append(self._pd(m_games, len(m_games)) / 15.0) |
| else: |
| row.extend([0.5, 0.0]) |
|
|
| |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for pattern in ["ww", "wl", "lw", "ll"]: |
| pattern_games = [] |
| for i in range(2, len(tr)): |
| p1 = "w" if tr[i-2][1] else "l" |
| p2 = "w" if tr[i-1][1] else "l" |
| if p1 + p2 == pattern: |
| pattern_games.append(tr[i]) |
| row.append(self._wp(pattern_games, len(pattern_games)) if pattern_games else 0.5) |
| for streak_len in [2, 3, 4, 5]: |
| |
| ws_games = [] |
| for i in range(streak_len, len(tr)): |
| if all(tr[i - j - 1][1] for j in range(streak_len)): |
| ws_games.append(tr[i]) |
| row.append(self._wp(ws_games, len(ws_games)) if ws_games else 0.5) |
| |
| ls_games = [] |
| for i in range(streak_len, len(tr)): |
| if all(not tr[i - j - 1][1] for j in range(streak_len)): |
| ls_games.append(tr[i]) |
| row.append(self._wp(ls_games, len(ls_games)) if ls_games else 0.5) |
|
|
| |
| _margin_buckets = { |
| "blowout_win": (15, 100), |
| "comfortable_win": (6, 14), |
| "close_win": (1, 5), |
| "close_loss": (-5, -1), |
| "comfortable_loss": (-14, -6), |
| "blowout_loss": (-100, -15), |
| } |
| for prefix, tr in [("h", hr_), ("a", ar_)]: |
| for bucket_name, (lo, hi) in _margin_buckets.items(): |
| bucket_games = [r for r in tr if lo <= r[2] <= hi] |
| row.append(len(bucket_games) / max(len(tr), 1)) |
| |
| next_games = [] |
| for i in range(1, len(tr)): |
| if lo <= tr[i-1][2] <= hi: |
| next_games.append(tr[i]) |
| row.append(self._wp(next_games, len(next_games)) if next_games else 0.5) |
|
|
| |
| for prefix, team_key in [("h", home), ("a", away)]: |
| qd_ = (quarter_data or {}).get(team_key, {}) |
| for q in ["q1", "q2", "q3", "q4"]: |
| for stat in ["margin", "ortg", "drtg", "pace", "efg", "tov_rate", "ft_rate"]: |
| row.append(qd_.get(f"{q}_{stat}", 0.0)) |
|
|
| |
| _ALPHA36 = {"005": 0.05, "015": 0.15, "025": 0.25, "04": 0.4, "07": 0.7} |
| _STAT_FN36 = { |
| "wp": lambda tr, w: self._wp(tr, w), |
| "pd": lambda tr, w: self._pd(tr, w), |
| "ppg": lambda tr, w: self._ppg(tr, w), |
| "papg": lambda tr, w: self._papg(tr, w), |
| "margin": lambda tr, w: self._avg_margin(tr, w), |
| "close": lambda tr, w: self._close_pct(tr, w), |
| "blowout": lambda tr, w: self._blowout_pct(tr, w), |
| "ou_avg": lambda tr, w: self._ou_avg(tr, w), |
| } |
| for _pfx36, _tr36 in [("h", hr_), ("a", ar_)]: |
| |
| _n36 = min(20, len(_tr36)) |
| _series36 = {} |
| for _st36 in _STAT_FN36: |
| _series36[_st36] = [] |
| for _i36 in range(max(0, len(_tr36) - _n36), len(_tr36)): |
| _sub = _tr36[:_i36+1] |
| _series36[_st36].append(_STAT_FN36[_st36](_sub, min(5, len(_sub)))) |
|
|
| |
| for _st36 in ["wp", "pd", "ppg", "papg", "margin", "close", "blowout", "ou_avg"]: |
| _vals = _series36.get(_st36, []) |
| for _ak36 in ["005", "015", "025", "04", "07"]: |
| row.append(_ewma_val(_vals, _ALPHA36[_ak36]) if _vals else 0.0) |
|
|
| |
| for _st36 in ["wp", "pd", "ppg", "papg", "margin", "close", "blowout", "ou_avg"]: |
| _vals = _series36.get(_st36, []) |
| if _vals: |
| row.append(_ewma_val(_vals, 0.7) - _ewma_val(_vals, 0.05)) |
| else: |
| row.append(0.0) |
|
|
| |
| _h_wp5 = self._wp(hr_, 5) |
| _a_wp5 = self._wp(ar_, 5) |
| _h_margin5 = self._avg_margin(hr_, 5) |
| _a_margin5 = self._avg_margin(ar_, 5) |
| _h_ortg = self._stat_avg(hr_, 10, "ortg") |
| _a_ortg = self._stat_avg(ar_, 10, "ortg") |
| _h_b2b = 1.0 if h_rest <= 1 else 0.0 |
| _a_b2b = 1.0 if a_rest <= 1 else 0.0 |
| _h_fatigue = self._fatigue_score(hr_, gd, home, h_rest) if hasattr(self, '_fatigue_score') else 0.0 |
| _a_fatigue = self._fatigue_score(ar_, gd, away, a_rest) if hasattr(self, '_fatigue_score') else 0.0 |
| row.extend([ |
| min(h_rest, 7) * _h_wp5, |
| min(a_rest, 7) * _a_wp5, |
| _h_b2b * _h_margin5, |
| _a_b2b * _a_margin5, |
| _h_fatigue * _h_ortg, |
| _a_fatigue * _a_ortg, |
| (h_rest - a_rest) * (_h_wp5 - _a_wp5), |
| (_h_b2b - _a_b2b) * (_h_margin5 - _a_margin5), |
| min(h_rest, 7) ** 2 / 49.0, |
| min(a_rest, 7) ** 2 / 49.0, |
| (h_rest - a_rest) * (self._travel_dist(hr_, home) if hasattr(self, '_travel_dist') else 0.0), |
| (self._games_in_window(hr_, gd, 7) - self._games_in_window(ar_, gd, 7)) * (_h_margin5 - _a_margin5), |
| ]) |
|
|
| |
| _movda_dr = team_movda[home] - team_movda[away] |
| _movda_wp = 1.0 / (1.0 + 10.0 ** (-_movda_dr / _MOVDA_C)) |
| for _mt, _mk in [(home, home), (away, away)]: |
| row.append((team_movda[_mk] - 1500.0) / 400.0) |
| row.append(mov_surprise_ewm[_mk] / 20.0) |
| row.append(_movda_dr / 400.0) |
| row.append(_movda_wp) |
| |
| for _mk in [home, away]: |
| _dh = delta_mov_history[_mk] |
| _raw = (_dh[-1] / 20.0) if _dh else 0.0 |
| _roll5 = (sum(_dh[-5:]) / len(_dh[-5:]) / 20.0) if _dh else 0.0 |
| _roll10 = (sum(_dh[-10:]) / len(_dh[-10:]) / 20.0) if _dh else 0.0 |
| row.append(_raw) |
| row.append(_roll5) |
| row.append(_roll10) |
| |
| _h_dh = delta_mov_history[home] |
| _a_dh = delta_mov_history[away] |
| _h_r5 = (sum(_h_dh[-5:]) / len(_h_dh[-5:]) / 20.0) if _h_dh else 0.0 |
| _a_r5 = (sum(_a_dh[-5:]) / len(_a_dh[-5:]) / 20.0) if _a_dh else 0.0 |
| row.append(_h_r5 - _a_r5) |
|
|
| |
| |
| _h_home_tr = team_home_results.get(home, []) |
| _a_away_tr = team_away_results.get(away, []) |
| for _w38 in [5, 10, 20]: |
| |
| _h_home_wp = self._wp(_h_home_tr, _w38) if _h_home_tr else self._wp(hr_, _w38) |
| _a_road_wp = self._wp(_a_away_tr, _w38) if _a_away_tr else self._wp(ar_, _w38) |
| row.append(_h_home_wp - _a_road_wp) |
| |
| _h_home_mg = self._pd(_h_home_tr, _w38) if _h_home_tr else self._pd(hr_, _w38) |
| _a_road_mg = self._pd(_a_away_tr, _w38) if _a_away_tr else self._pd(ar_, _w38) |
| row.append(_h_home_mg - _a_road_mg) |
| |
| _h_home_or = self._ortg(_h_home_tr, _w38) if _h_home_tr else self._ortg(hr_, _w38) |
| _a_road_dr = self._drtg(_a_away_tr, _w38) if _a_away_tr else self._drtg(ar_, _w38) |
| row.append((_h_home_or - _a_road_dr) / 10.0) |
| |
| _h_home_dr = self._drtg(_h_home_tr, _w38) if _h_home_tr else self._drtg(hr_, _w38) |
| _a_road_or = self._ortg(_a_away_tr, _w38) if _a_away_tr else self._ortg(ar_, _w38) |
| row.append((_h_home_dr - _a_road_or) / 10.0) |
| |
| _h_overall_wp = self._wp(hr_, len(hr_)) if hr_ else 0.5 |
| _h_home_wp_82 = self._wp(_h_home_tr, len(_h_home_tr)) if _h_home_tr else _h_overall_wp |
| row.append(_h_home_wp_82 - _h_overall_wp) |
| |
| _a_overall_wp = self._wp(ar_, len(ar_)) if ar_ else 0.5 |
| _a_road_wp_82 = self._wp(_a_away_tr, len(_a_away_tr)) if _a_away_tr else _a_overall_wp |
| row.append(_a_overall_wp - _a_road_wp_82) |
|
|
| |
| |
| try: |
| _h_dist = self._travel_dist(hr_, home) / 500.0 |
| _a_dist = self._travel_dist(ar_, away) / 500.0 |
| _h_tz = abs(TIMEZONE_ET.get(home, 0) - TIMEZONE_ET.get(self._last_location(hr_), 0)) |
| _a_tz = abs(TIMEZONE_ET.get(away, 0) - TIMEZONE_ET.get(self._last_location(ar_), 0)) |
| _h_b2b = 1.0 if h_rest <= 1 else 0.0 |
| _a_b2b = 1.0 if a_rest <= 1 else 0.0 |
| |
| _h_fatigue = _h_dist + _h_tz * 0.5 + _h_b2b * 2.0 - min(h_rest, 4) * 0.3 |
| _a_fatigue = _a_dist + _a_tz * 0.5 + _a_b2b * 2.0 - min(a_rest, 4) * 0.3 |
| |
| _h_rest_nl = min(h_rest, 5) ** 0.5 - min(a_rest, 5) ** 0.5 |
| row.extend([ |
| min(_h_dist, 6.0), |
| min(_a_dist, 6.0), |
| float(_h_tz), |
| float(_a_tz), |
| max(-3.0, min(5.0, _h_fatigue)), |
| max(-3.0, min(5.0, _a_fatigue)), |
| max(-5.0, min(5.0, _a_fatigue - _h_fatigue)), |
| max(-2.0, min(2.0, _h_rest_nl)), |
| ]) |
| except Exception: |
| row.extend([0.0] * 8) |
|
|
| |
| |
| try: |
| _h_fb_rate = self._stat_avg(hr_, 10, "fb_pts") / max(self._ppg(hr_, 10), 1.0) |
| _a_fb_rate = self._stat_avg(ar_, 10, "fb_pts") / max(self._ppg(ar_, 10), 1.0) |
| _h_pace10 = self._pace(hr_, 10) |
| _a_pace10 = self._pace(ar_, 10) |
| |
| _h_hc_eff = (self._ppg(hr_, 10) * (1.0 - _h_fb_rate)) / max(_h_pace10, 60.0) * 100.0 |
| _a_hc_eff = (self._ppg(ar_, 10) * (1.0 - _a_fb_rate)) / max(_a_pace10, 60.0) * 100.0 |
| |
| _h_pace_fb = (_h_pace10 / 100.0) * _h_fb_rate |
| _a_pace_fb = (_a_pace10 / 100.0) * _a_fb_rate |
| row.extend([ |
| min(_h_fb_rate, 0.5), |
| min(_a_fb_rate, 0.5), |
| min(_h_hc_eff / 120.0, 1.2), |
| min(_a_hc_eff / 120.0, 1.2), |
| _h_fb_rate - _a_fb_rate, |
| _h_pace_fb - _a_pace_fb, |
| (_h_hc_eff - _a_hc_eff) / 20.0, |
| ]) |
| except Exception: |
| row.extend([0.0] * 7) |
|
|
| |
| |
| try: |
| _h_clutch = [r for r in hr_[-30:] if abs(r[2]) <= 5] |
| _a_clutch = [r for r in ar_[-30:] if abs(r[2]) <= 5] |
| _h_cwp = self._wp(_h_clutch, len(_h_clutch)) if _h_clutch else 0.5 |
| _a_cwp = self._wp(_a_clutch, len(_a_clutch)) if _a_clutch else 0.5 |
| _h_cmg = self._pd(_h_clutch, len(_h_clutch)) if _h_clutch else 0.0 |
| _a_cmg = self._pd(_a_clutch, len(_a_clutch)) if _a_clutch else 0.0 |
| |
| _h_cortg = (sum(r[4].get("ortg", 100.0) for r in _h_clutch) / len(_h_clutch) |
| if _h_clutch else self._ortg(hr_, 10)) |
| _a_cortg = (sum(r[4].get("ortg", 100.0) for r in _a_clutch) / len(_a_clutch) |
| if _a_clutch else self._ortg(ar_, 10)) |
| row.extend([ |
| _h_cwp, |
| _a_cwp, |
| _h_cmg / 10.0, |
| _a_cmg / 10.0, |
| (_h_cortg - 100.0) / 20.0, |
| (_a_cortg - 100.0) / 20.0, |
| _h_cwp - _a_cwp, |
| (_h_cmg - _a_cmg) / 10.0, |
| ]) |
| except Exception: |
| row.extend([0.0] * 8) |
|
|
| |
| |
| |
| try: |
| _league_ppg = 110.0 |
| _h_ppg10 = self._ppg(hr_, 10) |
| _a_ppg10 = self._ppg(ar_, 10) |
| _h_pap10 = self._papg(hr_, 10) |
| _a_pap10 = self._papg(ar_, 10) |
| |
| _h_ppg_n = _h_ppg10 / _league_ppg |
| _a_ppg_n = _a_ppg10 / _league_ppg |
| _h_pap_n = _h_pap10 / _league_ppg |
| _a_pap_n = _a_pap10 / _league_ppg |
| |
| |
| _matchup_total = ((_h_ppg10 + _a_pap10) / 2.0 + (_a_ppg10 + _h_pap10) / 2.0) / 220.0 |
| |
| _h_pace10 = self._pace(hr_, 10) |
| _a_pace10 = self._pace(ar_, 10) |
| _avg_pace = (_h_pace10 + _a_pace10) / 2.0 |
| |
| _pace_sum_n = _avg_pace / 97.0 |
| _pace_mismatch = abs(_h_pace10 - _a_pace10) / 10.0 |
| |
| _h_ortg10 = self._ortg(hr_, 10) |
| _a_ortg10 = self._ortg(ar_, 10) |
| _h_drtg10 = self._drtg(hr_, 10) |
| _a_drtg10 = self._drtg(ar_, 10) |
| _ortg_sum = (_h_ortg10 + _a_ortg10) / (2.0 * 110.0) |
| _drtg_sum = (_h_drtg10 + _a_drtg10) / (2.0 * 110.0) |
| |
| _score_env = ((_h_ortg10 + _a_ortg10) - (_h_drtg10 + _a_drtg10)) / 20.0 |
| row.extend([ |
| max(0.5, min(2.0, _h_ppg_n)), |
| max(0.5, min(2.0, _a_ppg_n)), |
| max(0.5, min(2.0, _h_pap_n)), |
| max(0.5, min(2.0, _a_pap_n)), |
| max(0.7, min(1.5, _matchup_total)), |
| max(0.7, min(1.4, _pace_sum_n)), |
| min(2.0, _pace_mismatch), |
| max(0.7, min(1.4, _ortg_sum)), |
| max(0.7, min(1.4, _drtg_sum)), |
| max(-1.5, min(1.5, _score_env)), |
| ]) |
| except Exception: |
| row.extend([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0]) |
|
|
| |
| try: |
| td = tracking_data or {} |
| h_td = td.get(home, {}) |
| a_td = td.get(away, {}) |
| _h_rim = h_td.get('rim_rate', 0.30) |
| _a_rim = a_td.get('rim_rate', 0.30) |
| _h_mid = h_td.get('mid_rate', 0.15) |
| _a_mid = a_td.get('mid_rate', 0.15) |
| _h_three = h_td.get('three_rate', 0.40) |
| _a_three = a_td.get('three_rate', 0.40) |
| |
| _h_xefg = 0.65 * _h_rim + 0.40 * _h_mid + 0.53 * _h_three * 1.5 |
| _a_xefg = 0.65 * _a_rim + 0.40 * _a_mid + 0.53 * _a_three * 1.5 |
| row.extend([ |
| _h_rim, |
| _a_rim, |
| _h_mid, |
| _a_mid, |
| _h_three, |
| _a_three, |
| _h_xefg, |
| _a_xefg, |
| _h_rim - _a_rim, |
| _h_xefg - _a_xefg, |
| ]) |
| except Exception: |
| row.extend([0.30, 0.30, 0.15, 0.15, 0.40, 0.40, 0.51, 0.51, 0.0, 0.0]) |
|
|
| |
| try: |
| td = tracking_data or {} |
| h_td = td.get(home, {}) |
| a_td = td.get(away, {}) |
| _h_cont = h_td.get('contested_shots', 0.0) / 50.0 |
| _a_cont = a_td.get('contested_shots', 0.0) / 50.0 |
| _h_defl = h_td.get('deflections', 0.0) / 15.0 |
| _a_defl = a_td.get('deflections', 0.0) / 15.0 |
| _h_spd = h_td.get('avg_speed', 0.0) / 5.0 |
| _a_spd = a_td.get('avg_speed', 0.0) / 5.0 |
| _h_lb = h_td.get('loose_balls', 0.0) / 8.0 |
| _a_lb = a_td.get('loose_balls', 0.0) / 8.0 |
| _h_drv = h_td.get('drives', 0.0) / 50.0 |
| _a_drv = a_td.get('drives', 0.0) / 50.0 |
| row.extend([ |
| _h_cont, |
| _a_cont, |
| _h_defl, |
| _a_defl, |
| _h_spd, |
| _a_spd, |
| _h_lb, |
| _a_lb, |
| _h_drv, |
| _a_drv, |
| _h_cont - _a_cont, |
| _h_spd - _a_spd, |
| ]) |
| except Exception: |
| row.extend([0.0] * 12) |
|
|
| |
| try: |
| _odds_key = (gd, home, away) |
| _odds = (odds_data or {}).get(_odds_key, {}) |
| if _odds: |
| _ip_home = _odds.get('implied_home_prob', 0.5) |
| _ip_away = _odds.get('implied_away_prob', 0.5) |
| _fp_home = _odds.get('fair_home_prob', 0.5) |
| _fp_away = _odds.get('fair_away_prob', 0.5) |
| _sp_home = _odds.get('spread_home', None) |
| _total = _odds.get('total', None) |
| _overr = _odds.get('overround', 1.05) |
|
|
| |
| _sp_norm = (_sp_home / 10.0) if _sp_home is not None else 0.0 |
| |
| _total_norm = (_total / 220.0) if _total is not None else 1.0 |
|
|
| |
| |
| |
| if _sp_home is not None: |
| _sp_implied = 1.0 / (1.0 + 10.0 ** (_sp_home / 7.5)) |
| else: |
| _sp_implied = _fp_home |
|
|
| |
| _spread_ml_diff = _sp_implied - _fp_home |
|
|
| row.extend([ |
| _ip_home, |
| _ip_away, |
| _fp_home, |
| _fp_away, |
| _sp_norm, |
| _total_norm, |
| _overr, |
| _spread_ml_diff, |
| ]) |
| else: |
| |
| row.extend([0.5, 0.5, 0.5, 0.5, 0.0, 1.0, 1.05, 0.0]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.5, 0.5, 0.0, 1.0, 1.05, 0.0]) |
|
|
| |
| try: |
| td = tracking_data or {} |
| h_td = td.get(home, {}) |
| a_td = td.get(away, {}) |
| _h_drv_fg = h_td.get('drive_fg_pct', 0.488) |
| _a_drv_fg = a_td.get('drive_fg_pct', 0.488) |
| _h_drv_tov = h_td.get('drive_tov_pct', 0.07) |
| _a_drv_tov = a_td.get('drive_tov_pct', 0.07) |
| _h_drv_pts = h_td.get('drive_pts_pct', 0.50) |
| _a_drv_pts = a_td.get('drive_pts_pct', 0.50) |
| _h_rim_d = h_td.get('def_rim_fg_pct', 0.66) |
| _a_rim_d = a_td.get('def_rim_fg_pct', 0.66) |
| _h_blk = h_td.get('blk_per_game', 4.5) / 10.0 |
| _a_blk = a_td.get('blk_per_game', 4.5) / 10.0 |
| _h_drv_n = h_td.get('drives', 48.0) / 50.0 |
| _a_drv_n = a_td.get('drives', 48.0) / 50.0 |
| _h_off_vs_a = _h_drv_fg - _a_rim_d |
| _a_off_vs_h = _a_drv_fg - _h_rim_d |
| row.extend([ |
| _h_drv_fg, |
| _a_drv_fg, |
| _h_drv_tov, |
| _a_drv_tov, |
| _h_drv_pts, |
| _a_drv_pts, |
| _h_rim_d, |
| _a_rim_d, |
| _h_blk, |
| _a_blk, |
| _h_off_vs_a, |
| _a_off_vs_h, |
| _h_off_vs_a - _a_off_vs_h, |
| _h_drv_n - _a_drv_n, |
| ]) |
| except Exception: |
| row.extend([0.488, 0.488, 0.07, 0.07, 0.50, 0.50, 0.66, 0.66, |
| 0.45, 0.45, 0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| try: |
| td = tracking_data or {} |
| h_td = td.get(home, {}) |
| a_td = td.get(away, {}) |
| _h_atp = h_td.get('ast_to_pass_pct', 0.09) |
| _a_atp = a_td.get('ast_to_pass_pct', 0.09) |
| _h_pot = h_td.get('potential_ast', 47.0) / 50.0 |
| _a_pot = a_td.get('potential_ast', 47.0) / 50.0 |
| _h_apc = h_td.get('ast_points_created', 68.0) / 80.0 |
| _a_apc = a_td.get('ast_points_created', 68.0) / 80.0 |
| _h_sec = h_td.get('secondary_ast', 3.0) / 5.0 |
| _a_sec = a_td.get('secondary_ast', 3.0) / 5.0 |
| _bm_h = _h_atp + _h_sec * 0.1 + _h_pot * 0.05 |
| _bm_a = _a_atp + _a_sec * 0.1 + _a_pot * 0.05 |
| row.extend([ |
| _h_atp, |
| _a_atp, |
| _h_pot, |
| _a_pot, |
| _h_apc, |
| _a_apc, |
| _h_sec, |
| _a_sec, |
| _h_atp - _a_atp, |
| _bm_h - _bm_a, |
| ]) |
| except Exception: |
| row.extend([0.09, 0.09, 0.94, 0.94, 0.85, 0.85, 0.6, 0.6, 0.0, 0.0]) |
|
|
| |
| try: |
| td = tracking_data or {} |
| h_td = td.get(home, {}) |
| a_td = td.get(away, {}) |
| _h_iso = h_td.get('iso_ppp', 0.88) |
| _a_iso = a_td.get('iso_ppp', 0.88) |
| _h_pnr = h_td.get('pnr_ppp', 0.87) |
| _a_pnr = a_td.get('pnr_ppp', 0.87) |
| _h_spot = h_td.get('spot_ppp', 1.04) |
| _a_spot = a_td.get('spot_ppp', 1.04) |
| _h_trans = h_td.get('trans_ppp', 1.12) |
| _a_trans = a_td.get('trans_ppp', 1.12) |
| _h_avg = (_h_iso + _h_pnr + _h_spot + _h_trans) / 4.0 |
| _a_avg = (_a_iso + _a_pnr + _a_spot + _a_trans) / 4.0 |
| _h_above = sum(1 for p in [_h_iso, _h_pnr, _h_spot, _h_trans] if p > 1.0) / 4.0 |
| _a_above = sum(1 for p in [_a_iso, _a_pnr, _a_spot, _a_trans] if p > 1.0) / 4.0 |
| row.extend([ |
| _h_iso, |
| _a_iso, |
| _h_pnr, |
| _a_pnr, |
| _h_spot, |
| _a_spot, |
| _h_trans, |
| _a_trans, |
| _h_avg - _a_avg, |
| _h_above - _a_above, |
| ]) |
| except Exception: |
| row.extend([0.88, 0.88, 0.87, 0.87, 1.04, 1.04, 1.12, 1.12, 0.0, 0.0]) |
|
|
| |
| try: |
| def _seq_feats(records, n=10): |
| """Encode temporal sequence: order of wins/losses matters beyond averages.""" |
| last_n = records[-n:] if len(records) >= n else records[:] |
| m = len(last_n) |
| if m == 0: |
| return [0.5, 0.5, 0.0, 0.0, 0.0] |
| mid = max(1, m // 2) |
| early = last_n[:mid] |
| late = last_n[mid:] if len(last_n) > mid else last_n[-1:] |
| early_wp = sum(1 for r in early if r[1]) / len(early) |
| late_wp = sum(1 for r in late if r[1]) / len(late) |
| slope = late_wp - early_wp |
| r3 = last_n[-3:] |
| o3 = last_n[:3] if len(last_n) >= 6 else last_n[:1] |
| m_recent = sum(r[2] for r in r3) / len(r3) |
| m_old = sum(r[2] for r in o3) / len(o3) |
| m_slope = max(-1.0, min(1.0, (m_recent - m_old) / 30.0)) |
| streak = 0 |
| last_val = last_n[-1][1] |
| for r in reversed(last_n): |
| if r[1] == last_val: |
| streak += 1 if r[1] else -1 |
| else: |
| break |
| return [early_wp, late_wp, slope, m_slope, max(-1.0, min(1.0, streak / 10.0))] |
|
|
| _hs = _seq_feats(team_results[home]) |
| _as = _seq_feats(team_results[away]) |
| row.extend([ |
| _hs[0], |
| _hs[1], |
| _hs[2], |
| _hs[3], |
| _hs[4], |
| _as[0], |
| _as[1], |
| _as[2], |
| _as[3], |
| _as[4], |
| _hs[2] - _as[2], |
| _hs[4] - _as[4], |
| ]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.0, 0.0, 0.0, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| |
| |
| try: |
| sid = _era_season_id(gd) |
| h_ortg_v = self._ortg(hr_, 10) |
| h_drtg_v = self._drtg(hr_, 10) |
| a_ortg_v = self._ortg(ar_, 10) |
| a_drtg_v = self._drtg(ar_, 10) |
| h_pace_v = self._pace(hr_, 10) |
| a_pace_v = self._pace(ar_, 10) |
| h_nrtg_v = h_ortg_v - h_drtg_v |
| a_nrtg_v = a_ortg_v - a_drtg_v |
|
|
| ort_hist = _era51_ortg[sid] |
| drt_hist = _era51_drtg[sid] |
| pc_hist = _era51_pace[sid] |
| nrt_hist = _era51_nrtg[sid] |
|
|
| h_oz = _era_zscore(h_ortg_v, ort_hist) |
| h_dz = _era_zscore(h_drtg_v, drt_hist) |
| a_oz = _era_zscore(a_ortg_v, ort_hist) |
| a_dz = _era_zscore(a_drtg_v, drt_hist) |
| h_pz = _era_zscore(h_pace_v, pc_hist) |
| a_pz = _era_zscore(a_pace_v, pc_hist) |
| h_nz = _era_zscore(h_nrtg_v, nrt_hist) |
|
|
| row.extend([h_oz, h_dz, a_oz, a_dz, h_pz, a_pz, h_nz, h_oz - a_dz]) |
|
|
| |
| _era51_ortg[sid].extend([h_ortg_v, a_ortg_v]) |
| _era51_drtg[sid].extend([h_drtg_v, a_drtg_v]) |
| _era51_pace[sid].extend([h_pace_v, a_pace_v]) |
| _era51_nrtg[sid].extend([h_nrtg_v, a_nrtg_v]) |
| except Exception: |
| row.extend([0.0] * 8) |
|
|
| |
| |
| try: |
| _odds_key = (gd, home, away) |
| _odds = (odds_data or {}).get(_odds_key, {}) |
| _sp52 = _odds.get('spread_home', None) |
| _tot52 = _odds.get('total', None) |
| _ip52h = _odds.get('implied_home_prob', 0.5) |
| _ip52a = _odds.get('implied_away_prob', 0.5) |
| _or52 = _odds.get('overround', 1.05) |
|
|
| _sp_abs = abs(_sp52) if _sp52 is not None else 3.5 |
| _tot_v = _tot52 if _tot52 is not None else 220.0 |
|
|
| |
| _sp_sign = -1.0 if (_sp52 is not None and _sp52 < 0) else 1.0 |
| _ml_sign = -1.0 if _ip52h > 0.5 else 1.0 |
| _agree = 1.0 if _sp_sign == _ml_sign else 0.0 |
|
|
| _vig52 = max(0.0, _or52 - 1.0) |
| _home_dog = 1.0 if (_sp52 is not None and _sp52 > 0) else 0.0 |
|
|
| |
| _sp_adj = (_sp52 + 3.5) if _sp52 is not None else 0.0 |
|
|
| |
| |
| _fp52h = _odds.get('fair_home_prob', _ip52h) |
| if 0.001 < _fp52h < 0.999: |
| _ml_impl_spread = -7.5 * math.log10(_fp52h / (1.0 - _fp52h)) |
| else: |
| _ml_impl_spread = 0.0 |
| _ml_sp_gap = (_ml_impl_spread - (_sp52 if _sp52 is not None else _ml_impl_spread)) / 10.0 |
|
|
| _sharpness = 1.0 / max(_or52, 1.0) |
|
|
| |
| def _pct_rank(val, lst): |
| """Fraction of values in lst that are <= val.""" |
| if not lst: |
| return 0.5 |
| return sum(1 for v in lst if v <= val) / len(lst) |
|
|
| _sp_pct = _pct_rank(_sp_abs, _season_spreads) |
| _tot_pct = _pct_rank(_tot_v, _season_totals) |
|
|
| |
| if len(_season_spreads) >= 5: |
| _sp_mu = sum(_season_spreads) / len(_season_spreads) |
| _sp_std = math.sqrt(sum((v - _sp_mu)**2 for v in _season_spreads) / len(_season_spreads)) |
| else: |
| _sp_std = 3.5 |
|
|
| |
| _recent_tots = _season_totals[-10:] if len(_season_totals) >= 10 else _season_totals |
| _tot_trend = (sum(_recent_tots) / len(_recent_tots) - _tot_v) / 10.0 if _recent_tots else 0.0 |
|
|
| |
| _h_fav_str = ((-_sp52) / 10.0) if (_sp52 is not None and _sp52 < 0) else 0.0 |
|
|
| row.extend([ |
| _sp_abs, |
| _tot_v, |
| _ip52h, |
| _ip52a, |
| _agree, |
| _vig52, |
| _sp_pct, |
| _tot_pct, |
| _home_dog, |
| _sp_adj / 10.0, |
| _ml_sp_gap, |
| _sharpness, |
| _sp_std, |
| _tot_trend, |
| _h_fav_str, |
| ]) |
| except Exception: |
| row.extend([3.5, 220.0, 0.5, 0.5, 0.5, 0.05, 0.5, 0.5, 0.0, 0.0, 0.0, 0.952, 3.5, 0.0, 0.0]) |
|
|
| |
| |
| try: |
| def _ats_rate(records, n, fav_only=False, dog_only=False, home_only=None): |
| """Cover rate: last n ATS records. Record = (gd, covered, spread, is_home). |
| home_only=True → home games only |
| home_only=False → away (road) games only |
| home_only=None → all games (default) |
| """ |
| s = records[-n:] if n else records[:] |
| if fav_only: |
| s = [r for r in s if r[2] < 0] |
| if dog_only: |
| s = [r for r in s if r[2] > 0] |
| if home_only is True: |
| s = [r for r in s if r[3]] |
| elif home_only is False: |
| s = [r for r in s if not r[3]] |
| if not s: |
| return 0.5 |
| return sum(1 for r in s if r[1]) / len(s) |
|
|
| def _ats_streak(records): |
| """ATS streak: + if covering, - if not.""" |
| if not records: |
| return 0 |
| last_val = records[-1][1] |
| st = 0 |
| for r in reversed(records): |
| if r[1] == last_val: |
| st += 1 if last_val else -1 |
| else: |
| break |
| return st |
|
|
| def _margin_vs_spread(records, n): |
| """Avg (actual_margin - spread) last n games. + = covered more than expected.""" |
| s = records[-n:] if records else [] |
| if not s: |
| return 0.0 |
| |
| vals = [r[4] for r in s if len(r) > 4] |
| return sum(vals) / len(vals) if vals else 0.0 |
|
|
| h_ats = _team_ats[home] |
| a_ats = _team_ats[away] |
|
|
| |
| h2h_games = h2h_results.get((home, away), []) + h2h_results.get((away, home), []) |
| h2h_ats_dates = {r[0] for r in h_ats} |
| h2h_h_ats = [r for r in h_ats if r[0] in {g[0] for g in h2h_games}] |
|
|
| row.extend([ |
| _ats_rate(h_ats, 10), |
| _ats_rate(a_ats, 10), |
| _ats_rate(h_ats, 0), |
| _ats_rate(a_ats, 0), |
| max(-5.0, min(5.0, _ats_streak(h_ats))) / 5.0, |
| max(-5.0, min(5.0, _ats_streak(a_ats))) / 5.0, |
| _ats_rate(h_ats, 0, fav_only=True), |
| _ats_rate(a_ats, 0, dog_only=True), |
| _ats_rate(h2h_h_ats, 5) if h2h_h_ats else 0.5, |
| _ats_rate(h_ats, 0, home_only=True), |
| _ats_rate(a_ats, 0, home_only=False), |
| _margin_vs_spread(h_ats, 10), |
| ]) |
| except Exception: |
| row.extend([0.5] * 11 + [0.0]) |
|
|
| |
| |
| try: |
| def _ou_rate(records, n, home_only=None): |
| """Over rate: last n O/U records. Record = (gd, went_over, total, is_home).""" |
| s = records[-n:] if n else records[:] |
| if home_only is True: |
| s = [r for r in s if r[3]] |
| elif home_only is False: |
| s = [r for r in s if not r[3]] |
| if not s: |
| return 0.5 |
| return sum(1 for r in s if r[1]) / len(s) |
|
|
| def _ou_streak(records): |
| """O/U streak: + if over, - if under.""" |
| if not records: |
| return 0 |
| last_val = records[-1][1] |
| st = 0 |
| for r in reversed(records): |
| if r[1] == last_val: |
| st += 1 if last_val else -1 |
| else: |
| break |
| return st |
|
|
| def _ou_margin_avg(h_records, a_records, n): |
| """Avg (actual_total - ou_line) last n for combined home+away records.""" |
| h_s = h_records[-n:] if h_records else [] |
| a_s = a_records[-n:] if a_records else [] |
| combined = h_s + a_s |
| if not combined: |
| return 0.0 |
| |
| vals = [r[4] for r in combined if len(r) > 4] |
| return sum(vals) / len(vals) if vals else 0.0 |
|
|
| h_ou = _team_ou[home] |
| a_ou = _team_ou[away] |
| combined_over_rate = ( |
| (_ou_rate(h_ou, 10) + _ou_rate(a_ou, 10)) / 2.0 |
| if h_ou or a_ou else 0.5 |
| ) |
|
|
| |
| _h_ppg_apg = self._ppg(hr_, 10) + self._papg(hr_, 10) |
| _odds_key = (gd, home, away) |
| _tot_line = (odds_data or {}).get(_odds_key, {}).get('total', 220.0) or 220.0 |
| _pace_vs_total = (_h_ppg_apg - _tot_line) / 20.0 |
|
|
| |
| _recent_tots = _season_totals[-10:] if len(_season_totals) >= 10 else _season_totals |
| _tot_trend54 = (sum(_recent_tots) / len(_recent_tots) - _tot_line) / 10.0 if _recent_tots else 0.0 |
|
|
| row.extend([ |
| _ou_rate(h_ou, 10), |
| _ou_rate(a_ou, 10), |
| _ou_rate(h_ou, 0), |
| _ou_rate(a_ou, 0), |
| max(-5.0, min(5.0, _ou_streak(h_ou))) / 5.0, |
| max(-5.0, min(5.0, _ou_streak(a_ou))) / 5.0, |
| combined_over_rate, |
| max(-3.0, min(3.0, _pace_vs_total)), |
| _ou_rate(h_ou, 0, home_only=True), |
| _ou_rate(a_ou, 0, home_only=False), |
| _tot_trend54, |
| _ou_margin_avg(h_ou, a_ou, 10), |
| ]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.5, 0.5, 0.0, 0.0, 0.5, 0.0, 0.5, 0.5, 0.0, 0.0]) |
|
|
| |
| |
| try: |
| _odds55 = (odds_data or {}).get((gd, home, away), {}) |
| _sp55 = _odds55.get('spread_home', None) |
| _ip55h = _odds55.get('implied_home_prob', 0.5) |
| _tot55 = float(_odds55.get('total', 220.0) or 220.0) |
|
|
| |
| |
| if _sp55 is not None: |
| _fair_h55 = 1.0 / (1.0 + math.exp(float(_sp55) / 7.5)) |
| else: |
| _fair_h55 = 0.5 |
| _fair_a55 = 1.0 - _fair_h55 |
|
|
| |
| _ml_vs_sp55 = abs(_ip55h - _fair_h55) |
|
|
| |
| _ml_dir55 = 1 if _ip55h > 0.5 else -1 |
| _sp_dir55 = -1 if (_sp55 is not None and float(_sp55) < 0) else 1 |
| _disagree55 = 0.0 if _ml_dir55 == _sp_dir55 else 1.0 |
|
|
| |
| _edge_h55 = (_ip55h - 0.5) * (_fair_h55 - 0.5) * 4.0 |
|
|
| |
| _liq55 = min(1.0, max(0.0, (_tot55 - 190.0) / 60.0)) |
|
|
| |
| _ml_conf55 = abs(_ip55h - 0.5) |
| _sp_conf55 = min(1.0, abs(float(_sp55) / 10.0)) if _sp55 is not None else 0.35 |
| _consensus55 = (_ml_conf55 + _sp_conf55) / 2.0 |
|
|
| |
| _vig_dist55 = abs(_fair_h55 - _ip55h) |
|
|
| row.extend([ |
| _fair_h55, |
| _fair_a55, |
| _ml_vs_sp55, |
| _disagree55, |
| _edge_h55, |
| _liq55, |
| _ml_conf55, |
| _sp_conf55, |
| _consensus55, |
| _vig_dist55, |
| ]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.0, 0.0, 0.0, 0.5, 0.0, 0.35, 0.175, 0.0]) |
|
|
| |
| |
| |
| try: |
| _h56_last = self._last_location(hr_) |
| _a56_last = self._last_location(ar_) |
| _h56_home_tz = TIMEZONE_ET.get(home, 0) |
| _a56_away_tz = TIMEZONE_ET.get(away, 0) |
| _h56_last_tz = TIMEZONE_ET.get(_h56_last, _h56_home_tz) if _h56_last else _h56_home_tz |
| _a56_last_tz = TIMEZONE_ET.get(_a56_last, _a56_away_tz) if _a56_last else _a56_away_tz |
|
|
| |
| _h56_signed = _h56_home_tz - _h56_last_tz |
| _a56_signed = _a56_away_tz - _a56_last_tz |
|
|
| |
| |
| _h56_west_adv = 1.0 / (1.0 + math.exp(_h56_signed * 0.7)) |
| _a56_east_pen = 1.0 / (1.0 + math.exp(-_a56_signed * 0.7)) |
|
|
| |
| _dir56_mismatch = (1.0 if (_h56_signed < 0 and _a56_signed > 0) else |
| 0.5 if (_h56_signed < 0 or _a56_signed > 0) else 0.0) |
|
|
| |
| _h56_phase = max(-1.0, min(1.0, -_h56_signed / 3.0)) |
| _a56_phase = max(-1.0, min(1.0, _a56_signed / 3.0)) |
|
|
| |
| _dir56_edge = max(-1.0, min(1.0, _h56_west_adv - _a56_east_pen + 0.5)) |
|
|
| |
| _h56_density = len([r for r in (hr_ or [])[-14:] if hasattr(r[0], 'days') is False |
| and (gd - r[0]).days <= 7]) if hr_ else 0 |
| _a56_density = len([r for r in (ar_ or [])[-14:] if hasattr(r[0], 'days') is False |
| and (gd - r[0]).days <= 7]) if ar_ else 0 |
|
|
| row.extend([ |
| max(-1.0, min(1.0, _h56_signed / 3.0)), |
| max(-1.0, min(1.0, _a56_signed / 3.0)), |
| _h56_west_adv, |
| _a56_east_pen, |
| _dir56_mismatch, |
| _h56_phase, |
| _a56_phase, |
| _dir56_edge, |
| min(1.0, _h56_density / 5.0), |
| max(-1.0, min(1.0, (_a56_density - _h56_density) / 5.0)), |
| ]) |
| except Exception: |
| row.extend([0.0, 0.0, 0.5, 0.5, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0]) |
|
|
| |
| |
| |
| try: |
| def _exp_form57(records, n, alpha=0.4): |
| recent = records[-n:] if records else [] |
| if not recent: |
| return 0.5 |
| _wts = [(1.0 - alpha) ** i for i in range(len(recent) - 1, -1, -1)] |
| _wsum = sum(_wts) |
| _wwins = sum(w * (1.0 if r[1] else 0.0) for r, w in zip(recent, _wts)) |
| return _wwins / _wsum if _wsum > 0 else 0.5 |
|
|
| def _form_var57(records, n): |
| recent = records[-n:] if records else [] |
| if len(recent) < 2: |
| return 0.25 |
| wins = [1.0 if r[1] else 0.0 for r in recent] |
| mean = sum(wins) / len(wins) |
| return sum((w - mean) ** 2 for w in wins) / len(wins) |
|
|
| _h57_exp5 = _exp_form57(hr_, 5) |
| _a57_exp5 = _exp_form57(ar_, 5) |
| _h57_exp10 = _exp_form57(hr_, 10) |
| _a57_exp10 = _exp_form57(ar_, 10) |
| _h57_var = _form_var57(hr_, 5) |
| _a57_var = _form_var57(ar_, 5) |
|
|
| |
| _h57_streak = 0 |
| for _r in reversed(hr_[-7:] if hr_ else []): |
| if _r[1]: |
| _h57_streak += 1 |
| else: |
| break |
| _a57_streak = 0 |
| for _r in reversed(ar_[-7:] if ar_ else []): |
| if _r[1]: |
| _a57_streak += 1 |
| else: |
| break |
|
|
| _h57_hot = min(1.0, _h57_streak / 3.0) |
| _a57_hot = min(1.0, _a57_streak / 3.0) |
| _h57_mom = max(-0.5, min(0.5, _h57_exp5 - _h57_exp10)) |
| _a57_mom = max(-0.5, min(0.5, _a57_exp5 - _a57_exp10)) |
|
|
| row.extend([ |
| _h57_exp5, |
| _a57_exp5, |
| _h57_exp10, |
| _a57_exp10, |
| _h57_var, |
| _a57_var, |
| _h57_hot, |
| _a57_hot, |
| _h57_mom, |
| _a57_mom, |
| _h57_exp5 - _a57_exp5, |
| _h57_mom - _a57_mom, |
| ]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.5, 0.5, 0.25, 0.25, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| |
| |
| try: |
| _odds58 = (odds_data or {}).get((gd, home, away), {}) |
| _sp58 = _odds58.get('spread_home', None) |
| _ip58h = _odds58.get('implied_home_prob', 0.5) |
| _tot58 = float(_odds58.get('total', 220.0) or 220.0) |
|
|
| |
| _sp58_conf = min(1.0, abs(float(_sp58) / 10.0)) if _sp58 is not None else 0.35 |
| |
| _tot58_conf = max(0.3, 1.0 - (_tot58 - 190.0) / 200.0) |
|
|
| |
| _h58_b2b = 1.0 if h_rest <= 1 else 0.0 |
| _a58_b2b = 1.0 if a_rest <= 1 else 0.0 |
| _b2b58_unc = (_h58_b2b + _a58_b2b) * 0.5 |
|
|
| |
| _h2h58_all = h2h_results.get((home, away), []) + h2h_results.get((away, home), []) |
| _h2h58_conf = min(1.0, abs(self._wp(_h2h58_all, min(20, len(_h2h58_all))) - 0.5) * 2.0) |
|
|
| |
| _h58_home_tr = team_home_results.get(home, []) |
| _h58_home_wp = self._wp(_h58_home_tr, 0) if _h58_home_tr else 0.6 |
| _h58_reliability = min(1.0, abs(_h58_home_wp - 0.60) * 2.0) |
|
|
| |
| _market58_align = 1.0 - abs(_ip58h - 0.5) * 0.5 |
|
|
| |
| _a58_road_tr = team_away_results.get(away, []) |
| _a58_road_wp = self._wp(_a58_road_tr, 0) if _a58_road_tr else 0.4 |
|
|
| row.extend([ |
| _sp58_conf, |
| max(0.0, min(1.0, _tot58_conf)), |
| _b2b58_unc, |
| _h2h58_conf, |
| _h58_reliability, |
| _market58_align, |
| _h58_home_wp, |
| _a58_road_wp, |
| _sp58_conf * (1.0 - _b2b58_unc), |
| min(1.0, _h2h58_conf * _sp58_conf), |
| ]) |
| except Exception: |
| row.extend([0.35, 0.8, 0.0, 0.0, 0.0, 0.75, 0.6, 0.4, 0.35, 0.0]) |
|
|
| |
| |
| |
| try: |
| _h59_rec = hr_[-10:] |
| _a59_rec = ar_[-10:] |
|
|
| |
| def _opp59_sos2(records, n=5): |
| rec = records[-n:] |
| vals = [] |
| for _r in rec: |
| _opp_recs = team_results.get(_r[3], []) |
| if _opp_recs: |
| _opp_opp_wps = [] |
| for _r2 in _opp_recs[-5:]: |
| _opp2_recs = team_results.get(_r2[3], []) |
| if _opp2_recs: |
| _opp_opp_wps.append(self._wp(_opp2_recs, 82)) |
| if _opp_opp_wps: |
| vals.append(sum(_opp_opp_wps) / len(_opp_opp_wps)) |
| return sum(vals) / len(vals) if vals else 0.5 |
|
|
| _h59_sos2 = _opp59_sos2(_h59_rec) |
| _a59_sos2 = _opp59_sos2(_a59_rec) |
|
|
| |
| _h59_opp_margins = {_r[3]: _r[2] for _r in _h59_rec} |
| _a59_opp_margins = {_r[3]: _r[2] for _r in _a59_rec} |
| _common59 = set(_h59_opp_margins.keys()) & set(_a59_opp_margins.keys()) |
| if _common59: |
| _h59_cm = [_h59_opp_margins[_t] for _t in _common59] |
| _a59_cm = [_a59_opp_margins[_t] for _t in _common59] |
| _common59_adv = max(-1.0, min(1.0, |
| (sum(_h59_cm) / len(_h59_cm) - sum(_a59_cm) / len(_a59_cm)) / 20.0)) |
| else: |
| _common59_adv = 0.0 |
| _common59_count = min(1.0, len(_common59) / 10.0) |
|
|
| |
| _h59_beat_q = self._wp_vs_quality(_h59_rec, team_results, above=True) |
| _a59_beat_q = self._wp_vs_quality(_a59_rec, team_results, above=True) |
|
|
| |
| _h59_sos3 = self._sos(hr_, team_results, 3) |
| _h59_sos63 = self._sos(hr_[-6:-3], team_results, 3) if len(hr_) >= 6 else _h59_sos3 |
| _a59_sos3 = self._sos(ar_, team_results, 3) |
| _a59_sos63 = self._sos(ar_[-6:-3], team_results, 3) if len(ar_) >= 6 else _a59_sos3 |
| _h59_sos_trend = _h59_sos3 - _h59_sos63 |
| _a59_sos_trend = _a59_sos3 - _a59_sos63 |
|
|
| |
| _h59_sos5 = self._sos(_h59_rec, team_results, 5) |
| _a59_sos5 = self._sos(_a59_rec, team_results, 5) |
| _59_sched_asym = _h59_sos5 - _a59_sos5 |
|
|
| |
| def _opp59_form(records, n=5): |
| rec = records[-n:] |
| vals = [] |
| for _r in rec: |
| _opp_recs = team_results.get(_r[3], []) |
| if _opp_recs: |
| vals.append(self._wp(_opp_recs[-5:], 5)) |
| return sum(vals) / len(vals) if vals else 0.5 |
|
|
| _h59_opp_form = _opp59_form(_h59_rec) |
| _a59_opp_form = _opp59_form(_a59_rec) |
|
|
| |
| |
| _h59_wins_vs = {_r[3] for _r in _h59_rec if _r[2] > 0} |
| _a59_beaten_by = {_r[3] for _r in _a59_rec if _r[2] <= 0} |
| _transitive59_raw = len(_h59_wins_vs & _a59_beaten_by) |
| _transitive59 = min(1.0, _transitive59_raw / max(1.0, len(_a59_beaten_by))) |
|
|
| row.extend([ |
| _h59_sos2, |
| _a59_sos2, |
| _common59_adv, |
| _common59_count, |
| _h59_beat_q, |
| _a59_beat_q, |
| _h59_sos_trend, |
| _a59_sos_trend, |
| _59_sched_asym, |
| _h59_opp_form, |
| _a59_opp_form, |
| _transitive59, |
| ]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.0, 0.5, 0.5, 0.5, 0.0, 0.0, 0.0, 0.5, 0.5, 0.5]) |
|
|
| |
| |
| try: |
| _h60_margins = [_r[2] for _r in hr_[-10:]] if hr_ else [] |
| _a60_margins = [_r[2] for _r in ar_[-10:]] if ar_ else [] |
|
|
| def _mdist60_stats(margins): |
| if not margins: |
| return 0.0, 0.0, 0.0, 0.0, 0.0 |
| n = len(margins) |
| mean = sum(margins) / n |
| if n < 3: |
| return mean, 0.0, 0.0, 0.0, max(margins) - min(margins) if n > 1 else 0.0 |
| var = sum((m - mean) ** 2 for m in margins) / n |
| std = var ** 0.5 if var > 0 else 1e-6 |
| skew = sum(((m - mean) / std) ** 3 for m in margins) / n if std > 1e-6 else 0.0 |
| skew = max(-3.0, min(3.0, skew)) |
| sorted_m = sorted(margins) |
| q1 = sorted_m[n // 4] if n >= 4 else sorted_m[0] |
| q3 = sorted_m[3 * n // 4] if n >= 4 else sorted_m[-1] |
| iqr = (q3 - q1) / 30.0 |
| losses = [m for m in margins if m < 0] |
| close_loss = sum(1 for m in losses if m >= -5) / max(1, len(losses)) |
| wins = [m for m in margins if m > 0] |
| blowout_win = sum(1 for m in wins if m >= 15) / max(1, len(wins)) |
| return mean / 20.0, skew, close_loss, blowout_win, max(-1.0, min(1.0, iqr)) |
|
|
| _h60_mean, _h60_skew, _h60_cl, _h60_bw, _h60_iqr = _mdist60_stats(_h60_margins) |
| _a60_mean, _a60_skew, _a60_cl, _a60_bw, _a60_iqr = _mdist60_stats(_a60_margins) |
|
|
| row.extend([ |
| _h60_mean, |
| _a60_mean, |
| _h60_skew, |
| _a60_skew, |
| _h60_cl, |
| _a60_cl, |
| _h60_bw, |
| _a60_bw, |
| _h60_iqr, |
| _a60_iqr, |
| _h60_skew - _a60_skew, |
| (_h60_bw - _h60_cl) - (_a60_bw - _a60_cl), |
| ]) |
| except Exception: |
| row.extend([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| |
| try: |
| def _pace61_ratings(recs, stat_fn): |
| if not recs: |
| return 0.5, 0.5, 0.0, 0.5 |
| off_rtgs, def_rtgs, paces = [], [], [] |
| for _r in recs[-10:]: |
| _s = stat_fn(_r) |
| if not _s: |
| continue |
| fga = _s.get('fga', 0) or 0 |
| fta = _s.get('fta', 0) or 0 |
| orb = _s.get('orb', 0) or 0 |
| tov = _s.get('tov', _s.get('to', 0)) or 0 |
| pts = _r[2] if _r[2] is not None else 0 |
| opp_pts = _r[3] if len(_r) > 3 and _r[3] is not None else 0 |
| poss = fga + 0.44 * fta - orb + tov |
| if poss < 10: |
| continue |
| off_rtgs.append((pts / poss) * 100.0) |
| def_rtgs.append((opp_pts / poss) * 100.0) |
| paces.append(poss) |
| if not off_rtgs: |
| return 0.5, 0.5, 0.0, 0.5 |
| o = sum(off_rtgs) / len(off_rtgs) / 200.0 |
| d = sum(def_rtgs) / len(def_rtgs) / 200.0 |
| p = sum(paces) / len(paces) / 200.0 |
| return max(0.0, min(1.0, o)), max(0.0, min(1.0, d)), o - d, max(0.0, min(1.0, p)) |
|
|
| def _h_stat61(r): |
| return r[4] if len(r) > 4 else None |
| def _a_stat61(r): |
| return r[5] if len(r) > 5 else None |
|
|
| |
| _h61_recs = [] |
| for _r in hr_[-10:]: |
| if len(_r) >= 6: |
| _h61_recs.append(_r) |
| elif len(_r) >= 4: |
| _h61_recs.append(_r) |
|
|
| _a61_recs = [] |
| for _r in ar_[-10:]: |
| if len(_r) >= 6: |
| _a61_recs.append(_r) |
| elif len(_r) >= 4: |
| _a61_recs.append(_r) |
|
|
| _h61_off, _h61_def, _h61_net, _h61_pace = _pace61_ratings(_h61_recs, _h_stat61) |
| _a61_off, _a61_def, _a61_net, _a61_pace = _pace61_ratings(_a61_recs, _a_stat61) |
|
|
| row.extend([ |
| _h61_off, |
| _a61_off, |
| _h61_def, |
| _a61_def, |
| _h61_net, |
| _a61_net, |
| _h61_net - _a61_net, |
| _h61_pace, |
| _a61_pace, |
| abs(_h61_pace - _a61_pace), |
| ]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.5, 0.5, 0.0, 0.0, 0.0, 0.5, 0.5, 0.0]) |
|
|
| |
| try: |
| def _clutch62(recs): |
| if not recs: |
| return 0.5, 0.0, 0.0, 0.0 |
| wins = sum(1 for r in recs if r[2] > r[3]) |
| overall_wr = wins / len(recs) if recs else 0.5 |
| close = [(r[2] - r[3]) for r in recs if abs(r[2] - r[3]) <= 5] |
| close_wins = sum(1 for m in close if m > 0) |
| close_wr = close_wins / len(close) if close else 0.5 |
| close_freq = len(close) / len(recs) if recs else 0.0 |
| clutch_edge = close_wr - overall_wr |
| close_margin = sum(close) / len(close) if close else 0.0 |
| return close_wr, clutch_edge, close_freq, close_margin / 20.0 |
|
|
| _h62_recs = [(r[0], r[1], r[2], r[3]) for r in hr_[-15:] if len(r) >= 4 and r[2] is not None and r[3] is not None] |
| _a62_recs = [(r[0], r[1], r[2], r[3]) for r in ar_[-15:] if len(r) >= 4 and r[2] is not None and r[3] is not None] |
|
|
| _h62_cwr, _h62_ce, _h62_cf, _h62_cm = _clutch62(_h62_recs) |
| _a62_cwr, _a62_ce, _a62_cf, _a62_cm = _clutch62(_a62_recs) |
|
|
| row.extend([ |
| _h62_cwr, |
| _a62_cwr, |
| _h62_ce, |
| _a62_ce, |
| _h62_cf, |
| _a62_cf, |
| _h62_cwr - _a62_cwr, |
| _h62_ce - _a62_ce, |
| _h62_cm, |
| _a62_cm, |
| ]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| try: |
| _MOREY = 13.91 |
|
|
| def _pyth63(recs, n): |
| recent = recs[-n:] if len(recs) >= n else recs |
| if len(recent) < 3: |
| return 0.5, 0.5, 0.0 |
| pf = sum(r[4].get("pts", 100) for r in recent) |
| pa = sum(r[4].get("opp_pts", 100) for r in recent) |
| if pf + pa == 0: |
| return 0.5, 0.5, 0.0 |
| pyth_wp = pf ** _MOREY / max(pf ** _MOREY + pa ** _MOREY, 1e-9) |
| actual_wp = sum(1 for r in recent if r[1]) / len(recent) |
| luck = actual_wp - pyth_wp |
| return pyth_wp, actual_wp, luck |
|
|
| _h63_pw5, _h63_aw5, _h63_l5 = _pyth63(hr_, 5) |
| _a63_pw5, _a63_aw5, _a63_l5 = _pyth63(ar_, 5) |
| _h63_pw10, _h63_aw10, _h63_l10 = _pyth63(hr_, 10) |
| _a63_pw10, _a63_aw10, _a63_l10 = _pyth63(ar_, 10) |
|
|
| row.extend([ |
| _h63_pw5, |
| _a63_pw5, |
| _h63_pw10, |
| _a63_pw10, |
| _h63_l5, |
| _a63_l5, |
| _h63_l10, |
| _a63_l10, |
| _h63_pw10 - _a63_pw10, |
| _h63_l10 - _a63_l10, |
| abs(_h63_l10), |
| abs(_a63_l10), |
| ]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| try: |
| def _elow64(recs, n, team_elo_ref): |
| recent = recs[-n:] if len(recs) >= n else recs |
| if len(recent) < 3: |
| return 0.5, 0.0, 0.0 |
| total_w = 0.0 |
| wp_w = 0.0 |
| margin_w = 0.0 |
| netrtg_w = 0.0 |
| for r in recent: |
| opp = r[3] |
| opp_elo = team_elo_ref.get(opp, 1500.0) |
| w = opp_elo / 1500.0 |
| total_w += w |
| wp_w += w * (1.0 if r[1] else 0.0) |
| margin_w += w * r[2] |
| ortg = r[4].get("ortg", 100) |
| drtg = r[4].get("drtg", 100) |
| netrtg_w += w * (ortg - drtg) |
| if total_w < 1e-9: |
| return 0.5, 0.0, 0.0 |
| return wp_w / total_w, margin_w / total_w, netrtg_w / total_w |
|
|
| _h64_wp10, _h64_m10, _h64_nr10 = _elow64(hr_, 10, team_elo) |
| _a64_wp10, _a64_m10, _a64_nr10 = _elow64(ar_, 10, team_elo) |
| _h64_wp5, _h64_m5, _h64_nr5 = _elow64(hr_, 5, team_elo) |
| _a64_wp5, _a64_m5, _a64_nr5 = _elow64(ar_, 5, team_elo) |
|
|
| row.extend([ |
| _h64_wp10, |
| _a64_wp10, |
| _h64_m10, |
| _a64_m10, |
| _h64_nr10, |
| _a64_nr10, |
| _h64_wp10 - _a64_wp10, |
| _h64_m10 - _a64_m10, |
| _h64_m5 - _h64_m10, |
| _a64_m5 - _a64_m10, |
| ]) |
| except Exception: |
| row.extend([0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) |
|
|
| |
| try: |
| _h65_efg = self._efg(hr_, 10) |
| _a65_efg = self._efg(ar_, 10) |
| _h65_opp_efg = self._opp_efg(hr_, 10) |
| _a65_opp_efg = self._opp_efg(ar_, 10) |
| _h65_tov = self._tov_rate(hr_, 10) |
| _a65_tov = self._tov_rate(ar_, 10) |
| _h65_opp_tov = self._opp_tov_rate(hr_, 10) |
| _a65_opp_tov = self._opp_tov_rate(ar_, 10) |
| _h65_orb = self._orb_rate(hr_, 10) |
| _a65_orb = self._orb_rate(ar_, 10) |
| _h65_opp_orb = self._opp_orb_rate(hr_, 10) |
| _a65_opp_orb = self._opp_orb_rate(ar_, 10) |
| _h65_ftr = self._ft_rate(hr_, 10) |
| _a65_ftr = self._ft_rate(ar_, 10) |
| _h65_opp_ftr = self._opp_ft_rate(hr_, 10) |
| _a65_opp_ftr = self._opp_ft_rate(ar_, 10) |
|
|
| _h65_efg_edge = _h65_efg - _a65_opp_efg |
| _a65_efg_edge = _a65_efg - _h65_opp_efg |
| _h65_tov_edge = _a65_opp_tov - _h65_tov |
| _a65_tov_edge = _h65_opp_tov - _a65_tov |
| _h65_orb_edge = _h65_orb - _a65_opp_orb |
| _a65_orb_edge = _a65_orb - _h65_opp_orb |
| _h65_ftr_edge = _h65_ftr - _a65_opp_ftr |
| _a65_ftr_edge = _a65_ftr - _h65_opp_ftr |
|
|
| _h65_comp = 0.40 * _h65_efg_edge + 0.25 * _h65_tov_edge + 0.20 * _h65_orb_edge + 0.15 * _h65_ftr_edge |
| _a65_comp = 0.40 * _a65_efg_edge + 0.25 * _a65_tov_edge + 0.20 * _a65_orb_edge + 0.15 * _a65_ftr_edge |
|
|
| row.extend([ |
| _h65_efg_edge, |
| _a65_efg_edge, |
| _h65_tov_edge, |
| _a65_tov_edge, |
| _h65_orb_edge, |
| _a65_orb_edge, |
| _h65_ftr_edge, |
| _a65_ftr_edge, |
| abs(_h65_comp - _a65_comp), |
| _h65_comp, |
| _a65_comp, |
| _h65_comp - _a65_comp, |
| ]) |
| except Exception: |
| row.extend([0.0] * 12) |
|
|
| |
| |
| |
| try: |
| def _p100(records, n, stat_key, default_val): |
| """Average of stat_per_100_possessions over last n games.""" |
| rs = records[-n:] if len(records) >= n else records |
| if not rs: |
| return default_val |
| total = 0.0 |
| for r in rs: |
| sd = r[4] |
| poss = max(sd.get("poss", 90.0), 1.0) |
| raw = sd.get(stat_key, 0.0) |
| total += (raw / poss) * 100.0 |
| return total / len(rs) |
|
|
| |
| _h66_pts = sum(r[4].get("ortg", 100.0) for r in hr_[-10:]) / max(len(hr_[-10:]), 1) |
| _a66_pts = sum(r[4].get("ortg", 100.0) for r in ar_[-10:]) / max(len(ar_[-10:]), 1) |
|
|
| |
| _h66_ast = _p100(hr_, 10, "ast_rate", 0.6) * 25.0 |
| _a66_ast = _p100(ar_, 10, "ast_rate", 0.6) * 25.0 |
|
|
| |
| _h66_tov = _p100(hr_, 10, "tov_rate", 0.13) * 85.0 |
| _a66_tov = _p100(ar_, 10, "tov_rate", 0.13) * 85.0 |
|
|
| |
| _h66_reb = (_p100(hr_, 10, "oreb_pct", 0.25) + _p100(hr_, 10, "dreb_pct", 0.75)) * 45.0 |
| _a66_reb = (_p100(ar_, 10, "oreb_pct", 0.25) + _p100(ar_, 10, "dreb_pct", 0.75)) * 45.0 |
|
|
| row.extend([ |
| _h66_pts, |
| _a66_pts, |
| _h66_pts - _a66_pts, |
| _h66_ast, |
| _a66_ast, |
| _h66_ast - _a66_ast, |
| _h66_tov, |
| _a66_tov, |
| _a66_tov - _h66_tov, |
| _h66_reb, |
| _a66_reb, |
| _h66_reb - _a66_reb, |
| ]) |
| except Exception: |
| row.extend([0.0] * 12) |
|
|
| |
| |
| try: |
| if self.enable_youtube and self._yt_sent_cache is not None: |
| _yt67 = _youtube_sentiment_features( |
| self._yt_sent_cache, gd, sim_cutoff=self.sim_date_cutoff |
| ) |
| row.extend([ |
| _yt67["yt_pol_mean_3"], |
| _yt67["yt_pol_mean_7"], |
| _yt67["yt_pol_mean_14"], |
| _yt67["yt_abs_pol_mean_3"], |
| _yt67["yt_abs_pol_mean_7"], |
| _yt67["yt_abs_pol_mean_14"], |
| ]) |
| else: |
| row.extend([0.0] * 6) |
| except Exception: |
| row.extend([0.0] * 6) |
|
|
| X.append(row) |
| y.append(1 if hs > as_ else 0) |
| _y_margin.append(hs - as_) |
| _y_total.append(hs + as_) |
|
|
| |
| |
| try: |
| _odds_key = (gd, home, away) |
| _odds_now = (odds_data or {}).get(_odds_key, {}) |
| _sp_now = _odds_now.get('spread_home', None) |
| _tot_now = _odds_now.get('total', None) |
| actual_margin = hs - as_ |
|
|
| if _sp_now is not None: |
| |
| |
| h_covered = actual_margin > (-_sp_now) |
| a_covered = (-actual_margin) > _sp_now |
| h_mvs = actual_margin - (-_sp_now) |
| a_mvs = (-actual_margin) - _sp_now |
|
|
| _team_ats[home].append((gd, h_covered, _sp_now, True, h_mvs)) |
| _team_ats[away].append((gd, a_covered, -_sp_now, False, a_mvs)) |
| _season_spreads.append(abs(_sp_now)) |
|
|
| if _tot_now is not None: |
| game_total = hs + as_ |
| went_over = game_total > _tot_now |
| margin_vs_total = game_total - _tot_now |
|
|
| _team_ou[home].append((gd, went_over, _tot_now, True, margin_vs_total)) |
| _team_ou[away].append((gd, went_over, _tot_now, False, margin_vs_total)) |
| _season_totals.append(_tot_now) |
| except Exception: |
| pass |
|
|
| |
| self._record_game(team_results, team_last, team_elo, |
| team_home_results, team_away_results, |
| h2h_results, home, away, hs, as_, gd, |
| h_stats, a_stats) |
| |
| self._update_multi_elo( |
| home, away, hs, as_, h_stats, a_stats, |
| team_elo_margin, team_elo_offense, team_elo_defense, |
| team_elo_recency, team_elo_history, |
| team_home_margin_sum, team_home_games_count) |
| |
| self._update_movda(home, away, hs, as_, team_movda, mov_surprise_ewm, |
| delta_mov_history, |
| _MOVDA_K, _MOVDA_C, _MOVDA_LAMBDA, _MOVDA_ALPHA, |
| _MOVDA_BETA, _MOVDA_GAMMA, _MOVDA_DELTA, _MOVDA_EWM_ALPHA) |
|
|
| X = np.nan_to_num(np.array(X, dtype=np.float64)) |
| y = np.array(y, dtype=np.int32) |
| self.y_margin = np.array(_y_margin, dtype=np.int32) |
| self.y_total = np.array(_y_total, dtype=np.int32) |
|
|
| |
| expected = len(self.feature_names) |
| if X.shape[1] != expected: |
| print(f"WARNING: Expected {expected} features, got {X.shape[1]}") |
| |
| if X.shape[1] < expected: |
| pad = np.zeros((X.shape[0], expected - X.shape[1])) |
| X = np.hstack([X, pad]) |
| else: |
| X = X[:, :expected] |
|
|
| return X, y, self.feature_names |
|
|
| |
|
|
| def _wp(self, records, n): |
| s = records[-n:] |
| return sum(1 for x in s if x[1]) / len(s) if s else 0.5 |
|
|
| def _pd(self, records, n): |
| s = records[-n:] |
| return sum(x[2] for x in s) / len(s) if s else 0.0 |
|
|
| def _ppg(self, records, n): |
| s = records[-n:] |
| return sum(x[4].get("pts", 100) for x in s) / len(s) if s else 100.0 |
|
|
| def _papg(self, records, n): |
| s = records[-n:] |
| return sum(x[4].get("opp_pts", 100) for x in s) / len(s) if s else 100.0 |
|
|
| def _avg_margin(self, records, n): |
| return self._ppg(records, n) - self._papg(records, n) |
|
|
| def _close_pct(self, records, n): |
| s = records[-n:] |
| if not s: |
| return 0.5 |
| return sum(1 for x in s if abs(x[2]) <= 5) / len(s) |
|
|
| def _blowout_pct(self, records, n): |
| s = records[-n:] |
| if not s: |
| return 0.0 |
| return sum(1 for x in s if abs(x[2]) >= 15) / len(s) |
|
|
| def _ou_avg(self, records, n): |
| s = records[-n:] |
| return sum(x[4].get("pts", 100) + x[4].get("opp_pts", 100) for x in s) / len(s) if s else 200.0 |
|
|
| def _streak(self, records): |
| if not records: |
| return 0 |
| s = 0 |
| last = records[-1][1] |
| for x in reversed(records): |
| if x[1] == last: |
| s += 1 |
| else: |
| break |
| return s if last else -s |
|
|
| def _efg(self, records, n): |
| return self._stat_avg(records, n, "efg_pct") |
|
|
| def _tov_rate(self, records, n): |
| return self._stat_avg(records, n, "tov_rate") |
|
|
| def _orb_rate(self, records, n): |
| return self._stat_avg(records, n, "oreb_pct") |
|
|
| def _ft_rate(self, records, n): |
| return self._stat_avg(records, n, "ft_rate") |
|
|
| def _opp_efg(self, records, n): |
| return self._stat_avg(records, n, "opp_efg_pct") |
|
|
| def _opp_tov_rate(self, records, n): |
| return self._stat_avg(records, n, "opp_tov_rate") |
|
|
| def _opp_orb_rate(self, records, n): |
| return self._stat_avg(records, n, "opp_oreb_pct") |
|
|
| def _opp_ft_rate(self, records, n): |
| return self._stat_avg(records, n, "opp_ft_rate") |
|
|
| def _ortg(self, records, n): |
| return self._stat_avg(records, n, "ortg") |
|
|
| def _drtg(self, records, n): |
| return self._stat_avg(records, n, "drtg") |
|
|
| def _netrtg(self, records, n): |
| return self._ortg(records, n) - self._drtg(records, n) |
|
|
| def _pace(self, records, n): |
| return self._stat_avg(records, n, "pace") |
|
|
| def _ts(self, records, n): |
| return self._stat_avg(records, n, "ts_pct") |
|
|
| def _avg_poss(self, records, n): |
| return self._stat_avg(records, n, "poss") |
|
|
| def _ast_rate(self, records, n): |
| return self._stat_avg(records, n, "ast_rate") |
|
|
| def _stl_rate(self, records, n): |
| return self._stat_avg(records, n, "stl_rate") |
|
|
| def _blk_rate(self, records, n): |
| return self._stat_avg(records, n, "blk_rate") |
|
|
| def _tov_pct(self, records, n): |
| return self._stat_avg(records, n, "tov_pct") |
|
|
| def _oreb_pct(self, records, n): |
| return self._stat_avg(records, n, "oreb_pct") |
|
|
| def _dreb_pct(self, records, n): |
| return self._stat_avg(records, n, "dreb_pct") |
|
|
| def _stat_avg(self, records, n, key): |
| s = records[-n:] |
| if not s: |
| return 0.0 |
| vals = [x[4].get(key, 0) for x in s if key in x[4]] |
| return sum(vals) / len(vals) if vals else 0.0 |
|
|
| def _rest_days(self, team, game_date, team_last): |
| last = team_last.get(team) |
| if not last or not game_date: |
| return 3 |
| try: |
| d1 = datetime.strptime(game_date[:10], "%Y-%m-%d") |
| d2 = datetime.strptime(last[:10], "%Y-%m-%d") |
| return max(0, (d1 - d2).days) |
| except (ValueError, TypeError, AttributeError): |
| return 3 |
|
|
| def _n_in_m(self, records, game_date, n, m): |
| """Return 1.0 if team played n games in last m days.""" |
| if not game_date: |
| return 0.0 |
| try: |
| gd = datetime.strptime(game_date[:10], "%Y-%m-%d") |
| count = sum(1 for r in records[-10:] |
| if (gd - datetime.strptime(r[0][:10], "%Y-%m-%d")).days <= m) |
| return 1.0 if count >= n else 0.0 |
| except (ValueError, TypeError, AttributeError): |
| return 0.0 |
|
|
| def _travel_dist(self, records, team): |
| """Distance from last game location to current arena.""" |
| if not records: |
| return 0 |
| last_opp = records[-1][3] |
| |
| last_loc = last_opp if not records[-1][4].get("is_home", False) else team |
| if last_loc not in ARENA_COORDS or team not in ARENA_COORDS: |
| return 0 |
| c1 = ARENA_COORDS[last_loc] |
| c2 = ARENA_COORDS[team] |
| return haversine(c1[0], c1[1], c2[0], c2[1]) |
|
|
| def _last_location(self, records): |
| if not records: |
| return "ATL" |
| return records[-1][3] if not records[-1][4].get("is_home", False) else records[-1][3] |
|
|
| def _games_in_window(self, records, game_date, days): |
| if not game_date or not records: |
| return 0 |
| try: |
| gd = datetime.strptime(game_date[:10], "%Y-%m-%d") |
| return sum(1 for r in records[-15:] |
| if (gd - datetime.strptime(r[0][:10], "%Y-%m-%d")).days <= days |
| and (gd - datetime.strptime(r[0][:10], "%Y-%m-%d")).days > 0) |
| except (ValueError, TypeError, AttributeError): |
| return 0 |
|
|
| def _miles_in_window(self, records, game_date, days, team): |
| """Total travel miles in the last N days.""" |
| if not records or not game_date: |
| return 0 |
| try: |
| gd = datetime.strptime(game_date[:10], "%Y-%m-%d") |
| total = 0 |
| for i, r in enumerate(records[-15:]): |
| rd = datetime.strptime(r[0][:10], "%Y-%m-%d") |
| if 0 < (gd - rd).days <= days: |
| |
| if i > 0: |
| prev = records[-15:][i-1] |
| loc1 = prev[3] |
| loc2 = r[3] |
| if loc1 in ARENA_COORDS and loc2 in ARENA_COORDS: |
| total += haversine(*ARENA_COORDS[loc1], *ARENA_COORDS[loc2]) |
| return total |
| except (ValueError, TypeError, AttributeError): |
| return 0 |
|
|
| def _fatigue_score(self, records, game_date, team, rest): |
| """Composite fatigue: games + travel + rest.""" |
| g7 = self._games_in_window(records, game_date, 7) |
| m7 = self._miles_in_window(records, game_date, 7, team) / 1000 |
| b2b = 1 if rest <= 1 else 0 |
| return g7 * 0.3 + m7 * 0.3 + b2b * 0.4 |
|
|
| def _total_miles_season(self, records, team): |
| """Total travel miles for the season (approximate from game locations).""" |
| total = 0.0 |
| prev_loc = team |
| for r in records: |
| game_loc = team if r[4].get("is_home", False) else r[3] |
| if prev_loc in ARENA_COORDS and game_loc in ARENA_COORDS: |
| total += haversine(*ARENA_COORDS[prev_loc], *ARENA_COORDS[game_loc]) |
| prev_loc = game_loc |
| return total |
|
|
| def _miles_last_n_games(self, records, n, team): |
| """Travel miles across last n games.""" |
| recent = records[-n:] |
| total = 0.0 |
| prev_loc = team |
| for r in recent: |
| game_loc = team if r[4].get("is_home", False) else r[3] |
| if prev_loc in ARENA_COORDS and game_loc in ARENA_COORDS: |
| total += haversine(*ARENA_COORDS[prev_loc], *ARENA_COORDS[game_loc]) |
| prev_loc = game_loc |
| return total |
|
|
| def _recent_rest_days(self, records, n): |
| """List of rest days between last n games.""" |
| recent = records[-n:] |
| rests = [] |
| for i in range(1, len(recent)): |
| days = self._days_between(recent[i-1][0], recent[i][0]) |
| rests.append(max(0, days)) |
| return rests |
|
|
| def _days_between(self, date1, date2): |
| """Days between two date strings.""" |
| try: |
| d1 = datetime.strptime(date1[:10], "%Y-%m-%d") |
| d2 = datetime.strptime(date2[:10], "%Y-%m-%d") |
| return abs((d2 - d1).days) |
| except (ValueError, TypeError, AttributeError): |
| return 2 |
|
|
| def _count_b2b_in_window(self, records, game_date, days): |
| """Count back-to-back instances in the last N days.""" |
| if not records or not game_date: |
| return 0 |
| try: |
| gd = datetime.strptime(game_date[:10], "%Y-%m-%d") |
| recent = [r for r in records if (gd - datetime.strptime(r[0][:10], "%Y-%m-%d")).days <= days |
| and (gd - datetime.strptime(r[0][:10], "%Y-%m-%d")).days > 0] |
| count = 0 |
| for i in range(1, len(recent)): |
| if self._days_between(recent[i-1][0], recent[i][0]) <= 1: |
| count += 1 |
| return count |
| except (ValueError, TypeError, AttributeError): |
| return 0 |
|
|
| def _count_dense_stretches(self, records, game_date, window_days, n_games, n_days): |
| """Count how many times team played n_games in n_days within window.""" |
| if not records or not game_date: |
| return 0 |
| try: |
| gd = datetime.strptime(game_date[:10], "%Y-%m-%d") |
| dates = [] |
| for r in records: |
| rd = datetime.strptime(r[0][:10], "%Y-%m-%d") |
| if 0 < (gd - rd).days <= window_days: |
| dates.append(rd) |
| dates.sort() |
| count = 0 |
| for i in range(len(dates) - n_games + 1): |
| span = (dates[i + n_games - 1] - dates[i]).days |
| if span <= n_days: |
| count += 1 |
| return count |
| except (ValueError, TypeError, AttributeError): |
| return 0 |
|
|
| def _consecutive_away(self, records): |
| """Count current consecutive away games (from most recent).""" |
| count = 0 |
| for r in reversed(records): |
| if not r[4].get("is_home", False): |
| count += 1 |
| else: |
| break |
| return count |
|
|
| def _consecutive_home(self, records): |
| """Count current consecutive home games (from most recent).""" |
| count = 0 |
| for r in reversed(records): |
| if r[4].get("is_home", False): |
| count += 1 |
| else: |
| break |
| return count |
|
|
| def _game_rest(self, game_record, all_records): |
| """Get rest days before a specific game record.""" |
| idx = None |
| for i, r in enumerate(all_records): |
| if r[0] == game_record[0]: |
| idx = i |
| break |
| if idx is None or idx == 0: |
| return 3 |
| return self._days_between(all_records[idx - 1][0], game_record[0]) |
|
|
| def _count_tz_changes(self, records): |
| """Count timezone changes across the season.""" |
| if len(records) < 2: |
| return 0 |
| changes = 0 |
| for i in range(1, len(records)): |
| loc1 = records[i-1][3] if not records[i-1][4].get("is_home", False) else records[i-1][4].get("team", "ATL") |
| loc2 = records[i][3] if not records[i][4].get("is_home", False) else records[i][4].get("team", "ATL") |
| tz1 = TIMEZONE_ET.get(loc1, 0) |
| tz2 = TIMEZONE_ET.get(loc2, 0) |
| if tz1 != tz2: |
| changes += 1 |
| return changes |
|
|
| def _sos(self, records, all_results, n): |
| rec = records[-n:] |
| if not rec: |
| return 0.5 |
| opp_wps = [] |
| for r in rec: |
| opp = r[3] |
| if all_results[opp]: |
| opp_wps.append(self._wp(all_results[opp], 82)) |
| return sum(opp_wps) / len(opp_wps) if opp_wps else 0.5 |
|
|
| def _wp_vs_quality(self, records, all_results, above=True): |
| if not records: |
| return 0.5 |
| relevant = [] |
| for r in records: |
| opp_wp = self._wp(all_results[r[3]], 82) |
| if (above and opp_wp > 0.5) or (not above and opp_wp <= 0.5): |
| relevant.append(r) |
| return self._wp(relevant, len(relevant)) if relevant else 0.5 |
|
|
| def _wp_vs_topN(self, records, all_results, n, top=True): |
| if not records: |
| return 0.5 |
| |
| team_wps = {t: self._wp(r, 82) for t, r in all_results.items() if r} |
| sorted_teams = sorted(team_wps.items(), key=lambda x: x[1], reverse=True) |
| if top: |
| target = {t for t, _ in sorted_teams[:n]} |
| else: |
| target = {t for t, _ in sorted_teams[-n:]} |
| relevant = [r for r in records if r[3] in target] |
| return self._wp(relevant, len(relevant)) if relevant else 0.5 |
|
|
| def _pd_vs_topN(self, records, all_results, n, top=True): |
| if not records: |
| return 0.0 |
| team_wps = {t: self._wp(r, 82) for t, r in all_results.items() if r} |
| sorted_teams = sorted(team_wps.items(), key=lambda x: x[1], reverse=True) |
| target = {t for t, _ in (sorted_teams[:n] if top else sorted_teams[-n:])} |
| relevant = [r for r in records if r[3] in target] |
| return self._pd(relevant, len(relevant)) if relevant else 0.0 |
|
|
| def _avg_opp_stat(self, records, all_results, stat, n): |
| rec = records[-n:] |
| if not rec: |
| return 0.0 |
| vals = [] |
| for r in rec: |
| opp_recs = all_results[r[3]] |
| if opp_recs: |
| vals.append(self._stat_avg(opp_recs, 10, stat)) |
| return sum(vals) / len(vals) if vals else 0.0 |
|
|
| def _margin_vs_quality_corr(self, records, all_results): |
| if len(records) < 10: |
| return 0.0 |
| margins = [] |
| opp_wps = [] |
| for r in records[-20:]: |
| margins.append(r[2]) |
| opp_wps.append(self._wp(all_results[r[3]], 82)) |
| if len(set(opp_wps)) <= 1: |
| return 0.0 |
| m_mean = sum(margins) / len(margins) |
| o_mean = sum(opp_wps) / len(opp_wps) |
| num = sum((m - m_mean) * (o - o_mean) for m, o in zip(margins, opp_wps)) |
| den = (sum((m - m_mean)**2 for m in margins) * sum((o - o_mean)**2 for o in opp_wps)) ** 0.5 |
| return num / den if den > 0 else 0.0 |
|
|
| def _ats_wp(self, records, n): |
| return 0.5 |
|
|
| def _ou_record(self, records, n): |
| return 0.5 |
|
|
| def _clutch_wp(self, records): |
| close = [r for r in records if abs(r[2]) <= 5] |
| return self._wp(close, len(close)) if close else 0.5 |
|
|
| def _comeback_rate(self, records): |
| return 0.5 |
|
|
| def _consistency(self, records, n): |
| s = records[-n:] |
| if len(s) < 3: |
| return 0.0 |
| margins = [x[2] for x in s] |
| mean = sum(margins) / len(margins) |
| return (sum((m - mean)**2 for m in margins) / len(margins)) ** 0.5 |
|
|
| def _h2h_wp(self, h2h, team): |
| if not h2h: |
| return 0.5 |
| wins = sum(1 for r in h2h if (r[1] and r[4].get("team") == team) or |
| (not r[1] and r[4].get("team") != team)) |
| return wins / len(h2h) if h2h else 0.5 |
|
|
| def _h2h_margin(self, h2h, team): |
| if not h2h: |
| return 0.0 |
| margins = [r[2] if r[4].get("team") == team else -r[2] for r in h2h] |
| return sum(margins) / len(margins) if margins else 0.0 |
|
|
| def _h2h_home_wp(self, h2h): |
| if not h2h: |
| return 0.5 |
| home_wins = sum(1 for r in h2h if r[4].get("is_home", False) and r[1]) |
| home_games = sum(1 for r in h2h if r[4].get("is_home", False)) |
| return home_wins / home_games if home_games else 0.5 |
|
|
| def _elo_10_ago(self, records, team_elo, team): |
| |
| return 0.0 |
|
|
| def _record_game(self, team_results, team_last, team_elo, |
| team_home_results, team_away_results, |
| h2h_results, home, away, hs, as_, gd, |
| h_stats, a_stats): |
| """Record game for state tracking.""" |
| margin = hs - as_ |
| home_win = hs > as_ |
|
|
| |
| hs_d = self._parse_stats(h_stats, hs, as_, is_home=True) |
| as_d = self._parse_stats(a_stats, as_, hs, is_home=False) |
| hs_d["team"] = home |
| as_d["team"] = away |
|
|
| team_results[home].append((gd, home_win, margin, away, hs_d)) |
| team_results[away].append((gd, not home_win, -margin, home, as_d)) |
| team_home_results[home].append((gd, home_win, margin, away, hs_d)) |
| team_away_results[away].append((gd, not home_win, -margin, home, as_d)) |
| h2h_results[(home, away)].append((gd, home_win, margin, away, hs_d)) |
| team_last[home] = gd |
| team_last[away] = gd |
|
|
| |
| K = 20 |
| expected_home = 1 / (1 + 10 ** ((team_elo[away] - team_elo[home] - 50) / 400)) |
| result = 1.0 if home_win else 0.0 |
| team_elo[home] += K * (result - expected_home) |
| team_elo[away] += K * ((1 - result) - (1 - expected_home)) |
|
|
| def _update_multi_elo(self, home, away, hs, as_, h_stats, a_stats, |
| team_elo_margin, team_elo_offense, team_elo_defense, |
| team_elo_recency, team_elo_history, |
| team_home_margin_sum, team_home_games_count): |
| """Update multi-ELO rating systems for Category 24 (Power Ratings). |
| |
| Called after _record_game to update: |
| - Margin-adjusted ELO (MOV capped at 20 pts) |
| - Offensive ELO (based on points scored) |
| - Defensive ELO (based on points allowed) |
| - Recency-weighted ELO (K=30, decays faster) |
| - ELO history (for trend / momentum features) |
| - Home court advantage tracking |
| """ |
| margin = hs - as_ |
| home_win = hs > as_ |
|
|
| |
| if isinstance(h_stats, dict): |
| h_ortg = h_stats.get("ortg", hs * 100 / max(h_stats.get("poss", 100), 1)) |
| h_drtg = h_stats.get("drtg", as_ * 100 / max(h_stats.get("poss", 100), 1)) |
| else: |
| h_ortg = hs |
| h_drtg = as_ |
| if isinstance(a_stats, dict): |
| a_ortg = a_stats.get("ortg", as_ * 100 / max(a_stats.get("poss", 100), 1)) |
| a_drtg = a_stats.get("drtg", hs * 100 / max(a_stats.get("poss", 100), 1)) |
| else: |
| a_ortg = as_ |
| a_drtg = hs |
|
|
| result = 1.0 if home_win else 0.0 |
| HCA = 50 |
|
|
| |
| K_m = 20 |
| mov_mult = min(abs(margin), 20) / 10.0 |
| e_m = 1 / (1 + 10 ** ((team_elo_margin[away] - team_elo_margin[home] - HCA) / 400)) |
| team_elo_margin[home] += K_m * mov_mult * (result - e_m) |
| team_elo_margin[away] += K_m * mov_mult * ((1 - result) - (1 - e_m)) |
|
|
| |
| K_o = 15 |
| |
| off_result = hs / max(hs + as_, 1) |
| e_off = 1 / (1 + 10 ** ((team_elo_offense[away] - team_elo_offense[home]) / 400)) |
| team_elo_offense[home] += K_o * (off_result - e_off) |
| team_elo_offense[away] += K_o * ((1 - off_result) - (1 - e_off)) |
|
|
| |
| K_d = 15 |
| |
| def_result = as_ / max(hs + as_, 1) |
| def_result = 1.0 - def_result |
| e_def = 1 / (1 + 10 ** ((team_elo_defense[away] - team_elo_defense[home]) / 400)) |
| team_elo_defense[home] += K_d * (def_result - e_def) |
| team_elo_defense[away] += K_d * ((1 - def_result) - (1 - e_def)) |
|
|
| |
| K_r = 30 |
| e_r = 1 / (1 + 10 ** ((team_elo_recency[away] - team_elo_recency[home] - HCA) / 400)) |
| team_elo_recency[home] += K_r * (result - e_r) |
| team_elo_recency[away] += K_r * ((1 - result) - (1 - e_r)) |
|
|
| |
| team_elo_history[home].append(team_elo_margin[home]) |
| team_elo_history[away].append(team_elo_margin[away]) |
|
|
| |
| team_home_margin_sum[home] += margin |
| team_home_games_count[home] += 1 |
|
|
| def _update_movda(self, home, away, hs, as_, team_movda, mov_surprise_ewm, |
| delta_mov_history, |
| K, C, lam, alpha, beta, gamma, delta_param, ewm_alpha): |
| """Update MOVDA Elo ratings and raw delta_MOV history (Cat 37). arXiv:2506.00348.""" |
| margin = hs - as_ |
| result = 1.0 if margin > 0 else (0.0 if margin < 0 else 0.5) |
| delta_r = team_movda[home] - team_movda[away] |
| e_a = 1.0 / (1.0 + 10.0 ** (-delta_r / C)) |
| e_mov = alpha * np.tanh(beta * delta_r) + gamma + delta_param |
| delta_mov = float(margin) - e_mov |
| movda_update = K * (result - e_a) + lam * delta_mov |
| team_movda[home] += movda_update |
| team_movda[away] -= movda_update |
| mov_surprise_ewm[home] = ewm_alpha * delta_mov + (1 - ewm_alpha) * mov_surprise_ewm[home] |
| mov_surprise_ewm[away] = ewm_alpha * (-delta_mov) + (1 - ewm_alpha) * mov_surprise_ewm[away] |
| |
| delta_mov_history[home].append(delta_mov) |
| delta_mov_history[away].append(-delta_mov) |
|
|
| def _parse_stats(self, stats, pts, opp_pts, is_home=True): |
| """Extract stats from game data. Uses REAL box score when available, estimates otherwise.""" |
| if not isinstance(stats, dict): |
| stats = {} |
|
|
| |
| has_boxscore = "fga" in stats and stats["fga"] is not None and stats["fga"] > 0 |
|
|
| if has_boxscore: |
| |
| fga = stats["fga"] |
| fgm = stats.get("fgm", pts / 2.0) |
| fg3a = stats.get("fg3a", fga * 0.38) |
| fg3m = stats.get("fg3m", fg3a * 0.36) |
| ftm = stats.get("ftm", pts * 0.17) |
| fta = stats.get("fta", ftm / 0.78 if ftm else pts * 0.2) |
| oreb = stats.get("oreb", 10) |
| dreb = stats.get("dreb", 34) |
| tov = stats.get("tov", 13) |
| pf = stats.get("pf", 20) |
| |
| poss = max(fga + 0.44 * fta + tov - oreb, 60) |
| |
| ortg = pts * 100 / poss |
| drtg = opp_pts * 100 / poss |
| pace = poss |
| else: |
| |
| fga = max(pts / 1.1, 80) |
| fgm = pts / 2.0 |
| fg3a = fga * 0.38 |
| fg3m = pts * 0.3 / 3 |
| ftm = pts * 0.17 |
| fta = pts * 0.2 |
| oreb = 10 |
| dreb = 34 |
| tov = 13 |
| pf = 20 |
| poss = stats.get("poss", (pts + opp_pts) / 2.0) |
| ortg = stats.get("ortg", pts * 100 / max(poss, 1)) |
| drtg = stats.get("drtg", opp_pts * 100 / max(poss, 1)) |
| pace = poss |
|
|
| opp_drb = stats.get("opp_dreb", 34) |
|
|
| d = { |
| "pts": pts, "opp_pts": opp_pts, "is_home": is_home, |
| "ortg": ortg, "drtg": drtg, "pace": pace, "poss": poss, |
| "has_boxscore": 1.0 if has_boxscore else 0.0, |
| } |
|
|
| |
| d["efg_pct"] = (fgm + 0.5 * fg3m) / max(fga, 1) if has_boxscore else stats.get("efg_pct", (pts / 2.0) / max(fga, 1)) |
| d["tov_rate"] = tov / max(fga + 0.44 * fta + tov, 1) |
| d["oreb_pct"] = stats.get("oreb_pct", oreb / max(oreb + opp_drb, 1)) |
| d["ft_rate"] = fta / max(fga, 1) |
| d["ts_pct"] = pts / max(2 * (fga + 0.44 * fta), 1) |
|
|
| |
| d["opp_efg_pct"] = stats.get("opp_efg_pct", d["efg_pct"] * 0.95) |
| d["opp_tov_rate"] = stats.get("opp_tov_rate", d["tov_rate"]) |
| d["opp_oreb_pct"] = stats.get("opp_oreb_pct", d["oreb_pct"]) |
| d["opp_ft_rate"] = stats.get("opp_ft_rate", d["ft_rate"]) |
|
|
| |
| d["3par"] = fg3a / max(fga, 1) if has_boxscore else stats.get("3par", fg3m * 3 / max(pts, 1)) |
| d["fg3_pct"] = fg3m / max(fg3a, 1) if has_boxscore else stats.get("fg3_pct", 0.36) |
| d["fg2_pct"] = (fgm - fg3m) / max(fga - fg3a, 1) if has_boxscore else stats.get("fg2_pct", 0.52) |
| d["ft_pct"] = ftm / max(fta, 1) if has_boxscore else stats.get("ft_pct", 0.78) |
| d["paint_pts"] = stats.get("paint_pts", pts * 0.4) |
| d["fb_pts"] = stats.get("fb_pts", pts * 0.1) |
| d["bench_pts"] = stats.get("bench_pts", pts * 0.3) |
| d["2nd_pts"] = stats.get("2nd_pts", pts * 0.1) |
| d["pitp"] = stats.get("pitp", pts * 0.4) |
| d["pts_off_tov"] = stats.get("pts_off_tov", pts * 0.12) |
|
|
| |
| d["ast_rate"] = stats.get("ast_rate", stats.get("ast", 24) / max(fgm, 1) if has_boxscore else 0.6) |
| d["stl_rate"] = stats.get("stl_rate", stats.get("stl", 7) / max(poss, 1) if has_boxscore else 0.08) |
| d["blk_rate"] = stats.get("blk_rate", stats.get("blk", 5) / max(fga, 1) if has_boxscore else 0.05) |
| d["tov_pct"] = d["tov_rate"] |
| d["dreb_pct"] = stats.get("dreb_pct", dreb / max(dreb + oreb, 1)) |
|
|
| |
| d["opp_fg3_pct"] = stats.get("opp_fg3_pct", 0.36) |
| d["opp_paint_pts"] = stats.get("opp_paint_pts", opp_pts * 0.4) |
|
|
| return d |
|
|
| def _is_rivalry(self, home, away): |
| """Check if divisional rivalry.""" |
| return self._division(home) == self._division(away) |
|
|
| def _division(self, team): |
| divisions = { |
| "ATL": 2, "CHA": 2, "MIA": 2, "ORL": 2, "WAS": 2, |
| "BOS": 0, "BKN": 0, "NYK": 0, "PHI": 0, "TOR": 0, |
| "CHI": 1, "CLE": 1, "DET": 1, "IND": 1, "MIL": 1, |
| "DAL": 3, "HOU": 3, "MEM": 3, "NOP": 3, "SAS": 3, |
| "DEN": 4, "MIN": 4, "OKC": 4, "POR": 4, "UTA": 4, |
| "GSW": 5, "LAC": 5, "LAL": 5, "PHX": 5, "SAC": 5, |
| } |
| return divisions.get(team, 0) / 5.0 |
|
|
| def _conference(self, team): |
| east = {"ATL", "BOS", "BKN", "CHA", "CHI", "CLE", "DET", "IND", |
| "MIA", "MIL", "NYK", "ORL", "PHI", "TOR", "WAS"} |
| return 0 if team in east else 1 |
|
|
| def _get_dow(self, date_str): |
| """Get day of week (0=Mon, 6=Sun) from date string.""" |
| try: |
| return datetime.strptime(date_str[:10], "%Y-%m-%d").weekday() |
| except (ValueError, TypeError, AttributeError): |
| return 2 |
|
|
| def _get_month(self, date_str): |
| """Get month (1-12) from date string.""" |
| try: |
| return datetime.strptime(date_str[:10], "%Y-%m-%d").month |
| except (ValueError, TypeError, AttributeError): |
| return 1 |
|
|
|
|
| |
|
|
| def genetic_feature_selection(X, y, feature_names, n_generations=50, |
| population_size=100, target_features=200): |
| """ |
| Use genetic algorithm to find optimal feature subset. |
| |
| Chromosome: binary vector (1=include, 0=exclude) |
| Fitness: negative Brier score (minimize) on walk-forward CV |
| |
| Args: |
| X: Full feature matrix (n_games, ~6000) |
| y: Labels |
| feature_names: List of feature names |
| n_generations: Number of GA generations |
| population_size: Population size |
| target_features: Target number of features to select |
| |
| Returns: |
| selected_indices: Indices of selected features |
| selected_names: Names of selected features |
| fitness_history: Fitness per generation |
| """ |
| from sklearn.model_selection import TimeSeriesSplit |
| from sklearn.metrics import brier_score_loss |
| import random |
|
|
| try: |
| import xgboost as xgb |
| model_cls = lambda: xgb.XGBClassifier( |
| n_estimators=200, max_depth=5, learning_rate=0.05, |
| eval_metric="logloss", random_state=42, n_jobs=-1 |
| ) |
| except ImportError: |
| from sklearn.ensemble import GradientBoostingClassifier |
| model_cls = lambda: GradientBoostingClassifier( |
| n_estimators=200, max_depth=5, random_state=42 |
| ) |
|
|
| n_features = X.shape[1] |
| tscv = TimeSeriesSplit(n_splits=3) |
| random.seed(42) |
|
|
| def fitness(chromosome): |
| """Evaluate chromosome fitness = negative Brier score.""" |
| selected = [i for i, bit in enumerate(chromosome) if bit] |
| if len(selected) < 10 or len(selected) > target_features: |
| return -0.30 |
| X_sub = X[:, selected] |
| briers = [] |
| for ti, vi in tscv.split(X_sub): |
| try: |
| m = model_cls() |
| m.fit(X_sub[ti], y[ti]) |
| p = m.predict_proba(X_sub[vi])[:, 1] |
| briers.append(brier_score_loss(y[vi], p)) |
| except Exception: |
| briers.append(0.30) |
| return -np.mean(briers) |
|
|
| def crossover(parent1, parent2): |
| """Two-point crossover.""" |
| pt1 = random.randint(0, n_features - 1) |
| pt2 = random.randint(pt1, n_features - 1) |
| child = parent1[:pt1] + parent2[pt1:pt2] + parent1[pt2:] |
| return child |
|
|
| def mutate(chromosome, rate=0.02): |
| """Flip random bits.""" |
| return [1 - bit if random.random() < rate else bit for bit in chromosome] |
|
|
| |
| population = [] |
| for _ in range(population_size): |
| prob = target_features / n_features |
| chromo = [1 if random.random() < prob else 0 for _ in range(n_features)] |
| population.append(chromo) |
|
|
| best_fitness = -1.0 |
| best_chromosome = None |
| fitness_history = [] |
|
|
| print(f"Genetic Feature Selection: {n_features} candidates → ~{target_features} target") |
| print(f"Population: {population_size}, Generations: {n_generations}") |
|
|
| for gen in range(n_generations): |
| |
| scores = [fitness(c) for c in population] |
|
|
| |
| gen_best = max(scores) |
| gen_best_idx = scores.index(gen_best) |
| if gen_best > best_fitness: |
| best_fitness = gen_best |
| best_chromosome = population[gen_best_idx][:] |
|
|
| n_selected = sum(best_chromosome) if best_chromosome else 0 |
| fitness_history.append(-gen_best) |
| print(f" Gen {gen+1}/{n_generations}: Best Brier={-gen_best:.4f} " |
| f"(features: {n_selected})") |
|
|
| |
| new_pop = [best_chromosome[:]] |
| while len(new_pop) < population_size: |
| |
| contestants = random.sample(list(zip(population, scores)), 5) |
| p1 = max(contestants, key=lambda x: x[1])[0] |
| contestants = random.sample(list(zip(population, scores)), 5) |
| p2 = max(contestants, key=lambda x: x[1])[0] |
|
|
| child = crossover(p1, p2) |
| child = mutate(child) |
| new_pop.append(child) |
|
|
| population = new_pop |
|
|
| |
| selected_indices = [i for i, bit in enumerate(best_chromosome) if bit] |
| selected_names = [feature_names[i] for i in selected_indices] |
|
|
| print(f"\nSelected {len(selected_indices)} features (Brier: {-best_fitness:.4f})") |
| return selected_indices, selected_names, fitness_history |
|
|
|
|
| if __name__ == "__main__": |
| print(f"NBA Feature Engine initialized") |
| engine = NBAFeatureEngine(include_market=False) |
| print(f"Feature candidates: {len(engine.feature_names)}") |
| print(f"\nCategories:") |
| categories = defaultdict(int) |
| for name in engine.feature_names: |
| if name.startswith(("h_wp", "a_wp", "h_pd", "a_pd", "h_ppg", "a_ppg", |
| "h_papg", "a_papg", "h_margin", "a_margin", |
| "h_close", "a_close", "h_blowout", "a_blowout", |
| "h_ou_avg", "a_ou_avg")): |
| categories["1. Rolling Performance"] += 1 |
| elif "efg" in name or "tov_rate" in name or "orb_rate" in name or "ft_rate" in name: |
| categories["2. Four Factors"] += 1 |
| elif "ortg" in name or "drtg" in name or "pace" in name or "ts" in name: |
| categories["3. Pace & Efficiency"] += 1 |
| elif "3par" in name or "3p_pct" in name or "paint" in name or "bench" in name: |
| categories["4. Scoring Profile"] += 1 |
| elif "streak" in name or "momentum" in name or "trend" in name: |
| categories["5. Momentum"] += 1 |
| elif "rest" in name or "b2b" in name or "travel" in name or "fatigue" in name: |
| categories["6. Rest & Schedule"] += 1 |
| elif "sos" in name or "vs_top" in name or "vs_above" in name: |
| categories["7. Opponent-Adjusted"] += 1 |
| elif "h2h" in name or "matchup" in name or "elo" in name: |
| categories["8. Matchup & H2H"] += 1 |
| elif "spread" in name or "ml_" in name or "clv" in name or "steam" in name: |
| categories["9. Market Microstructure"] += 1 |
| else: |
| categories["10. Context & Situational"] += 1 |
|
|
| for cat, count in sorted(categories.items()): |
| print(f" {cat}: {count}") |
|
|