mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
This commit is contained in:
parent
31da90f5eb
commit
e6f54df92b
2 changed files with 235 additions and 0 deletions
151
domain/modelling/valuation.py
Normal file
151
domain/modelling/valuation.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
84
tests/domain/modelling/test_valuation.py
Normal file
84
tests/domain/modelling/test_valuation.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue