From 883c741a63309d3dc5c5f7ffb4c7be0fdc81b63a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:33:56 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=BC=D0=BF=20expected=5Fsold=20=E2=89=A4=20asking=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F,=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=B8=20#1?= =?UTF-8?q?795=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=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: оценки, посчитанные до деплоя бэкенда, поля не содержат.