fix(tradein/estimator): displayed analog cards match n_analogs + cross-source dedup (#2518)
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Failing after 54s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Failing after 54s
Deploy Trade-In / build-backend (push) Successful in 1m9s
This commit is contained in:
parent
2c5b30f8c9
commit
8b7af92ee3
3 changed files with 391 additions and 33 deletions
|
|
@ -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].
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue