From b88535425e2a91f4e4cadff3fb58c41bf8909c6f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 23:49:03 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein):=20=D0=B3=D0=B5=D0=B9=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B4=D0=BE=D0=BF=D0=BE=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B8=D1=8F=20=D0=BD=D0=B0=20=C2=AB=D0=BC=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=82=D0=BE=D1=80=D0=B3?= =?UTF-8?q?=C2=BB=20=E2=80=94=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B0=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D1=84=D0=B0=D0=BA=D1=82=20=D0=BF=D0=B5=D0=B9=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B0=20=D0=BA=D0=B0=D0=BA=20=D1=80=D1=8B=D0=BD=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0=D0=BA=D1=82=20(#2666)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /sales-vs-listings отдавал median_discount_pct без всякой проверки: после сегментного гарда #2660 по `%Космонавтов%` 2-комн. значение уехало с −11.9% на +36.4%, то есть пользователю написали бы «продали на 36% дороже, чем просили». Корень унаследованный — пейринг ДКП↔объявление идёт по улице без номера дома (ADR #721), так что на длинной улице в пару попадают квартиры разных ценовых классов. Пейринг здесь не чиним, перестаём показывать число, которому нельзя верить. Пороги подобраны по проду (симуляция эндпоинта на 238 реальных пользовательских запросах из trade_in_estimates, 128 дали хотя бы одну пару): - MIN_PAIRS = 10 — бутстрап по 12 плотным группам: p90 отклонения медианы подвыборки от полной 18.8 п.п. при k=5, 12.0 при k=10, 9.9 при k=15. Кривая ломается на 10; совпадает с уже принятым в продукте sell_time_sensitivity_min_n_lots. - Санитарный диапазон [−60%, +20%] — асимметричный. Сверху распределение разорвано (…+16.9, пусто, +33.7…+103.1), отсечка попадает в разрыв; ни один городской бакет asking_to_sold_ratios не даёт плюса вообще (max 0.9132). Снизу разрыва нет (у большого минуса есть механизм — занижение цены в ДКП), граница грубая «заведомо не рынок»: 2.5× худшего бакета (студии, −23.8%). Форма отказа — не пустота: новое поле median_discount_explanation по образцу confidence_explanation оценщика, фронт рендерит его вместо числа. Гаснет ровно строка «медианный торг»: сделки, медиана ₽/м², диапазон, linkage_rate_pct и per-pair discount_pct не трогаются. --- tradein-mvp/backend/app/api/v1/trade_in.py | 79 ++++++++++ tradein-mvp/backend/app/schemas/trade_in.py | 7 + .../backend/tests/test_sales_vs_listings.py | 142 ++++++++++++++++-- .../src/app/ui-preview/estimate/fixture.ts | 1 + .../components/trade-in/StreetDealsCard.tsx | 5 + .../src/components/trade-in/v2/mappers.ts | 14 +- tradein-mvp/frontend/src/types/trade-in.ts | 4 + 7 files changed, 235 insertions(+), 17 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 12e25b24..bd446ec4 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1821,6 +1821,50 @@ def get_street_deals( # ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ────────────── +# #2666 гейт правдоподобия на «медианный торг». Пейринг ДКП↔объявление идёт по +# УЛИЦЕ без номера дома (data_quality="street_only", ADR #721): на длинной улице +# сделка и объявление могут стоять в разных домах и разных ценовых классах, и +# тогда discount_pct — не торг, а разница между двумя чужими друг другу лотами. +# Гард #2660 (миграция 211) убрал предвзятые пары «вторичка ↔ новостройка» и тем +# самым сделал остаток артефактов ВИДНЫМ: по `%Космонавтов%` 2-комн. медиана +# уехала с −11.9% на +36.4%, т.е. пользователю написали бы «продали на 36% +# дороже, чем просили». Здесь не чиним пейринг (это ADR-уровень), а перестаём +# показывать число, которому нельзя верить. +# +# Пороги подобраны по проду 2026-08-05 (симуляция эндпоинта на 238 РЕАЛЬНЫХ +# пользовательских запросах из trade_in_estimates — тот же address/area/rooms, +# что уходил в виджет; 128 из них дали хотя бы одну пару): +# +# MIN_PAIRS = 10 — бутстрап по 12 «плотным» группам (n ≥ 60 пар): из полной +# выборки берём подвыборку размера k и смотрим, насколько медиана подвыборки +# отклоняется от полной. p90 |отклонения|: k=5 → 18.8 п.п., k=10 → 12.0, +# k=15 → 9.9, k=20 → 8.2. Кривая ломается ровно на 10 (5→10 даёт −6.8 п.п. +# шума, 10→15 уже только −2.1, а каждые +5 к порогу стоят ещё ~8-10% улиц). +# Совпадает с уже принятым в продукте порогом малой выборки +# settings.sell_time_sensitivity_min_n_lots = 10. +# +# SANE_MIN/MAX = [−60%, +20%] — асимметричны намеренно, у сторон разная природа: +# ВЕРХ. В наблюдаемом распределении 128 групп положительный хвост РАЗОРВАН: +# +11.1, +10.8, +16.9 — и дальше пусто до +33.7, +34.2, +34.6, +39.0, +52.5, +# +70.2, +81.5, +103.1. Отсечка +20% попадает в пустой промежуток, т.е. режет +# отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один +# городской бакет asking_to_sold_ratios не даёт плюса вообще (max ratio 0.9132 +# = −8.7% торга), так что «продали на +20% дороже ask» уже вдвое дальше любого +# рыночно объяснимого плюса. +# НИЗ. Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо: +# у большого отрицательного торга есть механизм (занижение цены в ДКП), в +# отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»: +# худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза +# глубже. Режет 6 групп из 128 (−87 … −64). +# +# Цена гейта на проде: из 128 групп с парами число сохраняют 64 (50%), 59 (46%) +# теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся: +# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно +# строка «медианный торг», и вместо неё уходит median_discount_explanation. +SALES_VS_LISTINGS_MIN_PAIRS = 10 +SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -60.0 +SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0 + @router.get("/sales-vs-listings", response_model=SalesVsListingsResponse) def get_sales_vs_listings( @@ -1961,6 +2005,40 @@ def get_sales_vs_listings( discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None) median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None + # #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо + # отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь + # прочитает как поломку, а не как честность. + median_discount_explanation: str | None = None + if median_discount is not None: + if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS: + median_discount_explanation = ( + f"Медианный торг не показываем: пар «сделка ↔ объявление» всего " + f"{len(discounts)}, надёжная медиана начинается от " + f"{SALES_VS_LISTINGS_MIN_PAIRS}." + ) + elif not ( + SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT + <= median_discount + <= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT + ): + # Типографский минус (U+2212) — как в fmtDiscount на фронте. + shown = f"{median_discount:+.1f}".replace("-", "−") + median_discount_explanation = ( + f"Медианный торг не показываем: расчёт дал неправдоподобное значение " + f"({shown}%) — на протяжённой улице в пару могли попасть квартиры " + f"разных ценовых классов." + ) + if median_discount_explanation is not None: + logger.info( + "sales-vs-listings: median_discount gated street=%r rooms=%d " + "n_pairs=%d value=%+.2f%%", + street_name, + rooms, + len(discounts), + median_discount, + ) + median_discount = None + logger.info( "sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s", street_name, @@ -1979,6 +2057,7 @@ def get_sales_vs_listings( deals_with_listings=deals_with_listings, linkage_rate_pct=linkage_rate_pct, median_discount_pct=median_discount, + median_discount_explanation=median_discount_explanation, # street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) → # даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им. data_quality="street_only" if total_deals > 0 else "no_data", diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 3df09fe1..812b2a82 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -604,6 +604,13 @@ class SalesVsListingsResponse(BaseModel): deals_with_listings: int # сколько имеют связанный listing linkage_rate_pct: float # deals_with_listings / total_deals * 100 median_discount_pct: float | None # медиана по парам с listing + # #2666: None вместе с median_discount_pct=None означает «медианы просто нет» + # (пар не нашлось). Непустая строка = медиана посчиталась, но не прошла гейт + # правдоподобия (мало пар / значение вне санитарного диапазона — см. пороги + # SALES_VS_LISTINGS_* в api/v1/trade_in.py) и намеренно не показывается. + # Форма отказа зеркалит confidence_explanation оценщика: пользователю нужен + # текст «почему числа нет», иначе пустое место читается как поломка виджета. + median_discount_explanation: str | None = None data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3) pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 184c785d..40f5d4c5 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -8,6 +8,8 @@ Covers: - linkage_rate_pct computation. - median_discount_pct on subset с listing_id != None. - extract_street_name failure → returns empty response with street=None. + - #2666 гейт правдоподобия median_discount_pct: мало пар / значение вне + санитарного диапазона → числа нет, но есть median_discount_explanation. """ import os @@ -101,6 +103,14 @@ def _make_pair_row( } +def _rows_with_discounts(discounts: list[float]) -> list[dict]: + """N пар с заданными discount_pct (deal_id/listing_id уникальны).""" + return [ + _make_pair_row(deal_id=1000 + i, listing_id=2000 + i, discount_pct=d) + for i, d in enumerate(discounts) + ] + + def _override_db(trade_in_app: FastAPI, db_mock: MagicMock) -> None: from app.core.db import get_db @@ -198,7 +208,10 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: assert data["total_deals"] == 1 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 100.0 - assert data["median_discount_pct"] == -5.77 + # #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а + # вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия. + assert data["median_discount_pct"] is None + assert "1" in data["median_discount_explanation"] assert len(data["pairs"]) == 1 pair = data["pairs"][0] assert pair["deal_id"] == 1001 @@ -251,8 +264,9 @@ def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None: assert data["total_deals"] == 2 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 50.0 - # median считается только по парам с discount_pct - assert data["median_discount_pct"] == -5.0 + # median считается только по парам с discount_pct — но одной пары мало, + # #2666 гейт её гасит (сам LEFT JOIN это не ломает). + assert data["median_discount_pct"] is None # Pair without listing pair_no_listing = next(p for p in data["pairs"] if p["deal_id"] == 1002) assert pair_no_listing["listing_id"] is None @@ -265,14 +279,16 @@ def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None: def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None: - """Median считается через _percentile(0.5) только по парам c discount_pct.""" - # Discounts: [-10, -5, 0, 3, 7] → median = 0 + """Median считается через _percentile(0.5) только по парам c discount_pct. + + 11 пар (≥ MIN_PAIRS #2666) с рыночной медианой — число доходит до ответа, + объяснения нет. + """ + # Discounts: 11 значений, средний (индекс 5) = -17.0 → median = -17.0 fixture_rows = [ - _make_pair_row(deal_id=1, listing_id=11, discount_pct=-10.0), - _make_pair_row(deal_id=2, listing_id=12, discount_pct=-5.0), - _make_pair_row(deal_id=3, listing_id=13, discount_pct=0.0), - _make_pair_row(deal_id=4, listing_id=14, discount_pct=3.0), - _make_pair_row(deal_id=5, listing_id=15, discount_pct=7.0), + *_rows_with_discounts( + [-25.0, -23.0, -21.0, -20.0, -19.0, -17.0, -16.0, -15.0, -13.0, -11.0, -9.0] + ), # Сделка без listing — не учитывается в median. _make_pair_row( deal_id=6, @@ -295,10 +311,11 @@ def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None: ) assert resp.status_code == 200 data = resp.json() - assert data["total_deals"] == 6 - assert data["deals_with_listings"] == 5 - assert round(data["linkage_rate_pct"], 1) == 83.3 - assert data["median_discount_pct"] == 0.0 + assert data["total_deals"] == 12 + assert data["deals_with_listings"] == 11 + assert round(data["linkage_rate_pct"], 1) == 91.7 + assert data["median_discount_pct"] == -17.0 + assert data["median_discount_explanation"] is None # ── Test: SQL function called with proper params ───────────────────────────── @@ -417,6 +434,7 @@ def test_sales_vs_listings_response_shape(trade_in_app: FastAPI) -> None: "deals_with_listings", "linkage_rate_pct", "median_discount_pct", + "median_discount_explanation", "pairs", } assert expected_keys.issubset(data.keys()) @@ -506,3 +524,99 @@ def test_sales_vs_listings_defaults(trade_in_app: FastAPI) -> None: assert data["window_days"] == 180 assert data["area_tolerance"] == 0.15 assert data["period_months"] == 24 + + +# ── Test: #2666 гейт правдоподобия median_discount_pct ─────────────────────── + + +def _get_sales(trade_in_app: FastAPI, rows: list[dict]) -> dict: + """GET /sales-vs-listings на фиксированном адресе, вернуть JSON.""" + _override_db(trade_in_app, _make_db_mock(rows)) + resp = TestClient(trade_in_app).get( + "/api/v1/trade-in/sales-vs-listings", + params={ + "address": "г. Екатеринбург, ул. Космонавтов, 50", + "area_m2": 50.0, + "rooms": 2, + }, + ) + assert resp.status_code == 200 + return resp.json() + + +def test_median_discount_gated_when_too_few_pairs(trade_in_app: FastAPI) -> None: + """#2666: пар меньше MIN_PAIRS → числа нет, но есть объяснение почему. + + Прод-бутстрап (2026-08-05): на 9 парах p90 отклонения медианы подвыборки от + полной ≈ 12-19 п.п. — такое число нельзя показывать как «медианный торг». + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 9)) + assert data["deals_with_listings"] == 9 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + # Объяснение называет ФАКТИЧЕСКОЕ число пар и порог — иначе оно бесполезно. + assert "9" in data["median_discount_explanation"] + assert "10" in data["median_discount_explanation"] + + +def test_median_discount_kept_at_min_pairs_boundary(trade_in_app: FastAPI) -> None: + """MIN_PAIRS включительно: ровно 10 пар — число ещё отдаётся.""" + data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 10)) + assert data["median_discount_pct"] == -12.0 + assert data["median_discount_explanation"] is None + + +def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI) -> None: + """#2666: «продали на 36% дороже, чем просили» — артефакт пейринга по улице. + + Ровно кейс из issue (`%Космонавтов%` 2-комн., +36.4% после гарда #2660). + Пар достаточно, гасит именно санитарный диапазон. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11)) + assert data["deals_with_listings"] == 11 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + assert "36" in data["median_discount_explanation"] + + +def test_median_discount_gated_when_implausibly_negative(trade_in_app: FastAPI) -> None: + """Нижняя граница диапазона: −70% в 3 раза глубже худшего городского + asking→sold бакета (студии, ratio 0.7623 = −23.8%) — тоже не рынок.""" + data = _get_sales(trade_in_app, _rows_with_discounts([-70.0] * 11)) + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + + +def test_median_discount_kept_at_sane_range_boundaries(trade_in_app: FastAPI) -> None: + """Границы санитарного диапазона включительные: +20.0% и −60.0% проходят.""" + top = _get_sales(trade_in_app, _rows_with_discounts([20.0] * 11)) + assert top["median_discount_pct"] == 20.0 + assert top["median_discount_explanation"] is None + + bottom = _get_sales(trade_in_app, _rows_with_discounts([-60.0] * 11)) + assert bottom["median_discount_pct"] == -60.0 + assert bottom["median_discount_explanation"] is None + + +def test_median_discount_normal_case_unchanged(trade_in_app: FastAPI) -> None: + """Нормальный случай (пар хватает, значение рыночное) — число как прежде.""" + data = _get_sales( + trade_in_app, + _rows_with_discounts( + [-25.0, -23.0, -21.0, -20.0, -19.0, -17.0, -16.0, -15.0, -13.0, -11.0, -9.0] + ), + ) + assert data["median_discount_pct"] == -17.0 + assert data["median_discount_explanation"] is None + + +def test_median_discount_gate_leaves_pairs_and_linkage_untouched( + trade_in_app: FastAPI, +) -> None: + """Гейт гасит ТОЛЬКО сводную медиану: linkage_rate_pct и per-pair discount_pct + остаются — это наблюдаемые факты, а не оценка по улице.""" + data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11)) + assert data["median_discount_pct"] is None + assert data["linkage_rate_pct"] == 100.0 + assert len(data["pairs"]) == 11 + assert all(p["discount_pct"] == 36.4 for p in data["pairs"]) diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts index eadedb6a..e441325a 100644 --- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts @@ -380,6 +380,7 @@ export const FIXTURE_SALES: SalesVsListingsResponse = { deals_with_listings: 5, linkage_rate_pct: 55.6, median_discount_pct: -6.2, + median_discount_explanation: null, data_quality: "house_linked", pairs: [ { diff --git a/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx index 69091f28..7a866783 100644 --- a/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx @@ -114,6 +114,11 @@ export function StreetDealsCard({ estimate }: Props) { )} )} + {/* #2666: медиана не прошла гейт правдоподобия — показываем причину, + а не пустое место (тот же паттерн, что confidence_explanation). */} + {data.median_discount_explanation && ( +
{data.median_discount_explanation}
+ )} {data.data_quality === "street_only" && (
Данные по улице, не по конкретному дому: привязать сделки ДКП к diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 79fd9f1e..81e9ff94 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -1524,9 +1524,17 @@ export function mapHistory( `медианный торг ${pct1(salesVsListings.median_discount_pct)}`, ); } - const note = - (noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") + - "Данные по улице, не по дому."; + // #2666: медиана не прошла гейт правдоподобия — отдельным предложением + // объясняем, почему числа нет (пустое место читается как поломка виджета). + const note = [ + noteParts.length > 0 ? `${noteParts.join(" · ")}.` : null, + salesVsListings?.median_discount_pct == null + ? salesVsListings?.median_discount_explanation + : null, + "Данные по улице, не по дому.", + ] + .filter(Boolean) + .join(" "); const dkpKpi = { count: streetDeals?.count != null ? String(streetDeals.count) : "—", diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index ed5c75f4..7db71758 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -405,6 +405,10 @@ export interface SalesVsListingsResponse { deals_with_listings: number; linkage_rate_pct: number; median_discount_pct: number | null; + // #2666: непустая строка = медиана посчиталась, но не прошла гейт правдоподобия + // (мало пар / значение вне санитарного диапазона) и намеренно не показывается. + // Рендерим ВМЕСТО числа — пустое место читается как поломка, а не как честность. + median_discount_explanation: string | null; // Качество данных: house_linked = есть пары ДКП↔listing; street_only = есть // сделки, но привязка к конкретному дому/объявлению невозможна; no_data = нет сделок. data_quality: "house_linked" | "street_only" | "no_data"; -- 2.45.3 From 77ae08f2074b151d681ef87edd27382672232f3f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 00:16:13 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein):=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20=D0=B3=D0=B5=D0=B9=D1=82=D0=B0=20=E2=80=94=20=D1=84?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=20=D0=BF=D1=80=D0=BE=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D1=83=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D0=BE=D0=B1=D0=B5=D1=89=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B4=D1=91=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20(#2666?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правки по ревью PR #2671. Текст «надёжная медиана начинается от 10» обещал то, чего мы гарантировать не можем: пары — псевдореплики (одно объявление переиспользуется на многих сделках, на живом кейсе Космонавтов 2-комн. 42 пары стоят на 2 различных объявлениях), и 10 пар надёжности не дают. Теперь отказ сообщает факт: сколько пар есть и что на такой выборке медиана гуляет на десятки п.п. Формулировка диапазонной ветки укорочена: она дублировала street_only- дисклеймер, который идёт следующим блоком. Проверено скриншотом отрендеренной карточки — две формулировки подряд читались как стена текста; теперь три однострочных хинта, на 820px — по две строки, переполнения нет. В шапку секции добавлен потолок гейта, найденный ревью: бутстрап пересэмплировал ПАРЫ, т.е. мерил дисперсию со стороны сделок, а доминирует дисперсия со стороны ОБЪЯВЛЕНИЙ (джекнайф p90 17.3 п.п., max 63.8); 22 из 64 переживших групп стоят на одном объявлении. Плюс нижняя граница оказалась слишком мягкой, а не строгой: 26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%. Оба пункта — отдельная задача, здесь только зафиксированы, чтобы порог не перечитали как гарантию. Тесты: пустое утверждение "1" in explanation (всегда истинно из-за "10") заменено на «всего 1 —». Добавлены два недостающих — отсутствие пар со скидкой даёт explanation=None, и порядок проверок (3 пары по +80% отчитываются «мало пар», а не «вне диапазона»). --- tradein-mvp/backend/app/api/v1/trade_in.py | 34 +++++++++++-- .../backend/tests/test_sales_vs_listings.py | 49 +++++++++++++++++-- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index bd446ec4..80a7cc54 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1861,6 +1861,26 @@ def get_street_deals( # теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся: # сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно # строка «медианный торг», и вместо неё уходит median_discount_explanation. +# +# ПОТОЛОК ГЕЙТА (ревью #2671, знать до следующей правки — здесь НЕ чинится): +# 1. Пары — псевдореплики. DISTINCT ON берёт по объявлению на сделку, но ОДНО +# объявление переиспользуется на многих сделках: медиана по группам — 18 +# сделок на одно различное объявление, а на живом кейсе из issue +# (Космонавтов 2-комн., 50 м²) 42 пары стоят на 2 РАЗЛИЧНЫХ объявлениях. +# Бутстрап выше пересэмплировал ПАРЫ, т.е. мерил дисперсию со стороны +# сделок; доминирует дисперсия со стороны ОБЪЯВЛЕНИЙ — джекнайф по +# объявлениям даёт p90 17.3 п.п. и max 63.8 п.п., и MIN_PAIRS против неё +# бессилен. Из 64 переживших групп 22 (34%) стоят на ОДНОМ объявлении. +# Настоящий рычаг — считать различные объявления (при «пар ≥ 10 И +# объявлений ≥ 2» проходят 42 из 128); заведено отдельной задачей. +# Поэтому MIN_PAIRS — пол, а не гарантия: снижать бессмысленно, повышать +# тоже (вернувшиеся/оставшиеся группы всё равно на одном-двух объявлениях). +# 2. Нижняя граница слишком МЯГКАЯ, а не слишком строгая, как думалось при +# её выборе: из 64 показываемых чисел 26 (41%) лежат ниже −23.8% (худший +# объяснимый рынком бакет), самое глубокое показываемое — −58.5%. Мы гасим +# «+34%» и показываем «−58.5%» из того же артефакта; асимметрия работает +# против нас — абсурдный плюс сам себя опровергает, абсурдный минус +# выглядит правдоподобно. Ужесточение — та же отдельная задача. SALES_VS_LISTINGS_MIN_PAIRS = 10 SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -60.0 SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0 @@ -2011,10 +2031,13 @@ def get_sales_vs_listings( median_discount_explanation: str | None = None if median_discount is not None: if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS: + # Формулировка — ФАКТ про выборку, а не обещание надёжности выше + # порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше — + # пары псевдореплики), обещать «от 10 надёжно» мы не вправе. median_discount_explanation = ( f"Медианный торг не показываем: пар «сделка ↔ объявление» всего " - f"{len(discounts)}, надёжная медиана начинается от " - f"{SALES_VS_LISTINGS_MIN_PAIRS}." + f"{len(discounts)} — на такой выборке медиана гуляет на десятки " + f"процентных пунктов." ) elif not ( SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT @@ -2023,10 +2046,13 @@ def get_sales_vs_listings( ): # Типографский минус (U+2212) — как в fmtDiscount на фронте. shown = f"{median_discount:+.1f}".replace("-", "−") + # Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно + # следующим блоком это говорит street_only-дисклеймер (карточка) / + # хвост note (v2-mappers). Проверено скриншотом — две формулировки + # подряд читались как стена текста. median_discount_explanation = ( f"Медианный торг не показываем: расчёт дал неправдоподобное значение " - f"({shown}%) — на протяжённой улице в пару могли попасть квартиры " - f"разных ценовых классов." + f"({shown}%) — такого торга на рынке не бывает." ) if median_discount_explanation is not None: logger.info( diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 40f5d4c5..940b96ab 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -211,7 +211,8 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: # #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а # вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия. assert data["median_discount_pct"] is None - assert "1" in data["median_discount_explanation"] + # «всего 1 —» целиком: голое "1" было бы всегда истинно (подстрока "10"). + assert "всего 1 —" in data["median_discount_explanation"] assert len(data["pairs"]) == 1 pair = data["pairs"][0] assert pair["deal_id"] == 1001 @@ -553,10 +554,10 @@ def test_median_discount_gated_when_too_few_pairs(trade_in_app: FastAPI) -> None data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 9)) assert data["deals_with_listings"] == 9 assert data["median_discount_pct"] is None - assert data["median_discount_explanation"] - # Объяснение называет ФАКТИЧЕСКОЕ число пар и порог — иначе оно бесполезно. - assert "9" in data["median_discount_explanation"] - assert "10" in data["median_discount_explanation"] + # Объяснение называет ФАКТИЧЕСКОЕ число пар — иначе оно бесполезно. И НЕ + # обещает надёжность выше порога: 10 пар тоже не гарантия (ревью #2671). + assert "всего 9 —" in data["median_discount_explanation"] + assert "надёжн" not in data["median_discount_explanation"] def test_median_discount_kept_at_min_pairs_boundary(trade_in_app: FastAPI) -> None: @@ -610,6 +611,44 @@ def test_median_discount_normal_case_unchanged(trade_in_app: FastAPI) -> None: assert data["median_discount_explanation"] is None +def test_median_discount_explanation_absent_when_no_pairs_at_all( + trade_in_app: FastAPI, +) -> None: + """Сделки есть, но ни одной пары → медианы просто НЕТ, объяснять нечего. + + Отличать «не посчиталось» от «посчиталось и погашено гейтом» обязан фронт: + он рендерит объяснение вместо числа, и текст «медиана гуляет» на улице без + единого объявления был бы враньём. + """ + rows = [ + _make_pair_row( + deal_id=1000 + i, + listing_id=None, + listing_price_rub=None, + discount_pct=None, + ) + for i in range(12) + ] + data = _get_sales(trade_in_app, rows) + assert data["total_deals"] == 12 + assert data["deals_with_listings"] == 0 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] is None + + +def test_too_few_pairs_reported_before_out_of_range(trade_in_app: FastAPI) -> None: + """Порядок проверок: 3 пары по +80% — причина «мало пар», НЕ «вне диапазона». + + Обе проверки сработали бы, но «пар всего 3» информативнее и точнее: при + такой выборке значение вообще не заслуживает разбора на правдоподобность. + Тест закрепляет порядок — перестановка условий деградирует объяснение. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([80.0] * 3)) + assert data["median_discount_pct"] is None + assert "всего 3 —" in data["median_discount_explanation"] + assert "неправдоподобное" not in data["median_discount_explanation"] + + def test_median_discount_gate_leaves_pairs_and_linkage_untouched( trade_in_app: FastAPI, ) -> None: -- 2.45.3