МЕРА: настройки оценщика, которые никогда не менялись, стали кодом — 4 кластера флагов #1970 #3556
4 changed files with 75 additions and 32 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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))])
|
||||
|
|
|
|||
45
tradein-mvp/backend/tests/test_1970_estimator_constants.py
Normal file
45
tradein-mvp/backend/tests/test_1970_estimator_constants.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue