fix(tradein): surface same-building comps as analogs when anchor fires (#694) #708
2 changed files with 155 additions and 25 deletions
|
|
@ -987,6 +987,34 @@ def _normalize_building_key(
|
||||||
return street_core, base_no, letter
|
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(
|
def _fetch_anchor_comps(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1030,7 +1058,9 @@ def _fetch_anchor_comps(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
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
|
FROM listings
|
||||||
WHERE is_active = true
|
WHERE is_active = true
|
||||||
AND price_per_m2 > 0
|
AND price_per_m2 > 0
|
||||||
|
|
@ -1053,17 +1083,7 @@ def _fetch_anchor_comps(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
rows = []
|
rows = []
|
||||||
comps = [
|
comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]]
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
]
|
|
||||||
if len(comps) >= min_comps:
|
if len(comps) >= min_comps:
|
||||||
logger.info(
|
logger.info(
|
||||||
"anchor tier=A street=%r base=%s letter=%s → %d comps",
|
"anchor tier=A street=%r base=%s letter=%s → %d comps",
|
||||||
|
|
@ -1081,7 +1101,9 @@ def _fetch_anchor_comps(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
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
|
FROM listings
|
||||||
WHERE is_active = true
|
WHERE is_active = true
|
||||||
AND price_per_m2 > 0
|
AND price_per_m2 > 0
|
||||||
|
|
@ -1119,17 +1141,7 @@ def _fetch_anchor_comps(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
rows = []
|
rows = []
|
||||||
comps = [
|
comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]]
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
]
|
|
||||||
if len(comps) >= min_comps:
|
if len(comps) >= min_comps:
|
||||||
logger.info("anchor tier=C micro-radius → %d comps", len(comps))
|
logger.info("anchor tier=C micro-radius → %d comps", len(comps))
|
||||||
return comps, "C"
|
return comps, "C"
|
||||||
|
|
@ -1645,6 +1657,10 @@ async def estimate_quality(
|
||||||
# больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое
|
# больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое
|
||||||
# поведение. listing_segment для Tier C берём из самого частого среди аналогов.
|
# поведение. listing_segment для Tier C берём из самого частого среди аналогов.
|
||||||
anchor_tier: str | None = None
|
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.
|
# #691: гейт НЕ требует радиусных аналогов (listings_clean) / median_price>0.
|
||||||
# На проде геокод часто = None → ST_DWithin не находит радиусные комплы →
|
# На проде геокод часто = None → ST_DWithin не находит радиусные комплы →
|
||||||
# median=0, и same-building якорь скипался, ХОТЯ комплы того же дома есть
|
# median=0, и same-building якорь скипался, ХОТЯ комплы того же дома есть
|
||||||
|
|
@ -1691,6 +1707,8 @@ async def estimate_quality(
|
||||||
floor_sigma=settings.estimate_sb_floor_sigma,
|
floor_sigma=settings.estimate_sb_floor_sigma,
|
||||||
)
|
)
|
||||||
if anchor is not None:
|
if anchor is not None:
|
||||||
|
# #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы.
|
||||||
|
anchor_comps_used = comps
|
||||||
# Headline = recommended ASKING price (комплы — активные объявления;
|
# Headline = recommended ASKING price (комплы — активные объявления;
|
||||||
# golden-реалы — asking). Берём anchor_ppm2 (PRE-haircut), НЕ
|
# golden-реалы — asking). Берём anchor_ppm2 (PRE-haircut), НЕ
|
||||||
# anchor_sold_ppm2. asking→sold скидка применяется единственным
|
# anchor_sold_ppm2. asking→sold скидка применяется единственным
|
||||||
|
|
@ -1946,7 +1964,13 @@ async def estimate_quality(
|
||||||
now = datetime.now(tz=UTC)
|
now = datetime.now(tz=UTC)
|
||||||
expires_at = now + timedelta(hours=24)
|
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]]
|
deals_lots = [_deal_to_analog(d) for d in deals[:10]]
|
||||||
freshness_pre = _compute_freshness_minutes(listings_clean)
|
freshness_pre = _compute_freshness_minutes(listings_clean)
|
||||||
# DaData enrichment (PR Q1) — заполняется только если service отработал.
|
# 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:
|
def _deal_to_analog(row: dict[str, Any]) -> AnalogLot:
|
||||||
"""deals не имеют photo_url — упрощённо.
|
"""deals не имеют photo_url — упрощённо.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -562,6 +562,72 @@ def test_anchor_exposes_comp_max() -> None:
|
||||||
assert res["comp_min_ppm2"] == 300_000
|
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) ──
|
# ── #680-WB: within-building heterogeneity refine (uplift-gate + floor weight) ──
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue