From 2864d80854383e52d741ba9cbd0723c3560dc119 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 12:41:32 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(mera-public):=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B5=20=D1=87=D0=B8=D1=81=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=BD=D0=B0=20=D1=82=D1=80=D1=91?= =?UTF-8?q?=D1=85=20=D0=BF=D1=80=D0=B5=D0=B4=D1=81=D1=82=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=B1=D0=BE=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Покрытие 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 --- .../__tests__/backtest-freshness.test.ts | 55 ++++++++++++++++ .../__tests__/landing-v3-render.test.tsx | 7 ++- .../src/app/mera-public/landing-facts.ts | 62 ++++++++++++++++--- 3 files changed, 113 insertions(+), 11 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts index 905ed0cf..70c90626 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts @@ -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."); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx index 4a3c7aca..59c4fcc0 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx @@ -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(); - 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("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => { diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index e1ee7300..9b0aef3a 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -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> = { 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 Date: Mon, 31 Aug 2026 12:59:27 +0500 Subject: [PATCH 2/2] =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82:?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BA=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80=D1=83,=20?= =?UTF-8?q?=D1=83=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=E2=80=94=20=D0=BA=20=D1=82=D0=BE=D0=BC=D1=83=20=D0=B6?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снимает три блокера ревью на этой же ветке. 1. ПОДПИСЬ ОКНА. Числа перевели на представительную выборку, а подпись осталась от прежнего прогона: «сделки II квартала 2026 года». Автор честно записал, что квартальный состав пересборки не смотрел, и оставил критерий снятия — посчитать deal_date по выборке. Посчитал: III кв 2025 — 106 (26,5 %) I кв 2026 — 88 (22,0 %) IV кв 2025 — 110 (27,5 %) II кв 2026 — 96 (24,0 %) Пересборка покрывает ЧЕТЫРЕ квартала, II квартал 2026 — четверть её. Прежняя подпись для этих чисел была бы прямым враньём. 2. ЗНАМЕНАТЕЛЬ. Был 5 954 — сделки только II квартала. Для выборки по четырём кварталам это завышало долю вчетверо: 5,5 % вместо 1,3 %. Замер тем же фильтром, что у выборки: в окне 24 333, из них II квартал 2026 — 5 954. Оба числа одним запросом. 3. УВЕРЕННОСТЬ. Плитка «325 из 327» была из прогона 29.08, а лид секции печатал «замер 31.08» — старому числу приписывалась новая дата, и 325 в лиде совпадало с 325 в плитке, читаясь как одна выборка. Заменено на «400 из 400» из ТОЙ ЖЕ пересборки. Знаменатель 400 — запрошенные сделки, а не 325 сматчившихся: уверенность считается на каждой оценке. 4. Оговорка уверенности не выводилась вовсе: AccuracyV3 собирал оговорки двух величин из трёх. Плитка стояла без единого слова о том, почему уверенность низкая. Добавлена третья. Тесты проверены фальсификацией: возврат знаменатели одного квартала даёт «expected 5954 to be 24333» и «expected 5.458 to be less than 2»; снятие оговорки из вывода — «оговорка не выводится». 157 passed. --- .../__tests__/backtest-window-labels.test.tsx | 68 ++++++++++++++++++ .../mera-public/_components/v3/AccuracyV3.tsx | 5 ++ .../src/app/mera-public/landing-facts.ts | 72 +++++++++++-------- 3 files changed, 117 insertions(+), 28 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx new file mode 100644 index 00000000..27235f9f --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx @@ -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(); + 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(); + } + } + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx index 2be7c7d8..418fff0b 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx @@ -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 ?? []; diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index 9b0aef3a..d0688d7c 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -97,14 +97,20 @@ export const BACKTEST: Readonly 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 = 5_954; +export const BACKTEST_POPULATION = 24_333; /** * Доля выборки. ВЫВОДИТСЯ, а не вписывается: разъехаться с `sampleN` не может.