Model/domain/modelling/valuation.py
Khalim Conn-Kowlessar e6f54df92b feat(modelling): ValuationUplift domain class (percentage-primary)
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 <noreply@anthropic.com>
2026-06-04 08:33:19 +00:00

151 lines
5.6 KiB
Python

"""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,
)