fix(mera-public): честные числа бэктеста — медиана трёх представительных пересборок

Покрытие 88 % и расхождение 14,5 % получались только на кластеризованной
выборке (ORDER BY id DESC: 20 строк на четыре улицы). Замер 31.08.2026 тремя
представительными пересборками (engine=full, ЕКБ, три соли) дал покрытие
82,77/84,31/86,02 % и MAPE 14,66/15,28/15,66 %: опубликованные 88 % лежат ВЫШЕ
всего диапазона, то есть недостижимы, а 14,5 % — чуть ниже, тот же
благоприятный край.

- BACKTEST.coverage 88 % -> 84,3 %, priceError 14,5 % -> 15,3 % (медианы),
  sampleN 327 -> 325 (медианная пересборка);
- в оговорки добавлен сам диапазон пересборок (82,8-86,0 % и 14,7-15,7 %):
  точечное значение без него обещает точность, которой у замера нет;
- BACKTEST_SOURCE переписан — представительная выборка, а не последние
  загруженные строки; дата замера 29.08 -> 31.08.2026;
- confidenceLow («325 из 327») на пересборках НЕ пересчитывался: у него
  отдельный источник с прежней датой и оговорка про свой знаменатель;
- квартальный состав представительной выборки не проверен — записан
  дословно, с критерием снятия, рядом с BACKTEST_PERIOD_LABEL.

Тест: backtest-freshness проверяет наличие диапазона в оговорках (+контроль на
инструмент) и что AccuracyV3/HeroV3 не содержат самих величин; render-тест
перестал сверяться с вписанным «14,5 %» и берёт значение из landing-facts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-31 12:41:32 +05:00
parent 189ae63e53
commit 2864d80854
3 changed files with 113 additions and 11 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

@ -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

@ -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 %, а не точностью точки: " +
@ -75,8 +99,13 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
confidenceLow: {
text: "325 из 327",
sampleN: 327,
source: BACKTEST_SOURCE,
caveats: ["Оценки «уверенность высокая» в выборке не встретилось ни разу."],
source: BACKTEST_CONFIDENCE_SOURCE,
caveats: [
"Оценки «уверенность высокая» в выборке не встретилось ни разу.",
"Число — из прогона 29.08.2026 по 327 кластеризованным сделкам; на " +
"представительных пересборках 31.08.2026 уверенность не пересчитывалась, " +
"поэтому знаменатель здесь свой, а не 325 головных величин.",
],
},
};
@ -104,6 +133,23 @@ export const BACKTEST_CORRIDOR_LABEL = "±37 %";
*/
export const BACKTEST_PERIOD_LABEL = "сделки II квартала 2026 года";
/**
* ЧЕГО МЫ НЕ ЗНАЕМ ПРО ОКНО ПРЕДСТАВИТЕЛЬНЫХ ПЕРЕСБОРОК (записано 31.08.2026).
*
* Подпись выше проверена на прогоне 29.08 (`ORDER BY id DESC`): там у всех
* 327 сделок стояло `deal_date = 2026-04-01`. Пересборки 31.08 разносят
* выборку по адресам, и квартальный состав их 325 сделок НИКТО не смотрел
* если разнесение идёт по всему окну `--since`, в выборку попадают и другие
* три метки квартала, а тогда и знаменатель `BACKTEST_POPULATION` (5 954
* только II квартал) назван не тот.
*
* КРИТЕРИЙ СНЯТИЯ, чтобы это не осталось вечной оговоркой: одним запросом
* посчитать `deal_date` по сделкам представительной выборки. Один квартал
* подпись и знаменатель остаются как есть; больше одного подпись назвать
* окном целиком и взять знаменатель того же окна. До этого числа окна не
* трогались: врать в другую сторону та же ошибка, что чинилась здесь.
*/
/**
* Сколько сделок было В ТОМ ЖЕ окне чтобы «мы сверили 327 сделок» не
* читалось как «столько их и было». Замер 30.08.2026 на проде: годных под те
@ -134,7 +180,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;