fix(tradein): surface same-building comps as analogs when anchor fires (#694)

При сработавшем same-building anchor headline строится из комплов того же дома,
а analogs[] показывали РАДИУСНЫЕ листинги (дешевле/другие) или пусто — премиум-
итог выглядел неподкреплённым (Олимп 13: 207М, analogs 41–46М; Ткачёва 13:
38М при пустом analogs[], хотя якорь на 27 комплах дома).

_fetch_anchor_comps теперь тянет display-поля (address/source/source_url/
price_rub/listing_date/days_on_market/photo_urls/lat/lon) в обоих тирах (A/C),
дедуп через _anchor_comp_from_row. При anchor_tier!=None analogs[] строится из
anchor_comps_used через _anchor_comp_to_analog (robust к отсутствующим полям:
price_rub = price_per_m2×area при отсутствии). Радиусный путь без якоря —
без изменений.

Тесты: analogs отражают anchor-комплы (ppm² 399k/472k/684k, не радиус 200–220k),
price_rub=round(ppm²×area); без якоря — радиусные; pass-through display-полей.
44 passed.

Closes #694
This commit is contained in:
lekss361 2026-05-30 12:05:27 +03:00
parent 9d94beae4f
commit 21356b00c5
2 changed files with 155 additions and 25 deletions

View file

@ -987,6 +987,34 @@ def _normalize_building_key(
return street_core, base_no, letter
def _anchor_comp_from_row(r: Any) -> dict[str, Any]:
"""Строит comp-dict из строки SQL same-building/micro-radius (#694).
Несёт 5 числовых полей для _compute_same_building_anchor
(price_per_m2/area_m2/rooms/floor/total_floors) + display-поля listings
(address/source/source_url/price_rub/listing_date/days_on_market/photo_urls/
lat/lon), чтобы UI-аналоги отражали ИМЕННО комплы, на которых построен якорь,
а не радиусные (cheaper/empty). Display-ключи best-effort: SELECT их тянет, но
helper устойчив к их отсутствию (тестовые моки могут давать только числа).
"""
return {
"price_per_m2": int(r["price_per_m2"]),
"area_m2": float(r["area_m2"]) if r.get("area_m2") is not None else None,
"rooms": int(r["rooms"]) if r.get("rooms") is not None else None,
"floor": int(r["floor"]) if r.get("floor") is not None else None,
"total_floors": int(r["total_floors"]) if r.get("total_floors") is not None else None,
"address": r.get("address"),
"source": r.get("source"),
"source_url": r.get("source_url"),
"price_rub": int(r["price_rub"]) if r.get("price_rub") is not None else None,
"listing_date": r.get("listing_date"),
"days_on_market": r.get("days_on_market"),
"photo_urls": r.get("photo_urls"),
"lat": float(r["lat"]) if r.get("lat") is not None else None,
"lon": float(r["lon"]) if r.get("lon") is not None else None,
}
def _fetch_anchor_comps(
db: Session,
*,
@ -1030,7 +1058,9 @@ def _fetch_anchor_comps(
db.execute(
text(
"""
SELECT price_per_m2, area_m2, rooms, floor, total_floors
SELECT price_per_m2, area_m2, rooms, floor, total_floors,
address, source, source_url, price_rub, listing_date,
days_on_market, photo_urls, lat, lon
FROM listings
WHERE is_active = true
AND price_per_m2 > 0
@ -1053,17 +1083,7 @@ def _fetch_anchor_comps(
except Exception:
pass
rows = []
comps = [
{
"price_per_m2": int(r["price_per_m2"]),
"area_m2": float(r["area_m2"]) if r["area_m2"] is not None else None,
"rooms": int(r["rooms"]) if r["rooms"] is not None else None,
"floor": int(r["floor"]) if r["floor"] is not None else None,
"total_floors": int(r["total_floors"]) if r["total_floors"] is not None else None,
}
for r in rows
if r["price_per_m2"]
]
comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]]
if len(comps) >= min_comps:
logger.info(
"anchor tier=A street=%r base=%s letter=%s%d comps",
@ -1081,7 +1101,9 @@ def _fetch_anchor_comps(
db.execute(
text(
"""
SELECT price_per_m2, area_m2, rooms, floor, total_floors
SELECT price_per_m2, area_m2, rooms, floor, total_floors,
address, source, source_url, price_rub, listing_date,
days_on_market, photo_urls, lat, lon
FROM listings
WHERE is_active = true
AND price_per_m2 > 0
@ -1119,17 +1141,7 @@ def _fetch_anchor_comps(
except Exception:
pass
rows = []
comps = [
{
"price_per_m2": int(r["price_per_m2"]),
"area_m2": float(r["area_m2"]) if r["area_m2"] is not None else None,
"rooms": int(r["rooms"]) if r["rooms"] is not None else None,
"floor": int(r["floor"]) if r["floor"] is not None else None,
"total_floors": int(r["total_floors"]) if r["total_floors"] is not None else None,
}
for r in rows
if r["price_per_m2"]
]
comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]]
if len(comps) >= min_comps:
logger.info("anchor tier=C micro-radius → %d comps", len(comps))
return comps, "C"
@ -1645,6 +1657,10 @@ async def estimate_quality(
# больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое
# поведение. listing_segment для Tier C берём из самого частого среди аналогов.
anchor_tier: str | None = None
# #694: комплы того же дома, на которых РЕАЛЬНО построен headline-якорь.
# Заполняется только когда anchor мутировал median (ниже) — тогда UI-аналоги
# строятся из них, а не из радиусных listings_clean (cheaper/empty).
anchor_comps_used: list[dict[str, Any]] = []
# #691: гейт НЕ требует радиусных аналогов (listings_clean) / median_price>0.
# На проде геокод часто = None → ST_DWithin не находит радиусные комплы →
# median=0, и same-building якорь скипался, ХОТЯ комплы того же дома есть
@ -1691,6 +1707,8 @@ async def estimate_quality(
floor_sigma=settings.estimate_sb_floor_sigma,
)
if anchor is not None:
# #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы.
anchor_comps_used = comps
# Headline = recommended ASKING price (комплы — активные объявления;
# golden-реалы — asking). Берём anchor_ppm2 (PRE-haircut), НЕ
# anchor_sold_ppm2. asking→sold скидка применяется единственным
@ -1946,7 +1964,13 @@ async def estimate_quality(
now = datetime.now(tz=UTC)
expires_at = now + timedelta(hours=24)
analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]]
# #694: когда same-building якорь сработал, headline построен на комплах того
# же дома (anchor_comps_used) — показываем ИХ, а не радиусные listings_clean
# (другие/дешевле/пусто → premium headline «не подтверждён» аналогами).
if anchor_tier is not None and anchor_comps_used:
analogs_lots = [_anchor_comp_to_analog(c) for c in anchor_comps_used[:10]]
else:
analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]]
deals_lots = [_deal_to_analog(d) for d in deals[:10]]
freshness_pre = _compute_freshness_minutes(listings_clean)
# DaData enrichment (PR Q1) — заполняется только если service отработал.
@ -3100,6 +3124,46 @@ def _listing_to_analog(row: dict[str, Any]) -> AnalogLot:
)
def _anchor_comp_to_analog(c: dict[str, Any]) -> AnalogLot:
"""Same-building/micro-radius comp → AnalogLot (#694).
Когда якорь сработал, headline построен на этих комплах показываем их в UI.
ROBUST к отсутствию display-полей: реальные комплы несут address/source/price_rub
(SELECT их тянет), но тестовые моки и legacy-строки могут давать только числа
(price_per_m2/area_m2/rooms). price_rub тогда вычисляем ppm²×area
(fallback на ppm² если area 0/None без zero-result/crash). distance_m не
значим для same-building None, если явно не передан.
"""
ppm2 = int(c.get("price_per_m2") or 0)
area = float(c.get("area_m2") or 0)
price_rub_raw = c.get("price_rub")
if price_rub_raw is not None:
price_rub = int(price_rub_raw)
elif area > 0:
price_rub = round(ppm2 * area)
else:
# area неизвестна — деградируем до ppm² (никогда 0/crash при ppm²>0).
price_rub = ppm2
photo_urls = c.get("photo_urls")
return AnalogLot(
address=c.get("address") or "",
area_m2=area,
rooms=int(c.get("rooms") or 0),
floor=c.get("floor"),
total_floors=c.get("total_floors"),
price_rub=price_rub,
price_per_m2=ppm2,
listing_date=c.get("listing_date"),
days_on_market=c.get("days_on_market"),
photo_url=(photo_urls or [None])[0] if photo_urls else None,
source=c.get("source"),
source_url=c.get("source_url"),
distance_m=int(c["distance_m"]) if c.get("distance_m") is not None else None,
lat=float(c["lat"]) if c.get("lat") is not None else None,
lon=float(c["lon"]) if c.get("lon") is not None else None,
)
def _deal_to_analog(row: dict[str, Any]) -> AnalogLot:
"""deals не имеют photo_url — упрощённо.

View file

@ -562,6 +562,72 @@ def test_anchor_exposes_comp_max() -> None:
assert res["comp_min_ppm2"] == 300_000
# ── #694: UI-аналоги отражают комплы, на которых построен headline ──────────
def test_estimate_analogs_reflect_anchor_comps_when_fired() -> None:
"""#694: якорь сработал (tier=A) → est.analogs строятся из SAME-BUILDING комплов
(399k/472k/684k), а НЕ из дешёвых радиусных (_RADIUS_ANALOGS 200k-220k).
price_rub вычислен ppm²×area (комплы без price_rub/address fallback-путь)."""
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
assert len(est.analogs) == 3
ppm2_shown = {a.price_per_m2 for a in est.analogs}
assert ppm2_shown == {399_478, 472_298, 683_995}
# НЕ радиусные дешёвые лоты.
assert ppm2_shown.isdisjoint({200_000, 210_000, 220_000})
# price_rub = round(ppm² × area) для каждого компла (нет price_rub в моке).
by_ppm2 = {a.price_per_m2: a for a in est.analogs}
for c in _SB_COMPS_PREMIUM:
a = by_ppm2[c["price_per_m2"]]
assert a.price_rub == round(c["price_per_m2"] * c["area_m2"])
def test_estimate_analogs_stay_radius_when_no_anchor() -> None:
"""#694: якорь НЕ сработал (tier=None, Tier D) → est.analogs отражают радиусные
аналоги (_RADIUS_ANALOGS 200k-220k) существующее поведение сохранено."""
est = _run_estimate(anchor_comps=[], anchor_tier=None)
assert len(est.analogs) == len(_RADIUS_ANALOGS)
ppm2_shown = {a.price_per_m2 for a in est.analogs}
assert ppm2_shown == {200_000, 210_000, 220_000}
def test_estimate_analogs_pass_through_display_fields() -> None:
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area)."""
comps_with_display = [
{
"price_per_m2": 399_478,
"area_m2": 153.2,
"rooms": 3,
"address": "ЕКБ, ул. Хохрякова, 48",
"source": "cian",
"source_url": "https://cian.ru/offer/42",
"price_rub": 61_000_000,
},
{
"price_per_m2": 472_298,
"area_m2": 110.1,
"rooms": 3,
"address": "ЕКБ, ул. Хохрякова, 48",
"source": "avito",
"source_url": "https://avito.ru/offer/7",
"price_rub": 52_000_000,
},
{"price_per_m2": 683_995, "area_m2": 146.2, "rooms": 4}, # без display → fallback
]
est = _run_estimate(anchor_comps=comps_with_display, anchor_tier="A")
by_ppm2 = {a.price_per_m2: a for a in est.analogs}
a1 = by_ppm2[399_478]
assert a1.address == "ЕКБ, ул. Хохрякова, 48"
assert a1.source == "cian"
assert a1.source_url == "https://cian.ru/offer/42"
assert a1.price_rub == 61_000_000 # прямой price_rub, не ppm²×area
# компл без display → price_rub вычислен, source None.
a3 = by_ppm2[683_995]
assert a3.price_rub == round(683_995 * 146.2)
assert a3.source is None
# ── #680-WB: within-building heterogeneity refine (uplift-gate + floor weight) ──