"""Méthode de comparaison : grille d'ajustements, ajustements séquentiels, ventes appariées, réconciliation.""" from __future__ import annotations from dataclasses import dataclass from typing import Sequence, Mapping import math def prix_ajuste_additif(prix: float, ajustements: Mapping[str, float]) -> float: """P_aj = P + Σ_k A_k (ajustements monétaires, signe du point de vue du SUJET : + si le sujet est supérieur).""" return prix + sum(ajustements.values()) def prix_ajuste_multiplicatif(prix: float, ajustements_pct: Sequence[float]) -> float: """P_aj = P × Π_k (1 + a_k) — ajustements en proportion appliqués en cascade (ordre déclaré).""" out = prix for a in ajustements_pct: out *= (1.0 + a) return out def prix_ajuste_mixte(prix: float, ajustements_pct_sequentiels: Sequence[float], ajustements_monetaires: Mapping[str, float]) -> dict: """Pratique courante : d'abord les ajustements de transaction en pourcentage (droits, financement, conditions, dépenses post-vente, marché/temps) en cascade, puis les ajustements de propriété en dollars, additifs, sur la base ajustée.""" base = prix_ajuste_multiplicatif(prix, ajustements_pct_sequentiels) final = base + sum(ajustements_monetaires.values()) return {"base_transaction": base, "prix_ajuste": final} def ajustement_temps(prix: float, indice_vente: float, indice_date_evaluation: float) -> float: """Ajustement de marché par ratio d'indices : P × I_eval / I_vente − P.""" return prix * indice_date_evaluation / indice_vente - prix def ajustement_temps_taux(prix: float, taux_annuel: float, mois: float) -> float: """Ajustement par taux composé mensuel : P × ((1+g)^(mois/12) − 1).""" return prix * ((1.0 + taux_annuel) ** (mois / 12.0) - 1.0) def ventes_appariees(prix_avec: Sequence[float], prix_sans: Sequence[float]) -> dict: """Extraction d'un ajustement par paires : différence moyenne et médiane, écart-type, n.""" diffs = [a - s for a, s in zip(prix_avec, prix_sans)] n = len(diffs) m = sum(diffs) / n s = math.sqrt(sum((d - m) ** 2 for d in diffs) / (n - 1)) if n > 1 else float("nan") med = sorted(diffs)[n // 2] if n % 2 else 0.5 * (sorted(diffs)[n // 2 - 1] + sorted(diffs)[n // 2]) return {"n": n, "moyenne": m, "mediane": med, "ecart_type": s, "differences": diffs} def ajustement_brut_net(ajustements: Mapping[str, float], prix: float) -> dict: """Ajustement brut = Σ|A_k| / P ; net = Σ A_k / P (indicateurs de qualité du comparable).""" brut = sum(abs(v) for v in ajustements.values()) net = sum(ajustements.values()) return {"brut": brut, "net": net, "brut_pct": brut / prix, "net_pct": net / prix} @dataclass class ComparableAjuste: nom: str prix: float ajustements: dict @property def prix_ajuste(self) -> float: return prix_ajuste_additif(self.prix, self.ajustements) @property def brut_pct(self) -> float: return ajustement_brut_net(self.ajustements, self.prix)["brut_pct"] @property def net_pct(self) -> float: return ajustement_brut_net(self.ajustements, self.prix)["net_pct"] def grille(comparables: Sequence[ComparableAjuste]) -> list[dict]: 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] def poids_inverse_brut(comparables: Sequence[ComparableAjuste], plancher: float = 0.01) -> list[float]: """Pondération ∝ 1 / max(ajustement brut, plancher) — règle usuelle de réconciliation.""" w = [1.0 / max(c.brut_pct, plancher) for c in comparables] s = sum(w) return [x / s for x in w] def reconciliation(comparables: Sequence[ComparableAjuste], poids: Sequence[float] | None = None) -> dict: """Valeur indiquée = Σ w_j P_aj_j ; fournit aussi min, max, médiane, moyenne simple, écart-type pondéré.""" pa = [c.prix_ajuste for c in comparables] n = len(pa) w = list(poids) if poids is not None else [1.0 / n] * n if abs(sum(w) - 1) > 1e-9: raise ValueError("les poids doivent sommer à 1") vp = sum(wi * p for wi, p in zip(w, pa)) var = sum(wi * (p - vp) ** 2 for wi, p in zip(w, pa)) srt = sorted(pa) med = srt[n // 2] if n % 2 else 0.5 * (srt[n // 2 - 1] + srt[n // 2]) return {"valeur_ponderee": vp, "moyenne": sum(pa) / n, "mediane": med, "min": min(pa), "max": max(pa), "ecart_type_pondere": math.sqrt(var), "etendue_pct": (max(pa) - min(pa)) / vp, "poids": w} def distance_attributs(sujet: Mapping[str, float], comp: Mapping[str, float], echelles: Mapping[str, float], poids: Mapping[str, float] | None = None) -> float: """Distance pondérée normalisée pour la sélection de comparables : sqrt(Σ w_k ((x_k − s_k)/e_k)^2).""" poids = poids or {k: 1.0 for k in echelles} return math.sqrt(sum(poids[k] * ((comp[k] - sujet[k]) / echelles[k]) ** 2 for k in echelles)) def ajustement_depuis_coefficient_log(beta: float) -> float: """Effet en proportion d'une variable indicatrice dans un modèle semi-log (Halvorsen–Palmquist 1980) : exp(β) − 1.""" return math.exp(beta) - 1.0 def ajustement_depuis_coefficient_log_sans_biais(beta: float, var_beta: float) -> float: """Correction de Kennedy (1981) : exp(β − V(β)/2) − 1.""" return math.exp(beta - 0.5 * var_beta) - 1.0