Merge pull request 'числа лендинга: покрытие 88 % было выше всего диапазона представительных пересборок' (#3295) from fix/mera-honest-coverage-number into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled

This commit is contained in:
bot-backend 2026-08-31 09:26:28 +00:00
commit 98a1944a86
5 changed files with 214 additions and 23 deletions

View file

@ -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.");
});
});

View file

@ -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(<AccuracyV3 stats={{}} showcase={null} />);
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();
}
}
});
});

View file

@ -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(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
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("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {

View file

@ -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 ?? [];

View file

@ -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<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
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<Record<"priceError" | "coverage" | "confidenceLo
],
},
confidenceLow: {
text: "325 из 327",
sampleN: 327,
source: BACKTEST_SOURCE,
caveats: ["Оценки «уверенность высокая» в выборке не встретилось ни разу."],
// Тот же прогон, что и остальные числа блока: пересборка «mera» от
// 31.08.2026. Раньше здесь стояло «325 из 327» из прогона 29.08 — под
// общей подписью «замер 31.08» это приписывало старому числу новую дату.
// Знаменатель 400 — это ЗАПРОШЕННЫЕ сделки, а не 325 сматчившихся:
// уверенность считается на каждой оценке, включая те, что не дошли до
// сверки с ценой ДКП.
text: "400 из 400",
sampleN: 400,
source: BACKTEST_CONFIDENCE_SOURCE,
caveats: [
"«Уверенность высокая» за три пересборки по 400 оценок встретилась один " +
"раз, «средняя» — 0, 6 и 9 раз. Это не скромность: порог требует " +
"разброса цен среди сопоставимых объявлений не выше 24 %, а на замере " +
"31.08.2026 по 60 случайным адресам его медиана — 29 %.",
],
},
};
@ -102,20 +137,47 @@ export const BACKTEST_CORRIDOR_LABEL = "±37 %";
* 01.04.2026). Поэтому «квартал» предельная точность, которую данные вообще
* позволяют назвать; «сделки за апрель» было бы вторым враньём.
*/
export const BACKTEST_PERIOD_LABEL = "сделки II квартала 2026 года";
export const BACKTEST_PERIOD_LABEL =
"сделки за четыре квартала — с III квартала 2025 по II квартал 2026";
/**
* Сколько сделок было В ТОМ ЖЕ окне чтобы «мы сверили 327 сделок» не
* читалось как «столько их и было». Замер 30.08.2026 на проде: годных под те
* же фильтры выборки (`source='rosreestr'`, geom, ppm² 30 000..600 000, rooms,
* area) сделок Екатеринбурга с `deal_date = 2026-04-01` 5 954.
* КВАРТАЛЬНЫЙ СОСТАВ ПРЕДСТАВИТЕЛЬНОЙ ВЫБОРКИ ЗАМЕРЕН 31.08.2026.
*
* ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, что и выборка. Соблазн подставить сюда
* 24 333 (все сделки ЕКБ с июня 2025) даёт долю красивее 1,3 % вместо
* 5,5 %, но это ровно та же ошибка, что чинится выше: доля от окна, которое
* замер не покрывал.
* Здесь стояла оговорка «никто не смотрел» с критерием снятия: посчитать
* `deal_date` по сделкам выборки. Посчитано, и ответ поменял подпись.
*
* Прогон 29.08 (`ORDER BY id DESC`) брал последние ЗАГРУЖЕННЫЕ строки, и у
* всех 327 сделок стояло `deal_date = 2026-04-01` один квартал. Пересборка
* вразброс покрывает ВСЁ окно `--since`, и её 400 сделок легли так:
*
* III кв 2025 106 (26,5 %) I кв 2026 88 (22,0 %)
* IV кв 2025 110 (27,5 %) II кв 2026 96 (24,0 %)
*
* То есть II квартал 2026 лишь четверть выборки. Прежняя подпись «сделки
* II квартала 2026 года» для этих чисел была бы прямым враньём, а знаменатель
* 5 954 (только II квартал) завышал бы долю вчетверо.
*
* Поэтому подпись называет окно целиком, а знаменатель взят того же окна.
*/
export const BACKTEST_POPULATION = 5_954;
/**
* Сколько сделок было В ТОМ ЖЕ окне чтобы «мы сверили 325 сделок» не
* читалось как «столько их и было».
*
* Замер 31.08.2026 на проде, ровно те же фильтры, что у выборки бэктеста
* (`source='rosreestr'`, geom, ppm² 30 000..600 000, rooms, area > 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;