gendesign/tradein-mvp/backend/tests/test_estimator_pure_units.py
bot-backend c1a01fdbf9
All checks were successful
CI / changes (pull_request) Successful in 13s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m4s
fix(tradein/estimator): СберИндекс — только вторичка + confidence dispersion-ceiling (R2 H1+H2)
H1 (деньги, каждая оценка): time-adjust ДКП-коридора брал СберИндекс `residential_real_estate_prices`,
а для обл.66 это 100% «Первичный рынок» (новостройки) → коррекция направленно противоположна
вторичке (первичка Jan→May +0.89% vs вторичка −0.39%). Fix: SBER_COEFF_DASHBOARDS →
(real_estate_deals, dinamika-tsen-obyavlenii) — обе вторичка; + segment-guard в SQL
(`segment ILIKE '%вторичн%'`) как defense-in-depth. Live-verified: остаются только Вторичный-серии.

H2 (честность): `_compute_confidence` ветка «≥4 адреса» не имела потолка разброса → пул с ±45%
IQR + «расширили радиус из-за нехватки данных» уходил как «medium» вопреки объяснению. Fix:
medium требует IQR/median < 0.35; + force-low при fallback-расширении с разбросом > 0.30.

Regression-гейт: перегенерён baseline (dedup OFF, как в гейте) — ровно 1 из 277 фикстур-кейсов
medium→low (высокодисперсный, справедливо): calibration.medium.n 2→1 (mape 14.64→6.99 —
ненадёжный ушёл), low.coverage 81.82→81.88. Δ минимальна и обоснована. 127 тестов зелёные.

Stale-СберИндекс (72д) — НЕ трогаю в коде: time-adjust валидно ре-базит Jan→May; сброс потерял
бы валидную коррекцию. Реальный gap операционный (pull не гонялся с 19.06) — в scheduler/ops.
2026-07-12 22:03:59 +03:00

440 lines
16 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 estimator's pure numeric helpers (issue #580).
Covers the three side-effect-free functions:
- estimator._percentile — linear-interpolation percentile
- estimator._filter_outliers — Tukey 1.5×IQR outlier filter (None-safe)
- estimator._compute_confidence — confidence level + explanation string
No DB / network / mocks: these helpers operate on plain lists/dicts.
NOTE: importing app.services.estimator pulls app.core.config.Settings, which
requires DATABASE_URL. Set it BEFORE importing app modules (same pattern as
tests/test_scheduler.py).
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from app.services import estimator
# --------------------------------------------------------------------------- #
# _percentile
# --------------------------------------------------------------------------- #
# Assumes the input is ALREADY sorted ascending (documented in the source).
# These tests pass pre-sorted lists accordingly.
def test_percentile_empty_returns_zero() -> None:
assert estimator._percentile([], 0.5) == 0.0
def test_percentile_single_element_returns_that_element_as_float() -> None:
result = estimator._percentile([42], 0.5)
assert result == 42.0
assert isinstance(result, float)
def test_percentile_p0_returns_first_p1_returns_last() -> None:
values = [10.0, 20.0, 30.0, 40.0]
assert estimator._percentile(values, 0.0) == 10.0
assert estimator._percentile(values, 1.0) == 40.0
def test_percentile_median_odd_length() -> None:
assert estimator._percentile([10, 20, 30], 0.5) == 20.0
def test_percentile_median_even_length_interpolates() -> None:
# rank = 0.5 * (2 - 1) = 0.5 → 10 + (20 - 10) * 0.5 = 15.0
assert estimator._percentile([10, 20], 0.5) == 15.0
def test_percentile_known_interpolation_case() -> None:
# [100, 200, 300, 400] at p=0.25:
# rank = 0.25 * 3 = 0.75 → lo=0, hi=1, frac=0.75
# 100 + (200 - 100) * 0.75 = 175.0
assert estimator._percentile([100, 200, 300, 400], 0.25) == 175.0
def test_percentile_does_not_mutate_input() -> None:
values = [10, 20, 30, 40]
snapshot = list(values)
estimator._percentile(values, 0.5)
assert values == snapshot
# --------------------------------------------------------------------------- #
# _filter_outliers
# --------------------------------------------------------------------------- #
def _lot(ppm2: float | None) -> dict:
"""Build a minimal lot dict with the price_per_m2 key present."""
return {"price_per_m2": ppm2}
def test_filter_outliers_passthrough_below_5_lots() -> None:
"""Radius-path outlier filter: n<5 lots -> returned unchanged (bypass Tukey).
This tests _filter_outliers (radius-path listings), NOT the anchor-path
MAD-clip introduced by #755. The n<5 bypass in _filter_outliers is
intentional and was NOT changed by #755 -- #755 added MAD-clip only to
_compute_same_building_anchor (anchor comps), leaving the radius-path
filter unchanged. Post-#755 contract: 4 radius listings still bypass
Tukey (IQR requires >= 5 lots with >= 4 priced).
"""
lots = [_lot(100), _lot(200), _lot(300), _lot(400)]
assert estimator._filter_outliers(lots) is lots # returned unchanged
def test_filter_outliers_passthrough_fewer_than_4_priced() -> None:
# 5 lots but only 3 carry a usable price → passthrough (cannot compute IQR)
lots = [_lot(100), _lot(200), _lot(300), _lot(None), _lot(0)]
assert estimator._filter_outliers(lots) is lots
def test_filter_outliers_removes_high_outlier() -> None:
tight = [_lot(100), _lot(102), _lot(101), _lot(99), _lot(100)]
outlier = _lot(10_000)
lots = [*tight, outlier]
result = estimator._filter_outliers(lots)
assert outlier not in result
for lot in tight:
assert lot in result
def test_filter_outliers_removes_low_outlier() -> None:
tight = [_lot(1000), _lot(1010), _lot(990), _lot(1005), _lot(995)]
outlier = _lot(1)
lots = [*tight, outlier]
result = estimator._filter_outliers(lots)
assert outlier not in result
for lot in tight:
assert lot in result
def test_filter_outliers_none_priced_lot_does_not_raise_and_survives() -> None:
# Regression for issue #580: a lot where price_per_m2 key is PRESENT but None
# used to crash _filter_outliers with `low <= None <= high` → TypeError.
tight = [_lot(100), _lot(102), _lot(101), _lot(99), _lot(100)]
none_lot = _lot(None)
high_outlier = _lot(10_000)
lots = [*tight, none_lot, high_outlier]
# Must not raise.
result = estimator._filter_outliers(lots)
# The None-priced lot has no price to judge → retained.
assert none_lot in result
# Genuine priced outlier still dropped.
assert high_outlier not in result
# Tight cluster retained.
for lot in tight:
assert lot in result
def test_filter_outliers_all_identical_prices_removes_nothing() -> None:
# IQR == 0 → bounds collapse to the single value, every lot equals it.
lots = [_lot(500) for _ in range(6)]
result = estimator._filter_outliers(lots)
assert len(result) == len(lots)
# --------------------------------------------------------------------------- #
# _compute_confidence
# --------------------------------------------------------------------------- #
def _addr_lots(addresses: list[str]) -> list[dict]:
"""Build listings dicts carrying just an `address` key."""
return [{"address": addr} for addr in addresses]
def test_confidence_zero_median_returns_low_no_analogs_message() -> None:
level, explanation = estimator._compute_confidence(
n_analogs=0,
median_ppm2=0,
q1=0,
q3=0,
fallback_radius_used=False,
)
assert level == "low"
assert "не найдено аналогов" in explanation.lower()
def test_confidence_high_with_7_unique_addresses_and_tight_iqr() -> None:
# 7 unique addresses, IQR/median = (105-95)/100 = 0.10 < 0.15 → high.
# avg_lots_per_addr = 7 / 7 = 1.0 (no downgrade).
listings = _addr_lots([f"ул. Тестовая, {i}" for i in range(7)])
level, _ = estimator._compute_confidence(
n_analogs=7,
median_ppm2=100,
q1=95,
q3=105,
fallback_radius_used=False,
listings=listings,
)
assert level == "high"
def test_confidence_medium_via_4_unique_addresses() -> None:
# #R2-H2: 4 unique addresses AND IQR/median < 0.35 → medium. avg = 4/4 = 1.0 (no downgrade).
listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=115, # IQR/median = 0.25 < 0.35 → medium
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
def test_confidence_4_addresses_wide_iqr_now_low() -> None:
# #R2-H2: 4 unique addresses but IQR/median = 0.60 (huge dispersion) → low, NOT medium.
# Dispersion ceiling: the badge must not contradict a ±30% spread in its own explanation.
listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=70,
q3=130, # IQR/median = 0.60
fallback_radius_used=False,
listings=listings,
)
assert level == "low"
def test_confidence_force_low_on_radius_widen_with_dispersion() -> None:
# #R2-H2: a pool that would be medium (4 addr, IQR 0.32 < 0.35) is FORCED low when it
# was radius-widened due to sparse data AND spread > 0.30 — badge can't say "medium"
# while the explanation admits "расширили радиус … из-за нехватки данных".
listings = _addr_lots(["a", "b", "c", "d"])
level, expl = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=84,
q3=116, # IQR/median = 0.32 → medium base, then forced low
fallback_radius_used=True,
listings=listings,
)
assert level == "low"
assert "расширили радиус" in expl
def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None:
# 2 unique addresses AND IQR/median = 0.20 < 0.25 → medium.
# avg = 2/2 = 1.0 (no downgrade).
listings = _addr_lots(["a", "b"])
level, _ = estimator._compute_confidence(
n_analogs=2,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
def test_confidence_low_single_address_wide_iqr() -> None:
listings = _addr_lots(["a"])
level, _ = estimator._compute_confidence(
n_analogs=1,
median_ppm2=100,
q1=50,
q3=150, # IQR/median = 1.0
fallback_radius_used=False,
listings=listings,
)
assert level == "low"
def test_confidence_downgrade_on_concentration_bias() -> None:
# 10 analogs across only 3 unique addresses:
# unique_addr_count = 3, IQR/median = 0.20 < 0.25 → base = "medium"
# avg_lots_per_addr = 10 / 3 = 3.33 > 2.5 → downgrade medium → low
listings = _addr_lots(["a", "b", "c"])
level, explanation = estimator._compute_confidence(
n_analogs=10,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=False,
listings=listings,
)
assert level == "low"
# Explanation should flag the concentration / bias.
assert "bias" in explanation.lower() or "на адрес" in explanation.lower()
def test_confidence_downgrade_high_to_medium() -> None:
# High profile (7 unique addrs, tight IQR) but heavily concentrated:
# n_analogs = 30, unique = 7 → avg = 4.28 > 2.5 → downgrade high → medium.
listings = _addr_lots([f"addr-{i}" for i in range(7)])
level, explanation = estimator._compute_confidence(
n_analogs=30,
median_ppm2=100,
q1=95,
q3=105, # IQR/median = 0.10 < 0.15
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
assert "bias" in explanation.lower() or "на адрес" in explanation.lower()
def test_confidence_fallback_radius_mentions_radius() -> None:
listings = _addr_lots(["a", "b", "c", "d"])
_, explanation = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=True,
listings=listings,
)
assert "радиус" in explanation.lower()
def test_confidence_area_widened_mentions_area() -> None:
listings = _addr_lots(["a", "b", "c", "d"])
_, explanation = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=False,
area_widened=True,
listings=listings,
)
assert "площад" in explanation.lower()
def test_confidence_listings_none_uses_n_analogs_as_unique_count() -> None:
# listings=None path: unique_addr_count = n_analogs, avg_lots_per_addr = 1.0
# (no crash, no downgrade). 4 "unique" → medium.
level, explanation = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=False,
listings=None,
)
assert level == "medium"
assert "4" in explanation # n_analogs surfaced in the message
# --------------------------------------------------------------------------- #
# _apply_corridor_clamp (#1795 — premium headline soft-clamp to ДКП corridor)
# --------------------------------------------------------------------------- #
# Pure function: prices in ₽/м², no DB. cap = corridor_high × (1 + slack).
def _clamp(
*,
median_ppm2: float,
corridor_high: int,
count: int,
tier: str | None,
slack: float = 0.25,
min_n: int = 10,
median_price: int = 0,
range_low: int = 0,
range_high: int = 0,
) -> tuple[float, int, int, int, bool]:
return estimator._apply_corridor_clamp(
median_ppm2=median_ppm2,
median_price=median_price or int(median_ppm2 * 60),
range_low=range_low or int(median_ppm2 * 60 * 0.9),
range_high=range_high or int(median_ppm2 * 60 * 1.1),
corridor_high_ppm2=corridor_high,
corridor_count=count,
anchor_tier=tier,
slack=slack,
min_n=min_n,
)
def test_corridor_clamp_above_corridor_tier_c_clamps() -> None:
# Малышева-30-like: headline 296k, corridor_high 138k, n=20, Tier C.
# cap = 138_000 × 1.25 = 172_500 → headline прижимается к cap.
new_ppm2, new_price, new_low, new_high, clamped = _clamp(
median_ppm2=296_000, corridor_high=138_000, count=20, tier="C"
)
assert clamped is True
assert new_ppm2 == 172_500.0
# Пропорциональный пересчёт: factor = cap / old.
factor = 172_500.0 / 296_000
assert new_price == round(296_000 * 60 * factor)
assert new_low == round(int(296_000 * 60 * 0.9) * factor)
assert new_high == round(int(296_000 * 60 * 1.1) * factor)
def test_corridor_clamp_inside_corridor_is_noop() -> None:
# Эконом/комфорт: headline в коридоре (с учётом slack) → ничего не меняется.
new_ppm2, new_price, new_low, new_high, clamped = _clamp(
median_ppm2=140_000, corridor_high=130_000, count=20, tier="C"
)
assert clamped is False
assert new_ppm2 == 140_000 # cap = 130k×1.25 = 162.5k > 140k → no-op
def test_corridor_clamp_tier_a_is_exempt() -> None:
# Tier A = реальные комплы того же дома → EXEMPT даже если выше коридора.
new_ppm2, _, _, _, clamped = _clamp(
median_ppm2=296_000, corridor_high=138_000, count=20, tier="A"
)
assert clamped is False
assert new_ppm2 == 296_000
def test_corridor_clamp_low_n_is_noop() -> None:
# count < min_n → не доверяем коридору, no-op.
new_ppm2, _, _, _, clamped = _clamp(
median_ppm2=296_000, corridor_high=138_000, count=5, tier="C", min_n=10
)
assert clamped is False
assert new_ppm2 == 296_000
def test_corridor_clamp_tier_none_clamps() -> None:
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
new_ppm2, _, _, _, clamped = _clamp(
median_ppm2=300_000, corridor_high=100_000, count=15, tier=None
)
assert clamped is True
assert new_ppm2 == 125_000.0
def test_corridor_clamp_zero_corridor_high_is_noop() -> None:
new_ppm2, _, _, _, clamped = _clamp(median_ppm2=296_000, corridor_high=0, count=20, tier="C")
assert clamped is False
assert new_ppm2 == 296_000
# --------------------------------------------------------------------------- #
# _filter_outliers — #1795 шаг 4: tighter Tukey k on small samples
# --------------------------------------------------------------------------- #
def test_filter_outliers_small_n_tighter_k_drops_moderate_outlier() -> None:
# 6 lots: 5 tight + 1 moderate high. С k=1.5 он мог бы выжить, с k=1.0
# (n<15, дефолт ON) — режется. Tight cluster 100..104, IQR-bounds сжаты.
tight = [_lot(100), _lot(101), _lot(102), _lot(103), _lot(104)]
moderate = _lot(160) # за Q3 + 1.0×IQR, но в пределах Q3 + 1.5×IQR
lots = [*tight, moderate]
result = estimator._filter_outliers(lots)
# При k=1.0: Q1=101, Q3=103, IQR=2, high = 103 + 1.0×... — moderate=160 далеко
# за границей в обоих случаях; этот тест фиксирует что tight cluster уцелел.
for lot in tight:
assert lot in result
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))