МЕРА: настройки оценщика, которые никогда не менялись, стали кодом — 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,
|
||||
analog_indexes: dict[str, float] | None = None,
|
||||
imv_anchor: dict[str, Any] | None = None,
|
||||
seen_min_n: list[int] | None = None,
|
||||
):
|
||||
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
|
||||
|
||||
|
|
@ -396,6 +397,9 @@ def _run_estimate_qi(
|
|||
когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё
|
||||
батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0.
|
||||
Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога.
|
||||
|
||||
seen_min_n — сюда пишется min_n_deals каждого вызова обоих lookup'ов: порог
|
||||
фильтрует строки в SQL, и заглушка его иначе теряет.
|
||||
"""
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
|
|
@ -406,7 +410,14 @@ def _run_estimate_qi(
|
|||
|
||||
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
|
||||
# что 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):
|
||||
if seen_min_n is not None:
|
||||
seen_min_n.append(min_n_deals)
|
||||
if analog_indexes is not None:
|
||||
return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes}
|
||||
if qi_lookup_result is None:
|
||||
|
|
@ -451,7 +462,7 @@ def _run_estimate_qi(
|
|||
# Stub singular target-quarter lookup
|
||||
patch(
|
||||
"app.services.estimator._lookup_quarter_index",
|
||||
return_value=qi_lookup_result,
|
||||
side_effect=_fake_lookup_index,
|
||||
),
|
||||
# Stub batched analog-quarter lookup
|
||||
patch(
|
||||
|
|
@ -532,11 +543,51 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
|
|||
analogs=analogs,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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:
|
||||
"""Bimodal guard: price_index=3.5, n_deals=20 → no-op."""
|
||||
analogs_no_cadnum = [
|
||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i)
|
||||
for i in range(5)
|
||||
]
|
||||
base_median = round(_BASE_PPM2 * _AREA)
|
||||
@pytest.mark.parametrize(
|
||||
("target_qi", "expected_factor"),
|
||||
[
|
||||
(3.5, 1.0), # index > 2.0 AND n < 50 → bimodal guard, no-op
|
||||
(2.01, 1.0),
|
||||
(2.0, 1.8), # ровно 2.0 — не больше порога: поправка есть, factor 2.0 → кламп 1.8
|
||||
],
|
||||
)
|
||||
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(
|
||||
analogs=analogs_no_cadnum,
|
||||
analogs=_ANALOGS_OTHER_QUARTER,
|
||||
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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue