fix(estimator): #1774 Tier A admits same-building novostroyki resales (secondary-dominant gate) #1776

Merged
lekss361 merged 1 commit from fix/1774-tier-a-same-building-primary into main 2026-06-19 09:28:16 +00:00
4 changed files with 367 additions and 9 deletions

View file

@ -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²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > 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 → полуширина диапазона

View file

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

View file

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

View 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