diff --git "a/features/engine.py" "b/features/engine.py" new file mode 100644--- /dev/null +++ "b/features/engine.py" @@ -0,0 +1,8012 @@ +#!/usr/bin/env python3 +""" +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) + ≈ 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 ── +ENGINE_VERSION = "v3.1-65cat" # Cat65: Style Matchup Advantage (4-factor offense vs defense matchup edges) + +# ── Team mappings ── +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 coordinates for travel distance (lat, lon) +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 altitudes (feet) — Denver is the key outlier +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 offsets from ET +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] # Rolling windows + + +# Alias map for non-standard team names (Bovada, international sources, etc.) +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 # Earth radius in miles + 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)) + + +# ── Odds Data Loader (Cat 46) ── + +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 + # Decimal odds are almost always between 1.01 and 50.0 + # American odds are < -100 or > 100 (e.g., -250, +200) + 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: + # Try common paths + 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 = {} + # Also store multi-book data for books_disagreement + multi_book = defaultdict(list) # (date, home, away) -> [implied_home_prob, ...] + + 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 + + # Parse moneylines — handle both American and decimal formats + 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): + # Decimal odds format + ip_home = _decimal_to_implied_prob(ml_home_str) + ip_away = _decimal_to_implied_prob(ml_away_str) + # Convert to American for storage + 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: + # American odds format + 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 (vig) + overround = ip_home + ip_away # typically 1.04-1.08 + + # Fair (vig-removed) probabilities + if overround > 0: + fair_home = ip_home / overround + fair_away = ip_away / overround + else: + fair_home = 0.5 + fair_away = 0.5 + + # Parse spread and total (may be missing) + 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) + + # Prefer betmgm over other books; first entry wins if same book + if key not in lookup or (book == 'betmgm' and lookup[key].get('book') != 'betmgm'): + lookup[key] = entry + + except Exception as e: + # Silently return empty on any file error + return {} + + # Add books_disagreement where multiple books exist + 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 + + +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): + self.include_market = include_market + self.skip_placeholder = skip_placeholder + self.feature_names = [] + self._build_feature_names() + + def _build_feature_names(self): + """Pre-compute all feature names for documentation.""" + names = [] + + # 1. ROLLING PERFORMANCE (96 features) + for prefix in ["h", "a"]: + for w in WINDOWS: + names.append(f"{prefix}_wp{w}") # Win % + names.append(f"{prefix}_pd{w}") # Point diff + names.append(f"{prefix}_ppg{w}") # Points per game + names.append(f"{prefix}_papg{w}") # Points allowed per game + names.append(f"{prefix}_margin{w}") # Avg margin + names.append(f"{prefix}_close{w}") # Close game % (margin <= 5) + names.append(f"{prefix}_blowout{w}") # Blowout % (margin >= 15) + names.append(f"{prefix}_ou_avg{w}") # Over/under average + + # 2. FOUR FACTORS (Dean Oliver) — per window (64 features) + for prefix in ["h", "a"]: + for w in [5, 10]: + names.append(f"{prefix}_efg{w}") # Effective FG% + names.append(f"{prefix}_tov_rate{w}") # Turnover rate + names.append(f"{prefix}_orb_rate{w}") # Offensive rebound rate + names.append(f"{prefix}_ft_rate{w}") # Free throw rate + names.append(f"{prefix}_opp_efg{w}") # Opponent eFG% + names.append(f"{prefix}_opp_tov{w}") # Opponent TOV rate + names.append(f"{prefix}_opp_orb{w}") # Opponent ORB rate + names.append(f"{prefix}_opp_ft{w}") # Opponent FT rate + + # 3. PACE & EFFICIENCY (48 features) + for prefix in ["h", "a"]: + for w in [5, 10]: + names.append(f"{prefix}_ortg{w}") # Offensive rating (pts/100 poss) + names.append(f"{prefix}_drtg{w}") # Defensive rating + names.append(f"{prefix}_netrtg{w}") # Net rating + names.append(f"{prefix}_pace{w}") # Pace (possessions/48min) + names.append(f"{prefix}_ts{w}") # True shooting % + names.append(f"{prefix}_poss{w}") # Avg possessions + names.append(f"{prefix}_ast_rate{w}") # Assist rate + names.append(f"{prefix}_stl_rate{w}") # Steal rate + names.append(f"{prefix}_blk_rate{w}") # Block rate + names.append(f"{prefix}_tov_pct{w}") # Turnover % + names.append(f"{prefix}_oreb_pct{w}") # Off rebound % + names.append(f"{prefix}_dreb_pct{w}") # Def rebound % + + # 4. SCORING PROFILE (40 features) + for prefix in ["h", "a"]: + for w in [5, 10]: + names.append(f"{prefix}_3par{w}") # 3-point attempt rate + names.append(f"{prefix}_3p_pct{w}") # 3-point % + names.append(f"{prefix}_2p_pct{w}") # 2-point % + names.append(f"{prefix}_ft_pct{w}") # Free throw % + names.append(f"{prefix}_paint_pts{w}") # Paint points avg + names.append(f"{prefix}_fb_pts{w}") # Fast break points avg + names.append(f"{prefix}_bench_pts{w}") # Bench points avg + names.append(f"{prefix}_2nd_pts{w}") # Second chance points + names.append(f"{prefix}_pitp{w}") # Points in the paint + names.append(f"{prefix}_pts_off_tov{w}") # Points off turnovers + + # 5. MOMENTUM & STREAKS (32 features) + for prefix in ["h", "a"]: + names.append(f"{prefix}_streak") # Current W/L streak + names.append(f"{prefix}_streak_abs") # Absolute streak length + names.append(f"{prefix}_last5_vs_season") # Last 5 wp - season wp (form delta) + names.append(f"{prefix}_last3_vs_last10") # Short vs medium term momentum + names.append(f"{prefix}_home_wp") # Home record + names.append(f"{prefix}_away_wp") # Away record + names.append(f"{prefix}_ha_split") # Home-away split + names.append(f"{prefix}_ats_wp5") # Against the spread win % (last 5) + names.append(f"{prefix}_ou_record5") # Over/under record (last 5) + names.append(f"{prefix}_scoring_trend") # PPG trend (last 5 vs last 20) + names.append(f"{prefix}_defense_trend") # PAPG trend + names.append(f"{prefix}_clutch_wp") # Win % in games decided by <= 5 + names.append(f"{prefix}_blowout_rate") # % of games decided by 15+ + names.append(f"{prefix}_comeback_rate") # % trailing at half but winning + names.append(f"{prefix}_consistency") # StdDev of point diff (lower = more consistent) + names.append(f"{prefix}_recent_margin_std") # Variance in recent margins + + # 6. REST & SCHEDULE (26 features) + names.extend([ + "h_rest_days", "a_rest_days", + "rest_advantage", # h_rest - a_rest + "h_b2b", "a_b2b", # Back-to-back (0/1) + "h_3in4", "a_3in4", # 3 games in 4 days + "h_4in6", "a_4in6", # 4 games in 6 days + "h_travel_dist", "a_travel_dist", # Miles traveled (last game → this) + "travel_advantage", # h_travel - a_travel (negative = home rested) + "h_altitude", "a_altitude", # Arena altitude + "altitude_delta", # Home altitude - away altitude (DEN advantage) + "h_tz_shift", "a_tz_shift", # Timezone hours shifted + "tz_advantage", # Timezone advantage + "h_games_7d", "a_games_7d", # Games played in last 7 days + "h_miles_7d", "a_miles_7d", # Total miles in last 7 days + "schedule_density_diff", # h_games_7d - a_games_7d + "combined_fatigue", # Composite fatigue score + "h_next_is_home", "a_next_is_home", # Forward schedule: next game is home? (research top-3 predictor) + ]) + + # 7. OPPONENT-ADJUSTED (24 features) + for prefix in ["h", "a"]: + names.append(f"{prefix}_sos5") # Strength of schedule (last 5) + names.append(f"{prefix}_sos10") # SOS (last 10) + names.append(f"{prefix}_sos_season") # Season SOS + names.append(f"{prefix}_wp_vs_above500") # Win% vs winning teams + names.append(f"{prefix}_wp_vs_below500") # Win% vs losing teams + names.append(f"{prefix}_wp_vs_top10") # Win% vs top 10 + names.append(f"{prefix}_wp_vs_bot10") # Win% vs bottom 10 + names.append(f"{prefix}_pd_vs_top10") # Point diff vs top 10 + names.append(f"{prefix}_pd_vs_bot10") # Point diff vs bottom 10 + names.append(f"{prefix}_opp_avg_ortg") # Avg opponent ORtg (last 10) + names.append(f"{prefix}_opp_avg_drtg") # Avg opponent DRtg (last 10) + names.append(f"{prefix}_margin_vs_quality") # Margin corr with opp quality + + # 8. MATCHUP & HEAD-TO-HEAD (20 features) + names.extend([ + "h2h_wp", # H2H win % (last 3 seasons) + "h2h_last3_wp", # H2H last 3 meetings + "h2h_avg_margin", # Average margin in H2H + "h2h_home_wp", # H2H home team win % + "pace_delta", # Home pace - Away pace (style clash) + "style_mismatch", # ORtg vs opponent DRtg gap + "3pt_matchup", # Home 3P% vs Away opp 3P% + "paint_matchup", # Home paint pts vs Away opp paint pts + "tempo_mismatch", # Absolute pace difference + "defensive_matchup", # How well defenses match up + "rebound_edge", # Total rebound rate diff + "turnover_edge", # Turnover rate diff + "free_throw_edge", # FT rate diff + "bench_depth_diff", # Bench scoring diff + "consistency_matchup", # Lower variance team + "fatigue_adjusted_rating", # NetRtg adjusted for rest/travel + "elo_home", "elo_away", # Elo ratings + "elo_diff", # Elo difference + home court + "elo_recent_change", # Elo change last 10 games + ]) + + # 9. MARKET MICROSTRUCTURE (32 features) + if self.include_market: + names.extend([ + "opening_spread", "current_spread", + "spread_movement", # current - opening (line movement) + "spread_movement_abs", # Absolute movement + "reverse_line_movement", # 1 if line moved opposite to public + "opening_total", "current_total", + "total_movement", + "opening_ml_home", "current_ml_home", + "ml_movement", # Moneyline movement + "implied_prob_home", "implied_prob_away", + "model_vs_market", # Our model prob - market implied prob + "edge_magnitude", # Absolute value of edge + "books_disagreement", # Max - Min implied prob across books + "sharp_line", # Pinnacle-implied probability + "public_pct_home", # % of public bets on home + "public_money_pct_home", # % of money on home + "smart_money_indicator", # Money% - Bet% divergence + "steam_move", # 1 if sharp steam move detected + "clv_recent_avg", # Avg CLV of our recent bets + "market_efficiency", # How much line has moved (settled) + "opening_overround", # Total implied prob > 1 + "best_available_odds_home", # Best odds across books + "best_available_odds_away", + "odds_range_home", # Max - min odds (disagreement) + "odds_range_away", + "time_to_close", # Hours until game + "late_money_direction", # Direction of late sharp money + "closing_line_estimate", # Estimated closing line + "historical_clv_vs_book", # Our historical CLV vs this book + "longshot_flag", # 1 if any side > 5.0 odds + ]) + + # 10. CONTEXT & SITUATIONAL (24 features) + names.extend([ + "home_court_adv", # 1.0 always (baseline) + "season_phase", # 0-1 (early → late) + "month_sin", "month_cos", # Cyclical month encoding + "day_of_week", # 0-6 (encoded) + "is_weekend", # 1 if Sat/Sun + "is_national_tv", # 1 if ESPN/TNT (higher motivation) + "h_games_played", "a_games_played", # Season games played + "h_season_pct", "a_season_pct", # % of season completed + "playoff_race", # 1 if both teams in playoff contention + "tanking_flag", # 1 if either team eliminated + "rivalry", # 1 if divisional rivalry + "h_division", "a_division", # Division encoding (0-5) + "same_division", # 1 if same division + "same_conference", # 1 if same conference + "conference_game", # 1 if cross-conference + "power_rank_diff", # Power ranking difference + "vegas_home_fav", # 1 if home is favored + "combined_record", # Combined win% (quality indicator) + "game_importance_score", # Playoff implications + "total_expected", # Expected total points + ]) + + # 11-15. PLACEHOLDER CATEGORIES (skippable — all zeros/defaults without real data) + if not self.skip_placeholder: + # 11. REFEREE FEATURES (10 features) — NEW 2026 + names.extend([ + "ref_home_foul_bias", # Avg (home_fouls - away_fouls) for this crew + "ref_total_fouls_avg", # Avg total fouls called per game + "ref_foul_rate_vs_league", # This crew's foul rate vs league avg + "ref_home_ft_advantage", # Avg FTA differential (home-away) for crew + "ref_experience_games", # Total games officiated this season + "ref_over_tendency", # % of games going over total for crew + "ref_close_game_bias", # Home win % in close games for crew + "ref_tech_foul_rate", # Technical fouls per game for crew + "ref_home_win_rate", # Home team win % with this crew + "ref_pace_impact", # Avg pace delta vs league avg for crew + ]) + + # 12. PLAYER IMPACT FEATURES (16 features) — NEW 2026 + for prefix in ["h", "a"]: + names.append(f"{prefix}_star_usage_rate") # Top 2 players usage rate combined + names.append(f"{prefix}_star_minutes_load") # Top 2 players avg minutes last 5 + names.append(f"{prefix}_injury_impact_score") # Weighted injury severity (0-1) + names.append(f"{prefix}_injured_war_lost") # WAR of injured players + names.append(f"{prefix}_lineup_continuity") # % same starting lineup last 5 + names.append(f"{prefix}_bench_depth_rating") # Bench net rating last 10 + names.append(f"{prefix}_star_rest_status") # 1 if star on B2B, 0.5 if 1 rest day + names.append(f"{prefix}_rotation_depth") # Number of players with 10+ min + + # 13. QUARTER-LEVEL PATTERNS (14 features) — NEW 2026 + for prefix in ["h", "a"]: + names.append(f"{prefix}_q1_margin_avg") # Avg Q1 margin (last 10) + names.append(f"{prefix}_q3_margin_avg") # Avg Q3 margin (comeback indicator) + names.append(f"{prefix}_q4_clutch_netrtg") # Net rating last 5 min, close games + names.append(f"{prefix}_half_adjustment") # Q3 performance vs Q1 (coaching adj) + names.append(f"{prefix}_comeback_win_pct") # Win% when trailing after Q3 + names.append(f"{prefix}_blowout_hold_pct") # % holding 10+ pt leads + names.append(f"{prefix}_garbage_time_margin") # Avg margin change in Q4 blowouts + + # 14. DEFENSIVE MATCHUP ADVANCED (12 features) — NEW 2026 + for prefix in ["h", "a"]: + names.append(f"{prefix}_paint_defense_rating") # Opp points in paint allowed + names.append(f"{prefix}_perimeter_defense") # Opp 3pt% allowed + names.append(f"{prefix}_transition_defense") # Opp fast break pts allowed + names.append(f"{prefix}_shot_contest_rate") # % of shots contested + names.append(f"{prefix}_deflections_per_game") # Deflections avg + names.append(f"{prefix}_rim_protection_rate") # FG% allowed at rim + + # 15. POLYMARKET & PREDICTION MARKET (8 features) — NEW 2026 + names.extend([ + "polymarket_home_prob", # Polymarket implied probability + "polymarket_volume", # Trading volume (confidence indicator) + "polymarket_line_movement", # Movement in last 6 hours + "polymarket_vs_books", # Polymarket prob - books prob (divergence) + "prediction_market_consensus", # Avg of multiple prediction markets + "market_wisdom_confidence", # How much markets agree (1 - std) + "smart_vs_public_divergence", # Sharp money vs public bets + "closing_line_value_history", # Our historical CLV performance + ]) + + # ===================================================================== + # CATEGORIES 16-25: ADVANCED FEATURE EXPANSION (1400+ new features) + # These features are registered here for genetic selection. + # Computation is handled in build_v2() (separate step). + # ===================================================================== + + # 16. INTERACTION & POLYNOMIAL FEATURES (210 features) + # Pairwise interactions between key stats + 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"), + # Additional interaction pairs for higher coverage + ("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 features (30 features) + 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}") + + # Squared terms (30 features) + 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 delta features: short_window - long_window (100 features) + 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}") + + # 17. ADVANCED ROLLING STATISTICS (168 features) + # EWMA features: 3 alphas × 7 stats × 2 teams = 42 + 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}") + + # Rolling volatility (std dev): 3 windows × 6 stats × 2 teams = 36 + 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}") + + # Rolling min/max: 2 windows × 4 stats × 2 (min/max) × 2 teams = 32 + 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}") + + # Z-scores relative to season avg: 10 stats × 2 teams = 20 + 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}") + + # Rolling skew and kurtosis: 4 stats × 2 teams × 2 = 16 + 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") + + # Range (max - min): 4 stats × 2 windows × 2 teams = 16 + for prefix in ["h", "a"]: + for stat in ["ppg", "margin", "ortg", "drtg"]: + for w in [5, 10]: + names.append(f"{prefix}_range_{stat}_{w}") + + # Coefficient of variation: 3 stats × 2 teams = 6 + for prefix in ["h", "a"]: + for stat in ["ppg", "margin", "ortg"]: + names.append(f"{prefix}_cv_{stat}_10") + + # 18. SEASON TRAJECTORY & CONTEXT (84 features) + for prefix in ["h", "a"]: + names.append(f"{prefix}_pyth_wp") # Pythagorean win expectation + names.append(f"{prefix}_pyth_vs_actual") # Pythagorean - actual (luck measure) + names.append(f"{prefix}_win_pace_82") # Projected wins over 82 games + names.append(f"{prefix}_playoff_pace_delta") # Win pace vs playoff threshold (42 wins) + names.append(f"{prefix}_games_behind_1st") # Games behind conf leader + names.append(f"{prefix}_games_behind_8th") # Games behind 8th seed (playoff line) + names.append(f"{prefix}_games_ahead_lottery") # Games ahead of lottery (worst record) + names.append(f"{prefix}_sos_remaining") # Strength of remaining schedule + names.append(f"{prefix}_conf_rank") # Conference ranking + names.append(f"{prefix}_div_rank") # Division ranking + names.append(f"{prefix}_is_playoff_team") # 1 if currently in top 10 + names.append(f"{prefix}_playin_range") # 1 if 7th-12th seed + names.append(f"{prefix}_pre_allstar_wp") # Win% before All-Star break + names.append(f"{prefix}_post_allstar_wp") # Win% after All-Star break + names.append(f"{prefix}_allstar_delta") # Post - pre All-Star performance + names.append(f"{prefix}_pre_deadline_wp") # Win% before trade deadline + names.append(f"{prefix}_post_deadline_wp") # Win% after trade deadline + names.append(f"{prefix}_deadline_delta") # Post - pre deadline performance + names.append(f"{prefix}_monthly_wp_trend") # Month-over-month win% change + names.append(f"{prefix}_monthly_ortg_trend") # Month-over-month ORtg change + names.append(f"{prefix}_monthly_drtg_trend") # Month-over-month DRtg change + names.append(f"{prefix}_season_half_improvement") # 2nd half vs 1st half win% + names.append(f"{prefix}_regression_indicator") # How far from league avg (mean reversion) + names.append(f"{prefix}_hot_cold_regime") # 1=hot, 0=neutral, -1=cold (last 15 games) + names.append(f"{prefix}_clinch_status") # 0=eliminated, 1=alive, 2=clinched + names.append(f"{prefix}_games_remaining") # Games left in regular season + names.append(f"{prefix}_wp_last30") # Win% last 30 games + names.append(f"{prefix}_wp_last30_vs_season") # Last 30 - season (late form) + names.append(f"{prefix}_home_road_trend_5") # Recent home/road split trend + names.append(f"{prefix}_scoring_variance_trend") # Is scoring becoming more consistent? + names.append(f"{prefix}_first_half_margin_avg") # Avg 1st half margin last 10 + names.append(f"{prefix}_second_half_margin_avg") # Avg 2nd half margin last 10 + names.append(f"{prefix}_half_margin_delta") # 2nd half - 1st half margin trend + names.append(f"{prefix}_record_vs_spread") # ATS record (season) + names.append(f"{prefix}_ats_trend_10") # ATS trend last 10 + names.append(f"{prefix}_over_rate_season") # % overs this season + names.append(f"{prefix}_pt_diff_close_vs_all") # Point diff in close games vs all + names.append(f"{prefix}_record_after_loss") # Win% after a loss (resilience) + names.append(f"{prefix}_record_after_win") # Win% after a win (consistency) + names.append(f"{prefix}_record_after_b2b") # Win% day after B2B + names.append(f"{prefix}_blowout_bounce_back") # Win% after losing by 15+ + names.append(f"{prefix}_overtime_record") # Win% in OT games + # game-level trajectory + names.extend([ + "trajectory_wp_diff", # h_wp_last30 - a_wp_last30 + "trajectory_pyth_diff", # h_pyth - a_pyth + ]) + + # 19. LINEUP & ROTATION ANALYTICS (64 features) + for prefix in ["h", "a"]: + names.append(f"{prefix}_starting5_netrtg") # Estimated starting 5 net rating + names.append(f"{prefix}_starting5_ortg") # Starting 5 offensive rating + names.append(f"{prefix}_starting5_drtg") # Starting 5 defensive rating + names.append(f"{prefix}_bench_netrtg") # Bench unit net rating + names.append(f"{prefix}_bench_ortg") # Bench unit offensive rating + names.append(f"{prefix}_bench_drtg") # Bench unit defensive rating + names.append(f"{prefix}_starter_bench_gap") # Starter netrtg - bench netrtg + names.append(f"{prefix}_minutes_entropy") # Entropy of minutes distribution + names.append(f"{prefix}_minutes_gini") # Gini coefficient of minutes + names.append(f"{prefix}_top_scorer_dependency") # Top scorer pts / team pts + names.append(f"{prefix}_top2_scorer_dependency") # Top 2 scorers pts / team pts + names.append(f"{prefix}_top_scorer_minutes") # Top scorer avg minutes + names.append(f"{prefix}_key_player_availability") # Weighted availability of top 5 + names.append(f"{prefix}_key_player_impact") # RPM of available key players + names.append(f"{prefix}_lineup_stability_10") # Lineup stability last 10 + names.append(f"{prefix}_lineup_stability_5") # Lineup stability last 5 + names.append(f"{prefix}_rotation_size") # Players with 15+ min avg + names.append(f"{prefix}_two_man_combo_best") # Best 2-man combo net rating + names.append(f"{prefix}_two_man_combo_worst") # Worst 2-man combo net rating + names.append(f"{prefix}_three_pt_shooters_count") # Players shooting > 35% from 3 + names.append(f"{prefix}_rim_protector_rating") # Best rim protector impact + names.append(f"{prefix}_playmaker_rating") # Best playmaker assist rate + names.append(f"{prefix}_defensive_versatility") # Positions that can switch + names.append(f"{prefix}_size_advantage") # Avg height/weight vs league + names.append(f"{prefix}_speed_advantage") # Pace proxy from lineup composition + names.append(f"{prefix}_experience_avg") # Avg years in NBA for rotation + names.append(f"{prefix}_age_avg") # Avg age of rotation players + names.append(f"{prefix}_youth_factor") # % of minutes to players < 24 yrs + names.append(f"{prefix}_veteran_factor") # % of minutes to players > 30 yrs + names.append(f"{prefix}_clutch_player_rating") # Best closer rating + names.append(f"{prefix}_injury_adjusted_depth") # Effective depth with injuries + names.append(f"{prefix}_recent_lineup_change") # 1 if lineup changed in last 3 + # matchup-level lineup features + names.extend([ + "lineup_netrtg_diff", # h_starting5_netrtg - a_starting5_netrtg + "bench_quality_diff", # h_bench_netrtg - a_bench_netrtg + ]) + + # 20. GAME THEORY & META FEATURES (82 features) + for prefix in ["h", "a"]: + names.append(f"{prefix}_model_accuracy_10") # Our model accuracy for this team (last 10) + names.append(f"{prefix}_model_accuracy_30") # Our model accuracy for this team (last 30) + names.append(f"{prefix}_model_calibration_bias") # Over/under prediction tendency + names.append(f"{prefix}_model_avg_edge") # Avg edge when predicting this team + names.append(f"{prefix}_model_roi_this_team") # ROI on bets involving this team + names.append(f"{prefix}_feat_importance_rank") # How feature-important this team is + names.append(f"{prefix}_prediction_confidence") # Avg model confidence for this team + names.append(f"{prefix}_upset_rate_as_fav") # % upsets when favored + names.append(f"{prefix}_upset_rate_as_dog") # % upsets when underdog + names.append(f"{prefix}_market_overreaction") # Market moves too much after wins/losses + names.append(f"{prefix}_public_bias_team") # How much public over/undervalues + names.append(f"{prefix}_contrarian_value") # Inverse of public betting % + names.append(f"{prefix}_line_sensitivity") # How much line moves with news + names.append(f"{prefix}_steam_target_freq") # How often targeted by steam moves + names.append(f"{prefix}_sharp_favorite") # How often sharps bet this team + names.append(f"{prefix}_model_disagreement") # Our prob vs market prob (team-specific) + # game-level meta features + names.extend([ + "meta_model_vs_market_abs", # Absolute model-market divergence + "meta_model_vs_market_direction", # Sign of model-market divergence + "meta_model_confidence", # How confident our model is (max prob) + "meta_market_confidence", # How confident market is (implied prob range) + "meta_consensus_strength", # Agreement across multiple model variants + "meta_historical_matchup_accuracy", # Our past accuracy on this matchup type + "meta_bankroll_adjusted_edge", # Edge × Kelly fraction + "meta_risk_adjusted_value", # Edge / volatility of this bet type + "meta_opp_strategy_fast", # Opponent likely pace (fast indicator) + "meta_opp_strategy_slow", # Opponent likely pace (slow indicator) + "meta_opp_strategy_defensive", # Opponent defensive game plan indicator + "meta_opp_strategy_three_heavy", # Opponent 3pt heavy game plan + "meta_game_type_cluster", # Cluster ID for similar historical games + "meta_cluster_home_win_rate", # Historical home win% in this cluster + "meta_recent_model_drift", # How much model predictions have shifted + "meta_feature_regime", # Which feature set is currently predictive + "meta_market_regime", # Current market efficiency regime + "meta_total_edge_composite", # Weighted sum of all edge signals + ]) + + # 21. ENVIRONMENTAL & EXTERNAL (44 features) + for prefix in ["h", "a"]: + names.append(f"{prefix}_conf_standing_pct") # Conf standing percentile + names.append(f"{prefix}_div_standing_pct") # Division standing percentile + names.append(f"{prefix}_lottery_odds_proxy") # Tanking incentive (worse record = higher) + names.append(f"{prefix}_tank_indicator") # 1 if bottom 5, eliminated + names.append(f"{prefix}_playoff_urgency") # How much each game matters for playoff + names.append(f"{prefix}_revenge_game") # 1 if lost badly (15+) to this opp last meeting + names.append(f"{prefix}_revenge_intensity") # Margin of last loss to this opponent + names.append(f"{prefix}_coach_exp_years") # Coach experience in years + names.append(f"{prefix}_coach_win_rate") # Coach career win% + names.append(f"{prefix}_coach_playoff_rate") # Coach playoff appearance rate + names.append(f"{prefix}_coach_vs_opp_coach") # H2H record coach vs opposing coach + names.append(f"{prefix}_coach_adjustment_rating") # In-game adjustment quality proxy + names.append(f"{prefix}_public_team_popularity") # Market popularity (bet volume proxy) + names.append(f"{prefix}_media_attention") # National TV game frequency + names.append(f"{prefix}_roster_turnover") # % new players this season + names.append(f"{prefix}_chemistry_index") # Games together for starting 5 + # game-level environmental + names.extend([ + "conf_standing_diff", # h_conf_standing - a_conf_standing + "div_standing_diff", # Division standing differential + "both_playoff_contenders", # 1 if both in playoff race + "both_tanking", # 1 if both eliminated + "upset_potential", # Dog win probability from features + "rivalry_intensity", # Combined history + division + stakes + "public_side_home", # 1 if public heavily on home + "contrarian_signal", # 1 if going against public is +EV + "weather_travel_factor", # Season-based weather impact on travel + "altitude_fatigue_compound", # altitude_delta × travel_dist interaction + "timezone_circadian_impact", # Timezone shift × game time interaction + "arena_noise_factor", # Home court quality proxy (attendance %) + ]) + + # 22. CROSS-WINDOW MOMENTUM (630 features) + # For each key stat, compute change between every pair of windows + # 15 pairs × 10 stats × 2 teams × 2 (delta + acceleration) = 600 + # Plus 30 additional composite features = 630 + 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}") # Delta: w1 - w2 + names.append(f"xw_accel_{prefix}_{stat}_{w1}vs{w2}") # Acceleration + + # Composite cross-window features + for prefix in ["h", "a"]: + names.append(f"xw_{prefix}_wp_shortterm_trend") # Avg of all short-window deltas + names.append(f"xw_{prefix}_wp_longterm_trend") # Avg of all long-window deltas + names.append(f"xw_{prefix}_margin_volatility_trend") # Volatility change across windows + names.append(f"xw_{prefix}_ortg_improvement_rate") # Rate of offensive improvement + names.append(f"xw_{prefix}_drtg_improvement_rate") # Rate of defensive improvement + names.append(f"xw_{prefix}_overall_trajectory") # Composite trajectory score + names.append(f"xw_{prefix}_form_acceleration") # Is improvement accelerating? + names.append(f"xw_{prefix}_peak_window") # Which window shows best form (encoded) + names.append(f"xw_{prefix}_trough_window") # Which window shows worst form + names.append(f"xw_{prefix}_consistency_across_windows") # Std of values across windows + names.append(f"xw_{prefix}_trend_agreement") # Do all windows agree on direction? + names.append(f"xw_{prefix}_breakout_signal") # Short windows >> long windows + names.append(f"xw_{prefix}_decline_signal") # Short windows << long windows + names.append(f"xw_{prefix}_mean_reversion_signal") # Extreme deviation from avg + names.append(f"xw_{prefix}_momentum_strength") # Strength of current momentum + # NOTE: 2 teams × (10 stats × 15 pairs × 2 + 15 composites) = 630 + + # 23. ADVANCED MARKET MICROSTRUCTURE II (62 features) + if self.include_market: + # Multi-book comparison (per book: Pinnacle, DraftKings, FanDuel, BetMGM, Caesars) + books = ["pinnacle", "draftkings", "fanduel", "betmgm", "caesars"] + for book in books: + names.append(f"mkt2_{book}_spread") # Spread from this book + names.append(f"mkt2_{book}_ml_home") # ML from this book + names.append(f"mkt2_{book}_total") # Total from this book + names.append(f"mkt2_{book}_implied_home") # Implied prob from this book + names.append(f"mkt2_{book}_line_move") # Line movement for this book + names.append(f"mkt2_{book}_reverse_move") # Reverse line movement flag + names.append(f"mkt2_{book}_historical_accuracy") # This book's historical closing accuracy + # Cross-book features + names.extend([ + "mkt2_spread_range", # Max spread - min spread across books + "mkt2_ml_range", # Max ML - min ML across books + "mkt2_total_range", # Max total - min total across books + "mkt2_implied_prob_range", # Range of implied probabilities + "mkt2_consensus_spread", # Median spread across books + "mkt2_consensus_total", # Median total across books + "mkt2_pinnacle_vs_avg", # Pinnacle spread vs average (sharp indicator) + "mkt2_sharp_vs_soft_spread", # Pinnacle vs avg of soft books + "mkt2_sharp_vs_soft_total", # Pinnacle total vs avg of soft books + "mkt2_time_since_open", # Hours since market opened + "mkt2_early_move_magnitude", # Total line move in first 12 hours + "mkt2_late_move_magnitude", # Total line move in last 4 hours + "mkt2_early_vs_late_direction", # 1 if same direction, -1 if reversed + "mkt2_public_vs_sharp_ratio", # Public money% / sharp money% + "mkt2_steam_count_24h", # Number of steam moves in 24h + "mkt2_contrarian_opportunity", # 1 if public on one side, sharp on other + "mkt2_market_maturity", # How settled the line is (low volatility) + "mkt2_opening_value_home", # Our edge vs opening line + "mkt2_current_value_home", # Our edge vs current line + "mkt2_value_trend", # Edge increasing or decreasing + "mkt2_best_line_home", # Best available line for home + "mkt2_best_line_away", # Best available line for away + "mkt2_juice_home", # Vig on home side + "mkt2_juice_away", # Vig on away side + "mkt2_vig_differential", # Difference in vig (market lean) + "mkt2_hold_pct", # Total market hold percentage + "mkt2_line_freeze_indicator", # 1 if line hasn't moved (suspicious) + ]) + + # 24. POWER RATING COMPOSITES (64 features) + for prefix in ["h", "a"]: + names.append(f"{prefix}_elo_standard") # Standard Elo rating + names.append(f"{prefix}_elo_margin_adj") # Margin-of-victory adjusted Elo + names.append(f"{prefix}_elo_recency_weighted") # More weight on recent games + names.append(f"{prefix}_elo_home_adj") # Home-court adjusted Elo + names.append(f"{prefix}_elo_sos_adj") # SOS-adjusted Elo + names.append(f"{prefix}_elo_conf_adj") # Conference-adjusted Elo + names.append(f"{prefix}_elo_pace_adj") # Pace-adjusted Elo + names.append(f"{prefix}_raptor_composite") # RAPTOR-style composite estimate + names.append(f"{prefix}_raptor_offense") # Offensive RAPTOR component + names.append(f"{prefix}_raptor_defense") # Defensive RAPTOR component + names.append(f"{prefix}_power_rank_ortg") # Power rank by offense + names.append(f"{prefix}_power_rank_drtg") # Power rank by defense + names.append(f"{prefix}_power_rank_netrtg") # Power rank by net rating + names.append(f"{prefix}_power_rank_srs") # Simple Rating System + names.append(f"{prefix}_power_rank_composite") # Weighted composite power rank + names.append(f"{prefix}_power_rank_trend") # Power rank change last 10 + names.append(f"{prefix}_power_conf_adjusted") # Power rank adjusted for conference strength + names.append(f"{prefix}_power_stability") # Std dev of power rank last 20 + names.append(f"{prefix}_power_percentile") # Power rank percentile in league + names.append(f"{prefix}_rating_confidence") # How stable is the rating (games played) + names.append(f"{prefix}_bayesian_rating") # Bayesian rating (prior + observed) + names.append(f"{prefix}_glicko_rating") # Glicko-style rating with uncertainty + names.append(f"{prefix}_glicko_rd") # Rating deviation (uncertainty) + names.append(f"{prefix}_trueskill_mu") # TrueSkill-style mean + names.append(f"{prefix}_trueskill_sigma") # TrueSkill-style uncertainty + # Pairwise power differentials + names.extend([ + "power_elo_std_diff", # Standard Elo diff + "power_elo_margin_diff", # Margin Elo diff + "power_elo_recency_diff", # Recency Elo diff + "power_raptor_diff", # RAPTOR composite diff + "power_raptor_off_diff", # RAPTOR offense diff + "power_raptor_def_diff", # RAPTOR defense diff + "power_srs_diff", # SRS diff + "power_composite_diff", # Composite power diff + "power_conf_adj_diff", # Conference-adjusted diff + "power_bayesian_diff", # Bayesian rating diff + "power_glicko_diff", # Glicko diff + "power_trueskill_diff", # TrueSkill diff + "power_max_diff", # Max diff across all rating systems + "power_avg_diff", # Avg diff across all rating systems + ]) + + # 25. FATIGUE & LOAD MANAGEMENT (82 features) + for prefix in ["h", "a"]: + names.append(f"{prefix}_cumul_games_played") # Total games played this season + names.append(f"{prefix}_cumul_minutes_total") # Total team minutes this season + names.append(f"{prefix}_avg_minutes_per_game") # Avg minutes per game + names.append(f"{prefix}_star_minutes_cumul") # Cumulative minutes for top 2 players + names.append(f"{prefix}_star_minutes_pct_season") # Star minutes as % of season capacity + names.append(f"{prefix}_travel_miles_season") # Total miles traveled this season + names.append(f"{prefix}_travel_miles_30d") # Miles traveled last 30 days + names.append(f"{prefix}_travel_miles_7d") # Miles traveled last 7 days + names.append(f"{prefix}_travel_intensity") # Miles per game last 10 + names.append(f"{prefix}_rest_pattern_consistency") # Std dev of rest days between games + names.append(f"{prefix}_rest_deficit_season") # Cumulative rest deficit vs league avg + names.append(f"{prefix}_b2b_count_season") # Back-to-backs this season + names.append(f"{prefix}_b2b_count_30d") # Back-to-backs in last 30 days + names.append(f"{prefix}_3in4_count_season") # 3-in-4 stretches this season + names.append(f"{prefix}_dense_schedule_flag") # 1 if 4+ games in last 7 days + names.append(f"{prefix}_road_trip_length") # Current consecutive road games + names.append(f"{prefix}_home_stand_length") # Current consecutive home games + names.append(f"{prefix}_road_trip_fatigue") # Road games × avg travel per game + names.append(f"{prefix}_load_management_prob") # Probability of star rest + names.append(f"{prefix}_season_fatigue_curve") # Expected performance dropoff (game #) + names.append(f"{prefix}_relative_fatigue") # This team's fatigue vs league avg + names.append(f"{prefix}_fatigue_adjusted_ortg") # ORtg adjusted for fatigue + names.append(f"{prefix}_fatigue_adjusted_drtg") # DRtg adjusted for fatigue + names.append(f"{prefix}_fatigue_adjusted_wp") # Win% adjusted for fatigue + names.append(f"{prefix}_recovery_quality") # Performance after rest (historical) + names.append(f"{prefix}_b2b_performance_drop") # Avg performance drop on B2B + names.append(f"{prefix}_altitude_fatigue_cumul") # Cumulative altitude adjustment + names.append(f"{prefix}_timezone_changes_season") # Total timezone changes this season + names.append(f"{prefix}_circadian_disruption") # Recent timezone shift impact + names.append(f"{prefix}_early_season_load") # Heavy early schedule flag + names.append(f"{prefix}_late_season_load") # Heavy late schedule flag + names.append(f"{prefix}_minutes_distribution_health") # Are starters being overworked? + names.append(f"{prefix}_injury_risk_score") # Fatigue-based injury risk proxy + names.append(f"{prefix}_stamina_rating") # Team stamina (Q4 performance vs Q1) + names.append(f"{prefix}_clutch_fatigue") # Performance in Q4 on B2B or heavy schedule + names.append(f"{prefix}_fresh_vs_tired_ratio") # Win% well-rested / win% fatigued + names.append(f"{prefix}_optimal_rest_indicator") # 1 if ideal rest pattern + names.append(f"{prefix}_wear_and_tear_index") # Composite fatigue accumulation + # Game-level fatigue differentials + names.extend([ + "fatigue_cumul_diff", # h_cumul_games - a_cumul_games + "fatigue_travel_diff", # h_travel_season - a_travel_season + "fatigue_rest_quality_diff", # h_rest_consistency - a_rest_consistency + "fatigue_load_diff", # h_wear_tear - a_wear_tear + "fatigue_star_load_diff", # h_star_minutes_pct - a_star_minutes_pct + "fatigue_b2b_count_diff", # h_b2b_count_30d - a_b2b_count_30d + "fatigue_adjusted_spread", # Current spread adjusted for fatigue + "fatigue_composite_edge", # Composite fatigue advantage + ]) + + # ===================================================================== + # CATEGORIES 26-35: MASSIVE FEATURE EXPANSION (4000+ new features) + # ===================================================================== + + # 26. ADVANCED PLAYER IMPACT (220 features) + # Star player +/-, usage rates per lineup, efficiency deltas, + # rest-adjusted player metrics, player chemistry indicators + _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"]: + # Top player stats across windows: 12 stats × 4 windows = 48 per team + for stat in _pi_stats: + for w in _pi_windows: + names.append(f"{prefix}_star1_{stat}_{w}") + # Second star: 12 stats × 4 windows = 48 per team + for stat in _pi_stats: + for w in _pi_windows: + names.append(f"{prefix}_star2_{stat}_{w}") + # Team-level aggregated player impact: 12 features per team + 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") + # Matchup-level player impact differentials: 14 features + 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", + ]) + + # 27. REFEREE DEEP ANALYSIS (120 features) + # Ref-specific foul rates by quarter, ref home/away bias history, + # ref pace impact by team type, ref total-over/under tendency + _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 bias by team type (fast/slow, top/bottom, home/away) + _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 over/under tendencies across contexts + _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}") + # Ref pace impact + 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") + # Ref composite features + 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", + ]) + + # 28. VENUE & ENVIRONMENTAL (160 features) + # Altitude effects, timezone crossing, attendance, temperature, + # court surface age, arena factors + _venue_windows = [3, 5, 10, 20] + for prefix in ["h", "a"]: + # Altitude impact features + 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}") + # Timezone crossing features + for w in _venue_windows: + names.append(f"{prefix}_tz_cross_wp_{w}") + names.append(f"{prefix}_tz_cross_margin_{w}") + # Home/away specific venue features + 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") + # Game-level venue differentials + 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", + ]) + + # 29. ADVANCED MARKET MICROSTRUCTURE III (220 features) + # Line movement velocity, acceleration, book consensus, sharp splits + if self.include_market: + _mkt3_windows = ["1h", "2h", "4h", "8h", "12h", "24h"] + # Line movement velocity & acceleration per window + 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}") + # Book consensus divergence + _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") + # Sharp vs public split features + 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", + ]) + # Steam move features + 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", + ]) + # Reverse line movement features + names.extend([ + "mkt3_rlm_spread_flag", "mkt3_rlm_total_flag", + "mkt3_rlm_magnitude_spread", "mkt3_rlm_magnitude_total", + "mkt3_rlm_sharp_confirmation", + ]) + # Closing line value by book + for book in _mkt3_books: + names.append(f"mkt3_clv_{book}_home") + # Opening-to-closing deltas + 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", + ]) + # Money line implied probability convergence + 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", + ]) + # Historical patterns + 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", + ]) + + # 30. TIME SERIES DECOMPOSITION (320 features) + # Trend, seasonal, residual components, autocorrelation + _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"]: + # Trend components: 8 stats × 4 windows = 32 per team + for stat in _ts_stats: + for w in _ts_trend_windows: + names.append(f"ts_trend_{prefix}_{stat}_{w}") + # Seasonal components (day-of-week): 8 stats = 8 per team + for stat in _ts_stats: + names.append(f"ts_seasonal_dow_{prefix}_{stat}") + # Seasonal components (month): 8 stats = 8 per team + for stat in _ts_stats: + names.append(f"ts_seasonal_month_{prefix}_{stat}") + # Residual volatility: 8 stats = 8 per team + for stat in _ts_stats: + names.append(f"ts_residual_vol_{prefix}_{stat}") + # Autocorrelation features: 8 stats × 5 lags = 40 per team + for stat in _ts_stats: + for lag in _ts_lags: + names.append(f"ts_acf_{prefix}_{stat}_lag{lag}") + # Partial autocorrelation: 8 stats × 5 lags = 40 per team + for stat in _ts_stats: + for lag in _ts_lags: + names.append(f"ts_pacf_{prefix}_{stat}_lag{lag}") + # Stationarity indicators: 8 stats = 8 per team + for stat in _ts_stats: + names.append(f"ts_stationarity_{prefix}_{stat}") + # Trend strength: 8 stats = 8 per team + for stat in _ts_stats: + names.append(f"ts_trend_strength_{prefix}_{stat}") + # Seasonality strength: 8 stats = 8 per team + for stat in _ts_stats: + names.append(f"ts_season_strength_{prefix}_{stat}") + + # 31. CROSS-TEAM INTERACTION MATRIX (440 features) + # Pace differential, defensive rating matchup, style clash + _xteam_stats = [ + "pace", "ortg", "drtg", "efg", "3p_pct", "paint_pts", + "fb_pts", "tov_rate", "oreb_pct", "ft_rate", + ] + _xteam_windows = [5, 10, 20] + # Pairwise matchup: 10 stats × 3 windows × 4 types + # (diff, ratio, interaction, mismatch) = 120 features + 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}") + # Offensive style vs defensive style: 10 stats × 3 windows = 30 + for stat in _xteam_stats: + for w in _xteam_windows: + names.append(f"xteam_off_vs_def_{stat}_{w}") + # Reverse: defensive style vs opponent offense: 10 stats × 3 windows = 30 + for stat in _xteam_stats: + for w in _xteam_windows: + names.append(f"xteam_def_vs_off_{stat}_{w}") + # Style clash indices: composite features + _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 matchup matrix (home strength area vs away weakness) + _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}") + # Game-level interaction composites + 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", + ]) + + # 32. BAYESIAN PRIORS (220 features) + # Pre-season win totals, Vegas priors, franchise strength, coach impact + for prefix in ["h", "a"]: + # Pre-season and Vegas priors + 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") + # Franchise historical 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") + # Coach impact features + 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") + # Bayesian blend features + 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") + # Game-level Bayesian differentials + 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", + ]) + + # 33. NETWORK/GRAPH FEATURES (220 features) + # PageRank, clustering coefficient, centrality, connectivity + _net_windows = [10, 20, 82] + for prefix in ["h", "a"]: + # PageRank-style features (from wins network) + 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}") + # Clustering coefficient + for w in _net_windows: + names.append(f"{prefix}_clustering_coeff_{w}") + # Betweenness centrality + for w in _net_windows: + names.append(f"{prefix}_betweenness_centrality_{w}") + # Strength of schedule network features + for w in _net_windows: + names.append(f"{prefix}_sos_network_centrality_{w}") + names.append(f"{prefix}_sos_network_pagerank_{w}") + # Conference connectivity + 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") + # Division rivalry features + 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") + # Win chain features (A beat B, B beat C → transitive) + for w in _net_windows: + names.append(f"{prefix}_win_chain_depth_{w}") + names.append(f"{prefix}_win_chain_strength_{w}") + # Loss chain features + for w in _net_windows: + names.append(f"{prefix}_loss_chain_depth_{w}") + # Network diversity (variety of opponents beaten) + 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}") + # Eigenvector centrality + for w in _net_windows: + names.append(f"{prefix}_eigenvector_centrality_{w}") + # Game-level network differentials + 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", + ]) + + # 34. ENSEMBLE META-FEATURES (160 features) + # Previous model prediction uncertainty, disagreement, drift + _meta_models = ["xgboost", "lightgbm", "catboost", "rf", "logistic"] + _meta_windows = [5, 10, 20, 30] + for prefix in ["h", "a"]: + # Per-model accuracy for this team + for model in _meta_models: + for w in _meta_windows: + names.append(f"meta2_{prefix}_{model}_accuracy_{w}") + # Per-model calibration + for model in _meta_models: + names.append(f"meta2_{prefix}_{model}_calibration") + # Model disagreement per team + 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") + # Game-level ensemble meta-features + 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", + ]) + + # 35. TEMPORAL DECAY FEATURES (320 features) + # Exponential decay weighted stats, recency-weighted metrics + _td_stats = ["wp", "ppg", "papg", "margin", "ortg", "drtg", + "efg", "ts", "pace", "3p_pct"] + _td_half_lives = [3, 5, 10, 20] # half-life in games + for prefix in ["h", "a"]: + # Exponential decay weighted stats: 10 stats × 4 half-lives = 40 per team + for stat in _td_stats: + for hl in _td_half_lives: + names.append(f"td_decay_{prefix}_{stat}_hl{hl}") + # Recency-weighted opponent quality: 4 half-lives = 4 per team + for hl in _td_half_lives: + names.append(f"td_opp_quality_{prefix}_hl{hl}") + # Time-weighted home/away splits: 4 half-lives × 2 = 8 per team + for hl in _td_half_lives: + names.append(f"td_home_split_{prefix}_hl{hl}") + names.append(f"td_away_split_{prefix}_hl{hl}") + # Season-phase interaction terms: 10 stats × 3 phases = 30 per team + _phases = ["early", "mid", "late"] + for stat in _td_stats: + for phase in _phases: + names.append(f"td_phase_{prefix}_{stat}_{phase}") + # Decay-weighted trend (difference between fast and slow decay) + # 10 stats × 6 pairs = 60 per team + _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}") + # Game-level temporal decay differentials + for stat in _td_stats: + for hl in _td_half_lives: + names.append(f"td_diff_{stat}_hl{hl}") + + # ===================================================================== + # EXPANDED SUB-FEATURES: Additional features to reach 6000+ total + # ===================================================================== + + # 26b. ADVANCED PLAYER IMPACT — EXPANDED (additional ~200 features) + # Per-position impact features + _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") + # Player pairwise synergy features: top 5 two-man combos + 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") + # Lineup unit features by window + 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}") + # Position matchup advantages + 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") + + # 27b. REFEREE DEEP ANALYSIS — EXPANDED (additional ~120 features) + # Ref tendency by score differential context + _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}") + # Ref impact on specific play types + _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}") + # Ref historical impact on team types + 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") + # Ref crew composition features + 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", + ]) + # Ref interaction with game context + 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}") + + # 28b. VENUE & ENVIRONMENTAL — EXPANDED (additional ~200 features) + # Temperature and weather impact (affects travel/mood) + _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") + # Travel weather impact + names.append(f"env_{prefix}_travel_weather_severity") + names.append(f"env_{prefix}_flight_delay_risk") + # Arena-specific features + 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") + # City-level features + 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") + # Time of game features + 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", + ]) + # Altitude-specific performance adjustments across windows + 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}") + # Cross-country travel impact + 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}") + # Environmental composites + 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", + ]) + + # 29b. ADVANCED MARKET MICROSTRUCTURE III — EXPANDED (additional ~200 features) + if self.include_market: + # Prop market features + _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") + # Alternative market features + 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", + ]) + # Cross-market correlation features + 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", + ]) + # Time-stamped market snapshots + _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}") + # Market efficiency metrics + 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", + ]) + # Historical accuracy of market in similar contexts + _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}") + + # 31b. CROSS-TEAM INTERACTION MATRIX — EXPANDED (additional ~300 features) + # Advanced matchup features: per-stat offensive efficiency vs opponent defense + _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 distribution matchup + _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 and style interaction matrix + _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}") + + # 32b. BAYESIAN PRIORS — EXPANDED (additional ~200 features) + # Bayesian-updated power ratings with different priors + _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}") + # Season-adjusted Bayesian features + 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}") + # Coach Bayesian impact with different baselines + 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") + # Roster turnover Bayesian adjustment + 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") + # Injury Bayesian 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") + # Game-level Bayesian expanded differentials + 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", + ]) + + # 33b. NETWORK/GRAPH FEATURES — EXPANDED (additional ~200 features) + # Conference/division subgraph features + for prefix in ["h", "a"]: + # Subgraph metrics within conference + 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") + # Division subgraph + names.append(f"net2_{prefix}_div_pagerank") + names.append(f"net2_{prefix}_div_clustering") + names.append(f"net2_{prefix}_div_degree_centrality") + # Quality-weighted network features + 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}") + # Opponent network features (2nd order) + 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") + # Transitive strength + for depth in [2, 3, 4]: + names.append(f"net2_{prefix}_transitive_strength_d{depth}") + names.append(f"net2_{prefix}_transitive_weakness_d{depth}") + # Colley rating system features + names.append(f"net2_{prefix}_colley_rating") + names.append(f"net2_{prefix}_colley_rank") + # Massey rating system features + names.append(f"net2_{prefix}_massey_rating") + names.append(f"net2_{prefix}_massey_offensive") + names.append(f"net2_{prefix}_massey_defensive") + # Keener rating features + names.append(f"net2_{prefix}_keener_rating") + names.append(f"net2_{prefix}_keener_dominance") + # Game-level expanded network differentials + 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", + ]) + + # 34b. ENSEMBLE META-FEATURES — EXPANDED (additional ~200 features) + # Cross-model interaction features + _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") + # Feature importance stability across models + _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") + # Model performance in different game contexts + _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}") + # Time-varying model performance + 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}") + # Stacking features (model outputs as features) + 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") + + # 35b. TEMPORAL DECAY FEATURES — EXPANDED (additional ~200 features) + # Kernel-weighted features (Gaussian, triangular, Epanechnikov) + _kernels = ["gaussian", "triangular", "epanechnikov"] + _kernel_bw = [3, 7, 15] # bandwidth in games + 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}") + # Regime change detection features + 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}") + # Weighted percentile features + 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}") + # Adaptive half-life features (half-life adjusts based on volatility) + 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}") + + # ===================================================================== + # CROSS-CATEGORY INTERACTION FEATURES (additional ~600 features) + # Interactions between new categories and existing core features + # ===================================================================== + + # Temporal decay × Market features + 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") + + # Bayesian × Power Rating interactions + 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") + + # Network × Matchup interactions + names.extend([ + "xi_net_matchup_pagerank_diff", + "xi_net_matchup_centrality_weighted", + "xi_net_matchup_transitivity_score", + "xi_net_matchup_network_surprise", + ]) + + # Player Impact × Fatigue interactions + 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") + + # Referee × Venue interactions + names.extend([ + "xi_ref_venue_home_bias_compound", + "xi_ref_venue_altitude_foul_rate", + "xi_ref_venue_pace_interaction", + "xi_ref_venue_crowd_effect", + ]) + + # Time Series × Cross-Team interactions + 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") + + # Ensemble × Market interactions + 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", + ]) + + # All-category composite features (grand summary features) + 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", + ]) + + # ===================================================================== + # HIGHER-ORDER POLYNOMIAL FEATURES on new categories (additional ~400) + # ===================================================================== + + # Squared terms from new categories + _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 interaction products between key new features + _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}") + + # Ratio features between new categories + _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 interaction features (3-way combinations of key signals) + _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}") + + # ===================================================================== + # ROLLING CROSS-CATEGORY FEATURES (additional ~400 features) + # Apply rolling window logic to new category outputs + # ===================================================================== + + # Rolling features on decay-weighted stats + _decay_roll_stats = ["wp", "margin", "ortg", "drtg"] + for prefix in ["h", "a"]: + for stat in _decay_roll_stats: + for hl in [3, 10]: + # Decay stat volatility + names.append(f"roll_td_vol_{prefix}_{stat}_hl{hl}") + # Decay stat trend + names.append(f"roll_td_trend_{prefix}_{stat}_hl{hl}") + # Decay stat z-score + names.append(f"roll_td_zscore_{prefix}_{stat}_hl{hl}") + + # Rolling features on network metrics + 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}") + + # Rolling features on Bayesian priors + 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}") + + # Rolling cross-team interaction features + 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}") + + # Cumulative information features + 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") + + # Game-level summary features from all new categories + 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", + ]) + + # ===================================================================== + # EXTENDED ROLLING WINDOW FEATURES ON NEW STATS (additional ~500) + # Apply all 6 WINDOWS to new derived stats for massive expansion + # ===================================================================== + + # Extended rolling windows on advanced stats + _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}") + + # Extended EWMA on new stats + _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}") + + # Extended volatility on new stats + _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}") + + # Extended z-scores on new stats + for prefix in ["h", "a"]: + for stat in _ext_stats: + names.append(f"ext_zscore_{prefix}_{stat}") + + # Extended trend deltas on new stats + _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}") + + # ===================================================================== + # ADDITIONAL CROSS-WINDOW MOMENTUM ON NEW STATS (additional ~300) + # ===================================================================== + + _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}") + + # Cross-window composites for new stats + 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") + + # ===================================================================== + # ADDITIONAL INTERACTION FEATURES (additional ~200) + # Pairwise products of top new features with core features + # ===================================================================== + + _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}") + + # ===================================================================== + # FINAL EXPANSION: Per-opponent rolling features (additional ~300) + # Performance against different opponent strength tiers + # ===================================================================== + + _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}") + + # Home/away specific performance by window + _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}") + + # Day-of-week performance features + _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}") + + # Month-specific performance features + _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}") + + # Consecutive game pattern features + 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") + + # Score differential buckets performance + _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") + + # Quarter-specific detailed features (additional ~56) + _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}") + + # 36. EWMA PERFORMANCE + CROSSOVERS + REST INTERACTIONS (~108 features) + # Inspired by deepshot (EWMA rolling stats) + kyleskom (rest × performance) + _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}") + + # EWMA crossovers: fast(0.7) - slow(0.05) = momentum signal (MACD-like) + for prefix in ["h", "a"]: + for stat in _ewma36_stats: + names.append(f"ewma36_{prefix}_{stat}_crossover") + + # Rest × performance interactions + names.extend([ + "rest_x_h_wp5", "rest_x_a_wp5", # rest_days × recent win% + "b2b_x_h_margin5", "b2b_x_a_margin5", # b2b × recent margin + "fatigue_x_h_ortg", "fatigue_x_a_ortg", # fatigue × offensive rating + "rest_adv_x_wp_diff", # rest_advantage × win% difference + "b2b_diff_x_margin_diff", # b2b differential × margin differential + "h_rest_sq", "a_rest_sq", # rest_days squared (diminishing returns) + "rest_x_travel", # rest_advantage × travel_advantage + "dense_sched_x_margin", # schedule_density × margin_diff + ]) + + # 37. MOVDA ELO FEATURES (13 features) — arXiv:2506.00348 + # Margin-of-Victory Differential Analysis: R' = R + K*(S-E) + λ*(MOV-E_MOV) + for prefix in ["h", "a"]: + names.append(f"{prefix}_movda_rating") # MOVDA Elo rating (normalized) + names.append(f"{prefix}_mov_surprise_ewm") # EWMA of MOV surprise signal + names.extend([ + "movda_diff", # MOVDA rating differential + "movda_win_prob", # MOVDA-derived win probability + ]) + # Raw delta_MOV rolling features (no EWM smoothing) — captures recent surprise momentum + for prefix in ["h", "a"]: + names.append(f"{prefix}_delta_mov_raw") # last game's raw MOV surprise + names.append(f"{prefix}_delta_mov_rolling_5") # rolling mean over last 5 games + names.append(f"{prefix}_delta_mov_rolling_10") # rolling mean over last 10 games + names.append("delta_mov_diff") # h_delta_mov_rolling_5 - a_delta_mov_rolling_5 + + # 38. VENUE-CONDITIONAL MATCHUP FEATURES (14 features) + # Home team's home-only stats vs away team's road-only stats + # This is the true matchup signal: how does home team perform AT HOME + # vs how does away team perform ON THE ROAD — not combined records + for w in [5, 10, 20]: + names.append(f"venue_wp_edge_{w}") # h_home_wp - a_road_wp + names.append(f"venue_margin_edge_{w}") # h_home_margin - a_road_margin + names.append(f"venue_ortg_edge_{w}") # h_home_ortg - a_road_drtg + names.append(f"venue_drtg_edge_{w}") # h_home_drtg - a_road_ortg + names.extend([ + "venue_home_boost", # h_home_wp - h_overall_wp (home court effect) + "venue_road_penalty", # a_overall_wp - a_road_wp (road penalty) + ]) + + # 39. CIRCADIAN RHYTHM & TRAVEL FATIGUE (8 features) — Chronobiology Intl 2024 + # Novel combinations: timezone-weighted fatigue + rest non-linearity + # Distinct from Cat 6 (which has raw rest/travel) — these are normalized composites + names.extend([ + "circ_h_travel_dist", # Great-circle miles from last game city (home team) + "circ_a_travel_dist", # Great-circle miles from last game city (away team) + "circ_h_tz_shift", # Timezone hours crossed since last game (home) + "circ_a_tz_shift", # Timezone hours crossed since last game (away) + "circ_h_fatigue_index", # Composite: distance/500 + tz_shift*0.5 + b2b*2 - rest*0.3 + "circ_a_fatigue_index", # Composite: distance/500 + tz_shift*0.5 + b2b*2 - rest*0.3 + "circ_advantage", # away_fatigue_index - home_fatigue_index (positive = home fresher) + "circ_rest_nonlinear", # (h_rest_sq - a_rest_sq) capped: captures diminishing rest benefit + ]) + + # 41. TRANSITION vs HALF-COURT EFFICIENCY SPLITS (7 features) + # Derived from fb_pts (fast break) and pace in existing box score stats + names.extend([ + "trans41_h_fb_rate", # Home fast-break pts as fraction of total scoring + "trans41_a_fb_rate", # Away fast-break pts as fraction of total scoring + "trans41_h_halfcourt_eff", # Home half-court efficiency proxy (pts - fb_pts) / poss + "trans41_a_halfcourt_eff", # Away half-court efficiency proxy + "trans41_fb_rate_diff", # h_fb_rate - a_fb_rate + "trans41_pace_x_fb", # pace * fb_rate interaction (high pace + high transition = synergy) + "trans41_halfcourt_edge", # h_halfcourt_eff - a_halfcourt_eff + ]) + + # 43. CLUTCH PERFORMANCE FEATURES (8 features) + # Computed from close games (|margin| <= 5) in rolling records + names.extend([ + "clutch43_h_wp", # Win % in clutch games (last 20) + "clutch43_a_wp", # Win % in clutch games (last 20) + "clutch43_h_margin", # Avg margin in clutch games + "clutch43_a_margin", # Avg margin in clutch games + "clutch43_h_ortg", # Offensive rating in clutch games + "clutch43_a_ortg", # Offensive rating in clutch games + "clutch43_wp_diff", # h_clutch_wp - a_clutch_wp + "clutch43_margin_diff", # h_clutch_margin - a_clutch_margin + ]) + + # 44. GAME TOTALS PREDICTION FEATURES (10 features) + # Encodes expected game pace and scoring volume for O/U modelling. + # Derived entirely from rolling pts/opp_pts/ortg/drtg — no new data source. + # Use case: (1) direct O/U prediction; (2) interaction terms for win model + # (high-total games are often closer; low-total = grind favors defense). + names.extend([ + "tot44_h_ppg10", # Home rolling PPG (last 10) normalized to league avg + "tot44_a_ppg10", # Away rolling PPG (last 10) normalized + "tot44_h_papg10", # Home rolling PAPG (last 10) normalized + "tot44_a_papg10", # Away rolling PAPG (last 10) normalized + "tot44_matchup_total", # Predicted total via PPG/PAPG interaction (normalized) + "tot44_pace_sum", # (h_pace + a_pace) / 2 — expected game pace + "tot44_pace_mismatch", # |h_pace - a_pace| — fast vs slow clash + "tot44_ortg_sum", # (h_ortg + a_ortg) / 2 — combined offensive quality + "tot44_drtg_sum", # (h_drtg + a_drtg) / 2 — combined defensive quality + "tot44_score_env", # (ortg_sum - drtg_sum) / 10 — net scoring environment + ]) + + # 42. SHOT QUALITY ZONE FEATURES (10 features) + # From nba_api shot locations: zone FG%, shot distribution, xEFG. + # Montrucchio 2026 (Brier 0.199) used spatial shot embeddings. + # Graceful fallback to 0.0 when tracking data not loaded. + names.extend([ + "shot42_h_rim_rate", # Home restricted area shot frequency + "shot42_a_rim_rate", # Away restricted area shot frequency + "shot42_h_mid_rate", # Home mid-range shot frequency + "shot42_a_mid_rate", # Away mid-range shot frequency + "shot42_h_three_rate", # Home 3PT rate + "shot42_a_three_rate", # Away 3PT rate + "shot42_h_xefg", # Home expected eFG% from shot distribution + "shot42_a_xefg", # Away expected eFG% from shot distribution + "shot42_rim_rate_diff", # h_rim_rate - a_rim_rate (paint dominance) + "shot42_xefg_diff", # h_xefg - a_xefg (shot quality edge) + ]) + + # 45. PLAYER TRACKING / HUSTLE FEATURES (12 features) + # From nba_api: hustle stats, speed/distance, touches, drives. + # Proxies effort intensity, pace profile, and defensive activity. + # Expected Brier delta: -0.003 to -0.006 (replaces box-score proxies). + names.extend([ + "track45_h_contested", # Home team avg contested shots per game + "track45_a_contested", # Away team avg contested shots per game + "track45_h_deflections", # Home team avg deflections per game + "track45_a_deflections", # Away team avg deflections per game + "track45_h_speed", # Home team avg player speed (mph) + "track45_a_speed", # Away team avg player speed (mph) + "track45_h_loose_balls", # Home team loose balls recovered per game + "track45_a_loose_balls", # Away team loose balls recovered per game + "track45_h_drives", # Home team drives per game + "track45_a_drives", # Away team drives per game + "track45_contested_diff", # h_contested - a_contested (defensive intensity edge) + "track45_speed_diff", # h_speed - a_speed (pace profile mismatch) + ]) + + # 46. REAL ODDS MARKET FEATURES (8 features) + # From historical odds CSV: pre-game moneylines, spreads, totals. + # These are the strongest predictive features available — the market + # aggregates all public information into a single number. + # Expected Brier delta: -0.005 to -0.015 (market-calibrated features). + # Graceful fallback: 0.5 prob, 0.0 spread, 220.0 total when odds unavailable. + names.extend([ + "odds46_implied_home_prob", # Market implied home win prob (vig-inclusive) + "odds46_implied_away_prob", # Market implied away win prob (vig-inclusive) + "odds46_fair_home_prob", # Vig-removed fair home probability + "odds46_fair_away_prob", # Vig-removed fair away probability + "odds46_spread_home", # Point spread (negative = home favored), normalized /10 + "odds46_total", # Over/under total, normalized /220 + "odds46_overround", # Total implied prob (vig level, ~1.04-1.08) + "odds46_spread_implied_diff", # spread_implied_prob - ml_implied_prob (market consistency) + ]) + + # 47. DRIVE-OFFENSE vs RIM-DEFENSE MATCHUP (14 features) + # Montrucchio 2026 (Brier 0.199): zone-level offense vs defense matchups. + # drive_fg_pct loaded by build_tracking_data but never consumed by engine. + # DEF_RIM_FG_PCT from defense CSV completely unused until now. + names.extend([ + "drive47_h_fg_pct", # Home drive FG% (offense quality on drives) + "drive47_a_fg_pct", # Away drive FG% (offense quality on drives) + "drive47_h_tov_pct", # Home drive turnover rate (ball security) + "drive47_a_tov_pct", # Away drive turnover rate + "drive47_h_pts_pct", # Home scoring share from drives + "drive47_a_pts_pct", # Away scoring share from drives + "drive47_h_def_rim_fg", # Home rim FG% allowed (rim protection) + "drive47_a_def_rim_fg", # Away rim FG% allowed + "drive47_h_blk_rate", # Home blocks per game (rim deterrent) + "drive47_a_blk_rate", # Away blocks per game + "drive47_h_off_vs_a_rim", # Home drive_fg - away def_rim_fg (matchup edge) + "drive47_a_off_vs_h_rim", # Away drive_fg - home def_rim_fg + "drive47_rim_matchup_net", # Combined rim advantage + "drive47_drive_volume_diff", # Home drives - away drives (attack style diff) + ]) + + # 48. PASSING NETWORK QUALITY (10 features) + # passing_2025-26.csv is completely unused. Ball movement quality + # is a strong team-strength indicator (GS dynasty, 2020s Nuggets). + names.extend([ + "pass48_h_ast_rate", # Home AST-to-pass ratio + "pass48_a_ast_rate", # Away AST-to-pass ratio + "pass48_h_potential_ast", # Home potential assists (normalized /50) + "pass48_a_potential_ast", # Away potential assists (normalized /50) + "pass48_h_ast_pts_created", # Home points created by assists (normalized /80) + "pass48_a_ast_pts_created", # Away points created by assists + "pass48_h_secondary_ast", # Home secondary assists (extra passes /5) + "pass48_a_secondary_ast", # Away secondary assists + "pass48_ast_rate_diff", # Home - away AST-to-pass rate + "pass48_ball_movement_edge", # Composite: ast_rate + secondary_ast + potential_ast diff + ]) + + # 49. PLAY-TYPE EFFICIENCY (10 features) + # NBA_Play_Types CSV has real PPP by play type — currently unused. + # Team-level aggregated: iso, P&R, transition, post-up, spot-up efficiency. + names.extend([ + "play49_h_iso_ppp", # Home isolation PPP (normalized /1.0) + "play49_a_iso_ppp", # Away isolation PPP + "play49_h_pnr_ppp", # Home pick-and-roll ball handler PPP + "play49_a_pnr_ppp", # Away pick-and-roll ball handler PPP + "play49_h_spot_ppp", # Home spot-up PPP + "play49_a_spot_ppp", # Away spot-up PPP + "play49_h_trans_ppp", # Home transition PPP + "play49_a_trans_ppp", # Away transition PPP + "play49_ppp_composite_diff", # Home avg PPP - away avg PPP (overall efficiency) + "play49_versatility_diff", # Home play-type variety - away (# play types above 1.0 PPP) + ]) + + # 50. TEMPORAL WIN SEQUENCE ENCODING (12 features) + # Encodes the ORDER of last-10-game outcomes — not just rolling averages. + # A team that lost early then won recent games (improving) differs from one + # that won early and is now declining, even at identical aggregate win%. + # Research basis: MDPI 2026 (temporal sequence models outperform rolling averages). + names.extend([ + "seq50_h_early_wp", # Home win% in older half of last 10 games + "seq50_h_late_wp", # Home win% in recent half of last 10 games + "seq50_h_slope", # Home momentum slope (late_wp - early_wp, positive=improving) + "seq50_h_margin_slope_norm", # Home margin trend (recent 3 - older 3 avg, /30 normalized) + "seq50_h_streak_norm", # Home current streak normalized (/10, sign = direction) + "seq50_a_early_wp", # Away win% in older half of last 10 games + "seq50_a_late_wp", # Away win% in recent half of last 10 games + "seq50_a_slope", # Away momentum slope + "seq50_a_margin_slope_norm", # Away margin trend normalized + "seq50_a_streak_norm", # Away current streak normalized + "seq50_slope_diff", # Home - away momentum slope differential + "seq50_streak_diff", # Home - away streak differential + ]) + + # 51. SEASON ERA NORMALIZATION (8 features) + # Z-score each team's rolling stats vs league-wide running average FOR THIS SEASON. + # Removes era drift: ORtg 110 in 2018-19 ≠ ORtg 110 in 2025-26 (pace increased). + # Research basis: MDPI 2026 (Info 17:56) — "season-fixed effects applied to handle + # era differences" improved calibration. Logistic regression Brier 0.199 used this. + names.extend([ + "era51_h_ortg_vs_league", # Home ORtg z-score vs league season running avg + "era51_h_drtg_vs_league", # Home DRtg z-score (lower = better defense) + "era51_a_ortg_vs_league", # Away ORtg z-score vs league season running avg + "era51_a_drtg_vs_league", # Away DRtg z-score vs league season running avg + "era51_h_pace_vs_league", # Home pace z-score vs league season running avg + "era51_a_pace_vs_league", # Away pace z-score vs league season running avg + "era51_h_netrtg_vs_league", # Home net rating z-score vs league this season + "era51_h_ortg_a_drtg_edge", # Matchup: h_ortg_z - a_drtg_z (offensive edge) + ]) + + # 52. ODDS LINE FEATURES (15 features) + # Direct features from the historical odds CSV: spread magnitude, total, + # moneyline-implied probabilities, vig, and season-relative percentiles. + # These are the raw market signals — the strongest single-source predictors. + # Expected Brier delta: -0.003 to -0.008 (complements Cat 46 prob features). + names.extend([ + "line52_spread_magnitude", # abs(spread_home) — how lopsided the game is + "line52_total", # Over/under line (raw, not normalized) + "line52_implied_home", # ML-implied home win prob (vig-inclusive) + "line52_implied_away", # ML-implied away win prob + "line52_spread_agree", # 1 if spread direction agrees with ML favorite + "line52_vig", # Overround - 1.0 (bookmaker edge) + "line52_spread_season_pct", # Spread magnitude percentile in season (0=smallest) + "line52_total_season_pct", # Total percentile in season (0=lowest) + "line52_home_dog", # 1 if home team is underdog (spread_home > 0) + "line52_spread_adj", # spread_home adjusted: home usually favored -3.5 + "line52_ml_spread_gap", # ML-implied spread - actual spread (/10 normalized) + "line52_sharpness", # 1 / overround (sharper = less vig) + "line52_season_spread_std", # Rolling std of spreads in season (line volatility) + "line52_season_total_trend", # Total line trend: recent 10 avg vs season avg + "line52_home_fav_strength", # If home fav: -spread/10; if dog: 0 (one-sided) + ]) + + # 53. ATS (AGAINST THE SPREAD) RECORD FEATURES (12 features) + # Tracks each team's ATS cover rate using the odds CSV spread data. + # A team that consistently covers (beats the spread) is underrated by the market. + # Expected Brier delta: -0.002 to -0.005 (captures systematic market bias). + names.extend([ + "ats53_h_last10", # Home team cover rate last 10 games + "ats53_a_last10", # Away team cover rate last 10 games + "ats53_h_season", # Home team season-long cover rate + "ats53_a_season", # Away team season-long cover rate + "ats53_h_streak", # Home ATS streak (+= covering, -= not covering) + "ats53_a_streak", # Away ATS streak + "ats53_h_as_fav", # Home cover rate when favored (spread < 0) + "ats53_a_as_dog", # Away cover rate when underdog (spread > 0) + "ats53_h2h_last5", # H2H cover rate for home team last 5 meetings + "ats53_h_home_only", # Home cover rate in home games only + "ats53_a_away_only", # Away cover rate in road games only + "ats53_margin_vs_spread_10", # Avg (actual_margin - spread) last 10 for home team + ]) + + # 54. OVER/UNDER RECORD FEATURES (12 features) + # Tracks each team's over/under hit rate using the odds CSV total line. + # Teams with high-pace or high-variance scoring consistently push totals. + # Expected Brier delta: -0.001 to -0.003 (pace signal + market calibration). + names.extend([ + "ou54_h_over_rate10", # Home team over rate last 10 games + "ou54_a_over_rate10", # Away team over rate last 10 games + "ou54_h_over_season", # Home team season over rate + "ou54_a_over_season", # Away team season over rate + "ou54_h_streak", # Home team O/U streak (+= over, -= under) + "ou54_a_streak", # Away team O/U streak + "ou54_combined_over_rate", # Combined over rate (both teams avg, last 10) + "ou54_pace_vs_total", # Home pace (pts/game proxy) vs total line + "ou54_h_home_over", # Home over rate in home games only + "ou54_a_away_over", # Away over rate in road games only + "ou54_total_trend", # Season total trend: last 10 avg vs season avg total + "ou54_margin_vs_total_10", # Avg (actual_total - ou_line) last 10 for combined games + ]) + + # 55. MARKET CONSENSUS DEVIATION (10 features) — MDPI 2026 fused model / arXiv 2502.05676 + # Cross-instrument disagreement: how much ML odds vs spread-implied fair prob diverge. + # Key insight: fused model AUC 0.95 vs 0.76/0.94 separate components (MDPI Information 2026). + # Vig-free spread probability uses sigmoid: p = 1/(1 + exp(spread/7.5)) where spread<0=home fav. + # Expected Brier delta: -0.002 to -0.004. + names.extend([ + "cmd55_fair_h_prob", # Vig-removed home prob from spread (sigmoid) + "cmd55_fair_a_prob", # Vig-removed away prob (1 - fair_h) + "cmd55_ml_vs_spread_gap", # |ml_implied_h - fair_h_spread| (instrument disagreement) + "cmd55_books_internal_disagree", # 1 if ML and spread favor different teams + "cmd55_market_edge_h", # (ml_implied_h-0.5)×(fair_h-0.5)×4 (compound home edge) + "cmd55_liquidity_proxy", # Total line normalized: (total-190)/60, clipped [0,1] + "cmd55_ml_confidence", # |ml_implied_h - 0.5| (market conviction via ML odds) + "cmd55_spread_confidence", # |spread_home| / 10, clipped [0,1] (spread conviction) + "cmd55_consensus_strength", # avg(ml_confidence, spread_confidence) (joint conviction) + "cmd55_vig_distortion", # |fair_h - ml_implied_h| (vig shifts probability by this) + ]) + + # 56. DIRECTIONAL CIRCADIAN ADVANTAGE (10 features) — Chronobiology Int'l 2022, ScienceDaily 2024 + # Westbound home teams win 63.5% vs 55.0% eastbound — net +8.5pp (25,016-game study). + # Cat39 uses abs() for tz-shift; Cat56 uses SIGNED delta to capture direction. + # Signed delta: negative = traveled west (favorable body-clock "ahead"), positive = east (adverse). + names.extend([ + "circ56_h_signed_tz", # Signed tz delta for home team (neg=westbound=favorable) + "circ56_a_signed_tz", # Signed tz delta for away team (pos=eastbound=adverse) + "circ56_h_westbound_adv", # Sigmoid-encoded westbound advantage for home [0,1] + "circ56_a_eastbound_pen", # Sigmoid-encoded eastbound penalty for away [0,1] + "circ56_dir_mismatch", # 1.0 if home went west AND away went east (double advantage) + "circ56_h_phase_alignment", # Body-clock phase alignment score home [-1,1] + "circ56_a_phase_alignment", # Body-clock phase alignment score away [-1,1] + "circ56_dir_edge", # Combined directional edge (home advantage signal) + "circ56_h_schedule_density", # Games in last 7 days for home team (higher=more fatigued) + "circ56_density_edge", # Away density - home density (positive=home fresher) + ]) + + # 57. SEQUENTIAL EXPONENTIAL FORM (12 features) — arXiv 2512.08591 (LSTM 8-season) + MDPI 2026 + # Exponentially weighted (α=0.4) form captures recency while covering 5-10 game horizon. + # Hot-hand encoding (3+ consecutive wins) + variance as consistency indicator. + # Momentum = exp5_form - exp10_form (positive = team improving, negative = fading). + names.extend([ + "seq57_h_exp_form5", # Home exp-weighted win rate, last 5 (α=0.4) + "seq57_a_exp_form5", # Away exp-weighted win rate, last 5 + "seq57_h_exp_form10", # Home exp-weighted win rate, last 10 + "seq57_a_exp_form10", # Away exp-weighted win rate, last 10 + "seq57_h_form_var", # Variance of home results, last 5 (low=consistent) + "seq57_a_form_var", # Variance of away results, last 5 + "seq57_h_hot_hand", # Home hot hand: 1.0 if 3+ consecutive wins + "seq57_a_hot_hand", # Away hot hand: 1.0 if 3+ consecutive wins + "seq57_h_momentum", # Home form momentum: exp5 - exp10 (positive=improving) + "seq57_a_momentum", # Away form momentum: exp5 - exp10 + "seq57_form5_diff", # seq57_h_exp_form5 - seq57_a_exp_form5 + "seq57_momentum_diff", # seq57_h_momentum - seq57_a_momentum + ]) + + # 58. MODEL CONFIDENCE & CALIBRATION ANCHORS (10 features) — arXiv 2303.06021 + # arXiv 2303.06021: calibration-optimal models beat accuracy-optimal by 5-7% ROI. + # Features that signal WHEN the model is likely well-calibrated vs uncertain. + # Helps the GA weight predictions by reliability rather than raw probability. + names.extend([ + "cal58_spread_confidence", # |spread|/10 clipped — market conviction (large=confident) + "cal58_total_confidence", # Inverse total effect: high total = more volatile game + "cal58_b2b_uncertainty", # avg(h_b2b, a_b2b): both on B2B → less predictable + "cal58_h2h_consistency", # |h2h_win_rate - 0.5| × 2 → how dominant historically + "cal58_h_home_reliability", # |home_team_home_wp - 0.60| × 2 → vs population mean + "cal58_model_market_alignment", # 1 - |implied_h - 0.5| × 0.5 → uncertain when near 50/50 + "cal58_h_home_wp_hist", # Full historical home win rate for home team + "cal58_a_road_wp_hist", # Full historical road win rate for away team + "cal58_compound_confidence", # spread_conf × (1 - b2b_uncertainty) + "cal58_matchup_mkt_compound", # h2h_consistency × spread_confidence + ]) + + # 59. OPPONENT GRAPH FEATURES (12 features — arXiv 2303.16741 GATv2-TCN approach) + # "Who you play affects how you play" — 2nd-order schedule strength, transitive wins, + # common-opponent advantage. GATv2-TCN finding: opponent scheduling is a latent + # performance signal not captured by raw SOS or rolling win%. + names.extend([ + "opp59_h_opp_sos5", # 2nd-order SOS: quality of home's last-5 opps' own schedules + "opp59_a_opp_sos5", # Same for away + "opp59_common_opp_h_adv",# Home margin advantage vs shared recent opponents (norm -1→1) + "opp59_common_opp_count",# Shared opponent count / 10 (confidence in above estimate) + "opp59_h_beat_quality", # Home win% vs above-.500 opponents in last 10 games + "opp59_a_beat_quality", # Same for away + "opp59_h_sos_trend", # Difficulty trend: sos_last3 − sos_prev3 (+ = getting harder) + "opp59_a_sos_trend", # Same for away + "opp59_sched_asymmetry", # h_sos5 − a_sos5 (+ = home faced harder recent schedule) + "opp59_h_opp_form", # Form of home's recent opponents at time of matchup + "opp59_a_opp_form", # Form of away's recent opponents at time of matchup + "opp59_graph_transitive",# Home beat teams that also beat away's recent nemeses (0→1) + ]) + + # 60. SCORE MARGIN DISTRIBUTION (12 features) + # Pythagorean expectation works because margin distribution reveals true quality. + # arXiv 2506.01923 (NBA game-level Bayesian): margin-shape features improve AUC by 2-3%. + # Teams winning by narrow margins are fragile; teams with heavy-tail blowout wins are elite. + names.extend([ + "mdist60_h_margin_mean", # Home avg point diff last 10 (raw strength) + "mdist60_a_margin_mean", # Away avg point diff last 10 + "mdist60_h_margin_skew", # Home margin skewness (+ = more blowout wins) + "mdist60_a_margin_skew", # Away margin skewness + "mdist60_h_close_loss_rate", # Home pct of losses by ≤5 pts (fragile losses) + "mdist60_a_close_loss_rate", # Away pct of losses by ≤5 pts + "mdist60_h_blowout_win_rate", # Home pct of wins by ≥15 pts (dominance signal) + "mdist60_a_blowout_win_rate", # Away pct of wins by ≥15 pts + "mdist60_h_margin_iqr", # Home interquartile range of margins (consistency) + "mdist60_a_margin_iqr", # Away interquartile range of margins + "mdist60_margin_shape_diff", # h_skew - a_skew (which team has heavier right tail) + "mdist60_quality_gap", # (h_blowout_win - h_close_loss) - same for away + ]) + + # ── Cat 61: Pace-Adjusted Net Rating (10 features) ── + names.extend([ + "pace61_h_off_rtg", # Home offensive rating (pts per 100 poss) + "pace61_a_off_rtg", # Away offensive rating + "pace61_h_def_rtg", # Home defensive rating (opp pts per 100 poss) + "pace61_a_def_rtg", # Away defensive rating + "pace61_h_net_rtg", # Home net rating (off - def) + "pace61_a_net_rtg", # Away net rating + "pace61_net_rtg_diff", # h_net - a_net (pace-adjusted strength gap) + "pace61_h_pace", # Home possessions per game (normalized) + "pace61_a_pace", # Away possessions per game (normalized) + "pace61_pace_mismatch", # |h_pace - a_pace| (tempo clash signal) + ]) + + # ── Cat 62: Clutch Performance — close-game DNA (10 features) ── + names.extend([ + "clutch62_h_close_winpct", # Home win% in games decided by ≤5 pts + "clutch62_a_close_winpct", # Away win% in games decided by ≤5 pts + "clutch62_h_clutch_edge", # Home close_winpct - overall_winpct (clutch lift) + "clutch62_a_clutch_edge", # Away close_winpct - overall_winpct + "clutch62_h_close_freq", # Fraction of home games that were close + "clutch62_a_close_freq", # Fraction of away games that were close + "clutch62_close_wr_diff", # h_close_winpct - a_close_winpct + "clutch62_clutch_edge_diff", # h_clutch_edge - a_clutch_edge + "clutch62_h_close_margin_avg", # Home avg margin in close games (signed) + "clutch62_a_close_margin_avg", # Away avg margin in close games (signed) + ]) + + # ── Cat 63: Pythagorean Luck — expected vs actual win% (12 features) ── + # Morey exponent 13.91 (Basketball-Reference standard). Teams outperforming + # their Pythagorean expectation regress; underperformers bounce back. + names.extend([ + "pyth63_h_pyth_wp5", # Home Pythagorean win% (last 5) + "pyth63_a_pyth_wp5", # Away Pythagorean win% (last 5) + "pyth63_h_pyth_wp10", # Home Pythagorean win% (last 10) + "pyth63_a_pyth_wp10", # Away Pythagorean win% (last 10) + "pyth63_h_luck5", # Home actual WP - pyth WP (last 5, +luck) + "pyth63_a_luck5", # Away actual WP - pyth WP (last 5) + "pyth63_h_luck10", # Home actual WP - pyth WP (last 10) + "pyth63_a_luck10", # Away actual WP - pyth WP (last 10) + "pyth63_pyth_wp_diff", # h_pyth_wp10 - a_pyth_wp10 + "pyth63_luck_diff", # h_luck10 - a_luck10 (who's luckier) + "pyth63_h_regression_signal", # |luck10| — magnitude of regression expected + "pyth63_a_regression_signal", # |luck10| — magnitude of regression expected + ]) + + # ── Cat 64: Opponent-Elo-Weighted Performance (10 features) ── + # Weight rolling stats by opponent Elo: +10 margin vs 1600-Elo team > +10 vs 1400-Elo. + # Separates true quality from schedule-inflated stats. + names.extend([ + "elow64_h_wp10", # Home Elo-weighted win% (last 10) + "elow64_a_wp10", # Away Elo-weighted win% (last 10) + "elow64_h_margin10", # Home Elo-weighted avg margin (last 10) + "elow64_a_margin10", # Away Elo-weighted avg margin (last 10) + "elow64_h_netrtg10", # Home Elo-weighted net rating (last 10) + "elow64_a_netrtg10", # Away Elo-weighted net rating (last 10) + "elow64_wp_diff", # h_elo_wp - a_elo_wp + "elow64_margin_diff", # h_elo_margin - a_elo_margin + "elow64_h_trend", # h_elo_margin5 - h_elo_margin10 (improving?) + "elow64_a_trend", # a_elo_margin5 - a_elo_margin10 (improving?) + ]) + + # ── Cat 65: Style Matchup Advantage (12 features) ── + # Measures how each team's offensive strengths exploit the opponent's defensive gaps. + # e.g. high-3PA offense vs poor 3P-defense = large matchup advantage. + names.extend([ + "style65_h_efg_vs_opp_def", # Home eFG% - Away opp_eFG% (shooting edge) + "style65_a_efg_vs_opp_def", # Away eFG% - Home opp_eFG% (shooting edge) + "style65_h_tov_vs_opp_force", # Home TOV rate - Away opp_TOV rate (ball security edge) + "style65_a_tov_vs_opp_force", # Away TOV rate - Home opp_TOV rate + "style65_h_orb_vs_opp_drb", # Home ORB% - Away opp_ORB% (glass edge) + "style65_a_orb_vs_opp_drb", # Away ORB% - Home opp_ORB% + "style65_h_ftr_vs_opp_ftr", # Home FT rate - Away opp_FT rate (foul-drawing edge) + "style65_a_ftr_vs_opp_ftr", # Away FT rate - Home opp_FT rate + "style65_matchup_asymmetry", # |h_composite_edge - a_composite_edge| + "style65_h_composite_edge", # Weighted sum of h's 4-factor edges + "style65_a_composite_edge", # Weighted sum of a's 4-factor edges + "style65_net_style_edge", # h_composite - a_composite (positive = home style advantage) + ]) + + 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 + """ + # Auto-load historical odds if not provided + if odds_data is None: + odds_data = load_historical_odds() + # State trackers + team_results = defaultdict(list) # team → [(date, win, margin, opp, stats_dict)] + team_last = {} # team → last game date + team_elo = defaultdict(lambda: 1500.0) + team_home_results = defaultdict(list) + team_away_results = defaultdict(list) + h2h_results = defaultdict(list) # (team1, team2) → results + + # ── Category 24: Multi-ELO state trackers ── + team_elo_margin = defaultdict(lambda: 1500.0) # Margin-adjusted ELO + team_elo_offense = defaultdict(lambda: 1500.0) # Offensive ELO + team_elo_defense = defaultdict(lambda: 1500.0) # Defensive ELO + team_elo_recency = defaultdict(lambda: 1500.0) # Recency-weighted ELO + team_elo_history = defaultdict(list) # team → [elo_after_each_game] + team_home_margin_sum = defaultdict(float) # For home court advantage + team_home_games_count = defaultdict(int) + # ── Category 37: MOVDA ELO state trackers ── + team_movda = defaultdict(lambda: 1500.0) # MOVDA Elo rating + mov_surprise_ewm = defaultdict(float) # Per-team EWMA of MOV surprise + delta_mov_history = defaultdict(list) # Per-team raw delta_MOV history + _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 + # ── Category 18: Season-level trackers (precomputed per game via records) ── + # These are derived from team_results on-the-fly (no extra state needed) + # ── Category 51: Season era normalization trackers ── + # Running league-wide stat distributions per season (keyed by season start year) + _era51_ortg = defaultdict(list) # season_id → all team ORtg observations + _era51_drtg = defaultdict(list) # season_id → all team DRtg observations + _era51_pace = defaultdict(list) # season_id → all team pace observations + _era51_nrtg = defaultdict(list) # season_id → all team net-rtg observations + + # ── Categories 52-54: Odds line / ATS / O-U state trackers ── + # Per-game spread & total stored per team as (date, covered_ats, went_over, actual_margin, + # spread_home, total, is_home_game) — populated AFTER feature computation each game + _team_ats = defaultdict(list) # team → [(gd, covered_ats, spread, is_home_game)] + _team_ou = defaultdict(list) # team → [(gd, went_over, total, is_home_game)] + _season_spreads = [] # rolling list of abs(spread) values THIS season only + _season_totals = [] # rolling list of total values THIS season only + _cat52_season_id = None # tracks current season for spread/total reset + + 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))) + + # ── Pre-compute next-game-is-home lookup (Cat 6: h_next_is_home, a_next_is_home) ── + # Research (cmunch1/nba-prediction, uncertainty-aware 2025) shows home_next is top-3 predictor. + _tg_next = defaultdict(list) # team → [(game_date, is_home)] + 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 = {} # (team_abbr, game_date) → 1.0=home next, 0.0=away next, 0.5=unknown + 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 # last game in data — unknown + + X, y = [], [] + n_market = 32 if self.include_market else 0 + + for game in games: + # Skip non-dict entries (corrupted data or wrapper keys) + if not isinstance(game, dict): + continue + # Parse game + 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] + + # Reset Cat 52/54 season percentile accumulators at each new season boundary. + # Without this, _season_spreads/_season_totals grow across ALL seasons, making + # line52_spread_season_pct / line52_total_season_pct lifetime percentiles instead + # of within-season percentiles (semantic bug: variable name vs actual behavior). + _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] + + # Skip if not enough history for any features + if len(hr_) < 3 or len(ar_) < 3: + # Still record this game for future reference + 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) + # Update multi-ELO systems (Cat 24) + 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) + # Update MOVDA ELO (Cat 37) + 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 + + # ── Build feature vector ── + row = [] + + # 1. ROLLING PERFORMANCE (96 features) + 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)) + + # 2. FOUR FACTORS (64 features) + 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)) + + # 3. PACE & EFFICIENCY (48 features) + 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)) + + # 4. SCORING PROFILE (40 features) + 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")) + + # 5. MOMENTUM & STREAKS (32 features) + 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)) + + # 6. REST & SCHEDULE (26 features) + 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), # h_next_is_home + _team_next_is_home.get((away, gd), 0.5), # a_next_is_home + ]) + + # 7. OPPONENT-ADJUSTED (24 features) + 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)) + + # 8. MATCHUP & HEAD-TO-HEAD (20 features) + 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, # +50 for home court + (team_elo[home] - 1500) - self._elo_10_ago(hr_, team_elo, home), + ]) + + # 9. MARKET MICROSTRUCTURE (32 features) — filled from market_data or zeros + 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, # model_vs_market — filled post-prediction + 0, # edge_magnitude + 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, + ]) + + # 10. CONTEXT & SITUATIONAL (24 features) + 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, # home court + 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, # national TV (needs external data) + 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, # game importance (needs standings) + self._ppg(hr_, 10) + self._ppg(ar_, 10), + ]) + + # 11-15. PLACEHOLDER CATEGORIES (skippable — all zeros/defaults without real data) + if not self.skip_placeholder: + # 11. REFEREE FEATURES (10 features) + 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), + ]) + + # 12. PLAYER IMPACT FEATURES (16 features) + 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) + + # 13. QUARTER-LEVEL PATTERNS (14 features) + 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)) + + # 14. DEFENSIVE MATCHUP ADVANCED (12 features) + 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) # proxy: own FB pts + row.append(min(1.0, self._blk_rate(tr, 10) * 5)) # proxy: blk_rate for shot contest + row.append(self._stat_avg(tr, 10, "stl_rate") * 5) # proxy: deflections + row.append(self._opp_efg(tr, 10) or 0.5) # opp eFG% as rim/perimeter defense (fallback 0.5 neutral) + + # 15. POLYMARKET & PREDICTION MARKET (8 features) + pmkt = (market_data or {}).get(game.get("id", gd), {}) + # Fall back to book-implied probability from odds_data when polymarket unavailable. + # This converts zero-variance constants into real signal for historical games. + _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 + # market_wisdom_confidence proxy: 1 - normalized(books_disagreement). + # books_disagreement = max(implied_home_prob) - min(implied_home_prob) across books, + # computed in load_historical_odds() for games with multi-book CSV data. + # Scale: 0.0 disagree → confidence 1.0; 0.10 disagree → confidence 0.0. + _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), # fallback: book implied prob + 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), # fallback: book fair prob + pmkt.get("market_wisdom_confidence", _mwc_fallback), # proxy: 1-norm(books_disagree) + pmkt.get("smart_vs_public_divergence", 0.0), + pmkt.get("closing_line_value_history", 0.0), + ]) + + # ── CATEGORIES 16-25: ADVANCED FEATURE COMPUTATION ── + # Build name→index lookup for values already computed (cats 1-15) + _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 + + # 16. INTERACTION & POLYNOMIAL FEATURES + # 16a. Pairwise interaction products + 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)) + + # 16b. Ratio features + 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) + + # 16c. Squared terms + 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) + + # 16d. Trend delta features: short_window - long_window + 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}")) + + # ══════════════════════════════════════════════════════════ + # 17. ADVANCED ROLLING STATISTICS (168 features) + # EWMA, volatility, z-scores, skew/kurtosis, range, CV + # ══════════════════════════════════════════════════════════ + + # Stat name → record accessor (record = (date, win, margin, opp, stats_dict)) + _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} + + # Pre-extract all needed stat series for both teams (avoid redundant slicing) + _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] + + # ── EWMA: 7 stats × 3 alphas = 21 per team ── + 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])) + + # ── Rolling volatility (std): 6 stats × 3 windows = 18 per team ── + for _st in ["margin", "ppg", "papg", "ortg", "drtg", "pace"]: + for _w in [5, 10, 20]: + row.append(_std_17(_c[_st][_w])) + + # ── Rolling min/max: 4 stats × 2 windows × 2 = 16 per team ── + 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]) + + # ── Z-scores vs season avg: 10 stats = 10 per team ── + 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) + + # ── Skew & kurtosis (window=10): 4 stats × 2 = 8 per team ── + 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) # skew + row.append((_m4 / (_sd ** 4)) - 3.0 if _sd > 1e-9 else 0.0) # kurtosis + else: + row.extend([0.0, 0.0]) + + # ── Range (max - min): 4 stats × 2 windows = 8 per team ── + 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) + + # ── Coefficient of variation: 3 stats = 3 per team ── + 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) + + # ── Locate category boundaries once (cached after first game) ── + 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)) + + # ================================================================ + # 18. SEASON TRAJECTORY & CONTEXT (86 features) — REAL COMPUTATION + # ================================================================ + 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, []) + + # ── Pythagorean win expectation ── + 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) + + # Pythagorean vs actual (luck measure) + actual_wp = self._wp(tr, n_games) + row.append(pyth_wp - actual_wp) + + # Win pace over 82 games + win_pace = actual_wp * 82.0 + row.append(win_pace / 82.0) + + # Playoff pace delta (vs 42-win threshold) + row.append((win_pace - 42.0) / 82.0) + + # Games behind 1st in conference + 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) + + # Games behind 8th seed + eighth_wp = conf_wps[7][1] if len(conf_wps) >= 8 else 0.5 + row.append((eighth_wp - actual_wp) * n_games / 82.0) + + # Games ahead of lottery (worst record) + worst_wp = conf_wps[-1][1] if conf_wps else 0.5 + row.append((actual_wp - worst_wp) * n_games / 82.0) + + # Strength of remaining schedule + row.append(self._sos(tr, team_results, min(n_games, 20))) + + # Conference ranking + 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) + + # Division ranking + 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) + + # Is playoff team (top 10 in conf) + row.append(1.0 if team_rank <= 10 else 0.0) + + # Play-in range (7th-12th) + row.append(1.0 if 7 <= team_rank <= 12 else 0.0) + + # Pre All-Star win% (first 55 games proxy) + pre_asg = tr[:min(55, n_games)] + row.append(self._wp(pre_asg, len(pre_asg)) if pre_asg else 0.5) + + # Post All-Star win% + post_asg = tr[55:] if n_games > 55 else [] + row.append(self._wp(post_asg, len(post_asg)) if post_asg else 0.5) + + # All-Star delta + 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 trade deadline win% (first 45 games proxy) + pre_dl = tr[:min(45, n_games)] + row.append(self._wp(pre_dl, len(pre_dl)) if pre_dl else 0.5) + + # Post deadline win% + post_dl = tr[45:] if n_games > 45 else [] + row.append(self._wp(post_dl, len(post_dl)) if post_dl else 0.5) + + # Deadline delta + 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) + + # Monthly win% trend + if n_games >= 30: + row.append(self._wp(tr[-15:], 15) - self._wp(tr[-30:-15], 15)) + else: + row.append(0.0) + + # Monthly ORtg trend + if n_games >= 30: + row.append(self._ortg(tr, 15) - self._stat_avg(tr[-30:-15], 15, "ortg")) + else: + row.append(0.0) + + # Monthly DRtg trend + if n_games >= 30: + row.append(self._drtg(tr, 15) - self._stat_avg(tr[-30:-15], 15, "drtg")) + else: + row.append(0.0) + + # Season half improvement: 2nd half vs 1st half win% + 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) + + # Regression indicator (distance from .500) + row.append(actual_wp - 0.5) + + # Hot/cold regime (last 15: 1=hot, 0=neutral, -1=cold) + 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) + + # Clinch status (0=eliminated, 1=alive, 2=clinched) + 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) + + # Games remaining + row.append(games_rem / 82.0) + + # Win% last 30 games + wp_last30 = self._wp(tr, 30) + row.append(wp_last30) + + # Win% last 30 vs season (form delta) + row.append(wp_last30 - actual_wp) + + # Home/road trend last 5 + 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) + + # Scoring variance trend + if n_games >= 20: + row.append((self._consistency(tr[-20:-10], 10) - self._consistency(tr, 10)) / 15.0) + else: + row.append(0.0) + + # First half margin avg last 10 (proxy: half margin) + last10 = tr[-10:] + avg_m10 = sum(r[2] for r in last10) / max(len(last10), 1) + row.append(avg_m10 * 0.5 / 15.0) + + # Second half margin avg last 10 (proxy) + row.append(avg_m10 * 0.5 / 15.0) + + # Half margin delta (proxy: 0) + row.append(0.0) + + # ATS record (placeholder) + row.append(0.5) + + # ATS trend last 10 (placeholder) + row.append(0.5) + + # Over rate season (placeholder) + row.append(0.5) + + # Point diff: close games vs all + 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) + + # Record after loss (resilience) + 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) + + # Record after win (consistency) + 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) + + # Record after B2B + 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) + + # Blowout bounce-back + 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) + + # Overtime record (proxy: very close games) + 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) + + # Game-level trajectory differentials (2 features) + 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) + + # 19-23: Placeholder zeros + _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) + + # ================================================================ + # 24. POWER RATING COMPOSITES (64 features) — REAL COMPUTATION + # ================================================================ + for prefix, team_key in [("h", home), ("a", away)]: + tr = team_results[team_key] + n_gp = len(tr) + + # Standard ELO (normalized) + row.append((team_elo[team_key] - 1500.0) / 400.0) + + # Margin-adjusted ELO + row.append((team_elo_margin[team_key] - 1500.0) / 400.0) + + # Recency-weighted ELO + row.append((team_elo_recency[team_key] - 1500.0) / 400.0) + + # Home-court adjusted ELO + 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-adjusted ELO + 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) + + # Conference-adjusted ELO + 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-adjusted ELO + 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) + + # RAPTOR composite (offensive + defensive ELO blend) + 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) + + # RAPTOR offense + row.append((off_elo - 1500.0) / 400.0) + + # RAPTOR defense + row.append((def_elo - 1500.0) / 400.0) + + # Power rank by offense (ORtg season) + ortg_s = self._ortg(tr, n_gp) if tr else 100.0 + row.append(ortg_s / 120.0) + + # Power rank by defense (DRtg season, lower=better) + drtg_s = self._drtg(tr, n_gp) if tr else 110.0 + row.append(1.0 - drtg_s / 120.0) + + # Power rank by net rating + netrtg_s = ortg_s - drtg_s + row.append(netrtg_s / 20.0) + + # SRS (Simple Rating System) + avg_m = self._pd(tr, n_gp) if tr else 0.0 + row.append((avg_m + (sos_val - 0.5) * 10) / 20.0) + + # Composite power rank + 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) + + # Power rank trend (ELO change over last 10 games) + 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) + + # Power conf adjusted + row.append((team_elo[team_key] - conf_avg_elo + netrtg_s) / 30.0) + + # Power stability (std dev of ELO last 20) + 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) + + # Power percentile in league + 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) + + # Rating confidence (games played) + row.append(min(1.0, n_gp / 30.0)) + + # Bayesian rating + 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) + + # Glicko-style rating + row.append((team_elo[team_key] - 1500.0) / 400.0) + + # Glicko RD (uncertainty) + glicko_rd = max(30.0, 350.0 - max(n_gp, 1) * 5.0) + row.append(glicko_rd / 350.0) + + # TrueSkill mu + row.append((team_elo[team_key] - 1500.0) / 400.0) + + # TrueSkill sigma + row.append(max(0.1, 1.0 - max(n_gp, 1) / 82.0)) + + # Pairwise power differentials (14 features) + h_elo_s = team_elo[home] + a_elo_s = team_elo[away] + row.append((h_elo_s - a_elo_s) / 400.0) # elo_std_diff + row.append((team_elo_margin[home] - team_elo_margin[away]) / 400.0) # elo_margin_diff + row.append((team_elo_recency[home] - team_elo_recency[away]) / 400.0) # elo_recency_diff + 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) # raptor_diff + row.append((team_elo_offense[home] - team_elo_offense[away]) / 400.0) # raptor_off_diff + row.append((team_elo_defense[home] - team_elo_defense[away]) / 400.0) # raptor_def_diff + 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) # srs_diff + 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) # composite_diff + 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) # conf_adj_diff + 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) # bayesian_diff + row.append((h_elo_s - a_elo_s) / 400.0) # glicko_diff + row.append((h_elo_s - a_elo_s) / 400.0) # trueskill_diff + _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)) # max_diff + row.append(sum(_all_diffs) / len(_all_diffs)) # avg_diff + + # 25. FATIGUE & LOAD MANAGEMENT + for prefix, tr, team_key in [("h", hr_, home), ("a", ar_, away)]: + # Cumulative games played this season + cumul_gp = len(tr) + row.append(cumul_gp / 82.0) + + # Cumulative minutes total (proxy: games × 48) + row.append(cumul_gp * 48.0 / (82.0 * 48.0)) + + # Avg minutes per game (proxy: constant 48 team mins) + row.append(48.0 / 48.0) + + # Star minutes cumulative (proxy from player data) + 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)) # Normalized + + # Star minutes as pct of season capacity + row.append(star_min / max(82.0 * 40.0, 1)) + + # Travel miles this season + season_miles = self._total_miles_season(tr, team_key) + row.append(season_miles / 50000.0) # Normalize + + # Travel miles last 30 days + row.append(self._miles_in_window(tr, gd, 30, team_key) / 15000.0) + + # Travel miles last 7 days + row.append(self._miles_in_window(tr, gd, 7, team_key) / 5000.0) + + # Travel intensity: miles per game last 10 + 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 pattern consistency: std of rest days between last 10 games + 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) + + # Rest deficit vs league avg (~1.2 days between games) + 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 count this season + b2b_season = self._count_b2b_in_window(tr, gd, 300) + row.append(b2b_season / 20.0) + + # B2B count last 30 days + b2b_30d = self._count_b2b_in_window(tr, gd, 30) + row.append(b2b_30d / 5.0) + + # 3-in-4 count this season + three_in_4 = self._count_dense_stretches(tr, gd, 300, 3, 4) + row.append(three_in_4 / 15.0) + + # Dense schedule flag: 4+ games in last 7 days + g7 = self._games_in_window(tr, gd, 7) + row.append(1.0 if g7 >= 4 else 0.0) + + # Road trip length (consecutive away games) + road_len = self._consecutive_away(tr) + row.append(road_len / 7.0) + + # Home stand length (consecutive home games) + home_len = self._consecutive_home(tr) + row.append(home_len / 7.0) + + # Road trip fatigue: road games × avg travel + row.append(road_len * (miles_10g / max(games_10, 1)) / 5000.0) + + # Load management probability (high fatigue + star minutes) + 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) + + # Season fatigue curve: expected dropoff based on game number + row.append(max(0.0, (cumul_gp - 50) / 82.0)) + + # Relative fatigue vs league avg (~41 games at midseason) + # Approximate league avg as season_pct * 82 + row.append((cumul_gp - 41 * sp) / 20.0 if sp > 0 else 0.0) + + # Fatigue-adjusted ORtg + 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) + + # Fatigue-adjusted DRtg (higher = worse defense when tired) + drtg_val = self._drtg(tr, 10) + row.append(drtg_val * (1.0 + fatigue_penalty * 0.5) / 110.0) + + # Fatigue-adjusted win% + wp_val = self._wp(tr, 10) + row.append(wp_val * (1.0 - fatigue_penalty)) + + # Recovery quality: win% when rested 2+ days (historical) + 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 performance drop + 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) + + # Altitude fatigue cumulative (games at high altitude recently) + 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) + + # Timezone changes this season + tz_changes = self._count_tz_changes(tr) + row.append(tz_changes / 40.0) + + # Circadian disruption (recent timezone impact) + row.append(abs(TIMEZONE_ET.get(team_key, 0) - + TIMEZONE_ET.get(self._last_location(tr), 0)) / 3.0) + + # Early season load: heavy in first 30 games + 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 season load: games 60+ + 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) + + # Minutes distribution health (proxy: consistency of scoring) + scoring_std = self._consistency(tr, 10) / 15.0 + row.append(max(0, 1.0 - scoring_std)) + + # Injury risk score (fatigue-based proxy) + 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)) + + # Stamina rating: Q4 vs Q1 performance proxy (use margin trend) + row.append(self._wp(tr[-5:], 5) - self._wp(tr[-15:], 15) + 0.5) + + # Clutch fatigue: performance in close games on heavy schedule + 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) + + # Fresh vs tired ratio + 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)) + + # Optimal rest indicator: 2-3 days rest, no recent travel + row.append(1.0 if 2 <= this_rest <= 3 and g7 <= 3 else 0.0) + + # Wear and tear index: composite fatigue accumulation + 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)) + + # Game-level fatigue differentials (8 features) + # Access the per-team fatigue values just computed + # Home team values start at _cat25_start, away at _cat25_start + 38 + _h25 = _cat25_start + _a25 = _cat25_start + 38 # 38 per-team features + row.append(row[_h25] - row[_a25]) # cumul_games diff + row.append(row[_h25 + 5] - row[_a25 + 5]) # travel_season diff + row.append(row[_h25 + 9] - row[_a25 + 9]) # rest_consistency diff + row.append(row[_h25 + 37] - row[_a25 + 37]) # wear_tear diff + row.append(row[_h25 + 4] - row[_a25 + 4]) # star_minutes_pct diff + row.append(row[_h25 + 12] - row[_a25 + 12]) # b2b_count_30d diff + # Fatigue-adjusted spread + 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) # fatigue-adjusted spread + # Fatigue composite edge + row.append((a_wear - h_wear) * 0.5 + (row[_a25 + 9] - row[_h25 + 9]) * 0.3 + + (row[_a25 + 12] - row[_h25 + 12]) * 0.2) # composite edge + + # ════════════════════════════════════════════════════════════════ + # CATEGORIES 26-35: NEW FEATURE COMPUTATION + # Features are computed from available data where possible. + # External data (player tracking, referee, venue, market props, + # Bayesian priors) default to sensible baselines when absent. + # The genetic algorithm will learn which features are useful. + # ════════════════════════════════════════════════════════════════ + + # Update name→index lookup to include cats 1-25 + _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 + + # ── 26. ADVANCED PLAYER IMPACT (220 features) ── + # Star player stats (from player_data or sensible defaults) + _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, {}) + # Star 1 stats across windows + for stat in _pi_stats: + base = pd_.get(f"star1_{stat}", _pi_defaults.get(stat, 0.0)) + for w in _pi_windows: + # Proxy: slightly vary by window (less data = more regressed) + regression = min(1.0, w / 20.0) + row.append(base * regression + _pi_defaults[stat] * (1 - regression)) + # Star 2 stats across windows + 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)) + # Team-level player impact (12 features per team) + 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)) + + # Matchup-level player impact differentials (14 features) + 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)) + + # ── 27. REFEREE DEEP ANALYSIS (120 features) ── + ref = (referee_data or {}).get(game.get("id", gd), {}) + # Quarter-specific features (24 features) + 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)) + # Ref bias by team type (18 features) + 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)) + # Ref over/under tendencies (21 features) + 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)) + # Ref pace impact per team (8 features) + 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)) + # Ref composites (16 features) + 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)) + + # ── 28. VENUE & ENVIRONMENTAL (160 features) ── + 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) + # Altitude-adjusted ratings by window + 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) + # Timezone crossing features by window + 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]) + # Home/away venue features + 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) + # Timezone disruption + 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) + # Attendance and arena + row.append(0.85) # attendance ratio proxy + row.append(0.0) # attendance trend proxy + row.append(0.5) # court surface age proxy + row.append(0.5) # temperature proxy + row.append(1.0) # indoor flag + row.append(0.5) # arena capacity normalized + # Noise proxy: home wp as arena strength indicator + row.append(self._wp(team_home_results.get(team_key, []), 20)) + + # Game-level venue differentials (16 features) + 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) # attendance ratio + row.append(0.0) # arena age diff + row.append(0.0) # climate diff + row.append(1.0 if h_tz < a_tz else (-1.0 if h_tz > a_tz else 0.0)) + row.append(0.0) # acclimatization diff + row.append(self._wp(team_home_results.get(home, []), 20)) + row.append(0.0) # surface familiarity + 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) # combined environmental edge + + # ── 29. ADVANCED MARKET MICROSTRUCTURE III (220 features) ── + if self.include_market: + mkt = (market_data or {}).get(game.get("id", gd), {}) + # Line movement velocity & acceleration (36 features) + 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)) + # Book consensus divergence (28 features) + 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)) + # Sharp vs public (9 features) + 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)) + # Steam (6 features) + 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)) + # Reverse line movement (5 features) + 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)) + # CLV by book (7 features) + for book in ["pinnacle", "draftkings", "fanduel", "betmgm", + "caesars", "bet365", "william_hill"]: + row.append(mkt.get(f"clv_{book}_home", 0.0)) + # Opening-to-closing deltas (4 features) + 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)) + # Convergence (10 features) + 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)) + # Historical patterns (10 features) + 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)) + + # ── 30. TIME SERIES DECOMPOSITION (320 features) ── + _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_)]: + # Trend components: short vs long window (32 per team) + for stat in _ts_stats_list: + fn = _ts_stat_fn[stat] + for w in [3, 5, 10, 20]: + # Trend = current window value minus season average + 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) + + # Seasonal by day-of-week (8 per team) + 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) + + # Seasonal by month (8 per team) + 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) + + # Residual volatility (8 per team) + 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) + # Linear trend + 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) + + # Autocorrelation features (40 per team) + 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) + + # Partial autocorrelation (40 per team — approximated as ACF residuals) + 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: + # Approximation: PACF ~ ACF / (1 + lag * 0.1) + 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) + + # Stationarity indicators (8 per team) + 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) + + # Trend strength (8 per team) + 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) + + # Seasonality strength (8 per team) + 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) + + # ── 31. CROSS-TEAM INTERACTION MATRIX (440 features) ── + _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), + } + # Pairwise matchup: diff, ratio, interaction, mismatch (120 features) + 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) # diff + row.append(h_v / max(abs(a_v), 0.001)) # ratio + row.append(h_v * a_v) # interaction + row.append(abs(h_v - a_v)) # mismatch + # Offensive vs defensive matchup (30 features) + 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) + # Reverse: away offense vs home defense (30 features) + 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) + # Style clash indices (30 features) + for style_idx in range(10): + for w in [5, 10, 20]: + # Simple composite based on available stats + row.append(abs(self._pace(hr_, w) - self._pace(ar_, w)) / 10.0 + + abs(self._ortg(hr_, w) - self._drtg(ar_, w)) / 20.0) + # Strength matchup areas (30 features) + for area_idx in range(10): + for w in [5, 10, 20]: + # Use different stat combinations as proxies + h_str = self._ortg(hr_, w) / 110.0 + a_wk = self._drtg(ar_, w) / 110.0 + row.append(h_str - a_wk) + # Game-level composites (10 features) + 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) + + # ── 32. BAYESIAN PRIORS (220 features) ── + 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) + # Pre-season and Vegas priors (10 per team) + 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) + # Franchise historical (10 per team) + 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)) + # Coach impact (18 per team) + 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 features (10 per team) + 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) + # Game-level Bayesian differentials (10 features) + 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 + # Use freshly computed values from the row + row.append(h_bay_wp - a_bay_wp) # preseason diff + row.append(0.0) # vegas win total diff + row.append(0.0) # franchise diff + row.append(0.0) # coach wp diff + row.append(0.0) # coach tenure diff + row.append(0.0) # prior blend diff + row.append(0.0) # regression diff + row.append(0.0) # championship odds diff + row.append(0.0) # system maturity diff + row.append(0.0) # composite diff + + # ── 33. NETWORK/GRAPH FEATURES (220 features) ── + # Compute lightweight graph features from win/loss records + for prefix, team_key, tr in [("h", home, hr_), ("a", away, ar_)]: + n_gp = len(tr) + # PageRank-style features (computed from wins) + 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 proxy: weighted by opponent strength + pagerank_wins = sum(opp_wps) / max(len(opp_wps), 1) + row.append(pagerank_wins) + # Margin-weighted PageRank + 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) + # Combined weighted + row.append((pagerank_wins + margin_pr) / 2.0) + else: + row.extend([0.5, 0.0, 0.25]) + # Clustering coefficient proxy (3 features) + for w in [10, 20, 82]: + recent = tr[-w:] + opps = set(r[3] for r in recent) + if len(opps) >= 3: + # How many of my opponents also played each other? + 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) + # Betweenness centrality proxy (3 features) + for w in [10, 20, 82]: + recent = tr[-w:] + # Proxy: number of unique opponents / league size + opps = set(r[3] for r in recent) + row.append(len(opps) / 29.0) + # SOS network features (6 features) + 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]) + # Conference connectivity (4 features) + 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) + # Division features (4 features) + 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) + # Win/loss chain features (6 features) + for w in [10, 20, 82]: + recent = tr[-w:] + beaten = set(r[3] for r in recent if r[1]) + # 2nd order: teams beaten by teams I beat + 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)) + # Loss chain (3 features) + 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)) + # Network diversity (9 features) + 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) + # Eigenvector centrality proxy (3 features) + 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) + # Game-level network differentials (10 features) + row.extend([0.0] * 10) + + # ── 34. ENSEMBLE META-FEATURES (160 features) ── + # These default to baselines — populated by model training pipeline + _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) # accuracy + for model in _meta_models: + row.append(0.0) # calibration + row.append(0.0) # model disagreement + row.append(0.0) # disagreement trend + row.append(0.5) # prediction uncertainty + row.append(0.5) # prediction stability + for model in _meta_models: + row.append(0.5) # predicted prob + row.append(0.5) # confidence + row.append(0.0) # edge vs market + # Game-level ensemble features (20 features) + row.extend([ + 0.5, 0.1, 0.5, 0.5, 0.0, # mean, std, max, min, range + 0.5, 0.5, 0.0, 0.5, 0.5, # agreement, weighted, calibration residual, hist accuracy, feat stability + 0.0, 0.0, 0.0, 0.5, 0.0, # feature regime, drift 5, drift 10, confidence, edge_conf + 0.0, 0.5, 0.0, 0.5, 0.0, # risk adj, bankroll, EV, sharpe, hist ROI + ]) + + # ── 35. TEMPORAL DECAY FEATURES (320 features) ── + _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:] # Use last 30 games for decay features + # Exponential decay weighted stats (40 per team) + 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)) + # Recency-weighted opponent quality (4 per team) + 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)) + # Time-weighted home/away splits (8 per team) + 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)) + # Season-phase interaction terms (30 per team) + n_gp = len(tr) + for stat in _td_stats_list: + fn = _td_stat_fn[stat] + # Early phase (first 27 games) + 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 phase (games 28-54) + 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 phase (games 55+) + 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) + # Decay-weighted trend: fast vs slow (60 per team) + _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) + + # Game-level temporal decay differentials (40 features) + 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) + + # ════════════════════════════════════════════════════════════════ + # EXPANDED SUB-FEATURES COMPUTATION (matching name registration) + # ════════════════════════════════════════════════════════════════ + + # 26b. Position & lineup expanded features + _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) + # Player synergy combos + 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) + # Lineup unit features + 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) + # Position matchup advantages + for pos in _positions: + row.append(0.0) # off advantage + row.append(0.0) # def advantage + row.append(0.0) # size diff + row.append(0.0) # speed diff + + # 27b. Referee expanded + 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) # Crew composition features + for prefix in ["h", "a"]: + for w in [5, 10]: + row.extend([0.5, 0.5, 0.5, 0.5]) + + # 28b. Venue expanded + for prefix in ["h", "a"]: + row.extend([0.5, 0.5, 0.0, 0.0, 0.0, 0.0]) # weather features + row.extend([0.0, 0.0]) # travel weather + for prefix in ["h", "a"]: + row.extend([0.5, 0.0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]) # arena features + for prefix in ["h", "a"]: + row.extend([0.5, 0.5, 0.5, 0.5]) # city features + row.extend([0.5, 0.0, 0.0, 0.0, 0.5, 0.5]) # time of game + for prefix, tr in [("h", hr_), ("a", ar_)]: + for w in [5, 10, 20]: + row.extend([0.5, 0.0, 0.5, 0.0]) # altitude performance + for prefix, tr in [("h", hr_), ("a", ar_)]: + for w in [5, 10]: + row.extend([0.5, 0.0, 0.5, 0.0]) # cross-country + row.extend([0.0] * 8) # environmental composites + + # 29b. Market expanded + if self.include_market: + mkt = (market_data or {}).get(game.get("id", gd), {}) + # Prop markets (40 features) + 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)) + # Alt markets (6 features) + row.extend([0.0] * 6) + # Cross-market correlations (5 features) + row.extend([0.0] * 5) + # Snapshots (18 features) + 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)) + # Market efficiency (8 features) + row.extend([0.5, 0.5, 0.5, 0.5, 0.0, 0.5, 0.0, 0.0]) + # Historical accuracy (10 features) + 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) + + # 31b. Cross-team expanded + # Advanced matchup stats (144 features) + 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]) + # Shot zones (50 features) + 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]) + # Pace categories (20 features) + for pace_cat in ["ultra_fast", "fast", "average", "slow", "ultra_slow"]: + row.extend([0.5, 0.5, 0.0, 0.0]) + + # 32b. Bayesian expanded + 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]) # coach expected + row.extend([0.0, 0.5, 0.7, 0.0]) # roster turnover + row.extend([0.0, 0.0, 0.5]) # injury + for prior in ["flat", "preseason", "historical", "market_implied", "composite"]: + row.append(0.0) + row.extend([0.0] * 8) + + # 33b. Network expanded + for prefix, team_key, tr in [("h", home, hr_), ("a", away, ar_)]: + # Conf subgraph (4) + div subgraph (3) + row.extend([0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]) + # Quality weighted (9) + for w in [10, 20, 82]: + row.extend([0.0, 0.0, 0.0]) + # 2nd order opponent (5) + row.extend([0.5, 0.5, 0.5, 0.5, 0.5]) + # Transitive (6) + for depth in [2, 3, 4]: + row.extend([0.0, 0.0]) + # Colley (2) + row.extend([0.5, 0.5]) + # Massey (3) + row.extend([0.5, 0.5, 0.5]) + # Keener (2) + row.extend([0.5, 0.5]) + row.extend([0.0] * 12) # network differentials + + # 34b. Ensemble expanded + 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]) + + # 35b. Temporal decay expanded + # Kernel-weighted (90 per team × 2 = 180 features) + 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) + # All kernels approximate to EWMA with different bandwidth + 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)) + # Regime change detection (32 features) + 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)) # regime change + row.append(10.0) # regime duration + row.append(m2) # regime level + # CUSUM + 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]) + # Weighted percentiles (30 per team × 2 = 60 features) + 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) + # Adaptive half-life (10 per team × 2 = 20 features) + 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]) + + # ── Cross-category interactions ── + 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")) + + # Bayesian × Power Rating interactions + 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)) + + # Network × Matchup interactions + row.extend([0.0, 0.0, 0.0, 0.0]) + + # Player Impact × Fatigue interactions + 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) + + # Referee × Venue interactions + row.extend([0.0, 0.0, 0.0, 0.0]) + + # Time Series × Cross-Team interactions + 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) + + # Ensemble × Market interactions + if self.include_market: + row.extend([0.0, 0.0, 0.0, 0.0, 0.0]) + + # Grand composite features + 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) + + # ── Higher-order polynomial features on new categories ── + _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 interaction products + _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)) + + # Ratio features + 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 interactions + _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)) + + # ── Rolling cross-category features ── + _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) # decay stat volatility + row.append(dw - season_avg) # decay stat trend + z = (dw - season_avg) / max(vol, 1e-6) + row.append(z) # decay stat z-score + + # Rolling network features + for prefix, team_key in [("h", home), ("a", away)]: + tr = team_results[team_key] + for w in [10, 20]: + row.append(0.0) # pagerank change + row.append(0.0) # centrality change + row.append(0.0) # quality wins trend + + # Rolling Bayesian features + for prefix in ["h", "a"]: + for w in [10, 20]: + row.extend([0.0, 0.0, 0.0]) + + # Rolling cross-team features + 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) + + # Cumulative info features + 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) + + # Game-level summary features + row.extend([0.0] * 10) + + # ── Extended rolling on new stats ── + _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): + # Compute base value from available 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: + # Proxy: regress toward base with window + factor = min(1.0, w / 10.0) + row.append(base * factor) + + # Extended EWMA + 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) + + # Extended volatility + 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) + + # Extended z-scores + 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) + + # Extended trend deltas + 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) + + # ── Additional cross-window momentum on new stats ── + 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) # delta + 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)) # acceleration + + # Cross-window composites for new stats + 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) # shortterm trend + row.append(long - mid) # longterm trend + 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) # volatility trend + row.append(1.0 if short > long + 3 else 0.0) # breakout + row.append(1.0 if short < long - 3 else 0.0) # decline + + # ── Additional interaction features ── + _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)) + + # ── Per-opponent tier features ── + _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) + + # ── Home/away by window features ── + 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) + + # ── Day-of-week features ── + 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 features ── + _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]) + + # ── Consecutive game pattern features ── + 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]: + # After win streak + 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) + # After loss streak + 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) + + # ── Score differential bucket features ── + _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 game win% after this bucket + 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) + + # ── Quarter-specific detailed features ── + 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)) + + # ── 36. EWMA PERFORMANCE + CROSSOVERS + REST INTERACTIONS ── + _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_)]: + # Build per-game stat series (last 20 games) for EWMA + _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)))) + + # EWMA: 8 stats × 5 alphas = 40 per team + 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) + + # EWMA crossover: fast(0.7) - slow(0.05) = momentum signal + 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) + + # Rest × performance interactions (12 features) + _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, # rest × win% + min(a_rest, 7) * _a_wp5, + _h_b2b * _h_margin5, # b2b × margin + _a_b2b * _a_margin5, + _h_fatigue * _h_ortg, # fatigue × offensive rating + _a_fatigue * _a_ortg, + (h_rest - a_rest) * (_h_wp5 - _a_wp5), # rest_adv × wp_diff + (_h_b2b - _a_b2b) * (_h_margin5 - _a_margin5), # b2b_diff × margin_diff + min(h_rest, 7) ** 2 / 49.0, # rest squared (normalized) + 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), + ]) + + # ── 37. MOVDA ELO FEATURES (13 features) ── + _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) # movda_rating (normalized) + row.append(mov_surprise_ewm[_mk] / 20.0) # mov_surprise_ewm (normalized) + row.append(_movda_dr / 400.0) # movda_diff + row.append(_movda_wp) # movda_win_prob + # Raw delta_MOV rolling features (no EWM smoothing) + 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) # {prefix}_delta_mov_raw + row.append(_roll5) # {prefix}_delta_mov_rolling_5 + row.append(_roll10) # {prefix}_delta_mov_rolling_10 + # delta_mov_diff: home rolling_5 - away rolling_5 + _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) # delta_mov_diff + + # ── 38. VENUE-CONDITIONAL MATCHUP FEATURES (14 features) ── + # Use true venue-specific records: home team at home vs away team on road + _h_home_tr = team_home_results.get(home, []) + _a_away_tr = team_away_results.get(away, []) + for _w38 in [5, 10, 20]: + # WP edge: home team's home record vs away team's road record + _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) + # Margin edge: home margin at home vs away margin on road + _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) + # ORtg edge: home team's home offense vs away team's road defense + _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) + # DRtg edge: home team's home defense vs away team's road offense + _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) + # Home court boost: how much better the home team is at home vs overall + _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) + # Road penalty: how much worse away team is on road vs overall + _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) + + # ── 39. CIRCADIAN RHYTHM & TRAVEL FATIGUE (8 features) ── + # Novel normalized composites distinct from raw Cat 6 rest/travel features + try: + _h_dist = self._travel_dist(hr_, home) / 500.0 # Normalize: ~500mi = 1 unit + _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 + # Fatigue index: travel + timezone disruption + back-to-back penalty - rest recovery + _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 + # Rest non-linearity: diminishing benefit beyond 3 days (capped at ±2) + _h_rest_nl = min(h_rest, 5) ** 0.5 - min(a_rest, 5) ** 0.5 + row.extend([ + min(_h_dist, 6.0), # circ_h_travel_dist (cap at 6 = 3000mi) + min(_a_dist, 6.0), # circ_a_travel_dist + float(_h_tz), # circ_h_tz_shift + float(_a_tz), # circ_a_tz_shift + max(-3.0, min(5.0, _h_fatigue)), # circ_h_fatigue_index (clipped) + max(-3.0, min(5.0, _a_fatigue)), # circ_a_fatigue_index (clipped) + max(-5.0, min(5.0, _a_fatigue - _h_fatigue)), # circ_advantage + max(-2.0, min(2.0, _h_rest_nl)), # circ_rest_nonlinear + ]) + except Exception: + row.extend([0.0] * 8) + + # ── 41. TRANSITION vs HALF-COURT EFFICIENCY SPLITS (7 features) ── + # Derived from fb_pts (fast break) and pace in existing box score stats + 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) + # Half-court efficiency: scoring minus fast-break contribution, per possession + _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 + # pace × fb_rate interaction: high-pace + high-transition = fast-break synergy + _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), # trans41_h_fb_rate + min(_a_fb_rate, 0.5), # trans41_a_fb_rate + min(_h_hc_eff / 120.0, 1.2), # trans41_h_halfcourt_eff (normalized) + min(_a_hc_eff / 120.0, 1.2), # trans41_a_halfcourt_eff + _h_fb_rate - _a_fb_rate, # trans41_fb_rate_diff + _h_pace_fb - _a_pace_fb, # trans41_pace_x_fb + (_h_hc_eff - _a_hc_eff) / 20.0, # trans41_halfcourt_edge + ]) + except Exception: + row.extend([0.0] * 7) + + # ── 43. CLUTCH PERFORMANCE FEATURES (8 features) ── + # Computed from close games (|margin| <= 5) in rolling records + 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 + # Ortg in clutch games + _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, # clutch43_h_wp + _a_cwp, # clutch43_a_wp + _h_cmg / 10.0, # clutch43_h_margin (normalized) + _a_cmg / 10.0, # clutch43_a_margin + (_h_cortg - 100.0) / 20.0, # clutch43_h_ortg (normalized) + (_a_cortg - 100.0) / 20.0, # clutch43_a_ortg + _h_cwp - _a_cwp, # clutch43_wp_diff + (_h_cmg - _a_cmg) / 10.0, # clutch43_margin_diff + ]) + except Exception: + row.extend([0.0] * 8) + + # ── 44. GAME TOTALS PREDICTION FEATURES (10 features) ── + # Normalized pace/scoring context to signal high-total vs grind-it-out games. + # High-total environments favor offense-heavy win predictions; low-total favors defense. + try: + _league_ppg = 110.0 # baseline league average PPG + _h_ppg10 = self._ppg(hr_, 10) + _a_ppg10 = self._ppg(ar_, 10) + _h_pap10 = self._papg(hr_, 10) + _a_pap10 = self._papg(ar_, 10) + # Normalize each PPG/PAPG to league avg (1.0 = average, >1 = high scoring) + _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_PPG + A_PAP)/2 + (A_PPG + H_PAP)/2 = H scoring pace + A scoring pace + # Normalize to league average total (220.0) + _matchup_total = ((_h_ppg10 + _a_pap10) / 2.0 + (_a_ppg10 + _h_pap10) / 2.0) / 220.0 + # Pace: use poss as proxy (ortg stored per 100 poss) + _h_pace10 = self._pace(hr_, 10) + _a_pace10 = self._pace(ar_, 10) + _avg_pace = (_h_pace10 + _a_pace10) / 2.0 + # Normalize pace to ~97 league avg + _pace_sum_n = _avg_pace / 97.0 + _pace_mismatch = abs(_h_pace10 - _a_pace10) / 10.0 # cap at ~3 sigma + # Offensive + defensive ratings (normalize to 110 = league avg) + _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) # >1 = above-avg offense + _drtg_sum = (_h_drtg10 + _a_drtg10) / (2.0 * 110.0) # >1 = above-avg defense (worse) + # Net scoring environment: positive = high-scoring game expected + _score_env = ((_h_ortg10 + _a_ortg10) - (_h_drtg10 + _a_drtg10)) / 20.0 + row.extend([ + max(0.5, min(2.0, _h_ppg_n)), # tot44_h_ppg10 + max(0.5, min(2.0, _a_ppg_n)), # tot44_a_ppg10 + max(0.5, min(2.0, _h_pap_n)), # tot44_h_papg10 + max(0.5, min(2.0, _a_pap_n)), # tot44_a_papg10 + max(0.7, min(1.5, _matchup_total)), # tot44_matchup_total + max(0.7, min(1.4, _pace_sum_n)), # tot44_pace_sum + min(2.0, _pace_mismatch), # tot44_pace_mismatch + max(0.7, min(1.4, _ortg_sum)), # tot44_ortg_sum + max(0.7, min(1.4, _drtg_sum)), # tot44_drtg_sum + max(-1.5, min(1.5, _score_env)), # tot44_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]) + + # ── Cat 42: Shot Quality Zone Features (10 features) ── + 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) + # xEFG: weighted by league-avg zone FG% + _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, # shot42_h_rim_rate + _a_rim, # shot42_a_rim_rate + _h_mid, # shot42_h_mid_rate + _a_mid, # shot42_a_mid_rate + _h_three, # shot42_h_three_rate + _a_three, # shot42_a_three_rate + _h_xefg, # shot42_h_xefg + _a_xefg, # shot42_a_xefg + _h_rim - _a_rim, # shot42_rim_rate_diff + _h_xefg - _a_xefg, # shot42_xefg_diff + ]) + except Exception: + row.extend([0.30, 0.30, 0.15, 0.15, 0.40, 0.40, 0.51, 0.51, 0.0, 0.0]) + + # ── Cat 45: Player Tracking / Hustle Features (12 features) ── + 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 # normalize (~50/game) + _a_cont = a_td.get('contested_shots', 0.0) / 50.0 + _h_defl = h_td.get('deflections', 0.0) / 15.0 # normalize (~15/game) + _a_defl = a_td.get('deflections', 0.0) / 15.0 + _h_spd = h_td.get('avg_speed', 0.0) / 5.0 # normalize (~4.5 mph) + _a_spd = a_td.get('avg_speed', 0.0) / 5.0 + _h_lb = h_td.get('loose_balls', 0.0) / 8.0 # normalize (~8/game) + _a_lb = a_td.get('loose_balls', 0.0) / 8.0 + _h_drv = h_td.get('drives', 0.0) / 50.0 # normalize (~50/game) + _a_drv = a_td.get('drives', 0.0) / 50.0 + row.extend([ + _h_cont, # track45_h_contested + _a_cont, # track45_a_contested + _h_defl, # track45_h_deflections + _a_defl, # track45_a_deflections + _h_spd, # track45_h_speed + _a_spd, # track45_a_speed + _h_lb, # track45_h_loose_balls + _a_lb, # track45_a_loose_balls + _h_drv, # track45_h_drives + _a_drv, # track45_a_drives + _h_cont - _a_cont, # track45_contested_diff + _h_spd - _a_spd, # track45_speed_diff + ]) + except Exception: + row.extend([0.0] * 12) + + # ── Cat 46: Real Odds Market Features (8 features) ── + 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) + + # Normalize spread: /10 so that a 10-point spread = 1.0 + _sp_norm = (_sp_home / 10.0) if _sp_home is not None else 0.0 + # Normalize total: /220 so that league-average total ~ 1.0 + _total_norm = (_total / 220.0) if _total is not None else 1.0 + + # Spread-implied probability (logistic approximation): + # P(home_win) ≈ 1 / (1 + 10^(spread / 7.5)) + # A 7.5-point spread corresponds to ~90% win probability + if _sp_home is not None: + _sp_implied = 1.0 / (1.0 + 10.0 ** (_sp_home / 7.5)) + else: + _sp_implied = _fp_home # fallback to moneyline-derived + + # Market consistency: difference between spread-implied and ML-implied + _spread_ml_diff = _sp_implied - _fp_home + + row.extend([ + _ip_home, # odds46_implied_home_prob + _ip_away, # odds46_implied_away_prob + _fp_home, # odds46_fair_home_prob + _fp_away, # odds46_fair_away_prob + _sp_norm, # odds46_spread_home (normalized) + _total_norm, # odds46_total (normalized) + _overr, # odds46_overround + _spread_ml_diff, # odds46_spread_implied_diff + ]) + else: + # No odds data for this game — safe defaults + 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]) + + # ── Cat 47: Drive-Offense vs Rim-Defense Matchup (14 features) ── + 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, # drive47_h_fg_pct + _a_drv_fg, # drive47_a_fg_pct + _h_drv_tov, # drive47_h_tov_pct + _a_drv_tov, # drive47_a_tov_pct + _h_drv_pts, # drive47_h_pts_pct + _a_drv_pts, # drive47_a_pts_pct + _h_rim_d, # drive47_h_def_rim_fg + _a_rim_d, # drive47_a_def_rim_fg + _h_blk, # drive47_h_blk_rate + _a_blk, # drive47_a_blk_rate + _h_off_vs_a, # drive47_h_off_vs_a_rim + _a_off_vs_h, # drive47_a_off_vs_h_rim + _h_off_vs_a - _a_off_vs_h, # drive47_rim_matchup_net + _h_drv_n - _a_drv_n, # drive47_drive_volume_diff + ]) + 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]) + + # ── Cat 48: Passing Network Quality (10 features) ── + 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, # pass48_h_ast_rate + _a_atp, # pass48_a_ast_rate + _h_pot, # pass48_h_potential_ast + _a_pot, # pass48_a_potential_ast + _h_apc, # pass48_h_ast_pts_created + _a_apc, # pass48_a_ast_pts_created + _h_sec, # pass48_h_secondary_ast + _a_sec, # pass48_a_secondary_ast + _h_atp - _a_atp, # pass48_ast_rate_diff + _bm_h - _bm_a, # pass48_ball_movement_edge + ]) + except Exception: + row.extend([0.09, 0.09, 0.94, 0.94, 0.85, 0.85, 0.6, 0.6, 0.0, 0.0]) + + # ── Cat 49: Play-Type Efficiency (10 features) ── + 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, # play49_h_iso_ppp + _a_iso, # play49_a_iso_ppp + _h_pnr, # play49_h_pnr_ppp + _a_pnr, # play49_a_pnr_ppp + _h_spot, # play49_h_spot_ppp + _a_spot, # play49_a_spot_ppp + _h_trans, # play49_h_trans_ppp + _a_trans, # play49_a_trans_ppp + _h_avg - _a_avg, # play49_ppp_composite_diff + _h_above - _a_above, # play49_versatility_diff + ]) + except Exception: + row.extend([0.88, 0.88, 0.87, 0.87, 1.04, 1.04, 1.12, 1.12, 0.0, 0.0]) + + # ── Cat 50: Temporal Win-Sequence Encoding (12 features) ── + 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], # seq50_h_early_wp + _hs[1], # seq50_h_late_wp + _hs[2], # seq50_h_slope + _hs[3], # seq50_h_margin_slope_norm + _hs[4], # seq50_h_streak_norm + _as[0], # seq50_a_early_wp + _as[1], # seq50_a_late_wp + _as[2], # seq50_a_slope + _as[3], # seq50_a_margin_slope_norm + _as[4], # seq50_a_streak_norm + _hs[2] - _as[2], # seq50_slope_diff + _hs[4] - _as[4], # seq50_streak_diff + ]) + 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]) + + # 51. SEASON ERA NORMALIZATION + # Normalize rolling efficiency vs league-wide running avg for THIS season. + # Uses stats from all games BEFORE this one (no lookahead). + 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]) + + # Update season trackers for future games (must be after feature extraction) + _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) + + # ── Cat 52: Odds Line Features (15 features) ── + # Raw market signals: spread magnitude, total, vig, season percentiles. + try: + _odds_key = (gd, home, away) + _odds = (odds_data or {}).get(_odds_key, {}) + _sp52 = _odds.get('spread_home', None) # None if no odds for this game + _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 + + # Spread agrees with ML if both point same direction + _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 + + # Spread adjusted for home court (home teams typically get -3.5 to -4 HCA) + _sp_adj = (_sp52 + 3.5) if _sp52 is not None else 0.0 + + # ML-implied spread vs actual spread + # Logistic inversion: spread ≈ -7.5 * log10(p/(1-p)) for fair p + _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) + + # Season-relative percentiles (based on spreads/totals seen so far) + 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) + + # Season spread rolling std (line volatility indicator) + 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 + + # Total trend: recent 10 avg vs season avg + _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 + + # Home favorite strength (one-sided: 0 if dog) + _h_fav_str = ((-_sp52) / 10.0) if (_sp52 is not None and _sp52 < 0) else 0.0 + + row.extend([ + _sp_abs, # line52_spread_magnitude + _tot_v, # line52_total + _ip52h, # line52_implied_home + _ip52a, # line52_implied_away + _agree, # line52_spread_agree + _vig52, # line52_vig + _sp_pct, # line52_spread_season_pct + _tot_pct, # line52_total_season_pct + _home_dog, # line52_home_dog + _sp_adj / 10.0, # line52_spread_adj (normalized) + _ml_sp_gap, # line52_ml_spread_gap + _sharpness, # line52_sharpness + _sp_std, # line52_season_spread_std + _tot_trend, # line52_season_total_trend + _h_fav_str, # line52_home_fav_strength + ]) + 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]) # line52_spread_agree=0.5 (neutral; was 1.0 = biased home-agree) + + # ── Cat 53: ATS Record Features (12 features) ── + # Cover rate derived from _team_ats tracker (populated after each game's features). + 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] # team was favored (spread_home < 0) + if dog_only: + s = [r for r in s if r[2] > 0] # team was dog + 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]] # away games only + 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 + # record format: (gd, covered, spread, is_home, actual_margin_vs_spread) + 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 ATS: last 5 h2h meetings from home team perspective + 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), # ats53_h_last10 + _ats_rate(a_ats, 10), # ats53_a_last10 + _ats_rate(h_ats, 0), # ats53_h_season + _ats_rate(a_ats, 0), # ats53_a_season + max(-5.0, min(5.0, _ats_streak(h_ats))) / 5.0, # ats53_h_streak (normalized) + max(-5.0, min(5.0, _ats_streak(a_ats))) / 5.0, # ats53_a_streak + _ats_rate(h_ats, 0, fav_only=True), # ats53_h_as_fav + _ats_rate(a_ats, 0, dog_only=True), # ats53_a_as_dog + _ats_rate(h2h_h_ats, 5) if h2h_h_ats else 0.5, # ats53_h2h_last5 + _ats_rate(h_ats, 0, home_only=True), # ats53_h_home_only + _ats_rate(a_ats, 0, home_only=False), # ats53_a_away_only (road games) + _margin_vs_spread(h_ats, 10), # ats53_margin_vs_spread_10 + ]) + except Exception: + row.extend([0.5] * 11 + [0.0]) + + # ── Cat 54: Over/Under Record Features (12 features) ── + # Over rate derived from _team_ou tracker (populated after each game's features). + 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 + # record has (gd, went_over, total, is_home, actual_vs_total) + 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 + ) + + # Pace vs total: home team's recent PPG+PAPG vs the line + _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 # normalized + + # Total trend: recent 10 avg vs current line + _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), # ou54_h_over_rate10 + _ou_rate(a_ou, 10), # ou54_a_over_rate10 + _ou_rate(h_ou, 0), # ou54_h_over_season + _ou_rate(a_ou, 0), # ou54_a_over_season + max(-5.0, min(5.0, _ou_streak(h_ou))) / 5.0, # ou54_h_streak (normalized) + max(-5.0, min(5.0, _ou_streak(a_ou))) / 5.0, # ou54_a_streak + combined_over_rate, # ou54_combined_over_rate + max(-3.0, min(3.0, _pace_vs_total)), # ou54_pace_vs_total + _ou_rate(h_ou, 0, home_only=True), # ou54_h_home_over + _ou_rate(a_ou, 0, home_only=False), # ou54_a_away_over + _tot_trend54, # ou54_total_trend + _ou_margin_avg(h_ou, a_ou, 10), # ou54_margin_vs_total_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]) + + # ── Cat 55: Market Consensus Deviation (10 features) ── + # Cross-instrument divergence: spread-fair-prob vs ML-implied-prob. + 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) + + # Fair home win prob from spread via sigmoid (vig-removed) + # spread_home < 0 → home favored → higher prob + 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 + + # Instrument disagreement: ML odds vs spread-implied fair prob + _ml_vs_sp55 = abs(_ip55h - _fair_h55) + + # Books internal disagreement: do ML and spread agree on favorite? + _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 + + # Compound home edge: positive only if BOTH instruments favor home + _edge_h55 = (_ip55h - 0.5) * (_fair_h55 - 0.5) * 4.0 + + # Liquidity proxy: higher totals = more liquid market = more reliable + _liq55 = min(1.0, max(0.0, (_tot55 - 190.0) / 60.0)) + + # Market confidence from each instrument + _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 distortion: how much vig shifts ML prob away from fair prob + _vig_dist55 = abs(_fair_h55 - _ip55h) + + row.extend([ + _fair_h55, # cmd55_fair_h_prob + _fair_a55, # cmd55_fair_a_prob + _ml_vs_sp55, # cmd55_ml_vs_spread_gap + _disagree55, # cmd55_books_internal_disagree + _edge_h55, # cmd55_market_edge_h + _liq55, # cmd55_liquidity_proxy + _ml_conf55, # cmd55_ml_confidence + _sp_conf55, # cmd55_spread_confidence + _consensus55, # cmd55_consensus_strength + _vig_dist55, # cmd55_vig_distortion + ]) + except Exception: + row.extend([0.5, 0.5, 0.0, 0.0, 0.0, 0.5, 0.0, 0.35, 0.175, 0.0]) + + # ── Cat 56: Directional Circadian Advantage (10 features) ── + # Chronobiology Int'l 2022 + ScienceDaily 2024: signed westbound/eastbound effect. + # Westbound home = 63.5% win rate, eastbound = 55.0%; net +8.5pp directional signal. + 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 + + # Signed tz delta: negative = traveled west (favorable), positive = east (adverse) + _h56_signed = _h56_home_tz - _h56_last_tz # arrived home from last location + _a56_signed = _a56_away_tz - _a56_last_tz # arrived at this away venue from last location + + # Sigmoid-encoded directional probability: +8.5pp = 0.085 advantage from research + # Using steepness 0.7 so ±3 tz = ±0.44 shift (matches ~63.5% vs 55% finding) + _h56_west_adv = 1.0 / (1.0 + math.exp(_h56_signed * 0.7)) # neg→>0.5 (favorable) + _a56_east_pen = 1.0 / (1.0 + math.exp(-_a56_signed * 0.7)) # pos→>0.5 (adverse) + + # Double advantage: home went west AND away went east + _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) + + # Phase alignment: body clock "ahead" of local time = favorable + _h56_phase = max(-1.0, min(1.0, -_h56_signed / 3.0)) + _a56_phase = max(-1.0, min(1.0, _a56_signed / 3.0)) + + # Combined directional edge signal + _dir56_edge = max(-1.0, min(1.0, _h56_west_adv - _a56_east_pen + 0.5)) + + # Schedule density: games in last 7 days + _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)), # circ56_h_signed_tz + max(-1.0, min(1.0, _a56_signed / 3.0)), # circ56_a_signed_tz + _h56_west_adv, # circ56_h_westbound_adv + _a56_east_pen, # circ56_a_eastbound_pen + _dir56_mismatch, # circ56_dir_mismatch + _h56_phase, # circ56_h_phase_alignment + _a56_phase, # circ56_a_phase_alignment + _dir56_edge, # circ56_dir_edge + min(1.0, _h56_density / 5.0), # circ56_h_schedule_density + max(-1.0, min(1.0, (_a56_density - _h56_density) / 5.0)), # circ56_density_edge + ]) + except Exception: + row.extend([0.0, 0.0, 0.5, 0.5, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0]) + + # ── Cat 57: Sequential Exponential Form (12 features) ── + # arXiv 2512.08591 (LSTM 8-season): exponentially weighted form outperforms rolling avg. + # α=0.4 preserves recency (last game weight ~2.5× 5th game back). + 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) + + # Hot hand: count consecutive wins from most recent game backwards + _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, # seq57_h_exp_form5 + _a57_exp5, # seq57_a_exp_form5 + _h57_exp10, # seq57_h_exp_form10 + _a57_exp10, # seq57_a_exp_form10 + _h57_var, # seq57_h_form_var + _a57_var, # seq57_a_form_var + _h57_hot, # seq57_h_hot_hand + _a57_hot, # seq57_a_hot_hand + _h57_mom, # seq57_h_momentum + _a57_mom, # seq57_a_momentum + _h57_exp5 - _a57_exp5, # seq57_form5_diff + _h57_mom - _a57_mom, # seq57_momentum_diff + ]) + 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]) + + # ── Cat 58: Model Confidence & Calibration Anchors (10 features) ── + # arXiv 2303.06021: Brier-calibrated model selection beats accuracy by 5-7% ROI. + # Signals WHEN the model is likely well-calibrated vs high-uncertainty scenarios. + 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) + + # Spread-based confidence: large spread = market conviction + _sp58_conf = min(1.0, abs(float(_sp58) / 10.0)) if _sp58 is not None else 0.35 + # Total confidence: higher total = more scoring variance = harder to predict + _tot58_conf = max(0.3, 1.0 - (_tot58 - 190.0) / 200.0) + + # B2B uncertainty: fatigued teams are harder to predict + _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 + + # H2H historical consistency: how dominant is one team over the other + _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) + + # Home reliability vs population mean (NBA long-run home win ≈ 60%) + _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) + + # Market alignment: more confident market = more reliable predictions + _market58_align = 1.0 - abs(_ip58h - 0.5) * 0.5 + + # Full historical records + _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, # cal58_spread_confidence + max(0.0, min(1.0, _tot58_conf)), # cal58_total_confidence + _b2b58_unc, # cal58_b2b_uncertainty + _h2h58_conf, # cal58_h2h_consistency + _h58_reliability, # cal58_h_home_reliability + _market58_align, # cal58_model_market_alignment + _h58_home_wp, # cal58_h_home_wp_hist + _a58_road_wp, # cal58_a_road_wp_hist + _sp58_conf * (1.0 - _b2b58_unc), # cal58_compound_confidence + min(1.0, _h2h58_conf * _sp58_conf), # cal58_matchup_mkt_compound + ]) + except Exception: + row.extend([0.35, 0.8, 0.0, 0.0, 0.0, 0.75, 0.6, 0.4, 0.35, 0.0]) + + # ── Cat 59: Opponent Graph Features (12 features) ── + # arXiv 2303.16741 (GATv2-TCN): opponent scheduling is a latent performance signal. + # "Who you play affects how you play" — 2nd-order SOS, transitive wins, common-opp advantage. + try: + _h59_rec = hr_[-10:] + _a59_rec = ar_[-10:] + + # 2nd-order SOS: average quality of opponents' own recent opponents + 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) + + # Common opponent advantage: home's margin vs shared recent opponents + _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) + + # Beat quality: win% specifically against above-.500 opponents + _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) + + # SOS trend: last 3 games vs the 3 before that + _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 + + # Schedule asymmetry: which team faced harder opponents recently + _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 + + # Opponent form: were recent opponents hot or cold going into that game? + 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) + + # Transitive graph advantage: home beat teams that also beat away's recent nemeses + # home_beat ∩ away_beaten_by → home has proven strength over away's weak spots + _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, # opp59_h_opp_sos5 + _a59_sos2, # opp59_a_opp_sos5 + _common59_adv, # opp59_common_opp_h_adv + _common59_count, # opp59_common_opp_count + _h59_beat_q, # opp59_h_beat_quality + _a59_beat_q, # opp59_a_beat_quality + _h59_sos_trend, # opp59_h_sos_trend + _a59_sos_trend, # opp59_a_sos_trend + _59_sched_asym, # opp59_sched_asymmetry + _h59_opp_form, # opp59_h_opp_form + _a59_opp_form, # opp59_a_opp_form + _transitive59, # opp59_graph_transitive + ]) + 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]) + + # ── Cat 60: Score Margin Distribution (12 features) ── + # Margin shape reveals true quality: skewness, close-loss fragility, blowout dominance. + 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, # mdist60_h_margin_mean + _a60_mean, # mdist60_a_margin_mean + _h60_skew, # mdist60_h_margin_skew + _a60_skew, # mdist60_a_margin_skew + _h60_cl, # mdist60_h_close_loss_rate + _a60_cl, # mdist60_a_close_loss_rate + _h60_bw, # mdist60_h_blowout_win_rate + _a60_bw, # mdist60_a_blowout_win_rate + _h60_iqr, # mdist60_h_margin_iqr + _a60_iqr, # mdist60_a_margin_iqr + _h60_skew - _a60_skew, # mdist60_margin_shape_diff + (_h60_bw - _h60_cl) - (_a60_bw - _a60_cl), # mdist60_quality_gap + ]) + 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]) + + # ── Cat 61: Pace-Adjusted Net Rating (10 features) ── + # Possessions ≈ FGA + 0.44*FTA - ORB + TOV; net rating = (pts/poss)*100 + 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 + + # Build enriched records: (date, opp, own_score, opp_score, own_stats, opp_stats) + _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, # pace61_h_off_rtg + _a61_off, # pace61_a_off_rtg + _h61_def, # pace61_h_def_rtg + _a61_def, # pace61_a_def_rtg + _h61_net, # pace61_h_net_rtg + _a61_net, # pace61_a_net_rtg + _h61_net - _a61_net, # pace61_net_rtg_diff + _h61_pace, # pace61_h_pace + _a61_pace, # pace61_a_pace + abs(_h61_pace - _a61_pace), # pace61_pace_mismatch + ]) + except Exception: + row.extend([0.5, 0.5, 0.5, 0.5, 0.0, 0.0, 0.0, 0.5, 0.5, 0.0]) + + # ── Cat 62: Clutch Performance — close-game DNA (10 features) ── + 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, # clutch62_h_close_winpct + _a62_cwr, # clutch62_a_close_winpct + _h62_ce, # clutch62_h_clutch_edge + _a62_ce, # clutch62_a_clutch_edge + _h62_cf, # clutch62_h_close_freq + _a62_cf, # clutch62_a_close_freq + _h62_cwr - _a62_cwr, # clutch62_close_wr_diff + _h62_ce - _a62_ce, # clutch62_clutch_edge_diff + _h62_cm, # clutch62_h_close_margin_avg + _a62_cm, # clutch62_a_close_margin_avg + ]) + except Exception: + row.extend([0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + + # ── Cat 63: Pythagorean Luck (12 features) ── + 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, # pyth63_h_pyth_wp5 + _a63_pw5, # pyth63_a_pyth_wp5 + _h63_pw10, # pyth63_h_pyth_wp10 + _a63_pw10, # pyth63_a_pyth_wp10 + _h63_l5, # pyth63_h_luck5 + _a63_l5, # pyth63_a_luck5 + _h63_l10, # pyth63_h_luck10 + _a63_l10, # pyth63_a_luck10 + _h63_pw10 - _a63_pw10, # pyth63_pyth_wp_diff + _h63_l10 - _a63_l10, # pyth63_luck_diff + abs(_h63_l10), # pyth63_h_regression_signal + abs(_a63_l10), # pyth63_a_regression_signal + ]) + 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]) + + # ── Cat 64: Opponent-Elo-Weighted Performance (10 features) ── + 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, # elow64_h_wp10 + _a64_wp10, # elow64_a_wp10 + _h64_m10, # elow64_h_margin10 + _a64_m10, # elow64_a_margin10 + _h64_nr10, # elow64_h_netrtg10 + _a64_nr10, # elow64_a_netrtg10 + _h64_wp10 - _a64_wp10, # elow64_wp_diff + _h64_m10 - _a64_m10, # elow64_margin_diff + _h64_m5 - _h64_m10, # elow64_h_trend + _a64_m5 - _a64_m10, # elow64_a_trend + ]) + except Exception: + row.extend([0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + + # ── Cat 65: Style Matchup Advantage (12 features) ── + 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, # style65_h_efg_vs_opp_def + _a65_efg_edge, # style65_a_efg_vs_opp_def + _h65_tov_edge, # style65_h_tov_vs_opp_force + _a65_tov_edge, # style65_a_tov_vs_opp_force + _h65_orb_edge, # style65_h_orb_vs_opp_drb + _a65_orb_edge, # style65_a_orb_vs_opp_drb + _h65_ftr_edge, # style65_h_ftr_vs_opp_ftr + _a65_ftr_edge, # style65_a_ftr_vs_opp_ftr + abs(_h65_comp - _a65_comp), # style65_matchup_asymmetry + _h65_comp, # style65_h_composite_edge + _a65_comp, # style65_a_composite_edge + _h65_comp - _a65_comp, # style65_net_style_edge + ]) + except Exception: + row.extend([0.0] * 12) + + X.append(row) + y.append(1 if hs > as_ else 0) + + # ── Update ATS / O-U trackers for future games ── + # Must be AFTER feature extraction (no lookahead) + 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_ # positive = home won by this many + + if _sp_now is not None: + # Covered = actual_margin > -spread_home (home perspective) + # spread_home = -7 means home favored by 7; cover if margin > 7 + h_covered = actual_margin > (-_sp_now) + a_covered = (-actual_margin) > _sp_now # away team perspective + h_mvs = actual_margin - (-_sp_now) # margin vs spread + 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 # Tracker update failures must not crash the build loop + + # Record this game + 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) + # Update multi-ELO systems (Cat 24) + 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) + # Update MOVDA ELO (Cat 37) + 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) + + # Verify dimensions + expected = len(self.feature_names) + if X.shape[1] != expected: + print(f"WARNING: Expected {expected} features, got {X.shape[1]}") + # Pad or truncate + 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 + + # ── Helper methods ── + + 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] + # If last game was home, team is at own arena + 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: + # Distance from previous game + 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 # Scale + 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 # Start at home + 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 + # Get top/bottom N teams by win% + 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 # Needs spread data + + def _ou_record(self, records, n): + return 0.5 # Needs total data + + 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 # Needs halftime data + + 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): + # Approximate: current elo minus recent changes + 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_ + + # Parse stats from game data + 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 + + # Update Elo + 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_ + + # Parse stats for ORtg/DRtg + 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 # Home court advantage in ELO terms + + # ── Margin-adjusted ELO (MOV capped, multiplier) ── + K_m = 20 + mov_mult = min(abs(margin), 20) / 10.0 # Cap at 20pt margin + 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)) + + # ── Offensive ELO (based on scoring performance) ── + K_o = 15 + # Home offense result: fraction of total points scored by home + 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)) + + # ── Defensive ELO (lower opponent scoring = better) ── + K_d = 15 + # Home defense result: inverse — fewer points allowed = better + def_result = as_ / max(hs + as_, 1) # away scored / total — lower is better for home D + def_result = 1.0 - def_result # Flip: home defense success + 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)) + + # ── Recency-weighted ELO (higher K for faster adaptation) ── + 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)) + + # ── ELO history (for trend/momentum tracking) ── + team_elo_history[home].append(team_elo_margin[home]) + team_elo_history[away].append(team_elo_margin[away]) + + # ── Home court advantage tracking ── + 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] + # Append raw delta_MOV to rolling history (home team's perspective) + 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 = {} + + # Detect real box score data (backfilled via backfill-boxscores.py) + has_boxscore = "fga" in stats and stats["fga"] is not None and stats["fga"] > 0 + + if has_boxscore: + # ── REAL DATA PATH ── + 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) + # Real possessions: standard formula + poss = max(fga + 0.44 * fta + tov - oreb, 60) + # Real efficiency + ortg = pts * 100 / poss + drtg = opp_pts * 100 / poss + pace = poss + else: + # ── ESTIMATE PATH (backward compat for old seasons) ── + 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, + } + + # Four Factors (REAL when boxscore available) + 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) + + # Opponent Four Factors (estimated — opponent box score not in same dict) + 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"]) + + # Shooting profile + 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) + + # Rates + 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)) + + # Opponent shooting + 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, # Southeast + "BOS": 0, "BKN": 0, "NYK": 0, "PHI": 0, "TOR": 0, # Atlantic + "CHI": 1, "CLE": 1, "DET": 1, "IND": 1, "MIL": 1, # Central + "DAL": 3, "HOU": 3, "MEM": 3, "NOP": 3, "SAS": 3, # Southwest + "DEN": 4, "MIN": 4, "OKC": 4, "POR": 4, "UTA": 4, # Northwest + "GSW": 5, "LAC": 5, "LAL": 5, "PHX": 5, "SAC": 5, # Pacific + } + 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 + + +# ── Genetic Feature Selection ── + +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) # Fewer splits for speed + 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 # Penalty for too few or too many + 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] + + # Initialize population — biased toward target_features count + 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): + # Evaluate fitness + scores = [fitness(c) for c in population] + + # Track best + 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})") + + # Selection (tournament) + new_pop = [best_chromosome[:]] # Elitism: keep best + while len(new_pop) < population_size: + # Tournament selection + 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 + + # Extract selected features + 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}")