From 45bd47fdd72b1b167c90f14e684bb4c52046a1d5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:29:26 +0500 Subject: [PATCH 1/8] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D0=BF=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=B8=20=D1=80?= =?UTF-8?q?=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20(#2378)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Флаг estimate_dedup_analogs_enabled снят: кросс-source дедуп работает всегда. На проде 17.09 флаг = True во всех трёх контейнерах (backend/scraper/tgbot), ENV-оверрайда нет; фикстура бэктеста захвачена с True, поэтому пин флага в реплее и monkeypatch в гейте больше не нужны. Числовые пороги estimate_wide_corridor_threshold и три estimate_manual_review_* перенесены в estimator.py константами модуля с прежними значениями (1.2 / 20 000 000 / 1.9 / 250 000). _manual_review больше не принимает settings. Осиротевшие комментарии Settings к уже снятым в #2475 флагам (#1871 P1.2, P2 radius-dedup) удалены. Тесты: OFF-тест дедупа удалён; два теста, пинившие дедуп OFF ради изоляции, получили разные площади у аналогов (разные физлоты). Регрессионный гейт и реплей бэктеста по сделкам (1600, из них 615 радиусных) побитово те же. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 68 --------- tradein-mvp/backend/app/services/estimator.py | 62 +++++--- .../backend/scripts/backtest_estimator.py | 138 ++++++++---------- .../tests/test_backtest_regression_gate.py | 14 +- ...t_estimator_analogs_display_consistency.py | 1 - .../test_estimator_dedup_cross_source_2087.py | 111 ++++---------- .../tests/test_estimator_manual_review.py | 33 ----- .../tests/test_estimator_n_analogs_priced.py | 22 +-- .../test_estimator_split_corridor_1871.py | 7 +- .../tests/test_same_building_anchor.py | 12 +- 10 files changed, 141 insertions(+), 327 deletions(-) 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 -- 2.45.3 From 0ff27030322b4400127415a7ccbeb04827b1cb1a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:31:12 +0500 Subject: [PATCH 2/8] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=B8=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B8=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B0=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=D1=8B=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BA=D0=B0?= =?UTF-8?q?,=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20(#2379)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единственный переключатель estimate_quarter_index_enabled снят ещё в #2475. Оставшиеся шесть числовых полей Settings (min_n_deals 10, match_skip_ratio 0.6, max_for_small_n 2.0, small_n_threshold 50, factor_min 0.6, factor_max 1.8) перенесены в estimator.py константами QUARTER_* с теми же значениями и комментариями #764/#859; бэктест берёт QUARTER_INDEX_MIN_N_DEALS из модуля. На проде 17.09 все шесть равны дефолтам, ENV-оверрайдов нет. Замороженная фикстура квартальный индекс не применяет ни разу (0 из 1600 сделок), а поведенческие тесты не краснеют при подмене трёх порогов из шести, поэтому добавлен tests/test_1970_estimator_constants.py: боевые значения, снятые с прода, и проверка, что поле не вернулось в Settings. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 21 +-------- tradein-mvp/backend/app/services/estimator.py | 36 +++++++++++---- .../backend/scripts/backtest_estimator.py | 5 +-- .../tests/test_1970_estimator_constants.py | 45 +++++++++++++++++++ 4 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_1970_estimator_constants.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 82bab36f..fb3b2d3b 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -695,26 +695,7 @@ class Settings(BaseSettings): # datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER). avito_imv_use_browser_fetcher: bool = False - # ── #764: per-cadastral-quarter price index correction ─────────────────── - # Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути - # (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ - # между квартальным уровнем целевого объекта и усреднённым квартальным уровнем - # аналогов — не дублирует location, уже заложенный в медиану аналогов. - # Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index. - # Минимальное число сделок в квартале (sparse fallback: меньше — no-op). - estimate_quarter_index_min_n_deals: int = 10 - # Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога — - # аналоги уже несут локацию квартала → skip (location in median). - estimate_quarter_match_skip_ratio: float = 0.6 - # Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы - # дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию. - estimate_quarter_index_max_for_small_n: float = 2.0 - estimate_quarter_index_small_n_threshold: int = 50 - # Sanity-clamp на factor = target_index / avg_analog_index (#859). - # Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные - # индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал. - estimate_quarter_index_factor_min: float = 0.6 - estimate_quarter_index_factor_max: float = 1.8 + # ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ── # Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local), # когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде. # ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED). diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 9b0ccc95..c4aec71f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -441,6 +441,24 @@ MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопр # отделку/вид/класс — unit-level, отсутствует в данных сделок). MANUAL_REVIEW_ELITE_PPM2 = 250_000 +# #764 (#2379): поправка на квартальный индекс цены. Применяется ТОЛЬКО на +# pure-radius пути (same-building anchor и IMV-blend не сработали) и корректирует +# РАЗРЫВ между квартальным уровнем цели и средним уровнем аналогов: +# adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index. +# Минимум сделок в квартале, чтобы индексу верить (меньше — поправки нет). +QUARTER_INDEX_MIN_N_DEALS = 10 +# Guard-2 (no double-count): доля аналогов ИЗ ТОГО ЖЕ квартала выше порога — +# локация квартала уже в медиане → skip. +QUARTER_MATCH_SKIP_RATIO = 0.6 +# Bimodal/nominal guard (бэктест 2026-05-31): структурно неоднородные кварталы дают +# индекс > 2.0 на малой выборке (< 50 сделок) → no-op, иначе регрессия. +QUARTER_INDEX_MAX_FOR_SMALL_N = 2.0 +QUARTER_INDEX_SMALL_N_THRESHOLD = 50 +# #859: sanity-clamp factor = target_index / avg_analog_index. Нормальные +# квартальные индексы РФ лежат в [0.6, 1.8]; за порогами — артефакт FDW, не сигнал. +QUARTER_INDEX_FACTOR_MIN = 0.6 +QUARTER_INDEX_FACTOR_MAX = 1.8 + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -3936,15 +3954,15 @@ def _price_from_inputs( # Bimodal/nominal guard (Guard-4). if ( - target_qi > settings.estimate_quarter_index_max_for_small_n - and target_n_deals < settings.estimate_quarter_index_small_n_threshold + target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N + and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD ): logger.info( "quarter_index: bimodal guard triggered " "(index=%.3f n=%d < %d) for %s — no-op", target_qi, target_n_deals, - settings.estimate_quarter_index_small_n_threshold, + QUARTER_INDEX_SMALL_N_THRESHOLD, target_quarter, ) else: @@ -3969,11 +3987,11 @@ def _price_from_inputs( if lot_quarters_for_guard2 else 0.0 ) - if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio: + if same_quarter_ratio > QUARTER_MATCH_SKIP_RATIO: logger.info( "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s", same_quarter_ratio, - settings.estimate_quarter_match_skip_ratio, + QUARTER_MATCH_SKIP_RATIO, target_quarter, ) else: @@ -4026,8 +4044,8 @@ def _price_from_inputs( base_range_high=range_high, target_index=target_qi, avg_analog_index=avg_analog_index, - min_factor=settings.estimate_quarter_index_factor_min, - max_factor=settings.estimate_quarter_index_factor_max, + min_factor=QUARTER_INDEX_FACTOR_MIN, + max_factor=QUARTER_INDEX_FACTOR_MAX, ) analogs_with_qi = sum( 1 for lq, _lp in analog_quarters if lq in analog_index_map @@ -5191,14 +5209,14 @@ async def estimate_quality( return _lookup_quarter_index( db, quarter_cad_number=q, - min_n_deals=settings.estimate_quarter_index_min_n_deals, + min_n_deals=QUARTER_INDEX_MIN_N_DEALS, ) def _qis_lookup(qs: list[str]) -> dict[str, float]: return _lookup_quarter_indexes( db, quarter_cad_numbers=qs, - min_n_deals=settings.estimate_quarter_index_min_n_deals, + min_n_deals=QUARTER_INDEX_MIN_N_DEALS, ) # ── Deterministic pricing orchestration ────────────────────────────────── diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 52181f84..f77eb0c8 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -2099,7 +2099,6 @@ def _predict_full_spine( whether the house IMV anchor actually resolved a row for this deal. """ m = est.m - settings = est.settings listings, analog_tier, fallback_used, area_widened = _select_analogs_full( db, deal, est, target_house_id=target_house_id @@ -2188,7 +2187,7 @@ def _predict_full_spine( res = m._lookup_quarter_index( db, quarter_cad_number=q, - min_n_deals=settings.estimate_quarter_index_min_n_deals, + min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS, ) if capture is not None: ret = _sanitize_json(list(res) if res is not None else None) @@ -2199,7 +2198,7 @@ def _predict_full_spine( res = m._lookup_quarter_indexes( db, quarter_cad_numbers=qs, - min_n_deals=settings.estimate_quarter_index_min_n_deals, + min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS, ) if capture is not None: qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))]) diff --git a/tradein-mvp/backend/tests/test_1970_estimator_constants.py b/tradein-mvp/backend/tests/test_1970_estimator_constants.py new file mode 100644 index 00000000..742e512b --- /dev/null +++ b/tradein-mvp/backend/tests/test_1970_estimator_constants.py @@ -0,0 +1,45 @@ +"""#1970: калибровочные константы движка, вынесенные из Settings, — значения. + +Поля estimate_* жили в Settings и формально переопределялись через ENV. При +переносе в константы estimator.py значения обязаны остаться боевыми. Числа ниже +сняты с прода 17.09.2026 (`settings.<поле>` в tradein-backend, tradein-scraper и +tradein-tgbot — совпали, ENV-оверрайдов нет), а не скопированы из кода. + +Зачем отдельный тест: замороженная фикстура бэктеста часть механизмов не +проходит вовсе (квартальный индекс в реплее применяется 0 раз из 1600 сделок), +а поведенческие тесты ловят не все пороги. Поменять число — осознанно: здесь, +в estimator.py и с прогоном регрессионного гейта. +""" + +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.core.config import Settings +from app.services import estimator + +# имя константы в estimator.py → (бывшее поле Settings, боевое значение 17.09.2026) +_PROD_VALUES: dict[str, tuple[str, float]] = { + # #2379, #764 квартальный индекс + "QUARTER_INDEX_MIN_N_DEALS": ("estimate_quarter_index_min_n_deals", 10), + "QUARTER_MATCH_SKIP_RATIO": ("estimate_quarter_match_skip_ratio", 0.6), + "QUARTER_INDEX_MAX_FOR_SMALL_N": ("estimate_quarter_index_max_for_small_n", 2.0), + "QUARTER_INDEX_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50), + "QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6), + "QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8), +} + + +@pytest.mark.parametrize("const", sorted(_PROD_VALUES)) +def test_constant_keeps_prod_value(const: str) -> None: + assert getattr(estimator, const) == _PROD_VALUES[const][1] + + +@pytest.mark.parametrize("const", sorted(_PROD_VALUES)) +def test_settings_field_is_gone(const: str) -> None: + # Поле вернулось в Settings → снова два источника одного числа (ENV и код). + assert _PROD_VALUES[const][0] not in Settings.model_fields -- 2.45.3 From 883c741a63309d3dc5c5f7ffb4c7be0fdc81b63a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:33:56 +0500 Subject: [PATCH 3/8] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=BC=D0=BF=20expected=5Fsold=20=E2=89=A4=20asking=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F,=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=20#1795=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=D1=8B=20(#2380)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Флаг estimate_expected_sold_le_asking снят: оба клампа (ratio > 1.0 и повторный после хедоники) безусловные. На проде 17.09 флаг = True во всех трёх контейнерах, ENV-оверрайда нет, фикстура бэктеста захвачена с True. Флаги corridor_clamp/radius_floor enabled сняты ещё в #2475. Числовые пороги перенесены с прежними значениями: CORRIDOR_CLAMP_SLACK 0.40, RADIUS_FLOOR_FACTOR 0.8, OUTLIER_SMALL_N_THRESHOLD 15, OUTLIER_TUKEY_K_SMALL 1.0 — в estimator.py; CORRIDOR_CLAMP_MIN_N 10 — в app.core.config рядом с LISTINGS_FRESH_DAYS, потому что его же читает DkpCorridor.advisory_only (#3452), а схема не должна тянуть estimator. Мёртвая проверка `tukey_k_small < 1.5` (константа против константы) убрана. Сегментный множитель (#2255) не тронут, порядок операций прежний. Тесты: OFF-тесты клампа удалены; тест потолка хедоники берёт ratio 0.70, при котором кламп не срабатывает (0.70 × 1.30 = 0.91), вместо выключения клампа. Реплей бэктеста по сделкам побитово тот же (бизнес 169, элит 5, премиум 4). Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 45 +++----------- tradein-mvp/backend/app/schemas/trade_in.py | 8 +-- tradein-mvp/backend/app/services/estimator.py | 59 ++++++++++++------- .../tests/test_1970_estimator_constants.py | 17 ++++++ .../tests/test_3452_corridor_advisory_zone.py | 21 ++++--- .../test_estimator_expected_sold_clamp.py | 21 +------ .../backend/tests/test_estimator_hedonic.py | 26 +++----- .../tests/test_estimator_radius_floor.py | 10 +--- tradein-mvp/frontend/src/lib/rosreestr.ts | 2 +- tradein-mvp/frontend/src/types/trade-in.ts | 2 +- 10 files changed, 92 insertions(+), 119 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index fb3b2d3b..763c75fd 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -44,6 +44,12 @@ _AUTH_DB_DEFAULT_USER = "auth_app" # никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк). LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней +# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп +# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py: +# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light. +# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось. +CORRIDOR_CLAMP_MIN_N = 10 + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -521,38 +527,7 @@ class Settings(BaseSettings): estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7% estimate_hedonic_factor_min: float = 0.75 estimate_hedonic_factor_max: float = 1.30 - # ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ────── - # Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП - # (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%. - # Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение. - # - # Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда - # 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_min_n: int = 10 - # cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов - # (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию - # (tier-C гейт ×1.5 ловит явную контаминацию выше). - estimate_corridor_clamp_slack: float = 0.40 - # Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу. - # Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor. - # Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw. - # factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома - # (избегаем ложных подъёмов) и ловит явный undershoot. - # ENV: ESTIMATE_RADIUS_FLOOR_FACTOR. - estimate_radius_floor_factor: float = 0.8 - # Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для - # trade-in (ожидаемая цена сделки не должна превышать цену объявления). - # Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные - # сделки выше прайса) -> expected_sold = headline x ratio > headline. - # При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется - # к point И range (expected_sold_low/high/price) консистентно. - # False -> старое поведение без clamp (backward-compat). - # ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING. - estimate_expected_sold_le_asking: bool = Field( - default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING" - ) + # ── #1795: premium headline anti-inflation (шаги 1/4/5 — константы estimator.py) ── # Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor: # при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k # (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх). @@ -563,10 +538,6 @@ class Settings(BaseSettings): # anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану. # Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение). estimate_anchor_tier_c_corridor_mult: float = 1.5 - # Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold - # k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение). - estimate_outlier_small_n_threshold: int = 15 - estimate_outlier_tukey_k_small: float = 1.0 # #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же # доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где # "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free). @@ -884,7 +855,7 @@ class Settings(BaseSettings): # регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138) # регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062) # Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50), - # 0.728→0.762 (66) — все ниже 1.0, клампа estimate_expected_sold_le_asking не задевают. + # 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают. # NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его # bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод # обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025. diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 00d6c074..fd9913e0 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -163,7 +163,7 @@ class DkpCorridor(BaseModel): [low,high]×slack — добавляется текстовая пометка. None / count=0 если по улице нет сопоставимых сделок. - #3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n + #3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only. """ @@ -192,7 +192,7 @@ class DkpCorridor(BaseModel): def advisory_only(self) -> bool: """#3452: True = сделок меньше порога, ценовые страховки коридора выключены. - Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ + Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ путей коридора: soft-кламп headline сверху и radius-floor снизу (estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит цену — зона n=3..9 на экране была неотличима от работающей. @@ -207,9 +207,9 @@ class DkpCorridor(BaseModel): автоматически (POST /estimate и GET-rehydrate) и не дублирует порог вторым числом. """ - from app.core.config import settings # локально: schemas остаётся import-light + from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light - return self.count < settings.estimate_corridor_clamp_min_n + return self.count < CORRIDOR_CLAMP_MIN_N class PriceTrendPoint(BaseModel): diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index c4aec71f..d826ca12 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 +from app.core.config import CORRIDOR_CLAMP_MIN_N, LISTINGS_FRESH_DAYS, settings from app.core.db import SessionLocal, run_db_thread from app.schemas.trade_in import ( AggregatedEstimate, @@ -316,7 +316,7 @@ def rehydrate_search_radius_m( # Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев # time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому — # московский коридор занижен на ~5.9%. Занижен НЕ advisory: при -# n>=estimate_corridor_clamp_min_n коридор участвует в clamp headline, radius-floor +# n>=CORRIDOR_CLAMP_MIN_N коридор участвует в clamp headline, radius-floor # и Tier-C anchor gate, т.е. едет итоговая цена. SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE) @@ -459,6 +459,26 @@ QUARTER_INDEX_SMALL_N_THRESHOLD = 50 QUARTER_INDEX_FACTOR_MIN = 0.6 QUARTER_INDEX_FACTOR_MAX = 1.8 +# #1795 (#2380): premium headline anti-inflation. Диагноз: бизнес/премиум headline +# завышался ~2× против медианы реальных ДКП (Малышева 30 = 296k при медиане сделок +# 138k), эконом/комфорт сходились ±5%. Порог числа сделок CORRIDOR_CLAMP_MIN_N — в +# app.core.config (его же читает DkpCorridor.advisory_only, #3452). +# Шаг 1 — soft-кламп headline к коридору ДКП-сделок: median_ppm2 > high_ppm2×(1+slack) +# И count ≥ CORRIDOR_CLAMP_MIN_N И anchor_tier != "A" (комплы того же дома EXEMPT) → +# headline прижимается к high_ppm2×(1+slack). 0.40 → cap ×1.40 даёт премиум-домам без +# своих листингов (tier-C) воздух над sold-коридором, не возвращая 2× инфляцию. +CORRIDOR_CLAMP_SLACK = 0.40 +# Нижний floor radius-пути (anchor_tier is None, коридор с count ≥ min_n): +# median_ppm2 < dkp_low_ppm2 × factor → поднимаем до floor. 0.8 — 20% зазор ниже P10 +# коридора: достаточно мягко для эконома, ловит явный undershoot. +RADIUS_FLOOR_FACTOR = 0.8 +# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < порога k = 1.0 вместо +# 1.5, иначе несколько элитных листингов тонкой выборки тянут медиану вверх. +OUTLIER_SMALL_N_THRESHOLD = 15 +OUTLIER_TUKEY_K_SMALL = 1.0 +# Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0 +# для trade-in физически невозможен, это артефакт high-price бакета. + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -2056,7 +2076,7 @@ def _resolve_target_city(address_text: str | None) -> str | None: Returns None если ни один город не распознан (адрес вне известного списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без city-фильтра. NB: коридор НЕ чисто advisory — при - n>=estimate_corridor_clamp_min_n участвует в clamp headline + radius-floor + + n>=CORRIDOR_CLAMP_MIN_N участвует в clamp headline + radius-floor + Tier-C anchor gate, поэтому city-scope влияет на итоговую цену. """ if not address_text: @@ -2266,7 +2286,7 @@ def _fetch_dkp_corridor( latest_deal = dd ppm2_values = sorted(adjusted) # #3452: чем получен коридор — улицей или расширением до города (widen ниже). - # Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и + # Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и # «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит # (pydantic игнорирует лишние kwargs), это служебная метка для логов. scope = "street" @@ -4067,7 +4087,7 @@ def _price_from_inputs( sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"}) # ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ────────────── - slack = settings.estimate_corridor_clamp_slack + slack = CORRIDOR_CLAMP_SLACK if dkp_raw is not None and median_ppm2 > 0: old_ppm2 = median_ppm2 median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp( @@ -4079,7 +4099,7 @@ def _price_from_inputs( corridor_count=dkp_raw["count"], anchor_tier=anchor_tier, slack=slack, - min_n=settings.estimate_corridor_clamp_min_n, + min_n=CORRIDOR_CLAMP_MIN_N, ) if clamped: logger.info( @@ -4102,9 +4122,9 @@ def _price_from_inputs( and dkp_raw is not None and dkp_raw.get("low_ppm2", 0) > 0 and median_ppm2 > 0 - and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n + and dkp_raw.get("count", 0) >= CORRIDOR_CLAMP_MIN_N ): - radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor + radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * RADIUS_FLOOR_FACTOR if median_ppm2 < radius_floor_ppm2: floor_factor = radius_floor_ppm2 / median_ppm2 logger.info( @@ -4112,7 +4132,7 @@ def _price_from_inputs( " → lifting (factor=%.3f)", int(median_ppm2), dkp_raw["low_ppm2"], - settings.estimate_radius_floor_factor, + RADIUS_FLOOR_FACTOR, int(radius_floor_ppm2), floor_factor, ) @@ -4132,7 +4152,7 @@ def _price_from_inputs( if asking_to_sold_ratio is not None and median_price > 0: effective_ratio = asking_to_sold_ratio - if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0: + if effective_ratio > 1.0: logger.info( "expected_sold ratio clamped %.3f->1.0 (rooms=%s)", effective_ratio, @@ -4175,7 +4195,7 @@ def _price_from_inputs( # which is an overpay risk for trade-in. Re-clamp the corrected point back to # the headline so the calibrated PI range below wraps the clamped point. # round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float). - if settings.estimate_expected_sold_le_asking and expected_sold_price: + if expected_sold_price: 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)) @@ -5354,7 +5374,7 @@ async def estimate_quality( estimate_id = uuid4() # #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок - # меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и + # меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и # soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе). # Зона n=3..9 на экране неотличима от работающего коридора, поэтому # попадание в неё пишется явной строкой: число оценок за сутки — @@ -5365,7 +5385,7 @@ async def estimate_quality( "corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s", estimate_id, dkp_corridor.count, - settings.estimate_corridor_clamp_min_n, + CORRIDOR_CLAMP_MIN_N, (dkp_raw or {}).get("scope", "unknown"), target_city or "unknown", ) @@ -7881,10 +7901,10 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: """Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR]. - #1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold) - ужесточаем k с 1.5 до estimate_outlier_tukey_k_small — несколько элитных + #1795 шаг 4: на малых выборках (n < OUTLIER_SMALL_N_THRESHOLD) + ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL — несколько элитных листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и - тянут радиусную медиану вверх. threshold=0 → выключено (старое поведение). + тянут радиусную медиану вверх. """ if len(lots) < 5: return lots # на маленькой выборке нечего фильтровать @@ -7894,11 +7914,8 @@ def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: return lots tukey_k = 1.5 - if ( - settings.estimate_outlier_tukey_k_small < 1.5 - and len(prices) < settings.estimate_outlier_small_n_threshold - ): - tukey_k = settings.estimate_outlier_tukey_k_small + if len(prices) < OUTLIER_SMALL_N_THRESHOLD: + tukey_k = OUTLIER_TUKEY_K_SMALL q1 = _percentile(prices, 0.25) q3 = _percentile(prices, 0.75) diff --git a/tradein-mvp/backend/tests/test_1970_estimator_constants.py b/tradein-mvp/backend/tests/test_1970_estimator_constants.py index 742e512b..4be8989f 100644 --- a/tradein-mvp/backend/tests/test_1970_estimator_constants.py +++ b/tradein-mvp/backend/tests/test_1970_estimator_constants.py @@ -31,8 +31,20 @@ _PROD_VALUES: dict[str, tuple[str, float]] = { "QUARTER_INDEX_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50), "QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6), "QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8), + # #2380, #1795 anti-inflation (CORRIDOR_CLAMP_MIN_N определён в app.core.config) + "CORRIDOR_CLAMP_MIN_N": ("estimate_corridor_clamp_min_n", 10), + "CORRIDOR_CLAMP_SLACK": ("estimate_corridor_clamp_slack", 0.40), + "RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8), + "OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15), + "OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0), } +# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены. +_REMOVED_SWITCHES = ( + "estimate_dedup_analogs_enabled", # #2378 + "estimate_expected_sold_le_asking", # #2380 +) + @pytest.mark.parametrize("const", sorted(_PROD_VALUES)) def test_constant_keeps_prod_value(const: str) -> None: @@ -43,3 +55,8 @@ def test_constant_keeps_prod_value(const: str) -> None: def test_settings_field_is_gone(const: str) -> None: # Поле вернулось в Settings → снова два источника одного числа (ENV и код). assert _PROD_VALUES[const][0] not in Settings.model_fields + + +@pytest.mark.parametrize("field", _REMOVED_SWITCHES) +def test_switch_is_gone(field: str) -> None: + assert field not in Settings.model_fields diff --git a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py index afcdabf7..08d61769 100644 --- a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py +++ b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py @@ -3,7 +3,7 @@ Два порога на одну выборку. Показ коридора открывается с трёх сделок (`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся -`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор +`CORRIDOR_CLAMP_MIN_N` (10). Между ними лежит зона, где коридор существует, показывается и участвует в fallback-путях, а цену не держит; на экране это неотличимо от работающего коридора. PR #3445 (снятие предиката `d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8 @@ -34,8 +34,8 @@ import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.core.config import settings -from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N +from app.core.config import CORRIDOR_CLAMP_MIN_N +from app.services.estimator import CORRIDOR_CLAMP_SLACK, DKP_CORRIDOR_CITY_WIDE_MIN_N # Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он # включён порогом, ОБЯЗАН сработать и прижать headline к cap. @@ -46,7 +46,7 @@ _MARKER = "corridor_advisory_zone" def _cap_ppm2() -> float: """Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp.""" - return _CORRIDOR_HIGH_PPM2 * (1.0 + settings.estimate_corridor_clamp_slack) + return _CORRIDOR_HIGH_PPM2 * (1.0 + CORRIDOR_CLAMP_SLACK) def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]: @@ -157,7 +157,7 @@ def test_zone_exists_at_all() -> None: Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо, а не оставит два теста ниже молча проверять пустое множество. """ - assert settings.estimate_corridor_clamp_min_n > DKP_CORRIDOR_CITY_WIDE_MIN_N, ( + assert CORRIDOR_CLAMP_MIN_N > DKP_CORRIDOR_CITY_WIDE_MIN_N, ( "зона 3..9 схлопнулась — пороги показа и клампа сравнялись, " "advisory-only решение #3452 больше не описывает реальность" ) @@ -167,15 +167,14 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp( caplog: pytest.LogCaptureFixture, ) -> None: """n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан.""" - n = settings.estimate_corridor_clamp_min_n - 1 + n = CORRIDOR_CLAMP_MIN_N - 1 with caplog.at_level(logging.INFO, logger="app.services.estimator"): est = _run_estimate(_corridor(n)) assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже" assert est.dkp_corridor.count == n assert est.dkp_corridor.advisory_only is True, ( - f"n={n} < порога {settings.estimate_corridor_clamp_min_n} — коридор справочный, " - "ответ обязан это называть" + f"n={n} < порога {CORRIDOR_CLAMP_MIN_N} — коридор справочный, ответ обязан это называть" ) # ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал. assert est.median_price_per_m2 > _cap_ppm2(), ( @@ -185,7 +184,7 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp( hits = [r for r in caplog.records if _MARKER in r.getMessage()] assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}" msg = hits[0].getMessage() - assert f"n={n}" in msg and f"min_n={settings.estimate_corridor_clamp_min_n}" in msg, msg + assert f"n={n}" in msg and f"min_n={CORRIDOR_CLAMP_MIN_N}" in msg, msg assert "scope=street" in msg, msg @@ -193,13 +192,13 @@ def test_corridor_above_threshold_is_not_flagged_and_clamps( caplog: pytest.LogCaptureFixture, ) -> None: """n выше порога: метки нет, headline прижат к потолку коридора, лога нет.""" - n = settings.estimate_corridor_clamp_min_n + 2 + n = CORRIDOR_CLAMP_MIN_N + 2 with caplog.at_level(logging.INFO, logger="app.services.estimator"): est = _run_estimate(_corridor(n)) assert est.dkp_corridor is not None assert est.dkp_corridor.advisory_only is False, ( - f"n={n} >= порога {settings.estimate_corridor_clamp_min_n} — коридор в цене участвует, " + f"n={n} >= порога {CORRIDOR_CLAMP_MIN_N} — коридор в цене участвует, " "справочным его называть нельзя" ) assert est.median_price_per_m2 <= round(_cap_ppm2()), ( diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py index ec38f8a5..290f03b7 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py @@ -80,8 +80,6 @@ def _make_payload(rooms: int = 3): def _run_estimate( ratio_tuple: tuple[float | None, str | None], - *, - clamp_enabled: bool = True, ) -> Any: from app.services.estimator import estimate_quality @@ -90,7 +88,6 @@ def _run_estimate( async def _run() -> Any: with ( - patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled), # #2002: these tests assert the clamp/ratio math exactly. Hold the # orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold). patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False), @@ -131,7 +128,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1( ) -> None: ratio = 1.15 with caplog.at_level(logging.INFO, logger="app.services.estimator"): - est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) + est = _run_estimate((ratio, "per_rooms")) assert est.median_price_rub > 0, "headline должен быть задан аналогами" assert est.expected_sold_price_rub is not None assert est.expected_sold_price_rub <= est.median_price_rub, ( @@ -167,7 +164,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1( def test_expected_sold_not_clamped_when_ratio_below_1() -> None: ratio = 0.8 - est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) + est = _run_estimate((ratio, "per_rooms")) assert est.median_price_rub > 0 assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio) @@ -182,22 +179,10 @@ def test_expected_sold_not_clamped_when_ratio_below_1() -> None: def test_expected_sold_none_when_ratio_none() -> None: - est = _run_estimate((None, None), clamp_enabled=True) + est = _run_estimate((None, None)) assert est.expected_sold_price_rub is None assert est.expected_sold_per_m2 is None assert est.expected_sold_range_low_rub is None assert est.expected_sold_range_high_rub is None assert est.asking_to_sold_ratio is None assert est.median_price_rub > 0 - - -def test_expected_sold_exceeds_asking_when_clamp_flag_off() -> None: - ratio = 1.15 - est = _run_estimate((ratio, "per_rooms"), clamp_enabled=False) - assert est.median_price_rub > 0 - assert est.expected_sold_price_rub is not None - assert est.expected_sold_price_rub > est.median_price_rub, ( - f"Ожидалось expected_sold > asking при clamp=False, " - f"но {est.expected_sold_price_rub} <= {est.median_price_rub}" - ) - assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) diff --git a/tradein-mvp/backend/tests/test_estimator_hedonic.py b/tradein-mvp/backend/tests/test_estimator_hedonic.py index d21d3302..1424d934 100644 --- a/tradein-mvp/backend/tests/test_estimator_hedonic.py +++ b/tradein-mvp/backend/tests/test_estimator_hedonic.py @@ -6,7 +6,7 @@ via ``_price_from_inputs`` (hermetic — no DB, no network). Verifies: * both clamp boundaries (≤ factor_min via huge area, ≥ factor_max via small+new); * the neutral year term when ``target_year`` is None (≡ year 2000); * the le_asking invariant — the corrected expected_sold never exceeds the asking - headline (median) when ``estimate_expected_sold_le_asking`` is on. + headline (median) — the expected_sold ≤ asking clamp has no switch (#2380). NOTE: importing app.services.estimator pulls app.core.config.Settings which requires DATABASE_URL. Set it BEFORE importing app modules. @@ -151,8 +151,9 @@ def test_factor_clamps_to_min_for_huge_area(monkeypatch: pytest.MonkeyPatch) -> def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) -> None: """Small area + new year → raw factor > factor_max → clamped to the ceiling. - le_asking is held OFF so the raw ceiling factor is observable on the point - (otherwise the re-clamp would cap it at the asking headline). + ratio 0.70 keeps 0.70 × factor_max (1.30) = 0.91 < 1.0, so the always-on + expected_sold ≤ asking re-clamp does not bind and the raw ceiling factor is + observable on the point (#2380: the clamp no longer has an OFF switch). #3248: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про текущую подгонку (после перефита b0 = -0.0140 и потолка сам по себе не @@ -161,11 +162,10 @@ def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr(estimator.settings, "estimate_hedonic_b0", 0.6146) monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603) monkeypatch.setattr(estimator.settings, "estimate_hedonic_year_coef", 0.1220) - monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False) monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) - off = _price(area_m2=15.0, target_year=2025, ratio=0.85) + off = _price(area_m2=15.0, target_year=2025, ratio=0.70) monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) - on = _price(area_m2=15.0, target_year=2025, ratio=0.85) + on = _price(area_m2=15.0, target_year=2025, ratio=0.70) factor = _expected_factor(15.0, 2025) assert factor == estimator.settings.estimate_hedonic_factor_max @@ -187,9 +187,8 @@ def test_target_year_none_is_neutral(monkeypatch: pytest.MonkeyPatch) -> None: def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch) -> None: - """With le_asking on, the hedonic-corrected expected_sold never exceeds asking.""" + """The hedonic-corrected expected_sold never exceeds asking.""" monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) - monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", True) # small area + new year → factor 1.30; ratio 0.95 → 0.95×1.30 ≈ 1.235 > 1 → # uncorrected the point would exceed the asking headline; the re-clamp must bind. res = _price(area_m2=15.0, target_year=2025, ratio=0.95) @@ -202,17 +201,6 @@ def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch assert res.expected_sold_price == res.median_price -def test_le_asking_off_allows_hedonic_above_asking(monkeypatch: pytest.MonkeyPatch) -> None: - """Control: with le_asking OFF, the hedonic uplift may exceed asking (no clamp).""" - monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) - monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False) - res = _price(area_m2=15.0, target_year=2025, ratio=0.95) - - assert res.expected_sold_price is not None - # 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline. - assert res.expected_sold_price > res.median_price - - def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None: """floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3. diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py index aba07b00..52725cbd 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -73,10 +73,7 @@ def _make_payload(): def _run_estimate( analogs: list[dict[str, Any]], dkp_raw: dict[str, Any] | None, - *, - radius_floor_factor: float = 0.8, ) -> Any: - from app.core.config import settings from app.services.estimator import estimate_quality db = MagicMock() @@ -107,7 +104,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_factor", radius_floor_factor), ): return await estimate_quality(payload, db) @@ -132,7 +128,7 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None: "high_ppm2": 220_000, "period_months": 12, } - est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + est = _run_estimate(analogs, dkp_raw) floor_ppm2 = 150_000 * 0.8 # 120_000 assert est.median_price_per_m2 >= floor_ppm2, ( @@ -157,7 +153,7 @@ def test_radius_median_above_dkp_floor_unchanged() -> None: "high_ppm2": 220_000, "period_months": 12, } - est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + est = _run_estimate(analogs, dkp_raw) # floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется # Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor. @@ -179,7 +175,7 @@ def test_no_dkp_raw_no_floor() -> None: _make_listing(price_per_m2=80_000.0), _make_listing(price_per_m2=85_000.0), ] - est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8) + est = _run_estimate(analogs, dkp_raw=None) # median ~80k, без dkp_raw floor не поднимает assert est.median_price_per_m2 < 100_000, ( diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index 3e92bb8a..59df72ef 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -41,7 +41,7 @@ export function dealsAsOfLabel( /** * #3452: подпись под коридором ДКП, собранным из малого числа сделок. * - * Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый + * Порог (`CORRIDOR_CLAMP_MIN_N`) считает бэкенд и отдаёт готовый * advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки — * кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы * выключенную страховку за работающую: на экране такой коридор ничем не diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index be0846ec..e324faae 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -168,7 +168,7 @@ export interface DkpCorridor { // посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется. latest_deal_date?: string | null; // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам, - // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ + // CORRIDOR_CLAMP_MIN_N) → коридор показан, но цену по нему НЕ // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего // коридора не про это — при count >= порога он в цену как раз идёт. // optional: оценки, посчитанные до деплоя бэкенда, поля не содержат. -- 2.45.3 From 31c31f8f82fd70d952d12f7018ebbb842f74f380 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:38:44 +0500 Subject: [PATCH 4/8] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D1=8F=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D1=8C=20=C2=AB=D1=82=D0=BE=D1=82=20=D0=B6=D0=B5=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=C2=BB=20=D0=B8=20IMV-blend=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=B2=D0=B8=D0=B6=D0=BA=D0=B0,=20=D0=BD=D0=B5=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20(#2381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пять булевых флагов кластера сняты ещё в #2475. Оставшиеся 14 числовых полей Settings перенесены в estimator.py константами с теми же значениями: IMV_BLEND_WEIGHT 0.5, IMV_BLEND_THRESHOLD 1.15, SB_MIN_COMPS 4, SB_AREA_SIGMA 0.18, SB_ROOMS_MATCH_BOOST 1.6, SB_FLOOR_SIGMA 0.25, SB_GUARDRAIL_TOL 0.05, SB_MAD_K 3.5, SB_MAD_K_SMALL_N 2.5, SB_SMALL_N_THRESHOLD 10, ANCHOR_TIER_C_CORRIDOR_MULT 1.5, FSD_K 1.65, SB_GATE_MIN_N 3, SB_GATE_MAX_FSD 0.20. На проде 17.09 все 14 равны дефолтам, ENV-оверрайдов нет. Мёртвая проверка `tier_c_mult > 0` (константа против нуля) убрана. Тесты подменяют SB_MIN_COMPS на модуле вместо поля settings. Реплей бэктеста по сделкам побитово тот же, срабатываний якоря 985, IMV-blend 3, low-conf гейта 11 — как на main. Шесть порогов (rooms_boost, floor_sigma, guardrail_tol, tier_c_mult, fsd_k, gate_max_fsd) не ловит ни один поведенческий тест и ни гейт, их держит только тест боевых значений. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 56 ------------ tradein-mvp/backend/app/schemas/trade_in.py | 2 +- tradein-mvp/backend/app/services/estimator.py | 85 ++++++++++++------- .../tests/test_1970_estimator_constants.py | 15 ++++ .../test_estimator_anchor_dedup_gap_2265.py | 10 +-- .../tests/test_estimator_audit_fixes.py | 11 +-- ...imator_deals_headline_fallback_oblast_d.py | 4 +- .../tests/test_estimator_price_spine.py | 4 +- .../tests/test_same_building_anchor.py | 8 +- .../backend/tests/test_tier_a_primary_1774.py | 12 +-- .../frontend/src/app/mera-public/content.ts | 2 +- 11 files changed, 95 insertions(+), 114 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 763c75fd..53841390 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -384,17 +384,6 @@ class Settings(BaseSettings): dadata_api_token: str | None = None dadata_api_secret: str | None = None - # ── #651: IMV / Yandex blend (killer accuracy fix) ────────────────────── - # Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры - # (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs - # факт ~100М). Если внешний якорь (Avito IMV recommended_price из - # house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в - # `threshold` раз — подмешиваем якорь к медиане с весом `weight` и - # расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение). - # При отсутствии IMV/Yandex no-op (медиана не меняется). - estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w - estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15 - # #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на # данные ЭТОГО адреса, а не на среднее по городу. # Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного @@ -414,25 +403,6 @@ class Settings(BaseSettings): # False ⇒ прежнее поведение целиком, без релиза. estimate_require_local_evidence: bool = True - # ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ────────── - # Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, - # комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), - # similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита - # дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95) - # и tighter FSD-диапазон. - # Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2. - estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов - estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian) - estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target - # #680-WB within-building heterogeneity refine: floor-similarity Gaussian по - # ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к - # комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше - # не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение). - # Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери - # покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%. - estimate_sb_floor_sigma: float = 0.25 - estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol) - estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD # ── #1966: honest calibrated prediction-interval для expected_sold range ───── # Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio): # ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й @@ -527,33 +497,7 @@ class Settings(BaseSettings): estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7% estimate_hedonic_factor_min: float = 0.75 estimate_hedonic_factor_max: float = 1.30 - # ── #1795: premium headline anti-inflation (шаги 1/4/5 — константы estimator.py) ── - # Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor: - # при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k - # (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх). - # mad_k_small >= estimate_sb_mad_k → no-op (старое поведение). - estimate_sb_mad_k_small_n: float = 2.5 - estimate_sb_small_n_threshold: int = 10 - # Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с - # anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану. - # Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение). - estimate_anchor_tier_c_corridor_mult: float = 1.5 - # #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же - # доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где - # "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free). - # Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус / - # ratio — не затрагиваются. asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) - estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона - - # ── #audit-1: anchor low-confidence gate ───────────────────────────────── - # Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd) - # НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что - # здоровые якоря (n≥4 с FSD<0.15) проходят без изменений. - # estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает - # estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь - estimate_sb_gate_min_n: int = 3 - estimate_sb_gate_max_fsd: float = 0.20 # ── #audit-3: price_trend freshness filter ──────────────────────────────── # Исключать items старше N месяцев из price_trend (house_placement_history). diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index fd9913e0..2d94c982 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel): Источник: `house_imv_evaluations` (per house_id, обновляется регулярно). Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а - (см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на + (см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на ценовой шкале. None если для дома нет свежей IMV-записи. """ diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d826ca12..e7c57415 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -479,6 +479,44 @@ OUTLIER_TUKEY_K_SMALL = 1.0 # Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0 # для trade-in физически невозможен, это артефакт high-price бакета. +# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает +# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV +# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем +# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT +# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем. +IMV_BLEND_WEIGHT = 0.5 +IMV_BLEND_THRESHOLD = 1.15 + +# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная +# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт −15-25%. v2 +# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по +# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded +# по ppm²), hard guardrail и tighter FSD-диапазон. +# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2. +SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов +SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian) +SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target +# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции +# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену +# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия +# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%). +SB_FLOOR_SIGMA = 0.25 +SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol) +SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD +# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip +# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх. +SB_MAD_K_SMALL_N = 2.5 +SB_SMALL_N_THRESHOLD = 10 +# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с +# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану. +ANCHOR_TIER_C_CORRIDOR_MULT = 1.5 +FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря +# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD +# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4, +# FSD<0.15) проходят без изменений. +SB_GATE_MIN_N = 3 +SB_GATE_MAX_FSD = 0.20 + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -2655,7 +2693,7 @@ def _fetch_anchor_comps( ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None), floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB). """ - min_comps = settings.estimate_sb_min_comps + min_comps = SB_MIN_COMPS # ── Tier A: same building ──────────────────────────────────────────────── street, base_no, letter = _normalize_building_key(address) @@ -3038,12 +3076,8 @@ def _compute_same_building_anchor( # легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь # < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip. effective_mad_k = mad_k - if ( - tier != "A" - and settings.estimate_sb_mad_k_small_n < mad_k - and len(raw_ppm2) < settings.estimate_sb_small_n_threshold - ): - effective_mad_k = settings.estimate_sb_mad_k_small_n + if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD: + effective_mad_k = SB_MAD_K_SMALL_N surviving_idx = _mad_clip(raw_ppm2, effective_mad_k) if len(surviving_idx) < min_comps: logger.info( @@ -3654,32 +3688,28 @@ def _price_from_inputs( area_target=area_m2, rooms_target=rooms, tier=anchor_tier or "", - sigma=settings.estimate_sb_area_sigma, - rooms_boost=settings.estimate_sb_rooms_match_boost, + sigma=SB_AREA_SIGMA, + rooms_boost=SB_ROOMS_MATCH_BOOST, floor_target=floor, total_floors_target=total_floors, - floor_sigma=settings.estimate_sb_floor_sigma, - min_comps=settings.estimate_sb_min_comps, - mad_k=settings.estimate_sb_mad_k, + floor_sigma=SB_FLOOR_SIGMA, + min_comps=SB_MIN_COMPS, + mad_k=SB_MAD_K, ) # #1795 шаг 3: гейт Tier C. - if ( - anchor is not None - and anchor_tier == "C" - and settings.estimate_anchor_tier_c_corridor_mult > 0 - ): + if anchor is not None and anchor_tier == "C": if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0: corridor_high_for_gate = float(dkp_raw["high_ppm2"]) else: corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0 - gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult + gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold: logger.info( "sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d" " → keep radius median (anchor suppressed)", int(anchor["anchor_ppm2"]), - settings.estimate_anchor_tier_c_corridor_mult, + ANCHOR_TIER_C_CORRIDOR_MULT, int(gate_threshold), ) anchor = None @@ -3687,10 +3717,7 @@ def _price_from_inputs( # #audit-1: low-confidence gate. if anchor is not None: gate_low = anchor["confidence"] == "low" - gate_thin = ( - anchor["n"] < settings.estimate_sb_gate_min_n - and anchor["fsd"] > settings.estimate_sb_gate_max_fsd - ) + gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD if gate_low or gate_thin: logger.info( "sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s" @@ -3730,13 +3757,13 @@ def _price_from_inputs( anchor_comps_used = anchor.get("comps") or anchor_comps est_ppm2 = anchor["anchor_ppm2"] # PREMIUM GUARDRAIL (hard). - floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol) + floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL) if est_ppm2 < floor_ppm2: est_ppm2 = floor_ppm2 new_ppm2 = est_ppm2 * repair_coef point = int(new_ppm2 * area_m2) # FSD-диапазон. - half = settings.estimate_fsd_k * anchor["fsd"] + half = FSD_K * anchor["fsd"] new_range_low = int(point * max(0.0, 1.0 - half)) new_range_high = int(point * (1.0 + half)) # Спред комплов. @@ -3893,8 +3920,8 @@ def _price_from_inputs( area=area_m2, anchor_total=anchor_total, anchor_higher=anchor_higher, - weight=settings.estimate_imv_blend_weight, - threshold=settings.estimate_imv_blend_threshold, + weight=IMV_BLEND_WEIGHT, + threshold=IMV_BLEND_THRESHOLD, market_count=( avito_imv_summary.market_count if avito_imv_summary is not None else None ), @@ -3906,7 +3933,7 @@ def _price_from_inputs( median_price, new_median, anchor_used, - settings.estimate_imv_blend_weight, + IMV_BLEND_WEIGHT, range_high, new_range_high, ) @@ -4277,7 +4304,7 @@ def _price_from_inputs( # #oblast-E: guards on `anchor is None` (the actual computed anchor dict), # NOT `anchor_tier is None`. Found via live backtest-fixture regen: when # `_compute_same_building_anchor` rejects a candidate outright (e.g. its - # own MAD-clip drops comps below estimate_sb_min_comps), it returns None + # own MAD-clip drops comps below SB_MIN_COMPS), it returns None # WITHOUT the caller resetting `anchor_tier` back to None (it stays # whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired, # but the stale tier flag falsely reads as "anchor claimed the headline" diff --git a/tradein-mvp/backend/tests/test_1970_estimator_constants.py b/tradein-mvp/backend/tests/test_1970_estimator_constants.py index 4be8989f..a2421aac 100644 --- a/tradein-mvp/backend/tests/test_1970_estimator_constants.py +++ b/tradein-mvp/backend/tests/test_1970_estimator_constants.py @@ -37,6 +37,21 @@ _PROD_VALUES: dict[str, tuple[str, float]] = { "RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8), "OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15), "OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0), + # #2381, #651/#652 same-building anchor + IMV-blend + "IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5), + "IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15), + "SB_MIN_COMPS": ("estimate_sb_min_comps", 4), + "SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18), + "SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6), + "SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25), + "SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05), + "SB_MAD_K": ("estimate_sb_mad_k", 3.5), + "SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5), + "SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10), + "ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5), + "FSD_K": ("estimate_fsd_k", 1.65), + "SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3), + "SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20), } # Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены. diff --git a/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py index 1a7386f1..9987f3e9 100644 --- a/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py +++ b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py @@ -101,7 +101,7 @@ _DUP_LOT_DOMKLIK = _comp( # 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту # (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают -# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает. +# SB_MIN_COMPS=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает. _OTHER_COMPS = [ _comp( source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5 @@ -132,10 +132,10 @@ def test_anchor_path_dedups_cross_source_duplicate_lot(monkeypatch: pytest.Monke После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_ anchor → est.analogs содержит физлот ровно ОДИН раз (n=1). """ - from app.core.config import settings + from app.services import estimator with monkeypatch.context() as m: - m.setattr(settings, "estimate_sb_min_comps", 1) + m.setattr(estimator, "SB_MIN_COMPS", 1) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя @@ -151,10 +151,10 @@ def test_anchor_path_n_analogs_matches_shown_count(monkeypatch: pytest.MonkeyPat (постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2 при одном реальном физлоте (дубль посчитан дважды) — самосогласованно, но нечестно. После фикса n_analogs=1, синхронно с analogs.""" - from app.core.config import settings + from app.services import estimator with monkeypatch.context() as m: - m.setattr(settings, "estimate_sb_min_comps", 1) + m.setattr(estimator, "SB_MIN_COMPS", 1) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) assert est.n_analogs == len(est.analogs) == 1 diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py index f6cb2430..83833adc 100644 --- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -101,19 +101,16 @@ def test_fix1_thin_n_high_fsd_triggers_gate() -> None: ) if anchor is None: pytest.skip("MAD-clip отсёк — нет якоря, тест не применим") - from app.core.config import settings + from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N # Проверяем условие gate_thin напрямую - gate_thin = ( - anchor["n"] < settings.estimate_sb_gate_min_n - and anchor["fsd"] > settings.estimate_sb_gate_max_fsd - ) + gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD # n=2 < 3 — должен сработать если FSD тоже высокий assert anchor["n"] == 2 # FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3; # tier_penalty=0.05 (C); CV = std/mean для 2 элементов # Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20 - if anchor["fsd"] > settings.estimate_sb_gate_max_fsd: + if anchor["fsd"] > SB_GATE_MAX_FSD: assert gate_thin, "gate_thin должен быть True при n=2 и high FSD" @@ -308,8 +305,6 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None: comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)] with patch("app.services.estimator.settings") as mock_settings: - mock_settings.estimate_sb_mad_k_small_n = 2.5 - mock_settings.estimate_sb_small_n_threshold = 10 mock_settings.avito_imv_thin_market_threshold = 10 mock_settings.sber_index_max_age_days = 35 diff --git a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py index 2b8f2a3c..3d7ef194 100644 --- a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py +++ b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py @@ -313,7 +313,7 @@ _EKB_LENINA_LISTING = { def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]: - """N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for + """N distinct EKB "ул. Ленина" comps (>= SB_MIN_COMPS=4 needed for Tier A to actually FIRE — see _fetch_anchor_comps `len(comps) >= min_comps`). Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265, street+floor+area+price physical key) treats them as distinct units, not @@ -407,7 +407,7 @@ def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None: "period_months": 12, } db = MagicMock() - # >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active + # >= SB_MIN_COMPS EKB comps — realistic (40 191 of ~40 200 active # listings are EKB) and necessary for Tier A to actually fire pre-fix. db.execute.side_effect = _fake_anchor_sql_execute( _ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]), diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py index 162fa2cc..b0aa665c 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_spine.py +++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py @@ -252,10 +252,10 @@ def test_anchor_tier_reset_when_anchor_not_built() -> None: Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше: когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше - ``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` — + ``SB_MIN_COMPS``=4), флаг раньше оставался равным ``anchor_tier_fetched`` — дальше по коду он читается как «headline построил якорь». """ - comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4 + comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < SB_MIN_COMPS=4 radius_median_price = int(100_000 * 50.0) pr = _call( listings=_lots(100_000, n=5), diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 2fadaeca..f2d8eba8 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -393,7 +393,7 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [ ] # Same-building комплы Хохрякова 48 (флагман 684k внутри). -# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755). +# 4 комплов — удовлетворяет SB_MIN_COMPS=4 (#755). _SB_COMPS_PREMIUM: list[dict[str, Any]] = [ {"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3}, {"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3}, @@ -527,7 +527,7 @@ def test_estimate_no_anchor_no_radius_stays_insufficient() -> None: def test_estimate_economy_no_regression() -> None: """(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов. - 4 компла — satisfies estimate_sb_min_comps=4 (#755). + 4 компла — satisfies SB_MIN_COMPS=4 (#755). Комплы специально подобраны так, что MAD-clip не удаляет ни один из них (все отклонения << 3.5×MAD при tight spread).""" eco_comps = [ @@ -596,7 +596,7 @@ def test_estimate_tier_d_fallback_keeps_radius() -> None: def test_estimate_range_covers_same_building_comp_spread() -> None: """range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не вылетает за диапазон — зеркало 8 Марта 204Г view-кейса). - 4 компла — satisfies estimate_sb_min_comps=4 (#755).""" + 4 компла — satisfies SB_MIN_COMPS=4 (#755).""" # comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой. view_comps = [ {"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2}, @@ -663,7 +663,7 @@ def test_estimate_analogs_stay_radius_when_no_anchor() -> None: def test_estimate_analogs_pass_through_display_fields() -> None: """#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают их в AnalogLot напрямую (без вычисления price_rub из ppm²×area). - 4 компла — satisfies estimate_sb_min_comps=4 (#755).""" + 4 компла — satisfies SB_MIN_COMPS=4 (#755).""" comps_with_display = [ { "price_per_m2": 399_478, diff --git a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py index 373ab2b0..abc94f4e 100644 --- a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py +++ b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py @@ -121,7 +121,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): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. @@ -146,7 +146,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None: _row(source="cian", source_id="c5", listing_segment="novostroyki"), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db) # primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip. assert tier is None @@ -168,7 +168,7 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None: _row(source="cian", source_id="c4", listing_segment="novostroyki"), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db) # Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D). assert tier is None @@ -193,7 +193,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None: _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. @@ -229,7 +229,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None: ), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # source_id-primary схлопывает несмотря на разные url → 5 comps. @@ -248,7 +248,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None: _row(source="avito", source_url=None, source_id="a4", area_m2=80.0), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 586e0116..b68988df 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -370,7 +370,7 @@ export interface SourceGroup { * ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок * — не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV * / Yandex blend», Tier D — когда якоря по дому/≤500 м нет) медиана - * переписывается на `new_median` с весом `estimate_imv_blend_weight`, + * переписывается на `new_median` с весом `IMV_BLEND_WEIGHT`, * объяснение дополняется «Оценка скорректирована по…», а `sources_used` * пополняется `avito_imv`. Умолчать об этом — значит утверждать на публичной * странице то, чего код не делает. -- 2.45.3 From 2cfc2eda08edd7cb3538939983540fbb0912fd0e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:56:48 +0500 Subject: [PATCH 5/8] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=D0=BE=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D1=87=D0=B5=D0=BD=D1=8B=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20=D0=B1=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=20=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D1=82=D0=B0=20(#1970)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три правки в комментариях tradein-mvp/frontend (имена estimate_corridor_clamp_min_n и estimate_imv_blend_weight → константы) кода не меняли, но фильтр deploy-tradein по tradein-mvp/frontend/** пересобирал и пересоздавал tradein-frontend ради комментариев. Комментарии переименуются вместе с ближайшей правкой фронта (в #3554 фронт и так пересобирается — переименование приложено к нему патчем). Co-Authored-By: Claude Opus 5 --- tradein-mvp/frontend/src/app/mera-public/content.ts | 2 +- tradein-mvp/frontend/src/lib/rosreestr.ts | 2 +- tradein-mvp/frontend/src/types/trade-in.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index b68988df..586e0116 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -370,7 +370,7 @@ export interface SourceGroup { * ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок * — не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV * / Yandex blend», Tier D — когда якоря по дому/≤500 м нет) медиана - * переписывается на `new_median` с весом `IMV_BLEND_WEIGHT`, + * переписывается на `new_median` с весом `estimate_imv_blend_weight`, * объяснение дополняется «Оценка скорректирована по…», а `sources_used` * пополняется `avito_imv`. Умолчать об этом — значит утверждать на публичной * странице то, чего код не делает. diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index 59df72ef..3e92bb8a 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -41,7 +41,7 @@ export function dealsAsOfLabel( /** * #3452: подпись под коридором ДКП, собранным из малого числа сделок. * - * Порог (`CORRIDOR_CLAMP_MIN_N`) считает бэкенд и отдаёт готовый + * Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый * advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки — * кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы * выключенную страховку за работающую: на экране такой коридор ничем не diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index e324faae..be0846ec 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -168,7 +168,7 @@ export interface DkpCorridor { // посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется. latest_deal_date?: string | null; // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам, - // CORRIDOR_CLAMP_MIN_N) → коридор показан, но цену по нему НЕ + // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего // коридора не про это — при count >= порога он в цену как раз идёт. // optional: оценки, посчитанные до деплоя бэкенда, поля не содержат. -- 2.45.3 From bc7fe9b0b91d1cc03e64fd6300f36dcab0199b58 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:57:04 +0500 Subject: [PATCH 6/8] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D0=B0=20=D0=BB=D0=BE=D0=B2=D1=8F=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=B8=20=D0=BD=D0=B0=20=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20(#2379)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3556: тест значений держит число в константе, но не то, что код её читает. Подмена min_n_deals=5 на обоих местах вызова проходила весь сьют. Хуже: Guard-2 и bimodal-гард снимались целиком (`if False:`) при зелёном test_estimator_quarter_index — оба теста были пустыми. Guard-2: заглушка отдавала аналогам индекс цели, factor=1.0. Bimodal: аналоги без кадастра, поправку гасил Guard-5 (#2583) независимо от гарда. - оба теста получили аналоги с известным уровнем 1.0 — без гарда цена сдвигается; - границы: доля ровно 0.6 поправку даёт, индекс ровно 2.0 даёт, 2.01 — нет; - заглушки lookup'ов пишут min_n_deals, тест требует 10 на обоих вызовах. Co-Authored-By: Claude Opus 5 --- .../tests/test_estimator_quarter_index.py | 81 ++++++++++++++++--- 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index 13b89e2d..5cb7c1bd 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -389,6 +389,7 @@ def _run_estimate_qi( anchor_tier_override: str | None = None, analog_indexes: dict[str, float] | None = None, imv_anchor: dict[str, Any] | None = None, + seen_min_n: list[int] | None = None, ): """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate. @@ -396,6 +397,9 @@ def _run_estimate_qi( когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0. Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога. + + seen_min_n — сюда пишется min_n_deals каждого вызова обоих lookup'ов: порог + фильтрует строки в SQL, и заглушка его иначе теряет. """ from app.services.estimator import estimate_quality @@ -406,7 +410,14 @@ def _run_estimate_qi( # Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс, # что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict. + def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals): + if seen_min_n is not None: + seen_min_n.append(min_n_deals) + return qi_lookup_result + def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals): + if seen_min_n is not None: + seen_min_n.append(min_n_deals) if analog_indexes is not None: return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes} if qi_lookup_result is None: @@ -451,7 +462,7 @@ def _run_estimate_qi( # Stub singular target-quarter lookup patch( "app.services.estimator._lookup_quarter_index", - return_value=qi_lookup_result, + side_effect=_fake_lookup_index, ), # Stub batched analog-quarter lookup patch( @@ -532,11 +543,51 @@ 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 + # #2379: уровень аналогов 1.0 — без гарда было бы ×1.5. Без явной карты стаб + # отдаёт аналогам индекс цели, factor=1.0, и тест зеленел при снятом гарде. + analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0}, ) # Медиана НЕ должна изменяться assert est.median_price_rub == base_median +def test_guard2_ratio_exactly_0_6_still_corrects() -> None: + """#2379: граница Guard-2 строгая — 3 из 5 аналогов (0.6) в квартале цели не skip.""" + analogs = [ + _make_listing_qi( + price_per_m2=_BASE_PPM2, + building_cadastral_number=f"{_TARGET_QUARTER if i < 3 else _OTHER_QUARTER}:100", + floor=1 + i, + ) + for i in range(5) + ] + est = _run_estimate_qi( + analogs=analogs, + dadata_cadnum=f"{_TARGET_QUARTER}:350", + qi_lookup_result=(1.5, 30), + analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0}, + ) + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5) + + +def test_quarter_lookups_filter_by_ten_deals() -> None: + """#2379: оба lookup'а (квартал цели и кварталы аналогов) просят индекс от 10 сделок. + + Порог уходит параметром в SQL (`n_deals >= :min_n`), в заглушке его не видно: + подмена числа на месте вызова проходила весь сьют. + """ + seen: list[int] = [] + est = _run_estimate_qi( + analogs=_ANALOGS_OTHER_QUARTER, + dadata_cadnum=f"{_TARGET_QUARTER}:350", + qi_lookup_result=(1.2, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, + seen_min_n=seen, + ) + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.2) # оба lookup'а вызваны + assert seen == [10, 10] + + # ───────────────────────────────────────────────────────────────────────────── # Sparse fallback: нет строки → no-op # ───────────────────────────────────────────────────────────────────────────── @@ -659,19 +710,27 @@ def test_c3_one_indexed_analog_is_enough_to_correct() -> None: # ───────────────────────────────────────────────────────────────────────────── -def test_bimodal_guard_skips_high_index_small_n() -> None: - """Bimodal guard: price_index=3.5, n_deals=20 → no-op.""" - analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) - for i in range(5) - ] - base_median = round(_BASE_PPM2 * _AREA) +@pytest.mark.parametrize( + ("target_qi", "expected_factor"), + [ + (3.5, 1.0), # index > 2.0 AND n < 50 → bimodal guard, no-op + (2.01, 1.0), + (2.0, 1.8), # ровно 2.0 — не больше порога: поправка есть, factor 2.0 → кламп 1.8 + ], +) +def test_bimodal_guard_skips_high_index_small_n(target_qi: float, expected_factor: float) -> None: + """Bimodal guard: price_index > 2.0 при n_deals=20 → no-op. + + #2379: аналоги с ИЗВЕСТНЫМ уровнем 1.0. Раньше здесь были аналоги без кадастра — + поправку на них гасит и Guard-5 (#2583), и тест зеленел при снятом bimodal-гарде. + """ est = _run_estimate_qi( - analogs=analogs_no_cadnum, + analogs=_ANALOGS_OTHER_QUARTER, dadata_cadnum=f"{_TARGET_QUARTER}:350", - qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard + qi_lookup_result=(target_qi, 20), + analog_indexes={_OTHER_QUARTER: 1.0}, ) - assert est.median_price_rub == base_median + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * expected_factor) def test_bimodal_guard_allows_high_index_large_n() -> None: -- 2.45.3 From 228c5f58f9c28c2889798c96cfd6fb2c2e9f92ee Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:57:05 +0500 Subject: [PATCH 7/8] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=20=D1=8F=D0=BA=D0=BE=D1=80=D1=8F=20=C2=AB=D1=82?= =?UTF-8?q?=D0=BE=D1=82=20=D0=B6=D0=B5=20=D0=B4=D0=BE=D0=BC=C2=BB=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D0=B0=20=D0=BD=D0=B0=20=D0=BC=D0=B5=D1=81=D1=82=D0=B5=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D0=B0=20(#2381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3556: подмена rooms_boost=1.0 на месте вызова _compute_same_building_anchor проходила весь сьют — тест значений держит только число в константе. Новый тест оборачивает расчёт якоря и требует боевые sigma/rooms_boost/floor_sigma/min_comps/ mad_k (прод 17.09.2026). SB_GUARDRAIL_TOL, FSD_K, SB_GATE_MAX_FSD и сдвиг ANCHOR_TIER_C_CORRIDOR_MULT вниз по-прежнему без поведенческих тестов. Co-Authored-By: Claude Opus 5 --- .../tests/test_same_building_anchor.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index f2d8eba8..268adbbd 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -501,6 +501,29 @@ def test_estimate_premium_lift_real_in_range() -> None: assert est.range_high_rub >= int(real * 0.9) +def test_estimate_anchor_gets_prod_weights() -> None: + """#2381: веса якоря доходят до расчёта боевыми (прод 17.09.2026). + + Тест значений констант держит только число в estimator.py. Подмена + rooms_boost/floor_sigma на месте вызова (например, 1.0) проходила весь сьют: + веса сдвигают headline внутри тех же допусков, что проверяют тесты выше. + """ + with patch( + "app.services.estimator._compute_same_building_anchor", + wraps=_compute_same_building_anchor, + ) as spy: + est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A") + assert est.median_price_per_m2 >= 450_000 # якорь сработал, а не радиус + kw = spy.call_args.kwargs + assert {k: kw[k] for k in ("sigma", "rooms_boost", "floor_sigma", "min_comps", "mad_k")} == { + "sigma": 0.18, + "rooms_boost": 1.6, + "floor_sigma": 0.25, + "min_comps": 4, + "mad_k": 3.5, + } + + def test_estimate_anchor_fires_without_radius_analogs() -> None: """#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но same-building комплы есть → якорь обязан сработать и дать median>0. -- 2.45.3 From 91a075bc81b07d2106135c956ab3cf09ee359778 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:57:07 +0500 Subject: [PATCH 8/8] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D1=81=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D0=BA=20=D1=84=D0=B8=D0=BA=D1=81=D1=82=D1=83?= =?UTF-8?q?=D1=80=D1=8B=20=D0=B1=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=20=D0=B7=D0=BD=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=B8=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=B8=D0=B6=D0=BA=D0=B0=20(#1970)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3556: settings_at_capture в _write_fixture писал только поля estimate_* из Settings. После переноса 31 порога в константы estimator.py будущая фикстура не знала бы, с какими порогами её захватили. Теперь в снимок попадают и числовые константы модуля (63 штуки, CORRIDOR_CLAMP_MIN_N в том числе). replay_fixture снимок по-прежнему не читает. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/scripts/backtest_estimator.py | 9 ++++++++- .../tests/test_backtest_fixture_roundtrip.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index f77eb0c8..1a4b1645 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -2633,7 +2633,9 @@ def _write_fixture( ) -> None: """Freeze captured per-deal replay records into a committed JSON fixture. - ``settings_at_capture`` records every ``estimate_*`` Settings field as an + ``settings_at_capture`` records every ``estimate_*`` Settings field plus the + estimator's numeric module constants (#1970: калибровочные пороги переехали из + Settings в код — без них снимок не говорит, с какими порогами захватили) as an informational snapshot (NOT consulted by ``replay_fixture``). A final recursive ``_sanitize_json`` pass guarantees the whole document is finite + JSON-plain before ``json.dump(allow_nan=False)``. @@ -2643,6 +2645,11 @@ def _write_fixture( for name in sorted(type(est.settings).model_fields) if name.startswith("estimate_") } + settings_at_capture.update( + (name, _sanitize_json(value)) + for name, value in sorted(vars(est.m).items()) + if name.isupper() and isinstance(value, int | float) + ) fixture = { "schema_version": FIXTURE_SCHEMA_VERSION, "engine": "full", diff --git a/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py b/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py index 4e7ee9e4..b648afba 100644 --- a/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py +++ b/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py @@ -297,3 +297,17 @@ def test_load_fixture_plain_and_gzip_roundtrip() -> None: m_plain = bt.replay_fixture(loaded_plain) m_gz = bt.replay_fixture(loaded_gz) assert json.dumps(m_plain, sort_keys=True) == json.dumps(m_gz, sort_keys=True) + + +def test_write_fixture_records_engine_constants(tmp_path: Path) -> None: + """#1970: пороги движка — константы estimator.py, снимок фикстуры обязан их нести.""" + est = bt._import_estimator_full() + out = tmp_path / "fixture.json" + bt._write_fixture(str(out), capture=[], since="2026-09-01", est=est) + snap = json.loads(out.read_text(encoding="utf-8"))["settings_at_capture"] + assert { + k: snap.get(k) + for k in ("CORRIDOR_CLAMP_MIN_N", "CORRIDOR_CLAMP_SLACK", "QUARTER_INDEX_MIN_N_DEALS") + } == {"CORRIDOR_CLAMP_MIN_N": 10, "CORRIDOR_CLAMP_SLACK": 0.4, "QUARTER_INDEX_MIN_N_DEALS": 10} + assert snap.get("SB_ROOMS_MATCH_BOOST") == 1.6 + assert "estimate_pi_low_mult" in snap # поля Settings по-прежнему в снимке -- 2.45.3