From 808a2e12e2ec367f28e7106c2bb09154292b0b0d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 00:07:07 +0500 Subject: [PATCH 1/5] =?UTF-8?q?fix(mera/=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3):=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=B5=D1=89=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BD=D1=8F=D1=82=D0=B8=D0=B5=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D1=81?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B8=20=D0=B1=D0=B5=D0=B7=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FAQ обещал, что мы «отслеживаем снятие объявлений с публикации» и считаем по этому расхождение прогноза с реальностью. Ни того, ни другого нет: расхождение считают landing_showcase_deals.py и backtest_estimator.py, оба берут только цену ДКП; delisted/relisted listing_source_snapshot.py не пишет намеренно (не выводимы при покрытии обхода 10-35%), на проде 0 таких строк в listing_source_events, deals.days_on_market заполнена 0 из 108 623. Ответ приведён к тому, что делается, и прямо говорит, что снятие сделкой не считаем — двумя блоками выше AccuracyV3 по той же причине зовёт величину «экспозицией АКТИВНОГО объявления». Два срока без источника убраны, а не заменены числом: «против двух месяцев вашей жизни» (CostOfErrorV3) и «не зависли на полгода» (HeroV3). Измеренная экспозиция считается по тем, кто ещё висит, и сроком продажи не является — подставлять её на место этих сроков значило бы подменить величину. Co-Authored-By: Claude Opus 5 --- .../_components/v3/CostOfErrorV3.tsx | 10 ++++++++- .../app/mera-public/_components/v3/HeroV3.tsx | 7 ++++++- .../_components/v3/ObjectionsV3.tsx | 21 +++++++++++++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx index c90201f6..1d700573 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx @@ -16,6 +16,14 @@ * единицу, которой её не мерили: подпись приведена к измеренному, а источник * назван прямо в ней, а не только в `note` под плиткой. * + * ЗАГОЛОВОК БЕЗ СРОКА (аудит 30.08). Стояло «против двух месяцев вашей + * жизни»: величина вписана в компонент руками, тогда как обе цифры секции + * приходят из `/stats` с выборками, а «двух месяцев» нет ни в одном замере. + * Подставить измеренную экспозицию вместо неё нельзя — это другая величина: + * `median_listing_age_days` считается по объявлениям, которые ЕЩЁ ВИСЯТ + * (цензурированная выборка, см. public-api.ts), и сроком продажи не является. + * Поэтому срок убран, а не заменён числом. + * * ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной * ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу * заявления. Её место заняла доля снижающих цену — измеренная величина про то @@ -77,7 +85,7 @@ export function CostOfErrorV3({ stats }: { stats: LandingStats }) { ПОЧЕМУ ЭТО ВАЖНО

- {`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`} + {`${SERVICE_PRICE_RUB} ₽ против ошибки в цене вашей квартиры`}

diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx index 42435255..670efdc2 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx @@ -28,6 +28,11 @@ * похожие объявления УЖЕ висят (`deals.days_on_market` пуст, сверять прогноз * срока не с чем). Обещание приведено к этой величине. * + * ПОДЗАГОЛОВОК БЕЗ СРОКА (аудит 30.08). Стояло «не зависли НА ПОЛГОДА» — + * длительность, которой нет ни в одном замере; ближайшая измеренная величина + * (медианная экспозиция активного объявления) считается по тем, кто ещё + * висит, и сроком продажи не является. Срок убран, а не заменён числом. + * * ЧЕГО В ТЕКСТЕ БОЛЬШЕ НЕТ: обещания «каждый такой прогноз мы потом сверяем с * фактом сделки». Контура сверки прогноза клиента с его сделкой в продукте не * существует (`trade_in_leads` — 4 строки без полей исхода), и обещать его @@ -105,7 +110,7 @@ export function HeroV3({ stats }: { stats: LandingStats }) {

Мы называем реальную цену вашей квартиры — чтобы вы не продали её - дешевле и не зависли на полгода. + дешевле рынка и не зависли в продаже.

diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx index e5cc5b1e..b1e9042c 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx @@ -8,9 +8,21 @@ * * Ответы НЕ из макета (там реквизит рендера): собраны из уже выверенной * честной копии продукта — 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 ИМПОРТИРУЕТСЯ, А НЕ ПЕРЕПИСЫВАЕТСЯ. Так было не * сразу: при переносе брались только `how-do-you-know` и `why-region`, а @@ -74,7 +86,8 @@ const ITEMS: readonly Objection[] = [ { q: "Откуда вы знаете факт сделки?", a: [ - "К объявлениям мы добавляем зарегистрированные сделки Росреестра и отслеживаем снятие объявлений с публикации. По этим данным считается расхождение прогноза с реальностью — и честно показывается, на скольких объектах построен каждый расчёт.", + "Зарегистрированные сделки Росреестра — это цены договоров купли-продажи, а не пожелания продавцов. По ним же мы проверяем себя: прошлые сделки прогоняются через тот же расчёт, и прогноз сравнивается с ценой ДКП — медианное расхождение и число сделок, на которых оно посчитано, стоят в шапке этой страницы.", + "Снятие объявления с публикации сделкой не считаем: по нашим данным нельзя отличить продажу от того, что объявление просто убрали или до него не дошёл обход. И в отчёте, и здесь честно показывается, на скольких объектах построен расчёт.", ], }, { -- 2.45.3 From 50a3a29b7293a381507cdb11e490fce221eb9ced Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 00:09:43 +0500 Subject: [PATCH 2/5] =?UTF-8?q?fix(mera/b2c):=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BE=D0=BA=D0=BD=D0=BE,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подпись обещала «сделки с июня 2025 года», а выборка бэктеста берётся ORDER BY id DESC LIMIT :sample (backend/scripts/backtest_estimator.py, _SAMPLE_SQL) — это последние по порядку загрузки строки, а не срез окна. Проверка на проде 30.08.2026: у всех 327 сделок deal_date = 2026-04-01, то есть один квартал; проверены оба варианта запуска (без --city и с --city Екатеринбург) — результат одинаковый. Случайной выборки в скрипте нет, поэтому чинится подпись, а не замер: числа те же, окно названо своё. Заодно: - доля выборки на витрине (5,5 % сделок квартала). Знаменатель — из ТОГО ЖЕ окна (5 954 годных сделки ЕКБ за II кв 2026), а не 24 333 за всё окно с июня 2025: доля от непокрытого окна повторила бы ту же ошибку; - дата замера выведена рядом с числами: регулярного пересчёта у них нет, без даты они стареют молча; - __tests__/backtest-freshness.test.ts краснеет, когда замеру больше BACKTEST_MAX_AGE_DAYS (100 дн. = квартальная пачка Росреестра + запас). Фальсифицирован: дата 2026-01-05 → красный с текстом «замеру 236 дн.». Co-Authored-By: Claude Opus 5 --- .../__tests__/backtest-freshness.test.ts | 80 +++++++++++++++++++ .../mera-public/_components/v3/AccuracyV3.tsx | 15 +++- .../src/app/mera-public/landing-facts.ts | 64 ++++++++++++++- 3 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts new file mode 100644 index 00000000..905ed0cf --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts @@ -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)); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx index 0a3be53d..a67a6e71 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx @@ -2,6 +2,17 @@ * AccuracyV3 — «Точность»: KPI-плитки + таблица «прогноз против цены сделки» * (макет 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`, там же источник и оговорки). Экспозиция и число * расчётов — `/stats`, каждая плитка рендерится только если величина пришла: @@ -34,7 +45,9 @@ import { BACKTEST, BACKTEST_CORRIDOR_LABEL, + BACKTEST_MEASURED_LABEL, BACKTEST_PERIOD_LABEL, + BACKTEST_SHARE_LABEL, } from "../../landing-facts"; import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api"; import styles from "../../landing-v3.module.css"; @@ -115,7 +128,7 @@ export function AccuracyV3({ Мы сверили прогноз с ценой сделки — вот что вышло

- {`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок.`} + {`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок — ${BACKTEST_SHARE_LABEL} сделок квартала. Разовый ${BACKTEST_MEASURED_LABEL}, регулярного пересчёта у этих чисел нет.`} {estimates ? ` Расчёты в системе живут в другом окне: ${estimates.text}${ period ? ` за ${period.text}` : "" diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index a73b1bb2..ed975d81 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -45,7 +45,7 @@ export interface MeasuredValue { /** Общий источник трёх величин ниже — один и тот же прогон сверки. */ const BACKTEST_SOURCE = "Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " + - "Росреестра по Екатеринбургу, сделки с 06.2025"; + "Росреестра по Екатеринбургу, сделки II квартала 2026 года"; /** * Сверка «прогноз → цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются @@ -83,8 +83,66 @@ export const BACKTEST: Readonly Date: Sun, 30 Aug 2026 00:10:36 +0500 Subject: [PATCH 3/5] =?UTF-8?q?fix(mera/landing):=20=C2=AB=D0=A6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=94=D0=9A=D0=9F=C2=BB=20=E2=80=94=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=B3=D0=BE=D0=B2=D0=BE=D1=80?= =?UTF-8?q?=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B8?= =?UTF-8?q?=D0=B7=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5;=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=81=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20=D0=B8=20=D0=94=D0=BE?= =?UTF-8?q?=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Витрина показывала fact_rub = price_per_m2 * area_m2, хотя deals.price_rub лежит в той же строке и не использовалась. price_per_m2 в базе integer, поэтому под подписью «Цена ДКП» ехала реконструкция: 4 799 995 вместо 4 800 000, 3 649 995 вместо 3 650 000 (прод, сделки 5777343 и др.). Теперь price_rub едет из выборки (DealSample.price_rub) и показывается как есть; err_pct считается от той же величины. Строка без price_rub НЕ показывается — подставлять реконструкцию в одну строку из двадцати значило бы спрятать тот же дефект (на проде price_rub заполнен у 33 555 из 33 555 сделок выборки витрины). Вторая находка аудита (listing_date якобы «когда увидели МЫ», экспозиция занижена втрое) НЕ ПОДТВЕРДИЛАСЬ. listing_date пишут cian (added_ts), yandex (creationDate) и avito (дата карточки выдачи) — это дата публикации у источника. Там, где заполнены и listing_date, и publish_date, они совпадают: yandex 10 761 из 10 903, avito 474 из 569, медиана разницы 0 дней. 75 дней у аудитора — эффект другой ВЫБОРКИ: publish_date есть у 15 058 активных строк (yandex + Домклик, оба старые), listing_date — у 25 982 (плюс cian с медианой 17 дней и 87% avito с медианой 19). Настоящий дефект рядом: по одному listing_date Домклик выпадал целиком (0 из 3061 активной строки), метрика считалась по 83.6% активных объявлений, и подпись об этом молчала. COALESCE(listing_date, publish_date) → охват 95.2% (29 568 из 31 068), медиана та же — 26 дней; охват теперь назван в note. Co-Authored-By: Claude Opus 5 --- .../app/tasks/landing_showcase_deals.py | 26 +++++++++-- .../backend/app/tasks/landing_stats.py | 46 ++++++++++++++----- .../backend/scripts/backtest_estimator.py | 9 ++++ .../tests/test_landing_showcase_deals.py | 40 +++++++++++++--- .../backend/tests/test_landing_stats.py | 38 +++++++++++++-- 5 files changed, 133 insertions(+), 26 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index 40aaed0d..ec69b54a 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -118,7 +118,8 @@ REJECTION_RULE = ( NOTE = ( "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " - "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " + "Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): " + "она бывает занижена сторонами, и тогда " "строка выглядит как промах оценщика, хотя врёт документ. " "Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома " "есть у 2.7% строк, поэтому дом не показан и показан быть не может." @@ -192,7 +193,7 @@ def build_row( total_floors: int | None, deal_date: date | None, predicted_rub: float | None, - fact_ppm2: float, + fact_rub: float | None, n_analogs: int, lat: 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: карта переживёт сделку без точки, а выбрасывание сделки из-за отсутствия координаты — отбор по признаку, не связанному с качеством оценки, то есть та же порча витрины, что и отбор по ошибке. """ - 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 quarter = quarter_label(deal_date) if quarter is None: return None - fact_rub = fact_ppm2 * area_m2 # Знак ошибки — как в бэктесте: (прогноз − факт) / факт. Плюс = МЕРА # назвала дороже, чем ушло по ДКП. err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub @@ -385,7 +401,7 @@ def refresh_landing_showcase_deals( total_floors=deal.total_floors, deal_date=deal.deal_date, 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, # Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота # (~60.6). Перепутать местами — это точка в другой стране, и никакой diff --git a/tradein-mvp/backend/app/tasks/landing_stats.py b/tradein-mvp/backend/app/tasks/landing_stats.py index b6821e93..4a9cdfc8 100644 --- a/tradein-mvp/backend/app/tasks/landing_stats.py +++ b/tradein-mvp/backend/app/tasks/landing_stats.py @@ -24,7 +24,10 @@ Точность считает бэктест (своя задача, свои допущения), а срок продажи требует пары «объявление снято → сделка», которой у нас нет: снятие объявления не означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван -экспозицией активного объявления — см. note метрики. +экспозицией активного объявления — см. note метрики. Считается от даты +публикации у источника (`listing_date`, иначе `publish_date` — это одна и та же +величина в двух колонках, замер в комментарии к `_LISTING_AGE_SQL`), и note +называет охват: у 1500 из 31 068 активных объявлений ЕКБ даты публикации нет. ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ ---------------------------------------- @@ -90,16 +93,33 @@ _ANALOGS_SQL = text(""" # Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи: # знаменатель — те, кто ещё висит, поэтому величина по построению занижена # относительно «сколько в итоге продавалось». Это ограничение уезжает в 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(""" - 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 ( - ORDER BY (CURRENT_DATE - listing_date) - ) AS median - FROM listings - WHERE is_active - AND city = CAST(:city AS text) - AND listing_date IS NOT NULL - AND listing_date <= CURRENT_DATE + 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), "note": ( "Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге " - "(сколько дней висит на сегодня). Это НЕ срок продажи: " - "считается по тем, кто ещё продаётся, и снятие объявления " - "не означает сделку" + "(сколько дней висит на сегодня, от даты публикации у источника). " + "Это НЕ срок продажи: считается по тем, кто ещё продаётся, и " + "снятие объявления не означает сделку. Дата публикации известна " + f"у {int(row.n)} из {int(row.n_active)} активных объявлений города — " + "остальные в расчёт не входят" ), } ) diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 2c272a42..e0fe8ef4 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -264,6 +264,12 @@ class DealSample: sold_ppm2: float deal_date: Any # datetime.date | None — carried through for reporting only 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 floor: int | None = None total_floors: int | None = None @@ -1013,6 +1019,7 @@ _SAMPLE_SQL = text( ST_Y(geom::geometry) AS lat, rooms, price_per_m2 AS sold_ppm2, + price_rub, deal_date, area_m2, address, @@ -1091,6 +1098,7 @@ def _sample_sql(city: str | None) -> Any: ST_Y(geom::geometry) AS lat, rooms, price_per_m2 AS sold_ppm2, + price_rub, deal_date, area_m2, address, @@ -1176,6 +1184,7 @@ def _load_sample( lat=float(r["lat"]), rooms=int(r["rooms"]), 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"], area_m2=float(r["area_m2"]), address=r["address"], diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py index 42bde9bd..702e42f7 100644 --- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -91,7 +91,7 @@ def _build(**over: object) -> ShowcaseRow | None: "total_floors": 9, "deal_date": date(2026, 4, 1), "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, "lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи, "lon": 60.6055, # поэтому перестановка ловится по значению. @@ -108,6 +108,27 @@ def test_plain_row_survives_and_carries_signed_error() -> None: 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: """Промах оценщика ЛЮБОГО размера остаётся на витрине. @@ -139,7 +160,7 @@ def test_underdeclared_dkp_is_shown_not_hidden() -> None: строки в `note`, а не за счёт отсева. """ # Факт 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.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_ppm2=2_000_000.0, predicted_rub=100_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_rub=100_000_000.0, predicted_rub=100_000_000.0) is not 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 diff --git a/tradein-mvp/backend/tests/test_landing_stats.py b/tradein-mvp/backend/tests/test_landing_stats.py index f6b71772..7c561d76 100644 --- a/tradein-mvp/backend/tests/test_landing_stats.py +++ b/tradein-mvp/backend/tests/test_landing_stats.py @@ -108,7 +108,7 @@ def _rows(**overrides: Any) -> list[Any]: base: dict[str, Any] = { "estimates": SimpleNamespace(total=1123, period_days=94.0), "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")), "deals": SimpleNamespace(n=18657), } @@ -171,6 +171,38 @@ def test_listing_age_note_says_exposure_not_time_to_sell() -> None: 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: """Этих величин в данных нет; их считает бэктест со своими допущениями.""" 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"), ({"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_cut_share_pct", @@ -260,7 +292,7 @@ def test_totally_empty_run_keeps_the_showcase_instead_of_wiping_it() -> None: empty = _rows( estimates=SimpleNamespace(total=0, period_days=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), deals=SimpleNamespace(n=0), ) -- 2.45.3 From df9dd52996eac0df6f9134c6326b164f494088df Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 00:11:56 +0500 Subject: [PATCH 4/5] =?UTF-8?q?fix(mera/public):=20Infinity/NaN=20=D0=B2?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B5=20=E2=80=94=20422,=20?= =?UTF-8?q?=D0=B8=20=D0=B1=D1=8E=D0=B4=D0=B6=D0=B5=D1=82=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20=D1=82=D0=B0=D0=BA=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аудит живого сайта 30.08.2026: POST /api/public/mera/coverage с {"lat":56.8,"lon":1e400,...} отвечал 500, и двенадцать таких запросов подряд дали двенадцать пятисоток и ни одного 429. Две независимые поломки в одном месте, обе воспроизведены локально до правки. 1. 500 вместо 422. json.loads принимает Infinity/-Infinity/NaN, а 1e400 даёт inf переполнением. Pydantic отбивает такое поле по границам и кладёт значение в input ошибки, а ответ об ошибке сериализуется json.dumps(allow_nan=False) и падает уже после входа в ответ. Ломается не поле, а сборка ответа об ошибке — одна на всё приложение, поэтому и обработчик один (app/core/http_errors.py), а не валидатор на lon. 2. Лимитер мимо. _enforce стоял первой строкой тела хендлера, а FastAPI валидирует тело позже зависимостей, но раньше тела — до проверки просто не доходило. Та же поправка места, что уже сделана сегодня у _require_public_estimate_enabled: перенос в dependencies. Сделано для всех ручек файла, не только coverage. У /estimate и /estimate/read флаг остаётся первой зависимостью — 429 на выключенной ручке подтверждал бы её существование. Тесты двусторонние: снятие обработчика роняет 4 проверки 422, возврат лимитера в тело роняет проверку бюджета (проверено). --- tradein-mvp/backend/app/api/public/mera.py | 81 ++++++++++++++----- tradein-mvp/backend/app/core/http_errors.py | 59 ++++++++++++++ tradein-mvp/backend/app/main.py | 5 ++ .../backend/tests/test_public_mera_api.py | 66 +++++++++++++++ 4 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 tradein-mvp/backend/app/core/http_errors.py diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 7b8c4d85..db853872 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -70,6 +70,7 @@ import asyncio import hashlib import logging import secrets +from collections.abc import Callable from datetime import datetime from typing import Annotated, Literal @@ -185,6 +186,33 @@ def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None 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): """Вход публичного автокомплита. @@ -231,9 +259,12 @@ def _query_with_city(query: str, city_hint: str | None) -> str: 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( - request: Request, payload: PublicSuggestInput, db: Annotated[Session, Depends(get_db)], ) -> SuggestResponse: @@ -260,8 +291,6 @@ async def public_suggest( десятка в публичном UI не показывается, а каждый лишний кандидат может стоить внешнего вызова. """ - _enforce(_suggest_limiter, request, "suggest") - # Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его # собственным лимитом, и только оставшееся считаем в общий бюджет. daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY) @@ -302,9 +331,12 @@ async def public_suggest( _suggest_slots.release() -@router.post("/coverage", response_model=CoverageProbeResponse) +@router.post( + "/coverage", + response_model=CoverageProbeResponse, + dependencies=[Depends(_budget(_coverage_limiter, "coverage"))], +) def public_coverage( - request: Request, payload: CoverageProbeInput, db: Annotated[Session, Depends(get_db)], ) -> CoverageProbeResponse: @@ -318,7 +350,6 @@ def public_coverage( Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) — бесплатный шаг доказывает наличие данных, цену продаёт платный. """ - _enforce(_coverage_limiter, request, "coverage") 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( - request: Request, db: Annotated[Session, Depends(get_db)], ) -> dict[str, LandingStat]: """Витринные метрики лэндинга — готовый ночной срез (issue: числа по проду). @@ -374,8 +408,6 @@ def public_stats( `value` — числовое value_num, если оно есть; иначе value_text (для метрик, у которых значение не число). Оба NULL — отдаём null, а не выдуманный ноль. """ - _enforce(_stats_limiter, request, "stats") - rows = db.execute(_STATS_SQL).fetchall() return { 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( - request: Request, db: Annotated[Session, Depends(get_db)], ) -> ShowcaseResponse: """Витрина лэндинга: реальные ДКП-сделки против прогноза МЕРЫ. @@ -518,7 +553,6 @@ def public_showcase( годных строк не поместилось и по какому правилу отсеяно остальное. Числа считает пересчёт; без них витрина не имеет права подписаться честно. """ - _enforce(_showcase_limiter, request, "showcase") run = db.execute(_SHOWCASE_RUN_SQL).mappings().first() if run is None: return ShowcaseResponse(computed_at=None, deals=[], stats=None) @@ -693,7 +727,13 @@ def _coverage_for( @router.post( "/estimate", 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( request: Request, @@ -714,8 +754,6 @@ async def public_estimate( контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по своему токену. Наружу здесь уезжает только `PublicEstimateResult`. """ - _enforce(_estimate_limiter, request, "estimate") - daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY) if daily_retry is not None: logger.error( @@ -773,10 +811,13 @@ async def public_estimate( @router.post( "/estimate/read", 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( - request: Request, payload: PublicEstimateTokenInput, db: Annotated[Session, Depends(get_db)], ) -> PublicEstimateResult: @@ -791,8 +832,6 @@ def public_estimate_read( Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их значит подтверждать существование расчёта тому, кто угадал токен. """ - _enforce(_estimate_read_limiter, request, "estimate-read") - row = db.execute( text( """ diff --git a/tradein-mvp/backend/app/core/http_errors.py b/tradein-mvp/backend/app/core/http_errors.py new file mode 100644 index 00000000..ef2aba49 --- /dev/null +++ b/tradein-mvp/backend/app/core/http_errors.py @@ -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) diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 2b2f25b2..74c2d2ad 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -43,6 +43,7 @@ from app.core.auth_db import get_auth_engine from app.core.config import settings from app.core.db import SessionLocal 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.rbac import rbac_guard from app.core.request_audit import RequestAuditMiddleware @@ -219,6 +220,10 @@ app = FastAPI( 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 # (см. app/core/auth.py + auth/roles.yaml). Правила: # 1) Любой non-public path требует X-Authenticated-User — иначе 401. diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 33a8ead9..474450ac 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -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.v1.geocode import SuggestResponse # 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.schemas.trade_in import CoverageProbeResponse # noqa: E402 @@ -94,6 +95,10 @@ def client() -> TestClient: """ app = FastAPI() 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.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["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 -- 2.45.3 From bb4a6d220fbd84b900a5de1d9df8423d84aa591e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 00:12:26 +0500 Subject: [PATCH 5/5] =?UTF-8?q?fix(mera/b2c):=20=D1=83=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BA=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=83=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D1=8C=20=D0=BE=D0=B6?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D0=B5=D0=BC=D0=BE=D0=B5=20=D0=BA=D0=B0=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B2=D0=BE=20=D0=BF=D0=BE=20=D0=B3=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D1=83=20=D0=B4=D0=BE=20=D0=BD=D0=B0=D0=B6?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экран проверки дорисовывал результат НИЖЕ формы и никуда не уводил: на 375 px человек после нажатия видел ту же форму, а заголовок ответа оставался за нижней кромкой — нажатие читается как «ничего не произошло». Ответ теперь получает фокус и прокрутку; анимация прокрутки спрашивается у prefers-reduced-motion, той же медиа-функции, что глушит остальную анимацию витрины. Фокус здесь не украшение: без него клавиатурный пользователь остаётся на кнопке и следующим Tab уходит в обход ответа, а живая область объявляет текст, но не перемещает точку ввода. Второе: в дропдауне девять городов, и они не равны по данным, но узнать об этом можно было только ПОСЛЕ нажатия. Замер на проде (30.08.2026, симуляция когорты самой ручки /coverage по случайным адресам активных объявлений, собственный адрес исключён): доля проверок с выборкой не ниже городского порога — ЕКБ 83 %, Верхняя Пышма 70, Серов 58, Нижний Тагил 52, Первоуральск 50, Каменск-Уральский 45, Среднеуральск 39, Берёзовский 38, Ревда 16 (по 120 адресов, Среднеуральск — 56, столько их там есть). Величина и её источник лежат в landing-facts.ts, формулировка — в coverage-copy.ts, в компонент не вписано ни одного числа. Города из списка НЕ убраны: систематического отказа нет ни в одном (пустая когорта у худшего — 11 случаев из 100), разница между ними количественная, и её честнее назвать числом, чем снятием опции. Счёт по listings.city, дающий ноль по трём городам-спутникам, здесь не годится — колонка хранит город свипа скрейпера, а не геокод объявления (разбор над _CITY_CENTROIDS_DEG в trade_in.py). Тест требует замера на каждый город из OBLAST_CITIES — добавить город в дропдаун, не измерив его, теперь нельзя. --- .../__tests__/coverage-copy.test.ts | 42 +++++++++++++- .../_components/estimate/EstimateFlow.tsx | 50 +++++++++++++++- .../src/app/mera-public/coverage-copy.ts | 32 +++++++++++ .../src/app/mera-public/landing-facts.ts | 57 +++++++++++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts index 6e9bd162..3d03e999 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts @@ -1,6 +1,9 @@ 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"; /** @@ -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(); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx index d34a8918..66c35579 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx @@ -26,7 +26,7 @@ import { useCallback, useEffect, useId, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; 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 { normalizeDraftRooms, takeDraft } from "../../estimate-draft"; import { @@ -110,6 +110,7 @@ export function EstimateFlow() { const addressRef = useRef(null); const areaRef = useRef(null); + const answerRef = useRef(null); const coverageAbort = useRef(null); // Незавершённый запрос покрытия при уходе со страницы отменяем — иначе @@ -140,6 +141,32 @@ export function EstimateFlow() { 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 + отмена предыдущего запроса. // // Контроллер создаётся СРАЗУ, а не внутри setTimeout, и отменяется в @@ -263,6 +290,10 @@ export function EstimateFlow() { } const showList = suggestions.length > 0 && !picked; + // Чего ждать от проверки в этом городе — ДО нажатия кнопки, а не после. + // Города в списке не равны по данным, и молчание об этом человек читает как + // «везде одинаково». + const cityExpectation = describeCityExpectation(city); return (

@@ -286,6 +317,13 @@ export function EstimateFlow() { + {cityExpectation && ( +
+

{cityExpectation.text}

+

{cityExpectation.source}

+
+ )} + {/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и физически перекрывает поля «Комнат» и «Площадь», в которые человек как раз собрался попасть. onBlur на контейнере, а не на инпуте, — @@ -412,7 +450,15 @@ export function EstimateFlow() { {/* Живая область постоянно в DOM: регион, добавленный в момент ответа, часть скринридеров не озвучивает. */} -
+
{fieldError === "address" && (

diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts index 6df3344b..42246c37 100644 --- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -22,6 +22,7 @@ * ноль вместо неизвестного значения был бы худшей из ошибок. */ +import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts"; import type { CoverageProbe } from "./public-api"; export interface CoverageTile { @@ -82,6 +83,37 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] { 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 { const where = probe.city ? `в городе ${probe.city}` : "по этому адресу"; diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index a73b1bb2..bcd891b8 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -105,3 +105,60 @@ export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года"; * независимо, и правка одного не касалась остальных. */ 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 }, +]; -- 2.45.3