gendesign/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py
bot-backend ed4335b932
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
feat(tradein/estimator): calibrated 80% prediction interval for expected_sold range (#1966)
The expected_sold_range_low/high were derived from the analog asking-IQR
(× asking→sold ratio): only ~55% of actual sold prices fell inside the stated
range — a 50%-ish interval mislabeled as the estimate's range. Replace with a
calibrated band around the expected_sold POINT, gated behind
estimate_calibrated_pi_enabled (default ON):

  range_low  = expected_sold_price × 0.649  (empirical p10 of sold/expected_sold)
  range_high = expected_sold_price × 1.392  (empirical p90, n=2366 prod deals)

This is a genuine ~80% prediction interval (verified 80.0% coverage on the
2366-deal sample). Headline asking range_low/high (market-asking IQR) untouched.

Frozen backtest baseline regenerated: range_coverage.overall 55.23 → 80.14
(n_covered 153 → 222); expected_sold POINT mape/bias and headline unchanged
(only the band moved). Tests asserting the old asking-IQR × ratio band updated
to the calibrated band (documented behavior change).
2026-06-27 16:53:14 +03:00

199 lines
7.6 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 4: clamp expected_sold <= asking (ratio cap 1.0).
Smoke-диагноз: 3к/27.0М -> expected_sold 31.4М > asking.
Причина: _get_asking_sold_ratio для high-price tier возвращает ratio > 1.0
(product artefact) -> expected_sold = headline x ratio > headline.
Проверяем:
1. ratio=1.15 + флаг on -> expected_sold == headline (не выше); лог clamp.
2. ratio=0.8 -> expected_sold = headline x 0.8 (норм, без clamp).
3. ratio None -> expected_sold не выводится (graceful).
4. Флаг off + ratio > 1 -> expected_sold > headline (старое поведение).
"""
from __future__ import annotations
import logging
import os
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
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": "ЕКБ, ул. Малышева, 30",
"lat": 56.838,
"lon": 60.595,
"rooms": 3,
"area_m2": area_m2,
"floor": 10,
"total_floors": 20,
"price_rub": price_per_m2 * area_m2,
"price_per_m2": price_per_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 5,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 100.0,
"relevance_score": 0.1,
}
_ANALOGS: list[dict[str, Any]] = [
_make_listing(price_per_m2=260_000.0),
_make_listing(price_per_m2=270_000.0),
_make_listing(price_per_m2=280_000.0),
]
def _make_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Малышева, 30",
provider="nominatim",
)
def _make_payload(rooms: int = 3):
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Малышева, 30", area_m2=50.0, rooms=rooms, floor=10, total_floors=20
)
def _run_estimate(
ratio_tuple: tuple[float | None, str | None],
*,
clamp_enabled: bool = True,
) -> Any:
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
async def _run() -> Any:
with (
patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled),
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", return_value=ratio_tuple),
):
return await estimate_quality(payload, db)
return anyio.run(_run)
def test_expected_sold_clamped_to_headline_when_ratio_above_1(
caplog: pytest.LogCaptureFixture,
) -> None:
ratio = 1.15
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
assert est.median_price_rub > 0, "headline должен быть задан аналогами"
assert est.expected_sold_price_rub is not None
assert (
est.expected_sold_price_rub <= est.median_price_rub
), f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}"
assert est.expected_sold_per_m2 is not None
assert (
est.expected_sold_per_m2 <= est.median_price_per_m2
), "expected_sold_per_m2 превышает median_price_per_m2"
# #1966: expected_sold range is now a calibrated ~80% PI around the point
# (point × [p10, p90] of sold/expected_sold), so it is NO LONGER bounded by the
# asking-IQR band — the high arm (point × 1.392) legitimately exceeds range_high.
# The invariant we keep is the calibrated band itself + ordering low ≤ point ≤ high.
from app.core.config import settings
assert est.expected_sold_range_high_rub is not None
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_range_low_rub is not None
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert (
est.expected_sold_range_low_rub
<= est.expected_sold_price_rub
<= est.expected_sold_range_high_rub
)
assert est.expected_sold_price_rub == est.median_price_rub
assert est.expected_sold_per_m2 == est.median_price_per_m2
clamp_logs = [r for r in caplog.records if "clamped" in r.getMessage()]
assert clamp_logs, "Ожидался log-message о clamp ratio"
assert "1.150" in clamp_logs[0].getMessage() or "1.15" in clamp_logs[0].getMessage()
def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
ratio = 0.8
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
assert est.median_price_rub > 0
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
# #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio).
from app.core.config import settings
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_price_rub < est.median_price_rub
def test_expected_sold_none_when_ratio_none() -> None:
est = _run_estimate((None, None), clamp_enabled=True)
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.median_price_rub > 0
def test_expected_sold_exceeds_asking_when_clamp_flag_off() -> None:
ratio = 1.15
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=False)
assert est.median_price_rub > 0
assert est.expected_sold_price_rub is not None
assert est.expected_sold_price_rub > est.median_price_rub, (
f"Ожидалось expected_sold > asking при clamp=False, "
f"но {est.expected_sold_price_rub} <= {est.median_price_rub}"
)
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)