The tier_aware_ratio_enabled path (#928) binned SOLD deals by sold-ppm² against ASKING-derived percentile bounds, then divided within-tier medians. That is a ratio-of-truncated-medians ARTIFACT: a Monte-Carlo with a CONSTANT true sold/asking=0.84 reproduced the prod tier values (0.94/0.99/0.96) and the skewed deal split (57/27/15%) exactly — the "premium sells closer to asking" gradient is 100% spurious. Flipping the flag ON made prod WORSE (overall MAPE 14.6→17.2, эконом bias +5.8→+18.1). It shipped dark (default False) but is a latent footgun. A valid within-price-tier sold/asking is uncomputable from asking-less ДКП deals; the per-rooms blend + the shipped hedonic (year+area) are the correct conditioning. So the dead path is removed entirely, not just disabled. - estimator._get_asking_sold_ratio: drop the tier branch (bounds read, t33/t66, asking_to_sold_ratios_tiered read, empirical-Bayes shrink) + the _legacy/tier cache-key split; keep only the legacy per-rooms → global -1 lookup. Cache key collapses to rooms bucket. anchor_ppm2 param retained for call-site compat (now unused). - config: remove tier_aware_ratio_enabled + tier_ratio_shrink_k settings. - tasks/asking_to_sold_ratio: drop the tiered refresh (DELETE/re-derive bounds + tier rows, tiered counters); the daily task no longer touches the dead tier tables. Legacy asking_to_sold_ratios DELETE+re-derive intact. - tests: delete the tier-ratio unit test file (legacy path covered by test_estimator_expected_sold.py Layer 1); fix the daily-refresh fake-db (8→3 execute calls, 3 counter keys); drop the two segment-guard tests that asserted the removed tiered SQL; clean a dead per_rooms_tier basis in fixtures. Tier tables asking_to_sold_ratios_tiered / asking_to_sold_tier_bounds + migration 098 are left in place (inert once nothing reads/writes them); a DROP migration is a separate, optional follow-up. Regression gate stays byte-identical (flag was False in prod → active path unchanged). Refs #2002
311 lines
13 KiB
Python
311 lines
13 KiB
Python
"""Tests for Fix 1: asking_to_sold ratio tier resolved from FINAL headline ppm².
|
||
|
||
Проверяем, что _get_asking_sold_ratio вызывается ПОСЛЕ всех headline-мутаций
|
||
(anchor/IMV-blend/quarter-index/corridor-clamp) с финальным median_ppm2, а не с
|
||
pre-anchor радиусной медианой. Это устраняет tier-несовпадение (#928 audit fix).
|
||
|
||
Два слоя:
|
||
1. Стохастический: _get_asking_sold_ratio не вызывается в ранней части estimate_quality
|
||
(только после corridor-clamp/radius-floor).
|
||
2. Функциональный: когда anchor поднял headline в другой tier, ratio берётся
|
||
по финальному ppm² (из нового tier), а не по исходной радиусной медиане.
|
||
Graceful: нет ratio → expected_sold_* == None, headline не изменён.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import anyio
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
|
||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
|
||
return {
|
||
"source": "cian",
|
||
"source_url": "https://cian.ru/offer/1",
|
||
"address": "ЕКБ, ул. Учителей, 18",
|
||
"lat": 56.838,
|
||
"lon": 60.595,
|
||
"rooms": 2,
|
||
"area_m2": area_m2,
|
||
"floor": 5,
|
||
"total_floors": 16,
|
||
"price_rub": price_per_m2 * area_m2,
|
||
"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": 150.0,
|
||
"relevance_score": 0.1,
|
||
}
|
||
|
||
|
||
_ANALOGS_LOW: list[dict[str, Any]] = [
|
||
_make_listing(price_per_m2=100_000.0),
|
||
_make_listing(price_per_m2=110_000.0),
|
||
_make_listing(price_per_m2=105_000.0),
|
||
]
|
||
|
||
_ANALOGS_HIGH: list[dict[str, Any]] = [
|
||
_make_listing(price_per_m2=300_000.0),
|
||
_make_listing(price_per_m2=310_000.0),
|
||
_make_listing(price_per_m2=305_000.0),
|
||
]
|
||
|
||
|
||
def _make_geo():
|
||
from app.services.geocoder import GeocodeResult
|
||
|
||
return GeocodeResult(
|
||
lat=56.838,
|
||
lon=60.595,
|
||
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||
provider="nominatim",
|
||
)
|
||
|
||
|
||
def _make_payload(rooms: int = 2):
|
||
from app.schemas.trade_in import TradeInEstimateInput
|
||
|
||
return TradeInEstimateInput(
|
||
address="ЕКБ, ул. Учителей, 18",
|
||
area_m2=50.0,
|
||
rooms=rooms,
|
||
floor=5,
|
||
total_floors=16,
|
||
)
|
||
|
||
|
||
def _run_estimate_with_ratio_spy(
|
||
analogs: list[dict[str, Any]],
|
||
ratio_return: tuple[float | None, str | None],
|
||
) -> tuple[Any, list[Any]]:
|
||
"""Запускает estimate_quality с отслеживанием вызовов _get_asking_sold_ratio.
|
||
|
||
Возвращает (estimate_result, list_of_call_args).
|
||
"""
|
||
from app.services.estimator import estimate_quality
|
||
|
||
db = MagicMock()
|
||
payload = _make_payload()
|
||
|
||
ratio_calls: list[Any] = []
|
||
|
||
def _spy_ratio(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> Any:
|
||
ratio_calls.append({"rooms": rooms, "anchor_ppm2": anchor_ppm2})
|
||
return ratio_return
|
||
|
||
async def _run() -> Any:
|
||
with (
|
||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
|
||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||
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, "S"),
|
||
),
|
||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||
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._fetch_dkp_corridor", return_value=None),
|
||
patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy_ratio),
|
||
):
|
||
return await estimate_quality(payload, db)
|
||
|
||
est = anyio.run(_run)
|
||
return est, ratio_calls
|
||
|
||
|
||
# ── тест 1: ratio вызывается РОВНО ОДИН РАЗ (не дважды — до и после headline) ──
|
||
|
||
|
||
def test_ratio_called_exactly_once() -> None:
|
||
"""_get_asking_sold_ratio должен вызываться ровно один раз — после headline."""
|
||
_est, calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (0.80, "per_rooms"))
|
||
assert (
|
||
len(calls) == 1
|
||
), f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз"
|
||
|
||
|
||
# ── тест 2: anchor поднял headline → ratio вызван с финальным (high) ppm² ────
|
||
|
||
|
||
def test_ratio_tier_uses_final_headline_after_anchor() -> None:
|
||
"""Когда anchor поднял median_ppm2 с ~105k до ~300k, ratio вызывается с ~300k."""
|
||
from app.services.estimator import estimate_quality
|
||
|
||
db = MagicMock()
|
||
payload = _make_payload()
|
||
|
||
# Симулируем: radius median = 105k, anchor поднимает до 300k.
|
||
# _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k).
|
||
captured_anchor_ppm2: list[float | None] = []
|
||
|
||
def _spy(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> tuple:
|
||
captured_anchor_ppm2.append(anchor_ppm2)
|
||
return (0.78, "per_rooms")
|
||
|
||
async def _run() -> Any:
|
||
with (
|
||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
|
||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||
# Radius analogs дают медиану ~105k.
|
||
patch(
|
||
"app.services.estimator._fetch_analogs",
|
||
return_value=(list(_ANALOGS_LOW), False, "S"),
|
||
),
|
||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||
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._fetch_dkp_corridor", return_value=None),
|
||
# Same-building anchor поднимает headline до ~300k.
|
||
patch(
|
||
"app.services.estimator._fetch_anchor_comps",
|
||
return_value=(
|
||
[
|
||
{
|
||
"price_per_m2": 300_000,
|
||
"area_m2": 50.0,
|
||
"rooms": 2,
|
||
"source": "cian",
|
||
"source_url": "u",
|
||
"address": "a",
|
||
"lat": 56.838,
|
||
"lon": 60.595,
|
||
"floor": 5,
|
||
"total_floors": 16,
|
||
"listing_date": datetime(2026, 5, 1),
|
||
"photo_urls": [],
|
||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||
"distance_m": 5.0,
|
||
"relevance_score": 0.9,
|
||
"price_rub": 15_000_000,
|
||
"building_cadastral_number": None,
|
||
"days_on_market": 5,
|
||
},
|
||
{
|
||
"price_per_m2": 305_000,
|
||
"area_m2": 50.0,
|
||
"rooms": 2,
|
||
"source": "cian",
|
||
"source_url": "u2",
|
||
"address": "a",
|
||
"lat": 56.838,
|
||
"lon": 60.595,
|
||
"floor": 6,
|
||
"total_floors": 16,
|
||
"listing_date": datetime(2026, 5, 1),
|
||
"photo_urls": [],
|
||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||
"distance_m": 6.0,
|
||
"relevance_score": 0.9,
|
||
"price_rub": 15_250_000,
|
||
"building_cadastral_number": None,
|
||
"days_on_market": 5,
|
||
},
|
||
{
|
||
"price_per_m2": 298_000,
|
||
"area_m2": 50.0,
|
||
"rooms": 2,
|
||
"source": "cian",
|
||
"source_url": "u3",
|
||
"address": "a",
|
||
"lat": 56.838,
|
||
"lon": 60.595,
|
||
"floor": 4,
|
||
"total_floors": 16,
|
||
"listing_date": datetime(2026, 5, 1),
|
||
"photo_urls": [],
|
||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||
"distance_m": 7.0,
|
||
"relevance_score": 0.9,
|
||
"price_rub": 14_900_000,
|
||
"building_cadastral_number": None,
|
||
"days_on_market": 5,
|
||
},
|
||
{
|
||
"price_per_m2": 302_000,
|
||
"area_m2": 50.0,
|
||
"rooms": 2,
|
||
"source": "cian",
|
||
"source_url": "u4",
|
||
"address": "a",
|
||
"lat": 56.838,
|
||
"lon": 60.595,
|
||
"floor": 7,
|
||
"total_floors": 16,
|
||
"listing_date": datetime(2026, 5, 1),
|
||
"photo_urls": [],
|
||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||
"distance_m": 8.0,
|
||
"relevance_score": 0.9,
|
||
"price_rub": 15_100_000,
|
||
"building_cadastral_number": None,
|
||
"days_on_market": 5,
|
||
},
|
||
],
|
||
"A",
|
||
),
|
||
),
|
||
patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy),
|
||
):
|
||
return await estimate_quality(payload, db)
|
||
|
||
anyio.run(_run)
|
||
|
||
assert len(captured_anchor_ppm2) == 1
|
||
ppm2_used = captured_anchor_ppm2[0]
|
||
assert ppm2_used is not None
|
||
# Финальный headline должен быть в зоне anchor (~300k), а НЕ в зоне radius (~105k).
|
||
assert (
|
||
ppm2_used > 200_000
|
||
), f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}"
|
||
|
||
|
||
# ── тест 3: graceful — нет ratio → expected_sold_* = None, headline не изменён ──
|
||
|
||
|
||
def test_ratio_graceful_none_no_expected_sold() -> None:
|
||
"""Если ratio = None → все expected_sold_* == None, headline в норме."""
|
||
est, _calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (None, None))
|
||
|
||
assert est.expected_sold_price_rub is None
|
||
assert est.expected_sold_per_m2 is None
|
||
assert est.expected_sold_range_low_rub is None
|
||
assert est.expected_sold_range_high_rub is None
|
||
assert est.asking_to_sold_ratio is None
|
||
assert est.ratio_basis is None
|
||
# Headline не изменён.
|
||
assert est.median_price_rub > 0
|