fix(estimator): expected_sold guard listings_clean → median_price>0 (Refs #773)

anchor-only оценки (same-building anchor без radius-comps) имели median_price>0
но expected_sold=NULL, т.к. guard проверял listings_clean (пуст) вместо
median_price>0. Чинит 7.5% (24/322) оценок без выкупной цены. median_price
в scope (init 1538/1544, mutated anchor 1806, IMV-blend 1904) до guard'а (1954).
Формула не менялась, только условие. IMV-blend gate (другой listings_clean,
1842) не тронут. 2 регресс-теста (anchor-only→>0, median=0→None). ruff clean,
pytest -k expected_sold 16 pass.
This commit is contained in:
bot-backend 2026-05-30 19:35:18 +03:00
parent 238f14a0d7
commit b547fca673
2 changed files with 108 additions and 14 deletions

View file

@ -1951,7 +1951,7 @@ async def estimate_quality(
# ratio: expected_sold = headline × ratio → DISTINCT, строго ниже median (когда
# ratio<1). Null-guard: нет ratio (нет migration-080 строки) → expected_sold_*
# остаются None → UI не показывает «N%» badge (не фабрикуем).
if asking_to_sold_ratio is not None and listings_clean:
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)
expected_sold_range_low = round(range_low * asking_to_sold_ratio)

View file

@ -66,9 +66,9 @@ def test_bucket_clamping() -> None:
_get_asking_sold_ratio(db, rooms)
# First positional bind param is {"b": bucket}.
bind = db.execute.call_args_list[0].args[1]
assert bind["b"] == expected_bucket, (
f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}"
)
assert (
bind["b"] == expected_bucket
), f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}"
def test_per_rooms_hit_returns_ratio_basis() -> None:
@ -193,19 +193,24 @@ def _run_estimate(ratio_tuple: tuple[float | None, str | None]):
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.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_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._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(payload, db)
@ -262,3 +267,92 @@ def test_global_fallback_basis_carried_through() -> None:
est = _run_estimate((0.79, "global_fallback"))
assert est.ratio_basis == "global_fallback"
assert est.asking_to_sold_ratio == 0.79
# ── #773: guard was `and listings_clean` → fixed to `and median_price > 0` ──
# Minimal anchor comp to produce a deterministic median_price via same-building anchor.
_ANCHOR_COMP_ONLY: list[dict] = [
{"price_per_m2": 150_000, "area_m2": 40.0, "rooms": 1},
{"price_per_m2": 160_000, "area_m2": 40.0, "rooms": 1},
{"price_per_m2": 170_000, "area_m2": 40.0, "rooms": 1},
]
def _run_estimate_anchor_only(
ratio_tuple: tuple[float | None, str | None],
anchor_comps: list[dict] | None = None,
anchor_tier: str | None = "A",
):
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
from app.core.config import settings
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
comps = _ANCHOR_COMP_ONLY if anchor_comps is None else anchor_comps
async def _run():
with (
patch.object(settings, "estimate_same_building_anchor_enabled", True),
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)),
# Empty radius — listings_clean will be [] → median_price=0 before anchor.
patch("app.services.estimator._fetch_analogs", return_value=([], False, None)),
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(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(payload, db)
return anyio.run(_run)
def test_expected_sold_fires_on_anchor_only_no_radius_comps() -> None:
"""#773: listings_clean=[], anchor sets median_price>0, ratio present
expected_sold_price > 0 (old guard `and listings_clean` blocked this)."""
ratio = 0.82
est = _run_estimate_anchor_only((ratio, "per_rooms"))
# Anchor must have produced a positive headline.
assert est.median_price_rub > 0, "anchor должен был задать median_price"
# expected_sold must now be computed (was None before #773 fix).
assert est.expected_sold_price_rub is not None
assert est.expected_sold_price_rub > 0
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_low_rub == round(est.range_low_rub * ratio)
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
def test_expected_sold_none_when_median_price_zero() -> None:
"""#773: ни радиуса, ни якоря → median_price=0, ratio present
expected_sold_* must remain None (guard `median_price > 0` blocks fabrication)."""
ratio = 0.82
est = _run_estimate_anchor_only((ratio, "per_rooms"), anchor_comps=[], anchor_tier=None)
assert est.median_price_rub == 0
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