fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666) #2671

Merged
bot-backend merged 2 commits from fix/2666-discount-plausibility-gate into main 2026-08-05 19:20:09 +00:00
7 changed files with 300 additions and 17 deletions

View file

@ -1821,6 +1821,70 @@ 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.
#
# ПОТОЛОК ГЕЙТА (ревью #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
@router.get("/sales-vs-listings", response_model=SalesVsListingsResponse)
def get_sales_vs_listings(
@ -1961,6 +2025,46 @@ 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:
# Формулировка — ФАКТ про выборку, а не обещание надёжности выше
# порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше —
# пары псевдореплики), обещать «от 10 надёжно» мы не вправе.
median_discount_explanation = (
f"Медианный торг не показываем: пар «сделка ↔ объявление» всего "
f"{len(discounts)} — на такой выборке медиана гуляет на десятки "
f"процентных пунктов."
)
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("-", "")
# Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно
# следующим блоком это говорит street_only-дисклеймер (карточка) /
# хвост note (v2-mappers). Проверено скриншотом — две формулировки
# подряд читались как стена текста.
median_discount_explanation = (
f"Медианный торг не показываем: расчёт дал неправдоподобное значение "
f"({shown}%) — такого торга на рынке не бывает."
)
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 +2083,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",

View file

@ -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

View file

@ -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,11 @@ 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
# «всего 1 —» целиком: голое "1" было бы всегда истинно (подстрока "10").
assert "всего 1 —" in data["median_discount_explanation"]
assert len(data["pairs"]) == 1
pair = data["pairs"][0]
assert pair["deal_id"] == 1001
@ -251,8 +265,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 +280,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 +312,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 +435,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 +525,137 @@ 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
# Объяснение называет ФАКТИЧЕСКОЕ число пар — иначе оно бесполезно. И НЕ
# обещает надёжность выше порога: 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:
"""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 раза глубже худшего городского
askingsold бакета (студии, 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_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:
"""Гейт гасит ТОЛЬКО сводную медиану: 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"])

View file

@ -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: [
{

View file

@ -114,6 +114,11 @@ export function StreetDealsCard({ estimate }: Props) {
)}
</div>
)}
{/* #2666: медиана не прошла гейт правдоподобия показываем причину,
а не пустое место (тот же паттерн, что confidence_explanation). */}
{data.median_discount_explanation && (
<div className="linkage-hint">{data.median_discount_explanation}</div>
)}
{data.data_quality === "street_only" && (
<div className="linkage-hint">
Данные по улице, не по конкретному дому: привязать сделки ДКП к

View file

@ -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) : "—",

View file

@ -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";