fix(estimator): expected_sold guard listings_clean → median_price>0 (Refs #773) (#784)
Some checks failed
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 25s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Successful in 39s
Deploy Trade-In / deploy (push) Has been cancelled
Some checks failed
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 25s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Successful in 39s
Deploy Trade-In / deploy (push) Has been cancelled
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
30ae4841a4
commit
ec84637abf
2 changed files with 108 additions and 14 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue