витрина: печатать разброс показанных строк рядом с медианой всей сверки
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m18s
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m18s
Посетитель видел 20 строк сверки и не мог понять, где они лежат относительно выборки: первой в таблице стоит сделка с расхождением +75,7 %, а медиана по всем 327 — 14,5 %. Порядок строк при этом верный и менять его нельзя — отбор идёт по полноте и свежести, а не по величине ошибки (отбор по ошибке был дефектом и уже убран). Поэтому не переупорядочиваем, а добавляем контекст: медиана модуля расхождения показанных строк, сколько из них в пределах порога и худшая — рядом с медианой всей сверки. Замер на проде 30.08.2026: показанные 11,5 % против 14,5 % по всей выборке, 12 из 20 в пределах 20 %, худшая 75,7 %. Считается на фронте из тех же объектов, которые рисует таблица, — второе место подсчёта рано или поздно отстало бы от строк на экране. Своей формулировки «чуть точнее» в подписи нет: она протухнет на первом же пересчёте витрины, а два числа рядом не протухают. Порог вынесен в WITHIN_PCT рядом с фильтром: гейт витринных чисел справедливо покраснел на вписанных руками «20 %» при <= 20 в коде.
This commit is contained in:
parent
10b90cb85b
commit
5f7ac0d50f
3 changed files with 126 additions and 1 deletions
|
|
@ -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 %");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)} %`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue