SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
9.1 KB · 197 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# coutreel.py : « Coût réel mensuel Lou-Ka » — loyer + frais non inclus,5#               ligne par ligne, chaque poste étiqueté :6#   included  : explicitement inclus dans le bail (details.inclusions)7#   observed  : montant affiché par la source (ex. prix du stationnement)8#   estimated : estimation sourcée (Hydro-Québec via hydro.py, médiane Internet)9#   unknown   : ni inclus ni estimable — dit explicitement, jamais inventé10#11# + prix au pi² avec percentile RÉELLEMENT calculé sur les comparables12#   (même ville + même nombre de chambres, et rayon ~2 km si géocodée).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json1718from . import db1920# Internet résidentiel : médiane des forfaits de base au Québec (ordre de21# grandeur public CRTC/fournisseurs). Estimation générique, PAS une donnée22# de l'adresse — toujours présentée comme telle.23INTERNET_ESTIME = 60.02425VERSION = "cout-reel-1.0"262728def _pctile(values: list[float], x: float) -> int:29    below = sum(1 for v in values if v < x)30    return round(100 * below / len(values))313233def pi2(con, l: dict) -> dict | None:34    """Prix au pi² + percentile parmi les comparables (calcul réel)."""35    price, area = l.get("price"), l.get("area_sqft")36    if not price or not area or area < 100:37        return None38    val = price / area39    out: dict = {"valeur": round(val, 2), "statut": "calculated"}4041    base = ("SELECT price, area_sqft, lat, lng FROM listings"42            " WHERE active=1 AND published=1 AND dup_of IS NULL"43            " AND price IS NOT NULL AND area_sqft > 100 AND uid<>?")44    args: list = [l["uid"]]45    if l.get("city"):46        base += " AND city=?"; args.append(l["city"])47    if l.get("bedrooms") is not None:48        base += " AND bedrooms=?"; args.append(l["bedrooms"])49    elif l.get("unit_type"):50        base += " AND unit_type=?"; args.append(l["unit_type"])51    rows = con.execute(base, args).fetchall()52    ville = [r["price"] / r["area_sqft"] for r in rows]53    if len(ville) >= 12:54        out["percentile_ville"] = _pctile(ville, val)55        out["n_ville"] = len(ville)56        out["portee_ville"] = (f"{l.get('city')}, "57                               + (f"{int(l['bedrooms'])} ch."58                                  if l.get("bedrooms") is not None59                                  else l.get("unit_type") or "tous types"))60    lat, lng = l.get("lat"), l.get("lng")61    if lat is not None and lng is not None:62        secteur = [r["price"] / r["area_sqft"] for r in rows63                   if r["lat"] is not None64                   and abs(r["lat"] - lat) < 0.018 and abs(r["lng"] - lng) < 0.025]65        if len(secteur) >= 12:66            out["percentile_secteur"] = _pctile(secteur, val)67            out["n_secteur"] = len(secteur)68    if "percentile_ville" not in out and "percentile_secteur" not in out:69        out["percentile_note"] = "comparables insuffisants pour un percentile"70    return out717273def compute(uid: str, con=None) -> dict | None:74    """Coût réel mensuel d'une annonce — module « Coût réel Lou-Ka »."""75    own = con is None76    if own:77        con = db.connect()78    try:79        row = con.execute(80            "SELECT uid, price, area_sqft, city, bedrooms, unit_type, lat, lng,"81            " details, address FROM listings WHERE uid=?", (uid,)).fetchone()82        if row is None:83            return None84        l = dict(row)85        try:86            details = json.loads(l.get("details") or "{}") or {}87        except (ValueError, TypeError):88            details = {}89        inc = details.get("inclusions") or {}90        price = l.get("price")9192        lignes: list[dict] = []93        lignes.append({"poste": "Loyer affiché", "statut": "observed",94                       "montant": price, "source": "annonce"})9596        # --- électricité + chauffage (souvent le même compteur au Québec) ----97        hydro_est = None98        if not inc.get("electricity"):99            from . import hydro100            h = hydro.estimate(uid=uid, adresse=l.get("address"),101                               lat=l.get("lat"), lng=l.get("lng"), solve=False)102            if h.get("disponible") and h.get("cout_mensuel"):103                hydro_est = round(float(h["cout_mensuel"]))104        if inc.get("electricity"):105            lignes.append({"poste": "Électricité", "statut": "included",106                           "montant": 0, "source": "annonce (incluse)"})107        elif inc.get("electricity") is False:108            lignes.append({"poste": "Électricité",109                           "statut": "estimated" if hydro_est else "unknown",110                           "montant": hydro_est,111                           "source": ("outil public d'estimation Hydro-Québec"112                                      if hydro_est else None),113                           "note": None if hydro_est else114                                   "explicitement à la charge du locataire, "115                                   "montant non estimable pour cette adresse"})116        else:117            lignes.append({"poste": "Électricité",118                           "statut": "estimated" if hydro_est else "unknown",119                           "montant": hydro_est,120                           "source": ("outil public d'estimation Hydro-Québec"121                                      if hydro_est else None),122                           "note": None if hydro_est else123                                   "l'annonce ne précise pas si elle est incluse"})124125        if inc.get("heating"):126            lignes.append({"poste": "Chauffage", "statut": "included",127                           "montant": 0, "source": "annonce (inclus)"})128        elif hydro_est:129            lignes.append({"poste": "Chauffage", "statut": "estimated",130                           "montant": None,131                           "note": "chauffage électrique répandu au Québec — "132                                   "déjà compté dans l'estimation d'électricité"})133        else:134            lignes.append({"poste": "Chauffage", "statut": "unknown",135                           "montant": None,136                           "note": "ni inclus ni estimable avec les données "137                                   "disponibles"})138139        if inc.get("hot_water"):140            lignes.append({"poste": "Eau chaude", "statut": "included",141                           "montant": 0, "source": "annonce (incluse)"})142        else:143            lignes.append({"poste": "Eau chaude", "statut": "unknown",144                           "montant": None,145                           "note": "l'annonce ne précise pas si elle est incluse"})146147        if inc.get("internet"):148            lignes.append({"poste": "Internet", "statut": "included",149                           "montant": 0, "source": "annonce (inclus)"})150        else:151            lignes.append({"poste": "Internet", "statut": "estimated",152                           "montant": INTERNET_ESTIME,153                           "source": "médiane des forfaits résidentiels de base "154                                     "au Québec (estimation générique)"})155156        parking = details.get("parking") or {}157        if parking.get("available"):158            if parking.get("included"):159                lignes.append({"poste": "Stationnement", "statut": "included",160                               "montant": 0, "source": "annonce (inclus)"})161            elif parking.get("price"):162                lignes.append({"poste": "Stationnement", "statut": "observed",163                               "montant": float(parking["price"]),164                               "source": "annonce (prix affiché)"})165            else:166                lignes.append({"poste": "Stationnement", "statut": "unknown",167                               "montant": None,168                               "note": "disponible, prix non précisé par la source"})169170        # --- totaux : jamais de montant inventé ------------------------------171        total = None172        if price:173            total = float(price) + sum(174                (li["montant"] or 0) for li in lignes[1:]175                if li["statut"] in ("observed", "estimated")176                and li["montant"] is not None)177        inconnues = [li["poste"] for li in lignes if li["statut"] == "unknown"]178179        out = {180            "uid": uid, "version": VERSION,181            "loyer": price,182            "lignes": lignes,183            "total_estime": round(total) if total else None,184            "annuel_estime": round(total * 12) if total else None,185            "postes_inconnus": inconnues,186            "pi2": pi2(con, l),187            "methode": ("loyer affiché + frais non inclus connus (observés) ou "188                        "estimés avec source ; les postes inconnus sont listés "189                        "tels quels, jamais chiffrés arbitrairement"),190        }191        if total and l.get("area_sqft") and l["area_sqft"] > 100:192            out["total_pi2"] = round(total / l["area_sqft"], 2)193        return out194    finally:195        if own:196            con.close()197