gendesign/tradein-mvp/backend/tests/test_estimator_range_floor.py
lekss361 9f28092356
All checks were successful
Deploy Trade-In / test (push) Successful in 1m31s
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 46s
fix(tradein/estimator): min-width floor ±12% на ценовой диапазон (#2209) (#2230)
2026-07-02 21:21:28 +00:00

220 lines
9 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.

"""Hermetic unit tests for the #2209 min-width range floor.
Two layers:
1. The pure ``_apply_range_floor`` helper (no DB, no imports beyond estimator).
2. Integration through ``_price_from_inputs`` — a degenerate n=1 analog sample
(q1==q3==median → zero-width asking range) must surface a non-zero ±12 %
range; a naturally-wide range must be left untouched; the floor must run
AFTER the IMV blend (a narrow post-blend range gets widened, never shrunk).
NOTE: importing app.services.estimator pulls app.core.config.Settings which
requires DATABASE_URL. Set it BEFORE importing app modules.
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from app.services.estimator import RANGE_MIN_HALFWIDTH_PCT, _apply_range_floor
from app.services.geocoder import GeocodeResult
# ── helpers (mirror tests/test_estimator_price_spine.py) ──────────────────────
def _geo() -> GeocodeResult:
return GeocodeResult(
lat=56.838,
lon=60.597,
full_address="ул. Тестовая, 1",
provider="nominatim",
confidence="approximate",
)
def _lot(ppm2: float, address: str = "ул. Тестовая, 1", source: str = "avito") -> dict:
return {"price_per_m2": ppm2, "address": address, "source": source}
def _lots(ppm2: float, n: int = 7) -> list[dict]:
return [_lot(ppm2, address=f"ул. Тестовая, {i + 1}") for i in range(n)]
def _call(
*,
listings: list[dict],
area_m2: float = 50.0,
rooms: int | None = 2,
imv_anchor: dict | None = None,
ratio: float | None = None,
) -> estimator.PricingResult:
_ratio = ratio
_basis = "per_rooms" if ratio is not None else None
def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
return _ratio, _basis if _ratio is not None else None
return estimator._price_from_inputs(
listings=listings,
area_m2=area_m2,
rooms=rooms,
repair_state=None,
floor=5,
total_floors=10,
target_year=None,
analog_tier="W",
fallback_used=False,
area_widened=False,
anchor_comps=[],
anchor_tier_fetched=None,
dkp_raw=None,
imv_anchor=imv_anchor,
imv_eval=None,
yandex_val_present=False,
cian_val_present=False,
ratio_resolver=ratio_resolver,
quarter_index_lookup=lambda q: None,
quarter_indexes_lookup=lambda qs: {},
target_house_cadnum=None,
dadata_coarse=False,
geo=_geo(),
dadata_qc_geo=None,
)
# ── 1. Pure helper ────────────────────────────────────────────────────────────
def test_floor_expands_zero_width_symmetrically() -> None:
"""point=5M, low==high==5M (n=1 collapse) → symmetric ±12 % around point."""
half = round(RANGE_MIN_HALFWIDTH_PCT * 5_000_000) # 600_000
low, high = _apply_range_floor(5_000_000, 5_000_000, 5_000_000)
assert low == 5_000_000 - half
assert high == 5_000_000 + half
# Point is the exact midpoint — floor never moves the point.
assert (low + high) // 2 == 5_000_000
def test_floor_leaves_wide_range_untouched() -> None:
"""A range already wider than 2×12 % of point is returned verbatim."""
# width = 3M, point 5M → rel 0.60 >> 0.24 floor → no-op.
assert _apply_range_floor(3_500_000, 6_500_000, 5_000_000) == (3_500_000, 6_500_000)
def test_floor_clamps_low_to_zero() -> None:
"""When point < floor_half the lower edge clamps at 0 (never negative)."""
# point=1M → floor_half=120_000; 1M-120k=880k stays positive, so pick a tiny point.
half = round(RANGE_MIN_HALFWIDTH_PCT * 100_000) # 12_000
low, _high = _apply_range_floor(50_000, 50_000, 100_000)
assert low == 100_000 - half # 88_000 — still positive here
# Now a point whose floor_half exceeds it → low clamps to 0.
low2, high2 = _apply_range_floor(5, 5, 10)
floor_half2 = round(RANGE_MIN_HALFWIDTH_PCT * 10) # ~1
assert low2 == max(0, 10 - floor_half2)
assert low2 >= 0
assert high2 == 10 + floor_half2
def test_floor_noop_when_point_nonpositive() -> None:
"""point<=0 → range returned unchanged (no division/expansion)."""
assert _apply_range_floor(0, 0, 0) == (0, 0)
assert _apply_range_floor(100, 200, 0) == (100, 200)
def test_floor_only_widens_never_shrinks() -> None:
"""A range exactly at the floor boundary is preserved (not shrunk)."""
point = 1_000_000
half = round(RANGE_MIN_HALFWIDTH_PCT * point) # 120_000
# width == 2*half exactly → NOT < 2*half → untouched.
assert _apply_range_floor(point - half, point + half, point) == (
point - half,
point + half,
)
# ── 2. Integration through _price_from_inputs ────────────────────────────────
def test_single_analog_asking_range_gets_nonzero_width() -> None:
"""n=1 analog: q1==q3==median → zero-width asking range → floored to ±12 %."""
pr = _call(listings=[_lot(100_000)])
assert pr.n_analogs == 1
point = pr.median_price # 100_000 × 50 = 5_000_000
assert point == 5_000_000
half = round(RANGE_MIN_HALFWIDTH_PCT * point)
assert pr.range_low == point - half
assert pr.range_high == point + half
# Non-degenerate: the range is no longer a single point.
assert pr.range_high > pr.range_low
# Point (median) itself is untouched — floor never moves it.
assert pr.median_ppm2 == 100_000.0
def test_wide_analog_range_not_floored() -> None:
"""A radius sample with real spread keeps its Q1..Q3 asking range unchanged."""
# Spread wide enough that (q3-q1)/median > 0.24.
lots = [_lot(p) for p in (60_000, 80_000, 100_000, 120_000, 140_000)]
pr = _call(listings=lots)
point = pr.median_price
half = round(RANGE_MIN_HALFWIDTH_PCT * point)
# Real IQR width exceeds the floor → floor is a no-op here.
assert (pr.range_high - pr.range_low) >= 2 * half
# And the edges are the genuine Q1/Q3 totals, not point±half.
assert pr.range_low != point - half
def test_expected_sold_range_floored_around_expected_point() -> None:
"""Degenerate n=1 with a ratio: expected_sold range also gets ±12 % of its point."""
pr = _call(listings=[_lot(100_000)], ratio=0.90)
assert pr.expected_sold_price is not None
assert pr.expected_sold_range_low is not None
assert pr.expected_sold_range_high is not None
esp = pr.expected_sold_price
# Calibrated PI band (0.649..1.392 of point) is far wider than the 12 % floor,
# so the expected_sold range is NOT degenerate and the floor is a no-op on it —
# but it must still bracket the point and stay non-negative.
assert pr.expected_sold_range_low <= esp <= pr.expected_sold_range_high
assert pr.expected_sold_range_low >= 0
assert (pr.expected_sold_range_high - pr.expected_sold_range_low) >= round(
2 * RANGE_MIN_HALFWIDTH_PCT * esp
)
def test_ppm2_point_consistent_with_floored_range() -> None:
"""Serialized ppm² point stays the analog median; floor only widens rub range.
The response serializes a single ppm² point (median_ppm2 / expected_sold_per_m2),
not a ppm² range, so цена↔ppm² consistency means the point ppm² must equal
median_price/area even after the range floor widens the rub band.
"""
pr = _call(listings=[_lot(100_000)], area_m2=50.0)
assert pr.median_ppm2 == pr.median_price / 50.0
# Floor widened the rub range but did not touch the ppm² point.
assert pr.median_ppm2 == 100_000.0
def test_floor_applied_after_imv_blend() -> None:
"""Floor runs LAST: a narrow post-IMV-blend asking range is widened, not shrunk.
Uniform lots → zero-width Q1..Q3. IMV anchor just above threshold (no higher_price)
blends the median up and lifts range_high to the anchor total, leaving a range that
is still narrower than the 12 % floor. The floor must then widen it symmetrically
around the POST-BLEND point — proving the floor sees post-blend values.
"""
imv_anchor = {
"recommended_price": 5_800_000, # > 5M × 1.15 = 5.75M → blends
"lower_price": 5_500_000,
"higher_price": None, # so range_top_candidate == anchor_total (5.8M)
"market_count": 50,
}
pr = _call(listings=_lots(100_000, n=5), imv_anchor=imv_anchor)
# Blend fired: point moved to round(5M×0.5 + 5.8M×0.5) = 5.4M.
point = pr.median_price
assert point == 5_400_000
half = round(RANGE_MIN_HALFWIDTH_PCT * point) # 648_000
# Pre-floor the post-blend range was [5.0M, 5.8M] (width 0.8M < 2×648k) → floored.
assert pr.range_low == point - half # 4_752_000 < 5.0M pre-floor low
assert pr.range_high == point + half # 6_048_000 > 5.8M pre-floor high
# Floor only WIDENED: high moved above the blend's 5.8M, low below the 5.0M q1.
assert pr.range_high > 5_800_000
assert pr.range_low < 5_000_000