fix(tradein/estimator): СберИндекс только вторичка + confidence dispersion-ceiling (R2 H1+H2) #2493

Merged
bot-backend merged 1 commit from fix/tradein-estimator-sber-confidence into main 2026-07-12 19:06:44 +00:00
3 changed files with 65 additions and 20 deletions

View file

@ -105,8 +105,11 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
SBER_TIME_ADJUST_REGION = "Свердловская область"
# Coefficient series preference: hedonic (quality-adjusted, cleanest) → deals (fallback).
SBER_COEFF_DASHBOARDS = ("residential_real_estate_prices", "real_estate_deals")
# Coefficient series preference — ТОЛЬКО вторичный рынок (эстиматор оценивает вторичку):
# real_estate_deals (Вторичный, зарег. сделки) → dinamika-tsen-obyavlenii (Вторичный, asking).
# #R2-H1: residential_real_estate_prices УБРАН — для обл.66 это 100% «Первичный рынок»
# (новостройки), даёт направленно ПРОТИВОПОЛОЖНУЮ time-adjust коррекцию для вторички.
SBER_COEFF_DASHBOARDS = ("real_estate_deals", "dinamika-tsen-obyavlenii")
SBER_TIME_FACTOR_MIN = 0.7 # clamp guards against bad/sparse index months
SBER_TIME_FACTOR_MAX = 1.6
@ -1221,6 +1224,9 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
FROM sber_price_index
WHERE city = CAST(:region AS text)
AND dashboard = CAST(:dash AS text)
-- #R2-H1: только вторичный рынок (эстиматор — вторичка);
-- первичка (новостройки) = направленно неверная коррекция.
AND (segment IS NULL OR segment ILIKE '%вторичн%')
ORDER BY period_month
"""),
{"region": region, "dash": dash},
@ -5729,9 +5735,11 @@ def _compute_confidence(
(например, MIN_ANALOGS_PER_SOURCE=5 + same-building bias).
high unique_addr 7 AND IQR/median < 0.15
medium unique_addr 4 OR (unique_addr 2 AND IQR/median < 0.25)
medium (unique_addr 4 AND IQR/median < 0.35) OR (unique_addr 2 AND IQR/median < 0.25)
low иначе
#R2-H2: dispersion-ceiling на medium (0.35) + force-low при fallback-расширении
с разбросом > 0.30 badge не должен противоречить собственному объяснению.
Downgrade на один уровень если avg_lots_per_addr > 2.5 (concentration bias).
"""
if median_ppm2 == 0:
@ -5760,13 +5768,19 @@ def _compute_confidence(
# Базовый уровень по уникальным адресам
if unique_addr_count >= 7 and iqr_pct < 0.15:
base = "high"
elif unique_addr_count >= 4:
elif unique_addr_count >= 4 and iqr_pct < 0.35:
base = "medium"
elif unique_addr_count >= 2 and iqr_pct < 0.25:
base = "medium"
else:
base = "low"
# #R2-H2: расширение радиуса/площади из-за нехватки данных + высокий разброс
# (IQR/median > 0.30) не может честно оставаться medium/high — иначе badge
# противоречит объяснению («расширили радиус … ±45%»). Форсим low.
if base != "low" and (fallback_radius_used or area_widened) and iqr_pct > 0.30:
base = "low"
# Downgrade на один шаг если слишком много лотов сконцентрировано на малом числе адресов
if avg_lots_per_addr > 2.5 and base != "low":
downgrade_map = {"high": "medium", "medium": "low"}

View file

@ -7,16 +7,16 @@
"n_covered": 0
},
"low": {
"coverage_pct": 81.82,
"mape_pct": 13.23,
"n": 275,
"n_covered": 225
"coverage_pct": 81.88,
"mape_pct": 13.25,
"n": 276,
"n_covered": 226
},
"medium": {
"coverage_pct": 100.0,
"mape_pct": 14.64,
"n": 2,
"n_covered": 2
"mape_pct": 6.99,
"n": 1,
"n_covered": 1
}
},
"confidence_order": [
@ -136,14 +136,14 @@
"n_covered": 0
},
"low": {
"coverage_pct": 81.82,
"n": 275,
"n_covered": 225
"coverage_pct": 81.88,
"n": 276,
"n_covered": 226
},
"medium": {
"coverage_pct": 100.0,
"n": 2,
"n_covered": 2
"n": 1,
"n_covered": 1
}
}
},

View file

@ -181,18 +181,49 @@ def test_confidence_high_with_7_unique_addresses_and_tight_iqr() -> None:
def test_confidence_medium_via_4_unique_addresses() -> None:
# 4 unique addresses → medium branch (independent of IQR).
# Wide IQR ensures it is NOT "high". avg = 4/4 = 1.0 (no downgrade).
# #R2-H2: 4 unique addresses AND IQR/median < 0.35 → medium. avg = 4/4 = 1.0 (no downgrade).
listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=115, # IQR/median = 0.25 < 0.35 → medium
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
def test_confidence_4_addresses_wide_iqr_now_low() -> None:
# #R2-H2: 4 unique addresses but IQR/median = 0.60 (huge dispersion) → low, NOT medium.
# Dispersion ceiling: the badge must not contradict a ±30% spread in its own explanation.
listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=70,
q3=130, # IQR/median = 0.60 → not high
q3=130, # IQR/median = 0.60
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
assert level == "low"
def test_confidence_force_low_on_radius_widen_with_dispersion() -> None:
# #R2-H2: a pool that would be medium (4 addr, IQR 0.32 < 0.35) is FORCED low when it
# was radius-widened due to sparse data AND spread > 0.30 — badge can't say "medium"
# while the explanation admits "расширили радиус … из-за нехватки данных".
listings = _addr_lots(["a", "b", "c", "d"])
level, expl = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=84,
q3=116, # IQR/median = 0.32 → medium base, then forced low
fallback_radius_used=True,
listings=listings,
)
assert level == "low"
assert "расширили радиус" in expl
def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None: