fix(tradein): «медианный торг» гаснет на псевдорепликах и на неправдоподобном минусе (#2672) #2706
2 changed files with 164 additions and 26 deletions
|
|
@ -1862,27 +1862,65 @@ def get_street_deals(
|
|||
# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно
|
||||
# строка «медианный торг», и вместо неё уходит 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%» из того же артефакта; асимметрия работает
|
||||
# против нас — абсурдный плюс сам себя опровергает, абсурдный минус
|
||||
# выглядит правдоподобно. Ужесточение — та же отдельная задача.
|
||||
# MIN_DISTINCT_LISTINGS = 2 (#2672) — ПАРЫ НЕ ЯВЛЯЮТСЯ НЕЗАВИСИМЫМИ НАБЛЮДЕНИЯМИ,
|
||||
# и MIN_PAIRS этого не видит. DISTINCT ON подбирает по объявлению на сделку, но
|
||||
# ОДНО объявление переиспользуется на многих сделках улицы: у показываемых групп
|
||||
# медиана — 18 сделок на одно различное объявление. До этого порога из 64
|
||||
# показываемых чисел 22 (34%) стояли на ОДНОМ объявлении (худший живой кейс —
|
||||
# `Белинского` 1-комн.: 50 пар, 1 объявление, −50.6%), 50 (78%) — меньше чем на
|
||||
# трёх. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на
|
||||
# ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира
|
||||
# отличалась от типичной сделки, а не о торге на улице.
|
||||
#
|
||||
# Почему именно 2, и почему порог здесь обоснован ИНАЧЕ, чем MIN_PAIRS. Разброс
|
||||
# со стороны объявлений мерили джекнайфом (выкинуть одно объявление, 45 групп,
|
||||
# 118 повторов): p50 3.6, p90 18.8, max 80.4 п.п. — тот же порядок, что и шум
|
||||
# при 5 парах, который при выборе MIN_PAIRS сочли неприемлемым. Но на группах с
|
||||
# ОДНИМ объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще:
|
||||
# выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0.
|
||||
# Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по
|
||||
# всем 64 группам от их добавления УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5),
|
||||
# т.е. метрика становилась тем зеленее, чем больше в ней неизмеримого. Поэтому
|
||||
# 2 — не статистический выбор, а граница выразимости: ниже неё нет выборки, о
|
||||
# разбросе которой можно спрашивать, и показывать число = фабриковать точность.
|
||||
# Выше 2 порог уже статистический, и данные (прод 2026-08-06, те же 128 групп)
|
||||
# говорят, что он должен быть выше — но ценой почти всей витрины:
|
||||
# объявлений ≥ 2 → 42 группы (33%), джекнайф p90 17.4;
|
||||
# объявлений ≥ 3 → 14 групп (11%), p90 10.9 (планка MIN_PAIRS — 12.0);
|
||||
# объявлений ≥ 4 → 7 групп ( 5%), p90 5.5.
|
||||
# Порог 3 попадал бы в принятую планку шума, но оставляет 11% витрины и всё
|
||||
# равно не делает число защищаемым (ошибка со стороны СДЕЛОК никуда не делась и
|
||||
# складывается с ней). Выбирать между «9% покрытия» и «выключить строку» —
|
||||
# решение владельца, не гейта; здесь снимается ровно то, что не является
|
||||
# наблюдением рынка в принципе. Понижать MIN_PAIRS в компенсацию нельзя:
|
||||
# вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность).
|
||||
#
|
||||
# SANE_MIN ужесточён −60% → −35% (#2672). Исходное подозрение «−60% режет живой
|
||||
# рынок» проверено и ОПРОВЕРГНУТО: до −60% проходило всё, законный механизм
|
||||
# большого минуса (занижение цены в ДКП) сохранён целиком. Ошибка была в другую
|
||||
# сторону — граница пропускала неправдоподобный отрицательный хвост: 26 из 64
|
||||
# показываемых чисел (41%) лежали ниже −23.7%, худшего объяснимого рынком
|
||||
# бакета (asking_to_sold_ratios: студии, ratio 0.7634, 1 519 сделок; ни один
|
||||
# бакет не глубже), самое глубокое показываемое — −58.5%. Мы гасили «+34%» и
|
||||
# показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга. Асимметрия
|
||||
# работала против пользователя: абсурдный плюс сам себя опровергает («продали
|
||||
# дороже, чем просили» — виджету просто не поверят), абсурдный минус выглядит
|
||||
# правдоподобно и подталкивает продавца к выводу, что его улица торгуется за
|
||||
# полцены. −35% ≈ в 1.5 раза глубже худшего рыночного бакета (запас на занижение
|
||||
# в ДКП сохранён) и попадает в разрыв наблюдаемого распределения −37.6 → −33.9.
|
||||
# Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → −46.5% показывался.
|
||||
#
|
||||
# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится):
|
||||
# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721).
|
||||
# Гейт по различным объявлениям честный промежуточный шаг, а не решение:
|
||||
# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё
|
||||
# сравнивают сделку в одном доме с объявлением в другом.
|
||||
# 2. Поштучный discount_pct в таблице пар НЕ гасится, когда сводное число
|
||||
# погашено (#2672 п.3, фронт): под погашенной медианой видны строки +76%,
|
||||
# +73% против той же одной цены предложения. Отдельная задача.
|
||||
SALES_VS_LISTINGS_MIN_PAIRS = 10
|
||||
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -60.0
|
||||
SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2
|
||||
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -35.0
|
||||
SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0
|
||||
|
||||
|
||||
|
|
@ -2024,6 +2062,12 @@ 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
|
||||
# #2672: сколько РАЗЛИЧНЫХ объявлений стоит за этими парами. len(discounts)
|
||||
# считает сделки, а не наблюдения рынка — одно объявление попадает в пару
|
||||
# к десяткам сделок улицы (см. шапку секции).
|
||||
n_distinct_listings = len(
|
||||
{p.listing_id for p in pairs if p.discount_pct is not None and p.listing_id is not None}
|
||||
)
|
||||
|
||||
# #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо
|
||||
# отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь
|
||||
|
|
@ -2039,6 +2083,14 @@ def get_sales_vs_listings(
|
|||
f"{len(discounts)} — на такой выборке медиана гуляет на десятки "
|
||||
f"процентных пунктов."
|
||||
)
|
||||
elif n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS:
|
||||
# Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1»
|
||||
# грамматично при любом значении, «на 1 различных объявлений» — нет.
|
||||
median_discount_explanation = (
|
||||
f"Медианный торг не показываем: сделок {len(discounts)}, а разных "
|
||||
f"объявлений для сравнения всего {n_distinct_listings} — такой процент "
|
||||
f"говорит о цене одной конкретной квартиры, а не о торге на улице."
|
||||
)
|
||||
elif not (
|
||||
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT
|
||||
<= median_discount
|
||||
|
|
@ -2057,19 +2109,22 @@ def get_sales_vs_listings(
|
|||
if median_discount_explanation is not None:
|
||||
logger.info(
|
||||
"sales-vs-listings: median_discount gated street=%r rooms=%d "
|
||||
"n_pairs=%d value=%+.2f%%",
|
||||
"n_pairs=%d distinct_listings=%d value=%+.2f%%",
|
||||
street_name,
|
||||
rooms,
|
||||
len(discounts),
|
||||
n_distinct_listings,
|
||||
median_discount,
|
||||
)
|
||||
median_discount = None
|
||||
|
||||
logger.info(
|
||||
"sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s",
|
||||
"sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d "
|
||||
"linkage=%.1f%% median_disc=%s",
|
||||
street_name,
|
||||
total_deals,
|
||||
deals_with_listings,
|
||||
n_distinct_listings,
|
||||
linkage_rate_pct,
|
||||
f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -111,6 +111,18 @@ def _rows_with_discounts(discounts: list[float]) -> list[dict]:
|
|||
]
|
||||
|
||||
|
||||
def _rows_on_shared_listings(discounts: list[float], n_listings: int) -> list[dict]:
|
||||
"""N пар, разложенных по n_listings РАЗЛИЧНЫМ объявлениям (#2672).
|
||||
|
||||
Псевдореплики из прода: сделки уникальны, а listing_id переиспользуется —
|
||||
ровно то, что `_rows_with_discounts` (одно объявление на пару) не выражает.
|
||||
"""
|
||||
return [
|
||||
_make_pair_row(deal_id=1000 + i, listing_id=2000 + (i % n_listings), 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
|
||||
|
||||
|
|
@ -567,6 +579,63 @@ def test_median_discount_kept_at_min_pairs_boundary(trade_in_app: FastAPI) -> No
|
|||
assert data["median_discount_explanation"] is None
|
||||
|
||||
|
||||
def test_median_discount_gated_when_all_pairs_share_one_listing(trade_in_app: FastAPI) -> None:
|
||||
"""#2672: 30 пар, но ОДНО различное объявление → числа нет.
|
||||
|
||||
Живой кейс (`Академика Ландау` 1-комн., −12.36%) выбран намеренно «скучным»:
|
||||
пар втрое больше порога, значение рыночное — ни один из старых признаков не
|
||||
срабатывает, гасит ровно новый. 30 сделок, делённые на одну цену предложения,
|
||||
это не 30 наблюдений рынка, а одно.
|
||||
"""
|
||||
data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.36] * 30, n_listings=1))
|
||||
assert data["deals_with_listings"] == 30
|
||||
assert data["median_discount_pct"] is None
|
||||
# Объяснение обязано назвать ОБА числа — «сделок 30» без «объявлений 1»
|
||||
# читается как «данных достаточно», т.е. ровно наоборот.
|
||||
assert "сделок 30" in data["median_discount_explanation"]
|
||||
assert "всего 1" in data["median_discount_explanation"]
|
||||
|
||||
|
||||
def test_median_discount_one_listing_gated_before_range(trade_in_app: FastAPI) -> None:
|
||||
"""#2672: `Белинского` 1-комн. — 50 пар, 1 объявление, −50.6%.
|
||||
|
||||
Проходил старый гейт целиком (пар с запасом, −50.6% внутри старых [−60,+20]).
|
||||
Сработать могли бы обе новые проверки; порядок закреплён: причина — «одно
|
||||
объявление», а не «неправдоподобное значение». Она первична: пока выборка
|
||||
стоит на одной цене, разбирать правдоподобность результата нечего.
|
||||
"""
|
||||
data = _get_sales(trade_in_app, _rows_on_shared_listings([-50.64] * 50, n_listings=1))
|
||||
assert data["median_discount_pct"] is None
|
||||
assert "всего 1" in data["median_discount_explanation"]
|
||||
assert "неправдоподобное" not in data["median_discount_explanation"]
|
||||
|
||||
|
||||
def test_median_discount_kosmonavtov_117_pairs_two_listings(trade_in_app: FastAPI) -> None:
|
||||
"""#2672 кейс, ради которого всё затевалось: 117 пар → 2 различных объявления.
|
||||
|
||||
Порог по объявлениям здесь НЕ срабатывает (2 ≥ 2, граница включительная) —
|
||||
гасит ужесточённая нижняя граница: −37.6% глубже −35% и в полтора раза глубже
|
||||
худшего объяснимого рынком бакета. Старый код показывал это число.
|
||||
"""
|
||||
data = _get_sales(trade_in_app, _rows_on_shared_listings([-37.57] * 117, n_listings=2))
|
||||
assert data["deals_with_listings"] == 117
|
||||
assert data["median_discount_pct"] is None
|
||||
assert "неправдоподобное" in data["median_discount_explanation"]
|
||||
assert "всего 2" not in data["median_discount_explanation"]
|
||||
|
||||
|
||||
def test_median_discount_kept_at_min_distinct_listings_boundary(trade_in_app: FastAPI) -> None:
|
||||
"""Граница MIN_DISTINCT_LISTINGS включительная: 2 объявления — число отдаётся.
|
||||
|
||||
Сторожит противоположное направление: гейт считает ОБЪЯВЛЕНИЯ, но не должен
|
||||
съесть всё, что не идеально. 117 пар на 2 объявлениях с рыночной медианой
|
||||
(вторая группа Космонавтова, −12.98%) проходят.
|
||||
"""
|
||||
data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.98] * 117, n_listings=2))
|
||||
assert data["median_discount_pct"] == -12.98
|
||||
assert data["median_discount_explanation"] is None
|
||||
|
||||
|
||||
def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI) -> None:
|
||||
"""#2666: «продали на 36% дороже, чем просили» — артефакт пейринга по улице.
|
||||
|
||||
|
|
@ -582,20 +651,34 @@ def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI)
|
|||
|
||||
def test_median_discount_gated_when_implausibly_negative(trade_in_app: FastAPI) -> None:
|
||||
"""Нижняя граница диапазона: −70% в 3 раза глубже худшего городского
|
||||
asking→sold бакета (студии, ratio 0.7623 = −23.8%) — тоже не рынок."""
|
||||
asking→sold бакета (студии, ratio 0.7634 = −23.7%) — тоже не рынок."""
|
||||
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_gated_below_tightened_floor(trade_in_app: FastAPI) -> None:
|
||||
"""#2672: −46.5% гасится, хотя старую границу −60% проходил.
|
||||
|
||||
Живой кейс из ревью #2671: Серов, ул. Ленина 163, 2-комн., 21 пара —
|
||||
показывался «медианный торг −46.54%» при том, что худший объяснимый рынком
|
||||
бакет = −23.7%. Пар вдвое больше порога, объявлений хватает — гасит именно
|
||||
ужесточённая нижняя граница, и только она.
|
||||
"""
|
||||
data = _get_sales(trade_in_app, _rows_with_discounts([-46.54] * 21))
|
||||
assert data["median_discount_pct"] is None
|
||||
assert "неправдоподобное" in data["median_discount_explanation"]
|
||||
assert "46" in data["median_discount_explanation"]
|
||||
|
||||
|
||||
def test_median_discount_kept_at_sane_range_boundaries(trade_in_app: FastAPI) -> None:
|
||||
"""Границы санитарного диапазона включительные: +20.0% и −60.0% проходят."""
|
||||
"""Границы санитарного диапазона включительные: +20.0% и −35.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
|
||||
bottom = _get_sales(trade_in_app, _rows_with_discounts([-35.0] * 11))
|
||||
assert bottom["median_discount_pct"] == -35.0
|
||||
assert bottom["median_discount_explanation"] is None
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue