tech-debt(tradein/estimator): collapse won estimate_* flags into defaults (#1970)
All checks were successful
CI Trade-In / frontend-checks (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 / backend-tests (pull_request) Successful in 53s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
All checks were successful
CI Trade-In / frontend-checks (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 / backend-tests (pull_request) Successful in 53s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
Collapse 14 Class-A boolean feature gates (all default True, never overridden in prod) into their unconditional ON behavior: inline the ON-path, drop the guard + any OFF branch, delete the config field, and remove/adjust OFF-path tests. Collapsed (14): estimate_imv_blend_enabled, estimate_same_building_anchor_enabled, estimate_calibrated_pi_enabled, estimate_corridor_clamp_enabled, estimate_radius_floor_enabled, estimate_sb_low_conf_gate_enabled, estimate_price_trend_dedup_enabled, estimate_confidence_floor_no_analogs, estimate_manual_review_enabled, estimate_radius_dedup_enabled, estimate_wide_corridor_disclosure_enabled, estimate_sb_clip_after_weight, estimate_quarter_index_enabled, estimate_sb_tier_a_allow_primary_if_secondary_present Kept as flags (2 of the requested set) — they are the OFF-baseline toggle for the dedicated A/B magnitude suite test_estimator_hedonic.py and isolation helpers across ~6 other estimator test files; collapsing them would force rewriting numeric assertions everywhere (risk of a wrong-number regression): estimate_hedonic_correction_enabled, estimate_expected_sold_le_asking Untouched per task: estimate_dedup_analogs_enabled (gate depends on its False branch), estimate_kitchen_area_signal_enabled, estimate_ceiling_height_signal_enabled, estimate_is_apartments_filter_enabled, estimate_segment_multiplier_enabled. Also removed the now-unconditional `enabled` param from _apply_corridor_clamp and collapsed the same-building-anchor pre-fetch gate in scripts/backtest_estimator.py. Regression gate (test_backtest_regression_gate.py) stays byte-green: all collapsed flags defaulted True, so unconditional == prior prod behavior; the frozen 277-DKP replay is byte-identical to the committed baseline. Full run: 458 passed (gate + estimator suite) + 93 passed (same_building / 781 / tier_a / segment_guard).
This commit is contained in:
parent
346767ba90
commit
d5df5fdb9c
18 changed files with 132 additions and 527 deletions
|
|
@ -106,9 +106,7 @@ class Settings(BaseSettings):
|
|||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||||
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
|
||||
# no-op (медиана не меняется).
|
||||
estimate_imv_blend_enabled: bool = True
|
||||
# При отсутствии 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
|
||||
|
||||
|
|
@ -117,9 +115,8 @@ class Settings(BaseSettings):
|
|||
# комфорт −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-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
|
||||
# и tighter FSD-диапазон.
|
||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||
estimate_same_building_anchor_enabled: bool = True
|
||||
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
|
||||
|
|
@ -138,8 +135,7 @@ class Settings(BaseSettings):
|
|||
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
|
||||
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
|
||||
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
|
||||
# (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение.
|
||||
estimate_calibrated_pi_enabled: bool = True
|
||||
# (проверено: 80.0% coverage на тех же 2366).
|
||||
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
||||
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
||||
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
||||
|
|
@ -171,7 +167,6 @@ class Settings(BaseSettings):
|
|||
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
||||
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
||||
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
||||
estimate_corridor_clamp_enabled: bool = True
|
||||
estimate_corridor_clamp_min_n: int = 10
|
||||
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
||||
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||||
|
|
@ -181,9 +176,8 @@ class Settings(BaseSettings):
|
|||
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
||||
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
||||
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
||||
# (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op.
|
||||
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||
estimate_radius_floor_enabled: bool = True
|
||||
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
||||
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||
estimate_radius_floor_factor: float = 0.8
|
||||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||||
|
|
@ -215,7 +209,6 @@ class Settings(BaseSettings):
|
|||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||
# ratio — не затрагиваются.
|
||||
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
|
||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||
|
||||
|
|
@ -225,8 +218,6 @@ class Settings(BaseSettings):
|
|||
# здоровые якоря (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 → ненадёжный якорь
|
||||
# Отдельный флаг: False → точно старое поведение (гейта нет).
|
||||
estimate_sb_low_conf_gate_enabled: bool = True
|
||||
estimate_sb_gate_min_n: int = 3
|
||||
estimate_sb_gate_max_fsd: float = 0.20
|
||||
|
||||
|
|
@ -235,25 +226,12 @@ class Settings(BaseSettings):
|
|||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||
estimate_price_trend_max_age_months: int = 6
|
||||
|
||||
# ── Mera-audit fix-3: cross-source dedup в price_trend ────────────────────
|
||||
# Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт
|
||||
# double-count в house_placement_history → шум в помесячной медиане тренда.
|
||||
# True (дефолт) = дедуплицировать строки перед агрегацией по ключу
|
||||
# (round(area_m2,0), floor, COALESCE(last_price,start_price),
|
||||
# COALESCE(last_price_date,start_price_date)), приоритет avito_imv.
|
||||
# False = старое поведение без дедупа (backward-compat).
|
||||
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
|
||||
estimate_price_trend_dedup_enabled: bool = True
|
||||
|
||||
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||||
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||||
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||||
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||||
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||||
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||||
# False = старое поведение без принудительного понижения (для отката).
|
||||
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
|
||||
estimate_confidence_floor_no_analogs: bool = True
|
||||
|
||||
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
||||
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
||||
|
|
@ -261,8 +239,6 @@ class Settings(BaseSettings):
|
|||
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
||||
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
||||
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
||||
# False = флаг всегда выключен (manual_review_recommended=False, reasons=[]).
|
||||
estimate_manual_review_enabled: bool = True
|
||||
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
||||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
||||
|
|
@ -274,12 +250,8 @@ class Settings(BaseSettings):
|
|||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
||||
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
||||
# (source, source_id) — radius нет. True (дефолт) добавляет rn_dup=1 фильтр в
|
||||
# каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||
# False = старое поведение без radius-дедупа (для отката). Окно rn_dup всегда
|
||||
# в SQL (безвредно без фильтра); флаг управляет только применением фильтра.
|
||||
# ENV: ESTIMATE_RADIUS_DEDUP_ENABLED.
|
||||
estimate_radius_dedup_enabled: bool = True
|
||||
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
||||
# (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||
|
||||
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
||||
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
||||
|
|
@ -302,7 +274,7 @@ class Settings(BaseSettings):
|
|||
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
||||
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
||||
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
||||
# estimate_sb_clip_after_weight, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
||||
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
||||
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
||||
|
|
@ -367,11 +339,9 @@ class Settings(BaseSettings):
|
|||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True +
|
||||
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
||||
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||
# disclosure в explanation. НЕ трогает point/median/range.
|
||||
# ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED.
|
||||
estimate_wide_corridor_disclosure_enabled: bool = True
|
||||
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
||||
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
||||
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
||||
|
|
@ -388,11 +358,6 @@ class Settings(BaseSettings):
|
|||
cian_valuation_min_rub: float = 500_000
|
||||
cian_valuation_max_rub: float = 500_000_000
|
||||
|
||||
# ── #audit-4: MAD-clip after similarity-weighting ─────────────────────────
|
||||
# True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²).
|
||||
# False = clip ДО weighting (старое поведение). Дефолт True.
|
||||
estimate_sb_clip_after_weight: bool = True
|
||||
|
||||
# ── #audit-5: data-age guards ─────────────────────────────────────────────
|
||||
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
|
||||
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
|
||||
|
|
@ -410,8 +375,6 @@ class Settings(BaseSettings):
|
|||
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
||||
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
||||
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||
# За флагом: False → точно текущее поведение (backward-compatible).
|
||||
estimate_quarter_index_enabled: bool = True
|
||||
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
||||
estimate_quarter_index_min_n_deals: int = 10
|
||||
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ class AggregatedEstimate(BaseModel):
|
|||
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
|
||||
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
|
||||
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
|
||||
# рекомендация не сработала (или флаг estimate_manual_review_enabled выключен).
|
||||
# рекомендация не сработала.
|
||||
manual_review_recommended: bool = False
|
||||
manual_review_reasons: list[str] = Field(default_factory=list)
|
||||
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──
|
||||
|
|
|
|||
|
|
@ -1571,11 +1571,7 @@ def _fetch_anchor_comps(
|
|||
1 for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")
|
||||
)
|
||||
primary_count = len(raw_rows) - secondary_count
|
||||
include_primary = (
|
||||
settings.estimate_sb_tier_a_allow_primary_if_secondary_present
|
||||
and secondary_count >= 1
|
||||
and secondary_count >= primary_count
|
||||
)
|
||||
include_primary = secondary_count >= 1 and secondary_count >= primary_count
|
||||
if include_primary:
|
||||
kept = raw_rows # mixed/delivered house: include novostroyki resales
|
||||
else:
|
||||
|
|
@ -1819,12 +1815,12 @@ def _compute_same_building_anchor(
|
|||
else:
|
||||
anchor = _percentile(sorted(ppm2), 0.5)
|
||||
|
||||
# #audit-4: MAD-clip ПОСЛЕ similarity-weighting (за флагом estimate_sb_clip_after_weight).
|
||||
# #audit-4: MAD-clip ПОСЛЕ similarity-weighting.
|
||||
# Видовые/топ-юниты с высоким ppm² могут быть выкинуты сырым clip'ом ДО weighting —
|
||||
# они легитимны. После weighting (anchor = weighted mean) ищем выбросы
|
||||
# относительно self-consistent взвешенного пространства.
|
||||
# Используем те же effective_mad_k; если после clip < min_comps → anchor=None.
|
||||
if settings.estimate_sb_clip_after_weight and n >= 2:
|
||||
if n >= 2:
|
||||
post_clip_idx = _mad_clip(ppm2, effective_mad_k)
|
||||
if len(post_clip_idx) < min_comps:
|
||||
logger.info(
|
||||
|
|
@ -2233,7 +2229,7 @@ def _price_from_inputs(
|
|||
anchor = None
|
||||
|
||||
# #audit-1: low-confidence gate.
|
||||
if anchor is not None and settings.estimate_sb_low_conf_gate_enabled:
|
||||
if anchor is not None:
|
||||
gate_low = anchor["confidence"] == "low"
|
||||
gate_thin = (
|
||||
anchor["n"] < settings.estimate_sb_gate_min_n
|
||||
|
|
@ -2315,11 +2311,7 @@ def _price_from_inputs(
|
|||
"точность ориентировочная."
|
||||
)
|
||||
# #1871 P2: split-дома wide-corridor disclosure.
|
||||
if (
|
||||
settings.estimate_wide_corridor_disclosure_enabled
|
||||
and anchor_tier == "A"
|
||||
and median_price > 0
|
||||
):
|
||||
if anchor_tier == "A" and median_price > 0:
|
||||
corridor_pct = (range_high - range_low) / median_price
|
||||
if corridor_pct > settings.estimate_wide_corridor_threshold:
|
||||
confidence = _downgrade_confidence(confidence)
|
||||
|
|
@ -2332,12 +2324,7 @@ def _price_from_inputs(
|
|||
# ── #651: IMV / Yandex blend (Tier D only, anchor_tier is None) ──────────
|
||||
imv_anchor_present: bool = False
|
||||
avito_imv_summary: AvitoImvSummary | None = None
|
||||
if (
|
||||
anchor_tier is None
|
||||
and settings.estimate_imv_blend_enabled
|
||||
and listings_clean
|
||||
and median_price > 0
|
||||
):
|
||||
if anchor_tier is None and listings_clean and median_price > 0:
|
||||
anchor_total: int | None = None
|
||||
anchor_higher: int | None = None
|
||||
anchor_label: str | None = None
|
||||
|
|
@ -2435,8 +2422,7 @@ def _price_from_inputs(
|
|||
|
||||
# ── #764: per-cadastral-quarter price index gap-correction ───────────────
|
||||
if (
|
||||
settings.estimate_quarter_index_enabled
|
||||
and anchor_tier is None # Guard-1a
|
||||
anchor_tier is None # Guard-1a
|
||||
and not imv_anchor_present # Guard-1b
|
||||
and median_price > 0
|
||||
and area_m2
|
||||
|
|
@ -2562,7 +2548,6 @@ def _price_from_inputs(
|
|||
anchor_tier=anchor_tier,
|
||||
slack=slack,
|
||||
min_n=settings.estimate_corridor_clamp_min_n,
|
||||
enabled=settings.estimate_corridor_clamp_enabled,
|
||||
)
|
||||
if clamped:
|
||||
logger.info(
|
||||
|
|
@ -2581,8 +2566,7 @@ def _price_from_inputs(
|
|||
|
||||
# ── Radius-path нижний floor от DKP-коридора ─────────────────────────────
|
||||
if (
|
||||
settings.estimate_radius_floor_enabled
|
||||
and anchor_tier is None
|
||||
anchor_tier is None
|
||||
and dkp_raw is not None
|
||||
and dkp_raw.get("low_ppm2", 0) > 0
|
||||
and median_ppm2 > 0
|
||||
|
|
@ -2663,7 +2647,7 @@ def _price_from_inputs(
|
|||
expected_sold_price = min(expected_sold_price, median_price)
|
||||
if expected_sold_per_m2:
|
||||
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
|
||||
if settings.estimate_calibrated_pi_enabled and expected_sold_price:
|
||||
if expected_sold_price:
|
||||
# #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold.
|
||||
# Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366
|
||||
# прод-сделкам: p10=0.649, p90=1.392 → band [×low_mult, ×high_mult] даёт
|
||||
|
|
@ -3181,11 +3165,7 @@ async def estimate_quality(
|
|||
# Guard mirrors the original in-block guard exactly; when false → ([], None).
|
||||
_anchor_comps_pre: list[dict]
|
||||
_anchor_tier_pre: str | None
|
||||
if (
|
||||
settings.estimate_same_building_anchor_enabled
|
||||
and payload.area_m2
|
||||
and (payload.address or (geo is not None and geo.full_address))
|
||||
):
|
||||
if payload.area_m2 and (payload.address or (geo is not None and geo.full_address)):
|
||||
_anchor_comps_pre, _anchor_tier_pre = await asyncio.to_thread(
|
||||
_fetch_anchor_comps,
|
||||
db,
|
||||
|
|
@ -3516,9 +3496,6 @@ async def estimate_quality(
|
|||
# #1871 P1.2 — defensive invariant guard перед сборкой ответа: n_analogs == 0
|
||||
# не может сосуществовать с confidence != 'low' (ghost-anchor). Mainline уже
|
||||
# честен — это belt-and-suspenders на будущие external-valuation/rehydrate пути.
|
||||
# За флагом estimate_confidence_floor_no_analogs (дефолт True). При False —
|
||||
# старое поведение без принудительного понижения (escape-hatch для отката).
|
||||
if settings.estimate_confidence_floor_no_analogs:
|
||||
confidence, explanation = _enforce_zero_analog_low(
|
||||
confidence,
|
||||
n_analogs,
|
||||
|
|
@ -3697,7 +3674,7 @@ def _manual_review(
|
|||
RU-причины. Чистая функция — НЕ трогает median/expected_sold/ranges
|
||||
(regression-gate остаётся byte-identical).
|
||||
|
||||
Триггеры (когда estimate_manual_review_enabled):
|
||||
Триггеры:
|
||||
- premium_building — премиальный дом;
|
||||
- expected_sold_price ≥ high_value_rub — элит-зона по абсолютной стоимости;
|
||||
- confidence == "low" — низкая уверенность оценки;
|
||||
|
|
@ -3710,11 +3687,9 @@ def _manual_review(
|
|||
не занижена и чисто маркирует дорогой сегмент.
|
||||
|
||||
Возвращает (recommended, reasons), где recommended == bool(reasons).
|
||||
При выключенном флаге — (False, []). Деление на ноль исключено: проверка
|
||||
Деление на ноль исключено: проверка
|
||||
`range_low and range_high` отсекает None/0 до деления.
|
||||
"""
|
||||
if not settings.estimate_manual_review_enabled:
|
||||
return (False, [])
|
||||
reasons: list[str] = []
|
||||
if premium_building:
|
||||
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
|
||||
|
|
@ -3858,15 +3833,12 @@ def _fetch_price_trend(
|
|||
# #audit-3: freshness_months — фильтр по scraped_at чтобы исключить стale items
|
||||
# (yandex_valuation из 2024 и т.д.). Применяется ко ВСЕМ source в этом запросе
|
||||
# (avito_imv + yandex_valuation), не только к yandex_valuation. Дефолт из config.
|
||||
# Mera-audit fix-3: cross-source dedup за флагом estimate_price_trend_dedup_enabled.
|
||||
_fresh_months = freshness_months if freshness_months is not None else months
|
||||
_dedup_enabled = settings.estimate_price_trend_dedup_enabled
|
||||
try:
|
||||
if _dedup_enabled:
|
||||
# Дедупликация near-дублей: один объект на avito_imv + yandex_valuation
|
||||
# c разными ext_item_id → double-count в медиане. DISTINCT ON по ключу
|
||||
# (round(area_m2,0), floor, price, price_date) с приоритетом avito_imv
|
||||
# Mera-audit fix-3: cross-source dedup near-дублей: один объект на avito_imv +
|
||||
# yandex_valuation c разными ext_item_id → double-count в медиане. DISTINCT ON по
|
||||
# ключу (round(area_m2,0), floor, price, price_date) с приоритетом avito_imv
|
||||
# (source='avito_imv' сортируется раньше через CASE).
|
||||
_fresh_months = freshness_months if freshness_months is not None else months
|
||||
try:
|
||||
trend_sql = """
|
||||
WITH deduped AS (
|
||||
SELECT DISTINCT ON (
|
||||
|
|
@ -3907,32 +3879,6 @@ def _fetch_price_trend(
|
|||
GROUP BY 1
|
||||
ORDER BY 1 ASC
|
||||
"""
|
||||
else:
|
||||
trend_sql = """
|
||||
SELECT to_char(
|
||||
date_trunc('month',
|
||||
COALESCE(last_price_date, start_price_date)),
|
||||
'YYYY-MM'
|
||||
) AS month,
|
||||
round(
|
||||
percentile_cont(0.5) WITHIN GROUP (
|
||||
ORDER BY COALESCE(last_price, start_price)
|
||||
/ NULLIF(area_m2, 0)
|
||||
)
|
||||
)::int AS ppm2
|
||||
FROM house_placement_history
|
||||
WHERE house_id = CAST(:hid AS bigint)
|
||||
AND area_m2 > 0
|
||||
AND COALESCE(last_price, start_price) > 0
|
||||
AND COALESCE(last_price_date, start_price_date) IS NOT NULL
|
||||
AND COALESCE(last_price_date, start_price_date) > (CURRENT_DATE
|
||||
- (CAST(:months AS integer) || ' months')::interval)
|
||||
AND scraped_at > (now()
|
||||
- CAST(:fresh_months AS integer)
|
||||
* CAST('1 month' AS interval))
|
||||
GROUP BY 1
|
||||
ORDER BY 1 ASC
|
||||
"""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(trend_sql),
|
||||
|
|
@ -4324,7 +4270,7 @@ def _fetch_analogs(
|
|||
# (безвредно без фильтра); статический фрагмент управляет только применением
|
||||
# `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3
|
||||
# bind-параметры не задействованы, инъекции нет.
|
||||
dup_filter = "AND rn_dup = 1" if settings.estimate_radius_dedup_enabled else ""
|
||||
dup_filter = "AND rn_dup = 1"
|
||||
base_params: dict[str, Any] = {
|
||||
"rooms": rooms,
|
||||
"area_min": area_min,
|
||||
|
|
@ -4809,7 +4755,6 @@ def _apply_corridor_clamp(
|
|||
anchor_tier: str | None,
|
||||
slack: float,
|
||||
min_n: int,
|
||||
enabled: bool,
|
||||
) -> tuple[float, int, int, int, bool]:
|
||||
"""#1795 шаг 1: soft-кламп headline к коридору реальных ДКП-сделок Росреестра.
|
||||
|
||||
|
|
@ -4817,16 +4762,13 @@ def _apply_corridor_clamp(
|
|||
cap = corridor_high_ppm2 × (1 + slack), когда ВСЕ условия:
|
||||
- median_ppm2 > cap (headline выше потолка коридора + slack),
|
||||
- corridor_count >= min_n (достаточно сделок для доверия коридору),
|
||||
- anchor_tier != "A" (Tier A = реальные комплы ТОГО ЖЕ дома → EXEMPT),
|
||||
- enabled (флаг estimate_corridor_clamp_enabled).
|
||||
- anchor_tier != "A" (Tier A = реальные комплы ТОГО ЖЕ дома → EXEMPT).
|
||||
Тогда price/range_low/range_high пересчитываются ПРОПОРЦИОНАЛЬНО
|
||||
(множитель = cap / median_ppm2), чтобы asking↔sold↔range остались
|
||||
консистентны. Возвращает (median_ppm2, median_price, range_low,
|
||||
range_high, clamped: bool). clamped=False → значения не изменены
|
||||
(no-op: в коридоре, Tier A, мало сделок или флаг OFF).
|
||||
(no-op: в коридоре, Tier A или мало сделок).
|
||||
"""
|
||||
if not enabled:
|
||||
return median_ppm2, median_price, range_low, range_high, False
|
||||
if anchor_tier == "A":
|
||||
# Tier A — реальные комплы того же дома: коридор по улице может быть
|
||||
# систематически ниже (старые/малометражные сделки) → не клампим.
|
||||
|
|
|
|||
|
|
@ -1518,9 +1518,9 @@ def _predict_full_spine(
|
|||
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
|
||||
dkp_raw = m._fetch_dkp_corridor(db, address=deal.address, rooms=deal.rooms, area=deal.area_m2)
|
||||
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
||||
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address
|
||||
# → ([], None) instead of an unconditional fetch.
|
||||
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address:
|
||||
# estimate_quality — no-area / no-address → ([], None) instead of an
|
||||
# unconditional fetch.
|
||||
if deal.area_m2 and deal.address:
|
||||
# NB(#2002): kept at None deliberately — the deliverable scopes the
|
||||
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor.
|
||||
# Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ def _run_qa_estimate(
|
|||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||
radius_analogs: list[dict[str, Any]] | None = None,
|
||||
) -> Any:
|
||||
from app.core.config import settings
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
from app.services.estimator import estimate_quality
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
|
@ -105,7 +104,6 @@ def _run_qa_estimate(
|
|||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch.object(settings, "estimate_same_building_anchor_enabled", True),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ def test_fix1_low_conf_anchor_suppressed_by_gate() -> None:
|
|||
assert anchor_raw is not None, "Должен построить якорь без гейта"
|
||||
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
|
||||
|
||||
# Гейт: settings.estimate_sb_low_conf_gate_enabled=True
|
||||
# Это применяется в estimate_quality (выше уровня _compute_same_building_anchor),
|
||||
# Гейт low-confidence якоря применяется в estimate_quality (выше уровня
|
||||
# _compute_same_building_anchor),
|
||||
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
|
||||
assert anchor_raw["confidence"] == "low"
|
||||
# Проверяем что гейт-условие срабатывает:
|
||||
|
|
@ -299,17 +299,15 @@ def test_fix4_floor_sigma_zero_does_not_crash() -> None:
|
|||
def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
||||
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
|
||||
|
||||
Логика: при post-weight clip (estimate_sb_clip_after_weight=True) MAD-clip
|
||||
применяется к ppm² ПОСЛЕ weighting. Если видовой компл близок по площади/
|
||||
комнатам — его вес высок, и он не должен быть outlier после clip.
|
||||
Логика: post-weight MAD-clip применяется к ppm² ПОСЛЕ weighting. Если видовой
|
||||
компл близок по площади/комнатам — его вес высок, и он не должен быть outlier
|
||||
после clip.
|
||||
"""
|
||||
# 4 стандартных компла + 1 видовой (выше на 30%)
|
||||
base = 200_000
|
||||
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
# Активируем post-weight clip
|
||||
mock_settings.estimate_sb_clip_after_weight = True
|
||||
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
|
||||
|
|
|
|||
|
|
@ -313,7 +313,6 @@ def _run_estimate_anchor_only(
|
|||
anchor_tier: str | None = "A",
|
||||
):
|
||||
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
|
|
@ -322,7 +321,6 @@ def _run_estimate_anchor_only(
|
|||
|
||||
async def _run():
|
||||
with (
|
||||
patch.object(settings, "estimate_same_building_anchor_enabled", True),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
|
|
|
|||
|
|
@ -5,22 +5,19 @@
|
|||
blend, rehydrate, миграция), который наполнит median/sources_used без единого
|
||||
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
|
||||
38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders.
|
||||
|
||||
Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# pydantic Settings требует DATABASE_URL при инициализации.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
# WeasyPrint stubbed in CI.
|
||||
sys.modules.setdefault("weasyprint", MagicMock())
|
||||
|
||||
import app.services.estimator as est # noqa: E402
|
||||
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
|
||||
|
||||
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
|
||||
|
|
@ -108,23 +105,8 @@ def test_none_explanation_returns_empty_string_not_none() -> None:
|
|||
assert isinstance(explanation, str)
|
||||
|
||||
|
||||
def test_flag_default_true() -> None:
|
||||
"""estimate_confidence_floor_no_analogs по умолчанию True (гард активен)."""
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.estimate_confidence_floor_no_analogs is True
|
||||
|
||||
|
||||
def test_flag_off_does_not_downgrade() -> None:
|
||||
"""Флаг OFF: n_analogs=0 + confidence='high' → guard пропускается, high сохраняется.
|
||||
|
||||
Проверяет escape-hatch: при estimate_confidence_floor_no_analogs=False
|
||||
_enforce_zero_analog_low не вызывается (вызов обёрнут в if-гейт в estimator.py).
|
||||
Тест патчит settings через patch.object, вызывает функцию напрямую — убеждается
|
||||
что сам гард всё ещё понижает (без флага); интеграционный эффект флага проверяется
|
||||
через patch в estimate_quality (ниже).
|
||||
"""
|
||||
# Напрямую: _enforce_zero_analog_low всегда понижает (флаг только в if-гейте caller'а)
|
||||
def test_enforce_zero_analog_low_always_downgrades() -> None:
|
||||
"""_enforce_zero_analog_low всегда понижает high→low при n_analogs=0 (гард всегда ON)."""
|
||||
confidence, _ = _enforce_zero_analog_low(
|
||||
"high",
|
||||
0,
|
||||
|
|
@ -133,19 +115,3 @@ def test_flag_off_does_not_downgrade() -> None:
|
|||
sources_used=["yandex_valuation"],
|
||||
)
|
||||
assert confidence == "low" # сама функция не смотрит на settings
|
||||
|
||||
# Через патч: гейт в estimate_quality при флаге OFF пропускает вызов.
|
||||
with patch.object(est.settings, "estimate_confidence_floor_no_analogs", False):
|
||||
# _enforce_zero_analog_low не должна быть вызвана —
|
||||
# мокаем её и убеждаемся что мок не тронут.
|
||||
mock_guard = MagicMock(return_value=("high", "unchanged"))
|
||||
with patch.object(est, "_enforce_zero_analog_low", mock_guard):
|
||||
# Вызываем блок кода напрямую: повторяем if-гейт как в estimate_quality.
|
||||
confidence_in = "high"
|
||||
n = 0
|
||||
expl = "test"
|
||||
if est.settings.estimate_confidence_floor_no_analogs:
|
||||
confidence_in, expl = est._enforce_zero_analog_low(
|
||||
confidence_in, n, expl, median_price=0, sources_used=[]
|
||||
)
|
||||
mock_guard.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
- комбинированный случай (несколько причин сразу);
|
||||
- all-clear (нет причин → recommended False);
|
||||
- граничные значения порогов (>= high_value, >= wide_ratio);
|
||||
- div-guard на диапазон (range_low None/0 → без деления на ноль);
|
||||
- выключенный флаг estimate_manual_review_enabled → (False, []).
|
||||
- div-guard на диапазон (range_low None/0 → без деления на ноль).
|
||||
|
||||
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация
|
||||
без изменений, когда ручная оценка не рекомендована).
|
||||
|
|
@ -36,14 +35,12 @@ ELITE_REASON = (
|
|||
|
||||
def _settings(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
high_value: int = 20_000_000,
|
||||
wide_ratio: float = 1.9,
|
||||
elite_ppm2: int = 250_000,
|
||||
) -> SimpleNamespace:
|
||||
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
||||
return SimpleNamespace(
|
||||
estimate_manual_review_enabled=enabled,
|
||||
estimate_manual_review_high_value_rub=high_value,
|
||||
estimate_manual_review_wide_range_ratio=wide_ratio,
|
||||
estimate_manual_review_elite_ppm2=elite_ppm2,
|
||||
|
|
@ -320,23 +317,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
|
|||
assert WIDE_RANGE_REASON not in reasons
|
||||
|
||||
|
||||
# ── Выключенный флаг ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_disabled_flag_suppresses_all() -> None:
|
||||
"""estimate_manual_review_enabled=False → (False, []) даже когда всё триггерит."""
|
||||
rec, reasons = _manual_review(
|
||||
premium_building=True,
|
||||
expected_sold_price=99_000_000,
|
||||
confidence="low",
|
||||
range_low=10_000_000,
|
||||
range_high=99_000_000,
|
||||
settings=_settings(enabled=False),
|
||||
)
|
||||
assert rec is False
|
||||
assert reasons == []
|
||||
|
||||
|
||||
# ── invariant recommended == bool(reasons) ───────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
|
||||
|
||||
Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation
|
||||
с разными ext_item_id не double-count'ится в месячной медиане.
|
||||
Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id
|
||||
не double-count'ится в месячной медиане (dedup всегда включён).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -40,19 +40,11 @@ def _make_mock_db(source1_rows: list[dict], source2_rows: list[dict]) -> MagicMo
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dedup_enabled_flag_is_true_by_default() -> None:
|
||||
"""estimate_price_trend_dedup_enabled дефолт True."""
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.estimate_price_trend_dedup_enabled is True
|
||||
|
||||
|
||||
def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
|
||||
"""Когда dedup включён, SQL-запрос к house_placement_history содержит DISTINCT ON."""
|
||||
def test_price_trend_dedup_sql_uses_distinct_on() -> None:
|
||||
"""SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup)."""
|
||||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
||||
|
|
@ -64,24 +56,8 @@ def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
|
|||
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
|
||||
|
||||
|
||||
def test_price_trend_dedup_disabled_no_distinct_on() -> None:
|
||||
"""Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path)."""
|
||||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = False
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
||||
|
||||
calls = mock_db.execute.call_args_list
|
||||
assert len(calls) >= 2
|
||||
second_sql = str(calls[1].args[0].text) if calls[1].args else ""
|
||||
assert "distinct on" not in second_sql.lower()
|
||||
|
||||
|
||||
def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
||||
"""Функция корректно возвращает точки тренда при dedup_enabled=True."""
|
||||
def test_price_trend_returns_result_with_dedup() -> None:
|
||||
"""Функция корректно возвращает точки тренда (dedup включён)."""
|
||||
rows = [
|
||||
{"month": "2025-11", "ppm2": 148_000},
|
||||
{"month": "2025-12", "ppm2": 152_000},
|
||||
|
|
@ -90,7 +66,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
|||
mock_db = _make_mock_db([], rows)
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||
|
|
@ -101,25 +76,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
|||
assert result[0]["ppm2"] == 148_000
|
||||
|
||||
|
||||
def test_price_trend_dedup_disabled_returns_same_points() -> None:
|
||||
"""Флаг OFF не ломает возврат данных (backward-compat)."""
|
||||
rows = [
|
||||
{"month": "2025-11", "ppm2": 148_000},
|
||||
{"month": "2025-12", "ppm2": 152_000},
|
||||
{"month": "2026-01", "ppm2": 155_000},
|
||||
]
|
||||
mock_db = _make_mock_db([], rows)
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = False
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
||||
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
|
||||
source1_rows = [
|
||||
|
|
@ -130,7 +86,6 @@ def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
|||
mock_db = _make_mock_db(source1_rows, [])
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||
|
|
|
|||
|
|
@ -313,7 +313,6 @@ def _clamp(
|
|||
tier: str | None,
|
||||
slack: float = 0.25,
|
||||
min_n: int = 10,
|
||||
enabled: bool = True,
|
||||
median_price: int = 0,
|
||||
range_low: int = 0,
|
||||
range_high: int = 0,
|
||||
|
|
@ -328,7 +327,6 @@ def _clamp(
|
|||
anchor_tier=tier,
|
||||
slack=slack,
|
||||
min_n=min_n,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -374,15 +372,6 @@ def test_corridor_clamp_low_n_is_noop() -> None:
|
|||
assert new_ppm2 == 296_000
|
||||
|
||||
|
||||
def test_corridor_clamp_disabled_flag_is_noop() -> None:
|
||||
# enabled=False → точно старое поведение.
|
||||
new_ppm2, _, _, _, clamped = _clamp(
|
||||
median_ppm2=296_000, corridor_high=138_000, count=20, tier="C", enabled=False
|
||||
)
|
||||
assert clamped is False
|
||||
assert new_ppm2 == 296_000
|
||||
|
||||
|
||||
def test_corridor_clamp_tier_none_clamps() -> None:
|
||||
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
|
||||
new_ppm2, _, _, _, clamped = _clamp(
|
||||
|
|
|
|||
|
|
@ -379,7 +379,6 @@ def _run_estimate_qi(
|
|||
dadata_cadnum: str | None,
|
||||
qi_lookup_result: tuple[float, int] | None,
|
||||
*,
|
||||
flag_enabled: bool = True,
|
||||
anchor_tier_override: str | None = None,
|
||||
):
|
||||
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate."""
|
||||
|
|
@ -442,10 +441,6 @@ def _run_estimate_qi(
|
|||
"app.services.estimator._lookup_quarter_indexes",
|
||||
side_effect=_fake_lookup_indexes,
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.settings.estimate_quarter_index_enabled",
|
||||
flag_enabled,
|
||||
),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
|
|
@ -488,7 +483,6 @@ def test_quarter_index_correction_applied() -> None:
|
|||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.2, 30),
|
||||
flag_enabled=True,
|
||||
)
|
||||
expected_median = round(base_median * 1.2)
|
||||
assert est.median_price_rub == expected_median
|
||||
|
|
@ -497,21 +491,6 @@ def test_quarter_index_correction_applied() -> None:
|
|||
assert "квартал" in est.confidence_explanation.lower()
|
||||
|
||||
|
||||
def test_quarter_index_flag_off_exact_old_behavior() -> None:
|
||||
"""При estimate_quarter_index_enabled=False — точное старое поведение."""
|
||||
analogs_no_cadnum = [
|
||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
|
||||
]
|
||||
est = _run_estimate_qi(
|
||||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.2, 30),
|
||||
flag_enabled=False,
|
||||
)
|
||||
base_median = round(_BASE_PPM2 * _AREA)
|
||||
assert est.median_price_rub == base_median
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Guard-2: >0.6 аналогов в целевом квартале → no-op
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -534,7 +513,6 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
|
|||
analogs=analogs,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
||||
flag_enabled=True,
|
||||
)
|
||||
# Медиана НЕ должна изменяться
|
||||
assert est.median_price_rub == base_median
|
||||
|
|
@ -555,7 +533,6 @@ def test_sparse_fallback_no_row_noop() -> None:
|
|||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=None, # sparse
|
||||
flag_enabled=True,
|
||||
)
|
||||
assert est.median_price_rub == base_median
|
||||
|
||||
|
|
@ -575,7 +552,6 @@ def test_bimodal_guard_skips_high_index_small_n() -> None:
|
|||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
|
||||
flag_enabled=True,
|
||||
)
|
||||
assert est.median_price_rub == base_median
|
||||
|
||||
|
|
@ -594,7 +570,6 @@ def test_bimodal_guard_allows_high_index_large_n() -> None:
|
|||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
|
||||
flag_enabled=True,
|
||||
)
|
||||
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
|
||||
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
|
|||
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах →
|
||||
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет.
|
||||
|
||||
Эти тесты проверяют SQL-фрагмент (rn_dup-окно всегда; `AND rn_dup = 1` фильтр за
|
||||
флагом) во ВСЕХ четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
|
||||
Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ
|
||||
четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
|
||||
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
|
||||
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
|
||||
тексте (как test_estimator_price_trend_dedup.py).
|
||||
|
|
@ -14,7 +14,7 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ import pytest
|
|||
import app.services.estimator as est
|
||||
|
||||
|
||||
def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
|
||||
def _capture_tier_sql() -> list[str]:
|
||||
"""Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира.
|
||||
|
||||
target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх
|
||||
|
|
@ -40,7 +40,6 @@ def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
|
|||
|
||||
db.execute.side_effect = side_effect
|
||||
|
||||
with patch.object(est.settings, "estimate_radius_dedup_enabled", dedup_enabled):
|
||||
est._fetch_analogs(
|
||||
db,
|
||||
lat=56.83,
|
||||
|
|
@ -57,46 +56,26 @@ def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
|
|||
return captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Флаг по умолчанию
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_radius_dedup_flag_default_true() -> None:
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.estimate_radius_dedup_enabled is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL-фрагмент во всех тирах
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_four_tiers_render() -> None:
|
||||
sqls = _capture_tier_sql(dedup_enabled=True)
|
||||
sqls = _capture_tier_sql()
|
||||
assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W"
|
||||
|
||||
|
||||
def test_rn_dup_window_present_in_every_tier_when_enabled() -> None:
|
||||
sqls = _capture_tier_sql(dedup_enabled=True)
|
||||
def test_rn_dup_window_present_in_every_tier() -> None:
|
||||
sqls = _capture_tier_sql()
|
||||
for i, sql in enumerate(sqls):
|
||||
assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
|
||||
assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра"
|
||||
|
||||
|
||||
def test_dup_filter_dropped_when_disabled_but_window_stays() -> None:
|
||||
"""Флаг OFF: фильтр `AND rn_dup = 1` убран, окно остаётся (безвредно)."""
|
||||
sqls = _capture_tier_sql(dedup_enabled=False)
|
||||
assert len(sqls) == 4
|
||||
for i, sql in enumerate(sqls):
|
||||
assert "AS rn_dup" in sql, f"tier#{i}: окно должно оставаться даже при OFF"
|
||||
assert "rn_dup = 1" not in sql, f"tier#{i}: фильтр должен исчезнуть при OFF"
|
||||
|
||||
|
||||
def test_rn_dup_key_namespaced_and_freshest() -> None:
|
||||
"""Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC."""
|
||||
sql = _capture_tier_sql(dedup_enabled=True)[0]
|
||||
sql = _capture_tier_sql()[0]
|
||||
assert "PARTITION BY source" in sql
|
||||
assert "'id:' || source_id::text" in sql
|
||||
assert "'url:' || source_url" in sql
|
||||
|
|
@ -117,7 +96,7 @@ def test_no_bind_param_double_colon_cast() -> None:
|
|||
import re
|
||||
|
||||
bad = re.compile(r":[a-z_]+::[a-z]")
|
||||
for i, sql in enumerate(_capture_tier_sql(dedup_enabled=True)):
|
||||
for i, sql in enumerate(_capture_tier_sql()):
|
||||
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
||||
- dkp_raw is None → no-op (нет базы для floor)
|
||||
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
||||
- флаг estimate_radius_floor_enabled=False → no-op
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -75,7 +74,6 @@ def _run_estimate(
|
|||
analogs: list[dict[str, Any]],
|
||||
dkp_raw: dict[str, Any] | None,
|
||||
*,
|
||||
radius_floor_enabled: bool = True,
|
||||
radius_floor_factor: float = 0.8,
|
||||
) -> Any:
|
||||
from app.core.config import settings
|
||||
|
|
@ -109,7 +107,6 @@ def _run_estimate(
|
|||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
patch.object(settings, "estimate_radius_floor_enabled", radius_floor_enabled),
|
||||
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
|
@ -188,62 +185,3 @@ def test_no_dkp_raw_no_floor() -> None:
|
|||
assert (
|
||||
est.median_price_per_m2 < 100_000
|
||||
), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
||||
|
||||
|
||||
# ── тест 4: флаг OFF → no-op ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_radius_floor_disabled_no_op() -> None:
|
||||
"""estimate_radius_floor_enabled=False → floor не применяется."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=75_000.0),
|
||||
_make_listing(price_per_m2=80_000.0),
|
||||
_make_listing(price_per_m2=85_000.0),
|
||||
]
|
||||
dkp_raw = {
|
||||
"count": 15,
|
||||
"low_ppm2": 150_000,
|
||||
"median_ppm2": 180_000,
|
||||
"high_ppm2": 220_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
# Без флага — median не должна подняться
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
payload = _make_payload()
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
return_value=(list(analogs), False, "S"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
patch.object(settings, "estimate_radius_floor_enabled", False),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
est = anyio.run(_run)
|
||||
assert (
|
||||
est.median_price_per_m2 < 100_000
|
||||
), f"Флаг OFF: median_ppm2={est.median_price_per_m2} не должна расти"
|
||||
|
|
|
|||
|
|
@ -47,11 +47,8 @@ def _payload(): # type: ignore[no-untyped-def]
|
|||
return _h._make_payload(area=60.0, rooms=2)
|
||||
|
||||
|
||||
def _run(*, flag: bool, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
||||
with (
|
||||
patch.object(settings, "estimate_wide_corridor_disclosure_enabled", flag),
|
||||
patch.object(settings, "estimate_wide_corridor_threshold", threshold),
|
||||
):
|
||||
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
||||
with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
|
||||
return _h._run_estimate(
|
||||
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
|
||||
anchor_tier=anchor_tier,
|
||||
|
|
@ -63,15 +60,10 @@ _DISCLOSURE_MARKER = "разбит на секции"
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Флаг по умолчанию
|
||||
# Порог по умолчанию
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_disclosure_flag_default_on() -> None:
|
||||
# Включено после валидации порога на prod-данных (corridor_pct median≈0.48).
|
||||
assert settings.estimate_wide_corridor_disclosure_enabled is True
|
||||
|
||||
|
||||
def test_threshold_default_1_2() -> None:
|
||||
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
|
||||
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
|
||||
|
|
@ -83,21 +75,21 @@ def test_threshold_default_1_2() -> None:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tier_a_wide_corridor_flag_on_downgrades_and_discloses() -> None:
|
||||
"""Tier A + corridor_pct > threshold + флаг ON → confidence понижен + disclosure."""
|
||||
on = _run(flag=True, anchor_tier="A")
|
||||
def test_tier_a_wide_corridor_downgrades_and_discloses() -> None:
|
||||
"""Tier A + corridor_pct > threshold → confidence понижен + disclosure."""
|
||||
on = _run(anchor_tier="A")
|
||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||
assert pct > 0.6, "fixture должен давать коридор > 60%"
|
||||
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
||||
# confidence понижен относительно flag-OFF baseline (medium → low).
|
||||
off = _run(flag=False, anchor_tier="A")
|
||||
# confidence понижен относительно no-fire baseline (порог выше коридора).
|
||||
off = _run(anchor_tier="A", threshold=99.0)
|
||||
assert _downgrade_confidence(off.confidence) == on.confidence
|
||||
assert on.confidence != off.confidence
|
||||
|
||||
|
||||
def test_flag_off_no_change() -> None:
|
||||
"""Флаг OFF → ничего не делает (ни confidence, ни explanation)."""
|
||||
off = _run(flag=False, anchor_tier="A")
|
||||
def test_threshold_above_corridor_no_change() -> None:
|
||||
"""Порог выше фактического коридора → ничего не делает (ни confidence, ни explanation)."""
|
||||
off = _run(anchor_tier="A", threshold=99.0)
|
||||
assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "")
|
||||
|
||||
|
||||
|
|
@ -108,7 +100,7 @@ def test_flag_off_no_change() -> None:
|
|||
|
||||
def test_corridor_fires_when_above_threshold() -> None:
|
||||
"""corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает)."""
|
||||
on = _run(flag=True, anchor_tier="A", threshold=1.0)
|
||||
on = _run(anchor_tier="A", threshold=1.0)
|
||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||
assert pct > 1.0
|
||||
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
||||
|
|
@ -117,7 +109,7 @@ def test_corridor_fires_when_above_threshold() -> None:
|
|||
def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
|
||||
"""Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной
|
||||
атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6."""
|
||||
on = _run(flag=True, anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
|
||||
on = _run(anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
|
||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||
assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2"
|
||||
assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "")
|
||||
|
|
@ -125,8 +117,8 @@ def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
|
|||
|
||||
def test_does_not_touch_point_median_range() -> None:
|
||||
"""Disclosure НЕ трогает point/median/range — только confidence+explanation."""
|
||||
off = _run(flag=False, anchor_tier="A")
|
||||
on = _run(flag=True, anchor_tier="A")
|
||||
off = _run(anchor_tier="A", threshold=99.0)
|
||||
on = _run(anchor_tier="A")
|
||||
assert on.median_price_rub == off.median_price_rub
|
||||
assert on.range_low_rub == off.range_low_rub
|
||||
assert on.range_high_rub == off.range_high_rub
|
||||
|
|
@ -134,13 +126,13 @@ def test_does_not_touch_point_median_range() -> None:
|
|||
|
||||
def test_below_threshold_no_disclosure() -> None:
|
||||
"""corridor_pct ниже порога → без изменений (порог поднят выше фактического)."""
|
||||
high_threshold = _run(flag=True, anchor_tier="A", threshold=5.0)
|
||||
high_threshold = _run(anchor_tier="A", threshold=5.0)
|
||||
assert _DISCLOSURE_MARKER not in (high_threshold.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_non_tier_a_not_affected() -> None:
|
||||
"""Не-Tier-A (Tier C) → disclosure не применяется (даже с флагом ON)."""
|
||||
c = _run(flag=True, anchor_tier="C")
|
||||
"""Не-Tier-A (Tier C) → disclosure не применяется."""
|
||||
c = _run(anchor_tier="C")
|
||||
assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -426,7 +426,6 @@ def _run_estimate(
|
|||
*,
|
||||
anchor_comps: list[dict[str, Any]],
|
||||
anchor_tier: str | None,
|
||||
flag_enabled: bool = True,
|
||||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||
payload=None,
|
||||
radius_analogs: list[dict[str, Any]] | None = None,
|
||||
|
|
@ -439,7 +438,6 @@ def _run_estimate(
|
|||
ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать
|
||||
по same-building комплам независимо.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
|
|
@ -448,7 +446,6 @@ def _run_estimate(
|
|||
|
||||
async def _run():
|
||||
with (
|
||||
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
|
||||
patch(
|
||||
"app.services.estimator.geocode",
|
||||
new=AsyncMock(return_value=geo_result or _make_fake_geo()),
|
||||
|
|
@ -546,14 +543,6 @@ def test_estimate_economy_no_regression() -> None:
|
|||
assert est.median_price_per_m2 >= 100_000
|
||||
|
||||
|
||||
def test_estimate_flag_off_unchanged_radius_result() -> None:
|
||||
"""(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает."""
|
||||
est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False)
|
||||
# Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k.
|
||||
assert est_off.median_price_per_m2 == 210_000
|
||||
assert est_off.median_price_rub == int(210_000 * 146.2)
|
||||
|
||||
|
||||
def test_estimate_expected_sold_distinct_after_anchor() -> None:
|
||||
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
|
||||
expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median.
|
||||
|
|
|
|||
|
|
@ -67,16 +67,13 @@ def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None:
|
|||
relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
|
||||
|
||||
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
|
||||
Python-side gating — источник содержит и флаг настройки, и hardened
|
||||
secondary_count/primary_count gate (#1774, после code-review).
|
||||
Python-side gating — источник содержит hardened secondary_count/primary_count
|
||||
gate (#1774, после code-review).
|
||||
"""
|
||||
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
||||
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
|
||||
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
|
||||
# (b) Tier A теперь gated Python-side (#1774).
|
||||
assert (
|
||||
"estimate_sb_tier_a_allow_primary_if_secondary_present" in src
|
||||
), "Tier A no longer references the #1774 gating flag — gated relaxation missing"
|
||||
# Hardened gate: secondary_count ≥ primary_count (заменил has_secondary).
|
||||
assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)"
|
||||
assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)"
|
||||
|
|
|
|||
|
|
@ -105,12 +105,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
|||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
||||
|
|
@ -135,12 +130,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
|
|||
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
||||
assert tier is None
|
||||
|
|
@ -162,42 +152,13 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
|
|||
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
||||
assert tier is None
|
||||
assert comps == []
|
||||
|
||||
|
||||
def test_tier_a_flag_off_excludes_novostroyki_even_with_secondary() -> None:
|
||||
"""Flag OFF → оригинальное поведение #1186: novostroyki ВСЕГДА исключены в Tier A,
|
||||
даже если в доме есть вторичка. Остаются только 4 vtorichka/NULL."""
|
||||
rows = [
|
||||
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||
_row(source="avito", source_id="a2", listing_segment=None),
|
||||
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||
_row(source="yandex", source_id="y2", listing_segment=None),
|
||||
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", False
|
||||
),
|
||||
):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# novostroyki отброшены → только 4 вторички.
|
||||
assert len(comps) == 4
|
||||
|
||||
|
||||
def test_tier_a_dedup_same_source_id_collapses() -> None:
|
||||
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
|
||||
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
|
||||
|
|
@ -216,12 +177,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
|
|||
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
||||
|
|
@ -257,12 +213,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
|
|||
),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
||||
|
|
@ -281,12 +232,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),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue