fix(mera/лендинг): утверждение про полосу проверяет само себя по показанным строкам
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 15s
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 1m48s
CI Trade-In / backend-tests (pull_request) Successful in 6m0s

Дыра в выкате, найденная ревьюером до мержа. Подпись про полосу собиралась из
констант `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 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-12 15:44:24 +05:00
parent 2467943200
commit db47fa0ecd
4 changed files with 126 additions and 9 deletions

View file

@ -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(<AccuracyV3 stats={STATS} showcase={outOfBand} />);
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(<AccuracyV3 stats={{}} showcase={null} />);
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(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(19.3)]} />);
render(<DealsTickerV3 deals={[tickerDeal(1.2), tickerDeal(-3.4), tickerDeal(19.3)]} />);
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(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />);
render(<DealsTickerV3 deals={[tickerDeal(1.2), tickerDeal(-3.4)]} />);
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(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />);
render(<DealsTickerV3 deals={[tickerDeal(1.2), tickerDeal(-3.4)]} />);
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(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(75.71)]} />);
const note = screen.getByText(/Медиана расхождения показанных строк/u);
expect(
note.textContent,
"лента обещает полосу, а сама прокручивает строку вне неё",
).not.toMatch(/от -5 % до \+20 %/u);
expect(note.textContent).toContain("75,7 %");
});
});

View file

@ -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({
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
</p>
{spread && (
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
// фронта, а строки приходят из БД, из последнего прогона задачи;
// задача в расписании не стоит и запускается руками. Между
// выкатом и пересчётом страница утверждала бы полосу над
// строками, собранными до неё (на проде сейчас 12 из 20 вне
// полосы, худшая +75,7 %) — утверждение и его опровержение в
// одном экране. Поэтому про полосу говорим, только если ни одна
// показанная строка этому не противоречит, а иначе называем то,
// что есть: строки собраны прогоном с другим правилом, и это
// правило напечатано выше — оно приезжает из ТОГО ЖЕ прогона,
// что и строки, поэтому разойтись с ними не может.
//
// «В ПРЕДЕЛАХ 20 % — N ИЗ N» ОТСЮДА СНЯТО, И ЭТО НЕ СОКРАЩЕНИЕ.
// Полоса витрины — от 5 % до +20 %, значит |отклонение| ≤ 20 у
// КАЖДОЙ показанной строки по построению фильтра: счёт всегда
@ -283,7 +305,11 @@ export function AccuracyV3({
// сторожит landing-v3-render («подпись витрины называет полосу и
// держит рядом медиану по всей сверке»).
<p className={styles.accFootnote}>
{`Разброс показанных строк: медианное расхождение ${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)} сделок).`}
</p>
)}
<p className={styles.accFootnote}>{deals[0].note}</p>

View file

@ -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[] }) {
</div>
</div>
{spread && (
// Про полосу говорим, только если ЛЕНТА ей соответствует. Границы
// стоят в коде, строки приходят из БД от последнего прогона задачи, а
// задача в расписании не стоит: между выкатом и пересчётом лента
// утверждала бы полосу над строками, собранными до неё, — и тут же
// прокручивала +75,7 % мимо этого утверждения. Не соответствует —
// печатаем только то, что посчитано по самим строкам.
<p className={styles.tickerNote}>
{`Показаны сделки с расхождением от ${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)}`}
</p>
)}
</div>

View file

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