diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d291f723..82bab36f 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -589,60 +589,6 @@ class Settings(BaseSettings): # Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо. estimate_price_trend_max_age_months: int = 6 - # ── #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 выглядит достоверным при нулевой реальной базе. - - # ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─ - # Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка - # человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит - # от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом, - # высокая стоимость, низкая уверенность, слишком широкий диапазон цены. - # Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable). - estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка - estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо - # asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна - # (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок). - estimate_manual_review_elite_ppm2: int = 250000 - - # ── #1871 P2: radius-tier (source, source_id) dedup ─────────────────────── - # Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address - # (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 нет. Добавляет rn_dup=1 фильтр в каждом тире - # (freshest scraped_at на (source, source_id|source_url|ctid)). - - # ── #2087 H4: кросс-source физический дедуп аналогов ────────────────────── - # Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id). - # Один физический лот кросс-постится на avito+cian+domklik (разные source, разные - # source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз → - # раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит - # #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных. - # True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv: - # building (building_cadastral_number | нормализованный address) - # + floor + area_bucket (round(area_m2), ~±0.5 м²) - # + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М). - # Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем; - # n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам - # («лот считается один раз»). - # - # Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% → - # 13.89%, coverage 83.33% → 83.33%, bias −3.83% → −3.83%, median width 0.743 → - # 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал - # 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют - # ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс - # ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по - # физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор — - # 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 откатывает). - estimate_dedup_analogs_enabled: bool = True - # ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ── # Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки # estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 / @@ -725,20 +671,6 @@ class Settings(BaseSettings): # ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED. estimate_is_apartments_filter_enabled: bool = False - # ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ── - # Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом - # дробится на несколько house_id). На split-доме разной этажности comp_min..max - # растягивается через несколько ценовых режимов → коридор range_low/high - # 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A + - # corridor_pct > threshold → понижаем confidence на ступень и дописываем - # disclosure в explanation. НЕ трогает point/median/range. - # Порог ширины коридора (range_high-range_low)/median_price для disclosure. - # 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 - # фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция). - # Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только - # экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость. - estimate_wide_corridor_threshold: float = 1.2 - # ── Mera-audit fix-1: Cian valuation sanity bounds ──────────────────────── # API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999). # sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется). diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index cd477ac6..9b0ccc95 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -55,7 +55,7 @@ from scraper_kit.proxy_errors import caused_by_no_proxy from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings +from app.core.config import LISTINGS_FRESH_DAYS, settings from app.core.db import SessionLocal, run_db_thread from app.schemas.trade_in import ( AggregatedEstimate, @@ -416,6 +416,31 @@ PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = ( ("премиум", float("inf")), ) +# ── #1970: калибровочные константы движка (бывшие поля Settings estimate_*) ── +# Раньше жили в Settings и формально переопределялись через ENV. Ни одно ни разу +# не переопределялось: на проде 17.09.2026 (tradein-backend/scraper/tgbot) живые +# значения совпадают с дефолтами, перенесёнными сюда. Переключателей, которые +# реально переключаются, среди них нет, поэтому это код, а не конфиг: поменять +# число = правка здесь + регрессионный гейт бэктеста +# (tests/test_backtest_regression_gate.py). + +# #1871 P2 (#2378): split-дома, disclosure широкого коридора. Tier A матчит по +# address-regex (намеренно НЕ house_id — дом дробится на несколько house_id), и на +# split-доме разной этажности comp_min..max растягивается через несколько ценовых +# режимов → коридор 148%/170%. Tier A + (range_high-range_low)/median_price > порога +# → confidence на ступень ниже + disclosure в explanation; point/median/range не +# трогает. 1.2: по прод-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 фаерил +# бы на ~31% оценок; genuine split-дома аудита = 1.48-1.70 → 1.2 ловит только хвост. +WIDE_CORRIDOR_THRESHOLD = 1.2 + +# #2002 #4 (#2378): пороги рекомендации ручной оценки (_manual_review). Чисто +# метаданные — median/expected_sold/ranges не трогают. +MANUAL_REVIEW_HIGH_VALUE_RUB = 20_000_000 # expected_sold ≥ этого — ручная оценка +MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопределённо +# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна (премия за +# отделку/вид/класс — unit-level, отсутствует в данных сделок). +MANUAL_REVIEW_ELITE_PPM2 = 250_000 + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -3394,7 +3419,7 @@ def _price_from_inputs( """ # 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и # агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не - # по кросс-постам. No-op при estimate_dedup_analogs_enabled=False. + # по кросс-постам. listings = _dedup_cross_source(listings) # #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A # same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше @@ -3726,7 +3751,7 @@ def _price_from_inputs( # #1871 P2: split-дома wide-corridor disclosure. if anchor_tier == "A" and median_price > 0: corridor_pct = (range_high - range_low) / median_price - if corridor_pct > settings.estimate_wide_corridor_threshold: + if corridor_pct > WIDE_CORRIDOR_THRESHOLD: confidence = _downgrade_confidence(confidence) explanation = (explanation or "") + ( " Очень широкий ценовой диапазон по дому (вероятно, дом " @@ -5641,7 +5666,6 @@ async def estimate_quality( confidence, range_low, range_high, - settings, # asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)), # что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите. asking_ppm2=int(median_ppm2), @@ -5781,8 +5805,8 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int | None) — premium_building флаг не выставится, даже если дом объективно элитный по рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже, строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости — - `estimate_manual_review_high_value_rub` (expected_sold_price ≥ порога) и - `estimate_manual_review_elite_ppm2` (asking ₽/м² ≥ порога) — независимо от + `MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price ≥ порога) и + `MANUAL_REVIEW_ELITE_PPM2` (asking ₽/м² ≥ порога) — независимо от premium_building. То есть oblast-элита не помечается как «премиальный дом», но почти всегда подхватывается value-based триггером той же функции. """ @@ -5819,7 +5843,6 @@ def _manual_review( confidence: str, range_low: int | None, range_high: int | None, - settings: Settings, asking_ppm2: int | None = None, ) -> tuple[bool, list[str]]: """#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена. @@ -5849,15 +5872,13 @@ def _manual_review( reasons: list[str] = [] if premium_building: reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)") - high_value = settings.estimate_manual_review_high_value_rub - if expected_sold_price and expected_sold_price >= high_value: + if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB: reasons.append("высокая стоимость (≥20 млн ₽)") if confidence == "low": reasons.append("низкая уверенность оценки") - wide_ratio = settings.estimate_manual_review_wide_range_ratio - if range_low and range_high and range_high / range_low >= wide_ratio: + if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO: reasons.append("широкий диапазон цены") - if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2: + if asking_ppm2 and asking_ppm2 >= MANUAL_REVIEW_ELITE_PPM2: reasons.append( "элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, " "вид и класс дома не отражена в данных сделок, фактическая цена может быть выше" @@ -7655,8 +7676,7 @@ def _union_find_phys_dedup( не дедупятся — остаются уникальными. include_price=True — статистический ключ (влияет на n_analogs/median/cv, - гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего - _dedup_cross_source). include_price=False — display-only ключ без + вызывающий — _dedup_cross_source). include_price=False — display-only ключ без price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у пары для merge (require_diff_source) — без цены единственный оставшийся @@ -7777,13 +7797,14 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: """Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/ median/cv/source_counts/sources_used (представитель несёт свой source → лот считается один раз; source кросс-поста, не ставший представителем, выпадает - из выборки — по нему нет независимой ценовой точки). Флаг - estimate_dedup_analogs_enabled OFF → no-op (байт-идентичный проход, нужно - для frozen backtest regression gate). Ключ включает price_bucket + из выборки — по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель + снят (#2378) — бэктест #1966 OFF vs ON был + accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%), дедуп меняет + только честность счёта n_analogs. Ключ включает price_bucket (_lot_dedup_components include_price=True по умолчанию) — см. _union_find_phys_dedup. """ - if not settings.estimate_dedup_analogs_enabled or len(lots) < 2: + if len(lots) < 2: return lots return _union_find_phys_dedup(lots, include_price=True) @@ -7829,9 +7850,8 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: случай: перевыставили на другой площадке дешевле/дороже) — без такого дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог». - Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет - статистикой (median/n_analogs), этот — только честностью UI-карточек и - может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает + Независим от _dedup_cross_source: тот управляет статистикой + (median/n_analogs), этот — только честностью UI-карточек. НЕ трогает n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены из отдельного (не обязательно совпадающего по итоговой длине) пула. """ diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 63f1785b..52181f84 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1975,12 +1975,11 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[ / ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile ``params`` block. Touches NO DB / network and does NOT consult ``settings_at_capture`` — it prices against the live committed - ``estimator.settings`` defaults (so a settings change is caught as a metric - drift, not silently honoured). ЕДИНСТВЕННОЕ исключение — - ``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не - настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного - потока (см. комментарий у пина ниже). Deterministic: same fixture → identical - dict. + ``estimator.settings`` defaults and module constants (so a change is caught as a + metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378 + работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON + (закоммиченная — такая); более старая упадёт на «control flow diverged from + capture» — её надо перезахватить. Deterministic: same fixture → identical dict. """ est = _import_estimator_full() m = est.m @@ -1991,83 +1990,60 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[ pred_ppm2_all: list[float] = [] unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре - # #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был - # no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти - # по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source - # подрезал бы listings до quarter_indexes_lookup и записанная последовательность - # вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг - # сам, а документированная регенерация baseline (--from-fixture --update-baseline) - # — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА. - # #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры, - # захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает - # НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок - # уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не - # вызывается, а реплей с dedup=False их оценивает и упирается в - # «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback - # False — для старых фикстур без settings_at_capture поведение прежнее). - _dedup_capture = bool( - (fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False) - ) - _dedup_saved = m.settings.estimate_dedup_analogs_enabled - m.settings.estimate_dedup_analogs_enabled = _dedup_capture - try: - for rec in deals: - kw = dict(rec["kwargs"]) - sold_ppm2_all.append(float(rec["sold_ppm2"])) - kw["geo"] = est.GeocodeResult(**kw["geo"]) - kw["ratio_resolver"] = _make_call_stub( - rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return - ) - # #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых - # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт - # Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие, - # которое надо знать при чтении метрик: для этих сделок квартальный индекс в - # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает - # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит. - # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода. - kw["quarter_index_lookup"] = _make_call_stub( - rec.get("qi_calls") or [], - label="quarter_index_lookup", - coerce=_coerce_qi_return, - on_exhausted=None, - unrecorded_counter=unrecorded, - ) - kw["quarter_indexes_lookup"] = _make_call_stub( - rec.get("qis_calls") or [], - label="quarter_indexes_lookup", - coerce=_coerce_qis_return, - on_exhausted={}, - unrecorded_counter=unrecorded, - ) + for rec in deals: + kw = dict(rec["kwargs"]) + sold_ppm2_all.append(float(rec["sold_ppm2"])) + kw["geo"] = est.GeocodeResult(**kw["geo"]) + kw["ratio_resolver"] = _make_call_stub( + rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return + ) + # #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых + # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт + # Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие, + # которое надо знать при чтении метрик: для этих сделок квартальный индекс в + # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает + # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит. + # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода. + kw["quarter_index_lookup"] = _make_call_stub( + rec.get("qi_calls") or [], + label="quarter_index_lookup", + coerce=_coerce_qi_return, + on_exhausted=None, + unrecorded_counter=unrecorded, + ) + kw["quarter_indexes_lookup"] = _make_call_stub( + rec.get("qis_calls") or [], + label="quarter_indexes_lookup", + coerce=_coerce_qis_return, + on_exhausted={}, + unrecorded_counter=unrecorded, + ) - pr = m._price_from_inputs(**kw) + pr = m._price_from_inputs(**kw) - _es_ppm2_raw = pr.expected_sold_per_m2 - _es_price_raw = pr.expected_sold_price - _r_low_raw = pr.expected_sold_range_low - _r_high_raw = pr.expected_sold_range_high - es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None - es_price = float(_es_price_raw) if _es_price_raw is not None else None - r_low = float(_r_low_raw) if _r_low_raw is not None else None - r_high = float(_r_high_raw) if _r_high_raw is not None else None - prediction = Prediction( - deal_id=int(rec["deal_id"]), - rooms=rec["rooms"], - area_m2=float(rec["area_m2"]), - sold_ppm2=float(rec["sold_ppm2"]), - median_ppm2=float(pr.median_ppm2), - confidence=pr.confidence, - anchor_tier=pr.anchor_tier, - expected_sold_ppm2=es_ppm2, - expected_sold_price=es_price, - range_low=r_low, - range_high=r_high, - ) - predictions.append(prediction) - pred_ppm2_all.append(prediction.median_ppm2) - - finally: - m.settings.estimate_dedup_analogs_enabled = _dedup_saved + _es_ppm2_raw = pr.expected_sold_per_m2 + _es_price_raw = pr.expected_sold_price + _r_low_raw = pr.expected_sold_range_low + _r_high_raw = pr.expected_sold_range_high + es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None + es_price = float(_es_price_raw) if _es_price_raw is not None else None + r_low = float(_r_low_raw) if _r_low_raw is not None else None + r_high = float(_r_high_raw) if _r_high_raw is not None else None + prediction = Prediction( + deal_id=int(rec["deal_id"]), + rooms=rec["rooms"], + area_m2=float(rec["area_m2"]), + sold_ppm2=float(rec["sold_ppm2"]), + median_ppm2=float(pr.median_ppm2), + confidence=pr.confidence, + anchor_tier=pr.anchor_tier, + expected_sold_ppm2=es_ppm2, + expected_sold_price=es_price, + range_low=r_low, + range_high=r_high, + ) + predictions.append(prediction) + pred_ppm2_all.append(prediction.median_ppm2) # The fixture stores ONLY priced deals, so n_no_prediction is 0 here. metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report) diff --git a/tradein-mvp/backend/tests/test_backtest_regression_gate.py b/tradein-mvp/backend/tests/test_backtest_regression_gate.py index 0275db49..8f963aa5 100644 --- a/tradein-mvp/backend/tests/test_backtest_regression_gate.py +++ b/tradein-mvp/backend/tests/test_backtest_regression_gate.py @@ -25,11 +25,8 @@ import math import os from pathlib import Path -import pytest - os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.services import estimator from scripts.backtest_estimator import load_fixture, replay_fixture _FIXTURES = Path(__file__).parent / "fixtures" @@ -76,16 +73,7 @@ def test_fixture_and_baseline_committed() -> None: assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}" -def test_backtest_regression_gate(monkeypatch: pytest.MonkeyPatch) -> None: - # The frozen fixture records the injected-callback control flow captured with - # cross-source physical dedup OFF (#2087 H4 was a no-op default at capture time). - # Dedup is now default ON (#2173), but this gate is a byte-identical REPLAY of a - # frozen OFF capture — with dedup active _dedup_cross_source would trim listings - # before quarter_indexes_lookup and the recorded call sequence would diverge. Pin - # the flag OFF so the replay follows the captured control flow. (Accuracy-neutral: - # backtest #1966 OFF vs ON is identical; dedup only trims user-visible n_analogs, - # so the OFF baseline stays the valid spine regression reference.) - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) +def test_backtest_regression_gate() -> None: fixture = load_fixture(_FIXTURE_PATH) baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8")) # Round-trip the replay output through JSON before comparing: the committed diff --git a/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py b/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py index c69cc3cc..ccb82884 100644 --- a/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py +++ b/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py @@ -172,7 +172,6 @@ def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None: # sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару — # иначе тест проверял бы не то, что заявлено в docstring. - assert estimator.settings.estimate_dedup_analogs_enabled is True deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b]) assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит" diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index 4564c412..077ae4f6 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -3,7 +3,7 @@ Проверяет: - композиты физического ключа (_lot_dedup_components) — что образуют / не образуют; - _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя - (свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично; + (свежайший scraped_at), не-дубли не трогаются; - выбор представителя детерминирован (freshest scraped_at). No DB / network: помощники оперируют plain-dict'ами. DATABASE_URL нужен только @@ -111,45 +111,28 @@ def test_phys_key_area_bucket_tolerates_rounding() -> None: # --------------------------------------------------------------------------- # -# Флаг: default ON (#2173) vs явный OFF (byte-identical) +# Дедуп без выключателя (#2378) # --------------------------------------------------------------------------- # def test_dedup_default_is_on() -> None: - # #2173: default флага теперь ON (в проде дедуп активен без ENV-override). - # Без monkeypatch settings отражает config-default → дедуп схлопывает - # кросс-пост-тройку одного физлота. Явно НЕ монкипатчим — проверяем именно - # поведение по умолчанию. - assert estimator.settings.estimate_dedup_analogs_enabled is True + # #2378: выключателя больше нет — дедуп работает всегда и схлопывает + # кросс-пост-тройку одного физлота. lots = [ _lot(source="avito"), _lot(source="cian"), _lot(source="domklik"), ] out = estimator._dedup_cross_source(lots) - assert len(out) == 1 # дедуп активен по умолчанию - - -def test_dedup_flag_off_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: - # OFF-путь проверяем ЯВНЫМ override (не полагаясь на default, который теперь ON): - # при флаге OFF проход байт-идентичен — возвращается тот же объект. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) - lots = [ - _lot(source="avito"), - _lot(source="cian"), - _lot(source="domklik"), - ] - out = estimator._dedup_cross_source(lots) - assert out is lots # тот же объект → нулевое влияние на выборку + assert len(out) == 1 # --------------------------------------------------------------------------- # -# _dedup_cross_source — флаг ON +# _dedup_cross_source # --------------------------------------------------------------------------- # -def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) +def test_dedup_collapses_cross_source_triple() -> None: # Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik. lots = [ _lot(source="avito", scraped_at=datetime(2026, 6, 1, tzinfo=UTC)), @@ -162,8 +145,7 @@ def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> assert out[0]["source"] == "cian" -def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) +def test_dedup_keeps_distinct_units() -> None: # Разные физлоты: разный этаж / площадь / цена / здание → не дубли. lots = [ _lot(source="avito", floor=4, area=67.0, price=21_995_000), @@ -176,8 +158,7 @@ def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None: assert len(out) == 5 # ничего не схлопнулось -def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) +def test_dedup_preserves_first_seen_order() -> None: # relevance-порядок из _fetch_analogs должен сохраниться: группа занимает # позицию первого появления. lots = [ @@ -191,8 +172,7 @@ def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> No assert [o["address"] for o in out] == ["ул. Мира 3", "ул. Ленина 5", "ул. Гагарина 9"] -def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) +def test_dedup_lots_without_key_stay_unique() -> None: # Нет площади → ключ None → не дедупим, даже если оба «пустые». lots = [ _lot(source="avito", area=0), @@ -225,10 +205,9 @@ def test_extract_street_token_strips_house_and_noise() -> None: assert _street_token(None) == "" -def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_three_source_formats() -> None: # Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан # как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="cian", @@ -259,10 +238,9 @@ def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) - assert len(out) == 1 # один физлот, не три -def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_merges_cadnum_asymmetry_same_street() -> None: # У одного cadnum есть, у другого нет, адрес совпадает → сливаются по # street-композиту (#2265: union по любому совпавшему ключу). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="cian", @@ -285,12 +263,9 @@ def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPat assert len(out) == 1 -def test_dedup_distinct_cadnums_same_metrics_do_not_merge( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_distinct_cadnums_same_metrics_do_not_merge() -> None: # Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ # кадастрами — авторитетный сигнал разных зданий → НЕ сливаем. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="cian", @@ -313,12 +288,9 @@ def test_dedup_distinct_cadnums_same_metrics_do_not_merge( assert len(out) == 2 -def test_dedup_same_street_different_metrics_stay_distinct( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_same_street_different_metrics_stay_distinct() -> None: # Разные лоты на одной улице с разной ценой/площадью/этажом → остаются # разными (защита от over-merge при отсутствии cadnum). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3), _lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8), @@ -350,9 +322,8 @@ def test_extract_street_token_numbered_not_degraded() -> None: assert _street_token("ул. Белинского 86") == "белинского" -def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_numbered_street_three_formats() -> None: # «8 Марта» в трёх source-форматах, cadnum пуст → один физлот. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="cian", @@ -383,10 +354,9 @@ def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.Monke assert len(out) == 1 -def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_house_no_guard_blocks_street_union() -> None: # Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера # дома → house-guard не даёт слить (both-present-different). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3), _lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3), @@ -395,10 +365,9 @@ def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatc assert len(out) == 2 -def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_house_no_guard_order_stable() -> None: # Устойчивость к порядку: домклик без номера сливается с одним из номеров, # два разных номера остаются раздельными независимо от позиции «пустого». - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) base = dict(area=33.6, price=4_000_000, floor=3, cad=None) lots = [ _lot(source="domklik", address="улица Сыромолотова", **base), # house "" @@ -410,10 +379,9 @@ def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> N assert len(out) == 2 -def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_empty_cadnum_and_street_stay_separate() -> None: # Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при # идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None), _lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None), @@ -485,7 +453,7 @@ def test_extract_street_token_extended_same_token_across_formats() -> None: assert domklik == avito == cian == "рассветная" -def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_domklik_cian_name_order() -> None: # Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1» # + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М), # cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен @@ -493,7 +461,6 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch # адреса используют одну и ту же глухую («к1») family корпуса, и после # #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера # независимо от пробела перед «к» (см. _normalize_house_no). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="domklik", @@ -519,7 +486,7 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch assert out[0]["source"] == "cian" # представитель — свежайший scraped_at -def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_domklik_cian_area_straddle() -> None: # #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в # СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному), # 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не @@ -527,7 +494,6 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa # должен склеить их в один физлот. Оба адреса — глухая («к1») family # корпуса → после #2436 нормализуются к одинаковой «6/1» (см. # _normalize_house_no), house-guard не блокирует merge независимо от пробела. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="domklik", @@ -552,11 +518,10 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa assert len(out) == 1 # склеились несмотря на area straddle 38.5/39.0 -def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_avito_glued_suffix() -> None: # (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная, # 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1» # (см. #2436 _normalize_house_no) → guard видит равные номера → склейка. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13), _lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13), @@ -565,11 +530,10 @@ def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> assert len(out) == 1 -def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_mkr_fallback_does_not_cross_with_street() -> None: # Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного: # «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не # сливаются даже при совпадении метрик. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6), _lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6), @@ -622,11 +586,10 @@ def test_parse_street_house_bare_street_requires_earlier_mkr_segment(addr: str) assert estimator._parse_street_house(addr) == ("", "") -def test_dedup_collapses_domclick_bare_street_mkr_misfire(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_domclick_bare_street_mkr_misfire() -> None: # Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше # ключевался по району и никогда не сливался с avito/cian, где «готвальда» # распознаётся явной веткой (тип-слово «улица»). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="domclick", @@ -660,10 +623,9 @@ def test_parse_street_house_yo_ye_fold() -> None: ) -def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None: +def test_dedup_collapses_yo_ye_variant() -> None: # Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах — # «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4), _lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4), @@ -753,13 +715,10 @@ def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None: assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500") -def test_dedup_collapses_avito_glued_metro_suffix_house_match( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_collapses_avito_glued_metro_suffix_house_match() -> None: # e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся, # извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard # больше не блокирует street-union (оба извлекают «2») → один физлот. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="avito", @@ -782,14 +741,11 @@ def test_dedup_collapses_avito_glued_metro_suffix_house_match( assert len(out) == 1 -def test_dedup_glued_metro_noise_does_not_cause_over_merge( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_glued_metro_noise_does_not_cause_over_merge() -> None: # Анти-регрессия: фикс не должен сделать guard_house снисходительнее для # адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома # («24» vs «26»), каждый одет в приклеенное название станции разной длины, # ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="avito", @@ -863,15 +819,12 @@ def test_parse_street_house_all_corpus_notations_agree(notation: str) -> None: assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4") -def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus() -> None: # Точный репортированный баг: avito-подобный адрес («...65к4...», глухая # нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же # физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ # схлопнуться в один. union() зависит от порядка (кто станет root) — # проверяем ОБА порядка следования лотов. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) def _pair(first_source: str, second_source: str) -> list[dict]: by_source = { @@ -903,14 +856,11 @@ def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus( assert len(out_domklik_first) == 1 -def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus() -> None: # Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за # пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и # «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не # должно. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="avito", @@ -933,13 +883,10 @@ def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus( assert len(out) == 2 -def test_dedup_house_no_guard_blocks_different_corpus_numbers( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_dedup_house_no_guard_blocks_different_corpus_numbers() -> None: # Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или # «65к4» vs «65к5») — genuinely разные здания, guard_house обязан # по-прежнему их разделять после нормализации. - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) lots = [ _lot( source="domklik", diff --git a/tradein-mvp/backend/tests/test_estimator_manual_review.py b/tradein-mvp/backend/tests/test_estimator_manual_review.py index d2c9d916..681c934c 100644 --- a/tradein-mvp/backend/tests/test_estimator_manual_review.py +++ b/tradein-mvp/backend/tests/test_estimator_manual_review.py @@ -15,7 +15,6 @@ from __future__ import annotations import os -from types import SimpleNamespace os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") @@ -33,20 +32,6 @@ ELITE_REASON = ( ) -def _settings( - *, - 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_high_value_rub=high_value, - estimate_manual_review_wide_range_ratio=wide_ratio, - estimate_manual_review_elite_ppm2=elite_ppm2, - ) - - # ── Каждый триггер в изоляции ──────────────────────────────────────────────── @@ -57,7 +42,6 @@ def test_premium_building_alone() -> None: confidence="high", range_low=7_500_000, range_high=8_500_000, - settings=_settings(), ) assert rec is True assert reasons == [PREMIUM_REASON] @@ -70,7 +54,6 @@ def test_high_value_alone() -> None: confidence="high", range_low=24_000_000, range_high=26_000_000, - settings=_settings(), ) assert rec is True assert reasons == [HIGH_VALUE_REASON] @@ -83,7 +66,6 @@ def test_low_confidence_alone() -> None: confidence="low", range_low=4_500_000, range_high=5_500_000, - settings=_settings(), ) assert rec is True assert reasons == [LOW_CONF_REASON] @@ -96,7 +78,6 @@ def test_wide_range_alone() -> None: confidence="high", range_low=5_000_000, range_high=10_000_000, # ratio 2.0 ≥ 1.9 - settings=_settings(), ) assert rec is True assert reasons == [WIDE_RANGE_REASON] @@ -114,7 +95,6 @@ def test_elite_ppm2_alone() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, # ratio ~1.18 < 1.9 - settings=_settings(), asking_ppm2=300_000, # ≥ 250_000 ) assert rec is True @@ -129,7 +109,6 @@ def test_elite_ppm2_boundary_inclusive() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, - settings=_settings(), asking_ppm2=250_000, ) assert rec is True @@ -144,7 +123,6 @@ def test_elite_ppm2_just_below_threshold() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, - settings=_settings(), asking_ppm2=249_999, ) assert rec is False @@ -159,7 +137,6 @@ def test_elite_ppm2_none_safe() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, - settings=_settings(), asking_ppm2=None, ) assert rec is False @@ -175,7 +152,6 @@ def test_elite_ppm2_zero_safe() -> None: confidence="high", range_low=11_000_000, range_high=13_000_000, - settings=_settings(), asking_ppm2=0, ) assert rec is False @@ -190,7 +166,6 @@ def test_elite_ppm2_combined_with_other_triggers() -> None: confidence="low", range_low=10_000_000, range_high=25_000_000, # ratio 2.5 ≥ 1.9 - settings=_settings(), asking_ppm2=400_000, ) assert rec is True @@ -214,7 +189,6 @@ def test_multiple_reasons_combined() -> None: confidence="low", range_low=10_000_000, range_high=25_000_000, # ratio 2.5 ≥ 1.9 - settings=_settings(), ) assert rec is True # порядок детерминирован: premium → high_value → low_conf → wide_range @@ -228,7 +202,6 @@ def test_all_clear_no_reasons() -> None: confidence="medium", range_low=9_000_000, range_high=11_000_000, # ratio ~1.22 < 1.9 - settings=_settings(), ) assert rec is False assert reasons == [] @@ -245,7 +218,6 @@ def test_high_value_boundary_inclusive() -> None: confidence="medium", range_low=19_000_000, range_high=21_000_000, - settings=_settings(), ) assert rec is True assert reasons == [HIGH_VALUE_REASON] @@ -258,7 +230,6 @@ def test_high_value_just_below_threshold() -> None: confidence="medium", range_low=19_000_000, range_high=21_000_000, - settings=_settings(), ) assert rec is False assert reasons == [] @@ -272,7 +243,6 @@ def test_wide_range_boundary_inclusive() -> None: confidence="high", range_low=10_000_000, range_high=19_000_000, # ratio == 1.9 - settings=_settings(), ) assert rec is True assert reasons == [WIDE_RANGE_REASON] @@ -285,7 +255,6 @@ def test_wide_range_just_below_threshold() -> None: confidence="high", range_low=10_000_000, range_high=18_900_000, # ratio 1.89 < 1.9 - settings=_settings(), ) assert rec is False assert reasons == [] @@ -311,7 +280,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) -> confidence="high", range_low=range_low, range_high=range_high, - settings=_settings(), ) assert rec is False assert WIDE_RANGE_REASON not in reasons @@ -332,7 +300,6 @@ def test_recommended_equals_bool_reasons_invariant() -> None: confidence=conf, range_low=9_000_000, range_high=11_000_000, - settings=_settings(), ) assert rec is bool(reasons) diff --git a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py index d73a9364..251d5964 100644 --- a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py +++ b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py @@ -26,12 +26,9 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any -import pytest - # Settings требует DATABASE_URL при инициализации (fail-fast, C-3). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") -from app.services import estimator # Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный # estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None @@ -43,16 +40,6 @@ _h = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_h) -@pytest.fixture(autouse=True) -def _pin_dedup_off(monkeypatch: pytest.MonkeyPatch) -> None: - # Эти тесты проверяют инвариант #2 «n_analogs = число priced-аналогов», - # ортогональный кросс-source физдедупу (#2087 H4, default ON с #2173). - # Синтетические аналоги делят один адрес/этаж/площадь → дедуп схлопнул бы - # близкие по цене как кросс-посты и исказил счётчик. Пиним флаг OFF, чтобы - # мерить именно #2 в изоляции (не полагаясь на прод-default, который теперь ON). - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) - - def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any]: """Радиусный аналог; price_per_m2=None моделирует листинг без ₽/м². @@ -103,8 +90,10 @@ def test_n_analogs_counts_only_priced_radius_analogs() -> None: _listing(price_per_m2=210_000.0), _listing(price_per_m2=215_000.0), _listing(price_per_m2=225_000.0), - _listing(price_per_m2=None), - _listing(price_per_m2=None), + # Разная площадь → разные физлоты: кросс-source дедуп (#2087 H4, всегда ON + # с #2378) не схлопывает два безценовых листинга в один. + _listing(price_per_m2=None, area_m2=61.0), + _listing(price_per_m2=None, area_m2=62.0), ] est = _h._run_estimate( anchor_comps=[], @@ -141,7 +130,8 @@ def test_n_analogs_all_priceless_yields_zero_and_low() -> None: def test_n_analogs_all_priced_unchanged() -> None: """Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется, зеркало test_radius_path_n_analogs_unchanged для priced-only выборки).""" - radius = [_listing(price_per_m2=200_000.0 + i * 1_000) for i in range(5)] + # Разная площадь → 5 разных физлотов, кросс-source дедуп их не схлопывает. + radius = [_listing(price_per_m2=200_000.0 + i * 1_000, area_m2=60.0 + i) for i in range(5)] est = _h._run_estimate( anchor_comps=[], anchor_tier=None, diff --git a/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py b/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py index f594deb4..afc2ea1b 100644 --- a/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py +++ b/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py @@ -22,8 +22,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/ import pytest -from app.core.config import settings -from app.services.estimator import _downgrade_confidence +from app.services.estimator import WIDE_CORRIDOR_THRESHOLD, _downgrade_confidence # Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный # estimate путь со всеми I/O застабленными). @@ -48,7 +47,7 @@ def _payload(): # type: ignore[no-untyped-def] 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): + with patch("app.services.estimator.WIDE_CORRIDOR_THRESHOLD", threshold): return _h._run_estimate( anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps, anchor_tier=anchor_tier, @@ -67,7 +66,7 @@ _DISCLOSURE_MARKER = "разбит на секции" def test_threshold_default_1_2() -> None: # 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом); # genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост. - assert settings.estimate_wide_corridor_threshold == 1.2 + assert WIDE_CORRIDOR_THRESHOLD == 1.2 # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 0f0502ee..2fadaeca 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -24,9 +24,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/ from unittest.mock import AsyncMock, MagicMock, patch import anyio -import pytest -from app.services import estimator from app.services.estimator import ( _compute_same_building_anchor, _geocode_is_coarse, @@ -1040,16 +1038,14 @@ def test_anchor_n_analogs_syncs_to_anchor_count() -> None: assert "по 4 аналогам" in (est.confidence_explanation or "") -def test_radius_path_n_analogs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: +def test_radius_path_n_analogs_unchanged() -> None: """#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется). Проверяет radius-passthrough vs anchor-override — ортогонально кросс-source - дедупу (#2087 H4, default ON с #2173). 5 ИДЕНТИЧНЫХ листингов — это ровно - патологический кросс-пост, который дедуп схлопнул бы в 1; пиним флаг OFF, - чтобы мерить именно #695-инвариант, не полагаясь на прод-default (теперь ON). + дедупу (#2087 H4, всегда ON с #2378). Площади разные, поэтому это 5 разных + физлотов: 5 ИДЕНТИЧНЫХ листингов дедуп честно схлопнул бы в 1. """ - monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) - radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)] + radius = [_make_listing(price_per_m2=200_000.0, area_m2=60.0 + i) for i in range(5)] est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius) assert est.n_analogs == 5