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%
3.5 KB · 80 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# mortgage/validate.py : couche de validation des observations de taux.5#   Règle absolue : ne jamais inventer ni laisser passer un taux impossible.6#   4.19 % -> 419 % doit être rejeté ; une bonne donnée n'est jamais écrasée7#   par une donnée manifestement erronée (garde-fou complémentaire dans8#   store.record_observations).9# -----------------------------------------------------------------------------10from __future__ import annotations1112RATE_MIN = 0.25      # sous 0,25 % : impossible pour une hypothèque canadienne13RATE_MAX = 20.0      # au-delà de 20 % : aberrant (même les prêts privés)14TERM_MIN_MONTHS = 315TERM_MAX_MONTHS = 1201617VALID_RATE_TYPES = {"fixed", "variable", "adjustable", "other"}18VALID_KINDS = {"posted", "special"}19VALID_INSURED = {"insured", "insurable", "uninsured", "unknown"}20VALID_PURPOSES = {"purchase", "renewal", "refinance", "unknown"}2122REQUIRED_FIELDS = ("provider", "institution", "rate_type", "term_months",23                   "kind", "rate", "source_url")242526def validate_product(p: dict) -> tuple[bool, list[str]]:27    """Valide UNE observation normalisée. Retourne (ok, problèmes)."""28    issues: list[str] = []29    for f in REQUIRED_FIELDS:30        if p.get(f) in (None, ""):31            issues.append(f"champ manquant: {f}")32    if issues:33        return False, issues34    rate = p["rate"]35    if not isinstance(rate, (int, float)):36        issues.append(f"taux non numérique: {rate!r}")37    elif rate <= 0:38        issues.append(f"taux nul ou négatif: {rate}")39    elif rate < RATE_MIN or rate > RATE_MAX:40        issues.append(f"taux impossible: {rate}")41    term = p["term_months"]42    if not isinstance(term, int) or not (TERM_MIN_MONTHS <= term <= TERM_MAX_MONTHS):43        issues.append(f"terme invalide: {term!r}")44    if p["rate_type"] not in VALID_RATE_TYPES:45        issues.append(f"rate_type invalide: {p['rate_type']}")46    if p["kind"] not in VALID_KINDS:47        issues.append(f"kind invalide: {p['kind']}")48    if p.get("insured_status", "unknown") not in VALID_INSURED:49        issues.append(f"insured_status invalide: {p.get('insured_status')}")50    if p.get("purpose", "purchase") not in VALID_PURPOSES:51        issues.append(f"purpose invalide: {p.get('purpose')}")52    apr = p.get("apr")53    if apr is not None:54        if not isinstance(apr, (int, float)) or apr < RATE_MIN or apr > RATE_MAX:55            issues.append(f"APR impossible: {apr!r}")56        elif isinstance(rate, (int, float)) and apr < rate - 0.02:57            issues.append(f"APR ({apr}) inférieur au taux contractuel ({rate})")58    return not issues, issues596061def validate_batch(products: list[dict]) -> tuple[list[dict], list[str]]:62    """Valide une passe complète : élimine invalides et duplicatas exacts.63    Retourne (produits valides, problèmes)."""64    from .store import product_key65    ok_products: list[dict] = []66    problems: list[str] = []67    seen: set[tuple[str, float]] = set()68    for p in products:69        valid, issues = validate_product(p)70        if not valid:71            name = p.get("product_name") or "?"72            problems.extend(f"{name}: {i}" for i in issues)73            continue74        key = (product_key(p), round(float(p["rate"]), 4))75        if key in seen:76            continue  # duplicata exact silencieusement ignoré77        seen.add(key)78        ok_products.append(p)79    return ok_products, problems80