feat(mera/лендинг): витрина показывает полосу расхождения −5…+20 %; плитку «уверенность низкая» сменил замер 12.09 #3468

Merged
bot-backend merged 2 commits from feat/landing-showcase-band into main 2026-09-12 10:51:50 +00:00
4 changed files with 126 additions and 9 deletions
Showing only changes of commit db47fa0ecd - Show all commits

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;