From e6f54df92bf47733c2a22612a08c76b385143f45 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 08:33:19 +0000 Subject: [PATCH] feat(modelling): ValuationUplift domain class (percentage-primary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The financial-uplift model per ADR-0018. `estimate_valuation_uplift( current_band, target_band, current_value=None, total_cost=None)` returns a `ValuationUplift`: band-transition uplift compounded from four broker tables (MoneySupermarket / Lloyds per-step, Knight Frank / Rightmove whole-jump), taking min/max/mean across the covering sources. Always a percentage; absolute £ forms (increase at each bound + post-retrofit value) only when a current market value is supplied; the 2x ROI cap rescales the percentages and can only bite once a value is known. A non-improving jump is a clean 0% no-op. Pure function, no external dependency. Persisting it (where the value lands) and sourcing the current market value stay deferred (ADR-0018). Co-Authored-By: Claude Opus 4.8 --- domain/modelling/valuation.py | 151 +++++++++++++++++++++++ tests/domain/modelling/test_valuation.py | 84 +++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 domain/modelling/valuation.py create mode 100644 tests/domain/modelling/test_valuation.py diff --git a/domain/modelling/valuation.py b/domain/modelling/valuation.py new file mode 100644 index 00000000..cc574acd --- /dev/null +++ b/domain/modelling/valuation.py @@ -0,0 +1,151 @@ +"""Valuation Uplift — the estimated market-value increase a retrofit produces. + +Percentage-primary (ADR-0018): the uplift is computed purely from the EPC Band +jump (current -> target) and is always returned as a percentage; the absolute £ +forms appear only when a Property Valuation (current market value) is supplied, +and are capped so the £ uplift never exceeds twice the retrofit cost. + +The band-transition percentages are ported verbatim from the legacy +`backend/ml_models/Valuation.py` — four published broker sources, a provenance +snapshot rather than a live feed. MoneySupermarket / Lloyds give per-band-step +figures we compound across the jump; Knight Frank / Rightmove give whole-jump +spot figures. The uplift takes the min / max / mean across the sources that +cover the jump. See CONTEXT.md (Property Valuation, Valuation Uplift). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import prod +from typing import Optional + +# Ascending energy efficiency, worst -> best (RdSAP band letters). +_EPC_BANDS: tuple[str, ...] = ("G", "F", "E", "D", "C", "B", "A") + +# Per-band-step uplift %, compounded across the jump. +_MSM_STEP: dict[tuple[str, str], float] = { + ("G", "F"): 0.06, + ("F", "E"): 0.01, + ("E", "D"): 0.01, + ("D", "C"): 0.02, + ("C", "B"): 0.04, + ("B", "A"): 0.0, +} +_LLOYDS_STEP: dict[tuple[str, str], float] = { + ("G", "F"): 0.038, + ("F", "E"): 0.029, + ("E", "D"): 0.024, + ("D", "C"): 0.02, + ("C", "B"): 0.02, + ("B", "A"): 0.018, +} + +# Whole-jump spot uplift %, looked up by (current, target); absent jumps don't +# contribute a source. +_KNIGHT_FRANK_JUMP: dict[tuple[str, str], float] = { + ("D", "C"): 0.03, + ("D", "B"): 0.088, + ("D", "A"): 0.088, +} +_RIGHTMOVE_JUMP: dict[tuple[str, str], float] = { + ("G", "C"): 0.15, + ("G", "B"): 0.15, + ("G", "A"): 0.15, + ("F", "C"): 0.15, + ("F", "B"): 0.15, + ("F", "A"): 0.15, + ("E", "C"): 0.07, + ("E", "B"): 0.07, + ("E", "A"): 0.07, + ("D", "C"): 0.03, + ("D", "B"): 0.03, + ("D", "A"): 0.03, +} + +_ROI_CAP = 2.0 # the £ uplift is capped at this multiple of the retrofit cost + + +@dataclass(frozen=True) +class ValuationUplift: + """A retrofit's estimated market-value uplift. The percentages are always + present (from the Band jump); the £ forms are populated only when a current + market value was supplied. `lower_value` / `upper_value` / `average_value` + are the £ *increase* at the min / max / mean source; `post_retrofit_value` + is the resulting market value (current + average increase).""" + + lower_pct: float + upper_pct: float + average_pct: float + lower_value: Optional[float] = None + upper_value: Optional[float] = None + average_value: Optional[float] = None + post_retrofit_value: Optional[float] = None + + +def _require_band(band: str) -> int: + if band not in _EPC_BANDS: + raise ValueError(f"unknown EPC band {band!r}") + return _EPC_BANDS.index(band) + + +def _band_uplift_percentages(current_band: str, target_band: str) -> tuple[float, float, float]: + """The (min, max, mean) uplift percentages across the sources covering the + jump. A non-improving jump (target no better than current) compounds over no + steps and matches no spot source, so MoneySupermarket / Lloyds both yield + 0 and the result is a no-op 0%.""" + current_index = _require_band(current_band) + target_index = _require_band(target_band) + steps = [ + (_EPC_BANDS[i], _EPC_BANDS[i + 1]) for i in range(current_index, target_index) + ] + msm: float = prod(1 + _MSM_STEP[step] for step in steps) - 1 + lloyds: float = prod(1 + _LLOYDS_STEP[step] for step in steps) - 1 + increases: list[float] = [msm, lloyds] + knight_frank: Optional[float] = _KNIGHT_FRANK_JUMP.get((current_band, target_band)) + rightmove: Optional[float] = _RIGHTMOVE_JUMP.get((current_band, target_band)) + if knight_frank is not None: + increases.append(knight_frank) + if rightmove is not None: + increases.append(rightmove) + return min(increases), max(increases), sum(increases) / len(increases) + + +def estimate_valuation_uplift( + current_band: str, + target_band: str, + current_value: Optional[float] = None, + total_cost: Optional[float] = None, +) -> ValuationUplift: + """Estimate the Valuation Uplift of moving a Property from `current_band` to + `target_band`. Returns percentages always; absolute £ forms only when + `current_value` is given. When both `current_value` and `total_cost` are + given, the percentages are rescaled so the average £ uplift does not exceed + `_ROI_CAP` times the cost (the cap can only bite once a value is known).""" + lower_pct, upper_pct, average_pct = _band_uplift_percentages( + current_band, target_band + ) + + if current_value is not None and total_cost is not None and total_cost > 0: + average_value = current_value * average_pct + if average_value > _ROI_CAP * total_cost: + capped_average_pct = _ROI_CAP * total_cost / current_value + scalar = capped_average_pct / average_pct + lower_pct *= scalar + upper_pct *= scalar + average_pct = capped_average_pct + + if current_value is None: + return ValuationUplift( + lower_pct=lower_pct, upper_pct=upper_pct, average_pct=average_pct + ) + + average_increase: float = current_value * average_pct + return ValuationUplift( + lower_pct=lower_pct, + upper_pct=upper_pct, + average_pct=average_pct, + lower_value=current_value * lower_pct, + upper_value=current_value * upper_pct, + average_value=average_increase, + post_retrofit_value=current_value + average_increase, + ) diff --git a/tests/domain/modelling/test_valuation.py b/tests/domain/modelling/test_valuation.py new file mode 100644 index 00000000..b471efa3 --- /dev/null +++ b/tests/domain/modelling/test_valuation.py @@ -0,0 +1,84 @@ +"""Valuation Uplift — the percentage-primary financial-uplift model (ADR-0018). + +Band-transition uplift compounded from four broker tables (MoneySupermarket, +Lloyds, Knight Frank, Rightmove); always a percentage, an absolute £ only when a +Property Valuation is supplied, capped so the £ uplift never exceeds 2x cost. +""" + +from __future__ import annotations + +from domain.modelling.valuation import ValuationUplift, estimate_valuation_uplift + + +def test_band_jump_yields_percentage_uplift_without_a_current_value() -> None: + # Arrange / Act — D -> C, no current market value supplied. + uplift: ValuationUplift = estimate_valuation_uplift("D", "C") + + # Assert — D->C sources: MSM 2%, Lloyds 2%, Knight Frank 3%, Rightmove 3% + # → min 2%, max 3%, mean 2.5%. The £ forms stay None with no Property Valuation. + assert abs(uplift.lower_pct - 0.02) <= 1e-9 + assert abs(uplift.upper_pct - 0.03) <= 1e-9 + assert abs(uplift.average_pct - 0.025) <= 1e-9 + assert uplift.lower_value is None + assert uplift.upper_value is None + assert uplift.average_value is None + assert uplift.post_retrofit_value is None + + +def test_a_current_value_yields_absolute_pound_uplift() -> None: + # Arrange / Act — D -> C with a £200k market value, no cost cap. + uplift: ValuationUplift = estimate_valuation_uplift( + "D", "C", current_value=200_000.0 + ) + + # Assert — £ increase at each source % plus the resulting post-retrofit value. + assert uplift.lower_value is not None and abs(uplift.lower_value - 4_000.0) <= 1e-6 + assert uplift.upper_value is not None and abs(uplift.upper_value - 6_000.0) <= 1e-6 + assert ( + uplift.average_value is not None + and abs(uplift.average_value - 5_000.0) <= 1e-6 + ) + assert ( + uplift.post_retrofit_value is not None + and abs(uplift.post_retrofit_value - 205_000.0) <= 1e-6 + ) + + +def test_roi_cap_rescales_the_uplift_to_twice_the_cost() -> None: + # Arrange / Act — a £1m property whose raw 2.5% average (£25k) exceeds twice + # a £10k retrofit, so the uplift is capped at 2x cost. + uplift: ValuationUplift = estimate_valuation_uplift( + "D", "C", current_value=1_000_000.0, total_cost=10_000.0 + ) + + # Assert — average £ uplift binds to 2x cost, and the bounds rescale by the + # same 0.8 scalar (0.025 -> 0.02). + assert ( + uplift.average_value is not None + and abs(uplift.average_value - 20_000.0) <= 1e-6 + ) + assert abs(uplift.average_pct - 0.02) <= 1e-9 + assert abs(uplift.lower_pct - 0.016) <= 1e-9 + assert abs(uplift.upper_pct - 0.024) <= 1e-9 + + +def test_multi_band_jump_compounds_steps_and_takes_a_spot_source() -> None: + # Arrange / Act — F -> C: MSM/Lloyds compound three steps; Rightmove gives a + # 15% whole-jump spot; Knight Frank has no F->C entry. + uplift: ValuationUplift = estimate_valuation_uplift("F", "C") + + # Assert — min = compounded MSM (~4.05%), max = Rightmove 15%, mean of the + # three covering sources. + assert abs(uplift.lower_pct - 0.040502) <= 1e-6 + assert abs(uplift.upper_pct - 0.15) <= 1e-9 + assert abs(uplift.average_pct - (0.040502 + 0.07476992 + 0.15) / 3) <= 1e-6 + + +def test_no_improvement_yields_zero_uplift() -> None: + # Arrange / Act — same band in and out. + uplift: ValuationUplift = estimate_valuation_uplift("C", "C") + + # Assert — every source compounds over no steps / matches no spot. + assert abs(uplift.lower_pct) <= 1e-12 + assert abs(uplift.upper_pct) <= 1e-12 + assert abs(uplift.average_pct) <= 1e-12