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
840 lines
54 KiB
Python
840 lines
54 KiB
Python
"""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-поиск аналогов/сделок используют ровно этот
|
||
# радиус (без авто-расширения). Диапазон 100–5000 м — 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: показывается как тонкая референсная
|
||
линия «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит
|
||
за [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.72–0.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
|