fix(tradein/estimator): analogs cards match n_analogs, no cross-source dups
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 55s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped

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.
This commit is contained in:
bot-backend 2026-07-13 23:04:25 +03:00
parent 5647c2b40e
commit 51c79f9324
3 changed files with 391 additions and 33 deletions

View file

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

View file

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

View file

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