fix(mera/b2c): восемь находок финального аудита прода — включая три моих собственных #3247
18 changed files with 688 additions and 60 deletions
|
|
@ -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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
59
tradein-mvp/backend/app/core/http_errors.py
Normal file
59
tradein-mvp/backend/app/core/http_errors.py
Normal 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)
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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). Перепутать местами — это точка в другой стране, и никакой
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
SELECT (CURRENT_DATE - COALESCE(listing_date, publish_date)) AS age_days
|
||||||
|
FROM listings
|
||||||
|
WHERE is_active
|
||||||
|
AND city = CAST(:city AS text)
|
||||||
|
)
|
||||||
|
SELECT count(*) FILTER (WHERE age_days >= 0) AS n,
|
||||||
|
count(*) AS n_active,
|
||||||
percentile_cont(0.5) WITHIN GROUP (
|
percentile_cont(0.5) WITHIN GROUP (
|
||||||
ORDER BY (CURRENT_DATE - listing_date)
|
ORDER BY age_days
|
||||||
) AS median
|
) FILTER (WHERE age_days >= 0) AS median
|
||||||
FROM listings
|
FROM active
|
||||||
WHERE is_active
|
|
||||||
AND city = CAST(:city AS text)
|
|
||||||
AND listing_date IS NOT NULL
|
|
||||||
AND listing_date <= CURRENT_DATE
|
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# ── Динамика цены объявлений ────────────────────────────────────────────────
|
# ── Динамика цены объявлений ────────────────────────────────────────────────
|
||||||
|
|
@ -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)} активных объявлений города — "
|
||||||
|
"остальные в расчёт не входят"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}` : ""
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
"К объявлениям мы добавляем зарегистрированные сделки Росреестра и отслеживаем снятие объявлений с публикации. По этим данным считается расхождение прогноза с реальностью — и честно показывается, на скольких объектах построен каждый расчёт.",
|
"Зарегистрированные сделки Росреестра — это цены договоров купли-продажи, а не пожелания продавцов. По ним же мы проверяем себя: прошлые сделки прогоняются через тот же расчёт, и прогноз сравнивается с ценой ДКП — медианное расхождение и число сделок, на которых оно посчитано, стоят в шапке этой страницы.",
|
||||||
|
"Снятие объявления с публикации сделкой не считаем: по нашим данным нельзя отличить продажу от того, что объявление просто убрали или до него не дошёл обход. И в отчёте, и здесь честно показывается, на скольких объектах построен расчёт.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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}` : "по этому адресу";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue