fix(estimator): DKP corridor uses P10/P90 not min/max (#1520)
All checks were successful
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) 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

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).
This commit is contained in:
bot-backend 2026-06-17 20:50:15 +03:00
parent ba83c36bf4
commit 6eda4a6d0a
2 changed files with 25 additions and 8 deletions

View file

@ -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,
}

View file

@ -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) ───────