Merge remote-tracking branch 'origin/fix/audit-accuracy-window' into fix/audit-all
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m55s

This commit is contained in:
bot-backend 2026-08-30 00:14:23 +05:00
commit 2eebc680a6
3 changed files with 155 additions and 4 deletions

View file

@ -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));
});
});

View file

@ -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}` : ""

View file

@ -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`).