fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666) #2671
7 changed files with 300 additions and 17 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 раза глубже худшего городского
|
||||
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_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"])
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
Данные по улице, не по конкретному дому: привязать сделки ДКП к
|
||||
|
|
|
|||
|
|
@ -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) : "—",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue