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
Было: 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>
151 lines
6.2 KiB
Python
151 lines
6.2 KiB
Python
"""#2898: окно срока экспозиции (p25–p75) вместо одного числа и его персист.
|
||
|
||
Раньше 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
|