From a6f073ba9a304c52db3a9dd5e966aa49132824ad Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 00:07:46 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20min-width=20floor=20?= =?UTF-8?q?=C2=B112%=20=D0=BD=D0=B0=20=D1=86=D0=B5=D0=BD=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=20(#2?= =?UTF-8?q?209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При n=1 аналоге q1==q3==median → asking-диапазон (Q1..Q3 ₽/м²) схлопывался в ноль ширины: юзеру уходил «диапазон» из одной точки с ложной точностью. RANGE_MIN_HALFWIDTH_PCT=0.12 + чистая _apply_range_floor, применяемая ПОСЛЕДНЕЙ в спайне к обоим диапазонам (headline asking + expected_sold) вокруг их точек. Floor ТОЛЬКО расширяет; точку и confidence не трогает; не может триггернуть manual_review wide-range (1.273 < 1.9). Frozen backtest baseline БАЙТ-ИДЕНТИЧЕН до/после (replay проверен): expected_sold-диапазон строится калиброванным PI-бэндом (rel-width 0.743, кратно шире floor 0.24) — floor на нём no-op; кусает только вырожденный asking-хвост (n=1..2), который replay не скорит. Bias/MAPE не сдвинуты. +10 юнит-тестов (включая доказательство порядка floor-после-IMV-blend). Refs #2209 --- tradein-mvp/backend/app/services/estimator.py | 38 +++ .../tests/test_estimator_range_floor.py | 220 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_estimator_range_floor.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index b8b1b447..4ba29d2f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -96,6 +96,15 @@ SBER_TIME_FACTOR_MAX = 1.6 # честный пересчёт дескриптора. 1e-4 << шаг бейджа round((1−ratio)×100) (0.005). _RATIO_DESCRIPTOR_EPS = 1e-4 +# #2209: минимальная относительная полуширина ценового диапазона. При n=1 аналоге +# q1==q3==median → asking-диапазон (Q1..Q3 ₽/м² аналогов) схлопывается в НОЛЬ ширины, +# и юзеру уходит «диапазон» из одной точки с ложной точностью. Живой бэктест +# 2026-07-02 (engine=full, n=276): MAPE low-confidence bucket 14.8 %, медианная +# относительная ширина диапазона 0.743 — floor ±12 % затрагивает только вырожденно +# узкий хвост (n=1..2 аналога), не трогая типичные оценки. Ширина не может честно +# быть у́же типичной ошибки: floor только РАСШИРЯЕТ, точку не двигает. +RANGE_MIN_HALFWIDTH_PCT = 0.12 + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -2577,6 +2586,19 @@ def _price_from_inputs( "по широкой окрестности)." ) + # ── #2209: min-width floor — ПОСЛЕДНИМ, после всех мутаций (anchor / IMV blend + # / quarter-index / corridor-clamp / radius-floor / hedonic PI), чтобы никакая + # последующая мутация не сузила диапазон обратно. Floor только расширяет и не + # трогает точку (median_price / expected_sold_price). Вырожденный n=1 asking- + # диапазон (Q1==Q3) перестаёт быть точкой с ложной точностью. + range_low, range_high = _apply_range_floor(range_low, range_high, median_price) + if expected_sold_range_low is not None and expected_sold_range_high is not None: + expected_sold_range_low, expected_sold_range_high = _apply_range_floor( + expected_sold_range_low, + expected_sold_range_high, + expected_sold_price if expected_sold_price is not None else 0, + ) + return PricingResult( median_ppm2=median_ppm2, median_price=median_price, @@ -4666,6 +4688,22 @@ def _percentile(sorted_values: list[float], p: float) -> float: return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac +def _apply_range_floor(low: int, high: int, point: int) -> tuple[int, int]: + """#2209: min-width floor ценового диапазона вокруг точки оценки. + + Если диапазон уже́ 2×RANGE_MIN_HALFWIDTH_PCT×point (вырожденный n=1..2 хвост), + расширяем симметрично вокруг point до floor-полуширины. Floor ТОЛЬКО расширяет + (никогда не сужает уже-широкий диапазон), точку (median/expected_sold) не двигает. + low не опускается ниже 0. Возвращает целые рубли. + """ + if point <= 0: + return low, high + floor_half = round(RANGE_MIN_HALFWIDTH_PCT * point) + if (high - low) < 2 * floor_half: + return max(0, point - floor_half), point + floor_half + return low, high + + def _enforce_zero_analog_low( confidence: str, n_analogs: int, diff --git a/tradein-mvp/backend/tests/test_estimator_range_floor.py b/tradein-mvp/backend/tests/test_estimator_range_floor.py new file mode 100644 index 00000000..1801958c --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_range_floor.py @@ -0,0 +1,220 @@ +"""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 -- 2.45.3