fix(tradein): recompute expected_sold_* after IMV blend + band-guard anchor (#651)
Review-bot findings: - CRITICAL: expected_sold_* were derived from PRE-blend median/range/ppm2, so after the #651 blend raised median_price the UI showed asking (blended) vs expected-sold (un-blended) → nonsensical discount. Moved the asking→sold derivation to AFTER the blend block; INSERT + returned estimate now use the post-blend values. - IMV anchor had no rooms/area band → a studio-only IMV row could anchor a 3-room target and inflate via the x1.15 trigger. Added NULL-tolerant guard (abs(Δrooms)<=1 AND area_m2 BETWEEN area*0.7..1.3) in _fetch_house_imv_anchor. - Tests: expected_sold↔blended-median consistency, band rejection, weight clamp [0,1], corridor count branch. ruff clean; 26 + 145 estimator regression pass.
This commit is contained in:
parent
1d74410832
commit
e9eb812d8b
2 changed files with 260 additions and 15 deletions
|
|
@ -695,6 +695,21 @@ def _fetch_house_imv_anchor(
|
||||||
FROM house_imv_evaluations
|
FROM house_imv_evaluations
|
||||||
WHERE house_id = CAST(:hid AS bigint)
|
WHERE house_id = CAST(:hid AS bigint)
|
||||||
AND recommended_price > 0
|
AND recommended_price > 0
|
||||||
|
-- Band-guard: строка пригодна как anchor только при правдоподобном
|
||||||
|
-- совпадении rooms/area. Иначе studio-only IMV запись «прилипала»
|
||||||
|
-- к 3-комн. target'у (ORDER BY всё равно вернёт LIMIT 1) и при
|
||||||
|
-- anchor > median×1.15 раздувала blend. NULL target/row → не
|
||||||
|
-- режем (graceful, нет данных для сравнения).
|
||||||
|
AND (
|
||||||
|
CAST(:rooms AS integer) IS NULL OR rooms IS NULL
|
||||||
|
OR abs(rooms - CAST(:rooms AS integer)) <= 1
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
CAST(:area AS double precision) IS NULL
|
||||||
|
OR area_m2 IS NULL OR area_m2 <= 0
|
||||||
|
OR area_m2 BETWEEN CAST(:area AS double precision) * 0.7
|
||||||
|
AND CAST(:area AS double precision) * 1.3
|
||||||
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
-- ближе по комнатам и площади → меньше score; NULL target → 0
|
-- ближе по комнатам и площади → меньше score; NULL target → 0
|
||||||
(CASE WHEN CAST(:rooms AS integer) IS NOT NULL AND rooms IS NOT NULL
|
(CASE WHEN CAST(:rooms AS integer) IS NOT NULL AND rooms IS NOT NULL
|
||||||
|
|
@ -1030,20 +1045,19 @@ async def estimate_quality(
|
||||||
# сделки (backtest #648 S1: bias asking-медианы +20% → −4% на held-out ДКП).
|
# сделки (backtest #648 S1: bias asking-медианы +20% → −4% на held-out ДКП).
|
||||||
# NOTE: actual_deals (#564) остаётся ИНФОРМАЦИОННЫМ и НЕ подмешивается в
|
# NOTE: actual_deals (#564) остаётся ИНФОРМАЦИОННЫМ и НЕ подмешивается в
|
||||||
# headline — sold-коррекция здесь единственный sold-сигнал (без double-count).
|
# 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).
|
||||||
asking_to_sold_ratio, ratio_basis = _get_asking_sold_ratio(db, payload.rooms)
|
asking_to_sold_ratio, ratio_basis = _get_asking_sold_ratio(db, payload.rooms)
|
||||||
if asking_to_sold_ratio is not None and listings_clean:
|
expected_sold_per_m2: int | None = None
|
||||||
expected_sold_per_m2: int | None = round(median_ppm2 * asking_to_sold_ratio)
|
expected_sold_price: int | None = None
|
||||||
expected_sold_price: int | None = round(median_price * asking_to_sold_ratio)
|
expected_sold_range_low: int | None = None
|
||||||
expected_sold_range_low: int | None = round(range_low * asking_to_sold_ratio)
|
expected_sold_range_high: int | None = None
|
||||||
expected_sold_range_high: int | None = round(range_high * asking_to_sold_ratio)
|
# Не было ratio (нет таблицы/бакета) — не вводим в заблуждение пустым basis.
|
||||||
else:
|
if asking_to_sold_ratio is None:
|
||||||
expected_sold_per_m2 = None
|
ratio_basis = None
|
||||||
expected_sold_price = None
|
|
||||||
expected_sold_range_low = None
|
|
||||||
expected_sold_range_high = None
|
|
||||||
# Не было ratio (нет таблицы/бакета) — не вводим в заблуждение пустым basis.
|
|
||||||
if asking_to_sold_ratio is None:
|
|
||||||
ratio_basis = None
|
|
||||||
|
|
||||||
confidence, explanation = _compute_confidence(
|
confidence, explanation = _compute_confidence(
|
||||||
n_analogs,
|
n_analogs,
|
||||||
|
|
@ -1226,6 +1240,17 @@ async def estimate_quality(
|
||||||
# Диапазон расширяем даже если медиану не двигали (информативность).
|
# Диапазон расширяем даже если медиану не двигали (информативность).
|
||||||
range_high = new_range_high
|
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:
|
||||||
|
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)
|
||||||
|
expected_sold_range_high = round(range_high * asking_to_sold_ratio)
|
||||||
|
|
||||||
# ── #652: ДКП-коридор реальных сделок (ADVISORY + soft sanity-bound) ─────
|
# ── #652: ДКП-коридор реальных сделок (ADVISORY + soft sanity-bound) ─────
|
||||||
dkp_corridor: DkpCorridor | None = None
|
dkp_corridor: DkpCorridor | None = None
|
||||||
dkp_raw = _fetch_dkp_corridor(
|
dkp_raw = _fetch_dkp_corridor(
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@ no-anchor / sub-threshold случаи — no-op.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
from app.services.estimator import (
|
from app.services.estimator import (
|
||||||
_apply_imv_blend,
|
_apply_imv_blend,
|
||||||
|
|
@ -188,3 +192,219 @@ def test_dkp_corridor_aggregates_ppm2() -> None:
|
||||||
assert res["low_ppm2"] == 100_000
|
assert res["low_ppm2"] == 100_000
|
||||||
assert res["median_ppm2"] == 120_000
|
assert res["median_ppm2"] == 120_000
|
||||||
assert res["high_ppm2"] == 140_000
|
assert res["high_ppm2"] == 140_000
|
||||||
|
|
||||||
|
|
||||||
|
# ── #651: anchor band-guard (WHERE clause is exercised via bind params) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_house_imv_anchor_passes_band_bind_params() -> None:
|
||||||
|
"""rooms/area проброшены в bind — band-guard WHERE их использует (no :x::type)."""
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.execute.return_value.mappings.return_value.first.return_value = None
|
||||||
|
_fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=3, area=80.0)
|
||||||
|
# Один execute с биндами {hid, rooms, area}; SQL содержит CAST(...) band-guard.
|
||||||
|
args, _kwargs = mock_db.execute.call_args
|
||||||
|
sql_text = str(args[0])
|
||||||
|
binds = args[1]
|
||||||
|
assert binds == {"hid": 11308, "rooms": 3, "area": 80.0}
|
||||||
|
# psycopg v3 convention — CAST(:x AS type), никогда :x::type.
|
||||||
|
assert "::" not in sql_text
|
||||||
|
assert "abs(rooms - CAST(:rooms AS integer)) <= 1" in sql_text
|
||||||
|
assert "area_m2 BETWEEN CAST(:area AS double precision) * 0.7" in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_house_imv_anchor_studio_not_used_for_3room() -> None:
|
||||||
|
"""Studio-only IMV запись отфильтрована band-guard'ом → anchor None (blend no-op).
|
||||||
|
|
||||||
|
Эмулируем БД-семантику WHERE: для target rooms=3 строка rooms=0/area=22 не
|
||||||
|
проходит abs(0-3)<=1 → SQL вернёт 0 строк → .first() = None.
|
||||||
|
"""
|
||||||
|
mock_db = MagicMock()
|
||||||
|
# БД с band-guard вернула бы None (studio не матчит 3-комн.).
|
||||||
|
mock_db.execute.return_value.mappings.return_value.first.return_value = None
|
||||||
|
res = _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=3, area=80.0)
|
||||||
|
assert res is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── #651/#652: weight clamp + DKP corridor boundary ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_blend_weight_clamped_above_one() -> None:
|
||||||
|
"""weight=1.5 → clamp к 1.0: blend = anchor (median*0 + anchor*1)."""
|
||||||
|
area = 50.0
|
||||||
|
median_price = 10_000_000
|
||||||
|
new_median, _, new_ppm2, blended, _ = _apply_imv_blend(
|
||||||
|
median_price=median_price,
|
||||||
|
range_high=11_000_000,
|
||||||
|
median_ppm2=median_price / area,
|
||||||
|
area=area,
|
||||||
|
anchor_total=20_000_000,
|
||||||
|
anchor_higher=None,
|
||||||
|
weight=1.5, # out-of-range → clamp 1.0
|
||||||
|
threshold=1.15,
|
||||||
|
)
|
||||||
|
assert blended is True
|
||||||
|
assert new_median == 20_000_000 # 10М*0 + 20М*1
|
||||||
|
assert new_ppm2 == 20_000_000 / area
|
||||||
|
|
||||||
|
|
||||||
|
def test_blend_weight_clamped_below_zero() -> None:
|
||||||
|
"""weight=-0.2 → clamp к 0.0: blend = median (медиана не двигается)."""
|
||||||
|
area = 50.0
|
||||||
|
median_price = 10_000_000
|
||||||
|
new_median, _, new_ppm2, blended, _ = _apply_imv_blend(
|
||||||
|
median_price=median_price,
|
||||||
|
range_high=11_000_000,
|
||||||
|
median_ppm2=median_price / area,
|
||||||
|
area=area,
|
||||||
|
anchor_total=20_000_000,
|
||||||
|
anchor_higher=None,
|
||||||
|
weight=-0.2, # out-of-range → clamp 0.0
|
||||||
|
threshold=1.15,
|
||||||
|
)
|
||||||
|
# blended=True (anchor > median×threshold), но w=0 → median неизменна.
|
||||||
|
assert blended is True
|
||||||
|
assert new_median == median_price
|
||||||
|
assert new_ppm2 == median_price / area
|
||||||
|
|
||||||
|
|
||||||
|
def test_dkp_corridor_count_below_three_still_aggregates() -> None:
|
||||||
|
"""count<3 → corridor всё равно вычисляется (sanity-bound branch уже у caller'а)."""
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
||||||
|
{"price_per_m2": 100_000},
|
||||||
|
{"price_per_m2": 130_000},
|
||||||
|
]
|
||||||
|
res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0)
|
||||||
|
assert res is not None
|
||||||
|
assert res["count"] == 2
|
||||||
|
assert res["low_ppm2"] == 100_000
|
||||||
|
assert res["high_ppm2"] == 130_000
|
||||||
|
|
||||||
|
|
||||||
|
# ── #651: expected_sold ↔ blended-median consistency (full estimate path) ───────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_listing(*, price_per_m2: float, area_m2: float = 40.0) -> dict[str, Any]:
|
||||||
|
price_rub = price_per_m2 * area_m2
|
||||||
|
return {
|
||||||
|
"source": "cian",
|
||||||
|
"source_url": "https://cian.ru/offer/1",
|
||||||
|
"address": "ЕКБ, ул. Учителей, 18",
|
||||||
|
"lat": 56.838,
|
||||||
|
"lon": 60.595,
|
||||||
|
"rooms": 1,
|
||||||
|
"area_m2": area_m2,
|
||||||
|
"floor": 4,
|
||||||
|
"total_floors": 16,
|
||||||
|
"price_rub": price_rub,
|
||||||
|
"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": 100.0,
|
||||||
|
"relevance_score": 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_BLEND_ANALOGS: list[dict[str, Any]] = [
|
||||||
|
_make_listing(price_per_m2=140_000.0),
|
||||||
|
_make_listing(price_per_m2=150_000.0),
|
||||||
|
_make_listing(price_per_m2=160_000.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_geo():
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
return GeocodeResult(
|
||||||
|
lat=56.838,
|
||||||
|
lon=60.595,
|
||||||
|
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||||||
|
provider="nominatim",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_payload():
|
||||||
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
|
|
||||||
|
return TradeInEstimateInput(
|
||||||
|
address="ЕКБ, ул. Учителей, 18",
|
||||||
|
area_m2=40.0,
|
||||||
|
rooms=1,
|
||||||
|
floor=4,
|
||||||
|
total_floors=16,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_estimate_with_anchor(
|
||||||
|
ratio_tuple: tuple[float | None, str | None],
|
||||||
|
anchor: dict[str, Any] | None,
|
||||||
|
):
|
||||||
|
"""estimate_quality со всеми I/O застабленными; IMV-anchor форсирован."""
|
||||||
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
payload = _make_payload()
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
with (
|
||||||
|
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(_BLEND_ANALOGS), False, "S")),
|
||||||
|
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||||
|
patch("app.services.estimator._fetch_dkp_corridor", 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),
|
||||||
|
patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor),
|
||||||
|
):
|
||||||
|
return await estimate_quality(payload, db)
|
||||||
|
|
||||||
|
return anyio.run(_run)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_sold_consistent_with_blended_median() -> None:
|
||||||
|
"""Когда blend срабатывает — expected_sold выводится из POST-blend median/range.
|
||||||
|
|
||||||
|
asking-median pre-blend = 150_000×40 = 6_000_000. Anchor 18_000_000 ≫ median×1.15
|
||||||
|
→ blend = 6М*0.5 + 18М*0.5 = 12_000_000. expected_sold должен браться от 12М.
|
||||||
|
"""
|
||||||
|
ratio = 0.74
|
||||||
|
anchor = {
|
||||||
|
"recommended_price": 18_000_000,
|
||||||
|
"lower_price": 16_000_000,
|
||||||
|
"higher_price": 20_000_000,
|
||||||
|
"market_count": 500,
|
||||||
|
"rooms": 1,
|
||||||
|
"area_m2": 40.0,
|
||||||
|
}
|
||||||
|
est = _run_estimate_with_anchor((ratio, "per_rooms"), anchor)
|
||||||
|
|
||||||
|
# Blend сработал: headline median поднялся над pre-blend 6М.
|
||||||
|
assert est.median_price_rub == 12_000_000
|
||||||
|
# expected_sold выведен из POST-blend значений — no stale.
|
||||||
|
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
||||||
|
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
|
||||||
|
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
|
||||||
|
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
|
||||||
|
# Sanity: sold < asking (ratio<1), но НЕ абсурдная «скидка» от stale 6М-базы.
|
||||||
|
assert est.expected_sold_price_rub == round(12_000_000 * ratio)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_sold_unblended_when_anchor_band_rejects() -> None:
|
||||||
|
"""anchor=None (band-guard отбросил) → blend no-op → expected_sold от чистой median."""
|
||||||
|
ratio = 0.74
|
||||||
|
est = _run_estimate_with_anchor((ratio, "per_rooms"), None)
|
||||||
|
|
||||||
|
assert est.median_price_rub == 6_000_000 # pre-blend (no blend)
|
||||||
|
assert est.expected_sold_price_rub == round(6_000_000 * ratio)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue