# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Tests du cœur statistique : transformations, pondération, tendance, simulation.""" import sys from datetime import date, timedelta from pathlib import Path import numpy as np import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app.config import settings from app.modeling.compositions import alr, alr_obs_cov, close, inv_alr from app.modeling.house_effects import PollsterProfile, adjust_shares, compute_profiles from app.modeling.simulate import SimulationInput, category_for, run_simulation from app.modeling.trend import fit_trend, shares_vector from app.modeling.weights import display_weight, effective_n, time_weight # ---------- compositions ---------- def test_alr_roundtrip(): p = np.array([0.30, 0.25, 0.20, 0.13, 0.09, 0.03]) assert np.allclose(inv_alr(alr(p)), p, atol=1e-9) def test_close_sums_to_one(): p = close(np.array([40.0, 30.0, 20.0, 5.0, 4.0, 0.0])) assert p.sum() == pytest.approx(1.0) assert (p > 0).all() # plancher appliqué def test_obs_cov_psd_and_scales_with_n(): p = np.array([0.30, 0.25, 0.20, 0.13, 0.09, 0.03]) small = alr_obs_cov(p, 400) big = alr_obs_cov(p, 4000) assert np.all(np.linalg.eigvalsh(small) > 0) assert np.trace(big) < np.trace(small) # plus d'échantillon → moins de variance # ---------- pondération ---------- def test_time_weight_decay(): today = date(2026, 8, 27) assert time_weight(today, today) == 1.0 w12 = time_weight(today - timedelta(days=12), today) assert w12 == pytest.approx(0.5, abs=1e-9) def test_effective_n_cap_and_design_effect(): assert effective_n(1_000_000, "web") == settings.max_effective_n assert effective_n(1300, "web") == pytest.approx(1000, rel=0.01) def test_display_weight_monotone_in_n(): today = date(2026, 8, 27) w_small = display_weight(today, today, 400, "web") w_big = display_weight(today, today, 2000, "web") assert w_big > w_small # ---------- house effects ---------- def test_house_effect_shrinkage_and_clip(): polls = [{"pollster": "X", "field_end": date(2022, 9, 30), "shares": {"CAQ": 49.0, "PLQ": 10.0, "PQ": 14.0, "QS": 15.0, "PCQ": 11.0}}] elections = [{"date": date(2022, 10, 3), "result": {"CAQ": 41.0, "PLQ": 14.4, "PQ": 14.6, "QS": 15.4, "PCQ": 12.9}}] profs = compute_profiles(polls, elections) he = profs["X"].house_effects # biais brut CAQ = +8 → rétréci ×0,5 = +4 → borné à +3 assert he["CAQ"] == pytest.approx(settings.house_effect_max_pp) assert he["PLQ"] < 0 def test_adjust_shares_renormalizes(): prof = PollsterProfile(name="X", house_effects={"CAQ": 2.0}) adj = adjust_shares({"CAQ": 40.0, "PQ": 30.0, "PLQ": 30.0}, prof) assert sum(adj.values()) == pytest.approx(100.0) assert adj["CAQ"] < 40.0 # ---------- tendance (Kalman) ---------- def _fake_polls(level, n=12, end=date(2026, 8, 24)): return [{"pollster": f"P{i % 3}", "field_end": end - timedelta(days=4 * i), "sample_size": 1000, "mode": "web", "shares": dict(level)} for i in range(n)] def test_trend_recovers_stable_level(): level = {"CAQ": 22.0, "PCQ": 14.0, "PLQ": 23.0, "PQ": 30.0, "QS": 9.0, "AUT": 2.0} tr = fit_trend(_fake_polls(level), {}, date(2026, 8, 27)) est = inv_alr(tr.x) * 100 for k, p in enumerate(settings.parties): assert est[k] == pytest.approx(level[p], abs=1.2) def test_trend_requires_minimum_polls(): assert fit_trend(_fake_polls({"CAQ": 30, "PLQ": 25, "PQ": 25, "QS": 10, "PCQ": 8}, n=2), {}, date(2026, 8, 27)) is None # ---------- simulation ---------- def _sim_input(D=20): rng = np.random.default_rng(7) base = rng.dirichlet(np.array([30, 15, 15, 15, 13, 2]), size=D) return SimulationInput( x_mean=alr(shares_vector({"CAQ": 22, "PCQ": 14, "PLQ": 23, "PQ": 30, "QS": 9, "AUT": 2})), P=np.eye(5) * 0.004, baseline_national=shares_vector({"CAQ": 41, "PLQ": 14.4, "QS": 15.4, "PQ": 14.6, "PCQ": 12.9, "AUT": 1.7}), district_names=[f"D{i}" for i in range(D)], district_baselines=base, district_regions=["R1" if i < D // 2 else "R2" for i in range(D)], retirement_flags=np.zeros(D, dtype=int), incumbent_party_idx=np.full(D, -1), majority_seats=D // 2 + 1) def test_simulation_invariants(): sim = run_simulation(_sim_input(), n_sims=2000) total_mean = sum(sim.seats[p]["mean"] for p in settings.parties) # les sièges se conservent (tolérance = arrondi à 0,1 par parti) assert total_mean == pytest.approx(20.0, abs=0.3 + 0.05 * len(settings.parties)) assert sum(sim.seats[p]["prob_most"] for p in settings.parties) >= 0.999 for d in sim.districts: assert sum(d["win_probs"].values()) == pytest.approx(1.0, abs=0.01) assert sum(d["expected"].values()) == pytest.approx(100.0, abs=0.5) def test_scenario_shifts_seats(): inp = _sim_input() base = run_simulation(inp, n_sims=4000) up = run_simulation(inp, n_sims=4000, scenario={"national_pp": {"PLQ": 6.0}}) assert up.seats["PLQ"]["mean"] > base.seats["PLQ"]["mean"] assert up.seats["PQ"]["mean"] < base.seats["PQ"]["mean"] def test_turnout_multiplier_shifts_votes(): inp = _sim_input() base = run_simulation(inp, n_sims=3000) up = run_simulation(inp, n_sims=3000, scenario={"turnout_mult": {"QS": 1.2}}) d_qs = np.mean([d2["expected"]["QS"] - d1["expected"]["QS"] for d1, d2 in zip(base.districts, up.districts)]) assert d_qs > 0.5 def test_categories(): assert category_for(0.97) == "Solide" assert category_for(0.85) == "Probable" assert category_for(0.65) == "Favorisé" assert category_for(0.50) == "Chaudement disputé"