fix(estimator): asking_to_sold ratio tier по финальному headline

_get_asking_sold_ratio теперь вызывается ПОСЛЕ anchor/IMV-blend/quarter-index/
corridor-clamp, получая ФИНАЛЬНЫЙ median_ppm2 для tier-placement (#928 audit).

До фикса: tier резолвился по pre-anchor radius-медиане (~105k), но ratio
применялся к post-anchor headline (~300k) — несовпадение tier'а в premium-
сегменте. После: ratio берётся из того же tier, что и финальный headline.

Graceful: нет ratio → expected_sold_* = None, headline не меняется.
This commit is contained in:
bot-backend 2026-06-21 00:24:02 +03:00
parent 01c91a9ea3
commit 352dfc8053
2 changed files with 339 additions and 28 deletions

View file

@ -2160,28 +2160,23 @@ async def estimate_quality(
# NOTE: actual_deals (#564) остаётся ИНФОРМАЦИОННЫМ и НЕ подмешивается в
# headline — sold-коррекция здесь единственный sold-сигнал (без double-count).
# NOTE: expected_sold_* (= asking × ratio) выводятся НЕ здесь, а ПОСЛЕ #651
# IMV-blend (ниже), который мутирует median_price/median_ppm2/range_high. Иначе
# expected_sold остаётся pre-blend → asking 75M / sold 45M (бессмысленная скидка
# в HeroSummary) и stale-значения persist'ятся в trade_in_estimates. Здесь только
# резолвим ratio/basis (нужны для confidence/explanation и null-guard).
# #928: pass median_ppm2 (best proxy available at this point — anchor_ppm2 from
# same-building anchor computed below, but that's post-call). median_ppm2 = 0 when
# no radius analogs yet; tier lookup uses it as the ppm2 placement signal.
# NOTE(#928): tier placement uses the pre-anchor radius median (median_ppm2), not the
# same-building Tukey anchor that #928 ideally specifies (anchor is computed post-call).
# Coarse 3-tier bucketing is robust to this for the golden case; revisit (resolve ratio
# after anchor) before flipping tier_aware_ratio_enabled ON — validate in the held-out
# backtest.
asking_to_sold_ratio, ratio_basis = _get_asking_sold_ratio(
db, payload.rooms, anchor_ppm2=median_ppm2 if median_ppm2 > 0 else None
)
# IMV-blend / SB-anchor / #1795 corridor-clamp / radius-floor (ниже), которые
# мутируют median_price/median_ppm2/range_high. Иначе expected_sold остаётся
# pre-blend → asking 75M / sold 45M (бессмысленная скидка в HeroSummary) и
# stale-значения persist'ятся в trade_in_estimates.
# FIX(ratio-tier-mismatch): _get_asking_sold_ratio вызывается ПОСЛЕ всех
# headline-мутаций (anchor/IMV-blend/quarter-index/corridor-clamp), передавая
# ФИНАЛЬНЫЙ median_ppm2 для tier-placement. Это устраняет tier-несовпадение
# когда anchor/quarter-index двигают headline в другой tier — ratio теперь
# соответствует тому ppm², к которому он будет применён.
# Инициализируем переменные здесь; реальный _get_asking_sold_ratio вызов — после
# corridor-clamp / radius-floor (ниже, ~строка 4c-cont).
asking_to_sold_ratio: float | None = None
ratio_basis: str | None = None
expected_sold_per_m2: int | None = None
expected_sold_price: int | None = None
expected_sold_range_low: int | None = None
expected_sold_range_high: int | None = None
# Не было ratio (нет таблицы/бакета) — не вводим в заблуждение пустым basis.
if asking_to_sold_ratio is None:
ratio_basis = None
confidence, explanation = _compute_confidence(
n_analogs,
@ -2799,17 +2794,22 @@ async def estimate_quality(
" Оценка ограничена коридором реальных сделок Росреестра по улице."
)
# 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ #651 IMV-blend / SB-anchor /
# #1795 corridor-clamp, которые могли поднять/опустить median_price/median_ppm2
# и range_high. Применяем ratio к POST-якорным значениям → asking
# (median_price_rub) и sold (expected_sold_price_rub) консистентны в HeroSummary,
# и в DB persist'ятся свежие значения (no stale «скидки»). range_low берём как есть.
# Headline (median_price/ppm²/range) — ASKING-space во ВСЕХ ветках (радиус,
# SB-якорь, IMV-blend): якорь теперь берёт anchor_ppm2 (pre-haircut), blend
# работает в asking. asking→sold скидка — ЕДИНСТВЕННЫМ механизмом per-rooms
# ratio: expected_sold = headline × ratio → DISTINCT, строго ниже median (когда
# ratio<1). Null-guard: нет ratio (нет migration-080 строки) → expected_sold_*
# 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ всех headline-мутаций:
# #651 IMV-blend / SB-anchor / quarter-index / #1795 corridor-clamp / radius-floor.
# FIX(ratio-tier-mismatch): _get_asking_sold_ratio вызывается с ФИНАЛЬНЫМ
# median_ppm2 (а не с pre-anchor radius-медианой). Это устраняет tier-несовпадение
# когда anchor/quarter-index двигают headline в другой ppm2-tier.
# Сохраняем per_rooms/global_fallback семантику ratio_basis из самой функции.
# Graceful: нет ratio (нет migration-080 строки / БД-ошибка) → expected_sold_*
# остаются None → UI не показывает «N%» badge (не фабрикуем).
if median_ppm2 > 0:
asking_to_sold_ratio, ratio_basis = _get_asking_sold_ratio(
db, payload.rooms, anchor_ppm2=median_ppm2
)
# Не было ratio — не вводим в заблуждение пустым basis.
if asking_to_sold_ratio is None:
ratio_basis = None
if asking_to_sold_ratio is not None and median_price > 0:
expected_sold_per_m2 = round(median_ppm2 * asking_to_sold_ratio)
expected_sold_price = round(median_price * asking_to_sold_ratio)

View file

@ -0,0 +1,311 @@
"""Tests for Fix 1: asking_to_sold ratio tier resolved from FINAL headline ppm².
Проверяем, что _get_asking_sold_ratio вызывается ПОСЛЕ всех headline-мутаций
(anchor/IMV-blend/quarter-index/corridor-clamp) с финальным median_ppm2, а не с
pre-anchor радиусной медианой. Это устраняет tier-несовпадение (#928 audit fix).
Два слоя:
1. Стохастический: _get_asking_sold_ratio не вызывается в ранней части estimate_quality
(только после corridor-clamp/radius-floor).
2. Функциональный: когда anchor поднял headline в другой tier, ratio берётся
по финальному ppm² (из нового tier), а не по исходной радиусной медиане.
Graceful: нет ratio expected_sold_* == None, headline не изменён.
"""
from __future__ import annotations
import os
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
return {
"source": "cian",
"source_url": "https://cian.ru/offer/1",
"address": "ЕКБ, ул. Учителей, 18",
"lat": 56.838,
"lon": 60.595,
"rooms": 2,
"area_m2": area_m2,
"floor": 5,
"total_floors": 16,
"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": 150.0,
"relevance_score": 0.1,
}
_ANALOGS_LOW: list[dict[str, Any]] = [
_make_listing(price_per_m2=100_000.0),
_make_listing(price_per_m2=110_000.0),
_make_listing(price_per_m2=105_000.0),
]
_ANALOGS_HIGH: list[dict[str, Any]] = [
_make_listing(price_per_m2=300_000.0),
_make_listing(price_per_m2=310_000.0),
_make_listing(price_per_m2=305_000.0),
]
def _make_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
provider="nominatim",
)
def _make_payload(rooms: int = 2):
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=50.0,
rooms=rooms,
floor=5,
total_floors=16,
)
def _run_estimate_with_ratio_spy(
analogs: list[dict[str, Any]],
ratio_return: tuple[float | None, str | None],
) -> tuple[Any, list[Any]]:
"""Запускает estimate_quality с отслеживанием вызовов _get_asking_sold_ratio.
Возвращает (estimate_result, list_of_call_args).
"""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
ratio_calls: list[Any] = []
def _spy_ratio(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> Any:
ratio_calls.append({"rooms": rooms, "anchor_ppm2": anchor_ppm2})
return ratio_return
async def _run() -> Any:
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_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(analogs), False, "S"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
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._fetch_dkp_corridor", return_value=None),
patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy_ratio),
):
return await estimate_quality(payload, db)
est = anyio.run(_run)
return est, ratio_calls
# ── тест 1: ratio вызывается РОВНО ОДИН РАЗ (не дважды — до и после headline) ──
def test_ratio_called_exactly_once() -> None:
"""_get_asking_sold_ratio должен вызываться ровно один раз — после headline."""
_est, calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (0.80, "per_rooms"))
assert (
len(calls) == 1
), f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз"
# ── тест 2: anchor поднял headline → ratio вызван с финальным (high) ppm² ────
def test_ratio_tier_uses_final_headline_after_anchor() -> None:
"""Когда anchor поднял median_ppm2 с ~105k до ~300k, ratio вызывается с ~300k."""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
# Симулируем: radius median = 105k, anchor поднимает до 300k.
# _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k).
captured_anchor_ppm2: list[float | None] = []
def _spy(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> tuple:
captured_anchor_ppm2.append(anchor_ppm2)
return (0.78, "per_rooms_tier:high")
async def _run() -> Any:
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_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)),
# Radius analogs дают медиану ~105k.
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(_ANALOGS_LOW), False, "S"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
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._fetch_dkp_corridor", return_value=None),
# Same-building anchor поднимает headline до ~300k.
patch(
"app.services.estimator._fetch_anchor_comps",
return_value=(
[
{
"price_per_m2": 300_000,
"area_m2": 50.0,
"rooms": 2,
"source": "cian",
"source_url": "u",
"address": "a",
"lat": 56.838,
"lon": 60.595,
"floor": 5,
"total_floors": 16,
"listing_date": datetime(2026, 5, 1),
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 5.0,
"relevance_score": 0.9,
"price_rub": 15_000_000,
"building_cadastral_number": None,
"days_on_market": 5,
},
{
"price_per_m2": 305_000,
"area_m2": 50.0,
"rooms": 2,
"source": "cian",
"source_url": "u2",
"address": "a",
"lat": 56.838,
"lon": 60.595,
"floor": 6,
"total_floors": 16,
"listing_date": datetime(2026, 5, 1),
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 6.0,
"relevance_score": 0.9,
"price_rub": 15_250_000,
"building_cadastral_number": None,
"days_on_market": 5,
},
{
"price_per_m2": 298_000,
"area_m2": 50.0,
"rooms": 2,
"source": "cian",
"source_url": "u3",
"address": "a",
"lat": 56.838,
"lon": 60.595,
"floor": 4,
"total_floors": 16,
"listing_date": datetime(2026, 5, 1),
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 7.0,
"relevance_score": 0.9,
"price_rub": 14_900_000,
"building_cadastral_number": None,
"days_on_market": 5,
},
{
"price_per_m2": 302_000,
"area_m2": 50.0,
"rooms": 2,
"source": "cian",
"source_url": "u4",
"address": "a",
"lat": 56.838,
"lon": 60.595,
"floor": 7,
"total_floors": 16,
"listing_date": datetime(2026, 5, 1),
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 8.0,
"relevance_score": 0.9,
"price_rub": 15_100_000,
"building_cadastral_number": None,
"days_on_market": 5,
},
],
"A",
),
),
patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy),
):
return await estimate_quality(payload, db)
anyio.run(_run)
assert len(captured_anchor_ppm2) == 1
ppm2_used = captured_anchor_ppm2[0]
assert ppm2_used is not None
# Финальный headline должен быть в зоне anchor (~300k), а НЕ в зоне radius (~105k).
assert (
ppm2_used > 200_000
), f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}"
# ── тест 3: graceful — нет ratio → expected_sold_* = None, headline не изменён ──
def test_ratio_graceful_none_no_expected_sold() -> None:
"""Если ratio = None → все expected_sold_* == None, headline в норме."""
est, _calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (None, None))
assert est.expected_sold_price_rub is None
assert est.expected_sold_per_m2 is None
assert est.expected_sold_range_low_rub is None
assert est.expected_sold_range_high_rub is None
assert est.asking_to_sold_ratio is None
assert est.ratio_basis is None
# Headline не изменён.
assert est.median_price_rub > 0