# 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 """Event Engine (v3 « 127 », §17) — détection d'événements de campagne par CONFIRMATION CROISÉE : une anomalie de volume dans la presse OU le social ne devient un « événement politique » que si au moins deux familles de sources la confirment dans la même fenêtre (presse RSS/Firecrawl, médias sociaux, attention Wikipédia). L'événement détecté n'a AUCUN effet directionnel présumé : il est publié dans la chronologie et peut justifier temporairement la hausse (déjà bornée) de la variance de campagne via signals/volatility.""" from __future__ import annotations from datetime import date, datetime, timedelta, timezone import numpy as np from sqlalchemy.orm import Session from ...config import settings from ... import models as Mo def _z_today(counts: list[float]) -> float: base = counts[1:] if len(base) < 7 or np.std(base) == 0: return 0.0 return float((counts[0] - np.mean(base)) / np.std(base)) def detect_events(db: Session, z_threshold: float = 2.0) -> int: """Anomalies confirmées inter-familles → NewsEvent kind « événement-confirmé ».""" today = date.today() now = datetime.now(timezone.utc) created = 0 for party in [p for p in settings.parties if p != "AUT"]: # famille presse (SentimentScore) et famille sociale (SocialPost) press, social = [], [] for back in range(15): d0 = datetime(today.year, today.month, today.day, tzinfo=timezone.utc) - timedelta(days=back) d1 = d0 + timedelta(days=1) press.append(db.query(Mo.SentimentScore).join(Mo.SentimentDocument) .filter(Mo.SentimentScore.entity == party, Mo.SentimentDocument.fetched_at >= d0, Mo.SentimentDocument.fetched_at < d1).count()) n_soc = 0 for post in (db.query(Mo.SocialPost) .filter(Mo.SocialPost.fetched_at >= d0, Mo.SocialPost.fetched_at < d1).all()): if party in (post.scores or {}): n_soc += 1 social.append(n_soc) zp, zs = _z_today([float(x) for x in press]), _z_today([float(x) for x in social]) # attention wikipédia du jour (déjà en base via wiki_attention) zw = 0.0 row = (db.query(Mo.Indicator) .filter(Mo.Indicator.name == f"wiki_att_{party}") .order_by(Mo.Indicator.as_of.desc()).limit(30).all()) if len(row) >= 10: vals = np.array([r.value for r in reversed(row)]) sd = float(vals[:-1].std() or 1.0) zw = float((vals[-1] - vals[:-1].mean()) / sd) confirmations = sum(1 for z in (zp, zs, zw) if z >= z_threshold) if confirmations >= 2: exists = (db.query(Mo.NewsEvent) .filter(Mo.NewsEvent.event_date == today, Mo.NewsEvent.kind == "événement-confirmé") .filter(Mo.NewsEvent.title.contains( settings.party_names[party])).count()) if not exists: db.add(Mo.NewsEvent( event_date=today, kind="événement-confirmé", title=(f"Événement de campagne détecté — " f"{settings.party_names[party]}"), description=(f"Confirmation croisée ({confirmations}/3 familles) : " f"presse z={zp:.1f}, social z={zs:.1f}, " f"Wikipédia z={zw:.1f}. Effet directionnel non " f"présumé; variance de campagne déjà bornée."), parties=[party], importance=min(0.95, 0.5 + 0.1 * confirmations), detected_by="event-engine")) created += 1 db.commit() return created