gendesign/tradein-mvp/backend/tests/test_estimator_quarter_index.py
bot-backend 51c79f9324
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 55s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
fix(tradein/estimator): analogs cards match n_analogs, no cross-source dups
Two display-layer bugs surfaced by pre-launch smoke (golden-path address
"Екатеринбург, улица Малышева, 51"):

1. n_analogs (same-building anchor path) was computed from the post-MAD-clip
   comp population (_compute_same_building_anchor: n = len(surviving ppm2)),
   but the displayed analogs cards were built from the ORIGINAL, un-clipped
   anchor_comps list. A MAD-clip-excluded price outlier (e.g. a cross-source
   duplicate priced ~4.5x above the rest) never affected the median/n_analogs,
   yet still showed up as a card.

   Fix: _compute_same_building_anchor now returns the final surviving "comps"
   list (len == n), and the caller sets anchor_comps_used = anchor["comps"]
   instead of the raw pre-clip anchor_comps.

2. The statistical cross-source dedup (_dedup_cross_source) requires the same
   price_bucket (rounded price_rub) to consider two listings duplicates. Real
   cross-posts of the same physical unit almost always have a small asking-
   price drift between platforms, which can straddle a bucket boundary and
   let the duplicate survive into both the price population and the UI.

   Fix: a new display-only dedup pass (_dedup_display_lots /
   _union_find_phys_dedup) uses the same physical key (cadnum-or-street +
   floor + area_bucket) but WITHOUT price_bucket, applied only when building
   the displayed analogs_lots — never touches n_analogs/median/cv. To avoid
   over-merging genuinely distinct same-building units that happen to share
   floor/area, it requires the two lots to come from DIFFERENT sources
   (require_diff_source guard) — same-source pairs are left untouched.

The radius-path (non-anchor) branch had a related count-consistency gap:
n_analogs counted only PRICED listings_clean entries, while the displayed
cards were sliced from the full (unfiltered) listings_clean, occasionally
letting price-less entries inflate the card count beyond n_analogs. Cards
are now built from the same priced subset.

Frozen backtest regression gate (tests/test_backtest_regression_gate.py,
hermetic replay of committed fixture) is byte-identical after this change —
zero delta on median/expected_sold/coverage/calibration, confirming this is
display-only and does not touch pricing.

Added tests/test_estimator_analogs_display_consistency.py: full-pipeline
regression for both root causes (outlier duplicate pair fully excluded from
both count and cards; non-outlier duplicate pair collapses in cards only,
honestly documenting len(analogs) <= n_analogs when the display-only dedup
catches a residual cross-source dup the stat dedup missed) + unit tests on
_dedup_display_lots. Updated test_estimator_quarter_index.py's hand-rolled
_compute_same_building_anchor test double to match the new "comps" contract.
2026-07-13 23:04:33 +03:00

965 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit tests for #764 — per-cadastral-quarter price index gap-correction.
Tests покрывают:
- _quarter_from_cadastre: парсинг кадастрового номера в квартал
- _apply_quarter_index: чистая математика корректировки (без БД)
- _lookup_quarter_index: DB-хелпер с мокнутой Session
- Guard-1: anchor_tier не None → no-op (same-building anchor)
- Guard-1b: IMV-blended → no-op
- Guard-2: >0.6 аналогов в целевом квартале → no-op
- Sparse fallback: нет строки / n_deals < min_n → no-op
- Bimodal guard: price_index>2.0 AND n_deals<50 → no-op
- Flag off → точное старое поведение
Паттерн: os.environ.setdefault перед импортом (как test_estimator_pure_units.py).
Чистые хелперы — без БД. DB-хелпер — мокнутая Session.
estimate_quality-level тесты — через anyio.run + полный stub-пач всех I/O.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import anyio
import pytest
from app.services.estimator import (
_apply_quarter_index,
_lookup_quarter_index,
_lookup_quarter_indexes,
_quarter_from_cadastre,
)
# ─────────────────────────────────────────────────────────────────────────────
# _quarter_from_cadastre
# ─────────────────────────────────────────────────────────────────────────────
def test_quarter_from_cadastre_standard() -> None:
"""Нормальный кадастровый номер дома → квартал (первые три части)."""
assert _quarter_from_cadastre("66:41:0204016:350") == "66:41:0204016"
def test_quarter_from_cadastre_7digit_block() -> None:
"""7-значный блок квартала → корректно."""
assert _quarter_from_cadastre("66:41:0401017:100") == "66:41:0401017"
def test_quarter_from_cadastre_none_input() -> None:
assert _quarter_from_cadastre(None) is None
def test_quarter_from_cadastre_empty_string() -> None:
assert _quarter_from_cadastre("") is None
def test_quarter_from_cadastre_too_few_parts() -> None:
assert _quarter_from_cadastre("66:41") is None
def test_quarter_from_cadastre_non_numeric_third_part() -> None:
"""Третья часть не числовая → None (не кадастровый квартал)."""
assert _quarter_from_cadastre("66:41:BADDATA:100") is None
def test_quarter_from_cadastre_no_fourth_part() -> None:
"""Три части без объекта — само по себе квартал: возвращаем как есть."""
result = _quarter_from_cadastre("66:41:0204016")
assert result == "66:41:0204016"
# ─────────────────────────────────────────────────────────────────────────────
# _apply_quarter_index — чистая математика
# ─────────────────────────────────────────────────────────────────────────────
def test_apply_quarter_index_basic_math() -> None:
"""Базовый кейс: target_index=1.2, avg_analog=1.0 → factor=1.2."""
ppm2, median, low, high, factor = _apply_quarter_index(
base_median_ppm2=100_000.0,
base_median_price=5_000_000,
base_range_low=4_000_000,
base_range_high=6_000_000,
target_index=1.2,
avg_analog_index=1.0,
)
assert abs(factor - 1.2) < 1e-9
assert abs(ppm2 - 120_000.0) < 1.0
assert median == round(5_000_000 * 1.2)
assert low == round(4_000_000 * 1.2)
assert high == round(6_000_000 * 1.2)
def test_apply_quarter_index_gap_correction() -> None:
"""Gap-correction: target=1.3, avg_analog=1.1 → factor=1.3/1.1 ≈ 1.182."""
_, median, _, _, factor = _apply_quarter_index(
base_median_ppm2=200_000.0,
base_median_price=10_000_000,
base_range_low=8_000_000,
base_range_high=12_000_000,
target_index=1.3,
avg_analog_index=1.1,
)
expected_factor = 1.3 / 1.1
assert abs(factor - expected_factor) < 1e-9
assert median == round(10_000_000 * expected_factor)
def test_apply_quarter_index_same_quarter_noop() -> None:
"""target_index == avg_analog_index → factor=1.0, медиана не меняется."""
_, median, low, high, factor = _apply_quarter_index(
base_median_ppm2=150_000.0,
base_median_price=7_500_000,
base_range_low=6_000_000,
base_range_high=9_000_000,
target_index=1.05,
avg_analog_index=1.05,
)
assert abs(factor - 1.0) < 1e-9
assert median == 7_500_000
assert low == 6_000_000
assert high == 9_000_000
def test_apply_quarter_index_downcorrection() -> None:
"""target_index < avg_analog_index → factor < 1.0 (коррекция вниз тоже работает)."""
_, median, _, _, factor = _apply_quarter_index(
base_median_ppm2=100_000.0,
base_median_price=5_000_000,
base_range_low=4_000_000,
base_range_high=6_000_000,
target_index=0.9,
avg_analog_index=1.0,
)
assert factor < 1.0
assert median == round(5_000_000 * 0.9)
# ─────────────────────────────────────────────────────────────────────────────
# _lookup_quarter_index — мокнутая Session
# ─────────────────────────────────────────────────────────────────────────────
def test_lookup_quarter_index_returns_row() -> None:
"""Нормальная строка → возвращает (price_index, n_deals)."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.first.return_value = {
"price_index": 1.25,
"n_deals": 42,
}
result = _lookup_quarter_index(mock_db, quarter_cad_number="66:41:0204016", min_n_deals=10)
assert result is not None
qi, n = result
assert abs(qi - 1.25) < 1e-9
assert n == 42
def test_lookup_quarter_index_none_when_no_row() -> None:
"""Нет строки → None."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.first.return_value = None
result = _lookup_quarter_index(mock_db, quarter_cad_number="66:41:9999999", min_n_deals=10)
assert result is None
def test_lookup_quarter_index_fdw_exception_graceful() -> None:
"""FDW exception → None (graceful, no re-raise)."""
mock_db = MagicMock()
mock_db.execute.side_effect = RuntimeError("FDW connection refused")
result = _lookup_quarter_index(mock_db, quarter_cad_number="66:41:0204016", min_n_deals=10)
assert result is None
def test_lookup_quarter_index_no_cast_colon_colon_in_sql() -> None:
"""SQL текст хелпера не должен содержать :x::type (psycopg v3 rule)."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.first.return_value = None
_lookup_quarter_index(mock_db, quarter_cad_number="66:41:0204016", min_n_deals=10)
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
# psycopg v3: CAST(:x AS type), never :x::type
import re
assert not re.search(r":[a-z_]+::[a-z]", sql_text), f"::type cast found in SQL: {sql_text}"
# ─────────────────────────────────────────────────────────────────────────────
# _lookup_quarter_indexes (plural) — батч-хелпер
# ─────────────────────────────────────────────────────────────────────────────
def test_lookup_quarter_indexes_returns_dict() -> None:
"""Нормальный результат → словарь {quarter: price_index}."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"quarter_cad_number": "66:41:0204016", "price_index": 1.25},
{"quarter_cad_number": "66:41:9998888", "price_index": 0.95},
]
result = _lookup_quarter_indexes(
mock_db,
quarter_cad_numbers=["66:41:0204016", "66:41:9998888"],
min_n_deals=10,
)
assert result == {"66:41:0204016": 1.25, "66:41:9998888": 0.95}
def test_lookup_quarter_indexes_empty_input_returns_empty() -> None:
"""Пустой список кварталов → {} без обращения к БД."""
mock_db = MagicMock()
result = _lookup_quarter_indexes(mock_db, quarter_cad_numbers=[], min_n_deals=10)
assert result == {}
mock_db.execute.assert_not_called()
def test_lookup_quarter_indexes_fdw_exception_returns_empty() -> None:
"""FDW exception → {} (graceful, no re-raise)."""
mock_db = MagicMock()
mock_db.execute.side_effect = RuntimeError("FDW connection refused")
result = _lookup_quarter_indexes(
mock_db,
quarter_cad_numbers=["66:41:0204016"],
min_n_deals=10,
)
assert result == {}
def test_lookup_quarter_indexes_deduplicates_input() -> None:
"""Дублирующиеся кварталы в списке — передаются в БД без дублей."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"quarter_cad_number": "66:41:0204016", "price_index": 1.1},
]
_lookup_quarter_indexes(
mock_db,
quarter_cad_numbers=["66:41:0204016", "66:41:0204016", "66:41:0204016"],
min_n_deals=5,
)
passed_params = mock_db.execute.call_args[0][1]
assert passed_params["quarters"] == ["66:41:0204016"]
def test_lookup_quarter_indexes_no_cast_colon_colon_in_sql() -> None:
"""Батч-хелпер: SQL не содержит :x::type (psycopg v3 rule)."""
import re
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = []
_lookup_quarter_indexes(
mock_db,
quarter_cad_numbers=["66:41:0204016"],
min_n_deals=10,
)
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
assert not re.search(r":[a-z_]+::[a-z]", sql_text), f"::type cast found in SQL: {sql_text}"
def test_lookup_quarter_indexes_uses_any_cast_array_idiom() -> None:
"""SQL батч-хелпера содержит ANY(CAST(:quarters AS varchar[])) — pgpsycopg3 idiom."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = []
_lookup_quarter_indexes(
mock_db,
quarter_cad_numbers=["66:41:0204016"],
min_n_deals=10,
)
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
assert "ANY(CAST(:quarters AS varchar[]))" in sql_text
def test_lookup_quarter_indexes_multi_quarter_factor_matches_single() -> None:
"""Батч возвращает те же значения, что N одиночных вызовов — математика идентична.
Аналоги: 2 лота из квартала A (index=1.2, ppm2=100k),
1 лот из квартала B (index=0.8, ppm2=200k).
Ожидаемый avg_analog_index = (100k*1.2 + 100k*1.2 + 200k*0.8) / (100k+100k+200k)
= (120k + 120k + 160k) / 400k = 400k/400k = 1.0.
"""
index_map = {"66:41:AAAAAAA": 1.2, "66:41:BBBBBBB": 0.8}
analog_lots = [
("66:41:AAAAAAA", 100_000.0),
("66:41:AAAAAAA", 100_000.0),
("66:41:BBBBBBB", 200_000.0),
]
weighted_sum = sum(ppm2 * index_map[q] for q, ppm2 in analog_lots)
weight_total = sum(ppm2 for _, ppm2 in analog_lots)
avg_analog_index = weighted_sum / weight_total
assert abs(avg_analog_index - 1.0) < 1e-9, f"Expected 1.0, got {avg_analog_index}"
# ─────────────────────────────────────────────────────────────────────────────
# Helpers for estimate_quality integration tests (full I/O stub)
# ─────────────────────────────────────────────────────────────────────────────
_BASE_PPM2 = 150_000.0
_AREA = 40.0
def _make_listing_qi(
*,
price_per_m2: float = _BASE_PPM2,
area_m2: float = _AREA,
building_cadastral_number: str | None = None,
) -> dict[str, Any]:
from datetime import UTC, datetime
price_rub = price_per_m2 * area_m2
return {
"source": "cian",
"source_url": "https://cian.ru/offer/1",
"address": "ЕКБ, ул. Тестовая, 5",
"lat": 56.838,
"lon": 60.595,
"rooms": 1,
"area_m2": area_m2,
"floor": 4,
"total_floors": 16,
"price_rub": price_rub,
"price_per_m2": price_per_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 10,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 100.0,
"relevance_score": 0.1,
"building_cadastral_number": building_cadastral_number,
}
def _make_fake_geo_qi():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Тестовая, 5",
provider="nominatim",
)
def _make_payload_qi(rooms: int = 1, area_m2: float = _AREA):
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Тестовая, 5",
area_m2=area_m2,
rooms=rooms,
floor=4,
total_floors=16,
)
def _make_fake_dadata(house_cadnum: str | None):
"""Minimal DadataAddressResult stub с нужным house_cadnum."""
from app.services.dadata import DadataAddressResult
return DadataAddressResult(
canonical_address="Свердловская обл., Екатеринбург, ул. Тестовая, 5",
house_cadnum=house_cadnum,
house_fias_id=None,
lat=56.838,
lon=60.595,
qc_geo=1,
qc_house=1,
kladr_id=None,
okato=None,
oktmo=None,
metro=[],
raw={},
)
def _run_estimate_qi(
analogs: list[dict[str, Any]],
dadata_cadnum: str | None,
qi_lookup_result: tuple[float, int] | None,
*,
anchor_tier_override: str | None = None,
):
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate."""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload_qi()
dadata_obj = _make_fake_dadata(dadata_cadnum) if dadata_cadnum is not None else None
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
if qi_lookup_result is None:
return {}
return {q: qi_lookup_result[0] for q in quarter_cad_numbers}
async def _run():
with (
patch(
"app.services.estimator.geocode",
new=AsyncMock(return_value=_make_fake_geo_qi()),
),
patch(
"app.services.estimator.dadata_clean_address",
new=AsyncMock(return_value=dadata_obj),
),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(analogs), False, "W"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_asking_sold_ratio",
return_value=(None, None),
),
patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
# Stub singular target-quarter lookup
patch(
"app.services.estimator._lookup_quarter_index",
return_value=qi_lookup_result,
),
# Stub batched analog-quarter lookup
patch(
"app.services.estimator._lookup_quarter_indexes",
side_effect=_fake_lookup_indexes,
),
):
return await estimate_quality(payload, db)
return anyio.run(_run)
# ─────────────────────────────────────────────────────────────────────────────
# Integration: gap-correction applied in pure-radius path
# ─────────────────────────────────────────────────────────────────────────────
_OTHER_QUARTER = "66:41:9998888"
_TARGET_QUARTER = "66:41:0204016"
_ANALOGS_OTHER_QUARTER = [
_make_listing_qi(
price_per_m2=_BASE_PPM2,
building_cadastral_number=f"{_OTHER_QUARTER}:100",
)
for _ in range(3)
]
def test_quarter_index_correction_applied() -> None:
"""Gap-correction срабатывает в pure-radius пути.
target_index=1.2, avg_analog_index=1.0 (аналоги без известного квартала →
avg=1.0) → factor=1.2 → median должен вырасти на ×1.2.
"""
base_median = round(_BASE_PPM2 * _AREA) # 6_000_000
# Аналоги из ДРУГОГО квартала (building_cadastral_number = OTHER_QUARTER:100)
# _lookup_quarter_index для аналогов вернёт тот же (1.2, 30) что и для target —
# avg_analog_index = 1.2, factor = 1.2/1.2 = 1.0 (no change!).
# Чтобы увидеть ненулевую коррекцию, делаем аналоги БЕЗ кадастрового номера
# → avg_analog_index = 1.0 → factor = 1.2.
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
]
est = _run_estimate_qi(
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.2, 30),
)
expected_median = round(base_median * 1.2)
assert est.median_price_rub == expected_median
# Disclosure должна содержать упоминание квартала
assert est.confidence_explanation is not None
assert "квартал" in est.confidence_explanation.lower()
# ─────────────────────────────────────────────────────────────────────────────
# Guard-2: >0.6 аналогов в целевом квартале → no-op
# ─────────────────────────────────────────────────────────────────────────────
def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
"""Guard-2: >60% аналогов из целевого квартала → индекс не применяется."""
# 4 аналога в target квартале, 1 в другом → ratio = 4/5 = 0.8 > 0.6 → skip
target_cadnum = f"{_TARGET_QUARTER}:100"
other_cadnum = f"{_OTHER_QUARTER}:100"
analogs = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum),
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum),
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum),
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum),
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=other_cadnum),
]
base_median = round(_BASE_PPM2 * _AREA)
est = _run_estimate_qi(
analogs=analogs,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
)
# Медиана НЕ должна изменяться
assert est.median_price_rub == base_median
# ─────────────────────────────────────────────────────────────────────────────
# Sparse fallback: нет строки → no-op
# ─────────────────────────────────────────────────────────────────────────────
def test_sparse_fallback_no_row_noop() -> None:
"""_lookup_quarter_index вернул None → no-op, медиана не меняется."""
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
]
base_median = round(_BASE_PPM2 * _AREA)
est = _run_estimate_qi(
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=None, # sparse
)
assert est.median_price_rub == base_median
# ─────────────────────────────────────────────────────────────────────────────
# Bimodal guard: price_index>2.0 AND n_deals<50 → no-op
# ─────────────────────────────────────────────────────────────────────────────
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) for _ in range(3)
]
base_median = round(_BASE_PPM2 * _AREA)
est = _run_estimate_qi(
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
)
assert est.median_price_rub == base_median
def test_bimodal_guard_allows_high_index_large_n() -> None:
"""Bimodal guard НЕ срабатывает при price_index>2.0 если n_deals>=50.
Коррекция применяется, но raw factor=2.5 зажат #859-clamp до max_factor=1.8.
Медиана меняется (guard не блокирует), но масштабируется на 1.8, не 2.5.
"""
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
]
base_median = round(_BASE_PPM2 * _AREA)
est = _run_estimate_qi(
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
)
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
assert est.median_price_rub != base_median
assert est.median_price_rub == round(base_median * 1.8)
# ─────────────────────────────────────────────────────────────────────────────
# Guard-1a: anchor_tier не None → correction не применяется
# ─────────────────────────────────────────────────────────────────────────────
def test_guard1_anchor_tier_prevents_correction() -> None:
"""Guard-1a: когда same-building anchor сработал (anchor_tier='A'),
квартальный индекс не применяется (double-count guard).
Стабим _compute_same_building_anchor, чтобы вернул непустой anchor dict
и _fetch_anchor_comps вернул comps → anchor_tier будет 'A'.
"""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload_qi()
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
fake_comps = [
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
for _ in range(3)
]
fake_anchor = {
"anchor_ppm2": _BASE_PPM2 * 1.5,
"anchor_sold_ppm2": _BASE_PPM2 * 1.4,
"fsd": 0.05,
"confidence": "high",
"n": 3,
"cv": 0.05,
"comp_min_ppm2": _BASE_PPM2 * 1.3,
"comp_max_ppm2": _BASE_PPM2 * 1.7,
"used_uplift": False,
"haircut": 0.05,
# display-consistency fix: contract now requires "comps" — the final
# post-MAD-clip survivors (see _compute_same_building_anchor docstring).
"comps": fake_comps,
}
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
]
async def _run():
with (
patch(
"app.services.estimator.geocode",
new=AsyncMock(return_value=_make_fake_geo_qi()),
),
patch(
"app.services.estimator.dadata_clean_address",
new=AsyncMock(return_value=dadata_obj),
),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(analogs_no_cadnum), False, "W"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_asking_sold_ratio",
return_value=(None, None),
),
patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
# same-building anchor FIRES → anchor_tier = 'A'
patch(
"app.services.estimator._fetch_anchor_comps",
return_value=(fake_comps, "A"),
),
patch(
"app.services.estimator._compute_same_building_anchor",
return_value=fake_anchor,
),
patch(
"app.services.estimator._lookup_quarter_index",
return_value=(1.5, 30), # would apply if not guarded
),
):
return await estimate_quality(payload, db)
est = anyio.run(_run)
# Медиана от anchor = anchor_ppm2 * repair_coef(=1.0 нет ремонта) * area
anchor_median = round(_BASE_PPM2 * 1.5 * _AREA)
assert est.median_price_rub == anchor_median
# explanation не содержит упоминания квартала (guard-1 сработал)
assert (
est.confidence_explanation is None
or "квартал" not in (est.confidence_explanation or "").lower()
)
# ─────────────────────────────────────────────────────────────────────────────
# Guard-1b: IMV-blended → correction не применяется
# ─────────────────────────────────────────────────────────────────────────────
def test_guard1b_imv_blend_prevents_correction() -> None:
"""Guard-1b: IMV-blend повышал медиану → квартальный индекс не применяется."""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload_qi()
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
]
# IMV anchor сильно выше медианы → blend сработает
imv_anchor = {
"recommended_price": 30_000_000, # ≫ base_median 6М × 1.15
"lower_price": 25_000_000,
"higher_price": 35_000_000,
"market_count": 500,
"rooms": 1,
"area_m2": _AREA,
}
async def _run():
with (
patch(
"app.services.estimator.geocode",
new=AsyncMock(return_value=_make_fake_geo_qi()),
),
patch(
"app.services.estimator.dadata_clean_address",
new=AsyncMock(return_value=dadata_obj),
),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(analogs_no_cadnum), False, "W"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_asking_sold_ratio",
return_value=(None, None),
),
# IMV anchor fires (returns non-None) → blend will trigger
patch(
"app.services.estimator._fetch_house_imv_anchor",
return_value=imv_anchor,
),
patch(
"app.services.estimator._lookup_quarter_index",
return_value=(1.5, 30), # would apply if not guarded
),
):
return await estimate_quality(payload, db)
est = anyio.run(_run)
# IMV blend: base 6М, anchor 30М, w=0.5 → 18М
blended_median = round(6_000_000 * 0.5 + 30_000_000 * 0.5)
assert est.median_price_rub == blended_median
# explanation не должна содержать квартального дисклоужера
assert "квартал" not in (est.confidence_explanation or "").lower()
def test_guard1b_imv_anchor_below_blend_threshold_prevents_correction() -> None:
"""Guard-1b: IMV anchor присутствует но ниже blend-порога (blended=False).
До фикса #764: imv_blended=False → квартальный индекс применялся поверх
IMV-расширенного range_high (double-influence). После фикса: imv_anchor_present=True
→ quarter index не применяется вне зависимости от blended.
"""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload_qi()
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
]
# IMV anchor НИЖЕ blend-порога: base_median = 6_000_000, threshold=1.15 → порог 6.9М.
# anchor=6_500_000 < 6.9М → blended=False, но range_high IMV-расширен.
imv_anchor_below_threshold = {
"recommended_price": 6_500_000,
"lower_price": 5_800_000,
"higher_price": 7_200_000,
"market_count": 100,
"rooms": 1,
"area_m2": _AREA,
}
async def _run():
with (
patch(
"app.services.estimator.geocode",
new=AsyncMock(return_value=_make_fake_geo_qi()),
),
patch(
"app.services.estimator.dadata_clean_address",
new=AsyncMock(return_value=dadata_obj),
),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(analogs_no_cadnum), False, "W"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_asking_sold_ratio",
return_value=(None, None),
),
# IMV anchor returns below-threshold value → blended=False but anchor_present=True
patch(
"app.services.estimator._fetch_house_imv_anchor",
return_value=imv_anchor_below_threshold,
),
patch(
"app.services.estimator._lookup_quarter_index",
return_value=(1.5, 30), # would apply factor=1.5 if not guarded
),
):
return await estimate_quality(payload, db)
est = anyio.run(_run)
# Медиана не должна быть умножена на 1.5 (квартальный индекс заблокирован).
base_median = round(_BASE_PPM2 * _AREA) # 6_000_000
assert est.median_price_rub == base_median
# explanation не содержит квартального дисклоужера
assert "квартал" not in (est.confidence_explanation or "").lower()
# ─────────────────────────────────────────────────────────────────────────────
# #859 — sanity-clamp на factor = target_index / avg_analog_index
# ─────────────────────────────────────────────────────────────────────────────
def test_apply_quarter_index_clamp_extreme_high_raw_factor() -> None:
"""Экстремально низкий avg_analog_index → raw factor >> max → зажат до max_factor.
target_index=1.0, avg_analog_index=0.3 → raw=3.33, clamped→1.8.
Выходы масштабируются на 1.8, не на 3.33.
"""
max_f = 1.8
ppm2, median, low, high, factor = _apply_quarter_index(
base_median_ppm2=100_000.0,
base_median_price=5_000_000,
base_range_low=4_000_000,
base_range_high=6_000_000,
target_index=1.0,
avg_analog_index=0.3,
min_factor=0.6,
max_factor=max_f,
)
assert abs(factor - max_f) < 1e-9, f"Expected factor={max_f}, got {factor}"
assert abs(ppm2 - 100_000.0 * max_f) < 1.0
assert median == round(5_000_000 * max_f)
assert low == round(4_000_000 * max_f)
assert high == round(6_000_000 * max_f)
# Убеждаемся, что raw factor действительно был бы за пределами clamp
raw = 1.0 / 0.3
assert raw > max_f
def test_apply_quarter_index_clamp_extreme_low_raw_factor() -> None:
"""Экстремально высокий avg_analog_index → raw factor << min → зажат до min_factor.
target_index=0.5, avg_analog_index=2.0 → raw=0.25, clamped→0.6.
Выходы масштабируются на 0.6, не на 0.25.
"""
min_f = 0.6
ppm2, median, low, high, factor = _apply_quarter_index(
base_median_ppm2=100_000.0,
base_median_price=5_000_000,
base_range_low=4_000_000,
base_range_high=6_000_000,
target_index=0.5,
avg_analog_index=2.0,
min_factor=min_f,
max_factor=1.8,
)
assert abs(factor - min_f) < 1e-9, f"Expected factor={min_f}, got {factor}"
assert abs(ppm2 - 100_000.0 * min_f) < 1.0
assert median == round(5_000_000 * min_f)
assert low == round(4_000_000 * min_f)
assert high == round(6_000_000 * min_f)
# Убеждаемся, что raw factor действительно был бы за пределами clamp
raw = 0.5 / 2.0
assert raw < min_f
def test_apply_quarter_index_clamp_normal_factor_unchanged() -> None:
"""Нормальный factor внутри [0.6, 1.8] → clamp не меняет значение (регрессия).
target_index=1.3, avg_analog_index=1.1 → raw=1.182, внутри [0.6,1.8] → без изменений.
"""
raw_expected = 1.3 / 1.1
ppm2, median, _low, _high, factor = _apply_quarter_index(
base_median_ppm2=200_000.0,
base_median_price=10_000_000,
base_range_low=8_000_000,
base_range_high=12_000_000,
target_index=1.3,
avg_analog_index=1.1,
min_factor=0.6,
max_factor=1.8,
)
assert abs(factor - raw_expected) < 1e-9, f"Expected {raw_expected}, got {factor}"
assert abs(ppm2 - 200_000.0 * raw_expected) < 1.0
assert median == round(10_000_000 * raw_expected)
def test_apply_quarter_index_clamp_boundary_at_max_exact() -> None:
"""factor точно на верхней границе (=1.8) → clamp не применяется."""
# target=1.8, avg=1.0 → raw=1.8 ровно = max_factor
_ppm2, median, _low, _high, factor = _apply_quarter_index(
base_median_ppm2=100_000.0,
base_median_price=5_000_000,
base_range_low=4_000_000,
base_range_high=6_000_000,
target_index=1.8,
avg_analog_index=1.0,
min_factor=0.6,
max_factor=1.8,
)
assert abs(factor - 1.8) < 1e-9
assert median == round(5_000_000 * 1.8)
def test_apply_quarter_index_clamp_boundary_at_min_exact() -> None:
"""factor точно на нижней границе (=0.6) → clamp не применяется."""
# target=0.6, avg=1.0 → raw=0.6 ровно = min_factor
_ppm2, median, _low, _high, factor = _apply_quarter_index(
base_median_ppm2=100_000.0,
base_median_price=5_000_000,
base_range_low=4_000_000,
base_range_high=6_000_000,
target_index=0.6,
avg_analog_index=1.0,
min_factor=0.6,
max_factor=1.8,
)
assert abs(factor - 0.6) < 1e-9
assert median == round(5_000_000 * 0.6)
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))