# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/validate.py : couche de validation des observations de taux. # Règle absolue : ne jamais inventer ni laisser passer un taux impossible. # 4.19 % -> 419 % doit être rejeté ; une bonne donnée n'est jamais écrasée # par une donnée manifestement erronée (garde-fou complémentaire dans # store.record_observations). # ----------------------------------------------------------------------------- from __future__ import annotations RATE_MIN = 0.25 # sous 0,25 % : impossible pour une hypothèque canadienne RATE_MAX = 20.0 # au-delà de 20 % : aberrant (même les prêts privés) TERM_MIN_MONTHS = 3 TERM_MAX_MONTHS = 120 VALID_RATE_TYPES = {"fixed", "variable", "adjustable", "other"} VALID_KINDS = {"posted", "special"} VALID_INSURED = {"insured", "insurable", "uninsured", "unknown"} VALID_PURPOSES = {"purchase", "renewal", "refinance", "unknown"} REQUIRED_FIELDS = ("provider", "institution", "rate_type", "term_months", "kind", "rate", "source_url") def validate_product(p: dict) -> tuple[bool, list[str]]: """Valide UNE observation normalisée. Retourne (ok, problèmes).""" issues: list[str] = [] for f in REQUIRED_FIELDS: if p.get(f) in (None, ""): issues.append(f"champ manquant: {f}") if issues: return False, issues rate = p["rate"] if not isinstance(rate, (int, float)): issues.append(f"taux non numérique: {rate!r}") elif rate <= 0: issues.append(f"taux nul ou négatif: {rate}") elif rate < RATE_MIN or rate > RATE_MAX: issues.append(f"taux impossible: {rate}") term = p["term_months"] if not isinstance(term, int) or not (TERM_MIN_MONTHS <= term <= TERM_MAX_MONTHS): issues.append(f"terme invalide: {term!r}") if p["rate_type"] not in VALID_RATE_TYPES: issues.append(f"rate_type invalide: {p['rate_type']}") if p["kind"] not in VALID_KINDS: issues.append(f"kind invalide: {p['kind']}") if p.get("insured_status", "unknown") not in VALID_INSURED: issues.append(f"insured_status invalide: {p.get('insured_status')}") if p.get("purpose", "purchase") not in VALID_PURPOSES: issues.append(f"purpose invalide: {p.get('purpose')}") apr = p.get("apr") if apr is not None: if not isinstance(apr, (int, float)) or apr < RATE_MIN or apr > RATE_MAX: issues.append(f"APR impossible: {apr!r}") elif isinstance(rate, (int, float)) and apr < rate - 0.02: issues.append(f"APR ({apr}) inférieur au taux contractuel ({rate})") return not issues, issues def validate_batch(products: list[dict]) -> tuple[list[dict], list[str]]: """Valide une passe complète : élimine invalides et duplicatas exacts. Retourne (produits valides, problèmes).""" from .store import product_key ok_products: list[dict] = [] problems: list[str] = [] seen: set[tuple[str, float]] = set() for p in products: valid, issues = validate_product(p) if not valid: name = p.get("product_name") or "?" problems.extend(f"{name}: {i}" for i in issues) continue key = (product_key(p), round(float(p["rate"]), 4)) if key in seen: continue # duplicata exact silencieusement ignoré seen.add(key) ok_products.append(p) return ok_products, problems