fix(mera/b2c): блок точности называет то окно, которое измерено
Подпись обещала «сделки с июня 2025 года», а выборка бэктеста берётся ORDER BY id DESC LIMIT :sample (backend/scripts/backtest_estimator.py, _SAMPLE_SQL) — это последние по порядку загрузки строки, а не срез окна. Проверка на проде 30.08.2026: у всех 327 сделок deal_date = 2026-04-01, то есть один квартал; проверены оба варианта запуска (без --city и с --city Екатеринбург) — результат одинаковый. Случайной выборки в скрипте нет, поэтому чинится подпись, а не замер: числа те же, окно названо своё. Заодно: - доля выборки на витрине (5,5 % сделок квартала). Знаменатель — из ТОГО ЖЕ окна (5 954 годных сделки ЕКБ за II кв 2026), а не 24 333 за всё окно с июня 2025: доля от непокрытого окна повторила бы ту же ошибку; - дата замера выведена рядом с числами: регулярного пересчёта у них нет, без даты они стареют молча; - __tests__/backtest-freshness.test.ts краснеет, когда замеру больше BACKTEST_MAX_AGE_DAYS (100 дн. = квартальная пачка Росреестра + запас). Фальсифицирован: дата 2026-01-05 → красный с текстом «замеру 236 дн.». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ba35c68eb2
commit
50a3a29b72
3 changed files with 155 additions and 4 deletions
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Срок годности ручного замера.
|
||||
*
|
||||
* ЗАЧЕМ. Числа бэктеста (медианное расхождение, попадание в коридор, доля
|
||||
* низкой уверенности) считает человек руками: ночной задачи, как у `/stats`,
|
||||
* у них нет — это записано в шапке `landing-facts.ts` и теперь видно на самой
|
||||
* витрине (дата замера в подписи блока «Точность»). Задокументированное
|
||||
* протухание — всё равно протухание: пока о нём знает только комментарий,
|
||||
* пересчёт остаётся ничьей задачей. Красный тест делает его чьей-то.
|
||||
*
|
||||
* ЧТО ИМЕННО ОН ЛОВИТ. Не «числа неверны» — этого тест знать не может, — а
|
||||
* «замеру больше BACKTEST_MAX_AGE_DAYS дней, и никто его не подтверждал».
|
||||
* Лечится двумя способами, и оба честные: перегнать бэктест и обновить числа
|
||||
* вместе с датой, либо снять блок с витрины. Двигать одну лишь дату, не
|
||||
* перегоняя замер, — враньё, и ровно оно тут и сторожится.
|
||||
*
|
||||
* ПОЧЕМУ СТОЛЬКО ДНЕЙ. Данные Росреестра приходят квартальными пачками
|
||||
* (`deals.deal_date` за окно принимает четыре значения — по одному на
|
||||
* квартал), значит чаще чем раз в квартал замеру обновляться не от чего.
|
||||
* Срок = квартал плюс запас на загрузку следующей пачки.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BACKTEST,
|
||||
BACKTEST_MAX_AGE_DAYS,
|
||||
BACKTEST_MEASURED_LABEL,
|
||||
BACKTEST_MEASURED_ON,
|
||||
BACKTEST_POPULATION,
|
||||
BACKTEST_SHARE_LABEL,
|
||||
} from "../landing-facts";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function ageDays(iso: string, now: number): number {
|
||||
return Math.floor((now - Date.parse(`${iso}T00:00:00Z`)) / DAY_MS);
|
||||
}
|
||||
|
||||
describe("свежесть ручного замера бэктеста", () => {
|
||||
it("дата замера разбирается и не из будущего — иначе сторож считает возраст мусора", () => {
|
||||
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
|
||||
expect(
|
||||
Number.isFinite(age),
|
||||
`BACKTEST_MEASURED_ON=${BACKTEST_MEASURED_ON} — не ISO-дата`,
|
||||
).toBe(true);
|
||||
expect(age, "дата замера в будущем").toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("замеру не больше срока годности", () => {
|
||||
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
|
||||
expect(
|
||||
age,
|
||||
[
|
||||
`замеру ${age} дн. (${BACKTEST_MEASURED_ON}), допустимо ${BACKTEST_MAX_AGE_DAYS}.`,
|
||||
"Числа блока «Точность» посчитаны руками и с тех пор никем не подтверждены.",
|
||||
"Перегнать: python -m scripts.backtest_estimator --city Екатеринбург",
|
||||
"— обновить BACKTEST, BACKTEST_PERIOD_LABEL (назвать окно, которое реально",
|
||||
"покрыла выборка), BACKTEST_POPULATION и дату замера; либо снять блок с",
|
||||
"витрины. Двигать дату без пересчёта — враньё.",
|
||||
].join(" "),
|
||||
).toBeLessThanOrEqual(BACKTEST_MAX_AGE_DAYS);
|
||||
});
|
||||
|
||||
it("сторож краснеет на протухшем замере — иначе он зелёный по построению", () => {
|
||||
// Контроль на инструмент: тот же расчёт возраста, но на заведомо старой
|
||||
// дате. Без него тест выше остаётся зелёным и при сломанной арифметике.
|
||||
const staleNow =
|
||||
Date.parse(`${BACKTEST_MEASURED_ON}T00:00:00Z`) + (BACKTEST_MAX_AGE_DAYS + 1) * DAY_MS;
|
||||
expect(ageDays(BACKTEST_MEASURED_ON, staleNow)).toBeGreaterThan(BACKTEST_MAX_AGE_DAYS);
|
||||
});
|
||||
|
||||
it("доля выборки выведена из размера выборки, а не вписана рядом", () => {
|
||||
expect(BACKTEST_POPULATION).toBeGreaterThan(BACKTEST.priceError.sampleN);
|
||||
const expected = `${((BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100)
|
||||
.toFixed(1)
|
||||
.replace(".", ",")} %`;
|
||||
expect(BACKTEST_SHARE_LABEL).toBe(expected);
|
||||
expect(BACKTEST_MEASURED_LABEL).toContain(BACKTEST_MEASURED_ON.slice(0, 4));
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,17 @@
|
|||
* AccuracyV3 — «Точность»: KPI-плитки + таблица «прогноз против цены сделки»
|
||||
* (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
|
||||
*
|
||||
* ОКНО НАЗЫВАЕТСЯ ТО, КОТОРОЕ ИЗМЕРЕНО. В подписи стояло «сделки с июня 2025
|
||||
* года», а выборка бэктеста берётся `ORDER BY id DESC LIMIT :sample` — все её
|
||||
* 327 сделок пришлись на один квартал (проверка на проде 30.08.2026, разбор в
|
||||
* `landing-facts.ts` при BACKTEST_PERIOD_LABEL). Рядом с размером выборки
|
||||
* стоит её ДОЛЯ: «327 сделок» без знаменателя читается как «столько их и
|
||||
* было», хотя в том же квартале их 5 954.
|
||||
*
|
||||
* ДАТА ЗАМЕРА — НА ВИТРИНЕ, А НЕ В КОММЕНТАРИИ. Числа бэктеста считаны руками
|
||||
* и не пересчитываются ночной задачей; без даты они стареют молча. Дату видит
|
||||
* читатель, а срок годности сторожит `__tests__/backtest-freshness.test.ts`.
|
||||
*
|
||||
* ОТКУДА ЧИСЛА. Три первых плитки — разовая сверка прогноза с ценой ДКП
|
||||
* (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
|
||||
* расчётов — `/stats`, каждая плитка рендерится только если величина пришла:
|
||||
|
|
@ -34,7 +45,9 @@
|
|||
import {
|
||||
BACKTEST,
|
||||
BACKTEST_CORRIDOR_LABEL,
|
||||
BACKTEST_MEASURED_LABEL,
|
||||
BACKTEST_PERIOD_LABEL,
|
||||
BACKTEST_SHARE_LABEL,
|
||||
} from "../../landing-facts";
|
||||
import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
|
@ -115,7 +128,7 @@ export function AccuracyV3({
|
|||
Мы сверили прогноз с ценой сделки — вот что вышло
|
||||
</h2>
|
||||
<p className={styles.accLead}>
|
||||
{`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок.`}
|
||||
{`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок — ${BACKTEST_SHARE_LABEL} сделок квартала. Разовый ${BACKTEST_MEASURED_LABEL}, регулярного пересчёта у этих чисел нет.`}
|
||||
{estimates
|
||||
? ` Расчёты в системе живут в другом окне: ${estimates.text}${
|
||||
period ? ` за ${period.text}` : ""
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export interface MeasuredValue {
|
|||
/** Общий источник трёх величин ниже — один и тот же прогон сверки. */
|
||||
const BACKTEST_SOURCE =
|
||||
"Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " +
|
||||
"Росреестра по Екатеринбургу, сделки с 06.2025";
|
||||
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
|
||||
|
||||
/**
|
||||
* Сверка «прогноз → цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются
|
||||
|
|
@ -83,8 +83,66 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
|
|||
/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
|
||||
export const BACKTEST_CORRIDOR_LABEL = "±37 %";
|
||||
|
||||
/** Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`. */
|
||||
export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года";
|
||||
/**
|
||||
* Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`.
|
||||
*
|
||||
* ЗДЕСЬ СТОЯЛО «сделки с июня 2025 года» — и это называло не то окно, которое
|
||||
* измерено. `--since 2025-06-01` задаёт только нижнюю границу, а выборку
|
||||
* скрипт берёт `ORDER BY id DESC LIMIT :sample`
|
||||
* (`backend/scripts/backtest_estimator.py`, _SAMPLE_SQL): это последние по
|
||||
* порядку загрузки строки, а не срез по всему окну. Проверено на проде
|
||||
* 30.08.2026: у всех 327 сделок выборки `deal_date = 2026-04-01`, то есть
|
||||
* ровно один квартал (проверены оба варианта запуска — и без `--city`, и с
|
||||
* `--city Екатеринбург`; результат один и тот же). Случайной выборки в
|
||||
* скрипте нет, поэтому чинится ПОДПИСЬ, а не замер: числа остаются те же,
|
||||
* названо окно, которое они покрыли.
|
||||
*
|
||||
* `deal_date` у Росреестра — не дата сделки, а метка квартала: во всей таблице
|
||||
* за окно ровно четыре значения (01.07.2025, 01.10.2025, 01.01.2026,
|
||||
* 01.04.2026). Поэтому «квартал» — предельная точность, которую данные вообще
|
||||
* позволяют назвать; «сделки за апрель» было бы вторым враньём.
|
||||
*/
|
||||
export const BACKTEST_PERIOD_LABEL = "сделки II квартала 2026 года";
|
||||
|
||||
/**
|
||||
* Сколько сделок было В ТОМ ЖЕ окне — чтобы «мы сверили 327 сделок» не
|
||||
* читалось как «столько их и было». Замер 30.08.2026 на проде: годных под те
|
||||
* же фильтры выборки (`source='rosreestr'`, geom, ppm² 30 000..600 000, rooms,
|
||||
* area) сделок Екатеринбурга с `deal_date = 2026-04-01` — 5 954.
|
||||
*
|
||||
* ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, что и выборка. Соблазн подставить сюда
|
||||
* 24 333 (все сделки ЕКБ с июня 2025) даёт долю красивее — 1,3 % вместо
|
||||
* 5,5 %, — но это ровно та же ошибка, что чинится выше: доля от окна, которое
|
||||
* замер не покрывал.
|
||||
*/
|
||||
export const BACKTEST_POPULATION = 5_954;
|
||||
|
||||
/**
|
||||
* Доля выборки. ВЫВОДИТСЯ, а не вписывается: разъехаться с `sampleN` не может.
|
||||
*/
|
||||
export const BACKTEST_SHARE_LABEL = `${((BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100)
|
||||
.toFixed(1)
|
||||
.replace(".", ",")} %`;
|
||||
|
||||
/**
|
||||
* Дата замера — на витрину, а не только в комментарий.
|
||||
*
|
||||
* Числа бэктеста считает человек руками, ночной задачи для них нет (см. шапку
|
||||
* файла), поэтому они стареют молча. Дата рядом с числом — первый признак
|
||||
* протухания, который видит читатель; второй, обязательный для нас, —
|
||||
* `__tests__/backtest-freshness.test.ts`: он краснеет, когда замеру больше
|
||||
* BACKTEST_MAX_AGE_DAYS. Пока теста не было, «пересчитать» было ничьей
|
||||
* задачей.
|
||||
*/
|
||||
export const BACKTEST_MEASURED_ON = "2026-08-29";
|
||||
|
||||
/** Через сколько дней замер считается протухшим (см. тест свежести). */
|
||||
export const BACKTEST_MAX_AGE_DAYS = 100;
|
||||
|
||||
const [measuredY, measuredM, measuredD] = BACKTEST_MEASURED_ON.split("-");
|
||||
|
||||
/** Человекочитаемая дата замера. Выводится из ISO — двух правок не требует. */
|
||||
export const BACKTEST_MEASURED_LABEL = `замер ${measuredD}.${measuredM}.${measuredY}`;
|
||||
|
||||
/**
|
||||
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue