gendesign/tradein-mvp/backend/app/schemas/trade_in.py
bot-backend cf48e6d6c8
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m54s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
feat(tradein): позиция квартиры внутри когорты аналогов — перцентиль (#2899) (#2926)
2026-08-19 10:08:18 +00:00

840 lines
54 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.

"""Pydantic schemas for Trade-In Estimator.
POST /api/v1/trade-in/estimate → AggregatedEstimate
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, computed_field
class TradeInEstimateInput(BaseModel):
address: str = Field(min_length=3, max_length=500)
area_m2: float = Field(gt=10, lt=500)
rooms: int = Field(ge=0, le=10) # 0 = студия
floor: int | None = Field(default=None, ge=1, le=100)
total_floors: int | None = Field(default=None, ge=1, le=100)
year_built: int | None = Field(default=None, ge=1800, le=2100)
house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
has_balcony: bool | None = None
# Variant A: координаты, уже разрезолвленные автокомплитом/картой на фронте.
# Если переданы (и в пределах ЕКБ) — estimate использует их напрямую, минуя
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
lat: float | None = Field(default=None, ge=-90, le=90)
lon: float | None = Field(default=None, ge=-180, le=180)
# #2576: город, если известен фронту (например выбран отдельным полем UI).
# Опционально — без него geocode() внутри estimate_quality() БОЛЬШЕ НЕ
# подставляет "Екатеринбург" молча (см. app.services.geocoder), что раньше
# давало уверенно неверную цену для жителей других городов области (те же
# улица+дом существуют и в ЕКБ, и, например, в Нижнем Тагиле).
city_hint: str | None = Field(default=None, max_length=100)
# ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest
# (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер
# (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional —
# отсутствие поля сохраняет прежнее поведение.
target_fias_id: str | None = Field(default=None, max_length=64)
# #2044: опциональный радиус анализа (контрол РАДИУС на /trade-in/v2), метры.
# None → текущее дефолтное поведение (1000 м поиск аналогов, 2000 м fallback).
# Задан → и первичный, и fallback-поиск аналогов/сделок используют ровно этот
# радиус (без авто-расширения). Диапазон 1005000 м — sane guard.
radius_m: int | None = Field(default=None, ge=100, le=5000)
# CRM-поля (#395) — операционные, на расчёт оценки не влияют
ownership_type: str | None = Field(default=None, max_length=100)
has_mortgage: bool | None = None
# client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167).
# ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 229).
# Enforcement (НЕ здесь): app.services.estimator.estimate_quality проверяет
# `created_by is None and not consent -> 422` ДО первого INSERT адреса в
# trade_in_estimates. Здесь поле намеренно `bool | None = None`, а НЕ
# `Literal[True]` (как TradeInLeadInput.consent) — сделать True строго-
# обязательным на уровне Pydantic сломало бы B2B-пилотов: их согласие
# закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе.
consent: bool | None = None
class AnalogLot(BaseModel):
address: str
area_m2: float
rooms: int
floor: int | None
total_floors: int | None
price_rub: int
price_per_m2: int
listing_date: date | None
days_on_market: int | None
photo_url: str | None = None
# ── Per-comp coords для MAP / price↔exposure views (web features) ──
# ADDITIVE + OPTIONAL. lat/lon из listings.lat-lon / deals.lat-lon (ST_Y/ST_X(geom)).
# Nullable: radius-фильтрованные аналоги имеют 100% coords, но Tier S
# (same-building через house_id_fk / address-prefix) может включать
# address-only Avito-лоты без geom → None (graceful, frontend пропускает на карте).
lat: float | None = None
lon: float | None = None
# ── Новые поля (Слой 5.2 — clickable links) ──
source: str | None = None # 'avito' / 'cian' / 'domklik' / 'rosreestr'
source_url: str | None = None # ссылка на оригинальное объявление / сделку
distance_m: int | None = None # расстояние до целевой квартиры в метрах
# ── Confidence tier (PR M / #564 Phase 3) ──
# Только для rosreestr-сделок: T0_per_house (kadastr_num exact match),
# T1_per_street (street-level only). Open dataset Росреестра не имеет
# kadastr_num — все ДКП-сделки сейчас T1. Поле зарезервировано на случай
# будущего enrichment data feed (ЕГРН direct).
tier: str | None = None
# ── Честность даты (#1995) ──
# Rosreestr open dataset публикует ДКП-сделки с точностью до КВАРТАЛА
# (listing_date = deals.deal_date = period_start_date, первый день квартала —
# см. data/sql/01_schema_rosreestr_deals.sql, комментарий "Excluded: ... exact
# deal date"). Поэтому ВСЕ rosreestr-сделки одного квартала несут ОДИНАКОВЫЙ
# listing_date — это честное отражение granularity источника, а НЕ баг/заглушка
# (подтверждено live-аудитом prod: 9 кварталов, ровно 1 distinct deal_date на
# квартал). listings (avito/cian/yandex/domklik) несут реальную day-level дату
# скрапинга/парсинга. None — источник не задан (напр. устаревшая persisted-запись
# до этого поля — rehydrate default).
date_precision: Literal["day", "quarter"] | None = None
class CianChartPoint(BaseModel):
"""Одна точка 7-месячного chart Cian Valuation Calculator."""
date: str
price: float
class CianValuationSummary(BaseModel):
"""Cian Valuation Calculator данные для UI.
Источник: external_valuations table (source='cian_valuation').
Заполняется только при successful Cian Calculator call в estimator.py.
"""
sale_price_rub: int | None = None
rent_price_rub: int | None = None
chart: list[CianChartPoint] = Field(default_factory=list)
chart_change_pct: float | None = None
chart_change_direction: Literal["increase", "decrease", "neutral"] | None = None
class AvitoImvSummary(BaseModel):
"""Avito IMV (Индекс Market Value) якорь для target-дома (#651).
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
ценовой шкале. None если для дома нет свежей IMV-записи.
"""
recommended_price: int | None = None # рекомендованная цена Avito, ₽
lower_price: int | None = None # нижняя граница IMV-коридора, ₽
higher_price: int | None = None # верхняя граница IMV-коридора, ₽
market_count: int | None = None # объём рынка, на котором построена оценка
# #audit-5b: тонкий рынок — market_count < avito_imv_thin_market_threshold.
# True = IMV построен на малой выборке → reliability ↓. Фронт/estimator могут
# использовать для понижения уверенности или отображения предупреждения.
thin_market: bool = False
class DkpCorridor(BaseModel):
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
линия «коридор реальных сделок: XY млн»; если итоговая медиана ₽/м² выходит
за [low,high]×slack — добавляется текстовая пометка. НЕ хард-клампит оценку.
None / count=0 если по улице нет сопоставимых сделок.
"""
count: int # число ДКП-сделок в выборке
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: int # медиана ₽/м²
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор)
period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date)
# #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/
# median/high — включая city-wide widen, если сработал), НЕ по всей таблице.
# period_months отвечает на «где искали», а не «насколько свежи сделки»: прод
# 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок
# даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы
# враньём в пользу свежести именно для них.
# Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день
# квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974
# сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно
# {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи
# на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад».
# None = сделки без даты (в проде не встречается) — потребитель молчит.
latest_deal_date: date | None = None
class PriceTrendPoint(BaseModel):
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).
Источник: houses_price_dynamics (если заполнена) ИЛИ агрегация
house_placement_history по месяцам. month — 'YYYY-MM', ppm2 — медиана ₽/м².
"""
month: str # 'YYYY-MM'
ppm2: int # медиана ₽/м² за месяц
class AggregatedEstimate(BaseModel):
estimate_id: UUID
median_price_rub: int
range_low_rub: int
range_high_rub: int
median_price_per_m2: int
confidence: Literal["low", "medium", "high"]
confidence_explanation: str | None = None # «Найдено 15 аналогов, разброс ±7%»
# #698: ПОЛНОЕ число найденных аналогов — НЕ равно len(analogs) (тот обрезан до
# top-10, см. поле `analogs` ниже). Консьюмер должен брать счёт отсюда, а не из len().
n_analogs: int
# #2899: позиция ЭТОЙ квартиры внутри когорты аналогов, 1..99 — «какая доля
# аналогов дешевле». None = когорта меньше MARKET_PERCENTILE_MIN_N (15) либо
# оценки нет; ниже порога один соседний лот двигает ярлык на целую категорию.
#
# НЕ путать с location_index_pct (`GET /location-index`) — тот про РАЙОН против
# медианы города, а не про квартиру внутри своей выборки, и в цену не идёт.
# Показывать имеет смысл только вместе с n_analogs.
market_percentile: int | None = None
@computed_field # type: ignore[prop-decorator]
@property
def insufficient_data(self) -> bool:
"""#697: нет пригодной оценки — комплов/якоря не нашлось → headline median=0.
Фронт по этому флагу рендерит явное «недостаточно данных», а не буквальный
«0 ₽». Производное от median_price_rub (==0 ⇔ нет оценки), поэтому корректно
во всех конструкторах AggregatedEstimate автоматически (вкл. rehydrate на GET).
"""
return self.median_price_rub <= 0
period_months: int # 24
# #698: показываемый top-10 (обрезано в estimator.py:2026/2028) — НЕ полный список.
# Полное число аналогов — в n_analogs (len(analogs) ≤ 10 < n_analogs при большой выборке).
analogs: list[AnalogLot]
actual_deals: list[AnalogLot] # реальные продажи last 12 mo
expires_at: datetime
# PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности
# расчёта — тот остаётся expires_at (не путать, см. migration 240).
# NULL = неоплачено (весь текущий трафик, B2B pilots включительно).
retain_until: datetime | None = None
# ── Дополнительные метаданные ──
target_address: str | None = None # geocoded full address
target_lat: float | None = None
target_lon: float | None = None
# #2576: True если ни адрес, ни `TradeInEstimateInput.city_hint` не называли
# город явно — итоговый город (и, соответственно, набор аналогов/цена)
# определил геокодер-провайдер, а не пользователь. Честный сигнал для
# UI (снизить доверие / переспросить город), НЕ персистится в БД
# (ephemeral, только для текущего POST /estimate ответа).
target_city_ambiguous: bool = False
# #2626: True если координаты дал ПОСЛЕДНИЙ тир geocode() — fallback на `houses`
# (см. `app.services.geocoder._local_houses_match`), а не Nominatim/geoportal/
# cadastral. Значит адрес пользователя не совпал буквально (разговорное/усечённое
# имя улицы или отсутствующий корпус), но был однозначно сопоставлен с домом из
# скрейпленных листингов. Честный сигнал для UI («адрес уточнён автоматически»),
# НЕ персистится в БД (ephemeral, как и `target_city_ambiguous`).
target_address_refined: bool = False
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов
last_scraped_at: datetime | None = None
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
cian_valuation: CianValuationSummary | None = None
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
# fallback агрегация house_placement_history по месяцам для target_house_id.
# None если house_id не разрешён / нет истории (graceful, frontend скрывает chart).
price_trend: list[PriceTrendPoint] | None = None
# ── #651/#652: внешние якоря (Avito IMV) + коридор реальных сделок (ДКП) ──
# avito_imv — реальная IMV-оценка дома (house_imv_evaluations). Используется
# как anchor для blend'а медианы (см. confidence_explanation).
# dkp_corridor — коридор ₽/м² по ДКП-сделкам Росреестра (advisory, не клампит).
# Оба nullable — None при отсутствии данных (graceful, common case без регрессий).
avito_imv: AvitoImvSummary | None = None
dkp_corridor: DkpCorridor | None = None
# ── Asking→sold correction (#648 Stage 3) — PURELY ADDITIVE ──
# Headline median_price_rub/range_*/median_price_per_m2 остаются ASKING (активные
# объявления). Эти параллельные expected_sold_* = asking × per-rooms ratio
# (asking_to_sold_ratios, migration 080) — релевантная для выкупа цена сделки.
# Backtest (#648 Stage 1) показал, что коррекция убирает bias asking-медианы
# +20% → 4% на held-out ДКП. None если ratio-таблицы нет / бакет пуст (graceful).
expected_sold_price_rub: int | None = None
expected_sold_range_low_rub: int | None = None
expected_sold_range_high_rub: int | None = None
expected_sold_per_m2: int | None = None
# #2087 M3: этот ratio — asking→sold дисконт ДЛЯ ЭТОГО эстимейта (показанная
# asking-медиана median_price_rub vs ожидаемая цена продажи expected_sold_price_rub
# по ДКП-корректировке Росреестра, per-rooms/tier коэффициент; #2141 гарантирует
# asking_to_sold_ratio == expected_sold_price_rub/median_price_rub байт-в-байт).
# НЕ путать с HouseAnalyticsKpi.median_bargain_pct — это торг ВНУТРИ жизни
# объявления (start_price→last_price до снятия, house_placement_history по
# конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг
# HouseAnalyticsKpi.
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.720.93
# 'per_rooms' | 'global_fallback' | 'expected_over_median' (LOW audit #2: set when
# #2141's honest_ratio recompute overwrites the raw ratio_resolver value below).
ratio_basis: str | None = None
# ── DaData enrichment (PR Q1) — on-demand для target адреса ──
# canonical_address — DaData-нормализованная форма (с улицей в short form).
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
# house_fias_id — UUID ФИАС дома.
# metro_nearest — ближайшие станции метро [{name,line,distance}, ...].
# NULL если DaData credentials не заданы / quota exceeded / address не распознан.
canonical_address: str | None = None
house_cadnum: str | None = None
house_fias_id: str | None = None
metro_nearest: list[dict] = Field(default_factory=list)
# address_precision — точность гео-привязки адреса (из DaData qc_geo):
# «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
# «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
# None если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы.
address_precision: Literal["house", "street", "approximate"] | None = None
# analog_tier — стабильный enum-тир источника аналогов (#audit-2).
# Значения (дословно, фронт на них завязан):
# "same_building" — Tier A: якорь из комплов ТОГО ЖЕ дома
# "micro_radius" — Tier C: якорь из micro-radius (≤500 м)
# "district" — radius-путь (Tier 0/S/H): когортный/узкий радиус
# "city" — radius Tier W: широкий fallback
# null — нет данных / оценка не построена
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
# search_radius_m — фактический радиус (метры), по которому реально отбирались
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
# при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад
# #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно,
# контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему
# выбору) — иначе карта врёт о реально использованном радиусе.
# На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ
# (estimator.rehydrate_search_radius_m): из persisted-подписи каскада
# «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из
# размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у
# _empty_estimate (поиск не выполнялся) и у старых строк без расстояний;
# фронт тогда fallback'ает на выбор пользователя, как раньше.
search_radius_m: int | None = None
# requested_radius_m — радиус, с которого поиск НАЧАЛСЯ: явный выбор
# пользователя (TradeInEstimateInput.radius_m) либо DEFAULT_RADIUS_M, если он
# выбрал «Авто». Отдаётся рядом с фактическим, чтобы ответ нёс ОБЕ величины —
# что просили и что получилось — и потребителю не приходилось выводить
# расхождение из своего локального состояния. None на GET-rehydrate:
# radius_m не персистится, а угадывать «просили 1 км» за пользователя —
# ровно та подмена входа результатом, которую чинит это поле.
requested_radius_m: int | None = None
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
# premium_building — целевой дом признан премиальным. Источник — curated overlay
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
# positive demotions), с fallback на MV `premium_houses` (data/sql/139, ~298
# EKB-домов по концентрации дорогих листингов) для невыверенных домов. Значение —
# уже скорректированное (curated перебивает MV). МЕТАДАННЫЕ для manual-review
# routing (#4) — НЕ влияет на median/expected_sold/ranges. Дефолт False.
# premium_building_median_ppm2 — med_ppm2 из MV (₽/м² медиана дорогих листингов дома),
# контекст для ревьюера. None если дом не премиальный / MV недоступна.
# premium_building_class — класс дома из curated overlay (премиум/элит/бизнес/
# комфорт/эконом/неизвестно). None для невыверенных MV-домов / непремиальных.
premium_building: bool = False
premium_building_median_ppm2: int | None = None
premium_building_class: str | None = None
# ── #2002 #4: manual-review recommendation (производный ФЛАГ, НЕ цена) ──
# manual_review_recommended — оценку НЕ стоит авто-оффэрить, нужна ручная
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
# рекомендация не сработала.
manual_review_recommended: bool = False
manual_review_reasons: list[str] = Field(default_factory=list)
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──
# cv — коэффициент вариации ₽/м² по аналогам (std/mean). Метрика разброса цен:
# <0.10 «тесно» / 0.10-0.20 «умеренно» / >0.20 «широко». Anchor-путь берёт
# CV комплов дома, radius-путь — CV радиусной выборки ₽/м². None если <2 цен.
# На GET /estimate/{id} пересчитывается из сохранённых analogs (best-effort).
# source_counts — счётчики аналогов по источнику ({'avito': 12, 'cian': 5}). На
# POST считается по ПОЛНОЙ выборке до top-N отсечки; на GET/rehydrate — по
# сохранённым top-N analogs (усечённо, best-effort). Пусто при отсутствии.
# created_at — момент создания оценки (колонка trade_in_estimates.created_at),
# для метки «отчёт от DD.MM» в UI. None если не проставлен.
cv: float | None = None
source_counts: dict[str, int] = Field(default_factory=dict)
created_at: datetime | None = None
# ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10,
# #oblast-E priority RESTORED same day — see estimator.py module
# docstring for the full 3-way headline-source rule) ──────────────────
# Product requirement: an estimate is ALWAYS surfaced — a thin base sample
# (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First
# estimator.estimate_quality() progressively relaxes the analog SEARCH
# (room-count adjacency → freshness window → novostroyki segment → radius)
# trying to grow the sample past the threshold; if it's STILL thin,
# _price_from_inputs() prefers a usable ДКП deals corridor over a noisy
# thin listings median when one is available (restored #oblast-E
# priority — the Серов repro: 3 listings must not outrank 54 deals), and
# only falls back to the thin listings median itself when no corridor
# exists. Real refusal happens only at genuine zero (no listings AND no
# usable anchor/deals).
# relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага
# ослабления, готовые к показу пользователю как честный дисклеймер рядом с
# confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад
# не понадобился (обычный случай). Возможные значения (дословно, фронт
# может на них завязываться): "снят фильтр по году постройки",
# "учтены студии", "комнатность ±1", "объявления за 60 дней",
# "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м",
# "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП
# deals corridor because the base listings sample was thin — a source
# SWITCH, not a search widening, but surfaced the same way).
# reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs
# (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если
# relaxations непусто (выборка набралась только ценой ослаблений); капается
# на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой
# выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего».
# НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное
# ограничение, каскад не переигрывается из сохранённых analogs). НЕ
# путать с `confidence` (Literal low/medium/high — старая метрика на
# основе уникальных адресов/IQR, см. её собственный докстринг выше).
relaxations: list[str] = Field(default_factory=list)
reliability: Literal["ok", "low", "very_low"] = "ok"
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
area_m2: float | None = None
rooms: int | None = None
floor: int | None = None
total_floors: int | None = None
year_built: int | None = None
house_type: str | None = None
repair_state: str | None = None
has_balcony: bool | None = None
class PhotoMeta(BaseModel):
"""Метаданные фото квартиры (#394). Содержимое отдаётся отдельным эндпоинтом."""
id: UUID
filename: str | None = None
content_type: str
size_bytes: int | None = None
uploaded_at: datetime
# ── Stage 4a response schemas ────────────────────────────────────────────────
class HouseInfoForEstimate(BaseModel):
"""Summary информации о доме целевой квартиры (для GET /estimate/{id}/houses)."""
house_id: int | None = None
source: str | None = None # 'avito' / 'derived' / 'cian_newbuilding' / etc.
ext_house_id: str | None = None
address: str | None = None
short_address: str | None = None
lat: float | None = None
lon: float | None = None
year_built: int | None = None
total_floors: int | None = None
house_type: str | None = None
passenger_elevators: int | None = None
cargo_elevators: int | None = None
has_concierge: bool | None = None
closed_yard: bool | None = None
has_playground: bool | None = None
parking_type: str | None = None
developer_name: str | None = None
rating: float | None = None
reviews_count: int | None = None
raw_characteristics: list[dict] = Field(default_factory=list)
class IMVBenchmarkResponse(BaseModel):
"""Avito IMV benchmark для UI (GET /estimate/{id}/imv-benchmark)."""
available: bool # есть ли IMV для этого estimate
cache_key: str | None = None
recommended_price: int | None = None
lower_price: int | None = None
higher_price: int | None = None
market_count: int | None = None
fetched_at: datetime | None = None
# comparison vs our estimate
our_median_price: int | None = None
diff_pct: float | None = None # (our - imv) / imv * 100
class PlacementHistoryEntry(BaseModel):
"""Запись истории размещения лота в доме (`house_placement_history`)."""
id: int
source: str
house_id: int | None = None
ext_item_id: str
title: str | None = None
rooms: int | None = None
area_m2: float | None = None
floor: int | None = None
total_floors: int | None = None
start_price: int | None = None
start_price_date: date | None = None
last_price: int | None = None
last_price_date: date | None = None
removed_date: date | None = None
exposure_days: int | None = None
notes: str | None = None
# ── In-app scheduler (Stage 4e) ─────────────────────────────────────────────
class ScheduleConfig(BaseModel):
"""Текущая конфигурация schedule для source."""
id: int
source: str
enabled: bool
window_start_hour: int = Field(ge=0, le=23)
window_end_hour: int = Field(ge=0, le=23)
default_params: dict[str, Any] = Field(default_factory=dict)
last_run_id: int | None = None
last_run_at: str | None = None # ISO
next_run_at: str | None = None # ISO
updated_at: str | None = None # ISO
class ScheduleConfigUpdate(BaseModel):
"""PUT body для update_schedule endpoint."""
enabled: bool = True
window_start_hour: int = Field(default=2, ge=0, le=23)
window_end_hour: int = Field(default=5, ge=0, le=23)
default_params: dict[str, Any] = Field(default_factory=dict)
# #2674: явная воля оператора по времени следующего запуска. None (умолчание) —
# «не трогай, посчитай сам от такта». Заданное значение уважается как есть, включая
# прошедшее/now() — это и есть «запустить сейчас» (планировщик берёт строки с
# next_run_at <= NOW()), у которого до сих пор не было API и его делали UPDATE'ом.
next_run_at: datetime | None = None
# ── House analytics (house_placement_history backfill) ───────────────────────
class PriceHistoryYearPoint(BaseModel):
"""Медианная цена ₽/м² и число лотов за год, разбитая по источнику."""
year: int
source: str # 'avito_imv' | 'yandex_valuation'
median_price_per_m2: int
n_lots: int
median_price_rub: int
class CianPriceChangeStats(BaseModel):
"""Статистика изменений цены для одного Cian-аналога.
Источник: offer_price_history JOIN listings (source='cian').
"""
cian_id: str
listing_id: int
n_changes: int # COUNT(*) из offer_price_history
last_change_time: datetime | None
last_diff_percent: float | None # последняя дельта (-5% если цена снизилась)
total_change_pct: float | None # суммарно (current - first) / first * 100
first_seen_price: int | None
current_price: int # из listings.price_rub
class RecentSoldEntry(BaseModel):
"""Лот из house_placement_history снятый с продажи за последние 12 мес."""
id: int
source: str # 'avito_imv' | 'yandex_valuation'
rooms: int | None
area_m2: float | None
floor: int | None
start_price: int | None
last_price: int | None
removed_date: date | None
exposure_days: int | None
discount_pct: float | None
class HouseAnalyticsKpi(BaseModel):
"""Агрегированные KPI по дому(ам) из house_placement_history.
#2087 M3: median_bargain_pct — торг ВНУТРИ жизни объявления (медиана
(start_price-last_price)/start_price×100 по ВСЕМ лотам house_placement_history
для дома/домов — не только снятым, без ограничения по давности; 12-мес окно
есть только у отдельного recent_sold_rows-запроса, не у этого поля). Другая база
и другой смысл «торга», чем AggregatedEstimate.asking_to_sold_ratio (asking-медиана
ЭТОГО эстимейта vs ожидаемая ДКП-цена продажи, per-rooms/tier коэффициент) —
не суммировать и не подписывать в UI как одну и ту же метрику.
"""
total_lots: int
sold_count: int
sold_rate_pct: float
median_exposure_days: int | None
# медиана (start_price-last_price)/start_price×100 по всем лотам дома, без sold-фильтра
# и без ограничения по давности (см. докстринг класса)
median_bargain_pct: float | None
class HouseAnalyticsResponse(BaseModel):
"""Ответ GET /estimate/{id}/house-analytics."""
house_ids: list[int]
radius_m: int
price_history: list[PriceHistoryYearPoint]
recent_sold: list[RecentSoldEntry]
kpi: HouseAnalyticsKpi
# ── Sell-time sensitivity (срок продажи по бакетам цены) ─────────────────────
class SellTimeBucket(BaseModel):
"""Один бакет срока продажи для данного ценового диапазона."""
price_premium_label: str # 'cheap' | 'median' | 'plus5' | 'plus10'
price_premium_pct: float # -5.0, 0.0, 5.0, 10.0 для UI
median_exposure_days: int | None
p25_days: int | None
p75_days: int | None
n_lots: int
# #1995: n_lots < settings.sell_time_sensitivity_min_n_lots → малая выборка,
# median/p25/p75 exposure_days шумные (немонотонность между бакетами — типичный
# артефакт, не data-баг). Фронт должен явно показать "недостаточно данных"
# вместо тихого шума. Не сглаживаем/не переоцениваем статистику — честный флаг.
insufficient_data: bool = False
class SellTimeSensitivityResponse(BaseModel):
"""Ответ GET /estimate/{id}/sell-time-sensitivity."""
house_ids: list[int]
radius_m: int
target_median_price_per_m2: int | None # benchmark — медиана ₽/м² за последние 2 года
buckets: list[SellTimeBucket]
# ── Street-level deals (rosreestr open dataset) ──────────────────────────────
class StreetDealsResponse(BaseModel):
"""Ответ GET /api/v1/trade-in/street-deals.
Open dataset Росреестра агрегирует адреса до уровня улицы (без номера дома).
Поэтому это per-street view, а не per-house.
"""
# извлечённая улица, напр. «Космонавтов» / None если не определилась
street: str | None
period_from: date
period_to: date
count: int # число всех matching сделок, не только топ-10
median_price_rub: int # 0 если count == 0
median_price_per_m2: int
range_low_rub: int
range_high_rub: int
deals: list[AnalogLot] # последние 10 по deal_date DESC
# ── Sales vs Listings (PR K, issue #564 Foundation Phase 1) ─────────────────
class SalesListingPair(BaseModel):
"""Пара (ДКП-сделка, listing того же ассортимента).
Возвращается из street_sales_vs_listings() SQL-function. Если для сделки
listing не нашёлся — все listing_* поля = None (LEFT JOIN).
"""
deal_id: int
deal_date: date
deal_price_rub: int
deal_price_per_m2: int
deal_area_m2: float
deal_rooms: int
deal_floor: int | None = None
deal_address: str | None = None
listing_id: int | None = None
listing_source: str | None = None # 'avito' / 'cian' / 'yandex' / 'domklik' (+ inactive 'n1')
listing_source_url: str | None = None
listing_date: date | None = None
listing_price_rub: int | None = None
listing_price_per_m2: int | None = None
listing_area_m2: float | None = None
# Положительный = listing date раньше сделки (типичный кейс).
# Отрицательный = listing появился позже (отложенный парсинг).
days_listing_to_deal: int | None = None
# discount_pct = (deal_price - listing_price) / listing_price * 100.
# Отрицательный = продали дешевле выставленного (торг).
discount_pct: float | None = None
# #1995: честность precision deal_date. Rosreestr open dataset публикует ДКП с
# точностью до КВАРТАЛА (deal_date = period_start_date, 1-е число квартала —
# см. data/sql/01_schema_rosreestr_deals.sql), НЕ реальную дату регистрации.
# street_sales_vs_listings() фильтрует ТОЛЬКО source='rosreestr' → сейчас
# precision одинаковая ("quarter") для всех pairs этого endpoint'а. Live-аудит
# prod (2026-07): 9 загруженных кварталов — ровно 1 distinct deal_date на
# квартал, подтверждает НЕ баг, а granularity источника. Per-pair (не
# response-level) — задел на случай будущего source с exact-датой (etazhi/
# domklik_history, см. deals table comment).
deal_date_precision: Literal["day", "quarter"] = "quarter"
class SalesVsListingsResponse(BaseModel):
"""Ответ GET /api/v1/trade-in/sales-vs-listings.
Per-street pairs ДКП-сделок и matching listings. Aggregate KPIs показывают
linkage rate и медианный discount.
"""
street: str | None # извлечённое имя улицы, None если не извлеклось
period_months: int # окно поиска сделок
window_days: int # окно matching listing → deal
area_tolerance: float # 0.15 = ±15% по area_m2
total_deals: int # количество всех matching ДКП в улице/период
deals_with_listings: int # сколько имеют связанный listing
linkage_rate_pct: float # deals_with_listings / total_deals * 100
median_discount_pct: float | None # медиана по парам с listing
# #2666: None вместе с median_discount_pct=None означает «медианы просто нет»
# (пар не нашлось). Непустая строка = медиана посчиталась, но не прошла гейт
# правдоподобия (мало пар / значение вне санитарного диапазона — см. пороги
# SALES_VS_LISTINGS_* в api/v1/trade_in.py) и намеренно не показывается.
# Форма отказа зеркалит confidence_explanation оценщика: пользователю нужен
# текст «почему числа нет», иначе пустое место читается как поломка виджета.
median_discount_explanation: str | None = None
data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3)
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
# ── Account quota (#quota) ──────────────────────────────────────────────────
class QuotaStatus(BaseModel):
"""Статус квоты оценок для текущего аккаунта (GET /api/v1/trade-in/quota)."""
limit: int # MONTHLY_LIMIT = 15
used: int # использовано в текущем месяце
remaining: int # max(0, limit - used)
unlimited: bool # True для admin / kopylov / без заголовка
class NearbyPoiOut(BaseModel):
"""Один пункт «что рядом» в ответе GET /api/v1/trade-in/location-index.
Качественная справка (школа 185 м, остановка 93 м) — НЕ участвует в location_index_pct.
"""
poi_type: str # категория POI (school/kindergarten/metro_stop/... — те же значения,
# что в osm_poi_ekb на стороне gendesign)
name: str | None
distance_m: float
class LocationIndexResponse(BaseModel):
"""Ответ GET /api/v1/trade-in/location-index — замена сломанного location-coef.
ИСТОРИЯ: старый `location-coef` (`coef = 0.95 + score/100*0.10`, range [0.95,1.05],
`result_price_rub = round(base_price_rub * coef)`) не был откалиброван на ценах — 67% из
1500 адресов ЕКБ попадали в ±1%, а бакеты coef НЕ монотонны относительно медианы ₽/м² по
4000 активным лотам (дороже — не значит выше coef). Полностью заменён.
location_index_pct — % отклонения медианы ₽/м² сопоставимых активных листингов в радиусе
точки от медианы ₽/м² по всему Екатеринбургу (см. app/services/location_index.py). НЕ
зажат искусственно — диапазон реальный. НЕ участвует в цене (estimator.py про него не
знает: аналоги уже несут локацию в базовой цене, повторное умножение — double-count).
status:
- "ok" — location_index_pct/local_median_price_per_m2 надёжны.
- "out_of_coverage" — точка вне гео-охвата продукта (Екатеринбург). Все числовые
поля None — честный прочерк на фронте, НЕ 0%.
- "insufficient_data" — даже на максимальном радиусе сопоставимых активных листингов
меньше порога (см. MIN_SAMPLE_SIZE). Числовые поля None, но sample_size/radius_m
показывают, что реально нашлось.
poi_status — независимый статус для nearby_poi: "ok" | "unavailable" (osm_poi_ekb_local
ещё не отрефрешена на этом окружении — пустой список, НЕ сфабрикованные точки).
"""
status: str
location_index_pct: float | None
local_median_price_per_m2: int | None
city_median_price_per_m2: int | None
sample_size: int
radius_m: int
nearby_poi: list[NearbyPoiOut]
poi_status: str
class CoverageProbeInput(BaseModel):
"""Вход POST /api/v1/trade-in/coverage (issue #2894) — бесплатная проба покрытия.
lat/lon — координаты, уже разрезолвленные фронтом (тот же контракт, что
TradeInEstimateInput.lat/lon — geocode делает фронт/автокомплит, эта ручка
сама НИКОГО не геокодирует). Город (и, соответственно, порог ok/thin) для
ответа резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon — см.
`app.api.v1.trade_in._resolve_coverage_city`.
city_hint — ИНФОРМАЦИОННОЕ поле, на результат НЕ влияет (повторная проверка
#2894, 2026-08). Раньше оно участвовало в резолве города как фолбэк —
убрано вместе с модой `listings.city`: оба источника ненадёжны (`city_hint`
— непроверенный клиентский вход, `listings.city` — город свип-контекста
скрейпера, не адреса объявления, см. комментарий в trade_in.py). Поле
оставлено в схеме, потому что фронт его уже шлёт в других ручках того же
автокомплита (см. TradeInEstimateInput.city_hint) — принимаем и молча
игнорируем, чтобы не ронять запрос лишней 422.
"""
lat: float = Field(ge=-90, le=90)
lon: float = Field(ge=-180, le=180)
rooms: int = Field(ge=0, le=10) # 0 = студия
area_m2: float = Field(gt=10, lt=500)
city_hint: str | None = Field(default=None, max_length=100)
class CoverageProbeResponse(BaseModel):
"""Ответ POST /api/v1/trade-in/coverage.
НАМЕРЕННО без единой цены (ни медианы, ни диапазона, ни ₽/м²) — продуктовое
правило issue #2894: бесплатный шаг доказывает, что похожие квартиры есть
и как быстро они уходят, а саму цену продукт продаёт на платном шаге.
status:
- "ok" — n_listings >= порога для этого города (зелёный/жёлтый список).
- "thin" — когорта непустая, но n_listings < порога.
- "not_covered" — город вне зелёного/жёлтого списка ИЛИ когорта пустая
(n_listings == 0) — независимо от того, поддерживается город или нет.
median_listing_age_days — ЧЕСТНОЕ имя: возраст АКТИВНОГО объявления
(days_on_market на текущий момент), а НЕ срок до продажи. Цензурированная
выборка (активные объявления ещё висят) всегда завышена относительно
реального времени экспозиции проданных — не путать со «сроком продажи».
ОГРАНИЧЕНИЕ ДАННЫХ (не продуктовое решение, см. coverage_probe docstring):
days_on_market на проде заполнена практически только у источника yandex —
возраст известен у меньшинства строк когорты. n_with_age ниже — честный
счётчик, по скольким объявлениям посчитана медиана; при n_with_age < порога
(COVERAGE_MIN_AGE_SAMPLES) median_listing_age_days принудительно null.
n_with_age — сколько объявлений когорты реально имеют известный
(non-null, не-выброс) days_on_market и вошли в расчёт медианы. Фронт
обязан иметь возможность не показывать median_listing_age_days при
маленьком n_with_age — цифра "медиана" по 1-2 объявлениям не медиана.
threshold — n, начиная с которого статус переходит в "ok" для резолвленного
города; 0 всегда, когда status == "not_covered" (порог неприменим — ни для
города вне зелёного/жёлтого списка, ни для поддерживаемого города с пустой
когортой), НЕ только для неподдерживаемого города.
city — резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon запроса (ближайший центроид из
зелёного/жёлтого списка в пределах `COVERAGE_CITY_MATCH_RADIUS_KM`), не из
`city_hint` и не из моды `listings.city` найденной когорты — см.
`app.api.v1.trade_in._resolve_coverage_city`.
"""
status: Literal["ok", "thin", "not_covered"]
n_listings: int
median_listing_age_days: int | None
n_with_age: int
radius_m: int
city: str
threshold: int