SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
47.2 KB · 1,035 lines python
Raw Blame History
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