spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1# QC Élection Forecast — Plateforme de prévision électorale du Québec 20262# Auteur : Simon-Pierre Boucher3# Contact : contact@spboucher.ai4# https://www.qc-election.com5"""Event Engine (v3 « 127 », §17) — détection d'événements de campagne par6CONFIRMATION CROISÉE : une anomalie de volume dans la presse OU le social ne7devient un « événement politique » que si au moins deux familles de sources la8confirment dans la même fenêtre (presse RSS/Firecrawl, médias sociaux,9attention Wikipédia).1011L'événement détecté n'a AUCUN effet directionnel présumé : il est publié dans12la chronologie et peut justifier temporairement la hausse (déjà bornée) de la13variance de campagne via signals/volatility."""14from __future__ import annotations1516from datetime import date, datetime, timedelta, timezone1718import numpy as np19from sqlalchemy.orm import Session2021from ...config import settings22from ... import models as Mo232425def _z_today(counts: list[float]) -> float:26 base = counts[1:]27 if len(base) < 7 or np.std(base) == 0:28 return 0.029 return float((counts[0] - np.mean(base)) / np.std(base))303132def detect_events(db: Session, z_threshold: float = 2.0) -> int:33 """Anomalies confirmées inter-familles → NewsEvent kind « événement-confirmé »."""34 today = date.today()35 now = datetime.now(timezone.utc)36 created = 037 for party in [p for p in settings.parties if p != "AUT"]:38 # famille presse (SentimentScore) et famille sociale (SocialPost)39 press, social = [], []40 for back in range(15):41 d0 = datetime(today.year, today.month, today.day,42 tzinfo=timezone.utc) - timedelta(days=back)43 d1 = d0 + timedelta(days=1)44 press.append(db.query(Mo.SentimentScore).join(Mo.SentimentDocument)45 .filter(Mo.SentimentScore.entity == party,46 Mo.SentimentDocument.fetched_at >= d0,47 Mo.SentimentDocument.fetched_at < d1).count())48 n_soc = 049 for post in (db.query(Mo.SocialPost)50 .filter(Mo.SocialPost.fetched_at >= d0,51 Mo.SocialPost.fetched_at < d1).all()):52 if party in (post.scores or {}):53 n_soc += 154 social.append(n_soc)55 zp, zs = _z_today([float(x) for x in press]), _z_today([float(x) for x in social])56 # attention wikipédia du jour (déjà en base via wiki_attention)57 zw = 0.058 row = (db.query(Mo.Indicator)59 .filter(Mo.Indicator.name == f"wiki_att_{party}")60 .order_by(Mo.Indicator.as_of.desc()).limit(30).all())61 if len(row) >= 10:62 vals = np.array([r.value for r in reversed(row)])63 sd = float(vals[:-1].std() or 1.0)64 zw = float((vals[-1] - vals[:-1].mean()) / sd)65 confirmations = sum(1 for z in (zp, zs, zw) if z >= z_threshold)66 if confirmations >= 2:67 exists = (db.query(Mo.NewsEvent)68 .filter(Mo.NewsEvent.event_date == today,69 Mo.NewsEvent.kind == "événement-confirmé")70 .filter(Mo.NewsEvent.title.contains(71 settings.party_names[party])).count())72 if not exists:73 db.add(Mo.NewsEvent(74 event_date=today, kind="événement-confirmé",75 title=(f"Événement de campagne détecté — "76 f"{settings.party_names[party]}"),77 description=(f"Confirmation croisée ({confirmations}/3 familles) : "78 f"presse z={zp:.1f}, social z={zs:.1f}, "79 f"Wikipédia z={zw:.1f}. Effet directionnel non "80 f"présumé; variance de campagne déjà bornée."),81 parties=[party],82 importance=min(0.95, 0.5 + 0.1 * confirmations),83 detected_by="event-engine"))84 created += 185 db.commit()86 return created87