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
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:
commit
98a1944a86
5 changed files with 214 additions and 23 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.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue