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:
parent
189ae63e53
commit
2864d80854
3 changed files with 113 additions and 11 deletions
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue