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