# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # coutreel.py : « Coût réel mensuel Lou-Ka » — loyer + frais non inclus, # ligne par ligne, chaque poste étiqueté : # included : explicitement inclus dans le bail (details.inclusions) # observed : montant affiché par la source (ex. prix du stationnement) # estimated : estimation sourcée (Hydro-Québec via hydro.py, médiane Internet) # unknown : ni inclus ni estimable — dit explicitement, jamais inventé # # + prix au pi² avec percentile RÉELLEMENT calculé sur les comparables # (même ville + même nombre de chambres, et rayon ~2 km si géocodée). # ----------------------------------------------------------------------------- from __future__ import annotations import json from . import db # Internet résidentiel : médiane des forfaits de base au Québec (ordre de # grandeur public CRTC/fournisseurs). Estimation générique, PAS une donnée # de l'adresse — toujours présentée comme telle. INTERNET_ESTIME = 60.0 VERSION = "cout-reel-1.0" def _pctile(values: list[float], x: float) -> int: below = sum(1 for v in values if v < x) return round(100 * below / len(values)) def pi2(con, l: dict) -> dict | None: """Prix au pi² + percentile parmi les comparables (calcul réel).""" price, area = l.get("price"), l.get("area_sqft") if not price or not area or area < 100: return None val = price / area out: dict = {"valeur": round(val, 2), "statut": "calculated"} base = ("SELECT price, area_sqft, lat, lng FROM listings" " WHERE active=1 AND published=1 AND dup_of IS NULL" " AND price IS NOT NULL AND area_sqft > 100 AND uid<>?") args: list = [l["uid"]] if l.get("city"): base += " AND city=?"; args.append(l["city"]) if l.get("bedrooms") is not None: base += " AND bedrooms=?"; args.append(l["bedrooms"]) elif l.get("unit_type"): base += " AND unit_type=?"; args.append(l["unit_type"]) rows = con.execute(base, args).fetchall() ville = [r["price"] / r["area_sqft"] for r in rows] if len(ville) >= 12: out["percentile_ville"] = _pctile(ville, val) out["n_ville"] = len(ville) out["portee_ville"] = (f"{l.get('city')}, " + (f"{int(l['bedrooms'])} ch." if l.get("bedrooms") is not None else l.get("unit_type") or "tous types")) lat, lng = l.get("lat"), l.get("lng") if lat is not None and lng is not None: secteur = [r["price"] / r["area_sqft"] for r in rows if r["lat"] is not None and abs(r["lat"] - lat) < 0.018 and abs(r["lng"] - lng) < 0.025] if len(secteur) >= 12: out["percentile_secteur"] = _pctile(secteur, val) out["n_secteur"] = len(secteur) if "percentile_ville" not in out and "percentile_secteur" not in out: out["percentile_note"] = "comparables insuffisants pour un percentile" return out def compute(uid: str, con=None) -> dict | None: """Coût réel mensuel d'une annonce — module « Coût réel Lou-Ka ».""" own = con is None if own: con = db.connect() try: row = con.execute( "SELECT uid, price, area_sqft, city, bedrooms, unit_type, lat, lng," " details, address FROM listings WHERE uid=?", (uid,)).fetchone() if row is None: return None l = dict(row) try: details = json.loads(l.get("details") or "{}") or {} except (ValueError, TypeError): details = {} inc = details.get("inclusions") or {} price = l.get("price") lignes: list[dict] = [] lignes.append({"poste": "Loyer affiché", "statut": "observed", "montant": price, "source": "annonce"}) # --- électricité + chauffage (souvent le même compteur au Québec) ---- hydro_est = None if not inc.get("electricity"): from . import hydro h = hydro.estimate(uid=uid, adresse=l.get("address"), lat=l.get("lat"), lng=l.get("lng"), solve=False) if h.get("disponible") and h.get("cout_mensuel"): hydro_est = round(float(h["cout_mensuel"])) if inc.get("electricity"): lignes.append({"poste": "Électricité", "statut": "included", "montant": 0, "source": "annonce (incluse)"}) elif inc.get("electricity") is False: lignes.append({"poste": "Électricité", "statut": "estimated" if hydro_est else "unknown", "montant": hydro_est, "source": ("outil public d'estimation Hydro-Québec" if hydro_est else None), "note": None if hydro_est else "explicitement à la charge du locataire, " "montant non estimable pour cette adresse"}) else: lignes.append({"poste": "Électricité", "statut": "estimated" if hydro_est else "unknown", "montant": hydro_est, "source": ("outil public d'estimation Hydro-Québec" if hydro_est else None), "note": None if hydro_est else "l'annonce ne précise pas si elle est incluse"}) if inc.get("heating"): lignes.append({"poste": "Chauffage", "statut": "included", "montant": 0, "source": "annonce (inclus)"}) elif hydro_est: lignes.append({"poste": "Chauffage", "statut": "estimated", "montant": None, "note": "chauffage électrique répandu au Québec — " "déjà compté dans l'estimation d'électricité"}) else: lignes.append({"poste": "Chauffage", "statut": "unknown", "montant": None, "note": "ni inclus ni estimable avec les données " "disponibles"}) if inc.get("hot_water"): lignes.append({"poste": "Eau chaude", "statut": "included", "montant": 0, "source": "annonce (incluse)"}) else: lignes.append({"poste": "Eau chaude", "statut": "unknown", "montant": None, "note": "l'annonce ne précise pas si elle est incluse"}) if inc.get("internet"): lignes.append({"poste": "Internet", "statut": "included", "montant": 0, "source": "annonce (inclus)"}) else: lignes.append({"poste": "Internet", "statut": "estimated", "montant": INTERNET_ESTIME, "source": "médiane des forfaits résidentiels de base " "au Québec (estimation générique)"}) parking = details.get("parking") or {} if parking.get("available"): if parking.get("included"): lignes.append({"poste": "Stationnement", "statut": "included", "montant": 0, "source": "annonce (inclus)"}) elif parking.get("price"): lignes.append({"poste": "Stationnement", "statut": "observed", "montant": float(parking["price"]), "source": "annonce (prix affiché)"}) else: lignes.append({"poste": "Stationnement", "statut": "unknown", "montant": None, "note": "disponible, prix non précisé par la source"}) # --- totaux : jamais de montant inventé ------------------------------ total = None if price: total = float(price) + sum( (li["montant"] or 0) for li in lignes[1:] if li["statut"] in ("observed", "estimated") and li["montant"] is not None) inconnues = [li["poste"] for li in lignes if li["statut"] == "unknown"] out = { "uid": uid, "version": VERSION, "loyer": price, "lignes": lignes, "total_estime": round(total) if total else None, "annuel_estime": round(total * 12) if total else None, "postes_inconnus": inconnues, "pi2": pi2(con, l), "methode": ("loyer affiché + frais non inclus connus (observés) ou " "estimés avec source ; les postes inconnus sont listés " "tels quels, jamais chiffrés arbitrairement"), } if total and l.get("area_sqft") and l["area_sqft"] > 100: out["total_pi2"] = round(total / l["area_sqft"], 2) return out finally: if own: con.close()