From 6eda4a6d0acdafe3631c909e42d19685734d9289 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:50:15 +0300 Subject: [PATCH] fix(estimator): DKP corridor uses P10/P90 not min/max (#1520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace absolute min/max with robust percentiles so a single outlier ДКП deal cannot shift the soft-bound corridor boundaries. Small samples fall back gracefully via linear interpolation (n=3: P10≈index 0.2, P90≈index 2.8). --- tradein-mvp/backend/app/services/estimator.py | 9 +++++-- .../backend/tests/test_estimator_imv_blend.py | 24 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 4eb4a9ed..0b294cbf 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1240,11 +1240,16 @@ def _fetch_dkp_corridor( max(factors_applied), SBER_TIME_ADJUST_REGION, ) + # #1520: используем P10/P90 вместо абсолютных min/max, чтобы коридор был + # устойчив к выбросам (один нерыночный ДКП не сдвигает границу). + # При маленьких выборках (n < 10) _percentile с линейной интерполяцией + # плавно сжимается к первому/последнему элементу — специального fallback + # не нужно (n=3: P10 ≈ индекс 0.2, P90 ≈ индекс 2.8 → оба внутри диапазона). return { "count": len(ppm2_values), - "low_ppm2": int(ppm2_values[0]), + "low_ppm2": int(_percentile(ppm2_values, 0.10)), "median_ppm2": int(_percentile(ppm2_values, 0.5)), - "high_ppm2": int(ppm2_values[-1]), + "high_ppm2": int(_percentile(ppm2_values, 0.90)), "period_months": period_months, } diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index e80d16dc..296223ae 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -177,7 +177,14 @@ def test_dkp_corridor_none_without_street() -> None: def test_dkp_corridor_aggregates_ppm2() -> None: - """Считает low/median/high ₽/м² по сделкам.""" + """Считает low/median/high ₽/м² по сделкам. + + #1520: low/high — P10/P90 (не min/max), устойчивы к выбросам. + Для 3 значений [100k, 120k, 140k]: + P10 = 100k + (120k-100k)*0.2 = 104k + P50 = 120k + P90 = 120k + (140k-120k)*0.8 = 136k + """ mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.all.return_value = [ {"price_per_m2": 100_000}, @@ -188,9 +195,9 @@ def test_dkp_corridor_aggregates_ppm2() -> None: res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0) assert res is not None assert res["count"] == 3 - assert res["low_ppm2"] == 100_000 + assert res["low_ppm2"] == 104_000 # P10 assert res["median_ppm2"] == 120_000 - assert res["high_ppm2"] == 140_000 + assert res["high_ppm2"] == 136_000 # P90 # ── #651: anchor band-guard (WHERE clause is exercised via bind params) ───────── @@ -268,7 +275,12 @@ def test_blend_weight_clamped_below_zero() -> None: def test_dkp_corridor_count_below_three_still_aggregates() -> None: - """count<3 → corridor всё равно вычисляется (sanity-bound branch уже у caller'а).""" + """count<3 → corridor всё равно вычисляется (sanity-bound branch уже у caller'а). + + #1520: P10/P90 при n=2 [100k, 130k] (интерполяция по диапазону 30k): + P10 = 100k + 30k*0.1 = 103k + P90 = 100k + 30k*0.9 = 127k + """ mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.all.return_value = [ {"price_per_m2": 100_000}, @@ -278,8 +290,8 @@ def test_dkp_corridor_count_below_three_still_aggregates() -> None: res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0) assert res is not None assert res["count"] == 2 - assert res["low_ppm2"] == 100_000 - assert res["high_ppm2"] == 130_000 + assert res["low_ppm2"] == 103_000 # P10 + assert res["high_ppm2"] == 127_000 # P90 # ── #651: expected_sold ↔ blended-median consistency (full estimate path) ───────