From 5f7ac0d50fe1dc16015d92347c465d82eff96891 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 13:54:22 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D0=B0:=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=B0=D1=82=D1=8C=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B1=D1=80=D0=BE=D1=81=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=20=D1=80=D1=8F=D0=B4=D0=BE=D0=BC=20=D1=81=20=D0=BC=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=D0=BD=D0=BE=D0=B9=20=D0=B2=D1=81=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Посетитель видел 20 строк сверки и не мог понять, где они лежат относительно выборки: первой в таблице стоит сделка с расхождением +75,7 %, а медиана по всем 327 — 14,5 %. Порядок строк при этом верный и менять его нельзя — отбор идёт по полноте и свежести, а не по величине ошибки (отбор по ошибке был дефектом и уже убран). Поэтому не переупорядочиваем, а добавляем контекст: медиана модуля расхождения показанных строк, сколько из них в пределах порога и худшая — рядом с медианой всей сверки. Замер на проде 30.08.2026: показанные 11,5 % против 14,5 % по всей выборке, 12 из 20 в пределах 20 %, худшая 75,7 %. Считается на фронте из тех же объектов, которые рисует таблица, — второе место подсчёта рано или поздно отстало бы от строк на экране. Своей формулировки «чуть точнее» в подписи нет: она протухнет на первом же пересчёте витрины, а два числа рядом не протухают. Порог вынесен в WITHIN_PCT рядом с фильтром: гейт витринных чисел справедливо покраснел на вписанных руками «20 %» при <= 20 в коде. --- .../__tests__/shown-spread.test.ts | 65 +++++++++++++++++++ .../mera-public/_components/v3/AccuracyV3.tsx | 17 ++++- .../mera-public/_components/v3/deal-view.ts | 45 +++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/shown-spread.test.ts diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/shown-spread.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/shown-spread.test.ts new file mode 100644 index 00000000..66f82333 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/shown-spread.test.ts @@ -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 %"); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx index a67a6e71..2be7c7d8 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx @@ -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 (
+ {spread && ( +

+ {`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)}, в пределах ${WITHIN_PCT} % — ${spread.within} из ${spread.n}, худшая ${absPct(spread.worstAbsPct)}. Медиана по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок). Строки отобраны по полноте и свежести, не по величине ошибки.`} +

+ )}

{deals[0].note}

)} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts index 13e9c86a..b8751743 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts @@ -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)} %`; -- 2.45.3