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

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-05-30 06:35:46 +00:00 committed by bot-reviewer
parent 3361e434d4
commit 6fac54642a
2 changed files with 50 additions and 24 deletions

View file

@ -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)

View file

@ -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 askingsold механизм (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: