gendesign/tradein-mvp/backend/tests/test_2898_exposure_window.py
bot-backend 3b2f5ed642
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m32s
CI Trade-In / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 25s
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
CI Trade-In / frontend-checks (pull_request) Successful in 3m18s
МЕРА: срок экспозиции окном p25–p75 по снятым объявлениям и хранится с оценкой (#2898)
Было: est_days_on_market — одно число, медиана days_on_market активных
аналогов (возраст висящего объявления, цензурированная выборка), в
trade_in_estimates не сохранялось, GET по ссылке отдавал null.

Стало:
- эстиматор берёт exposure_days из house_placement_history: те же комнаты,
  площадь ±15%, дома в радиусе подбора аналогов, снятые за 24 мес.;
  квартили как percentile_cont; при выборке < 30 окна нет;
- миграция 324: est_days_p25/p50/p75/n в trade_in_estimates, пишутся в
  INSERT и при ревайвле, поднимаются на GET (/estimate/{id} и /r/{token});
- API: exposure_window {p25_days, p50_days, p75_days, n}; старое поле
  est_days_on_market оставлено и равно p50 окна, у старых строк null;
- B2B hero: «До снятия объявления N–M дн.» вместо «Срок продажи»;
- /docs: убран «прогноз срока» из описания полного отчёта.

Порог 30 — замер 17.09 на 51 прод-когорте: ошибка края окна при 20 лотах
24%, при 30 — 18%. Окно есть у 94 из 217 недавних оценок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:44:44 +05:00

151 lines
6.2 KiB
Python
Raw Permalink 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.

"""#2898: окно срока экспозиции (p25p75) вместо одного числа и его персист.
Раньше est_days_on_market был медианой возраста АКТИВНЫХ объявлений-аналогов и не
сохранялся: отчёт по ссылке отдавал null. Теперь квартили exposure_days снятых
объявлений считаются на POST, пишутся в trade_in_estimates и поднимаются на GET.
Ожидаемые квартили посчитаны руками по формуле percentile_cont (позиция
(n-1)·q, линейная интерполяция, ::int округляет .5 вверх), а не той же функцией.
"""
from __future__ import annotations
import contextlib
import os
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
from app.services.estimator import (
EXPOSURE_WINDOW_MIN_N,
_exposure_window,
_fetch_exposure_days,
)
# 2..31 (n=30): p25 на позиции 7.25 → 9.25 → 9; p50 на 14.5 → 16.5 → 17 (банковское
# округление дало бы 16); p75 на 21.75 → 23.75 → 24.
_DAYS_30 = list(range(2, 32))
def test_quartiles_match_percentile_cont() -> None:
w = _exposure_window(_DAYS_30)
assert w is not None
assert (w.p25_days, w.p50_days, w.p75_days, w.n) == (9, 17, 24, 30)
def test_order_of_input_does_not_matter() -> None:
w = _exposure_window(list(reversed(_DAYS_30)))
assert w is not None
assert (w.p25_days, w.p50_days, w.p75_days) == (9, 17, 24)
def test_skewed_sample_window() -> None:
"""Прод-подобная выборка: пачка ровно по 31 дню (срок размещения на Авито) и хвост."""
days = [31] * 20 + [62] * 5 + [120, 180, 240, 300, 400]
w = _exposure_window(days)
assert w is not None
# позиции 7.25 / 14.5 / 21.75 — внутри пачки 31 и пачки 62
assert (w.p25_days, w.p50_days, w.p75_days) == (31, 31, 62)
def test_below_threshold_no_window() -> None:
assert EXPOSURE_WINDOW_MIN_N == 30
assert _exposure_window(_DAYS_30[:-1]) is None
assert _exposure_window([]) is None
def test_fetch_failure_is_graceful() -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _fetch_exposure_days(db, lat=56.8, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
def test_fetch_without_coords_does_not_query() -> None:
db = MagicMock()
assert _fetch_exposure_days(db, lat=None, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
assert not db.execute.called
# ── POST: окно уходит и в ответ, и в INSERT ───────────────────────────────────
def _run_estimate(exposure_days: list[int]) -> tuple[Any, dict[str, Any]]:
from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import estimate_quality
from app.services.geocoder import GeocodeResult
db = MagicMock()
payload = TradeInEstimateInput(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1)
geo = GeocodeResult(
lat=56.838, lon=60.595, full_address="Екатеринбург, ул. Тестовая, 1", provider="nominatim"
)
fetch = MagicMock(return_value=exposure_days)
patches = (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=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=([], False, "W")),
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._get_asking_sold_ratio", return_value=(None, None)),
patch("app.services.estimator._fetch_exposure_days", new=fetch),
)
async def _run() -> Any:
with contextlib.ExitStack() as stack:
for cm in patches:
stack.enter_context(cm)
return await estimate_quality(payload, db)
result = anyio.run(_run)
# Выборка берётся по координатам и параметрам оцениваемой квартиры.
kwargs = fetch.call_args.kwargs
assert (kwargs["lat"], kwargs["lon"], kwargs["rooms"], kwargs["area"]) == (
56.838,
60.595,
1,
40.0,
)
for call in db.execute.call_args_list:
sql = str(getattr(call.args[0], "text", call.args[0]))
if "INSERT INTO trade_in_estimates" in sql:
return result, call.args[1]
raise AssertionError("INSERT INTO trade_in_estimates не вызван")
def test_post_persists_window_and_returns_it() -> None:
result, params = _run_estimate(_DAYS_30)
assert (
params["est_days_p25"],
params["est_days_p50"],
params["est_days_p75"],
params["est_days_n"],
) == (9, 17, 24, 30)
assert result.exposure_window is not None
assert (result.exposure_window.p25_days, result.exposure_window.p75_days) == (9, 24)
# Старое поле для старых клиентов — медиана того же окна, а не возраст объявлений.
assert result.est_days_on_market == 17
def test_post_small_sample_persists_nulls() -> None:
result, params = _run_estimate(_DAYS_30[:-1])
assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [
None,
None,
None,
None,
]
assert result.exposure_window is None
assert result.est_days_on_market is None