spb/traite-evaluation-qc
Public
1"""Méthode de comparaison : grille d'ajustements, ajustements séquentiels, ventes appariées, réconciliation."""2from __future__ import annotations3from dataclasses import dataclass4from typing import Sequence, Mapping5import math67def prix_ajuste_additif(prix: float, ajustements: Mapping[str, float]) -> float:8 """P_aj = P + Σ_k A_k (ajustements monétaires, signe du point de vue du SUJET : + si le sujet est supérieur)."""9 return prix + sum(ajustements.values())1011def prix_ajuste_multiplicatif(prix: float, ajustements_pct: Sequence[float]) -> float:12 """P_aj = P × Π_k (1 + a_k) — ajustements en proportion appliqués en cascade (ordre déclaré)."""13 out = prix14 for a in ajustements_pct:15 out *= (1.0 + a)16 return out1718def prix_ajuste_mixte(prix: float, ajustements_pct_sequentiels: Sequence[float], ajustements_monetaires: Mapping[str, float]) -> dict:19 """Pratique courante : d'abord les ajustements de transaction en pourcentage (droits, financement, conditions, dépenses20 post-vente, marché/temps) en cascade, puis les ajustements de propriété en dollars, additifs, sur la base ajustée."""21 base = prix_ajuste_multiplicatif(prix, ajustements_pct_sequentiels)22 final = base + sum(ajustements_monetaires.values())23 return {"base_transaction": base, "prix_ajuste": final}2425def ajustement_temps(prix: float, indice_vente: float, indice_date_evaluation: float) -> float:26 """Ajustement de marché par ratio d'indices : P × I_eval / I_vente − P."""27 return prix * indice_date_evaluation / indice_vente - prix2829def ajustement_temps_taux(prix: float, taux_annuel: float, mois: float) -> float:30 """Ajustement par taux composé mensuel : P × ((1+g)^(mois/12) − 1)."""31 return prix * ((1.0 + taux_annuel) ** (mois / 12.0) - 1.0)3233def ventes_appariees(prix_avec: Sequence[float], prix_sans: Sequence[float]) -> dict:34 """Extraction d'un ajustement par paires : différence moyenne et médiane, écart-type, n."""35 diffs = [a - s for a, s in zip(prix_avec, prix_sans)]36 n = len(diffs)37 m = sum(diffs) / n38 s = math.sqrt(sum((d - m) ** 2 for d in diffs) / (n - 1)) if n > 1 else float("nan")39 med = sorted(diffs)[n // 2] if n % 2 else 0.5 * (sorted(diffs)[n // 2 - 1] + sorted(diffs)[n // 2])40 return {"n": n, "moyenne": m, "mediane": med, "ecart_type": s, "differences": diffs}4142def ajustement_brut_net(ajustements: Mapping[str, float], prix: float) -> dict:43 """Ajustement brut = Σ|A_k| / P ; net = Σ A_k / P (indicateurs de qualité du comparable)."""44 brut = sum(abs(v) for v in ajustements.values())45 net = sum(ajustements.values())46 return {"brut": brut, "net": net, "brut_pct": brut / prix, "net_pct": net / prix}4748@dataclass49class ComparableAjuste:50 nom: str51 prix: float52 ajustements: dict53 @property54 def prix_ajuste(self) -> float: return prix_ajuste_additif(self.prix, self.ajustements)55 @property56 def brut_pct(self) -> float: return ajustement_brut_net(self.ajustements, self.prix)["brut_pct"]57 @property58 def net_pct(self) -> float: return ajustement_brut_net(self.ajustements, self.prix)["net_pct"]5960def grille(comparables: Sequence[ComparableAjuste]) -> list[dict]:61 return [{"nom": c.nom, "prix": c.prix, **c.ajustements, "prix_ajuste": c.prix_ajuste, "brut_pct": c.brut_pct, "net_pct": c.net_pct} for c in comparables]6263def poids_inverse_brut(comparables: Sequence[ComparableAjuste], plancher: float = 0.01) -> list[float]:64 """Pondération ∝ 1 / max(ajustement brut, plancher) — règle usuelle de réconciliation."""65 w = [1.0 / max(c.brut_pct, plancher) for c in comparables]66 s = sum(w)67 return [x / s for x in w]6869def reconciliation(comparables: Sequence[ComparableAjuste], poids: Sequence[float] | None = None) -> dict:70 """Valeur indiquée = Σ w_j P_aj_j ; fournit aussi min, max, médiane, moyenne simple, écart-type pondéré."""71 pa = [c.prix_ajuste for c in comparables]72 n = len(pa)73 w = list(poids) if poids is not None else [1.0 / n] * n74 if abs(sum(w) - 1) > 1e-9:75 raise ValueError("les poids doivent sommer à 1")76 vp = sum(wi * p for wi, p in zip(w, pa))77 var = sum(wi * (p - vp) ** 2 for wi, p in zip(w, pa))78 srt = sorted(pa)79 med = srt[n // 2] if n % 2 else 0.5 * (srt[n // 2 - 1] + srt[n // 2])80 return {"valeur_ponderee": vp, "moyenne": sum(pa) / n, "mediane": med, "min": min(pa), "max": max(pa),81 "ecart_type_pondere": math.sqrt(var), "etendue_pct": (max(pa) - min(pa)) / vp, "poids": w}8283def distance_attributs(sujet: Mapping[str, float], comp: Mapping[str, float], echelles: Mapping[str, float], poids: Mapping[str, float] | None = None) -> float:84 """Distance pondérée normalisée pour la sélection de comparables : sqrt(Σ w_k ((x_k − s_k)/e_k)^2)."""85 poids = poids or {k: 1.0 for k in echelles}86 return math.sqrt(sum(poids[k] * ((comp[k] - sujet[k]) / echelles[k]) ** 2 for k in echelles))8788def ajustement_depuis_coefficient_log(beta: float) -> float:89 """Effet en proportion d'une variable indicatrice dans un modèle semi-log (Halvorsen–Palmquist 1980) : exp(β) − 1."""90 return math.exp(beta) - 1.09192def ajustement_depuis_coefficient_log_sans_biais(beta: float, var_beta: float) -> float:93 """Correction de Kennedy (1981) : exp(β − V(β)/2) − 1."""94 return math.exp(beta - 0.5 * var_beta) - 1.095