МЕРА: настройки оценщика, которые никогда не менялись, стали кодом — 4 кластера флагов #1970 #3556
1 changed files with 70 additions and 11 deletions
|
|
@ -389,6 +389,7 @@ def _run_estimate_qi(
|
||||||
anchor_tier_override: str | None = None,
|
anchor_tier_override: str | None = None,
|
||||||
analog_indexes: dict[str, float] | None = None,
|
analog_indexes: dict[str, float] | None = None,
|
||||||
imv_anchor: dict[str, Any] | None = None,
|
imv_anchor: dict[str, Any] | None = None,
|
||||||
|
seen_min_n: list[int] | None = None,
|
||||||
):
|
):
|
||||||
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
|
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
|
||||||
|
|
||||||
|
|
@ -396,6 +397,9 @@ def _run_estimate_qi(
|
||||||
когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё
|
когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё
|
||||||
батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0.
|
батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0.
|
||||||
Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога.
|
Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога.
|
||||||
|
|
||||||
|
seen_min_n — сюда пишется min_n_deals каждого вызова обоих lookup'ов: порог
|
||||||
|
фильтрует строки в SQL, и заглушка его иначе теряет.
|
||||||
"""
|
"""
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
|
|
@ -406,7 +410,14 @@ def _run_estimate_qi(
|
||||||
|
|
||||||
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
|
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
|
||||||
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
|
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
|
||||||
|
def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals):
|
||||||
|
if seen_min_n is not None:
|
||||||
|
seen_min_n.append(min_n_deals)
|
||||||
|
return qi_lookup_result
|
||||||
|
|
||||||
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
|
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
|
||||||
|
if seen_min_n is not None:
|
||||||
|
seen_min_n.append(min_n_deals)
|
||||||
if analog_indexes is not None:
|
if analog_indexes is not None:
|
||||||
return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes}
|
return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes}
|
||||||
if qi_lookup_result is None:
|
if qi_lookup_result is None:
|
||||||
|
|
@ -451,7 +462,7 @@ def _run_estimate_qi(
|
||||||
# Stub singular target-quarter lookup
|
# Stub singular target-quarter lookup
|
||||||
patch(
|
patch(
|
||||||
"app.services.estimator._lookup_quarter_index",
|
"app.services.estimator._lookup_quarter_index",
|
||||||
return_value=qi_lookup_result,
|
side_effect=_fake_lookup_index,
|
||||||
),
|
),
|
||||||
# Stub batched analog-quarter lookup
|
# Stub batched analog-quarter lookup
|
||||||
patch(
|
patch(
|
||||||
|
|
@ -532,11 +543,51 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
||||||
|
# #2379: уровень аналогов 1.0 — без гарда было бы ×1.5. Без явной карты стаб
|
||||||
|
# отдаёт аналогам индекс цели, factor=1.0, и тест зеленел при снятом гарде.
|
||||||
|
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
|
||||||
)
|
)
|
||||||
# Медиана НЕ должна изменяться
|
# Медиана НЕ должна изменяться
|
||||||
assert est.median_price_rub == base_median
|
assert est.median_price_rub == base_median
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard2_ratio_exactly_0_6_still_corrects() -> None:
|
||||||
|
"""#2379: граница Guard-2 строгая — 3 из 5 аналогов (0.6) в квартале цели не skip."""
|
||||||
|
analogs = [
|
||||||
|
_make_listing_qi(
|
||||||
|
price_per_m2=_BASE_PPM2,
|
||||||
|
building_cadastral_number=f"{_TARGET_QUARTER if i < 3 else _OTHER_QUARTER}:100",
|
||||||
|
floor=1 + i,
|
||||||
|
)
|
||||||
|
for i in range(5)
|
||||||
|
]
|
||||||
|
est = _run_estimate_qi(
|
||||||
|
analogs=analogs,
|
||||||
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
|
qi_lookup_result=(1.5, 30),
|
||||||
|
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
|
||||||
|
)
|
||||||
|
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quarter_lookups_filter_by_ten_deals() -> None:
|
||||||
|
"""#2379: оба lookup'а (квартал цели и кварталы аналогов) просят индекс от 10 сделок.
|
||||||
|
|
||||||
|
Порог уходит параметром в SQL (`n_deals >= :min_n`), в заглушке его не видно:
|
||||||
|
подмена числа на месте вызова проходила весь сьют.
|
||||||
|
"""
|
||||||
|
seen: list[int] = []
|
||||||
|
est = _run_estimate_qi(
|
||||||
|
analogs=_ANALOGS_OTHER_QUARTER,
|
||||||
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
|
qi_lookup_result=(1.2, 30),
|
||||||
|
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||||
|
seen_min_n=seen,
|
||||||
|
)
|
||||||
|
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.2) # оба lookup'а вызваны
|
||||||
|
assert seen == [10, 10]
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Sparse fallback: нет строки → no-op
|
# Sparse fallback: нет строки → no-op
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -659,19 +710,27 @@ def test_c3_one_indexed_analog_is_enough_to_correct() -> None:
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_bimodal_guard_skips_high_index_small_n() -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Bimodal guard: price_index=3.5, n_deals=20 → no-op."""
|
("target_qi", "expected_factor"),
|
||||||
analogs_no_cadnum = [
|
[
|
||||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i)
|
(3.5, 1.0), # index > 2.0 AND n < 50 → bimodal guard, no-op
|
||||||
for i in range(5)
|
(2.01, 1.0),
|
||||||
]
|
(2.0, 1.8), # ровно 2.0 — не больше порога: поправка есть, factor 2.0 → кламп 1.8
|
||||||
base_median = round(_BASE_PPM2 * _AREA)
|
],
|
||||||
|
)
|
||||||
|
def test_bimodal_guard_skips_high_index_small_n(target_qi: float, expected_factor: float) -> None:
|
||||||
|
"""Bimodal guard: price_index > 2.0 при n_deals=20 → no-op.
|
||||||
|
|
||||||
|
#2379: аналоги с ИЗВЕСТНЫМ уровнем 1.0. Раньше здесь были аналоги без кадастра —
|
||||||
|
поправку на них гасит и Guard-5 (#2583), и тест зеленел при снятом bimodal-гарде.
|
||||||
|
"""
|
||||||
est = _run_estimate_qi(
|
est = _run_estimate_qi(
|
||||||
analogs=analogs_no_cadnum,
|
analogs=_ANALOGS_OTHER_QUARTER,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
|
qi_lookup_result=(target_qi, 20),
|
||||||
|
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||||
)
|
)
|
||||||
assert est.median_price_rub == base_median
|
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * expected_factor)
|
||||||
|
|
||||||
|
|
||||||
def test_bimodal_guard_allows_high_index_large_n() -> None:
|
def test_bimodal_guard_allows_high_index_large_n() -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue