spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1# QC Élection Forecast — Plateforme de prévision électorale du Québec 20262# Auteur : Simon-Pierre Boucher3# Contact : contact@spboucher.ai4# https://www.qc-election.com5"""Configuration centrale de la plateforme QC-Élection."""6from __future__ import annotations78import os9from datetime import date10from pathlib import Path1112from pydantic_settings import BaseSettings1314BASE_DIR = Path(__file__).resolve().parent.parent # backend/15DATA_DIR = BASE_DIR / "data"16FRONTEND_DIR = BASE_DIR.parent / "frontend"171819def _load_env_file(path: Path) -> None:20 """Charge backend/.env dans os.environ (sans écraser l'existant) — PM2 ne21 transmet pas de fichier d'environnement, et les défauts `os.environ.get(...)`22 ci-dessous (ADMIN_TOKEN, FIRECRAWL_API_KEY…) doivent le voir."""23 if not path.exists():24 return25 for line in path.read_text().splitlines():26 line = line.strip()27 if not line or line.startswith("#") or "=" not in line:28 continue29 k, _, v = line.partition("=")30 os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))313233_load_env_file(BASE_DIR / ".env")343536class Settings(BaseSettings):37 # --- Général ---38 app_name: str = "QC Élection Forecast"39 model_version: str = "3.2.0"40 database_url: str = f"sqlite:///{BASE_DIR / 'qc_election.db'}"41 admin_token: str = os.environ.get("ADMIN_TOKEN", "change-me")42 port: int = 81404344 # --- Élection cible ---45 election_date: date = date(2026, 10, 5)46 election_name: str = "Élection générale québécoise de 2026"47 total_seats: int = 12748 majority_seats: int = 644950 # --- Partis (ordre alphabétique du sigle — neutralité) ---51 parties: list[str] = ["CAQ", "PCQ", "PLQ", "PQ", "QS", "AUT"]52 party_names: dict[str, str] = {53 "CAQ": "Coalition avenir Québec",54 "PCQ": "Parti conservateur du Québec",55 "PLQ": "Parti libéral du Québec",56 "PQ": "Parti québécois",57 "QS": "Québec solidaire",58 "AUT": "Autres",59 }6061 # --- Modèle : pondération des sondages ---62 # Décroissance temporelle appliquée en plus de la structure Kalman63 # (utilisée pour les poids affichés et la variance d'observation).64 time_decay_halflife_days: float = 12.065 max_effective_n: int = 2500 # plafond anti-domination des très gros sondages66 design_effects: dict[str, float] = { # deff par mode de collecte67 "web": 1.30, "ivr": 1.40, "phone": 1.20, "sms": 1.45, "mixed": 1.25, "unknown": 1.35,68 }69 # Variance excédentaire (erreur non-échantillonnale) ajoutée à chaque sondage,70 # en points de pourcentage au carré (≈ (1.5 pp)²) — cf. littérature "total survey error".71 excess_poll_sd_pp: float = 1.57273 # --- Modèle : marche aléatoire latente (espace log-ratio) ---74 # Écart-type quotidien de la marche aléatoire (estimé + borné).75 rw_daily_sd_min: float = 0.00476 rw_daily_sd_max: float = 0.03077 # Inflation d'incertitude pour le forecast (opinion future) : multiplicateur78 # appliqué à la variance de dérive jusqu'au jour du vote (volatilité de campagne).79 campaign_drift_inflation: float = 1.68081 # --- House effects ---82 house_effect_shrinkage: float = 0.5 # rétrécissement partiel vers 083 house_effect_max_pp: float = 3.0 # borne de correction (pp)8485 # --- Simulation Monte Carlo ---86 n_simulations: int = 2500087 # Erreur systémique de sondage (« industry error ») : les maisons se trompent88 # ensemble (2022 : CAQ sous-estimée de ~3 pp par toute l'industrie). Couche89 # corrélée ajoutée au forecast du jour du vote, calibrée sur 2018/2022.90 industry_error_sd: float = 0.1291 # Écarts-types calibrés sur les résidus du backtest 2022 (swing proportionnel92 # 2018→2022), atténués car 2022 fut un réalignement atypique (émergence PCQ).93 regional_error_sd: float = 0.14 # écart-type régional (espace log-ratio)94 riding_error_sd: float = 0.20 # écart-type propre à la circonscription95 incumbent_retirement_penalty: float = 0.06 # log-ratio (~1.3 pp) si le sortant ne se représente pas9697 # --- v2 « Au-delà des sondages » : couches d'inférence supplémentaires ---98 # 1) Prior de fondamentaux (satisfaction envers le gouvernement, vote précédent,99 # usure du pouvoir) — poids qui décroît vers 0 à l'approche du scrutin.100 fundamentals_enabled: bool = True101 fundamentals_horizon_days: float = 540.0 # poids plein du prior à ~18 mois102 fundamentals_max_weight: float = 0.8 # jamais plus de 80 % de la précision103 fundamentals_term_penalty_pp: float = 1.5 # usure : −pp par mandat au-delà du 1er104 fundamentals_prior_sd_alr: float = 0.40 # diffusivité du prior (espace alr)105 gov_satisfaction_fallback: float = 35.0 # % satisfaits (Léger) si rien d'ingéré106 # 2) Élections partielles — votes réels convertis en observations nationales107 # bruitées (le swing des partielles surestime le swing général → rétréci).108 byelections_enabled: bool = True109 byelection_swing_shrink: float = 0.6110 byelection_equivalent_n: int = 350 # taille d'échantillon équivalente111 byelection_excess_pp: float = 3.0 # erreur excédentaire (participation, local)112 # 3) Ajustement médias — nudge borné du forecast issu du sentiment de presse.113 media_nudge_enabled: bool = True114 media_nudge_pp_max: float = 0.35 # amplitude maximale (pp) par parti115 media_nudge_min_volume: int = 12 # mentions minimales sur 14 j pour agir116 # 4) Ensemble avec les marchés prédictifs (probabilité « plus de sièges »).117 market_blend_weight: float = 0.12118 # Veille web continue (Firecrawl) : radar sondages, satisfaction, presse élargie.119 firecrawl_api_key: str = os.environ.get("FIRECRAWL_API_KEY", "")120 firecrawl_enabled: bool = True121 firecrawl_max_docs_per_cycle: int = 40122 # Pouls social continu : acteur Apify maison (qc-social-pulse) + Reddit via123 # index Google (Serper) + Mastodon + Lemmy — poids nul dans le forecast.124 social_pulse_enabled: bool = True125 apify_token: str = os.environ.get("APIFY_TOKEN", "")126 serper_api_key: str = os.environ.get("SERPER_API_KEY", "")127128 # --- v3 « 127 » : feature flags (défauts production CONSERVATEURS) ---129 # Chaque couche est mesurable, bornée, débrayable, décomposée — règle 1.1.130 enable_social_volatility: bool = True # attention web+sociale → variance seulement131 volatility_multiplier_max: float = 1.5 # borne stricte (règle §16)132 enable_tipping_points: bool = True # circonscriptions pivots (battlegrounds)133 tipping_n_sims: int = 8000 # sous-échantillon pour le calcul pivot134 enable_ablation: bool = True # cadre d'ablation (validation §22)135 enable_social_directional: bool = False # EXPÉRIMENTAL — jamais activé par défaut136 # σ_industrie APPRIS (LOEO, 6 élections 2007-2022) au lieu du 0,12 manuel.137 # Validé par replay : log score −3,80 vs −5,33, CRPS 2,64 vs 2,67,138 # couverture 95 % 83 % vs 76,5 %, EAM inchangée (rapport replay_report.json).139 use_empirical_industry_error: bool = True140141 # --- Sentiment (signal affiché; n'agit que via l'ajustement médias borné) ---142 sentiment_feeds: list[str] = [143 "https://ici.radio-canada.ca/rss/92", # Radio-Canada — Politique144 "https://www.ledevoir.com/rss/section/politique.xml",145 "https://www.lapresse.ca/actualites/politique/rss",146 ]147 maclustr_api_key: str = os.environ.get("MACLUSTR_API_KEY", "")148 maclustr_llm_url: str = "https://llm.maclustr.io/v1/chat/completions"149 maclustr_llm_model: str = "maclustr-fast"150151 # --- Pipeline ---152 refresh_interval_hours: float = 6.0153154 class Config:155 env_prefix = "QCE_"156 env_file = ".env"157 extra = "ignore"158159160settings = Settings()161