SPB Git

Repository Public

Commit

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
25 changed files +2,968 −1,358 Browse files
modified .env.example +10 −0
@@ -5,6 +5,16 @@ ADMIN_TOKEN=changez-moi
5 # Clé du gateway LLM MacLustr (optionnel — scoreur de stance LLM) 5 # Clé du gateway LLM MacLustr (optionnel — scoreur de stance LLM)
6 # MACLUSTR_API_KEY= 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 # Surcharges du modèle (préfixe QCE_) 18 # Surcharges du modèle (préfixe QCE_)
9 # QCE_PORT=8140 19 # QCE_PORT=8140
10 # QCE_N_SIMULATIONS=25000 20 # QCE_N_SIMULATIONS=25000
modified METHODOLOGY.md +65 −14
@@ -5,8 +5,8 @@
5 5
6 # Méthodologie — QC Élection Forecast 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 8+Version du modèle : **2.0.0 « Au-delà des sondages »** · Élection cible : générale
9−(127 circonscriptions, majorité à 64). 9+québécoise du 5 octobre 2026 (127 circonscriptions, majorité à 64).
10 10
11 Ce document est la référence auditable du système. La version grand public est servie sur 11 Ce document est la référence auditable du système. La version grand public est servie sur
12 `/methodologie`; les snapshots de chaque exécution sont conservés en base 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 | 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 | 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 | 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 | 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** | 21+| **Élections partielles (DGEQ)** | 4 partielles 2023-2025 : votes réels comptés | **couche 2 — observations nationales du filtre (v2)** |
22−| Presse québécoise (RSS publics) | Radio-Canada, Le Devoir, La Presse | **sentiment auxiliaire, hors modèle** | 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 Chaque enregistrement conserve : source, URL, date d'accès, valeur brute, valeur normalisée. 27 Chaque enregistrement conserve : source, URL, date d'accès, valeur brute, valeur normalisée.
25 Les HTML bruts sont archivés dans `backend/data/raw/`. 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 `q × jours_restants × 1,6` (volatilité de campagne) **plus l'erreur systémique de 66 `q × jours_restants × 1,6` (volatilité de campagne) **plus l'erreur systémique de
64 sondage** `σ_industrie = 0,12` (voir §7). 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 ## 6. Modèle de circonscription 108 ## 6. Modèle de circonscription
67 109
68 `part(circ, parti) = softmax( alr(référence 2022 locale) + swing_national_alr 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 Calibration des probabilités locales (tous horizons confondus) : suit la diagonale 146 Calibration des probabilités locales (tous horizons confondus) : suit la diagonale
105 (ex. bac 70–80 % → observé 84 %; bac 90–100 % → observé 98 %). L'ajout de la couche 147 (ex. bac 70–80 % → observé 84 %; bac 90–100 % → observé 98 %). L'ajout de la couche
106 systémique a fait passer la couverture J−1 de 20 % à 100 % sans dégrader le Brier. 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) — 149+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. 150+
109− 151+**Backtest v2** : le blend de fondamentaux est rejoué avec l'information de 2022
110−## 9. Signaux auxiliaires (poids nul dans le forecast) 152+(sortant CAQ, 1er mandat, satisfaction ~54 %, vote 2018); le rapport publie côte à côte
111− 153+`mae_pp` (v2) et `mae_pp_sondages_seuls`. Les partielles et l'ajustement médias ne sont
112−- **Sentiment médiatique** : RSS → extraction d'entités → stance (pro/anti/neutre/ambigu) 154+pas rejouables pour 2022 (données indisponibles) — ils sont exclus du backtest et bornés
113− avec négation et attaques dirigées; scoreur LLM optionnel (MacLustr) sinon lexique; 155+par construction.
114− regroupement des nouvelles par grappes; anomalies de volume (z ≥ 2,5) → événements. 156+
115−- **Marchés prédictifs** : probabilités implicites Polymarket affichées face au modèle. 157+## 9. Signaux et leur poids (v2)
116−- **Momentum** : Δ vote modèle 14 j + sentiment 7 j, borné [−100, 100]. 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 ## 10. Limites connues 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 statistique complet — modèle d'état latent bayésien, effets maison, simulations Monte Carlo à 10 statistique complet — modèle d'état latent bayésien, effets maison, simulations Monte Carlo à
11 erreurs corrélées, backtesting et calibration. 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 **Production : https://www.qc-election.com** (nœud M2U64, PM2 + ngrok) 22 **Production : https://www.qc-election.com** (nœud M2U64, PM2 + ngrok)
14 23
15 ## Architecture 24 ## Architecture
@@ -24,7 +33,11 @@ backend/
24 ingest/ 33 ingest/
25 wikipedia.py sondages 2026 + 2022, résultats 2022 par circonscription, carte 2025 34 wikipedia.py sondages 2026 + 2022, résultats 2022 par circonscription, carte 2025
26 news_rss.py sentiment médiatique (RSS) : entités → stance → anomalies de volume 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 modeling/ 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 compositions.py transformations log-ratio (alr) + covariance multinomiale 41 compositions.py transformations log-ratio (alr) + covariance multinomiale
29 weights.py pondération : récence, taille effective, design effect, cote maison 42 weights.py pondération : récence, taille effective, design effect, cote maison
30 house_effects.py biais maison rétrécis + cote de qualité dynamique 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 return {"status": "backtest lancé en arrière-plan"} 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 @router.get("/logs", dependencies=[Depends(require_admin)]) 163 @router.get("/logs", dependencies=[Depends(require_admin)])
117 def logs(db: Session = Depends(get_db), limit: int = 100): 164 def logs(db: Session = Depends(get_db), limit: int = 100):
118 rows = (db.query(Mo.PipelineLog).order_by(Mo.PipelineLog.at.desc()).limit(limit).all()) 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 return data 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 @router.get("/polls") 222 @router.get("/polls")
162 def polls(db: Session = Depends(get_db), 223 def polls(db: Session = Depends(get_db),
163 date_from: date | None = None, date_to: date | None = None, 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 for d, v in sorted(daily.get(party, {}).items())], 356 for d, v in sorted(daily.get(party, {}).items())],
296 } 357 }
297 n_docs = len(docs) 358 n_docs = len(docs)
298− return {"note": ("Signal auxiliaire très bruité — n'entre PAS dans le calcul " 359+ return {"note": ("Signal bruité — n'agit sur le forecast que via l'ajustement "
299− "des probabilités électorales."), 360+ f"médias borné (±{settings.media_nudge_pp_max} pp max par parti)."),
300 "documents": n_docs, "parties": out} 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 class Settings(BaseSettings): 19 class Settings(BaseSettings):
20 # --- Général --- 20 # --- Général ---
21 app_name: str = "QC Élection Forecast" 21 app_name: str = "QC Élection Forecast"
22− model_version: str = "1.0.0" 22+ model_version: str = "2.0.0"
23 database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}" 23 database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}"
24 admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me") 24 admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me")
25 port: int = 8140 25 port: int = 8140
@@ -77,7 +77,33 @@ class Settings(BaseSettings):
77 riding_error_sd: float = 0.20 # écart-type propre à la circonscription 77 riding_error_sd: float = 0.20 # écart-type propre à la circonscription
78 incumbent_retirement_penalty: float = 0.06 # log-ratio (~1.3 pp) si le sortant ne se représente pas 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 sentiment_feeds: list[str] = [ 107 sentiment_feeds: list[str] = [
82 "https://ici.radio-canada.ca/rss/92", # Radio-Canada — Politique 108 "https://ici.radio-canada.ca/rss/92", # Radio-Canada — Politique
83 "https://www.ledevoir.com/rss/section/politique.xml", 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 return {"status": "ok", "app": settings.app_name, "version": settings.model_version} 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 "methodologie", "backtest", "admin"] 82 "methodologie", "backtest", "admin"]
83 for page in PAGES: 83 for page in PAGES:
84 path = "/" if page == "index" else f"/{page}" 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 from ..config import DATA_DIR, settings 22 from ..config import DATA_DIR, settings
23 from .. import models as Mo 23 from .. import models as Mo
24 from ..pipeline import load_polls 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 from .simulate import SimulationInput, run_simulation 27 from .simulate import SimulationInput, run_simulation
27 from .trend import fit_trend 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 if trend is None: 74 if trend is None:
74 horizons_report.append({"horizon": h, "error": "sondages insuffisants"}) 75 horizons_report.append({"horizon": h, "error": "sondages insuffisants"})
75 continue 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 inp.x_mean, inp.P = f.x, f.P 87 inp.x_mean, inp.P = f.x, f.P
78 sim = run_simulation(inp, n_sims=n_sims) 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 rmse = float(np.sqrt(np.mean(np.square(errs)))) 93 rmse = float(np.sqrt(np.mean(np.square(errs))))
84 coverage = float(np.mean([ 94 coverage = float(np.mean([
85 f.summary[p]["lo95"] <= RESULT_2022[p] <= f.summary[p]["hi95"] for p in parties])) 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 # --- sièges 99 # --- sièges
88 seat_mae = float(np.mean([abs(sim.seats[p]["mean"] - SEATS_2022[p]) for p in parties])) 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 rep = { 120 rep = {
109 "horizon": h, "as_of": as_of.isoformat(), "n_polls": trend.n_polls, 121 "horizon": h, "as_of": as_of.isoformat(), "n_polls": trend.n_polls,
110 "national": {"mae_pp": round(mae, 2), "rmse_pp": round(rmse, 2), 122 "national": {"mae_pp": round(mae, 2), "rmse_pp": round(rmse, 2),
123+ "mae_pp_sondages_seuls": round(mae_polls_only, 2),
111 "coverage_95": round(coverage, 3), 124 "coverage_95": round(coverage, 3),
112 "forecast": {p: f.summary[p]["mean"] for p in parties}, 125 "forecast": {p: f.summary[p]["mean"] for p in parties},
113 "actual": {p: RESULT_2022[p] for p in parties}}, 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 "observed": round(float(outs[m].mean()), 3), 154 "observed": round(float(outs[m].mean()), 3),
142 "n": int(m.sum())}) 155 "n": int(m.sum())})
143 report = {"election": "Québec 2022", "generated": date.today().isoformat(), 156 report = {"election": "Québec 2022", "generated": date.today().isoformat(),
157+ "model_version": settings.model_version,
144 "note": ("Aucune correction maison appliquée en backtest (pas de " 158 "note": ("Aucune correction maison appliquée en backtest (pas de "
145− "données antérieures à 2018) — le backtest mesure le " 159+ "données antérieures à 2018). v2 : le prior de fondamentaux "
146− "modèle de tendance + simulation seul."), 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 "horizons": horizons_report, "calibration": bins} 165 "horizons": horizons_report, "calibration": bins}
148 (DATA_DIR / "backtest_report.json").write_text( 166 (DATA_DIR / "backtest_report.json").write_text(
149 json.dumps(report, indent=2, ensure_ascii=False)) 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 P_f = trend.P + (drift_var + settings.industry_error_sd ** 2) * np.eye(M) 67 P_f = trend.P + (drift_var + settings.industry_error_sd ** 2) * np.eye(M)
68 return VoteDistribution("forecast", as_of, trend.x, P_f, 68 return VoteDistribution("forecast", as_of, trend.x, P_f,
69 _summarize(trend.x, P_f, rng)) 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 n_eff = effective_n(poll.get("sample_size"), poll.get("mode", "unknown")) 72 n_eff = effective_n(poll.get("sample_size"), poll.get("mode", "unknown"))
73 # la cote du sondeur module la précision effective (jamais > ±40 %) 73 # la cote du sondeur module la précision effective (jamais > ±40 %)
74 n_scaled = n_eff * prof.weight_multiplier 74 n_scaled = n_eff * prof.weight_multiplier
75− # maison sans historique : erreur excédentaire accrue 75+ # maison sans historique : erreur excédentaire accrue; les observations
76− excess = settings.excess_poll_sd_pp * (1.0 if prof.mae_pp is not None else 1.25) 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 R = alr_obs_cov(p_adj, n_scaled, excess_pp=excess) 79 R = alr_obs_cov(p_adj, n_scaled, excess_pp=excess)
78 out.append(PreparedPoll( 80 out.append(PreparedPoll(
79 field_end=poll["field_end"], pollster=poll["pollster"], y=alr(p_adj), 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 detected_by: Mapped[str] = mapped_column(String(30), default="manual") 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 class DataSource(Base): 264 class DataSource(Base):
215 __tablename__ = "data_sources" 265 __tablename__ = "data_sources"
216 id: Mapped[int] = mapped_column(primary_key=True) 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 from .config import settings 17 from .config import settings
18 from .db import SessionLocal, init_db 18 from .db import SessionLocal, init_db
19 from . import models as Mo 19 from . import models as Mo
20+from .modeling import ensemble as ENS
21+from .modeling import fundamentals as FUND
20 from .modeling import house_effects as HE 22 from .modeling import house_effects as HE
21 from .modeling import simulate as SIM 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 from .modeling.trend import fit_trend 25 from .modeling.trend import fit_trend
24 26
25 log = logging.getLogger("pipeline") 27 log = logging.getLogger("pipeline")
@@ -96,25 +98,98 @@ def _district_inputs(db: Session, baseline_national: dict[str, float]) -> SIM.Si
96 def run_forecast(db: Session, as_of: date | None = None, label: str | None = None, 98 def run_forecast(db: Session, as_of: date | None = None, label: str | None = None,
97 is_backtest: bool = False, n_sims: int | None = None, 99 is_backtest: bool = False, n_sims: int | None = None,
98 save: bool = True) -> Mo.ForecastRun | None: 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 as_of = as_of or date.today() 104 as_of = as_of or date.today()
101 target = db.query(Mo.Election).filter_by(is_target=True).one() 105 target = db.query(Mo.Election).filter_by(is_target=True).one()
106+ days_left = (target.election_date - as_of).days
102 profiles = compute_pollster_profiles(db) 107 profiles = compute_pollster_profiles(db)
103 polls = load_polls(db, target.id, as_of=as_of) 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 if trend is None: 116 if trend is None:
106 raise RuntimeError("Pas assez de sondages pour ajuster le modèle") 117 raise RuntimeError("Pas assez de sondages pour ajuster le modèle")
107 118
108 now_d = nc(trend, as_of) 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 result_2022 = (db.query(Mo.Election) 123 result_2022 = (db.query(Mo.Election)
112 .filter(Mo.Election.actual_result.isnot(None)) 124 .filter(Mo.Election.actual_result.isnot(None))
113 .order_by(Mo.Election.election_date.desc()).first().actual_result) 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 inp = _district_inputs(db, result_2022) 142 inp = _district_inputs(db, result_2022)
115 inp.x_mean, inp.P = fc_d.x, fc_d.P 143 inp.x_mean, inp.P = fc_d.x, fc_d.P
116 sim = SIM.run_simulation(inp, n_sims=n_sims) 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 # série lissée compacte pour les graphiques (sous-échantillonnée) 193 # série lissée compacte pour les graphiques (sous-échantillonnée)
119 step = max(1, len(trend.dates) // 400) 194 step = max(1, len(trend.dates) // 400)
120 idx = list(range(0, len(trend.dates), step)) 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 for k, p in enumerate(settings.parties)}, 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 run = Mo.ForecastRun( 218 run = Mo.ForecastRun(
134 as_of=as_of, model_version=settings.model_version, n_polls_used=trend.n_polls, 219 as_of=as_of, model_version=settings.model_version, n_polls_used=trend.n_polls,
135 n_simulations=sim.n_sims, is_backtest=is_backtest, label=label, 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 "trend_series": series, 222 "trend_series": series,
138 "x_forecast": [float(v) for v in fc_d.x], 223 "x_forecast": [float(v) for v in fc_d.x],
139 "P_forecast": [[float(v) for v in row] for row in fc_d.P], 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 seats={"per_party": sim.seats, "summary": sim.seat_matrix_summary, 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 diagnostics={"q_daily_var": trend.q, "loglik": trend.loglik, 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 for d in sim.districts: 235 for d in sim.districts:
146 run.district_results.append(Mo.ForecastDistrictResult( 236 run.district_results.append(Mo.ForecastDistrictResult(
147 district_name=d["district"], favorite=d["favorite"], category=d["category"], 237 district_name=d["district"], favorite=d["favorite"], category=d["category"],
@@ -187,6 +277,22 @@ def run_pipeline(full_refresh: bool = True) -> dict:
187 except Exception as e: 277 except Exception as e:
188 report["sentiment"] = f"échec: {e}" 278 report["sentiment"] = f"échec: {e}"
189 _log(db, "sentiment", "error", traceback.format_exc()) 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 try: 296 try:
191 run = run_forecast(db) 297 run = run_forecast(db)
192 report["forecast_run_id"] = run.id 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 get_or_create(db, Mo.NewsEvent, event_date=d, title=title, 137 get_or_create(db, Mo.NewsEvent, event_date=d, title=title,
138 defaults=dict(kind=kind, importance=imp, detected_by="seed")) 138 defaults=dict(kind=kind, importance=imp, detected_by="seed"))
139 # Version du modèle 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 defaults=dict(changelog="Modèle initial : DLM Kalman (alr), " 141 defaults=dict(changelog="Modèle initial : DLM Kalman (alr), "
142 "house effects 2022, Monte Carlo corrélé.")) 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 # Sources 152 # Sources
144 for name, url, kind in [ 153 for name, url, kind in [
145 ("Wikipédia — sondages 2026", "https://en.wikipedia.org/wiki/2026_Quebec_general_election", "polls"), 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