Флаг 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 <noreply@anthropic.com>
This commit is contained in:
parent
0ff2703032
commit
883c741a63
10 changed files with 92 additions and 119 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()), (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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, (
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function dealsAsOfLabel(
|
|||
/**
|
||||
* #3452: подпись под коридором ДКП, собранным из малого числа сделок.
|
||||
*
|
||||
* Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый
|
||||
* Порог (`CORRIDOR_CLAMP_MIN_N`) считает бэкенд и отдаёт готовый
|
||||
* advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки —
|
||||
* кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы
|
||||
* выключенную страховку за работающую: на экране такой коридор ничем не
|
||||
|
|
|
|||
|
|
@ -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: оценки, посчитанные до деплоя бэкенда, поля не содержат.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue