feat(tradein): same-building anchor estimator (#651/#652 v2) behind flag

Replaces radius-median ppm2 with a same-building anchor (Tier A: normalized
street+house comps; Tier C: <=500m segment-matched; Tier D: existing radius
fallback). Similarity-weighted mean + guarded premium uplift + ppm2-banded
asking->sold haircut + hard guardrail + tightened FSD range. Fixes ~2.5x
premium underestimate / 15-25% comfort dilution. Address normalizer handles
e->e, Tkacheva->Tkachey alias, base-house-number across corpus letters.
Behind estimate_same_building_anchor_enabled (default on); OFF = prior behavior.
Validated offline (55 golden: coverage 53->95%, premium medAPE 53->18%) + 4 live cases.
19 new tests; full suite 1106 passed; ruff clean.

Refs #651 #652
This commit is contained in:
lekss361 2026-05-29 23:51:34 +03:00
parent be63b249b5
commit bd7f40428a
3 changed files with 870 additions and 14 deletions

View file

@ -15,8 +15,8 @@ class Settings(BaseSettings):
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
# backfill_house_coords.py + audit_address_mismatch.py + main backend
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
# для User-Agent в Nominatim (Nominatim Usage Policy)
contact_email: str = "erginrajpopxbe@outlook.com"
@ -64,9 +64,24 @@ class Settings(BaseSettings):
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
# no-op (медиана не меняется).
estimate_imv_blend_enabled: bool = True
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
# комфорт 15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
# и tighter FSD-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_same_building_anchor_enabled: bool = True
estimate_sb_min_comps: int = 2 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
# ── Estimate enrichment time-budgets (#654) ──────────────────────────────
# POST /estimate делает несколько ПОСЛЕДОВАТЕЛЬНЫХ блокирующих сетевых
# вызовов (geocode → Overpass → Yandex valuation → IMV → Cian). Yandex

View file

@ -844,6 +844,321 @@ def _fetch_dkp_corridor(
}
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ─────────────
#
# Радиусная медиана ₽/м² системно занижает премиум/видовые квартиры — она мешает
# топовый дом с массовой застройкой рядом. v2 строит PRIMARY якорь из комплов ТОГО
# ЖЕ ДОМА (Tier A), similarity-weighted по площади+комнатам, с premium-uplift и
# hard guardrail. Industry-grounded (Fannie Mae «same-project comps preferred» +
# inverse-adjustment weighting + FSD-as-confidence). Полностью за флагом.
# Street-alias map: ЕКБ-специфичные расхождения между golden/source-адресами и БД.
# Golden «Ткачёва 13» = БД «Ткачей 13» — без алиаса 0 комплов для 5 business-кейсов
# (Clever Park). Ключи/значения уже ё→е-нормализованы и lowercase. Расширяемо.
_STREET_ALIAS_MAP: dict[str, str] = {
"ткачева": "ткачей", # «ул. Ткачёва» (родит. падеж) ↔ БД «ул. Ткачей»
}
# Токен номера дома: ведущее целое + опциональная литера (рус/лат) + опциональный
# корпус «/N». Примеры: «204Г» → (204, 'г'); «4/2» → (4, None); «48» → (48, None).
_HOUSE_TOKEN_RE = re.compile(
r"(?P<num>\d+)\s*(?P<letter>[а-яёa-z])?(?:\s*[/-]\s*\d+)?",
flags=re.IGNORECASE | re.UNICODE,
)
def _normalize_building_key(
address: str | None,
) -> tuple[str | None, int | None, str | None]:
"""Нормализует адрес в ключ «того же дома»: (street_alias, base_house_no, letter).
- ёе, lowercase, отрезаем «г. Екатеринбург»/«ул.»/«пр.»/ (reuse extract_street_name).
- base_house_no = ведущее целое house-токена («204Г»/«4/2»/«9/1» 204/4/9).
- letter = опциональная литера корпуса (204**Г** 'г'); корпус «/N» отбрасываем
(один дом). Литеры РАЗНЫЕ дома в одном ЖК (8 Марта 204Г 204Д), поэтому
при наличии литеры у target матчим именно её (точнее, без ложного слипания).
- street прогоняется через _STREET_ALIAS_MAP (ТкачёваТкачей и т.д.).
Returns (street_norm, base_no, letter) любой элемент None если не извлёкся.
Best-effort: при пустом адресе (None, None, None).
"""
if not address:
return None, None, None
norm = address.replace("ё", "е").replace("Ё", "Е")
street = extract_street_name(norm)
street_norm = street.strip().lower() if street else None
if street_norm:
street_norm = _STREET_ALIAS_MAP.get(street_norm, street_norm)
# Ищем house-токен ПОСЛЕ имени улицы (чтобы «8 Марта» не дало num=8 как дом).
tail = norm
if street:
idx = norm.lower().find(street.lower())
if idx >= 0:
tail = norm[idx + len(street) :]
base_no: int | None = None
letter: str | None = None
m = _HOUSE_TOKEN_RE.search(tail)
if m:
base_no = int(m.group("num"))
letter = m.group("letter").lower() if m.group("letter") else None
return street_norm, base_no, letter
def _fetch_anchor_comps(
db: Session,
*,
address: str | None,
target_house_id: int | None,
lat: float | None,
lon: float | None,
rooms: int | None,
area: float | None,
listing_segment: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
"""Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps.
Tier A SAME BUILDING: normalized street + base house no (+ литера если есть).
RELAXED rooms (без фильтра), БЕЗ area±15%. Не группируем по house_id_fk
один дом дробится на несколько fk (Хохрякова 48 = 7085/9878/12797).
Tier C micro-radius 500m (ST_DWithin) + same listing_segment + rooms match
+ area±25%. (Tier B «тот же ЖК» skip: complex_id/cian_zhk_url ненадёжны.)
Tier D фолбэк: None tier (caller остаётся на радиусном median-пути).
Excludes lots без price_per_m2. is_active=true. Best-effort: ([], None) на ошибке.
Returns (comps, tier) где tier {'A','C', None}. comps list dict с
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None).
"""
min_comps = settings.estimate_sb_min_comps
# ── Tier A: same building ────────────────────────────────────────────────
street, base_no, letter = _normalize_building_key(address)
if street and base_no is not None:
# Numeric-boundary regex: дом 204 не матчит 2040/1204; литера при наличии
# обязательна (204г ≠ 204д). Корпус «/N» допускаем (тот же дом). ё→е в SQL
# для symmetry с нормализатором. psycopg v3: bind через :param, оператор ~.
if letter:
house_re = rf"(^|[^0-9]){base_no}\s*{letter}([^а-яёa-z0-9/]|/|$)"
else:
house_re = rf"(^|[^0-9]){base_no}([^а-яёa-z0-9/]|/|$)"
try:
rows = (
db.execute(
text(
"""
SELECT price_per_m2, area_m2, rooms
FROM listings
WHERE is_active = true
AND price_per_m2 > 0
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
"""
),
{
"street_like": "%" + street + "%",
"house_re": house_re,
},
)
.mappings()
.all()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("anchor Tier A lookup failed (graceful): %s", exc)
try:
db.rollback()
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,
}
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",
street,
base_no,
letter,
len(comps),
)
return comps, "A"
# ── Tier C: micro-radius ≤500m + same segment + rooms + area±25% ─────────
if lat is not None and lon is not None and rooms is not None and area:
try:
rows = (
db.execute(
text(
"""
SELECT price_per_m2, area_m2, rooms
FROM listings
WHERE is_active = true
AND price_per_m2 > 0
AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max
AND (
CAST(:segment AS text) IS NULL
OR listing_segment IS NULL
OR listing_segment = CAST(:segment AS text)
)
AND geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
500
)
"""
),
{
"rooms": rooms,
"area_min": area * 0.75,
"area_max": area * 1.25,
"segment": listing_segment,
"lon": lon,
"lat": lat,
},
)
.mappings()
.all()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("anchor Tier C lookup failed (graceful): %s", exc)
try:
db.rollback()
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,
}
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"
# Tier D — caller fallback (радиусный median-путь без anchor).
return [], None
def _band_haircut(anchor_ppm2: float) -> float:
"""asking→sold haircut, banded по ppm² (class-label в prod пуст — band на цену).
Премиум (высокий ppm²) торгуется плотнее меньше скидка; эконом больше.
Пороги ЕКБ-вторички: 350k 4%; 180-350k 5%; <180k 7%. Дефолт из config.
"""
if anchor_ppm2 >= 350_000:
return 0.04
if anchor_ppm2 >= 180_000:
return settings.asking_to_sold_haircut # 5% mid
return 0.07
def _compute_same_building_anchor(
comps: list[dict[str, Any]],
*,
area_target: float,
rooms_target: int | None,
tier: str,
sigma: float,
rooms_boost: float,
) -> dict[str, Any] | None:
"""Чистая (testable без БД) свёртка комплов в anchor-оценку.
1. similarity-weighted mean ppm²: w_i = exp((ln(area_i/area_target))²/(2σ²))
× (rooms_boost если rooms_i==rooms_target). area_i=None area-вес 1.0
(нейтрально: Yandex-листинги без площади судим только по комнатам).
2. PREMIUM uplift (class-free): если target топ-юнит ДОМА (area_target p66
площадей комплов) И tier == 'A' берём weighted ~p70 верхний квантиль comp
ppm² вместо mean (консервативно, только вверх).
3. haircut askingsold (banded по anchor ppm²): anchor_sold = anchor×(1haircut).
4. FSD = 0.07 + 0.25·CV(comp ppm²) + tier_penalty + n_penalty; range полуширина
= k·fsd. confidence-банд по fsd.
Returns dict {anchor_ppm2, anchor_sold_ppm2, fsd, confidence, n, cv,
comp_min_ppm2, used_uplift, haircut} или None если комплов нет.
"""
if not comps:
return None
ppm2 = [float(c["price_per_m2"]) for c in comps if c.get("price_per_m2")]
if not ppm2:
return None
n = len(ppm2)
# 1. similarity weights
weights: list[float] = []
for c in comps:
a = c.get("area_m2")
if a and area_target > 0:
area_w = math.exp(-((math.log(a / area_target)) ** 2) / (2.0 * sigma * sigma))
else:
area_w = 1.0 # площадь неизвестна → нейтральный area-вес
rooms_match = rooms_target is not None and c.get("rooms") == rooms_target
rooms_w = rooms_boost if rooms_match else 1.0
weights.append(area_w * rooms_w)
wsum = sum(weights)
if wsum > 0:
anchor = sum(w * p for w, p in zip(weights, ppm2, strict=True)) / wsum
else:
anchor = _percentile(sorted(ppm2), 0.5)
# 2. premium uplift — топ-юнит дома по площади (≥p66) И Tier A → weighted p70.
used_uplift = False
areas = [c.get("area_m2") for c in comps if c.get("area_m2")]
if tier == "A" and areas and area_target > 0:
p66_area = _percentile(sorted(areas), 0.66)
if area_target >= p66_area:
p70 = _percentile(sorted(ppm2), 0.70)
if p70 > anchor:
anchor = p70
used_uplift = True
# 3. asking→sold haircut (banded)
haircut = _band_haircut(anchor)
anchor_sold = anchor * (1.0 - haircut)
# 4. FSD-диапазон (tight). CV = std/mean comp ppm².
mean_ppm2 = sum(ppm2) / n
if mean_ppm2 > 0 and n >= 2:
var = sum((p - mean_ppm2) ** 2 for p in ppm2) / n
cv = math.sqrt(var) / mean_ppm2
else:
cv = 0.0
tier_penalty = {"A": 0.0, "C": 0.05}.get(tier, 0.09)
n_penalty = 0.05 if n < 3 else (0.02 if n < 5 else 0.0)
fsd = 0.07 + 0.25 * cv + tier_penalty + n_penalty
if fsd <= 0.13:
confidence = "high"
elif fsd <= 0.20:
confidence = "medium"
else:
confidence = "low"
return {
"anchor_ppm2": anchor,
"anchor_sold_ppm2": anchor_sold,
"fsd": fsd,
"confidence": confidence,
"n": n,
"cv": cv,
"comp_min_ppm2": min(ppm2),
"comp_max_ppm2": max(ppm2),
"used_uplift": used_uplift,
"haircut": haircut,
}
# ── Time-budget guard (#654) ────────────────────────────────────────────────
async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any:
"""Await `coro` under an asyncio.wait_for() time budget.
@ -864,6 +1179,7 @@ async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any:
logger.warning("%s exceeded %.1fs budget — degrading to None (#654)", label, budget_s)
return None
# ── Public ───────────────────────────────────────────────────────────────────
async def estimate_quality(
payload: TradeInEstimateInput, db: Session, created_by: str | None = None
@ -1206,14 +1522,109 @@ async def estimate_quality(
except Exception as exc:
logger.warning("cian_valuation: lookup failed (graceful): %s", exc)
# ── #651: IMV / Yandex BLEND (killer accuracy fix) ──────────────────────
# ── #651/#652 v2: SAME-BUILDING ANCHOR (PRIMARY, validated) ──────────────
# Якорь из комплов ТОГО ЖЕ ДОМА (Tier A) / micro-radius (Tier C). Когда он
# сработал — он ЗАМЕНЯЕТ радиусную медиану для median_price/ppm²/range (premium
# больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое
# поведение. listing_segment для Tier C берём из самого частого среди аналогов.
anchor_tier: str | None = None
if (
settings.estimate_same_building_anchor_enabled
and listings_clean
and median_price > 0
and payload.area_m2
):
seg_counts: dict[str, int] = {}
for lot in listings_clean:
seg = lot.get("listing_segment")
if seg:
seg_counts[seg] = seg_counts.get(seg, 0) + 1
target_segment = max(seg_counts, key=seg_counts.get) if seg_counts else None
comps, anchor_tier = _fetch_anchor_comps(
db,
address=geo.full_address,
target_house_id=target_house_id,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
listing_segment=target_segment,
)
anchor = _compute_same_building_anchor(
comps,
area_target=payload.area_m2,
rooms_target=payload.rooms,
tier=anchor_tier or "",
sigma=settings.estimate_sb_area_sigma,
rooms_boost=settings.estimate_sb_rooms_match_boost,
)
if anchor is not None:
est_ppm2 = anchor["anchor_sold_ppm2"]
# PREMIUM GUARDRAIL (hard): не ниже минимального same-building ppm² (tol).
# Только Tier A/C (комплы реально из дома/микрорайона). Эконом — no-op
# (est уже ≥ floor), премиум — поднимает если mean занизил.
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol)
if est_ppm2 < floor_ppm2:
est_ppm2 = floor_ppm2
# POINT = anchor_sold × area × repair_coef (repair уже применён к старой
# median; здесь применяем к свежему якорю — заменяем headline целиком).
new_ppm2 = est_ppm2 * repair_coef
point = int(new_ppm2 * payload.area_m2)
# FSD-диапазон (tight): симметричный вокруг point, k·fsd полуширина.
half = settings.estimate_fsd_k * anchor["fsd"]
new_range_low = int(point * max(0.0, 1.0 - half))
new_range_high = int(point * (1.0 + half))
# Диапазон должен ПОКРЫВАТЬ same-building спред комплов (sold-adjusted) и
# удовлетворять low ≤ point ≤ high. Внутридомовая дисперсия (этаж/вид) —
# реальный разброс цены в доме; честный диапазон обязан её включать
# (иначе видовой топ-юнит вылетает за range_high — residual miss спека).
# comp spread в ASKING-пространстве (комплы — активные объявления). range_high
# покрывает RAW comp max — честно показываем верх дома (видовой/топ-юнит),
# иначе он вылетает за диапазон. range_low — sold-adjusted нижняя граница.
spread_low = int(anchor["comp_min_ppm2"] * (1.0 - anchor["haircut"]) * payload.area_m2)
spread_high = int(anchor["comp_max_ppm2"] * payload.area_m2)
new_range_low = min(new_range_low, spread_low, point)
new_range_high = max(new_range_high, spread_high, point)
logger.info(
"sb_anchor: tier=%s n=%d radius_median_ppm2=%d → anchor_sold_ppm2=%d"
" (uplift=%s haircut=%.2f) point %d%d",
anchor_tier,
anchor["n"],
int(median_ppm2),
int(est_ppm2),
anchor["used_uplift"],
anchor["haircut"],
median_price,
point,
)
median_ppm2 = new_ppm2
median_price = point
range_low = new_range_low
range_high = new_range_high
confidence = anchor["confidence"]
tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)"
explanation = (explanation or "") + (
f" Оценка построена по {anchor['n']} аналогам из {tier_label}"
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
)
# ── #651: IMV / Yandex BLEND (killer accuracy fix) — SECONDARY, Tier D only ──
# Радиусная медиана системно занижает премиум/видовые квартиры (нет class/
# segment-коррекции). Берём РЕАЛЬНЫЙ Avito IMV target-дома из house_imv_evaluations
# (avito_imv_evaluations пуст — keyed estimate_id, on-demand), используем как
# anchor: если IMV recommended_price > median × threshold — поднимаем медиану
# blend'ом и расширяем верх диапазона. Всё за флагом + null-guard (no-op без IMV).
# ВАЖНО (v2): IMV-blend выполняется ТОЛЬКО когда same-building anchor НЕ сработал
# (anchor_tier is None) — не накладываем blend поверх уже-построенного якоря дома.
avito_imv_summary: AvitoImvSummary | None = None
if settings.estimate_imv_blend_enabled and listings_clean and median_price > 0:
if (
anchor_tier is None
and settings.estimate_imv_blend_enabled
and listings_clean
and median_price > 0
):
imv_anchor = _fetch_house_imv_anchor(
db,
target_house_id=target_house_id,
@ -1266,8 +1677,12 @@ async def estimate_quality(
if blended:
logger.info(
"imv_blend: median %d%d (anchor=%d w=%.2f) range_high %d%d",
median_price, new_median, anchor_used,
settings.estimate_imv_blend_weight, range_high, new_range_high,
median_price,
new_median,
anchor_used,
settings.estimate_imv_blend_weight,
range_high,
new_range_high,
)
median_price = new_median
median_ppm2 = new_ppm2
@ -1279,12 +1694,49 @@ async def estimate_quality(
# Диапазон расширяем даже если медиану не двигали (информативность).
range_high = new_range_high
# 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ #651 IMV-blend, который мог
# поднять median_price/median_ppm2 и расширить range_high. Применяем ratio к
# POST-blend значениям → asking (median_price_rub) и sold (expected_sold_price_rub)
# консистентны в HeroSummary, и в DB persist'ятся свежие значения (no stale 40%
# «скидки»). range_low blend не трогает — берём как есть.
if asking_to_sold_ratio is not None and listings_clean:
# Display-only Avito IMV summary, когда headline построен same-building якорем
# (IMV-blend выше пропущен). Якорь дома — primary; IMV остаётся cross-check в UI.
if anchor_tier is not None and avito_imv_summary is None:
imv_anchor_disp = _fetch_house_imv_anchor(
db,
target_house_id=target_house_id,
rooms=payload.rooms,
area=payload.area_m2,
)
if imv_anchor_disp is not None and imv_anchor_disp.get("recommended_price"):
avito_imv_summary = AvitoImvSummary(
recommended_price=int(imv_anchor_disp["recommended_price"]),
lower_price=(
int(imv_anchor_disp["lower_price"])
if imv_anchor_disp.get("lower_price")
else None
),
higher_price=(
int(imv_anchor_disp["higher_price"])
if imv_anchor_disp.get("higher_price")
else None
),
market_count=(
int(imv_anchor_disp["market_count"])
if imv_anchor_disp.get("market_count")
else None
),
)
# 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ #651 IMV-blend / SB-anchor,
# которые могли поднять median_price/median_ppm2 и расширить range_high. Применяем
# ratio к POST-якорным значениям → asking (median_price_rub) и sold
# (expected_sold_price_rub) консистентны в HeroSummary, и в DB persist'ятся
# свежие значения (no stale «скидки»). range_low берём как есть.
if anchor_tier is not None:
# SB-якорь УЖЕ применил asking→sold haircut (anchor_sold_ppm2) → headline и
# есть sold-релевантная цена. Повторный per-rooms ratio = double-discount,
# поэтому expected_sold == headline (без второй скидки).
expected_sold_per_m2 = int(median_ppm2)
expected_sold_price = median_price
expected_sold_range_low = range_low
expected_sold_range_high = range_high
elif asking_to_sold_ratio is not None and listings_clean:
expected_sold_per_m2 = round(median_ppm2 * asking_to_sold_ratio)
expected_sold_price = round(median_price * asking_to_sold_ratio)
expected_sold_range_low = round(range_low * asking_to_sold_ratio)
@ -1697,7 +2149,7 @@ def extract_street_name(full_address: str | None) -> str | None:
# 1. Keyword-based extraction (работает для обоих форматов: forward и reverse)
m = _STREET_KW_RE.search(s)
if m:
rest = s[m.end():].lstrip()
rest = s[m.end() :].lstrip()
nm = _STREET_NAME_RE.match(rest)
if nm:
return nm.group(1).strip()

View file

@ -0,0 +1,389 @@
"""Unit tests for #651/#652 v2 — same-building anchor (validated, 55 golden cases).
Покрываем чистые helpers (без БД): нормализатор адреса, свёртку комплов в anchor,
hard guardrail; и full estimate path с замоканным `_fetch_anchor_comps`:
(a) premium lift (Хохрякова 48: 399k/472k/684k est ~550k, real 684k в range),
(b) economy NO overshoot (guardrail не раздувает),
(c) address alias («Ткачёва 13» «Ткачей 13»),
(d) base-house-number match через corpus-литеры,
(e) флаг OFF неизменный радиусный результат,
(f) expected_sold консистентен после якоря (no double-discount).
"""
import os
from datetime import UTC, datetime
from typing import Any
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
from app.services.estimator import (
_compute_same_building_anchor,
_normalize_building_key,
)
# ── Address normalizer ──────────────────────────────────────────────────────
def test_normalize_alias_tkacheva_to_tkachei() -> None:
"""golden «Ткачёва 13» нормализуется к БД-улице «ткачей» + дом 13 (ё→е + alias)."""
street, base, letter = _normalize_building_key("Екатеринбург, ул. Ткачёва, 13")
assert street == "ткачей"
assert base == 13
assert letter is None
def test_normalize_base_house_across_corpus_letter() -> None:
"""«8 Марта 204Г» → street «8 марта», base 204, letter 'г' (литера сохранена)."""
street, base, letter = _normalize_building_key("Екатеринбург, улица 8 Марта, 204Г")
assert street == "8 марта"
assert base == 204
assert letter == "г"
def test_normalize_slash_corpus_collapses_to_base() -> None:
"""«4/2», «9/1» → base 4/9, корпус-slash отброшен (тот же дом)."""
_, base1, letter1 = _normalize_building_key("Екатеринбург, ул. Мраморская, 4/2")
assert base1 == 4
assert letter1 is None
_, base2, _ = _normalize_building_key("Екатеринбург, Олимпийская набережная, 9/1")
assert base2 == 9
def test_normalize_empty_address() -> None:
assert _normalize_building_key(None) == (None, None, None)
assert _normalize_building_key("") == (None, None, None)
# ── Pure anchor compute ─────────────────────────────────────────────────────
def _comp(ppm2: int, area: float | None = None, rooms: int | None = None) -> dict[str, Any]:
return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms}
def test_anchor_premium_lift_hohryakova48() -> None:
"""Зеркало Хохрякова 48: комплы 399k/472k/684k, target 4к/146 → est_ppm² поднят,
реал 684k попадает в диапазон. Радиусная медиана сильно ниже якорь её заменяет."""
comps = [
_comp(399_478, area=153.2, rooms=3),
_comp(472_298, area=110.1, rooms=3),
_comp(683_995, area=146.2, rooms=4),
]
res = _compute_same_building_anchor(
comps,
area_target=146.2,
rooms_target=4,
tier="A",
sigma=0.18,
rooms_boost=1.6,
)
assert res is not None
# rooms-boost на флагман-компл (rooms=4 == target) + area-вес тянут anchor к ~566k
# (sim дал ~552k) — заметно выше радиусной медианы (~210k), что и есть фикс.
assert res["anchor_ppm2"] >= 470_000
assert 520_000 <= res["anchor_sold_ppm2"] <= 600_000
# реал 684k должен попасть в диапазон point ± k·fsd (range_high ≥ real×0.95).
point_ppm2 = res["anchor_sold_ppm2"]
half = 1.65 * res["fsd"]
assert point_ppm2 * (1.0 + half) >= 683_995 * 0.95
def test_anchor_olimp13_flagship_area_matched_no_uplift_needed() -> None:
"""Олимп 13 пентхаус: флагман 996k сам area+rooms-matched с target 207.9 →
weighted mean уже 996k (реал 996k), uplift не нужен (был бы избыточен)."""
comps = [
_comp(373_444, area=96.4, rooms=3),
_comp(468_750, area=96.0, rooms=2),
_comp(995_671, area=208.0, rooms=3),
]
res = _compute_same_building_anchor(
comps, area_target=207.9, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6
)
assert res is not None
# Флагман доминирует вес → anchor ≈ 996k, реал 996k подтверждён.
assert res["anchor_ppm2"] >= 900_000
assert res["used_uplift"] is False # mean уже ≥ p70 → uplift избыточен
def test_anchor_premium_uplift_when_mean_dilutes() -> None:
"""premium uplift срабатывает когда премиум-компл НЕ area-similar (mean занижает),
а target топ-юнит дома (p66) И Tier A берём верхний квантиль p70."""
comps = [
_comp(900_000, area=60.0, rooms=1), # дорогой, но малая площадь → area-вес низкий
_comp(300_000, area=200.0, rooms=3),
_comp(320_000, area=210.0, rooms=3),
]
res = _compute_same_building_anchor(
comps, area_target=205.0, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6
)
assert res is not None
assert res["used_uplift"] is True
assert res["anchor_ppm2"] >= 500_000 # подтянут к p70, не к дил. mean ~310k
def test_anchor_uplift_skipped_off_tier_a() -> None:
"""uplift только Tier A — на Tier C (micro-radius) не применяется (комплы не из дома)."""
comps = [
_comp(900_000, area=60.0, rooms=1),
_comp(300_000, area=200.0, rooms=3),
_comp(320_000, area=210.0, rooms=3),
]
res = _compute_same_building_anchor(
comps, area_target=205.0, rooms_target=3, tier="C", sigma=0.18, rooms_boost=1.6
)
assert res is not None
assert res["used_uplift"] is False
def test_anchor_economy_no_overshoot() -> None:
"""Эконом (Ильича 28): дешёвые комплы ~112k → guardrail не раздувает, anchor ~112k."""
comps = [_comp(112_500, area=64.0, rooms=3), _comp(112_500, area=63.0, rooms=3)]
res = _compute_same_building_anchor(
comps,
area_target=63.0,
rooms_target=3,
tier="A",
sigma=0.18,
rooms_boost=1.6,
)
assert res is not None
# mean ~112.5k; haircut эконом-band 7% → sold ~104.6k. НЕ раздут вверх.
assert 100_000 <= res["anchor_sold_ppm2"] <= 113_000
# uplift не сработал (все комплы одной площади, p66≈target, p70≈mean → no lift вверх).
assert res["anchor_sold_ppm2"] < res["anchor_ppm2"] + 1 # haircut только вниз
def test_anchor_guardrail_floor_on_min_comp() -> None:
"""Guardrail-floor (применяется у caller'а) = min(comp)×(1tol); helper отдаёт comp_min."""
comps = [_comp(400_000, area=100.0, rooms=2), _comp(600_000, area=100.0, rooms=2)]
res = _compute_same_building_anchor(
comps, area_target=100.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6
)
assert res is not None
assert res["comp_min_ppm2"] == 400_000
def test_anchor_none_when_no_comps() -> None:
assert (
_compute_same_building_anchor(
[], area_target=50.0, rooms_target=1, tier="A", sigma=0.18, rooms_boost=1.6
)
is None
)
def test_anchor_null_area_neutral_weight() -> None:
"""Комплы без площади (Yandex) судятся только по комнатам — area-вес 1.0, не падает."""
comps = [_comp(300_000, area=None, rooms=2), _comp(320_000, area=None, rooms=2)]
res = _compute_same_building_anchor(
comps, area_target=70.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6
)
assert res is not None
# rooms совпали у обоих → equal weights → anchor ≈ mean 310k, haircut mid 5%.
assert abs(res["anchor_ppm2"] - 310_000) < 1_000
# ── Full estimate path (mocked I/O) ─────────────────────────────────────────
def _make_listing(*, price_per_m2: float, area_m2: float = 60.0) -> dict[str, Any]:
return {
"source": "cian",
"source_url": "https://cian.ru/offer/1",
"address": "ЕКБ, ул. Хохрякова, 48",
"lat": 56.830,
"lon": 60.592,
"rooms": 4,
"area_m2": area_m2,
"floor": 5,
"total_floors": 14,
"price_rub": price_per_m2 * area_m2,
"price_per_m2": price_per_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 10,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 0.0,
"relevance_score": 0.0,
"listing_segment": "premium",
}
# Радиусные аналоги — НИЗКИЕ (массовая застройка рядом размывает премиум).
_RADIUS_ANALOGS: list[dict[str, Any]] = [
_make_listing(price_per_m2=200_000.0),
_make_listing(price_per_m2=210_000.0),
_make_listing(price_per_m2=220_000.0),
]
# Same-building комплы Хохрякова 48 (флагман 684k внутри).
_SB_COMPS_PREMIUM: list[dict[str, Any]] = [
{"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3},
{"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3},
{"price_per_m2": 683_995, "area_m2": 146.2, "rooms": 4},
]
def _make_fake_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.830,
lon=60.592,
full_address="Свердловская обл., Екатеринбург, ул. Хохрякова, 48",
provider="nominatim",
)
def _make_payload(area: float = 146.2, rooms: int = 4):
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="Екатеринбург, ул. Хохрякова, 48",
area_m2=area,
rooms=rooms,
floor=5,
total_floors=14,
)
def _run_estimate(
*,
anchor_comps: list[dict[str, Any]],
anchor_tier: str | None,
flag_enabled: bool = True,
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
payload=None,
):
"""estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован."""
from app.core.config import settings
from app.services.estimator import estimate_quality
db = MagicMock()
pl = payload or _make_payload()
async def _run():
with (
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(_RADIUS_ANALOGS), False, "W"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
patch(
"app.services.estimator._fetch_anchor_comps",
return_value=(list(anchor_comps), anchor_tier),
),
patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
patch(
"app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=ratio_tuple),
):
return await estimate_quality(pl, db)
return anyio.run(_run)
def test_estimate_premium_lift_real_in_range() -> None:
"""(a) Хохрякова 48: радиус ~210k размывает → якорь поднимает, реал 684k в range."""
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
radius_median = int(210_000 * 146.2) # старый радиусный headline
# Якорь заменил радиусную медиану и поднял её.
assert est.median_price_rub > radius_median
assert est.median_price_per_m2 >= 450_000
# Реальная цена флагмана 684k×146.2 ≈ 100М должна попасть в [range_low, range_high].
real = int(683_995 * 146.2)
assert est.range_low_rub <= est.median_price_rub <= est.range_high_rub
assert est.range_high_rub >= int(real * 0.9)
def test_estimate_economy_no_regression() -> None:
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов."""
eco_comps = [
{"price_per_m2": 112_500, "area_m2": 64.0, "rooms": 3},
{"price_per_m2": 112_500, "area_m2": 63.0, "rooms": 3},
]
est = _run_estimate(
anchor_comps=eco_comps,
anchor_tier="A",
payload=_make_payload(area=63.0, rooms=3),
)
# ~112.5k × haircut 7% × area; НЕ раздут вверх (overshoot-guard).
assert est.median_price_per_m2 <= 113_000
assert est.median_price_per_m2 >= 100_000
def test_estimate_flag_off_unchanged_radius_result() -> None:
"""(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает."""
est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False)
# Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k.
assert est_off.median_price_per_m2 == 210_000
assert est_off.median_price_rub == int(210_000 * 146.2)
def test_estimate_expected_sold_consistency_after_anchor() -> None:
"""(f) При сработавшем якоре expected_sold == headline (haircut уже внутри —
no double-discount per-rooms ratio'м)."""
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
assert est.expected_sold_price_rub == est.median_price_rub
assert est.expected_sold_per_m2 == est.median_price_per_m2
assert est.expected_sold_range_high_rub == est.range_high_rub
assert est.expected_sold_range_low_rub == est.range_low_rub
def test_estimate_tier_d_fallback_keeps_radius() -> None:
"""anchor_tier=None (Tier D) → headline остаётся радиусной медианой (210k)."""
est = _run_estimate(anchor_comps=[], anchor_tier=None)
assert est.median_price_per_m2 == 210_000
def test_estimate_range_covers_same_building_comp_spread() -> None:
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
вылетает за диапазон зеркало 8 Марта 204Г view-кейса)."""
# comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой.
view_comps = [
{"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2},
{"price_per_m2": 200_000, "area_m2": 63.0, "rooms": 2},
{"price_per_m2": 255_459, "area_m2": 34.8, "rooms": 1},
]
est = _run_estimate(
anchor_comps=view_comps,
anchor_tier="A",
payload=_make_payload(area=63.4, rooms=2),
)
# range_high покрывает comp max в asking-пространстве (255_459 × area).
assert est.range_high_rub >= int(255_459 * 63.4)
def test_anchor_exposes_comp_max() -> None:
"""anchor dict отдаёт comp_max_ppm2 (нужен caller'у для spread-coverage)."""
res = _compute_same_building_anchor(
[_comp(300_000, area=60.0, rooms=2), _comp(500_000, area=60.0, rooms=2)],
area_target=60.0,
rooms_target=2,
tier="A",
sigma=0.18,
rooms_boost=1.6,
)
assert res is not None
assert res["comp_max_ppm2"] == 500_000
assert res["comp_min_ppm2"] == 300_000