# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Couches d'ensemble v2 — signaux non-sondages à poids explicites et bornés. 1. **Ajustement médias** : le sentiment de presse des 14 derniers jours (RSS + veille Firecrawl) déplace le forecast d'au plus ±`media_nudge_pp_max` points par parti (défaut 0,35 pp). Un signal bruité obtient un poids minuscule mais non nul — c'est un nudge, jamais un moteur. 2. **Ensemble marchés prédictifs** : la probabilité « plus de sièges » publiée est une moyenne pondérée modèle/marché (défaut 88 % / 12 %). Le marché agrège de l'information hors-sondages (initiés, campagnes locales, anticipations) et sa combinaison avec un modèle statistique améliore presque toujours le Brier (littérature sur l'agrégation de prévisions). Chaque couche est débrayable par configuration et sa contribution exacte est publiée dans la décomposition du forecast. """ from __future__ import annotations from datetime import datetime, timedelta, timezone import numpy as np from sqlalchemy.orm import Session from ..config import settings from .. import models as Mo from .compositions import alr, close, inv_alr def media_nudge(db: Session) -> dict: """Sentiment 14 j (pondéré par volume, saturé) → délta borné par parti (pp). delta_pp = cap × tanh(sentiment_moyen × 2) × min(1, volume / 60) Volume < `media_nudge_min_volume` → aucun effet (signal trop mince).""" out = {"enabled": settings.media_nudge_enabled, "delta_pp": {}, "volume_14j": {}, "sentiment_14j": {}} if not settings.media_nudge_enabled: return out cutoff = datetime.now(timezone.utc) - timedelta(days=14) cap = settings.media_nudge_pp_max for party in [p for p in settings.parties if p != "AUT"]: rows = (db.query(Mo.SentimentScore).join(Mo.SentimentDocument) .filter(Mo.SentimentScore.entity == party, Mo.SentimentDocument.fetched_at >= cutoff).all()) vol = len(rows) out["volume_14j"][party] = vol if vol < settings.media_nudge_min_volume: out["delta_pp"][party] = 0.0 out["sentiment_14j"][party] = None continue mean_s = float(np.mean([r.sentiment for r in rows])) out["sentiment_14j"][party] = round(mean_s, 3) out["delta_pp"][party] = round( cap * float(np.tanh(mean_s * 2.0)) * min(1.0, vol / 60.0), 3) return out def apply_nudge(x: np.ndarray, delta_pp: dict[str, float]) -> np.ndarray: """Applique le délta (pp de parts) au vecteur d'état alr, à somme conservée.""" if not any(delta_pp.values()): return x p0 = inv_alr(x) p1 = p0.copy() for i, party in enumerate(settings.parties): p1[i] += delta_pp.get(party, 0.0) / 100.0 return alr(close(np.clip(p1, 0.001, None))) def market_blend(model_probs: dict[str, float], market_probs: dict[str, float] | None) -> dict: """P(plus de sièges) finale = (1−w)·modèle + w·marché, renormalisée. Sans marché disponible : probabilités du modèle telles quelles.""" w = settings.market_blend_weight if not market_probs or w <= 0: return {"weight_market": 0.0, "blended": dict(model_probs), "market": market_probs or {}, "model": dict(model_probs)} blended = {} for p, mv in model_probs.items(): blended[p] = (1 - w) * mv + w * market_probs.get(p, mv) tot = sum(blended.values()) if tot > 0: blended = {p: round(v / tot, 4) for p, v in blended.items()} return {"weight_market": w, "blended": blended, "market": {p: round(v, 4) for p, v in market_probs.items()}, "model": {p: round(v, 4) for p, v in model_probs.items()}}