fix(tradein): distinct asking median vs expected-sold price (no double-discount) (#682)
All checks were successful
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 22s
Deploy Trade-In / build-backend (push) Successful in 39s
Deploy Trade-In / deploy (push) Successful in 32s
All checks were successful
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 22s
Deploy Trade-In / build-backend (push) Successful in 39s
Deploy Trade-In / deploy (push) Successful in 32s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
3361e434d4
commit
6fac54642a
2 changed files with 50 additions and 24 deletions
|
|
@ -1683,14 +1683,20 @@ async def estimate_quality(
|
|||
floor_sigma=settings.estimate_sb_floor_sigma,
|
||||
)
|
||||
if anchor is not None:
|
||||
est_ppm2 = anchor["anchor_sold_ppm2"]
|
||||
# Headline = recommended ASKING price (комплы — активные объявления;
|
||||
# golden-реалы — asking). Берём anchor_ppm2 (PRE-haircut), НЕ
|
||||
# anchor_sold_ppm2. asking→sold скидка применяется единственным
|
||||
# механизмом — per-rooms asking_to_sold_ratio (migration 080) ниже,
|
||||
# дающим distinct expected_sold. Двойная скидка (band-haircut здесь +
|
||||
# ratio там) давала median == expected_sold (#677/#681 collision).
|
||||
est_ppm2 = anchor["anchor_ppm2"]
|
||||
# PREMIUM GUARDRAIL (hard): не ниже минимального same-building ppm² (−tol).
|
||||
# Только Tier A/C (комплы реально из дома/микрорайона). Эконом — no-op
|
||||
# (est уже ≥ floor), премиум — поднимает если mean занизил.
|
||||
# (est уже ≥ floor), премиум — поднимает если mean занизил. ASKING-space.
|
||||
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 уже применён к старой
|
||||
# POINT = anchor_asking × area × repair_coef (repair уже применён к старой
|
||||
# median; здесь применяем к свежему якорю — заменяем headline целиком).
|
||||
new_ppm2 = est_ppm2 * repair_coef
|
||||
point = int(new_ppm2 * payload.area_m2)
|
||||
|
|
@ -1704,14 +1710,16 @@ async def estimate_quality(
|
|||
# (иначе видовой топ-юнит вылетает за 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)
|
||||
# иначе он вылетает за диапазон. range_low — RAW comp min (asking-space):
|
||||
# headline теперь asking, band-haircut больше не применяется (sold-скидка —
|
||||
# единственным механизмом per-rooms ratio ниже).
|
||||
spread_low = int(anchor["comp_min_ppm2"] * 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"
|
||||
"sb_anchor: tier=%s n=%d radius_median_ppm2=%d → anchor_asking_ppm2=%d"
|
||||
" (uplift=%s haircut=%.2f) point %d → %d",
|
||||
anchor_tier,
|
||||
anchor["n"],
|
||||
|
|
@ -1851,15 +1859,13 @@ async def estimate_quality(
|
|||
# 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:
|
||||
# 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_*
|
||||
# остаются None → UI не показывает «−N%» badge (не фабрикуем).
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ hard guardrail; и full estimate path с замоканным `_fetch_anchor_com
|
|||
(c) address alias («Ткачёва 13» → «Ткачей 13»),
|
||||
(d) base-house-number match через corpus-литеры,
|
||||
(e) флаг OFF ⇒ неизменный радиусный результат,
|
||||
(f) expected_sold консистентен после якоря (no double-discount).
|
||||
(f) headline = ASKING после якоря, expected_sold = headline × ratio (DISTINCT).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -458,14 +458,34 @@ def test_estimate_flag_off_unchanged_radius_result() -> None:
|
|||
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_expected_sold_distinct_after_anchor() -> None:
|
||||
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
|
||||
expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median.
|
||||
Single asking→sold механизм (ratio); band-haircut больше не в headline."""
|
||||
ratio = 0.92
|
||||
est = _run_estimate(
|
||||
anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(ratio, "per_rooms")
|
||||
)
|
||||
# expected_sold выведен из POST-anchor headline × ratio (не равен headline).
|
||||
# per_m2 берётся от внутренней float-медианы (схема отдаёт int(median_ppm2)),
|
||||
# поэтому сравниваем с допуском ±1 на округление float→int.
|
||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
||||
assert abs(est.expected_sold_per_m2 - round(est.median_price_per_m2 * ratio)) <= 1
|
||||
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)
|
||||
# Distinct: sold строго ниже asking (ratio < 1) — фикс двойных идентичных чисел.
|
||||
assert est.expected_sold_price_rub < est.median_price_rub
|
||||
assert est.expected_sold_per_m2 < est.median_price_per_m2
|
||||
|
||||
|
||||
def test_estimate_expected_sold_none_without_ratio_after_anchor() -> None:
|
||||
"""Нет migration-080 ratio (None) → expected_sold_* = None даже при якоре
|
||||
(UI не показывает badge — не фабрикуем)."""
|
||||
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(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
|
||||
|
||||
|
||||
def test_estimate_tier_d_fallback_keeps_radius() -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue