"""S21 SOTA hooks — Darwinian weights driven by rolling 30-day PnL. Inspiration: atlas-gic (research_cycle7_sota_gap.md). Instead of weighting ensemble members by validation log-loss, we track a rolling 30-day PnL per base estimator and recompute weights every 10 generations with exponential decay alpha=0.9. Usage: tracker = DarwinianWeights(n_models=3, window_days=30, alpha=0.9, reweight_every=10) tracker.record_pnl(model_idx=0, date="2026-04-15", pnl=12.3) ... if tracker.should_reweight(current_gen): weights = tracker.compute_weights() # -> np.ndarray shape (n_models,) final_prob = np.dot(weights, [p0, p1, p2]) """ from __future__ import annotations from collections import deque from dataclasses import dataclass, field from datetime import datetime from typing import Deque, Dict, List import numpy as np @dataclass class DarwinianWeights: n_models: int window_days: int = 30 alpha: float = 0.9 reweight_every: int = 10 _history: Dict[int, Deque] = field(default_factory=dict) _cached_weights: np.ndarray = field(default=None) _last_reweight_gen: int = -1 def __post_init__(self): self._history = {i: deque() for i in range(self.n_models)} self._cached_weights = np.ones(self.n_models) / self.n_models def record_pnl(self, model_idx: int, date: str, pnl: float) -> None: """Record a single-day PnL contribution for a base model.""" if model_idx < 0 or model_idx >= self.n_models: return try: d = datetime.fromisoformat(date[:10]) except Exception: d = datetime.utcnow() hist = self._history[model_idx] hist.append((d, float(pnl))) # trim to window cutoff = datetime.utcnow().toordinal() - self.window_days while hist and hist[0][0].toordinal() < cutoff: hist.popleft() def should_reweight(self, current_gen: int) -> bool: return (current_gen - self._last_reweight_gen) >= self.reweight_every def compute_weights(self) -> np.ndarray: """Compute softmax weights across models based on exponentially decayed rolling PnL. Returns a normalized weight vector.""" scores = np.zeros(self.n_models) for i in range(self.n_models): hist = list(self._history[i]) if not hist: scores[i] = 0.0 continue # Sort ascending by date, apply exponential decay from most recent. hist.sort(key=lambda t: t[0]) # Walk newest -> oldest with decay weighted = 0.0 w = 1.0 for d, pnl in reversed(hist): weighted += w * pnl w *= self.alpha scores[i] = weighted # Softmax with temperature to keep weights bounded if np.all(scores == 0): w = np.ones(self.n_models) / self.n_models else: temp = max(1e-3, float(np.std(scores))) z = scores / temp z -= z.max() exp_z = np.exp(z) w = exp_z / exp_z.sum() self._cached_weights = w return w def get_weights(self, current_gen: int = 0) -> np.ndarray: """Return cached weights; recompute if reweight interval hit.""" if self.should_reweight(current_gen): self._last_reweight_gen = current_gen return self.compute_weights() return self._cached_weights def snapshot(self) -> dict: return { "n_models": self.n_models, "window_days": self.window_days, "alpha": self.alpha, "reweight_every": self.reweight_every, "last_reweight_gen": self._last_reweight_gen, "weights": self._cached_weights.tolist(), "history_counts": {i: len(self._history[i]) for i in range(self.n_models)}, }