МЕРА: якорь «тот же дом» и IMV-blend — константы движка, не настройки (#2381)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 22s
CI / changes (pull_request) Successful in 25s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m35s
CI Trade-In / backend-tests (pull_request) Successful in 6m20s

Пять булевых флагов кластера сняты ещё в #2475. Оставшиеся 14 числовых полей
Settings перенесены в estimator.py константами с теми же значениями:
IMV_BLEND_WEIGHT 0.5, IMV_BLEND_THRESHOLD 1.15, SB_MIN_COMPS 4, SB_AREA_SIGMA
0.18, SB_ROOMS_MATCH_BOOST 1.6, SB_FLOOR_SIGMA 0.25, SB_GUARDRAIL_TOL 0.05,
SB_MAD_K 3.5, SB_MAD_K_SMALL_N 2.5, SB_SMALL_N_THRESHOLD 10,
ANCHOR_TIER_C_CORRIDOR_MULT 1.5, FSD_K 1.65, SB_GATE_MIN_N 3, SB_GATE_MAX_FSD
0.20. На проде 17.09 все 14 равны дефолтам, ENV-оверрайдов нет. Мёртвая
проверка `tier_c_mult > 0` (константа против нуля) убрана.

Тесты подменяют SB_MIN_COMPS на модуле вместо поля settings. Реплей бэктеста
по сделкам побитово тот же, срабатываний якоря 985, IMV-blend 3, low-conf
гейта 11 — как на main. Шесть порогов (rooms_boost, floor_sigma,
guardrail_tol, tier_c_mult, fsd_k, gate_max_fsd) не ловит ни один
поведенческий тест и ни гейт, их держит только тест боевых значений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 12:38:44 +05:00
parent 883c741a63
commit 31c31f8f82
11 changed files with 95 additions and 114 deletions

View file

@ -384,17 +384,6 @@ class Settings(BaseSettings):
dadata_api_token: str | None = None dadata_api_token: str | None = None
dadata_api_secret: str | None = None dadata_api_secret: str | None = None
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
# При отсутствии IMV/Yandex no-op (медиана не меняется).
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на # #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
# данные ЭТОГО адреса, а не на среднее по городу. # данные ЭТОГО адреса, а не на среднее по городу.
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного # Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
@ -414,25 +403,6 @@ class Settings(BaseSettings):
# False ⇒ прежнее поведение целиком, без релиза. # False ⇒ прежнее поведение целиком, без релиза.
estimate_require_local_evidence: bool = True estimate_require_local_evidence: bool = True
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
# комфорт 15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
# и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
estimate_sb_floor_sigma: float = 0.25
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# ── #1966: honest calibrated prediction-interval для expected_sold range ───── # ── #1966: honest calibrated prediction-interval для expected_sold range ─────
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio): # Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й # ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
@ -527,33 +497,7 @@ class Settings(BaseSettings):
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7% estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
estimate_hedonic_factor_min: float = 0.75 estimate_hedonic_factor_min: float = 0.75
estimate_hedonic_factor_max: float = 1.30 estimate_hedonic_factor_max: float = 1.30
# ── #1795: premium headline anti-inflation (шаги 1/4/5 — константы estimator.py) ──
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
estimate_sb_mad_k_small_n: float = 2.5
estimate_sb_small_n_threshold: int = 10
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
estimate_anchor_tier_c_corridor_mult: float = 1.5
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
# ratio — не затрагиваются.
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
estimate_sb_gate_min_n: int = 3
estimate_sb_gate_max_fsd: float = 0.20
# ── #audit-3: price_trend freshness filter ──────────────────────────────── # ── #audit-3: price_trend freshness filter ────────────────────────────────
# Исключать items старше N месяцев из price_trend (house_placement_history). # Исключать items старше N месяцев из price_trend (house_placement_history).

View file

@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно). Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на (см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на
ценовой шкале. None если для дома нет свежей IMV-записи. ценовой шкале. None если для дома нет свежей IMV-записи.
""" """

View file

@ -479,6 +479,44 @@ OUTLIER_TUKEY_K_SMALL = 1.0
# Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0 # Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0
# для trade-in физически невозможен, это артефакт high-price бакета. # для trade-in физически невозможен, это артефакт high-price бакета.
# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает
# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV
# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем
# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT
# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем.
IMV_BLEND_WEIGHT = 0.5
IMV_BLEND_THRESHOLD = 1.15
# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная
# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт 15-25%. v2
# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по
# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded
# по ppm²), hard guardrail и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов
SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian)
SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target
# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции
# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену
# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия
# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%).
SB_FLOOR_SIGMA = 0.25
SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip
# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх.
SB_MAD_K_SMALL_N = 2.5
SB_SMALL_N_THRESHOLD = 10
# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с
# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану.
ANCHOR_TIER_C_CORRIDOR_MULT = 1.5
FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря
# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD
# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4,
# FSD<0.15) проходят без изменений.
SB_GATE_MIN_N = 3
SB_GATE_MAX_FSD = 0.20
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
@ -2655,7 +2693,7 @@ def _fetch_anchor_comps(
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None), ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
floor (int|None), total_floors (int|None) последние два для floor-веса (#680-WB). floor (int|None), total_floors (int|None) последние два для floor-веса (#680-WB).
""" """
min_comps = settings.estimate_sb_min_comps min_comps = SB_MIN_COMPS
# ── Tier A: same building ──────────────────────────────────────────────── # ── Tier A: same building ────────────────────────────────────────────────
street, base_no, letter = _normalize_building_key(address) street, base_no, letter = _normalize_building_key(address)
@ -3038,12 +3076,8 @@ def _compute_same_building_anchor(
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь # легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip. # < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
effective_mad_k = mad_k effective_mad_k = mad_k
if ( if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD:
tier != "A" effective_mad_k = SB_MAD_K_SMALL_N
and settings.estimate_sb_mad_k_small_n < mad_k
and len(raw_ppm2) < settings.estimate_sb_small_n_threshold
):
effective_mad_k = settings.estimate_sb_mad_k_small_n
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k) surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
if len(surviving_idx) < min_comps: if len(surviving_idx) < min_comps:
logger.info( logger.info(
@ -3654,32 +3688,28 @@ def _price_from_inputs(
area_target=area_m2, area_target=area_m2,
rooms_target=rooms, rooms_target=rooms,
tier=anchor_tier or "", tier=anchor_tier or "",
sigma=settings.estimate_sb_area_sigma, sigma=SB_AREA_SIGMA,
rooms_boost=settings.estimate_sb_rooms_match_boost, rooms_boost=SB_ROOMS_MATCH_BOOST,
floor_target=floor, floor_target=floor,
total_floors_target=total_floors, total_floors_target=total_floors,
floor_sigma=settings.estimate_sb_floor_sigma, floor_sigma=SB_FLOOR_SIGMA,
min_comps=settings.estimate_sb_min_comps, min_comps=SB_MIN_COMPS,
mad_k=settings.estimate_sb_mad_k, mad_k=SB_MAD_K,
) )
# #1795 шаг 3: гейт Tier C. # #1795 шаг 3: гейт Tier C.
if ( if anchor is not None and anchor_tier == "C":
anchor is not None
and anchor_tier == "C"
and settings.estimate_anchor_tier_c_corridor_mult > 0
):
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0: if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
corridor_high_for_gate = float(dkp_raw["high_ppm2"]) corridor_high_for_gate = float(dkp_raw["high_ppm2"])
else: else:
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0 corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold: if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
logger.info( logger.info(
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d" "sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
" → keep radius median (anchor suppressed)", " → keep radius median (anchor suppressed)",
int(anchor["anchor_ppm2"]), int(anchor["anchor_ppm2"]),
settings.estimate_anchor_tier_c_corridor_mult, ANCHOR_TIER_C_CORRIDOR_MULT,
int(gate_threshold), int(gate_threshold),
) )
anchor = None anchor = None
@ -3687,10 +3717,7 @@ def _price_from_inputs(
# #audit-1: low-confidence gate. # #audit-1: low-confidence gate.
if anchor is not None: if anchor is not None:
gate_low = anchor["confidence"] == "low" gate_low = anchor["confidence"] == "low"
gate_thin = ( gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
anchor["n"] < settings.estimate_sb_gate_min_n
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
)
if gate_low or gate_thin: if gate_low or gate_thin:
logger.info( logger.info(
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s" "sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
@ -3730,13 +3757,13 @@ def _price_from_inputs(
anchor_comps_used = anchor.get("comps") or anchor_comps anchor_comps_used = anchor.get("comps") or anchor_comps
est_ppm2 = anchor["anchor_ppm2"] est_ppm2 = anchor["anchor_ppm2"]
# PREMIUM GUARDRAIL (hard). # PREMIUM GUARDRAIL (hard).
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol) floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL)
if est_ppm2 < floor_ppm2: if est_ppm2 < floor_ppm2:
est_ppm2 = floor_ppm2 est_ppm2 = floor_ppm2
new_ppm2 = est_ppm2 * repair_coef new_ppm2 = est_ppm2 * repair_coef
point = int(new_ppm2 * area_m2) point = int(new_ppm2 * area_m2)
# FSD-диапазон. # FSD-диапазон.
half = settings.estimate_fsd_k * anchor["fsd"] half = FSD_K * anchor["fsd"]
new_range_low = int(point * max(0.0, 1.0 - half)) new_range_low = int(point * max(0.0, 1.0 - half))
new_range_high = int(point * (1.0 + half)) new_range_high = int(point * (1.0 + half))
# Спред комплов. # Спред комплов.
@ -3893,8 +3920,8 @@ def _price_from_inputs(
area=area_m2, area=area_m2,
anchor_total=anchor_total, anchor_total=anchor_total,
anchor_higher=anchor_higher, anchor_higher=anchor_higher,
weight=settings.estimate_imv_blend_weight, weight=IMV_BLEND_WEIGHT,
threshold=settings.estimate_imv_blend_threshold, threshold=IMV_BLEND_THRESHOLD,
market_count=( market_count=(
avito_imv_summary.market_count if avito_imv_summary is not None else None avito_imv_summary.market_count if avito_imv_summary is not None else None
), ),
@ -3906,7 +3933,7 @@ def _price_from_inputs(
median_price, median_price,
new_median, new_median,
anchor_used, anchor_used,
settings.estimate_imv_blend_weight, IMV_BLEND_WEIGHT,
range_high, range_high,
new_range_high, new_range_high,
) )
@ -4277,7 +4304,7 @@ def _price_from_inputs(
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict), # #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when # NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its # `_compute_same_building_anchor` rejects a candidate outright (e.g. its
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None # own MAD-clip drops comps below SB_MIN_COMPS), it returns None
# WITHOUT the caller resetting `anchor_tier` back to None (it stays # WITHOUT the caller resetting `anchor_tier` back to None (it stays
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired, # whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
# but the stale tier flag falsely reads as "anchor claimed the headline" # but the stale tier flag falsely reads as "anchor claimed the headline"

View file

@ -37,6 +37,21 @@ _PROD_VALUES: dict[str, tuple[str, float]] = {
"RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8), "RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8),
"OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15), "OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15),
"OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0), "OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0),
# #2381, #651/#652 same-building anchor + IMV-blend
"IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5),
"IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15),
"SB_MIN_COMPS": ("estimate_sb_min_comps", 4),
"SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18),
"SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6),
"SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25),
"SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05),
"SB_MAD_K": ("estimate_sb_mad_k", 3.5),
"SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5),
"SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10),
"ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5),
"FSD_K": ("estimate_fsd_k", 1.65),
"SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3),
"SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20),
} }
# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены. # Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены.

View file

@ -101,7 +101,7 @@ _DUP_LOT_DOMKLIK = _comp(
# 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту # 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту
# (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают # (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают
# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает. # SB_MIN_COMPS=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
_OTHER_COMPS = [ _OTHER_COMPS = [
_comp( _comp(
source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5 source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5
@ -132,10 +132,10 @@ def test_anchor_path_dedups_cross_source_duplicate_lot(monkeypatch: pytest.Monke
После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_ После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_
anchor est.analogs содержит физлот ровно ОДИН раз (n=1). anchor est.analogs содержит физлот ровно ОДИН раз (n=1).
""" """
from app.core.config import settings from app.services import estimator
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr(settings, "estimate_sb_min_comps", 1) m.setattr(estimator, "SB_MIN_COMPS", 1)
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя
@ -151,10 +151,10 @@ def test_anchor_path_n_analogs_matches_shown_count(monkeypatch: pytest.MonkeyPat
(постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2 (постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2
при одном реальном физлоте (дубль посчитан дважды) самосогласованно, но при одном реальном физлоте (дубль посчитан дважды) самосогласованно, но
нечестно. После фикса n_analogs=1, синхронно с analogs.""" нечестно. После фикса n_analogs=1, синхронно с analogs."""
from app.core.config import settings from app.services import estimator
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr(settings, "estimate_sb_min_comps", 1) m.setattr(estimator, "SB_MIN_COMPS", 1)
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
assert est.n_analogs == len(est.analogs) == 1 assert est.n_analogs == len(est.analogs) == 1

View file

@ -101,19 +101,16 @@ def test_fix1_thin_n_high_fsd_triggers_gate() -> None:
) )
if anchor is None: if anchor is None:
pytest.skip("MAD-clip отсёк — нет якоря, тест не применим") pytest.skip("MAD-clip отсёк — нет якоря, тест не применим")
from app.core.config import settings from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N
# Проверяем условие gate_thin напрямую # Проверяем условие gate_thin напрямую
gate_thin = ( gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
anchor["n"] < settings.estimate_sb_gate_min_n
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
)
# n=2 < 3 — должен сработать если FSD тоже высокий # n=2 < 3 — должен сработать если FSD тоже высокий
assert anchor["n"] == 2 assert anchor["n"] == 2
# FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3; # FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3;
# tier_penalty=0.05 (C); CV = std/mean для 2 элементов # tier_penalty=0.05 (C); CV = std/mean для 2 элементов
# Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20 # Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20
if anchor["fsd"] > settings.estimate_sb_gate_max_fsd: if anchor["fsd"] > SB_GATE_MAX_FSD:
assert gate_thin, "gate_thin должен быть True при n=2 и high FSD" assert gate_thin, "gate_thin должен быть True при n=2 и high FSD"
@ -308,8 +305,6 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None:
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)] comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
with patch("app.services.estimator.settings") as mock_settings: with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_sb_mad_k_small_n = 2.5
mock_settings.estimate_sb_small_n_threshold = 10
mock_settings.avito_imv_thin_market_threshold = 10 mock_settings.avito_imv_thin_market_threshold = 10
mock_settings.sber_index_max_age_days = 35 mock_settings.sber_index_max_age_days = 35

View file

@ -313,7 +313,7 @@ _EKB_LENINA_LISTING = {
def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]: def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]:
"""N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for """N distinct EKB "ул. Ленина" comps (>= SB_MIN_COMPS=4 needed for
Tier A to actually FIRE see _fetch_anchor_comps `len(comps) >= min_comps`). Tier A to actually FIRE see _fetch_anchor_comps `len(comps) >= min_comps`).
Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265, Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265,
street+floor+area+price physical key) treats them as distinct units, not street+floor+area+price physical key) treats them as distinct units, not
@ -407,7 +407,7 @@ def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None:
"period_months": 12, "period_months": 12,
} }
db = MagicMock() db = MagicMock()
# >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active # >= SB_MIN_COMPS EKB comps — realistic (40 191 of ~40 200 active
# listings are EKB) and necessary for Tier A to actually fire pre-fix. # listings are EKB) and necessary for Tier A to actually fire pre-fix.
db.execute.side_effect = _fake_anchor_sql_execute( db.execute.side_effect = _fake_anchor_sql_execute(
_ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]), _ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]),

View file

@ -252,10 +252,10 @@ def test_anchor_tier_reset_when_anchor_not_built() -> None:
Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше: Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше:
когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше
``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` ``SB_MIN_COMPS``=4), флаг раньше оставался равным ``anchor_tier_fetched``
дальше по коду он читается как «headline построил якорь». дальше по коду он читается как «headline построил якорь».
""" """
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4 comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < SB_MIN_COMPS=4
radius_median_price = int(100_000 * 50.0) radius_median_price = int(100_000 * 50.0)
pr = _call( pr = _call(
listings=_lots(100_000, n=5), listings=_lots(100_000, n=5),

View file

@ -393,7 +393,7 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [
] ]
# Same-building комплы Хохрякова 48 (флагман 684k внутри). # Same-building комплы Хохрякова 48 (флагман 684k внутри).
# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755). # 4 комплов — удовлетворяет SB_MIN_COMPS=4 (#755).
_SB_COMPS_PREMIUM: list[dict[str, Any]] = [ _SB_COMPS_PREMIUM: list[dict[str, Any]] = [
{"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3}, {"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3},
{"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3}, {"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3},
@ -527,7 +527,7 @@ def test_estimate_no_anchor_no_radius_stays_insufficient() -> None:
def test_estimate_economy_no_regression() -> None: def test_estimate_economy_no_regression() -> None:
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов. """(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.
4 компла satisfies estimate_sb_min_comps=4 (#755). 4 компла satisfies SB_MIN_COMPS=4 (#755).
Комплы специально подобраны так, что MAD-clip не удаляет ни один из них Комплы специально подобраны так, что MAD-clip не удаляет ни один из них
(все отклонения << 3.5×MAD при tight spread).""" (все отклонения << 3.5×MAD при tight spread)."""
eco_comps = [ eco_comps = [
@ -596,7 +596,7 @@ def test_estimate_tier_d_fallback_keeps_radius() -> None:
def test_estimate_range_covers_same_building_comp_spread() -> None: def test_estimate_range_covers_same_building_comp_spread() -> None:
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не """range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
вылетает за диапазон зеркало 8 Марта 204Г view-кейса). вылетает за диапазон зеркало 8 Марта 204Г view-кейса).
4 компла satisfies estimate_sb_min_comps=4 (#755).""" 4 компла satisfies SB_MIN_COMPS=4 (#755)."""
# comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой. # comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой.
view_comps = [ view_comps = [
{"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2}, {"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2},
@ -663,7 +663,7 @@ def test_estimate_analogs_stay_radius_when_no_anchor() -> None:
def test_estimate_analogs_pass_through_display_fields() -> None: def test_estimate_analogs_pass_through_display_fields() -> None:
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают """#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area). их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).
4 компла satisfies estimate_sb_min_comps=4 (#755).""" 4 компла satisfies SB_MIN_COMPS=4 (#755)."""
comps_with_display = [ comps_with_display = [
{ {
"price_per_m2": 399_478, "price_per_m2": 399_478,

View file

@ -121,7 +121,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
_row(source="cian", source_id="c2", listing_segment="novostroyki"), _row(source="cian", source_id="c2", listing_segment="novostroyki"),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db, lat=_LAT, lon=_LON) comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A" assert tier == "A"
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
@ -146,7 +146,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
_row(source="cian", source_id="c5", listing_segment="novostroyki"), _row(source="cian", source_id="c5", listing_segment="novostroyki"),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db) comps, tier = _fetch(db)
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip. # primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
assert tier is None assert tier is None
@ -168,7 +168,7 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
_row(source="cian", source_id="c4", listing_segment="novostroyki"), _row(source="cian", source_id="c4", listing_segment="novostroyki"),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db) comps, tier = _fetch(db)
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D). # Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
assert tier is None assert tier is None
@ -193,7 +193,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None), _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db, lat=_LAT, lon=_LON) comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A" assert tier == "A"
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
@ -229,7 +229,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
), ),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db, lat=_LAT, lon=_LON) comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A" assert tier == "A"
# source_id-primary схлопывает несмотря на разные url → 5 comps. # source_id-primary схлопывает несмотря на разные url → 5 comps.
@ -248,7 +248,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0), _row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db, lat=_LAT, lon=_LON) comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A" assert tier == "A"
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.

View file

@ -370,7 +370,7 @@ export interface SourceGroup {
* ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок * ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок
* не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV * не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV
* / Yandex blend», Tier D — когда якоря по дому/500 м нет) медиана * / Yandex blend», Tier D — когда якоря по дому/500 м нет) медиана
* переписывается на `new_median` с весом `estimate_imv_blend_weight`, * переписывается на `new_median` с весом `IMV_BLEND_WEIGHT`,
* объяснение дополняется «Оценка скорректирована по», а `sources_used` * объяснение дополняется «Оценка скорректирована по», а `sources_used`
* пополняется `avito_imv`. Умолчать об этом значит утверждать на публичной * пополняется `avito_imv`. Умолчать об этом значит утверждать на публичной
* странице то, чего код не делает. * странице то, чего код не делает.