LBJLincoln Claude Opus 4.6 commited on
Commit
4a39063
Β·
1 Parent(s): 8330e9d

feat: Supabase run logger + auto-cut + 2058 features (25 categories)

Browse files

- RunLogger: logs every gen/cycle/eval to Supabase PostgreSQL (4 tables)
- 6 auto-cut rules: regression, stagnation, ROI, diversity, features, brier floor
- Integrated into app.py (S10) and genetic_loop_v3.py
- Feature engine expanded: 580 β†’ 2058 features across 25 categories
- New categories: interactions, advanced rolling, season trajectory, lineup,
game theory, environmental, cross-window momentum, market II, power ratings, fatigue
- S10 API endpoints: /api/run-stats, /api/cuts, /api/brier-trend, /api/recent-runs
- Deploy script for S10 HF Space

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

app.py CHANGED
@@ -47,6 +47,15 @@ import gradio as gr
47
  from fastapi import FastAPI, Request
48
  from fastapi.responses import JSONResponse
49
 
 
 
 
 
 
 
 
 
 
50
  # ── Paths (use /data for HF Space persistent storage) ──
51
  _persistent = Path("/data")
52
  DATA_DIR = _persistent if _persistent.exists() else Path("data")
@@ -674,6 +683,17 @@ def evolution_loop():
674
  log(f"Pop: {POP_SIZE} | Target features: {TARGET_FEATURES} | Gens/cycle: {GENS_PER_CYCLE}")
675
  log("=" * 60)
676
 
 
 
 
 
 
 
 
 
 
 
 
677
  live["status"] = "LOADING DATA"
678
  pull_seasons()
679
  games = load_all_games()
@@ -819,6 +839,59 @@ def evolution_loop():
819
  f"Sharpe={best.fitness['sharpe']:.2f} Feat={best.n_features} "
820
  f"Model={best.hyperparams['model_type']} Stag={stagnation} ({ge:.0f}s)")
821
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
  # Next generation
823
  new_pop = []
824
  for i in range(ELITE_SIZE):
@@ -891,6 +964,28 @@ def evolution_loop():
891
 
892
  live["top5"] = results["top5"]
893
  log(f"Results saved: evolution-{ts}.json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
894
  except Exception as e:
895
  log(f"Save error: {e}", "ERROR")
896
 
@@ -1131,6 +1226,62 @@ async def api_remote_log():
1131
  })
1132
 
1133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1134
  with gr.Blocks(title="NOMOS NBA QUANT β€” Genetic Evolution", theme=gr.themes.Monochrome()) as app:
1135
  gr.Markdown("# NOMOS NBA QUANT AI β€” Real Genetic Evolution 24/7")
1136
  gr.Markdown("*Population of 60 individuals evolving feature selection + hyperparameters. Multi-objective: Brier + ROI + Sharpe + Calibration.*")
 
47
  from fastapi import FastAPI, Request
48
  from fastapi.responses import JSONResponse
49
 
50
+ # ── Run Logger (Supabase logging + auto-cut) ──
51
+ try:
52
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
53
+ from evolution.run_logger import RunLogger
54
+ _HAS_LOGGER = True
55
+ except ImportError:
56
+ _HAS_LOGGER = False
57
+ print("[WARN] run_logger not available β€” logging disabled")
58
+
59
  # ── Paths (use /data for HF Space persistent storage) ──
60
  _persistent = Path("/data")
61
  DATA_DIR = _persistent if _persistent.exists() else Path("data")
 
683
  log(f"Pop: {POP_SIZE} | Target features: {TARGET_FEATURES} | Gens/cycle: {GENS_PER_CYCLE}")
684
  log("=" * 60)
685
 
686
+ # ── Supabase Run Logger + Auto-Cut ──
687
+ global _global_logger
688
+ run_logger = None
689
+ if _HAS_LOGGER:
690
+ try:
691
+ run_logger = RunLogger(local_dir=str(DATA_DIR / "run-logs"))
692
+ _global_logger = run_logger
693
+ log("[RUN-LOGGER] Initialized β€” Supabase logging + auto-cut ACTIVE")
694
+ except Exception as e:
695
+ log(f"[RUN-LOGGER] Init failed: {e} β€” continuing without", "WARN")
696
+
697
  live["status"] = "LOADING DATA"
698
  pull_seasons()
699
  games = load_all_games()
 
839
  f"Sharpe={best.fitness['sharpe']:.2f} Feat={best.n_features} "
840
  f"Model={best.hyperparams['model_type']} Stag={stagnation} ({ge:.0f}s)")
841
 
842
+ # ── Supabase: log generation ──
843
+ if run_logger:
844
+ try:
845
+ pop_diversity = np.std([ind.fitness.get("composite", 0) for ind in population])
846
+ avg_comp = np.mean([ind.fitness.get("composite", 0) for ind in population])
847
+ run_logger.log_generation(
848
+ cycle=cycle, generation=generation,
849
+ best={"brier": best.fitness["brier"], "roi": best.fitness["roi"],
850
+ "sharpe": best.fitness["sharpe"], "composite": best.fitness["composite"],
851
+ "n_features": best.n_features, "model_type": best.hyperparams["model_type"]},
852
+ mutation_rate=mutation_rate, avg_composite=float(avg_comp),
853
+ pop_diversity=float(pop_diversity), duration_s=ge)
854
+
855
+ # Log top 10 evals
856
+ top10 = [{"brier": ind.fitness["brier"], "roi": ind.fitness["roi"],
857
+ "sharpe": ind.fitness["sharpe"], "composite": ind.fitness["composite"],
858
+ "n_features": ind.n_features, "model_type": ind.hyperparams["model_type"]}
859
+ for ind in population[:10]]
860
+ run_logger.log_top_evals(generation, top10)
861
+
862
+ # ── Auto-Cut check ──
863
+ engine_state = {
864
+ "mutation_rate": mutation_rate, "stagnation": stagnation,
865
+ "pop_size": len(population), "pop_diversity": float(pop_diversity),
866
+ }
867
+ cut_actions = run_logger.check_auto_cut(best.fitness, engine_state)
868
+ for action in cut_actions:
869
+ atype = action["type"]
870
+ params = action.get("params", {})
871
+ if atype == "config":
872
+ remote_config["pending_params"].update(params)
873
+ log(f"[AUTO-CUT] Config queued: {params}")
874
+ elif atype == "emergency_diversify":
875
+ remote_config["commands"].append("diversify")
876
+ if "pop_size" in params:
877
+ remote_config["pending_params"]["pop_size"] = params["pop_size"]
878
+ if "mutation_rate" in params:
879
+ remote_config["pending_params"]["mutation_rate"] = params["mutation_rate"]
880
+ log(f"[AUTO-CUT] Emergency diversify: {params}")
881
+ elif atype == "inject":
882
+ count = params.get("count", 10)
883
+ remote_config["commands"].append("diversify")
884
+ log(f"[AUTO-CUT] Injecting {count} random individuals")
885
+ elif atype == "full_reset":
886
+ remote_config["pending_reset"] = True
887
+ remote_config["pending_params"].update(params)
888
+ log(f"[AUTO-CUT] FULL RESET triggered: {params}")
889
+ elif atype == "flag":
890
+ live["pause_betting"] = params.get("pause_betting", False)
891
+ log(f"[AUTO-CUT] Flag set: {params}")
892
+ except Exception as e:
893
+ log(f"[RUN-LOGGER] Gen log error: {e}", "WARN")
894
+
895
  # Next generation
896
  new_pop = []
897
  for i in range(ELITE_SIZE):
 
964
 
965
  live["top5"] = results["top5"]
966
  log(f"Results saved: evolution-{ts}.json")
967
+
968
+ # ── Supabase: log cycle ──
969
+ if run_logger:
970
+ try:
971
+ pop_diversity = np.std([ind.fitness.get("composite", 0) for ind in population])
972
+ avg_comp = np.mean([ind.fitness.get("composite", 0) for ind in population])
973
+ sel_features = [feature_names[i] for i in best_ever.selected_indices() if i < len(feature_names)] if best_ever else []
974
+ run_logger.log_cycle(
975
+ cycle=cycle, generation=generation,
976
+ best={"brier": best_ever.fitness["brier"], "roi": best_ever.fitness["roi"],
977
+ "sharpe": best_ever.fitness["sharpe"], "composite": best_ever.fitness["composite"],
978
+ "calibration": best_ever.fitness.get("calibration", 0),
979
+ "n_features": best_ever.n_features, "model_type": best_ever.hyperparams["model_type"]},
980
+ pop_size=len(population), mutation_rate=mutation_rate,
981
+ crossover_rate=CROSSOVER_RATE, stagnation=stagnation,
982
+ games=len(games), feature_candidates=n_feat,
983
+ cycle_duration_s=time.time() - cycle_start,
984
+ avg_composite=float(avg_comp), pop_diversity=float(pop_diversity),
985
+ top5=results["top5"], selected_features=sel_features[:50])
986
+ log("[RUN-LOGGER] Cycle logged to Supabase")
987
+ except Exception as e:
988
+ log(f"[RUN-LOGGER] Cycle log error: {e}", "WARN")
989
  except Exception as e:
990
  log(f"Save error: {e}", "ERROR")
991
 
 
1226
  })
1227
 
1228
 
1229
+ # ═══════════════════════════════════════════════════════
1230
+ # RUN LOGGER API β€” Supabase monitoring endpoints
1231
+ # ═══════════════════════════════════════════════════════
1232
+
1233
+ # Global logger ref (set from evolution_loop thread)
1234
+ _global_logger = None
1235
+
1236
+
1237
+ @control_api.get("/api/run-stats")
1238
+ async def api_run_stats():
1239
+ """Evolution run statistics from Supabase."""
1240
+ if not _global_logger:
1241
+ return JSONResponse({"error": "logger not initialized"}, status_code=503)
1242
+ try:
1243
+ stats = _global_logger.get_stats()
1244
+ return JSONResponse(stats)
1245
+ except Exception as e:
1246
+ return JSONResponse({"error": str(e)}, status_code=500)
1247
+
1248
+
1249
+ @control_api.get("/api/cuts")
1250
+ async def api_cuts():
1251
+ """Recent auto-cut events."""
1252
+ if not _global_logger:
1253
+ return JSONResponse({"error": "logger not initialized"}, status_code=503)
1254
+ try:
1255
+ cuts = _global_logger.get_recent_cuts(20)
1256
+ return JSONResponse({"cuts": [list(c) for c in cuts] if cuts else []})
1257
+ except Exception as e:
1258
+ return JSONResponse({"error": str(e)}, status_code=500)
1259
+
1260
+
1261
+ @control_api.get("/api/brier-trend")
1262
+ async def api_brier_trend():
1263
+ """Brier score trend (last 50 generations)."""
1264
+ if not _global_logger:
1265
+ return JSONResponse({"error": "logger not initialized"}, status_code=503)
1266
+ try:
1267
+ trend = _global_logger.get_brier_trend(50)
1268
+ return JSONResponse({"trend": trend})
1269
+ except Exception as e:
1270
+ return JSONResponse({"error": str(e)}, status_code=500)
1271
+
1272
+
1273
+ @control_api.get("/api/recent-runs")
1274
+ async def api_recent_runs():
1275
+ """Recent cycle summaries from Supabase."""
1276
+ if not _global_logger:
1277
+ return JSONResponse({"error": "logger not initialized"}, status_code=503)
1278
+ try:
1279
+ runs = _global_logger.get_recent_runs(20)
1280
+ return JSONResponse({"runs": [list(r) for r in runs] if runs else []})
1281
+ except Exception as e:
1282
+ return JSONResponse({"error": str(e)}, status_code=500)
1283
+
1284
+
1285
  with gr.Blocks(title="NOMOS NBA QUANT β€” Genetic Evolution", theme=gr.themes.Monochrome()) as app:
1286
  gr.Markdown("# NOMOS NBA QUANT AI β€” Real Genetic Evolution 24/7")
1287
  gr.Markdown("*Population of 60 individuals evolving feature selection + hyperparameters. Multi-objective: Brier + ROI + Sharpe + Calibration.*")
deploy.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Deploy NBA Quant AI to lbjlincoln/nomos-nba-quant HF Space.
3
+
4
+ Uploads all files from hf-space/ dir, configures secrets, restarts.
5
+
6
+ Usage:
7
+ source .env.local
8
+ python3 hf-space/deploy.py
9
+ """
10
+
11
+ import os, sys
12
+ from pathlib import Path
13
+ from huggingface_hub import HfApi, CommitOperationAdd
14
+
15
+ SPACE_ID = "lbjlincoln/nomos-nba-quant"
16
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HF_TOKEN_2")
17
+ LOCAL_DIR = Path(__file__).parent
18
+
19
+ SECRETS = {
20
+ "DATABASE_URL": os.environ.get("DATABASE_URL", ""),
21
+ "SUPABASE_URL": os.environ.get("SUPABASE_URL", ""),
22
+ "SUPABASE_API_KEY": os.environ.get("SUPABASE_API_KEY", ""),
23
+ "ODDS_API_KEY": os.environ.get("ODDS_API_KEY", ""),
24
+ "VM_CALLBACK_URL": os.environ.get("VM_CALLBACK_URL", "http://34.136.180.66:8080"),
25
+ }
26
+
27
+
28
+ def main():
29
+ if not HF_TOKEN:
30
+ print("ERROR: HF_TOKEN not set. Run: source .env.local")
31
+ sys.exit(1)
32
+
33
+ api = HfApi(token=HF_TOKEN)
34
+ print(f"Deploying NBA Quant AI to {SPACE_ID}...")
35
+
36
+ operations = []
37
+ skip = {"__pycache__", ".pyc", "node_modules", ".git", "deploy.py"}
38
+
39
+ for fp in LOCAL_DIR.rglob("*"):
40
+ if fp.is_dir():
41
+ continue
42
+ if any(s in str(fp) for s in skip):
43
+ continue
44
+ rel = fp.relative_to(LOCAL_DIR)
45
+ print(f" + {rel}")
46
+ operations.append(CommitOperationAdd(path_in_repo=str(rel), path_or_fileobj=str(fp)))
47
+
48
+ if not operations:
49
+ print("ERROR: No files found")
50
+ sys.exit(1)
51
+
52
+ print(f"\nUploading {len(operations)} files...")
53
+ try:
54
+ api.create_commit(
55
+ repo_id=SPACE_ID, repo_type="space", operations=operations,
56
+ commit_message="Deploy: RunLogger + auto-cut + 2058 features (25 categories)",
57
+ )
58
+ print("Upload OK!")
59
+ except Exception as e:
60
+ if "404" in str(e) or "not found" in str(e).lower():
61
+ print(f"Space not found, creating {SPACE_ID}...")
62
+ api.create_repo(repo_id=SPACE_ID, repo_type="space", space_sdk="docker",
63
+ space_hardware="cpu-basic", private=False)
64
+ api.create_commit(
65
+ repo_id=SPACE_ID, repo_type="space", operations=operations,
66
+ commit_message="Deploy: RunLogger + auto-cut + 2058 features (25 categories)",
67
+ )
68
+ else:
69
+ raise
70
+
71
+ print("\nConfiguring secrets...")
72
+ for key, value in SECRETS.items():
73
+ if value:
74
+ try:
75
+ api.add_space_secret(SPACE_ID, key, value)
76
+ print(f" Set {key}")
77
+ except Exception as e:
78
+ print(f" WARN: {key}: {e}")
79
+ else:
80
+ print(f" SKIP {key} (empty)")
81
+
82
+ print("\nRestarting Space...")
83
+ try:
84
+ api.restart_space(SPACE_ID)
85
+ except Exception as e:
86
+ print(f" Restart: {e}")
87
+
88
+ print(f"\nDone! Space: https://lbjlincoln-nomos-nba-quant.hf.space")
89
+ print(f"Monitor: https://huggingface.co/spaces/{SPACE_ID}")
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
evolution/__init__.py ADDED
File without changes
evolution/run_logger.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Run Logger & Auto-Cut β€” Hedge Fund Grade Evolution Monitoring
4
+ ================================================================
5
+ Logs EVERY generation, cycle, and eval to Supabase.
6
+ Auto-cuts evolution when regression detected or stagnation exceeds threshold.
7
+
8
+ Tables (auto-created):
9
+ - nba_evolution_runs : one row per cycle (summary)
10
+ - nba_evolution_gens : one row per generation (detailed)
11
+ - nba_evolution_evals : one row per individual evaluation
12
+ - nba_evolution_cuts : log of auto-cut events
13
+
14
+ Auto-Cut Rules:
15
+ 1. REGRESSION CUT: If best Brier increases by > 0.005 for 3 consecutive gens β†’ rollback
16
+ 2. STAGNATION CUT: If no improvement for 20 gens β†’ emergency diversify + log
17
+ 3. ROI CUT: If ROI drops below -15% β†’ pause betting, continue evolving
18
+ 4. DIVERSITY CUT: If population diversity < 0.05 β†’ inject fresh individuals
19
+ 5. FEATURE CUT: If selected features < 40 β†’ expand target_features
20
+
21
+ Designed for real-time monitoring via Supabase dashboard or Telegram.
22
+ """
23
+
24
+ import os
25
+ import json
26
+ import time
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Dict, List, Optional
30
+
31
+ # ── Supabase connection ──
32
+ _SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
33
+ _DATABASE_URL = os.environ.get("DATABASE_URL", "")
34
+ _SUPABASE_KEY = os.environ.get("SUPABASE_API_KEY", os.environ.get("SUPABASE_ANON_KEY", ""))
35
+
36
+ _pg_pool = None
37
+
38
+ def _get_pg():
39
+ """Lazy PostgreSQL connection pool (Supabase = PostgreSQL)."""
40
+ global _pg_pool
41
+ if _pg_pool is not None:
42
+ return _pg_pool
43
+ db_url = _DATABASE_URL
44
+ if not db_url:
45
+ return None
46
+ try:
47
+ import psycopg2
48
+ from psycopg2 import pool as pg_pool
49
+ _pg_pool = pg_pool.SimpleConnectionPool(1, 3, db_url)
50
+ return _pg_pool
51
+ except Exception as e:
52
+ print(f"[RUN-LOGGER] PostgreSQL connection failed: {e}")
53
+ return None
54
+
55
+
56
+ def _exec_sql(sql, params=None):
57
+ """Execute SQL on Supabase PostgreSQL. Best-effort, never crashes."""
58
+ pool = _get_pg()
59
+ if not pool:
60
+ return None
61
+ conn = None
62
+ try:
63
+ conn = pool.getconn()
64
+ with conn.cursor() as cur:
65
+ cur.execute(sql, params)
66
+ conn.commit()
67
+ try:
68
+ return cur.fetchall()
69
+ except Exception:
70
+ return []
71
+ except Exception as e:
72
+ if conn:
73
+ conn.rollback()
74
+ print(f"[RUN-LOGGER] SQL error: {e}")
75
+ return None
76
+ finally:
77
+ if conn and pool:
78
+ pool.putconn(conn)
79
+
80
+
81
+ def _ensure_tables():
82
+ """Create logging tables if they don't exist."""
83
+ sqls = [
84
+ """CREATE TABLE IF NOT EXISTS nba_evolution_runs (
85
+ id SERIAL PRIMARY KEY,
86
+ ts TIMESTAMPTZ DEFAULT NOW(),
87
+ cycle INT,
88
+ generation INT,
89
+ best_brier FLOAT,
90
+ best_roi FLOAT,
91
+ best_sharpe FLOAT,
92
+ best_calibration FLOAT,
93
+ best_composite FLOAT,
94
+ best_features INT,
95
+ best_model_type TEXT,
96
+ pop_size INT,
97
+ mutation_rate FLOAT,
98
+ crossover_rate FLOAT,
99
+ stagnation INT,
100
+ games INT,
101
+ feature_candidates INT,
102
+ cycle_duration_s FLOAT,
103
+ avg_composite FLOAT,
104
+ pop_diversity FLOAT,
105
+ top5 JSONB,
106
+ selected_features JSONB
107
+ )""",
108
+ """CREATE TABLE IF NOT EXISTS nba_evolution_gens (
109
+ id SERIAL PRIMARY KEY,
110
+ ts TIMESTAMPTZ DEFAULT NOW(),
111
+ cycle INT,
112
+ generation INT,
113
+ best_brier FLOAT,
114
+ best_roi FLOAT,
115
+ best_sharpe FLOAT,
116
+ best_composite FLOAT,
117
+ n_features INT,
118
+ model_type TEXT,
119
+ mutation_rate FLOAT,
120
+ avg_composite FLOAT,
121
+ pop_diversity FLOAT,
122
+ gen_duration_s FLOAT,
123
+ improved BOOLEAN DEFAULT FALSE
124
+ )""",
125
+ """CREATE TABLE IF NOT EXISTS nba_evolution_cuts (
126
+ id SERIAL PRIMARY KEY,
127
+ ts TIMESTAMPTZ DEFAULT NOW(),
128
+ cut_type TEXT,
129
+ reason TEXT,
130
+ brier_before FLOAT,
131
+ brier_after FLOAT,
132
+ action_taken TEXT,
133
+ params_applied JSONB
134
+ )""",
135
+ """CREATE TABLE IF NOT EXISTS nba_evolution_evals (
136
+ id SERIAL PRIMARY KEY,
137
+ ts TIMESTAMPTZ DEFAULT NOW(),
138
+ generation INT,
139
+ individual_rank INT,
140
+ brier FLOAT,
141
+ roi FLOAT,
142
+ sharpe FLOAT,
143
+ composite FLOAT,
144
+ n_features INT,
145
+ model_type TEXT
146
+ )""",
147
+ ]
148
+ for sql in sqls:
149
+ _exec_sql(sql)
150
+
151
+
152
+ # ── Auto-initialize tables on import ──
153
+ _tables_ready = False
154
+
155
+
156
+ class RunLogger:
157
+ """Logs evolution runs to Supabase + local files. Never crashes the main loop."""
158
+
159
+ def __init__(self, local_dir=None):
160
+ global _tables_ready
161
+ self.local_dir = Path(local_dir or "/data/run-logs")
162
+ self.local_dir.mkdir(parents=True, exist_ok=True)
163
+
164
+ # Auto-cut state
165
+ self.brier_history = [] # last N best Brier values
166
+ self.regression_count = 0 # consecutive regressions
167
+ self.stagnation_count = 0
168
+ self.last_best_brier = 1.0
169
+ self.last_best_composite = 0.0
170
+ self.cuts_applied = 0
171
+
172
+ # Ensure Supabase tables exist
173
+ if not _tables_ready:
174
+ _ensure_tables()
175
+ _tables_ready = True
176
+ print("[RUN-LOGGER] Supabase tables ready")
177
+
178
+ # ══════════════════════════════════════════
179
+ # LOG β€” Record events
180
+ # ══════════════════════════════════════════
181
+
182
+ def log_generation(self, cycle, generation, best, mutation_rate, avg_composite, pop_diversity, duration_s):
183
+ """Log one generation result."""
184
+ improved = best["brier"] < self.last_best_brier - 0.0001
185
+
186
+ # Supabase
187
+ _exec_sql("""INSERT INTO nba_evolution_gens
188
+ (cycle, generation, best_brier, best_roi, best_sharpe, best_composite,
189
+ n_features, model_type, mutation_rate, avg_composite, pop_diversity,
190
+ gen_duration_s, improved)
191
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
192
+ (cycle, generation, best["brier"], best["roi"], best["sharpe"],
193
+ best["composite"], best.get("n_features", 0), best.get("model_type", "?"),
194
+ mutation_rate, avg_composite, pop_diversity, duration_s, improved))
195
+
196
+ # Track for auto-cut
197
+ self.brier_history.append(best["brier"])
198
+ if len(self.brier_history) > 50:
199
+ self.brier_history = self.brier_history[-50:]
200
+
201
+ return improved
202
+
203
+ def log_cycle(self, cycle, generation, best, pop_size, mutation_rate, crossover_rate,
204
+ stagnation, games, feature_candidates, cycle_duration_s,
205
+ avg_composite, pop_diversity, top5=None, selected_features=None):
206
+ """Log one full cycle (multiple generations) result."""
207
+ _exec_sql("""INSERT INTO nba_evolution_runs
208
+ (cycle, generation, best_brier, best_roi, best_sharpe, best_calibration,
209
+ best_composite, best_features, best_model_type, pop_size, mutation_rate,
210
+ crossover_rate, stagnation, games, feature_candidates, cycle_duration_s,
211
+ avg_composite, pop_diversity, top5, selected_features)
212
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
213
+ (cycle, generation, best["brier"], best["roi"], best["sharpe"],
214
+ best.get("calibration", 0), best["composite"], best.get("n_features", 0),
215
+ best.get("model_type", "?"), pop_size, mutation_rate, crossover_rate,
216
+ stagnation, games, feature_candidates, cycle_duration_s,
217
+ avg_composite, pop_diversity,
218
+ json.dumps(top5 or [], default=str),
219
+ json.dumps(selected_features or [], default=str)))
220
+
221
+ # Local file backup
222
+ entry = {
223
+ "ts": datetime.now(timezone.utc).isoformat(),
224
+ "cycle": cycle, "generation": generation,
225
+ "best": best, "pop_size": pop_size,
226
+ "mutation_rate": mutation_rate, "stagnation": stagnation,
227
+ }
228
+ log_file = self.local_dir / f"cycle-{cycle:04d}.json"
229
+ log_file.write_text(json.dumps(entry, indent=2, default=str))
230
+
231
+ def log_top_evals(self, generation, top_individuals):
232
+ """Log top 10 individuals for this generation."""
233
+ for rank, ind in enumerate(top_individuals[:10]):
234
+ _exec_sql("""INSERT INTO nba_evolution_evals
235
+ (generation, individual_rank, brier, roi, sharpe, composite, n_features, model_type)
236
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
237
+ (generation, rank + 1,
238
+ ind.get("brier", ind.get("fitness", {}).get("brier", 0)),
239
+ ind.get("roi", ind.get("fitness", {}).get("roi", 0)),
240
+ ind.get("sharpe", ind.get("fitness", {}).get("sharpe", 0)),
241
+ ind.get("composite", ind.get("fitness", {}).get("composite", 0)),
242
+ ind.get("n_features", 0),
243
+ ind.get("model_type", ind.get("hyperparams", {}).get("model_type", "?"))))
244
+
245
+ def log_cut(self, cut_type, reason, brier_before, brier_after, action, params=None):
246
+ """Log an auto-cut event."""
247
+ _exec_sql("""INSERT INTO nba_evolution_cuts
248
+ (cut_type, reason, brier_before, brier_after, action_taken, params_applied)
249
+ VALUES (%s,%s,%s,%s,%s,%s)""",
250
+ (cut_type, reason, brier_before, brier_after, action,
251
+ json.dumps(params or {}, default=str)))
252
+ self.cuts_applied += 1
253
+ print(f"[AUTO-CUT] {cut_type}: {reason} β†’ {action}")
254
+
255
+ # ══════════════════════════════════════════
256
+ # AUTO-CUT β€” Automatic regression/stagnation handling
257
+ # ══════════════════════════════════════════
258
+
259
+ def check_auto_cut(self, current_best, engine_state):
260
+ """
261
+ Check if an auto-cut should be applied.
262
+ Returns: list of actions to take, or empty list.
263
+
264
+ Actions are dicts: {"type": "...", "params": {...}}
265
+ The caller (evolution loop) is responsible for executing them.
266
+ """
267
+ actions = []
268
+ brier = current_best.get("brier", 1.0)
269
+ composite = current_best.get("composite", 0)
270
+
271
+ # ── RULE 1: REGRESSION CUT ──
272
+ # If Brier is getting worse for 3+ consecutive generations
273
+ if len(self.brier_history) >= 3:
274
+ last3 = self.brier_history[-3:]
275
+ if all(last3[i] > last3[i-1] + 0.0003 for i in range(1, len(last3))):
276
+ self.regression_count += 1
277
+ if self.regression_count >= 2:
278
+ self.log_cut("REGRESSION", f"Brier increasing 3+ gens: {[f'{b:.4f}' for b in last3]}",
279
+ last3[0], last3[-1], "rollback_mutation",
280
+ {"mutation_rate": max(0.03, engine_state.get("mutation_rate", 0.1) * 0.5)})
281
+ actions.append({
282
+ "type": "config",
283
+ "params": {"mutation_rate": max(0.03, engine_state.get("mutation_rate", 0.1) * 0.5)},
284
+ })
285
+ self.regression_count = 0
286
+ else:
287
+ self.regression_count = 0
288
+
289
+ # ── RULE 2: STAGNATION CUT ──
290
+ stagnation = engine_state.get("stagnation", 0)
291
+ if stagnation >= 20:
292
+ self.log_cut("STAGNATION", f"No improvement for {stagnation} generations",
293
+ brier, brier, "emergency_diversify",
294
+ {"pop_size": 200, "mutation_rate": 0.20, "target_features": 300})
295
+ actions.append({
296
+ "type": "emergency_diversify",
297
+ "params": {"pop_size": 200, "mutation_rate": 0.20, "target_features": 300},
298
+ })
299
+
300
+ # ── RULE 3: ROI CUT ──
301
+ roi = current_best.get("roi", 0)
302
+ if roi < -0.15:
303
+ self.log_cut("ROI_THRESHOLD", f"ROI dropped to {roi:.1%} β€” betting paused",
304
+ brier, brier, "pause_betting")
305
+ # Don't stop evolution, just flag for betting logic
306
+ actions.append({"type": "flag", "params": {"pause_betting": True}})
307
+
308
+ # ── RULE 4: DIVERSITY CUT ──
309
+ diversity = engine_state.get("pop_diversity", 0)
310
+ if diversity < 3.0 and engine_state.get("pop_size", 0) > 20:
311
+ self.log_cut("DIVERSITY", f"Population diversity {diversity:.1f} too low",
312
+ brier, brier, "inject_random",
313
+ {"inject_count": max(10, engine_state.get("pop_size", 50) // 4)})
314
+ actions.append({
315
+ "type": "inject",
316
+ "params": {"count": max(10, engine_state.get("pop_size", 50) // 4)},
317
+ })
318
+
319
+ # ── RULE 5: FEATURE CUT ──
320
+ n_features = current_best.get("n_features", 0)
321
+ if 0 < n_features < 40:
322
+ self.log_cut("LOW_FEATURES", f"Only {n_features} features selected",
323
+ brier, brier, "expand_target",
324
+ {"target_features": 200})
325
+ actions.append({
326
+ "type": "config",
327
+ "params": {"target_features": 200},
328
+ })
329
+
330
+ # ── RULE 6: BRIER FLOOR ──
331
+ # If Brier is stuck above 0.24 for 30+ gens, force aggressive exploration
332
+ if len(self.brier_history) >= 30:
333
+ if all(b > 0.24 for b in self.brier_history[-30:]):
334
+ self.log_cut("BRIER_FLOOR", "Brier stuck above 0.24 for 30+ gens",
335
+ brier, brier, "full_reset",
336
+ {"mutation_rate": 0.25, "pop_size": 250, "target_features": 400})
337
+ actions.append({
338
+ "type": "full_reset",
339
+ "params": {"mutation_rate": 0.25, "pop_size": 250, "target_features": 400},
340
+ })
341
+
342
+ # Update tracking
343
+ if brier < self.last_best_brier:
344
+ self.last_best_brier = brier
345
+ self.last_best_composite = composite
346
+
347
+ return actions
348
+
349
+ # ══════════════════════════════════════════
350
+ # QUERY β€” Read logged data
351
+ # ══════════════════════════════════════════
352
+
353
+ def get_recent_runs(self, limit=20):
354
+ """Get recent cycle logs from Supabase."""
355
+ rows = _exec_sql(
356
+ "SELECT * FROM nba_evolution_runs ORDER BY ts DESC LIMIT %s", (limit,))
357
+ return rows or []
358
+
359
+ def get_recent_cuts(self, limit=10):
360
+ rows = _exec_sql(
361
+ "SELECT * FROM nba_evolution_cuts ORDER BY ts DESC LIMIT %s", (limit,))
362
+ return rows or []
363
+
364
+ def get_brier_trend(self, last_n=50):
365
+ rows = _exec_sql(
366
+ "SELECT generation, best_brier FROM nba_evolution_gens ORDER BY ts DESC LIMIT %s",
367
+ (last_n,))
368
+ if rows:
369
+ return [(r[0], r[1]) for r in reversed(rows)]
370
+ return self.brier_history[-last_n:]
371
+
372
+ def get_stats(self):
373
+ """Summary stats for dashboard."""
374
+ total_gens = _exec_sql("SELECT COUNT(*) FROM nba_evolution_gens")
375
+ total_runs = _exec_sql("SELECT COUNT(*) FROM nba_evolution_runs")
376
+ total_cuts = _exec_sql("SELECT COUNT(*) FROM nba_evolution_cuts")
377
+ best_ever = _exec_sql(
378
+ "SELECT MIN(best_brier) FROM nba_evolution_runs")
379
+
380
+ return {
381
+ "total_generations": total_gens[0][0] if total_gens else 0,
382
+ "total_cycles": total_runs[0][0] if total_runs else 0,
383
+ "total_cuts": total_cuts[0][0] if total_cuts else 0,
384
+ "best_brier_ever": best_ever[0][0] if best_ever and best_ever[0][0] else None,
385
+ "local_cuts_applied": self.cuts_applied,
386
+ "regression_count": self.regression_count,
387
+ "brier_history_len": len(self.brier_history),
388
+ }
features/__init__.py ADDED
File without changes
features/engine.py ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -6,3 +6,4 @@ nba_api>=1.4
6
  gradio>=5.0
7
  uvicorn>=0.30
8
  catboost>=1.2
 
 
6
  gradio>=5.0
7
  uvicorn>=0.30
8
  catboost>=1.2
9
+ psycopg2-binary>=2.9