МЕРА: якорь «тот же дом» и 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
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:
parent
883c741a63
commit
31c31f8f82
11 changed files with 95 additions and 114 deletions
|
|
@ -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²)×(1−tol)
|
|
||||||
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > 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).
|
||||||
|
|
|
||||||
|
|
@ -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-записи.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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²)×(1−tol)
|
||||||
|
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2−median| > 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"
|
||||||
|
|
|
||||||
|
|
@ -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-ветки из кода удалены.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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, ничего не схлопнуто.
|
||||||
|
|
|
||||||
|
|
@ -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`. Умолчать об этом — значит утверждать на публичной
|
||||||
* странице то, чего код не делает.
|
* странице то, чего код не делает.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue