МЕРА: пороги квартального индекса — константы движка, не настройки (#2379)

Единственный переключатель 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 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 12:31:12 +05:00
parent 45bd47fdd7
commit 0ff2703032
4 changed files with 75 additions and 32 deletions

View file

@ -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).

View file

@ -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 ──────────────────────────────────

View file

@ -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))])

View file

@ -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