SPB Git

spb/qc26

Public

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>
Simon-Pierre Boucher committed (Sep 7, 2026) parent 570fe76 Browse files

70 changed files +3,279 −634

70 changed files
modified CLAUDE.md +7 −2
@@ -12,11 +12,16 @@ Spécification produit complète : voir la mission QC26 (design « Editorial Dat
12 ## Où sont les choses 12 ## Où sont les choses
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/`. 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 - Web : `web/` (Next 16, :8200), rewrite `/api/*` → moteur. Composants du design system dans `web/src/components/`. 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 - 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. 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 ## Gotchas 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 - DGEQ : « PCQ » = Parti communiste, le Parti conservateur est « PCOQ » ; CSV en cp1252 avec tiret 0x96 (`util.fix_dgeq_dash`). 25 - DGEQ : « PCQ » = Parti communiste, le Parti conservateur est « PCOQ » ; CSV en cp1252 avec tiret 0x96 (`util.fix_dgeq_dash`).
21 - 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. 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 - La règle « pas de candidature → part résiduelle » ne s'active que pour un parti dont la liste est ≥ 90 % complète. 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 ## Production 50 ## Production
51 51
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. 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", 2+ "app": "qc26",
3− "label": "QC26 — L'élection québécoise 2026, en données", 3+ "label": "QC26 — L'élection québécoise 2026, en données",
4− "domain": "www.qc26.ca", 4+ "domain": "www.qc26.ca",
5− "port": 8200, 5+ "port": 8200,
6− "health_path": "/healthz", 6+ "health_path": "/healthz",
7− "dir": "~/apps/qc26", 7+ "dir": "~/apps/qc26",
8− "extra_paths": [], 8+ "extra_paths": [],
9− "sync_excludes": [ 9+ "sync_excludes": [
10− "engine/.venv/", 10+ "engine/.venv/",
11− "__pycache__/", 11+ "__pycache__/",
12− ".pytest_cache/", 12+ ".pytest_cache/",
13− ".ruff_cache/", 13+ ".ruff_cache/",
14− "*.egg-info/", 14+ "*.egg-info/",
15− ".git/", 15+ ".git/",
16− "web/node_modules/", 16+ "web/node_modules/",
17− "web/.next/", 17+ "web/.next/",
18− "engine/logs/", 18+ "engine/logs/",
19− "logs/", 19+ "logs/",
20− ".DS_Store", 20+ ".DS_Store",
21− "engine/data/qc26.db", 21+ "engine/data/qc26.db",
22− "engine/data/qc26.db-shm", 22+ "engine/data/qc26.db-shm",
23− "engine/data/qc26.db-wal", 23+ "engine/data/qc26.db-wal",
24− "engine/data/archive/", 24+ "engine/data/archive/",
25− "engine/data/cache/" 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": { 36+ "ram_gb": 4,
28− "runtimes": [ 37+ "ports": [
29− "pm2", 38+ 8200,
30− "ngrok", 39+ 8201
31− "node", 40+ ]
32− "pnpm", 41+ },
33− "uv", 42+ "ram_mb_observed": 1200,
34− "uv-python@3.12" 43+ "size_mb": 120,
35− ], 44+ "placement": {
36− "ram_gb": 4, 45+ "pin": null,
37− "ports": [ 46+ "prefer": "M3U96a",
38− 8200, 47+ "avoid": [
39− 8201 48+ "M3U96b",
40− ] 49+ "M1M32"
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− }
109 ], 50 ],
110− "ngrok": { 51+ "reason": "Monte Carlo numpy + build Next.js : cœurs et RAM ; uv-py3.12 + pnpm présents ; M3U96b réservé hfmarketdata"
111− "name": "qc26-ngrok", 52+ },
112− "url": "www.qc26.ca", 53+ "processes": [
113− "port": 8200 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 return positions.compute_positions(s, only_missing=only_missing) 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 @router.get("/review", dependencies=[Depends(require_admin)]) 139 @router.get("/review", dependencies=[Depends(require_admin)])
114 def review_queue(s: Session = Depends(db)): 140 def review_queue(s: Session = Depends(db)):
115 polls = s.scalars(select(Poll).where(Poll.validated == False).order_by(desc(Poll.fieldwork_end))).all() # noqa: E712 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 doc = s.scalar(select(PollDocument).where(PollDocument.sha256 == sha)) 389 doc = s.scalar(select(PollDocument).where(PollDocument.sha256 == sha))
390 if not doc: 390 if not doc:
391 raise HTTPException(404) 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 @router.get("/documents/{doc_id}") 399 @router.get("/documents/{doc_id}")
@@ -413,7 +417,11 @@ def document_file(doc_id: int, vid: int, s: Session = Depends(db)):
413 v = s.get(DocumentVersion, vid) 417 v = s.get(DocumentVersion, vid)
414 if not v or v.document_id != doc_id: 418 if not v or v.document_id != doc_id:
415 raise HTTPException(404) 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 @router.get("/proposals") 427 @router.get("/proposals")
@@ -623,7 +631,8 @@ def replay(t: str | None = None, s: Session = Depends(db)):
623 if not target: 631 if not target:
624 raise HTTPException(404) 632 raise HTTPException(404)
625 from pathlib import Path 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 ridings_by_name = {} 636 ridings_by_name = {}
628 from ..util import fix_dgeq_dash, norm_name 637 from ..util import fix_dgeq_dash, norm_name
629 for r in s.scalars(select(Riding)).all(): 638 for r in s.scalars(select(Riding)).all():
@@ -678,7 +687,8 @@ def status(s: Session = Depends(db)):
678 return {"components": {"pollWatcher": "operational" if poll_srcs and all(x.status == "operational" for x in poll_srcs) else ("degraded" if poll_srcs else "ready"), 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 "forecastEngine": "operational" if run and run.status == "ok" else "degraded", 688 "forecastEngine": "operational" if run and run.status == "ok" else "degraded",
680 "electionFeed": next((x.status for x in sources if x.id == "eq-live"), "ready"), 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 "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, 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 "lastOkAt": x.last_ok_at.isoformat() if x.last_ok_at else None, "detail": x.detail} for x in sources], 693 "lastOkAt": x.last_ok_at.isoformat() if x.last_ok_at else None, "detail": x.detail} for x in sources],
684 "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, 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 CHANGELOG = [ 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 {"version": "1.1.0", "date": "2026-09-06", "title": "Recensement 2021 et swing par segments", 708 {"version": "1.1.0", "date": "2026-09-06", "title": "Recensement 2021 et swing par segments",
692 "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.", 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 "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).", 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 @router.get("/changelog") 777 @router.get("/changelog")
708 def changelog(s: Session = Depends(db)): 778 def changelog(s: Session = Depends(db)):
709 runs = s.scalars(select(ForecastRun).where(ForecastRun.kind == "forecast", ForecastRun.status == "ok").order_by(desc(ForecastRun.id)).limit(30)).all() 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 # ils dérivent de la liste officielle des circonscriptions (127 en 2026 → 64). 52 # ils dérivent de la liste officielle des circonscriptions (127 en 2026 → 64).
53 53
54 # --- Modèle --- 54 # --- Modèle ---
55− model_version: str = "1.1.0" 55+ model_version: str = "2.0.0"
56 n_simulations: int = 20000 56 n_simulations: int = 20000
57 random_seed: int | None = None 57 random_seed: int | None = None
58 recency_halflife_days: float = 14.0 # hors campagne 58 recency_halflife_days: float = 14.0 # hors campagne
@@ -68,9 +68,39 @@ class Settings(BaseSettings):
68 riding_error_sd: float = 0.20 # logit 68 riding_error_sd: float = 0.20 # logit
69 incumbent_retirement_penalty: float = 0.06 # logit (~1,3 pp) 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 # --- Sources / connecteurs --- 91 # --- Sources / connecteurs ---
72 firecrawl_api_key: str = "" 92 firecrawl_api_key: str = ""
73 anthropic_api_key: str = "" 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 llm_model: str = "claude-opus-5" 104 llm_model: str = "claude-opus-5"
75 llm_enabled: bool = True 105 llm_enabled: bool = True
76 user_agent: str = ( 106 user_agent: str = (
@@ -111,5 +141,5 @@ class Settings(BaseSettings):
111 141
112 settings = Settings() 142 settings = Settings()
113 for _d in (settings.archive_dir / "polls", settings.archive_dir / "documents", 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 _d.mkdir(parents=True, exist_ok=True) 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 from ... import llm 21 from ... import llm
22 from ...config import settings 22 from ...config import settings
23 from ...db import (DocumentChunk, DocumentVersion, IngestionJob, Party, PartyDocument, PolicyProposal, Source, audit, utcnow) 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 from ..polls.registry import firecrawl_map, firecrawl_scrape 25 from ..polls.registry import firecrawl_map, firecrawl_scrape
26 26
27 log = logging.getLogger("qc26.parties.docs") 27 log = logging.getLogger("qc26.parties.docs")
@@ -232,8 +232,9 @@ def fetch_version(s: Session, doc: PartyDocument) -> DocumentVersion | None:
232 232
233 def diff_versions(old: DocumentVersion, new: DocumentVersion) -> dict: 233 def diff_versions(old: DocumentVersion, new: DocumentVersion) -> dict:
234 import difflib 234 import difflib
235− a = Path(old.text_path).read_text().splitlines() if old.text_path and Path(old.text_path).exists() else [] 235+ _pa, _pb = resolve_archive_path(old.text_path), resolve_archive_path(new.text_path)
236− b = Path(new.text_path).read_text().splitlines() if new.text_path and Path(new.text_path).exists() else [] 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 sm = difflib.SequenceMatcher(None, a, b, autojunk=False) 238 sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
238 added, removed = [], [] 239 added, removed = [], []
239 for tag, i1, i2, j1, j2 in sm.get_opcodes(): 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 def analyze_version(s: Session, doc: PartyDocument, v: DocumentVersion, max_chunks: int = 12) -> int: 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 if len(text.strip()) < 300: 282 if len(text.strip()) < 300:
281 doc.status = "ignored" 283 doc.status = "ignored"
282 v.analyzed_at = utcnow() 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 s.commit() 367 s.commit()
366 if not doc.title or doc.title == url.rsplit("/", 1)[-1][:200]: 368 if not doc.title or doc.title == url.rsplit("/", 1)[-1][:200]:
367 # titre depuis le texte (première ligne significative) 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 first = next((l.strip() for l in txt.splitlines() if len(l.strip()) > 12), None) 372 first = next((l.strip() for l in txt.splitlines() if len(l.strip()) > 12), None)
370 if first: 373 if first:
371 doc.title = first[:200] 374 doc.title = first[:200]
modified engine/qc26/connectors/polls/base.py +4 −2
@@ -20,7 +20,7 @@ from ... import llm
20 from ...config import settings 20 from ...config import settings
21 from ...db import Poll, PollDocument, PollResult, PollSubsample, Pollster, audit, utcnow 21 from ...db import Poll, PollDocument, PollResult, PollSubsample, Pollster, audit, utcnow
22 from ...parties import PARTY_IDS, party_id_from_label 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 log = logging.getLogger("qc26.polls.connector") 25 log = logging.getLogger("qc26.polls.connector")
26 26
@@ -95,7 +95,9 @@ class PollConnector:
95 return doc 95 return doc
96 96
97 def extract_text(self, doc: PollDocument) -> tuple[list[str], list[tuple[int, str]]]: 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 if path.suffix == ".pdf": 101 if path.suffix == ".pdf":
100 pages = pdf_pages_text(path) 102 pages = pdf_pages_text(path)
101 tables = pdf_tables_text(path) 103 tables = pdf_tables_text(path)
modified engine/qc26/db.py +135 −26
@@ -11,7 +11,7 @@ from contextlib import contextmanager
11 from datetime import date, datetime, timezone 11 from datetime import date, datetime, timezone
12 from typing import Any, Iterator 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 UniqueConstraint, create_engine, event, text) 15 UniqueConstraint, create_engine, event, text)
16 from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship, sessionmaker 16 from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship, sessionmaker
17 17
@@ -22,6 +22,23 @@ def utcnow() -> datetime:
22 return datetime.now(timezone.utc) 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 class Base(DeclarativeBase): 42 class Base(DeclarativeBase):
26 pass 43 pass
27 44
@@ -47,7 +64,7 @@ class Party(Base):
47 vote_2022: Mapped[float | None] = mapped_column(Float) 64 vote_2022: Mapped[float | None] = mapped_column(Float)
48 order: Mapped[int] = mapped_column(Integer, default=0) 65 order: Mapped[int] = mapped_column(Integer, default=0)
49 color_note: Mapped[str | None] = mapped_column(Text) 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 class Region(Base): 70 class Region(Base):
@@ -115,7 +132,7 @@ class RidingDemographics(Base):
115 ada_count: Mapped[int] = mapped_column(Integer, default=0) 132 ada_count: Mapped[int] = mapped_column(Integer, default=0)
116 coverage: Mapped[float | None] = mapped_column(Float) # part de la population de la circ. couverte par des ADA 133 coverage: Mapped[float | None] = mapped_column(Float) # part de la population de la circ. couverte par des ADA
117 source: Mapped[str] = mapped_column(String(300)) 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 class HistoricalResult(Base): 138 class HistoricalResult(Base):
@@ -149,8 +166,8 @@ class Candidate(Base):
149 incumbent: Mapped[bool] = mapped_column(Boolean, default=False) 166 incumbent: Mapped[bool] = mapped_column(Boolean, default=False)
150 status: Mapped[str] = mapped_column(String(8)) # CPA (confirmé parti) / CIA (indépendant) … 167 status: Mapped[str] = mapped_column(String(8)) # CPA (confirmé parti) / CIA (indépendant) …
151 state: Mapped[str] = mapped_column(String(8)) 168 state: Mapped[str] = mapped_column(String(8))
152− dgeq_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 169+ dgeq_updated_at: Mapped[datetime | None] = mapped_column(TZDateTime)
153− fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 170+ fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
154 171
155 172
156 # --------------------------------------------------------------------- sondages 173 # --------------------------------------------------------------------- sondages
@@ -175,7 +192,7 @@ class PollDocument(Base):
175 content_type: Mapped[str | None] = mapped_column(String(80)) 192 content_type: Mapped[str | None] = mapped_column(String(80))
176 bytes: Mapped[int] = mapped_column(Integer) 193 bytes: Mapped[int] = mapped_column(Integer)
177 pages: Mapped[int | None] = mapped_column(Integer) 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 text_path: Mapped[str | None] = mapped_column(String(300)) 196 text_path: Mapped[str | None] = mapped_column(String(300))
180 http_last_modified: Mapped[str | None] = mapped_column(String(80)) 197 http_last_modified: Mapped[str | None] = mapped_column(String(80))
181 pollster_id: Mapped[str | None] = mapped_column(String(32)) 198 pollster_id: Mapped[str | None] = mapped_column(String(32))
@@ -204,8 +221,8 @@ class Poll(Base):
204 validation_notes: Mapped[dict | list | None] = mapped_column(JSON) 221 validation_notes: Mapped[dict | list | None] = mapped_column(JSON)
205 undecided: Mapped[float | None] = mapped_column(Float) 222 undecided: Mapped[float | None] = mapped_column(Float)
206 notes: Mapped[dict | list | None] = mapped_column(JSON) 223 notes: Mapped[dict | list | None] = mapped_column(JSON)
207− ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 224+ ingested_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
208− updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) 225+ updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow)
209 excluded: Mapped[bool] = mapped_column(Boolean, default=False) 226 excluded: Mapped[bool] = mapped_column(Boolean, default=False)
210 exclusion_reason: Mapped[str | None] = mapped_column(String(200)) 227 exclusion_reason: Mapped[str | None] = mapped_column(String(200))
211 results: Mapped[list["PollResult"]] = relationship(cascade="all, delete-orphan", lazy="selectin") 228 results: Mapped[list["PollResult"]] = relationship(cascade="all, delete-orphan", lazy="selectin")
@@ -250,8 +267,8 @@ class PartyDocument(Base):
250 url: Mapped[str] = mapped_column(String(600), unique=True) 267 url: Mapped[str] = mapped_column(String(600), unique=True)
251 doc_type: Mapped[str] = mapped_column(String(32)) # plateforme / cadre_financier / communique / page / engagement 268 doc_type: Mapped[str] = mapped_column(String(32)) # plateforme / cadre_financier / communique / page / engagement
252 published_date: Mapped[date | None] = mapped_column(Date) 269 published_date: Mapped[date | None] = mapped_column(Date)
253− discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 270+ discovered_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
254− last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 271+ last_checked_at: Mapped[datetime | None] = mapped_column(TZDateTime)
255 current_version_id: Mapped[int | None] = mapped_column(Integer) 272 current_version_id: Mapped[int | None] = mapped_column(Integer)
256 proposals_count: Mapped[int] = mapped_column(Integer, default=0) 273 proposals_count: Mapped[int] = mapped_column(Integer, default=0)
257 status: Mapped[str] = mapped_column(String(16), default="new") # new / analyzed / failed / ignored 274 status: Mapped[str] = mapped_column(String(16), default="new") # new / analyzed / failed / ignored
@@ -268,8 +285,8 @@ class DocumentVersion(Base):
268 content_type: Mapped[str | None] = mapped_column(String(80)) 285 content_type: Mapped[str | None] = mapped_column(String(80))
269 bytes: Mapped[int] = mapped_column(Integer) 286 bytes: Mapped[int] = mapped_column(Integer)
270 pages: Mapped[int | None] = mapped_column(Integer) 287 pages: Mapped[int | None] = mapped_column(Integer)
271− fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 288+ fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
272− analyzed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 289+ analyzed_at: Mapped[datetime | None] = mapped_column(TZDateTime)
273 diff_summary: Mapped[dict | None] = mapped_column(JSON) 290 diff_summary: Mapped[dict | None] = mapped_column(JSON)
274 291
275 292
@@ -298,7 +315,7 @@ class PolicyProposal(Base):
298 quote: Mapped[str] = mapped_column(Text) 315 quote: Mapped[str] = mapped_column(Text)
299 confidence: Mapped[float] = mapped_column(Float) 316 confidence: Mapped[float] = mapped_column(Float)
300 status: Mapped[str] = mapped_column(String(16), default="accepted") # accepted / review / rejected 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 class CompassQuestion(Base): 321 class CompassQuestion(Base):
@@ -324,7 +341,7 @@ class PartyPosition(Base):
324 proposal_ids: Mapped[list | None] = mapped_column(JSON) 341 proposal_ids: Mapped[list | None] = mapped_column(JSON)
325 rationale: Mapped[str | None] = mapped_column(Text) 342 rationale: Mapped[str | None] = mapped_column(Text)
326 status: Mapped[str] = mapped_column(String(16), default="pending") # pending / extracted / review / manual 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 # -------------------------------------------------------------------- forecast 347 # -------------------------------------------------------------------- forecast
@@ -334,8 +351,8 @@ class ForecastRun(Base):
334 model_version: Mapped[str] = mapped_column(String(16)) 351 model_version: Mapped[str] = mapped_column(String(16))
335 kind: Mapped[str] = mapped_column(String(16), default="forecast") # forecast / nowcast 352 kind: Mapped[str] = mapped_column(String(16), default="forecast") # forecast / nowcast
336 data_cutoff: Mapped[date] = mapped_column(Date) 353 data_cutoff: Mapped[date] = mapped_column(Date)
337− started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 354+ started_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
338− completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 355+ completed_at: Mapped[datetime | None] = mapped_column(TZDateTime)
339 poll_count: Mapped[int] = mapped_column(Integer, default=0) 356 poll_count: Mapped[int] = mapped_column(Integer, default=0)
340 n_simulations: Mapped[int] = mapped_column(Integer, default=0) 357 n_simulations: Mapped[int] = mapped_column(Integer, default=0)
341 seed: Mapped[int | None] = mapped_column(Integer) 358 seed: Mapped[int | None] = mapped_column(Integer)
@@ -427,7 +444,7 @@ class PollWeight(Base):
427 class LiveSnapshot(Base): 444 class LiveSnapshot(Base):
428 __tablename__ = "live_snapshots" 445 __tablename__ = "live_snapshots"
429 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 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 source_updated_at: Mapped[str | None] = mapped_column(String(40)) 448 source_updated_at: Mapped[str | None] = mapped_column(String(40))
432 sha256: Mapped[str] = mapped_column(String(64), index=True) 449 sha256: Mapped[str] = mapped_column(String(64), index=True)
433 path: Mapped[str] = mapped_column(String(300)) 450 path: Mapped[str] = mapped_column(String(300))
@@ -445,7 +462,7 @@ class LiveSnapshot(Base):
445 class LiveRidingState(Base): 462 class LiveRidingState(Base):
446 __tablename__ = "live_riding_states" 463 __tablename__ = "live_riding_states"
447 riding_code: Mapped[int] = mapped_column(Integer, primary_key=True) 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 source_updated_at: Mapped[str | None] = mapped_column(String(40)) 466 source_updated_at: Mapped[str | None] = mapped_column(String(40))
450 status: Mapped[str] = mapped_column(String(16), default="NOT_STARTED") # NOT_STARTED/REPORTING/LEADING/PROJECTED/ELECTED/FINAL 467 status: Mapped[str] = mapped_column(String(16), default="NOT_STARTED") # NOT_STARTED/REPORTING/LEADING/PROJECTED/ELECTED/FINAL
451 bureaux_completes: Mapped[int] = mapped_column(Integer, default=0) 468 bureaux_completes: Mapped[int] = mapped_column(Integer, default=0)
@@ -466,7 +483,7 @@ class LiveRidingState(Base):
466 class ElectionEvent(Base): 483 class ElectionEvent(Base):
467 __tablename__ = "election_events" 484 __tablename__ = "election_events"
468 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 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 kind: Mapped[str] = mapped_column(String(32), index=True) # lead_change / riding_complete / seats_update / reporting / first_results / final 487 kind: Mapped[str] = mapped_column(String(32), index=True) # lead_change / riding_complete / seats_update / reporting / first_results / final
471 riding_code: Mapped[int | None] = mapped_column(Integer) 488 riding_code: Mapped[int | None] = mapped_column(Integer)
472 party_id: Mapped[str | None] = mapped_column(String(8)) 489 party_id: Mapped[str | None] = mapped_column(String(8))
@@ -478,12 +495,104 @@ class ElectionEvent(Base):
478 class NowcastRun(Base): 495 class NowcastRun(Base):
479 __tablename__ = "nowcast_runs" 496 __tablename__ = "nowcast_runs"
480 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 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 snapshot_id: Mapped[int | None] = mapped_column(Integer) 499 snapshot_id: Mapped[int | None] = mapped_column(Integer)
483 ridings_reporting: Mapped[int] = mapped_column(Integer) 500 ridings_reporting: Mapped[int] = mapped_column(Integer)
484 summary: Mapped[dict] = mapped_column(JSON) # par parti : élus, en avance, total, p_majority, seats mean/ci 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 # ------------------------------------------------------------------ exploitation 596 # ------------------------------------------------------------------ exploitation
488 class Source(Base): 597 class Source(Base):
489 __tablename__ = "sources" 598 __tablename__ = "sources"
@@ -492,8 +601,8 @@ class Source(Base):
492 url: Mapped[str] = mapped_column(String(600)) 601 url: Mapped[str] = mapped_column(String(600))
493 tier: Mapped[int] = mapped_column(Integer, default=1) # 1 officiel, 2 firme, 3 média, 5 index 602 tier: Mapped[int] = mapped_column(Integer, default=1) # 1 officiel, 2 firme, 3 média, 5 index
494 kind: Mapped[str] = mapped_column(String(32)) 603 kind: Mapped[str] = mapped_column(String(32))
495− last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 604+ last_checked_at: Mapped[datetime | None] = mapped_column(TZDateTime)
496− last_ok_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 605+ last_ok_at: Mapped[datetime | None] = mapped_column(TZDateTime)
497 status: Mapped[str] = mapped_column(String(16), default="unknown") # operational / degraded / down / ready 606 status: Mapped[str] = mapped_column(String(16), default="unknown") # operational / degraded / down / ready
498 detail: Mapped[str | None] = mapped_column(String(300)) 607 detail: Mapped[str | None] = mapped_column(String(300))
499 608
@@ -502,8 +611,8 @@ class IngestionJob(Base):
502 __tablename__ = "ingestion_jobs" 611 __tablename__ = "ingestion_jobs"
503 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 612 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
504 kind: Mapped[str] = mapped_column(String(48), index=True) 613 kind: Mapped[str] = mapped_column(String(48), index=True)
505− started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 614+ started_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
506− finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 615+ finished_at: Mapped[datetime | None] = mapped_column(TZDateTime)
507 status: Mapped[str] = mapped_column(String(16), default="running") 616 status: Mapped[str] = mapped_column(String(16), default="running")
508 items: Mapped[int] = mapped_column(Integer, default=0) 617 items: Mapped[int] = mapped_column(Integer, default=0)
509 message: Mapped[str | None] = mapped_column(Text) 618 message: Mapped[str | None] = mapped_column(Text)
@@ -513,7 +622,7 @@ class IngestionJob(Base):
513 class AuditLog(Base): 622 class AuditLog(Base):
514 __tablename__ = "audit_logs" 623 __tablename__ = "audit_logs"
515 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 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 actor: Mapped[str] = mapped_column(String(64)) # system:<job> / admin 626 actor: Mapped[str] = mapped_column(String(64)) # system:<job> / admin
518 entity: Mapped[str] = mapped_column(String(48)) 627 entity: Mapped[str] = mapped_column(String(48))
519 entity_id: Mapped[str] = mapped_column(String(80)) 628 entity_id: Mapped[str] = mapped_column(String(80))
@@ -537,7 +646,7 @@ class Setting(Base):
537 __tablename__ = "settings" 646 __tablename__ = "settings"
538 key: Mapped[str] = mapped_column(String(64), primary_key=True) 647 key: Mapped[str] = mapped_column(String(64), primary_key=True)
539 value: Mapped[dict | list | str | None] = mapped_column(JSON) 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 # -------------------------------------------------------------------- moteur 652 # -------------------------------------------------------------------- moteur
modified engine/qc26/ingest/official.py +31 −0
@@ -224,16 +224,47 @@ def sync_candidates(s: Session, refresh: bool = True) -> int:
224 counts[pid] += 1 224 counts[pid] += 1
225 if c.get("depute_sortant") == "O": 225 if c.get("depute_sortant") == "O":
226 incumbents_running[code] = True 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 for pid, n in counts.items(): 233 for pid, n in counts.items():
228 p = s.get(Party, pid) 234 p = s.get(Party, pid)
229 if p: 235 if p:
230 p.candidates_count = n 236 p.candidates_count = n
231 for code, rd in ridings.items(): 237 for code, rd in ridings.items():
232 rd.incumbent_running = incumbents_running.get(code, False) 238 rd.incumbent_running = incumbents_running.get(code, False)
239+ apply_by_election_incumbents(s, ridings)
233 _mark_source(s, "eq-candidates", "Élections Québec — candidatures officielles", settings.eq_candidates_url, True) 240 _mark_source(s, "eq-candidates", "Élections Québec — candidatures officielles", settings.eq_candidates_url, True)
234 return len(data) 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 # ------------------------------------------------------------------ résultats 268 # ------------------------------------------------------------------ résultats
238 def _load_results_json(path_dir: Path, url_dir: str, refresh: bool) -> dict: 269 def _load_results_json(path_dir: Path, url_dir: str, refresh: bool) -> dict:
239 raw = _download_or_local(f"{url_dir}/resultats.json", path_dir / "resultats.json", refresh) 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 run_forecast(s, trigger="reference_polls_2022") 44 run_forecast(s, trigger="reference_polls_2022")
45 except Exception as e: # noqa: BLE001 45 except Exception as e: # noqa: BLE001
46 log.warning("sondages de référence 2022 : %s", e) 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 if not s.scalar(select(ForecastRun).where(ForecastRun.status == "ok").limit(1)): 54 if not s.scalar(select(ForecastRun).where(ForecastRun.status == "ok").limit(1)):
48 from .model.run import run_forecast 55 from .model.run import run_forecast
49 try: 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 electors: int = 50000 31 electors: int = 50000
32 pct_french: float | None = None # langue maternelle française (recensement 2021) 32 pct_french: float | None = None # langue maternelle française (recensement 2021)
33 poll_region: str = "reste" 33 poll_region: str = "reste"
34+ name: str = ""
34 35
35 36
36 def simulate_ridings(ridings: list[RidingInput], national_draws: np.ndarray, national_2022: dict[str, float], 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 → 1+"""Exécution d'un run de prévision (modèle 2.0) : sondages → latent → mélange fondamentaux →
2−persistance versionnée → « Qu'est-ce qui a changé ? ».""" 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 from __future__ import annotations 5 from __future__ import annotations
4 6
5 import logging 7 import logging
@@ -17,6 +19,7 @@ from ..db import (Candidate, ForecastHouseEffect, ForecastPartyResult, ForecastR
17 from ..parties import PARTY_IDS 19 from ..parties import PARTY_IDS
18 from ..regions import POLL_REGION_LABELS, poll_region_for 20 from ..regions import POLL_REGION_LABELS, poll_region_for
19 from .aggregate import PARTIES_ALL, Obs, compute_weights, election_day_distribution, house_effects, kalman_latent 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 from .ridings import RidingInput, explain_factors, rating, simulate_ridings 23 from .ridings import RidingInput, explain_factors, rating, simulate_ridings
21 from .segments import build_segment_model, riding_adjustments 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 out = [RidingInput(code=r.code, region_id=r.region_id or "inconnue", baseline=baselines.get(r.code, {}), 60 out = [RidingInput(code=r.code, region_id=r.region_id or "inconnue", baseline=baselines.get(r.code, {}),
58 incumbent_party=r.incumbent_party_id, incumbent_running=r.incumbent_running, candidates=cands.get(r.code, set()), 61 incumbent_party=r.incumbent_party_id, incumbent_running=r.incumbent_running, candidates=cands.get(r.code, set()),
59 electors=r.electors_2026 or 50000, pct_french=(demo[r.code].pct_french_mt if r.code in demo else None), 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 for r in rows if baselines.get(r.code)] 64 for r in rows if baselines.get(r.code)]
62 national_2022 = {p.id: (p.vote_2022 or 0.0) for p in s.scalars(select(Party)).all()} 65 national_2022 = {p.id: (p.vote_2022 or 0.0) for p in s.scalars(select(Party)).all()}
63 national_2022["aut"] = max(0.5, 100.0 - sum(national_2022.values())) 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 return 100.0 * p / p.sum(axis=1, keepdims=True) 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 def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None, publish: bool = True, 106 def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None, publish: bool = True,
74 n_sims: int | None = None) -> ForecastRun: 107 n_sims: int | None = None) -> ForecastRun:
75 cutoff = cutoff or date.today() 108 cutoff = cutoff or date.today()
@@ -84,66 +117,87 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None
84 obs = load_observations(s, cutoff) 117 obs = load_observations(s, cutoff)
85 if len(obs) < 3: 118 if len(obs) < 3:
86 raise RuntimeError("trop peu de sondages") 119 raise RuntimeError("trop peu de sondages")
120+ days_left = max(0, (settings.election_date - cutoff).days)
87 weights = compute_weights(obs, cutoff) 121 weights = compute_weights(obs, cutoff)
88 effects = house_effects(obs, cutoff) 122 effects = house_effects(obs, cutoff)
89 latent = kalman_latent(obs, cutoff, effects) 123 latent = kalman_latent(obs, cutoff, effects)
90− mu, cov = election_day_distribution(latent, cutoff, settings.election_date) 124+ signals = _load_signals(s, cutoff)
91− national = _draw_national(mu, cov, n_sims, rng) # (S, P) % 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 ridings, national_2022 = load_ridings(s) 142 ridings, national_2022 = load_ridings(s)
94 national_now_mean = {p: float(national[:, j].mean()) for j, p in enumerate(PARTIES_ALL)} 143 national_now_mean = {p: float(national[:, j].mean()) for j, p in enumerate(PARTIES_ALL)}
95 seg_model, f_r = build_segment_model(s, cutoff, national_now_mean, national_2022) 144 seg_model, f_r = build_segment_model(s, cutoff, national_now_mean, national_2022)
96 seg_adj = riding_adjustments(seg_model, [r.code for r in ridings], f_r) 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 S, R, P = shares.shape 150 S, R, P = shares.shape
99 seats_total = int(get_setting(s, "seats_total", R)) 151 seats_total = int(get_setting(s, "seats_total", R))
100 majority = int(get_setting(s, "majority_threshold", seats_total // 2 + 1)) 152 majority = int(get_setting(s, "majority_threshold", seats_total // 2 + 1))
101 seats = np.zeros((S, P), dtype=int) 153 seats = np.zeros((S, P), dtype=int)
102 for j in range(P): 154 for j in range(P):
103 seats[:, j] = (winners == j).sum(axis=1) 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 s.execute(delete(ForecastPartyResult).where(ForecastPartyResult.run_id == run.id)) 173 s.execute(delete(ForecastPartyResult).where(ForecastPartyResult.run_id == run.id))
108 for j, pid in enumerate(PARTIES_ALL): 174 for j, pid in enumerate(PARTIES_ALL):
109− v = national[:, j] 175+ v, sj, sp = national[:, j], seats[:, j], summary_parties[pid]
110− sj = seats[:, j] 176+ s.add(ForecastPartyResult(
111− hist = np.bincount(sj, minlength=seats_total + 1).tolist() 177+ run_id=run.id, party_id=pid, vote_mean=sp["vote"], vote_sd=sp["vote_sd"],
112− fpr = ForecastPartyResult( 178+ vote_lo80=sp["vote80"][0], vote_hi80=sp["vote80"][1], vote_lo95=sp["vote95"][0], vote_hi95=sp["vote95"][1],
113− run_id=run.id, party_id=pid, vote_mean=float(v.mean()), vote_sd=float(v.std()), 179+ seats_mean=sp["seats_mean"], seats_median=sp["seats"], seats_lo80=sp["seats80"][0], seats_hi80=sp["seats80"][1],
114− vote_lo80=float(np.percentile(v, 10)), vote_hi80=float(np.percentile(v, 90)), 180+ seats_lo95=sp["seats95"][0], seats_hi95=sp["seats95"][1], p_most_seats=sp["p_most_seats"], p_majority=sp["p_majority"],
115− vote_lo95=float(np.percentile(v, 2.5)), vote_hi95=float(np.percentile(v, 97.5)), 181+ seat_distribution=whist(sj, w, seats_total + 1)))
116− seats_mean=float(sj.mean()), seats_median=int(np.median(sj)), 182+ p_minority = float(np.average(seats.max(axis=1) < majority, weights=w))
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())
128 183
129 # circonscriptions 184 # circonscriptions
130 s.execute(delete(ForecastRidingResult).where(ForecastRidingResult.run_id == run.id)) 185 s.execute(delete(ForecastRidingResult).where(ForecastRidingResult.run_id == run.id))
131 s.execute(delete(ForecastRidingSummary).where(ForecastRidingSummary.run_id == run.id)) 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)} 187+ national_now = {p: summary_parties[p]["vote"] for p in PARTIES_ALL}
133− battlegrounds = [] 188+ battlegrounds, riding_rows = [], []
134− riding_rows = [] 189+ be_codes = {x["code"]: x for x in be_detail.get("ridings", [])}
135 for i, r in enumerate(ridings): 190 for i, r in enumerate(ridings):
136 sh = shares[:, i, :] 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 order = np.argsort(-pw) 193 order = np.argsort(-pw)
139 fav, second = int(order[0]), int(order[1]) 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 for j, pid in enumerate(PARTIES_ALL): 196 for j, pid in enumerate(PARTIES_ALL):
142 if pid == "aut" and share_mean[pid] < 1.5: 197 if pid == "aut" and share_mean[pid] < 1.5:
143 continue 198 continue
144 s.add(ForecastRidingResult(run_id=run.id, riding_code=r.code, party_id=pid, vote_mean=share_mean[pid], 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)), 200+ vote_lo80=wquantile(sh[:, j], w, 0.10), vote_hi80=wquantile(sh[:, j], w, 0.90), p_win=float(pw[j])))
146− p_win=float(pw[j])))
147 fav_id = PARTIES_ALL[fav] 201 fav_id = PARTIES_ALL[fav]
148 margin = share_mean[fav_id] - share_mean[PARTIES_ALL[second]] 202 margin = share_mean[fav_id] - share_mean[PARTIES_ALL[second]]
149 swing = share_mean[fav_id] - r.baseline.get(fav_id, 0.0) 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 if r.poll_region in seg_model.current and fav_id in seg_model.current[r.poll_region]: 208 if r.poll_region in seg_model.current and fav_id in seg_model.current[r.poll_region]:
155 seg_detail["region"] = {"label": POLL_REGION_LABELS.get(r.poll_region, r.poll_region), "now": seg_model.current[r.poll_region][fav_id], 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 "2022": seg_model.baseline.get(r.poll_region, {}).get(fav_id, 0)} 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 summ = ForecastRidingSummary(run_id=run.id, riding_code=r.code, favorite_party_id=fav_id, favorite_p=float(pw[fav]), 223 summ = ForecastRidingSummary(run_id=run.id, riding_code=r.code, favorite_party_id=fav_id, favorite_p=float(pw[fav]),
158 runner_up_party_id=PARTIES_ALL[second], margin_mean=float(margin), 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), 225+ volatility=float(1 - pw[fav]), rating=rating(float(pw[fav])), swing_from_2022=float(swing), factors=factors)
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))
162 s.add(summ) 226 s.add(summ)
163 riding_rows.append((r.code, fav_id, float(pw[fav]), PARTIES_ALL[second], float(margin))) 227 riding_rows.append((r.code, fav_id, float(pw[fav]), PARTIES_ALL[second], float(margin)))
164 battlegrounds.append((float(1 - pw[fav]), r.code)) 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 recent_ids = [o.poll_id for o in obs if (cutoff - o.end).days <= 45] 241 recent_ids = [o.poll_id for o in obs if (cutoff - o.end).days <= 45]
178 base_last = {p: float(x) for p, x in zip(PARTIES_ALL, latent["last_pct"])} 242 base_last = {p: float(x) for p, x in zip(PARTIES_ALL, latent["last_pct"])}
179 for o in obs: 243 for o in obs:
180− w = weights[o.poll_id] 244+ wgt = weights[o.poll_id]
181 impact = None 245 impact = None
182 if o.poll_id in recent_ids and len(obs) > 4: 246 if o.poll_id in recent_ids and len(obs) > 4:
183 sub = [x for x in obs if x.poll_id != o.poll_id] 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 impact = {p: round(base_last[p] - float(x), 2) for p, x in zip(PARTIES_ALL, lat2["last_pct"])} 250 impact = {p: round(base_last[p] - float(x), 2) for p, x in zip(PARTIES_ALL, lat2["last_pct"])}
187 except Exception: # noqa: BLE001 251 except Exception: # noqa: BLE001
188 impact = None 252 impact = None
189− s.add(PollWeight(run_id=run.id, poll_id=o.poll_id, weight=w["weight"], recency_weight=w["recency"], 253+ s.add(PollWeight(run_id=run.id, poll_id=o.poll_id, weight=wgt["weight"], recency_weight=wgt["recency"],
190− sample_weight=w["sample"], impact=impact)) 254+ sample_weight=wgt["sample"], impact=impact))
191 255
192− # tendance quotidienne (série pour les graphiques) — allégée à 1 point / jour
193 trend = latent["series"] 256 trend = latent["series"]
194−
195− # résumé + diff avec le run publié précédent
196 prev = s.scalar(select(ForecastRun).where(ForecastRun.is_published == True, ForecastRun.kind == "forecast", # noqa: E712 257 prev = s.scalar(select(ForecastRun).where(ForecastRun.is_published == True, ForecastRun.kind == "forecast", # noqa: E712
197 ForecastRun.id != run.id).order_by(ForecastRun.id.desc())) 258 ForecastRun.id != run.id).order_by(ForecastRun.id.desc()))
198 top = sorted(summary_parties.items(), key=lambda kv: -kv[1]["seats_mean"]) 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 summary = { 271 summary = {
200 "seats_total": seats_total, "majority": majority, "parties": summary_parties, "p_minority": round(p_minority, 4), 272 "seats_total": seats_total, "majority": majority, "parties": summary_parties, "p_minority": round(p_minority, 4),
201 "leader": top[0][0], "poll_count": len(obs), "latest_poll": max(o.end for o in obs).isoformat(), 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 "battlegrounds": [c for _, c in sorted(battlegrounds, reverse=True)[:15]], 275 "battlegrounds": [c for _, c in sorted(battlegrounds, reverse=True)[:15]],
204 "segments": seg_model.payload(), 276 "segments": seg_model.payload(),
205 "demographics_coverage": sum(1 for r in ridings if r.pct_french is not None), 277 "demographics_coverage": sum(1 for r in ridings if r.pct_french is not None),
206 "ridings": {str(c): {"fav": f, "p": round(p, 3), "second": sec, "margin": round(m, 1)} for c, f, p, sec, m in riding_rows}, 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 diff = None 281 diff = None
209− if prev and prev.summary: 282+ if prev and prev.summary and "parties" in 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 283+ prev_ids = {pw.poll_id for pw in s.scalars(select(PollWeight).where(PollWeight.run_id == prev.id)).all()}
211− o.poll_id not in {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 changes = {} 285 changes = {}
213 for pid in PARTIES_ALL: 286 for pid in PARTIES_ALL:
214 a, b = prev.summary["parties"].get(pid), summary_parties[pid] 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 causes = [] 297 causes = []
225 if new_polls: 298 if new_polls:
226 causes.append({"kind": "new_polls", "detail": new_polls[:6]}) 299 causes.append({"kind": "new_polls", "detail": new_polls[:6]})
227− if not new_polls: 300+ prev_mk = ((prev.summary.get("components") or {}).get("markets") or {}).get("mostSeatsMarket")
228− causes.append({"kind": "time", "detail": "recalcul sans nouveau sondage (récence, dérive vers le scrutin)"}) 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 diff = {"previous_run": prev.id, "previous_at": prev.completed_at.isoformat() if prev.completed_at else None, 310 diff = {"previous_run": prev.id, "previous_at": prev.completed_at.isoformat() if prev.completed_at else None,
230 "changes": changes, "flips": flips, "causes": causes} 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 run.is_published = True 320 run.is_published = True
240 set_setting(s, "latest_forecast_run", run.id) 321 set_setting(s, "latest_forecast_run", run.id)
241 s.flush() 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 return run 324 return run
244 except Exception as e: # noqa: BLE001 325 except Exception as e: # noqa: BLE001
245 run.status = "failed" 326 run.status = "failed"
modified engine/qc26/scheduler.py +52 −0
@@ -92,6 +92,54 @@ def job_documents():
92 return {"docs": res, "positions": pos} 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 def _in_live_window() -> bool: 143 def _in_live_window() -> bool:
96 if settings.election_simulation: 144 if settings.election_simulation:
97 return True 145 return True
@@ -126,6 +174,10 @@ def start() -> BackgroundScheduler:
126 sch.add_job(job_daily_forecast, "cron", hour=6, minute=15, id="daily_forecast") 174 sch.add_job(job_daily_forecast, "cron", hour=6, minute=15, id="daily_forecast")
127 sch.add_job(job_documents, "interval", minutes=settings.doc_watch_minutes, id="documents", next_run_time=datetime.now(timezone.utc) + timedelta(minutes=30)) 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 sch.add_job(job_live, "interval", seconds=settings.live_poll_seconds, id="live") 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 sch.start() 181 sch.start()
130 _sched = sch 182 _sched = sch
131 log.info("planificateur démarré") 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
Menu

Theme