# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Élections partielles 2022-2026 — les seuls VOTES RÉELS disponibles avant le scrutin général (v2 « au-delà des sondages »). Chaque partielle est convertie en observation nationale bruitée : alr_national_implicite = alr(national 2022) + rétrécissement × [ alr(résultat partielle) − alr(résultat 2022 local) ] Le « swing » local observé est rétréci (défaut 0,6) car la littérature montre que les partielles EXAGÈRENT le mouvement national (participation faible, vote protestataire, absence d'enjeu de gouvernement). L'observation reçoit une grande variance (n équivalent ~350, erreur excédentaire ±3 pp) : elle informe le filtre de Kalman sans jamais le dominer. Contrairement à un sondage, c'est un bulletin de vote compté — la seule vérité de terrain d'ici le 5 octobre. Les résultats embarqués proviennent du DGEQ (recopiés; `verified=False` tant que la vérification Firecrawl n'a pas confirmé les pourcentages en ligne). """ from __future__ import annotations import logging import re import unicodedata from datetime import date import numpy as np import pandas as pd from sqlalchemy.orm import Session from ..config import DATA_DIR, settings from .. import models as Mo from ..modeling.compositions import alr, close, inv_alr log = logging.getLogger("byelections") RESULT_2022 = {"CAQ": 40.98, "PLQ": 14.37, "QS": 15.43, "PQ": 14.61, "PCQ": 12.91, "AUT": 1.70} # Résultats des partielles (DGEQ). AUT = candidats restants. BYELECTIONS = [ {"district": "Saint-Henri-Sainte-Anne", "held_on": date(2023, 3, 13), "winner": "QS", "previous_winner": "PLQ", "turnout": 35.7, "result": {"QS": 44.2, "PLQ": 21.4, "PQ": 15.7, "CAQ": 8.8, "PCQ": 3.4, "AUT": 6.5}, "source_url": "https://fr.wikipedia.org/wiki/Saint-Henri%E2%80%93Sainte-Anne", "notes": "Démission de Dominique Anglade — gain QS (Guillaume Cliche-Rivard)."}, {"district": "Jean-Talon", "held_on": date(2023, 10, 2), "winner": "PQ", "previous_winner": "CAQ", "turnout": 52.4, "result": {"PQ": 44.0, "CAQ": 20.3, "QS": 13.0, "PCQ": 9.1, "PLQ": 8.9, "AUT": 4.7}, "source_url": "https://fr.wikipedia.org/wiki/Jean-Talon_(circonscription)", "notes": "Démission de Joëlle Boutin — gain PQ (Pascal Paradis), premier signal du réalignement."}, {"district": "Terrebonne", "held_on": date(2025, 3, 17), "winner": "PQ", "previous_winner": "CAQ", "turnout": 37.9, "result": {"PQ": 46.5, "CAQ": 18.5, "PLQ": 15.5, "PCQ": 9.0, "QS": 5.5, "AUT": 5.0}, "source_url": "https://fr.wikipedia.org/wiki/Terrebonne_(circonscription)", "notes": "Gain PQ (Catherine Gentilcore) dans un château fort caquiste de 2022."}, {"district": "Arthabaska", "held_on": date(2025, 8, 11), "winner": "PQ", "previous_winner": "CAQ", "turnout": 56.2, "result": {"PQ": 46.5, "PCQ": 35.0, "CAQ": 7.0, "PLQ": 5.0, "QS": 1.5, "AUT": 5.0}, "source_url": "https://fr.wikipedia.org/wiki/Arthabaska_(circonscription)", "notes": "Gain PQ (Alex Boissonneault) devant Éric Duhaime (PCQ); effondrement CAQ."}, ] def _norm(s: str) -> str: s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode() return re.sub(r"[^a-z]", "", s.lower()) def _riding_2022(district: str) -> dict[str, float] | None: """Résultat 2022 de la circonscription (carte 2022) — depuis le CSV canonique.""" f = DATA_DIR / "riding_results_2022.csv" if not f.exists(): return None df = pd.read_csv(f) key = _norm(district) for _, r in df.iterrows(): if _norm(str(r["district"])) == key or key in _norm(str(r["district"])): return {p: float(r[p]) for p in settings.parties if p in r} return None def ensure_byelections(db: Session) -> int: """Upsert des partielles embarquées (idempotent, n'écrase pas une ligne vérifiée).""" added = 0 for b in BYELECTIONS: row = (db.query(Mo.ByElection) .filter_by(district_name=b["district"], held_on=b["held_on"]).first()) if row is None: row = Mo.ByElection(district_name=b["district"], held_on=b["held_on"], result=b["result"], turnout=b["turnout"], winner=b["winner"], previous_winner=b["previous_winner"], source_url=b["source_url"], notes=b["notes"]) db.add(row) added += 1 elif not row.verified: row.result = b["result"]; row.turnout = b["turnout"] row.source_url = b["source_url"]; row.notes = b["notes"] db.commit() return added def verify_with_firecrawl(db: Session) -> dict: """Vérification best-effort des pourcentages en ligne (Wikipédia via Firecrawl). Ne met à jour une partielle que si ≥5 partis sont extraits et que la somme est plausible; sinon les valeurs embarquées restent.""" from .firecrawl_watch import scrape_markdown report = {} aliases = {"CAQ": ["coalition avenir"], "PLQ": ["libéral"], "PQ": ["parti québécois"], "QS": ["québec solidaire"], "PCQ": ["conservateur"]} for row in db.query(Mo.ByElection).filter_by(verified=False).all(): if not row.source_url: continue md = scrape_markdown(row.source_url) if not md: report[row.district_name] = "scrape échoué" continue # fenêtre de texte autour de la date de la partielle year = str(row.held_on.year) idx = md.lower().find(f"partielle") window = md[max(0, idx - 500):idx + 6000] if idx >= 0 else md if year not in window: report[row.district_name] = "section partielle introuvable" continue found = {} low = window.lower() for party, keys in aliases.items(): for k in keys: for m in re.finditer(re.escape(k), low): seg = window[m.end():m.end() + 120] pm = re.search(r"(\d{1,2}[.,]\d{1,2})\s*%", seg) if pm: v = float(pm.group(1).replace(",", ".")) if 0.1 <= v <= 75: found[party] = v break if party in found: break if len(found) >= 5 and 80 <= sum(found.values()) <= 105: found["AUT"] = max(0.5, round(100 - sum(found.values()), 2)) row.result = found row.verified = True report[row.district_name] = f"vérifié: {found}" else: report[row.district_name] = f"extraction incomplète ({len(found)} partis)" db.commit() return report def pseudo_polls(db: Session, as_of: date | None = None) -> list[dict]: """Partielles → observations nationales implicites, au format « sondage » consommé par le filtre de Kalman (avec erreur excédentaire dédiée).""" if not settings.byelections_enabled: return [] out = [] nat = close(np.array([RESULT_2022[p] for p in settings.parties]) / 100.0) for row in db.query(Mo.ByElection).filter_by(used_in_model=True).all(): if as_of and row.held_on > as_of: continue base = _riding_2022(row.district_name) if base is None: log.warning("partielle %s : baseline 2022 introuvable", row.district_name) continue b = close(np.array([base.get(p, 0.5) for p in settings.parties]) / 100.0) r = close(np.array([row.result.get(p, 0.5) for p in settings.parties]) / 100.0) implied = inv_alr(alr(nat) + settings.byelection_swing_shrink * (alr(r) - alr(b))) out.append({ "id": None, "pollster": f"Vote réel — {row.district_name}", "field_end": row.held_on, "sample_size": settings.byelection_equivalent_n, "mode": "byelection", "excess_pp": settings.byelection_excess_pp, "is_byelection": True, "shares": {p: round(float(implied[i] * 100), 2) for i, p in enumerate(settings.parties)}, }) return out