fix(estimator): DKP corridor uses P10/P90 not min/max (#1520) #1695
2 changed files with 25 additions and 8 deletions
|
|
@ -1240,11 +1240,16 @@ def _fetch_dkp_corridor(
|
||||||
max(factors_applied),
|
max(factors_applied),
|
||||||
SBER_TIME_ADJUST_REGION,
|
SBER_TIME_ADJUST_REGION,
|
||||||
)
|
)
|
||||||
|
# #1520: используем P10/P90 вместо абсолютных min/max, чтобы коридор был
|
||||||
|
# устойчив к выбросам (один нерыночный ДКП не сдвигает границу).
|
||||||
|
# При маленьких выборках (n < 10) _percentile с линейной интерполяцией
|
||||||
|
# плавно сжимается к первому/последнему элементу — специального fallback
|
||||||
|
# не нужно (n=3: P10 ≈ индекс 0.2, P90 ≈ индекс 2.8 → оба внутри диапазона).
|
||||||
return {
|
return {
|
||||||
"count": len(ppm2_values),
|
"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)),
|
"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,
|
"period_months": period_months,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,14 @@ def test_dkp_corridor_none_without_street() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_dkp_corridor_aggregates_ppm2() -> 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 = MagicMock()
|
||||||
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
||||||
{"price_per_m2": 100_000},
|
{"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)
|
res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0)
|
||||||
assert res is not None
|
assert res is not None
|
||||||
assert res["count"] == 3
|
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["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) ─────────
|
# ── #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:
|
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 = MagicMock()
|
||||||
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
||||||
{"price_per_m2": 100_000},
|
{"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)
|
res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0)
|
||||||
assert res is not None
|
assert res is not None
|
||||||
assert res["count"] == 2
|
assert res["count"] == 2
|
||||||
assert res["low_ppm2"] == 100_000
|
assert res["low_ppm2"] == 103_000 # P10
|
||||||
assert res["high_ppm2"] == 130_000
|
assert res["high_ppm2"] == 127_000 # P90
|
||||||
|
|
||||||
|
|
||||||
# ── #651: expected_sold ↔ blended-median consistency (full estimate path) ───────
|
# ── #651: expected_sold ↔ blended-median consistency (full estimate path) ───────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue