SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
7.8 KB · 161 lines python
Raw Blame History
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