gendesign/tradein-mvp/backend/tests/test_backtest_estimator.py
Light1YT 6f018f4cdb feat(backtest): per-rooms asking→sold correction block (#648 S1)
Extend the read-only harness with an in-memory per-rooms asking→sold ratio
(median sold / median pred-ask, global fallback for buckets < MIN_BUCKET=20)
and a CORRECTED metrics block re-scored via the existing _compute_metrics.
--holdout-split fits on even-id / evaluates on odd-id deals (deterministic)
for an honest out-of-sample number.

Prod A/B (sample 200, holdout): overall bias +20.4%→-4.0%, MAPE 25.4%→20.2%
— a per-rooms ratio removes the systematic asking→sold bias. Still SELECT-only,
estimator untouched. +14 tests (33 total).
2026-05-29 18:11:25 +05:00

480 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit tests for the read-only backtest harness (issue #648).
Covers the PURE aggregation / metric helpers, factored out of the DB code so
they're testable without a live database:
- _compute_metrics — signed/abs error %, median bias, MAPE, per-rooms split
- _errors_summary — bias / MAPE / p25 / p75 of a signed-error list
- _bucketize_rooms / _rooms_label — 4+ collapse, студия labelling
- _derive_room_ratios — per-rooms asking→sold ratio, global fallback, guards
- _apply_ratios + corrected metrics — ratios that cancel a known bias → ~0
No DB / network / mocks: these operate on plain lists/tuples.
NOTE: importing scripts.backtest_estimator pulls app.services.estimator →
app.core.config.Settings, which REQUIRES DATABASE_URL. Set a dummy value
BEFORE importing app modules (same pattern as tests/test_estimator_pure_units.py
and tests/test_audit_address_mismatch.py).
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import math
import pytest
from scripts import backtest_estimator as bt
# --------------------------------------------------------------------------- #
# _bucketize_rooms / _rooms_label
# --------------------------------------------------------------------------- #
def test_bucketize_studio_and_negative_clamp_to_zero() -> None:
assert bt._bucketize_rooms(0) == 0
assert bt._bucketize_rooms(-3) == 0
def test_bucketize_four_plus_collapses() -> None:
assert bt._bucketize_rooms(4) == 4
assert bt._bucketize_rooms(5) == 4
assert bt._bucketize_rooms(9) == 4
def test_bucketize_passthrough_for_one_to_three() -> None:
assert bt._bucketize_rooms(1) == 1
assert bt._bucketize_rooms(2) == 2
assert bt._bucketize_rooms(3) == 3
def test_rooms_label() -> None:
assert bt._rooms_label(0) == "студия"
assert bt._rooms_label(1) == ""
assert bt._rooms_label(3) == ""
assert bt._rooms_label(4) == "4+"
assert bt._rooms_label(7) == "4+"
# --------------------------------------------------------------------------- #
# _errors_summary
# --------------------------------------------------------------------------- #
def test_errors_summary_empty_returns_all_none() -> None:
s = bt._errors_summary([])
assert s["n"] == 0
assert s["median_bias_pct"] is None
assert s["mape_pct"] is None
assert s["p25_pct"] is None
assert s["p75_pct"] is None
def test_errors_summary_uses_median_abs_for_mape_not_mean() -> None:
# signed errors with an asymmetric outlier: median |err| (=10) differs
# sharply from the MEAN |err| (=40). The brief defines MAPE as the MEDIAN
# absolute error, so we assert the robust median is used.
signed = [10.0, 10.0, 10.0, 130.0]
s = bt._errors_summary(signed)
assert s["mape_pct"] == 10.0 # median(|10,10,10,130|) = 10, not mean 40
assert s["median_bias_pct"] == 10.0 # median([10,10,10,130]) = 10
def test_errors_summary_signed_bias_can_be_negative() -> None:
# Under-prediction → negative bias.
s = bt._errors_summary([-20.0, -10.0, -30.0])
assert s["median_bias_pct"] == -20.0
assert s["mape_pct"] == 20.0 # median of |[-20,-10,-30]| = median[10,20,30]
# --------------------------------------------------------------------------- #
# _compute_metrics — signed/abs error %, bias, MAPE, per-rooms
# --------------------------------------------------------------------------- #
def test_compute_metrics_empty_overall_is_none_per_rooms_all_present() -> None:
m = bt._compute_metrics([])
assert m["overall"]["n"] == 0
assert m["overall"]["median_bias_pct"] is None
assert m["overall"]["mape_pct"] is None
assert m["overall"]["n_no_analogs"] == 0
# Every room bucket must still appear (with n=0) so the report renders.
assert set(m["per_rooms"].keys()) == set(bt.ROOM_BUCKETS)
for bucket in bt.ROOM_BUCKETS:
assert m["per_rooms"][bucket]["n"] == 0
assert m["per_rooms"][bucket]["median_bias_pct"] is None
assert m["per_rooms"][bucket]["label"] == bt._rooms_label(bucket)
def test_compute_metrics_known_plus_22_pct_overprediction() -> None:
# The headline finding: asking median over-predicts SOLD by ~+22%.
# pred = 1.22 * sold for every row → signed error must be exactly +22%,
# MAPE +22%, p25 == p75 == +22% (no spread).
rows = [
(122_000.0, 100_000.0, 1),
(244_000.0, 200_000.0, 2),
(366_000.0, 300_000.0, 3),
]
m = bt._compute_metrics(rows)
assert m["overall"]["n"] == 3
assert m["overall"]["median_bias_pct"] == pytest.approx(22.0)
assert m["overall"]["mape_pct"] == pytest.approx(22.0)
assert m["overall"]["p25_pct"] == pytest.approx(22.0)
assert m["overall"]["p75_pct"] == pytest.approx(22.0)
def test_compute_metrics_signed_error_formula() -> None:
# Single row, hand-computed: 100*(150k-120k)/120k = +25.0%.
m = bt._compute_metrics([(150_000.0, 120_000.0, 2)])
assert m["overall"]["median_bias_pct"] == pytest.approx(25.0)
assert m["overall"]["mape_pct"] == pytest.approx(25.0)
def test_compute_metrics_abs_error_distinct_from_signed() -> None:
# Mixed over/under: signed bias near 0 but MAPE (median |err|) is positive.
# rows: +50%, -50%, +50%, -50% → median signed in {-50,+50} band,
# median |err| = 50.
rows = [
(150_000.0, 100_000.0, 1), # +50
(50_000.0, 100_000.0, 1), # -50
(150_000.0, 100_000.0, 1), # +50
(50_000.0, 100_000.0, 1), # -50
]
m = bt._compute_metrics(rows)
assert m["overall"]["mape_pct"] == pytest.approx(50.0)
# signed median of [-50,-50,50,50] = 0.0 (mean of two middles)
assert m["overall"]["median_bias_pct"] == pytest.approx(0.0)
def test_compute_metrics_per_rooms_split_and_four_plus_collapse() -> None:
rows = [
(110_000.0, 100_000.0, 0), # студия: +10
(130_000.0, 100_000.0, 0), # студия: +30 → median bucket 0 = +20
(90_000.0, 100_000.0, 2), # 2к: -10
(200_000.0, 100_000.0, 5), # 4+ (5 collapses): +100
(300_000.0, 100_000.0, 4), # 4+ : +200 → median bucket 4 = +150
]
m = bt._compute_metrics(rows)
assert m["per_rooms"][0]["n"] == 2
assert m["per_rooms"][0]["median_bias_pct"] == pytest.approx(20.0)
assert m["per_rooms"][0]["label"] == "студия"
assert m["per_rooms"][2]["n"] == 1
assert m["per_rooms"][2]["median_bias_pct"] == pytest.approx(-10.0)
# rooms=5 and rooms=4 both land in bucket 4.
assert m["per_rooms"][4]["n"] == 2
assert m["per_rooms"][4]["median_bias_pct"] == pytest.approx(150.0)
assert m["per_rooms"][4]["label"] == "4+"
# buckets 1 and 3 had no rows.
assert m["per_rooms"][1]["n"] == 0
assert m["per_rooms"][3]["n"] == 0
# overall n counts every matched row.
assert m["overall"]["n"] == 5
def test_compute_metrics_drops_nonpositive_sold() -> None:
# sold_ppm2 <= 0 cannot be divided → row dropped, not counted, no crash.
rows = [
(120_000.0, 0.0, 1), # dropped
(120_000.0, -5.0, 2), # dropped
(122_000.0, 100_000.0, 1), # kept → +22
]
m = bt._compute_metrics(rows)
assert m["overall"]["n"] == 1
assert m["overall"]["median_bias_pct"] == pytest.approx(22.0)
def test_compute_metrics_carries_no_analog_counts() -> None:
rows = [(122_000.0, 100_000.0, 1)]
m = bt._compute_metrics(
rows,
n_no_analogs=7,
per_rooms_no_analogs={1: 4, 2: 3},
)
assert m["overall"]["n_no_analogs"] == 7
assert m["per_rooms"][1]["n_no_analogs"] == 4
assert m["per_rooms"][2]["n_no_analogs"] == 3
# bucket with no skipped deals defaults to 0.
assert m["per_rooms"][0]["n_no_analogs"] == 0
# --------------------------------------------------------------------------- #
# _derive_room_ratios — per-rooms asking→sold ratio, global fallback, guards.
# --------------------------------------------------------------------------- #
def _rows_for_bucket(
bucket: int, *, n: int, ask: float, sold: float
) -> list[tuple[float, float, int]]:
"""n identical (ask, sold, bucket) rows — keeps per-bucket median == ask/sold."""
return [(ask, sold, bucket) for _ in range(n)]
def test_derive_ratios_per_bucket_exact() -> None:
# Each bucket ≥ MIN_BUCKET deals so every bucket gets its OWN ratio.
# bucket 1: sold/ask = 80k/100k = 0.80 ; bucket 2: 150k/200k = 0.75.
rows = (
_rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=80_000.0)
+ _rows_for_bucket(2, n=bt.MIN_BUCKET, ask=200_000.0, sold=150_000.0)
)
ratios, meta = bt._derive_room_ratios(rows)
assert ratios[1] == pytest.approx(0.80)
assert ratios[2] == pytest.approx(0.75)
assert meta["fallback_buckets"] == [] # both buckets were big enough
assert meta["bucket_n"][1] == bt.MIN_BUCKET
assert meta["bucket_n"][2] == bt.MIN_BUCKET
def test_derive_ratios_median_not_mean() -> None:
# A bucket whose ask/sold pairs vary: ratio must use the MEDIAN of each
# series, not a per-row mean. asks median = 100k, solds median = 90k → 0.9.
rows = [
(80_000.0, 60_000.0, 1),
(100_000.0, 90_000.0, 1), # median row
(300_000.0, 200_000.0, 1),
*_rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=90_000.0),
]
ratios, _ = bt._derive_room_ratios(rows)
# median ask and median sold are both pinned to 100k/90k by the padding.
assert ratios[1] == pytest.approx(0.90)
def test_derive_ratios_thin_bucket_uses_global_fallback() -> None:
# bucket 1 has plenty (own ratio 0.80); bucket 2 has only 1 deal (< MIN) →
# must inherit the GLOBAL ratio, and be flagged as a fallback bucket.
rows = [
*_rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=80_000.0),
(200_000.0, 120_000.0, 2), # lone bucket-2 deal
]
ratios, meta = bt._derive_room_ratios(rows)
assert ratios[1] == pytest.approx(0.80)
assert 2 in meta["fallback_buckets"]
# global = median(all sold) / median(all ask). With MIN_BUCKET copies of
# (100k/80k) plus one (200k/120k), both medians stay at the dense point.
assert ratios[2] == pytest.approx(meta["global_ratio"])
assert meta["global_ratio"] is not None
def test_derive_ratios_respects_custom_min_bucket() -> None:
# With min_bucket=2, a 1-deal bucket falls back; a 2-deal bucket keeps own.
rows = [
(100_000.0, 50_000.0, 1), # lone bucket-1 deal → fallback
(100_000.0, 90_000.0, 2),
(100_000.0, 90_000.0, 2), # 2 deals → own ratio 0.9
]
ratios, meta = bt._derive_room_ratios(rows, min_bucket=2)
assert 1 in meta["fallback_buckets"]
assert 2 not in meta["fallback_buckets"]
assert ratios[2] == pytest.approx(0.90)
def test_derive_ratios_empty_returns_empty_and_safe_meta() -> None:
ratios, meta = bt._derive_room_ratios([])
assert ratios == {}
assert meta["global_ratio"] is None
assert meta["fallback_buckets"] == []
assert all(n == 0 for n in meta["bucket_n"].values())
def test_derive_ratios_skips_nonpositive_and_guards_div_by_zero() -> None:
# pred<=0 or sold<=0 rows are dropped; a bucket left with only bad rows
# gets neither its own ratio NOR a (here non-existent) global one → omitted,
# and the function does not raise ZeroDivisionError.
rows = [
(0.0, 100_000.0, 1), # pred 0 → dropped (would div-by-zero)
(100_000.0, 0.0, 1), # sold 0 → dropped
(-5.0, 100_000.0, 2), # pred <0 → dropped
]
ratios, meta = bt._derive_room_ratios(rows)
assert ratios == {} # nothing valid survived
assert meta["global_ratio"] is None # no valid pred → no global ratio
def test_derive_ratios_bucket_zero_global_pred_no_ratio() -> None:
# If a bucket's own pred median is 0 (all-zero preds) it can't form a ratio;
# with no global fallback either, it must be omitted, not crash.
rows = [(0.0, 100_000.0, 1) for _ in range(bt.MIN_BUCKET)]
ratios, meta = bt._derive_room_ratios(rows)
assert 1 not in ratios
assert meta["global_ratio"] is None
# --------------------------------------------------------------------------- #
# _apply_ratios + corrected metrics — ratios that cancel a known bias → ~0.
# --------------------------------------------------------------------------- #
def test_apply_ratios_multiplies_pred_by_bucket_ratio() -> None:
rows = [(100_000.0, 90_000.0, 1), (200_000.0, 150_000.0, 2)]
out = bt._apply_ratios(rows, {1: 0.9, 2: 0.75})
assert out[0] == (pytest.approx(90_000.0), 90_000.0, 1)
assert out[1] == (pytest.approx(150_000.0), 150_000.0, 2)
def test_apply_ratios_missing_bucket_leaves_pred_unchanged() -> None:
# bucket 3 absent from the ratios map → identity multiplier (×1.0).
rows = [(123_456.0, 100_000.0, 3)]
out = bt._apply_ratios(rows, {1: 0.9})
assert out[0][0] == pytest.approx(123_456.0)
def test_corrected_metrics_cancel_plus_30_pct_bias_to_zero() -> None:
# Construct a uniform +30% asking bias (pred = 1.30 * sold) across buckets
# with enough deals that each bucket forms its OWN ratio. Deriving the ratio
# in-sample and re-applying it MUST collapse the corrected bias to ~0.
rows: list[tuple[float, float, int]] = []
for bucket, sold in ((0, 80_000.0), (1, 100_000.0), (2, 150_000.0)):
rows += _rows_for_bucket(
bucket, n=bt.MIN_BUCKET, ask=1.30 * sold, sold=sold
)
# sanity: the ASKING block really is +30%.
asking = bt._compute_metrics(rows)
assert asking["overall"]["median_bias_pct"] == pytest.approx(30.0)
ratios, meta = bt._derive_room_ratios(rows)
# every per-bucket ratio == 1/1.30 ≈ 0.7692.
for bucket in (0, 1, 2):
assert ratios[bucket] == pytest.approx(1.0 / 1.30, rel=1e-6)
assert meta["fallback_buckets"] == []
corrected = bt._compute_metrics(bt._apply_ratios(rows, ratios))
assert corrected["overall"]["median_bias_pct"] == pytest.approx(0.0, abs=1e-6)
assert corrected["overall"]["mape_pct"] == pytest.approx(0.0, abs=1e-6)
for bucket in (0, 1, 2):
assert corrected["per_rooms"][bucket]["median_bias_pct"] == pytest.approx(
0.0, abs=1e-6
)
def test_corrected_metrics_global_fallback_cancels_uniform_bias() -> None:
# Even when buckets are too thin for their OWN ratio, the GLOBAL fallback
# (uniform +30% here) still cancels the systematic bias to ~0.
rows = [
(130_000.0, 100_000.0, 1), # +30
(260_000.0, 200_000.0, 2), # +30
(104_000.0, 80_000.0, 0), # +30
]
ratios, meta = bt._derive_room_ratios(rows) # all buckets < MIN_BUCKET
assert set(meta["fallback_buckets"]) == {0, 1, 2}
# meta["global_ratio"] is rounded to 4dp for the report; the APPLIED ratios
# in `ratios` keep full precision, so the corrected bias still cancels to 0.
assert meta["global_ratio"] == pytest.approx(0.7692, abs=5e-5)
assert ratios[1] == pytest.approx(1.0 / 1.30, rel=1e-9) # full precision applied
corrected = bt._compute_metrics(bt._apply_ratios(rows, ratios))
assert corrected["overall"]["median_bias_pct"] == pytest.approx(0.0, abs=1e-6)
# --------------------------------------------------------------------------- #
# Rendering smoke tests — table + empty render must not crash.
# --------------------------------------------------------------------------- #
def test_render_table_runs_on_real_metrics() -> None:
m = bt._compute_metrics([(122_000.0, 100_000.0, 1)], n_no_analogs=2)
headline = {
"deal_median_ppm2": 100_000.0,
"ask_median_ppm2": 122_000.0,
"spread_pct": 22.0,
}
out = bt._render_table(m, headline)
assert "BACKTEST" in out
assert "OVERALL" in out
assert "+22.0" in out # bias rendered with sign
assert "100 000" in out # ppm2 formatted with space thousands separator
def test_render_table_handles_empty_sample() -> None:
m = bt._compute_metrics([])
headline = {"deal_median_ppm2": None, "ask_median_ppm2": None, "spread_pct": None}
out = bt._render_table(m, headline)
assert "n/a" in out # None metrics render as n/a, no crash
def test_render_table_includes_corrected_block_and_in_sample_warning() -> None:
# Build a metrics dict the way run_backtest does, with a corrected block.
rows = [(130_000.0, 100_000.0, 1)] * bt.MIN_BUCKET
m = bt._compute_metrics(rows)
ratios, meta = bt._derive_room_ratios(rows)
meta["holdout_split"] = False
m["ratios"] = ratios
m["ratios_meta"] = meta
m["corrected"] = bt._compute_metrics(bt._apply_ratios(rows, ratios))
headline = {
"deal_median_ppm2": 100_000.0,
"ask_median_ppm2": 130_000.0,
"spread_pct": 30.0,
}
out = bt._render_table(m, headline)
assert "ASKING" in out
assert "CORRECTED" in out
assert "ratio=" in out # derived ratio line rendered
assert "IN-SAMPLE" in out # honesty caveat shown when not holdout
def test_render_table_corrected_block_holdout_message() -> None:
rows = [(130_000.0, 100_000.0, 1)] * bt.MIN_BUCKET
m = bt._compute_metrics(rows)
ratios, meta = bt._derive_room_ratios(rows)
meta["holdout_split"] = True
m["ratios"] = ratios
m["ratios_meta"] = meta
m["corrected"] = bt._compute_metrics(bt._apply_ratios(rows, ratios))
headline = {"deal_median_ppm2": None, "ask_median_ppm2": None, "spread_pct": None}
out = bt._render_table(m, headline)
assert "OUT-OF-SAMPLE" in out
assert "IN-SAMPLE" not in out # holdout path swaps the caveat
def test_render_table_no_corrected_block_when_absent() -> None:
# Backward-compat: a metrics dict without "corrected" still renders (ASKING
# only) and does not raise.
m = bt._compute_metrics([(122_000.0, 100_000.0, 1)])
headline = {
"deal_median_ppm2": 100_000.0,
"ask_median_ppm2": 122_000.0,
"spread_pct": 22.0,
}
out = bt._render_table(m, headline)
assert "ASKING" in out
assert "CORRECTED" not in out
def test_fmt_helpers_handle_none_and_nan_safely() -> None:
assert bt._fmt_pct(None) == " n/a"
assert bt._fmt_ppm2(None) == "n/a"
# sanity: finite values format
assert "+22" in bt._fmt_pct(22.0)
assert not math.isnan(22.0)
# --------------------------------------------------------------------------- #
# argparse — defaults match the brief.
# --------------------------------------------------------------------------- #
def test_argparse_defaults() -> None:
ns = bt._parse_args([])
assert ns.sample == 300
assert ns.since == "2025-06-01"
assert ns.radius == 1000
assert ns.rooms_tolerance == 0
assert ns.json is False
assert ns.holdout_split is False
def test_argparse_overrides() -> None:
ns = bt._parse_args(
["--sample", "50", "--since", "2024-01-01", "--radius", "2000",
"--rooms-tolerance", "1", "--holdout-split", "--json"]
)
assert ns.sample == 50
assert ns.since == "2024-01-01"
assert ns.radius == 2000
assert ns.rooms_tolerance == 1
assert ns.holdout_split is True
assert ns.json is True