Python 67%
TypeScript 18.2%
CSS 14.4%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# stats.py : tableau de bord analytique /api/stats/dashboard + rapport PDF5# /api/stats/report (module Stats commun Groupe KA v2 — voir6# frontend/src/ka/stats/SPEC.md). Toutes les valeurs viennent de la base7# (listings, price_log, sync_log) — AUCUNE statistique inventée : une8# mesure indisponible est simplement omise (le front affiche un état vide).9# v2 : sparklines KPI, jauges (géolocalisation, photos, publiable),10# multi-courbes (prix médian par type / grande ville), barres empilées11# (nouvelles inscriptions par bannière), distributions (prix, superficie,12# année de construction), heatmap horaire 7×24, tableaux quarantaine &13# bannières, records enrichis.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import json18import statistics19import threading20import time21import unicodedata22from datetime import date, datetime, timedelta23from pathlib import Path24from zoneinfo import ZoneInfo2526from . import db2728TZ = ZoneInfo("America/Toronto")29SQFT_PER_M2 = 10.763910430ROOT = Path(__file__).resolve().parent.parent31SOURCES_PATH = ROOT / "data" / "sources.json"3233# Position vs estimation Vrai-Prix (mêmes seuils que le fair value Lou-Ka) :34# sous le marché si écart <= -8 %, au-dessus si >= +8 % ; les écarts hors35# (-50 %, +100 %) sont presque toujours des erreurs de lecture -> ignorés.36FV_SEUIL_SOUS = -0.0837FV_SEUIL_SUR = 0.0838FV_DEV_BOUNDS = (-0.50, 1.00)39# bornes de plausibilité des prix de vente résidentiels (journal de prix) :40# les écarts extrêmes sont des erreurs de source, pas de vraies baisses.41PRICE_MIN, PRICE_MAX = 25_000, 50_000_0004243# Même règle de visibilité que le reste de l'API (web.DEDUP_CLAUSE) :44# doublons de sous-agences masqués + « Prix sur demande » exclus.45VISIBLE = " AND dup_hidden=0 AND published=1"4647PERIOD_LABELS = {48 "auj": "Aujourd'hui", "7j": "7 jours", "30j": "30 jours",49 "3m": "3 mois", "6m": "6 mois", "12m": "12 mois",50 "annee": "Année en cours", "tout": "Toute la période",51}5253# --- cache serveur (>= 5 min par période, contrat SPEC) -----------------------54_CACHE: dict[str, tuple[float, dict]] = {}55_CACHE_TTL = 30056_CACHE_LOCK = threading.Lock()575859# --- utilitaires --------------------------------------------------------------60def _today() -> date:61 return datetime.now(TZ).date()626364def _iso(d: date) -> str:65 return d.isoformat()666768def _parse(d: str) -> date | None:69 try:70 return date.fromisoformat(d[:10])71 except (ValueError, TypeError):72 return None737475def _epoch(d: date) -> float:76 """Minuit local (heure de l'Est) du jour donné, en epoch."""77 return datetime(d.year, d.month, d.day, tzinfo=TZ).timestamp()787980def resolve_period(period: str | None, frm: str | None, to: str | None,81 data_start: date) -> tuple[date, date, str]:82 today = _today()83 f, t = _parse(frm or ""), _parse(to or "")84 if f and t:85 if t < f:86 f, t = t, f87 return f, t, f"{_iso(f)} → {_iso(t)}"88 p = (period or "30j").lower()89 spans = {"7j": 6, "30j": 29, "3m": 89, "6m": 181, "12m": 364}90 if p == "auj":91 return today, today, PERIOD_LABELS["auj"]92 if p == "annee":93 return date(today.year, 1, 1), today, PERIOD_LABELS["annee"]94 if p == "tout":95 return data_start, today, PERIOD_LABELS["tout"]96 days = spans.get(p, 29)97 label = PERIOD_LABELS.get(p, PERIOD_LABELS["30j"])98 return today - timedelta(days=days), today, label99100101def _fold(s: str) -> str:102 return "".join(c for c in unicodedata.normalize("NFKD", s.lower().strip())103 if not unicodedata.combining(c))104105106def _median(vals: list[float]) -> float | None:107 # défensif : la DB peut contenir des prix NULL — on les écarte108 vals = [v for v in vals if isinstance(v, (int, float))]109 return statistics.median(vals) if vals else None110111112def _fmt_money(v: float) -> str:113 return f"{round(v):,}".replace(",", " ") + " $"114115116def _fmt_pct(cur: float, prev: float) -> float | None:117 if prev <= 0:118 return None119 return round((cur - prev) / prev * 100.0, 1)120121122def _daterange(a: date, b: date):123 d = a124 while d <= b:125 yield d126 d += timedelta(days=1)127128129def _source_names() -> dict[str, str]:130 """id -> nom lisible depuis data/sources.json (repli : id brut)."""131 try:132 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]133 return {s["id"]: s.get("name") or s["id"] for s in reg}134 except (OSError, ValueError, KeyError, TypeError):135 return {}136137138# Familles de connecteurs (mêmes règles que web._FRANCHISES — dupliquées ici139# pour éviter l'import circulaire web ⇄ stats)140_FAMILLES = [141 ("RE/MAX", lambda s: s == "remax_quebec" or s.startswith("remax_ag_")),142 ("Via Capitale", lambda s: s == "via_capitale" or s.startswith("via_ag_")),143 ("Century 21", lambda s: s == "century21" or s.startswith("c21_ag_")),144 ("Royal LePage", lambda s: s == "royal_lepage"),145 ("Groupe Sutton", lambda s: s == "sutton"),146 ("Keller Williams", lambda s: s.startswith("kw_")),147 ("DuProprio", lambda s: s == "duproprio"),148 ("Vendre.ca", lambda s: s == "vendre_ag_ca"),149]150151152def _famille_of(source: str, names: dict[str, str]) -> str:153 for name, match in _FAMILLES:154 if match(source):155 return name156 return names.get(source, source)157158159def _downsample(pts: list[dict], keep: int = 40) -> list[dict]:160 """Réduit une série de points {t,v} à <= keep points (sparklines)."""161 if len(pts) <= keep:162 return pts163 step = (len(pts) - 1) / (keep - 1)164 return [pts[round(i * step)] for i in range(keep)]165166167# Libellés lisibles des motifs de quarantaine/anomalies (quality.py)168_MOTIFS_QUALITE = {169 "quarantaine": "Sous le seuil de publication",170 "sans_image": "Sans image (secours affiché)",171 "prix_hors_bornes": "Prix hors bornes",172 "superficie_improbable": "Superficie improbable",173 "terrain_improbable": "Terrain improbable",174 "chambres_improbables": "Chambres improbables",175 "sdb_improbables": "Salles de bain improbables",176 "chambres_vs_type": "Chambres vs type incohérents",177 "annee_invalide": "Année de construction invalide",178 "prix_pi2_extreme": "Prix au pi² extrême",179}180181182# --- calcul du tableau de bord ------------------------------------------------183def _compute(frm_q: str | None, to_q: str | None, period: str | None) -> dict:184 con = db.connect()185 try:186 return _compute_con(con, frm_q, to_q, period)187 finally:188 con.close()189190191def _compute_con(con, frm_q, to_q, period) -> dict:192 today = _today()193 row = con.execute("SELECT MIN(first_seen) m FROM listings").fetchone()194 data_start = (datetime.fromtimestamp(row["m"], TZ).date()195 if row and row["m"] else today)196197 frm, to, label = resolve_period(period, frm_q, to_q, data_start)198 to = min(to, today)199 # fenêtre observée : la collecte a commencé le data_start — les séries200 # sont bornées à ce qui a réellement été mesuré (rien d'extrapolé).201 s_frm = max(frm, data_start)202 s_to = max(to, s_frm)203 ep_frm, ep_to = _epoch(s_frm), _epoch(s_to + timedelta(days=1))204 ndays = (s_to - s_frm).days + 1205 # période précédente de même longueur (pour les deltas)206 p_frm, p_to = s_frm - timedelta(days=ndays), s_frm - timedelta(days=1)207 # deltas seulement si la période précédente a été observée EN ENTIER —208 # comparer à une fenêtre tronquée fausserait les variations.209 prev_ok = p_frm >= data_start210 ep_pfrm, ep_pto = _epoch(p_frm), _epoch(p_to + timedelta(days=1))211212 # ---- reconstruction « annonces actives par jour » (événements) ----------213 actives_by_day: dict[str, int] = {}214 deltas: dict[date, int] = {}215 for r in con.execute(216 "SELECT date(first_seen,'unixepoch','localtime') fs,"217 " date(last_seen,'unixepoch','localtime') ls, active"218 " FROM listings WHERE 1=1" + VISIBLE):219 d0 = _parse(r["fs"])220 if d0 is None:221 continue222 deltas[d0] = deltas.get(d0, 0) + 1223 if not r["active"]:224 d1 = (_parse(r["ls"]) or d0) + timedelta(days=1)225 deltas[d1] = deltas.get(d1, 0) - 1226 run = 0227 for d in _daterange(data_start, today):228 run += deltas.get(d, 0)229 actives_by_day[_iso(d)] = run230231 # ---- KPI -----------------------------------------------------------------232 snap = con.execute(233 "SELECT COUNT(*) n, AVG(price) avg_p,"234 " COUNT(DISTINCT NULLIF(city,'')) cities"235 " FROM listings WHERE active=1" + VISIBLE).fetchone()236 prices = [r["price"] for r in con.execute(237 "SELECT price FROM listings WHERE active=1" + VISIBLE)]238 med_price = _median(prices)239 ppm2 = [r["v"] for r in con.execute(240 "SELECT price/(area_sqft/" + str(SQFT_PER_M2) + ") v FROM listings"241 " WHERE active=1 AND area_sqft>=200" + VISIBLE)]242 med_ppm2 = _median(ppm2)243 n_ppm2 = len(ppm2)244245 new_cur = con.execute(246 "SELECT COUNT(*) n FROM listings WHERE first_seen>=? AND first_seen<?"247 + VISIBLE, (ep_frm, ep_to)).fetchone()["n"]248 new_prev = con.execute(249 "SELECT COUNT(*) n FROM listings WHERE first_seen>=? AND first_seen<?"250 + VISIBLE, (ep_pfrm, ep_pto)).fetchone()["n"] if prev_ok else 0251 gone_cur = con.execute(252 "SELECT COUNT(*) n FROM listings WHERE active=0 AND last_seen>=?"253 " AND last_seen<?" + VISIBLE, (ep_frm, ep_to)).fetchone()["n"]254 gone_prev = con.execute(255 "SELECT COUNT(*) n FROM listings WHERE active=0 AND last_seen>=?"256 " AND last_seen<?" + VISIBLE, (ep_pfrm, ep_pto)).fetchone()["n"] if prev_ok else 0257 conn_cur = con.execute(258 "SELECT COUNT(DISTINCT source) n FROM sync_log WHERE ok=1 AND ts>=?"259 " AND ts<?", (ep_frm, ep_to)).fetchone()["n"]260261 act_now = snap["n"]262 act_prev = actives_by_day.get(_iso(p_to)) if prev_ok else None263264 def kpi(id_, lbl, val, unit="", dpct=None):265 k = {"id": id_, "label": lbl, "value": val, "unit": unit}266 if dpct is not None:267 k["delta_pct"] = dpct268 k["direction"] = "up" if dpct >= 0 else "down"269 return k270271 kpis = [272 kpi("actives", "Annonces actives", act_now, "",273 _fmt_pct(act_now, act_prev) if act_prev else None),274 kpi("nouvelles", "Nouvelles annonces (période)", new_cur, "",275 _fmt_pct(new_cur, new_prev) if prev_ok and new_prev else None),276 kpi("retirees", "Vendues / retirées (période)", gone_cur, "",277 _fmt_pct(gone_cur, gone_prev) if prev_ok and gone_prev else None),278 ]279 if snap["avg_p"]:280 kpis.append(kpi("prix_moyen", "Prix moyen demandé",281 round(snap["avg_p"]), "$"))282 if med_price:283 kpis.append(kpi("prix_median", "Prix médian demandé",284 round(med_price), "$"))285 if med_ppm2 and n_ppm2 >= 100:286 kpis.append(kpi("prix_m2",287 f"Prix médian au m² ({n_ppm2:,} annonces avec superficie)".replace(",", " "),288 round(med_ppm2), "$/m²"))289 # prix au pi² déclaré à la source (details.prix_pi2) — médiane290 ppi2 = [r["v"] for r in con.execute(291 "SELECT CAST(json_extract(details,'$.prix_pi2') AS REAL) v"292 " FROM listings WHERE active=1" + VISIBLE +293 " AND CAST(json_extract(details,'$.prix_pi2') AS REAL)"294 " BETWEEN 30 AND 10000")]295 med_ppi2 = _median(ppi2)296 if med_ppi2 and len(ppi2) >= 100:297 kpis.append(kpi(298 "prix_pi2",299 f"Prix médian au pi² ({len(ppi2):,} annonces le déclarant)".replace(",", " "),300 round(med_ppi2), "$/pi²"))301 # jours sur le marché (annonces actives) — médiane depuis first_seen302 now_ts = time.time()303 dom = [max((now_ts - r["fs"]) / 86400.0, 0.0) for r in con.execute(304 "SELECT first_seen fs FROM listings WHERE active=1" + VISIBLE)]305 med_dom = _median(dom)306 if med_dom is not None and dom:307 kpis.append(kpi("jours_marche", "Jours sur le marché (médiane, actives)",308 round(med_dom, 1), "j"))309 # baisses de prix observées dans la période (journal price_log) —310 # une entrée par annonce, bornes de plausibilité (voir en tête de fichier)311 drops = con.execute(312 """SELECT l.city city, MAX(p1.price - p2.price) amt,313 date(MAX(p2.ts),'unixepoch','localtime') dt314 FROM price_log p1315 JOIN price_log p2 ON p2.uid = p1.uid AND p2.ts > p1.ts316 JOIN listings l ON l.uid = p1.uid317 WHERE p2.ts>=? AND p2.ts<? AND p2.price < p1.price318 AND p1.price BETWEEN ? AND ? AND p2.price BETWEEN ? AND ?319 AND p2.price >= p1.price * 0.5 AND l.dup_hidden=0 AND l.published=1320 GROUP BY l.uid ORDER BY amt DESC""",321 (ep_frm, ep_to, PRICE_MIN, PRICE_MAX, PRICE_MIN, PRICE_MAX)).fetchall()322 kpis.append(kpi("baisses_prix", "Baisses de prix observées (période)",323 len(drops)))324 # qualité des données (quality.py) : score moyen + quarantaine325 qual = con.execute(326 "SELECT ROUND(AVG(quality_score),1) c FROM listings WHERE active=1"327 + VISIBLE).fetchone()328 quar = con.execute(329 "SELECT COUNT(*) n FROM listings"330 " WHERE active=1 AND dup_hidden=0 AND published=0").fetchone()["n"]331 if qual["c"] is not None:332 kpis.append(kpi("qualite", "Score de qualité moyen des fiches",333 qual["c"], "/100"))334 kpis.append(kpi("quarantaine", "Annonces en quarantaine (qualité)",335 quar))336 # position des prix vs estimation Vrai-Prix (juste valeur) — un seul337 # balayage réutilisé par le KPI, l'anneau et le tableau par ville338 b_lo, b_hi = FV_DEV_BOUNDS339 fv_rows = con.execute(340 "SELECT city, (price - CAST(json_extract(vraiprix,'$.value') AS REAL))"341 " / CAST(json_extract(vraiprix,'$.value') AS REAL) dev"342 " FROM listings WHERE active=1" + VISIBLE +343 " AND CAST(json_extract(vraiprix,'$.value') AS REAL) > 0"344 " AND price BETWEEN ? AND ?", (PRICE_MIN, PRICE_MAX)).fetchall()345 fv_sous = fv_marche = fv_sur = 0346 fv_city: dict[str, list[float]] = {}347 for r in fv_rows:348 dev = r["dev"]349 if dev is None or not (b_lo < dev < b_hi):350 continue351 if dev <= FV_SEUIL_SOUS:352 fv_sous += 1353 elif dev >= FV_SEUIL_SUR:354 fv_sur += 1355 else:356 fv_marche += 1357 if r["city"]:358 fv_city.setdefault(r["city"], []).append(dev)359 fv_n = fv_sous + fv_marche + fv_sur360 if fv_n:361 kpis.append(kpi("sous_marche", "Annonces sous l'estimation Vrai-Prix",362 fv_sous))363 kpis.append(kpi("villes", "Villes couvertes", snap["cities"]))364 kpis.append(kpi("connecteurs", "Connecteurs actifs (période)", conn_cur))365 # indice de tension : retraits / nouvelles entrées (mesuré, pas modélisé)366 if new_cur >= 50:367 kpis.append(kpi("tension", "Tension — retraits / nouvelles",368 round(100.0 * gone_cur / new_cur, 1), "%"))369370 # ---- jauges (v2) : couvertures mesurées sur les annonces publiées --------371 gauges: list[dict] = []372 if act_now:373 g_geo = con.execute(374 "SELECT COUNT(*) n FROM listings WHERE active=1"375 " AND lat IS NOT NULL AND lng IS NOT NULL" + VISIBLE).fetchone()["n"]376 g_photo = con.execute(377 "SELECT COUNT(*) n FROM listings WHERE active=1"378 " AND images IS NOT NULL AND images<>'' AND images<>'[]'"379 + VISIBLE).fetchone()["n"]380 g_vp = con.execute(381 "SELECT COUNT(*) n FROM listings WHERE active=1" + VISIBLE +382 " AND CAST(json_extract(vraiprix,'$.value') AS REAL) > 0"383 ).fetchone()["n"]384 gauges.append({"id": "geoloc", "label": "Fiches géolocalisées",385 "value": round(100.0 * g_geo / act_now, 1),386 "max": 100, "unit": "%"})387 gauges.append({"id": "photos", "label": "Fiches avec photos",388 "value": round(100.0 * g_photo / act_now, 1),389 "max": 100, "unit": "%"})390 if g_vp:391 gauges.append({"id": "vraiprix",392 "label": "Fiches avec estimation Vrai-Prix",393 "value": round(100.0 * g_vp / act_now, 1),394 "max": 100, "unit": "%"})395 act_all = con.execute(396 "SELECT COUNT(*) n FROM listings WHERE active=1 AND dup_hidden=0"397 ).fetchone()["n"]398 if act_all:399 gauges.append({"id": "publiable",400 "label": "Hors quarantaine (qualité publiable)",401 "value": round(100.0 * (act_all - quar) / act_all, 1),402 "max": 100, "unit": "%"})403 if qual["c"] is not None:404 gauges.append({"id": "completude",405 "label": "Complétude moyenne des fiches (0–100)",406 "value": qual["c"], "max": 100})407408 # ---- séries quotidiennes ---------------------------------------------------409 days = [_iso(d) for d in _daterange(s_frm, s_to)]410 new_by_day = {r["d"]: r["n"] for r in con.execute(411 "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"412 " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +413 " GROUP BY d", (ep_frm, ep_to))}414 gone_by_day = {r["d"]: r["n"] for r in con.execute(415 "SELECT date(last_seen,'unixepoch','localtime') d, COUNT(*) n"416 " FROM listings WHERE active=0 AND last_seen>=? AND last_seen<?"417 + VISIBLE + " GROUP BY d", (ep_frm, ep_to))}418 series = []419 if len(days) >= 2:420 series = [421 {"id": "actives", "title": "Annonces actives par jour",422 "unit": "annonces", "kind": "line",423 "points": [{"t": d, "v": actives_by_day.get(d, 0)} for d in days]},424 {"id": "nouvelles", "title": "Nouvelles annonces par jour",425 "unit": "annonces", "kind": "bar",426 "points": [{"t": d, "v": new_by_day.get(d, 0)} for d in days]},427 {"id": "retraits", "title": "Retraits (vendues / retirées) par jour",428 "unit": "annonces", "kind": "bar",429 "points": [{"t": d, "v": gone_by_day.get(d, 0)} for d in days]},430 ]431 # prix médian demandé des nouvelles inscriptions (jours à >= 3 entrées432 # seulement — rien d'interpolé, l'axe saute les jours creux)433 med_day: list[dict] = []434 day_prices: dict[str, list[float]] = {}435 for r in con.execute(436 "SELECT date(first_seen,'unixepoch','localtime') d, price"437 " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +438 " AND price BETWEEN ? AND ?",439 (ep_frm, ep_to, PRICE_MIN, PRICE_MAX)):440 day_prices.setdefault(r["d"], []).append(r["price"])441 for d in days:442 ps = day_prices.get(d)443 if ps and len(ps) >= 3:444 med_day.append({"t": d, "v": round(statistics.median(ps))})445 if len(med_day) >= 5:446 series.append({447 "id": "prix_median_nouvelles",448 "title": "Prix médian demandé des nouvelles inscriptions"449 " (jours à ≥ 3 entrées)",450 "unit": "$", "kind": "area", "points": med_day})451 # comparaison période précédente (même longueur) : seulement si elle452 # a réellement été observée en entier (rien d'extrapolé)453 if prev_ok:454 pdays = [_iso(d) for d in _daterange(p_frm, p_to)]455 series[0]["compare"] = [{"t": d, "v": actives_by_day.get(d, 0)}456 for d in pdays]457 cmp_new = {r["d"]: r["n"] for r in con.execute(458 "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"459 " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +460 " GROUP BY d", (ep_pfrm, ep_pto))}461 series[1]["compare"] = [{"t": d, "v": cmp_new.get(d, 0)}462 for d in pdays]463464 # ---- sparklines des KPI (v2) — mêmes données que les séries ---------------465 if len(days) >= 2:466 conn_day = {r["d"]: r["n"] for r in con.execute(467 "SELECT date(ts,'unixepoch','localtime') d,"468 " COUNT(DISTINCT source) n FROM sync_log"469 " WHERE ok=1 AND ts>=? AND ts<? GROUP BY d", (ep_frm, ep_to))}470 sparks = {471 "actives": [{"t": d, "v": actives_by_day.get(d, 0)} for d in days],472 "nouvelles": [{"t": d, "v": new_by_day.get(d, 0)} for d in days],473 "retirees": [{"t": d, "v": gone_by_day.get(d, 0)} for d in days],474 "connecteurs": [{"t": d, "v": conn_day.get(d, 0)} for d in days],475 }476 for k in kpis:477 sp = sparks.get(k["id"])478 if sp and any(p["v"] for p in sp):479 k["spark"] = _downsample(sp)480481 # ---- multi-courbes (v2) : prix médian des nouvelles inscriptions ----------482 # Buckets quotidiens (<= 45 j) ou hebdomadaires ; un bucket n'est gardé que483 # si CHAQUE groupe y compte >= 3 inscriptions (axes alignés, rien d'inventé).484 def _multiserie(id_, title, group_sql, top_n):485 weekly = ndays > 45486 bucket_sql = ("strftime('%Y-%m-%d', first_seen, 'unixepoch',"487 " 'localtime', 'weekday 1', '-6 days')" if weekly else488 "date(first_seen,'unixepoch','localtime')")489 rows = con.execute(490 f"SELECT {group_sql} g, {bucket_sql} b, price FROM listings"491 " WHERE first_seen>=? AND first_seen<?" + VISIBLE +492 f" AND price BETWEEN ? AND ? AND {group_sql} <> ''",493 (ep_frm, ep_to, PRICE_MIN, PRICE_MAX)).fetchall()494 vol: dict[str, int] = {}495 data: dict[str, dict[str, list[float]]] = {}496 for r in rows:497 if "/" in r["g"]: # libellés composites de sources ("Laval /498 continue # North Shore") — bruit, pas une vraie ville499 vol[r["g"]] = vol.get(r["g"], 0) + 1500 data.setdefault(r["g"], {}).setdefault(r["b"], []).append(r["price"])501 groups = [g for g, _ in sorted(vol.items(), key=lambda kv: -kv[1])[:top_n]]502 if len(groups) < 2:503 return None504 buckets = sorted({b for g in groups for b in data[g]505 if all(len(data[gg].get(b, [])) >= 3 for gg in groups)})506 if len(buckets) < 4:507 return None508 return {"id": id_, "title": title + (" (semaines)" if weekly else ""),509 "unit": "$",510 "series": [{"label": g, "points": [511 {"t": b, "v": round(statistics.median(data[g][b]))}512 for b in buckets]} for g in groups]}513514 multiseries = []515 ms_type = _multiserie(516 "prix_type", "Prix médian des nouvelles inscriptions par type",517 "property_type", 3)518 if ms_type:519 multiseries.append(ms_type)520 ms_ville = _multiserie(521 "prix_ville", "Prix médian des nouvelles inscriptions — grandes villes",522 "city", 4)523 if ms_ville:524 multiseries.append(ms_ville)525526 # ---- barres empilées (v2) : nouvelles inscriptions par bannière -----------527 names = _source_names()528 stacked = []529 if len(days) >= 2:530 weekly_st = ndays > 60531 bucket_st = ("strftime('%Y-%m-%d', first_seen, 'unixepoch',"532 " 'localtime', 'weekday 1', '-6 days')" if weekly_st else533 "date(first_seen,'unixepoch','localtime')")534 fam_day: dict[str, dict[str, int]] = {}535 fam_tot: dict[str, int] = {}536 for r in con.execute(537 f"SELECT source s, {bucket_st} b, COUNT(*) n FROM listings"538 " WHERE first_seen>=? AND first_seen<?" + VISIBLE +539 " GROUP BY source, b", (ep_frm, ep_to)):540 fam = _famille_of(r["s"], names)541 fam_day.setdefault(fam, {})542 fam_day[fam][r["b"]] = fam_day[fam].get(r["b"], 0) + r["n"]543 fam_tot[fam] = fam_tot.get(fam, 0) + r["n"]544 if fam_tot:545 top_fams = [f for f, _ in546 sorted(fam_tot.items(), key=lambda kv: -kv[1])[:5]]547 others = [f for f in fam_day if f not in top_fams]548 keys = top_fams + (["Autres"] if others else [])549 buckets_st = sorted({b for d_ in fam_day.values() for b in d_})550 pts = []551 for b in buckets_st:552 vals = [fam_day[f].get(b, 0) for f in top_fams]553 if others:554 vals.append(sum(fam_day[f].get(b, 0) for f in others))555 pts.append({"t": b, "values": vals})556 if len(pts) >= 2:557 stacked.append({558 "id": "ajouts_bannieres",559 "title": "Nouvelles inscriptions par bannière"560 + (" (semaines)" if weekly_st else ""),561 "unit": "inscriptions", "keys": keys, "points": pts})562563 # ---- distributions (v2) : prix, superficie, année de construction ---------564 distributions = []565 price_bins = [("< 100 k$", 0, 100e3)] + [566 (f"{i}00–{i+1}00 k$", i * 100e3, (i + 1) * 100e3) for i in range(1, 10)567 ] + [("1–1,5 M$", 1e6, 1.5e6), ("1,5–2 M$", 1.5e6, 2e6),568 ("2 M$ +", 2e6, None)]569 bins_p = []570 for lbl, lo, hi in price_bins:571 q = ("SELECT COUNT(*) n FROM listings WHERE active=1 AND price>=?"572 + VISIBLE)573 args: list = [lo]574 if hi is not None:575 q += " AND price<?"576 args.append(hi)577 bins_p.append({"label": lbl,578 "value": con.execute(q, args).fetchone()["n"]})579 if sum(b["value"] for b in bins_p):580 distributions.append({581 "id": "prix", "unit": "annonces",582 "title": "Distribution des prix demandés (annonces actives)",583 "bins": bins_p})584 area_bins = [("< 500", 100, 500), ("500–1 000", 500, 1000),585 ("1 000–1 500", 1000, 1500), ("1 500–2 000", 1500, 2000),586 ("2 000–2 500", 2000, 2500), ("2 500–3 000", 2500, 3000),587 ("3 000–4 000", 3000, 4000), ("4 000 +", 4000, 20000)]588 bins_a = [{"label": f"{lbl} pi²",589 "value": con.execute(590 "SELECT COUNT(*) n FROM listings WHERE active=1"591 " AND area_sqft>=? AND area_sqft<?" + VISIBLE,592 (lo, hi)).fetchone()["n"]}593 for lbl, lo, hi in area_bins]594 if sum(b["value"] for b in bins_a) >= 100:595 distributions.append({596 "id": "superficie", "unit": "annonces",597 "title": "Distribution des superficies habitables (renseignées)",598 "bins": bins_a})599 yr_now = today.year600 year_bins = ([("< 1900", 1600, 1900), ("1900–1949", 1900, 1950)] +601 [(f"{d}–{d+9}", d, d + 10) for d in range(1950, 2020, 10)] +602 [("2020 +", 2020, yr_now + 2)])603 bins_y = [{"label": lbl,604 "value": con.execute(605 "SELECT COUNT(*) n FROM listings WHERE active=1"606 " AND year_built>=? AND year_built<?" + VISIBLE,607 (lo, hi)).fetchone()["n"]}608 for lbl, lo, hi in year_bins]609 if sum(b["value"] for b in bins_y) >= 100:610 distributions.append({611 "id": "annee", "unit": "annonces",612 "title": "Distribution des années de construction (renseignées)",613 "bins": bins_y})614615 # ---- heatmap horaire (v2) : détection des nouvelles annonces (7×24) -------616 # first_seen = moment où la synchronisation a détecté l'annonce — c'est le617 # rythme réel d'alimentation de la plateforme (8 dernières semaines).618 h56 = _epoch(max(data_start, s_to - timedelta(days=55)))619 hourly_cells = [620 {"dow": (int(r["w"]) + 6) % 7, "hour": int(r["h"]), "value": r["n"]}621 for r in con.execute(622 "SELECT strftime('%w', first_seen,'unixepoch','localtime') w,"623 " strftime('%H', first_seen,'unixepoch','localtime') h, COUNT(*) n"624 " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +625 " GROUP BY w, h", (h56, _epoch(s_to + timedelta(days=1))))]626 hourly = ({"title": "Détection de nouvelles annonces par heure"627 " (8 dernières semaines)", "cells": hourly_cells}628 if len(hourly_cells) >= 12 else None)629630 # ---- répartitions (photo des annonces actives) -----------------------------631 types = [{"label": r["t"] or "Autre / non précisé", "value": r["n"]}632 for r in con.execute(633 "SELECT property_type t, COUNT(*) n FROM listings"634 " WHERE active=1" + VISIBLE +635 " GROUP BY property_type ORDER BY n DESC LIMIT 9")]636 ranges = [("Moins de 200 k$", 0, 200e3), ("200 – 300 k$", 200e3, 300e3),637 ("300 – 400 k$", 300e3, 400e3), ("400 – 500 k$", 400e3, 500e3),638 ("500 – 750 k$", 500e3, 750e3), ("750 k$ – 1 M$", 750e3, 1e6),639 ("1 – 2 M$", 1e6, 2e6), ("2 M$ et plus", 2e6, None)]640 price_items = []641 for lbl, lo, hi in ranges:642 q = "SELECT COUNT(*) n FROM listings WHERE active=1 AND price>=?" + VISIBLE643 args: list = [lo]644 if hi is not None:645 q += " AND price<?"646 args.append(hi)647 price_items.append({"label": lbl,648 "value": con.execute(q, args).fetchone()["n"]})649 # nouvelles inscriptions de la période par fourchette + delta honnête650 # (vs période précédente entièrement observée seulement)651 new_price_items = []652 for lbl, lo, hi in ranges:653 base = ("SELECT COUNT(*) n FROM listings WHERE first_seen>=?"654 " AND first_seen<? AND price>=?" + VISIBLE)655 args_c: list = [ep_frm, ep_to, lo]656 args_p: list = [ep_pfrm, ep_pto, lo]657 if hi is not None:658 base += " AND price<?"659 args_c.append(hi)660 args_p.append(hi)661 n_c = con.execute(base, args_c).fetchone()["n"]662 item = {"label": lbl, "value": n_c}663 if prev_ok:664 n_p = con.execute(base, args_p).fetchone()["n"]665 if n_p:666 item["delta_pct"] = _fmt_pct(n_c, n_p)667 new_price_items.append(item)668 beds = [{"label": ("8 chambres et +" if r["b"] >= 8669 else f"{int(r['b'])} chambre" + ("s" if r["b"] > 1 else "")),670 "value": r["n"]}671 for r in con.execute(672 "SELECT MIN(bedrooms,8) b, COUNT(*) n FROM listings"673 " WHERE active=1 AND bedrooms IS NOT NULL" + VISIBLE +674 " GROUP BY MIN(bedrooms,8) ORDER BY b")]675 names = _source_names()676 by_source = [{"label": names.get(r["s"], r["s"]), "value": r["n"]}677 for r in con.execute(678 "SELECT source s, COUNT(*) n FROM listings"679 " WHERE active=1" + VISIBLE +680 " GROUP BY source ORDER BY n DESC LIMIT 12")]681 breakdowns = []682 if fv_n:683 breakdowns.append({684 "id": "fairvalue",685 "title": "Position des prix demandés vs estimation Vrai-Prix",686 "kind": "donut", "items": [687 {"label": "Sous le marché", "value": fv_sous},688 {"label": "Dans le marché", "value": fv_marche},689 {"label": "Au-dessus du marché", "value": fv_sur}]})690 breakdowns += [691 {"id": "types", "title": "Répartition par type de propriété",692 "kind": "donut", "items": types},693 {"id": "prix", "title": "Répartition par fourchette de prix demandé",694 "kind": "bar", "items": price_items},695 ]696 if sum(i["value"] for i in new_price_items):697 breakdowns.append({698 "id": "prix_nouvelles",699 "title": "Nouvelles inscriptions par fourchette de prix (période)",700 "kind": "bar", "items": new_price_items})701 if beds:702 breakdowns.append({"id": "chambres",703 "title": "Répartition par nombre de chambres (renseignées)",704 "kind": "bar", "items": beds})705 if by_source:706 breakdowns.append({"id": "sources",707 "title": "Top sources (annonces actives)",708 "kind": "bar", "items": by_source})709710 # ---- géographie : par région (fusion accents/casse, libellé le + fréquent)711 reg_counts: dict[str, dict[str, int]] = {}712 for r in con.execute(713 "SELECT region, COUNT(*) n FROM listings WHERE active=1"714 " AND region<>''" + VISIBLE + " GROUP BY region"):715 raw = (r["region"] or "").strip()716 key = _fold(raw)717 if not key or key.isdigit():718 continue719 reg_counts.setdefault(key, {})[raw] = reg_counts.get(key, {}).get(raw, 0) + r["n"]720 geo_items = []721 for key, variants in reg_counts.items():722 best_variant = max(variants, key=variants.get)723 geo_items.append({"label": best_variant, "value": sum(variants.values())})724 geo_items.sort(key=lambda x: -x["value"])725 geo = ({"title": "Annonces actives par région", "items": geo_items[:14]}726 if geo_items else None)727728 # ---- heatmap : nouvelles annonces par jour (26 dernières semaines max) ----729 h_frm = max(data_start, s_to - timedelta(days=181))730 hm = [{"date": r["d"], "value": r["n"]} for r in con.execute(731 "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"732 " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +733 " GROUP BY d", (_epoch(h_frm), _epoch(s_to + timedelta(days=1))))]734 heatmap = ({"title": "Nouvelles annonces par jour", "cells": hm}735 if len(hm) >= 2 else None)736737 # ---- tableaux ---------------------------------------------------------------738 # Top villes : actives, prix moyen/médian, nouvelles sur la période + delta739 city_prices: dict[str, list[float]] = {}740 for r in con.execute(741 "SELECT city, price FROM listings WHERE active=1 AND city<>''"742 + VISIBLE):743 city_prices.setdefault(r["city"], []).append(r["price"])744 new_city = {r["city"]: r["n"] for r in con.execute(745 "SELECT city, COUNT(*) n FROM listings WHERE city<>''"746 " AND first_seen>=? AND first_seen<?" + VISIBLE + " GROUP BY city",747 (ep_frm, ep_to))}748 new_city_prev = {r["city"]: r["n"] for r in con.execute(749 "SELECT city, COUNT(*) n FROM listings WHERE city<>''"750 " AND first_seen>=? AND first_seen<?" + VISIBLE + " GROUP BY city",751 (ep_pfrm, ep_pto))} if prev_ok else {}752 gone_city = {r["city"]: r["n"] for r in con.execute(753 "SELECT city, COUNT(*) n FROM listings WHERE active=0 AND city<>''"754 " AND last_seen>=? AND last_seen<?" + VISIBLE + " GROUP BY city",755 (ep_frm, ep_to))}756 top = sorted(city_prices.items(), key=lambda kv: -len(kv[1]))[:50]757 top_rows = []758 for city, ps in top:759 n_new = new_city.get(city, 0)760 n_prev = new_city_prev.get(city, 0)761 net = n_new - gone_city.get(city, 0)762 d = _fmt_pct(n_new, n_prev) if prev_ok and n_prev else None763 pn = [v for v in ps if isinstance(v, (int, float))] # prix NULL écartés764 top_rows.append([765 city, len(ps),766 _fmt_money(sum(pn) / len(pn)) if pn else "—",767 _fmt_money(statistics.median(pn)) if pn else "—", n_new,768 f"{'+' if net >= 0 else ''}{net}",769 (f"{'+' if d >= 0 else ''}{str(d).replace('.', ',')} %"770 if d is not None else "—"),771 ])772 tables = [{773 "id": "top_villes", "title": "Top villes",774 "columns": ["Ville", "Actives", "Prix moyen", "Prix médian",775 "Nouvelles (période)", "Δ net (période)", "Var. nouvelles"],776 "rows": top_rows,777 }]778 # Top sources : actives, prix moyen, nouvelles, qualité, quarantaine, synchro779 top_srcs = con.execute(780 """SELECT source s, COUNT(*) n,781 AVG(CASE WHEN price>0 THEN price END) avg_p,782 SUM(CASE WHEN first_seen>=? AND first_seen<? THEN 1 ELSE 0 END) new_n,783 ROUND(AVG(quality_score),0) qual784 FROM listings WHERE active=1""" + VISIBLE +785 " GROUP BY source ORDER BY n DESC LIMIT 50", (ep_frm, ep_to)).fetchall()786 quar_src = {r["s"]: r["n"] for r in con.execute(787 "SELECT source s, COUNT(*) n FROM listings"788 " WHERE active=1 AND dup_hidden=0 AND published=0 GROUP BY source")}789 last_sync = {r["source"]: r["ts"] for r in con.execute(790 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}791 if top_srcs:792 src_rows = []793 for r in top_srcs:794 ls = last_sync.get(r["s"])795 src_rows.append([796 names.get(r["s"], r["s"]), r["n"],797 _fmt_money(r["avg_p"]) if r["avg_p"] else "—",798 r["new_n"],799 f"{r['qual']:.0f} /100" if r["qual"] is not None else "—",800 quar_src.get(r["s"], 0),801 (datetime.fromtimestamp(ls, TZ).strftime("%Y-%m-%d %H:%M")802 if ls else "—")])803 tables.append({804 "id": "top_sources", "title": "Top sources & courtiers",805 "columns": ["Source", "Annonces actives", "Prix moyen",806 "Nouvelles (période)", "Qualité", "Quarantaine",807 "Dernière synchro"],808 "rows": src_rows})809 # Délai de présence (retirées de la période) par ville810 dur_city: dict[str, list[float]] = {}811 for r in con.execute(812 "SELECT city, (last_seen-first_seen)/86400.0 d FROM listings"813 " WHERE active=0 AND city<>'' AND last_seen>=? AND last_seen<?"814 + VISIBLE, (ep_frm, ep_to)):815 dur_city.setdefault(r["city"], []).append(max(r["d"], 0.0))816 dur_rows = []817 for city, ds in sorted(dur_city.items(), key=lambda kv: -len(kv[1]))[:50]:818 if len(ds) < 3:819 continue820 dur_rows.append([821 city, len(ds),822 str(round(sum(ds) / len(ds), 1)).replace(".", ","),823 str(round(statistics.median(ds), 1)).replace(".", ","),824 ])825 if dur_rows:826 tables.append({827 "id": "delai_villes",828 "title": "Délai de présence avant retrait, par ville (période)",829 "columns": ["Ville", "Retirées", "Délai moyen (j)", "Délai médian (j)"],830 "rows": dur_rows,831 })832 # Écart moyen à l'estimation Vrai-Prix par ville (volume suffisant)833 fv_rows_city = []834 for city, devs in sorted(fv_city.items(), key=lambda kv: -len(kv[1]))[:25]:835 if len(devs) < 50:836 continue837 dev_pct = round(100.0 * sum(devs) / len(devs), 1)838 fv_rows_city.append([839 city, len(devs),840 f"{'+' if dev_pct >= 0 else ''}{str(dev_pct).replace('.', ',')} %",841 sum(1 for d in devs if d <= FV_SEUIL_SOUS)])842 if fv_rows_city:843 tables.append({844 "id": "fv_villes",845 "title": "Écart à l'estimation Vrai-Prix par ville",846 "columns": ["Ville", "Annonces évaluées", "Écart moyen",847 "Sous le marché"],848 "rows": fv_rows_city})849 # Couche qualité : anomalies & quarantaine par motif (quality.py)850 anomalies: dict[str, int] = {}851 for r in con.execute(852 "SELECT quality_issues FROM listings WHERE active=1"853 " AND dup_hidden=0 AND quality_issues IS NOT NULL"):854 try:855 for issue in json.loads(r["quality_issues"]):856 key = issue.split(":")[0]857 anomalies[key] = anomalies.get(key, 0) + 1858 except ValueError:859 continue860 if anomalies and act_all:861 tables.append({862 "id": "quarantaine_motifs",863 "title": "Couche qualité — anomalies et quarantaine par motif",864 "columns": ["Motif", "Annonces touchées", "% des actives"],865 "rows": [[_MOTIFS_QUALITE.get(k, k), v,866 str(round(100.0 * v / act_all, 1)).replace(".", ",") + " %"]867 for k, v in sorted(anomalies.items(), key=lambda kv: -kv[1])],868 })869 # Bannières / familles de connecteurs : volume, nouvelles, prix, fraîcheur870 fam_info: dict[str, dict] = {}871 for r in con.execute(872 "SELECT source s, price p FROM listings WHERE active=1" + VISIBLE):873 e = fam_info.setdefault(_famille_of(r["s"], names),874 {"srcs": set(), "prices": [], "new": 0,875 "sync": None})876 e["srcs"].add(r["s"])877 e["prices"].append(r["p"])878 for r in con.execute(879 "SELECT source s, COUNT(*) n FROM listings WHERE first_seen>=?"880 " AND first_seen<?" + VISIBLE + " GROUP BY source",881 (ep_frm, ep_to)):882 fam = _famille_of(r["s"], names)883 if fam in fam_info:884 fam_info[fam]["new"] += r["n"]885 for src, ts in last_sync.items():886 fam = _famille_of(src, names)887 if fam in fam_info:888 e = fam_info[fam]889 e["sync"] = max(e["sync"] or 0, ts)890 if fam_info:891 fam_rows = []892 for fam, e in sorted(fam_info.items(),893 key=lambda kv: -len(kv[1]["prices"]))[:30]:894 fam_rows.append([895 fam, len(e["srcs"]), len(e["prices"]), e["new"],896 (_fmt_money(statistics.median(pn))897 if (pn := [v for v in e["prices"]898 if isinstance(v, (int, float))]) else "—"),899 (datetime.fromtimestamp(e["sync"], TZ).strftime("%Y-%m-%d %H:%M")900 if e["sync"] else "—")])901 tables.append({902 "id": "familles",903 "title": "Bannières & familles de connecteurs",904 "columns": ["Bannière / famille", "Connecteurs", "Annonces actives",905 "Nouvelles (période)", "Prix médian",906 "Dernière synchro"],907 "rows": fam_rows})908909 # ---- records & faits marquants ---------------------------------------------910 records = []911 if new_by_day:912 best = max(new_by_day.items(), key=lambda kv: kv[1])913 records.append({"label": "Jour record de nouvelles annonces",914 "value": f"{best[1]:,} annonces".replace(",", " "),915 "date": best[0]})916 if gone_by_day:917 worst = max(gone_by_day.items(), key=lambda kv: kv[1])918 records.append({"label": "Jour record de retraits",919 "value": f"{worst[1]:,} annonces".replace(",", " "),920 "date": worst[0]})921 fast = con.execute(922 "SELECT city, address, (last_seen-first_seen)/86400.0 d,"923 " date(last_seen,'unixepoch','localtime') dt FROM listings"924 " WHERE active=0 AND last_seen>=? AND last_seen<?"925 " AND last_seen-first_seen>=3600" # >= 1 h : écarte les artefacts de sync926 + VISIBLE + " ORDER BY (last_seen-first_seen) ASC LIMIT 1",927 (ep_frm, ep_to)).fetchone()928 if fast:929 d = fast["d"]930 val = (f"{round(d * 24, 1)} h" if d < 1 else f"{round(d, 1)} j").replace(".", ",")931 records.append({"label": "Retrait le plus rapide (mise en ligne → retrait)",932 "value": val + (f" · {fast['city']}" if fast["city"] else ""),933 "date": fast["dt"]})934 if drops: # balayage price_log fait plus haut (KPI baisses_prix)935 drop = drops[0]936 records.append({"label": "Plus forte baisse de prix demandé",937 "value": "−" + _fmt_money(drop["amt"]) +938 (f" · {drop['city']}" if drop["city"] else ""),939 "date": drop["dt"]})940 if new_city:941 c, n = max(new_city.items(), key=lambda kv: kv[1])942 records.append({"label": "Ville la plus active (nouvelles annonces)",943 "value": f"{c} — {n:,} annonces".replace(",", " ")})944 if top_srcs:945 src = max(top_srcs, key=lambda r: r["new_n"])946 if src["new_n"]:947 records.append({"label": "Source la plus active (nouvelles annonces)",948 "value": f"{names.get(src['s'], src['s'])}"949 f" — {src['new_n']:,}".replace(",", " ")})950 top_price = con.execute(951 "SELECT city, price FROM listings WHERE active=1" + VISIBLE +952 " AND price BETWEEN ? AND ? ORDER BY price DESC LIMIT 1",953 (PRICE_MIN, PRICE_MAX)).fetchone()954 if top_price:955 records.append({"label": "Inscription active la plus chère",956 "value": _fmt_money(top_price["price"]) +957 (f" · {top_price['city']}"958 if top_price["city"] else "")})959 med_cities = {c: statistics.median(pn) for c, ps in city_prices.items()960 if len(pn := [v for v in ps961 if isinstance(v, (int, float))]) >= 30}962 if med_cities:963 c_hi = max(med_cities, key=med_cities.get)964 c_lo = min(med_cities, key=med_cities.get)965 records.append({"label": "Ville la plus chère (prix médian, ≥ 30 annonces)",966 "value": f"{c_hi} — {_fmt_money(med_cities[c_hi])}"})967 records.append({"label": "Ville la plus abordable (prix médian, ≥ 30 annonces)",968 "value": f"{c_lo} — {_fmt_money(med_cities[c_lo])}"})969 big_area = con.execute(970 "SELECT city, area_sqft a FROM listings WHERE active=1" + VISIBLE +971 " AND area_sqft BETWEEN 100 AND 50000"972 " ORDER BY area_sqft DESC LIMIT 1").fetchone()973 if big_area:974 records.append({"label": "Plus grande superficie habitable (plausible)",975 "value": f"{round(big_area['a']):,} pi²".replace(",", " ") +976 (f" · {big_area['city']}"977 if big_area["city"] else "")})978 if fam_info:979 fam_big = max(fam_info.items(), key=lambda kv: len(kv[1]["srcs"]))980 if len(fam_big[1]["srcs"]) > 1:981 records.append({"label": "Bannière au plus grand réseau agrégé",982 "value": f"{fam_big[0]} — "983 f"{len(fam_big[1]['srcs'])} connecteurs"})984985 out = {986 "updated": datetime.now(TZ).isoformat(timespec="seconds"),987 "period": {"from": _iso(frm), "to": _iso(to), "label": label,988 "observed_from": _iso(data_start)},989 "kpis": kpis,990 "series": series,991 "breakdowns": breakdowns,992 "tables": tables,993 "records": records,994 }995 if gauges:996 out["gauges"] = gauges997 if multiseries:998 out["multiseries"] = multiseries999 if stacked:1000 out["stacked"] = stacked1001 if distributions:1002 out["distributions"] = distributions1003 if geo:1004 out["geo"] = geo1005 if heatmap:1006 out["heatmap"] = heatmap1007 if hourly:1008 out["hourly"] = hourly1009 try:1010 from . import statsextra, statsfiche1011 pnls = statsfiche.panels(con) + statsextra.panels(con)1012 if pnls:1013 out["panels"] = pnls1014 except Exception:1015 pass1016 return out101710181019def dashboard(period: str | None = None, frm: str | None = None,1020 to: str | None = None) -> dict:1021 key = f"{period or ''}|{frm or ''}|{to or ''}"1022 now = time.time()1023 with _CACHE_LOCK:1024 hit = _CACHE.get(key)1025 if hit and now - hit[0] < _CACHE_TTL:1026 return hit[1]1027 data = _compute(frm, to, period)1028 with _CACHE_LOCK:1029 _CACHE[key] = (time.time(), data)1030 # garder le cache borné1031 if len(_CACHE) > 64:1032 for k in sorted(_CACHE, key=lambda k: _CACHE[k][0])[:32]:1033 _CACHE.pop(k, None)1034 return data1035