From db47fa0ecdf13862032a5e863c3079b999218512 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Sat, 12 Sep 2026 15:44:24 +0500
Subject: [PATCH] =?UTF-8?q?fix(mera/=D0=BB=D0=B5=D0=BD=D0=B4=D0=B8=D0=BD?=
=?UTF-8?q?=D0=B3):=20=D1=83=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?=
=?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=20=D0=BF=D0=BE=D0=BB?=
=?UTF-8?q?=D0=BE=D1=81=D1=83=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F?=
=?UTF-8?q?=D0=B5=D1=82=20=D1=81=D0=B0=D0=BC=D0=BE=20=D1=81=D0=B5=D0=B1?=
=?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0?=
=?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=BC=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0?=
=?UTF-8?q?=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Дыра в выкате, найденная ревьюером до мержа. Подпись про полосу собиралась из
констант `BAND_MIN_PCT`/`BAND_MAX_PCT` в коде фронта, а строки витрины и
`rejection_rule` приезжают из БД, от ПОСЛЕДНЕГО прогона задачи
`landing_showcase_deals`. Задачи нет в расписании — её запускают руками.
Значит в окне «фронт выкачен, витрина не пересчитана» страница утверждала бы
«показаны сделки с расхождением от −5 % до +20 %», а под утверждением лежали
бы прежние двадцать строк: по замеру на проде 12 из 20 вне полосы, худшая
+75,7 %. Утверждение и его опровержение в одном экране — хуже, чем было до
правки.
Чинится конструкцией, а не запуском задачи: `allWithinBand(deals)` в
`deal-view.ts` спрашивает САМИ показанные строки теми же границами, что стоят
в тексте.
* Все показанные строки в полосе — печатаем прежнюю формулировку.
* Хоть одна вне — про полосу НЕ утверждаем. В таблице: «Полосу расхождения от
-5 % до +20 % эта подпись не обещает: среди показанных строк есть
расхождения вне неё, то есть витрину собрал прогон с другим правилом — тем,
что напечатано выше». Правило того прогона и так приезжает в
`rejection_rule` из ТОГО ЖЕ прогона, что и строки, поэтому подпись с ними
согласована по построению. В ленте остаётся только то, что посчитано по
строкам: медиана и худшая.
* Медиана по ВСЕЙ сверке (15,3 %, 325 сделок) печатается в обеих ветках — она
и удерживает страницу честной независимо от того, пересчитана витрина.
Тесты по значению в обе стороны: набор с одной строкой вне полосы (+75,71 % —
реальная строка прода) → утверждения про полосу нет; все в полосе → есть.
Фальсификация: `allWithinBand` обезврежен руками (всегда true) — краснеют оба
новых теста, и красный текст показывает ровно тот дефект:
«Это отобранная полоса расхождения от -5 % до +20 % … худшая 75,7 %».
Проверка возвращена.
Проверка заодно поймала мои же фикстуры ленты: −11,5 % ниже нижней границы
полосы (−5 %), то есть «маленькое отклонение» ещё не значит «в полосе».
Значения заменены на внутриполосные.
Co-Authored-By: Claude Opus 5
---
.../__tests__/landing-v3-render.test.tsx | 67 +++++++++++++++++--
.../mera-public/_components/v3/AccuracyV3.tsx | 28 +++++++-
.../_components/v3/DealsTickerV3.tsx | 23 ++++++-
.../mera-public/_components/v3/deal-view.ts | 17 +++++
4 files changed, 126 insertions(+), 9 deletions(-)
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 1a476d84..892374e7 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
@@ -132,6 +132,40 @@ describe("витрина лэндинга v3 без данных", () => {
expect(note.textContent).toContain(String(BACKTEST.priceError.sampleN));
});
+ /**
+ * УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
+ *
+ * Границы стоят в коде фронта, строки приходят из БД от последнего прогона
+ * задачи, а задача в расписании не стоит. Между выкатом фронта и пересчётом
+ * витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
+ * худшая +75,71 %. Утверждение «показана полоса −5…+20 %» и строка +75,7 %
+ * под ним — хуже, чем отсутствие утверждения.
+ *
+ * Здесь ровно этот набор: одна строка вне полосы (значение с прода) — и про
+ * полосу не утверждается ничего. Ломать так: сделать `allWithinBand`
+ * всегда-true — тест покраснеет ПО ЗНАЧЕНИЮ, найдя «отобранная полоса» над
+ * строкой, которая в неё не входит.
+ */
+ it("«Точность»: строка вне полосы снимает утверждение про полосу, медиана сверки остаётся", () => {
+ const outOfBand: ShowcaseResponse = {
+ ...SHOWCASE,
+ deals: [
+ SHOWCASE.deals[0],
+ { ...SHOWCASE.deals[0], err_pct: 75.71 },
+ ],
+ };
+ render();
+ const note = screen.getByText(/Разброс показанных строк/u);
+ expect(
+ note.textContent,
+ "подпись обещает полосу, а в таблице под ней строка вне полосы",
+ ).not.toContain("отобранная полоса расхождения");
+ expect(note.textContent).toMatch(/есть расхождения вне неё/u);
+ // Медиана по всей сверке остаётся в обеих ветках — она и удерживает
+ // страницу честной, независимо от того, пересчитана витрина или нет.
+ expect(note.textContent).toContain(BACKTEST.priceError.text);
+ });
+
it("«Точность»: пустой /stats снимает плитки и таблицу, а не обнуляет их", () => {
const { container } = render();
expect(screen.queryByText("расчётов сделано")).toBeNull();
@@ -202,24 +236,47 @@ describe("лента сделок: контекст разброса", () => {
predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)),
});
+ // Значения ВНУТРИ полосы: −11,5 %, стоявшее здесь раньше, ниже её нижней
+ // границы (−5 %), и лента о такой ленте полосу не утверждает — проверку
+ // ниже это роняло по делу. Полоса несимметрична, и «маленькое отклонение»
+ // ещё не значит «в полосе».
it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => {
- render();
+ render();
const note = screen.getByText(/Медиана показанных строк/u);
- expect(note.textContent).toContain("11,5 %");
+ expect(note.textContent).toContain("3,4 %");
expect(note.textContent).toContain("19,3 %");
});
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
- render();
+ render();
const note = screen.getByText(/Медиана показанных строк/u);
- expect(note.textContent).toContain("7,8 %");
+ expect(note.textContent).toContain("2,3 %");
expect(note.textContent).not.toContain("19,3 %");
});
it("подпись называет полосу — иначе разброс ленты читается как точность расчёта", () => {
- render();
+ render();
const note = screen.getByText(/Медиана показанных строк/u);
expect(note.textContent).toMatch(/от -5 % до \+20 %/u);
expect(note.textContent).toContain("не вся сверка");
});
+
+ /**
+ * Та же лента с ОДНОЙ строкой вне полосы (значение с прода) — про полосу не
+ * утверждается ничего, остаются только посчитанные по строкам числа. Это и
+ * есть окно «фронт выкачен, витрина не пересчитана»: лента прокручивает
+ * +75,7 % и не имеет права обещать над ним −5…+20 %.
+ *
+ * Ломать так: сделать `allWithinBand` всегда-true — тест покраснеет ПО
+ * ЗНАЧЕНИЮ на обещании полосы рядом с 75,7 %.
+ */
+ it("строка вне полосы снимает утверждение про полосу, числа остаются", () => {
+ render();
+ const note = screen.getByText(/Медиана расхождения показанных строк/u);
+ expect(
+ note.textContent,
+ "лента обещает полосу, а сама прокручивает строку вне неё",
+ ).not.toMatch(/от -5 % до \+20 %/u);
+ expect(note.textContent).toContain("75,7 %");
+ });
});
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 54db3f3f..9cf2a043 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
@@ -29,6 +29,15 @@
* строк по построению лучше работы расчёта, и без второго числа страница
* обещала бы точность, которой никто не мерил.
*
+ * УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
+ * приходят из БД от последнего прогона задачи, а задача в расписании не стоит
+ * — её запускают руками. В окне «фронт выкачен, витрина не пересчитана»
+ * страница утверждала бы полосу над строками прежнего правила, а под
+ * утверждением стояла бы строка +75,7 % (на проде сейчас 12 таких из 20).
+ * Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
+ * не соответствуют — про полосу не говорим, называем то, что есть, а правило
+ * прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
+ *
* ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
* коридоре» без ширины коридора (±37 %) и без разброса пересборок — это
* разные способы выглядеть точнее, чем есть. Поэтому ширина коридора стоит в
@@ -70,6 +79,7 @@ import styles from "../../landing-v3.module.css";
import {
absPct,
+ allWithinBand,
BAND_MAX_PCT,
BAND_MIN_PCT,
count,
@@ -267,6 +277,18 @@ export function AccuracyV3({
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
{spread && (
+ // УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
+ // фронта, а строки приходят из БД, из последнего прогона задачи;
+ // задача в расписании не стоит и запускается руками. Между
+ // выкатом и пересчётом страница утверждала бы полосу над
+ // строками, собранными до неё (на проде сейчас 12 из 20 вне
+ // полосы, худшая +75,7 %) — утверждение и его опровержение в
+ // одном экране. Поэтому про полосу говорим, только если ни одна
+ // показанная строка этому не противоречит, а иначе называем то,
+ // что есть: строки собраны прогоном с другим правилом, и это
+ // правило напечатано выше — оно приезжает из ТОГО ЖЕ прогона,
+ // что и строки, поэтому разойтись с ними не может.
+ //
// «В ПРЕДЕЛАХ 20 % — N ИЗ N» ОТСЮДА СНЯТО, И ЭТО НЕ СОКРАЩЕНИЕ.
// Полоса витрины — от −5 % до +20 %, значит |отклонение| ≤ 20 у
// КАЖДОЙ показанной строки по построению фильтра: счёт всегда
@@ -283,7 +305,11 @@ export function AccuracyV3({
// сторожит landing-v3-render («подпись витрины называет полосу и
// держит рядом медиану по всей сверке»).
- {`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)} по ${spread.n} строкам, худшая ${absPct(spread.worstAbsPct)}. Это отобранная полоса расхождения от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)}, а не вся сверка: промахи крупнее полосы в данных есть, здесь их не видно. Медиана по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок).`}
+ {`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)} по ${spread.n} строкам, худшая ${absPct(spread.worstAbsPct)}. ${
+ allWithinBand(deals)
+ ? `Это отобранная полоса расхождения от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)}, а не вся сверка: промахи крупнее полосы в данных есть, здесь их не видно.`
+ : `Полосу расхождения от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)} эта подпись не обещает: среди показанных строк есть расхождения вне неё, то есть витрину собрал прогон с другим правилом — тем, что напечатано выше.`
+ } Медиана по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок).`}
)}
{deals[0].note}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
index 2ee33a49..657dd8fb 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
@@ -16,8 +16,16 @@
* пишет только сделки с расхождением от −5 % до +20 % (`select_rows`/`BAND_*`
* в app/tasks/landing_showcase_deals.py), внутри полосы порядок задают полнота
* и свежесть. Лента берёт ТЕ ЖЕ строки, поэтому её содержимое отобрано ровно
- * так же — и «худшая» в подписи ниже теперь ограничена полосой сверху, а не
- * данными.
+ * так же.
+ *
+ * НО УТВЕРЖДАЕМ МЫ ЭТО НЕ НА ВЕРУ. Границы живут в коде фронта, а строки
+ * приезжают из БД от ПОСЛЕДНЕГО прогона задачи — и задача в расписании не
+ * стоит, её запускают руками. Значит между выкатом фронта и пересчётом
+ * витрины лента утверждала бы полосу над строками, собранными по прежнему
+ * правилу (на проде сейчас 12 из 20 таких, худшая +75,7 %), и тут же
+ * прокручивала бы опровержение мимо собственной подписи. Поэтому подпись
+ * спрашивает сами строки (`allWithinBand`) и говорит про полосу, только если
+ * ни одна из них этому не противоречит.
*
* ПОД лентой — подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же
* функция, что и под таблицей сверок): медиана модуля и худшая. До полосы она
@@ -44,6 +52,7 @@ import styles from "../../landing-v3.module.css";
import {
absPct,
+ allWithinBand,
BAND_MAX_PCT,
BAND_MIN_PCT,
dealTitle,
@@ -85,8 +94,16 @@ export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
{spread && (
+ // Про полосу говорим, только если ЛЕНТА ей соответствует. Границы
+ // стоят в коде, строки приходят из БД от последнего прогона задачи, а
+ // задача в расписании не стоит: между выкатом и пересчётом лента
+ // утверждала бы полосу над строками, собранными до неё, — и тут же
+ // прокручивала +75,7 % мимо этого утверждения. Не соответствует —
+ // печатаем только то, что посчитано по самим строкам.
- {`Показаны сделки с расхождением от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)} — отобранная полоса, не вся сверка. Медиана показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
+ {allWithinBand(deals)
+ ? `Показаны сделки с расхождением от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)} — отобранная полоса, не вся сверка. Медиана показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`
+ : `Медиана расхождения показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
)}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
index 61653657..5bd0b185 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
@@ -206,6 +206,23 @@ export const WITHIN_PCT = 20;
export const BAND_MIN_PCT = -5;
export const BAND_MAX_PCT = 20;
+/**
+ * Можно ли про ЭТИ строки утверждать, что они — полоса.
+ *
+ * Подпись про полосу собирается из констант, а строки приезжают из БД, из
+ * последнего прогона задачи. Прогон в расписании не стоит и запускается
+ * руками, поэтому между выкатом фронта и пересчётом витрины страница
+ * утверждала бы полосу над строками, собранными ДО неё: на проде 12 из 20
+ * лежащих сейчас строк вне полосы, включая +75,7 % — утверждение и его
+ * опровержение в одном экране.
+ *
+ * Поэтому утверждение самопроверяемое: печатается, только если ни одна
+ * показанная строка ему не противоречит. Проверка идёт по тем же границам,
+ * что стоят в тексте, — соврать, не покраснев, подпись не может.
+ */
+export const allWithinBand = (deals: readonly ShowcaseDeal[]): boolean =>
+ deals.every((d) => d.err_pct >= BAND_MIN_PCT && d.err_pct <= BAND_MAX_PCT);
+
export interface ShownSpread {
readonly n: number;
readonly medianAbsPct: number;