SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
17.7 KB · 316 lines html
Raw Blame History
1<!DOCTYPE html>2<!--3  QC Élection Forecast — Plateforme de prévision électorale du Québec 20264  Auteur : Simon-Pierre Boucher5  Contact : contact@spboucher.ai6  https://www.qc-election.com7-->8<html lang="fr">9<head>10<script>document.documentElement.dataset.theme=localStorage.getItem("qce-theme")||"dark";</script>11<meta charset="utf-8">12<meta name="viewport" content="width=device-width, initial-scale=1">13<title>Au-delà des sondages — QC Élection Forecast</title>14<meta name="description" content="Décomposition du forecast v2 : sondages, votes réels des partielles, prior de fondamentaux, ajustement médias et ensemble avec les marchés prédictifs.">15<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">16<link rel="icon" type="image/png" sizes="64x64" href="/assets/img/favicon-64.png">17<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">18<meta property="og:site_name" content="QC Élection Forecast">19<meta property="og:type" content="website">20<meta property="og:title" content="Au-delà des sondages — QC Élection Forecast">21<meta property="og:description" content="Décomposition du forecast v2 : sondages, votes réels des partielles, prior de fondamentaux, ajustement médias et ensemble avec les marchés prédictifs.">22<meta property="og:image" content="https://www.qc-election.com/assets/img/og.png">23<meta property="og:image:width" content="1200">24<meta property="og:image:height" content="630">25<meta property="og:locale" content="fr_CA">26<meta name="twitter:card" content="summary_large_image">27<meta name="twitter:title" content="Au-delà des sondages — QC Élection Forecast">28<meta name="twitter:image" content="https://www.qc-election.com/assets/img/og.png">29<meta name="theme-color" content="#0a0d14">30<link rel="stylesheet" href="/assets/style.css">31<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>32<style>33  .layer-card { position: relative; }34  .layer-card .lnum { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }35  .layer-card .lidx { display: inline-flex; align-items: center; justify-content: center;36    width: 21px; height: 21px; margin-right: 8px; border-radius: 7px;37    background: color-mix(in srgb, var(--accent-strong) 16%, transparent);38    color: var(--accent); font-size: .72rem; font-weight: 800;39    font-variant-numeric: tabular-nums; vertical-align: -3px; }40  .layer-card .ltag { display:inline-block; margin-top:6px; font-size:11px;41    color:var(--muted); }42  .gauge-wrap { display:flex; align-items:center; gap:18px; flex-wrap:wrap; }43  .sat-num { font-size: 44px; font-weight: 800; line-height: 1; }44  .verified { color: var(--good); font-weight: 600; }45  .unverified { color: var(--muted); }46</style>47</head>48<body>49<header class="site"></header>50<div class="wrap">51  <div class="hero">52    <h1>Au-delà des sondages</h1>53    <p class="sub">Le forecast v2 ne repose plus uniquement sur les sondages : il combine54      <b>cinq couches d'inférence</b>, chacune à poids explicite, borné et traçable.55      <span class="chip badge-aux" id="ver-chip">modèle v2</span></p>56    <p class="disclaimer">Chaque couche est débrayable, documentée dans la57      <a href="/methodologie">méthodologie</a> et validée par backtest lorsque les données58      historiques le permettent.</p>59  </div>6061  <h2 class="section">Les cinq couches du forecast</h2>62  <div class="grid parties" id="layers"><div class="loading">Chargement…</div></div>6364  <h2 class="section">Décomposition — ce que chaque couche change</h2>65  <p class="section-sub">On reconstruit le forecast couche par couche : sondages seuls,66    puis + votes réels des partielles, + prior de fondamentaux, + ajustement médias.67    La dernière colonne intègre l'ensemble avec les marchés (probabilités seulement).</p>68  <div class="card" style="overflow-x:auto"><table class="data" id="decomp-table"></table>69    <p class="chart-note" id="decomp-note"></p></div>70  <div class="card">71    <h3>Δ points de vote apportés par chaque couche (vs sondages seuls)</h3>72    <div class="chart" id="decomp-chart"></div>73    <p class="chart-note">À l'approche du scrutin, les sondages dominent naturellement :74      les couches complémentaires pèsent surtout loin du vote, et servent de garde-fous.</p>75  </div>7677  <div class="grid two">78    <div class="card">79      <h3>Prior de fondamentaux</h3>80      <p class="section-sub">Régression sur les 8 générales de 1998 à 2022 :81        vote du sortant ~ vote précédent + satisfaction, pénalité d'usure par mandat.</p>82      <div class="gauge-wrap">83        <div>84          <div class="sat-num" id="sat-val">—</div>85          <div class="ltag">satisfaits du gouvernement</div>86        </div>87        <div style="flex:1;min-width:220px">88          <div class="kv"><span>Source</span><b id="sat-src">—</b></div>89          <div class="kv"><span>Méthode</span><b id="sat-method">—</b></div>90          <div class="kv"><span>Erreur LOO de la régression</span><b id="fund-loo">—</b></div>91          <div class="kv"><span>Poids actuel dans le forecast</span><b id="fund-w">—</b></div>92        </div>93      </div>94      <table class="data" id="fund-table" style="margin-top:12px"></table>95    </div>96    <div class="card">97      <h3>Poids des fondamentaux selon l'horizon</h3>98      <p class="section-sub">w(t) = min(0,8 · (jours restants / 540)<sup>1,3</sup>) —99        les fondamentaux comptent à 18 mois, presque plus la veille du vote.</p>100      <div class="chart" id="weight-chart"></div>101    </div>102  </div>103104  <h2 class="section">Votes réels — élections partielles</h2>105  <p class="section-sub">Les seuls bulletins comptés depuis 2022. Chaque partielle devient106    une observation nationale bruitée (swing local rétréci ×<span id="shrink"></span>,107    n équivalent <span id="eqn"></span>) injectée dans le filtre de Kalman à sa date.</p>108  <div class="card" style="overflow-x:auto"><table class="data" id="bye-table"></table></div>109110  <h2 class="section">Ensemble avec les marchés prédictifs</h2>111  <div class="card" id="market-card" style="overflow-x:auto"><div class="loading">Chargement…</div></div>112113  <h2 class="section">Radar de veille continue <span class="chip badge-aux">Firecrawl</span></h2>114  <p class="section-sub">À chaque cycle (6 h) : détection des nouveaux sondages dans la115    presse, mise à jour de la satisfaction gouvernementale, articles au-delà des flux RSS.116    Les détections sont des alertes de provenance — rien n'entre dans le modèle sans117    validation.</p>118  <div class="card" style="overflow-x:auto"><table class="data" id="radar-table"></table></div>119</div>120<footer class="site"></footer>121122<script src="/assets/app.js"></script>123<script>124(async () => {125  const meta = await initShell("/signaux");126  const parties = Object.keys(meta.parties).filter(p => p !== "AUT");127  document.getElementById("ver-chip").textContent = "modèle v" + meta.model_version;128129  const [bd, byes, sig] = await Promise.all([130    getJSON(`${API}/beyond`), getJSON(`${API}/byelections`), getJSON(`${API}/signals`)]);131  const B = bd.beyond || {};132  const layers = (B.decomposition || {}).layers || [];133  const fund = B.fundamentals || {};134  const media = B.media_adjustment || {};135  const mkt = B.market_ensemble;136137  /* ---- cartes des 5 couches ---- */138  const wFund = ((fund.blend || {}).precision_share || 0) * 100;139  const nBye = (B.byelections_used || []).length;140  const maxNudge = Math.max(0, ...Object.values(media.delta_pp || {}).map(Math.abs));141  const cards = [142    ["1", "Sondages", "Le cœur : Kalman bayésien, effets maison, pondération",143      `${bd.n_polls ?? "—"} sondages pondérés`, "poids dominant"],144    ["2", "Votes réels", "Partielles converties en observations nationales",145      `${nBye} partielles injectées`, "n équiv. " + (byes[0] ? byes[0].equivalent_n : 350)],146    ["3", "Fondamentaux", "Satisfaction, vote précédent, usure du pouvoir",147      `${((fund.satisfaction || {}).value ?? "—")} % satisfaits`,148      `poids actuel ${wFund.toFixed(1)} % (décroît vers 0)`],149    ["4", "Médias", "Sentiment de presse 14 j — nudge borné",150      `±${maxNudge.toFixed(2)} pp appliqués`, "plafond ±0,35 pp"],151    ["5", "Marchés", "Ensemble modèle/marché sur P(plus de sièges)",152      mkt ? `${(mkt.weight_market * 100).toFixed(0)} % marché` : "indisponible",153      mkt && mkt.market_meta ? `volume ${(mkt.market_meta.volume_usd || 0).toLocaleString("fr-CA")} $ US` : ""],154  ];155  document.getElementById("layers").innerHTML = cards.map(([ic, t, d, v, tag]) =>156    `<div class="card layer-card">157      <div class="pname"><span class="lidx">${ic}</span>${t}</div>158      <p class="section-sub" style="margin:6px 0">${d}</p>159      <div class="lnum">${v}</div><span class="ltag">${tag}</span>160    </div>`).join("");161162  /* ---- table de décomposition ---- */163  if (layers.length) {164    const last = layers[layers.length - 1];165    const rows = parties.sort((a, b) => last.vote[b] - last.vote[a]);166    let html = `<tr><th>Couche</th>${rows.map(p =>167      `<th><span class="dot" style="background:${partyColor(p)}"></span>${p}</th>`).join("")}168      <th>P(1er) PQ vs CAQ</th></tr>`;169    for (const l of layers) {170      const top2 = rows.slice(0, 2);171      html += `<tr><td>${l.label}</td>${rows.map(p => {172        const d = l.delta_vote ? l.delta_vote[p] : null;173        return `<td>${l.vote[p].toFixed(1)} %${d != null && Math.abs(d) >= 0.01174          ? ` <span style="color:var(--muted);font-size:11px">(${d > 0 ? "+" : ""}${d.toFixed(2)})</span>` : ""}</td>`;175      }).join("")}<td style="color:var(--muted)">${top2.map(p =>176        `${p} ${(l.prob_most[p] * 100).toFixed(1)} %`).join(" · ")}</td></tr>`;177    }178    if (mkt) {179      html += `<tr><td><b>+ ensemble marchés (probabilités)</b>${180        ""}</td><td colspan="${rows.length}" style="color:var(--muted)">les intentions de vote ne changent pas — seule P(plus de sièges) est blendée</td>181        <td>${rows.slice(0, 2).map(p => `${p} ${(mkt.blended[p] * 100).toFixed(1)} %`).join(" · ")}</td></tr>`;182    }183    document.getElementById("decomp-table").innerHTML = html;184    document.getElementById("decomp-note").textContent =185      `Décomposition recalculée à chaque run (${(B.decomposition.n_sims_probe || 8000).toLocaleString("fr-CA")} simulations par variante) — run #${bd.run_id}, ${fmtDate(bd.as_of)}.`;186187    /* ---- graphique des deltas cumulés ---- */188    const base = layers[0];189    const dLayers = layers.slice(1);190    const c = baseChart(document.getElementById("decomp-chart"));191    c.setOption({192      grid: { left: 50, right: 20, top: 34, bottom: 28 },193      legend: { ...LEGEND, data: dLayers.map(l => l.label) },194      tooltip: { ...TOOLTIP, valueFormatter: v => (v >= 0 ? "+" : "") + v.toFixed(3) + " pp" },195      xAxis: { type: "category", data: parties, ...AXIS_STYLE, splitLine: { show: false } },196      yAxis: { type: "value", ...AXIS_STYLE,197        axisLabel: { ...AXIS_STYLE.axisLabel, formatter: v => v + " pp" } },198      series: dLayers.map((l, i) => ({199        name: l.label, type: "bar", barGap: "22%", barMaxWidth: 26,200        itemStyle: { opacity: 0.9, borderRadius: 4 },201        data: parties.map(p => +(l.vote[p] - base.vote[p]).toFixed(3)) })),202    });203  } else {204    document.getElementById("decomp-table").innerHTML =205      `<tr><td style="color:var(--muted)">Décomposition disponible au prochain run du pipeline.</td></tr>`;206  }207208  /* ---- fondamentaux ---- */209  const sat = fund.satisfaction || sig.satisfaction || {};210  document.getElementById("sat-val").textContent =211    sat.value != null ? sat.value.toFixed(0) + " %" : "—";212  document.getElementById("sat-src").innerHTML = sat.source_url213    ? `<a href="${sat.source_url}" target="_blank" rel="noopener">${(sat.source || "source").slice(0, 60)}</a>`214    : (sat.note || "valeur de repli");215  document.getElementById("sat-method").textContent =216    sat.method === "firecrawl" ? "veille web (Firecrawl)" :217    sat.method === "manual" ? "saisie manuelle" : "repli de configuration";218  const reg = (fund.prior || {}).regression || {};219  document.getElementById("fund-loo").textContent =220    reg.loo_rmse_pp != null ? "±" + reg.loo_rmse_pp + " pp (leave-one-out)" : "—";221  document.getElementById("fund-w").textContent =222    wFund.toFixed(2) + " % de la précision totale";223  const priorPct = (fund.prior || {}).prior_pct;224  if (priorPct && layers.length) {225    const nowVote = layers[layers.length - 1].vote;226    document.getElementById("fund-table").innerHTML =227      `<tr><th>Parti</th><th>Prior fondamentaux</th><th>Forecast actuel</th><th>Écart</th></tr>` +228      parties.map(p => `<tr>229        <td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>230        <td>${priorPct[p].toFixed(1)} %</td><td>${nowVote[p].toFixed(1)} %</td>231        <td style="color:var(--muted)">${(priorPct[p] - nowVote[p] >= 0 ? "+" : "")}${(priorPct[p] - nowVote[p]).toFixed(1)} pp</td></tr>`).join("");232  }233234  /* ---- courbe w(t) ---- */235  const sched = bd.fundamentals_weight_schedule || [];236  const wc = baseChart(document.getElementById("weight-chart"));237  wc.setOption({238    grid: { left: 46, right: 20, top: 20, bottom: 40 },239    tooltip: { ...TOOLTIP, valueFormatter: v => (v * 100).toFixed(1) + " %" },240    xAxis: { type: "value", inverse: true, name: "jours avant le scrutin",241      nameLocation: "middle", nameGap: 26, nameTextStyle: { color: MUTED },242      ...AXIS_STYLE, splitLine: { show: false } },243    yAxis: { type: "value", ...AXIS_STYLE, max: 1,244      axisLabel: { ...AXIS_STYLE.axisLabel, formatter: v => (v * 100) + " %" } },245    series: [{ type: "line", symbol: "circle", symbolSize: 6, smooth: 0.3,246      color: "#ad8226", lineStyle: lineGlow("#ad8226", 2.4),247      areaStyle: areaGrad("#ad8226", 0.28),248      data: sched.map(s => [s.days, s.weight]),249      markLine: { symbol: "none", silent: true,250        lineStyle: { color: MUTED, type: "dashed" },251        label: { color: MUTED, formatter: "aujourd'hui" },252        data: [{ xAxis: bd.days_to_election }] } }],253  });254255  /* ---- partielles ---- */256  document.getElementById("shrink").textContent = byes[0] ? byes[0].swing_shrink : "0,6";257  document.getElementById("eqn").textContent = byes[0] ? byes[0].equivalent_n : 350;258  document.getElementById("bye-table").innerHTML =259    `<tr><th>Circonscription</th><th>Date</th><th>Résultat réel</th><th>Gain</th>260     <th>Observation nationale implicite</th><th>Participation</th><th>Statut</th></tr>` +261    byes.map(b => {262      const res = Object.entries(b.result).filter(([p]) => p !== "AUT")263        .sort((a, c) => c[1] - a[1]).slice(0, 3)264        .map(([p, v]) => `<span class="chip" style="background:${partyColor(p)}22;color:${partyColor(p)}">${p} ${v.toFixed(1)} %</span>`).join(" ");265      const imp = b.implied_national ? Object.entries(b.implied_national)266        .filter(([p]) => p !== "AUT").sort((a, c) => c[1] - a[1]).slice(0, 3)267        .map(([p, v]) => `${p} ${v.toFixed(0)}`).join(" · ") : "—";268      return `<tr><td><b>${b.district}</b></td><td>${fmtDate(b.held_on)}</td>269        <td>${res}</td>270        <td><span class="dot" style="background:${partyColor(b.winner)}"></span>${b.winner}271          <span style="color:var(--muted)">(était ${b.previous_winner || "?"})</span></td>272        <td style="color:var(--muted)">${imp}</td>273        <td>${b.turnout != null ? b.turnout.toFixed(0) + " %" : "—"}</td>274        <td class="${b.verified ? "verified" : "unverified"}">${b.verified ? "✓ vérifié en ligne" : "DGEQ (embarqué)"}${b.used_in_model ? "" : " · exclu"}</td></tr>`;275    }).join("");276277  /* ---- marchés ---- */278  if (mkt && mkt.market && Object.keys(mkt.market).length) {279    const rows = parties.filter(p => mkt.blended[p] != null)280      .sort((a, b) => mkt.blended[b] - mkt.blended[a]);281    document.getElementById("market-card").innerHTML = `282      <table class="data">283        <tr><th>Parti</th><th>Modèle</th><th>Marché (Polymarket)</th>284        <th>Publié (ensemble ${(100 - mkt.weight_market * 100).toFixed(0)}/${(mkt.weight_market * 100).toFixed(0)})</th></tr>285        ${rows.map(p => `<tr>286          <td><span class="dot" style="background:${partyColor(p)}"></span>${partyName(p)}</td>287          <td>${(mkt.model[p] * 100).toFixed(1)} %</td>288          <td>${mkt.market[p] != null ? (mkt.market[p] * 100).toFixed(1) + " %" : "—"}</td>289          <td><b>${(mkt.blended[p] * 100).toFixed(1)} %</b></td></tr>`).join("")}290      </table>291      <p class="note">P(plus de sièges). Le marché agrège de l'information hors-sondages;292        la combinaison modèle/marché améliore presque toujours la calibration293        (littérature sur l'agrégation de prévisions).294        ${mkt.market_meta ? `<a href="${mkt.market_meta.url}" target="_blank" rel="noopener">${mkt.market_meta.event}</a> · consulté ${new Date(mkt.market_meta.fetched_at).toLocaleString("fr-CA")}` : ""}</p>`;295  } else {296    document.getElementById("market-card").innerHTML =297      '<p class="note">Marché indisponible pour ce run — probabilités du modèle publiées telles quelles.</p>';298  }299300  /* ---- radar ---- */301  const signals = (sig.signals || []).filter(s => s.kind !== "presse");302  document.getElementById("radar-table").innerHTML =303    `<tr><th>Détecté</th><th>Type</th><th>Maison</th><th>Titre</th></tr>` +304    (signals.length ? signals.slice(0, 30).map(s => `<tr>305      <td style="white-space:nowrap">${new Date(s.detected_at + "Z").toLocaleDateString("fr-CA", { month: "short", day: "numeric" })}</td>306      <td><span class="chip badge-aux">${s.kind === "poll-radar" ? "sondage repéré" : s.kind}</span></td>307      <td>${s.pollster || "—"}</td>308      <td><a href="${s.url}" target="_blank" rel="noopener">${s.title}</a></td></tr>`).join("")309    : `<tr><td colspan="4" style="color:var(--muted)">${sig.enabled310        ? "Aucune détection encore — le prochain cycle de veille alimentera cette section."311        : "Veille Firecrawl désactivée (clé absente)."}</td></tr>`);312})();313</script>314</body>315</html>316