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:
parent
be63b249b5
commit
bd7f40428a
3 changed files with 870 additions and 14 deletions
|
|
@ -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²)×(1−tol)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 asking→sold (banded по anchor ppm²): anchor_sold = anchor×(1−haircut).
|
||||
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()
|
||||
|
|
|
|||
389
tradein-mvp/backend/tests/test_same_building_anchor.py
Normal file
389
tradein-mvp/backend/tests/test_same_building_anchor.py
Normal 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)×(1−tol); 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
|
||||
Loading…
Add table
Reference in a new issue