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