Merge pull request 'fix(estimator): DKP corridor uses P10/P90 not min/max (#1520)' (#1695) from fix/dkp-corridor-percentile-1520 into main
Some checks are pending
Deploy Trade-In / changes (push) Waiting to run
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions

This commit is contained in:
lekss361 2026-06-17 18:05:39 +00:00
commit 262adbf762
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) ───────