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>
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