fix(mera/b2c): восемь находок финального аудита прода — включая три моих собственных #3247

Merged
bot-backend merged 9 commits from fix/audit-all into main 2026-08-29 19:23:45 +00:00
18 changed files with 688 additions and 60 deletions

View file

@ -70,6 +70,7 @@ import asyncio
import hashlib import hashlib
import logging import logging
import secrets import secrets
from collections.abc import Callable
from datetime import datetime from datetime import datetime
from typing import Annotated, Literal from typing import Annotated, Literal
@ -185,6 +186,33 @@ def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None
limiter.record(ip) limiter.record(ip)
def _budget(limiter: SlidingWindowLimiter, what: str) -> Callable[[Request], None]:
"""Per-IP бюджет КАК ЗАВИСИМОСТЬ, а не первой строкой тела.
Ровно та же поправка места, что уже сделана у
`_require_public_estimate_enabled` (см. его докстринг): FastAPI решает
зависимости РАНЬШЕ, чем валидирует тело, поэтому проверка в теле не
срабатывает на запросах, которые падают на разборе тела до неё просто не
доходит.
Для флага это стоило утечки схемы, для лимитера неограниченного потока
отказов: 12 запросов подряд с некорректным телом на `/coverage` дали
двенадцать ответов и ни одного 429 (замер 30.08.2026). Пока такой вход
ронял сериализацию 422 (чинится в app/core/http_errors.py), это был поток
500 с одного адреса; после починки поток 422, но всё так же мимо бюджета.
ЧЕГО ЭТА ЗАВИСИМОСТЬ НЕ ЛОВИТ: тело, которое не разбирается как JSON
вообще там `RequestValidationError` летит до решения зависимостей. Такой
запрос стоит один `json.loads` и остаётся под общим `RateLimitMiddleware`
(300/60с на IP); заводить ради него middleware поверх middleware смысла нет.
"""
def _dep(request: Request) -> None:
_enforce(limiter, request, what)
return _dep
class PublicSuggestInput(BaseModel): class PublicSuggestInput(BaseModel):
"""Вход публичного автокомплита. """Вход публичного автокомплита.
@ -231,9 +259,12 @@ def _query_with_city(query: str, city_hint: str | None) -> str:
return f"{city_hint}, {query}" return f"{city_hint}, {query}"
@router.post("/suggest", response_model=SuggestResponse) @router.post(
"/suggest",
response_model=SuggestResponse,
dependencies=[Depends(_budget(_suggest_limiter, "suggest"))],
)
async def public_suggest( async def public_suggest(
request: Request,
payload: PublicSuggestInput, payload: PublicSuggestInput,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> SuggestResponse: ) -> SuggestResponse:
@ -260,8 +291,6 @@ async def public_suggest(
десятка в публичном UI не показывается, а каждый лишний кандидат может десятка в публичном UI не показывается, а каждый лишний кандидат может
стоить внешнего вызова. стоить внешнего вызова.
""" """
_enforce(_suggest_limiter, request, "suggest")
# Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его # Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его
# собственным лимитом, и только оставшееся считаем в общий бюджет. # собственным лимитом, и только оставшееся считаем в общий бюджет.
daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY) daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY)
@ -302,9 +331,12 @@ async def public_suggest(
_suggest_slots.release() _suggest_slots.release()
@router.post("/coverage", response_model=CoverageProbeResponse) @router.post(
"/coverage",
response_model=CoverageProbeResponse,
dependencies=[Depends(_budget(_coverage_limiter, "coverage"))],
)
def public_coverage( def public_coverage(
request: Request,
payload: CoverageProbeInput, payload: CoverageProbeInput,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> CoverageProbeResponse: ) -> CoverageProbeResponse:
@ -318,7 +350,6 @@ def public_coverage(
Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) бесплатный Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) бесплатный
шаг доказывает наличие данных, цену продаёт платный. шаг доказывает наличие данных, цену продаёт платный.
""" """
_enforce(_coverage_limiter, request, "coverage")
return coverage_probe(payload=payload, db=db) return coverage_probe(payload=payload, db=db)
@ -351,9 +382,12 @@ _STATS_SQL = text("""
""") """)
@router.get("/stats", response_model=dict[str, LandingStat]) @router.get(
"/stats",
response_model=dict[str, LandingStat],
dependencies=[Depends(_budget(_stats_limiter, "stats"))],
)
def public_stats( def public_stats(
request: Request,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> dict[str, LandingStat]: ) -> dict[str, LandingStat]:
"""Витринные метрики лэндинга — готовый ночной срез (issue: числа по проду). """Витринные метрики лэндинга — готовый ночной срез (issue: числа по проду).
@ -374,8 +408,6 @@ def public_stats(
`value` числовое value_num, если оно есть; иначе value_text (для метрик, `value` числовое value_num, если оно есть; иначе value_text (для метрик,
у которых значение не число). Оба NULL отдаём null, а не выдуманный ноль. у которых значение не число). Оба NULL отдаём null, а не выдуманный ноль.
""" """
_enforce(_stats_limiter, request, "stats")
rows = db.execute(_STATS_SQL).fetchall() rows = db.execute(_STATS_SQL).fetchall()
return { return {
row.metric: LandingStat( row.metric: LandingStat(
@ -499,9 +531,12 @@ _SHOWCASE_SQL = text(
) )
@router.get("/showcase", response_model=ShowcaseResponse) @router.get(
"/showcase",
response_model=ShowcaseResponse,
dependencies=[Depends(_budget(_showcase_limiter, "showcase"))],
)
def public_showcase( def public_showcase(
request: Request,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> ShowcaseResponse: ) -> ShowcaseResponse:
"""Витрина лэндинга: реальные ДКП-сделки против прогноза МЕРЫ. """Витрина лэндинга: реальные ДКП-сделки против прогноза МЕРЫ.
@ -518,7 +553,6 @@ def public_showcase(
годных строк не поместилось и по какому правилу отсеяно остальное. Числа годных строк не поместилось и по какому правилу отсеяно остальное. Числа
считает пересчёт; без них витрина не имеет права подписаться честно. считает пересчёт; без них витрина не имеет права подписаться честно.
""" """
_enforce(_showcase_limiter, request, "showcase")
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first() run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
if run is None: if run is None:
return ShowcaseResponse(computed_at=None, deals=[], stats=None) return ShowcaseResponse(computed_at=None, deals=[], stats=None)
@ -693,7 +727,13 @@ def _coverage_for(
@router.post( @router.post(
"/estimate", "/estimate",
response_model=PublicEstimateResult, response_model=PublicEstimateResult,
dependencies=[Depends(_require_public_estimate_enabled)], # Порядок несущий: флаг ПЕРВЫМ. Лимитер впереди него отвечал бы 429 на
# выключенной ручке, а несуществующий путь даёт 401 — то есть 429 снова
# подтверждал бы существование ручки, ровно то, что чинил флаг-гейт.
dependencies=[
Depends(_require_public_estimate_enabled),
Depends(_budget(_estimate_limiter, "estimate")),
],
) )
async def public_estimate( async def public_estimate(
request: Request, request: Request,
@ -714,8 +754,6 @@ async def public_estimate(
контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по
своему токену. Наружу здесь уезжает только `PublicEstimateResult`. своему токену. Наружу здесь уезжает только `PublicEstimateResult`.
""" """
_enforce(_estimate_limiter, request, "estimate")
daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY) daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY)
if daily_retry is not None: if daily_retry is not None:
logger.error( logger.error(
@ -773,10 +811,13 @@ async def public_estimate(
@router.post( @router.post(
"/estimate/read", "/estimate/read",
response_model=PublicEstimateResult, response_model=PublicEstimateResult,
dependencies=[Depends(_require_public_estimate_enabled)], # Флаг первым — по той же причине, что у `/estimate`.
dependencies=[
Depends(_require_public_estimate_enabled),
Depends(_budget(_estimate_read_limiter, "estimate-read")),
],
) )
def public_estimate_read( def public_estimate_read(
request: Request,
payload: PublicEstimateTokenInput, payload: PublicEstimateTokenInput,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> PublicEstimateResult: ) -> PublicEstimateResult:
@ -791,8 +832,6 @@ def public_estimate_read(
Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их
значит подтверждать существование расчёта тому, кто угадал токен. значит подтверждать существование расчёта тому, кто угадал токен.
""" """
_enforce(_estimate_read_limiter, request, "estimate-read")
row = db.execute( row = db.execute(
text( text(
""" """

View file

@ -0,0 +1,59 @@
"""Ответ об ошибке валидации, который собирается при ЛЮБОМ входе.
ЧТО СЛОМАЛОСЬ
-------------
Аудит живого сайта 30.08.2026: публичная проба покрытия отвечала 500 на входе,
который обязан отсеиваться валидацией::
POST /trade-in/api/public/mera/coverage
{"lat":56.8,"lon":1e400,"rooms":2,"area_m2":50} 500
Разбор. `json.loads` принимает то, чего нет в стандарте JSON: литералы
`Infinity`, `-Infinity`, `NaN`, а `1e400` даёт `inf` переполнением. Pydantic
такое поле честно отбивает по границам (`lon: le=180`) и кладёт значение в
`input` ошибки. Дальше штатный обработчик FastAPI отдаёт перечень ошибок через
`JSONResponse`, а тот сериализует `json.dumps(..., allow_nan=False)` и падает
уже ПОСЛЕ входа в ответ. Наружу это 500, то есть отказ сервера там, где
корректный ответ 422.
ПОЧЕМУ ОДИН ОБРАБОТЧИК, А НЕ ВАЛИДАТОР НА ПОЛЕ
----------------------------------------------
Чинить по одному полю значит починить `lon` и оставить `lat`, `area_m2`,
`limit` и каждое число каждой будущей схемы. Ломается не поле: ломается
сборка ОТВЕТА об ошибке, одна на всё приложение. Здесь она и чинится.
Отдельным модулем (а не строкой в `app/main.py`) ровно затем, чтобы тест мог
поставить ТОТ ЖЕ обработчик на своё маленькое приложение, не втягивая весь
граф роутеров: иначе тестовое приложение отвечало бы иначе, чем прод, и
проверка «422, а не 500» была бы зелёной по построению.
"""
from __future__ import annotations
import math
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
def _json_safe_float(value: float) -> float | str:
"""inf/nan → строка. JSON их не умеет, а на ВХОД они приходят законно."""
return value if math.isfinite(value) else str(value)
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Тот же стандартный `{"detail": [...]}`, но нефинитное число в `input`
едет строкой ("inf"/"nan") вместо того, чтобы ронять ответ."""
return JSONResponse(
status_code=422,
content=jsonable_encoder(
{"detail": exc.errors()},
custom_encoder={float: _json_safe_float},
),
)
def install_validation_error_handler(app: FastAPI) -> None:
app.add_exception_handler(RequestValidationError, validation_error_handler)

View file

@ -43,6 +43,7 @@ from app.core.auth_db import get_auth_engine
from app.core.config import settings from app.core.config import settings
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping from app.core.fdw import ensure_fdw_user_mapping
from app.core.http_errors import install_validation_error_handler
from app.core.ratelimit import RateLimitMiddleware from app.core.ratelimit import RateLimitMiddleware
from app.core.rbac import rbac_guard from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware from app.core.request_audit import RequestAuditMiddleware
@ -219,6 +220,10 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# 422 вместо 500 на Infinity/NaN во входе — разбор в app/core/http_errors.py.
install_validation_error_handler(app)
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User # RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
# (см. app/core/auth.py + auth/roles.yaml). Правила: # (см. app/core/auth.py + auth/roles.yaml). Правила:
# 1) Любой non-public path требует X-Authenticated-User — иначе 401. # 1) Любой non-public path требует X-Authenticated-User — иначе 401.

View file

@ -118,7 +118,8 @@ REJECTION_RULE = (
NOTE = ( NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " "Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
"она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ. " "строка выглядит как промах оценщика, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома " "Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может." "есть у 2.7% строк, поэтому дом не показан и показан быть не может."
@ -192,7 +193,7 @@ def build_row(
total_floors: int | None, total_floors: int | None,
deal_date: date | None, deal_date: date | None,
predicted_rub: float | None, predicted_rub: float | None,
fact_ppm2: float, fact_rub: float | None,
n_analogs: int, n_analogs: int,
lat: float | None = None, lat: float | None = None,
lon: float | None = None, lon: float | None = None,
@ -204,18 +205,33 @@ def build_row(
(делить не на что). Величина отклонения причиной НЕ является ни при каких (делить не на что). Величина отклонения причиной НЕ является ни при каких
значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля. значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
ФАКТ ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
показана. До 2026-08-30 здесь считалось `price_per_m2 * area_m2`, а
`deals.price_rub` лежала рядом и не использовалась: `price_per_m2` в базе
integer, поэтому произведение промахивалось на единицы рублей (на проде
4 799 995 вместо 4 800 000, 3 649 995 вместо 3 650 000). Расхождение
копеечное, но показывалась реконструкция под именем документа.
Строка без `price_rub` НЕ ПОКАЗЫВАЕТСЯ это «данных нет», и подставить туда
реконструкцию значило бы вернуть дефект в одной строке из двадцати, где его
уже никто не найдёт. Замер на проде 2026-08-30: в выборке витрины (ЕКБ,
rosreestr, с 2025-01-01, санитарный диапазон) price_rub заполнен у 33 555 из
33 555 сделок, так что отказ по этой причине защита, а не рабочий путь.
ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на
витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание
сделки из-за отсутствия координаты отбор по признаку, не связанному с сделки из-за отсутствия координаты отбор по признаку, не связанному с
качеством оценки, то есть та же порча витрины, что и отбор по ошибке. качеством оценки, то есть та же порча витрины, что и отбор по ошибке.
""" """
if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0: if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0:
return None
if fact_rub is None or fact_rub <= 0:
return None return None
quarter = quarter_label(deal_date) quarter = quarter_label(deal_date)
if quarter is None: if quarter is None:
return None return None
fact_rub = fact_ppm2 * area_m2
# Знак ошибки — как в бэктесте: (прогноз факт) / факт. Плюс = МЕРА # Знак ошибки — как в бэктесте: (прогноз факт) / факт. Плюс = МЕРА
# назвала дороже, чем ушло по ДКП. # назвала дороже, чем ушло по ДКП.
err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub
@ -385,7 +401,7 @@ def refresh_landing_showcase_deals(
total_floors=deal.total_floors, total_floors=deal.total_floors,
deal_date=deal.deal_date, deal_date=deal.deal_date,
predicted_rub=pr.expected_sold_price, predicted_rub=pr.expected_sold_price,
fact_ppm2=deal.sold_ppm2, fact_rub=deal.price_rub,
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
# Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота # Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота
# (~60.6). Перепутать местами — это точка в другой стране, и никакой # (~60.6). Перепутать местами — это точка в другой стране, и никакой

View file

@ -24,7 +24,10 @@
Точность считает бэктест (своя задача, свои допущения), а срок продажи требует Точность считает бэктест (своя задача, свои допущения), а срок продажи требует
пары «объявление снято сделка», которой у нас нет: снятие объявления не пары «объявление снято сделка», которой у нас нет: снятие объявления не
означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван
экспозицией активного объявления см. note метрики. экспозицией активного объявления см. note метрики. Считается от даты
публикации у источника (`listing_date`, иначе `publish_date` это одна и та же
величина в двух колонках, замер в комментарии к `_LISTING_AGE_SQL`), и note
называет охват: у 1500 из 31 068 активных объявлений ЕКБ даты публикации нет.
ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ
---------------------------------------- ----------------------------------------
@ -90,16 +93,33 @@ _ANALOGS_SQL = text("""
# Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи: # Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи:
# знаменатель — те, кто ещё висит, поэтому величина по построению занижена # знаменатель — те, кто ещё висит, поэтому величина по построению занижена
# относительно «сколько в итоге продавалось». Это ограничение уезжает в note. # относительно «сколько в итоге продавалось». Это ограничение уезжает в note.
#
# ДАТА ПУБЛИКАЦИИ ЛЕЖИТ В ДВУХ КОЛОНКАХ, И ОБЕ ЗНАЧАТ ОДНО. `listing_date`
# наполняют cian (`added_ts`), yandex (`creationDate`) и avito (дата карточки
# выдачи); `publish_date` — yandex (тем же значением) и Домклик
# (`publishedDate`). Замер на проде 2026-08-30 по активным ЕКБ: там, где
# заполнены ОБЕ, они совпадают (yandex 10 761 из 10 903, avito 474 из 569,
# медиана разницы 0 дней) — то есть это не «когда увидели мы» против «когда
# выставили», а одна величина в двух полях.
#
# Поэтому COALESCE: по одному `listing_date` Домклик выпадал ЦЕЛИКОМ (0 из
# 3061 активных строк с датой), и метрика считалась по 25 982 из 31 068
# активных объявлений — 83.6%, о чём подпись молчала. С COALESCE охват
# 29 568 из 31 068 (95.2%), а медиана осталась той же: 26 дней. Охват едет в
# note, потому что 1500 объявлений без даты публикации — это не ноль.
_LISTING_AGE_SQL = text(""" _LISTING_AGE_SQL = text("""
SELECT count(*) AS n, WITH active AS (
percentile_cont(0.5) WITHIN GROUP ( SELECT (CURRENT_DATE - COALESCE(listing_date, publish_date)) AS age_days
ORDER BY (CURRENT_DATE - listing_date)
) AS median
FROM listings FROM listings
WHERE is_active WHERE is_active
AND city = CAST(:city AS text) AND city = CAST(:city AS text)
AND listing_date IS NOT NULL )
AND listing_date <= CURRENT_DATE SELECT count(*) FILTER (WHERE age_days >= 0) AS n,
count(*) AS n_active,
percentile_cont(0.5) WITHIN GROUP (
ORDER BY age_days
) FILTER (WHERE age_days >= 0) AS median
FROM active
""") """)
# ── Динамика цены объявлений ──────────────────────────────────────────────── # ── Динамика цены объявлений ────────────────────────────────────────────────
@ -270,9 +290,11 @@ def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
"sample_n": int(row.n), "sample_n": int(row.n),
"note": ( "note": (
"Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге " "Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге "
"(сколько дней висит на сегодня). Это НЕ срок продажи: " "(сколько дней висит на сегодня, от даты публикации у источника). "
"считается по тем, кто ещё продаётся, и снятие объявления " "Это НЕ срок продажи: считается по тем, кто ещё продаётся, и "
"не означает сделку" "снятие объявления не означает сделку. Дата публикации известна "
f"у {int(row.n)} из {int(row.n_active)} активных объявлений города — "
"остальные в расчёт не входят"
), ),
} }
) )

View file

@ -264,6 +264,12 @@ class DealSample:
sold_ppm2: float sold_ppm2: float
deal_date: Any # datetime.date | None — carried through for reporting only deal_date: Any # datetime.date | None — carried through for reporting only
area_m2: float = 0.0 area_m2: float = 0.0
# Цена ДКП как она записана в договоре. НЕ sold_ppm2 * area_m2: price_per_m2
# в базе integer, и произведение промахивается на единицы рублей
# (4 799 995 против 4 800 000). Витрине нужна цена документа, поэтому поле
# едет из выборки, а не восстанавливается. Бэктест считает в ₽/м² и его не
# использует. None только если в строке нет цены — в выборке ЕКБ таких нет.
price_rub: float | None = None
address: str | None = None address: str | None = None
floor: int | None = None floor: int | None = None
total_floors: int | None = None total_floors: int | None = None
@ -1013,6 +1019,7 @@ _SAMPLE_SQL = text(
ST_Y(geom::geometry) AS lat, ST_Y(geom::geometry) AS lat,
rooms, rooms,
price_per_m2 AS sold_ppm2, price_per_m2 AS sold_ppm2,
price_rub,
deal_date, deal_date,
area_m2, area_m2,
address, address,
@ -1091,6 +1098,7 @@ def _sample_sql(city: str | None) -> Any:
ST_Y(geom::geometry) AS lat, ST_Y(geom::geometry) AS lat,
rooms, rooms,
price_per_m2 AS sold_ppm2, price_per_m2 AS sold_ppm2,
price_rub,
deal_date, deal_date,
area_m2, area_m2,
address, address,
@ -1176,6 +1184,7 @@ def _load_sample(
lat=float(r["lat"]), lat=float(r["lat"]),
rooms=int(r["rooms"]), rooms=int(r["rooms"]),
sold_ppm2=float(r["sold_ppm2"]), sold_ppm2=float(r["sold_ppm2"]),
price_rub=(float(r["price_rub"]) if r["price_rub"] is not None else None),
deal_date=r["deal_date"], deal_date=r["deal_date"],
area_m2=float(r["area_m2"]), area_m2=float(r["area_m2"]),
address=r["address"], address=r["address"],

View file

@ -91,7 +91,7 @@ def _build(**over: object) -> ShowcaseRow | None:
"total_floors": 9, "total_floors": 9,
"deal_date": date(2026, 4, 1), "deal_date": date(2026, 4, 1),
"predicted_rub": 5_000_000.0, "predicted_rub": 5_000_000.0,
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0% "fact_rub": 5_000_000.0, # цена ДКП из договора, ошибка 0%
"n_analogs": 30, "n_analogs": 30,
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи, "lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
"lon": 60.6055, # поэтому перестановка ловится по значению. "lon": 60.6055, # поэтому перестановка ловится по значению.
@ -108,6 +108,27 @@ def test_plain_row_survives_and_carries_signed_error() -> None:
assert row.deal_quarter == "II квартал 2026" assert row.deal_quarter == "II квартал 2026"
def test_fact_is_the_contract_price_not_the_reconstruction() -> None:
"""«Цена ДКП» — это `deals.price_rub`, а не `price_per_m2 × area_m2`.
Числа взяты с прода (сделка 5777343): в договоре 4 800 000 , а
произведение даёт 4 799 995 `price_per_m2` в базе integer. Витрина
показывала произведение под подписью «Цена ДКП».
Ломать так: вернуть в `build_row` реконструкцию (`fact_ppm2 * area_m2`,
то есть 185 328 × 25.9) тест покраснеет ПО ЗНАЧЕНИЮ: 4 799 995 вместо
4 800 000, и вместе с ним поедет err_pct.
"""
contract_rub = 4_800_000.0
reconstruction = 185_328 * 25.9 # 4 799 995.2 — то, что показывалось раньше
assert round(reconstruction) != contract_rub
row = _build(area_m2=25.9, fact_rub=contract_rub, predicted_rub=contract_rub)
assert row is not None
assert row.fact_rub == 4_800_000, "на витрину уехала реконструкция, а не цена договора"
assert row.err_pct == 0.0, "отклонение считается от той же величины, что показана"
def test_no_error_magnitude_is_ever_rejected() -> None: def test_no_error_magnitude_is_ever_rejected() -> None:
"""Промах оценщика ЛЮБОГО размера остаётся на витрине. """Промах оценщика ЛЮБОГО размера остаётся на витрине.
@ -139,7 +160,7 @@ def test_underdeclared_dkp_is_shown_not_hidden() -> None:
строки в `note`, а не за счёт отсева. строки в `note`, а не за счёт отсева.
""" """
# Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%. # Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%.
row = _build(fact_ppm2=40_000.0) row = _build(fact_rub=2_000_000.0)
assert row is not None assert row is not None
assert row.err_pct == 150.0 assert row.err_pct == 150.0
@ -152,13 +173,20 @@ def test_ppm2_band_is_not_duplicated_here() -> None:
отбраковок срабатывала ровно одна по ошибке. Ломать так: вернуть любую отбраковок срабатывала ровно одна по ошибке. Ломать так: вернуть любую
из границ покраснеет соответствующая половина. из границ покраснеет соответствующая половина.
""" """
assert _build(fact_ppm2=20_000.0, predicted_rub=1_000_000.0) is not None assert _build(fact_rub=1_000_000.0, predicted_rub=1_000_000.0) is not None
assert _build(fact_ppm2=2_000_000.0, predicted_rub=100_000_000.0) is not None assert _build(fact_rub=100_000_000.0, predicted_rub=100_000_000.0) is not None
def test_missing_fact_price_is_rejected() -> None: def test_missing_fact_price_is_rejected() -> None:
"""Нулевая цена сделки — это «данных нет», а не «число некрасивое»: делить не на что.""" """Нет цены ДКП — строки нет. Реконструкция вместо неё запрещена.
assert _build(fact_ppm2=0.0) is None
Подставить `price_per_m2 * area_m2` в строку без `price_rub` значило бы
вернуть тот самый дефект в одну строку из двадцати, где его уже не найти.
На проде price_rub заполнен у 33 555 из 33 555 сделок выборки, так что это
защита, а не рабочий путь.
"""
assert _build(fact_rub=None) is None
assert _build(fact_rub=0.0) is None
assert _build(area_m2=0.0) is None assert _build(area_m2=0.0) is None

View file

@ -108,7 +108,7 @@ def _rows(**overrides: Any) -> list[Any]:
base: dict[str, Any] = { base: dict[str, Any] = {
"estimates": SimpleNamespace(total=1123, period_days=94.0), "estimates": SimpleNamespace(total=1123, period_days=94.0),
"analogs": SimpleNamespace(n=975, median=Decimal("12")), "analogs": SimpleNamespace(n=975, median=Decimal("12")),
"listing_age": SimpleNamespace(n=25943, median=Decimal("26")), "listing_age": SimpleNamespace(n=29568, n_active=31068, median=Decimal("26")),
"price": SimpleNamespace(n=6276, n_cut=3018, median_pct_per_month=Decimal("-2.174")), "price": SimpleNamespace(n=6276, n_cut=3018, median_pct_per_month=Decimal("-2.174")),
"deals": SimpleNamespace(n=18657), "deals": SimpleNamespace(n=18657),
} }
@ -171,6 +171,38 @@ def test_listing_age_note_says_exposure_not_time_to_sell() -> None:
assert "НЕ срок продажи" in note assert "НЕ срок продажи" in note
def test_listing_age_note_names_its_coverage() -> None:
"""Знаменатель обязан быть в подписи: метрика видит не все активные.
Дата публикации есть у 29 568 из 31 068 активных объявлений ЕКБ (замер
2026-08-30). Полторы тысячи без даты это не ноль, и «медиана по активным
объявлениям» без охвата читается как «по всем».
Ломать так: убрать из note подстановку n/n_active тест покраснеет.
"""
got = _by_metric(ls.collect_landing_metrics(_FakeSession(_rows())))
note = got["listing_age_median_days"]["note"]
assert "29568" in note and "31068" in note, f"охват не назван: {note}"
def test_listing_age_counts_both_publication_date_columns() -> None:
"""Дата публикации лежит в двух колонках, и обе значат одно.
`listing_date` пишут cian/yandex/avito, `publish_date` yandex (тем же
значением) и Домклик. Там, где заполнены обе, они совпадают (прод
2026-08-30: yandex 10 761 из 10 903, avito 474 из 569). По одному
`listing_date` Домклик выпадал целиком 3061 активное объявление, 0 с
датой, охват 83.6% вместо 95.2%.
Ломать так: вернуть `ORDER BY (CURRENT_DATE - listing_date)` без COALESCE
тест покраснеет.
"""
sql = str(ls._LISTING_AGE_SQL)
assert "COALESCE(listing_date, publish_date)" in sql, (
"метрика снова считает по одной колонке — Домклик выпадает целиком"
)
def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None: def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None:
"""Этих величин в данных нет; их считает бэктест со своими допущениями.""" """Этих величин в данных нет; их считает бэктест со своими допущениями."""
names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows()))} names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows()))}
@ -185,7 +217,7 @@ def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None:
[ [
({"estimates": SimpleNamespace(total=0, period_days=None)}, "estimates_total"), ({"estimates": SimpleNamespace(total=0, period_days=None)}, "estimates_total"),
({"analogs": SimpleNamespace(n=0, median=None)}, "analogs_median"), ({"analogs": SimpleNamespace(n=0, median=None)}, "analogs_median"),
({"listing_age": SimpleNamespace(n=0, median=None)}, "listing_age_median_days"), ({"listing_age": SimpleNamespace(n=0, n_active=0, median=None)}, "listing_age_median_days"),
( (
{"price": SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None)}, {"price": SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None)},
"price_cut_share_pct", "price_cut_share_pct",
@ -260,7 +292,7 @@ def test_totally_empty_run_keeps_the_showcase_instead_of_wiping_it() -> None:
empty = _rows( empty = _rows(
estimates=SimpleNamespace(total=0, period_days=None), estimates=SimpleNamespace(total=0, period_days=None),
analogs=SimpleNamespace(n=0, median=None), analogs=SimpleNamespace(n=0, median=None),
listing_age=SimpleNamespace(n=0, median=None), listing_age=SimpleNamespace(n=0, n_active=0, median=None),
price=SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None), price=SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None),
deals=SimpleNamespace(n=0), deals=SimpleNamespace(n=0),
) )

View file

@ -47,6 +47,7 @@ from fastapi.testclient import TestClient # noqa: E402
from app.api.public import mera as public_mera # noqa: E402 from app.api.public import mera as public_mera # noqa: E402
from app.api.v1.geocode import SuggestResponse # noqa: E402 from app.api.v1.geocode import SuggestResponse # noqa: E402
from app.core.db import get_db # noqa: E402 from app.core.db import get_db # noqa: E402
from app.core.http_errors import install_validation_error_handler # noqa: E402
from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402 from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402
from app.schemas.trade_in import CoverageProbeResponse # noqa: E402 from app.schemas.trade_in import CoverageProbeResponse # noqa: E402
@ -94,6 +95,10 @@ def client() -> TestClient:
""" """
app = FastAPI() app = FastAPI()
app.middleware("http")(rbac_guard) app.middleware("http")(rbac_guard)
# Тот же обработчик 422, что вешает app/main.py. Без него тестовое
# приложение отвечало бы на нефинитные числа иначе, чем прод, и проверка
# «422, а не 500» была бы зелёной по построению.
install_validation_error_handler(app)
app.include_router(public_mera.router, prefix=PREFIX) app.include_router(public_mera.router, prefix=PREFIX)
@app.get("/api/v1/trade-in/coverage") @app.get("/api/v1/trade-in/coverage")
@ -635,3 +640,64 @@ def test_suggest_passes_city_prefixed_query_downstream(client: TestClient) -> No
assert captured["q"] == "Серов, Ленина 1" assert captured["q"] == "Серов, Ленина 1"
# Сам хинт продолжаем передавать: от него зависит гейт кадастрового тира. # Сам хинт продолжаем передавать: от него зависит гейт кадастрового тира.
assert captured["city_hint"] == "Серов" assert captured["city_hint"] == "Серов"
# ── 9. Невалидный вход: 422 и всё тот же бюджет ──────────────────────────────
#
# Аудит живого сайта 30.08.2026: POST /coverage с `"lon":1e400` отвечал 500, и
# двенадцать таких запросов подряд дали двенадцать пятисоток и ни одного 429.
# Две разные поломки в одном месте, поэтому и проверок здесь две.
_JSON = {"content-type": "application/json"}
# `json.loads` принимает нестандартные литералы Infinity/NaN, а `1e400` — это
# переполнение float. Ни одно из этих чисел не сериализуется обратно в JSON,
# поэтому они и роняли ответ об ошибке. Поля берём разные намеренно: чинить
# должно не поле, а сериализацию перечня ошибок.
_NON_FINITE_BODIES = [
b'{"lat":56.838,"lon":1e400,"rooms":2,"area_m2":54.0}',
b'{"lat":56.838,"lon":NaN,"rooms":2,"area_m2":54.0}',
b'{"lat":Infinity,"lon":60.597,"rooms":2,"area_m2":54.0}',
b'{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":-Infinity}',
]
@pytest.mark.parametrize("body", _NON_FINITE_BODIES)
def test_non_finite_number_is_422_not_500(client: TestClient, body: bytes) -> None:
"""Infinity/NaN во входе — это невалидный вход, а не отказ сервера.
Красный вид этого теста без починки не «assert 500 != 422», а
необработанный ValueError из `json.dumps(..., allow_nan=False)`: он летит
сквозь TestClient. Оба исхода одинаково красные и оба про одно: ответ об
ошибке не собрался.
"""
resp = client.post(f"{PREFIX}/coverage", content=body, headers=_JSON)
assert resp.status_code == 422, resp.text
# Форма ответа остаётся стандартной, иначе фронт разбирает её иначе.
assert isinstance(resp.json()["detail"], list)
@pytest.mark.parametrize("route", ["/coverage", "/suggest"])
def test_invalid_body_still_spends_the_per_ip_budget(client: TestClient, route: str) -> None:
"""Бюджет обязан срабатывать РАНЬШЕ разбора тела.
Иначе он не защищает ровно от того, что на разборе тела и падает: клиент
льёт неограниченный поток отказов с одного адреса.
Двусторонность: верните `_enforce(...)` первой строкой тела хендлера и
все ответы станут 422, ни одного 429, тест покраснеет.
"""
limit = public_mera._COVERAGE_LIMIT if route == "/coverage" else public_mera._SUGGEST_LIMIT
codes = [
client.post(
f"{PREFIX}{route}", content=b'{"lat":56.838,"lon":1e400}', headers=_JSON
).status_code
for _ in range(limit + 3)
]
assert 429 in codes, f"бюджет не сработал: {codes}"
# Ничего третьего быть не должно — ни 500, ни внезапной 200 на мусоре.
assert set(codes) <= {422, 429}, codes
# Отказ начинается ровно после исчерпания окна, а не «когда-нибудь».
assert codes.index(429) == limit, codes

View file

@ -0,0 +1,80 @@
/**
* Срок годности ручного замера.
*
* ЗАЧЕМ. Числа бэктеста (медианное расхождение, попадание в коридор, доля
* низкой уверенности) считает человек руками: ночной задачи, как у `/stats`,
* у них нет это записано в шапке `landing-facts.ts` и теперь видно на самой
* витрине (дата замера в подписи блока «Точность»). Задокументированное
* протухание всё равно протухание: пока о нём знает только комментарий,
* пересчёт остаётся ничьей задачей. Красный тест делает его чьей-то.
*
* ЧТО ИМЕННО ОН ЛОВИТ. Не «числа неверны» этого тест знать не может, а
* «замеру больше BACKTEST_MAX_AGE_DAYS дней, и никто его не подтверждал».
* Лечится двумя способами, и оба честные: перегнать бэктест и обновить числа
* вместе с датой, либо снять блок с витрины. Двигать одну лишь дату, не
* перегоняя замер, враньё, и ровно оно тут и сторожится.
*
* ПОЧЕМУ СТОЛЬКО ДНЕЙ. Данные Росреестра приходят квартальными пачками
* (`deals.deal_date` за окно принимает четыре значения по одному на
* квартал), значит чаще чем раз в квартал замеру обновляться не от чего.
* Срок = квартал плюс запас на загрузку следующей пачки.
*/
import { describe, expect, it } from "vitest";
import {
BACKTEST,
BACKTEST_MAX_AGE_DAYS,
BACKTEST_MEASURED_LABEL,
BACKTEST_MEASURED_ON,
BACKTEST_POPULATION,
BACKTEST_SHARE_LABEL,
} from "../landing-facts";
const DAY_MS = 24 * 60 * 60 * 1000;
function ageDays(iso: string, now: number): number {
return Math.floor((now - Date.parse(`${iso}T00:00:00Z`)) / DAY_MS);
}
describe("свежесть ручного замера бэктеста", () => {
it("дата замера разбирается и не из будущего — иначе сторож считает возраст мусора", () => {
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
expect(
Number.isFinite(age),
`BACKTEST_MEASURED_ON=${BACKTEST_MEASURED_ON} — не ISO-дата`,
).toBe(true);
expect(age, "дата замера в будущем").toBeGreaterThanOrEqual(0);
});
it("замеру не больше срока годности", () => {
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
expect(
age,
[
`замеру ${age} дн. (${BACKTEST_MEASURED_ON}), допустимо ${BACKTEST_MAX_AGE_DAYS}.`,
"Числа блока «Точность» посчитаны руками и с тех пор никем не подтверждены.",
"Перегнать: python -m scripts.backtest_estimator --city Екатеринбург",
"— обновить BACKTEST, BACKTEST_PERIOD_LABEL (назвать окно, которое реально",
"покрыла выборка), BACKTEST_POPULATION и дату замера; либо снять блок с",
"витрины. Двигать дату без пересчёта — враньё.",
].join(" "),
).toBeLessThanOrEqual(BACKTEST_MAX_AGE_DAYS);
});
it("сторож краснеет на протухшем замере — иначе он зелёный по построению", () => {
// Контроль на инструмент: тот же расчёт возраста, но на заведомо старой
// дате. Без него тест выше остаётся зелёным и при сломанной арифметике.
const staleNow =
Date.parse(`${BACKTEST_MEASURED_ON}T00:00:00Z`) + (BACKTEST_MAX_AGE_DAYS + 1) * DAY_MS;
expect(ageDays(BACKTEST_MEASURED_ON, staleNow)).toBeGreaterThan(BACKTEST_MAX_AGE_DAYS);
});
it("доля выборки выведена из размера выборки, а не вписана рядом", () => {
expect(BACKTEST_POPULATION).toBeGreaterThan(BACKTEST.priceError.sampleN);
const expected = `${((BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100)
.toFixed(1)
.replace(".", ",")} %`;
expect(BACKTEST_SHARE_LABEL).toBe(expected);
expect(BACKTEST_MEASURED_LABEL).toContain(BACKTEST_MEASURED_ON.slice(0, 4));
});
});

View file

@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { describeCoverage } from "../coverage-copy"; import { OBLAST_CITIES } from "@/lib/city-registry";
import { describeCityExpectation, describeCoverage } from "../coverage-copy";
import { CITY_COVERAGE } from "../landing-facts";
import type { CoverageProbe } from "../public-api"; import type { CoverageProbe } from "../public-api";
/** /**
@ -111,3 +114,40 @@ describe("describeCoverage", () => {
} }
}); });
}); });
/**
* Ожидаемое качество по городу. Проверяется не вёрстка, а два обещания: город
* из дропдауна не может остаться без замера, и величина названа тем, чем она
* измерена (доля проверок, а не «объём базы» и не «точность»).
*/
describe("describeCityExpectation", () => {
it("у каждого предлагаемого города есть замер — и лишних замеров нет", () => {
const offered = OBLAST_CITIES.map((c) => c.label).sort();
const measured = CITY_COVERAGE.map((m) => m.city).sort();
expect(measured).toEqual(offered);
});
it("у величины есть размер выборки и источник — без них она на витрину не выходит", () => {
for (const measure of CITY_COVERAGE) {
expect(measure.sampleN, `${measure.city}: выборка не указана`).toBeGreaterThan(0);
const said = describeCityExpectation(measure.city);
expect(said, `${measure.city}: нет текста`).not.toBeNull();
expect(said?.source).toContain(String(measure.sampleN));
expect(said?.source.length).toBeGreaterThan(40);
expect(said?.text).toContain(String(measure.confidentPct));
}
});
it("называет ту величину, которая измерена, — долю проверок, а не точность", () => {
const said = describeCityExpectation("Ревда");
expect(said?.text).toContain("проверках");
expect(said?.text).not.toMatch(/точност/i);
// Доли складываются в целое: 16 уверенных + 84 остальных — иначе текст
// утверждает больше, чем измерено.
expect(said?.text).toContain("84");
});
it("неизвестный город не выдумывает величину", () => {
expect(describeCityExpectation("Москва")).toBeNull();
});
});

View file

@ -26,7 +26,7 @@ import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react"; import type { FormEvent, KeyboardEvent } from "react";
import { COVERED_CITIES, PRIMARY_CITY } from "../../content"; import { COVERED_CITIES, PRIMARY_CITY } from "../../content";
import { describeCoverage } from "../../coverage-copy"; import { describeCityExpectation, describeCoverage } from "../../coverage-copy";
import type { CoverageVerdict } from "../../coverage-copy"; import type { CoverageVerdict } from "../../coverage-copy";
import { normalizeDraftRooms, takeDraft } from "../../estimate-draft"; import { normalizeDraftRooms, takeDraft } from "../../estimate-draft";
import { import {
@ -110,6 +110,7 @@ export function EstimateFlow() {
const addressRef = useRef<HTMLInputElement>(null); const addressRef = useRef<HTMLInputElement>(null);
const areaRef = useRef<HTMLInputElement>(null); const areaRef = useRef<HTMLInputElement>(null);
const answerRef = useRef<HTMLDivElement>(null);
const coverageAbort = useRef<AbortController | null>(null); const coverageAbort = useRef<AbortController | null>(null);
// Незавершённый запрос покрытия при уходе со страницы отменяем — иначе // Незавершённый запрос покрытия при уходе со страницы отменяем — иначе
@ -140,6 +141,32 @@ export function EstimateFlow() {
if (normalized) setRooms(normalized); if (normalized) setRooms(normalized);
}, []); }, []);
// Ответ дорисовывается НИЖЕ формы, а не вместо неё (так работает «Изменить
// параметры» в результате — форма остаётся заполненной под ним). На узком
// экране это значит, что после нажатия человек видит ровно ту же форму:
// заголовок ответа оказывается за нижней кромкой (замер на 375 px,
// 30.08.2026), и нажатие читается как «ничего не произошло».
//
// Уводим к ответу и переводим на него фокус. Фокус здесь не украшение: без
// него человек с клавиатуры остаётся на кнопке «Проверить мой дом» и
// следующим Tab уходит В ОБХОД ответа, а не в него. Живая область
// (`role="status"`) объявляет текст сама, но объявление не перемещает точку
// ввода.
//
// `scroll-behavior` фиксированной строкой в JS обходит настройку системы,
// поэтому анимацию спрашиваем у той же медиа-функции, что глушит остальную
// анимацию витрины (`prefers-reduced-motion` в landing-v3.module.css).
// `matchMedia` может отсутствовать (jsdom без стабов) — тогда просто без
// анимации.
useEffect(() => {
if (phase.kind !== "result" && phase.kind !== "failed") return;
const node = answerRef.current;
if (!node) return;
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? true;
node.focus({ preventScroll: true });
node.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "start" });
}, [phase.kind]);
// Подсказки: debounce + отмена предыдущего запроса. // Подсказки: debounce + отмена предыдущего запроса.
// //
// Контроллер создаётся СРАЗУ, а не внутри setTimeout, и отменяется в // Контроллер создаётся СРАЗУ, а не внутри setTimeout, и отменяется в
@ -263,6 +290,10 @@ export function EstimateFlow() {
} }
const showList = suggestions.length > 0 && !picked; const showList = suggestions.length > 0 && !picked;
// Чего ждать от проверки в этом городе — ДО нажатия кнопки, а не после.
// Города в списке не равны по данным, и молчание об этом человек читает как
// «везде одинаково».
const cityExpectation = describeCityExpectation(city);
return ( return (
<div className={styles.estCard}> <div className={styles.estCard}>
@ -286,6 +317,13 @@ export function EstimateFlow() {
</select> </select>
</label> </label>
{cityExpectation && (
<div className={styles.heroFormFeedback}>
<p className={styles.heroFormFeedbackText}>{cityExpectation.text}</p>
<p className={styles.heroFormFeedbackText}>{cityExpectation.source}</p>
</div>
)}
{/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и {/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и
физически перекрывает поля «Комнат» и «Площадь», в которые человек физически перекрывает поля «Комнат» и «Площадь», в которые человек
как раз собрался попасть. onBlur на контейнере, а не на инпуте, как раз собрался попасть. onBlur на контейнере, а не на инпуте,
@ -412,7 +450,15 @@ export function EstimateFlow() {
{/* Живая область постоянно в DOM: регион, добавленный в момент ответа, {/* Живая область постоянно в DOM: регион, добавленный в момент ответа,
часть скринридеров не озвучивает. */} часть скринридеров не озвучивает. */}
<div id={statusId} role="status" aria-live="polite"> <div
id={statusId}
ref={answerRef}
// Точка, в которую уводится фокус после ответа (см. эффект выше).
// -1: программно достижима, из таб-порядка не торчит.
tabIndex={-1}
role="status"
aria-live="polite"
>
{fieldError === "address" && ( {fieldError === "address" && (
<div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}> <div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}>
<p className={styles.heroFormFeedbackText}> <p className={styles.heroFormFeedbackText}>

View file

@ -2,6 +2,17 @@
* AccuracyV3 «Точность»: KPI-плитки + таблица «прогноз против цены сделки» * AccuracyV3 «Точность»: KPI-плитки + таблица «прогноз против цены сделки»
* (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент. * (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
* *
* ОКНО НАЗЫВАЕТСЯ ТО, КОТОРОЕ ИЗМЕРЕНО. В подписи стояло «сделки с июня 2025
* года», а выборка бэктеста берётся `ORDER BY id DESC LIMIT :sample` все её
* 327 сделок пришлись на один квартал (проверка на проде 30.08.2026, разбор в
* `landing-facts.ts` при BACKTEST_PERIOD_LABEL). Рядом с размером выборки
* стоит её ДОЛЯ: «327 сделок» без знаменателя читается как «столько их и
* было», хотя в том же квартале их 5 954.
*
* ДАТА ЗАМЕРА НА ВИТРИНЕ, А НЕ В КОММЕНТАРИИ. Числа бэктеста считаны руками
* и не пересчитываются ночной задачей; без даты они стареют молча. Дату видит
* читатель, а срок годности сторожит `__tests__/backtest-freshness.test.ts`.
*
* ОТКУДА ЧИСЛА. Три первых плитки разовая сверка прогноза с ценой ДКП * ОТКУДА ЧИСЛА. Три первых плитки разовая сверка прогноза с ценой ДКП
* (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число * (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
* расчётов `/stats`, каждая плитка рендерится только если величина пришла: * расчётов `/stats`, каждая плитка рендерится только если величина пришла:
@ -34,7 +45,9 @@
import { import {
BACKTEST, BACKTEST,
BACKTEST_CORRIDOR_LABEL, BACKTEST_CORRIDOR_LABEL,
BACKTEST_MEASURED_LABEL,
BACKTEST_PERIOD_LABEL, BACKTEST_PERIOD_LABEL,
BACKTEST_SHARE_LABEL,
} from "../../landing-facts"; } from "../../landing-facts";
import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api"; import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api";
import styles from "../../landing-v3.module.css"; import styles from "../../landing-v3.module.css";
@ -115,7 +128,7 @@ export function AccuracyV3({
Мы сверили прогноз с ценой сделки вот что вышло Мы сверили прогноз с ценой сделки вот что вышло
</h2> </h2>
<p className={styles.accLead}> <p className={styles.accLead}>
{`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок.`} {`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок${BACKTEST_SHARE_LABEL} сделок квартала. Разовый ${BACKTEST_MEASURED_LABEL}, регулярного пересчёта у этих чисел нет.`}
{estimates {estimates
? ` Расчёты в системе живут в другом окне: ${estimates.text}${ ? ` Расчёты в системе живут в другом окне: ${estimates.text}${
period ? ` за ${period.text}` : "" period ? ` за ${period.text}` : ""

View file

@ -16,6 +16,14 @@
* единицу, которой её не мерили: подпись приведена к измеренному, а источник * единицу, которой её не мерили: подпись приведена к измеренному, а источник
* назван прямо в ней, а не только в `note` под плиткой. * назван прямо в ней, а не только в `note` под плиткой.
* *
* ЗАГОЛОВОК БЕЗ СРОКА (аудит 30.08). Стояло «против двух месяцев вашей
* жизни»: величина вписана в компонент руками, тогда как обе цифры секции
* приходят из `/stats` с выборками, а «двух месяцев» нет ни в одном замере.
* Подставить измеренную экспозицию вместо неё нельзя это другая величина:
* `median_listing_age_days` считается по объявлениям, которые ЕЩЁ ВИСЯТ
* (цензурированная выборка, см. public-api.ts), и сроком продажи не является.
* Поэтому срок убран, а не заменён числом.
*
* ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной * ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной
* ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу * ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу
* заявления. Её место заняла доля снижающих цену измеренная величина про то * заявления. Её место заняла доля снижающих цену измеренная величина про то
@ -77,7 +85,7 @@ export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
ПОЧЕМУ ЭТО ВАЖНО ПОЧЕМУ ЭТО ВАЖНО
</div> </div>
<h2 id="cost-v3-title" className={styles.costTitle}> <h2 id="cost-v3-title" className={styles.costTitle}>
{`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`} {`${SERVICE_PRICE_RUB} ₽ против ошибки в цене вашей квартиры`}
</h2> </h2>
</div> </div>

View file

@ -28,6 +28,11 @@
* похожие объявления УЖЕ висят (`deals.days_on_market` пуст, сверять прогноз * похожие объявления УЖЕ висят (`deals.days_on_market` пуст, сверять прогноз
* срока не с чем). Обещание приведено к этой величине. * срока не с чем). Обещание приведено к этой величине.
* *
* ПОДЗАГОЛОВОК БЕЗ СРОКА (аудит 30.08). Стояло «не зависли НА ПОЛГОДА»
* длительность, которой нет ни в одном замере; ближайшая измеренная величина
* (медианная экспозиция активного объявления) считается по тем, кто ещё
* висит, и сроком продажи не является. Срок убран, а не заменён числом.
*
* ЧЕГО В ТЕКСТЕ БОЛЬШЕ НЕТ: обещания «каждый такой прогноз мы потом сверяем с * ЧЕГО В ТЕКСТЕ БОЛЬШЕ НЕТ: обещания «каждый такой прогноз мы потом сверяем с
* фактом сделки». Контура сверки прогноза клиента с его сделкой в продукте не * фактом сделки». Контура сверки прогноза клиента с его сделкой в продукте не
* существует (`trade_in_leads` 4 строки без полей исхода), и обещать его * существует (`trade_in_leads` 4 строки без полей исхода), и обещать его
@ -105,7 +110,7 @@ export function HeroV3({ stats }: { stats: LandingStats }) {
<p className={styles.heroLead}> <p className={styles.heroLead}>
Мы называем реальную цену вашей квартиры чтобы вы не продали её Мы называем реальную цену вашей квартиры чтобы вы не продали её
дешевле и не зависли на полгода. дешевле рынка и не зависли в продаже.
</p> </p>
<div className={styles.heroChecklist}> <div className={styles.heroChecklist}>

View file

@ -8,9 +8,21 @@
* *
* Ответы НЕ из макета (там реквизит рендера): собраны из уже выверенной * Ответы НЕ из макета (там реквизит рендера): собраны из уже выверенной
* честной копии продукта content.ts (FAQ), oferta/refund. Вопрос про факт * честной копии продукта content.ts (FAQ), oferta/refund. Вопрос про факт
* сделки отвечаем тем, что в продукте ЕСТЬ (сделки Росреестра + снятие * сделки отвечаем тем, что в продукте ЕСТЬ (сделки Росреестра, сверка
* объявлений с публикации), а не макетной формулировкой про «отметки * прогноза с ценой ДКП), а не макетной формулировкой про «отметки продавцов»,
* продавцов», механики которых не существует. * механики которых не существует.
*
* СНЯТИЕ ОБЪЯВЛЕНИЙ ОТСЮДА УБРАНО (аудит 30.08). Ответ обещал, что мы
* «отслеживаем снятие объявлений с публикации» и считаем по этому расхождение
* прогноза с реальностью. Ни одного из двух не происходит: расхождение
* считается в `app/tasks/landing_showcase_deals.py` и
* `backend/scripts/backtest_estimator.py`, и оба берут ТОЛЬКО цену ДКП;
* признака снятия в данных нет вовсе `listing_source_snapshot.py` не пишет
* delisted/relisted намеренно (не выводимы при покрытии обхода 10-35%), на
* проде 0 таких строк в `listing_source_events`, а `deals.days_on_market`
* заполнена 0 из 108 623. Страница при этом сама себе и противоречила:
* `AccuracyV3` двумя блоками выше зовёт ту же величину «медианной экспозицией
* АКТИВНОГО объявления» именно потому, что снятие сделкой не является.
* *
* ВЕСЬ `FAQ` ИЗ content.ts ИМПОРТИРУЕТСЯ, А НЕ ПЕРЕПИСЫВАЕТСЯ. Так было не * ВЕСЬ `FAQ` ИЗ content.ts ИМПОРТИРУЕТСЯ, А НЕ ПЕРЕПИСЫВАЕТСЯ. Так было не
* сразу: при переносе брались только `how-do-you-know` и `why-region`, а * сразу: при переносе брались только `how-do-you-know` и `why-region`, а
@ -74,7 +86,8 @@ const ITEMS: readonly Objection[] = [
{ {
q: "Откуда вы знаете факт сделки?", q: "Откуда вы знаете факт сделки?",
a: [ a: [
"К объявлениям мы добавляем зарегистрированные сделки Росреестра и отслеживаем снятие объявлений с публикации. По этим данным считается расхождение прогноза с реальностью — и честно показывается, на скольких объектах построен каждый расчёт.", "Зарегистрированные сделки Росреестра — это цены договоров купли-продажи, а не пожелания продавцов. По ним же мы проверяем себя: прошлые сделки прогоняются через тот же расчёт, и прогноз сравнивается с ценой ДКП — медианное расхождение и число сделок, на которых оно посчитано, стоят в шапке этой страницы.",
"Снятие объявления с публикации сделкой не считаем: по нашим данным нельзя отличить продажу от того, что объявление просто убрали или до него не дошёл обход. И в отчёте, и здесь честно показывается, на скольких объектах построен расчёт.",
], ],
}, },
{ {

View file

@ -22,6 +22,7 @@
* ноль вместо неизвестного значения был бы худшей из ошибок. * ноль вместо неизвестного значения был бы худшей из ошибок.
*/ */
import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts";
import type { CoverageProbe } from "./public-api"; import type { CoverageProbe } from "./public-api";
export interface CoverageTile { export interface CoverageTile {
@ -82,6 +83,37 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] {
return tiles; return tiles;
} }
export interface CityExpectation {
/** Что человек увидит рядом с выбранным городом ДО нажатия кнопки. */
text: string;
/** Откуда величина: запрос, база, дата, размер выборки. */
source: string;
}
/**
* Половина городов дропдауна не «отказ», но и не Екатеринбург. Замер лежит
* в `landing-facts.ts::CITY_COVERAGE`, здесь только формулировка.
*
* ГОРОДА НЕ ДЕЛЯТСЯ НА «РАБОЧИЕ» И «НЕТ». Ни в одном из девяти проба не
* упирается в отказ систематически (худший Ревда: 11 пустых из 100), так
* что выбрасывать города из списка не за что. Разница между ними
* количественная её и показываем числом, а не отсутствием опции.
*/
export function describeCityExpectation(city: string): CityExpectation | null {
const measure = CITY_COVERAGE.find((m) => m.city === city);
if (!measure) return null;
const rest = 100 - measure.confidentPct;
return {
text:
`В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` +
`из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` +
`будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` +
"не находится ни одной похожей квартиры.",
source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`,
};
}
export function describeCoverage(probe: CoverageProbe): CoverageVerdict { export function describeCoverage(probe: CoverageProbe): CoverageVerdict {
const where = probe.city ? `в городе ${probe.city}` : "по этому адресу"; const where = probe.city ? `в городе ${probe.city}` : "по этому адресу";

View file

@ -45,7 +45,7 @@ export interface MeasuredValue {
/** Общий источник трёх величин ниже — один и тот же прогон сверки. */ /** Общий источник трёх величин ниже — один и тот же прогон сверки. */
const BACKTEST_SOURCE = const BACKTEST_SOURCE =
"Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " + "Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " +
"Росреестра по Екатеринбургу, сделки с 06.2025"; "Росреестра по Екатеринбургу, сделки II квартала 2026 года";
/** /**
* Сверка «прогноз цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются * Сверка «прогноз цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются
@ -83,8 +83,66 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */ /** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
export const BACKTEST_CORRIDOR_LABEL = "±37 %"; export const BACKTEST_CORRIDOR_LABEL = "±37 %";
/** Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`. */ /**
export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года"; * Окно сделок бэктеста. Окно РАСЧЁТОВ другое, оно приходит из `/stats`.
*
* ЗДЕСЬ СТОЯЛО «сделки с июня 2025 года» и это называло не то окно, которое
* измерено. `--since 2025-06-01` задаёт только нижнюю границу, а выборку
* скрипт берёт `ORDER BY id DESC LIMIT :sample`
* (`backend/scripts/backtest_estimator.py`, _SAMPLE_SQL): это последние по
* порядку загрузки строки, а не срез по всему окну. Проверено на проде
* 30.08.2026: у всех 327 сделок выборки `deal_date = 2026-04-01`, то есть
* ровно один квартал (проверены оба варианта запуска и без `--city`, и с
* `--city Екатеринбург`; результат один и тот же). Случайной выборки в
* скрипте нет, поэтому чинится ПОДПИСЬ, а не замер: числа остаются те же,
* названо окно, которое они покрыли.
*
* `deal_date` у Росреестра не дата сделки, а метка квартала: во всей таблице
* за окно ровно четыре значения (01.07.2025, 01.10.2025, 01.01.2026,
* 01.04.2026). Поэтому «квартал» предельная точность, которую данные вообще
* позволяют назвать; «сделки за апрель» было бы вторым враньём.
*/
export const BACKTEST_PERIOD_LABEL = "сделки II квартала 2026 года";
/**
* Сколько сделок было В ТОМ ЖЕ окне чтобы «мы сверили 327 сделок» не
* читалось как «столько их и было». Замер 30.08.2026 на проде: годных под те
* же фильтры выборки (`source='rosreestr'`, geom, ppm² 30 000..600 000, rooms,
* area) сделок Екатеринбурга с `deal_date = 2026-04-01` 5 954.
*
* ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, что и выборка. Соблазн подставить сюда
* 24 333 (все сделки ЕКБ с июня 2025) даёт долю красивее 1,3 % вместо
* 5,5 %, но это ровно та же ошибка, что чинится выше: доля от окна, которое
* замер не покрывал.
*/
export const BACKTEST_POPULATION = 5_954;
/**
* Доля выборки. ВЫВОДИТСЯ, а не вписывается: разъехаться с `sampleN` не может.
*/
export const BACKTEST_SHARE_LABEL = `${((BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100)
.toFixed(1)
.replace(".", ",")} %`;
/**
* Дата замера на витрину, а не только в комментарий.
*
* Числа бэктеста считает человек руками, ночной задачи для них нет (см. шапку
* файла), поэтому они стареют молча. Дата рядом с числом первый признак
* протухания, который видит читатель; второй, обязательный для нас,
* `__tests__/backtest-freshness.test.ts`: он краснеет, когда замеру больше
* BACKTEST_MAX_AGE_DAYS. Пока теста не было, «пересчитать» было ничьей
* задачей.
*/
export const BACKTEST_MEASURED_ON = "2026-08-29";
/** Через сколько дней замер считается протухшим (см. тест свежести). */
export const BACKTEST_MAX_AGE_DAYS = 100;
const [measuredY, measuredM, measuredD] = BACKTEST_MEASURED_ON.split("-");
/** Человекочитаемая дата замера. Выводится из ISO — двух правок не требует. */
export const BACKTEST_MEASURED_LABEL = `замер ${measuredD}.${measuredM}.${measuredY}`;
/** /**
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`). * Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).
@ -105,3 +163,60 @@ export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года";
* независимо, и правка одного не касалась остальных. * независимо, и правка одного не касалась остальных.
*/ */
export const STEP1_FIELDS_LABEL = "6 полей"; export const STEP1_FIELDS_LABEL = "6 полей";
/**
* Чего ждать от бесплатной пробы в каждом городе дропдауна.
*
* ЗАЧЕМ. В форме предлагается девять городов, и они не равны по данным. До
* этого замера человек узнавал об этом только ПОСЛЕ нажатия кнопки из
* честного, но запоздалого «данные есть, но их мало». Предлагать выбор,
* ничего не говоря о его цене, плохой продукт, даже когда отказ честен.
*
* ЧТО ИМЕННО ИЗМЕРЕНО (и чем это НЕ является). Не «объём базы» и не «число
* объявлений в городе». Измерена доля проб, которые вернули бы выборку не
* меньше городского порога `_COVERAGE_CITY_THRESHOLDS`
* (`backend/app/api/v1/trade_in.py`; порог 8 у ближнего круга, 12 у дальних
* городов) то есть доля проверок, на которые сервис отвечает «данных
* хватает», а не «данных мало».
*
* ПОЧЕМУ НЕ СЧЁТ ПО `listings.city`. Эта колонка хранит город СВИПА скрейпера,
* а не геокод объявления (миграция 196, разбор над `_CITY_CENTROIDS_DEG` в
* `trade_in.py`): вокруг Берёзовского 90/90 строк лежат с city='Екатеринбург'.
* Счёт по ней даёт ноль по трём городам-спутникам и читается как «города нет
* в базе» вывод неверный, ошибка в мерке. Города здесь резолвятся по
* координатам, тем же правилом ближайшего центроида, что и сама проба.
*
* ЧЕГО ЗАМЕР НЕ ЗНАЕТ. Точки взяты из адресов активных объявлений, а не из
* жилого фонда: там, где никто ничего не продаёт, мы не мерили. Свой адрес
* пробы исключён из когорты иначе `emptyPct` был бы нулём по построению
* (объявление всегда попадает в собственный радиус), и «ни один город не
* пуст» оказалось бы свойством запроса, а не данных.
*/
export interface CityCoverageMeasure {
/** Лейбл ровно как в `OBLAST_CITIES` — по нему город и находится. */
readonly city: string;
/** Доля проб с выборкой ≥ городского порога, % (округление до целого). */
readonly confidentPct: number;
/** Доля проб, у которых рядом не нашлось ни одной похожей квартиры, %. */
readonly emptyPct: number;
/** Сколько адресов прогнали. Где объявлений меньше — там и выборка меньше. */
readonly sampleN: number;
}
export const CITY_COVERAGE_SOURCE =
"Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026): случайные адреса " +
"активных объявлений, для каждого — когорта самой ручки /coverage (радиус 1 км, то же " +
"число комнат, площадь ±15 %, свежесть 14 дней, дедуп по источнику и адресу), " +
"собственный адрес из когорты исключён";
export const CITY_COVERAGE: readonly CityCoverageMeasure[] = [
{ city: "Екатеринбург", confidentPct: 83, emptyPct: 3, sampleN: 120 },
{ city: "Верхняя Пышма", confidentPct: 70, emptyPct: 5, sampleN: 120 },
{ city: "Серов", confidentPct: 58, emptyPct: 7, sampleN: 120 },
{ city: "Нижний Тагил", confidentPct: 52, emptyPct: 7, sampleN: 120 },
{ city: "Первоуральск", confidentPct: 50, emptyPct: 3, sampleN: 120 },
{ city: "Каменск-Уральский", confidentPct: 45, emptyPct: 6, sampleN: 120 },
{ city: "Среднеуральск", confidentPct: 39, emptyPct: 21, sampleN: 56 },
{ city: "Берёзовский", confidentPct: 38, emptyPct: 18, sampleN: 120 },
{ city: "Ревда", confidentPct: 16, emptyPct: 11, sampleN: 120 },
];