| """Ensemble blending + calibration nudges for the FutureQuery forecaster. |
| |
| This module is deliberately dependency-light (numpy only) so the blending |
| rules can be unit-tested without loading TimesFM / Chronos. ``main.py`` runs |
| the two models and hands their raw forecast arrays to :func:`full_response`; |
| the short-series / no-history guards live in :func:`short_series_response`. |
| |
| Rules (per the Nov-2025 research notes baked into the task): |
| |
| * < 10 points -> use_forecast = False, warning set. |
| * question_type == "event" -> ensemble = 0.5*TimesFM + 0.5*Chronos-2 |
| (equal weight: the models showed negative R^2 on binary event markets, so |
| we do not let either dominate). |
| * question_type == "numeric" -> Chronos-2 0.6 / TimesFM 0.4 (Chronos-2 |
| beats TimesFM on fev-bench for numeric trajectories). |
| * base-rate prior -> if the latest price is within 0.1 of 0.5, |
| pull the ensemble 15% back toward 0.5 (regression to the mean). |
| * extremise (events only) -> if confidence > 0.70, sharpen with the |
| standard superforecasting transform 0.5 + 1.3*(p - 0.5). |
| """ |
|
|
| from __future__ import annotations |
|
|
| from typing import Dict, List, Optional, Sequence |
|
|
| import numpy as np |
|
|
| |
| MIN_POINTS = 10 |
| EVENT_WEIGHTS = (0.5, 0.5) |
| NUMERIC_WEIGHTS = (0.4, 0.6) |
| BASE_RATE_BAND = 0.1 |
| BASE_RATE_PULL = 0.15 |
| EXTREMISE_GATE = 0.70 |
| EXTREMISE_K = 1.3 |
| TREND_EPS = 0.02 |
|
|
|
|
| def _clamp01(arr: np.ndarray) -> np.ndarray: |
| return np.clip(arr, 0.0, 1.0) |
|
|
|
|
| def _trend(forecast: np.ndarray, reference: float, numeric: bool) -> str: |
| """up / down / flat relative to the last observed value.""" |
| if forecast.size == 0: |
| return "flat" |
| delta = float(forecast[-1]) - float(reference) |
| eps = max(TREND_EPS * abs(reference), 1e-9) if numeric else TREND_EPS |
| if delta > eps: |
| return "up" |
| if delta < -eps: |
| return "down" |
| return "flat" |
|
|
|
|
| def _persistence(prices: np.ndarray, horizon: int, event: bool) -> np.ndarray: |
| """Flat continuation used when we cannot really forecast.""" |
| if prices.size: |
| return np.full(int(horizon), float(prices[-1]), dtype=float) |
| return np.full(int(horizon), 0.5 if event else 0.0, dtype=float) |
|
|
|
|
| def _block(forecast: np.ndarray, reference: float, numeric: bool) -> Dict: |
| return { |
| "forecast": [round(float(x), 4) for x in forecast], |
| "trend": _trend(forecast, reference, numeric), |
| } |
|
|
|
|
| def blend( |
| prices: Sequence[float], |
| timesfm_fc: Sequence[float], |
| chronos_fc: Sequence[float], |
| question_type: str, |
| horizon: int, |
| ) -> Dict: |
| """Blend two model forecasts into the ensemble (the heart of Phase 1).""" |
| price_arr = np.asarray(prices, dtype=float) |
| last = float(price_arr[-1]) if price_arr.size else 0.5 |
| event = question_type == "event" |
| numeric = not event |
|
|
| tf = np.asarray(timesfm_fc, dtype=float) |
| ch = np.asarray(chronos_fc, dtype=float) |
| n = min(tf.size, ch.size, int(horizon)) |
| if n == 0: |
| n = int(horizon) |
| tf = tf[:n] |
| ch = ch[:n] |
|
|
| if event: |
| tf = _clamp01(tf) |
| ch = _clamp01(ch) |
| w_tf, w_ch = EVENT_WEIGHTS |
| else: |
| w_tf, w_ch = NUMERIC_WEIGHTS |
|
|
| ensemble = w_tf * tf + w_ch * ch |
|
|
| if event: |
| |
| if abs(last - 0.5) <= BASE_RATE_BAND: |
| ensemble = ensemble + BASE_RATE_PULL * (0.5 - ensemble) |
| |
| headline = float(np.mean(ensemble)) |
| confidence = max(headline, 1.0 - headline) |
| if confidence > EXTREMISE_GATE: |
| ensemble = 0.5 + EXTREMISE_K * (ensemble - 0.5) |
| ensemble = _clamp01(ensemble) |
|
|
| brier = _brier_estimate(ensemble) if event else None |
|
|
| return { |
| "timesfm": _block(tf, last, numeric), |
| "chronos2": _block(ch, last, numeric), |
| "ensemble": { |
| **_block(ensemble, last, numeric), |
| "brier_estimate": brier, |
| }, |
| } |
|
|
|
|
| def _brier_estimate(ensemble: np.ndarray) -> Optional[float]: |
| """Expected Brier score if the outcome were Bernoulli(p): p*(1-p). |
| |
| This is a self-consistency proxy reported at forecast time (we don't yet |
| know the resolution). It peaks at 0.25 for p=0.5 and shrinks as the |
| forecast gets confident. The realised Brier score is tracked separately in |
| calibration.py once markets resolve. |
| """ |
| if ensemble.size == 0: |
| return None |
| p = float(np.clip(ensemble[-1], 0.0, 1.0)) |
| return round(p * (1.0 - p), 2) |
|
|
|
|
| def short_series_response( |
| prices: Sequence[float], |
| horizon: int, |
| question_type: str, |
| warning: str, |
| ) -> Dict: |
| """Schema-valid response when we refuse to forecast (too short / no data).""" |
| event = question_type == "event" |
| price_arr = np.asarray(prices, dtype=float) |
| flat = _persistence(price_arr, horizon, event) |
| last = float(price_arr[-1]) if price_arr.size else (0.5 if event else 0.0) |
| block = _block(flat, last, not event) |
| return { |
| "timesfm": dict(block), |
| "chronos2": dict(block), |
| "ensemble": {**block, "brier_estimate": None}, |
| "use_forecast": False, |
| "warning": warning, |
| } |
|
|
|
|
| def full_response( |
| prices: Sequence[float], |
| timesfm_fc: Sequence[float], |
| chronos_fc: Sequence[float], |
| question_type: str, |
| horizon: int, |
| ) -> Dict: |
| payload = blend(prices, timesfm_fc, chronos_fc, question_type, horizon) |
| payload["use_forecast"] = True |
| payload["warning"] = None |
| return payload |
|
|
|
|
| def headline_probability(payload: Dict) -> Optional[float]: |
| """Representative probability of an ensemble payload (last horizon point).""" |
| forecast: List[float] = payload.get("ensemble", {}).get("forecast", []) |
| if not forecast: |
| return None |
| return float(np.clip(forecast[-1], 0.0, 1.0)) |
|
|