# 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 """Configuration centrale de la plateforme QC-Élection.""" from __future__ import annotations import os from datetime import date from pathlib import Path from pydantic_settings import BaseSettings BASE_DIR = Path(__file__).resolve().parent.parent # backend/ DATA_DIR = BASE_DIR / "data" FRONTEND_DIR = BASE_DIR.parent / "frontend" def _load_env_file(path: Path) -> None: """Charge backend/.env dans os.environ (sans écraser l'existant) — PM2 ne transmet pas de fichier d'environnement, et les défauts `os.environ.get(...)` ci-dessous (ADMIN_TOKEN, FIRECRAWL_API_KEY…) doivent le voir.""" if not path.exists(): return for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, _, v = line.partition("=") os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) _load_env_file(BASE_DIR / ".env") class Settings(BaseSettings): # --- Général --- app_name: str = "QC Élection Forecast" model_version: str = "3.2.0" database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}" admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me") port: int = 8140 # --- Élection cible --- election_date: date = date(2026, 10, 5) election_name: str = "Élection générale québécoise de 2026" total_seats: int = 127 majority_seats: int = 64 # --- Partis (ordre alphabétique du sigle — neutralité) --- parties: list[str] = ["CAQ", "PCQ", "PLQ", "PQ", "QS", "AUT"] party_names: dict[str, str] = { "CAQ": "Coalition avenir Québec", "PCQ": "Parti conservateur du Québec", "PLQ": "Parti libéral du Québec", "PQ": "Parti québécois", "QS": "Québec solidaire", "AUT": "Autres", } # --- Modèle : pondération des sondages --- # Décroissance temporelle appliquée en plus de la structure Kalman # (utilisée pour les poids affichés et la variance d'observation). time_decay_halflife_days: float = 12.0 max_effective_n: int = 2500 # plafond anti-domination des très gros sondages design_effects: dict[str, float] = { # deff par mode de collecte "web": 1.30, "ivr": 1.40, "phone": 1.20, "sms": 1.45, "mixed": 1.25, "unknown": 1.35, } # Variance excédentaire (erreur non-échantillonnale) ajoutée à chaque sondage, # en points de pourcentage au carré (≈ (1.5 pp)²) — cf. littérature "total survey error". excess_poll_sd_pp: float = 1.5 # --- Modèle : marche aléatoire latente (espace log-ratio) --- # Écart-type quotidien de la marche aléatoire (estimé + borné). rw_daily_sd_min: float = 0.004 rw_daily_sd_max: float = 0.030 # Inflation d'incertitude pour le forecast (opinion future) : multiplicateur # appliqué à la variance de dérive jusqu'au jour du vote (volatilité de campagne). campaign_drift_inflation: float = 1.6 # --- House effects --- house_effect_shrinkage: float = 0.5 # rétrécissement partiel vers 0 house_effect_max_pp: float = 3.0 # borne de correction (pp) # --- Simulation Monte Carlo --- n_simulations: int = 25000 # Erreur systémique de sondage (« industry error ») : les maisons se trompent # ensemble (2022 : CAQ sous-estimée de ~3 pp par toute l'industrie). Couche # corrélée ajoutée au forecast du jour du vote, calibrée sur 2018/2022. industry_error_sd: float = 0.12 # Écarts-types calibrés sur les résidus du backtest 2022 (swing proportionnel # 2018→2022), atténués car 2022 fut un réalignement atypique (émergence PCQ). regional_error_sd: float = 0.14 # écart-type régional (espace log-ratio) riding_error_sd: float = 0.20 # écart-type propre à la circonscription incumbent_retirement_penalty: float = 0.06 # log-ratio (~1.3 pp) si le sortant ne se représente pas # --- v2 « Au-delà des sondages » : couches d'inférence supplémentaires --- # 1) Prior de fondamentaux (satisfaction envers le gouvernement, vote précédent, # usure du pouvoir) — poids qui décroît vers 0 à l'approche du scrutin. fundamentals_enabled: bool = True fundamentals_horizon_days: float = 540.0 # poids plein du prior à ~18 mois fundamentals_max_weight: float = 0.8 # jamais plus de 80 % de la précision fundamentals_term_penalty_pp: float = 1.5 # usure : −pp par mandat au-delà du 1er fundamentals_prior_sd_alr: float = 0.40 # diffusivité du prior (espace alr) gov_satisfaction_fallback: float = 35.0 # % satisfaits (Léger) si rien d'ingéré # 2) Élections partielles — votes réels convertis en observations nationales # bruitées (le swing des partielles surestime le swing général → rétréci). byelections_enabled: bool = True byelection_swing_shrink: float = 0.6 byelection_equivalent_n: int = 350 # taille d'échantillon équivalente byelection_excess_pp: float = 3.0 # erreur excédentaire (participation, local) # 3) Ajustement médias — nudge borné du forecast issu du sentiment de presse. media_nudge_enabled: bool = True media_nudge_pp_max: float = 0.35 # amplitude maximale (pp) par parti media_nudge_min_volume: int = 12 # mentions minimales sur 14 j pour agir # 4) Ensemble avec les marchés prédictifs (probabilité « plus de sièges »). market_blend_weight: float = 0.12 # Veille web continue (Firecrawl) : radar sondages, satisfaction, presse élargie. firecrawl_api_key: str = os.environ.get("FIRECRAWL_API_KEY", "") firecrawl_enabled: bool = True firecrawl_max_docs_per_cycle: int = 40 # Pouls social continu : acteur Apify maison (qc-social-pulse) + Reddit via # index Google (Serper) + Mastodon + Lemmy — poids nul dans le forecast. social_pulse_enabled: bool = True apify_token: str = os.environ.get("APIFY_TOKEN", "") serper_api_key: str = os.environ.get("SERPER_API_KEY", "") # --- v3 « 127 » : feature flags (défauts production CONSERVATEURS) --- # Chaque couche est mesurable, bornée, débrayable, décomposée — règle 1.1. enable_social_volatility: bool = True # attention web+sociale → variance seulement volatility_multiplier_max: float = 1.5 # borne stricte (règle §16) enable_tipping_points: bool = True # circonscriptions pivots (battlegrounds) tipping_n_sims: int = 8000 # sous-échantillon pour le calcul pivot enable_ablation: bool = True # cadre d'ablation (validation §22) enable_social_directional: bool = False # EXPÉRIMENTAL — jamais activé par défaut # σ_industrie APPRIS (LOEO, 6 élections 2007-2022) au lieu du 0,12 manuel. # Validé par replay : log score −3,80 vs −5,33, CRPS 2,64 vs 2,67, # couverture 95 % 83 % vs 76,5 %, EAM inchangée (rapport replay_report.json). use_empirical_industry_error: bool = True # --- Sentiment (signal affiché; n'agit que via l'ajustement médias borné) --- sentiment_feeds: list[str] = [ "https://ici.radio-canada.ca/rss/92", # Radio-Canada — Politique "https://www.ledevoir.com/rss/section/politique.xml", "https://www.lapresse.ca/actualites/politique/rss", ] maclustr_api_key: str = os.environ.get("MACLUSTR_API_KEY", "") maclustr_llm_url: str = "https://llm.maclustr.io/v1/chat/completions" maclustr_llm_model: str = "maclustr-fast" # --- Pipeline --- refresh_interval_hours: float = 6.0 class Config: env_prefix = "QCE_" env_file = ".env" extra = "ignore" settings = Settings()