From 50a3a29b7293a381507cdb11e490fce221eb9ced Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 00:09:43 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera/b2c):=20=D0=B1=D0=BB=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D1=82=D0=BE=20=D0=BE?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=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