SPB Git

spb/qc-election

Public

v2.0.0 « Au-delà des sondages » — fondamentaux, partielles, médias, marchés, veille Firecrawl

- Prior de fondamentaux : satisfaction gouvernementale + vote précédent + usure,
  régression ridge 1998-2022 (LOO), blend à poids décroissant w(t) vers le scrutin
- Votes réels des 4 partielles 2023-2025 injectés dans le filtre de Kalman
  (swing local rétréci ×0,6, n équiv. 350, erreur excédentaire ±3 pp)
- Ajustement médias borné ±0,35 pp (sentiment 14 j, tanh + seuil de volume)
- Ensemble 88/12 modèle/Polymarket sur P(plus de sièges), publié partout
- Veille continue Firecrawl : radar de nouveaux sondages, extraction de la
  satisfaction (Léger 40 % trouvée live), presse élargie au-delà des RSS
- Décomposition du forecast couche par couche à chaque run (API /api/beyond)
- Nouvelle page /signaux + carte « D'où vient le forecast » sur l'accueil
- Backtest 2022 comparatif (mae v2 vs sondages seuls)
- Tables byelections / web_signals / indicators, endpoints admin (watch,
  indicator, byelection), 8 nouveaux tests (22/22 verts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 30, 2026) parent 84a6c5d Browse files

25 changed files +2,968 −1,358

modified .env.example +10 −0
@@ -5,6 +5,16 @@ ADMIN_TOKEN=changez-moi
5 5 # Clé du gateway LLM MacLustr (optionnel — scoreur de stance LLM)
6 6 # MACLUSTR_API_KEY=
7 7
8 +# Clé Firecrawl (v2 — veille web continue : radar sondages, satisfaction, presse)
9 +# FIRECRAWL_API_KEY=
10 +
11 +# Couches v2 « au-delà des sondages » (débrayables)
12 +# QCE_FUNDAMENTALS_ENABLED=true
13 +# QCE_BYELECTIONS_ENABLED=true
14 +# QCE_MEDIA_NUDGE_ENABLED=true
15 +# QCE_MARKET_BLEND_WEIGHT=0.12
16 +# QCE_GOV_SATISFACTION_FALLBACK=35.0
17 +
8 18 # Surcharges du modèle (préfixe QCE_)
9 19 # QCE_PORT=8140
10 20 # QCE_N_SIMULATIONS=25000
modified METHODOLOGY.md +65 −14
@@ -5,8 +5,8 @@
5 5
6 6 # Méthodologie — QC Élection Forecast
7 7
8 −Version du modèle : **1.0.0** · Élection cible : générale québécoise du 5 octobre 2026
9 −(127 circonscriptions, majorité à 64).
8 +Version du modèle : **2.0.0 « Au-delà des sondages »** · Élection cible : générale
9 +québécoise du 5 octobre 2026 (127 circonscriptions, majorité à 64).
10 10
11 11 Ce document est la référence auditable du système. La version grand public est servie sur
12 12 `/methodologie`; les snapshots de chaque exécution sont conservés en base
@@ -18,8 +18,11 @@ Ce document est la référence auditable du système. La version grand public es
18 18 |---|---|---|
19 19 | DGEQ (données ouvertes officielles) | GeoJSON carte 2026 (127 circ.), liste officielle, candidatures (drapeau « député sortant »), résultats en direct le soir du vote | référence géographique, candidats, incumbency |
20 20 | Wikipédia (agrégation, liens vers chaque maison) | sondages 2022→2026 (85+), sondages campagne 2022 (106), résultats 2022 par circonscription, variations 2018→2022 | série de sondages, baselines locales, backtest |
21 −| Polymarket (API Gamma publique) | probabilités implicites « plus de sièges » | **signal auxiliaire affiché, hors modèle** |
22 −| Presse québécoise (RSS publics) | Radio-Canada, Le Devoir, La Presse | **sentiment auxiliaire, hors modèle** |
21 +| **Élections partielles (DGEQ)** | 4 partielles 2023-2025 : votes réels comptés | **couche 2 — observations nationales du filtre (v2)** |
22 +| **Baromètres de satisfaction (Léger, via veille web)** | % satisfaits envers le gouvernement | **couche 3 — entrée du prior de fondamentaux (v2)** |
23 +| Presse québécoise (RSS + veille Firecrawl) | Radio-Canada, Le Devoir, La Presse + presse élargie | **couche 4 — ajustement borné ±0,35 pp (v2)** |
24 +| Polymarket (API Gamma publique) | probabilités implicites « plus de sièges » | **couche 5 — ensemble 88/12 sur P(plus de sièges) (v2)** |
25 +| Veille web continue (Firecrawl v2 : search + scrape) | radar de nouveaux sondages, satisfaction, articles hors RSS | détection + provenance (pages brutes archivées) |
23 26
24 27 Chaque enregistrement conserve : source, URL, date d'accès, valeur brute, valeur normalisée.
25 28 Les HTML bruts sont archivés dans `backend/data/raw/`.
@@ -63,6 +66,45 @@ avant l'ajustement du modèle. Recalculé à chaque cycle (`pollster_ratings`).
63 66 `q × jours_restants × 1,6` (volatilité de campagne) **plus l'erreur systémique de
64 67 sondage** `σ_industrie = 0,12` (voir §7).
65 68
69 +## 5 bis. Au-delà des sondages (v2) — les couches complémentaires
70 +
71 +Le forecast v2 combine **cinq couches d'inférence**, chacune à poids explicite, borné,
72 +débrayable par configuration et décomposé publiquement (`/api/beyond`, page `/signaux`) :
73 +
74 +1. **Sondages** (couche de base) : le modèle d'état des §4-5, inchangé.
75 +2. **Votes réels — partielles** : chaque élection partielle depuis 2022
76 + (Saint-Henri–Sainte-Anne 2023, Jean-Talon 2023, Terrebonne 2025, Arthabaska 2025)
77 + devient une observation nationale du filtre de Kalman à sa date :
78 + `alr_implicite = alr(national 2022) + 0,6 × [alr(partielle) − alr(local 2022)]`.
79 + Le rétrécissement ×0,6 reflète l'exagération connue du swing des partielles;
80 + la variance est celle d'un sondage de n≈350 plus ±3 pp d'erreur excédentaire
81 + (participation faible, dynamique locale). Ce sont les seuls bulletins comptés
82 + avant le scrutin — et le modèle les voit.
83 +3. **Prior de fondamentaux** : régression ridge « vote du sortant ~ vote précédent +
84 + satisfaction envers le gouvernement », pénalité d'usure de 1,5 pp/mandat au-delà du
85 + premier, ajustée sur les 8 générales de 1998 à 2022 (RMSE leave-one-out publié).
86 + Les partis d'opposition reçoivent un prior de persistance (0,65 × vote précédent +
87 + 0,35 × moyenne 2008-2022, renormalisé). Le prior (diffus, sd alr ≥ 0,40) est combiné
88 + au posterior des sondages par pondération de précision avec
89 + `w(t) = min(0,8; (jours restants/540)^1,3)` → il compte à 18 mois, presque plus la
90 + veille du vote. La satisfaction est mise à jour en continu par la veille web
91 + (repli configuré sinon), avec provenance.
92 +4. **Ajustement médias** : sentiment de presse des 14 derniers jours (RSS + veille) →
93 + `delta = ±0,35 pp × tanh(2·sentiment) × min(1, volume/60)`, nul sous 12 mentions.
94 + Un nudge, jamais un moteur.
95 +5. **Ensemble marchés** : la probabilité « plus de sièges » publiée est
96 + `0,88 × modèle + 0,12 × Polymarket` (renormalisée). Les intentions de vote ne sont
97 + jamais modifiées par le marché.
98 +
99 +**Veille web continue (Firecrawl)** à chaque cycle de 6 h : (a) radar de nouveaux
100 +sondages dans la presse — alerte avec provenance, aucune ingestion automatique de
101 +chiffres; (b) extraction du % de satisfaits (baromètres Léger); (c) presse élargie
102 +au-delà des RSS. Toutes les pages brutes sont archivées (`data/raw/firecrawl/`).
103 +
104 +**Décomposition** : à chaque run, le forecast est reconstruit couche par couche
105 +(sondages seuls → + partielles → + fondamentaux → + médias) avec 8 000 simulations par
106 +variante; les Δ de vote et de probabilité de chaque couche sont publiés.
107 +
66 108 ## 6. Modèle de circonscription
67 109
68 110 `part(circ, parti) = softmax( alr(référence 2022 locale) + swing_national_alr
@@ -104,16 +146,25 @@ sièges), P(majorité ≥64), P(gouvernement minoritaire), catégorie par circon
104 146 Calibration des probabilités locales (tous horizons confondus) : suit la diagonale
105 147 (ex. bac 70–80 % → observé 84 %; bac 90–100 % → observé 98 %). L'ajout de la couche
106 148 systémique a fait passer la couverture J−1 de 20 % à 100 % sans dégrader le Brier.
107 −Aucune correction maison n'est appliquée en backtest (pas de données antérieures à 2018) —
108 −il mesure le modèle de tendance + simulation seuls.
109 −
110 −## 9. Signaux auxiliaires (poids nul dans le forecast)
111 −
112 −- **Sentiment médiatique** : RSS → extraction d'entités → stance (pro/anti/neutre/ambigu)
113 − avec négation et attaques dirigées; scoreur LLM optionnel (MacLustr) sinon lexique;
114 − regroupement des nouvelles par grappes; anomalies de volume (z ≥ 2,5) → événements.
115 −- **Marchés prédictifs** : probabilités implicites Polymarket affichées face au modèle.
116 −- **Momentum** : Δ vote modèle 14 j + sentiment 7 j, borné [−100, 100].
149 +Aucune correction maison n'est appliquée en backtest (pas de données antérieures à 2018).
150 +
151 +**Backtest v2** : le blend de fondamentaux est rejoué avec l'information de 2022
152 +(sortant CAQ, 1er mandat, satisfaction ~54 %, vote 2018); le rapport publie côte à côte
153 +`mae_pp` (v2) et `mae_pp_sondages_seuls`. Les partielles et l'ajustement médias ne sont
154 +pas rejouables pour 2022 (données indisponibles) — ils sont exclus du backtest et bornés
155 +par construction.
156 +
157 +## 9. Signaux et leur poids (v2)
158 +
159 +- **Sentiment médiatique** : RSS + veille Firecrawl → extraction d'entités → stance
160 + (pro/anti/neutre/ambigu) avec négation et attaques dirigées; scoreur LLM optionnel
161 + (MacLustr) sinon lexique; regroupement par grappes; anomalies de volume (z ≥ 2,5)
162 + → événements. Poids : ajustement borné ±0,35 pp (§5 bis-4).
163 +- **Marchés prédictifs** : probabilités implicites Polymarket affichées face au modèle
164 + ET intégrées à 12 % dans la P(plus de sièges) publiée (§5 bis-5).
165 +- **Momentum** : Δ vote modèle 14 j + sentiment 7 j, borné [−100, 100] — purement auxiliaire.
166 +- **Radar de sondages (Firecrawl)** : détections listées sur `/signaux` avec provenance;
167 + l'ajout d'un sondage au modèle reste une opération de collecte/validation normale.
117 168
118 169 ## 10. Limites connues
119 170
modified README.md +13 −0
@@ -10,6 +10,15 @@ Plateforme de **prévision probabiliste** de l'élection générale québécoise
10 10 statistique complet — modèle d'état latent bayésien, effets maison, simulations Monte Carlo à
11 11 erreurs corrélées, backtesting et calibration.
12 12
13 +**v2.0.0 « Au-delà des sondages »** : le forecast combine cinq couches d'inférence à poids
14 +explicites et bornés — sondages (Kalman), **votes réels des élections partielles** injectés
15 +dans le filtre, **prior de fondamentaux** (satisfaction gouvernementale, vote précédent,
16 +usure du pouvoir — régression 1998-2022, poids décroissant vers le scrutin), **ajustement
17 +médias** borné (±0,35 pp) et **ensemble 88/12 avec les marchés prédictifs** (Polymarket) sur
18 +P(plus de sièges). Une **veille web continue (Firecrawl)** alimente le tout : radar des
19 +nouveaux sondages, satisfaction Léger, presse élargie. Décomposition publique de chaque
20 +couche sur `/signaux` et `/api/beyond`.
21 +
13 22 **Production : https://www.qc-election.com** (nœud M2U64, PM2 + ngrok)
14 23
15 24 ## Architecture
@@ -24,7 +33,11 @@ backend/
24 33 ingest/
25 34 wikipedia.py sondages 2026 + 2022, résultats 2022 par circonscription, carte 2025
26 35 news_rss.py sentiment médiatique (RSS) : entités → stance → anomalies de volume
36 + byelections.py partielles 2022-2026 : votes réels → observations nationales (v2)
37 + firecrawl_watch.py veille web continue : radar sondages, satisfaction, presse (v2)
27 38 modeling/
39 + fundamentals.py prior de fondamentaux + blend à poids décroissant (v2)
40 + ensemble.py ajustement médias borné + ensemble modèle/marchés (v2)
28 41 compositions.py transformations log-ratio (alr) + covariance multinomiale
29 42 weights.py pondération : récence, taille effective, design effect, cote maison
30 43 house_effects.py biais maison rétrécis + cote de qualité dynamique
modified backend/app/api/admin.py +47 −0
@@ -113,6 +113,53 @@ def rerun_backtest(background: BackgroundTasks):
113 113 return {"status": "backtest lancé en arrière-plan"}
114 114
115 115
116 +@router.post("/watch", dependencies=[Depends(require_admin)])
117 +def run_watch_now(db: Session = Depends(get_db)):
118 + """Cycle de veille Firecrawl immédiat (radar sondages, satisfaction, presse)."""
119 + from ..ingest.firecrawl_watch import run_watch
120 + return run_watch(db)
121 +
122 +
123 +class ByElectionPatch(BaseModel):
124 + used_in_model: bool | None = None
125 + result: dict[str, float] | None = None
126 + verified: bool | None = None
127 +
128 +
129 +@router.patch("/byelection/{bye_id}", dependencies=[Depends(require_admin)])
130 +def patch_byelection(bye_id: int, patch: ByElectionPatch, db: Session = Depends(get_db)):
131 + row = db.get(Mo.ByElection, bye_id)
132 + if row is None:
133 + raise HTTPException(404, "Partielle introuvable")
134 + for k, v in patch.model_dump(exclude_none=True).items():
135 + setattr(row, k, v)
136 + db.commit()
137 + return {"id": row.id, "status": "modifié"}
138 +
139 +
140 +class IndicatorIn(BaseModel):
141 + name: str = "gov_satisfaction"
142 + value: float = Field(ge=0, le=100)
143 + as_of: date | None = None
144 + source: str = "saisie manuelle"
145 + source_url: str | None = None
146 +
147 +
148 +@router.post("/indicator", dependencies=[Depends(require_admin)])
149 +def set_indicator(ind: IndicatorIn, db: Session = Depends(get_db)):
150 + d = ind.as_of or date.today()
151 + row = db.query(Mo.Indicator).filter_by(name=ind.name, as_of=d).first()
152 + if row is None:
153 + row = Mo.Indicator(name=ind.name, as_of=d, value=ind.value)
154 + db.add(row)
155 + row.value = ind.value
156 + row.source = ind.source
157 + row.source_url = ind.source_url
158 + row.method = "manual"
159 + db.commit()
160 + return {"id": row.id, "status": "enregistré"}
161 +
162 +
116 163 @router.get("/logs", dependencies=[Depends(require_admin)])
117 164 def logs(db: Session = Depends(get_db), limit: int = 100):
118 165 rows = (db.query(Mo.PipelineLog).order_by(Mo.PipelineLog.at.desc()).limit(limit).all())
modified backend/app/api/public.py +63 −2
@@ -158,6 +158,67 @@ def markets(db: Session = Depends(get_db)):
158 158 return data
159 159
160 160
161 +@router.get("/beyond")
162 +def beyond(db: Session = Depends(get_db)):
163 + """v2 « Au-delà des sondages » : décomposition du forecast par couche
164 + (sondages / partielles / fondamentaux / médias / marchés), prior de
165 + fondamentaux, satisfaction gouvernementale, poids selon l'horizon."""
166 + run = _latest_run(db)
167 + from ..modeling.fundamentals import blend_weight
168 + days = run.diagnostics.get("days_to_election", 0)
169 + schedule = [{"days": d, "weight": round(blend_weight(d), 4)}
170 + for d in [540, 450, 365, 270, 180, 120, 90, 60, 30, 14, 7, 1, 0]]
171 + n_bye = run.diagnostics.get("n_byelections", 0)
172 + return {"run_id": run.id, "as_of": run.as_of.isoformat(),
173 + "model_version": run.model_version,
174 + "days_to_election": days,
175 + "n_polls": run.n_polls_used - n_bye, "n_byelections": n_bye,
176 + "beyond": run.national.get("beyond") or {},
177 + "fundamentals_weight_schedule": schedule}
178 +
179 +
180 +@router.get("/byelections")
181 +def byelections(db: Session = Depends(get_db)):
182 + """Partielles 2022-2026 : votes réels + observation nationale implicite."""
183 + from ..ingest.byelections import pseudo_polls
184 + pseudo = {p["pollster"]: p for p in pseudo_polls(db)}
185 + out = []
186 + for b in (db.query(Mo.ByElection).order_by(Mo.ByElection.held_on.asc()).all()):
187 + ps = pseudo.get(f"Vote réel — {b.district_name}")
188 + out.append({"district": b.district_name, "held_on": b.held_on.isoformat(),
189 + "result": b.result, "turnout": b.turnout, "winner": b.winner,
190 + "previous_winner": b.previous_winner, "verified": b.verified,
191 + "used_in_model": b.used_in_model, "source_url": b.source_url,
192 + "notes": b.notes,
193 + "implied_national": ps["shares"] if ps else None,
194 + "equivalent_n": settings.byelection_equivalent_n,
195 + "swing_shrink": settings.byelection_swing_shrink})
196 + return out
197 +
198 +
199 +@router.get("/signals")
200 +def signals(db: Session = Depends(get_db), kind: str | None = None,
201 + limit: int = Query(60, le=300)):
202 + """Veille web continue (Firecrawl) : radar sondages, satisfaction, presse."""
203 + q = db.query(Mo.WebSignal).order_by(Mo.WebSignal.detected_at.desc())
204 + if kind:
205 + q = q.filter(Mo.WebSignal.kind == kind)
206 + rows = q.limit(limit).all()
207 + sat = (db.query(Mo.Indicator).filter_by(name="gov_satisfaction")
208 + .order_by(Mo.Indicator.as_of.desc()).first())
209 + return {
210 + "enabled": bool(settings.firecrawl_api_key) and settings.firecrawl_enabled,
211 + "satisfaction": ({"value": sat.value, "as_of": sat.as_of.isoformat(),
212 + "source": sat.source, "source_url": sat.source_url,
213 + "method": sat.method} if sat else
214 + {"value": settings.gov_satisfaction_fallback,
215 + "method": "fallback-config"}),
216 + "signals": [{"detected_at": s.detected_at.isoformat(), "kind": s.kind,
217 + "title": s.title, "url": s.url, "snippet": s.snippet,
218 + "pollster": s.pollster, "status": s.status, "extra": s.extra}
219 + for s in rows]}
220 +
221 +
161 222 @router.get("/polls")
162 223 def polls(db: Session = Depends(get_db),
163 224 date_from: date | None = None, date_to: date | None = None,
@@ -295,8 +356,8 @@ def sentiment(db: Session = Depends(get_db), days: int = Query(30, le=120)):
295 356 for d, v in sorted(daily.get(party, {}).items())],
296 357 }
297 358 n_docs = len(docs)
298 − return {"note": ("Signal auxiliaire très bruité — n'entre PAS dans le calcul "
299 − "des probabilités électorales."),
359 + return {"note": ("Signal bruité — n'agit sur le forecast que via l'ajustement "
360 + f"médias borné (±{settings.media_nudge_pp_max} pp max par parti)."),
300 361 "documents": n_docs, "parties": out}
301 362
302 363
modified backend/app/config.py +28 −2
@@ -19,7 +19,7 @@ FRONTEND_DIR = BASE_DIR.parent / "frontend"
19 19 class Settings(BaseSettings):
20 20 # --- Général ---
21 21 app_name: str = "QC Élection Forecast"
22 − model_version: str = "1.0.0"
22 + model_version: str = "2.0.0"
23 23 database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}"
24 24 admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me")
25 25 port: int = 8140
@@ -77,7 +77,33 @@ class Settings(BaseSettings):
77 77 riding_error_sd: float = 0.20 # écart-type propre à la circonscription
78 78 incumbent_retirement_penalty: float = 0.06 # log-ratio (~1.3 pp) si le sortant ne se représente pas
79 79
80 − # --- Sentiment (signal auxiliaire, poids nul dans le forecast) ---
80 + # --- v2 « Au-delà des sondages » : couches d'inférence supplémentaires ---
81 + # 1) Prior de fondamentaux (satisfaction envers le gouvernement, vote précédent,
82 + # usure du pouvoir) — poids qui décroît vers 0 à l'approche du scrutin.
83 + fundamentals_enabled: bool = True
84 + fundamentals_horizon_days: float = 540.0 # poids plein du prior à ~18 mois
85 + fundamentals_max_weight: float = 0.8 # jamais plus de 80 % de la précision
86 + fundamentals_term_penalty_pp: float = 1.5 # usure : −pp par mandat au-delà du 1er
87 + fundamentals_prior_sd_alr: float = 0.40 # diffusivité du prior (espace alr)
88 + gov_satisfaction_fallback: float = 35.0 # % satisfaits (Léger) si rien d'ingéré
89 + # 2) Élections partielles — votes réels convertis en observations nationales
90 + # bruitées (le swing des partielles surestime le swing général → rétréci).
91 + byelections_enabled: bool = True
92 + byelection_swing_shrink: float = 0.6
93 + byelection_equivalent_n: int = 350 # taille d'échantillon équivalente
94 + byelection_excess_pp: float = 3.0 # erreur excédentaire (participation, local)
95 + # 3) Ajustement médias — nudge borné du forecast issu du sentiment de presse.
96 + media_nudge_enabled: bool = True
97 + media_nudge_pp_max: float = 0.35 # amplitude maximale (pp) par parti
98 + media_nudge_min_volume: int = 12 # mentions minimales sur 14 j pour agir
99 + # 4) Ensemble avec les marchés prédictifs (probabilité « plus de sièges »).
100 + market_blend_weight: float = 0.12
101 + # Veille web continue (Firecrawl) : radar sondages, satisfaction, presse élargie.
102 + firecrawl_api_key: str = os.environ.get("FIRECRAWL_API_KEY", "")
103 + firecrawl_enabled: bool = True
104 + firecrawl_max_docs_per_cycle: int = 40
105 +
106 + # --- Sentiment (signal affiché; n'agit que via l'ajustement médias borné) ---
81 107 sentiment_feeds: list[str] = [
82 108 "https://ici.radio-canada.ca/rss/92", # Radio-Canada — Politique
83 109 "https://www.ledevoir.com/rss/section/politique.xml",
added backend/app/ingest/byelections.py +180 −0
@@ -0,0 +1,180 @@
1 +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026
2 +# Auteur : Simon-Pierre Boucher
3 +# Contact : contact@spboucher.ai
4 +# https://www.qc-election.com
5 +"""Élections partielles 2022-2026 — les seuls VOTES RÉELS disponibles avant le
6 +scrutin général (v2 « au-delà des sondages »).
7 +
8 +Chaque partielle est convertie en observation nationale bruitée :
9 +
10 + alr_national_implicite = alr(national 2022)
11 + + rétrécissement × [ alr(résultat partielle) − alr(résultat 2022 local) ]
12 +
13 +Le « swing » local observé est rétréci (défaut 0,6) car la littérature montre
14 +que les partielles EXAGÈRENT le mouvement national (participation faible, vote
15 +protestataire, absence d'enjeu de gouvernement). L'observation reçoit une
16 +grande variance (n équivalent ~350, erreur excédentaire ±3 pp) : elle informe
17 +le filtre de Kalman sans jamais le dominer. Contrairement à un sondage, c'est
18 +un bulletin de vote compté — la seule vérité de terrain d'ici le 5 octobre.
19 +
20 +Les résultats embarqués proviennent du DGEQ (recopiés; `verified=False` tant
21 +que la vérification Firecrawl n'a pas confirmé les pourcentages en ligne).
22 +"""
23 +from __future__ import annotations
24 +
25 +import logging
26 +import re
27 +import unicodedata
28 +from datetime import date
29 +
30 +import numpy as np
31 +import pandas as pd
32 +from sqlalchemy.orm import Session
33 +
34 +from ..config import DATA_DIR, settings
35 +from .. import models as Mo
36 +from ..modeling.compositions import alr, close, inv_alr
37 +
38 +log = logging.getLogger("byelections")
39 +
40 +RESULT_2022 = {"CAQ": 40.98, "PLQ": 14.37, "QS": 15.43, "PQ": 14.61, "PCQ": 12.91, "AUT": 1.70}
41 +
42 +# Résultats des partielles (DGEQ). AUT = candidats restants.
43 +BYELECTIONS = [
44 + {"district": "Saint-Henri-Sainte-Anne", "held_on": date(2023, 3, 13),
45 + "winner": "QS", "previous_winner": "PLQ", "turnout": 35.7,
46 + "result": {"QS": 44.2, "PLQ": 21.4, "PQ": 15.7, "CAQ": 8.8, "PCQ": 3.4, "AUT": 6.5},
47 + "source_url": "https://fr.wikipedia.org/wiki/Saint-Henri%E2%80%93Sainte-Anne",
48 + "notes": "Démission de Dominique Anglade — gain QS (Guillaume Cliche-Rivard)."},
49 + {"district": "Jean-Talon", "held_on": date(2023, 10, 2),
50 + "winner": "PQ", "previous_winner": "CAQ", "turnout": 52.4,
51 + "result": {"PQ": 44.0, "CAQ": 20.3, "QS": 13.0, "PCQ": 9.1, "PLQ": 8.9, "AUT": 4.7},
52 + "source_url": "https://fr.wikipedia.org/wiki/Jean-Talon_(circonscription)",
53 + "notes": "Démission de Joëlle Boutin — gain PQ (Pascal Paradis), premier signal du réalignement."},
54 + {"district": "Terrebonne", "held_on": date(2025, 3, 17),
55 + "winner": "PQ", "previous_winner": "CAQ", "turnout": 37.9,
56 + "result": {"PQ": 46.5, "CAQ": 18.5, "PLQ": 15.5, "PCQ": 9.0, "QS": 5.5, "AUT": 5.0},
57 + "source_url": "https://fr.wikipedia.org/wiki/Terrebonne_(circonscription)",
58 + "notes": "Gain PQ (Catherine Gentilcore) dans un château fort caquiste de 2022."},
59 + {"district": "Arthabaska", "held_on": date(2025, 8, 11),
60 + "winner": "PQ", "previous_winner": "CAQ", "turnout": 56.2,
61 + "result": {"PQ": 46.5, "PCQ": 35.0, "CAQ": 7.0, "PLQ": 5.0, "QS": 1.5, "AUT": 5.0},
62 + "source_url": "https://fr.wikipedia.org/wiki/Arthabaska_(circonscription)",
63 + "notes": "Gain PQ (Alex Boissonneault) devant Éric Duhaime (PCQ); effondrement CAQ."},
64 +]
65 +
66 +
67 +def _norm(s: str) -> str:
68 + s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
69 + return re.sub(r"[^a-z]", "", s.lower())
70 +
71 +
72 +def _riding_2022(district: str) -> dict[str, float] | None:
73 + """Résultat 2022 de la circonscription (carte 2022) — depuis le CSV canonique."""
74 + f = DATA_DIR / "riding_results_2022.csv"
75 + if not f.exists():
76 + return None
77 + df = pd.read_csv(f)
78 + key = _norm(district)
79 + for _, r in df.iterrows():
80 + if _norm(str(r["district"])) == key or key in _norm(str(r["district"])):
81 + return {p: float(r[p]) for p in settings.parties if p in r}
82 + return None
83 +
84 +
85 +def ensure_byelections(db: Session) -> int:
86 + """Upsert des partielles embarquées (idempotent, n'écrase pas une ligne vérifiée)."""
87 + added = 0
88 + for b in BYELECTIONS:
89 + row = (db.query(Mo.ByElection)
90 + .filter_by(district_name=b["district"], held_on=b["held_on"]).first())
91 + if row is None:
92 + row = Mo.ByElection(district_name=b["district"], held_on=b["held_on"],
93 + result=b["result"], turnout=b["turnout"],
94 + winner=b["winner"], previous_winner=b["previous_winner"],
95 + source_url=b["source_url"], notes=b["notes"])
96 + db.add(row)
97 + added += 1
98 + elif not row.verified:
99 + row.result = b["result"]; row.turnout = b["turnout"]
100 + row.source_url = b["source_url"]; row.notes = b["notes"]
101 + db.commit()
102 + return added
103 +
104 +
105 +def verify_with_firecrawl(db: Session) -> dict:
106 + """Vérification best-effort des pourcentages en ligne (Wikipédia via Firecrawl).
107 + Ne met à jour une partielle que si ≥5 partis sont extraits et que la somme
108 + est plausible; sinon les valeurs embarquées restent."""
109 + from .firecrawl_watch import scrape_markdown
110 + report = {}
111 + aliases = {"CAQ": ["coalition avenir"], "PLQ": ["libéral"], "PQ": ["parti québécois"],
112 + "QS": ["québec solidaire"], "PCQ": ["conservateur"]}
113 + for row in db.query(Mo.ByElection).filter_by(verified=False).all():
114 + if not row.source_url:
115 + continue
116 + md = scrape_markdown(row.source_url)
117 + if not md:
118 + report[row.district_name] = "scrape échoué"
119 + continue
120 + # fenêtre de texte autour de la date de la partielle
121 + year = str(row.held_on.year)
122 + idx = md.lower().find(f"partielle")
123 + window = md[max(0, idx - 500):idx + 6000] if idx >= 0 else md
124 + if year not in window:
125 + report[row.district_name] = "section partielle introuvable"
126 + continue
127 + found = {}
128 + low = window.lower()
129 + for party, keys in aliases.items():
130 + for k in keys:
131 + for m in re.finditer(re.escape(k), low):
132 + seg = window[m.end():m.end() + 120]
133 + pm = re.search(r"(\d{1,2}[.,]\d{1,2})\s*%", seg)
134 + if pm:
135 + v = float(pm.group(1).replace(",", "."))
136 + if 0.1 <= v <= 75:
137 + found[party] = v
138 + break
139 + if party in found:
140 + break
141 + if len(found) >= 5 and 80 <= sum(found.values()) <= 105:
142 + found["AUT"] = max(0.5, round(100 - sum(found.values()), 2))
143 + row.result = found
144 + row.verified = True
145 + report[row.district_name] = f"vérifié: {found}"
146 + else:
147 + report[row.district_name] = f"extraction incomplète ({len(found)} partis)"
148 + db.commit()
149 + return report
150 +
151 +
152 +def pseudo_polls(db: Session, as_of: date | None = None) -> list[dict]:
153 + """Partielles → observations nationales implicites, au format « sondage »
154 + consommé par le filtre de Kalman (avec erreur excédentaire dédiée)."""
155 + if not settings.byelections_enabled:
156 + return []
157 + out = []
158 + nat = close(np.array([RESULT_2022[p] for p in settings.parties]) / 100.0)
159 + for row in db.query(Mo.ByElection).filter_by(used_in_model=True).all():
160 + if as_of and row.held_on > as_of:
161 + continue
162 + base = _riding_2022(row.district_name)
163 + if base is None:
164 + log.warning("partielle %s : baseline 2022 introuvable", row.district_name)
165 + continue
166 + b = close(np.array([base.get(p, 0.5) for p in settings.parties]) / 100.0)
167 + r = close(np.array([row.result.get(p, 0.5) for p in settings.parties]) / 100.0)
168 + implied = inv_alr(alr(nat) + settings.byelection_swing_shrink * (alr(r) - alr(b)))
169 + out.append({
170 + "id": None,
171 + "pollster": f"Vote réel — {row.district_name}",
172 + "field_end": row.held_on,
173 + "sample_size": settings.byelection_equivalent_n,
174 + "mode": "byelection",
175 + "excess_pp": settings.byelection_excess_pp,
176 + "is_byelection": True,
177 + "shares": {p: round(float(implied[i] * 100), 2)
178 + for i, p in enumerate(settings.parties)},
179 + })
180 + return out
added backend/app/ingest/firecrawl_watch.py +281 −0
@@ -0,0 +1,281 @@
1 +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026
2 +# Auteur : Simon-Pierre Boucher
3 +# Contact : contact@spboucher.ai
4 +# https://www.qc-election.com
5 +"""Veille web CONTINUE via Firecrawl (v2 « au-delà des sondages »).
6 +
7 +Trois missions, exécutées à chaque cycle du pipeline (budget d'appels borné) :
8 +
9 +1. **Radar sondages** — recherche d'actualité ciblée : tout nouvel article
10 + mentionnant un sondage provincial québécois est détecté, archivé avec
11 + provenance (`web_signals`) et signalé sur la page Signaux et dans l'admin —
12 + souvent avant son apparition dans l'agrégation Wikipédia.
13 +2. **Satisfaction gouvernementale** — extraction du % de satisfaits envers le
14 + gouvernement du Québec (baromètres Léger/rapportage de presse). C'est
15 + l'entrée vivante du prior de fondamentaux (`indicators`).
16 +3. **Presse élargie** — recherche par parti au-delà des flux RSS (JdM/JdQ,
17 + TVA, 98,5, médias régionaux) : les articles alimentent le moteur de
18 + sentiment existant (mêmes scoreurs, même provenance).
19 +
20 +Chaque page brute est archivée dans data/raw/firecrawl/. Tout est tolérant à
21 +l'échec : sans clé ou sans réseau, le forecast fonctionne exactement comme avant.
22 +"""
23 +from __future__ import annotations
24 +
25 +import hashlib
26 +import logging
27 +import re
28 +from datetime import date, datetime, timezone
29 +
30 +import httpx
31 +from sqlalchemy.orm import Session
32 +
33 +from ..config import DATA_DIR, settings
34 +from .. import models as Mo
35 +
36 +log = logging.getLogger("firecrawl")
37 +
38 +FC_BASE = "https://api.firecrawl.dev/v2"
39 +RAW_DIR = DATA_DIR / "raw" / "firecrawl"
40 +
41 +KNOWN_POLLSTERS = ["Léger", "Leger", "Pallas", "Mainstreet", "Segma", "SOM",
42 + "Angus Reid", "Research Co", "Abacus", "EKOS", "Ipsos", "CROP",
43 + "Synopsis", "Forum"]
44 +
45 +PARTY_QUERIES = {
46 + "CAQ": "Coalition avenir Québec Christine Fréchette",
47 + "PLQ": "Parti libéral du Québec Charles Milliard",
48 + "PQ": "Parti québécois Paul St-Pierre Plamondon",
49 + "QS": "Québec solidaire Ruba Ghazal",
50 + "PCQ": "Parti conservateur du Québec Éric Duhaime",
51 +}
52 +
53 +
54 +def _headers() -> dict:
55 + return {"Authorization": f"Bearer {settings.firecrawl_api_key}",
56 + "Content-Type": "application/json"}
57 +
58 +
59 +def _enabled() -> bool:
60 + return settings.firecrawl_enabled and bool(settings.firecrawl_api_key)
61 +
62 +
63 +def _post(path: str, payload: dict, timeout: float = 60.0) -> dict | None:
64 + try:
65 + r = httpx.post(f"{FC_BASE}{path}", json=payload, headers=_headers(),
66 + timeout=timeout)
67 + r.raise_for_status()
68 + data = r.json()
69 + return data if data.get("success", True) else None
70 + except Exception as e:
71 + log.warning("firecrawl %s: %s", path, e)
72 + return None
73 +
74 +
75 +def _archive(url: str, content: str) -> None:
76 + try:
77 + RAW_DIR.mkdir(parents=True, exist_ok=True)
78 + h = hashlib.sha1(url.encode()).hexdigest()[:16]
79 + (RAW_DIR / f"{date.today().isoformat()}_{h}.md").write_text(
80 + f"<!-- {url} — {datetime.now(timezone.utc).isoformat()} -->\n{content}")
81 + except Exception:
82 + pass
83 +
84 +
85 +def scrape_markdown(url: str) -> str | None:
86 + """Scrape une page → markdown (archivé pour provenance)."""
87 + if not _enabled():
88 + return None
89 + data = _post("/scrape", {"url": url, "formats": ["markdown"],
90 + "onlyMainContent": True}, timeout=90.0)
91 + md = ((data or {}).get("data") or {}).get("markdown")
92 + if md:
93 + _archive(url, md[:200000])
94 + return md
95 +
96 +
97 +def search(query: str, limit: int = 8, tbs: str | None = "qdr:w",
98 + news: bool = True) -> list[dict]:
99 + """Recherche web/actualités → [{url, title, description, date?}]."""
100 + if not _enabled():
101 + return []
102 + payload: dict = {"query": query, "limit": limit, "location": "Montreal,Quebec,Canada"}
103 + if tbs:
104 + payload["tbs"] = tbs
105 + if news:
106 + payload["sources"] = [{"type": "news"}]
107 + data = _post("/search", payload)
108 + if not data:
109 + return []
110 + d = data.get("data") or {}
111 + items = []
112 + for bucket in ("news", "web"):
113 + for it in (d.get(bucket) or []):
114 + if isinstance(it, dict) and it.get("url"):
115 + items.append(it)
116 + if not items and isinstance(d, list): # tolérance : ancien format plat
117 + items = [it for it in d if isinstance(it, dict) and it.get("url")]
118 + return items[:limit]
119 +
120 +
121 +def _add_signal(db: Session, kind: str, title: str, url: str,
122 + snippet: str | None = None, pollster: str | None = None,
123 + extra: dict | None = None) -> bool:
124 + if db.query(Mo.WebSignal).filter_by(url=url, kind=kind).first():
125 + return False
126 + db.add(Mo.WebSignal(kind=kind, title=title[:500], url=url,
127 + snippet=(snippet or "")[:1000], pollster=pollster,
128 + extra=extra or {}))
129 + return True
130 +
131 +
132 +# ---------------------------------------------------------------------------
133 +# 1. Radar sondages
134 +# ---------------------------------------------------------------------------
135 +def radar_polls(db: Session) -> int:
136 + """Détecte les nouveaux sondages provinciaux dans l'actualité."""
137 + added, seen = 0, set()
138 + for q in ["sondage intentions de vote Québec provincial",
139 + "nouveau sondage élections Québec 2026"]:
140 + for it in search(q, limit=8, tbs="qdr:w"):
141 + text = f"{it.get('title', '')} {it.get('description', '')}"
142 + if it["url"] in seen or not re.search(r"sondage|intentions de vote|poll",
143 + text, re.I):
144 + continue
145 + seen.add(it["url"])
146 + house = next((h for h in KNOWN_POLLSTERS if h.lower() in text.lower()), None)
147 + if _add_signal(db, "poll-radar", it.get("title", "(sans titre)"),
148 + it["url"], it.get("description"), pollster=house,
149 + extra={"query": q, "published": it.get("date")}):
150 + db.flush()
151 + added += 1
152 + db.commit()
153 + return added
154 +
155 +
156 +# ---------------------------------------------------------------------------
157 +# 2. Satisfaction envers le gouvernement (entrée du prior de fondamentaux)
158 +# ---------------------------------------------------------------------------
159 +SAT_PATTERNS = [
160 + r"(\d{2})\s?%\s+(?:des\s+\S+\s+){0,4}(?:se\s+disent?\s+|sont\s+)?satisfait",
161 + r"satisfaction[^.\d]{0,60}?(\d{2})\s?%",
162 + r"taux de satisfaction[^.\d]{0,40}?(\d{2})\s?%",
163 +]
164 +
165 +
166 +def fetch_gov_satisfaction(db: Session) -> dict:
167 + """Cherche la mesure la plus récente du % de satisfaits envers le
168 + gouvernement du Québec; enregistre un Indicator avec provenance."""
169 + report = {"found": False}
170 + hits = search("sondage Léger satisfaction gouvernement du Québec",
171 + limit=6, tbs="qdr:m")
172 + for it in hits:
173 + blob = f"{it.get('title', '')} {it.get('description', '')}"
174 + val = _extract_sat(blob)
175 + source = it["url"]
176 + if val is None: # le extrait ne suffit pas → scrape de l'article
177 + md = scrape_markdown(it["url"])
178 + if md:
179 + val = _extract_sat(md[:8000])
180 + if val is not None:
181 + today = date.today()
182 + row = db.query(Mo.Indicator).filter_by(name="gov_satisfaction",
183 + as_of=today).first()
184 + if row is None:
185 + row = Mo.Indicator(name="gov_satisfaction", as_of=today, value=val)
186 + db.add(row)
187 + row.value = val
188 + row.source = it.get("title", "")[:120]
189 + row.source_url = source
190 + row.method = "firecrawl"
191 + _add_signal(db, "satisfaction", it.get("title", ""), source,
192 + it.get("description"), extra={"value": val})
193 + db.commit()
194 + report = {"found": True, "value": val, "source": source}
195 + break
196 + return report
197 +
198 +
199 +def _extract_sat(text: str) -> float | None:
200 + low = text.lower()
201 + if "gouvernement" not in low and "legault" not in low and "fréchette" not in low:
202 + return None
203 + for pat in SAT_PATTERNS:
204 + m = re.search(pat, low)
205 + if m:
206 + v = float(m.group(1))
207 + if 10 <= v <= 80:
208 + return v
209 + return None
210 +
211 +
212 +def latest_satisfaction(db: Session) -> tuple[float, dict]:
213 + """% de satisfaits le plus récent (ingéré), sinon valeur de repli configurée."""
214 + row = (db.query(Mo.Indicator).filter_by(name="gov_satisfaction")
215 + .order_by(Mo.Indicator.as_of.desc()).first())
216 + if row:
217 + return row.value, {"as_of": row.as_of.isoformat(), "source": row.source,
218 + "source_url": row.source_url, "method": row.method}
219 + return settings.gov_satisfaction_fallback, {
220 + "method": "fallback-config",
221 + "note": "aucune mesure ingérée — valeur de repli de configuration"}
222 +
223 +
224 +# ---------------------------------------------------------------------------
225 +# 3. Presse élargie → moteur de sentiment existant
226 +# ---------------------------------------------------------------------------
227 +def sweep_news(db: Session) -> dict:
228 + """Articles au-delà des RSS : recherche par parti, scoring par le moteur
229 + de sentiment existant (mêmes scoreurs lexique/LLM, même provenance)."""
230 + from .news_rss import cluster_key, detect_entities, lexicon_score, llm_score
231 + added, scored, budget = 0, 0, settings.firecrawl_max_docs_per_cycle
232 + for party, q in PARTY_QUERIES.items():
233 + if added >= budget:
234 + break
235 + for it in search(q, limit=6, tbs="qdr:w"):
236 + url, title = it.get("url"), it.get("title") or ""
237 + if not url or not title or added >= budget:
238 + continue
239 + if db.query(Mo.SentimentDocument).filter_by(url=url).first():
240 + continue
241 + summary = (it.get("description") or "")[:2000]
242 + pub = None
243 + if it.get("date"):
244 + try:
245 + pub = datetime.fromisoformat(str(it["date"]).replace("Z", "+00:00"))
246 + except ValueError:
247 + pub = None
248 + doc = Mo.SentimentDocument(
249 + source=f"Firecrawl · {url.split('/')[2]}", url=url, title=title,
250 + summary=summary, published=pub, cluster_key=cluster_key(title))
251 + text = f"{title}. {summary}"
252 + for ent in detect_entities(text):
253 + res = llm_score(text, ent)
254 + method = "llm"
255 + if res is None:
256 + res, method = lexicon_score(text, ent), "lexicon"
257 + s, stance = res
258 + doc.scores.append(Mo.SentimentScore(entity=ent, sentiment=s,
259 + stance=stance, method=method))
260 + scored += 1
261 + db.add(doc)
262 + added += 1
263 + db.commit()
264 + return {"documents": added, "scores": scored}
265 +
266 +
267 +# ---------------------------------------------------------------------------
268 +def run_watch(db: Session) -> dict:
269 + """Cycle complet de veille — chaque mission est isolée (échec toléré)."""
270 + if not _enabled():
271 + return {"skipped": "Firecrawl désactivé ou clé absente"}
272 + report: dict = {}
273 + for name, fn in [("radar_sondages", radar_polls),
274 + ("satisfaction", fetch_gov_satisfaction),
275 + ("presse_elargie", sweep_news)]:
276 + try:
277 + report[name] = fn(db)
278 + except Exception as e:
279 + db.rollback()
280 + report[name] = f"échec: {e}"
281 + return report
modified backend/app/main.py +1 −1
@@ -78,7 +78,7 @@ def health():
78 78 return {"status": "ok", "app": settings.app_name, "version": settings.model_version}
79 79
80 80
81 −PAGES = ["index", "carte", "sondages", "simulateur", "intelligence",
81 +PAGES = ["index", "carte", "sondages", "signaux", "simulateur", "intelligence",
82 82 "methodologie", "backtest", "admin"]
83 83 for page in PAGES:
84 84 path = "/" if page == "index" else f"/{page}"
modified backend/app/modeling/backtest.py +22 −4
@@ -22,7 +22,8 @@ from sqlalchemy.orm import Session
22 22 from ..config import DATA_DIR, settings
23 23 from .. import models as Mo
24 24 from ..pipeline import load_polls
25 −from .forecast import forecast as fc
25 +from . import fundamentals as FUND
26 +from .forecast import distribution_from, forecast as fc
26 27 from .simulate import SimulationInput, run_simulation
27 28 from .trend import fit_trend
28 29
@@ -73,7 +74,16 @@ def run_backtest(db: Session, n_sims: int = 10000, save_runs: bool = True) -> di
73 74 if trend is None:
74 75 horizons_report.append({"horizon": h, "error": "sondages insuffisants"})
75 76 continue
76 − f = fc(trend, as_of, ELECTION_2022)
77 + f_polls = fc(trend, as_of, ELECTION_2022)
78 + # v2 : blend avec le prior de fondamentaux, information ≤ 2022 seulement
79 + # (sortant CAQ, 1er mandat, satisfaction pré-électorale ~54 %, vote 2018).
80 + f = f_polls
81 + if settings.fundamentals_enabled:
82 + prior22 = FUND.compute_prior(as_of, incumbent="CAQ", terms=1,
83 + satisfaction=54.0, prev_result=RESULT_2018)
84 + x_b, P_b, _ = FUND.blend(f_polls.x, f_polls.P, prior22,
85 + (ELECTION_2022 - as_of).days)
86 + f = distribution_from("forecast", as_of, x_b, P_b)
77 87 inp.x_mean, inp.P = f.x, f.P
78 88 sim = run_simulation(inp, n_sims=n_sims)
79 89
@@ -83,6 +93,8 @@ def run_backtest(db: Session, n_sims: int = 10000, save_runs: bool = True) -> di
83 93 rmse = float(np.sqrt(np.mean(np.square(errs))))
84 94 coverage = float(np.mean([
85 95 f.summary[p]["lo95"] <= RESULT_2022[p] <= f.summary[p]["hi95"] for p in parties]))
96 + mae_polls_only = float(np.mean(
97 + [abs(f_polls.summary[p]["mean"] - RESULT_2022[p]) for p in parties]))
86 98
87 99 # --- sièges
88 100 seat_mae = float(np.mean([abs(sim.seats[p]["mean"] - SEATS_2022[p]) for p in parties]))
@@ -108,6 +120,7 @@ def run_backtest(db: Session, n_sims: int = 10000, save_runs: bool = True) -> di
108 120 rep = {
109 121 "horizon": h, "as_of": as_of.isoformat(), "n_polls": trend.n_polls,
110 122 "national": {"mae_pp": round(mae, 2), "rmse_pp": round(rmse, 2),
123 + "mae_pp_sondages_seuls": round(mae_polls_only, 2),
111 124 "coverage_95": round(coverage, 3),
112 125 "forecast": {p: f.summary[p]["mean"] for p in parties},
113 126 "actual": {p: RESULT_2022[p] for p in parties}},
@@ -141,9 +154,14 @@ def run_backtest(db: Session, n_sims: int = 10000, save_runs: bool = True) -> di
141 154 "observed": round(float(outs[m].mean()), 3),
142 155 "n": int(m.sum())})
143 156 report = {"election": "Québec 2022", "generated": date.today().isoformat(),
157 + "model_version": settings.model_version,
144 158 "note": ("Aucune correction maison appliquée en backtest (pas de "
145 − "données antérieures à 2018) — le backtest mesure le "
146 − "modèle de tendance + simulation seul."),
159 + "données antérieures à 2018). v2 : le prior de fondamentaux "
160 + "(information ≤ 2022 : sortant CAQ, 1er mandat, satisfaction "
161 + "~54 %, vote 2018) est blendé comme en production; "
162 + "mae_pp_sondages_seuls permet la comparaison directe. "
163 + "Partielles et ajustement médias non rejouables pour 2022 "
164 + "(données indisponibles) — exclus du backtest."),
147 165 "horizons": horizons_report, "calibration": bins}
148 166 (DATA_DIR / "backtest_report.json").write_text(
149 167 json.dumps(report, indent=2, ensure_ascii=False))
added backend/app/modeling/ensemble.py +88 −0
@@ -0,0 +1,88 @@
1 +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026
2 +# Auteur : Simon-Pierre Boucher
3 +# Contact : contact@spboucher.ai
4 +# https://www.qc-election.com
5 +"""Couches d'ensemble v2 — signaux non-sondages à poids explicites et bornés.
6 +
7 +1. **Ajustement médias** : le sentiment de presse des 14 derniers jours
8 + (RSS + veille Firecrawl) déplace le forecast d'au plus ±`media_nudge_pp_max`
9 + points par parti (défaut 0,35 pp). Un signal bruité obtient un poids
10 + minuscule mais non nul — c'est un nudge, jamais un moteur.
11 +
12 +2. **Ensemble marchés prédictifs** : la probabilité « plus de sièges » publiée
13 + est une moyenne pondérée modèle/marché (défaut 88 % / 12 %). Le marché
14 + agrège de l'information hors-sondages (initiés, campagnes locales,
15 + anticipations) et sa combinaison avec un modèle statistique améliore
16 + presque toujours le Brier (littérature sur l'agrégation de prévisions).
17 +
18 +Chaque couche est débrayable par configuration et sa contribution exacte est
19 +publiée dans la décomposition du forecast.
20 +"""
21 +from __future__ import annotations
22 +
23 +from datetime import datetime, timedelta, timezone
24 +
25 +import numpy as np
26 +from sqlalchemy.orm import Session
27 +
28 +from ..config import settings
29 +from .. import models as Mo
30 +from .compositions import alr, close, inv_alr
31 +
32 +
33 +def media_nudge(db: Session) -> dict:
34 + """Sentiment 14 j (pondéré par volume, saturé) → délta borné par parti (pp).
35 +
36 + delta_pp = cap × tanh(sentiment_moyen × 2) × min(1, volume / 60)
37 + Volume < `media_nudge_min_volume` → aucun effet (signal trop mince)."""
38 + out = {"enabled": settings.media_nudge_enabled, "delta_pp": {},
39 + "volume_14j": {}, "sentiment_14j": {}}
40 + if not settings.media_nudge_enabled:
41 + return out
42 + cutoff = datetime.now(timezone.utc) - timedelta(days=14)
43 + cap = settings.media_nudge_pp_max
44 + for party in [p for p in settings.parties if p != "AUT"]:
45 + rows = (db.query(Mo.SentimentScore).join(Mo.SentimentDocument)
46 + .filter(Mo.SentimentScore.entity == party,
47 + Mo.SentimentDocument.fetched_at >= cutoff).all())
48 + vol = len(rows)
49 + out["volume_14j"][party] = vol
50 + if vol < settings.media_nudge_min_volume:
51 + out["delta_pp"][party] = 0.0
52 + out["sentiment_14j"][party] = None
53 + continue
54 + mean_s = float(np.mean([r.sentiment for r in rows]))
55 + out["sentiment_14j"][party] = round(mean_s, 3)
56 + out["delta_pp"][party] = round(
57 + cap * float(np.tanh(mean_s * 2.0)) * min(1.0, vol / 60.0), 3)
58 + return out
59 +
60 +
61 +def apply_nudge(x: np.ndarray, delta_pp: dict[str, float]) -> np.ndarray:
62 + """Applique le délta (pp de parts) au vecteur d'état alr, à somme conservée."""
63 + if not any(delta_pp.values()):
64 + return x
65 + p0 = inv_alr(x)
66 + p1 = p0.copy()
67 + for i, party in enumerate(settings.parties):
68 + p1[i] += delta_pp.get(party, 0.0) / 100.0
69 + return alr(close(np.clip(p1, 0.001, None)))
70 +
71 +
72 +def market_blend(model_probs: dict[str, float],
73 + market_probs: dict[str, float] | None) -> dict:
74 + """P(plus de sièges) finale = (1−w)·modèle + w·marché, renormalisée.
75 + Sans marché disponible : probabilités du modèle telles quelles."""
76 + w = settings.market_blend_weight
77 + if not market_probs or w <= 0:
78 + return {"weight_market": 0.0, "blended": dict(model_probs),
79 + "market": market_probs or {}, "model": dict(model_probs)}
80 + blended = {}
81 + for p, mv in model_probs.items():
82 + blended[p] = (1 - w) * mv + w * market_probs.get(p, mv)
83 + tot = sum(blended.values())
84 + if tot > 0:
85 + blended = {p: round(v / tot, 4) for p, v in blended.items()}
86 + return {"weight_market": w, "blended": blended,
87 + "market": {p: round(v, 4) for p, v in market_probs.items()},
88 + "model": {p: round(v, 4) for p, v in model_probs.items()}}
modified backend/app/modeling/forecast.py +8 −0
@@ -67,3 +67,11 @@ def forecast(trend: TrendResult, as_of: date, election_day: date,
67 67 P_f = trend.P + (drift_var + settings.industry_error_sd ** 2) * np.eye(M)
68 68 return VoteDistribution("forecast", as_of, trend.x, P_f,
69 69 _summarize(trend.x, P_f, rng))
70 +
71 +
72 +def distribution_from(kind: str, as_of: date, x: np.ndarray, P: np.ndarray,
73 + seed: int = 3) -> VoteDistribution:
74 + """Distribution à partir d'un état (x, P) arbitraire — utilisé par les
75 + couches v2 (blend fondamentaux, ajustement médias)."""
76 + rng = np.random.default_rng(seed)
77 + return VoteDistribution(kind, as_of, x, P, _summarize(x, P, rng))
added backend/app/modeling/fundamentals.py +178 −0
@@ -0,0 +1,178 @@
1 +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026
2 +# Auteur : Simon-Pierre Boucher
3 +# Contact : contact@spboucher.ai
4 +# https://www.qc-election.com
5 +"""Prior de fondamentaux — le forecast ne repose plus uniquement sur les sondages.
6 +
7 +Idée (Abramowitz, Hibbs, The Economist/538 appliqués au Québec) : longtemps avant
8 +le scrutin, les sondages sont peu informatifs sur le résultat final; des variables
9 +structurelles le sont davantage :
10 +
11 + * la **satisfaction envers le gouvernement** (baromètres Léger/CROP) — le
12 + prédicteur historique le plus fort du vote du parti sortant au Québec;
13 + * le **vote à l'élection précédente** (persistance des coalitions);
14 + * l'**usure du pouvoir** (pénalité par mandat au-delà du premier).
15 +
16 +Le vote du parti sortant est estimé par une régression ridge sur les huit
17 +générales de 1998 à 2022 (validation leave-one-out → variance honnête). Les
18 +partis d'opposition reçoivent un prior de persistance (vote précédent, rappelé
19 +vers leur moyenne de long terme). Le tout devient une distribution a priori en
20 +espace log-ratio, combinée au posterior des sondages par pondération de
21 +précision, avec un poids qui décroît vers zéro à l'approche du scrutin :
22 +
23 + w(t) = min(w_max, (jours_restants / horizon)^1.3)
24 +
25 +À 18 mois, les fondamentaux comptent; la veille du vote, ils ne comptent
26 +presque plus — exactement le comportement souhaité.
27 +"""
28 +from __future__ import annotations
29 +
30 +from dataclasses import dataclass, field
31 +from datetime import date
32 +
33 +import numpy as np
34 +
35 +from ..config import settings
36 +from .compositions import alr, close, inv_alr
37 +
38 +# ---------------------------------------------------------------------------
39 +# Élections générales québécoises 1998-2022 — variables de fondamentaux.
40 +# satisfaction : % « satisfaits du gouvernement » pré-électoral (Léger/CROP,
41 +# reconstitution documentée — valeurs approximatives au point de pourcentage
42 +# près, suffisant pour une régression à 8 observations).
43 +# ---------------------------------------------------------------------------
44 +HISTORY = [
45 + # (année, parti sortant, mandats, satisfaction, vote précédent du sortant, résultat du sortant)
46 + (1998, "PQ", 1, 52.0, 44.75, 42.87),
47 + (2003, "PQ", 2, 45.0, 42.87, 33.24),
48 + (2007, "PLQ", 1, 36.0, 45.99, 33.08),
49 + (2008, "PLQ", 2, 52.0, 33.08, 42.08),
50 + (2012, "PLQ", 3, 31.0, 42.08, 31.20),
51 + (2014, "PQ", 1, 36.0, 31.95, 25.38),
52 + (2018, "PLQ", 1, 41.0, 41.52, 24.82),
53 + (2022, "CAQ", 1, 54.0, 37.42, 40.98),
54 +]
55 +
56 +# Moyennes de long terme (2008-2022) — force de rappel des priors d'opposition.
57 +LONG_RUN_MEAN = {"CAQ": 30.0, "PLQ": 30.8, "PQ": 24.8, "QS": 9.8, "PCQ": 7.2, "AUT": 2.5}
58 +PERSISTENCE = 0.65 # part du vote précédent dans le prior des non-sortants
59 +
60 +RIDGE_LAMBDA = 4.0 # shrinkage de la régression (8 observations seulement)
61 +
62 +
63 +@dataclass
64 +class FundamentalsPrior:
65 + as_of: date
66 + incumbent: str
67 + mean_shares: np.ndarray # (6,) fractions, somme 1
68 + x: np.ndarray # alr (5,)
69 + P: np.ndarray # covariance a priori alr (5,5)
70 + detail: dict = field(default_factory=dict)
71 +
72 +
73 +def _ridge_fit() -> tuple[np.ndarray, float, list[float]]:
74 + """Régression ridge « vote du sortant ~ vote précédent + satisfaction »,
75 + avec pénalité d'usure retranchée AVANT l'ajustement (paramètre fixé, pas
76 + estimé — 8 points ne supportent pas un 3e prédicteur).
77 + Retourne (coefficients [b0, b_prev, b_sat], RMSE leave-one-out, résidus LOO)."""
78 + pen = settings.fundamentals_term_penalty_pp
79 + X = np.array([[1.0, prev, sat] for (_, _, _, sat, prev, _) in HISTORY])
80 + y = np.array([res + pen * (terms - 1) for (_, _, terms, _, _, res) in HISTORY])
81 + # centrage des prédicteurs (le ridge ne pénalise pas l'ordonnée à l'origine)
82 + mx = X[:, 1:].mean(axis=0)
83 + Xc = X.copy(); Xc[:, 1:] -= mx
84 +
85 + def fit(Xf, yf):
86 + L = RIDGE_LAMBDA * np.eye(3); L[0, 0] = 0.0
87 + return np.linalg.solve(Xf.T @ Xf + L, Xf.T @ yf)
88 +
89 + beta = fit(Xc, y)
90 + loo = []
91 + for i in range(len(y)):
92 + m = np.ones(len(y), bool); m[i] = False
93 + b = fit(Xc[m], y[m])
94 + loo.append(float(y[i] - Xc[i] @ b))
95 + rmse = float(np.sqrt(np.mean(np.square(loo))))
96 + # recentrage pour usage direct sur (1, prev, sat)
97 + b0 = float(beta[0] - beta[1] * mx[0] - beta[2] * mx[1])
98 + return np.array([b0, beta[1], beta[2]]), rmse, [round(r, 2) for r in loo]
99 +
100 +
101 +def compute_prior(as_of: date, incumbent: str = "CAQ", terms: int = 2,
102 + satisfaction: float | None = None,
103 + prev_result: dict[str, float] | None = None) -> FundamentalsPrior:
104 + """Construit le prior de fondamentaux pour l'élection cible.
105 +
106 + prev_result : {party: %} de la dernière générale (défaut : 2022).
107 + satisfaction : % satisfaits du gouvernement (défaut : indicateur ingéré ou
108 + valeur de repli de configuration)."""
109 + prev = prev_result or {"CAQ": 40.98, "PLQ": 14.37, "QS": 15.43,
110 + "PQ": 14.61, "PCQ": 12.91, "AUT": 1.70}
111 + sat = satisfaction if satisfaction is not None else settings.gov_satisfaction_fallback
112 +
113 + beta, loo_rmse, loo = _ridge_fit()
114 + pen = settings.fundamentals_term_penalty_pp * (terms - 1)
115 + inc_vote = float(beta @ np.array([1.0, prev.get(incumbent, 35.0), sat])) - pen
116 + inc_vote = float(np.clip(inc_vote, 8.0, 55.0))
117 +
118 + # Opposition : persistance + rappel vers la moyenne de long terme,
119 + # renormalisée sur l'espace restant (100 − sortant).
120 + raw = {}
121 + for p in settings.parties:
122 + if p == incumbent:
123 + continue
124 + raw[p] = PERSISTENCE * prev.get(p, 2.0) + (1 - PERSISTENCE) * LONG_RUN_MEAN.get(p, 3.0)
125 + rest = 100.0 - inc_vote
126 + tot = sum(raw.values())
127 + shares = {p: v * rest / tot for p, v in raw.items()}
128 + shares[incumbent] = inc_vote
129 +
130 + vec = close(np.array([shares[p] for p in settings.parties]) / 100.0)
131 + x_f = alr(vec)
132 + # Prior diffus : LOO ±rmse pp sur le sortant → sd alr large et honnête.
133 + sd = max(settings.fundamentals_prior_sd_alr, loo_rmse / 100.0 / max(vec.min(), 0.05))
134 + P_f = np.eye(len(x_f)) * sd ** 2
135 +
136 + return FundamentalsPrior(
137 + as_of=as_of, incumbent=incumbent, mean_shares=vec, x=x_f, P=P_f,
138 + detail={
139 + "regression": {"b0": round(float(beta[0]), 2),
140 + "b_vote_precedent": round(float(beta[1]), 3),
141 + "b_satisfaction": round(float(beta[2]), 3),
142 + "loo_rmse_pp": round(loo_rmse, 2), "loo_residus": loo,
143 + "n_elections": len(HISTORY)},
144 + "inputs": {"sortant": incumbent, "mandats": terms, "satisfaction": sat,
145 + "penalite_usure_pp": pen,
146 + "vote_precedent": {p: prev.get(p) for p in settings.parties}},
147 + "prior_pct": {p: round(float(vec[i] * 100), 1)
148 + for i, p in enumerate(settings.parties)},
149 + "sd_alr": round(sd, 3),
150 + })
151 +
152 +
153 +def blend_weight(days_to_election: int) -> float:
154 + """Poids du prior selon l'horizon — nul le jour du vote, plafonné à w_max."""
155 + if days_to_election <= 0:
156 + return 0.0
157 + w = (days_to_election / settings.fundamentals_horizon_days) ** 1.3
158 + return float(min(settings.fundamentals_max_weight, w))
159 +
160 +
161 +def blend(x_polls: np.ndarray, P_polls: np.ndarray, prior: FundamentalsPrior,
162 + days_to_election: int) -> tuple[np.ndarray, np.ndarray, dict]:
163 + """Combinaison bayésienne précision-pondérée : Λ = Λ_sondages + w·Λ_prior."""
164 + w = blend_weight(days_to_election)
165 + M_ = len(x_polls)
166 + if w <= 0:
167 + return x_polls, P_polls, {"weight_schedule": 0.0, "precision_share": 0.0}
168 + Lp = np.linalg.inv(P_polls + 1e-10 * np.eye(M_))
169 + Lf = np.linalg.inv(prior.P) * w
170 + Lc = Lp + Lf
171 + Pc = np.linalg.inv(Lc)
172 + xc = Pc @ (Lp @ x_polls + Lf @ prior.x)
173 + share = float(np.trace(Lf) / np.trace(Lc))
174 + dpp = {p: round(float((inv_alr(xc)[i] - inv_alr(x_polls)[i]) * 100), 3)
175 + for i, p in enumerate(settings.parties)}
176 + return xc, 0.5 * (Pc + Pc.T), {
177 + "weight_schedule": round(w, 4), "precision_share": round(share, 4),
178 + "delta_pp": dpp}
modified backend/app/modeling/trend.py +4 −2
@@ -72,8 +72,10 @@ def prepare_polls(polls: list[dict], profiles: dict[str, PollsterProfile]) -> li
72 72 n_eff = effective_n(poll.get("sample_size"), poll.get("mode", "unknown"))
73 73 # la cote du sondeur module la précision effective (jamais > ±40 %)
74 74 n_scaled = n_eff * prof.weight_multiplier
75 − # maison sans historique : erreur excédentaire accrue
76 − excess = settings.excess_poll_sd_pp * (1.0 if prof.mae_pp is not None else 1.25)
75 + # maison sans historique : erreur excédentaire accrue; les observations
76 + # spéciales (ex. partielles) fixent leur propre erreur excédentaire
77 + excess = poll.get("excess_pp") or (
78 + settings.excess_poll_sd_pp * (1.0 if prof.mae_pp is not None else 1.25))
77 79 R = alr_obs_cov(p_adj, n_scaled, excess_pp=excess)
78 80 out.append(PreparedPoll(
79 81 field_end=poll["field_end"], pollster=poll["pollster"], y=alr(p_adj),
modified backend/app/models.py +50 −0
@@ -211,6 +211,56 @@ class NewsEvent(Base):
211 211 detected_by: Mapped[str] = mapped_column(String(30), default="manual")
212 212
213 213
214 +class ByElection(Base):
215 + """Élection partielle depuis la dernière générale — vote RÉEL, converti en
216 + observation nationale bruitée (v2 « au-delà des sondages »)."""
217 + __tablename__ = "byelections"
218 + __table_args__ = (UniqueConstraint("district_name", "held_on", name="uq_bye"),)
219 + id: Mapped[int] = mapped_column(primary_key=True)
220 + district_name: Mapped[str] = mapped_column(String(80))
221 + held_on: Mapped[date] = mapped_column(Date, index=True)
222 + result: Mapped[dict] = mapped_column(JSON) # {party: pct}
223 + turnout: Mapped[float | None] = mapped_column(Float, nullable=True)
224 + winner: Mapped[str] = mapped_column(String(8))
225 + previous_winner: Mapped[str | None] = mapped_column(String(8), nullable=True)
226 + source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
227 + verified: Mapped[bool] = mapped_column(Boolean, default=False) # confirmé par scrape
228 + used_in_model: Mapped[bool] = mapped_column(Boolean, default=True)
229 + notes: Mapped[str | None] = mapped_column(Text, nullable=True)
230 +
231 +
232 +class WebSignal(Base):
233 + """Détection de la veille web continue (Firecrawl) : nouveau sondage repéré,
234 + article pertinent, mesure de satisfaction… Provenance complète."""
235 + __tablename__ = "web_signals"
236 + __table_args__ = (UniqueConstraint("url", "kind", name="uq_signal"),)
237 + id: Mapped[int] = mapped_column(primary_key=True)
238 + detected_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
239 + kind: Mapped[str] = mapped_column(String(30), index=True) # poll-radar / satisfaction / presse
240 + title: Mapped[str] = mapped_column(Text)
241 + url: Mapped[str] = mapped_column(Text)
242 + snippet: Mapped[str | None] = mapped_column(Text, nullable=True)
243 + pollster: Mapped[str | None] = mapped_column(String(80), nullable=True)
244 + status: Mapped[str] = mapped_column(String(12), default="nouveau") # nouveau/traité/ignoré
245 + extra: Mapped[dict] = mapped_column(JSON, default=dict)
246 +
247 +
248 +class Indicator(Base):
249 + """Indicateur de fondamentaux (ex. % satisfaits du gouvernement — Léger),
250 + avec provenance et méthode (firecrawl / fallback-config / manuel)."""
251 + __tablename__ = "indicators"
252 + __table_args__ = (UniqueConstraint("name", "as_of", name="uq_indicator"),)
253 + id: Mapped[int] = mapped_column(primary_key=True)
254 + name: Mapped[str] = mapped_column(String(60), index=True)
255 + value: Mapped[float] = mapped_column(Float)
256 + as_of: Mapped[date] = mapped_column(Date)
257 + source: Mapped[str] = mapped_column(String(120), default="")
258 + source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
259 + method: Mapped[str] = mapped_column(String(30), default="manual")
260 + fetched_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
261 + extra: Mapped[dict] = mapped_column(JSON, default=dict)
262 +
263 +
214 264 class DataSource(Base):
215 265 __tablename__ = "data_sources"
216 266 id: Mapped[int] = mapped_column(primary_key=True)
modified backend/app/pipeline.py +113 −7
@@ -17,9 +17,11 @@ from sqlalchemy.orm import Session
17 17 from .config import settings
18 18 from .db import SessionLocal, init_db
19 19 from . import models as Mo
20 +from .modeling import ensemble as ENS
21 +from .modeling import fundamentals as FUND
20 22 from .modeling import house_effects as HE
21 23 from .modeling import simulate as SIM
22 −from .modeling.forecast import forecast as fc, nowcast as nc
24 +from .modeling.forecast import distribution_from, forecast as fc, nowcast as nc
23 25 from .modeling.trend import fit_trend
24 26
25 27 log = logging.getLogger("pipeline")
@@ -96,25 +98,98 @@ def _district_inputs(db: Session, baseline_national: dict[str, float]) -> SIM.Si
96 98 def run_forecast(db: Session, as_of: date | None = None, label: str | None = None,
97 99 is_backtest: bool = False, n_sims: int | None = None,
98 100 save: bool = True) -> Mo.ForecastRun | None:
99 − """Ajuste le modèle et simule l'élection cible; persiste un ForecastRun."""
101 + """Ajuste le modèle v2 (sondages + partielles + fondamentaux + médias),
102 + simule l'élection cible et persiste un ForecastRun avec la décomposition
103 + complète de la contribution de chaque couche."""
100 104 as_of = as_of or date.today()
101 105 target = db.query(Mo.Election).filter_by(is_target=True).one()
106 + days_left = (target.election_date - as_of).days
102 107 profiles = compute_pollster_profiles(db)
103 108 polls = load_polls(db, target.id, as_of=as_of)
104 − trend = fit_trend(polls, profiles, as_of)
109 +
110 + # --- couche 2 : votes réels des partielles → observations nationales ---
111 + from .ingest.byelections import pseudo_polls
112 + bye_polls = pseudo_polls(db, as_of=as_of)
113 +
114 + trend_a = fit_trend(polls, profiles, as_of) # sondages seuls
115 + trend = fit_trend(polls + bye_polls, profiles, as_of) if bye_polls else trend_a
105 116 if trend is None:
106 117 raise RuntimeError("Pas assez de sondages pour ajuster le modèle")
107 118
108 119 now_d = nc(trend, as_of)
109 − fc_d = fc(trend, as_of, target.election_date)
120 + fc_b = fc(trend, as_of, target.election_date) # sondages + partielles
110 121
122 + # --- couche 3 : prior de fondamentaux (poids décroissant vers le scrutin) ---
111 123 result_2022 = (db.query(Mo.Election)
112 124 .filter(Mo.Election.actual_result.isnot(None))
113 125 .order_by(Mo.Election.election_date.desc()).first().actual_result)
126 + fund_diag: dict = {"enabled": settings.fundamentals_enabled}
127 + x_c, P_c = fc_b.x, fc_b.P
128 + if settings.fundamentals_enabled:
129 + from .ingest.firecrawl_watch import latest_satisfaction
130 + sat, sat_prov = latest_satisfaction(db)
131 + prior = FUND.compute_prior(as_of, incumbent="CAQ", terms=2,
132 + satisfaction=sat, prev_result=result_2022)
133 + x_c, P_c, bl = FUND.blend(fc_b.x, fc_b.P, prior, days_left)
134 + fund_diag.update(prior=prior.detail, blend=bl,
135 + satisfaction={"value": sat, **sat_prov})
136 +
137 + # --- couche 4 : ajustement médias borné ---
138 + nudge = ENS.media_nudge(db)
139 + x_d = ENS.apply_nudge(x_c, nudge.get("delta_pp", {}))
140 + fc_d = distribution_from("forecast", as_of, x_d, P_c)
141 +
114 142 inp = _district_inputs(db, result_2022)
115 143 inp.x_mean, inp.P = fc_d.x, fc_d.P
116 144 sim = SIM.run_simulation(inp, n_sims=n_sims)
117 145
146 + # --- décomposition : contribution de chaque couche (votes et probabilités) ---
147 + decomposition = None
148 + if not is_backtest and trend_a is not None:
149 + def _probe(x, P, seed):
150 + inp.x_mean, inp.P = x, P
151 + s = SIM.run_simulation(inp, n_sims=8000,
152 + rng=np.random.default_rng(seed))
153 + return {p: s.seats[p]["prob_most"] for p in settings.parties}
154 + fc_a = fc(trend_a, as_of, target.election_date)
155 + variants = [
156 + ("sondages", "Sondages seuls (modèle v1)", fc_a.x, fc_a.P),
157 + ("partielles", "+ votes réels des partielles", fc_b.x, fc_b.P),
158 + ("fondamentaux", "+ prior de fondamentaux", x_c, P_c),
159 + ("medias", "+ ajustement médias", x_d, P_c),
160 + ]
161 + layers, prev_vote, prev_prob = [], None, None
162 + for key, lab, x, P in variants:
163 + d = distribution_from("probe", as_of, x, P)
164 + vote = {p: d.summary[p]["mean"] for p in settings.parties}
165 + prob = _probe(x, P, seed=hash(key) % 2 ** 31)
166 + layers.append({
167 + "key": key, "label": lab, "vote": vote, "prob_most": prob,
168 + "delta_vote": ({p: round(vote[p] - prev_vote[p], 2)
169 + for p in settings.parties} if prev_vote else None),
170 + "delta_prob": ({p: round(prob[p] - prev_prob[p], 4)
171 + for p in settings.parties} if prev_prob else None)})
172 + prev_vote, prev_prob = vote, prob
173 + inp.x_mean, inp.P = fc_d.x, fc_d.P # restaure l'entrée finale
174 + decomposition = {"layers": layers, "n_sims_probe": 8000}
175 +
176 + # --- couche 5 : ensemble avec les marchés prédictifs (probabilités) ---
177 + market_ens = None
178 + if not is_backtest:
179 + try:
180 + from .ingest.markets import fetch_winner_market
181 + mk = fetch_winner_market()
182 + market_ens = ENS.market_blend(
183 + {p: sim.seats[p]["prob_most"] for p in settings.parties},
184 + (mk or {}).get("implied_prob_most_seats"))
185 + if mk:
186 + market_ens["market_meta"] = {"event": mk.get("event"),
187 + "url": mk.get("url"),
188 + "volume_usd": mk.get("volume_usd"),
189 + "fetched_at": mk.get("fetched_at")}
190 + except Exception:
191 + market_ens = None
192 +
118 193 # série lissée compacte pour les graphiques (sous-échantillonnée)
119 194 step = max(1, len(trend.dates) // 400)
120 195 idx = list(range(0, len(trend.dates), step))
@@ -130,6 +205,16 @@ def run_forecast(db: Session, as_of: date | None = None, label: str | None = Non
130 205 for k, p in enumerate(settings.parties)},
131 206 }
132 207
208 + beyond = {
209 + "decomposition": decomposition,
210 + "fundamentals": fund_diag,
211 + "byelections_used": [{"pollster": b["pollster"],
212 + "field_end": b["field_end"].isoformat(),
213 + "implied_national": b["shares"]} for b in bye_polls],
214 + "media_adjustment": nudge,
215 + "market_ensemble": market_ens,
216 + }
217 +
133 218 run = Mo.ForecastRun(
134 219 as_of=as_of, model_version=settings.model_version, n_polls_used=trend.n_polls,
135 220 n_simulations=sim.n_sims, is_backtest=is_backtest, label=label,
@@ -137,11 +222,16 @@ def run_forecast(db: Session, as_of: date | None = None, label: str | None = Non
137 222 "trend_series": series,
138 223 "x_forecast": [float(v) for v in fc_d.x],
139 224 "P_forecast": [[float(v) for v in row] for row in fc_d.P],
140 − "baseline_national": result_2022},
225 + "baseline_national": result_2022,
226 + "beyond": beyond},
141 227 seats={"per_party": sim.seats, "summary": sim.seat_matrix_summary,
142 − "sim_national_vote": sim.national_vote},
228 + "sim_national_vote": sim.national_vote,
229 + "ensemble": market_ens},
143 230 diagnostics={"q_daily_var": trend.q, "loglik": trend.loglik,
144 − "days_to_election": (target.election_date - as_of).days})
231 + "days_to_election": days_left,
232 + "n_byelections": len(bye_polls),
233 + "fundamentals_weight": (fund_diag.get("blend") or {}).get(
234 + "precision_share", 0.0)})
145 235 for d in sim.districts:
146 236 run.district_results.append(Mo.ForecastDistrictResult(
147 237 district_name=d["district"], favorite=d["favorite"], category=d["category"],
@@ -187,6 +277,22 @@ def run_pipeline(full_refresh: bool = True) -> dict:
187 277 except Exception as e:
188 278 report["sentiment"] = f"échec: {e}"
189 279 _log(db, "sentiment", "error", traceback.format_exc())
280 + try:
281 + from .ingest.byelections import ensure_byelections, verify_with_firecrawl
282 + added = ensure_byelections(db)
283 + verif = verify_with_firecrawl(db) if settings.firecrawl_api_key else {}
284 + report["byelections"] = {"ajoutées": added, "vérification": verif}
285 + _log(db, "byelections", "ok", str(report["byelections"])[:500])
286 + except Exception as e:
287 + report["byelections"] = f"échec: {e}"
288 + _log(db, "byelections", "error", traceback.format_exc())
289 + try:
290 + from .ingest.firecrawl_watch import run_watch
291 + report["veille"] = run_watch(db)
292 + _log(db, "veille-firecrawl", "ok", str(report["veille"])[:500])
293 + except Exception as e:
294 + report["veille"] = f"échec: {e}"
295 + _log(db, "veille-firecrawl", "error", traceback.format_exc())
190 296 try:
191 297 run = run_forecast(db)
192 298 report["forecast_run_id"] = run.id
modified backend/app/seed.py +10 −1
@@ -137,9 +137,18 @@ def seed_all(db: Session | None = None) -> dict:
137 137 get_or_create(db, Mo.NewsEvent, event_date=d, title=title,
138 138 defaults=dict(kind=kind, importance=imp, detected_by="seed"))
139 139 # Version du modèle
140 − get_or_create(db, Mo.ModelVersion, version=settings.model_version,
140 + get_or_create(db, Mo.ModelVersion, version="1.0.0",
141 141 defaults=dict(changelog="Modèle initial : DLM Kalman (alr), "
142 142 "house effects 2022, Monte Carlo corrélé."))
143 + get_or_create(db, Mo.ModelVersion, version="2.0.0",
144 + defaults=dict(changelog=(
145 + "« Au-delà des sondages » : votes réels des partielles injectés "
146 + "dans le filtre (swing rétréci ×0,6), prior de fondamentaux "
147 + "(satisfaction/vote précédent/usure, régression 1998-2022, poids "
148 + "décroissant w(t)), ajustement médias borné ±0,35 pp, ensemble "
149 + "88/12 avec Polymarket sur P(plus de sièges), veille web continue "
150 + "Firecrawl (radar sondages + satisfaction + presse élargie), "
151 + "décomposition du forecast par couche, backtest comparatif v2.")))
143 152 # Sources
144 153 for name, url, kind in [
145 154 ("Wikipédia — sondages 2026", "https://en.wikipedia.org/wiki/2026_Quebec_general_election", "polls"),
modified backend/data/polls_2022.csv +910 −910
Large diff (1,821 lines) is not loaded by default to keep the page fast.Load diff
modified backend/data/polls_2026.csv +412 −406
Large diff (819 lines) is not loaded by default to keep the page fast.Load diff
added backend/tests/test_beyond.py +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/assets/app.js +5 −2
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/index.html +36 −2
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/intelligence.html +3 −3
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/methodologie.html +36 −2
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added frontend/signaux.html +294 −0
Large diff (295 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme