Merge pull request 'МЕРА: срок продажи показываем окном «от–до» по снятым объявлениям и сохраняем его вместе с оценкой' (#3573) from fix/days-on-market-window into main
Some checks failed
Deploy Trade-In / changes (push) Has been cancelled
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Some checks failed
Deploy Trade-In / changes (push) Has been cancelled
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
This commit is contained in:
commit
71e551d57d
13 changed files with 469 additions and 23 deletions
|
|
@ -31,6 +31,7 @@ from app.schemas.trade_in import (
|
||||||
CoverageProbeInput,
|
CoverageProbeInput,
|
||||||
CoverageProbeResponse,
|
CoverageProbeResponse,
|
||||||
DkpCorridor,
|
DkpCorridor,
|
||||||
|
ExposureWindow,
|
||||||
HouseAnalyticsKpi,
|
HouseAnalyticsKpi,
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
|
|
@ -390,6 +391,7 @@ async def _try_revive_dead_estimate(
|
||||||
),
|
),
|
||||||
{"orig": str(estimate_id), "temp": str(temp_id)},
|
{"orig": str(estimate_id), "temp": str(temp_id)},
|
||||||
)
|
)
|
||||||
|
w = result.exposure_window # #2898: окно целиком или NULL целиком
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -418,6 +420,10 @@ async def _try_revive_dead_estimate(
|
||||||
ratio_basis = :ratio_basis,
|
ratio_basis = :ratio_basis,
|
||||||
relaxations = CAST(:relaxations_json AS jsonb),
|
relaxations = CAST(:relaxations_json AS jsonb),
|
||||||
reliability = :reliability,
|
reliability = :reliability,
|
||||||
|
est_days_p25 = :est_days_p25,
|
||||||
|
est_days_p50 = :est_days_p50,
|
||||||
|
est_days_p75 = :est_days_p75,
|
||||||
|
est_days_n = :est_days_n,
|
||||||
revival_completed_at = NOW()
|
revival_completed_at = NOW()
|
||||||
WHERE id = CAST(:id AS uuid)
|
WHERE id = CAST(:id AS uuid)
|
||||||
"""
|
"""
|
||||||
|
|
@ -452,6 +458,10 @@ async def _try_revive_dead_estimate(
|
||||||
"ratio_basis": result.ratio_basis,
|
"ratio_basis": result.ratio_basis,
|
||||||
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
||||||
"reliability": result.reliability,
|
"reliability": result.reliability,
|
||||||
|
"est_days_p25": w.p25_days if w else None,
|
||||||
|
"est_days_p50": w.p50_days if w else None,
|
||||||
|
"est_days_p75": w.p75_days if w else None,
|
||||||
|
"est_days_n": w.n if w else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -678,7 +688,8 @@ def load_estimate(
|
||||||
expected_sold_price, expected_sold_range_low,
|
expected_sold_price, expected_sold_range_low,
|
||||||
expected_sold_range_high, expected_sold_per_m2,
|
expected_sold_range_high, expected_sold_per_m2,
|
||||||
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
||||||
relaxations, reliability
|
relaxations, reliability,
|
||||||
|
est_days_p25, est_days_p50, est_days_p75, est_days_n
|
||||||
FROM trade_in_estimates
|
FROM trade_in_estimates
|
||||||
WHERE id = CAST(:id AS uuid)
|
WHERE id = CAST(:id AS uuid)
|
||||||
AND {ESTIMATE_READABLE_SQL}
|
AND {ESTIMATE_READABLE_SQL}
|
||||||
|
|
@ -843,6 +854,19 @@ def load_estimate(
|
||||||
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
||||||
# ответ AttributeError'ом.
|
# ответ AttributeError'ом.
|
||||||
market_percentile=getattr(row, "market_percentile", None),
|
market_percentile=getattr(row, "market_percentile", None),
|
||||||
|
# #2898: окно из колонок, а не пересчёт — по ссылке то же окно, что при оценке.
|
||||||
|
# getattr: строка без колонок (in-memory double) — окна нет, а не AttributeError.
|
||||||
|
exposure_window=(
|
||||||
|
ExposureWindow(
|
||||||
|
p25_days=row.est_days_p25,
|
||||||
|
p50_days=row.est_days_p50,
|
||||||
|
p75_days=row.est_days_p75,
|
||||||
|
n=row.est_days_n,
|
||||||
|
)
|
||||||
|
if getattr(row, "est_days_n", None) is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
est_days_on_market=getattr(row, "est_days_p50", None),
|
||||||
period_months=12,
|
period_months=12,
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
actual_deals=actual_deals,
|
actual_deals=actual_deals,
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,21 @@ class PriceTrendPoint(BaseModel):
|
||||||
ppm2: int # медиана ₽/м² за месяц
|
ppm2: int # медиана ₽/м² за месяц
|
||||||
|
|
||||||
|
|
||||||
|
class ExposureWindow(BaseModel):
|
||||||
|
"""#2898: сколько дней похожие квартиры рядом висели до снятия объявления.
|
||||||
|
|
||||||
|
Квартили `house_placement_history.exposure_days`: те же комнаты, площадь ±15%,
|
||||||
|
дома в радиусе подбора аналогов, снятые за последние 24 мес. Это срок ДО СНЯТИЯ
|
||||||
|
объявления, а не подтверждённой продажи: в данных снятие и продажа неразличимы.
|
||||||
|
Окно либо целиком, либо None (выборка < `EXPOSURE_WINDOW_MIN_N`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
p25_days: int
|
||||||
|
p50_days: int
|
||||||
|
p75_days: int
|
||||||
|
n: int # сколько снятых объявлений в выборке
|
||||||
|
|
||||||
|
|
||||||
class AggregatedEstimate(BaseModel):
|
class AggregatedEstimate(BaseModel):
|
||||||
estimate_id: UUID
|
estimate_id: UUID
|
||||||
median_price_rub: int
|
median_price_rub: int
|
||||||
|
|
@ -292,7 +307,11 @@ class AggregatedEstimate(BaseModel):
|
||||||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||||
# абсолютный timestamp самого свежего парсинга аналогов
|
# абсолютный timestamp самого свежего парсинга аналогов
|
||||||
last_scraped_at: datetime | None = None
|
last_scraped_at: datetime | None = None
|
||||||
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
|
# #2898: окно срока экспозиции (p25–p75) — показывать его, а не одно число.
|
||||||
|
exposure_window: ExposureWindow | None = None
|
||||||
|
# Устаревшее, оставлено для старых клиентов: = exposure_window.p50_days. Раньше —
|
||||||
|
# медиана возраста АКТИВНЫХ объявлений-аналогов (цензурированная выборка).
|
||||||
|
est_days_on_market: int | None = None
|
||||||
cian_valuation: CianValuationSummary | None = None
|
cian_valuation: CianValuationSummary | None = None
|
||||||
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
|
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
|
||||||
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
|
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ from app.schemas.trade_in import (
|
||||||
AvitoImvSummary,
|
AvitoImvSummary,
|
||||||
CianValuationSummary,
|
CianValuationSummary,
|
||||||
DkpCorridor,
|
DkpCorridor,
|
||||||
|
ExposureWindow,
|
||||||
PriceTrendPoint,
|
PriceTrendPoint,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
|
|
@ -5517,6 +5518,19 @@ async def estimate_quality(
|
||||||
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
|
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
|
||||||
source_counts = _source_counts([lot.source for lot in analogs_lots])
|
source_counts = _source_counts([lot.source for lot in analogs_lots])
|
||||||
freshness_pre = _compute_freshness_minutes(metadata_lots)
|
freshness_pre = _compute_freshness_minutes(metadata_lots)
|
||||||
|
# #2898: окно считается один раз здесь и персистится — отчёт по ссылке через месяц
|
||||||
|
# покажет то же окно, что в момент расчёта, а не пересчёт по новой истории.
|
||||||
|
exposure_window = _exposure_window(
|
||||||
|
await run_db_thread(
|
||||||
|
_fetch_exposure_days,
|
||||||
|
db,
|
||||||
|
lat=geo.lat,
|
||||||
|
lon=geo.lon,
|
||||||
|
rooms=payload.rooms,
|
||||||
|
area=payload.area_m2,
|
||||||
|
radius_m=search_radius_m,
|
||||||
|
)
|
||||||
|
)
|
||||||
# DaData enrichment (PR Q1) — заполняется только если service отработал.
|
# DaData enrichment (PR Q1) — заполняется только если service отработал.
|
||||||
# При DaData = None все колонки идут в DB как NULL (graceful).
|
# При DaData = None все колонки идут в DB как NULL (graceful).
|
||||||
dadata_metro_json = (
|
dadata_metro_json = (
|
||||||
|
|
@ -5546,6 +5560,7 @@ async def estimate_quality(
|
||||||
expected_sold_range_high, expected_sold_per_m2,
|
expected_sold_range_high, expected_sold_per_m2,
|
||||||
asking_to_sold_ratio, ratio_basis,
|
asking_to_sold_ratio, ratio_basis,
|
||||||
relaxations, reliability,
|
relaxations, reliability,
|
||||||
|
est_days_p25, est_days_p50, est_days_p75, est_days_n,
|
||||||
created_by,
|
created_by,
|
||||||
expires_at,
|
expires_at,
|
||||||
consent, client_ip, consent_policy_version, consent_text_snapshot
|
consent, client_ip, consent_policy_version, consent_text_snapshot
|
||||||
|
|
@ -5569,6 +5584,7 @@ async def estimate_quality(
|
||||||
:expected_sold_range_high, :expected_sold_per_m2,
|
:expected_sold_range_high, :expected_sold_per_m2,
|
||||||
:asking_to_sold_ratio, :ratio_basis,
|
:asking_to_sold_ratio, :ratio_basis,
|
||||||
CAST(:relaxations_json AS jsonb), :reliability,
|
CAST(:relaxations_json AS jsonb), :reliability,
|
||||||
|
:est_days_p25, :est_days_p50, :est_days_p75, :est_days_n,
|
||||||
:created_by,
|
:created_by,
|
||||||
:expires_at,
|
:expires_at,
|
||||||
:consent, CAST(:client_ip AS inet), :consent_policy_version,
|
:consent, CAST(:client_ip AS inet), :consent_policy_version,
|
||||||
|
|
@ -5629,6 +5645,11 @@ async def estimate_quality(
|
||||||
# пропадал при открытии оценки по сохранённой ссылке (?id=).
|
# пропадал при открытии оценки по сохранённой ссылке (?id=).
|
||||||
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
|
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
|
||||||
"reliability": reliability,
|
"reliability": reliability,
|
||||||
|
# #2898: окно пишем целиком или NULL целиком (n < порога).
|
||||||
|
"est_days_p25": exposure_window.p25_days if exposure_window else None,
|
||||||
|
"est_days_p50": exposure_window.p50_days if exposure_window else None,
|
||||||
|
"est_days_p75": exposure_window.p75_days if exposure_window else None,
|
||||||
|
"est_days_n": exposure_window.n if exposure_window else None,
|
||||||
"created_by": created_by,
|
"created_by": created_by,
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
**_estimate_consent_persist_fields(require_consent, client_ip),
|
**_estimate_consent_persist_fields(require_consent, client_ip),
|
||||||
|
|
@ -5799,7 +5820,8 @@ async def estimate_quality(
|
||||||
data_freshness_minutes=freshness_min,
|
data_freshness_minutes=freshness_min,
|
||||||
last_scraped_at=last_scraped_at,
|
last_scraped_at=last_scraped_at,
|
||||||
price_trend=price_trend,
|
price_trend=price_trend,
|
||||||
est_days_on_market=_estimate_days_on_market(metadata_lots, deals),
|
exposure_window=exposure_window,
|
||||||
|
est_days_on_market=exposure_window.p50_days if exposure_window else None,
|
||||||
cian_valuation=(
|
cian_valuation=(
|
||||||
CianValuationSummary(
|
CianValuationSummary(
|
||||||
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
|
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
|
||||||
|
|
@ -5991,23 +6013,88 @@ def _manual_review(
|
||||||
return (bool(reasons), reasons)
|
return (bool(reasons), reasons)
|
||||||
|
|
||||||
|
|
||||||
def _estimate_days_on_market(
|
# #2898: окно срока экспозиции вместо одного числа. Раньше est_days_on_market был
|
||||||
listings: list[dict[str, Any]], deals: list[dict[str, Any]]
|
# медианой days_on_market АКТИВНЫХ аналогов — возраст висящего объявления, а не срок
|
||||||
) -> int | None:
|
# до снятия (цензурированная выборка; deals.days_on_market пуст). Реальная экспозиция
|
||||||
"""Прогноз срока продажи — медиана days_on_market по аналогам/сделкам.
|
# снятых объявлений — house_placement_history.exposure_days.
|
||||||
|
#
|
||||||
|
# Порог 30 — замер 17.09.2026 на 51 прод-когорте с n>=80 (радиус 1 км): подвыборка
|
||||||
|
# в 10 лотов даёт среднюю ошибку края окна 34-40%, 20 — 24%, 30 — 18%, 50 — 12%.
|
||||||
|
# При 30 окно есть у 94 из 217 недавних оценок (при 20 — у 109).
|
||||||
|
EXPOSURE_WINDOW_MIN_N = 30
|
||||||
|
EXPOSURE_WINDOW_MONTHS = 24
|
||||||
|
|
||||||
Возвращает None если ни у одного аналога нет данных о сроке экспозиции
|
|
||||||
(наши парсеры не всегда его отдают — честно показываем «нет данных»).
|
def _exposure_window(days: list[int]) -> ExposureWindow | None:
|
||||||
|
"""Квартили срока экспозиции; None, если выборка меньше EXPOSURE_WINDOW_MIN_N.
|
||||||
|
|
||||||
|
method="inclusive" — та же линейная интерполяция, что percentile_cont в
|
||||||
|
sell-time-sensitivity, чтобы одно и то же окно не расходилось на единицу.
|
||||||
"""
|
"""
|
||||||
values = [
|
if len(days) < EXPOSURE_WINDOW_MIN_N:
|
||||||
int(lot["days_on_market"])
|
|
||||||
for lot in (*listings, *deals)
|
|
||||||
if lot.get("days_on_market") and int(lot["days_on_market"]) > 0
|
|
||||||
]
|
|
||||||
if len(values) < 3:
|
|
||||||
return None
|
return None
|
||||||
values.sort()
|
p25, p50, p75 = statistics.quantiles(days, n=4, method="inclusive")
|
||||||
return values[len(values) // 2]
|
return ExposureWindow(
|
||||||
|
p25_days=int(p25 + 0.5),
|
||||||
|
p50_days=int(p50 + 0.5),
|
||||||
|
p75_days=int(p75 + 0.5),
|
||||||
|
n=len(days),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_exposure_days(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
lat: float | None,
|
||||||
|
lon: float | None,
|
||||||
|
rooms: int,
|
||||||
|
area: float,
|
||||||
|
radius_m: int,
|
||||||
|
) -> list[int]:
|
||||||
|
"""exposure_days снятых объявлений той же комнатности и площади ±AREA_TOLERANCE
|
||||||
|
в домах в радиусе подбора аналогов. Best-effort: ошибка → [] (окна нет).
|
||||||
|
|
||||||
|
ponytail: без дедупа avito_imv/yandex_valuation — прод 17.09: у 926 из 9482
|
||||||
|
yandex-строк есть avito-двойник (~3% всей выборки), на квартили не влияет.
|
||||||
|
"""
|
||||||
|
if lat is None or lon is None or not area:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
# SAVEPOINT на месте глушения: упавший SELECT не должен отравить транзакцию,
|
||||||
|
# в которой следом идёт INSERT оценки.
|
||||||
|
with db.begin_nested():
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT h.exposure_days
|
||||||
|
FROM house_placement_history h
|
||||||
|
JOIN houses ho ON ho.id = h.house_id
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
ho.geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
|
||||||
|
)
|
||||||
|
AND h.rooms = :rooms
|
||||||
|
AND h.area_m2 BETWEEN :area_min AND :area_max
|
||||||
|
AND h.exposure_days IS NOT NULL
|
||||||
|
AND h.removed_date IS NOT NULL
|
||||||
|
AND h.removed_date <= CURRENT_DATE
|
||||||
|
AND h.removed_date > CURRENT_DATE
|
||||||
|
- make_interval(months => CAST(:months AS integer))
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"radius": radius_m,
|
||||||
|
"rooms": rooms,
|
||||||
|
"area_min": area * (1 - AREA_TOLERANCE),
|
||||||
|
"area_max": area * (1 + AREA_TOLERANCE),
|
||||||
|
"months": EXPOSURE_WINDOW_MONTHS,
|
||||||
|
},
|
||||||
|
).all()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("exposure_window lookup failed (graceful): %s", exc)
|
||||||
|
return []
|
||||||
|
return [int(r[0]) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
|
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- #2898: окно срока экспозиции (p25/p50/p75 и размер выборки) вместо одного числа.
|
||||||
|
--
|
||||||
|
-- ЗАЧЕМ КОЛОНКИ. Окно считается по house_placement_history на момент оценки, а
|
||||||
|
-- история пополняется каждый день: пересчёт на чтении показал бы по ссылке через
|
||||||
|
-- месяц другое окно, чем в момент покупки отчёта. Поэтому значение хранится.
|
||||||
|
--
|
||||||
|
-- Все четыре либо заполнены, либо NULL: NULL — выборка меньше
|
||||||
|
-- EXPOSURE_WINDOW_MIN_N (30) или оценка посчитана до этой миграции. Бэкфилла нет:
|
||||||
|
-- историю размещений на дату старой оценки не восстановить.
|
||||||
|
--
|
||||||
|
-- Старое поле ответа est_days_on_market не колонка и не удаляется: GET отдаёт в нём
|
||||||
|
-- est_days_p50, старые строки дают null, как и раньше.
|
||||||
|
--
|
||||||
|
-- ПРО ЛОКИ (#2752): ADD COLUMN без DEFAULT не переписывает таблицу, но ждать
|
||||||
|
-- ACCESS EXCLUSIVE может долго и держать очередь запросов — lock_timeout.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
ALTER TABLE trade_in_estimates
|
||||||
|
ADD COLUMN IF NOT EXISTS est_days_p25 integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS est_days_p50 integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS est_days_p75 integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS est_days_n integer;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN trade_in_estimates.est_days_p50 IS
|
||||||
|
'#2898: медиана дней до СНЯТИЯ объявления (не продажи) у похожих квартир рядом, '
|
||||||
|
'house_placement_history.exposure_days. p25/p75 — края окна, n — размер выборки. '
|
||||||
|
'NULL — выборка < 30.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
151
tradein-mvp/backend/tests/test_2898_exposure_window.py
Normal file
151
tradein-mvp/backend/tests/test_2898_exposure_window.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""#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
|
||||||
|
|
@ -80,6 +80,11 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
|
||||||
# #2899: колонка есть у всех строк после миграции 267; NULL у старых
|
# #2899: колонка есть у всех строк после миграции 267; NULL у старых
|
||||||
# (бэкфилла нет — позицию по сохранённому top-10 не восстановить).
|
# (бэкфилла нет — позицию по сохранённому top-10 не восстановить).
|
||||||
market_percentile=63,
|
market_percentile=63,
|
||||||
|
# #2898: окно срока экспозиции (миграция 324); NULL у строк до неё.
|
||||||
|
est_days_p25=31,
|
||||||
|
est_days_p50=58,
|
||||||
|
est_days_p75=121,
|
||||||
|
est_days_n=44,
|
||||||
analogs=[],
|
analogs=[],
|
||||||
actual_deals=[],
|
actual_deals=[],
|
||||||
sources_used=["avito"],
|
sources_used=["avito"],
|
||||||
|
|
@ -801,3 +806,36 @@ def test_get_estimate_market_percentile_nullable(trade_in_app: FastAPI) -> None:
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["market_percentile"] is None
|
assert resp.json()["market_percentile"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_estimate_surfaces_exposure_window(trade_in_app: FastAPI) -> None:
|
||||||
|
"""#2898: окно срока поднимается из колонок — по ссылке то же, что при оценке.
|
||||||
|
|
||||||
|
До #2898 GET отдавал est_days_on_market=null всегда: значение жило только в POST.
|
||||||
|
"""
|
||||||
|
row = _make_estimate_row(created_by="kopylov")
|
||||||
|
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["exposure_window"] == {"p25_days": 31, "p50_days": 58, "p75_days": 121, "n": 44}
|
||||||
|
assert body["est_days_on_market"] == 58
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_estimate_old_row_without_window(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Контроль: строка до миграции 324 (все четыре NULL) — окна нет, старое поле null."""
|
||||||
|
row = _make_estimate_row(created_by="kopylov")
|
||||||
|
row.est_days_p25 = row.est_days_p50 = row.est_days_p75 = row.est_days_n = None
|
||||||
|
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["exposure_window"] is None
|
||||||
|
assert resp.json()["est_days_on_market"] is None
|
||||||
|
|
|
||||||
|
|
@ -602,3 +602,32 @@ def test_empty_estimate_persists_relaxations_reliability() -> None:
|
||||||
src = inspect.getsource(estimator._empty_estimate)
|
src = inspect.getsource(estimator._empty_estimate)
|
||||||
assert "relaxations, reliability" in src
|
assert "relaxations, reliability" in src
|
||||||
assert "'very_low'" in src
|
assert "'very_low'" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_revival_persists_exposure_window(
|
||||||
|
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||||
|
) -> None:
|
||||||
|
"""#2898: пересчитанное окно срока пишется в исходную строку — иначе следующий
|
||||||
|
GET по той же ссылке отдал бы окно null, хотя ответ ревайвла его показал."""
|
||||||
|
from app.schemas.trade_in import ExposureWindow
|
||||||
|
|
||||||
|
window = ExposureWindow(p25_days=31, p50_days=58, p75_days=121, n=44)
|
||||||
|
|
||||||
|
async def _fake_estimate_quality(payload, db, **kwargs):
|
||||||
|
return _fake_revived_result(exposure_window=window, est_days_on_market=58)
|
||||||
|
|
||||||
|
_estimator_stub.estimate_quality = _fake_estimate_quality
|
||||||
|
|
||||||
|
db = _dispatch_db(_make_dead_row(), claim_result=SimpleNamespace(id=_ESTIMATE_ID))
|
||||||
|
resp = _client_with(trade_in_app, db).get(
|
||||||
|
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
params = _calls_containing(db, "UPDATE trade_in_estimates SET")[0].args[1]
|
||||||
|
assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [
|
||||||
|
31,
|
||||||
|
58,
|
||||||
|
121,
|
||||||
|
44,
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,7 @@ _PAID_FIELDS = {
|
||||||
"actual_deals",
|
"actual_deals",
|
||||||
"expected_sold_price_rub",
|
"expected_sold_price_rub",
|
||||||
"est_days_on_market",
|
"est_days_on_market",
|
||||||
|
"exposure_window",
|
||||||
"price_trend",
|
"price_trend",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ const TARIFFS: readonly {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Полный отчёт",
|
name: "Полный отчёт",
|
||||||
note: "Цена, прогноз срока, диапазон, аналоги, факторы. Разовый платёж",
|
note: "Цена, диапазон, срок экспозиции похожих объявлений, аналоги, факторы. Разовый платёж",
|
||||||
price: `${SERVICE_PRICE_RUB} ₽`,
|
price: `${SERVICE_PRICE_RUB} ₽`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
|
||||||
sources_used: ["avito", "cian", "rosreestr"],
|
sources_used: ["avito", "cian", "rosreestr"],
|
||||||
data_freshness_minutes: 35,
|
data_freshness_minutes: 35,
|
||||||
last_scraped_at: "2026-05-30T12:00:00Z",
|
last_scraped_at: "2026-05-30T12:00:00Z",
|
||||||
|
exposure_window: { p25_days: 31, p50_days: 48, p75_days: 104, n: 57 },
|
||||||
est_days_on_market: 48,
|
est_days_on_market: 48,
|
||||||
address_precision: "house",
|
address_precision: "house",
|
||||||
area_m2: 55.3,
|
area_m2: 55.3,
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
const houseType = estimate.house_type ?? input.house_type;
|
const houseType = estimate.house_type ?? input.house_type;
|
||||||
const repairState = estimate.repair_state ?? input.repair_state;
|
const repairState = estimate.repair_state ?? input.repair_state;
|
||||||
const hasBalcony = estimate.has_balcony ?? input.has_balcony;
|
const hasBalcony = estimate.has_balcony ?? input.has_balcony;
|
||||||
const estDaysOnMarket = estimate.est_days_on_market;
|
// #2898: окно p25–p75, а не одно число. est_days_on_market не показываем:
|
||||||
|
// у старых оценок это возраст висящих объявлений, а не срок до снятия.
|
||||||
|
const exposure = estimate.exposure_window ?? null;
|
||||||
|
|
||||||
function handleEnrichSubmit(e: React.FormEvent) {
|
function handleEnrichSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -403,9 +405,14 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
<span className="v mono">{estimate.n_analogs}</span>
|
<span className="v mono">{estimate.n_analogs}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="meta-row">
|
<div className="meta-row">
|
||||||
<span className="k">Срок продажи</span>
|
<span className="k">До снятия объявления</span>
|
||||||
{estDaysOnMarket !== null ? (
|
{exposure ? (
|
||||||
<span className="v mono">{estDaysOnMarket} дн.</span>
|
<span
|
||||||
|
className="v mono"
|
||||||
|
title={`Средняя половина из ${exposure.n} похожих объявлений рядом висела столько дней до снятия. Снятие объявления не значит продажу.`}
|
||||||
|
>
|
||||||
|
{exposure.p25_days}–{exposure.p75_days} дн.
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="v" style={{ color: "var(--muted)" }}>нет данных</span>
|
<span className="v" style={{ color: "var(--muted)" }}>нет данных</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* #2898 — в hero окно срока до снятия объявления (p25–p75), а не одно число.
|
||||||
|
* Старое est_days_on_market без окна не показываем: у оценок до #2898 это возраст
|
||||||
|
* висящих объявлений, и «48 дн.» читалось бы как срок продажи.
|
||||||
|
*/
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { HeroSummary } from "../HeroSummary";
|
||||||
|
import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture";
|
||||||
|
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||||
|
|
||||||
|
vi.mock("@/lib/useBrand", () => ({
|
||||||
|
useActiveBrandSlug: () => null,
|
||||||
|
useBrand: () => ({ data: null }),
|
||||||
|
}));
|
||||||
|
vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null }));
|
||||||
|
|
||||||
|
function heroText(estimate: AggregatedEstimate): string {
|
||||||
|
const { container } = render(
|
||||||
|
<HeroSummary estimate={estimate} input={FIXTURE_INPUT} onResubmit={() => {}} />,
|
||||||
|
);
|
||||||
|
return container.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("#2898 окно срока в v1 HeroSummary", () => {
|
||||||
|
it("показывает края окна", () => {
|
||||||
|
const text = heroText({
|
||||||
|
...FIXTURE_ESTIMATE,
|
||||||
|
exposure_window: { p25_days: 29, p50_days: 61, p75_days: 146, n: 44 },
|
||||||
|
est_days_on_market: 61,
|
||||||
|
});
|
||||||
|
expect(text).toContain("До снятия объявления29–146 дн.");
|
||||||
|
expect(text).not.toContain("Срок продажи");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без окна — «нет данных», старое число не всплывает", () => {
|
||||||
|
const text = heroText({
|
||||||
|
...FIXTURE_ESTIMATE,
|
||||||
|
exposure_window: null,
|
||||||
|
est_days_on_market: 48,
|
||||||
|
});
|
||||||
|
expect(text).toContain("До снятия объявлениянет данных");
|
||||||
|
expect(text).not.toContain("48 дн.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -128,6 +128,15 @@ export interface PriceTrendPoint {
|
||||||
ppm2: number; // ₽/м²
|
ppm2: number; // ₽/м²
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #2898: сколько дней похожие квартиры рядом висели до СНЯТИЯ объявления
|
||||||
|
// (не подтверждённой продажи) — квартили house_placement_history.exposure_days.
|
||||||
|
export interface ExposureWindow {
|
||||||
|
p25_days: number;
|
||||||
|
p50_days: number;
|
||||||
|
p75_days: number;
|
||||||
|
n: number; // снятых объявлений в выборке
|
||||||
|
}
|
||||||
|
|
||||||
export interface CianValuationSummary {
|
export interface CianValuationSummary {
|
||||||
sale_price_rub: number | null;
|
sale_price_rub: number | null;
|
||||||
rent_price_rub: number | null;
|
rent_price_rub: number | null;
|
||||||
|
|
@ -234,7 +243,10 @@ export interface AggregatedEstimate {
|
||||||
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
|
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
|
||||||
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
||||||
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
||||||
est_days_on_market: number | null; // прогноз срока продажи
|
// #2898: окно p25–p75; null — выборка < 30 или оценка старее бэкенда.
|
||||||
|
exposure_window?: ExposureWindow | null;
|
||||||
|
// Устарело: = exposure_window.p50_days. Показывать окно, не это число.
|
||||||
|
est_days_on_market: number | null;
|
||||||
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
||||||
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
||||||
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue