МЕРА: пороги квартального индекса — константы движка, не настройки (#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:
parent
45bd47fdd7
commit
0ff2703032
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).
|
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
||||||
avito_imv_use_browser_fetcher: bool = False
|
avito_imv_use_browser_fetcher: bool = False
|
||||||
|
|
||||||
# ── #764: per-cadastral-quarter price index correction ───────────────────
|
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
|
||||||
# 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
|
|
||||||
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
||||||
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
||||||
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,24 @@ MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопр
|
||||||
# отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
# отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
||||||
MANUAL_REVIEW_ELITE_PPM2 = 250_000
|
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`). В сырых сделках встречаются
|
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
||||||
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
||||||
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
||||||
|
|
@ -3936,15 +3954,15 @@ def _price_from_inputs(
|
||||||
|
|
||||||
# Bimodal/nominal guard (Guard-4).
|
# Bimodal/nominal guard (Guard-4).
|
||||||
if (
|
if (
|
||||||
target_qi > settings.estimate_quarter_index_max_for_small_n
|
target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N
|
||||||
and target_n_deals < settings.estimate_quarter_index_small_n_threshold
|
and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
"quarter_index: bimodal guard triggered "
|
"quarter_index: bimodal guard triggered "
|
||||||
"(index=%.3f n=%d < %d) for %s — no-op",
|
"(index=%.3f n=%d < %d) for %s — no-op",
|
||||||
target_qi,
|
target_qi,
|
||||||
target_n_deals,
|
target_n_deals,
|
||||||
settings.estimate_quarter_index_small_n_threshold,
|
QUARTER_INDEX_SMALL_N_THRESHOLD,
|
||||||
target_quarter,
|
target_quarter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -3969,11 +3987,11 @@ def _price_from_inputs(
|
||||||
if lot_quarters_for_guard2
|
if lot_quarters_for_guard2
|
||||||
else 0.0
|
else 0.0
|
||||||
)
|
)
|
||||||
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
|
if same_quarter_ratio > QUARTER_MATCH_SKIP_RATIO:
|
||||||
logger.info(
|
logger.info(
|
||||||
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
|
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
|
||||||
same_quarter_ratio,
|
same_quarter_ratio,
|
||||||
settings.estimate_quarter_match_skip_ratio,
|
QUARTER_MATCH_SKIP_RATIO,
|
||||||
target_quarter,
|
target_quarter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -4026,8 +4044,8 @@ def _price_from_inputs(
|
||||||
base_range_high=range_high,
|
base_range_high=range_high,
|
||||||
target_index=target_qi,
|
target_index=target_qi,
|
||||||
avg_analog_index=avg_analog_index,
|
avg_analog_index=avg_analog_index,
|
||||||
min_factor=settings.estimate_quarter_index_factor_min,
|
min_factor=QUARTER_INDEX_FACTOR_MIN,
|
||||||
max_factor=settings.estimate_quarter_index_factor_max,
|
max_factor=QUARTER_INDEX_FACTOR_MAX,
|
||||||
)
|
)
|
||||||
analogs_with_qi = sum(
|
analogs_with_qi = sum(
|
||||||
1 for lq, _lp in analog_quarters if lq in analog_index_map
|
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(
|
return _lookup_quarter_index(
|
||||||
db,
|
db,
|
||||||
quarter_cad_number=q,
|
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]:
|
def _qis_lookup(qs: list[str]) -> dict[str, float]:
|
||||||
return _lookup_quarter_indexes(
|
return _lookup_quarter_indexes(
|
||||||
db,
|
db,
|
||||||
quarter_cad_numbers=qs,
|
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 ──────────────────────────────────
|
# ── Deterministic pricing orchestration ──────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -2099,7 +2099,6 @@ def _predict_full_spine(
|
||||||
whether the house IMV anchor actually resolved a row for this deal.
|
whether the house IMV anchor actually resolved a row for this deal.
|
||||||
"""
|
"""
|
||||||
m = est.m
|
m = est.m
|
||||||
settings = est.settings
|
|
||||||
|
|
||||||
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
|
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
|
||||||
db, deal, est, target_house_id=target_house_id
|
db, deal, est, target_house_id=target_house_id
|
||||||
|
|
@ -2188,7 +2187,7 @@ def _predict_full_spine(
|
||||||
res = m._lookup_quarter_index(
|
res = m._lookup_quarter_index(
|
||||||
db,
|
db,
|
||||||
quarter_cad_number=q,
|
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:
|
if capture is not None:
|
||||||
ret = _sanitize_json(list(res) if res is not None else 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(
|
res = m._lookup_quarter_indexes(
|
||||||
db,
|
db,
|
||||||
quarter_cad_numbers=qs,
|
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:
|
if capture is not None:
|
||||||
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
|
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