Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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