QC26 2.0.0 — au-delà des sondages + refonte visuelle
Moteur : signaux (Polymarket, Wikipédia, presse Serper+LLM, GDELT, questions de leadership, partielles DGEQ), ensemble (prior fondamentaux, momentum borné, tempérage par repondération IPF vers les marchés, ajustement partielles), statistiques pondérées, summary.components (sondages seuls / sans marchés / ensemble), API /api/signals*, /api/forecast/components, jobs planifiés, admin signals/refresh, changelog 2.0.0. Correctifs : sortants depuis la liste DGEQ des candidatures (5 circonscriptions de partielles erronées), chemins d'archive relocalisables, horodatages UTC explicites (TZDateTime). Web : design system clair/sombre (Fraunces, Inter, JetBrains Mono, accent sarcelle), couleurs de partis en variables CSS, héros éditorial avec gaufre des 127 sièges, comparaison sondages/marchés/ensemble, page /signaux, tendance interactive, refonte nav/mobile/footer, méthodologie v2. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
70 changed files +3,279 −634
modified
CLAUDE.md
+7 −2
@@ -12,11 +12,16 @@ Spécification produit complète : voir la mission QC26 (design « Editorial Dat | ||
| 12 | 12 | ## Où sont les choses |
| 13 | 13 | - Moteur : `engine/` (FastAPI :8201). Base SQLite `engine/data/qc26.db` (WAL). Archives `engine/data/archive/{polls,documents,live}` par SHA-256. Copies officielles DGEQ `engine/data/official/`. |
| 14 | 14 | - Web : `web/` (Next 16, :8200), rewrite `/api/*` → moteur. Composants du design system dans `web/src/components/`. |
| 15 | −- Modèle : `engine/qc26/model/` (aggregate = poids/effets maison/Kalman ; ridings = swing proportionnel + erreurs ; run = orchestration, diff, impacts). Changer un paramètre = bump `model_version` + entrée `CHANGELOG` dans `api/public.py`. | |
| 15 | +- Modèle : `engine/qc26/model/` (aggregate = poids/effets maison/Kalman ; ridings = swing proportionnel + erreurs ; ensemble = v2 : prior fondamentaux, momentum borné, tempérage par les marchés via repondération IPF des simulations, ajustement partielles ; run = orchestration, stats pondérées, `summary.components`, diff). Changer un paramètre = bump `model_version` + entrée `CHANGELOG` dans `api/public.py`. | |
| 16 | +- Signaux (v2) : `engine/qc26/signals/` — markets (Polymarket Gamma/CLOB), attention (Wikimedia pageviews), media (Serper news + classement LLM ; GDELT best-effort, 1 req/6,5 s), fundamentals (questions de leadership des sondages → satisfaction, meilleur PM, favorabilité…), byelections (DGEQ via Firecrawl, code URL = code DGEQ carte 2017). Tables `signals`, `markets`, `market_prices`, `media_items`, `by_elections`. API `/api/signals*`, `/api/forecast/components`, admin `POST /api/admin/signals/refresh?what=all`. Page `/signaux`. | |
| 17 | +- Design : tokens clair/sombre dans `web/src/app/globals.css` (`data-theme`, script inline `THEME_INIT_SCRIPT`), Fraunces (display) + Inter + JetBrains Mono ; couleurs de partis via `partyVar()/partyAlpha()` (variables CSS `--p-*`, éclaircies en sombre) — `partyColor()` (hex) réservé aux images OG. | |
| 16 | 18 | - Soirée électorale : `engine/qc26/live/` (feed officiel, simulation 2022, nowcast). `POST /api/admin/live/simulation/start` rejoue 2022 en accéléré pour tester la chaîne complète. |
| 17 | 19 | |
| 18 | 20 | ## Gotchas |
| 19 | −- SQLite : ne jamais tenir une transaction ouverte pendant un appel LLM (commits explicites dans les connecteurs) ; timeout 90 s. | |
| 21 | +- SQLite : ne jamais tenir une transaction ouverte pendant un appel LLM (commits explicites dans les connecteurs) ; timeout 90 s. Les `DateTime` passent par `TZDateTime` (UTC explicite à la relecture) : toujours `.isoformat()` sur des objets tz-aware. | |
| 22 | +- Chemins d'archive stockés absolus : lire via `util.resolve_archive_path()` (la base a été créée sur le laptop puis déplacée). | |
| 23 | +- Sortant·e = drapeau `depute_sortant` de la liste DGEQ des candidatures, puis gagnant de partielle, puis gagnant 2022 transposé (jamais l'inverse). | |
| 24 | +- Fraunces via `next/font/google` : `axes` seulement sans `weight` (police variable). | |
| 20 | 25 | - DGEQ : « PCQ » = Parti communiste, le Parti conservateur est « PCOQ » ; CSV en cp1252 avec tiret 0x96 (`util.fix_dgeq_dash`). |
| 21 | 26 | - Wikipédia n'est qu'un index d'amorçage (tier 5) : les lignes `secondary-index` sont remplacées par le PDF de la firme dès qu'il est archivé ; les URL médias (La Presse, L'actualité, X…) ne sont pas des documents primaires. |
| 22 | 27 | - La règle « pas de candidature → part résiduelle » ne s'active que pour un parti dont la liste est ≥ 90 % complète. |
modified
README.md
+5 −0
@@ -50,3 +50,8 @@ Le premier démarrage sur base vide télécharge les données officielles, amorc | ||
| 50 | 50 | ## Production |
| 51 | 51 | |
| 52 | 52 | Déployé par `mld` (passerelle M1M32) : `mld stage <dir> qc26` → `mld deploy qc26`. Deux processus PM2 (`qc26-engine` :8201, `qc26-web` :8200) + `qc26-ngrok` (www.qc26.ca → 8200). Le web réécrit `/api/*` vers le moteur. |
| 53 | + | |
| 54 | + | |
| 55 | +## Modèle 2.0 (2026-09-07) — au-delà des sondages | |
| 56 | + | |
| 57 | +Quatre familles de signaux indépendants, archivés avec leur provenance et publiés séparément (`/signaux`, `summary.components`) : marchés de prédiction Polymarket (repondération des simulations, poids 30 %), fondamentaux (satisfaction du gouvernement → prior sur le parti sortant, poids décroissant vers 0 le jour du vote), momentum borné (prime au chef, attention Wikipédia, ton de presse classé par LLM), élections partielles depuis 2022 (excès de swing local). La vue « sondages seuls » reste publiée. Refonte visuelle « Editorial Data Intelligence » : thème clair/sombre, Fraunces + Inter + JetBrains Mono, gaufre des 127 sièges, tendance interactive. | |
modified
deploy/qc26.manifest.json
+125 −123
@@ -1,127 +1,129 @@ | ||
| 1 | 1 | { |
| 2 | − "app": "qc26", | |
| 3 | − "label": "QC26 — L'élection québécoise 2026, en données", | |
| 4 | − "domain": "www.qc26.ca", | |
| 5 | − "port": 8200, | |
| 6 | − "health_path": "/healthz", | |
| 7 | − "dir": "~/apps/qc26", | |
| 8 | − "extra_paths": [], | |
| 9 | − "sync_excludes": [ | |
| 10 | − "engine/.venv/", | |
| 11 | − "__pycache__/", | |
| 12 | − ".pytest_cache/", | |
| 13 | − ".ruff_cache/", | |
| 14 | − "*.egg-info/", | |
| 15 | − ".git/", | |
| 16 | − "web/node_modules/", | |
| 17 | − "web/.next/", | |
| 18 | − "engine/logs/", | |
| 19 | − "logs/", | |
| 20 | − ".DS_Store", | |
| 21 | − "engine/data/qc26.db", | |
| 22 | − "engine/data/qc26.db-shm", | |
| 23 | − "engine/data/qc26.db-wal", | |
| 24 | − "engine/data/archive/", | |
| 25 | − "engine/data/cache/" | |
| 2 | + "app": "qc26", | |
| 3 | + "label": "QC26 — L'élection québécoise 2026, en données", | |
| 4 | + "domain": "www.qc26.ca", | |
| 5 | + "port": 8200, | |
| 6 | + "health_path": "/healthz", | |
| 7 | + "dir": "~/apps/qc26", | |
| 8 | + "extra_paths": [], | |
| 9 | + "sync_excludes": [ | |
| 10 | + "engine/.venv/", | |
| 11 | + "__pycache__/", | |
| 12 | + ".pytest_cache/", | |
| 13 | + ".ruff_cache/", | |
| 14 | + "*.egg-info/", | |
| 15 | + ".git/", | |
| 16 | + "web/node_modules/", | |
| 17 | + "web/.next/", | |
| 18 | + "engine/logs/", | |
| 19 | + "logs/", | |
| 20 | + ".DS_Store", | |
| 21 | + "engine/data/qc26.db", | |
| 22 | + "engine/data/qc26.db-shm", | |
| 23 | + "engine/data/qc26.db-wal", | |
| 24 | + "engine/data/archive/", | |
| 25 | + "engine/data/cache/" | |
| 26 | + ], | |
| 27 | + "requires": { | |
| 28 | + "runtimes": [ | |
| 29 | + "pm2", | |
| 30 | + "ngrok", | |
| 31 | + "node", | |
| 32 | + "pnpm", | |
| 33 | + "uv", | |
| 34 | + "uv-python@3.12" | |
| 26 | 35 | ], |
| 27 | − "requires": { | |
| 28 | − "runtimes": [ | |
| 29 | − "pm2", | |
| 30 | − "ngrok", | |
| 31 | − "node", | |
| 32 | − "pnpm", | |
| 33 | − "uv", | |
| 34 | − "uv-python@3.12" | |
| 35 | − ], | |
| 36 | − "ram_gb": 4, | |
| 37 | − "ports": [ | |
| 38 | − 8200, | |
| 39 | − 8201 | |
| 40 | − ] | |
| 41 | − }, | |
| 42 | − "ram_mb_observed": 1200, | |
| 43 | − "size_mb": 120, | |
| 44 | − "placement": { | |
| 45 | − "pin": null, | |
| 46 | − "prefer": "M3U96a", | |
| 47 | − "avoid": [ | |
| 48 | − "M3U96b", | |
| 49 | − "M1M32" | |
| 50 | − ], | |
| 51 | − "reason": "Monte Carlo numpy + build Next.js : cœurs et RAM ; uv-py3.12 + pnpm présents ; M3U96b réservé hfmarketdata" | |
| 52 | − }, | |
| 53 | − "processes": [ | |
| 54 | − { | |
| 55 | − "name": "qc26-engine", | |
| 56 | − "manager": "pm2", | |
| 57 | − "script": "{{HOME}}/apps/qc26/engine/.venv/bin/python", | |
| 58 | − "args": [ | |
| 59 | − "-m", | |
| 60 | − "uvicorn", | |
| 61 | − "qc26.main:app", | |
| 62 | − "--host", | |
| 63 | − "127.0.0.1", | |
| 64 | − "--port", | |
| 65 | − "8201", | |
| 66 | − "--no-access-log" | |
| 67 | − ], | |
| 68 | − "interpreter": null, | |
| 69 | − "cwd": "{{HOME}}/apps/qc26/engine", | |
| 70 | − "env": { | |
| 71 | − "QC26_ENVIRONMENT": "production", | |
| 72 | − "QC26_PORT": "8201", | |
| 73 | − "QC26_PUBLIC_URL": "https://www.qc26.ca", | |
| 74 | − "QC26_ADMIN_TOKEN": "qc26-admin-2026-Kp8vT3nQ7zLw", | |
| 75 | − "QC26_FIRECRAWL_API_KEY": "fc-8cc44cb683044aee82e8cbad6505a01a", | |
| 76 | − "QC26_ANTHROPIC_API_KEY": "__SET_AT_DEPLOY__", | |
| 77 | − "QC26_LLM_MODEL": "claude-opus-5", | |
| 78 | − "QC26_N_SIMULATIONS": "20000", | |
| 79 | − "PYTHONUNBUFFERED": "1" | |
| 80 | − }, | |
| 81 | − "cron_restart": null, | |
| 82 | − "autorestart": true, | |
| 83 | − "max_memory_restart": "3G" | |
| 84 | − }, | |
| 85 | − { | |
| 86 | − "name": "qc26-web", | |
| 87 | − "manager": "pm2", | |
| 88 | − "script": "/opt/homebrew/bin/node", | |
| 89 | − "args": [ | |
| 90 | − "node_modules/next/dist/bin/next", | |
| 91 | − "start", | |
| 92 | − "-p", | |
| 93 | − "8200", | |
| 94 | − "-H", | |
| 95 | − "0.0.0.0" | |
| 96 | − ], | |
| 97 | − "interpreter": null, | |
| 98 | − "cwd": "{{HOME}}/apps/qc26/web", | |
| 99 | − "env": { | |
| 100 | − "NODE_ENV": "production", | |
| 101 | − "QC26_ENGINE_URL": "http://127.0.0.1:8201", | |
| 102 | − "NEXT_PUBLIC_SITE_URL": "https://www.qc26.ca", | |
| 103 | − "NEXT_TELEMETRY_DISABLED": "1" | |
| 104 | − }, | |
| 105 | − "cron_restart": null, | |
| 106 | − "autorestart": true, | |
| 107 | − "max_memory_restart": "1500M" | |
| 108 | − } | |
| 36 | + "ram_gb": 4, | |
| 37 | + "ports": [ | |
| 38 | + 8200, | |
| 39 | + 8201 | |
| 40 | + ] | |
| 41 | + }, | |
| 42 | + "ram_mb_observed": 1200, | |
| 43 | + "size_mb": 120, | |
| 44 | + "placement": { | |
| 45 | + "pin": null, | |
| 46 | + "prefer": "M3U96a", | |
| 47 | + "avoid": [ | |
| 48 | + "M3U96b", | |
| 49 | + "M1M32" | |
| 109 | 50 | ], |
| 110 | − "ngrok": { | |
| 111 | − "name": "qc26-ngrok", | |
| 112 | − "url": "www.qc26.ca", | |
| 113 | − "port": 8200 | |
| 51 | + "reason": "Monte Carlo numpy + build Next.js : cœurs et RAM ; uv-py3.12 + pnpm présents ; M3U96b réservé hfmarketdata" | |
| 52 | + }, | |
| 53 | + "processes": [ | |
| 54 | + { | |
| 55 | + "name": "qc26-engine", | |
| 56 | + "manager": "pm2", | |
| 57 | + "script": "{{HOME}}/apps/qc26/engine/.venv/bin/python", | |
| 58 | + "args": [ | |
| 59 | + "-m", | |
| 60 | + "uvicorn", | |
| 61 | + "qc26.main:app", | |
| 62 | + "--host", | |
| 63 | + "127.0.0.1", | |
| 64 | + "--port", | |
| 65 | + "8201", | |
| 66 | + "--no-access-log" | |
| 67 | + ], | |
| 68 | + "interpreter": null, | |
| 69 | + "cwd": "{{HOME}}/apps/qc26/engine", | |
| 70 | + "env": { | |
| 71 | + "QC26_ENVIRONMENT": "production", | |
| 72 | + "QC26_PORT": "8201", | |
| 73 | + "QC26_PUBLIC_URL": "https://www.qc26.ca", | |
| 74 | + "QC26_ADMIN_TOKEN": "qc26-admin-2026-Kp8vT3nQ7zLw", | |
| 75 | + "QC26_FIRECRAWL_API_KEY": "fc-8cc44cb683044aee82e8cbad6505a01a", | |
| 76 | + "QC26_ANTHROPIC_API_KEY": "__SET_AT_DEPLOY__", | |
| 77 | + "QC26_LLM_MODEL": "claude-opus-5", | |
| 78 | + "QC26_N_SIMULATIONS": "20000", | |
| 79 | + "PYTHONUNBUFFERED": "1", | |
| 80 | + "QC26_SERPER_API_KEY": "__SET_AT_DEPLOY__", | |
| 81 | + "QC26_TAVILY_API_KEY": "__SET_AT_DEPLOY__" | |
| 82 | + }, | |
| 83 | + "cron_restart": null, | |
| 84 | + "autorestart": true, | |
| 85 | + "max_memory_restart": "3G" | |
| 114 | 86 | }, |
| 115 | − "launchd": [], | |
| 116 | − "env_overrides": {}, | |
| 117 | − "hooks": { | |
| 118 | − "post_sync": [ | |
| 119 | − "export PATH=\"$HOME/.local/bin:/opt/homebrew/bin:$PATH\"; cd engine && (test -x .venv/bin/python || uv venv --python 3.12 .venv) && uv pip install -q --python .venv/bin/python -e . && echo ' engine deps ok'", | |
| 120 | − "export PATH=\"/opt/homebrew/bin:$PATH\"; cd web && pnpm install --frozen-lockfile --silent && echo ' web deps ok'", | |
| 121 | − "export PATH=\"/opt/homebrew/bin:$PATH\"; cd web && QC26_ENGINE_URL=http://127.0.0.1:8201 NEXT_PUBLIC_SITE_URL=https://www.qc26.ca NEXT_TELEMETRY_DISABLED=1 pnpm build 2>&1 | tail -3 && echo ' web build ok'", | |
| 122 | − "mkdir -p engine/data/archive/polls engine/data/archive/documents engine/data/archive/live engine/data/cache engine/logs && echo ' data ok'" | |
| 123 | − ], | |
| 124 | − "post_start": [] | |
| 125 | − }, | |
| 126 | − "notes": "Après le premier déploiement (2026-09-06), la base et les archives du nœud sont la source de vérité : exclues des syncs suivants." | |
| 127 | −} | |
| \ No newline at end of file | ||
| 87 | + { | |
| 88 | + "name": "qc26-web", | |
| 89 | + "manager": "pm2", | |
| 90 | + "script": "/opt/homebrew/bin/node", | |
| 91 | + "args": [ | |
| 92 | + "node_modules/next/dist/bin/next", | |
| 93 | + "start", | |
| 94 | + "-p", | |
| 95 | + "8200", | |
| 96 | + "-H", | |
| 97 | + "0.0.0.0" | |
| 98 | + ], | |
| 99 | + "interpreter": null, | |
| 100 | + "cwd": "{{HOME}}/apps/qc26/web", | |
| 101 | + "env": { | |
| 102 | + "NODE_ENV": "production", | |
| 103 | + "QC26_ENGINE_URL": "http://127.0.0.1:8201", | |
| 104 | + "NEXT_PUBLIC_SITE_URL": "https://www.qc26.ca", | |
| 105 | + "NEXT_TELEMETRY_DISABLED": "1" | |
| 106 | + }, | |
| 107 | + "cron_restart": null, | |
| 108 | + "autorestart": true, | |
| 109 | + "max_memory_restart": "1500M" | |
| 110 | + } | |
| 111 | + ], | |
| 112 | + "ngrok": { | |
| 113 | + "name": "qc26-ngrok", | |
| 114 | + "url": "www.qc26.ca", | |
| 115 | + "port": 8200 | |
| 116 | + }, | |
| 117 | + "launchd": [], | |
| 118 | + "env_overrides": {}, | |
| 119 | + "hooks": { | |
| 120 | + "post_sync": [ | |
| 121 | + "export PATH=\"$HOME/.local/bin:/opt/homebrew/bin:$PATH\"; cd engine && (test -x .venv/bin/python || uv venv --python 3.12 .venv) && uv pip install -q --python .venv/bin/python -e . && echo ' engine deps ok'", | |
| 122 | + "export PATH=\"/opt/homebrew/bin:$PATH\"; cd web && pnpm install --frozen-lockfile --silent && echo ' web deps ok'", | |
| 123 | + "export PATH=\"/opt/homebrew/bin:$PATH\"; cd web && QC26_ENGINE_URL=http://127.0.0.1:8201 NEXT_PUBLIC_SITE_URL=https://www.qc26.ca NEXT_TELEMETRY_DISABLED=1 pnpm build 2>&1 | tail -3 && echo ' web build ok'", | |
| 124 | + "mkdir -p engine/data/archive/polls engine/data/archive/documents engine/data/archive/live engine/data/cache engine/logs && echo ' data ok'" | |
| 125 | + ], | |
| 126 | + "post_start": [] | |
| 127 | + }, | |
| 128 | + "notes": "Après le premier déploiement (2026-09-06), la base et les archives du nœud sont la source de vérité : exclues des syncs suivants. v2.0 (2026-09-07) : clés Serper/Tavily requises pour la revue de presse ; les tables signaux sont créées au démarrage (create_all)." | |
| 129 | +} | |
modified
engine/qc26/api/admin.py
+26 −0
@@ -110,6 +110,32 @@ def compass_positions(only_missing: bool = True, s: Session = Depends(db)): | ||
| 110 | 110 | return positions.compute_positions(s, only_missing=only_missing) |
| 111 | 111 | |
| 112 | 112 | |
| 113 | +@router.post("/signals/refresh", dependencies=[Depends(require_admin)]) | |
| 114 | +def signals_refresh(what: str = "all", run: bool = True, s: Session = Depends(db)): | |
| 115 | + """Rafraîchit les signaux (markets | attention | fundamentals | byelections | news | gdelt | all) puis recalcule.""" | |
| 116 | + from ..ingest import official | |
| 117 | + from ..signals import attention, byelections, fundamentals, markets, media | |
| 118 | + out = {} | |
| 119 | + parts = [x.strip() for x in what.split(",")] if what != "all" else ["markets", "attention", "fundamentals", "byelections", "news"] | |
| 120 | + if "markets" in parts: | |
| 121 | + out["markets"] = markets.sync_markets(s); s.commit() | |
| 122 | + if "attention" in parts: | |
| 123 | + out["attention"] = attention.sync_attention(s); s.commit() | |
| 124 | + if "fundamentals" in parts: | |
| 125 | + out["fundamentals"] = fundamentals.extract_from_polls(s); s.commit() | |
| 126 | + if "byelections" in parts: | |
| 127 | + out["byelections"] = byelections.sync_by_elections(s); official.apply_by_election_incumbents(s); s.commit() | |
| 128 | + if "news" in parts: | |
| 129 | + out["news"] = media.sync_news(s, classify=True); s.commit() | |
| 130 | + if "gdelt" in parts: | |
| 131 | + out["gdelt"] = media.sync_gdelt(s); s.commit() | |
| 132 | + if run: | |
| 133 | + from ..model.run import run_forecast | |
| 134 | + r = run_forecast(s, trigger="admin:signals") | |
| 135 | + out["run"] = r.id | |
| 136 | + return out | |
| 137 | + | |
| 138 | + | |
| 113 | 139 | @router.get("/review", dependencies=[Depends(require_admin)]) |
| 114 | 140 | def review_queue(s: Session = Depends(db)): |
| 115 | 141 | polls = s.scalars(select(Poll).where(Poll.validated == False).order_by(desc(Poll.fieldwork_end))).all() # noqa: E712 |
modified
engine/qc26/api/public.py
+74 −4
@@ -389,7 +389,11 @@ def poll_document_file(sha: str, s: Session = Depends(db)): | ||
| 389 | 389 | doc = s.scalar(select(PollDocument).where(PollDocument.sha256 == sha)) |
| 390 | 390 | if not doc: |
| 391 | 391 | raise HTTPException(404) |
| 392 | − return FileResponse(doc.path, media_type=doc.content_type or "application/octet-stream", filename=f"qc26-archive-{sha[:12]}{'.pdf' if doc.path.endswith('.pdf') else '.html'}") | |
| 392 | + from ..util import resolve_archive_path | |
| 393 | + fp = resolve_archive_path(doc.path) | |
| 394 | + if not fp or not fp.exists(): | |
| 395 | + raise HTTPException(404, "fichier d'archive introuvable") | |
| 396 | + return FileResponse(str(fp), media_type=doc.content_type or "application/octet-stream", filename=f"qc26-archive-{sha[:12]}{'.pdf' if doc.path.endswith('.pdf') else '.html'}") | |
| 393 | 397 | |
| 394 | 398 | |
| 395 | 399 | @router.get("/documents/{doc_id}") |
@@ -413,7 +417,11 @@ def document_file(doc_id: int, vid: int, s: Session = Depends(db)): | ||
| 413 | 417 | v = s.get(DocumentVersion, vid) |
| 414 | 418 | if not v or v.document_id != doc_id: |
| 415 | 419 | raise HTTPException(404) |
| 416 | − return FileResponse(v.path, media_type=v.content_type or "application/octet-stream") | |
| 420 | + from ..util import resolve_archive_path | |
| 421 | + fp = resolve_archive_path(v.path) | |
| 422 | + if not fp or not fp.exists(): | |
| 423 | + raise HTTPException(404, "fichier d'archive introuvable") | |
| 424 | + return FileResponse(str(fp), media_type=v.content_type or "application/octet-stream") | |
| 417 | 425 | |
| 418 | 426 | |
| 419 | 427 | @router.get("/proposals") |
@@ -623,7 +631,8 @@ def replay(t: str | None = None, s: Session = Depends(db)): | ||
| 623 | 631 | if not target: |
| 624 | 632 | raise HTTPException(404) |
| 625 | 633 | from pathlib import Path |
| 626 | − data = json.loads(Path(target.path).read_text()) | |
| 634 | + from ..util import resolve_archive_path | |
| 635 | + data = json.loads((resolve_archive_path(target.path) or Path(target.path)).read_text()) | |
| 627 | 636 | ridings_by_name = {} |
| 628 | 637 | from ..util import fix_dgeq_dash, norm_name |
| 629 | 638 | for r in s.scalars(select(Riding)).all(): |
@@ -678,7 +687,8 @@ def status(s: Session = Depends(db)): | ||
| 678 | 687 | return {"components": {"pollWatcher": "operational" if poll_srcs and all(x.status == "operational" for x in poll_srcs) else ("degraded" if poll_srcs else "ready"), |
| 679 | 688 | "forecastEngine": "operational" if run and run.status == "ok" else "degraded", |
| 680 | 689 | "electionFeed": next((x.status for x in sources if x.id == "eq-live"), "ready"), |
| 681 | − "documentWatcher": comp("party-docs"), "officialData": "operational" if all(x.status == "operational" for x in sources if x.kind == "official") else "degraded"}, | |
| 690 | + "documentWatcher": comp("party-docs"), "officialData": "operational" if all(x.status == "operational" for x in sources if x.kind == "official") else "degraded", | |
| 691 | + "signals": ("operational" if [x for x in sources if x.kind == "signal"] and all(x.status in ("operational", "ready") for x in sources if x.kind == "signal") else ("degraded" if [x for x in sources if x.kind == "signal"] else "ready"))}, | |
| 682 | 692 | "sources": [{"id": x.id, "name": x.name, "url": x.url, "tier": x.tier, "kind": x.kind, "status": x.status, "lastCheckedAt": x.last_checked_at.isoformat() if x.last_checked_at else None, |
| 683 | 693 | "lastOkAt": x.last_ok_at.isoformat() if x.last_ok_at else None, "detail": x.detail} for x in sources], |
| 684 | 694 | "jobs": [{"id": j.id, "kind": j.kind, "status": j.status, "startedAt": j.started_at.isoformat(), "finishedAt": j.finished_at.isoformat() if j.finished_at else None, |
@@ -688,6 +698,13 @@ def status(s: Session = Depends(db)): | ||
| 688 | 698 | |
| 689 | 699 | |
| 690 | 700 | CHANGELOG = [ |
| 701 | + {"version": "2.0.0", "date": "2026-09-07", "title": "Au-delà des sondages : ensemble à quatre composantes", | |
| 702 | + "notes": ["Marchés de prédiction (Polymarket, ~800 k$ de volume) : marchés « plus de sièges », « majorité PQ », tranches de sièges (PQ, CAQ, PLQ), 2e/3e place, historique quotidien archivé. Les 20 000 simulations sont repondérées (ajustement proportionnel itératif) pour que les probabilités de l'ensemble valent 70 % modèle + 30 % marché (15 % pour les tranches, moins liquides). La vue « sondages seuls » reste publiée.", | |
| 703 | + "Fondamentaux : la satisfaction à l'égard du gouvernement (questions de leadership des rapports de sondage, pondérées récence) fixe un prior sur la part du parti sortant (≈ 6 + 0,6 × satisfaction, σ 5,5 pt) mélangé aux sondages avec un poids qui décroît de 20 % à 120 jours du vote jusqu'à 0 le jour J.", | |
| 704 | + "Momentum borné (± 1 pt, somme nulle) : prime au chef (meilleur premier ministre vs intention de vote, κ 0,15), attention publique (pages vues Wikipédia des partis et chef·fes, part 7 j vs 28 j, κ 0,08), ton médiatique (titres de presse classés par LLM, κ 0,5, atténué sous 10 articles).", | |
| 705 | + "Élections partielles depuis 2022 (Saint-Henri–Sainte-Anne, Jean-Talon, Terrebonne, Arthabaska, Chicoutimi — résultats officiels DGEQ archivés) : l'excès de swing local observé, net du swing national du jour, corrige le prior de la circonscription (κ 0,35).", | |
| 706 | + "Correction : le·la député·e sortant·e vient désormais de la liste officielle des candidatures (drapeau DGEQ) puis du résultat des partielles, et non plus du seul gagnant 2022 transposé (Jean-Talon, Terrebonne, Arthabaska–L'Érable, Chicoutimi, Saint-Henri–Sainte-Anne étaient erronés).", | |
| 707 | + "Publication de chaque composante (summary.components) : sondages seuls, modèle sans marchés, prior fondamentaux, décomposition du momentum, cibles et probabilités atteintes du tempérage, taille d'échantillon effective."]}, | |
| 691 | 708 | {"version": "1.1.0", "date": "2026-09-06", "title": "Recensement 2021 et swing par segments", |
| 692 | 709 | "notes": ["Recensement de la population 2021 (Statistique Canada, profil des aires de diffusion agrégées + limites) agrégé par intersection surfacique aux 127 circonscriptions : langue maternelle, première langue officielle parlée, immigration, minorités visibles, âge, revenu, tenure, scolarité, chômage.", |
| 693 | 710 | "Excès de swing par langue : ventilations francophones / non-francophones des sondages (90 jours, pondérées récence × √n) comparées à une référence 2022 par régression écologique des résultats sur la part francophone ; appliqué à chaque circonscription selon sa composition (κ = 0,6).", |
@@ -704,6 +721,59 @@ CHANGELOG = [ | ||
| 704 | 721 | ] |
| 705 | 722 | |
| 706 | 723 | |
| 724 | +# ---------------------------------------------------------------- signaux (v2) | |
| 725 | +@router.get("/signals") | |
| 726 | +def signals_all(s: Session = Depends(db)): | |
| 727 | + from ..signals import attention, byelections, fundamentals, markets, media | |
| 728 | + run = _latest_run(s) | |
| 729 | + comp = (run.summary or {}).get("components") if run else None | |
| 730 | + return {"asOf": datetime.now(timezone.utc).isoformat(), "runId": run.id if run else None, "components": comp, | |
| 731 | + "markets": {**markets.implied(s), "history": markets.history(s)["series"]}, | |
| 732 | + "attention": attention.attention_summary(s), "media": media.media_summary(s), | |
| 733 | + "fundamentals": fundamentals.fundamentals_summary(s), "byElections": byelections.byelection_summary(s)} | |
| 734 | + | |
| 735 | + | |
| 736 | +@router.get("/signals/markets") | |
| 737 | +def signals_markets(days: int = 120, s: Session = Depends(db)): | |
| 738 | + from ..signals import markets | |
| 739 | + return {**markets.implied(s), "history": markets.history(s, days)["series"]} | |
| 740 | + | |
| 741 | + | |
| 742 | +@router.get("/signals/attention") | |
| 743 | +def signals_attention(s: Session = Depends(db)): | |
| 744 | + from ..signals import attention | |
| 745 | + return attention.attention_summary(s) | |
| 746 | + | |
| 747 | + | |
| 748 | +@router.get("/signals/media") | |
| 749 | +def signals_media(window: int = 7, limit: int = 60, s: Session = Depends(db)): | |
| 750 | + from ..signals import media | |
| 751 | + d = media.media_summary(s, window=window) | |
| 752 | + d["latest"] = d["latest"][:limit] | |
| 753 | + return d | |
| 754 | + | |
| 755 | + | |
| 756 | +@router.get("/signals/fundamentals") | |
| 757 | +def signals_fundamentals(s: Session = Depends(db)): | |
| 758 | + from ..signals import fundamentals | |
| 759 | + return fundamentals.fundamentals_summary(s) | |
| 760 | + | |
| 761 | + | |
| 762 | +@router.get("/signals/byelections") | |
| 763 | +def signals_byelections(s: Session = Depends(db)): | |
| 764 | + from ..signals import byelections | |
| 765 | + return byelections.byelection_summary(s) | |
| 766 | + | |
| 767 | + | |
| 768 | +@router.get("/forecast/components") | |
| 769 | +def forecast_components(s: Session = Depends(db)): | |
| 770 | + run = _latest_run(s) | |
| 771 | + if not run: | |
| 772 | + raise HTTPException(404) | |
| 773 | + return {"runId": run.id, "modelVersion": run.model_version, "completedAt": run.completed_at.isoformat() if run.completed_at else None, | |
| 774 | + "components": (run.summary or {}).get("components"), "parties": run.summary.get("parties"), "pMinority": run.summary.get("p_minority")} | |
| 775 | + | |
| 776 | + | |
| 707 | 777 | @router.get("/changelog") |
| 708 | 778 | def changelog(s: Session = Depends(db)): |
| 709 | 779 | runs = s.scalars(select(ForecastRun).where(ForecastRun.kind == "forecast", ForecastRun.status == "ok").order_by(desc(ForecastRun.id)).limit(30)).all() |
modified
engine/qc26/config.py
+32 −2
@@ -52,7 +52,7 @@ class Settings(BaseSettings): | ||
| 52 | 52 | # ils dérivent de la liste officielle des circonscriptions (127 en 2026 → 64). |
| 53 | 53 | |
| 54 | 54 | # --- Modèle --- |
| 55 | − model_version: str = "1.1.0" | |
| 55 | + model_version: str = "2.0.0" | |
| 56 | 56 | n_simulations: int = 20000 |
| 57 | 57 | random_seed: int | None = None |
| 58 | 58 | recency_halflife_days: float = 14.0 # hors campagne |
@@ -68,9 +68,39 @@ class Settings(BaseSettings): | ||
| 68 | 68 | riding_error_sd: float = 0.20 # logit |
| 69 | 69 | incumbent_retirement_penalty: float = 0.06 # logit (~1,3 pp) |
| 70 | 70 | |
| 71 | + # --- Ensemble v2 : au-delà des sondages --- | |
| 72 | + # Fondamentaux (satisfaction du gouvernement → part du parti sortant) : poids maximal loin du | |
| 73 | + # scrutin, décroissant linéairement vers 0 le jour du vote (horizon en jours). | |
| 74 | + fundamentals_weight_max: float = 0.20 | |
| 75 | + fundamentals_horizon_days: int = 120 | |
| 76 | + fundamentals_alpha: float = 6.0 # part sortant ≈ α + β·satisfaction (%), calibration indicative QC 2003-2022 | |
| 77 | + fundamentals_beta: float = 0.60 | |
| 78 | + fundamentals_sd_pp: float = 5.5 | |
| 79 | + # Momentum : leadership (meilleur PM vs vote), attention (Wikipédia), ton médiatique → nudge borné (pp) | |
| 80 | + momentum_leader_kappa: float = 0.15 | |
| 81 | + momentum_attention_kappa: float = 0.08 # pp de vote par pp de variation de la part d'attention (7 j vs 28 j) | |
| 82 | + momentum_media_kappa: float = 0.5 # pp de vote par unité de ton médiatique (−1…+1), atténué sous 10 articles | |
| 83 | + momentum_max_pp: float = 1.0 | |
| 84 | + # Marchés de prédiction : tempérage des simulations (pondération itérative) vers les prix implicites | |
| 85 | + markets_weight: float = 0.30 | |
| 86 | + markets_min_volume: float = 5000.0 | |
| 87 | + markets_max_age_hours: int = 72 | |
| 88 | + # Partielles : ajustement des priors de circonscription du swing observé (rétréci) | |
| 89 | + byelection_kappa: float = 0.35 | |
| 90 | + | |
| 71 | 91 | # --- Sources / connecteurs --- |
| 72 | 92 | firecrawl_api_key: str = "" |
| 73 | 93 | anthropic_api_key: str = "" |
| 94 | + serper_api_key: str = "" | |
| 95 | + tavily_api_key: str = "" | |
| 96 | + polymarket_gamma: str = "https://gamma-api.polymarket.com" | |
| 97 | + polymarket_clob: str = "https://clob.polymarket.com" | |
| 98 | + polymarket_event_slugs: str = ("quebec-general-election-winner,of-seats-won-by-pq-in-the-2026-quebec-general-election," | |
| 99 | + "of-seats-won-by-caq-in-the-2026-quebec-general-election,of-seats-won-by-plq-in-the-2026-quebec-general-election," | |
| 100 | + "will-the-pq-win-a-majority-in-the-2026-quebec-general-election,quebec-general-election-second-place-20260825152158," | |
| 101 | + "quebec-general-election-third-place-20260825152158") | |
| 102 | + signals_markets_minutes: int = 30 | |
| 103 | + signals_media_hours: int = 3 | |
| 74 | 104 | llm_model: str = "claude-opus-5" |
| 75 | 105 | llm_enabled: bool = True |
| 76 | 106 | user_agent: str = ( |
@@ -111,5 +141,5 @@ class Settings(BaseSettings): | ||
| 111 | 141 | |
| 112 | 142 | settings = Settings() |
| 113 | 143 | for _d in (settings.archive_dir / "polls", settings.archive_dir / "documents", |
| 114 | − settings.archive_dir / "live", settings.cache_dir): | |
| 144 | + settings.archive_dir / "live", settings.archive_dir / "partielles", settings.cache_dir): | |
| 115 | 145 | _d.mkdir(parents=True, exist_ok=True) |
modified
engine/qc26/connectors/parties/documents.py
+8 −5
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session | ||
| 21 | 21 | from ... import llm |
| 22 | 22 | from ...config import settings |
| 23 | 23 | from ...db import (DocumentChunk, DocumentVersion, IngestionJob, Party, PartyDocument, PolicyProposal, Source, audit, utcnow) |
| 24 | −from ...util import fetch_bytes, html_to_text, http, parse_fr_date, pdf_pages_text, sha256_bytes | |
| 24 | +from ...util import fetch_bytes, html_to_text, http, parse_fr_date, pdf_pages_text, resolve_archive_path, sha256_bytes | |
| 25 | 25 | from ..polls.registry import firecrawl_map, firecrawl_scrape |
| 26 | 26 | |
| 27 | 27 | log = logging.getLogger("qc26.parties.docs") |
@@ -232,8 +232,9 @@ def fetch_version(s: Session, doc: PartyDocument) -> DocumentVersion | None: | ||
| 232 | 232 | |
| 233 | 233 | def diff_versions(old: DocumentVersion, new: DocumentVersion) -> dict: |
| 234 | 234 | import difflib |
| 235 | − a = Path(old.text_path).read_text().splitlines() if old.text_path and Path(old.text_path).exists() else [] | |
| 236 | − b = Path(new.text_path).read_text().splitlines() if new.text_path and Path(new.text_path).exists() else [] | |
| 235 | + _pa, _pb = resolve_archive_path(old.text_path), resolve_archive_path(new.text_path) | |
| 236 | + a = _pa.read_text().splitlines() if _pa and _pa.exists() else [] | |
| 237 | + b = _pb.read_text().splitlines() if _pb and _pb.exists() else [] | |
| 237 | 238 | sm = difflib.SequenceMatcher(None, a, b, autojunk=False) |
| 238 | 239 | added, removed = [], [] |
| 239 | 240 | for tag, i1, i2, j1, j2 in sm.get_opcodes(): |
@@ -276,7 +277,8 @@ Si l'extrait ne contient aucune proposition (page de navigation, formulaire, bio | ||
| 276 | 277 | |
| 277 | 278 | |
| 278 | 279 | def analyze_version(s: Session, doc: PartyDocument, v: DocumentVersion, max_chunks: int = 12) -> int: |
| 279 | − text = Path(v.text_path).read_text() if v.text_path else "" | |
| 280 | + _tp = resolve_archive_path(v.text_path) | |
| 281 | + text = _tp.read_text() if _tp and _tp.exists() else "" | |
| 280 | 282 | if len(text.strip()) < 300: |
| 281 | 283 | doc.status = "ignored" |
| 282 | 284 | v.analyzed_at = utcnow() |
@@ -365,7 +367,8 @@ def sync_party(s: Session, party_id: str, max_docs: int = 80, analyze: bool = Tr | ||
| 365 | 367 | s.commit() |
| 366 | 368 | if not doc.title or doc.title == url.rsplit("/", 1)[-1][:200]: |
| 367 | 369 | # titre depuis le texte (première ligne significative) |
| 368 | − txt = Path(v.text_path).read_text()[:2000] if v.text_path else "" | |
| 370 | + _tp2 = resolve_archive_path(v.text_path) | |
| 371 | + txt = _tp2.read_text()[:2000] if _tp2 and _tp2.exists() else "" | |
| 369 | 372 | first = next((l.strip() for l in txt.splitlines() if len(l.strip()) > 12), None) |
| 370 | 373 | if first: |
| 371 | 374 | doc.title = first[:200] |
modified
engine/qc26/connectors/polls/base.py
+4 −2
@@ -20,7 +20,7 @@ from ... import llm | ||
| 20 | 20 | from ...config import settings |
| 21 | 21 | from ...db import Poll, PollDocument, PollResult, PollSubsample, Pollster, audit, utcnow |
| 22 | 22 | from ...parties import PARTY_IDS, party_id_from_label |
| 23 | −from ...util import fetch_bytes, html_to_text, pdf_pages_text, pdf_tables_text, sha256_bytes | |
| 23 | +from ...util import fetch_bytes, html_to_text, pdf_pages_text, pdf_tables_text, resolve_archive_path, sha256_bytes | |
| 24 | 24 | |
| 25 | 25 | log = logging.getLogger("qc26.polls.connector") |
| 26 | 26 | |
@@ -95,7 +95,9 @@ class PollConnector: | ||
| 95 | 95 | return doc |
| 96 | 96 | |
| 97 | 97 | def extract_text(self, doc: PollDocument) -> tuple[list[str], list[tuple[int, str]]]: |
| 98 | − path = Path(doc.path) | |
| 98 | + path = resolve_archive_path(doc.path) or Path(doc.path) | |
| 99 | + if not path.exists(): | |
| 100 | + raise FileNotFoundError(f"archive absente : {doc.path}") | |
| 99 | 101 | if path.suffix == ".pdf": |
| 100 | 102 | pages = pdf_pages_text(path) |
| 101 | 103 | tables = pdf_tables_text(path) |
modified
engine/qc26/db.py
+135 −26
@@ -11,7 +11,7 @@ from contextlib import contextmanager | ||
| 11 | 11 | from datetime import date, datetime, timezone |
| 12 | 12 | from typing import Any, Iterator |
| 13 | 13 | |
| 14 | −from sqlalchemy import (JSON, Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, | |
| 14 | +from sqlalchemy import (JSON, Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, TypeDecorator, | |
| 15 | 15 | UniqueConstraint, create_engine, event, text) |
| 16 | 16 | from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship, sessionmaker |
| 17 | 17 | |
@@ -22,6 +22,23 @@ def utcnow() -> datetime: | ||
| 22 | 22 | return datetime.now(timezone.utc) |
| 23 | 23 | |
| 24 | 24 | |
| 25 | +class TZDateTime(TypeDecorator): | |
| 26 | + """SQLite ne conserve pas le fuseau : on stocke en UTC naïf et on relit en UTC explicite, pour que | |
| 27 | + `.isoformat()` porte toujours « +00:00 » (sinon le navigateur interprète l'heure comme locale).""" | |
| 28 | + impl = DateTime(timezone=True) | |
| 29 | + cache_ok = True | |
| 30 | + | |
| 31 | + def process_bind_param(self, value, dialect): | |
| 32 | + if value is not None and value.tzinfo is not None: | |
| 33 | + return value.astimezone(timezone.utc).replace(tzinfo=None) | |
| 34 | + return value | |
| 35 | + | |
| 36 | + def process_result_value(self, value, dialect): | |
| 37 | + if value is not None and value.tzinfo is None: | |
| 38 | + return value.replace(tzinfo=timezone.utc) | |
| 39 | + return value | |
| 40 | + | |
| 41 | + | |
| 25 | 42 | class Base(DeclarativeBase): |
| 26 | 43 | pass |
| 27 | 44 | |
@@ -47,7 +64,7 @@ class Party(Base): | ||
| 47 | 64 | vote_2022: Mapped[float | None] = mapped_column(Float) |
| 48 | 65 | order: Mapped[int] = mapped_column(Integer, default=0) |
| 49 | 66 | color_note: Mapped[str | None] = mapped_column(Text) |
| 50 | − updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) | |
| 67 | + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow) | |
| 51 | 68 | |
| 52 | 69 | |
| 53 | 70 | class Region(Base): |
@@ -115,7 +132,7 @@ class RidingDemographics(Base): | ||
| 115 | 132 | ada_count: Mapped[int] = mapped_column(Integer, default=0) |
| 116 | 133 | coverage: Mapped[float | None] = mapped_column(Float) # part de la population de la circ. couverte par des ADA |
| 117 | 134 | source: Mapped[str] = mapped_column(String(300)) |
| 118 | − computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) | |
| 135 | + computed_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 119 | 136 | |
| 120 | 137 | |
| 121 | 138 | class HistoricalResult(Base): |
@@ -149,8 +166,8 @@ class Candidate(Base): | ||
| 149 | 166 | incumbent: Mapped[bool] = mapped_column(Boolean, default=False) |
| 150 | 167 | status: Mapped[str] = mapped_column(String(8)) # CPA (confirmé parti) / CIA (indépendant) … |
| 151 | 168 | state: Mapped[str] = mapped_column(String(8)) |
| 152 | − dgeq_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) | |
| 153 | − fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) | |
| 169 | + dgeq_updated_at: Mapped[datetime | None] = mapped_column(TZDateTime) | |
| 170 | + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 154 | 171 | |
| 155 | 172 | |
| 156 | 173 | # --------------------------------------------------------------------- sondages |
@@ -175,7 +192,7 @@ class PollDocument(Base): | ||
| 175 | 192 | content_type: Mapped[str | None] = mapped_column(String(80)) |
| 176 | 193 | bytes: Mapped[int] = mapped_column(Integer) |
| 177 | 194 | pages: Mapped[int | None] = mapped_column(Integer) |
| 178 | − downloaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) | |
| 195 | + downloaded_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 179 | 196 | text_path: Mapped[str | None] = mapped_column(String(300)) |
| 180 | 197 | http_last_modified: Mapped[str | None] = mapped_column(String(80)) |
| 181 | 198 | pollster_id: Mapped[str | None] = mapped_column(String(32)) |
@@ -204,8 +221,8 @@ class Poll(Base): | ||
| 204 | 221 | validation_notes: Mapped[dict | list | None] = mapped_column(JSON) |
| 205 | 222 | undecided: Mapped[float | None] = mapped_column(Float) |
| 206 | 223 | notes: Mapped[dict | list | None] = mapped_column(JSON) |
| 207 | − ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) | |
| 208 | − updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) | |
| 224 | + ingested_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 225 | + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow) | |
| 209 | 226 | excluded: Mapped[bool] = mapped_column(Boolean, default=False) |
| 210 | 227 | exclusion_reason: Mapped[str | None] = mapped_column(String(200)) |
| 211 | 228 | results: Mapped[list["PollResult"]] = relationship(cascade="all, delete-orphan", lazy="selectin") |
@@ -250,8 +267,8 @@ class PartyDocument(Base): | ||
| 250 | 267 | url: Mapped[str] = mapped_column(String(600), unique=True) |
| 251 | 268 | doc_type: Mapped[str] = mapped_column(String(32)) # plateforme / cadre_financier / communique / page / engagement |
| 252 | 269 | published_date: Mapped[date | None] = mapped_column(Date) |
| 253 | − discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) | |
| 254 | − last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) | |
| 270 | + discovered_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 271 | + last_checked_at: Mapped[datetime | None] = mapped_column(TZDateTime) | |
| 255 | 272 | current_version_id: Mapped[int | None] = mapped_column(Integer) |
| 256 | 273 | proposals_count: Mapped[int] = mapped_column(Integer, default=0) |
| 257 | 274 | status: Mapped[str] = mapped_column(String(16), default="new") # new / analyzed / failed / ignored |
@@ -268,8 +285,8 @@ class DocumentVersion(Base): | ||
| 268 | 285 | content_type: Mapped[str | None] = mapped_column(String(80)) |
| 269 | 286 | bytes: Mapped[int] = mapped_column(Integer) |
| 270 | 287 | pages: Mapped[int | None] = mapped_column(Integer) |
| 271 | − fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) | |
| 272 | − analyzed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) | |
| 288 | + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 289 | + analyzed_at: Mapped[datetime | None] = mapped_column(TZDateTime) | |
| 273 | 290 | diff_summary: Mapped[dict | None] = mapped_column(JSON) |
| 274 | 291 | |
| 275 | 292 | |
@@ -298,7 +315,7 @@ class PolicyProposal(Base): | ||
| 298 | 315 | quote: Mapped[str] = mapped_column(Text) |
| 299 | 316 | confidence: Mapped[float] = mapped_column(Float) |
| 300 | 317 | status: Mapped[str] = mapped_column(String(16), default="accepted") # accepted / review / rejected |
| 301 | − extracted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) | |
| 318 | + extracted_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 302 | 319 | |
| 303 | 320 | |
| 304 | 321 | class CompassQuestion(Base): |
@@ -324,7 +341,7 @@ class PartyPosition(Base): | ||
| 324 | 341 | proposal_ids: Mapped[list | None] = mapped_column(JSON) |
| 325 | 342 | rationale: Mapped[str | None] = mapped_column(Text) |
| 326 | 343 | status: Mapped[str] = mapped_column(String(16), default="pending") # pending / extracted / review / manual |
| 327 | − updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) | |
| 344 | + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow) | |
| 328 | 345 | |
| 329 | 346 | |
| 330 | 347 | # -------------------------------------------------------------------- forecast |
@@ -334,8 +351,8 @@ class ForecastRun(Base): | ||
| 334 | 351 | model_version: Mapped[str] = mapped_column(String(16)) |
| 335 | 352 | kind: Mapped[str] = mapped_column(String(16), default="forecast") # forecast / nowcast |
| 336 | 353 | data_cutoff: Mapped[date] = mapped_column(Date) |
| 337 | − started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) | |
| 338 | − completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) | |
| 354 | + started_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 355 | + completed_at: Mapped[datetime | None] = mapped_column(TZDateTime) | |
| 339 | 356 | poll_count: Mapped[int] = mapped_column(Integer, default=0) |
| 340 | 357 | n_simulations: Mapped[int] = mapped_column(Integer, default=0) |
| 341 | 358 | seed: Mapped[int | None] = mapped_column(Integer) |
@@ -427,7 +444,7 @@ class PollWeight(Base): | ||
| 427 | 444 | class LiveSnapshot(Base): |
| 428 | 445 | __tablename__ = "live_snapshots" |
| 429 | 446 | id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) |
| 430 | − fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) | |
| 447 | + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, index=True) | |
| 431 | 448 | source_updated_at: Mapped[str | None] = mapped_column(String(40)) |
| 432 | 449 | sha256: Mapped[str] = mapped_column(String(64), index=True) |
| 433 | 450 | path: Mapped[str] = mapped_column(String(300)) |
@@ -445,7 +462,7 @@ class LiveSnapshot(Base): | ||
| 445 | 462 | class LiveRidingState(Base): |
| 446 | 463 | __tablename__ = "live_riding_states" |
| 447 | 464 | riding_code: Mapped[int] = mapped_column(Integer, primary_key=True) |
| 448 | − updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) | |
| 465 | + updated_at: Mapped[datetime | None] = mapped_column(TZDateTime) | |
| 449 | 466 | source_updated_at: Mapped[str | None] = mapped_column(String(40)) |
| 450 | 467 | status: Mapped[str] = mapped_column(String(16), default="NOT_STARTED") # NOT_STARTED/REPORTING/LEADING/PROJECTED/ELECTED/FINAL |
| 451 | 468 | bureaux_completes: Mapped[int] = mapped_column(Integer, default=0) |
@@ -466,7 +483,7 @@ class LiveRidingState(Base): | ||
| 466 | 483 | class ElectionEvent(Base): |
| 467 | 484 | __tablename__ = "election_events" |
| 468 | 485 | id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) |
| 469 | − ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) | |
| 486 | + ts: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, index=True) | |
| 470 | 487 | kind: Mapped[str] = mapped_column(String(32), index=True) # lead_change / riding_complete / seats_update / reporting / first_results / final |
| 471 | 488 | riding_code: Mapped[int | None] = mapped_column(Integer) |
| 472 | 489 | party_id: Mapped[str | None] = mapped_column(String(8)) |
@@ -478,12 +495,104 @@ class ElectionEvent(Base): | ||
| 478 | 495 | class NowcastRun(Base): |
| 479 | 496 | __tablename__ = "nowcast_runs" |
| 480 | 497 | id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) |
| 481 | − ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) | |
| 498 | + ts: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, index=True) | |
| 482 | 499 | snapshot_id: Mapped[int | None] = mapped_column(Integer) |
| 483 | 500 | ridings_reporting: Mapped[int] = mapped_column(Integer) |
| 484 | 501 | summary: Mapped[dict] = mapped_column(JSON) # par parti : élus, en avance, total, p_majority, seats mean/ci |
| 485 | 502 | |
| 486 | 503 | |
| 504 | +# ------------------------------------------------------- signaux alternatifs (v2) | |
| 505 | +class Signal(Base): | |
| 506 | + """Série temporelle d'un signal « au-delà des sondages » : marchés de prédiction, attention | |
| 507 | + (Wikipédia), médias (volume / ton), fondamentaux tirés des questions de leadership, partielles. | |
| 508 | + Une ligne = une observation datée, avec sa provenance (URL) et ses métadonnées.""" | |
| 509 | + __tablename__ = "signals" | |
| 510 | + __table_args__ = (UniqueConstraint("source", "key", "party_id", "date"),) | |
| 511 | + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) | |
| 512 | + source: Mapped[str] = mapped_column(String(32), index=True) # polymarket / wikipedia / gdelt / serper-news / polls-leadership / dgeq-partielles | |
| 513 | + key: Mapped[str] = mapped_column(String(64), index=True) # ex. p_most_seats / pageviews / tone / best_pm / satisfaction | |
| 514 | + party_id: Mapped[str | None] = mapped_column(String(8), index=True) | |
| 515 | + date: Mapped[date] = mapped_column(Date, index=True) | |
| 516 | + value: Mapped[float] = mapped_column(Float) | |
| 517 | + n: Mapped[int | None] = mapped_column(Integer) | |
| 518 | + meta: Mapped[dict | list | None] = mapped_column(JSON) | |
| 519 | + url: Mapped[str | None] = mapped_column(String(600)) | |
| 520 | + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow) | |
| 521 | + | |
| 522 | + | |
| 523 | +class Market(Base): | |
| 524 | + """Marché de prédiction (Polymarket) rattaché à l'élection : question, type, parti, tranche.""" | |
| 525 | + __tablename__ = "markets" | |
| 526 | + id: Mapped[str] = mapped_column(String(32), primary_key=True) # id Polymarket | |
| 527 | + source: Mapped[str] = mapped_column(String(24), default="polymarket") | |
| 528 | + event_slug: Mapped[str] = mapped_column(String(160), index=True) | |
| 529 | + event_title: Mapped[str | None] = mapped_column(String(200)) | |
| 530 | + question: Mapped[str] = mapped_column(String(300)) | |
| 531 | + kind: Mapped[str] = mapped_column(String(24), index=True) # most_seats / majority / seats_band / place | |
| 532 | + party_id: Mapped[str | None] = mapped_column(String(8), index=True) | |
| 533 | + band_lo: Mapped[int | None] = mapped_column(Integer) | |
| 534 | + band_hi: Mapped[int | None] = mapped_column(Integer) | |
| 535 | + place: Mapped[int | None] = mapped_column(Integer) | |
| 536 | + condition_id: Mapped[str | None] = mapped_column(String(80)) | |
| 537 | + token_id: Mapped[str | None] = mapped_column(String(100)) | |
| 538 | + volume: Mapped[float | None] = mapped_column(Float) | |
| 539 | + liquidity: Mapped[float | None] = mapped_column(Float) | |
| 540 | + end_date: Mapped[str | None] = mapped_column(String(40)) | |
| 541 | + url: Mapped[str | None] = mapped_column(String(300)) | |
| 542 | + last_price: Mapped[float | None] = mapped_column(Float) | |
| 543 | + best_bid: Mapped[float | None] = mapped_column(Float) | |
| 544 | + best_ask: Mapped[float | None] = mapped_column(Float) | |
| 545 | + active: Mapped[bool] = mapped_column(Boolean, default=True) | |
| 546 | + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow) | |
| 547 | + | |
| 548 | + | |
| 549 | +class MarketPrice(Base): | |
| 550 | + __tablename__ = "market_prices" | |
| 551 | + __table_args__ = (UniqueConstraint("market_id", "ts"),) | |
| 552 | + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) | |
| 553 | + market_id: Mapped[str] = mapped_column(ForeignKey("markets.id"), index=True) | |
| 554 | + ts: Mapped[datetime] = mapped_column(TZDateTime, index=True) | |
| 555 | + price: Mapped[float] = mapped_column(Float) # prix « Oui » (0–1) | |
| 556 | + | |
| 557 | + | |
| 558 | +class MediaItem(Base): | |
| 559 | + """Article de presse repéré (titre, média, date) et classé par le LLM : partis mentionnés et ton.""" | |
| 560 | + __tablename__ = "media_items" | |
| 561 | + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) | |
| 562 | + url: Mapped[str] = mapped_column(String(600), unique=True) | |
| 563 | + title: Mapped[str] = mapped_column(String(400)) | |
| 564 | + domain: Mapped[str | None] = mapped_column(String(120), index=True) | |
| 565 | + snippet: Mapped[str | None] = mapped_column(Text) | |
| 566 | + published_at: Mapped[datetime | None] = mapped_column(TZDateTime, index=True) | |
| 567 | + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 568 | + query: Mapped[str | None] = mapped_column(String(200)) | |
| 569 | + source: Mapped[str] = mapped_column(String(24), default="serper-news") | |
| 570 | + parties: Mapped[dict | None] = mapped_column(JSON) # {party_id: tone −1…+1} | |
| 571 | + topic: Mapped[str | None] = mapped_column(String(48)) | |
| 572 | + summary: Mapped[str | None] = mapped_column(String(300)) | |
| 573 | + confidence: Mapped[float | None] = mapped_column(Float) | |
| 574 | + classified: Mapped[bool] = mapped_column(Boolean, default=False, index=True) | |
| 575 | + | |
| 576 | + | |
| 577 | +class ByElection(Base): | |
| 578 | + """Élection partielle provinciale depuis 2022 (résultats officiels DGEQ) comparée au résultat | |
| 579 | + de 2022 dans la même circonscription : mesure directe du swing en conditions réelles.""" | |
| 580 | + __tablename__ = "by_elections" | |
| 581 | + id: Mapped[str] = mapped_column(String(32), primary_key=True) # ex. 2025-03-17-542 | |
| 582 | + date: Mapped[date] = mapped_column(Date, index=True) | |
| 583 | + dgeq_code: Mapped[int] = mapped_column(Integer) | |
| 584 | + riding_name: Mapped[str | None] = mapped_column(String(80)) | |
| 585 | + url: Mapped[str] = mapped_column(String(300)) | |
| 586 | + sha256: Mapped[str | None] = mapped_column(String(64)) | |
| 587 | + path: Mapped[str | None] = mapped_column(String(300)) | |
| 588 | + results: Mapped[list | None] = mapped_column(JSON) # [{candidate, party_label, party_id, votes, share}] | |
| 589 | + previous: Mapped[dict | None] = mapped_column(JSON) # {party_id: share 2022} (résultat général précédent, mêmes limites) | |
| 590 | + turnout: Mapped[float | None] = mapped_column(Float) | |
| 591 | + winner_party_id: Mapped[str | None] = mapped_column(String(8)) | |
| 592 | + status: Mapped[str] = mapped_column(String(16), default="new") | |
| 593 | + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow) | |
| 594 | + | |
| 595 | + | |
| 487 | 596 | # ------------------------------------------------------------------ exploitation |
| 488 | 597 | class Source(Base): |
| 489 | 598 | __tablename__ = "sources" |
@@ -492,8 +601,8 @@ class Source(Base): | ||
| 492 | 601 | url: Mapped[str] = mapped_column(String(600)) |
| 493 | 602 | tier: Mapped[int] = mapped_column(Integer, default=1) # 1 officiel, 2 firme, 3 média, 5 index |
| 494 | 603 | kind: Mapped[str] = mapped_column(String(32)) |
| 495 | − last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) | |
| 496 | − last_ok_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) | |
| 604 | + last_checked_at: Mapped[datetime | None] = mapped_column(TZDateTime) | |
| 605 | + last_ok_at: Mapped[datetime | None] = mapped_column(TZDateTime) | |
| 497 | 606 | status: Mapped[str] = mapped_column(String(16), default="unknown") # operational / degraded / down / ready |
| 498 | 607 | detail: Mapped[str | None] = mapped_column(String(300)) |
| 499 | 608 | |
@@ -502,8 +611,8 @@ class IngestionJob(Base): | ||
| 502 | 611 | __tablename__ = "ingestion_jobs" |
| 503 | 612 | id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) |
| 504 | 613 | kind: Mapped[str] = mapped_column(String(48), index=True) |
| 505 | − started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) | |
| 506 | − finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) | |
| 614 | + started_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow) | |
| 615 | + finished_at: Mapped[datetime | None] = mapped_column(TZDateTime) | |
| 507 | 616 | status: Mapped[str] = mapped_column(String(16), default="running") |
| 508 | 617 | items: Mapped[int] = mapped_column(Integer, default=0) |
| 509 | 618 | message: Mapped[str | None] = mapped_column(Text) |
@@ -513,7 +622,7 @@ class IngestionJob(Base): | ||
| 513 | 622 | class AuditLog(Base): |
| 514 | 623 | __tablename__ = "audit_logs" |
| 515 | 624 | id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) |
| 516 | − ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) | |
| 625 | + ts: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, index=True) | |
| 517 | 626 | actor: Mapped[str] = mapped_column(String(64)) # system:<job> / admin |
| 518 | 627 | entity: Mapped[str] = mapped_column(String(48)) |
| 519 | 628 | entity_id: Mapped[str] = mapped_column(String(80)) |
@@ -537,7 +646,7 @@ class Setting(Base): | ||
| 537 | 646 | __tablename__ = "settings" |
| 538 | 647 | key: Mapped[str] = mapped_column(String(64), primary_key=True) |
| 539 | 648 | value: Mapped[dict | list | str | None] = mapped_column(JSON) |
| 540 | − updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) | |
| 649 | + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow) | |
| 541 | 650 | |
| 542 | 651 | |
| 543 | 652 | # -------------------------------------------------------------------- moteur |
modified
engine/qc26/ingest/official.py
+31 −0
@@ -224,16 +224,47 @@ def sync_candidates(s: Session, refresh: bool = True) -> int: | ||
| 224 | 224 | counts[pid] += 1 |
| 225 | 225 | if c.get("depute_sortant") == "O": |
| 226 | 226 | incumbents_running[code] = True |
| 227 | + # la liste officielle des candidatures est la vérité sur le·la député·e sortant·e : | |
| 228 | + # elle corrige le gagnant 2022 transposé quand une partielle a changé la donne (Jean-Talon, Terrebonne, Arthabaska, Chicoutimi…) | |
| 229 | + rd = ridings[code] | |
| 230 | + if pid and (rd.incumbent_party_id != pid or not rd.incumbent_name): | |
| 231 | + rd.incumbent_party_id = pid | |
| 232 | + rd.incumbent_name = f"{c.get('prenom_bulletin_vote') or ''} {c.get('nom_bulletin_vote') or ''}".strip() | |
| 227 | 233 | for pid, n in counts.items(): |
| 228 | 234 | p = s.get(Party, pid) |
| 229 | 235 | if p: |
| 230 | 236 | p.candidates_count = n |
| 231 | 237 | for code, rd in ridings.items(): |
| 232 | 238 | rd.incumbent_running = incumbents_running.get(code, False) |
| 239 | + apply_by_election_incumbents(s, ridings) | |
| 233 | 240 | _mark_source(s, "eq-candidates", "Élections Québec — candidatures officielles", settings.eq_candidates_url, True) |
| 234 | 241 | return len(data) |
| 235 | 242 | |
| 236 | 243 | |
| 244 | +def apply_by_election_incumbents(s: Session, ridings: dict | None = None) -> int: | |
| 245 | + """Sortant·e des circonscriptions ayant connu une partielle depuis 2022 : le·la gagnant·e de la partielle | |
| 246 | + (résultat officiel DGEQ archivé), sauf si la liste des candidatures désigne déjà un·e sortant·e.""" | |
| 247 | + from ..db import ByElection, Candidate | |
| 248 | + ridings = ridings or {r.code: r for r in s.scalars(select(Riding)).all()} | |
| 249 | + by_name = {norm_name(r.name): r for r in ridings.values()} | |
| 250 | + n = 0 | |
| 251 | + for b in s.scalars(select(ByElection).where(ByElection.status == "ok").order_by(ByElection.date)).all(): | |
| 252 | + if not b.riding_name or not b.results: | |
| 253 | + continue | |
| 254 | + key = norm_name(b.riding_name) | |
| 255 | + rd = by_name.get(key) or next((r for k, r in by_name.items() if k.startswith(key)), None) | |
| 256 | + if not rd: | |
| 257 | + continue | |
| 258 | + has_official = s.scalar(select(Candidate).where(Candidate.riding_code == rd.code, Candidate.incumbent == True).limit(1)) # noqa: E712 | |
| 259 | + if has_official: | |
| 260 | + continue | |
| 261 | + win = max(b.results, key=lambda r: r["votes"]) | |
| 262 | + if win.get("party_id") and rd.incumbent_party_id != win["party_id"]: | |
| 263 | + rd.incumbent_party_id, rd.incumbent_name = win["party_id"], win.get("candidate") | |
| 264 | + n += 1 | |
| 265 | + return n | |
| 266 | + | |
| 267 | + | |
| 237 | 268 | # ------------------------------------------------------------------ résultats |
| 238 | 269 | def _load_results_json(path_dir: Path, url_dir: str, refresh: bool) -> dict: |
| 239 | 270 | raw = _download_or_local(f"{url_dir}/resultats.json", path_dir / "resultats.json", refresh) |
modified
engine/qc26/main.py
+7 −0
@@ -44,6 +44,13 @@ def bootstrap() -> None: | ||
| 44 | 44 | run_forecast(s, trigger="reference_polls_2022") |
| 45 | 45 | except Exception as e: # noqa: BLE001 |
| 46 | 46 | log.warning("sondages de référence 2022 : %s", e) |
| 47 | + try: | |
| 48 | + from .db import Signal | |
| 49 | + from .signals import fundamentals | |
| 50 | + if not s.scalar(select(Signal).where(Signal.source == "polls-leadership").limit(1)): | |
| 51 | + fundamentals.extract_from_polls(s) # dérivé des sondages déjà en base, sans réseau | |
| 52 | + except Exception as e: # noqa: BLE001 | |
| 53 | + log.warning("fondamentaux : %s", e) | |
| 47 | 54 | if not s.scalar(select(ForecastRun).where(ForecastRun.status == "ok").limit(1)): |
| 48 | 55 | from .model.run import run_forecast |
| 49 | 56 | try: |
added
engine/qc26/model/ensemble.py
+258 −0
@@ -0,0 +1,258 @@ | ||
| 1 | +"""Ensemble QC26 (modèle 2.0) — au-delà des sondages. | |
| 2 | + | |
| 3 | +Quatre composantes, toutes optionnelles (désactivées si le signal est absent de la base), toutes | |
| 4 | +paramétrées dans `config.py` et toutes publiées séparément dans le run (« Sondages seuls » | |
| 5 | +vs « Ensemble QC26 ») : | |
| 6 | + | |
| 7 | +1. **Fondamentaux** — prior structurel sur la part du parti sortant : part ≈ α + β·satisfaction | |
| 8 | + (satisfaction à l'égard du gouvernement, pondérée récence, tirée des rapports de sondage). Mélangé | |
| 9 | + à l'état latent des sondages dans l'espace logit avec un poids w_f qui décroît linéairement des | |
| 10 | + `fundamentals_horizon_days` jours précédant le scrutin jusqu'à 0 le jour du vote. | |
| 11 | +2. **Momentum** — nudge additif borné (± `momentum_max_pp`) sur la moyenne du vote au jour du vote : | |
| 12 | + prime au chef (meilleur PM vs intention de vote), attention (Wikipédia, 7 j vs 28 j), ton médiatique | |
| 13 | + (titres classés). Recentré à somme nulle entre les cinq partis. | |
| 14 | +3. **Marchés de prédiction** — tempérage : les simulations Monte Carlo sont **repondérées** | |
| 15 | + (ajustement proportionnel itératif) pour que les probabilités « plus de sièges », « majorité » et les | |
| 16 | + tranches de sièges implicites de l'ensemble valent (1 − w_m)·modèle + w_m·marché. Toute la | |
| 17 | + distribution (sièges, circonscriptions) reste cohérente : ce sont les mêmes simulations, pesées. | |
| 18 | +4. **Partielles** — excès de swing local observé lors d'une partielle (net du swing national à la même | |
| 19 | + date) appliqué, rétréci (κ), au prior de la circonscription concernée. | |
| 20 | +""" | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import math | |
| 24 | +from dataclasses import dataclass, field | |
| 25 | +from datetime import date | |
| 26 | + | |
| 27 | +import numpy as np | |
| 28 | + | |
| 29 | +from ..config import settings | |
| 30 | +from ..parties import PARTY_IDS | |
| 31 | +from .aggregate import PARTIES_ALL | |
| 32 | + | |
| 33 | + | |
| 34 | +def _logit(p: float) -> float: | |
| 35 | + p = min(max(p, 0.002), 0.998) | |
| 36 | + return math.log(p / (1 - p)) | |
| 37 | + | |
| 38 | + | |
| 39 | +def _clip(x: float, lo: float, hi: float) -> float: | |
| 40 | + return max(lo, min(hi, x)) | |
| 41 | + | |
| 42 | + | |
| 43 | +# --------------------------------------------------------------- pondérations | |
| 44 | +def wmean(x: np.ndarray, w: np.ndarray) -> float: | |
| 45 | + return float(np.average(x, weights=w)) | |
| 46 | + | |
| 47 | + | |
| 48 | +def wquantile(x: np.ndarray, w: np.ndarray, q: float) -> float: | |
| 49 | + idx = np.argsort(x) | |
| 50 | + xs, ws = x[idx], w[idx] | |
| 51 | + cw = np.cumsum(ws) | |
| 52 | + cw = cw / cw[-1] | |
| 53 | + return float(xs[min(len(xs) - 1, int(np.searchsorted(cw, q, side="left")))]) | |
| 54 | + | |
| 55 | + | |
| 56 | +def whist(x: np.ndarray, w: np.ndarray, minlength: int) -> list[float]: | |
| 57 | + h = np.bincount(x, weights=w, minlength=minlength) | |
| 58 | + return [round(float(v), 2) for v in h] | |
| 59 | + | |
| 60 | + | |
| 61 | +def ess(w: np.ndarray) -> float: | |
| 62 | + return float(w.sum() ** 2 / (w ** 2).sum()) | |
| 63 | + | |
| 64 | + | |
| 65 | +# ------------------------------------------------------------- fondamentaux | |
| 66 | +def time_weight(days_left: int) -> float: | |
| 67 | + if settings.fundamentals_horizon_days <= 0: | |
| 68 | + return 0.0 | |
| 69 | + return settings.fundamentals_weight_max * _clip(days_left / settings.fundamentals_horizon_days, 0.0, 1.0) | |
| 70 | + | |
| 71 | + | |
| 72 | +def fundamentals_blend(mu_logit: np.ndarray, var_logit: np.ndarray, last_pct: np.ndarray, fund: dict | None, days_left: int, incumbent: str = "caq") -> tuple[np.ndarray, np.ndarray, dict]: | |
| 73 | + """Mélange logit de l'état latent (sondages) et du prior fondamentaux. Retourne (mu, var, détail).""" | |
| 74 | + detail = {"enabled": False, "weight": 0.0} | |
| 75 | + if not fund or not fund.get("enabled") or fund.get("satisfaction") is None or incumbent not in PARTIES_ALL: | |
| 76 | + return mu_logit, var_logit, detail | |
| 77 | + w = time_weight(days_left) | |
| 78 | + sat = float(fund["satisfaction"]) | |
| 79 | + inc_prior = _clip(settings.fundamentals_alpha + settings.fundamentals_beta * sat, 5.0, 60.0) | |
| 80 | + j_inc = PARTIES_ALL.index(incumbent) | |
| 81 | + polls = last_pct.copy() # % somme 100 | |
| 82 | + # les autres partis se partagent le reste au prorata des sondages (le prior n'informe que le sortant) | |
| 83 | + rest_polls = polls.sum() - polls[j_inc] | |
| 84 | + rest_prior = 100.0 - inc_prior | |
| 85 | + prior = polls * (rest_prior / rest_polls) if rest_polls > 0 else polls.copy() | |
| 86 | + prior[j_inc] = inc_prior | |
| 87 | + prior_logit = np.array([_logit(p / 100.0) for p in prior]) | |
| 88 | + sd_pp = settings.fundamentals_sd_pp | |
| 89 | + prior_var_logit = np.array([(sd_pp / 100.0) ** 2 / max((p / 100.0) * (1 - p / 100.0), 1e-3) ** 2 for p in prior]) | |
| 90 | + mu = (1 - w) * mu_logit + w * prior_logit | |
| 91 | + var = var_logit + (w ** 2) * prior_var_logit | |
| 92 | + # traduction en points de % (pour l'affichage) : moyenne après mélange vs avant | |
| 93 | + def to_pct(z: np.ndarray) -> np.ndarray: | |
| 94 | + p = 1 / (1 + np.exp(-z)) | |
| 95 | + return 100.0 * p / p.sum() | |
| 96 | + before, after = to_pct(mu_logit), to_pct(mu) | |
| 97 | + detail = {"enabled": True, "weight": round(w, 3), "daysLeft": days_left, "satisfaction": round(sat, 1), "satisfactionPolls": fund.get("satisfactionPolls"), | |
| 98 | + "incumbent": incumbent, "incumbentPrior": round(inc_prior, 1), "prior": {p: round(float(v), 1) for p, v in zip(PARTIES_ALL, prior)}, | |
| 99 | + "shiftPp": {p: round(float(a - b), 2) for p, a, b in zip(PARTIES_ALL, after, before)}, | |
| 100 | + "params": {"alpha": settings.fundamentals_alpha, "beta": settings.fundamentals_beta, "sdPp": sd_pp, "weightMax": settings.fundamentals_weight_max, "horizonDays": settings.fundamentals_horizon_days}} | |
| 101 | + return mu, var, detail | |
| 102 | + | |
| 103 | + | |
| 104 | +# ----------------------------------------------------------------- momentum | |
| 105 | +def momentum_shift(last_pct: np.ndarray, fund: dict | None, attention: dict | None, media: dict | None) -> tuple[np.ndarray, dict]: | |
| 106 | + """Nudge additif (pp) par parti, borné et recentré ; détail par composante.""" | |
| 107 | + P = len(PARTIES_ALL) | |
| 108 | + parts = {"leader": {}, "attention": {}, "media": {}} | |
| 109 | + shift = np.zeros(P) | |
| 110 | + for j, pid in enumerate(PARTY_IDS): | |
| 111 | + v = float(last_pct[j]) | |
| 112 | + comp = 0.0 | |
| 113 | + if fund and fund.get("enabled"): | |
| 114 | + bp = (fund.get("bestPmShare") or {}).get(pid) | |
| 115 | + if bp is not None: | |
| 116 | + x = settings.momentum_leader_kappa * (float(bp) - v) | |
| 117 | + parts["leader"][pid] = round(x, 2) | |
| 118 | + comp += x | |
| 119 | + if attention and attention.get("enabled"): | |
| 120 | + d = (attention.get("delta") or {}).get(pid) | |
| 121 | + if d is not None: | |
| 122 | + x = settings.momentum_attention_kappa * float(d) | |
| 123 | + parts["attention"][pid] = round(x, 2) | |
| 124 | + comp += x | |
| 125 | + if media and media.get("enabled"): | |
| 126 | + t = (media.get("tone") or {}).get(pid) | |
| 127 | + n = (media.get("counts") or {}).get(pid, 0) | |
| 128 | + if t is not None and n: | |
| 129 | + x = settings.momentum_media_kappa * float(t) * min(1.0, n / 10.0) | |
| 130 | + parts["media"][pid] = round(x, 2) | |
| 131 | + comp += x | |
| 132 | + shift[j] = _clip(comp, -settings.momentum_max_pp, settings.momentum_max_pp) | |
| 133 | + main = shift[: len(PARTY_IDS)] | |
| 134 | + if np.any(main): | |
| 135 | + main = main - main.mean() # somme nulle : le momentum redistribue, il ne crée pas de vote | |
| 136 | + shift[: len(PARTY_IDS)] = main | |
| 137 | + detail = {"enabled": bool(np.any(shift)), "shiftPp": {p: round(float(x), 2) for p, x in zip(PARTIES_ALL, shift)}, "parts": parts, | |
| 138 | + "params": {"leaderKappa": settings.momentum_leader_kappa, "attentionKappa": settings.momentum_attention_kappa, "mediaKappa": settings.momentum_media_kappa, "maxPp": settings.momentum_max_pp}} | |
| 139 | + return shift, detail | |
| 140 | + | |
| 141 | + | |
| 142 | +def apply_shift(national: np.ndarray, shift: np.ndarray) -> np.ndarray: | |
| 143 | + """Décale les tirages nationaux (S, P) de `shift` pp, puis renormalise (plancher 0,2 %).""" | |
| 144 | + if not np.any(shift): | |
| 145 | + return national | |
| 146 | + out = np.clip(national + shift[None, :], 0.2, None) | |
| 147 | + return 100.0 * out / out.sum(axis=1, keepdims=True) | |
| 148 | + | |
| 149 | + | |
| 150 | +# ------------------------------------------------------------------ marchés | |
| 151 | +@dataclass | |
| 152 | +class Tempering: | |
| 153 | + weights: np.ndarray | |
| 154 | + detail: dict = field(default_factory=dict) | |
| 155 | + | |
| 156 | + | |
| 157 | +def market_tempering(seats: np.ndarray, majority: int, markets: dict | None, w_m: float | None = None) -> Tempering: | |
| 158 | + """Repondération IPF des simulations vers (1−w)·modèle + w·marché sur : gagnant (plus de sièges), | |
| 159 | + majorité (parti concerné), tranches de sièges (poids réduit de moitié : marchés moins liquides).""" | |
| 160 | + S, P = seats.shape | |
| 161 | + w = np.ones(S) | |
| 162 | + w_m = settings.markets_weight if w_m is None else w_m | |
| 163 | + detail = {"enabled": False, "weight": w_m} | |
| 164 | + if not markets or w_m <= 0: | |
| 165 | + return Tempering(w, detail) | |
| 166 | + most = seats.argmax(axis=1) | |
| 167 | + constraints: list[tuple[np.ndarray, dict[int, float], str]] = [] | |
| 168 | + # 1) plus de sièges | |
| 169 | + ms = markets.get("mostSeats") or {} | |
| 170 | + if ms: | |
| 171 | + model = {j: float((most == j).mean()) for j in range(P)} | |
| 172 | + target = {} | |
| 173 | + for j, pid in enumerate(PARTIES_ALL): | |
| 174 | + mk = ms.get(pid) | |
| 175 | + target[j] = (1 - w_m) * model[j] + w_m * float(mk) if mk is not None else model[j] | |
| 176 | + tot = sum(target.values()) or 1 | |
| 177 | + target = {j: v / tot for j, v in target.items()} | |
| 178 | + constraints.append((most, target, "mostSeats")) | |
| 179 | + # 2) majorité | |
| 180 | + for pid, pm in (markets.get("majority") or {}).items(): | |
| 181 | + if pid in PARTIES_ALL: | |
| 182 | + j = PARTIES_ALL.index(pid) | |
| 183 | + ind = (seats[:, j] >= majority).astype(int) | |
| 184 | + model_p = float(ind.mean()) | |
| 185 | + t = (1 - w_m) * model_p + w_m * float(pm) | |
| 186 | + constraints.append((ind, {1: t, 0: 1 - t}, f"majority:{pid}")) | |
| 187 | + # 3) tranches de sièges (poids réduit) | |
| 188 | + wb = w_m / 2 | |
| 189 | + for pid, sb in (markets.get("seatBands") or {}).items(): | |
| 190 | + if pid not in PARTIES_ALL or not sb.get("bands"): | |
| 191 | + continue | |
| 192 | + j = PARTIES_ALL.index(pid) | |
| 193 | + bands = sb["bands"] | |
| 194 | + cat = np.full(S, -1) | |
| 195 | + for k, b in enumerate(bands): | |
| 196 | + lo, hi = b["lo"], b["hi"] if b["hi"] is not None else 10**6 | |
| 197 | + cat[(seats[:, j] >= lo) & (seats[:, j] <= hi)] = k | |
| 198 | + if (cat < 0).any(): | |
| 199 | + continue | |
| 200 | + model = {k: float((cat == k).mean()) for k in range(len(bands))} | |
| 201 | + target = {k: (1 - wb) * model[k] + wb * float(bands[k]["p"]) for k in range(len(bands))} | |
| 202 | + tot = sum(target.values()) or 1 | |
| 203 | + constraints.append((cat, {k: v / tot for k, v in target.items()}, f"seatBands:{pid}")) | |
| 204 | + if not constraints: | |
| 205 | + return Tempering(w, detail) | |
| 206 | + for _ in range(25): | |
| 207 | + for cat, target, _name in constraints: | |
| 208 | + cur = {k: float(w[cat == k].sum() / w.sum()) for k in target} | |
| 209 | + for k, t in target.items(): | |
| 210 | + if cur.get(k, 0) > 1e-9 and t > 0: | |
| 211 | + w[cat == k] *= t / cur[k] | |
| 212 | + elif t <= 0: | |
| 213 | + w[cat == k] *= 0.0 | |
| 214 | + w = np.clip(w, 0.02, 50.0) | |
| 215 | + w *= S / w.sum() | |
| 216 | + achieved = {} | |
| 217 | + for cat, target, name in constraints: | |
| 218 | + achieved[name] = {str(k): round(float(w[cat == k].sum() / w.sum()), 4) for k in target} | |
| 219 | + detail = {"enabled": True, "weight": w_m, "bandWeight": wb, "ess": round(ess(w), 0), "constraints": [c[2] for c in constraints], "achieved": achieved, | |
| 220 | + "marketAsOf": markets.get("asOf"), "marketVolume": markets.get("volume"), "mostSeatsMarket": markets.get("mostSeats"), "majorityMarket": markets.get("majority"), | |
| 221 | + "seatBandsImpliedMean": {p: v.get("impliedMean") for p, v in (markets.get("seatBands") or {}).items()}} | |
| 222 | + return Tempering(w, detail) | |
| 223 | + | |
| 224 | + | |
| 225 | +# --------------------------------------------------------------- partielles | |
| 226 | +def byelection_adjustments(byel: dict | None, ridings: list, trend: list[dict], national_2022: dict[str, float]) -> tuple[np.ndarray, dict]: | |
| 227 | + """Matrice (R, P) d'ajustements logit : κ × (swing local observé à la partielle − swing national du jour).""" | |
| 228 | + R, P = len(ridings), len(PARTIES_ALL) | |
| 229 | + adj = np.zeros((R, P)) | |
| 230 | + detail = {"enabled": False, "ridings": []} | |
| 231 | + if not byel or not byel.get("enabled") or not trend: | |
| 232 | + return adj, detail | |
| 233 | + from ..util import norm_name | |
| 234 | + by_name = {norm_name(r.name): i for i, r in enumerate(ridings) if getattr(r, "name", None)} | |
| 235 | + by_date = {t["date"]: t["mean"] for t in trend} | |
| 236 | + dates = sorted(by_date) | |
| 237 | + for b in byel.get("byElections", []): | |
| 238 | + key = norm_name(b.get("riding") or "") | |
| 239 | + i = by_name.get(key) | |
| 240 | + if i is None: | |
| 241 | + i = next((idx for k, idx in by_name.items() if k.startswith(key) and key), None) | |
| 242 | + if i is None or not b.get("previous"): | |
| 243 | + continue | |
| 244 | + d = b["date"] | |
| 245 | + nat_d = by_date.get(d) or by_date.get(next((x for x in dates if x >= d), dates[-1])) | |
| 246 | + res = {r["party_id"]: r["share"] for r in b.get("results", []) if r.get("party_id")} | |
| 247 | + row = {} | |
| 248 | + for j, pid in enumerate(PARTY_IDS): | |
| 249 | + if pid not in res or pid not in b["previous"] or pid not in nat_d: | |
| 250 | + continue | |
| 251 | + local = _logit(res[pid] / 100) - _logit(b["previous"][pid] / 100) | |
| 252 | + national = _logit(nat_d[pid] / 100) - _logit(national_2022.get(pid, 1) / 100) | |
| 253 | + x = _clip(settings.byelection_kappa * (local - national), -1.0, 1.0) | |
| 254 | + adj[i, j] = x | |
| 255 | + row[pid] = round(x, 3) | |
| 256 | + detail["ridings"].append({"code": ridings[i].code, "riding": b.get("riding"), "date": d, "adjLogit": row, "kappa": settings.byelection_kappa}) | |
| 257 | + detail["enabled"] = bool(detail["ridings"]) | |
| 258 | + return adj, detail | |
modified
engine/qc26/model/ridings.py
+1 −0
@@ -31,6 +31,7 @@ class RidingInput: | ||
| 31 | 31 | electors: int = 50000 |
| 32 | 32 | pct_french: float | None = None # langue maternelle française (recensement 2021) |
| 33 | 33 | poll_region: str = "reste" |
| 34 | + name: str = "" | |
| 34 | 35 | |
| 35 | 36 | |
| 36 | 37 | def simulate_ridings(ridings: list[RidingInput], national_draws: np.ndarray, national_2022: dict[str, float], |
modified
engine/qc26/model/run.py
+131 −50
@@ -1,5 +1,7 @@ | ||
| 1 | −"""Exécution d'un run de prévision : sondages → latent → Monte Carlo → sièges → | |
| 2 | −persistance versionnée → « Qu'est-ce qui a changé ? ».""" | |
| 1 | +"""Exécution d'un run de prévision (modèle 2.0) : sondages → latent → mélange fondamentaux → | |
| 2 | +Monte Carlo → momentum → sièges → tempérage par les marchés (repondération) → persistance | |
| 3 | +versionnée → « Qu'est-ce qui a changé ? ». Chaque composante est publiée séparément (summary.components) | |
| 4 | +et la vue « sondages seuls » (pondération uniforme, sans fondamentaux ni momentum) est conservée.""" | |
| 3 | 5 | from __future__ import annotations |
| 4 | 6 | |
| 5 | 7 | import logging |
@@ -17,6 +19,7 @@ from ..db import (Candidate, ForecastHouseEffect, ForecastPartyResult, ForecastR | ||
| 17 | 19 | from ..parties import PARTY_IDS |
| 18 | 20 | from ..regions import POLL_REGION_LABELS, poll_region_for |
| 19 | 21 | from .aggregate import PARTIES_ALL, Obs, compute_weights, election_day_distribution, house_effects, kalman_latent |
| 22 | +from .ensemble import (apply_shift, byelection_adjustments, ess, fundamentals_blend, market_tempering, momentum_shift, whist, wmean, wquantile) | |
| 20 | 23 | from .ridings import RidingInput, explain_factors, rating, simulate_ridings |
| 21 | 24 | from .segments import build_segment_model, riding_adjustments |
| 22 | 25 | |
@@ -57,7 +60,7 @@ def load_ridings(s: Session) -> tuple[list[RidingInput], dict[str, float]]: | ||
| 57 | 60 | out = [RidingInput(code=r.code, region_id=r.region_id or "inconnue", baseline=baselines.get(r.code, {}), |
| 58 | 61 | incumbent_party=r.incumbent_party_id, incumbent_running=r.incumbent_running, candidates=cands.get(r.code, set()), |
| 59 | 62 | electors=r.electors_2026 or 50000, pct_french=(demo[r.code].pct_french_mt if r.code in demo else None), |
| 60 | − poll_region=poll_region_for(r.code)) | |
| 63 | + poll_region=poll_region_for(r.code), name=r.name) | |
| 61 | 64 | for r in rows if baselines.get(r.code)] |
| 62 | 65 | national_2022 = {p.id: (p.vote_2022 or 0.0) for p in s.scalars(select(Party)).all()} |
| 63 | 66 | national_2022["aut"] = max(0.5, 100.0 - sum(national_2022.values())) |
@@ -70,6 +73,36 @@ def _draw_national(mu: np.ndarray, cov: np.ndarray, n: int, rng: np.random.Gener | ||
| 70 | 73 | return 100.0 * p / p.sum(axis=1, keepdims=True) |
| 71 | 74 | |
| 72 | 75 | |
| 76 | +def _load_signals(s: Session, cutoff: date) -> dict: | |
| 77 | + """Signaux disponibles en base (jamais interrogés en ligne pendant un run).""" | |
| 78 | + out = {"markets": None, "fundamentals": None, "attention": None, "media": None, "byelections": None} | |
| 79 | + try: | |
| 80 | + from ..signals import attention, byelections, fundamentals, markets, media | |
| 81 | + out["markets"] = markets.implied(s) | |
| 82 | + out["fundamentals"] = fundamentals.fundamentals_summary(s, cutoff) | |
| 83 | + out["attention"] = attention.attention_summary(s, cutoff) | |
| 84 | + out["media"] = media.media_summary(s, cutoff) | |
| 85 | + out["byelections"] = byelections.byelection_summary(s) | |
| 86 | + except Exception as e: # noqa: BLE001 | |
| 87 | + log.warning("signaux indisponibles : %s", e) | |
| 88 | + return out | |
| 89 | + | |
| 90 | + | |
| 91 | +def _party_stats(national: np.ndarray, seats: np.ndarray, w: np.ndarray, majority: int, seats_total: int) -> dict: | |
| 92 | + most = seats.argmax(axis=1) | |
| 93 | + out = {} | |
| 94 | + for j, pid in enumerate(PARTIES_ALL): | |
| 95 | + v, sj = national[:, j], seats[:, j] | |
| 96 | + out[pid] = {"vote": round(wmean(v, w), 2), "vote_sd": round(float(np.sqrt(np.average((v - wmean(v, w)) ** 2, weights=w))), 2), | |
| 97 | + "vote80": [round(wquantile(v, w, 0.10), 1), round(wquantile(v, w, 0.90), 1)], | |
| 98 | + "vote95": [round(wquantile(v, w, 0.025), 1), round(wquantile(v, w, 0.975), 1)], | |
| 99 | + "seats": int(wquantile(sj.astype(float), w, 0.5)), "seats_mean": round(wmean(sj, w), 1), | |
| 100 | + "seats80": [int(wquantile(sj.astype(float), w, 0.10)), int(wquantile(sj.astype(float), w, 0.90))], | |
| 101 | + "seats95": [int(wquantile(sj.astype(float), w, 0.025)), int(wquantile(sj.astype(float), w, 0.975))], | |
| 102 | + "p_most_seats": round(float(np.average(most == j, weights=w)), 4), "p_majority": round(float(np.average(sj >= majority, weights=w)), 4)} | |
| 103 | + return out | |
| 104 | + | |
| 105 | + | |
| 73 | 106 | def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None, publish: bool = True, |
| 74 | 107 | n_sims: int | None = None) -> ForecastRun: |
| 75 | 108 | cutoff = cutoff or date.today() |
@@ -84,66 +117,87 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None | ||
| 84 | 117 | obs = load_observations(s, cutoff) |
| 85 | 118 | if len(obs) < 3: |
| 86 | 119 | raise RuntimeError("trop peu de sondages") |
| 120 | + days_left = max(0, (settings.election_date - cutoff).days) | |
| 87 | 121 | weights = compute_weights(obs, cutoff) |
| 88 | 122 | effects = house_effects(obs, cutoff) |
| 89 | 123 | latent = kalman_latent(obs, cutoff, effects) |
| 90 | − mu, cov = election_day_distribution(latent, cutoff, settings.election_date) | |
| 91 | − national = _draw_national(mu, cov, n_sims, rng) # (S, P) % | |
| 124 | + signals = _load_signals(s, cutoff) | |
| 125 | + | |
| 126 | + # --- 1. vote national : sondages seuls, puis mélange fondamentaux --- | |
| 127 | + mu_polls, cov_polls = election_day_distribution(latent, cutoff, settings.election_date) | |
| 128 | + national_polls = _draw_national(mu_polls, cov_polls, n_sims, rng) # (S, P) % — sondages seuls | |
| 129 | + mu_f, var_f, fund_detail = fundamentals_blend(latent["mean_logit"], latent["var_logit"], latent["last_pct"], signals["fundamentals"], days_left) | |
| 130 | + if fund_detail["enabled"]: | |
| 131 | + latent_f = {**latent, "mean_logit": mu_f, "var_logit": var_f} | |
| 132 | + mu_e, cov_e = election_day_distribution(latent_f, cutoff, settings.election_date) | |
| 133 | + rng_e = np.random.default_rng(seed) # mêmes aléas → différence attribuable au seul mélange | |
| 134 | + national = _draw_national(mu_e, cov_e, n_sims, rng_e) | |
| 135 | + else: | |
| 136 | + national = national_polls.copy() | |
| 137 | + # --- 2. momentum (nudge borné, somme nulle) --- | |
| 138 | + shift, mom_detail = momentum_shift(latent["last_pct"], signals["fundamentals"], signals["attention"], signals["media"]) | |
| 139 | + national = apply_shift(national, shift) | |
| 92 | 140 | |
| 141 | + # --- 3. circonscriptions --- | |
| 93 | 142 | ridings, national_2022 = load_ridings(s) |
| 94 | 143 | national_now_mean = {p: float(national[:, j].mean()) for j, p in enumerate(PARTIES_ALL)} |
| 95 | 144 | seg_model, f_r = build_segment_model(s, cutoff, national_now_mean, national_2022) |
| 96 | 145 | seg_adj = riding_adjustments(seg_model, [r.code for r in ridings], f_r) |
| 97 | − winners, shares = simulate_ridings(ridings, national, national_2022, rng, seg_adj=seg_adj) | |
| 146 | + be_adj, be_detail = byelection_adjustments(signals["byelections"], ridings, latent["series"], national_2022) | |
| 147 | + total_adj = seg_adj + be_adj | |
| 148 | + rng_r = np.random.default_rng(seed + 1) | |
| 149 | + winners, shares = simulate_ridings(ridings, national, national_2022, rng_r, seg_adj=total_adj) | |
| 98 | 150 | S, R, P = shares.shape |
| 99 | 151 | seats_total = int(get_setting(s, "seats_total", R)) |
| 100 | 152 | majority = int(get_setting(s, "majority_threshold", seats_total // 2 + 1)) |
| 101 | 153 | seats = np.zeros((S, P), dtype=int) |
| 102 | 154 | for j in range(P): |
| 103 | 155 | seats[:, j] = (winners == j).sum(axis=1) |
| 104 | − most = seats.argmax(axis=1) | |
| 156 | + # sondages seuls (mêmes aléas de circonscription, sans fondamentaux, momentum ni partielles) — pour comparaison | |
| 157 | + rng_r0 = np.random.default_rng(seed + 1) | |
| 158 | + winners0, _ = simulate_ridings(ridings, national_polls, national_2022, rng_r0, seg_adj=seg_adj) | |
| 159 | + seats0 = np.zeros((S, P), dtype=int) | |
| 160 | + for j in range(P): | |
| 161 | + seats0[:, j] = (winners0 == j).sum(axis=1) | |
| 162 | + | |
| 163 | + # --- 4. tempérage par les marchés : repondération des simulations --- | |
| 164 | + temp = market_tempering(seats, majority, signals["markets"]) | |
| 165 | + w = temp.weights | |
| 166 | + uniform = np.ones(S) | |
| 105 | 167 | |
| 106 | − summary_parties = {} | |
| 168 | + # --- statistiques pondérées --- | |
| 169 | + most = seats.argmax(axis=1) | |
| 170 | + summary_parties = _party_stats(national, seats, w, majority, seats_total) | |
| 171 | + polls_only = _party_stats(national_polls, seats0, uniform, majority, seats_total) | |
| 172 | + model_only = _party_stats(national, seats, uniform, majority, seats_total) # fondamentaux + momentum, sans marchés | |
| 107 | 173 | s.execute(delete(ForecastPartyResult).where(ForecastPartyResult.run_id == run.id)) |
| 108 | 174 | for j, pid in enumerate(PARTIES_ALL): |
| 109 | − v = national[:, j] | |
| 110 | − sj = seats[:, j] | |
| 111 | − hist = np.bincount(sj, minlength=seats_total + 1).tolist() | |
| 112 | − fpr = ForecastPartyResult( | |
| 113 | − run_id=run.id, party_id=pid, vote_mean=float(v.mean()), vote_sd=float(v.std()), | |
| 114 | − vote_lo80=float(np.percentile(v, 10)), vote_hi80=float(np.percentile(v, 90)), | |
| 115 | − vote_lo95=float(np.percentile(v, 2.5)), vote_hi95=float(np.percentile(v, 97.5)), | |
| 116 | − seats_mean=float(sj.mean()), seats_median=int(np.median(sj)), | |
| 117 | − seats_lo80=int(np.percentile(sj, 10)), seats_hi80=int(np.percentile(sj, 90)), | |
| 118 | − seats_lo95=int(np.percentile(sj, 2.5)), seats_hi95=int(np.percentile(sj, 97.5)), | |
| 119 | − p_most_seats=float((most == j).mean()), p_majority=float((sj >= majority).mean()), | |
| 120 | − seat_distribution=hist) | |
| 121 | − s.add(fpr) | |
| 122 | − summary_parties[pid] = {"vote": round(fpr.vote_mean, 2), "vote_sd": round(fpr.vote_sd, 2), | |
| 123 | − "vote80": [round(fpr.vote_lo80, 1), round(fpr.vote_hi80, 1)], | |
| 124 | − "seats": fpr.seats_median, "seats_mean": round(fpr.seats_mean, 1), | |
| 125 | − "seats80": [fpr.seats_lo80, fpr.seats_hi80], "seats95": [fpr.seats_lo95, fpr.seats_hi95], | |
| 126 | − "p_most_seats": round(fpr.p_most_seats, 4), "p_majority": round(fpr.p_majority, 4)} | |
| 127 | − p_minority = float((seats.max(axis=1) < majority).mean()) | |
| 175 | + v, sj, sp = national[:, j], seats[:, j], summary_parties[pid] | |
| 176 | + s.add(ForecastPartyResult( | |
| 177 | + run_id=run.id, party_id=pid, vote_mean=sp["vote"], vote_sd=sp["vote_sd"], | |
| 178 | + vote_lo80=sp["vote80"][0], vote_hi80=sp["vote80"][1], vote_lo95=sp["vote95"][0], vote_hi95=sp["vote95"][1], | |
| 179 | + seats_mean=sp["seats_mean"], seats_median=sp["seats"], seats_lo80=sp["seats80"][0], seats_hi80=sp["seats80"][1], | |
| 180 | + seats_lo95=sp["seats95"][0], seats_hi95=sp["seats95"][1], p_most_seats=sp["p_most_seats"], p_majority=sp["p_majority"], | |
| 181 | + seat_distribution=whist(sj, w, seats_total + 1))) | |
| 182 | + p_minority = float(np.average(seats.max(axis=1) < majority, weights=w)) | |
| 128 | 183 | |
| 129 | 184 | # circonscriptions |
| 130 | 185 | s.execute(delete(ForecastRidingResult).where(ForecastRidingResult.run_id == run.id)) |
| 131 | 186 | s.execute(delete(ForecastRidingSummary).where(ForecastRidingSummary.run_id == run.id)) |
| 132 | − national_now = {p: round(float(national[:, j].mean()), 2) for j, p in enumerate(PARTIES_ALL)} | |
| 133 | − battlegrounds = [] | |
| 134 | − riding_rows = [] | |
| 187 | + national_now = {p: summary_parties[p]["vote"] for p in PARTIES_ALL} | |
| 188 | + battlegrounds, riding_rows = [], [] | |
| 189 | + be_codes = {x["code"]: x for x in be_detail.get("ridings", [])} | |
| 135 | 190 | for i, r in enumerate(ridings): |
| 136 | 191 | sh = shares[:, i, :] |
| 137 | − pw = np.array([(winners[:, i] == j).mean() for j in range(P)]) | |
| 192 | + pw = np.array([float(np.average(winners[:, i] == j, weights=w)) for j in range(P)]) | |
| 138 | 193 | order = np.argsort(-pw) |
| 139 | 194 | fav, second = int(order[0]), int(order[1]) |
| 140 | − share_mean = {p: float(sh[:, j].mean()) for j, p in enumerate(PARTIES_ALL)} | |
| 195 | + share_mean = {p: wmean(sh[:, j], w) for j, p in enumerate(PARTIES_ALL)} | |
| 141 | 196 | for j, pid in enumerate(PARTIES_ALL): |
| 142 | 197 | if pid == "aut" and share_mean[pid] < 1.5: |
| 143 | 198 | continue |
| 144 | 199 | s.add(ForecastRidingResult(run_id=run.id, riding_code=r.code, party_id=pid, vote_mean=share_mean[pid], |
| 145 | − vote_lo80=float(np.percentile(sh[:, j], 10)), vote_hi80=float(np.percentile(sh[:, j], 90)), | |
| 146 | − p_win=float(pw[j]))) | |
| 200 | + vote_lo80=wquantile(sh[:, j], w, 0.10), vote_hi80=wquantile(sh[:, j], w, 0.90), p_win=float(pw[j]))) | |
| 147 | 201 | fav_id = PARTIES_ALL[fav] |
| 148 | 202 | margin = share_mean[fav_id] - share_mean[PARTIES_ALL[second]] |
| 149 | 203 | swing = share_mean[fav_id] - r.baseline.get(fav_id, 0.0) |
@@ -154,11 +208,21 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None | ||
| 154 | 208 | if r.poll_region in seg_model.current and fav_id in seg_model.current[r.poll_region]: |
| 155 | 209 | seg_detail["region"] = {"label": POLL_REGION_LABELS.get(r.poll_region, r.poll_region), "now": seg_model.current[r.poll_region][fav_id], |
| 156 | 210 | "2022": seg_model.baseline.get(r.poll_region, {}).get(fav_id, 0)} |
| 211 | + factors = explain_factors(r, national_now, national_2022, fav_id, share_mean, seg_adj_fav=float(seg_adj[i, fav]), seg_detail=seg_detail) | |
| 212 | + if r.code in be_codes: | |
| 213 | + be = be_codes[r.code] | |
| 214 | + a = be["adjLogit"].get(fav_id, 0.0) | |
| 215 | + _d = date.fromisoformat(be["date"]) | |
| 216 | + _mois = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"][_d.month - 1] | |
| 217 | + factors.insert(2, {"factor": "partielle", "label": f"Élection partielle du {_d.day}{'er' if _d.day == 1 else ''} {_mois} {_d.year} (résultat officiel)", | |
| 218 | + "detail": f"excès de swing local observé, net du swing national du jour, rétréci (κ {be['kappa']}) : {a:+.2f} logit pour {fav_id.upper()}", | |
| 219 | + "direction": "+" if a > 0.02 else "-" if a < -0.02 else "±"}) | |
| 220 | + if temp.detail.get("enabled"): | |
| 221 | + factors.append({"factor": "marches", "label": "Tempérage par les marchés de prédiction (Polymarket)", | |
| 222 | + "detail": f"simulations repondérées (poids {temp.detail['weight']}) vers les probabilités implicites nationales ; effet indirect sur la circonscription", "direction": "±"}) | |
| 157 | 223 | summ = ForecastRidingSummary(run_id=run.id, riding_code=r.code, favorite_party_id=fav_id, favorite_p=float(pw[fav]), |
| 158 | 224 | runner_up_party_id=PARTIES_ALL[second], margin_mean=float(margin), |
| 159 | − volatility=float(1 - pw[fav]), rating=rating(float(pw[fav])), swing_from_2022=float(swing), | |
| 160 | − factors=explain_factors(r, national_now, national_2022, fav_id, share_mean, | |
| 161 | − seg_adj_fav=float(seg_adj[i, fav]), seg_detail=seg_detail)) | |
| 225 | + volatility=float(1 - pw[fav]), rating=rating(float(pw[fav])), swing_from_2022=float(swing), factors=factors) | |
| 162 | 226 | s.add(summ) |
| 163 | 227 | riding_rows.append((r.code, fav_id, float(pw[fav]), PARTIES_ALL[second], float(margin))) |
| 164 | 228 | battlegrounds.append((float(1 - pw[fav]), r.code)) |
@@ -177,7 +241,7 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None | ||
| 177 | 241 | recent_ids = [o.poll_id for o in obs if (cutoff - o.end).days <= 45] |
| 178 | 242 | base_last = {p: float(x) for p, x in zip(PARTIES_ALL, latent["last_pct"])} |
| 179 | 243 | for o in obs: |
| 180 | − w = weights[o.poll_id] | |
| 244 | + wgt = weights[o.poll_id] | |
| 181 | 245 | impact = None |
| 182 | 246 | if o.poll_id in recent_ids and len(obs) > 4: |
| 183 | 247 | sub = [x for x in obs if x.poll_id != o.poll_id] |
@@ -186,29 +250,38 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None | ||
| 186 | 250 | impact = {p: round(base_last[p] - float(x), 2) for p, x in zip(PARTIES_ALL, lat2["last_pct"])} |
| 187 | 251 | except Exception: # noqa: BLE001 |
| 188 | 252 | impact = None |
| 189 | − s.add(PollWeight(run_id=run.id, poll_id=o.poll_id, weight=w["weight"], recency_weight=w["recency"], | |
| 190 | − sample_weight=w["sample"], impact=impact)) | |
| 253 | + s.add(PollWeight(run_id=run.id, poll_id=o.poll_id, weight=wgt["weight"], recency_weight=wgt["recency"], | |
| 254 | + sample_weight=wgt["sample"], impact=impact)) | |
| 191 | 255 | |
| 192 | − # tendance quotidienne (série pour les graphiques) — allégée à 1 point / jour | |
| 193 | 256 | trend = latent["series"] |
| 194 | − | |
| 195 | − # résumé + diff avec le run publié précédent | |
| 196 | 257 | prev = s.scalar(select(ForecastRun).where(ForecastRun.is_published == True, ForecastRun.kind == "forecast", # noqa: E712 |
| 197 | 258 | ForecastRun.id != run.id).order_by(ForecastRun.id.desc())) |
| 198 | 259 | top = sorted(summary_parties.items(), key=lambda kv: -kv[1]["seats_mean"]) |
| 260 | + components = { | |
| 261 | + "pollsOnly": {"parties": polls_only, "p_minority": round(float((seats0.max(axis=1) < majority).mean()), 4)}, | |
| 262 | + "modelNoMarkets": {"parties": model_only, "p_minority": round(float((seats.max(axis=1) < majority).mean()), 4)}, | |
| 263 | + "fundamentals": fund_detail, "momentum": mom_detail, "markets": temp.detail, "byelections": be_detail, | |
| 264 | + "signals": {"attention": {k: v for k, v in (signals["attention"] or {}).items() if k in ("enabled", "asOf", "share7", "share28", "delta")}, | |
| 265 | + "media": {k: v for k, v in (signals["media"] or {}).items() if k in ("enabled", "asOf", "articles", "shareOfVoice", "tone", "counts")}, | |
| 266 | + "fundamentals": {k: v for k, v in (signals["fundamentals"] or {}).items() if k in ("enabled", "asOf", "satisfaction", "satisfactionNet", "bestPmShare", "leaderNetFavorable", "secondChoice", "sovereigntyYes")}, | |
| 267 | + "markets": {k: v for k, v in (signals["markets"] or {}).items() if k in ("asOf", "mostSeats", "majority", "volume")} if signals["markets"] else None, | |
| 268 | + "byelections": {"n": (signals["byelections"] or {}).get("n", 0), "meanSwing": (signals["byelections"] or {}).get("meanSwing")}}, | |
| 269 | + "ess": round(ess(w), 0), "nSimulations": n_sims, | |
| 270 | + } | |
| 199 | 271 | summary = { |
| 200 | 272 | "seats_total": seats_total, "majority": majority, "parties": summary_parties, "p_minority": round(p_minority, 4), |
| 201 | 273 | "leader": top[0][0], "poll_count": len(obs), "latest_poll": max(o.end for o in obs).isoformat(), |
| 202 | − "national_now": national_now, "cutoff": cutoff.isoformat(), "days_to_election": (settings.election_date - cutoff).days, | |
| 274 | + "national_now": national_now, "cutoff": cutoff.isoformat(), "days_to_election": days_left, | |
| 203 | 275 | "battlegrounds": [c for _, c in sorted(battlegrounds, reverse=True)[:15]], |
| 204 | 276 | "segments": seg_model.payload(), |
| 205 | 277 | "demographics_coverage": sum(1 for r in ridings if r.pct_french is not None), |
| 206 | 278 | "ridings": {str(c): {"fav": f, "p": round(p, 3), "second": sec, "margin": round(m, 1)} for c, f, p, sec, m in riding_rows}, |
| 279 | + "components": components, | |
| 207 | 280 | } |
| 208 | 281 | diff = None |
| 209 | − if prev and prev.summary: | |
| 210 | − new_polls = [o.poll_id for o in obs if o.end > date.fromisoformat(prev.summary.get("latest_poll", "1900-01-01")) or | |
| 211 | − o.poll_id not in {pw.poll_id for pw in s.scalars(select(PollWeight).where(PollWeight.run_id == prev.id)).all()}] | |
| 282 | + if prev and prev.summary and "parties" in prev.summary: | |
| 283 | + prev_ids = {pw.poll_id for pw in s.scalars(select(PollWeight).where(PollWeight.run_id == prev.id)).all()} | |
| 284 | + new_polls = [o.poll_id for o in obs if o.end > date.fromisoformat(prev.summary.get("latest_poll", "1900-01-01")) or o.poll_id not in prev_ids] | |
| 212 | 285 | changes = {} |
| 213 | 286 | for pid in PARTIES_ALL: |
| 214 | 287 | a, b = prev.summary["parties"].get(pid), summary_parties[pid] |
@@ -224,8 +297,16 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None | ||
| 224 | 297 | causes = [] |
| 225 | 298 | if new_polls: |
| 226 | 299 | causes.append({"kind": "new_polls", "detail": new_polls[:6]}) |
| 227 | − if not new_polls: | |
| 228 | − causes.append({"kind": "time", "detail": "recalcul sans nouveau sondage (récence, dérive vers le scrutin)"}) | |
| 300 | + prev_mk = ((prev.summary.get("components") or {}).get("markets") or {}).get("mostSeatsMarket") | |
| 301 | + cur_mk = temp.detail.get("mostSeatsMarket") | |
| 302 | + if prev_mk and cur_mk: | |
| 303 | + moved = {p: round(cur_mk.get(p, 0) - prev_mk.get(p, 0), 3) for p in cur_mk if abs(cur_mk.get(p, 0) - prev_mk.get(p, 0)) >= 0.02} | |
| 304 | + if moved: | |
| 305 | + causes.append({"kind": "markets", "detail": moved}) | |
| 306 | + if prev.model_version != settings.model_version: | |
| 307 | + causes.append({"kind": "model", "detail": f"modèle {prev.model_version} → {settings.model_version}"}) | |
| 308 | + if not causes: | |
| 309 | + causes.append({"kind": "time", "detail": "recalcul sans nouveau sondage (récence, dérive vers le scrutin, signaux mis à jour)"}) | |
| 229 | 310 | diff = {"previous_run": prev.id, "previous_at": prev.completed_at.isoformat() if prev.completed_at else None, |
| 230 | 311 | "changes": changes, "flips": flips, "causes": causes} |
| 231 | 312 | |
@@ -239,7 +320,7 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None | ||
| 239 | 320 | run.is_published = True |
| 240 | 321 | set_setting(s, "latest_forecast_run", run.id) |
| 241 | 322 | s.flush() |
| 242 | − log.info("run %d : %s — sièges %s", run.id, summary["leader"], {k: v["seats"] for k, v in summary_parties.items()}) | |
| 323 | + log.info("run %d : %s — sièges %s (ESS %s)", run.id, summary["leader"], {k: v["seats"] for k, v in summary_parties.items()}, components["ess"]) | |
| 243 | 324 | return run |
| 244 | 325 | except Exception as e: # noqa: BLE001 |
| 245 | 326 | run.status = "failed" |
modified
engine/qc26/scheduler.py
+52 −0
@@ -92,6 +92,54 @@ def job_documents(): | ||
| 92 | 92 | return {"docs": res, "positions": pos} |
| 93 | 93 | |
| 94 | 94 | |
| 95 | +@_guarded("signals_markets") | |
| 96 | +def job_signals_markets(): | |
| 97 | + from .signals import markets | |
| 98 | + with session_scope() as s: | |
| 99 | + return markets.sync_markets(s, with_history=True) | |
| 100 | + | |
| 101 | + | |
| 102 | +@_guarded("signals_daily") | |
| 103 | +def job_signals_daily(): | |
| 104 | + """Attention (Wikipédia), fondamentaux (questions de leadership), partielles, GDELT — une fois par jour, puis recalcul.""" | |
| 105 | + from .ingest import official | |
| 106 | + from .model.run import run_forecast | |
| 107 | + from .signals import attention, byelections, fundamentals, media | |
| 108 | + out = {} | |
| 109 | + with session_scope() as s: | |
| 110 | + out["attention"] = attention.sync_attention(s) | |
| 111 | + with session_scope() as s: | |
| 112 | + out["fundamentals"] = fundamentals.extract_from_polls(s) | |
| 113 | + with session_scope() as s: | |
| 114 | + out["byelections"] = byelections.sync_by_elections(s) | |
| 115 | + official.apply_by_election_incumbents(s) | |
| 116 | + try: | |
| 117 | + with session_scope() as s: | |
| 118 | + out["gdelt"] = media.sync_gdelt(s) | |
| 119 | + except Exception as e: # noqa: BLE001 | |
| 120 | + out["gdelt"] = {"error": str(e)[:200]} | |
| 121 | + with session_scope() as s: | |
| 122 | + run = run_forecast(s, trigger="signals_daily") | |
| 123 | + out["run"] = run.id | |
| 124 | + return out | |
| 125 | + | |
| 126 | + | |
| 127 | +@_guarded("signals_media") | |
| 128 | +def job_signals_media(): | |
| 129 | + from .signals import media | |
| 130 | + with session_scope() as s: | |
| 131 | + return media.sync_news(s, classify=True) | |
| 132 | + | |
| 133 | + | |
| 134 | +@_guarded("periodic_forecast") | |
| 135 | +def job_periodic_forecast(): | |
| 136 | + """Recalcul toutes les 6 h : les marchés bougent en continu, la récence des sondages aussi.""" | |
| 137 | + from .model.run import run_forecast | |
| 138 | + with session_scope() as s: | |
| 139 | + run = run_forecast(s, trigger="periodic") | |
| 140 | + return {"run": run.id} | |
| 141 | + | |
| 142 | + | |
| 95 | 143 | def _in_live_window() -> bool: |
| 96 | 144 | if settings.election_simulation: |
| 97 | 145 | return True |
@@ -126,6 +174,10 @@ def start() -> BackgroundScheduler: | ||
| 126 | 174 | sch.add_job(job_daily_forecast, "cron", hour=6, minute=15, id="daily_forecast") |
| 127 | 175 | sch.add_job(job_documents, "interval", minutes=settings.doc_watch_minutes, id="documents", next_run_time=datetime.now(timezone.utc) + timedelta(minutes=30)) |
| 128 | 176 | sch.add_job(job_live, "interval", seconds=settings.live_poll_seconds, id="live") |
| 177 | + sch.add_job(job_signals_markets, "interval", minutes=settings.signals_markets_minutes, id="signals_markets", next_run_time=datetime.now(timezone.utc) + timedelta(minutes=5)) | |
| 178 | + sch.add_job(job_signals_media, "interval", hours=settings.signals_media_hours, id="signals_media", next_run_time=datetime.now(timezone.utc) + timedelta(minutes=12)) | |
| 179 | + sch.add_job(job_signals_daily, "cron", hour=5, minute=40, id="signals_daily") | |
| 180 | + sch.add_job(job_periodic_forecast, "cron", hour="0,12,18", minute=20, id="periodic_forecast") | |
| 129 | 181 | sch.start() |
| 130 | 182 | _sched = sch |
| 131 | 183 | log.info("planificateur démarré") |
added
engine/qc26/signals/__init__.py
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +"""Signaux « au-delà des sondages » (modèle 2.0) : marchés de prédiction, attention publique, | |
| 2 | +couverture médiatique, fondamentaux tirés des questions de leadership, élections partielles. | |
| 3 | + | |
| 4 | +Chaque connecteur écrit des observations datées et sourcées dans `signals` (et les tables | |
| 5 | +dédiées `markets`, `market_prices`, `media_items`, `by_elections`). Le modèle (`model/ensemble.py`) | |
| 6 | +n'utilise que ce qui est en base : aucun signal n'est inventé, l'absence d'un signal désactive | |
| 7 | +simplement la composante correspondante. | |
| 8 | +""" | |
added
engine/qc26/signals/attention.py
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +"""Attention publique — pages vues Wikipédia (API REST Wikimedia, publique, sans clé). | |
| 2 | + | |
| 3 | +Pour chaque parti : page du parti + page de son·sa chef·fe (Wikipédia FR), vues quotidiennes | |
| 4 | +« user » (robots exclus) depuis le 1er juin 2026. Le signal dérivé est la **part d'attention** | |
| 5 | +(part des vues du parti dans le total des cinq partis) et son évolution 7 j vs 28 j. C'est un | |
| 6 | +indicateur de saillance, pas d'intention de vote : il n'entre dans le modèle que comme | |
| 7 | +composante de momentum bornée. | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import logging | |
| 12 | +from datetime import date, datetime, timedelta | |
| 13 | +from urllib.parse import quote | |
| 14 | + | |
| 15 | +import httpx | |
| 16 | +from sqlalchemy import select | |
| 17 | +from sqlalchemy.orm import Session | |
| 18 | + | |
| 19 | +from ..config import settings | |
| 20 | +from ..db import Signal, Source, utcnow | |
| 21 | +from ..parties import PARTY_IDS | |
| 22 | +from .markets import _upsert_signal | |
| 23 | + | |
| 24 | +log = logging.getLogger("qc26.signals.attention") | |
| 25 | + | |
| 26 | +# Pages Wikipédia FR (titres exacts) — parti puis chef·fe (registre DGEQ ; pour QS la cheffe | |
| 27 | +# officielle est peu connue du public : on suit la co-porte-parole parlementaire). | |
| 28 | +PAGES: dict[str, list[str]] = { | |
| 29 | + "caq": ["Coalition_avenir_Québec", "Christine_Fréchette"], | |
| 30 | + "plq": ["Parti_libéral_du_Québec", "Charles_Milliard"], | |
| 31 | + "pq": ["Parti_québécois", "Paul_St-Pierre_Plamondon"], | |
| 32 | + "qs": ["Québec_solidaire", "Ruba_Ghazal"], | |
| 33 | + "pcq": ["Parti_conservateur_du_Québec_(2009)", "Éric_Duhaime"], | |
| 34 | +} | |
| 35 | +ELECTION_PAGE = "Élections_générales_québécoises_de_2026" | |
| 36 | +START = date(2026, 6, 1) | |
| 37 | + | |
| 38 | + | |
| 39 | +def fetch_views(title: str, start: date, end: date) -> dict[date, int]: | |
| 40 | + url = (f"https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/fr.wikipedia/all-access/user/" | |
| 41 | + f"{quote(title, safe='')}/daily/{start:%Y%m%d}/{end:%Y%m%d}") | |
| 42 | + with httpx.Client(timeout=30, headers={"User-Agent": "QC26Bot/1.0 (https://www.qc26.ca; contact@qc26.ca)"}) as c: | |
| 43 | + r = c.get(url) | |
| 44 | + if r.status_code == 404: | |
| 45 | + return {} | |
| 46 | + r.raise_for_status() | |
| 47 | + items = r.json().get("items", []) | |
| 48 | + return {datetime.strptime(i["timestamp"][:8], "%Y%m%d").date(): int(i["views"]) for i in items} | |
| 49 | + | |
| 50 | + | |
| 51 | +def sync_attention(s: Session) -> dict: | |
| 52 | + src = s.get(Source, "wikipedia-pageviews") | |
| 53 | + if not src: | |
| 54 | + src = Source(id="wikipedia-pageviews", name="Wikimedia — pages vues Wikipédia FR (partis, chef·fes)", url="https://wikimedia.org/api/rest_v1/", tier=3, kind="signal") | |
| 55 | + s.add(src) | |
| 56 | + src.last_checked_at = utcnow() | |
| 57 | + end = date.today() - timedelta(days=1) | |
| 58 | + n, errors = 0, 0 | |
| 59 | + # reprise : on repart 10 jours avant la dernière observation connue | |
| 60 | + last = s.scalar(select(Signal.date).where(Signal.source == "wikipedia", Signal.key == "pageviews").order_by(Signal.date.desc())) | |
| 61 | + start = max(START, (last - timedelta(days=10))) if last else START | |
| 62 | + for pid, titles in PAGES.items(): | |
| 63 | + total: dict[date, int] = {} | |
| 64 | + detail: dict[str, dict[date, int]] = {} | |
| 65 | + for t in titles: | |
| 66 | + try: | |
| 67 | + v = fetch_views(t, start, end) | |
| 68 | + detail[t] = v | |
| 69 | + for d, x in v.items(): | |
| 70 | + total[d] = total.get(d, 0) + x | |
| 71 | + except Exception as e: # noqa: BLE001 | |
| 72 | + errors += 1 | |
| 73 | + log.warning("pageviews %s : %s", t, e) | |
| 74 | + for d, x in total.items(): | |
| 75 | + _upsert_signal(s, "wikipedia", "pageviews", pid, d, float(x), url=f"https://fr.wikipedia.org/wiki/{titles[0]}", | |
| 76 | + meta={"pages": {t: detail[t].get(d) for t in detail}}) | |
| 77 | + n += 1 | |
| 78 | + try: | |
| 79 | + for d, x in fetch_views(ELECTION_PAGE, start, end).items(): | |
| 80 | + _upsert_signal(s, "wikipedia", "pageviews_election", None, d, float(x), url=f"https://fr.wikipedia.org/wiki/{ELECTION_PAGE}") | |
| 81 | + except Exception as e: # noqa: BLE001 | |
| 82 | + errors += 1 | |
| 83 | + log.warning("pageviews élection : %s", e) | |
| 84 | + src.last_ok_at, src.status = utcnow(), "operational" if not errors else "degraded" | |
| 85 | + src.detail = f"{n} observations parti-jour" | |
| 86 | + return {"observations": n, "errors": errors, "from": start.isoformat(), "to": end.isoformat()} | |
| 87 | + | |
| 88 | + | |
| 89 | +def attention_summary(s: Session, cutoff: date | None = None) -> dict: | |
| 90 | + """Part d'attention 7 j, 28 j et variation, par parti ; série quotidienne des parts.""" | |
| 91 | + cutoff = cutoff or date.today() | |
| 92 | + rows = s.execute(select(Signal.party_id, Signal.date, Signal.value).where(Signal.source == "wikipedia", Signal.key == "pageviews", Signal.date <= cutoff)).all() | |
| 93 | + if not rows: | |
| 94 | + return {"enabled": False} | |
| 95 | + by_day: dict[date, dict[str, float]] = {} | |
| 96 | + for pid, d, v in rows: | |
| 97 | + by_day.setdefault(d, {})[pid] = v | |
| 98 | + days = sorted(by_day) | |
| 99 | + series = [] | |
| 100 | + for d in days: | |
| 101 | + tot = sum(by_day[d].values()) or 1 | |
| 102 | + series.append({"date": d.isoformat(), "share": {p: round(100 * by_day[d].get(p, 0) / tot, 2) for p in PARTY_IDS}, "views": {p: by_day[d].get(p, 0) for p in PARTY_IDS}}) | |
| 103 | + last = days[-1] | |
| 104 | + def window(n: int) -> dict[str, float]: | |
| 105 | + w = [d for d in days if (last - d).days < n] | |
| 106 | + tot = {p: sum(by_day[d].get(p, 0) for d in w) for p in PARTY_IDS} | |
| 107 | + all_ = sum(tot.values()) or 1 | |
| 108 | + return {p: 100 * tot[p] / all_ for p in PARTY_IDS} | |
| 109 | + s7, s28 = window(7), window(28) | |
| 110 | + election = s.execute(select(Signal.date, Signal.value).where(Signal.source == "wikipedia", Signal.key == "pageviews_election", Signal.date <= cutoff).order_by(Signal.date)).all() | |
| 111 | + return {"enabled": True, "asOf": last.isoformat(), "share7": {p: round(v, 2) for p, v in s7.items()}, "share28": {p: round(v, 2) for p, v in s28.items()}, | |
| 112 | + "delta": {p: round(s7[p] - s28[p], 2) for p in PARTY_IDS}, "series": series[-120:], | |
| 113 | + "election": [{"date": d.isoformat(), "views": v} for d, v in election][-120:], "pages": PAGES} | |
added
engine/qc26/signals/byelections.py
+177 −0
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added
engine/qc26/signals/fundamentals.py
+230 −0
Large diff (230 lines) is not loaded by default to keep the page fast.Load diff
added
engine/qc26/signals/markets.py
+226 −0
Large diff (226 lines) is not loaded by default to keep the page fast.Load diff
added
engine/qc26/signals/media.py
+267 −0
Large diff (267 lines) is not loaded by default to keep the page fast.Load diff
modified
engine/qc26/util.py
+18 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/boussole/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/carte/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/circonscription/[slug]/page.tsx
+6 −6
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/circonscriptions/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/documents/[id]/page.tsx
+3 −3
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/documents/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/enjeux/[topic]/page.tsx
+3 −3
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/enjeux/page.tsx
+4 −4
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/globals.css
+166 −46
Large diff (273 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/layout.tsx
+21 −15
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/live/model-vs-reality/page.tsx
+3 −3
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/live/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/methodologie/changelog/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/methodologie/page.tsx
+15 −5
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/page.tsx
+54 −29
Large diff (168 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/partis/[id]/page.tsx
+2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/partis/comparateur/page.tsx
+3 −3
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/partis/page.tsx
+3 −3
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/plus/page.tsx
+3 −3
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/prevision/page.tsx
+58 −20
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/replay/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/app/signaux/page.tsx
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/sitemap.ts
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/sondages/[id]/page.tsx
+5 −5
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/sondages/page.tsx
+9 −11
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/app/statut/page.tsx
+4 −4
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/ask-box.tsx
+2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/charts.tsx
+162 −49
Large diff (323 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/compass.tsx
+7 −7
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/forecast-blocks.tsx
+125 −83
Large diff (319 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/forecast-history.tsx
+3 −3
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/live.tsx
+7 −7
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/map-section.tsx
+2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/map.tsx
+16 −16
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/party.tsx
+3 −3
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/polls-table.tsx
+3 −3
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/replay.tsx
+2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/riding-live.tsx
+2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/riding-map.tsx
+2 −2
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/ridings-grid.tsx
+3 −3
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/shell.tsx
+50 −38
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/components/signals.tsx
+284 −0
Large diff (284 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/status-strip.tsx
+2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/components/theme.tsx
+35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/components/trend-chart.tsx
+115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/components/ui.tsx
+45 −22
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/lib/api.ts
+24 −1
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
modified
web/src/lib/parties.ts
+11 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff