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
index 905ed0cf..70c90626 100644
--- 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
@@ -19,6 +19,9 @@
* квартал), значит чаще чем раз в квартал замеру обновляться не от чего.
* Срок = квартал плюс запас на загрузку следующей пачки.
*/
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
import { describe, expect, it } from "vitest";
import {
@@ -78,3 +81,55 @@ describe("свежесть ручного замера бэктеста", () =>
expect(BACKTEST_MEASURED_LABEL).toContain(BACKTEST_MEASURED_ON.slice(0, 4));
});
});
+
+/**
+ * Разброс пересборок и происхождение величин.
+ *
+ * ЗАЧЕМ. 31.08.2026 три представительные пересборки одной и той же сверки дали
+ * попадание 82,8-86,0 % и расхождение 14,7-15,7 %. Опубликованные до этого
+ * 88 % лежали ВЫШЕ всего диапазона — значение получалось только на
+ * кластеризованной выборке (`ORDER BY id DESC`, 20 строк на четыре улицы).
+ * Точечное число без диапазона обещает точность, которой у замера нет, поэтому
+ * оговорка про разброс — не украшение, а часть величины: снимут её — красное.
+ *
+ * ВТОРАЯ ПОЛОВИНА — откуда число берёт компонент. Прежние 88 % пережили бы
+ * правку `landing-facts.ts`, будь они вписаны в разметку; здесь проверяется,
+ * что в витринных компонентах нет самих значений, а есть ссылки на факты.
+ */
+const V3 = join(__dirname, "..", "_components", "v3");
+const SPREAD_RANGES = ["14,7-15,7", "82,8-86,0"];
+
+function caveatsText(): string {
+ return [...BACKTEST.priceError.caveats, ...BACKTEST.coverage.caveats].join(" ");
+}
+
+describe("разброс пересборок назван, а величины не вписаны в компонент", () => {
+ it.each(SPREAD_RANGES)("оговорка называет диапазон пересборок %s", (range) => {
+ expect(
+ caveatsText(),
+ [
+ `в оговорках priceError/coverage нет диапазона ${range}.`,
+ "Точечное значение разового замера гуляет при пересборке выборки —",
+ "без диапазона витрина обещает точность, которой у замера нет.",
+ ].join(" "),
+ ).toContain(range);
+ });
+
+ it("проверка диапазона краснеет на тексте без него — иначе она зелёная по построению", () => {
+ const withoutRange = "Попадание достигнуто шириной коридора, а не точностью точки.";
+ for (const range of SPREAD_RANGES) {
+ expect(withoutRange).not.toContain(range);
+ }
+ });
+
+ it.each(["AccuracyV3.tsx", "HeroV3.tsx"])("%s берёт величины из landing-facts, а не из своей разметки", (file) => {
+ const src = readFileSync(join(V3, file), "utf8");
+ for (const value of [BACKTEST.priceError.text, BACKTEST.coverage.text]) {
+ expect(
+ src,
+ `${file}: величина ${value} вписана в компонент — правка landing-facts её не догонит`,
+ ).not.toContain(value);
+ }
+ expect(src, `${file}: величина берётся не из BACKTEST`).toContain("BACKTEST.");
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx
new file mode 100644
index 00000000..27235f9f
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx
@@ -0,0 +1,68 @@
+/**
+ * Подписи бэктеста обязаны описывать ТО окно, по которому числа посчитаны.
+ *
+ * 31.08.2026 числа перевели на представительную выборку (медиана трёх
+ * пересборок), но подписи остались от прежнего прогона: «сделки II квартала
+ * 2026 года» и знаменатель 5 954 (только этот квартал). Замер показал, что
+ * пересборка покрывает ЧЕТЫРЕ квартала и II квартал 2026 — лишь 24 % её
+ * сделок. Доля выборки при старом знаменателе выходила 5,5 % вместо 1,3 %,
+ * то есть вчетверо льстила.
+ *
+ * Отдельно: плитка «уверенность низкая» показывалась БЕЗ своей оговорки —
+ * `AccuracyV3` собирал оговорки только двух величин из трёх.
+ */
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AccuracyV3 } from "../_components/v3/AccuracyV3";
+import {
+ BACKTEST,
+ BACKTEST_PERIOD_LABEL,
+ BACKTEST_POPULATION,
+ BACKTEST_SHARE_LABEL,
+} from "../landing-facts";
+
+describe("подписи окна бэктеста", () => {
+ it("окно названо целиком, а не одним кварталом из четырёх", () => {
+ // Прежняя подпись называла II квартал 2026 — в представительной выборке
+ // это 24 % сделок, а не все.
+ expect(BACKTEST_PERIOD_LABEL).not.toMatch(/^сделки II квартала/u);
+ expect(BACKTEST_PERIOD_LABEL).toMatch(/четыре квартала/u);
+ });
+
+ it("знаменатель — того же окна, что и выборка", () => {
+ // 5 954 — только II квартал 2026; окно целиком — 24 333 (замер 31.08.2026).
+ expect(BACKTEST_POPULATION).toBe(24_333);
+ });
+
+ it("доля выборки выводится и получается около 1,3 %, а не 5,5 %", () => {
+ const share = (BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100;
+ expect(share).toBeGreaterThan(1.0);
+ expect(share).toBeLessThan(2.0);
+ expect(BACKTEST_SHARE_LABEL).toBe("1,3 %");
+ });
+
+ it("уверенность посчитана на том же прогоне, что и остальные числа", () => {
+ // «325 из 327» было из прогона 29.08 и под общей подписью «замер 31.08»
+ // приписывало старому числу новую дату.
+ expect(BACKTEST.confidenceLow.text).not.toMatch(/327/u);
+ });
+});
+
+describe("оговорки в интерфейсе", () => {
+ it("на экране есть оговорки ВСЕХ ТРЁХ величин блока", () => {
+ render();
+ for (const набор of [
+ BACKTEST.priceError.caveats,
+ BACKTEST.coverage.caveats,
+ BACKTEST.confidenceLow.caveats,
+ ]) {
+ for (const line of набор) {
+ expect(
+ screen.queryByText(line),
+ `оговорка не выводится: ${line.slice(0, 48)}…`,
+ ).not.toBeNull();
+ }
+ }
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
index 4a3c7aca..59c4fcc0 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
@@ -13,6 +13,7 @@ import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { HeroV3 } from "../_components/v3/HeroV3";
+import { BACKTEST } from "../landing-facts";
import type { LandingStat, ShowcaseResponse } from "../public-api";
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
@@ -77,9 +78,9 @@ const SHOWCASE: ShowcaseResponse = {
describe("витрина лэндинга v3 без данных", () => {
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
render();
- expect(screen.getByText("14,5 %")).toBeTruthy();
+ expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy();
- expect(screen.getByText("325 из 327")).toBeTruthy();
+ expect(screen.getByText(BACKTEST.confidenceLow.text)).toBeTruthy();
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
@@ -92,7 +93,7 @@ describe("витрина лэндинга v3 без данных", () => {
expect(container.querySelector('[role="table"]')).toBeNull();
expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
- expect(screen.getByText("14,5 %")).toBeTruthy();
+ expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
});
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
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 2be7c7d8..418fff0b 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
@@ -114,9 +114,14 @@ export function AccuracyV3({
});
}
+ // Оговорки ВСЕХ ТРЁХ величин блока, а не двух. `confidenceLow` показывается
+ // отдельной плиткой, но её оговорка сюда не попадала — то есть на экране
+ // стояло «400 из 400 — уверенность низкая» без единого слова о том, почему.
+ // Плитка без своей оговорки — ровно то, что этот блок и не должен делать.
const caveats = [
...BACKTEST.priceError.caveats,
...BACKTEST.coverage.caveats,
+ ...BACKTEST.confidenceLow.caveats,
];
const deals = showcase?.deals ?? [];
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 e1ee7300..d0688d7c 100644
--- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
@@ -42,8 +42,29 @@ export interface MeasuredValue {
readonly caveats: readonly string[];
}
-/** Общий источник трёх величин ниже — один и тот же прогон сверки. */
+/**
+ * Источник двух головных величин — ПРЕДСТАВИТЕЛЬНЫЙ прогон 31.08.2026.
+ *
+ * ЧТО ПОМЕНЯЛОСЬ ПРОТИВ 29.08. Прежний прогон брал строки `ORDER BY id DESC`,
+ * то есть последние загруженные: 20 строк выборки приходились на ЧЕТЫРЕ улицы.
+ * Это не выборка города, а её кластер, и покрытие на нём выходило 88 % —
+ * ВЫШЕ всего диапазона представительных пересборок (82,8-86,0 %). Значение
+ * было недостижимым, а не «чуть оптимистичным»; расхождение 14,5 % лежало
+ * чуть ниже диапазона 14,66-15,66 % — тот же благоприятный край, но на
+ * порядок слабее. Здесь стоит МЕДИАНА трёх пересборок с разными солями.
+ */
const BACKTEST_SOURCE =
+ "Бэктест на боевой базе (31.08.2026, engine=full): прогноз МЕРЫ против цены " +
+ "ДКП Росреестра по Екатеринбургу, представительная выборка (разнесение по " +
+ "адресам, три пересборки с разными солями), медиана трёх прогонов";
+
+/**
+ * Источник `confidenceLow` — ПРЕЖНИЙ прогон, он не пересчитывался 31.08.
+ * Держится отдельной строкой, чтобы дата и выборка не выглядели общими с
+ * головными числами: там 325 сделок представительной выборки, здесь 327
+ * кластеризованных.
+ */
+const BACKTEST_CONFIDENCE_SOURCE =
"Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " +
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
@@ -54,18 +75,21 @@ const BACKTEST_SOURCE =
*/
export const BACKTEST: Readonly> = {
priceError: {
- text: "14,5 %",
- sampleN: 327,
+ text: "15,3 %",
+ sampleN: 325,
source: BACKTEST_SOURCE,
caveats: [
+ "Величина разового замера гуляет при пересборке выборки: на трёх пересборках " +
+ "расхождение вышло 14,7-15,7 %, попадание в коридор — 82,8-86,0 %. " +
+ "Точечное значение без этого диапазона обещает точность, которой у замера нет.",
"Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " +
"это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.",
"Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.",
],
},
coverage: {
- text: "88 %",
- sampleN: 327,
+ text: "84,3 %",
+ sampleN: 325,
source: BACKTEST_SOURCE,
caveats: [
"Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " +
@@ -73,10 +97,21 @@ export const BACKTEST: Readonly 0,
+ * `deal_date >= 2025-06-01`, город Екатеринбург): **24 333** сделки.
+ *
+ * ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, ЧТО И ВЫБОРКА — правило не изменилось,
+ * изменилось окно. Раньше здесь стояло 5 954 (только II квартал 2026), и это
+ * было верно для прогона 29.08, который в один квартал и укладывался. Для
+ * пересборок вразброс, покрывающих четыре квартала, тот же знаменатель дал бы
+ * долю вчетверо больше реальной — 5,5 % вместо 1,3 %.
+ *
+ * Обе величины проверены одним запросом: в окне 24 333, из них с
+ * `deal_date = 2026-04-01` — 5 954.
+ */
+export const BACKTEST_POPULATION = 24_333;
/**
* Доля выборки. ВЫВОДИТСЯ, а не вписывается: разъехаться с `sampleN` не может.
@@ -134,7 +196,7 @@ export const BACKTEST_SHARE_LABEL = `${((BACKTEST.priceError.sampleN / BACKTEST_
* BACKTEST_MAX_AGE_DAYS. Пока теста не было, «пересчитать» было ничьей
* задачей.
*/
-export const BACKTEST_MEASURED_ON = "2026-08-29";
+export const BACKTEST_MEASURED_ON = "2026-08-31";
/** Через сколько дней замер считается протухшим (см. тест свежести). */
export const BACKTEST_MAX_AGE_DAYS = 100;