Merge pull request 'витрина: разброс показанных строк рядом с медианой всей сверки' (#3269) from feat/showcase-spread-footnote into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m17s
Deploy Trade-In / test (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 41s
Deploy Trade-In / deploy (push) Successful in 2m8s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 14s

This commit is contained in:
bot-backend 2026-08-30 09:19:32 +00:00
commit 56e4de0b35
3 changed files with 126 additions and 1 deletions

View file

@ -0,0 +1,65 @@
/**
* Разброс показанных строк единственное место, где лэндинг сам считает
* статистику по данным, а не печатает замеренную величину. Проверяем ЗНАЧЕНИЕ,
* а не факт вызова: подпись под таблицей утверждает конкретные числа.
*/
import { describe, expect, it } from "vitest";
import type { ShowcaseDeal } from "../public-api";
import { absPct, shownSpread } from "../_components/v3/deal-view";
const deal = (err_pct: number): ShowcaseDeal =>
({
district: "Кировский",
rooms: 1,
area_m2: 40,
floor: 3,
total_floors: 9,
deal_quarter: "II квартал 2026",
predicted_rub: 5_000_000,
fact_rub: 5_000_000,
err_pct,
n_analogs: 8,
note: "",
street_name: null,
street_scheme: null,
lat: null,
lon: null,
}) as ShowcaseDeal;
describe("shownSpread", () => {
it("пустая витрина не даёт подписи — печатать нечего", () => {
expect(shownSpread([])).toBeNull();
});
it("считает по МОДУЛЮ: недооценка на 30 % так же далека от факта, как переоценка", () => {
// Модули: [12, 13, 30, 40] → медиана (13+30)/2 = 21.5.
// Без Math.abs набор сортировался бы как [-30, -12, 13, 40] и дал бы 0.5.
const s = shownSpread([deal(-30), deal(-12), deal(13), deal(40)]);
expect(s).not.toBeNull();
expect(s!.medianAbsPct).toBe(21.5);
expect(s!.worstAbsPct).toBe(40);
expect(s!.within).toBe(2);
});
it("нечётное число строк — медиана это средний элемент, а не среднее", () => {
// Среднее этого набора 34.3 — если бы считали его, тест бы покраснел.
const s = shownSpread([deal(1), deal(2), deal(100)]);
expect(s!.medianAbsPct).toBe(2);
expect(s!.n).toBe(3);
});
it("граница «в пределах 20 %» включающая — ровно 20 считается попаданием", () => {
expect(shownSpread([deal(20), deal(20.1)])!.within).toBe(1);
});
it("сортировка числовая, а не лексикографическая", () => {
// Строковая сортировка поставила бы «100» перед «9» и дала худшую 9.
expect(shownSpread([deal(9), deal(100), deal(11)])!.worstAbsPct).toBe(100);
});
it("модуль печатается без знака — рядом со знаковым errPct", () => {
expect(absPct(11.5)).toBe("11,5 %");
expect(absPct(75.71)).toBe("75,7 %");
});
});

View file

@ -52,7 +52,16 @@ import {
import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api";
import styles from "../../landing-v3.module.css";
import { count, dealMeta, dealTitle, errPct, rub } from "./deal-view";
import {
absPct,
count,
dealMeta,
dealTitle,
errPct,
rub,
shownSpread,
WITHIN_PCT,
} from "./deal-view";
interface Tile {
readonly key: string;
@ -112,6 +121,7 @@ export function AccuracyV3({
const deals = showcase?.deals ?? [];
const showcaseStats = showcase?.stats ?? null;
const spread = shownSpread(deals);
return (
<section
@ -206,6 +216,11 @@ export function AccuracyV3({
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} годных, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
</p>
{spread && (
<p className={styles.accFootnote}>
{`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)}, в пределах ${WITHIN_PCT} % — ${spread.within} из ${spread.n}, худшая ${absPct(spread.worstAbsPct)}. Медиана по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок). Строки отобраны по полноте и свежести, не по величине ошибки.`}
</p>
)}
<p className={styles.accFootnote}>{deals[0].note}</p>
</>
)}

View file

@ -129,3 +129,48 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
errPctText: errPct(deal.err_pct),
};
}
/**
* Разброс расхождений ПОКАЗАННЫХ строк.
*
* Считается из тех же объектов, которые рисует таблица, поэтому подпись под
* ней физически не может разойтись с её содержимым. Отдельного запроса или
* поля в API для этого нет намеренно: любое второе место, где эти числа
* считаются, рано или поздно отстанет от строк на экране.
*
* Строки витрины отобраны по ПОЛНОТЕ и СВЕЖЕСТИ, а не по величине ошибки
* (`_sort_key` в `app/tasks/landing_showcase_deals.py`), поэтому их разброс не
* обязан совпадать с разбросом всей сверки. Обе величины печатаются рядом, и
* вывод о том, повезло ли показанной двадцатке, читатель делает сам своей
* формулировки вроде «чуть точнее» здесь нет: она бы протухла на первом же
* пересчёте витрины, а числа рядом не протухают никогда.
*/
/**
* Порог «попадания» для подписи. Живёт РЯДОМ с фильтром, который его
* применяет, и подставляется в текст подстановкой: вписанные руками «20 %»
* в подписи и `<= 20` в коде две независимые величины, которые разъедутся
* при первой же правке порога. Гейт витринных чисел на такую пару и ловит.
*/
export const WITHIN_PCT = 20;
export interface ShownSpread {
readonly n: number;
readonly medianAbsPct: number;
readonly within: number;
readonly worstAbsPct: number;
}
export function shownSpread(deals: readonly ShowcaseDeal[]): ShownSpread | null {
if (deals.length === 0) return null;
const abs = deals.map((d) => Math.abs(d.err_pct)).sort((a, b) => a - b);
const mid = Math.floor(abs.length / 2);
return {
n: abs.length,
medianAbsPct: abs.length % 2 === 1 ? abs[mid] : (abs[mid - 1] + abs[mid]) / 2,
within: abs.filter((v) => v <= WITHIN_PCT).length,
worstAbsPct: abs[abs.length - 1],
};
}
/** Модуль расхождения — БЕЗ знака: «11,5 %» рядом со знаковым «+75,7 %». */
export const absPct = (value: number): string => `${AREA.format(value)} %`;