fix(tradein/estimator): СберИндекс только вторичка + confidence dispersion-ceiling (R2 H1+H2) #2493
3 changed files with 65 additions and 20 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue