fix(estimator): #1774 Tier A admits same-building novostroyki resales (secondary-dominant gate) #1776
4 changed files with 367 additions and 9 deletions
|
|
@ -96,6 +96,12 @@ class Settings(BaseSettings):
|
|||
estimate_sb_floor_sigma: float = 0.25
|
||||
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
|
||||
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
||||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||
# ratio — не затрагиваются.
|
||||
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
|
||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||
|
||||
|
|
|
|||
|
|
@ -1469,14 +1469,13 @@ def _fetch_anchor_comps(
|
|||
"""
|
||||
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
|
||||
days_on_market, photo_urls, lat, lon,
|
||||
listing_segment, source_id
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
AND price_per_m2 > 0
|
||||
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
|
||||
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
|
||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
"""
|
||||
),
|
||||
{
|
||||
|
|
@ -1494,14 +1493,60 @@ def _fetch_anchor_comps(
|
|||
except Exception:
|
||||
pass
|
||||
rows = []
|
||||
comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]]
|
||||
# #1774: gated relaxation of the #1186 guard, Tier A ONLY. В сданном доме
|
||||
# cian тегирует переуступки/перепродажи собственниками как 'novostroyki';
|
||||
# впускаем novostroyki только если вторичных (vtorichka/NULL) НЕ МЕНЬШЕ, чем
|
||||
# первичных — иначе MAD-clip может выкинуть редкую вторичку и заякорить на
|
||||
# ценах застройщика. Чисто-первичный / primary-dominated дом → гард #1186.
|
||||
raw_rows = list(rows)
|
||||
secondary_count = sum(
|
||||
1 for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")
|
||||
)
|
||||
primary_count = len(raw_rows) - secondary_count
|
||||
include_primary = (
|
||||
settings.estimate_sb_tier_a_allow_primary_if_secondary_present
|
||||
and secondary_count >= 1
|
||||
and secondary_count >= primary_count
|
||||
)
|
||||
if include_primary:
|
||||
kept = raw_rows # mixed/delivered house: include novostroyki resales
|
||||
else:
|
||||
kept = [r for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")]
|
||||
# Dedup дублей одного объявления (#1774: source_id=330047129 → 2 active-строки,
|
||||
# одна с house_id_fk, одна NULL). Ключ — (source, source_id) PRIMARY (codebase
|
||||
# canon: скрейперы дедупят на source_id; source_url может отличаться trailing
|
||||
# slash/query-param). Fallback source_url, затем атрибуты. Namespaced-теги
|
||||
# ('id'/'url') защищают от value-collision между id и url.
|
||||
deduped: dict[Any, dict[str, Any]] = {}
|
||||
for r in kept:
|
||||
sid = r.get("source_id")
|
||||
url = r.get("source_url")
|
||||
if sid is not None:
|
||||
key: Any = (r.get("source"), "id", sid)
|
||||
elif url is not None:
|
||||
key = (r.get("source"), "url", url)
|
||||
else:
|
||||
key = (
|
||||
r.get("source"),
|
||||
r.get("address"),
|
||||
r.get("area_m2"),
|
||||
r.get("price_per_m2"),
|
||||
)
|
||||
if key not in deduped:
|
||||
deduped[key] = r
|
||||
deduped_rows = list(deduped.values())
|
||||
comps = [_anchor_comp_from_row(r) for r in deduped_rows if r["price_per_m2"]]
|
||||
if len(comps) >= min_comps:
|
||||
primary_n = sum(
|
||||
1 for r in deduped_rows if r.get("listing_segment") == "novostroyki"
|
||||
)
|
||||
logger.info(
|
||||
"anchor tier=A street=%r base=%s letter=%s → %d comps",
|
||||
"anchor tier=A street=%r base=%s letter=%s → %d comps (primary=%d)",
|
||||
street,
|
||||
base_no,
|
||||
letter,
|
||||
len(comps),
|
||||
primary_n,
|
||||
)
|
||||
return comps, "A"
|
||||
|
||||
|
|
|
|||
|
|
@ -62,16 +62,30 @@ def test_tier_w_inline_has_canonical_guard() -> None:
|
|||
assert "listing_segment != 'novostroyki'" not in src
|
||||
|
||||
|
||||
def test_tier_a_anchor_has_canonical_guard() -> None:
|
||||
"""Tier A anchor (same building) — ранее не имел segment-guard (#1186 fix)."""
|
||||
def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None:
|
||||
"""#1774: Tier A больше НЕ хардкодит SQL-guard — он делает gated Python-side
|
||||
relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
|
||||
|
||||
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
|
||||
Python-side gating — источник содержит и флаг настройки, и hardened
|
||||
secondary_count/primary_count gate (#1774, после code-review).
|
||||
"""
|
||||
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
||||
assert _GUARD_RE.search(src), "_fetch_anchor_comps Tier A SQL lacks novostroyki guard"
|
||||
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
|
||||
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
|
||||
# (b) Tier A теперь gated Python-side (#1774).
|
||||
assert "estimate_sb_tier_a_allow_primary_if_secondary_present" in src, (
|
||||
"Tier A no longer references the #1774 gating flag — gated relaxation missing"
|
||||
)
|
||||
# Hardened gate: secondary_count ≥ primary_count (заменил has_secondary).
|
||||
assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)"
|
||||
assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)"
|
||||
|
||||
|
||||
def test_tier_c_anchor_has_canonical_guard_not_segment_param() -> None:
|
||||
"""Tier C anchor — канон-предикат, не CAST(:segment AS text) (тот вариант отбрасывал NULL)."""
|
||||
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
||||
# Canonical guard present.
|
||||
# Canonical guard present (Tier C; Tier A guard снят в #1774 → gated Python-side).
|
||||
assert _GUARD_RE.search(src)
|
||||
# Старый параметрический вариант убран.
|
||||
assert "listing_segment = CAST(:segment AS text)" not in src
|
||||
|
|
|
|||
293
tradein-mvp/backend/tests/test_tier_a_primary_1774.py
Normal file
293
tradein-mvp/backend/tests/test_tier_a_primary_1774.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
"""#1774 — Tier A (same building) gated relaxation of the #1186 novostroyki guard.
|
||||
|
||||
В СДАННОМ доме cian тегирует переуступки/перепродажи собственниками (sale_type=free)
|
||||
как listing_segment='novostroyki'. Раньше Tier A-гард #1186
|
||||
`(listing_segment IS NULL OR listing_segment='vtorichka')` отбрасывал их, и
|
||||
same-building listing пользователя не учитывался (кейс «Евгения Савкова 29», ЕКБ).
|
||||
|
||||
Дизайн #1774 (Tier A ONLY), gate hardened после code-review:
|
||||
- secondary_count = число vtorichka/NULL rows; primary_count = остальные.
|
||||
- flag ON + secondary_count ≥ 1 И secondary_count ≥ primary_count →
|
||||
впускаем ВСЕ rows (включая novostroyki-переуступки).
|
||||
- flag ON + primary-dominated дом (secondary_count < primary_count) → гард
|
||||
#1186 сохраняется: иначе MAD-clip выкинет редкую вторичку и заякорит на
|
||||
ценах застройщика (~27% overvaluation).
|
||||
- flag OFF → гард #1186 сохраняется всегда (novostroyki исключены).
|
||||
- dedup: PRIMARY ключ (source, source_id) — codebase canon (скрейперы дедупят
|
||||
на source_id; source_url может отличаться trailing slash/query-param).
|
||||
|
||||
Tier C / радиус / ratio — НЕ затрагиваются (см. test_segment_guard_1186.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from app.services import estimator as est_mod
|
||||
from app.services.estimator import _fetch_anchor_comps
|
||||
|
||||
# Адрес сводится к ('евгения савкова', 29, None) — Tier A street+base.
|
||||
_ADDRESS = "Екатеринбург, ул. Евгения Савкова, 29"
|
||||
|
||||
|
||||
def _row(
|
||||
*,
|
||||
source: str = "avito",
|
||||
source_url: str | None = None,
|
||||
source_id: str | None = None,
|
||||
listing_segment: str | None = None,
|
||||
price_per_m2: float = 150_000.0,
|
||||
area_m2: float = 50.0,
|
||||
rooms: int = 2,
|
||||
address: str = _ADDRESS,
|
||||
) -> dict[str, Any]:
|
||||
"""Mock listings-row (mappings shape) для Tier A: несёт listing_segment + source_id."""
|
||||
if source_url is None:
|
||||
source_url = f"https://{source}.ru/offer/{source_id or '0'}"
|
||||
return {
|
||||
"price_per_m2": price_per_m2,
|
||||
"area_m2": area_m2,
|
||||
"rooms": rooms,
|
||||
"floor": 3,
|
||||
"total_floors": 9,
|
||||
"address": address,
|
||||
"source": source,
|
||||
"source_url": source_url,
|
||||
"price_rub": int(price_per_m2 * area_m2),
|
||||
"listing_date": None,
|
||||
"days_on_market": 15,
|
||||
"photo_urls": [],
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"listing_segment": listing_segment,
|
||||
"source_id": source_id,
|
||||
}
|
||||
|
||||
|
||||
def _db_mock(rows: list[dict[str, Any]]) -> MagicMock:
|
||||
"""Session mock: db.execute().mappings().all() → rows (Tier A path)."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = rows
|
||||
return db
|
||||
|
||||
|
||||
def _fetch(db: MagicMock) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""Вызов _fetch_anchor_comps с Tier A-релевантными аргументами (без lat/lon → Tier C skip)."""
|
||||
return _fetch_anchor_comps(
|
||||
db,
|
||||
address=_ADDRESS,
|
||||
target_house_id=None,
|
||||
lat=None,
|
||||
lon=None,
|
||||
rooms=2,
|
||||
area=50.0,
|
||||
)
|
||||
|
||||
|
||||
def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
||||
"""Mixed/сданный дом: 4 vtorichka/NULL + 2 novostroyki → flag ON впускает ВСЕ 6.
|
||||
|
||||
secondary_count=4 ≥ primary_count=2 → hardened gate допускает novostroyki.
|
||||
Зеркало «Евгения Савкова 29»: vtorichka-комплы (avito/yandex) соседствуют с
|
||||
cian-переуступками (novostroyki) — последние тоже должны попасть в anchor.
|
||||
"""
|
||||
rows = [
|
||||
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||
_row(source="avito", source_id="a2", listing_segment=None),
|
||||
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||
_row(source="yandex", source_id="y2", listing_segment=None),
|
||||
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
||||
assert len(comps) == 6
|
||||
|
||||
|
||||
def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
|
||||
"""Hardened gate (code-review fix): 1 vtorichka + 5 novostroyki → secondary_count=1
|
||||
< primary_count=5 → novostroyki ИСКЛЮЧЕНЫ (anchor строился бы на ценах застройщика
|
||||
→ ~27% overvaluation после MAD-clip выкидывания одинокой вторички).
|
||||
|
||||
После исключения остаётся 1 vtorichka < min_comps=4 → Tier A не срабатывает
|
||||
(lat/lon None → Tier C skip → tier=None, comps=[]). Это доказывает, что primary
|
||||
rows отброшены: при их допуске было бы 6 comps ≥ min_comps и tier='A'.
|
||||
"""
|
||||
rows = [
|
||||
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c3", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
comps, tier = _fetch(db)
|
||||
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
||||
assert tier is None
|
||||
assert comps == []
|
||||
|
||||
|
||||
def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
|
||||
"""Чисто-первичный дом (0 вторички, ВСЕ novostroyki) → гард #1186 сохраняется,
|
||||
novostroyki исключаются → comps < min_comps → Tier A не срабатывает (tier=None).
|
||||
|
||||
Tier-D-skip path намеренный: lat/lon=None → Tier C не запускается, поэтому
|
||||
после отбраковки всех 4 novostroyki Tier A не набирает min_comps и возвращает
|
||||
(tier=None, comps=[]). Assert именно на этом нулевом результате — фиксирует,
|
||||
что чисто-первичный дом НЕ заякоривается на ценах застройщика."""
|
||||
rows = [
|
||||
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c3", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
comps, tier = _fetch(db)
|
||||
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
||||
assert tier is None
|
||||
assert comps == []
|
||||
|
||||
|
||||
def test_tier_a_flag_off_excludes_novostroyki_even_with_secondary() -> None:
|
||||
"""Flag OFF → оригинальное поведение #1186: novostroyki ВСЕГДА исключены в Tier A,
|
||||
даже если в доме есть вторичка. Остаются только 4 vtorichka/NULL."""
|
||||
rows = [
|
||||
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||
_row(source="avito", source_id="a2", listing_segment=None),
|
||||
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||
_row(source="yandex", source_id="y2", listing_segment=None),
|
||||
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", False
|
||||
),
|
||||
):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# novostroyki отброшены → только 4 вторички.
|
||||
assert len(comps) == 4
|
||||
|
||||
|
||||
def test_tier_a_dedup_same_source_id_collapses() -> None:
|
||||
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
|
||||
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
|
||||
(source, source_id).
|
||||
|
||||
5 уникальных vtorichka + дубль одного source_id → 5 comps (не 6 — дубль убран).
|
||||
"""
|
||||
dup_url = "https://cian.ru/sale/flat/330047129/"
|
||||
rows = [
|
||||
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||
_row(source="avito", source_id="a2", listing_segment="vtorichka"),
|
||||
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||
_row(source="yandex", source_id="y2", listing_segment="vtorichka"),
|
||||
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment="vtorichka"),
|
||||
# Дубль: тот же (source, source_id), но другой row (house_id_fk NULL-вариант).
|
||||
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
||||
assert len(comps) == 5
|
||||
cian = [c for c in comps if c["source"] == "cian"]
|
||||
assert len(cian) == 1
|
||||
|
||||
|
||||
def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
|
||||
"""Code-review Fix 2: те же source_id, но РАЗНЫЙ source_url (trailing slash /
|
||||
query-param). PRIMARY ключ (source, source_id) всё равно схлопывает в 1 comp —
|
||||
старый url-primary ключ этот дубль ПРОПУСКАЛ (это и есть баг #1774 Fix 2).
|
||||
|
||||
5 уникальных vtorichka + дубль того же source_id с другим url → 5 comps.
|
||||
"""
|
||||
rows = [
|
||||
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||
_row(source="avito", source_id="a2", listing_segment="vtorichka"),
|
||||
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||
_row(source="yandex", source_id="y2", listing_segment="vtorichka"),
|
||||
_row(
|
||||
source="cian",
|
||||
source_url="https://cian.ru/sale/flat/330047129/",
|
||||
source_id="330047129",
|
||||
listing_segment="vtorichka",
|
||||
),
|
||||
# Дубль того же source_id, но url отличается trailing slash + query-param.
|
||||
_row(
|
||||
source="cian",
|
||||
source_url="https://cian.ru/sale/flat/330047129?utm=x",
|
||||
source_id="330047129",
|
||||
listing_segment=None,
|
||||
),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
||||
assert len(comps) == 5
|
||||
cian = [c for c in comps if c["source"] == "cian"]
|
||||
assert len(cian) == 1
|
||||
|
||||
|
||||
def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
|
||||
"""source_url=None, но source_id задан → PRIMARY ключ (source, source_id):
|
||||
реально разные лоты НЕ схлопываются (разные source_id)."""
|
||||
rows = [
|
||||
_row(source="avito", source_url=None, source_id="a1", area_m2=50.0),
|
||||
_row(source="avito", source_url=None, source_id="a2", area_m2=60.0),
|
||||
_row(source="avito", source_url=None, source_id="a3", area_m2=70.0),
|
||||
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||
assert len(comps) == 4
|
||||
Loading…
Add table
Reference in a new issue