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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -78,3 +81,55 @@ describe("свежесть ручного замера бэктеста", () =>
|
||||||
expect(BACKTEST_MEASURED_LABEL).toContain(BACKTEST_MEASURED_ON.slice(0, 4));
|
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 { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
|
||||||
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
|
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
|
||||||
import { HeroV3 } from "../_components/v3/HeroV3";
|
import { HeroV3 } from "../_components/v3/HeroV3";
|
||||||
|
import { BACKTEST } from "../landing-facts";
|
||||||
import type { LandingStat, ShowcaseResponse } from "../public-api";
|
import type { LandingStat, ShowcaseResponse } from "../public-api";
|
||||||
|
|
||||||
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
|
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
|
||||||
|
|
@ -77,9 +78,9 @@ const SHOWCASE: ShowcaseResponse = {
|
||||||
describe("витрина лэндинга v3 без данных", () => {
|
describe("витрина лэндинга v3 без данных", () => {
|
||||||
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
|
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
|
||||||
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
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(/коридор шириной ±37 %/)).toBeTruthy();
|
||||||
expect(screen.getByText("325 из 327")).toBeTruthy();
|
expect(screen.getByText(BACKTEST.confidenceLow.text)).toBeTruthy();
|
||||||
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
|
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
|
||||||
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
|
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
|
||||||
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
||||||
|
|
@ -92,7 +93,7 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
expect(container.querySelector('[role="table"]')).toBeNull();
|
expect(container.querySelector('[role="table"]')).toBeNull();
|
||||||
expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
|
expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
|
||||||
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
|
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
|
||||||
expect(screen.getByText("14,5 %")).toBeTruthy();
|
expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
|
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,29 @@ export interface MeasuredValue {
|
||||||
readonly caveats: readonly string[];
|
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 =
|
const BACKTEST_SOURCE =
|
||||||
|
"Бэктест на боевой базе (31.08.2026, engine=full): прогноз МЕРЫ против цены " +
|
||||||
|
"ДКП Росреестра по Екатеринбургу, представительная выборка (разнесение по " +
|
||||||
|
"адресам, три пересборки с разными солями), медиана трёх прогонов";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Источник `confidenceLow` — ПРЕЖНИЙ прогон, он не пересчитывался 31.08.
|
||||||
|
* Держится отдельной строкой, чтобы дата и выборка не выглядели общими с
|
||||||
|
* головными числами: там 325 сделок представительной выборки, здесь 327
|
||||||
|
* кластеризованных.
|
||||||
|
*/
|
||||||
|
const BACKTEST_CONFIDENCE_SOURCE =
|
||||||
"Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " +
|
"Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " +
|
||||||
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
|
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
|
||||||
|
|
||||||
|
|
@ -54,18 +75,21 @@ const BACKTEST_SOURCE =
|
||||||
*/
|
*/
|
||||||
export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
|
export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
|
||||||
priceError: {
|
priceError: {
|
||||||
text: "14,5 %",
|
text: "15,3 %",
|
||||||
sampleN: 327,
|
sampleN: 325,
|
||||||
source: BACKTEST_SOURCE,
|
source: BACKTEST_SOURCE,
|
||||||
caveats: [
|
caveats: [
|
||||||
|
"Величина разового замера гуляет при пересборке выборки: на трёх пересборках " +
|
||||||
|
"расхождение вышло 14,7-15,7 %, попадание в коридор — 82,8-86,0 %. " +
|
||||||
|
"Точечное значение без этого диапазона обещает точность, которой у замера нет.",
|
||||||
"Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " +
|
"Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " +
|
||||||
"это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.",
|
"это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.",
|
||||||
"Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.",
|
"Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
coverage: {
|
coverage: {
|
||||||
text: "88 %",
|
text: "84,3 %",
|
||||||
sampleN: 327,
|
sampleN: 325,
|
||||||
source: BACKTEST_SOURCE,
|
source: BACKTEST_SOURCE,
|
||||||
caveats: [
|
caveats: [
|
||||||
"Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " +
|
"Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " +
|
||||||
|
|
@ -75,8 +99,13 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
|
||||||
confidenceLow: {
|
confidenceLow: {
|
||||||
text: "325 из 327",
|
text: "325 из 327",
|
||||||
sampleN: 327,
|
sampleN: 327,
|
||||||
source: BACKTEST_SOURCE,
|
source: BACKTEST_CONFIDENCE_SOURCE,
|
||||||
caveats: ["Оценки «уверенность высокая» в выборке не встретилось ни разу."],
|
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 года";
|
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 сделок» не
|
* Сколько сделок было В ТОМ ЖЕ окне — чтобы «мы сверили 327 сделок» не
|
||||||
* читалось как «столько их и было». Замер 30.08.2026 на проде: годных под те
|
* читалось как «столько их и было». Замер 30.08.2026 на проде: годных под те
|
||||||
|
|
@ -134,7 +180,7 @@ export const BACKTEST_SHARE_LABEL = `${((BACKTEST.priceError.sampleN / BACKTEST_
|
||||||
* BACKTEST_MAX_AGE_DAYS. Пока теста не было, «пересчитать» было ничьей
|
* 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;
|
export const BACKTEST_MAX_AGE_DAYS = 100;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue