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
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:
commit
56e4de0b35
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