gendesign/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py
bot-backend ac99886201
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/estimator): remove dead tier-aware-ratio path — truncation artifact + footgun (#2002)
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
2026-06-27 20:31:49 +03:00

311 lines
13 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.

"""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