From 51c79f93245b8127e53ae43de365ff6e734cab45 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 13 Jul 2026 23:04:25 +0300 Subject: [PATCH] fix(tradein/estimator): analogs cards match n_analogs, no cross-source dups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two display-layer bugs surfaced by pre-launch smoke (golden-path address "Екатеринбург, улица Малышева, 51"): 1. n_analogs (same-building anchor path) was computed from the post-MAD-clip comp population (_compute_same_building_anchor: n = len(surviving ppm2)), but the displayed analogs cards were built from the ORIGINAL, un-clipped anchor_comps list. A MAD-clip-excluded price outlier (e.g. a cross-source duplicate priced ~4.5x above the rest) never affected the median/n_analogs, yet still showed up as a card. Fix: _compute_same_building_anchor now returns the final surviving "comps" list (len == n), and the caller sets anchor_comps_used = anchor["comps"] instead of the raw pre-clip anchor_comps. 2. The statistical cross-source dedup (_dedup_cross_source) requires the same price_bucket (rounded price_rub) to consider two listings duplicates. Real cross-posts of the same physical unit almost always have a small asking- price drift between platforms, which can straddle a bucket boundary and let the duplicate survive into both the price population and the UI. Fix: a new display-only dedup pass (_dedup_display_lots / _union_find_phys_dedup) uses the same physical key (cadnum-or-street + floor + area_bucket) but WITHOUT price_bucket, applied only when building the displayed analogs_lots — never touches n_analogs/median/cv. To avoid over-merging genuinely distinct same-building units that happen to share floor/area, it requires the two lots to come from DIFFERENT sources (require_diff_source guard) — same-source pairs are left untouched. The radius-path (non-anchor) branch had a related count-consistency gap: n_analogs counted only PRICED listings_clean entries, while the displayed cards were sliced from the full (unfiltered) listings_clean, occasionally letting price-less entries inflate the card count beyond n_analogs. Cards are now built from the same priced subset. Frozen backtest regression gate (tests/test_backtest_regression_gate.py, hermetic replay of committed fixture) is byte-identical after this change — zero delta on median/expected_sold/coverage/calibration, confirming this is display-only and does not touch pricing. Added tests/test_estimator_analogs_display_consistency.py: full-pipeline regression for both root causes (outlier duplicate pair fully excluded from both count and cards; non-outlier duplicate pair collapses in cards only, honestly documenting len(analogs) <= n_analogs when the display-only dedup catches a residual cross-source dup the stat dedup missed) + unit tests on _dedup_display_lots. Updated test_estimator_quarter_index.py's hand-rolled _compute_same_building_anchor test double to match the new "comps" contract. --- tradein-mvp/backend/app/services/estimator.py | 152 ++++++++-- ...t_estimator_analogs_display_consistency.py | 261 ++++++++++++++++++ .../tests/test_estimator_quarter_index.py | 11 +- 3 files changed, 391 insertions(+), 33 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 953d5a57..c8bc30ab 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1959,7 +1959,14 @@ def _compute_same_building_anchor( = k·fsd. confidence-банд по fsd. Returns dict {anchor_ppm2, anchor_sold_ppm2, fsd, confidence, n, cv, - comp_min_ppm2, used_uplift, haircut} или None если комплов нет. + comp_min_ppm2, used_uplift, haircut, comps} или None если комплов нет. + "comps" — ФИНАЛЬНЫЙ (пост-MAD-clip, оба прохода) список выживших comp-словарей, + len(comps) == n. Это ЕДИНСТВЕННЫЙ источник правды для того, какие именно + комплы сформировали anchor_ppm2/n — caller обязан строить UI-карточки + (anchor_comps_used) из НЕГО, а не из исходного (пред-clip) списка, иначе + MAD-clip-отсечённый ценовой выброс всё ещё показывается пользователю, а + n_analogs (=n) расходится с фактическим числом карточек (display-consistency + fix, см. estimate_quality #2527). """ if not comps: return None @@ -2130,6 +2137,8 @@ def _compute_same_building_anchor( "comp_max_ppm2": max(ppm2), "used_uplift": used_uplift, "haircut": haircut, + # финальный пост-MAD-clip пул, len(comps) == n (см. docstring выше). + "comps": comps, } @@ -2329,9 +2338,10 @@ def _price_from_inputs( # Когда anchor сработал, anchor_comps_used (и n_analogs=anchor["n"]) брались # из недедупленного пула → тот же физлот с разных source (пустой cadnum, # street-композит совпал) считался/показывался дважды. Дедупим ЗДЕСЬ, ДО - # _compute_same_building_anchor и ДО anchor_comps_used=anchor_comps ниже — - # оба места читают этот же (теперь задедупленный) список, так что n_analogs - # и показанные top-N analogs остаются согласованы автоматически. + # _compute_same_building_anchor — она сама дальше MAD-клипит anchor_comps и + # возвращает финальный переживший пул как anchor["comps"] (display-consistency + # fix, см. anchor_comps_used=anchor["comps"] ниже) — n_analogs и показанные + # top-N analogs остаются согласованы автоматически по построению. anchor_comps = _dedup_cross_source(anchor_comps) # 3. Outlier filter @@ -2482,7 +2492,15 @@ def _price_from_inputs( if anchor is not None: # #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы. - anchor_comps_used = anchor_comps + # display-consistency fix: anchor["comps"] — ФИНАЛЬНЫЙ пост-MAD-clip пул + # (len == anchor["n"] == n_analogs ниже), НЕ исходный anchor_comps — + # иначе clip-отсечённый ценовой выброс (напр. кросс-пост ×4.5 дороже + # остальных) оставался бы среди показанных карточек, а count карточек + # расходился бы с заявленным n_analogs. + # .get() fallback — defensive: тестовые/внешние доубли _compute_same_ + # building_anchor могут не нести "comps" (старый контракт); деградируем + # к пред-clip списку, а не падаем KeyError. + anchor_comps_used = anchor.get("comps") or anchor_comps est_ppm2 = anchor["anchor_ppm2"] # PREMIUM GUARDRAIL (hard). floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol) @@ -3586,16 +3604,29 @@ async def estimate_quality( # #694: когда same-building якорь сработал, headline построен на комплах того # же дома (anchor_comps_used) — показываем ИХ, а не радиусные listings_clean # (другие/дешевле/пусто → premium headline «не подтверждён» аналогами). + # display-consistency fix: anchor_comps_used уже = anchor["comps"] (финальный + # пост-MAD-clip пул, len == n_analogs) — count согласован по построению. + # _dedup_display_lots ловит остаточные кросс-source дубли (ценовой дрейф между + # площадками), которые price_bucket-строгий статистический _dedup_cross_source + # мог пропустить — display-only, n_analogs/median/cv не трогает. if anchor_tier is not None and anchor_comps_used: - analogs_lots = [_anchor_comp_to_analog(c) for c in anchor_comps_used[:10]] + display_pool = _dedup_display_lots(anchor_comps_used) + analogs_lots = [_anchor_comp_to_analog(c) for c in display_pool[:10]] # #1519: при сработавшем якоре метаданные (freshness/last_scraped_at/ # days_on_market) считаем по ПОКАЗАННЫМ комплам, а не по радиусной выборке — # иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому # набору (или = None при пустом listings_clean, хотя у комплов данные есть). - metadata_lots = anchor_comps_used + metadata_lots = display_pool else: - analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]] - metadata_lots = listings_clean + # display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что + # дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs. + # listings_clean может содержать записи с price_per_m2=None (outlier-фильтр + # их сохраняет — «нечего судить»), которые раздували карточки сверх + # заявленного N. _dedup_display_lots — см. ветку anchor выше. + priced_clean = [lot for lot in listings_clean if lot.get("price_per_m2")] + display_pool = _dedup_display_lots(priced_clean) + analogs_lots = [_listing_to_analog(lot) for lot in display_pool[:10]] + metadata_lots = display_pool deals_lots = [_deal_to_analog(d) for d in deals[:10]] # #2087 (M1): единый канонический sources_used + source_counts. ОБА считаем по # persisted analogs_lots (top-N, ровно то, что уходит в колонку analogs и @@ -5459,6 +5490,8 @@ def _extract_street_token(addr: str | None) -> str: def _lot_dedup_components( lot: dict[str, Any], + *, + include_price: bool = True, ) -> tuple[str, str, tuple[str, Any, int, int] | None, tuple[str, Any, int, int] | None]: """(cadnum, house_no, cadnum-композит, street-композит) лота для union-find. @@ -5467,15 +5500,23 @@ def _lot_dedup_components( гасит 66.9 vs 67.0), price_bucket = round(price_rub / _DEDUP_PRICE_BUCKET_RUB) (у настоящего кросс-поста цена идентична, допуск лишь гасит округление). house_no — extra-сигнал для street-union guard (не входит в equality-ключ). - Нет площади/цены → композиты None (лот уникален, не дедупится). + Нет площади (или, при include_price=True, нет цены) → композиты None (лот + уникален, не дедупится). + + include_price=False (display-only дедуп, см. _dedup_display_lots) отключает + price_bucket-компонент композита (фиксирован в 0, цена не требуется) — + кросс-постинг одного физлота с ценовым дрейфом между площадками (частый + случай: перевыставили дешевле/дороже) всё равно схлопывается в один + building+floor+area_bucket ключ. НЕ используется в статистическом пайплайне + (_dedup_cross_source, влияет на n_analogs/median/cv) — только для UI-карточек. """ area = lot.get("area_m2") price = lot.get("price_rub") - if not area or not price: + if not area or (include_price and not price): return "", "", None, None floor = lot.get("floor") area_b = round(float(area)) - price_b = round(float(price) / _DEDUP_PRICE_BUCKET_RUB) + price_b = round(float(price) / _DEDUP_PRICE_BUCKET_RUB) if include_price and price else 0 cad = lot.get("building_cadastral_number") cad_s = str(cad).strip() if cad and str(cad).strip() else "" cad_key = (cad_s, floor, area_b, price_b) if cad_s else None @@ -5507,31 +5548,36 @@ def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]: return (sa_ord, has_photo, lot.get("source") or "", lot.get("source_url") or "") -def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Схлопывает кросс-source дубли одного физического лота (#2087 H4 / #2265). +def _union_find_phys_dedup( + lots: list[dict[str, Any]], *, include_price: bool +) -> list[dict[str, Any]]: + """Общий union-find движок для _dedup_cross_source / _dedup_display_lots. - Union-find по физическим композитам: два лота — дубли, если совпал ЛЮБОЙ - ключ — cadnum-композит ИЛИ street-композит (_lot_dedup_components). Из группы - оставляет ОДНОГО представителя (свежайший scraped_at), НЕ суммирует. Порядок - вывода — по первому появлению группы (сохраняет relevance-сортировку из - _fetch_analogs для top-N UI). Представитель несёт свой source → n_analogs/ - median/cv/source_counts/sources_used считаются по физическим лотам («лот - считается один раз»; source кросс-поста, не ставший представителем, выпадает - из выборки — по нему нет независимой ценовой точки). + Схлопывает кросс-source дубли одного физического лота (#2087 H4 / #2265): + два лота — дубли, если совпал ЛЮБОЙ ключ — cadnum-композит ИЛИ street-композит + (_lot_dedup_components). Из группы оставляет ОДНОГО представителя (свежайший + scraped_at), НЕ суммирует. Порядок вывода — по первому появлению группы + (сохраняет relevance-сортировку из _fetch_analogs для top-N UI). #2265: cadnum-композит обрабатывается первым (авторитетный), затем street- композит — но union по street НЕ сливает два РАЗНЫХ непустых кадастра (разные здания) И два РАЗНЫХ извлечённых номера дома (equal-or-missing = сливаем, both-present-different = нет). Оба guard'а — на уровне корня группы, - устойчивы к порядку. Флаг OFF → no-op (байт-идентичный проход). Лоты без - площади/цены не дедупятся — остаются уникальными. - """ - if not settings.estimate_dedup_analogs_enabled or len(lots) < 2: - return lots + устойчивы к порядку. Лоты без площади (или, при include_price=True, без цены) + не дедупятся — остаются уникальными. + include_price=True — статистический ключ (влияет на n_analogs/median/cv, + гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего + _dedup_cross_source). include_price=False — display-only ключ без + price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами + не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у + пары для merge (require_diff_source) — без цены единственный оставшийся + сигнал «это правда репост», а не два разных юнита в одном доме/этаже/ + area_bucket, случайно совпавших по округлённой площади. + """ n = len(lots) parent = list(range(n)) - comps = [_lot_dedup_components(lot) for lot in lots] + comps = [_lot_dedup_components(lot, include_price=include_price) for lot in lots] # cadnum/house группы (root → «» если группа без/со смешанным значением). root_cad = [c[0] for c in comps] root_house = [c[1] for c in comps] @@ -5542,10 +5588,20 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: x = parent[x] return x + # display-only (include_price=False): без price_bucket в ключе два РАЗНЫХ + # реальных юнита в одном доме/этаже/area_bucket (обычный случай — соседние + # квартиры на площадке) стали бы неотличимы от кросс-поста ОДНОГО юнита. + # Требуем разный source для merge — реальный кросс-пост живёт на РАЗНЫХ + # площадках (avito/cian/yandex/domklik); одинаковый source + одинаковый + # физключ, но разная цена → разные юниты (или дефект данных), не схлопываем. + require_diff_source = not include_price + def union(a: int, b: int, guard_house: bool) -> None: ra, rb = find(a), find(b) if ra == rb: return + if require_diff_source and lots[a].get("source") == lots[b].get("source"): + return # display-only: тот же source → не кросс-пост, не сливаем ca, cb = root_cad[ra], root_cad[rb] if ca and cb and ca != cb: return # разные кадастры → разные здания, не сливаем @@ -5620,7 +5676,8 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: collapsed += len(group) - 1 if collapsed: logger.info( - "cross-source dedup #2087/#2265: %d lots → %d unique (-%d dups)", + "phys dedup (include_price=%s): %d lots → %d unique (-%d dups)", + include_price, len(lots), len(result), collapsed, @@ -5628,6 +5685,43 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: return result +def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/ + median/cv/source_counts/sources_used (представитель несёт свой source → лот + считается один раз; source кросс-поста, не ставший представителем, выпадает + из выборки — по нему нет независимой ценовой точки). Флаг + estimate_dedup_analogs_enabled OFF → no-op (байт-идентичный проход, нужно + для frozen backtest regression gate). Ключ включает price_bucket + (_lot_dedup_components include_price=True по умолчанию) — см. + _union_find_phys_dedup. + """ + if not settings.estimate_dedup_analogs_enabled or len(lots) < 2: + return lots + return _union_find_phys_dedup(lots, include_price=True) + + +def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Дедуп ОТОБРАЖАЕМЫХ карточек-аналогов по физическому ключу БЕЗ price_bucket. + + Display-consistency fix (2026-07): применяется к финальному пулу, из которого + строятся analogs_lots в estimate_quality — ПОСЛЕ MAD-clip/anchor-фильтрации и + ПОСЛЕ статистического _dedup_cross_source, но независимо от него. Ловит + остаточные кросс-source дубли одного физлота, которые price_bucket-строгий + _dedup_cross_source пропустил из-за ценового дрейфа между площадками (частый + случай: перевыставили на другой площадке дешевле/дороже) — без такого + дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог». + + Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет + статистикой (median/n_analogs), этот — только честностью UI-карточек и + может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает + n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены + из отдельного (не обязательно совпадающего по итоговой длине) пула. + """ + if len(lots) < 2: + return lots + return _union_find_phys_dedup(lots, include_price=False) + + def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: """Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR]. diff --git a/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py b/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py new file mode 100644 index 00000000..c69cc3cc --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py @@ -0,0 +1,261 @@ +"""Display-consistency fix — показанные карточки аналогов согласованы с n_analogs +и не содержат ни MAD-clip-отсечённых ценовых выбросов, ни кросс-source дублей +одного физлота (live QA прод-смоук: «Екатеринбург, ул. Малышева, 51»). + +Два независимых root cause (см. PR): + +1. `n_analogs` считался ПОСЛЕ MAD-clip (_compute_same_building_anchor: n = len + выживших ppm2), но отображаемые карточки строились из ИСХОДНОГО (пред-clip) + anchor_comps — клип-отсечённый ценовой выброс оставался видимым в UI, даже + когда он не участвовал в headline/n_analogs. Фикс: anchor_comps_used теперь = + anchor["comps"] (тот же пост-clip пул, что дал n). + +2. Статистический кросс-source дедуп (_dedup_cross_source) требует совпадения + ЦЕНОВОГО бакета (price_bucket) — реальный кросс-пост одного физлота на разных + площадках почти всегда имеет небольшой ценовой дрейф (перевыставили дешевле/ + дороже), который может увести пару в разные бакеты → дубль выживает в + популяции. Фикс: отдельный display-only дедуп (_dedup_display_lots) — тот же + физический ключ (building/floor/area), но БЕЗ price_bucket — ловит именно эти + остаточные дубли ТОЛЬКО на уровне отображаемых карточек, не трогая + n_analogs/median/cv (frozen backtest regression gate не задет — см. + tests/test_backtest_regression_gate.py, который эту ветку кода не вызывает). + +Test A воспроизводит буквальный прод-паттерн (кросс-source дубль-пара, которая +ЦЕЛИКОМ является ценовым выбросом, — «Гоголя 18» из QA-смоука): MAD-clip убирает +ОБЕ копии из anchor["comps"] → они пропадают из карточек естественным образом +(fix #1 самодостаточен) → len(analogs) == n_analogs И нет дубль-адресов. + +Test B покрывает менее тривиальный случай — дубль-пара НЕ является ценовым +выбросом (обычная цена, просто с небольшим дрейфом между площадками) и +переживает MAD-clip как 2 отдельные записи (её и ловит fix #2, display-only +дедуп). Здесь len(analogs) < n_analogs ЧЕСТНО (статистика не трогается) — тест +документирует именно это, а не искусственное равенство. + +Полный estimate-путь со всеми I/O застаблен через harness _run_estimate из +test_same_building_anchor.py (anchor_tier="A" → same-building anchor ветка, где +живёт MAD-clip). +""" + +from __future__ import annotations + +import importlib.util +import os +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services import estimator + +_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py" +_spec = importlib.util.spec_from_file_location("_anchor_harness_display_consistency", _ANCHOR_TEST) +assert _spec is not None and _spec.loader is not None +_h = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_h) + + +def _comp( + *, + source: str, + address: str, + ppm2: float, + area_m2: float = 146.2, + rooms: int = 4, + scraped_at: datetime | None = None, +) -> dict[str, Any]: + """Минимальный same-building comp-dict (без floor/total_floors → нейтральный + floor-вес; area_m2 по умолчанию = target площадь _make_payload(), area-вес ~1.0). + """ + return { + "source": source, + "source_url": f"https://{source}.example/offer/{address}/{ppm2}", + "address": address, + "area_m2": area_m2, + "rooms": rooms, + "price_per_m2": ppm2, + "price_rub": ppm2 * area_m2, + "scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC), + "photo_urls": [], + } + + +# --------------------------------------------------------------------------- # +# Test A — outlier дубль-пара («Гоголя 18»-паттерн): MAD-clip убирает ОБЕ копии +# --------------------------------------------------------------------------- # + + +def test_outlier_duplicate_pair_excluded_from_both_count_and_cards() -> None: + """5 обычных комплов (разные дома, MAD-clip не трогает) + кросс-source + дубль-пара одного физлота, которая ЦЕЛИКОМ ценовой выброс (~4.4× медианы, + как «Гоголя 18» в QA-смоуке) → MAD-clip убирает ОБЕ копии из + anchor["comps"]. Т.к. карточки теперь строятся из anchor["comps"] (не из + исходного anchor_comps), выброс не просачивается в UI — количество карточек + равно n_analogs, и обеих копий дубля нет ни в счётчике, ни в карточках. + """ + clean = [ + _comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0), + _comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0), + _comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0), + _comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0), + _comp(source="cian", address="Екатеринбург, ул. Хохрякова, 58", ppm2=215_000.0), + ] + # Кросс-source дубль одного физлота — ~4.4-4.5× дороже остальных (целиком + # ценовой выброс, как реальный прод-кейс «Гоголя 18»). + outlier_dup = [ + _comp(source="avito", address="Екатеринбург, ул. Гоголя, 18", ppm2=900_000.0), + _comp(source="domklik", address="Екатеринбург, ул. Гоголя, 18", ppm2=920_000.0), + ] + + est = _h._run_estimate(anchor_comps=clean + outlier_dup, anchor_tier="A") + + # Выброс не участвовал в headline/n_analogs (MAD-clip) — фикс #1 не менял эту + # часть: сверяем, что n_analogs действительно = 5 (7 - 2 отсечённых). + assert est.n_analogs == 5 + + # Главный инвариант display-consistency: показанные карточки == n_analogs. + assert len(est.analogs) == est.n_analogs == 5 + + # Ни одна из копий выброса не попала в карточки. + shown_addresses = {a.address for a in est.analogs} + assert "Екатеринбург, ул. Гоголя, 18" not in shown_addresses + shown_ppm2 = {a.price_per_m2 for a in est.analogs} + assert 900_000 not in shown_ppm2 + assert 920_000 not in shown_ppm2 + + # Нет дублей адресов среди карточек. + assert len(shown_addresses) == len(est.analogs) + + +# --------------------------------------------------------------------------- # +# Test B — non-outlier дубль-пара («Пушкина 9»-паттерн): переживает MAD-clip, +# ловится ТОЛЬКО display-only дедупом (price_bucket-строгий стат-дедуп её +# пропускает из-за ценового дрейфа, бакет-straddle). +# --------------------------------------------------------------------------- # + + +def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None: + """4 обычных компла + кросс-source дубль-пара ОБЫЧНОЙ цены (небольшой дрейф + 2-25к, «Пушкина 9»-паттерн из QA-смоука) — НЕ выброс, переживает MAD-clip как + 2 отдельные записи (n_analogs их считает по отдельности, статистика не + тронута). display-only дедуп (без price_bucket) схлопывает пару в ОДНУ + карточку — карточек становится МЕНЬШЕ n_analogs (честно: это НЕ баг, а + следствие того, что стат-пайплайн и display-дедуп используют разные ключи + по дизайну — см. docstring _dedup_display_lots). Инвариант, который держит + этот тест: адрес дубля встречается в карточках РОВНО один раз. + """ + clean = [ + _comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0), + _comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0), + _comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0), + _comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0), + ] + # Один физлот, кросс-source (yandex/domklik), лёгкий ценовой дрейф ~1.6% — + # достаточно, чтобы price_bucket (_DEDUP_PRICE_BUCKET_RUB=100_000 руб.) + # развёл пару по РАЗНЫМ бакетам (311 vs 316), но НЕ выброс (в пределах + # MAD-clip диапазона остальных 4). + dup_a = _comp( + source="yandex", + address="Екатеринбург, ул. Пушкина, 9", + ppm2=219_000.0, + area_m2=142.0, + scraped_at=datetime(2026, 6, 1, tzinfo=UTC), + ) + dup_b = _comp( + source="domklik", + address="Екатеринбург, ул. Пушкина, 9", + ppm2=222_500.0, + area_m2=142.0, + scraped_at=datetime(2026, 6, 3, tzinfo=UTC), # свежее → представитель + ) + + # sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару — + # иначе тест проверял бы не то, что заявлено в docstring. + assert estimator.settings.estimate_dedup_analogs_enabled is True + deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b]) + assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит" + + est = _h._run_estimate(anchor_comps=[*clean, dup_a, dup_b], anchor_tier="A") + + # Статистика ЧЕСТНО считает обе копии (стат-дедуп их не поймал, MAD-clip не + # выброс) — n_analogs = 6. + assert est.n_analogs == 6 + + # display-only дедуп схлопнул пару → карточек МЕНЬШЕ n_analogs. + assert len(est.analogs) == 5 + assert len(est.analogs) < est.n_analogs + + # Инвариант: адрес дубля встречается РОВНО один раз среди карточек. + shown_addresses = [a.address for a in est.analogs] + assert shown_addresses.count("Екатеринбург, ул. Пушкина, 9") == 1 + # Представитель — свежайший scraped_at (domklik, 222_500). + pushkina_card = next(a for a in est.analogs if a.address == "Екатеринбург, ул. Пушкина, 9") + assert pushkina_card.price_per_m2 == 222_500 + + +# --------------------------------------------------------------------------- # +# Unit-level: _dedup_display_lots — прямые проверки поведения helper'а +# --------------------------------------------------------------------------- # + + +def _lot( + *, + source: str, + address: str = "ул. Ленина, 5", + area: float = 60.0, + price: float = 12_000_000.0, + floor: int | None = 5, + scraped_at: datetime | None = None, +) -> dict[str, Any]: + return { + "source": source, + "address": address, + "area_m2": area, + "price_rub": price, + "price_per_m2": price / area if area else 0, + "floor": floor, + "scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC), + "photo_urls": [], + } + + +def test_dedup_display_lots_collapses_price_drifted_cross_source_pair() -> None: + # Тот же building/floor/area, РАЗНЫЙ source, цена отличается на 20% — стат- + # дедуп (price_bucket) не поймал бы такой дрейф, display-дедуп игнорирует + # цену вовсе. + lots = [ + _lot(source="avito", price=12_000_000.0, scraped_at=datetime(2026, 6, 1, tzinfo=UTC)), + _lot(source="cian", price=14_400_000.0, scraped_at=datetime(2026, 6, 5, tzinfo=UTC)), + ] + out = estimator._dedup_display_lots(lots) + assert len(out) == 1 + assert out[0]["source"] == "cian" # свежайший scraped_at + + +def test_dedup_display_lots_same_source_pair_not_merged() -> None: + # Тот же building/floor/area, ОДИНАКОВЫЙ source, разная цена — НЕ кросс-пост + # (либо два разных реальных юнита, случайно совпавших по округлённой + # площади/этажу), display-дедуп НЕ должен их схлопывать (require_diff_source + # guard). + lots = [ + _lot(source="cian", price=12_000_000.0), + _lot(source="cian", price=13_000_000.0), + ] + out = estimator._dedup_display_lots(lots) + assert len(out) == 2 + + +def test_dedup_display_lots_different_addresses_stay_distinct() -> None: + lots = [ + _lot(source="avito", address="ул. Ленина, 5", price=12_000_000.0), + _lot(source="cian", address="ул. Мира, 3", price=12_000_000.0), + ] + out = estimator._dedup_display_lots(lots) + assert len(out) == 2 + + +def test_dedup_display_lots_noop_under_two_lots() -> None: + lots = [_lot(source="avito")] + out = estimator._dedup_display_lots(lots) + assert out is lots diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index d0f7cc83..d9ad61d7 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -595,6 +595,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None: payload = _make_payload_qi() dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350") + fake_comps = [ + _make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None) + for _ in range(3) + ] fake_anchor = { "anchor_ppm2": _BASE_PPM2 * 1.5, "anchor_sold_ppm2": _BASE_PPM2 * 1.4, @@ -606,11 +610,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None: "comp_max_ppm2": _BASE_PPM2 * 1.7, "used_uplift": False, "haircut": 0.05, + # display-consistency fix: contract now requires "comps" — the final + # post-MAD-clip survivors (see _compute_same_building_anchor docstring). + "comps": fake_comps, } - fake_comps = [ - _make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None) - for _ in range(3) - ] analogs_no_cadnum = [ _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) -- 2.45.3